Compare commits

..
20 Commits
Author SHA1 Message Date
Gavin Mak 8c7e0a683e info: reuse branch snapshots and combine history walks
Derive the current branch from GetBranches so attached HEAD and refs are
resolved once, including overview reporting. Replace the two
complementary rev-list calls in --all output with one --left-right
symmetric-difference walk and partition its records in memory.

Bug: 553599402
Change-Id: I01d5db804cbf591ecc0e08d6605971f0f5a38538
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/634005
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
2026-09-21 13:40:06 -07:00
Gavin Mak f6f5946422 project: avoid unnecessary sync revision enumeration
Skip the detached-history walk when its count will not be printed, ask
rev-list --count for upstream gain, and cap the published-but-unmerged
existence probe at one commit. These preserve the decisions while
reducing traversal and output work.

Bug: 553599402
Change-Id: I30f769c9aa5269428ece15ffcb45c50993d7c4b8
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/634004
Commit-Queue: Gavin Mak <gavinmak@google.com>
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
2026-09-21 13:40:01 -07:00
Gavin Mak 6321b26685 project: make fast-forward merges explicit
Every caller expects _FastForward to reject a merge commit. Always pass
--ff-only and remove the optional parameter so a violated ancestry
assumption fails instead of silently creating a merge.

Bug: 553599402
Change-Id: I04690b5ca675cb7d3062df08d5e20cd82f5b9e82
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/634003
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
2026-09-21 13:39:56 -07:00
Gavin Mak 7bffc72b5b project: fold normal stash detection into status
When Git 2.35 or newer supplies a stash header, use the same status
snapshot for stateless dirty and stash checks. Retain rev-parse for
earlier clients or when the status snapshot is unavailable.

Bug: 543851900
Bug: 553599402
Change-Id: I6e310459d9c1a3ba7978f7907ae6b7a9bc611749
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/632146
Tested-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
2026-09-21 13:39:51 -07:00
Gavin Mak aaadd5da35 sync: reuse status HEAD in the bloat scan
The shallow-project bloat check already needs dirty state. Request
branch metadata in that same porcelain-v2 snapshot and use branch.oid
instead of launching a separate rev-parse, with the existing calls
retained as a fallback.

Bug: 543851900
Bug: 553599402
Change-Id: I3e7068c2e14c025a53adf92bfe8483b876cfda9d
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/632145
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
2026-09-21 13:39:21 -07:00
Gavin Mak c9448f986e project: render worktree status from one snapshot
Use porcelain-v2 branch and path records to replace the status display
fan-out with one Git process on Git 2.11 and newer. Preserve the legacy
renderer for older clients, force rename and ahead/behind behavior where
supported, and fall back to the old divergence query only when status
cannot supply branch.ab.

Bug: 543851900
Bug: 553599402
Change-Id: Ic760360f4f48ab9320abf69dbbb7310dab4ac649
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/632144
Commit-Queue: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Gavin Mak <gavinmak@google.com>
2026-09-21 13:39:16 -07:00
Gavin Mak 530258d08e project: use one status snapshot for dirty checks
On Git 2.11 and newer, replace update-index, diff-index, diff-files, and
ls-files fan-out with one porcelain-v2 snapshot for IsDirty,
UncommittedFiles, and HasChanges. Preserve category ordering and
duplicate staged-plus-unstaged paths, and keep the exact legacy path for
older Git or a failed snapshot.

Bug: 543851900
Bug: 553599402
Change-Id: I309e39977e1f1d4ee6115f488d6aabb83ec3d83c
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/632143
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
2026-09-21 13:37:38 -07:00
Gavin Mak c1566487a8 git_status: parse one porcelain v2 worktree snapshot
Add a strict, NUL-aware parser for porcelain v2 records, including
ordinary changes, renames, conflicts, untracked paths, branch
divergence, and stash metadata. The reader is gated on Git 2.11, opts
into newer flags only when supported, and preserves arbitrary pathname
bytes.

Bug: 543851900
Bug: 553599402
Change-Id: Ibae3e1056fd9866f3cb6d490745d377cda1e8fef
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/632142
Tested-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
2026-09-21 13:36:55 -07:00
Gavin Mak 22f820de17 git_command: allow lossless capture of machine output
NUL-delimited Git formats may contain path bytes that are not valid
UTF-8. Add an opt-in byte capture mode so parsers can preserve those
bytes instead of receiving backslash replacements, while continuing to
decode stderr for diagnostics.

Bug: 543851900
Bug: 553599402
Change-Id: Ia9dfcde065a0f6f72c785a4b96896f346e17510a
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/632141
Tested-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
2026-09-21 13:36:47 -07:00
Sam Saccone 4c37f58806 project: pass --no-filter and --no-tags in DownloadPatchSet
In partial clone checkouts (e.g. --filter=blob:none or blob:limit=10k),
DownloadPatchSet previously invoked `git fetch <remote> <ref>` without
explicit filter overrides. Consequently, git fetch applied the
configured partial clone filter from the remote and omitted the blobs
for the incoming change.

When repo download subsequently checks out, cherry-picks, or merges the
downloaded commit, Git discovers that the required blobs are missing
locally and triggers on-demand unadvertised object fetches to the
promisor remote (POST /git-upload-pack with `want <blob_sha>`). On large
repositories, server-side reachability validation for unadvertised
objects can result in expensive graph traversals, proxy/gateway timeouts
(such as HTTP 502 or connection drops), and client fetch failures:
  fatal: expected 'packfile'
  fatal: could not fetch <sha> from promisor remote

Pass --no-filter (when Git >= 2.17.0) and --no-tags when fetching patch
sets in DownloadPatchSet. Because the change ref is an advertised ref
tip, fetching with --no-filter allows the server to generate a complete
packfile containing all required blobs upfront without requiring
unadvertised object reachability checks. All blobs are present in the
local object database prior to checkout or cherry-pick, completely
preventing on-demand promisor queries.

TAG=agy
CONV=bdc489e3-b20b-4ef3-b32e-549dbe21fc6c

Google-Bug-Id: b/532920848
Test: pytest tests/test_project.py
Change-Id: Ic6118e4edeb754f0467e0580fd1d0965a6553153
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/625621
Reviewed-by: Gavin Mak <gavinmak@google.com>
Tested-by: Sam Saccone <samccone@google.com>
Commit-Queue: Sam Saccone <samccone@google.com>
2026-09-21 09:19:39 -07:00
Gavin Mak c86ddc1628 project: rename UncommitedFiles to UncommittedFiles
Fix typo in UncommitedFiles across project.py and HasChanges.

Bug: 553599402
Change-Id: I2b9b606b4c9a47fd998e50cca976545fcd783771
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/632881
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
2026-09-17 17:21:32 -07:00
Victor Pushkarev c638b54e19 command: preserve project filters in regex selection
`FindProjects` gets its candidate projects from `GetProjects` and then
applies regex matching. However, it only forwards `all_manifests`, so
other project filters are lost on regex paths.

As a result, `repo forall -r/-i --groups` falls back to the configured
group selection instead of using the requested groups.
Similarly, `repo list -r --groups` ignores the requested groups, and
`repo list -r --all` still excludes projects without a checkout.

Pass `groups` and `missing_ok` through `FindProjects`, and forward the
existing options from `forall` and `list`. When no group override is
given, keep using each manifest's effective groups.

Add regression tests for `FindProjects` and both command paths.

Change-Id: I5dfc22172fdd81de5312a84433867300a1342aec
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/631281
Reviewed-by: Mike Frysinger <vapier@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Victor Pushkarev <corvinus.v@gmail.com>
Tested-by: Victor Pushkarev <corvinus.v@gmail.com>
2026-09-16 04:54:02 -07:00
Victor Pushkarev fb4a91060e tests: avoid losing trace2 socket readiness signal
The trace2 socket test uses a Condition to wait for its server to start
listening. If the server calls notify() before the test starts waiting,
the notification is lost and the test waits for the full 120-second
timeout before continuing.

Use an Event for the readiness signal instead.
Once the server starts listening, the event stays set, so the test can
continue immediately even if the server becomes ready before the test
starts waiting.

Change-Id: I0e8e192d997c8bd785bcefa5d5b91493f93863e8
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/630861
Tested-by: Victor Pushkarev <corvinus.v@gmail.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Victor Pushkarev <corvinus.v@gmail.com>
2026-09-16 04:53:31 -07:00
Victor Pushkarev 4b3ada1781 sync: keep generated jobs-checkout help host-independent
The default shown for `--jobs-checkout` comes from `DEFAULT_LOCAL_JOBS`,
which depends on the host CPU count. As a result, regenerating the sync
and smartsync manpages on a different machine can change their output
even though repo's behavior has not changed.

Use shared CPU-based help formatting for `--jobs` and `--jobs-checkout`.
Interactive help still shows the actual default, while generated
manpages describe it without including a host-specific value.
Runtime job selection is unchanged.

Add coverage for both help modes and regenerate the affected manpages.

Change-Id: Ia60e6fcc17b6f4618c52a76217c128eddead8848
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/625381
Reviewed-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Victor Pushkarev <corvinus.v@gmail.com>
Tested-by: Victor Pushkarev <corvinus.v@gmail.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
2026-09-16 04:51:54 -07:00
Gavin Mak a468ea7752 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>
2026-09-15 12:53:01 -07:00
Brian Gan c2c330ba4a project: report why refreshing the index failed
"repo status" aborts the whole tree when one project fails, without
saying why. The three callers that refresh the index stat cache pass -q,
which suppresses git's diagnosis when it cannot create the index lock;
git then exits 128 with both streams empty. A stale lock, a permissions
problem on the gitdir and a full disk all reach that same silent exit,
yet each needs a different remedy.

Route the three identical call sites through one helper that keeps -q
for the refresh itself. When that fails, it repeats the command without
-q to obtain git's diagnosis. The command still fails, which is correct,
but the user is now told why.

Keeping -q on the common path matters: without it git exits 1 whenever
paths hold uncommitted changes, which would report a failure to
telemetry for every modified project, even if we treat it as benign in
the code. The retry accepts that 1, since --unmerged makes git skip
conflicted paths rather than count them, so nothing else yields it.

The similar call in subcmds/sync.py is unchanged: it passes different
flags, without --unmerged, so exit 1 cannot be assumed benign there.

Bug: 560289756
Change-Id: I2e1024c84454f86076081f2d7f155728b1aa35c6
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/630641
Tested-by: Brian Gan <brgan@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Brian Gan <brgan@google.com>
2026-09-14 13:27:19 -07:00
Victor Pushkarev cc88be34d2 command: fix group selection across submanifests
When GetProjects walks all manifests without explicit groups, it
resolves the effective groups once from the selected manifest and reuses
them for projects from child manifests. This can ignore a child
manifest's own group selection and include projects that should be
excluded.

When no groups are specified, resolve them from each project's owning
manifest. Explicit groups still apply to all projects in the call, and
single-manifest behavior remains unchanged.

Add regression tests for per-manifest group selection, explicit
overrides, and name lookup across manifests.

Bug: 382086294
Change-Id: Id5c7e52410431b86b395f489cfd494ca05620975
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/624821
Reviewed-by: Mike Frysinger <vapier@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Victor Pushkarev <corvinus.v@gmail.com>
Tested-by: Victor Pushkarev <corvinus.v@gmail.com>
2026-09-11 01:54:27 -07:00
Victor Pushkarev 16cfb53e0a release: update-manpages: restore __main__ guard
The update-manpages wrapper calls update_manpages.main() unconditionally.
With spawn and forkserver, multiprocessing re-executes the directly run
main script as __mp_main__.
The wrapper then calls main() again and tries to create another Pool
while the child is still starting.

Protect the entrypoint with __main__ guard. On Linux, this was mostly
hidden while fork was the default start method; Python 3.14 now defaults
to forkserver there. Normal command-line behavior is unchanged.

Add a regression test that mirrors multiprocessing's __mp_main__
execution path and checks that it does not call update_manpages.main().

Change-Id: I28fdb18ccf2e4d61605cc072b4de52b6eb561335
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/626841
Tested-by: Victor Pushkarev <corvinus.v@gmail.com>
Commit-Queue: Victor Pushkarev <corvinus.v@gmail.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
2026-09-11 01:52:38 -07:00
Fredrik de Groot 578b57c975 manifest_xml: Improve error message on base check fail
Previous error had a scent of alien blob to it
due to dumping the list in a single long line, so
improve print for human readability.

Example of new fail:

error: in `manifest`: detected base-revision mismatch, updates needed:
remove-project name build/make:
  base fccb6b3e884b4b964e795f5f8865f6bbb4722b5a vs revision 6e9a0c16f20d1cfcf08b8b9cb42301b6c9712862
remove-project path build/soong:
  base 16b3c9ca0679bb376966866afb849322c8aec79f vs revision b7490575c16b87d861a532b03bd1b2d076b6fa0b
extend-project name platform/system/sepolicy:
  base c2652856e9f0e0433d78cfb481efe392d6f5dbc4 vs revision cf9a73f92f503ffc4bd3e726164babfb40a9fcaf

Test: repo manifest > /dev/null && echo 'Nice =)'
      on various good and bad test manifests
Change-Id: I8d741e11c69ef9e4f8cf09e32aa0868b6daa5acd
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/619921
Tested-by: Fredrik de Groot <fredrik.de.groot@haleytek.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Fredrik de Groot <fredrik.de.groot@haleytek.com>
2026-09-10 23:17:12 -07:00
Arif Kasim fe2c23f8e2 upload: bypass branch editor with explicit branch and -y
When uploading across multiple projects with --yes and an explicit
branch specified (via --current-branch / -c or --branch / --br),
repo previously still opened the interactive branch selection editor.

If an explicit branch and --yes are provided, bypass the interactive
editor and upload all matching pending branches directly.

