Skip to content

Commit 38fa2c9

Browse files
committed
Isolate git operations with a new repository
Shallow fetch operations permanently change the shallow configuration of the repository they run in. Using a temporary worktree does not protect the user's repository against such modifications. Switch to using a new separate repository to provide appropriate protection. Set up the alternates mechanism to have the new repository share storage with the original. This cannot be done automatically by git-clone since git-clone refuses to use a shallow repository as an alternate object store (for good reason!). Manually create the new repository and provide it with a copy of the original's shallow configuration to make this use of alternates correct.
1 parent 73afb8d commit 38fa2c9

File tree

3 files changed

+79
-86
lines changed

3 files changed

+79
-86
lines changed

README.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,11 @@ First, change to your local nixpkgs repository directory, i.e.:
6060
cd ~/git/nixpkgs
6161
```
6262

63-
If you've shallow cloned nixpkgs (`git clone --depth`), `nixpkgs-review` may be
64-
unable to perform merges due to missing merge base commits. Reclone nixpkgs
65-
without the `--depth` flag.
66-
67-
Note that your local checkout git will not be affected by `nixpkgs-review`,
68-
since it will use [git-worktree](https://git-scm.com/docs/git-worktree) to
69-
perform fast checkouts.
63+
Note that `nixpkgs-review` will not leave any effects in your local Git
64+
repository (if it does, it's a bug!). It does its work in a temporary repository
65+
separate from your own. It uses Git's
66+
["alternates"](https://git-scm.com/docs/gitrepository-layout) mechanism to
67+
minimize transfers.
7068

7169
Then run `nixpkgs-review` by providing the pull request number…
7270

@@ -154,6 +152,18 @@ nix-shell> nixpkgs-review post-result
154152
nix-shell> nixpkgs-review comments
155153
```
156154

155+
## Caveats for shallow clones
156+
157+
If you've shallow cloned nixpkgs (`git clone --depth`), `nixpkgs-review` may be
158+
unable to perform merges due to missing merge base commits. The simplest fix for
159+
this is, as you might imagine, recloning without the `--depth` flag. Even in
160+
cases where your shallow cuts do not interfere with merging, it is possible that
161+
the latest upstream commits provide a new path "around" your shallow boundary,
162+
in which case `git fetch` will (very, very slowly) more-or-less unshallow your
163+
repository. This is not a problem in `nixpkgs-review`, but a limitation of Git.
164+
It is possible to get around this by using a more intelligent fetching program
165+
to update your repository before running `nixpkgs-review`.
166+
157167
## Optional tools
158168

159169
`nixpkgs-review` can integrate with several optional tools to enhance the user

nixpkgs_review/builddir.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from tempfile import TemporaryDirectory
77
from typing import TYPE_CHECKING, Self
88

9-
from . import git
109
from .overlay import Overlay
1110
from .utils import warn
1211

@@ -72,10 +71,10 @@ def __init__(self, name: str) -> None:
7271

7372
self.overlay = Overlay()
7473

