diff --git a/copier/main.py b/copier/main.py index 6525c33f0..dc076fcd0 100644 --- a/copier/main.py +++ b/copier/main.py @@ -11,7 +11,7 @@ from functools import cached_property, partial from itertools import chain from pathlib import Path -from shutil import copytree, ignore_patterns, rmtree +from shutil import rmtree from tempfile import TemporaryDirectory from types import TracebackType from typing import ( @@ -54,6 +54,7 @@ normalize_git_path, printf, readlink, + set_git_alternates, ) from .types import ( MISSING, @@ -923,9 +924,7 @@ def _apply_update(self) -> None: # noqa: C901 prefix=f"{__name__}.old_copy.", ) as old_copy, TemporaryDirectory( prefix=f"{__name__}.new_copy.", - ) as new_copy, TemporaryDirectory( - prefix=f"{__name__}.dst_copy.", - ) as dst_copy: + ) as new_copy: # Copy old template into a temporary destination with replace( self, @@ -941,10 +940,14 @@ def _apply_update(self) -> None: # noqa: C901 self._execute_tasks( self.template.migration_tasks("before", self.subproject.template) # type: ignore[arg-type] ) + # Create a Git tree object from the current (possibly dirty) index + # and keep the object reference. + with local.cwd(subproject_top): + subproject_head = git("write-tree").strip() with local.cwd(old_copy): self._git_initialize_repo() - git("remote", "add", "real_dst", "file://" + str(subproject_top)) - git("fetch", "--depth=1", "real_dst", "HEAD") + # Configure borrowing Git objects from the real destination. + set_git_alternates(subproject_top) # Save a list of files that were intentionally removed in the generated # project to avoid recreating them during the update. # Files listed in `skip_if_exists` should only be skipped if they exist. @@ -954,7 +957,8 @@ def _apply_update(self) -> None: # noqa: C901 "-r", "--diff-filter=D", "--name-only", - "HEAD...FETCH_HEAD", + "HEAD", + subproject_head, ).splitlines() exclude_plus_removed = list( set(self.exclude).union( @@ -971,19 +975,6 @@ def _apply_update(self) -> None: # noqa: C901 ) ) ) - # Create a copy of the real destination after applying migrations - # but before performing any further update for extracting the diff - # between the temporary destination of the old template and the - # real destination later. - with local.cwd(dst_copy): - copytree( - subproject_top, - ".", - symlinks=True, - ignore=ignore_patterns("/.git"), - dirs_exist_ok=True, - ) - self._git_initialize_repo() # Clear last answers cache to load possible answers migration, if skip_answered flag is not set if self.skip_answered is False: self.answers = AnswersMap() @@ -1015,14 +1006,14 @@ def _apply_update(self) -> None: # noqa: C901 new_worker.run_copy() with local.cwd(new_copy): self._git_initialize_repo() - # Extract diff between temporary destination and (copy from above) - # real destination with some special handling of newly added files - # in both the poject and the template. + new_copy_head = git("rev-parse", "HEAD").strip() + # Extract diff between temporary destination and real destination + # with some special handling of newly added files in both the poject + # and the template. with local.cwd(old_copy): - git("remote", "add", "dst_copy", "file://" + str(dst_copy)) - git("fetch", "--depth=1", "dst_copy", "HEAD:dst_copy") - git("remote", "add", "new_copy", "file://" + str(new_copy)) - git("fetch", "--depth=1", "new_copy", "HEAD:new_copy") + # Configure borrowing Git objects from the real destination and + # temporary destination of the new template. + set_git_alternates(subproject_top, Path(new_copy)) # Create an empty file in the temporary destination when the # same file was added in *both* the project and the temporary # destination of the new template. With this minor change, the @@ -1034,17 +1025,20 @@ def _apply_update(self) -> None: # noqa: C901 "diff-tree", "-r", "--diff-filter=A", "--name-only" ] for filename in ( - set(diff_added_cmd("HEAD...dst_copy").splitlines()) - ) & set(diff_added_cmd("HEAD...new_copy").splitlines()): + set(diff_added_cmd("HEAD", subproject_head).splitlines()) + ) & set(diff_added_cmd("HEAD", new_copy_head).splitlines()): f = Path(filename) f.parent.mkdir(parents=True, exist_ok=True) - f.touch(Path(dst_copy, filename).stat().st_mode) + f.touch((subproject_top / filename).stat().st_mode) git("add", filename) self._git_commit("add new empty files") # Extract diff between temporary destination and real # destination diff_cmd = git[ - "diff-tree", f"--unified={self.context_lines}", "HEAD...dst_copy" + "diff-tree", + f"--unified={self.context_lines}", + "HEAD", + subproject_head, ] try: diff = diff_cmd("--inter-hunk-context=-1") diff --git a/copier/tools.py b/copier/tools.py index 06439e02a..468894e5e 100644 --- a/copier/tools.py +++ b/copier/tools.py @@ -232,3 +232,38 @@ def escape_git_path(path: str) -> str: lambda match: "".join(f"\\{whitespace}" for whitespace in match.group()), path, ) + + +def get_git_objects_dir(path: Path) -> Path: + """Get the absolute path of a Git repository's objects directory.""" + # FIXME: A lazy import is currently necessary to avoid circular imports with + # `errors.py`. + from .vcs import get_git + + git = get_git() + return Path( + git( + "-C", + path, + "rev-parse", + "--path-format=absolute", + "--git-path", + "objects", + ).strip() + ) + + +def set_git_alternates(*repos: Path, path: Path = Path(".")) -> None: + """Set Git alternates to borrow Git objects from other repositories. + + Alternates are paths of other repositories' object directories written to + `$GIT_DIR/objects/info/alternates` and delimited by the newline character. + + Args: + *repos: The paths of repositories from which to borrow Git objects. + path: The path of the repository where to set Git alternates. Defaults + to the current working directory. + """ + alternates_file = get_git_objects_dir(path) / "info" / "alternates" + alternates_file.parent.mkdir(parents=True, exist_ok=True) + alternates_file.write_bytes(b"\n".join(map(bytes, map(get_git_objects_dir, repos)))) diff --git a/tests/test_updatediff.py b/tests/test_updatediff.py index f8dc3e98f..2e73a41c6 100644 --- a/tests/test_updatediff.py +++ b/tests/test_updatediff.py @@ -1478,3 +1478,43 @@ def test_update_with_new_file_in_template_and_project_via_migration( >>>>>>> after updating """ ) + + +def test_update_with_separate_git_directory( + tmp_path_factory: pytest.TempPathFactory, +) -> None: + src, dst, dst_git_dir = map(tmp_path_factory.mktemp, ("src", "dst", "dst_git_dir")) + + with local.cwd(src): + build_file_tree( + { + "version.txt": "v1", + "{{ _copier_conf.answers_file }}.jinja": "{{ _copier_answers|to_nice_yaml }}", + } + ) + git("init") + git("add", ".") + git("commit", "-m1") + git("tag", "v1") + + run_copy(str(src), dst, overwrite=True) + assert "_commit: v1" in (dst / ".copier-answers.yml").read_text() + + with local.cwd(dst): + git("init", "--separate-git-dir", dst_git_dir) + # Add a file to make sure the subproject's tree object is different from + # that of the fresh copy from the old template version; otherwise, we + # cannot test the linking of local (temporary) repositories for + # borrowing Git objects. + build_file_tree({"foo.txt": "bar"}) + git("add", ".") + git("commit", "-m1") + + with local.cwd(src): + build_file_tree({"version.txt": "v2"}) + git("add", ".") + git("commit", "-m2") + git("tag", "v2") + + run_update(dst, overwrite=True) + assert "_commit: v2" in (dst / ".copier-answers.yml").read_text()