From 6eaa71d32f6d8ff443d80140afd620f49cac1360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Tue, 21 Nov 2023 22:45:05 +0100 Subject: [PATCH] feat: Conflicts on updates now appear as git merge conflicts, also on VSCode --- copier/main.py | 22 +++++++++++ tests/test_updatediff.py | 85 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/copier/main.py b/copier/main.py index 1486a1b86..19dfda119 100644 --- a/copier/main.py +++ b/copier/main.py @@ -901,6 +901,7 @@ def _apply_update(self): apply_cmd = apply_cmd["--exclude", skip_pattern] (apply_cmd << diff)(retcode=None) if self.conflict == "inline": + conflicted = [] status = git("status", "--porcelain").strip().splitlines() for line in status: # Filter merge rejections (part 1/2) @@ -933,6 +934,27 @@ def _apply_update(self): ) # Remove rejection witness Path(f"{fname}.rej").unlink() + # Store file name for marking it as unmerged after the loop + conflicted.append(fname) + # Forcefully mark files with conflict markers as unmerged, + # see SO post: https://stackoverflow.com/questions/77391627/ + # and Git docs: https://git-scm.com/docs/git-update-index#_using_index_info. + # For each file with conflict markers, we update the index to add + # higher order versions of their paths, without entries for resolved contents. + if conflicted: + input_lines = [] + for line in ( + git("ls-files", "--stage", *conflicted).strip().splitlines() + ): + perms_sha_mode, path = line.split("\t") + perms, sha, _ = perms_sha_mode.split() + input_lines.append(f"0 {'0' * 40}\t{path}") + for mode in (1, 2, 3): + input_lines.append(f"{perms} {sha} {mode}\t{path}") + ( + git["update-index", "--index-info"] + << "\n".join(input_lines) + )() # Trigger recursive removal of deleted files in last template version _remove_old_files(subproject_top, compared) diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py index 4c298758c..8b9582850 100644 --- a/tests/test_updatediff.py +++ b/tests/test_updatediff.py @@ -13,6 +13,7 @@ from copier.cli import CopierApp from copier.errors import UserMessageError from copier.main import Worker, run_copy, run_update +from copier.tools import normalize_git_path from .helpers import ( BRACKET_ENVOPS_JSON, @@ -21,6 +22,7 @@ SUFFIX_TMPL, Spawn, build_file_tree, + git_init, ) @@ -983,3 +985,86 @@ def function_two(): print("Previous line lied.") """ ) + + +@pytest.mark.parametrize( + "filename", + [ + "README.md", + "spa ces", + # Double quotes are not supported in file names on Windows. + "qu`o'tes" if platform.system() == "Windows" else 'qu`o"tes', + "m4â4ñ4a", + ], +) +def test_conflicted_files_are_marked_unmerged( + tmp_path_factory: pytest.TempPathFactory, + filename: str, +) -> None: + # Template in v1 has a file with a single line; + # in v2 it changes that line. + # Meanwhile, downstream project appended contents to the first line. + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + + # First, create the template with an initial file + build_file_tree( + { + (src / filename): "upstream version 1", + (src / "{{_copier_conf.answers_file}}.jinja"): ( + "{{_copier_answers|to_nice_yaml}}" + ), + } + ) + with local.cwd(src): + git_init("hello template") + git("tag", "v1") + + # Generate the project a first time, assert the file exists + run_copy(str(src), dst, defaults=True, overwrite=True) + assert (dst / filename).exists() + assert "_commit: v1" in (dst / ".copier-answers.yml").read_text() + + # Start versioning the generated project + with local.cwd(dst): + git_init("hello project") + + # After first commit, change the file, commit again + Path(filename).write_text("upstream version 1 + downstream") + git("commit", "-am", "updated file") + + # Now change the template + with local.cwd(src): + # Update the file + Path(filename).write_text("upstream version 2") + + # Commit the changes + git("add", ".", "-A") + git("commit", "-m", "change line in file") + git("tag", "v2") + + # Finally, update the generated project + run_update(dst_path=dst, defaults=True, overwrite=True, conflict="inline") + assert "_commit: v2" in (dst / ".copier-answers.yml").read_text() + + # Assert that the file still exists, has inline markers, + # and is reported as "unmerged" by Git. + assert (dst / filename).exists() + + expected_contents = dedent( + """\ + <<<<<<< before updating + upstream version 1 + downstream + ======= + upstream version 2 + >>>>>>> after updating + """ + ) + assert (dst / filename).read_text().splitlines() == expected_contents.splitlines() + assert not (dst / f"{filename}.rej").exists() + + with local.cwd(dst): + lines = git("status", "--porcelain=v1").strip().splitlines() + assert any( + line.startswith("UU") and normalize_git_path(line[3:]) == filename + for line in lines + )