mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-11-10 22:41:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7c431b996 | ||
|
|
020767b9d3 | ||
|
|
0f83e19bbb | ||
|
|
221a31cd0a | ||
|
|
ccddf51633 | ||
|
|
72e3c8c277 | ||
|
|
4cef373d98 | ||
|
|
95d7c8c0df | ||
|
|
616396317c | ||
|
|
4978203b07 | ||
|
|
cc3f52cd6c | ||
|
|
e045aa19d8 | ||
|
|
c382d3807a | ||
|
|
9d01d216e0 | ||
|
|
52f01a18da | ||
|
|
3a3d1fc7cd | ||
|
|
2029997aef | ||
|
|
caa4cd6564 | ||
|
|
e15b3e0298 | ||
|
|
a4b44c5026 | ||
|
|
8f2c0f1483 | ||
|
|
7ed9700a95 | ||
|
|
d621aa8ee4 | ||
|
|
b16e5f49d6 | ||
|
|
6596cb6499 | ||
|
|
ecb91f465e | ||
|
|
3656290156 | ||
|
|
b90c7cd8f9 | ||
|
|
c5340585b8 | ||
|
|
86a7bec83d | ||
|
|
338ddb7d83 | ||
|
|
8c316d7c98 | ||
|
|
e3e8e8a3b0 | ||
|
|
cad9f591a4 | ||
|
|
24197b8ae1 | ||
|
|
bfb9869b89 | ||
|
|
887f9a9014 | ||
|
|
dc1811ba23 | ||
|
|
9aac80dafc | ||
|
|
f4ce9572d1 | ||
|
|
cc1f41d782 | ||
|
|
f7d252f5dc | ||
|
|
f95e7cd899 | ||
|
|
2c04b98c60 | ||
|
|
4212588c20 | ||
|
|
be06f8dd01 | ||
|
|
74d887ea86 | ||
|
|
29d644a5c0 | ||
|
|
a93741993b | ||
|
|
9991aa6177 | ||
|
|
d744eb15f3 | ||
|
|
f0124d0fd5 | ||
|
|
46e9866ca0 | ||
|
|
404aec7b53 | ||
|
|
a0bc2b14a3 | ||
|
|
3448df3954 | ||
|
|
a110680ff3 | ||
|
|
178b66ee3e | ||
|
|
beda65323e | ||
|
|
02e07525f9 | ||
|
|
2e8a3805bb | ||
|
|
277759b9e1 | ||
|
|
9593a76fd1 | ||
|
|
9293cda144 | ||
|
|
340b587e94 | ||
|
|
0a4e68f9d2 | ||
|
|
7bba4ee47e | ||
|
|
d53a4278f5 | ||
|
|
5d4149f32e | ||
|
|
cf499e312a | ||
|
|
cc0546bc71 | ||
|
|
ccf9ce39b8 | ||
|
|
9066409c2d | ||
|
|
2908f596c5 | ||
|
|
e1e215a14e | ||
|
|
175198b0aa | ||
|
|
310c99571f | ||
|
|
e7d4d97ffc | ||
|
|
8c7e0a683e | ||
|
|
f6f5946422 | ||
|
|
6321b26685 | ||
|
|
7bffc72b5b | ||
|
|
aaadd5da35 | ||
|
|
c9448f986e | ||
|
|
530258d08e | ||
|
|
c1566487a8 | ||
|
|
22f820de17 | ||
|
|
4c37f58806 | ||
|
|
c86ddc1628 | ||
|
|
c638b54e19 | ||
|
|
fb4a91060e | ||
|
|
4b3ada1781 | ||
|
|
a468ea7752 | ||
|
|
c2c330ba4a | ||
|
|
cc88be34d2 | ||
|
|
16cfb53e0a | ||
|
|
578b57c975 | ||
|
|
fe2c23f8e2 | ||
|
|
d27d6829a8 | ||
|
|
0ea57e2eed | ||
|
|
ba8ddf396c | ||
|
|
d88ce8d952 | ||
|
|
5e8d2a6e3a | ||
|
|
e59c9cde99 | ||
|
|
83428a9b26 | ||
|
|
948abc85bc | ||
|
|
c63a2f92fa | ||
|
|
0039e39000 | ||
|
|
5f378458d2 | ||
|
|
d27034bf62 | ||
|
|
6541729a18 | ||
|
|
b85e76a86a | ||
|
|
e5bbb5c9e6 | ||
|
|
4fe87617ff | ||
|
|
d7299422ae | ||
|
|
3f087a8dd9 | ||
|
|
3a6e25af75 | ||
|
|
41c2597509 | ||
|
|
e6ad708009 | ||
|
|
09914bcab7 | ||
|
|
3f1775607f | ||
|
|
b85886fa9f |
No files matched your search
@@ -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,
|
||||
@@ -24,3 +24,16 @@ configs {
|
||||
}
|
||||
automatic: false
|
||||
}
|
||||
|
||||
# Default Multi-Perspective Code Review Agent
|
||||
configs {
|
||||
id: "git-repo-code-review-agent"
|
||||
display_name: "Code Review Agent"
|
||||
description: "Default multi-perspective review agent for git-repo."
|
||||
include_filters {
|
||||
project: "git-repo"
|
||||
path_regex: ".*"
|
||||
}
|
||||
use_default_subagents: true
|
||||
tags: "code-review-agent"
|
||||
}
|
||||
@@ -2004,10 +2004,10 @@ def _Run(self, *cmd):
|
||||
**Do:**
|
||||
|
||||
```python
|
||||
from git_command import GitCommand
|
||||
import git_command
|
||||
|
||||
def _Run(self, cmd):
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
capture_stdout=True,
|
||||
|
||||
+75
-26
@@ -17,12 +17,10 @@ import multiprocessing
|
||||
import optparse
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
from error import InvalidProjectGroupsError
|
||||
from error import NoSuchProjectError
|
||||
from error import RepoExitError
|
||||
from event_log import EventLog
|
||||
import error
|
||||
import event_log
|
||||
import progress
|
||||
|
||||
|
||||
@@ -48,7 +46,7 @@ WORKER_BATCH_SIZE = 32
|
||||
DEFAULT_LOCAL_JOBS = min(os.cpu_count(), 8)
|
||||
|
||||
|
||||
class UsageError(RepoExitError):
|
||||
class UsageError(error.RepoExitError):
|
||||
"""Exception thrown with invalid command usage."""
|
||||
|
||||
|
||||
@@ -59,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
|
||||
@@ -152,6 +150,20 @@ class Command:
|
||||
self._Options(self._optparse)
|
||||
return self._optparse
|
||||
|
||||
@staticmethod
|
||||
def _GetHelpForCpuJobCount(
|
||||
default_jobs: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Return CPU-based job help, with an explicit default when needed.
|
||||
|
||||
Specify default_jobs when additional logic computes effective default.
|
||||
"""
|
||||
if GENERATE_MANPAGES:
|
||||
return "based on number of CPU cores"
|
||||
|
||||
default = "%default" if default_jobs is None else str(default_jobs)
|
||||
return f"{default}; based on number of CPU cores"
|
||||
|
||||
def _CommonOptions(self, p, opt_v=True):
|
||||
"""Initialize the option parser with common options.
|
||||
|
||||
@@ -176,11 +188,7 @@ class Command:
|
||||
)
|
||||
|
||||
if self.PARALLEL_JOBS is not None:
|
||||
default = "based on number of CPU cores"
|
||||
if not GENERATE_MANPAGES:
|
||||
# Only include active cpu count if we aren't generating man
|
||||
# pages.
|
||||
default = f"%default; {default}"
|
||||
default = self._GetHelpForCpuJobCount()
|
||||
p.add_option(
|
||||
"-j",
|
||||
"--jobs",
|
||||
@@ -398,7 +406,11 @@ class Command:
|
||||
Args:
|
||||
args: a list of (case-insensitive) strings, projects to search for.
|
||||
manifest: an XmlManifest, the manifest to use, or None for default.
|
||||
groups: a string, the manifest groups in use.
|
||||
groups: a string, the manifest group selection to apply.
|
||||
Non-empty values apply to all candidate projects in this call.
|
||||
When empty or omitted, single-manifest calls use the selected
|
||||
manifest's effective groups; all-manifest calls use each
|
||||
candidate project's owning manifest's effective groups.
|
||||
missing_ok: a boolean, whether to allow missing projects.
|
||||
submodules_ok: whether to allow submodules. True allows them for
|
||||
all projects, False disallows them for all projects, and None
|
||||
@@ -425,9 +437,33 @@ class Command:
|
||||
return project.sync_s
|
||||
return submodules_ok
|
||||
|
||||
if not groups:
|
||||
groups = manifest.GetManifestGroupsStr()
|
||||
groups = [x for x in re.split(r"[,\s]+", groups) if x]
|
||||
def parse_groups(value: str) -> List[str]:
|
||||
return [x for x in re.split(r"[,\s]+", value) if x]
|
||||
|
||||
if groups:
|
||||
groups_for_all_projects = parse_groups(groups)
|
||||
elif all_manifests:
|
||||
# In all-manifest mode, each project uses its owning
|
||||
# manifest's effective groups.
|
||||
groups_for_all_projects = None
|
||||
else:
|
||||
groups_for_all_projects = parse_groups(
|
||||
manifest.GetManifestGroupsStr()
|
||||
)
|
||||
|
||||
groups_by_manifest = {}
|
||||
|
||||
def matches_groups(project: "Project") -> bool:
|
||||
if groups_for_all_projects is not None:
|
||||
return project.MatchesGroups(groups_for_all_projects)
|
||||
|
||||
project_manifest = project.manifest
|
||||
if project_manifest not in groups_by_manifest:
|
||||
groups_by_manifest[project_manifest] = parse_groups(
|
||||
project_manifest.GetManifestGroupsStr()
|
||||
)
|
||||
|
||||
return project.MatchesGroups(groups_by_manifest[project_manifest])
|
||||
|
||||
if not args:
|
||||
derived_projects = {}
|
||||
@@ -439,9 +475,7 @@ class Command:
|
||||
)
|
||||
all_projects_list.extend(derived_projects.values())
|
||||
for project in all_projects_list:
|
||||
if (missing_ok or project.Exists) and project.MatchesGroups(
|
||||
groups
|
||||
):
|
||||
if (missing_ok or project.Exists) and matches_groups(project):
|
||||
result.append(project)
|
||||
else:
|
||||
self._ResetPathToProjectMap(all_projects_list)
|
||||
@@ -455,7 +489,7 @@ class Command:
|
||||
for project in manifest.GetProjectsWithName(
|
||||
arg, all_manifests=all_manifests
|
||||
)
|
||||
if project.MatchesGroups(groups)
|
||||
if matches_groups(project)
|
||||
]
|
||||
|
||||
if not projects:
|
||||
@@ -490,16 +524,16 @@ class Command:
|
||||
projects = [project]
|
||||
|
||||
if not projects:
|
||||
raise NoSuchProjectError(arg)
|
||||
raise error.NoSuchProjectError(arg)
|
||||
|
||||
for project in projects:
|
||||
if not missing_ok and not project.Exists:
|
||||
raise NoSuchProjectError(
|
||||
raise error.NoSuchProjectError(
|
||||
"%s (%s)"
|
||||
% (arg, project.RelPath(local=not all_manifests))
|
||||
)
|
||||
if not project.MatchesGroups(groups):
|
||||
raise InvalidProjectGroupsError(arg)
|
||||
if not matches_groups(project):
|
||||
raise error.InvalidProjectGroupsError(arg)
|
||||
|
||||
result.extend(projects)
|
||||
|
||||
@@ -509,7 +543,14 @@ class Command:
|
||||
result.sort(key=_getpath)
|
||||
return result
|
||||
|
||||
def FindProjects(self, args, inverse=False, all_manifests=False):
|
||||
def FindProjects(
|
||||
self,
|
||||
args: List[str],
|
||||
inverse: bool = False,
|
||||
all_manifests: bool = False,
|
||||
groups: Optional[str] = "",
|
||||
missing_ok: Optional[bool] = False,
|
||||
) -> List["Project"]:
|
||||
"""Find projects from command line arguments.
|
||||
|
||||
Args:
|
||||
@@ -519,10 +560,18 @@ class Command:
|
||||
all_manifests: a boolean, if True then all manifests and
|
||||
submanifests are used. If False, then only the local
|
||||
(sub)manifest is used.
|
||||
groups: a string specifying manifest groups. If empty or None, use
|
||||
each manifest's effective groups.
|
||||
missing_ok: a boolean, whether to allow missing projects.
|
||||
"""
|
||||
result = []
|
||||
patterns = [re.compile(r"%s" % a, re.IGNORECASE) for a in args]
|
||||
for project in self.GetProjects("", all_manifests=all_manifests):
|
||||
for project in self.GetProjects(
|
||||
"",
|
||||
groups=groups,
|
||||
missing_ok=missing_ok,
|
||||
all_manifests=all_manifests,
|
||||
):
|
||||
paths = [project.name, project.RelPath(local=not all_manifests)]
|
||||
for pattern in patterns:
|
||||
match = any(pattern.search(x) for x in paths)
|
||||
|
||||
@@ -24,6 +24,7 @@ _repo() {
|
||||
'branches:View current topic branches'
|
||||
'checkout:Checkout a branch'
|
||||
'cherry-pick:Cherry-pick a change'
|
||||
'config:Read and write per-user repo settings'
|
||||
'diff:Show changes between commit and working tree'
|
||||
'diffmanifests:Show differences between two manifests'
|
||||
'download:Download and find a change'
|
||||
@@ -117,6 +118,15 @@ _repo() {
|
||||
${common_opts} \
|
||||
'1: :_message "sha1"'
|
||||
;;
|
||||
config)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--type=[Validate and canonicalize the value as this type]:type:(bool int)' \
|
||||
'(-z --null)'{-z,--null}'[Terminate output with NUL]' \
|
||||
'1: :(get set unset list)' \
|
||||
'2: :_message "name"' \
|
||||
'3: :_message "value"'
|
||||
;;
|
||||
diff)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
@@ -236,6 +246,9 @@ _repo() {
|
||||
'--clone-filter=[Clone filter]:filter:' \
|
||||
'--use-superproject[Use superproject]' \
|
||||
'--no-use-superproject[Do not use superproject]' \
|
||||
'(-s --smart-sync)'{-s,--smart-sync}'[Default to smart sync]' \
|
||||
'--no-smart-sync[Do not default to smart sync]' \
|
||||
'--head[Do not default to smart sync]' \
|
||||
'--clone-bundle[Use clone bundle]' \
|
||||
'--no-clone-bundle[Do not use clone bundle]' \
|
||||
'--git-lfs[Use git lfs]' \
|
||||
@@ -337,6 +350,8 @@ _repo() {
|
||||
'--auto-gc[Run auto gc]' \
|
||||
'--no-auto-gc[Do not run auto gc]' \
|
||||
'(-s --smart-sync)'{-s,--smart-sync}'[Smart sync]' \
|
||||
'--no-smart-sync[Do not smart sync]' \
|
||||
'--head[Do not smart sync]' \
|
||||
'(-t --smart-tag)'{-t,--smart-tag=}'[Smart tag]:tag:' \
|
||||
'--no-repo-verify[Do not verify repo]' \
|
||||
'--no-verify[Do not verify]' \
|
||||
@@ -396,10 +411,12 @@ _repo() {
|
||||
'(-y --yes)'{-y,--yes}'[Answer yes to all prompts]' \
|
||||
'--ignore-untracked-files[Ignore untracked files]' \
|
||||
'--no-ignore-untracked-files[Do not ignore untracked files]' \
|
||||
'--ignore-remote-commits[Ignore commits already in remote branches]' \
|
||||
'--no-cert-checks[Do not check certificates]' \
|
||||
'--no-verify[Do not verify]' \
|
||||
'--verify[Verify]' \
|
||||
'--ignore-hooks[Ignore hooks]' \
|
||||
'--fix[Automatically fix]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
version)
|
||||
|
||||
+28
-16
@@ -6,6 +6,9 @@ executed during `repo sync` to fetch objects, instead of using standard
|
||||
filesystems or lazy checkouts where fetching metadata and downloading file
|
||||
contents should be decoupled.
|
||||
|
||||
The checkout half of a sync has a counterpart, `repo.reprojectcmd`; see
|
||||
`docs/reproject-cmd.md`.
|
||||
|
||||
## Configuration
|
||||
|
||||
To use this feature, set the following in `.repo/manifests.git/config`:
|
||||
@@ -25,7 +28,10 @@ Environment section in `repo help forall` or `subcmds/forall.py`.
|
||||
|
||||
The following environment variable is specific to `repo.fetchcmd`:
|
||||
|
||||
* `REPO_TREV`: The target revision resolved to a full commit hash.
|
||||
* `REPO_TREV`: The target commit hash when the revision is already pinned to
|
||||
a commit (e.g. via a superproject or a commit SHA in the manifest), or
|
||||
empty when `REPO_RREV` is an unpinned branch or tag to be resolved by the
|
||||
fetch command.
|
||||
|
||||
## Contract
|
||||
|
||||
@@ -34,22 +40,23 @@ The following environment variable is specific to `repo.fetchcmd`:
|
||||
After the fetch command exits with status 0, `repo` expects the following
|
||||
postconditions to be met:
|
||||
|
||||
1. `git cat-file -e REPO_TREV` succeeds (the commit must exist in the object
|
||||
store).
|
||||
2. The mapped local tracking ref (e.g. `refs/remotes/REPO_REMOTE/<branch>`
|
||||
for a branch revision, or the tag ref itself for a tag) must point to
|
||||
`REPO_TREV`.
|
||||
3. `FETCH_HEAD` must point to `REPO_TREV`.
|
||||
4. The commit graph from `REPO_TREV` must be reachable far enough to compute
|
||||
merge bases with local branches.
|
||||
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 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
|
||||
|
||||
* The command should be idempotent; fetching the same `REPO_TREV` twice should
|
||||
be a no-op.
|
||||
* Only `FETCH_HEAD` and `refs/remotes/*` should be modified to preserve
|
||||
`repo sync --network-only` semantics. `HEAD` and local branches must not be
|
||||
touched by the fetch command.
|
||||
* The command should be idempotent; repeating the same fetch should be a
|
||||
no-op.
|
||||
* Only `FETCH_HEAD`, `refs/remotes/*`, and `refs/tags/*` should be modified to
|
||||
preserve `repo sync --network-only` semantics. `HEAD` and local branches
|
||||
must not be touched by the fetch command.
|
||||
* Dirty worktree state must be preserved.
|
||||
* The command is **not** executed for `MetaProject`s (i.e. the internal `repo`
|
||||
repository itself at `.repo/repo` and the `manifests` repository at
|
||||
@@ -59,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.
|
||||
@@ -176,6 +176,7 @@ User controlled settings are initialized when running `repo init`.
|
||||
| repo.partialclone | `--partial-clone` | Create [partial git clones] |
|
||||
| repo.partialcloneexclude | `--partial-clone-exclude` | Comma-delimited list of project names (not paths) to exclude while using [partial git clones] |
|
||||
| repo.reference | `--reference` | Reference repo client checkout |
|
||||
| repo.smartsync | `--smart-sync` | Default to [Smart Sync](./smart-sync.md) when syncing |
|
||||
| repo.submodules | `--submodules` | Sync git submodules |
|
||||
| repo.superproject | `--use-superproject` | Sync [superproject] |
|
||||
| repo.worktree | `--worktree` | Use [git worktree] for checkouts |
|
||||
@@ -253,6 +254,8 @@ setting `REPO_CONFIG_DIR`.
|
||||
|
||||
* `.repoconfig/`: Repo's per-user directory for all random config files/state.
|
||||
* `.repoconfig/config`: Per-user settings using [git-config] file format.
|
||||
Managed with `repo config`; see [repoconfig.md](./repoconfig.md) for the
|
||||
available settings.
|
||||
* `.repoconfig/keyring-version`: Cache file for checking if the gnupg subdir
|
||||
has all the same keys as the repo launcher. Used to avoid running gpg
|
||||
constantly as that can be quite slow.
|
||||
|
||||
+82
-16
@@ -46,18 +46,19 @@ following DTD:
|
||||
<!ATTLIST remote revision CDATA #IMPLIED>
|
||||
|
||||
<!ELEMENT default EMPTY>
|
||||
<!ATTLIST default remote IDREF #IMPLIED>
|
||||
<!ATTLIST default revision CDATA #IMPLIED>
|
||||
<!ATTLIST default dest-branch CDATA #IMPLIED>
|
||||
<!ATTLIST default upstream CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-j CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-j-max CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-c CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-s CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-tags CDATA #IMPLIED>
|
||||
<!ATTLIST default remote IDREF #IMPLIED>
|
||||
<!ATTLIST default revision CDATA #IMPLIED>
|
||||
<!ATTLIST default dest-branch CDATA #IMPLIED>
|
||||
<!ATTLIST default upstream CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-j CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-j-max CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-c CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-s CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-tags CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-smartsync CDATA #IMPLIED>
|
||||
|
||||
<!ELEMENT manifest-server EMPTY>
|
||||
<!ATTLIST manifest-server url CDATA #REQUIRED>
|
||||
<!ATTLIST manifest-server url CDATA #REQUIRED>
|
||||
<!ATTLIST manifest-server helper CDATA #IMPLIED>
|
||||
|
||||
<!ELEMENT submanifest EMPTY>
|
||||
@@ -73,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>
|
||||
@@ -101,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>
|
||||
@@ -231,6 +237,8 @@ Attribute `sync-tags`: Set to false to only sync the given Git
|
||||
branch (specified in the `revision` attribute) rather than
|
||||
the other ref tags.
|
||||
|
||||
Attribute `sync-smartsync`: Set to true to enable smart sync by default.
|
||||
|
||||
|
||||
### Element manifest-server
|
||||
|
||||
@@ -421,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.
|
||||
@@ -468,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
|
||||
|
||||
@@ -45,10 +45,10 @@ that are critical to us.
|
||||
That said, let's assume we need functionality that only exists in Python 3.7.
|
||||
How do we decide when it's acceptable to drop Python 3.6?
|
||||
|
||||
1. Review the [Project References](./release-process.md#project-references) to
|
||||
see what major distros are using the previous version of Python, and when
|
||||
they go EOL. Generally we care about Ubuntu LTS & current/previous Debian
|
||||
stable versions.
|
||||
1. Review what major distros are using the previous version of Python (e.g.
|
||||
via [Repology Python versions]), and when they go EOL (e.g. via
|
||||
[Ubuntu releases] & [Debian releases]). Generally we care about Ubuntu LTS
|
||||
& current/previous Debian stable versions.
|
||||
* If they're all EOL already, then go for it, drop support.
|
||||
* If they aren't EOL, start a thread on [repo-discuss] to see how the user
|
||||
base feels about the proposal.
|
||||
@@ -88,5 +88,8 @@ $ repo-2.32 init --repo-rev=repo-1 ...
|
||||
|
||||
|
||||
[repo-discuss]: https://groups.google.com/forum/#!forum/repo-discuss
|
||||
[Debian releases]: https://wiki.debian.org/DebianReleases#Versions_of_Debian_stable
|
||||
[Repology Python versions]: https://repology.org/project/python/versions
|
||||
[repo hooks]: ./repo-hooks.md
|
||||
[repo launcher]: ../repo
|
||||
[Ubuntu releases]: https://wiki.ubuntu.com/Releases
|
||||
+54
-200
@@ -5,11 +5,19 @@ related topics and flows.
|
||||
|
||||
[TOC]
|
||||
|
||||
## Schedule
|
||||
## Schedule {#schedule}
|
||||
|
||||
Releases are automated.
|
||||
Every Tuesday morning [US PT], a pipeline checks if any new commits have landed
|
||||
since last release.
|
||||
If so, it tags & tests the latest `main`, pushes & announces the tag, and then
|
||||
promotes it to `stable` a day later.
|
||||
The release is skipped if there's a holiday or [production freeze](#freeze)
|
||||
that week.
|
||||
Maintainers can also trigger it by hand.
|
||||
The [manual steps](#manual) below are only needed as a fallback, or to roll
|
||||
back a release.
|
||||
|
||||
There is no specific schedule for when releases are made.
|
||||
Usually it's more along the lines of "enough minor changes have been merged",
|
||||
or "there's a known issue the maintainers know should get fixed".
|
||||
If you find a fix has been merged for an issue important to you, but hasn't been
|
||||
released after a week or so, feel free to [contact] us to request a new release.
|
||||
|
||||
@@ -48,6 +56,11 @@ Whenever major changes are made to the launcher script, you should increment the
|
||||
At runtime, repo will check this to see if it needs to be updated (and notify
|
||||
the user automatically).
|
||||
|
||||
When `VERSION` changes, the release pipeline publishes the new launcher to the
|
||||
`git-repo-downloads` Google Cloud Storage bucket.
|
||||
To publish it by hand, run `./release/sign-launcher.py`, which signs the
|
||||
launcher and prints the commands to upload it.
|
||||
|
||||
## Key management
|
||||
|
||||
Every release has a git tag that is signed with a key that repo recognizes.
|
||||
@@ -96,8 +109,8 @@ If that tag is valid, then repo will warn and use that commit instead.
|
||||
|
||||
If that tag cannot be verified, it gives up and forces the user to resolve.
|
||||
|
||||
If env variable `REPO_SKIP_SELF_UPDATE` is defined, this will
|
||||
bypass the self update algorithm.
|
||||
If the env variable `REPO_SKIP_SELF_UPDATE` is set to anything other than `0`,
|
||||
`repo sync` will bypass the self update algorithm.
|
||||
|
||||
### Force an update
|
||||
|
||||
@@ -110,29 +123,20 @@ All development happens on the `main` branch and should generally be stable.
|
||||
|
||||
Since the repo launcher defaults to tracking the `stable` branch, it is not
|
||||
normally updated until a new release is available.
|
||||
If something goes wrong with a new release, an older release can be force pushed
|
||||
and clients will automatically downgrade.
|
||||
|
||||
The `maint` branch is used to track the previous major release of repo.
|
||||
It is not normally meant to be used by people as `stable` should be good enough.
|
||||
Once a new major release is pushed to the `stable` branch, then the previous
|
||||
major release can be pushed to `maint`.
|
||||
For example, when `stable` moves from `v1.10.x` to `v1.11.x`, then the `maint`
|
||||
branch will be updated from `v1.9.x` to `v1.10.x`.
|
||||
If something goes wrong with a new release, `stable` can be pointed back at an
|
||||
older release and clients will automatically downgrade.
|
||||
|
||||
We don't have parallel release branches/series.
|
||||
Typically all tags are made against the `main` branch and then pushed to the
|
||||
Typically all tags are made against the `main` branch and then promoted to the
|
||||
`stable` branch to make it available to the rest of the world.
|
||||
Since repo doesn't typically see a lot of changes, this tends to be OK.
|
||||
|
||||
## Creating a new release
|
||||
## Creating a new release {#manual}
|
||||
|
||||
When you want to create a new release, you'll need to select a good version and
|
||||
Releases are normally created automatically (see [Schedule](#schedule)).
|
||||
If you need to create one by hand, you'll need to select a good version and
|
||||
create a signed tag using a key registered in repo itself.
|
||||
Typically we just tag the latest version of the `main` branch.
|
||||
The tag could be pushed now, but it won't be used by clients normally (since the
|
||||
default `repo-rev` setting is `stable`).
|
||||
This would allow some early testing on systems who explicitly select `main`.
|
||||
|
||||
### Creating a signed tag
|
||||
|
||||
@@ -147,10 +151,10 @@ internal processes for accessing the restricted keys.
|
||||
|
||||
```sh
|
||||
# Pick the new version.
|
||||
$ t=v2.30
|
||||
$ t=v2.XX
|
||||
|
||||
# Create a new signed tag with the current HEAD.
|
||||
$ ./release/sign-tag.py $t
|
||||
# Create a new signed tag of the latest main branch.
|
||||
$ ./release/sign-tag.py $t origin/main
|
||||
|
||||
# Verify the signed tag.
|
||||
$ git show $t
|
||||
@@ -158,199 +162,49 @@ $ git show $t
|
||||
|
||||
### Push the new release
|
||||
|
||||
Once you're ready to make the release available to everyone, push it to the
|
||||
`stable` branch.
|
||||
|
||||
Make sure you never push the tag itself to the stable branch!
|
||||
Only push the commit -- note the use of `^0` below.
|
||||
First push the tag, [announce](#announce) it, and wait a day to get feedback
|
||||
from people testing new releases early.
|
||||
|
||||
```sh
|
||||
$ git push https://gerrit-review.googlesource.com/git-repo $t
|
||||
$ git push https://gerrit-review.googlesource.com/git-repo $t^0:stable
|
||||
```
|
||||
|
||||
If something goes horribly wrong, you can force push the previous version to the
|
||||
`stable` branch and people should automatically recover.
|
||||
Again, make sure you never push the tag itself!
|
||||
Then make the release available to everyone by updating the `stable` branch.
|
||||
Direct pushes to it are rejected, so it has to be deleted & recreated via the
|
||||
authenticated [Gerrit REST API] (see [go/repo-release] for maintainers).
|
||||
|
||||
Make sure you never point the stable branch at the tag itself!
|
||||
Only use the commit -- note the use of `^0` below.
|
||||
The commit is resolved before deleting anything so that a bad `$t` can't leave
|
||||
`stable` missing.
|
||||
|
||||
```sh
|
||||
$ oldrev="whatever-old-commit"
|
||||
$ git push https://gerrit-review.googlesource.com/git-repo $oldrev:stable --force
|
||||
$ api=https://gerrit-review.googlesource.com/a/projects/git-repo/branches/stable
|
||||
$ rev=$(git rev-parse --verify "$t^0") && \
|
||||
curl --fail -X DELETE "$api" && \
|
||||
curl --fail -X PUT -H 'Content-Type: application/json' \
|
||||
-d "{\"revision\":\"$rev\"}" "$api"
|
||||
```
|
||||
|
||||
### Announce the release
|
||||
If something goes horribly wrong, run the same commands with the previous
|
||||
release (e.g. `t=$(git describe --abbrev=0 $t^0~)`) and people should
|
||||
automatically recover.
|
||||
|
||||
Once you do push a new release to `stable`, make sure to announce it on the
|
||||
[repo-discuss@googlegroups.com] group.
|
||||
### Announce the release {#announce}
|
||||
|
||||
Once you push a new release, make sure to announce it on the
|
||||
[repo-discuss@googlegroups.com] group, and follow up once it's on `stable`.
|
||||
Here is an [example announcement].
|
||||
|
||||
You can create a short changelog using the command:
|
||||
You can create a short changelog since the previous release using the command:
|
||||
|
||||
```sh
|
||||
# If you haven't pushed to the stable branch yet, you can use origin/stable.
|
||||
# If you have pushed, change origin/stable to the previous release tag.
|
||||
# This assumes "main" is the current tagged release. If it's newer, change it
|
||||
# to the current release tag too.
|
||||
$ git log --format="%h (%aN) %s" --no-merges origin/stable..main
|
||||
$ git log --format="%h (%aN) %s" --no-merges \
|
||||
$(git describe --abbrev=0 $t^0~)..$t
|
||||
```
|
||||
|
||||
## Project References
|
||||
|
||||
Here's a table showing the relationship of major tools, their EOL dates, and
|
||||
their status in Ubuntu & Debian.
|
||||
Those distros tend to be good indicators of how long we need to support things.
|
||||
|
||||
Things in bold indicate stuff to take note of, but does not guarantee that we
|
||||
still support them.
|
||||
Things in italics are things we used to care about but probably don't anymore.
|
||||
|
||||
| Date | EOL | [Git][rel-g] | [Python][rel-p] | [SSH][rel-o] | [Ubuntu][rel-u] / [Debian][rel-d] | Git | Python | SSH |
|
||||
|:--------:|:------------:|:------------:|:---------------:|:------------:|-----------------------------------|:---:|:------:|:---:|
|
||||
| Apr 2008 | | | | 5.0 |
|
||||
| Jun 2008 | | | | 5.1 |
|
||||
| Oct 2008 | *Oct 2013* | | 2.6.0 | | *10.04 Lucid* - 10.10 Maverick / *Squeeze* |
|
||||
| Dec 2008 | *Feb 2009* | | 3.0.0 |
|
||||
| Feb 2009 | | | | 5.2 |
|
||||
| Feb 2009 | *Mar 2012* | | | | Debian 5 Lenny | 1.5.6.5 | 2.5.2 |
|
||||
| Jun 2009 | *Jun 2016* | | 3.1.0 | | *10.04 Lucid* - 10.10 Maverick / *Squeeze* |
|
||||
| Sep 2009 | | | | 5.3 | *10.04 Lucid* |
|
||||
| Feb 2010 | *Oct 2012* | 1.7.0 | | | *10.04 Lucid* - *12.04 Precise* - 12.10 Quantal |
|
||||
| Mar 2010 | | | | 5.4 |
|
||||
| Apr 2010 | | | | 5.5 | 10.10 Maverick |
|
||||
| Apr 2010 | *Apr 2015* | | | | *10.04 Lucid* | 1.7.0.4 | 2.6.5 3.1.2 | 5.3 |
|
||||
| Jul 2010 | *Dec 2019* | | *2.7.0* | | 11.04 Natty - *<current>* |
|
||||
| Aug 2010 | | | | 5.6 |
|
||||
| Oct 2010 | | | | | 10.10 Maverick | 1.7.1 | 2.6.6 3.1.3 | 5.5 |
|
||||
| Jan 2011 | | | | 5.7 |
|
||||
| Feb 2011 | | | | 5.8 | 11.04 Natty |
|
||||
| Feb 2011 | *Feb 2016* | | | | Debian 6 Squeeze | 1.7.2.5 | 2.6.6 3.1.3 |
|
||||
| Apr 2011 | | | | | 11.04 Natty | 1.7.4 | 2.7.1 3.2.0 | 5.8 |
|
||||
| Sep 2011 | | | | 5.9 | *12.04 Precise* |
|
||||
| Oct 2011 | *Feb 2016* | | 3.2.0 | | 11.04 Natty - 12.10 Quantal |
|
||||
| Oct 2011 | | | | | 11.10 Ocelot | 1.7.5.4 | 2.7.2 3.2.2 | 5.8 |
|
||||
| Apr 2012 | | | | 6.0 | 12.10 Quantal |
|
||||
| Apr 2012 | *Apr 2019* | | | | *12.04 Precise* | 1.7.9.5 | 2.7.3 3.2.3 | 5.9 |
|
||||
| Aug 2012 | | | | 6.1 | 13.04 Raring |
|
||||
| Sep 2012 | *Sep 2017* | | 3.3.0 | | 13.04 Raring - 13.10 Saucy |
|
||||
| Oct 2012 | *Dec 2014* | 1.8.0 | | | 13.04 Raring - 13.10 Saucy |
|
||||
| Oct 2012 | | | | | 12.10 Quantal | 1.7.10.4 | 2.7.3 3.2.3 | 6.0 |
|
||||
| Mar 2013 | | | | 6.2 | 13.10 Saucy |
|
||||
| Apr 2013 | | | | | 13.04 Raring | 1.8.1.2 | 2.7.4 3.3.1 | 6.1 |
|
||||
| May 2013 | *May 2018* | | | | Debian 7 Wheezy | 1.7.10.4 | 2.7.3 3.2.3 |
|
||||
| Sep 2013 | | | | 6.3 |
|
||||
| Oct 2013 | | | | | 13.10 Saucy | 1.8.3.2 | 2.7.5 3.3.2 | 6.2 |
|
||||
| Nov 2013 | | | | 6.4 |
|
||||
| Jan 2014 | | | | 6.5 |
|
||||
| Feb 2014 | *Dec 2014* | **1.9.0** | | | *14.04 Trusty* |
|
||||
| Mar 2014 | *Mar 2019* | | *3.4.0* | | *14.04 Trusty* - 15.10 Wily / *Jessie* |
|
||||
| Mar 2014 | | | | 6.6 | *14.04 Trusty* - 14.10 Utopic |
|
||||
| Apr 2014 | *Apr 2024* | | | | *14.04 Trusty* | 1.9.1 | 2.7.5 3.4.0 | 6.6 |
|
||||
| May 2014 | *Dec 2014* | 2.0.0 |
|
||||
| Aug 2014 | *Dec 2014* | *2.1.0* | | | 14.10 Utopic - 15.04 Vivid / *Jessie* |
|
||||
| Oct 2014 | | | | 6.7 | 15.04 Vivid |
|
||||
| Oct 2014 | | | | | 14.10 Utopic | 2.1.0 | 2.7.8 3.4.2 | 6.6 |
|
||||
| Nov 2014 | *Sep 2015* | 2.2.0 |
|
||||
| Feb 2015 | *Sep 2015* | 2.3.0 |
|
||||
| Mar 2015 | | | | 6.8 |
|
||||
| Apr 2015 | *May 2017* | 2.4.0 |
|
||||
| Apr 2015 | *Jun 2020* | | | | *Debian 8 Jessie* | 2.1.4 | 2.7.9 3.4.2 |
|
||||
| Apr 2015 | | | | | 15.04 Vivid | 2.1.4 | 2.7.9 3.4.3 | 6.7 |
|
||||
| Jul 2015 | *May 2017* | 2.5.0 | | | 15.10 Wily |
|
||||
| Jul 2015 | | | | 6.9 | 15.10 Wily |
|
||||
| Aug 2015 | | | | 7.0 |
|
||||
| Aug 2015 | | | | 7.1 |
|
||||
| Sep 2015 | *May 2017* | 2.6.0 |
|
||||
| Sep 2015 | *Sep 2020* | | *3.5.0* | | *16.04 Xenial* - 17.04 Zesty / *Stretch* |
|
||||
| Oct 2015 | | | | | 15.10 Wily | 2.5.0 | 2.7.9 3.4.3 | 6.9 |
|
||||
| Jan 2016 | *Jul 2017* | *2.7.0* | | | *16.04 Xenial* |
|
||||
| Feb 2016 | | | | 7.2 | *16.04 Xenial* |
|
||||
| Mar 2016 | *Jul 2017* | 2.8.0 |
|
||||
| Apr 2016 | *Apr 2026* | | | | *16.04 Xenial* | 2.7.4 | 2.7.11 3.5.1 | 7.2 |
|
||||
| Jun 2016 | *Jul 2017* | 2.9.0 | | | 16.10 Yakkety |
|
||||
| Jul 2016 | | | | 7.3 | 16.10 Yakkety |
|
||||
| Sep 2016 | *Sep 2017* | 2.10.0 |
|
||||
| Oct 2016 | | | | | 16.10 Yakkety | 2.9.3 | 2.7.11 3.5.1 | 7.3 |
|
||||
| Nov 2016 | *Sep 2017* | *2.11.0* | | | 17.04 Zesty / *Stretch* |
|
||||
| Dec 2016 | **Dec 2021** | | **3.6.0** | | 17.10 Artful - **18.04 Bionic** - 18.10 Cosmic |
|
||||
| Dec 2016 | | | | 7.4 | 17.04 Zesty / *Debian 9 Stretch* |
|
||||
| Feb 2017 | *Sep 2017* | 2.12.0 |
|
||||
| Mar 2017 | | | | 7.5 | 17.10 Artful |
|
||||
| Apr 2017 | | | | | 17.04 Zesty | 2.11.0 | 2.7.13 3.5.3 | 7.4 |
|
||||
| May 2017 | *May 2018* | 2.13.0 |
|
||||
| Jun 2017 | *Jun 2022* | | | | *Debian 9 Stretch* | 2.11.0 | 2.7.13 3.5.3 | 7.4 |
|
||||
| Aug 2017 | *Dec 2019* | 2.14.0 | | | 17.10 Artful |
|
||||
| Oct 2017 | *Dec 2019* | 2.15.0 |
|
||||
| Oct 2017 | | | | 7.6 | **18.04 Bionic** |
|
||||
| Oct 2017 | | | | | 17.10 Artful | 2.14.1 | 2.7.14 3.6.3 | 7.5 |
|
||||
| Jan 2018 | *Dec 2019* | 2.16.0 |
|
||||
| Apr 2018 | *Mar 2021* | **2.17.0** | | | **18.04 Bionic** |
|
||||
| Apr 2018 | | | | 7.7 | 18.10 Cosmic |
|
||||
| Apr 2018 | **Apr 2028** | | | | **18.04 Bionic** | 2.17.0 | 2.7.15 3.6.5 | 7.6 |
|
||||
| Jun 2018 | *Mar 2021* | 2.18.0 |
|
||||
| Jun 2018 | **Jun 2023** | | 3.7.0 | | 19.04 Disco - **Buster** |
|
||||
| Aug 2018 | | | | 7.8 |
|
||||
| Sep 2018 | *Mar 2021* | 2.19.0 | | | 18.10 Cosmic |
|
||||
| Oct 2018 | | | | 7.9 | 19.04 Disco / **Buster** |
|
||||
| Oct 2018 | | | | | 18.10 Cosmic | 2.19.1 | 2.7.15 3.6.6 | 7.7 |
|
||||
| Dec 2018 | *Mar 2021* | **2.20.0** | | | 19.04 Disco - 19.10 Eoan / **Buster** |
|
||||
| Feb 2019 | *Mar 2021* | 2.21.0 |
|
||||
| Apr 2019 | | | | 8.0 | 19.10 Eoan |
|
||||
| Apr 2019 | | | | | 19.04 Disco | 2.20.1 | 2.7.16 3.7.3 | 7.9 |
|
||||
| Jun 2019 | | 2.22.0 |
|
||||
| Jul 2019 | **Jul 2024** | | | | **Debian 10 Buster** | 2.20.1 | 2.7.16 3.7.3 | 7.9 |
|
||||
| Aug 2019 | *Mar 2021* | 2.23.0 |
|
||||
| Oct 2019 | **Oct 2024** | | 3.8.0 | | **20.04 Focal** - 20.10 Groovy |
|
||||
| Oct 2019 | | | | 8.1 |
|
||||
| Oct 2019 | | | | | 19.10 Eoan | 2.20.1 | 2.7.17 3.7.5 | 8.0 |
|
||||
| Nov 2019 | *Mar 2021* | 2.24.0 |
|
||||
| Jan 2020 | *Mar 2021* | 2.25.0 | | | **20.04 Focal** |
|
||||
| Feb 2020 | | | | 8.2 | **20.04 Focal** |
|
||||
| Mar 2020 | *Mar 2021* | 2.26.0 |
|
||||
| Apr 2020 | **Apr 2030** | | | | **20.04 Focal** | 2.25.1 | 2.7.17 3.8.2 | 8.2 |
|
||||
| May 2020 | *Mar 2021* | 2.27.0 | | | 20.10 Groovy |
|
||||
| May 2020 | | | | 8.3 |
|
||||
| Jul 2020 | *Mar 2021* | 2.28.0 |
|
||||
| Sep 2020 | | | | 8.4 | 21.04 Hirsute / **Bullseye** |
|
||||
| Oct 2020 | *Mar 2021* | 2.29.0 |
|
||||
| Oct 2020 | | | | | 20.10 Groovy | 2.27.0 | 2.7.18 3.8.6 | 8.3 |
|
||||
| Oct 2020 | **Oct 2025** | | 3.9.0 | | 21.04 Hirsute / **Bullseye** |
|
||||
| Dec 2020 | *Mar 2021* | 2.30.0 | | | 21.04 Hirsute / **Bullseye** |
|
||||
| Mar 2021 | | 2.31.0 | | 8.5 |
|
||||
| Apr 2021 | | | | 8.6 |
|
||||
| Apr 2021 | *Jan 2022* | | | | 21.04 Hirsute | 2.30.2 | 2.7.18 3.9.4 | 8.4 |
|
||||
| Jun 2021 | | 2.32.0 |
|
||||
| Aug 2021 | | 2.33.0 | | 8.7 |
|
||||
| Aug 2021 | **Aug 2026** | | | | **Debian 11 Bullseye** | 2.30.2 | 2.7.18 3.9.2 | 8.4 |
|
||||
| Sep 2021 | | | | 8.8 |
|
||||
| Oct 2021 | | 2.34.0 | 3.10.0 | | **22.04 Jammy** |
|
||||
| Jan 2022 | | 2.35.0 |
|
||||
| Feb 2022 | | | | 8.9 | **22.04 Jammy** |
|
||||
| Apr 2022 | | 2.36.0 | | 9.0 |
|
||||
| Apr 2022 | **Apr 2032** | | | | **22.04 Jammy** | 2.34.1 | 2.7.18 3.10.6 | 8.9 |
|
||||
| Jun 2022 | | 2.37.0 |
|
||||
| Oct 2022 | | 2.38.0 | | 9.1 |
|
||||
| Oct 2022 | | | 3.11.0 | | **Bookworm** |
|
||||
| Dec 2022 | | 2.39.0 | | | **Bookworm** |
|
||||
| Feb 2023 | | | | 9.2 | **Bookworm** |
|
||||
| Mar 2023 | | 2.40.0 | | 9.3 |
|
||||
| Jun 2023 | | 2.41.0 |
|
||||
| Jun 2023 | **Jun 2028** | | | | **Debian 12 Bookworm** | 2.39.2 | 3.11.2 | 9.2 |
|
||||
| Aug 2023 | | 2.42.0 | | 9.4 |
|
||||
| Oct 2023 | | | 3.12.0 | 9.5 |
|
||||
| Nov 2022 | | 2.43.0 |
|
||||
| Dec 2023 | | | | 9.6 |
|
||||
| Feb 2024 | | 2.44.0 |
|
||||
| Mar 2024 | | | | 9.7 |
|
||||
| Oct 2024 | | | 3.13.0 |
|
||||
| **Date** | **EOL** | **[Git][rel-g]** | **[Python][rel-p]** | **[SSH][rel-o]** | **[Ubuntu][rel-u] / [Debian][rel-d]** | **Git** | **Python** | **SSH** |
|
||||
|
||||
|
||||
[contact]: ../README.md#contact
|
||||
[rel-d]: https://en.wikipedia.org/wiki/Debian_version_history
|
||||
[rel-g]: https://en.wikipedia.org/wiki/Git#Releases
|
||||
[rel-o]: https://www.openssh.com/releasenotes.html
|
||||
[rel-p]: https://en.wikipedia.org/wiki/History_of_Python#Table_of_versions
|
||||
[rel-u]: https://wiki.ubuntu.com/Releases
|
||||
[example announcement]: https://groups.google.com/d/topic/repo-discuss/UGBNismWo1M/discussion
|
||||
[repo-discuss@googlegroups.com]: https://groups.google.com/forum/#!forum/repo-discuss
|
||||
[go/repo-release]: https://goto.google.com/repo-release
|
||||
[Gerrit REST API]: https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#branch-endpoints
|
||||
+10
-2
@@ -88,7 +88,14 @@ be useful when deploying automatic fixes.
|
||||
If the repo command that triggered the hook supports a "yes" option (e.g.,
|
||||
`repo upload --yes`), this option is propagated to the hook's `main` function
|
||||
as `yes` parameter (defaulting to `False`). Hooks can use this to bypass
|
||||
interactive confirmation prompts when they can automatically fix issues.
|
||||
interactive confirmation prompts for safe non-modifying operations.
|
||||
|
||||
### Automated Fixes
|
||||
|
||||
If the repo command that triggered the hook supports a "fix" option (e.g.,
|
||||
`repo upload --fix`), this option is propagated to the hook's `main` function
|
||||
as `fix` parameter (defaulting to `False`). Hooks can use this to automatically
|
||||
apply fixes without prompting the user.
|
||||
|
||||
### Shebang Handling
|
||||
|
||||
@@ -126,7 +133,7 @@ This hook runs when people run `repo upload`.
|
||||
The `pre-upload.py` file should be defined like:
|
||||
|
||||
```py
|
||||
def main(project_list, worktree_list=None, yes=False, **kwargs):
|
||||
def main(project_list, worktree_list=None, fix=False, yes=False, **kwargs):
|
||||
"""Main function invoked directly by repo.
|
||||
|
||||
We must use the name "main" as that is what repo requires.
|
||||
@@ -137,6 +144,7 @@ def main(project_list, worktree_list=None, yes=False, **kwargs):
|
||||
project_list, so that each entry in project_list matches with a
|
||||
directory in worktree_list. If None, we will attempt to calculate
|
||||
the directories automatically.
|
||||
fix: Whether to automatically apply fixes without prompting.
|
||||
yes: Whether to answer yes to all safe prompts (see
|
||||
[Safe Prompts](#safe-prompts)).
|
||||
kwargs: Leave this here for forward-compatibility.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# repoconfig: User Configuration
|
||||
|
||||
Repo keeps a small number of settings that apply to the current user rather
|
||||
than to one checkout. They live in the per-user configuration file
|
||||
`~/.repoconfig/config`, which is separate from both git's own `~/.gitconfig`
|
||||
and the per-checkout settings stored in `.repo/manifests.git/config` (see
|
||||
[internal-fs-layout.md](./internal-fs-layout.md#Global-settings) for those).
|
||||
|
||||
[TOC]
|
||||
|
||||
## Location
|
||||
|
||||
The file is `.repoconfig/config` inside the user's home directory. The parent
|
||||
directory can be changed by setting the `REPO_CONFIG_DIR` environment variable,
|
||||
in which case repo reads `$REPO_CONFIG_DIR/.repoconfig/config` instead.
|
||||
|
||||
The file is created on demand; it's fine for it to be missing.
|
||||
|
||||
## Format
|
||||
|
||||
The file uses the [git-config] syntax. The easiest way to manage it is the
|
||||
`repo config` command:
|
||||
|
||||
```sh
|
||||
# Set a value.
|
||||
repo config set sync.smartsync true
|
||||
|
||||
# Read a value.
|
||||
repo config get sync.smartsync
|
||||
|
||||
# Remove a value.
|
||||
repo config unset sync.smartsync
|
||||
|
||||
# Show all settings.
|
||||
repo config list
|
||||
```
|
||||
|
||||
Like other repo subcommands, `repo config` must be run from inside a repo
|
||||
checkout. The file can also be edited by hand:
|
||||
|
||||
```ini
|
||||
[sync]
|
||||
smartsync = true
|
||||
```
|
||||
|
||||
Boolean settings accept `true`/`yes` and `false`/`no`.
|
||||
|
||||
## Settings
|
||||
|
||||
### alias.*
|
||||
|
||||
`alias.<name>` defines `repo <name>` as a shortcut for another subcommand,
|
||||
optionally with arguments, in the same way as git aliases:
|
||||
|
||||
```ini
|
||||
[alias]
|
||||
ss = sync -s
|
||||
st = status -o
|
||||
```
|
||||
|
||||
Aliases defined in a checkout's `.repo/config` take precedence over per-user
|
||||
ones. Existing subcommands cannot be overridden by an alias.
|
||||
|
||||
### sync
|
||||
|
||||
#### smartsync
|
||||
|
||||
`sync.smartsync` is a boolean that lets a user decide once, for all their
|
||||
checkouts, whether `repo sync` should [Smart Sync](./smart-sync.md) by
|
||||
default: `true` always Smart Syncs, as if `-s`/`--smart-sync` were passed;
|
||||
`false` never does by default, even if the manifest sets
|
||||
`sync-smartsync="true"`; unset follows the manifest.
|
||||
|
||||
The effective behavior is resolved in this order; the first match wins:
|
||||
|
||||
1. Command line: `-s`/`--smart-sync`, `--no-smart-sync`, or `-t`/`--smart-tag`.
|
||||
2. User config: `sync.smartsync` in `~/.repoconfig/config`.
|
||||
3. Manifest: `sync-smartsync="true"` on the `<default>` element.
|
||||
4. Otherwise, a regular ToT sync.
|
||||
|
||||
So `sync.smartsync=false` turns off the manifest-driven default, but an
|
||||
explicit `repo sync -s`, `repo sync -t TAG` or `repo smartsync` still works.
|
||||
Likewise `sync.smartsync=true` is overridden by `--no-smart-sync`.
|
||||
|
||||
A Smart Sync enabled by `sync.smartsync=true` is treated as an explicit
|
||||
request: it is subject to the same restrictions as `-s` (it is skipped when
|
||||
`-l`/`--local-only`, `-m`/`--manifest-name`, `-t`/`--smart-tag` or
|
||||
`--superproject-revision` is used), and a failure to obtain a manifest from
|
||||
the server fails the sync instead of silently falling back to ToT.
|
||||
|
||||
`sync.smartsync` does not affect `-l`/`--local-only`: a local-only sync keeps
|
||||
whatever `smart_sync_override.xml` the previous network sync left behind. See
|
||||
[smart-sync.md](./smart-sync.md#Client-Options) for details.
|
||||
|
||||
|
||||
[git-config]: https://git-scm.com/docs/git-config
|
||||
@@ -0,0 +1,127 @@
|
||||
# Reproject Command Contract
|
||||
|
||||
The `repo.reprojectcmd` configuration names a command that `repo sync` runs
|
||||
instead of Git to move a project's index and worktree to the tree of the
|
||||
target commit. It is the checkout-side counterpart of `repo.fetchcmd` (see
|
||||
`docs/fetch-cmd.md`): together they let an external tool take over both the
|
||||
network fetch and the materialization of a project. This is useful on
|
||||
virtualized filesystems that address content by hash, where a tree can be
|
||||
materialized far faster than `git checkout` can write every file.
|
||||
|
||||
The command only materializes the tree. `repo` then makes the ref write that
|
||||
Git would have made, using `git update-ref`.
|
||||
|
||||
## Configuration
|
||||
|
||||
To use this feature, set the following in `.repo/manifests.git/config`:
|
||||
```ini
|
||||
[repo]
|
||||
reprojectcmd = "your custom command here"
|
||||
uselocalgitdirs = true
|
||||
```
|
||||
Setting `repo.reprojectcmd` **requires** `repo.uselocalgitdirs` to be set to
|
||||
`true`.
|
||||
|
||||
For reference, this command does with Git what `repo` would otherwise do
|
||||
itself:
|
||||
```ini
|
||||
[repo]
|
||||
reprojectcmd = "git -C $REPO_PATH read-tree -m -u $REPO_TREV"
|
||||
uselocalgitdirs = true
|
||||
```
|
||||
The one-tree merge applies the change to the target, keeps local changes to
|
||||
every other path, and refuses to overwrite a modified or untracked file, so it
|
||||
enforces the preconditions below by itself. It also works for a project that
|
||||
has nothing checked out yet.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The command is executed in a subshell, from the root of the client, populated
|
||||
with standard project-context environment variables. For details on standard
|
||||
variables (such as `REPO_PROJECT`, `REPO_PATH`, `REPO_REMOTE`, etc.), see the
|
||||
Environment section in `repo help forall` or `subcmds/forall.py`.
|
||||
|
||||
The variables the command typically needs are:
|
||||
|
||||
* `REPO_PATH`: The project path relative to the root of the client.
|
||||
* `REPO_TREV`: The target revision resolved to a full commit hash. Match this
|
||||
commit's tree.
|
||||
|
||||
There is no force mode: a project that would need one never reaches the
|
||||
command (see the preconditions below).
|
||||
|
||||
## When the command runs
|
||||
|
||||
`repo sync` already classifies each project and picks a Git operation. The
|
||||
command replaces the three that are a materialization of a target tree:
|
||||
|
||||
1. The checkout that detaches HEAD at the target. This is the common case: a
|
||||
project on a detached HEAD, a project on a branch that does not track
|
||||
upstream, and `repo sync -d`.
|
||||
2. The fast-forward of the checked out branch to the target.
|
||||
3. The hard reset of the checked out branch to the target, when the commits
|
||||
it carried were dropped upstream.
|
||||
|
||||
After the command exits 0, `repo` writes the ref itself: it detaches `HEAD` at
|
||||
`REPO_TREV`, or moves the checked out branch to `REPO_TREV`.
|
||||
|
||||
The command is **not** run:
|
||||
|
||||
* When `HEAD` already names `REPO_TREV`.
|
||||
* At the fast-forward step when `HEAD` is ahead of `REPO_TREV`, where Git's
|
||||
merge would be a no-op.
|
||||
* For a rebase. A branch carrying local commits has them replayed onto the
|
||||
target by `git rebase`, which is not a materialization of a target tree.
|
||||
* For `MetaProject`s (i.e. the internal `repo` repository itself at
|
||||
`.repo/repo` and the `manifests` repository at `.repo/manifests`).
|
||||
|
||||
## Contract
|
||||
|
||||
### Preconditions
|
||||
|
||||
Before invoking the command, `repo` ensures that:
|
||||
|
||||
* The index has no staged changes (the index matches `HEAD`, or is empty on an unborn `HEAD`).
|
||||
* No rebase, cherry-pick, merge, or revert is in progress.
|
||||
|
||||
Detecting collisions with untracked files or unstaged working-tree modifications is the responsibility of the reproject command itself (e.g. via `git read-tree -m -u $REPO_TREV` or a custom virtual filesystem checkout tool). If local changes collide with the target tree, the command must abort with a non-zero exit code. Local modifications and untracked files outside the diff between `HEAD` and `REPO_TREV` must be preserved.
|
||||
|
||||
### Postconditions on exit 0
|
||||
|
||||
After the command exits with status 0, `repo` expects the following
|
||||
postconditions to be met:
|
||||
|
||||
1. 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.
|
||||
|
||||
### Invariants
|
||||
|
||||
The command may modify the worktree and the index, and may write project-local
|
||||
Git config. The command must:
|
||||
|
||||
* Apply the change from `HEAD`'s tree to `REPO_TREV`'s tree and leave every
|
||||
other path alone. Local modifications and untracked files outside that
|
||||
change must survive: the command applies a diff, it does not reset the
|
||||
tree.
|
||||
* Not write any ref, including `HEAD` and `ORIG_HEAD`. `repo` owns every ref
|
||||
write.
|
||||
* Not create or replace `.git/`, and not touch anything under `.repo/`.
|
||||
* Not require the Git remote, to preserve `repo sync --local-only`.
|
||||
* Be idempotent. Running it twice on the same target is a no-op.
|
||||
|
||||
### Failure
|
||||
|
||||
* A non-zero exit status, a failed precondition or a failed postcondition
|
||||
fails that project's sync, and the command's or Git's output is surfaced
|
||||
to the user.
|
||||
* Other projects continue, and `repo sync` exits non-zero.
|
||||
|
||||
## Limitations
|
||||
|
||||
Nested projects are out of scope: a project whose path lies inside another
|
||||
project's path, a `<project>` nested in another `<project>` in the manifest,
|
||||
and a submodule discovered with `sync-s` or `--recurse-submodules`. `repo sync`
|
||||
fails if the manifest has one while `repo.reprojectcmd` is set.
|
||||
+118
-4
@@ -95,6 +95,36 @@ example would do. Google's internal server uses Python's
|
||||
|
||||
The manifest server should implement the following RPC methods.
|
||||
|
||||
### GetApprovedManifestV2
|
||||
|
||||
> `GetApprovedManifestV2(request: dict) -> dict`
|
||||
|
||||
Like [GetApprovedManifest](#GetApprovedManifest), but takes a single struct of
|
||||
named options so new options can be added without changing the method signature.
|
||||
Unset options are omitted from the struct. Servers should ignore options they do
|
||||
not understand.
|
||||
|
||||
Request options:
|
||||
|
||||
* `branch` (`str`, required): Same as in `GetApprovedManifest`.
|
||||
* `target` (`str`, optional): Same as in `GetApprovedManifest`.
|
||||
* `best_effort` (`bool`, optional, default `False`): The client can proceed
|
||||
without a manifest, so the server may decline the request (e.g. during a
|
||||
staged rollout or to shed load). Repo sets this for Smart Syncs enabled by
|
||||
the manifest's `sync-smartsync` default, and falls back to a regular ToT
|
||||
sync if the request is declined. Servers must not decline requests without
|
||||
this option for these reasons.
|
||||
|
||||
Response fields:
|
||||
|
||||
* `success` (`bool`): Whether a manifest was returned.
|
||||
* `manifest_xml` (`str`): The manifest. Required if `success` is true.
|
||||
* `error_message` (`str`): Why the request failed or was declined. Repo shows
|
||||
it to the user like `GetApprovedManifest` errors.
|
||||
|
||||
If the server does not implement this method, repo calls `GetApprovedManifest`
|
||||
instead and drops the options it cannot express.
|
||||
|
||||
### GetApprovedManifest
|
||||
|
||||
> `GetApprovedManifest(branch: str, target: Optional[str]) -> str`
|
||||
@@ -138,12 +168,79 @@ copy into `.repo/manifests/smart_sync_override.xml` so users can examine it.
|
||||
The next time `repo sync` is run, this file is automatically replaced or removed
|
||||
based on the current set of options.
|
||||
|
||||
### --smart-sync
|
||||
With `-l`/`--local-only` and no explicit `-s`/`--smart-sync` or
|
||||
`-t`/`--smart-tag`, repo does not contact the manifest server. Instead, it keeps
|
||||
using the existing `smart_sync_override.xml`, if there is one, whether it came
|
||||
from an explicit `-s`/`--smart-sync` or from the manifest's `sync-smartsync`
|
||||
default. This way, `repo sync -n` followed by `repo sync -l` ends up on the same
|
||||
revisions as the equivalent single `repo sync` (e.g. `repo sync -s -n` +
|
||||
`repo sync -l` matches `repo sync -s`). Pass `--no-smart-sync` to discard it and
|
||||
use the default manifest instead.
|
||||
|
||||
Repo will call `GetApprovedManifest(branch[, target])`.
|
||||
`--no-manifest-update` only skips updating the manifest checkout. It does not
|
||||
affect Smart Sync: the default Smart Sync from `sync-smartsync` (see below)
|
||||
still runs, and an existing `smart_sync_override.xml` is replaced or removed as
|
||||
in a regular sync.
|
||||
|
||||
### `repo init --[no-]smart-sync`
|
||||
|
||||
Users can configure Smart Sync as the default for a checkout by passing
|
||||
`-s`/`--smart-sync` (or `--no-smart-sync`/`--head`) to `repo init` (this can
|
||||
also be re-run in an existing checkout to change the setting).
|
||||
|
||||
When resolving whether to perform a Smart Sync by default, `repo sync` uses the
|
||||
following precedence order (highest to lowest):
|
||||
|
||||
1. Per-invocation CLI flags (`-s`/`--smart-sync`, `--no-smart-sync`/`--head`,
|
||||
or target selection flags like `-t`, `-m`, `--superproject-revision`, `-l`).
|
||||
2. Checkout `repo init` setting (`repo init --[no-]smart-sync`).
|
||||
3. [Per-user `sync.smartsync` setting](#Per_user-default-sync_smartsync).
|
||||
4. Manifest [`sync-smartsync`](#sync-smartsync-manifest-attribute) setting.
|
||||
|
||||
Running `repo init --no-smart-sync` (or `repo init --head`) disables default
|
||||
Smart Sync for the checkout even when enabled by the user setting or manifest.
|
||||
As with the [manifest setting](#sync-smartsync-manifest-attribute), a
|
||||
checkout-enabled default Smart Sync falls back to a regular ToT sync if the
|
||||
manifest does not define a `<manifest-server>` or the manifest server cannot
|
||||
provide a usable manifest.
|
||||
|
||||
### sync-smartsync (Manifest Attribute)
|
||||
|
||||
The manifest can enable Smart Sync by default for all `repo sync` invocations
|
||||
by setting `sync-smartsync="true"` on the `<default>` element:
|
||||
|
||||
```xml
|
||||
<default sync-smartsync="true" ... />
|
||||
```
|
||||
|
||||
When enabled in the manifest, `repo sync` will automatically perform a Smart
|
||||
Sync unless explicitly overridden on the command line. Since the user did not
|
||||
ask for a Smart Sync, any failure to obtain a manifest from the manifest server
|
||||
(e.g. the server is unreachable, or it returns a manifest that cannot be parsed)
|
||||
is silently ignored and `repo sync` falls back to a regular ToT sync; pass
|
||||
`--verbose` to see the reason. Repo marks these requests as `best_effort` (see
|
||||
[GetApprovedManifestV2](#GetApprovedManifestV2)), so the server may also decline
|
||||
them, e.g. during a staged rollout. This is only a fallback for the
|
||||
manifest-driven default: an explicit `-s`/`--smart-sync` or `-t`/`--smart-tag`
|
||||
still fails the sync, as before. Options that specify an explicit target or
|
||||
manifest source (such as `-t`/`--smart-tag`, `-m`/`--manifest-name` or
|
||||
`--superproject-revision`) will also disable the default Smart Sync behavior.
|
||||
|
||||
Users can override this manifest default on their workstation with the
|
||||
`sync.smartsync` user setting; see
|
||||
[Per-user default](#Per_user-default-sync_smartsync) below.
|
||||
|
||||
### --smart-sync / -s
|
||||
|
||||
Explicitly enables Smart Sync. Repo will call
|
||||
`GetApprovedManifestV2({branch[, target]})`, or
|
||||
`GetApprovedManifest(branch[, target])` if the server does not implement it.
|
||||
|
||||
The `branch` is determined by the current manifest branch as specified by
|
||||
`--manifest-branch=BRANCH` when running `repo init`.
|
||||
`--manifest-branch=BRANCH` when running `repo init`. If `${SYNC_BRANCH}` is
|
||||
defined and non-empty, its value is sent instead. This allows selecting a
|
||||
branch other than the one the checkout tracks, e.g. a server-side (CI) branch
|
||||
name that builds from the same manifest branch.
|
||||
|
||||
The `target` is defined by environment variables in the order below. If none
|
||||
of them match, then `target` is omitted. These variables were decided as they
|
||||
@@ -155,6 +252,23 @@ match the settings Android build environments automatically setup.
|
||||
3. `${TARGET_PRODUCT}-${TARGET_BUILD_VARIANT}`: If these variables are all
|
||||
defined, then they are merged with `-` and used.
|
||||
|
||||
### --smart-tag=TAG
|
||||
### --no-smart-sync / --head
|
||||
|
||||
Explicitly disables Smart Sync, overriding any `repo.smartsync` git config or
|
||||
`sync-smartsync="true"` setting declared in the manifest `<default>` element.
|
||||
|
||||
### Per-user default (sync.smartsync)
|
||||
|
||||
A user can decide once, for all their checkouts, whether `repo sync` should
|
||||
Smart Sync by default by setting the boolean `sync.smartsync` key in their
|
||||
[per-user repo config](./repoconfig.md#smartsync).
|
||||
|
||||
Command line flags (`-s`, `--no-smart-sync`, `-t`) override the user setting,
|
||||
which in turn overrides the manifest's `sync-smartsync` default. A Smart Sync
|
||||
enabled this way is treated as an explicit request, so it is subject to the
|
||||
same restrictions as `-s` and fails the sync if no manifest can be obtained
|
||||
from the server instead of silently falling back to ToT.
|
||||
|
||||
### --smart-tag=TAG / -t
|
||||
|
||||
Repo will call `GetManifest(TAG)`.
|
||||
@@ -14,11 +14,12 @@
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from error import EditorError
|
||||
import error
|
||||
import platform_utils
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -104,9 +103,11 @@ least one of these before using this command.""", # noqa: E501
|
||||
try:
|
||||
rc = subprocess.Popen(args, shell=shell).wait()
|
||||
except OSError as e:
|
||||
raise EditorError(f"editor failed, {str(e)}: {editor} {path}")
|
||||
raise error.EditorError(
|
||||
f"editor failed, {str(e)}: {editor} {path}"
|
||||
)
|
||||
if rc != 0:
|
||||
raise EditorError(
|
||||
raise error.EditorError(
|
||||
"editor failed with exit status %d: %s %s"
|
||||
% (rc, editor, path)
|
||||
)
|
||||
|
||||
+11
-3
@@ -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,
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from error import RepoExitError
|
||||
import error
|
||||
|
||||
|
||||
class FetchFileError(RepoExitError):
|
||||
class FetchFileError(error.RepoExitError):
|
||||
"""Exit error when fetch_file fails."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
+80
-29
@@ -20,15 +20,12 @@ import subprocess
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
from error import GitError
|
||||
from error import RepoExitError
|
||||
from git_trace2_event_log_base import BaseEventLog
|
||||
import error
|
||||
import git_trace2_event_log_base
|
||||
import platform_utils
|
||||
from repo_logging import RepoLogger
|
||||
from repo_trace import IsTrace
|
||||
from repo_trace import REPO_TRACE
|
||||
from repo_trace import Trace
|
||||
from wrapper import Wrapper
|
||||
import repo_logging
|
||||
import repo_trace
|
||||
import wrapper
|
||||
|
||||
|
||||
GIT = "git"
|
||||
@@ -43,13 +40,13 @@ GIT_ERROR_STDOUT_LINES = 1
|
||||
GIT_ERROR_STDERR_LINES = 10
|
||||
INVALID_GIT_EXIT_CODE = 126
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
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)
|
||||
@@ -70,6 +67,19 @@ class _GitCall:
|
||||
git = _GitCall()
|
||||
|
||||
|
||||
def IsValidBranchName(name: str) -> bool:
|
||||
"""Return whether |name| is valid where Git expects a branch name."""
|
||||
p = GitCommand(
|
||||
None,
|
||||
["check-ref-format", "--branch", name],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
add_event_log=False,
|
||||
log_as_error=False,
|
||||
)
|
||||
return p.Wait() == 0
|
||||
|
||||
|
||||
def RepoSourceVersion():
|
||||
"""Return the version of the repo.git tree."""
|
||||
ver = getattr(RepoSourceVersion, "version", None)
|
||||
@@ -270,6 +280,7 @@ class GitCommand:
|
||||
bare=False,
|
||||
input=None,
|
||||
capture_stdout=False,
|
||||
capture_stdout_bytes: bool = False,
|
||||
capture_stderr=False,
|
||||
merge_output=False,
|
||||
disable_editor=False,
|
||||
@@ -291,6 +302,12 @@ class GitCommand:
|
||||
self.cmdv = cmdv
|
||||
self.verify_command = verify_command
|
||||
self.stdout, self.stderr = None, None
|
||||
if capture_stdout_bytes:
|
||||
if merge_output:
|
||||
raise ValueError(
|
||||
"capture_stdout_bytes cannot be combined with merge_output"
|
||||
)
|
||||
capture_stdout = True
|
||||
|
||||
# Git on Windows wants its paths only using / for reliability.
|
||||
if platform_utils.isWindows():
|
||||
@@ -315,16 +332,22 @@ class GitCommand:
|
||||
|
||||
if command_name in ("fetch", "clone"):
|
||||
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||
# Need to use the --progress flag for fetch/clone so output will be
|
||||
# displayed as by default git only does progress output if stderr is
|
||||
# a TTY.
|
||||
if command_name in ("fetch", "clone", "push"):
|
||||
# Need to use the --progress flag for fetch/clone/push so output
|
||||
# will be displayed as by default git only does progress output if
|
||||
# stderr is a TTY.
|
||||
if sys.stderr.isatty():
|
||||
if "--progress" not in cmdv and "--quiet" not in cmdv:
|
||||
if (
|
||||
"--progress" not in cmdv
|
||||
and "--no-progress" not in cmdv
|
||||
and "--quiet" not in cmdv
|
||||
and "-q" not in cmdv
|
||||
):
|
||||
command.append("--progress")
|
||||
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
|
||||
)
|
||||
@@ -334,6 +357,7 @@ class GitCommand:
|
||||
command,
|
||||
env,
|
||||
capture_stdout=capture_stdout,
|
||||
capture_stdout_bytes=capture_stdout_bytes,
|
||||
capture_stderr=capture_stderr,
|
||||
merge_output=merge_output,
|
||||
ssh_proxy=ssh_proxy,
|
||||
@@ -367,6 +391,7 @@ class GitCommand:
|
||||
command,
|
||||
env,
|
||||
capture_stdout=False,
|
||||
capture_stdout_bytes: bool = False,
|
||||
capture_stderr=False,
|
||||
merge_output=False,
|
||||
ssh_proxy=None,
|
||||
@@ -399,6 +424,10 @@ class GitCommand:
|
||||
# See go/tee-repo-stderr for more context.
|
||||
tee_stderr = False
|
||||
kwargs = {"encoding": "utf-8", "errors": "backslashreplace"}
|
||||
if capture_stdout_bytes:
|
||||
kwargs = {}
|
||||
if isinstance(input, str):
|
||||
input = input.encode("utf-8", "surrogateescape")
|
||||
if not (stdin or stdout or stderr):
|
||||
tee_stderr = True
|
||||
# stderr will be written back to sys.stderr even though it is
|
||||
@@ -407,7 +436,7 @@ class GitCommand:
|
||||
kwargs = {}
|
||||
|
||||
dbg = ""
|
||||
if IsTrace():
|
||||
if repo_trace.IsTrace():
|
||||
global LAST_CWD
|
||||
global LAST_GITDIR
|
||||
|
||||
@@ -444,7 +473,7 @@ class GitCommand:
|
||||
elif stderr == subprocess.STDOUT:
|
||||
dbg += " 2>&1"
|
||||
|
||||
with Trace(
|
||||
with repo_trace.Trace(
|
||||
"git command %s %s with debug: %s", LAST_GITDIR, command, dbg
|
||||
):
|
||||
try:
|
||||
@@ -477,6 +506,10 @@ class GitCommand:
|
||||
self.stderr = self._Tee(p.stderr, sys.stderr)
|
||||
else:
|
||||
self.stdout, self.stderr = p.communicate(input=input)
|
||||
if capture_stdout_bytes and isinstance(self.stderr, bytes):
|
||||
self.stderr = self.stderr.decode(
|
||||
"utf-8", "backslashreplace"
|
||||
).replace("\r\n", "\n")
|
||||
finally:
|
||||
if ssh_proxy:
|
||||
ssh_proxy.remove_client(p)
|
||||
@@ -517,7 +550,7 @@ class GitCommand:
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
for key in (
|
||||
REPO_TRACE,
|
||||
repo_trace.REPO_TRACE,
|
||||
GIT_DIR,
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
@@ -528,17 +561,35 @@ class GitCommand:
|
||||
env.pop(key, None)
|
||||
return env
|
||||
|
||||
def VerifyCommand(self):
|
||||
def VerifyCommand(self) -> None:
|
||||
if self.rc == 0:
|
||||
return None
|
||||
stdout = (
|
||||
"\n".join(self.stdout.split("\n")[:GIT_ERROR_STDOUT_LINES])
|
||||
if self.stdout
|
||||
else None
|
||||
)
|
||||
raw_stdout = self.stdout
|
||||
if isinstance(raw_stdout, bytes):
|
||||
first_records = re.split(
|
||||
rb"\r\n|[\r\n\0]", raw_stdout, maxsplit=GIT_ERROR_STDOUT_LINES
|
||||
)[:GIT_ERROR_STDOUT_LINES]
|
||||
stdout = (
|
||||
"\n".join(
|
||||
r.decode("utf-8", "backslashreplace") for r in first_records
|
||||
)
|
||||
if raw_stdout
|
||||
else None
|
||||
)
|
||||
elif raw_stdout:
|
||||
first_records = re.split(
|
||||
r"\r\n|[\r\n\0]", raw_stdout, maxsplit=GIT_ERROR_STDOUT_LINES
|
||||
)[:GIT_ERROR_STDOUT_LINES]
|
||||
stdout = "\n".join(first_records)
|
||||
else:
|
||||
stdout = None
|
||||
|
||||
raw_stderr = self.stderr
|
||||
if isinstance(raw_stderr, bytes):
|
||||
raw_stderr = raw_stderr.decode("utf-8", "backslashreplace")
|
||||
stderr = (
|
||||
"\n".join(self.stderr.split("\n")[:GIT_ERROR_STDERR_LINES])
|
||||
if self.stderr
|
||||
"\n".join(raw_stderr.split("\n")[:GIT_ERROR_STDERR_LINES])
|
||||
if raw_stderr
|
||||
else None
|
||||
)
|
||||
project = self.project.name if self.project else None
|
||||
@@ -556,14 +607,14 @@ class GitCommand:
|
||||
return self.rc
|
||||
|
||||
|
||||
class GitRequireError(RepoExitError):
|
||||
class GitRequireError(error.RepoExitError):
|
||||
"""Error raised when git version is unavailable or invalid."""
|
||||
|
||||
def __init__(self, message, exit_code: int = INVALID_GIT_EXIT_CODE):
|
||||
super().__init__(message, exit_code=exit_code)
|
||||
|
||||
|
||||
class GitCommandError(GitError):
|
||||
class GitCommandError(error.GitError):
|
||||
"""
|
||||
Error raised from a failed git command.
|
||||
Note that GitError can refer to any Git related error (e.g. branch not
|
||||
@@ -643,7 +694,7 @@ class GitCommandError(GitError):
|
||||
return string
|
||||
|
||||
|
||||
class GitPopenCommandError(GitError):
|
||||
class GitPopenCommandError(error.GitError):
|
||||
"""
|
||||
Error raised when subprocess.Popen fails for a GitCommand
|
||||
"""
|
||||
+267
-83
@@ -15,6 +15,7 @@
|
||||
import contextlib
|
||||
import datetime
|
||||
import errno
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
@@ -22,31 +23,32 @@ 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
|
||||
|
||||
from error import GitError
|
||||
from error import UploadError
|
||||
from git_command import GitCommand
|
||||
from git_refs import R_CHANGES
|
||||
from git_refs import R_HEADS
|
||||
from git_refs import R_TAGS
|
||||
import error
|
||||
import git_command
|
||||
import git_refs
|
||||
import platform_utils
|
||||
from repo_trace import Trace
|
||||
import repo_trace
|
||||
|
||||
|
||||
# Prefix that is prepended to all the keys of SyncAnalysisState's data
|
||||
# that is saved in the config.
|
||||
SYNC_STATE_PREFIX = "repo.syncstate."
|
||||
|
||||
ID_RE = re.compile(r"^[0-9a-f]{40,64}$")
|
||||
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(R_CHANGES)
|
||||
return rev.startswith(git_refs.R_CHANGES)
|
||||
|
||||
|
||||
def IsId(rev: str) -> bool:
|
||||
@@ -54,13 +56,63 @@ def IsId(rev: str) -> bool:
|
||||
|
||||
|
||||
def IsTag(rev):
|
||||
return rev.startswith(R_TAGS)
|
||||
return rev.startswith(git_refs.R_TAGS)
|
||||
|
||||
|
||||
def IsImmutable(rev):
|
||||
return IsChange(rev) or IsId(rev) or IsTag(rev)
|
||||
|
||||
|
||||
def ParseBoolean(value: str) -> Union[bool, None]:
|
||||
"""Parses a git config boolean value.
|
||||
|
||||
Accepts the same spellings as `git config --type=bool`.
|
||||
https://git-scm.com/docs/git-config#Documentation/git-config.txt-boolean
|
||||
|
||||
Returns:
|
||||
True/False, or None if |value| is not a valid boolean.
|
||||
"""
|
||||
v = value.strip().lower()
|
||||
if v in ("true", "yes", "on", "1"):
|
||||
return True
|
||||
if v in ("false", "no", "off", "0"):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def ParseInt(value: str) -> Union[int, None]:
|
||||
"""Parses a git config integer value.
|
||||
|
||||
Accepts the same syntax as `git config --type=int`: an optional k, m or
|
||||
g suffix (multiples of 1024) and hex with a 0x prefix.
|
||||
https://git-scm.com/docs/git-config#Documentation/git-config.txt-integer
|
||||
|
||||
Returns:
|
||||
The integer, or None if |value| is not a valid integer.
|
||||
"""
|
||||
v = value.strip().lower()
|
||||
|
||||
mult = 1
|
||||
if v.endswith("k"):
|
||||
v = v[:-1]
|
||||
mult = 1024
|
||||
elif v.endswith("m"):
|
||||
v = v[:-1]
|
||||
mult = 1024 * 1024
|
||||
elif v.endswith("g"):
|
||||
v = v[:-1]
|
||||
mult = 1024 * 1024 * 1024
|
||||
|
||||
base = 10
|
||||
if v.startswith("0x"):
|
||||
base = 16
|
||||
|
||||
try:
|
||||
return int(v, base=base) * mult
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _key(name):
|
||||
parts = name.split(".")
|
||||
if len(parts) < 2:
|
||||
@@ -114,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 = {}
|
||||
@@ -128,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."""
|
||||
@@ -152,32 +219,15 @@ class GitConfig:
|
||||
v = self.GetString(name)
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
|
||||
mult = 1
|
||||
if v.endswith("k"):
|
||||
v = v[:-1]
|
||||
mult = 1024
|
||||
elif v.endswith("m"):
|
||||
v = v[:-1]
|
||||
mult = 1024 * 1024
|
||||
elif v.endswith("g"):
|
||||
v = v[:-1]
|
||||
mult = 1024 * 1024 * 1024
|
||||
|
||||
base = 10
|
||||
if v.startswith("0x"):
|
||||
base = 16
|
||||
|
||||
try:
|
||||
return int(v, base=base) * mult
|
||||
except ValueError:
|
||||
ret = ParseInt(v)
|
||||
if ret is None:
|
||||
print(
|
||||
f"warning: expected {name} to represent an integer, got {v} "
|
||||
"instead",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
return ret
|
||||
|
||||
def DumpConfigDict(self):
|
||||
"""Returns the current configuration dict.
|
||||
@@ -197,24 +247,25 @@ class GitConfig:
|
||||
def GetBoolean(self, name: str) -> Union[bool, None]:
|
||||
"""Returns a boolean from the configuration file.
|
||||
|
||||
This follows the git config syntax.
|
||||
|
||||
Returns:
|
||||
None: The value was not defined, or is not a boolean.
|
||||
True: The value was set to true or yes.
|
||||
False: The value was set to false or no.
|
||||
True: The value was set to true, yes, on or 1.
|
||||
False: The value was set to false, no, off or 0.
|
||||
"""
|
||||
v = self.GetString(name)
|
||||
if v is None:
|
||||
return None
|
||||
v = v.lower()
|
||||
if v in ("true", "yes"):
|
||||
return True
|
||||
if v in ("false", "no"):
|
||||
return False
|
||||
print(
|
||||
f"warning: expected {name} to represent a boolean, got {v} instead",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
ret = ParseBoolean(v)
|
||||
if ret is None:
|
||||
print(
|
||||
f"warning: expected {name} to represent a boolean, got {v} "
|
||||
"instead",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return ret
|
||||
|
||||
def SetBoolean(self, name, value):
|
||||
"""Set the truthy value for a key."""
|
||||
@@ -261,15 +312,30 @@ 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:
|
||||
old = []
|
||||
|
||||
if self.file != self._SYSTEM_CONFIG:
|
||||
# `git config --file` does not create missing parent directories
|
||||
# (e.g. ~/.repoconfig/ on first use).
|
||||
parent_dir = os.path.dirname(self.file)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
|
||||
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:
|
||||
@@ -279,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."""
|
||||
@@ -379,27 +479,109 @@ class GitConfig:
|
||||
self._SaveJson(d)
|
||||
return d
|
||||
|
||||
def _ReadJson(self):
|
||||
def _ReadFileFingerprint(self) -> Optional[Tuple[int, int, int, int, str]]:
|
||||
"""Return (mtime_ns, ctime_ns, size, ino, sha1_hex) for self.file."""
|
||||
try:
|
||||
if os.path.getmtime(self._json) <= os.path.getmtime(self.file):
|
||||
platform_utils.remove(self._json)
|
||||
return None
|
||||
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:
|
||||
with Trace(": parsing %s", self.file):
|
||||
with open(self._json) as fd:
|
||||
return json.load(fd)
|
||||
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, 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):
|
||||
"""
|
||||
@@ -410,6 +592,7 @@ class GitConfig:
|
||||
"""
|
||||
c = {}
|
||||
if not os.path.exists(self.file):
|
||||
self._cache_fingerprint = None
|
||||
return c
|
||||
|
||||
d = self._do("--null", "--list")
|
||||
@@ -425,25 +608,33 @@ 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:
|
||||
command = ["config", "--file", self.file, "--includes"]
|
||||
command.extend(args)
|
||||
|
||||
p = GitCommand(None, command, capture_stdout=True, capture_stderr=True)
|
||||
if p.Wait() == 0:
|
||||
p = git_command.GitCommand(
|
||||
None, command, capture_stdout=True, capture_stderr=True
|
||||
)
|
||||
rc = p.Wait()
|
||||
if rc == 0 or (allow_missing and rc == 5):
|
||||
return p.stdout
|
||||
else:
|
||||
raise GitError(f"git {' '.join(command)}: {p.stderr}")
|
||||
raise error.GitError(f"git {' '.join(command)}: {p.stderr}")
|
||||
|
||||
|
||||
class RepoConfig(GitConfig):
|
||||
"""User settings for repo itself."""
|
||||
|
||||
# Keep separate caches from GitConfig, otherwise ForUser()/ForSystem()
|
||||
# would return the already-cached ~/.gitconfig instance of the parent.
|
||||
_ForUser = None
|
||||
_ForSystem = None
|
||||
|
||||
@staticmethod
|
||||
def _getUserConfig():
|
||||
repo_config_dir = os.getenv("REPO_CONFIG_DIR", os.path.expanduser("~"))
|
||||
@@ -671,11 +862,13 @@ class Remote:
|
||||
userEmail, host, port
|
||||
)
|
||||
except urllib.error.HTTPError as e:
|
||||
raise UploadError(f"{self.review}: {str(e)}")
|
||||
raise error.UploadError(f"{self.review}: {str(e)}")
|
||||
except urllib.error.URLError as e:
|
||||
raise UploadError(f"{self.review}: {str(e)}")
|
||||
raise error.UploadError(f"{self.review}: {str(e)}")
|
||||
except http.client.HTTPException as e:
|
||||
raise UploadError(f"{self.review}: {e.__class__.__name__}")
|
||||
raise error.UploadError(
|
||||
f"{self.review}: {e.__class__.__name__}"
|
||||
)
|
||||
|
||||
REVIEW_CACHE[u] = self._review_url
|
||||
return self._review_url + self.projectname
|
||||
@@ -692,16 +885,16 @@ class Remote:
|
||||
return rev
|
||||
|
||||
if not rev.startswith("refs/"):
|
||||
rev = R_HEADS + rev
|
||||
rev = git_refs.R_HEADS + rev
|
||||
|
||||
for spec in self.fetch:
|
||||
if spec.SourceMatches(rev):
|
||||
return spec.MapSource(rev)
|
||||
|
||||
if not rev.startswith(R_HEADS):
|
||||
if not rev.startswith(git_refs.R_HEADS):
|
||||
return rev
|
||||
|
||||
raise GitError(
|
||||
raise error.GitError(
|
||||
"%s: remote %s does not have %s"
|
||||
% (self.projectname, self.name, rev)
|
||||
)
|
||||
@@ -767,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}"
|
||||
|
||||
+166
-29
@@ -14,9 +14,9 @@
|
||||
|
||||
import os
|
||||
|
||||
from git_command import GitCommand
|
||||
import git_command
|
||||
import platform_utils
|
||||
from repo_trace import Trace
|
||||
import repo_trace
|
||||
|
||||
|
||||
HEAD = "HEAD"
|
||||
@@ -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:
|
||||
@@ -41,6 +43,17 @@ class GitRefs:
|
||||
self._EnsureLoaded()
|
||||
return self._phyref
|
||||
|
||||
@property
|
||||
def head(self) -> str:
|
||||
"""Return HEAD's symbolic target or detached object ID."""
|
||||
self._EnsureLoaded()
|
||||
return self._symref.get(HEAD) or self._phyref.get(HEAD, "")
|
||||
|
||||
@property
|
||||
def is_loaded(self) -> bool:
|
||||
"""Whether a ref snapshot has already been loaded."""
|
||||
return self._phyref is not None
|
||||
|
||||
def get(self, name):
|
||||
try:
|
||||
return self.all[name]
|
||||
@@ -65,12 +78,107 @@ 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()
|
||||
|
||||
def _NeedUpdate(self):
|
||||
with Trace(": scan refs %s", self._gitdir):
|
||||
with repo_trace.Trace(": scan refs %s", self._gitdir):
|
||||
for name, mtime in self._mtime.items():
|
||||
try:
|
||||
if mtime != os.path.getmtime(
|
||||
@@ -82,49 +190,61 @@ class GitRefs:
|
||||
return False
|
||||
|
||||
def _LoadAll(self):
|
||||
with Trace(": load refs %s", self._gitdir):
|
||||
with repo_trace.Trace(": load refs %s", self._gitdir):
|
||||
self._phyref = {}
|
||||
self._symref = {}
|
||||
self._mtime = {}
|
||||
|
||||
self._ReadRefs()
|
||||
self._ReadSymbolicRef(HEAD)
|
||||
root_refs_loaded = self._ReadRefs()
|
||||
if not root_refs_loaded or (
|
||||
HEAD not in self._phyref and HEAD not in self._symref
|
||||
):
|
||||
# --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:
|
||||
"""Check if a ref_id is a null object ID."""
|
||||
return ref_id and all(ch == "0" for ch in ref_id)
|
||||
|
||||
def _ReadRefs(self) -> None:
|
||||
"""Read all references using git for-each-ref."""
|
||||
p = GitCommand(
|
||||
def _ReadRefs(self) -> bool:
|
||||
"""Read all references using git for-each-ref.
|
||||
|
||||
Returns:
|
||||
Whether root refs, including HEAD when it exists, were loaded.
|
||||
"""
|
||||
include_root_refs = git_command.git_require((2, 45, 0))
|
||||
cmd = [
|
||||
"for-each-ref",
|
||||
"--format=%(objectname)%00%(refname)%00%(symref)",
|
||||
]
|
||||
if include_root_refs:
|
||||
cmd.insert(1, "--include-root-refs")
|
||||
# Avoid caching volatile root refs such as ORIG_HEAD. HEAD and
|
||||
# refs/* are the only namespaces GitRefs exposes to callers.
|
||||
cmd.extend([HEAD, "refs"])
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["for-each-ref", "--format=%(objectname)%00%(refname)%00%(symref)"],
|
||||
cmd,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
bare=True,
|
||||
gitdir=self._gitdir,
|
||||
)
|
||||
if p.Wait() != 0:
|
||||
return
|
||||
return False
|
||||
|
||||
for line in p.stdout.splitlines():
|
||||
ref_id, name, symref = line.split("\0")
|
||||
@@ -132,10 +252,27 @@ class GitRefs:
|
||||
self._symref[name] = symref
|
||||
elif ref_id and not self._IsNullRef(ref_id):
|
||||
self._phyref[name] = ref_id
|
||||
return include_root_refs
|
||||
|
||||
def _ReadSymbolicRef(self, name: str) -> None:
|
||||
"""Read a symbolic reference."""
|
||||
p = GitCommand(
|
||||
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],
|
||||
capture_stdout=True,
|
||||
@@ -149,7 +286,7 @@ class GitRefs:
|
||||
self._symref[name] = ref
|
||||
return
|
||||
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["rev-parse", "--verify", "-q", name],
|
||||
capture_stdout=True,
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Read a worktree's state from one machine-readable git status snapshot."""
|
||||
|
||||
import collections
|
||||
import os
|
||||
from typing import Iterator, List, Optional, TYPE_CHECKING
|
||||
|
||||
import git_command
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from project import Project
|
||||
|
||||
|
||||
class StatusEntry:
|
||||
"""The state of one path on one side of the index."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
status: str,
|
||||
src_path: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.status = status
|
||||
self.src_path = src_path
|
||||
self.level = level
|
||||
|
||||
|
||||
class StatusSnapshot:
|
||||
"""A consistent view of worktree, index, and branch state."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.index_changes = collections.OrderedDict()
|
||||
self.worktree_changes = collections.OrderedDict()
|
||||
self.untracked = []
|
||||
self.branch_oid = None
|
||||
self.branch_head = None
|
||||
self.upstream = None
|
||||
self.ahead = 0
|
||||
self.behind = 0
|
||||
self.has_ahead_behind = False
|
||||
self.stash_count = 0
|
||||
|
||||
@property
|
||||
def current_branch(self) -> Optional[str]:
|
||||
if self.branch_head in (None, "(detached)", "(unknown)"):
|
||||
return None
|
||||
return self.branch_head
|
||||
|
||||
def is_dirty(self, consider_untracked: bool = True) -> bool:
|
||||
return bool(
|
||||
self.index_changes
|
||||
or self.worktree_changes
|
||||
or (consider_untracked and self.untracked)
|
||||
)
|
||||
|
||||
|
||||
def GetStatus(
|
||||
project: "Project",
|
||||
gitdir: str,
|
||||
untracked_files: str = "all",
|
||||
branch: bool = False,
|
||||
ahead_behind: bool = False,
|
||||
show_stash: bool = False,
|
||||
) -> StatusSnapshot:
|
||||
"""Return one machine-readable status snapshot for |project|."""
|
||||
if not git_command.git_require((2, 11, 0)):
|
||||
raise UnsupportedStatusError("porcelain v2 requires Git 2.11")
|
||||
cmd = [
|
||||
"status",
|
||||
"--porcelain=v2",
|
||||
"-z",
|
||||
"--ignore-submodules=all",
|
||||
f"--untracked-files={untracked_files}",
|
||||
]
|
||||
if branch:
|
||||
cmd.append("--branch")
|
||||
if git_command.git_require((2, 17, 0)):
|
||||
cmd.append(
|
||||
"--ahead-behind" if ahead_behind else "--no-ahead-behind"
|
||||
)
|
||||
if git_command.git_require((2, 18, 0)):
|
||||
# Match the existing staged diff's explicit rename detection even if
|
||||
# status.renames is disabled in the user's config.
|
||||
cmd.append("--renames")
|
||||
if show_stash and git_command.git_require((2, 35, 0)):
|
||||
cmd.append("--show-stash")
|
||||
|
||||
p = git_command.GitCommand(
|
||||
project,
|
||||
cmd,
|
||||
bare=False,
|
||||
gitdir=gitdir,
|
||||
capture_stdout=True,
|
||||
capture_stdout_bytes=True,
|
||||
capture_stderr=True,
|
||||
verify_command=True,
|
||||
)
|
||||
p.Wait()
|
||||
return ParsePorcelainV2(p.stdout)
|
||||
|
||||
|
||||
def _Path(value: bytes) -> str:
|
||||
"""Decode a Git pathname without losing undecodable bytes."""
|
||||
return os.fsdecode(value)
|
||||
|
||||
|
||||
def _Status(value: int) -> str:
|
||||
"""Normalize Git's unchanged markers for repo's status display."""
|
||||
char = chr(value)
|
||||
return "" if char == "." else char
|
||||
|
||||
|
||||
def _Records(output: bytes) -> Iterator[bytes]:
|
||||
if not output:
|
||||
return iter(())
|
||||
if not output.endswith(b"\0"):
|
||||
raise StatusParseError("porcelain v2 output is not NUL terminated")
|
||||
records = output.split(b"\0")
|
||||
if not records[-1]:
|
||||
records.pop()
|
||||
return iter(records)
|
||||
|
||||
|
||||
class StatusParseError(ValueError):
|
||||
"""Raised when machine-readable status output is malformed."""
|
||||
|
||||
|
||||
class UnsupportedStatusError(RuntimeError):
|
||||
"""Raised when the Git client cannot produce porcelain v2."""
|
||||
|
||||
|
||||
def _Fields(record: bytes, count: int) -> List[bytes]:
|
||||
fields = record.split(b" ", count - 1)
|
||||
if len(fields) != count:
|
||||
raise StatusParseError(f"malformed porcelain v2 record: {record!r}")
|
||||
return fields
|
||||
|
||||
|
||||
def _AddTracked(
|
||||
status: StatusSnapshot,
|
||||
path: str,
|
||||
xy: bytes,
|
||||
src_path: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
) -> None:
|
||||
index_status = _Status(xy[0])
|
||||
worktree_status = _Status(xy[1])
|
||||
if index_status:
|
||||
status.index_changes[path] = StatusEntry(
|
||||
path,
|
||||
index_status,
|
||||
src_path=src_path if index_status in ("R", "C") else None,
|
||||
level=level if index_status in ("R", "C") else None,
|
||||
)
|
||||
if worktree_status:
|
||||
status.worktree_changes[path] = StatusEntry(
|
||||
path,
|
||||
worktree_status,
|
||||
src_path=src_path if worktree_status in ("R", "C") else None,
|
||||
level=level if worktree_status in ("R", "C") else None,
|
||||
)
|
||||
|
||||
|
||||
def ParsePorcelainV2(output: bytes) -> StatusSnapshot:
|
||||
"""Parse ``git status --porcelain=v2 -z --branch`` output."""
|
||||
status = StatusSnapshot()
|
||||
records = _Records(output)
|
||||
for record in records:
|
||||
kind = record[:1]
|
||||
if kind == b"#":
|
||||
try:
|
||||
key, value = record[2:].split(b" ", 1)
|
||||
except ValueError as e:
|
||||
raise StatusParseError(
|
||||
f"malformed porcelain v2 header: {record!r}"
|
||||
) from e
|
||||
if key == b"branch.oid":
|
||||
value = value.decode("ascii")
|
||||
status.branch_oid = None if value == "(initial)" else value
|
||||
elif key == b"branch.head":
|
||||
status.branch_head = _Path(value)
|
||||
elif key == b"branch.upstream":
|
||||
status.upstream = _Path(value)
|
||||
elif key == b"branch.ab":
|
||||
try:
|
||||
value = value.decode("ascii")
|
||||
ahead, behind = value.split()
|
||||
if ahead != "+?" and behind != "-?":
|
||||
status.ahead = int(ahead)
|
||||
status.behind = -int(behind)
|
||||
status.has_ahead_behind = True
|
||||
except ValueError as e:
|
||||
raise StatusParseError(
|
||||
f"malformed porcelain v2 branch.ab record: {record!r}"
|
||||
) from e
|
||||
elif key == b"stash":
|
||||
status.stash_count = int(value.decode("ascii"))
|
||||
continue
|
||||
|
||||
if kind == b"1":
|
||||
fields = _Fields(record, 9)
|
||||
xy = fields[1]
|
||||
if len(xy) != 2:
|
||||
raise StatusParseError(f"invalid status pair: {xy!r}")
|
||||
path = _Path(fields[8])
|
||||
_AddTracked(status, path, xy)
|
||||
elif kind == b"2":
|
||||
fields = _Fields(record, 10)
|
||||
xy = fields[1]
|
||||
if len(xy) != 2:
|
||||
raise StatusParseError(f"invalid status pair: {xy!r}")
|
||||
score = fields[8][1:].lstrip(b"0") or b"0"
|
||||
try:
|
||||
src_path = _Path(next(records))
|
||||
except StopIteration as e:
|
||||
raise StatusParseError(
|
||||
"rename record has no source path"
|
||||
) from e
|
||||
path = _Path(fields[9])
|
||||
_AddTracked(
|
||||
status,
|
||||
path,
|
||||
xy,
|
||||
src_path=src_path,
|
||||
level=score.decode("ascii"),
|
||||
)
|
||||
elif kind == b"u":
|
||||
fields = _Fields(record, 11)
|
||||
path = _Path(fields[10])
|
||||
# The old diff-index/diff-files pair reported unmerged paths on
|
||||
# both sides, regardless of porcelain's more specific XY pair.
|
||||
status.index_changes[path] = StatusEntry(path, "U")
|
||||
status.worktree_changes[path] = StatusEntry(path, "U")
|
||||
elif kind == b"?":
|
||||
status.untracked.append(_Path(record[2:]))
|
||||
elif kind == b"!":
|
||||
continue
|
||||
else:
|
||||
raise StatusParseError(
|
||||
f"unknown porcelain v2 record type: {record!r}"
|
||||
)
|
||||
return status
|
||||
+31
-16
@@ -32,11 +32,8 @@ import time
|
||||
from typing import NamedTuple
|
||||
import urllib.parse
|
||||
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
from git_config import IsId
|
||||
from git_config import RepoConfig
|
||||
from git_refs import GitRefs
|
||||
import git_command
|
||||
import git_config
|
||||
import platform_utils
|
||||
|
||||
|
||||
@@ -141,7 +138,7 @@ class Superproject:
|
||||
def commit_id(self):
|
||||
"""Returns the commit ID of the superproject checkout."""
|
||||
cmd = ["rev-parse", self.revision]
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None, # project
|
||||
cmd,
|
||||
gitdir=self._work_git,
|
||||
@@ -189,7 +186,7 @@ class Superproject:
|
||||
if netloc:
|
||||
parts = netloc.split("-review", 1)
|
||||
host = parts[0]
|
||||
rev = GitRefs(self._work_git).get("HEAD")
|
||||
rev = self._GetRef("HEAD")
|
||||
return f"{host}/{self.name}@{rev}"
|
||||
return None
|
||||
|
||||
@@ -243,7 +240,7 @@ class Superproject:
|
||||
|
||||
try:
|
||||
cmd = ["init", "--bare", tmp_git_name]
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
cwd=self._superproject_path,
|
||||
@@ -293,7 +290,7 @@ class Superproject:
|
||||
if not os.path.exists(self._work_git):
|
||||
self._LogWarning("git fetch missing directory: {}", self._work_git)
|
||||
return False
|
||||
if not git_require((2, 28, 0)):
|
||||
if not git_command.git_require((2, 28, 0)):
|
||||
self._LogWarning(
|
||||
"superproject requires a git version 2.28 or later"
|
||||
)
|
||||
@@ -314,7 +311,10 @@ class Superproject:
|
||||
# We use --negotiation-tip to speed up the fetch. Superproject branches
|
||||
# do not share commits. So this lets git know it only needs to send
|
||||
# commits reachable from the specified local refs.
|
||||
rev_commit = GitRefs(self._work_git).get(f"refs/heads/{self.revision}")
|
||||
negotiation_ref = self.revision
|
||||
if negotiation_ref and not negotiation_ref.startswith("refs/"):
|
||||
negotiation_ref = f"refs/heads/{negotiation_ref}"
|
||||
rev_commit = self._GetRef(negotiation_ref) if negotiation_ref else ""
|
||||
if rev_commit:
|
||||
cmd.extend(["--negotiation-tip", rev_commit])
|
||||
|
||||
@@ -323,11 +323,11 @@ 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)
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
gitdir=self._work_git,
|
||||
@@ -347,6 +347,21 @@ class Superproject:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _GetRef(self, ref: str) -> str:
|
||||
"""Resolve one local ref without loading the entire ref namespace."""
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["rev-parse", "--verify", "--quiet", ref],
|
||||
gitdir=self._work_git,
|
||||
bare=True,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
log_as_error=False,
|
||||
)
|
||||
if p.Wait() == 0:
|
||||
return p.stdout.strip()
|
||||
return ""
|
||||
|
||||
def _LsTree(self):
|
||||
"""Gets the commit ids for all projects.
|
||||
|
||||
@@ -364,7 +379,7 @@ class Superproject:
|
||||
branch = "HEAD" if not self.revision else self.revision
|
||||
cmd = ["ls-tree", "-z", "-r", branch]
|
||||
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
gitdir=self._work_git,
|
||||
@@ -413,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)
|
||||
@@ -552,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")
|
||||
@@ -586,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
|
||||
|
||||
@@ -14,12 +14,11 @@
|
||||
|
||||
"""Event logging in the git trace2 EVENT format."""
|
||||
|
||||
from git_command import GetEventTargetPath
|
||||
from git_command import RepoSourceVersion
|
||||
from git_trace2_event_log_base import BaseEventLog
|
||||
import git_command
|
||||
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.
|
||||
@@ -37,7 +36,9 @@ class EventLog(BaseEventLog):
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(repo_source_version=RepoSourceVersion(), **kwargs)
|
||||
super().__init__(
|
||||
repo_source_version=git_command.RepoSourceVersion(), **kwargs
|
||||
)
|
||||
|
||||
def Write(self, path=None, **kwargs):
|
||||
if path is None:
|
||||
@@ -45,4 +46,4 @@ class EventLog(BaseEventLog):
|
||||
return super().Write(path=path, **kwargs)
|
||||
|
||||
def _GetEventTargetPath(self):
|
||||
return GetEventTargetPath()
|
||||
return git_command.GetEventTargetPath()
|
||||
@@ -12,14 +12,15 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import optparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
import urllib.parse
|
||||
|
||||
from error import HookError
|
||||
from git_refs import HEAD
|
||||
import error
|
||||
import git_refs
|
||||
|
||||
|
||||
# The API we've documented to hook authors. Keep in sync with repo-hooks.md.
|
||||
@@ -69,6 +70,7 @@ class RepoHook:
|
||||
ignore_hooks=False,
|
||||
abort_if_user_denies=False,
|
||||
yes=False,
|
||||
fix=False,
|
||||
):
|
||||
"""RepoHook constructor.
|
||||
|
||||
@@ -91,6 +93,7 @@ class RepoHook:
|
||||
abort_if_user_denies: If True, we'll abort running the hook if the
|
||||
user doesn't allow us to run the hook.
|
||||
yes: If True, then 'Yes' is assumed for any prompts.
|
||||
fix: If True, then 'Fix' is assumed for any fixup prompts.
|
||||
"""
|
||||
self._hook_type = hook_type
|
||||
self._hooks_project = hooks_project
|
||||
@@ -102,6 +105,7 @@ class RepoHook:
|
||||
self._ignore_hooks = ignore_hooks
|
||||
self._abort_if_user_denies = abort_if_user_denies
|
||||
self._yes = yes
|
||||
self._fix = fix
|
||||
|
||||
# Store the full path to the script for convenience.
|
||||
self._script_fullpath = None
|
||||
@@ -138,7 +142,7 @@ class RepoHook:
|
||||
# NOTE: Local (non-committed) changes will not be factored into this
|
||||
# hash. I think this is OK, since we're really only worried about
|
||||
# warning the user about upstream changes.
|
||||
return self._hooks_project.work_git.rev_parse(HEAD)
|
||||
return self._hooks_project.work_git.rev_parse(git_refs.HEAD)
|
||||
|
||||
def _GetMustVerb(self):
|
||||
"""Return 'must' if the hook is required; 'should' if not."""
|
||||
@@ -224,7 +228,7 @@ class RepoHook:
|
||||
|
||||
# For anything else, we'll assume no approval.
|
||||
if self._abort_if_user_denies:
|
||||
raise HookError(
|
||||
raise error.HookError(
|
||||
"You must allow the %s hook or use --no-verify."
|
||||
% self._hook_type
|
||||
)
|
||||
@@ -322,14 +326,16 @@ class RepoHook:
|
||||
try:
|
||||
exec(compile(data, self._script_fullpath, "exec"), context)
|
||||
except Exception:
|
||||
raise HookError(
|
||||
raise error.HookError(
|
||||
"%s\nFailed to import %s hook; see traceback above."
|
||||
% (traceback.format_exc(), self._hook_type)
|
||||
)
|
||||
|
||||
# Running the script should have defined a main() function.
|
||||
if "main" not in context:
|
||||
raise HookError('Missing main() in: "%s"' % self._script_fullpath)
|
||||
raise error.HookError(
|
||||
'Missing main() in: "%s"' % self._script_fullpath
|
||||
)
|
||||
|
||||
# Call the main function in the hook. If the hook should cause the
|
||||
# build to fail, it will raise an Exception. We'll catch that convert
|
||||
@@ -337,7 +343,7 @@ class RepoHook:
|
||||
try:
|
||||
context["main"](**kwargs)
|
||||
except Exception:
|
||||
raise HookError(
|
||||
raise error.HookError(
|
||||
"%s\nFailed to run main() for %s hook; see traceback "
|
||||
"above." % (traceback.format_exc(), self._hook_type)
|
||||
)
|
||||
@@ -380,6 +386,7 @@ class RepoHook:
|
||||
kwargs = {
|
||||
**kwargs,
|
||||
"hook_should_take_kwargs": True,
|
||||
"fix": self._fix,
|
||||
"yes": self._yes,
|
||||
}
|
||||
|
||||
@@ -389,7 +396,7 @@ class RepoHook:
|
||||
if interp:
|
||||
prog = os.path.basename(interp)
|
||||
if prog.startswith("python2"):
|
||||
raise HookError("Python 2 is not supported")
|
||||
raise error.HookError("Python 2 is not supported")
|
||||
|
||||
# Run the hook by importing directly.
|
||||
self._ExecuteHookViaImport(data, context, **kwargs)
|
||||
@@ -401,7 +408,7 @@ class RepoHook:
|
||||
def _CheckHook(self):
|
||||
# Bail with a nice error if we can't find the hook.
|
||||
if not os.path.isfile(self._script_fullpath):
|
||||
raise HookError(
|
||||
raise error.HookError(
|
||||
"Couldn't find repo hook: %s" % self._script_fullpath
|
||||
)
|
||||
|
||||
@@ -468,7 +475,7 @@ class RepoHook:
|
||||
% (self._hook_type, str(e)),
|
||||
file=sys.stderr,
|
||||
)
|
||||
except HookError as e:
|
||||
except error.HookError as e:
|
||||
passed = False
|
||||
print("ERROR: %s" % str(e), file=sys.stderr)
|
||||
|
||||
@@ -504,12 +511,17 @@ class RepoHook:
|
||||
).url,
|
||||
"bug_url": manifest.contactinfo.bugurl,
|
||||
"yes": getattr(opt, "yes", False),
|
||||
"fix": getattr(opt, "fix", False),
|
||||
}
|
||||
)
|
||||
return cls(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def AddOptionGroup(parser, name):
|
||||
def AddOptionGroup(
|
||||
parser: optparse.OptionParser,
|
||||
name: str,
|
||||
allow_fix: bool = False,
|
||||
) -> None:
|
||||
"""Help options relating to the various hooks."""
|
||||
|
||||
# Note that verify and no-verify are NOT opposites of each other, which
|
||||
@@ -533,3 +545,10 @@ class RepoHook:
|
||||
action="store_true",
|
||||
help="Do not abort if %s hooks fail." % name,
|
||||
)
|
||||
if allow_fix:
|
||||
group.add_option(
|
||||
"--fix",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Automatically apply %s fixes without prompting." % name,
|
||||
)
|
||||
@@ -33,7 +33,7 @@ import time
|
||||
from typing import Optional
|
||||
import urllib.request
|
||||
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
|
||||
|
||||
try:
|
||||
@@ -41,38 +41,24 @@ try:
|
||||
except ImportError:
|
||||
kerberos = None
|
||||
|
||||
from color import SetDefaultColoring
|
||||
import color
|
||||
from command import InteractiveCommand
|
||||
from command import MirrorSafeCommand
|
||||
from editor import Editor
|
||||
from error import DownloadError
|
||||
from error import InvalidProjectGroupsError
|
||||
from error import ManifestInvalidRevisionError
|
||||
from error import ManifestParseError
|
||||
from error import NoManifestException
|
||||
from error import NoSuchProjectError
|
||||
from error import RepoChangedException
|
||||
from error import RepoError
|
||||
from error import RepoExitError
|
||||
from error import RepoUnhandledExceptionError
|
||||
from error import SilentRepoExitError
|
||||
import editor
|
||||
import error
|
||||
import event_log
|
||||
from git_command import user_agent
|
||||
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
|
||||
from repo_trace import SetTrace
|
||||
from repo_trace import SetTraceToStderr
|
||||
from repo_trace import Trace
|
||||
import git_command
|
||||
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 = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
# NB: These do not need to be kept in sync with the repo launcher script.
|
||||
@@ -104,6 +90,11 @@ elif sys.version_info < MIN_PYTHON_VERSION_SOFT:
|
||||
KEYBOARD_INTERRUPT_EXIT = 128 + signal.SIGINT
|
||||
MAX_PRINT_ERRORS = 5
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(errors="surrogateescape")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(errors="surrogateescape")
|
||||
|
||||
global_options = optparse.OptionParser(
|
||||
usage="repo [-p|--paginate|--no-pager] COMMAND [ARGS]",
|
||||
add_help_option=False,
|
||||
@@ -202,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:
|
||||
@@ -237,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, []
|
||||
|
||||
@@ -270,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
|
||||
)
|
||||
with Trace(
|
||||
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,
|
||||
@@ -295,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."""
|
||||
|
||||
@@ -393,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,
|
||||
@@ -420,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)
|
||||
@@ -430,7 +422,7 @@ class _Repo:
|
||||
try:
|
||||
copts, cargs = cmd.OptionParser.parse_args(argv)
|
||||
copts = cmd.ReadEnvironmentOptions(copts)
|
||||
except NoManifestException as e:
|
||||
except error.NoManifestException as e:
|
||||
logger.error("error: in `%s`: %s", " ".join([name] + argv), e)
|
||||
logger.error(
|
||||
"error: manifest missing or unreadable -- please run init"
|
||||
@@ -449,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)
|
||||
@@ -506,23 +498,23 @@ class _Repo:
|
||||
KeyboardInterrupt,
|
||||
SystemExit,
|
||||
Exception,
|
||||
RepoExitError,
|
||||
error.RepoExitError,
|
||||
) as e:
|
||||
ok = isinstance(e, SystemExit) and not e.code
|
||||
exception_name = type(e).__name__
|
||||
if isinstance(e, RepoUnhandledExceptionError):
|
||||
if isinstance(e, error.RepoUnhandledExceptionError):
|
||||
exception_name = type(e.error).__name__
|
||||
if isinstance(e, RepoExitError):
|
||||
if isinstance(e, error.RepoExitError):
|
||||
aggregated_errors = e.aggregate_errors or []
|
||||
for error in aggregated_errors:
|
||||
for err in aggregated_errors:
|
||||
project = None
|
||||
if isinstance(error, RepoError):
|
||||
project = error.project
|
||||
if isinstance(err, error.RepoError):
|
||||
project = err.project
|
||||
error_info = json.dumps(
|
||||
{
|
||||
"ErrorType": type(error).__name__,
|
||||
"ErrorType": type(err).__name__,
|
||||
"Project": str(project),
|
||||
"Message": str(error),
|
||||
"Message": str(err),
|
||||
}
|
||||
)
|
||||
git_trace2_event_log.ErrorEvent(
|
||||
@@ -537,24 +529,24 @@ class _Repo:
|
||||
try:
|
||||
execute_command()
|
||||
except (
|
||||
DownloadError,
|
||||
ManifestInvalidRevisionError,
|
||||
ManifestParseError,
|
||||
NoManifestException,
|
||||
error.DownloadError,
|
||||
error.ManifestInvalidRevisionError,
|
||||
error.ManifestParseError,
|
||||
error.NoManifestException,
|
||||
) as e:
|
||||
logger.error("error: in `%s`: %s", " ".join([name] + argv), e)
|
||||
if isinstance(e, NoManifestException):
|
||||
if isinstance(e, error.NoManifestException):
|
||||
logger.error(
|
||||
"error: manifest missing or unreadable -- please run init"
|
||||
)
|
||||
result = e.exit_code
|
||||
except NoSuchProjectError as e:
|
||||
except error.NoSuchProjectError as e:
|
||||
if e.name:
|
||||
logger.error("error: project %s not found", e.name)
|
||||
else:
|
||||
logger.error("error: no project in current directory")
|
||||
result = e.exit_code
|
||||
except InvalidProjectGroupsError as e:
|
||||
except error.InvalidProjectGroupsError as e:
|
||||
if e.name:
|
||||
logger.error(
|
||||
"error: project group must be enabled for project %s",
|
||||
@@ -573,7 +565,7 @@ class _Repo:
|
||||
except KeyboardInterrupt:
|
||||
result = KEYBOARD_INTERRUPT_EXIT
|
||||
raise
|
||||
except RepoExitError as e:
|
||||
except error.RepoExitError as e:
|
||||
result = e.exit_code
|
||||
raise
|
||||
except Exception:
|
||||
@@ -595,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()
|
||||
)
|
||||
@@ -634,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))
|
||||
@@ -652,7 +645,7 @@ repo: error:
|
||||
ver_str,
|
||||
min_str,
|
||||
exp_str,
|
||||
WrapperPath(),
|
||||
wrapper.WrapperPath(),
|
||||
repo_path,
|
||||
)
|
||||
sys.exit(1)
|
||||
@@ -667,7 +660,7 @@ repo: error:
|
||||
... You should upgrade soon:
|
||||
cp %s %s
|
||||
""",
|
||||
WrapperPath(),
|
||||
wrapper.WrapperPath(),
|
||||
repo_path,
|
||||
)
|
||||
else:
|
||||
@@ -678,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,
|
||||
)
|
||||
|
||||
@@ -707,11 +700,11 @@ def _PruneOptions(argv, opt):
|
||||
|
||||
class _UserAgentHandler(urllib.request.BaseHandler):
|
||||
def http_request(self, req):
|
||||
req.add_header("User-Agent", user_agent.repo)
|
||||
req.add_header("User-Agent", git_command.user_agent.repo)
|
||||
return req
|
||||
|
||||
def https_request(self, req):
|
||||
req.add_header("User-Agent", user_agent.repo)
|
||||
req.add_header("User-Agent", git_command.user_agent.repo)
|
||||
return req
|
||||
|
||||
|
||||
@@ -938,20 +931,20 @@ def _Main(argv):
|
||||
name, gopts, argv = repo._ParseArgs(argv)
|
||||
|
||||
if gopts.trace:
|
||||
SetTrace()
|
||||
repo_trace.SetTrace()
|
||||
|
||||
if gopts.trace_to_stderr:
|
||||
SetTraceToStderr()
|
||||
repo_trace.SetTraceToStderr()
|
||||
|
||||
result = repo._Run(name, gopts, argv) or 0
|
||||
except RepoExitError as e:
|
||||
if not isinstance(e, SilentRepoExitError):
|
||||
except error.RepoExitError as e:
|
||||
if not isinstance(e, error.SilentRepoExitError):
|
||||
logger.log_aggregated_errors(e)
|
||||
result = e.exit_code
|
||||
except KeyboardInterrupt:
|
||||
print("aborted by user", file=sys.stderr)
|
||||
result = KEYBOARD_INTERRUPT_EXIT
|
||||
except RepoChangedException as rce:
|
||||
except error.RepoChangedException as rce:
|
||||
# If repo changed, re-exec ourselves.
|
||||
argv = list(sys.argv)
|
||||
argv.extend(rce.extra_args)
|
||||
@@ -962,7 +955,7 @@ def _Main(argv):
|
||||
print("fatal: %s" % e, file=sys.stderr)
|
||||
result = 128
|
||||
|
||||
TerminatePager()
|
||||
pager.TerminatePager()
|
||||
sys.exit(result)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "October 2026" "repo config" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo config - manual page for repo config
|
||||
.SH SYNOPSIS
|
||||
.B repo
|
||||
\fI\,config \/\fR[\fI\,--type=<type>\/\fR] [\fI\,-z\/\fR] \fI\,get <name>\/\fR
|
||||
.SH DESCRIPTION
|
||||
Summary
|
||||
.PP
|
||||
Read and write per\-user repo settings
|
||||
.PP
|
||||
repo config [\-\-type=<type>] set <name> <value>
|
||||
repo config unset <name>
|
||||
repo config [\-z] list
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-\-type\fR=\fI\,TYPE\/\fR
|
||||
validate and canonicalize the value as this type for get and set (bool, int)
|
||||
.TP
|
||||
\fB\-z\fR, \fB\-\-null\fR
|
||||
terminate output of get and list with NUL instead of newline, and separate key and value with a newline
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
show all output
|
||||
.TP
|
||||
\fB\-q\fR, \fB\-\-quiet\fR
|
||||
only show errors
|
||||
.SS Multi\-manifest options:
|
||||
.TP
|
||||
\fB\-\-outer\-manifest\fR
|
||||
operate starting at the outermost manifest
|
||||
.TP
|
||||
\fB\-\-no\-outer\-manifest\fR
|
||||
do not operate on outer manifests
|
||||
.TP
|
||||
\fB\-\-this\-manifest\-only\fR
|
||||
only operate on this (sub)manifest
|
||||
.TP
|
||||
\fB\-\-no\-this\-manifest\-only\fR, \fB\-\-all\-manifests\fR
|
||||
operate on this manifest and its submanifests
|
||||
.PP
|
||||
Run `repo help config` to view the detailed manual.
|
||||
.SH DETAILS
|
||||
.PP
|
||||
The 'repo config' command reads and writes repo's own per\-user settings. These
|
||||
are separate from git's config and from a checkout's settings; see
|
||||
docs/repoconfig.md for where they live and which settings repo understands.
|
||||
.PP
|
||||
Settings are named <section>.<name>, e.g. sync.smartsync. 'get' prints every
|
||||
value of the setting and exits 1 without output if it is not set, like `git
|
||||
config`.
|
||||
.PP
|
||||
Values are stored as strings. With \fB\-\-type\fR, 'set' validates the value and stores
|
||||
it in canonical form (e.g. "on" as "true", "1k" as "1024"), and 'get' prints the
|
||||
canonical form, like `git config \fB\-\-type\fR`.
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "June 2026" "repo gc" "Repo Manual"
|
||||
.TH REPO "1" "September 2026" "repo gc" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo gc - manual page for repo gc
|
||||
.SH SYNOPSIS
|
||||
@@ -22,6 +22,9 @@ answer yes to all safe prompts
|
||||
.TP
|
||||
\fB\-\-repack\fR
|
||||
repack all projects that use partial clone with filter=blob:none
|
||||
.TP
|
||||
\fB\-\-keep\-manifest\-projects\fR
|
||||
keep Git data for projects still in the manifests
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
@@ -44,3 +47,11 @@ only operate on this (sub)manifest
|
||||
operate on this manifest and its submanifests
|
||||
.PP
|
||||
Run `repo help gc` to view the detailed manual.
|
||||
.SH DETAILS
|
||||
.PP
|
||||
The 'repo gc' command removes internal Git data for projects that are no longer
|
||||
active in the current configuration.
|
||||
.PP
|
||||
Projects excluded by the configured manifest groups are inactive by default. Use
|
||||
\fB\-\-keep\-manifest\-projects\fR to keep Git data for projects that are still in the
|
||||
manifests.
|
||||
@@ -103,6 +103,12 @@ use the manifest superproject to sync projects; implies \fB\-c\fR
|
||||
\fB\-\-no\-use\-superproject\fR
|
||||
disable use of manifest superprojects
|
||||
.TP
|
||||
\fB\-s\fR, \fB\-\-smart\-sync\fR
|
||||
default to smart sync when syncing projects
|
||||
.TP
|
||||
\fB\-\-no\-smart\-sync\fR, \fB\-\-head\fR
|
||||
do not default to smart sync when syncing projects
|
||||
.TP
|
||||
\fB\-\-clone\-bundle\fR
|
||||
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS (default if not \fB\-\-partial\-clone\fR)
|
||||
.TP
|
||||
|
||||
+81
-17
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "June 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
|
||||
@@ -121,18 +121,19 @@ include*)>
|
||||
<!ATTLIST remote revision CDATA #IMPLIED>
|
||||
.IP
|
||||
<!ELEMENT default EMPTY>
|
||||
<!ATTLIST default remote IDREF #IMPLIED>
|
||||
<!ATTLIST default revision CDATA #IMPLIED>
|
||||
<!ATTLIST default dest\-branch CDATA #IMPLIED>
|
||||
<!ATTLIST default upstream CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-j CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-j\-max CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-c CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-s CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-tags CDATA #IMPLIED>
|
||||
<!ATTLIST default remote IDREF #IMPLIED>
|
||||
<!ATTLIST default revision CDATA #IMPLIED>
|
||||
<!ATTLIST default dest\-branch CDATA #IMPLIED>
|
||||
<!ATTLIST default upstream CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-j CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-j\-max CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-c CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-s CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-tags CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-smartsync CDATA #IMPLIED>
|
||||
.IP
|
||||
<!ELEMENT manifest\-server EMPTY>
|
||||
<!ATTLIST manifest\-server url CDATA #REQUIRED>
|
||||
<!ATTLIST manifest\-server url CDATA #REQUIRED>
|
||||
<!ATTLIST manifest\-server helper CDATA #IMPLIED>
|
||||
.IP
|
||||
<!ELEMENT submanifest EMPTY>
|
||||
@@ -148,7 +149,8 @@ include*)>
|
||||
<!ELEMENT project (annotation*,
|
||||
project*,
|
||||
copyfile*,
|
||||
linkfile*)>
|
||||
linkfile*,
|
||||
sparse\-path*)>
|
||||
.TP
|
||||
<!ATTLIST project name
|
||||
CDATA #REQUIRED>
|
||||
@@ -200,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>
|
||||
@@ -336,6 +342,8 @@ Attribute `sync\-s`: Set to true to also sync sub\-projects.
|
||||
Attribute `sync\-tags`: Set to false to only sync the given Git branch (specified
|
||||
in the `revision` attribute) rather than the other ref tags.
|
||||
.PP
|
||||
Attribute `sync\-smartsync`: Set to true to enable smart sync by default.
|
||||
.PP
|
||||
Element manifest\-server
|
||||
.PP
|
||||
At most one manifest\-server may be specified. The url attribute is used to
|
||||
@@ -511,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.
|
||||
@@ -558,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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2026" "repo smartsync" "Repo Manual"
|
||||
.TH REPO "1" "September 2026" "repo smartsync" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo smartsync - manual page for repo smartsync
|
||||
.SH SYNOPSIS
|
||||
@@ -21,7 +21,7 @@ number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
number of network jobs to run in parallel (defaults to \fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-\-jobs\-checkout\fR=\fI\,JOBS\/\fR
|
||||
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or 8). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or based on number of CPU cores). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-f\fR, \fB\-\-force\-broken\fR
|
||||
obsolete option (to be deleted in the future)
|
||||
|
||||
+9
-2
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2026" "repo sync" "Repo Manual"
|
||||
.TH REPO "1" "October 2026" "repo sync" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo sync - manual page for repo sync
|
||||
.SH SYNOPSIS
|
||||
@@ -21,7 +21,7 @@ number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
number of network jobs to run in parallel (defaults to \fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-\-jobs\-checkout\fR=\fI\,JOBS\/\fR
|
||||
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or 8). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or based on number of CPU cores). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-f\fR, \fB\-\-force\-broken\fR
|
||||
obsolete option (to be deleted in the future)
|
||||
@@ -125,6 +125,9 @@ do not run garbage collection on any projects (default)
|
||||
\fB\-s\fR, \fB\-\-smart\-sync\fR
|
||||
smart sync using manifest from the latest known good build
|
||||
.TP
|
||||
\fB\-\-no\-smart\-sync\fR, \fB\-\-head\fR
|
||||
disable smart sync and sync to ToT instead
|
||||
.TP
|
||||
\fB\-t\fR SMART_TAG, \fB\-\-smart\-tag\fR=\fI\,SMART_TAG\/\fR
|
||||
smart sync using manifest from a known tag
|
||||
.SS Logging options:
|
||||
@@ -185,6 +188,10 @@ The \fB\-s\fR/\-\-smart\-sync option can be used to sync to a known good build a
|
||||
specified by the manifest\-server element in the current manifest. The
|
||||
\fB\-t\fR/\-\-smart\-tag option is similar and allows you to specify a custom tag/label.
|
||||
.PP
|
||||
Whether 'repo sync' smart syncs by default can also be controlled with the
|
||||
sync.smartsync per\-user setting; see docs/repoconfig.md. Explicit
|
||||
\fB\-s\fR/\-\-no\-smart\-sync/\-t flags always override it.
|
||||
.PP
|
||||
The \fB\-u\fR/\-\-manifest\-server\-username and \fB\-p\fR/\-\-manifest\-server\-password options can
|
||||
be used to specify a username and password to authenticate with the manifest
|
||||
server when using the \fB\-s\fR or \fB\-t\fR option.
|
||||
|
||||
+7
-1
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "June 2026" "repo upload" "Repo Manual"
|
||||
.TH REPO "1" "October 2026" "repo upload" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo upload - manual page for repo upload
|
||||
.SH SYNOPSIS
|
||||
@@ -80,6 +80,9 @@ ignore untracked files in the working copy
|
||||
\fB\-\-no\-ignore\-untracked\-files\fR
|
||||
always ask about untracked files in the working copy
|
||||
.TP
|
||||
\fB\-\-ignore\-remote\-commits\fR
|
||||
ignore commits already in remote branches
|
||||
.TP
|
||||
\fB\-\-no\-cert\-checks\fR
|
||||
disable verifying ssl certs (unsafe)
|
||||
.SS Logging options:
|
||||
@@ -112,6 +115,9 @@ Run the pre\-upload hook without prompting.
|
||||
.TP
|
||||
\fB\-\-ignore\-hooks\fR
|
||||
Do not abort if pre\-upload hooks fail.
|
||||
.TP
|
||||
\fB\-\-fix\fR
|
||||
Automatically apply pre\-upload fixes without prompting.
|
||||
.PP
|
||||
Run `repo help upload` to view the detailed manual.
|
||||
.SH DETAILS
|
||||
|
||||
+4
-1
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "June 2026" "repo" "Repo Manual"
|
||||
.TH REPO "1" "October 2026" "repo" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repository management tool built on top of git
|
||||
.SH SYNOPSIS
|
||||
@@ -65,6 +65,9 @@ Checkout a branch for development
|
||||
cherry\-pick
|
||||
Cherry\-pick a change.
|
||||
.TP
|
||||
config
|
||||
Read and write per\-user repo settings
|
||||
.TP
|
||||
diff
|
||||
Show changes between commit and working tree
|
||||
.TP
|
||||
|
||||
+132
-84
@@ -21,20 +21,17 @@ import sys
|
||||
import urllib.parse
|
||||
import xml.dom.minidom
|
||||
|
||||
from error import ManifestInvalidPathError
|
||||
from error import ManifestInvalidRevisionError
|
||||
from error import ManifestParseError
|
||||
from git_config import GitConfig
|
||||
from git_refs import HEAD
|
||||
from git_refs import R_HEADS
|
||||
from git_superproject import Superproject
|
||||
import error
|
||||
import git_config
|
||||
import git_refs
|
||||
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"
|
||||
@@ -114,7 +111,9 @@ def XmlInt(node, attr, default=None):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise ManifestParseError(f'manifest: invalid {attr}="{value}" integer')
|
||||
raise error.ManifestParseError(
|
||||
f'manifest: invalid {attr}="{value}" integer'
|
||||
)
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
@@ -159,6 +158,7 @@ class _Default:
|
||||
sync_c = False
|
||||
sync_s = False
|
||||
sync_tags = True
|
||||
sync_smartsync = False
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, _Default):
|
||||
@@ -289,7 +289,7 @@ class _XmlSubmanifest:
|
||||
self.annotations = []
|
||||
outer_client = parent._outer_client or parent
|
||||
if self.remote and not self.project:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
f"Submanifest {name}: must specify project when remote is "
|
||||
"given."
|
||||
)
|
||||
@@ -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)
|
||||
@@ -423,7 +423,7 @@ class XmlManifest:
|
||||
# This avoids a trailing os.path.sep when submanifest_path is empty.
|
||||
self.topdir = os.path.join(self.topdir, submanifest_path)
|
||||
if manifest_file != os.path.abspath(manifest_file):
|
||||
raise ManifestParseError("manifest_file must be abspath")
|
||||
raise error.ManifestParseError("manifest_file must be abspath")
|
||||
self.manifestFile = manifest_file
|
||||
if not outer_client or outer_client == self:
|
||||
# manifestFileOverrides only exists in the outer_client's manifest,
|
||||
@@ -437,7 +437,9 @@ class XmlManifest:
|
||||
|
||||
if submanifest_path and not outer_client:
|
||||
# If passing a submanifest_path, there must be an outer_client.
|
||||
raise ManifestParseError(f"Bad call to {self.__class__.__name__}")
|
||||
raise error.ManifestParseError(
|
||||
f"Bad call to {self.__class__.__name__}"
|
||||
)
|
||||
|
||||
# If self._outer_client is None, this is not a checkout that supports
|
||||
# multi-tree.
|
||||
@@ -476,13 +478,24 @@ class XmlManifest:
|
||||
if path is None:
|
||||
path = os.path.join(self.manifestProject.worktree, name)
|
||||
if not os.path.isfile(path):
|
||||
raise ManifestParseError("manifest %s not found" % name)
|
||||
raise error.ManifestParseError("manifest %s not found" % name)
|
||||
|
||||
self._load_local_manifests = load_local_manifests
|
||||
self._outer_client.manifestFileOverrides[self.path_prefix] = path
|
||||
self.Unload()
|
||||
self._Load()
|
||||
|
||||
def ClearOverride(self) -> None:
|
||||
"""Stop overriding the manifest, reverting to the default one.
|
||||
|
||||
This is the inverse of Override(), and is a no-op if no override is in
|
||||
effect. It is useful to recover from a failed Override(), which
|
||||
registers the override before parsing it.
|
||||
"""
|
||||
self._outer_client.manifestFileOverrides.pop(self.path_prefix, None)
|
||||
self._load_local_manifests = True
|
||||
self.Unload()
|
||||
|
||||
def Link(self, name):
|
||||
"""Update the repo metadata to use a different manifest."""
|
||||
self.Override(name)
|
||||
@@ -644,6 +657,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if not d.sync_tags:
|
||||
have_default = True
|
||||
e.setAttribute("sync-tags", "false")
|
||||
if d.sync_smartsync:
|
||||
have_default = True
|
||||
e.setAttribute("sync-smartsync", "true")
|
||||
if have_default:
|
||||
root.appendChild(e)
|
||||
root.appendChild(doc.createTextNode(""))
|
||||
@@ -692,9 +708,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
e.setAttribute("remote", remoteName)
|
||||
if peg_rev:
|
||||
if self.IsMirror:
|
||||
value = p.bare_git.rev_parse(p.revisionExpr + "^0")
|
||||
value = p.bare_git.ResolveCommit(p.revisionExpr)
|
||||
else:
|
||||
value = p.work_git.rev_parse(HEAD + "^0")
|
||||
value = p.work_git.ResolveCommit(git_refs.HEAD)
|
||||
e.setAttribute("revision", value)
|
||||
if peg_rev_upstream:
|
||||
if p.upstream:
|
||||
@@ -738,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)))
|
||||
@@ -799,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)
|
||||
@@ -830,6 +851,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
"annotation",
|
||||
"copyfile",
|
||||
"linkfile",
|
||||
"sparse-path",
|
||||
}
|
||||
|
||||
doc = self.ToXml(**kwargs)
|
||||
@@ -848,7 +870,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
elif child.nodeName in MULTI_ELEMENTS:
|
||||
ret.setdefault(child.nodeName, []).append(element)
|
||||
else:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
f'Unhandled element "{child.nodeName}"'
|
||||
)
|
||||
|
||||
@@ -1160,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
|
||||
@@ -1173,7 +1195,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
def _Load(self, initial_client=None, submanifest_depth=0):
|
||||
if submanifest_depth > MAX_SUBMANIFEST_DEPTH:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"maximum submanifest depth %d exceeded." % MAX_SUBMANIFEST_DEPTH
|
||||
)
|
||||
if not self._loaded:
|
||||
@@ -1191,8 +1213,8 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
try:
|
||||
m = self.manifestProject
|
||||
b = m.GetBranch(m.CurrentBranch).merge
|
||||
if b is not None and b.startswith(R_HEADS):
|
||||
b = b[len(R_HEADS) :]
|
||||
if b is not None and b.startswith(git_refs.R_HEADS):
|
||||
b = b[len(git_refs.R_HEADS) :]
|
||||
self.branch = b
|
||||
|
||||
parent_groups = self.parent_groups.copy()
|
||||
@@ -1245,7 +1267,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
try:
|
||||
self._ParseManifest(nodes)
|
||||
except ManifestParseError as e:
|
||||
except error.ManifestParseError as e:
|
||||
# There was a problem parsing, unload ourselves in case they
|
||||
# catch this error and try again later, we will show the
|
||||
# correct error
|
||||
@@ -1307,10 +1329,12 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
try:
|
||||
root = xml.dom.minidom.parse(path)
|
||||
except (OSError, xml.parsers.expat.ExpatError) as e:
|
||||
raise ManifestParseError(f"error parsing manifest {path}: {e}")
|
||||
raise error.ManifestParseError(
|
||||
f"error parsing manifest {path}: {e}"
|
||||
)
|
||||
|
||||
if not root or not root.childNodes:
|
||||
raise ManifestParseError(f"no root node in {path}")
|
||||
raise error.ManifestParseError(f"no root node in {path}")
|
||||
|
||||
for manifest in root.childNodes:
|
||||
if (
|
||||
@@ -1319,7 +1343,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
):
|
||||
break
|
||||
else:
|
||||
raise ManifestParseError(f"no <manifest> in {path}")
|
||||
raise error.ManifestParseError(f"no <manifest> in {path}")
|
||||
|
||||
nodes = []
|
||||
for node in manifest.childNodes:
|
||||
@@ -1336,7 +1360,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if restrict_includes:
|
||||
msg = self._CheckLocalPath(name)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<include> invalid "name": {name}: {msg}'
|
||||
)
|
||||
include_groups = (parent_groups or set()).copy()
|
||||
@@ -1346,7 +1370,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
)
|
||||
fp = os.path.join(include_root, name)
|
||||
if not os.path.isfile(fp):
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"include [%s/]%s doesn't exist or isn't a file"
|
||||
% (include_root, name)
|
||||
)
|
||||
@@ -1358,10 +1382,10 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
)
|
||||
# should isolate this to the exact exception, but that's
|
||||
# tricky. actual parsing implementation may vary.
|
||||
except (RuntimeError, ManifestParseError):
|
||||
except (RuntimeError, error.ManifestParseError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
f"failed parsing included manifest {name}: {e}"
|
||||
)
|
||||
else:
|
||||
@@ -1385,7 +1409,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if remote:
|
||||
if remote.name in self._remotes:
|
||||
if remote != self._remotes[remote.name]:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"remote %s already exists with different "
|
||||
"attributes" % (remote.name)
|
||||
)
|
||||
@@ -1401,7 +1425,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if self._default is None:
|
||||
self._default = new_default
|
||||
elif not emptyDefault and new_default != self._default:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate default in %s" % (self.manifestFile)
|
||||
)
|
||||
|
||||
@@ -1415,7 +1439,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if submanifest:
|
||||
if submanifest.name in self._submanifests:
|
||||
if submanifest != self._submanifests[submanifest.name]:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"submanifest %s already exists with different "
|
||||
"attributes" % (submanifest.name)
|
||||
)
|
||||
@@ -1426,7 +1450,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
for node in itertools.chain(*node_list):
|
||||
if node.nodeName == "notice":
|
||||
if self._notice is not None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate notice in %s" % (self.manifestFile)
|
||||
)
|
||||
self._notice = self._ParseNotice(node)
|
||||
@@ -1436,7 +1460,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
url = self._reqatt(node, "url")
|
||||
helper = node.getAttribute("helper") or None
|
||||
if self._manifest_server is not None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate manifest-server in %s" % (self.manifestFile)
|
||||
)
|
||||
self._manifest_server = url
|
||||
@@ -1445,18 +1469,18 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
def recursively_add_projects(project):
|
||||
projects = self._projects.setdefault(project.name, [])
|
||||
if project.relpath is None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"missing path for %s in %s"
|
||||
% (project.name, self.manifestFile)
|
||||
)
|
||||
if project.relpath in self._paths:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate path %s in %s"
|
||||
% (project.relpath, self.manifestFile)
|
||||
)
|
||||
for tree in submanifest_paths:
|
||||
if project.relpath.startswith(tree):
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"project %s conflicts with submanifest path %s"
|
||||
% (project.relpath, tree)
|
||||
)
|
||||
@@ -1476,7 +1500,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
name = self._reqatt(node, "name")
|
||||
|
||||
if name not in self._projects:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"extend-project element specifies non-existent "
|
||||
"project: %s" % name
|
||||
)
|
||||
@@ -1498,7 +1522,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
named_projects = self._projects[name]
|
||||
if dest_path and not path and len(named_projects) > 1:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"extend-project cannot use dest-path when "
|
||||
"matching multiple projects: %s" % name
|
||||
)
|
||||
@@ -1519,9 +1543,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if base_revision:
|
||||
if p.revisionExpr != base_revision:
|
||||
failed_revision_changes.append(
|
||||
"extend-project name %s mismatch base "
|
||||
"%s vs revision %s"
|
||||
% (name, base_revision, p.revisionExpr)
|
||||
f"extend-project name {name}:\n "
|
||||
f"base {base_revision} vs "
|
||||
f"revision {p.revisionExpr}"
|
||||
)
|
||||
p.SetRevision(revision)
|
||||
|
||||
@@ -1551,11 +1575,13 @@ 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
|
||||
if repo_hooks_project is not None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate repo-hooks in %s" % (self.manifestFile)
|
||||
)
|
||||
|
||||
@@ -1569,7 +1595,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
name = self._reqatt(node, "name")
|
||||
# There can only be one superproject.
|
||||
if self._superproject:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate superproject in %s" % (self.manifestFile)
|
||||
)
|
||||
remote_name = node.getAttribute("remote")
|
||||
@@ -1578,7 +1604,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
else:
|
||||
remote = self._get_remote(node)
|
||||
if remote is None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"no remote for superproject %s within %s"
|
||||
% (name, self.manifestFile)
|
||||
)
|
||||
@@ -1586,11 +1612,11 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if not revision:
|
||||
revision = self._default.revisionExpr
|
||||
if not revision:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"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),
|
||||
@@ -1609,7 +1635,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
# Name or path needed.
|
||||
if not name and not path:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"remove-project must have name and/or path"
|
||||
)
|
||||
|
||||
@@ -1622,9 +1648,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if base_revision:
|
||||
if p.revisionExpr != base_revision:
|
||||
failed_revision_changes.append(
|
||||
"remove-project name %s mismatch base "
|
||||
"%s vs revision %s"
|
||||
% (name, base_revision, p.revisionExpr)
|
||||
f"remove-project name {name}:\n "
|
||||
f"base {base_revision} vs "
|
||||
f"revision {p.revisionExpr}"
|
||||
)
|
||||
del self._paths[p.relpath]
|
||||
if not removed_project:
|
||||
@@ -1636,13 +1662,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if base_revision:
|
||||
if p.revisionExpr != base_revision:
|
||||
failed_revision_changes.append(
|
||||
"remove-project path %s mismatch base "
|
||||
"%s vs revision %s"
|
||||
% (
|
||||
p.relpath,
|
||||
base_revision,
|
||||
p.revisionExpr,
|
||||
)
|
||||
f"remove-project path {p.relpath}:\n "
|
||||
f"base {base_revision} vs "
|
||||
f"revision {p.revisionExpr}"
|
||||
)
|
||||
self._projects[projname].remove(p)
|
||||
del self._paths[p.relpath]
|
||||
@@ -1658,16 +1680,16 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
repo_hooks_project = None
|
||||
|
||||
if not removed_project and not XmlBool(node, "optional", False):
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"remove-project element specifies non-existent "
|
||||
"project: %s" % node.toxml()
|
||||
)
|
||||
|
||||
if failed_revision_changes:
|
||||
raise ManifestParseError(
|
||||
"revision base check failed, rebase patches and update "
|
||||
"base revs for: ",
|
||||
failed_revision_changes,
|
||||
fail_string = "\n".join(failed_revision_changes)
|
||||
raise error.ManifestParseError(
|
||||
f"detected base-revision mismatch, updates needed:\n"
|
||||
f"{fail_string}",
|
||||
)
|
||||
|
||||
# Store repo hooks project information.
|
||||
@@ -1676,12 +1698,12 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
try:
|
||||
repo_hooks_projects = self._projects[repo_hooks_project]
|
||||
except KeyError:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"project %s not found for repo-hooks" % (repo_hooks_project)
|
||||
)
|
||||
|
||||
if len(repo_hooks_projects) != 1:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"internal error parsing repo-hooks in %s"
|
||||
% (self.manifestFile)
|
||||
)
|
||||
@@ -1693,7 +1715,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
name = None
|
||||
m_url = m.GetRemote().url
|
||||
if m_url.endswith("/.git"):
|
||||
raise ManifestParseError("refusing to mirror %s" % m_url)
|
||||
raise error.ManifestParseError("refusing to mirror %s" % m_url)
|
||||
|
||||
if self._default and self._default.remote:
|
||||
url = self._default.remote.resolvedFetchUrl
|
||||
@@ -1778,14 +1800,14 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
d.sync_j = XmlInt(node, "sync-j", None)
|
||||
if d.sync_j is not None and d.sync_j <= 0:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
'%s: sync-j must be greater than 0, not "%s"'
|
||||
% (self.manifestFile, d.sync_j)
|
||||
)
|
||||
|
||||
d.sync_j_max = XmlInt(node, "sync-j-max", None)
|
||||
if d.sync_j_max is not None and d.sync_j_max <= 0:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
'%s: sync-j-max must be greater than 0, not "%s"'
|
||||
% (self.manifestFile, d.sync_j_max)
|
||||
)
|
||||
@@ -1793,6 +1815,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
d.sync_c = XmlBool(node, "sync-c", False)
|
||||
d.sync_s = XmlBool(node, "sync-s", False)
|
||||
d.sync_tags = XmlBool(node, "sync-tags", True)
|
||||
d.sync_smartsync = XmlBool(node, "sync-smartsync", False)
|
||||
return d
|
||||
|
||||
def _ParseNotice(self, node):
|
||||
@@ -1860,20 +1883,20 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if revision:
|
||||
msg = self._CheckLocalPath(revision.split("/")[-1])
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
'<submanifest> invalid "revision": %s: %s'
|
||||
% (revision, msg)
|
||||
)
|
||||
else:
|
||||
msg = self._CheckLocalPath(name)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<submanifest> invalid "name": {name}: {msg}'
|
||||
)
|
||||
else:
|
||||
msg = self._CheckLocalPath(path)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<submanifest> invalid "path": {path}: {msg}'
|
||||
)
|
||||
|
||||
@@ -1908,7 +1931,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
name = self._reqatt(node, "name")
|
||||
msg = self._CheckLocalPath(name, dir_ok=True)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<project> invalid "name": {name}: {msg}'
|
||||
)
|
||||
if parent:
|
||||
@@ -1918,7 +1941,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if remote is None:
|
||||
remote = self._default.remote
|
||||
if remote is None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
f"no remote for project {name} within {self.manifestFile}"
|
||||
)
|
||||
|
||||
@@ -1926,7 +1949,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if not revisionExpr:
|
||||
revisionExpr = self._default.revisionExpr
|
||||
if not revisionExpr:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"no revision for project %s within %s"
|
||||
% (name, self.manifestFile)
|
||||
)
|
||||
@@ -1939,7 +1962,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
# Project.Sync_LocalHalf.
|
||||
msg = self._CheckLocalPath(path, dir_ok=True, cwd_dot_ok=True)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<project> invalid "path": {path}: {msg}'
|
||||
)
|
||||
|
||||
@@ -1950,7 +1973,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
clone_depth = XmlInt(node, "clone-depth")
|
||||
if clone_depth is not None and clone_depth <= 0:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
'%s: clone-depth must be greater than 0, not "%s"'
|
||||
% (self.manifestFile, clone_depth)
|
||||
)
|
||||
@@ -2018,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)
|
||||
@@ -2244,7 +2269,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
# It is relative to the top of the repo client checkout.
|
||||
msg = cls._CheckLocalPath(dest)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<{element}> invalid "dest": {dest}: {msg}'
|
||||
)
|
||||
|
||||
@@ -2255,7 +2280,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
src, dir_ok=is_linkfile, cwd_dot_ok=is_linkfile
|
||||
)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<{element}> invalid "src": {src}: {msg}'
|
||||
)
|
||||
|
||||
@@ -2284,14 +2309,37 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
value = self._reqatt(node, "value")
|
||||
try:
|
||||
keep = self._reqatt(node, "keep").lower()
|
||||
except ManifestParseError:
|
||||
except error.ManifestParseError:
|
||||
keep = "true"
|
||||
if keep != "true" and keep != "false":
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
'optional "keep" attribute must be ' '"true" or "false"'
|
||||
)
|
||||
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:
|
||||
@@ -2299,7 +2347,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
v = self._remotes.get(name)
|
||||
if not v:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
f"remote {name} not defined in {self.manifestFile}"
|
||||
)
|
||||
return v
|
||||
@@ -2310,7 +2358,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
"""
|
||||
v = node.getAttribute(attname)
|
||||
if not v:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"no %s in <%s> within %s"
|
||||
% (attname, node.nodeName, self.manifestFile)
|
||||
)
|
||||
@@ -2348,7 +2396,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
try:
|
||||
fromRevId = fromProj.GetCommitRevisionId()
|
||||
toRevId = toProj.GetCommitRevisionId()
|
||||
except ManifestInvalidRevisionError:
|
||||
except error.ManifestInvalidRevisionError:
|
||||
diff["unreachable"].append((fromProj, toProj))
|
||||
else:
|
||||
if fromRevId != toRevId:
|
||||
|
||||
+4
-6
@@ -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)
|
||||
+62
-80
@@ -12,32 +12,14 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from ctypes import addressof
|
||||
from ctypes import byref
|
||||
from ctypes import c_buffer
|
||||
from ctypes import c_ubyte
|
||||
from ctypes import FormatError
|
||||
from ctypes import get_last_error
|
||||
from ctypes import Structure
|
||||
from ctypes import Union
|
||||
from ctypes import WinDLL
|
||||
from ctypes import WinError
|
||||
from ctypes.wintypes import BOOL
|
||||
from ctypes.wintypes import BOOLEAN
|
||||
from ctypes.wintypes import DWORD
|
||||
from ctypes.wintypes import HANDLE
|
||||
from ctypes.wintypes import LPCWSTR
|
||||
from ctypes.wintypes import LPDWORD
|
||||
from ctypes.wintypes import LPVOID
|
||||
from ctypes.wintypes import ULONG
|
||||
from ctypes.wintypes import USHORT
|
||||
from ctypes.wintypes import WCHAR
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import errno
|
||||
|
||||
|
||||
kernel32 = WinDLL("kernel32", use_last_error=True)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
|
||||
UCHAR = c_ubyte
|
||||
UCHAR = ctypes.c_ubyte
|
||||
|
||||
# Win32 error codes
|
||||
ERROR_SUCCESS = 0
|
||||
@@ -46,11 +28,11 @@ ERROR_PRIVILEGE_NOT_HELD = 1314
|
||||
|
||||
# Win32 API entry points
|
||||
CreateSymbolicLinkW = kernel32.CreateSymbolicLinkW
|
||||
CreateSymbolicLinkW.restype = BOOLEAN
|
||||
CreateSymbolicLinkW.restype = wintypes.BOOLEAN
|
||||
CreateSymbolicLinkW.argtypes = (
|
||||
LPCWSTR, # lpSymlinkFileName In
|
||||
LPCWSTR, # lpTargetFileName In
|
||||
DWORD, # dwFlags In
|
||||
wintypes.LPCWSTR, # lpSymlinkFileName In
|
||||
wintypes.LPCWSTR, # lpTargetFileName In
|
||||
wintypes.DWORD, # dwFlags In
|
||||
)
|
||||
|
||||
# Symbolic link creation flags
|
||||
@@ -61,44 +43,44 @@ SYMBOLIC_LINK_FLAG_DIRECTORY = 0x01
|
||||
SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE = 0x02
|
||||
|
||||
GetFileAttributesW = kernel32.GetFileAttributesW
|
||||
GetFileAttributesW.restype = DWORD
|
||||
GetFileAttributesW.argtypes = (LPCWSTR,) # lpFileName In
|
||||
GetFileAttributesW.restype = wintypes.DWORD
|
||||
GetFileAttributesW.argtypes = (wintypes.LPCWSTR,) # lpFileName In
|
||||
|
||||
INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF
|
||||
FILE_ATTRIBUTE_REPARSE_POINT = 0x00400
|
||||
|
||||
CreateFileW = kernel32.CreateFileW
|
||||
CreateFileW.restype = HANDLE
|
||||
CreateFileW.restype = wintypes.HANDLE
|
||||
CreateFileW.argtypes = (
|
||||
LPCWSTR, # lpFileName In
|
||||
DWORD, # dwDesiredAccess In
|
||||
DWORD, # dwShareMode In
|
||||
LPVOID, # lpSecurityAttributes In_opt
|
||||
DWORD, # dwCreationDisposition In
|
||||
DWORD, # dwFlagsAndAttributes In
|
||||
HANDLE, # hTemplateFile In_opt
|
||||
wintypes.LPCWSTR, # lpFileName In
|
||||
wintypes.DWORD, # dwDesiredAccess In
|
||||
wintypes.DWORD, # dwShareMode In
|
||||
wintypes.LPVOID, # lpSecurityAttributes In_opt
|
||||
wintypes.DWORD, # dwCreationDisposition In
|
||||
wintypes.DWORD, # dwFlagsAndAttributes In
|
||||
wintypes.HANDLE, # hTemplateFile In_opt
|
||||
)
|
||||
|
||||
CloseHandle = kernel32.CloseHandle
|
||||
CloseHandle.restype = BOOL
|
||||
CloseHandle.argtypes = (HANDLE,) # hObject In
|
||||
CloseHandle.restype = wintypes.BOOL
|
||||
CloseHandle.argtypes = (wintypes.HANDLE,) # hObject In
|
||||
|
||||
INVALID_HANDLE_VALUE = HANDLE(-1).value
|
||||
INVALID_HANDLE_VALUE = wintypes.HANDLE(-1).value
|
||||
OPEN_EXISTING = 3
|
||||
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
|
||||
FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000
|
||||
|
||||
DeviceIoControl = kernel32.DeviceIoControl
|
||||
DeviceIoControl.restype = BOOL
|
||||
DeviceIoControl.restype = wintypes.BOOL
|
||||
DeviceIoControl.argtypes = (
|
||||
HANDLE, # hDevice In
|
||||
DWORD, # dwIoControlCode In
|
||||
LPVOID, # lpInBuffer In_opt
|
||||
DWORD, # nInBufferSize In
|
||||
LPVOID, # lpOutBuffer Out_opt
|
||||
DWORD, # nOutBufferSize In
|
||||
LPDWORD, # lpBytesReturned Out_opt
|
||||
LPVOID, # lpOverlapped Inout_opt
|
||||
wintypes.HANDLE, # hDevice In
|
||||
wintypes.DWORD, # dwIoControlCode In
|
||||
wintypes.LPVOID, # lpInBuffer In_opt
|
||||
wintypes.DWORD, # nInBufferSize In
|
||||
wintypes.LPVOID, # lpOutBuffer Out_opt
|
||||
wintypes.DWORD, # nOutBufferSize In
|
||||
wintypes.LPDWORD, # lpBytesReturned Out_opt
|
||||
wintypes.LPVOID, # lpOverlapped Inout_opt
|
||||
)
|
||||
|
||||
# Device I/O control flags and options
|
||||
@@ -108,45 +90,45 @@ IO_REPARSE_TAG_SYMLINK = 0xA000000C
|
||||
MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 0x4000
|
||||
|
||||
|
||||
class GENERIC_REPARSE_BUFFER(Structure):
|
||||
class GENERIC_REPARSE_BUFFER(ctypes.Structure):
|
||||
_fields_ = (("DataBuffer", UCHAR * 1),)
|
||||
|
||||
|
||||
class SYMBOLIC_LINK_REPARSE_BUFFER(Structure):
|
||||
class SYMBOLIC_LINK_REPARSE_BUFFER(ctypes.Structure):
|
||||
_fields_ = (
|
||||
("SubstituteNameOffset", USHORT),
|
||||
("SubstituteNameLength", USHORT),
|
||||
("PrintNameOffset", USHORT),
|
||||
("PrintNameLength", USHORT),
|
||||
("Flags", ULONG),
|
||||
("PathBuffer", WCHAR * 1),
|
||||
("SubstituteNameOffset", wintypes.USHORT),
|
||||
("SubstituteNameLength", wintypes.USHORT),
|
||||
("PrintNameOffset", wintypes.USHORT),
|
||||
("PrintNameLength", wintypes.USHORT),
|
||||
("Flags", wintypes.ULONG),
|
||||
("PathBuffer", wintypes.WCHAR * 1),
|
||||
)
|
||||
|
||||
@property
|
||||
def PrintName(self):
|
||||
arrayt = WCHAR * (self.PrintNameLength // 2)
|
||||
arrayt = wintypes.WCHAR * (self.PrintNameLength // 2)
|
||||
offset = type(self).PathBuffer.offset + self.PrintNameOffset
|
||||
return arrayt.from_address(addressof(self) + offset).value
|
||||
return arrayt.from_address(ctypes.addressof(self) + offset).value
|
||||
|
||||
|
||||
class MOUNT_POINT_REPARSE_BUFFER(Structure):
|
||||
class MOUNT_POINT_REPARSE_BUFFER(ctypes.Structure):
|
||||
_fields_ = (
|
||||
("SubstituteNameOffset", USHORT),
|
||||
("SubstituteNameLength", USHORT),
|
||||
("PrintNameOffset", USHORT),
|
||||
("PrintNameLength", USHORT),
|
||||
("PathBuffer", WCHAR * 1),
|
||||
("SubstituteNameOffset", wintypes.USHORT),
|
||||
("SubstituteNameLength", wintypes.USHORT),
|
||||
("PrintNameOffset", wintypes.USHORT),
|
||||
("PrintNameLength", wintypes.USHORT),
|
||||
("PathBuffer", wintypes.WCHAR * 1),
|
||||
)
|
||||
|
||||
@property
|
||||
def PrintName(self):
|
||||
arrayt = WCHAR * (self.PrintNameLength // 2)
|
||||
arrayt = wintypes.WCHAR * (self.PrintNameLength // 2)
|
||||
offset = type(self).PathBuffer.offset + self.PrintNameOffset
|
||||
return arrayt.from_address(addressof(self) + offset).value
|
||||
return arrayt.from_address(ctypes.addressof(self) + offset).value
|
||||
|
||||
|
||||
class REPARSE_DATA_BUFFER(Structure):
|
||||
class REPARSE_BUFFER(Union):
|
||||
class REPARSE_DATA_BUFFER(ctypes.Structure):
|
||||
class REPARSE_BUFFER(ctypes.Union):
|
||||
_fields_ = (
|
||||
("SymbolicLinkReparseBuffer", SYMBOLIC_LINK_REPARSE_BUFFER),
|
||||
("MountPointReparseBuffer", MOUNT_POINT_REPARSE_BUFFER),
|
||||
@@ -154,9 +136,9 @@ class REPARSE_DATA_BUFFER(Structure):
|
||||
)
|
||||
|
||||
_fields_ = (
|
||||
("ReparseTag", ULONG),
|
||||
("ReparseDataLength", USHORT),
|
||||
("Reserved", USHORT),
|
||||
("ReparseTag", wintypes.ULONG),
|
||||
("ReparseDataLength", wintypes.USHORT),
|
||||
("Reserved", wintypes.USHORT),
|
||||
("ReparseBuffer", REPARSE_BUFFER),
|
||||
)
|
||||
_anonymous_ = ("ReparseBuffer",)
|
||||
@@ -182,8 +164,8 @@ def _create_symlink(source, link_name, dwFlags):
|
||||
# "the unprivileged create flag is unsupported below Windows 10 (1703,
|
||||
# v10.0.14972). retry without it."
|
||||
if not CreateSymbolicLinkW(link_name, source, dwFlags):
|
||||
code = get_last_error()
|
||||
error_desc = FormatError(code).strip()
|
||||
code = ctypes.get_last_error()
|
||||
error_desc = ctypes.FormatError(code).strip()
|
||||
if code == ERROR_PRIVILEGE_NOT_HELD:
|
||||
raise OSError(errno.EPERM, error_desc, link_name)
|
||||
_raise_winerror(code, f'Error creating symbolic link "{link_name}"')
|
||||
@@ -208,10 +190,10 @@ def readlink(path):
|
||||
)
|
||||
if reparse_point_handle == INVALID_HANDLE_VALUE:
|
||||
_raise_winerror(
|
||||
get_last_error(), f'Error opening symbolic link "{path}"'
|
||||
ctypes.get_last_error(), f'Error opening symbolic link "{path}"'
|
||||
)
|
||||
target_buffer = c_buffer(MAXIMUM_REPARSE_DATA_BUFFER_SIZE)
|
||||
n_bytes_returned = DWORD()
|
||||
target_buffer = ctypes.c_buffer(MAXIMUM_REPARSE_DATA_BUFFER_SIZE)
|
||||
n_bytes_returned = wintypes.DWORD()
|
||||
io_result = DeviceIoControl(
|
||||
reparse_point_handle,
|
||||
FSCTL_GET_REPARSE_POINT,
|
||||
@@ -219,13 +201,13 @@ def readlink(path):
|
||||
0,
|
||||
target_buffer,
|
||||
len(target_buffer),
|
||||
byref(n_bytes_returned),
|
||||
ctypes.byref(n_bytes_returned),
|
||||
None,
|
||||
)
|
||||
CloseHandle(reparse_point_handle)
|
||||
if not io_result:
|
||||
_raise_winerror(
|
||||
get_last_error(), f'Error reading symbolic link "{path}"'
|
||||
ctypes.get_last_error(), f'Error reading symbolic link "{path}"'
|
||||
)
|
||||
rdb = REPARSE_DATA_BUFFER.from_buffer(target_buffer)
|
||||
if rdb.ReparseTag == IO_REPARSE_TAG_SYMLINK:
|
||||
@@ -239,6 +221,6 @@ def readlink(path):
|
||||
|
||||
|
||||
def _raise_winerror(code, error_desc):
|
||||
win_error_desc = FormatError(code).strip()
|
||||
win_error_desc = ctypes.FormatError(code).strip()
|
||||
error_desc = f"{error_desc}: {win_error_desc}"
|
||||
raise WinError(code, error_desc)
|
||||
raise ctypes.WinError(code, error_desc)
|
||||
+4
-4
@@ -22,7 +22,7 @@ try:
|
||||
except ImportError:
|
||||
import dummy_threading as _threading
|
||||
|
||||
from repo_trace import IsTraceToStderr
|
||||
import repo_trace
|
||||
|
||||
|
||||
# Capture the original stderr stream. We use this exclusively for progress
|
||||
@@ -166,7 +166,7 @@ class Progress:
|
||||
msg = self._last_msg
|
||||
self._last_msg = msg
|
||||
|
||||
if not _TTY or IsTraceToStderr() or self._quiet:
|
||||
if not _TTY or repo_trace.IsTraceToStderr() or self._quiet:
|
||||
return
|
||||
|
||||
elapsed_sec = time.time() - self._start
|
||||
@@ -211,7 +211,7 @@ class Progress:
|
||||
|
||||
The progress bar is then redrawn on the next line.
|
||||
"""
|
||||
if not _TTY or IsTraceToStderr() or self._quiet:
|
||||
if not _TTY or repo_trace.IsTraceToStderr() or self._quiet:
|
||||
return
|
||||
|
||||
# Erase the current line, print the message with a newline,
|
||||
@@ -227,7 +227,7 @@ class Progress:
|
||||
self._ended = True
|
||||
|
||||
self._update_event.set()
|
||||
if not _TTY or IsTraceToStderr() or self._quiet:
|
||||
if not _TTY or repo_trace.IsTraceToStderr() or self._quiet:
|
||||
return
|
||||
|
||||
duration = duration_str(time.time() - self._start)
|
||||
|
||||
+1657
-525
File diff suppressed because it is too large.
Load diff
@@ -43,4 +43,5 @@ single_line_exclusions = ['abc', 'collections.abc', 'typing']
|
||||
[tool.pytest.ini_options]
|
||||
markers = """
|
||||
skip_cq: Skip tests in the CQ. Should be rarely used!
|
||||
e2e: Hermetic end-to-end tests (tests/e2e/). Deselect with -m 'not e2e'.
|
||||
"""
|
||||
@@ -23,4 +23,5 @@ import sys
|
||||
import update_manpages
|
||||
|
||||
|
||||
sys.exit(update_manpages.main(sys.argv[1:]))
|
||||
if __name__ == "__main__":
|
||||
sys.exit(update_manpages.main(sys.argv[1:]))
|
||||
@@ -39,7 +39,7 @@ MANDIR = TOPDIR.joinpath("man")
|
||||
|
||||
# Load repo local modules.
|
||||
sys.path.insert(0, str(TOPDIR))
|
||||
from git_command import RepoSourceVersion
|
||||
import git_command
|
||||
import subcmds
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ def main(argv: List[str]) -> int:
|
||||
del subcmds.all_commands["branch"]
|
||||
(MANDIR / "repo-branch.1").write_text(".so man1/repo-branches.1")
|
||||
|
||||
version = RepoSourceVersion()
|
||||
version = git_command.RepoSourceVersion()
|
||||
cmdlist = [
|
||||
[
|
||||
*help2man,
|
||||
|
||||
@@ -26,7 +26,7 @@ import platform
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import NamedTuple
|
||||
from typing import NamedTuple, Tuple
|
||||
|
||||
|
||||
# These should never be newer than the main.py version since this needs to be a
|
||||
@@ -129,7 +129,7 @@ if not REPO_REV:
|
||||
BUG_URL = "https://issues.gerritcodereview.com/issues/new?component=1370071"
|
||||
|
||||
# increment this whenever we make important changes to this script
|
||||
VERSION = (2, 65)
|
||||
VERSION = (2, 71)
|
||||
|
||||
# increment this if the MAINTAINER_KEYS block is modified
|
||||
KEYRING_VERSION = (2, 3)
|
||||
@@ -220,6 +220,10 @@ repodir = ".repo" # name of repo's private directory
|
||||
S_repo = "repo" # special repo repository
|
||||
S_manifests = "manifests" # special manifest repository
|
||||
REPO_MAIN = S_repo + "/main.py" # main script
|
||||
# The automated release process deletes the stable branch and then recreates it
|
||||
# at the new release, so a fetch can briefly miss it. Wait this long and
|
||||
# refetch once before giving up on --repo-rev.
|
||||
REV_REFETCH_DELAY_SEC = 10
|
||||
|
||||
|
||||
import errno
|
||||
@@ -228,6 +232,7 @@ import optparse
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
@@ -438,6 +443,20 @@ def InitParser(parser):
|
||||
dest="use_superproject",
|
||||
help="disable use of manifest superprojects",
|
||||
)
|
||||
group.add_option(
|
||||
"-s",
|
||||
"--smart-sync",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="default to smart sync when syncing projects",
|
||||
)
|
||||
group.add_option(
|
||||
"--no-smart-sync",
|
||||
"--head",
|
||||
action="store_false",
|
||||
dest="smart_sync",
|
||||
help="do not default to smart sync when syncing projects",
|
||||
)
|
||||
group.add_option(
|
||||
"--clone-bundle",
|
||||
action="store_true",
|
||||
@@ -631,6 +650,7 @@ def _Init(args):
|
||||
dst = dst_final + ".tmp"
|
||||
shutil.rmtree(dst, ignore_errors=True)
|
||||
_Clone(url, dst, opt.clone_bundle, opt.quiet, opt.verbose)
|
||||
_RefetchIfRevMissing(url, dst, rev, opt.quiet, opt.verbose)
|
||||
|
||||
remote_ref, rev = check_repo_rev(
|
||||
dst, rev, opt.repo_verify, quiet=opt.quiet
|
||||
@@ -1005,7 +1025,43 @@ def _Clone(url, cwd, clone_bundle, quiet, verbose):
|
||||
_Fetch(url, cwd, "origin", quiet, verbose)
|
||||
|
||||
|
||||
def resolve_repo_rev(cwd, committish):
|
||||
def _RefetchIfRevMissing(
|
||||
url: str, cwd: str, rev: str, quiet: bool, verbose: bool
|
||||
) -> None:
|
||||
"""Refetch once if |rev| doesn't resolve.
|
||||
|
||||
This doesn't fail if |rev| still doesn't resolve; check_repo_rev reports
|
||||
that.
|
||||
|
||||
Any unresolvable |rev| is retried, not just branches. Telling a branch
|
||||
apart from a tag or commit isn't possible while it's missing, and the only
|
||||
cost for a bad --repo-rev is a short delay before the same error.
|
||||
|
||||
Args:
|
||||
url: The URL of the repo source.
|
||||
cwd: The git checkout to fetch into.
|
||||
rev: The --repo-rev to wait for.
|
||||
quiet: Whether to suppress fetch output.
|
||||
verbose: Whether to show verbose fetch output.
|
||||
"""
|
||||
try:
|
||||
resolve_repo_rev(cwd, rev, report_errors=False)
|
||||
return
|
||||
except CloneFailure:
|
||||
pass
|
||||
# Print even with --quiet since this explains the delay.
|
||||
print(
|
||||
f'repo: warning: unable to resolve "{rev}"; '
|
||||
f"refetching in {REV_REFETCH_DELAY_SEC} seconds",
|
||||
file=sys.stderr,
|
||||
)
|
||||
time.sleep(REV_REFETCH_DELAY_SEC)
|
||||
_Fetch(url, cwd, "origin", quiet, verbose)
|
||||
|
||||
|
||||
def resolve_repo_rev(
|
||||
cwd: str, committish: str, report_errors: bool = True
|
||||
) -> Tuple[str, str]:
|
||||
"""Figure out what REPO_REV represents.
|
||||
|
||||
We support:
|
||||
@@ -1016,6 +1072,7 @@ def resolve_repo_rev(cwd, committish):
|
||||
Args:
|
||||
cwd: The git checkout to run in.
|
||||
committish: The REPO_REV argument to resolve.
|
||||
report_errors: Whether to print an error if it can't be resolved.
|
||||
|
||||
Returns:
|
||||
A tuple of (remote ref, commit) as makes sense for the committish.
|
||||
@@ -1034,17 +1091,18 @@ def resolve_repo_rev(cwd, committish):
|
||||
)
|
||||
return None if ret.returncode else ret.stdout.strip()
|
||||
|
||||
def error(msg: str) -> CloneFailure:
|
||||
if report_errors:
|
||||
print(f"repo: error: {msg}", file=sys.stderr)
|
||||
return CloneFailure()
|
||||
|
||||
# An explicit branch.
|
||||
if committish.startswith("refs/heads/"):
|
||||
remote_ref = committish
|
||||
committish = committish[len("refs/heads/") :]
|
||||
rev = resolve("refs/remotes/origin/%s" % committish)
|
||||
if rev is None:
|
||||
print(
|
||||
f'repo: error: unknown branch "{committish}"',
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise CloneFailure()
|
||||
raise error(f'unknown branch "{committish}"')
|
||||
return (remote_ref, rev)
|
||||
|
||||
# An explicit tag.
|
||||
@@ -1053,11 +1111,7 @@ def resolve_repo_rev(cwd, committish):
|
||||
committish = committish[len("refs/tags/") :]
|
||||
rev = resolve(remote_ref)
|
||||
if rev is None:
|
||||
print(
|
||||
f'repo: error: unknown tag "{committish}"',
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise CloneFailure()
|
||||
raise error(f'unknown tag "{committish}"')
|
||||
return (remote_ref, rev)
|
||||
|
||||
# See if it's a short branch name.
|
||||
@@ -1076,11 +1130,7 @@ def resolve_repo_rev(cwd, committish):
|
||||
return (rev, rev)
|
||||
|
||||
# Give up!
|
||||
print(
|
||||
f'repo: error: unable to resolve "{committish}"',
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise CloneFailure()
|
||||
raise error(f'unable to resolve "{committish}"')
|
||||
|
||||
|
||||
def verify_rev(cwd, remote_ref, rev, quiet):
|
||||
|
||||
+4
-4
@@ -16,8 +16,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from color import Coloring
|
||||
from error import RepoExitError
|
||||
import color
|
||||
import error
|
||||
|
||||
|
||||
SEPARATOR = "=" * 80
|
||||
@@ -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):
|
||||
@@ -71,7 +71,7 @@ class RepoLogger(logging.Logger):
|
||||
handler.setFormatter(_LogColoringFormatter(config))
|
||||
self.addHandler(handler)
|
||||
|
||||
def log_aggregated_errors(self, err: RepoExitError):
|
||||
def log_aggregated_errors(self, err: error.RepoExitError) -> None:
|
||||
"""Print all aggregated logs."""
|
||||
self.error(SEPARATOR)
|
||||
|
||||
|
||||
+3
-3
@@ -57,10 +57,10 @@ def SetTraceToStderr():
|
||||
_TRACE_TO_STDERR = True
|
||||
|
||||
|
||||
def SetTrace():
|
||||
"""Enables tracing."""
|
||||
def SetTrace(value: bool = True) -> None:
|
||||
"""Enables by default, or disables tracing."""
|
||||
global _TRACE
|
||||
_TRACE = True
|
||||
_TRACE = value
|
||||
|
||||
|
||||
def _SetTraceFile(quiet):
|
||||
|
||||
+8
-3
@@ -19,9 +19,14 @@
|
||||
# Hard versions are when we refuse to work the tool. Users will be shown an
|
||||
# error message before we abort entirely.
|
||||
|
||||
# When deciding whether to upgrade a version requirement, check out the distro
|
||||
# lists to see who will be impacted:
|
||||
# https://gerrit.googlesource.com/git-repo/+/HEAD/docs/release-process.md#Project-References
|
||||
# When deciding whether to upgrade a version requirement, check out what
|
||||
# versions major distros (e.g. Ubuntu LTS & Debian stable) ship and when they
|
||||
# go EOL to see who will be impacted:
|
||||
# https://wiki.ubuntu.com/Releases
|
||||
# https://wiki.debian.org/DebianReleases#Versions_of_Debian_stable
|
||||
# https://repology.org/project/python/versions
|
||||
# https://repology.org/project/git/versions
|
||||
# https://repology.org/project/openssh/versions
|
||||
|
||||
{
|
||||
# The repo launcher itself. This allows us to force people to upgrade as some
|
||||
|
||||
@@ -13,6 +13,19 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# /// script
|
||||
# requires-python = '>=3.11,<3.12'
|
||||
# dependencies = [
|
||||
# 'pytest==8.3.4',
|
||||
# 'black==25.1.0',
|
||||
# 'flake8==7.4.1',
|
||||
# 'flake8-bugbear==26.9.30',
|
||||
# 'flake8-comprehensions==3.17.0',
|
||||
# 'flake8-import-restrictions==2.1',
|
||||
# 'isort==5.10.1'
|
||||
# ]
|
||||
# ///
|
||||
|
||||
"""Wrapper to run linters and pytest with the right settings."""
|
||||
|
||||
import functools
|
||||
@@ -42,10 +55,16 @@ def is_ci() -> bool:
|
||||
return os.getenv("LUCI_CQ") == "yes"
|
||||
|
||||
|
||||
def ci_pytest_args() -> list[str]:
|
||||
"""Returns extra pytest args for CI runs."""
|
||||
if not is_ci():
|
||||
return []
|
||||
return ["-m", "not skip_cq", "--fail-on-skip"]
|
||||
|
||||
|
||||
def run_pytest(argv: list[str]) -> int:
|
||||
"""Returns the exit code from pytest."""
|
||||
if is_ci():
|
||||
argv = ["-m", "not skip_cq"] + argv
|
||||
argv = ci_pytest_args() + argv
|
||||
|
||||
log_cmd("pytest", argv)
|
||||
return subprocess.run(
|
||||
@@ -57,8 +76,7 @@ def run_pytest(argv: list[str]) -> int:
|
||||
|
||||
def run_pytest_py38(argv: list[str]) -> int:
|
||||
"""Returns the exit code from pytest under Python 3.8."""
|
||||
if is_ci():
|
||||
argv = ["-m", "not skip_cq"] + argv
|
||||
argv = ci_pytest_args() + argv
|
||||
|
||||
log_cmd("[vpython 3.8] pytest", argv)
|
||||
try:
|
||||
@@ -135,7 +153,7 @@ def run_isort():
|
||||
cwd=ROOT_DIR,
|
||||
)
|
||||
|
||||
argv = ["--check", ROOT_DIR]
|
||||
argv = ["--check", "--diff", ROOT_DIR]
|
||||
log_cmd("isort", argv)
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "isort"] + argv,
|
||||
|
||||
Generated
+78
@@ -0,0 +1,78 @@
|
||||
attrs==25.4.0 ; python_full_version < '3.12' \
|
||||
--hash=sha256:3e2be3ccc931d6c0cda7d37122b992d6d4b905968a437da7843cff016e2c41e8 \
|
||||
--hash=sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373
|
||||
# via flake8-bugbear
|
||||
black==25.1.0 ; python_full_version < '3.12' \
|
||||
--hash=sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717 \
|
||||
--hash=sha256:c9ed033e41998dd45d6f19bf9923e2444cb401185ccb26d1a2101a9049637c1f
|
||||
click==8.4.1 ; python_full_version < '3.12' \
|
||||
--hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2
|
||||
# via black
|
||||
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 ; python_full_version < '3.12' \
|
||||
--hash=sha256:9b5bf0ebd06aff1601e38dfc4e91f9b99ba3cdf446e74ee25a9cbbe01aef81ee \
|
||||
--hash=sha256:f34b5cb8bf908ff49e4673d3dbc557b2381a14132b637d53e9d3a59816f4b6ba
|
||||
# via
|
||||
# flake8-bugbear
|
||||
# flake8-comprehensions
|
||||
flake8-bugbear==24.12.12 ; python_full_version < '3.12' \
|
||||
--hash=sha256:1b6967436f65ca22a42e5373aaa6f2d87966ade9aa38d4baf2a1be550767545e
|
||||
flake8-comprehensions==3.16.0 ; python_full_version < '3.12' \
|
||||
--hash=sha256:7c1eadc9d22e765f39857798febe7766b4d9c519793c6c149e3e13bf99693f70
|
||||
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 ; python_full_version < '3.12' \
|
||||
--hash=sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7 \
|
||||
--hash=sha256:ec0da0d88470e89ade75bf1e2ea7b4349ec906f95c84aff08e4d41dea6864d25
|
||||
mccabe==0.7.0 ; python_full_version < '3.12' \
|
||||
--hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e \
|
||||
--hash=sha256:b85abd0a321cdd357d0be34adbf49cf01453a857a23e94d44612ec6f159cb261
|
||||
# via flake8
|
||||
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==26.2 ; python_full_version < '3.12' \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e
|
||||
# via
|
||||
# black
|
||||
# pytest
|
||||
pathspec==1.0.4 ; python_full_version < '3.12' \
|
||||
--hash=sha256:eeaf9fc7cbc0a4492b666be276f38956baa67cd3faca745462db83d98f1cef87 \
|
||||
--hash=sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723
|
||||
# via black
|
||||
platformdirs==4.9.2 ; python_full_version < '3.12' \
|
||||
--hash=sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd \
|
||||
--hash=sha256:ef245e78eb7a2ba57599bdb39c4ff323cfae3de356a47207b58bb29e7f479f46
|
||||
# via black
|
||||
pluggy==1.6.0 ; python_full_version < '3.12' \
|
||||
--hash=sha256:88349c39e440567519b15b27a781bcd3430de6bae17c44c37e758795be96dc5a \
|
||||
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
# via pytest
|
||||
pycodestyle==2.10.0 ; python_full_version < '3.12' \
|
||||
--hash=sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610 \
|
||||
--hash=sha256:aacf8d28ecea10fad31b2d14943a42d85d007f4d2fee8f212fc05751c8460647
|
||||
# via flake8
|
||||
pyflakes==3.0.1 ; python_full_version < '3.12' \
|
||||
--hash=sha256:a968b8baa43926124b692c7e76279bc5fa8c6ef9a2281e4a2a475be69f954bdf \
|
||||
--hash=sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf
|
||||
# via flake8
|
||||
pytest==8.3.4 ; python_full_version < '3.12' \
|
||||
--hash=sha256:00cba7845d2dccfe5403025a688d60ceae228b1ddaa866d2eb86b2edb6923e73 \
|
||||
--hash=sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6
|
||||
@@ -1,135 +0,0 @@
|
||||
# This is a vpython "spec" file.
|
||||
#
|
||||
# Read more about `vpython` and how to modify this file here:
|
||||
# https://chromium.googlesource.com/infra/infra/+/main/doc/users/vpython.md
|
||||
# List of available wheels:
|
||||
# https://chromium.googlesource.com/infra/infra/+/main/infra/tools/dockerbuild/wheels.md
|
||||
|
||||
python_version: "3.11"
|
||||
|
||||
wheel: <
|
||||
name: "infra/python/wheels/pytest-py3"
|
||||
version: "version:8.3.4"
|
||||
>
|
||||
|
||||
# Required by pytest==8.3.4
|
||||
wheel: <
|
||||
name: "infra/python/wheels/py-py2_py3"
|
||||
version: "version:1.11.0"
|
||||
>
|
||||
|
||||
# Required by pytest==8.3.4
|
||||
wheel: <
|
||||
name: "infra/python/wheels/iniconfig-py3"
|
||||
version: "version:1.1.1"
|
||||
>
|
||||
|
||||
# Required by pytest==8.3.4
|
||||
wheel: <
|
||||
name: "infra/python/wheels/packaging-py3"
|
||||
version: "version:23.0"
|
||||
>
|
||||
|
||||
# Required by pytest==8.3.4
|
||||
wheel: <
|
||||
name: "infra/python/wheels/pluggy-py3"
|
||||
version: "version:1.5.0"
|
||||
>
|
||||
|
||||
# Required by pytest==8.3.4
|
||||
wheel: <
|
||||
name: "infra/python/wheels/toml-py3"
|
||||
version: "version:0.10.1"
|
||||
>
|
||||
|
||||
# Required by pytest==8.3.4
|
||||
wheel: <
|
||||
name: "infra/python/wheels/pyparsing-py3"
|
||||
version: "version:3.0.7"
|
||||
>
|
||||
|
||||
# Required by pytest==8.3.4 and flake8-bugbear==24.12.12
|
||||
wheel: <
|
||||
name: "infra/python/wheels/attrs-py3"
|
||||
version: "version:24.2.0"
|
||||
>
|
||||
|
||||
# NB: Keep in sync with constraints.txt.
|
||||
wheel: <
|
||||
name: "infra/python/wheels/black-py3"
|
||||
version: "version:25.1.0"
|
||||
>
|
||||
|
||||
# Required by black==25.1.0
|
||||
wheel: <
|
||||
name: "infra/python/wheels/mypy-extensions-py3"
|
||||
version: "version:0.4.3"
|
||||
>
|
||||
|
||||
# Required by black==25.1.0
|
||||
wheel: <
|
||||
name: "infra/python/wheels/tomli-py3"
|
||||
version: "version:2.0.1"
|
||||
>
|
||||
|
||||
# Required by black==25.1.0
|
||||
wheel: <
|
||||
name: "infra/python/wheels/platformdirs-py3"
|
||||
version: "version:2.5.2"
|
||||
>
|
||||
|
||||
# Required by black==25.1.0
|
||||
wheel: <
|
||||
name: "infra/python/wheels/pathspec-py3"
|
||||
version: "version:0.9.0"
|
||||
>
|
||||
|
||||
# Required by black==25.1.0
|
||||
wheel: <
|
||||
name: "infra/python/wheels/typing-extensions-py3"
|
||||
version: "version:4.3.0"
|
||||
>
|
||||
|
||||
# Required by black==25.1.0
|
||||
wheel: <
|
||||
name: "infra/python/wheels/click-py3"
|
||||
version: "version:8.0.3"
|
||||
>
|
||||
|
||||
wheel: <
|
||||
name: "infra/python/wheels/flake8-py2_py3"
|
||||
version: "version:6.0.0"
|
||||
>
|
||||
|
||||
# Required by flake8==6.0.0
|
||||
wheel: <
|
||||
name: "infra/python/wheels/mccabe-py2_py3"
|
||||
version: "version:0.7.0"
|
||||
>
|
||||
|
||||
# Required by flake8==6.0.0
|
||||
wheel: <
|
||||
name: "infra/python/wheels/pyflakes-py2_py3"
|
||||
version: "version:3.0.1"
|
||||
>
|
||||
|
||||
# Required by flake8==6.0.0
|
||||
wheel: <
|
||||
name: "infra/python/wheels/pycodestyle-py2_py3"
|
||||
version: "version:2.10.0"
|
||||
>
|
||||
|
||||
wheel: <
|
||||
name: "infra/python/wheels/flake8-bugbear-py3"
|
||||
version: "version:24.12.12"
|
||||
>
|
||||
|
||||
wheel: <
|
||||
name: "infra/python/wheels/flake8-comprehensions-py3"
|
||||
version: "version:3.16.0"
|
||||
>
|
||||
|
||||
wheel: <
|
||||
name: "infra/python/wheels/isort-py3"
|
||||
version: "version:5.10.1"
|
||||
>
|
||||
@@ -24,9 +24,9 @@ import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from git_command import git
|
||||
import git_command
|
||||
import platform_utils
|
||||
from repo_trace import Trace
|
||||
import repo_trace
|
||||
|
||||
|
||||
PROXY_PATH = os.path.join(os.path.dirname(__file__), "git_ssh")
|
||||
@@ -201,7 +201,9 @@ class ProxyManager:
|
||||
# is the same as 'myhost.com' where "User git" is setup in the user's
|
||||
# ~/.ssh/config file.
|
||||
check_command = command_base + ["-O", "check"]
|
||||
with Trace("Call to ssh (check call): %s", " ".join(check_command)):
|
||||
with repo_trace.Trace(
|
||||
"Call to ssh (check call): %s", " ".join(check_command)
|
||||
):
|
||||
try:
|
||||
check_process = subprocess.Popen(
|
||||
check_command,
|
||||
@@ -250,7 +252,7 @@ class ProxyManager:
|
||||
)
|
||||
p = None
|
||||
try:
|
||||
with Trace("Call to ssh: %s", " ".join(command)):
|
||||
with repo_trace.Trace("Call to ssh: %s", " ".join(command)):
|
||||
p = subprocess.Popen(command)
|
||||
except Exception as e:
|
||||
self._master_broken.value = True
|
||||
@@ -357,7 +359,7 @@ def _get_git_protocol_version() -> str:
|
||||
if e.returncode == 1:
|
||||
# Exit code 1 means that the git config key was not found.
|
||||
# Try to imitate the defaults that git would have used.
|
||||
git_version = git.version_tuple()
|
||||
git_version = git_command.git.version_tuple()
|
||||
if git_version >= (2, 26, 0):
|
||||
# Since git version 2.26, protocol v2 is the default.
|
||||
return "2"
|
||||
|
||||
+9
-10
@@ -18,17 +18,16 @@ import itertools
|
||||
|
||||
from command import Command
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from error import RepoError
|
||||
from error import RepoExitError
|
||||
from git_command import git
|
||||
from progress import Progress
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import git_command
|
||||
import progress
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class AbandonError(RepoExitError):
|
||||
class AbandonError(error.RepoExitError):
|
||||
"""Exit error when abandon command fails."""
|
||||
|
||||
|
||||
@@ -59,7 +58,7 @@ It is equivalent to "git branch -D <branchname>".
|
||||
if not opt.all:
|
||||
branches = args[0].split()
|
||||
invalid_branches = [
|
||||
x for x in branches if not git.check_ref_format(f"heads/{x}")
|
||||
x for x in branches if not git_command.IsValidBranchName(x)
|
||||
]
|
||||
|
||||
if invalid_branches:
|
||||
@@ -84,7 +83,7 @@ It is equivalent to "git branch -D <branchname>".
|
||||
status = None
|
||||
try:
|
||||
status = project.AbandonBranch(name)
|
||||
except RepoError as e:
|
||||
except error.RepoError as e:
|
||||
status = False
|
||||
errors.append(e)
|
||||
if status is not None:
|
||||
@@ -120,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
@@ -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")
|
||||
|
||||
+11
-12
@@ -17,13 +17,12 @@ from typing import NamedTuple
|
||||
|
||||
from command import Command
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from error import GitError
|
||||
from error import RepoExitError
|
||||
from progress import Progress
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import progress
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class CheckoutBranchResult(NamedTuple):
|
||||
@@ -33,11 +32,11 @@ class CheckoutBranchResult(NamedTuple):
|
||||
error: Exception
|
||||
|
||||
|
||||
class CheckoutCommandError(RepoExitError):
|
||||
class CheckoutCommandError(error.RepoExitError):
|
||||
"""Exception thrown when checkout command fails."""
|
||||
|
||||
|
||||
class MissingBranchError(RepoExitError):
|
||||
class MissingBranchError(error.RepoExitError):
|
||||
"""Exception thrown when no project has specified branch."""
|
||||
|
||||
|
||||
@@ -64,14 +63,14 @@ The command is equivalent to:
|
||||
@classmethod
|
||||
def _ExecuteOne(cls, nb, project_idx):
|
||||
"""Checkout one project."""
|
||||
error = None
|
||||
err = None
|
||||
result = None
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
try:
|
||||
result = project.CheckoutBranch(nb)
|
||||
except GitError as e:
|
||||
error = e
|
||||
return CheckoutBranchResult(result, project_idx, error)
|
||||
except error.GitError as e:
|
||||
err = e
|
||||
return CheckoutBranchResult(result, project_idx, err)
|
||||
|
||||
def Execute(self, opt, args):
|
||||
nb = args[0]
|
||||
@@ -99,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
|
||||
),
|
||||
)
|
||||
|
||||
+53
-38
@@ -14,15 +14,16 @@
|
||||
|
||||
import re
|
||||
import sys
|
||||
from typing import Tuple
|
||||
|
||||
from command import Command
|
||||
from error import GitError
|
||||
from git_command import GitCommand
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import git_command
|
||||
import repo_logging
|
||||
|
||||
|
||||
CHANGE_ID_RE = re.compile(r"^\s*Change-Id: I([0-9a-f]{40})\s*$")
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class CherryPick(Command):
|
||||
@@ -43,38 +44,10 @@ change id will be added.
|
||||
|
||||
def Execute(self, opt, args):
|
||||
reference = args[0]
|
||||
sha1, commit = self._ResolveReference(reference)
|
||||
old_msg = self._StripHeader(commit)
|
||||
|
||||
p = GitCommand(
|
||||
None,
|
||||
["rev-parse", "--verify", reference],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
verify_command=True,
|
||||
)
|
||||
try:
|
||||
p.Wait()
|
||||
except GitError:
|
||||
logger.error(p.stderr)
|
||||
raise
|
||||
|
||||
sha1 = p.stdout.strip()
|
||||
|
||||
p = GitCommand(
|
||||
None,
|
||||
["cat-file", "commit", sha1],
|
||||
capture_stdout=True,
|
||||
verify_command=True,
|
||||
)
|
||||
|
||||
try:
|
||||
p.Wait()
|
||||
except GitError:
|
||||
logger.error("error: Failed to retrieve old commit message")
|
||||
raise
|
||||
|
||||
old_msg = self._StripHeader(p.stdout)
|
||||
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["cherry-pick", sha1],
|
||||
capture_stdout=True,
|
||||
@@ -84,7 +57,7 @@ change id will be added.
|
||||
|
||||
try:
|
||||
p.Wait()
|
||||
except GitError as e:
|
||||
except error.GitError as e:
|
||||
logger.error(e)
|
||||
logger.warning(
|
||||
"NOTE: When committing (please see above) and editing the "
|
||||
@@ -103,7 +76,7 @@ change id will be added.
|
||||
# the commit message.
|
||||
new_msg = self._Reformat(old_msg, sha1)
|
||||
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["commit", "--amend", "-F", "-"],
|
||||
input=new_msg,
|
||||
@@ -113,10 +86,52 @@ change id will be added.
|
||||
)
|
||||
try:
|
||||
p.Wait()
|
||||
except GitError:
|
||||
except error.GitError:
|
||||
logger.error("error: Failed to update commit message")
|
||||
raise
|
||||
|
||||
def _ResolveReference(self, reference: str) -> Tuple[str, str]:
|
||||
"""Resolve a commit and read it through one cat-file batch request."""
|
||||
expression = f"{reference}^{{commit}}"
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["cat-file", "--batch"],
|
||||
input=expression + "\n",
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
verify_command=True,
|
||||
)
|
||||
try:
|
||||
p.Wait()
|
||||
header, separator, output = p.stdout.partition("\n")
|
||||
if not separator:
|
||||
raise ValueError("missing cat-file header")
|
||||
if header.endswith(" missing") or header.endswith(" ambiguous"):
|
||||
raise error.GitError(f"commit {reference} not found")
|
||||
|
||||
parts = header.split(" ", 2)
|
||||
if len(parts) != 3 or parts[1] != "commit":
|
||||
raise ValueError(
|
||||
f"unexpected object type {parts[1]!r}"
|
||||
if len(parts) >= 2
|
||||
else "invalid header"
|
||||
)
|
||||
sha1, _object_type, _size = parts
|
||||
|
||||
if not output.endswith("\n"):
|
||||
raise ValueError("truncated cat-file object")
|
||||
|
||||
commit = output[:-1]
|
||||
except (error.GitError, ValueError) as e:
|
||||
logger.error(
|
||||
"error: Failed to resolve or read commit %s", reference
|
||||
)
|
||||
if isinstance(e, error.GitError):
|
||||
raise
|
||||
raise error.GitError(str(e)) from e
|
||||
|
||||
return sha1, commit
|
||||
|
||||
def _IsChangeId(self, line):
|
||||
return CHANGE_ID_RE.match(line)
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import optparse
|
||||
from typing import List, Optional
|
||||
|
||||
import command
|
||||
import error
|
||||
import git_config
|
||||
|
||||
|
||||
class Error(error.RepoExitError):
|
||||
"""Exit error when the config command fails."""
|
||||
|
||||
|
||||
class NotSetError(error.SilentRepoExitError):
|
||||
"""Exit quietly when a requested setting is not set, like `git config`."""
|
||||
|
||||
|
||||
# Subcommand name -> number of positional arguments it takes.
|
||||
_ACTIONS = {
|
||||
"get": 1,
|
||||
"set": 2,
|
||||
"unset": 1,
|
||||
"list": 0,
|
||||
}
|
||||
|
||||
|
||||
def _CanonicalizeBool(name: str, value: str) -> str:
|
||||
"""Returns |value| as "true"/"false", or raises Error."""
|
||||
ret = git_config.ParseBoolean(value)
|
||||
if ret is None:
|
||||
raise Error(f"error: {name}: '{value}' is not a boolean")
|
||||
return "true" if ret else "false"
|
||||
|
||||
|
||||
def _CanonicalizeInt(name: str, value: str) -> str:
|
||||
"""Returns |value| as a plain decimal integer, or raises Error."""
|
||||
ret = git_config.ParseInt(value)
|
||||
if ret is None:
|
||||
raise Error(f"error: {name}: '{value}' is not an integer")
|
||||
return str(ret)
|
||||
|
||||
|
||||
# --type name -> function turning a value into its canonical string form.
|
||||
_TYPES = {
|
||||
"bool": _CanonicalizeBool,
|
||||
"int": _CanonicalizeInt,
|
||||
}
|
||||
|
||||
|
||||
class Config(command.Command, command.MirrorSafeCommand):
|
||||
"""Read and write repo's per-user settings."""
|
||||
|
||||
COMMON = True
|
||||
MULTI_MANIFEST_SUPPORT = True
|
||||
helpSummary = "Read and write per-user repo settings"
|
||||
helpUsage = """
|
||||
%prog [--type=<type>] [-z] get <name>
|
||||
%prog [--type=<type>] set <name> <value>
|
||||
%prog unset <name>
|
||||
%prog [-z] list
|
||||
"""
|
||||
helpDescription = """
|
||||
The '%prog' command reads and writes repo's own per-user settings. These
|
||||
are separate from git's config and from a checkout's settings; see
|
||||
docs/repoconfig.md for where they live and which settings repo understands.
|
||||
|
||||
Settings are named <section>.<name>, e.g. sync.smartsync. 'get' prints
|
||||
every value of the setting and exits 1 without output if it is not set,
|
||||
like `git config`.
|
||||
|
||||
Values are stored as strings. With --type, 'set' validates the value and
|
||||
stores it in canonical form (e.g. "on" as "true", "1k" as "1024"), and
|
||||
'get' prints the canonical form, like `git config --type`.
|
||||
"""
|
||||
|
||||
def _Options(self, p: optparse.OptionParser) -> None:
|
||||
p.add_option(
|
||||
"--type",
|
||||
choices=sorted(_TYPES),
|
||||
help="validate and canonicalize the value as this type for "
|
||||
"get and set (%s)" % ", ".join(sorted(_TYPES)),
|
||||
)
|
||||
p.add_option(
|
||||
"-z",
|
||||
"--null",
|
||||
action="store_true",
|
||||
help="terminate output of get and list with NUL instead of "
|
||||
"newline, and separate key and value with a newline",
|
||||
)
|
||||
|
||||
def ValidateOptions(self, opt: optparse.Values, args: List[str]) -> None:
|
||||
if not args:
|
||||
self.Usage()
|
||||
action, rest = args[0], args[1:]
|
||||
if action not in _ACTIONS:
|
||||
self.OptionParser.error(f"unknown action '{action}'")
|
||||
if len(rest) != _ACTIONS[action]:
|
||||
self.Usage()
|
||||
if opt.type and action not in ("get", "set"):
|
||||
self.OptionParser.error("--type only applies to get and set")
|
||||
if opt.null and action not in ("get", "list"):
|
||||
self.OptionParser.error("-z/--null only applies to get and list")
|
||||
|
||||
def Execute(self, opt: optparse.Values, args: List[str]) -> None:
|
||||
config = git_config.RepoConfig.ForUser()
|
||||
action, rest = args[0], args[1:]
|
||||
dispatch = {
|
||||
"get": self._ExecuteGet,
|
||||
"set": self._ExecuteSet,
|
||||
"unset": self._ExecuteUnset,
|
||||
"list": self._ExecuteList,
|
||||
}[action]
|
||||
dispatch(opt, config, *rest)
|
||||
|
||||
@staticmethod
|
||||
def _Print(opt: optparse.Values, text: str) -> None:
|
||||
"""Prints |text| terminated by NUL with -z, else by newline."""
|
||||
print(text, end="\0" if opt.null else "\n")
|
||||
|
||||
@staticmethod
|
||||
def _Format(opt: optparse.Values, name: str, value: Optional[str]) -> str:
|
||||
"""Formats a value for output, canonicalizing it per --type."""
|
||||
if value is None:
|
||||
# A key without `=` in the file has no value; git prints a blank.
|
||||
return ""
|
||||
if opt.type:
|
||||
return _TYPES[opt.type](name, value)
|
||||
return value
|
||||
|
||||
def _ExecuteGet(
|
||||
self, opt: optparse.Values, config: git_config.RepoConfig, name: str
|
||||
) -> None:
|
||||
values = config.GetString(name, all_keys=True)
|
||||
if not values:
|
||||
raise NotSetError(exit_code=1)
|
||||
for value in values:
|
||||
self._Print(opt, self._Format(opt, name, value))
|
||||
|
||||
def _ExecuteSet(
|
||||
self,
|
||||
opt: optparse.Values,
|
||||
config: git_config.RepoConfig,
|
||||
name: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
if opt.type:
|
||||
value = _TYPES[opt.type](name, value)
|
||||
self._Write(config, name, value)
|
||||
|
||||
def _ExecuteUnset(
|
||||
self, opt: optparse.Values, config: git_config.RepoConfig, name: str
|
||||
) -> None:
|
||||
self._Write(config, name, None)
|
||||
|
||||
def _ExecuteList(
|
||||
self, opt: optparse.Values, config: git_config.RepoConfig
|
||||
) -> None:
|
||||
sep = "\n" if opt.null else "="
|
||||
for key in sorted(config.DumpConfigDict()):
|
||||
for value in config.GetString(key, all_keys=True):
|
||||
self._Print(opt, key if value is None else f"{key}{sep}{value}")
|
||||
|
||||
@staticmethod
|
||||
def _Write(
|
||||
config: git_config.RepoConfig, name: str, value: Optional[str]
|
||||
) -> None:
|
||||
"""Writes |value| (None to delete) via `git config`, which also
|
||||
validates the name."""
|
||||
try:
|
||||
config.SetString(name, value)
|
||||
except error.GitError as e:
|
||||
raise Error(str(e))
|
||||
@@ -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)
|
||||
|
||||
+9
-11
@@ -16,17 +16,15 @@ import re
|
||||
import sys
|
||||
|
||||
from command import Command
|
||||
from error import GitError
|
||||
from error import NoSuchProjectError
|
||||
from error import RepoExitError
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import repo_logging
|
||||
|
||||
|
||||
CHANGE_RE = re.compile(r"^([1-9][0-9]*)(?:[/\.-]([1-9][0-9]*))?$")
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class DownloadCommandError(RepoExitError):
|
||||
class DownloadCommandError(error.RepoExitError):
|
||||
"""Error raised when download command fails."""
|
||||
|
||||
|
||||
@@ -107,7 +105,7 @@ If no project is specified try to use current directory as a project.
|
||||
# If the cwd is one of the projects, assume they want that.
|
||||
try:
|
||||
project = self.GetProjects(".")[0]
|
||||
except NoSuchProjectError:
|
||||
except error.NoSuchProjectError:
|
||||
project = None
|
||||
if project not in projects:
|
||||
logger.error(
|
||||
@@ -121,7 +119,7 @@ If no project is specified try to use current directory as a project.
|
||||
project.RelPath(local=opt.this_manifest_only),
|
||||
project.revisionExpr,
|
||||
)
|
||||
raise NoSuchProjectError()
|
||||
raise error.NoSuchProjectError()
|
||||
else:
|
||||
project = projects[0]
|
||||
print("Defaulting to cwd project", project.name)
|
||||
@@ -143,7 +141,7 @@ If no project is specified try to use current directory as a project.
|
||||
try:
|
||||
self._ExecuteHelper(opt, args)
|
||||
except Exception as e:
|
||||
if isinstance(e, RepoExitError):
|
||||
if isinstance(e, error.RepoExitError):
|
||||
raise e
|
||||
raise DownloadCommandError(aggregate_errors=[e])
|
||||
|
||||
@@ -195,14 +193,14 @@ If no project is specified try to use current directory as a project.
|
||||
elif opt.revert:
|
||||
project._Revert(dl.commit)
|
||||
elif opt.ffonly:
|
||||
project._FastForward(dl.commit, ffonly=True)
|
||||
project._FastForward(dl.commit)
|
||||
else:
|
||||
if opt.branch:
|
||||
project.StartBranch(opt.branch, revision=dl.commit)
|
||||
else:
|
||||
project._Checkout(dl.commit)
|
||||
|
||||
except GitError:
|
||||
except error.GitError:
|
||||
logger.error(
|
||||
"[%s] Could not complete the %s of %s",
|
||||
project.name,
|
||||
|
||||
+13
-9
@@ -21,14 +21,14 @@ 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
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
_CAN_COLOR = [
|
||||
"branch",
|
||||
"diff",
|
||||
@@ -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")
|
||||
@@ -244,10 +244,14 @@ without iterating through the remaining projects.
|
||||
mirror = self.manifest.IsMirror
|
||||
|
||||
if opt.regex:
|
||||
projects = self.FindProjects(args, all_manifests=all_trees)
|
||||
projects = self.FindProjects(
|
||||
args,
|
||||
groups=opt.groups,
|
||||
all_manifests=all_trees,
|
||||
)
|
||||
elif opt.inverse_regex:
|
||||
projects = self.FindProjects(
|
||||
args, inverse=True, all_manifests=all_trees
|
||||
args, inverse=True, groups=opt.groups, all_manifests=all_trees
|
||||
)
|
||||
else:
|
||||
projects = self.GetProjects(
|
||||
|
||||
+37
-19
@@ -16,10 +16,9 @@ import os
|
||||
from typing import List, Set
|
||||
|
||||
from command import Command
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
import git_command
|
||||
import platform_utils
|
||||
from progress import Progress
|
||||
import progress
|
||||
from project import Project
|
||||
|
||||
|
||||
@@ -28,6 +27,14 @@ class Gc(Command):
|
||||
helpSummary = "Cleaning up internal repo and Git state."
|
||||
helpUsage = """
|
||||
%prog
|
||||
"""
|
||||
helpDescription = """
|
||||
The '%prog' command removes internal Git data for projects that are no
|
||||
longer active in the current configuration.
|
||||
|
||||
Projects excluded by the configured manifest groups are inactive by
|
||||
default. Use --keep-manifest-projects to keep Git data for projects that
|
||||
are still in the manifests.
|
||||
"""
|
||||
|
||||
def _Options(self, p):
|
||||
@@ -53,6 +60,12 @@ class Gc(Command):
|
||||
help="repack all projects that use partial clone with "
|
||||
"filter=blob:none",
|
||||
)
|
||||
p.add_option(
|
||||
"--keep-manifest-projects",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="keep Git data for projects still in the manifests",
|
||||
)
|
||||
|
||||
def _find_git_to_delete(
|
||||
self, to_keep: Set[str], start_dir: str
|
||||
@@ -111,7 +124,7 @@ class Gc(Command):
|
||||
if ask.lower() != "y":
|
||||
return 1
|
||||
|
||||
pm = Progress(
|
||||
pm = progress.Progress(
|
||||
"Deleting",
|
||||
len(to_delete),
|
||||
delay=False,
|
||||
@@ -167,7 +180,7 @@ class Gc(Command):
|
||||
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,
|
||||
@@ -186,13 +199,13 @@ class Gc(Command):
|
||||
|
||||
# Prepare workspace for repacking - remove all unreachable refs and
|
||||
# their objects.
|
||||
GitCommand(
|
||||
git_command.GitCommand(
|
||||
project,
|
||||
["reflog", "expire", "--expire-unreachable=all"],
|
||||
verify_command=True,
|
||||
).Wait()
|
||||
pm.update(msg=f"{project.name} | gc", inc=0)
|
||||
GitCommand(
|
||||
git_command.GitCommand(
|
||||
project,
|
||||
["gc"],
|
||||
verify_command=True,
|
||||
@@ -200,7 +213,7 @@ class Gc(Command):
|
||||
|
||||
# Get all objects that are reachable from the remote, and pack them.
|
||||
pm.update(msg=f"{project.name} | generating list of objects", inc=0)
|
||||
remote_objects_cmd = GitCommand(
|
||||
remote_objects_cmd = git_command.GitCommand(
|
||||
project,
|
||||
[
|
||||
"rev-list",
|
||||
@@ -215,7 +228,7 @@ class Gc(Command):
|
||||
)
|
||||
|
||||
# Get all local objects and pack them.
|
||||
local_head_objects_cmd = GitCommand(
|
||||
local_head_objects_cmd = git_command.GitCommand(
|
||||
project,
|
||||
[
|
||||
"rev-list",
|
||||
@@ -226,7 +239,7 @@ class Gc(Command):
|
||||
capture_stdout=True,
|
||||
verify_command=True,
|
||||
)
|
||||
local_objects_cmd = GitCommand(
|
||||
local_objects_cmd = git_command.GitCommand(
|
||||
project,
|
||||
[
|
||||
"rev-list",
|
||||
@@ -246,7 +259,7 @@ class Gc(Command):
|
||||
remote_objects_cmd.Wait()
|
||||
|
||||
pm.update(msg=f"{project.name} | remote repack", inc=0)
|
||||
GitCommand(
|
||||
git_command.GitCommand(
|
||||
project,
|
||||
["pack-objects", os.path.join(pack_dir, "pack")],
|
||||
input=remote_objects_cmd.stdout,
|
||||
@@ -262,7 +275,7 @@ class Gc(Command):
|
||||
local_objects_cmd.Wait()
|
||||
|
||||
pm.update(msg=f"{project.name} | local repack", inc=0)
|
||||
GitCommand(
|
||||
git_command.GitCommand(
|
||||
project,
|
||||
["pack-objects", os.path.join(pack_dir, "pack")],
|
||||
input=local_head_objects_cmd.stdout + local_objects_cmd.stdout,
|
||||
@@ -292,21 +305,26 @@ class Gc(Command):
|
||||
args, all_manifests=not opt.this_manifest_only
|
||||
)
|
||||
|
||||
# If the user specified projects, fetch the global list separately
|
||||
# to avoid deleting untargeted projects.
|
||||
if args:
|
||||
all_projects = self.GetProjects(
|
||||
if opt.keep_manifest_projects:
|
||||
projects_to_keep = self.GetProjects(
|
||||
[],
|
||||
groups="all",
|
||||
missing_ok=True,
|
||||
all_manifests=not opt.this_manifest_only,
|
||||
)
|
||||
elif args:
|
||||
projects_to_keep = self.GetProjects(
|
||||
[], all_manifests=not opt.this_manifest_only
|
||||
)
|
||||
else:
|
||||
all_projects = projects
|
||||
projects_to_keep = projects
|
||||
|
||||
ret = self.delete_unused_projects(all_projects, opt)
|
||||
ret = self.delete_unused_projects(projects_to_keep, opt)
|
||||
if ret != 0:
|
||||
return ret
|
||||
|
||||
if opt.repack:
|
||||
git_require((2, 17, 0), fail=True, msg="--repack")
|
||||
git_command.git_require((2, 17, 0), fail=True, msg="--repack")
|
||||
ret = self.repack_projects(projects, opt)
|
||||
|
||||
return ret
|
||||
+16
-18
@@ -16,22 +16,20 @@ import functools
|
||||
import sys
|
||||
from typing import NamedTuple
|
||||
|
||||
from color import Coloring
|
||||
import color
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from command import PagedCommand
|
||||
from error import GitError
|
||||
from error import InvalidArgumentsError
|
||||
from error import SilentRepoExitError
|
||||
from git_command import GitCommand
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import git_command
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
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")
|
||||
|
||||
@@ -43,10 +41,10 @@ class ExecuteOneResult(NamedTuple):
|
||||
rc: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
error: GitError
|
||||
error: error.GitError
|
||||
|
||||
|
||||
class GrepCommandError(SilentRepoExitError):
|
||||
class GrepCommandError(error.SilentRepoExitError):
|
||||
"""Grep command failure. Since Grep command
|
||||
output already outputs errors ensure that
|
||||
aggregate errors exit silently."""
|
||||
@@ -265,7 +263,7 @@ contain a line that matches both expressions:
|
||||
"""Process one project."""
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
try:
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
project,
|
||||
cmd_argv,
|
||||
bare=False,
|
||||
@@ -273,16 +271,16 @@ contain a line that matches both expressions:
|
||||
capture_stderr=True,
|
||||
verify_command=True,
|
||||
)
|
||||
except GitError as e:
|
||||
except error.GitError as e:
|
||||
return ExecuteOneResult(project_idx, -1, None, str(e), e)
|
||||
|
||||
try:
|
||||
error = None
|
||||
err = None
|
||||
rc = p.Wait()
|
||||
except GitError as e:
|
||||
except error.GitError as e:
|
||||
rc = 1
|
||||
error = e
|
||||
return ExecuteOneResult(project_idx, rc, p.stdout, p.stderr, error)
|
||||
err = e
|
||||
return ExecuteOneResult(project_idx, rc, p.stdout, p.stderr, err)
|
||||
|
||||
@staticmethod
|
||||
def _ProcessResults(
|
||||
@@ -377,7 +375,7 @@ contain a line that matches both expressions:
|
||||
if "--cached" in cmd_argv:
|
||||
msg = "fatal: cannot combine --cached and --revision"
|
||||
logger.error(msg)
|
||||
raise InvalidArgumentsError(msg)
|
||||
raise error.InvalidArgumentsError(msg)
|
||||
have_rev = True
|
||||
cmd_argv.extend(opt.revision)
|
||||
cmd_argv.append("--")
|
||||
|
||||
+8
-8
@@ -16,15 +16,15 @@ import re
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
from color import Coloring
|
||||
import color
|
||||
from command import MirrorSafeCommand
|
||||
from command import PagedCommand
|
||||
from error import RepoExitError
|
||||
import error
|
||||
from subcmds import all_commands
|
||||
from wrapper import Wrapper
|
||||
import wrapper
|
||||
|
||||
|
||||
class InvalidHelpCommand(RepoExitError):
|
||||
class InvalidHelpCommand(error.RepoExitError):
|
||||
"""Invalid command passed into help."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+65
-34
@@ -18,13 +18,12 @@ import io
|
||||
import json
|
||||
import optparse
|
||||
import sys
|
||||
from typing import Any, Dict, List, NamedTuple
|
||||
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
|
||||
|
||||
from color import Coloring
|
||||
import color
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from command import PagedCommand
|
||||
from git_refs import R_HEADS
|
||||
from git_refs import R_M
|
||||
import git_refs
|
||||
|
||||
|
||||
class BranchInfo(NamedTuple):
|
||||
@@ -47,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):
|
||||
@@ -189,18 +188,26 @@ class Info(PagedCommand):
|
||||
"superproject_revision": srev,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _GetCurrentBranch(branches: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return the name of the current branch from a GetBranches mapping."""
|
||||
return next(
|
||||
(name for name, branch in branches.items() if branch.current), None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _getProjectData(cls, project) -> Dict[str, Any]:
|
||||
"""Gather project data as a dict."""
|
||||
branches = project.GetBranches()
|
||||
currentBranch = cls._GetCurrentBranch(branches)
|
||||
data = {
|
||||
"name": project.name,
|
||||
"mount_path": project.worktree,
|
||||
"current_revision": project.GetHeadRevisionId()
|
||||
or project.GetRevisionId(),
|
||||
"manifest_revision": project.revisionExpr,
|
||||
"local_branches": list(project.GetBranches()),
|
||||
"local_branches": list(branches),
|
||||
}
|
||||
currentBranch = project.CurrentBranch
|
||||
if currentBranch:
|
||||
data["current_branch"] = currentBranch
|
||||
return data
|
||||
@@ -285,6 +292,9 @@ class Info(PagedCommand):
|
||||
text = out.nofmt_printer("text")
|
||||
dimtext = out.printer("dimtext", attr="dim")
|
||||
|
||||
branches = project.GetBranches()
|
||||
currentBranch = cls._GetCurrentBranch(branches)
|
||||
|
||||
heading("Project: ")
|
||||
headtext(project.name)
|
||||
out.nl()
|
||||
@@ -297,7 +307,6 @@ class Info(PagedCommand):
|
||||
headtext(project.GetHeadRevisionId() or project.GetRevisionId())
|
||||
out.nl()
|
||||
|
||||
currentBranch = project.CurrentBranch
|
||||
if currentBranch:
|
||||
heading("Current branch: ")
|
||||
headtext(currentBranch)
|
||||
@@ -307,7 +316,7 @@ class Info(PagedCommand):
|
||||
headtext(project.revisionExpr)
|
||||
out.nl()
|
||||
|
||||
localBranches = list(project.GetBranches().keys())
|
||||
localBranches = list(branches)
|
||||
heading("Local Branches: ")
|
||||
redtext(str(len(localBranches)))
|
||||
if localBranches:
|
||||
@@ -323,29 +332,14 @@ class Info(PagedCommand):
|
||||
branch = project.manifest.manifestProject.config.GetBranch(
|
||||
"default"
|
||||
).merge
|
||||
if branch.startswith(R_HEADS):
|
||||
branch = branch[len(R_HEADS) :]
|
||||
logTarget = R_M + branch
|
||||
if branch.startswith(git_refs.R_HEADS):
|
||||
branch = branch[len(git_refs.R_HEADS) :]
|
||||
logTarget = git_refs.R_M + branch
|
||||
|
||||
bareTmp = project.bare_git._bare
|
||||
project.bare_git._bare = False
|
||||
localCommits = project.bare_git.rev_list(
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
logTarget + "..",
|
||||
"--",
|
||||
localCommits, originCommits = cls._GetDiffCommits(
|
||||
project, logTarget
|
||||
)
|
||||
|
||||
originCommits = project.bare_git.rev_list(
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
".." + logTarget,
|
||||
"--",
|
||||
)
|
||||
project.bare_git._bare = bareTmp
|
||||
|
||||
heading("Local Commits: ")
|
||||
redtext(str(len(localCommits)))
|
||||
dimtext(" (on current branch)")
|
||||
@@ -375,6 +369,35 @@ class Info(PagedCommand):
|
||||
|
||||
return buf.getvalue()
|
||||
|
||||
@classmethod
|
||||
def _GetDiffCommits(
|
||||
cls, project: Any, log_target: str
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Return local-only and remote-only commits from one history walk."""
|
||||
git = getattr(project, "work_git", None) or getattr(
|
||||
project, "bare_git", None
|
||||
)
|
||||
if git is None:
|
||||
return [], []
|
||||
commits = git.rev_list(
|
||||
"--left-right",
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
f"HEAD...{log_target}",
|
||||
"--",
|
||||
)
|
||||
if isinstance(commits, str):
|
||||
commits = commits.splitlines()
|
||||
local = []
|
||||
remote = []
|
||||
for commit in commits:
|
||||
if commit.startswith("<"):
|
||||
local.append(commit[1:])
|
||||
elif commit.startswith(">"):
|
||||
remote.append(commit[1:])
|
||||
return local, remote
|
||||
|
||||
def _printDiffInfo(self, opt, args):
|
||||
projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
|
||||
|
||||
@@ -404,10 +427,18 @@ class Info(PagedCommand):
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
|
||||
branches = []
|
||||
br = [project.GetUploadableBranch(x) for x in project.GetBranches()]
|
||||
br = [x for x in br if x]
|
||||
local_branches = project.GetBranches()
|
||||
current_branch = cls._GetCurrentBranch(local_branches)
|
||||
if opt.current_branch:
|
||||
br = [x for x in br if x.name == project.CurrentBranch]
|
||||
candidate_branches = (
|
||||
[current_branch]
|
||||
if current_branch and current_branch in local_branches
|
||||
else []
|
||||
)
|
||||
else:
|
||||
candidate_branches = local_branches
|
||||
br = [project.GetUploadableBranch(x) for x in candidate_branches]
|
||||
br = [x for x in br if x]
|
||||
|
||||
for b in br:
|
||||
branches.append(
|
||||
@@ -416,7 +447,7 @@ class Info(PagedCommand):
|
||||
name=b.name,
|
||||
commits=b.commits,
|
||||
date=b.date,
|
||||
is_current=b.name == project.CurrentBranch,
|
||||
is_current=b.name == current_branch,
|
||||
)
|
||||
)
|
||||
return branches
|
||||
|
||||
+20
-21
@@ -15,18 +15,16 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from color import Coloring
|
||||
import color
|
||||
from command import InteractiveCommand
|
||||
from command import MirrorSafeCommand
|
||||
from error import RepoUnhandledExceptionError
|
||||
from error import UpdateManifestError
|
||||
from git_command import git_require
|
||||
from repo_logging import RepoLogger
|
||||
from wrapper import Wrapper
|
||||
from wrapper import WrapperDir
|
||||
import error
|
||||
import git_command
|
||||
import repo_logging
|
||||
import wrapper
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
_REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW")
|
||||
|
||||
@@ -96,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",
|
||||
@@ -164,6 +162,7 @@ to update the working directory files.
|
||||
clone_bundle=opt.clone_bundle,
|
||||
git_lfs=opt.git_lfs,
|
||||
use_superproject=opt.use_superproject,
|
||||
smart_sync=opt.smart_sync,
|
||||
verbose=opt.verbose,
|
||||
current_branch_only=opt.current_branch_only,
|
||||
tags=opt.tags,
|
||||
@@ -173,7 +172,7 @@ to update the working directory files.
|
||||
use_local_gitdirs=opt.use_local_gitdirs,
|
||||
):
|
||||
manifest_name = opt.manifest_name
|
||||
raise UpdateManifestError(
|
||||
raise error.UpdateManifestError(
|
||||
f"Unable to sync manifest {manifest_name}"
|
||||
)
|
||||
|
||||
@@ -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,12 +343,10 @@ 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())
|
||||
git_require(reqs.get_hard_ver("git"), fail=True)
|
||||
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_require(min_git_version_soft):
|
||||
if not git_command.git_require(min_git_version_soft):
|
||||
logger.warning(
|
||||
"repo: warning: git-%s+ will soon be required; "
|
||||
"please upgrade your version of git to maintain "
|
||||
@@ -368,17 +365,17 @@ 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)
|
||||
raise RepoUnhandledExceptionError(e)
|
||||
raise error.RepoUnhandledExceptionError(e)
|
||||
|
||||
branch = rp.GetBranch("default")
|
||||
branch.merge = remote_ref
|
||||
@@ -388,7 +385,9 @@ to update the working directory files.
|
||||
if opt.worktree:
|
||||
# Older versions of git supported worktree, but had dangerous gc
|
||||
# bugs.
|
||||
git_require((2, 15, 0), fail=True, msg="git gc worktree corruption")
|
||||
git_command.git_require(
|
||||
(2, 15, 0), fail=True, msg="git gc worktree corruption"
|
||||
)
|
||||
|
||||
# Provide a short notice that we're reinitializing an existing checkout.
|
||||
# Sometimes developers might not realize that they're in one, or that
|
||||
|
||||
+4
-1
@@ -109,7 +109,10 @@ This is similar to running: repo forall -c 'echo "$REPO_PATH : $REPO_PROJECT"'.
|
||||
)
|
||||
else:
|
||||
projects = self.FindProjects(
|
||||
args, all_manifests=not opt.this_manifest_only
|
||||
args,
|
||||
groups=opt.groups,
|
||||
missing_ok=opt.all,
|
||||
all_manifests=not opt.this_manifest_only,
|
||||
)
|
||||
|
||||
def _getpath(x):
|
||||
|
||||
+2
-2
@@ -19,10 +19,10 @@ import os
|
||||
import sys
|
||||
|
||||
from command import PagedCommand
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class OutputFormat(enum.Enum):
|
||||
|
||||
+12
-7
@@ -14,7 +14,7 @@
|
||||
|
||||
import optparse
|
||||
|
||||
from color import Coloring
|
||||
import color
|
||||
from command import PagedCommand
|
||||
|
||||
|
||||
@@ -59,18 +59,23 @@ are displayed.
|
||||
for project in self.GetProjects(
|
||||
args, all_manifests=not opt.this_manifest_only
|
||||
):
|
||||
br = [project.GetUploadableBranch(x) for x in project.GetBranches()]
|
||||
br = [x for x in br if x]
|
||||
local_branches = project.GetBranches()
|
||||
br = []
|
||||
for name, branch in local_branches.items():
|
||||
uploadable = project.GetUploadableBranch(name)
|
||||
if uploadable:
|
||||
uploadable.branch.current = branch.current
|
||||
br.append(uploadable)
|
||||
if opt.current_branch:
|
||||
br = [x for x in br if x.name == project.CurrentBranch]
|
||||
br = [x for x in br if x.current]
|
||||
all_branches.extend(br)
|
||||
|
||||
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")
|
||||
|
||||
@@ -97,7 +102,7 @@ are displayed.
|
||||
print(
|
||||
"%s %-33s (%2d commit%s, %s)"
|
||||
% (
|
||||
branch.name == project.CurrentBranch and "*" or " ",
|
||||
branch.current and "*" or " ",
|
||||
branch.name,
|
||||
len(commits),
|
||||
len(commits) != 1 and "s" or " ",
|
||||
|
||||
+4
-4
@@ -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)
|
||||
@@ -80,7 +80,7 @@ class Prune(PagedCommand):
|
||||
print(
|
||||
"%s %-33s "
|
||||
% (
|
||||
branch.name == project.CurrentBranch and "*" or " ",
|
||||
branch.current and "*" or " ",
|
||||
branch.name,
|
||||
),
|
||||
end="",
|
||||
|
||||
+11
-28
@@ -14,20 +14,20 @@
|
||||
|
||||
import sys
|
||||
|
||||
from color import Coloring
|
||||
import color
|
||||
from command import Command
|
||||
from error import GitError
|
||||
from git_command import GitCommand
|
||||
import error
|
||||
import git_command
|
||||
from project import Project
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
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")
|
||||
|
||||
@@ -39,7 +39,7 @@ def _ResolveOntoManifest(project: Project) -> str:
|
||||
"""
|
||||
try:
|
||||
return project.GetRemote().ToLocal(project.revisionExpr)
|
||||
except GitError:
|
||||
except error.GitError:
|
||||
return project.revisionExpr
|
||||
|
||||
|
||||
@@ -139,6 +139,8 @@ branch but need to incorporate new upstream changes "underneath" them.
|
||||
common_args.append("--autosquash")
|
||||
if opt.interactive:
|
||||
common_args.append("-i")
|
||||
if opt.auto_stash:
|
||||
common_args.append("--autostash")
|
||||
|
||||
config = self.manifest.manifestProject.config
|
||||
out = RebaseColoring(config)
|
||||
@@ -188,29 +190,10 @@ branch but need to incorporate new upstream changes "underneath" them.
|
||||
out.nl()
|
||||
out.flush()
|
||||
|
||||
needs_stash = False
|
||||
if opt.auto_stash:
|
||||
stash_args = ["update-index", "--refresh", "-q"]
|
||||
|
||||
if GitCommand(project, stash_args).Wait() != 0:
|
||||
needs_stash = True
|
||||
# Dirty index, requires stash...
|
||||
stash_args = ["stash"]
|
||||
|
||||
if GitCommand(project, stash_args).Wait() != 0:
|
||||
ret += 1
|
||||
continue
|
||||
|
||||
if GitCommand(project, args).Wait() != 0:
|
||||
if git_command.GitCommand(project, args).Wait() != 0:
|
||||
ret += 1
|
||||
continue
|
||||
|
||||
if needs_stash:
|
||||
stash_args.append("pop")
|
||||
stash_args.append("--quiet")
|
||||
if GitCommand(project, stash_args).Wait() != 0:
|
||||
ret += 1
|
||||
|
||||
if ret:
|
||||
msg_fmt = "%d projects had errors"
|
||||
self.git_event_log.ErrorEvent(msg_fmt % (ret), msg_fmt)
|
||||
|
||||
@@ -16,16 +16,16 @@ import optparse
|
||||
|
||||
from command import Command
|
||||
from command import MirrorSafeCommand
|
||||
from error import RepoExitError
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import repo_logging
|
||||
from subcmds.sync import _PostRepoFetch
|
||||
from subcmds.sync import _PostRepoUpgrade
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class SelfupdateError(RepoExitError):
|
||||
class SelfupdateError(error.RepoExitError):
|
||||
"""Exit error for failed selfupdate command."""
|
||||
|
||||
|
||||
|
||||
+7
-7
@@ -14,18 +14,18 @@
|
||||
|
||||
import sys
|
||||
|
||||
from color import Coloring
|
||||
import color
|
||||
from command import InteractiveCommand
|
||||
from git_command import GitCommand
|
||||
from repo_logging import RepoLogger
|
||||
import git_command
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
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")
|
||||
@@ -129,5 +129,5 @@ The '%prog' command stages files to prepare the next commit.
|
||||
|
||||
|
||||
def _AddI(project):
|
||||
p = GitCommand(project, ["add", "--interactive"], bare=False)
|
||||
p = git_command.GitCommand(project, ["add", "--interactive"], bare=False)
|
||||
p.Wait()
|
||||
+13
-13
@@ -17,14 +17,14 @@ from typing import NamedTuple
|
||||
|
||||
from command import Command
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from error import RepoExitError
|
||||
from git_command import git
|
||||
from git_config import IsImmutable
|
||||
from progress import Progress
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import git_command
|
||||
import git_config
|
||||
import progress
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class ExecuteOneResult(NamedTuple):
|
||||
@@ -32,7 +32,7 @@ class ExecuteOneResult(NamedTuple):
|
||||
error: Exception
|
||||
|
||||
|
||||
class StartError(RepoExitError):
|
||||
class StartError(error.RepoExitError):
|
||||
"""Exit error for failed start command."""
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ revision specified in the manifest.
|
||||
self.Usage()
|
||||
|
||||
nb = args[0]
|
||||
if not git.check_ref_format("heads/%s" % nb):
|
||||
if not git_command.IsValidBranchName(nb):
|
||||
self.OptionParser.error("'%s' is not a valid name" % nb)
|
||||
|
||||
@classmethod
|
||||
@@ -85,9 +85,9 @@ revision specified in the manifest.
|
||||
# a change, then we can't push back to it. Substitute with
|
||||
# dest_branch, if defined; or with manifest default revision instead.
|
||||
branch_merge = ""
|
||||
error = None
|
||||
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:
|
||||
@@ -99,8 +99,8 @@ revision specified in the manifest.
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("error: unable to checkout %s: %s", project.name, e)
|
||||
error = e
|
||||
return ExecuteOneResult(project_idx, error)
|
||||
err = e
|
||||
return ExecuteOneResult(project_idx, err)
|
||||
|
||||
def Execute(self, opt, args):
|
||||
nb = args[0]
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
+516
-122
File diff suppressed because it is too large.
Load diff
+86
-51
@@ -21,24 +21,21 @@ from typing import List
|
||||
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from command import InteractiveCommand
|
||||
from editor import Editor
|
||||
from error import GitError
|
||||
from error import SilentRepoExitError
|
||||
from error import UploadError
|
||||
from git_command import GitCommand
|
||||
from git_refs import R_HEADS
|
||||
import editor
|
||||
import error
|
||||
import git_refs
|
||||
import git_superproject
|
||||
from hooks import RepoHook
|
||||
from project import ReviewableBranch
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
from subcmds.sync import LocalSyncState
|
||||
|
||||
|
||||
_DEFAULT_UNUSUAL_COMMIT_THRESHOLD = 5
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class UploadExitError(SilentRepoExitError):
|
||||
class UploadExitError(error.SilentRepoExitError):
|
||||
"""Indicates that there is an upload command error requiring a sys exit."""
|
||||
|
||||
|
||||
@@ -372,6 +369,12 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
action="store_false",
|
||||
help="always ask about untracked files in the working copy",
|
||||
)
|
||||
p.add_option(
|
||||
"--ignore-remote-commits",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="ignore commits already in remote branches",
|
||||
)
|
||||
p.add_option(
|
||||
"--no-cert-checks",
|
||||
dest="validate_certs",
|
||||
@@ -379,7 +382,7 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
default=True,
|
||||
help="disable verifying ssl certs (unsafe)",
|
||||
)
|
||||
RepoHook.AddOptionGroup(p, "pre-upload")
|
||||
RepoHook.AddOptionGroup(p, "pre-upload", allow_fix=True)
|
||||
|
||||
def _SingleBranch(self, opt, branch, people):
|
||||
project = branch.project
|
||||
@@ -436,6 +439,18 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
self._UploadAndReport(opt, [branch], people)
|
||||
|
||||
def _MultipleBranches(self, opt, pending, people):
|
||||
if opt.yes and (opt.current_branch or opt.branch):
|
||||
todo = [
|
||||
branch
|
||||
for _, avail in pending
|
||||
for branch in avail
|
||||
if branch is not None
|
||||
]
|
||||
if not todo:
|
||||
_die("nothing ready for upload")
|
||||
self._UploadAndReport(opt, todo, people)
|
||||
return
|
||||
|
||||
projects = {}
|
||||
branches = {}
|
||||
|
||||
@@ -479,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*\(.*")
|
||||
@@ -598,11 +613,11 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
)
|
||||
|
||||
full_dest = destination
|
||||
if not full_dest.startswith(R_HEADS):
|
||||
full_dest = R_HEADS + full_dest
|
||||
if not full_dest.startswith(git_refs.R_HEADS):
|
||||
full_dest = git_refs.R_HEADS + full_dest
|
||||
full_revision = branch.project.revisionExpr
|
||||
if not full_revision.startswith(R_HEADS):
|
||||
full_revision = R_HEADS + full_revision
|
||||
if not full_revision.startswith(git_refs.R_HEADS):
|
||||
full_revision = git_refs.R_HEADS + full_revision
|
||||
|
||||
# If the merge branch of the local branch is different from
|
||||
# the project's revision AND destination, this might not be
|
||||
@@ -649,6 +664,8 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
validate_certs=opt.validate_certs,
|
||||
push_options=push_options,
|
||||
patchset_description=opt.patchset_description,
|
||||
git_event_log=self.git_event_log,
|
||||
quiet=opt.quiet,
|
||||
)
|
||||
|
||||
branch.uploaded = True
|
||||
@@ -659,15 +676,17 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
for branch in todo:
|
||||
try:
|
||||
self._UploadBranch(opt, branch, people)
|
||||
except (UploadError, GitError) as e:
|
||||
except (error.UploadError, error.GitError) as e:
|
||||
self.git_event_log.ErrorEvent(f"upload error: {e}")
|
||||
branch.error = e
|
||||
aggregate_errors.append(e)
|
||||
branch.uploaded = False
|
||||
have_errors = True
|
||||
|
||||
print(file=sys.stderr)
|
||||
print("-" * 70, file=sys.stderr)
|
||||
quiet = getattr(opt, "quiet", False)
|
||||
if not quiet or have_errors:
|
||||
print(file=sys.stderr)
|
||||
print("-" * 70, file=sys.stderr)
|
||||
|
||||
if have_errors:
|
||||
for branch in todo:
|
||||
@@ -688,52 +707,68 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
)
|
||||
print()
|
||||
|
||||
for branch in todo:
|
||||
if branch.uploaded:
|
||||
print(
|
||||
"[OK ] %-15s %s"
|
||||
% (
|
||||
branch.project.RelPath(local=opt.this_manifest_only)
|
||||
+ "/",
|
||||
branch.name,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not quiet:
|
||||
for branch in todo:
|
||||
if branch.uploaded:
|
||||
print(
|
||||
"[OK ] %-15s %s"
|
||||
% (
|
||||
branch.project.RelPath(local=opt.this_manifest_only)
|
||||
+ "/",
|
||||
branch.name,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if have_errors:
|
||||
raise UploadExitError(aggregate_errors=aggregate_errors)
|
||||
|
||||
def _GetMergeBranch(self, project, local_branch=None):
|
||||
"""Get the merge branch name for a local branch.
|
||||
|
||||
Resolves the merge branch in-memory via project configuration to
|
||||
avoid git subprocess overhead during upload.
|
||||
"""
|
||||
if local_branch is None:
|
||||
p = GitCommand(
|
||||
project,
|
||||
["rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
p.Wait()
|
||||
local_branch = p.stdout.strip()
|
||||
p = GitCommand(
|
||||
project,
|
||||
["config", "--get", "branch.%s.merge" % local_branch],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
p.Wait()
|
||||
merge_branch = p.stdout.strip()
|
||||
return merge_branch
|
||||
local_branch = project.CurrentBranch
|
||||
if local_branch:
|
||||
branch = project.GetBranch(local_branch)
|
||||
if branch.merge:
|
||||
return branch.merge
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _GatherOne(cls, opt, project_idx):
|
||||
"""Figure out the upload status for |project|."""
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
cbr = None
|
||||
if opt.current_branch:
|
||||
cbr = project.CurrentBranch
|
||||
up_branch = project.GetUploadableBranch(cbr)
|
||||
up_branch = project.GetUploadableBranch(
|
||||
cbr, ignore_remote_commits=opt.ignore_remote_commits
|
||||
)
|
||||
avail = [up_branch] if up_branch else None
|
||||
else:
|
||||
avail = project.GetUploadableBranches(opt.branch)
|
||||
return (project_idx, avail)
|
||||
avail = project.GetUploadableBranches(
|
||||
opt.branch, ignore_remote_commits=opt.ignore_remote_commits
|
||||
)
|
||||
return (project_idx, avail, cbr)
|
||||
|
||||
def ValidateOptions(self, opt: optparse.Values, args: List[str]) -> None:
|
||||
if opt.fix:
|
||||
if opt.ignore_hooks:
|
||||
self.OptionParser.error(
|
||||
"--fix and --ignore-hooks are mutually exclusive options: "
|
||||
"with --ignore-hooks, the original commits are uploaded, "
|
||||
"so fixups would only modify the working tree and would "
|
||||
"not be part of the upload"
|
||||
)
|
||||
if opt.bypass_hooks:
|
||||
self.OptionParser.error(
|
||||
"--fix and --no-verify are mutually exclusive options: "
|
||||
"--no-verify skips the pre-upload hook, so --fix would "
|
||||
"have no effect"
|
||||
)
|
||||
|
||||
def Execute(self, opt, args):
|
||||
projects = self.GetProjects(
|
||||
@@ -743,7 +778,7 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
def _ProcessResults(_pool, _out, results):
|
||||
pending = []
|
||||
for result in results:
|
||||
project_idx, avail = result
|
||||
project_idx, avail, current_branch = result
|
||||
project = projects[project_idx]
|
||||
if avail is None:
|
||||
logger.error(
|
||||
@@ -751,7 +786,7 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
"You might be able to fix the branch by running:\n"
|
||||
" git branch --set-upstream-to m/%s",
|
||||
project.RelPath(local=opt.this_manifest_only),
|
||||
project.CurrentBranch,
|
||||
current_branch,
|
||||
project.manifest.branch,
|
||||
)
|
||||
elif avail:
|
||||
|
||||
+27
-12
@@ -14,14 +14,13 @@
|
||||
|
||||
import platform
|
||||
import sys
|
||||
from typing import Any, Tuple
|
||||
|
||||
from command import Command
|
||||
from command import MirrorSafeCommand
|
||||
from git_command import git
|
||||
from git_command import RepoSourceVersion
|
||||
from git_command import user_agent
|
||||
from git_refs import HEAD
|
||||
from wrapper import Wrapper
|
||||
import git_command
|
||||
import git_refs
|
||||
import wrapper
|
||||
|
||||
|
||||
class Version(Command, MirrorSafeCommand):
|
||||
@@ -34,18 +33,34 @@ class Version(Command, MirrorSafeCommand):
|
||||
%prog
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _RepoVersion(project: Any) -> Tuple[str, str]:
|
||||
"""Return repo's describe string and commit date."""
|
||||
if git_command.git_require((2, 32, 0)):
|
||||
output = project.bare_git.log(
|
||||
"-1", "--format=%(describe)%n%cD", git_refs.HEAD
|
||||
)
|
||||
description, commit_date = output.rstrip("\n").split("\n", 1)
|
||||
if description:
|
||||
return description, commit_date
|
||||
|
||||
return (
|
||||
project.bare_git.describe(git_refs.HEAD),
|
||||
project.bare_git.log("-1", "--format=%cD", git_refs.HEAD),
|
||||
)
|
||||
|
||||
def Execute(self, opt, args):
|
||||
rp = self.manifest.repoProject
|
||||
rem = rp.GetRemote()
|
||||
branch = rp.GetBranch("default")
|
||||
|
||||
# These might not be the same. Report them both.
|
||||
src_ver = RepoSourceVersion()
|
||||
rp_ver = rp.bare_git.describe(HEAD)
|
||||
src_ver = git_command.RepoSourceVersion()
|
||||
rp_ver, commit_date = self._RepoVersion(rp)
|
||||
print(f"repo version {rp_ver}")
|
||||
print(f" (from {rem.url})")
|
||||
print(f" (tracking {branch.merge})")
|
||||
print(f" ({rp.bare_git.log('-1', '--format=%cD', HEAD)})")
|
||||
print(f" ({commit_date})")
|
||||
|
||||
if self.wrapper_path is not None:
|
||||
print(f"repo launcher version {self.wrapper_version}")
|
||||
@@ -54,9 +69,9 @@ class Version(Command, MirrorSafeCommand):
|
||||
if src_ver != rp_ver:
|
||||
print(f" (currently at {src_ver})")
|
||||
|
||||
print(f"repo User-Agent {user_agent.repo}")
|
||||
print(f"git {git.version_tuple().full}")
|
||||
print(f"git User-Agent {user_agent.git}")
|
||||
print(f"repo User-Agent {git_command.user_agent.repo}")
|
||||
print(f"git {git_command.git.version_tuple().full}")
|
||||
print(f"git User-Agent {git_command.user_agent.git}")
|
||||
print(f"Python {sys.version}")
|
||||
uname = platform.uname()
|
||||
if sys.version_info.major < 3:
|
||||
@@ -66,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)
|
||||
+3
-4
@@ -17,13 +17,12 @@ import sys
|
||||
from typing import List
|
||||
|
||||
from command import Command
|
||||
from error import GitError
|
||||
from error import RepoExitError
|
||||
import error
|
||||
import platform_utils
|
||||
from project import DeleteWorktreeError
|
||||
|
||||
|
||||
class Error(RepoExitError):
|
||||
class Error(error.RepoExitError):
|
||||
"""Exit error when wipe command fails."""
|
||||
|
||||
|
||||
@@ -101,7 +100,7 @@ Examples:
|
||||
try:
|
||||
if project.HasChanges():
|
||||
uncommitted_projects.append(project.name)
|
||||
except GitError:
|
||||
except error.GitError:
|
||||
uncommitted_projects.append(f"{project.name} (corrupted)")
|
||||
|
||||
users = objdir_map.get(project.objdir, {project.relpath})
|
||||
|
||||
@@ -14,6 +14,8 @@ should be written using [pytest] only.
|
||||
[subcmds/forall.py](../subcmds/forall.py) module.
|
||||
* [conftest.py](./conftest.py): Custom pytest fixtures for sharing.
|
||||
* [utils_for_test.py](./utils_for_test.py): Helpers for sharing in tests.
|
||||
* [e2e/](./e2e/): Hermetic end-to-end tests that run the real `repo` against
|
||||
a local git server.
|
||||
|
||||
|
||||
[pytest]: https://pytest.org/
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"""Common fixtures for pytests."""
|
||||
|
||||
import pathlib
|
||||
from typing import Any, Generator
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -23,9 +24,44 @@ import platform_utils
|
||||
import repo_trace
|
||||
|
||||
|
||||
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
parser.addoption(
|
||||
"--fail-on-skip",
|
||||
action="store_true",
|
||||
help="fail skipped tests (run_tests sets this in CQ)",
|
||||
)
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_makereport(
|
||||
item: pytest.Item,
|
||||
) -> Generator[None, Any, None]:
|
||||
"""Fail skipped tests with --fail-on-skip.
|
||||
|
||||
A skip in CQ usually means the builder is missing a dependency (e.g. a new
|
||||
enough git), which would otherwise silently drop coverage. Tests that
|
||||
should not run in CQ are deselected via the `skip_cq` marker instead.
|
||||
"""
|
||||
outcome = yield
|
||||
report = outcome.get_result()
|
||||
if (
|
||||
item.config.getoption("fail_on_skip")
|
||||
and report.skipped
|
||||
# xfail outcomes are also reported as skipped.
|
||||
and not hasattr(report, "wasxfail")
|
||||
):
|
||||
reason = report.longrepr
|
||||
if isinstance(reason, tuple):
|
||||
# (path, lineno, reason)
|
||||
reason = reason[2]
|
||||
report.outcome = "failed"
|
||||
report.longrepr = f"Not allowed with --fail-on-skip: {reason}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_repo_trace(tmp_path):
|
||||
"""Set an environment marker to relax certain strict checks for test code.""" # noqa: E501
|
||||
repo_trace.SetTrace(False)
|
||||
repo_trace._TRACE_FILE = str(tmp_path / "TRACE_FILE_from_test")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Repo End-to-End Tests
|
||||
|
||||
These tests run the real `repo` launcher & code (as subprocesses) against a
|
||||
local git smart HTTP server. They are hermetic: everything is served from
|
||||
`127.0.0.1` out of temporary directories, and the user's git config &
|
||||
environment are ignored.
|
||||
|
||||
They run as part of `./run_tests` by default, after the unit tests.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
# Everything (unit + e2e).
|
||||
./run_tests
|
||||
|
||||
# Only the e2e tests.
|
||||
./run_tests -m e2e
|
||||
|
||||
# Everything but the e2e tests.
|
||||
./run_tests -m 'not e2e'
|
||||
```
|
||||
|
||||
The suite is skipped automatically when `git http-backend` isn't
|
||||
available, when git is older than 2.28 (need default branch support and
|
||||
superproject support), or when not run from a git checkout.
|
||||
|
||||
## Layout
|
||||
|
||||
* `harness.py`: the git server, server-side repos & the repo client.
|
||||
* `test_init_sync.py`: core `repo init` & `repo sync` options.
|
||||
* `test_partial_clone.py`: partial clones, incl. the standard Android
|
||||
configuration from go/repo-init & go/sync.
|
||||
* `test_sync_modes.py`: `sync -n`/`-l` & the other sync flags.
|
||||
* `test_groups.py`: manifest groups.
|
||||
* `test_failures.py`: failure paths (missing projects, dirty trees, ...).
|
||||
* `test_workflows.py`: multi-step workflows.
|
||||
* `test_manifest_regressions.py`: manifests that broke users in the past.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Fixtures for the hermetic end-to-end tests.
|
||||
|
||||
Tests normally only need the `host` fixture (to create server repos) and the
|
||||
`client` fixture (to run repo). Override the `git_defaults` fixture via
|
||||
parametrize to run under Git 3 defaults.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterator, List
|
||||
|
||||
import harness
|
||||
import pytest
|
||||
|
||||
|
||||
# Run before pytest's own `-m` deselection so `-m e2e` & `-m 'not e2e'` see the
|
||||
# marker.
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_collection_modifyitems(items: List[pytest.Item]) -> None:
|
||||
"""Mark tests in this directory as `e2e` & run them after unit tests.
|
||||
|
||||
Note: pytest passes every collected item (not just this directory's), so
|
||||
this reorders the whole session.
|
||||
"""
|
||||
this_dir = Path(__file__).resolve().parent
|
||||
for item in items:
|
||||
if this_dir in item.path.resolve().parents:
|
||||
item.add_marker(pytest.mark.e2e)
|
||||
# Stable sort: unit tests keep their order & come first, then E2E tests.
|
||||
items.sort(key=lambda item: item.get_closest_marker("e2e") is not None)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _require_e2e_support() -> None:
|
||||
"""Skip the suite when the host can't run it."""
|
||||
# The code under test is published from the git checkout (e.g. this isn't
|
||||
# the case when running from an unpacked sdist).
|
||||
if not (harness.ROOT_DIR / ".git").exists():
|
||||
pytest.skip("E2E tests must run from a git checkout")
|
||||
if not harness.has_http_backend():
|
||||
pytest.skip("git http-backend is not available")
|
||||
if harness.git_version() < (2, 28, 0):
|
||||
pytest.skip("E2E tests require git 2.28+")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def git_server(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator[harness.GitHttpServer]:
|
||||
"""A git smart HTTP server shared by all tests in the session."""
|
||||
with harness.serve(tmp_path_factory.mktemp("git-server")) as server:
|
||||
yield server
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def launcher(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""A standalone copy of the repo launcher."""
|
||||
return harness.install_launcher(tmp_path_factory.mktemp("bin"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def repo_source(git_server: harness.GitHttpServer) -> harness.ServerRepo:
|
||||
"""The code under test, published as a repo on `git_server`."""
|
||||
return harness.publish_repo_source(
|
||||
git_server.root / "git-repo.git", f"{git_server.url}/git-repo"
|
||||
)
|
||||
|
||||
|
||||
NAMESPACE_IDS = itertools.count()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host(git_server: harness.GitHttpServer) -> harness.GitHost:
|
||||
"""A per-test namespace for creating repos on the server."""
|
||||
return harness.GitHost(git_server, f"t{next(NAMESPACE_IDS)}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_defaults() -> str:
|
||||
"""Client git defaults to emulate; a key of harness.GIT_DEFAULTS."""
|
||||
return "git2"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_env(tmp_path: Path, git_defaults: str) -> Dict[str, str]:
|
||||
"""Isolated environment for running repo & git as a client."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
settings = {
|
||||
"user.name": "E2E User",
|
||||
"user.email": "user@example.com",
|
||||
"color.ui": "false",
|
||||
# Don't let background maintenance race with assertions.
|
||||
"maintenance.auto": "false",
|
||||
"gc.auto": "0",
|
||||
}
|
||||
if git_defaults == "git3" and harness.git_version() < (2, 47, 0):
|
||||
# init.defaultRefFormat & init.defaultObjectFormat landed in git 2.47.
|
||||
pytest.skip("git is too old to emulate Git 3 defaults")
|
||||
settings.update(harness.GIT_DEFAULTS[git_defaults])
|
||||
harness.write_config(home / ".gitconfig", settings)
|
||||
# Python resolves `~` via USERPROFILE on Windows. The ceiling keeps git
|
||||
# from finding a repo above the test dir (e.g. with `--basetemp=./tmp`).
|
||||
return dict(
|
||||
harness.clean_environ(),
|
||||
HOME=str(home),
|
||||
USERPROFILE=str(home),
|
||||
GIT_CEILING_DIRECTORIES=str(tmp_path),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(
|
||||
tmp_path: Path,
|
||||
launcher: Path,
|
||||
repo_source: harness.ServerRepo,
|
||||
client_env: Dict[str, str],
|
||||
) -> harness.RepoClient:
|
||||
"""A repo client checkout directory that uses the code under test."""
|
||||
return harness.RepoClient(
|
||||
tmp_path / "client", launcher, client_env, repo_source.url
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,195 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""E2E tests for failure paths: sync must fail loudly & leave a usable tree.
|
||||
|
||||
Note: repo retries failed fetches once after a few seconds, so each failing
|
||||
fetch costs about 5s.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from harness import GitHost
|
||||
from harness import manifest_xml
|
||||
from harness import RepoClient
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sync_args",
|
||||
[
|
||||
pytest.param(["-j1"], id="interleaved"),
|
||||
pytest.param(["-j1", "--fail-fast"], id="fail-fast"),
|
||||
],
|
||||
)
|
||||
def test_missing_project(
|
||||
host: GitHost, client: RepoClient, sync_args: List[str]
|
||||
) -> None:
|
||||
"""A project missing on the server fails the sync with a useful error.
|
||||
|
||||
By default the other projects are still synced & creating the missing repo
|
||||
lets the next sync recover; with `--fail-fast` the sync stops at the first
|
||||
failure without contacting later repos.
|
||||
"""
|
||||
host.create_repo("platform/foo", {"f": "foo"})
|
||||
host.create_repo("platform/bar", {"f": "bar"})
|
||||
# Paths sort the missing project first, so `-j1` reaches it first.
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/missing" path="missing" />',
|
||||
'<project name="platform/foo" path="present-foo" />',
|
||||
'<project name="platform/bar" path="present-bar" />',
|
||||
)
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
|
||||
with host.watch_requests() as requests:
|
||||
result = client.sync(*sync_args, check=False)
|
||||
|
||||
assert result.returncode != 0
|
||||
# The failing fetch & git's reason are reported either way.
|
||||
assert "on platform/missing failed" in result.output
|
||||
assert "not found" in result.output
|
||||
assert not (client.top / "missing").exists()
|
||||
# Repo requested the missing project (it's a 404, not a client bug).
|
||||
assert {r.status for r in host.requests_for("platform/missing")} == {404}
|
||||
|
||||
if "--fail-fast" in sync_args:
|
||||
assert "SyncFailFastError" in result.stderr
|
||||
assert client.checkouts() == set()
|
||||
assert host.requested_repos(requests) == {
|
||||
"platform/manifest",
|
||||
"platform/missing",
|
||||
}
|
||||
return
|
||||
|
||||
assert client.checkouts() == {"present-bar", "present-foo"}
|
||||
assert "error: Unable to fully sync the tree" in result.stderr
|
||||
assert "Failing repos (network):\nmissing" in result.stderr
|
||||
assert 'Try re-running with "-j1 --fail-fast"' in result.stderr
|
||||
|
||||
# Once the missing project exists on the server, the next sync recovers.
|
||||
host.create_repo("platform/missing", {"f": "missing"})
|
||||
client.sync(*sync_args)
|
||||
assert client.checkouts() == {"missing", "present-bar", "present-foo"}
|
||||
assert (client.top / "missing/f").read_text() == "missing"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"revision,error",
|
||||
[
|
||||
(
|
||||
"nonexistent",
|
||||
"couldn't find remote ref refs/heads/nonexistent",
|
||||
),
|
||||
# A pinned commit that was never pushed.
|
||||
("1" * 40, "1" * 40),
|
||||
],
|
||||
ids=["branch", "commit"],
|
||||
)
|
||||
def test_missing_project_revision(
|
||||
host: GitHost, client: RepoClient, revision: str, error: str
|
||||
) -> None:
|
||||
"""A manifest revision that doesn't exist on the server fails the sync."""
|
||||
foo = host.create_repo("platform/foo", {"f": "foo"})
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
f'<project name="platform/foo" path="foo" revision="{revision}" />',
|
||||
'<project name="platform/foo" path="ok" />',
|
||||
)
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
|
||||
result = client.sync(check=False)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "error: Unable to fully sync the tree" in result.stderr
|
||||
assert "Failing repos (network):\nfoo" in result.stderr
|
||||
assert error in result.stderr
|
||||
assert not (client.top / "foo").exists()
|
||||
assert client.head("ok") == foo.rev_parse("main")
|
||||
|
||||
# Fixing the missing branch on the server recovers on the next sync.
|
||||
if revision == "nonexistent":
|
||||
fixed = foo.commit({"f": "fixed"}, branch="nonexistent")
|
||||
client.sync()
|
||||
assert client.head("foo") == fixed
|
||||
|
||||
|
||||
def test_missing_manifest_branch(host: GitHost, client: RepoClient) -> None:
|
||||
"""`init -b` with a branch the manifest repo doesn't have fails cleanly.
|
||||
|
||||
The half-initialized manifest checkout is removed so a corrected `init`
|
||||
starts from scratch.
|
||||
"""
|
||||
host.create_repo("platform/foo", {"f": "foo"})
|
||||
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
|
||||
result = client.init(host.manifest_url, "-b", "nonexistent", check=False)
|
||||
assert result.returncode != 0
|
||||
assert "couldn't find remote ref refs/heads/nonexistent" in result.output
|
||||
assert "fatal: cannot obtain manifest" in result.stderr
|
||||
assert not (client.repodir / "manifests.git").exists()
|
||||
|
||||
client.init(host.manifest_url, "-b", "main")
|
||||
client.sync()
|
||||
assert (client.top / "platform/foo/f").read_text() == "foo"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("on_branch", [False, True])
|
||||
def test_dirty_worktree_blocks_sync(
|
||||
host: GitHost, client: RepoClient, on_branch: bool
|
||||
) -> None:
|
||||
"""Uncommitted changes that conflict with upstream fail the checkout.
|
||||
|
||||
The local change is preserved and the fetched objects are kept, so the
|
||||
user can resolve it and run `repo sync -l`. On a detached HEAD,
|
||||
`--force-checkout` discards the local change instead.
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
host.create_manifest(
|
||||
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
old = foo.rev_parse("main")
|
||||
if on_branch:
|
||||
client.run("start", "topic", "--all")
|
||||
(client.top / "foo/f").write_text("local")
|
||||
latest = foo.commit({"f": "2"})
|
||||
|
||||
result = client.sync(check=False)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "error: Checking out local projects failed" in result.stderr
|
||||
assert "Failing repos (checkout):\nfoo" in result.stderr
|
||||
if on_branch:
|
||||
assert "contains uncommitted changes" in result.stderr
|
||||
else:
|
||||
assert f"platform/foo checkout {latest}" in result.stderr
|
||||
assert (client.top / "foo/f").read_text() == "local"
|
||||
assert client.head("foo") == old
|
||||
assert client.git("foo", "rev-parse", "refs/remotes/origin/main") == latest
|
||||
|
||||
if not on_branch:
|
||||
client.sync("--force-checkout")
|
||||
else:
|
||||
# Discarding the edit & running `sync -l` recovers without touching
|
||||
# the network.
|
||||
client.git("foo", "checkout", "--", "f")
|
||||
with host.watch_requests() as requests, host.offline():
|
||||
client.sync("-l")
|
||||
assert requests == []
|
||||
assert client.head("foo") == latest
|
||||
assert (client.top / "foo/f").read_text() == "2"
|
||||
@@ -0,0 +1,201 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""E2E tests for manifest groups beyond `repo init -g`."""
|
||||
|
||||
import platform
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from harness import GitHost
|
||||
from harness import Gitlink
|
||||
from harness import manifest_xml
|
||||
from harness import RepoClient
|
||||
from harness import ServerRepo
|
||||
import pytest
|
||||
|
||||
|
||||
# `a` & `b` are in the default group; `c` & `d` are not.
|
||||
GROUPED_MANIFEST = manifest_xml(
|
||||
'<project name="platform/a" path="a" />',
|
||||
'<project name="platform/b" path="b" groups="g1" />',
|
||||
'<project name="platform/c" path="c" groups="g2,notdefault" />',
|
||||
'<project name="platform/d" path="d" groups="notdefault" />',
|
||||
)
|
||||
|
||||
|
||||
def create_grouped_projects(host: GitHost) -> Dict[str, ServerRepo]:
|
||||
"""Create the projects of `GROUPED_MANIFEST` & the manifest itself."""
|
||||
repos = {}
|
||||
for name in "abcd":
|
||||
repos[name] = host.create_repo(f"platform/{name}", {"f": name})
|
||||
host.create_manifest(GROUPED_MANIFEST)
|
||||
return repos
|
||||
|
||||
|
||||
def listed(client: RepoClient, *args: str) -> Set[str]:
|
||||
"""The project paths printed by `repo list <args>`."""
|
||||
return set(client.list_projects(*args))
|
||||
|
||||
|
||||
def test_reinit_groups(host: GitHost, client: RepoClient) -> None:
|
||||
"""Re-running `init -g` adds & removes projects on the next sync."""
|
||||
create_grouped_projects(host)
|
||||
this_platform = f"platform-{platform.system().lower()}"
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
assert client.checkouts() == {"a", "b"}
|
||||
assert client.config(".repo/manifests", "manifest.groups") is None
|
||||
|
||||
client.init(host.manifest_url, "-g", "default,g2")
|
||||
client.sync()
|
||||
|
||||
assert client.checkouts() == {"a", "b", "c"}
|
||||
assert (
|
||||
client.config(".repo/manifests", "manifest.groups")
|
||||
== f"default,g2,{this_platform}"
|
||||
)
|
||||
|
||||
client.init(host.manifest_url, "-g", "g2")
|
||||
client.sync()
|
||||
|
||||
assert client.checkouts() == {"c"}
|
||||
assert client.list_projects() == {"c": "platform/c"}
|
||||
assert (
|
||||
client.config(".repo/manifests", "manifest.groups")
|
||||
== f"g2,{this_platform}"
|
||||
)
|
||||
# The object dirs of removed projects are kept for a later re-add.
|
||||
assert (client.repodir / "project-objects/platform/a.git").is_dir()
|
||||
|
||||
# Back to the default groups.
|
||||
client.init(host.manifest_url, "-g", "default")
|
||||
client.sync()
|
||||
assert client.checkouts() == {"a", "b"}
|
||||
assert (client.top / "a/f").read_text() == "a"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"init_args,expected",
|
||||
[
|
||||
([], {"base"}),
|
||||
(["-g", "default,extra"], {"base", "extra", "extra2"}),
|
||||
(["-g", "default,extra,-g2"], {"base", "extra"}),
|
||||
(["-g", "g2"], {"extra2"}),
|
||||
],
|
||||
)
|
||||
def test_include_groups(
|
||||
host: GitHost, client: RepoClient, init_args: List[str], expected: Set[str]
|
||||
) -> None:
|
||||
"""`<include groups=...>` adds its groups to every included project."""
|
||||
for name in ("base", "extra", "extra2"):
|
||||
host.create_repo(f"platform/{name}", {"f": name})
|
||||
host.create_manifest(
|
||||
{
|
||||
"default.xml": manifest_xml(
|
||||
'<project name="platform/base" path="base" />',
|
||||
'<include name="extra.xml" groups="notdefault,extra" />',
|
||||
),
|
||||
"extra.xml": (
|
||||
"<manifest>\n"
|
||||
' <project name="platform/extra" path="extra" />\n'
|
||||
' <project name="platform/extra2" path="extra2" '
|
||||
'groups="g2" />\n'
|
||||
"</manifest>\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
client.init(host.manifest_url, *init_args)
|
||||
client.sync()
|
||||
|
||||
assert client.checkouts() == expected
|
||||
assert set(client.list_projects()) == expected
|
||||
# The include's groups are merged with the project's own.
|
||||
assert listed(client, "-a", "-g", "extra") == {"extra", "extra2"}
|
||||
assert listed(client, "-a", "-g", "g2") == {"extra2"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"init_args,platforms",
|
||||
[
|
||||
# None means the platform the test runs on.
|
||||
([], None),
|
||||
(["--platform=all"], {"linux", "darwin", "windows"}),
|
||||
(["--platform=none"], set()),
|
||||
(["-p", "darwin"], {"darwin"}),
|
||||
(["-g", "default,platform-linux", "--platform=none"], {"linux"}),
|
||||
],
|
||||
)
|
||||
def test_platform_groups(
|
||||
host: GitHost,
|
||||
client: RepoClient,
|
||||
init_args: List[str],
|
||||
platforms: Optional[Set[str]],
|
||||
) -> None:
|
||||
"""`platform-*` groups follow the host OS unless `--platform` is given."""
|
||||
names = ("linux", "darwin", "windows")
|
||||
host.create_repo("platform/base", {"f": "base"})
|
||||
for name in names:
|
||||
host.create_repo(f"platform/{name}", {"f": name})
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/base" path="base" />',
|
||||
*(
|
||||
f'<project name="platform/{name}" path="{name}" '
|
||||
f'groups="notdefault,platform-{name}" />'
|
||||
for name in names
|
||||
),
|
||||
)
|
||||
)
|
||||
if platforms is None:
|
||||
platforms = {platform.system().lower()}
|
||||
|
||||
client.init(host.manifest_url, *init_args)
|
||||
client.sync()
|
||||
|
||||
assert client.checkouts() == {"base"} | platforms
|
||||
assert set(client.list_projects()) == {"base"} | platforms
|
||||
|
||||
|
||||
def test_groups_with_superproject(host: GitHost, client: RepoClient) -> None:
|
||||
"""Group selection applies on top of superproject pinning."""
|
||||
pins = {}
|
||||
for name in "ab":
|
||||
repo = host.create_repo(f"platform/{name}", {"f": "v1"})
|
||||
pins[name] = repo.rev_parse("main")
|
||||
repo.commit({"f": "v2"})
|
||||
host.create_repo(
|
||||
"superproject", {name: Gitlink(rev) for name, rev in pins.items()}
|
||||
)
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<superproject name="superproject" />',
|
||||
'<project name="platform/a" path="a" />',
|
||||
'<project name="platform/b" path="b" groups="notdefault,g1" />',
|
||||
)
|
||||
)
|
||||
|
||||
client.init(host.manifest_url, "--use-superproject")
|
||||
assert client.sync().stderr == ""
|
||||
|
||||
assert client.checkouts() == {"a"}
|
||||
assert client.head("a") == pins["a"]
|
||||
assert host.requests_for("platform/b") == []
|
||||
|
||||
client.init(host.manifest_url, "-g", "all")
|
||||
assert client.sync().stderr == ""
|
||||
|
||||
assert client.checkouts() == {"a", "b"}
|
||||
assert client.head("b") == pins["b"]
|
||||
assert (client.top / "b/f").read_text() == "v1"
|
||||
@@ -0,0 +1,209 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the E2E harness helpers themselves."""
|
||||
|
||||
import http.client
|
||||
from pathlib import Path
|
||||
from typing import Mapping, Tuple
|
||||
import urllib.parse
|
||||
|
||||
import harness
|
||||
import pytest
|
||||
|
||||
|
||||
def _http_request(
|
||||
server: harness.GitHttpServer, method: str, path: str
|
||||
) -> Tuple[int, bytes]:
|
||||
"""Send a request to `server` & return the response status & body."""
|
||||
url = urllib.parse.urlsplit(server.url)
|
||||
conn = http.client.HTTPConnection(url.hostname, url.port, timeout=30)
|
||||
try:
|
||||
conn.request(method, path)
|
||||
resp = conn.getresponse()
|
||||
return resp.status, resp.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key,value",
|
||||
[
|
||||
("core.bare", "true"),
|
||||
('url.C:\\src\\#1;"x".insteadOf', 'C:\\dst\\ #2;"y"'),
|
||||
("alias.multi", "a\nb"),
|
||||
],
|
||||
)
|
||||
def test_write_config(tmp_path: Path, key: str, value: str) -> None:
|
||||
"""Subsections & values round-trip through git's config parser."""
|
||||
path = tmp_path / "config"
|
||||
harness.write_config(path, {key: value})
|
||||
got = harness.run_git("config", "--file", str(path), "--get", key)
|
||||
assert got == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"files",
|
||||
[
|
||||
{"foo/bar": "x", "foo": None},
|
||||
{"foo": None, "foo/bar": "x"},
|
||||
],
|
||||
)
|
||||
def test_commit_replace_file_with_dir(
|
||||
tmp_path: Path, files: Mapping[str, harness.FileContent]
|
||||
) -> None:
|
||||
"""Deletions apply before additions regardless of dict order."""
|
||||
repo = harness.ServerRepo.create(tmp_path / "repo.git", "unused")
|
||||
repo.commit({"foo": "file"})
|
||||
repo.commit(files)
|
||||
assert repo.git("ls-tree", "-r", "--name-only", "main") == "foo/bar"
|
||||
|
||||
|
||||
def test_snapshot_worktree_host_excludes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The developer's global excludes apply despite the isolated HOME."""
|
||||
host_home = tmp_path / "host-home"
|
||||
host_home.mkdir()
|
||||
excludes = host_home / "excludes"
|
||||
excludes.write_text("venv/\n")
|
||||
# Set excludesFile explicitly so a system-wide one can't take precedence.
|
||||
harness.write_config(
|
||||
host_home / ".gitconfig", {"core.excludesFile": excludes.as_posix()}
|
||||
)
|
||||
monkeypatch.setattr(harness, "HOST_ENVIRON", {"HOME": str(host_home)})
|
||||
root = tmp_path / "checkout"
|
||||
harness.run_git("init", "--quiet", str(root))
|
||||
(root / ".gitignore").write_text("*.pyc\n")
|
||||
(root / "main.py").write_text("")
|
||||
(root / "main.pyc").write_text("")
|
||||
(root / "venv").mkdir()
|
||||
(root / "venv" / "lib.py").write_text("")
|
||||
assert sorted(harness._snapshot_worktree(root)) == [".gitignore", "main.py"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"chunked,expect_continue",
|
||||
[(False, False), (True, False), (True, True)],
|
||||
)
|
||||
def test_http_post_body(
|
||||
git_server: harness.GitHttpServer, chunked: bool, expect_continue: bool
|
||||
) -> None:
|
||||
"""POST bodies reach git http-backend however curl encodes them."""
|
||||
name = f"post-{int(chunked)}{int(expect_continue)}.git"
|
||||
repo = harness.ServerRepo.create(git_server.root / name, "unused")
|
||||
commit = repo.commit({"file": "x"})
|
||||
# A protocol v2 ls-refs request: a command pkt-line & a flush-pkt.
|
||||
body = b"0014command=ls-refs\n0000"
|
||||
headers = {
|
||||
"Content-Type": "application/x-git-upload-pack-request",
|
||||
"Git-Protocol": "version=2",
|
||||
}
|
||||
if expect_continue:
|
||||
headers["Expect"] = "100-continue"
|
||||
url = urllib.parse.urlsplit(git_server.url)
|
||||
conn = http.client.HTTPConnection(url.hostname, url.port, timeout=30)
|
||||
try:
|
||||
# http.client skips any interim `100 Continue` response.
|
||||
conn.request(
|
||||
"POST",
|
||||
f"/{name}/git-upload-pack",
|
||||
body=iter([body[:10], body[10:]]) if chunked else body,
|
||||
headers=headers,
|
||||
encode_chunked=chunked,
|
||||
)
|
||||
resp = conn.getresponse()
|
||||
assert resp.status == 200
|
||||
assert f"{commit} refs/heads/main".encode() in resp.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_static_files(git_server: harness.GitHttpServer) -> None:
|
||||
"""Published files are served as-is, and nothing outside of them."""
|
||||
host = harness.GitHost(git_server, "static-files")
|
||||
host.create_repo("repo", {"f": "x"})
|
||||
url = host.publish_file("dir/default.xml", "<manifest />\n")
|
||||
path = urllib.parse.urlsplit(url).path
|
||||
assert _http_request(git_server, "GET", path) == (200, b"<manifest />\n")
|
||||
|
||||
# Publishing again replaces the file.
|
||||
assert host.publish_file("dir/default.xml", b"v2") == url
|
||||
assert _http_request(git_server, "GET", path) == (200, b"v2")
|
||||
|
||||
static = f"/{host.prefix}/{harness.STATIC_DIR}"
|
||||
for bad_path in (
|
||||
f"{static}/missing.xml",
|
||||
f"{static}/dir",
|
||||
f"{static}/../repo.git/config",
|
||||
f"{static}/%2e%2e/repo.git/config",
|
||||
f"{static}//etc/passwd",
|
||||
):
|
||||
assert _http_request(git_server, "GET", bad_path)[0] == 404, bad_path
|
||||
assert _http_request(git_server, "POST", path)[0] == 404
|
||||
|
||||
|
||||
def test_offline(git_server: harness.GitHttpServer) -> None:
|
||||
"""Requests fail while a namespace is offline, and work again after."""
|
||||
host = harness.GitHost(git_server, "offline")
|
||||
repo = host.create_repo("platform/repo", {"f": "x"})
|
||||
other = harness.GitHost(git_server, "online")
|
||||
other_repo = other.create_repo("repo", {"f": "x"})
|
||||
|
||||
with host.offline():
|
||||
with pytest.raises(AssertionError, match="503"):
|
||||
harness.run_git("ls-remote", repo.url)
|
||||
# Other namespaces are unaffected.
|
||||
assert "refs/heads/main" in harness.run_git("ls-remote", other_repo.url)
|
||||
assert {r.status for r in host.requests()} == {503}
|
||||
|
||||
# The outage ends even if the context exits with an exception.
|
||||
with pytest.raises(RuntimeError):
|
||||
with host.offline():
|
||||
raise RuntimeError("boom")
|
||||
assert "refs/heads/main" in harness.run_git("ls-remote", repo.url)
|
||||
|
||||
|
||||
def test_requested_repos(git_server: harness.GitHttpServer) -> None:
|
||||
"""`requested_repos()` & `watch_requests()` track repo requests."""
|
||||
host = harness.GitHost(git_server, "requested")
|
||||
nested = host.create_repo("platform/nested", {"f": "x"})
|
||||
top = host.create_repo("top", {"f": "x"})
|
||||
host.publish_file("file.xml", "x")
|
||||
|
||||
harness.run_git("ls-remote", nested.url)
|
||||
with host.watch_requests() as watched:
|
||||
harness.run_git("ls-remote", top.url)
|
||||
_http_request(git_server, "GET", f"/{host.prefix}/top/clone.bundle")
|
||||
_http_request(git_server, "GET", f"/{host.prefix}/_static/file.xml")
|
||||
|
||||
assert host.requested_repos() == {"platform/nested", "top"}
|
||||
assert host.requested_repos(watched) == {"top"}
|
||||
assert host.requests_for("platform/nested", watched) == []
|
||||
|
||||
|
||||
def test_client_config(tmp_path: Path) -> None:
|
||||
"""`RepoClient.config()` tells unset keys apart from errors."""
|
||||
# Don't let git find a repo above tmp_path (e.g. with --basetemp).
|
||||
env = dict(harness.clean_environ(), GIT_CEILING_DIRECTORIES=str(tmp_path))
|
||||
client = harness.RepoClient(tmp_path / "client", harness.LAUNCHER, env, "")
|
||||
repo = client.top / "repo"
|
||||
harness.run_git("init", "-q", str(repo))
|
||||
harness.run_git("config", "-f", str(repo / ".git/config"), "a.key", "val")
|
||||
(client.top / "not-a-repo").mkdir()
|
||||
|
||||
assert client.config("repo", "a.key") == "val"
|
||||
assert client.config("repo", "a.unset") is None
|
||||
with pytest.raises(AssertionError, match="failed in not-a-repo"):
|
||||
client.config("not-a-repo", "a.key")
|
||||
@@ -0,0 +1,355 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""E2E tests for the core `repo init` & `repo sync` options."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Set
|
||||
|
||||
from harness import GIT_DEFAULTS_PARAMS
|
||||
from harness import GitHost
|
||||
from harness import manifest_xml
|
||||
from harness import move_aside
|
||||
from harness import RepoClient
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||
def test_init_sync_basic(host: GitHost, client: RepoClient) -> None:
|
||||
"""The basic `repo init && repo sync` flow."""
|
||||
foo = host.create_repo("platform/foo", {"foo.txt": "foo"})
|
||||
host.create_repo("platform/bar", {"bar.txt": "bar"})
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
'<project name="platform/bar" path="sub/bar" />',
|
||||
)
|
||||
)
|
||||
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
|
||||
assert (client.top / "foo" / "foo.txt").read_text() == "foo"
|
||||
assert (client.top / "sub" / "bar" / "bar.txt").read_text() == "bar"
|
||||
assert client.head("foo") == foo.rev_parse("main")
|
||||
assert client.list_projects() == {
|
||||
"foo": "platform/foo",
|
||||
"sub/bar": "platform/bar",
|
||||
}
|
||||
# The code under test is what got installed & is running.
|
||||
assert "v99.0-e2e" in client.run("version").stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"init_args,sync_args,manifest_refs,project_refs",
|
||||
[
|
||||
# `init -c` is the default for the manifest project.
|
||||
([], [], {"main"}, {"main", "other"}),
|
||||
(["--no-current-branch"], [], {"main", "other"}, {"main", "other"}),
|
||||
([], ["-c"], {"main"}, {"main"}),
|
||||
],
|
||||
)
|
||||
def test_current_branch(
|
||||
host: GitHost,
|
||||
client: RepoClient,
|
||||
init_args: List[str],
|
||||
sync_args: List[str],
|
||||
manifest_refs: Set[str],
|
||||
project_refs: Set[str],
|
||||
) -> None:
|
||||
"""`init` limits the manifest fetch; `sync -c` limits projects."""
|
||||
foo = host.create_repo("platform/foo", {"foo.txt": "main"})
|
||||
foo.commit({"foo.txt": "other"}, branch="other")
|
||||
manifest = host.create_manifest(
|
||||
manifest_xml('<project name="platform/foo" />')
|
||||
)
|
||||
manifest.commit({"README": "other"}, branch="other")
|
||||
|
||||
client.init(host.manifest_url, *init_args)
|
||||
assert client.remote_branches(".repo/manifests") == manifest_refs
|
||||
|
||||
client.sync(*sync_args)
|
||||
assert client.remote_branches("platform/foo") == project_refs
|
||||
assert (client.top / "platform/foo/foo.txt").read_text() == "main"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sync_args,expect_bundle", [([], True), (["--no-clone-bundle"], False)]
|
||||
)
|
||||
def test_clone_bundle(
|
||||
host: GitHost,
|
||||
client: RepoClient,
|
||||
sync_args: List[str],
|
||||
expect_bundle: bool,
|
||||
) -> None:
|
||||
"""repo bootstraps new projects from clone.bundle when available."""
|
||||
foo = host.create_repo("platform/foo", {"foo.txt": "foo"})
|
||||
# A commit that only the bundle has, to prove the bundle was applied.
|
||||
bundle_only = foo.commit({"x": "bundle"}, branch="bundle-only")
|
||||
foo.write_bundle()
|
||||
foo.git("update-ref", "-d", "refs/heads/bundle-only")
|
||||
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
|
||||
client.init(host.manifest_url)
|
||||
client.sync(*sync_args)
|
||||
|
||||
bundle_gets = [
|
||||
r
|
||||
for r in host.requests()
|
||||
if r.path.endswith("/platform/foo/clone.bundle")
|
||||
]
|
||||
assert [r.status for r in bundle_gets] == ([200] if expect_bundle else [])
|
||||
assert client.has_object("platform/foo", bundle_only) == expect_bundle
|
||||
assert client.head("platform/foo") == foo.rev_parse("main")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"init_args,project_attrs",
|
||||
[
|
||||
(["--depth=1"], ""),
|
||||
([], 'clone-depth="1"'),
|
||||
],
|
||||
)
|
||||
def test_shallow(
|
||||
host: GitHost,
|
||||
client: RepoClient,
|
||||
init_args: List[str],
|
||||
project_attrs: str,
|
||||
) -> None:
|
||||
"""Shallow clones via `--depth` and manifest `clone-depth`."""
|
||||
foo = host.create_repo("platform/foo", {"foo.txt": "1"})
|
||||
foo.commit({"foo.txt": "2"})
|
||||
foo.commit({"foo.txt": "3"})
|
||||
host.create_manifest(
|
||||
manifest_xml(f'<project name="platform/foo" {project_attrs} />')
|
||||
)
|
||||
|
||||
client.init(host.manifest_url, *init_args)
|
||||
client.sync()
|
||||
|
||||
path = "platform/foo"
|
||||
assert client.is_shallow(path)
|
||||
assert client.git(path, "rev-list", "--count", "HEAD") == "1"
|
||||
assert (client.top / path / "foo.txt").read_text() == "3"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"init_args,expected",
|
||||
[
|
||||
([], {"a", "b"}),
|
||||
(["-g", "all"], {"a", "b", "c", "d"}),
|
||||
(["-g", "g2"], {"c"}),
|
||||
(["-g", "default,-g1"], {"a"}),
|
||||
(["-g", "default,g2"], {"a", "b", "c"}),
|
||||
(["-g", "name:platform/d"], {"d"}),
|
||||
],
|
||||
)
|
||||
def test_groups(
|
||||
host: GitHost, client: RepoClient, init_args: List[str], expected: Set[str]
|
||||
) -> None:
|
||||
"""Manifest groups select which projects get synced."""
|
||||
for name in "abcd":
|
||||
host.create_repo(f"platform/{name}", {"f": name})
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/a" path="a" />',
|
||||
'<project name="platform/b" path="b" groups="g1" />',
|
||||
'<project name="platform/c" path="c" groups="g2,notdefault" />',
|
||||
'<project name="platform/d" path="d" groups="notdefault" />',
|
||||
)
|
||||
)
|
||||
|
||||
client.init(host.manifest_url, *init_args)
|
||||
client.sync()
|
||||
|
||||
assert set(client.list_projects()) == expected
|
||||
assert client.checkouts() == expected
|
||||
|
||||
|
||||
def test_local_manifests(host: GitHost, client: RepoClient) -> None:
|
||||
""".repo/local_manifests/ can add, remove & extend projects."""
|
||||
foo = host.create_repo("platform/foo", {"f": "main"})
|
||||
stable = foo.commit({"f": "stable"}, branch="stable")
|
||||
host.create_repo("platform/bar", {"f": "bar"})
|
||||
host.create_repo("vendor/extra", {"f": "extra"})
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
'<project name="platform/bar" path="bar" />',
|
||||
)
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
local = client.repodir / "local_manifests"
|
||||
local.mkdir()
|
||||
(local / "local.xml").write_text(
|
||||
"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<manifest>
|
||||
<remove-project name="platform/bar" />
|
||||
<extend-project name="platform/foo" revision="stable" />
|
||||
<project name="vendor/extra" path="extra" />
|
||||
</manifest>
|
||||
"""
|
||||
)
|
||||
|
||||
client.sync()
|
||||
|
||||
assert client.list_projects() == {
|
||||
"extra": "vendor/extra",
|
||||
"foo": "platform/foo",
|
||||
}
|
||||
assert client.head("foo") == stable
|
||||
assert not (client.top / "bar").exists()
|
||||
|
||||
|
||||
def test_manifest_name(host: GitHost, client: RepoClient) -> None:
|
||||
"""`-m` selects a manifest file other than default.xml."""
|
||||
host.create_repo("platform/foo", {"f": "foo"})
|
||||
host.create_repo("platform/bar", {"f": "bar"})
|
||||
host.create_manifest(
|
||||
{
|
||||
"default.xml": manifest_xml('<project name="platform/foo" />'),
|
||||
"other.xml": manifest_xml('<project name="platform/bar" />'),
|
||||
}
|
||||
)
|
||||
|
||||
client.init(host.manifest_url, "-m", "other.xml")
|
||||
client.sync()
|
||||
|
||||
assert client.list_projects() == {"platform/bar": "platform/bar"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dissociate", [False, True])
|
||||
def test_reference_mirror(
|
||||
host: GitHost, client: RepoClient, dissociate: bool
|
||||
) -> None:
|
||||
"""`init --reference` borrows objects from a local `--mirror` checkout.
|
||||
|
||||
With `--dissociate` the objects are copied so the mirror can go away.
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "foo"})
|
||||
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
mirror = RepoClient(
|
||||
client.top.parent / "mirror",
|
||||
client.launcher,
|
||||
client.env,
|
||||
client.repo_url,
|
||||
)
|
||||
mirror.init(host.manifest_url, "--mirror")
|
||||
mirror.sync()
|
||||
if dissociate:
|
||||
# Dissociating the manifest repo itself currently fails (its
|
||||
# preciousObjects setting blocks `git repack -a -d`), so only reference
|
||||
# the mirror's projects.
|
||||
move_aside(mirror.repodir / "manifests.git")
|
||||
|
||||
init_args = ["--reference", str(mirror.top)]
|
||||
if dissociate:
|
||||
init_args.append("--dissociate")
|
||||
client.init(host.manifest_url, *init_args)
|
||||
client.sync()
|
||||
|
||||
path = "platform/foo"
|
||||
assert client.head(path) == foo.rev_parse("main")
|
||||
assert (client.top / path / "f").read_text() == "foo"
|
||||
assert client.has_object(path, foo.rev_parse("main"))
|
||||
objdir = client.repodir / "project-objects/platform/foo.git"
|
||||
alternates = objdir / "objects/info/alternates"
|
||||
assert client.config(".repo/manifests", "repo.reference") == str(mirror.top)
|
||||
if dissociate:
|
||||
assert not alternates.exists()
|
||||
assert client.config(".repo/manifests", "repo.dissociate") == "true"
|
||||
else:
|
||||
expected = mirror.top / "platform/foo.git/objects"
|
||||
assert Path(alternates.read_text().strip()) == expected
|
||||
# Nothing had to be downloaded or copied into the local object dir.
|
||||
counts = client.git(".", f"--git-dir={objdir}", "count-objects", "-v")
|
||||
assert "count: 0\n" in counts
|
||||
assert "in-pack: 0\n" in counts
|
||||
# Updates still come from the server (the mirror isn't synced).
|
||||
latest = foo.commit({"f": "2"})
|
||||
client.sync()
|
||||
assert client.head(path) == latest
|
||||
|
||||
# If the mirror goes away, `--dissociate` checkouts still have all
|
||||
# historical objects, whereas shared checkouts lost the borrowed ones.
|
||||
move_aside(mirror.top)
|
||||
if dissociate:
|
||||
assert client.git(path, "rev-list", "--count", "HEAD") == "2"
|
||||
client.git(path, "rev-list", "--objects", "HEAD")
|
||||
else:
|
||||
with pytest.raises(AssertionError, match="rev-list"):
|
||||
client.git(path, "rev-list", "--objects", "HEAD")
|
||||
|
||||
|
||||
def test_standalone_manifest(host: GitHost, client: RepoClient) -> None:
|
||||
"""`init --standalone-manifest` fetches a static manifest file over HTTP.
|
||||
|
||||
There's no manifest checkout to update, so syncs only fetch the projects.
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
host.create_repo("platform/bar", {"f": "bar"})
|
||||
xml = manifest_xml('<project name="platform/foo" />', fetch=host.url)
|
||||
manifest_url = host.publish_file("default.xml", xml)
|
||||
|
||||
client.init(manifest_url, "--standalone-manifest")
|
||||
client.sync()
|
||||
|
||||
path = "platform/foo"
|
||||
assert client.head(path) == foo.rev_parse("main")
|
||||
assert (client.repodir / "manifests/default.xml").read_text() == xml
|
||||
assert (
|
||||
client.config(".repo/manifests.git", "manifest.standalone")
|
||||
== manifest_url
|
||||
)
|
||||
# Updating the static file on the server does not affect `repo sync`,
|
||||
# which only fetches the project.
|
||||
host.publish_file(
|
||||
"default.xml",
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" />',
|
||||
'<project name="platform/bar" />',
|
||||
fetch=host.url,
|
||||
),
|
||||
)
|
||||
latest = foo.commit({"f": "2"})
|
||||
with host.watch_requests() as requests:
|
||||
client.sync()
|
||||
assert client.head(path) == latest
|
||||
assert client.list_projects() == {"platform/foo": "platform/foo"}
|
||||
assert requests == host.requests_for("platform/foo", requests)
|
||||
# Re-initializing without a manifest URL isn't allowed.
|
||||
result = client.run("init", "--no-repo-verify", check=False)
|
||||
assert result.returncode != 0
|
||||
assert "cannot be re-initialized without --manifest-url/-u" in result.stderr
|
||||
|
||||
|
||||
def test_manifest_depth(host: GitHost, client: RepoClient) -> None:
|
||||
"""`init --manifest-depth` shallow-clones the manifest repo only."""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
foo.commit({"f": "2"})
|
||||
host.create_manifest(manifest_xml())
|
||||
host.update_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
|
||||
client.init(host.manifest_url, "--manifest-depth=1")
|
||||
|
||||
manifests = ".repo/manifests"
|
||||
assert client.is_shallow(manifests)
|
||||
assert client.git(manifests, "rev-list", "--count", "HEAD") == "1"
|
||||
|
||||
# `--nmu` skips updating the manifest project, keeping it shallow while
|
||||
# syncing projects at full depth.
|
||||
client.sync("--nmu")
|
||||
assert client.is_shallow(manifests)
|
||||
assert not client.is_shallow("platform/foo")
|
||||
assert client.git("platform/foo", "rev-list", "--count", "HEAD") == "2"
|
||||
@@ -0,0 +1,387 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""E2E regression tests for manifests that broke users in the past.
|
||||
|
||||
Each test names the commit and/or bug that fixed the original breakage.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from harness import GitHost
|
||||
from harness import Gitlink
|
||||
from harness import manifest_xml
|
||||
from harness import RepoClient
|
||||
import pytest
|
||||
|
||||
|
||||
def test_empty_manifest(host: GitHost, client: RepoClient) -> None:
|
||||
"""A manifest without projects syncs (616e314, b/377546300)."""
|
||||
host.create_manifest(manifest_xml())
|
||||
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
|
||||
assert client.list_projects() == {}
|
||||
|
||||
|
||||
def test_local_extend_project_with_superproject(
|
||||
host: GitHost, client: RepoClient
|
||||
) -> None:
|
||||
"""Local `extend-project` works with superproject overrides.
|
||||
|
||||
Regression test for 3c0e67b (b/470374343).
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "v1"})
|
||||
pinned = foo.rev_parse("main")
|
||||
host.create_repo("superproject", {"foo": Gitlink(pinned)})
|
||||
foo.commit({"f": "v2"})
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<superproject name="superproject" />',
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
)
|
||||
)
|
||||
client.init(host.manifest_url, "--use-superproject")
|
||||
local = client.repodir / "local_manifests"
|
||||
local.mkdir()
|
||||
(local / "local.xml").write_text(
|
||||
"<manifest>\n"
|
||||
' <extend-project name="platform/foo" groups="extra" />\n'
|
||||
"</manifest>\n"
|
||||
)
|
||||
|
||||
client.sync("--use-superproject")
|
||||
|
||||
assert client.head("foo") == pinned
|
||||
|
||||
|
||||
# 3af9e2f fixed both the regular & the --optimized-fetch skip-fetch checks.
|
||||
@pytest.mark.parametrize("sync_args", [[], ["--optimized-fetch"]])
|
||||
def test_same_project_shallow_pinned_paths(
|
||||
host: GitHost, client: RepoClient, sync_args: List[str]
|
||||
) -> None:
|
||||
"""One project at two paths pinned to SHAs with sync-c, one shallow.
|
||||
|
||||
The non-shallow path shares the shallow path's (depth-limited) object
|
||||
store, so its fetch must not be skipped as already up to date, or its
|
||||
history is left truncated.
|
||||
|
||||
Regression test for 3af9e2f (b/505072873).
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
host.create_manifest(manifest_xml())
|
||||
|
||||
def pin(rev: str) -> None:
|
||||
host.update_manifest(
|
||||
manifest_xml(
|
||||
f'<project name="platform/foo" path="a" revision="{rev}" '
|
||||
'clone-depth="1" />',
|
||||
f'<project name="platform/foo" path="b" revision="{rev}" />',
|
||||
default='remote="origin" revision="main" sync-c="true"',
|
||||
)
|
||||
)
|
||||
|
||||
pin(foo.commit({"f": "2"}))
|
||||
client.init(host.manifest_url)
|
||||
client.sync(*sync_args)
|
||||
|
||||
# Skip a commit so the shallow fetch alone would leave a gap in the shared
|
||||
# history.
|
||||
foo.commit({"f": "3"})
|
||||
latest = foo.commit({"f": "4"})
|
||||
pin(latest)
|
||||
client.sync(*sync_args)
|
||||
|
||||
assert client.is_shallow("a")
|
||||
for path in ("a", "b"):
|
||||
assert client.head(path) == latest
|
||||
# Fails to traverse the shared, truncated history if `path`'s fetch
|
||||
# was skipped.
|
||||
client.git(path, "rev-list", "HEAD")
|
||||
|
||||
|
||||
def test_shallow_pinned_with_upstream(
|
||||
host: GitHost, client: RepoClient
|
||||
) -> None:
|
||||
"""clone-depth + upstream + SHA revisions stay shallow.
|
||||
|
||||
The bug only triggered when the upstream branch was ahead of the pinned
|
||||
SHA, so each pin leaves `main` one commit ahead.
|
||||
|
||||
Regression test for d9cc0a1 (b/427093249).
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
host.create_manifest(manifest_xml())
|
||||
|
||||
def pin(rev: str) -> None:
|
||||
host.update_manifest(
|
||||
manifest_xml(
|
||||
f'<project name="platform/foo" revision="{rev}" '
|
||||
'upstream="main" clone-depth="1" />'
|
||||
)
|
||||
)
|
||||
|
||||
path = "platform/foo"
|
||||
pinned = foo.commit({"f": "2"})
|
||||
foo.commit({"f": "2 ahead"})
|
||||
pin(pinned)
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
assert client.head(path) == pinned
|
||||
assert client.git(path, "rev-list", "--count", "HEAD") == "1"
|
||||
|
||||
pinned = foo.commit({"f": "3"})
|
||||
foo.commit({"f": "3 ahead"})
|
||||
pin(pinned)
|
||||
client.sync()
|
||||
|
||||
assert client.head(path) == pinned
|
||||
assert client.git(path, "rev-list", "--count", "HEAD") == "1"
|
||||
|
||||
|
||||
def test_pinned_revision_refreshes_upstream(
|
||||
host: GitHost, client: RepoClient
|
||||
) -> None:
|
||||
"""Pinned SHAs already present locally still refresh the upstream ref.
|
||||
|
||||
Regression test for d27d682: when the pinned commit was already in the
|
||||
local object store (e.g. prefetched), `sync -c` skipped the fetch as an
|
||||
immutable revision, so the stale tracking ref of the upstream branch was
|
||||
never updated.
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
host.create_manifest(manifest_xml())
|
||||
|
||||
def pin(rev: str) -> None:
|
||||
host.update_manifest(
|
||||
manifest_xml(
|
||||
f'<project name="platform/foo" revision="{rev}" '
|
||||
'upstream="main" />',
|
||||
# The skip only happens when fetching the current branch only.
|
||||
default='remote="origin" revision="main" sync-c="true"',
|
||||
)
|
||||
)
|
||||
|
||||
initial = foo.rev_parse("main")
|
||||
pin(initial)
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
|
||||
path = "platform/foo"
|
||||
second = foo.commit({"f": "2"})
|
||||
latest = foo.commit({"f": "3"})
|
||||
# Emulate a background prefetch that doesn't touch the tracking refs.
|
||||
client.git(
|
||||
path, "fetch", "-q", "--refmap=", "origin", "+main:refs/prefetch/main"
|
||||
)
|
||||
assert client.has_object(path, second)
|
||||
assert client.git(path, "rev-parse", "refs/remotes/origin/main") == initial
|
||||
pin(second)
|
||||
client.sync()
|
||||
|
||||
assert client.head(path) == second
|
||||
assert client.git(path, "rev-parse", "refs/remotes/origin/main") == latest
|
||||
|
||||
|
||||
def test_nested_projects(host: GitHost, client: RepoClient) -> None:
|
||||
"""Nested project paths are checked out and removed in the right order.
|
||||
|
||||
Regression test for 4679022 (b/325119758).
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "foo", "bar/f": "old"})
|
||||
host.create_repo("platform/bar", {"f": "foo/bar"})
|
||||
host.create_repo("platform/foo-bar", {"f": "foo-bar"})
|
||||
# aaa shares foo/bar's project, so foo/bar is grouped with it & checked out
|
||||
# early unless the checkout order puts it after its parent foo.
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/bar" path="aaa" />',
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
'<project name="platform/foo-bar" path="foo-bar" />',
|
||||
)
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
|
||||
# Move foo's bar/f into the new nested project foo/bar.
|
||||
foo.commit({"bar/f": None})
|
||||
host.update_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/bar" path="aaa" />',
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
'<project name="platform/bar" path="foo/bar" />',
|
||||
'<project name="platform/foo-bar" path="foo-bar" />',
|
||||
)
|
||||
)
|
||||
client.sync("-j1")
|
||||
|
||||
for path in ("foo", "foo/bar", "foo-bar"):
|
||||
assert (client.top / path / "f").read_text() == path
|
||||
assert client.git("foo/bar", "status", "--porcelain") == ""
|
||||
|
||||
host.update_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
'<project name="platform/foo-bar" path="foo-bar" />',
|
||||
)
|
||||
)
|
||||
client.sync()
|
||||
|
||||
assert set(client.list_projects()) == {"foo", "foo-bar"}
|
||||
assert not (client.top / "foo/bar").exists()
|
||||
assert (client.top / "foo/f").read_text() == "foo"
|
||||
|
||||
|
||||
def test_copyfile_linkfile(host: GitHost, client: RepoClient) -> None:
|
||||
"""copyfile and linkfile are created, updated and cleaned up.
|
||||
|
||||
linkfile symlinks are relative (0184dcc) so trees can be moved.
|
||||
"""
|
||||
foo = host.create_repo(
|
||||
"platform/foo", {"copy.txt": "v1", "dir/link.txt": "link"}
|
||||
)
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" path="foo">'
|
||||
'<copyfile src="copy.txt" dest="copied.txt" />'
|
||||
'<linkfile src="dir/link.txt" dest="links/link.txt" />'
|
||||
"</project>"
|
||||
)
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
|
||||
link = client.top / "links/link.txt"
|
||||
# Python <3.8 on Windows rejects Path objects here.
|
||||
target = os.readlink(str(link))
|
||||
assert target == os.path.join("..", "foo", "dir", "link.txt")
|
||||
assert link.read_text() == "link"
|
||||
assert (client.top / "copied.txt").read_text() == "v1"
|
||||
|
||||
foo.commit({"copy.txt": "v2"})
|
||||
host.update_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" path="foo">'
|
||||
'<copyfile src="copy.txt" dest="copied.txt" />'
|
||||
"</project>"
|
||||
)
|
||||
)
|
||||
client.sync()
|
||||
|
||||
assert (client.top / "copied.txt").read_text() == "v2"
|
||||
assert not os.path.lexists(link)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sync_args", [[], ["--no-interleaved"]])
|
||||
def test_submodules(
|
||||
host: GitHost, client: RepoClient, sync_args: List[str]
|
||||
) -> None:
|
||||
"""sync-s checks out & updates submodules, incl. for repeated projects.
|
||||
|
||||
Regression tests for 4623264 (b/444366154, `git submodule init` in
|
||||
interleaved sync), 1b4e7a0 (the same repo checked out at multiple paths),
|
||||
and 3a6e25a/3f087a8 (b/550074864, submodules follow their parent's new
|
||||
revision in the same interleaved & phased sync).
|
||||
"""
|
||||
sub = host.create_repo("platform/sub", {"s": "1"})
|
||||
gitmodules = '[submodule "sub"]\n\tpath = sub\n\turl = ../sub\n'
|
||||
foo = host.create_repo(
|
||||
"platform/foo",
|
||||
{".gitmodules": gitmodules, "sub": Gitlink(sub.rev_parse("main"))},
|
||||
)
|
||||
paths = ("a", "b")
|
||||
# Track a branch, as gitlinks are only stale (b/550074864) when the
|
||||
# parent's revision resolves before it's fetched (unlike new pinned SHAs).
|
||||
# With sync-c, phased syncs skip fetching the stale (present) revision.
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
*(
|
||||
f'<project name="platform/foo" path="{p}" sync-s="true" '
|
||||
'sync-c="true" />'
|
||||
for p in paths
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
client.init(host.manifest_url)
|
||||
client.sync(*sync_args)
|
||||
for path in paths:
|
||||
assert (client.top / path / "sub/s").read_text() == "1"
|
||||
assert client.git(path, "config", "--get", "submodule.sub.url")
|
||||
|
||||
latest = foo.commit({"sub": Gitlink(sub.commit({"s": "2"}))})
|
||||
client.sync(*sync_args)
|
||||
|
||||
for path in paths:
|
||||
assert client.head(path) == latest
|
||||
assert (client.top / path / "sub/s").read_text() == "2"
|
||||
|
||||
|
||||
# The default layout reads HEAD from the ref cache instead of GetHead().
|
||||
@pytest.mark.parametrize("init_args", [[], ["--worktree"]])
|
||||
def test_prune_detached_head(
|
||||
host: GitHost, client: RepoClient, init_args: List[str]
|
||||
) -> None:
|
||||
"""`repo prune` handles projects with a detached HEAD.
|
||||
|
||||
Regression test for 7f7d70e (b/434077990).
|
||||
"""
|
||||
host.create_repo("platform/foo", {"f": "foo"})
|
||||
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
client.init(host.manifest_url, *init_args)
|
||||
client.sync()
|
||||
path = "platform/foo"
|
||||
client.run("start", "topic", "--all")
|
||||
client.git(path, "checkout", "-q", "--detach")
|
||||
|
||||
client.run("prune")
|
||||
|
||||
assert not client.git(path, "branch", "--list", "topic")
|
||||
|
||||
|
||||
def test_submanifest_groups(host: GitHost, client: RepoClient) -> None:
|
||||
"""Submanifests apply their own default groups.
|
||||
|
||||
Regression test for cc88be3 (b/382086294).
|
||||
"""
|
||||
for name in ("parent", "child", "child-extra"):
|
||||
host.create_repo(f"platform/{name}", {"f": name})
|
||||
host.create_repo(
|
||||
"platform/child-manifest",
|
||||
{
|
||||
"default.xml": manifest_xml(
|
||||
'<project name="platform/child" path="child" groups="g1" />',
|
||||
'<project name="platform/child-extra" path="extra" />',
|
||||
)
|
||||
},
|
||||
)
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/parent" path="parent" />',
|
||||
'<submanifest name="sub" project="platform/child-manifest" '
|
||||
'revision="main" path="sub" default-groups="g1" />',
|
||||
)
|
||||
)
|
||||
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
|
||||
assert client.list_projects() == {
|
||||
"parent": "platform/parent",
|
||||
"sub/child": "platform/child",
|
||||
}
|
||||
assert (client.top / "sub/child/f").read_text() == "child"
|
||||
assert not (client.top / "sub/extra").exists()
|
||||
@@ -0,0 +1,294 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""E2E tests for partial clones, incl. the standard Android configuration."""
|
||||
|
||||
import os
|
||||
from typing import Dict, List, NamedTuple, Tuple
|
||||
|
||||
from harness import GIT_DEFAULTS_PARAMS
|
||||
from harness import GitHost
|
||||
from harness import Gitlink
|
||||
from harness import manifest_xml
|
||||
from harness import RepoClient
|
||||
from harness import ServerRepo
|
||||
import pytest
|
||||
|
||||
|
||||
# The go/repo-init flags. The filter is 10M there; 10k keeps the test data
|
||||
# small without changing the semantics.
|
||||
ANDROID_INIT_ARGS = [
|
||||
"-c",
|
||||
"-b",
|
||||
"main",
|
||||
"--use-superproject",
|
||||
"--partial-clone",
|
||||
"--partial-clone-exclude=platform/frameworks/base",
|
||||
"--clone-filter=blob:limit=10k",
|
||||
]
|
||||
|
||||
# The go/sync flags.
|
||||
ANDROID_SYNC_ARGS = ["-c", "-j20", "--no-tags", "--optimized-fetch", "--prune"]
|
||||
|
||||
# Larger than the 10k filter above, smaller than git's default big file
|
||||
# threshold so it's still delta compressed & packed normally.
|
||||
BIG = "x" * 20_000
|
||||
|
||||
# The git config key holding a partial clone's filter.
|
||||
FILTER_KEY = "remote.origin.partialclonefilter"
|
||||
|
||||
|
||||
def create_with_big_history(host: GitHost, name: str) -> Tuple[ServerRepo, str]:
|
||||
"""Create `name` with a large blob that only exists in history.
|
||||
|
||||
Returns:
|
||||
The repo and the id of the historical large blob. The tip commit has
|
||||
a different large blob (`big2.bin`) and a small file.
|
||||
"""
|
||||
repo = host.create_repo(name, {"big.bin": BIG, "small": "s"})
|
||||
big_blob = repo.git("rev-parse", "main:big.bin")
|
||||
repo.commit({"big.bin": None, "big2.bin": BIG + "2"})
|
||||
return repo, big_blob
|
||||
|
||||
|
||||
class AndroidTree(NamedTuple):
|
||||
"""The server side of `create_android_tree`, keyed by project path."""
|
||||
|
||||
repos: Dict[str, ServerRepo]
|
||||
superproject: ServerRepo
|
||||
# The commit the superproject pins each project to.
|
||||
pins: Dict[str, str]
|
||||
# A large blob that only exists in each project's history.
|
||||
blobs: Dict[str, str]
|
||||
|
||||
|
||||
def create_android_tree(host: GitHost) -> AndroidTree:
|
||||
"""Create a small AOSP-like tree with a superproject.
|
||||
|
||||
`platform/foo` is a regular project and `platform/frameworks/base` is the
|
||||
one go/repo-init excludes from the partial clone. Both have moved past
|
||||
the superproject's pins.
|
||||
"""
|
||||
repos, pins, blobs = {}, {}, {}
|
||||
for path in ("foo", "frameworks/base"):
|
||||
name = f"platform/{path}"
|
||||
repos[path], blobs[path] = create_with_big_history(host, name)
|
||||
pins[path] = repos[path].rev_parse("main")
|
||||
superproject = host.create_repo(
|
||||
"platform/superproject",
|
||||
{path: Gitlink(rev) for path, rev in pins.items()},
|
||||
)
|
||||
for repo in repos.values():
|
||||
repo.commit({"small": "ahead"})
|
||||
repo.commit({"small": "other"}, branch="other")
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<superproject name="platform/superproject" />',
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
'<project name="platform/frameworks/base" '
|
||||
'path="frameworks/base" />',
|
||||
)
|
||||
)
|
||||
return AndroidTree(repos, superproject, pins, blobs)
|
||||
|
||||
|
||||
def bundle_requests(host: GitHost) -> Dict[str, List[int]]:
|
||||
"""Map repo name to the status codes of its `clone.bundle` requests."""
|
||||
result: Dict[str, List[int]] = {}
|
||||
for r in host.requests():
|
||||
if r.path.endswith("/clone.bundle"):
|
||||
name = r.path[len(f"/{host.prefix}/") : -len("/clone.bundle")]
|
||||
result.setdefault(name, []).append(r.status)
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||
def test_android_init_sync(host: GitHost, client: RepoClient) -> None:
|
||||
"""The go/repo-init flag combination followed by `repo sync -c -j32`.
|
||||
|
||||
Covers superproject pinning, `-c`, the clone filter on included projects,
|
||||
no filter on the `--partial-clone-exclude`d project, and that historical
|
||||
large blobs stay on the server while small historical blobs are fetched.
|
||||
"""
|
||||
tree = create_android_tree(host)
|
||||
small_history_blob = tree.repos["foo"].commit_history_only_blob(
|
||||
"small_old.txt", b"small history"
|
||||
)
|
||||
new_foo_pin = tree.repos["foo"].rev_parse("main")
|
||||
tree.superproject.commit({"foo": Gitlink(new_foo_pin)})
|
||||
tree.repos["foo"].commit({"small": "unpinned"})
|
||||
pins = dict(tree.pins, foo=new_foo_pin)
|
||||
|
||||
client.init(host.manifest_url, *ANDROID_INIT_ARGS)
|
||||
assert client.sync("-c", "-j32").stderr == ""
|
||||
|
||||
# init persisted the settings later syncs depend on.
|
||||
manifests = ".repo/manifests"
|
||||
assert client.config(manifests, "repo.superproject") == "true"
|
||||
assert client.config(manifests, "repo.partialclone") == "true"
|
||||
assert client.config(manifests, "repo.clonefilter") == "blob:limit=10k"
|
||||
assert (
|
||||
client.config(manifests, "repo.partialcloneexclude")
|
||||
== "platform/frameworks/base"
|
||||
)
|
||||
for path, pinned in pins.items():
|
||||
assert client.head(path) == pinned
|
||||
assert (client.top / path / "big2.bin").read_text() == BIG + "2"
|
||||
assert client.remote_branches(path) == {"main"}
|
||||
override = client.repodir / "exp-superproject/superproject_override.xml"
|
||||
assert override.exists()
|
||||
# The included project is a partial clone (git normalizes the size), so
|
||||
# the large historical blob was never downloaded, while the small
|
||||
# historical blob (under the 10k limit) was downloaded.
|
||||
assert client.config("foo", "extensions.partialclone") == "origin"
|
||||
assert client.config("foo", FILTER_KEY) == "blob:limit=10240"
|
||||
assert not client.has_object("foo", tree.blobs["foo"])
|
||||
assert client.has_object("foo", small_history_blob)
|
||||
# The excluded project is a full clone.
|
||||
assert client.config("frameworks/base", "extensions.partialclone") is None
|
||||
assert client.config("frameworks/base", FILTER_KEY) is None
|
||||
assert client.has_object("frameworks/base", tree.blobs["frameworks/base"])
|
||||
# Partial clones disable clone bundles, but repo still requests one for
|
||||
# excluded projects (this server has none).
|
||||
assert bundle_requests(host) == {"platform/frameworks/base": [404]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||
def test_android_sync_flags(host: GitHost, client: RepoClient) -> None:
|
||||
"""The go/sync flags on an initial & an incremental sync.
|
||||
|
||||
`-c -jN --no-tags --optimized-fetch --prune` on top of the go/repo-init
|
||||
configuration. The incremental sync must fetch a project whose pin moved
|
||||
and skip one whose pin didn't.
|
||||
"""
|
||||
tree = create_android_tree(host)
|
||||
tree.repos["foo"].tag("v1", tree.pins["foo"])
|
||||
|
||||
client.init(host.manifest_url, *ANDROID_INIT_ARGS)
|
||||
assert client.sync(*ANDROID_SYNC_ARGS).stderr == ""
|
||||
|
||||
for path, pinned in tree.pins.items():
|
||||
assert client.head(path) == pinned
|
||||
assert client.remote_branches(path) == {"main"}
|
||||
assert client.tags(path) == set()
|
||||
|
||||
# Move foo's pin forward (on main) & tag it; frameworks/base keeps its pin.
|
||||
new_pin = tree.repos["foo"].commit({"small": "new pin"})
|
||||
tree.repos["foo"].tag("v2", new_pin)
|
||||
tree.superproject.commit({"foo": Gitlink(new_pin)})
|
||||
with host.watch_requests() as requests:
|
||||
assert client.sync(*ANDROID_SYNC_ARGS).stderr == ""
|
||||
|
||||
assert client.head("foo") == new_pin
|
||||
assert client.head("frameworks/base") == tree.pins["frameworks/base"]
|
||||
assert client.tags("foo") == set()
|
||||
assert (client.top / "foo/small").read_text() == "new pin"
|
||||
# A project already at its (immutable) pin isn't fetched.
|
||||
assert host.requested_repos(requests) == {
|
||||
"platform/manifest",
|
||||
"platform/superproject",
|
||||
"platform/foo",
|
||||
}
|
||||
|
||||
|
||||
def test_partial_clone_default_filter(
|
||||
host: GitHost, client: RepoClient
|
||||
) -> None:
|
||||
"""`--partial-clone` alone uses `blob:none` & disables clone bundles."""
|
||||
foo, big_blob = create_with_big_history(host, "platform/foo")
|
||||
small_history = foo.commit_history_only_blob("old_small.txt", b"tiny")
|
||||
small_tip = foo.git("rev-parse", "main:small")
|
||||
foo.write_bundle()
|
||||
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
|
||||
client.init(host.manifest_url, "--partial-clone")
|
||||
client.sync()
|
||||
|
||||
path = "platform/foo"
|
||||
assert client.config(".repo/manifests", "repo.partialclone") == "true"
|
||||
assert client.config(".repo/manifests", "repo.clonefilter") == "blob:none"
|
||||
assert client.config(".repo/manifests", FILTER_KEY) == "blob:none"
|
||||
assert client.config(path, FILTER_KEY) == "blob:none"
|
||||
assert client.config(path, "extensions.partialclone") == "origin"
|
||||
# Blobs of the checked out tree were fetched on demand; history wasn't
|
||||
# (even tiny blobs).
|
||||
assert (client.top / path / "big2.bin").read_text() == BIG + "2"
|
||||
assert client.has_object(path, small_tip)
|
||||
assert not client.has_object(path, big_blob)
|
||||
assert not client.has_object(path, small_history)
|
||||
assert bundle_requests(host) == {}
|
||||
|
||||
|
||||
def test_partial_clone_two_phase_sync(
|
||||
host: GitHost, client: RepoClient
|
||||
) -> None:
|
||||
"""`sync -n` then `sync -l` with a partial clone (initial & incremental).
|
||||
|
||||
The network phase fetches no blobs, so the local phase has to fetch the
|
||||
checked out blobs on demand: `-l` isn't offline for partial clones. When
|
||||
the server is unreachable, `sync -l` fails cleanly & can be retried.
|
||||
"""
|
||||
foo, big_blob = create_with_big_history(host, "platform/foo")
|
||||
small_blob = foo.git("rev-parse", "main:small")
|
||||
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
client.init(host.manifest_url, "--partial-clone")
|
||||
|
||||
assert client.sync("-n").stderr == ""
|
||||
path = "platform/foo"
|
||||
assert not (client.top / path).exists()
|
||||
gitdir = f"--git-dir={client.repodir / 'projects' / path}.git"
|
||||
assert client.git(
|
||||
".", gitdir, "rev-parse", "refs/remotes/origin/main"
|
||||
) == foo.rev_parse("main")
|
||||
objects = client.git(
|
||||
".",
|
||||
gitdir,
|
||||
"cat-file",
|
||||
"--batch-check=%(objectname)",
|
||||
"--batch-all-objects",
|
||||
).split()
|
||||
assert foo.rev_parse("main") in objects
|
||||
assert small_blob not in objects
|
||||
|
||||
# When the server is offline, `sync -l` fails cleanly without leaving a
|
||||
# broken worktree behind.
|
||||
with host.offline():
|
||||
failed = client.sync("-l", check=False)
|
||||
assert failed.returncode != 0
|
||||
assert "Cannot initialize work tree for platform/foo" in failed.stderr
|
||||
assert not os.path.lexists(client.top / path / ".git")
|
||||
|
||||
with host.watch_requests() as requests:
|
||||
client.sync("-l")
|
||||
|
||||
assert client.head(path) == foo.rev_parse("main")
|
||||
assert (client.top / path / "big2.bin").read_text() == BIG + "2"
|
||||
assert client.has_object(path, small_blob)
|
||||
assert not client.has_object(path, big_blob)
|
||||
# The on-demand blob fetches are the only traffic of the local sync.
|
||||
assert host.requested_repos(requests) == {"platform/foo"}
|
||||
|
||||
# Incremental `-n` then `-l` on the existing partial-clone checkout.
|
||||
v2 = foo.commit({"small": "s2"})
|
||||
s2_blob = foo.git("rev-parse", "main:small")
|
||||
client.sync("-n")
|
||||
assert (client.top / path / "small").read_text() == "s"
|
||||
assert not client.has_object(path, s2_blob)
|
||||
|
||||
with host.watch_requests() as inc_requests:
|
||||
client.sync("-l")
|
||||
assert client.head(path) == v2
|
||||
assert (client.top / path / "small").read_text() == "s2"
|
||||
assert client.has_object(path, s2_blob)
|
||||
assert host.requested_repos(inc_requests) == {"platform/foo"}
|
||||
@@ -0,0 +1,339 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""E2E tests for `repo sync` modes (`-n`, `-l`) & sync flags."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from harness import GIT_DEFAULTS_PARAMS
|
||||
from harness import GitHost
|
||||
from harness import Gitlink
|
||||
from harness import manifest_xml
|
||||
from harness import RepoClient
|
||||
import pytest
|
||||
|
||||
|
||||
# Interleaved (the default) & phased syncs implement `-n` & `-l` separately.
|
||||
SYNC_MODES = [[], ["--no-interleaved"]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode_args", SYNC_MODES, ids=["interleaved", "phased"])
|
||||
def test_network_only_then_local_only(
|
||||
host: GitHost, client: RepoClient, mode_args: List[str]
|
||||
) -> None:
|
||||
"""`sync -n` on an existing checkout updates objects & remote refs only.
|
||||
|
||||
The worktree, HEAD & local branches don't change until `sync -l`, which
|
||||
checks out what `-n` fetched without touching the network.
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
bar = host.create_repo("platform/bar", {"f": "1"})
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
'<project name="platform/bar" path="bar" />',
|
||||
)
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
client.sync(*mode_args)
|
||||
old_foo = foo.rev_parse("main")
|
||||
# Local work on a topic branch in bar.
|
||||
client.run("start", "topic", "bar")
|
||||
(client.top / "bar/local").write_text("local")
|
||||
client.git("bar", "add", "local")
|
||||
client.git("bar", "commit", "-q", "-m", "local change")
|
||||
local_commit = client.head("bar")
|
||||
fetched = {"foo": foo.commit({"f": "2"}), "bar": bar.commit({"f": "2"})}
|
||||
|
||||
assert client.sync("-n", *mode_args).stderr == ""
|
||||
|
||||
assert client.head("foo") == old_foo
|
||||
assert (client.top / "foo/f").read_text() == "1"
|
||||
assert client.head("bar") == local_commit
|
||||
assert client.git("bar", "symbolic-ref", "--short", "HEAD") == "topic"
|
||||
for path, commit in fetched.items():
|
||||
assert client.has_object(path, commit)
|
||||
assert client.git(path, "rev-parse", "refs/remotes/origin/main") == (
|
||||
commit
|
||||
)
|
||||
assert client.git(path, "status", "--porcelain") == ""
|
||||
|
||||
# The server moves on, but `-l` must only use what `-n` fetched.
|
||||
foo.commit({"f": "3"})
|
||||
with host.watch_requests() as requests, host.offline():
|
||||
client.sync("-l", *mode_args)
|
||||
|
||||
assert requests == []
|
||||
assert client.head("foo") == fetched["foo"]
|
||||
assert (client.top / "foo/f").read_text() == "2"
|
||||
# The local commit on `bar` was rebased onto what `-n` fetched.
|
||||
assert client.git("bar", "symbolic-ref", "--short", "HEAD") == "topic"
|
||||
assert client.git("bar", "rev-parse", "HEAD~1") == fetched["bar"]
|
||||
assert (client.top / "bar/local").read_text() == "local"
|
||||
|
||||
|
||||
def test_local_only_unfetched_project(
|
||||
host: GitHost, client: RepoClient
|
||||
) -> None:
|
||||
"""`sync -l` fails clearly for a project that was never fetched.
|
||||
|
||||
The other projects are still checked out, no network requests are made,
|
||||
and the error says how to fix it.
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
host.create_repo("platform/bar", {"f": "bar"})
|
||||
host.create_manifest(
|
||||
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
v2 = foo.commit({"f": "2"})
|
||||
host.update_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
'<project name="platform/bar" path="bar" />',
|
||||
)
|
||||
)
|
||||
# Update the manifest & fetch foo, but not the new project.
|
||||
client.sync("-n", "platform/foo")
|
||||
assert not (client.repodir / "projects/bar.git").exists()
|
||||
|
||||
with host.watch_requests() as requests, host.offline():
|
||||
result = client.sync("-l", check=False)
|
||||
|
||||
assert requests == []
|
||||
assert result.returncode != 0
|
||||
assert (
|
||||
"Cannot checkout platform/bar due to missing network sync; Run "
|
||||
"`repo sync -n platform/bar` first." in result.stderr
|
||||
)
|
||||
assert not (client.top / "bar").exists()
|
||||
# The already-fetched project still got checked out.
|
||||
assert client.head("foo") == v2
|
||||
assert (client.top / "foo/f").read_text() == "2"
|
||||
|
||||
# Fetching the missing project fixes it.
|
||||
client.sync("-n", "platform/bar")
|
||||
with host.watch_requests() as requests, host.offline():
|
||||
client.sync("-l")
|
||||
assert requests == []
|
||||
assert (client.top / "bar/f").read_text() == "bar"
|
||||
assert client.list_projects() == {
|
||||
"bar": "platform/bar",
|
||||
"foo": "platform/foo",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||
def test_two_phase_sync_superproject(host: GitHost, client: RepoClient) -> None:
|
||||
"""`sync -n` pins projects via the superproject; `sync -l` reuses the pins.
|
||||
|
||||
The local phase must not hit the network, even to re-read the
|
||||
superproject after the server's superproject moved on.
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "v1"})
|
||||
pinned = foo.rev_parse("main")
|
||||
superproject = host.create_repo("superproject", {"foo": Gitlink(pinned)})
|
||||
foo.commit({"f": "v2"})
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<superproject name="superproject" />',
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
)
|
||||
)
|
||||
client.init(host.manifest_url, "--use-superproject")
|
||||
|
||||
assert client.sync("-n").stderr == ""
|
||||
assert not (client.top / "foo").exists()
|
||||
assert (
|
||||
client.repodir / "exp-superproject/superproject_override.xml"
|
||||
).exists()
|
||||
|
||||
# Advance the superproject on the server before running `sync -l`.
|
||||
new_pin = foo.commit({"f": "v3"})
|
||||
superproject.commit({"foo": Gitlink(new_pin)})
|
||||
|
||||
with host.watch_requests() as requests, host.offline():
|
||||
client.sync("-l")
|
||||
|
||||
assert requests == []
|
||||
assert client.head("foo") == pinned
|
||||
assert (client.top / "foo/f").read_text() == "v1"
|
||||
|
||||
# The next two-phase sync picks up the new superproject pin.
|
||||
client.sync("-n")
|
||||
assert client.head("foo") == pinned
|
||||
with host.watch_requests() as requests, host.offline():
|
||||
client.sync("-l")
|
||||
assert requests == []
|
||||
assert client.head("foo") == new_pin
|
||||
assert (client.top / "foo/f").read_text() == "v3"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sync_args,project_attrs,expect_tags",
|
||||
[
|
||||
# Despite `repo sync --help` calling `--no-tags` the default.
|
||||
([], "", True),
|
||||
# `-c` only limits branches: tags on other branches are still fetched
|
||||
# unless `--no-tags` is passed.
|
||||
(["-c"], "", True),
|
||||
(["--tags"], "", True),
|
||||
(["-c", "--no-tags"], "", False),
|
||||
(["-c"], 'sync-tags="false"', False),
|
||||
(["-c", "--tags"], 'sync-tags="false"', True),
|
||||
],
|
||||
)
|
||||
def test_tags(
|
||||
host: GitHost,
|
||||
client: RepoClient,
|
||||
sync_args: List[str],
|
||||
project_attrs: str,
|
||||
expect_tags: bool,
|
||||
) -> None:
|
||||
"""Tags are fetched unless `--no-tags` or the manifest says `sync-tags`."""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
foo.tag("v1", "main")
|
||||
# A tag that isn't on the synced branch, like release tags usually are.
|
||||
foo.commit({"f": "release"}, branch="release")
|
||||
foo.tag("v2", "release")
|
||||
host.create_manifest(
|
||||
manifest_xml(f'<project name="platform/foo" {project_attrs} />')
|
||||
)
|
||||
|
||||
client.init(host.manifest_url)
|
||||
client.sync(*sync_args)
|
||||
|
||||
assert client.tags("platform/foo") == (
|
||||
{"v1", "v2"} if expect_tags else set()
|
||||
)
|
||||
expected_branches = {"main"} if "-c" in sync_args else {"main", "release"}
|
||||
assert client.remote_branches("platform/foo") == expected_branches
|
||||
|
||||
|
||||
def test_prune(host: GitHost, client: RepoClient) -> None:
|
||||
"""Remote-tracking branches deleted on the server are pruned by default.
|
||||
|
||||
`--no-prune` keeps them.
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
foo.commit({"f": "stale"}, branch="stale")
|
||||
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
path = "platform/foo"
|
||||
assert client.remote_branches(path) == {"main", "stale"}
|
||||
|
||||
foo.git("update-ref", "-d", "refs/heads/stale")
|
||||
client.sync("--no-prune")
|
||||
assert client.remote_branches(path) == {"main", "stale"}
|
||||
|
||||
client.sync()
|
||||
assert client.remote_branches(path) == {"main"}
|
||||
|
||||
|
||||
def test_force_sync(host: GitHost, client: RepoClient) -> None:
|
||||
"""`--force-sync` re-creates a path whose project changed in the manifest.
|
||||
|
||||
The old gitdir points at another project's object dir, which sync refuses
|
||||
to overwrite unless forced.
|
||||
"""
|
||||
host.create_repo("platform/foo", {"f": "foo"})
|
||||
foo_v2 = host.create_repo("platform/foo-v2", {"f": "foo-v2"})
|
||||
host.create_manifest(
|
||||
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
host.update_manifest(
|
||||
manifest_xml('<project name="platform/foo-v2" path="foo" />')
|
||||
)
|
||||
|
||||
result = client.sync(check=False)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "--force-sync not enabled" in result.stderr
|
||||
assert "`repo sync --force-sync foo`" in result.stderr
|
||||
assert (client.top / "foo/f").read_text() == "foo"
|
||||
|
||||
client.sync("--force-sync")
|
||||
|
||||
assert client.head("foo") == foo_v2.rev_parse("main")
|
||||
assert (client.top / "foo/f").read_text() == "foo-v2"
|
||||
assert client.list_projects() == {"foo": "platform/foo-v2"}
|
||||
assert client.git("foo", "status", "--porcelain") == ""
|
||||
|
||||
|
||||
def test_force_remove_dirty(host: GitHost, client: RepoClient) -> None:
|
||||
"""Removed projects with uncommitted changes need `--force-remove-dirty`."""
|
||||
host.create_repo("platform/foo", {"f": "foo"})
|
||||
host.create_repo("platform/bar", {"f": "bar"})
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
'<project name="platform/bar" path="bar" />',
|
||||
)
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
(client.top / "bar/f").write_text("dirty")
|
||||
host.update_manifest(
|
||||
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||
)
|
||||
|
||||
# Repeat to check that the project stays tracked in project.list (rather
|
||||
# than forgotten & left behind) after the first failure.
|
||||
for _ in range(2):
|
||||
result = client.sync(check=False)
|
||||
assert result.returncode != 0
|
||||
assert (
|
||||
"bar: Cannot remove project: uncommitted changes are present"
|
||||
in result.stderr
|
||||
)
|
||||
assert (client.top / "bar/f").read_text() == "dirty"
|
||||
|
||||
result = client.sync("--force-remove-dirty")
|
||||
assert "bar: Removing dirty project" in result.stderr
|
||||
assert not (client.top / "bar").exists()
|
||||
assert client.list_projects() == {"foo": "platform/foo"}
|
||||
|
||||
|
||||
def test_detach(host: GitHost, client: RepoClient) -> None:
|
||||
"""`sync -d` detaches projects on a topic branch at the manifest revision.
|
||||
|
||||
The topic branch & its local commit are left alone.
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
host.create_manifest(
|
||||
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
client.run("start", "topic", "--all")
|
||||
(client.top / "foo/local").write_text("local")
|
||||
client.git("foo", "add", "local")
|
||||
client.git("foo", "commit", "-q", "-m", "local change")
|
||||
local = client.head("foo")
|
||||
latest = foo.commit({"f": "2"})
|
||||
|
||||
client.sync("-d")
|
||||
|
||||
assert client.is_detached("foo")
|
||||
assert client.head("foo") == latest
|
||||
assert (client.top / "foo/f").read_text() == "2"
|
||||
assert not (client.top / "foo/local").exists()
|
||||
assert client.git("foo", "rev-parse", "topic") == local
|
||||
# A regular sync afterwards stays detached.
|
||||
client.sync()
|
||||
assert client.is_detached("foo")
|
||||
@@ -0,0 +1,261 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""E2E tests for multi-step workflows, many from past point releases."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
from harness import GIT_DEFAULTS_PARAMS
|
||||
from harness import GitHost
|
||||
from harness import manifest_xml
|
||||
from harness import RepoClient
|
||||
from harness import run_git
|
||||
from harness import ServerRepo
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||
@pytest.mark.parametrize(
|
||||
"project_attrs",
|
||||
["", 'clone-depth="1"', 'sync-strategy="stateless"'],
|
||||
)
|
||||
def test_two_phase_sync(
|
||||
host: GitHost, client: RepoClient, project_attrs: str
|
||||
) -> None:
|
||||
"""`repo sync -n` then `repo sync -l` before worktrees exist.
|
||||
|
||||
Regression tests for b/565047698 (v2.68.1, incl. e1e215a for stateless
|
||||
syncs).
|
||||
"""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
f'<project name="platform/foo" path="foo" {project_attrs} />'
|
||||
)
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
|
||||
# The b/565047698 regression only logs a warning & still exits 0, so also
|
||||
# require a clean stderr.
|
||||
assert client.sync("-n").stderr == ""
|
||||
assert not (client.top / "foo").exists()
|
||||
# A second network-only sync (after upstream moved) with no worktree yet.
|
||||
latest = foo.commit({"f": "2"})
|
||||
assert client.sync("-n").stderr == ""
|
||||
assert not (client.top / "foo").exists()
|
||||
|
||||
client.sync("-l")
|
||||
assert client.head("foo") == latest
|
||||
assert (client.top / "foo/f").read_text() == "2"
|
||||
|
||||
# And the checkout keeps working with regular syncs afterwards.
|
||||
latest = foo.commit({"f": "3"})
|
||||
client.sync()
|
||||
assert client.head("foo") == latest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra_init_args", [[], ["--manifest-upstream-branch", "main"]]
|
||||
)
|
||||
def test_init_manifest_commit(
|
||||
host: GitHost, client: RepoClient, extra_init_args: List[str]
|
||||
) -> None:
|
||||
"""`repo init -b <sha>` pins the manifest to a raw commit.
|
||||
|
||||
Regression test for b/544041102 (v2.66.1).
|
||||
"""
|
||||
host.create_repo("platform/foo", {"f": "foo"})
|
||||
host.create_repo("platform/bar", {"f": "bar"})
|
||||
manifest = host.create_manifest(
|
||||
manifest_xml('<project name="platform/foo" />')
|
||||
)
|
||||
pinned = manifest.rev_parse("main")
|
||||
host.update_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" />',
|
||||
'<project name="platform/bar" />',
|
||||
)
|
||||
)
|
||||
|
||||
client.init(host.manifest_url, "-b", pinned, *extra_init_args)
|
||||
client.sync()
|
||||
|
||||
assert client.list_projects() == {"platform/foo": "platform/foo"}
|
||||
assert client.git(".repo/manifests", "rev-parse", "HEAD") == pinned
|
||||
|
||||
|
||||
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||
def test_mirror(host: GitHost, client: RepoClient) -> None:
|
||||
"""`repo init --mirror && repo sync` creates bare mirrors (v2.57.1)."""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
foo.commit({"f": "other"}, branch="other")
|
||||
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
|
||||
client.init(host.manifest_url, "--mirror")
|
||||
client.sync()
|
||||
|
||||
mirror = client.top / "platform/foo.git"
|
||||
|
||||
def mirror_rev(ref: str) -> str:
|
||||
return run_git(f"--git-dir={mirror}", "rev-parse", ref, env=client.env)
|
||||
|
||||
assert not (client.top / "platform/foo").exists()
|
||||
assert mirror_rev("refs/heads/main") == foo.rev_parse("main")
|
||||
assert mirror_rev("refs/heads/other") == foo.rev_parse("other")
|
||||
|
||||
latest = foo.commit({"f": "2"})
|
||||
client.sync()
|
||||
assert mirror_rev("refs/heads/main") == latest
|
||||
|
||||
|
||||
def test_incremental_sync(host: GitHost, client: RepoClient) -> None:
|
||||
"""Syncs pick up new commits, new projects & removed projects."""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
host.create_repo("platform/bar", {"f": "bar"})
|
||||
host.create_repo("platform/baz", {"f": "baz"})
|
||||
host.create_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
'<project name="platform/bar" path="bar" />',
|
||||
)
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
|
||||
latest = foo.commit({"f": "2", "new": "new"})
|
||||
host.update_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
'<project name="platform/baz" path="baz" />',
|
||||
)
|
||||
)
|
||||
client.sync()
|
||||
|
||||
assert client.head("foo") == latest
|
||||
assert (client.top / "foo/new").read_text() == "new"
|
||||
assert (client.top / "baz/f").read_text() == "baz"
|
||||
assert not (client.top / "bar").exists()
|
||||
assert client.list_projects() == {
|
||||
"baz": "platform/baz",
|
||||
"foo": "platform/foo",
|
||||
}
|
||||
|
||||
|
||||
def test_rebase_local_work(host: GitHost, client: RepoClient) -> None:
|
||||
"""Local commits on a `repo start` branch are rebased by sync."""
|
||||
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
|
||||
path = "platform/foo"
|
||||
client.run("start", "topic", "--all")
|
||||
(client.top / path / "local").write_text("local")
|
||||
client.git(path, "add", "local")
|
||||
client.git(path, "commit", "-q", "-m", "local change")
|
||||
latest = foo.commit({"f": "2"})
|
||||
|
||||
client.sync()
|
||||
|
||||
assert client.git(path, "symbolic-ref", "--short", "HEAD") == "topic"
|
||||
assert client.git(path, "rev-parse", "HEAD~1") == latest
|
||||
assert (client.top / path / "local").read_text() == "local"
|
||||
|
||||
|
||||
def test_switch_manifest_branch(host: GitHost, client: RepoClient) -> None:
|
||||
"""Re-running `repo init -b` on an existing checkout switches branches."""
|
||||
foo = host.create_repo("platform/foo", {"f": "main"})
|
||||
release = foo.commit({"f": "release"}, branch="release")
|
||||
host.create_manifest(
|
||||
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||
)
|
||||
host.update_manifest(
|
||||
manifest_xml(
|
||||
'<project name="platform/foo" path="foo" />',
|
||||
default='remote="origin" revision="release"',
|
||||
),
|
||||
branch="release",
|
||||
)
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
assert client.head("foo") == foo.rev_parse("main")
|
||||
|
||||
client.init(host.manifest_url, "-b", "release")
|
||||
client.sync()
|
||||
|
||||
assert client.head("foo") == release
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
["selfupdate", "--no-repo-verify"],
|
||||
["sync", "--no-repo-verify"],
|
||||
],
|
||||
)
|
||||
def test_selfupdate(
|
||||
host: GitHost,
|
||||
client: RepoClient,
|
||||
repo_source: ServerRepo,
|
||||
command: List[str],
|
||||
) -> None:
|
||||
"""repo upgrades itself when its source moves forward."""
|
||||
source = host.fork(repo_source, "git-repo")
|
||||
client.repo_url = source.url
|
||||
host.create_repo("platform/foo", {"f": "foo"})
|
||||
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
# Start from an existing synced checkout before publishing the repo update.
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
|
||||
latest = source.commit({"E2E_SELFUPDATE_MARKER": "new"})
|
||||
# sync only checks for updates once a day.
|
||||
fetch_head = client.repodir / "repo/.git/FETCH_HEAD"
|
||||
old = time.time() - 2 * 24 * 60 * 60
|
||||
os.utime(fetch_head, (old, old))
|
||||
|
||||
result = client.run(*command)
|
||||
|
||||
assert "A new version of repo is available" in result.stderr
|
||||
assert client.git(".repo/repo", "rev-parse", "HEAD") == latest
|
||||
assert (client.repodir / "repo/E2E_SELFUPDATE_MARKER").exists()
|
||||
# The restarted (upgraded) repo keeps working.
|
||||
assert "v99.0-e2e-1-g" in client.run("version").stdout
|
||||
|
||||
|
||||
def test_commands_after_sync(host: GitHost, client: RepoClient) -> None:
|
||||
"""Common read-only commands work on a freshly synced checkout."""
|
||||
host.create_repo("platform/foo", {"f": "foo"})
|
||||
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||
client.init(host.manifest_url)
|
||||
client.sync()
|
||||
|
||||
for command, expected in (
|
||||
(["status"], r"nothing to commit"),
|
||||
(["info"], r"Manifest branch: main"),
|
||||
(["branches"], r"\(no branches\)"),
|
||||
# No local changes, so no output.
|
||||
(["diff"], r"\A\Z"),
|
||||
(["forall", "-c", "git", "log", "-1"], r"platform/foo: initial"),
|
||||
# Projects are pinned to commit ids.
|
||||
(["manifest", "-r"], r'revision="[0-9a-f]{40}"'),
|
||||
):
|
||||
output = client.run(*command).output
|
||||
assert re.search(expected, output), (
|
||||
f"`repo {' '.join(command)}` output {output!r} "
|
||||
f"did not match {expected!r}"
|
||||
)
|
||||
+234
-4
@@ -14,6 +14,8 @@
|
||||
|
||||
"""Unittests for the command.py module."""
|
||||
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from command import Command
|
||||
@@ -30,12 +32,15 @@ class FakeProject:
|
||||
gitdir=None,
|
||||
derived_subprojects=None,
|
||||
sync_s=False,
|
||||
exists: bool = True,
|
||||
):
|
||||
self.name = name
|
||||
self.relpath = relpath
|
||||
self.worktree = f"/work/{relpath}"
|
||||
self.manifest = None
|
||||
self.gitdir = gitdir or f"/git/{relpath}"
|
||||
self.sync_s = sync_s
|
||||
self.Exists = True
|
||||
self.Exists = exists
|
||||
self._derived_subprojects = derived_subprojects or []
|
||||
|
||||
def GetDerivedSubprojects(self):
|
||||
@@ -51,11 +56,59 @@ class FakeProject:
|
||||
class FakeManifest:
|
||||
"""Minimal manifest double for Command.GetProjects tests."""
|
||||
|
||||
def __init__(self, projects):
|
||||
self.projects = projects
|
||||
def __init__(
|
||||
self,
|
||||
projects: Iterable[FakeProject],
|
||||
*,
|
||||
all_projects: Optional[Iterable[FakeProject]] = None,
|
||||
effective_groups: str = "default",
|
||||
):
|
||||
self.projects = list(projects)
|
||||
self.all_projects = (
|
||||
list(self.projects) if all_projects is None else list(all_projects)
|
||||
)
|
||||
self._effective_groups = effective_groups
|
||||
self.path_prefix = ""
|
||||
|
||||
# all_projects may include projects owned by child manifests,
|
||||
# so only set this manifest on its direct projects.
|
||||
for project in self.projects:
|
||||
self._set_project_manifest(project)
|
||||
|
||||
def _set_project_manifest(self, project: FakeProject) -> None:
|
||||
project.manifest = self
|
||||
for subproject in project.GetDerivedSubprojects():
|
||||
self._set_project_manifest(subproject)
|
||||
|
||||
def GetManifestGroupsStr(self):
|
||||
return "default"
|
||||
return self._effective_groups
|
||||
|
||||
def GetProjectsWithName(
|
||||
self, name: str, all_manifests: bool = False
|
||||
) -> List[FakeProject]:
|
||||
projects = self.all_projects if all_manifests else self.projects
|
||||
return [project for project in projects if project.name == name]
|
||||
|
||||
|
||||
class GroupMatchingFakeProject(FakeProject):
|
||||
"""Fake project with predictable group matches for GetProjects tests.
|
||||
|
||||
This lets the tests check which groups GetProjects uses without
|
||||
reimplementing Project.MatchesGroups.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
relpath: str,
|
||||
*,
|
||||
matching_groups: Iterable[str],
|
||||
):
|
||||
super().__init__(name, relpath)
|
||||
self._matching_groups = set(matching_groups)
|
||||
|
||||
def MatchesGroups(self, groups: Iterable[str]) -> bool:
|
||||
return bool(self._matching_groups.intersection(groups))
|
||||
|
||||
|
||||
def test_get_projects_keeps_derived_subprojects_for_repeated_repo():
|
||||
@@ -117,3 +170,180 @@ def test_get_projects_submodule_override(
|
||||
projects = cmd.GetProjects([], submodules_ok=submodules_ok)
|
||||
|
||||
assert (submodule in projects) is includes_submodule
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("groups", "expected_relpaths"),
|
||||
[
|
||||
(None, ["outer", "sub/child"]),
|
||||
("", ["outer", "sub/child"]),
|
||||
("override-group", ["sub/override"]),
|
||||
],
|
||||
ids=("groups-omitted", "groups-empty", "explicit-override"),
|
||||
)
|
||||
def test_get_projects_uses_groups_from_each_manifest_unless_overridden(
|
||||
groups: Optional[str],
|
||||
expected_relpaths: List[str],
|
||||
) -> None:
|
||||
"""Use each manifest's effective groups unless the caller overrides them."""
|
||||
outer_project = GroupMatchingFakeProject(
|
||||
"outer",
|
||||
"outer",
|
||||
matching_groups={"outer-group"},
|
||||
)
|
||||
|
||||
# Both child projects also match "outer". Reusing the outer manifest's
|
||||
# groups would therefore select both child projects.
|
||||
child_project = GroupMatchingFakeProject(
|
||||
"child",
|
||||
"sub/child",
|
||||
matching_groups={"outer-group", "child-group"},
|
||||
)
|
||||
override_project = GroupMatchingFakeProject(
|
||||
"override",
|
||||
"sub/override",
|
||||
matching_groups={"outer-group", "override-group"},
|
||||
)
|
||||
|
||||
child_manifest = FakeManifest(
|
||||
[child_project, override_project],
|
||||
effective_groups="child-group",
|
||||
)
|
||||
outer_manifest = FakeManifest(
|
||||
[outer_project],
|
||||
all_projects=[outer_project, *child_manifest.projects],
|
||||
effective_groups="outer-group",
|
||||
)
|
||||
cmd = Command(manifest=outer_manifest)
|
||||
|
||||
projects = cmd.GetProjects(
|
||||
[],
|
||||
manifest=outer_manifest,
|
||||
groups=groups,
|
||||
all_manifests=True,
|
||||
)
|
||||
|
||||
assert [project.relpath for project in projects] == expected_relpaths
|
||||
|
||||
|
||||
def test_get_projects_by_name_uses_groups_from_each_manifest() -> None:
|
||||
"""Name matches use the groups from each project's owning manifest."""
|
||||
outer_project = GroupMatchingFakeProject(
|
||||
"shared",
|
||||
"outer/shared",
|
||||
matching_groups={"outer-group"},
|
||||
)
|
||||
child_project = GroupMatchingFakeProject(
|
||||
"shared",
|
||||
"sub/shared",
|
||||
matching_groups={"child-group"},
|
||||
)
|
||||
|
||||
child_manifest = FakeManifest(
|
||||
[child_project],
|
||||
effective_groups="child-group",
|
||||
)
|
||||
outer_manifest = FakeManifest(
|
||||
[outer_project],
|
||||
all_projects=[outer_project, *child_manifest.projects],
|
||||
effective_groups="outer-group",
|
||||
)
|
||||
cmd = Command(manifest=outer_manifest)
|
||||
|
||||
projects = cmd.GetProjects(
|
||||
["shared"],
|
||||
manifest=outer_manifest,
|
||||
all_manifests=True,
|
||||
)
|
||||
|
||||
assert [project.relpath for project in projects] == [
|
||||
"outer/shared",
|
||||
"sub/shared",
|
||||
]
|
||||
|
||||
|
||||
def test_find_projects_uses_groups_from_each_manifest() -> None:
|
||||
"""Use each manifest's effective groups for regex selection."""
|
||||
outer_project = GroupMatchingFakeProject(
|
||||
"match-outer",
|
||||
"outer",
|
||||
matching_groups={"outer-group"},
|
||||
)
|
||||
child_project = GroupMatchingFakeProject(
|
||||
"match-child",
|
||||
"sub/child",
|
||||
matching_groups={"child-group"},
|
||||
)
|
||||
excluded_project = GroupMatchingFakeProject(
|
||||
"match-excluded",
|
||||
"sub/excluded",
|
||||
matching_groups={"other-group"},
|
||||
)
|
||||
|
||||
child_manifest = FakeManifest(
|
||||
[child_project, excluded_project],
|
||||
effective_groups="child-group",
|
||||
)
|
||||
child_manifest.path_prefix = "sub"
|
||||
|
||||
outer_manifest = FakeManifest(
|
||||
[outer_project],
|
||||
all_projects=[
|
||||
outer_project,
|
||||
*child_manifest.projects,
|
||||
],
|
||||
effective_groups="outer-group",
|
||||
)
|
||||
outer_manifest.outer_client = outer_manifest
|
||||
|
||||
cmd = Command(manifest=outer_manifest)
|
||||
|
||||
projects = cmd.FindProjects(["match"], all_manifests=True)
|
||||
|
||||
assert [project.relpath for project in projects] == [
|
||||
"outer",
|
||||
"sub/child",
|
||||
]
|
||||
|
||||
|
||||
def test_find_projects_uses_explicit_groups() -> None:
|
||||
"""Use explicit groups for regex selection."""
|
||||
default_project = GroupMatchingFakeProject(
|
||||
"default",
|
||||
"default",
|
||||
matching_groups={"default-group"},
|
||||
)
|
||||
override_project = GroupMatchingFakeProject(
|
||||
"override",
|
||||
"override",
|
||||
matching_groups={"override-group"},
|
||||
)
|
||||
manifest = FakeManifest(
|
||||
[default_project, override_project],
|
||||
effective_groups="default-group",
|
||||
)
|
||||
cmd = Command(manifest=manifest)
|
||||
|
||||
projects = cmd.FindProjects(
|
||||
["override"],
|
||||
groups="override-group",
|
||||
)
|
||||
|
||||
assert projects == [override_project]
|
||||
|
||||
|
||||
def test_find_projects_allows_missing_projects() -> None:
|
||||
"""Allow regex selection to include projects without a checkout."""
|
||||
project = FakeProject(
|
||||
"missing",
|
||||
"missing",
|
||||
exists=False,
|
||||
)
|
||||
cmd = Command(manifest=FakeManifest([project]))
|
||||
|
||||
projects = cmd.FindProjects(
|
||||
["missing"],
|
||||
missing_ok=True,
|
||||
)
|
||||
|
||||
assert projects == [project]
|
||||
@@ -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"
|
||||
@@ -123,6 +123,7 @@ class GitCommandStreamLogsTest(unittest.TestCase):
|
||||
"""Tests the GitCommand class stderr log streaming cases."""
|
||||
|
||||
def setUp(self):
|
||||
_ = git_command.user_agent.git
|
||||
self.mock_process = mock.MagicMock()
|
||||
self.mock_process.communicate.return_value = (None, None)
|
||||
self.mock_process.wait.return_value = 0
|
||||
@@ -200,6 +201,7 @@ class GitCommandStreamLogsTest(unittest.TestCase):
|
||||
|
||||
@mock.patch("sys.stderr")
|
||||
def test_streams_stderr_when_no_stream_is_set(self, mock_stderr):
|
||||
mock_stderr.isatty.return_value = False
|
||||
logs = "\n".join(
|
||||
[
|
||||
"Enumerating objects: 5, done.",
|
||||
@@ -227,10 +229,185 @@ class GitCommandStreamLogsTest(unittest.TestCase):
|
||||
mock_stderr.write.assert_called_once_with(logs)
|
||||
self.assertEqual(cmd.stderr, logs)
|
||||
|
||||
@mock.patch("sys.stderr")
|
||||
def test_push_injects_progress_only_when_tty_and_not_suppressed(
|
||||
self, mock_stderr: mock.MagicMock
|
||||
) -> None:
|
||||
mock_stderr.isatty.return_value = True
|
||||
self.mock_process.stderr = io.BufferedReader(io.BytesIO(b""))
|
||||
git_command.GitCommand(None, ["push"])
|
||||
self.mock_popen.assert_called_once()
|
||||
cmd = self.mock_popen.call_args[0][0]
|
||||
self.assertEqual(cmd, ["git", "push", "--progress"])
|
||||
|
||||
@mock.patch("sys.stderr")
|
||||
def test_push_does_not_inject_progress_when_no_progress_set(
|
||||
self, mock_stderr: mock.MagicMock
|
||||
) -> None:
|
||||
mock_stderr.isatty.return_value = True
|
||||
self.mock_process.stderr = io.BufferedReader(io.BytesIO(b""))
|
||||
git_command.GitCommand(None, ["push", "--no-progress"])
|
||||
self.mock_popen.assert_called_once()
|
||||
cmd = self.mock_popen.call_args[0][0]
|
||||
self.assertEqual(cmd, ["git", "push", "--no-progress"])
|
||||
|
||||
|
||||
class GitCommandCaptureBytesTest(unittest.TestCase):
|
||||
"""Tests the GitCommand class byte capture cases."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
_ = git_command.user_agent.git
|
||||
self.mock_process = mock.MagicMock()
|
||||
self.mock_process.communicate.return_value = (None, None)
|
||||
self.mock_process.wait.return_value = 0
|
||||
|
||||
self.mock_popen = mock.MagicMock()
|
||||
self.mock_popen.return_value = self.mock_process
|
||||
mock.patch("subprocess.Popen", self.mock_popen).start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
mock.patch.stopall()
|
||||
|
||||
def test_captures_stdout_as_bytes(self) -> None:
|
||||
self.mock_process.communicate.return_value = (b"\xff\x00", b"error\r\n")
|
||||
|
||||
cmd = git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
capture_stdout=True,
|
||||
capture_stdout_bytes=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
|
||||
self.mock_popen.assert_called_once_with(
|
||||
["git", "status"],
|
||||
cwd=None,
|
||||
env=mock.ANY,
|
||||
stdin=None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.assertEqual(cmd.stdout, b"\xff\x00")
|
||||
self.assertEqual(cmd.stderr, "error\n")
|
||||
|
||||
def test_capture_stdout_bytes_auto_enables_capture_stdout(self) -> None:
|
||||
self.mock_process.communicate.return_value = (b"output", b"")
|
||||
|
||||
cmd = git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
|
||||
self.mock_popen.assert_called_once_with(
|
||||
["git", "status"],
|
||||
cwd=None,
|
||||
env=mock.ANY,
|
||||
stdin=None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=None,
|
||||
)
|
||||
self.assertEqual(cmd.stdout, b"output")
|
||||
|
||||
def test_capture_stdout_bytes_with_merge_output_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
capture_stdout_bytes=True,
|
||||
merge_output=True,
|
||||
)
|
||||
|
||||
def test_captures_stdout_as_bytes_encodes_str_input(self) -> None:
|
||||
self.mock_process.communicate.return_value = (b"output", b"")
|
||||
|
||||
git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
input="hello world",
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
|
||||
self.mock_process.communicate.assert_called_once_with(
|
||||
input=b"hello world"
|
||||
)
|
||||
|
||||
def test_captures_stdout_as_bytes_encodes_surrogate_input(self) -> None:
|
||||
self.mock_process.communicate.return_value = (b"output", b"")
|
||||
|
||||
git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
input="file_\udcff.txt",
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
|
||||
self.mock_process.communicate.assert_called_once_with(
|
||||
input=b"file_\xff.txt"
|
||||
)
|
||||
|
||||
def test_captures_stdout_as_bytes_passes_bytes_input(self) -> None:
|
||||
self.mock_process.communicate.return_value = (b"output", b"")
|
||||
|
||||
git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
input=b"raw_\xff.txt",
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
|
||||
self.mock_process.communicate.assert_called_once_with(
|
||||
input=b"raw_\xff.txt"
|
||||
)
|
||||
|
||||
def test_verify_command_truncates_nul_delimited_stdout(self) -> None:
|
||||
cmd = git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
cmd.rc = 1
|
||||
cmd.stdout = b"first_file\0second_file\0third_file"
|
||||
cmd.stderr = "stderr"
|
||||
with self.assertRaises(git_command.GitCommandError) as cm:
|
||||
cmd.VerifyCommand()
|
||||
self.assertEqual(cm.exception.git_stdout, "first_file")
|
||||
|
||||
def test_verify_command_decodes_bytes_stdout(self) -> None:
|
||||
cmd = git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
cmd.rc = 1
|
||||
cmd.stdout = b"error\xff\nline2"
|
||||
cmd.stderr = "stderr"
|
||||
with self.assertRaises(git_command.GitCommandError) as cm:
|
||||
cmd.VerifyCommand()
|
||||
self.assertEqual(cm.exception.git_stdout, "error\\xff")
|
||||
|
||||
|
||||
class GitCallUnitTest(unittest.TestCase):
|
||||
"""Tests the _GitCall class (via git_command.git)."""
|
||||
|
||||
def test_valid_branch_name_uses_branch_mode(self) -> None:
|
||||
"""Branch validation applies Git's branch-specific restrictions."""
|
||||
command = mock.MagicMock()
|
||||
command.Wait.return_value = 1
|
||||
with mock.patch.object(
|
||||
git_command, "GitCommand", return_value=command
|
||||
) as check:
|
||||
self.assertFalse(git_command.IsValidBranchName("-topic"))
|
||||
|
||||
check.assert_called_once_with(
|
||||
None,
|
||||
["check-ref-format", "--branch", "-topic"],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
add_event_log=False,
|
||||
log_as_error=False,
|
||||
)
|
||||
|
||||
def test_version_tuple(self):
|
||||
"""Check git.version_tuple() handling."""
|
||||
ver = git_command.git.version_tuple()
|
||||
|
||||
+295
-3
@@ -14,12 +14,15 @@
|
||||
|
||||
"""Unittests for the git_config.py module."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
import error
|
||||
import git_config
|
||||
|
||||
|
||||
@@ -256,8 +259,8 @@ def test_remote_save_with_push_url_without_projectname(
|
||||
("0" * 64, True),
|
||||
("f" * 64, True),
|
||||
("a" * 39, False),
|
||||
("a" * 41, True),
|
||||
("a" * 63, True),
|
||||
("a" * 41, False),
|
||||
("a" * 63, False),
|
||||
("a" * 65, False),
|
||||
("g" * 40, False),
|
||||
("g" * 64, False),
|
||||
@@ -268,3 +271,292 @@ def test_remote_save_with_push_url_without_projectname(
|
||||
def test_is_id(rev: str, expected: bool) -> None:
|
||||
"""Test IsId identifies both SHA-1 and SHA-256 hashes."""
|
||||
assert git_config.IsId(rev) == expected
|
||||
|
||||
|
||||
def test_repo_config_for_user_is_not_shared_with_git_config(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_home_dir: Path
|
||||
) -> None:
|
||||
"""RepoConfig.ForUser() must not reuse GitConfig.ForUser()'s cache."""
|
||||
monkeypatch.setattr(git_config.GitConfig, "_ForUser", None)
|
||||
monkeypatch.setattr(git_config.RepoConfig, "_ForUser", None)
|
||||
monkeypatch.delenv("REPO_CONFIG_DIR", raising=False)
|
||||
|
||||
# Populate the GitConfig cache first, as manifest loading does.
|
||||
git_cfg = git_config.GitConfig.ForUser()
|
||||
repo_cfg = git_config.RepoConfig.ForUser()
|
||||
|
||||
assert repo_cfg is not git_cfg
|
||||
assert git_cfg.file == str(tmp_home_dir / ".gitconfig")
|
||||
assert repo_cfg.file == str(tmp_home_dir / ".repoconfig" / "config")
|
||||
# And the RepoConfig instance is cached on its own class.
|
||||
assert git_config.RepoConfig.ForUser() is repo_cfg
|
||||
assert git_config.GitConfig.ForUser() is git_cfg
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
(
|
||||
("true", True),
|
||||
("True", True),
|
||||
("yes", True),
|
||||
("on", True),
|
||||
("1", True),
|
||||
(" true ", True),
|
||||
("false", False),
|
||||
("No", False),
|
||||
("off", False),
|
||||
("0", False),
|
||||
("oops", None),
|
||||
("2", None),
|
||||
("", None),
|
||||
),
|
||||
)
|
||||
def test_parse_boolean(value: str, expected: Optional[bool]) -> None:
|
||||
"""Test ParseBoolean accepts the spellings `git config --type=bool` does."""
|
||||
assert git_config.ParseBoolean(value) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
(
|
||||
("10", 10),
|
||||
("-7", -7),
|
||||
("10k", 10 * 1024),
|
||||
("10M", 10 * 1024 * 1024),
|
||||
("10g", 10 * 1024 * 1024 * 1024),
|
||||
("0x10", 16),
|
||||
("0x10k", 16 * 1024),
|
||||
(" 10 ", 10),
|
||||
("oops", None),
|
||||
("1x", None),
|
||||
("", None),
|
||||
),
|
||||
)
|
||||
def test_parse_int(value: str, expected: Optional[int]) -> None:
|
||||
"""Test ParseInt accepts the syntax `git config --type=int` does."""
|
||||
assert git_config.ParseInt(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
(("on", True), ("off", False), ("1", True), ("0", False)),
|
||||
)
|
||||
def test_get_boolean_git_spellings(
|
||||
tmp_path: Path, value: str, expected: bool
|
||||
) -> None:
|
||||
"""Test GetBoolean accepts on/off/1/0 like git does."""
|
||||
config_file = tmp_path / "config"
|
||||
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"
|
||||
+311
-2
@@ -17,14 +17,17 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any, List
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
import git_command
|
||||
import git_refs
|
||||
|
||||
|
||||
def _run(repo, *args):
|
||||
def _run(repo: str, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-C", repo, *args],
|
||||
stdout=subprocess.PIPE,
|
||||
@@ -34,7 +37,7 @@ def _run(repo, *args):
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _init_repo(tmp_path, reftable=False):
|
||||
def _init_repo(tmp_path: Path, reftable: bool = False) -> str:
|
||||
repo = os.path.join(tmp_path, "repo")
|
||||
ref_format = "reftable" if reftable else "files"
|
||||
utils_for_test.init_git_tree(repo, ref_format=ref_format)
|
||||
@@ -57,10 +60,154 @@ def test_reads_refs(tmp_path, reftable):
|
||||
branch = _run(repo, "symbolic-ref", "--short", "HEAD")
|
||||
head = _run(repo, "rev-parse", "HEAD")
|
||||
assert refs.symref("HEAD") == f"refs/heads/{branch}"
|
||||
assert refs.head == f"refs/heads/{branch}"
|
||||
assert refs.get("HEAD") == head
|
||||
assert refs.get(f"refs/heads/{branch}") == head
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reftable", [False, True])
|
||||
def test_reads_detached_head(tmp_path: Path, reftable: bool) -> None:
|
||||
if reftable and not utils_for_test.supports_reftable():
|
||||
pytest.skip("reftable not supported")
|
||||
|
||||
repo = _init_repo(tmp_path, reftable=reftable)
|
||||
head = _run(repo, "rev-parse", "HEAD")
|
||||
_run(repo, "checkout", "--detach", head)
|
||||
refs = git_refs.GitRefs(os.path.join(repo, ".git"))
|
||||
|
||||
assert refs.symref("HEAD") == ""
|
||||
assert refs.head == head
|
||||
assert refs.get("HEAD") == head
|
||||
|
||||
|
||||
def test_reads_head_with_root_refs_in_one_command(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Git 2.45 and newer include HEAD in the ref snapshot."""
|
||||
head = "1" * 40
|
||||
commands = []
|
||||
|
||||
class FakeGitCommand:
|
||||
def __init__(
|
||||
self, _project: Any, cmdv: List[str], **_kwargs: Any
|
||||
) -> None:
|
||||
commands.append(cmdv)
|
||||
self.stdout = (
|
||||
f"{head}\0HEAD\0refs/heads/main\n"
|
||||
f"{head}\0refs/heads/main\0\n"
|
||||
)
|
||||
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_command, "git_require", lambda _version: True)
|
||||
refs = git_refs.GitRefs("/nonexistent")
|
||||
with mock.patch.object(refs, "_ReadSymbolicRef") as read_head:
|
||||
assert refs.get("HEAD") == head
|
||||
|
||||
assert commands == [
|
||||
[
|
||||
"for-each-ref",
|
||||
"--include-root-refs",
|
||||
"--format=%(objectname)%00%(refname)%00%(symref)",
|
||||
"HEAD",
|
||||
"refs",
|
||||
]
|
||||
]
|
||||
read_head.assert_not_called()
|
||||
|
||||
|
||||
def test_root_ref_snapshot_falls_back_for_unborn_head(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An unborn HEAD still uses symbolic-ref after the ref snapshot."""
|
||||
|
||||
class FakeGitCommand:
|
||||
def __init__(
|
||||
self, _project: Any, _cmdv: List[str], **_kwargs: Any
|
||||
) -> None:
|
||||
self.stdout = ""
|
||||
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_command, "git_require", lambda _version: True)
|
||||
refs = git_refs.GitRefs("/nonexistent")
|
||||
|
||||
def read_head(name: str) -> None:
|
||||
assert name == "HEAD"
|
||||
refs._symref[name] = "refs/heads/main"
|
||||
|
||||
monkeypatch.setattr(refs, "_ReadSymbolicRef", read_head)
|
||||
|
||||
assert refs.symref("HEAD") == "refs/heads/main"
|
||||
|
||||
|
||||
def test_old_git_keeps_separate_head_fallback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Git before 2.45 uses the original for-each-ref and HEAD calls."""
|
||||
head = "1" * 40
|
||||
commands = []
|
||||
|
||||
class FakeGitCommand:
|
||||
def __init__(
|
||||
self, _project: Any, cmdv: List[str], **_kwargs: Any
|
||||
) -> None:
|
||||
commands.append(cmdv)
|
||||
self.stdout = f"{head}\0refs/heads/main\0\n"
|
||||
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_command, "git_require", lambda _version: False)
|
||||
refs = git_refs.GitRefs("/nonexistent")
|
||||
|
||||
def read_head(name: str) -> None:
|
||||
assert name == "HEAD"
|
||||
refs._symref[name] = "refs/heads/main"
|
||||
|
||||
monkeypatch.setattr(refs, "_ReadSymbolicRef", read_head)
|
||||
|
||||
assert refs.get("HEAD") == head
|
||||
assert commands == [
|
||||
[
|
||||
"for-each-ref",
|
||||
"--format=%(objectname)%00%(refname)%00%(symref)",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_for_each_ref_failure_falls_back_to_symbolic_ref(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When for-each-ref fails, HEAD is still resolved via symbolic-ref."""
|
||||
|
||||
class FakeGitCommand:
|
||||
def __init__(
|
||||
self, _project: Any, _cmdv: List[str], **_kwargs: Any
|
||||
) -> None:
|
||||
self.stdout = ""
|
||||
|
||||
def Wait(self) -> int:
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_command, "git_require", lambda _version: True)
|
||||
refs = git_refs.GitRefs("/nonexistent")
|
||||
|
||||
def read_head(name: str) -> None:
|
||||
assert name == "HEAD"
|
||||
refs._symref[name] = "refs/heads/main"
|
||||
|
||||
monkeypatch.setattr(refs, "_ReadSymbolicRef", read_head)
|
||||
|
||||
assert refs.symref("HEAD") == "refs/heads/main"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reftable", [False, True])
|
||||
def test_updates_when_refs_change(tmp_path, reftable):
|
||||
if reftable and not utils_for_test.supports_reftable():
|
||||
@@ -97,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
|
||||
@@ -0,0 +1,211 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unittests for the git_status.py module."""
|
||||
|
||||
import os
|
||||
from typing import Any, List
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import git_command
|
||||
import git_status
|
||||
|
||||
|
||||
def test_parse_porcelain_v2_branch_and_paths() -> None:
|
||||
output = (
|
||||
b"# branch.oid " + b"1" * 40 + b"\0"
|
||||
b"# branch.head topic\0"
|
||||
b"# branch.upstream origin/main\0"
|
||||
b"# branch.ab +2 -3\0"
|
||||
b"# stash 1\0"
|
||||
b"1 M. N... 100644 100644 100644 "
|
||||
+ b"1" * 40
|
||||
+ b" "
|
||||
+ b"2" * 40
|
||||
+ b" staged name\0"
|
||||
b"1 .M N... 100644 100644 100644 "
|
||||
+ b"1" * 40
|
||||
+ b" "
|
||||
+ b"2" * 40
|
||||
+ b" worktree name\0"
|
||||
b"2 R. N... 100644 100644 100644 "
|
||||
+ b"1" * 40
|
||||
+ b" "
|
||||
+ b"2" * 40
|
||||
+ b" R075 renamed\0old name\0"
|
||||
b"? untracked\0"
|
||||
)
|
||||
|
||||
status = git_status.ParsePorcelainV2(output)
|
||||
|
||||
assert status.current_branch == "topic"
|
||||
assert status.upstream == "origin/main"
|
||||
assert (status.ahead, status.behind, status.stash_count) == (2, 3, 1)
|
||||
assert status.index_changes["staged name"].status == "M"
|
||||
assert status.worktree_changes["worktree name"].status == "M"
|
||||
renamed = status.index_changes["renamed"]
|
||||
assert (renamed.src_path, renamed.level) == ("old name", "75")
|
||||
assert status.untracked == ["untracked"]
|
||||
|
||||
|
||||
def test_parse_porcelain_v2_unmerged_and_non_utf8_path() -> None:
|
||||
path = b"bad-\xff-name"
|
||||
output = (
|
||||
b"u UU N... 100644 100644 100644 100644 "
|
||||
+ b"1" * 40
|
||||
+ b" "
|
||||
+ b"2" * 40
|
||||
+ b" "
|
||||
+ b"3" * 40
|
||||
+ b" "
|
||||
+ path
|
||||
+ b"\0"
|
||||
)
|
||||
|
||||
status = git_status.ParsePorcelainV2(output)
|
||||
decoded = os.fsdecode(path)
|
||||
|
||||
assert status.index_changes[decoded].status == "U"
|
||||
assert status.worktree_changes[decoded].status == "U"
|
||||
assert os.fsencode(status.index_changes[decoded].path) == path
|
||||
|
||||
|
||||
def test_untracked_only_respects_consider_untracked() -> None:
|
||||
status = git_status.ParsePorcelainV2(b"? new file\0")
|
||||
|
||||
assert status.is_dirty()
|
||||
assert not status.is_dirty(consider_untracked=False)
|
||||
|
||||
|
||||
def test_branch_headers_preserve_non_ascii_names() -> None:
|
||||
branch = "tópico"
|
||||
status = git_status.ParsePorcelainV2(
|
||||
b"# branch.oid " + b"1" * 40 + b"\0"
|
||||
b"# branch.head " + os.fsencode(branch) + b"\0"
|
||||
)
|
||||
|
||||
assert status.current_branch == branch
|
||||
|
||||
|
||||
def test_quick_ahead_behind_is_recorded_as_unknown() -> None:
|
||||
status = git_status.ParsePorcelainV2(b"# branch.ab +? -?\0")
|
||||
|
||||
assert (status.ahead, status.behind) == (0, 0)
|
||||
assert not status.has_ahead_behind
|
||||
|
||||
|
||||
def test_unknown_head_is_not_a_current_branch() -> None:
|
||||
status = git_status.ParsePorcelainV2(b"# branch.head (unknown)\0")
|
||||
|
||||
assert status.current_branch is None
|
||||
|
||||
|
||||
def test_malformed_output_is_rejected() -> None:
|
||||
with pytest.raises(git_status.StatusParseError):
|
||||
git_status.ParsePorcelainV2(b"2 R. truncated\0")
|
||||
|
||||
|
||||
def test_malformed_branch_ab_is_rejected() -> None:
|
||||
with pytest.raises(git_status.StatusParseError):
|
||||
git_status.ParsePorcelainV2(b"# branch.ab not-a-valid-ab\0")
|
||||
|
||||
|
||||
def test_ignored_records_are_skipped() -> None:
|
||||
status = git_status.ParsePorcelainV2(b"! ignored_file\0")
|
||||
assert not status.is_dirty()
|
||||
assert status.untracked == []
|
||||
|
||||
|
||||
def test_get_status_uses_versioned_machine_options(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
commands = []
|
||||
|
||||
class FakeGitCommand:
|
||||
def __init__(
|
||||
self, _project: Any, cmdv: List[str], **kwargs: Any
|
||||
) -> None:
|
||||
commands.append((cmdv, kwargs))
|
||||
self.stdout = b""
|
||||
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_command, "git_require", lambda _version: True)
|
||||
|
||||
git_status.GetStatus(
|
||||
mock.sentinel.project,
|
||||
mock.sentinel.gitdir,
|
||||
untracked_files="no",
|
||||
branch=True,
|
||||
ahead_behind=True,
|
||||
show_stash=True,
|
||||
)
|
||||
|
||||
cmd, kwargs = commands[0]
|
||||
assert cmd == [
|
||||
"status",
|
||||
"--porcelain=v2",
|
||||
"-z",
|
||||
"--ignore-submodules=all",
|
||||
"--untracked-files=no",
|
||||
"--branch",
|
||||
"--ahead-behind",
|
||||
"--renames",
|
||||
"--show-stash",
|
||||
]
|
||||
assert kwargs["capture_stdout_bytes"]
|
||||
|
||||
|
||||
def test_get_status_rejects_git_before_2_11(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(git_command, "git_require", lambda _version: False)
|
||||
|
||||
with pytest.raises(git_status.UnsupportedStatusError):
|
||||
git_status.GetStatus(mock.sentinel.project, mock.sentinel.gitdir)
|
||||
|
||||
|
||||
def test_get_status_omits_stash_header_before_2_35(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
commands = []
|
||||
|
||||
class FakeGitCommand:
|
||||
def __init__(
|
||||
self, _project: Any, cmdv: List[str], **_kwargs: Any
|
||||
) -> None:
|
||||
commands.append(cmdv)
|
||||
self.stdout = b""
|
||||
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(
|
||||
git_command,
|
||||
"git_require",
|
||||
lambda version: version <= (2, 34, 0),
|
||||
)
|
||||
|
||||
git_status.GetStatus(
|
||||
mock.sentinel.project,
|
||||
mock.sentinel.gitdir,
|
||||
show_stash=True,
|
||||
)
|
||||
|
||||
assert "--show-stash" not in commands[0]
|
||||
@@ -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"/>'
|
||||
@@ -465,7 +465,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
"""Test _Init succeeds and creates the work git dir."""
|
||||
self.assertFalse(os.path.exists(self._superproject._work_git))
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", autospec=True
|
||||
"git_command.GitCommand", autospec=True
|
||||
) as mock_git_command:
|
||||
instance = mock_git_command.return_value
|
||||
instance.Wait.return_value = 0
|
||||
@@ -492,7 +492,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
os.mkdir(self._superproject._superproject_path)
|
||||
os.mkdir(self._superproject._work_git)
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", autospec=True
|
||||
"git_command.GitCommand", autospec=True
|
||||
) as mock_git_command:
|
||||
self.assertTrue(self._superproject._Init())
|
||||
mock_git_command.assert_not_called()
|
||||
@@ -500,7 +500,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
def test_Init_failure_cleans_up(self):
|
||||
"""Test _Init cleans up the temporary directory if 'git init' fails."""
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", autospec=True
|
||||
"git_command.GitCommand", autospec=True
|
||||
) as mock_git_command:
|
||||
instance = mock_git_command.return_value
|
||||
instance.Wait.return_value = 1
|
||||
@@ -540,16 +540,17 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
os.mkdir(self._superproject._work_git)
|
||||
with mock.patch.object(self._superproject, "_Init", return_value=True):
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", autospec=True
|
||||
"git_command.GitCommand", autospec=True
|
||||
) as mock_git_command:
|
||||
with mock.patch(
|
||||
"git_superproject.GitRefs.get", autospec=True
|
||||
) as mock_git_refs:
|
||||
with mock.patch.object(
|
||||
self._superproject, "_GetRef"
|
||||
) as get_ref:
|
||||
instance = mock_git_command.return_value
|
||||
instance.Wait.return_value = 0
|
||||
mock_git_refs.side_effect = ["", "1234"]
|
||||
get_ref.side_effect = ["", "1234"]
|
||||
|
||||
self.assertTrue(self._superproject._Fetch())
|
||||
get_ref.assert_called_with("refs/heads/main")
|
||||
self.assertEqual(
|
||||
# TODO: Once we require Python 3.8+,
|
||||
# use 'mock_git_command.call_args.args'.
|
||||
@@ -572,6 +573,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
|
||||
# If branch for revision exists, set as --negotiation-tip.
|
||||
self.assertTrue(self._superproject._Fetch())
|
||||
get_ref.assert_called_with("refs/heads/main")
|
||||
self.assertEqual(
|
||||
# TODO: Once we require Python 3.8+,
|
||||
# use 'mock_git_command.call_args.args'.
|
||||
@@ -593,3 +595,21 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
def test_GetRef_resolves_only_the_requested_ref(self) -> None:
|
||||
command = mock.MagicMock(stdout="1234\n")
|
||||
command.Wait.return_value = 0
|
||||
with mock.patch(
|
||||
"git_command.GitCommand", return_value=command
|
||||
) as git_command:
|
||||
self.assertEqual("1234", self._superproject._GetRef("HEAD"))
|
||||
|
||||
git_command.assert_called_once_with(
|
||||
None,
|
||||
["rev-parse", "--verify", "--quiet", "HEAD"],
|
||||
gitdir=self._superproject._work_git,
|
||||
bare=True,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
log_as_error=False,
|
||||
)
|
||||
@@ -33,7 +33,7 @@ import platform_utils
|
||||
|
||||
def server_logging_thread(
|
||||
socket_path: str,
|
||||
server_ready: threading.Condition,
|
||||
server_ready: threading.Event,
|
||||
received_traces: List[str],
|
||||
) -> None:
|
||||
"""Helper function to receive logs over a Unix domain socket.
|
||||
@@ -43,8 +43,7 @@ def server_logging_thread(
|
||||
|
||||
Args:
|
||||
socket_path: path to a Unix domain socket on which to listen for traces
|
||||
server_ready: a threading.Condition used to signal to the caller that
|
||||
this thread is ready to accept connections
|
||||
server_ready: event set when the server is ready to accept connections
|
||||
received_traces: a list to which received traces will be appended (after
|
||||
decoding to a utf-8 string).
|
||||
"""
|
||||
@@ -53,8 +52,7 @@ def server_logging_thread(
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(socket_path)
|
||||
sock.listen(0)
|
||||
with server_ready:
|
||||
server_ready.notify()
|
||||
server_ready.set()
|
||||
with sock.accept()[0] as conn:
|
||||
while True:
|
||||
recved = conn.recv(4096)
|
||||
@@ -404,7 +402,7 @@ def test_write_socket(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
received_traces: List[str] = []
|
||||
with tempfile.TemporaryDirectory(prefix="test_server_sockets") as tempdir:
|
||||
socket_path = os.path.join(tempdir, "server.sock")
|
||||
server_ready = threading.Condition()
|
||||
server_ready = threading.Event()
|
||||
# Start "server" listening on Unix domain socket at socket_path.
|
||||
server_thread = threading.Thread(
|
||||
target=server_logging_thread,
|
||||
@@ -412,9 +410,7 @@ def test_write_socket(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
)
|
||||
try:
|
||||
server_thread.start()
|
||||
|
||||
with server_ready:
|
||||
server_ready.wait(timeout=120)
|
||||
server_ready.wait(timeout=120)
|
||||
|
||||
event_log.StartEvent([])
|
||||
path = event_log.Write(path=f"af_unix:{socket_path}")
|
||||
|
||||
+66
-4
@@ -14,7 +14,8 @@
|
||||
|
||||
"""Unittests for the hooks.py module."""
|
||||
|
||||
from io import StringIO
|
||||
import io
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -81,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`
|
||||
@@ -108,11 +109,11 @@ def test_post_sync_argument_validation() -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize("yes_val", (True, False))
|
||||
def test_repo_upload_yes_arg(tmp_path, yes_val: bool) -> None:
|
||||
def test_repo_upload_yes_arg(tmp_path: Path, yes_val: bool) -> None:
|
||||
"""Test that yes is passed in kwargs during hook execution."""
|
||||
|
||||
class FakeProject:
|
||||
def __init__(self, worktree):
|
||||
def __init__(self, worktree: str) -> None:
|
||||
self.worktree = worktree
|
||||
self.enabled_repo_hooks = ["pre-upload"]
|
||||
self.config = None
|
||||
@@ -139,3 +140,64 @@ def main(project_list, **kwargs):
|
||||
|
||||
assert res is True
|
||||
assert project_list == [yes_val]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fix_val", (True, False))
|
||||
def test_repo_upload_fix_arg(tmp_path: Path, fix_val: bool) -> None:
|
||||
"""Test that fix is passed in kwargs during hook execution."""
|
||||
|
||||
class FakeProject:
|
||||
def __init__(self, worktree: str) -> None:
|
||||
self.worktree = worktree
|
||||
self.enabled_repo_hooks = ["pre-upload"]
|
||||
self.config = None
|
||||
|
||||
hook_file = tmp_path / "pre-upload.py"
|
||||
|
||||
hook_content = """
|
||||
def main(project_list, **kwargs):
|
||||
project_list.append(kwargs.get("fix"))
|
||||
"""
|
||||
hook_file.write_text(hook_content)
|
||||
|
||||
hook = hooks.RepoHook(
|
||||
hook_type="pre-upload",
|
||||
hooks_project=FakeProject(str(tmp_path)),
|
||||
repo_topdir=str(tmp_path),
|
||||
manifest_url="https://gerrit",
|
||||
allow_all_hooks=True,
|
||||
fix=fix_val,
|
||||
)
|
||||
|
||||
project_list = []
|
||||
res = hook.Run(project_list=project_list, worktree_list=[])
|
||||
|
||||
assert res is True
|
||||
assert project_list == [fix_val]
|
||||
|
||||
|
||||
def test_from_subcmd_without_fix_option() -> None:
|
||||
"""Test that FromSubcmd works when opt does not have fix attribute."""
|
||||
|
||||
class Remote:
|
||||
url = "https://gerrit"
|
||||
|
||||
class FakeManifest:
|
||||
repo_hooks_project = None
|
||||
topdir = "/fake/topdir"
|
||||
|
||||
class manifestProject:
|
||||
@staticmethod
|
||||
def GetRemote(name: str) -> "Remote":
|
||||
return Remote()
|
||||
|
||||
class contactinfo:
|
||||
bugurl = "https://bugs"
|
||||
|
||||
class FakeOpt:
|
||||
bypass_hooks = False
|
||||
allow_all_hooks = False
|
||||
ignore_hooks = False
|
||||
|
||||
hook = hooks.RepoHook.FromSubcmd(FakeManifest(), FakeOpt(), "post-sync")
|
||||
assert hook._fix is False
|
||||
@@ -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
|
||||
@@ -420,6 +420,62 @@ class TestXmlManifest:
|
||||
)
|
||||
manifest.ToXml()
|
||||
|
||||
def test_sync_smartsync(self, repo_client: RepoClient) -> None:
|
||||
"""Check sync-smartsync handling."""
|
||||
manifest = repo_client.get_xml_manifest(
|
||||
"<manifest><default /></manifest>"
|
||||
)
|
||||
assert manifest.default.sync_smartsync is False
|
||||
|
||||
manifest = repo_client.get_xml_manifest(
|
||||
'<manifest><default sync-smartsync="true" /></manifest>'
|
||||
)
|
||||
assert manifest.default.sync_smartsync is True
|
||||
assert (
|
||||
manifest.ToXml().toxml() == '<?xml version="1.0" ?>'
|
||||
'<manifest><default sync-smartsync="true"/></manifest>'
|
||||
)
|
||||
|
||||
manifest = repo_client.get_xml_manifest(
|
||||
'<manifest><default sync-smartsync="false" /></manifest>'
|
||||
)
|
||||
assert manifest.default.sync_smartsync is False
|
||||
assert manifest.ToXml().toxml() == '<?xml version="1.0" ?><manifest/>'
|
||||
|
||||
def test_clear_override(self, repo_client: RepoClient) -> None:
|
||||
"""Check ClearOverride reverts to the default manifest."""
|
||||
manifest_xml_fmt = (
|
||||
'<manifest><remote name="r" fetch="." />'
|
||||
'<default remote="r" revision="main" />'
|
||||
'<project name="%s" /></manifest>'
|
||||
)
|
||||
manifest = repo_client.get_xml_manifest(manifest_xml_fmt % "default")
|
||||
(repo_client.manifest_dir / "good.xml").write_text(
|
||||
manifest_xml_fmt % "override", encoding="utf-8"
|
||||
)
|
||||
(repo_client.manifest_dir / "bad.xml").write_text(
|
||||
"<manifest>", encoding="utf-8"
|
||||
)
|
||||
|
||||
# No override in effect: a no-op.
|
||||
manifest.ClearOverride()
|
||||
assert list(manifest.paths) == ["default"]
|
||||
|
||||
manifest.Override("good.xml")
|
||||
assert list(manifest.paths) == ["override"]
|
||||
manifest.ClearOverride()
|
||||
assert not manifest.manifestFileOverrides
|
||||
assert list(manifest.paths) == ["default"]
|
||||
|
||||
# Override() registers the override before parsing it, so a failed
|
||||
# Override() leaves the client pinned until ClearOverride().
|
||||
with pytest.raises(error.ManifestParseError):
|
||||
manifest.Override("bad.xml")
|
||||
with pytest.raises(error.ManifestParseError):
|
||||
manifest.Load()
|
||||
manifest.ClearOverride()
|
||||
assert list(manifest.paths) == ["default"]
|
||||
|
||||
|
||||
class TestIncludeElement:
|
||||
"""Tests for <include>."""
|
||||
@@ -877,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>."""
|
||||
|
||||
+4301
-53
File diff suppressed because it is too large.
Load diff
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user