Compare commits

..
26 Commits
Author SHA1 Message Date
Mike Frysinger d7c431b996 style: use import progress everywhere
Align with Google style guides and `import progress` as a module
rather than import symbols out of it.

Change-Id: I3ef9109af8c6883ac0df7fa19bdb88bc6cdfd5e9
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654818
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
2026-11-10 09:45:00 -08:00
Mike Frysinger 020767b9d3 style: use import wrapper everywhere
Align with Google style guides and `import wrapper` as a module
rather than import symbols out of it.

Change-Id: Ifc6165974a242a1ce38bd09b1b4226dc0ba943d5
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654817
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
2026-11-10 07:24:10 -08:00
Mike Frysinger 0f83e19bbb style: use import git_trace2_event_log_base everywhere
Align with Google style guides and `import git_trace2_event_log_base` as
a module rather than import symbols out of it.

Change-Id: I98c68a9ddca06325a8e0a620346c408e1ff3b661
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654816
Tested-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
2026-11-10 07:13:59 -08:00
Mike Frysinger 221a31cd0a style: use import git_trace2_event_log everywhere
Align with Google style guides and `import git_trace2_event_log` as a
module rather than import symbols out of it.

Change-Id: I4fdb0692ccbcf38ecdd756a4417c68f0cfaebc0c
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654815
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
2026-11-09 19:52:17 -08:00
Mike Frysinger ccddf51633 style: use import git_superproject everywhere
Align with Google style guides and `import git_superproject` as a module
rather than import symbols out of it.

Change-Id: I939f1123d5c6f79b56c244a5ffdf5bcfa136b2db
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654814
Commit-Queue: Mike Frysinger <vapier@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
2026-11-09 19:43:03 -08:00
Mike Frysinger 72e3c8c277 style: use import event_log everywhere
Align with Google style guides and `import event_log` as a module
rather than import symbols out of it.

Change-Id: I2c558f1161490b354f0b956ac0e86e658942c3a2
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654813
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
2026-11-09 19:06:10 -08:00
Mike Frysinger 4cef373d98 style: use import manifest_xml everywhere
Align with Google style guides and `import manifest_xml` as a module
rather than import symbols out of it.

Change-Id: Ib600a5ccaf53b178e27e6e9d56847751d266bfe4
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654812
Tested-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
2026-11-09 17:34:52 -08:00
Gavin Mak 95d7c8c0df sync: don't read target tree after reprojectcmd
After repo.reprojectcmd exits 0, _Reproject ran `git diff-index --cached
--name-only <revid>^{tree}` to verify that the index matches the target
commit's tree.

Peeling <revid>^{tree} and comparing every entry against the index
forces Git to read the target commit and walk every tree object in the
target revision from .git/objects.

