mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-07-16 21:57:00 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d54384a5e | |||
| 1b4e7a04be | |||
| a94c9e2b52 | |||
| 51021fb209 | |||
| d453273f06 | |||
| 0129ac1c46 | |||
| bf8c59f4a0 | |||
| 003f0407fc | |||
| 1db50e49fb | |||
| c97a306fe7 | |||
| 5534f164d6 | |||
| 67e52a120b | |||
| d5037230e9 | |||
| 7cc99b24c1 | |||
| 12ad396f67 | |||
| a6bc1b7cf0 | |||
| a98e422113 | |||
| 5d8585012f | |||
| e8deeabf2a | |||
| 11428ae984 | |||
| 27d2232eb3 | |||
| e3eadd3728 | |||
| 12cfc6036a | |||
| 134eeb024b | |||
| 03fb18109f | |||
| 5af71ce907 |
@@ -6,6 +6,7 @@ __pycache__
|
||||
/dist
|
||||
.repopickle_*
|
||||
/repoc
|
||||
/.cipd_bin
|
||||
/.tox
|
||||
/.venv
|
||||
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ However there are some differences, so please review and familiarize
|
||||
yourself with the following relevant bits.
|
||||
|
||||
|
||||
## Make separate commits for logically separate changes.
|
||||
## Make separate commits for logically separate changes
|
||||
|
||||
Unless your patch is really trivial, you should not be sending out a patch that
|
||||
was generated between your working tree and your commit head.
|
||||
@@ -118,7 +118,7 @@ your patch. It is virtually impossible to remove a patch once it
|
||||
has been applied and pushed out.
|
||||
|
||||
|
||||
## Sending your patches.
|
||||
## Sending your patches
|
||||
|
||||
Do not email your patches to anyone.
|
||||
|
||||
|
||||
@@ -49,6 +49,43 @@ $ curl https://storage.googleapis.com/git-repo-downloads/repo > ~/.bin/repo
|
||||
$ chmod a+rx ~/.bin/repo
|
||||
```
|
||||
|
||||
## Shell Completion
|
||||
|
||||
Repo includes completion scripts for Bash and Zsh.
|
||||
|
||||
### Bash
|
||||
|
||||
To enable completion in Bash, source `completion.bash` in your `~/.bashrc`:
|
||||
|
||||
```sh
|
||||
source /path/to/git-repo/completion.bash
|
||||
```
|
||||
|
||||
### Zsh
|
||||
|
||||
To enable completion in Zsh, you can either:
|
||||
|
||||
1. Copy or symlink `completion.zsh` to a file named `_repo` in a directory in your `$fpath`:
|
||||
```sh
|
||||
mkdir -p ~/.zsh/completion
|
||||
# You can copy the file:
|
||||
cp /path/to/git-repo/completion.zsh ~/.zsh/completion/_repo
|
||||
# Or symlink it:
|
||||
ln -s /path/to/git-repo/completion.zsh ~/.zsh/completion/_repo
|
||||
```
|
||||
Then add that directory to your `fpath` in `~/.zshrc` before `compinit`:
|
||||
```zsh
|
||||
fpath=(~/.zsh/completion $fpath)
|
||||
autoload -Uz compinit
|
||||
compinit
|
||||
```
|
||||
|
||||
2. Or source the file directly and call `compdef` in your `~/.zshrc`:
|
||||
```zsh
|
||||
source /path/to/git-repo/completion.zsh
|
||||
compdef _repo repo
|
||||
```
|
||||
|
||||
|
||||
[new-bug]: https://issues.gerritcodereview.com/issues/new?component=1370071
|
||||
[issue tracker]: https://issues.gerritcodereview.com/issues?q=is:open%20componentid:1370071
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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.
|
||||
|
||||
# This file contains version pins of a few tools used by run_tests.
|
||||
|
||||
# Pin resolved versions in the repo, to reduce trust in the CIPD backend.
|
||||
#
|
||||
# Most of these tools are generated via builders at
|
||||
# https://ci.chromium.org/p/infra/g/infra/console
|
||||
#
|
||||
# For these, the git revision is the one of
|
||||
# https://chromium.googlesource.com/infra/infra.git.
|
||||
#
|
||||
# To regenerate them (after modifying this file):
|
||||
# cipd ensure-file-resolve -ensure-file cipd_manifest.txt
|
||||
$ResolvedVersions cipd_manifest.versions
|
||||
|
||||
# Supported platforms. Feel free to add more as long as the tools work.
|
||||
$VerifiedPlatform linux-amd64 linux-arm64 mac-amd64 mac-arm64
|
||||
|
||||
infra/3pp/tools/help2man/${platform} version:3@1.49.3
|
||||
@@ -0,0 +1,18 @@
|
||||
# This file is auto-generated by 'cipd ensure-file-resolve'.
|
||||
# Do not modify manually. All changes will be overwritten.
|
||||
|
||||
infra/3pp/tools/help2man/linux-amd64
|
||||
version:3@1.49.3
|
||||
82ySLC4--mvjnmuwj_j_2odHLj5zFaNFpWvfCyMunY0C
|
||||
|
||||
infra/3pp/tools/help2man/linux-arm64
|
||||
version:3@1.49.3
|
||||
szfWfjI4RyCT6gIB48CeUOGG6_Un4wfbIb0Vvs5M9MUC
|
||||
|
||||
infra/3pp/tools/help2man/mac-amd64
|
||||
version:3@1.49.3
|
||||
t7iyQx5TnGbuJfj6zMTvjPdBl-Cb_w5papUh80NI2S8C
|
||||
|
||||
infra/3pp/tools/help2man/mac-arm64
|
||||
version:3@1.49.3
|
||||
t5eD-eLKRpnSifZdbi072U8bnPfGye1pIMmQ07nxJ7kC
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import pager
|
||||
|
||||
@@ -84,7 +85,7 @@ def _Color(fg=None, bg=None, attr=None):
|
||||
DEFAULT = None
|
||||
|
||||
|
||||
def SetDefaultColoring(state):
|
||||
def SetDefaultColoring(state: Optional[str]) -> None:
|
||||
"""Set coloring behavior to |state|.
|
||||
|
||||
This is useful for overriding config options via the command line.
|
||||
@@ -97,9 +98,9 @@ def SetDefaultColoring(state):
|
||||
state = state.lower()
|
||||
if state in ("auto",):
|
||||
DEFAULT = state
|
||||
elif state in ("always", "yes", "true", True):
|
||||
elif state in ("always", "yes", "true"):
|
||||
DEFAULT = "always"
|
||||
elif state in ("never", "no", "false", False):
|
||||
elif state in ("never", "no", "false"):
|
||||
DEFAULT = "never"
|
||||
|
||||
|
||||
|
||||
+23
-1
@@ -101,6 +101,11 @@ class Command:
|
||||
def WantPager(self, _opt):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_multiprocessing_active() -> bool:
|
||||
"""Whether the current process is a worker in a pool."""
|
||||
return multiprocessing.current_process().name != "MainProcess"
|
||||
|
||||
def ReadEnvironmentOptions(self, opts):
|
||||
"""Set options from environment variables."""
|
||||
|
||||
@@ -407,7 +412,8 @@ class Command:
|
||||
for project in all_projects_list:
|
||||
if submodules_ok or project.sync_s:
|
||||
derived_projects.update(
|
||||
(p.name, p) for p in project.GetDerivedSubprojects()
|
||||
(p.RelPath(local=False), p)
|
||||
for p in project.GetDerivedSubprojects()
|
||||
)
|
||||
all_projects_list.extend(derived_projects.values())
|
||||
for project in all_projects_list:
|
||||
@@ -511,6 +517,22 @@ class Command:
|
||||
)
|
||||
return result
|
||||
|
||||
def GetSmartSyncOverridePath(self, manifest=None) -> str:
|
||||
"""Return the path where smart sync writes its override manifest."""
|
||||
if manifest is None:
|
||||
manifest = self.manifest
|
||||
return os.path.join(
|
||||
manifest.manifestProject.worktree, "smart_sync_override.xml"
|
||||
)
|
||||
|
||||
def TryOverrideManifestWithSmartSync(self, manifest=None) -> None:
|
||||
"""Override manifest with smart_sync_override.xml if it exists."""
|
||||
if manifest is None:
|
||||
manifest = self.manifest
|
||||
smart_sync_manifest_path = self.GetSmartSyncOverridePath(manifest)
|
||||
if os.path.isfile(smart_sync_manifest_path):
|
||||
manifest.Override(smart_sync_manifest_path)
|
||||
|
||||
def ManifestList(self, opt):
|
||||
"""Yields all of the manifests to traverse.
|
||||
|
||||
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
#compdef _repo repo
|
||||
|
||||
# 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.
|
||||
|
||||
_repo() {
|
||||
local context state line
|
||||
typeset -A opt_args
|
||||
|
||||
local -a subcommands
|
||||
subcommands=(
|
||||
'abandon:Abandon a development branch'
|
||||
'branches:View current topic branches'
|
||||
'checkout:Checkout a branch'
|
||||
'cherry-pick:Cherry-pick a change'
|
||||
'diff:Show changes between commit and working tree'
|
||||
'diffmanifests:Show differences between two manifests'
|
||||
'download:Download and find a change'
|
||||
'forall:Run a shell command in each project'
|
||||
'gc:Garbage collect'
|
||||
'grep:Print lines matching a pattern'
|
||||
'help:Display help about a command'
|
||||
'info:Get info about the manifest'
|
||||
'init:Initialize a repo client'
|
||||
'list:List projects and their names'
|
||||
'manifest:Manifest management'
|
||||
'overview:Display overview of topic branches'
|
||||
'prune:Prune topic branches'
|
||||
'rebase:Rebase local branches'
|
||||
'selfupdate:Update repo'
|
||||
'smartsync:Update working tree to latest known good revision'
|
||||
'stage:Stage file(s) for commit'
|
||||
'start:Start a new branch for development'
|
||||
'status:Show the working tree status'
|
||||
'sync:Update working tree'
|
||||
'upload:Upload changes to the review server'
|
||||
'version:Display version'
|
||||
'wipe:Wipe uncommitted changes'
|
||||
)
|
||||
|
||||
local -a global_opts
|
||||
global_opts=(
|
||||
'(-h --help)'{-h,--help}'[Show help message]'
|
||||
'--help-all[Show help message for all commands]'
|
||||
'(-p --paginate)'{-p,--paginate}'[Pipe all output into less]'
|
||||
'--no-pager[Do not pipe output into a pager]'
|
||||
'--color=[Control color output]:color:(always never auto)'
|
||||
'--trace[Trace git command execution]'
|
||||
'--trace-to-stderr[Trace git command execution to stderr]'
|
||||
'--trace-python[Trace python execution]'
|
||||
'--time[Time execute of command]'
|
||||
'--version[Show version]'
|
||||
'--show-toplevel[Show top level of workspace]'
|
||||
'--event-log=[File to log events to]:file:_files'
|
||||
'--git-trace2-event-log=[File to log git trace2 events to]:file:_files'
|
||||
'--submanifest-path=[Path to submanifest]:path:_files'
|
||||
)
|
||||
|
||||
_arguments -C \
|
||||
${global_opts} \
|
||||
'1: :->cmds' \
|
||||
'*:: :->subcmds'
|
||||
|
||||
case ${state} in
|
||||
cmds)
|
||||
_describe -t commands 'repo command' subcommands
|
||||
;;
|
||||
subcmds)
|
||||
local cmd=${words[1]}
|
||||
curcontext="${curcontext%:*}-${cmd}:"
|
||||
|
||||
local -a common_opts
|
||||
common_opts=(
|
||||
'(-h --help)'{-h,--help}'[Show help message]'
|
||||
'(-v --verbose)'{-v,--verbose}'[Show verbose messages]'
|
||||
'(-q --quiet)'{-q,--quiet}'[Show only errors]'
|
||||
'(-j --jobs)'{-j,--jobs=}'[Number of jobs to run in parallel]:jobs:'
|
||||
'--outer-manifest[Use outer manifest]'
|
||||
'--no-outer-manifest[Do not use outer manifest]'
|
||||
'--this-manifest-only[Only use this manifest]'
|
||||
'--no-this-manifest-only[Do not only use this manifest]'
|
||||
'--all-manifests[Use all manifests]'
|
||||
)
|
||||
|
||||
case ${cmd} in
|
||||
abandon)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--all[Abandon all branches]' \
|
||||
'1: :->branch' \
|
||||
'*: :->project'
|
||||
;;
|
||||
branches)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'*: :->project'
|
||||
;;
|
||||
checkout)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'1: :->branch' \
|
||||
'*: :->project'
|
||||
;;
|
||||
cherry-pick)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'1: :_message "sha1"'
|
||||
;;
|
||||
diff)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-u --absolute)'{-u,--absolute}'[Show absolute paths]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
diffmanifests)
|
||||
_arguments \
|
||||
'--raw[Show raw diff]' \
|
||||
'--no-color[Do not show color]' \
|
||||
'--pretty-format=[Pretty format]:format:' \
|
||||
'1: :_files -g "*.xml"' \
|
||||
'2: :_files -g "*.xml"'
|
||||
;;
|
||||
download)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-b --branch)'{-b,--branch=}'[One or more branches to check]:branch:' \
|
||||
'(-c --cherry-pick)'{-c,--cherry-pick}'[Cherry-pick the change]' \
|
||||
'(-x --record-origin)'{-x,--record-origin}'[Record origin of cherry-pick]' \
|
||||
'(-r --revert)'{-r,--revert}'[Revert the change]' \
|
||||
'(-f --ff-only)'{-f,--ff-only}'[Force fast-forward]' \
|
||||
'*: :_message "change[/patchset]"'
|
||||
;;
|
||||
forall)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-r --regex)'{-r,--regex}'[Execute command only on projects matching regex]' \
|
||||
'(-i --inverse-regex)'{-i,--inverse-regex}'[Execute command only on projects not matching regex]' \
|
||||
'(-g --groups)'{-g,--groups=}'[Execute command only on projects matching groups]:groups:' \
|
||||
'(-c --command)'{-c,--command}'[Command to execute]' \
|
||||
'(-e --abort-on-errors)'{-e,--abort-on-errors}'[Abort on errors]' \
|
||||
'--ignore-missing[Ignore missing projects]' \
|
||||
'--interactive[Run interactively]' \
|
||||
'-p[Show project headers]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
gc)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-n --dry-run)'{-n,--dry-run}'[Dry run]' \
|
||||
'(-y --yes)'{-y,--yes}'[Answer yes to all prompts]' \
|
||||
'--repack[Repack objects]'
|
||||
;;
|
||||
grep)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--cached[Search cached files]' \
|
||||
'(-r --revision)'{-r,--revision=}'[Search in revision]:revision:' \
|
||||
'-e[Pattern]' \
|
||||
'(-i --ignore-case)'{-i,--ignore-case}'[Ignore case]' \
|
||||
'(-a --text)'{-a,--text}'[Treat all files as text]' \
|
||||
'-I[Do not match binary files]' \
|
||||
'(-w --word-regexp)'{-w,--word-regexp}'[Match word boundaries]' \
|
||||
'(-v --invert-match)'{-v,--invert-match}'[Invert match]' \
|
||||
'(-G --basic-regexp)'{-G,--basic-regexp}'[Basic regexp]' \
|
||||
'(-E --extended-regexp)'{-E,--extended-regexp}'[Extended regexp]' \
|
||||
'(-F --fixed-strings)'{-F,--fixed-strings}'[Fixed strings]' \
|
||||
'--all-match[All match]' \
|
||||
'--and[And]' \
|
||||
'--or[Or]' \
|
||||
'--not[Not]' \
|
||||
'-([Open paren]' \
|
||||
'-)[Close paren]' \
|
||||
'-n[Line number]' \
|
||||
'-C[Context]:lines:' \
|
||||
'-B[Before context]:lines:' \
|
||||
'-A[After context]:lines:' \
|
||||
'-l[Name only]' \
|
||||
'--name-only[Name only]' \
|
||||
'--files-with-matches[Files with matches]' \
|
||||
'-L[Files without match]' \
|
||||
'--files-without-match[Files without match]' \
|
||||
'1: :->pattern' \
|
||||
'*: :->project'
|
||||
;;
|
||||
help)
|
||||
_arguments \
|
||||
'(-a --all)'{-a,--all}'[Show all commands]' \
|
||||
'--help-all[Show help message for all commands]' \
|
||||
'1: :->help_cmds'
|
||||
;;
|
||||
info)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-d --diff)'{-d,--diff}'[Show diff]' \
|
||||
'(-o --overview)'{-o,--overview}'[Show overview]' \
|
||||
'(-c --current-branch)'{-c,--current-branch}'[Show current branch]' \
|
||||
'--no-current-branch[Do not show current branch]' \
|
||||
'(-l --local-only)'{-l,--local-only}'[Local only]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
init)
|
||||
_arguments \
|
||||
'(-u --manifest-url)'{-u,--manifest-url=}'[Manifest URL]:url:' \
|
||||
'(-b --manifest-branch)'{-b,--manifest-branch=}'[Manifest branch]:branch:' \
|
||||
'--manifest-upstream-branch=[Manifest upstream branch]:branch:' \
|
||||
'(-m --manifest-name)'{-m,--manifest-name=}'[Manifest name]:file:_files -g "*.xml"' \
|
||||
'(-g --groups)'{-g,--groups=}'[Restrict manifest projects to groups]:groups:' \
|
||||
'(-p --platform)'{-p,--platform=}'[Restrict manifest projects to platform]:platform:' \
|
||||
'--submodules[Sync submodules]' \
|
||||
'--standalone-manifest[Standalone manifest]' \
|
||||
'--manifest-depth=[Manifest depth]:depth:' \
|
||||
'(-c --current-branch)'{-c,--current-branch}'[Sync current branch only]' \
|
||||
'--no-current-branch[Do not sync current branch only]' \
|
||||
'--tags[Sync tags]' \
|
||||
'--no-tags[Do not sync tags]' \
|
||||
'--mirror[Mirror]' \
|
||||
'--archive[Archive]' \
|
||||
'--worktree[Worktree]' \
|
||||
'--reference=[Reference repository]:repository:_files -/' \
|
||||
'--dissociate[Dissociate from reference]' \
|
||||
'--depth=[Depth]:depth:' \
|
||||
'--partial-clone[Partial clone]' \
|
||||
'--no-partial-clone[Do not partial clone]' \
|
||||
'--partial-clone-exclude=[Exclude from partial clone]:projects:' \
|
||||
'--clone-filter=[Clone filter]:filter:' \
|
||||
'--use-superproject[Use superproject]' \
|
||||
'--no-use-superproject[Do not use superproject]' \
|
||||
'--clone-bundle[Use clone bundle]' \
|
||||
'--no-clone-bundle[Do not use clone bundle]' \
|
||||
'--git-lfs[Use git lfs]' \
|
||||
'--no-git-lfs[Do not use git lfs]' \
|
||||
'--repo-url=[Repo URL]:url:' \
|
||||
'--repo-rev=[Repo revision]:revision:' \
|
||||
'--no-repo-verify[Do not verify repo]' \
|
||||
'--config-name[Use config name]'
|
||||
;;
|
||||
list)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-r --regex)'{-r,--regex}'[Filter by regex]' \
|
||||
'(-g --groups)'{-g,--groups=}'[Filter by groups]:groups:' \
|
||||
'(-a --all)'{-a,--all}'[Show all projects]' \
|
||||
'(-n --name-only)'{-n,--name-only}'[Show only names]' \
|
||||
'(-p --path-only)'{-p,--path-only}'[Show only paths]' \
|
||||
'(-f --fullpath)'{-f,--fullpath}'[Show full paths]' \
|
||||
'--relative-to=[Show paths relative to]:path:_files -/' \
|
||||
'*: :->project'
|
||||
;;
|
||||
manifest)
|
||||
_arguments \
|
||||
'(-r --revision-as-HEAD)'{-r,--revision-as-HEAD}'[Save revisions as current HEAD]' \
|
||||
'(-m --manifest-name)'{-m,--manifest-name=}'[Manifest name]:file:_files -g "*.xml"' \
|
||||
'--suppress-upstream-revision[Suppress upstream revision]' \
|
||||
'--suppress-dest-branch[Suppress dest branch]' \
|
||||
'--format=[Output format]:format:(xml json)' \
|
||||
'--pretty[Pretty print]' \
|
||||
'--no-local-manifests[Ignore local manifests]' \
|
||||
'(-o --output-file)'{-o,--output-file=}'[Output file]:file:_files'
|
||||
;;
|
||||
overview)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-c --current-branch)'{-c,--current-branch}'[Show current branch]' \
|
||||
'--no-current-branch[Do not show current branch]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
prune)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'*: :->project'
|
||||
;;
|
||||
rebase)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--fail-fast[Fail fast]' \
|
||||
'(-f --force-rebase)'{-f,--force-rebase}'[Force rebase]' \
|
||||
'--no-ff[No fast forward]' \
|
||||
'--autosquash[Autosquash]' \
|
||||
'--whitespace=[Whitespace option]:option:' \
|
||||
'--auto-stash[Auto stash]' \
|
||||
'(-m --onto-manifest)'{-m,--onto-manifest}'[Rebase onto manifest]' \
|
||||
'(-i --interactive)'{-i,--interactive}'[Interactive rebase]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
selfupdate)
|
||||
_arguments \
|
||||
'--no-repo-verify[Do not verify repo]'
|
||||
;;
|
||||
smartsync | sync)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--jobs-network=[Number of network jobs]:jobs:' \
|
||||
'--jobs-checkout=[Number of checkout jobs]:jobs:' \
|
||||
'(-f --force-broken)'{-f,--force-broken}'[Continue sync even if a project fails]' \
|
||||
'--fail-fast[Fail fast]' \
|
||||
'--force-sync[Overwrite existing git directories]' \
|
||||
'--force-checkout[Force checkout]' \
|
||||
'--force-remove-dirty[Force remove dirty]' \
|
||||
'--rebase[Rebase local branches]' \
|
||||
'(-l --local-only)'{-l,--local-only}'[Only use local files]' \
|
||||
'--no-manifest-update[Do not update manifest]' \
|
||||
'--nmu[Do not update manifest]' \
|
||||
'--interleaved[Interleave output]' \
|
||||
'--no-interleaved[Do not interleave output]' \
|
||||
'(-n --network-only)'{-n,--network-only}'[Only fetch]' \
|
||||
'(-d --detach)'{-d,--detach}'[Detach HEAD]' \
|
||||
'(-c --current-branch)'{-c,--current-branch}'[Fetch only current branch]' \
|
||||
'--no-current-branch[Do not fetch only current branch]' \
|
||||
'(-m --manifest-name)'{-m,--manifest-name=}'[Manifest name]:file:_files -g "*.xml"' \
|
||||
'--clone-bundle[Use clone bundle]' \
|
||||
'--no-clone-bundle[Do not use clone bundle]' \
|
||||
'(-u --manifest-server-username)'{-u,--manifest-server-username=}'[Username for manifest server]:username:' \
|
||||
'(-p --manifest-server-password)'{-p,--manifest-server-password=}'[Password for manifest server]:password:' \
|
||||
'--fetch-submodules[Fetch submodules]' \
|
||||
'--use-superproject[Use superproject]' \
|
||||
'--no-use-superproject[Do not use superproject]' \
|
||||
'--tags[Sync tags]' \
|
||||
'--no-tags[Do not sync tags]' \
|
||||
'--optimized-fetch[Optimized fetch]' \
|
||||
'--retry-fetches=[Retry fetches]:count:' \
|
||||
'--prune[Prune]' \
|
||||
'--no-prune[Do not prune]' \
|
||||
'--auto-gc[Run auto gc]' \
|
||||
'--no-auto-gc[Do not run auto gc]' \
|
||||
'(-s --smart-sync)'{-s,--smart-sync}'[Smart sync]' \
|
||||
'(-t --smart-tag)'{-t,--smart-tag=}'[Smart tag]:tag:' \
|
||||
'--no-repo-verify[Do not verify repo]' \
|
||||
'--no-verify[Do not verify]' \
|
||||
'--verify[Verify]' \
|
||||
'--ignore-hooks[Ignore hooks]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
stage)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-i --interactive)'{-i,--interactive}'[Interactive staging]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
start)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--all[Start branch in all projects]' \
|
||||
'(-r --rev --revision)'{-r,--rev=,--revision=}'[Revision]:revision:' \
|
||||
'--head[Head]' \
|
||||
'--HEAD[Head]' \
|
||||
'1: :->newbranch' \
|
||||
'*: :->project'
|
||||
;;
|
||||
status)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-o --orphans)'{-o,--orphans}'[Show orphans]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
upload)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-t --topic-branch)'{-t,--topic-branch}'[Topic branch]' \
|
||||
'--topic=[Topic]:topic:' \
|
||||
'--hashtag=[Hashtag]:hashtag:' \
|
||||
'--ht=[Hashtag]:hashtag:' \
|
||||
'--hashtag-branch[Hashtag branch]' \
|
||||
'--htb[Hashtag branch]' \
|
||||
'(-l --label)'{-l,--label=}'[Label]:label:' \
|
||||
'--pd=[Patchset description]:description:' \
|
||||
'--patchset-description=[Patchset description]:description:' \
|
||||
'--re=[Reviewers]:reviewers:' \
|
||||
'--reviewers=[Reviewers]:reviewers:' \
|
||||
'--cc=[CC]:cc:' \
|
||||
'--br=[Branch]:branch:' \
|
||||
'--branch=[Branch]:branch:' \
|
||||
'(-c --current-branch)'{-c,--current-branch}'[Upload current branch]' \
|
||||
'--no-current-branch[Do not upload current branch]' \
|
||||
'--ne[No emails]' \
|
||||
'--no-emails[No emails]' \
|
||||
'(-p --private)'{-p,--private}'[Private]' \
|
||||
'(-w --wip)'{-w,--wip}'[Work in progress]' \
|
||||
'(-r --ready)'{-r,--ready}'[Ready]' \
|
||||
'(-o --push-option)'{-o,--push-option=}'[Push option]:option:' \
|
||||
'(-D --destination --dest)'{-D,--destination=,--dest=}'[Destination]:destination:' \
|
||||
'(-n --dry-run)'{-n,--dry-run}'[Dry run]' \
|
||||
'(-y --yes)'{-y,--yes}'[Answer yes to all prompts]' \
|
||||
'--ignore-untracked-files[Ignore untracked files]' \
|
||||
'--no-ignore-untracked-files[Do not ignore untracked files]' \
|
||||
'--no-cert-checks[Do not check certificates]' \
|
||||
'--no-verify[Do not verify]' \
|
||||
'--verify[Verify]' \
|
||||
'--ignore-hooks[Ignore hooks]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
version)
|
||||
_arguments ${common_opts}
|
||||
;;
|
||||
wipe)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-f --force)'{-f,--force}'[Force wipe]' \
|
||||
'--force-uncommitted[Force uncommitted]' \
|
||||
'--force-shared[Force shared]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
esac
|
||||
|
||||
# Handle states for positional arguments.
|
||||
case ${state} in
|
||||
branch)
|
||||
[[ ${PREFIX} != -* ]] && _repo_branches
|
||||
;;
|
||||
project)
|
||||
[[ ${PREFIX} != -* ]] && _repo_projects
|
||||
;;
|
||||
pattern)
|
||||
_message 'pattern'
|
||||
;;
|
||||
newbranch)
|
||||
_message 'new branch name'
|
||||
;;
|
||||
help_cmds)
|
||||
_describe -t commands 'repo command' subcommands
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_repo_branches() {
|
||||
local -a branches
|
||||
branches=(${(f)"$(_call_program branches repo branches 2>/dev/null | sed -E 's/^.*[[:space:]]([^[:space:]]+)[[:space:]]+\|.*$/\1/' | awk '{print $1}')"})
|
||||
_describe -t branches 'branch' branches
|
||||
}
|
||||
|
||||
_repo_projects() {
|
||||
local -a projects
|
||||
projects=(${(f)"$(_call_program projects repo list -n 2>/dev/null)"})
|
||||
_describe -t projects 'project' projects
|
||||
}
|
||||
|
||||
_repo "$@"
|
||||
+2
-1
@@ -163,13 +163,14 @@ Example:
|
||||
The `post-sync.py` file should be defined like:
|
||||
|
||||
```py
|
||||
def main(repo_topdir=None, **kwargs):
|
||||
def main(repo_topdir=None, sync_duration_seconds=None, **kwargs):
|
||||
"""Main function invoked directly by repo.
|
||||
|
||||
We must use the name "main" as that is what repo requires.
|
||||
|
||||
Args:
|
||||
repo_topdir: The absolute path to the top-level directory of the repo workspace.
|
||||
sync_duration_seconds: The duration of the sync operation in seconds.
|
||||
kwargs: Leave this here for forward-compatibility.
|
||||
"""
|
||||
```
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ You will have to specify this flag every time you upload.
|
||||
|
||||
[mintty]: https://github.com/mintty/mintty/issues/56
|
||||
|
||||
### repohooks always fail with an close_fds error.
|
||||
### repohooks always fail with an close_fds error
|
||||
|
||||
When using the [reference repohooks project][repohooks] included in AOSP,
|
||||
you might see errors like this when running `repo upload`:
|
||||
|
||||
@@ -34,7 +34,7 @@ def fetch_file(url, verbose=False):
|
||||
"""
|
||||
scheme = urlparse(url).scheme
|
||||
if scheme == "gs":
|
||||
cmd = ["gsutil", "cat", url]
|
||||
cmd = ["gcloud", "storage", "cat", url]
|
||||
errors = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -42,7 +42,7 @@ def fetch_file(url, verbose=False):
|
||||
)
|
||||
if result.stderr and verbose:
|
||||
print(
|
||||
'warning: non-fatal error running "gsutil": %s'
|
||||
'warning: non-fatal error running "gcloud storage": %s'
|
||||
% result.stderr,
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -50,7 +50,8 @@ def fetch_file(url, verbose=False):
|
||||
except subprocess.CalledProcessError as e:
|
||||
errors.append(e)
|
||||
print(
|
||||
'fatal: error running "gsutil": %s' % e.stderr, file=sys.stderr
|
||||
'fatal: error running "gcloud storage": %s' % e.stderr,
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise FetchFileError(aggregate_errors=errors)
|
||||
with urlopen(url) as f:
|
||||
|
||||
+5
-2
@@ -438,7 +438,7 @@ class GitConfig:
|
||||
if p.Wait() == 0:
|
||||
return p.stdout
|
||||
else:
|
||||
raise GitError(f"git config {str(args)}: {p.stderr}")
|
||||
raise GitError(f"git {' '.join(command)}: {p.stderr}")
|
||||
|
||||
|
||||
class RepoConfig(GitConfig):
|
||||
@@ -724,7 +724,10 @@ class Remote:
|
||||
def Save(self):
|
||||
"""Save this remote to the configuration."""
|
||||
self._Set("url", self.url)
|
||||
if self.pushUrl is not None:
|
||||
# projectname is initialized for projects listed in the manifest, but
|
||||
# not for others (e.g. the manifest project). This class is used for
|
||||
# all of them.
|
||||
if self.pushUrl is not None and self.projectname is not None:
|
||||
self._Set("pushurl", self.pushUrl + "/" + self.projectname)
|
||||
else:
|
||||
self._Set("pushurl", self.pushUrl)
|
||||
|
||||
+20
-5
@@ -34,6 +34,7 @@ import urllib.parse
|
||||
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
from git_config import IsId
|
||||
from git_config import RepoConfig
|
||||
from git_refs import GitRefs
|
||||
import platform_utils
|
||||
@@ -100,7 +101,7 @@ class Superproject:
|
||||
self._manifest = manifest
|
||||
self.name = name
|
||||
self.remote = remote
|
||||
self.revision = self._branch = revision
|
||||
self.revision = revision
|
||||
self._repodir = manifest.repodir
|
||||
self._superproject_dir = superproject_dir
|
||||
self._superproject_path = manifest.SubmanifestInfoDir(
|
||||
@@ -132,6 +133,10 @@ class Superproject:
|
||||
"""Set the _print_messages attribute."""
|
||||
self._print_messages = value
|
||||
|
||||
def SetRevisionId(self, revision_id: str) -> None:
|
||||
"""Set the revisionId of the superproject to sync to."""
|
||||
self.revision = revision_id
|
||||
|
||||
@property
|
||||
def commit_id(self):
|
||||
"""Returns the commit ID of the superproject checkout."""
|
||||
@@ -199,7 +204,8 @@ class Superproject:
|
||||
def _LogMessagePrefix(self):
|
||||
"""Returns the prefix string to be logged in each log message"""
|
||||
return (
|
||||
f"repo superproject branch: {self._branch} url: {self._remote_url}"
|
||||
f"repo superproject revision: {self.revision} "
|
||||
f"url: {self._remote_url}"
|
||||
)
|
||||
|
||||
def _LogError(self, fmt, *inputs):
|
||||
@@ -312,8 +318,15 @@ class Superproject:
|
||||
if rev_commit:
|
||||
cmd.extend(["--negotiation-tip", rev_commit])
|
||||
|
||||
if self._branch:
|
||||
cmd += [self._branch + ":" + self._branch]
|
||||
if self.revision:
|
||||
# If revision is a commit hash, fetch it directly to avoid
|
||||
# creating a local branch of the same name.
|
||||
refspec = (
|
||||
self.revision
|
||||
if IsId(self.revision)
|
||||
else f"{self.revision}:{self.revision}"
|
||||
)
|
||||
cmd.append(refspec)
|
||||
p = GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
@@ -348,7 +361,7 @@ class Superproject:
|
||||
)
|
||||
return None
|
||||
data = None
|
||||
branch = "HEAD" if not self._branch else self._branch
|
||||
branch = "HEAD" if not self.revision else self.revision
|
||||
cmd = ["ls-tree", "-z", "-r", branch]
|
||||
|
||||
p = GitCommand(
|
||||
@@ -400,6 +413,8 @@ class Superproject:
|
||||
|
||||
if not self._Init():
|
||||
return SyncResult(False, should_exit)
|
||||
if IsId(self.revision) and self.commit_id:
|
||||
return SyncResult(True, False)
|
||||
if not self._Fetch():
|
||||
return SyncResult(False, should_exit)
|
||||
if not self._quiet:
|
||||
|
||||
@@ -25,7 +25,7 @@ from git_refs import HEAD
|
||||
# The API we've documented to hook authors. Keep in sync with repo-hooks.md.
|
||||
_API_ARGS = {
|
||||
"pre-upload": {"project_list", "worktree_list"},
|
||||
"post-sync": {"repo_topdir"},
|
||||
"post-sync": {"repo_topdir", "sync_duration_seconds"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
+6
-1
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo forall" "Repo Manual"
|
||||
.TH REPO "1" "May 2026" "repo forall" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo forall - manual page for repo forall
|
||||
.SH SYNOPSIS
|
||||
@@ -120,6 +120,11 @@ git command, use REPO_LREV.
|
||||
REPO_RREV is the name of the revision from the manifest, exactly as written in
|
||||
the manifest.
|
||||
.PP
|
||||
REPO_UPSTREAM is the name of the upstream branch as specified in the manifest.
|
||||
.PP
|
||||
REPO_DEST_BRANCH is the name of the destination branch for code review, as
|
||||
specified in the manifest.
|
||||
.PP
|
||||
REPO_COUNT is the total number of projects being iterated.
|
||||
.PP
|
||||
REPO_I is the current (1\-based) iteration count. Can be used in conjunction with
|
||||
|
||||
+21
-2
@@ -1,10 +1,10 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo info" "Repo Manual"
|
||||
.TH REPO "1" "May 2026" "repo info" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo info - manual page for repo info
|
||||
.SH SYNOPSIS
|
||||
.B repo
|
||||
\fI\,info \/\fR[\fI\,-dl\/\fR] [\fI\,-o \/\fR[\fI\,-c\/\fR]] [\fI\,<project>\/\fR...]
|
||||
\fI\,info \/\fR[\fI\,-dl\/\fR] [\fI\,-o \/\fR[\fI\,-c\/\fR]] [\fI\,--format=<format>\/\fR] [\fI\,<project>\/\fR...]
|
||||
.SH DESCRIPTION
|
||||
Summary
|
||||
.PP
|
||||
@@ -14,6 +14,10 @@ Get info on the manifest branch, current branch or unmerged branches
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
.TP
|
||||
\fB\-d\fR, \fB\-\-diff\fR
|
||||
show full info and commit diff including remote
|
||||
branches
|
||||
@@ -21,6 +25,18 @@ branches
|
||||
\fB\-o\fR, \fB\-\-overview\fR
|
||||
show overview of all local commits
|
||||
.TP
|
||||
\fB\-\-include\-summary\fR
|
||||
include manifest summary (default: true)
|
||||
.TP
|
||||
\fB\-\-no\-include\-summary\fR
|
||||
exclude manifest summary
|
||||
.TP
|
||||
\fB\-\-include\-projects\fR
|
||||
include project details (default: true)
|
||||
.TP
|
||||
\fB\-\-no\-include\-projects\fR
|
||||
exclude project details
|
||||
.TP
|
||||
\fB\-c\fR, \fB\-\-current\-branch\fR
|
||||
consider only checked out branches
|
||||
.TP
|
||||
@@ -29,6 +45,9 @@ consider all local branches
|
||||
.TP
|
||||
\fB\-l\fR, \fB\-\-local\-only\fR
|
||||
disable all remote operations
|
||||
.TP
|
||||
\fB\-\-format\fR=\fI\,FORMAT\/\fR
|
||||
output format: text, json (default: text)
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "August 2025" "repo smartsync" "Repo Manual"
|
||||
.TH REPO "1" "May 2026" "repo smartsync" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo smartsync - manual page for repo smartsync
|
||||
.SH SYNOPSIS
|
||||
@@ -101,6 +101,10 @@ implies \fB\-c\fR
|
||||
\fB\-\-no\-use\-superproject\fR
|
||||
disable use of manifest superprojects
|
||||
.TP
|
||||
\fB\-\-superproject\-revision\fR=\fI\,SUPERPROJECT_REVISION\/\fR
|
||||
sync to superproject revision (applies to outer
|
||||
manifest)
|
||||
.TP
|
||||
\fB\-\-tags\fR
|
||||
fetch tags
|
||||
.TP
|
||||
|
||||
+5
-1
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "August 2025" "repo sync" "Repo Manual"
|
||||
.TH REPO "1" "May 2026" "repo sync" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo sync - manual page for repo sync
|
||||
.SH SYNOPSIS
|
||||
@@ -101,6 +101,10 @@ implies \fB\-c\fR
|
||||
\fB\-\-no\-use\-superproject\fR
|
||||
disable use of manifest superprojects
|
||||
.TP
|
||||
\fB\-\-superproject\-revision\fR=\fI\,SUPERPROJECT_REVISION\/\fR
|
||||
sync to superproject revision (applies to outer
|
||||
manifest)
|
||||
.TP
|
||||
\fB\-\-tags\fR
|
||||
fetch tags
|
||||
.TP
|
||||
|
||||
@@ -222,6 +222,43 @@ def rmdir(path):
|
||||
os.rmdir(_makelongpath(path))
|
||||
|
||||
|
||||
def removedirs(path: str) -> None:
|
||||
"""Remove a directory tree of symlinks and empty directories.
|
||||
|
||||
Walks |path| bottom-up without following symlinks. Removes symlinks
|
||||
and empty directories. Stops at (and preserves) regular files and
|
||||
non-empty directories. Silently succeeds if |path| does not exist.
|
||||
|
||||
Availability: Unix, Windows.
|
||||
"""
|
||||
if islink(path):
|
||||
remove(path)
|
||||
return
|
||||
|
||||
if not isdir(path):
|
||||
return
|
||||
|
||||
for dirpath, dirnames, filenames in walk(path, topdown=False):
|
||||
for name in dirnames:
|
||||
entry = os.path.join(dirpath, name)
|
||||
if islink(entry):
|
||||
remove(entry)
|
||||
else:
|
||||
try:
|
||||
rmdir(entry)
|
||||
except OSError:
|
||||
pass
|
||||
for name in filenames:
|
||||
entry = os.path.join(dirpath, name)
|
||||
if islink(entry):
|
||||
remove(entry)
|
||||
|
||||
try:
|
||||
rmdir(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def isdir(path):
|
||||
"""os.path.isdir(path) wrapper with support for long paths on Windows.
|
||||
|
||||
|
||||
+8
-4
@@ -453,9 +453,13 @@ class _LinkFile(NamedTuple):
|
||||
platform_utils.readlink(absDest) != relSrc
|
||||
):
|
||||
try:
|
||||
# Remove existing file first, since it might be read-only.
|
||||
# Remove existing path first, since it might be read-only.
|
||||
if os.path.lexists(absDest):
|
||||
platform_utils.remove(absDest)
|
||||
# removedirs handles symlinks, empty dirs, and nested
|
||||
# trees of stale linkfile dests without deleting user
|
||||
# content. Falls through to symlink() if the path was
|
||||
# fully removed, or raises OSError if not.
|
||||
platform_utils.removedirs(absDest)
|
||||
else:
|
||||
dest_dir = os.path.dirname(absDest)
|
||||
if not platform_utils.isdir(dest_dir):
|
||||
@@ -1767,7 +1771,7 @@ class Project:
|
||||
self, "leaving %s; does not track upstream", branch.name
|
||||
)
|
||||
try:
|
||||
self._Checkout(revid, quiet=True)
|
||||
self._Checkout(revid, force_checkout=force_checkout, quiet=True)
|
||||
if submodules:
|
||||
self._SyncSubmodules(quiet=True)
|
||||
except GitError as e:
|
||||
@@ -2444,7 +2448,7 @@ class Project:
|
||||
result.extend(project.GetDerivedSubprojects())
|
||||
continue
|
||||
|
||||
if url.startswith(".."):
|
||||
if url.startswith(("./", "../")):
|
||||
url = urllib.parse.urljoin("%s/" % self.remote.url, url)
|
||||
remote = RemoteSpec(
|
||||
self.remote.name,
|
||||
|
||||
@@ -85,18 +85,18 @@ Repo launcher bucket:
|
||||
gs://git-repo-downloads/
|
||||
|
||||
You should first upload it with a specific version:
|
||||
gsutil cp -a public-read {opts.launcher} gs://git-repo-downloads/repo-{version}
|
||||
gsutil cp -a public-read {opts.launcher}.asc gs://git-repo-downloads/repo-{version}.asc
|
||||
gcloud storage cp --predefined-acl=publicRead {opts.launcher} gs://git-repo-downloads/repo-{version}
|
||||
gcloud storage cp --predefined-acl=publicRead {opts.launcher}.asc gs://git-repo-downloads/repo-{version}.asc
|
||||
|
||||
Then to make it the public default:
|
||||
gsutil cp -a public-read gs://git-repo-downloads/repo-{version} gs://git-repo-downloads/repo
|
||||
gsutil cp -a public-read gs://git-repo-downloads/repo-{version}.asc gs://git-repo-downloads/repo.asc
|
||||
gcloud storage cp --predefined-acl=publicRead gs://git-repo-downloads/repo-{version} gs://git-repo-downloads/repo
|
||||
gcloud storage cp --predefined-acl=publicRead gs://git-repo-downloads/repo-{version}.asc gs://git-repo-downloads/repo.asc
|
||||
|
||||
NB: If a rollback is necessary, the GS bucket archives old versions, and may be
|
||||
accessed by specifying their unique id number.
|
||||
gsutil ls -la gs://git-repo-downloads/repo gs://git-repo-downloads/repo.asc
|
||||
gsutil cp -a public-read gs://git-repo-downloads/repo#<unique id> gs://git-repo-downloads/repo
|
||||
gsutil cp -a public-read gs://git-repo-downloads/repo.asc#<unique id> gs://git-repo-downloads/repo.asc
|
||||
gcloud storage ls --long --all-versions gs://git-repo-downloads/repo gs://git-repo-downloads/repo.asc
|
||||
gcloud storage cp --predefined-acl=publicRead gs://git-repo-downloads/repo#<unique id> gs://git-repo-downloads/repo
|
||||
gcloud storage cp --predefined-acl=publicRead gs://git-repo-downloads/repo.asc#<unique id> gs://git-repo-downloads/repo.asc
|
||||
""" # noqa: E501
|
||||
)
|
||||
|
||||
|
||||
@@ -30,8 +30,7 @@ import tempfile
|
||||
from typing import List
|
||||
|
||||
|
||||
# NB: This script is currently imported by tests/ to unittest some logic.
|
||||
assert sys.version_info >= (3, 6), "Python 3.6+ required"
|
||||
assert sys.version_info >= (3, 9), "Release framework requires Python 3.9+"
|
||||
|
||||
|
||||
THIS_FILE = Path(__file__).resolve()
|
||||
@@ -66,7 +65,11 @@ def main(argv: List[str]) -> int:
|
||||
parser = get_parser()
|
||||
opts = parser.parse_args(argv)
|
||||
|
||||
if not shutil.which("help2man"):
|
||||
help2man = ["help2man"]
|
||||
cipd_help2man = TOPDIR / ".cipd_bin/bin/help2man"
|
||||
if cipd_help2man.exists():
|
||||
help2man = [cipd_help2man]
|
||||
elif not shutil.which("help2man"):
|
||||
sys.exit("Please install help2man to continue.")
|
||||
|
||||
# Let repo know we're generating man pages so it can avoid some dynamic
|
||||
@@ -81,7 +84,7 @@ def main(argv: List[str]) -> int:
|
||||
version = RepoSourceVersion()
|
||||
cmdlist = [
|
||||
[
|
||||
"help2man",
|
||||
*help2man,
|
||||
"-N",
|
||||
"-n",
|
||||
f"repo {cmd} - manual page for repo {cmd}",
|
||||
@@ -100,7 +103,7 @@ def main(argv: List[str]) -> int:
|
||||
]
|
||||
cmdlist.append(
|
||||
[
|
||||
"help2man",
|
||||
*help2man,
|
||||
"-N",
|
||||
"-n",
|
||||
"repository management tool built on top of git",
|
||||
@@ -178,7 +181,6 @@ def replace_regex(data):
|
||||
"""
|
||||
regex = (
|
||||
(r"(It was generated by help2man) [0-9.]+", r"\g<1>."),
|
||||
(r"^\033\[[0-9;]*m([^\033]*)\033\[m", r"\g<1>"),
|
||||
(r"^\.IP\n(.*:)\n", r".SS \g<1>\n"),
|
||||
(r"^\.PP\nDescription", r".SH DETAILS"),
|
||||
)
|
||||
|
||||
@@ -157,11 +157,6 @@ def run_check_metadata():
|
||||
|
||||
def run_update_manpages() -> int:
|
||||
"""Returns the exit code from release/update-manpages."""
|
||||
# Allow this to fail on CI, but not local devs.
|
||||
if is_ci() and not shutil.which("help2man"):
|
||||
print("update-manpages: help2man not found; skipping test")
|
||||
return 0
|
||||
|
||||
argv = ["--check"]
|
||||
log_cmd("release/update-manpages", argv)
|
||||
return subprocess.run(
|
||||
@@ -171,8 +166,24 @@ def run_update_manpages() -> int:
|
||||
).returncode
|
||||
|
||||
|
||||
def cipd_bootstrap() -> None:
|
||||
"""Install packages via cipd if available."""
|
||||
argv = ["ensure", "-root", ".cipd_bin", "-ensure-file", "cipd_manifest.txt"]
|
||||
log_cmd("cipd", argv)
|
||||
try:
|
||||
subprocess.run(
|
||||
["cipd"] + argv,
|
||||
check=True,
|
||||
cwd=ROOT_DIR,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# Skip if the user doesn't have cipd from depot_tools.
|
||||
return
|
||||
|
||||
|
||||
def main(argv):
|
||||
"""The main entry."""
|
||||
cipd_bootstrap()
|
||||
checks = (
|
||||
functools.partial(run_pytest, argv),
|
||||
functools.partial(run_pytest_py38, argv),
|
||||
|
||||
@@ -94,6 +94,7 @@ It is equivalent to "git branch -D <branchname>".
|
||||
|
||||
def Execute(self, opt, args):
|
||||
nb = args[0].split()
|
||||
self.TryOverrideManifestWithSmartSync()
|
||||
err = collections.defaultdict(list)
|
||||
success = collections.defaultdict(list)
|
||||
aggregate_errors = []
|
||||
|
||||
+7
-7
@@ -102,6 +102,12 @@ revision to a locally executed git command, use REPO_LREV.
|
||||
REPO_RREV is the name of the revision from the manifest, exactly
|
||||
as written in the manifest.
|
||||
|
||||
REPO_UPSTREAM is the name of the upstream branch as specified in the
|
||||
manifest.
|
||||
|
||||
REPO_DEST_BRANCH is the name of the destination branch for code review,
|
||||
as specified in the manifest.
|
||||
|
||||
REPO_COUNT is the total number of projects being iterated.
|
||||
|
||||
REPO_I is the current (1-based) iteration count. Can be used in
|
||||
@@ -236,13 +242,7 @@ without iterating through the remaining projects.
|
||||
|
||||
mirror = self.manifest.IsMirror
|
||||
|
||||
smart_sync_manifest_name = "smart_sync_override.xml"
|
||||
smart_sync_manifest_path = os.path.join(
|
||||
self.manifest.manifestProject.worktree, smart_sync_manifest_name
|
||||
)
|
||||
|
||||
if os.path.isfile(smart_sync_manifest_path):
|
||||
self.manifest.Override(smart_sync_manifest_path)
|
||||
self.TryOverrideManifestWithSmartSync()
|
||||
|
||||
if opt.regex:
|
||||
projects = self.FindProjects(args, all_manifests=all_trees)
|
||||
|
||||
+322
-124
@@ -12,14 +12,41 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import enum
|
||||
import functools
|
||||
import io
|
||||
import json
|
||||
import optparse
|
||||
import sys
|
||||
from typing import Any, Dict, List, NamedTuple
|
||||
|
||||
from color import Coloring
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from command import PagedCommand
|
||||
from git_refs import R_HEADS
|
||||
from git_refs import R_M
|
||||
|
||||
|
||||
class BranchInfo(NamedTuple):
|
||||
"""Holds information about a branch in a project."""
|
||||
|
||||
relpath: str
|
||||
name: str
|
||||
commits: Any
|
||||
date: str
|
||||
is_current: bool
|
||||
|
||||
|
||||
class OutputFormat(enum.Enum):
|
||||
"""Type for the requested output format."""
|
||||
|
||||
# Human-readable text output.
|
||||
TEXT = enum.auto()
|
||||
|
||||
# Machine-readable JSON output.
|
||||
JSON = enum.auto()
|
||||
|
||||
|
||||
class _Coloring(Coloring):
|
||||
def __init__(self, config):
|
||||
Coloring.__init__(self, config, "status")
|
||||
@@ -27,10 +54,11 @@ class _Coloring(Coloring):
|
||||
|
||||
class Info(PagedCommand):
|
||||
COMMON = True
|
||||
PARALLEL_JOBS = DEFAULT_LOCAL_JOBS
|
||||
helpSummary = (
|
||||
"Get info on the manifest branch, current branch or unmerged branches"
|
||||
)
|
||||
helpUsage = "%prog [-dl] [-o [-c]] [<project>...]"
|
||||
helpUsage = "%prog [-dl] [-o [-c]] [--format=<format>] [<project>...]"
|
||||
|
||||
def _Options(self, p):
|
||||
p.add_option(
|
||||
@@ -46,6 +74,30 @@ class Info(PagedCommand):
|
||||
action="store_true",
|
||||
help="show overview of all local commits",
|
||||
)
|
||||
p.add_option(
|
||||
"--include-summary",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="include manifest summary (default: true)",
|
||||
)
|
||||
p.add_option(
|
||||
"--no-include-summary",
|
||||
dest="include_summary",
|
||||
action="store_false",
|
||||
help="exclude manifest summary",
|
||||
)
|
||||
p.add_option(
|
||||
"--include-projects",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="include project details (default: true)",
|
||||
)
|
||||
p.add_option(
|
||||
"--no-include-projects",
|
||||
dest="include_projects",
|
||||
action="store_false",
|
||||
help="exclude project details",
|
||||
)
|
||||
p.add_option(
|
||||
"-c",
|
||||
"--current-branch",
|
||||
@@ -72,8 +124,39 @@ class Info(PagedCommand):
|
||||
action="store_true",
|
||||
help="disable all remote operations",
|
||||
)
|
||||
formats = tuple(x.lower() for x in OutputFormat.__members__.keys())
|
||||
p.add_option(
|
||||
"--format",
|
||||
default=OutputFormat.TEXT.name.lower(),
|
||||
choices=formats,
|
||||
help=f"output format: {', '.join(formats)} (default: %default)",
|
||||
)
|
||||
|
||||
def WantPager(self, opt):
|
||||
return OutputFormat[opt.format.upper()] == OutputFormat.TEXT
|
||||
|
||||
def ValidateOptions(self, opt, args):
|
||||
output_format = OutputFormat[opt.format.upper()]
|
||||
if output_format == OutputFormat.JSON:
|
||||
if opt.all:
|
||||
self.OptionParser.error("--diff is not supported with JSON")
|
||||
if opt.overview:
|
||||
self.OptionParser.error("--overview is not supported with JSON")
|
||||
|
||||
def Execute(self, opt, args):
|
||||
if not opt.this_manifest_only:
|
||||
self.manifest = self.manifest.outer_client
|
||||
|
||||
self.TryOverrideManifestWithSmartSync()
|
||||
|
||||
output_format = OutputFormat[opt.format.upper()]
|
||||
if output_format == OutputFormat.JSON:
|
||||
self._ExecuteJson(opt, args)
|
||||
else:
|
||||
self._ExecuteText(opt, args)
|
||||
|
||||
def _ExecuteText(self, opt, args) -> None:
|
||||
"""Output info as human-readable text."""
|
||||
self.out = _Coloring(self.client.globalConfig)
|
||||
self.heading = self.out.printer("heading", attr="bold")
|
||||
self.headtext = self.out.nofmt_printer("headtext", fg="yellow")
|
||||
@@ -84,157 +167,272 @@ class Info(PagedCommand):
|
||||
|
||||
self.opt = opt
|
||||
|
||||
if not opt.this_manifest_only:
|
||||
self.manifest = self.manifest.outer_client
|
||||
manifestConfig = self.manifest.manifestProject.config
|
||||
mergeBranch = manifestConfig.GetBranch("default").merge
|
||||
manifestGroups = self.manifest.GetManifestGroupsStr()
|
||||
if opt.include_summary:
|
||||
self._printSummary()
|
||||
|
||||
self.heading("Manifest branch: ")
|
||||
if self.manifest.default.revisionExpr:
|
||||
self.headtext(self.manifest.default.revisionExpr)
|
||||
self.out.nl()
|
||||
self.heading("Manifest merge branch: ")
|
||||
# The manifest might not have a merge branch if it isn't in a git repo,
|
||||
# e.g. if `repo init --standalone-manifest` is used.
|
||||
self.headtext(mergeBranch or "")
|
||||
self.out.nl()
|
||||
self.heading("Manifest groups: ")
|
||||
self.headtext(manifestGroups)
|
||||
self.out.nl()
|
||||
sp = self.manifest.superproject
|
||||
srev = sp.commit_id if sp and sp.commit_id else "None"
|
||||
self.heading("Superproject revision: ")
|
||||
self.headtext(srev)
|
||||
self.out.nl()
|
||||
|
||||
self.printSeparator()
|
||||
|
||||
if not opt.overview:
|
||||
if not opt.include_projects:
|
||||
return
|
||||
elif not opt.overview:
|
||||
self._printDiffInfo(opt, args)
|
||||
else:
|
||||
self._printCommitOverview(opt, args)
|
||||
|
||||
def _getSummaryData(self) -> Dict[str, Any]:
|
||||
"""Gather manifest summary data as a dict."""
|
||||
manifestConfig = self.manifest.manifestProject.config
|
||||
mergeBranch = manifestConfig.GetBranch("default").merge
|
||||
manifestGroups = self.manifest.GetManifestGroupsStr()
|
||||
sp = self.manifest.superproject
|
||||
srev = sp.commit_id if sp and sp.commit_id else None
|
||||
return {
|
||||
"manifest_branch": self.manifest.default.revisionExpr or "",
|
||||
"manifest_merge_branch": mergeBranch or "",
|
||||
"manifest_groups": manifestGroups,
|
||||
"superproject_revision": srev,
|
||||
}
|
||||
|
||||
def _getProjectData(self, project) -> Dict[str, Any]:
|
||||
"""Gather project data as a dict."""
|
||||
data = {
|
||||
"name": project.name,
|
||||
"mount_path": project.worktree,
|
||||
"current_revision": project.GetRevisionId(),
|
||||
"manifest_revision": project.revisionExpr,
|
||||
"local_branches": list(project.GetBranches()),
|
||||
}
|
||||
currentBranch = project.CurrentBranch
|
||||
if currentBranch:
|
||||
data["current_branch"] = currentBranch
|
||||
return data
|
||||
|
||||
def _ExecuteJson(self, opt, args) -> None:
|
||||
"""Output info as JSON."""
|
||||
result = {}
|
||||
if opt.include_summary:
|
||||
result["summary"] = self._getSummaryData()
|
||||
if opt.include_projects:
|
||||
projs = self.GetProjects(
|
||||
args, all_manifests=not opt.this_manifest_only
|
||||
)
|
||||
result["projects"] = [self._getProjectData(p) for p in projs]
|
||||
|
||||
json_settings = {
|
||||
# JSON style guide says Unicode characters are fully allowed.
|
||||
"ensure_ascii": False,
|
||||
# We use 2 space indent to match JSON style guide.
|
||||
"indent": 2,
|
||||
"separators": (",", ": "),
|
||||
"sort_keys": True,
|
||||
}
|
||||
sys.stdout.write(json.dumps(result, **json_settings) + "\n")
|
||||
|
||||
def _printSummary(self) -> None:
|
||||
"""Print manifest summary in text format."""
|
||||
data = self._getSummaryData()
|
||||
self.heading("Manifest branch: ")
|
||||
self.headtext(data["manifest_branch"])
|
||||
self.out.nl()
|
||||
self.heading("Manifest merge branch: ")
|
||||
self.headtext(data["manifest_merge_branch"])
|
||||
self.out.nl()
|
||||
self.heading("Manifest groups: ")
|
||||
self.headtext(data["manifest_groups"])
|
||||
self.out.nl()
|
||||
self.heading("Superproject revision: ")
|
||||
self.headtext(data["superproject_revision"] or "None")
|
||||
self.out.nl()
|
||||
self.printSeparator()
|
||||
|
||||
def printSeparator(self):
|
||||
self.text("----------------------------")
|
||||
self.out.nl()
|
||||
|
||||
@classmethod
|
||||
def _DiffHelper(cls, project_idx: int, opt: Any) -> str:
|
||||
"""Helper for ParallelContext to get diff info for a project."""
|
||||
buf = io.StringIO()
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
config = cls.get_parallel_context()["config"]
|
||||
|
||||
out = _Coloring(config)
|
||||
out.redirect(buf)
|
||||
|
||||
heading = out.printer("heading", attr="bold")
|
||||
headtext = out.nofmt_printer("headtext", fg="yellow")
|
||||
redtext = out.printer("redtext", fg="red")
|
||||
sha = out.printer("sha", fg="yellow")
|
||||
text = out.nofmt_printer("text")
|
||||
dimtext = out.printer("dimtext", attr="dim")
|
||||
|
||||
heading("Project: ")
|
||||
headtext(project.name)
|
||||
out.nl()
|
||||
|
||||
heading("Mount path: ")
|
||||
headtext(project.worktree)
|
||||
out.nl()
|
||||
|
||||
heading("Current revision: ")
|
||||
headtext(project.GetRevisionId())
|
||||
out.nl()
|
||||
|
||||
currentBranch = project.CurrentBranch
|
||||
if currentBranch:
|
||||
heading("Current branch: ")
|
||||
headtext(currentBranch)
|
||||
out.nl()
|
||||
|
||||
heading("Manifest revision: ")
|
||||
headtext(project.revisionExpr)
|
||||
out.nl()
|
||||
|
||||
localBranches = list(project.GetBranches().keys())
|
||||
heading("Local Branches: ")
|
||||
redtext(str(len(localBranches)))
|
||||
if localBranches:
|
||||
text(" [")
|
||||
text(", ".join(localBranches))
|
||||
text("]")
|
||||
out.nl()
|
||||
|
||||
if opt.all:
|
||||
if not opt.local:
|
||||
project.Sync_NetworkHalf(quiet=True, current_branch_only=True)
|
||||
|
||||
branch = project.manifest.manifestProject.config.GetBranch(
|
||||
"default"
|
||||
).merge
|
||||
if branch.startswith(R_HEADS):
|
||||
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 + "..",
|
||||
"--",
|
||||
)
|
||||
|
||||
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)")
|
||||
out.nl()
|
||||
|
||||
for c in localCommits:
|
||||
split = c.split()
|
||||
sha(split[0] + " ")
|
||||
text(" ".join(split[1:]))
|
||||
out.nl()
|
||||
|
||||
text("----------------------------")
|
||||
out.nl()
|
||||
|
||||
heading("Remote Commits: ")
|
||||
redtext(str(len(originCommits)))
|
||||
out.nl()
|
||||
|
||||
for c in originCommits:
|
||||
split = c.split()
|
||||
sha(split[0] + " ")
|
||||
text(" ".join(split[1:]))
|
||||
out.nl()
|
||||
|
||||
text("----------------------------")
|
||||
out.nl()
|
||||
|
||||
return buf.getvalue()
|
||||
|
||||
def _printDiffInfo(self, opt, args):
|
||||
# We let exceptions bubble up to main as they'll be well structured.
|
||||
projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
|
||||
|
||||
for p in projs:
|
||||
self.heading("Project: ")
|
||||
self.headtext(p.name)
|
||||
self.out.nl()
|
||||
def _ProcessResults(_pool, _output, results):
|
||||
for output in results:
|
||||
if output:
|
||||
print(output, end="")
|
||||
|
||||
self.heading("Mount path: ")
|
||||
self.headtext(p.worktree)
|
||||
self.out.nl()
|
||||
with self.ParallelContext():
|
||||
self.get_parallel_context()["projects"] = projs
|
||||
self.get_parallel_context()[
|
||||
"config"
|
||||
] = self.manifest.manifestProject.config
|
||||
|
||||
self.heading("Current revision: ")
|
||||
self.headtext(p.GetRevisionId())
|
||||
self.out.nl()
|
||||
self.ExecuteInParallel(
|
||||
opt.jobs,
|
||||
functools.partial(self._DiffHelper, opt=opt),
|
||||
range(len(projs)),
|
||||
callback=_ProcessResults,
|
||||
ordered=True,
|
||||
chunksize=1,
|
||||
)
|
||||
|
||||
currentBranch = p.CurrentBranch
|
||||
if currentBranch:
|
||||
self.heading("Current branch: ")
|
||||
self.headtext(currentBranch)
|
||||
self.out.nl()
|
||||
@classmethod
|
||||
def _OverviewHelper(cls, project_idx: int, opt: Any) -> List[BranchInfo]:
|
||||
"""Helper to get overview of uploadable branches."""
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
|
||||
self.heading("Manifest revision: ")
|
||||
self.headtext(p.revisionExpr)
|
||||
self.out.nl()
|
||||
branches = []
|
||||
br = [project.GetUploadableBranch(x) for x in project.GetBranches()]
|
||||
br = [x for x in br if x]
|
||||
if opt.current_branch:
|
||||
br = [x for x in br if x.name == project.CurrentBranch]
|
||||
|
||||
localBranches = list(p.GetBranches().keys())
|
||||
self.heading("Local Branches: ")
|
||||
self.redtext(str(len(localBranches)))
|
||||
if localBranches:
|
||||
self.text(" [")
|
||||
self.text(", ".join(localBranches))
|
||||
self.text("]")
|
||||
self.out.nl()
|
||||
|
||||
if self.opt.all:
|
||||
self.findRemoteLocalDiff(p)
|
||||
|
||||
self.printSeparator()
|
||||
|
||||
def findRemoteLocalDiff(self, project):
|
||||
# Fetch all the latest commits.
|
||||
if not self.opt.local:
|
||||
project.Sync_NetworkHalf(quiet=True, current_branch_only=True)
|
||||
|
||||
branch = self.manifest.manifestProject.config.GetBranch("default").merge
|
||||
if branch.startswith(R_HEADS):
|
||||
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 + "..",
|
||||
"--",
|
||||
)
|
||||
|
||||
originCommits = project.bare_git.rev_list(
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
".." + logTarget,
|
||||
"--",
|
||||
)
|
||||
project.bare_git._bare = bareTmp
|
||||
|
||||
self.heading("Local Commits: ")
|
||||
self.redtext(str(len(localCommits)))
|
||||
self.dimtext(" (on current branch)")
|
||||
self.out.nl()
|
||||
|
||||
for c in localCommits:
|
||||
split = c.split()
|
||||
self.sha(split[0] + " ")
|
||||
self.text(" ".join(split[1:]))
|
||||
self.out.nl()
|
||||
|
||||
self.printSeparator()
|
||||
|
||||
self.heading("Remote Commits: ")
|
||||
self.redtext(str(len(originCommits)))
|
||||
self.out.nl()
|
||||
|
||||
for c in originCommits:
|
||||
split = c.split()
|
||||
self.sha(split[0] + " ")
|
||||
self.text(" ".join(split[1:]))
|
||||
self.out.nl()
|
||||
for b in br:
|
||||
branches.append(
|
||||
BranchInfo(
|
||||
relpath=project.RelPath(local=opt.this_manifest_only),
|
||||
name=b.name,
|
||||
commits=b.commits,
|
||||
date=b.date,
|
||||
is_current=b.name == project.CurrentBranch,
|
||||
)
|
||||
)
|
||||
return branches
|
||||
|
||||
def _printCommitOverview(self, opt, args):
|
||||
projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
|
||||
|
||||
all_branches = []
|
||||
for project in self.GetProjects(
|
||||
args, all_manifests=not opt.this_manifest_only
|
||||
):
|
||||
br = [project.GetUploadableBranch(x) for x in project.GetBranches()]
|
||||
br = [x for x in br if x]
|
||||
if self.opt.current_branch:
|
||||
br = [x for x in br if x.name == project.CurrentBranch]
|
||||
all_branches.extend(br)
|
||||
|
||||
def _ProcessResults(_pool, _output, results):
|
||||
for branches in results:
|
||||
all_branches.extend(branches)
|
||||
|
||||
with self.ParallelContext():
|
||||
self.get_parallel_context()["projects"] = projs
|
||||
|
||||
self.ExecuteInParallel(
|
||||
opt.jobs,
|
||||
functools.partial(self._OverviewHelper, opt=opt),
|
||||
range(len(projs)),
|
||||
callback=_ProcessResults,
|
||||
ordered=True,
|
||||
chunksize=1,
|
||||
)
|
||||
|
||||
if not all_branches:
|
||||
return
|
||||
|
||||
self.out.nl()
|
||||
self.heading("Projects Overview")
|
||||
project = None
|
||||
current_relpath = None
|
||||
|
||||
for branch in all_branches:
|
||||
if project != branch.project:
|
||||
project = branch.project
|
||||
if current_relpath != branch.relpath:
|
||||
current_relpath = branch.relpath
|
||||
self.out.nl()
|
||||
self.headtext(project.RelPath(local=opt.this_manifest_only))
|
||||
self.headtext(current_relpath)
|
||||
self.out.nl()
|
||||
|
||||
commits = branch.commits
|
||||
@@ -242,7 +440,7 @@ class Info(PagedCommand):
|
||||
self.text(
|
||||
"%s %-33s (%2d commit%s, %s)"
|
||||
% (
|
||||
branch.name == project.CurrentBranch and "*" or " ",
|
||||
branch.is_current and "*" or " ",
|
||||
branch.name,
|
||||
len(commits),
|
||||
len(commits) != 1 and "s" or "",
|
||||
|
||||
@@ -104,6 +104,7 @@ revision specified in the manifest.
|
||||
|
||||
def Execute(self, opt, args):
|
||||
nb = args[0]
|
||||
self.TryOverrideManifestWithSmartSync()
|
||||
err_projects = []
|
||||
err = []
|
||||
projects = []
|
||||
|
||||
+129
-23
@@ -64,6 +64,7 @@ from error import SyncError
|
||||
from error import UpdateManifestError
|
||||
import event_log
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
from git_config import GetUrlCookieFile
|
||||
from git_refs import HEAD
|
||||
from git_refs import R_HEADS
|
||||
@@ -565,6 +566,11 @@ later is required to fix a server side protocol bug.
|
||||
dest="use_superproject",
|
||||
help="disable use of manifest superprojects",
|
||||
)
|
||||
p.add_option(
|
||||
"--superproject-revision",
|
||||
action="store",
|
||||
help="sync to superproject revision (applies to outer manifest)",
|
||||
)
|
||||
p.add_option("--tags", action="store_true", help="fetch tags")
|
||||
p.add_option(
|
||||
"--no-tags",
|
||||
@@ -668,6 +674,24 @@ later is required to fix a server side protocol bug.
|
||||
or opt.current_branch_only
|
||||
)
|
||||
|
||||
def _ConfigureSuperproject(
|
||||
self,
|
||||
opt: optparse.Values,
|
||||
manifest,
|
||||
revision: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Configure superproject with options."""
|
||||
if not manifest.superproject:
|
||||
return False
|
||||
manifest.superproject.SetQuiet(not opt.verbose)
|
||||
print_messages = git_superproject.PrintMessages(
|
||||
opt.use_superproject, manifest
|
||||
)
|
||||
manifest.superproject.SetPrintMessages(print_messages)
|
||||
if revision:
|
||||
manifest.superproject.SetRevisionId(revision)
|
||||
return print_messages
|
||||
|
||||
def _UpdateProjectsRevisionId(
|
||||
self, opt, args, superproject_logging_data, manifest
|
||||
):
|
||||
@@ -741,11 +765,7 @@ later is required to fix a server side protocol bug.
|
||||
|
||||
if not use_super:
|
||||
continue
|
||||
m.superproject.SetQuiet(not opt.verbose)
|
||||
print_messages = git_superproject.PrintMessages(
|
||||
opt.use_superproject, m
|
||||
)
|
||||
m.superproject.SetPrintMessages(print_messages)
|
||||
print_messages = self._ConfigureSuperproject(opt, m)
|
||||
update_result = m.superproject.UpdateProjectsRevisionId(
|
||||
per_manifest[m.path_prefix], git_event_log=self.git_event_log
|
||||
)
|
||||
@@ -841,6 +861,8 @@ later is required to fix a server side protocol bug.
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
logger.error("Keyboard interrupt while processing %s", project.name)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
logger.error("error.GitError: Cannot fetch %s", e)
|
||||
errors.append(e)
|
||||
@@ -1104,6 +1126,8 @@ later is required to fix a server side protocol bug.
|
||||
errors.extend(syncbuf.errors)
|
||||
except KeyboardInterrupt:
|
||||
logger.error("Keyboard interrupt while processing %s", project.name)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
logger.error(
|
||||
"error.GitError: Cannot checkout %s: %s", project.name, e
|
||||
@@ -1459,7 +1483,11 @@ later is required to fix a server side protocol bug.
|
||||
if not git_require((2, 23, 0)):
|
||||
return
|
||||
|
||||
projects = [p for p in projects if p.clone_depth]
|
||||
projects = [
|
||||
p
|
||||
for p in projects
|
||||
if p.clone_depth and not p.stateless_prune_needed
|
||||
]
|
||||
if not projects:
|
||||
return
|
||||
|
||||
@@ -1623,9 +1651,10 @@ later is required to fix a server side protocol bug.
|
||||
new_paths = {}
|
||||
new_linkfile_paths = []
|
||||
new_copyfile_paths = []
|
||||
for project in self.GetProjects(
|
||||
projects = self.GetProjects(
|
||||
None, missing_ok=True, manifest=manifest, all_manifests=False
|
||||
):
|
||||
)
|
||||
for project in projects:
|
||||
new_linkfile_paths.extend(x.dest for x in project.linkfiles)
|
||||
new_copyfile_paths.extend(x.dest for x in project.copyfiles)
|
||||
|
||||
@@ -1661,17 +1690,36 @@ later is required to fix a server side protocol bug.
|
||||
)
|
||||
|
||||
for need_remove_file in need_remove_files:
|
||||
# Try to remove the updated copyfile or linkfile.
|
||||
# So, if the file is not exist, nothing need to do.
|
||||
platform_utils.remove(
|
||||
os.path.join(self.client.topdir, need_remove_file),
|
||||
missing_ok=True,
|
||||
need_remove_path = os.path.join(
|
||||
self.client.topdir, need_remove_file
|
||||
)
|
||||
if os.path.isfile(need_remove_path):
|
||||
platform_utils.remove(need_remove_path)
|
||||
else:
|
||||
platform_utils.removedirs(need_remove_path)
|
||||
|
||||
# Also try to remove empty parent directories.
|
||||
parent = os.path.dirname(need_remove_path)
|
||||
while parent != self.client.topdir:
|
||||
try:
|
||||
os.rmdir(parent)
|
||||
except OSError:
|
||||
break
|
||||
parent = os.path.dirname(parent)
|
||||
|
||||
# Create copy-link-files.json, save dest path of "copyfile" and
|
||||
# "linkfile".
|
||||
with open(copylinkfile_path, "w", encoding="utf-8") as fp:
|
||||
json.dump(new_paths, fp)
|
||||
|
||||
# Retry linkfile/copyfile creation for all projects. In
|
||||
# interleaved sync mode, _CopyAndLinkFiles runs before this
|
||||
# cleanup, so linkfiles whose dest was blocked by an old
|
||||
# directory may have failed. _CopyAndLinkFiles is idempotent
|
||||
# and skips dests that are already correct.
|
||||
for project in projects:
|
||||
project._CopyAndLinkFiles()
|
||||
|
||||
return True
|
||||
|
||||
def _SmartSyncSetup(self, opt, smart_sync_manifest_path, manifest):
|
||||
@@ -1804,7 +1852,11 @@ later is required to fix a server side protocol bug.
|
||||
mp: the manifestProject to query.
|
||||
manifest_name: Manifest file to be reloaded.
|
||||
"""
|
||||
if not mp.standalone_manifest_url:
|
||||
if opt.superproject_revision and mp.manifest == self.outer_manifest:
|
||||
self._SyncToSuperprojectRev(
|
||||
opt, mp.manifest, mp, manifest_name, errors
|
||||
)
|
||||
elif not mp.standalone_manifest_url:
|
||||
self._UpdateManifestProject(opt, mp, manifest_name, errors)
|
||||
|
||||
if mp.manifest.submanifests:
|
||||
@@ -1986,17 +2038,19 @@ later is required to fix a server side protocol bug.
|
||||
|
||||
def Execute(self, opt, args):
|
||||
errors = []
|
||||
start_time = time.time()
|
||||
try:
|
||||
self._ExecuteHelper(opt, args, errors)
|
||||
sync_duration_seconds = time.time() - start_time
|
||||
except (RepoExitError, RepoChangedException):
|
||||
raise
|
||||
except (KeyboardInterrupt, Exception) as e:
|
||||
raise RepoUnhandledExceptionError(e, aggregate_errors=errors)
|
||||
|
||||
# Run post-sync hook only after successful sync
|
||||
self._RunPostSyncHook(opt)
|
||||
self._RunPostSyncHook(opt, sync_duration_seconds=sync_duration_seconds)
|
||||
|
||||
def _RunPostSyncHook(self, opt):
|
||||
def _RunPostSyncHook(self, opt, sync_duration_seconds=None):
|
||||
"""Run post-sync hook if configured in manifest <repo-hooks>."""
|
||||
hook = RepoHook.FromSubcmd(
|
||||
hook_type="post-sync",
|
||||
@@ -2004,10 +2058,60 @@ later is required to fix a server side protocol bug.
|
||||
opt=opt,
|
||||
abort_if_user_denies=False,
|
||||
)
|
||||
success = hook.Run(repo_topdir=self.client.topdir)
|
||||
success = hook.Run(
|
||||
repo_topdir=self.client.topdir,
|
||||
sync_duration_seconds=sync_duration_seconds,
|
||||
)
|
||||
if not success:
|
||||
print("Warning: post-sync hook reported failure.")
|
||||
|
||||
def _SyncToSuperprojectRev(
|
||||
self,
|
||||
opt: optparse.Values,
|
||||
manifest,
|
||||
mp: Project,
|
||||
manifest_name: Optional[str],
|
||||
errors: List[Exception],
|
||||
) -> None:
|
||||
"""Sync to a specific superproject commit."""
|
||||
if not manifest.superproject:
|
||||
raise SyncError("superproject not defined in manifest")
|
||||
|
||||
self._ConfigureSuperproject(
|
||||
opt, manifest, revision=opt.superproject_revision
|
||||
)
|
||||
|
||||
sync_result = manifest.superproject.Sync(self.git_event_log)
|
||||
if not sync_result.success:
|
||||
raise SyncError("failed to sync superproject")
|
||||
|
||||
cmd = ["show", f"{opt.superproject_revision}:.supermanifest"]
|
||||
p = GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
gitdir=manifest.superproject._work_git,
|
||||
bare=True,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
if p.Wait() != 0:
|
||||
raise SyncError(
|
||||
f"failed to read .supermanifest from superproject: {p.stderr}"
|
||||
)
|
||||
|
||||
try:
|
||||
_, _, manifest_commit = p.stdout.strip().split()
|
||||
except ValueError:
|
||||
raise SyncError("could not parse .supermanifest")
|
||||
|
||||
mp.SetRevision(manifest_commit)
|
||||
try:
|
||||
self._UpdateManifestProject(opt, mp, manifest_name, errors)
|
||||
except UpdateManifestError as e:
|
||||
raise SyncError(
|
||||
"failed to sync manifest project", aggregate_errors=[e]
|
||||
)
|
||||
|
||||
def _ExecuteHelper(self, opt, args, errors):
|
||||
manifest = self.outer_manifest
|
||||
if not opt.outer_manifest:
|
||||
@@ -2017,9 +2121,7 @@ later is required to fix a server side protocol bug.
|
||||
manifest.Override(opt.manifest_name)
|
||||
|
||||
manifest_name = opt.manifest_name
|
||||
smart_sync_manifest_path = os.path.join(
|
||||
manifest.manifestProject.worktree, "smart_sync_override.xml"
|
||||
)
|
||||
smart_sync_manifest_path = self.GetSmartSyncOverridePath(manifest)
|
||||
|
||||
if opt.clone_bundle is None:
|
||||
opt.clone_bundle = manifest.CloneBundle
|
||||
@@ -2068,7 +2170,7 @@ later is required to fix a server side protocol bug.
|
||||
):
|
||||
mp.ConfigureCloneFilterForDepth("blob:none")
|
||||
|
||||
if opt.mp_update:
|
||||
if opt.mp_update or opt.superproject_revision:
|
||||
self._UpdateAllManifestProjects(opt, mp, manifest_name, errors)
|
||||
else:
|
||||
print("Skipping update of local manifest project.")
|
||||
@@ -2146,8 +2248,8 @@ later is required to fix a server side protocol bug.
|
||||
for project_name in sorted(self._bloated_projects):
|
||||
warn_msg = (
|
||||
f'warning: Project "{project_name}" is accumulating '
|
||||
'unoptimized data. Please run "repo sync --auto-gc" or '
|
||||
'"repo gc --repack" to clean up.'
|
||||
'unoptimized data. Please run "git repack -a -d" in the '
|
||||
'project directory or "repo gc --repack" to clean up.'
|
||||
)
|
||||
self.git_event_log.ErrorEvent(warn_msg)
|
||||
logger.warning(warn_msg)
|
||||
@@ -2401,6 +2503,8 @@ later is required to fix a server side protocol bug.
|
||||
logger.error(
|
||||
"Keyboard interrupt while processing %s", project.name
|
||||
)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
fetch_errors.append(e)
|
||||
logger.error("error.GitError: Cannot fetch %s", e)
|
||||
@@ -2451,6 +2555,8 @@ later is required to fix a server side protocol bug.
|
||||
logger.error(
|
||||
"Keyboard interrupt while processing %s", project.name
|
||||
)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
checkout_errors.append(e)
|
||||
logger.error(
|
||||
|
||||
@@ -18,6 +18,7 @@ import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
import color
|
||||
import platform_utils
|
||||
import repo_trace
|
||||
|
||||
@@ -28,6 +29,16 @@ def disable_repo_trace(tmp_path):
|
||||
repo_trace._TRACE_FILE = str(tmp_path / "TRACE_FILE_from_test")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_default_coloring() -> None:
|
||||
"""Force disable color for reproducible test behavior.
|
||||
|
||||
The few tests that cover color behavior can still reset/change the color as
|
||||
needed.
|
||||
"""
|
||||
color.SetDefaultColoring("never")
|
||||
|
||||
|
||||
# adapted from pytest-home 0.5.1
|
||||
def _set_home(monkeypatch, path: pathlib.Path):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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 command.py module."""
|
||||
|
||||
from command import Command
|
||||
|
||||
|
||||
class FakeProject:
|
||||
"""Minimal project double for Command.GetProjects tests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
relpath,
|
||||
*,
|
||||
gitdir=None,
|
||||
derived_subprojects=None,
|
||||
sync_s=False,
|
||||
):
|
||||
self.name = name
|
||||
self.relpath = relpath
|
||||
self.gitdir = gitdir or f"/git/{relpath}"
|
||||
self.sync_s = sync_s
|
||||
self.Exists = True
|
||||
self._derived_subprojects = derived_subprojects or []
|
||||
|
||||
def GetDerivedSubprojects(self):
|
||||
return list(self._derived_subprojects)
|
||||
|
||||
def MatchesGroups(self, _groups):
|
||||
return True
|
||||
|
||||
def RelPath(self, local=True):
|
||||
return self.relpath
|
||||
|
||||
|
||||
class FakeManifest:
|
||||
"""Minimal manifest double for Command.GetProjects tests."""
|
||||
|
||||
def __init__(self, projects):
|
||||
self.projects = projects
|
||||
|
||||
def GetManifestGroupsStr(self):
|
||||
return "default"
|
||||
|
||||
|
||||
def test_get_projects_keeps_derived_subprojects_for_repeated_repo():
|
||||
"""Derived subprojects are keyed by checkout path, not repo identity."""
|
||||
submodule_a = FakeProject(
|
||||
"submodule",
|
||||
"src/one/submodule",
|
||||
gitdir="/shared/modules/submodule.git",
|
||||
)
|
||||
submodule_b = FakeProject(
|
||||
"submodule",
|
||||
"src/two/submodule",
|
||||
gitdir="/shared/modules/submodule.git",
|
||||
)
|
||||
project_a = FakeProject(
|
||||
"project",
|
||||
"src/one",
|
||||
derived_subprojects=[submodule_a],
|
||||
sync_s=True,
|
||||
)
|
||||
project_b = FakeProject(
|
||||
"project",
|
||||
"src/two",
|
||||
derived_subprojects=[submodule_b],
|
||||
sync_s=True,
|
||||
)
|
||||
manifest = FakeManifest([project_a, project_b])
|
||||
cmd = Command(manifest=manifest)
|
||||
|
||||
projects = cmd.GetProjects([])
|
||||
|
||||
assert set(projects) == {project_a, project_b, submodule_a, submodule_b}
|
||||
@@ -226,3 +226,21 @@ def test_get_sync_analysis_state_data(rw_config_file: Path) -> None:
|
||||
for key, value in TESTS:
|
||||
assert sync_data[f"{git_config.SYNC_STATE_PREFIX}{key}"] == value
|
||||
assert sync_data[f"{git_config.SYNC_STATE_PREFIX}main.synctime"]
|
||||
|
||||
|
||||
def test_remote_save_with_push_url_without_projectname(
|
||||
rw_config_file: Path,
|
||||
) -> None:
|
||||
"""Test saving a remote pushUrl when projectname is unset."""
|
||||
config = git_config.GitConfig(str(rw_config_file))
|
||||
remote = config.GetRemote("origin")
|
||||
remote.url = "https://example.com/repo"
|
||||
remote.pushUrl = "ssh://example.com"
|
||||
remote.projectname = None
|
||||
|
||||
remote.Save()
|
||||
|
||||
written_config = git_config.GitConfig(str(rw_config_file))
|
||||
assert (
|
||||
written_config.GetString("remote.origin.pushurl") == "ssh://example.com"
|
||||
)
|
||||
|
||||
@@ -190,7 +190,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
),
|
||||
revision="refs/heads/main",
|
||||
)
|
||||
with mock.patch.object(self._superproject, "_branch", "junk"):
|
||||
with mock.patch.object(self._superproject, "revision", "junk"):
|
||||
sync_result = self._superproject.Sync(self.git_event_log)
|
||||
self.assertFalse(sync_result.success)
|
||||
self.assertTrue(sync_result.fatal)
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
|
||||
"""Unittests for the hooks.py module."""
|
||||
|
||||
from io import StringIO
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import hooks
|
||||
@@ -58,3 +61,47 @@ def test_direct_interp(shebang: str, interp: str) -> None:
|
||||
def test_env_interp(shebang: str, interp: str) -> None:
|
||||
"""Lines whose shebang launches through `env`."""
|
||||
assert hooks.RepoHook._ExtractInterpFromShebang(shebang) == interp
|
||||
|
||||
|
||||
def test_post_sync_argument_validation() -> None:
|
||||
"""Test that post-sync hook requires exact API arguments."""
|
||||
|
||||
class FakeProject:
|
||||
|
||||
def __init__(self):
|
||||
self.worktree = "/some/path"
|
||||
self.enabled_repo_hooks = ["post-sync"]
|
||||
|
||||
hook = hooks.RepoHook(
|
||||
hook_type="post-sync",
|
||||
hooks_project=FakeProject(),
|
||||
repo_topdir="/topdir",
|
||||
manifest_url="https://gerrit",
|
||||
allow_all_hooks=True,
|
||||
)
|
||||
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = StringIO()
|
||||
|
||||
try:
|
||||
# Call with missing arg `sync_duration_seconds`
|
||||
res = hook.Run(repo_topdir="/topdir")
|
||||
assert res is False
|
||||
assert "hook 'post-sync' called incorrectly" in sys.stderr.getvalue()
|
||||
|
||||
# Mock _CheckHook and _ExecuteHook to test success path
|
||||
hook._CheckHook = lambda: None
|
||||
|
||||
executed_kwargs = {}
|
||||
|
||||
def fake_execute(**kw):
|
||||
executed_kwargs.update(kw)
|
||||
|
||||
hook._ExecuteHook = fake_execute
|
||||
|
||||
res = hook.Run(repo_topdir="/topdir", sync_duration_seconds=12.345)
|
||||
assert res is True
|
||||
assert executed_kwargs.get("sync_duration_seconds") == 12.345
|
||||
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
@@ -46,3 +46,71 @@ def test_remove_missing_ok(tmp_path: Path) -> None:
|
||||
path.touch()
|
||||
platform_utils.remove(path, missing_ok=False)
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_removedirs_nonexistent(tmp_path: Path) -> None:
|
||||
"""removedirs should silently succeed on nonexistent paths."""
|
||||
platform_utils.removedirs(tmp_path / "does-not-exist")
|
||||
|
||||
|
||||
def test_removedirs_symlink(tmp_path: Path) -> None:
|
||||
"""removedirs should remove a symlink."""
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to("target")
|
||||
platform_utils.removedirs(link)
|
||||
assert not link.exists()
|
||||
|
||||
|
||||
def test_removedirs_empty_dir(tmp_path: Path) -> None:
|
||||
"""removedirs should remove an empty directory."""
|
||||
d = tmp_path / "empty"
|
||||
d.mkdir()
|
||||
platform_utils.removedirs(d)
|
||||
assert not d.exists()
|
||||
|
||||
|
||||
def test_removedirs_nested_empty_dirs(tmp_path: Path) -> None:
|
||||
"""removedirs should remove nested empty directories."""
|
||||
d = tmp_path / "a" / "b" / "c"
|
||||
d.mkdir(parents=True)
|
||||
platform_utils.removedirs(tmp_path / "a")
|
||||
assert not (tmp_path / "a").exists()
|
||||
|
||||
|
||||
def test_removedirs_symlinks_inside_dir(tmp_path: Path) -> None:
|
||||
"""removedirs should remove symlinks inside a directory."""
|
||||
d = tmp_path / "dir"
|
||||
d.mkdir()
|
||||
(d / "link1").symlink_to("target1")
|
||||
(d / "link2").symlink_to("target2")
|
||||
platform_utils.removedirs(d)
|
||||
assert not d.exists()
|
||||
|
||||
|
||||
def test_removedirs_preserves_user_files(tmp_path: Path) -> None:
|
||||
"""removedirs should not delete regular files or their parent dirs."""
|
||||
d = tmp_path / "dir"
|
||||
d.mkdir()
|
||||
(d / "link").symlink_to("target")
|
||||
(d / "user-file.txt").write_text("keep me")
|
||||
platform_utils.removedirs(d)
|
||||
assert d.exists()
|
||||
assert not (d / "link").exists()
|
||||
assert (d / "user-file.txt").read_text() == "keep me"
|
||||
|
||||
|
||||
def test_removedirs_deep_nested_with_symlinks(tmp_path: Path) -> None:
|
||||
"""removedirs should handle deep nesting: sub/dir/target."""
|
||||
d = tmp_path / "sub" / "dir"
|
||||
d.mkdir(parents=True)
|
||||
(d / "link").symlink_to("target")
|
||||
platform_utils.removedirs(tmp_path / "sub")
|
||||
assert not (tmp_path / "sub").exists()
|
||||
|
||||
|
||||
def test_removedirs_regular_file_noop(tmp_path: Path) -> None:
|
||||
"""removedirs should not delete a regular file."""
|
||||
f = tmp_path / "file.txt"
|
||||
f.write_text("data")
|
||||
platform_utils.removedirs(f)
|
||||
assert f.exists()
|
||||
|
||||
@@ -127,6 +127,88 @@ class ProjectTests(unittest.TestCase):
|
||||
).strip()
|
||||
self.assertEqual(expected, fakeproj.work_git.GetHead())
|
||||
|
||||
def _get_derived_subproject_url(self, submodule_url):
|
||||
with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
|
||||
|
||||
class FakeManifest:
|
||||
def __init__(self, topdir):
|
||||
self.topdir = topdir
|
||||
self.globalConfig = None
|
||||
self.is_multimanifest = False
|
||||
self.path_prefix = ""
|
||||
self.paths = {}
|
||||
|
||||
def GetSubprojectName(self, parent, path):
|
||||
return path
|
||||
|
||||
def GetSubprojectPaths(self, parent, name, path):
|
||||
relpath = path
|
||||
worktree = os.path.join(self.topdir, path)
|
||||
gitdir = os.path.join(self.topdir, f"{path}.git")
|
||||
objdir = os.path.join(self.topdir, f"{path}.obj")
|
||||
os.makedirs(worktree, exist_ok=True)
|
||||
os.makedirs(gitdir, exist_ok=True)
|
||||
os.makedirs(objdir, exist_ok=True)
|
||||
return relpath, worktree, gitdir, objdir
|
||||
|
||||
manifest = FakeManifest(tempdir)
|
||||
worktree = os.path.join(tempdir, "parent")
|
||||
gitdir = os.path.join(tempdir, "parent.git")
|
||||
objdir = os.path.join(tempdir, "parent.obj")
|
||||
os.makedirs(worktree)
|
||||
os.makedirs(gitdir)
|
||||
os.makedirs(objdir)
|
||||
|
||||
parent = project.Project(
|
||||
manifest=manifest,
|
||||
name="parent",
|
||||
remote=project.RemoteSpec(
|
||||
"origin", url="https://example.com/platform/superproject"
|
||||
),
|
||||
gitdir=gitdir,
|
||||
objdir=objdir,
|
||||
worktree=worktree,
|
||||
relpath="parent",
|
||||
revisionExpr="refs/heads/main",
|
||||
revisionId=None,
|
||||
)
|
||||
|
||||
def fake_get_submodules(current):
|
||||
if current is parent:
|
||||
return [("subrev", "child", submodule_url, "false")]
|
||||
return []
|
||||
|
||||
with mock.patch.object(
|
||||
project.Project, "_GetSubmodules", autospec=True
|
||||
) as get_submodules:
|
||||
get_submodules.side_effect = fake_get_submodules
|
||||
result = parent.GetDerivedSubprojects()
|
||||
|
||||
self.assertEqual(1, len(result))
|
||||
return result[0].remote.url
|
||||
|
||||
def test_derived_subproject_joins_only_git_relative_urls(self):
|
||||
tests = (
|
||||
(
|
||||
"./submodule",
|
||||
"https://example.com/platform/superproject/submodule",
|
||||
),
|
||||
("../sibling", "https://example.com/platform/sibling"),
|
||||
)
|
||||
for submodule_url, expected in tests:
|
||||
with self.subTest(submodule_url=submodule_url):
|
||||
self.assertEqual(
|
||||
expected, self._get_derived_subproject_url(submodule_url)
|
||||
)
|
||||
|
||||
def test_derived_subproject_leaves_dot_prefixed_names_unchanged(self):
|
||||
for submodule_url in (".foo", "..bar"):
|
||||
with self.subTest(submodule_url=submodule_url):
|
||||
self.assertEqual(
|
||||
submodule_url,
|
||||
self._get_derived_subproject_url(submodule_url),
|
||||
)
|
||||
|
||||
|
||||
class CopyLinkTestCase(unittest.TestCase):
|
||||
"""TestCase for stub repo client checkouts.
|
||||
@@ -365,6 +447,47 @@ class LinkFile(CopyLinkTestCase):
|
||||
os.path.join("git-project", "foo.txt"), os.readlink(dest)
|
||||
)
|
||||
|
||||
def test_replace_empty_dir_with_symlink(self):
|
||||
"""A linkfile should replace an empty real directory at the dest path.
|
||||
|
||||
This is the common case: the old linkfiles inside the directory were
|
||||
already cleaned up by UpdateCopyLinkfileList, leaving an empty parent
|
||||
directory behind.
|
||||
"""
|
||||
src_dir = os.path.join(self.worktree, "dot-llms")
|
||||
os.makedirs(src_dir)
|
||||
|
||||
dest = os.path.join(self.topdir, "mydir")
|
||||
os.makedirs(dest)
|
||||
|
||||
lf = self.LinkFile("dot-llms", "mydir")
|
||||
lf._Link()
|
||||
self.assertTrue(os.path.islink(dest))
|
||||
self.assertEqual(
|
||||
os.path.join("git-project", "dot-llms"), os.readlink(dest)
|
||||
)
|
||||
|
||||
def test_nonempty_dir_not_clobbered(self):
|
||||
"""A linkfile must not delete a non-empty directory.
|
||||
|
||||
If the user created files in a directory that a new linkfile wants
|
||||
to replace, __linkIt should fail safely rather than deleting content.
|
||||
"""
|
||||
src_dir = os.path.join(self.worktree, "dot-llms")
|
||||
os.makedirs(src_dir)
|
||||
|
||||
dest = os.path.join(self.topdir, "mydir")
|
||||
os.makedirs(dest)
|
||||
user_file = os.path.join(dest, "user-notes.txt")
|
||||
self.touch(user_file)
|
||||
|
||||
lf = self.LinkFile("dot-llms", "mydir")
|
||||
lf._Link()
|
||||
# The directory should NOT be replaced — user content is preserved.
|
||||
self.assertFalse(os.path.islink(dest))
|
||||
self.assertTrue(os.path.isdir(dest))
|
||||
self.assertTrue(os.path.exists(user_file))
|
||||
|
||||
|
||||
class MigrateWorkTreeTests(unittest.TestCase):
|
||||
"""Check _MigrateOldWorkTreeGitDir handling."""
|
||||
@@ -663,6 +786,36 @@ class StatelessSyncTests(unittest.TestCase):
|
||||
capture_stderr=True,
|
||||
)
|
||||
|
||||
def test_sync_local_half_no_upstream_propagates_force_checkout(self):
|
||||
"""Test Sync_LocalHalf forwards force_checkout when detaching."""
|
||||
with utils_for_test.TempGitTree() as tempdir:
|
||||
proj = self._get_project(tempdir)
|
||||
|
||||
proj._InitWorkTree = mock.MagicMock()
|
||||
proj.CleanPublishedCache = mock.MagicMock()
|
||||
proj.GetRevisionId = mock.MagicMock(return_value="1234abcd")
|
||||
proj._Checkout = mock.MagicMock()
|
||||
proj._CopyAndLinkFiles = mock.MagicMock()
|
||||
|
||||
proj.work_git = mock.MagicMock()
|
||||
proj.work_git.GetHead.return_value = "refs/heads/topic"
|
||||
|
||||
proj.bare_ref = mock.MagicMock()
|
||||
proj.bare_ref.all = {"refs/heads/topic": "5678abcd"}
|
||||
|
||||
branch = mock.MagicMock()
|
||||
branch.name = "topic"
|
||||
branch.LocalMerge = False
|
||||
proj.GetBranch = mock.MagicMock(return_value=branch)
|
||||
|
||||
syncbuf = project.SyncBuffer(proj.config)
|
||||
proj.Sync_LocalHalf(syncbuf, force_checkout=True)
|
||||
|
||||
proj._Checkout.assert_called_once_with(
|
||||
"1234abcd", force_checkout=True, quiet=True
|
||||
)
|
||||
proj._CopyAndLinkFiles.assert_called_once_with()
|
||||
|
||||
def test_sync_network_half_stateless_skips_if_stash(self):
|
||||
"""Test stateless sync skips if stash exists."""
|
||||
with utils_for_test.TempGitTree() as tempdir:
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unittests for the subcmds/info.py module."""
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from subcmds import info
|
||||
|
||||
|
||||
def _get_cmd() -> info.Info:
|
||||
"""Build a mock-backed Info command for testing."""
|
||||
manifest = mock.MagicMock()
|
||||
manifest.default.revisionExpr = "refs/heads/main"
|
||||
manifest.manifestProject.config.GetBranch.return_value.merge = (
|
||||
"refs/heads/main"
|
||||
)
|
||||
manifest.GetManifestGroupsStr.return_value = "all"
|
||||
manifest.superproject = None
|
||||
manifest.outer_client = manifest
|
||||
|
||||
client = mock.MagicMock()
|
||||
git_event_log = mock.MagicMock()
|
||||
|
||||
return info.Info(
|
||||
manifest=manifest,
|
||||
client=client,
|
||||
git_event_log=git_event_log,
|
||||
)
|
||||
|
||||
|
||||
def test_include_options_default_true() -> None:
|
||||
"""Both include options should default to True."""
|
||||
opts, _ = _get_cmd().OptionParser.parse_args([])
|
||||
assert opts.include_summary
|
||||
assert opts.include_projects
|
||||
|
||||
|
||||
def test_no_include_summary_parses() -> None:
|
||||
"""--no-include-summary should set include_summary to False."""
|
||||
opts, _ = _get_cmd().OptionParser.parse_args(["--no-include-summary"])
|
||||
assert not opts.include_summary
|
||||
|
||||
|
||||
def test_no_include_projects_parses() -> None:
|
||||
"""--no-include-projects should set include_projects to False."""
|
||||
opts, _ = _get_cmd().OptionParser.parse_args(["--no-include-projects"])
|
||||
assert not opts.include_projects
|
||||
|
||||
|
||||
def test_format_default_text() -> None:
|
||||
"""Default format should be text."""
|
||||
opts, _ = _get_cmd().OptionParser.parse_args([])
|
||||
assert opts.format == "text"
|
||||
|
||||
|
||||
def test_format_json_parses() -> None:
|
||||
"""--format=json should be accepted."""
|
||||
opts, _ = _get_cmd().OptionParser.parse_args(["--format=json"])
|
||||
assert opts.format == "json"
|
||||
|
||||
|
||||
def test_no_include_projects_skips_projects() -> None:
|
||||
"""--no-include-projects should skip project iteration."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(["--no-include-projects"])
|
||||
|
||||
with mock.patch.object(
|
||||
cmd, "_printDiffInfo"
|
||||
) as mock_diff, mock.patch.object(
|
||||
cmd, "_printCommitOverview"
|
||||
) as mock_overview:
|
||||
cmd.Execute(opts, args)
|
||||
mock_diff.assert_not_called()
|
||||
mock_overview.assert_not_called()
|
||||
|
||||
|
||||
def test_no_include_summary_skips_summary() -> None:
|
||||
"""--no-include-summary should not query or print manifest metadata."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(["--no-include-summary"])
|
||||
|
||||
with mock.patch.object(cmd, "_printDiffInfo"):
|
||||
cmd.Execute(opts, args)
|
||||
cmd.manifest.GetManifestGroupsStr.assert_not_called()
|
||||
|
||||
|
||||
def test_default_calls_diff_info() -> None:
|
||||
"""Default options should call _printDiffInfo."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args([])
|
||||
|
||||
with mock.patch.object(
|
||||
cmd, "_printDiffInfo"
|
||||
) as mock_diff, mock.patch.object(
|
||||
cmd, "_printCommitOverview"
|
||||
) as mock_overview:
|
||||
cmd.Execute(opts, args)
|
||||
mock_diff.assert_called_once_with(opts, args)
|
||||
mock_overview.assert_not_called()
|
||||
|
||||
|
||||
def test_overview_calls_commit_overview() -> None:
|
||||
"""--overview should call _printCommitOverview, not _printDiffInfo."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(["--overview"])
|
||||
|
||||
with mock.patch.object(
|
||||
cmd, "_printDiffInfo"
|
||||
) as mock_diff, mock.patch.object(
|
||||
cmd, "_printCommitOverview"
|
||||
) as mock_overview:
|
||||
cmd.Execute(opts, args)
|
||||
mock_diff.assert_not_called()
|
||||
mock_overview.assert_called_once_with(opts, args)
|
||||
|
||||
|
||||
def test_no_include_projects_with_overview() -> None:
|
||||
"""--no-include-projects should take priority over --overview."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(
|
||||
["--no-include-projects", "--overview"]
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
cmd, "_printDiffInfo"
|
||||
) as mock_diff, mock.patch.object(
|
||||
cmd, "_printCommitOverview"
|
||||
) as mock_overview:
|
||||
cmd.Execute(opts, args)
|
||||
mock_diff.assert_not_called()
|
||||
mock_overview.assert_not_called()
|
||||
|
||||
|
||||
def test_json_summary_only(capsys) -> None:
|
||||
"""--format=json --no-include-projects should emit only summary."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(
|
||||
["--format=json", "--no-include-projects"]
|
||||
)
|
||||
cmd.Execute(opts, args)
|
||||
data = json.loads(capsys.readouterr().out)
|
||||
assert "summary" in data
|
||||
assert "projects" not in data
|
||||
assert data["summary"]["manifest_branch"] == "refs/heads/main"
|
||||
assert data["summary"]["manifest_groups"] == "all"
|
||||
|
||||
|
||||
def test_json_no_summary(capsys) -> None:
|
||||
"""--format=json --no-include-summary should omit summary."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(
|
||||
["--format=json", "--no-include-summary", "--no-include-projects"]
|
||||
)
|
||||
cmd.Execute(opts, args)
|
||||
data = json.loads(capsys.readouterr().out)
|
||||
assert "summary" not in data
|
||||
|
||||
|
||||
def test_json_rejects_diff() -> None:
|
||||
"""--format=json --diff should be rejected."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(["--format=json", "--diff"])
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.ValidateOptions(opts, args)
|
||||
|
||||
|
||||
def test_json_rejects_overview() -> None:
|
||||
"""--format=json --overview should be rejected."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(["--format=json", "--overview"])
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.ValidateOptions(opts, args)
|
||||
|
||||
|
||||
def test_json_disables_pager() -> None:
|
||||
"""--format=json should disable the pager."""
|
||||
cmd = _get_cmd()
|
||||
opts, _ = cmd.OptionParser.parse_args(["--format=json"])
|
||||
assert not cmd.WantPager(opts)
|
||||
|
||||
|
||||
def test_text_enables_pager() -> None:
|
||||
"""Default text format should enable the pager."""
|
||||
cmd = _get_cmd()
|
||||
opts, _ = cmd.OptionParser.parse_args([])
|
||||
assert cmd.WantPager(opts)
|
||||
@@ -0,0 +1,220 @@
|
||||
# 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 status subcmd."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import List, Tuple
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
import manifest_xml
|
||||
import subcmds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo_client_checkout(
|
||||
tmp_path: Path,
|
||||
) -> Tuple[Path, manifest_xml.XmlManifest]:
|
||||
"""Create a basic repo client checkout for status tests."""
|
||||
# Create in a subdir to avoid noise (like the repo_trace file).
|
||||
topdir = tmp_path / "client_checkout"
|
||||
repodir = topdir / ".repo"
|
||||
manifest_dir = repodir / "manifests"
|
||||
manifest_file = repodir / manifest_xml.MANIFEST_FILE_NAME
|
||||
|
||||
repodir.mkdir(parents=True)
|
||||
manifest_dir.mkdir()
|
||||
|
||||
gitdir = repodir / "manifests.git"
|
||||
gitdir.mkdir()
|
||||
(gitdir / "config").write_text(
|
||||
"""[remote "origin"]
|
||||
url = https://localhost:0/manifest
|
||||
verbose = false
|
||||
"""
|
||||
)
|
||||
|
||||
_init_temp_git_tree(manifest_dir)
|
||||
|
||||
manifest_file.write_text(
|
||||
"""
|
||||
<manifest>
|
||||
<remote name="origin" fetch="http://localhost" />
|
||||
<default remote="origin" revision="refs/heads/main" />
|
||||
<project name="proj" path="src/proj" />
|
||||
</manifest>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
(repodir / "projects" / "src" / "proj.git").mkdir(parents=True)
|
||||
(repodir / "project-objects" / "proj.git").mkdir(parents=True)
|
||||
|
||||
worktree = topdir / "src" / "proj"
|
||||
worktree.parent.mkdir(parents=True, exist_ok=True)
|
||||
_init_temp_git_tree(worktree)
|
||||
|
||||
manifest = manifest_xml.XmlManifest(str(repodir), str(manifest_file))
|
||||
return topdir, manifest
|
||||
|
||||
|
||||
def _init_temp_git_tree(git_dir: Path) -> None:
|
||||
"""Create a new git checkout with an initial commit for testing."""
|
||||
utils_for_test.init_git_tree(git_dir)
|
||||
(git_dir / "README").write_text("init")
|
||||
subprocess.check_call(["git", "add", "README"], cwd=git_dir)
|
||||
subprocess.check_call(["git", "commit", "-q", "-m", "init"], cwd=git_dir)
|
||||
|
||||
|
||||
def _run_status(manifest: manifest_xml.XmlManifest, argv: List[str]) -> None:
|
||||
"""Run the status subcommand with parsed options against a test manifest."""
|
||||
cmd = subcmds.status.Status()
|
||||
cmd.manifest = manifest
|
||||
cmd.client = mock.MagicMock(globalConfig=manifest.globalConfig)
|
||||
|
||||
opts, args = cmd.OptionParser.parse_args(argv + ["--jobs=1"])
|
||||
cmd.CommonValidateOptions(opts, args)
|
||||
|
||||
cmd.Execute(opts, args)
|
||||
|
||||
|
||||
def _status_lines(output: str) -> List[str]:
|
||||
"""Normalize path separators and split command output into lines."""
|
||||
return output.replace(os.sep, "/").splitlines()
|
||||
|
||||
|
||||
def _assert_project_header(line: str, project_path: str, branch: str) -> None:
|
||||
"""Assert a status project header line for a project and branch."""
|
||||
expected = f"project {(project_path + '/ '):<40}branch {branch}"
|
||||
assert line == expected
|
||||
|
||||
|
||||
def _assert_orphan_block(lines: List[str], expected: List[str]) -> None:
|
||||
"""Assert orphan block header and entries, independent of entry ordering."""
|
||||
assert lines
|
||||
assert lines[0] == ("Objects not within a project (orphans)")
|
||||
orphan_lines = lines[1:]
|
||||
assert len(orphan_lines) == len(expected)
|
||||
assert sorted(orphan_lines) == sorted(expected)
|
||||
|
||||
|
||||
def test_orphans_basic(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify -o output includes project header and orphan block."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
|
||||
(topdir / "src" / "orphan_dir").mkdir(parents=True)
|
||||
(topdir / "orphan.txt").write_text("data")
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, ["-o"])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
_assert_project_header(lines[0], project_path, "main")
|
||||
_assert_orphan_block(
|
||||
lines[1:],
|
||||
[
|
||||
" --\torphan.txt",
|
||||
" --\tsrc/orphan_dir/",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_empty_status_without_orphans(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify clean status without -o prints only the project header line."""
|
||||
_, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 1
|
||||
_assert_project_header(lines[0], project_path, "main")
|
||||
|
||||
|
||||
def test_status_without_orphans(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify modified tracked file appears in status output without -o."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
|
||||
(topdir / project_path / "README").write_text("updated")
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 2
|
||||
_assert_project_header(lines[0], project_path, "main")
|
||||
assert lines[1] == " -m\tREADME"
|
||||
|
||||
|
||||
def test_status_with_orphans_and_modified_file(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify modified-file status plus orphan block."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
|
||||
(topdir / project_path / "README").write_text("updated")
|
||||
(topdir / "src" / "orphan_dir").mkdir(parents=True)
|
||||
(topdir / "orphan.txt").write_text("data")
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, ["-o"])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
_assert_project_header(lines[0], project_path, "main")
|
||||
assert lines[1] == " -m\tREADME"
|
||||
_assert_orphan_block(
|
||||
lines[2:],
|
||||
[
|
||||
" --\torphan.txt",
|
||||
" --\tsrc/orphan_dir/",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_empty_status_after_start_shows_started_branch(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify status shows the started branch name when the tree is clean."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
project_worktree = topdir / project_path
|
||||
started_branch = "topic/test-status-branch"
|
||||
subprocess.check_call(
|
||||
["git", "checkout", "-q", "-b", started_branch], cwd=project_worktree
|
||||
)
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 1
|
||||
_assert_project_header(lines[0], project_path, started_branch)
|
||||
@@ -13,6 +13,7 @@
|
||||
# limitations under the License.
|
||||
"""Unittests for the subcmds/sync.py module."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
@@ -477,6 +478,56 @@ class GetPreciousObjectsState(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class KeyboardInterruptTest(unittest.TestCase):
|
||||
"""Tests for KeyboardInterrupt handling in Sync operations."""
|
||||
|
||||
def setUp(self):
|
||||
self.project = mock.MagicMock(name="project")
|
||||
self.project.name = "project"
|
||||
self.project.relpath = "proj"
|
||||
self.project.manifest.IsArchive = False
|
||||
self.opt = mock.Mock()
|
||||
self.opt.quiet = True
|
||||
self.opt.verbose = False
|
||||
self.opt.tags = False
|
||||
|
||||
self.sync_dict = {}
|
||||
|
||||
self.get_parallel_context_mock = {
|
||||
"projects": [self.project],
|
||||
"sync_dict": self.sync_dict,
|
||||
"ssh_proxy": None,
|
||||
}
|
||||
|
||||
@mock.patch("subcmds.sync.Sync.is_multiprocessing_active")
|
||||
def test_fetch_one_keyboard_interrupt_main_process(self, mock_is_active):
|
||||
"""Test that _FetchOne re-raises KeyboardInterrupt if not worker."""
|
||||
mock_is_active.return_value = False
|
||||
self.project.Sync_NetworkHalf.side_effect = KeyboardInterrupt()
|
||||
|
||||
with mock.patch.object(
|
||||
sync.Sync,
|
||||
"get_parallel_context",
|
||||
return_value=self.get_parallel_context_mock,
|
||||
):
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
sync.Sync._FetchOne(self.opt, 0)
|
||||
|
||||
@mock.patch("subcmds.sync.Sync.is_multiprocessing_active")
|
||||
def test_fetch_one_keyboard_interrupt_worker_process(self, mock_is_active):
|
||||
"""Test that _FetchOne suppresses KeyboardInterrupt in workers."""
|
||||
mock_is_active.return_value = True
|
||||
self.project.Sync_NetworkHalf.side_effect = KeyboardInterrupt()
|
||||
|
||||
with mock.patch.object(
|
||||
sync.Sync,
|
||||
"get_parallel_context",
|
||||
return_value=self.get_parallel_context_mock,
|
||||
):
|
||||
result = sync.Sync._FetchOne(self.opt, 0)
|
||||
self.assertFalse(result.success)
|
||||
|
||||
|
||||
class CheckForBloatedProjects(unittest.TestCase):
|
||||
"""Tests for Sync._CheckForBloatedProjects."""
|
||||
|
||||
@@ -489,6 +540,7 @@ class CheckForBloatedProjects(unittest.TestCase):
|
||||
self.project.name = "project"
|
||||
self.project.Exists = True
|
||||
self.project.worktree = "worktree"
|
||||
self.project.stateless_prune_needed = False
|
||||
self.cmd.git_event_log = mock.MagicMock()
|
||||
self.cmd._bloated_projects = []
|
||||
|
||||
@@ -530,6 +582,21 @@ class CheckForBloatedProjects(unittest.TestCase):
|
||||
|
||||
self.assertEqual(self.cmd._bloated_projects, ["project"])
|
||||
|
||||
@mock.patch("subcmds.sync.git_require")
|
||||
@mock.patch("subcmds.sync.Progress")
|
||||
def test_stateless_prune_excluded(self, mock_progress, mock_git_require):
|
||||
"""Test that projects pruned for stateless sync are excluded."""
|
||||
mock_git_require.return_value = True
|
||||
self.project.stateless_prune_needed = True
|
||||
|
||||
self.cmd.ExecuteInParallel = mock.Mock()
|
||||
|
||||
with mock.patch.object(self.cmd, "ParallelContext"):
|
||||
self.cmd._CheckForBloatedProjects([self.project], self.opt)
|
||||
|
||||
self.assertFalse(self.cmd.ExecuteInParallel.called)
|
||||
self.assertEqual(self.cmd._bloated_projects, [])
|
||||
|
||||
|
||||
class GCProjectsTest(unittest.TestCase):
|
||||
"""Tests for Sync._GCProjects."""
|
||||
@@ -1079,3 +1146,311 @@ class InterleavedSyncTest(unittest.TestCase):
|
||||
self.assertTrue(result.checkout_success)
|
||||
project.Sync_NetworkHalf.assert_called_once()
|
||||
project.Sync_LocalHalf.assert_not_called()
|
||||
|
||||
|
||||
class UpdateCopyLinkfileListTest(unittest.TestCase):
|
||||
"""Tests for Sync.UpdateCopyLinkfileList."""
|
||||
|
||||
def setUp(self):
|
||||
self.tempdirobj = tempfile.TemporaryDirectory(prefix="repo_tests")
|
||||
self.topdir = self.tempdirobj.name
|
||||
self.repodir = os.path.join(self.topdir, ".repo")
|
||||
os.makedirs(self.repodir)
|
||||
|
||||
manifest = mock.MagicMock()
|
||||
manifest.subdir = self.repodir
|
||||
self.manifest = manifest
|
||||
|
||||
git_event_log = mock.MagicMock(ErrorEvent=mock.Mock(return_value=None))
|
||||
self.cmd = sync.Sync(
|
||||
manifest=manifest,
|
||||
outer_client=mock.MagicMock(),
|
||||
git_event_log=git_event_log,
|
||||
)
|
||||
self.cmd.client = mock.MagicMock(topdir=self.topdir)
|
||||
|
||||
def tearDown(self):
|
||||
self.tempdirobj.cleanup()
|
||||
|
||||
def _write_copylinkfile_json(self, data: dict) -> None:
|
||||
path = os.path.join(self.repodir, "copy-link-files.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
def _setup_projects(self, linkfile_dests: list) -> None:
|
||||
project = mock.MagicMock()
|
||||
project.linkfiles = [mock.MagicMock(dest=d) for d in linkfile_dests]
|
||||
project.copyfiles = []
|
||||
mock.patch.object(
|
||||
self.cmd, "GetProjects", return_value=[project]
|
||||
).start()
|
||||
|
||||
def test_removes_old_symlink_dest(self):
|
||||
"""Old linkfile dests that are symlinks should be removed."""
|
||||
old_dest = os.path.join(self.topdir, "old-link")
|
||||
os.symlink("target", old_dest)
|
||||
|
||||
self._write_copylinkfile_json(
|
||||
{"linkfile": ["old-link"], "copyfile": []}
|
||||
)
|
||||
self._setup_projects([])
|
||||
|
||||
self.cmd.UpdateCopyLinkfileList(self.manifest)
|
||||
self.assertFalse(os.path.lexists(old_dest))
|
||||
|
||||
def test_does_not_delete_through_new_symlink(self):
|
||||
"""Old dests that resolve through a new symlink must not delete files.
|
||||
|
||||
When the manifest changes from individual linkfiles inside a directory
|
||||
to a single directory linkfile, and _CopyAndLinkFiles has already
|
||||
created the symlink (interleaved mode), cleanup must not follow the
|
||||
symlink and delete real project files.
|
||||
"""
|
||||
project_dir = os.path.join(self.topdir, "vendor", "tools", "llms")
|
||||
os.makedirs(os.path.join(project_dir, "dot-llms", "rules"))
|
||||
os.makedirs(os.path.join(project_dir, "dot-llms", "skills"))
|
||||
with open(
|
||||
os.path.join(project_dir, "dot-llms", "rules", "basics.md"), "w"
|
||||
) as f:
|
||||
f.write("# basics")
|
||||
with open(
|
||||
os.path.join(project_dir, "dot-llms", "skills", "repo.md"), "w"
|
||||
) as f:
|
||||
f.write("# repo")
|
||||
|
||||
# Simulate interleaved mode: .llms -> vendor/tools/llms/dot-llms.
|
||||
llms_link = os.path.join(self.topdir, ".llms")
|
||||
os.symlink("vendor/tools/llms/dot-llms", llms_link)
|
||||
|
||||
self._write_copylinkfile_json(
|
||||
{"linkfile": [".llms/rules", ".llms/skills"], "copyfile": []}
|
||||
)
|
||||
self._setup_projects([".llms"])
|
||||
|
||||
self.cmd.UpdateCopyLinkfileList(self.manifest)
|
||||
|
||||
# Real project files must still exist.
|
||||
self.assertTrue(
|
||||
os.path.exists(
|
||||
os.path.join(project_dir, "dot-llms", "rules", "basics.md")
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.exists(
|
||||
os.path.join(project_dir, "dot-llms", "skills", "repo.md")
|
||||
),
|
||||
)
|
||||
self.assertTrue(os.path.islink(llms_link))
|
||||
|
||||
def test_cleans_up_empty_parent_dirs(self):
|
||||
"""After removing old dests, empty parent directories are removed."""
|
||||
llms_dir = os.path.join(self.topdir, ".llms")
|
||||
os.makedirs(llms_dir)
|
||||
os.symlink(
|
||||
"../vendor/tools/llms/rules", os.path.join(llms_dir, "rules")
|
||||
)
|
||||
os.symlink(
|
||||
"../vendor/tools/llms/skills",
|
||||
os.path.join(llms_dir, "skills"),
|
||||
)
|
||||
|
||||
self._write_copylinkfile_json(
|
||||
{"linkfile": [".llms/rules", ".llms/skills"], "copyfile": []}
|
||||
)
|
||||
self._setup_projects([".llms"])
|
||||
|
||||
self.cmd.UpdateCopyLinkfileList(self.manifest)
|
||||
|
||||
self.assertFalse(os.path.lexists(os.path.join(llms_dir, "rules")))
|
||||
self.assertFalse(os.path.lexists(os.path.join(llms_dir, "skills")))
|
||||
# Parent directory should be removed since it's now empty.
|
||||
self.assertFalse(os.path.exists(llms_dir))
|
||||
|
||||
def test_preserves_nonempty_parent_dirs(self):
|
||||
"""Non-empty parent directories are preserved after old dest removal."""
|
||||
llms_dir = os.path.join(self.topdir, ".llms")
|
||||
os.makedirs(llms_dir)
|
||||
os.symlink(
|
||||
"../vendor/tools/llms/rules", os.path.join(llms_dir, "rules")
|
||||
)
|
||||
with open(os.path.join(llms_dir, "my-notes.txt"), "w") as f:
|
||||
f.write("user content")
|
||||
|
||||
self._write_copylinkfile_json(
|
||||
{"linkfile": [".llms/rules"], "copyfile": []}
|
||||
)
|
||||
self._setup_projects([".llms"])
|
||||
|
||||
self.cmd.UpdateCopyLinkfileList(self.manifest)
|
||||
|
||||
self.assertFalse(os.path.lexists(os.path.join(llms_dir, "rules")))
|
||||
self.assertTrue(os.path.exists(os.path.join(llms_dir, "my-notes.txt")))
|
||||
self.assertTrue(os.path.isdir(llms_dir))
|
||||
|
||||
|
||||
class SyncToSuperprojectRevTests(unittest.TestCase):
|
||||
"""Tests for Sync._SyncToSuperprojectRev."""
|
||||
|
||||
def setUp(self):
|
||||
self.repodir = tempfile.mkdtemp(".repo")
|
||||
self.manifest = mock.MagicMock(repodir=self.repodir)
|
||||
self.manifest.superproject = mock.MagicMock()
|
||||
self.manifest.path_prefix = ""
|
||||
|
||||
self.mp = mock.MagicMock()
|
||||
self.cmd = sync.Sync(manifest=self.manifest)
|
||||
self.cmd.outer_manifest = self.manifest
|
||||
|
||||
self.opt = mock.Mock()
|
||||
self.opt.verbose = False
|
||||
self.opt.superproject_revision = "deadbeef"
|
||||
self.opt.mp_update = True
|
||||
|
||||
self.errors = []
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.repodir)
|
||||
|
||||
@mock.patch("subcmds.sync.GitCommand")
|
||||
def test_successful_sync(self, mock_git_command):
|
||||
"""Test successful sync to superproject rev."""
|
||||
mock_superproject = self.manifest.superproject
|
||||
mock_superproject.Sync.return_value = mock.Mock(success=True)
|
||||
|
||||
mock_git = mock.Mock()
|
||||
mock_git.Wait.return_value = 0
|
||||
mock_git.stdout = "proj branch manifest_commit_hash\n"
|
||||
mock_git_command.return_value = mock_git
|
||||
|
||||
with mock.patch.object(
|
||||
self.cmd, "_UpdateManifestProject"
|
||||
) as mock_update:
|
||||
self.cmd._SyncToSuperprojectRev(
|
||||
self.opt, self.manifest, self.mp, "name", self.errors
|
||||
)
|
||||
|
||||
mock_superproject.SetRevisionId.assert_called_with("deadbeef")
|
||||
mock_superproject.Sync.assert_called_once()
|
||||
mock_git_command.assert_called_once()
|
||||
self.mp.SetRevision.assert_called_with("manifest_commit_hash")
|
||||
mock_update.assert_called_once()
|
||||
self.assertEqual(self.errors, [])
|
||||
|
||||
@mock.patch("subcmds.sync.GitCommand")
|
||||
def test_parse_error(self, mock_git_command):
|
||||
"""Test error when .supermanifest cannot be parsed."""
|
||||
mock_superproject = self.manifest.superproject
|
||||
mock_superproject.Sync.return_value = mock.Mock(success=True)
|
||||
|
||||
mock_git = mock.Mock()
|
||||
mock_git.Wait.return_value = 0
|
||||
# Invalid format (not 3 parts)
|
||||
mock_git.stdout = "invalid_content\n"
|
||||
mock_git_command.return_value = mock_git
|
||||
|
||||
with self.assertRaises(sync.SyncError) as e:
|
||||
self.cmd._SyncToSuperprojectRev(
|
||||
self.opt, self.manifest, self.mp, "name", self.errors
|
||||
)
|
||||
self.assertIn("could not parse .supermanifest", str(e.exception))
|
||||
|
||||
@mock.patch("subcmds.sync.GitCommand")
|
||||
def test_read_error(self, mock_git_command):
|
||||
"""Test error when reading .supermanifest fails."""
|
||||
mock_superproject = self.manifest.superproject
|
||||
mock_superproject.Sync.return_value = mock.Mock(success=True)
|
||||
|
||||
mock_git = mock.Mock()
|
||||
mock_git.Wait.return_value = 1
|
||||
mock_git.stderr = "git error"
|
||||
mock_git_command.return_value = mock_git
|
||||
|
||||
with self.assertRaises(sync.SyncError) as e:
|
||||
self.cmd._SyncToSuperprojectRev(
|
||||
self.opt, self.manifest, self.mp, "name", self.errors
|
||||
)
|
||||
self.assertIn("failed to read .supermanifest", str(e.exception))
|
||||
|
||||
def test_no_superproject(self):
|
||||
"""Test error when superproject is not defined."""
|
||||
self.manifest.superproject = None
|
||||
|
||||
with self.assertRaises(sync.SyncError) as e:
|
||||
self.cmd._SyncToSuperprojectRev(
|
||||
self.opt, self.manifest, self.mp, "name", self.errors
|
||||
)
|
||||
self.assertIn("superproject not defined", str(e.exception))
|
||||
|
||||
@mock.patch("subcmds.sync.GitCommand")
|
||||
def test_sync_failure(self, mock_git_command):
|
||||
"""Test error when superproject sync fails."""
|
||||
mock_superproject = self.manifest.superproject
|
||||
mock_superproject.Sync.return_value = mock.Mock(success=False)
|
||||
|
||||
with self.assertRaises(sync.SyncError) as e:
|
||||
self.cmd._SyncToSuperprojectRev(
|
||||
self.opt, self.manifest, self.mp, "name", self.errors
|
||||
)
|
||||
self.assertIn("failed to sync superproject", str(e.exception))
|
||||
|
||||
|
||||
class UpdateAllManifestProjectsTests(unittest.TestCase):
|
||||
"""Tests for Sync._UpdateAllManifestProjects."""
|
||||
|
||||
def setUp(self):
|
||||
self.repodir = tempfile.mkdtemp(".repo")
|
||||
self.manifest = mock.MagicMock(repodir=self.repodir)
|
||||
self.manifest.superproject = mock.MagicMock()
|
||||
self.manifest.path_prefix = ""
|
||||
self.manifest.standalone_manifest_url = None
|
||||
self.manifest.submanifests = {}
|
||||
|
||||
self.mp = mock.MagicMock()
|
||||
self.mp.manifest = self.manifest
|
||||
self.mp.standalone_manifest_url = None
|
||||
self.cmd = sync.Sync(manifest=self.manifest)
|
||||
self.cmd.outer_manifest = self.manifest
|
||||
|
||||
self.opt = mock.Mock()
|
||||
self.opt.verbose = False
|
||||
self.opt.superproject_revision = None
|
||||
self.opt.mp_update = True
|
||||
|
||||
self.errors = []
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.repodir)
|
||||
|
||||
def test_superproject_revision_outer_manifest(self):
|
||||
"""Test that _SyncToSuperprojectRev is called for outer manifest."""
|
||||
self.opt.superproject_revision = "deadbeef"
|
||||
|
||||
with mock.patch.object(
|
||||
self.cmd, "_SyncToSuperprojectRev"
|
||||
) as mock_sync_to_rev:
|
||||
self.cmd._UpdateAllManifestProjects(
|
||||
self.opt, self.mp, "name", self.errors
|
||||
)
|
||||
mock_sync_to_rev.assert_called_once_with(
|
||||
self.opt, self.manifest, self.mp, "name", self.errors
|
||||
)
|
||||
|
||||
def test_superproject_revision_submanifest(self):
|
||||
"""Test that _SyncToSuperprojectRev is NOT called for submanifest."""
|
||||
self.opt.superproject_revision = "deadbeef"
|
||||
submanifest = mock.MagicMock()
|
||||
submanifest.path_prefix = "sub/"
|
||||
submanifest.standalone_manifest_url = None
|
||||
self.mp.manifest = submanifest
|
||||
|
||||
with mock.patch.object(
|
||||
self.cmd, "_SyncToSuperprojectRev"
|
||||
) as mock_sync_to_rev:
|
||||
with mock.patch.object(
|
||||
self.cmd, "_UpdateManifestProject"
|
||||
) as mock_update_manifest:
|
||||
self.cmd._UpdateAllManifestProjects(
|
||||
self.opt, self.mp, "name", self.errors
|
||||
)
|
||||
mock_sync_to_rev.assert_not_called()
|
||||
mock_update_manifest.assert_called_once()
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Copyright (C) 2022 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 update_manpages module."""
|
||||
|
||||
from release import update_manpages
|
||||
|
||||
|
||||
def test_replace_regex() -> None:
|
||||
"""Check that replace_regex works."""
|
||||
data = "\n\033[1mSummary\033[m\n"
|
||||
assert update_manpages.replace_regex(data) == "\nSummary\n"
|
||||
Reference in New Issue
Block a user