mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-10-28 08:06:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
338ddb7d83 | ||
|
|
8c316d7c98 | ||
|
|
e3e8e8a3b0 | ||
|
|
cad9f591a4 | ||
|
|
24197b8ae1 | ||
|
|
bfb9869b89 | ||
|
|
887f9a9014 | ||
|
|
dc1811ba23 | ||
|
|
9aac80dafc | ||
|
|
f4ce9572d1 | ||
|
|
cc1f41d782 | ||
|
|
f7d252f5dc | ||
|
|
f95e7cd899 | ||
|
|
2c04b98c60 | ||
|
|
4212588c20 | ||
|
|
be06f8dd01 | ||
|
|
74d887ea86 | ||
|
|
29d644a5c0 | ||
|
|
a93741993b | ||
|
|
9991aa6177 | ||
|
|
d744eb15f3 | ||
|
|
f0124d0fd5 |
No files matched your search
@@ -2004,10 +2004,10 @@ def _Run(self, *cmd):
|
||||
**Do:**
|
||||
|
||||
```python
|
||||
from git_command import GitCommand
|
||||
import git_command
|
||||
|
||||
def _Run(self, cmd):
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
capture_stdout=True,
|
||||
|
||||
+5
-7
@@ -19,9 +19,7 @@ import os
|
||||
import re
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
from error import InvalidProjectGroupsError
|
||||
from error import NoSuchProjectError
|
||||
from error import RepoExitError
|
||||
import error
|
||||
from event_log import EventLog
|
||||
import progress
|
||||
|
||||
@@ -48,7 +46,7 @@ WORKER_BATCH_SIZE = 32
|
||||
DEFAULT_LOCAL_JOBS = min(os.cpu_count(), 8)
|
||||
|
||||
|
||||
class UsageError(RepoExitError):
|
||||
class UsageError(error.RepoExitError):
|
||||
"""Exception thrown with invalid command usage."""
|
||||
|
||||
|
||||
@@ -526,16 +524,16 @@ class Command:
|
||||
projects = [project]
|
||||
|
||||
if not projects:
|
||||
raise NoSuchProjectError(arg)
|
||||
raise error.NoSuchProjectError(arg)
|
||||
|
||||
for project in projects:
|
||||
if not missing_ok and not project.Exists:
|
||||
raise NoSuchProjectError(
|
||||
raise error.NoSuchProjectError(
|
||||
"%s (%s)"
|
||||
% (arg, project.RelPath(local=not all_manifests))
|
||||
)
|
||||
if not matches_groups(project):
|
||||
raise InvalidProjectGroupsError(arg)
|
||||
raise error.InvalidProjectGroupsError(arg)
|
||||
|
||||
result.extend(projects)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ _repo() {
|
||||
'branches:View current topic branches'
|
||||
'checkout:Checkout a branch'
|
||||
'cherry-pick:Cherry-pick a change'
|
||||
'config:Read and write per-user repo settings'
|
||||
'diff:Show changes between commit and working tree'
|
||||
'diffmanifests:Show differences between two manifests'
|
||||
'download:Download and find a change'
|
||||
@@ -117,6 +118,15 @@ _repo() {
|
||||
${common_opts} \
|
||||
'1: :_message "sha1"'
|
||||
;;
|
||||
config)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--type=[Validate and canonicalize the value as this type]:type:(bool int)' \
|
||||
'(-z --null)'{-z,--null}'[Terminate output with NUL]' \
|
||||
'1: :(get set unset list)' \
|
||||
'2: :_message "name"' \
|
||||
'3: :_message "value"'
|
||||
;;
|
||||
diff)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
@@ -236,6 +246,9 @@ _repo() {
|
||||
'--clone-filter=[Clone filter]:filter:' \
|
||||
'--use-superproject[Use superproject]' \
|
||||
'--no-use-superproject[Do not use superproject]' \
|
||||
'(-s --smart-sync)'{-s,--smart-sync}'[Default to smart sync]' \
|
||||
'--no-smart-sync[Do not default to smart sync]' \
|
||||
'--head[Do not default to smart sync]' \
|
||||
'--clone-bundle[Use clone bundle]' \
|
||||
'--no-clone-bundle[Do not use clone bundle]' \
|
||||
'--git-lfs[Use git lfs]' \
|
||||
@@ -338,6 +351,7 @@ _repo() {
|
||||
'--no-auto-gc[Do not run auto gc]' \
|
||||
'(-s --smart-sync)'{-s,--smart-sync}'[Smart sync]' \
|
||||
'--no-smart-sync[Do not smart sync]' \
|
||||
'--head[Do not smart sync]' \
|
||||
'(-t --smart-tag)'{-t,--smart-tag=}'[Smart tag]:tag:' \
|
||||
'--no-repo-verify[Do not verify repo]' \
|
||||
'--no-verify[Do not verify]' \
|
||||
|
||||
@@ -176,6 +176,7 @@ User controlled settings are initialized when running `repo init`.
|
||||
| repo.partialclone | `--partial-clone` | Create [partial git clones] |
|
||||
| repo.partialcloneexclude | `--partial-clone-exclude` | Comma-delimited list of project names (not paths) to exclude while using [partial git clones] |
|
||||
| repo.reference | `--reference` | Reference repo client checkout |
|
||||
| repo.smartsync | `--smart-sync` | Default to [Smart Sync](./smart-sync.md) when syncing |
|
||||
| repo.submodules | `--submodules` | Sync git submodules |
|
||||
| repo.superproject | `--use-superproject` | Sync [superproject] |
|
||||
| repo.worktree | `--worktree` | Use [git worktree] for checkouts |
|
||||
@@ -253,7 +254,8 @@ setting `REPO_CONFIG_DIR`.
|
||||
|
||||
* `.repoconfig/`: Repo's per-user directory for all random config files/state.
|
||||
* `.repoconfig/config`: Per-user settings using [git-config] file format.
|
||||
See [repoconfig.md](./repoconfig.md) for the available settings.
|
||||
Managed with `repo config`; see [repoconfig.md](./repoconfig.md) for the
|
||||
available settings.
|
||||
* `.repoconfig/keyring-version`: Cache file for checking if the gnupg subdir
|
||||
has all the same keys as the repo launcher. Used to avoid running gpg
|
||||
constantly as that can be quite slow.
|
||||
|
||||
+22
-29
@@ -18,31 +18,33 @@ The file is created on demand; it's fine for it to be missing.
|
||||
|
||||
## Format
|
||||
|
||||
The file uses the [git-config] syntax, so it can be edited by hand:
|
||||
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
|
||||
```
|
||||
|
||||
or managed with `git config` by pointing it at the file explicitly:
|
||||
|
||||
```sh
|
||||
# Set a value.
|
||||
git config --file ~/.repoconfig/config sync.smartsync true
|
||||
|
||||
# Read a value.
|
||||
git config --file ~/.repoconfig/config sync.smartsync
|
||||
|
||||
# Remove a value.
|
||||
git config --file ~/.repoconfig/config --unset sync.smartsync
|
||||
```
|
||||
|
||||
Boolean settings accept `true`/`yes` and `false`/`no`.
|
||||
|
||||
Repo caches a parsed copy of the file in `.repoconfig/.repo_config.json`; it is
|
||||
refreshed automatically whenever `config` changes and can be safely deleted.
|
||||
|
||||
## Settings
|
||||
|
||||
### alias.*
|
||||
@@ -65,18 +67,9 @@ ones. Existing subcommands cannot be overridden by an alias.
|
||||
|
||||
`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:
|
||||
|
||||
```sh
|
||||
# Always Smart Sync, as if -s/--smart-sync were passed.
|
||||
git config --file ~/.repoconfig/config sync.smartsync true
|
||||
|
||||
# Never Smart Sync by default, even if the manifest sets sync-smartsync="true".
|
||||
git config --file ~/.repoconfig/config sync.smartsync false
|
||||
|
||||
# Remove the preference and go back to following the manifest.
|
||||
git config --file ~/.repoconfig/config --unset sync.smartsync
|
||||
```
|
||||
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:
|
||||
|
||||
|
||||
+25
-3
@@ -182,6 +182,28 @@ 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
|
||||
@@ -230,10 +252,10 @@ match the settings Android build environments automatically setup.
|
||||
3. `${TARGET_PRODUCT}-${TARGET_BUILD_VARIANT}`: If these variables are all
|
||||
defined, then they are merged with `-` and used.
|
||||
|
||||
### --no-smart-sync
|
||||
### --no-smart-sync / --head
|
||||
|
||||
Explicitly disables Smart Sync, overriding any `sync-smartsync="true"` setting
|
||||
declared in the manifest `<default>` element.
|
||||
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)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from error import EditorError
|
||||
import error
|
||||
import platform_utils
|
||||
|
||||
|
||||
@@ -104,9 +104,11 @@ least one of these before using this command.""", # noqa: E501
|
||||
try:
|
||||
rc = subprocess.Popen(args, shell=shell).wait()
|
||||
except OSError as e:
|
||||
raise EditorError(f"editor failed, {str(e)}: {editor} {path}")
|
||||
raise error.EditorError(
|
||||
f"editor failed, {str(e)}: {editor} {path}"
|
||||
)
|
||||
if rc != 0:
|
||||
raise EditorError(
|
||||
raise error.EditorError(
|
||||
"editor failed with exit status %d: %s %s"
|
||||
% (rc, editor, path)
|
||||
)
|
||||
|
||||
@@ -19,10 +19,10 @@ import sys
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
from error import RepoExitError
|
||||
import error
|
||||
|
||||
|
||||
class FetchFileError(RepoExitError):
|
||||
class FetchFileError(error.RepoExitError):
|
||||
"""Exit error when fetch_file fails."""
|
||||
|
||||
|
||||
|
||||
+20
-17
@@ -20,14 +20,11 @@ import subprocess
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
from error import GitError
|
||||
from error import RepoExitError
|
||||
import error
|
||||
from git_trace2_event_log_base import BaseEventLog
|
||||
import platform_utils
|
||||
from repo_logging import RepoLogger
|
||||
from repo_trace import IsTrace
|
||||
from repo_trace import REPO_TRACE
|
||||
from repo_trace import Trace
|
||||
import repo_logging
|
||||
import repo_trace
|
||||
from wrapper import Wrapper
|
||||
|
||||
|
||||
@@ -43,7 +40,7 @@ GIT_ERROR_STDOUT_LINES = 1
|
||||
GIT_ERROR_STDERR_LINES = 10
|
||||
INVALID_GIT_EXIT_CODE = 126
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class _GitCall:
|
||||
@@ -335,11 +332,17 @@ class GitCommand:
|
||||
|
||||
if command_name in ("fetch", "clone"):
|
||||
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||
# Need to use the --progress flag for fetch/clone so output will be
|
||||
# displayed as by default git only does progress output if stderr is
|
||||
# a TTY.
|
||||
if command_name in ("fetch", "clone", "push"):
|
||||
# Need to use the --progress flag for fetch/clone/push so output
|
||||
# will be displayed as by default git only does progress output if
|
||||
# stderr is a TTY.
|
||||
if sys.stderr.isatty():
|
||||
if "--progress" not in cmdv and "--quiet" not in cmdv:
|
||||
if (
|
||||
"--progress" not in cmdv
|
||||
and "--no-progress" not in cmdv
|
||||
and "--quiet" not in cmdv
|
||||
and "-q" not in cmdv
|
||||
):
|
||||
command.append("--progress")
|
||||
command.extend(cmdv[1:])
|
||||
|
||||
@@ -433,7 +436,7 @@ class GitCommand:
|
||||
kwargs = {}
|
||||
|
||||
dbg = ""
|
||||
if IsTrace():
|
||||
if repo_trace.IsTrace():
|
||||
global LAST_CWD
|
||||
global LAST_GITDIR
|
||||
|
||||
@@ -470,7 +473,7 @@ class GitCommand:
|
||||
elif stderr == subprocess.STDOUT:
|
||||
dbg += " 2>&1"
|
||||
|
||||
with Trace(
|
||||
with repo_trace.Trace(
|
||||
"git command %s %s with debug: %s", LAST_GITDIR, command, dbg
|
||||
):
|
||||
try:
|
||||
@@ -547,7 +550,7 @@ class GitCommand:
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
for key in (
|
||||
REPO_TRACE,
|
||||
repo_trace.REPO_TRACE,
|
||||
GIT_DIR,
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
@@ -604,14 +607,14 @@ class GitCommand:
|
||||
return self.rc
|
||||
|
||||
|
||||
class GitRequireError(RepoExitError):
|
||||
class GitRequireError(error.RepoExitError):
|
||||
"""Error raised when git version is unavailable or invalid."""
|
||||
|
||||
def __init__(self, message, exit_code: int = INVALID_GIT_EXIT_CODE):
|
||||
super().__init__(message, exit_code=exit_code)
|
||||
|
||||
|
||||
class GitCommandError(GitError):
|
||||
class GitCommandError(error.GitError):
|
||||
"""
|
||||
Error raised from a failed git command.
|
||||
Note that GitError can refer to any Git related error (e.g. branch not
|
||||
@@ -691,7 +694,7 @@ class GitCommandError(GitError):
|
||||
return string
|
||||
|
||||
|
||||
class GitPopenCommandError(GitError):
|
||||
class GitPopenCommandError(error.GitError):
|
||||
"""
|
||||
Error raised when subprocess.Popen fails for a GitCommand
|
||||
"""
|
||||
+85
-50
@@ -26,14 +26,11 @@ from typing import Union
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from error import GitError
|
||||
from error import UploadError
|
||||
from git_command import GitCommand
|
||||
from git_refs import R_CHANGES
|
||||
from git_refs import R_HEADS
|
||||
from git_refs import R_TAGS
|
||||
import error
|
||||
import git_command
|
||||
import git_refs
|
||||
import platform_utils
|
||||
from repo_trace import Trace
|
||||
import repo_trace
|
||||
|
||||
|
||||
# Prefix that is prepended to all the keys of SyncAnalysisState's data
|
||||
@@ -46,7 +43,7 @@ REVIEW_CACHE = {}
|
||||
|
||||
|
||||
def IsChange(rev):
|
||||
return rev.startswith(R_CHANGES)
|
||||
return rev.startswith(git_refs.R_CHANGES)
|
||||
|
||||
|
||||
def IsId(rev: str) -> bool:
|
||||
@@ -54,13 +51,63 @@ def IsId(rev: str) -> bool:
|
||||
|
||||
|
||||
def IsTag(rev):
|
||||
return rev.startswith(R_TAGS)
|
||||
return rev.startswith(git_refs.R_TAGS)
|
||||
|
||||
|
||||
def IsImmutable(rev):
|
||||
return IsChange(rev) or IsId(rev) or IsTag(rev)
|
||||
|
||||
|
||||
def ParseBoolean(value: str) -> Union[bool, None]:
|
||||
"""Parses a git config boolean value.
|
||||
|
||||
Accepts the same spellings as `git config --type=bool`.
|
||||
https://git-scm.com/docs/git-config#Documentation/git-config.txt-boolean
|
||||
|
||||
Returns:
|
||||
True/False, or None if |value| is not a valid boolean.
|
||||
"""
|
||||
v = value.strip().lower()
|
||||
if v in ("true", "yes", "on", "1"):
|
||||
return True
|
||||
if v in ("false", "no", "off", "0"):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def ParseInt(value: str) -> Union[int, None]:
|
||||
"""Parses a git config integer value.
|
||||
|
||||
Accepts the same syntax as `git config --type=int`: an optional k, m or
|
||||
g suffix (multiples of 1024) and hex with a 0x prefix.
|
||||
https://git-scm.com/docs/git-config#Documentation/git-config.txt-integer
|
||||
|
||||
Returns:
|
||||
The integer, or None if |value| is not a valid integer.
|
||||
"""
|
||||
v = value.strip().lower()
|
||||
|
||||
mult = 1
|
||||
if v.endswith("k"):
|
||||
v = v[:-1]
|
||||
mult = 1024
|
||||
elif v.endswith("m"):
|
||||
v = v[:-1]
|
||||
mult = 1024 * 1024
|
||||
elif v.endswith("g"):
|
||||
v = v[:-1]
|
||||
mult = 1024 * 1024 * 1024
|
||||
|
||||
base = 10
|
||||
if v.startswith("0x"):
|
||||
base = 16
|
||||
|
||||
try:
|
||||
return int(v, base=base) * mult
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _key(name):
|
||||
parts = name.split(".")
|
||||
if len(parts) < 2:
|
||||
@@ -152,32 +199,15 @@ class GitConfig:
|
||||
v = self.GetString(name)
|
||||
if v is None:
|
||||
return None
|
||||
v = v.strip()
|
||||
|
||||
mult = 1
|
||||
if v.endswith("k"):
|
||||
v = v[:-1]
|
||||
mult = 1024
|
||||
elif v.endswith("m"):
|
||||
v = v[:-1]
|
||||
mult = 1024 * 1024
|
||||
elif v.endswith("g"):
|
||||
v = v[:-1]
|
||||
mult = 1024 * 1024 * 1024
|
||||
|
||||
base = 10
|
||||
if v.startswith("0x"):
|
||||
base = 16
|
||||
|
||||
try:
|
||||
return int(v, base=base) * mult
|
||||
except ValueError:
|
||||
ret = ParseInt(v)
|
||||
if ret is None:
|
||||
print(
|
||||
f"warning: expected {name} to represent an integer, got {v} "
|
||||
"instead",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
return ret
|
||||
|
||||
def DumpConfigDict(self):
|
||||
"""Returns the current configuration dict.
|
||||
@@ -197,24 +227,25 @@ class GitConfig:
|
||||
def GetBoolean(self, name: str) -> Union[bool, None]:
|
||||
"""Returns a boolean from the configuration file.
|
||||
|
||||
This follows the git config syntax.
|
||||
|
||||
Returns:
|
||||
None: The value was not defined, or is not a boolean.
|
||||
True: The value was set to true or yes.
|
||||
False: The value was set to false or no.
|
||||
True: The value was set to true, yes, on or 1.
|
||||
False: The value was set to false, no, off or 0.
|
||||
"""
|
||||
v = self.GetString(name)
|
||||
if v is None:
|
||||
return None
|
||||
v = v.lower()
|
||||
if v in ("true", "yes"):
|
||||
return True
|
||||
if v in ("false", "no"):
|
||||
return False
|
||||
print(
|
||||
f"warning: expected {name} to represent a boolean, got {v} instead",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
ret = ParseBoolean(v)
|
||||
if ret is None:
|
||||
print(
|
||||
f"warning: expected {name} to represent a boolean, got {v} "
|
||||
"instead",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return ret
|
||||
|
||||
def SetBoolean(self, name, value):
|
||||
"""Set the truthy value for a key."""
|
||||
@@ -394,7 +425,7 @@ class GitConfig:
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
with Trace(": parsing %s", self.file):
|
||||
with repo_trace.Trace(": parsing %s", self.file):
|
||||
with open(self._json) as fd:
|
||||
return json.load(fd)
|
||||
except (OSError, ValueError):
|
||||
@@ -441,11 +472,13 @@ class GitConfig:
|
||||
command = ["config", "--file", self.file, "--includes"]
|
||||
command.extend(args)
|
||||
|
||||
p = GitCommand(None, command, capture_stdout=True, capture_stderr=True)
|
||||
p = git_command.GitCommand(
|
||||
None, command, capture_stdout=True, capture_stderr=True
|
||||
)
|
||||
if p.Wait() == 0:
|
||||
return p.stdout
|
||||
else:
|
||||
raise GitError(f"git {' '.join(command)}: {p.stderr}")
|
||||
raise error.GitError(f"git {' '.join(command)}: {p.stderr}")
|
||||
|
||||
|
||||
class RepoConfig(GitConfig):
|
||||
@@ -683,11 +716,13 @@ class Remote:
|
||||
userEmail, host, port
|
||||
)
|
||||
except urllib.error.HTTPError as e:
|
||||
raise UploadError(f"{self.review}: {str(e)}")
|
||||
raise error.UploadError(f"{self.review}: {str(e)}")
|
||||
except urllib.error.URLError as e:
|
||||
raise UploadError(f"{self.review}: {str(e)}")
|
||||
raise error.UploadError(f"{self.review}: {str(e)}")
|
||||
except http.client.HTTPException as e:
|
||||
raise UploadError(f"{self.review}: {e.__class__.__name__}")
|
||||
raise error.UploadError(
|
||||
f"{self.review}: {e.__class__.__name__}"
|
||||
)
|
||||
|
||||
REVIEW_CACHE[u] = self._review_url
|
||||
return self._review_url + self.projectname
|
||||
@@ -704,16 +739,16 @@ class Remote:
|
||||
return rev
|
||||
|
||||
if not rev.startswith("refs/"):
|
||||
rev = R_HEADS + rev
|
||||
rev = git_refs.R_HEADS + rev
|
||||
|
||||
for spec in self.fetch:
|
||||
if spec.SourceMatches(rev):
|
||||
return spec.MapSource(rev)
|
||||
|
||||
if not rev.startswith(R_HEADS):
|
||||
if not rev.startswith(git_refs.R_HEADS):
|
||||
return rev
|
||||
|
||||
raise GitError(
|
||||
raise error.GitError(
|
||||
"%s: remote %s does not have %s"
|
||||
% (self.projectname, self.name, rev)
|
||||
)
|
||||
|
||||
+8
-9
@@ -14,10 +14,9 @@
|
||||
|
||||
import os
|
||||
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
import git_command
|
||||
import platform_utils
|
||||
from repo_trace import Trace
|
||||
import repo_trace
|
||||
|
||||
|
||||
HEAD = "HEAD"
|
||||
@@ -82,7 +81,7 @@ class GitRefs:
|
||||
self._LoadAll()
|
||||
|
||||
def _NeedUpdate(self):
|
||||
with Trace(": scan refs %s", self._gitdir):
|
||||
with repo_trace.Trace(": scan refs %s", self._gitdir):
|
||||
for name, mtime in self._mtime.items():
|
||||
try:
|
||||
if mtime != os.path.getmtime(
|
||||
@@ -94,7 +93,7 @@ class GitRefs:
|
||||
return False
|
||||
|
||||
def _LoadAll(self):
|
||||
with Trace(": load refs %s", self._gitdir):
|
||||
with repo_trace.Trace(": load refs %s", self._gitdir):
|
||||
self._phyref = {}
|
||||
self._symref = {}
|
||||
self._mtime = {}
|
||||
@@ -135,7 +134,7 @@ class GitRefs:
|
||||
Returns:
|
||||
Whether root refs, including HEAD when it exists, were loaded.
|
||||
"""
|
||||
include_root_refs = git_require((2, 45, 0))
|
||||
include_root_refs = git_command.git_require((2, 45, 0))
|
||||
cmd = [
|
||||
"for-each-ref",
|
||||
"--format=%(objectname)%00%(refname)%00%(symref)",
|
||||
@@ -145,7 +144,7 @@ class GitRefs:
|
||||
# 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 = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
capture_stdout=True,
|
||||
@@ -166,7 +165,7 @@ class GitRefs:
|
||||
|
||||
def _ReadSymbolicRef(self, name: str) -> None:
|
||||
"""Read a symbolic reference."""
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["symbolic-ref", "-q", name],
|
||||
capture_stdout=True,
|
||||
@@ -180,7 +179,7 @@ class GitRefs:
|
||||
self._symref[name] = ref
|
||||
return
|
||||
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["rev-parse", "--verify", "-q", name],
|
||||
capture_stdout=True,
|
||||
|
||||
+6
-7
@@ -18,8 +18,7 @@ from collections import OrderedDict
|
||||
import os
|
||||
from typing import Iterator, List, Optional, TYPE_CHECKING
|
||||
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
import git_command
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -80,7 +79,7 @@ def GetStatus(
|
||||
show_stash: bool = False,
|
||||
) -> StatusSnapshot:
|
||||
"""Return one machine-readable status snapshot for |project|."""
|
||||
if not git_require((2, 11, 0)):
|
||||
if not git_command.git_require((2, 11, 0)):
|
||||
raise UnsupportedStatusError("porcelain v2 requires Git 2.11")
|
||||
cmd = [
|
||||
"status",
|
||||
@@ -91,18 +90,18 @@ def GetStatus(
|
||||
]
|
||||
if branch:
|
||||
cmd.append("--branch")
|
||||
if git_require((2, 17, 0)):
|
||||
if git_command.git_require((2, 17, 0)):
|
||||
cmd.append(
|
||||
"--ahead-behind" if ahead_behind else "--no-ahead-behind"
|
||||
)
|
||||
if git_require((2, 18, 0)):
|
||||
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_require((2, 35, 0)):
|
||||
if show_stash and git_command.git_require((2, 35, 0)):
|
||||
cmd.append("--show-stash")
|
||||
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
project,
|
||||
cmd,
|
||||
bare=False,
|
||||
|
||||
+7
-8
@@ -32,8 +32,7 @@ import time
|
||||
from typing import NamedTuple
|
||||
import urllib.parse
|
||||
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
import git_command
|
||||
from git_config import IsId
|
||||
from git_config import RepoConfig
|
||||
import platform_utils
|
||||
@@ -140,7 +139,7 @@ class Superproject:
|
||||
def commit_id(self):
|
||||
"""Returns the commit ID of the superproject checkout."""
|
||||
cmd = ["rev-parse", self.revision]
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None, # project
|
||||
cmd,
|
||||
gitdir=self._work_git,
|
||||
@@ -242,7 +241,7 @@ class Superproject:
|
||||
|
||||
try:
|
||||
cmd = ["init", "--bare", tmp_git_name]
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
cwd=self._superproject_path,
|
||||
@@ -292,7 +291,7 @@ class Superproject:
|
||||
if not os.path.exists(self._work_git):
|
||||
self._LogWarning("git fetch missing directory: {}", self._work_git)
|
||||
return False
|
||||
if not git_require((2, 28, 0)):
|
||||
if not git_command.git_require((2, 28, 0)):
|
||||
self._LogWarning(
|
||||
"superproject requires a git version 2.28 or later"
|
||||
)
|
||||
@@ -329,7 +328,7 @@ class Superproject:
|
||||
else f"{self.revision}:{self.revision}"
|
||||
)
|
||||
cmd.append(refspec)
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
gitdir=self._work_git,
|
||||
@@ -351,7 +350,7 @@ class Superproject:
|
||||
|
||||
def _GetRef(self, ref: str) -> str:
|
||||
"""Resolve one local ref without loading the entire ref namespace."""
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["rev-parse", "--verify", "--quiet", ref],
|
||||
gitdir=self._work_git,
|
||||
@@ -381,7 +380,7 @@ class Superproject:
|
||||
branch = "HEAD" if not self.revision else self.revision
|
||||
cmd = ["ls-tree", "-z", "-r", branch]
|
||||
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
gitdir=self._work_git,
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
|
||||
"""Event logging in the git trace2 EVENT format."""
|
||||
|
||||
from git_command import GetEventTargetPath
|
||||
from git_command import RepoSourceVersion
|
||||
import git_command
|
||||
from git_trace2_event_log_base import BaseEventLog
|
||||
|
||||
|
||||
@@ -37,7 +36,9 @@ class EventLog(BaseEventLog):
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(repo_source_version=RepoSourceVersion(), **kwargs)
|
||||
super().__init__(
|
||||
repo_source_version=git_command.RepoSourceVersion(), **kwargs
|
||||
)
|
||||
|
||||
def Write(self, path=None, **kwargs):
|
||||
if path is None:
|
||||
@@ -45,4 +46,4 @@ class EventLog(BaseEventLog):
|
||||
return super().Write(path=path, **kwargs)
|
||||
|
||||
def _GetEventTargetPath(self):
|
||||
return GetEventTargetPath()
|
||||
return git_command.GetEventTargetPath()
|
||||
@@ -19,8 +19,8 @@ import sys
|
||||
import traceback
|
||||
import urllib.parse
|
||||
|
||||
from error import HookError
|
||||
from git_refs import HEAD
|
||||
import error
|
||||
import git_refs
|
||||
|
||||
|
||||
# The API we've documented to hook authors. Keep in sync with repo-hooks.md.
|
||||
@@ -142,7 +142,7 @@ class RepoHook:
|
||||
# NOTE: Local (non-committed) changes will not be factored into this
|
||||
# hash. I think this is OK, since we're really only worried about
|
||||
# warning the user about upstream changes.
|
||||
return self._hooks_project.work_git.rev_parse(HEAD)
|
||||
return self._hooks_project.work_git.rev_parse(git_refs.HEAD)
|
||||
|
||||
def _GetMustVerb(self):
|
||||
"""Return 'must' if the hook is required; 'should' if not."""
|
||||
@@ -228,7 +228,7 @@ class RepoHook:
|
||||
|
||||
# For anything else, we'll assume no approval.
|
||||
if self._abort_if_user_denies:
|
||||
raise HookError(
|
||||
raise error.HookError(
|
||||
"You must allow the %s hook or use --no-verify."
|
||||
% self._hook_type
|
||||
)
|
||||
@@ -326,14 +326,16 @@ class RepoHook:
|
||||
try:
|
||||
exec(compile(data, self._script_fullpath, "exec"), context)
|
||||
except Exception:
|
||||
raise HookError(
|
||||
raise error.HookError(
|
||||
"%s\nFailed to import %s hook; see traceback above."
|
||||
% (traceback.format_exc(), self._hook_type)
|
||||
)
|
||||
|
||||
# Running the script should have defined a main() function.
|
||||
if "main" not in context:
|
||||
raise HookError('Missing main() in: "%s"' % self._script_fullpath)
|
||||
raise error.HookError(
|
||||
'Missing main() in: "%s"' % self._script_fullpath
|
||||
)
|
||||
|
||||
# Call the main function in the hook. If the hook should cause the
|
||||
# build to fail, it will raise an Exception. We'll catch that convert
|
||||
@@ -341,7 +343,7 @@ class RepoHook:
|
||||
try:
|
||||
context["main"](**kwargs)
|
||||
except Exception:
|
||||
raise HookError(
|
||||
raise error.HookError(
|
||||
"%s\nFailed to run main() for %s hook; see traceback "
|
||||
"above." % (traceback.format_exc(), self._hook_type)
|
||||
)
|
||||
@@ -394,7 +396,7 @@ class RepoHook:
|
||||
if interp:
|
||||
prog = os.path.basename(interp)
|
||||
if prog.startswith("python2"):
|
||||
raise HookError("Python 2 is not supported")
|
||||
raise error.HookError("Python 2 is not supported")
|
||||
|
||||
# Run the hook by importing directly.
|
||||
self._ExecuteHookViaImport(data, context, **kwargs)
|
||||
@@ -406,7 +408,7 @@ class RepoHook:
|
||||
def _CheckHook(self):
|
||||
# Bail with a nice error if we can't find the hook.
|
||||
if not os.path.isfile(self._script_fullpath):
|
||||
raise HookError(
|
||||
raise error.HookError(
|
||||
"Couldn't find repo hook: %s" % self._script_fullpath
|
||||
)
|
||||
|
||||
@@ -473,7 +475,7 @@ class RepoHook:
|
||||
% (self._hook_type, str(e)),
|
||||
file=sys.stderr,
|
||||
)
|
||||
except HookError as e:
|
||||
except error.HookError as e:
|
||||
passed = False
|
||||
print("ERROR: %s" % str(e), file=sys.stderr)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import time
|
||||
from typing import Optional
|
||||
import urllib.request
|
||||
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
|
||||
|
||||
try:
|
||||
@@ -45,34 +45,22 @@ from color import SetDefaultColoring
|
||||
from command import InteractiveCommand
|
||||
from command import MirrorSafeCommand
|
||||
from editor import Editor
|
||||
from error import DownloadError
|
||||
from error import InvalidProjectGroupsError
|
||||
from error import ManifestInvalidRevisionError
|
||||
from error import ManifestParseError
|
||||
from error import NoManifestException
|
||||
from error import NoSuchProjectError
|
||||
from error import RepoChangedException
|
||||
from error import RepoError
|
||||
from error import RepoExitError
|
||||
from error import RepoUnhandledExceptionError
|
||||
from error import SilentRepoExitError
|
||||
import error
|
||||
import event_log
|
||||
from git_command import user_agent
|
||||
import git_command
|
||||
from git_config import RepoConfig
|
||||
from git_trace2_event_log import EventLog
|
||||
from manifest_xml import RepoClient
|
||||
from pager import RunPager
|
||||
from pager import TerminatePager
|
||||
from repo_trace import SetTrace
|
||||
from repo_trace import SetTraceToStderr
|
||||
from repo_trace import Trace
|
||||
import repo_trace
|
||||
from subcmds import all_commands
|
||||
from subcmds.version import Version
|
||||
from wrapper import Wrapper
|
||||
from wrapper import WrapperPath
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
# NB: These do not need to be kept in sync with the repo launcher script.
|
||||
@@ -279,7 +267,7 @@ class _Repo:
|
||||
run = (
|
||||
lambda: self._RunLong(name, gopts, argv, git_trace2_event_log) or 0
|
||||
)
|
||||
with Trace(
|
||||
with repo_trace.Trace(
|
||||
"starting new command: %s [sid=%s]",
|
||||
", ".join([name] + argv),
|
||||
git_trace2_event_log.full_sid,
|
||||
@@ -435,7 +423,7 @@ class _Repo:
|
||||
try:
|
||||
copts, cargs = cmd.OptionParser.parse_args(argv)
|
||||
copts = cmd.ReadEnvironmentOptions(copts)
|
||||
except NoManifestException as e:
|
||||
except error.NoManifestException as e:
|
||||
logger.error("error: in `%s`: %s", " ".join([name] + argv), e)
|
||||
logger.error(
|
||||
"error: manifest missing or unreadable -- please run init"
|
||||
@@ -511,23 +499,23 @@ class _Repo:
|
||||
KeyboardInterrupt,
|
||||
SystemExit,
|
||||
Exception,
|
||||
RepoExitError,
|
||||
error.RepoExitError,
|
||||
) as e:
|
||||
ok = isinstance(e, SystemExit) and not e.code
|
||||
exception_name = type(e).__name__
|
||||
if isinstance(e, RepoUnhandledExceptionError):
|
||||
if isinstance(e, error.RepoUnhandledExceptionError):
|
||||
exception_name = type(e.error).__name__
|
||||
if isinstance(e, RepoExitError):
|
||||
if isinstance(e, error.RepoExitError):
|
||||
aggregated_errors = e.aggregate_errors or []
|
||||
for error in aggregated_errors:
|
||||
for err in aggregated_errors:
|
||||
project = None
|
||||
if isinstance(error, RepoError):
|
||||
project = error.project
|
||||
if isinstance(err, error.RepoError):
|
||||
project = err.project
|
||||
error_info = json.dumps(
|
||||
{
|
||||
"ErrorType": type(error).__name__,
|
||||
"ErrorType": type(err).__name__,
|
||||
"Project": str(project),
|
||||
"Message": str(error),
|
||||
"Message": str(err),
|
||||
}
|
||||
)
|
||||
git_trace2_event_log.ErrorEvent(
|
||||
@@ -542,24 +530,24 @@ class _Repo:
|
||||
try:
|
||||
execute_command()
|
||||
except (
|
||||
DownloadError,
|
||||
ManifestInvalidRevisionError,
|
||||
ManifestParseError,
|
||||
NoManifestException,
|
||||
error.DownloadError,
|
||||
error.ManifestInvalidRevisionError,
|
||||
error.ManifestParseError,
|
||||
error.NoManifestException,
|
||||
) as e:
|
||||
logger.error("error: in `%s`: %s", " ".join([name] + argv), e)
|
||||
if isinstance(e, NoManifestException):
|
||||
if isinstance(e, error.NoManifestException):
|
||||
logger.error(
|
||||
"error: manifest missing or unreadable -- please run init"
|
||||
)
|
||||
result = e.exit_code
|
||||
except NoSuchProjectError as e:
|
||||
except error.NoSuchProjectError as e:
|
||||
if e.name:
|
||||
logger.error("error: project %s not found", e.name)
|
||||
else:
|
||||
logger.error("error: no project in current directory")
|
||||
result = e.exit_code
|
||||
except InvalidProjectGroupsError as e:
|
||||
except error.InvalidProjectGroupsError as e:
|
||||
if e.name:
|
||||
logger.error(
|
||||
"error: project group must be enabled for project %s",
|
||||
@@ -578,7 +566,7 @@ class _Repo:
|
||||
except KeyboardInterrupt:
|
||||
result = KEYBOARD_INTERRUPT_EXIT
|
||||
raise
|
||||
except RepoExitError as e:
|
||||
except error.RepoExitError as e:
|
||||
result = e.exit_code
|
||||
raise
|
||||
except Exception:
|
||||
@@ -712,11 +700,11 @@ def _PruneOptions(argv, opt):
|
||||
|
||||
class _UserAgentHandler(urllib.request.BaseHandler):
|
||||
def http_request(self, req):
|
||||
req.add_header("User-Agent", user_agent.repo)
|
||||
req.add_header("User-Agent", git_command.user_agent.repo)
|
||||
return req
|
||||
|
||||
def https_request(self, req):
|
||||
req.add_header("User-Agent", user_agent.repo)
|
||||
req.add_header("User-Agent", git_command.user_agent.repo)
|
||||
return req
|
||||
|
||||
|
||||
@@ -943,20 +931,20 @@ def _Main(argv):
|
||||
name, gopts, argv = repo._ParseArgs(argv)
|
||||
|
||||
if gopts.trace:
|
||||
SetTrace()
|
||||
repo_trace.SetTrace()
|
||||
|
||||
if gopts.trace_to_stderr:
|
||||
SetTraceToStderr()
|
||||
repo_trace.SetTraceToStderr()
|
||||
|
||||
result = repo._Run(name, gopts, argv) or 0
|
||||
except RepoExitError as e:
|
||||
if not isinstance(e, SilentRepoExitError):
|
||||
except error.RepoExitError as e:
|
||||
if not isinstance(e, error.SilentRepoExitError):
|
||||
logger.log_aggregated_errors(e)
|
||||
result = e.exit_code
|
||||
except KeyboardInterrupt:
|
||||
print("aborted by user", file=sys.stderr)
|
||||
result = KEYBOARD_INTERRUPT_EXIT
|
||||
except RepoChangedException as rce:
|
||||
except error.RepoChangedException as rce:
|
||||
# If repo changed, re-exec ourselves.
|
||||
argv = list(sys.argv)
|
||||
argv.extend(rce.extra_args)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "October 2026" "repo config" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo config - manual page for repo config
|
||||
.SH SYNOPSIS
|
||||
.B repo
|
||||
\fI\,config \/\fR[\fI\,--type=<type>\/\fR] [\fI\,-z\/\fR] \fI\,get <name>\/\fR
|
||||
.SH DESCRIPTION
|
||||
Summary
|
||||
.PP
|
||||
Read and write per\-user repo settings
|
||||
.PP
|
||||
repo config [\-\-type=<type>] set <name> <value>
|
||||
repo config unset <name>
|
||||
repo config [\-z] list
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-\-type\fR=\fI\,TYPE\/\fR
|
||||
validate and canonicalize the value as this type for get and set (bool, int)
|
||||
.TP
|
||||
\fB\-z\fR, \fB\-\-null\fR
|
||||
terminate output of get and list with NUL instead of newline, and separate key and value with a newline
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
show all output
|
||||
.TP
|
||||
\fB\-q\fR, \fB\-\-quiet\fR
|
||||
only show errors
|
||||
.SS Multi\-manifest options:
|
||||
.TP
|
||||
\fB\-\-outer\-manifest\fR
|
||||
operate starting at the outermost manifest
|
||||
.TP
|
||||
\fB\-\-no\-outer\-manifest\fR
|
||||
do not operate on outer manifests
|
||||
.TP
|
||||
\fB\-\-this\-manifest\-only\fR
|
||||
only operate on this (sub)manifest
|
||||
.TP
|
||||
\fB\-\-no\-this\-manifest\-only\fR, \fB\-\-all\-manifests\fR
|
||||
operate on this manifest and its submanifests
|
||||
.PP
|
||||
Run `repo help config` to view the detailed manual.
|
||||
.SH DETAILS
|
||||
.PP
|
||||
The 'repo config' command reads and writes repo's own per\-user settings. These
|
||||
are separate from git's config and from a checkout's settings; see
|
||||
docs/repoconfig.md for where they live and which settings repo understands.
|
||||
.PP
|
||||
Settings are named <section>.<name>, e.g. sync.smartsync. 'get' prints every
|
||||
value of the setting and exits 1 without output if it is not set, like `git
|
||||
config`.
|
||||
.PP
|
||||
Values are stored as strings. With \fB\-\-type\fR, 'set' validates the value and stores
|
||||
it in canonical form (e.g. "on" as "true", "1k" as "1024"), and 'get' prints the
|
||||
canonical form, like `git config \fB\-\-type\fR`.
|
||||
@@ -103,6 +103,12 @@ use the manifest superproject to sync projects; implies \fB\-c\fR
|
||||
\fB\-\-no\-use\-superproject\fR
|
||||
disable use of manifest superprojects
|
||||
.TP
|
||||
\fB\-s\fR, \fB\-\-smart\-sync\fR
|
||||
default to smart sync when syncing projects
|
||||
.TP
|
||||
\fB\-\-no\-smart\-sync\fR, \fB\-\-head\fR
|
||||
do not default to smart sync when syncing projects
|
||||
.TP
|
||||
\fB\-\-clone\-bundle\fR
|
||||
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS (default if not \fB\-\-partial\-clone\fR)
|
||||
.TP
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ do not run garbage collection on any projects (default)
|
||||
\fB\-s\fR, \fB\-\-smart\-sync\fR
|
||||
smart sync using manifest from the latest known good build
|
||||
.TP
|
||||
\fB\-\-no\-smart\-sync\fR
|
||||
\fB\-\-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
|
||||
|
||||
+4
-1
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "June 2026" "repo" "Repo Manual"
|
||||
.TH REPO "1" "October 2026" "repo" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repository management tool built on top of git
|
||||
.SH SYNOPSIS
|
||||
@@ -65,6 +65,9 @@ Checkout a branch for development
|
||||
cherry\-pick
|
||||
Cherry\-pick a change.
|
||||
.TP
|
||||
config
|
||||
Read and write per\-user repo settings
|
||||
.TP
|
||||
diff
|
||||
Show changes between commit and working tree
|
||||
.TP
|
||||
|
||||
+63
-60
@@ -21,12 +21,9 @@ import sys
|
||||
import urllib.parse
|
||||
import xml.dom.minidom
|
||||
|
||||
from error import ManifestInvalidPathError
|
||||
from error import ManifestInvalidRevisionError
|
||||
from error import ManifestParseError
|
||||
import error
|
||||
from git_config import GitConfig
|
||||
from git_refs import HEAD
|
||||
from git_refs import R_HEADS
|
||||
import git_refs
|
||||
from git_superproject import Superproject
|
||||
import platform_utils
|
||||
from project import Annotation
|
||||
@@ -114,7 +111,9 @@ def XmlInt(node, attr, default=None):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise ManifestParseError(f'manifest: invalid {attr}="{value}" integer')
|
||||
raise error.ManifestParseError(
|
||||
f'manifest: invalid {attr}="{value}" integer'
|
||||
)
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
@@ -290,7 +289,7 @@ class _XmlSubmanifest:
|
||||
self.annotations = []
|
||||
outer_client = parent._outer_client or parent
|
||||
if self.remote and not self.project:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
f"Submanifest {name}: must specify project when remote is "
|
||||
"given."
|
||||
)
|
||||
@@ -424,7 +423,7 @@ class XmlManifest:
|
||||
# This avoids a trailing os.path.sep when submanifest_path is empty.
|
||||
self.topdir = os.path.join(self.topdir, submanifest_path)
|
||||
if manifest_file != os.path.abspath(manifest_file):
|
||||
raise ManifestParseError("manifest_file must be abspath")
|
||||
raise error.ManifestParseError("manifest_file must be abspath")
|
||||
self.manifestFile = manifest_file
|
||||
if not outer_client or outer_client == self:
|
||||
# manifestFileOverrides only exists in the outer_client's manifest,
|
||||
@@ -438,7 +437,9 @@ class XmlManifest:
|
||||
|
||||
if submanifest_path and not outer_client:
|
||||
# If passing a submanifest_path, there must be an outer_client.
|
||||
raise ManifestParseError(f"Bad call to {self.__class__.__name__}")
|
||||
raise error.ManifestParseError(
|
||||
f"Bad call to {self.__class__.__name__}"
|
||||
)
|
||||
|
||||
# If self._outer_client is None, this is not a checkout that supports
|
||||
# multi-tree.
|
||||
@@ -477,7 +478,7 @@ class XmlManifest:
|
||||
if path is None:
|
||||
path = os.path.join(self.manifestProject.worktree, name)
|
||||
if not os.path.isfile(path):
|
||||
raise ManifestParseError("manifest %s not found" % name)
|
||||
raise error.ManifestParseError("manifest %s not found" % name)
|
||||
|
||||
self._load_local_manifests = load_local_manifests
|
||||
self._outer_client.manifestFileOverrides[self.path_prefix] = path
|
||||
@@ -709,7 +710,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if self.IsMirror:
|
||||
value = p.bare_git.ResolveCommit(p.revisionExpr)
|
||||
else:
|
||||
value = p.work_git.ResolveCommit(HEAD)
|
||||
value = p.work_git.ResolveCommit(git_refs.HEAD)
|
||||
e.setAttribute("revision", value)
|
||||
if peg_rev_upstream:
|
||||
if p.upstream:
|
||||
@@ -863,7 +864,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
elif child.nodeName in MULTI_ELEMENTS:
|
||||
ret.setdefault(child.nodeName, []).append(element)
|
||||
else:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
f'Unhandled element "{child.nodeName}"'
|
||||
)
|
||||
|
||||
@@ -1188,7 +1189,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
def _Load(self, initial_client=None, submanifest_depth=0):
|
||||
if submanifest_depth > MAX_SUBMANIFEST_DEPTH:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"maximum submanifest depth %d exceeded." % MAX_SUBMANIFEST_DEPTH
|
||||
)
|
||||
if not self._loaded:
|
||||
@@ -1206,8 +1207,8 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
try:
|
||||
m = self.manifestProject
|
||||
b = m.GetBranch(m.CurrentBranch).merge
|
||||
if b is not None and b.startswith(R_HEADS):
|
||||
b = b[len(R_HEADS) :]
|
||||
if b is not None and b.startswith(git_refs.R_HEADS):
|
||||
b = b[len(git_refs.R_HEADS) :]
|
||||
self.branch = b
|
||||
|
||||
parent_groups = self.parent_groups.copy()
|
||||
@@ -1260,7 +1261,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
try:
|
||||
self._ParseManifest(nodes)
|
||||
except ManifestParseError as e:
|
||||
except error.ManifestParseError as e:
|
||||
# There was a problem parsing, unload ourselves in case they
|
||||
# catch this error and try again later, we will show the
|
||||
# correct error
|
||||
@@ -1322,10 +1323,12 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
try:
|
||||
root = xml.dom.minidom.parse(path)
|
||||
except (OSError, xml.parsers.expat.ExpatError) as e:
|
||||
raise ManifestParseError(f"error parsing manifest {path}: {e}")
|
||||
raise error.ManifestParseError(
|
||||
f"error parsing manifest {path}: {e}"
|
||||
)
|
||||
|
||||
if not root or not root.childNodes:
|
||||
raise ManifestParseError(f"no root node in {path}")
|
||||
raise error.ManifestParseError(f"no root node in {path}")
|
||||
|
||||
for manifest in root.childNodes:
|
||||
if (
|
||||
@@ -1334,7 +1337,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
):
|
||||
break
|
||||
else:
|
||||
raise ManifestParseError(f"no <manifest> in {path}")
|
||||
raise error.ManifestParseError(f"no <manifest> in {path}")
|
||||
|
||||
nodes = []
|
||||
for node in manifest.childNodes:
|
||||
@@ -1351,7 +1354,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if restrict_includes:
|
||||
msg = self._CheckLocalPath(name)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<include> invalid "name": {name}: {msg}'
|
||||
)
|
||||
include_groups = (parent_groups or set()).copy()
|
||||
@@ -1361,7 +1364,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
)
|
||||
fp = os.path.join(include_root, name)
|
||||
if not os.path.isfile(fp):
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"include [%s/]%s doesn't exist or isn't a file"
|
||||
% (include_root, name)
|
||||
)
|
||||
@@ -1373,10 +1376,10 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
)
|
||||
# should isolate this to the exact exception, but that's
|
||||
# tricky. actual parsing implementation may vary.
|
||||
except (RuntimeError, ManifestParseError):
|
||||
except (RuntimeError, error.ManifestParseError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
f"failed parsing included manifest {name}: {e}"
|
||||
)
|
||||
else:
|
||||
@@ -1400,7 +1403,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if remote:
|
||||
if remote.name in self._remotes:
|
||||
if remote != self._remotes[remote.name]:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"remote %s already exists with different "
|
||||
"attributes" % (remote.name)
|
||||
)
|
||||
@@ -1416,7 +1419,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if self._default is None:
|
||||
self._default = new_default
|
||||
elif not emptyDefault and new_default != self._default:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate default in %s" % (self.manifestFile)
|
||||
)
|
||||
|
||||
@@ -1430,7 +1433,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if submanifest:
|
||||
if submanifest.name in self._submanifests:
|
||||
if submanifest != self._submanifests[submanifest.name]:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"submanifest %s already exists with different "
|
||||
"attributes" % (submanifest.name)
|
||||
)
|
||||
@@ -1441,7 +1444,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
for node in itertools.chain(*node_list):
|
||||
if node.nodeName == "notice":
|
||||
if self._notice is not None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate notice in %s" % (self.manifestFile)
|
||||
)
|
||||
self._notice = self._ParseNotice(node)
|
||||
@@ -1451,7 +1454,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
url = self._reqatt(node, "url")
|
||||
helper = node.getAttribute("helper") or None
|
||||
if self._manifest_server is not None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate manifest-server in %s" % (self.manifestFile)
|
||||
)
|
||||
self._manifest_server = url
|
||||
@@ -1460,18 +1463,18 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
def recursively_add_projects(project):
|
||||
projects = self._projects.setdefault(project.name, [])
|
||||
if project.relpath is None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"missing path for %s in %s"
|
||||
% (project.name, self.manifestFile)
|
||||
)
|
||||
if project.relpath in self._paths:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate path %s in %s"
|
||||
% (project.relpath, self.manifestFile)
|
||||
)
|
||||
for tree in submanifest_paths:
|
||||
if project.relpath.startswith(tree):
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"project %s conflicts with submanifest path %s"
|
||||
% (project.relpath, tree)
|
||||
)
|
||||
@@ -1491,7 +1494,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
name = self._reqatt(node, "name")
|
||||
|
||||
if name not in self._projects:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"extend-project element specifies non-existent "
|
||||
"project: %s" % name
|
||||
)
|
||||
@@ -1513,7 +1516,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
named_projects = self._projects[name]
|
||||
if dest_path and not path and len(named_projects) > 1:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"extend-project cannot use dest-path when "
|
||||
"matching multiple projects: %s" % name
|
||||
)
|
||||
@@ -1570,7 +1573,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if node.nodeName == "repo-hooks":
|
||||
# Only one project can be the hooks project
|
||||
if repo_hooks_project is not None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate repo-hooks in %s" % (self.manifestFile)
|
||||
)
|
||||
|
||||
@@ -1584,7 +1587,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
name = self._reqatt(node, "name")
|
||||
# There can only be one superproject.
|
||||
if self._superproject:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"duplicate superproject in %s" % (self.manifestFile)
|
||||
)
|
||||
remote_name = node.getAttribute("remote")
|
||||
@@ -1593,7 +1596,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
else:
|
||||
remote = self._get_remote(node)
|
||||
if remote is None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"no remote for superproject %s within %s"
|
||||
% (name, self.manifestFile)
|
||||
)
|
||||
@@ -1601,7 +1604,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if not revision:
|
||||
revision = self._default.revisionExpr
|
||||
if not revision:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"no revision for superproject %s within %s"
|
||||
% (name, self.manifestFile)
|
||||
)
|
||||
@@ -1624,7 +1627,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
# Name or path needed.
|
||||
if not name and not path:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"remove-project must have name and/or path"
|
||||
)
|
||||
|
||||
@@ -1669,14 +1672,14 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
repo_hooks_project = None
|
||||
|
||||
if not removed_project and not XmlBool(node, "optional", False):
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"remove-project element specifies non-existent "
|
||||
"project: %s" % node.toxml()
|
||||
)
|
||||
|
||||
if failed_revision_changes:
|
||||
fail_string = "\n".join(failed_revision_changes)
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
f"detected base-revision mismatch, updates needed:\n"
|
||||
f"{fail_string}",
|
||||
)
|
||||
@@ -1687,12 +1690,12 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
try:
|
||||
repo_hooks_projects = self._projects[repo_hooks_project]
|
||||
except KeyError:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"project %s not found for repo-hooks" % (repo_hooks_project)
|
||||
)
|
||||
|
||||
if len(repo_hooks_projects) != 1:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"internal error parsing repo-hooks in %s"
|
||||
% (self.manifestFile)
|
||||
)
|
||||
@@ -1704,7 +1707,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
name = None
|
||||
m_url = m.GetRemote().url
|
||||
if m_url.endswith("/.git"):
|
||||
raise ManifestParseError("refusing to mirror %s" % m_url)
|
||||
raise error.ManifestParseError("refusing to mirror %s" % m_url)
|
||||
|
||||
if self._default and self._default.remote:
|
||||
url = self._default.remote.resolvedFetchUrl
|
||||
@@ -1789,14 +1792,14 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
d.sync_j = XmlInt(node, "sync-j", None)
|
||||
if d.sync_j is not None and d.sync_j <= 0:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
'%s: sync-j must be greater than 0, not "%s"'
|
||||
% (self.manifestFile, d.sync_j)
|
||||
)
|
||||
|
||||
d.sync_j_max = XmlInt(node, "sync-j-max", None)
|
||||
if d.sync_j_max is not None and d.sync_j_max <= 0:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
'%s: sync-j-max must be greater than 0, not "%s"'
|
||||
% (self.manifestFile, d.sync_j_max)
|
||||
)
|
||||
@@ -1872,20 +1875,20 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if revision:
|
||||
msg = self._CheckLocalPath(revision.split("/")[-1])
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
'<submanifest> invalid "revision": %s: %s'
|
||||
% (revision, msg)
|
||||
)
|
||||
else:
|
||||
msg = self._CheckLocalPath(name)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<submanifest> invalid "name": {name}: {msg}'
|
||||
)
|
||||
else:
|
||||
msg = self._CheckLocalPath(path)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<submanifest> invalid "path": {path}: {msg}'
|
||||
)
|
||||
|
||||
@@ -1920,7 +1923,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
name = self._reqatt(node, "name")
|
||||
msg = self._CheckLocalPath(name, dir_ok=True)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<project> invalid "name": {name}: {msg}'
|
||||
)
|
||||
if parent:
|
||||
@@ -1930,7 +1933,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if remote is None:
|
||||
remote = self._default.remote
|
||||
if remote is None:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
f"no remote for project {name} within {self.manifestFile}"
|
||||
)
|
||||
|
||||
@@ -1938,7 +1941,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if not revisionExpr:
|
||||
revisionExpr = self._default.revisionExpr
|
||||
if not revisionExpr:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"no revision for project %s within %s"
|
||||
% (name, self.manifestFile)
|
||||
)
|
||||
@@ -1951,7 +1954,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
# Project.Sync_LocalHalf.
|
||||
msg = self._CheckLocalPath(path, dir_ok=True, cwd_dot_ok=True)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<project> invalid "path": {path}: {msg}'
|
||||
)
|
||||
|
||||
@@ -1962,7 +1965,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
clone_depth = XmlInt(node, "clone-depth")
|
||||
if clone_depth is not None and clone_depth <= 0:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
'%s: clone-depth must be greater than 0, not "%s"'
|
||||
% (self.manifestFile, clone_depth)
|
||||
)
|
||||
@@ -2256,7 +2259,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
# It is relative to the top of the repo client checkout.
|
||||
msg = cls._CheckLocalPath(dest)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<{element}> invalid "dest": {dest}: {msg}'
|
||||
)
|
||||
|
||||
@@ -2267,7 +2270,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
src, dir_ok=is_linkfile, cwd_dot_ok=is_linkfile
|
||||
)
|
||||
if msg:
|
||||
raise ManifestInvalidPathError(
|
||||
raise error.ManifestInvalidPathError(
|
||||
f'<{element}> invalid "src": {src}: {msg}'
|
||||
)
|
||||
|
||||
@@ -2296,10 +2299,10 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
value = self._reqatt(node, "value")
|
||||
try:
|
||||
keep = self._reqatt(node, "keep").lower()
|
||||
except ManifestParseError:
|
||||
except error.ManifestParseError:
|
||||
keep = "true"
|
||||
if keep != "true" and keep != "false":
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
'optional "keep" attribute must be ' '"true" or "false"'
|
||||
)
|
||||
element.AddAnnotation(name, value, keep)
|
||||
@@ -2311,7 +2314,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
|
||||
v = self._remotes.get(name)
|
||||
if not v:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
f"remote {name} not defined in {self.manifestFile}"
|
||||
)
|
||||
return v
|
||||
@@ -2322,7 +2325,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
"""
|
||||
v = node.getAttribute(attname)
|
||||
if not v:
|
||||
raise ManifestParseError(
|
||||
raise error.ManifestParseError(
|
||||
"no %s in <%s> within %s"
|
||||
% (attname, node.nodeName, self.manifestFile)
|
||||
)
|
||||
@@ -2360,7 +2363,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
try:
|
||||
fromRevId = fromProj.GetCommitRevisionId()
|
||||
toRevId = toProj.GetCommitRevisionId()
|
||||
except ManifestInvalidRevisionError:
|
||||
except error.ManifestInvalidRevisionError:
|
||||
diff["unreachable"].append((fromProj, toProj))
|
||||
else:
|
||||
if fromRevId != toRevId:
|
||||
|
||||
+62
-80
@@ -12,32 +12,14 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from ctypes import addressof
|
||||
from ctypes import byref
|
||||
from ctypes import c_buffer
|
||||
from ctypes import c_ubyte
|
||||
from ctypes import FormatError
|
||||
from ctypes import get_last_error
|
||||
from ctypes import Structure
|
||||
from ctypes import Union
|
||||
from ctypes import WinDLL
|
||||
from ctypes import WinError
|
||||
from ctypes.wintypes import BOOL
|
||||
from ctypes.wintypes import BOOLEAN
|
||||
from ctypes.wintypes import DWORD
|
||||
from ctypes.wintypes import HANDLE
|
||||
from ctypes.wintypes import LPCWSTR
|
||||
from ctypes.wintypes import LPDWORD
|
||||
from ctypes.wintypes import LPVOID
|
||||
from ctypes.wintypes import ULONG
|
||||
from ctypes.wintypes import USHORT
|
||||
from ctypes.wintypes import WCHAR
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import errno
|
||||
|
||||
|
||||
kernel32 = WinDLL("kernel32", use_last_error=True)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
|
||||
UCHAR = c_ubyte
|
||||
UCHAR = ctypes.c_ubyte
|
||||
|
||||
# Win32 error codes
|
||||
ERROR_SUCCESS = 0
|
||||
@@ -46,11 +28,11 @@ ERROR_PRIVILEGE_NOT_HELD = 1314
|
||||
|
||||
# Win32 API entry points
|
||||
CreateSymbolicLinkW = kernel32.CreateSymbolicLinkW
|
||||
CreateSymbolicLinkW.restype = BOOLEAN
|
||||
CreateSymbolicLinkW.restype = wintypes.BOOLEAN
|
||||
CreateSymbolicLinkW.argtypes = (
|
||||
LPCWSTR, # lpSymlinkFileName In
|
||||
LPCWSTR, # lpTargetFileName In
|
||||
DWORD, # dwFlags In
|
||||
wintypes.LPCWSTR, # lpSymlinkFileName In
|
||||
wintypes.LPCWSTR, # lpTargetFileName In
|
||||
wintypes.DWORD, # dwFlags In
|
||||
)
|
||||
|
||||
# Symbolic link creation flags
|
||||
@@ -61,44 +43,44 @@ SYMBOLIC_LINK_FLAG_DIRECTORY = 0x01
|
||||
SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE = 0x02
|
||||
|
||||
GetFileAttributesW = kernel32.GetFileAttributesW
|
||||
GetFileAttributesW.restype = DWORD
|
||||
GetFileAttributesW.argtypes = (LPCWSTR,) # lpFileName In
|
||||
GetFileAttributesW.restype = wintypes.DWORD
|
||||
GetFileAttributesW.argtypes = (wintypes.LPCWSTR,) # lpFileName In
|
||||
|
||||
INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF
|
||||
FILE_ATTRIBUTE_REPARSE_POINT = 0x00400
|
||||
|
||||
CreateFileW = kernel32.CreateFileW
|
||||
CreateFileW.restype = HANDLE
|
||||
CreateFileW.restype = wintypes.HANDLE
|
||||
CreateFileW.argtypes = (
|
||||
LPCWSTR, # lpFileName In
|
||||
DWORD, # dwDesiredAccess In
|
||||
DWORD, # dwShareMode In
|
||||
LPVOID, # lpSecurityAttributes In_opt
|
||||
DWORD, # dwCreationDisposition In
|
||||
DWORD, # dwFlagsAndAttributes In
|
||||
HANDLE, # hTemplateFile In_opt
|
||||
wintypes.LPCWSTR, # lpFileName In
|
||||
wintypes.DWORD, # dwDesiredAccess In
|
||||
wintypes.DWORD, # dwShareMode In
|
||||
wintypes.LPVOID, # lpSecurityAttributes In_opt
|
||||
wintypes.DWORD, # dwCreationDisposition In
|
||||
wintypes.DWORD, # dwFlagsAndAttributes In
|
||||
wintypes.HANDLE, # hTemplateFile In_opt
|
||||
)
|
||||
|
||||
CloseHandle = kernel32.CloseHandle
|
||||
CloseHandle.restype = BOOL
|
||||
CloseHandle.argtypes = (HANDLE,) # hObject In
|
||||
CloseHandle.restype = wintypes.BOOL
|
||||
CloseHandle.argtypes = (wintypes.HANDLE,) # hObject In
|
||||
|
||||
INVALID_HANDLE_VALUE = HANDLE(-1).value
|
||||
INVALID_HANDLE_VALUE = wintypes.HANDLE(-1).value
|
||||
OPEN_EXISTING = 3
|
||||
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
|
||||
FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000
|
||||
|
||||
DeviceIoControl = kernel32.DeviceIoControl
|
||||
DeviceIoControl.restype = BOOL
|
||||
DeviceIoControl.restype = wintypes.BOOL
|
||||
DeviceIoControl.argtypes = (
|
||||
HANDLE, # hDevice In
|
||||
DWORD, # dwIoControlCode In
|
||||
LPVOID, # lpInBuffer In_opt
|
||||
DWORD, # nInBufferSize In
|
||||
LPVOID, # lpOutBuffer Out_opt
|
||||
DWORD, # nOutBufferSize In
|
||||
LPDWORD, # lpBytesReturned Out_opt
|
||||
LPVOID, # lpOverlapped Inout_opt
|
||||
wintypes.HANDLE, # hDevice In
|
||||
wintypes.DWORD, # dwIoControlCode In
|
||||
wintypes.LPVOID, # lpInBuffer In_opt
|
||||
wintypes.DWORD, # nInBufferSize In
|
||||
wintypes.LPVOID, # lpOutBuffer Out_opt
|
||||
wintypes.DWORD, # nOutBufferSize In
|
||||
wintypes.LPDWORD, # lpBytesReturned Out_opt
|
||||
wintypes.LPVOID, # lpOverlapped Inout_opt
|
||||
)
|
||||
|
||||
# Device I/O control flags and options
|
||||
@@ -108,45 +90,45 @@ IO_REPARSE_TAG_SYMLINK = 0xA000000C
|
||||
MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 0x4000
|
||||
|
||||
|
||||
class GENERIC_REPARSE_BUFFER(Structure):
|
||||
class GENERIC_REPARSE_BUFFER(ctypes.Structure):
|
||||
_fields_ = (("DataBuffer", UCHAR * 1),)
|
||||
|
||||
|
||||
class SYMBOLIC_LINK_REPARSE_BUFFER(Structure):
|
||||
class SYMBOLIC_LINK_REPARSE_BUFFER(ctypes.Structure):
|
||||
_fields_ = (
|
||||
("SubstituteNameOffset", USHORT),
|
||||
("SubstituteNameLength", USHORT),
|
||||
("PrintNameOffset", USHORT),
|
||||
("PrintNameLength", USHORT),
|
||||
("Flags", ULONG),
|
||||
("PathBuffer", WCHAR * 1),
|
||||
("SubstituteNameOffset", wintypes.USHORT),
|
||||
("SubstituteNameLength", wintypes.USHORT),
|
||||
("PrintNameOffset", wintypes.USHORT),
|
||||
("PrintNameLength", wintypes.USHORT),
|
||||
("Flags", wintypes.ULONG),
|
||||
("PathBuffer", wintypes.WCHAR * 1),
|
||||
)
|
||||
|
||||
@property
|
||||
def PrintName(self):
|
||||
arrayt = WCHAR * (self.PrintNameLength // 2)
|
||||
arrayt = wintypes.WCHAR * (self.PrintNameLength // 2)
|
||||
offset = type(self).PathBuffer.offset + self.PrintNameOffset
|
||||
return arrayt.from_address(addressof(self) + offset).value
|
||||
return arrayt.from_address(ctypes.addressof(self) + offset).value
|
||||
|
||||
|
||||
class MOUNT_POINT_REPARSE_BUFFER(Structure):
|
||||
class MOUNT_POINT_REPARSE_BUFFER(ctypes.Structure):
|
||||
_fields_ = (
|
||||
("SubstituteNameOffset", USHORT),
|
||||
("SubstituteNameLength", USHORT),
|
||||
("PrintNameOffset", USHORT),
|
||||
("PrintNameLength", USHORT),
|
||||
("PathBuffer", WCHAR * 1),
|
||||
("SubstituteNameOffset", wintypes.USHORT),
|
||||
("SubstituteNameLength", wintypes.USHORT),
|
||||
("PrintNameOffset", wintypes.USHORT),
|
||||
("PrintNameLength", wintypes.USHORT),
|
||||
("PathBuffer", wintypes.WCHAR * 1),
|
||||
)
|
||||
|
||||
@property
|
||||
def PrintName(self):
|
||||
arrayt = WCHAR * (self.PrintNameLength // 2)
|
||||
arrayt = wintypes.WCHAR * (self.PrintNameLength // 2)
|
||||
offset = type(self).PathBuffer.offset + self.PrintNameOffset
|
||||
return arrayt.from_address(addressof(self) + offset).value
|
||||
return arrayt.from_address(ctypes.addressof(self) + offset).value
|
||||
|
||||
|
||||
class REPARSE_DATA_BUFFER(Structure):
|
||||
class REPARSE_BUFFER(Union):
|
||||
class REPARSE_DATA_BUFFER(ctypes.Structure):
|
||||
class REPARSE_BUFFER(ctypes.Union):
|
||||
_fields_ = (
|
||||
("SymbolicLinkReparseBuffer", SYMBOLIC_LINK_REPARSE_BUFFER),
|
||||
("MountPointReparseBuffer", MOUNT_POINT_REPARSE_BUFFER),
|
||||
@@ -154,9 +136,9 @@ class REPARSE_DATA_BUFFER(Structure):
|
||||
)
|
||||
|
||||
_fields_ = (
|
||||
("ReparseTag", ULONG),
|
||||
("ReparseDataLength", USHORT),
|
||||
("Reserved", USHORT),
|
||||
("ReparseTag", wintypes.ULONG),
|
||||
("ReparseDataLength", wintypes.USHORT),
|
||||
("Reserved", wintypes.USHORT),
|
||||
("ReparseBuffer", REPARSE_BUFFER),
|
||||
)
|
||||
_anonymous_ = ("ReparseBuffer",)
|
||||
@@ -182,8 +164,8 @@ def _create_symlink(source, link_name, dwFlags):
|
||||
# "the unprivileged create flag is unsupported below Windows 10 (1703,
|
||||
# v10.0.14972). retry without it."
|
||||
if not CreateSymbolicLinkW(link_name, source, dwFlags):
|
||||
code = get_last_error()
|
||||
error_desc = FormatError(code).strip()
|
||||
code = ctypes.get_last_error()
|
||||
error_desc = ctypes.FormatError(code).strip()
|
||||
if code == ERROR_PRIVILEGE_NOT_HELD:
|
||||
raise OSError(errno.EPERM, error_desc, link_name)
|
||||
_raise_winerror(code, f'Error creating symbolic link "{link_name}"')
|
||||
@@ -208,10 +190,10 @@ def readlink(path):
|
||||
)
|
||||
if reparse_point_handle == INVALID_HANDLE_VALUE:
|
||||
_raise_winerror(
|
||||
get_last_error(), f'Error opening symbolic link "{path}"'
|
||||
ctypes.get_last_error(), f'Error opening symbolic link "{path}"'
|
||||
)
|
||||
target_buffer = c_buffer(MAXIMUM_REPARSE_DATA_BUFFER_SIZE)
|
||||
n_bytes_returned = DWORD()
|
||||
target_buffer = ctypes.c_buffer(MAXIMUM_REPARSE_DATA_BUFFER_SIZE)
|
||||
n_bytes_returned = wintypes.DWORD()
|
||||
io_result = DeviceIoControl(
|
||||
reparse_point_handle,
|
||||
FSCTL_GET_REPARSE_POINT,
|
||||
@@ -219,13 +201,13 @@ def readlink(path):
|
||||
0,
|
||||
target_buffer,
|
||||
len(target_buffer),
|
||||
byref(n_bytes_returned),
|
||||
ctypes.byref(n_bytes_returned),
|
||||
None,
|
||||
)
|
||||
CloseHandle(reparse_point_handle)
|
||||
if not io_result:
|
||||
_raise_winerror(
|
||||
get_last_error(), f'Error reading symbolic link "{path}"'
|
||||
ctypes.get_last_error(), f'Error reading symbolic link "{path}"'
|
||||
)
|
||||
rdb = REPARSE_DATA_BUFFER.from_buffer(target_buffer)
|
||||
if rdb.ReparseTag == IO_REPARSE_TAG_SYMLINK:
|
||||
@@ -239,6 +221,6 @@ def readlink(path):
|
||||
|
||||
|
||||
def _raise_winerror(code, error_desc):
|
||||
win_error_desc = FormatError(code).strip()
|
||||
win_error_desc = ctypes.FormatError(code).strip()
|
||||
error_desc = f"{error_desc}: {win_error_desc}"
|
||||
raise WinError(code, error_desc)
|
||||
raise ctypes.WinError(code, error_desc)
|
||||
+4
-4
@@ -22,7 +22,7 @@ try:
|
||||
except ImportError:
|
||||
import dummy_threading as _threading
|
||||
|
||||
from repo_trace import IsTraceToStderr
|
||||
import repo_trace
|
||||
|
||||
|
||||
# Capture the original stderr stream. We use this exclusively for progress
|
||||
@@ -166,7 +166,7 @@ class Progress:
|
||||
msg = self._last_msg
|
||||
self._last_msg = msg
|
||||
|
||||
if not _TTY or IsTraceToStderr() or self._quiet:
|
||||
if not _TTY or repo_trace.IsTraceToStderr() or self._quiet:
|
||||
return
|
||||
|
||||
elapsed_sec = time.time() - self._start
|
||||
@@ -211,7 +211,7 @@ class Progress:
|
||||
|
||||
The progress bar is then redrawn on the next line.
|
||||
"""
|
||||
if not _TTY or IsTraceToStderr() or self._quiet:
|
||||
if not _TTY or repo_trace.IsTraceToStderr() or self._quiet:
|
||||
return
|
||||
|
||||
# Erase the current line, print the message with a newline,
|
||||
@@ -227,7 +227,7 @@ class Progress:
|
||||
self._ended = True
|
||||
|
||||
self._update_event.set()
|
||||
if not _TTY or IsTraceToStderr() or self._quiet:
|
||||
if not _TTY or repo_trace.IsTraceToStderr() or self._quiet:
|
||||
return
|
||||
|
||||
duration = duration_str(time.time() - self._start)
|
||||
|
||||
+273
-239
File diff suppressed because it is too large.
Load diff
@@ -43,4 +43,5 @@ single_line_exclusions = ['abc', 'collections.abc', 'typing']
|
||||
[tool.pytest.ini_options]
|
||||
markers = """
|
||||
skip_cq: Skip tests in the CQ. Should be rarely used!
|
||||
e2e: Hermetic end-to-end tests (tests/e2e/). Deselect with -m 'not e2e'.
|
||||
"""
|
||||
@@ -39,7 +39,7 @@ MANDIR = TOPDIR.joinpath("man")
|
||||
|
||||
# Load repo local modules.
|
||||
sys.path.insert(0, str(TOPDIR))
|
||||
from git_command import RepoSourceVersion
|
||||
import git_command
|
||||
import subcmds
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ def main(argv: List[str]) -> int:
|
||||
del subcmds.all_commands["branch"]
|
||||
(MANDIR / "repo-branch.1").write_text(".so man1/repo-branches.1")
|
||||
|
||||
version = RepoSourceVersion()
|
||||
version = git_command.RepoSourceVersion()
|
||||
cmdlist = [
|
||||
[
|
||||
*help2man,
|
||||
|
||||
@@ -129,7 +129,7 @@ if not REPO_REV:
|
||||
BUG_URL = "https://issues.gerritcodereview.com/issues/new?component=1370071"
|
||||
|
||||
# increment this whenever we make important changes to this script
|
||||
VERSION = (2, 69)
|
||||
VERSION = (2, 71)
|
||||
|
||||
# increment this if the MAINTAINER_KEYS block is modified
|
||||
KEYRING_VERSION = (2, 3)
|
||||
@@ -443,6 +443,20 @@ def InitParser(parser):
|
||||
dest="use_superproject",
|
||||
help="disable use of manifest superprojects",
|
||||
)
|
||||
group.add_option(
|
||||
"-s",
|
||||
"--smart-sync",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="default to smart sync when syncing projects",
|
||||
)
|
||||
group.add_option(
|
||||
"--no-smart-sync",
|
||||
"--head",
|
||||
action="store_false",
|
||||
dest="smart_sync",
|
||||
help="do not default to smart sync when syncing projects",
|
||||
)
|
||||
group.add_option(
|
||||
"--clone-bundle",
|
||||
action="store_true",
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@
|
||||
import logging
|
||||
|
||||
from color import Coloring
|
||||
from error import RepoExitError
|
||||
import error
|
||||
|
||||
|
||||
SEPARATOR = "=" * 80
|
||||
@@ -71,7 +71,7 @@ class RepoLogger(logging.Logger):
|
||||
handler.setFormatter(_LogColoringFormatter(config))
|
||||
self.addHandler(handler)
|
||||
|
||||
def log_aggregated_errors(self, err: RepoExitError):
|
||||
def log_aggregated_errors(self, err: error.RepoExitError) -> None:
|
||||
"""Print all aggregated logs."""
|
||||
self.error(SEPARATOR)
|
||||
|
||||
|
||||
@@ -54,10 +54,16 @@ def is_ci() -> bool:
|
||||
return os.getenv("LUCI_CQ") == "yes"
|
||||
|
||||
|
||||
def ci_pytest_args() -> list[str]:
|
||||
"""Returns extra pytest args for CI runs."""
|
||||
if not is_ci():
|
||||
return []
|
||||
return ["-m", "not skip_cq", "--fail-on-skip"]
|
||||
|
||||
|
||||
def run_pytest(argv: list[str]) -> int:
|
||||
"""Returns the exit code from pytest."""
|
||||
if is_ci():
|
||||
argv = ["-m", "not skip_cq"] + argv
|
||||
argv = ci_pytest_args() + argv
|
||||
|
||||
log_cmd("pytest", argv)
|
||||
return subprocess.run(
|
||||
@@ -69,8 +75,7 @@ def run_pytest(argv: list[str]) -> int:
|
||||
|
||||
def run_pytest_py38(argv: list[str]) -> int:
|
||||
"""Returns the exit code from pytest under Python 3.8."""
|
||||
if is_ci():
|
||||
argv = ["-m", "not skip_cq"] + argv
|
||||
argv = ci_pytest_args() + argv
|
||||
|
||||
log_cmd("[vpython 3.8] pytest", argv)
|
||||
try:
|
||||
|
||||
@@ -24,9 +24,9 @@ import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from git_command import git
|
||||
import git_command
|
||||
import platform_utils
|
||||
from repo_trace import Trace
|
||||
import repo_trace
|
||||
|
||||
|
||||
PROXY_PATH = os.path.join(os.path.dirname(__file__), "git_ssh")
|
||||
@@ -201,7 +201,9 @@ class ProxyManager:
|
||||
# is the same as 'myhost.com' where "User git" is setup in the user's
|
||||
# ~/.ssh/config file.
|
||||
check_command = command_base + ["-O", "check"]
|
||||
with Trace("Call to ssh (check call): %s", " ".join(check_command)):
|
||||
with repo_trace.Trace(
|
||||
"Call to ssh (check call): %s", " ".join(check_command)
|
||||
):
|
||||
try:
|
||||
check_process = subprocess.Popen(
|
||||
check_command,
|
||||
@@ -250,7 +252,7 @@ class ProxyManager:
|
||||
)
|
||||
p = None
|
||||
try:
|
||||
with Trace("Call to ssh: %s", " ".join(command)):
|
||||
with repo_trace.Trace("Call to ssh: %s", " ".join(command)):
|
||||
p = subprocess.Popen(command)
|
||||
except Exception as e:
|
||||
self._master_broken.value = True
|
||||
@@ -357,7 +359,7 @@ def _get_git_protocol_version() -> str:
|
||||
if e.returncode == 1:
|
||||
# Exit code 1 means that the git config key was not found.
|
||||
# Try to imitate the defaults that git would have used.
|
||||
git_version = git.version_tuple()
|
||||
git_version = git_command.git.version_tuple()
|
||||
if git_version >= (2, 26, 0):
|
||||
# Since git version 2.26, protocol v2 is the default.
|
||||
return "2"
|
||||
|
||||
+9
-8
@@ -18,17 +18,16 @@ import itertools
|
||||
|
||||
from command import Command
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from error import RepoError
|
||||
from error import RepoExitError
|
||||
from git_command import IsValidBranchName
|
||||
import error
|
||||
import git_command
|
||||
from progress import Progress
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class AbandonError(RepoExitError):
|
||||
class AbandonError(error.RepoExitError):
|
||||
"""Exit error when abandon command fails."""
|
||||
|
||||
|
||||
@@ -58,7 +57,9 @@ It is equivalent to "git branch -D <branchname>".
|
||||
|
||||
if not opt.all:
|
||||
branches = args[0].split()
|
||||
invalid_branches = [x for x in branches if not IsValidBranchName(x)]
|
||||
invalid_branches = [
|
||||
x for x in branches if not git_command.IsValidBranchName(x)
|
||||
]
|
||||
|
||||
if invalid_branches:
|
||||
self.OptionParser.error(
|
||||
@@ -82,7 +83,7 @@ It is equivalent to "git branch -D <branchname>".
|
||||
status = None
|
||||
try:
|
||||
status = project.AbandonBranch(name)
|
||||
except RepoError as e:
|
||||
except error.RepoError as e:
|
||||
status = False
|
||||
errors.append(e)
|
||||
if status is not None:
|
||||
|
||||
+9
-10
@@ -17,13 +17,12 @@ from typing import NamedTuple
|
||||
|
||||
from command import Command
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from error import GitError
|
||||
from error import RepoExitError
|
||||
import error
|
||||
from progress import Progress
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class CheckoutBranchResult(NamedTuple):
|
||||
@@ -33,11 +32,11 @@ class CheckoutBranchResult(NamedTuple):
|
||||
error: Exception
|
||||
|
||||
|
||||
class CheckoutCommandError(RepoExitError):
|
||||
class CheckoutCommandError(error.RepoExitError):
|
||||
"""Exception thrown when checkout command fails."""
|
||||
|
||||
|
||||
class MissingBranchError(RepoExitError):
|
||||
class MissingBranchError(error.RepoExitError):
|
||||
"""Exception thrown when no project has specified branch."""
|
||||
|
||||
|
||||
@@ -64,14 +63,14 @@ The command is equivalent to:
|
||||
@classmethod
|
||||
def _ExecuteOne(cls, nb, project_idx):
|
||||
"""Checkout one project."""
|
||||
error = None
|
||||
err = None
|
||||
result = None
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
try:
|
||||
result = project.CheckoutBranch(nb)
|
||||
except GitError as e:
|
||||
error = e
|
||||
return CheckoutBranchResult(result, project_idx, error)
|
||||
except error.GitError as e:
|
||||
err = e
|
||||
return CheckoutBranchResult(result, project_idx, err)
|
||||
|
||||
def Execute(self, opt, args):
|
||||
nb = args[0]
|
||||
|
||||
+13
-13
@@ -17,13 +17,13 @@ import sys
|
||||
from typing import Tuple
|
||||
|
||||
from command import Command
|
||||
from error import GitError
|
||||
from git_command import GitCommand
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import git_command
|
||||
import repo_logging
|
||||
|
||||
|
||||
CHANGE_ID_RE = re.compile(r"^\s*Change-Id: I([0-9a-f]{40})\s*$")
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class CherryPick(Command):
|
||||
@@ -47,7 +47,7 @@ change id will be added.
|
||||
sha1, commit = self._ResolveReference(reference)
|
||||
old_msg = self._StripHeader(commit)
|
||||
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["cherry-pick", sha1],
|
||||
capture_stdout=True,
|
||||
@@ -57,7 +57,7 @@ change id will be added.
|
||||
|
||||
try:
|
||||
p.Wait()
|
||||
except GitError as e:
|
||||
except error.GitError as e:
|
||||
logger.error(e)
|
||||
logger.warning(
|
||||
"NOTE: When committing (please see above) and editing the "
|
||||
@@ -76,7 +76,7 @@ change id will be added.
|
||||
# the commit message.
|
||||
new_msg = self._Reformat(old_msg, sha1)
|
||||
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["commit", "--amend", "-F", "-"],
|
||||
input=new_msg,
|
||||
@@ -86,14 +86,14 @@ change id will be added.
|
||||
)
|
||||
try:
|
||||
p.Wait()
|
||||
except GitError:
|
||||
except error.GitError:
|
||||
logger.error("error: Failed to update commit message")
|
||||
raise
|
||||
|
||||
def _ResolveReference(self, reference: str) -> Tuple[str, str]:
|
||||
"""Resolve a commit and read it through one cat-file batch request."""
|
||||
expression = f"{reference}^{{commit}}"
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
["cat-file", "--batch"],
|
||||
input=expression + "\n",
|
||||
@@ -107,7 +107,7 @@ change id will be added.
|
||||
if not separator:
|
||||
raise ValueError("missing cat-file header")
|
||||
if header.endswith(" missing") or header.endswith(" ambiguous"):
|
||||
raise GitError(f"commit {reference} not found")
|
||||
raise error.GitError(f"commit {reference} not found")
|
||||
|
||||
parts = header.split(" ", 2)
|
||||
if len(parts) != 3 or parts[1] != "commit":
|
||||
@@ -122,13 +122,13 @@ change id will be added.
|
||||
raise ValueError("truncated cat-file object")
|
||||
|
||||
commit = output[:-1]
|
||||
except (GitError, ValueError) as e:
|
||||
except (error.GitError, ValueError) as e:
|
||||
logger.error(
|
||||
"error: Failed to resolve or read commit %s", reference
|
||||
)
|
||||
if isinstance(e, GitError):
|
||||
if isinstance(e, error.GitError):
|
||||
raise
|
||||
raise GitError(str(e)) from e
|
||||
raise error.GitError(str(e)) from e
|
||||
|
||||
return sha1, commit
|
||||
|
||||
|
||||
@@ -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))
|
||||
+8
-10
@@ -16,17 +16,15 @@ import re
|
||||
import sys
|
||||
|
||||
from command import Command
|
||||
from error import GitError
|
||||
from error import NoSuchProjectError
|
||||
from error import RepoExitError
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import repo_logging
|
||||
|
||||
|
||||
CHANGE_RE = re.compile(r"^([1-9][0-9]*)(?:[/\.-]([1-9][0-9]*))?$")
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class DownloadCommandError(RepoExitError):
|
||||
class DownloadCommandError(error.RepoExitError):
|
||||
"""Error raised when download command fails."""
|
||||
|
||||
|
||||
@@ -107,7 +105,7 @@ If no project is specified try to use current directory as a project.
|
||||
# If the cwd is one of the projects, assume they want that.
|
||||
try:
|
||||
project = self.GetProjects(".")[0]
|
||||
except NoSuchProjectError:
|
||||
except error.NoSuchProjectError:
|
||||
project = None
|
||||
if project not in projects:
|
||||
logger.error(
|
||||
@@ -121,7 +119,7 @@ If no project is specified try to use current directory as a project.
|
||||
project.RelPath(local=opt.this_manifest_only),
|
||||
project.revisionExpr,
|
||||
)
|
||||
raise NoSuchProjectError()
|
||||
raise error.NoSuchProjectError()
|
||||
else:
|
||||
project = projects[0]
|
||||
print("Defaulting to cwd project", project.name)
|
||||
@@ -143,7 +141,7 @@ If no project is specified try to use current directory as a project.
|
||||
try:
|
||||
self._ExecuteHelper(opt, args)
|
||||
except Exception as e:
|
||||
if isinstance(e, RepoExitError):
|
||||
if isinstance(e, error.RepoExitError):
|
||||
raise e
|
||||
raise DownloadCommandError(aggregate_errors=[e])
|
||||
|
||||
@@ -202,7 +200,7 @@ If no project is specified try to use current directory as a project.
|
||||
else:
|
||||
project._Checkout(dl.commit)
|
||||
|
||||
except GitError:
|
||||
except error.GitError:
|
||||
logger.error(
|
||||
"[%s] Could not complete the %s of %s",
|
||||
project.name,
|
||||
|
||||
+2
-2
@@ -25,10 +25,10 @@ from color import Coloring
|
||||
from command import Command
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from command import MirrorSafeCommand
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
_CAN_COLOR = [
|
||||
"branch",
|
||||
"diff",
|
||||
|
||||
+9
-10
@@ -16,8 +16,7 @@ import os
|
||||
from typing import List, Set
|
||||
|
||||
from command import Command
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
import git_command
|
||||
import platform_utils
|
||||
from progress import Progress
|
||||
from project import Project
|
||||
@@ -200,13 +199,13 @@ are still in the manifests.
|
||||
|
||||
# Prepare workspace for repacking - remove all unreachable refs and
|
||||
# their objects.
|
||||
GitCommand(
|
||||
git_command.GitCommand(
|
||||
project,
|
||||
["reflog", "expire", "--expire-unreachable=all"],
|
||||
verify_command=True,
|
||||
).Wait()
|
||||
pm.update(msg=f"{project.name} | gc", inc=0)
|
||||
GitCommand(
|
||||
git_command.GitCommand(
|
||||
project,
|
||||
["gc"],
|
||||
verify_command=True,
|
||||
@@ -214,7 +213,7 @@ are still in the manifests.
|
||||
|
||||
# Get all objects that are reachable from the remote, and pack them.
|
||||
pm.update(msg=f"{project.name} | generating list of objects", inc=0)
|
||||
remote_objects_cmd = GitCommand(
|
||||
remote_objects_cmd = git_command.GitCommand(
|
||||
project,
|
||||
[
|
||||
"rev-list",
|
||||
@@ -229,7 +228,7 @@ are still in the manifests.
|
||||
)
|
||||
|
||||
# Get all local objects and pack them.
|
||||
local_head_objects_cmd = GitCommand(
|
||||
local_head_objects_cmd = git_command.GitCommand(
|
||||
project,
|
||||
[
|
||||
"rev-list",
|
||||
@@ -240,7 +239,7 @@ are still in the manifests.
|
||||
capture_stdout=True,
|
||||
verify_command=True,
|
||||
)
|
||||
local_objects_cmd = GitCommand(
|
||||
local_objects_cmd = git_command.GitCommand(
|
||||
project,
|
||||
[
|
||||
"rev-list",
|
||||
@@ -260,7 +259,7 @@ are still in the manifests.
|
||||
remote_objects_cmd.Wait()
|
||||
|
||||
pm.update(msg=f"{project.name} | remote repack", inc=0)
|
||||
GitCommand(
|
||||
git_command.GitCommand(
|
||||
project,
|
||||
["pack-objects", os.path.join(pack_dir, "pack")],
|
||||
input=remote_objects_cmd.stdout,
|
||||
@@ -276,7 +275,7 @@ are still in the manifests.
|
||||
local_objects_cmd.Wait()
|
||||
|
||||
pm.update(msg=f"{project.name} | local repack", inc=0)
|
||||
GitCommand(
|
||||
git_command.GitCommand(
|
||||
project,
|
||||
["pack-objects", os.path.join(pack_dir, "pack")],
|
||||
input=local_head_objects_cmd.stdout + local_objects_cmd.stdout,
|
||||
@@ -325,7 +324,7 @@ are still in the manifests.
|
||||
return ret
|
||||
|
||||
if opt.repack:
|
||||
git_require((2, 17, 0), fail=True, msg="--repack")
|
||||
git_command.git_require((2, 17, 0), fail=True, msg="--repack")
|
||||
ret = self.repack_projects(projects, opt)
|
||||
|
||||
return ret
|
||||
+13
-15
@@ -19,14 +19,12 @@ from typing import NamedTuple
|
||||
from color import Coloring
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from command import PagedCommand
|
||||
from error import GitError
|
||||
from error import InvalidArgumentsError
|
||||
from error import SilentRepoExitError
|
||||
from git_command import GitCommand
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import git_command
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class GrepColoring(Coloring):
|
||||
@@ -43,10 +41,10 @@ class ExecuteOneResult(NamedTuple):
|
||||
rc: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
error: GitError
|
||||
error: error.GitError
|
||||
|
||||
|
||||
class GrepCommandError(SilentRepoExitError):
|
||||
class GrepCommandError(error.SilentRepoExitError):
|
||||
"""Grep command failure. Since Grep command
|
||||
output already outputs errors ensure that
|
||||
aggregate errors exit silently."""
|
||||
@@ -265,7 +263,7 @@ contain a line that matches both expressions:
|
||||
"""Process one project."""
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
try:
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
project,
|
||||
cmd_argv,
|
||||
bare=False,
|
||||
@@ -273,16 +271,16 @@ contain a line that matches both expressions:
|
||||
capture_stderr=True,
|
||||
verify_command=True,
|
||||
)
|
||||
except GitError as e:
|
||||
except error.GitError as e:
|
||||
return ExecuteOneResult(project_idx, -1, None, str(e), e)
|
||||
|
||||
try:
|
||||
error = None
|
||||
err = None
|
||||
rc = p.Wait()
|
||||
except GitError as e:
|
||||
except error.GitError as e:
|
||||
rc = 1
|
||||
error = e
|
||||
return ExecuteOneResult(project_idx, rc, p.stdout, p.stderr, error)
|
||||
err = e
|
||||
return ExecuteOneResult(project_idx, rc, p.stdout, p.stderr, err)
|
||||
|
||||
@staticmethod
|
||||
def _ProcessResults(
|
||||
@@ -377,7 +375,7 @@ contain a line that matches both expressions:
|
||||
if "--cached" in cmd_argv:
|
||||
msg = "fatal: cannot combine --cached and --revision"
|
||||
logger.error(msg)
|
||||
raise InvalidArgumentsError(msg)
|
||||
raise error.InvalidArgumentsError(msg)
|
||||
have_rev = True
|
||||
cmd_argv.extend(opt.revision)
|
||||
cmd_argv.append("--")
|
||||
|
||||
+2
-2
@@ -19,12 +19,12 @@ import textwrap
|
||||
from color import Coloring
|
||||
from command import MirrorSafeCommand
|
||||
from command import PagedCommand
|
||||
from error import RepoExitError
|
||||
import error
|
||||
from subcmds import all_commands
|
||||
from wrapper import Wrapper
|
||||
|
||||
|
||||
class InvalidHelpCommand(RepoExitError):
|
||||
class InvalidHelpCommand(error.RepoExitError):
|
||||
"""Invalid command passed into help."""
|
||||
|
||||
|
||||
|
||||
+4
-5
@@ -23,8 +23,7 @@ from typing import Any, Dict, List, NamedTuple, Optional, Tuple
|
||||
from color import Coloring
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from command import PagedCommand
|
||||
from git_refs import R_HEADS
|
||||
from git_refs import R_M
|
||||
import git_refs
|
||||
|
||||
|
||||
class BranchInfo(NamedTuple):
|
||||
@@ -333,9 +332,9 @@ class Info(PagedCommand):
|
||||
branch = project.manifest.manifestProject.config.GetBranch(
|
||||
"default"
|
||||
).merge
|
||||
if branch.startswith(R_HEADS):
|
||||
branch = branch[len(R_HEADS) :]
|
||||
logTarget = R_M + branch
|
||||
if branch.startswith(git_refs.R_HEADS):
|
||||
branch = branch[len(git_refs.R_HEADS) :]
|
||||
logTarget = git_refs.R_M + branch
|
||||
|
||||
localCommits, originCommits = cls._GetDiffCommits(
|
||||
project, logTarget
|
||||
|
||||
+12
-10
@@ -18,15 +18,14 @@ import sys
|
||||
from color import Coloring
|
||||
from command import InteractiveCommand
|
||||
from command import MirrorSafeCommand
|
||||
from error import RepoUnhandledExceptionError
|
||||
from error import UpdateManifestError
|
||||
from git_command import git_require
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import git_command
|
||||
import repo_logging
|
||||
from wrapper import Wrapper
|
||||
from wrapper import WrapperDir
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
_REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW")
|
||||
|
||||
@@ -164,6 +163,7 @@ to update the working directory files.
|
||||
clone_bundle=opt.clone_bundle,
|
||||
git_lfs=opt.git_lfs,
|
||||
use_superproject=opt.use_superproject,
|
||||
smart_sync=opt.smart_sync,
|
||||
verbose=opt.verbose,
|
||||
current_branch_only=opt.current_branch_only,
|
||||
tags=opt.tags,
|
||||
@@ -173,7 +173,7 @@ to update the working directory files.
|
||||
use_local_gitdirs=opt.use_local_gitdirs,
|
||||
):
|
||||
manifest_name = opt.manifest_name
|
||||
raise UpdateManifestError(
|
||||
raise error.UpdateManifestError(
|
||||
f"Unable to sync manifest {manifest_name}"
|
||||
)
|
||||
|
||||
@@ -347,9 +347,9 @@ to update the working directory files.
|
||||
wrapper = Wrapper()
|
||||
|
||||
reqs = wrapper.Requirements.from_dir(WrapperDir())
|
||||
git_require(reqs.get_hard_ver("git"), fail=True)
|
||||
git_command.git_require(reqs.get_hard_ver("git"), fail=True)
|
||||
min_git_version_soft = reqs.get_soft_ver("git")
|
||||
if not git_require(min_git_version_soft):
|
||||
if not git_command.git_require(min_git_version_soft):
|
||||
logger.warning(
|
||||
"repo: warning: git-%s+ will soon be required; "
|
||||
"please upgrade your version of git to maintain "
|
||||
@@ -378,7 +378,7 @@ to update the working directory files.
|
||||
err_msg = "fatal: double check your --repo-rev setting."
|
||||
logger.error(err_msg)
|
||||
self.git_event_log.ErrorEvent(err_msg)
|
||||
raise RepoUnhandledExceptionError(e)
|
||||
raise error.RepoUnhandledExceptionError(e)
|
||||
|
||||
branch = rp.GetBranch("default")
|
||||
branch.merge = remote_ref
|
||||
@@ -388,7 +388,9 @@ to update the working directory files.
|
||||
if opt.worktree:
|
||||
# Older versions of git supported worktree, but had dangerous gc
|
||||
# bugs.
|
||||
git_require((2, 15, 0), fail=True, msg="git gc worktree corruption")
|
||||
git_command.git_require(
|
||||
(2, 15, 0), fail=True, msg="git gc worktree corruption"
|
||||
)
|
||||
|
||||
# Provide a short notice that we're reinitializing an existing checkout.
|
||||
# Sometimes developers might not realize that they're in one, or that
|
||||
|
||||
+2
-2
@@ -19,10 +19,10 @@ import os
|
||||
import sys
|
||||
|
||||
from command import PagedCommand
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class OutputFormat(enum.Enum):
|
||||
|
||||
+6
-6
@@ -16,13 +16,13 @@ import sys
|
||||
|
||||
from color import Coloring
|
||||
from command import Command
|
||||
from error import GitError
|
||||
from git_command import GitCommand
|
||||
import error
|
||||
import git_command
|
||||
from project import Project
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class RebaseColoring(Coloring):
|
||||
@@ -39,7 +39,7 @@ def _ResolveOntoManifest(project: Project) -> str:
|
||||
"""
|
||||
try:
|
||||
return project.GetRemote().ToLocal(project.revisionExpr)
|
||||
except GitError:
|
||||
except error.GitError:
|
||||
return project.revisionExpr
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ branch but need to incorporate new upstream changes "underneath" them.
|
||||
out.nl()
|
||||
out.flush()
|
||||
|
||||
if GitCommand(project, args).Wait() != 0:
|
||||
if git_command.GitCommand(project, args).Wait() != 0:
|
||||
ret += 1
|
||||
continue
|
||||
|
||||
|
||||
@@ -16,16 +16,16 @@ import optparse
|
||||
|
||||
from command import Command
|
||||
from command import MirrorSafeCommand
|
||||
from error import RepoExitError
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import repo_logging
|
||||
from subcmds.sync import _PostRepoFetch
|
||||
from subcmds.sync import _PostRepoUpgrade
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class SelfupdateError(RepoExitError):
|
||||
class SelfupdateError(error.RepoExitError):
|
||||
"""Exit error for failed selfupdate command."""
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -16,11 +16,11 @@ import sys
|
||||
|
||||
from color import Coloring
|
||||
from command import InteractiveCommand
|
||||
from git_command import GitCommand
|
||||
from repo_logging import RepoLogger
|
||||
import git_command
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class _ProjectList(Coloring):
|
||||
@@ -129,5 +129,5 @@ The '%prog' command stages files to prepare the next commit.
|
||||
|
||||
|
||||
def _AddI(project):
|
||||
p = GitCommand(project, ["add", "--interactive"], bare=False)
|
||||
p = git_command.GitCommand(project, ["add", "--interactive"], bare=False)
|
||||
p.Wait()
|
||||
+9
-9
@@ -17,14 +17,14 @@ from typing import NamedTuple
|
||||
|
||||
from command import Command
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from error import RepoExitError
|
||||
from git_command import IsValidBranchName
|
||||
import error
|
||||
import git_command
|
||||
from git_config import IsImmutable
|
||||
from progress import Progress
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class ExecuteOneResult(NamedTuple):
|
||||
@@ -32,7 +32,7 @@ class ExecuteOneResult(NamedTuple):
|
||||
error: Exception
|
||||
|
||||
|
||||
class StartError(RepoExitError):
|
||||
class StartError(error.RepoExitError):
|
||||
"""Exit error for failed start command."""
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ revision specified in the manifest.
|
||||
self.Usage()
|
||||
|
||||
nb = args[0]
|
||||
if not IsValidBranchName(nb):
|
||||
if not git_command.IsValidBranchName(nb):
|
||||
self.OptionParser.error("'%s' is not a valid name" % nb)
|
||||
|
||||
@classmethod
|
||||
@@ -85,7 +85,7 @@ revision specified in the manifest.
|
||||
# a change, then we can't push back to it. Substitute with
|
||||
# dest_branch, if defined; or with manifest default revision instead.
|
||||
branch_merge = ""
|
||||
error = None
|
||||
err = None
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
if IsImmutable(project.revisionExpr):
|
||||
if project.dest_branch:
|
||||
@@ -99,8 +99,8 @@ revision specified in the manifest.
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("error: unable to checkout %s: %s", project.name, e)
|
||||
error = e
|
||||
return ExecuteOneResult(project_idx, error)
|
||||
err = e
|
||||
return ExecuteOneResult(project_idx, err)
|
||||
|
||||
def Execute(self, opt, args):
|
||||
nb = args[0]
|
||||
|
||||
+63
-57
@@ -57,21 +57,12 @@ from command import Command
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from command import MirrorSafeCommand
|
||||
from command import WORKER_BATCH_SIZE
|
||||
from error import GitError
|
||||
from error import ManifestParseError
|
||||
from error import RepoChangedException
|
||||
from error import RepoError
|
||||
from error import RepoExitError
|
||||
from error import RepoUnhandledExceptionError
|
||||
from error import SyncError
|
||||
from error import UpdateManifestError
|
||||
import error
|
||||
import event_log
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
import git_command
|
||||
from git_config import GetUrlCookieFile
|
||||
from git_config import RepoConfig
|
||||
from git_refs import HEAD
|
||||
from git_refs import R_HEADS
|
||||
import git_refs
|
||||
import git_superproject
|
||||
from hooks import RepoHook
|
||||
from manifest_xml import XmlManifest
|
||||
@@ -83,8 +74,8 @@ from project import DeleteWorktreeError
|
||||
from project import Project
|
||||
from project import RemoteSpec
|
||||
from project import SyncBuffer
|
||||
from repo_logging import RepoLogger
|
||||
from repo_trace import Trace
|
||||
import repo_logging
|
||||
import repo_trace
|
||||
import ssh
|
||||
from wrapper import Wrapper
|
||||
|
||||
@@ -97,7 +88,7 @@ _BLOAT_PACK_COUNT_THRESHOLD = 10
|
||||
_BLOAT_SIZE_PACK_THRESHOLD_KB = 10 * 1024 * 1024 # 10 GiB in KiB
|
||||
_BLOAT_SIZE_GARBAGE_THRESHOLD_KB = 1 * 1024 * 1024 # 1 GiB in KiB
|
||||
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
# Manifest server RPC that takes a single struct of named options. Servers
|
||||
# that do not implement it are called via the positional GetApprovedManifest.
|
||||
@@ -418,19 +409,19 @@ class _InterleavedSyncResult(NamedTuple):
|
||||
results: List[_SyncResult]
|
||||
|
||||
|
||||
class SuperprojectError(SyncError):
|
||||
class SuperprojectError(error.SyncError):
|
||||
"""Superproject sync repo."""
|
||||
|
||||
|
||||
class SyncFailFastError(SyncError):
|
||||
class SyncFailFastError(error.SyncError):
|
||||
"""Sync exit error when --fail-fast set."""
|
||||
|
||||
|
||||
class SmartSyncError(SyncError):
|
||||
class SmartSyncError(error.SyncError):
|
||||
"""Smart sync exit error."""
|
||||
|
||||
|
||||
class ManifestInterruptError(RepoError):
|
||||
class ManifestInterruptError(error.RepoError):
|
||||
"""Aggregate Error to be logged when a user interrupts a manifest update."""
|
||||
|
||||
def __init__(self, output, **kwargs):
|
||||
@@ -839,6 +830,7 @@ later is required to fix a server side protocol bug.
|
||||
)
|
||||
p.add_option(
|
||||
"--no-smart-sync",
|
||||
"--head",
|
||||
dest="smart_sync",
|
||||
action="store_false",
|
||||
help="disable smart sync and sync to ToT instead",
|
||||
@@ -873,8 +865,8 @@ later is required to fix a server side protocol bug.
|
||||
"""
|
||||
b = manifest_project.GetBranch(manifest_project.CurrentBranch)
|
||||
branch = b.merge
|
||||
if branch.startswith(R_HEADS):
|
||||
branch = branch[len(R_HEADS) :]
|
||||
if branch.startswith(git_refs.R_HEADS):
|
||||
branch = branch[len(git_refs.R_HEADS) :]
|
||||
return branch
|
||||
|
||||
@classmethod
|
||||
@@ -1115,7 +1107,7 @@ later is required to fix a server side protocol bug.
|
||||
logger.error("Keyboard interrupt while processing %s", project.name)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
except error.GitError as e:
|
||||
logger.error("error.GitError: Cannot fetch %s", e)
|
||||
errors.append(e)
|
||||
except Exception as e:
|
||||
@@ -1321,7 +1313,7 @@ later is required to fix a server side protocol bug.
|
||||
if opt.network_only:
|
||||
# Bail out now; the rest touches the working tree.
|
||||
if err_event.is_set():
|
||||
e = SyncError(
|
||||
e = error.SyncError(
|
||||
"error: Exited sync due to fetch errors.",
|
||||
aggregate_errors=errors,
|
||||
)
|
||||
@@ -1415,7 +1407,7 @@ later is required to fix a server side protocol bug.
|
||||
logger.error("Keyboard interrupt while processing %s", project.name)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
except error.GitError as e:
|
||||
logger.error(
|
||||
"error.GitError: Cannot checkout %s: %s", project.name, e
|
||||
)
|
||||
@@ -1571,7 +1563,7 @@ later is required to fix a server side protocol bug.
|
||||
|
||||
if expected != actual:
|
||||
# If this is unexpected, log it and repair.
|
||||
Trace(
|
||||
repo_trace.Trace(
|
||||
f"{relpath} expected preciousObjects={expected}, got {actual}"
|
||||
)
|
||||
if expected:
|
||||
@@ -1581,7 +1573,7 @@ later is required to fix a server side protocol bug.
|
||||
% (relpath, project.name)
|
||||
)
|
||||
|
||||
if git_require((2, 7, 0)):
|
||||
if git_command.git_require((2, 7, 0)):
|
||||
project.EnableRepositoryExtension("preciousObjects")
|
||||
else:
|
||||
# This isn't perfect, but it's the best we can do with old
|
||||
@@ -1674,7 +1666,7 @@ later is required to fix a server side protocol bug.
|
||||
cls._RunOneGC(bare_git._project, config=config)
|
||||
else:
|
||||
bare_git.pack_refs(config=config)
|
||||
except GitError:
|
||||
except error.GitError:
|
||||
err_event.set()
|
||||
except Exception:
|
||||
err_event.set()
|
||||
@@ -1771,7 +1763,7 @@ later is required to fix a server side protocol bug.
|
||||
# We only care about bloated projects if we have a git version that
|
||||
# supports --no-auto-gc (2.23.0+) since what we use to disable auto-gc
|
||||
# in Project._RemoteFetch.
|
||||
if not git_require((2, 23, 0)):
|
||||
if not git_command.git_require((2, 23, 0)):
|
||||
return
|
||||
|
||||
# Skip projects with no worktree on disk, e.g. ones only ever synced
|
||||
@@ -2345,7 +2337,7 @@ later is required to fix a server side protocol bug.
|
||||
mp, event_log.TASK_SYNC_LOCAL, start, time.time(), clean
|
||||
)
|
||||
if not clean:
|
||||
raise UpdateManifestError(aggregate_errors=errors)
|
||||
raise error.UpdateManifestError(aggregate_errors=errors)
|
||||
self._ReloadManifest(manifest_name, mp.manifest)
|
||||
|
||||
def ValidateOptions(self, opt, args):
|
||||
@@ -2453,10 +2445,10 @@ later is required to fix a server side protocol bug.
|
||||
try:
|
||||
self._ExecuteHelper(opt, args, errors)
|
||||
sync_duration_seconds = time.time() - start_time
|
||||
except (RepoExitError, RepoChangedException):
|
||||
except (error.RepoExitError, error.RepoChangedException):
|
||||
raise
|
||||
except (KeyboardInterrupt, Exception) as e:
|
||||
raise RepoUnhandledExceptionError(e, aggregate_errors=errors)
|
||||
raise error.RepoUnhandledExceptionError(e, aggregate_errors=errors)
|
||||
|
||||
# Run post-sync hook only after successful sync
|
||||
self._RunPostSyncHook(opt, sync_duration_seconds=sync_duration_seconds)
|
||||
@@ -2489,16 +2481,16 @@ later is required to fix a server side protocol bug.
|
||||
Only valid for the outer manifest; see _GetSuperprojectRevision.
|
||||
"""
|
||||
if not manifest.superproject:
|
||||
raise SyncError("superproject not defined in manifest")
|
||||
raise error.SyncError("superproject not defined in manifest")
|
||||
|
||||
self._ConfigureSuperproject(opt, manifest)
|
||||
|
||||
sync_result = manifest.superproject.Sync(self.git_event_log)
|
||||
if not sync_result.success:
|
||||
raise SyncError("failed to sync superproject")
|
||||
raise error.SyncError("failed to sync superproject")
|
||||
|
||||
cmd = ["show", f"{opt.superproject_revision}:.supermanifest"]
|
||||
p = GitCommand(
|
||||
p = git_command.GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
gitdir=manifest.superproject._work_git,
|
||||
@@ -2507,36 +2499,40 @@ later is required to fix a server side protocol bug.
|
||||
capture_stderr=True,
|
||||
)
|
||||
if p.Wait() != 0:
|
||||
raise SyncError(
|
||||
raise error.SyncError(
|
||||
f"failed to read .supermanifest from superproject: {p.stderr}"
|
||||
)
|
||||
|
||||
try:
|
||||
_, _, manifest_commit = p.stdout.strip().split()
|
||||
except ValueError:
|
||||
raise SyncError("could not parse .supermanifest")
|
||||
raise error.SyncError("could not parse .supermanifest")
|
||||
|
||||
mp.SetRevision(manifest_commit)
|
||||
try:
|
||||
self._UpdateManifestProject(opt, mp, manifest_name, errors)
|
||||
except UpdateManifestError as e:
|
||||
raise SyncError(
|
||||
except error.UpdateManifestError as e:
|
||||
raise error.SyncError(
|
||||
"failed to sync manifest project", aggregate_errors=[e]
|
||||
)
|
||||
|
||||
def _ResolveSmartSyncOption(
|
||||
self, opt: optparse.Values, manifest: XmlManifest
|
||||
) -> bool:
|
||||
"""Resolve opt.smart_sync from CLI flags, user config and manifest.
|
||||
"""Resolve opt.smart_sync from CLI flags, config, and manifest.
|
||||
|
||||
Precedence: CLI flags > per-user settings > per-manifest settings.
|
||||
Precedence: CLI flags > per-checkout settings > per-user settings >
|
||||
per-manifest settings.
|
||||
See docs/repoconfig.md and docs/smart-sync.md for details.
|
||||
|
||||
Returns:
|
||||
True if smart_sync was enabled implicitly by the manifest default,
|
||||
False otherwise. Smart sync enabled via per-user settings counts
|
||||
as an explicit user request, not implicit.
|
||||
True if smart_sync was enabled implicitly by checkout or manifest
|
||||
default, False otherwise. Smart sync enabled via per-user settings
|
||||
counts as an explicit user request, not implicit.
|
||||
"""
|
||||
checkout_pref = getattr(manifest.manifestProject, "smart_sync", None)
|
||||
if not isinstance(checkout_pref, bool):
|
||||
checkout_pref = None
|
||||
user_pref = RepoConfig.ForUser().GetBoolean("sync.smartsync")
|
||||
default_allowed = (
|
||||
opt.smart_sync is None
|
||||
@@ -2545,13 +2541,20 @@ later is required to fix a server side protocol bug.
|
||||
and not opt.superproject_revision
|
||||
and not opt.local_only
|
||||
)
|
||||
default_smart_sync = (
|
||||
checkout_pref
|
||||
if checkout_pref is not None
|
||||
else getattr(manifest.default, "sync_smartsync", False) is True
|
||||
)
|
||||
implicit = (
|
||||
default_allowed
|
||||
and user_pref is None
|
||||
and getattr(manifest.default, "sync_smartsync", False) is True
|
||||
and (checkout_pref is not None or user_pref is None)
|
||||
and default_smart_sync
|
||||
)
|
||||
if opt.smart_sync is None:
|
||||
if user_pref is False:
|
||||
if checkout_pref is not None:
|
||||
opt.smart_sync = implicit
|
||||
elif user_pref is False:
|
||||
opt.smart_sync = False
|
||||
elif user_pref is True:
|
||||
opt.smart_sync = default_allowed
|
||||
@@ -2604,7 +2607,7 @@ later is required to fix a server side protocol bug.
|
||||
manifest,
|
||||
best_effort=implicit_smart_sync,
|
||||
)
|
||||
except (SmartSyncError, ManifestParseError) as e:
|
||||
except (SmartSyncError, error.ManifestParseError) as e:
|
||||
if not implicit_smart_sync:
|
||||
raise
|
||||
# The user did not ask for a smart sync, so failing to get a
|
||||
@@ -2623,7 +2626,7 @@ later is required to fix a server side protocol bug.
|
||||
manifest_name = os.path.basename(smart_sync_manifest_path)
|
||||
try:
|
||||
self._ReloadManifest(manifest_name, manifest)
|
||||
except ManifestParseError as e:
|
||||
except error.ManifestParseError as e:
|
||||
reuse_smart_sync_override = False
|
||||
manifest_name = None
|
||||
manifest.ClearOverride()
|
||||
@@ -2793,7 +2796,7 @@ later is required to fix a server side protocol bug.
|
||||
nested = _NestedProjects(projects)
|
||||
if not nested:
|
||||
return
|
||||
e = SyncError(
|
||||
e = error.SyncError(
|
||||
"error: repo.reprojectcmd does not support nested projects or "
|
||||
"submodules; found:\n"
|
||||
+ "\n".join(
|
||||
@@ -2917,7 +2920,7 @@ later is required to fix a server side protocol bug.
|
||||
logger.error(
|
||||
'Try re-running with "-j1 --fail-fast" to exit at the first error.'
|
||||
)
|
||||
raise SyncError(aggregate_errors=errors)
|
||||
raise error.SyncError(aggregate_errors=errors)
|
||||
|
||||
def _SyncPhased(
|
||||
self,
|
||||
@@ -3051,7 +3054,7 @@ later is required to fix a server side protocol bug.
|
||||
)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
except error.GitError as e:
|
||||
fetch_errors.append(e)
|
||||
logger.error("error.GitError: Cannot fetch %s", e)
|
||||
except Exception as e:
|
||||
@@ -3103,7 +3106,7 @@ later is required to fix a server side protocol bug.
|
||||
)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
except error.GitError as e:
|
||||
checkout_errors.append(e)
|
||||
logger.error(
|
||||
"error.GitError: Cannot checkout %s: %s",
|
||||
@@ -3471,7 +3474,7 @@ def _PostRepoFetch(rp, repo_verify=True, verbose=False):
|
||||
wrapper = Wrapper()
|
||||
try:
|
||||
rev = rp.bare_git.describe(rp.GetRevisionId())
|
||||
except GitError:
|
||||
except error.GitError:
|
||||
rev = None
|
||||
_, new_rev = wrapper.check_repo_rev(
|
||||
rp.gitdir, rev, repo_verify=repo_verify
|
||||
@@ -3489,15 +3492,18 @@ def _PostRepoFetch(rp, repo_verify=True, verbose=False):
|
||||
# Refresh index since reset --keep won't do it.
|
||||
rp.work_git.update_index("-q", "--refresh")
|
||||
rp.work_git.reset("--keep", new_rev)
|
||||
except GitError as e:
|
||||
raise RepoUnhandledExceptionError(e)
|
||||
except error.GitError as e:
|
||||
raise error.RepoUnhandledExceptionError(e)
|
||||
print("info: Restarting repo with latest version")
|
||||
raise RepoChangedException(["--repo-upgraded"])
|
||||
raise error.RepoChangedException(["--repo-upgraded"])
|
||||
else:
|
||||
logger.warning("warning: Skipped upgrade to unverified version")
|
||||
else:
|
||||
if verbose:
|
||||
print("repo version %s is current" % rp.work_git.describe(HEAD))
|
||||
print(
|
||||
"repo version %s is current"
|
||||
% rp.work_git.describe(git_refs.HEAD)
|
||||
)
|
||||
|
||||
|
||||
class _FetchTimes:
|
||||
|
||||
+27
-25
@@ -22,22 +22,20 @@ from typing import List
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from command import InteractiveCommand
|
||||
from editor import Editor
|
||||
from error import GitError
|
||||
from error import SilentRepoExitError
|
||||
from error import UploadError
|
||||
from git_refs import R_HEADS
|
||||
import error
|
||||
import git_refs
|
||||
import git_superproject
|
||||
from hooks import RepoHook
|
||||
from project import ReviewableBranch
|
||||
from repo_logging import RepoLogger
|
||||
import repo_logging
|
||||
from subcmds.sync import LocalSyncState
|
||||
|
||||
|
||||
_DEFAULT_UNUSUAL_COMMIT_THRESHOLD = 5
|
||||
logger = RepoLogger(__file__)
|
||||
logger = repo_logging.RepoLogger(__file__)
|
||||
|
||||
|
||||
class UploadExitError(SilentRepoExitError):
|
||||
class UploadExitError(error.SilentRepoExitError):
|
||||
"""Indicates that there is an upload command error requiring a sys exit."""
|
||||
|
||||
|
||||
@@ -609,11 +607,11 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
)
|
||||
|
||||
full_dest = destination
|
||||
if not full_dest.startswith(R_HEADS):
|
||||
full_dest = R_HEADS + full_dest
|
||||
if not full_dest.startswith(git_refs.R_HEADS):
|
||||
full_dest = git_refs.R_HEADS + full_dest
|
||||
full_revision = branch.project.revisionExpr
|
||||
if not full_revision.startswith(R_HEADS):
|
||||
full_revision = R_HEADS + full_revision
|
||||
if not full_revision.startswith(git_refs.R_HEADS):
|
||||
full_revision = git_refs.R_HEADS + full_revision
|
||||
|
||||
# If the merge branch of the local branch is different from
|
||||
# the project's revision AND destination, this might not be
|
||||
@@ -661,6 +659,7 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
push_options=push_options,
|
||||
patchset_description=opt.patchset_description,
|
||||
git_event_log=self.git_event_log,
|
||||
quiet=opt.quiet,
|
||||
)
|
||||
|
||||
branch.uploaded = True
|
||||
@@ -671,15 +670,17 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
for branch in todo:
|
||||
try:
|
||||
self._UploadBranch(opt, branch, people)
|
||||
except (UploadError, GitError) as e:
|
||||
except (error.UploadError, error.GitError) as e:
|
||||
self.git_event_log.ErrorEvent(f"upload error: {e}")
|
||||
branch.error = e
|
||||
aggregate_errors.append(e)
|
||||
branch.uploaded = False
|
||||
have_errors = True
|
||||
|
||||
print(file=sys.stderr)
|
||||
print("-" * 70, file=sys.stderr)
|
||||
quiet = getattr(opt, "quiet", False)
|
||||
if not quiet or have_errors:
|
||||
print(file=sys.stderr)
|
||||
print("-" * 70, file=sys.stderr)
|
||||
|
||||
if have_errors:
|
||||
for branch in todo:
|
||||
@@ -700,17 +701,18 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
)
|
||||
print()
|
||||
|
||||
for branch in todo:
|
||||
if branch.uploaded:
|
||||
print(
|
||||
"[OK ] %-15s %s"
|
||||
% (
|
||||
branch.project.RelPath(local=opt.this_manifest_only)
|
||||
+ "/",
|
||||
branch.name,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not quiet:
|
||||
for branch in todo:
|
||||
if branch.uploaded:
|
||||
print(
|
||||
"[OK ] %-15s %s"
|
||||
% (
|
||||
branch.project.RelPath(local=opt.this_manifest_only)
|
||||
+ "/",
|
||||
branch.name,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if have_errors:
|
||||
raise UploadExitError(aggregate_errors=aggregate_errors)
|
||||
|
||||
+10
-13
@@ -18,11 +18,8 @@ from typing import Any, Tuple
|
||||
|
||||
from command import Command
|
||||
from command import MirrorSafeCommand
|
||||
from git_command import git
|
||||
from git_command import git_require
|
||||
from git_command import RepoSourceVersion
|
||||
from git_command import user_agent
|
||||
from git_refs import HEAD
|
||||
import git_command
|
||||
import git_refs
|
||||
from wrapper import Wrapper
|
||||
|
||||
|
||||
@@ -39,17 +36,17 @@ class Version(Command, MirrorSafeCommand):
|
||||
@staticmethod
|
||||
def _RepoVersion(project: Any) -> Tuple[str, str]:
|
||||
"""Return repo's describe string and commit date."""
|
||||
if git_require((2, 32, 0)):
|
||||
if git_command.git_require((2, 32, 0)):
|
||||
output = project.bare_git.log(
|
||||
"-1", "--format=%(describe)%n%cD", HEAD
|
||||
"-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(HEAD),
|
||||
project.bare_git.log("-1", "--format=%cD", HEAD),
|
||||
project.bare_git.describe(git_refs.HEAD),
|
||||
project.bare_git.log("-1", "--format=%cD", git_refs.HEAD),
|
||||
)
|
||||
|
||||
def Execute(self, opt, args):
|
||||
@@ -58,7 +55,7 @@ class Version(Command, MirrorSafeCommand):
|
||||
branch = rp.GetBranch("default")
|
||||
|
||||
# These might not be the same. Report them both.
|
||||
src_ver = RepoSourceVersion()
|
||||
src_ver = git_command.RepoSourceVersion()
|
||||
rp_ver, commit_date = self._RepoVersion(rp)
|
||||
print(f"repo version {rp_ver}")
|
||||
print(f" (from {rem.url})")
|
||||
@@ -72,9 +69,9 @@ class Version(Command, MirrorSafeCommand):
|
||||
if src_ver != rp_ver:
|
||||
print(f" (currently at {src_ver})")
|
||||
|
||||
print(f"repo User-Agent {user_agent.repo}")
|
||||
print(f"git {git.version_tuple().full}")
|
||||
print(f"git User-Agent {user_agent.git}")
|
||||
print(f"repo User-Agent {git_command.user_agent.repo}")
|
||||
print(f"git {git_command.git.version_tuple().full}")
|
||||
print(f"git User-Agent {git_command.user_agent.git}")
|
||||
print(f"Python {sys.version}")
|
||||
uname = platform.uname()
|
||||
if sys.version_info.major < 3:
|
||||
|
||||
+3
-4
@@ -17,13 +17,12 @@ import sys
|
||||
from typing import List
|
||||
|
||||
from command import Command
|
||||
from error import GitError
|
||||
from error import RepoExitError
|
||||
import error
|
||||
import platform_utils
|
||||
from project import DeleteWorktreeError
|
||||
|
||||
|
||||
class Error(RepoExitError):
|
||||
class Error(error.RepoExitError):
|
||||
"""Exit error when wipe command fails."""
|
||||
|
||||
|
||||
@@ -101,7 +100,7 @@ Examples:
|
||||
try:
|
||||
if project.HasChanges():
|
||||
uncommitted_projects.append(project.name)
|
||||
except GitError:
|
||||
except error.GitError:
|
||||
uncommitted_projects.append(f"{project.name} (corrupted)")
|
||||
|
||||
users = objdir_map.get(project.objdir, {project.relpath})
|
||||
|
||||
@@ -14,6 +14,8 @@ should be written using [pytest] only.
|
||||
[subcmds/forall.py](../subcmds/forall.py) module.
|
||||
* [conftest.py](./conftest.py): Custom pytest fixtures for sharing.
|
||||
* [utils_for_test.py](./utils_for_test.py): Helpers for sharing in tests.
|
||||
* [e2e/](./e2e/): Hermetic end-to-end tests that run the real `repo` against
|
||||
a local git server.
|
||||
|
||||
|
||||
[pytest]: https://pytest.org/
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"""Common fixtures for pytests."""
|
||||
|
||||
import pathlib
|
||||
from typing import Any, Generator
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -23,6 +24,40 @@ import platform_utils
|
||||
import repo_trace
|
||||
|
||||
|
||||
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
parser.addoption(
|
||||
"--fail-on-skip",
|
||||
action="store_true",
|
||||
help="fail skipped tests (run_tests sets this in CQ)",
|
||||
)
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_makereport(
|
||||
item: pytest.Item,
|
||||
) -> Generator[None, Any, None]:
|
||||
"""Fail skipped tests with --fail-on-skip.
|
||||
|
||||
A skip in CQ usually means the builder is missing a dependency (e.g. a new
|
||||
enough git), which would otherwise silently drop coverage. Tests that
|
||||
should not run in CQ are deselected via the `skip_cq` marker instead.
|
||||
"""
|
||||
outcome = yield
|
||||
report = outcome.get_result()
|
||||
if (
|
||||
item.config.getoption("fail_on_skip")
|
||||
and report.skipped
|
||||
# xfail outcomes are also reported as skipped.
|
||||
and not hasattr(report, "wasxfail")
|
||||
):
|
||||
reason = report.longrepr
|
||||
if isinstance(reason, tuple):
|
||||
# (path, lineno, reason)
|
||||
reason = reason[2]
|
||||
report.outcome = "failed"
|
||||
report.longrepr = f"Not allowed with --fail-on-skip: {reason}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_repo_trace(tmp_path):
|
||||
"""Set an environment marker to relax certain strict checks for test code.""" # noqa: E501
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -201,6 +201,7 @@ class GitCommandStreamLogsTest(unittest.TestCase):
|
||||
|
||||
@mock.patch("sys.stderr")
|
||||
def test_streams_stderr_when_no_stream_is_set(self, mock_stderr):
|
||||
mock_stderr.isatty.return_value = False
|
||||
logs = "\n".join(
|
||||
[
|
||||
"Enumerating objects: 5, done.",
|
||||
@@ -228,6 +229,28 @@ class GitCommandStreamLogsTest(unittest.TestCase):
|
||||
mock_stderr.write.assert_called_once_with(logs)
|
||||
self.assertEqual(cmd.stderr, logs)
|
||||
|
||||
@mock.patch("sys.stderr")
|
||||
def test_push_injects_progress_only_when_tty_and_not_suppressed(
|
||||
self, mock_stderr: mock.MagicMock
|
||||
) -> None:
|
||||
mock_stderr.isatty.return_value = True
|
||||
self.mock_process.stderr = io.BufferedReader(io.BytesIO(b""))
|
||||
git_command.GitCommand(None, ["push"])
|
||||
self.mock_popen.assert_called_once()
|
||||
cmd = self.mock_popen.call_args[0][0]
|
||||
self.assertEqual(cmd, ["git", "push", "--progress"])
|
||||
|
||||
@mock.patch("sys.stderr")
|
||||
def test_push_does_not_inject_progress_when_no_progress_set(
|
||||
self, mock_stderr: mock.MagicMock
|
||||
) -> None:
|
||||
mock_stderr.isatty.return_value = True
|
||||
self.mock_process.stderr = io.BufferedReader(io.BytesIO(b""))
|
||||
git_command.GitCommand(None, ["push", "--no-progress"])
|
||||
self.mock_popen.assert_called_once()
|
||||
cmd = self.mock_popen.call_args[0][0]
|
||||
self.assertEqual(cmd, ["git", "push", "--no-progress"])
|
||||
|
||||
|
||||
class GitCommandCaptureBytesTest(unittest.TestCase):
|
||||
"""Tests the GitCommand class byte capture cases."""
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"""Unittests for the git_config.py module."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
@@ -288,3 +288,61 @@ def test_repo_config_for_user_is_not_shared_with_git_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
|
||||
@@ -23,6 +23,7 @@ from unittest import mock
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
import git_command
|
||||
import git_refs
|
||||
|
||||
|
||||
@@ -99,8 +100,8 @@ def test_reads_head_with_root_refs_in_one_command(
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_refs, "git_require", lambda _version: True)
|
||||
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
|
||||
@@ -131,8 +132,8 @@ def test_root_ref_snapshot_falls_back_for_unborn_head(
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_refs, "git_require", lambda _version: True)
|
||||
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:
|
||||
@@ -161,8 +162,8 @@ def test_old_git_keeps_separate_head_fallback(
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_refs, "git_require", lambda _version: False)
|
||||
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:
|
||||
@@ -194,8 +195,8 @@ def test_for_each_ref_failure_falls_back_to_symbolic_ref(
|
||||
def Wait(self) -> int:
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_refs, "git_require", lambda _version: True)
|
||||
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:
|
||||
|
||||
@@ -20,6 +20,7 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import git_command
|
||||
import git_status
|
||||
|
||||
|
||||
@@ -143,8 +144,8 @@ def test_get_status_uses_versioned_machine_options(
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_status, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_status, "git_require", lambda _version: True)
|
||||
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_command, "git_require", lambda _version: True)
|
||||
|
||||
git_status.GetStatus(
|
||||
mock.sentinel.project,
|
||||
@@ -173,7 +174,7 @@ def test_get_status_uses_versioned_machine_options(
|
||||
def test_get_status_rejects_git_before_2_11(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(git_status, "git_require", lambda _version: False)
|
||||
monkeypatch.setattr(git_command, "git_require", lambda _version: False)
|
||||
|
||||
with pytest.raises(git_status.UnsupportedStatusError):
|
||||
git_status.GetStatus(mock.sentinel.project, mock.sentinel.gitdir)
|
||||
@@ -194,9 +195,9 @@ def test_get_status_omits_stash_header_before_2_35(
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_status, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(
|
||||
git_status,
|
||||
git_command,
|
||||
"git_require",
|
||||
lambda version: version <= (2, 34, 0),
|
||||
)
|
||||
|
||||
@@ -465,7 +465,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
"""Test _Init succeeds and creates the work git dir."""
|
||||
self.assertFalse(os.path.exists(self._superproject._work_git))
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", autospec=True
|
||||
"git_command.GitCommand", autospec=True
|
||||
) as mock_git_command:
|
||||
instance = mock_git_command.return_value
|
||||
instance.Wait.return_value = 0
|
||||
@@ -492,7 +492,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
os.mkdir(self._superproject._superproject_path)
|
||||
os.mkdir(self._superproject._work_git)
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", autospec=True
|
||||
"git_command.GitCommand", autospec=True
|
||||
) as mock_git_command:
|
||||
self.assertTrue(self._superproject._Init())
|
||||
mock_git_command.assert_not_called()
|
||||
@@ -500,7 +500,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
def test_Init_failure_cleans_up(self):
|
||||
"""Test _Init cleans up the temporary directory if 'git init' fails."""
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", autospec=True
|
||||
"git_command.GitCommand", autospec=True
|
||||
) as mock_git_command:
|
||||
instance = mock_git_command.return_value
|
||||
instance.Wait.return_value = 1
|
||||
@@ -540,7 +540,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
os.mkdir(self._superproject._work_git)
|
||||
with mock.patch.object(self._superproject, "_Init", return_value=True):
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", autospec=True
|
||||
"git_command.GitCommand", autospec=True
|
||||
) as mock_git_command:
|
||||
with mock.patch.object(
|
||||
self._superproject, "_GetRef"
|
||||
@@ -600,7 +600,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
command = mock.MagicMock(stdout="1234\n")
|
||||
command.Wait.return_value = 0
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", return_value=command
|
||||
"git_command.GitCommand", return_value=command
|
||||
) as git_command:
|
||||
self.assertEqual("1234", self._superproject._GetRef("HEAD"))
|
||||
|
||||
|
||||
+104
-45
@@ -31,6 +31,7 @@ import utils_for_test
|
||||
import error
|
||||
import git_command
|
||||
import git_config
|
||||
import git_refs
|
||||
import git_status
|
||||
import git_trace2_event_log
|
||||
import manifest_xml
|
||||
@@ -114,6 +115,19 @@ class ReviewableBranchTests(unittest.TestCase):
|
||||
_, kwargs = proj.UploadForReview.call_args
|
||||
self.assertEqual(kwargs.get("git_event_log"), mock_event_log)
|
||||
|
||||
def test_upload_for_review_forwards_quiet(self) -> None:
|
||||
"""Check ReviewableBranch.UploadForReview forwards quiet flag."""
|
||||
proj = mock.MagicMock(spec=project.Project)
|
||||
branch = mock.MagicMock()
|
||||
branch.name = "work"
|
||||
rb = project.ReviewableBranch(proj, branch, "main")
|
||||
|
||||
rb.UploadForReview(people=([], []), quiet=True)
|
||||
|
||||
proj.UploadForReview.assert_called_once()
|
||||
_, kwargs = proj.UploadForReview.call_args
|
||||
self.assertTrue(kwargs.get("quiet"))
|
||||
|
||||
def test_current_comes_from_branch_snapshot(self) -> None:
|
||||
"""Reviewable branches carry current state into report consumers."""
|
||||
branch = mock.MagicMock()
|
||||
@@ -216,7 +230,7 @@ class ProjectTests(unittest.TestCase):
|
||||
"""Check UploadForReview emits repo.uploadstate trace2 data events."""
|
||||
proj, _ = self._create_project_for_upload_test()
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd, mock.patch(
|
||||
"project.ReviewableBranch"
|
||||
) as mock_rb_cls:
|
||||
mock_cmd = mock.MagicMock()
|
||||
@@ -256,7 +270,7 @@ class ProjectTests(unittest.TestCase):
|
||||
"""Check event emission when stderr contains no review URLs."""
|
||||
proj, _ = self._create_project_for_upload_test()
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd, mock.patch(
|
||||
"project.ReviewableBranch"
|
||||
) as mock_rb_cls:
|
||||
mock_cmd = mock.MagicMock()
|
||||
@@ -290,7 +304,7 @@ class ProjectTests(unittest.TestCase):
|
||||
"""Check UploadForReview succeeds when git_event_log is None."""
|
||||
proj, _ = self._create_project_for_upload_test()
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd, mock.patch(
|
||||
"project.ReviewableBranch"
|
||||
) as mock_rb_cls:
|
||||
mock_cmd = mock.MagicMock()
|
||||
@@ -313,7 +327,7 @@ class ProjectTests(unittest.TestCase):
|
||||
"""Check exceptions during tracing are caught and do not fail upload."""
|
||||
proj, _ = self._create_project_for_upload_test()
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd, mock.patch(
|
||||
"project.ReviewableBranch"
|
||||
) as mock_rb_cls, mock.patch("project.logger") as mock_logger:
|
||||
mock_cmd = mock.MagicMock()
|
||||
@@ -338,7 +352,7 @@ class ProjectTests(unittest.TestCase):
|
||||
"""Check integration with real git_trace2_event_log.EventLog."""
|
||||
proj, _ = self._create_project_for_upload_test()
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd, mock.patch(
|
||||
"project.ReviewableBranch"
|
||||
) as mock_rb_cls:
|
||||
mock_cmd = mock.MagicMock()
|
||||
@@ -376,6 +390,36 @@ class ProjectTests(unittest.TestCase):
|
||||
data_map.get("repo.uploadstate/files"), "file1.py,file2.py"
|
||||
)
|
||||
|
||||
def test_upload_for_review_progress_flag(self) -> None:
|
||||
"""Check UploadForReview passes --no-progress iff quiet=True."""
|
||||
for quiet in (False, True):
|
||||
with self.subTest(quiet=quiet):
|
||||
proj, _ = self._create_project_for_upload_test()
|
||||
with mock.patch(
|
||||
"git_command.GitCommand"
|
||||
) as mock_git_cmd, mock.patch("project.ReviewableBranch"):
|
||||
mock_cmd = mock.MagicMock()
|
||||
mock_cmd.Wait.return_value = 0
|
||||
mock_cmd.stderr = ""
|
||||
mock_git_cmd.return_value = mock_cmd
|
||||
|
||||
project.Project.UploadForReview(
|
||||
proj,
|
||||
branch="test-branch",
|
||||
dryrun=True,
|
||||
quiet=quiet,
|
||||
)
|
||||
|
||||
mock_git_cmd.assert_called_once()
|
||||
_, args, _ = mock_git_cmd.mock_calls[0]
|
||||
cmd = args[1]
|
||||
if quiet:
|
||||
self.assertIn("--no-progress", cmd)
|
||||
else:
|
||||
self.assertNotIn("--no-progress", cmd)
|
||||
self.assertNotIn("--progress", cmd)
|
||||
self.assertNotIn("--quiet", cmd)
|
||||
|
||||
def test_get_head_revision_id(self):
|
||||
"""Check GetHeadRevisionId behavior."""
|
||||
with utils_for_test.TempGitTree() as tempdir:
|
||||
@@ -408,9 +452,9 @@ class ProjectTests(unittest.TestCase):
|
||||
command.Wait.return_value = 0
|
||||
|
||||
with mock.patch.object(
|
||||
project, "git_require", return_value=True
|
||||
git_command, "git_require", return_value=True
|
||||
), mock.patch.object(
|
||||
project, "GitCommand", return_value=command
|
||||
git_command, "GitCommand", return_value=command
|
||||
) as cmd:
|
||||
self.assertEqual("1" * 40, git.ResolveCommit("topic"))
|
||||
|
||||
@@ -437,7 +481,7 @@ class ProjectTests(unittest.TestCase):
|
||||
proj.name = "project"
|
||||
git = project.Project._GitGetByExec(proj, bare=True, gitdir="gitdir")
|
||||
|
||||
with mock.patch.object(project, "git_require", return_value=False):
|
||||
with mock.patch.object(git_command, "git_require", return_value=False):
|
||||
with self.assertRaises(error.GitError):
|
||||
git.ResolveCommit("--not-a-revision")
|
||||
|
||||
@@ -735,7 +779,9 @@ class ProjectTests(unittest.TestCase):
|
||||
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
|
||||
proj.HasStash = mock.MagicMock()
|
||||
|
||||
with mock.patch.object(project, "git_require", return_value=True):
|
||||
with mock.patch.object(
|
||||
git_command, "git_require", return_value=True
|
||||
):
|
||||
self.assertTrue(proj._HasDirtyOrStash())
|
||||
|
||||
proj.HasStash.assert_not_called()
|
||||
@@ -748,7 +794,9 @@ class ProjectTests(unittest.TestCase):
|
||||
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
|
||||
proj.HasStash = mock.MagicMock()
|
||||
|
||||
with mock.patch.object(project, "git_require", return_value=True):
|
||||
with mock.patch.object(
|
||||
git_command, "git_require", return_value=True
|
||||
):
|
||||
self.assertFalse(proj._HasDirtyOrStash())
|
||||
|
||||
proj.HasStash.assert_not_called()
|
||||
@@ -761,7 +809,9 @@ class ProjectTests(unittest.TestCase):
|
||||
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
|
||||
proj.HasStash = mock.MagicMock(return_value=True)
|
||||
|
||||
with mock.patch.object(project, "git_require", return_value=False):
|
||||
with mock.patch.object(
|
||||
git_command, "git_require", return_value=False
|
||||
):
|
||||
self.assertTrue(proj._HasDirtyOrStash())
|
||||
|
||||
proj._GetStatusSnapshot.assert_called_once_with(
|
||||
@@ -778,7 +828,7 @@ class ProjectTests(unittest.TestCase):
|
||||
for use_status in (True, False):
|
||||
with self.subTest(expected=expected, status=use_status):
|
||||
with mock.patch.object(
|
||||
project, "git_require", return_value=use_status
|
||||
git_command, "git_require", return_value=use_status
|
||||
):
|
||||
self.assertEqual(expected, proj.GetDirtyAndHead())
|
||||
|
||||
@@ -819,7 +869,9 @@ class ProjectTests(unittest.TestCase):
|
||||
proj._IsDirtyLegacy = mock.MagicMock(return_value=False)
|
||||
proj.HasStash = mock.MagicMock(return_value=False)
|
||||
|
||||
with mock.patch.object(project, "git_require", return_value=True):
|
||||
with mock.patch.object(
|
||||
git_command, "git_require", return_value=True
|
||||
):
|
||||
self.assertFalse(proj._HasDirtyOrStash())
|
||||
|
||||
proj._GetStatusSnapshot.assert_called_once_with(
|
||||
@@ -834,7 +886,9 @@ class ProjectTests(unittest.TestCase):
|
||||
proj.work_git = mock.MagicMock()
|
||||
proj.work_git.DiffZ.side_effect = [{}, {"tracked": mock.sentinel}]
|
||||
|
||||
with mock.patch.object(project, "git_require", return_value=False):
|
||||
with mock.patch.object(
|
||||
git_command, "git_require", return_value=False
|
||||
):
|
||||
self.assertTrue(proj.IsDirty())
|
||||
|
||||
proj.work_git.update_index.assert_called_once_with(
|
||||
@@ -851,7 +905,7 @@ class ProjectTests(unittest.TestCase):
|
||||
with utils_for_test.TempGitTree() as tempdir:
|
||||
proj = _create_mock_project(tempdir)
|
||||
with mock.patch.object(
|
||||
project, "GitCommand", autospec=True
|
||||
git_command, "GitCommand", autospec=True
|
||||
) as run_git:
|
||||
run_git.return_value.Wait.return_value = 0
|
||||
proj._FastForward("revision")
|
||||
@@ -1339,7 +1393,7 @@ class ProjectTests(unittest.TestCase):
|
||||
hex41 = "a" * 41
|
||||
proj.SetRevision(hex41)
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git:
|
||||
with mock.patch("git_command.GitCommand") as mock_git:
|
||||
mock_cmd = mock.MagicMock()
|
||||
mock_cmd.Wait.return_value = 0
|
||||
mock_git.return_value = mock_cmd
|
||||
@@ -1822,6 +1876,9 @@ class ManifestPropertiesFetchedCorrectly(unittest.TestCase):
|
||||
fakeproj.config.SetBoolean("repo.superproject", False)
|
||||
self.assertFalse(fakeproj.use_superproject)
|
||||
|
||||
fakeproj.config.SetBoolean("repo.smartsync", False)
|
||||
self.assertFalse(fakeproj.smart_sync)
|
||||
|
||||
fakeproj.config.SetBoolean("repo.partialclone", False)
|
||||
self.assertFalse(fakeproj.partial_clone)
|
||||
|
||||
@@ -2039,8 +2096,8 @@ def test_default_branch_fallback(
|
||||
project._DefaultBranchFallback.cache_clear()
|
||||
try:
|
||||
with mock.patch.object(
|
||||
project, "git_require", return_value=supports_git_var
|
||||
), mock.patch.object(project, "GitCommand", FakeGitCommand):
|
||||
git_command, "git_require", return_value=supports_git_var
|
||||
), mock.patch.object(git_command, "GitCommand", FakeGitCommand):
|
||||
assert project._DefaultBranchFallback() == expected_ref
|
||||
assert seen == expected_calls
|
||||
finally:
|
||||
@@ -2131,7 +2188,7 @@ def test_remote_fetch_retry_sleep(
|
||||
proj = _create_meta_project(tempdir, project_cls)
|
||||
proj.config.GetRemote("origin").ResetFetch()
|
||||
proj.revisionExpr = "refs/heads/stable"
|
||||
with mock.patch.object(project, "GitCommand") as mock_git:
|
||||
with mock.patch.object(git_command, "GitCommand") as mock_git:
|
||||
# What git does while stable is deleted.
|
||||
mock_git.return_value.Wait.return_value = 128
|
||||
mock_git.return_value.stdout = (
|
||||
@@ -2350,7 +2407,7 @@ class StatelessSyncTests(unittest.TestCase):
|
||||
proj.work_git = mock.MagicMock()
|
||||
syncbuf = project.SyncBuffer(proj.config)
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||
mock_cmd_instance = mock.MagicMock()
|
||||
mock_cmd_instance.Wait.return_value = 0
|
||||
mock_git_cmd.return_value = mock_cmd_instance
|
||||
@@ -2471,7 +2528,7 @@ class StatelessSyncTests(unittest.TestCase):
|
||||
proj.bare_ref.head = "refs/heads/topic"
|
||||
proj.work_git = mock.MagicMock()
|
||||
proj.work_git.GetHead.return_value = "refs/heads/topic"
|
||||
proj.work_git.merge_base.side_effect = project.GitError("diverged")
|
||||
proj.work_git.merge_base.side_effect = error.GitError("diverged")
|
||||
branch = mock.MagicMock()
|
||||
branch.name = "topic"
|
||||
branch.LocalMerge = "refs/remotes/origin/main"
|
||||
@@ -2699,7 +2756,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
||||
if os.path.exists(shallow_path):
|
||||
os.unlink(shallow_path)
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||
mock_cmd_instance = mock.MagicMock()
|
||||
mock_cmd_instance.Wait.return_value = 0
|
||||
mock_git_cmd.return_value = mock_cmd_instance
|
||||
@@ -2722,7 +2779,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
||||
with open(os.path.join(proj.gitdir, "shallow"), "w") as f:
|
||||
f.write("")
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||
res = proj._RemoteFetch(
|
||||
current_branch_only=True,
|
||||
depth=1,
|
||||
@@ -2745,7 +2802,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
||||
with open(os.path.join(other.gitdir, "shallow"), "w") as f:
|
||||
f.write("")
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||
mock_cmd_instance = mock.MagicMock()
|
||||
mock_cmd_instance.Wait.return_value = 0
|
||||
mock_git_cmd.return_value = mock_cmd_instance
|
||||
@@ -2774,7 +2831,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
||||
with open(os.path.join(other.gitdir, "shallow"), "w") as f:
|
||||
f.write("")
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||
res = proj._RemoteFetch(
|
||||
current_branch_only=True,
|
||||
depth=1,
|
||||
@@ -2968,7 +3025,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
||||
mock_remote.PreConnectFetch.return_value = True
|
||||
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||
mock_cmd_instance = mock.MagicMock()
|
||||
mock_cmd_instance.Wait.return_value = 0
|
||||
mock_git_cmd.return_value = mock_cmd_instance
|
||||
@@ -3007,7 +3064,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
||||
mock_remote.PreConnectFetch.return_value = True
|
||||
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||
mock_cmd_instance = mock.MagicMock()
|
||||
mock_cmd_instance.Wait.return_value = 0
|
||||
mock_git_cmd.return_value = mock_cmd_instance
|
||||
@@ -3049,7 +3106,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
||||
mock_remote.PreConnectFetch.return_value = True
|
||||
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||
mock_cmd_instance = mock.MagicMock()
|
||||
mock_cmd_instance.Wait.return_value = 0
|
||||
mock_git_cmd.return_value = mock_cmd_instance
|
||||
@@ -3097,7 +3154,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
||||
mock_remote.PreConnectFetch.return_value = True
|
||||
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||
mock_cmd_instance = mock.MagicMock()
|
||||
mock_cmd_instance.Wait.return_value = 0
|
||||
mock_git_cmd.return_value = mock_cmd_instance
|
||||
@@ -3199,7 +3256,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
||||
return inst
|
||||
|
||||
with mock.patch(
|
||||
"project.GitCommand", side_effect=_fake_git_command
|
||||
"git_command.GitCommand", side_effect=_fake_git_command
|
||||
):
|
||||
res = proj._RemoteFetch(current_branch_only=True)
|
||||
|
||||
@@ -3575,7 +3632,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
||||
) -> mock.MagicMock:
|
||||
"""Run _Reproject against mocked Git; return the subprocess mock."""
|
||||
with mock.patch(
|
||||
"project.GitCommand", side_effect=self._git_command(**outputs)
|
||||
"git_command.GitCommand", side_effect=self._git_command(**outputs)
|
||||
), mock.patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
||||
proj._Reproject(self.REVID)
|
||||
@@ -3586,7 +3643,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
||||
with utils_for_test.TempGitTree() as tempdir:
|
||||
proj = self._get_project(tempdir)
|
||||
with mock.patch(
|
||||
"project.GitCommand", side_effect=self._git_command()
|
||||
"git_command.GitCommand", side_effect=self._git_command()
|
||||
), mock.patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
||||
proj._Reproject(self.REVID)
|
||||
@@ -3646,7 +3703,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
||||
with utils_for_test.TempGitTree() as tempdir:
|
||||
proj = self._get_project(tempdir)
|
||||
with mock.patch(
|
||||
"project.GitCommand", side_effect=self._git_command()
|
||||
"git_command.GitCommand", side_effect=self._git_command()
|
||||
), mock.patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = mock.MagicMock(
|
||||
returncode=3, stdout="disk on fire\n"
|
||||
@@ -3662,7 +3719,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
||||
proj = self._get_project(tempdir)
|
||||
proj.work_git.GetHead.side_effect = [self.HEAD_ID, self.REVID]
|
||||
with mock.patch(
|
||||
"project.GitCommand", side_effect=self._git_command()
|
||||
"git_command.GitCommand", side_effect=self._git_command()
|
||||
), mock.patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
||||
with self.assertRaises(project.LocalSyncFail) as e:
|
||||
@@ -3680,7 +3737,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
||||
self.REVID, # HEAD after command
|
||||
]
|
||||
with mock.patch(
|
||||
"project.GitCommand", side_effect=self._git_command()
|
||||
"git_command.GitCommand", side_effect=self._git_command()
|
||||
), mock.patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
||||
with self.assertRaises(project.LocalSyncFail) as e:
|
||||
@@ -3692,7 +3749,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
||||
with utils_for_test.TempGitTree() as tempdir:
|
||||
proj = self._get_project(tempdir)
|
||||
with mock.patch(
|
||||
"project.GitCommand",
|
||||
"git_command.GitCommand",
|
||||
side_effect=self._git_command(diff="lib/a.c\n"),
|
||||
), mock.patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
||||
@@ -3736,8 +3793,10 @@ class ReprojectCmdTests(unittest.TestCase):
|
||||
|
||||
proj.bare_ref = mock.MagicMock()
|
||||
if branch:
|
||||
proj.bare_ref.head = project.R_HEADS + branch
|
||||
proj.bare_ref.all = {project.R_HEADS + branch: head} if head else {}
|
||||
proj.bare_ref.head = git_refs.R_HEADS + branch
|
||||
proj.bare_ref.all = (
|
||||
{git_refs.R_HEADS + branch: head} if head else {}
|
||||
)
|
||||
# A branch that does not track upstream; tests that need one
|
||||
# tracking upstream replace this with _tracking_branch().
|
||||
proj.GetBranch = mock.MagicMock(
|
||||
@@ -3750,7 +3809,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
||||
def _revlist(*args: Any, **kwargs: Any) -> List[str]:
|
||||
if kwargs.get("format"):
|
||||
return list(local_changes)
|
||||
if project.not_rev(project.HEAD) in args:
|
||||
if project.not_rev(git_refs.HEAD) in args:
|
||||
if "--count" in args:
|
||||
return [str(len(upstream_gain))]
|
||||
return list(upstream_gain)
|
||||
@@ -3853,7 +3912,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
||||
self.assertTrue(syncbuf.Finish())
|
||||
proj._Reproject.assert_called_once_with(self.REVID, verbose=False)
|
||||
proj.work_git.UpdateRef.assert_called_once_with(
|
||||
project.HEAD,
|
||||
git_refs.HEAD,
|
||||
self.REVID,
|
||||
old=self.HEAD_ID,
|
||||
message=f"merge {self.REVID}: Fast-forward",
|
||||
@@ -3900,7 +3959,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
||||
self.assertTrue(syncbuf.Finish())
|
||||
proj._Reproject.assert_called_once_with(self.REVID, verbose=False)
|
||||
proj.work_git.UpdateRef.assert_called_once_with(
|
||||
project.HEAD,
|
||||
git_refs.HEAD,
|
||||
self.REVID,
|
||||
old=self.HEAD_ID,
|
||||
message=f"reset: moving to {self.REVID}",
|
||||
@@ -4358,9 +4417,9 @@ class DownloadPatchSetTests(unittest.TestCase):
|
||||
def test_download_patch_set_with_no_filter_and_no_tags(self) -> None:
|
||||
"""Test DownloadPatchSet includes --no-filter and --no-tags."""
|
||||
with mock.patch(
|
||||
"project.git_require", return_value=True
|
||||
"git_command.git_require", return_value=True
|
||||
) as mock_git_require:
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||
mock_cmd_instance = mock.MagicMock()
|
||||
mock_cmd_instance.Wait.return_value = 0
|
||||
mock_git_cmd.return_value = mock_cmd_instance
|
||||
@@ -4386,8 +4445,8 @@ class DownloadPatchSetTests(unittest.TestCase):
|
||||
|
||||
def test_download_patch_set_legacy_git_omits_no_filter(self) -> None:
|
||||
"""Test DownloadPatchSet omits --no-filter on git < 2.17."""
|
||||
with mock.patch("project.git_require", return_value=False):
|
||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
||||
with mock.patch("git_command.git_require", return_value=False):
|
||||
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||
mock_cmd_instance = mock.MagicMock()
|
||||
mock_cmd_instance.Wait.return_value = 0
|
||||
mock_git_cmd.return_value = mock_cmd_instance
|
||||
|
||||
@@ -23,16 +23,16 @@ from unittest import mock
|
||||
import pytest
|
||||
|
||||
from color import SetDefaultColoring
|
||||
from error import RepoExitError
|
||||
from repo_logging import RepoLogger
|
||||
import error
|
||||
import repo_logging
|
||||
|
||||
|
||||
@mock.patch.object(RepoLogger, "error")
|
||||
@mock.patch.object(repo_logging.RepoLogger, "error")
|
||||
def test_log_aggregated_errors_logs_aggregated_errors(mock_error) -> None:
|
||||
"""Test if log_aggregated_errors logs a list of aggregated errors."""
|
||||
logger = RepoLogger(__name__)
|
||||
logger = repo_logging.RepoLogger(__name__)
|
||||
logger.log_aggregated_errors(
|
||||
RepoExitError(
|
||||
error.RepoExitError(
|
||||
aggregate_errors=[
|
||||
Exception("foo"),
|
||||
Exception("bar"),
|
||||
@@ -57,11 +57,11 @@ def test_log_aggregated_errors_logs_aggregated_errors(mock_error) -> None:
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(RepoLogger, "error")
|
||||
@mock.patch.object(repo_logging.RepoLogger, "error")
|
||||
def test_log_aggregated_errors_logs_single_error(mock_error) -> None:
|
||||
"""Test if log_aggregated_errors logs empty aggregated_errors."""
|
||||
logger = RepoLogger(__name__)
|
||||
logger.log_aggregated_errors(RepoExitError())
|
||||
logger = repo_logging.RepoLogger(__name__)
|
||||
logger.log_aggregated_errors(error.RepoExitError())
|
||||
|
||||
mock_error.assert_has_calls(
|
||||
[
|
||||
@@ -99,7 +99,7 @@ def test_log_with_format_string(level: int) -> None:
|
||||
output = io.StringIO()
|
||||
|
||||
with contextlib.redirect_stderr(output):
|
||||
logger = RepoLogger(__name__)
|
||||
logger = repo_logging.RepoLogger(__name__)
|
||||
logger.log(level, "%s", "100% pass")
|
||||
|
||||
assert re.search(
|
||||
|
||||
@@ -18,8 +18,8 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from error import GitError
|
||||
from git_command import GitCommand
|
||||
import error
|
||||
import git_command
|
||||
from subcmds import cherry_pick
|
||||
|
||||
|
||||
@@ -30,12 +30,12 @@ def test_resolve_reference_uses_one_typed_batch_request(
|
||||
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(GitCommand, instance=True)
|
||||
command = mock.create_autospec(git_command.GitCommand, instance=True)
|
||||
command.stdout = output
|
||||
command.stderr = ""
|
||||
command.Wait.return_value = 0
|
||||
run_git = mock.create_autospec(GitCommand, return_value=command)
|
||||
monkeypatch.setattr(cherry_pick, "GitCommand", run_git)
|
||||
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")
|
||||
|
||||
@@ -54,32 +54,32 @@ def test_resolve_reference_uses_one_typed_batch_request(
|
||||
def test_resolve_reference_rejects_missing_object(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
command = mock.create_autospec(GitCommand, instance=True)
|
||||
command = mock.create_autospec(git_command.GitCommand, instance=True)
|
||||
command.stdout = "topic^{commit} missing\n"
|
||||
command.stderr = ""
|
||||
command.Wait.return_value = 0
|
||||
monkeypatch.setattr(
|
||||
cherry_pick,
|
||||
git_command,
|
||||
"GitCommand",
|
||||
mock.create_autospec(GitCommand, return_value=command),
|
||||
mock.create_autospec(git_command.GitCommand, return_value=command),
|
||||
)
|
||||
|
||||
with pytest.raises(GitError, match="commit topic not found"):
|
||||
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(GitCommand, instance=True)
|
||||
command = mock.create_autospec(git_command.GitCommand, instance=True)
|
||||
command.stdout = "topic^{commit} ambiguous\n"
|
||||
command.stderr = ""
|
||||
command.Wait.return_value = 0
|
||||
monkeypatch.setattr(
|
||||
cherry_pick,
|
||||
git_command,
|
||||
"GitCommand",
|
||||
mock.create_autospec(GitCommand, return_value=command),
|
||||
mock.create_autospec(git_command.GitCommand, return_value=command),
|
||||
)
|
||||
|
||||
with pytest.raises(GitError, match="commit topic not found"):
|
||||
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)
|
||||
@@ -23,7 +23,13 @@ from subcmds import init
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
([],),
|
||||
(
|
||||
[],
|
||||
["-s"],
|
||||
["--smart-sync"],
|
||||
["--no-smart-sync"],
|
||||
["--head"],
|
||||
),
|
||||
)
|
||||
def test_cli_parser_good(argv: List[str]) -> None:
|
||||
"""Check valid command line options."""
|
||||
|
||||
@@ -19,7 +19,8 @@ import io
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from error import GitError
|
||||
import error
|
||||
import git_command
|
||||
from subcmds import rebase
|
||||
|
||||
|
||||
@@ -44,7 +45,7 @@ def test_resolve_onto_manifest_fallback() -> None:
|
||||
project.revisionExpr = "main"
|
||||
|
||||
remote = mock.MagicMock()
|
||||
remote.ToLocal.side_effect = GitError("Failed to resolve")
|
||||
remote.ToLocal.side_effect = error.GitError("Failed to resolve")
|
||||
project.GetRemote.return_value = remote
|
||||
|
||||
res = rebase._ResolveOntoManifest(project)
|
||||
@@ -77,11 +78,11 @@ def test_execute_delegates_autostash_to_rebase() -> None:
|
||||
onto_manifest=False,
|
||||
this_manifest_only=False,
|
||||
)
|
||||
git_command = mock.MagicMock()
|
||||
git_command.Wait.return_value = 0
|
||||
command = mock.MagicMock()
|
||||
command.Wait.return_value = 0
|
||||
|
||||
with mock.patch.object(
|
||||
rebase, "GitCommand", return_value=git_command
|
||||
git_command, "GitCommand", return_value=command
|
||||
) as run_git, contextlib.redirect_stdout(io.StringIO()):
|
||||
assert cmd.Execute(opt, []) == 0
|
||||
|
||||
|
||||
+96
-35
@@ -31,9 +31,8 @@ import xmlrpc.server
|
||||
import pytest
|
||||
|
||||
import command
|
||||
from error import GitError
|
||||
from error import ManifestParseError
|
||||
from error import RepoExitError
|
||||
import error
|
||||
import git_command
|
||||
import manifest_xml
|
||||
from project import SyncNetworkHalfResult
|
||||
from subcmds import sync
|
||||
@@ -465,6 +464,8 @@ def test_cli_jobs_sync_j_max(
|
||||
(["--smart-sync"], True, True, False),
|
||||
(["--no-smart-sync"], True, False, False),
|
||||
(["--no-smart-sync"], False, False, False),
|
||||
(["--head"], True, False, False),
|
||||
(["--head"], False, False, False),
|
||||
(["-t", "tag123"], True, False, False),
|
||||
(["--smart-tag=tag123"], True, False, False),
|
||||
(["-m", "other.xml"], True, False, False),
|
||||
@@ -491,6 +492,7 @@ def test_cli_smart_sync(
|
||||
"""
|
||||
manifest = mock.MagicMock()
|
||||
manifest.default.sync_smartsync = sync_smartsync_manifest
|
||||
manifest.manifestProject.smart_sync = None
|
||||
|
||||
cmd = sync.Sync(manifest=manifest)
|
||||
opts, args = cmd.OptionParser.parse_args(argv)
|
||||
@@ -500,6 +502,65 @@ def test_cli_smart_sync(
|
||||
assert implicit == expected_implicit
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv, config_smart_sync, user_pref, sync_smartsync_manifest, "
|
||||
"expected_smart_sync, expected_implicit",
|
||||
[
|
||||
# Config True overrides manifest False/None and user_pref False.
|
||||
([], True, None, False, True, True),
|
||||
([], True, None, None, True, True),
|
||||
([], True, False, False, True, True),
|
||||
# Config False overrides manifest True and user_pref True.
|
||||
([], False, None, True, False, False),
|
||||
([], False, True, True, False, False),
|
||||
# Config None falls back to user_pref, then manifest.
|
||||
([], None, True, False, True, False),
|
||||
([], None, False, True, False, False),
|
||||
([], None, None, True, True, True),
|
||||
([], None, None, False, False, False),
|
||||
# CLI flags override config.
|
||||
(["-s"], False, None, False, True, False),
|
||||
(["--smart-sync"], False, None, True, True, False),
|
||||
(["--no-smart-sync"], True, None, True, False, False),
|
||||
(["--no-smart-sync"], True, None, False, False, False),
|
||||
(["--head"], True, None, True, False, False),
|
||||
(["--head"], True, None, False, False, False),
|
||||
# Conflicting flags suppress implicit smart sync from config.
|
||||
(["-t", "tag123"], True, None, False, False, False),
|
||||
(["-m", "other.xml"], True, None, False, False, False),
|
||||
(["-l"], True, None, False, False, False),
|
||||
(["--nmu"], True, None, False, True, True),
|
||||
(["--superproject-revision=abc"], True, None, False, False, False),
|
||||
(["-s", "-l"], True, None, False, True, False),
|
||||
],
|
||||
)
|
||||
def test_cli_smart_sync_with_config(
|
||||
argv: List[str],
|
||||
config_smart_sync: Optional[bool],
|
||||
user_pref: Optional[bool],
|
||||
sync_smartsync_manifest: Optional[bool],
|
||||
expected_smart_sync: bool,
|
||||
expected_implicit: bool,
|
||||
) -> None:
|
||||
"""Tests repo.smartsync config precedence between CLI, user, and
|
||||
manifest.
|
||||
"""
|
||||
manifest = mock.MagicMock()
|
||||
manifest.default.sync_smartsync = sync_smartsync_manifest
|
||||
manifest.manifestProject.smart_sync = config_smart_sync
|
||||
|
||||
user_cfg = mock.MagicMock()
|
||||
user_cfg.GetBoolean.return_value = user_pref
|
||||
|
||||
cmd = sync.Sync(manifest=manifest)
|
||||
opts, args = cmd.OptionParser.parse_args(argv)
|
||||
cmd.ValidateOptions(opts, args)
|
||||
with mock.patch.object(sync.RepoConfig, "ForUser", return_value=user_cfg):
|
||||
implicit = cmd._ResolveSmartSyncOption(opts, manifest)
|
||||
assert opts.smart_sync == expected_smart_sync
|
||||
assert implicit == expected_implicit
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv, user_pref, sync_smartsync_manifest, expected_smart_sync, "
|
||||
"expected_implicit",
|
||||
@@ -1415,14 +1476,14 @@ class CheckForBloatedProjects(unittest.TestCase):
|
||||
self.project.GetRevisionId.assert_not_called()
|
||||
self.project.bare_git.count_objects.assert_not_called()
|
||||
|
||||
@mock.patch("subcmds.sync.git_require")
|
||||
@mock.patch.object(git_command, "git_require")
|
||||
def test_git_version_unsupported(self, mock_git_require):
|
||||
"""Test that it returns early if git version is unsupported."""
|
||||
mock_git_require.return_value = False
|
||||
self.cmd._CheckForBloatedProjects([self.project], self.opt)
|
||||
self.assertFalse(self.cmd.git_event_log.ErrorEvent.called)
|
||||
|
||||
@mock.patch("subcmds.sync.git_require")
|
||||
@mock.patch.object(git_command, "git_require")
|
||||
def test_no_projects(self, mock_git_require):
|
||||
"""Test that it returns early if no projects have clone_depth."""
|
||||
mock_git_require.return_value = True
|
||||
@@ -1430,7 +1491,7 @@ class CheckForBloatedProjects(unittest.TestCase):
|
||||
self.cmd._CheckForBloatedProjects([self.project], self.opt)
|
||||
self.assertFalse(self.cmd.git_event_log.ErrorEvent.called)
|
||||
|
||||
@mock.patch("subcmds.sync.git_require", return_value=True)
|
||||
@mock.patch.object(git_command, "git_require", return_value=True)
|
||||
@mock.patch("subcmds.sync.Progress")
|
||||
def test_network_only_skips_check(
|
||||
self, mock_progress: mock.Mock, mock_git_require: mock.Mock
|
||||
@@ -1444,7 +1505,7 @@ class CheckForBloatedProjects(unittest.TestCase):
|
||||
mock_progress.assert_not_called()
|
||||
self.cmd.ExecuteInParallel.assert_not_called()
|
||||
|
||||
@mock.patch("subcmds.sync.git_require", return_value=True)
|
||||
@mock.patch.object(git_command, "git_require", return_value=True)
|
||||
@mock.patch("subcmds.sync.Progress")
|
||||
def test_projects_without_worktree_excluded(
|
||||
self, mock_progress: mock.Mock, mock_git_require: mock.Mock
|
||||
@@ -1466,7 +1527,7 @@ class CheckForBloatedProjects(unittest.TestCase):
|
||||
mock_progress.assert_not_called()
|
||||
self.cmd.ExecuteInParallel.assert_not_called()
|
||||
|
||||
@mock.patch("subcmds.sync.git_require")
|
||||
@mock.patch.object(git_command, "git_require")
|
||||
@mock.patch("subcmds.sync.Progress")
|
||||
def test_bloated_project_found(self, mock_progress, mock_git_require):
|
||||
"""Test that it adds project to _bloated_projects."""
|
||||
@@ -1489,7 +1550,7 @@ class CheckForBloatedProjects(unittest.TestCase):
|
||||
|
||||
self.assertEqual(self.cmd._bloated_projects, ["project"])
|
||||
|
||||
@mock.patch("subcmds.sync.git_require")
|
||||
@mock.patch.object(git_command, "git_require")
|
||||
@mock.patch("subcmds.sync.Progress")
|
||||
def test_stateless_prune_excluded(self, mock_progress, mock_git_require):
|
||||
"""Test that projects pruned for stateless sync are excluded."""
|
||||
@@ -1631,13 +1692,13 @@ class SyncCommand(unittest.TestCase):
|
||||
|
||||
def test_command_exit_error(self):
|
||||
"""Ensure unsuccessful commands raise expected errors."""
|
||||
self.sync_network_half_error = GitError(
|
||||
self.sync_network_half_error = error.GitError(
|
||||
"sync_network_half_error error", project=self.project
|
||||
)
|
||||
self.sync_local_half_error = GitError(
|
||||
self.sync_local_half_error = error.GitError(
|
||||
"sync_local_half_error", project=self.project
|
||||
)
|
||||
with self.assertRaises(RepoExitError) as e:
|
||||
with self.assertRaises(error.RepoExitError) as e:
|
||||
self.cmd.Execute(self.opt, [])
|
||||
self.assertIn(self.sync_local_half_error, e.aggregate_errors)
|
||||
self.assertIn(self.sync_network_half_error, e.aggregate_errors)
|
||||
@@ -1689,7 +1750,7 @@ class SyncCommand(unittest.TestCase):
|
||||
p_foo_bar = FakeProject("foo/bar")
|
||||
p_foo.UseReprojectCmd = p_foo_bar.UseReprojectCmd = True
|
||||
self.cmd.GetProjects.return_value = [p_foo, p_foo_bar]
|
||||
with self.assertRaises(sync.SyncError) as e:
|
||||
with self.assertRaises(error.SyncError) as e:
|
||||
self._ExecuteUntilSync([])
|
||||
self.assertIn("foo/bar", str(e.exception))
|
||||
self.assertNotIn(" - foo\n", str(e.exception))
|
||||
@@ -1700,7 +1761,7 @@ class SyncCommand(unittest.TestCase):
|
||||
p_sub = FakeProject("foo/sub", parent=p_foo, is_derived=True)
|
||||
p_foo.UseReprojectCmd = p_sub.UseReprojectCmd = True
|
||||
self.cmd.GetProjects.return_value = [p_foo, p_sub]
|
||||
with self.assertRaises(sync.SyncError) as e:
|
||||
with self.assertRaises(error.SyncError) as e:
|
||||
self._ExecuteUntilSync([])
|
||||
self.assertIn("foo/sub", str(e.exception))
|
||||
|
||||
@@ -1712,7 +1773,7 @@ class SyncCommand(unittest.TestCase):
|
||||
self.cmd.GetProjects.side_effect = lambda args, **kwargs: (
|
||||
[p_foo_bar] if args else [p_foo, p_foo_bar]
|
||||
)
|
||||
with self.assertRaises(sync.SyncError):
|
||||
with self.assertRaises(error.SyncError):
|
||||
self._ExecuteUntilSync(["foo/bar"])
|
||||
self.assertEqual(self.cmd.GetProjects.call_count, 2)
|
||||
_, kwargs = self.cmd.GetProjects.call_args
|
||||
@@ -1843,7 +1904,7 @@ class SyncCommand(unittest.TestCase):
|
||||
with mock.patch.object(
|
||||
self.cmd,
|
||||
"_SmartSyncSetup",
|
||||
side_effect=ManifestParseError("mismatched tag"),
|
||||
side_effect=error.ManifestParseError("mismatched tag"),
|
||||
):
|
||||
with mock.patch.object(self.cmd, "_UpdateRepoProject"):
|
||||
with mock.patch.object(
|
||||
@@ -1864,9 +1925,9 @@ class SyncCommand(unittest.TestCase):
|
||||
with mock.patch.object(
|
||||
self.cmd,
|
||||
"_SmartSyncSetup",
|
||||
side_effect=ManifestParseError("mismatched tag"),
|
||||
side_effect=error.ManifestParseError("mismatched tag"),
|
||||
):
|
||||
with self.assertRaises(ManifestParseError):
|
||||
with self.assertRaises(error.ManifestParseError):
|
||||
self.cmd.Execute(self.opt, [])
|
||||
|
||||
def _ExecuteWithSmartSyncOverride(
|
||||
@@ -1988,7 +2049,7 @@ class SyncCommand(unittest.TestCase):
|
||||
self.opt.smart_sync = None
|
||||
self.opt.local_only = True
|
||||
path, _, _, update_mps = self._ExecuteWithSmartSyncOverride(
|
||||
reload_error=ManifestParseError("mismatched tag")
|
||||
reload_error=error.ManifestParseError("mismatched tag")
|
||||
)
|
||||
self.manifest.ClearOverride.assert_called_once_with()
|
||||
self.assertFalse(os.path.isfile(path))
|
||||
@@ -2085,7 +2146,7 @@ class SyncUpdateRepoProject(unittest.TestCase):
|
||||
self.manifest.repoProject.LastFetch = time.time() - (
|
||||
sync._ONE_DAY_S + 1
|
||||
)
|
||||
fetch_error = GitError("Fetch failed")
|
||||
fetch_error = error.GitError("Fetch failed")
|
||||
self.manifest.repoProject.Sync_NetworkHalf.return_value = (
|
||||
SyncNetworkHalfResult(False, fetch_error)
|
||||
)
|
||||
@@ -2378,10 +2439,10 @@ class InterleavedSyncTest(unittest.TestCase):
|
||||
sub = self._make_syncable(
|
||||
FakeProject("foo", name="sub", objdir="b", path_prefix="sub")
|
||||
)
|
||||
sub.Sync_LocalHalf.side_effect = GitError("checkout failed")
|
||||
sub.Sync_LocalHalf.side_effect = error.GitError("checkout failed")
|
||||
self.cmd.git_event_log = mock.MagicMock()
|
||||
|
||||
with self.assertRaises(sync.SyncError):
|
||||
with self.assertRaises(error.SyncError):
|
||||
self._run_interleaved(opt, [outer, sub], [outer, sub])
|
||||
|
||||
self.assertEqual(
|
||||
@@ -2503,7 +2564,7 @@ class InterleavedSyncTest(unittest.TestCase):
|
||||
"""Test _SyncProjectList with a failed fetch."""
|
||||
opt = self._get_opts()
|
||||
project = self.projA
|
||||
fetch_error = GitError("Fetch failed")
|
||||
fetch_error = error.GitError("Fetch failed")
|
||||
project.Sync_NetworkHalf = mock.Mock(
|
||||
return_value=SyncNetworkHalfResult(
|
||||
error=fetch_error, remote_fetched=False
|
||||
@@ -2545,7 +2606,7 @@ class InterleavedSyncTest(unittest.TestCase):
|
||||
"""Test _SyncProjectList with an exception during fetch."""
|
||||
opt = self._get_opts()
|
||||
project = self.projA
|
||||
fetch_error = GitError("Fetch failed")
|
||||
fetch_error = error.GitError("Fetch failed")
|
||||
project.Sync_NetworkHalf = mock.Mock(side_effect=fetch_error)
|
||||
project.Sync_LocalHalf = mock.Mock()
|
||||
self.mock_context["projects"] = [project]
|
||||
@@ -2566,7 +2627,7 @@ class InterleavedSyncTest(unittest.TestCase):
|
||||
project.Sync_NetworkHalf = mock.Mock(
|
||||
return_value=SyncNetworkHalfResult(error=None, remote_fetched=True)
|
||||
)
|
||||
checkout_error = GitError("Checkout failed")
|
||||
checkout_error = error.GitError("Checkout failed")
|
||||
project.Sync_LocalHalf = mock.Mock(side_effect=checkout_error)
|
||||
project.manifest.manifestProject.config = mock.MagicMock()
|
||||
self.mock_context["projects"] = [project]
|
||||
@@ -2787,7 +2848,7 @@ class SyncToSuperprojectRevTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.repodir)
|
||||
|
||||
@mock.patch("subcmds.sync.GitCommand")
|
||||
@mock.patch.object(git_command, "GitCommand")
|
||||
def test_successful_sync(self, mock_git_command):
|
||||
"""Test successful sync to superproject rev."""
|
||||
mock_superproject = self.manifest.superproject
|
||||
@@ -2812,7 +2873,7 @@ class SyncToSuperprojectRevTests(unittest.TestCase):
|
||||
mock_update.assert_called_once()
|
||||
self.assertEqual(self.errors, [])
|
||||
|
||||
@mock.patch("subcmds.sync.GitCommand")
|
||||
@mock.patch.object(git_command, "GitCommand")
|
||||
def test_parse_error(self, mock_git_command):
|
||||
"""Test error when .supermanifest cannot be parsed."""
|
||||
mock_superproject = self.manifest.superproject
|
||||
@@ -2824,13 +2885,13 @@ class SyncToSuperprojectRevTests(unittest.TestCase):
|
||||
mock_git.stdout = "invalid_content\n"
|
||||
mock_git_command.return_value = mock_git
|
||||
|
||||
with self.assertRaises(sync.SyncError) as e:
|
||||
with self.assertRaises(error.SyncError) as e:
|
||||
self.cmd._SyncToSuperprojectRev(
|
||||
self.opt, self.manifest, self.mp, "name", self.errors
|
||||
)
|
||||
self.assertIn("could not parse .supermanifest", str(e.exception))
|
||||
|
||||
@mock.patch("subcmds.sync.GitCommand")
|
||||
@mock.patch.object(git_command, "GitCommand")
|
||||
def test_read_error(self, mock_git_command):
|
||||
"""Test error when reading .supermanifest fails."""
|
||||
mock_superproject = self.manifest.superproject
|
||||
@@ -2841,7 +2902,7 @@ class SyncToSuperprojectRevTests(unittest.TestCase):
|
||||
mock_git.stderr = "git error"
|
||||
mock_git_command.return_value = mock_git
|
||||
|
||||
with self.assertRaises(sync.SyncError) as e:
|
||||
with self.assertRaises(error.SyncError) as e:
|
||||
self.cmd._SyncToSuperprojectRev(
|
||||
self.opt, self.manifest, self.mp, "name", self.errors
|
||||
)
|
||||
@@ -2851,19 +2912,19 @@ class SyncToSuperprojectRevTests(unittest.TestCase):
|
||||
"""Test error when superproject is not defined."""
|
||||
self.manifest.superproject = None
|
||||
|
||||
with self.assertRaises(sync.SyncError) as e:
|
||||
with self.assertRaises(error.SyncError) as e:
|
||||
self.cmd._SyncToSuperprojectRev(
|
||||
self.opt, self.manifest, self.mp, "name", self.errors
|
||||
)
|
||||
self.assertIn("superproject not defined", str(e.exception))
|
||||
|
||||
@mock.patch("subcmds.sync.GitCommand")
|
||||
@mock.patch.object(git_command, "GitCommand")
|
||||
def test_sync_failure(self, mock_git_command):
|
||||
"""Test error when superproject sync fails."""
|
||||
mock_superproject = self.manifest.superproject
|
||||
mock_superproject.Sync.return_value = mock.Mock(success=False)
|
||||
|
||||
with self.assertRaises(sync.SyncError) as e:
|
||||
with self.assertRaises(error.SyncError) as e:
|
||||
self.cmd._SyncToSuperprojectRev(
|
||||
self.opt, self.manifest, self.mp, "name", self.errors
|
||||
)
|
||||
@@ -3034,11 +3095,11 @@ class TestSmartSyncSetupRemoteHelper(unittest.TestCase):
|
||||
|
||||
self.cmd._GetBranch = mock.MagicMock(return_value="main")
|
||||
self.cmd._ReloadManifest = mock.MagicMock(
|
||||
side_effect=ManifestParseError("mismatched tag")
|
||||
side_effect=error.ManifestParseError("mismatched tag")
|
||||
)
|
||||
|
||||
with mock.patch("builtins.open", mock.mock_open()):
|
||||
with self.assertRaises(ManifestParseError):
|
||||
with self.assertRaises(error.ManifestParseError):
|
||||
self.cmd._SmartSyncSetup(
|
||||
self.opt, self.smart_sync_manifest_path, self.manifest
|
||||
)
|
||||
|
||||
@@ -19,8 +19,7 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from error import GitError
|
||||
from error import UploadError
|
||||
import error
|
||||
from subcmds import upload
|
||||
|
||||
|
||||
@@ -45,7 +44,9 @@ def cmd() -> upload.Upload:
|
||||
def test_UploadAndReport_UploadError(cmd: upload.Upload) -> None:
|
||||
"""Check UploadExitError raised when UploadError encountered."""
|
||||
opt, _ = cmd.OptionParser.parse_args([])
|
||||
with mock.patch.object(cmd, "_UploadBranch", side_effect=UploadError("")):
|
||||
with mock.patch.object(
|
||||
cmd, "_UploadBranch", side_effect=error.UploadError("")
|
||||
):
|
||||
with pytest.raises(upload.UploadExitError):
|
||||
cmd._UploadAndReport(opt, [mock.MagicMock()], _STUB_PEOPLE)
|
||||
|
||||
@@ -53,7 +54,9 @@ def test_UploadAndReport_UploadError(cmd: upload.Upload) -> None:
|
||||
def test_UploadAndReport_GitError(cmd: upload.Upload) -> None:
|
||||
"""Check UploadExitError raised when GitError encountered."""
|
||||
opt, _ = cmd.OptionParser.parse_args([])
|
||||
with mock.patch.object(cmd, "_UploadBranch", side_effect=GitError("")):
|
||||
with mock.patch.object(
|
||||
cmd, "_UploadBranch", side_effect=error.GitError("")
|
||||
):
|
||||
with pytest.raises(upload.UploadExitError):
|
||||
cmd._UploadAndReport(opt, [mock.MagicMock()], _STUB_PEOPLE)
|
||||
|
||||
@@ -275,3 +278,55 @@ def test_ValidateOptions_allowed(cmd: upload.Upload, argv: List[str]) -> None:
|
||||
"""Check valid hook option combinations are accepted."""
|
||||
opt, args = cmd.OptionParser.parse_args(argv)
|
||||
cmd.ValidateOptions(opt, args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("argv,expected_quiet", [([], False), (["-q"], True)])
|
||||
def test_UploadBranch_forwards_quiet(
|
||||
cmd: upload.Upload, argv: List[str], expected_quiet: bool
|
||||
) -> None:
|
||||
"""Check _UploadBranch forwards opt.quiet to branch.UploadForReview."""
|
||||
opt, _ = cmd.OptionParser.parse_args(argv)
|
||||
cmd.CommonValidateOptions(opt, [])
|
||||
branch = mock.MagicMock()
|
||||
branch.project.dest_branch = None
|
||||
with mock.patch("git_superproject.UseSuperproject", return_value=False):
|
||||
cmd._UploadBranch(opt, branch, _STUB_PEOPLE)
|
||||
|
||||
branch.UploadForReview.assert_called_once()
|
||||
_, kwargs = branch.UploadForReview.call_args
|
||||
assert kwargs["quiet"] is expected_quiet
|
||||
|
||||
|
||||
def test_UploadAndReport_quiet_suppresses_ok_summary(
|
||||
cmd: upload.Upload, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Check -q suppresses non-error [OK] summary output."""
|
||||
opt, _ = cmd.OptionParser.parse_args(["-q"])
|
||||
cmd.CommonValidateOptions(opt, [])
|
||||
branch = mock.MagicMock()
|
||||
branch.name = "topic"
|
||||
branch.project.RelPath.return_value = "proj"
|
||||
with mock.patch.object(cmd, "_UploadBranch"):
|
||||
cmd._UploadAndReport(opt, [branch], _STUB_PEOPLE)
|
||||
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_UploadAndReport_quiet_still_prints_errors(
|
||||
cmd: upload.Upload, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Check -q still prints [FAILED] error summary when upload fails."""
|
||||
opt, _ = cmd.OptionParser.parse_args(["-q"])
|
||||
cmd.CommonValidateOptions(opt, [])
|
||||
branch = mock.MagicMock()
|
||||
branch.name = "topic"
|
||||
branch.project.RelPath.return_value = "proj"
|
||||
with mock.patch.object(
|
||||
cmd, "_UploadBranch", side_effect=error.UploadError("boom")
|
||||
):
|
||||
with pytest.raises(upload.UploadExitError):
|
||||
cmd._UploadAndReport(opt, [branch], _STUB_PEOPLE)
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "[FAILED]" in err
|
||||
assert "[OK ]" not in err
|
||||
@@ -18,6 +18,7 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import git_command
|
||||
from subcmds import version
|
||||
|
||||
|
||||
@@ -26,7 +27,7 @@ def test_repo_version_uses_one_pretty_format_call(
|
||||
) -> None:
|
||||
project = mock.MagicMock()
|
||||
project.bare_git.log.return_value = "v2.0-1-g12345678\nTue, 25 Aug\n"
|
||||
monkeypatch.setattr(version, "git_require", lambda _version: True)
|
||||
monkeypatch.setattr(git_command, "git_require", lambda _version: True)
|
||||
|
||||
result = version.Version._RepoVersion(project)
|
||||
|
||||
@@ -43,7 +44,7 @@ def test_repo_version_keeps_old_git_fallback(
|
||||
project = mock.MagicMock()
|
||||
project.bare_git.describe.return_value = "v2.0"
|
||||
project.bare_git.log.return_value = "Tue, 25 Aug"
|
||||
monkeypatch.setattr(version, "git_require", lambda _version: False)
|
||||
monkeypatch.setattr(git_command, "git_require", lambda _version: False)
|
||||
|
||||
result = version.Version._RepoVersion(project)
|
||||
|
||||
|
||||
Reference in new issue
Block a user