Bug: 553024110
Test: python3 -m pytest tests/
Test: flake8 subcmds/upload.py tests/test_subcmds_upload.py
Test: isort --check subcmds/upload.py tests/test_subcmds_upload.py
Change-Id: I104619603f6e0539d05b01d3eb0292a9145d58db
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/622661
Tested-by: Arif Kasim <arifkasim@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
2026-09-09 12:10:24 -07:00
29 changed files with 3665 additions and 184 deletions
+68 -17
View File
@@ -17,7 +17,7 @@ import multiprocessing
import optparse
import os
import re
from typing import TYPE_CHECKING
from typing import List, Optional, TYPE_CHECKING
from error import InvalidProjectGroupsError
from error import NoSuchProjectError
@@ -152,6 +152,20 @@ class Command:
self._Options(self._optparse)
return self._optparse
@staticmethod
def _GetHelpForCpuJobCount(
default_jobs: Optional[int] = None,
) -> str:
"""Return CPU-based job help, with an explicit default when needed.
Specify default_jobs when additional logic computes effective default.
"""
if GENERATE_MANPAGES:
return "based on number of CPU cores"
default = "%default" if default_jobs is None else str(default_jobs)
return f"{default}; based on number of CPU cores"
def _CommonOptions(self, p, opt_v=True):
"""Initialize the option parser with common options.
@@ -176,11 +190,7 @@ class Command:
)
if self.PARALLEL_JOBS is not None:
default = "based on number of CPU cores"
if not GENERATE_MANPAGES:
# Only include active cpu count if we aren't generating man
# pages.
default = f"%default; {default}"
default = self._GetHelpForCpuJobCount()
p.add_option(
"-j",
"--jobs",
@@ -398,7 +408,11 @@ class Command:
Args:
args: a list of (case-insensitive) strings, projects to search for.
manifest: an XmlManifest, the manifest to use, or None for default.
groups: a string, the manifest groups in use.
groups: a string, the manifest group selection to apply.
Non-empty values apply to all candidate projects in this call.
When empty or omitted, single-manifest calls use the selected
manifest's effective groups; all-manifest calls use each
candidate project's owning manifest's effective groups.
missing_ok: a boolean, whether to allow missing projects.
submodules_ok: whether to allow submodules. True allows them for
all projects, False disallows them for all projects, and None
@@ -425,9 +439,33 @@ class Command:
return project.sync_s
return submodules_ok
if not groups:
groups = manifest.GetManifestGroupsStr()
groups = [x for x in re.split(r"[,\s]+", groups) if x]
def parse_groups(value: str) -> List[str]:
return [x for x in re.split(r"[,\s]+", value) if x]
if groups:
groups_for_all_projects = parse_groups(groups)
elif all_manifests:
# In all-manifest mode, each project uses its owning
# manifest's effective groups.
groups_for_all_projects = None
else:
groups_for_all_projects = parse_groups(
manifest.GetManifestGroupsStr()
)
groups_by_manifest = {}
def matches_groups(project: "Project") -> bool:
if groups_for_all_projects is not None:
return project.MatchesGroups(groups_for_all_projects)
project_manifest = project.manifest
if project_manifest not in groups_by_manifest:
groups_by_manifest[project_manifest] = parse_groups(
project_manifest.GetManifestGroupsStr()
)
return project.MatchesGroups(groups_by_manifest[project_manifest])
if not args:
derived_projects = {}
@@ -439,9 +477,7 @@ class Command:
)
all_projects_list.extend(derived_projects.values())
for project in all_projects_list:
if (missing_ok or project.Exists) and project.MatchesGroups(
groups
):
if (missing_ok or project.Exists) and matches_groups(project):
result.append(project)
else:
self._ResetPathToProjectMap(all_projects_list)
@@ -455,7 +491,7 @@ class Command:
for project in manifest.GetProjectsWithName(
arg, all_manifests=all_manifests
)
if project.MatchesGroups(groups)
if matches_groups(project)
]
if not projects:
@@ -498,7 +534,7 @@ class Command:
"%s (%s)"
% (arg, project.RelPath(local=not all_manifests))
)
if not project.MatchesGroups(groups):
if not matches_groups(project):
raise InvalidProjectGroupsError(arg)
result.extend(projects)
@@ -509,7 +545,14 @@ class Command:
result.sort(key=_getpath)
return result
def FindProjects(self, args, inverse=False, all_manifests=False):
def FindProjects(
self,
args: List[str],
inverse: bool = False,
all_manifests: bool = False,
groups: Optional[str] = "",
missing_ok: Optional[bool] = False,
) -> List["Project"]:
"""Find projects from command line arguments.
Args:
@@ -519,10 +562,18 @@ class Command:
all_manifests: a boolean, if True then all manifests and
submanifests are used. If False, then only the local
(sub)manifest is used.
groups: a string specifying manifest groups. If empty or None, use
each manifest's effective groups.
missing_ok: a boolean, whether to allow missing projects.
"""
result = []
patterns = [re.compile(r"%s" % a, re.IGNORECASE) for a in args]
for project in self.GetProjects("", all_manifests=all_manifests):
for project in self.GetProjects(
"",
groups=groups,
missing_ok=missing_ok,
all_manifests=all_manifests,
):
paths = [project.name, project.RelPath(local=not all_manifests)]
for pattern in patterns:
match = any(pattern.search(x) for x in paths)
+3
View File
@@ -6,6 +6,9 @@ executed during `repo sync` to fetch objects, instead of using standard
filesystems or lazy checkouts where fetching metadata and downloading file
contents should be decoupled.
The checkout half of a sync has a counterpart, `repo.reprojectcmd`; see
`docs/reproject-cmd.md`.
## Configuration
To use this feature, set the following in `.repo/manifests.git/config`:
+126
View File
@@ -0,0 +1,126 @@
# Reproject Command Contract
The `repo.reprojectcmd` configuration names a command that `repo sync` runs
instead of Git to move a project's index and worktree to the tree of the
target commit. It is the checkout-side counterpart of `repo.fetchcmd` (see
`docs/fetch-cmd.md`): together they let an external tool take over both the
network fetch and the materialization of a project. This is useful on
virtualized filesystems that address content by hash, where a tree can be
materialized far faster than `git checkout` can write every file.
The command only materializes the tree. `repo` then makes the ref write that
Git would have made, using `git update-ref`.
## Configuration
To use this feature, set the following in `.repo/manifests.git/config`:
```ini
[repo]
reprojectcmd = "your custom command here"
uselocalgitdirs = true
```
Setting `repo.reprojectcmd` **requires** `repo.uselocalgitdirs` to be set to
`true`.
For reference, this command does with Git what `repo` would otherwise do
itself:
```ini
[repo]
reprojectcmd = "git -C $REPO_PATH read-tree -m -u $REPO_TREV"
uselocalgitdirs = true
```
The one-tree merge applies the change to the target, keeps local changes to
every other path, and refuses to overwrite a modified or untracked file, so it
enforces the preconditions below by itself. It also works for a project that
has nothing checked out yet.
## Environment Variables
The command is executed in a subshell, from the root of the client, populated
with standard project-context environment variables. For details on standard
variables (such as `REPO_PROJECT`, `REPO_PATH`, `REPO_REMOTE`, etc.), see the
Environment section in `repo help forall` or `subcmds/forall.py`.
The variables the command typically needs are:
* `REPO_PATH`: The project path relative to the root of the client.
* `REPO_TREV`: The target revision resolved to a full commit hash. Match this
commit's tree.
There is no force mode: a project that would need one never reaches the
command (see the preconditions below).
## When the command runs
`repo sync` already classifies each project and picks a Git operation. The
command replaces the three that are a materialization of a target tree:
1. The checkout that detaches HEAD at the target. This is the common case: a
project on a detached HEAD, a project on a branch that does not track
upstream, and `repo sync -d`.
2. The fast-forward of the checked out branch to the target.
3. The hard reset of the checked out branch to the target, when the commits
it carried were dropped upstream.
After the command exits 0, `repo` writes the ref itself: it detaches `HEAD` at
`REPO_TREV`, or moves the checked out branch to `REPO_TREV`.
The command is **not** run:
* When `HEAD` already names `REPO_TREV`.
* At the fast-forward step when `HEAD` is ahead of `REPO_TREV`, where Git's
merge would be a no-op.
* For a rebase. A branch carrying local commits has them replayed onto the
target by `git rebase`, which is not a materialization of a target tree.
* For `MetaProject`s (i.e. the internal `repo` repository itself at
`.repo/repo` and the `manifests` repository at `.repo/manifests`).
## Contract
### Preconditions
Before invoking the command, `repo` ensures that:
* The index has no staged changes (the index matches `HEAD`, or is empty on an unborn `HEAD`).
* No rebase, cherry-pick, merge, or revert is in progress.
Detecting collisions with untracked files or unstaged working-tree modifications is the responsibility of the reproject command itself (e.g. via `git read-tree -m -u $REPO_TREV` or a custom virtual filesystem checkout tool). If local changes collide with the target tree, the command must abort with a non-zero exit code. Local modifications and untracked files outside the diff between `HEAD` and `REPO_TREV` must be preserved.
### Postconditions on exit 0
After the command exits with status 0, `repo` expects the following
postconditions to be met:
1. `git diff-index --quiet --cached REPO_TREV^{tree}` exits 0 (the index
matches the target tree).
2. `HEAD` still names what it did before the command, and its resolved commit
object ID has not changed.
### Invariants
The command may modify the worktree and the index, and may write project-local
Git config. The command must:
* Apply the change from `HEAD`'s tree to `REPO_TREV`'s tree and leave every
other path alone. Local modifications and untracked files outside that
change must survive: the command applies a diff, it does not reset the
tree.
* Not write any ref, including `HEAD` and `ORIG_HEAD`. `repo` owns every ref
write.
* Not create or replace `.git/`, and not touch anything under `.repo/`.
* Not require the Git remote, to preserve `repo sync --local-only`.
* Be idempotent. Running it twice on the same target is a no-op.
### Failure
* A non-zero exit status, a failed precondition or a failed postcondition
fails that project's sync, and the command's or Git's output is surfaced
to the user.
* Other projects continue, and `repo sync` exits non-zero.
## Limitations
Nested projects are out of scope: a project whose path lies inside another
project's path, a `<project>` nested in another `<project>` in the manifest,
and a submodule discovered with `sync-s` or `--recurse-submodules`. `repo sync`
fails if the manifest has one while `repo.reprojectcmd` is set.
+43 -8
View File
@@ -283,6 +283,7 @@ class GitCommand:
bare=False,
input=None,
capture_stdout=False,
capture_stdout_bytes: bool = False,
capture_stderr=False,
merge_output=False,
disable_editor=False,
@@ -304,6 +305,12 @@ class GitCommand:
self.cmdv = cmdv
self.verify_command = verify_command
self.stdout, self.stderr = None, None
if capture_stdout_bytes:
if merge_output:
raise ValueError(
"capture_stdout_bytes cannot be combined with merge_output"
)
capture_stdout = True
# Git on Windows wants its paths only using / for reliability.
if platform_utils.isWindows():
@@ -347,6 +354,7 @@ class GitCommand:
command,
env,
capture_stdout=capture_stdout,
capture_stdout_bytes=capture_stdout_bytes,
capture_stderr=capture_stderr,
merge_output=merge_output,
ssh_proxy=ssh_proxy,
@@ -380,6 +388,7 @@ class GitCommand:
command,
env,
capture_stdout=False,
capture_stdout_bytes: bool = False,
capture_stderr=False,
merge_output=False,
ssh_proxy=None,
@@ -412,6 +421,10 @@ class GitCommand:
# See go/tee-repo-stderr for more context.
tee_stderr = False
kwargs = {"encoding": "utf-8", "errors": "backslashreplace"}
if capture_stdout_bytes:
kwargs = {}
if isinstance(input, str):
input = input.encode("utf-8", "surrogateescape")
if not (stdin or stdout or stderr):
tee_stderr = True
# stderr will be written back to sys.stderr even though it is
@@ -490,6 +503,10 @@ class GitCommand:
self.stderr = self._Tee(p.stderr, sys.stderr)
else:
self.stdout, self.stderr = p.communicate(input=input)
if capture_stdout_bytes and isinstance(self.stderr, bytes):
self.stderr = self.stderr.decode(
"utf-8", "backslashreplace"
).replace("\r\n", "\n")
finally:
if ssh_proxy:
ssh_proxy.remove_client(p)
@@ -541,17 +558,35 @@ class GitCommand:
env.pop(key, None)
return env
def VerifyCommand(self):
def VerifyCommand(self) -> None:
if self.rc == 0:
return None
stdout = (
"\n".join(self.stdout.split("\n")[:GIT_ERROR_STDOUT_LINES])
if self.stdout
else None
)
raw_stdout = self.stdout
if isinstance(raw_stdout, bytes):
first_records = re.split(
rb"\r\n|[\r\n\0]", raw_stdout, maxsplit=GIT_ERROR_STDOUT_LINES
)[:GIT_ERROR_STDOUT_LINES]
stdout = (
"\n".join(
r.decode("utf-8", "backslashreplace") for r in first_records
)
if raw_stdout
else None
)
elif raw_stdout:
first_records = re.split(
r"\r\n|[\r\n\0]", raw_stdout, maxsplit=GIT_ERROR_STDOUT_LINES
)[:GIT_ERROR_STDOUT_LINES]
stdout = "\n".join(first_records)
else:
stdout = None
raw_stderr = self.stderr
if isinstance(raw_stderr, bytes):
raw_stderr = raw_stderr.decode("utf-8", "backslashreplace")
stderr = (
"\n".join(self.stderr.split("\n")[:GIT_ERROR_STDERR_LINES])
if self.stderr
"\n".join(raw_stderr.split("\n")[:GIT_ERROR_STDERR_LINES])
if raw_stderr
else None
)
project = self.project.name if self.project else None
+259
View File
@@ -0,0 +1,259 @@
# Copyright (C) 2026 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Read a worktree's state from one machine-readable git status snapshot."""
from collections import OrderedDict
import os
from typing import Iterator, List, Optional, TYPE_CHECKING
from git_command import git_require
from git_command import GitCommand
if TYPE_CHECKING:
from project import Project
class StatusEntry:
"""The state of one path on one side of the index."""
def __init__(
self,
path: str,
status: str,
src_path: Optional[str] = None,
level: Optional[str] = None,
) -> None:
self.path = path
self.status = status
self.src_path = src_path
self.level = level
class StatusSnapshot:
"""A consistent view of worktree, index, and branch state."""
def __init__(self) -> None:
self.index_changes = OrderedDict()
self.worktree_changes = OrderedDict()
self.untracked = []
self.branch_oid = None
self.branch_head = None
self.upstream = None
self.ahead = 0
self.behind = 0
self.has_ahead_behind = False
self.stash_count = 0
@property
def current_branch(self) -> Optional[str]:
if self.branch_head in (None, "(detached)", "(unknown)"):
return None
return self.branch_head
def is_dirty(self, consider_untracked: bool = True) -> bool:
return bool(
self.index_changes
or self.worktree_changes
or (consider_untracked and self.untracked)
)
def GetStatus(
project: "Project",
gitdir: str,
untracked_files: str = "all",
branch: bool = False,
ahead_behind: bool = False,
show_stash: bool = False,
) -> StatusSnapshot:
"""Return one machine-readable status snapshot for |project|."""
if not git_require((2, 11, 0)):
raise UnsupportedStatusError("porcelain v2 requires Git 2.11")
cmd = [
"status",
"--porcelain=v2",
"-z",
"--ignore-submodules=all",
f"--untracked-files={untracked_files}",
]
if branch:
cmd.append("--branch")
if git_require((2, 17, 0)):
cmd.append(
"--ahead-behind" if ahead_behind else "--no-ahead-behind"
)
if git_require((2, 18, 0)):
# Match the existing staged diff's explicit rename detection even if
# status.renames is disabled in the user's config.
cmd.append("--renames")
if show_stash and git_require((2, 35, 0)):
cmd.append("--show-stash")
p = GitCommand(
project,
cmd,
bare=False,
gitdir=gitdir,
capture_stdout=True,
capture_stdout_bytes=True,
capture_stderr=True,
verify_command=True,
)
p.Wait()
return ParsePorcelainV2(p.stdout)
def _Path(value: bytes) -> str:
"""Decode a Git pathname without losing undecodable bytes."""
return os.fsdecode(value)
def _Status(value: int) -> str:
"""Normalize Git's unchanged markers for repo's status display."""
char = chr(value)
return "" if char == "." else char
def _Records(output: bytes) -> Iterator[bytes]:
if not output:
return iter(())
if not output.endswith(b"\0"):
raise StatusParseError("porcelain v2 output is not NUL terminated")
records = output.split(b"\0")
if not records[-1]:
records.pop()
return iter(records)
class StatusParseError(ValueError):
"""Raised when machine-readable status output is malformed."""
class UnsupportedStatusError(RuntimeError):
"""Raised when the Git client cannot produce porcelain v2."""
def _Fields(record: bytes, count: int) -> List[bytes]:
fields = record.split(b" ", count - 1)
if len(fields) != count:
raise StatusParseError(f"malformed porcelain v2 record: {record!r}")
return fields
def _AddTracked(
status: StatusSnapshot,
path: str,
xy: bytes,
src_path: Optional[str] = None,
level: Optional[str] = None,
) -> None:
index_status = _Status(xy[0])
worktree_status = _Status(xy[1])
if index_status:
status.index_changes[path] = StatusEntry(
path,
index_status,
src_path=src_path if index_status in ("R", "C") else None,
level=level if index_status in ("R", "C") else None,
)
if worktree_status:
status.worktree_changes[path] = StatusEntry(
path,
worktree_status,
src_path=src_path if worktree_status in ("R", "C") else None,
level=level if worktree_status in ("R", "C") else None,
)
def ParsePorcelainV2(output: bytes) -> StatusSnapshot:
"""Parse ``git status --porcelain=v2 -z --branch`` output."""
status = StatusSnapshot()
records = _Records(output)
for record in records:
kind = record[:1]
if kind == b"#":
try:
key, value = record[2:].split(b" ", 1)
except ValueError as e:
raise StatusParseError(
f"malformed porcelain v2 header: {record!r}"
) from e
if key == b"branch.oid":
value = value.decode("ascii")
status.branch_oid = None if value == "(initial)" else value
elif key == b"branch.head":
status.branch_head = _Path(value)
elif key == b"branch.upstream":
status.upstream = _Path(value)
elif key == b"branch.ab":
try:
value = value.decode("ascii")
ahead, behind = value.split()
if ahead != "+?" and behind != "-?":
status.ahead = int(ahead)
status.behind = -int(behind)
status.has_ahead_behind = True
except ValueError as e:
raise StatusParseError(
f"malformed porcelain v2 branch.ab record: {record!r}"
) from e
elif key == b"stash":
status.stash_count = int(value.decode("ascii"))
continue
if kind == b"1":
fields = _Fields(record, 9)
xy = fields[1]
if len(xy) != 2:
raise StatusParseError(f"invalid status pair: {xy!r}")
path = _Path(fields[8])
_AddTracked(status, path, xy)
elif kind == b"2":
fields = _Fields(record, 10)
xy = fields[1]
if len(xy) != 2:
raise StatusParseError(f"invalid status pair: {xy!r}")
score = fields[8][1:].lstrip(b"0") or b"0"
try:
src_path = _Path(next(records))
except StopIteration as e:
raise StatusParseError(
"rename record has no source path"
) from e
path = _Path(fields[9])
_AddTracked(
status,
path,
xy,
src_path=src_path,
level=score.decode("ascii"),
)
elif kind == b"u":
fields = _Fields(record, 11)
path = _Path(fields[10])
# The old diff-index/diff-files pair reported unmerged paths on
# both sides, regardless of porcelain's more specific XY pair.
status.index_changes[path] = StatusEntry(path, "U")
status.worktree_changes[path] = StatusEntry(path, "U")
elif kind == b"?":
status.untracked.append(_Path(record[2:]))
elif kind == b"!":
continue
else:
raise StatusParseError(
f"unknown porcelain v2 record type: {record!r}"
)
return status
+5
View File
@@ -104,6 +104,11 @@ elif sys.version_info < MIN_PYTHON_VERSION_SOFT:
KEYBOARD_INTERRUPT_EXIT = 128 + signal.SIGINT
MAX_PRINT_ERRORS = 5
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="surrogateescape")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(errors="surrogateescape")
global_options = optparse.OptionParser(
usage="repo [-p|--paginate|--no-pager] COMMAND [ARGS]",
add_help_option=False,
+2 -2
View File
@@ -1,5 +1,5 @@
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
.TH REPO "1" "July 2026" "repo smartsync" "Repo Manual"
.TH REPO "1" "September 2026" "repo smartsync" "Repo Manual"
.SH NAME
repo \- repo smartsync - manual page for repo smartsync
.SH SYNOPSIS
@@ -21,7 +21,7 @@ number of jobs to run in parallel (default: based on number of CPU cores)
number of network jobs to run in parallel (defaults to \fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
.TP
\fB\-\-jobs\-checkout\fR=\fI\,JOBS\/\fR
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or 8). Ignored unless \fB\-\-no\-interleaved\fR is set
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or based on number of CPU cores). Ignored unless \fB\-\-no\-interleaved\fR is set
.TP
\fB\-f\fR, \fB\-\-force\-broken\fR
obsolete option (to be deleted in the future)
+2 -2
View File
@@ -1,5 +1,5 @@
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
.TH REPO "1" "July 2026" "repo sync" "Repo Manual"
.TH REPO "1" "September 2026" "repo sync" "Repo Manual"
.SH NAME
repo \- repo sync - manual page for repo sync
.SH SYNOPSIS
@@ -21,7 +21,7 @@ number of jobs to run in parallel (default: based on number of CPU cores)
number of network jobs to run in parallel (defaults to \fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
.TP
\fB\-\-jobs\-checkout\fR=\fI\,JOBS\/\fR
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or 8). Ignored unless \fB\-\-no\-interleaved\fR is set
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or based on number of CPU cores). Ignored unless \fB\-\-no\-interleaved\fR is set
.TP
\fB\-f\fR, \fB\-\-force\-broken\fR
obsolete option (to be deleted in the future)
+12 -16
View File
@@ -1519,9 +1519,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
if base_revision:
if p.revisionExpr != base_revision:
failed_revision_changes.append(
"extend-project name %s mismatch base "
"%s vs revision %s"
% (name, base_revision, p.revisionExpr)
f"extend-project name {name}:\n "
f"base {base_revision} vs "
f"revision {p.revisionExpr}"
)
p.SetRevision(revision)
@@ -1622,9 +1622,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
if base_revision:
if p.revisionExpr != base_revision:
failed_revision_changes.append(
"remove-project name %s mismatch base "
"%s vs revision %s"
% (name, base_revision, p.revisionExpr)
f"remove-project name {name}:\n "
f"base {base_revision} vs "
f"revision {p.revisionExpr}"
)
del self._paths[p.relpath]
if not removed_project:
@@ -1636,13 +1636,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
if base_revision:
if p.revisionExpr != base_revision:
failed_revision_changes.append(
"remove-project path %s mismatch base "
"%s vs revision %s"
% (
p.relpath,
base_revision,
p.revisionExpr,
)
f"remove-project path {p.relpath}:\n "
f"base {base_revision} vs "
f"revision {p.revisionExpr}"
)
self._projects[projname].remove(p)
del self._paths[p.relpath]
@@ -1664,10 +1660,10 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
)
if failed_revision_changes:
fail_string = "\n".join(failed_revision_changes)
raise ManifestParseError(
"revision base check failed, rebase patches and update "
"base revs for: ",
failed_revision_changes,
f"detected base-revision mismatch, updates needed:\n"
f"{fail_string}",
)
# Store repo hooks project information.
+567 -77
View File
@@ -29,7 +29,7 @@ import sys
import tarfile
import tempfile
import time
from typing import Dict, List, NamedTuple, Optional
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
import urllib.parse
from color import Coloring
@@ -45,6 +45,7 @@ from error import UploadError
import fetch
from git_command import git_require
from git_command import GitCommand
from git_command import GitCommandError
from git_config import GetSchemeFromUrl
from git_config import GetUrlCookieFile
from git_config import GitConfig
@@ -56,6 +57,7 @@ from git_refs import R_M
from git_refs import R_PUB
from git_refs import R_TAGS
from git_refs import R_WORKTREE_M
import git_status
import git_superproject
from git_trace2_event_log import EventLog
import platform_utils
@@ -109,6 +111,14 @@ RETRY_JITTER_PERCENT = 0.1
_ALTERNATES = os.environ.get("REPO_USE_ALTERNATES") == "1"
def _FirstLines(lines: List[str], limit: int = 10) -> str:
"""Join |lines|, eliding all but the first |limit| of them."""
shown = list(lines[:limit])
if len(lines) > limit:
shown.append(f"... and {len(lines) - limit} more")
return "\n".join(shown)
def _lwrite(path, content):
lock = "%s.lock" % path
@@ -810,6 +820,17 @@ class Project:
"""Returns True if a cherry-pick is in progress."""
return os.path.exists(self.work_git.GetDotgitPath("CHERRY_PICK_HEAD"))
def _OperationInProgress(self) -> Optional[str]:
"""Return the name of the Git operation in progress, if any."""
if self.IsRebaseInProgress():
return "rebase"
if self.IsCherryPickInProgress():
return "cherry-pick"
for state, name in (("MERGE_HEAD", "merge"), ("REVERT_HEAD", "revert")):
if os.path.exists(self.work_git.GetDotgitPath(state)):
return name
return None
def _AbortRebase(self):
"""Abort ongoing rebase, cherry-pick or patch apply (am).
@@ -825,11 +846,70 @@ class Project:
_git("rebase", "--abort")
_git("am", "--abort")
def IsDirty(self, consider_untracked=True):
def _RefreshIndexStatCache(self) -> None:
"""Refresh the index's cached stat information."""
args = ["--unmerged", "--ignore-missing", "--refresh"]
# Run twice because -q is needed and unhelpful in equal measure. It
# keeps git quiet about uncommitted changes, which would otherwise
# exit 1 and report a failure to telemetry for every modified project,
# but it also suppresses the reason a refresh failed, leaving a bare
# exit 128. So refresh with it, and repeat without it only to get
# the reason.
try:
self.work_git.update_index("-q", *args, log_as_error=False)
return
except GitError:
pass
# Exit code 1 means there are modified files, which is okay.
try:
self.work_git.update_index(*args)
except GitCommandError as e:
if e.git_rc != 1:
raise
def IsDirty(self, consider_untracked: bool = True) -> bool:
"""Is the working directory modified in some way?"""
self.work_git.update_index(
"-q", "--unmerged", "--ignore-missing", "--refresh"
status = self._GetStatusSnapshot(
untracked_files="normal" if consider_untracked else "no"
)
if status is not None:
return status.is_dirty(consider_untracked=consider_untracked)
return self._IsDirtyLegacy(consider_untracked=consider_untracked)
def _GetStatusSnapshot(
self,
untracked_files: str = "all",
branch: bool = False,
ahead_behind: bool = False,
show_stash: bool = False,
) -> Optional[git_status.StatusSnapshot]:
"""Read one porcelain-v2 snapshot, or select the legacy path."""
if not git_require((2, 11, 0)):
return None
try:
return git_status.GetStatus(
self,
self.gitdir,
untracked_files=untracked_files,
branch=branch,
ahead_behind=ahead_behind,
show_stash=show_stash,
)
except (GitError, ValueError, git_status.UnsupportedStatusError) as e:
logger.warning(
"project %s: porcelain v2 status failed; using legacy "
"status: %s",
self.RelPath(local=False),
e,
)
return None
def _IsDirtyLegacy(self, consider_untracked: bool = True) -> bool:
"""Check dirty state with plumbing supported by older Git."""
self._RefreshIndexStatCache()
if self.work_git.DiffZ("diff-index", "-M", "--cached", HEAD):
return True
if self.work_git.DiffZ("diff-files"):
@@ -850,6 +930,21 @@ class Project:
)
return p.Wait() == 0
def _HasDirtyOrStash(self) -> bool:
"""Check dirty and normal stash state with one status when possible."""
has_status_stash = git_require((2, 35, 0))
status = self._GetStatusSnapshot(
untracked_files="normal",
show_stash=has_status_stash,
)
if status is not None:
if status.is_dirty(consider_untracked=True):
return True
if has_status_stash:
return bool(status.stash_count)
return self.HasStash()
return self.IsDirty(consider_untracked=True) or self.HasStash()
_userident_name = None
_userident_email = None
@@ -945,7 +1040,7 @@ class Project:
return matched
def UncommitedFiles(self, get_all=True):
def UncommittedFiles(self, get_all: bool = True) -> List[str]:
"""Returns a list of strings, uncommitted files in the git tree.
Args:
@@ -953,10 +1048,48 @@ class Project:
uncommitted files. If False - return as soon as any kind of
uncommitted files is detected.
"""
status = self._GetStatusSnapshot(untracked_files="all")
if status is not None:
return self._UncommittedFilesFromStatus(status, get_all=get_all)
return self._UncommittedFilesLegacy(get_all=get_all)
def _UncommittedFilesFromStatus(
self, status: git_status.StatusSnapshot, get_all: bool = True
) -> List[str]:
"""Format uncommitted paths from a porcelain-v2 snapshot."""
details = []
self.work_git.update_index(
"-q", "--unmerged", "--ignore-missing", "--refresh"
)
if self.IsRebaseInProgress():
details.append("rebase in progress")
if not get_all:
return details
changes = []
for path, entry in status.index_changes.items():
changes.append(path)
# The legacy diff-index call did not enable rename detection, so
# it reported a staged rename as delete(source) plus add(target).
if entry.status == "R" and entry.src_path:
changes.append(entry.src_path)
changes.sort()
if changes:
details.extend(changes)
if not get_all:
return details
changes = list(status.worktree_changes)
if changes:
details.extend(changes)
if not get_all:
return details
details.extend(status.untracked)
return details
def _UncommittedFilesLegacy(self, get_all: bool = True) -> List[str]:
"""List uncommitted paths with plumbing supported by older Git."""
details = []
self._RefreshIndexStatCache()
if self.IsRebaseInProgress():
details.append("rebase in progress")
if not get_all:
@@ -984,11 +1117,16 @@ class Project:
"""Returns a list of strings, untracked files in the git tree."""
return self.work_git.LsOthers()
def HasChanges(self):
def HasChanges(self) -> bool:
"""Returns true if there are uncommitted changes."""
return bool(self.UncommitedFiles(get_all=False))
return bool(self.UncommittedFiles(get_all=False))
def PrintWorkTreeStatus(self, output_redir=None, quiet=False, local=False):
def PrintWorkTreeStatus(
self,
output_redir: Any = None,
quiet: bool = False,
local: bool = False,
) -> Optional[str]:
"""Prints the status of the repository to stdout.
Args:
@@ -1007,14 +1145,103 @@ class Project:
print(' missing (run "repo sync")', file=output_redir)
return
self.work_git.update_index(
"-q", "--unmerged", "--ignore-missing", "--refresh"
status = self._GetStatusSnapshot(
untracked_files="all",
branch=True,
ahead_behind=not quiet,
)
if status is not None:
ahead = status.ahead
behind = status.behind
if (
not quiet
and status.current_branch is not None
and not status.has_ahead_behind
):
ahead, behind = self._GetBranchAheadBehind(
status.current_branch
)
return self._RenderWorkTreeStatus(
status.index_changes,
status.worktree_changes,
status.untracked,
self.IsRebaseInProgress(),
status.current_branch,
ahead,
behind,
output_redir=output_redir,
quiet=quiet,
local=local,
)
return self._PrintWorkTreeStatusLegacy(
output_redir=output_redir, quiet=quiet, local=local
)
def _PrintWorkTreeStatusLegacy(
self, output_redir: Any = None, quiet: bool = False, local: bool = False
) -> str:
"""Render status using plumbing supported by older Git."""
self._RefreshIndexStatCache()
rb = self.IsRebaseInProgress()
di = self.work_git.DiffZ("diff-index", "-M", "--cached", HEAD)
df = self.work_git.DiffZ("diff-files")
do = self.work_git.LsOthers()
if not rb and not di and not df and not do and not self.CurrentBranch:
if quiet and (rb or di or df or do):
branch_name = None
else:
branch_name = self.CurrentBranch
ahead = behind = 0
if branch_name is not None and not quiet:
ahead, behind = self._GetBranchAheadBehind(branch_name)
return self._RenderWorkTreeStatus(
di,
df,
do,
rb,
branch_name,
ahead,
behind,
output_redir=output_redir,
quiet=quiet,
local=local,
)
def _GetBranchAheadBehind(self, branch_name: str) -> Tuple[int, int]:
"""Return divergence when status could not supply branch.ab."""
ahead = behind = 0
branch_obj = self.GetBranch(branch_name)
try:
local_merge = branch_obj.LocalMerge
if local_merge:
left_right = self.work_git.rev_list(
"--left-right",
"--count",
f"{local_merge}...{R_HEADS}{branch_name}",
)
left, right = left_right[0].split()
behind = int(left)
ahead = int(right)
except (GitError, IndexError, ValueError):
pass
return ahead, behind
def _RenderWorkTreeStatus(
self,
di: Any,
df: Any,
do: Any,
rb: bool,
branch_name: Optional[str],
ahead: int,
behind: int,
output_redir: Any = None,
quiet: bool = False,
local: bool = False,
) -> str:
"""Render a normalized worktree snapshot."""
if not rb and not di and not df and not do and branch_name is None:
return "CLEAN"
out = StatusColoring(self.config)
@@ -1026,31 +1253,16 @@ class Project:
out.nl()
return "DIRTY"
branch_name = self.CurrentBranch
if branch_name is None:
out.nobranch("(*** NO BRANCH ***)")
else:
branch_obj = self.GetBranch(branch_name)
ahead_behind = ""
try:
local_merge = branch_obj.LocalMerge
if local_merge:
left_right = self.work_git.rev_list(
"--left-right",
"--count",
f"{local_merge}...{R_HEADS}{branch_name}",
)
left, right = left_right[0].split()
behind = int(left)
ahead = int(right)
if ahead and behind:
ahead_behind = f" [ahead {ahead}, behind {behind}]"
elif ahead:
ahead_behind = f" [ahead {ahead}]"
elif behind:
ahead_behind = f" [behind {behind}]"
except GitError:
pass
if ahead and behind:
ahead_behind = f" [ahead {ahead}, behind {behind}]"
elif ahead:
ahead_behind = f" [ahead {ahead}]"
elif behind:
ahead_behind = f" [behind {behind}]"
out.branch("branch %s%s", branch_name, ahead_behind)
out.nl()
@@ -1058,6 +1270,19 @@ class Project:
out.important("prior sync failed; rebase still in progress")
out.nl()
def _SafePath(path_str: str) -> str:
stream = output_redir if output_redir is not None else sys.stdout
encoding = getattr(stream, "encoding", None) or "utf-8"
errors = getattr(stream, "errors", None)
if errors not in ("surrogateescape", "backslashreplace", "replace"):
try:
path_str.encode(encoding)
except UnicodeEncodeError:
return path_str.encode(encoding, "backslashreplace").decode(
encoding
)
return path_str
paths = []
paths.extend(di.keys())
paths.extend(df.keys())
@@ -1084,12 +1309,15 @@ class Project:
else:
f_status = "-"
disp_p = _SafePath(p)
if i and i.src_path:
disp_src = _SafePath(i.src_path)
line = (
f" {i_status}{f_status}\t{i.src_path} => {p} ({i.level}%)"
f" {i_status}{f_status}\t"
f"{disp_src} => {disp_p} ({i.level}%)"
)
else:
line = f" {i_status}{f_status}\t{p}"
line = f" {i_status}{f_status}\t{disp_p}"
if i and not f:
out.added("%s", line)
@@ -1457,7 +1685,7 @@ class Project:
except (GitError, IndexError, ValueError):
return False
if self.IsDirty(consider_untracked=True) or self.HasStash():
if self._HasDirtyOrStash():
return False
return True
@@ -1811,15 +2039,27 @@ class Project:
self.revisionId = revisionId
@property
def UseReprojectCmd(self) -> bool:
"""Whether repo.reprojectcmd materializes this project's tree.
MetaProjects (repo itself and the manifests) always use Git. See
docs/reproject-cmd.md.
"""
if isinstance(self, MetaProject):
return False
mp = self.manifest.manifestProject
return bool(mp.use_local_gitdirs and mp.reproject_cmd)
def Sync_LocalHalf(
self,
syncbuf,
force_sync=False,
force_checkout=False,
force_rebase=False,
submodules=False,
verbose=False,
):
syncbuf: Any,
force_sync: bool = False,
force_checkout: bool = False,
force_rebase: bool = False,
submodules: bool = False,
verbose: bool = False,
) -> None:
"""Perform only the local IO portion of the sync process.
Network access is not required.
@@ -1838,6 +2078,29 @@ class Project:
)
return
if not isinstance(self, MetaProject):
mp = self.manifest.manifestProject
if mp.reproject_cmd and not mp.use_local_gitdirs:
fail(
LocalSyncFail(
"repo.reprojectcmd requires repo.uselocalgitdirs to be "
"enabled",
project=self.name,
)
)
return
reproject = self.UseReprojectCmd
if reproject and self.parent:
fail(
LocalSyncFail(
"repo.reprojectcmd does not support nested projects or "
"submodules",
project=self.name,
)
)
return
self._InitWorkTree(force_sync=force_sync, submodules=submodules)
# TODO(https://git-scm.com/docs/git-worktree#_bugs): Re-evaluate if
# submodules can be init when using worktrees once its support is
@@ -1868,8 +2131,30 @@ class Project:
)
return
head = self._GetHead()
if head and head.startswith(R_HEADS):
branch = head[len(R_HEADS) :]
try:
head = all_refs[head]
except KeyError:
head = None
else:
branch = None
def _checkout() -> None:
"""Detach HEAD at revid, like `git checkout <revid>`."""
if reproject:
self._ReprojectCheckout(revid, head, verbose=verbose)
else:
self._Checkout(revid, force_checkout=force_checkout, quiet=True)
def _doff():
self._FastForward(revid)
if reproject:
self._ReprojectBranch(
revid, head, f"merge {revid}: Fast-forward", verbose=verbose
)
else:
self._FastForward(revid)
self._CopyAndLinkFiles()
def _dorebase():
@@ -1895,16 +2180,6 @@ class Project:
if p.Wait() != 0:
logger.warning("warn: %s: stateless gc failed", self.name)
head = self._GetHead()
if head and head.startswith(R_HEADS):
branch = head[len(R_HEADS) :]
try:
head = all_refs[head]
except KeyError:
head = None
else:
branch = None
if branch is None or syncbuf.detach_head:
# Currently on a detached HEAD. The user is assumed to
# not have any local modifications worth worrying about.
@@ -1928,15 +2203,17 @@ class Project:
self._CopyAndLinkFiles()
return
else:
lost = self._revlist(not_rev(revid), HEAD)
if lost and verbose:
syncbuf.info(self, "discarding %d commits", len(lost))
if verbose:
lost_output = self._revlist("--count", not_rev(revid), HEAD)
lost = int(lost_output[0]) if lost_output else 0
if lost:
syncbuf.info(self, "discarding %d commits", lost)
try:
self._Checkout(revid, force_checkout=force_checkout, quiet=True)
_checkout()
if submodules:
self._SyncSubmodules(quiet=True)
except GitError as e:
except (GitError, LocalSyncFail) as e:
fail(e)
return
self._CopyAndLinkFiles()
@@ -1960,16 +2237,17 @@ class Project:
self, "leaving %s; does not track upstream", branch.name
)
try:
self._Checkout(revid, force_checkout=force_checkout, quiet=True)
_checkout()
if submodules:
self._SyncSubmodules(quiet=True)
except GitError as e:
except (GitError, LocalSyncFail) as e:
fail(e)
return
self._CopyAndLinkFiles()
return
upstream_gain = self._revlist(not_rev(HEAD), revid)
gain_output = self._revlist("--count", not_rev(HEAD), revid)
upstream_gain = int(gain_output[0]) if gain_output else 0
# See if we can perform a fast forward merge. This can happen if our
# branch isn't in the exact same state as we last published.
@@ -1983,7 +2261,7 @@ class Project:
pub = self.WasPublished(branch.name, all_refs)
if pub:
not_merged = self._revlist(not_rev(revid), pub)
not_merged = self._revlist("-1", not_rev(revid), pub)
if not_merged:
if upstream_gain:
if force_rebase:
@@ -1999,12 +2277,17 @@ class Project:
"branch %s is published (but not merged) and "
"is now %d commits behind. Fix this manually "
"or rerun with the --rebase option to force a "
"rebase." % (branch.name, len(upstream_gain)),
"rebase." % (branch.name, upstream_gain),
project=self.name,
)
)
return
syncbuf.later1(self, _doff, not verbose)
if reproject:
# HEAD is ahead of revid, so there is no tree to
# materialize: Git's fast-forward would be a no-op.
self._CopyAndLinkFiles()
else:
syncbuf.later1(self, _doff, not verbose)
return
elif pub == head:
# All published commits are merged, and thus we are a
@@ -2030,7 +2313,9 @@ class Project:
self._CopyAndLinkFiles()
return
if self.IsDirty(consider_untracked=False):
if (not reproject or (cnt_mine > 0 and self.rebase)) and self.IsDirty(
consider_untracked=False
):
fail(_DirtyError(project=self.name))
return
@@ -2076,11 +2361,19 @@ class Project:
syncbuf.later2(self, _docopyandlink, not verbose)
elif local_changes:
try:
self._ResetHard(revid)
if reproject:
self._ReprojectBranch(
revid,
head,
f"reset: moving to {revid}",
verbose=verbose,
)
else:
self._ResetHard(revid)
if submodules:
self._SyncSubmodules(quiet=True)
self._CopyAndLinkFiles()
except GitError as e:
except (GitError, LocalSyncFail) as e:
fail(e)
return
else:
@@ -2119,7 +2412,11 @@ class Project:
"""Download a single patch set of a single change to FETCH_HEAD."""
remote = self.GetRemote()
cmd = ["fetch", remote.name]
cmd = ["fetch"]
if git_require((2, 17, 0)):
cmd.append("--no-filter")
cmd.append("--no-tags")
cmd.append(remote.name)
cmd.append(
"refs/changes/%2.2d/%d/%d" % (change_id % 100, change_id, patch_id)
)
@@ -3669,14 +3966,193 @@ class Project:
if GitCommand(self, cmd).Wait() != 0:
raise GitError(f"{self.name} rebase {upstream} ", project=self.name)
def _FastForward(self, head, ffonly=False, quiet=True):
cmd = ["merge", "--no-stat", head]
if ffonly:
cmd.append("--ff-only")
def _FastForward(self, head: str, quiet: bool = True) -> None:
cmd = ["merge", "--no-stat", "--ff-only"]
if quiet:
cmd.append("-q")
cmd.append(head)
if GitCommand(self, cmd).Wait() != 0:
raise GitError(f"{self.name} merge {head} ", project=self.name)
raise GitError(
f"{self.name} merge --ff-only {head}", project=self.name
)
def _ReprojectCheckout(
self, revid: str, head: Optional[str], verbose: bool = False
) -> None:
"""Detach HEAD at |revid| with repo.reprojectcmd.
This stands in for `git checkout <revid>`. |head| is the commit HEAD
names now, or None. The command is not run when that is already
|revid|, since there is then nothing to materialize.
"""
old = self._GetHead()
if old and old.startswith(R_HEADS):
old = old[len(R_HEADS) :]
if head != revid:
self._Reproject(revid, verbose=verbose)
self.work_git.DetachHead(
revid, message=f"checkout: moving from {old or revid} to {revid}"
)
def _ReprojectBranch(
self,
revid: str,
head: Optional[str],
message: str,
verbose: bool = False,
) -> None:
"""Move the checked out branch to |revid| with repo.reprojectcmd.
This stands in for a fast-forward merge or a hard reset. |head| is the
commit the branch is at; the ref write fails if it moved meanwhile.
"""
self._Reproject(revid, verbose=verbose)
self.work_git.UpdateRef(HEAD, revid, old=head, message=message)
def _Reproject(self, revid: str, verbose: bool = False) -> None:
"""Make the index and worktree match |revid| with repo.reprojectcmd.
The command stands in for the tree materialization of a checkout, a
fast-forward or a hard reset. It must leave every ref alone; the
caller writes the ref Git would have written.
For the contract the command has to honor, see docs/reproject-cmd.md.
Raises:
LocalSyncFail: An operation is in progress, the index has staged
changes, the command failed, or it left the project in a state
that breaks the contract.
"""
in_progress = self._OperationInProgress()
if in_progress:
raise LocalSyncFail(
f"{in_progress} in progress; reprojectcmd cannot run",
project=self.name,
)
try:
head_tree = self.work_git.rev_parse(
"-q", "--verify", "HEAD^{tree}", log_as_error=False
)
except GitError:
head_tree = None
if head_tree:
p = GitCommand(
self,
["diff-index", "-z", "--cached", "--name-only", head_tree],
capture_stdout=True,
capture_stderr=True,
)
if p.Wait() != 0:
raise LocalSyncFail(
f"cannot check the index for staged changes: "
f"{p.stderr.strip()}",
project=self.name,
)
staged = p.stdout.split("\0")[:-1]
else:
p = GitCommand(
self,
["ls-files", "-z", "--cached"],
capture_stdout=True,
capture_stderr=True,
)
if p.Wait() != 0:
raise LocalSyncFail(
f"cannot check the index for staged changes: "
f"{p.stderr.strip()}",
project=self.name,
)
staged = p.stdout.split("\0")[:-1]
if staged:
raise LocalSyncFail(
"reprojectcmd cannot run with staged changes:\n"
+ _FirstLines(staged),
project=self.name,
)
head = self.work_git.GetHead()
try:
head_oid = self.work_git.rev_parse(
"-q", "--verify", "HEAD", log_as_error=False
)
except GitError:
head_oid = None
env = os.environ.copy()
env.update(self.GetEnvVars())
env["REPO_TREV"] = revid
cmd_str = self.manifest.manifestProject.reproject_cmd
if verbose:
print(f"Running reprojectcmd: {cmd_str} for {self.name}")
output = None if verbose else subprocess.PIPE
try:
p = subprocess.run(
cmd_str,
shell=True,
cwd=self.manifest.topdir,
env=env,
stdout=output,
stderr=None if verbose else subprocess.STDOUT,
text=True,
)
except OSError as e:
raise LocalSyncFail(
f"failed to run reprojectcmd: {e}", project=self.name
)
if p.returncode != 0:
msg = f"reprojectcmd exited with {p.returncode}"
if p.stdout:
msg += ":\n" + p.stdout.rstrip()
raise LocalSyncFail(msg, project=self.name)
new_head = self.work_git.GetHead()
try:
new_head_oid = self.work_git.rev_parse(
"-q", "--verify", "HEAD", log_as_error=False
)
except GitError:
new_head_oid = None
if new_head != head or new_head_oid != head_oid:
from_desc = (
f"{head} ({head_oid})"
if head_oid and head != head_oid
else f"{head}"
)
to_desc = (
f"{new_head} ({new_head_oid})"
if new_head_oid and new_head != new_head_oid
else f"{new_head}"
)
raise LocalSyncFail(
f"reprojectcmd moved HEAD from {from_desc} to {to_desc}; repo "
"owns every ref write",
project=self.name,
)
p = GitCommand(
self,
["diff-index", "--cached", "--name-only", f"{revid}^{{tree}}"],
capture_stdout=True,
capture_stderr=True,
)
if p.Wait() != 0:
raise LocalSyncFail(
f"cannot compare the index against {revid}: "
f"{p.stderr.strip()}",
project=self.name,
)
mismatched = p.stdout.splitlines()
if mismatched:
raise LocalSyncFail(
f"reprojectcmd left the index different from {revid}:\n"
+ _FirstLines(mismatched),
project=self.name,
)
def _InitGitDir(self, mirror_git=None, force_sync=False, quiet=False):
# Prefix for temporary directories created during gitdir initialization.
@@ -4788,7 +5264,7 @@ class _Later:
if not self.quiet:
out.nl()
return True
except GitError as e:
except (GitError, LocalSyncFail) as e:
syncbuf.fail(self.project, e)
out.nl()
return False
@@ -5067,6 +5543,11 @@ class ManifestProject(MetaProject):
"""The fetch command to use."""
return self.config.GetString("repo.fetchcmd")
@property
def reproject_cmd(self) -> Optional[str]:
"""The command that materializes a project's tree instead of Git."""
return self.config.GetString("repo.reprojectcmd")
@property
def clone_bundle(self):
"""Whether we use clone_bundle."""
@@ -5487,6 +5968,15 @@ class ManifestProject(MetaProject):
)
return False
if self.reproject_cmd and not (
use_local_gitdirs or self.use_local_gitdirs
):
logger.error(
"fatal: repo.reprojectcmd is set but repo.uselocalgitdirs is "
"not enabled"
)
return False
if archive:
if is_new:
self.config.SetBoolean("repo.archive", archive)
+2 -1
View File
@@ -23,4 +23,5 @@ import sys
import update_manpages
sys.exit(update_manpages.main(sys.argv[1:]))
if __name__ == "__main__":
sys.exit(update_manpages.main(sys.argv[1:]))
+1 -1
View File
@@ -195,7 +195,7 @@ If no project is specified try to use current directory as a project.
elif opt.revert:
project._Revert(dl.commit)
elif opt.ffonly:
project._FastForward(dl.commit, ffonly=True)
project._FastForward(dl.commit)
else:
if opt.branch:
project.StartBranch(opt.branch, revision=dl.commit)
+6 -2
View File
@@ -244,10 +244,14 @@ without iterating through the remaining projects.
mirror = self.manifest.IsMirror
if opt.regex:
projects = self.FindProjects(args, all_manifests=all_trees)
projects = self.FindProjects(
args,
groups=opt.groups,
all_manifests=all_trees,
)
elif opt.inverse_regex:
projects = self.FindProjects(
args, inverse=True, all_manifests=all_trees
args, inverse=True, groups=opt.groups, all_manifests=all_trees
)
else:
projects = self.GetProjects(
+58 -26
View File
@@ -18,7 +18,7 @@ import io
import json
import optparse
import sys
from typing import Any, Dict, List, NamedTuple
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
from color import Coloring
from command import DEFAULT_LOCAL_JOBS
@@ -189,18 +189,26 @@ class Info(PagedCommand):
"superproject_revision": srev,
}
@staticmethod
def _GetCurrentBranch(branches: Dict[str, Any]) -> Optional[str]:
"""Return the name of the current branch from a GetBranches mapping."""
return next(
(name for name, branch in branches.items() if branch.current), None
)
@classmethod
def _getProjectData(cls, project) -> Dict[str, Any]:
"""Gather project data as a dict."""
branches = project.GetBranches()
currentBranch = cls._GetCurrentBranch(branches)
data = {
"name": project.name,
"mount_path": project.worktree,
"current_revision": project.GetHeadRevisionId()
or project.GetRevisionId(),
"manifest_revision": project.revisionExpr,
"local_branches": list(project.GetBranches()),
"local_branches": list(branches),
}
currentBranch = project.CurrentBranch
if currentBranch:
data["current_branch"] = currentBranch
return data
@@ -285,6 +293,9 @@ class Info(PagedCommand):
text = out.nofmt_printer("text")
dimtext = out.printer("dimtext", attr="dim")
branches = project.GetBranches()
currentBranch = cls._GetCurrentBranch(branches)
heading("Project: ")
headtext(project.name)
out.nl()
@@ -297,7 +308,6 @@ class Info(PagedCommand):
headtext(project.GetHeadRevisionId() or project.GetRevisionId())
out.nl()
currentBranch = project.CurrentBranch
if currentBranch:
heading("Current branch: ")
headtext(currentBranch)
@@ -307,7 +317,7 @@ class Info(PagedCommand):
headtext(project.revisionExpr)
out.nl()
localBranches = list(project.GetBranches().keys())
localBranches = list(branches)
heading("Local Branches: ")
redtext(str(len(localBranches)))
if localBranches:
@@ -327,25 +337,10 @@ class Info(PagedCommand):
branch = branch[len(R_HEADS) :]
logTarget = R_M + branch
bareTmp = project.bare_git._bare
project.bare_git._bare = False
localCommits = project.bare_git.rev_list(
"--abbrev=8",
"--abbrev-commit",
"--pretty=oneline",
logTarget + "..",
"--",
localCommits, originCommits = cls._GetDiffCommits(
project, logTarget
)
originCommits = project.bare_git.rev_list(
"--abbrev=8",
"--abbrev-commit",
"--pretty=oneline",
".." + logTarget,
"--",
)
project.bare_git._bare = bareTmp
heading("Local Commits: ")
redtext(str(len(localCommits)))
dimtext(" (on current branch)")
@@ -375,6 +370,35 @@ class Info(PagedCommand):
return buf.getvalue()
@classmethod
def _GetDiffCommits(
cls, project: Any, log_target: str
) -> Tuple[List[str], List[str]]:
"""Return local-only and remote-only commits from one history walk."""
git = getattr(project, "work_git", None) or getattr(
project, "bare_git", None
)
if git is None:
return [], []
commits = git.rev_list(
"--left-right",
"--abbrev=8",
"--abbrev-commit",
"--pretty=oneline",
f"HEAD...{log_target}",
"--",
)
if isinstance(commits, str):
commits = commits.splitlines()
local = []
remote = []
for commit in commits:
if commit.startswith("<"):
local.append(commit[1:])
elif commit.startswith(">"):
remote.append(commit[1:])
return local, remote
def _printDiffInfo(self, opt, args):
projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
@@ -404,10 +428,18 @@ class Info(PagedCommand):
project = cls.get_parallel_context()["projects"][project_idx]
branches = []
br = [project.GetUploadableBranch(x) for x in project.GetBranches()]
br = [x for x in br if x]
local_branches = project.GetBranches()
current_branch = cls._GetCurrentBranch(local_branches)
if opt.current_branch:
br = [x for x in br if x.name == project.CurrentBranch]
candidate_branches = (
[current_branch]
if current_branch and current_branch in local_branches
else []
)
else:
candidate_branches = local_branches
br = [project.GetUploadableBranch(x) for x in candidate_branches]
br = [x for x in br if x]
for b in br:
branches.append(
@@ -416,7 +448,7 @@ class Info(PagedCommand):
name=b.name,
commits=b.commits,
date=b.date,
is_current=b.name == project.CurrentBranch,
is_current=b.name == current_branch,
)
)
return branches
+4 -1
View File
@@ -109,7 +109,10 @@ This is similar to running: repo forall -c 'echo "$REPO_PATH : $REPO_PROJECT"'.
)
else:
projects = self.FindProjects(
args, all_manifests=not opt.this_manifest_only
args,
groups=opt.groups,
missing_ok=opt.all,
all_manifests=not opt.this_manifest_only,
)
def _getpath(x):
+65 -3
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.
@@ -528,6 +534,8 @@ later is required to fix a server side protocol bug.
help="number of network jobs to run in parallel (defaults to "
"--jobs or 1). Ignored unless --no-interleaved is set",
)
jobs_checkout_default = self._GetHelpForCpuJobCount(DEFAULT_LOCAL_JOBS)
p.add_option(
"--jobs-checkout",
default=None,
@@ -535,7 +543,7 @@ later is required to fix a server side protocol bug.
metavar="JOBS",
help=(
"number of local checkout jobs to run in parallel (defaults "
f"to --jobs or {DEFAULT_LOCAL_JOBS}). Ignored unless "
f"to --jobs or {jobs_checkout_default}). Ignored unless "
"--no-interleaved is set"
),
)
@@ -1606,10 +1614,23 @@ later is required to fix a server side protocol bug.
# Only check dirty or locally modified projects. These can't be
# freshly cloned and will accumulate garbage.
try:
is_dirty = project.IsDirty(consider_untracked=True)
status = project._GetStatusSnapshot(
untracked_files="normal", branch=True
)
if status is not None:
is_dirty = status.is_dirty(consider_untracked=True)
head_rev = status.branch_oid
else:
is_dirty = project.IsDirty(consider_untracked=True)
head_rev = project.work_git.rev_parse(HEAD)
if head_rev is None:
# Porcelain v2 reports an unborn branch as "(initial)". The
# legacy rev-parse path failed here and skipped the bloat
# calculation, so preserve that behavior.
return None
manifest_rev = project.GetRevisionId(project.bare_ref.all)
head_rev = project.work_git.rev_parse(HEAD)
has_local_commits = manifest_rev != head_rev
if not (is_dirty or has_local_commits):
@@ -2478,6 +2499,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 +2563,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:
+12
View File
@@ -435,6 +435,18 @@ Gerrit Code Review: https://www.gerritcodereview.com/
self._UploadAndReport(opt, [branch], people)
def _MultipleBranches(self, opt, pending, people):
if opt.yes and (opt.current_branch or opt.branch):
todo = [
branch
for _, avail in pending
for branch in avail
if branch is not None
]
if not todo:
_die("nothing ready for upload")
self._UploadAndReport(opt, todo, people)
return
projects = {}
branches = {}
+234 -4
View File
@@ -14,6 +14,8 @@
"""Unittests for the command.py module."""
from typing import Iterable, List, Optional
import pytest
from command import Command
@@ -30,12 +32,15 @@ class FakeProject:
gitdir=None,
derived_subprojects=None,
sync_s=False,
exists: bool = True,
):
self.name = name
self.relpath = relpath
self.worktree = f"/work/{relpath}"
self.manifest = None
self.gitdir = gitdir or f"/git/{relpath}"
self.sync_s = sync_s
self.Exists = True
self.Exists = exists
self._derived_subprojects = derived_subprojects or []
def GetDerivedSubprojects(self):
@@ -51,11 +56,59 @@ class FakeProject:
class FakeManifest:
"""Minimal manifest double for Command.GetProjects tests."""
def __init__(self, projects):
self.projects = projects
def __init__(
self,
projects: Iterable[FakeProject],
*,
all_projects: Optional[Iterable[FakeProject]] = None,
effective_groups: str = "default",
):
self.projects = list(projects)
self.all_projects = (
list(self.projects) if all_projects is None else list(all_projects)
)
self._effective_groups = effective_groups
self.path_prefix = ""
# all_projects may include projects owned by child manifests,
# so only set this manifest on its direct projects.
for project in self.projects:
self._set_project_manifest(project)
def _set_project_manifest(self, project: FakeProject) -> None:
project.manifest = self
for subproject in project.GetDerivedSubprojects():
self._set_project_manifest(subproject)
def GetManifestGroupsStr(self):
return "default"
return self._effective_groups
def GetProjectsWithName(
self, name: str, all_manifests: bool = False
) -> List[FakeProject]:
projects = self.all_projects if all_manifests else self.projects
return [project for project in projects if project.name == name]
class GroupMatchingFakeProject(FakeProject):
"""Fake project with predictable group matches for GetProjects tests.
This lets the tests check which groups GetProjects uses without
reimplementing Project.MatchesGroups.
"""
def __init__(
self,
name: str,
relpath: str,
*,
matching_groups: Iterable[str],
):
super().__init__(name, relpath)
self._matching_groups = set(matching_groups)
def MatchesGroups(self, groups: Iterable[str]) -> bool:
return bool(self._matching_groups.intersection(groups))
def test_get_projects_keeps_derived_subprojects_for_repeated_repo():
@@ -117,3 +170,180 @@ def test_get_projects_submodule_override(
projects = cmd.GetProjects([], submodules_ok=submodules_ok)
assert (submodule in projects) is includes_submodule
@pytest.mark.parametrize(
("groups", "expected_relpaths"),
[
(None, ["outer", "sub/child"]),
("", ["outer", "sub/child"]),
("override-group", ["sub/override"]),
],
ids=("groups-omitted", "groups-empty", "explicit-override"),
)
def test_get_projects_uses_groups_from_each_manifest_unless_overridden(
groups: Optional[str],
expected_relpaths: List[str],
) -> None:
"""Use each manifest's effective groups unless the caller overrides them."""
outer_project = GroupMatchingFakeProject(
"outer",
"outer",
matching_groups={"outer-group"},
)
# Both child projects also match "outer". Reusing the outer manifest's
# groups would therefore select both child projects.
child_project = GroupMatchingFakeProject(
"child",
"sub/child",
matching_groups={"outer-group", "child-group"},
)
override_project = GroupMatchingFakeProject(
"override",
"sub/override",
matching_groups={"outer-group", "override-group"},
)
child_manifest = FakeManifest(
[child_project, override_project],
effective_groups="child-group",
)
outer_manifest = FakeManifest(
[outer_project],
all_projects=[outer_project, *child_manifest.projects],
effective_groups="outer-group",
)
cmd = Command(manifest=outer_manifest)
projects = cmd.GetProjects(
[],
manifest=outer_manifest,
groups=groups,
all_manifests=True,
)
assert [project.relpath for project in projects] == expected_relpaths
def test_get_projects_by_name_uses_groups_from_each_manifest() -> None:
"""Name matches use the groups from each project's owning manifest."""
outer_project = GroupMatchingFakeProject(
"shared",
"outer/shared",
matching_groups={"outer-group"},
)
child_project = GroupMatchingFakeProject(
"shared",
"sub/shared",
matching_groups={"child-group"},
)
child_manifest = FakeManifest(
[child_project],
effective_groups="child-group",
)
outer_manifest = FakeManifest(
[outer_project],
all_projects=[outer_project, *child_manifest.projects],
effective_groups="outer-group",
)
cmd = Command(manifest=outer_manifest)
projects = cmd.GetProjects(
["shared"],
manifest=outer_manifest,
all_manifests=True,
)
assert [project.relpath for project in projects] == [
"outer/shared",
"sub/shared",
]
def test_find_projects_uses_groups_from_each_manifest() -> None:
"""Use each manifest's effective groups for regex selection."""
outer_project = GroupMatchingFakeProject(
"match-outer",
"outer",
matching_groups={"outer-group"},
)
child_project = GroupMatchingFakeProject(
"match-child",
"sub/child",
matching_groups={"child-group"},
)
excluded_project = GroupMatchingFakeProject(
"match-excluded",
"sub/excluded",
matching_groups={"other-group"},
)
child_manifest = FakeManifest(
[child_project, excluded_project],
effective_groups="child-group",
)
child_manifest.path_prefix = "sub"
outer_manifest = FakeManifest(
[outer_project],
all_projects=[
outer_project,
*child_manifest.projects,
],
effective_groups="outer-group",
)
outer_manifest.outer_client = outer_manifest
cmd = Command(manifest=outer_manifest)
projects = cmd.FindProjects(["match"], all_manifests=True)
assert [project.relpath for project in projects] == [
"outer",
"sub/child",
]
def test_find_projects_uses_explicit_groups() -> None:
"""Use explicit groups for regex selection."""
default_project = GroupMatchingFakeProject(
"default",
"default",
matching_groups={"default-group"},
)
override_project = GroupMatchingFakeProject(
"override",
"override",
matching_groups={"override-group"},
)
manifest = FakeManifest(
[default_project, override_project],
effective_groups="default-group",
)
cmd = Command(manifest=manifest)
projects = cmd.FindProjects(
["override"],
groups="override-group",
)
assert projects == [override_project]
def test_find_projects_allows_missing_projects() -> None:
"""Allow regex selection to include projects without a checkout."""
project = FakeProject(
"missing",
"missing",
exists=False,
)
cmd = Command(manifest=FakeManifest([project]))
projects = cmd.FindProjects(
["missing"],
missing_ok=True,
)
assert projects == [project]
+136
View File
@@ -123,6 +123,7 @@ class GitCommandStreamLogsTest(unittest.TestCase):
"""Tests the GitCommand class stderr log streaming cases."""
def setUp(self):
_ = git_command.user_agent.git
self.mock_process = mock.MagicMock()
self.mock_process.communicate.return_value = (None, None)
self.mock_process.wait.return_value = 0
@@ -228,6 +229,141 @@ class GitCommandStreamLogsTest(unittest.TestCase):
self.assertEqual(cmd.stderr, logs)
class GitCommandCaptureBytesTest(unittest.TestCase):
"""Tests the GitCommand class byte capture cases."""
def setUp(self) -> None:
_ = git_command.user_agent.git
self.mock_process = mock.MagicMock()
self.mock_process.communicate.return_value = (None, None)
self.mock_process.wait.return_value = 0
self.mock_popen = mock.MagicMock()
self.mock_popen.return_value = self.mock_process
mock.patch("subprocess.Popen", self.mock_popen).start()
def tearDown(self) -> None:
mock.patch.stopall()
def test_captures_stdout_as_bytes(self) -> None:
self.mock_process.communicate.return_value = (b"\xff\x00", b"error\r\n")
cmd = git_command.GitCommand(
None,
["status"],
capture_stdout=True,
capture_stdout_bytes=True,
capture_stderr=True,
)
self.mock_popen.assert_called_once_with(
["git", "status"],
cwd=None,
env=mock.ANY,
stdin=None,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.assertEqual(cmd.stdout, b"\xff\x00")
self.assertEqual(cmd.stderr, "error\n")
def test_capture_stdout_bytes_auto_enables_capture_stdout(self) -> None:
self.mock_process.communicate.return_value = (b"output", b"")
cmd = git_command.GitCommand(
None,
["status"],
capture_stdout_bytes=True,
)
self.mock_popen.assert_called_once_with(
["git", "status"],
cwd=None,
env=mock.ANY,
stdin=None,
stdout=subprocess.PIPE,
stderr=None,
)
self.assertEqual(cmd.stdout, b"output")
def test_capture_stdout_bytes_with_merge_output_raises(self) -> None:
with self.assertRaises(ValueError):
git_command.GitCommand(
None,
["status"],
capture_stdout_bytes=True,
merge_output=True,
)
def test_captures_stdout_as_bytes_encodes_str_input(self) -> None:
self.mock_process.communicate.return_value = (b"output", b"")
git_command.GitCommand(
None,
["status"],
input="hello world",
capture_stdout_bytes=True,
)
self.mock_process.communicate.assert_called_once_with(
input=b"hello world"
)
def test_captures_stdout_as_bytes_encodes_surrogate_input(self) -> None:
self.mock_process.communicate.return_value = (b"output", b"")
git_command.GitCommand(
None,
["status"],
input="file_\udcff.txt",
capture_stdout_bytes=True,
)
self.mock_process.communicate.assert_called_once_with(
input=b"file_\xff.txt"
)
def test_captures_stdout_as_bytes_passes_bytes_input(self) -> None:
self.mock_process.communicate.return_value = (b"output", b"")
git_command.GitCommand(
None,
["status"],
input=b"raw_\xff.txt",
capture_stdout_bytes=True,
)
self.mock_process.communicate.assert_called_once_with(
input=b"raw_\xff.txt"
)
def test_verify_command_truncates_nul_delimited_stdout(self) -> None:
cmd = git_command.GitCommand(
None,
["status"],
capture_stdout_bytes=True,
)
cmd.rc = 1
cmd.stdout = b"first_file\0second_file\0third_file"
cmd.stderr = "stderr"
with self.assertRaises(git_command.GitCommandError) as cm:
cmd.VerifyCommand()
self.assertEqual(cm.exception.git_stdout, "first_file")
def test_verify_command_decodes_bytes_stdout(self) -> None:
cmd = git_command.GitCommand(
None,
["status"],
capture_stdout_bytes=True,
)
cmd.rc = 1
cmd.stdout = b"error\xff\nline2"
cmd.stderr = "stderr"
with self.assertRaises(git_command.GitCommandError) as cm:
cmd.VerifyCommand()
self.assertEqual(cm.exception.git_stdout, "error\\xff")
class GitCallUnitTest(unittest.TestCase):
"""Tests the _GitCall class (via git_command.git)."""
+210
View File
@@ -0,0 +1,210 @@
# Copyright (C) 2026 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unittests for the git_status.py module."""
import os
from typing import Any, List
from unittest import mock
import pytest
import git_status
def test_parse_porcelain_v2_branch_and_paths() -> None:
output = (
b"# branch.oid " + b"1" * 40 + b"\0"
b"# branch.head topic\0"
b"# branch.upstream origin/main\0"
b"# branch.ab +2 -3\0"
b"# stash 1\0"
b"1 M. N... 100644 100644 100644 "
+ b"1" * 40
+ b" "
+ b"2" * 40
+ b" staged name\0"
b"1 .M N... 100644 100644 100644 "
+ b"1" * 40
+ b" "
+ b"2" * 40
+ b" worktree name\0"
b"2 R. N... 100644 100644 100644 "
+ b"1" * 40
+ b" "
+ b"2" * 40
+ b" R075 renamed\0old name\0"
b"? untracked\0"
)
status = git_status.ParsePorcelainV2(output)
assert status.current_branch == "topic"
assert status.upstream == "origin/main"
assert (status.ahead, status.behind, status.stash_count) == (2, 3, 1)
assert status.index_changes["staged name"].status == "M"
assert status.worktree_changes["worktree name"].status == "M"
renamed = status.index_changes["renamed"]
assert (renamed.src_path, renamed.level) == ("old name", "75")
assert status.untracked == ["untracked"]
def test_parse_porcelain_v2_unmerged_and_non_utf8_path() -> None:
path = b"bad-\xff-name"
output = (
b"u UU N... 100644 100644 100644 100644 "
+ b"1" * 40
+ b" "
+ b"2" * 40
+ b" "
+ b"3" * 40
+ b" "
+ path
+ b"\0"
)
status = git_status.ParsePorcelainV2(output)
decoded = os.fsdecode(path)
assert status.index_changes[decoded].status == "U"
assert status.worktree_changes[decoded].status == "U"
assert os.fsencode(status.index_changes[decoded].path) == path
def test_untracked_only_respects_consider_untracked() -> None:
status = git_status.ParsePorcelainV2(b"? new file\0")
assert status.is_dirty()
assert not status.is_dirty(consider_untracked=False)
def test_branch_headers_preserve_non_ascii_names() -> None:
branch = "tópico"
status = git_status.ParsePorcelainV2(
b"# branch.oid " + b"1" * 40 + b"\0"
b"# branch.head " + os.fsencode(branch) + b"\0"
)
assert status.current_branch == branch
def test_quick_ahead_behind_is_recorded_as_unknown() -> None:
status = git_status.ParsePorcelainV2(b"# branch.ab +? -?\0")
assert (status.ahead, status.behind) == (0, 0)
assert not status.has_ahead_behind
def test_unknown_head_is_not_a_current_branch() -> None:
status = git_status.ParsePorcelainV2(b"# branch.head (unknown)\0")
assert status.current_branch is None
def test_malformed_output_is_rejected() -> None:
with pytest.raises(git_status.StatusParseError):
git_status.ParsePorcelainV2(b"2 R. truncated\0")
def test_malformed_branch_ab_is_rejected() -> None:
with pytest.raises(git_status.StatusParseError):
git_status.ParsePorcelainV2(b"# branch.ab not-a-valid-ab\0")
def test_ignored_records_are_skipped() -> None:
status = git_status.ParsePorcelainV2(b"! ignored_file\0")
assert not status.is_dirty()
assert status.untracked == []
def test_get_status_uses_versioned_machine_options(
monkeypatch: pytest.MonkeyPatch,
) -> None:
commands = []
class FakeGitCommand:
def __init__(
self, _project: Any, cmdv: List[str], **kwargs: Any
) -> None:
commands.append((cmdv, kwargs))
self.stdout = b""
def Wait(self) -> int:
return 0
monkeypatch.setattr(git_status, "GitCommand", FakeGitCommand)
monkeypatch.setattr(git_status, "git_require", lambda _version: True)
git_status.GetStatus(
mock.sentinel.project,
mock.sentinel.gitdir,
untracked_files="no",
branch=True,
ahead_behind=True,
show_stash=True,
)
cmd, kwargs = commands[0]
assert cmd == [
"status",
"--porcelain=v2",
"-z",
"--ignore-submodules=all",
"--untracked-files=no",
"--branch",
"--ahead-behind",
"--renames",
"--show-stash",
]
assert kwargs["capture_stdout_bytes"]
def test_get_status_rejects_git_before_2_11(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(git_status, "git_require", lambda _version: False)
with pytest.raises(git_status.UnsupportedStatusError):
git_status.GetStatus(mock.sentinel.project, mock.sentinel.gitdir)
def test_get_status_omits_stash_header_before_2_35(
monkeypatch: pytest.MonkeyPatch,
) -> None:
commands = []
class FakeGitCommand:
def __init__(
self, _project: Any, cmdv: List[str], **_kwargs: Any
) -> None:
commands.append(cmdv)
self.stdout = b""
def Wait(self) -> int:
return 0
monkeypatch.setattr(git_status, "GitCommand", FakeGitCommand)
monkeypatch.setattr(
git_status,
"git_require",
lambda version: version <= (2, 34, 0),
)
git_status.GetStatus(
mock.sentinel.project,
mock.sentinel.gitdir,
show_stash=True,
)
assert "--show-stash" not in commands[0]
+5 -9
View File
@@ -33,7 +33,7 @@ import platform_utils
def server_logging_thread(
socket_path: str,
server_ready: threading.Condition,
server_ready: threading.Event,
received_traces: List[str],
) -> None:
"""Helper function to receive logs over a Unix domain socket.
@@ -43,8 +43,7 @@ def server_logging_thread(
Args:
socket_path: path to a Unix domain socket on which to listen for traces
server_ready: a threading.Condition used to signal to the caller that
this thread is ready to accept connections
server_ready: event set when the server is ready to accept connections
received_traces: a list to which received traces will be appended (after
decoding to a utf-8 string).
"""
@@ -53,8 +52,7 @@ def server_logging_thread(
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.bind(socket_path)
sock.listen(0)
with server_ready:
server_ready.notify()
server_ready.set()
with sock.accept()[0] as conn:
while True:
recved = conn.recv(4096)
@@ -404,7 +402,7 @@ def test_write_socket(event_log: git_trace2_event_log.EventLog) -> None:
received_traces: List[str] = []
with tempfile.TemporaryDirectory(prefix="test_server_sockets") as tempdir:
socket_path = os.path.join(tempdir, "server.sock")
server_ready = threading.Condition()
server_ready = threading.Event()
# Start "server" listening on Unix domain socket at socket_path.
server_thread = threading.Thread(
target=server_logging_thread,
@@ -412,9 +410,7 @@ def test_write_socket(event_log: git_trace2_event_log.EventLog) -> None:
)
try:
server_thread.start()
with server_ready:
server_ready.wait(timeout=120)
server_ready.wait(timeout=120)
event_log.StartEvent([])
path = event_log.Write(path=f"af_unix:{socket_path}")
+1185 -12
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
# Copyright (C) 2026 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for the release/update-manpages wrapper."""
import runpy
import sys
from types import ModuleType
import pytest
import utils_for_test
UPDATE_MANPAGES_SCRIPT = (
utils_for_test.THIS_DIR.parent / "release" / "update-manpages"
)
def test_wrapper_does_not_run_main_for_multiprocessing_child(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Do not rerun main when multiprocessing re-executes the wrapper."""
fake_update_manpages = ModuleType("update_manpages")
# Mock this because the real module requires newer Python versions than
# our unittest framework does.
monkeypatch.setitem(sys.modules, "update_manpages", fake_update_manpages)
runpy.run_path(
str(UPDATE_MANPAGES_SCRIPT),
run_name="__mp_main__",
)
+56
View File
@@ -17,7 +17,9 @@
import contextlib
import io
from pathlib import Path
from unittest import mock
import pytest
import utils_for_test
import manifest_xml
@@ -105,3 +107,57 @@ def test_forall_all_projects_called_once(tmp_path: Path) -> None:
line_count = sum(1 for x in output.splitlines() if x)
# Verify that we didn't get more lines than expected.
assert line_count == 8
@pytest.mark.parametrize(
("regex_option", "inverse"),
[
("-r", False),
("-i", True),
],
ids=("regex", "inverse-regex"),
)
def test_forall_regex_modes_pass_groups_to_find_projects(
tmp_path: Path,
regex_option: str,
inverse: bool,
) -> None:
"""Pass --groups through in regex modes."""
manifest = _create_manifest_with_8_projects(tmp_path)
cmd = subcmds.forall.Forall()
cmd.manifest = manifest
opts, args = cmd.OptionParser.parse_args(
[
regex_option,
"--groups",
"special",
"project",
"-c",
"true",
]
)
with mock.patch.object(
cmd,
"FindProjects",
return_value=[],
) as find_projects, mock.patch.object(
cmd,
"ExecuteInParallel",
return_value=0,
):
cmd.Execute(opts, args)
expected_kwargs = {
"groups": "special",
"all_manifests": True,
}
if inverse:
expected_kwargs["inverse"] = True
find_projects.assert_called_once_with(
["project"],
**expected_kwargs,
)
+116 -2
View File
@@ -208,7 +208,7 @@ def test_get_project_data_uses_head_revision() -> None:
project.name = "foo"
project.worktree = "/path/to/foo"
project.revisionExpr = "refs/heads/main"
project.GetBranches.return_value = []
project.GetBranches.return_value = {}
# GetHeadRevisionId() returns a SHA, it should be used.
project.GetHeadRevisionId.return_value = "head_sha_12345"
@@ -235,7 +235,9 @@ def test_json_with_projects(capsys) -> None:
project.name = "foo"
project.worktree = "/path/to/foo"
project.revisionExpr = "refs/heads/main"
project.GetBranches.return_value = {"branch1": mock.MagicMock()}
branch = mock.MagicMock()
branch.current = True
project.GetBranches.return_value = {"branch1": branch}
project.GetHeadRevisionId.return_value = "head_sha_12345"
project.CurrentBranch = "branch1"
@@ -253,3 +255,115 @@ def test_json_with_projects(capsys) -> None:
assert project_data["manifest_revision"] == "refs/heads/main"
assert project_data["local_branches"] == ["branch1"]
assert project_data["current_branch"] == "branch1"
def test_diff_commits_uses_one_left_right_walk() -> None:
"""Local and remote commits are partitioned from one rev-list."""
project = mock.MagicMock()
project.work_git.rev_list.return_value = [
"<11111111 local commit",
">22222222 remote commit",
]
local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main")
assert local == ["11111111 local commit"]
assert remote == ["22222222 remote commit"]
project.work_git.rev_list.assert_called_once_with(
"--left-right",
"--abbrev=8",
"--abbrev-commit",
"--pretty=oneline",
"HEAD...refs/remotes/m/main",
"--",
)
def test_diff_commits_falls_back_to_bare_git_when_no_worktree() -> None:
"""Bare or worktree-less projects fall back to bare_git for history walk."""
project = mock.MagicMock()
project.work_git = None
project.bare_git.rev_list.return_value = [
"<11111111 local commit",
">22222222 remote commit",
]
local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main")
assert local == ["11111111 local commit"]
assert remote == ["22222222 remote commit"]
project.bare_git.rev_list.assert_called_once_with(
"--left-right",
"--abbrev=8",
"--abbrev-commit",
"--pretty=oneline",
"HEAD...refs/remotes/m/main",
"--",
)
def test_diff_commits_empty_output() -> None:
"""Empty rev-list output produces empty local and remote commit lists."""
project = mock.MagicMock()
project.work_git.rev_list.return_value = []
local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main")
assert local == []
assert remote == []
def test_get_current_branch() -> None:
"""_GetCurrentBranch identifies the branch with current=True."""
b1 = mock.MagicMock(current=False)
b2 = mock.MagicMock(current=True)
assert info.Info._GetCurrentBranch({"b1": b1, "b2": b2}) == "b2"
assert info.Info._GetCurrentBranch({"b1": b1}) is None
assert info.Info._GetCurrentBranch({}) is None
def test_overview_helper_current_branch_filters_before_uploadable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_OverviewHelper only checks uploadable state for the current branch."""
project = mock.MagicMock()
project.RelPath.return_value = "proj"
b1 = mock.MagicMock(current=False)
b2 = mock.MagicMock(current=True)
project.GetBranches.return_value = {"b1": b1, "b2": b2}
uploadable = mock.MagicMock(commits=["c1"], date="2026-09-21")
uploadable.name = "b2"
project.GetUploadableBranch.return_value = uploadable
monkeypatch.setattr(
info.Info,
"get_parallel_context",
lambda: {"projects": [project]},
)
opt = mock.MagicMock(current_branch=True, this_manifest_only=False)
result = info.Info._OverviewHelper(0, opt)
project.GetUploadableBranch.assert_called_once_with("b2")
assert len(result) == 1
assert result[0].name == "b2"
assert result[0].is_current is True
def test_overview_helper_current_branch_detached_head_skips_uploadable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_OverviewHelper skips GetUploadableBranch when detached with -b."""
project = mock.MagicMock()
b1 = mock.MagicMock(current=False)
project.GetBranches.return_value = {"b1": b1}
monkeypatch.setattr(
info.Info,
"get_parallel_context",
lambda: {"projects": [project]},
)
opt = mock.MagicMock(current_branch=True, this_manifest_only=False)
result = info.Info._OverviewHelper(0, opt)
project.GetUploadableBranch.assert_not_called()
assert result == []
+57
View File
@@ -0,0 +1,57 @@
# Copyright (C) 2026 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unittests for the list subcmd."""
from typing import List, Optional
from unittest import mock
import pytest
import subcmds
@pytest.mark.parametrize(
("extra_args", "expected_groups", "expected_missing_ok"),
[
(["--groups", "special"], "special", None),
(["--all"], None, True),
],
ids=("groups", "all"),
)
def test_list_regex_passes_groups_and_all(
extra_args: List[str],
expected_groups: Optional[str],
expected_missing_ok: Optional[bool],
) -> None:
"""Pass --groups and --all through in regex mode."""
cmd = subcmds.list.List()
opts, args = cmd.OptionParser.parse_args(
["--regex", *extra_args, "project"]
)
with mock.patch.object(
cmd,
"FindProjects",
return_value=[],
) as find_projects:
cmd.Execute(opts, args)
find_projects.assert_called_once_with(
["project"],
groups=expected_groups,
missing_ok=expected_missing_ok,
all_manifests=True,
)
+111
View File
@@ -185,6 +185,96 @@ def test_status_without_orphans(
assert lines[1] == " -m\tREADME"
def test_status_staged_and_unstaged_same_path(
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
) -> None:
"""A path changed on both sides of the index renders both states."""
topdir, manifest = repo_client_checkout
project_path = next(iter(manifest.paths.keys()))
project_worktree = topdir / project_path
readme = project_worktree / "README"
readme.write_text("staged")
subprocess.check_call(["git", "add", "README"], cwd=project_worktree)
readme.write_text("unstaged")
with contextlib.redirect_stdout(io.StringIO()) as stdout:
_run_status(manifest, [])
lines = _status_lines(stdout.getvalue())
assert lines[1] == " Mm\tREADME"
def test_status_forces_staged_rename_detection(
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
) -> None:
"""The snapshot preserves rename scores despite user status config."""
topdir, manifest = repo_client_checkout
project_path = next(iter(manifest.paths.keys()))
project_worktree = topdir / project_path
subprocess.check_call(
["git", "config", "status.renames", "false"], cwd=project_worktree
)
subprocess.check_call(
["git", "mv", "README", "RENAMED"], cwd=project_worktree
)
with contextlib.redirect_stdout(io.StringIO()) as stdout:
_run_status(manifest, [])
lines = _status_lines(stdout.getvalue())
assert lines[1] == " R-\tREADME => RENAMED (100%)"
def test_detached_clean_status_is_suppressed(
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
) -> None:
"""A clean detached checkout keeps returning CLEAN without output."""
topdir, manifest = repo_client_checkout
project_path = next(iter(manifest.paths.keys()))
subprocess.check_call(
["git", "checkout", "-q", "--detach", "HEAD"],
cwd=topdir / project_path,
)
with contextlib.redirect_stdout(io.StringIO()) as stdout:
_run_status(manifest, [])
assert _status_lines(stdout.getvalue()) == [
"nothing to commit (working directory clean)"
]
def test_status_unmerged_path_matches_legacy_display(
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
) -> None:
"""Porcelain-v2 unmerged records render as index U and worktree u."""
topdir, manifest = repo_client_checkout
project_path = next(iter(manifest.paths.keys()))
worktree = topdir / project_path
subprocess.check_call(
["git", "checkout", "-q", "-b", "other"], cwd=worktree
)
(worktree / "README").write_text("other")
subprocess.check_call(["git", "commit", "-qam", "other"], cwd=worktree)
subprocess.check_call(["git", "checkout", "-q", "main"], cwd=worktree)
(worktree / "README").write_text("main")
subprocess.check_call(["git", "commit", "-qam", "main"], cwd=worktree)
merge = subprocess.run(
["git", "merge", "other"],
cwd=worktree,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
assert merge.returncode != 0
with contextlib.redirect_stdout(io.StringIO()) as stdout:
_run_status(manifest, [])
lines = _status_lines(stdout.getvalue())
assert lines[1] == " Uu\tREADME"
def test_status_with_orphans_and_modified_file(
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
) -> None:
@@ -304,6 +394,10 @@ def test_status_branch_ahead_of_upstream(
project_worktree = topdir / project_path
_setup_remote_tracking_branch(manifest, "feature")
subprocess.check_call(
["git", "config", "status.aheadBehind", "false"],
cwd=project_worktree,
)
subprocess.check_call(
["git", "commit", "-q", "--allow-empty", "-m", "c1"],
cwd=project_worktree,
@@ -451,3 +545,20 @@ def test_status_branch_synced_no_ahead_behind(
lines = _status_lines(stdout.getvalue())
assert len(lines) == 1
_assert_project_header(lines[0], project_path, "synced")
def test_status_non_utf8_path(
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
) -> None:
"""Non-UTF-8 pathnames render without crashing."""
topdir, manifest = repo_client_checkout
project_path = next(iter(manifest.paths.keys()))
project_worktree = topdir / project_path
bad_path = project_worktree / os.fsdecode(b"bad-\xff-name")
bad_path.write_bytes(b"content")
with contextlib.redirect_stdout(io.StringIO()) as stdout:
_run_status(manifest, [])
lines = _status_lines(stdout.getvalue())
assert any("bad-" in line for line in lines)
+161 -1
View File
@@ -13,6 +13,7 @@
# limitations under the License.
"""Unittests for the subcmds/sync.py module."""
import contextlib
import json
import optparse
import os
@@ -20,7 +21,7 @@ from pathlib import Path
import shutil
import tempfile
import time
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
import unittest
from unittest import mock
@@ -29,6 +30,7 @@ import pytest
import command
from error import GitError
from error import RepoExitError
import git_status
import manifest_xml
from project import SyncNetworkHalfResult
from subcmds import sync
@@ -214,6 +216,31 @@ def test_sync_update_projects_revision_id_respects_groups(tmp_path: Path):
assert kwargs.get("groups") == "group1"
@pytest.mark.parametrize(
"generate_manpages, expected_default",
[
(False, "7; based on number of CPU cores"),
(True, "based on number of CPU cores"),
],
ids=("interactive", "manpages"),
)
def test_jobs_checkout_help_default(
generate_manpages: bool,
expected_default: str,
) -> None:
"""Test checkout-jobs default help in interactive and manpage modes."""
with mock.patch.object(sync, "DEFAULT_LOCAL_JOBS", 7), mock.patch.object(
command,
"GENERATE_MANPAGES",
generate_manpages,
):
help_text = " ".join(sync.Sync().OptionParser.format_help().split())
assert f"defaults to --jobs or {expected_default}" in help_text
if generate_manpages:
assert "defaults to --jobs or 7" not in help_text
# Used to patch os.cpu_count() for reliable results.
OS_CPU_COUNT = 24
@@ -516,6 +543,7 @@ class FakeProject:
self.use_git_worktrees = False
self.UseAlternates = False
self.UseReprojectCmd = False
self.manifest = mock.MagicMock()
self.manifest.GetProjectsWithName.return_value = [self]
self.config = mock.MagicMock()
@@ -642,6 +670,27 @@ class SafeCheckoutOrder(unittest.TestCase):
)
class NestedProjects(unittest.TestCase):
def test_flat_manifest(self) -> None:
p_foo = FakeProject("foo")
p_foo_bar = FakeProject("foo-bar")
self.assertEqual(sync._NestedProjects([p_foo, p_foo_bar]), [])
def test_nested_paths(self) -> None:
p_foo = FakeProject("foo")
p_foo_bar = FakeProject("foo/bar")
p_foo_bar_baz = FakeProject("foo/bar/baz")
self.assertEqual(
sync._NestedProjects([p_foo_bar_baz, p_foo, p_foo_bar]),
[p_foo_bar, p_foo_bar_baz],
)
def test_submodule_of_a_parent(self) -> None:
parent = FakeProject("foo")
sub = FakeProject("foo/sub", parent=parent, is_derived=True)
self.assertEqual(sync._NestedProjects([parent, sub]), [sub])
class ParentFirstBatches(unittest.TestCase):
def test_no_submodules(self) -> None:
p_a = FakeProject("a")
@@ -941,6 +990,42 @@ class CheckForBloatedProjects(unittest.TestCase):
self.cmd.git_event_log = mock.MagicMock()
self.cmd._bloated_projects = []
def test_one_project_reuses_status_head_oid(self) -> None:
"""The bloat scan gets dirty state and HEAD from one snapshot."""
status = git_status.StatusSnapshot()
status.branch_oid = "local"
self.project._GetStatusSnapshot.return_value = status
self.project.GetRevisionId.return_value = "manifest"
self.project.bare_git.count_objects.return_value = (
"packs: 0\nsize-pack: 0\nsize-garbage: 0\n"
)
with mock.patch.object(
sync.Sync,
"get_parallel_context",
return_value={"projects": [self.project]},
):
self.assertIsNone(self.cmd._CheckOneBloatedProject(0))
self.project.IsDirty.assert_not_called()
self.project.work_git.rev_parse.assert_not_called()
self.project.bare_git.count_objects.assert_called_once_with("-v")
def test_one_unborn_project_skips_bloat_check(self) -> None:
"""A porcelain initial branch behaves like failed rev-parse HEAD."""
status = git_status.StatusSnapshot()
status.index_changes["staged"] = git_status.StatusEntry("staged", "M")
self.project._GetStatusSnapshot.return_value = status
with mock.patch.object(
sync.Sync,
"get_parallel_context",
return_value={"projects": [self.project]},
):
self.assertIsNone(self.cmd._CheckOneBloatedProject(0))
self.project.GetRevisionId.assert_not_called()
self.project.bare_git.count_objects.assert_not_called()
@mock.patch("subcmds.sync.git_require")
def test_git_version_unsupported(self, mock_git_require):
"""Test that it returns early if git version is unsupported."""
@@ -1083,7 +1168,10 @@ class SyncCommand(unittest.TestCase):
self.project = p = mock.MagicMock(
use_git_worktrees=False,
UseAlternates=False,
UseReprojectCmd=False,
name="project",
relpath="rel_path",
parent=None,
Sync_NetworkHalf=Sync_NetworkHalf,
Sync_LocalHalf=Sync_LocalHalf,
RelPath=mock.Mock(return_value="rel_path"),
@@ -1142,6 +1230,78 @@ class SyncCommand(unittest.TestCase):
_, kwargs = self.cmd.GetProjects.call_args
self.assertEqual(kwargs.get("groups"), "my_group")
def _ExecuteUntilSync(
self, args: List[str]
) -> Tuple[mock.MagicMock, mock.MagicMock]:
"""Run Execute up to the sync itself, returning the sync mocks."""
self.opt.mp_update = False
with contextlib.ExitStack() as stack:
for name in (
"_UpdateRepoProject",
"_UpdateProjectsRevisionId",
"_ValidateOptionsWithManifest",
"_RunPostSyncHook",
):
stack.enter_context(mock.patch.object(self.cmd, name))
phased = stack.enter_context(
mock.patch.object(self.cmd, "_SyncPhased")
)
interleaved = stack.enter_context(
mock.patch.object(self.cmd, "_SyncInterleaved")
)
self.cmd.Execute(self.opt, args)
return phased, interleaved
def test_reproject_cmd_allows_a_flat_manifest(self) -> None:
"""Ensure repo.reprojectcmd syncs a manifest without nesting."""
self.project.UseReprojectCmd = True
phased, interleaved = self._ExecuteUntilSync([])
self.assertTrue(phased.called or interleaved.called)
def test_reproject_cmd_rejects_nested_projects(self) -> None:
"""Ensure repo.reprojectcmd fails a manifest with nested projects."""
p_foo = FakeProject("foo")
p_foo_bar = FakeProject("foo/bar")
p_foo.UseReprojectCmd = p_foo_bar.UseReprojectCmd = True
self.cmd.GetProjects.return_value = [p_foo, p_foo_bar]
with self.assertRaises(sync.SyncError) as e:
self._ExecuteUntilSync([])
self.assertIn("foo/bar", str(e.exception))
self.assertNotIn(" - foo\n", str(e.exception))
def test_reproject_cmd_rejects_a_submodule(self) -> None:
"""Ensure repo.reprojectcmd fails a manifest with a submodule."""
p_foo = FakeProject("foo")
p_sub = FakeProject("foo/sub", parent=p_foo, is_derived=True)
p_foo.UseReprojectCmd = p_sub.UseReprojectCmd = True
self.cmd.GetProjects.return_value = [p_foo, p_sub]
with self.assertRaises(sync.SyncError) as e:
self._ExecuteUntilSync([])
self.assertIn("foo/sub", str(e.exception))
def test_reproject_cmd_checks_the_whole_manifest(self) -> None:
"""Ensure nesting is checked beyond the projects given as args."""
p_foo = FakeProject("foo")
p_foo_bar = FakeProject("foo/bar")
p_foo.UseReprojectCmd = p_foo_bar.UseReprojectCmd = True
self.cmd.GetProjects.side_effect = lambda args, **kwargs: (
[p_foo_bar] if args else [p_foo, p_foo_bar]
)
with self.assertRaises(sync.SyncError):
self._ExecuteUntilSync(["foo/bar"])
self.assertEqual(self.cmd.GetProjects.call_count, 2)
_, kwargs = self.cmd.GetProjects.call_args
self.assertEqual(kwargs.get("missing_ok"), True)
def test_reproject_cmd_off_ignores_nested_projects(self) -> None:
"""Ensure nesting is only checked with repo.reprojectcmd in use."""
projects = [FakeProject("foo"), FakeProject("foo/bar")]
for p in projects:
p.Exists = False
self.cmd.GetProjects.return_value = projects
phased, interleaved = self._ExecuteUntilSync([])
self.assertTrue(phased.called or interleaved.called)
class SyncUpdateRepoProject(unittest.TestCase):
"""Tests for Sync._UpdateRepoProject."""
+116
View File
@@ -14,6 +14,7 @@
"""Unittests for the subcmds/upload.py module."""
from typing import List, Optional
from unittest import mock
import pytest
@@ -117,3 +118,118 @@ def test_GatherOne_returns_resolved_current_branch(
assert upload.Upload._GatherOne(opt, 0) == (0, [branch], "topic")
project.GetUploadableBranch.assert_called_once_with("topic")
def _create_mock_branch(
name: str = "main",
commits: Optional[List[str]] = None,
project_relpath: str = "project-a",
) -> mock.MagicMock:
"""Helper to construct a mock ReviewableBranch."""
branch = mock.MagicMock()
branch.name = name
branch.commits = commits if commits is not None else ["commit1"]
project = mock.MagicMock()
project.RelPath.return_value = project_relpath
branch.project = project
return branch
def test_MultipleBranches_yes_with_current_branch_flag_bypasses_editor(
cmd: upload.Upload,
) -> None:
"""_MultipleBranches with --yes and -c flag bypasses editor."""
opt, _ = cmd.OptionParser.parse_args(["-c", "-y"])
branch1 = _create_mock_branch("b1", project_relpath="p1")
branch2 = _create_mock_branch("b2", project_relpath="p2")
pending = [(branch1.project, [branch1]), (branch2.project, [branch2])]
with mock.patch.object(cmd, "_UploadAndReport") as mock_upload, mock.patch(
"editor.Editor.EditString"
) as mock_edit:
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
mock_edit.assert_not_called()
mock_upload.assert_called_once_with(
opt, [branch1, branch2], _STUB_PEOPLE
)
def test_MultipleBranches_yes_with_cbr_flag_bypasses_editor(
cmd: upload.Upload,
) -> None:
"""_MultipleBranches with --yes and --cbr flag bypasses editor."""
opt, _ = cmd.OptionParser.parse_args(["--cbr", "--yes"])
branch1 = _create_mock_branch("b1", project_relpath="p1")
branch2 = _create_mock_branch("b2", project_relpath="p2")
pending = [(branch1.project, [branch1]), (branch2.project, [branch2])]
with mock.patch.object(cmd, "_UploadAndReport") as mock_upload, mock.patch(
"editor.Editor.EditString"
) as mock_edit:
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
mock_edit.assert_not_called()
mock_upload.assert_called_once_with(
opt, [branch1, branch2], _STUB_PEOPLE
)
def test_MultipleBranches_yes_with_branch_flag_bypasses_editor(
cmd: upload.Upload,
) -> None:
"""_MultipleBranches with --yes and --br flag bypasses editor."""
opt, _ = cmd.OptionParser.parse_args(["--br", "feature", "-y"])
branch1 = _create_mock_branch("feature", project_relpath="p1")
branch2 = _create_mock_branch("feature", project_relpath="p2")
pending = [(branch1.project, [branch1]), (branch2.project, [branch2])]
with mock.patch.object(cmd, "_UploadAndReport") as mock_upload, mock.patch(
"editor.Editor.EditString"
) as mock_edit:
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
mock_edit.assert_not_called()
mock_upload.assert_called_once_with(
opt, [branch1, branch2], _STUB_PEOPLE
)
def test_MultipleBranches_yes_with_branch_flag_empty_pending_dies(
cmd: upload.Upload,
) -> None:
"""_MultipleBranches with --yes and empty pending branches dies."""
opt, _ = cmd.OptionParser.parse_args(["--br", "feature", "-y"])
mock_project = mock.MagicMock()
pending = [(mock_project, [])]
with pytest.raises(
upload.UploadExitError, match="nothing ready for upload"
):
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
def test_MultipleBranches_yes_without_branch_or_cbr_uses_editor(
cmd: upload.Upload,
) -> None:
"""_MultipleBranches with -y but no -c/--br falls back to editor."""
opt, _ = cmd.OptionParser.parse_args(["-y"])
branch1 = _create_mock_branch("b1", project_relpath="p1")
branch1.date = "2026-08-26"
mock_remote = mock.MagicMock()
branch1.project.dest_branch = None
branch1.project.revisionExpr = "refs/heads/main"
branch_config = mock.MagicMock()
branch_config.remote = mock_remote
branch1.project.GetBranch.return_value = branch_config
pending = [(branch1.project, [branch1])]
edited_script = (
"project p1/:\n"
" branch b1 ( 1 commit, 2026-08-26) to remote branch "
"refs/heads/main:\n"
)
with mock.patch.object(cmd, "_UploadAndReport") as mock_upload, mock.patch(
"editor.Editor.EditString", return_value=edited_script
) as mock_edit:
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
mock_edit.assert_called_once()
mock_upload.assert_called_once_with(opt, [branch1], _STUB_PEOPLE)