reprojectcmd exists for virtualized filesystems such as ABFS whose
checkout command materializes the worktree and writes .git/index from
its own tree metadata without reading .git/objects/pack/*-trees-*.pack.
Running diff-index against <revid>^{tree} afterward forces Git to fault
in the tree packfiles over the network (260 FetchTrees pack downloads /
1.64 GiB across a 3,296-project Android checkout).

Trust reprojectcmd's exit status for index materialization instead of
re-walking the target tree in Git, while keeping the HEAD postcondition
check that verifies the command did not modify refs.

Bug: 543101585
Change-Id: Ie5feea41a4191245232c6763a489d01596370c55

Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/643004
Commit-Queue: Gavin Mak <gavinmak@google.com>
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
2026-11-09 15:15:49 -08:00
Gavin Mak 616396317c sync: verify fetchcmd postconditions without reading objects
After repo.fetchcmd exits, _CustomFetch verified the tracking ref and
FETCH_HEAD with ResolveCommit, i.e. `git rev-parse --verify
<ref>^{commit}`. Peeling to a commit makes Git look the object up: it
opens every pack index in objects/pack/ until it finds the commit, then
reads the commit from its packfile.

fetchcmd exists for object stores that are populated lazily, such as
ABFS, whose `abfs git fetch` registers packfiles as virtual files and
returns at once. There the peel is the first read of the packs the
command registered, and it blocks until they are downloaded. A project
whose revision did not change needs no objects to sync, yet paid that
download whenever fetchcmd registered new packs for it.

Compare the object IDs that the refs record instead (ResolveRef, which
runs the same rev-parse without the peel). Only when they differ peel
both sides to commits, so an annotated tag still matches the commit it
points to. A commit missing from the object store now fails the checkout
that reads it instead of the network half.

On a first sync the checkout reads the commit anyway, so there the
download only moves to the next command that needs the object.

Bug: 543101585
Change-Id: I51248ff1e9674b16e3ff8cee69572dbc8c7a8e99
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/643003
Commit-Queue: Gavin Mak <gavinmak@google.com>
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
2026-11-09 15:15:43 -08:00
Gavin Mak 4978203b07 sync: skip shallow bloat check when using fetchcmd
Skip projects fetched via repo.fetchcmd in _CheckForBloatedProjects,
and expose Project.UseFetchCmd alongside Project.UseReprojectCmd.

The bloat check exists because Project._RemoteFetch passes --no-auto-gc
for clone-depth="1" repositories. Projects using repo.fetchcmd bypass
_RemoteFetch and manage object storage externally, so scanning their
worktrees for bloat is unnecessary and recommending git repack can
materialize virtualized objects locally.

Bug: 543101585
Change-Id: I16510ad3b5b39c3a5e2a1a75df706439252a088e
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/655083
Tested-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
2026-11-09 15:03:09 -08:00
Mike Frysinger cc3f52cd6c style: use import git_config everywhere
Align with Google style guides and `import git_config` as a module
rather than import symbols out of it.

Change-Id: I467f1f8c0868c53b71917ca0a9610606e1ce4d83
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654811
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
2026-11-09 13:35:49 -08:00
John Cai e045aa19d8 Adding sparse checkout functionality
Sparse checkout allows Git to check out only a portion of the
repository. This is particularly helpful for monorepository workflows
where CI jobs only need a portion of the repository to run tests.

This speeds up the CI jobs, and puts less load on the Git server as it
relieves it from having to pack up all the objects.

In the Git project, though it is still labeled experimental, the feature
has been stable for a number of years and is unlikely to change at this
point.

This change allows configuring Git to use sparse checkout and provide a
list of directories for the sync command to retrieve.

Projects with sparse paths default to a blob:none partial clone so the
excluded paths are never fetched, unless a filter is already configured
or the project is excluded from partial clones.  Both the plain and the
git-worktree checkout flows configure sparse-checkout before populating
the working tree.

Change-Id: I772c7db5209b345306be378771c8f2040832bca1
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/535061
Reviewed-by: Gavin Mak <gavinmak@google.com>
Tested-by: John Cai <johncai86@gmail.com>
Commit-Queue: John Cai <johncai86@gmail.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
2026-11-09 11:14:00 -08:00
Mike Frysinger c382d3807a style: use import color everywhere
Align with Google style guides and `import color` as a module
rather than import symbols out of it.

Change-Id: Id3f1f8ea324097d6e1ea5ed33c67b4b024abce7f
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654810
Commit-Queue: Mike Frysinger <vapier@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
2026-11-09 09:07:27 -08:00
Mike Frysinger 9d01d216e0 style: use import test_manifest_xml everywhere
Align with Google style guides and `import test_manifest_xml` as a module
rather than import symbols out of it.

Change-Id: Ib85716bea2eb09028e05b86d445336405e1df870
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654809
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
2026-11-09 08:52:28 -08:00
Mike Frysinger 52f01a18da style: use import pager everywhere
Align with Google style guides and `import pager` as a module
rather than import symbols out of it.

Change-Id: I20ec5024ece35ae27bf46364e97c59400aae0cf0
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654808
Commit-Queue: Mike Frysinger <vapier@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
2026-11-09 07:55:40 -08:00
Mike Frysinger 3a3d1fc7cd style: use import editor everywhere
Align with Google style guides and `import editor` as a module
rather than import symbols out of it.

Change-Id: Ie941ff12f63c2b168bcec6510751e155156f26e7
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654807
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
2026-11-09 07:09:25 -08:00
Mike Frysinger 2029997aef style: use import io everywhere
Align with Google style guides and `import io` as a module
rather than import symbols out of it.

Change-Id: Ic3e78aa81ffc24687f61c7ce744e596fbdb13415
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654806
Commit-Queue: Mike Frysinger <vapier@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
2026-11-06 11:58:10 -08:00
Mike Frysinger caa4cd6564 style: use import collections everywhere
Align with Google style guides and `import collections` as a module
rather than import symbols out of it.

Change-Id: I165028d5b70a78492e361e6b6f9e3eea19d014b6
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654805
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
2026-11-06 11:57:56 -08:00
Mike Frysinger e15b3e0298 style: use import urllib.xxx everywhere
Align with Google style guides and `import urllib.xxx` as a module
rather than import symbols out of it.

Change-Id: Ia177f4fd5ee2a3f38f8a8860e9a2ba8beac8078f
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654804
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
2026-11-06 11:56:05 -08:00
Mike Frysinger a4b44c5026 style: use import types everywhere
Align with Google style guides and `import types` as a module
rather than import symbols out of it.

Change-Id: I20f8058eb1b5dafc1d2964c276e28b95b188bfff
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654803
Commit-Queue: Mike Frysinger <vapier@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
2026-11-06 10:29:39 -08:00
Mike Frysinger 8f2c0f1483 flake8: enable flake8-import-restrictions
Disable the warning across existing modules for now.  We'll need to
clean those up, but we can at least stop new bad modules from being
added to the tree.

Change-Id: I39be6450ecde86ce30553b6e277f870e0d3865c1
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654623
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
2026-11-05 11:03:12 -08:00
Mike Frysinger 7ed9700a95 flake8: fix nested import checks
Import modules at the top of the module in most cases.  Many of these
weren't actually saving any time:
* editor: shlex is already imported by the main module
* platform_utils: On Windows, the win32 module is almost always needed
* tests: There's no point in optimizing these.

The trace import in the main module is about the only one worth allowing
since that module itself is rarely used, and only when in a specific
debug mode.

Change-Id: I5d3fcecbe51e0ce78d4e856d7033565150ef4526
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/654103
Commit-Queue: Mike Frysinger <vapier@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
2026-11-05 10:50:14 -08:00
hungweichen d621aa8ee4 event_log: only record events when --event-log is specified
Add an enabled flag to EventLog (defaulting to False on
Command.event_log and enabled by _Repo._RunLong when --event-log is
passed) and return None early from EventLog.Add when disabled.

Because EventLog.AddSync only resolves project.GetCommitRevisionId()
when EventLog.Add returns an event, disabling Command.event_log by
default eliminates 3,300+ synchronous git subprocess calls in
MainProcess result callbacks during normal repo sync runs without
touching subcmds/sync.py.

Bug: 564817989
Change-Id: I79dd66d1c392ba5ee562f104759e36d1dd6eefea
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/649606
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
2026-11-05 08:19:19 -08:00
hungweichen b16e5f49d6 git_refs, project: skip redundant git calls on unborn HEAD and warm refs
Read plain files-backend unborn HEAD symrefs directly before spawning
git symbolic-ref, update already-loaded bare_ref snapshots in
_InitAnyMRef when no external ref changes occurred, and reuse warm
bare_ref snapshots in GetRevisionId for non-tag refs.

Also skip redundant rev-parse and empty-index ls-files subprocesses in
_Reproject when HEAD is guaranteed to be an unborn local ref.

Bug: 564817989
Change-Id: I0da10cefb14753ce6e1e22e192d652790d59a5b0
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/649605
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
2026-11-05 07:52:57 -08:00
hungweichen 6596cb6499 git_config, project: keep config cache coherent and skip init templates
Update .repo_config.json atomically with file metadata and keep cached
sections, remotes, and branches coherent across SetString, Branch.Save,
ClearCache, and Reload. Also reload Project.config in workers when
fetchcmd or reprojectcmd modifies .git/config externally so subsequent
reads hit the JSON cache instead of re-spawning git config --list in
MainProcess.

Also pass --template= to git init when no custom template directory is
configured so Git does not copy sample hooks only to delete them.

Bug: 564817989
Change-Id: I5ff655b0ab0b34a4f77f13617bbe5a1fff488c8c
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/649604
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
2026-11-05 07:43:32 -08:00
hungweichen ecb91f465e project: trace fetchcmd and reprojectcmd executions
Wrap repo.fetchcmd and repo.reprojectcmd subprocess invocations in
Trace blocks so TRACE_FILE records START/END spans for custom fetch and
reproject commands alongside standard Git commands.

Bug: 564817989
Change-Id: I68a4d863370e7c46d144e35e5826d01b386b9b07
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/649603
Commit-Queue: Gavin Mak <gavinmak@google.com>
Tested-by: Gavin Mak <gavinmak@google.com>
Reviewed-by: Haamed Gheibi <haamed@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
2026-11-04 13:10:24 -08:00
54 changed files with 3177 additions and 569 deletions

No files matched your search

+55 -1
View File
@@ -1,8 +1,54 @@
# https://flake8.pycqa.org/en/latest/user/configuration.html
[flake8]
max-line-length = 80
per-file-ignores =
# E501: line too long
tests/test_git_superproject.py: E501
tests/test_git_superproject.py: E501,
# IMR241: When using the from syntax, only submodules are imported, not module elements.
# NB: Do not add any more here.
command.py: IMR241,
git_status.py: IMR241,
main.py: IMR241,
manifest_xml.py: IMR241,
subcmds/abandon.py: IMR241,
subcmds/branches.py: IMR241,
subcmds/checkout.py: IMR241,
subcmds/cherry_pick.py: IMR241,
subcmds/diffmanifests.py: IMR241,
subcmds/diff.py: IMR241,
subcmds/download.py: IMR241,
subcmds/forall.py: IMR241,
subcmds/gc.py: IMR241,
subcmds/grep.py: IMR241,
subcmds/help.py: IMR241,
subcmds/info.py: IMR241,
subcmds/init.py: IMR241,
subcmds/list.py: IMR241,
subcmds/manifest.py: IMR241,
subcmds/overview.py: IMR241,
subcmds/prune.py: IMR241,
subcmds/rebase.py: IMR241,
subcmds/selfupdate.py: IMR241,
subcmds/smartsync.py: IMR241,
subcmds/stage.py: IMR241,
subcmds/start.py: IMR241,
subcmds/status.py: IMR241,
subcmds/sync.py: IMR241,
subcmds/upload.py: IMR241,
subcmds/version.py: IMR241,
subcmds/wipe.py: IMR241,
tests/e2e/test_failures.py: IMR241,
tests/e2e/test_groups.py: IMR241,
tests/e2e/test_init_sync.py: IMR241,
tests/e2e/test_manifest_regressions.py: IMR241,
tests/e2e/test_partial_clone.py: IMR241,
tests/e2e/test_sync_modes.py: IMR241,
tests/e2e/test_workflows.py: IMR241,
tests/test_command.py: IMR241,
tests/test_error.py: IMR241,
tests/test_main.py: IMR241,
tests/test_subcmds.py: IMR241,
tests/test_subcmds_sync.py: IMR241,
extend-ignore =
# E203: Whitespace before ':'
# See https://github.com/PyCQA/pycodestyle/issues/373
@@ -15,3 +61,11 @@ exclude =
.venv,
venv,
.tox,
# IMR241: When using the from syntax, only submodules are imported, not module elements.
# NB: Do not add more modules here without strong discussion. This is aligned
# with Google's Python style guide.
imr241_exclude =
collections.abc,
pathlib,
typing,
typing_extensions,
+2 -2
View File
@@ -20,7 +20,7 @@ import re
from typing import List, Optional, TYPE_CHECKING
import error
from event_log import EventLog
import event_log
import progress
@@ -57,7 +57,7 @@ class Command:
# provide event summary to callers. Only used by sync subcommand currently.
#
# NB: This is being replaced by git trace2 events. See git_trace2_event_log.
event_log = EventLog()
event_log = event_log.EventLog(enabled=False)
# Whether this command is a "common" one, i.e. whether the user would
# commonly use it or it's a more uncommon command. This is used by the help
+14 -9
View File
@@ -42,13 +42,13 @@ postconditions to be met:
1. When the manifest revision names a branch or tag, the mapped local tracking
ref (e.g. `refs/remotes/REPO_REMOTE/<branch>` for a branch, or
`refs/tags/<tag>` for a tag) must resolve to a valid commit in the object
store (`git rev-parse --verify <ref>^{commit}`), and must equal `REPO_TREV`
if `REPO_TREV` was non-empty.
2. `FETCH_HEAD` must resolve to the same target commit in the object store
(`git rev-parse --verify FETCH_HEAD^{commit}`).
3. The commit graph from the target commit must be reachable far enough to
compute merge bases with local branches.
`refs/tags/<tag>` for a tag) must name the target commit, and must equal
`REPO_TREV` if `REPO_TREV` was non-empty. A tag ref may name an annotated
tag of the target commit.
2. `FETCH_HEAD` must name the same target commit.
3. The target commit, and the commit graph from it far enough to compute
merge bases with local branches, must be readable from the object store.
They may be materialized lazily, when first read.
### Invariants
@@ -66,5 +66,10 @@ postconditions to be met:
* A non-zero exit status aborts the project's sync, and the command's stderr
is surfaced to the user.
* `repo` verifies the tracking ref and target reachability after exit 0. Any
mismatch is treated as a failure.
* After exit 0, `repo` checks postconditions 1 and 2 by comparing the object
IDs that the refs record (`git rev-parse --verify <ref>`), so it does not
read the object store during the network half of the sync. It peels the
IDs to commits only when they differ, as for an annotated tag. Any mismatch
is treated as a failure.
* A target commit missing from the object store fails the checkout that reads
it.
+69 -6
View File
@@ -74,7 +74,8 @@ following DTD:
<!ELEMENT project (annotation*,
project*,
copyfile*,
linkfile*)>
linkfile*,
sparse-path*)>
<!ATTLIST project name CDATA #REQUIRED>
<!ATTLIST project path CDATA #IMPLIED>
<!ATTLIST project remote IDREF #IMPLIED>
@@ -102,9 +103,13 @@ following DTD:
<!ATTLIST linkfile src CDATA #REQUIRED>
<!ATTLIST linkfile dest CDATA #REQUIRED>
<!ELEMENT sparse-path EMPTY>
<!ATTLIST sparse-path path CDATA #REQUIRED>
<!ELEMENT extend-project (annotation*,
copyfile*,
linkfile*)>
linkfile*,
sparse-path*)>
<!ATTLIST extend-project name CDATA #REQUIRED>
<!ATTLIST extend-project path CDATA #IMPLIED>
<!ATTLIST extend-project dest-path CDATA #IMPLIED>
@@ -424,10 +429,11 @@ attributes of an existing project without completely replacing the
existing project definition. This makes the local manifest more robust
against changes to the original manifest.
The `extend-project` element can also contain `annotation`, `copyfile`, and
`linkfile` child elements. These are added to the project's definition. A
`copyfile` or `linkfile` with a `dest` that already exists in the project
will overwrite the original.
The `extend-project` element can also contain `annotation`, `copyfile`,
`linkfile`, and `sparse-path` child elements. These are added to the project's
definition. A `copyfile` or `linkfile` with a `dest` that already exists in the
project will overwrite the original. There is no way to remove a `sparse-path`
that the original project definition declared.
Attribute `path`: If specified, limit the change to projects checked out
at the specified path, rather than all projects with the given name.
@@ -471,6 +477,63 @@ attribute "keep" which accepts the case insensitive values "true"
(default) or "false". This attribute determines whether or not the
annotation will be kept when exported with the manifest subcommand.
### Element sparse-path
Zero or more sparse-path elements may be specified as children of a
project element or an extend-project element. Each element specifies a
path to include in the working directory.
Requires Git 2.25.0 or later, which is when `git sparse-checkout` and cone mode
were introduced. Declaring a sparse-path with an older Git fails the sync for
that project.
Declaring at least one sparse-path is what enables Git sparse-checkout for
the project: the listed paths are checked out, and other directories are left
out of the working directory. Cone mode also checks out the files sitting
directly in the project's top-level directory and in each leading directory of
a sparse-path, so `src/backend` brings along the top-level files and the files
directly under `src/` too. A project with no sparse-path elements is checked
out in full.
The checkout uses [cone mode](https://git-scm.com/docs/git-sparse-checkout#_internalscone_mode_handling)
for better performance, which means only directory paths are supported;
individual files and globs are not. Cone mode does no pattern matching, so `*`,
`?`, `[`, `]`, and `\` are not glob syntax in a `path`. In fact, Git 2.36.0 and
later reject them with `specify directories rather than patterns`, which
surfaces as a sync failure for the project. Earlier versions starting with
2.25.0 have no such check and are treated as literals.
Sparse-checkout is configured only when the project's working directory is
first created. Changes to `sparse-path` in the manifest (added, changed, or
removed) are not applied to a project that is already checked out. To change
the paths in an existing checkout, run `git sparse-checkout` in the project
directly, or remove the project's working directory and sync again.
Because sparse-checkout only prunes the working directory, a project with at
least one sparse-path is fetched with `--filter=blob:none` so that blobs which
will never be checked out are not downloaded. Without the filter Git still
downloads every blob in the project and sparse-checkout merely declines to
write most of them to disk, so the filter is what turns sparse-checkout into a
saving in transfer size and server load. The filter from
`repo init --partial-clone --clone-filter` takes precedence, and projects listed
in `repo init --partial-clone-exclude` are fetched in full as usual. Like
sparse-checkout itself, the implied filter only applies when the project is
first fetched (or is already a partial clone), so adding a sparse-path to an
existing full checkout does not convert it to a partial clone. Mirrors are never
filtered.
Because that filter is only useful if the objects it excludes are not fetched
by some other route, a project that takes the implied `--filter=blob:none` also
skips the `/clone.bundle` download: the bundle is an unfiltered pack of every
object, so fetching it would undo the filter. Projects that set the filter
explicitly, or that are listed in `repo init --partial-clone-exclude`, keep
using the clone bundle.
Attribute `path`: A path relative to the project root to include in the
sparse-checkout. The path is validated like `<copyfile>` and `<linkfile>`
paths: it must stay inside the project, so absolute paths, `.` or `..`
components, `~`, and `.git` or `.repo*` components are all rejected. A trailing
`/` is allowed.
### Element copyfile
Zero or more copyfile elements may be specified as children of a
+3 -2
View File
@@ -91,8 +91,9 @@ Detecting collisions with untracked files or unstaged working-tree modifications
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).
1. The index matches `REPO_TREV`'s tree (`repo` trusts the command's exit
status and does not run `git diff-index` against `REPO_TREV^{tree}`, which
would read every tree object from `.git/objects`).
2. `HEAD` still names what it did before the command, and its resolved commit
object ID has not changed.
+1 -2
View File
@@ -14,6 +14,7 @@
import os
import re
import shlex
import subprocess
import sys
import tempfile
@@ -89,8 +90,6 @@ least one of these before using this command.""", # noqa: E501
if platform_utils.isWindows():
# Split on spaces, respecting quoted strings
import shlex
args = shlex.split(editor)
shell = False
elif re.compile("^.*[$ \t'].*$").match(editor):
+11 -3
View File
@@ -47,10 +47,15 @@ class EventLog:
Specific tasks may include additional informational properties.
"""
def __init__(self):
"""Initializes the event log."""
def __init__(self, enabled: bool = True) -> None:
"""Initializes the event log.
Args:
enabled: Whether event logging is enabled.
"""
self._log = []
self._parent = None
self.enabled = enabled
def Add(
self,
@@ -74,8 +79,11 @@ class EventLog:
kind: The kind of the object for the unique identifier.
Returns:
A dictionary of the event added to the log.
A dictionary of the event added to the log, or None if disabled.
"""
if not self.enabled:
return None
event = {
"id": (kind, _NextEventId()),
"name": name,
+4 -4
View File
@@ -16,8 +16,8 @@
import subprocess
import sys
from urllib.parse import urlparse
from urllib.request import urlopen
import urllib.parse
import urllib.request
import error
@@ -32,7 +32,7 @@ def fetch_file(url, verbose=False):
Returns:
The contents of the file as bytes.
"""
scheme = urlparse(url).scheme
scheme = urllib.parse.urlparse(url).scheme
if scheme == "gs":
cmd = ["gcloud", "storage", "cat", url]
errors = []
@@ -54,5 +54,5 @@ def fetch_file(url, verbose=False):
file=sys.stderr,
)
raise FetchFileError(aggregate_errors=errors)
with urlopen(url) as f:
with urllib.request.urlopen(url) as f:
return f.read()
+4 -4
View File
@@ -21,11 +21,11 @@ import sys
from typing import Any, Optional
import error
from git_trace2_event_log_base import BaseEventLog
import git_trace2_event_log_base
import platform_utils
import repo_logging
import repo_trace
from wrapper import Wrapper
import wrapper
GIT = "git"
@@ -46,7 +46,7 @@ logger = repo_logging.RepoLogger(__file__)
class _GitCall:
@functools.lru_cache(maxsize=None) # noqa: B019
def version_tuple(self):
ret = Wrapper().ParseGitVersion()
ret = wrapper.Wrapper().ParseGitVersion()
if ret is None:
msg = "fatal: unable to detect git version"
logger.error(msg)
@@ -347,7 +347,7 @@ class GitCommand:
command.extend(cmdv[1:])
event_log = (
BaseEventLog(env=env, add_init_count=True)
git_trace2_event_log_base.BaseEventLog(env=env, add_init_count=True)
if add_event_log
else None
)
+169 -32
View File
@@ -15,6 +15,7 @@
import contextlib
import datetime
import errno
import hashlib
import http.client
import json
import os
@@ -22,7 +23,8 @@ import re
import ssl
import subprocess
import sys
from typing import Union
import tempfile
from typing import Optional, Tuple, Union
import urllib.error
import urllib.request
@@ -41,6 +43,9 @@ ID_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
REVIEW_CACHE = {}
# Metadata key stored in .repo_config.json to detect same-tick config edits.
_JSON_META_KEY = "__repo_meta__"
def IsChange(rev):
return rev.startswith(git_refs.R_CHANGES)
@@ -161,6 +166,7 @@ class GitConfig:
self.file = str(configfile)
self.defaults = defaults
self._cache_dict = None
self._cache_fingerprint = None
self._section_dict = None
self._remotes = {}
self._branches = {}
@@ -175,6 +181,20 @@ class GitConfig:
def ClearCache(self):
"""Clear the in-memory cache of config."""
self._cache_dict = None
self._cache_fingerprint = None
self._section_dict = None
self._remotes = {}
self._branches = {}
def _RemoveJson(self) -> None:
with contextlib.suppress(OSError):
platform_utils.remove(self._json, missing_ok=True)
def Reload(self) -> None:
"""Clear cached state and reload configuration from disk."""
self.ClearCache()
self._RemoveJson()
_ = self._cache
def Has(self, name, include_defaults=True):
"""Return true if this configuration file has the key."""
@@ -292,6 +312,13 @@ class GitConfig:
"""
key = _key(name)
if (
self._cache_dict is not None
and self._ReadFileFingerprint() != self._cache_fingerprint
):
self.ClearCache()
self._RemoveJson()
try:
old = self._cache[key]
except KeyError:
@@ -306,8 +333,9 @@ class GitConfig:
if value is None:
if old:
self._do("--unset-all", name, allow_missing=True)
del self._cache[key]
self._do("--unset-all", name)
self._SyncCacheAfterSet(key)
elif isinstance(value, list):
if len(value) == 0:
@@ -317,14 +345,48 @@ class GitConfig:
self.SetString(name, value[0])
elif old != value:
self._cache[key] = list(value)
self._do("--replace-all", name, value[0])
for i in range(1, len(value)):
self._do("--add", name, value[i])
try:
for i in range(1, len(value)):
self._do("--add", name, value[i])
except BaseException:
self.ClearCache()
self._RemoveJson()
raise
self._cache[key] = list(value)
self._SyncCacheAfterSet(key)
elif len(old) != 1 or old[0] != value:
self._cache[key] = [value]
self._do("--replace-all", name, value)
self._cache[key] = [value]
self._SyncCacheAfterSet(key)
def _SyncCacheAfterSet(self, key: str) -> None:
"""Keep the in-memory section index and JSON cache coherent.
Args:
key: Canonicalized configuration key that was modified.
"""
self._section_dict = None
if key.startswith("remote."):
self._remotes = {}
self._branches = {}
elif key.startswith("branch."):
self._branches = {}
has_includes = self._cache_dict is not None and (
"include.path" in self._cache_dict
or any(k.startswith("includeif.") for k in self._cache_dict)
)
if (
key == "include.path"
or key.startswith("includeif.")
or has_includes
):
self.ClearCache()
self._RemoveJson()
else:
self._SaveJson(self._cache)
def GetRemote(self, name):
"""Get the remote.$name.* configuration values as an object."""
@@ -417,27 +479,109 @@ class GitConfig:
self._SaveJson(d)
return d
def _ReadFileFingerprint(self) -> Optional[Tuple[int, int, int, int, str]]:
"""Return (mtime_ns, ctime_ns, size, ino, sha1_hex) for self.file."""
try:
with open(self.file, "rb") as fd:
raw_cfg = fd.read()
st = os.fstat(fd.fileno())
except OSError:
return None
return (
st.st_mtime_ns,
st.st_ctime_ns,
st.st_size,
st.st_ino,
hashlib.sha1(raw_cfg).hexdigest(),
)
def _ReadJson(self):
try:
if os.path.getmtime(self._json) <= os.path.getmtime(self.file):
platform_utils.remove(self._json)
return None
cfg_stat = os.stat(self.file)
json_stat = os.stat(self._json)
except OSError:
return None
if json_stat.st_mtime_ns < cfg_stat.st_mtime_ns:
self._RemoveJson()
return None
try:
with repo_trace.Trace(": parsing %s", self.file):
with open(self._json) as fd:
return json.load(fd)
with open(self._json, encoding="utf-8") as fd:
data = json.load(fd)
except (OSError, ValueError):
platform_utils.remove(self._json, missing_ok=True)
self._RemoveJson()
return None
if not isinstance(data, dict):
self._RemoveJson()
return None
meta = data.pop(_JSON_META_KEY, None)
if (
isinstance(meta, list)
and len(meta) == 2
and isinstance(meta[1], str)
):
if (
json_stat.st_mtime_ns == cfg_stat.st_mtime_ns
or cfg_stat.st_size != meta[0]
):
if cfg_stat.st_size != meta[0]:
self._RemoveJson()
return None
fp = self._ReadFileFingerprint()
if fp is None:
return None
if fp[4] != meta[1]:
self._RemoveJson()
return None
self._cache_fingerprint = fp
else:
self._cache_fingerprint = (
cfg_stat.st_mtime_ns,
cfg_stat.st_ctime_ns,
cfg_stat.st_size,
cfg_stat.st_ino,
meta[1],
)
elif json_stat.st_mtime_ns <= cfg_stat.st_mtime_ns:
self._RemoveJson()
return None
else:
fp = self._ReadFileFingerprint()
if fp is None:
return None
self._cache_fingerprint = fp
return data
def _SaveJson(self, cache):
fp = self._ReadFileFingerprint()
if fp is None:
self._cache_fingerprint = None
self._RemoveJson()
return
self._cache_fingerprint = fp
payload = dict(cache)
payload[_JSON_META_KEY] = [fp[2], fp[4]]
json_dir = os.path.dirname(self._json) or "."
tmp_path = None
try:
with open(self._json, "w") as fd:
json.dump(cache, fd, indent=2)
with tempfile.NamedTemporaryFile(
"w",
dir=json_dir,
prefix=f".tmp{os.path.basename(self._json)}.",
delete=False,
encoding="utf-8",
) as fd:
tmp_path = fd.name
json.dump(payload, fd, indent=2)
os.replace(tmp_path, self._json)
except (OSError, TypeError):
platform_utils.remove(self._json, missing_ok=True)
if tmp_path is not None:
with contextlib.suppress(OSError):
platform_utils.remove(tmp_path, missing_ok=True)
self._RemoveJson()
def _ReadGit(self):
"""
@@ -448,6 +592,7 @@ class GitConfig:
"""
c = {}
if not os.path.exists(self.file):
self._cache_fingerprint = None
return c
d = self._do("--null", "--list")
@@ -463,9 +608,10 @@ class GitConfig:
else:
c[key] = [val]
self._cache_fingerprint = self._ReadFileFingerprint()
return c
def _do(self, *args):
def _do(self, *args, allow_missing: bool = False) -> str:
if self.file == self._SYSTEM_CONFIG:
command = ["config", "--system", "--includes"]
else:
@@ -475,10 +621,10 @@ class GitConfig:
p = git_command.GitCommand(
None, command, capture_stdout=True, capture_stderr=True
)
if p.Wait() == 0:
rc = p.Wait()
if rc == 0 or (allow_missing and rc == 5):
return p.stdout
else:
raise error.GitError(f"git {' '.join(command)}: {p.stderr}")
raise error.GitError(f"git {' '.join(command)}: {p.stderr}")
class RepoConfig(GitConfig):
@@ -814,20 +960,11 @@ class Branch:
def Save(self):
"""Save this branch back into the configuration."""
if self._config.HasSection("branch", self.name):
if self.remote:
self._Set("remote", self.remote.name)
else:
self._Set("remote", None)
self._Set("merge", self.merge)
if self.remote:
self._Set("remote", self.remote.name)
else:
with open(self._config.file, "a") as fd:
fd.write('[branch "%s"]\n' % self.name)
if self.remote:
fd.write("\tremote = %s\n" % self.remote.name)
if self.merge:
fd.write("\tmerge = %s\n" % self.merge)
self._Set("remote", None)
self._Set("merge", self.merge)
def _Set(self, key, value):
key = f"branch.{self.name}.{key}"
+123 -16
View File
@@ -27,6 +27,8 @@ R_PUB = "refs/published/"
R_WORKTREE = "refs/worktree/"
R_WORKTREE_M = R_WORKTREE + "m/"
R_M = "refs/remotes/m/"
# Maximum symbolic-ref indirection depth (matches Git SYMREF_MAXDEPTH).
_SYMREF_MAXDEPTH = 5
class GitRefs:
@@ -76,6 +78,101 @@ class GitRefs:
except KeyError:
return ""
def UpdateSymref(self, name: str, target: str) -> None:
"""Update a symbolic reference in an already-loaded ref snapshot.
Args:
name: Full symbolic reference name (e.g. "refs/remotes/m/main").
target: Target reference name that the symref points to.
"""
if self._phyref is None:
return
if self._HasExternalRefChanges(name):
self._phyref = None
return
self._symref[name] = target
self._ResolveSymrefs()
parts = name.strip("/").split("/")
for idx in range(1, len(parts)):
anc = os.path.join(*parts[:idx])
self._TrackMtime(anc)
self._TrackMtime(os.path.join(*parts))
def _HasExternalRefChanges(self, updated_symref: str) -> bool:
"""Check whether refs outside |updated_symref| changed on disk.
Args:
updated_symref: Symbolic ref path just updated on disk.
Returns:
True if reftable is in use or any unrelated ref path changed.
"""
if os.path.exists(os.path.join(self._gitdir, "reftable")):
return True
if "packed-refs" not in self._mtime and os.path.exists(
os.path.join(self._gitdir, "packed-refs")
):
return True
parts = updated_symref.strip("/").split("/")
expected_child = {
"/".join(parts[:idx]): parts[idx] for idx in range(1, len(parts))
}
for tracked, mtime in self._mtime.items():
norm = tracked.replace(os.sep, "/")
if norm == updated_symref:
continue
full_path = os.path.join(self._gitdir, tracked)
try:
curr_mtime = os.path.getmtime(full_path)
except OSError:
return True
if norm in expected_child:
if curr_mtime != mtime:
try:
children = platform_utils.listdir(full_path)
except OSError:
return True
next_comp = expected_child[norm]
for child in children:
if child == next_comp:
continue
if os.path.join(tracked, child) not in self._mtime:
return True
elif curr_mtime != mtime:
return True
for anc, next_comp in expected_child.items():
anc_os = anc.replace("/", os.sep)
if anc_os not in self._mtime:
anc_full = os.path.join(self._gitdir, anc_os)
try:
if platform_utils.listdir(anc_full) != [next_comp]:
return True
except OSError:
return True
return False
def _ResolveSymrefs(self) -> None:
"""Resolve all symbolic references into _phyref up to max depth."""
for name in self._symref:
self._phyref.pop(name, None)
scan = self._symref
attempts = 0
while scan and attempts < _SYMREF_MAXDEPTH:
scan_next = {}
for name, dest in scan.items():
if dest in self._phyref:
self._phyref[name] = self._phyref[dest]
else:
scan_next[name] = dest
scan = scan_next
attempts += 1
def _EnsureLoaded(self):
if self._phyref is None or self._NeedUpdate():
self._LoadAll()
@@ -105,23 +202,17 @@ class GitRefs:
# --include-root-refs does not report an unborn HEAD.
self._ReadSymbolicRef(HEAD)
scan = self._symref
attempts = 0
while scan and attempts < 5:
scan_next = {}
for name, dest in scan.items():
if dest in self._phyref:
self._phyref[name] = self._phyref[dest]
else:
scan_next[name] = dest
scan = scan_next
attempts += 1
self._ResolveSymrefs()
self._RefreshMtimes()
self._TrackMtime(HEAD)
self._TrackMtime("config")
self._TrackMtime("packed-refs")
self._TrackTreeMtimes("refs")
self._TrackTreeMtimes("reftable")
def _RefreshMtimes(self) -> None:
"""Record current modification times for ref storage paths."""
self._mtime = {}
self._TrackMtime(HEAD)
self._TrackMtime("config")
self._TrackMtime("packed-refs")
self._TrackTreeMtimes("refs")
self._TrackTreeMtimes("reftable")
@staticmethod
def _IsNullRef(ref_id: str) -> bool:
@@ -165,6 +256,22 @@ class GitRefs:
def _ReadSymbolicRef(self, name: str) -> None:
"""Read a symbolic reference."""
if name == HEAD:
path = os.path.join(self._gitdir, HEAD)
try:
if not platform_utils.islink(path):
with open(
path, "r", encoding="utf-8", errors="replace"
) as fd:
line = fd.readline().strip()
if line.startswith("ref:"):
ref = line[4:].strip()
if ref and ref != R_HEADS + ".invalid":
self._symref[name] = ref
return
except OSError:
pass
p = git_command.GitCommand(
None,
["symbolic-ref", "-q", name],
+3 -3
View File
@@ -14,7 +14,7 @@
"""Read a worktree's state from one machine-readable git status snapshot."""
from collections import OrderedDict
import collections
import os
from typing import Iterator, List, Optional, TYPE_CHECKING
@@ -45,8 +45,8 @@ class StatusSnapshot:
"""A consistent view of worktree, index, and branch state."""
def __init__(self) -> None:
self.index_changes = OrderedDict()
self.worktree_changes = OrderedDict()
self.index_changes = collections.OrderedDict()
self.worktree_changes = collections.OrderedDict()
self.untracked = []
self.branch_oid = None
self.branch_head = None
+5 -6
View File
@@ -33,8 +33,7 @@ from typing import NamedTuple
import urllib.parse
import git_command
from git_config import IsId
from git_config import RepoConfig
import git_config
import platform_utils
@@ -324,7 +323,7 @@ class Superproject:
# creating a local branch of the same name.
refspec = (
self.revision
if IsId(self.revision)
if git_config.IsId(self.revision)
else f"{self.revision}:{self.revision}"
)
cmd.append(refspec)
@@ -429,7 +428,7 @@ class Superproject:
if not self._Init():
return SyncResult(False, should_exit)
if IsId(self.revision) and self.commit_id:
if git_config.IsId(self.revision) and self.commit_id:
return SyncResult(True, False)
if not self._Fetch():
return SyncResult(False, should_exit)
@@ -568,7 +567,7 @@ class Superproject:
@functools.lru_cache(maxsize=None)
def _UseSuperprojectFromConfiguration():
"""Returns the user choice of whether to use superproject."""
user_cfg = RepoConfig.ForUser()
user_cfg = git_config.RepoConfig.ForUser()
time_now = int(time.time())
user_value = user_cfg.GetBoolean("repo.superprojectChoice")
@@ -602,7 +601,7 @@ def _UseSuperprojectFromConfiguration():
return user_value
# We don't have an unexpired choice, ask for one.
system_cfg = RepoConfig.ForSystem()
system_cfg = git_config.RepoConfig.ForSystem()
system_value = system_cfg.GetBoolean("repo.superprojectChoice")
if system_value:
# The system configuration is proposing that we should enable the
+2 -2
View File
@@ -15,10 +15,10 @@
"""Event logging in the git trace2 EVENT format."""
import git_command
from git_trace2_event_log_base import BaseEventLog
import git_trace2_event_log_base
class EventLog(BaseEventLog):
class EventLog(git_trace2_event_log_base.BaseEventLog):
"""Event log that records events that occurred during a repo invocation.
Events are written to the log as a consecutive JSON entries, one per line.
+32 -32
View File
@@ -41,23 +41,21 @@ try:
except ImportError:
kerberos = None
from color import SetDefaultColoring
import color
from command import InteractiveCommand
from command import MirrorSafeCommand
from editor import Editor
import editor
import error
import event_log
import git_command
from git_config import RepoConfig
from git_trace2_event_log import EventLog
from manifest_xml import RepoClient
from pager import RunPager
from pager import TerminatePager
import git_config
import git_trace2_event_log
import manifest_xml
import pager
import repo_trace
from subcmds import all_commands
from subcmds.version import Version
from wrapper import Wrapper
from wrapper import WrapperPath
import wrapper
logger = repo_logging.RepoLogger(__file__)
@@ -195,7 +193,7 @@ class _Repo:
help_commands = "".join(f"\n {x}" for x in wrapped_commands)
print(f"Available commands:{help_commands}")
print("\nRun `repo help <command>` for command-specific details.")
print("Bug reports:", Wrapper().BUG_URL)
print("Bug reports:", wrapper.Wrapper().BUG_URL)
else:
cmd = self.commands["help"]()
if all_commands:
@@ -230,9 +228,9 @@ class _Repo:
return name, []
key = f"alias.{name}"
alias = RepoConfig.ForRepository(self.repodir).GetString(key)
alias = git_config.RepoConfig.ForRepository(self.repodir).GetString(key)
if alias is None:
alias = RepoConfig.ForUser().GetString(key)
alias = git_config.RepoConfig.ForUser().GetString(key)
if alias is None:
return name, []
@@ -263,18 +261,18 @@ class _Repo:
self._PrintHelp(short=True)
return 1
git_trace2_event_log = EventLog()
run = (
lambda: self._RunLong(name, gopts, argv, git_trace2_event_log) or 0
)
git_event_log = git_trace2_event_log.EventLog()
run = lambda: self._RunLong(name, gopts, argv, git_event_log) or 0
with repo_trace.Trace(
"starting new command: %s [sid=%s]",
", ".join([name] + argv),
git_trace2_event_log.full_sid,
git_event_log.full_sid,
first_trace=True,
):
if gopts.trace_python:
import trace
# This is a debug module that is not otherwise used, so don't
# import it all the time.
import trace # noqa: IMR200
tracer = trace.Trace(
count=False,
@@ -288,7 +286,7 @@ class _Repo:
return result
def _autocorrect_command_name(
self, name: str, config: RepoConfig
self, name: str, config: git_config.RepoConfig
) -> Optional[str]:
"""Autocorrect command name based on user's git config."""
@@ -386,12 +384,12 @@ class _Repo:
def _RunLong(self, name, gopts, argv, git_trace2_event_log):
"""Execute the (longer running) requested subcommand."""
result = 0
SetDefaultColoring(gopts.color)
color.SetDefaultColoring(gopts.color)
outer_client = RepoClient(self.repodir)
outer_client = manifest_xml.RepoClient(self.repodir)
repo_client = outer_client
if gopts.submanifest_path:
repo_client = RepoClient(
repo_client = manifest_xml.RepoClient(
self.repodir,
submanifest_path=gopts.submanifest_path,
outer_client=outer_client,
@@ -413,8 +411,9 @@ class _Repo:
outer_manifest=outer_client.manifest,
git_event_log=git_trace2_event_log,
)
cmd.event_log.enabled = bool(gopts.event_log)
Editor.globalConfig = cmd.client.globalConfig
editor.Editor.globalConfig = cmd.client.globalConfig
if not isinstance(cmd, MirrorSafeCommand) and cmd.manifest.IsMirror:
logger.error("fatal: '%s' requires a working directory", name)
@@ -442,7 +441,7 @@ class _Repo:
if use_pager is None:
use_pager = cmd.WantPager(copts)
if use_pager:
RunPager(config)
pager.RunPager(config)
start = time.time()
cmd_event = cmd.event_log.Add(name, event_log.TASK_COMMAND, start)
@@ -588,9 +587,10 @@ class _Repo:
file=sys.stderr,
)
cmd.event_log.FinishEvent(
cmd_event, finish, result is None or result == 0
)
if cmd_event is not None:
cmd.event_log.FinishEvent(
cmd_event, finish, result is None or result == 0
)
git_trace2_event_log.DefParamRepoEvents(
cmd.manifest.manifestProject.config.DumpConfigDict()
)
@@ -627,7 +627,7 @@ def _CheckWrapperVersion(ver_str, repo_path):
sys.exit(1)
# Pull out the version of the repo launcher we know about to compare.
exp = Wrapper().VERSION
exp = wrapper.Wrapper().VERSION
ver = tuple(map(int, ver_str.split(".")))
exp_str = ".".join(map(str, exp))
@@ -645,7 +645,7 @@ repo: error:
ver_str,
min_str,
exp_str,
WrapperPath(),
wrapper.WrapperPath(),
repo_path,
)
sys.exit(1)
@@ -660,7 +660,7 @@ repo: error:
... You should upgrade soon:
cp %s %s
""",
WrapperPath(),
wrapper.WrapperPath(),
repo_path,
)
else:
@@ -671,7 +671,7 @@ repo: error:
!!! The launcher is not writable. Please talk to your sysadmin or distro
!!! to get an update installed.
""",
WrapperPath(),
wrapper.WrapperPath(),
repo_path,
)
@@ -955,7 +955,7 @@ def _Main(argv):
print("fatal: %s" % e, file=sys.stderr)
result = 128
TerminatePager()
pager.TerminatePager()
sys.exit(result)
+68 -7
View File
@@ -1,5 +1,5 @@
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
.TH REPO "1" "August 2026" "repo manifest" "Repo Manual"
.TH REPO "1" "October 2026" "repo manifest" "Repo Manual"
.SH NAME
repo \- repo manifest - manual page for repo manifest
.SH SYNOPSIS
@@ -149,7 +149,8 @@ include*)>
<!ELEMENT project (annotation*,
project*,
copyfile*,
linkfile*)>
linkfile*,
sparse\-path*)>
.TP
<!ATTLIST project name
CDATA #REQUIRED>
@@ -201,10 +202,14 @@ CDATA #IMPLIED>
<!ELEMENT linkfile EMPTY>
<!ATTLIST linkfile src CDATA #REQUIRED>
<!ATTLIST linkfile dest CDATA #REQUIRED>
.IP
<!ELEMENT sparse\-path EMPTY>
<!ATTLIST sparse\-path path CDATA #REQUIRED>
.TP
<!ELEMENT extend\-project (annotation*,
copyfile*,
linkfile*)>
linkfile*,
sparse\-path*)>
.TP
<!ATTLIST extend\-project name
CDATA #REQUIRED>
@@ -514,10 +519,11 @@ of an existing project without completely replacing the existing project
definition. This makes the local manifest more robust against changes to the
original manifest.
.PP
The `extend\-project` element can also contain `annotation`, `copyfile`, and
`linkfile` child elements. These are added to the project's definition. A
`copyfile` or `linkfile` with a `dest` that already exists in the project will
overwrite the original.
The `extend\-project` element can also contain `annotation`, `copyfile`,
`linkfile`, and `sparse\-path` child elements. These are added to the project's
definition. A `copyfile` or `linkfile` with a `dest` that already exists in the
project will overwrite the original. There is no way to remove a `sparse\-path`
that the original project definition declared.
.PP
Attribute `path`: If specified, limit the change to projects checked out at the
specified path, rather than all projects with the given name.
@@ -561,6 +567,61 @@ insensitive values "true" (default) or "false". This attribute determines
whether or not the annotation will be kept when exported with the manifest
subcommand.
.PP
Element sparse\-path
.PP
Zero or more sparse\-path elements may be specified as children of a project
element or an extend\-project element. Each element specifies a path to include
in the working directory.
.PP
Requires Git 2.25.0 or later, which is when `git sparse\-checkout` and cone mode
were introduced. Declaring a sparse\-path with an older Git fails the sync for
that project.
.PP
Declaring at least one sparse\-path is what enables Git sparse\-checkout for the
project: the listed paths are checked out, and other directories are left out of
the working directory. Cone mode also checks out the files sitting directly in
the project's top\-level directory and in each leading directory of a
sparse\-path, so `src/backend` brings along the top\-level files and the files
directly under `src/` too. A project with no sparse\-path elements is checked out
in full. The checkout uses [cone
mode](https://git\-scm.com/docs/git\-sparse\-checkout#_internalscone_mode_handling)
for better performance, which means only directory paths are supported;
individual files and globs are not. Cone mode does no pattern matching, so `*`,
`?`, `[`, `]`, and `\e` are not glob syntax in a `path`. In fact, Git 2.36.0 and
later reject them with `specify directories rather than patterns`, which
surfaces as a sync failure for the project. Earlier versions starting with
2.25.0 have no such check and are treated as literals.
.PP
Sparse\-checkout is configured only when the project's working directory is first
created. Changes to `sparse\-path` in the manifest (added, changed, or removed)
are not applied to a project that is already checked out. To change the paths in
an existing checkout, run `git sparse\-checkout` in the project directly, or
remove the project's working directory and sync again.
.PP
Because sparse\-checkout only prunes the working directory, a project with at
least one sparse\-path is fetched with `\-\-filter=blob:none` so that blobs which
will never be checked out are not downloaded. Without the filter Git still
downloads every blob in the project and sparse\-checkout merely declines to write
most of them to disk, so the filter is what turns sparse\-checkout into a saving
in transfer size and server load. The filter from `repo init \fB\-\-partial\-clone\fR
\fB\-\-clone\-filter\fR` takes precedence, and projects listed in `repo init
\fB\-\-partial\-clone\-exclude\fR` are fetched in full as usual. Like sparse\-checkout
itself, the implied filter only applies when the project is first fetched (or is
already a partial clone), so adding a sparse\-path to an existing full checkout
does not convert it to a partial clone. Mirrors are never filtered.
.PP
Because that filter is only useful if the objects it excludes are not fetched by
some other route, a project that takes the implied `\-\-filter=blob:none` also
skips the `/clone.bundle` download: the bundle is an unfiltered pack of every
object, so fetching it would undo the filter. Projects that set the filter
explicitly, or that are listed in `repo init \fB\-\-partial\-clone\-exclude\fR`, keep
using the clone bundle.
.PP
Attribute `path`: A path relative to the project root to include in the
sparse\-checkout. The path is validated like `<copyfile>` and `<linkfile>` paths:
it must stay inside the project, so absolute paths, `.` or `..` components, `~`,
and `.git` or `.repo*` components are all rejected. A trailing `/` is allowed.
.PP
Element copyfile
.PP
Zero or more copyfile elements may be specified as children of a project
+40 -7
View File
@@ -22,16 +22,16 @@ import urllib.parse
import xml.dom.minidom
import error
from git_config import GitConfig
import git_config
import git_refs
from git_superproject import Superproject
import git_superproject
import platform_utils
from project import Annotation
from project import ManifestProject
from project import Project
from project import RemoteSpec
from project import RepoProject
from wrapper import Wrapper
import wrapper
MANIFEST_FILE_NAME = "manifest.xml"
@@ -414,7 +414,7 @@ class XmlManifest:
default_groups: a string, the default manifest groups to use.
"""
# TODO(vapier): Move this out of this class.
self.globalConfig = GitConfig.ForUser()
self.globalConfig = git_config.GitConfig.ForUser()
self.repodir = os.path.abspath(repodir)
self._CheckLocalPath(submanifest_path)
@@ -754,6 +754,11 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
le.setAttribute("dest", lf.dest)
e.appendChild(le)
for sp in p.sparse_paths:
se = doc.createElement("sparse-path")
se.setAttribute("path", sp)
e.appendChild(se)
groups = p.groups - {"all", f"name:{p.name}", f"path:{p.relpath}"}
if groups:
e.setAttribute("groups", ",".join(sorted(groups)))
@@ -815,7 +820,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
e.setAttribute("revision", self._superproject.revision)
root.appendChild(e)
if self._contactinfo.bugurl != Wrapper().BUG_URL:
if self._contactinfo.bugurl != wrapper.Wrapper().BUG_URL:
root.appendChild(doc.createTextNode(""))
e = doc.createElement("contactinfo")
e.setAttribute("bugurl", self._contactinfo.bugurl)
@@ -846,6 +851,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
"annotation",
"copyfile",
"linkfile",
"sparse-path",
}
doc = self.ToXml(**kwargs)
@@ -1176,7 +1182,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
self._submanifests = {}
self._repo_hooks_project = None
self._superproject = None
self._contactinfo = ContactInfo(Wrapper().BUG_URL)
self._contactinfo = ContactInfo(wrapper.Wrapper().BUG_URL)
self._notice = None
self.branch = None
self._manifest_server = None
@@ -1569,6 +1575,8 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
self._ParseLinkFile(p, n)
elif n.nodeName == "annotation":
self._ParseAnnotation(p, n)
elif n.nodeName == "sparse-path":
self._ParseSparsePath(p, n)
if node.nodeName == "repo-hooks":
# Only one project can be the hooks project
@@ -1608,7 +1616,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
"no revision for superproject %s within %s"
% (name, self.manifestFile)
)
self._superproject = Superproject(
self._superproject = git_superproject.Superproject(
self,
name=name,
remote=remote.ToRemoteSpec(name),
@@ -2033,6 +2041,8 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
self._ParseLinkFile(project, n)
elif n.nodeName == "annotation":
self._ParseAnnotation(project, n)
elif n.nodeName == "sparse-path":
self._ParseSparsePath(project, n)
elif n.nodeName == "project":
project.subprojects.append(
self._ParseProject(n, parent=project)
@@ -2307,6 +2317,29 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
)
element.AddAnnotation(name, value, keep)
def _ParseSparsePath(
self, project: Project, node: xml.dom.minidom.Element
) -> None:
"""Parses a <sparse-path> element and adds it to the project.
Args:
project: The Project object to add the sparse path to.
node: The XML node representing the <sparse-path> element.
Raises:
ManifestInvalidPathError: |path| is not usable as a project
relative path.
"""
path = self._reqatt(node, "path")
# Sparse paths name directories inside the project checkout, so they
# get the same scrutiny as <copyfile> & <linkfile> sources.
msg = self._CheckLocalPath(path, dir_ok=True)
if msg:
raise error.ManifestInvalidPathError(
f'<sparse-path> invalid "path": {path}: {msg}'
)
project.sparse_paths.append(path)
def _get_remote(self, node):
name = node.getAttribute("remote")
if not name:
+4 -6
View File
@@ -28,6 +28,10 @@ def isWindows():
return platform.system() == "Windows"
if isWindows():
import platform_utils_win32
def symlink(source, link_name):
"""Creates a symbolic link pointing to source named link_name.
@@ -35,8 +39,6 @@ def symlink(source, link_name):
to know whether to create a "File" or a "Directory" symbolic link.
"""
if isWindows():
import platform_utils_win32
source = _validate_winpath(source)
link_name = _validate_winpath(link_name)
target = os.path.join(os.path.dirname(link_name), source)
@@ -273,8 +275,6 @@ def islink(path):
Availability: Windows, Unix.
"""
if isWindows():
import platform_utils_win32
return platform_utils_win32.islink(_makelongpath(path))
else:
return os.path.islink(path)
@@ -289,8 +289,6 @@ def readlink(path):
Availability: Windows, Unix.
"""
if isWindows():
import platform_utils_win32
return platform_utils_win32.readlink(_makelongpath(path))
else:
return os.readlink(path)
+501 -186
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -16,7 +16,7 @@
import logging
from color import Coloring
import color
import error
@@ -34,7 +34,7 @@ class _ConfigMock:
return self.default_values.get(x, None)
class _LogColoring(Coloring):
class _LogColoring(color.Coloring):
"""Coloring outstream for logging."""
def __init__(self, config):
+4 -3
View File
@@ -18,9 +18,10 @@
# dependencies = [
# 'pytest==8.3.4',
# 'black==25.1.0',
# 'flake8==6.0.0',
# 'flake8-bugbear==24.12.12',
# 'flake8-comprehensions==3.16.0',
# 'flake8==7.4.1',
# 'flake8-bugbear==26.9.30',
# 'flake8-comprehensions==3.17.0',
# 'flake8-import-restrictions==2.1',
# 'isort==5.10.1'
# ]
# ///
+41 -64
View File
@@ -1,101 +1,78 @@
attrs==24.2.0 \
--hash=sha256:37c4653e51383dc1249dba15635d59011ca11714a8e61db8cd6dc73011e4b603 \
--hash=sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2
attrs==25.4.0 ; python_full_version < '3.12' \
--hash=sha256:3e2be3ccc931d6c0cda7d37122b992d6d4b905968a437da7843cff016e2c41e8 \
--hash=sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373
# via flake8-bugbear
black==25.1.0 \
black==25.1.0 ; python_full_version < '3.12' \
--hash=sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717 \
--hash=sha256:c9ed033e41998dd45d6f19bf9923e2444cb401185ccb26d1a2101a9049637c1f
click==8.0.3 \
--hash=sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3 \
--hash=sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3 \
--hash=sha256:b0a0492d07c8e7e4a88ee153ca6553d357a0746792d6868bfca79dc1a344bb1b
click==8.4.1 ; python_full_version < '3.12' \
--hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2
# via black
colorama==0.4.6 ; sys_platform == 'win32' \
colorama==0.4.6 ; python_full_version < '3.12' and sys_platform == 'win32' \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 \
--hash=sha256:8a5a9494346d7ae2d8f599e4661738007d2df56dbbf4ccbbf7a6a23679a3a302
# via
# click
# pytest
flake8==6.0.0 \
flake8==6.0.0 ; python_full_version < '3.12' \
--hash=sha256:9b5bf0ebd06aff1601e38dfc4e91f9b99ba3cdf446e74ee25a9cbbe01aef81ee \
--hash=sha256:f34b5cb8bf908ff49e4673d3dbc557b2381a14132b637d53e9d3a59816f4b6ba
# via
# flake8-bugbear
# flake8-comprehensions
flake8-bugbear==24.12.12 \
flake8-bugbear==24.12.12 ; python_full_version < '3.12' \
--hash=sha256:1b6967436f65ca22a42e5373aaa6f2d87966ade9aa38d4baf2a1be550767545e
flake8-comprehensions==3.16.0 \
flake8-comprehensions==3.16.0 ; python_full_version < '3.12' \
--hash=sha256:7c1eadc9d22e765f39857798febe7766b4d9c519793c6c149e3e13bf99693f70
iniconfig==1.1.1 \
--hash=sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3 \
--hash=sha256:9d81514370059186276e9a1799e5a9752e4c060233c254d6954b53fad2ff8a5e
flake8-import-restrictions==2.1 ; python_full_version < '3.12' \
--hash=sha256:77522e76b82a6ccb25820b1e195825a695094ceded23a18c1b5ce026c23e1071
iniconfig==2.3.0 ; python_full_version < '3.12' \
--hash=sha256:e6ae3625a739878eeb4baf25f17a0b4e9d985421e45b897d1332ed7478f822d9 \
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
# via pytest
isort==5.10.1 \
isort==5.10.1 ; python_full_version < '3.12' \
--hash=sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7 \
--hash=sha256:ec0da0d88470e89ade75bf1e2ea7b4349ec906f95c84aff08e4d41dea6864d25
mccabe==0.7.0 \
mccabe==0.7.0 ; python_full_version < '3.12' \
--hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e \
--hash=sha256:b85abd0a321cdd357d0be34adbf49cf01453a857a23e94d44612ec6f159cb261
# via flake8
mypy-extensions==0.4.3 \
--hash=sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d \
--hash=sha256:38cbac35a502c4f13b9d9b1d06258fa51e01da4f5828b171b4d1200d00378e7c
mypy-extensions==1.1.0 ; python_full_version < '3.12' \
--hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \
--hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \
--hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \
--hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \
--hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \
--hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \
--hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \
--hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \
--hash=sha256:4ec363d206d93f233330fa7f08eefe9a382dd1e326112a94244f95ec2f22f10e
# via black
packaging==23.0 \
--hash=sha256:37cb5b4759d17ae6bad78ee3347f0ee1057452895fcd1bbff48fb12908ce00a1 \
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2
packaging==26.2 ; python_full_version < '3.12' \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e
# via
# black
# pytest
pathspec==0.9.0 \
--hash=sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a \
--hash=sha256:aaf90f4ad38a16bd3ebe0fa64da7f6ee01819c881120db68cc6440941a9adc08
pathspec==1.0.4 ; python_full_version < '3.12' \
--hash=sha256:eeaf9fc7cbc0a4492b666be276f38956baa67cd3faca745462db83d98f1cef87 \
--hash=sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723
# via black
platformdirs==2.5.2 \
--hash=sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788 \
--hash=sha256:e257a04b5d0528cb9836b766c8a16a85368e249cba7ae42888a403976ccd7d2d
platformdirs==4.9.2 ; python_full_version < '3.12' \
--hash=sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd \
--hash=sha256:ef245e78eb7a2ba57599bdb39c4ff323cfae3de356a47207b58bb29e7f479f46
# via black
pluggy==1.5.0 \
--hash=sha256:40da5bf96b15e359e280fd760a80c11d7a8ea20c3c296c3f3e09f8be67cec10b \
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669
pluggy==1.6.0 ; python_full_version < '3.12' \
--hash=sha256:88349c39e440567519b15b27a781bcd3430de6bae17c44c37e758795be96dc5a \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest
py==1.11.0 \
--hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378 \
--hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378
pycodestyle==2.10.0 \
pycodestyle==2.10.0 ; python_full_version < '3.12' \
--hash=sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610 \
--hash=sha256:aacf8d28ecea10fad31b2d14943a42d85d007f4d2fee8f212fc05751c8460647
# via flake8
pyflakes==3.0.1 \
pyflakes==3.0.1 ; python_full_version < '3.12' \
--hash=sha256:a968b8baa43926124b692c7e76279bc5fa8c6ef9a2281e4a2a475be69f954bdf \
--hash=sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf
# via flake8
pyparsing==3.0.7 \
--hash=sha256:943c220ca64e93940b74dd070152ed9ef1402bc3b28fd1f899843bee34e83708 \
--hash=sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484
pytest==8.3.4 \
pytest==8.3.4 ; python_full_version < '3.12' \
--hash=sha256:00cba7845d2dccfe5403025a688d60ceae228b1ddaa866d2eb86b2edb6923e73 \
--hash=sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6
toml==0.10.1 \
--hash=sha256:4099063d9b50219bf09bfa693ab8dcd96ce585cee9fdadce5a48a08ee23b0646 \
--hash=sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88
tomli==2.0.1 \
--hash=sha256:864f310e021d68415d6ba45b23e29808f99703b471d884267d6942eecdf8d6c5 \
--hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc
typing-extensions==4.3.0 \
--hash=sha256:09126dab54422810a541e078c37e62024e7260a022d4f67c075a6707d9553649 \
--hash=sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02
+2 -2
View File
@@ -20,7 +20,7 @@ from command import Command
from command import DEFAULT_LOCAL_JOBS
import error
import git_command
from progress import Progress
import progress
import repo_logging
@@ -119,7 +119,7 @@ It is equivalent to "git branch -D <branchname>".
functools.partial(self._ExecuteOne, opt.all, nb),
range(len(all_projects)),
callback=_ProcessResults,
output=Progress(
output=progress.Progress(
f"Abandon {nb}", len(all_projects), quiet=opt.quiet
),
chunksize=1,
+3 -3
View File
@@ -15,14 +15,14 @@
import itertools
import sys
from color import Coloring
import color
from command import Command
from command import DEFAULT_LOCAL_JOBS
class BranchColoring(Coloring):
class BranchColoring(color.Coloring):
def __init__(self, config):
Coloring.__init__(self, config, "branch")
super().__init__(config, "branch")
self.current = self.printer("current", fg="green")
self.local = self.printer("local")
self.notinproject = self.printer("notinproject", fg="red")
+2 -2
View File
@@ -18,7 +18,7 @@ from typing import NamedTuple
from command import Command
from command import DEFAULT_LOCAL_JOBS
import error
from progress import Progress
import progress
import repo_logging
@@ -98,7 +98,7 @@ The command is equivalent to:
functools.partial(self._ExecuteOne, nb),
range(len(all_projects)),
callback=_ProcessResults,
output=Progress(
output=progress.Progress(
f"Checkout {nb}", len(all_projects), quiet=opt.quiet
),
)
+6 -6
View File
@@ -12,14 +12,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from color import Coloring
import color
from command import PagedCommand
from manifest_xml import RepoClient
import manifest_xml
class _Coloring(Coloring):
class _Coloring(color.Coloring):
def __init__(self, config):
Coloring.__init__(self, config, "status")
super().__init__(config, "status")
class Diffmanifests(PagedCommand):
@@ -238,12 +238,12 @@ synced and their revisions won't be found.
self.printRevision
) = self.printText
manifest1 = RepoClient(self.repodir)
manifest1 = manifest_xml.RepoClient(self.repodir)
manifest1.Override(args[0], load_local_manifests=False)
if len(args) == 1:
manifest2 = self.manifest
else:
manifest2 = RepoClient(self.repodir)
manifest2 = manifest_xml.RepoClient(self.repodir)
manifest2.Override(args[1], load_local_manifests=False)
diff = manifest1.projectsDiff(manifest2)
+5 -5
View File
@@ -21,7 +21,7 @@ import signal
import subprocess
import sys
from color import Coloring
import color
from command import Command
from command import DEFAULT_LOCAL_JOBS
from command import MirrorSafeCommand
@@ -37,9 +37,9 @@ _CAN_COLOR = [
]
class ForallColoring(Coloring):
class ForallColoring(color.Coloring):
def __init__(self, config):
Coloring.__init__(self, config, "forall")
super().__init__(config, "forall")
self.project = self.printer("project", attr="bold")
@@ -234,9 +234,9 @@ without iterating through the remaining projects.
cn = None
if cn and cn in _CAN_COLOR:
class ColorCmd(Coloring):
class ColorCmd(color.Coloring):
def __init__(self, config, cmd):
Coloring.__init__(self, config, cmd)
super().__init__(config, cmd)
if ColorCmd(self.manifest.manifestProject.config, cn).is_on:
cmd.insert(cmd.index(cn) + 1, "--color")
+3 -3
View File
@@ -18,7 +18,7 @@ from typing import List, Set
from command import Command
import git_command
import platform_utils
from progress import Progress
import progress
from project import Project
@@ -124,7 +124,7 @@ are still in the manifests.
if ask.lower() != "y":
return 1
pm = Progress(
pm = progress.Progress(
"Deleting",
len(to_delete),
delay=False,
@@ -180,7 +180,7 @@ are still in the manifests.
print(f"Would have repacked {len(repack_projects)} projects.")
return 0
pm = Progress(
pm = progress.Progress(
"Repacking (this will take a while)",
len(repack_projects),
delay=False,
+3 -3
View File
@@ -16,7 +16,7 @@ import functools
import sys
from typing import NamedTuple
from color import Coloring
import color
from command import DEFAULT_LOCAL_JOBS
from command import PagedCommand
import error
@@ -27,9 +27,9 @@ import repo_logging
logger = repo_logging.RepoLogger(__file__)
class GrepColoring(Coloring):
class GrepColoring(color.Coloring):
def __init__(self, config):
Coloring.__init__(self, config, "grep")
super().__init__(config, "grep")
self.project = self.printer("project", attr="bold")
self.fail = self.printer("fail", fg="red")
+6 -6
View File
@@ -16,12 +16,12 @@ import re
import sys
import textwrap
from color import Coloring
import color
from command import MirrorSafeCommand
from command import PagedCommand
import error
from subcmds import all_commands
from wrapper import Wrapper
import wrapper
class InvalidHelpCommand(error.RepoExitError):
@@ -65,7 +65,7 @@ Displays detailed usage information about a command.
"See 'repo help <command>' for more information on a "
"specific command."
)
print("Bug reports:", Wrapper().BUG_URL)
print("Bug reports:", wrapper.Wrapper().BUG_URL)
def _PrintCommonCommands(self):
print("usage: repo COMMAND [ARGS]")
@@ -84,12 +84,12 @@ Displays detailed usage information about a command.
"command.\nSee 'repo help --all' for a complete list of recognized "
"commands."
)
print("Bug reports:", Wrapper().BUG_URL)
print("Bug reports:", wrapper.Wrapper().BUG_URL)
def _PrintCommandHelp(self, cmd, header_prefix=""):
class _Out(Coloring):
class _Out(color.Coloring):
def __init__(self, gc):
Coloring.__init__(self, gc, "help")
super().__init__(gc, "help")
self.heading = self.printer("heading", attr="bold")
self._first = True
+3 -3
View File
@@ -20,7 +20,7 @@ import optparse
import sys
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
from color import Coloring
import color
from command import DEFAULT_LOCAL_JOBS
from command import PagedCommand
import git_refs
@@ -46,9 +46,9 @@ class OutputFormat(enum.Enum):
JSON = enum.auto()
class _Coloring(Coloring):
class _Coloring(color.Coloring):
def __init__(self, config):
Coloring.__init__(self, config, "status")
super().__init__(config, "status")
class Info(PagedCommand):
+8 -11
View File
@@ -15,14 +15,13 @@
import os
import sys
from color import Coloring
import color
from command import InteractiveCommand
from command import MirrorSafeCommand
import error
import git_command
import repo_logging
from wrapper import Wrapper
from wrapper import WrapperDir
import wrapper
logger = repo_logging.RepoLogger(__file__)
@@ -95,7 +94,7 @@ to update the working directory files.
"""Disable due to re-use of Wrapper()."""
def _Options(self, p):
Wrapper().InitParser(p)
wrapper.Wrapper().InitParser(p)
m = p.add_option_group("Multi-manifest")
m.add_option(
"--outer-manifest",
@@ -244,9 +243,9 @@ to update the working directory files.
if self._HasColorSet(gc):
return
class _Test(Coloring):
class _Test(color.Coloring):
def __init__(self):
Coloring.__init__(self, gc, "test color display")
super().__init__(gc, "test color display")
self._on = True
out = _Test()
@@ -344,9 +343,7 @@ to update the working directory files.
self.OptionParser.error("too many arguments to init")
def Execute(self, opt, args):
wrapper = Wrapper()
reqs = wrapper.Requirements.from_dir(WrapperDir())
reqs = wrapper.Wrapper().Requirements.from_dir(wrapper.WrapperDir())
git_command.git_require(reqs.get_hard_ver("git"), fail=True)
min_git_version_soft = reqs.get_soft_ver("git")
if not git_command.git_require(min_git_version_soft):
@@ -368,13 +365,13 @@ to update the working directory files.
# Handle new --repo-rev requests.
if opt.repo_rev:
try:
remote_ref, rev = wrapper.check_repo_rev(
remote_ref, rev = wrapper.Wrapper().check_repo_rev(
rp.worktree,
opt.repo_rev,
repo_verify=opt.repo_verify,
quiet=opt.quiet,
)
except wrapper.CloneFailure as e:
except wrapper.Wrapper().CloneFailure as e:
err_msg = "fatal: double check your --repo-rev setting."
logger.error(err_msg)
self.git_event_log.ErrorEvent(err_msg)
+3 -3
View File
@@ -14,7 +14,7 @@
import optparse
from color import Coloring
import color
from command import PagedCommand
@@ -73,9 +73,9 @@ are displayed.
if not all_branches:
return
class Report(Coloring):
class Report(color.Coloring):
def __init__(self, config):
Coloring.__init__(self, config, "status")
super().__init__(config, "status")
self.project = self.printer("header", attr="bold")
self.text = self.printer("text")
+3 -3
View File
@@ -14,7 +14,7 @@
import itertools
from color import Coloring
import color
from command import DEFAULT_LOCAL_JOBS
from command import PagedCommand
@@ -56,9 +56,9 @@ class Prune(PagedCommand):
if not all_branches:
return
class Report(Coloring):
class Report(color.Coloring):
def __init__(self, config):
Coloring.__init__(self, config, "status")
super().__init__(config, "status")
self.project = self.printer("header", attr="bold")
out = Report(all_branches[0].project.config)
+3 -3
View File
@@ -14,7 +14,7 @@
import sys
from color import Coloring
import color
from command import Command
import error
import git_command
@@ -25,9 +25,9 @@ import repo_logging
logger = repo_logging.RepoLogger(__file__)
class RebaseColoring(Coloring):
class RebaseColoring(color.Coloring):
def __init__(self, config):
Coloring.__init__(self, config, "rebase")
super().__init__(config, "rebase")
self.project = self.printer("project", attr="bold")
self.fail = self.printer("fail", fg="red")
+3 -3
View File
@@ -14,7 +14,7 @@
import sys
from color import Coloring
import color
from command import InteractiveCommand
import git_command
import repo_logging
@@ -23,9 +23,9 @@ import repo_logging
logger = repo_logging.RepoLogger(__file__)
class _ProjectList(Coloring):
class _ProjectList(color.Coloring):
def __init__(self, gc):
Coloring.__init__(self, gc, "interactive")
super().__init__(gc, "interactive")
self.prompt = self.printer("prompt", fg="blue", attr="bold")
self.header = self.printer("header", attr="bold")
self.help = self.printer("help", fg="red", attr="bold")
+4 -4
View File
@@ -19,8 +19,8 @@ from command import Command
from command import DEFAULT_LOCAL_JOBS
import error
import git_command
from git_config import IsImmutable
from progress import Progress
import git_config
import progress
import repo_logging
@@ -87,7 +87,7 @@ revision specified in the manifest.
branch_merge = ""
err = None
project = cls.get_parallel_context()["projects"][project_idx]
if IsImmutable(project.revisionExpr):
if git_config.IsImmutable(project.revisionExpr):
if project.dest_branch:
branch_merge = project.dest_branch
else:
@@ -137,7 +137,7 @@ revision specified in the manifest.
),
range(len(all_projects)),
callback=_ProcessResults,
output=Progress(
output=progress.Progress(
f"Starting {nb}", len(all_projects), quiet=opt.quiet
),
chunksize=1,
+3 -3
View File
@@ -17,7 +17,7 @@ import glob
import io
import os
from color import Coloring
import color
from command import DEFAULT_LOCAL_JOBS
from command import PagedCommand
import platform_utils
@@ -180,9 +180,9 @@ the following meanings:
(head, _tail) = os.path.split(head)
proj_dirs.add(".repo")
class StatusColoring(Coloring):
class StatusColoring(color.Coloring):
def __init__(self, config):
Coloring.__init__(self, config, "status")
super().__init__(config, "status")
self.project = self.printer("header", attr="bold")
self.untracked = self.printer("untracked", fg="red")
+31 -32
View File
@@ -60,16 +60,13 @@ from command import WORKER_BATCH_SIZE
import error
import event_log
import git_command
from git_config import GetUrlCookieFile
from git_config import RepoConfig
import git_config
import git_refs
import git_superproject
from hooks import RepoHook
from manifest_xml import XmlManifest
import manifest_xml
import platform_utils
from progress import elapsed_str
from progress import jobs_str
from progress import Progress
import progress
from project import DeleteWorktreeError
from project import Project
from project import RemoteSpec
@@ -77,7 +74,7 @@ from project import SyncBuffer
import repo_logging
import repo_trace
import ssh
from wrapper import Wrapper
import wrapper
_ONE_DAY_S = 24 * 60 * 60
@@ -888,7 +885,7 @@ later is required to fix a server side protocol bug.
)
def _GetSuperprojectRevision(
self, opt: optparse.Values, manifest: XmlManifest
self, opt: optparse.Values, manifest: manifest_xml.XmlManifest
) -> Optional[str]:
"""Returns the --superproject-revision to apply to manifest.
@@ -907,7 +904,7 @@ later is required to fix a server side protocol bug.
return None
def _ConfigureSuperproject(
self, opt: optparse.Values, manifest: XmlManifest
self, opt: optparse.Values, manifest: manifest_xml.XmlManifest
) -> bool:
"""Configure superproject with options.
@@ -1143,8 +1140,8 @@ later is required to fix a server side protocol bug.
return "..working.."
elapsed = time.time() - earliest_time
jobs = jobs_str(len(items))
return f"{jobs} | {elapsed_str(elapsed)} {earliest_proj}"
jobs = progress.jobs_str(len(items))
return f"{jobs} | {progress.elapsed_str(elapsed)} {earliest_proj}"
@classmethod
def InitWorker(cls):
@@ -1161,7 +1158,7 @@ later is required to fix a server side protocol bug.
fetched = set()
remote_fetched = set()
pm = Progress(
pm = progress.Progress(
"Fetching",
len(projects),
delay=False,
@@ -1485,7 +1482,7 @@ later is required to fix a server side protocol bug.
),
range(len(projects)),
callback=_ProcessResults,
output=Progress(
output=progress.Progress(
"Checking out", len(all_projects), quiet=opt.quiet
),
# Use chunksize=1 to avoid the chance that some workers are
@@ -1617,7 +1614,7 @@ later is required to fix a server side protocol bug.
cls._SetPreciousObjectsState(project, opt)
return
pm = Progress(
pm = progress.Progress(
"Garbage collecting", len(projects), delay=False, quiet=opt.quiet
)
pm.update(inc=0, msg="prescan")
@@ -1748,7 +1745,9 @@ later is required to fix a server side protocol bug.
return project.name
return None
def _CheckForBloatedProjects(self, projects, opt):
def _CheckForBloatedProjects(
self, projects: List[Project], opt: optparse.Values
) -> None:
"""Check for shallow projects that are accumulating unoptimized data.
For projects with clone-depth="1" that are dirty (have local changes),
@@ -1766,12 +1765,14 @@ later is required to fix a server side protocol bug.
if not git_command.git_require((2, 23, 0)):
return
# Skip projects with no worktree on disk, e.g. ones only ever synced
# with --network-only.
# Skip projects fetched via repo.fetchcmd (which bypasses _RemoteFetch
# and manages object storage externally) or with no worktree on disk,
# e.g. ones only ever synced with --network-only.
projects = [
p
for p in projects
if p.clone_depth
and not p.UseFetchCmd
and not p.stateless_prune_needed
and p.worktree
and p.Exists
@@ -1780,7 +1781,7 @@ later is required to fix a server side protocol bug.
if not projects:
return
pm = Progress(
pm = progress.Progress(
"Checking for bloat", len(projects), delay=False, quiet=opt.quiet
)
@@ -2153,7 +2154,7 @@ later is required to fix a server side protocol bug.
self,
opt: optparse.Values,
smart_sync_manifest_path: str,
manifest: XmlManifest,
manifest: manifest_xml.XmlManifest,
best_effort: bool = False,
) -> str:
if not manifest.manifest_server:
@@ -2471,7 +2472,7 @@ later is required to fix a server side protocol bug.
def _SyncToSuperprojectRev(
self,
opt: optparse.Values,
manifest: XmlManifest,
manifest: manifest_xml.XmlManifest,
mp: Project,
manifest_name: Optional[str],
errors: List[Exception],
@@ -2517,7 +2518,7 @@ later is required to fix a server side protocol bug.
)
def _ResolveSmartSyncOption(
self, opt: optparse.Values, manifest: XmlManifest
self, opt: optparse.Values, manifest: manifest_xml.XmlManifest
) -> bool:
"""Resolve opt.smart_sync from CLI flags, config, and manifest.
@@ -2533,7 +2534,7 @@ later is required to fix a server side protocol bug.
checkout_pref = getattr(manifest.manifestProject, "smart_sync", None)
if not isinstance(checkout_pref, bool):
checkout_pref = None
user_pref = RepoConfig.ForUser().GetBoolean("sync.smartsync")
user_pref = git_config.RepoConfig.ForUser().GetBoolean("sync.smartsync")
default_allowed = (
opt.smart_sync is None
and not opt.smart_tag
@@ -2770,7 +2771,7 @@ later is required to fix a server side protocol bug.
self,
opt: optparse.Values,
args: List[str],
manifest: XmlManifest,
manifest: manifest_xml.XmlManifest,
all_projects: List[Project],
) -> None:
"""Fail when repo.reprojectcmd is used on a manifest nesting projects.
@@ -2807,7 +2808,7 @@ later is required to fix a server side protocol bug.
raise e
def _CreateSyncProgressThread(
self, pm: Progress, stop_event: _threading.Event
self, pm: progress.Progress, stop_event: _threading.Event
) -> _threading.Thread:
"""Creates and returns a daemon thread to update a Progress object.
@@ -3191,7 +3192,7 @@ later is required to fix a server side protocol bug.
errors: List[Exception],
opt: optparse.Values,
pool: Optional[multiprocessing.Pool],
pm: Progress,
pm: progress.Progress,
results_sets: List[_InterleavedSyncResult],
):
"""Callback to process results from interleaved sync workers."""
@@ -3290,7 +3291,7 @@ later is required to fix a server side protocol bug.
err_event = multiprocessing.Event()
finished_relpaths = set()
project_list = list(all_projects)
pm = Progress(
pm = progress.Progress(
"Syncing",
len(project_list),
delay=False,
@@ -3460,9 +3461,8 @@ def _PostRepoUpgrade(manifest, quiet=False):
except Exception:
pass
wrapper = Wrapper()
if wrapper.NeedSetupGnuPG():
wrapper.SetupGnuPG(quiet)
if wrapper.Wrapper().NeedSetupGnuPG():
wrapper.Wrapper().SetupGnuPG(quiet)
for project in manifest.projects:
if project.Exists:
project.PostRepoUpgrade()
@@ -3471,12 +3471,11 @@ def _PostRepoUpgrade(manifest, quiet=False):
def _PostRepoFetch(rp, repo_verify=True, verbose=False):
if rp.HasChanges:
logger.warning("info: A new version of repo is available")
wrapper = Wrapper()
try:
rev = rp.bare_git.describe(rp.GetRevisionId())
except error.GitError:
rev = None
_, new_rev = wrapper.check_repo_rev(
_, new_rev = wrapper.Wrapper().check_repo_rev(
rp.gitdir, rev, repo_verify=repo_verify
)
# See if we're held back due to missing signed tag.
@@ -3652,7 +3651,7 @@ class PersistentTransport(xmlrpc.client.Transport):
self.proxy = proxy
def request(self, host, handler, request_body, verbose=False):
with GetUrlCookieFile(self.orig_host, not verbose) as (
with git_config.GetUrlCookieFile(self.orig_host, not verbose) as (
cookiefile,
cookie_proxy,
):
+2 -2
View File
@@ -21,7 +21,7 @@ from typing import List
from command import DEFAULT_LOCAL_JOBS
from command import InteractiveCommand
from editor import Editor
import editor
import error
import git_refs
import git_superproject
@@ -494,7 +494,7 @@ Gerrit Code Review: https://www.gerritcodereview.com/
branches[project_path] = b
script.append("")
script = Editor.EditString("\n".join(script)).split("\n")
script = editor.Editor.EditString("\n".join(script)).split("\n")
project_re = re.compile(r"^#?\s*project\s*([^\s]+)/:$")
branch_re = re.compile(r"^\s*branch\s*([^\s(]+)\s*\(.*")
+2 -2
View File
@@ -20,7 +20,7 @@ from command import Command
from command import MirrorSafeCommand
import git_command
import git_refs
from wrapper import Wrapper
import wrapper
class Version(Command, MirrorSafeCommand):
@@ -81,4 +81,4 @@ class Version(Command, MirrorSafeCommand):
print(f"OS {uname.system} {uname.release} ({uname.version})")
processor = uname.processor if uname.processor else "unknown"
print(f"CPU {uname.machine} ({processor})")
print("Bug reports:", Wrapper().BUG_URL)
print("Bug reports:", wrapper.Wrapper().BUG_URL)
+9 -9
View File
@@ -16,30 +16,30 @@
import pytest
from editor import Editor
import editor
@pytest.fixture(autouse=True)
def reset_editor() -> None:
"""Take care of resetting Editor state across tests."""
Editor._editor = None
editor.Editor._editor = None
yield
Editor._editor = None
editor.Editor._editor = None
def test_basic() -> None:
"""Basic checking of _GetEditor."""
Editor._editor = ":"
assert Editor._GetEditor() == ":"
editor.Editor._editor = ":"
assert editor.Editor._GetEditor() == ":"
def test_no_editor() -> None:
"""Check behavior when no editor is available."""
Editor._editor = ":"
assert Editor.EditString("foo") == "foo"
editor.Editor._editor = ":"
assert editor.Editor.EditString("foo") == "foo"
def test_cat_editor() -> None:
"""Check behavior when editor is `cat`."""
Editor._editor = "cat"
assert Editor.EditString("foo") == "foo"
editor.Editor._editor = "cat"
assert editor.Editor.EditString("foo") == "foo"
+214
View File
@@ -14,12 +14,15 @@
"""Unittests for the git_config.py module."""
import json
import os
from pathlib import Path
from typing import Any, Optional
import pytest
import utils_for_test
import error
import git_config
@@ -346,3 +349,214 @@ def test_get_boolean_git_spellings(
config_file.write_text(f"[section]\n\tkey = {value}\n")
config = git_config.GitConfig(config_file)
assert config.GetBoolean("section.key") is expected
def test_set_string_updates_json_and_section_caches(
rw_config_file: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test SetString keeps .repo_config.json and _sections coherent."""
config = git_config.GitConfig(str(rw_config_file))
assert not config.HasSection("remote", "origin")
config.SetString("remote.origin.url", "https://example.com/repo")
assert config.HasSection("remote", "origin")
json_file = rw_config_file.parent / ".repo_config.json"
raw = json.loads(json_file.read_text(encoding="utf-8"))
assert git_config._JSON_META_KEY in raw
raw.pop(git_config._JSON_META_KEY)
assert raw == {"remote.origin.url": ["https://example.com/repo"]}
reader = git_config.GitConfig(str(rw_config_file))
def fail_read_git() -> None:
raise AssertionError("Expected _ReadJson cache hit without git config")
monkeypatch.setattr(reader, "_ReadGit", fail_read_git)
assert reader.GetString("remote.origin.url") == "https://example.com/repo"
assert git_config._JSON_META_KEY not in reader.DumpConfigDict()
def test_reload_refreshes_external_edits(rw_config_file: Path) -> None:
"""Test Reload() discards stale JSON cache and reloads external edits."""
config = git_config.GitConfig(str(rw_config_file))
config.SetString("abfs.initialized", "false")
assert config.GetBoolean("abfs.initialized") is False
rw_config_file.write_text(
"[abfs]\n\tinitialized = true\n", encoding="utf-8"
)
config.Reload()
assert config.GetBoolean("abfs.initialized") is True
json_file = rw_config_file.parent / ".repo_config.json"
raw = json.loads(json_file.read_text(encoding="utf-8"))
raw.pop(git_config._JSON_META_KEY, None)
assert raw == {"abfs.initialized": ["true"]}
def test_same_tick_external_edit_invalidates_json_cache(
rw_config_file: Path,
) -> None:
"""External edit with identical mtime_ns and size invalidates JSON."""
config = git_config.GitConfig(str(rw_config_file))
config.SetString("foo.bar", "val1")
json_file = rw_config_file.parent / ".repo_config.json"
json_stat = os.stat(json_file)
# Rewrite with identical byte length and force identical mtime_ns.
updated = rw_config_file.read_text(encoding="utf-8").replace("val1", "val2")
rw_config_file.write_text(updated, encoding="utf-8")
os.utime(rw_config_file, ns=(json_stat.st_atime_ns, json_stat.st_mtime_ns))
reader = git_config.GitConfig(str(rw_config_file))
assert reader.GetString("foo.bar") == "val2"
def test_clear_cache_and_set_string_refresh_remotes_and_branches(
rw_config_file: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ClearCache, Reload, SetString, and Branch.Save keep caches coherent."""
config = git_config.GitConfig(str(rw_config_file))
remote = config.GetRemote("origin")
remote.url = "https://old.example.com/repo"
remote.ResetFetch()
remote.Save()
branch = config.GetBranch("main")
branch.remote = remote
branch.merge = "refs/heads/main"
branch.Save()
assert config.HasSection("branch", "main")
assert config.GetString("branch.main.remote") == "origin"
assert config.GetString("branch.main.merge") == "refs/heads/main"
# Subsequent SetString must not clobber branch.* keys in .repo_config.json.
config.SetString("custom.flag", "1")
reader = git_config.GitConfig(str(rw_config_file))
monkeypatch.setattr(
reader,
"_ReadGit",
lambda: (_ for _ in ()).throw(AssertionError("expected JSON hit")),
)
assert reader.GetString("branch.main.remote") == "origin"
assert reader.GetString("branch.main.merge") == "refs/heads/main"
# Direct SetString on remote.* invalidates cached Remote and Branch objects.
config.SetString(
"remote.origin.fetch", "+refs/heads/dev:refs/remotes/origin/dev"
)
assert (
config.GetRemote("origin").ToLocal("dev") == "refs/remotes/origin/dev"
)
assert config.GetBranch("main").remote is config.GetRemote("origin")
# External edit + Reload() refreshes both GetRemote() and GetBranch().
rw_config_file.write_text(
'[remote "origin"]\n'
"\turl = https://new.example.com/repo\n"
"\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
'[branch "main"]\n'
"\tremote = origin\n"
"\tmerge = refs/heads/next\n",
encoding="utf-8",
)
config.Reload()
assert config.GetRemote("origin").url == "https://new.example.com/repo"
assert config.GetBranch("main").merge == "refs/heads/next"
def test_set_string_with_include_invalidates_instead_of_corrupting(
tmp_path: Path,
) -> None:
"""SetString with [include] avoids freezing or dropping included values."""
inc_file = tmp_path / "extra.gitconfig"
inc_file.write_text("[inc]\n\tkey = v1\n", encoding="utf-8")
cfg_file = tmp_path / "config"
config = git_config.GitConfig(str(cfg_file))
config.SetString("include.path", str(inc_file))
assert config.GetString("inc.key") == "v1"
config.SetString("local.only", "yes")
json_file = tmp_path / ".repo_config.json"
assert not json_file.exists()
inc_file.write_text("[inc]\n\tkey = v2\n", encoding="utf-8")
assert config.GetString("inc.key") == "v2"
assert config.GetString("local.only") == "yes"
# Unsetting an included-only key does not fail with exit code 5 or hide it.
config.SetString("inc.key", None)
assert config.GetString("inc.key") == "v2"
def test_set_string_failure_preserves_cache(
rw_config_file: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Failed _do() must not leave mutated values in _cache."""
config = git_config.GitConfig(str(rw_config_file))
config.SetString("foo.bar", "good")
def fail_do(*_args: Any, **_kwargs: Any) -> str:
raise error.GitError("lock contention")
monkeypatch.setattr(config, "_do", fail_do)
with pytest.raises(error.GitError):
config.SetString("foo.bar", "bad")
assert config.GetString("foo.bar") == "good"
with pytest.raises(error.GitError):
config.SetString("foo.bar", None)
assert config.GetString("foo.bar") == "good"
def test_set_string_after_external_edit_preserves_external_keys(
rw_config_file: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Warm SetString reloads external edits instead of overwriting JSON."""
config = git_config.GitConfig(str(rw_config_file))
config.SetString("init.key", "v0")
assert config._cache_dict is not None
# External edit adds other.key while config._cache_dict is warm in memory.
rw_config_file.write_text(
"[init]\n\tkey = v0\n[other]\n\tkey = ext1\n", encoding="utf-8"
)
config.SetString("user.name", "Alice")
assert config.GetString("other.key") == "ext1"
assert config.GetString("user.name") == "Alice"
reader = git_config.GitConfig(str(rw_config_file))
monkeypatch.setattr(
reader,
"_ReadGit",
lambda: (_ for _ in ()).throw(AssertionError("expected JSON hit")),
)
assert reader.GetString("other.key") == "ext1"
assert reader.GetString("user.name") == "Alice"
# Same-mtime_ns and same-size external edit while warm is also detected.
prev_stat = os.stat(rw_config_file)
same_len = rw_config_file.read_text(encoding="utf-8").replace(
"ext1", "ext2"
)
rw_config_file.write_text(same_len, encoding="utf-8")
os.utime(rw_config_file, ns=(prev_stat.st_atime_ns, prev_stat.st_mtime_ns))
config.SetString("user.email", "alice@example.com")
assert config.GetString("other.key") == "ext2"
assert config.GetString("user.email") == "alice@example.com"
reader2 = git_config.GitConfig(str(rw_config_file))
monkeypatch.setattr(
reader2,
"_ReadGit",
lambda: (_ for _ in ()).throw(AssertionError("expected JSON hit")),
)
assert reader2.GetString("other.key") == "ext2"
assert reader2.GetString("user.name") == "Alice"
assert reader2.GetString("user.email") == "alice@example.com"
+162
View File
@@ -244,3 +244,165 @@ def test_updates_when_storage_backend_toggles(tmp_path):
_run(repo, "refs", "migrate", "--ref-format=files")
_run(repo, "branch", "files-branch")
assert refs.get("refs/heads/files-branch") == head
@pytest.mark.parametrize("reftable", [False, True])
def test_unborn_head_resolution(
tmp_path: Path, reftable: bool, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Unborn HEAD reads files backend directly and uses git on reftable."""
if reftable and not utils_for_test.supports_reftable():
pytest.skip("reftable not supported")
repo = os.path.join(tmp_path, "unborn")
ref_format = "reftable" if reftable else "files"
utils_for_test.init_git_tree(repo, ref_format=ref_format)
_run(repo, "symbolic-ref", "HEAD", "refs/heads/unborn-main")
refs = git_refs.GitRefs(os.path.join(repo, ".git"))
seen_symbolic_ref = []
class SpyGitCommand(git_command.GitCommand):
def __init__(
self,
proj: Any,
cmdv: List[str],
*args: Any,
**kwargs: Any,
) -> None:
if cmdv and cmdv[0] == "symbolic-ref":
seen_symbolic_ref.append(cmdv)
super().__init__(proj, cmdv, *args, **kwargs)
monkeypatch.setattr(git_command, "GitCommand", SpyGitCommand)
assert refs.symref("HEAD") == "refs/heads/unborn-main"
if not reftable:
assert seen_symbolic_ref == []
@pytest.mark.parametrize("reftable", [False, True])
def test_update_symref_keeps_snapshot_warm(
tmp_path: Path, reftable: bool
) -> None:
"""UpdateSymref stays warm on files backend and reloads on reftable."""
if reftable and not utils_for_test.supports_reftable():
pytest.skip("reftable not supported")
repo = _init_repo(tmp_path, reftable=reftable)
branch = _run(repo, "symbolic-ref", "--short", "HEAD")
head = _run(repo, "rev-parse", "HEAD")
refs = git_refs.GitRefs(os.path.join(repo, ".git"))
assert refs.get(f"refs/heads/{branch}") == head
_run(repo, "symbolic-ref", "refs/remotes/m/main", f"refs/heads/{branch}")
refs.UpdateSymref("refs/remotes/m/main", f"refs/heads/{branch}")
if reftable:
assert not refs.is_loaded
assert refs.symref("refs/remotes/m/main") == f"refs/heads/{branch}"
assert refs.get("refs/remotes/m/main") == head
else:
with mock.patch.object(
refs,
"_LoadAll",
side_effect=AssertionError("expected warm GitRefs snapshot"),
):
assert refs.symref("refs/remotes/m/main") == f"refs/heads/{branch}"
assert refs.get("refs/remotes/m/main") == head
def test_update_symref_invalidates_when_external_ref_changed(
tmp_path: Path,
) -> None:
"""External ref writes before UpdateSymref invalidate instead of masking."""
repo = _init_repo(tmp_path, reftable=False)
branch = _run(repo, "symbolic-ref", "--short", "HEAD")
head = _run(repo, "rev-parse", "HEAD")
refs = git_refs.GitRefs(os.path.join(repo, ".git"))
assert refs.get(f"refs/heads/{branch}") == head
# External branch write happens before symbolic-ref + UpdateSymref.
_run(repo, "branch", "external-topic")
_run(repo, "symbolic-ref", "refs/remotes/m/main", f"refs/heads/{branch}")
refs.UpdateSymref("refs/remotes/m/main", f"refs/heads/{branch}")
assert not refs.is_loaded
assert refs.get("refs/heads/external-topic") == head
assert refs.get("refs/remotes/m/main") == head
# External sibling ref in the same directory is also detected.
_run(repo, "update-ref", "refs/remotes/m/sibling", head)
_run(
repo,
"symbolic-ref",
"refs/remotes/m/main",
"refs/heads/external-topic",
)
refs.UpdateSymref("refs/remotes/m/main", "refs/heads/external-topic")
assert not refs.is_loaded
assert refs.get("refs/remotes/m/sibling") == head
def test_update_symref_self_or_cyclic_clears_phyref(tmp_path: Path) -> None:
"""Self or cyclic symrefs clear any prior physical SHA from _phyref."""
repo = _init_repo(tmp_path, reftable=False)
head = _run(repo, "rev-parse", "HEAD")
gitdir = os.path.join(repo, ".git")
_run(repo, "update-ref", "refs/remotes/m/main", head)
_run(repo, "update-ref", "refs/remotes/m/other", head)
refs = git_refs.GitRefs(gitdir)
assert refs.get("refs/remotes/m/main") == head
assert refs.get("refs/remotes/m/other") == head
_run(repo, "symbolic-ref", "refs/remotes/m/main", "refs/remotes/m/main")
refs.UpdateSymref("refs/remotes/m/main", "refs/remotes/m/main")
assert refs.get("refs/remotes/m/main") == ""
_run(repo, "symbolic-ref", "refs/remotes/m/main", "refs/remotes/m/other")
refs.UpdateSymref("refs/remotes/m/main", "refs/remotes/m/other")
assert refs.get("refs/remotes/m/main") == head
_run(repo, "symbolic-ref", "refs/remotes/m/other", "refs/remotes/m/main")
refs.UpdateSymref("refs/remotes/m/other", "refs/remotes/m/main")
assert refs.get("refs/remotes/m/main") == ""
assert refs.get("refs/remotes/m/other") == ""
def test_update_symref_propagates_chained_symrefs(tmp_path: Path) -> None:
"""Re-pointing an intermediate symref updates chained symrefs cleanly."""
repo = _init_repo(tmp_path, reftable=False)
branch = _run(repo, "symbolic-ref", "--short", "HEAD")
c1 = _run(repo, "rev-parse", "HEAD")
Path(os.path.join(repo, "a")).write_text("2", encoding="utf-8")
_run(repo, "add", "a")
_run(repo, "commit", "-q", "-m", "second")
c2 = _run(repo, "rev-parse", "HEAD")
_run(repo, "update-ref", "refs/heads/first", c1)
# Point HEAD at unborn refs/remotes/m/main before warming GitRefs.
_run(repo, "symbolic-ref", "HEAD", "refs/remotes/m/main")
refs = git_refs.GitRefs(os.path.join(repo, ".git"))
assert refs.symref("HEAD") == "refs/remotes/m/main"
assert refs.get("HEAD") == ""
_run(repo, "symbolic-ref", "refs/remotes/m/main", "refs/heads/first")
refs.UpdateSymref("refs/remotes/m/main", "refs/heads/first")
_run(repo, "symbolic-ref", "refs/remotes/m/alias", "refs/remotes/m/main")
refs.UpdateSymref("refs/remotes/m/alias", "refs/remotes/m/main")
assert refs.get("HEAD") == c1
assert refs.get("refs/remotes/m/alias") == c1
_run(repo, "symbolic-ref", "refs/remotes/m/main", f"refs/heads/{branch}")
refs.UpdateSymref("refs/remotes/m/main", f"refs/heads/{branch}")
with mock.patch.object(
refs,
"_LoadAll",
side_effect=AssertionError("expected warm GitRefs snapshot"),
):
assert refs.get("refs/remotes/m/main") == c2
assert refs.get("refs/remotes/m/alias") == c2
assert refs.get("HEAD") == c2
+6 -6
View File
@@ -22,7 +22,7 @@ import unittest
from unittest import mock
import pytest
from test_manifest_xml import sort_attributes
import test_manifest_xml
import git_superproject
import git_trace2_event_log
@@ -255,7 +255,7 @@ class SuperprojectTestCase(unittest.TestCase):
with open(manifest_path) as fp:
manifest_xml_data = fp.read()
self.assertEqual(
sort_attributes(manifest_xml_data),
test_manifest_xml.sort_attributes(manifest_xml_data),
'<?xml version="1.0" ?><manifest>'
'<remote fetch="http://localhost" name="default-remote"/>'
'<default remote="default-remote" revision="refs/heads/main"/>'
@@ -290,7 +290,7 @@ class SuperprojectTestCase(unittest.TestCase):
with open(update_result.manifest_path) as fp:
manifest_xml_data = fp.read()
self.assertEqual(
sort_attributes(manifest_xml_data),
test_manifest_xml.sort_attributes(manifest_xml_data),
'<?xml version="1.0" ?><manifest>'
'<remote fetch="http://localhost" name="default-remote"/>'
'<default remote="default-remote" revision="refs/heads/main"/>'
@@ -317,7 +317,7 @@ class SuperprojectTestCase(unittest.TestCase):
self.maxDiff = None
self.assertIsNone(manifest.superproject)
self.assertEqual(
sort_attributes(manifest.ToXml().toxml()),
test_manifest_xml.sort_attributes(manifest.ToXml().toxml()),
'<?xml version="1.0" ?><manifest>'
'<remote fetch="http://localhost" name="default-remote"/>'
'<default remote="default-remote" revision="refs/heads/main"/>'
@@ -379,7 +379,7 @@ class SuperprojectTestCase(unittest.TestCase):
# Verify platform/vendor/x's project revision hasn't
# changed.
self.assertEqual(
sort_attributes(manifest_xml_data),
test_manifest_xml.sort_attributes(manifest_xml_data),
'<?xml version="1.0" ?><manifest>'
'<remote fetch="http://localhost" name="default-remote"/>'
'<remote fetch="http://localhost2" name="goog"/>'
@@ -444,7 +444,7 @@ class SuperprojectTestCase(unittest.TestCase):
# Verify platform/vendor/x's project revision hasn't
# changed.
self.assertEqual(
sort_attributes(manifest_xml_data),
test_manifest_xml.sort_attributes(manifest_xml_data),
'<?xml version="1.0" ?><manifest>'
'<remote fetch="http://localhost" name="default-remote"/>'
'<default remote="default-remote" revision="refs/heads/main"/>'
+2 -2
View File
@@ -14,7 +14,7 @@
"""Unittests for the hooks.py module."""
from io import StringIO
import io
from pathlib import Path
import sys
@@ -82,7 +82,7 @@ def test_post_sync_argument_validation() -> None:
)
old_stderr = sys.stderr
sys.stderr = StringIO()
sys.stderr = io.StringIO()
try:
# Call with missing arg `sync_duration_seconds`
+51
View File
@@ -14,10 +14,12 @@
"""Tests for the main repo script and subcommand routing."""
from pathlib import Path
from unittest import mock
import pytest
import event_log
from main import _Repo
@@ -164,3 +166,52 @@ def test_autocorrect_prompt_interrupt(mock_input, repo, mock_config):
res = repo._autocorrect_command_name("tart", mock_config)
assert res is None
def test_event_log_disabled_skips_add_and_git_hash() -> None:
"""Test disabled EventLog returns None and skips GetCommitRevisionId."""
log = event_log.EventLog(enabled=False)
project = mock.MagicMock()
assert log.Add("sync", event_log.TASK_COMMAND, 1.0) is None
assert (
log.AddSync(project, event_log.TASK_SYNC_NETWORK, 1.0, 2.0, True)
is None
)
project.GetCommitRevisionId.assert_not_called()
@mock.patch("manifest_xml.RepoClient")
def test_run_long_gates_event_log_on_flag(
mock_client_cls: mock.MagicMock, tmp_path: Path
) -> None:
"""Test _RunLong enables cmd.event_log only when --event-log is set."""
client = mock_client_cls.return_value
client.manifest.IsMirror = False
client.manifest.manifestProject.config.DumpConfigDict.return_value = {}
repo = _Repo("repodir")
for log_path, expected in (
(None, False),
(str(tmp_path / "events.log"), True),
):
cmd = mock.MagicMock()
cmd.event_log = event_log.EventLog(enabled=False)
cmd.manifest = client.manifest
cmd.MULTI_MANIFEST_SUPPORT = True
cmd.OptionParser.parse_args.return_value = (mock.MagicMock(), [])
cmd.ReadEnvironmentOptions.side_effect = lambda copts: copts
cmd.Execute.return_value = 0
repo.commands = {"sync": mock.Mock(return_value=cmd)}
gopts = mock.MagicMock(
color=None,
submanifest_path="",
pager=False,
time=False,
event_log=log_path,
git_trace2_event_log=None,
)
assert repo._RunLong("sync", gopts, [], mock.MagicMock()) == 0
assert cmd.event_log.enabled is expected
assert bool(log_path and Path(log_path).exists()) is expected
+100
View File
@@ -933,6 +933,106 @@ class TestProjectElement:
with pytest.raises(error.ManifestInvalidPathError):
parse("ok", path)
def test_sparse_checkout(self, repo_client: RepoClient) -> None:
"""Check sparse-checkout settings."""
manifest = repo_client.get_xml_manifest(
"""
<manifest>
<remote name="test-remote" fetch="http://localhost" />
<default remote="test-remote" revision="refs/heads/main" />
<project name="test-name" path="test-path">
<sparse-path path="src/main" />
<sparse-path path="docs" />
</project>
<project name="no-sparse" path="no-sparse-path" />
</manifest>
"""
)
assert len(manifest.projects) == 2
result = {p.name: p for p in manifest.projects}
assert result["test-name"].sparse_paths == ["src/main", "docs"]
assert result["no-sparse"].sparse_paths == []
def test_sparse_checkout_bad_paths(self, repo_client: RepoClient) -> None:
"""Check <sparse-path> rejects paths that escape the checkout."""
def parse(path: str) -> None:
path = repo_client.encode_xml_attr(path)
manifest = repo_client.get_xml_manifest(
f"""
<manifest>
<remote name="test-remote" fetch="http://localhost" />
<default remote="test-remote" revision="refs/heads/main" />
<project name="test-name" path="test-path">
<sparse-path path="{path}" />
</project>
</manifest>
"""
)
# Force the manifest to be parsed.
manifest.ToXml()
# Verify the parser is valid by default to avoid buggy tests below.
parse("src/main")
# Sparse paths name directories, so a trailing slash is fine.
parse("src/main/")
# A missing path is rejected by a different codepath.
with pytest.raises(error.ManifestParseError):
parse("")
for path in INVALID_FS_PATHS:
if not path or path.endswith("/") or path.endswith(os.path.sep):
continue
with pytest.raises(error.ManifestInvalidPathError):
parse(path)
def test_sparse_checkout_extend_project(
self, repo_client: RepoClient
) -> None:
"""Check sparse-path elements added via extend-project."""
manifest = repo_client.get_xml_manifest(
"""
<manifest>
<remote name="test-remote" fetch="http://localhost" />
<default remote="test-remote" revision="refs/heads/main" />
<project name="test-name" path="test-path">
<sparse-path path="src/main" />
</project>
<extend-project name="test-name">
<sparse-path path="docs" />
</extend-project>
</manifest>
"""
)
assert len(manifest.projects) == 1
assert manifest.projects[0].sparse_paths == ["src/main", "docs"]
def test_sparse_checkout_roundtrip(self, repo_client: RepoClient) -> None:
"""Check sparse-path elements survive a manifest round-trip."""
manifest = repo_client.get_xml_manifest(
"""
<manifest>
<remote name="test-remote" fetch="http://localhost" />
<default remote="test-remote" revision="refs/heads/main" />
<project name="test-name" path="test-path">
<sparse-path path="src/main" />
<sparse-path path="docs" />
</project>
</manifest>
"""
)
roundtrip = repo_client.get_xml_manifest(manifest.ToXml().toxml())
assert roundtrip.projects[0].sparse_paths == ["src/main", "docs"]
assert manifest.ToDict()["project"][0]["sparse-path"] == [
{"path": "src/main"},
{"path": "docs"},
]
class TestSuperProjectElement:
"""Tests for <superproject>."""
+1332 -30
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -16,7 +16,7 @@
import runpy
import sys
from types import ModuleType
import types
import pytest
import utils_for_test
@@ -31,7 +31,7 @@ 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")
fake_update_manpages = types.ModuleType("update_manpages")
# Mock this because the real module requires newer Python versions than
# our unittest framework does.
+2 -2
View File
@@ -22,7 +22,7 @@ from unittest import mock
import pytest
from color import SetDefaultColoring
import color
import error
import repo_logging
@@ -86,7 +86,7 @@ def test_log_with_format_string(level: int) -> None:
# Set color output to "always" for consistent test results.
# This ensures the logger's behavior is uniform across different
# environments and git configurations.
SetDefaultColoring("always")
color.SetDefaultColoring("always")
# Regex pattern to match optional ANSI color codes.
# \033 - Escape character
+2 -3
View File
@@ -21,6 +21,7 @@ import pytest
from command import Command
import subcmds
from subcmds import sync
# NB: We don't test all subcommands as we want to avoid "change detection"
@@ -164,9 +165,7 @@ def test_common_validate_options(name: str, cls: Type[Command]) -> None:
def test_attribute_error_repro() -> None:
"""Confirm that accessing verbose before CommonValidateOptions fails."""
from subcmds.sync import Sync
cmd = Sync()
cmd = sync.Sync()
opts, args = cmd.OptionParser.parse_args([])
# This confirms that without the fix in main.py, an AttributeError
+2 -2
View File
@@ -16,7 +16,7 @@
import contextlib
import io
from types import SimpleNamespace
import types
from unittest import mock
import error
@@ -66,7 +66,7 @@ def test_execute_delegates_autostash_to_rebase() -> None:
branch.LocalMerge = "refs/remotes/origin/main"
project.GetBranch.return_value = branch
cmd.GetProjects = mock.MagicMock(return_value=[project])
opt = SimpleNamespace(
opt = types.SimpleNamespace(
interactive=False,
fail_fast=False,
whitespace=None,
+41 -15
View File
@@ -19,6 +19,7 @@ import optparse
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import threading
import time
@@ -33,7 +34,9 @@ import pytest
import command
import error
import git_command
import git_config
import manifest_xml
import progress
from project import SyncNetworkHalfResult
from subcmds import sync
@@ -555,7 +558,9 @@ def test_cli_smart_sync_with_config(
cmd = sync.Sync(manifest=manifest)
opts, args = cmd.OptionParser.parse_args(argv)
cmd.ValidateOptions(opts, args)
with mock.patch.object(sync.RepoConfig, "ForUser", return_value=user_cfg):
with mock.patch.object(
git_config.RepoConfig, "ForUser", return_value=user_cfg
):
implicit = cmd._ResolveSmartSyncOption(opts, manifest)
assert opts.smart_sync == expected_smart_sync
assert implicit == expected_implicit
@@ -603,7 +608,9 @@ def test_user_config_smart_sync(
cmd = sync.Sync(manifest=manifest)
opts, args = cmd.OptionParser.parse_args(argv)
cmd.ValidateOptions(opts, args)
with mock.patch.object(sync.RepoConfig, "ForUser", return_value=user_cfg):
with mock.patch.object(
git_config.RepoConfig, "ForUser", return_value=user_cfg
):
implicit = cmd._ResolveSmartSyncOption(opts, manifest)
user_cfg.GetBoolean.assert_called_once_with("sync.smartsync")
assert opts.smart_sync == expected_smart_sync
@@ -622,7 +629,7 @@ def test_user_config_smart_sync_from_file(
) -> None:
"""Tests that sync.smartsync is read from ~/.repoconfig/config."""
# ForUser() caches its instance process-wide; make it pick up this HOME.
monkeypatch.setattr(sync.RepoConfig, "_ForUser", None)
monkeypatch.setattr(git_config.RepoConfig, "_ForUser", None)
monkeypatch.delenv("REPO_CONFIG_DIR", raising=False)
cfg_dir = tmp_home_dir / ".repoconfig"
cfg_dir.mkdir()
@@ -684,7 +691,9 @@ def test_cli_manifest_server_credentials(
cmd = sync.Sync(manifest=manifest)
opts, args = cmd.OptionParser.parse_args(argv)
with mock.patch.object(sync.RepoConfig, "ForUser", return_value=user_cfg):
with mock.patch.object(
git_config.RepoConfig, "ForUser", return_value=user_cfg
):
if expected_exception:
with pytest.raises(expected_exception):
cmd.ValidateOptions(opts, args)
@@ -979,6 +988,7 @@ class FakeProject:
self.use_git_worktrees = False
self.UseAlternates = False
self.UseFetchCmd = False
self.UseReprojectCmd = False
self.manifest = mock.MagicMock()
self.manifest.GetProjectsWithName.return_value = [self]
@@ -1425,6 +1435,7 @@ class CheckForBloatedProjects(unittest.TestCase):
self.project.name = "project"
self.project.Exists = True
self.project.worktree = self.tempdirobj.name
self.project.UseFetchCmd = False
self.project.stateless_prune_needed = False
self.cmd.git_event_log = mock.MagicMock()
self.cmd._bloated_projects = []
@@ -1492,7 +1503,7 @@ class CheckForBloatedProjects(unittest.TestCase):
self.assertFalse(self.cmd.git_event_log.ErrorEvent.called)
@mock.patch.object(git_command, "git_require", return_value=True)
@mock.patch("subcmds.sync.Progress")
@mock.patch.object(progress, "Progress")
def test_network_only_skips_check(
self, mock_progress: mock.Mock, mock_git_require: mock.Mock
) -> None:
@@ -1506,7 +1517,7 @@ class CheckForBloatedProjects(unittest.TestCase):
self.cmd.ExecuteInParallel.assert_not_called()
@mock.patch.object(git_command, "git_require", return_value=True)
@mock.patch("subcmds.sync.Progress")
@mock.patch.object(progress, "Progress")
def test_projects_without_worktree_excluded(
self, mock_progress: mock.Mock, mock_git_require: mock.Mock
) -> None:
@@ -1528,7 +1539,7 @@ class CheckForBloatedProjects(unittest.TestCase):
self.cmd.ExecuteInParallel.assert_not_called()
@mock.patch.object(git_command, "git_require")
@mock.patch("subcmds.sync.Progress")
@mock.patch.object(progress, "Progress")
def test_bloated_project_found(self, mock_progress, mock_git_require):
"""Test that it adds project to _bloated_projects."""
mock_git_require.return_value = True
@@ -1551,7 +1562,7 @@ class CheckForBloatedProjects(unittest.TestCase):
self.assertEqual(self.cmd._bloated_projects, ["project"])
@mock.patch.object(git_command, "git_require")
@mock.patch("subcmds.sync.Progress")
@mock.patch.object(progress, "Progress")
def test_stateless_prune_excluded(self, mock_progress, mock_git_require):
"""Test that projects pruned for stateless sync are excluded."""
mock_git_require.return_value = True
@@ -1565,6 +1576,22 @@ class CheckForBloatedProjects(unittest.TestCase):
self.assertFalse(self.cmd.ExecuteInParallel.called)
self.assertEqual(self.cmd._bloated_projects, [])
@mock.patch.object(git_command, "git_require", return_value=True)
@mock.patch.object(progress, "Progress")
def test_fetch_cmd_excluded(
self, mock_progress: mock.Mock, mock_git_require: mock.Mock
) -> None:
"""Projects using repo.fetchcmd are excluded from the bloat check."""
self.project.UseFetchCmd = True
self.cmd.ExecuteInParallel = mock.Mock()
with mock.patch.object(self.cmd, "ParallelContext"):
self.cmd._CheckForBloatedProjects([self.project], self.opt)
mock_progress.assert_not_called()
self.cmd.ExecuteInParallel.assert_not_called()
self.assertEqual(self.cmd._bloated_projects, [])
class GCProjectsTest(unittest.TestCase):
"""Tests for Sync._GCProjects."""
@@ -1583,7 +1610,7 @@ class GCProjectsTest(unittest.TestCase):
self.project.bare_git._project = self.project
self.cmd.git_event_log = mock.MagicMock()
@mock.patch("subcmds.sync.Progress")
@mock.patch.object(progress, "Progress")
def test_GCProjects_skip_gc(self, mock_progress):
"""Test that it skips GC if opt.auto_gc is False."""
self.opt.auto_gc = False
@@ -1594,7 +1621,7 @@ class GCProjectsTest(unittest.TestCase):
mock_set_state.assert_called_once_with(self.project, self.opt)
self.assertFalse(self.project.bare_git.gc.called)
@mock.patch("subcmds.sync.Progress")
@mock.patch.object(progress, "Progress")
def test_GCProjects_sequential(self, mock_progress):
"""Test sequential GC (jobs < 2)."""
with mock.patch.object(sync.Sync, "_SetPreciousObjectsState"):
@@ -1606,7 +1633,7 @@ class GCProjectsTest(unittest.TestCase):
for call in self.project.config.SetString.call_args_list:
self.assertNotEqual(call.args[0], "gc.autoDetach")
@mock.patch("subcmds.sync.Progress")
@mock.patch.object(progress, "Progress")
def test_GCProjects_parallel(self, mock_progress):
"""Test parallel GC (jobs >= 2)."""
self.opt.jobs = 2
@@ -1654,6 +1681,7 @@ class SyncCommand(unittest.TestCase):
self.project = p = mock.MagicMock(
use_git_worktrees=False,
UseAlternates=False,
UseFetchCmd=False,
UseReprojectCmd=False,
name="project",
relpath="rel_path",
@@ -1861,7 +1889,7 @@ class SyncCommand(unittest.TestCase):
user_cfg = mock.MagicMock()
user_cfg.GetBoolean.return_value = False
with mock.patch.object(
sync.RepoConfig, "ForUser", return_value=user_cfg
git_config.RepoConfig, "ForUser", return_value=user_cfg
):
with mock.patch.object(self.cmd, "_SmartSyncSetup") as mock_setup:
with mock.patch.object(self.cmd, "_UpdateRepoProject"):
@@ -1884,7 +1912,7 @@ class SyncCommand(unittest.TestCase):
user_cfg = mock.MagicMock()
user_cfg.GetBoolean.return_value = True
with mock.patch.object(
sync.RepoConfig, "ForUser", return_value=user_cfg
git_config.RepoConfig, "ForUser", return_value=user_cfg
):
with mock.patch.object(
self.cmd,
@@ -3012,8 +3040,6 @@ class TestSmartSyncSetupRemoteHelper(unittest.TestCase):
self, mock_transport_class, mock_server_class, mock_popen, mock_which
):
"""Test _SmartSyncSetup when a helper is present and succeeds."""
import subprocess
self.manifest.manifest_server = (
"persistent-https://android-smartsync.corp.google.com/"
"manifestserver"