mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-10-28 08:06:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d9da609d8c | ||
|
|
4bec297eb6 | ||
|
|
54fa31cd84 | ||
|
|
29b6630a65 | ||
|
|
0b82311632 | ||
|
|
06c4f9e1cc | ||
|
|
dd1130352b | ||
|
|
eeba6f268d | ||
|
|
1729aaebae | ||
|
|
978adb7ea5 | ||
|
|
0398c6718e | ||
|
|
3bb4871c44 |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -84,9 +84,14 @@ def _Color(fg=None, bg=None, attr=None):
|
||||
|
||||
DEFAULT = None
|
||||
|
||||
|
||||
class _CheckConsoleSentinel:
|
||||
"""Sentinel for checking console coloring."""
|
||||
|
||||
|
||||
# Placholder value that indicates we need to check if the user is in an
|
||||
# interactive terminal session to determine if we turn on color or not.
|
||||
_CHECK_CONSOLE = object()
|
||||
_CHECK_CONSOLE = _CheckConsoleSentinel()
|
||||
|
||||
# https://git-scm.com/docs/git-config#Documentation/git-config.txt-colorui
|
||||
_CONFIG_TO_COLOR_SETTING = {
|
||||
|
||||
+98
-27
@@ -17,14 +17,17 @@ import multiprocessing
|
||||
import optparse
|
||||
import os
|
||||
import re
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
from error import InvalidProjectGroupsError
|
||||
from error import NoSuchProjectError
|
||||
from error import RepoExitError
|
||||
import error
|
||||
from event_log import EventLog
|
||||
import progress
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from project import Project
|
||||
|
||||
|
||||
# Are we generating man-pages?
|
||||
GENERATE_MANPAGES = os.environ.get("_REPO_GENERATE_MANPAGES_") == " indeed! "
|
||||
|
||||
@@ -43,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."""
|
||||
|
||||
|
||||
@@ -61,6 +64,10 @@ class Command:
|
||||
# command to show short-vs-full summaries.
|
||||
COMMON = False
|
||||
|
||||
# Whether this command should respect the smart sync override manifest if
|
||||
# it exists.
|
||||
RESPECT_SMART_SYNC_OVERRIDE = True
|
||||
|
||||
# Whether this command supports running in parallel. If greater than 0,
|
||||
# it is the number of parallel jobs to default to.
|
||||
PARALLEL_JOBS = None
|
||||
@@ -143,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.
|
||||
|
||||
@@ -167,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",
|
||||
@@ -242,6 +259,12 @@ class Command:
|
||||
# from the user's perspective.
|
||||
opt.outer_manifest = True
|
||||
|
||||
if self.RESPECT_SMART_SYNC_OVERRIDE:
|
||||
if self.manifest:
|
||||
self.TryOverrideManifestWithSmartSync(self.manifest)
|
||||
if self.outer_manifest and self.outer_manifest != self.manifest:
|
||||
self.TryOverrideManifestWithSmartSync(self.outer_manifest)
|
||||
|
||||
def ValidateOptions(self, opt, args):
|
||||
"""Validate the user options & arguments before executing.
|
||||
|
||||
@@ -375,7 +398,7 @@ class Command:
|
||||
manifest=None,
|
||||
groups="",
|
||||
missing_ok=False,
|
||||
submodules_ok=False,
|
||||
submodules_ok=None,
|
||||
all_manifests=False,
|
||||
):
|
||||
"""A list of projects that match the arguments.
|
||||
@@ -383,9 +406,15 @@ 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: a boolean, whether to allow submodules.
|
||||
submodules_ok: whether to allow submodules. True allows them for
|
||||
all projects, False disallows them for all projects, and None
|
||||
defers to each project's sync-s setting.
|
||||
all_manifests: a boolean, if True then all manifests and
|
||||
submanifests are used. If False, then only the local
|
||||
(sub)manifest is used.
|
||||
@@ -403,23 +432,50 @@ class Command:
|
||||
all_projects_list = manifest.projects
|
||||
result = []
|
||||
|
||||
if not groups:
|
||||
groups = manifest.GetManifestGroupsStr()
|
||||
groups = [x for x in re.split(r"[,\s]+", groups) if x]
|
||||
def should_include_submodules(project: "Project") -> bool:
|
||||
if submodules_ok is None:
|
||||
return project.sync_s
|
||||
return submodules_ok
|
||||
|
||||
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 = {}
|
||||
for project in all_projects_list:
|
||||
if submodules_ok or project.sync_s:
|
||||
if should_include_submodules(project):
|
||||
derived_projects.update(
|
||||
(p.RelPath(local=False), p)
|
||||
for p in project.GetDerivedSubprojects()
|
||||
)
|
||||
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)
|
||||
@@ -433,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:
|
||||
@@ -452,7 +508,7 @@ class Command:
|
||||
if (
|
||||
project
|
||||
and not project.Derived
|
||||
and (submodules_ok or project.sync_s)
|
||||
and should_include_submodules(project)
|
||||
):
|
||||
search_again = False
|
||||
for subproject in project.GetDerivedSubprojects():
|
||||
@@ -468,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)
|
||||
|
||||
@@ -487,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:
|
||||
@@ -497,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)
|
||||
|
||||
+20
-1
@@ -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]' \
|
||||
@@ -322,7 +335,10 @@ _repo() {
|
||||
'--no-clone-bundle[Do not use clone bundle]' \
|
||||
'(-u --manifest-server-username)'{-u,--manifest-server-username=}'[Username for manifest server]:username:' \
|
||||
'(-p --manifest-server-password)'{-p,--manifest-server-password=}'[Password for manifest server]:password:' \
|
||||
'--fetch-submodules[Fetch submodules]' \
|
||||
'--recurse-submodules[Sync submodules]' \
|
||||
'--no-recurse-submodules[Do not sync submodules]' \
|
||||
'--fetch-submodules[Deprecated alias for --recurse-submodules]' \
|
||||
'--no-fetch-submodules[Deprecated alias for --no-recurse-submodules]' \
|
||||
'--use-superproject[Use superproject]' \
|
||||
'--no-use-superproject[Do not use superproject]' \
|
||||
'--tags[Sync tags]' \
|
||||
@@ -334,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]' \
|
||||
@@ -397,6 +415,7 @@ _repo() {
|
||||
'--no-verify[Do not verify]' \
|
||||
'--verify[Verify]' \
|
||||
'--ignore-hooks[Ignore hooks]' \
|
||||
'--fix[Automatically fix]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
version)
|
||||
|
||||
+21
-14
@@ -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 resolve to a valid commit in the object
|
||||
store (`git rev-parse --verify <ref>^{commit}`), and must equal `REPO_TREV`
|
||||
if `REPO_TREV` was non-empty.
|
||||
2. `FETCH_HEAD` must resolve to the same target commit in the object store
|
||||
(`git rev-parse --verify FETCH_HEAD^{commit}`).
|
||||
3. The commit graph from the target commit must be reachable far enough to
|
||||
compute merge bases with local branches.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+13
-10
@@ -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>
|
||||
@@ -231,6 +232,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
|
||||
|
||||
|
||||
+18
-1
@@ -83,6 +83,20 @@ then check it directly. Hooks should not normally modify the active git repo
|
||||
the user. Although user interaction is discouraged in the common case, it can
|
||||
be useful when deploying automatic fixes.
|
||||
|
||||
### Safe Prompts
|
||||
|
||||
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 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
|
||||
|
||||
*** note
|
||||
@@ -119,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, **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.
|
||||
@@ -130,6 +144,9 @@ def main(project_list, worktree_list=None, **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,126 @@
|
||||
# Reproject Command Contract
|
||||
|
||||
The `repo.reprojectcmd` configuration names a command that `repo sync` runs
|
||||
instead of Git to move a project's index and worktree to the tree of the
|
||||
target commit. It is the checkout-side counterpart of `repo.fetchcmd` (see
|
||||
`docs/fetch-cmd.md`): together they let an external tool take over both the
|
||||
network fetch and the materialization of a project. This is useful on
|
||||
virtualized filesystems that address content by hash, where a tree can be
|
||||
materialized far faster than `git checkout` can write every file.
|
||||
|
||||
The command only materializes the tree. `repo` then makes the ref write that
|
||||
Git would have made, using `git update-ref`.
|
||||
|
||||
## Configuration
|
||||
|
||||
To use this feature, set the following in `.repo/manifests.git/config`:
|
||||
```ini
|
||||
[repo]
|
||||
reprojectcmd = "your custom command here"
|
||||
uselocalgitdirs = true
|
||||
```
|
||||
Setting `repo.reprojectcmd` **requires** `repo.uselocalgitdirs` to be set to
|
||||
`true`.
|
||||
|
||||
For reference, this command does with Git what `repo` would otherwise do
|
||||
itself:
|
||||
```ini
|
||||
[repo]
|
||||
reprojectcmd = "git -C $REPO_PATH read-tree -m -u $REPO_TREV"
|
||||
uselocalgitdirs = true
|
||||
```
|
||||
The one-tree merge applies the change to the target, keeps local changes to
|
||||
every other path, and refuses to overwrite a modified or untracked file, so it
|
||||
enforces the preconditions below by itself. It also works for a project that
|
||||
has nothing checked out yet.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The command is executed in a subshell, from the root of the client, populated
|
||||
with standard project-context environment variables. For details on standard
|
||||
variables (such as `REPO_PROJECT`, `REPO_PATH`, `REPO_REMOTE`, etc.), see the
|
||||
Environment section in `repo help forall` or `subcmds/forall.py`.
|
||||
|
||||
The variables the command typically needs are:
|
||||
|
||||
* `REPO_PATH`: The project path relative to the root of the client.
|
||||
* `REPO_TREV`: The target revision resolved to a full commit hash. Match this
|
||||
commit's tree.
|
||||
|
||||
There is no force mode: a project that would need one never reaches the
|
||||
command (see the preconditions below).
|
||||
|
||||
## When the command runs
|
||||
|
||||
`repo sync` already classifies each project and picks a Git operation. The
|
||||
command replaces the three that are a materialization of a target tree:
|
||||
|
||||
1. The checkout that detaches HEAD at the target. This is the common case: a
|
||||
project on a detached HEAD, a project on a branch that does not track
|
||||
upstream, and `repo sync -d`.
|
||||
2. The fast-forward of the checked out branch to the target.
|
||||
3. The hard reset of the checked out branch to the target, when the commits
|
||||
it carried were dropped upstream.
|
||||
|
||||
After the command exits 0, `repo` writes the ref itself: it detaches `HEAD` at
|
||||
`REPO_TREV`, or moves the checked out branch to `REPO_TREV`.
|
||||
|
||||
The command is **not** run:
|
||||
|
||||
* When `HEAD` already names `REPO_TREV`.
|
||||
* At the fast-forward step when `HEAD` is ahead of `REPO_TREV`, where Git's
|
||||
merge would be a no-op.
|
||||
* For a rebase. A branch carrying local commits has them replayed onto the
|
||||
target by `git rebase`, which is not a materialization of a target tree.
|
||||
* For `MetaProject`s (i.e. the internal `repo` repository itself at
|
||||
`.repo/repo` and the `manifests` repository at `.repo/manifests`).
|
||||
|
||||
## Contract
|
||||
|
||||
### Preconditions
|
||||
|
||||
Before invoking the command, `repo` ensures that:
|
||||
|
||||
* The index has no staged changes (the index matches `HEAD`, or is empty on an unborn `HEAD`).
|
||||
* No rebase, cherry-pick, merge, or revert is in progress.
|
||||
|
||||
Detecting collisions with untracked files or unstaged working-tree modifications is the responsibility of the reproject command itself (e.g. via `git read-tree -m -u $REPO_TREV` or a custom virtual filesystem checkout tool). If local changes collide with the target tree, the command must abort with a non-zero exit code. Local modifications and untracked files outside the diff between `HEAD` and `REPO_TREV` must be preserved.
|
||||
|
||||
### Postconditions on exit 0
|
||||
|
||||
After the command exits with status 0, `repo` expects the following
|
||||
postconditions to be met:
|
||||
|
||||
1. `git diff-index --quiet --cached REPO_TREV^{tree}` exits 0 (the index
|
||||
matches the target tree).
|
||||
2. `HEAD` still names what it did before the command, and its resolved commit
|
||||
object ID has not changed.
|
||||
|
||||
### Invariants
|
||||
|
||||
The command may modify the worktree and the index, and may write project-local
|
||||
Git config. The command must:
|
||||
|
||||
* Apply the change from `HEAD`'s tree to `REPO_TREV`'s tree and leave every
|
||||
other path alone. Local modifications and untracked files outside that
|
||||
change must survive: the command applies a diff, it does not reset the
|
||||
tree.
|
||||
* Not write any ref, including `HEAD` and `ORIG_HEAD`. `repo` owns every ref
|
||||
write.
|
||||
* Not create or replace `.git/`, and not touch anything under `.repo/`.
|
||||
* Not require the Git remote, to preserve `repo sync --local-only`.
|
||||
* Be idempotent. Running it twice on the same target is a no-op.
|
||||
|
||||
### Failure
|
||||
|
||||
* A non-zero exit status, a failed precondition or a failed postcondition
|
||||
fails that project's sync, and the command's or Git's output is surfaced
|
||||
to the user.
|
||||
* Other projects continue, and `repo sync` exits non-zero.
|
||||
|
||||
## Limitations
|
||||
|
||||
Nested projects are out of scope: a project whose path lies inside another
|
||||
project's path, a `<project>` nested in another `<project>` in the manifest,
|
||||
and a submodule discovered with `sync-s` or `--recurse-submodules`. `repo sync`
|
||||
fails if the manifest has one while `repo.reprojectcmd` is set.
|
||||
+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)`.
|
||||
@@ -18,7 +18,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from error import EditorError
|
||||
import error
|
||||
import platform_utils
|
||||
|
||||
|
||||
@@ -104,9 +104,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)
|
||||
)
|
||||
|
||||
@@ -19,10 +19,10 @@ import sys
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
from error import RepoExitError
|
||||
import error
|
||||
|
||||
|
||||
class FetchFileError(RepoExitError):
|
||||
class FetchFileError(error.RepoExitError):
|
||||
"""Exit error when fetch_file fails."""
|
||||
|
||||
|
||||
|
||||
+76
-25
@@ -20,14 +20,11 @@ import subprocess
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
from error import GitError
|
||||
from error import RepoExitError
|
||||
import error
|
||||
from git_trace2_event_log_base import BaseEventLog
|
||||
import platform_utils
|
||||
from repo_logging import RepoLogger
|
||||
from repo_trace import IsTrace
|
||||
from repo_trace import REPO_TRACE
|
||||
from repo_trace import Trace
|
||||
import repo_logging
|
||||
import repo_trace
|
||||
from wrapper import Wrapper
|
||||
|
||||
|
||||
@@ -43,7 +40,7 @@ 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:
|
||||
@@ -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,11 +332,17 @@ 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:])
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
+98
-51
@@ -26,27 +26,24 @@ from typing import 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 = {}
|
||||
|
||||
|
||||
def IsChange(rev):
|
||||
return rev.startswith(R_CHANGES)
|
||||
return rev.startswith(git_refs.R_CHANGES)
|
||||
|
||||
|
||||
def IsId(rev: str) -> bool:
|
||||
@@ -54,13 +51,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:
|
||||
@@ -152,32 +199,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 +227,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."""
|
||||
@@ -266,6 +297,13 @@ class GitConfig:
|
||||
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:
|
||||
del self._cache[key]
|
||||
@@ -387,7 +425,7 @@ class GitConfig:
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
with Trace(": parsing %s", self.file):
|
||||
with repo_trace.Trace(": parsing %s", self.file):
|
||||
with open(self._json) as fd:
|
||||
return json.load(fd)
|
||||
except (OSError, ValueError):
|
||||
@@ -434,16 +472,23 @@ class GitConfig:
|
||||
command = ["config", "--file", self.file, "--includes"]
|
||||
command.extend(args)
|
||||
|
||||
p = GitCommand(None, command, capture_stdout=True, capture_stderr=True)
|
||||
p = git_command.GitCommand(
|
||||
None, command, capture_stdout=True, capture_stderr=True
|
||||
)
|
||||
if p.Wait() == 0:
|
||||
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 +716,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 +739,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)
|
||||
)
|
||||
|
||||
+43
-13
@@ -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"
|
||||
@@ -41,6 +41,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]
|
||||
@@ -70,7 +81,7 @@ class GitRefs:
|
||||
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,13 +93,17 @@ 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
|
||||
@@ -113,18 +128,32 @@ class GitRefs:
|
||||
"""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 +161,11 @@ 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(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["symbolic-ref", "-q", name],
|
||||
capture_stdout=True,
|
||||
@@ -149,7 +179,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."""
|
||||
|
||||
from collections import OrderedDict
|
||||
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 = OrderedDict()
|
||||
self.worktree_changes = OrderedDict()
|
||||
self.untracked = []
|
||||
self.branch_oid = None
|
||||
self.branch_head = None
|
||||
self.upstream = None
|
||||
self.ahead = 0
|
||||
self.behind = 0
|
||||
self.has_ahead_behind = False
|
||||
self.stash_count = 0
|
||||
|
||||
@property
|
||||
def current_branch(self) -> Optional[str]:
|
||||
if self.branch_head in (None, "(detached)", "(unknown)"):
|
||||
return None
|
||||
return self.branch_head
|
||||
|
||||
def is_dirty(self, consider_untracked: bool = True) -> bool:
|
||||
return bool(
|
||||
self.index_changes
|
||||
or self.worktree_changes
|
||||
or (consider_untracked and self.untracked)
|
||||
)
|
||||
|
||||
|
||||
def GetStatus(
|
||||
project: "Project",
|
||||
gitdir: str,
|
||||
untracked_files: str = "all",
|
||||
branch: bool = False,
|
||||
ahead_behind: bool = False,
|
||||
show_stash: bool = False,
|
||||
) -> StatusSnapshot:
|
||||
"""Return one machine-readable status snapshot for |project|."""
|
||||
if not git_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
|
||||
+27
-11
@@ -32,11 +32,9 @@ import time
|
||||
from typing import NamedTuple
|
||||
import urllib.parse
|
||||
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
import git_command
|
||||
from git_config import IsId
|
||||
from git_config import RepoConfig
|
||||
from git_refs import GitRefs
|
||||
import platform_utils
|
||||
|
||||
|
||||
@@ -141,7 +139,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 +187,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 +241,7 @@ class Superproject:
|
||||
|
||||
try:
|
||||
cmd = ["init", "--bare", tmp_git_name]
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
cwd=self._superproject_path,
|
||||
@@ -293,7 +291,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 +312,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])
|
||||
|
||||
@@ -327,7 +328,7 @@ class Superproject:
|
||||
else f"{self.revision}:{self.revision}"
|
||||
)
|
||||
cmd.append(refspec)
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
gitdir=self._work_git,
|
||||
@@ -347,6 +348,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 +380,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,
|
||||
@@ -473,7 +489,7 @@ class Superproject:
|
||||
)
|
||||
return None
|
||||
manifest_str = self._manifest.ToXml(
|
||||
filter_groups=self._manifest.GetManifestGroupsStr(),
|
||||
filter_groups="all",
|
||||
omit_local=True,
|
||||
).toxml()
|
||||
manifest_path = self._manifest_path
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
|
||||
"""Event logging in the git trace2 EVENT format."""
|
||||
|
||||
from git_command import GetEventTargetPath
|
||||
from git_command import RepoSourceVersion
|
||||
import git_command
|
||||
from git_trace2_event_log_base import BaseEventLog
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -68,6 +69,8 @@ class RepoHook:
|
||||
allow_all_hooks=False,
|
||||
ignore_hooks=False,
|
||||
abort_if_user_denies=False,
|
||||
yes=False,
|
||||
fix=False,
|
||||
):
|
||||
"""RepoHook constructor.
|
||||
|
||||
@@ -89,6 +92,8 @@ class RepoHook:
|
||||
ignore_hooks: If True, then 'Do not abort action if hooks fail'.
|
||||
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
|
||||
@@ -99,6 +104,8 @@ class RepoHook:
|
||||
self._allow_all_hooks = allow_all_hooks
|
||||
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
|
||||
@@ -135,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."""
|
||||
@@ -221,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
|
||||
)
|
||||
@@ -319,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
|
||||
@@ -334,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)
|
||||
)
|
||||
@@ -374,8 +383,12 @@ class RepoHook:
|
||||
# def main(project_list, **kwargs):
|
||||
#
|
||||
# This allows us to later expand the API without breaking old hooks.
|
||||
kwargs = kwargs.copy()
|
||||
kwargs["hook_should_take_kwargs"] = True
|
||||
kwargs = {
|
||||
**kwargs,
|
||||
"hook_should_take_kwargs": True,
|
||||
"fix": self._fix,
|
||||
"yes": self._yes,
|
||||
}
|
||||
|
||||
# See what version of python the hook has been written against.
|
||||
data = open(self._script_fullpath).read()
|
||||
@@ -383,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)
|
||||
@@ -395,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
|
||||
)
|
||||
|
||||
@@ -462,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)
|
||||
|
||||
@@ -497,12 +510,18 @@ class RepoHook:
|
||||
"origin"
|
||||
).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
|
||||
@@ -526,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:
|
||||
@@ -45,34 +45,22 @@ from color import SetDefaultColoring
|
||||
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 error
|
||||
import event_log
|
||||
from git_command import user_agent
|
||||
import git_command
|
||||
from git_config import RepoConfig
|
||||
from git_trace2_event_log import EventLog
|
||||
from manifest_xml import RepoClient
|
||||
from pager import RunPager
|
||||
from pager import TerminatePager
|
||||
from repo_trace import SetTrace
|
||||
from repo_trace import SetTraceToStderr
|
||||
from repo_trace import Trace
|
||||
import repo_trace
|
||||
from subcmds import all_commands
|
||||
from subcmds.version import Version
|
||||
from wrapper import Wrapper
|
||||
from wrapper import WrapperPath
|
||||
|
||||
|
||||
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 +92,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,
|
||||
@@ -274,7 +267,7 @@ class _Repo:
|
||||
run = (
|
||||
lambda: self._RunLong(name, gopts, argv, git_trace2_event_log) or 0
|
||||
)
|
||||
with Trace(
|
||||
with repo_trace.Trace(
|
||||
"starting new command: %s [sid=%s]",
|
||||
", ".join([name] + argv),
|
||||
git_trace2_event_log.full_sid,
|
||||
@@ -430,7 +423,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"
|
||||
@@ -506,23 +499,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 +530,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 +566,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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-11
@@ -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" "August 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>
|
||||
@@ -336,6 +337,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
|
||||
|
||||
+10
-4
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "June 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)
|
||||
@@ -68,6 +68,9 @@ fetch all branches from server
|
||||
\fB\-m\fR NAME.xml, \fB\-\-manifest\-name\fR=\fI\,NAME\/\fR.xml
|
||||
temporary manifest to use for this sync
|
||||
.TP
|
||||
\fB\-g\fR GROUP, \fB\-\-groups\fR=\fI\,GROUP\/\fR
|
||||
sync projects matching the specific groups. Not persistent unlike when used on init
|
||||
.TP
|
||||
\fB\-\-clone\-bundle\fR
|
||||
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS
|
||||
.TP
|
||||
@@ -80,8 +83,11 @@ username to authenticate with the manifest server
|
||||
\fB\-p\fR MANIFEST_SERVER_PASSWORD, \fB\-\-manifest\-server\-password\fR=\fI\,MANIFEST_SERVER_PASSWORD\/\fR
|
||||
password to authenticate with the manifest server
|
||||
.TP
|
||||
\fB\-\-fetch\-submodules\fR
|
||||
fetch submodules from server
|
||||
\fB\-\-recurse\-submodules\fR
|
||||
sync submodules from server
|
||||
.TP
|
||||
\fB\-\-no\-recurse\-submodules\fR
|
||||
don't sync submodules from server
|
||||
.TP
|
||||
\fB\-\-use\-superproject\fR
|
||||
use the manifest superproject to sync projects; implies \fB\-c\fR
|
||||
|
||||
+23
-6
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "June 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)
|
||||
@@ -68,6 +68,9 @@ fetch all branches from server
|
||||
\fB\-m\fR NAME.xml, \fB\-\-manifest\-name\fR=\fI\,NAME\/\fR.xml
|
||||
temporary manifest to use for this sync
|
||||
.TP
|
||||
\fB\-g\fR GROUP, \fB\-\-groups\fR=\fI\,GROUP\/\fR
|
||||
sync projects matching the specific groups. Not persistent unlike when used on init
|
||||
.TP
|
||||
\fB\-\-clone\-bundle\fR
|
||||
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS
|
||||
.TP
|
||||
@@ -80,8 +83,11 @@ username to authenticate with the manifest server
|
||||
\fB\-p\fR MANIFEST_SERVER_PASSWORD, \fB\-\-manifest\-server\-password\fR=\fI\,MANIFEST_SERVER_PASSWORD\/\fR
|
||||
password to authenticate with the manifest server
|
||||
.TP
|
||||
\fB\-\-fetch\-submodules\fR
|
||||
fetch submodules from server
|
||||
\fB\-\-recurse\-submodules\fR
|
||||
sync submodules from server
|
||||
.TP
|
||||
\fB\-\-no\-recurse\-submodules\fR
|
||||
don't sync submodules from server
|
||||
.TP
|
||||
\fB\-\-use\-superproject\fR
|
||||
use the manifest superproject to sync projects; implies \fB\-c\fR
|
||||
@@ -119,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:
|
||||
@@ -179,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.
|
||||
@@ -212,8 +225,12 @@ bootstrap a new Git repository from a resumeable bundle file on a content
|
||||
delivery network. This may be necessary if there are problems with the local
|
||||
Python HTTP client or proxy configuration, but the Git binary works.
|
||||
.PP
|
||||
The \fB\-\-fetch\-submodules\fR option enables fetching Git submodules of a project from
|
||||
server.
|
||||
The \fB\-\-recurse\-submodules\fR option enables syncing Git submodules of all projects
|
||||
from the server. The \fB\-\-no\-recurse\-submodules\fR option disables syncing Git
|
||||
submodules, even when a project has sync\-s="true" in the manifest.
|
||||
.PP
|
||||
The \fB\-\-fetch\-submodules\fR and \fB\-\-no\-fetch\-submodules\fR options are deprecated aliases
|
||||
for \fB\-\-recurse\-submodules\fR and \fB\-\-no\-recurse\-submodules\fR, respectively.
|
||||
.PP
|
||||
The \fB\-c\fR/\-\-current\-branch option can be used to only fetch objects that are on the
|
||||
branch specified by a project's revision.
|
||||
|
||||
+4
-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" "August 2026" "repo upload" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo upload - manual page for repo upload
|
||||
.SH SYNOPSIS
|
||||
@@ -112,6 +112,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
|
||||
|
||||
+92
-77
@@ -21,12 +21,9 @@ import sys
|
||||
import urllib.parse
|
||||
import xml.dom.minidom
|
||||
|
||||
from error import ManifestInvalidPathError
|
||||
from error import ManifestInvalidRevisionError
|
||||
from error import ManifestParseError
|
||||
import error
|
||||
from git_config import GitConfig
|
||||
from git_refs import HEAD
|
||||
from git_refs import R_HEADS
|
||||
import git_refs
|
||||
from git_superproject import Superproject
|
||||
import platform_utils
|
||||
from project import Annotation
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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:
|
||||
@@ -848,7 +864,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}"'
|
||||
)
|
||||
|
||||
@@ -1173,7 +1189,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 +1207,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 +1261,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 +1323,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 +1337,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 +1354,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 +1364,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 +1376,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 +1403,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 +1419,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 +1433,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 +1444,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 +1454,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 +1463,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 +1494,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 +1516,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 +1537,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)
|
||||
|
||||
@@ -1555,7 +1573,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
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 +1587,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 +1596,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,7 +1604,7 @@ 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)
|
||||
)
|
||||
@@ -1609,7 +1627,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 +1640,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 +1654,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 +1672,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 +1690,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 +1707,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 +1792,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 +1807,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 +1875,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 +1923,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 +1933,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 +1941,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 +1954,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 +1965,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)
|
||||
)
|
||||
@@ -2244,7 +2259,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 +2270,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,10 +2299,10 @@ 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)
|
||||
@@ -2299,7 +2314,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 +2325,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 +2363,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:
|
||||
|
||||
+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)
|
||||
|
||||
+1270
-419
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):
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@
|
||||
import logging
|
||||
|
||||
from color import Coloring
|
||||
from error import RepoExitError
|
||||
import error
|
||||
|
||||
|
||||
SEPARATOR = "=" * 80
|
||||
@@ -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):
|
||||
|
||||
@@ -13,6 +13,18 @@
|
||||
# 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==6.0.0',
|
||||
# 'flake8-bugbear==24.12.12',
|
||||
# 'flake8-comprehensions==3.16.0',
|
||||
# 'isort==5.10.1'
|
||||
# ]
|
||||
# ///
|
||||
|
||||
"""Wrapper to run linters and pytest with the right settings."""
|
||||
|
||||
import functools
|
||||
@@ -42,10 +54,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 +75,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 +152,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
+101
@@ -0,0 +1,101 @@
|
||||
attrs==24.2.0 \
|
||||
--hash=sha256:37c4653e51383dc1249dba15635d59011ca11714a8e61db8cd6dc73011e4b603 \
|
||||
--hash=sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2
|
||||
# via flake8-bugbear
|
||||
black==25.1.0 \
|
||||
--hash=sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717 \
|
||||
--hash=sha256:c9ed033e41998dd45d6f19bf9923e2444cb401185ccb26d1a2101a9049637c1f
|
||||
click==8.0.3 \
|
||||
--hash=sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3 \
|
||||
--hash=sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3 \
|
||||
--hash=sha256:b0a0492d07c8e7e4a88ee153ca6553d357a0746792d6868bfca79dc1a344bb1b
|
||||
# via black
|
||||
colorama==0.4.6 ; sys_platform == 'win32' \
|
||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 \
|
||||
--hash=sha256:8a5a9494346d7ae2d8f599e4661738007d2df56dbbf4ccbbf7a6a23679a3a302
|
||||
# via
|
||||
# click
|
||||
# pytest
|
||||
flake8==6.0.0 \
|
||||
--hash=sha256:9b5bf0ebd06aff1601e38dfc4e91f9b99ba3cdf446e74ee25a9cbbe01aef81ee \
|
||||
--hash=sha256:f34b5cb8bf908ff49e4673d3dbc557b2381a14132b637d53e9d3a59816f4b6ba
|
||||
# via
|
||||
# flake8-bugbear
|
||||
# flake8-comprehensions
|
||||
flake8-bugbear==24.12.12 \
|
||||
--hash=sha256:1b6967436f65ca22a42e5373aaa6f2d87966ade9aa38d4baf2a1be550767545e
|
||||
flake8-comprehensions==3.16.0 \
|
||||
--hash=sha256:7c1eadc9d22e765f39857798febe7766b4d9c519793c6c149e3e13bf99693f70
|
||||
iniconfig==1.1.1 \
|
||||
--hash=sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3 \
|
||||
--hash=sha256:9d81514370059186276e9a1799e5a9752e4c060233c254d6954b53fad2ff8a5e
|
||||
# via pytest
|
||||
isort==5.10.1 \
|
||||
--hash=sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7 \
|
||||
--hash=sha256:ec0da0d88470e89ade75bf1e2ea7b4349ec906f95c84aff08e4d41dea6864d25
|
||||
mccabe==0.7.0 \
|
||||
--hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e \
|
||||
--hash=sha256:b85abd0a321cdd357d0be34adbf49cf01453a857a23e94d44612ec6f159cb261
|
||||
# via flake8
|
||||
mypy-extensions==0.4.3 \
|
||||
--hash=sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d \
|
||||
--hash=sha256:38cbac35a502c4f13b9d9b1d06258fa51e01da4f5828b171b4d1200d00378e7c
|
||||
# via black
|
||||
packaging==23.0 \
|
||||
--hash=sha256:37cb5b4759d17ae6bad78ee3347f0ee1057452895fcd1bbff48fb12908ce00a1 \
|
||||
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2
|
||||
# via
|
||||
# black
|
||||
# pytest
|
||||
pathspec==0.9.0 \
|
||||
--hash=sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a \
|
||||
--hash=sha256:aaf90f4ad38a16bd3ebe0fa64da7f6ee01819c881120db68cc6440941a9adc08
|
||||
# via black
|
||||
platformdirs==2.5.2 \
|
||||
--hash=sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788 \
|
||||
--hash=sha256:e257a04b5d0528cb9836b766c8a16a85368e249cba7ae42888a403976ccd7d2d
|
||||
# via black
|
||||
pluggy==1.5.0 \
|
||||
--hash=sha256:40da5bf96b15e359e280fd760a80c11d7a8ea20c3c296c3f3e09f8be67cec10b \
|
||||
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669
|
||||
# via pytest
|
||||
py==1.11.0 \
|
||||
--hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378 \
|
||||
--hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378
|
||||
pycodestyle==2.10.0 \
|
||||
--hash=sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610 \
|
||||
--hash=sha256:aacf8d28ecea10fad31b2d14943a42d85d007f4d2fee8f212fc05751c8460647
|
||||
# via flake8
|
||||
pyflakes==3.0.1 \
|
||||
--hash=sha256:a968b8baa43926124b692c7e76279bc5fa8c6ef9a2281e4a2a475be69f954bdf \
|
||||
--hash=sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf
|
||||
# via flake8
|
||||
pyparsing==3.0.7 \
|
||||
--hash=sha256:943c220ca64e93940b74dd070152ed9ef1402bc3b28fd1f899843bee34e83708 \
|
||||
--hash=sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484
|
||||
pytest==8.3.4 \
|
||||
--hash=sha256:00cba7845d2dccfe5403025a688d60ceae228b1ddaa866d2eb86b2edb6923e73 \
|
||||
--hash=sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6
|
||||
toml==0.10.1 \
|
||||
--hash=sha256:4099063d9b50219bf09bfa693ab8dcd96ce585cee9fdadce5a48a08ee23b0646 \
|
||||
--hash=sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88
|
||||
tomli==2.0.1 \
|
||||
--hash=sha256:864f310e021d68415d6ba45b23e29808f99703b471d884267d6942eecdf8d6c5 \
|
||||
--hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc
|
||||
typing-extensions==4.3.0 \
|
||||
--hash=sha256:09126dab54422810a541e078c37e62024e7260a022d4f67c075a6707d9553649 \
|
||||
--hash=sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02
|
||||
@@ -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"
|
||||
|
||||
+7
-9
@@ -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
|
||||
import error
|
||||
import git_command
|
||||
from progress import Progress
|
||||
from repo_logging import RepoLogger
|
||||
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:
|
||||
@@ -94,7 +93,6 @@ It is equivalent to "git branch -D <branchname>".
|
||||
|
||||
def Execute(self, opt, args):
|
||||
nb = args[0].split()
|
||||
self.TryOverrideManifestWithSmartSync()
|
||||
err = collections.defaultdict(list)
|
||||
success = collections.defaultdict(list)
|
||||
aggregate_errors = []
|
||||
|
||||
+9
-10
@@ -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
|
||||
import error
|
||||
from progress import Progress
|
||||
from repo_logging import RepoLogger
|
||||
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]
|
||||
|
||||
+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))
|
||||
+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,
|
||||
|
||||
+8
-6
@@ -25,10 +25,10 @@ from color import Coloring
|
||||
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",
|
||||
@@ -243,13 +243,15 @@ without iterating through the remaining projects.
|
||||
|
||||
mirror = self.manifest.IsMirror
|
||||
|
||||
self.TryOverrideManifestWithSmartSync()
|
||||
|
||||
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(
|
||||
|
||||
+34
-16
@@ -16,8 +16,7 @@ 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
|
||||
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
|
||||
@@ -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
|
||||
+13
-15
@@ -19,14 +19,12 @@ from typing import NamedTuple
|
||||
from color import Coloring
|
||||
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):
|
||||
@@ -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("--")
|
||||
|
||||
+2
-2
@@ -19,12 +19,12 @@ import textwrap
|
||||
from color import Coloring
|
||||
from command import MirrorSafeCommand
|
||||
from command import PagedCommand
|
||||
from error import RepoExitError
|
||||
import error
|
||||
from subcmds import all_commands
|
||||
from wrapper import Wrapper
|
||||
|
||||
|
||||
class InvalidHelpCommand(RepoExitError):
|
||||
class InvalidHelpCommand(error.RepoExitError):
|
||||
"""Invalid command passed into help."""
|
||||
|
||||
|
||||
|
||||
+62
-33
@@ -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
|
||||
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):
|
||||
@@ -147,8 +146,6 @@ class Info(PagedCommand):
|
||||
if not opt.this_manifest_only:
|
||||
self.manifest = self.manifest.outer_client
|
||||
|
||||
self.TryOverrideManifestWithSmartSync()
|
||||
|
||||
output_format = OutputFormat[opt.format.upper()]
|
||||
if output_format == OutputFormat.JSON:
|
||||
self._ExecuteJson(opt, args)
|
||||
@@ -191,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
|
||||
@@ -287,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()
|
||||
@@ -299,7 +307,6 @@ class Info(PagedCommand):
|
||||
headtext(project.GetHeadRevisionId() or project.GetRevisionId())
|
||||
out.nl()
|
||||
|
||||
currentBranch = project.CurrentBranch
|
||||
if currentBranch:
|
||||
heading("Current branch: ")
|
||||
headtext(currentBranch)
|
||||
@@ -309,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:
|
||||
@@ -325,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)")
|
||||
@@ -377,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)
|
||||
|
||||
@@ -406,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(
|
||||
@@ -418,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
|
||||
|
||||
+13
-10
@@ -18,21 +18,21 @@ import sys
|
||||
from color import Coloring
|
||||
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
|
||||
import error
|
||||
import git_command
|
||||
import repo_logging
|
||||
from wrapper import Wrapper
|
||||
from wrapper import WrapperDir
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
_REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW")
|
||||
|
||||
|
||||
class Init(InteractiveCommand, MirrorSafeCommand):
|
||||
COMMON = True
|
||||
RESPECT_SMART_SYNC_OVERRIDE = False
|
||||
MULTI_MANIFEST_SUPPORT = True
|
||||
helpSummary = "Initialize a repo client checkout in the current directory"
|
||||
helpUsage = """
|
||||
@@ -163,6 +163,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,
|
||||
@@ -172,7 +173,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}"
|
||||
)
|
||||
|
||||
@@ -346,9 +347,9 @@ to update the working directory files.
|
||||
wrapper = Wrapper()
|
||||
|
||||
reqs = wrapper.Requirements.from_dir(WrapperDir())
|
||||
git_require(reqs.get_hard_ver("git"), fail=True)
|
||||
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 "
|
||||
@@ -377,7 +378,7 @@ to update the working directory files.
|
||||
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
|
||||
@@ -387,7 +388,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):
|
||||
|
||||
+9
-4
@@ -59,10 +59,15 @@ 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:
|
||||
@@ -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 " ",
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ class Prune(PagedCommand):
|
||||
print(
|
||||
"%s %-33s "
|
||||
% (
|
||||
branch.name == project.CurrentBranch and "*" or " ",
|
||||
branch.current and "*" or " ",
|
||||
branch.name,
|
||||
),
|
||||
end="",
|
||||
|
||||
+20
-24
@@ -16,11 +16,13 @@ import sys
|
||||
|
||||
from color import Coloring
|
||||
from command import Command
|
||||
from git_command import GitCommand
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import git_command
|
||||
from project import Project
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class RebaseColoring(Coloring):
|
||||
@@ -30,6 +32,17 @@ class RebaseColoring(Coloring):
|
||||
self.fail = self.printer("fail", fg="red")
|
||||
|
||||
|
||||
def _ResolveOntoManifest(project: Project) -> str:
|
||||
"""Resolve project's revisionExpr to a local tracking branch.
|
||||
|
||||
Falls back to the raw revisionExpr if ToLocal fails or raises GitError.
|
||||
"""
|
||||
try:
|
||||
return project.GetRemote().ToLocal(project.revisionExpr)
|
||||
except error.GitError:
|
||||
return project.revisionExpr
|
||||
|
||||
|
||||
class Rebase(Command):
|
||||
COMMON = True
|
||||
helpSummary = "Rebase local branches on upstream branch"
|
||||
@@ -126,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)
|
||||
@@ -162,7 +177,7 @@ branch but need to incorporate new upstream changes "underneath" them.
|
||||
args = common_args[:]
|
||||
if opt.onto_manifest:
|
||||
args.append("--onto")
|
||||
args.append(project.revisionExpr)
|
||||
args.append(_ResolveOntoManifest(project))
|
||||
|
||||
args.append(upbranch.LocalMerge)
|
||||
|
||||
@@ -175,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."""
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -16,11 +16,11 @@ import sys
|
||||
|
||||
from color import Coloring
|
||||
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):
|
||||
@@ -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()
|
||||
+9
-10
@@ -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
|
||||
import error
|
||||
import git_command
|
||||
from git_config import IsImmutable
|
||||
from progress import Progress
|
||||
from repo_logging import RepoLogger
|
||||
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,7 +85,7 @@ 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 project.dest_branch:
|
||||
@@ -99,12 +99,11 @@ 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]
|
||||
self.TryOverrideManifestWithSmartSync()
|
||||
err_projects = []
|
||||
err = []
|
||||
projects = []
|
||||
|
||||
+549
-109
File diff suppressed because it is too large.
Load diff
+72
-47
@@ -22,23 +22,20 @@ 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 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."""
|
||||
|
||||
|
||||
@@ -379,7 +376,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 +433,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 = {}
|
||||
|
||||
@@ -598,11 +607,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 +658,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 +670,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 +701,64 @@ 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)
|
||||
avail = [up_branch] if up_branch else None
|
||||
else:
|
||||
avail = project.GetUploadableBranches(opt.branch)
|
||||
return (project_idx, avail)
|
||||
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 +768,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 +776,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:
|
||||
|
||||
+25
-10
@@ -14,13 +14,12 @@
|
||||
|
||||
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
|
||||
import git_command
|
||||
import git_refs
|
||||
from wrapper import Wrapper
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+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}"
|
||||
)
|
||||
+265
-4
@@ -14,6 +14,10 @@
|
||||
|
||||
"""Unittests for the command.py module."""
|
||||
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from command import Command
|
||||
|
||||
|
||||
@@ -28,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):
|
||||
@@ -49,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():
|
||||
@@ -86,3 +141,209 @@ def test_get_projects_keeps_derived_subprojects_for_repeated_repo():
|
||||
projects = cmd.GetProjects([])
|
||||
|
||||
assert set(projects) == {project_a, project_b, submodule_a, submodule_b}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"submodules_ok, sync_s, includes_submodule",
|
||||
[
|
||||
(None, False, False),
|
||||
(None, True, True),
|
||||
(True, False, True),
|
||||
(True, True, True),
|
||||
(False, False, False),
|
||||
(False, True, False),
|
||||
],
|
||||
)
|
||||
def test_get_projects_submodule_override(
|
||||
submodules_ok, sync_s, includes_submodule
|
||||
):
|
||||
"""The CLI override takes precedence over a project's sync-s setting."""
|
||||
submodule = FakeProject("submodule", "project/submodule")
|
||||
project = FakeProject(
|
||||
"project",
|
||||
"project",
|
||||
derived_subprojects=[submodule],
|
||||
sync_s=sync_s,
|
||||
)
|
||||
cmd = Command(manifest=FakeManifest([project]))
|
||||
|
||||
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]
|
||||
@@ -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()
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"""Unittests for the git_config.py module."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
@@ -256,8 +256,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 +268,81 @@ 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
|
||||
+149
-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():
|
||||
|
||||
@@ -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]
|
||||
@@ -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}")
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"""Unittests for the hooks.py module."""
|
||||
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -105,3 +106,98 @@ def test_post_sync_argument_validation() -> None:
|
||||
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("yes_val", (True, False))
|
||||
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: 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("yes"))
|
||||
"""
|
||||
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,
|
||||
yes=yes_val,
|
||||
)
|
||||
|
||||
project_list = []
|
||||
res = hook.Run(project_list=project_list, worktree_list=[])
|
||||
|
||||
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
|
||||
@@ -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>."""
|
||||
|
||||
+3133
-26
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the release/update-manpages wrapper."""
|
||||
|
||||
import runpy
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
|
||||
UPDATE_MANPAGES_SCRIPT = (
|
||||
utils_for_test.THIS_DIR.parent / "release" / "update-manpages"
|
||||
)
|
||||
|
||||
|
||||
def test_wrapper_does_not_run_main_for_multiprocessing_child(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Do not rerun main when multiprocessing re-executes the wrapper."""
|
||||
fake_update_manpages = ModuleType("update_manpages")
|
||||
|
||||
# Mock this because the real module requires newer Python versions than
|
||||
# our unittest framework does.
|
||||
monkeypatch.setitem(sys.modules, "update_manpages", fake_update_manpages)
|
||||
|
||||
runpy.run_path(
|
||||
str(UPDATE_MANPAGES_SCRIPT),
|
||||
run_name="__mp_main__",
|
||||
)
|
||||
@@ -23,16 +23,16 @@ from unittest import mock
|
||||
import pytest
|
||||
|
||||
from color import SetDefaultColoring
|
||||
from error import RepoExitError
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import repo_logging
|
||||
|
||||
|
||||
@mock.patch.object(RepoLogger, "error")
|
||||
@mock.patch.object(repo_logging.RepoLogger, "error")
|
||||
def test_log_aggregated_errors_logs_aggregated_errors(mock_error) -> None:
|
||||
"""Test if log_aggregated_errors logs a list of aggregated errors."""
|
||||
logger = RepoLogger(__name__)
|
||||
logger = repo_logging.RepoLogger(__name__)
|
||||
logger.log_aggregated_errors(
|
||||
RepoExitError(
|
||||
error.RepoExitError(
|
||||
aggregate_errors=[
|
||||
Exception("foo"),
|
||||
Exception("bar"),
|
||||
@@ -57,11 +57,11 @@ def test_log_aggregated_errors_logs_aggregated_errors(mock_error) -> None:
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(RepoLogger, "error")
|
||||
@mock.patch.object(repo_logging.RepoLogger, "error")
|
||||
def test_log_aggregated_errors_logs_single_error(mock_error) -> None:
|
||||
"""Test if log_aggregated_errors logs empty aggregated_errors."""
|
||||
logger = RepoLogger(__name__)
|
||||
logger.log_aggregated_errors(RepoExitError())
|
||||
logger = repo_logging.RepoLogger(__name__)
|
||||
logger.log_aggregated_errors(error.RepoExitError())
|
||||
|
||||
mock_error.assert_has_calls(
|
||||
[
|
||||
@@ -99,7 +99,7 @@ def test_log_with_format_string(level: int) -> None:
|
||||
output = io.StringIO()
|
||||
|
||||
with contextlib.redirect_stderr(output):
|
||||
logger = RepoLogger(__name__)
|
||||
logger = repo_logging.RepoLogger(__name__)
|
||||
logger.log(level, "%s", "100% pass")
|
||||
|
||||
assert re.search(
|
||||
|
||||
@@ -25,6 +25,9 @@ def test_trace_max_size_enforced(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Check Trace behavior."""
|
||||
content = "git chicken"
|
||||
|
||||
# Enable trace for the test, in case users have it disabled.
|
||||
monkeypatch.setattr(repo_trace, "_TRACE", True)
|
||||
|
||||
with repo_trace.Trace(content, first_trace=True):
|
||||
pass
|
||||
first_trace_size = os.path.getsize(repo_trace._TRACE_FILE)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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 subcmds/cherry_pick.py."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import error
|
||||
import git_command
|
||||
from subcmds import cherry_pick
|
||||
|
||||
|
||||
def test_resolve_reference_uses_one_typed_batch_request(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
oid = "1" * 40
|
||||
commit = "tree " + "2" * 40 + "\n\nSubject 🚀\n\nBody\n"
|
||||
output = oid + " commit " + str(len(commit.encode("utf-8"))) + "\n"
|
||||
output += commit + "\n"
|
||||
command = mock.create_autospec(git_command.GitCommand, instance=True)
|
||||
command.stdout = output
|
||||
command.stderr = ""
|
||||
command.Wait.return_value = 0
|
||||
run_git = mock.create_autospec(git_command.GitCommand, return_value=command)
|
||||
monkeypatch.setattr(git_command, "GitCommand", run_git)
|
||||
|
||||
resolved, contents = cherry_pick.CherryPick()._ResolveReference("topic")
|
||||
|
||||
assert resolved == oid
|
||||
assert contents == commit
|
||||
run_git.assert_called_once_with(
|
||||
None,
|
||||
["cat-file", "--batch"],
|
||||
input="topic^{commit}\n",
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
verify_command=True,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_reference_rejects_missing_object(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
command = mock.create_autospec(git_command.GitCommand, instance=True)
|
||||
command.stdout = "topic^{commit} missing\n"
|
||||
command.stderr = ""
|
||||
command.Wait.return_value = 0
|
||||
monkeypatch.setattr(
|
||||
git_command,
|
||||
"GitCommand",
|
||||
mock.create_autospec(git_command.GitCommand, return_value=command),
|
||||
)
|
||||
|
||||
with pytest.raises(error.GitError, match="commit topic not found"):
|
||||
cherry_pick.CherryPick()._ResolveReference("topic")
|
||||
|
||||
|
||||
def test_resolve_reference_rejects_ambiguous_object(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
command = mock.create_autospec(git_command.GitCommand, instance=True)
|
||||
command.stdout = "topic^{commit} ambiguous\n"
|
||||
command.stderr = ""
|
||||
command.Wait.return_value = 0
|
||||
monkeypatch.setattr(
|
||||
git_command,
|
||||
"GitCommand",
|
||||
mock.create_autospec(git_command.GitCommand, return_value=command),
|
||||
)
|
||||
|
||||
with pytest.raises(error.GitError, match="commit topic not found"):
|
||||
cherry_pick.CherryPick()._ResolveReference("topic")
|
||||
@@ -0,0 +1,343 @@
|
||||
# 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 subcmds/config.py module."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterator, List
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import command
|
||||
import error
|
||||
import git_config
|
||||
from subcmds import config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
[
|
||||
[],
|
||||
["get"],
|
||||
["get", "a.b", "c"],
|
||||
["set"],
|
||||
["set", "a.b"],
|
||||
["set", "a.b", "c", "d"],
|
||||
["unset"],
|
||||
["unset", "a.b", "c"],
|
||||
["list", "a.b"],
|
||||
],
|
||||
)
|
||||
def test_validate_options_usage(argv: List[str]) -> None:
|
||||
"""Wrong number of arguments for an action shows usage."""
|
||||
cmd = config.Config()
|
||||
opts, args = cmd.OptionParser.parse_args(argv)
|
||||
with pytest.raises(command.UsageError):
|
||||
cmd.ValidateOptions(opts, args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
[
|
||||
["bogus"],
|
||||
["a.b", "c"],
|
||||
["--type=bool", "unset", "a.b"],
|
||||
["--type=bool", "list"],
|
||||
["-z", "set", "a.b", "c"],
|
||||
["-z", "unset", "a.b"],
|
||||
],
|
||||
)
|
||||
def test_validate_options_error(argv: List[str]) -> None:
|
||||
"""Unknown actions and inapplicable options are rejected."""
|
||||
cmd = config.Config()
|
||||
opts, args = cmd.OptionParser.parse_args(argv)
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.ValidateOptions(opts, args)
|
||||
|
||||
|
||||
def test_validate_options_unknown_type() -> None:
|
||||
"""--type only accepts the supported types."""
|
||||
cmd = config.Config()
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.OptionParser.parse_args(["--type=path", "get", "a.b"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
[
|
||||
["get", "sync.smartsync"],
|
||||
["set", "sync.smartsync", "true"],
|
||||
["unset", "sync.smartsync"],
|
||||
["list"],
|
||||
["--type=bool", "get", "sync.smartsync"],
|
||||
["--type=int", "set", "a.b", "1"],
|
||||
["-z", "get", "sync.smartsync"],
|
||||
["--null", "list"],
|
||||
],
|
||||
)
|
||||
def test_validate_options_accepts(argv: List[str]) -> None:
|
||||
"""Valid invocations pass validation."""
|
||||
cmd = config.Config()
|
||||
opts, args = cmd.OptionParser.parse_args(argv)
|
||||
cmd.ValidateOptions(opts, args)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg(tmp_path: Path) -> Iterator[git_config.RepoConfig]:
|
||||
"""A RepoConfig backed by a temp file, used as the per-user config."""
|
||||
cfg = git_config.RepoConfig(configfile=tmp_path / "config")
|
||||
with mock.patch.object(git_config.RepoConfig, "ForUser", return_value=cfg):
|
||||
yield cfg
|
||||
|
||||
|
||||
def run(argv: List[str], capsys: pytest.CaptureFixture) -> str:
|
||||
"""Runs `repo config` with |argv| and returns its stdout."""
|
||||
cmd = config.Config()
|
||||
opts, args = cmd.OptionParser.parse_args(argv)
|
||||
cmd.ValidateOptions(opts, args)
|
||||
capsys.readouterr()
|
||||
cmd.Execute(opts, args)
|
||||
return capsys.readouterr().out
|
||||
|
||||
|
||||
def test_set_creates_file(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Setting a value writes it to the config file."""
|
||||
run(["set", "sync.smartsync", "true"], capsys)
|
||||
assert "smartsync = true" in Path(cfg.file).read_text()
|
||||
assert cfg.GetBoolean("sync.smartsync") is True
|
||||
|
||||
|
||||
def test_set_then_get(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Getting a value prints what was set."""
|
||||
run(["set", "sync.smartsync", "false"], capsys)
|
||||
assert run(["get", "sync.smartsync"], capsys) == "false\n"
|
||||
|
||||
|
||||
def test_set_overwrites(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Setting an existing key replaces its value."""
|
||||
run(["set", "sync.smartsync", "true"], capsys)
|
||||
run(["set", "sync.smartsync", "false"], capsys)
|
||||
assert run(["get", "sync.smartsync"], capsys) == "false\n"
|
||||
assert Path(cfg.file).read_text().count("smartsync") == 1
|
||||
|
||||
|
||||
def test_set_invalid_name(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""git's own validation of the name is surfaced as a clean error."""
|
||||
with pytest.raises(config.Error) as e:
|
||||
run(["set", "nodot", "true"], capsys)
|
||||
assert "nodot" in str(e.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[
|
||||
("true", "true"),
|
||||
("Yes", "true"),
|
||||
("on", "true"),
|
||||
("1", "true"),
|
||||
("false", "false"),
|
||||
("No", "false"),
|
||||
("off", "false"),
|
||||
("0", "false"),
|
||||
],
|
||||
)
|
||||
def test_set_type_bool(
|
||||
cfg: git_config.RepoConfig,
|
||||
capsys: pytest.CaptureFixture,
|
||||
value: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""--type=bool canonicalizes accepted spellings on set."""
|
||||
run(["--type=bool", "set", "sync.smartsync", value], capsys)
|
||||
assert run(["get", "sync.smartsync"], capsys) == f"{expected}\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[
|
||||
("42", "42"),
|
||||
("1k", "1024"),
|
||||
("2M", "2097152"),
|
||||
("1g", "1073741824"),
|
||||
("0x10", "16"),
|
||||
],
|
||||
)
|
||||
def test_set_type_int(
|
||||
cfg: git_config.RepoConfig,
|
||||
capsys: pytest.CaptureFixture,
|
||||
value: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""--type=int canonicalizes accepted spellings on set."""
|
||||
run(["--type=int", "set", "section.num", value], capsys)
|
||||
assert run(["get", "section.num"], capsys) == f"{expected}\n"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
[
|
||||
["--type=bool", "set", "sync.smartsync", "maybe"],
|
||||
["--type=bool", "set", "sync.smartsync", "2"],
|
||||
["--type=int", "set", "section.num", "lots"],
|
||||
["--type=int", "set", "section.num", "1x"],
|
||||
["--type=int", "set", "section.num", ""],
|
||||
],
|
||||
)
|
||||
def test_set_type_rejects_bad_values(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture, argv: List[str]
|
||||
) -> None:
|
||||
"""--type rejects values that do not parse and writes nothing."""
|
||||
with pytest.raises(config.Error):
|
||||
run(argv, capsys)
|
||||
assert not Path(cfg.file).exists()
|
||||
|
||||
|
||||
def test_get_type_canonicalizes(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""--type on get prints the canonical form of a stored value."""
|
||||
Path(cfg.file).write_text("[a]\n\tb = Yes\n\tn = 1k\n")
|
||||
assert run(["--type=bool", "get", "a.b"], capsys) == "true\n"
|
||||
assert run(["--type=int", "get", "a.n"], capsys) == "1024\n"
|
||||
with pytest.raises(config.Error):
|
||||
run(["--type=int", "get", "a.b"], capsys)
|
||||
|
||||
|
||||
def test_get_missing_exits_quietly(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Reading an unset key exits 1 without output, like git config."""
|
||||
cmd = config.Config()
|
||||
opts, args = cmd.OptionParser.parse_args(["get", "sync.smartsync"])
|
||||
with pytest.raises(config.NotSetError) as e:
|
||||
cmd.Execute(opts, args)
|
||||
assert e.value.exit_code == 1
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_get_multiple_values(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""All values of a multi-valued key are printed."""
|
||||
cfg.SetString("section.multi", ["one", "two"])
|
||||
assert run(["get", "section.multi"], capsys) == "one\ntwo\n"
|
||||
|
||||
|
||||
def test_get_valueless_key(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""A key present without a value prints an empty line, like git."""
|
||||
Path(cfg.file).write_text("[sync]\n\tsmartsync\n")
|
||||
assert run(["get", "sync.smartsync"], capsys) == "\n"
|
||||
|
||||
|
||||
def test_get_null(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""-z terminates each value with NUL."""
|
||||
cfg.SetString("section.multi", ["one", "two"])
|
||||
assert run(["-z", "get", "section.multi"], capsys) == "one\0two\0"
|
||||
|
||||
|
||||
def test_unset(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""unset removes the key."""
|
||||
run(["set", "sync.smartsync", "true"], capsys)
|
||||
run(["unset", "sync.smartsync"], capsys)
|
||||
assert cfg.GetString("sync.smartsync") is None
|
||||
assert "smartsync" not in Path(cfg.file).read_text()
|
||||
|
||||
|
||||
def test_unset_missing_is_noop(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""unset on a missing key does not fail or create the file."""
|
||||
run(["unset", "sync.smartsync"], capsys)
|
||||
assert not Path(cfg.file).exists()
|
||||
|
||||
|
||||
def test_list(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""list prints all keys sorted as key=value, with every value."""
|
||||
run(["set", "sync.smartsync", "true"], capsys)
|
||||
run(["set", "alias.ss", "sync -s"], capsys)
|
||||
cfg.SetString("section.multi", ["one", "two"])
|
||||
assert run(["list"], capsys) == (
|
||||
"alias.ss=sync -s\n"
|
||||
"section.multi=one\n"
|
||||
"section.multi=two\n"
|
||||
"sync.smartsync=true\n"
|
||||
)
|
||||
|
||||
|
||||
def test_list_null(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""-z separates key and value with a newline and entries with NUL."""
|
||||
Path(cfg.file).write_text("[alias]\n\tss = sync -s\n[sync]\n\tsmartsync\n")
|
||||
assert run(["-z", "list"], capsys) == "alias.ss\nsync -s\0sync.smartsync\0"
|
||||
|
||||
|
||||
def test_list_valueless_key(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""list prints a bare key for a key without a value, like git."""
|
||||
Path(cfg.file).write_text("[sync]\n\tsmartsync\n")
|
||||
assert run(["list"], capsys) == "sync.smartsync\n"
|
||||
|
||||
|
||||
def test_list_empty(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""list on a missing file prints nothing."""
|
||||
assert run(["list"], capsys) == ""
|
||||
|
||||
|
||||
def test_value_with_spaces(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Values containing spaces round trip."""
|
||||
run(["set", "alias.ss", "sync -s -j 4"], capsys)
|
||||
assert run(["get", "alias.ss"], capsys) == "sync -s -j 4\n"
|
||||
|
||||
|
||||
def test_key_is_case_insensitive(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Section and name are case-insensitive, like git."""
|
||||
run(["set", "Sync.SmartSync", "true"], capsys)
|
||||
assert run(["get", "sync.smartsync"], capsys) == "true\n"
|
||||
|
||||
|
||||
def test_write_failure_is_reported(
|
||||
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""A git failure while writing becomes a clean error, not a traceback."""
|
||||
with mock.patch.object(
|
||||
cfg, "SetString", side_effect=error.GitError("locked")
|
||||
):
|
||||
with pytest.raises(config.Error) as e:
|
||||
run(["set", "sync.smartsync", "true"], capsys)
|
||||
assert "locked" in str(e.value)
|
||||
@@ -17,7 +17,9 @@
|
||||
import contextlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
import manifest_xml
|
||||
@@ -105,3 +107,57 @@ def test_forall_all_projects_called_once(tmp_path: Path) -> None:
|
||||
line_count = sum(1 for x in output.splitlines() if x)
|
||||
# Verify that we didn't get more lines than expected.
|
||||
assert line_count == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("regex_option", "inverse"),
|
||||
[
|
||||
("-r", False),
|
||||
("-i", True),
|
||||
],
|
||||
ids=("regex", "inverse-regex"),
|
||||
)
|
||||
def test_forall_regex_modes_pass_groups_to_find_projects(
|
||||
tmp_path: Path,
|
||||
regex_option: str,
|
||||
inverse: bool,
|
||||
) -> None:
|
||||
"""Pass --groups through in regex modes."""
|
||||
manifest = _create_manifest_with_8_projects(tmp_path)
|
||||
|
||||
cmd = subcmds.forall.Forall()
|
||||
cmd.manifest = manifest
|
||||
|
||||
opts, args = cmd.OptionParser.parse_args(
|
||||
[
|
||||
regex_option,
|
||||
"--groups",
|
||||
"special",
|
||||
"project",
|
||||
"-c",
|
||||
"true",
|
||||
]
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
cmd,
|
||||
"FindProjects",
|
||||
return_value=[],
|
||||
) as find_projects, mock.patch.object(
|
||||
cmd,
|
||||
"ExecuteInParallel",
|
||||
return_value=0,
|
||||
):
|
||||
cmd.Execute(opts, args)
|
||||
|
||||
expected_kwargs = {
|
||||
"groups": "special",
|
||||
"all_manifests": True,
|
||||
}
|
||||
if inverse:
|
||||
expected_kwargs["inverse"] = True
|
||||
|
||||
find_projects.assert_called_once_with(
|
||||
["project"],
|
||||
**expected_kwargs,
|
||||
)
|
||||
+108
-1
@@ -14,9 +14,11 @@
|
||||
|
||||
"""Unittests for the subcmds/gc.py module."""
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import manifest_xml
|
||||
from subcmds import gc
|
||||
|
||||
|
||||
@@ -24,7 +26,7 @@ class GcCommand(unittest.TestCase):
|
||||
"""Tests for gc command."""
|
||||
|
||||
def setUp(self):
|
||||
self.cmd = gc.Gc()
|
||||
self.cmd = gc.Gc(manifest=mock.MagicMock())
|
||||
self.opt, self.args = self.cmd.OptionParser.parse_args([])
|
||||
self.opt.this_manifest_only = False
|
||||
self.opt.repack = False
|
||||
@@ -80,3 +82,108 @@ class GcCommand(unittest.TestCase):
|
||||
ret = self.cmd.Execute(self.opt, [])
|
||||
self.assertEqual(ret, 1)
|
||||
self.mock_repack.assert_not_called()
|
||||
|
||||
|
||||
def test_gc_keep_manifest_projects(tmp_path: Path) -> None:
|
||||
"""Keep inactive projects that are still in the manifest."""
|
||||
repodir = tmp_path / ".repo"
|
||||
manifest_dir = repodir / "manifests"
|
||||
manifest_file = repodir / manifest_xml.MANIFEST_FILE_NAME
|
||||
|
||||
repodir.mkdir()
|
||||
manifest_dir.mkdir()
|
||||
|
||||
manifest_gitdir = repodir / "manifests.git"
|
||||
manifest_gitdir.mkdir()
|
||||
(manifest_gitdir / "config").write_text(
|
||||
"""[remote "origin"]
|
||||
url = https://localhost:0/manifest
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest_file.write_text(
|
||||
"""\
|
||||
<manifest>
|
||||
<remote name="origin" fetch="http://localhost" />
|
||||
<default remote="origin" revision="refs/heads/main" />
|
||||
<project name="included" groups="active-group" />
|
||||
<project name="excluded" groups="other-group" />
|
||||
</manifest>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest = manifest_xml.XmlManifest(str(repodir), str(manifest_file))
|
||||
manifest.manifestProject.config.SetString(
|
||||
"manifest.groups",
|
||||
"active-group",
|
||||
)
|
||||
|
||||
projects = {project.name: project for project in manifest.projects}
|
||||
included = projects["included"]
|
||||
excluded = projects["excluded"]
|
||||
|
||||
Path(included.gitdir).mkdir(parents=True)
|
||||
Path(included.objdir).mkdir(parents=True)
|
||||
|
||||
# Simulate an inactive project with only its object cache left.
|
||||
Path(excluded.objdir).mkdir(parents=True)
|
||||
assert not excluded.Exists
|
||||
|
||||
orphan_gitdir = repodir / "projects" / "orphan.git"
|
||||
orphan_objdir = repodir / "project-objects" / "orphan.git"
|
||||
orphan_gitdir.mkdir()
|
||||
orphan_objdir.mkdir()
|
||||
|
||||
cmd = gc.Gc(repodir=str(repodir), manifest=manifest)
|
||||
|
||||
opt, args = cmd.OptionParser.parse_args(
|
||||
["--yes", "--keep-manifest-projects"]
|
||||
)
|
||||
opt.quiet = True
|
||||
|
||||
selected_projects = cmd.GetProjects([], all_manifests=True)
|
||||
assert included in selected_projects
|
||||
assert excluded not in selected_projects
|
||||
|
||||
cmd.Execute(opt, args)
|
||||
|
||||
assert included.Exists
|
||||
assert Path(excluded.objdir).exists()
|
||||
assert not orphan_gitdir.exists()
|
||||
assert not orphan_objdir.exists()
|
||||
|
||||
|
||||
def test_gc_keep_manifest_projects_preserves_repack_targets() -> None:
|
||||
"""Keep manifest projects without expanding repack targets."""
|
||||
cmd = gc.Gc()
|
||||
opt, _ = cmd.OptionParser.parse_args(
|
||||
["--keep-manifest-projects", "--repack"]
|
||||
)
|
||||
opt.this_manifest_only = False
|
||||
|
||||
with mock.patch.object(
|
||||
cmd,
|
||||
"GetProjects",
|
||||
side_effect=[["projA"], ["manifest_projects"]],
|
||||
) as get_projects, mock.patch.object(
|
||||
cmd, "delete_unused_projects", return_value=0
|
||||
) as delete, mock.patch.object(
|
||||
cmd, "repack_projects", return_value=0
|
||||
) as repack:
|
||||
cmd.Execute(opt, ["projA"])
|
||||
|
||||
get_projects.assert_has_calls(
|
||||
[
|
||||
mock.call(["projA"], all_manifests=True),
|
||||
mock.call(
|
||||
[],
|
||||
groups="all",
|
||||
missing_ok=True,
|
||||
all_manifests=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
delete.assert_called_once_with(["manifest_projects"], opt)
|
||||
repack.assert_called_once_with(["projA"], opt)
|
||||
+116
-2
@@ -208,7 +208,7 @@ def test_get_project_data_uses_head_revision() -> None:
|
||||
project.name = "foo"
|
||||
project.worktree = "/path/to/foo"
|
||||
project.revisionExpr = "refs/heads/main"
|
||||
project.GetBranches.return_value = []
|
||||
project.GetBranches.return_value = {}
|
||||
|
||||
# GetHeadRevisionId() returns a SHA, it should be used.
|
||||
project.GetHeadRevisionId.return_value = "head_sha_12345"
|
||||
@@ -235,7 +235,9 @@ def test_json_with_projects(capsys) -> None:
|
||||
project.name = "foo"
|
||||
project.worktree = "/path/to/foo"
|
||||
project.revisionExpr = "refs/heads/main"
|
||||
project.GetBranches.return_value = {"branch1": mock.MagicMock()}
|
||||
branch = mock.MagicMock()
|
||||
branch.current = True
|
||||
project.GetBranches.return_value = {"branch1": branch}
|
||||
project.GetHeadRevisionId.return_value = "head_sha_12345"
|
||||
project.CurrentBranch = "branch1"
|
||||
|
||||
@@ -253,3 +255,115 @@ def test_json_with_projects(capsys) -> None:
|
||||
assert project_data["manifest_revision"] == "refs/heads/main"
|
||||
assert project_data["local_branches"] == ["branch1"]
|
||||
assert project_data["current_branch"] == "branch1"
|
||||
|
||||
|
||||
def test_diff_commits_uses_one_left_right_walk() -> None:
|
||||
"""Local and remote commits are partitioned from one rev-list."""
|
||||
project = mock.MagicMock()
|
||||
project.work_git.rev_list.return_value = [
|
||||
"<11111111 local commit",
|
||||
">22222222 remote commit",
|
||||
]
|
||||
|
||||
local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main")
|
||||
|
||||
assert local == ["11111111 local commit"]
|
||||
assert remote == ["22222222 remote commit"]
|
||||
project.work_git.rev_list.assert_called_once_with(
|
||||
"--left-right",
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
"HEAD...refs/remotes/m/main",
|
||||
"--",
|
||||
)
|
||||
|
||||
|
||||
def test_diff_commits_falls_back_to_bare_git_when_no_worktree() -> None:
|
||||
"""Bare or worktree-less projects fall back to bare_git for history walk."""
|
||||
project = mock.MagicMock()
|
||||
project.work_git = None
|
||||
project.bare_git.rev_list.return_value = [
|
||||
"<11111111 local commit",
|
||||
">22222222 remote commit",
|
||||
]
|
||||
|
||||
local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main")
|
||||
|
||||
assert local == ["11111111 local commit"]
|
||||
assert remote == ["22222222 remote commit"]
|
||||
project.bare_git.rev_list.assert_called_once_with(
|
||||
"--left-right",
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
"HEAD...refs/remotes/m/main",
|
||||
"--",
|
||||
)
|
||||
|
||||
|
||||
def test_diff_commits_empty_output() -> None:
|
||||
"""Empty rev-list output produces empty local and remote commit lists."""
|
||||
project = mock.MagicMock()
|
||||
project.work_git.rev_list.return_value = []
|
||||
|
||||
local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main")
|
||||
|
||||
assert local == []
|
||||
assert remote == []
|
||||
|
||||
|
||||
def test_get_current_branch() -> None:
|
||||
"""_GetCurrentBranch identifies the branch with current=True."""
|
||||
b1 = mock.MagicMock(current=False)
|
||||
b2 = mock.MagicMock(current=True)
|
||||
assert info.Info._GetCurrentBranch({"b1": b1, "b2": b2}) == "b2"
|
||||
assert info.Info._GetCurrentBranch({"b1": b1}) is None
|
||||
assert info.Info._GetCurrentBranch({}) is None
|
||||
|
||||
|
||||
def test_overview_helper_current_branch_filters_before_uploadable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""_OverviewHelper only checks uploadable state for the current branch."""
|
||||
project = mock.MagicMock()
|
||||
project.RelPath.return_value = "proj"
|
||||
b1 = mock.MagicMock(current=False)
|
||||
b2 = mock.MagicMock(current=True)
|
||||
project.GetBranches.return_value = {"b1": b1, "b2": b2}
|
||||
uploadable = mock.MagicMock(commits=["c1"], date="2026-09-21")
|
||||
uploadable.name = "b2"
|
||||
project.GetUploadableBranch.return_value = uploadable
|
||||
monkeypatch.setattr(
|
||||
info.Info,
|
||||
"get_parallel_context",
|
||||
lambda: {"projects": [project]},
|
||||
)
|
||||
opt = mock.MagicMock(current_branch=True, this_manifest_only=False)
|
||||
|
||||
result = info.Info._OverviewHelper(0, opt)
|
||||
|
||||
project.GetUploadableBranch.assert_called_once_with("b2")
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "b2"
|
||||
assert result[0].is_current is True
|
||||
|
||||
|
||||
def test_overview_helper_current_branch_detached_head_skips_uploadable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""_OverviewHelper skips GetUploadableBranch when detached with -b."""
|
||||
project = mock.MagicMock()
|
||||
b1 = mock.MagicMock(current=False)
|
||||
project.GetBranches.return_value = {"b1": b1}
|
||||
monkeypatch.setattr(
|
||||
info.Info,
|
||||
"get_parallel_context",
|
||||
lambda: {"projects": [project]},
|
||||
)
|
||||
opt = mock.MagicMock(current_branch=True, this_manifest_only=False)
|
||||
|
||||
result = info.Info._OverviewHelper(0, opt)
|
||||
|
||||
project.GetUploadableBranch.assert_not_called()
|
||||
assert result == []
|
||||
@@ -23,7 +23,13 @@ from subcmds import init
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
([],),
|
||||
(
|
||||
[],
|
||||
["-s"],
|
||||
["--smart-sync"],
|
||||
["--no-smart-sync"],
|
||||
["--head"],
|
||||
),
|
||||
)
|
||||
def test_cli_parser_good(argv: List[str]) -> None:
|
||||
"""Check valid command line options."""
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unittests for the list subcmd."""
|
||||
|
||||
from typing import List, Optional
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import subcmds
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extra_args", "expected_groups", "expected_missing_ok"),
|
||||
[
|
||||
(["--groups", "special"], "special", None),
|
||||
(["--all"], None, True),
|
||||
],
|
||||
ids=("groups", "all"),
|
||||
)
|
||||
def test_list_regex_passes_groups_and_all(
|
||||
extra_args: List[str],
|
||||
expected_groups: Optional[str],
|
||||
expected_missing_ok: Optional[bool],
|
||||
) -> None:
|
||||
"""Pass --groups and --all through in regex mode."""
|
||||
cmd = subcmds.list.List()
|
||||
|
||||
opts, args = cmd.OptionParser.parse_args(
|
||||
["--regex", *extra_args, "project"]
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
cmd,
|
||||
"FindProjects",
|
||||
return_value=[],
|
||||
) as find_projects:
|
||||
cmd.Execute(opts, args)
|
||||
|
||||
find_projects.assert_called_once_with(
|
||||
["project"],
|
||||
groups=expected_groups,
|
||||
missing_ok=expected_missing_ok,
|
||||
all_manifests=True,
|
||||
)
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user