75-
self.worktree_dir = self.path / "nixpkgs"
76-
self.worktree_dir.mkdir()
74+
self.clone_dir = self.path / "nixpkgs"
75+
self.clone_dir.mkdir()
7776
self._nix_path_parts = [
78-
f"nixpkgs={self.worktree_dir}",
77+
f"nixpkgs={self.clone_dir}",
7978
f"nixpkgs-overlays={self.overlay.path}",
8079
]
8180
self.nix_path = " ".join(self._nix_path_parts)
@@ -94,14 +93,6 @@ def __exit__(
9493
os.environ.clear()
9594
os.environ.update(self.environ)
9695

97-
with DisableKeyboardInterrupt():
98-
if (self.worktree_dir / ".git").exists():
99-
res = git.run(["worktree", "remove", "-f", str(self.worktree_dir)])
100-
if res.returncode != 0:
101-
warn(
102-
f"Failed to remove worktree at {self.worktree_dir}. Please remove it manually. Git failed with: {res.returncode}"
103-
)
104-
10596
self.overlay.cleanup()
10697

10798
# Clean up the temporary directory if we created one

nixpkgs_review/review.py

Lines changed: 59 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from .github import GithubClient, GitHubPullRequest
2424
from .nix import Attr, nix_build, nix_eval, nix_shell
2525
from .report import Report
26-
from .utils import System, current_system, die, info, sh, system_order_key, warn
26+
from .utils import System, current_system, die, info, system_order_key, warn
2727

2828
if TYPE_CHECKING:
2929
import argparse
@@ -232,8 +232,8 @@ def _process_aliases_for_systems(self, system: str) -> set[str]:
232232
case _:
233233
return {system}
234234

235-
def worktree_dir(self) -> str:
236-
return str(self.builddir.worktree_dir)
235+
def clone_dir(self) -> str:
236+
return str(self.builddir.clone_dir)
237237

238238
def _render_markdown(self, content: str, max_length: int = 1000) -> None:
239239
"""Render markdown content using glow if available, otherwise plain text."""
@@ -329,17 +329,15 @@ def _display_pr_info(self, pr: GitHubPullRequest, pr_number: int) -> None:
329329
print(f"{'=' * 80}\n")
330330

331331
def git_merge(self, commit: str) -> None:
332-
res = git.run(
333-
["merge", "--no-commit", "--no-ff", commit], cwd=self.worktree_dir()
334-
)
332+
res = git.run(["merge", "--no-commit", "--no-ff", commit], cwd=self.clone_dir())
335333
if res.returncode != 0:
336-
msg = f"Failed to merge {commit} into {self.worktree_dir()}. git merge failed with exit code {res.returncode}"
334+
msg = f"Failed to merge {commit} into {self.clone_dir()}. git merge failed with exit code {res.returncode}"
337335
raise NixpkgsReviewError(msg)
338336

339337
def git_checkout(self, commit: str) -> None:
340-
res = git.run(["checkout", commit], cwd=self.worktree_dir())
338+
res = git.run(["checkout", commit], cwd=self.clone_dir())
341339
if res.returncode != 0:
342-
msg = f"Failed to checkout {commit} in {self.worktree_dir()}. git checkout failed with exit code {res.returncode}"
340+
msg = f"Failed to checkout {commit} in {self.clone_dir()}. git checkout failed with exit code {res.returncode}"
343341
raise NixpkgsReviewError(msg)
344342

345343
def apply_uncommitted(self, changes: UncommittedChanges) -> None:
@@ -362,10 +360,10 @@ def apply_uncommitted(self, changes: UncommittedChanges) -> None:
362360
sys.exit(0)
363361

364362
info("Applying `nixpkgs` diff...")
365-
result = git.run(["apply"], cwd=self.worktree_dir(), stdin=diff)
363+
result = git.run(["apply"], cwd=self.clone_dir(), stdin=diff)
366364

367365
if result.returncode != 0:
368-
die(f"Failed to apply diff in {self.worktree_dir()}")
366+
die(f"Failed to apply diff in {self.clone_dir()}")
369367

370368
def build_changes(
371369
self,
@@ -380,8 +378,11 @@ def build_changes(
380378
`changes` can be a string naming a commit, or it can be one of the
381379
`UncommittedChanges` variants. `merge_commit`, if given, is used
382380
instead of repeating the merge of `changes` into `base_commit`.
381+
382+
The sandbox repository is expected to be set up and able to see both
383+
commits.
383384
"""
384-
self.git_worktree(base_commit)
385+
self.git_checkout(base_commit)
385386
changed_attrs: dict[System, set[str]] = {}
386387

387388
if self.only_packages:
@@ -448,10 +449,42 @@ def build_changes(
448449

449450
return self.build(changed_attrs, self.build_args)
450451

451-
def git_worktree(self, commit: str) -> None:
452-
res = git.run(["worktree", "add", self.worktree_dir(), commit])
452+
def git_clone(
453+
self, repo: str, ref: str, *, shallow_depth: int | None = None
454+
) -> None:
455+
# moral equivalent of git clone --revision=<new_head> --reference=. [--depth=...] <repo> <dir>
456+
# but it will work even with a shallow repository as reference
457+
# we need the isolation of a separate repository because --depth "irreversibly" mutates .git/shallow
458+
res = git.run(["init"], self.clone_dir())
459+
if res.returncode != 0:
460+
msg = f"Failed to create sandbox repository for {ref} in {self.clone_dir()}. git init failed with exit code {res.returncode}"
461+
raise NixpkgsReviewError(msg)
462+
463+
# really, we should be locking this file until the clone is disposed of
464+
shallow_original = resolve_git_dir() / "shallow"
465+
if shallow_original.exists():
466+
shallow_target = Path(self.clone_dir()) / ".git" / "shallow"
467+
shutil.copyfile(shallow_original, shallow_target)
468+
469+
alternates_path = (
470+
Path(self.clone_dir()) / ".git" / "objects" / "info" / "alternates"
471+
)
472+
object_store_path = resolve_git_dir().absolute() / "objects"
473+
with alternates_path.open("a") as alternates:
474+
alternates.write(f"{object_store_path}\n")
475+
476+
fetch_args = ["fetch", repo, f"+{ref}:refs/heads/nixpkgs-review"]
477+
if shallow_depth:
478+
fetch_args.append(f"--depth={shallow_depth}")
479+
# we should warn if shallow_depth is not set and the original repository is shallow (and there are commits to fetch)
480+
# in this case git fetch (really, upload-pack) acts stupidly and has a very high chance of both unshallowing the repository
481+
# AND taking forever to compute how to do it
482+
# (but it WILL work and we WILL have enough history to do any merges we need later, which is better than if we set shallow_depth incorrectly)
483+
# if the user wants something more efficient, they can kill us and do it themselves
484+
485+
res = git.run(fetch_args, self.clone_dir())
453486
if res.returncode != 0:
454-
msg = f"Failed to add worktree for {commit} in {self.worktree_dir()}. git worktree failed with exit code {res.returncode}"
487+
msg = f"Failed to fetch {ref} into sandbox repository {self.clone_dir()}. git fetch failed with exit code {res.returncode}"
455488
raise NixpkgsReviewError(msg)
456489

457490
def build(
@@ -526,24 +559,26 @@ def build_pr(self, pr_number: int) -> dict[System, list[Attr]]:
526559
packages_per_system = (
527560
self._fetch_packages_from_github_eval(pr) if self._use_github_eval else None
528561
)
529-
530-
[merge_rev] = fetch_refs(self.remote, pr["merge_commit_sha"], shallow_depth=2)
531-
base_rev = git.verify_commit_hash(f"{merge_rev}^1")
532-
head_rev = git.verify_commit_hash(f"{merge_rev}^2")
533-
534562
if self.only_packages:
535563
packages_per_system = {
536564
system: set(self.only_packages) for system in self.systems
537565
}
538566

567+
merge_rev = pr["merge_commit_sha"]
568+
base_rev = pr["base"]["sha"]
569+
head_rev = pr["head"]["sha"]
570+
539571
if packages_per_system is None:
572+
self.git_clone(self.remote, merge_rev, shallow_depth=2)
540573
return self.build_changes(base_rev, head_rev, merge_rev)
541574

542575
match self.checkout:
543576
case CheckoutOption.MERGE:
544-
self.git_worktree(merge_rev)
577+
checkout_rev = merge_rev
545578
case CheckoutOption.COMMIT:
546-
self.git_worktree(head_rev)
579+
checkout_rev = head_rev
580+
self.git_clone(self.remote, checkout_rev, shallow_depth=1)
581+
self.git_checkout(checkout_rev)
547582

548583
for system in list(packages_per_system.keys()):
549584
if system not in self.systems:
@@ -622,10 +657,10 @@ def review_local(
622657
print_result: bool = False,
623658
approve_pr: bool = False,
624659
) -> None:
625-
branch_rev = fetch_refs(self.remote, branch)[0]
660+
self.git_clone(self.remote, branch)
626661
self.start_review(
627662
changes if isinstance(changes, str) else None,
628-
self.build_changes(branch_rev, changes),
663+
self.build_changes("nixpkgs-review", changes),
629664
path,
630665
print_result=print_result,
631666
approve_pr=approve_pr,
@@ -904,49 +939,6 @@ def resolve_git_dir() -> Path:
904939
raise NixpkgsReviewError(msg)
905940

906941

907-
def fetch_refs(repo: str, *refs: str, shallow_depth: int = 1) -> list[str]:
908-
shallow = subprocess.run(
909-
["git", "rev-parse", "--is-shallow-repository"],
910-
text=True,
911-
stdout=subprocess.PIPE,
912-
check=False,
913-
)
914-
if shallow.returncode != 0:
915-
msg = f"Failed to detect if {repo} is shallow repository"
916-
raise NixpkgsReviewError(msg)
917-
918-
fetch_cmd = [
919-
"git",
920-
"-c",
921-
"fetch.prune=false",
922-
"fetch",
923-
"--no-tags",
924-
"--force",
925-
repo,
926-
]
927-
if shallow.stdout.strip() == "true":
928-
fetch_cmd.append(f"--depth={shallow_depth}")
929-
for i, ref in enumerate(refs):
930-
fetch_cmd.append(f"{ref}:refs/nixpkgs-review/{i}")
931-
dotgit = resolve_git_dir()
932-
with locked_open(dotgit / "nixpkgs-review", "w"):
933-
res = sh(fetch_cmd)
934-
if res.returncode != 0:
935-
msg = f"Failed to fetch {refs} from {repo}. git fetch failed with exit code {res.returncode}"
936-
raise NixpkgsReviewError(msg)
937-
shas = []
938-
for i, ref in enumerate(refs):
939-
rev_parse_cmd = ["git", "rev-parse", "--verify", f"refs/nixpkgs-review/{i}"]
940-
out = subprocess.run(
941-
rev_parse_cmd, text=True, stdout=subprocess.PIPE, check=False
942-
)
943-
if out.returncode != 0:
944-
msg = f"Failed to fetch {ref} from {repo} with command: {''.join(rev_parse_cmd)}"
945-
raise NixpkgsReviewError(msg)
946-
shas.append(out.stdout.strip())
947-
return shas
948-
949-
950942
def differences(
951943
old: list[Package], new: list[Package]
952944
) -> tuple[list[Package], list[Package]]:

0 commit comments

Comments
 (0)