mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-09-22 06:40:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c7e0a683e | ||
|
|
f6f5946422 | ||
|
|
6321b26685 | ||
|
|
7bffc72b5b | ||
|
|
aaadd5da35 | ||
|
|
c9448f986e | ||
|
|
530258d08e | ||
|
|
c1566487a8 | ||
|
|
22f820de17 | ||
|
|
4c37f58806 | ||
|
|
c86ddc1628 | ||
|
|
c638b54e19 | ||
|
|
fb4a91060e | ||
|
|
4b3ada1781 | ||
|
|
a468ea7752 | ||
|
|
c2c330ba4a | ||
|
|
cc88be34d2 | ||
|
|
16cfb53e0a | ||
|
|
578b57c975 | ||
|
|
fe2c23f8e2 |
+68
-17
@@ -17,7 +17,7 @@ import multiprocessing
|
||||
import optparse
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
from error import InvalidProjectGroupsError
|
||||
from error import NoSuchProjectError
|
||||
@@ -152,6 +152,20 @@ class Command:
|
||||
self._Options(self._optparse)
|
||||
return self._optparse
|
||||
|
||||
@staticmethod
|
||||
def _GetHelpForCpuJobCount(
|
||||
default_jobs: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Return CPU-based job help, with an explicit default when needed.
|
||||
|
||||
Specify default_jobs when additional logic computes effective default.
|
||||
"""
|
||||
if GENERATE_MANPAGES:
|
||||
return "based on number of CPU cores"
|
||||
|
||||
default = "%default" if default_jobs is None else str(default_jobs)
|
||||
return f"{default}; based on number of CPU cores"
|
||||
|
||||
def _CommonOptions(self, p, opt_v=True):
|
||||
"""Initialize the option parser with common options.
|
||||
|
||||
@@ -176,11 +190,7 @@ class Command:
|
||||
)
|
||||
|
||||
if self.PARALLEL_JOBS is not None:
|
||||
default = "based on number of CPU cores"
|
||||
if not GENERATE_MANPAGES:
|
||||
# Only include active cpu count if we aren't generating man
|
||||
# pages.
|
||||
default = f"%default; {default}"
|
||||
default = self._GetHelpForCpuJobCount()
|
||||
p.add_option(
|
||||
"-j",
|
||||
"--jobs",
|
||||
@@ -398,7 +408,11 @@ class Command:
|
||||
Args:
|
||||
args: a list of (case-insensitive) strings, projects to search for.
|
||||
manifest: an XmlManifest, the manifest to use, or None for default.
|
||||
groups: a string, the manifest groups in use.
|
||||
groups: a string, the manifest group selection to apply.
|
||||
Non-empty values apply to all candidate projects in this call.
|
||||
When empty or omitted, single-manifest calls use the selected
|
||||
manifest's effective groups; all-manifest calls use each
|
||||
candidate project's owning manifest's effective groups.
|
||||
missing_ok: a boolean, whether to allow missing projects.
|
||||
submodules_ok: whether to allow submodules. True allows them for
|
||||
all projects, False disallows them for all projects, and None
|
||||
@@ -425,9 +439,33 @@ class Command:
|
||||
return project.sync_s
|
||||
return submodules_ok
|
||||
|
||||
if not groups:
|
||||
groups = manifest.GetManifestGroupsStr()
|
||||
groups = [x for x in re.split(r"[,\s]+", groups) if x]
|
||||
def parse_groups(value: str) -> List[str]:
|
||||
return [x for x in re.split(r"[,\s]+", value) if x]
|
||||
|
||||
if groups:
|
||||
groups_for_all_projects = parse_groups(groups)
|
||||
elif all_manifests:
|
||||
# In all-manifest mode, each project uses its owning
|
||||
# manifest's effective groups.
|
||||
groups_for_all_projects = None
|
||||
else:
|
||||
groups_for_all_projects = parse_groups(
|
||||
manifest.GetManifestGroupsStr()
|
||||
)
|
||||
|
||||
groups_by_manifest = {}
|
||||
|
||||
def matches_groups(project: "Project") -> bool:
|
||||
if groups_for_all_projects is not None:
|
||||
return project.MatchesGroups(groups_for_all_projects)
|
||||
|
||||
project_manifest = project.manifest
|
||||
if project_manifest not in groups_by_manifest:
|
||||
groups_by_manifest[project_manifest] = parse_groups(
|
||||
project_manifest.GetManifestGroupsStr()
|
||||
)
|
||||
|
||||
return project.MatchesGroups(groups_by_manifest[project_manifest])
|
||||
|
||||
if not args:
|
||||
derived_projects = {}
|
||||
@@ -439,9 +477,7 @@ class Command:
|
||||
)
|
||||
all_projects_list.extend(derived_projects.values())
|
||||
for project in all_projects_list:
|
||||
if (missing_ok or project.Exists) and project.MatchesGroups(
|
||||
groups
|
||||
):
|
||||
if (missing_ok or project.Exists) and matches_groups(project):
|
||||
result.append(project)
|
||||
else:
|
||||
self._ResetPathToProjectMap(all_projects_list)
|
||||
@@ -455,7 +491,7 @@ class Command:
|
||||
for project in manifest.GetProjectsWithName(
|
||||
arg, all_manifests=all_manifests
|
||||
)
|
||||
if project.MatchesGroups(groups)
|
||||
if matches_groups(project)
|
||||
]
|
||||
|
||||
if not projects:
|
||||
@@ -498,7 +534,7 @@ class Command:
|
||||
"%s (%s)"
|
||||
% (arg, project.RelPath(local=not all_manifests))
|
||||
)
|
||||
if not project.MatchesGroups(groups):
|
||||
if not matches_groups(project):
|
||||
raise InvalidProjectGroupsError(arg)
|
||||
|
||||
result.extend(projects)
|
||||
@@ -509,7 +545,14 @@ class Command:
|
||||
result.sort(key=_getpath)
|
||||
return result
|
||||
|
||||
def FindProjects(self, args, inverse=False, all_manifests=False):
|
||||
def FindProjects(
|
||||
self,
|
||||
args: List[str],
|
||||
inverse: bool = False,
|
||||
all_manifests: bool = False,
|
||||
groups: Optional[str] = "",
|
||||
missing_ok: Optional[bool] = False,
|
||||
) -> List["Project"]:
|
||||
"""Find projects from command line arguments.
|
||||
|
||||
Args:
|
||||
@@ -519,10 +562,18 @@ class Command:
|
||||
all_manifests: a boolean, if True then all manifests and
|
||||
submanifests are used. If False, then only the local
|
||||
(sub)manifest is used.
|
||||
groups: a string specifying manifest groups. If empty or None, use
|
||||
each manifest's effective groups.
|
||||
missing_ok: a boolean, whether to allow missing projects.
|
||||
"""
|
||||
result = []
|
||||
patterns = [re.compile(r"%s" % a, re.IGNORECASE) for a in args]
|
||||
for project in self.GetProjects("", all_manifests=all_manifests):
|
||||
for project in self.GetProjects(
|
||||
"",
|
||||
groups=groups,
|
||||
missing_ok=missing_ok,
|
||||
all_manifests=all_manifests,
|
||||
):
|
||||
paths = [project.name, project.RelPath(local=not all_manifests)]
|
||||
for pattern in patterns:
|
||||
match = any(pattern.search(x) for x in paths)
|
||||
|
||||
@@ -6,6 +6,9 @@ executed during `repo sync` to fetch objects, instead of using standard
|
||||
filesystems or lazy checkouts where fetching metadata and downloading file
|
||||
contents should be decoupled.
|
||||
|
||||
The checkout half of a sync has a counterpart, `repo.reprojectcmd`; see
|
||||
`docs/reproject-cmd.md`.
|
||||
|
||||
## Configuration
|
||||
|
||||
To use this feature, set the following in `.repo/manifests.git/config`:
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Reproject Command Contract
|
||||
|
||||
The `repo.reprojectcmd` configuration names a command that `repo sync` runs
|
||||
instead of Git to move a project's index and worktree to the tree of the
|
||||
target commit. It is the checkout-side counterpart of `repo.fetchcmd` (see
|
||||
`docs/fetch-cmd.md`): together they let an external tool take over both the
|
||||
network fetch and the materialization of a project. This is useful on
|
||||
virtualized filesystems that address content by hash, where a tree can be
|
||||
materialized far faster than `git checkout` can write every file.
|
||||
|
||||
The command only materializes the tree. `repo` then makes the ref write that
|
||||
Git would have made, using `git update-ref`.
|
||||
|
||||
## Configuration
|
||||
|
||||
To use this feature, set the following in `.repo/manifests.git/config`:
|
||||
```ini
|
||||
[repo]
|
||||
reprojectcmd = "your custom command here"
|
||||
uselocalgitdirs = true
|
||||
```
|
||||
Setting `repo.reprojectcmd` **requires** `repo.uselocalgitdirs` to be set to
|
||||
`true`.
|
||||
|
||||
For reference, this command does with Git what `repo` would otherwise do
|
||||
itself:
|
||||
```ini
|
||||
[repo]
|
||||
reprojectcmd = "git -C $REPO_PATH read-tree -m -u $REPO_TREV"
|
||||
uselocalgitdirs = true
|
||||
```
|
||||
The one-tree merge applies the change to the target, keeps local changes to
|
||||
every other path, and refuses to overwrite a modified or untracked file, so it
|
||||
enforces the preconditions below by itself. It also works for a project that
|
||||
has nothing checked out yet.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The command is executed in a subshell, from the root of the client, populated
|
||||
with standard project-context environment variables. For details on standard
|
||||
variables (such as `REPO_PROJECT`, `REPO_PATH`, `REPO_REMOTE`, etc.), see the
|
||||
Environment section in `repo help forall` or `subcmds/forall.py`.
|
||||
|
||||
The variables the command typically needs are:
|
||||
|
||||
* `REPO_PATH`: The project path relative to the root of the client.
|
||||
* `REPO_TREV`: The target revision resolved to a full commit hash. Match this
|
||||
commit's tree.
|
||||
|
||||
There is no force mode: a project that would need one never reaches the
|
||||
command (see the preconditions below).
|
||||
|
||||
## When the command runs
|
||||
|
||||
`repo sync` already classifies each project and picks a Git operation. The
|
||||
command replaces the three that are a materialization of a target tree:
|
||||
|
||||
1. The checkout that detaches HEAD at the target. This is the common case: a
|
||||
project on a detached HEAD, a project on a branch that does not track
|
||||
upstream, and `repo sync -d`.
|
||||
2. The fast-forward of the checked out branch to the target.
|
||||
3. The hard reset of the checked out branch to the target, when the commits
|
||||
it carried were dropped upstream.
|
||||
|
||||
After the command exits 0, `repo` writes the ref itself: it detaches `HEAD` at
|
||||
`REPO_TREV`, or moves the checked out branch to `REPO_TREV`.
|
||||
|
||||
The command is **not** run:
|
||||
|
||||
* When `HEAD` already names `REPO_TREV`.
|
||||
* At the fast-forward step when `HEAD` is ahead of `REPO_TREV`, where Git's
|
||||
merge would be a no-op.
|
||||
* For a rebase. A branch carrying local commits has them replayed onto the
|
||||
target by `git rebase`, which is not a materialization of a target tree.
|
||||
* For `MetaProject`s (i.e. the internal `repo` repository itself at
|
||||
`.repo/repo` and the `manifests` repository at `.repo/manifests`).
|
||||
|
||||
## Contract
|
||||
|
||||
### Preconditions
|
||||
|
||||
Before invoking the command, `repo` ensures that:
|
||||
|
||||
* The index has no staged changes (the index matches `HEAD`, or is empty on an unborn `HEAD`).
|
||||
* No rebase, cherry-pick, merge, or revert is in progress.
|
||||
|
||||
Detecting collisions with untracked files or unstaged working-tree modifications is the responsibility of the reproject command itself (e.g. via `git read-tree -m -u $REPO_TREV` or a custom virtual filesystem checkout tool). If local changes collide with the target tree, the command must abort with a non-zero exit code. Local modifications and untracked files outside the diff between `HEAD` and `REPO_TREV` must be preserved.
|
||||
|
||||
### Postconditions on exit 0
|
||||
|
||||
After the command exits with status 0, `repo` expects the following
|
||||
postconditions to be met:
|
||||
|
||||
1. `git diff-index --quiet --cached REPO_TREV^{tree}` exits 0 (the index
|
||||
matches the target tree).
|
||||
2. `HEAD` still names what it did before the command, and its resolved commit
|
||||
object ID has not changed.
|
||||
|
||||
### Invariants
|
||||
|
||||
The command may modify the worktree and the index, and may write project-local
|
||||
Git config. The command must:
|
||||
|
||||
* Apply the change from `HEAD`'s tree to `REPO_TREV`'s tree and leave every
|
||||
other path alone. Local modifications and untracked files outside that
|
||||
change must survive: the command applies a diff, it does not reset the
|
||||
tree.
|
||||
* Not write any ref, including `HEAD` and `ORIG_HEAD`. `repo` owns every ref
|
||||
write.
|
||||
* Not create or replace `.git/`, and not touch anything under `.repo/`.
|
||||
* Not require the Git remote, to preserve `repo sync --local-only`.
|
||||
* Be idempotent. Running it twice on the same target is a no-op.
|
||||
|
||||
### Failure
|
||||
|
||||
* A non-zero exit status, a failed precondition or a failed postcondition
|
||||
fails that project's sync, and the command's or Git's output is surfaced
|
||||
to the user.
|
||||
* Other projects continue, and `repo sync` exits non-zero.
|
||||
|
||||
## Limitations
|
||||
|
||||
Nested projects are out of scope: a project whose path lies inside another
|
||||
project's path, a `<project>` nested in another `<project>` in the manifest,
|
||||
and a submodule discovered with `sync-s` or `--recurse-submodules`. `repo sync`
|
||||
fails if the manifest has one while `repo.reprojectcmd` is set.
|
||||
+43
-8
@@ -283,6 +283,7 @@ class GitCommand:
|
||||
bare=False,
|
||||
input=None,
|
||||
capture_stdout=False,
|
||||
capture_stdout_bytes: bool = False,
|
||||
capture_stderr=False,
|
||||
merge_output=False,
|
||||
disable_editor=False,
|
||||
@@ -304,6 +305,12 @@ class GitCommand:
|
||||
self.cmdv = cmdv
|
||||
self.verify_command = verify_command
|
||||
self.stdout, self.stderr = None, None
|
||||
if capture_stdout_bytes:
|
||||
if merge_output:
|
||||
raise ValueError(
|
||||
"capture_stdout_bytes cannot be combined with merge_output"
|
||||
)
|
||||
capture_stdout = True
|
||||
|
||||
# Git on Windows wants its paths only using / for reliability.
|
||||
if platform_utils.isWindows():
|
||||
@@ -347,6 +354,7 @@ class GitCommand:
|
||||
command,
|
||||
env,
|
||||
capture_stdout=capture_stdout,
|
||||
capture_stdout_bytes=capture_stdout_bytes,
|
||||
capture_stderr=capture_stderr,
|
||||
merge_output=merge_output,
|
||||
ssh_proxy=ssh_proxy,
|
||||
@@ -380,6 +388,7 @@ class GitCommand:
|
||||
command,
|
||||
env,
|
||||
capture_stdout=False,
|
||||
capture_stdout_bytes: bool = False,
|
||||
capture_stderr=False,
|
||||
merge_output=False,
|
||||
ssh_proxy=None,
|
||||
@@ -412,6 +421,10 @@ class GitCommand:
|
||||
# See go/tee-repo-stderr for more context.
|
||||
tee_stderr = False
|
||||
kwargs = {"encoding": "utf-8", "errors": "backslashreplace"}
|
||||
if capture_stdout_bytes:
|
||||
kwargs = {}
|
||||
if isinstance(input, str):
|
||||
input = input.encode("utf-8", "surrogateescape")
|
||||
if not (stdin or stdout or stderr):
|
||||
tee_stderr = True
|
||||
# stderr will be written back to sys.stderr even though it is
|
||||
@@ -490,6 +503,10 @@ class GitCommand:
|
||||
self.stderr = self._Tee(p.stderr, sys.stderr)
|
||||
else:
|
||||
self.stdout, self.stderr = p.communicate(input=input)
|
||||
if capture_stdout_bytes and isinstance(self.stderr, bytes):
|
||||
self.stderr = self.stderr.decode(
|
||||
"utf-8", "backslashreplace"
|
||||
).replace("\r\n", "\n")
|
||||
finally:
|
||||
if ssh_proxy:
|
||||
ssh_proxy.remove_client(p)
|
||||
@@ -541,17 +558,35 @@ class GitCommand:
|
||||
env.pop(key, None)
|
||||
return env
|
||||
|
||||
def VerifyCommand(self):
|
||||
def VerifyCommand(self) -> None:
|
||||
if self.rc == 0:
|
||||
return None
|
||||
stdout = (
|
||||
"\n".join(self.stdout.split("\n")[:GIT_ERROR_STDOUT_LINES])
|
||||
if self.stdout
|
||||
else None
|
||||
)
|
||||
raw_stdout = self.stdout
|
||||
if isinstance(raw_stdout, bytes):
|
||||
first_records = re.split(
|
||||
rb"\r\n|[\r\n\0]", raw_stdout, maxsplit=GIT_ERROR_STDOUT_LINES
|
||||
)[:GIT_ERROR_STDOUT_LINES]
|
||||
stdout = (
|
||||
"\n".join(
|
||||
r.decode("utf-8", "backslashreplace") for r in first_records
|
||||
)
|
||||
if raw_stdout
|
||||
else None
|
||||
)
|
||||
elif raw_stdout:
|
||||
first_records = re.split(
|
||||
r"\r\n|[\r\n\0]", raw_stdout, maxsplit=GIT_ERROR_STDOUT_LINES
|
||||
)[:GIT_ERROR_STDOUT_LINES]
|
||||
stdout = "\n".join(first_records)
|
||||
else:
|
||||
stdout = None
|
||||
|
||||
raw_stderr = self.stderr
|
||||
if isinstance(raw_stderr, bytes):
|
||||
raw_stderr = raw_stderr.decode("utf-8", "backslashreplace")
|
||||
stderr = (
|
||||
"\n".join(self.stderr.split("\n")[:GIT_ERROR_STDERR_LINES])
|
||||
if self.stderr
|
||||
"\n".join(raw_stderr.split("\n")[:GIT_ERROR_STDERR_LINES])
|
||||
if raw_stderr
|
||||
else None
|
||||
)
|
||||
project = self.project.name if self.project else None
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Read a worktree's state from one machine-readable git status snapshot."""
|
||||
|
||||
from collections import OrderedDict
|
||||
import os
|
||||
from typing import Iterator, List, Optional, TYPE_CHECKING
|
||||
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from project import Project
|
||||
|
||||
|
||||
class StatusEntry:
|
||||
"""The state of one path on one side of the index."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
status: str,
|
||||
src_path: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.status = status
|
||||
self.src_path = src_path
|
||||
self.level = level
|
||||
|
||||
|
||||
class StatusSnapshot:
|
||||
"""A consistent view of worktree, index, and branch state."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.index_changes = OrderedDict()
|
||||
self.worktree_changes = OrderedDict()
|
||||
self.untracked = []
|
||||
self.branch_oid = None
|
||||
self.branch_head = None
|
||||
self.upstream = None
|
||||
self.ahead = 0
|
||||
self.behind = 0
|
||||
self.has_ahead_behind = False
|
||||
self.stash_count = 0
|
||||
|
||||
@property
|
||||
def current_branch(self) -> Optional[str]:
|
||||
if self.branch_head in (None, "(detached)", "(unknown)"):
|
||||
return None
|
||||
return self.branch_head
|
||||
|
||||
def is_dirty(self, consider_untracked: bool = True) -> bool:
|
||||
return bool(
|
||||
self.index_changes
|
||||
or self.worktree_changes
|
||||
or (consider_untracked and self.untracked)
|
||||
)
|
||||
|
||||
|
||||
def GetStatus(
|
||||
project: "Project",
|
||||
gitdir: str,
|
||||
untracked_files: str = "all",
|
||||
branch: bool = False,
|
||||
ahead_behind: bool = False,
|
||||
show_stash: bool = False,
|
||||
) -> StatusSnapshot:
|
||||
"""Return one machine-readable status snapshot for |project|."""
|
||||
if not git_require((2, 11, 0)):
|
||||
raise UnsupportedStatusError("porcelain v2 requires Git 2.11")
|
||||
cmd = [
|
||||
"status",
|
||||
"--porcelain=v2",
|
||||
"-z",
|
||||
"--ignore-submodules=all",
|
||||
f"--untracked-files={untracked_files}",
|
||||
]
|
||||
if branch:
|
||||
cmd.append("--branch")
|
||||
if git_require((2, 17, 0)):
|
||||
cmd.append(
|
||||
"--ahead-behind" if ahead_behind else "--no-ahead-behind"
|
||||
)
|
||||
if 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)):
|
||||
cmd.append("--show-stash")
|
||||
|
||||
p = GitCommand(
|
||||
project,
|
||||
cmd,
|
||||
bare=False,
|
||||
gitdir=gitdir,
|
||||
capture_stdout=True,
|
||||
capture_stdout_bytes=True,
|
||||
capture_stderr=True,
|
||||
verify_command=True,
|
||||
)
|
||||
p.Wait()
|
||||
return ParsePorcelainV2(p.stdout)
|
||||
|
||||
|
||||
def _Path(value: bytes) -> str:
|
||||
"""Decode a Git pathname without losing undecodable bytes."""
|
||||
return os.fsdecode(value)
|
||||
|
||||
|
||||
def _Status(value: int) -> str:
|
||||
"""Normalize Git's unchanged markers for repo's status display."""
|
||||
char = chr(value)
|
||||
return "" if char == "." else char
|
||||
|
||||
|
||||
def _Records(output: bytes) -> Iterator[bytes]:
|
||||
if not output:
|
||||
return iter(())
|
||||
if not output.endswith(b"\0"):
|
||||
raise StatusParseError("porcelain v2 output is not NUL terminated")
|
||||
records = output.split(b"\0")
|
||||
if not records[-1]:
|
||||
records.pop()
|
||||
return iter(records)
|
||||
|
||||
|
||||
class StatusParseError(ValueError):
|
||||
"""Raised when machine-readable status output is malformed."""
|
||||
|
||||
|
||||
class UnsupportedStatusError(RuntimeError):
|
||||
"""Raised when the Git client cannot produce porcelain v2."""
|
||||
|
||||
|
||||
def _Fields(record: bytes, count: int) -> List[bytes]:
|
||||
fields = record.split(b" ", count - 1)
|
||||
if len(fields) != count:
|
||||
raise StatusParseError(f"malformed porcelain v2 record: {record!r}")
|
||||
return fields
|
||||
|
||||
|
||||
def _AddTracked(
|
||||
status: StatusSnapshot,
|
||||
path: str,
|
||||
xy: bytes,
|
||||
src_path: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
) -> None:
|
||||
index_status = _Status(xy[0])
|
||||
worktree_status = _Status(xy[1])
|
||||
if index_status:
|
||||
status.index_changes[path] = StatusEntry(
|
||||
path,
|
||||
index_status,
|
||||
src_path=src_path if index_status in ("R", "C") else None,
|
||||
level=level if index_status in ("R", "C") else None,
|
||||
)
|
||||
if worktree_status:
|
||||
status.worktree_changes[path] = StatusEntry(
|
||||
path,
|
||||
worktree_status,
|
||||
src_path=src_path if worktree_status in ("R", "C") else None,
|
||||
level=level if worktree_status in ("R", "C") else None,
|
||||
)
|
||||
|
||||
|
||||
def ParsePorcelainV2(output: bytes) -> StatusSnapshot:
|
||||
"""Parse ``git status --porcelain=v2 -z --branch`` output."""
|
||||
status = StatusSnapshot()
|
||||
records = _Records(output)
|
||||
for record in records:
|
||||
kind = record[:1]
|
||||
if kind == b"#":
|
||||
try:
|
||||
key, value = record[2:].split(b" ", 1)
|
||||
except ValueError as e:
|
||||
raise StatusParseError(
|
||||
f"malformed porcelain v2 header: {record!r}"
|
||||
) from e
|
||||
if key == b"branch.oid":
|
||||
value = value.decode("ascii")
|
||||
status.branch_oid = None if value == "(initial)" else value
|
||||
elif key == b"branch.head":
|
||||
status.branch_head = _Path(value)
|
||||
elif key == b"branch.upstream":
|
||||
status.upstream = _Path(value)
|
||||
elif key == b"branch.ab":
|
||||
try:
|
||||
value = value.decode("ascii")
|
||||
ahead, behind = value.split()
|
||||
if ahead != "+?" and behind != "-?":
|
||||
status.ahead = int(ahead)
|
||||
status.behind = -int(behind)
|
||||
status.has_ahead_behind = True
|
||||
except ValueError as e:
|
||||
raise StatusParseError(
|
||||
f"malformed porcelain v2 branch.ab record: {record!r}"
|
||||
) from e
|
||||
elif key == b"stash":
|
||||
status.stash_count = int(value.decode("ascii"))
|
||||
continue
|
||||
|
||||
if kind == b"1":
|
||||
fields = _Fields(record, 9)
|
||||
xy = fields[1]
|
||||
if len(xy) != 2:
|
||||
raise StatusParseError(f"invalid status pair: {xy!r}")
|
||||
path = _Path(fields[8])
|
||||
_AddTracked(status, path, xy)
|
||||
elif kind == b"2":
|
||||
fields = _Fields(record, 10)
|
||||
xy = fields[1]
|
||||
if len(xy) != 2:
|
||||
raise StatusParseError(f"invalid status pair: {xy!r}")
|
||||
score = fields[8][1:].lstrip(b"0") or b"0"
|
||||
try:
|
||||
src_path = _Path(next(records))
|
||||
except StopIteration as e:
|
||||
raise StatusParseError(
|
||||
"rename record has no source path"
|
||||
) from e
|
||||
path = _Path(fields[9])
|
||||
_AddTracked(
|
||||
status,
|
||||
path,
|
||||
xy,
|
||||
src_path=src_path,
|
||||
level=score.decode("ascii"),
|
||||
)
|
||||
elif kind == b"u":
|
||||
fields = _Fields(record, 11)
|
||||
path = _Path(fields[10])
|
||||
# The old diff-index/diff-files pair reported unmerged paths on
|
||||
# both sides, regardless of porcelain's more specific XY pair.
|
||||
status.index_changes[path] = StatusEntry(path, "U")
|
||||
status.worktree_changes[path] = StatusEntry(path, "U")
|
||||
elif kind == b"?":
|
||||
status.untracked.append(_Path(record[2:]))
|
||||
elif kind == b"!":
|
||||
continue
|
||||
else:
|
||||
raise StatusParseError(
|
||||
f"unknown porcelain v2 record type: {record!r}"
|
||||
)
|
||||
return status
|
||||
@@ -104,6 +104,11 @@ elif sys.version_info < MIN_PYTHON_VERSION_SOFT:
|
||||
KEYBOARD_INTERRUPT_EXIT = 128 + signal.SIGINT
|
||||
MAX_PRINT_ERRORS = 5
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(errors="surrogateescape")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(errors="surrogateescape")
|
||||
|
||||
global_options = optparse.OptionParser(
|
||||
usage="repo [-p|--paginate|--no-pager] COMMAND [ARGS]",
|
||||
add_help_option=False,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2026" "repo smartsync" "Repo Manual"
|
||||
.TH REPO "1" "September 2026" "repo smartsync" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo smartsync - manual page for repo smartsync
|
||||
.SH SYNOPSIS
|
||||
@@ -21,7 +21,7 @@ number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
number of network jobs to run in parallel (defaults to \fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-\-jobs\-checkout\fR=\fI\,JOBS\/\fR
|
||||
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or 8). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or based on number of CPU cores). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-f\fR, \fB\-\-force\-broken\fR
|
||||
obsolete option (to be deleted in the future)
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2026" "repo sync" "Repo Manual"
|
||||
.TH REPO "1" "September 2026" "repo sync" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo sync - manual page for repo sync
|
||||
.SH SYNOPSIS
|
||||
@@ -21,7 +21,7 @@ number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
number of network jobs to run in parallel (defaults to \fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-\-jobs\-checkout\fR=\fI\,JOBS\/\fR
|
||||
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or 8). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or based on number of CPU cores). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-f\fR, \fB\-\-force\-broken\fR
|
||||
obsolete option (to be deleted in the future)
|
||||
|
||||
+12
-16
@@ -1519,9 +1519,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if base_revision:
|
||||
if p.revisionExpr != base_revision:
|
||||
failed_revision_changes.append(
|
||||
"extend-project name %s mismatch base "
|
||||
"%s vs revision %s"
|
||||
% (name, base_revision, p.revisionExpr)
|
||||
f"extend-project name {name}:\n "
|
||||
f"base {base_revision} vs "
|
||||
f"revision {p.revisionExpr}"
|
||||
)
|
||||
p.SetRevision(revision)
|
||||
|
||||
@@ -1622,9 +1622,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if base_revision:
|
||||
if p.revisionExpr != base_revision:
|
||||
failed_revision_changes.append(
|
||||
"remove-project name %s mismatch base "
|
||||
"%s vs revision %s"
|
||||
% (name, base_revision, p.revisionExpr)
|
||||
f"remove-project name {name}:\n "
|
||||
f"base {base_revision} vs "
|
||||
f"revision {p.revisionExpr}"
|
||||
)
|
||||
del self._paths[p.relpath]
|
||||
if not removed_project:
|
||||
@@ -1636,13 +1636,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if base_revision:
|
||||
if p.revisionExpr != base_revision:
|
||||
failed_revision_changes.append(
|
||||
"remove-project path %s mismatch base "
|
||||
"%s vs revision %s"
|
||||
% (
|
||||
p.relpath,
|
||||
base_revision,
|
||||
p.revisionExpr,
|
||||
)
|
||||
f"remove-project path {p.relpath}:\n "
|
||||
f"base {base_revision} vs "
|
||||
f"revision {p.revisionExpr}"
|
||||
)
|
||||
self._projects[projname].remove(p)
|
||||
del self._paths[p.relpath]
|
||||
@@ -1664,10 +1660,10 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
)
|
||||
|
||||
if failed_revision_changes:
|
||||
fail_string = "\n".join(failed_revision_changes)
|
||||
raise ManifestParseError(
|
||||
"revision base check failed, rebase patches and update "
|
||||
"base revs for: ",
|
||||
failed_revision_changes,
|
||||
f"detected base-revision mismatch, updates needed:\n"
|
||||
f"{fail_string}",
|
||||
)
|
||||
|
||||
# Store repo hooks project information.
|
||||
|
||||
+567
-77
@@ -29,7 +29,7 @@ import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Dict, List, NamedTuple, Optional
|
||||
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
|
||||
import urllib.parse
|
||||
|
||||
from color import Coloring
|
||||
@@ -45,6 +45,7 @@ from error import UploadError
|
||||
import fetch
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
from git_command import GitCommandError
|
||||
from git_config import GetSchemeFromUrl
|
||||
from git_config import GetUrlCookieFile
|
||||
from git_config import GitConfig
|
||||
@@ -56,6 +57,7 @@ from git_refs import R_M
|
||||
from git_refs import R_PUB
|
||||
from git_refs import R_TAGS
|
||||
from git_refs import R_WORKTREE_M
|
||||
import git_status
|
||||
import git_superproject
|
||||
from git_trace2_event_log import EventLog
|
||||
import platform_utils
|
||||
@@ -109,6 +111,14 @@ RETRY_JITTER_PERCENT = 0.1
|
||||
_ALTERNATES = os.environ.get("REPO_USE_ALTERNATES") == "1"
|
||||
|
||||
|
||||
def _FirstLines(lines: List[str], limit: int = 10) -> str:
|
||||
"""Join |lines|, eliding all but the first |limit| of them."""
|
||||
shown = list(lines[:limit])
|
||||
if len(lines) > limit:
|
||||
shown.append(f"... and {len(lines) - limit} more")
|
||||
return "\n".join(shown)
|
||||
|
||||
|
||||
def _lwrite(path, content):
|
||||
lock = "%s.lock" % path
|
||||
|
||||
@@ -810,6 +820,17 @@ class Project:
|
||||
"""Returns True if a cherry-pick is in progress."""
|
||||
return os.path.exists(self.work_git.GetDotgitPath("CHERRY_PICK_HEAD"))
|
||||
|
||||
def _OperationInProgress(self) -> Optional[str]:
|
||||
"""Return the name of the Git operation in progress, if any."""
|
||||
if self.IsRebaseInProgress():
|
||||
return "rebase"
|
||||
if self.IsCherryPickInProgress():
|
||||
return "cherry-pick"
|
||||
for state, name in (("MERGE_HEAD", "merge"), ("REVERT_HEAD", "revert")):
|
||||
if os.path.exists(self.work_git.GetDotgitPath(state)):
|
||||
return name
|
||||
return None
|
||||
|
||||
def _AbortRebase(self):
|
||||
"""Abort ongoing rebase, cherry-pick or patch apply (am).
|
||||
|
||||
@@ -825,11 +846,70 @@ class Project:
|
||||
_git("rebase", "--abort")
|
||||
_git("am", "--abort")
|
||||
|
||||
def IsDirty(self, consider_untracked=True):
|
||||
def _RefreshIndexStatCache(self) -> None:
|
||||
"""Refresh the index's cached stat information."""
|
||||
args = ["--unmerged", "--ignore-missing", "--refresh"]
|
||||
|
||||
# Run twice because -q is needed and unhelpful in equal measure. It
|
||||
# keeps git quiet about uncommitted changes, which would otherwise
|
||||
# exit 1 and report a failure to telemetry for every modified project,
|
||||
# but it also suppresses the reason a refresh failed, leaving a bare
|
||||
# exit 128. So refresh with it, and repeat without it only to get
|
||||
# the reason.
|
||||
try:
|
||||
self.work_git.update_index("-q", *args, log_as_error=False)
|
||||
return
|
||||
except GitError:
|
||||
pass
|
||||
|
||||
# Exit code 1 means there are modified files, which is okay.
|
||||
try:
|
||||
self.work_git.update_index(*args)
|
||||
except GitCommandError as e:
|
||||
if e.git_rc != 1:
|
||||
raise
|
||||
|
||||
def IsDirty(self, consider_untracked: bool = True) -> bool:
|
||||
"""Is the working directory modified in some way?"""
|
||||
self.work_git.update_index(
|
||||
"-q", "--unmerged", "--ignore-missing", "--refresh"
|
||||
status = self._GetStatusSnapshot(
|
||||
untracked_files="normal" if consider_untracked else "no"
|
||||
)
|
||||
if status is not None:
|
||||
return status.is_dirty(consider_untracked=consider_untracked)
|
||||
|
||||
return self._IsDirtyLegacy(consider_untracked=consider_untracked)
|
||||
|
||||
def _GetStatusSnapshot(
|
||||
self,
|
||||
untracked_files: str = "all",
|
||||
branch: bool = False,
|
||||
ahead_behind: bool = False,
|
||||
show_stash: bool = False,
|
||||
) -> Optional[git_status.StatusSnapshot]:
|
||||
"""Read one porcelain-v2 snapshot, or select the legacy path."""
|
||||
if not git_require((2, 11, 0)):
|
||||
return None
|
||||
try:
|
||||
return git_status.GetStatus(
|
||||
self,
|
||||
self.gitdir,
|
||||
untracked_files=untracked_files,
|
||||
branch=branch,
|
||||
ahead_behind=ahead_behind,
|
||||
show_stash=show_stash,
|
||||
)
|
||||
except (GitError, ValueError, git_status.UnsupportedStatusError) as e:
|
||||
logger.warning(
|
||||
"project %s: porcelain v2 status failed; using legacy "
|
||||
"status: %s",
|
||||
self.RelPath(local=False),
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
def _IsDirtyLegacy(self, consider_untracked: bool = True) -> bool:
|
||||
"""Check dirty state with plumbing supported by older Git."""
|
||||
self._RefreshIndexStatCache()
|
||||
if self.work_git.DiffZ("diff-index", "-M", "--cached", HEAD):
|
||||
return True
|
||||
if self.work_git.DiffZ("diff-files"):
|
||||
@@ -850,6 +930,21 @@ class Project:
|
||||
)
|
||||
return p.Wait() == 0
|
||||
|
||||
def _HasDirtyOrStash(self) -> bool:
|
||||
"""Check dirty and normal stash state with one status when possible."""
|
||||
has_status_stash = git_require((2, 35, 0))
|
||||
status = self._GetStatusSnapshot(
|
||||
untracked_files="normal",
|
||||
show_stash=has_status_stash,
|
||||
)
|
||||
if status is not None:
|
||||
if status.is_dirty(consider_untracked=True):
|
||||
return True
|
||||
if has_status_stash:
|
||||
return bool(status.stash_count)
|
||||
return self.HasStash()
|
||||
return self.IsDirty(consider_untracked=True) or self.HasStash()
|
||||
|
||||
_userident_name = None
|
||||
_userident_email = None
|
||||
|
||||
@@ -945,7 +1040,7 @@ class Project:
|
||||
|
||||
return matched
|
||||
|
||||
def UncommitedFiles(self, get_all=True):
|
||||
def UncommittedFiles(self, get_all: bool = True) -> List[str]:
|
||||
"""Returns a list of strings, uncommitted files in the git tree.
|
||||
|
||||
Args:
|
||||
@@ -953,10 +1048,48 @@ class Project:
|
||||
uncommitted files. If False - return as soon as any kind of
|
||||
uncommitted files is detected.
|
||||
"""
|
||||
status = self._GetStatusSnapshot(untracked_files="all")
|
||||
if status is not None:
|
||||
return self._UncommittedFilesFromStatus(status, get_all=get_all)
|
||||
|
||||
return self._UncommittedFilesLegacy(get_all=get_all)
|
||||
|
||||
def _UncommittedFilesFromStatus(
|
||||
self, status: git_status.StatusSnapshot, get_all: bool = True
|
||||
) -> List[str]:
|
||||
"""Format uncommitted paths from a porcelain-v2 snapshot."""
|
||||
details = []
|
||||
self.work_git.update_index(
|
||||
"-q", "--unmerged", "--ignore-missing", "--refresh"
|
||||
)
|
||||
if self.IsRebaseInProgress():
|
||||
details.append("rebase in progress")
|
||||
if not get_all:
|
||||
return details
|
||||
|
||||
changes = []
|
||||
for path, entry in status.index_changes.items():
|
||||
changes.append(path)
|
||||
# The legacy diff-index call did not enable rename detection, so
|
||||
# it reported a staged rename as delete(source) plus add(target).
|
||||
if entry.status == "R" and entry.src_path:
|
||||
changes.append(entry.src_path)
|
||||
changes.sort()
|
||||
if changes:
|
||||
details.extend(changes)
|
||||
if not get_all:
|
||||
return details
|
||||
|
||||
changes = list(status.worktree_changes)
|
||||
if changes:
|
||||
details.extend(changes)
|
||||
if not get_all:
|
||||
return details
|
||||
|
||||
details.extend(status.untracked)
|
||||
return details
|
||||
|
||||
def _UncommittedFilesLegacy(self, get_all: bool = True) -> List[str]:
|
||||
"""List uncommitted paths with plumbing supported by older Git."""
|
||||
details = []
|
||||
self._RefreshIndexStatCache()
|
||||
if self.IsRebaseInProgress():
|
||||
details.append("rebase in progress")
|
||||
if not get_all:
|
||||
@@ -984,11 +1117,16 @@ class Project:
|
||||
"""Returns a list of strings, untracked files in the git tree."""
|
||||
return self.work_git.LsOthers()
|
||||
|
||||
def HasChanges(self):
|
||||
def HasChanges(self) -> bool:
|
||||
"""Returns true if there are uncommitted changes."""
|
||||
return bool(self.UncommitedFiles(get_all=False))
|
||||
return bool(self.UncommittedFiles(get_all=False))
|
||||
|
||||
def PrintWorkTreeStatus(self, output_redir=None, quiet=False, local=False):
|
||||
def PrintWorkTreeStatus(
|
||||
self,
|
||||
output_redir: Any = None,
|
||||
quiet: bool = False,
|
||||
local: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Prints the status of the repository to stdout.
|
||||
|
||||
Args:
|
||||
@@ -1007,14 +1145,103 @@ class Project:
|
||||
print(' missing (run "repo sync")', file=output_redir)
|
||||
return
|
||||
|
||||
self.work_git.update_index(
|
||||
"-q", "--unmerged", "--ignore-missing", "--refresh"
|
||||
status = self._GetStatusSnapshot(
|
||||
untracked_files="all",
|
||||
branch=True,
|
||||
ahead_behind=not quiet,
|
||||
)
|
||||
if status is not None:
|
||||
ahead = status.ahead
|
||||
behind = status.behind
|
||||
if (
|
||||
not quiet
|
||||
and status.current_branch is not None
|
||||
and not status.has_ahead_behind
|
||||
):
|
||||
ahead, behind = self._GetBranchAheadBehind(
|
||||
status.current_branch
|
||||
)
|
||||
return self._RenderWorkTreeStatus(
|
||||
status.index_changes,
|
||||
status.worktree_changes,
|
||||
status.untracked,
|
||||
self.IsRebaseInProgress(),
|
||||
status.current_branch,
|
||||
ahead,
|
||||
behind,
|
||||
output_redir=output_redir,
|
||||
quiet=quiet,
|
||||
local=local,
|
||||
)
|
||||
|
||||
return self._PrintWorkTreeStatusLegacy(
|
||||
output_redir=output_redir, quiet=quiet, local=local
|
||||
)
|
||||
|
||||
def _PrintWorkTreeStatusLegacy(
|
||||
self, output_redir: Any = None, quiet: bool = False, local: bool = False
|
||||
) -> str:
|
||||
"""Render status using plumbing supported by older Git."""
|
||||
self._RefreshIndexStatCache()
|
||||
rb = self.IsRebaseInProgress()
|
||||
di = self.work_git.DiffZ("diff-index", "-M", "--cached", HEAD)
|
||||
df = self.work_git.DiffZ("diff-files")
|
||||
do = self.work_git.LsOthers()
|
||||
if not rb and not di and not df and not do and not self.CurrentBranch:
|
||||
if quiet and (rb or di or df or do):
|
||||
branch_name = None
|
||||
else:
|
||||
branch_name = self.CurrentBranch
|
||||
ahead = behind = 0
|
||||
if branch_name is not None and not quiet:
|
||||
ahead, behind = self._GetBranchAheadBehind(branch_name)
|
||||
|
||||
return self._RenderWorkTreeStatus(
|
||||
di,
|
||||
df,
|
||||
do,
|
||||
rb,
|
||||
branch_name,
|
||||
ahead,
|
||||
behind,
|
||||
output_redir=output_redir,
|
||||
quiet=quiet,
|
||||
local=local,
|
||||
)
|
||||
|
||||
def _GetBranchAheadBehind(self, branch_name: str) -> Tuple[int, int]:
|
||||
"""Return divergence when status could not supply branch.ab."""
|
||||
ahead = behind = 0
|
||||
branch_obj = self.GetBranch(branch_name)
|
||||
try:
|
||||
local_merge = branch_obj.LocalMerge
|
||||
if local_merge:
|
||||
left_right = self.work_git.rev_list(
|
||||
"--left-right",
|
||||
"--count",
|
||||
f"{local_merge}...{R_HEADS}{branch_name}",
|
||||
)
|
||||
left, right = left_right[0].split()
|
||||
behind = int(left)
|
||||
ahead = int(right)
|
||||
except (GitError, IndexError, ValueError):
|
||||
pass
|
||||
return ahead, behind
|
||||
|
||||
def _RenderWorkTreeStatus(
|
||||
self,
|
||||
di: Any,
|
||||
df: Any,
|
||||
do: Any,
|
||||
rb: bool,
|
||||
branch_name: Optional[str],
|
||||
ahead: int,
|
||||
behind: int,
|
||||
output_redir: Any = None,
|
||||
quiet: bool = False,
|
||||
local: bool = False,
|
||||
) -> str:
|
||||
"""Render a normalized worktree snapshot."""
|
||||
if not rb and not di and not df and not do and branch_name is None:
|
||||
return "CLEAN"
|
||||
|
||||
out = StatusColoring(self.config)
|
||||
@@ -1026,31 +1253,16 @@ class Project:
|
||||
out.nl()
|
||||
return "DIRTY"
|
||||
|
||||
branch_name = self.CurrentBranch
|
||||
if branch_name is None:
|
||||
out.nobranch("(*** NO BRANCH ***)")
|
||||
else:
|
||||
branch_obj = self.GetBranch(branch_name)
|
||||
ahead_behind = ""
|
||||
try:
|
||||
local_merge = branch_obj.LocalMerge
|
||||
if local_merge:
|
||||
left_right = self.work_git.rev_list(
|
||||
"--left-right",
|
||||
"--count",
|
||||
f"{local_merge}...{R_HEADS}{branch_name}",
|
||||
)
|
||||
left, right = left_right[0].split()
|
||||
behind = int(left)
|
||||
ahead = int(right)
|
||||
if ahead and behind:
|
||||
ahead_behind = f" [ahead {ahead}, behind {behind}]"
|
||||
elif ahead:
|
||||
ahead_behind = f" [ahead {ahead}]"
|
||||
elif behind:
|
||||
ahead_behind = f" [behind {behind}]"
|
||||
except GitError:
|
||||
pass
|
||||
if ahead and behind:
|
||||
ahead_behind = f" [ahead {ahead}, behind {behind}]"
|
||||
elif ahead:
|
||||
ahead_behind = f" [ahead {ahead}]"
|
||||
elif behind:
|
||||
ahead_behind = f" [behind {behind}]"
|
||||
out.branch("branch %s%s", branch_name, ahead_behind)
|
||||
out.nl()
|
||||
|
||||
@@ -1058,6 +1270,19 @@ class Project:
|
||||
out.important("prior sync failed; rebase still in progress")
|
||||
out.nl()
|
||||
|
||||
def _SafePath(path_str: str) -> str:
|
||||
stream = output_redir if output_redir is not None else sys.stdout
|
||||
encoding = getattr(stream, "encoding", None) or "utf-8"
|
||||
errors = getattr(stream, "errors", None)
|
||||
if errors not in ("surrogateescape", "backslashreplace", "replace"):
|
||||
try:
|
||||
path_str.encode(encoding)
|
||||
except UnicodeEncodeError:
|
||||
return path_str.encode(encoding, "backslashreplace").decode(
|
||||
encoding
|
||||
)
|
||||
return path_str
|
||||
|
||||
paths = []
|
||||
paths.extend(di.keys())
|
||||
paths.extend(df.keys())
|
||||
@@ -1084,12 +1309,15 @@ class Project:
|
||||
else:
|
||||
f_status = "-"
|
||||
|
||||
disp_p = _SafePath(p)
|
||||
if i and i.src_path:
|
||||
disp_src = _SafePath(i.src_path)
|
||||
line = (
|
||||
f" {i_status}{f_status}\t{i.src_path} => {p} ({i.level}%)"
|
||||
f" {i_status}{f_status}\t"
|
||||
f"{disp_src} => {disp_p} ({i.level}%)"
|
||||
)
|
||||
else:
|
||||
line = f" {i_status}{f_status}\t{p}"
|
||||
line = f" {i_status}{f_status}\t{disp_p}"
|
||||
|
||||
if i and not f:
|
||||
out.added("%s", line)
|
||||
@@ -1457,7 +1685,7 @@ class Project:
|
||||
except (GitError, IndexError, ValueError):
|
||||
return False
|
||||
|
||||
if self.IsDirty(consider_untracked=True) or self.HasStash():
|
||||
if self._HasDirtyOrStash():
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -1811,15 +2039,27 @@ class Project:
|
||||
|
||||
self.revisionId = revisionId
|
||||
|
||||
@property
|
||||
def UseReprojectCmd(self) -> bool:
|
||||
"""Whether repo.reprojectcmd materializes this project's tree.
|
||||
|
||||
MetaProjects (repo itself and the manifests) always use Git. See
|
||||
docs/reproject-cmd.md.
|
||||
"""
|
||||
if isinstance(self, MetaProject):
|
||||
return False
|
||||
mp = self.manifest.manifestProject
|
||||
return bool(mp.use_local_gitdirs and mp.reproject_cmd)
|
||||
|
||||
def Sync_LocalHalf(
|
||||
self,
|
||||
syncbuf,
|
||||
force_sync=False,
|
||||
force_checkout=False,
|
||||
force_rebase=False,
|
||||
submodules=False,
|
||||
verbose=False,
|
||||
):
|
||||
syncbuf: Any,
|
||||
force_sync: bool = False,
|
||||
force_checkout: bool = False,
|
||||
force_rebase: bool = False,
|
||||
submodules: bool = False,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Perform only the local IO portion of the sync process.
|
||||
|
||||
Network access is not required.
|
||||
@@ -1838,6 +2078,29 @@ class Project:
|
||||
)
|
||||
return
|
||||
|
||||
if not isinstance(self, MetaProject):
|
||||
mp = self.manifest.manifestProject
|
||||
if mp.reproject_cmd and not mp.use_local_gitdirs:
|
||||
fail(
|
||||
LocalSyncFail(
|
||||
"repo.reprojectcmd requires repo.uselocalgitdirs to be "
|
||||
"enabled",
|
||||
project=self.name,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
reproject = self.UseReprojectCmd
|
||||
if reproject and self.parent:
|
||||
fail(
|
||||
LocalSyncFail(
|
||||
"repo.reprojectcmd does not support nested projects or "
|
||||
"submodules",
|
||||
project=self.name,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self._InitWorkTree(force_sync=force_sync, submodules=submodules)
|
||||
# TODO(https://git-scm.com/docs/git-worktree#_bugs): Re-evaluate if
|
||||
# submodules can be init when using worktrees once its support is
|
||||
@@ -1868,8 +2131,30 @@ class Project:
|
||||
)
|
||||
return
|
||||
|
||||
head = self._GetHead()
|
||||
if head and head.startswith(R_HEADS):
|
||||
branch = head[len(R_HEADS) :]
|
||||
try:
|
||||
head = all_refs[head]
|
||||
except KeyError:
|
||||
head = None
|
||||
else:
|
||||
branch = None
|
||||
|
||||
def _checkout() -> None:
|
||||
"""Detach HEAD at revid, like `git checkout <revid>`."""
|
||||
if reproject:
|
||||
self._ReprojectCheckout(revid, head, verbose=verbose)
|
||||
else:
|
||||
self._Checkout(revid, force_checkout=force_checkout, quiet=True)
|
||||
|
||||
def _doff():
|
||||
self._FastForward(revid)
|
||||
if reproject:
|
||||
self._ReprojectBranch(
|
||||
revid, head, f"merge {revid}: Fast-forward", verbose=verbose
|
||||
)
|
||||
else:
|
||||
self._FastForward(revid)
|
||||
self._CopyAndLinkFiles()
|
||||
|
||||
def _dorebase():
|
||||
@@ -1895,16 +2180,6 @@ class Project:
|
||||
if p.Wait() != 0:
|
||||
logger.warning("warn: %s: stateless gc failed", self.name)
|
||||
|
||||
head = self._GetHead()
|
||||
if head and head.startswith(R_HEADS):
|
||||
branch = head[len(R_HEADS) :]
|
||||
try:
|
||||
head = all_refs[head]
|
||||
except KeyError:
|
||||
head = None
|
||||
else:
|
||||
branch = None
|
||||
|
||||
if branch is None or syncbuf.detach_head:
|
||||
# Currently on a detached HEAD. The user is assumed to
|
||||
# not have any local modifications worth worrying about.
|
||||
@@ -1928,15 +2203,17 @@ class Project:
|
||||
self._CopyAndLinkFiles()
|
||||
return
|
||||
else:
|
||||
lost = self._revlist(not_rev(revid), HEAD)
|
||||
if lost and verbose:
|
||||
syncbuf.info(self, "discarding %d commits", len(lost))
|
||||
if verbose:
|
||||
lost_output = self._revlist("--count", not_rev(revid), HEAD)
|
||||
lost = int(lost_output[0]) if lost_output else 0
|
||||
if lost:
|
||||
syncbuf.info(self, "discarding %d commits", lost)
|
||||
|
||||
try:
|
||||
self._Checkout(revid, force_checkout=force_checkout, quiet=True)
|
||||
_checkout()
|
||||
if submodules:
|
||||
self._SyncSubmodules(quiet=True)
|
||||
except GitError as e:
|
||||
except (GitError, LocalSyncFail) as e:
|
||||
fail(e)
|
||||
return
|
||||
self._CopyAndLinkFiles()
|
||||
@@ -1960,16 +2237,17 @@ class Project:
|
||||
self, "leaving %s; does not track upstream", branch.name
|
||||
)
|
||||
try:
|
||||
self._Checkout(revid, force_checkout=force_checkout, quiet=True)
|
||||
_checkout()
|
||||
if submodules:
|
||||
self._SyncSubmodules(quiet=True)
|
||||
except GitError as e:
|
||||
except (GitError, LocalSyncFail) as e:
|
||||
fail(e)
|
||||
return
|
||||
self._CopyAndLinkFiles()
|
||||
return
|
||||
|
||||
upstream_gain = self._revlist(not_rev(HEAD), revid)
|
||||
gain_output = self._revlist("--count", not_rev(HEAD), revid)
|
||||
upstream_gain = int(gain_output[0]) if gain_output else 0
|
||||
|
||||
# See if we can perform a fast forward merge. This can happen if our
|
||||
# branch isn't in the exact same state as we last published.
|
||||
@@ -1983,7 +2261,7 @@ class Project:
|
||||
pub = self.WasPublished(branch.name, all_refs)
|
||||
|
||||
if pub:
|
||||
not_merged = self._revlist(not_rev(revid), pub)
|
||||
not_merged = self._revlist("-1", not_rev(revid), pub)
|
||||
if not_merged:
|
||||
if upstream_gain:
|
||||
if force_rebase:
|
||||
@@ -1999,12 +2277,17 @@ class Project:
|
||||
"branch %s is published (but not merged) and "
|
||||
"is now %d commits behind. Fix this manually "
|
||||
"or rerun with the --rebase option to force a "
|
||||
"rebase." % (branch.name, len(upstream_gain)),
|
||||
"rebase." % (branch.name, upstream_gain),
|
||||
project=self.name,
|
||||
)
|
||||
)
|
||||
return
|
||||
syncbuf.later1(self, _doff, not verbose)
|
||||
if reproject:
|
||||
# HEAD is ahead of revid, so there is no tree to
|
||||
# materialize: Git's fast-forward would be a no-op.
|
||||
self._CopyAndLinkFiles()
|
||||
else:
|
||||
syncbuf.later1(self, _doff, not verbose)
|
||||
return
|
||||
elif pub == head:
|
||||
# All published commits are merged, and thus we are a
|
||||
@@ -2030,7 +2313,9 @@ class Project:
|
||||
self._CopyAndLinkFiles()
|
||||
return
|
||||
|
||||
if self.IsDirty(consider_untracked=False):
|
||||
if (not reproject or (cnt_mine > 0 and self.rebase)) and self.IsDirty(
|
||||
consider_untracked=False
|
||||
):
|
||||
fail(_DirtyError(project=self.name))
|
||||
return
|
||||
|
||||
@@ -2076,11 +2361,19 @@ class Project:
|
||||
syncbuf.later2(self, _docopyandlink, not verbose)
|
||||
elif local_changes:
|
||||
try:
|
||||
self._ResetHard(revid)
|
||||
if reproject:
|
||||
self._ReprojectBranch(
|
||||
revid,
|
||||
head,
|
||||
f"reset: moving to {revid}",
|
||||
verbose=verbose,
|
||||
)
|
||||
else:
|
||||
self._ResetHard(revid)
|
||||
if submodules:
|
||||
self._SyncSubmodules(quiet=True)
|
||||
self._CopyAndLinkFiles()
|
||||
except GitError as e:
|
||||
except (GitError, LocalSyncFail) as e:
|
||||
fail(e)
|
||||
return
|
||||
else:
|
||||
@@ -2119,7 +2412,11 @@ class Project:
|
||||
"""Download a single patch set of a single change to FETCH_HEAD."""
|
||||
remote = self.GetRemote()
|
||||
|
||||
cmd = ["fetch", remote.name]
|
||||
cmd = ["fetch"]
|
||||
if git_require((2, 17, 0)):
|
||||
cmd.append("--no-filter")
|
||||
cmd.append("--no-tags")
|
||||
cmd.append(remote.name)
|
||||
cmd.append(
|
||||
"refs/changes/%2.2d/%d/%d" % (change_id % 100, change_id, patch_id)
|
||||
)
|
||||
@@ -3669,14 +3966,193 @@ class Project:
|
||||
if GitCommand(self, cmd).Wait() != 0:
|
||||
raise GitError(f"{self.name} rebase {upstream} ", project=self.name)
|
||||
|
||||
def _FastForward(self, head, ffonly=False, quiet=True):
|
||||
cmd = ["merge", "--no-stat", head]
|
||||
if ffonly:
|
||||
cmd.append("--ff-only")
|
||||
def _FastForward(self, head: str, quiet: bool = True) -> None:
|
||||
cmd = ["merge", "--no-stat", "--ff-only"]
|
||||
if quiet:
|
||||
cmd.append("-q")
|
||||
cmd.append(head)
|
||||
if GitCommand(self, cmd).Wait() != 0:
|
||||
raise GitError(f"{self.name} merge {head} ", project=self.name)
|
||||
raise GitError(
|
||||
f"{self.name} merge --ff-only {head}", project=self.name
|
||||
)
|
||||
|
||||
def _ReprojectCheckout(
|
||||
self, revid: str, head: Optional[str], verbose: bool = False
|
||||
) -> None:
|
||||
"""Detach HEAD at |revid| with repo.reprojectcmd.
|
||||
|
||||
This stands in for `git checkout <revid>`. |head| is the commit HEAD
|
||||
names now, or None. The command is not run when that is already
|
||||
|revid|, since there is then nothing to materialize.
|
||||
"""
|
||||
old = self._GetHead()
|
||||
if old and old.startswith(R_HEADS):
|
||||
old = old[len(R_HEADS) :]
|
||||
if head != revid:
|
||||
self._Reproject(revid, verbose=verbose)
|
||||
self.work_git.DetachHead(
|
||||
revid, message=f"checkout: moving from {old or revid} to {revid}"
|
||||
)
|
||||
|
||||
def _ReprojectBranch(
|
||||
self,
|
||||
revid: str,
|
||||
head: Optional[str],
|
||||
message: str,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""Move the checked out branch to |revid| with repo.reprojectcmd.
|
||||
|
||||
This stands in for a fast-forward merge or a hard reset. |head| is the
|
||||
commit the branch is at; the ref write fails if it moved meanwhile.
|
||||
"""
|
||||
self._Reproject(revid, verbose=verbose)
|
||||
self.work_git.UpdateRef(HEAD, revid, old=head, message=message)
|
||||
|
||||
def _Reproject(self, revid: str, verbose: bool = False) -> None:
|
||||
"""Make the index and worktree match |revid| with repo.reprojectcmd.
|
||||
|
||||
The command stands in for the tree materialization of a checkout, a
|
||||
fast-forward or a hard reset. It must leave every ref alone; the
|
||||
caller writes the ref Git would have written.
|
||||
|
||||
For the contract the command has to honor, see docs/reproject-cmd.md.
|
||||
|
||||
Raises:
|
||||
LocalSyncFail: An operation is in progress, the index has staged
|
||||
changes, the command failed, or it left the project in a state
|
||||
that breaks the contract.
|
||||
"""
|
||||
in_progress = self._OperationInProgress()
|
||||
if in_progress:
|
||||
raise LocalSyncFail(
|
||||
f"{in_progress} in progress; reprojectcmd cannot run",
|
||||
project=self.name,
|
||||
)
|
||||
|
||||
try:
|
||||
head_tree = self.work_git.rev_parse(
|
||||
"-q", "--verify", "HEAD^{tree}", log_as_error=False
|
||||
)
|
||||
except GitError:
|
||||
head_tree = None
|
||||
|
||||
if head_tree:
|
||||
p = GitCommand(
|
||||
self,
|
||||
["diff-index", "-z", "--cached", "--name-only", head_tree],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
if p.Wait() != 0:
|
||||
raise LocalSyncFail(
|
||||
f"cannot check the index for staged changes: "
|
||||
f"{p.stderr.strip()}",
|
||||
project=self.name,
|
||||
)
|
||||
staged = p.stdout.split("\0")[:-1]
|
||||
else:
|
||||
p = GitCommand(
|
||||
self,
|
||||
["ls-files", "-z", "--cached"],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
if p.Wait() != 0:
|
||||
raise LocalSyncFail(
|
||||
f"cannot check the index for staged changes: "
|
||||
f"{p.stderr.strip()}",
|
||||
project=self.name,
|
||||
)
|
||||
staged = p.stdout.split("\0")[:-1]
|
||||
|
||||
if staged:
|
||||
raise LocalSyncFail(
|
||||
"reprojectcmd cannot run with staged changes:\n"
|
||||
+ _FirstLines(staged),
|
||||
project=self.name,
|
||||
)
|
||||
|
||||
head = self.work_git.GetHead()
|
||||
try:
|
||||
head_oid = self.work_git.rev_parse(
|
||||
"-q", "--verify", "HEAD", log_as_error=False
|
||||
)
|
||||
except GitError:
|
||||
head_oid = None
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(self.GetEnvVars())
|
||||
env["REPO_TREV"] = revid
|
||||
cmd_str = self.manifest.manifestProject.reproject_cmd
|
||||
if verbose:
|
||||
print(f"Running reprojectcmd: {cmd_str} for {self.name}")
|
||||
|
||||
output = None if verbose else subprocess.PIPE
|
||||
try:
|
||||
p = subprocess.run(
|
||||
cmd_str,
|
||||
shell=True,
|
||||
cwd=self.manifest.topdir,
|
||||
env=env,
|
||||
stdout=output,
|
||||
stderr=None if verbose else subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
except OSError as e:
|
||||
raise LocalSyncFail(
|
||||
f"failed to run reprojectcmd: {e}", project=self.name
|
||||
)
|
||||
if p.returncode != 0:
|
||||
msg = f"reprojectcmd exited with {p.returncode}"
|
||||
if p.stdout:
|
||||
msg += ":\n" + p.stdout.rstrip()
|
||||
raise LocalSyncFail(msg, project=self.name)
|
||||
|
||||
new_head = self.work_git.GetHead()
|
||||
try:
|
||||
new_head_oid = self.work_git.rev_parse(
|
||||
"-q", "--verify", "HEAD", log_as_error=False
|
||||
)
|
||||
except GitError:
|
||||
new_head_oid = None
|
||||
|
||||
if new_head != head or new_head_oid != head_oid:
|
||||
from_desc = (
|
||||
f"{head} ({head_oid})"
|
||||
if head_oid and head != head_oid
|
||||
else f"{head}"
|
||||
)
|
||||
to_desc = (
|
||||
f"{new_head} ({new_head_oid})"
|
||||
if new_head_oid and new_head != new_head_oid
|
||||
else f"{new_head}"
|
||||
)
|
||||
raise LocalSyncFail(
|
||||
f"reprojectcmd moved HEAD from {from_desc} to {to_desc}; repo "
|
||||
"owns every ref write",
|
||||
project=self.name,
|
||||
)
|
||||
|
||||
p = GitCommand(
|
||||
self,
|
||||
["diff-index", "--cached", "--name-only", f"{revid}^{{tree}}"],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
if p.Wait() != 0:
|
||||
raise LocalSyncFail(
|
||||
f"cannot compare the index against {revid}: "
|
||||
f"{p.stderr.strip()}",
|
||||
project=self.name,
|
||||
)
|
||||
mismatched = p.stdout.splitlines()
|
||||
if mismatched:
|
||||
raise LocalSyncFail(
|
||||
f"reprojectcmd left the index different from {revid}:\n"
|
||||
+ _FirstLines(mismatched),
|
||||
project=self.name,
|
||||
)
|
||||
|
||||
def _InitGitDir(self, mirror_git=None, force_sync=False, quiet=False):
|
||||
# Prefix for temporary directories created during gitdir initialization.
|
||||
@@ -4788,7 +5264,7 @@ class _Later:
|
||||
if not self.quiet:
|
||||
out.nl()
|
||||
return True
|
||||
except GitError as e:
|
||||
except (GitError, LocalSyncFail) as e:
|
||||
syncbuf.fail(self.project, e)
|
||||
out.nl()
|
||||
return False
|
||||
@@ -5067,6 +5543,11 @@ class ManifestProject(MetaProject):
|
||||
"""The fetch command to use."""
|
||||
return self.config.GetString("repo.fetchcmd")
|
||||
|
||||
@property
|
||||
def reproject_cmd(self) -> Optional[str]:
|
||||
"""The command that materializes a project's tree instead of Git."""
|
||||
return self.config.GetString("repo.reprojectcmd")
|
||||
|
||||
@property
|
||||
def clone_bundle(self):
|
||||
"""Whether we use clone_bundle."""
|
||||
@@ -5487,6 +5968,15 @@ class ManifestProject(MetaProject):
|
||||
)
|
||||
return False
|
||||
|
||||
if self.reproject_cmd and not (
|
||||
use_local_gitdirs or self.use_local_gitdirs
|
||||
):
|
||||
logger.error(
|
||||
"fatal: repo.reprojectcmd is set but repo.uselocalgitdirs is "
|
||||
"not enabled"
|
||||
)
|
||||
return False
|
||||
|
||||
if archive:
|
||||
if is_new:
|
||||
self.config.SetBoolean("repo.archive", archive)
|
||||
|
||||
@@ -23,4 +23,5 @@ import sys
|
||||
import update_manpages
|
||||
|
||||
|
||||
sys.exit(update_manpages.main(sys.argv[1:]))
|
||||
if __name__ == "__main__":
|
||||
sys.exit(update_manpages.main(sys.argv[1:]))
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@ If no project is specified try to use current directory as a project.
|
||||
elif opt.revert:
|
||||
project._Revert(dl.commit)
|
||||
elif opt.ffonly:
|
||||
project._FastForward(dl.commit, ffonly=True)
|
||||
project._FastForward(dl.commit)
|
||||
else:
|
||||
if opt.branch:
|
||||
project.StartBranch(opt.branch, revision=dl.commit)
|
||||
|
||||
+6
-2
@@ -244,10 +244,14 @@ without iterating through the remaining projects.
|
||||
mirror = self.manifest.IsMirror
|
||||
|
||||
if opt.regex:
|
||||
projects = self.FindProjects(args, all_manifests=all_trees)
|
||||
projects = self.FindProjects(
|
||||
args,
|
||||
groups=opt.groups,
|
||||
all_manifests=all_trees,
|
||||
)
|
||||
elif opt.inverse_regex:
|
||||
projects = self.FindProjects(
|
||||
args, inverse=True, all_manifests=all_trees
|
||||
args, inverse=True, groups=opt.groups, all_manifests=all_trees
|
||||
)
|
||||
else:
|
||||
projects = self.GetProjects(
|
||||
|
||||
+58
-26
@@ -18,7 +18,7 @@ import io
|
||||
import json
|
||||
import optparse
|
||||
import sys
|
||||
from typing import Any, Dict, List, NamedTuple
|
||||
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
|
||||
|
||||
from color import Coloring
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
@@ -189,18 +189,26 @@ class Info(PagedCommand):
|
||||
"superproject_revision": srev,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _GetCurrentBranch(branches: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return the name of the current branch from a GetBranches mapping."""
|
||||
return next(
|
||||
(name for name, branch in branches.items() if branch.current), None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _getProjectData(cls, project) -> Dict[str, Any]:
|
||||
"""Gather project data as a dict."""
|
||||
branches = project.GetBranches()
|
||||
currentBranch = cls._GetCurrentBranch(branches)
|
||||
data = {
|
||||
"name": project.name,
|
||||
"mount_path": project.worktree,
|
||||
"current_revision": project.GetHeadRevisionId()
|
||||
or project.GetRevisionId(),
|
||||
"manifest_revision": project.revisionExpr,
|
||||
"local_branches": list(project.GetBranches()),
|
||||
"local_branches": list(branches),
|
||||
}
|
||||
currentBranch = project.CurrentBranch
|
||||
if currentBranch:
|
||||
data["current_branch"] = currentBranch
|
||||
return data
|
||||
@@ -285,6 +293,9 @@ class Info(PagedCommand):
|
||||
text = out.nofmt_printer("text")
|
||||
dimtext = out.printer("dimtext", attr="dim")
|
||||
|
||||
branches = project.GetBranches()
|
||||
currentBranch = cls._GetCurrentBranch(branches)
|
||||
|
||||
heading("Project: ")
|
||||
headtext(project.name)
|
||||
out.nl()
|
||||
@@ -297,7 +308,6 @@ class Info(PagedCommand):
|
||||
headtext(project.GetHeadRevisionId() or project.GetRevisionId())
|
||||
out.nl()
|
||||
|
||||
currentBranch = project.CurrentBranch
|
||||
if currentBranch:
|
||||
heading("Current branch: ")
|
||||
headtext(currentBranch)
|
||||
@@ -307,7 +317,7 @@ class Info(PagedCommand):
|
||||
headtext(project.revisionExpr)
|
||||
out.nl()
|
||||
|
||||
localBranches = list(project.GetBranches().keys())
|
||||
localBranches = list(branches)
|
||||
heading("Local Branches: ")
|
||||
redtext(str(len(localBranches)))
|
||||
if localBranches:
|
||||
@@ -327,25 +337,10 @@ class Info(PagedCommand):
|
||||
branch = branch[len(R_HEADS) :]
|
||||
logTarget = R_M + branch
|
||||
|
||||
bareTmp = project.bare_git._bare
|
||||
project.bare_git._bare = False
|
||||
localCommits = project.bare_git.rev_list(
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
logTarget + "..",
|
||||
"--",
|
||||
localCommits, originCommits = cls._GetDiffCommits(
|
||||
project, logTarget
|
||||
)
|
||||
|
||||
originCommits = project.bare_git.rev_list(
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
".." + logTarget,
|
||||
"--",
|
||||
)
|
||||
project.bare_git._bare = bareTmp
|
||||
|
||||
heading("Local Commits: ")
|
||||
redtext(str(len(localCommits)))
|
||||
dimtext(" (on current branch)")
|
||||
@@ -375,6 +370,35 @@ class Info(PagedCommand):
|
||||
|
||||
return buf.getvalue()
|
||||
|
||||
@classmethod
|
||||
def _GetDiffCommits(
|
||||
cls, project: Any, log_target: str
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Return local-only and remote-only commits from one history walk."""
|
||||
git = getattr(project, "work_git", None) or getattr(
|
||||
project, "bare_git", None
|
||||
)
|
||||
if git is None:
|
||||
return [], []
|
||||
commits = git.rev_list(
|
||||
"--left-right",
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
f"HEAD...{log_target}",
|
||||
"--",
|
||||
)
|
||||
if isinstance(commits, str):
|
||||
commits = commits.splitlines()
|
||||
local = []
|
||||
remote = []
|
||||
for commit in commits:
|
||||
if commit.startswith("<"):
|
||||
local.append(commit[1:])
|
||||
elif commit.startswith(">"):
|
||||
remote.append(commit[1:])
|
||||
return local, remote
|
||||
|
||||
def _printDiffInfo(self, opt, args):
|
||||
projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
|
||||
|
||||
@@ -404,10 +428,18 @@ class Info(PagedCommand):
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
|
||||
branches = []
|
||||
br = [project.GetUploadableBranch(x) for x in project.GetBranches()]
|
||||
br = [x for x in br if x]
|
||||
local_branches = project.GetBranches()
|
||||
current_branch = cls._GetCurrentBranch(local_branches)
|
||||
if opt.current_branch:
|
||||
br = [x for x in br if x.name == project.CurrentBranch]
|
||||
candidate_branches = (
|
||||
[current_branch]
|
||||
if current_branch and current_branch in local_branches
|
||||
else []
|
||||
)
|
||||
else:
|
||||
candidate_branches = local_branches
|
||||
br = [project.GetUploadableBranch(x) for x in candidate_branches]
|
||||
br = [x for x in br if x]
|
||||
|
||||
for b in br:
|
||||
branches.append(
|
||||
@@ -416,7 +448,7 @@ class Info(PagedCommand):
|
||||
name=b.name,
|
||||
commits=b.commits,
|
||||
date=b.date,
|
||||
is_current=b.name == project.CurrentBranch,
|
||||
is_current=b.name == current_branch,
|
||||
)
|
||||
)
|
||||
return branches
|
||||
|
||||
+4
-1
@@ -109,7 +109,10 @@ This is similar to running: repo forall -c 'echo "$REPO_PATH : $REPO_PROJECT"'.
|
||||
)
|
||||
else:
|
||||
projects = self.FindProjects(
|
||||
args, all_manifests=not opt.this_manifest_only
|
||||
args,
|
||||
groups=opt.groups,
|
||||
missing_ok=opt.all,
|
||||
all_manifests=not opt.this_manifest_only,
|
||||
)
|
||||
|
||||
def _getpath(x):
|
||||
|
||||
+65
-3
@@ -72,6 +72,7 @@ from git_refs import HEAD
|
||||
from git_refs import R_HEADS
|
||||
import git_superproject
|
||||
from hooks import RepoHook
|
||||
from manifest_xml import XmlManifest
|
||||
import platform_utils
|
||||
from progress import elapsed_str
|
||||
from progress import jobs_str
|
||||
@@ -156,6 +157,11 @@ def _SafeCheckoutOrder(checkouts: List[Project]) -> List[List[Project]]:
|
||||
return res
|
||||
|
||||
|
||||
def _NestedProjects(projects: List[Project]) -> List[Project]:
|
||||
"""Return the projects in |projects| living inside another one's path."""
|
||||
return [p for level in _SafeCheckoutOrder(projects)[1:] for p in level]
|
||||
|
||||
|
||||
def _ParentFirstBatches(projects: List[Project]) -> List[List[Project]]:
|
||||
"""Group |projects| so that a parent is fetched before its submodules.
|
||||
|
||||
@@ -528,6 +534,8 @@ later is required to fix a server side protocol bug.
|
||||
help="number of network jobs to run in parallel (defaults to "
|
||||
"--jobs or 1). Ignored unless --no-interleaved is set",
|
||||
)
|
||||
|
||||
jobs_checkout_default = self._GetHelpForCpuJobCount(DEFAULT_LOCAL_JOBS)
|
||||
p.add_option(
|
||||
"--jobs-checkout",
|
||||
default=None,
|
||||
@@ -535,7 +543,7 @@ later is required to fix a server side protocol bug.
|
||||
metavar="JOBS",
|
||||
help=(
|
||||
"number of local checkout jobs to run in parallel (defaults "
|
||||
f"to --jobs or {DEFAULT_LOCAL_JOBS}). Ignored unless "
|
||||
f"to --jobs or {jobs_checkout_default}). Ignored unless "
|
||||
"--no-interleaved is set"
|
||||
),
|
||||
)
|
||||
@@ -1606,10 +1614,23 @@ later is required to fix a server side protocol bug.
|
||||
# Only check dirty or locally modified projects. These can't be
|
||||
# freshly cloned and will accumulate garbage.
|
||||
try:
|
||||
is_dirty = project.IsDirty(consider_untracked=True)
|
||||
status = project._GetStatusSnapshot(
|
||||
untracked_files="normal", branch=True
|
||||
)
|
||||
if status is not None:
|
||||
is_dirty = status.is_dirty(consider_untracked=True)
|
||||
head_rev = status.branch_oid
|
||||
else:
|
||||
is_dirty = project.IsDirty(consider_untracked=True)
|
||||
head_rev = project.work_git.rev_parse(HEAD)
|
||||
|
||||
if head_rev is None:
|
||||
# Porcelain v2 reports an unborn branch as "(initial)". The
|
||||
# legacy rev-parse path failed here and skipped the bloat
|
||||
# calculation, so preserve that behavior.
|
||||
return None
|
||||
|
||||
manifest_rev = project.GetRevisionId(project.bare_ref.all)
|
||||
head_rev = project.work_git.rev_parse(HEAD)
|
||||
has_local_commits = manifest_rev != head_rev
|
||||
|
||||
if not (is_dirty or has_local_commits):
|
||||
@@ -2478,6 +2499,7 @@ later is required to fix a server side protocol bug.
|
||||
manifest=manifest,
|
||||
all_manifests=not opt.this_manifest_only,
|
||||
)
|
||||
self._CheckReprojectCmdNesting(opt, args, manifest, all_projects)
|
||||
|
||||
# Log the repo projects by existing and new.
|
||||
existing = [x for x in all_projects if x.Exists]
|
||||
@@ -2541,6 +2563,46 @@ later is required to fix a server side protocol bug.
|
||||
if not opt.quiet:
|
||||
print("repo sync has finished successfully.")
|
||||
|
||||
def _CheckReprojectCmdNesting(
|
||||
self,
|
||||
opt: optparse.Values,
|
||||
args: List[str],
|
||||
manifest: XmlManifest,
|
||||
all_projects: List[Project],
|
||||
) -> None:
|
||||
"""Fail when repo.reprojectcmd is used on a manifest nesting projects.
|
||||
|
||||
The command materializes a project's tree without Git, so nothing
|
||||
keeps it from clobbering a project or submodule checked out inside
|
||||
that tree. See docs/reproject-cmd.md.
|
||||
"""
|
||||
if not any(p.UseReprojectCmd for p in all_projects):
|
||||
return
|
||||
projects = all_projects
|
||||
if args:
|
||||
# Nesting is a property of the manifest, not of the projects
|
||||
# picked on the command line.
|
||||
projects = self.GetProjects(
|
||||
[],
|
||||
groups=opt.groups,
|
||||
missing_ok=True,
|
||||
submodules_ok=opt.recurse_submodules,
|
||||
manifest=manifest,
|
||||
all_manifests=not opt.this_manifest_only,
|
||||
)
|
||||
nested = _NestedProjects(projects)
|
||||
if not nested:
|
||||
return
|
||||
e = SyncError(
|
||||
"error: repo.reprojectcmd does not support nested projects or "
|
||||
"submodules; found:\n"
|
||||
+ "\n".join(
|
||||
f" - {p.RelPath(local=opt.this_manifest_only)}" for p in nested
|
||||
)
|
||||
)
|
||||
logger.error(e)
|
||||
raise e
|
||||
|
||||
def _CreateSyncProgressThread(
|
||||
self, pm: Progress, stop_event: _threading.Event
|
||||
) -> _threading.Thread:
|
||||
|
||||
@@ -435,6 +435,18 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
self._UploadAndReport(opt, [branch], people)
|
||||
|
||||
def _MultipleBranches(self, opt, pending, people):
|
||||
if opt.yes and (opt.current_branch or opt.branch):
|
||||
todo = [
|
||||
branch
|
||||
for _, avail in pending
|
||||
for branch in avail
|
||||
if branch is not None
|
||||
]
|
||||
if not todo:
|
||||
_die("nothing ready for upload")
|
||||
self._UploadAndReport(opt, todo, people)
|
||||
return
|
||||
|
||||
projects = {}
|
||||
branches = {}
|
||||
|
||||
|
||||
+234
-4
@@ -14,6 +14,8 @@
|
||||
|
||||
"""Unittests for the command.py module."""
|
||||
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from command import Command
|
||||
@@ -30,12 +32,15 @@ class FakeProject:
|
||||
gitdir=None,
|
||||
derived_subprojects=None,
|
||||
sync_s=False,
|
||||
exists: bool = True,
|
||||
):
|
||||
self.name = name
|
||||
self.relpath = relpath
|
||||
self.worktree = f"/work/{relpath}"
|
||||
self.manifest = None
|
||||
self.gitdir = gitdir or f"/git/{relpath}"
|
||||
self.sync_s = sync_s
|
||||
self.Exists = True
|
||||
self.Exists = exists
|
||||
self._derived_subprojects = derived_subprojects or []
|
||||
|
||||
def GetDerivedSubprojects(self):
|
||||
@@ -51,11 +56,59 @@ class FakeProject:
|
||||
class FakeManifest:
|
||||
"""Minimal manifest double for Command.GetProjects tests."""
|
||||
|
||||
def __init__(self, projects):
|
||||
self.projects = projects
|
||||
def __init__(
|
||||
self,
|
||||
projects: Iterable[FakeProject],
|
||||
*,
|
||||
all_projects: Optional[Iterable[FakeProject]] = None,
|
||||
effective_groups: str = "default",
|
||||
):
|
||||
self.projects = list(projects)
|
||||
self.all_projects = (
|
||||
list(self.projects) if all_projects is None else list(all_projects)
|
||||
)
|
||||
self._effective_groups = effective_groups
|
||||
self.path_prefix = ""
|
||||
|
||||
# all_projects may include projects owned by child manifests,
|
||||
# so only set this manifest on its direct projects.
|
||||
for project in self.projects:
|
||||
self._set_project_manifest(project)
|
||||
|
||||
def _set_project_manifest(self, project: FakeProject) -> None:
|
||||
project.manifest = self
|
||||
for subproject in project.GetDerivedSubprojects():
|
||||
self._set_project_manifest(subproject)
|
||||
|
||||
def GetManifestGroupsStr(self):
|
||||
return "default"
|
||||
return self._effective_groups
|
||||
|
||||
def GetProjectsWithName(
|
||||
self, name: str, all_manifests: bool = False
|
||||
) -> List[FakeProject]:
|
||||
projects = self.all_projects if all_manifests else self.projects
|
||||
return [project for project in projects if project.name == name]
|
||||
|
||||
|
||||
class GroupMatchingFakeProject(FakeProject):
|
||||
"""Fake project with predictable group matches for GetProjects tests.
|
||||
|
||||
This lets the tests check which groups GetProjects uses without
|
||||
reimplementing Project.MatchesGroups.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
relpath: str,
|
||||
*,
|
||||
matching_groups: Iterable[str],
|
||||
):
|
||||
super().__init__(name, relpath)
|
||||
self._matching_groups = set(matching_groups)
|
||||
|
||||
def MatchesGroups(self, groups: Iterable[str]) -> bool:
|
||||
return bool(self._matching_groups.intersection(groups))
|
||||
|
||||
|
||||
def test_get_projects_keeps_derived_subprojects_for_repeated_repo():
|
||||
@@ -117,3 +170,180 @@ def test_get_projects_submodule_override(
|
||||
projects = cmd.GetProjects([], submodules_ok=submodules_ok)
|
||||
|
||||
assert (submodule in projects) is includes_submodule
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("groups", "expected_relpaths"),
|
||||
[
|
||||
(None, ["outer", "sub/child"]),
|
||||
("", ["outer", "sub/child"]),
|
||||
("override-group", ["sub/override"]),
|
||||
],
|
||||
ids=("groups-omitted", "groups-empty", "explicit-override"),
|
||||
)
|
||||
def test_get_projects_uses_groups_from_each_manifest_unless_overridden(
|
||||
groups: Optional[str],
|
||||
expected_relpaths: List[str],
|
||||
) -> None:
|
||||
"""Use each manifest's effective groups unless the caller overrides them."""
|
||||
outer_project = GroupMatchingFakeProject(
|
||||
"outer",
|
||||
"outer",
|
||||
matching_groups={"outer-group"},
|
||||
)
|
||||
|
||||
# Both child projects also match "outer". Reusing the outer manifest's
|
||||
# groups would therefore select both child projects.
|
||||
child_project = GroupMatchingFakeProject(
|
||||
"child",
|
||||
"sub/child",
|
||||
matching_groups={"outer-group", "child-group"},
|
||||
)
|
||||
override_project = GroupMatchingFakeProject(
|
||||
"override",
|
||||
"sub/override",
|
||||
matching_groups={"outer-group", "override-group"},
|
||||
)
|
||||
|
||||
child_manifest = FakeManifest(
|
||||
[child_project, override_project],
|
||||
effective_groups="child-group",
|
||||
)
|
||||
outer_manifest = FakeManifest(
|
||||
[outer_project],
|
||||
all_projects=[outer_project, *child_manifest.projects],
|
||||
effective_groups="outer-group",
|
||||
)
|
||||
cmd = Command(manifest=outer_manifest)
|
||||
|
||||
projects = cmd.GetProjects(
|
||||
[],
|
||||
manifest=outer_manifest,
|
||||
groups=groups,
|
||||
all_manifests=True,
|
||||
)
|
||||
|
||||
assert [project.relpath for project in projects] == expected_relpaths
|
||||
|
||||
|
||||
def test_get_projects_by_name_uses_groups_from_each_manifest() -> None:
|
||||
"""Name matches use the groups from each project's owning manifest."""
|
||||
outer_project = GroupMatchingFakeProject(
|
||||
"shared",
|
||||
"outer/shared",
|
||||
matching_groups={"outer-group"},
|
||||
)
|
||||
child_project = GroupMatchingFakeProject(
|
||||
"shared",
|
||||
"sub/shared",
|
||||
matching_groups={"child-group"},
|
||||
)
|
||||
|
||||
child_manifest = FakeManifest(
|
||||
[child_project],
|
||||
effective_groups="child-group",
|
||||
)
|
||||
outer_manifest = FakeManifest(
|
||||
[outer_project],
|
||||
all_projects=[outer_project, *child_manifest.projects],
|
||||
effective_groups="outer-group",
|
||||
)
|
||||
cmd = Command(manifest=outer_manifest)
|
||||
|
||||
projects = cmd.GetProjects(
|
||||
["shared"],
|
||||
manifest=outer_manifest,
|
||||
all_manifests=True,
|
||||
)
|
||||
|
||||
assert [project.relpath for project in projects] == [
|
||||
"outer/shared",
|
||||
"sub/shared",
|
||||
]
|
||||
|
||||
|
||||
def test_find_projects_uses_groups_from_each_manifest() -> None:
|
||||
"""Use each manifest's effective groups for regex selection."""
|
||||
outer_project = GroupMatchingFakeProject(
|
||||
"match-outer",
|
||||
"outer",
|
||||
matching_groups={"outer-group"},
|
||||
)
|
||||
child_project = GroupMatchingFakeProject(
|
||||
"match-child",
|
||||
"sub/child",
|
||||
matching_groups={"child-group"},
|
||||
)
|
||||
excluded_project = GroupMatchingFakeProject(
|
||||
"match-excluded",
|
||||
"sub/excluded",
|
||||
matching_groups={"other-group"},
|
||||
)
|
||||
|
||||
child_manifest = FakeManifest(
|
||||
[child_project, excluded_project],
|
||||
effective_groups="child-group",
|
||||
)
|
||||
child_manifest.path_prefix = "sub"
|
||||
|
||||
outer_manifest = FakeManifest(
|
||||
[outer_project],
|
||||
all_projects=[
|
||||
outer_project,
|
||||
*child_manifest.projects,
|
||||
],
|
||||
effective_groups="outer-group",
|
||||
)
|
||||
outer_manifest.outer_client = outer_manifest
|
||||
|
||||
cmd = Command(manifest=outer_manifest)
|
||||
|
||||
projects = cmd.FindProjects(["match"], all_manifests=True)
|
||||
|
||||
assert [project.relpath for project in projects] == [
|
||||
"outer",
|
||||
"sub/child",
|
||||
]
|
||||
|
||||
|
||||
def test_find_projects_uses_explicit_groups() -> None:
|
||||
"""Use explicit groups for regex selection."""
|
||||
default_project = GroupMatchingFakeProject(
|
||||
"default",
|
||||
"default",
|
||||
matching_groups={"default-group"},
|
||||
)
|
||||
override_project = GroupMatchingFakeProject(
|
||||
"override",
|
||||
"override",
|
||||
matching_groups={"override-group"},
|
||||
)
|
||||
manifest = FakeManifest(
|
||||
[default_project, override_project],
|
||||
effective_groups="default-group",
|
||||
)
|
||||
cmd = Command(manifest=manifest)
|
||||
|
||||
projects = cmd.FindProjects(
|
||||
["override"],
|
||||
groups="override-group",
|
||||
)
|
||||
|
||||
assert projects == [override_project]
|
||||
|
||||
|
||||
def test_find_projects_allows_missing_projects() -> None:
|
||||
"""Allow regex selection to include projects without a checkout."""
|
||||
project = FakeProject(
|
||||
"missing",
|
||||
"missing",
|
||||
exists=False,
|
||||
)
|
||||
cmd = Command(manifest=FakeManifest([project]))
|
||||
|
||||
projects = cmd.FindProjects(
|
||||
["missing"],
|
||||
missing_ok=True,
|
||||
)
|
||||
|
||||
assert projects == [project]
|
||||
|
||||
@@ -123,6 +123,7 @@ class GitCommandStreamLogsTest(unittest.TestCase):
|
||||
"""Tests the GitCommand class stderr log streaming cases."""
|
||||
|
||||
def setUp(self):
|
||||
_ = git_command.user_agent.git
|
||||
self.mock_process = mock.MagicMock()
|
||||
self.mock_process.communicate.return_value = (None, None)
|
||||
self.mock_process.wait.return_value = 0
|
||||
@@ -228,6 +229,141 @@ class GitCommandStreamLogsTest(unittest.TestCase):
|
||||
self.assertEqual(cmd.stderr, logs)
|
||||
|
||||
|
||||
class GitCommandCaptureBytesTest(unittest.TestCase):
|
||||
"""Tests the GitCommand class byte capture cases."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
_ = git_command.user_agent.git
|
||||
self.mock_process = mock.MagicMock()
|
||||
self.mock_process.communicate.return_value = (None, None)
|
||||
self.mock_process.wait.return_value = 0
|
||||
|
||||
self.mock_popen = mock.MagicMock()
|
||||
self.mock_popen.return_value = self.mock_process
|
||||
mock.patch("subprocess.Popen", self.mock_popen).start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
mock.patch.stopall()
|
||||
|
||||
def test_captures_stdout_as_bytes(self) -> None:
|
||||
self.mock_process.communicate.return_value = (b"\xff\x00", b"error\r\n")
|
||||
|
||||
cmd = git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
capture_stdout=True,
|
||||
capture_stdout_bytes=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
|
||||
self.mock_popen.assert_called_once_with(
|
||||
["git", "status"],
|
||||
cwd=None,
|
||||
env=mock.ANY,
|
||||
stdin=None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.assertEqual(cmd.stdout, b"\xff\x00")
|
||||
self.assertEqual(cmd.stderr, "error\n")
|
||||
|
||||
def test_capture_stdout_bytes_auto_enables_capture_stdout(self) -> None:
|
||||
self.mock_process.communicate.return_value = (b"output", b"")
|
||||
|
||||
cmd = git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
|
||||
self.mock_popen.assert_called_once_with(
|
||||
["git", "status"],
|
||||
cwd=None,
|
||||
env=mock.ANY,
|
||||
stdin=None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=None,
|
||||
)
|
||||
self.assertEqual(cmd.stdout, b"output")
|
||||
|
||||
def test_capture_stdout_bytes_with_merge_output_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
capture_stdout_bytes=True,
|
||||
merge_output=True,
|
||||
)
|
||||
|
||||
def test_captures_stdout_as_bytes_encodes_str_input(self) -> None:
|
||||
self.mock_process.communicate.return_value = (b"output", b"")
|
||||
|
||||
git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
input="hello world",
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
|
||||
self.mock_process.communicate.assert_called_once_with(
|
||||
input=b"hello world"
|
||||
)
|
||||
|
||||
def test_captures_stdout_as_bytes_encodes_surrogate_input(self) -> None:
|
||||
self.mock_process.communicate.return_value = (b"output", b"")
|
||||
|
||||
git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
input="file_\udcff.txt",
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
|
||||
self.mock_process.communicate.assert_called_once_with(
|
||||
input=b"file_\xff.txt"
|
||||
)
|
||||
|
||||
def test_captures_stdout_as_bytes_passes_bytes_input(self) -> None:
|
||||
self.mock_process.communicate.return_value = (b"output", b"")
|
||||
|
||||
git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
input=b"raw_\xff.txt",
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
|
||||
self.mock_process.communicate.assert_called_once_with(
|
||||
input=b"raw_\xff.txt"
|
||||
)
|
||||
|
||||
def test_verify_command_truncates_nul_delimited_stdout(self) -> None:
|
||||
cmd = git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
cmd.rc = 1
|
||||
cmd.stdout = b"first_file\0second_file\0third_file"
|
||||
cmd.stderr = "stderr"
|
||||
with self.assertRaises(git_command.GitCommandError) as cm:
|
||||
cmd.VerifyCommand()
|
||||
self.assertEqual(cm.exception.git_stdout, "first_file")
|
||||
|
||||
def test_verify_command_decodes_bytes_stdout(self) -> None:
|
||||
cmd = git_command.GitCommand(
|
||||
None,
|
||||
["status"],
|
||||
capture_stdout_bytes=True,
|
||||
)
|
||||
cmd.rc = 1
|
||||
cmd.stdout = b"error\xff\nline2"
|
||||
cmd.stderr = "stderr"
|
||||
with self.assertRaises(git_command.GitCommandError) as cm:
|
||||
cmd.VerifyCommand()
|
||||
self.assertEqual(cm.exception.git_stdout, "error\\xff")
|
||||
|
||||
|
||||
class GitCallUnitTest(unittest.TestCase):
|
||||
"""Tests the _GitCall class (via git_command.git)."""
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unittests for the git_status.py module."""
|
||||
|
||||
import os
|
||||
from typing import Any, List
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import git_status
|
||||
|
||||
|
||||
def test_parse_porcelain_v2_branch_and_paths() -> None:
|
||||
output = (
|
||||
b"# branch.oid " + b"1" * 40 + b"\0"
|
||||
b"# branch.head topic\0"
|
||||
b"# branch.upstream origin/main\0"
|
||||
b"# branch.ab +2 -3\0"
|
||||
b"# stash 1\0"
|
||||
b"1 M. N... 100644 100644 100644 "
|
||||
+ b"1" * 40
|
||||
+ b" "
|
||||
+ b"2" * 40
|
||||
+ b" staged name\0"
|
||||
b"1 .M N... 100644 100644 100644 "
|
||||
+ b"1" * 40
|
||||
+ b" "
|
||||
+ b"2" * 40
|
||||
+ b" worktree name\0"
|
||||
b"2 R. N... 100644 100644 100644 "
|
||||
+ b"1" * 40
|
||||
+ b" "
|
||||
+ b"2" * 40
|
||||
+ b" R075 renamed\0old name\0"
|
||||
b"? untracked\0"
|
||||
)
|
||||
|
||||
status = git_status.ParsePorcelainV2(output)
|
||||
|
||||
assert status.current_branch == "topic"
|
||||
assert status.upstream == "origin/main"
|
||||
assert (status.ahead, status.behind, status.stash_count) == (2, 3, 1)
|
||||
assert status.index_changes["staged name"].status == "M"
|
||||
assert status.worktree_changes["worktree name"].status == "M"
|
||||
renamed = status.index_changes["renamed"]
|
||||
assert (renamed.src_path, renamed.level) == ("old name", "75")
|
||||
assert status.untracked == ["untracked"]
|
||||
|
||||
|
||||
def test_parse_porcelain_v2_unmerged_and_non_utf8_path() -> None:
|
||||
path = b"bad-\xff-name"
|
||||
output = (
|
||||
b"u UU N... 100644 100644 100644 100644 "
|
||||
+ b"1" * 40
|
||||
+ b" "
|
||||
+ b"2" * 40
|
||||
+ b" "
|
||||
+ b"3" * 40
|
||||
+ b" "
|
||||
+ path
|
||||
+ b"\0"
|
||||
)
|
||||
|
||||
status = git_status.ParsePorcelainV2(output)
|
||||
decoded = os.fsdecode(path)
|
||||
|
||||
assert status.index_changes[decoded].status == "U"
|
||||
assert status.worktree_changes[decoded].status == "U"
|
||||
assert os.fsencode(status.index_changes[decoded].path) == path
|
||||
|
||||
|
||||
def test_untracked_only_respects_consider_untracked() -> None:
|
||||
status = git_status.ParsePorcelainV2(b"? new file\0")
|
||||
|
||||
assert status.is_dirty()
|
||||
assert not status.is_dirty(consider_untracked=False)
|
||||
|
||||
|
||||
def test_branch_headers_preserve_non_ascii_names() -> None:
|
||||
branch = "tópico"
|
||||
status = git_status.ParsePorcelainV2(
|
||||
b"# branch.oid " + b"1" * 40 + b"\0"
|
||||
b"# branch.head " + os.fsencode(branch) + b"\0"
|
||||
)
|
||||
|
||||
assert status.current_branch == branch
|
||||
|
||||
|
||||
def test_quick_ahead_behind_is_recorded_as_unknown() -> None:
|
||||
status = git_status.ParsePorcelainV2(b"# branch.ab +? -?\0")
|
||||
|
||||
assert (status.ahead, status.behind) == (0, 0)
|
||||
assert not status.has_ahead_behind
|
||||
|
||||
|
||||
def test_unknown_head_is_not_a_current_branch() -> None:
|
||||
status = git_status.ParsePorcelainV2(b"# branch.head (unknown)\0")
|
||||
|
||||
assert status.current_branch is None
|
||||
|
||||
|
||||
def test_malformed_output_is_rejected() -> None:
|
||||
with pytest.raises(git_status.StatusParseError):
|
||||
git_status.ParsePorcelainV2(b"2 R. truncated\0")
|
||||
|
||||
|
||||
def test_malformed_branch_ab_is_rejected() -> None:
|
||||
with pytest.raises(git_status.StatusParseError):
|
||||
git_status.ParsePorcelainV2(b"# branch.ab not-a-valid-ab\0")
|
||||
|
||||
|
||||
def test_ignored_records_are_skipped() -> None:
|
||||
status = git_status.ParsePorcelainV2(b"! ignored_file\0")
|
||||
assert not status.is_dirty()
|
||||
assert status.untracked == []
|
||||
|
||||
|
||||
def test_get_status_uses_versioned_machine_options(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
commands = []
|
||||
|
||||
class FakeGitCommand:
|
||||
def __init__(
|
||||
self, _project: Any, cmdv: List[str], **kwargs: Any
|
||||
) -> None:
|
||||
commands.append((cmdv, kwargs))
|
||||
self.stdout = b""
|
||||
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_status, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(git_status, "git_require", lambda _version: True)
|
||||
|
||||
git_status.GetStatus(
|
||||
mock.sentinel.project,
|
||||
mock.sentinel.gitdir,
|
||||
untracked_files="no",
|
||||
branch=True,
|
||||
ahead_behind=True,
|
||||
show_stash=True,
|
||||
)
|
||||
|
||||
cmd, kwargs = commands[0]
|
||||
assert cmd == [
|
||||
"status",
|
||||
"--porcelain=v2",
|
||||
"-z",
|
||||
"--ignore-submodules=all",
|
||||
"--untracked-files=no",
|
||||
"--branch",
|
||||
"--ahead-behind",
|
||||
"--renames",
|
||||
"--show-stash",
|
||||
]
|
||||
assert kwargs["capture_stdout_bytes"]
|
||||
|
||||
|
||||
def test_get_status_rejects_git_before_2_11(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(git_status, "git_require", lambda _version: False)
|
||||
|
||||
with pytest.raises(git_status.UnsupportedStatusError):
|
||||
git_status.GetStatus(mock.sentinel.project, mock.sentinel.gitdir)
|
||||
|
||||
|
||||
def test_get_status_omits_stash_header_before_2_35(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
commands = []
|
||||
|
||||
class FakeGitCommand:
|
||||
def __init__(
|
||||
self, _project: Any, cmdv: List[str], **_kwargs: Any
|
||||
) -> None:
|
||||
commands.append(cmdv)
|
||||
self.stdout = b""
|
||||
|
||||
def Wait(self) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(git_status, "GitCommand", FakeGitCommand)
|
||||
monkeypatch.setattr(
|
||||
git_status,
|
||||
"git_require",
|
||||
lambda version: version <= (2, 34, 0),
|
||||
)
|
||||
|
||||
git_status.GetStatus(
|
||||
mock.sentinel.project,
|
||||
mock.sentinel.gitdir,
|
||||
show_stash=True,
|
||||
)
|
||||
|
||||
assert "--show-stash" not in commands[0]
|
||||
@@ -33,7 +33,7 @@ import platform_utils
|
||||
|
||||
def server_logging_thread(
|
||||
socket_path: str,
|
||||
server_ready: threading.Condition,
|
||||
server_ready: threading.Event,
|
||||
received_traces: List[str],
|
||||
) -> None:
|
||||
"""Helper function to receive logs over a Unix domain socket.
|
||||
@@ -43,8 +43,7 @@ def server_logging_thread(
|
||||
|
||||
Args:
|
||||
socket_path: path to a Unix domain socket on which to listen for traces
|
||||
server_ready: a threading.Condition used to signal to the caller that
|
||||
this thread is ready to accept connections
|
||||
server_ready: event set when the server is ready to accept connections
|
||||
received_traces: a list to which received traces will be appended (after
|
||||
decoding to a utf-8 string).
|
||||
"""
|
||||
@@ -53,8 +52,7 @@ def server_logging_thread(
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(socket_path)
|
||||
sock.listen(0)
|
||||
with server_ready:
|
||||
server_ready.notify()
|
||||
server_ready.set()
|
||||
with sock.accept()[0] as conn:
|
||||
while True:
|
||||
recved = conn.recv(4096)
|
||||
@@ -404,7 +402,7 @@ def test_write_socket(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
received_traces: List[str] = []
|
||||
with tempfile.TemporaryDirectory(prefix="test_server_sockets") as tempdir:
|
||||
socket_path = os.path.join(tempdir, "server.sock")
|
||||
server_ready = threading.Condition()
|
||||
server_ready = threading.Event()
|
||||
# Start "server" listening on Unix domain socket at socket_path.
|
||||
server_thread = threading.Thread(
|
||||
target=server_logging_thread,
|
||||
@@ -412,9 +410,7 @@ def test_write_socket(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
)
|
||||
try:
|
||||
server_thread.start()
|
||||
|
||||
with server_ready:
|
||||
server_ready.wait(timeout=120)
|
||||
server_ready.wait(timeout=120)
|
||||
|
||||
event_log.StartEvent([])
|
||||
path = event_log.Write(path=f"af_unix:{socket_path}")
|
||||
|
||||
+1185
-12
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the release/update-manpages wrapper."""
|
||||
|
||||
import runpy
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
|
||||
UPDATE_MANPAGES_SCRIPT = (
|
||||
utils_for_test.THIS_DIR.parent / "release" / "update-manpages"
|
||||
)
|
||||
|
||||
|
||||
def test_wrapper_does_not_run_main_for_multiprocessing_child(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Do not rerun main when multiprocessing re-executes the wrapper."""
|
||||
fake_update_manpages = ModuleType("update_manpages")
|
||||
|
||||
# Mock this because the real module requires newer Python versions than
|
||||
# our unittest framework does.
|
||||
monkeypatch.setitem(sys.modules, "update_manpages", fake_update_manpages)
|
||||
|
||||
runpy.run_path(
|
||||
str(UPDATE_MANPAGES_SCRIPT),
|
||||
run_name="__mp_main__",
|
||||
)
|
||||
@@ -17,7 +17,9 @@
|
||||
import contextlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
import manifest_xml
|
||||
@@ -105,3 +107,57 @@ def test_forall_all_projects_called_once(tmp_path: Path) -> None:
|
||||
line_count = sum(1 for x in output.splitlines() if x)
|
||||
# Verify that we didn't get more lines than expected.
|
||||
assert line_count == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("regex_option", "inverse"),
|
||||
[
|
||||
("-r", False),
|
||||
("-i", True),
|
||||
],
|
||||
ids=("regex", "inverse-regex"),
|
||||
)
|
||||
def test_forall_regex_modes_pass_groups_to_find_projects(
|
||||
tmp_path: Path,
|
||||
regex_option: str,
|
||||
inverse: bool,
|
||||
) -> None:
|
||||
"""Pass --groups through in regex modes."""
|
||||
manifest = _create_manifest_with_8_projects(tmp_path)
|
||||
|
||||
cmd = subcmds.forall.Forall()
|
||||
cmd.manifest = manifest
|
||||
|
||||
opts, args = cmd.OptionParser.parse_args(
|
||||
[
|
||||
regex_option,
|
||||
"--groups",
|
||||
"special",
|
||||
"project",
|
||||
"-c",
|
||||
"true",
|
||||
]
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
cmd,
|
||||
"FindProjects",
|
||||
return_value=[],
|
||||
) as find_projects, mock.patch.object(
|
||||
cmd,
|
||||
"ExecuteInParallel",
|
||||
return_value=0,
|
||||
):
|
||||
cmd.Execute(opts, args)
|
||||
|
||||
expected_kwargs = {
|
||||
"groups": "special",
|
||||
"all_manifests": True,
|
||||
}
|
||||
if inverse:
|
||||
expected_kwargs["inverse"] = True
|
||||
|
||||
find_projects.assert_called_once_with(
|
||||
["project"],
|
||||
**expected_kwargs,
|
||||
)
|
||||
|
||||
+116
-2
@@ -208,7 +208,7 @@ def test_get_project_data_uses_head_revision() -> None:
|
||||
project.name = "foo"
|
||||
project.worktree = "/path/to/foo"
|
||||
project.revisionExpr = "refs/heads/main"
|
||||
project.GetBranches.return_value = []
|
||||
project.GetBranches.return_value = {}
|
||||
|
||||
# GetHeadRevisionId() returns a SHA, it should be used.
|
||||
project.GetHeadRevisionId.return_value = "head_sha_12345"
|
||||
@@ -235,7 +235,9 @@ def test_json_with_projects(capsys) -> None:
|
||||
project.name = "foo"
|
||||
project.worktree = "/path/to/foo"
|
||||
project.revisionExpr = "refs/heads/main"
|
||||
project.GetBranches.return_value = {"branch1": mock.MagicMock()}
|
||||
branch = mock.MagicMock()
|
||||
branch.current = True
|
||||
project.GetBranches.return_value = {"branch1": branch}
|
||||
project.GetHeadRevisionId.return_value = "head_sha_12345"
|
||||
project.CurrentBranch = "branch1"
|
||||
|
||||
@@ -253,3 +255,115 @@ def test_json_with_projects(capsys) -> None:
|
||||
assert project_data["manifest_revision"] == "refs/heads/main"
|
||||
assert project_data["local_branches"] == ["branch1"]
|
||||
assert project_data["current_branch"] == "branch1"
|
||||
|
||||
|
||||
def test_diff_commits_uses_one_left_right_walk() -> None:
|
||||
"""Local and remote commits are partitioned from one rev-list."""
|
||||
project = mock.MagicMock()
|
||||
project.work_git.rev_list.return_value = [
|
||||
"<11111111 local commit",
|
||||
">22222222 remote commit",
|
||||
]
|
||||
|
||||
local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main")
|
||||
|
||||
assert local == ["11111111 local commit"]
|
||||
assert remote == ["22222222 remote commit"]
|
||||
project.work_git.rev_list.assert_called_once_with(
|
||||
"--left-right",
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
"HEAD...refs/remotes/m/main",
|
||||
"--",
|
||||
)
|
||||
|
||||
|
||||
def test_diff_commits_falls_back_to_bare_git_when_no_worktree() -> None:
|
||||
"""Bare or worktree-less projects fall back to bare_git for history walk."""
|
||||
project = mock.MagicMock()
|
||||
project.work_git = None
|
||||
project.bare_git.rev_list.return_value = [
|
||||
"<11111111 local commit",
|
||||
">22222222 remote commit",
|
||||
]
|
||||
|
||||
local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main")
|
||||
|
||||
assert local == ["11111111 local commit"]
|
||||
assert remote == ["22222222 remote commit"]
|
||||
project.bare_git.rev_list.assert_called_once_with(
|
||||
"--left-right",
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
"HEAD...refs/remotes/m/main",
|
||||
"--",
|
||||
)
|
||||
|
||||
|
||||
def test_diff_commits_empty_output() -> None:
|
||||
"""Empty rev-list output produces empty local and remote commit lists."""
|
||||
project = mock.MagicMock()
|
||||
project.work_git.rev_list.return_value = []
|
||||
|
||||
local, remote = info.Info._GetDiffCommits(project, "refs/remotes/m/main")
|
||||
|
||||
assert local == []
|
||||
assert remote == []
|
||||
|
||||
|
||||
def test_get_current_branch() -> None:
|
||||
"""_GetCurrentBranch identifies the branch with current=True."""
|
||||
b1 = mock.MagicMock(current=False)
|
||||
b2 = mock.MagicMock(current=True)
|
||||
assert info.Info._GetCurrentBranch({"b1": b1, "b2": b2}) == "b2"
|
||||
assert info.Info._GetCurrentBranch({"b1": b1}) is None
|
||||
assert info.Info._GetCurrentBranch({}) is None
|
||||
|
||||
|
||||
def test_overview_helper_current_branch_filters_before_uploadable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""_OverviewHelper only checks uploadable state for the current branch."""
|
||||
project = mock.MagicMock()
|
||||
project.RelPath.return_value = "proj"
|
||||
b1 = mock.MagicMock(current=False)
|
||||
b2 = mock.MagicMock(current=True)
|
||||
project.GetBranches.return_value = {"b1": b1, "b2": b2}
|
||||
uploadable = mock.MagicMock(commits=["c1"], date="2026-09-21")
|
||||
uploadable.name = "b2"
|
||||
project.GetUploadableBranch.return_value = uploadable
|
||||
monkeypatch.setattr(
|
||||
info.Info,
|
||||
"get_parallel_context",
|
||||
lambda: {"projects": [project]},
|
||||
)
|
||||
opt = mock.MagicMock(current_branch=True, this_manifest_only=False)
|
||||
|
||||
result = info.Info._OverviewHelper(0, opt)
|
||||
|
||||
project.GetUploadableBranch.assert_called_once_with("b2")
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "b2"
|
||||
assert result[0].is_current is True
|
||||
|
||||
|
||||
def test_overview_helper_current_branch_detached_head_skips_uploadable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""_OverviewHelper skips GetUploadableBranch when detached with -b."""
|
||||
project = mock.MagicMock()
|
||||
b1 = mock.MagicMock(current=False)
|
||||
project.GetBranches.return_value = {"b1": b1}
|
||||
monkeypatch.setattr(
|
||||
info.Info,
|
||||
"get_parallel_context",
|
||||
lambda: {"projects": [project]},
|
||||
)
|
||||
opt = mock.MagicMock(current_branch=True, this_manifest_only=False)
|
||||
|
||||
result = info.Info._OverviewHelper(0, opt)
|
||||
|
||||
project.GetUploadableBranch.assert_not_called()
|
||||
assert result == []
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unittests for the list subcmd."""
|
||||
|
||||
from typing import List, Optional
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import subcmds
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extra_args", "expected_groups", "expected_missing_ok"),
|
||||
[
|
||||
(["--groups", "special"], "special", None),
|
||||
(["--all"], None, True),
|
||||
],
|
||||
ids=("groups", "all"),
|
||||
)
|
||||
def test_list_regex_passes_groups_and_all(
|
||||
extra_args: List[str],
|
||||
expected_groups: Optional[str],
|
||||
expected_missing_ok: Optional[bool],
|
||||
) -> None:
|
||||
"""Pass --groups and --all through in regex mode."""
|
||||
cmd = subcmds.list.List()
|
||||
|
||||
opts, args = cmd.OptionParser.parse_args(
|
||||
["--regex", *extra_args, "project"]
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
cmd,
|
||||
"FindProjects",
|
||||
return_value=[],
|
||||
) as find_projects:
|
||||
cmd.Execute(opts, args)
|
||||
|
||||
find_projects.assert_called_once_with(
|
||||
["project"],
|
||||
groups=expected_groups,
|
||||
missing_ok=expected_missing_ok,
|
||||
all_manifests=True,
|
||||
)
|
||||
@@ -185,6 +185,96 @@ def test_status_without_orphans(
|
||||
assert lines[1] == " -m\tREADME"
|
||||
|
||||
|
||||
def test_status_staged_and_unstaged_same_path(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""A path changed on both sides of the index renders both states."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
project_worktree = topdir / project_path
|
||||
readme = project_worktree / "README"
|
||||
readme.write_text("staged")
|
||||
subprocess.check_call(["git", "add", "README"], cwd=project_worktree)
|
||||
readme.write_text("unstaged")
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert lines[1] == " Mm\tREADME"
|
||||
|
||||
|
||||
def test_status_forces_staged_rename_detection(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""The snapshot preserves rename scores despite user status config."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
project_worktree = topdir / project_path
|
||||
subprocess.check_call(
|
||||
["git", "config", "status.renames", "false"], cwd=project_worktree
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "mv", "README", "RENAMED"], cwd=project_worktree
|
||||
)
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert lines[1] == " R-\tREADME => RENAMED (100%)"
|
||||
|
||||
|
||||
def test_detached_clean_status_is_suppressed(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""A clean detached checkout keeps returning CLEAN without output."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
subprocess.check_call(
|
||||
["git", "checkout", "-q", "--detach", "HEAD"],
|
||||
cwd=topdir / project_path,
|
||||
)
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
assert _status_lines(stdout.getvalue()) == [
|
||||
"nothing to commit (working directory clean)"
|
||||
]
|
||||
|
||||
|
||||
def test_status_unmerged_path_matches_legacy_display(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Porcelain-v2 unmerged records render as index U and worktree u."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
worktree = topdir / project_path
|
||||
subprocess.check_call(
|
||||
["git", "checkout", "-q", "-b", "other"], cwd=worktree
|
||||
)
|
||||
(worktree / "README").write_text("other")
|
||||
subprocess.check_call(["git", "commit", "-qam", "other"], cwd=worktree)
|
||||
subprocess.check_call(["git", "checkout", "-q", "main"], cwd=worktree)
|
||||
(worktree / "README").write_text("main")
|
||||
subprocess.check_call(["git", "commit", "-qam", "main"], cwd=worktree)
|
||||
merge = subprocess.run(
|
||||
["git", "merge", "other"],
|
||||
cwd=worktree,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
assert merge.returncode != 0
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert lines[1] == " Uu\tREADME"
|
||||
|
||||
|
||||
def test_status_with_orphans_and_modified_file(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
@@ -304,6 +394,10 @@ def test_status_branch_ahead_of_upstream(
|
||||
project_worktree = topdir / project_path
|
||||
|
||||
_setup_remote_tracking_branch(manifest, "feature")
|
||||
subprocess.check_call(
|
||||
["git", "config", "status.aheadBehind", "false"],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "commit", "-q", "--allow-empty", "-m", "c1"],
|
||||
cwd=project_worktree,
|
||||
@@ -451,3 +545,20 @@ def test_status_branch_synced_no_ahead_behind(
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 1
|
||||
_assert_project_header(lines[0], project_path, "synced")
|
||||
|
||||
|
||||
def test_status_non_utf8_path(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Non-UTF-8 pathnames render without crashing."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
project_worktree = topdir / project_path
|
||||
bad_path = project_worktree / os.fsdecode(b"bad-\xff-name")
|
||||
bad_path.write_bytes(b"content")
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert any("bad-" in line for line in lines)
|
||||
|
||||
+161
-1
@@ -13,6 +13,7 @@
|
||||
# limitations under the License.
|
||||
"""Unittests for the subcmds/sync.py module."""
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import optparse
|
||||
import os
|
||||
@@ -20,7 +21,7 @@ from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
@@ -29,6 +30,7 @@ import pytest
|
||||
import command
|
||||
from error import GitError
|
||||
from error import RepoExitError
|
||||
import git_status
|
||||
import manifest_xml
|
||||
from project import SyncNetworkHalfResult
|
||||
from subcmds import sync
|
||||
@@ -214,6 +216,31 @@ def test_sync_update_projects_revision_id_respects_groups(tmp_path: Path):
|
||||
assert kwargs.get("groups") == "group1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"generate_manpages, expected_default",
|
||||
[
|
||||
(False, "7; based on number of CPU cores"),
|
||||
(True, "based on number of CPU cores"),
|
||||
],
|
||||
ids=("interactive", "manpages"),
|
||||
)
|
||||
def test_jobs_checkout_help_default(
|
||||
generate_manpages: bool,
|
||||
expected_default: str,
|
||||
) -> None:
|
||||
"""Test checkout-jobs default help in interactive and manpage modes."""
|
||||
with mock.patch.object(sync, "DEFAULT_LOCAL_JOBS", 7), mock.patch.object(
|
||||
command,
|
||||
"GENERATE_MANPAGES",
|
||||
generate_manpages,
|
||||
):
|
||||
help_text = " ".join(sync.Sync().OptionParser.format_help().split())
|
||||
|
||||
assert f"defaults to --jobs or {expected_default}" in help_text
|
||||
if generate_manpages:
|
||||
assert "defaults to --jobs or 7" not in help_text
|
||||
|
||||
|
||||
# Used to patch os.cpu_count() for reliable results.
|
||||
OS_CPU_COUNT = 24
|
||||
|
||||
@@ -516,6 +543,7 @@ class FakeProject:
|
||||
|
||||
self.use_git_worktrees = False
|
||||
self.UseAlternates = False
|
||||
self.UseReprojectCmd = False
|
||||
self.manifest = mock.MagicMock()
|
||||
self.manifest.GetProjectsWithName.return_value = [self]
|
||||
self.config = mock.MagicMock()
|
||||
@@ -642,6 +670,27 @@ class SafeCheckoutOrder(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class NestedProjects(unittest.TestCase):
|
||||
def test_flat_manifest(self) -> None:
|
||||
p_foo = FakeProject("foo")
|
||||
p_foo_bar = FakeProject("foo-bar")
|
||||
self.assertEqual(sync._NestedProjects([p_foo, p_foo_bar]), [])
|
||||
|
||||
def test_nested_paths(self) -> None:
|
||||
p_foo = FakeProject("foo")
|
||||
p_foo_bar = FakeProject("foo/bar")
|
||||
p_foo_bar_baz = FakeProject("foo/bar/baz")
|
||||
self.assertEqual(
|
||||
sync._NestedProjects([p_foo_bar_baz, p_foo, p_foo_bar]),
|
||||
[p_foo_bar, p_foo_bar_baz],
|
||||
)
|
||||
|
||||
def test_submodule_of_a_parent(self) -> None:
|
||||
parent = FakeProject("foo")
|
||||
sub = FakeProject("foo/sub", parent=parent, is_derived=True)
|
||||
self.assertEqual(sync._NestedProjects([parent, sub]), [sub])
|
||||
|
||||
|
||||
class ParentFirstBatches(unittest.TestCase):
|
||||
def test_no_submodules(self) -> None:
|
||||
p_a = FakeProject("a")
|
||||
@@ -941,6 +990,42 @@ class CheckForBloatedProjects(unittest.TestCase):
|
||||
self.cmd.git_event_log = mock.MagicMock()
|
||||
self.cmd._bloated_projects = []
|
||||
|
||||
def test_one_project_reuses_status_head_oid(self) -> None:
|
||||
"""The bloat scan gets dirty state and HEAD from one snapshot."""
|
||||
status = git_status.StatusSnapshot()
|
||||
status.branch_oid = "local"
|
||||
self.project._GetStatusSnapshot.return_value = status
|
||||
self.project.GetRevisionId.return_value = "manifest"
|
||||
self.project.bare_git.count_objects.return_value = (
|
||||
"packs: 0\nsize-pack: 0\nsize-garbage: 0\n"
|
||||
)
|
||||
with mock.patch.object(
|
||||
sync.Sync,
|
||||
"get_parallel_context",
|
||||
return_value={"projects": [self.project]},
|
||||
):
|
||||
self.assertIsNone(self.cmd._CheckOneBloatedProject(0))
|
||||
|
||||
self.project.IsDirty.assert_not_called()
|
||||
self.project.work_git.rev_parse.assert_not_called()
|
||||
self.project.bare_git.count_objects.assert_called_once_with("-v")
|
||||
|
||||
def test_one_unborn_project_skips_bloat_check(self) -> None:
|
||||
"""A porcelain initial branch behaves like failed rev-parse HEAD."""
|
||||
status = git_status.StatusSnapshot()
|
||||
status.index_changes["staged"] = git_status.StatusEntry("staged", "M")
|
||||
self.project._GetStatusSnapshot.return_value = status
|
||||
|
||||
with mock.patch.object(
|
||||
sync.Sync,
|
||||
"get_parallel_context",
|
||||
return_value={"projects": [self.project]},
|
||||
):
|
||||
self.assertIsNone(self.cmd._CheckOneBloatedProject(0))
|
||||
|
||||
self.project.GetRevisionId.assert_not_called()
|
||||
self.project.bare_git.count_objects.assert_not_called()
|
||||
|
||||
@mock.patch("subcmds.sync.git_require")
|
||||
def test_git_version_unsupported(self, mock_git_require):
|
||||
"""Test that it returns early if git version is unsupported."""
|
||||
@@ -1083,7 +1168,10 @@ class SyncCommand(unittest.TestCase):
|
||||
self.project = p = mock.MagicMock(
|
||||
use_git_worktrees=False,
|
||||
UseAlternates=False,
|
||||
UseReprojectCmd=False,
|
||||
name="project",
|
||||
relpath="rel_path",
|
||||
parent=None,
|
||||
Sync_NetworkHalf=Sync_NetworkHalf,
|
||||
Sync_LocalHalf=Sync_LocalHalf,
|
||||
RelPath=mock.Mock(return_value="rel_path"),
|
||||
@@ -1142,6 +1230,78 @@ class SyncCommand(unittest.TestCase):
|
||||
_, kwargs = self.cmd.GetProjects.call_args
|
||||
self.assertEqual(kwargs.get("groups"), "my_group")
|
||||
|
||||
def _ExecuteUntilSync(
|
||||
self, args: List[str]
|
||||
) -> Tuple[mock.MagicMock, mock.MagicMock]:
|
||||
"""Run Execute up to the sync itself, returning the sync mocks."""
|
||||
self.opt.mp_update = False
|
||||
with contextlib.ExitStack() as stack:
|
||||
for name in (
|
||||
"_UpdateRepoProject",
|
||||
"_UpdateProjectsRevisionId",
|
||||
"_ValidateOptionsWithManifest",
|
||||
"_RunPostSyncHook",
|
||||
):
|
||||
stack.enter_context(mock.patch.object(self.cmd, name))
|
||||
phased = stack.enter_context(
|
||||
mock.patch.object(self.cmd, "_SyncPhased")
|
||||
)
|
||||
interleaved = stack.enter_context(
|
||||
mock.patch.object(self.cmd, "_SyncInterleaved")
|
||||
)
|
||||
self.cmd.Execute(self.opt, args)
|
||||
return phased, interleaved
|
||||
|
||||
def test_reproject_cmd_allows_a_flat_manifest(self) -> None:
|
||||
"""Ensure repo.reprojectcmd syncs a manifest without nesting."""
|
||||
self.project.UseReprojectCmd = True
|
||||
phased, interleaved = self._ExecuteUntilSync([])
|
||||
self.assertTrue(phased.called or interleaved.called)
|
||||
|
||||
def test_reproject_cmd_rejects_nested_projects(self) -> None:
|
||||
"""Ensure repo.reprojectcmd fails a manifest with nested projects."""
|
||||
p_foo = FakeProject("foo")
|
||||
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:
|
||||
self._ExecuteUntilSync([])
|
||||
self.assertIn("foo/bar", str(e.exception))
|
||||
self.assertNotIn(" - foo\n", str(e.exception))
|
||||
|
||||
def test_reproject_cmd_rejects_a_submodule(self) -> None:
|
||||
"""Ensure repo.reprojectcmd fails a manifest with a submodule."""
|
||||
p_foo = FakeProject("foo")
|
||||
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:
|
||||
self._ExecuteUntilSync([])
|
||||
self.assertIn("foo/sub", str(e.exception))
|
||||
|
||||
def test_reproject_cmd_checks_the_whole_manifest(self) -> None:
|
||||
"""Ensure nesting is checked beyond the projects given as args."""
|
||||
p_foo = FakeProject("foo")
|
||||
p_foo_bar = FakeProject("foo/bar")
|
||||
p_foo.UseReprojectCmd = p_foo_bar.UseReprojectCmd = True
|
||||
self.cmd.GetProjects.side_effect = lambda args, **kwargs: (
|
||||
[p_foo_bar] if args else [p_foo, p_foo_bar]
|
||||
)
|
||||
with self.assertRaises(sync.SyncError):
|
||||
self._ExecuteUntilSync(["foo/bar"])
|
||||
self.assertEqual(self.cmd.GetProjects.call_count, 2)
|
||||
_, kwargs = self.cmd.GetProjects.call_args
|
||||
self.assertEqual(kwargs.get("missing_ok"), True)
|
||||
|
||||
def test_reproject_cmd_off_ignores_nested_projects(self) -> None:
|
||||
"""Ensure nesting is only checked with repo.reprojectcmd in use."""
|
||||
projects = [FakeProject("foo"), FakeProject("foo/bar")]
|
||||
for p in projects:
|
||||
p.Exists = False
|
||||
self.cmd.GetProjects.return_value = projects
|
||||
phased, interleaved = self._ExecuteUntilSync([])
|
||||
self.assertTrue(phased.called or interleaved.called)
|
||||
|
||||
|
||||
class SyncUpdateRepoProject(unittest.TestCase):
|
||||
"""Tests for Sync._UpdateRepoProject."""
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
"""Unittests for the subcmds/upload.py module."""
|
||||
|
||||
from typing import List, Optional
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -117,3 +118,118 @@ def test_GatherOne_returns_resolved_current_branch(
|
||||
assert upload.Upload._GatherOne(opt, 0) == (0, [branch], "topic")
|
||||
|
||||
project.GetUploadableBranch.assert_called_once_with("topic")
|
||||
|
||||
|
||||
def _create_mock_branch(
|
||||
name: str = "main",
|
||||
commits: Optional[List[str]] = None,
|
||||
project_relpath: str = "project-a",
|
||||
) -> mock.MagicMock:
|
||||
"""Helper to construct a mock ReviewableBranch."""
|
||||
branch = mock.MagicMock()
|
||||
branch.name = name
|
||||
branch.commits = commits if commits is not None else ["commit1"]
|
||||
|
||||
project = mock.MagicMock()
|
||||
project.RelPath.return_value = project_relpath
|
||||
branch.project = project
|
||||
return branch
|
||||
|
||||
|
||||
def test_MultipleBranches_yes_with_current_branch_flag_bypasses_editor(
|
||||
cmd: upload.Upload,
|
||||
) -> None:
|
||||
"""_MultipleBranches with --yes and -c flag bypasses editor."""
|
||||
opt, _ = cmd.OptionParser.parse_args(["-c", "-y"])
|
||||
branch1 = _create_mock_branch("b1", project_relpath="p1")
|
||||
branch2 = _create_mock_branch("b2", project_relpath="p2")
|
||||
pending = [(branch1.project, [branch1]), (branch2.project, [branch2])]
|
||||
|
||||
with mock.patch.object(cmd, "_UploadAndReport") as mock_upload, mock.patch(
|
||||
"editor.Editor.EditString"
|
||||
) as mock_edit:
|
||||
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
|
||||
mock_edit.assert_not_called()
|
||||
mock_upload.assert_called_once_with(
|
||||
opt, [branch1, branch2], _STUB_PEOPLE
|
||||
)
|
||||
|
||||
|
||||
def test_MultipleBranches_yes_with_cbr_flag_bypasses_editor(
|
||||
cmd: upload.Upload,
|
||||
) -> None:
|
||||
"""_MultipleBranches with --yes and --cbr flag bypasses editor."""
|
||||
opt, _ = cmd.OptionParser.parse_args(["--cbr", "--yes"])
|
||||
branch1 = _create_mock_branch("b1", project_relpath="p1")
|
||||
branch2 = _create_mock_branch("b2", project_relpath="p2")
|
||||
pending = [(branch1.project, [branch1]), (branch2.project, [branch2])]
|
||||
|
||||
with mock.patch.object(cmd, "_UploadAndReport") as mock_upload, mock.patch(
|
||||
"editor.Editor.EditString"
|
||||
) as mock_edit:
|
||||
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
|
||||
mock_edit.assert_not_called()
|
||||
mock_upload.assert_called_once_with(
|
||||
opt, [branch1, branch2], _STUB_PEOPLE
|
||||
)
|
||||
|
||||
|
||||
def test_MultipleBranches_yes_with_branch_flag_bypasses_editor(
|
||||
cmd: upload.Upload,
|
||||
) -> None:
|
||||
"""_MultipleBranches with --yes and --br flag bypasses editor."""
|
||||
opt, _ = cmd.OptionParser.parse_args(["--br", "feature", "-y"])
|
||||
branch1 = _create_mock_branch("feature", project_relpath="p1")
|
||||
branch2 = _create_mock_branch("feature", project_relpath="p2")
|
||||
pending = [(branch1.project, [branch1]), (branch2.project, [branch2])]
|
||||
|
||||
with mock.patch.object(cmd, "_UploadAndReport") as mock_upload, mock.patch(
|
||||
"editor.Editor.EditString"
|
||||
) as mock_edit:
|
||||
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
|
||||
mock_edit.assert_not_called()
|
||||
mock_upload.assert_called_once_with(
|
||||
opt, [branch1, branch2], _STUB_PEOPLE
|
||||
)
|
||||
|
||||
|
||||
def test_MultipleBranches_yes_with_branch_flag_empty_pending_dies(
|
||||
cmd: upload.Upload,
|
||||
) -> None:
|
||||
"""_MultipleBranches with --yes and empty pending branches dies."""
|
||||
opt, _ = cmd.OptionParser.parse_args(["--br", "feature", "-y"])
|
||||
mock_project = mock.MagicMock()
|
||||
pending = [(mock_project, [])]
|
||||
|
||||
with pytest.raises(
|
||||
upload.UploadExitError, match="nothing ready for upload"
|
||||
):
|
||||
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
|
||||
|
||||
|
||||
def test_MultipleBranches_yes_without_branch_or_cbr_uses_editor(
|
||||
cmd: upload.Upload,
|
||||
) -> None:
|
||||
"""_MultipleBranches with -y but no -c/--br falls back to editor."""
|
||||
opt, _ = cmd.OptionParser.parse_args(["-y"])
|
||||
branch1 = _create_mock_branch("b1", project_relpath="p1")
|
||||
branch1.date = "2026-08-26"
|
||||
mock_remote = mock.MagicMock()
|
||||
branch1.project.dest_branch = None
|
||||
branch1.project.revisionExpr = "refs/heads/main"
|
||||
branch_config = mock.MagicMock()
|
||||
branch_config.remote = mock_remote
|
||||
branch1.project.GetBranch.return_value = branch_config
|
||||
pending = [(branch1.project, [branch1])]
|
||||
|
||||
edited_script = (
|
||||
"project p1/:\n"
|
||||
" branch b1 ( 1 commit, 2026-08-26) to remote branch "
|
||||
"refs/heads/main:\n"
|
||||
)
|
||||
with mock.patch.object(cmd, "_UploadAndReport") as mock_upload, mock.patch(
|
||||
"editor.Editor.EditString", return_value=edited_script
|
||||
) as mock_edit:
|
||||
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
|
||||
mock_edit.assert_called_once()
|
||||
mock_upload.assert_called_once_with(opt, [branch1], _STUB_PEOPLE)
|
||||
|
||||
Reference in New Issue
Block a user