sync: implement reprojectcmd for the local half

Add `repo.reprojectcmd`, the checkout counterpart to `repo.fetchcmd`.
When set, `repo sync` runs this command instead of Git to materialize a
project's index and worktree at the target tree, while `repo` handles
ref updates directly via `git update-ref`.

This replaces Git's tree materialization steps: detaching HEAD,
fast-forwarding, and hard-resetting. Rebasing is not delegated, and the
command is skipped if HEAD is already at the target, if HEAD is ahead of
the target during fast-forward, or for MetaProjects.

The command runs in a subshell with project environment variables (such
as `REPO_TREV`). Before running, `repo` ensures no operation is in
progress and no staged changes exist. Worktree collision detection is
delegated to the command (preserving benign unstaged/untracked edits).
Afterward, `repo` verifies that HEAD was untouched and that the index
matches the target tree.

Like `repo.fetchcmd`, this requires `repo.uselocalgitdirs`. Nested
projects and submodules are unsupported; `repo sync` fails if the
manifest contains any while `repo.reprojectcmd` is enabled.

Verified end-to-end with repo init using local-gitdirs, repo.fetchcmd,
and repo.reprojectcmd ('git -C $REPO_PATH read-tree -m -u $REPO_TREV'):
  * Verified detached HEAD checkout and correct reflog generation across
    projects.
  * Verified benign unstaged edits and untracked files survive checkout.
  * Verified conflicting untracked files fail with exit 128 without
    clobbering worktree.
  * Verified staged changes fail upfront before reprojectcmd is executed.

Bug: 513329573
Change-Id: I964d24d22dccffc05a9b991ad69f3a7e93268c01
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/626281
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
This commit is contained in:
Gavin Mak authored and gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com committed 2026-09-15 12:53:01 -07:00
1 parent c2c330ba4a
commit a468ea7752
6 files changed
+1335 -32

No files matched your search

+47
View File
@@ -72,6 +72,7 @@ from git_refs import HEAD
from git_refs import R_HEADS
import git_superproject
from hooks import RepoHook
from manifest_xml import XmlManifest
import platform_utils
from progress import elapsed_str
from progress import jobs_str
@@ -156,6 +157,11 @@ def _SafeCheckoutOrder(checkouts: List[Project]) -> List[List[Project]]:
return res
def _NestedProjects(projects: List[Project]) -> List[Project]:
"""Return the projects in |projects| living inside another one's path."""
return [p for level in _SafeCheckoutOrder(projects)[1:] for p in level]
def _ParentFirstBatches(projects: List[Project]) -> List[List[Project]]:
"""Group |projects| so that a parent is fetched before its submodules.
@@ -2478,6 +2484,7 @@ later is required to fix a server side protocol bug.
manifest=manifest,
all_manifests=not opt.this_manifest_only,
)
self._CheckReprojectCmdNesting(opt, args, manifest, all_projects)
# Log the repo projects by existing and new.
existing = [x for x in all_projects if x.Exists]
@@ -2541,6 +2548,46 @@ later is required to fix a server side protocol bug.
if not opt.quiet:
print("repo sync has finished successfully.")
def _CheckReprojectCmdNesting(
self,
opt: optparse.Values,
args: List[str],
manifest: XmlManifest,
all_projects: List[Project],
) -> None:
"""Fail when repo.reprojectcmd is used on a manifest nesting projects.
The command materializes a project's tree without Git, so nothing
keeps it from clobbering a project or submodule checked out inside
that tree. See docs/reproject-cmd.md.
"""
if not any(p.UseReprojectCmd for p in all_projects):
return
projects = all_projects
if args:
# Nesting is a property of the manifest, not of the projects
# picked on the command line.
projects = self.GetProjects(
[],
groups=opt.groups,
missing_ok=True,
submodules_ok=opt.recurse_submodules,
manifest=manifest,
all_manifests=not opt.this_manifest_only,
)
nested = _NestedProjects(projects)
if not nested:
return
e = SyncError(
"error: repo.reprojectcmd does not support nested projects or "
"submodules; found:\n"
+ "\n".join(
f" - {p.RelPath(local=opt.this_manifest_only)}" for p in nested
)
)
logger.error(e)
raise e
def _CreateSyncProgressThread(
self, pm: Progress, stop_event: _threading.Event
) -> _threading.Thread: