mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-11-15 16:57:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3656290156 | ||
|
|
b90c7cd8f9 | ||
|
|
c5340585b8 | ||
|
|
86a7bec83d | ||
|
|
338ddb7d83 | ||
|
|
8c316d7c98 | ||
|
|
e3e8e8a3b0 | ||
|
|
cad9f591a4 | ||
|
|
24197b8ae1 | ||
|
|
bfb9869b89 | ||
|
|
887f9a9014 | ||
|
|
dc1811ba23 | ||
|
|
9aac80dafc | ||
|
|
f4ce9572d1 | ||
|
|
cc1f41d782 | ||
|
|
f7d252f5dc | ||
|
|
f95e7cd899 | ||
|
|
2c04b98c60 | ||
|
|
4212588c20 | ||
|
|
be06f8dd01 | ||
|
|
74d887ea86 | ||
|
|
29d644a5c0 | ||
|
|
a93741993b | ||
|
|
9991aa6177 | ||
|
|
d744eb15f3 | ||
|
|
f0124d0fd5 | ||
|
|
46e9866ca0 | ||
|
|
404aec7b53 | ||
|
|
a0bc2b14a3 | ||
|
|
3448df3954 | ||
|
|
a110680ff3 | ||
|
|
178b66ee3e | ||
|
|
beda65323e | ||
|
|
02e07525f9 | ||
|
|
2e8a3805bb | ||
|
|
277759b9e1 | ||
|
|
9593a76fd1 | ||
|
|
9293cda144 | ||
|
|
340b587e94 | ||
|
|
0a4e68f9d2 | ||
|
|
7bba4ee47e | ||
|
|
d53a4278f5 | ||
|
|
5d4149f32e | ||
|
|
cf499e312a | ||
|
|
cc0546bc71 | ||
|
|
ccf9ce39b8 | ||
|
|
9066409c2d | ||
|
|
2908f596c5 | ||
|
|
e1e215a14e | ||
|
|
175198b0aa | ||
|
|
310c99571f | ||
|
|
e7d4d97ffc | ||
|
|
8c7e0a683e | ||
|
|
f6f5946422 | ||
|
|
6321b26685 | ||
|
|
7bffc72b5b | ||
|
|
aaadd5da35 | ||
|
|
c9448f986e | ||
|
|
530258d08e | ||
|
|
c1566487a8 | ||
|
|
22f820de17 | ||
|
|
4c37f58806 | ||
|
|
c86ddc1628 | ||
|
|
c638b54e19 | ||
|
|
fb4a91060e | ||
|
|
4b3ada1781 | ||
|
|
a468ea7752 | ||
|
|
c2c330ba4a | ||
|
|
cc88be34d2 | ||
|
|
16cfb53e0a | ||
|
|
578b57c975 | ||
|
|
fe2c23f8e2 | ||
|
|
d27d6829a8 | ||
|
|
0ea57e2eed | ||
|
|
ba8ddf396c | ||
|
|
d88ce8d952 | ||
|
|
5e8d2a6e3a | ||
|
|
e59c9cde99 | ||
|
|
83428a9b26 | ||
|
|
948abc85bc | ||
|
|
c63a2f92fa | ||
|
|
0039e39000 | ||
|
|
5f378458d2 | ||
|
|
d27034bf62 | ||
|
|
6541729a18 | ||
|
|
b85e76a86a | ||
|
|
e5bbb5c9e6 | ||
|
|
4fe87617ff | ||
|
|
d7299422ae | ||
|
|
3f087a8dd9 | ||
|
|
3a6e25af75 | ||
|
|
41c2597509 | ||
|
|
e6ad708009 | ||
|
|
09914bcab7 | ||
|
|
3f1775607f | ||
|
|
b85886fa9f | ||
|
|
d9da609d8c | ||
|
|
4bec297eb6 | ||
|
|
54fa31cd84 | ||
|
|
29b6630a65 | ||
|
|
0b82311632 | ||
|
|
06c4f9e1cc | ||
|
|
dd1130352b | ||
|
|
eeba6f268d | ||
|
|
1729aaebae | ||
|
|
978adb7ea5 | ||
|
|
0398c6718e | ||
|
|
3bb4871c44 |
No files matched your search
@@ -24,3 +24,16 @@ configs {
|
|||||||
}
|
}
|
||||||
automatic: false
|
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:**
|
**Do:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from git_command import GitCommand
|
import git_command
|
||||||
|
|
||||||
def _Run(self, cmd):
|
def _Run(self, cmd):
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
cmd,
|
cmd,
|
||||||
capture_stdout=True,
|
capture_stdout=True,
|
||||||
|
|||||||
@@ -84,9 +84,14 @@ def _Color(fg=None, bg=None, attr=None):
|
|||||||
|
|
||||||
DEFAULT = None
|
DEFAULT = None
|
||||||
|
|
||||||
|
|
||||||
|
class _CheckConsoleSentinel:
|
||||||
|
"""Sentinel for checking console coloring."""
|
||||||
|
|
||||||
|
|
||||||
# Placholder value that indicates we need to check if the user is in an
|
# 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.
|
# 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
|
# https://git-scm.com/docs/git-config#Documentation/git-config.txt-colorui
|
||||||
_CONFIG_TO_COLOR_SETTING = {
|
_CONFIG_TO_COLOR_SETTING = {
|
||||||
|
|||||||
+98
-27
@@ -17,14 +17,17 @@ import multiprocessing
|
|||||||
import optparse
|
import optparse
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from typing import List, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from error import InvalidProjectGroupsError
|
import error
|
||||||
from error import NoSuchProjectError
|
|
||||||
from error import RepoExitError
|
|
||||||
from event_log import EventLog
|
from event_log import EventLog
|
||||||
import progress
|
import progress
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from project import Project
|
||||||
|
|
||||||
|
|
||||||
# Are we generating man-pages?
|
# Are we generating man-pages?
|
||||||
GENERATE_MANPAGES = os.environ.get("_REPO_GENERATE_MANPAGES_") == " indeed! "
|
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)
|
DEFAULT_LOCAL_JOBS = min(os.cpu_count(), 8)
|
||||||
|
|
||||||
|
|
||||||
class UsageError(RepoExitError):
|
class UsageError(error.RepoExitError):
|
||||||
"""Exception thrown with invalid command usage."""
|
"""Exception thrown with invalid command usage."""
|
||||||
|
|
||||||
|
|
||||||
@@ -61,6 +64,10 @@ class Command:
|
|||||||
# command to show short-vs-full summaries.
|
# command to show short-vs-full summaries.
|
||||||
COMMON = False
|
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,
|
# Whether this command supports running in parallel. If greater than 0,
|
||||||
# it is the number of parallel jobs to default to.
|
# it is the number of parallel jobs to default to.
|
||||||
PARALLEL_JOBS = None
|
PARALLEL_JOBS = None
|
||||||
@@ -143,6 +150,20 @@ class Command:
|
|||||||
self._Options(self._optparse)
|
self._Options(self._optparse)
|
||||||
return 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):
|
def _CommonOptions(self, p, opt_v=True):
|
||||||
"""Initialize the option parser with common options.
|
"""Initialize the option parser with common options.
|
||||||
|
|
||||||
@@ -167,11 +188,7 @@ class Command:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if self.PARALLEL_JOBS is not None:
|
if self.PARALLEL_JOBS is not None:
|
||||||
default = "based on number of CPU cores"
|
default = self._GetHelpForCpuJobCount()
|
||||||
if not GENERATE_MANPAGES:
|
|
||||||
# Only include active cpu count if we aren't generating man
|
|
||||||
# pages.
|
|
||||||
default = f"%default; {default}"
|
|
||||||
p.add_option(
|
p.add_option(
|
||||||
"-j",
|
"-j",
|
||||||
"--jobs",
|
"--jobs",
|
||||||
@@ -242,6 +259,12 @@ class Command:
|
|||||||
# from the user's perspective.
|
# from the user's perspective.
|
||||||
opt.outer_manifest = True
|
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):
|
def ValidateOptions(self, opt, args):
|
||||||
"""Validate the user options & arguments before executing.
|
"""Validate the user options & arguments before executing.
|
||||||
|
|
||||||
@@ -375,7 +398,7 @@ class Command:
|
|||||||
manifest=None,
|
manifest=None,
|
||||||
groups="",
|
groups="",
|
||||||
missing_ok=False,
|
missing_ok=False,
|
||||||
submodules_ok=False,
|
submodules_ok=None,
|
||||||
all_manifests=False,
|
all_manifests=False,
|
||||||
):
|
):
|
||||||
"""A list of projects that match the arguments.
|
"""A list of projects that match the arguments.
|
||||||
@@ -383,9 +406,15 @@ class Command:
|
|||||||
Args:
|
Args:
|
||||||
args: a list of (case-insensitive) strings, projects to search for.
|
args: a list of (case-insensitive) strings, projects to search for.
|
||||||
manifest: an XmlManifest, the manifest to use, or None for default.
|
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.
|
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
|
all_manifests: a boolean, if True then all manifests and
|
||||||
submanifests are used. If False, then only the local
|
submanifests are used. If False, then only the local
|
||||||
(sub)manifest is used.
|
(sub)manifest is used.
|
||||||
@@ -403,23 +432,50 @@ class Command:
|
|||||||
all_projects_list = manifest.projects
|
all_projects_list = manifest.projects
|
||||||
result = []
|
result = []
|
||||||
|
|
||||||
if not groups:
|
def should_include_submodules(project: "Project") -> bool:
|
||||||
groups = manifest.GetManifestGroupsStr()
|
if submodules_ok is None:
|
||||||
groups = [x for x in re.split(r"[,\s]+", groups) if x]
|
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:
|
if not args:
|
||||||
derived_projects = {}
|
derived_projects = {}
|
||||||
for project in all_projects_list:
|
for project in all_projects_list:
|
||||||
if submodules_ok or project.sync_s:
|
if should_include_submodules(project):
|
||||||
derived_projects.update(
|
derived_projects.update(
|
||||||
(p.RelPath(local=False), p)
|
(p.RelPath(local=False), p)
|
||||||
for p in project.GetDerivedSubprojects()
|
for p in project.GetDerivedSubprojects()
|
||||||
)
|
)
|
||||||
all_projects_list.extend(derived_projects.values())
|
all_projects_list.extend(derived_projects.values())
|
||||||
for project in all_projects_list:
|
for project in all_projects_list:
|
||||||
if (missing_ok or project.Exists) and project.MatchesGroups(
|
if (missing_ok or project.Exists) and matches_groups(project):
|
||||||
groups
|
|
||||||
):
|
|
||||||
result.append(project)
|
result.append(project)
|
||||||
else:
|
else:
|
||||||
self._ResetPathToProjectMap(all_projects_list)
|
self._ResetPathToProjectMap(all_projects_list)
|
||||||
@@ -433,7 +489,7 @@ class Command:
|
|||||||
for project in manifest.GetProjectsWithName(
|
for project in manifest.GetProjectsWithName(
|
||||||
arg, all_manifests=all_manifests
|
arg, all_manifests=all_manifests
|
||||||
)
|
)
|
||||||
if project.MatchesGroups(groups)
|
if matches_groups(project)
|
||||||
]
|
]
|
||||||
|
|
||||||
if not projects:
|
if not projects:
|
||||||
@@ -452,7 +508,7 @@ class Command:
|
|||||||
if (
|
if (
|
||||||
project
|
project
|
||||||
and not project.Derived
|
and not project.Derived
|
||||||
and (submodules_ok or project.sync_s)
|
and should_include_submodules(project)
|
||||||
):
|
):
|
||||||
search_again = False
|
search_again = False
|
||||||
for subproject in project.GetDerivedSubprojects():
|
for subproject in project.GetDerivedSubprojects():
|
||||||
@@ -468,16 +524,16 @@ class Command:
|
|||||||
projects = [project]
|
projects = [project]
|
||||||
|
|
||||||
if not projects:
|
if not projects:
|
||||||
raise NoSuchProjectError(arg)
|
raise error.NoSuchProjectError(arg)
|
||||||
|
|
||||||
for project in projects:
|
for project in projects:
|
||||||
if not missing_ok and not project.Exists:
|
if not missing_ok and not project.Exists:
|
||||||
raise NoSuchProjectError(
|
raise error.NoSuchProjectError(
|
||||||
"%s (%s)"
|
"%s (%s)"
|
||||||
% (arg, project.RelPath(local=not all_manifests))
|
% (arg, project.RelPath(local=not all_manifests))
|
||||||
)
|
)
|
||||||
if not project.MatchesGroups(groups):
|
if not matches_groups(project):
|
||||||
raise InvalidProjectGroupsError(arg)
|
raise error.InvalidProjectGroupsError(arg)
|
||||||
|
|
||||||
result.extend(projects)
|
result.extend(projects)
|
||||||
|
|
||||||
@@ -487,7 +543,14 @@ class Command:
|
|||||||
result.sort(key=_getpath)
|
result.sort(key=_getpath)
|
||||||
return result
|
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.
|
"""Find projects from command line arguments.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -497,10 +560,18 @@ class Command:
|
|||||||
all_manifests: a boolean, if True then all manifests and
|
all_manifests: a boolean, if True then all manifests and
|
||||||
submanifests are used. If False, then only the local
|
submanifests are used. If False, then only the local
|
||||||
(sub)manifest is used.
|
(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 = []
|
result = []
|
||||||
patterns = [re.compile(r"%s" % a, re.IGNORECASE) for a in args]
|
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)]
|
paths = [project.name, project.RelPath(local=not all_manifests)]
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
match = any(pattern.search(x) for x in paths)
|
match = any(pattern.search(x) for x in paths)
|
||||||
|
|||||||
+21
-1
@@ -24,6 +24,7 @@ _repo() {
|
|||||||
'branches:View current topic branches'
|
'branches:View current topic branches'
|
||||||
'checkout:Checkout a branch'
|
'checkout:Checkout a branch'
|
||||||
'cherry-pick:Cherry-pick a change'
|
'cherry-pick:Cherry-pick a change'
|
||||||
|
'config:Read and write per-user repo settings'
|
||||||
'diff:Show changes between commit and working tree'
|
'diff:Show changes between commit and working tree'
|
||||||
'diffmanifests:Show differences between two manifests'
|
'diffmanifests:Show differences between two manifests'
|
||||||
'download:Download and find a change'
|
'download:Download and find a change'
|
||||||
@@ -117,6 +118,15 @@ _repo() {
|
|||||||
${common_opts} \
|
${common_opts} \
|
||||||
'1: :_message "sha1"'
|
'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)
|
diff)
|
||||||
_arguments \
|
_arguments \
|
||||||
${common_opts} \
|
${common_opts} \
|
||||||
@@ -236,6 +246,9 @@ _repo() {
|
|||||||
'--clone-filter=[Clone filter]:filter:' \
|
'--clone-filter=[Clone filter]:filter:' \
|
||||||
'--use-superproject[Use superproject]' \
|
'--use-superproject[Use superproject]' \
|
||||||
'--no-use-superproject[Do not 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]' \
|
'--clone-bundle[Use clone bundle]' \
|
||||||
'--no-clone-bundle[Do not use clone bundle]' \
|
'--no-clone-bundle[Do not use clone bundle]' \
|
||||||
'--git-lfs[Use git lfs]' \
|
'--git-lfs[Use git lfs]' \
|
||||||
@@ -322,7 +335,10 @@ _repo() {
|
|||||||
'--no-clone-bundle[Do not use clone bundle]' \
|
'--no-clone-bundle[Do not use clone bundle]' \
|
||||||
'(-u --manifest-server-username)'{-u,--manifest-server-username=}'[Username for manifest server]:username:' \
|
'(-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:' \
|
'(-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]' \
|
'--use-superproject[Use superproject]' \
|
||||||
'--no-use-superproject[Do not use superproject]' \
|
'--no-use-superproject[Do not use superproject]' \
|
||||||
'--tags[Sync tags]' \
|
'--tags[Sync tags]' \
|
||||||
@@ -334,6 +350,8 @@ _repo() {
|
|||||||
'--auto-gc[Run auto gc]' \
|
'--auto-gc[Run auto gc]' \
|
||||||
'--no-auto-gc[Do not run auto gc]' \
|
'--no-auto-gc[Do not run auto gc]' \
|
||||||
'(-s --smart-sync)'{-s,--smart-sync}'[Smart sync]' \
|
'(-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:' \
|
'(-t --smart-tag)'{-t,--smart-tag=}'[Smart tag]:tag:' \
|
||||||
'--no-repo-verify[Do not verify repo]' \
|
'--no-repo-verify[Do not verify repo]' \
|
||||||
'--no-verify[Do not verify]' \
|
'--no-verify[Do not verify]' \
|
||||||
@@ -393,10 +411,12 @@ _repo() {
|
|||||||
'(-y --yes)'{-y,--yes}'[Answer yes to all prompts]' \
|
'(-y --yes)'{-y,--yes}'[Answer yes to all prompts]' \
|
||||||
'--ignore-untracked-files[Ignore untracked files]' \
|
'--ignore-untracked-files[Ignore untracked files]' \
|
||||||
'--no-ignore-untracked-files[Do not ignore untracked files]' \
|
'--no-ignore-untracked-files[Do not ignore untracked files]' \
|
||||||
|
'--ignore-remote-commits[Ignore commits already in remote branches]' \
|
||||||
'--no-cert-checks[Do not check certificates]' \
|
'--no-cert-checks[Do not check certificates]' \
|
||||||
'--no-verify[Do not verify]' \
|
'--no-verify[Do not verify]' \
|
||||||
'--verify[Verify]' \
|
'--verify[Verify]' \
|
||||||
'--ignore-hooks[Ignore hooks]' \
|
'--ignore-hooks[Ignore hooks]' \
|
||||||
|
'--fix[Automatically fix]' \
|
||||||
'*: :->project'
|
'*: :->project'
|
||||||
;;
|
;;
|
||||||
version)
|
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
|
filesystems or lazy checkouts where fetching metadata and downloading file
|
||||||
contents should be decoupled.
|
contents should be decoupled.
|
||||||
|
|
||||||
|
The checkout half of a sync has a counterpart, `repo.reprojectcmd`; see
|
||||||
|
`docs/reproject-cmd.md`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
To use this feature, set the following in `.repo/manifests.git/config`:
|
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`:
|
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
|
## 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
|
After the fetch command exits with status 0, `repo` expects the following
|
||||||
postconditions to be met:
|
postconditions to be met:
|
||||||
|
|
||||||
1. `git cat-file -e REPO_TREV` succeeds (the commit must exist in the object
|
1. When the manifest revision names a branch or tag, the mapped local tracking
|
||||||
store).
|
ref (e.g. `refs/remotes/REPO_REMOTE/<branch>` for a branch, or
|
||||||
2. The mapped local tracking ref (e.g. `refs/remotes/REPO_REMOTE/<branch>`
|
`refs/tags/<tag>` for a tag) must resolve to a valid commit in the object
|
||||||
for a branch revision, or the tag ref itself for a tag) must point to
|
store (`git rev-parse --verify <ref>^{commit}`), and must equal `REPO_TREV`
|
||||||
`REPO_TREV`.
|
if `REPO_TREV` was non-empty.
|
||||||
3. `FETCH_HEAD` must point to `REPO_TREV`.
|
2. `FETCH_HEAD` must resolve to the same target commit in the object store
|
||||||
4. The commit graph from `REPO_TREV` must be reachable far enough to compute
|
(`git rev-parse --verify FETCH_HEAD^{commit}`).
|
||||||
merge bases with local branches.
|
3. The commit graph from the target commit must be reachable far enough to
|
||||||
|
compute merge bases with local branches.
|
||||||
|
|
||||||
### Invariants
|
### Invariants
|
||||||
|
|
||||||
* The command should be idempotent; fetching the same `REPO_TREV` twice should
|
* The command should be idempotent; repeating the same fetch should be a
|
||||||
be a no-op.
|
no-op.
|
||||||
* Only `FETCH_HEAD` and `refs/remotes/*` should be modified to preserve
|
* Only `FETCH_HEAD`, `refs/remotes/*`, and `refs/tags/*` should be modified to
|
||||||
`repo sync --network-only` semantics. `HEAD` and local branches must not be
|
preserve `repo sync --network-only` semantics. `HEAD` and local branches
|
||||||
touched by the fetch command.
|
must not be touched by the fetch command.
|
||||||
* Dirty worktree state must be preserved.
|
* Dirty worktree state must be preserved.
|
||||||
* The command is **not** executed for `MetaProject`s (i.e. the internal `repo`
|
* The command is **not** executed for `MetaProject`s (i.e. the internal `repo`
|
||||||
repository itself at `.repo/repo` and the `manifests` repository at
|
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.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.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.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.submodules | `--submodules` | Sync git submodules |
|
||||||
| repo.superproject | `--use-superproject` | Sync [superproject] |
|
| repo.superproject | `--use-superproject` | Sync [superproject] |
|
||||||
| repo.worktree | `--worktree` | Use [git worktree] for checkouts |
|
| 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/`: Repo's per-user directory for all random config files/state.
|
||||||
* `.repoconfig/config`: Per-user settings using [git-config] file format.
|
* `.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
|
* `.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
|
has all the same keys as the repo launcher. Used to avoid running gpg
|
||||||
constantly as that can be quite slow.
|
constantly as that can be quite slow.
|
||||||
|
|||||||
+13
-10
@@ -46,18 +46,19 @@ following DTD:
|
|||||||
<!ATTLIST remote revision CDATA #IMPLIED>
|
<!ATTLIST remote revision CDATA #IMPLIED>
|
||||||
|
|
||||||
<!ELEMENT default EMPTY>
|
<!ELEMENT default EMPTY>
|
||||||
<!ATTLIST default remote IDREF #IMPLIED>
|
<!ATTLIST default remote IDREF #IMPLIED>
|
||||||
<!ATTLIST default revision CDATA #IMPLIED>
|
<!ATTLIST default revision CDATA #IMPLIED>
|
||||||
<!ATTLIST default dest-branch CDATA #IMPLIED>
|
<!ATTLIST default dest-branch CDATA #IMPLIED>
|
||||||
<!ATTLIST default upstream CDATA #IMPLIED>
|
<!ATTLIST default upstream CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync-j CDATA #IMPLIED>
|
<!ATTLIST default sync-j CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync-j-max CDATA #IMPLIED>
|
<!ATTLIST default sync-j-max CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync-c CDATA #IMPLIED>
|
<!ATTLIST default sync-c CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync-s CDATA #IMPLIED>
|
<!ATTLIST default sync-s CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync-tags CDATA #IMPLIED>
|
<!ATTLIST default sync-tags CDATA #IMPLIED>
|
||||||
|
<!ATTLIST default sync-smartsync CDATA #IMPLIED>
|
||||||
|
|
||||||
<!ELEMENT manifest-server EMPTY>
|
<!ELEMENT manifest-server EMPTY>
|
||||||
<!ATTLIST manifest-server url CDATA #REQUIRED>
|
<!ATTLIST manifest-server url CDATA #REQUIRED>
|
||||||
<!ATTLIST manifest-server helper CDATA #IMPLIED>
|
<!ATTLIST manifest-server helper CDATA #IMPLIED>
|
||||||
|
|
||||||
<!ELEMENT submanifest EMPTY>
|
<!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
|
branch (specified in the `revision` attribute) rather than
|
||||||
the other ref tags.
|
the other ref tags.
|
||||||
|
|
||||||
|
Attribute `sync-smartsync`: Set to true to enable smart sync by default.
|
||||||
|
|
||||||
|
|
||||||
### Element manifest-server
|
### Element manifest-server
|
||||||
|
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ that are critical to us.
|
|||||||
That said, let's assume we need functionality that only exists in Python 3.7.
|
That said, let's assume we need functionality that only exists in Python 3.7.
|
||||||
How do we decide when it's acceptable to drop Python 3.6?
|
How do we decide when it's acceptable to drop Python 3.6?
|
||||||
|
|
||||||
1. Review the [Project References](./release-process.md#project-references) to
|
1. Review what major distros are using the previous version of Python (e.g.
|
||||||
see what major distros are using the previous version of Python, and when
|
via [Repology Python versions]), and when they go EOL (e.g. via
|
||||||
they go EOL. Generally we care about Ubuntu LTS & current/previous Debian
|
[Ubuntu releases] & [Debian releases]). Generally we care about Ubuntu LTS
|
||||||
stable versions.
|
& current/previous Debian stable versions.
|
||||||
* If they're all EOL already, then go for it, drop support.
|
* If they're all EOL already, then go for it, drop support.
|
||||||
* If they aren't EOL, start a thread on [repo-discuss] to see how the user
|
* If they aren't EOL, start a thread on [repo-discuss] to see how the user
|
||||||
base feels about the proposal.
|
base feels about the proposal.
|
||||||
@@ -88,5 +88,8 @@ $ repo-2.32 init --repo-rev=repo-1 ...
|
|||||||
|
|
||||||
|
|
||||||
[repo-discuss]: https://groups.google.com/forum/#!forum/repo-discuss
|
[repo-discuss]: https://groups.google.com/forum/#!forum/repo-discuss
|
||||||
|
[Debian releases]: https://wiki.debian.org/DebianReleases#Versions_of_Debian_stable
|
||||||
|
[Repology Python versions]: https://repology.org/project/python/versions
|
||||||
[repo hooks]: ./repo-hooks.md
|
[repo hooks]: ./repo-hooks.md
|
||||||
[repo launcher]: ../repo
|
[repo launcher]: ../repo
|
||||||
|
[Ubuntu releases]: https://wiki.ubuntu.com/Releases
|
||||||
+54
-200
@@ -5,11 +5,19 @@ related topics and flows.
|
|||||||
|
|
||||||
[TOC]
|
[TOC]
|
||||||
|
|
||||||
## Schedule
|
## Schedule {#schedule}
|
||||||
|
|
||||||
|
Releases are automated.
|
||||||
|
Every Tuesday morning [US PT], a pipeline checks if any new commits have landed
|
||||||
|
since last release.
|
||||||
|
If so, it tags & tests the latest `main`, pushes & announces the tag, and then
|
||||||
|
promotes it to `stable` a day later.
|
||||||
|
The release is skipped if there's a holiday or [production freeze](#freeze)
|
||||||
|
that week.
|
||||||
|
Maintainers can also trigger it by hand.
|
||||||
|
The [manual steps](#manual) below are only needed as a fallback, or to roll
|
||||||
|
back a release.
|
||||||
|
|
||||||
There is no specific schedule for when releases are made.
|
|
||||||
Usually it's more along the lines of "enough minor changes have been merged",
|
|
||||||
or "there's a known issue the maintainers know should get fixed".
|
|
||||||
If you find a fix has been merged for an issue important to you, but hasn't been
|
If you find a fix has been merged for an issue important to you, but hasn't been
|
||||||
released after a week or so, feel free to [contact] us to request a new release.
|
released after a week or so, feel free to [contact] us to request a new release.
|
||||||
|
|
||||||
@@ -48,6 +56,11 @@ Whenever major changes are made to the launcher script, you should increment the
|
|||||||
At runtime, repo will check this to see if it needs to be updated (and notify
|
At runtime, repo will check this to see if it needs to be updated (and notify
|
||||||
the user automatically).
|
the user automatically).
|
||||||
|
|
||||||
|
When `VERSION` changes, the release pipeline publishes the new launcher to the
|
||||||
|
`git-repo-downloads` Google Cloud Storage bucket.
|
||||||
|
To publish it by hand, run `./release/sign-launcher.py`, which signs the
|
||||||
|
launcher and prints the commands to upload it.
|
||||||
|
|
||||||
## Key management
|
## Key management
|
||||||
|
|
||||||
Every release has a git tag that is signed with a key that repo recognizes.
|
Every release has a git tag that is signed with a key that repo recognizes.
|
||||||
@@ -96,8 +109,8 @@ If that tag is valid, then repo will warn and use that commit instead.
|
|||||||
|
|
||||||
If that tag cannot be verified, it gives up and forces the user to resolve.
|
If that tag cannot be verified, it gives up and forces the user to resolve.
|
||||||
|
|
||||||
If env variable `REPO_SKIP_SELF_UPDATE` is defined, this will
|
If the env variable `REPO_SKIP_SELF_UPDATE` is set to anything other than `0`,
|
||||||
bypass the self update algorithm.
|
`repo sync` will bypass the self update algorithm.
|
||||||
|
|
||||||
### Force an update
|
### Force an update
|
||||||
|
|
||||||
@@ -110,29 +123,20 @@ All development happens on the `main` branch and should generally be stable.
|
|||||||
|
|
||||||
Since the repo launcher defaults to tracking the `stable` branch, it is not
|
Since the repo launcher defaults to tracking the `stable` branch, it is not
|
||||||
normally updated until a new release is available.
|
normally updated until a new release is available.
|
||||||
If something goes wrong with a new release, an older release can be force pushed
|
If something goes wrong with a new release, `stable` can be pointed back at an
|
||||||
and clients will automatically downgrade.
|
older release and clients will automatically downgrade.
|
||||||
|
|
||||||
The `maint` branch is used to track the previous major release of repo.
|
|
||||||
It is not normally meant to be used by people as `stable` should be good enough.
|
|
||||||
Once a new major release is pushed to the `stable` branch, then the previous
|
|
||||||
major release can be pushed to `maint`.
|
|
||||||
For example, when `stable` moves from `v1.10.x` to `v1.11.x`, then the `maint`
|
|
||||||
branch will be updated from `v1.9.x` to `v1.10.x`.
|
|
||||||
|
|
||||||
We don't have parallel release branches/series.
|
We don't have parallel release branches/series.
|
||||||
Typically all tags are made against the `main` branch and then pushed to the
|
Typically all tags are made against the `main` branch and then promoted to the
|
||||||
`stable` branch to make it available to the rest of the world.
|
`stable` branch to make it available to the rest of the world.
|
||||||
Since repo doesn't typically see a lot of changes, this tends to be OK.
|
Since repo doesn't typically see a lot of changes, this tends to be OK.
|
||||||
|
|
||||||
## Creating a new release
|
## Creating a new release {#manual}
|
||||||
|
|
||||||
When you want to create a new release, you'll need to select a good version and
|
Releases are normally created automatically (see [Schedule](#schedule)).
|
||||||
|
If you need to create one by hand, you'll need to select a good version and
|
||||||
create a signed tag using a key registered in repo itself.
|
create a signed tag using a key registered in repo itself.
|
||||||
Typically we just tag the latest version of the `main` branch.
|
Typically we just tag the latest version of the `main` branch.
|
||||||
The tag could be pushed now, but it won't be used by clients normally (since the
|
|
||||||
default `repo-rev` setting is `stable`).
|
|
||||||
This would allow some early testing on systems who explicitly select `main`.
|
|
||||||
|
|
||||||
### Creating a signed tag
|
### Creating a signed tag
|
||||||
|
|
||||||
@@ -147,10 +151,10 @@ internal processes for accessing the restricted keys.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Pick the new version.
|
# Pick the new version.
|
||||||
$ t=v2.30
|
$ t=v2.XX
|
||||||
|
|
||||||
# Create a new signed tag with the current HEAD.
|
# Create a new signed tag of the latest main branch.
|
||||||
$ ./release/sign-tag.py $t
|
$ ./release/sign-tag.py $t origin/main
|
||||||
|
|
||||||
# Verify the signed tag.
|
# Verify the signed tag.
|
||||||
$ git show $t
|
$ git show $t
|
||||||
@@ -158,199 +162,49 @@ $ git show $t
|
|||||||
|
|
||||||
### Push the new release
|
### Push the new release
|
||||||
|
|
||||||
Once you're ready to make the release available to everyone, push it to the
|
First push the tag, [announce](#announce) it, and wait a day to get feedback
|
||||||
`stable` branch.
|
from people testing new releases early.
|
||||||
|
|
||||||
Make sure you never push the tag itself to the stable branch!
|
|
||||||
Only push the commit -- note the use of `^0` below.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ git push https://gerrit-review.googlesource.com/git-repo $t
|
$ git push https://gerrit-review.googlesource.com/git-repo $t
|
||||||
$ git push https://gerrit-review.googlesource.com/git-repo $t^0:stable
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If something goes horribly wrong, you can force push the previous version to the
|
Then make the release available to everyone by updating the `stable` branch.
|
||||||
`stable` branch and people should automatically recover.
|
Direct pushes to it are rejected, so it has to be deleted & recreated via the
|
||||||
Again, make sure you never push the tag itself!
|
authenticated [Gerrit REST API] (see [go/repo-release] for maintainers).
|
||||||
|
|
||||||
|
Make sure you never point the stable branch at the tag itself!
|
||||||
|
Only use the commit -- note the use of `^0` below.
|
||||||
|
The commit is resolved before deleting anything so that a bad `$t` can't leave
|
||||||
|
`stable` missing.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ oldrev="whatever-old-commit"
|
$ api=https://gerrit-review.googlesource.com/a/projects/git-repo/branches/stable
|
||||||
$ git push https://gerrit-review.googlesource.com/git-repo $oldrev:stable --force
|
$ rev=$(git rev-parse --verify "$t^0") && \
|
||||||
|
curl --fail -X DELETE "$api" && \
|
||||||
|
curl --fail -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"revision\":\"$rev\"}" "$api"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Announce the release
|
If something goes horribly wrong, run the same commands with the previous
|
||||||
|
release (e.g. `t=$(git describe --abbrev=0 $t^0~)`) and people should
|
||||||
|
automatically recover.
|
||||||
|
|
||||||
Once you do push a new release to `stable`, make sure to announce it on the
|
### Announce the release {#announce}
|
||||||
[repo-discuss@googlegroups.com] group.
|
|
||||||
|
Once you push a new release, make sure to announce it on the
|
||||||
|
[repo-discuss@googlegroups.com] group, and follow up once it's on `stable`.
|
||||||
Here is an [example announcement].
|
Here is an [example announcement].
|
||||||
|
|
||||||
You can create a short changelog using the command:
|
You can create a short changelog since the previous release using the command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# If you haven't pushed to the stable branch yet, you can use origin/stable.
|
$ git log --format="%h (%aN) %s" --no-merges \
|
||||||
# If you have pushed, change origin/stable to the previous release tag.
|
$(git describe --abbrev=0 $t^0~)..$t
|
||||||
# This assumes "main" is the current tagged release. If it's newer, change it
|
|
||||||
# to the current release tag too.
|
|
||||||
$ git log --format="%h (%aN) %s" --no-merges origin/stable..main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project References
|
|
||||||
|
|
||||||
Here's a table showing the relationship of major tools, their EOL dates, and
|
|
||||||
their status in Ubuntu & Debian.
|
|
||||||
Those distros tend to be good indicators of how long we need to support things.
|
|
||||||
|
|
||||||
Things in bold indicate stuff to take note of, but does not guarantee that we
|
|
||||||
still support them.
|
|
||||||
Things in italics are things we used to care about but probably don't anymore.
|
|
||||||
|
|
||||||
| Date | EOL | [Git][rel-g] | [Python][rel-p] | [SSH][rel-o] | [Ubuntu][rel-u] / [Debian][rel-d] | Git | Python | SSH |
|
|
||||||
|:--------:|:------------:|:------------:|:---------------:|:------------:|-----------------------------------|:---:|:------:|:---:|
|
|
||||||
| Apr 2008 | | | | 5.0 |
|
|
||||||
| Jun 2008 | | | | 5.1 |
|
|
||||||
| Oct 2008 | *Oct 2013* | | 2.6.0 | | *10.04 Lucid* - 10.10 Maverick / *Squeeze* |
|
|
||||||
| Dec 2008 | *Feb 2009* | | 3.0.0 |
|
|
||||||
| Feb 2009 | | | | 5.2 |
|
|
||||||
| Feb 2009 | *Mar 2012* | | | | Debian 5 Lenny | 1.5.6.5 | 2.5.2 |
|
|
||||||
| Jun 2009 | *Jun 2016* | | 3.1.0 | | *10.04 Lucid* - 10.10 Maverick / *Squeeze* |
|
|
||||||
| Sep 2009 | | | | 5.3 | *10.04 Lucid* |
|
|
||||||
| Feb 2010 | *Oct 2012* | 1.7.0 | | | *10.04 Lucid* - *12.04 Precise* - 12.10 Quantal |
|
|
||||||
| Mar 2010 | | | | 5.4 |
|
|
||||||
| Apr 2010 | | | | 5.5 | 10.10 Maverick |
|
|
||||||
| Apr 2010 | *Apr 2015* | | | | *10.04 Lucid* | 1.7.0.4 | 2.6.5 3.1.2 | 5.3 |
|
|
||||||
| Jul 2010 | *Dec 2019* | | *2.7.0* | | 11.04 Natty - *<current>* |
|
|
||||||
| Aug 2010 | | | | 5.6 |
|
|
||||||
| Oct 2010 | | | | | 10.10 Maverick | 1.7.1 | 2.6.6 3.1.3 | 5.5 |
|
|
||||||
| Jan 2011 | | | | 5.7 |
|
|
||||||
| Feb 2011 | | | | 5.8 | 11.04 Natty |
|
|
||||||
| Feb 2011 | *Feb 2016* | | | | Debian 6 Squeeze | 1.7.2.5 | 2.6.6 3.1.3 |
|
|
||||||
| Apr 2011 | | | | | 11.04 Natty | 1.7.4 | 2.7.1 3.2.0 | 5.8 |
|
|
||||||
| Sep 2011 | | | | 5.9 | *12.04 Precise* |
|
|
||||||
| Oct 2011 | *Feb 2016* | | 3.2.0 | | 11.04 Natty - 12.10 Quantal |
|
|
||||||
| Oct 2011 | | | | | 11.10 Ocelot | 1.7.5.4 | 2.7.2 3.2.2 | 5.8 |
|
|
||||||
| Apr 2012 | | | | 6.0 | 12.10 Quantal |
|
|
||||||
| Apr 2012 | *Apr 2019* | | | | *12.04 Precise* | 1.7.9.5 | 2.7.3 3.2.3 | 5.9 |
|
|
||||||
| Aug 2012 | | | | 6.1 | 13.04 Raring |
|
|
||||||
| Sep 2012 | *Sep 2017* | | 3.3.0 | | 13.04 Raring - 13.10 Saucy |
|
|
||||||
| Oct 2012 | *Dec 2014* | 1.8.0 | | | 13.04 Raring - 13.10 Saucy |
|
|
||||||
| Oct 2012 | | | | | 12.10 Quantal | 1.7.10.4 | 2.7.3 3.2.3 | 6.0 |
|
|
||||||
| Mar 2013 | | | | 6.2 | 13.10 Saucy |
|
|
||||||
| Apr 2013 | | | | | 13.04 Raring | 1.8.1.2 | 2.7.4 3.3.1 | 6.1 |
|
|
||||||
| May 2013 | *May 2018* | | | | Debian 7 Wheezy | 1.7.10.4 | 2.7.3 3.2.3 |
|
|
||||||
| Sep 2013 | | | | 6.3 |
|
|
||||||
| Oct 2013 | | | | | 13.10 Saucy | 1.8.3.2 | 2.7.5 3.3.2 | 6.2 |
|
|
||||||
| Nov 2013 | | | | 6.4 |
|
|
||||||
| Jan 2014 | | | | 6.5 |
|
|
||||||
| Feb 2014 | *Dec 2014* | **1.9.0** | | | *14.04 Trusty* |
|
|
||||||
| Mar 2014 | *Mar 2019* | | *3.4.0* | | *14.04 Trusty* - 15.10 Wily / *Jessie* |
|
|
||||||
| Mar 2014 | | | | 6.6 | *14.04 Trusty* - 14.10 Utopic |
|
|
||||||
| Apr 2014 | *Apr 2024* | | | | *14.04 Trusty* | 1.9.1 | 2.7.5 3.4.0 | 6.6 |
|
|
||||||
| May 2014 | *Dec 2014* | 2.0.0 |
|
|
||||||
| Aug 2014 | *Dec 2014* | *2.1.0* | | | 14.10 Utopic - 15.04 Vivid / *Jessie* |
|
|
||||||
| Oct 2014 | | | | 6.7 | 15.04 Vivid |
|
|
||||||
| Oct 2014 | | | | | 14.10 Utopic | 2.1.0 | 2.7.8 3.4.2 | 6.6 |
|
|
||||||
| Nov 2014 | *Sep 2015* | 2.2.0 |
|
|
||||||
| Feb 2015 | *Sep 2015* | 2.3.0 |
|
|
||||||
| Mar 2015 | | | | 6.8 |
|
|
||||||
| Apr 2015 | *May 2017* | 2.4.0 |
|
|
||||||
| Apr 2015 | *Jun 2020* | | | | *Debian 8 Jessie* | 2.1.4 | 2.7.9 3.4.2 |
|
|
||||||
| Apr 2015 | | | | | 15.04 Vivid | 2.1.4 | 2.7.9 3.4.3 | 6.7 |
|
|
||||||
| Jul 2015 | *May 2017* | 2.5.0 | | | 15.10 Wily |
|
|
||||||
| Jul 2015 | | | | 6.9 | 15.10 Wily |
|
|
||||||
| Aug 2015 | | | | 7.0 |
|
|
||||||
| Aug 2015 | | | | 7.1 |
|
|
||||||
| Sep 2015 | *May 2017* | 2.6.0 |
|
|
||||||
| Sep 2015 | *Sep 2020* | | *3.5.0* | | *16.04 Xenial* - 17.04 Zesty / *Stretch* |
|
|
||||||
| Oct 2015 | | | | | 15.10 Wily | 2.5.0 | 2.7.9 3.4.3 | 6.9 |
|
|
||||||
| Jan 2016 | *Jul 2017* | *2.7.0* | | | *16.04 Xenial* |
|
|
||||||
| Feb 2016 | | | | 7.2 | *16.04 Xenial* |
|
|
||||||
| Mar 2016 | *Jul 2017* | 2.8.0 |
|
|
||||||
| Apr 2016 | *Apr 2026* | | | | *16.04 Xenial* | 2.7.4 | 2.7.11 3.5.1 | 7.2 |
|
|
||||||
| Jun 2016 | *Jul 2017* | 2.9.0 | | | 16.10 Yakkety |
|
|
||||||
| Jul 2016 | | | | 7.3 | 16.10 Yakkety |
|
|
||||||
| Sep 2016 | *Sep 2017* | 2.10.0 |
|
|
||||||
| Oct 2016 | | | | | 16.10 Yakkety | 2.9.3 | 2.7.11 3.5.1 | 7.3 |
|
|
||||||
| Nov 2016 | *Sep 2017* | *2.11.0* | | | 17.04 Zesty / *Stretch* |
|
|
||||||
| Dec 2016 | **Dec 2021** | | **3.6.0** | | 17.10 Artful - **18.04 Bionic** - 18.10 Cosmic |
|
|
||||||
| Dec 2016 | | | | 7.4 | 17.04 Zesty / *Debian 9 Stretch* |
|
|
||||||
| Feb 2017 | *Sep 2017* | 2.12.0 |
|
|
||||||
| Mar 2017 | | | | 7.5 | 17.10 Artful |
|
|
||||||
| Apr 2017 | | | | | 17.04 Zesty | 2.11.0 | 2.7.13 3.5.3 | 7.4 |
|
|
||||||
| May 2017 | *May 2018* | 2.13.0 |
|
|
||||||
| Jun 2017 | *Jun 2022* | | | | *Debian 9 Stretch* | 2.11.0 | 2.7.13 3.5.3 | 7.4 |
|
|
||||||
| Aug 2017 | *Dec 2019* | 2.14.0 | | | 17.10 Artful |
|
|
||||||
| Oct 2017 | *Dec 2019* | 2.15.0 |
|
|
||||||
| Oct 2017 | | | | 7.6 | **18.04 Bionic** |
|
|
||||||
| Oct 2017 | | | | | 17.10 Artful | 2.14.1 | 2.7.14 3.6.3 | 7.5 |
|
|
||||||
| Jan 2018 | *Dec 2019* | 2.16.0 |
|
|
||||||
| Apr 2018 | *Mar 2021* | **2.17.0** | | | **18.04 Bionic** |
|
|
||||||
| Apr 2018 | | | | 7.7 | 18.10 Cosmic |
|
|
||||||
| Apr 2018 | **Apr 2028** | | | | **18.04 Bionic** | 2.17.0 | 2.7.15 3.6.5 | 7.6 |
|
|
||||||
| Jun 2018 | *Mar 2021* | 2.18.0 |
|
|
||||||
| Jun 2018 | **Jun 2023** | | 3.7.0 | | 19.04 Disco - **Buster** |
|
|
||||||
| Aug 2018 | | | | 7.8 |
|
|
||||||
| Sep 2018 | *Mar 2021* | 2.19.0 | | | 18.10 Cosmic |
|
|
||||||
| Oct 2018 | | | | 7.9 | 19.04 Disco / **Buster** |
|
|
||||||
| Oct 2018 | | | | | 18.10 Cosmic | 2.19.1 | 2.7.15 3.6.6 | 7.7 |
|
|
||||||
| Dec 2018 | *Mar 2021* | **2.20.0** | | | 19.04 Disco - 19.10 Eoan / **Buster** |
|
|
||||||
| Feb 2019 | *Mar 2021* | 2.21.0 |
|
|
||||||
| Apr 2019 | | | | 8.0 | 19.10 Eoan |
|
|
||||||
| Apr 2019 | | | | | 19.04 Disco | 2.20.1 | 2.7.16 3.7.3 | 7.9 |
|
|
||||||
| Jun 2019 | | 2.22.0 |
|
|
||||||
| Jul 2019 | **Jul 2024** | | | | **Debian 10 Buster** | 2.20.1 | 2.7.16 3.7.3 | 7.9 |
|
|
||||||
| Aug 2019 | *Mar 2021* | 2.23.0 |
|
|
||||||
| Oct 2019 | **Oct 2024** | | 3.8.0 | | **20.04 Focal** - 20.10 Groovy |
|
|
||||||
| Oct 2019 | | | | 8.1 |
|
|
||||||
| Oct 2019 | | | | | 19.10 Eoan | 2.20.1 | 2.7.17 3.7.5 | 8.0 |
|
|
||||||
| Nov 2019 | *Mar 2021* | 2.24.0 |
|
|
||||||
| Jan 2020 | *Mar 2021* | 2.25.0 | | | **20.04 Focal** |
|
|
||||||
| Feb 2020 | | | | 8.2 | **20.04 Focal** |
|
|
||||||
| Mar 2020 | *Mar 2021* | 2.26.0 |
|
|
||||||
| Apr 2020 | **Apr 2030** | | | | **20.04 Focal** | 2.25.1 | 2.7.17 3.8.2 | 8.2 |
|
|
||||||
| May 2020 | *Mar 2021* | 2.27.0 | | | 20.10 Groovy |
|
|
||||||
| May 2020 | | | | 8.3 |
|
|
||||||
| Jul 2020 | *Mar 2021* | 2.28.0 |
|
|
||||||
| Sep 2020 | | | | 8.4 | 21.04 Hirsute / **Bullseye** |
|
|
||||||
| Oct 2020 | *Mar 2021* | 2.29.0 |
|
|
||||||
| Oct 2020 | | | | | 20.10 Groovy | 2.27.0 | 2.7.18 3.8.6 | 8.3 |
|
|
||||||
| Oct 2020 | **Oct 2025** | | 3.9.0 | | 21.04 Hirsute / **Bullseye** |
|
|
||||||
| Dec 2020 | *Mar 2021* | 2.30.0 | | | 21.04 Hirsute / **Bullseye** |
|
|
||||||
| Mar 2021 | | 2.31.0 | | 8.5 |
|
|
||||||
| Apr 2021 | | | | 8.6 |
|
|
||||||
| Apr 2021 | *Jan 2022* | | | | 21.04 Hirsute | 2.30.2 | 2.7.18 3.9.4 | 8.4 |
|
|
||||||
| Jun 2021 | | 2.32.0 |
|
|
||||||
| Aug 2021 | | 2.33.0 | | 8.7 |
|
|
||||||
| Aug 2021 | **Aug 2026** | | | | **Debian 11 Bullseye** | 2.30.2 | 2.7.18 3.9.2 | 8.4 |
|
|
||||||
| Sep 2021 | | | | 8.8 |
|
|
||||||
| Oct 2021 | | 2.34.0 | 3.10.0 | | **22.04 Jammy** |
|
|
||||||
| Jan 2022 | | 2.35.0 |
|
|
||||||
| Feb 2022 | | | | 8.9 | **22.04 Jammy** |
|
|
||||||
| Apr 2022 | | 2.36.0 | | 9.0 |
|
|
||||||
| Apr 2022 | **Apr 2032** | | | | **22.04 Jammy** | 2.34.1 | 2.7.18 3.10.6 | 8.9 |
|
|
||||||
| Jun 2022 | | 2.37.0 |
|
|
||||||
| Oct 2022 | | 2.38.0 | | 9.1 |
|
|
||||||
| Oct 2022 | | | 3.11.0 | | **Bookworm** |
|
|
||||||
| Dec 2022 | | 2.39.0 | | | **Bookworm** |
|
|
||||||
| Feb 2023 | | | | 9.2 | **Bookworm** |
|
|
||||||
| Mar 2023 | | 2.40.0 | | 9.3 |
|
|
||||||
| Jun 2023 | | 2.41.0 |
|
|
||||||
| Jun 2023 | **Jun 2028** | | | | **Debian 12 Bookworm** | 2.39.2 | 3.11.2 | 9.2 |
|
|
||||||
| Aug 2023 | | 2.42.0 | | 9.4 |
|
|
||||||
| Oct 2023 | | | 3.12.0 | 9.5 |
|
|
||||||
| Nov 2022 | | 2.43.0 |
|
|
||||||
| Dec 2023 | | | | 9.6 |
|
|
||||||
| Feb 2024 | | 2.44.0 |
|
|
||||||
| Mar 2024 | | | | 9.7 |
|
|
||||||
| Oct 2024 | | | 3.13.0 |
|
|
||||||
| **Date** | **EOL** | **[Git][rel-g]** | **[Python][rel-p]** | **[SSH][rel-o]** | **[Ubuntu][rel-u] / [Debian][rel-d]** | **Git** | **Python** | **SSH** |
|
|
||||||
|
|
||||||
|
|
||||||
[contact]: ../README.md#contact
|
[contact]: ../README.md#contact
|
||||||
[rel-d]: https://en.wikipedia.org/wiki/Debian_version_history
|
|
||||||
[rel-g]: https://en.wikipedia.org/wiki/Git#Releases
|
|
||||||
[rel-o]: https://www.openssh.com/releasenotes.html
|
|
||||||
[rel-p]: https://en.wikipedia.org/wiki/History_of_Python#Table_of_versions
|
|
||||||
[rel-u]: https://wiki.ubuntu.com/Releases
|
|
||||||
[example announcement]: https://groups.google.com/d/topic/repo-discuss/UGBNismWo1M/discussion
|
[example announcement]: https://groups.google.com/d/topic/repo-discuss/UGBNismWo1M/discussion
|
||||||
[repo-discuss@googlegroups.com]: https://groups.google.com/forum/#!forum/repo-discuss
|
[repo-discuss@googlegroups.com]: https://groups.google.com/forum/#!forum/repo-discuss
|
||||||
[go/repo-release]: https://goto.google.com/repo-release
|
[go/repo-release]: https://goto.google.com/repo-release
|
||||||
|
[Gerrit REST API]: https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#branch-endpoints
|
||||||
+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
|
the user. Although user interaction is discouraged in the common case, it can
|
||||||
be useful when deploying automatic fixes.
|
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
|
### Shebang Handling
|
||||||
|
|
||||||
*** note
|
*** note
|
||||||
@@ -119,7 +133,7 @@ This hook runs when people run `repo upload`.
|
|||||||
The `pre-upload.py` file should be defined like:
|
The `pre-upload.py` file should be defined like:
|
||||||
|
|
||||||
```py
|
```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.
|
"""Main function invoked directly by repo.
|
||||||
|
|
||||||
We must use the name "main" as that is what repo requires.
|
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
|
project_list, so that each entry in project_list matches with a
|
||||||
directory in worktree_list. If None, we will attempt to calculate
|
directory in worktree_list. If None, we will attempt to calculate
|
||||||
the directories automatically.
|
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.
|
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.
|
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
|
||||||
|
|
||||||
> `GetApprovedManifest(branch: str, target: Optional[str]) -> str`
|
> `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
|
The next time `repo sync` is run, this file is automatically replaced or removed
|
||||||
based on the current set of options.
|
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
|
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
|
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
|
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
|
3. `${TARGET_PRODUCT}-${TARGET_BUILD_VARIANT}`: If these variables are all
|
||||||
defined, then they are merged with `-` and used.
|
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)`.
|
Repo will call `GetManifest(TAG)`.
|
||||||
@@ -18,7 +18,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from error import EditorError
|
import error
|
||||||
import platform_utils
|
import platform_utils
|
||||||
|
|
||||||
|
|
||||||
@@ -104,9 +104,11 @@ least one of these before using this command.""", # noqa: E501
|
|||||||
try:
|
try:
|
||||||
rc = subprocess.Popen(args, shell=shell).wait()
|
rc = subprocess.Popen(args, shell=shell).wait()
|
||||||
except OSError as e:
|
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:
|
if rc != 0:
|
||||||
raise EditorError(
|
raise error.EditorError(
|
||||||
"editor failed with exit status %d: %s %s"
|
"editor failed with exit status %d: %s %s"
|
||||||
% (rc, editor, path)
|
% (rc, editor, path)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import sys
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
|
|
||||||
from error import RepoExitError
|
import error
|
||||||
|
|
||||||
|
|
||||||
class FetchFileError(RepoExitError):
|
class FetchFileError(error.RepoExitError):
|
||||||
"""Exit error when fetch_file fails."""
|
"""Exit error when fetch_file fails."""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+76
-25
@@ -20,14 +20,11 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from error import GitError
|
import error
|
||||||
from error import RepoExitError
|
|
||||||
from git_trace2_event_log_base import BaseEventLog
|
from git_trace2_event_log_base import BaseEventLog
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
from repo_trace import IsTrace
|
import repo_trace
|
||||||
from repo_trace import REPO_TRACE
|
|
||||||
from repo_trace import Trace
|
|
||||||
from wrapper import Wrapper
|
from wrapper import Wrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -43,7 +40,7 @@ GIT_ERROR_STDOUT_LINES = 1
|
|||||||
GIT_ERROR_STDERR_LINES = 10
|
GIT_ERROR_STDERR_LINES = 10
|
||||||
INVALID_GIT_EXIT_CODE = 126
|
INVALID_GIT_EXIT_CODE = 126
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class _GitCall:
|
class _GitCall:
|
||||||
@@ -70,6 +67,19 @@ class _GitCall:
|
|||||||
git = _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():
|
def RepoSourceVersion():
|
||||||
"""Return the version of the repo.git tree."""
|
"""Return the version of the repo.git tree."""
|
||||||
ver = getattr(RepoSourceVersion, "version", None)
|
ver = getattr(RepoSourceVersion, "version", None)
|
||||||
@@ -270,6 +280,7 @@ class GitCommand:
|
|||||||
bare=False,
|
bare=False,
|
||||||
input=None,
|
input=None,
|
||||||
capture_stdout=False,
|
capture_stdout=False,
|
||||||
|
capture_stdout_bytes: bool = False,
|
||||||
capture_stderr=False,
|
capture_stderr=False,
|
||||||
merge_output=False,
|
merge_output=False,
|
||||||
disable_editor=False,
|
disable_editor=False,
|
||||||
@@ -291,6 +302,12 @@ class GitCommand:
|
|||||||
self.cmdv = cmdv
|
self.cmdv = cmdv
|
||||||
self.verify_command = verify_command
|
self.verify_command = verify_command
|
||||||
self.stdout, self.stderr = None, None
|
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.
|
# Git on Windows wants its paths only using / for reliability.
|
||||||
if platform_utils.isWindows():
|
if platform_utils.isWindows():
|
||||||
@@ -315,11 +332,17 @@ class GitCommand:
|
|||||||
|
|
||||||
if command_name in ("fetch", "clone"):
|
if command_name in ("fetch", "clone"):
|
||||||
env["GIT_TERMINAL_PROMPT"] = "0"
|
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||||
# Need to use the --progress flag for fetch/clone so output will be
|
if command_name in ("fetch", "clone", "push"):
|
||||||
# displayed as by default git only does progress output if stderr is
|
# Need to use the --progress flag for fetch/clone/push so output
|
||||||
# a TTY.
|
# will be displayed as by default git only does progress output if
|
||||||
|
# stderr is a TTY.
|
||||||
if sys.stderr.isatty():
|
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.append("--progress")
|
||||||
command.extend(cmdv[1:])
|
command.extend(cmdv[1:])
|
||||||
|
|
||||||
@@ -334,6 +357,7 @@ class GitCommand:
|
|||||||
command,
|
command,
|
||||||
env,
|
env,
|
||||||
capture_stdout=capture_stdout,
|
capture_stdout=capture_stdout,
|
||||||
|
capture_stdout_bytes=capture_stdout_bytes,
|
||||||
capture_stderr=capture_stderr,
|
capture_stderr=capture_stderr,
|
||||||
merge_output=merge_output,
|
merge_output=merge_output,
|
||||||
ssh_proxy=ssh_proxy,
|
ssh_proxy=ssh_proxy,
|
||||||
@@ -367,6 +391,7 @@ class GitCommand:
|
|||||||
command,
|
command,
|
||||||
env,
|
env,
|
||||||
capture_stdout=False,
|
capture_stdout=False,
|
||||||
|
capture_stdout_bytes: bool = False,
|
||||||
capture_stderr=False,
|
capture_stderr=False,
|
||||||
merge_output=False,
|
merge_output=False,
|
||||||
ssh_proxy=None,
|
ssh_proxy=None,
|
||||||
@@ -399,6 +424,10 @@ class GitCommand:
|
|||||||
# See go/tee-repo-stderr for more context.
|
# See go/tee-repo-stderr for more context.
|
||||||
tee_stderr = False
|
tee_stderr = False
|
||||||
kwargs = {"encoding": "utf-8", "errors": "backslashreplace"}
|
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):
|
if not (stdin or stdout or stderr):
|
||||||
tee_stderr = True
|
tee_stderr = True
|
||||||
# stderr will be written back to sys.stderr even though it is
|
# stderr will be written back to sys.stderr even though it is
|
||||||
@@ -407,7 +436,7 @@ class GitCommand:
|
|||||||
kwargs = {}
|
kwargs = {}
|
||||||
|
|
||||||
dbg = ""
|
dbg = ""
|
||||||
if IsTrace():
|
if repo_trace.IsTrace():
|
||||||
global LAST_CWD
|
global LAST_CWD
|
||||||
global LAST_GITDIR
|
global LAST_GITDIR
|
||||||
|
|
||||||
@@ -444,7 +473,7 @@ class GitCommand:
|
|||||||
elif stderr == subprocess.STDOUT:
|
elif stderr == subprocess.STDOUT:
|
||||||
dbg += " 2>&1"
|
dbg += " 2>&1"
|
||||||
|
|
||||||
with Trace(
|
with repo_trace.Trace(
|
||||||
"git command %s %s with debug: %s", LAST_GITDIR, command, dbg
|
"git command %s %s with debug: %s", LAST_GITDIR, command, dbg
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@@ -477,6 +506,10 @@ class GitCommand:
|
|||||||
self.stderr = self._Tee(p.stderr, sys.stderr)
|
self.stderr = self._Tee(p.stderr, sys.stderr)
|
||||||
else:
|
else:
|
||||||
self.stdout, self.stderr = p.communicate(input=input)
|
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:
|
finally:
|
||||||
if ssh_proxy:
|
if ssh_proxy:
|
||||||
ssh_proxy.remove_client(p)
|
ssh_proxy.remove_client(p)
|
||||||
@@ -517,7 +550,7 @@ class GitCommand:
|
|||||||
"""
|
"""
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
for key in (
|
for key in (
|
||||||
REPO_TRACE,
|
repo_trace.REPO_TRACE,
|
||||||
GIT_DIR,
|
GIT_DIR,
|
||||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||||
"GIT_OBJECT_DIRECTORY",
|
"GIT_OBJECT_DIRECTORY",
|
||||||
@@ -528,17 +561,35 @@ class GitCommand:
|
|||||||
env.pop(key, None)
|
env.pop(key, None)
|
||||||
return env
|
return env
|
||||||
|
|
||||||
def VerifyCommand(self):
|
def VerifyCommand(self) -> None:
|
||||||
if self.rc == 0:
|
if self.rc == 0:
|
||||||
return None
|
return None
|
||||||
stdout = (
|
raw_stdout = self.stdout
|
||||||
"\n".join(self.stdout.split("\n")[:GIT_ERROR_STDOUT_LINES])
|
if isinstance(raw_stdout, bytes):
|
||||||
if self.stdout
|
first_records = re.split(
|
||||||
else None
|
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 = (
|
stderr = (
|
||||||
"\n".join(self.stderr.split("\n")[:GIT_ERROR_STDERR_LINES])
|
"\n".join(raw_stderr.split("\n")[:GIT_ERROR_STDERR_LINES])
|
||||||
if self.stderr
|
if raw_stderr
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
project = self.project.name if self.project else None
|
project = self.project.name if self.project else None
|
||||||
@@ -556,14 +607,14 @@ class GitCommand:
|
|||||||
return self.rc
|
return self.rc
|
||||||
|
|
||||||
|
|
||||||
class GitRequireError(RepoExitError):
|
class GitRequireError(error.RepoExitError):
|
||||||
"""Error raised when git version is unavailable or invalid."""
|
"""Error raised when git version is unavailable or invalid."""
|
||||||
|
|
||||||
def __init__(self, message, exit_code: int = INVALID_GIT_EXIT_CODE):
|
def __init__(self, message, exit_code: int = INVALID_GIT_EXIT_CODE):
|
||||||
super().__init__(message, exit_code=exit_code)
|
super().__init__(message, exit_code=exit_code)
|
||||||
|
|
||||||
|
|
||||||
class GitCommandError(GitError):
|
class GitCommandError(error.GitError):
|
||||||
"""
|
"""
|
||||||
Error raised from a failed git command.
|
Error raised from a failed git command.
|
||||||
Note that GitError can refer to any Git related error (e.g. branch not
|
Note that GitError can refer to any Git related error (e.g. branch not
|
||||||
@@ -643,7 +694,7 @@ class GitCommandError(GitError):
|
|||||||
return string
|
return string
|
||||||
|
|
||||||
|
|
||||||
class GitPopenCommandError(GitError):
|
class GitPopenCommandError(error.GitError):
|
||||||
"""
|
"""
|
||||||
Error raised when subprocess.Popen fails for a GitCommand
|
Error raised when subprocess.Popen fails for a GitCommand
|
||||||
"""
|
"""
|
||||||
+98
-51
@@ -26,27 +26,24 @@ from typing import Union
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from error import GitError
|
import error
|
||||||
from error import UploadError
|
import git_command
|
||||||
from git_command import GitCommand
|
import git_refs
|
||||||
from git_refs import R_CHANGES
|
|
||||||
from git_refs import R_HEADS
|
|
||||||
from git_refs import R_TAGS
|
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from repo_trace import Trace
|
import repo_trace
|
||||||
|
|
||||||
|
|
||||||
# Prefix that is prepended to all the keys of SyncAnalysisState's data
|
# Prefix that is prepended to all the keys of SyncAnalysisState's data
|
||||||
# that is saved in the config.
|
# that is saved in the config.
|
||||||
SYNC_STATE_PREFIX = "repo.syncstate."
|
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 = {}
|
REVIEW_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
def IsChange(rev):
|
def IsChange(rev):
|
||||||
return rev.startswith(R_CHANGES)
|
return rev.startswith(git_refs.R_CHANGES)
|
||||||
|
|
||||||
|
|
||||||
def IsId(rev: str) -> bool:
|
def IsId(rev: str) -> bool:
|
||||||
@@ -54,13 +51,63 @@ def IsId(rev: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def IsTag(rev):
|
def IsTag(rev):
|
||||||
return rev.startswith(R_TAGS)
|
return rev.startswith(git_refs.R_TAGS)
|
||||||
|
|
||||||
|
|
||||||
def IsImmutable(rev):
|
def IsImmutable(rev):
|
||||||
return IsChange(rev) or IsId(rev) or IsTag(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):
|
def _key(name):
|
||||||
parts = name.split(".")
|
parts = name.split(".")
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
@@ -152,32 +199,15 @@ class GitConfig:
|
|||||||
v = self.GetString(name)
|
v = self.GetString(name)
|
||||||
if v is None:
|
if v is None:
|
||||||
return None
|
return None
|
||||||
v = v.strip()
|
|
||||||
|
|
||||||
mult = 1
|
ret = ParseInt(v)
|
||||||
if v.endswith("k"):
|
if ret is None:
|
||||||
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:
|
|
||||||
print(
|
print(
|
||||||
f"warning: expected {name} to represent an integer, got {v} "
|
f"warning: expected {name} to represent an integer, got {v} "
|
||||||
"instead",
|
"instead",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return None
|
return ret
|
||||||
|
|
||||||
def DumpConfigDict(self):
|
def DumpConfigDict(self):
|
||||||
"""Returns the current configuration dict.
|
"""Returns the current configuration dict.
|
||||||
@@ -197,24 +227,25 @@ class GitConfig:
|
|||||||
def GetBoolean(self, name: str) -> Union[bool, None]:
|
def GetBoolean(self, name: str) -> Union[bool, None]:
|
||||||
"""Returns a boolean from the configuration file.
|
"""Returns a boolean from the configuration file.
|
||||||
|
|
||||||
|
This follows the git config syntax.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None: The value was not defined, or is not a boolean.
|
None: The value was not defined, or is not a boolean.
|
||||||
True: The value was set to true or yes.
|
True: The value was set to true, yes, on or 1.
|
||||||
False: The value was set to false or no.
|
False: The value was set to false, no, off or 0.
|
||||||
"""
|
"""
|
||||||
v = self.GetString(name)
|
v = self.GetString(name)
|
||||||
if v is None:
|
if v is None:
|
||||||
return None
|
return None
|
||||||
v = v.lower()
|
|
||||||
if v in ("true", "yes"):
|
ret = ParseBoolean(v)
|
||||||
return True
|
if ret is None:
|
||||||
if v in ("false", "no"):
|
print(
|
||||||
return False
|
f"warning: expected {name} to represent a boolean, got {v} "
|
||||||
print(
|
"instead",
|
||||||
f"warning: expected {name} to represent a boolean, got {v} instead",
|
file=sys.stderr,
|
||||||
file=sys.stderr,
|
)
|
||||||
)
|
return ret
|
||||||
return None
|
|
||||||
|
|
||||||
def SetBoolean(self, name, value):
|
def SetBoolean(self, name, value):
|
||||||
"""Set the truthy value for a key."""
|
"""Set the truthy value for a key."""
|
||||||
@@ -266,6 +297,13 @@ class GitConfig:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
old = []
|
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 value is None:
|
||||||
if old:
|
if old:
|
||||||
del self._cache[key]
|
del self._cache[key]
|
||||||
@@ -387,7 +425,7 @@ class GitConfig:
|
|||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with Trace(": parsing %s", self.file):
|
with repo_trace.Trace(": parsing %s", self.file):
|
||||||
with open(self._json) as fd:
|
with open(self._json) as fd:
|
||||||
return json.load(fd)
|
return json.load(fd)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
@@ -434,16 +472,23 @@ class GitConfig:
|
|||||||
command = ["config", "--file", self.file, "--includes"]
|
command = ["config", "--file", self.file, "--includes"]
|
||||||
command.extend(args)
|
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:
|
if p.Wait() == 0:
|
||||||
return p.stdout
|
return p.stdout
|
||||||
else:
|
else:
|
||||||
raise GitError(f"git {' '.join(command)}: {p.stderr}")
|
raise error.GitError(f"git {' '.join(command)}: {p.stderr}")
|
||||||
|
|
||||||
|
|
||||||
class RepoConfig(GitConfig):
|
class RepoConfig(GitConfig):
|
||||||
"""User settings for repo itself."""
|
"""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
|
@staticmethod
|
||||||
def _getUserConfig():
|
def _getUserConfig():
|
||||||
repo_config_dir = os.getenv("REPO_CONFIG_DIR", os.path.expanduser("~"))
|
repo_config_dir = os.getenv("REPO_CONFIG_DIR", os.path.expanduser("~"))
|
||||||
@@ -671,11 +716,13 @@ class Remote:
|
|||||||
userEmail, host, port
|
userEmail, host, port
|
||||||
)
|
)
|
||||||
except urllib.error.HTTPError as e:
|
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:
|
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:
|
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
|
REVIEW_CACHE[u] = self._review_url
|
||||||
return self._review_url + self.projectname
|
return self._review_url + self.projectname
|
||||||
@@ -692,16 +739,16 @@ class Remote:
|
|||||||
return rev
|
return rev
|
||||||
|
|
||||||
if not rev.startswith("refs/"):
|
if not rev.startswith("refs/"):
|
||||||
rev = R_HEADS + rev
|
rev = git_refs.R_HEADS + rev
|
||||||
|
|
||||||
for spec in self.fetch:
|
for spec in self.fetch:
|
||||||
if spec.SourceMatches(rev):
|
if spec.SourceMatches(rev):
|
||||||
return spec.MapSource(rev)
|
return spec.MapSource(rev)
|
||||||
|
|
||||||
if not rev.startswith(R_HEADS):
|
if not rev.startswith(git_refs.R_HEADS):
|
||||||
return rev
|
return rev
|
||||||
|
|
||||||
raise GitError(
|
raise error.GitError(
|
||||||
"%s: remote %s does not have %s"
|
"%s: remote %s does not have %s"
|
||||||
% (self.projectname, self.name, rev)
|
% (self.projectname, self.name, rev)
|
||||||
)
|
)
|
||||||
|
|||||||
+43
-13
@@ -14,9 +14,9 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from git_command import GitCommand
|
import git_command
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from repo_trace import Trace
|
import repo_trace
|
||||||
|
|
||||||
|
|
||||||
HEAD = "HEAD"
|
HEAD = "HEAD"
|
||||||
@@ -41,6 +41,17 @@ class GitRefs:
|
|||||||
self._EnsureLoaded()
|
self._EnsureLoaded()
|
||||||
return self._phyref
|
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):
|
def get(self, name):
|
||||||
try:
|
try:
|
||||||
return self.all[name]
|
return self.all[name]
|
||||||
@@ -70,7 +81,7 @@ class GitRefs:
|
|||||||
self._LoadAll()
|
self._LoadAll()
|
||||||
|
|
||||||
def _NeedUpdate(self):
|
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():
|
for name, mtime in self._mtime.items():
|
||||||
try:
|
try:
|
||||||
if mtime != os.path.getmtime(
|
if mtime != os.path.getmtime(
|
||||||
@@ -82,13 +93,17 @@ class GitRefs:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _LoadAll(self):
|
def _LoadAll(self):
|
||||||
with Trace(": load refs %s", self._gitdir):
|
with repo_trace.Trace(": load refs %s", self._gitdir):
|
||||||
self._phyref = {}
|
self._phyref = {}
|
||||||
self._symref = {}
|
self._symref = {}
|
||||||
self._mtime = {}
|
self._mtime = {}
|
||||||
|
|
||||||
self._ReadRefs()
|
root_refs_loaded = self._ReadRefs()
|
||||||
self._ReadSymbolicRef(HEAD)
|
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
|
scan = self._symref
|
||||||
attempts = 0
|
attempts = 0
|
||||||
@@ -113,18 +128,32 @@ class GitRefs:
|
|||||||
"""Check if a ref_id is a null object ID."""
|
"""Check if a ref_id is a null object ID."""
|
||||||
return ref_id and all(ch == "0" for ch in ref_id)
|
return ref_id and all(ch == "0" for ch in ref_id)
|
||||||
|
|
||||||
def _ReadRefs(self) -> None:
|
def _ReadRefs(self) -> bool:
|
||||||
"""Read all references using git for-each-ref."""
|
"""Read all references using git for-each-ref.
|
||||||
p = GitCommand(
|
|
||||||
|
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,
|
None,
|
||||||
["for-each-ref", "--format=%(objectname)%00%(refname)%00%(symref)"],
|
cmd,
|
||||||
capture_stdout=True,
|
capture_stdout=True,
|
||||||
capture_stderr=True,
|
capture_stderr=True,
|
||||||
bare=True,
|
bare=True,
|
||||||
gitdir=self._gitdir,
|
gitdir=self._gitdir,
|
||||||
)
|
)
|
||||||
if p.Wait() != 0:
|
if p.Wait() != 0:
|
||||||
return
|
return False
|
||||||
|
|
||||||
for line in p.stdout.splitlines():
|
for line in p.stdout.splitlines():
|
||||||
ref_id, name, symref = line.split("\0")
|
ref_id, name, symref = line.split("\0")
|
||||||
@@ -132,10 +161,11 @@ class GitRefs:
|
|||||||
self._symref[name] = symref
|
self._symref[name] = symref
|
||||||
elif ref_id and not self._IsNullRef(ref_id):
|
elif ref_id and not self._IsNullRef(ref_id):
|
||||||
self._phyref[name] = ref_id
|
self._phyref[name] = ref_id
|
||||||
|
return include_root_refs
|
||||||
|
|
||||||
def _ReadSymbolicRef(self, name: str) -> None:
|
def _ReadSymbolicRef(self, name: str) -> None:
|
||||||
"""Read a symbolic reference."""
|
"""Read a symbolic reference."""
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
["symbolic-ref", "-q", name],
|
["symbolic-ref", "-q", name],
|
||||||
capture_stdout=True,
|
capture_stdout=True,
|
||||||
@@ -149,7 +179,7 @@ class GitRefs:
|
|||||||
self._symref[name] = ref
|
self._symref[name] = ref
|
||||||
return
|
return
|
||||||
|
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
["rev-parse", "--verify", "-q", name],
|
["rev-parse", "--verify", "-q", name],
|
||||||
capture_stdout=True,
|
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
|
from typing import NamedTuple
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from git_command import git_require
|
import git_command
|
||||||
from git_command import GitCommand
|
|
||||||
from git_config import IsId
|
from git_config import IsId
|
||||||
from git_config import RepoConfig
|
from git_config import RepoConfig
|
||||||
from git_refs import GitRefs
|
|
||||||
import platform_utils
|
import platform_utils
|
||||||
|
|
||||||
|
|
||||||
@@ -141,7 +139,7 @@ class Superproject:
|
|||||||
def commit_id(self):
|
def commit_id(self):
|
||||||
"""Returns the commit ID of the superproject checkout."""
|
"""Returns the commit ID of the superproject checkout."""
|
||||||
cmd = ["rev-parse", self.revision]
|
cmd = ["rev-parse", self.revision]
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None, # project
|
None, # project
|
||||||
cmd,
|
cmd,
|
||||||
gitdir=self._work_git,
|
gitdir=self._work_git,
|
||||||
@@ -189,7 +187,7 @@ class Superproject:
|
|||||||
if netloc:
|
if netloc:
|
||||||
parts = netloc.split("-review", 1)
|
parts = netloc.split("-review", 1)
|
||||||
host = parts[0]
|
host = parts[0]
|
||||||
rev = GitRefs(self._work_git).get("HEAD")
|
rev = self._GetRef("HEAD")
|
||||||
return f"{host}/{self.name}@{rev}"
|
return f"{host}/{self.name}@{rev}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -243,7 +241,7 @@ class Superproject:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
cmd = ["init", "--bare", tmp_git_name]
|
cmd = ["init", "--bare", tmp_git_name]
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
cmd,
|
cmd,
|
||||||
cwd=self._superproject_path,
|
cwd=self._superproject_path,
|
||||||
@@ -293,7 +291,7 @@ class Superproject:
|
|||||||
if not os.path.exists(self._work_git):
|
if not os.path.exists(self._work_git):
|
||||||
self._LogWarning("git fetch missing directory: {}", self._work_git)
|
self._LogWarning("git fetch missing directory: {}", self._work_git)
|
||||||
return False
|
return False
|
||||||
if not git_require((2, 28, 0)):
|
if not git_command.git_require((2, 28, 0)):
|
||||||
self._LogWarning(
|
self._LogWarning(
|
||||||
"superproject requires a git version 2.28 or later"
|
"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
|
# 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
|
# do not share commits. So this lets git know it only needs to send
|
||||||
# commits reachable from the specified local refs.
|
# 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:
|
if rev_commit:
|
||||||
cmd.extend(["--negotiation-tip", rev_commit])
|
cmd.extend(["--negotiation-tip", rev_commit])
|
||||||
|
|
||||||
@@ -327,7 +328,7 @@ class Superproject:
|
|||||||
else f"{self.revision}:{self.revision}"
|
else f"{self.revision}:{self.revision}"
|
||||||
)
|
)
|
||||||
cmd.append(refspec)
|
cmd.append(refspec)
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
cmd,
|
cmd,
|
||||||
gitdir=self._work_git,
|
gitdir=self._work_git,
|
||||||
@@ -347,6 +348,21 @@ class Superproject:
|
|||||||
return False
|
return False
|
||||||
return True
|
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):
|
def _LsTree(self):
|
||||||
"""Gets the commit ids for all projects.
|
"""Gets the commit ids for all projects.
|
||||||
|
|
||||||
@@ -364,7 +380,7 @@ class Superproject:
|
|||||||
branch = "HEAD" if not self.revision else self.revision
|
branch = "HEAD" if not self.revision else self.revision
|
||||||
cmd = ["ls-tree", "-z", "-r", branch]
|
cmd = ["ls-tree", "-z", "-r", branch]
|
||||||
|
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
cmd,
|
cmd,
|
||||||
gitdir=self._work_git,
|
gitdir=self._work_git,
|
||||||
@@ -473,7 +489,7 @@ class Superproject:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
manifest_str = self._manifest.ToXml(
|
manifest_str = self._manifest.ToXml(
|
||||||
filter_groups=self._manifest.GetManifestGroupsStr(),
|
filter_groups="all",
|
||||||
omit_local=True,
|
omit_local=True,
|
||||||
).toxml()
|
).toxml()
|
||||||
manifest_path = self._manifest_path
|
manifest_path = self._manifest_path
|
||||||
|
|||||||
@@ -14,8 +14,7 @@
|
|||||||
|
|
||||||
"""Event logging in the git trace2 EVENT format."""
|
"""Event logging in the git trace2 EVENT format."""
|
||||||
|
|
||||||
from git_command import GetEventTargetPath
|
import git_command
|
||||||
from git_command import RepoSourceVersion
|
|
||||||
from git_trace2_event_log_base import BaseEventLog
|
from git_trace2_event_log_base import BaseEventLog
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +36,9 @@ class EventLog(BaseEventLog):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
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):
|
def Write(self, path=None, **kwargs):
|
||||||
if path is None:
|
if path is None:
|
||||||
@@ -45,4 +46,4 @@ class EventLog(BaseEventLog):
|
|||||||
return super().Write(path=path, **kwargs)
|
return super().Write(path=path, **kwargs)
|
||||||
|
|
||||||
def _GetEventTargetPath(self):
|
def _GetEventTargetPath(self):
|
||||||
return GetEventTargetPath()
|
return git_command.GetEventTargetPath()
|
||||||
@@ -12,14 +12,15 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
import optparse
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from error import HookError
|
import error
|
||||||
from git_refs import HEAD
|
import git_refs
|
||||||
|
|
||||||
|
|
||||||
# The API we've documented to hook authors. Keep in sync with repo-hooks.md.
|
# 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,
|
allow_all_hooks=False,
|
||||||
ignore_hooks=False,
|
ignore_hooks=False,
|
||||||
abort_if_user_denies=False,
|
abort_if_user_denies=False,
|
||||||
|
yes=False,
|
||||||
|
fix=False,
|
||||||
):
|
):
|
||||||
"""RepoHook constructor.
|
"""RepoHook constructor.
|
||||||
|
|
||||||
@@ -89,6 +92,8 @@ class RepoHook:
|
|||||||
ignore_hooks: If True, then 'Do not abort action if hooks fail'.
|
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
|
abort_if_user_denies: If True, we'll abort running the hook if the
|
||||||
user doesn't allow us to run the hook.
|
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._hook_type = hook_type
|
||||||
self._hooks_project = hooks_project
|
self._hooks_project = hooks_project
|
||||||
@@ -99,6 +104,8 @@ class RepoHook:
|
|||||||
self._allow_all_hooks = allow_all_hooks
|
self._allow_all_hooks = allow_all_hooks
|
||||||
self._ignore_hooks = ignore_hooks
|
self._ignore_hooks = ignore_hooks
|
||||||
self._abort_if_user_denies = abort_if_user_denies
|
self._abort_if_user_denies = abort_if_user_denies
|
||||||
|
self._yes = yes
|
||||||
|
self._fix = fix
|
||||||
|
|
||||||
# Store the full path to the script for convenience.
|
# Store the full path to the script for convenience.
|
||||||
self._script_fullpath = None
|
self._script_fullpath = None
|
||||||
@@ -135,7 +142,7 @@ class RepoHook:
|
|||||||
# NOTE: Local (non-committed) changes will not be factored into this
|
# NOTE: Local (non-committed) changes will not be factored into this
|
||||||
# hash. I think this is OK, since we're really only worried about
|
# hash. I think this is OK, since we're really only worried about
|
||||||
# warning the user about upstream changes.
|
# 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):
|
def _GetMustVerb(self):
|
||||||
"""Return 'must' if the hook is required; 'should' if not."""
|
"""Return 'must' if the hook is required; 'should' if not."""
|
||||||
@@ -221,7 +228,7 @@ class RepoHook:
|
|||||||
|
|
||||||
# For anything else, we'll assume no approval.
|
# For anything else, we'll assume no approval.
|
||||||
if self._abort_if_user_denies:
|
if self._abort_if_user_denies:
|
||||||
raise HookError(
|
raise error.HookError(
|
||||||
"You must allow the %s hook or use --no-verify."
|
"You must allow the %s hook or use --no-verify."
|
||||||
% self._hook_type
|
% self._hook_type
|
||||||
)
|
)
|
||||||
@@ -319,14 +326,16 @@ class RepoHook:
|
|||||||
try:
|
try:
|
||||||
exec(compile(data, self._script_fullpath, "exec"), context)
|
exec(compile(data, self._script_fullpath, "exec"), context)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HookError(
|
raise error.HookError(
|
||||||
"%s\nFailed to import %s hook; see traceback above."
|
"%s\nFailed to import %s hook; see traceback above."
|
||||||
% (traceback.format_exc(), self._hook_type)
|
% (traceback.format_exc(), self._hook_type)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Running the script should have defined a main() function.
|
# Running the script should have defined a main() function.
|
||||||
if "main" not in context:
|
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
|
# 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
|
# build to fail, it will raise an Exception. We'll catch that convert
|
||||||
@@ -334,7 +343,7 @@ class RepoHook:
|
|||||||
try:
|
try:
|
||||||
context["main"](**kwargs)
|
context["main"](**kwargs)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HookError(
|
raise error.HookError(
|
||||||
"%s\nFailed to run main() for %s hook; see traceback "
|
"%s\nFailed to run main() for %s hook; see traceback "
|
||||||
"above." % (traceback.format_exc(), self._hook_type)
|
"above." % (traceback.format_exc(), self._hook_type)
|
||||||
)
|
)
|
||||||
@@ -374,8 +383,12 @@ class RepoHook:
|
|||||||
# def main(project_list, **kwargs):
|
# def main(project_list, **kwargs):
|
||||||
#
|
#
|
||||||
# This allows us to later expand the API without breaking old hooks.
|
# This allows us to later expand the API without breaking old hooks.
|
||||||
kwargs = kwargs.copy()
|
kwargs = {
|
||||||
kwargs["hook_should_take_kwargs"] = True
|
**kwargs,
|
||||||
|
"hook_should_take_kwargs": True,
|
||||||
|
"fix": self._fix,
|
||||||
|
"yes": self._yes,
|
||||||
|
}
|
||||||
|
|
||||||
# See what version of python the hook has been written against.
|
# See what version of python the hook has been written against.
|
||||||
data = open(self._script_fullpath).read()
|
data = open(self._script_fullpath).read()
|
||||||
@@ -383,7 +396,7 @@ class RepoHook:
|
|||||||
if interp:
|
if interp:
|
||||||
prog = os.path.basename(interp)
|
prog = os.path.basename(interp)
|
||||||
if prog.startswith("python2"):
|
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.
|
# Run the hook by importing directly.
|
||||||
self._ExecuteHookViaImport(data, context, **kwargs)
|
self._ExecuteHookViaImport(data, context, **kwargs)
|
||||||
@@ -395,7 +408,7 @@ class RepoHook:
|
|||||||
def _CheckHook(self):
|
def _CheckHook(self):
|
||||||
# Bail with a nice error if we can't find the hook.
|
# Bail with a nice error if we can't find the hook.
|
||||||
if not os.path.isfile(self._script_fullpath):
|
if not os.path.isfile(self._script_fullpath):
|
||||||
raise HookError(
|
raise error.HookError(
|
||||||
"Couldn't find repo hook: %s" % self._script_fullpath
|
"Couldn't find repo hook: %s" % self._script_fullpath
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -462,7 +475,7 @@ class RepoHook:
|
|||||||
% (self._hook_type, str(e)),
|
% (self._hook_type, str(e)),
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
except HookError as e:
|
except error.HookError as e:
|
||||||
passed = False
|
passed = False
|
||||||
print("ERROR: %s" % str(e), file=sys.stderr)
|
print("ERROR: %s" % str(e), file=sys.stderr)
|
||||||
|
|
||||||
@@ -497,12 +510,18 @@ class RepoHook:
|
|||||||
"origin"
|
"origin"
|
||||||
).url,
|
).url,
|
||||||
"bug_url": manifest.contactinfo.bugurl,
|
"bug_url": manifest.contactinfo.bugurl,
|
||||||
|
"yes": getattr(opt, "yes", False),
|
||||||
|
"fix": getattr(opt, "fix", False),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return cls(*args, **kwargs)
|
return cls(*args, **kwargs)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def AddOptionGroup(parser, name):
|
def AddOptionGroup(
|
||||||
|
parser: optparse.OptionParser,
|
||||||
|
name: str,
|
||||||
|
allow_fix: bool = False,
|
||||||
|
) -> None:
|
||||||
"""Help options relating to the various hooks."""
|
"""Help options relating to the various hooks."""
|
||||||
|
|
||||||
# Note that verify and no-verify are NOT opposites of each other, which
|
# Note that verify and no-verify are NOT opposites of each other, which
|
||||||
@@ -526,3 +545,10 @@ class RepoHook:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Do not abort if %s hooks fail." % name,
|
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
|
from typing import Optional
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -45,34 +45,22 @@ from color import SetDefaultColoring
|
|||||||
from command import InteractiveCommand
|
from command import InteractiveCommand
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from editor import Editor
|
from editor import Editor
|
||||||
from error import DownloadError
|
import error
|
||||||
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 event_log
|
import event_log
|
||||||
from git_command import user_agent
|
import git_command
|
||||||
from git_config import RepoConfig
|
from git_config import RepoConfig
|
||||||
from git_trace2_event_log import EventLog
|
from git_trace2_event_log import EventLog
|
||||||
from manifest_xml import RepoClient
|
from manifest_xml import RepoClient
|
||||||
from pager import RunPager
|
from pager import RunPager
|
||||||
from pager import TerminatePager
|
from pager import TerminatePager
|
||||||
from repo_trace import SetTrace
|
import repo_trace
|
||||||
from repo_trace import SetTraceToStderr
|
|
||||||
from repo_trace import Trace
|
|
||||||
from subcmds import all_commands
|
from subcmds import all_commands
|
||||||
from subcmds.version import Version
|
from subcmds.version import Version
|
||||||
from wrapper import Wrapper
|
from wrapper import Wrapper
|
||||||
from wrapper import WrapperPath
|
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.
|
# 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
|
KEYBOARD_INTERRUPT_EXIT = 128 + signal.SIGINT
|
||||||
MAX_PRINT_ERRORS = 5
|
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(
|
global_options = optparse.OptionParser(
|
||||||
usage="repo [-p|--paginate|--no-pager] COMMAND [ARGS]",
|
usage="repo [-p|--paginate|--no-pager] COMMAND [ARGS]",
|
||||||
add_help_option=False,
|
add_help_option=False,
|
||||||
@@ -274,7 +267,7 @@ class _Repo:
|
|||||||
run = (
|
run = (
|
||||||
lambda: self._RunLong(name, gopts, argv, git_trace2_event_log) or 0
|
lambda: self._RunLong(name, gopts, argv, git_trace2_event_log) or 0
|
||||||
)
|
)
|
||||||
with Trace(
|
with repo_trace.Trace(
|
||||||
"starting new command: %s [sid=%s]",
|
"starting new command: %s [sid=%s]",
|
||||||
", ".join([name] + argv),
|
", ".join([name] + argv),
|
||||||
git_trace2_event_log.full_sid,
|
git_trace2_event_log.full_sid,
|
||||||
@@ -430,7 +423,7 @@ class _Repo:
|
|||||||
try:
|
try:
|
||||||
copts, cargs = cmd.OptionParser.parse_args(argv)
|
copts, cargs = cmd.OptionParser.parse_args(argv)
|
||||||
copts = cmd.ReadEnvironmentOptions(copts)
|
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: in `%s`: %s", " ".join([name] + argv), e)
|
||||||
logger.error(
|
logger.error(
|
||||||
"error: manifest missing or unreadable -- please run init"
|
"error: manifest missing or unreadable -- please run init"
|
||||||
@@ -506,23 +499,23 @@ class _Repo:
|
|||||||
KeyboardInterrupt,
|
KeyboardInterrupt,
|
||||||
SystemExit,
|
SystemExit,
|
||||||
Exception,
|
Exception,
|
||||||
RepoExitError,
|
error.RepoExitError,
|
||||||
) as e:
|
) as e:
|
||||||
ok = isinstance(e, SystemExit) and not e.code
|
ok = isinstance(e, SystemExit) and not e.code
|
||||||
exception_name = type(e).__name__
|
exception_name = type(e).__name__
|
||||||
if isinstance(e, RepoUnhandledExceptionError):
|
if isinstance(e, error.RepoUnhandledExceptionError):
|
||||||
exception_name = type(e.error).__name__
|
exception_name = type(e.error).__name__
|
||||||
if isinstance(e, RepoExitError):
|
if isinstance(e, error.RepoExitError):
|
||||||
aggregated_errors = e.aggregate_errors or []
|
aggregated_errors = e.aggregate_errors or []
|
||||||
for error in aggregated_errors:
|
for err in aggregated_errors:
|
||||||
project = None
|
project = None
|
||||||
if isinstance(error, RepoError):
|
if isinstance(err, error.RepoError):
|
||||||
project = error.project
|
project = err.project
|
||||||
error_info = json.dumps(
|
error_info = json.dumps(
|
||||||
{
|
{
|
||||||
"ErrorType": type(error).__name__,
|
"ErrorType": type(err).__name__,
|
||||||
"Project": str(project),
|
"Project": str(project),
|
||||||
"Message": str(error),
|
"Message": str(err),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
git_trace2_event_log.ErrorEvent(
|
git_trace2_event_log.ErrorEvent(
|
||||||
@@ -537,24 +530,24 @@ class _Repo:
|
|||||||
try:
|
try:
|
||||||
execute_command()
|
execute_command()
|
||||||
except (
|
except (
|
||||||
DownloadError,
|
error.DownloadError,
|
||||||
ManifestInvalidRevisionError,
|
error.ManifestInvalidRevisionError,
|
||||||
ManifestParseError,
|
error.ManifestParseError,
|
||||||
NoManifestException,
|
error.NoManifestException,
|
||||||
) as e:
|
) as e:
|
||||||
logger.error("error: in `%s`: %s", " ".join([name] + argv), e)
|
logger.error("error: in `%s`: %s", " ".join([name] + argv), e)
|
||||||
if isinstance(e, NoManifestException):
|
if isinstance(e, error.NoManifestException):
|
||||||
logger.error(
|
logger.error(
|
||||||
"error: manifest missing or unreadable -- please run init"
|
"error: manifest missing or unreadable -- please run init"
|
||||||
)
|
)
|
||||||
result = e.exit_code
|
result = e.exit_code
|
||||||
except NoSuchProjectError as e:
|
except error.NoSuchProjectError as e:
|
||||||
if e.name:
|
if e.name:
|
||||||
logger.error("error: project %s not found", e.name)
|
logger.error("error: project %s not found", e.name)
|
||||||
else:
|
else:
|
||||||
logger.error("error: no project in current directory")
|
logger.error("error: no project in current directory")
|
||||||
result = e.exit_code
|
result = e.exit_code
|
||||||
except InvalidProjectGroupsError as e:
|
except error.InvalidProjectGroupsError as e:
|
||||||
if e.name:
|
if e.name:
|
||||||
logger.error(
|
logger.error(
|
||||||
"error: project group must be enabled for project %s",
|
"error: project group must be enabled for project %s",
|
||||||
@@ -573,7 +566,7 @@ class _Repo:
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
result = KEYBOARD_INTERRUPT_EXIT
|
result = KEYBOARD_INTERRUPT_EXIT
|
||||||
raise
|
raise
|
||||||
except RepoExitError as e:
|
except error.RepoExitError as e:
|
||||||
result = e.exit_code
|
result = e.exit_code
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -707,11 +700,11 @@ def _PruneOptions(argv, opt):
|
|||||||
|
|
||||||
class _UserAgentHandler(urllib.request.BaseHandler):
|
class _UserAgentHandler(urllib.request.BaseHandler):
|
||||||
def http_request(self, req):
|
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
|
return req
|
||||||
|
|
||||||
def https_request(self, 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
|
return req
|
||||||
|
|
||||||
|
|
||||||
@@ -938,20 +931,20 @@ def _Main(argv):
|
|||||||
name, gopts, argv = repo._ParseArgs(argv)
|
name, gopts, argv = repo._ParseArgs(argv)
|
||||||
|
|
||||||
if gopts.trace:
|
if gopts.trace:
|
||||||
SetTrace()
|
repo_trace.SetTrace()
|
||||||
|
|
||||||
if gopts.trace_to_stderr:
|
if gopts.trace_to_stderr:
|
||||||
SetTraceToStderr()
|
repo_trace.SetTraceToStderr()
|
||||||
|
|
||||||
result = repo._Run(name, gopts, argv) or 0
|
result = repo._Run(name, gopts, argv) or 0
|
||||||
except RepoExitError as e:
|
except error.RepoExitError as e:
|
||||||
if not isinstance(e, SilentRepoExitError):
|
if not isinstance(e, error.SilentRepoExitError):
|
||||||
logger.log_aggregated_errors(e)
|
logger.log_aggregated_errors(e)
|
||||||
result = e.exit_code
|
result = e.exit_code
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("aborted by user", file=sys.stderr)
|
print("aborted by user", file=sys.stderr)
|
||||||
result = KEYBOARD_INTERRUPT_EXIT
|
result = KEYBOARD_INTERRUPT_EXIT
|
||||||
except RepoChangedException as rce:
|
except error.RepoChangedException as rce:
|
||||||
# If repo changed, re-exec ourselves.
|
# If repo changed, re-exec ourselves.
|
||||||
argv = list(sys.argv)
|
argv = list(sys.argv)
|
||||||
argv.extend(rce.extra_args)
|
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.
|
.\" 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
|
.SH NAME
|
||||||
repo \- repo gc - manual page for repo gc
|
repo \- repo gc - manual page for repo gc
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -22,6 +22,9 @@ answer yes to all safe prompts
|
|||||||
.TP
|
.TP
|
||||||
\fB\-\-repack\fR
|
\fB\-\-repack\fR
|
||||||
repack all projects that use partial clone with filter=blob:none
|
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:
|
.SS Logging options:
|
||||||
.TP
|
.TP
|
||||||
\fB\-v\fR, \fB\-\-verbose\fR
|
\fB\-v\fR, \fB\-\-verbose\fR
|
||||||
@@ -44,3 +47,11 @@ only operate on this (sub)manifest
|
|||||||
operate on this manifest and its submanifests
|
operate on this manifest and its submanifests
|
||||||
.PP
|
.PP
|
||||||
Run `repo help gc` to view the detailed manual.
|
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
|
\fB\-\-no\-use\-superproject\fR
|
||||||
disable use of manifest superprojects
|
disable use of manifest superprojects
|
||||||
.TP
|
.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
|
\fB\-\-clone\-bundle\fR
|
||||||
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS (default if not \fB\-\-partial\-clone\fR)
|
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS (default if not \fB\-\-partial\-clone\fR)
|
||||||
.TP
|
.TP
|
||||||
|
|||||||
+14
-11
@@ -1,5 +1,5 @@
|
|||||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
.\" 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
|
.SH NAME
|
||||||
repo \- repo manifest - manual page for repo manifest
|
repo \- repo manifest - manual page for repo manifest
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -121,18 +121,19 @@ include*)>
|
|||||||
<!ATTLIST remote revision CDATA #IMPLIED>
|
<!ATTLIST remote revision CDATA #IMPLIED>
|
||||||
.IP
|
.IP
|
||||||
<!ELEMENT default EMPTY>
|
<!ELEMENT default EMPTY>
|
||||||
<!ATTLIST default remote IDREF #IMPLIED>
|
<!ATTLIST default remote IDREF #IMPLIED>
|
||||||
<!ATTLIST default revision CDATA #IMPLIED>
|
<!ATTLIST default revision CDATA #IMPLIED>
|
||||||
<!ATTLIST default dest\-branch CDATA #IMPLIED>
|
<!ATTLIST default dest\-branch CDATA #IMPLIED>
|
||||||
<!ATTLIST default upstream CDATA #IMPLIED>
|
<!ATTLIST default upstream CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync\-j CDATA #IMPLIED>
|
<!ATTLIST default sync\-j CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync\-j\-max CDATA #IMPLIED>
|
<!ATTLIST default sync\-j\-max CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync\-c CDATA #IMPLIED>
|
<!ATTLIST default sync\-c CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync\-s CDATA #IMPLIED>
|
<!ATTLIST default sync\-s CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync\-tags CDATA #IMPLIED>
|
<!ATTLIST default sync\-tags CDATA #IMPLIED>
|
||||||
|
<!ATTLIST default sync\-smartsync CDATA #IMPLIED>
|
||||||
.IP
|
.IP
|
||||||
<!ELEMENT manifest\-server EMPTY>
|
<!ELEMENT manifest\-server EMPTY>
|
||||||
<!ATTLIST manifest\-server url CDATA #REQUIRED>
|
<!ATTLIST manifest\-server url CDATA #REQUIRED>
|
||||||
<!ATTLIST manifest\-server helper CDATA #IMPLIED>
|
<!ATTLIST manifest\-server helper CDATA #IMPLIED>
|
||||||
.IP
|
.IP
|
||||||
<!ELEMENT submanifest EMPTY>
|
<!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
|
Attribute `sync\-tags`: Set to false to only sync the given Git branch (specified
|
||||||
in the `revision` attribute) rather than the other ref tags.
|
in the `revision` attribute) rather than the other ref tags.
|
||||||
.PP
|
.PP
|
||||||
|
Attribute `sync\-smartsync`: Set to true to enable smart sync by default.
|
||||||
|
.PP
|
||||||
Element manifest\-server
|
Element manifest\-server
|
||||||
.PP
|
.PP
|
||||||
At most one manifest\-server may be specified. The url attribute is used to
|
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.
|
.\" 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
|
.SH NAME
|
||||||
repo \- repo smartsync - manual page for repo smartsync
|
repo \- repo smartsync - manual page for repo smartsync
|
||||||
.SH SYNOPSIS
|
.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
|
number of network jobs to run in parallel (defaults to \fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||||
.TP
|
.TP
|
||||||
\fB\-\-jobs\-checkout\fR=\fI\,JOBS\/\fR
|
\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
|
.TP
|
||||||
\fB\-f\fR, \fB\-\-force\-broken\fR
|
\fB\-f\fR, \fB\-\-force\-broken\fR
|
||||||
obsolete option (to be deleted in the future)
|
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
|
\fB\-m\fR NAME.xml, \fB\-\-manifest\-name\fR=\fI\,NAME\/\fR.xml
|
||||||
temporary manifest to use for this sync
|
temporary manifest to use for this sync
|
||||||
.TP
|
.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
|
\fB\-\-clone\-bundle\fR
|
||||||
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS
|
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS
|
||||||
.TP
|
.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
|
\fB\-p\fR MANIFEST_SERVER_PASSWORD, \fB\-\-manifest\-server\-password\fR=\fI\,MANIFEST_SERVER_PASSWORD\/\fR
|
||||||
password to authenticate with the manifest server
|
password to authenticate with the manifest server
|
||||||
.TP
|
.TP
|
||||||
\fB\-\-fetch\-submodules\fR
|
\fB\-\-recurse\-submodules\fR
|
||||||
fetch submodules from server
|
sync submodules from server
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-recurse\-submodules\fR
|
||||||
|
don't sync submodules from server
|
||||||
.TP
|
.TP
|
||||||
\fB\-\-use\-superproject\fR
|
\fB\-\-use\-superproject\fR
|
||||||
use the manifest superproject to sync projects; implies \fB\-c\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.
|
.\" 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
|
.SH NAME
|
||||||
repo \- repo sync - manual page for repo sync
|
repo \- repo sync - manual page for repo sync
|
||||||
.SH SYNOPSIS
|
.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
|
number of network jobs to run in parallel (defaults to \fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||||
.TP
|
.TP
|
||||||
\fB\-\-jobs\-checkout\fR=\fI\,JOBS\/\fR
|
\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
|
.TP
|
||||||
\fB\-f\fR, \fB\-\-force\-broken\fR
|
\fB\-f\fR, \fB\-\-force\-broken\fR
|
||||||
obsolete option (to be deleted in the future)
|
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
|
\fB\-m\fR NAME.xml, \fB\-\-manifest\-name\fR=\fI\,NAME\/\fR.xml
|
||||||
temporary manifest to use for this sync
|
temporary manifest to use for this sync
|
||||||
.TP
|
.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
|
\fB\-\-clone\-bundle\fR
|
||||||
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS
|
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS
|
||||||
.TP
|
.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
|
\fB\-p\fR MANIFEST_SERVER_PASSWORD, \fB\-\-manifest\-server\-password\fR=\fI\,MANIFEST_SERVER_PASSWORD\/\fR
|
||||||
password to authenticate with the manifest server
|
password to authenticate with the manifest server
|
||||||
.TP
|
.TP
|
||||||
\fB\-\-fetch\-submodules\fR
|
\fB\-\-recurse\-submodules\fR
|
||||||
fetch submodules from server
|
sync submodules from server
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-recurse\-submodules\fR
|
||||||
|
don't sync submodules from server
|
||||||
.TP
|
.TP
|
||||||
\fB\-\-use\-superproject\fR
|
\fB\-\-use\-superproject\fR
|
||||||
use the manifest superproject to sync projects; implies \fB\-c\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
|
\fB\-s\fR, \fB\-\-smart\-sync\fR
|
||||||
smart sync using manifest from the latest known good build
|
smart sync using manifest from the latest known good build
|
||||||
.TP
|
.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
|
\fB\-t\fR SMART_TAG, \fB\-\-smart\-tag\fR=\fI\,SMART_TAG\/\fR
|
||||||
smart sync using manifest from a known tag
|
smart sync using manifest from a known tag
|
||||||
.SS Logging options:
|
.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
|
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.
|
\fB\-t\fR/\-\-smart\-tag option is similar and allows you to specify a custom tag/label.
|
||||||
.PP
|
.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
|
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
|
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.
|
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
|
delivery network. This may be necessary if there are problems with the local
|
||||||
Python HTTP client or proxy configuration, but the Git binary works.
|
Python HTTP client or proxy configuration, but the Git binary works.
|
||||||
.PP
|
.PP
|
||||||
The \fB\-\-fetch\-submodules\fR option enables fetching Git submodules of a project from
|
The \fB\-\-recurse\-submodules\fR option enables syncing Git submodules of all projects
|
||||||
server.
|
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
|
.PP
|
||||||
The \fB\-c\fR/\-\-current\-branch option can be used to only fetch objects that are on the
|
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.
|
branch specified by a project's revision.
|
||||||
|
|||||||
+7
-1
@@ -1,5 +1,5 @@
|
|||||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||||
.TH REPO "1" "June 2026" "repo upload" "Repo Manual"
|
.TH REPO "1" "October 2026" "repo upload" "Repo Manual"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
repo \- repo upload - manual page for repo upload
|
repo \- repo upload - manual page for repo upload
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -80,6 +80,9 @@ ignore untracked files in the working copy
|
|||||||
\fB\-\-no\-ignore\-untracked\-files\fR
|
\fB\-\-no\-ignore\-untracked\-files\fR
|
||||||
always ask about untracked files in the working copy
|
always ask about untracked files in the working copy
|
||||||
.TP
|
.TP
|
||||||
|
\fB\-\-ignore\-remote\-commits\fR
|
||||||
|
ignore commits already in remote branches
|
||||||
|
.TP
|
||||||
\fB\-\-no\-cert\-checks\fR
|
\fB\-\-no\-cert\-checks\fR
|
||||||
disable verifying ssl certs (unsafe)
|
disable verifying ssl certs (unsafe)
|
||||||
.SS Logging options:
|
.SS Logging options:
|
||||||
@@ -112,6 +115,9 @@ Run the pre\-upload hook without prompting.
|
|||||||
.TP
|
.TP
|
||||||
\fB\-\-ignore\-hooks\fR
|
\fB\-\-ignore\-hooks\fR
|
||||||
Do not abort if pre\-upload hooks fail.
|
Do not abort if pre\-upload hooks fail.
|
||||||
|
.TP
|
||||||
|
\fB\-\-fix\fR
|
||||||
|
Automatically apply pre\-upload fixes without prompting.
|
||||||
.PP
|
.PP
|
||||||
Run `repo help upload` to view the detailed manual.
|
Run `repo help upload` to view the detailed manual.
|
||||||
.SH DETAILS
|
.SH DETAILS
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,5 @@
|
|||||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
.\" 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
|
.SH NAME
|
||||||
repo \- repository management tool built on top of git
|
repo \- repository management tool built on top of git
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -65,6 +65,9 @@ Checkout a branch for development
|
|||||||
cherry\-pick
|
cherry\-pick
|
||||||
Cherry\-pick a change.
|
Cherry\-pick a change.
|
||||||
.TP
|
.TP
|
||||||
|
config
|
||||||
|
Read and write per\-user repo settings
|
||||||
|
.TP
|
||||||
diff
|
diff
|
||||||
Show changes between commit and working tree
|
Show changes between commit and working tree
|
||||||
.TP
|
.TP
|
||||||
|
|||||||
+92
-77
@@ -21,12 +21,9 @@ import sys
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import xml.dom.minidom
|
import xml.dom.minidom
|
||||||
|
|
||||||
from error import ManifestInvalidPathError
|
import error
|
||||||
from error import ManifestInvalidRevisionError
|
|
||||||
from error import ManifestParseError
|
|
||||||
from git_config import GitConfig
|
from git_config import GitConfig
|
||||||
from git_refs import HEAD
|
import git_refs
|
||||||
from git_refs import R_HEADS
|
|
||||||
from git_superproject import Superproject
|
from git_superproject import Superproject
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from project import Annotation
|
from project import Annotation
|
||||||
@@ -114,7 +111,9 @@ def XmlInt(node, attr, default=None):
|
|||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except ValueError:
|
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:
|
def normalize_url(url: str) -> str:
|
||||||
@@ -159,6 +158,7 @@ class _Default:
|
|||||||
sync_c = False
|
sync_c = False
|
||||||
sync_s = False
|
sync_s = False
|
||||||
sync_tags = True
|
sync_tags = True
|
||||||
|
sync_smartsync = False
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
if not isinstance(other, _Default):
|
if not isinstance(other, _Default):
|
||||||
@@ -289,7 +289,7 @@ class _XmlSubmanifest:
|
|||||||
self.annotations = []
|
self.annotations = []
|
||||||
outer_client = parent._outer_client or parent
|
outer_client = parent._outer_client or parent
|
||||||
if self.remote and not self.project:
|
if self.remote and not self.project:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
f"Submanifest {name}: must specify project when remote is "
|
f"Submanifest {name}: must specify project when remote is "
|
||||||
"given."
|
"given."
|
||||||
)
|
)
|
||||||
@@ -423,7 +423,7 @@ class XmlManifest:
|
|||||||
# This avoids a trailing os.path.sep when submanifest_path is empty.
|
# This avoids a trailing os.path.sep when submanifest_path is empty.
|
||||||
self.topdir = os.path.join(self.topdir, submanifest_path)
|
self.topdir = os.path.join(self.topdir, submanifest_path)
|
||||||
if manifest_file != os.path.abspath(manifest_file):
|
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
|
self.manifestFile = manifest_file
|
||||||
if not outer_client or outer_client == self:
|
if not outer_client or outer_client == self:
|
||||||
# manifestFileOverrides only exists in the outer_client's manifest,
|
# manifestFileOverrides only exists in the outer_client's manifest,
|
||||||
@@ -437,7 +437,9 @@ class XmlManifest:
|
|||||||
|
|
||||||
if submanifest_path and not outer_client:
|
if submanifest_path and not outer_client:
|
||||||
# If passing a submanifest_path, there must be an 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
|
# If self._outer_client is None, this is not a checkout that supports
|
||||||
# multi-tree.
|
# multi-tree.
|
||||||
@@ -476,13 +478,24 @@ class XmlManifest:
|
|||||||
if path is None:
|
if path is None:
|
||||||
path = os.path.join(self.manifestProject.worktree, name)
|
path = os.path.join(self.manifestProject.worktree, name)
|
||||||
if not os.path.isfile(path):
|
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._load_local_manifests = load_local_manifests
|
||||||
self._outer_client.manifestFileOverrides[self.path_prefix] = path
|
self._outer_client.manifestFileOverrides[self.path_prefix] = path
|
||||||
self.Unload()
|
self.Unload()
|
||||||
self._Load()
|
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):
|
def Link(self, name):
|
||||||
"""Update the repo metadata to use a different manifest."""
|
"""Update the repo metadata to use a different manifest."""
|
||||||
self.Override(name)
|
self.Override(name)
|
||||||
@@ -644,6 +657,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if not d.sync_tags:
|
if not d.sync_tags:
|
||||||
have_default = True
|
have_default = True
|
||||||
e.setAttribute("sync-tags", "false")
|
e.setAttribute("sync-tags", "false")
|
||||||
|
if d.sync_smartsync:
|
||||||
|
have_default = True
|
||||||
|
e.setAttribute("sync-smartsync", "true")
|
||||||
if have_default:
|
if have_default:
|
||||||
root.appendChild(e)
|
root.appendChild(e)
|
||||||
root.appendChild(doc.createTextNode(""))
|
root.appendChild(doc.createTextNode(""))
|
||||||
@@ -692,9 +708,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
e.setAttribute("remote", remoteName)
|
e.setAttribute("remote", remoteName)
|
||||||
if peg_rev:
|
if peg_rev:
|
||||||
if self.IsMirror:
|
if self.IsMirror:
|
||||||
value = p.bare_git.rev_parse(p.revisionExpr + "^0")
|
value = p.bare_git.ResolveCommit(p.revisionExpr)
|
||||||
else:
|
else:
|
||||||
value = p.work_git.rev_parse(HEAD + "^0")
|
value = p.work_git.ResolveCommit(git_refs.HEAD)
|
||||||
e.setAttribute("revision", value)
|
e.setAttribute("revision", value)
|
||||||
if peg_rev_upstream:
|
if peg_rev_upstream:
|
||||||
if p.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:
|
elif child.nodeName in MULTI_ELEMENTS:
|
||||||
ret.setdefault(child.nodeName, []).append(element)
|
ret.setdefault(child.nodeName, []).append(element)
|
||||||
else:
|
else:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
f'Unhandled element "{child.nodeName}"'
|
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):
|
def _Load(self, initial_client=None, submanifest_depth=0):
|
||||||
if submanifest_depth > MAX_SUBMANIFEST_DEPTH:
|
if submanifest_depth > MAX_SUBMANIFEST_DEPTH:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"maximum submanifest depth %d exceeded." % MAX_SUBMANIFEST_DEPTH
|
"maximum submanifest depth %d exceeded." % MAX_SUBMANIFEST_DEPTH
|
||||||
)
|
)
|
||||||
if not self._loaded:
|
if not self._loaded:
|
||||||
@@ -1191,8 +1207,8 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
try:
|
try:
|
||||||
m = self.manifestProject
|
m = self.manifestProject
|
||||||
b = m.GetBranch(m.CurrentBranch).merge
|
b = m.GetBranch(m.CurrentBranch).merge
|
||||||
if b is not None and b.startswith(R_HEADS):
|
if b is not None and b.startswith(git_refs.R_HEADS):
|
||||||
b = b[len(R_HEADS) :]
|
b = b[len(git_refs.R_HEADS) :]
|
||||||
self.branch = b
|
self.branch = b
|
||||||
|
|
||||||
parent_groups = self.parent_groups.copy()
|
parent_groups = self.parent_groups.copy()
|
||||||
@@ -1245,7 +1261,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self._ParseManifest(nodes)
|
self._ParseManifest(nodes)
|
||||||
except ManifestParseError as e:
|
except error.ManifestParseError as e:
|
||||||
# There was a problem parsing, unload ourselves in case they
|
# There was a problem parsing, unload ourselves in case they
|
||||||
# catch this error and try again later, we will show the
|
# catch this error and try again later, we will show the
|
||||||
# correct error
|
# correct error
|
||||||
@@ -1307,10 +1323,12 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
try:
|
try:
|
||||||
root = xml.dom.minidom.parse(path)
|
root = xml.dom.minidom.parse(path)
|
||||||
except (OSError, xml.parsers.expat.ExpatError) as e:
|
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:
|
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:
|
for manifest in root.childNodes:
|
||||||
if (
|
if (
|
||||||
@@ -1319,7 +1337,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
):
|
):
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise ManifestParseError(f"no <manifest> in {path}")
|
raise error.ManifestParseError(f"no <manifest> in {path}")
|
||||||
|
|
||||||
nodes = []
|
nodes = []
|
||||||
for node in manifest.childNodes:
|
for node in manifest.childNodes:
|
||||||
@@ -1336,7 +1354,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if restrict_includes:
|
if restrict_includes:
|
||||||
msg = self._CheckLocalPath(name)
|
msg = self._CheckLocalPath(name)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<include> invalid "name": {name}: {msg}'
|
f'<include> invalid "name": {name}: {msg}'
|
||||||
)
|
)
|
||||||
include_groups = (parent_groups or set()).copy()
|
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)
|
fp = os.path.join(include_root, name)
|
||||||
if not os.path.isfile(fp):
|
if not os.path.isfile(fp):
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"include [%s/]%s doesn't exist or isn't a file"
|
"include [%s/]%s doesn't exist or isn't a file"
|
||||||
% (include_root, name)
|
% (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
|
# should isolate this to the exact exception, but that's
|
||||||
# tricky. actual parsing implementation may vary.
|
# tricky. actual parsing implementation may vary.
|
||||||
except (RuntimeError, ManifestParseError):
|
except (RuntimeError, error.ManifestParseError):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
f"failed parsing included manifest {name}: {e}"
|
f"failed parsing included manifest {name}: {e}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1385,7 +1403,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if remote:
|
if remote:
|
||||||
if remote.name in self._remotes:
|
if remote.name in self._remotes:
|
||||||
if remote != self._remotes[remote.name]:
|
if remote != self._remotes[remote.name]:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"remote %s already exists with different "
|
"remote %s already exists with different "
|
||||||
"attributes" % (remote.name)
|
"attributes" % (remote.name)
|
||||||
)
|
)
|
||||||
@@ -1401,7 +1419,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if self._default is None:
|
if self._default is None:
|
||||||
self._default = new_default
|
self._default = new_default
|
||||||
elif not emptyDefault and new_default != self._default:
|
elif not emptyDefault and new_default != self._default:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate default in %s" % (self.manifestFile)
|
"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:
|
||||||
if submanifest.name in self._submanifests:
|
if submanifest.name in self._submanifests:
|
||||||
if submanifest != self._submanifests[submanifest.name]:
|
if submanifest != self._submanifests[submanifest.name]:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"submanifest %s already exists with different "
|
"submanifest %s already exists with different "
|
||||||
"attributes" % (submanifest.name)
|
"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):
|
for node in itertools.chain(*node_list):
|
||||||
if node.nodeName == "notice":
|
if node.nodeName == "notice":
|
||||||
if self._notice is not None:
|
if self._notice is not None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate notice in %s" % (self.manifestFile)
|
"duplicate notice in %s" % (self.manifestFile)
|
||||||
)
|
)
|
||||||
self._notice = self._ParseNotice(node)
|
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")
|
url = self._reqatt(node, "url")
|
||||||
helper = node.getAttribute("helper") or None
|
helper = node.getAttribute("helper") or None
|
||||||
if self._manifest_server is not None:
|
if self._manifest_server is not None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate manifest-server in %s" % (self.manifestFile)
|
"duplicate manifest-server in %s" % (self.manifestFile)
|
||||||
)
|
)
|
||||||
self._manifest_server = url
|
self._manifest_server = url
|
||||||
@@ -1445,18 +1463,18 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
def recursively_add_projects(project):
|
def recursively_add_projects(project):
|
||||||
projects = self._projects.setdefault(project.name, [])
|
projects = self._projects.setdefault(project.name, [])
|
||||||
if project.relpath is None:
|
if project.relpath is None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"missing path for %s in %s"
|
"missing path for %s in %s"
|
||||||
% (project.name, self.manifestFile)
|
% (project.name, self.manifestFile)
|
||||||
)
|
)
|
||||||
if project.relpath in self._paths:
|
if project.relpath in self._paths:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate path %s in %s"
|
"duplicate path %s in %s"
|
||||||
% (project.relpath, self.manifestFile)
|
% (project.relpath, self.manifestFile)
|
||||||
)
|
)
|
||||||
for tree in submanifest_paths:
|
for tree in submanifest_paths:
|
||||||
if project.relpath.startswith(tree):
|
if project.relpath.startswith(tree):
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"project %s conflicts with submanifest path %s"
|
"project %s conflicts with submanifest path %s"
|
||||||
% (project.relpath, tree)
|
% (project.relpath, tree)
|
||||||
)
|
)
|
||||||
@@ -1476,7 +1494,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
name = self._reqatt(node, "name")
|
name = self._reqatt(node, "name")
|
||||||
|
|
||||||
if name not in self._projects:
|
if name not in self._projects:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"extend-project element specifies non-existent "
|
"extend-project element specifies non-existent "
|
||||||
"project: %s" % name
|
"project: %s" % name
|
||||||
)
|
)
|
||||||
@@ -1498,7 +1516,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
|
|
||||||
named_projects = self._projects[name]
|
named_projects = self._projects[name]
|
||||||
if dest_path and not path and len(named_projects) > 1:
|
if dest_path and not path and len(named_projects) > 1:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"extend-project cannot use dest-path when "
|
"extend-project cannot use dest-path when "
|
||||||
"matching multiple projects: %s" % name
|
"matching multiple projects: %s" % name
|
||||||
)
|
)
|
||||||
@@ -1519,9 +1537,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if base_revision:
|
if base_revision:
|
||||||
if p.revisionExpr != base_revision:
|
if p.revisionExpr != base_revision:
|
||||||
failed_revision_changes.append(
|
failed_revision_changes.append(
|
||||||
"extend-project name %s mismatch base "
|
f"extend-project name {name}:\n "
|
||||||
"%s vs revision %s"
|
f"base {base_revision} vs "
|
||||||
% (name, base_revision, p.revisionExpr)
|
f"revision {p.revisionExpr}"
|
||||||
)
|
)
|
||||||
p.SetRevision(revision)
|
p.SetRevision(revision)
|
||||||
|
|
||||||
@@ -1555,7 +1573,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if node.nodeName == "repo-hooks":
|
if node.nodeName == "repo-hooks":
|
||||||
# Only one project can be the hooks project
|
# Only one project can be the hooks project
|
||||||
if repo_hooks_project is not None:
|
if repo_hooks_project is not None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate repo-hooks in %s" % (self.manifestFile)
|
"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")
|
name = self._reqatt(node, "name")
|
||||||
# There can only be one superproject.
|
# There can only be one superproject.
|
||||||
if self._superproject:
|
if self._superproject:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate superproject in %s" % (self.manifestFile)
|
"duplicate superproject in %s" % (self.manifestFile)
|
||||||
)
|
)
|
||||||
remote_name = node.getAttribute("remote")
|
remote_name = node.getAttribute("remote")
|
||||||
@@ -1578,7 +1596,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
else:
|
else:
|
||||||
remote = self._get_remote(node)
|
remote = self._get_remote(node)
|
||||||
if remote is None:
|
if remote is None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"no remote for superproject %s within %s"
|
"no remote for superproject %s within %s"
|
||||||
% (name, self.manifestFile)
|
% (name, self.manifestFile)
|
||||||
)
|
)
|
||||||
@@ -1586,7 +1604,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if not revision:
|
if not revision:
|
||||||
revision = self._default.revisionExpr
|
revision = self._default.revisionExpr
|
||||||
if not revision:
|
if not revision:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"no revision for superproject %s within %s"
|
"no revision for superproject %s within %s"
|
||||||
% (name, self.manifestFile)
|
% (name, self.manifestFile)
|
||||||
)
|
)
|
||||||
@@ -1609,7 +1627,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
|
|
||||||
# Name or path needed.
|
# Name or path needed.
|
||||||
if not name and not path:
|
if not name and not path:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"remove-project must have name and/or path"
|
"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 base_revision:
|
||||||
if p.revisionExpr != base_revision:
|
if p.revisionExpr != base_revision:
|
||||||
failed_revision_changes.append(
|
failed_revision_changes.append(
|
||||||
"remove-project name %s mismatch base "
|
f"remove-project name {name}:\n "
|
||||||
"%s vs revision %s"
|
f"base {base_revision} vs "
|
||||||
% (name, base_revision, p.revisionExpr)
|
f"revision {p.revisionExpr}"
|
||||||
)
|
)
|
||||||
del self._paths[p.relpath]
|
del self._paths[p.relpath]
|
||||||
if not removed_project:
|
if not removed_project:
|
||||||
@@ -1636,13 +1654,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if base_revision:
|
if base_revision:
|
||||||
if p.revisionExpr != base_revision:
|
if p.revisionExpr != base_revision:
|
||||||
failed_revision_changes.append(
|
failed_revision_changes.append(
|
||||||
"remove-project path %s mismatch base "
|
f"remove-project path {p.relpath}:\n "
|
||||||
"%s vs revision %s"
|
f"base {base_revision} vs "
|
||||||
% (
|
f"revision {p.revisionExpr}"
|
||||||
p.relpath,
|
|
||||||
base_revision,
|
|
||||||
p.revisionExpr,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self._projects[projname].remove(p)
|
self._projects[projname].remove(p)
|
||||||
del self._paths[p.relpath]
|
del self._paths[p.relpath]
|
||||||
@@ -1658,16 +1672,16 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
repo_hooks_project = None
|
repo_hooks_project = None
|
||||||
|
|
||||||
if not removed_project and not XmlBool(node, "optional", False):
|
if not removed_project and not XmlBool(node, "optional", False):
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"remove-project element specifies non-existent "
|
"remove-project element specifies non-existent "
|
||||||
"project: %s" % node.toxml()
|
"project: %s" % node.toxml()
|
||||||
)
|
)
|
||||||
|
|
||||||
if failed_revision_changes:
|
if failed_revision_changes:
|
||||||
raise ManifestParseError(
|
fail_string = "\n".join(failed_revision_changes)
|
||||||
"revision base check failed, rebase patches and update "
|
raise error.ManifestParseError(
|
||||||
"base revs for: ",
|
f"detected base-revision mismatch, updates needed:\n"
|
||||||
failed_revision_changes,
|
f"{fail_string}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store repo hooks project information.
|
# Store repo hooks project information.
|
||||||
@@ -1676,12 +1690,12 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
try:
|
try:
|
||||||
repo_hooks_projects = self._projects[repo_hooks_project]
|
repo_hooks_projects = self._projects[repo_hooks_project]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"project %s not found for repo-hooks" % (repo_hooks_project)
|
"project %s not found for repo-hooks" % (repo_hooks_project)
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(repo_hooks_projects) != 1:
|
if len(repo_hooks_projects) != 1:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"internal error parsing repo-hooks in %s"
|
"internal error parsing repo-hooks in %s"
|
||||||
% (self.manifestFile)
|
% (self.manifestFile)
|
||||||
)
|
)
|
||||||
@@ -1693,7 +1707,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
name = None
|
name = None
|
||||||
m_url = m.GetRemote().url
|
m_url = m.GetRemote().url
|
||||||
if m_url.endswith("/.git"):
|
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:
|
if self._default and self._default.remote:
|
||||||
url = self._default.remote.resolvedFetchUrl
|
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)
|
d.sync_j = XmlInt(node, "sync-j", None)
|
||||||
if d.sync_j is not None and d.sync_j <= 0:
|
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"'
|
'%s: sync-j must be greater than 0, not "%s"'
|
||||||
% (self.manifestFile, d.sync_j)
|
% (self.manifestFile, d.sync_j)
|
||||||
)
|
)
|
||||||
|
|
||||||
d.sync_j_max = XmlInt(node, "sync-j-max", None)
|
d.sync_j_max = XmlInt(node, "sync-j-max", None)
|
||||||
if d.sync_j_max is not None and d.sync_j_max <= 0:
|
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"'
|
'%s: sync-j-max must be greater than 0, not "%s"'
|
||||||
% (self.manifestFile, d.sync_j_max)
|
% (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_c = XmlBool(node, "sync-c", False)
|
||||||
d.sync_s = XmlBool(node, "sync-s", False)
|
d.sync_s = XmlBool(node, "sync-s", False)
|
||||||
d.sync_tags = XmlBool(node, "sync-tags", True)
|
d.sync_tags = XmlBool(node, "sync-tags", True)
|
||||||
|
d.sync_smartsync = XmlBool(node, "sync-smartsync", False)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def _ParseNotice(self, node):
|
def _ParseNotice(self, node):
|
||||||
@@ -1860,20 +1875,20 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if revision:
|
if revision:
|
||||||
msg = self._CheckLocalPath(revision.split("/")[-1])
|
msg = self._CheckLocalPath(revision.split("/")[-1])
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
'<submanifest> invalid "revision": %s: %s'
|
'<submanifest> invalid "revision": %s: %s'
|
||||||
% (revision, msg)
|
% (revision, msg)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
msg = self._CheckLocalPath(name)
|
msg = self._CheckLocalPath(name)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<submanifest> invalid "name": {name}: {msg}'
|
f'<submanifest> invalid "name": {name}: {msg}'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
msg = self._CheckLocalPath(path)
|
msg = self._CheckLocalPath(path)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<submanifest> invalid "path": {path}: {msg}'
|
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")
|
name = self._reqatt(node, "name")
|
||||||
msg = self._CheckLocalPath(name, dir_ok=True)
|
msg = self._CheckLocalPath(name, dir_ok=True)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<project> invalid "name": {name}: {msg}'
|
f'<project> invalid "name": {name}: {msg}'
|
||||||
)
|
)
|
||||||
if parent:
|
if parent:
|
||||||
@@ -1918,7 +1933,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if remote is None:
|
if remote is None:
|
||||||
remote = self._default.remote
|
remote = self._default.remote
|
||||||
if remote is None:
|
if remote is None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
f"no remote for project {name} within {self.manifestFile}"
|
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:
|
if not revisionExpr:
|
||||||
revisionExpr = self._default.revisionExpr
|
revisionExpr = self._default.revisionExpr
|
||||||
if not revisionExpr:
|
if not revisionExpr:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"no revision for project %s within %s"
|
"no revision for project %s within %s"
|
||||||
% (name, self.manifestFile)
|
% (name, self.manifestFile)
|
||||||
)
|
)
|
||||||
@@ -1939,7 +1954,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
# Project.Sync_LocalHalf.
|
# Project.Sync_LocalHalf.
|
||||||
msg = self._CheckLocalPath(path, dir_ok=True, cwd_dot_ok=True)
|
msg = self._CheckLocalPath(path, dir_ok=True, cwd_dot_ok=True)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<project> invalid "path": {path}: {msg}'
|
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")
|
clone_depth = XmlInt(node, "clone-depth")
|
||||||
if clone_depth is not None and clone_depth <= 0:
|
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"'
|
'%s: clone-depth must be greater than 0, not "%s"'
|
||||||
% (self.manifestFile, clone_depth)
|
% (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.
|
# It is relative to the top of the repo client checkout.
|
||||||
msg = cls._CheckLocalPath(dest)
|
msg = cls._CheckLocalPath(dest)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<{element}> invalid "dest": {dest}: {msg}'
|
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
|
src, dir_ok=is_linkfile, cwd_dot_ok=is_linkfile
|
||||||
)
|
)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<{element}> invalid "src": {src}: {msg}'
|
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")
|
value = self._reqatt(node, "value")
|
||||||
try:
|
try:
|
||||||
keep = self._reqatt(node, "keep").lower()
|
keep = self._reqatt(node, "keep").lower()
|
||||||
except ManifestParseError:
|
except error.ManifestParseError:
|
||||||
keep = "true"
|
keep = "true"
|
||||||
if keep != "true" and keep != "false":
|
if keep != "true" and keep != "false":
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
'optional "keep" attribute must be ' '"true" or "false"'
|
'optional "keep" attribute must be ' '"true" or "false"'
|
||||||
)
|
)
|
||||||
element.AddAnnotation(name, value, keep)
|
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)
|
v = self._remotes.get(name)
|
||||||
if not v:
|
if not v:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
f"remote {name} not defined in {self.manifestFile}"
|
f"remote {name} not defined in {self.manifestFile}"
|
||||||
)
|
)
|
||||||
return v
|
return v
|
||||||
@@ -2310,7 +2325,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
"""
|
"""
|
||||||
v = node.getAttribute(attname)
|
v = node.getAttribute(attname)
|
||||||
if not v:
|
if not v:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"no %s in <%s> within %s"
|
"no %s in <%s> within %s"
|
||||||
% (attname, node.nodeName, self.manifestFile)
|
% (attname, node.nodeName, self.manifestFile)
|
||||||
)
|
)
|
||||||
@@ -2348,7 +2363,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
try:
|
try:
|
||||||
fromRevId = fromProj.GetCommitRevisionId()
|
fromRevId = fromProj.GetCommitRevisionId()
|
||||||
toRevId = toProj.GetCommitRevisionId()
|
toRevId = toProj.GetCommitRevisionId()
|
||||||
except ManifestInvalidRevisionError:
|
except error.ManifestInvalidRevisionError:
|
||||||
diff["unreachable"].append((fromProj, toProj))
|
diff["unreachable"].append((fromProj, toProj))
|
||||||
else:
|
else:
|
||||||
if fromRevId != toRevId:
|
if fromRevId != toRevId:
|
||||||
|
|||||||
+62
-80
@@ -12,32 +12,14 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from ctypes import addressof
|
import ctypes
|
||||||
from ctypes import byref
|
from ctypes import wintypes
|
||||||
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 errno
|
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
|
# Win32 error codes
|
||||||
ERROR_SUCCESS = 0
|
ERROR_SUCCESS = 0
|
||||||
@@ -46,11 +28,11 @@ ERROR_PRIVILEGE_NOT_HELD = 1314
|
|||||||
|
|
||||||
# Win32 API entry points
|
# Win32 API entry points
|
||||||
CreateSymbolicLinkW = kernel32.CreateSymbolicLinkW
|
CreateSymbolicLinkW = kernel32.CreateSymbolicLinkW
|
||||||
CreateSymbolicLinkW.restype = BOOLEAN
|
CreateSymbolicLinkW.restype = wintypes.BOOLEAN
|
||||||
CreateSymbolicLinkW.argtypes = (
|
CreateSymbolicLinkW.argtypes = (
|
||||||
LPCWSTR, # lpSymlinkFileName In
|
wintypes.LPCWSTR, # lpSymlinkFileName In
|
||||||
LPCWSTR, # lpTargetFileName In
|
wintypes.LPCWSTR, # lpTargetFileName In
|
||||||
DWORD, # dwFlags In
|
wintypes.DWORD, # dwFlags In
|
||||||
)
|
)
|
||||||
|
|
||||||
# Symbolic link creation flags
|
# Symbolic link creation flags
|
||||||
@@ -61,44 +43,44 @@ SYMBOLIC_LINK_FLAG_DIRECTORY = 0x01
|
|||||||
SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE = 0x02
|
SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE = 0x02
|
||||||
|
|
||||||
GetFileAttributesW = kernel32.GetFileAttributesW
|
GetFileAttributesW = kernel32.GetFileAttributesW
|
||||||
GetFileAttributesW.restype = DWORD
|
GetFileAttributesW.restype = wintypes.DWORD
|
||||||
GetFileAttributesW.argtypes = (LPCWSTR,) # lpFileName In
|
GetFileAttributesW.argtypes = (wintypes.LPCWSTR,) # lpFileName In
|
||||||
|
|
||||||
INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF
|
INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF
|
||||||
FILE_ATTRIBUTE_REPARSE_POINT = 0x00400
|
FILE_ATTRIBUTE_REPARSE_POINT = 0x00400
|
||||||
|
|
||||||
CreateFileW = kernel32.CreateFileW
|
CreateFileW = kernel32.CreateFileW
|
||||||
CreateFileW.restype = HANDLE
|
CreateFileW.restype = wintypes.HANDLE
|
||||||
CreateFileW.argtypes = (
|
CreateFileW.argtypes = (
|
||||||
LPCWSTR, # lpFileName In
|
wintypes.LPCWSTR, # lpFileName In
|
||||||
DWORD, # dwDesiredAccess In
|
wintypes.DWORD, # dwDesiredAccess In
|
||||||
DWORD, # dwShareMode In
|
wintypes.DWORD, # dwShareMode In
|
||||||
LPVOID, # lpSecurityAttributes In_opt
|
wintypes.LPVOID, # lpSecurityAttributes In_opt
|
||||||
DWORD, # dwCreationDisposition In
|
wintypes.DWORD, # dwCreationDisposition In
|
||||||
DWORD, # dwFlagsAndAttributes In
|
wintypes.DWORD, # dwFlagsAndAttributes In
|
||||||
HANDLE, # hTemplateFile In_opt
|
wintypes.HANDLE, # hTemplateFile In_opt
|
||||||
)
|
)
|
||||||
|
|
||||||
CloseHandle = kernel32.CloseHandle
|
CloseHandle = kernel32.CloseHandle
|
||||||
CloseHandle.restype = BOOL
|
CloseHandle.restype = wintypes.BOOL
|
||||||
CloseHandle.argtypes = (HANDLE,) # hObject In
|
CloseHandle.argtypes = (wintypes.HANDLE,) # hObject In
|
||||||
|
|
||||||
INVALID_HANDLE_VALUE = HANDLE(-1).value
|
INVALID_HANDLE_VALUE = wintypes.HANDLE(-1).value
|
||||||
OPEN_EXISTING = 3
|
OPEN_EXISTING = 3
|
||||||
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
|
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
|
||||||
FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000
|
FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000
|
||||||
|
|
||||||
DeviceIoControl = kernel32.DeviceIoControl
|
DeviceIoControl = kernel32.DeviceIoControl
|
||||||
DeviceIoControl.restype = BOOL
|
DeviceIoControl.restype = wintypes.BOOL
|
||||||
DeviceIoControl.argtypes = (
|
DeviceIoControl.argtypes = (
|
||||||
HANDLE, # hDevice In
|
wintypes.HANDLE, # hDevice In
|
||||||
DWORD, # dwIoControlCode In
|
wintypes.DWORD, # dwIoControlCode In
|
||||||
LPVOID, # lpInBuffer In_opt
|
wintypes.LPVOID, # lpInBuffer In_opt
|
||||||
DWORD, # nInBufferSize In
|
wintypes.DWORD, # nInBufferSize In
|
||||||
LPVOID, # lpOutBuffer Out_opt
|
wintypes.LPVOID, # lpOutBuffer Out_opt
|
||||||
DWORD, # nOutBufferSize In
|
wintypes.DWORD, # nOutBufferSize In
|
||||||
LPDWORD, # lpBytesReturned Out_opt
|
wintypes.LPDWORD, # lpBytesReturned Out_opt
|
||||||
LPVOID, # lpOverlapped Inout_opt
|
wintypes.LPVOID, # lpOverlapped Inout_opt
|
||||||
)
|
)
|
||||||
|
|
||||||
# Device I/O control flags and options
|
# Device I/O control flags and options
|
||||||
@@ -108,45 +90,45 @@ IO_REPARSE_TAG_SYMLINK = 0xA000000C
|
|||||||
MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 0x4000
|
MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 0x4000
|
||||||
|
|
||||||
|
|
||||||
class GENERIC_REPARSE_BUFFER(Structure):
|
class GENERIC_REPARSE_BUFFER(ctypes.Structure):
|
||||||
_fields_ = (("DataBuffer", UCHAR * 1),)
|
_fields_ = (("DataBuffer", UCHAR * 1),)
|
||||||
|
|
||||||
|
|
||||||
class SYMBOLIC_LINK_REPARSE_BUFFER(Structure):
|
class SYMBOLIC_LINK_REPARSE_BUFFER(ctypes.Structure):
|
||||||
_fields_ = (
|
_fields_ = (
|
||||||
("SubstituteNameOffset", USHORT),
|
("SubstituteNameOffset", wintypes.USHORT),
|
||||||
("SubstituteNameLength", USHORT),
|
("SubstituteNameLength", wintypes.USHORT),
|
||||||
("PrintNameOffset", USHORT),
|
("PrintNameOffset", wintypes.USHORT),
|
||||||
("PrintNameLength", USHORT),
|
("PrintNameLength", wintypes.USHORT),
|
||||||
("Flags", ULONG),
|
("Flags", wintypes.ULONG),
|
||||||
("PathBuffer", WCHAR * 1),
|
("PathBuffer", wintypes.WCHAR * 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def PrintName(self):
|
def PrintName(self):
|
||||||
arrayt = WCHAR * (self.PrintNameLength // 2)
|
arrayt = wintypes.WCHAR * (self.PrintNameLength // 2)
|
||||||
offset = type(self).PathBuffer.offset + self.PrintNameOffset
|
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_ = (
|
_fields_ = (
|
||||||
("SubstituteNameOffset", USHORT),
|
("SubstituteNameOffset", wintypes.USHORT),
|
||||||
("SubstituteNameLength", USHORT),
|
("SubstituteNameLength", wintypes.USHORT),
|
||||||
("PrintNameOffset", USHORT),
|
("PrintNameOffset", wintypes.USHORT),
|
||||||
("PrintNameLength", USHORT),
|
("PrintNameLength", wintypes.USHORT),
|
||||||
("PathBuffer", WCHAR * 1),
|
("PathBuffer", wintypes.WCHAR * 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def PrintName(self):
|
def PrintName(self):
|
||||||
arrayt = WCHAR * (self.PrintNameLength // 2)
|
arrayt = wintypes.WCHAR * (self.PrintNameLength // 2)
|
||||||
offset = type(self).PathBuffer.offset + self.PrintNameOffset
|
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_DATA_BUFFER(ctypes.Structure):
|
||||||
class REPARSE_BUFFER(Union):
|
class REPARSE_BUFFER(ctypes.Union):
|
||||||
_fields_ = (
|
_fields_ = (
|
||||||
("SymbolicLinkReparseBuffer", SYMBOLIC_LINK_REPARSE_BUFFER),
|
("SymbolicLinkReparseBuffer", SYMBOLIC_LINK_REPARSE_BUFFER),
|
||||||
("MountPointReparseBuffer", MOUNT_POINT_REPARSE_BUFFER),
|
("MountPointReparseBuffer", MOUNT_POINT_REPARSE_BUFFER),
|
||||||
@@ -154,9 +136,9 @@ class REPARSE_DATA_BUFFER(Structure):
|
|||||||
)
|
)
|
||||||
|
|
||||||
_fields_ = (
|
_fields_ = (
|
||||||
("ReparseTag", ULONG),
|
("ReparseTag", wintypes.ULONG),
|
||||||
("ReparseDataLength", USHORT),
|
("ReparseDataLength", wintypes.USHORT),
|
||||||
("Reserved", USHORT),
|
("Reserved", wintypes.USHORT),
|
||||||
("ReparseBuffer", REPARSE_BUFFER),
|
("ReparseBuffer", REPARSE_BUFFER),
|
||||||
)
|
)
|
||||||
_anonymous_ = ("ReparseBuffer",)
|
_anonymous_ = ("ReparseBuffer",)
|
||||||
@@ -182,8 +164,8 @@ def _create_symlink(source, link_name, dwFlags):
|
|||||||
# "the unprivileged create flag is unsupported below Windows 10 (1703,
|
# "the unprivileged create flag is unsupported below Windows 10 (1703,
|
||||||
# v10.0.14972). retry without it."
|
# v10.0.14972). retry without it."
|
||||||
if not CreateSymbolicLinkW(link_name, source, dwFlags):
|
if not CreateSymbolicLinkW(link_name, source, dwFlags):
|
||||||
code = get_last_error()
|
code = ctypes.get_last_error()
|
||||||
error_desc = FormatError(code).strip()
|
error_desc = ctypes.FormatError(code).strip()
|
||||||
if code == ERROR_PRIVILEGE_NOT_HELD:
|
if code == ERROR_PRIVILEGE_NOT_HELD:
|
||||||
raise OSError(errno.EPERM, error_desc, link_name)
|
raise OSError(errno.EPERM, error_desc, link_name)
|
||||||
_raise_winerror(code, f'Error creating symbolic link "{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:
|
if reparse_point_handle == INVALID_HANDLE_VALUE:
|
||||||
_raise_winerror(
|
_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)
|
target_buffer = ctypes.c_buffer(MAXIMUM_REPARSE_DATA_BUFFER_SIZE)
|
||||||
n_bytes_returned = DWORD()
|
n_bytes_returned = wintypes.DWORD()
|
||||||
io_result = DeviceIoControl(
|
io_result = DeviceIoControl(
|
||||||
reparse_point_handle,
|
reparse_point_handle,
|
||||||
FSCTL_GET_REPARSE_POINT,
|
FSCTL_GET_REPARSE_POINT,
|
||||||
@@ -219,13 +201,13 @@ def readlink(path):
|
|||||||
0,
|
0,
|
||||||
target_buffer,
|
target_buffer,
|
||||||
len(target_buffer),
|
len(target_buffer),
|
||||||
byref(n_bytes_returned),
|
ctypes.byref(n_bytes_returned),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
CloseHandle(reparse_point_handle)
|
CloseHandle(reparse_point_handle)
|
||||||
if not io_result:
|
if not io_result:
|
||||||
_raise_winerror(
|
_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)
|
rdb = REPARSE_DATA_BUFFER.from_buffer(target_buffer)
|
||||||
if rdb.ReparseTag == IO_REPARSE_TAG_SYMLINK:
|
if rdb.ReparseTag == IO_REPARSE_TAG_SYMLINK:
|
||||||
@@ -239,6 +221,6 @@ def readlink(path):
|
|||||||
|
|
||||||
|
|
||||||
def _raise_winerror(code, error_desc):
|
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}"
|
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:
|
except ImportError:
|
||||||
import dummy_threading as _threading
|
import dummy_threading as _threading
|
||||||
|
|
||||||
from repo_trace import IsTraceToStderr
|
import repo_trace
|
||||||
|
|
||||||
|
|
||||||
# Capture the original stderr stream. We use this exclusively for progress
|
# Capture the original stderr stream. We use this exclusively for progress
|
||||||
@@ -166,7 +166,7 @@ class Progress:
|
|||||||
msg = self._last_msg
|
msg = self._last_msg
|
||||||
self._last_msg = msg
|
self._last_msg = msg
|
||||||
|
|
||||||
if not _TTY or IsTraceToStderr() or self._quiet:
|
if not _TTY or repo_trace.IsTraceToStderr() or self._quiet:
|
||||||
return
|
return
|
||||||
|
|
||||||
elapsed_sec = time.time() - self._start
|
elapsed_sec = time.time() - self._start
|
||||||
@@ -211,7 +211,7 @@ class Progress:
|
|||||||
|
|
||||||
The progress bar is then redrawn on the next line.
|
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
|
return
|
||||||
|
|
||||||
# Erase the current line, print the message with a newline,
|
# Erase the current line, print the message with a newline,
|
||||||
@@ -227,7 +227,7 @@ class Progress:
|
|||||||
self._ended = True
|
self._ended = True
|
||||||
|
|
||||||
self._update_event.set()
|
self._update_event.set()
|
||||||
if not _TTY or IsTraceToStderr() or self._quiet:
|
if not _TTY or repo_trace.IsTraceToStderr() or self._quiet:
|
||||||
return
|
return
|
||||||
|
|
||||||
duration = duration_str(time.time() - self._start)
|
duration = duration_str(time.time() - self._start)
|
||||||
|
|||||||
+1301
-424
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]
|
[tool.pytest.ini_options]
|
||||||
markers = """
|
markers = """
|
||||||
skip_cq: Skip tests in the CQ. Should be rarely used!
|
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
|
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.
|
# Load repo local modules.
|
||||||
sys.path.insert(0, str(TOPDIR))
|
sys.path.insert(0, str(TOPDIR))
|
||||||
from git_command import RepoSourceVersion
|
import git_command
|
||||||
import subcmds
|
import subcmds
|
||||||
|
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ def main(argv: List[str]) -> int:
|
|||||||
del subcmds.all_commands["branch"]
|
del subcmds.all_commands["branch"]
|
||||||
(MANDIR / "repo-branch.1").write_text(".so man1/repo-branches.1")
|
(MANDIR / "repo-branch.1").write_text(".so man1/repo-branches.1")
|
||||||
|
|
||||||
version = RepoSourceVersion()
|
version = git_command.RepoSourceVersion()
|
||||||
cmdlist = [
|
cmdlist = [
|
||||||
[
|
[
|
||||||
*help2man,
|
*help2man,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import platform
|
|||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
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
|
# 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"
|
BUG_URL = "https://issues.gerritcodereview.com/issues/new?component=1370071"
|
||||||
|
|
||||||
# increment this whenever we make important changes to this script
|
# 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
|
# increment this if the MAINTAINER_KEYS block is modified
|
||||||
KEYRING_VERSION = (2, 3)
|
KEYRING_VERSION = (2, 3)
|
||||||
@@ -220,6 +220,10 @@ repodir = ".repo" # name of repo's private directory
|
|||||||
S_repo = "repo" # special repo repository
|
S_repo = "repo" # special repo repository
|
||||||
S_manifests = "manifests" # special manifest repository
|
S_manifests = "manifests" # special manifest repository
|
||||||
REPO_MAIN = S_repo + "/main.py" # main script
|
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
|
import errno
|
||||||
@@ -228,6 +232,7 @@ import optparse
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
@@ -438,6 +443,20 @@ def InitParser(parser):
|
|||||||
dest="use_superproject",
|
dest="use_superproject",
|
||||||
help="disable use of manifest superprojects",
|
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(
|
group.add_option(
|
||||||
"--clone-bundle",
|
"--clone-bundle",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -631,6 +650,7 @@ def _Init(args):
|
|||||||
dst = dst_final + ".tmp"
|
dst = dst_final + ".tmp"
|
||||||
shutil.rmtree(dst, ignore_errors=True)
|
shutil.rmtree(dst, ignore_errors=True)
|
||||||
_Clone(url, dst, opt.clone_bundle, opt.quiet, opt.verbose)
|
_Clone(url, dst, opt.clone_bundle, opt.quiet, opt.verbose)
|
||||||
|
_RefetchIfRevMissing(url, dst, rev, opt.quiet, opt.verbose)
|
||||||
|
|
||||||
remote_ref, rev = check_repo_rev(
|
remote_ref, rev = check_repo_rev(
|
||||||
dst, rev, opt.repo_verify, quiet=opt.quiet
|
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)
|
_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.
|
"""Figure out what REPO_REV represents.
|
||||||
|
|
||||||
We support:
|
We support:
|
||||||
@@ -1016,6 +1072,7 @@ def resolve_repo_rev(cwd, committish):
|
|||||||
Args:
|
Args:
|
||||||
cwd: The git checkout to run in.
|
cwd: The git checkout to run in.
|
||||||
committish: The REPO_REV argument to resolve.
|
committish: The REPO_REV argument to resolve.
|
||||||
|
report_errors: Whether to print an error if it can't be resolved.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A tuple of (remote ref, commit) as makes sense for the committish.
|
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()
|
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.
|
# An explicit branch.
|
||||||
if committish.startswith("refs/heads/"):
|
if committish.startswith("refs/heads/"):
|
||||||
remote_ref = committish
|
remote_ref = committish
|
||||||
committish = committish[len("refs/heads/") :]
|
committish = committish[len("refs/heads/") :]
|
||||||
rev = resolve("refs/remotes/origin/%s" % committish)
|
rev = resolve("refs/remotes/origin/%s" % committish)
|
||||||
if rev is None:
|
if rev is None:
|
||||||
print(
|
raise error(f'unknown branch "{committish}"')
|
||||||
f'repo: error: unknown branch "{committish}"',
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise CloneFailure()
|
|
||||||
return (remote_ref, rev)
|
return (remote_ref, rev)
|
||||||
|
|
||||||
# An explicit tag.
|
# An explicit tag.
|
||||||
@@ -1053,11 +1111,7 @@ def resolve_repo_rev(cwd, committish):
|
|||||||
committish = committish[len("refs/tags/") :]
|
committish = committish[len("refs/tags/") :]
|
||||||
rev = resolve(remote_ref)
|
rev = resolve(remote_ref)
|
||||||
if rev is None:
|
if rev is None:
|
||||||
print(
|
raise error(f'unknown tag "{committish}"')
|
||||||
f'repo: error: unknown tag "{committish}"',
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise CloneFailure()
|
|
||||||
return (remote_ref, rev)
|
return (remote_ref, rev)
|
||||||
|
|
||||||
# See if it's a short branch name.
|
# See if it's a short branch name.
|
||||||
@@ -1076,11 +1130,7 @@ def resolve_repo_rev(cwd, committish):
|
|||||||
return (rev, rev)
|
return (rev, rev)
|
||||||
|
|
||||||
# Give up!
|
# Give up!
|
||||||
print(
|
raise error(f'unable to resolve "{committish}"')
|
||||||
f'repo: error: unable to resolve "{committish}"',
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise CloneFailure()
|
|
||||||
|
|
||||||
|
|
||||||
def verify_rev(cwd, remote_ref, rev, quiet):
|
def verify_rev(cwd, remote_ref, rev, quiet):
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from color import Coloring
|
from color import Coloring
|
||||||
from error import RepoExitError
|
import error
|
||||||
|
|
||||||
|
|
||||||
SEPARATOR = "=" * 80
|
SEPARATOR = "=" * 80
|
||||||
@@ -71,7 +71,7 @@ class RepoLogger(logging.Logger):
|
|||||||
handler.setFormatter(_LogColoringFormatter(config))
|
handler.setFormatter(_LogColoringFormatter(config))
|
||||||
self.addHandler(handler)
|
self.addHandler(handler)
|
||||||
|
|
||||||
def log_aggregated_errors(self, err: RepoExitError):
|
def log_aggregated_errors(self, err: error.RepoExitError) -> None:
|
||||||
"""Print all aggregated logs."""
|
"""Print all aggregated logs."""
|
||||||
self.error(SEPARATOR)
|
self.error(SEPARATOR)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -57,10 +57,10 @@ def SetTraceToStderr():
|
|||||||
_TRACE_TO_STDERR = True
|
_TRACE_TO_STDERR = True
|
||||||
|
|
||||||
|
|
||||||
def SetTrace():
|
def SetTrace(value: bool = True) -> None:
|
||||||
"""Enables tracing."""
|
"""Enables by default, or disables tracing."""
|
||||||
global _TRACE
|
global _TRACE
|
||||||
_TRACE = True
|
_TRACE = value
|
||||||
|
|
||||||
|
|
||||||
def _SetTraceFile(quiet):
|
def _SetTraceFile(quiet):
|
||||||
|
|||||||
+8
-3
@@ -19,9 +19,14 @@
|
|||||||
# Hard versions are when we refuse to work the tool. Users will be shown an
|
# Hard versions are when we refuse to work the tool. Users will be shown an
|
||||||
# error message before we abort entirely.
|
# error message before we abort entirely.
|
||||||
|
|
||||||
# When deciding whether to upgrade a version requirement, check out the distro
|
# When deciding whether to upgrade a version requirement, check out what
|
||||||
# lists to see who will be impacted:
|
# versions major distros (e.g. Ubuntu LTS & Debian stable) ship and when they
|
||||||
# https://gerrit.googlesource.com/git-repo/+/HEAD/docs/release-process.md#Project-References
|
# go EOL to see who will be impacted:
|
||||||
|
# https://wiki.ubuntu.com/Releases
|
||||||
|
# https://wiki.debian.org/DebianReleases#Versions_of_Debian_stable
|
||||||
|
# https://repology.org/project/python/versions
|
||||||
|
# https://repology.org/project/git/versions
|
||||||
|
# https://repology.org/project/openssh/versions
|
||||||
|
|
||||||
{
|
{
|
||||||
# The repo launcher itself. This allows us to force people to upgrade as some
|
# The repo launcher itself. This allows us to force people to upgrade as some
|
||||||
|
|||||||
@@ -13,6 +13,18 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# 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."""
|
"""Wrapper to run linters and pytest with the right settings."""
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
@@ -42,10 +54,16 @@ def is_ci() -> bool:
|
|||||||
return os.getenv("LUCI_CQ") == "yes"
|
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:
|
def run_pytest(argv: list[str]) -> int:
|
||||||
"""Returns the exit code from pytest."""
|
"""Returns the exit code from pytest."""
|
||||||
if is_ci():
|
argv = ci_pytest_args() + argv
|
||||||
argv = ["-m", "not skip_cq"] + argv
|
|
||||||
|
|
||||||
log_cmd("pytest", argv)
|
log_cmd("pytest", argv)
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
@@ -57,8 +75,7 @@ def run_pytest(argv: list[str]) -> int:
|
|||||||
|
|
||||||
def run_pytest_py38(argv: list[str]) -> int:
|
def run_pytest_py38(argv: list[str]) -> int:
|
||||||
"""Returns the exit code from pytest under Python 3.8."""
|
"""Returns the exit code from pytest under Python 3.8."""
|
||||||
if is_ci():
|
argv = ci_pytest_args() + argv
|
||||||
argv = ["-m", "not skip_cq"] + argv
|
|
||||||
|
|
||||||
log_cmd("[vpython 3.8] pytest", argv)
|
log_cmd("[vpython 3.8] pytest", argv)
|
||||||
try:
|
try:
|
||||||
@@ -135,7 +152,7 @@ def run_isort():
|
|||||||
cwd=ROOT_DIR,
|
cwd=ROOT_DIR,
|
||||||
)
|
)
|
||||||
|
|
||||||
argv = ["--check", ROOT_DIR]
|
argv = ["--check", "--diff", ROOT_DIR]
|
||||||
log_cmd("isort", argv)
|
log_cmd("isort", argv)
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
[sys.executable, "-m", "isort"] + argv,
|
[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 tempfile
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from git_command import git
|
import git_command
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from repo_trace import Trace
|
import repo_trace
|
||||||
|
|
||||||
|
|
||||||
PROXY_PATH = os.path.join(os.path.dirname(__file__), "git_ssh")
|
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
|
# is the same as 'myhost.com' where "User git" is setup in the user's
|
||||||
# ~/.ssh/config file.
|
# ~/.ssh/config file.
|
||||||
check_command = command_base + ["-O", "check"]
|
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:
|
try:
|
||||||
check_process = subprocess.Popen(
|
check_process = subprocess.Popen(
|
||||||
check_command,
|
check_command,
|
||||||
@@ -250,7 +252,7 @@ class ProxyManager:
|
|||||||
)
|
)
|
||||||
p = None
|
p = None
|
||||||
try:
|
try:
|
||||||
with Trace("Call to ssh: %s", " ".join(command)):
|
with repo_trace.Trace("Call to ssh: %s", " ".join(command)):
|
||||||
p = subprocess.Popen(command)
|
p = subprocess.Popen(command)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._master_broken.value = True
|
self._master_broken.value = True
|
||||||
@@ -357,7 +359,7 @@ def _get_git_protocol_version() -> str:
|
|||||||
if e.returncode == 1:
|
if e.returncode == 1:
|
||||||
# Exit code 1 means that the git config key was not found.
|
# Exit code 1 means that the git config key was not found.
|
||||||
# Try to imitate the defaults that git would have used.
|
# 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):
|
if git_version >= (2, 26, 0):
|
||||||
# Since git version 2.26, protocol v2 is the default.
|
# Since git version 2.26, protocol v2 is the default.
|
||||||
return "2"
|
return "2"
|
||||||
|
|||||||
+7
-9
@@ -18,17 +18,16 @@ import itertools
|
|||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from error import RepoError
|
import error
|
||||||
from error import RepoExitError
|
import git_command
|
||||||
from git_command import git
|
|
||||||
from progress import Progress
|
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."""
|
"""Exit error when abandon command fails."""
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ It is equivalent to "git branch -D <branchname>".
|
|||||||
if not opt.all:
|
if not opt.all:
|
||||||
branches = args[0].split()
|
branches = args[0].split()
|
||||||
invalid_branches = [
|
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:
|
if invalid_branches:
|
||||||
@@ -84,7 +83,7 @@ It is equivalent to "git branch -D <branchname>".
|
|||||||
status = None
|
status = None
|
||||||
try:
|
try:
|
||||||
status = project.AbandonBranch(name)
|
status = project.AbandonBranch(name)
|
||||||
except RepoError as e:
|
except error.RepoError as e:
|
||||||
status = False
|
status = False
|
||||||
errors.append(e)
|
errors.append(e)
|
||||||
if status is not None:
|
if status is not None:
|
||||||
@@ -94,7 +93,6 @@ It is equivalent to "git branch -D <branchname>".
|
|||||||
|
|
||||||
def Execute(self, opt, args):
|
def Execute(self, opt, args):
|
||||||
nb = args[0].split()
|
nb = args[0].split()
|
||||||
self.TryOverrideManifestWithSmartSync()
|
|
||||||
err = collections.defaultdict(list)
|
err = collections.defaultdict(list)
|
||||||
success = collections.defaultdict(list)
|
success = collections.defaultdict(list)
|
||||||
aggregate_errors = []
|
aggregate_errors = []
|
||||||
|
|||||||
+9
-10
@@ -17,13 +17,12 @@ from typing import NamedTuple
|
|||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from error import GitError
|
import error
|
||||||
from error import RepoExitError
|
|
||||||
from progress import Progress
|
from progress import Progress
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class CheckoutBranchResult(NamedTuple):
|
class CheckoutBranchResult(NamedTuple):
|
||||||
@@ -33,11 +32,11 @@ class CheckoutBranchResult(NamedTuple):
|
|||||||
error: Exception
|
error: Exception
|
||||||
|
|
||||||
|
|
||||||
class CheckoutCommandError(RepoExitError):
|
class CheckoutCommandError(error.RepoExitError):
|
||||||
"""Exception thrown when checkout command fails."""
|
"""Exception thrown when checkout command fails."""
|
||||||
|
|
||||||
|
|
||||||
class MissingBranchError(RepoExitError):
|
class MissingBranchError(error.RepoExitError):
|
||||||
"""Exception thrown when no project has specified branch."""
|
"""Exception thrown when no project has specified branch."""
|
||||||
|
|
||||||
|
|
||||||
@@ -64,14 +63,14 @@ The command is equivalent to:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _ExecuteOne(cls, nb, project_idx):
|
def _ExecuteOne(cls, nb, project_idx):
|
||||||
"""Checkout one project."""
|
"""Checkout one project."""
|
||||||
error = None
|
err = None
|
||||||
result = None
|
result = None
|
||||||
project = cls.get_parallel_context()["projects"][project_idx]
|
project = cls.get_parallel_context()["projects"][project_idx]
|
||||||
try:
|
try:
|
||||||
result = project.CheckoutBranch(nb)
|
result = project.CheckoutBranch(nb)
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
error = e
|
err = e
|
||||||
return CheckoutBranchResult(result, project_idx, error)
|
return CheckoutBranchResult(result, project_idx, err)
|
||||||
|
|
||||||
def Execute(self, opt, args):
|
def Execute(self, opt, args):
|
||||||
nb = args[0]
|
nb = args[0]
|
||||||
|
|||||||
+53
-38
@@ -14,15 +14,16 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from error import GitError
|
import error
|
||||||
from git_command import GitCommand
|
import git_command
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
CHANGE_ID_RE = re.compile(r"^\s*Change-Id: I([0-9a-f]{40})\s*$")
|
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):
|
class CherryPick(Command):
|
||||||
@@ -43,38 +44,10 @@ change id will be added.
|
|||||||
|
|
||||||
def Execute(self, opt, args):
|
def Execute(self, opt, args):
|
||||||
reference = args[0]
|
reference = args[0]
|
||||||
|
sha1, commit = self._ResolveReference(reference)
|
||||||
|
old_msg = self._StripHeader(commit)
|
||||||
|
|
||||||
p = GitCommand(
|
p = git_command.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(
|
|
||||||
None,
|
None,
|
||||||
["cherry-pick", sha1],
|
["cherry-pick", sha1],
|
||||||
capture_stdout=True,
|
capture_stdout=True,
|
||||||
@@ -84,7 +57,7 @@ change id will be added.
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
p.Wait()
|
p.Wait()
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"NOTE: When committing (please see above) and editing the "
|
"NOTE: When committing (please see above) and editing the "
|
||||||
@@ -103,7 +76,7 @@ change id will be added.
|
|||||||
# the commit message.
|
# the commit message.
|
||||||
new_msg = self._Reformat(old_msg, sha1)
|
new_msg = self._Reformat(old_msg, sha1)
|
||||||
|
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
["commit", "--amend", "-F", "-"],
|
["commit", "--amend", "-F", "-"],
|
||||||
input=new_msg,
|
input=new_msg,
|
||||||
@@ -113,10 +86,52 @@ change id will be added.
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
p.Wait()
|
p.Wait()
|
||||||
except GitError:
|
except error.GitError:
|
||||||
logger.error("error: Failed to update commit message")
|
logger.error("error: Failed to update commit message")
|
||||||
raise
|
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):
|
def _IsChangeId(self, line):
|
||||||
return CHANGE_ID_RE.match(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
|
import sys
|
||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from error import GitError
|
import error
|
||||||
from error import NoSuchProjectError
|
import repo_logging
|
||||||
from error import RepoExitError
|
|
||||||
from repo_logging import RepoLogger
|
|
||||||
|
|
||||||
|
|
||||||
CHANGE_RE = re.compile(r"^([1-9][0-9]*)(?:[/\.-]([1-9][0-9]*))?$")
|
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."""
|
"""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.
|
# If the cwd is one of the projects, assume they want that.
|
||||||
try:
|
try:
|
||||||
project = self.GetProjects(".")[0]
|
project = self.GetProjects(".")[0]
|
||||||
except NoSuchProjectError:
|
except error.NoSuchProjectError:
|
||||||
project = None
|
project = None
|
||||||
if project not in projects:
|
if project not in projects:
|
||||||
logger.error(
|
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.RelPath(local=opt.this_manifest_only),
|
||||||
project.revisionExpr,
|
project.revisionExpr,
|
||||||
)
|
)
|
||||||
raise NoSuchProjectError()
|
raise error.NoSuchProjectError()
|
||||||
else:
|
else:
|
||||||
project = projects[0]
|
project = projects[0]
|
||||||
print("Defaulting to cwd project", project.name)
|
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:
|
try:
|
||||||
self._ExecuteHelper(opt, args)
|
self._ExecuteHelper(opt, args)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if isinstance(e, RepoExitError):
|
if isinstance(e, error.RepoExitError):
|
||||||
raise e
|
raise e
|
||||||
raise DownloadCommandError(aggregate_errors=[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:
|
elif opt.revert:
|
||||||
project._Revert(dl.commit)
|
project._Revert(dl.commit)
|
||||||
elif opt.ffonly:
|
elif opt.ffonly:
|
||||||
project._FastForward(dl.commit, ffonly=True)
|
project._FastForward(dl.commit)
|
||||||
else:
|
else:
|
||||||
if opt.branch:
|
if opt.branch:
|
||||||
project.StartBranch(opt.branch, revision=dl.commit)
|
project.StartBranch(opt.branch, revision=dl.commit)
|
||||||
else:
|
else:
|
||||||
project._Checkout(dl.commit)
|
project._Checkout(dl.commit)
|
||||||
|
|
||||||
except GitError:
|
except error.GitError:
|
||||||
logger.error(
|
logger.error(
|
||||||
"[%s] Could not complete the %s of %s",
|
"[%s] Could not complete the %s of %s",
|
||||||
project.name,
|
project.name,
|
||||||
|
|||||||
+8
-6
@@ -25,10 +25,10 @@ from color import Coloring
|
|||||||
from command import Command
|
from command import Command
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
_CAN_COLOR = [
|
_CAN_COLOR = [
|
||||||
"branch",
|
"branch",
|
||||||
"diff",
|
"diff",
|
||||||
@@ -243,13 +243,15 @@ without iterating through the remaining projects.
|
|||||||
|
|
||||||
mirror = self.manifest.IsMirror
|
mirror = self.manifest.IsMirror
|
||||||
|
|
||||||
self.TryOverrideManifestWithSmartSync()
|
|
||||||
|
|
||||||
if opt.regex:
|
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:
|
elif opt.inverse_regex:
|
||||||
projects = self.FindProjects(
|
projects = self.FindProjects(
|
||||||
args, inverse=True, all_manifests=all_trees
|
args, inverse=True, groups=opt.groups, all_manifests=all_trees
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
projects = self.GetProjects(
|
projects = self.GetProjects(
|
||||||
|
|||||||
+34
-16
@@ -16,8 +16,7 @@ import os
|
|||||||
from typing import List, Set
|
from typing import List, Set
|
||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from git_command import git_require
|
import git_command
|
||||||
from git_command import GitCommand
|
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from progress import Progress
|
from progress import Progress
|
||||||
from project import Project
|
from project import Project
|
||||||
@@ -28,6 +27,14 @@ class Gc(Command):
|
|||||||
helpSummary = "Cleaning up internal repo and Git state."
|
helpSummary = "Cleaning up internal repo and Git state."
|
||||||
helpUsage = """
|
helpUsage = """
|
||||||
%prog
|
%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):
|
def _Options(self, p):
|
||||||
@@ -53,6 +60,12 @@ class Gc(Command):
|
|||||||
help="repack all projects that use partial clone with "
|
help="repack all projects that use partial clone with "
|
||||||
"filter=blob:none",
|
"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(
|
def _find_git_to_delete(
|
||||||
self, to_keep: Set[str], start_dir: str
|
self, to_keep: Set[str], start_dir: str
|
||||||
@@ -186,13 +199,13 @@ class Gc(Command):
|
|||||||
|
|
||||||
# Prepare workspace for repacking - remove all unreachable refs and
|
# Prepare workspace for repacking - remove all unreachable refs and
|
||||||
# their objects.
|
# their objects.
|
||||||
GitCommand(
|
git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
["reflog", "expire", "--expire-unreachable=all"],
|
["reflog", "expire", "--expire-unreachable=all"],
|
||||||
verify_command=True,
|
verify_command=True,
|
||||||
).Wait()
|
).Wait()
|
||||||
pm.update(msg=f"{project.name} | gc", inc=0)
|
pm.update(msg=f"{project.name} | gc", inc=0)
|
||||||
GitCommand(
|
git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
["gc"],
|
["gc"],
|
||||||
verify_command=True,
|
verify_command=True,
|
||||||
@@ -200,7 +213,7 @@ class Gc(Command):
|
|||||||
|
|
||||||
# Get all objects that are reachable from the remote, and pack them.
|
# Get all objects that are reachable from the remote, and pack them.
|
||||||
pm.update(msg=f"{project.name} | generating list of objects", inc=0)
|
pm.update(msg=f"{project.name} | generating list of objects", inc=0)
|
||||||
remote_objects_cmd = GitCommand(
|
remote_objects_cmd = git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
[
|
[
|
||||||
"rev-list",
|
"rev-list",
|
||||||
@@ -215,7 +228,7 @@ class Gc(Command):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get all local objects and pack them.
|
# Get all local objects and pack them.
|
||||||
local_head_objects_cmd = GitCommand(
|
local_head_objects_cmd = git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
[
|
[
|
||||||
"rev-list",
|
"rev-list",
|
||||||
@@ -226,7 +239,7 @@ class Gc(Command):
|
|||||||
capture_stdout=True,
|
capture_stdout=True,
|
||||||
verify_command=True,
|
verify_command=True,
|
||||||
)
|
)
|
||||||
local_objects_cmd = GitCommand(
|
local_objects_cmd = git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
[
|
[
|
||||||
"rev-list",
|
"rev-list",
|
||||||
@@ -246,7 +259,7 @@ class Gc(Command):
|
|||||||
remote_objects_cmd.Wait()
|
remote_objects_cmd.Wait()
|
||||||
|
|
||||||
pm.update(msg=f"{project.name} | remote repack", inc=0)
|
pm.update(msg=f"{project.name} | remote repack", inc=0)
|
||||||
GitCommand(
|
git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
["pack-objects", os.path.join(pack_dir, "pack")],
|
["pack-objects", os.path.join(pack_dir, "pack")],
|
||||||
input=remote_objects_cmd.stdout,
|
input=remote_objects_cmd.stdout,
|
||||||
@@ -262,7 +275,7 @@ class Gc(Command):
|
|||||||
local_objects_cmd.Wait()
|
local_objects_cmd.Wait()
|
||||||
|
|
||||||
pm.update(msg=f"{project.name} | local repack", inc=0)
|
pm.update(msg=f"{project.name} | local repack", inc=0)
|
||||||
GitCommand(
|
git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
["pack-objects", os.path.join(pack_dir, "pack")],
|
["pack-objects", os.path.join(pack_dir, "pack")],
|
||||||
input=local_head_objects_cmd.stdout + local_objects_cmd.stdout,
|
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
|
args, all_manifests=not opt.this_manifest_only
|
||||||
)
|
)
|
||||||
|
|
||||||
# If the user specified projects, fetch the global list separately
|
if opt.keep_manifest_projects:
|
||||||
# to avoid deleting untargeted projects.
|
projects_to_keep = self.GetProjects(
|
||||||
if args:
|
[],
|
||||||
all_projects = 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
|
[], all_manifests=not opt.this_manifest_only
|
||||||
)
|
)
|
||||||
else:
|
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:
|
if ret != 0:
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
if opt.repack:
|
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)
|
ret = self.repack_projects(projects, opt)
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
+13
-15
@@ -19,14 +19,12 @@ from typing import NamedTuple
|
|||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from command import PagedCommand
|
from command import PagedCommand
|
||||||
from error import GitError
|
import error
|
||||||
from error import InvalidArgumentsError
|
import git_command
|
||||||
from error import SilentRepoExitError
|
import repo_logging
|
||||||
from git_command import GitCommand
|
|
||||||
from repo_logging import RepoLogger
|
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class GrepColoring(Coloring):
|
class GrepColoring(Coloring):
|
||||||
@@ -43,10 +41,10 @@ class ExecuteOneResult(NamedTuple):
|
|||||||
rc: int
|
rc: int
|
||||||
stdout: str
|
stdout: str
|
||||||
stderr: str
|
stderr: str
|
||||||
error: GitError
|
error: error.GitError
|
||||||
|
|
||||||
|
|
||||||
class GrepCommandError(SilentRepoExitError):
|
class GrepCommandError(error.SilentRepoExitError):
|
||||||
"""Grep command failure. Since Grep command
|
"""Grep command failure. Since Grep command
|
||||||
output already outputs errors ensure that
|
output already outputs errors ensure that
|
||||||
aggregate errors exit silently."""
|
aggregate errors exit silently."""
|
||||||
@@ -265,7 +263,7 @@ contain a line that matches both expressions:
|
|||||||
"""Process one project."""
|
"""Process one project."""
|
||||||
project = cls.get_parallel_context()["projects"][project_idx]
|
project = cls.get_parallel_context()["projects"][project_idx]
|
||||||
try:
|
try:
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
cmd_argv,
|
cmd_argv,
|
||||||
bare=False,
|
bare=False,
|
||||||
@@ -273,16 +271,16 @@ contain a line that matches both expressions:
|
|||||||
capture_stderr=True,
|
capture_stderr=True,
|
||||||
verify_command=True,
|
verify_command=True,
|
||||||
)
|
)
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
return ExecuteOneResult(project_idx, -1, None, str(e), e)
|
return ExecuteOneResult(project_idx, -1, None, str(e), e)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
error = None
|
err = None
|
||||||
rc = p.Wait()
|
rc = p.Wait()
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
rc = 1
|
rc = 1
|
||||||
error = e
|
err = e
|
||||||
return ExecuteOneResult(project_idx, rc, p.stdout, p.stderr, error)
|
return ExecuteOneResult(project_idx, rc, p.stdout, p.stderr, err)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ProcessResults(
|
def _ProcessResults(
|
||||||
@@ -377,7 +375,7 @@ contain a line that matches both expressions:
|
|||||||
if "--cached" in cmd_argv:
|
if "--cached" in cmd_argv:
|
||||||
msg = "fatal: cannot combine --cached and --revision"
|
msg = "fatal: cannot combine --cached and --revision"
|
||||||
logger.error(msg)
|
logger.error(msg)
|
||||||
raise InvalidArgumentsError(msg)
|
raise error.InvalidArgumentsError(msg)
|
||||||
have_rev = True
|
have_rev = True
|
||||||
cmd_argv.extend(opt.revision)
|
cmd_argv.extend(opt.revision)
|
||||||
cmd_argv.append("--")
|
cmd_argv.append("--")
|
||||||
|
|||||||
+2
-2
@@ -19,12 +19,12 @@ import textwrap
|
|||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from command import PagedCommand
|
from command import PagedCommand
|
||||||
from error import RepoExitError
|
import error
|
||||||
from subcmds import all_commands
|
from subcmds import all_commands
|
||||||
from wrapper import Wrapper
|
from wrapper import Wrapper
|
||||||
|
|
||||||
|
|
||||||
class InvalidHelpCommand(RepoExitError):
|
class InvalidHelpCommand(error.RepoExitError):
|
||||||
"""Invalid command passed into help."""
|
"""Invalid command passed into help."""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+62
-33
@@ -18,13 +18,12 @@ import io
|
|||||||
import json
|
import json
|
||||||
import optparse
|
import optparse
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict, List, NamedTuple
|
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
|
||||||
|
|
||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from command import PagedCommand
|
from command import PagedCommand
|
||||||
from git_refs import R_HEADS
|
import git_refs
|
||||||
from git_refs import R_M
|
|
||||||
|
|
||||||
|
|
||||||
class BranchInfo(NamedTuple):
|
class BranchInfo(NamedTuple):
|
||||||
@@ -147,8 +146,6 @@ class Info(PagedCommand):
|
|||||||
if not opt.this_manifest_only:
|
if not opt.this_manifest_only:
|
||||||
self.manifest = self.manifest.outer_client
|
self.manifest = self.manifest.outer_client
|
||||||
|
|
||||||
self.TryOverrideManifestWithSmartSync()
|
|
||||||
|
|
||||||
output_format = OutputFormat[opt.format.upper()]
|
output_format = OutputFormat[opt.format.upper()]
|
||||||
if output_format == OutputFormat.JSON:
|
if output_format == OutputFormat.JSON:
|
||||||
self._ExecuteJson(opt, args)
|
self._ExecuteJson(opt, args)
|
||||||
@@ -191,18 +188,26 @@ class Info(PagedCommand):
|
|||||||
"superproject_revision": srev,
|
"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
|
@classmethod
|
||||||
def _getProjectData(cls, project) -> Dict[str, Any]:
|
def _getProjectData(cls, project) -> Dict[str, Any]:
|
||||||
"""Gather project data as a dict."""
|
"""Gather project data as a dict."""
|
||||||
|
branches = project.GetBranches()
|
||||||
|
currentBranch = cls._GetCurrentBranch(branches)
|
||||||
data = {
|
data = {
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
"mount_path": project.worktree,
|
"mount_path": project.worktree,
|
||||||
"current_revision": project.GetHeadRevisionId()
|
"current_revision": project.GetHeadRevisionId()
|
||||||
or project.GetRevisionId(),
|
or project.GetRevisionId(),
|
||||||
"manifest_revision": project.revisionExpr,
|
"manifest_revision": project.revisionExpr,
|
||||||
"local_branches": list(project.GetBranches()),
|
"local_branches": list(branches),
|
||||||
}
|
}
|
||||||
currentBranch = project.CurrentBranch
|
|
||||||
if currentBranch:
|
if currentBranch:
|
||||||
data["current_branch"] = currentBranch
|
data["current_branch"] = currentBranch
|
||||||
return data
|
return data
|
||||||
@@ -287,6 +292,9 @@ class Info(PagedCommand):
|
|||||||
text = out.nofmt_printer("text")
|
text = out.nofmt_printer("text")
|
||||||
dimtext = out.printer("dimtext", attr="dim")
|
dimtext = out.printer("dimtext", attr="dim")
|
||||||
|
|
||||||
|
branches = project.GetBranches()
|
||||||
|
currentBranch = cls._GetCurrentBranch(branches)
|
||||||
|
|
||||||
heading("Project: ")
|
heading("Project: ")
|
||||||
headtext(project.name)
|
headtext(project.name)
|
||||||
out.nl()
|
out.nl()
|
||||||
@@ -299,7 +307,6 @@ class Info(PagedCommand):
|
|||||||
headtext(project.GetHeadRevisionId() or project.GetRevisionId())
|
headtext(project.GetHeadRevisionId() or project.GetRevisionId())
|
||||||
out.nl()
|
out.nl()
|
||||||
|
|
||||||
currentBranch = project.CurrentBranch
|
|
||||||
if currentBranch:
|
if currentBranch:
|
||||||
heading("Current branch: ")
|
heading("Current branch: ")
|
||||||
headtext(currentBranch)
|
headtext(currentBranch)
|
||||||
@@ -309,7 +316,7 @@ class Info(PagedCommand):
|
|||||||
headtext(project.revisionExpr)
|
headtext(project.revisionExpr)
|
||||||
out.nl()
|
out.nl()
|
||||||
|
|
||||||
localBranches = list(project.GetBranches().keys())
|
localBranches = list(branches)
|
||||||
heading("Local Branches: ")
|
heading("Local Branches: ")
|
||||||
redtext(str(len(localBranches)))
|
redtext(str(len(localBranches)))
|
||||||
if localBranches:
|
if localBranches:
|
||||||
@@ -325,29 +332,14 @@ class Info(PagedCommand):
|
|||||||
branch = project.manifest.manifestProject.config.GetBranch(
|
branch = project.manifest.manifestProject.config.GetBranch(
|
||||||
"default"
|
"default"
|
||||||
).merge
|
).merge
|
||||||
if branch.startswith(R_HEADS):
|
if branch.startswith(git_refs.R_HEADS):
|
||||||
branch = branch[len(R_HEADS) :]
|
branch = branch[len(git_refs.R_HEADS) :]
|
||||||
logTarget = R_M + branch
|
logTarget = git_refs.R_M + branch
|
||||||
|
|
||||||
bareTmp = project.bare_git._bare
|
localCommits, originCommits = cls._GetDiffCommits(
|
||||||
project.bare_git._bare = False
|
project, logTarget
|
||||||
localCommits = project.bare_git.rev_list(
|
|
||||||
"--abbrev=8",
|
|
||||||
"--abbrev-commit",
|
|
||||||
"--pretty=oneline",
|
|
||||||
logTarget + "..",
|
|
||||||
"--",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
originCommits = project.bare_git.rev_list(
|
|
||||||
"--abbrev=8",
|
|
||||||
"--abbrev-commit",
|
|
||||||
"--pretty=oneline",
|
|
||||||
".." + logTarget,
|
|
||||||
"--",
|
|
||||||
)
|
|
||||||
project.bare_git._bare = bareTmp
|
|
||||||
|
|
||||||
heading("Local Commits: ")
|
heading("Local Commits: ")
|
||||||
redtext(str(len(localCommits)))
|
redtext(str(len(localCommits)))
|
||||||
dimtext(" (on current branch)")
|
dimtext(" (on current branch)")
|
||||||
@@ -377,6 +369,35 @@ class Info(PagedCommand):
|
|||||||
|
|
||||||
return buf.getvalue()
|
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):
|
def _printDiffInfo(self, opt, args):
|
||||||
projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
|
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]
|
project = cls.get_parallel_context()["projects"][project_idx]
|
||||||
|
|
||||||
branches = []
|
branches = []
|
||||||
br = [project.GetUploadableBranch(x) for x in project.GetBranches()]
|
local_branches = project.GetBranches()
|
||||||
br = [x for x in br if x]
|
current_branch = cls._GetCurrentBranch(local_branches)
|
||||||
if opt.current_branch:
|
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:
|
for b in br:
|
||||||
branches.append(
|
branches.append(
|
||||||
@@ -418,7 +447,7 @@ class Info(PagedCommand):
|
|||||||
name=b.name,
|
name=b.name,
|
||||||
commits=b.commits,
|
commits=b.commits,
|
||||||
date=b.date,
|
date=b.date,
|
||||||
is_current=b.name == project.CurrentBranch,
|
is_current=b.name == current_branch,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return branches
|
return branches
|
||||||
|
|||||||
+13
-10
@@ -18,21 +18,21 @@ import sys
|
|||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import InteractiveCommand
|
from command import InteractiveCommand
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from error import RepoUnhandledExceptionError
|
import error
|
||||||
from error import UpdateManifestError
|
import git_command
|
||||||
from git_command import git_require
|
import repo_logging
|
||||||
from repo_logging import RepoLogger
|
|
||||||
from wrapper import Wrapper
|
from wrapper import Wrapper
|
||||||
from wrapper import WrapperDir
|
from wrapper import WrapperDir
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
_REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW")
|
_REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW")
|
||||||
|
|
||||||
|
|
||||||
class Init(InteractiveCommand, MirrorSafeCommand):
|
class Init(InteractiveCommand, MirrorSafeCommand):
|
||||||
COMMON = True
|
COMMON = True
|
||||||
|
RESPECT_SMART_SYNC_OVERRIDE = False
|
||||||
MULTI_MANIFEST_SUPPORT = True
|
MULTI_MANIFEST_SUPPORT = True
|
||||||
helpSummary = "Initialize a repo client checkout in the current directory"
|
helpSummary = "Initialize a repo client checkout in the current directory"
|
||||||
helpUsage = """
|
helpUsage = """
|
||||||
@@ -163,6 +163,7 @@ to update the working directory files.
|
|||||||
clone_bundle=opt.clone_bundle,
|
clone_bundle=opt.clone_bundle,
|
||||||
git_lfs=opt.git_lfs,
|
git_lfs=opt.git_lfs,
|
||||||
use_superproject=opt.use_superproject,
|
use_superproject=opt.use_superproject,
|
||||||
|
smart_sync=opt.smart_sync,
|
||||||
verbose=opt.verbose,
|
verbose=opt.verbose,
|
||||||
current_branch_only=opt.current_branch_only,
|
current_branch_only=opt.current_branch_only,
|
||||||
tags=opt.tags,
|
tags=opt.tags,
|
||||||
@@ -172,7 +173,7 @@ to update the working directory files.
|
|||||||
use_local_gitdirs=opt.use_local_gitdirs,
|
use_local_gitdirs=opt.use_local_gitdirs,
|
||||||
):
|
):
|
||||||
manifest_name = opt.manifest_name
|
manifest_name = opt.manifest_name
|
||||||
raise UpdateManifestError(
|
raise error.UpdateManifestError(
|
||||||
f"Unable to sync manifest {manifest_name}"
|
f"Unable to sync manifest {manifest_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -346,9 +347,9 @@ to update the working directory files.
|
|||||||
wrapper = Wrapper()
|
wrapper = Wrapper()
|
||||||
|
|
||||||
reqs = wrapper.Requirements.from_dir(WrapperDir())
|
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")
|
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(
|
logger.warning(
|
||||||
"repo: warning: git-%s+ will soon be required; "
|
"repo: warning: git-%s+ will soon be required; "
|
||||||
"please upgrade your version of git to maintain "
|
"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."
|
err_msg = "fatal: double check your --repo-rev setting."
|
||||||
logger.error(err_msg)
|
logger.error(err_msg)
|
||||||
self.git_event_log.ErrorEvent(err_msg)
|
self.git_event_log.ErrorEvent(err_msg)
|
||||||
raise RepoUnhandledExceptionError(e)
|
raise error.RepoUnhandledExceptionError(e)
|
||||||
|
|
||||||
branch = rp.GetBranch("default")
|
branch = rp.GetBranch("default")
|
||||||
branch.merge = remote_ref
|
branch.merge = remote_ref
|
||||||
@@ -387,7 +388,9 @@ to update the working directory files.
|
|||||||
if opt.worktree:
|
if opt.worktree:
|
||||||
# Older versions of git supported worktree, but had dangerous gc
|
# Older versions of git supported worktree, but had dangerous gc
|
||||||
# bugs.
|
# 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.
|
# Provide a short notice that we're reinitializing an existing checkout.
|
||||||
# Sometimes developers might not realize that they're in one, or that
|
# 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:
|
else:
|
||||||
projects = self.FindProjects(
|
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):
|
def _getpath(x):
|
||||||
|
|||||||
+2
-2
@@ -19,10 +19,10 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from command import PagedCommand
|
from command import PagedCommand
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class OutputFormat(enum.Enum):
|
class OutputFormat(enum.Enum):
|
||||||
|
|||||||
+9
-4
@@ -59,10 +59,15 @@ are displayed.
|
|||||||
for project in self.GetProjects(
|
for project in self.GetProjects(
|
||||||
args, all_manifests=not opt.this_manifest_only
|
args, all_manifests=not opt.this_manifest_only
|
||||||
):
|
):
|
||||||
br = [project.GetUploadableBranch(x) for x in project.GetBranches()]
|
local_branches = project.GetBranches()
|
||||||
br = [x for x in br if x]
|
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:
|
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)
|
all_branches.extend(br)
|
||||||
|
|
||||||
if not all_branches:
|
if not all_branches:
|
||||||
@@ -97,7 +102,7 @@ are displayed.
|
|||||||
print(
|
print(
|
||||||
"%s %-33s (%2d commit%s, %s)"
|
"%s %-33s (%2d commit%s, %s)"
|
||||||
% (
|
% (
|
||||||
branch.name == project.CurrentBranch and "*" or " ",
|
branch.current and "*" or " ",
|
||||||
branch.name,
|
branch.name,
|
||||||
len(commits),
|
len(commits),
|
||||||
len(commits) != 1 and "s" or " ",
|
len(commits) != 1 and "s" or " ",
|
||||||
|
|||||||
+1
-1
@@ -80,7 +80,7 @@ class Prune(PagedCommand):
|
|||||||
print(
|
print(
|
||||||
"%s %-33s "
|
"%s %-33s "
|
||||||
% (
|
% (
|
||||||
branch.name == project.CurrentBranch and "*" or " ",
|
branch.current and "*" or " ",
|
||||||
branch.name,
|
branch.name,
|
||||||
),
|
),
|
||||||
end="",
|
end="",
|
||||||
|
|||||||
+20
-24
@@ -16,11 +16,13 @@ import sys
|
|||||||
|
|
||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import Command
|
from command import Command
|
||||||
from git_command import GitCommand
|
import error
|
||||||
from repo_logging import RepoLogger
|
import git_command
|
||||||
|
from project import Project
|
||||||
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class RebaseColoring(Coloring):
|
class RebaseColoring(Coloring):
|
||||||
@@ -30,6 +32,17 @@ class RebaseColoring(Coloring):
|
|||||||
self.fail = self.printer("fail", fg="red")
|
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):
|
class Rebase(Command):
|
||||||
COMMON = True
|
COMMON = True
|
||||||
helpSummary = "Rebase local branches on upstream branch"
|
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")
|
common_args.append("--autosquash")
|
||||||
if opt.interactive:
|
if opt.interactive:
|
||||||
common_args.append("-i")
|
common_args.append("-i")
|
||||||
|
if opt.auto_stash:
|
||||||
|
common_args.append("--autostash")
|
||||||
|
|
||||||
config = self.manifest.manifestProject.config
|
config = self.manifest.manifestProject.config
|
||||||
out = RebaseColoring(config)
|
out = RebaseColoring(config)
|
||||||
@@ -162,7 +177,7 @@ branch but need to incorporate new upstream changes "underneath" them.
|
|||||||
args = common_args[:]
|
args = common_args[:]
|
||||||
if opt.onto_manifest:
|
if opt.onto_manifest:
|
||||||
args.append("--onto")
|
args.append("--onto")
|
||||||
args.append(project.revisionExpr)
|
args.append(_ResolveOntoManifest(project))
|
||||||
|
|
||||||
args.append(upbranch.LocalMerge)
|
args.append(upbranch.LocalMerge)
|
||||||
|
|
||||||
@@ -175,29 +190,10 @@ branch but need to incorporate new upstream changes "underneath" them.
|
|||||||
out.nl()
|
out.nl()
|
||||||
out.flush()
|
out.flush()
|
||||||
|
|
||||||
needs_stash = False
|
if git_command.GitCommand(project, args).Wait() != 0:
|
||||||
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:
|
|
||||||
ret += 1
|
ret += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if needs_stash:
|
|
||||||
stash_args.append("pop")
|
|
||||||
stash_args.append("--quiet")
|
|
||||||
if GitCommand(project, stash_args).Wait() != 0:
|
|
||||||
ret += 1
|
|
||||||
|
|
||||||
if ret:
|
if ret:
|
||||||
msg_fmt = "%d projects had errors"
|
msg_fmt = "%d projects had errors"
|
||||||
self.git_event_log.ErrorEvent(msg_fmt % (ret), msg_fmt)
|
self.git_event_log.ErrorEvent(msg_fmt % (ret), msg_fmt)
|
||||||
|
|||||||
@@ -16,16 +16,16 @@ import optparse
|
|||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from error import RepoExitError
|
import error
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
from subcmds.sync import _PostRepoFetch
|
from subcmds.sync import _PostRepoFetch
|
||||||
from subcmds.sync import _PostRepoUpgrade
|
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."""
|
"""Exit error for failed selfupdate command."""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -16,11 +16,11 @@ import sys
|
|||||||
|
|
||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import InteractiveCommand
|
from command import InteractiveCommand
|
||||||
from git_command import GitCommand
|
import git_command
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class _ProjectList(Coloring):
|
class _ProjectList(Coloring):
|
||||||
@@ -129,5 +129,5 @@ The '%prog' command stages files to prepare the next commit.
|
|||||||
|
|
||||||
|
|
||||||
def _AddI(project):
|
def _AddI(project):
|
||||||
p = GitCommand(project, ["add", "--interactive"], bare=False)
|
p = git_command.GitCommand(project, ["add", "--interactive"], bare=False)
|
||||||
p.Wait()
|
p.Wait()
|
||||||
+9
-10
@@ -17,14 +17,14 @@ from typing import NamedTuple
|
|||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from error import RepoExitError
|
import error
|
||||||
from git_command import git
|
import git_command
|
||||||
from git_config import IsImmutable
|
from git_config import IsImmutable
|
||||||
from progress import Progress
|
from progress import Progress
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class ExecuteOneResult(NamedTuple):
|
class ExecuteOneResult(NamedTuple):
|
||||||
@@ -32,7 +32,7 @@ class ExecuteOneResult(NamedTuple):
|
|||||||
error: Exception
|
error: Exception
|
||||||
|
|
||||||
|
|
||||||
class StartError(RepoExitError):
|
class StartError(error.RepoExitError):
|
||||||
"""Exit error for failed start command."""
|
"""Exit error for failed start command."""
|
||||||
|
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ revision specified in the manifest.
|
|||||||
self.Usage()
|
self.Usage()
|
||||||
|
|
||||||
nb = args[0]
|
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)
|
self.OptionParser.error("'%s' is not a valid name" % nb)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -85,7 +85,7 @@ revision specified in the manifest.
|
|||||||
# a change, then we can't push back to it. Substitute with
|
# a change, then we can't push back to it. Substitute with
|
||||||
# dest_branch, if defined; or with manifest default revision instead.
|
# dest_branch, if defined; or with manifest default revision instead.
|
||||||
branch_merge = ""
|
branch_merge = ""
|
||||||
error = None
|
err = None
|
||||||
project = cls.get_parallel_context()["projects"][project_idx]
|
project = cls.get_parallel_context()["projects"][project_idx]
|
||||||
if IsImmutable(project.revisionExpr):
|
if IsImmutable(project.revisionExpr):
|
||||||
if project.dest_branch:
|
if project.dest_branch:
|
||||||
@@ -99,12 +99,11 @@ revision specified in the manifest.
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("error: unable to checkout %s: %s", project.name, e)
|
logger.error("error: unable to checkout %s: %s", project.name, e)
|
||||||
error = e
|
err = e
|
||||||
return ExecuteOneResult(project_idx, error)
|
return ExecuteOneResult(project_idx, err)
|
||||||
|
|
||||||
def Execute(self, opt, args):
|
def Execute(self, opt, args):
|
||||||
nb = args[0]
|
nb = args[0]
|
||||||
self.TryOverrideManifestWithSmartSync()
|
|
||||||
err_projects = []
|
err_projects = []
|
||||||
err = []
|
err = []
|
||||||
projects = []
|
projects = []
|
||||||
|
|||||||
+549
-109
File diff suppressed because it is too large.
Load diff
+84
-49
@@ -22,23 +22,20 @@ from typing import List
|
|||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from command import InteractiveCommand
|
from command import InteractiveCommand
|
||||||
from editor import Editor
|
from editor import Editor
|
||||||
from error import GitError
|
import error
|
||||||
from error import SilentRepoExitError
|
import git_refs
|
||||||
from error import UploadError
|
|
||||||
from git_command import GitCommand
|
|
||||||
from git_refs import R_HEADS
|
|
||||||
import git_superproject
|
import git_superproject
|
||||||
from hooks import RepoHook
|
from hooks import RepoHook
|
||||||
from project import ReviewableBranch
|
from project import ReviewableBranch
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
from subcmds.sync import LocalSyncState
|
from subcmds.sync import LocalSyncState
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_UNUSUAL_COMMIT_THRESHOLD = 5
|
_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."""
|
"""Indicates that there is an upload command error requiring a sys exit."""
|
||||||
|
|
||||||
|
|
||||||
@@ -372,6 +369,12 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
action="store_false",
|
action="store_false",
|
||||||
help="always ask about untracked files in the working copy",
|
help="always ask about untracked files in the working copy",
|
||||||
)
|
)
|
||||||
|
p.add_option(
|
||||||
|
"--ignore-remote-commits",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="ignore commits already in remote branches",
|
||||||
|
)
|
||||||
p.add_option(
|
p.add_option(
|
||||||
"--no-cert-checks",
|
"--no-cert-checks",
|
||||||
dest="validate_certs",
|
dest="validate_certs",
|
||||||
@@ -379,7 +382,7 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
default=True,
|
default=True,
|
||||||
help="disable verifying ssl certs (unsafe)",
|
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):
|
def _SingleBranch(self, opt, branch, people):
|
||||||
project = branch.project
|
project = branch.project
|
||||||
@@ -436,6 +439,18 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
self._UploadAndReport(opt, [branch], people)
|
self._UploadAndReport(opt, [branch], people)
|
||||||
|
|
||||||
def _MultipleBranches(self, opt, pending, 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 = {}
|
projects = {}
|
||||||
branches = {}
|
branches = {}
|
||||||
|
|
||||||
@@ -598,11 +613,11 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
)
|
)
|
||||||
|
|
||||||
full_dest = destination
|
full_dest = destination
|
||||||
if not full_dest.startswith(R_HEADS):
|
if not full_dest.startswith(git_refs.R_HEADS):
|
||||||
full_dest = R_HEADS + full_dest
|
full_dest = git_refs.R_HEADS + full_dest
|
||||||
full_revision = branch.project.revisionExpr
|
full_revision = branch.project.revisionExpr
|
||||||
if not full_revision.startswith(R_HEADS):
|
if not full_revision.startswith(git_refs.R_HEADS):
|
||||||
full_revision = R_HEADS + full_revision
|
full_revision = git_refs.R_HEADS + full_revision
|
||||||
|
|
||||||
# If the merge branch of the local branch is different from
|
# If the merge branch of the local branch is different from
|
||||||
# the project's revision AND destination, this might not be
|
# the project's revision AND destination, this might not be
|
||||||
@@ -649,6 +664,8 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
validate_certs=opt.validate_certs,
|
validate_certs=opt.validate_certs,
|
||||||
push_options=push_options,
|
push_options=push_options,
|
||||||
patchset_description=opt.patchset_description,
|
patchset_description=opt.patchset_description,
|
||||||
|
git_event_log=self.git_event_log,
|
||||||
|
quiet=opt.quiet,
|
||||||
)
|
)
|
||||||
|
|
||||||
branch.uploaded = True
|
branch.uploaded = True
|
||||||
@@ -659,15 +676,17 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
for branch in todo:
|
for branch in todo:
|
||||||
try:
|
try:
|
||||||
self._UploadBranch(opt, branch, people)
|
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}")
|
self.git_event_log.ErrorEvent(f"upload error: {e}")
|
||||||
branch.error = e
|
branch.error = e
|
||||||
aggregate_errors.append(e)
|
aggregate_errors.append(e)
|
||||||
branch.uploaded = False
|
branch.uploaded = False
|
||||||
have_errors = True
|
have_errors = True
|
||||||
|
|
||||||
print(file=sys.stderr)
|
quiet = getattr(opt, "quiet", False)
|
||||||
print("-" * 70, file=sys.stderr)
|
if not quiet or have_errors:
|
||||||
|
print(file=sys.stderr)
|
||||||
|
print("-" * 70, file=sys.stderr)
|
||||||
|
|
||||||
if have_errors:
|
if have_errors:
|
||||||
for branch in todo:
|
for branch in todo:
|
||||||
@@ -688,52 +707,68 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
)
|
)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
for branch in todo:
|
if not quiet:
|
||||||
if branch.uploaded:
|
for branch in todo:
|
||||||
print(
|
if branch.uploaded:
|
||||||
"[OK ] %-15s %s"
|
print(
|
||||||
% (
|
"[OK ] %-15s %s"
|
||||||
branch.project.RelPath(local=opt.this_manifest_only)
|
% (
|
||||||
+ "/",
|
branch.project.RelPath(local=opt.this_manifest_only)
|
||||||
branch.name,
|
+ "/",
|
||||||
),
|
branch.name,
|
||||||
file=sys.stderr,
|
),
|
||||||
)
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
if have_errors:
|
if have_errors:
|
||||||
raise UploadExitError(aggregate_errors=aggregate_errors)
|
raise UploadExitError(aggregate_errors=aggregate_errors)
|
||||||
|
|
||||||
def _GetMergeBranch(self, project, local_branch=None):
|
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:
|
if local_branch is None:
|
||||||
p = GitCommand(
|
local_branch = project.CurrentBranch
|
||||||
project,
|
if local_branch:
|
||||||
["rev-parse", "--abbrev-ref", "HEAD"],
|
branch = project.GetBranch(local_branch)
|
||||||
capture_stdout=True,
|
if branch.merge:
|
||||||
capture_stderr=True,
|
return branch.merge
|
||||||
)
|
return ""
|
||||||
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
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _GatherOne(cls, opt, project_idx):
|
def _GatherOne(cls, opt, project_idx):
|
||||||
"""Figure out the upload status for |project|."""
|
"""Figure out the upload status for |project|."""
|
||||||
project = cls.get_parallel_context()["projects"][project_idx]
|
project = cls.get_parallel_context()["projects"][project_idx]
|
||||||
|
cbr = None
|
||||||
if opt.current_branch:
|
if opt.current_branch:
|
||||||
cbr = project.CurrentBranch
|
cbr = project.CurrentBranch
|
||||||
up_branch = project.GetUploadableBranch(cbr)
|
up_branch = project.GetUploadableBranch(
|
||||||
|
cbr, ignore_remote_commits=opt.ignore_remote_commits
|
||||||
|
)
|
||||||
avail = [up_branch] if up_branch else None
|
avail = [up_branch] if up_branch else None
|
||||||
else:
|
else:
|
||||||
avail = project.GetUploadableBranches(opt.branch)
|
avail = project.GetUploadableBranches(
|
||||||
return (project_idx, avail)
|
opt.branch, ignore_remote_commits=opt.ignore_remote_commits
|
||||||
|
)
|
||||||
|
return (project_idx, avail, cbr)
|
||||||
|
|
||||||
|
def ValidateOptions(self, opt: optparse.Values, args: List[str]) -> None:
|
||||||
|
if opt.fix:
|
||||||
|
if opt.ignore_hooks:
|
||||||
|
self.OptionParser.error(
|
||||||
|
"--fix and --ignore-hooks are mutually exclusive options: "
|
||||||
|
"with --ignore-hooks, the original commits are uploaded, "
|
||||||
|
"so fixups would only modify the working tree and would "
|
||||||
|
"not be part of the upload"
|
||||||
|
)
|
||||||
|
if opt.bypass_hooks:
|
||||||
|
self.OptionParser.error(
|
||||||
|
"--fix and --no-verify are mutually exclusive options: "
|
||||||
|
"--no-verify skips the pre-upload hook, so --fix would "
|
||||||
|
"have no effect"
|
||||||
|
)
|
||||||
|
|
||||||
def Execute(self, opt, args):
|
def Execute(self, opt, args):
|
||||||
projects = self.GetProjects(
|
projects = self.GetProjects(
|
||||||
@@ -743,7 +778,7 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
def _ProcessResults(_pool, _out, results):
|
def _ProcessResults(_pool, _out, results):
|
||||||
pending = []
|
pending = []
|
||||||
for result in results:
|
for result in results:
|
||||||
project_idx, avail = result
|
project_idx, avail, current_branch = result
|
||||||
project = projects[project_idx]
|
project = projects[project_idx]
|
||||||
if avail is None:
|
if avail is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -751,7 +786,7 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
"You might be able to fix the branch by running:\n"
|
"You might be able to fix the branch by running:\n"
|
||||||
" git branch --set-upstream-to m/%s",
|
" git branch --set-upstream-to m/%s",
|
||||||
project.RelPath(local=opt.this_manifest_only),
|
project.RelPath(local=opt.this_manifest_only),
|
||||||
project.CurrentBranch,
|
current_branch,
|
||||||
project.manifest.branch,
|
project.manifest.branch,
|
||||||
)
|
)
|
||||||
elif avail:
|
elif avail:
|
||||||
|
|||||||
+25
-10
@@ -14,13 +14,12 @@
|
|||||||
|
|
||||||
import platform
|
import platform
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Any, Tuple
|
||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from git_command import git
|
import git_command
|
||||||
from git_command import RepoSourceVersion
|
import git_refs
|
||||||
from git_command import user_agent
|
|
||||||
from git_refs import HEAD
|
|
||||||
from wrapper import Wrapper
|
from wrapper import Wrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -34,18 +33,34 @@ class Version(Command, MirrorSafeCommand):
|
|||||||
%prog
|
%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):
|
def Execute(self, opt, args):
|
||||||
rp = self.manifest.repoProject
|
rp = self.manifest.repoProject
|
||||||
rem = rp.GetRemote()
|
rem = rp.GetRemote()
|
||||||
branch = rp.GetBranch("default")
|
branch = rp.GetBranch("default")
|
||||||
|
|
||||||
# These might not be the same. Report them both.
|
# These might not be the same. Report them both.
|
||||||
src_ver = RepoSourceVersion()
|
src_ver = git_command.RepoSourceVersion()
|
||||||
rp_ver = rp.bare_git.describe(HEAD)
|
rp_ver, commit_date = self._RepoVersion(rp)
|
||||||
print(f"repo version {rp_ver}")
|
print(f"repo version {rp_ver}")
|
||||||
print(f" (from {rem.url})")
|
print(f" (from {rem.url})")
|
||||||
print(f" (tracking {branch.merge})")
|
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:
|
if self.wrapper_path is not None:
|
||||||
print(f"repo launcher version {self.wrapper_version}")
|
print(f"repo launcher version {self.wrapper_version}")
|
||||||
@@ -54,9 +69,9 @@ class Version(Command, MirrorSafeCommand):
|
|||||||
if src_ver != rp_ver:
|
if src_ver != rp_ver:
|
||||||
print(f" (currently at {src_ver})")
|
print(f" (currently at {src_ver})")
|
||||||
|
|
||||||
print(f"repo User-Agent {user_agent.repo}")
|
print(f"repo User-Agent {git_command.user_agent.repo}")
|
||||||
print(f"git {git.version_tuple().full}")
|
print(f"git {git_command.git.version_tuple().full}")
|
||||||
print(f"git User-Agent {user_agent.git}")
|
print(f"git User-Agent {git_command.user_agent.git}")
|
||||||
print(f"Python {sys.version}")
|
print(f"Python {sys.version}")
|
||||||
uname = platform.uname()
|
uname = platform.uname()
|
||||||
if sys.version_info.major < 3:
|
if sys.version_info.major < 3:
|
||||||
|
|||||||
+3
-4
@@ -17,13 +17,12 @@ import sys
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from error import GitError
|
import error
|
||||||
from error import RepoExitError
|
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from project import DeleteWorktreeError
|
from project import DeleteWorktreeError
|
||||||
|
|
||||||
|
|
||||||
class Error(RepoExitError):
|
class Error(error.RepoExitError):
|
||||||
"""Exit error when wipe command fails."""
|
"""Exit error when wipe command fails."""
|
||||||
|
|
||||||
|
|
||||||
@@ -101,7 +100,7 @@ Examples:
|
|||||||
try:
|
try:
|
||||||
if project.HasChanges():
|
if project.HasChanges():
|
||||||
uncommitted_projects.append(project.name)
|
uncommitted_projects.append(project.name)
|
||||||
except GitError:
|
except error.GitError:
|
||||||
uncommitted_projects.append(f"{project.name} (corrupted)")
|
uncommitted_projects.append(f"{project.name} (corrupted)")
|
||||||
|
|
||||||
users = objdir_map.get(project.objdir, {project.relpath})
|
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.
|
[subcmds/forall.py](../subcmds/forall.py) module.
|
||||||
* [conftest.py](./conftest.py): Custom pytest fixtures for sharing.
|
* [conftest.py](./conftest.py): Custom pytest fixtures for sharing.
|
||||||
* [utils_for_test.py](./utils_for_test.py): Helpers for sharing in tests.
|
* [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/
|
[pytest]: https://pytest.org/
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"""Common fixtures for pytests."""
|
"""Common fixtures for pytests."""
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from typing import Any, Generator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -23,9 +24,44 @@ import platform_utils
|
|||||||
import repo_trace
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def disable_repo_trace(tmp_path):
|
def disable_repo_trace(tmp_path):
|
||||||
"""Set an environment marker to relax certain strict checks for test code.""" # noqa: E501
|
"""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")
|
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."""
|
"""Unittests for the command.py module."""
|
||||||
|
|
||||||
|
from typing import Iterable, List, Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
|
|
||||||
|
|
||||||
@@ -28,12 +32,15 @@ class FakeProject:
|
|||||||
gitdir=None,
|
gitdir=None,
|
||||||
derived_subprojects=None,
|
derived_subprojects=None,
|
||||||
sync_s=False,
|
sync_s=False,
|
||||||
|
exists: bool = True,
|
||||||
):
|
):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.relpath = relpath
|
self.relpath = relpath
|
||||||
|
self.worktree = f"/work/{relpath}"
|
||||||
|
self.manifest = None
|
||||||
self.gitdir = gitdir or f"/git/{relpath}"
|
self.gitdir = gitdir or f"/git/{relpath}"
|
||||||
self.sync_s = sync_s
|
self.sync_s = sync_s
|
||||||
self.Exists = True
|
self.Exists = exists
|
||||||
self._derived_subprojects = derived_subprojects or []
|
self._derived_subprojects = derived_subprojects or []
|
||||||
|
|
||||||
def GetDerivedSubprojects(self):
|
def GetDerivedSubprojects(self):
|
||||||
@@ -49,11 +56,59 @@ class FakeProject:
|
|||||||
class FakeManifest:
|
class FakeManifest:
|
||||||
"""Minimal manifest double for Command.GetProjects tests."""
|
"""Minimal manifest double for Command.GetProjects tests."""
|
||||||
|
|
||||||
def __init__(self, projects):
|
def __init__(
|
||||||
self.projects = projects
|
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):
|
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():
|
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([])
|
projects = cmd.GetProjects([])
|
||||||
|
|
||||||
assert set(projects) == {project_a, project_b, submodule_a, submodule_b}
|
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."""
|
"""Tests the GitCommand class stderr log streaming cases."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
_ = git_command.user_agent.git
|
||||||
self.mock_process = mock.MagicMock()
|
self.mock_process = mock.MagicMock()
|
||||||
self.mock_process.communicate.return_value = (None, None)
|
self.mock_process.communicate.return_value = (None, None)
|
||||||
self.mock_process.wait.return_value = 0
|
self.mock_process.wait.return_value = 0
|
||||||
@@ -200,6 +201,7 @@ class GitCommandStreamLogsTest(unittest.TestCase):
|
|||||||
|
|
||||||
@mock.patch("sys.stderr")
|
@mock.patch("sys.stderr")
|
||||||
def test_streams_stderr_when_no_stream_is_set(self, mock_stderr):
|
def test_streams_stderr_when_no_stream_is_set(self, mock_stderr):
|
||||||
|
mock_stderr.isatty.return_value = False
|
||||||
logs = "\n".join(
|
logs = "\n".join(
|
||||||
[
|
[
|
||||||
"Enumerating objects: 5, done.",
|
"Enumerating objects: 5, done.",
|
||||||
@@ -227,10 +229,185 @@ class GitCommandStreamLogsTest(unittest.TestCase):
|
|||||||
mock_stderr.write.assert_called_once_with(logs)
|
mock_stderr.write.assert_called_once_with(logs)
|
||||||
self.assertEqual(cmd.stderr, 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):
|
class GitCallUnitTest(unittest.TestCase):
|
||||||
"""Tests the _GitCall class (via git_command.git)."""
|
"""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):
|
def test_version_tuple(self):
|
||||||
"""Check git.version_tuple() handling."""
|
"""Check git.version_tuple() handling."""
|
||||||
ver = git_command.git.version_tuple()
|
ver = git_command.git.version_tuple()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"""Unittests for the git_config.py module."""
|
"""Unittests for the git_config.py module."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import utils_for_test
|
import utils_for_test
|
||||||
@@ -256,8 +256,8 @@ def test_remote_save_with_push_url_without_projectname(
|
|||||||
("0" * 64, True),
|
("0" * 64, True),
|
||||||
("f" * 64, True),
|
("f" * 64, True),
|
||||||
("a" * 39, False),
|
("a" * 39, False),
|
||||||
("a" * 41, True),
|
("a" * 41, False),
|
||||||
("a" * 63, True),
|
("a" * 63, False),
|
||||||
("a" * 65, False),
|
("a" * 65, False),
|
||||||
("g" * 40, False),
|
("g" * 40, False),
|
||||||
("g" * 64, 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:
|
def test_is_id(rev: str, expected: bool) -> None:
|
||||||
"""Test IsId identifies both SHA-1 and SHA-256 hashes."""
|
"""Test IsId identifies both SHA-1 and SHA-256 hashes."""
|
||||||
assert git_config.IsId(rev) == expected
|
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
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import Any, List
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import utils_for_test
|
import utils_for_test
|
||||||
|
|
||||||
|
import git_command
|
||||||
import git_refs
|
import git_refs
|
||||||
|
|
||||||
|
|
||||||
def _run(repo, *args):
|
def _run(repo: str, *args: str) -> str:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["git", "-C", repo, *args],
|
["git", "-C", repo, *args],
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
@@ -34,7 +37,7 @@ def _run(repo, *args):
|
|||||||
).stdout.strip()
|
).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")
|
repo = os.path.join(tmp_path, "repo")
|
||||||
ref_format = "reftable" if reftable else "files"
|
ref_format = "reftable" if reftable else "files"
|
||||||
utils_for_test.init_git_tree(repo, ref_format=ref_format)
|
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")
|
branch = _run(repo, "symbolic-ref", "--short", "HEAD")
|
||||||
head = _run(repo, "rev-parse", "HEAD")
|
head = _run(repo, "rev-parse", "HEAD")
|
||||||
assert refs.symref("HEAD") == f"refs/heads/{branch}"
|
assert refs.symref("HEAD") == f"refs/heads/{branch}"
|
||||||
|
assert refs.head == f"refs/heads/{branch}"
|
||||||
assert refs.get("HEAD") == head
|
assert refs.get("HEAD") == head
|
||||||
assert refs.get(f"refs/heads/{branch}") == 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])
|
@pytest.mark.parametrize("reftable", [False, True])
|
||||||
def test_updates_when_refs_change(tmp_path, reftable):
|
def test_updates_when_refs_change(tmp_path, reftable):
|
||||||
if reftable and not utils_for_test.supports_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."""
|
"""Test _Init succeeds and creates the work git dir."""
|
||||||
self.assertFalse(os.path.exists(self._superproject._work_git))
|
self.assertFalse(os.path.exists(self._superproject._work_git))
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"git_superproject.GitCommand", autospec=True
|
"git_command.GitCommand", autospec=True
|
||||||
) as mock_git_command:
|
) as mock_git_command:
|
||||||
instance = mock_git_command.return_value
|
instance = mock_git_command.return_value
|
||||||
instance.Wait.return_value = 0
|
instance.Wait.return_value = 0
|
||||||
@@ -492,7 +492,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
|||||||
os.mkdir(self._superproject._superproject_path)
|
os.mkdir(self._superproject._superproject_path)
|
||||||
os.mkdir(self._superproject._work_git)
|
os.mkdir(self._superproject._work_git)
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"git_superproject.GitCommand", autospec=True
|
"git_command.GitCommand", autospec=True
|
||||||
) as mock_git_command:
|
) as mock_git_command:
|
||||||
self.assertTrue(self._superproject._Init())
|
self.assertTrue(self._superproject._Init())
|
||||||
mock_git_command.assert_not_called()
|
mock_git_command.assert_not_called()
|
||||||
@@ -500,7 +500,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
|||||||
def test_Init_failure_cleans_up(self):
|
def test_Init_failure_cleans_up(self):
|
||||||
"""Test _Init cleans up the temporary directory if 'git init' fails."""
|
"""Test _Init cleans up the temporary directory if 'git init' fails."""
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"git_superproject.GitCommand", autospec=True
|
"git_command.GitCommand", autospec=True
|
||||||
) as mock_git_command:
|
) as mock_git_command:
|
||||||
instance = mock_git_command.return_value
|
instance = mock_git_command.return_value
|
||||||
instance.Wait.return_value = 1
|
instance.Wait.return_value = 1
|
||||||
@@ -540,16 +540,17 @@ class SuperprojectTestCase(unittest.TestCase):
|
|||||||
os.mkdir(self._superproject._work_git)
|
os.mkdir(self._superproject._work_git)
|
||||||
with mock.patch.object(self._superproject, "_Init", return_value=True):
|
with mock.patch.object(self._superproject, "_Init", return_value=True):
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"git_superproject.GitCommand", autospec=True
|
"git_command.GitCommand", autospec=True
|
||||||
) as mock_git_command:
|
) as mock_git_command:
|
||||||
with mock.patch(
|
with mock.patch.object(
|
||||||
"git_superproject.GitRefs.get", autospec=True
|
self._superproject, "_GetRef"
|
||||||
) as mock_git_refs:
|
) as get_ref:
|
||||||
instance = mock_git_command.return_value
|
instance = mock_git_command.return_value
|
||||||
instance.Wait.return_value = 0
|
instance.Wait.return_value = 0
|
||||||
mock_git_refs.side_effect = ["", "1234"]
|
get_ref.side_effect = ["", "1234"]
|
||||||
|
|
||||||
self.assertTrue(self._superproject._Fetch())
|
self.assertTrue(self._superproject._Fetch())
|
||||||
|
get_ref.assert_called_with("refs/heads/main")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
# TODO: Once we require Python 3.8+,
|
# TODO: Once we require Python 3.8+,
|
||||||
# use 'mock_git_command.call_args.args'.
|
# use 'mock_git_command.call_args.args'.
|
||||||
@@ -572,6 +573,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
# If branch for revision exists, set as --negotiation-tip.
|
# If branch for revision exists, set as --negotiation-tip.
|
||||||
self.assertTrue(self._superproject._Fetch())
|
self.assertTrue(self._superproject._Fetch())
|
||||||
|
get_ref.assert_called_with("refs/heads/main")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
# TODO: Once we require Python 3.8+,
|
# TODO: Once we require Python 3.8+,
|
||||||
# use 'mock_git_command.call_args.args'.
|
# 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(
|
def server_logging_thread(
|
||||||
socket_path: str,
|
socket_path: str,
|
||||||
server_ready: threading.Condition,
|
server_ready: threading.Event,
|
||||||
received_traces: List[str],
|
received_traces: List[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Helper function to receive logs over a Unix domain socket.
|
"""Helper function to receive logs over a Unix domain socket.
|
||||||
@@ -43,8 +43,7 @@ def server_logging_thread(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
socket_path: path to a Unix domain socket on which to listen for traces
|
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
|
server_ready: event set when the server is ready to accept connections
|
||||||
this thread is ready to accept connections
|
|
||||||
received_traces: a list to which received traces will be appended (after
|
received_traces: a list to which received traces will be appended (after
|
||||||
decoding to a utf-8 string).
|
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:
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||||
sock.bind(socket_path)
|
sock.bind(socket_path)
|
||||||
sock.listen(0)
|
sock.listen(0)
|
||||||
with server_ready:
|
server_ready.set()
|
||||||
server_ready.notify()
|
|
||||||
with sock.accept()[0] as conn:
|
with sock.accept()[0] as conn:
|
||||||
while True:
|
while True:
|
||||||
recved = conn.recv(4096)
|
recved = conn.recv(4096)
|
||||||
@@ -404,7 +402,7 @@ def test_write_socket(event_log: git_trace2_event_log.EventLog) -> None:
|
|||||||
received_traces: List[str] = []
|
received_traces: List[str] = []
|
||||||
with tempfile.TemporaryDirectory(prefix="test_server_sockets") as tempdir:
|
with tempfile.TemporaryDirectory(prefix="test_server_sockets") as tempdir:
|
||||||
socket_path = os.path.join(tempdir, "server.sock")
|
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.
|
# Start "server" listening on Unix domain socket at socket_path.
|
||||||
server_thread = threading.Thread(
|
server_thread = threading.Thread(
|
||||||
target=server_logging_thread,
|
target=server_logging_thread,
|
||||||
@@ -412,9 +410,7 @@ def test_write_socket(event_log: git_trace2_event_log.EventLog) -> None:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
server_thread.start()
|
server_thread.start()
|
||||||
|
server_ready.wait(timeout=120)
|
||||||
with server_ready:
|
|
||||||
server_ready.wait(timeout=120)
|
|
||||||
|
|
||||||
event_log.StartEvent([])
|
event_log.StartEvent([])
|
||||||
path = event_log.Write(path=f"af_unix:{socket_path}")
|
path = event_log.Write(path=f"af_unix:{socket_path}")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"""Unittests for the hooks.py module."""
|
"""Unittests for the hooks.py module."""
|
||||||
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -105,3 +106,98 @@ def test_post_sync_argument_validation() -> None:
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
sys.stderr = old_stderr
|
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()
|
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:
|
class TestIncludeElement:
|
||||||
"""Tests for <include>."""
|
"""Tests for <include>."""
|
||||||
|
|||||||
+3177
-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
|
import pytest
|
||||||
|
|
||||||
from color import SetDefaultColoring
|
from color import SetDefaultColoring
|
||||||
from error import RepoExitError
|
import error
|
||||||
from repo_logging import RepoLogger
|
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:
|
def test_log_aggregated_errors_logs_aggregated_errors(mock_error) -> None:
|
||||||
"""Test if log_aggregated_errors logs a list of aggregated errors."""
|
"""Test if log_aggregated_errors logs a list of aggregated errors."""
|
||||||
logger = RepoLogger(__name__)
|
logger = repo_logging.RepoLogger(__name__)
|
||||||
logger.log_aggregated_errors(
|
logger.log_aggregated_errors(
|
||||||
RepoExitError(
|
error.RepoExitError(
|
||||||
aggregate_errors=[
|
aggregate_errors=[
|
||||||
Exception("foo"),
|
Exception("foo"),
|
||||||
Exception("bar"),
|
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:
|
def test_log_aggregated_errors_logs_single_error(mock_error) -> None:
|
||||||
"""Test if log_aggregated_errors logs empty aggregated_errors."""
|
"""Test if log_aggregated_errors logs empty aggregated_errors."""
|
||||||
logger = RepoLogger(__name__)
|
logger = repo_logging.RepoLogger(__name__)
|
||||||
logger.log_aggregated_errors(RepoExitError())
|
logger.log_aggregated_errors(error.RepoExitError())
|
||||||
|
|
||||||
mock_error.assert_has_calls(
|
mock_error.assert_has_calls(
|
||||||
[
|
[
|
||||||
@@ -99,7 +99,7 @@ def test_log_with_format_string(level: int) -> None:
|
|||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
|
|
||||||
with contextlib.redirect_stderr(output):
|
with contextlib.redirect_stderr(output):
|
||||||
logger = RepoLogger(__name__)
|
logger = repo_logging.RepoLogger(__name__)
|
||||||
logger.log(level, "%s", "100% pass")
|
logger.log(level, "%s", "100% pass")
|
||||||
|
|
||||||
assert re.search(
|
assert re.search(
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ def test_trace_max_size_enforced(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
"""Check Trace behavior."""
|
"""Check Trace behavior."""
|
||||||
content = "git chicken"
|
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):
|
with repo_trace.Trace(content, first_trace=True):
|
||||||
pass
|
pass
|
||||||
first_trace_size = os.path.getsize(repo_trace._TRACE_FILE)
|
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 contextlib
|
||||||
import io
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
import utils_for_test
|
import utils_for_test
|
||||||
|
|
||||||
import manifest_xml
|
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)
|
line_count = sum(1 for x in output.splitlines() if x)
|
||||||
# Verify that we didn't get more lines than expected.
|
# Verify that we didn't get more lines than expected.
|
||||||
assert line_count == 8
|
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."""
|
"""Unittests for the subcmds/gc.py module."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import manifest_xml
|
||||||
from subcmds import gc
|
from subcmds import gc
|
||||||
|
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ class GcCommand(unittest.TestCase):
|
|||||||
"""Tests for gc command."""
|
"""Tests for gc command."""
|
||||||
|
|
||||||
def setUp(self):
|
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, self.args = self.cmd.OptionParser.parse_args([])
|
||||||
self.opt.this_manifest_only = False
|
self.opt.this_manifest_only = False
|
||||||
self.opt.repack = False
|
self.opt.repack = False
|
||||||
@@ -80,3 +82,108 @@ class GcCommand(unittest.TestCase):
|
|||||||
ret = self.cmd.Execute(self.opt, [])
|
ret = self.cmd.Execute(self.opt, [])
|
||||||
self.assertEqual(ret, 1)
|
self.assertEqual(ret, 1)
|
||||||
self.mock_repack.assert_not_called()
|
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)
|
||||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user