mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-11-15 16:57:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3656290156 | ||
|
|
b90c7cd8f9 | ||
|
|
c5340585b8 | ||
|
|
86a7bec83d | ||
|
|
338ddb7d83 | ||
|
|
8c316d7c98 | ||
|
|
e3e8e8a3b0 | ||
|
|
cad9f591a4 | ||
|
|
24197b8ae1 | ||
|
|
bfb9869b89 | ||
|
|
887f9a9014 | ||
|
|
dc1811ba23 | ||
|
|
9aac80dafc | ||
|
|
f4ce9572d1 | ||
|
|
cc1f41d782 | ||
|
|
f7d252f5dc | ||
|
|
f95e7cd899 | ||
|
|
2c04b98c60 | ||
|
|
4212588c20 | ||
|
|
be06f8dd01 | ||
|
|
74d887ea86 | ||
|
|
29d644a5c0 | ||
|
|
a93741993b | ||
|
|
9991aa6177 | ||
|
|
d744eb15f3 | ||
|
|
f0124d0fd5 | ||
|
|
46e9866ca0 | ||
|
|
404aec7b53 | ||
|
|
a0bc2b14a3 | ||
|
|
3448df3954 | ||
|
|
a110680ff3 | ||
|
|
178b66ee3e | ||
|
|
beda65323e | ||
|
|
02e07525f9 | ||
|
|
2e8a3805bb | ||
|
|
277759b9e1 | ||
|
|
9593a76fd1 | ||
|
|
9293cda144 | ||
|
|
340b587e94 | ||
|
|
0a4e68f9d2 | ||
|
|
7bba4ee47e | ||
|
|
d53a4278f5 | ||
|
|
5d4149f32e | ||
|
|
cf499e312a | ||
|
|
cc0546bc71 | ||
|
|
ccf9ce39b8 | ||
|
|
9066409c2d | ||
|
|
2908f596c5 | ||
|
|
e1e215a14e | ||
|
|
175198b0aa | ||
|
|
310c99571f | ||
|
|
e7d4d97ffc |
No files matched your search
@@ -24,3 +24,16 @@ configs {
|
|||||||
}
|
}
|
||||||
automatic: false
|
automatic: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Default Multi-Perspective Code Review Agent
|
||||||
|
configs {
|
||||||
|
id: "git-repo-code-review-agent"
|
||||||
|
display_name: "Code Review Agent"
|
||||||
|
description: "Default multi-perspective review agent for git-repo."
|
||||||
|
include_filters {
|
||||||
|
project: "git-repo"
|
||||||
|
path_regex: ".*"
|
||||||
|
}
|
||||||
|
use_default_subagents: true
|
||||||
|
tags: "code-review-agent"
|
||||||
|
}
|
||||||
@@ -2004,10 +2004,10 @@ def _Run(self, *cmd):
|
|||||||
**Do:**
|
**Do:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from git_command import GitCommand
|
import git_command
|
||||||
|
|
||||||
def _Run(self, cmd):
|
def _Run(self, cmd):
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
cmd,
|
cmd,
|
||||||
capture_stdout=True,
|
capture_stdout=True,
|
||||||
|
|||||||
+5
-7
@@ -19,9 +19,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
from typing import List, Optional, TYPE_CHECKING
|
from typing import List, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from error import InvalidProjectGroupsError
|
import error
|
||||||
from error import NoSuchProjectError
|
|
||||||
from error import RepoExitError
|
|
||||||
from event_log import EventLog
|
from event_log import EventLog
|
||||||
import progress
|
import progress
|
||||||
|
|
||||||
@@ -48,7 +46,7 @@ WORKER_BATCH_SIZE = 32
|
|||||||
DEFAULT_LOCAL_JOBS = min(os.cpu_count(), 8)
|
DEFAULT_LOCAL_JOBS = min(os.cpu_count(), 8)
|
||||||
|
|
||||||
|
|
||||||
class UsageError(RepoExitError):
|
class UsageError(error.RepoExitError):
|
||||||
"""Exception thrown with invalid command usage."""
|
"""Exception thrown with invalid command usage."""
|
||||||
|
|
||||||
|
|
||||||
@@ -526,16 +524,16 @@ class Command:
|
|||||||
projects = [project]
|
projects = [project]
|
||||||
|
|
||||||
if not projects:
|
if not projects:
|
||||||
raise NoSuchProjectError(arg)
|
raise error.NoSuchProjectError(arg)
|
||||||
|
|
||||||
for project in projects:
|
for project in projects:
|
||||||
if not missing_ok and not project.Exists:
|
if not missing_ok and not project.Exists:
|
||||||
raise NoSuchProjectError(
|
raise error.NoSuchProjectError(
|
||||||
"%s (%s)"
|
"%s (%s)"
|
||||||
% (arg, project.RelPath(local=not all_manifests))
|
% (arg, project.RelPath(local=not all_manifests))
|
||||||
)
|
)
|
||||||
if not matches_groups(project):
|
if not matches_groups(project):
|
||||||
raise InvalidProjectGroupsError(arg)
|
raise error.InvalidProjectGroupsError(arg)
|
||||||
|
|
||||||
result.extend(projects)
|
result.extend(projects)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ _repo() {
|
|||||||
'branches:View current topic branches'
|
'branches:View current topic branches'
|
||||||
'checkout:Checkout a branch'
|
'checkout:Checkout a branch'
|
||||||
'cherry-pick:Cherry-pick a change'
|
'cherry-pick:Cherry-pick a change'
|
||||||
|
'config:Read and write per-user repo settings'
|
||||||
'diff:Show changes between commit and working tree'
|
'diff:Show changes between commit and working tree'
|
||||||
'diffmanifests:Show differences between two manifests'
|
'diffmanifests:Show differences between two manifests'
|
||||||
'download:Download and find a change'
|
'download:Download and find a change'
|
||||||
@@ -117,6 +118,15 @@ _repo() {
|
|||||||
${common_opts} \
|
${common_opts} \
|
||||||
'1: :_message "sha1"'
|
'1: :_message "sha1"'
|
||||||
;;
|
;;
|
||||||
|
config)
|
||||||
|
_arguments \
|
||||||
|
${common_opts} \
|
||||||
|
'--type=[Validate and canonicalize the value as this type]:type:(bool int)' \
|
||||||
|
'(-z --null)'{-z,--null}'[Terminate output with NUL]' \
|
||||||
|
'1: :(get set unset list)' \
|
||||||
|
'2: :_message "name"' \
|
||||||
|
'3: :_message "value"'
|
||||||
|
;;
|
||||||
diff)
|
diff)
|
||||||
_arguments \
|
_arguments \
|
||||||
${common_opts} \
|
${common_opts} \
|
||||||
@@ -236,6 +246,9 @@ _repo() {
|
|||||||
'--clone-filter=[Clone filter]:filter:' \
|
'--clone-filter=[Clone filter]:filter:' \
|
||||||
'--use-superproject[Use superproject]' \
|
'--use-superproject[Use superproject]' \
|
||||||
'--no-use-superproject[Do not use superproject]' \
|
'--no-use-superproject[Do not use superproject]' \
|
||||||
|
'(-s --smart-sync)'{-s,--smart-sync}'[Default to smart sync]' \
|
||||||
|
'--no-smart-sync[Do not default to smart sync]' \
|
||||||
|
'--head[Do not default to smart sync]' \
|
||||||
'--clone-bundle[Use clone bundle]' \
|
'--clone-bundle[Use clone bundle]' \
|
||||||
'--no-clone-bundle[Do not use clone bundle]' \
|
'--no-clone-bundle[Do not use clone bundle]' \
|
||||||
'--git-lfs[Use git lfs]' \
|
'--git-lfs[Use git lfs]' \
|
||||||
@@ -337,6 +350,8 @@ _repo() {
|
|||||||
'--auto-gc[Run auto gc]' \
|
'--auto-gc[Run auto gc]' \
|
||||||
'--no-auto-gc[Do not run auto gc]' \
|
'--no-auto-gc[Do not run auto gc]' \
|
||||||
'(-s --smart-sync)'{-s,--smart-sync}'[Smart sync]' \
|
'(-s --smart-sync)'{-s,--smart-sync}'[Smart sync]' \
|
||||||
|
'--no-smart-sync[Do not smart sync]' \
|
||||||
|
'--head[Do not smart sync]' \
|
||||||
'(-t --smart-tag)'{-t,--smart-tag=}'[Smart tag]:tag:' \
|
'(-t --smart-tag)'{-t,--smart-tag=}'[Smart tag]:tag:' \
|
||||||
'--no-repo-verify[Do not verify repo]' \
|
'--no-repo-verify[Do not verify repo]' \
|
||||||
'--no-verify[Do not verify]' \
|
'--no-verify[Do not verify]' \
|
||||||
@@ -396,6 +411,7 @@ _repo() {
|
|||||||
'(-y --yes)'{-y,--yes}'[Answer yes to all prompts]' \
|
'(-y --yes)'{-y,--yes}'[Answer yes to all prompts]' \
|
||||||
'--ignore-untracked-files[Ignore untracked files]' \
|
'--ignore-untracked-files[Ignore untracked files]' \
|
||||||
'--no-ignore-untracked-files[Do not ignore untracked files]' \
|
'--no-ignore-untracked-files[Do not ignore untracked files]' \
|
||||||
|
'--ignore-remote-commits[Ignore commits already in remote branches]' \
|
||||||
'--no-cert-checks[Do not check certificates]' \
|
'--no-cert-checks[Do not check certificates]' \
|
||||||
'--no-verify[Do not verify]' \
|
'--no-verify[Do not verify]' \
|
||||||
'--verify[Verify]' \
|
'--verify[Verify]' \
|
||||||
|
|||||||
+18
-14
@@ -28,7 +28,10 @@ Environment section in `repo help forall` or `subcmds/forall.py`.
|
|||||||
|
|
||||||
The following environment variable is specific to `repo.fetchcmd`:
|
The following environment variable is specific to `repo.fetchcmd`:
|
||||||
|
|
||||||
* `REPO_TREV`: The target revision resolved to a full commit hash.
|
* `REPO_TREV`: The target commit hash when the revision is already pinned to
|
||||||
|
a commit (e.g. via a superproject or a commit SHA in the manifest), or
|
||||||
|
empty when `REPO_RREV` is an unpinned branch or tag to be resolved by the
|
||||||
|
fetch command.
|
||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
@@ -37,22 +40,23 @@ The following environment variable is specific to `repo.fetchcmd`:
|
|||||||
After the fetch command exits with status 0, `repo` expects the following
|
After the fetch command exits with status 0, `repo` expects the following
|
||||||
postconditions to be met:
|
postconditions to be met:
|
||||||
|
|
||||||
1. `git cat-file -e REPO_TREV` succeeds (the commit must exist in the object
|
1. When the manifest revision names a branch or tag, the mapped local tracking
|
||||||
store).
|
ref (e.g. `refs/remotes/REPO_REMOTE/<branch>` for a branch, or
|
||||||
2. The mapped local tracking ref (e.g. `refs/remotes/REPO_REMOTE/<branch>`
|
`refs/tags/<tag>` for a tag) must resolve to a valid commit in the object
|
||||||
for a branch revision, or the tag ref itself for a tag) must point to
|
store (`git rev-parse --verify <ref>^{commit}`), and must equal `REPO_TREV`
|
||||||
`REPO_TREV`.
|
if `REPO_TREV` was non-empty.
|
||||||
3. `FETCH_HEAD` must point to `REPO_TREV`.
|
2. `FETCH_HEAD` must resolve to the same target commit in the object store
|
||||||
4. The commit graph from `REPO_TREV` must be reachable far enough to compute
|
(`git rev-parse --verify FETCH_HEAD^{commit}`).
|
||||||
merge bases with local branches.
|
3. The commit graph from the target commit must be reachable far enough to
|
||||||
|
compute merge bases with local branches.
|
||||||
|
|
||||||
### Invariants
|
### Invariants
|
||||||
|
|
||||||
* The command should be idempotent; fetching the same `REPO_TREV` twice should
|
* The command should be idempotent; repeating the same fetch should be a
|
||||||
be a no-op.
|
no-op.
|
||||||
* Only `FETCH_HEAD` and `refs/remotes/*` should be modified to preserve
|
* Only `FETCH_HEAD`, `refs/remotes/*`, and `refs/tags/*` should be modified to
|
||||||
`repo sync --network-only` semantics. `HEAD` and local branches must not be
|
preserve `repo sync --network-only` semantics. `HEAD` and local branches
|
||||||
touched by the fetch command.
|
must not be touched by the fetch command.
|
||||||
* Dirty worktree state must be preserved.
|
* Dirty worktree state must be preserved.
|
||||||
* The command is **not** executed for `MetaProject`s (i.e. the internal `repo`
|
* The command is **not** executed for `MetaProject`s (i.e. the internal `repo`
|
||||||
repository itself at `.repo/repo` and the `manifests` repository at
|
repository itself at `.repo/repo` and the `manifests` repository at
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ User controlled settings are initialized when running `repo init`.
|
|||||||
| repo.partialclone | `--partial-clone` | Create [partial git clones] |
|
| repo.partialclone | `--partial-clone` | Create [partial git clones] |
|
||||||
| repo.partialcloneexclude | `--partial-clone-exclude` | Comma-delimited list of project names (not paths) to exclude while using [partial git clones] |
|
| repo.partialcloneexclude | `--partial-clone-exclude` | Comma-delimited list of project names (not paths) to exclude while using [partial git clones] |
|
||||||
| repo.reference | `--reference` | Reference repo client checkout |
|
| repo.reference | `--reference` | Reference repo client checkout |
|
||||||
|
| repo.smartsync | `--smart-sync` | Default to [Smart Sync](./smart-sync.md) when syncing |
|
||||||
| repo.submodules | `--submodules` | Sync git submodules |
|
| repo.submodules | `--submodules` | Sync git submodules |
|
||||||
| repo.superproject | `--use-superproject` | Sync [superproject] |
|
| repo.superproject | `--use-superproject` | Sync [superproject] |
|
||||||
| repo.worktree | `--worktree` | Use [git worktree] for checkouts |
|
| repo.worktree | `--worktree` | Use [git worktree] for checkouts |
|
||||||
@@ -253,6 +254,8 @@ setting `REPO_CONFIG_DIR`.
|
|||||||
|
|
||||||
* `.repoconfig/`: Repo's per-user directory for all random config files/state.
|
* `.repoconfig/`: Repo's per-user directory for all random config files/state.
|
||||||
* `.repoconfig/config`: Per-user settings using [git-config] file format.
|
* `.repoconfig/config`: Per-user settings using [git-config] file format.
|
||||||
|
Managed with `repo config`; see [repoconfig.md](./repoconfig.md) for the
|
||||||
|
available settings.
|
||||||
* `.repoconfig/keyring-version`: Cache file for checking if the gnupg subdir
|
* `.repoconfig/keyring-version`: Cache file for checking if the gnupg subdir
|
||||||
has all the same keys as the repo launcher. Used to avoid running gpg
|
has all the same keys as the repo launcher. Used to avoid running gpg
|
||||||
constantly as that can be quite slow.
|
constantly as that can be quite slow.
|
||||||
|
|||||||
+13
-10
@@ -46,18 +46,19 @@ following DTD:
|
|||||||
<!ATTLIST remote revision CDATA #IMPLIED>
|
<!ATTLIST remote revision CDATA #IMPLIED>
|
||||||
|
|
||||||
<!ELEMENT default EMPTY>
|
<!ELEMENT default EMPTY>
|
||||||
<!ATTLIST default remote IDREF #IMPLIED>
|
<!ATTLIST default remote IDREF #IMPLIED>
|
||||||
<!ATTLIST default revision CDATA #IMPLIED>
|
<!ATTLIST default revision CDATA #IMPLIED>
|
||||||
<!ATTLIST default dest-branch CDATA #IMPLIED>
|
<!ATTLIST default dest-branch CDATA #IMPLIED>
|
||||||
<!ATTLIST default upstream CDATA #IMPLIED>
|
<!ATTLIST default upstream CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync-j CDATA #IMPLIED>
|
<!ATTLIST default sync-j CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync-j-max CDATA #IMPLIED>
|
<!ATTLIST default sync-j-max CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync-c CDATA #IMPLIED>
|
<!ATTLIST default sync-c CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync-s CDATA #IMPLIED>
|
<!ATTLIST default sync-s CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync-tags CDATA #IMPLIED>
|
<!ATTLIST default sync-tags CDATA #IMPLIED>
|
||||||
|
<!ATTLIST default sync-smartsync CDATA #IMPLIED>
|
||||||
|
|
||||||
<!ELEMENT manifest-server EMPTY>
|
<!ELEMENT manifest-server EMPTY>
|
||||||
<!ATTLIST manifest-server url CDATA #REQUIRED>
|
<!ATTLIST manifest-server url CDATA #REQUIRED>
|
||||||
<!ATTLIST manifest-server helper CDATA #IMPLIED>
|
<!ATTLIST manifest-server helper CDATA #IMPLIED>
|
||||||
|
|
||||||
<!ELEMENT submanifest EMPTY>
|
<!ELEMENT submanifest EMPTY>
|
||||||
@@ -231,6 +232,8 @@ Attribute `sync-tags`: Set to false to only sync the given Git
|
|||||||
branch (specified in the `revision` attribute) rather than
|
branch (specified in the `revision` attribute) rather than
|
||||||
the other ref tags.
|
the other ref tags.
|
||||||
|
|
||||||
|
Attribute `sync-smartsync`: Set to true to enable smart sync by default.
|
||||||
|
|
||||||
|
|
||||||
### Element manifest-server
|
### Element manifest-server
|
||||||
|
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ that are critical to us.
|
|||||||
That said, let's assume we need functionality that only exists in Python 3.7.
|
That said, let's assume we need functionality that only exists in Python 3.7.
|
||||||
How do we decide when it's acceptable to drop Python 3.6?
|
How do we decide when it's acceptable to drop Python 3.6?
|
||||||
|
|
||||||
1. Review the [Project References](./release-process.md#project-references) to
|
1. Review what major distros are using the previous version of Python (e.g.
|
||||||
see what major distros are using the previous version of Python, and when
|
via [Repology Python versions]), and when they go EOL (e.g. via
|
||||||
they go EOL. Generally we care about Ubuntu LTS & current/previous Debian
|
[Ubuntu releases] & [Debian releases]). Generally we care about Ubuntu LTS
|
||||||
stable versions.
|
& current/previous Debian stable versions.
|
||||||
* If they're all EOL already, then go for it, drop support.
|
* If they're all EOL already, then go for it, drop support.
|
||||||
* If they aren't EOL, start a thread on [repo-discuss] to see how the user
|
* If they aren't EOL, start a thread on [repo-discuss] to see how the user
|
||||||
base feels about the proposal.
|
base feels about the proposal.
|
||||||
@@ -88,5 +88,8 @@ $ repo-2.32 init --repo-rev=repo-1 ...
|
|||||||
|
|
||||||
|
|
||||||
[repo-discuss]: https://groups.google.com/forum/#!forum/repo-discuss
|
[repo-discuss]: https://groups.google.com/forum/#!forum/repo-discuss
|
||||||
|
[Debian releases]: https://wiki.debian.org/DebianReleases#Versions_of_Debian_stable
|
||||||
|
[Repology Python versions]: https://repology.org/project/python/versions
|
||||||
[repo hooks]: ./repo-hooks.md
|
[repo hooks]: ./repo-hooks.md
|
||||||
[repo launcher]: ../repo
|
[repo launcher]: ../repo
|
||||||
|
[Ubuntu releases]: https://wiki.ubuntu.com/Releases
|
||||||
+54
-200
@@ -5,11 +5,19 @@ related topics and flows.
|
|||||||
|
|
||||||
[TOC]
|
[TOC]
|
||||||
|
|
||||||
## Schedule
|
## Schedule {#schedule}
|
||||||
|
|
||||||
|
Releases are automated.
|
||||||
|
Every Tuesday morning [US PT], a pipeline checks if any new commits have landed
|
||||||
|
since last release.
|
||||||
|
If so, it tags & tests the latest `main`, pushes & announces the tag, and then
|
||||||
|
promotes it to `stable` a day later.
|
||||||
|
The release is skipped if there's a holiday or [production freeze](#freeze)
|
||||||
|
that week.
|
||||||
|
Maintainers can also trigger it by hand.
|
||||||
|
The [manual steps](#manual) below are only needed as a fallback, or to roll
|
||||||
|
back a release.
|
||||||
|
|
||||||
There is no specific schedule for when releases are made.
|
|
||||||
Usually it's more along the lines of "enough minor changes have been merged",
|
|
||||||
or "there's a known issue the maintainers know should get fixed".
|
|
||||||
If you find a fix has been merged for an issue important to you, but hasn't been
|
If you find a fix has been merged for an issue important to you, but hasn't been
|
||||||
released after a week or so, feel free to [contact] us to request a new release.
|
released after a week or so, feel free to [contact] us to request a new release.
|
||||||
|
|
||||||
@@ -48,6 +56,11 @@ Whenever major changes are made to the launcher script, you should increment the
|
|||||||
At runtime, repo will check this to see if it needs to be updated (and notify
|
At runtime, repo will check this to see if it needs to be updated (and notify
|
||||||
the user automatically).
|
the user automatically).
|
||||||
|
|
||||||
|
When `VERSION` changes, the release pipeline publishes the new launcher to the
|
||||||
|
`git-repo-downloads` Google Cloud Storage bucket.
|
||||||
|
To publish it by hand, run `./release/sign-launcher.py`, which signs the
|
||||||
|
launcher and prints the commands to upload it.
|
||||||
|
|
||||||
## Key management
|
## Key management
|
||||||
|
|
||||||
Every release has a git tag that is signed with a key that repo recognizes.
|
Every release has a git tag that is signed with a key that repo recognizes.
|
||||||
@@ -96,8 +109,8 @@ If that tag is valid, then repo will warn and use that commit instead.
|
|||||||
|
|
||||||
If that tag cannot be verified, it gives up and forces the user to resolve.
|
If that tag cannot be verified, it gives up and forces the user to resolve.
|
||||||
|
|
||||||
If env variable `REPO_SKIP_SELF_UPDATE` is defined, this will
|
If the env variable `REPO_SKIP_SELF_UPDATE` is set to anything other than `0`,
|
||||||
bypass the self update algorithm.
|
`repo sync` will bypass the self update algorithm.
|
||||||
|
|
||||||
### Force an update
|
### Force an update
|
||||||
|
|
||||||
@@ -110,29 +123,20 @@ All development happens on the `main` branch and should generally be stable.
|
|||||||
|
|
||||||
Since the repo launcher defaults to tracking the `stable` branch, it is not
|
Since the repo launcher defaults to tracking the `stable` branch, it is not
|
||||||
normally updated until a new release is available.
|
normally updated until a new release is available.
|
||||||
If something goes wrong with a new release, an older release can be force pushed
|
If something goes wrong with a new release, `stable` can be pointed back at an
|
||||||
and clients will automatically downgrade.
|
older release and clients will automatically downgrade.
|
||||||
|
|
||||||
The `maint` branch is used to track the previous major release of repo.
|
|
||||||
It is not normally meant to be used by people as `stable` should be good enough.
|
|
||||||
Once a new major release is pushed to the `stable` branch, then the previous
|
|
||||||
major release can be pushed to `maint`.
|
|
||||||
For example, when `stable` moves from `v1.10.x` to `v1.11.x`, then the `maint`
|
|
||||||
branch will be updated from `v1.9.x` to `v1.10.x`.
|
|
||||||
|
|
||||||
We don't have parallel release branches/series.
|
We don't have parallel release branches/series.
|
||||||
Typically all tags are made against the `main` branch and then pushed to the
|
Typically all tags are made against the `main` branch and then promoted to the
|
||||||
`stable` branch to make it available to the rest of the world.
|
`stable` branch to make it available to the rest of the world.
|
||||||
Since repo doesn't typically see a lot of changes, this tends to be OK.
|
Since repo doesn't typically see a lot of changes, this tends to be OK.
|
||||||
|
|
||||||
## Creating a new release
|
## Creating a new release {#manual}
|
||||||
|
|
||||||
When you want to create a new release, you'll need to select a good version and
|
Releases are normally created automatically (see [Schedule](#schedule)).
|
||||||
|
If you need to create one by hand, you'll need to select a good version and
|
||||||
create a signed tag using a key registered in repo itself.
|
create a signed tag using a key registered in repo itself.
|
||||||
Typically we just tag the latest version of the `main` branch.
|
Typically we just tag the latest version of the `main` branch.
|
||||||
The tag could be pushed now, but it won't be used by clients normally (since the
|
|
||||||
default `repo-rev` setting is `stable`).
|
|
||||||
This would allow some early testing on systems who explicitly select `main`.
|
|
||||||
|
|
||||||
### Creating a signed tag
|
### Creating a signed tag
|
||||||
|
|
||||||
@@ -147,10 +151,10 @@ internal processes for accessing the restricted keys.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Pick the new version.
|
# Pick the new version.
|
||||||
$ t=v2.30
|
$ t=v2.XX
|
||||||
|
|
||||||
# Create a new signed tag with the current HEAD.
|
# Create a new signed tag of the latest main branch.
|
||||||
$ ./release/sign-tag.py $t
|
$ ./release/sign-tag.py $t origin/main
|
||||||
|
|
||||||
# Verify the signed tag.
|
# Verify the signed tag.
|
||||||
$ git show $t
|
$ git show $t
|
||||||
@@ -158,199 +162,49 @@ $ git show $t
|
|||||||
|
|
||||||
### Push the new release
|
### Push the new release
|
||||||
|
|
||||||
Once you're ready to make the release available to everyone, push it to the
|
First push the tag, [announce](#announce) it, and wait a day to get feedback
|
||||||
`stable` branch.
|
from people testing new releases early.
|
||||||
|
|
||||||
Make sure you never push the tag itself to the stable branch!
|
|
||||||
Only push the commit -- note the use of `^0` below.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ git push https://gerrit-review.googlesource.com/git-repo $t
|
$ git push https://gerrit-review.googlesource.com/git-repo $t
|
||||||
$ git push https://gerrit-review.googlesource.com/git-repo $t^0:stable
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If something goes horribly wrong, you can force push the previous version to the
|
Then make the release available to everyone by updating the `stable` branch.
|
||||||
`stable` branch and people should automatically recover.
|
Direct pushes to it are rejected, so it has to be deleted & recreated via the
|
||||||
Again, make sure you never push the tag itself!
|
authenticated [Gerrit REST API] (see [go/repo-release] for maintainers).
|
||||||
|
|
||||||
|
Make sure you never point the stable branch at the tag itself!
|
||||||
|
Only use the commit -- note the use of `^0` below.
|
||||||
|
The commit is resolved before deleting anything so that a bad `$t` can't leave
|
||||||
|
`stable` missing.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ oldrev="whatever-old-commit"
|
$ api=https://gerrit-review.googlesource.com/a/projects/git-repo/branches/stable
|
||||||
$ git push https://gerrit-review.googlesource.com/git-repo $oldrev:stable --force
|
$ rev=$(git rev-parse --verify "$t^0") && \
|
||||||
|
curl --fail -X DELETE "$api" && \
|
||||||
|
curl --fail -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"revision\":\"$rev\"}" "$api"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Announce the release
|
If something goes horribly wrong, run the same commands with the previous
|
||||||
|
release (e.g. `t=$(git describe --abbrev=0 $t^0~)`) and people should
|
||||||
|
automatically recover.
|
||||||
|
|
||||||
Once you do push a new release to `stable`, make sure to announce it on the
|
### Announce the release {#announce}
|
||||||
[repo-discuss@googlegroups.com] group.
|
|
||||||
|
Once you push a new release, make sure to announce it on the
|
||||||
|
[repo-discuss@googlegroups.com] group, and follow up once it's on `stable`.
|
||||||
Here is an [example announcement].
|
Here is an [example announcement].
|
||||||
|
|
||||||
You can create a short changelog using the command:
|
You can create a short changelog since the previous release using the command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# If you haven't pushed to the stable branch yet, you can use origin/stable.
|
$ git log --format="%h (%aN) %s" --no-merges \
|
||||||
# If you have pushed, change origin/stable to the previous release tag.
|
$(git describe --abbrev=0 $t^0~)..$t
|
||||||
# This assumes "main" is the current tagged release. If it's newer, change it
|
|
||||||
# to the current release tag too.
|
|
||||||
$ git log --format="%h (%aN) %s" --no-merges origin/stable..main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project References
|
|
||||||
|
|
||||||
Here's a table showing the relationship of major tools, their EOL dates, and
|
|
||||||
their status in Ubuntu & Debian.
|
|
||||||
Those distros tend to be good indicators of how long we need to support things.
|
|
||||||
|
|
||||||
Things in bold indicate stuff to take note of, but does not guarantee that we
|
|
||||||
still support them.
|
|
||||||
Things in italics are things we used to care about but probably don't anymore.
|
|
||||||
|
|
||||||
| Date | EOL | [Git][rel-g] | [Python][rel-p] | [SSH][rel-o] | [Ubuntu][rel-u] / [Debian][rel-d] | Git | Python | SSH |
|
|
||||||
|:--------:|:------------:|:------------:|:---------------:|:------------:|-----------------------------------|:---:|:------:|:---:|
|
|
||||||
| Apr 2008 | | | | 5.0 |
|
|
||||||
| Jun 2008 | | | | 5.1 |
|
|
||||||
| Oct 2008 | *Oct 2013* | | 2.6.0 | | *10.04 Lucid* - 10.10 Maverick / *Squeeze* |
|
|
||||||
| Dec 2008 | *Feb 2009* | | 3.0.0 |
|
|
||||||
| Feb 2009 | | | | 5.2 |
|
|
||||||
| Feb 2009 | *Mar 2012* | | | | Debian 5 Lenny | 1.5.6.5 | 2.5.2 |
|
|
||||||
| Jun 2009 | *Jun 2016* | | 3.1.0 | | *10.04 Lucid* - 10.10 Maverick / *Squeeze* |
|
|
||||||
| Sep 2009 | | | | 5.3 | *10.04 Lucid* |
|
|
||||||
| Feb 2010 | *Oct 2012* | 1.7.0 | | | *10.04 Lucid* - *12.04 Precise* - 12.10 Quantal |
|
|
||||||
| Mar 2010 | | | | 5.4 |
|
|
||||||
| Apr 2010 | | | | 5.5 | 10.10 Maverick |
|
|
||||||
| Apr 2010 | *Apr 2015* | | | | *10.04 Lucid* | 1.7.0.4 | 2.6.5 3.1.2 | 5.3 |
|
|
||||||
| Jul 2010 | *Dec 2019* | | *2.7.0* | | 11.04 Natty - *<current>* |
|
|
||||||
| Aug 2010 | | | | 5.6 |
|
|
||||||
| Oct 2010 | | | | | 10.10 Maverick | 1.7.1 | 2.6.6 3.1.3 | 5.5 |
|
|
||||||
| Jan 2011 | | | | 5.7 |
|
|
||||||
| Feb 2011 | | | | 5.8 | 11.04 Natty |
|
|
||||||
| Feb 2011 | *Feb 2016* | | | | Debian 6 Squeeze | 1.7.2.5 | 2.6.6 3.1.3 |
|
|
||||||
| Apr 2011 | | | | | 11.04 Natty | 1.7.4 | 2.7.1 3.2.0 | 5.8 |
|
|
||||||
| Sep 2011 | | | | 5.9 | *12.04 Precise* |
|
|
||||||
| Oct 2011 | *Feb 2016* | | 3.2.0 | | 11.04 Natty - 12.10 Quantal |
|
|
||||||
| Oct 2011 | | | | | 11.10 Ocelot | 1.7.5.4 | 2.7.2 3.2.2 | 5.8 |
|
|
||||||
| Apr 2012 | | | | 6.0 | 12.10 Quantal |
|
|
||||||
| Apr 2012 | *Apr 2019* | | | | *12.04 Precise* | 1.7.9.5 | 2.7.3 3.2.3 | 5.9 |
|
|
||||||
| Aug 2012 | | | | 6.1 | 13.04 Raring |
|
|
||||||
| Sep 2012 | *Sep 2017* | | 3.3.0 | | 13.04 Raring - 13.10 Saucy |
|
|
||||||
| Oct 2012 | *Dec 2014* | 1.8.0 | | | 13.04 Raring - 13.10 Saucy |
|
|
||||||
| Oct 2012 | | | | | 12.10 Quantal | 1.7.10.4 | 2.7.3 3.2.3 | 6.0 |
|
|
||||||
| Mar 2013 | | | | 6.2 | 13.10 Saucy |
|
|
||||||
| Apr 2013 | | | | | 13.04 Raring | 1.8.1.2 | 2.7.4 3.3.1 | 6.1 |
|
|
||||||
| May 2013 | *May 2018* | | | | Debian 7 Wheezy | 1.7.10.4 | 2.7.3 3.2.3 |
|
|
||||||
| Sep 2013 | | | | 6.3 |
|
|
||||||
| Oct 2013 | | | | | 13.10 Saucy | 1.8.3.2 | 2.7.5 3.3.2 | 6.2 |
|
|
||||||
| Nov 2013 | | | | 6.4 |
|
|
||||||
| Jan 2014 | | | | 6.5 |
|
|
||||||
| Feb 2014 | *Dec 2014* | **1.9.0** | | | *14.04 Trusty* |
|
|
||||||
| Mar 2014 | *Mar 2019* | | *3.4.0* | | *14.04 Trusty* - 15.10 Wily / *Jessie* |
|
|
||||||
| Mar 2014 | | | | 6.6 | *14.04 Trusty* - 14.10 Utopic |
|
|
||||||
| Apr 2014 | *Apr 2024* | | | | *14.04 Trusty* | 1.9.1 | 2.7.5 3.4.0 | 6.6 |
|
|
||||||
| May 2014 | *Dec 2014* | 2.0.0 |
|
|
||||||
| Aug 2014 | *Dec 2014* | *2.1.0* | | | 14.10 Utopic - 15.04 Vivid / *Jessie* |
|
|
||||||
| Oct 2014 | | | | 6.7 | 15.04 Vivid |
|
|
||||||
| Oct 2014 | | | | | 14.10 Utopic | 2.1.0 | 2.7.8 3.4.2 | 6.6 |
|
|
||||||
| Nov 2014 | *Sep 2015* | 2.2.0 |
|
|
||||||
| Feb 2015 | *Sep 2015* | 2.3.0 |
|
|
||||||
| Mar 2015 | | | | 6.8 |
|
|
||||||
| Apr 2015 | *May 2017* | 2.4.0 |
|
|
||||||
| Apr 2015 | *Jun 2020* | | | | *Debian 8 Jessie* | 2.1.4 | 2.7.9 3.4.2 |
|
|
||||||
| Apr 2015 | | | | | 15.04 Vivid | 2.1.4 | 2.7.9 3.4.3 | 6.7 |
|
|
||||||
| Jul 2015 | *May 2017* | 2.5.0 | | | 15.10 Wily |
|
|
||||||
| Jul 2015 | | | | 6.9 | 15.10 Wily |
|
|
||||||
| Aug 2015 | | | | 7.0 |
|
|
||||||
| Aug 2015 | | | | 7.1 |
|
|
||||||
| Sep 2015 | *May 2017* | 2.6.0 |
|
|
||||||
| Sep 2015 | *Sep 2020* | | *3.5.0* | | *16.04 Xenial* - 17.04 Zesty / *Stretch* |
|
|
||||||
| Oct 2015 | | | | | 15.10 Wily | 2.5.0 | 2.7.9 3.4.3 | 6.9 |
|
|
||||||
| Jan 2016 | *Jul 2017* | *2.7.0* | | | *16.04 Xenial* |
|
|
||||||
| Feb 2016 | | | | 7.2 | *16.04 Xenial* |
|
|
||||||
| Mar 2016 | *Jul 2017* | 2.8.0 |
|
|
||||||
| Apr 2016 | *Apr 2026* | | | | *16.04 Xenial* | 2.7.4 | 2.7.11 3.5.1 | 7.2 |
|
|
||||||
| Jun 2016 | *Jul 2017* | 2.9.0 | | | 16.10 Yakkety |
|
|
||||||
| Jul 2016 | | | | 7.3 | 16.10 Yakkety |
|
|
||||||
| Sep 2016 | *Sep 2017* | 2.10.0 |
|
|
||||||
| Oct 2016 | | | | | 16.10 Yakkety | 2.9.3 | 2.7.11 3.5.1 | 7.3 |
|
|
||||||
| Nov 2016 | *Sep 2017* | *2.11.0* | | | 17.04 Zesty / *Stretch* |
|
|
||||||
| Dec 2016 | **Dec 2021** | | **3.6.0** | | 17.10 Artful - **18.04 Bionic** - 18.10 Cosmic |
|
|
||||||
| Dec 2016 | | | | 7.4 | 17.04 Zesty / *Debian 9 Stretch* |
|
|
||||||
| Feb 2017 | *Sep 2017* | 2.12.0 |
|
|
||||||
| Mar 2017 | | | | 7.5 | 17.10 Artful |
|
|
||||||
| Apr 2017 | | | | | 17.04 Zesty | 2.11.0 | 2.7.13 3.5.3 | 7.4 |
|
|
||||||
| May 2017 | *May 2018* | 2.13.0 |
|
|
||||||
| Jun 2017 | *Jun 2022* | | | | *Debian 9 Stretch* | 2.11.0 | 2.7.13 3.5.3 | 7.4 |
|
|
||||||
| Aug 2017 | *Dec 2019* | 2.14.0 | | | 17.10 Artful |
|
|
||||||
| Oct 2017 | *Dec 2019* | 2.15.0 |
|
|
||||||
| Oct 2017 | | | | 7.6 | **18.04 Bionic** |
|
|
||||||
| Oct 2017 | | | | | 17.10 Artful | 2.14.1 | 2.7.14 3.6.3 | 7.5 |
|
|
||||||
| Jan 2018 | *Dec 2019* | 2.16.0 |
|
|
||||||
| Apr 2018 | *Mar 2021* | **2.17.0** | | | **18.04 Bionic** |
|
|
||||||
| Apr 2018 | | | | 7.7 | 18.10 Cosmic |
|
|
||||||
| Apr 2018 | **Apr 2028** | | | | **18.04 Bionic** | 2.17.0 | 2.7.15 3.6.5 | 7.6 |
|
|
||||||
| Jun 2018 | *Mar 2021* | 2.18.0 |
|
|
||||||
| Jun 2018 | **Jun 2023** | | 3.7.0 | | 19.04 Disco - **Buster** |
|
|
||||||
| Aug 2018 | | | | 7.8 |
|
|
||||||
| Sep 2018 | *Mar 2021* | 2.19.0 | | | 18.10 Cosmic |
|
|
||||||
| Oct 2018 | | | | 7.9 | 19.04 Disco / **Buster** |
|
|
||||||
| Oct 2018 | | | | | 18.10 Cosmic | 2.19.1 | 2.7.15 3.6.6 | 7.7 |
|
|
||||||
| Dec 2018 | *Mar 2021* | **2.20.0** | | | 19.04 Disco - 19.10 Eoan / **Buster** |
|
|
||||||
| Feb 2019 | *Mar 2021* | 2.21.0 |
|
|
||||||
| Apr 2019 | | | | 8.0 | 19.10 Eoan |
|
|
||||||
| Apr 2019 | | | | | 19.04 Disco | 2.20.1 | 2.7.16 3.7.3 | 7.9 |
|
|
||||||
| Jun 2019 | | 2.22.0 |
|
|
||||||
| Jul 2019 | **Jul 2024** | | | | **Debian 10 Buster** | 2.20.1 | 2.7.16 3.7.3 | 7.9 |
|
|
||||||
| Aug 2019 | *Mar 2021* | 2.23.0 |
|
|
||||||
| Oct 2019 | **Oct 2024** | | 3.8.0 | | **20.04 Focal** - 20.10 Groovy |
|
|
||||||
| Oct 2019 | | | | 8.1 |
|
|
||||||
| Oct 2019 | | | | | 19.10 Eoan | 2.20.1 | 2.7.17 3.7.5 | 8.0 |
|
|
||||||
| Nov 2019 | *Mar 2021* | 2.24.0 |
|
|
||||||
| Jan 2020 | *Mar 2021* | 2.25.0 | | | **20.04 Focal** |
|
|
||||||
| Feb 2020 | | | | 8.2 | **20.04 Focal** |
|
|
||||||
| Mar 2020 | *Mar 2021* | 2.26.0 |
|
|
||||||
| Apr 2020 | **Apr 2030** | | | | **20.04 Focal** | 2.25.1 | 2.7.17 3.8.2 | 8.2 |
|
|
||||||
| May 2020 | *Mar 2021* | 2.27.0 | | | 20.10 Groovy |
|
|
||||||
| May 2020 | | | | 8.3 |
|
|
||||||
| Jul 2020 | *Mar 2021* | 2.28.0 |
|
|
||||||
| Sep 2020 | | | | 8.4 | 21.04 Hirsute / **Bullseye** |
|
|
||||||
| Oct 2020 | *Mar 2021* | 2.29.0 |
|
|
||||||
| Oct 2020 | | | | | 20.10 Groovy | 2.27.0 | 2.7.18 3.8.6 | 8.3 |
|
|
||||||
| Oct 2020 | **Oct 2025** | | 3.9.0 | | 21.04 Hirsute / **Bullseye** |
|
|
||||||
| Dec 2020 | *Mar 2021* | 2.30.0 | | | 21.04 Hirsute / **Bullseye** |
|
|
||||||
| Mar 2021 | | 2.31.0 | | 8.5 |
|
|
||||||
| Apr 2021 | | | | 8.6 |
|
|
||||||
| Apr 2021 | *Jan 2022* | | | | 21.04 Hirsute | 2.30.2 | 2.7.18 3.9.4 | 8.4 |
|
|
||||||
| Jun 2021 | | 2.32.0 |
|
|
||||||
| Aug 2021 | | 2.33.0 | | 8.7 |
|
|
||||||
| Aug 2021 | **Aug 2026** | | | | **Debian 11 Bullseye** | 2.30.2 | 2.7.18 3.9.2 | 8.4 |
|
|
||||||
| Sep 2021 | | | | 8.8 |
|
|
||||||
| Oct 2021 | | 2.34.0 | 3.10.0 | | **22.04 Jammy** |
|
|
||||||
| Jan 2022 | | 2.35.0 |
|
|
||||||
| Feb 2022 | | | | 8.9 | **22.04 Jammy** |
|
|
||||||
| Apr 2022 | | 2.36.0 | | 9.0 |
|
|
||||||
| Apr 2022 | **Apr 2032** | | | | **22.04 Jammy** | 2.34.1 | 2.7.18 3.10.6 | 8.9 |
|
|
||||||
| Jun 2022 | | 2.37.0 |
|
|
||||||
| Oct 2022 | | 2.38.0 | | 9.1 |
|
|
||||||
| Oct 2022 | | | 3.11.0 | | **Bookworm** |
|
|
||||||
| Dec 2022 | | 2.39.0 | | | **Bookworm** |
|
|
||||||
| Feb 2023 | | | | 9.2 | **Bookworm** |
|
|
||||||
| Mar 2023 | | 2.40.0 | | 9.3 |
|
|
||||||
| Jun 2023 | | 2.41.0 |
|
|
||||||
| Jun 2023 | **Jun 2028** | | | | **Debian 12 Bookworm** | 2.39.2 | 3.11.2 | 9.2 |
|
|
||||||
| Aug 2023 | | 2.42.0 | | 9.4 |
|
|
||||||
| Oct 2023 | | | 3.12.0 | 9.5 |
|
|
||||||
| Nov 2022 | | 2.43.0 |
|
|
||||||
| Dec 2023 | | | | 9.6 |
|
|
||||||
| Feb 2024 | | 2.44.0 |
|
|
||||||
| Mar 2024 | | | | 9.7 |
|
|
||||||
| Oct 2024 | | | 3.13.0 |
|
|
||||||
| **Date** | **EOL** | **[Git][rel-g]** | **[Python][rel-p]** | **[SSH][rel-o]** | **[Ubuntu][rel-u] / [Debian][rel-d]** | **Git** | **Python** | **SSH** |
|
|
||||||
|
|
||||||
|
|
||||||
[contact]: ../README.md#contact
|
[contact]: ../README.md#contact
|
||||||
[rel-d]: https://en.wikipedia.org/wiki/Debian_version_history
|
|
||||||
[rel-g]: https://en.wikipedia.org/wiki/Git#Releases
|
|
||||||
[rel-o]: https://www.openssh.com/releasenotes.html
|
|
||||||
[rel-p]: https://en.wikipedia.org/wiki/History_of_Python#Table_of_versions
|
|
||||||
[rel-u]: https://wiki.ubuntu.com/Releases
|
|
||||||
[example announcement]: https://groups.google.com/d/topic/repo-discuss/UGBNismWo1M/discussion
|
[example announcement]: https://groups.google.com/d/topic/repo-discuss/UGBNismWo1M/discussion
|
||||||
[repo-discuss@googlegroups.com]: https://groups.google.com/forum/#!forum/repo-discuss
|
[repo-discuss@googlegroups.com]: https://groups.google.com/forum/#!forum/repo-discuss
|
||||||
[go/repo-release]: https://goto.google.com/repo-release
|
[go/repo-release]: https://goto.google.com/repo-release
|
||||||
|
[Gerrit REST API]: https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#branch-endpoints
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# repoconfig: User Configuration
|
||||||
|
|
||||||
|
Repo keeps a small number of settings that apply to the current user rather
|
||||||
|
than to one checkout. They live in the per-user configuration file
|
||||||
|
`~/.repoconfig/config`, which is separate from both git's own `~/.gitconfig`
|
||||||
|
and the per-checkout settings stored in `.repo/manifests.git/config` (see
|
||||||
|
[internal-fs-layout.md](./internal-fs-layout.md#Global-settings) for those).
|
||||||
|
|
||||||
|
[TOC]
|
||||||
|
|
||||||
|
## Location
|
||||||
|
|
||||||
|
The file is `.repoconfig/config` inside the user's home directory. The parent
|
||||||
|
directory can be changed by setting the `REPO_CONFIG_DIR` environment variable,
|
||||||
|
in which case repo reads `$REPO_CONFIG_DIR/.repoconfig/config` instead.
|
||||||
|
|
||||||
|
The file is created on demand; it's fine for it to be missing.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
The file uses the [git-config] syntax. The easiest way to manage it is the
|
||||||
|
`repo config` command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Set a value.
|
||||||
|
repo config set sync.smartsync true
|
||||||
|
|
||||||
|
# Read a value.
|
||||||
|
repo config get sync.smartsync
|
||||||
|
|
||||||
|
# Remove a value.
|
||||||
|
repo config unset sync.smartsync
|
||||||
|
|
||||||
|
# Show all settings.
|
||||||
|
repo config list
|
||||||
|
```
|
||||||
|
|
||||||
|
Like other repo subcommands, `repo config` must be run from inside a repo
|
||||||
|
checkout. The file can also be edited by hand:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[sync]
|
||||||
|
smartsync = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Boolean settings accept `true`/`yes` and `false`/`no`.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
### alias.*
|
||||||
|
|
||||||
|
`alias.<name>` defines `repo <name>` as a shortcut for another subcommand,
|
||||||
|
optionally with arguments, in the same way as git aliases:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[alias]
|
||||||
|
ss = sync -s
|
||||||
|
st = status -o
|
||||||
|
```
|
||||||
|
|
||||||
|
Aliases defined in a checkout's `.repo/config` take precedence over per-user
|
||||||
|
ones. Existing subcommands cannot be overridden by an alias.
|
||||||
|
|
||||||
|
### sync
|
||||||
|
|
||||||
|
#### smartsync
|
||||||
|
|
||||||
|
`sync.smartsync` is a boolean that lets a user decide once, for all their
|
||||||
|
checkouts, whether `repo sync` should [Smart Sync](./smart-sync.md) by
|
||||||
|
default: `true` always Smart Syncs, as if `-s`/`--smart-sync` were passed;
|
||||||
|
`false` never does by default, even if the manifest sets
|
||||||
|
`sync-smartsync="true"`; unset follows the manifest.
|
||||||
|
|
||||||
|
The effective behavior is resolved in this order; the first match wins:
|
||||||
|
|
||||||
|
1. Command line: `-s`/`--smart-sync`, `--no-smart-sync`, or `-t`/`--smart-tag`.
|
||||||
|
2. User config: `sync.smartsync` in `~/.repoconfig/config`.
|
||||||
|
3. Manifest: `sync-smartsync="true"` on the `<default>` element.
|
||||||
|
4. Otherwise, a regular ToT sync.
|
||||||
|
|
||||||
|
So `sync.smartsync=false` turns off the manifest-driven default, but an
|
||||||
|
explicit `repo sync -s`, `repo sync -t TAG` or `repo smartsync` still works.
|
||||||
|
Likewise `sync.smartsync=true` is overridden by `--no-smart-sync`.
|
||||||
|
|
||||||
|
A Smart Sync enabled by `sync.smartsync=true` is treated as an explicit
|
||||||
|
request: it is subject to the same restrictions as `-s` (it is skipped when
|
||||||
|
`-l`/`--local-only`, `-m`/`--manifest-name`, `-t`/`--smart-tag` or
|
||||||
|
`--superproject-revision` is used), and a failure to obtain a manifest from
|
||||||
|
the server fails the sync instead of silently falling back to ToT.
|
||||||
|
|
||||||
|
`sync.smartsync` does not affect `-l`/`--local-only`: a local-only sync keeps
|
||||||
|
whatever `smart_sync_override.xml` the previous network sync left behind. See
|
||||||
|
[smart-sync.md](./smart-sync.md#Client-Options) for details.
|
||||||
|
|
||||||
|
|
||||||
|
[git-config]: https://git-scm.com/docs/git-config
|
||||||
+118
-4
@@ -95,6 +95,36 @@ example would do. Google's internal server uses Python's
|
|||||||
|
|
||||||
The manifest server should implement the following RPC methods.
|
The manifest server should implement the following RPC methods.
|
||||||
|
|
||||||
|
### GetApprovedManifestV2
|
||||||
|
|
||||||
|
> `GetApprovedManifestV2(request: dict) -> dict`
|
||||||
|
|
||||||
|
Like [GetApprovedManifest](#GetApprovedManifest), but takes a single struct of
|
||||||
|
named options so new options can be added without changing the method signature.
|
||||||
|
Unset options are omitted from the struct. Servers should ignore options they do
|
||||||
|
not understand.
|
||||||
|
|
||||||
|
Request options:
|
||||||
|
|
||||||
|
* `branch` (`str`, required): Same as in `GetApprovedManifest`.
|
||||||
|
* `target` (`str`, optional): Same as in `GetApprovedManifest`.
|
||||||
|
* `best_effort` (`bool`, optional, default `False`): The client can proceed
|
||||||
|
without a manifest, so the server may decline the request (e.g. during a
|
||||||
|
staged rollout or to shed load). Repo sets this for Smart Syncs enabled by
|
||||||
|
the manifest's `sync-smartsync` default, and falls back to a regular ToT
|
||||||
|
sync if the request is declined. Servers must not decline requests without
|
||||||
|
this option for these reasons.
|
||||||
|
|
||||||
|
Response fields:
|
||||||
|
|
||||||
|
* `success` (`bool`): Whether a manifest was returned.
|
||||||
|
* `manifest_xml` (`str`): The manifest. Required if `success` is true.
|
||||||
|
* `error_message` (`str`): Why the request failed or was declined. Repo shows
|
||||||
|
it to the user like `GetApprovedManifest` errors.
|
||||||
|
|
||||||
|
If the server does not implement this method, repo calls `GetApprovedManifest`
|
||||||
|
instead and drops the options it cannot express.
|
||||||
|
|
||||||
### GetApprovedManifest
|
### GetApprovedManifest
|
||||||
|
|
||||||
> `GetApprovedManifest(branch: str, target: Optional[str]) -> str`
|
> `GetApprovedManifest(branch: str, target: Optional[str]) -> str`
|
||||||
@@ -138,12 +168,79 @@ copy into `.repo/manifests/smart_sync_override.xml` so users can examine it.
|
|||||||
The next time `repo sync` is run, this file is automatically replaced or removed
|
The next time `repo sync` is run, this file is automatically replaced or removed
|
||||||
based on the current set of options.
|
based on the current set of options.
|
||||||
|
|
||||||
### --smart-sync
|
With `-l`/`--local-only` and no explicit `-s`/`--smart-sync` or
|
||||||
|
`-t`/`--smart-tag`, repo does not contact the manifest server. Instead, it keeps
|
||||||
|
using the existing `smart_sync_override.xml`, if there is one, whether it came
|
||||||
|
from an explicit `-s`/`--smart-sync` or from the manifest's `sync-smartsync`
|
||||||
|
default. This way, `repo sync -n` followed by `repo sync -l` ends up on the same
|
||||||
|
revisions as the equivalent single `repo sync` (e.g. `repo sync -s -n` +
|
||||||
|
`repo sync -l` matches `repo sync -s`). Pass `--no-smart-sync` to discard it and
|
||||||
|
use the default manifest instead.
|
||||||
|
|
||||||
Repo will call `GetApprovedManifest(branch[, target])`.
|
`--no-manifest-update` only skips updating the manifest checkout. It does not
|
||||||
|
affect Smart Sync: the default Smart Sync from `sync-smartsync` (see below)
|
||||||
|
still runs, and an existing `smart_sync_override.xml` is replaced or removed as
|
||||||
|
in a regular sync.
|
||||||
|
|
||||||
|
### `repo init --[no-]smart-sync`
|
||||||
|
|
||||||
|
Users can configure Smart Sync as the default for a checkout by passing
|
||||||
|
`-s`/`--smart-sync` (or `--no-smart-sync`/`--head`) to `repo init` (this can
|
||||||
|
also be re-run in an existing checkout to change the setting).
|
||||||
|
|
||||||
|
When resolving whether to perform a Smart Sync by default, `repo sync` uses the
|
||||||
|
following precedence order (highest to lowest):
|
||||||
|
|
||||||
|
1. Per-invocation CLI flags (`-s`/`--smart-sync`, `--no-smart-sync`/`--head`,
|
||||||
|
or target selection flags like `-t`, `-m`, `--superproject-revision`, `-l`).
|
||||||
|
2. Checkout `repo init` setting (`repo init --[no-]smart-sync`).
|
||||||
|
3. [Per-user `sync.smartsync` setting](#Per_user-default-sync_smartsync).
|
||||||
|
4. Manifest [`sync-smartsync`](#sync-smartsync-manifest-attribute) setting.
|
||||||
|
|
||||||
|
Running `repo init --no-smart-sync` (or `repo init --head`) disables default
|
||||||
|
Smart Sync for the checkout even when enabled by the user setting or manifest.
|
||||||
|
As with the [manifest setting](#sync-smartsync-manifest-attribute), a
|
||||||
|
checkout-enabled default Smart Sync falls back to a regular ToT sync if the
|
||||||
|
manifest does not define a `<manifest-server>` or the manifest server cannot
|
||||||
|
provide a usable manifest.
|
||||||
|
|
||||||
|
### sync-smartsync (Manifest Attribute)
|
||||||
|
|
||||||
|
The manifest can enable Smart Sync by default for all `repo sync` invocations
|
||||||
|
by setting `sync-smartsync="true"` on the `<default>` element:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<default sync-smartsync="true" ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled in the manifest, `repo sync` will automatically perform a Smart
|
||||||
|
Sync unless explicitly overridden on the command line. Since the user did not
|
||||||
|
ask for a Smart Sync, any failure to obtain a manifest from the manifest server
|
||||||
|
(e.g. the server is unreachable, or it returns a manifest that cannot be parsed)
|
||||||
|
is silently ignored and `repo sync` falls back to a regular ToT sync; pass
|
||||||
|
`--verbose` to see the reason. Repo marks these requests as `best_effort` (see
|
||||||
|
[GetApprovedManifestV2](#GetApprovedManifestV2)), so the server may also decline
|
||||||
|
them, e.g. during a staged rollout. This is only a fallback for the
|
||||||
|
manifest-driven default: an explicit `-s`/`--smart-sync` or `-t`/`--smart-tag`
|
||||||
|
still fails the sync, as before. Options that specify an explicit target or
|
||||||
|
manifest source (such as `-t`/`--smart-tag`, `-m`/`--manifest-name` or
|
||||||
|
`--superproject-revision`) will also disable the default Smart Sync behavior.
|
||||||
|
|
||||||
|
Users can override this manifest default on their workstation with the
|
||||||
|
`sync.smartsync` user setting; see
|
||||||
|
[Per-user default](#Per_user-default-sync_smartsync) below.
|
||||||
|
|
||||||
|
### --smart-sync / -s
|
||||||
|
|
||||||
|
Explicitly enables Smart Sync. Repo will call
|
||||||
|
`GetApprovedManifestV2({branch[, target]})`, or
|
||||||
|
`GetApprovedManifest(branch[, target])` if the server does not implement it.
|
||||||
|
|
||||||
The `branch` is determined by the current manifest branch as specified by
|
The `branch` is determined by the current manifest branch as specified by
|
||||||
`--manifest-branch=BRANCH` when running `repo init`.
|
`--manifest-branch=BRANCH` when running `repo init`. If `${SYNC_BRANCH}` is
|
||||||
|
defined and non-empty, its value is sent instead. This allows selecting a
|
||||||
|
branch other than the one the checkout tracks, e.g. a server-side (CI) branch
|
||||||
|
name that builds from the same manifest branch.
|
||||||
|
|
||||||
The `target` is defined by environment variables in the order below. If none
|
The `target` is defined by environment variables in the order below. If none
|
||||||
of them match, then `target` is omitted. These variables were decided as they
|
of them match, then `target` is omitted. These variables were decided as they
|
||||||
@@ -155,6 +252,23 @@ match the settings Android build environments automatically setup.
|
|||||||
3. `${TARGET_PRODUCT}-${TARGET_BUILD_VARIANT}`: If these variables are all
|
3. `${TARGET_PRODUCT}-${TARGET_BUILD_VARIANT}`: If these variables are all
|
||||||
defined, then they are merged with `-` and used.
|
defined, then they are merged with `-` and used.
|
||||||
|
|
||||||
### --smart-tag=TAG
|
### --no-smart-sync / --head
|
||||||
|
|
||||||
|
Explicitly disables Smart Sync, overriding any `repo.smartsync` git config or
|
||||||
|
`sync-smartsync="true"` setting declared in the manifest `<default>` element.
|
||||||
|
|
||||||
|
### Per-user default (sync.smartsync)
|
||||||
|
|
||||||
|
A user can decide once, for all their checkouts, whether `repo sync` should
|
||||||
|
Smart Sync by default by setting the boolean `sync.smartsync` key in their
|
||||||
|
[per-user repo config](./repoconfig.md#smartsync).
|
||||||
|
|
||||||
|
Command line flags (`-s`, `--no-smart-sync`, `-t`) override the user setting,
|
||||||
|
which in turn overrides the manifest's `sync-smartsync` default. A Smart Sync
|
||||||
|
enabled this way is treated as an explicit request, so it is subject to the
|
||||||
|
same restrictions as `-s` and fails the sync if no manifest can be obtained
|
||||||
|
from the server instead of silently falling back to ToT.
|
||||||
|
|
||||||
|
### --smart-tag=TAG / -t
|
||||||
|
|
||||||
Repo will call `GetManifest(TAG)`.
|
Repo will call `GetManifest(TAG)`.
|
||||||
@@ -18,7 +18,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from error import EditorError
|
import error
|
||||||
import platform_utils
|
import platform_utils
|
||||||
|
|
||||||
|
|
||||||
@@ -104,9 +104,11 @@ least one of these before using this command.""", # noqa: E501
|
|||||||
try:
|
try:
|
||||||
rc = subprocess.Popen(args, shell=shell).wait()
|
rc = subprocess.Popen(args, shell=shell).wait()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise EditorError(f"editor failed, {str(e)}: {editor} {path}")
|
raise error.EditorError(
|
||||||
|
f"editor failed, {str(e)}: {editor} {path}"
|
||||||
|
)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
raise EditorError(
|
raise error.EditorError(
|
||||||
"editor failed with exit status %d: %s %s"
|
"editor failed with exit status %d: %s %s"
|
||||||
% (rc, editor, path)
|
% (rc, editor, path)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import sys
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
|
|
||||||
from error import RepoExitError
|
import error
|
||||||
|
|
||||||
|
|
||||||
class FetchFileError(RepoExitError):
|
class FetchFileError(error.RepoExitError):
|
||||||
"""Exit error when fetch_file fails."""
|
"""Exit error when fetch_file fails."""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+20
-17
@@ -20,14 +20,11 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from error import GitError
|
import error
|
||||||
from error import RepoExitError
|
|
||||||
from git_trace2_event_log_base import BaseEventLog
|
from git_trace2_event_log_base import BaseEventLog
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
from repo_trace import IsTrace
|
import repo_trace
|
||||||
from repo_trace import REPO_TRACE
|
|
||||||
from repo_trace import Trace
|
|
||||||
from wrapper import Wrapper
|
from wrapper import Wrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -43,7 +40,7 @@ GIT_ERROR_STDOUT_LINES = 1
|
|||||||
GIT_ERROR_STDERR_LINES = 10
|
GIT_ERROR_STDERR_LINES = 10
|
||||||
INVALID_GIT_EXIT_CODE = 126
|
INVALID_GIT_EXIT_CODE = 126
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class _GitCall:
|
class _GitCall:
|
||||||
@@ -335,11 +332,17 @@ class GitCommand:
|
|||||||
|
|
||||||
if command_name in ("fetch", "clone"):
|
if command_name in ("fetch", "clone"):
|
||||||
env["GIT_TERMINAL_PROMPT"] = "0"
|
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||||
# Need to use the --progress flag for fetch/clone so output will be
|
if command_name in ("fetch", "clone", "push"):
|
||||||
# displayed as by default git only does progress output if stderr is
|
# Need to use the --progress flag for fetch/clone/push so output
|
||||||
# a TTY.
|
# will be displayed as by default git only does progress output if
|
||||||
|
# stderr is a TTY.
|
||||||
if sys.stderr.isatty():
|
if sys.stderr.isatty():
|
||||||
if "--progress" not in cmdv and "--quiet" not in cmdv:
|
if (
|
||||||
|
"--progress" not in cmdv
|
||||||
|
and "--no-progress" not in cmdv
|
||||||
|
and "--quiet" not in cmdv
|
||||||
|
and "-q" not in cmdv
|
||||||
|
):
|
||||||
command.append("--progress")
|
command.append("--progress")
|
||||||
command.extend(cmdv[1:])
|
command.extend(cmdv[1:])
|
||||||
|
|
||||||
@@ -433,7 +436,7 @@ class GitCommand:
|
|||||||
kwargs = {}
|
kwargs = {}
|
||||||
|
|
||||||
dbg = ""
|
dbg = ""
|
||||||
if IsTrace():
|
if repo_trace.IsTrace():
|
||||||
global LAST_CWD
|
global LAST_CWD
|
||||||
global LAST_GITDIR
|
global LAST_GITDIR
|
||||||
|
|
||||||
@@ -470,7 +473,7 @@ class GitCommand:
|
|||||||
elif stderr == subprocess.STDOUT:
|
elif stderr == subprocess.STDOUT:
|
||||||
dbg += " 2>&1"
|
dbg += " 2>&1"
|
||||||
|
|
||||||
with Trace(
|
with repo_trace.Trace(
|
||||||
"git command %s %s with debug: %s", LAST_GITDIR, command, dbg
|
"git command %s %s with debug: %s", LAST_GITDIR, command, dbg
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@@ -547,7 +550,7 @@ class GitCommand:
|
|||||||
"""
|
"""
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
for key in (
|
for key in (
|
||||||
REPO_TRACE,
|
repo_trace.REPO_TRACE,
|
||||||
GIT_DIR,
|
GIT_DIR,
|
||||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||||
"GIT_OBJECT_DIRECTORY",
|
"GIT_OBJECT_DIRECTORY",
|
||||||
@@ -604,14 +607,14 @@ class GitCommand:
|
|||||||
return self.rc
|
return self.rc
|
||||||
|
|
||||||
|
|
||||||
class GitRequireError(RepoExitError):
|
class GitRequireError(error.RepoExitError):
|
||||||
"""Error raised when git version is unavailable or invalid."""
|
"""Error raised when git version is unavailable or invalid."""
|
||||||
|
|
||||||
def __init__(self, message, exit_code: int = INVALID_GIT_EXIT_CODE):
|
def __init__(self, message, exit_code: int = INVALID_GIT_EXIT_CODE):
|
||||||
super().__init__(message, exit_code=exit_code)
|
super().__init__(message, exit_code=exit_code)
|
||||||
|
|
||||||
|
|
||||||
class GitCommandError(GitError):
|
class GitCommandError(error.GitError):
|
||||||
"""
|
"""
|
||||||
Error raised from a failed git command.
|
Error raised from a failed git command.
|
||||||
Note that GitError can refer to any Git related error (e.g. branch not
|
Note that GitError can refer to any Git related error (e.g. branch not
|
||||||
@@ -691,7 +694,7 @@ class GitCommandError(GitError):
|
|||||||
return string
|
return string
|
||||||
|
|
||||||
|
|
||||||
class GitPopenCommandError(GitError):
|
class GitPopenCommandError(error.GitError):
|
||||||
"""
|
"""
|
||||||
Error raised when subprocess.Popen fails for a GitCommand
|
Error raised when subprocess.Popen fails for a GitCommand
|
||||||
"""
|
"""
|
||||||
+97
-50
@@ -26,14 +26,11 @@ from typing import Union
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from error import GitError
|
import error
|
||||||
from error import UploadError
|
import git_command
|
||||||
from git_command import GitCommand
|
import git_refs
|
||||||
from git_refs import R_CHANGES
|
|
||||||
from git_refs import R_HEADS
|
|
||||||
from git_refs import R_TAGS
|
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from repo_trace import Trace
|
import repo_trace
|
||||||
|
|
||||||
|
|
||||||
# Prefix that is prepended to all the keys of SyncAnalysisState's data
|
# Prefix that is prepended to all the keys of SyncAnalysisState's data
|
||||||
@@ -46,7 +43,7 @@ REVIEW_CACHE = {}
|
|||||||
|
|
||||||
|
|
||||||
def IsChange(rev):
|
def IsChange(rev):
|
||||||
return rev.startswith(R_CHANGES)
|
return rev.startswith(git_refs.R_CHANGES)
|
||||||
|
|
||||||
|
|
||||||
def IsId(rev: str) -> bool:
|
def IsId(rev: str) -> bool:
|
||||||
@@ -54,13 +51,63 @@ def IsId(rev: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def IsTag(rev):
|
def IsTag(rev):
|
||||||
return rev.startswith(R_TAGS)
|
return rev.startswith(git_refs.R_TAGS)
|
||||||
|
|
||||||
|
|
||||||
def IsImmutable(rev):
|
def IsImmutable(rev):
|
||||||
return IsChange(rev) or IsId(rev) or IsTag(rev)
|
return IsChange(rev) or IsId(rev) or IsTag(rev)
|
||||||
|
|
||||||
|
|
||||||
|
def ParseBoolean(value: str) -> Union[bool, None]:
|
||||||
|
"""Parses a git config boolean value.
|
||||||
|
|
||||||
|
Accepts the same spellings as `git config --type=bool`.
|
||||||
|
https://git-scm.com/docs/git-config#Documentation/git-config.txt-boolean
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True/False, or None if |value| is not a valid boolean.
|
||||||
|
"""
|
||||||
|
v = value.strip().lower()
|
||||||
|
if v in ("true", "yes", "on", "1"):
|
||||||
|
return True
|
||||||
|
if v in ("false", "no", "off", "0"):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ParseInt(value: str) -> Union[int, None]:
|
||||||
|
"""Parses a git config integer value.
|
||||||
|
|
||||||
|
Accepts the same syntax as `git config --type=int`: an optional k, m or
|
||||||
|
g suffix (multiples of 1024) and hex with a 0x prefix.
|
||||||
|
https://git-scm.com/docs/git-config#Documentation/git-config.txt-integer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The integer, or None if |value| is not a valid integer.
|
||||||
|
"""
|
||||||
|
v = value.strip().lower()
|
||||||
|
|
||||||
|
mult = 1
|
||||||
|
if v.endswith("k"):
|
||||||
|
v = v[:-1]
|
||||||
|
mult = 1024
|
||||||
|
elif v.endswith("m"):
|
||||||
|
v = v[:-1]
|
||||||
|
mult = 1024 * 1024
|
||||||
|
elif v.endswith("g"):
|
||||||
|
v = v[:-1]
|
||||||
|
mult = 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
base = 10
|
||||||
|
if v.startswith("0x"):
|
||||||
|
base = 16
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(v, base=base) * mult
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _key(name):
|
def _key(name):
|
||||||
parts = name.split(".")
|
parts = name.split(".")
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
@@ -152,32 +199,15 @@ class GitConfig:
|
|||||||
v = self.GetString(name)
|
v = self.GetString(name)
|
||||||
if v is None:
|
if v is None:
|
||||||
return None
|
return None
|
||||||
v = v.strip()
|
|
||||||
|
|
||||||
mult = 1
|
ret = ParseInt(v)
|
||||||
if v.endswith("k"):
|
if ret is None:
|
||||||
v = v[:-1]
|
|
||||||
mult = 1024
|
|
||||||
elif v.endswith("m"):
|
|
||||||
v = v[:-1]
|
|
||||||
mult = 1024 * 1024
|
|
||||||
elif v.endswith("g"):
|
|
||||||
v = v[:-1]
|
|
||||||
mult = 1024 * 1024 * 1024
|
|
||||||
|
|
||||||
base = 10
|
|
||||||
if v.startswith("0x"):
|
|
||||||
base = 16
|
|
||||||
|
|
||||||
try:
|
|
||||||
return int(v, base=base) * mult
|
|
||||||
except ValueError:
|
|
||||||
print(
|
print(
|
||||||
f"warning: expected {name} to represent an integer, got {v} "
|
f"warning: expected {name} to represent an integer, got {v} "
|
||||||
"instead",
|
"instead",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return None
|
return ret
|
||||||
|
|
||||||
def DumpConfigDict(self):
|
def DumpConfigDict(self):
|
||||||
"""Returns the current configuration dict.
|
"""Returns the current configuration dict.
|
||||||
@@ -197,24 +227,25 @@ class GitConfig:
|
|||||||
def GetBoolean(self, name: str) -> Union[bool, None]:
|
def GetBoolean(self, name: str) -> Union[bool, None]:
|
||||||
"""Returns a boolean from the configuration file.
|
"""Returns a boolean from the configuration file.
|
||||||
|
|
||||||
|
This follows the git config syntax.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None: The value was not defined, or is not a boolean.
|
None: The value was not defined, or is not a boolean.
|
||||||
True: The value was set to true or yes.
|
True: The value was set to true, yes, on or 1.
|
||||||
False: The value was set to false or no.
|
False: The value was set to false, no, off or 0.
|
||||||
"""
|
"""
|
||||||
v = self.GetString(name)
|
v = self.GetString(name)
|
||||||
if v is None:
|
if v is None:
|
||||||
return None
|
return None
|
||||||
v = v.lower()
|
|
||||||
if v in ("true", "yes"):
|
ret = ParseBoolean(v)
|
||||||
return True
|
if ret is None:
|
||||||
if v in ("false", "no"):
|
print(
|
||||||
return False
|
f"warning: expected {name} to represent a boolean, got {v} "
|
||||||
print(
|
"instead",
|
||||||
f"warning: expected {name} to represent a boolean, got {v} instead",
|
file=sys.stderr,
|
||||||
file=sys.stderr,
|
)
|
||||||
)
|
return ret
|
||||||
return None
|
|
||||||
|
|
||||||
def SetBoolean(self, name, value):
|
def SetBoolean(self, name, value):
|
||||||
"""Set the truthy value for a key."""
|
"""Set the truthy value for a key."""
|
||||||
@@ -266,6 +297,13 @@ class GitConfig:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
old = []
|
old = []
|
||||||
|
|
||||||
|
if self.file != self._SYSTEM_CONFIG:
|
||||||
|
# `git config --file` does not create missing parent directories
|
||||||
|
# (e.g. ~/.repoconfig/ on first use).
|
||||||
|
parent_dir = os.path.dirname(self.file)
|
||||||
|
if parent_dir:
|
||||||
|
os.makedirs(parent_dir, exist_ok=True)
|
||||||
|
|
||||||
if value is None:
|
if value is None:
|
||||||
if old:
|
if old:
|
||||||
del self._cache[key]
|
del self._cache[key]
|
||||||
@@ -387,7 +425,7 @@ class GitConfig:
|
|||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with Trace(": parsing %s", self.file):
|
with repo_trace.Trace(": parsing %s", self.file):
|
||||||
with open(self._json) as fd:
|
with open(self._json) as fd:
|
||||||
return json.load(fd)
|
return json.load(fd)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
@@ -434,16 +472,23 @@ class GitConfig:
|
|||||||
command = ["config", "--file", self.file, "--includes"]
|
command = ["config", "--file", self.file, "--includes"]
|
||||||
command.extend(args)
|
command.extend(args)
|
||||||
|
|
||||||
p = GitCommand(None, command, capture_stdout=True, capture_stderr=True)
|
p = git_command.GitCommand(
|
||||||
|
None, command, capture_stdout=True, capture_stderr=True
|
||||||
|
)
|
||||||
if p.Wait() == 0:
|
if p.Wait() == 0:
|
||||||
return p.stdout
|
return p.stdout
|
||||||
else:
|
else:
|
||||||
raise GitError(f"git {' '.join(command)}: {p.stderr}")
|
raise error.GitError(f"git {' '.join(command)}: {p.stderr}")
|
||||||
|
|
||||||
|
|
||||||
class RepoConfig(GitConfig):
|
class RepoConfig(GitConfig):
|
||||||
"""User settings for repo itself."""
|
"""User settings for repo itself."""
|
||||||
|
|
||||||
|
# Keep separate caches from GitConfig, otherwise ForUser()/ForSystem()
|
||||||
|
# would return the already-cached ~/.gitconfig instance of the parent.
|
||||||
|
_ForUser = None
|
||||||
|
_ForSystem = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _getUserConfig():
|
def _getUserConfig():
|
||||||
repo_config_dir = os.getenv("REPO_CONFIG_DIR", os.path.expanduser("~"))
|
repo_config_dir = os.getenv("REPO_CONFIG_DIR", os.path.expanduser("~"))
|
||||||
@@ -671,11 +716,13 @@ class Remote:
|
|||||||
userEmail, host, port
|
userEmail, host, port
|
||||||
)
|
)
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
raise UploadError(f"{self.review}: {str(e)}")
|
raise error.UploadError(f"{self.review}: {str(e)}")
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
raise UploadError(f"{self.review}: {str(e)}")
|
raise error.UploadError(f"{self.review}: {str(e)}")
|
||||||
except http.client.HTTPException as e:
|
except http.client.HTTPException as e:
|
||||||
raise UploadError(f"{self.review}: {e.__class__.__name__}")
|
raise error.UploadError(
|
||||||
|
f"{self.review}: {e.__class__.__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
REVIEW_CACHE[u] = self._review_url
|
REVIEW_CACHE[u] = self._review_url
|
||||||
return self._review_url + self.projectname
|
return self._review_url + self.projectname
|
||||||
@@ -692,16 +739,16 @@ class Remote:
|
|||||||
return rev
|
return rev
|
||||||
|
|
||||||
if not rev.startswith("refs/"):
|
if not rev.startswith("refs/"):
|
||||||
rev = R_HEADS + rev
|
rev = git_refs.R_HEADS + rev
|
||||||
|
|
||||||
for spec in self.fetch:
|
for spec in self.fetch:
|
||||||
if spec.SourceMatches(rev):
|
if spec.SourceMatches(rev):
|
||||||
return spec.MapSource(rev)
|
return spec.MapSource(rev)
|
||||||
|
|
||||||
if not rev.startswith(R_HEADS):
|
if not rev.startswith(git_refs.R_HEADS):
|
||||||
return rev
|
return rev
|
||||||
|
|
||||||
raise GitError(
|
raise error.GitError(
|
||||||
"%s: remote %s does not have %s"
|
"%s: remote %s does not have %s"
|
||||||
% (self.projectname, self.name, rev)
|
% (self.projectname, self.name, rev)
|
||||||
)
|
)
|
||||||
|
|||||||
+8
-9
@@ -14,10 +14,9 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from git_command import git_require
|
import git_command
|
||||||
from git_command import GitCommand
|
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from repo_trace import Trace
|
import repo_trace
|
||||||
|
|
||||||
|
|
||||||
HEAD = "HEAD"
|
HEAD = "HEAD"
|
||||||
@@ -82,7 +81,7 @@ class GitRefs:
|
|||||||
self._LoadAll()
|
self._LoadAll()
|
||||||
|
|
||||||
def _NeedUpdate(self):
|
def _NeedUpdate(self):
|
||||||
with Trace(": scan refs %s", self._gitdir):
|
with repo_trace.Trace(": scan refs %s", self._gitdir):
|
||||||
for name, mtime in self._mtime.items():
|
for name, mtime in self._mtime.items():
|
||||||
try:
|
try:
|
||||||
if mtime != os.path.getmtime(
|
if mtime != os.path.getmtime(
|
||||||
@@ -94,7 +93,7 @@ class GitRefs:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _LoadAll(self):
|
def _LoadAll(self):
|
||||||
with Trace(": load refs %s", self._gitdir):
|
with repo_trace.Trace(": load refs %s", self._gitdir):
|
||||||
self._phyref = {}
|
self._phyref = {}
|
||||||
self._symref = {}
|
self._symref = {}
|
||||||
self._mtime = {}
|
self._mtime = {}
|
||||||
@@ -135,7 +134,7 @@ class GitRefs:
|
|||||||
Returns:
|
Returns:
|
||||||
Whether root refs, including HEAD when it exists, were loaded.
|
Whether root refs, including HEAD when it exists, were loaded.
|
||||||
"""
|
"""
|
||||||
include_root_refs = git_require((2, 45, 0))
|
include_root_refs = git_command.git_require((2, 45, 0))
|
||||||
cmd = [
|
cmd = [
|
||||||
"for-each-ref",
|
"for-each-ref",
|
||||||
"--format=%(objectname)%00%(refname)%00%(symref)",
|
"--format=%(objectname)%00%(refname)%00%(symref)",
|
||||||
@@ -145,7 +144,7 @@ class GitRefs:
|
|||||||
# Avoid caching volatile root refs such as ORIG_HEAD. HEAD and
|
# Avoid caching volatile root refs such as ORIG_HEAD. HEAD and
|
||||||
# refs/* are the only namespaces GitRefs exposes to callers.
|
# refs/* are the only namespaces GitRefs exposes to callers.
|
||||||
cmd.extend([HEAD, "refs"])
|
cmd.extend([HEAD, "refs"])
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
cmd,
|
cmd,
|
||||||
capture_stdout=True,
|
capture_stdout=True,
|
||||||
@@ -166,7 +165,7 @@ class GitRefs:
|
|||||||
|
|
||||||
def _ReadSymbolicRef(self, name: str) -> None:
|
def _ReadSymbolicRef(self, name: str) -> None:
|
||||||
"""Read a symbolic reference."""
|
"""Read a symbolic reference."""
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
["symbolic-ref", "-q", name],
|
["symbolic-ref", "-q", name],
|
||||||
capture_stdout=True,
|
capture_stdout=True,
|
||||||
@@ -180,7 +179,7 @@ class GitRefs:
|
|||||||
self._symref[name] = ref
|
self._symref[name] = ref
|
||||||
return
|
return
|
||||||
|
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
["rev-parse", "--verify", "-q", name],
|
["rev-parse", "--verify", "-q", name],
|
||||||
capture_stdout=True,
|
capture_stdout=True,
|
||||||
|
|||||||
+6
-7
@@ -18,8 +18,7 @@ from collections import OrderedDict
|
|||||||
import os
|
import os
|
||||||
from typing import Iterator, List, Optional, TYPE_CHECKING
|
from typing import Iterator, List, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from git_command import git_require
|
import git_command
|
||||||
from git_command import GitCommand
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -80,7 +79,7 @@ def GetStatus(
|
|||||||
show_stash: bool = False,
|
show_stash: bool = False,
|
||||||
) -> StatusSnapshot:
|
) -> StatusSnapshot:
|
||||||
"""Return one machine-readable status snapshot for |project|."""
|
"""Return one machine-readable status snapshot for |project|."""
|
||||||
if not git_require((2, 11, 0)):
|
if not git_command.git_require((2, 11, 0)):
|
||||||
raise UnsupportedStatusError("porcelain v2 requires Git 2.11")
|
raise UnsupportedStatusError("porcelain v2 requires Git 2.11")
|
||||||
cmd = [
|
cmd = [
|
||||||
"status",
|
"status",
|
||||||
@@ -91,18 +90,18 @@ def GetStatus(
|
|||||||
]
|
]
|
||||||
if branch:
|
if branch:
|
||||||
cmd.append("--branch")
|
cmd.append("--branch")
|
||||||
if git_require((2, 17, 0)):
|
if git_command.git_require((2, 17, 0)):
|
||||||
cmd.append(
|
cmd.append(
|
||||||
"--ahead-behind" if ahead_behind else "--no-ahead-behind"
|
"--ahead-behind" if ahead_behind else "--no-ahead-behind"
|
||||||
)
|
)
|
||||||
if git_require((2, 18, 0)):
|
if git_command.git_require((2, 18, 0)):
|
||||||
# Match the existing staged diff's explicit rename detection even if
|
# Match the existing staged diff's explicit rename detection even if
|
||||||
# status.renames is disabled in the user's config.
|
# status.renames is disabled in the user's config.
|
||||||
cmd.append("--renames")
|
cmd.append("--renames")
|
||||||
if show_stash and git_require((2, 35, 0)):
|
if show_stash and git_command.git_require((2, 35, 0)):
|
||||||
cmd.append("--show-stash")
|
cmd.append("--show-stash")
|
||||||
|
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
cmd,
|
cmd,
|
||||||
bare=False,
|
bare=False,
|
||||||
|
|||||||
+7
-8
@@ -32,8 +32,7 @@ import time
|
|||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from git_command import git_require
|
import git_command
|
||||||
from git_command import GitCommand
|
|
||||||
from git_config import IsId
|
from git_config import IsId
|
||||||
from git_config import RepoConfig
|
from git_config import RepoConfig
|
||||||
import platform_utils
|
import platform_utils
|
||||||
@@ -140,7 +139,7 @@ class Superproject:
|
|||||||
def commit_id(self):
|
def commit_id(self):
|
||||||
"""Returns the commit ID of the superproject checkout."""
|
"""Returns the commit ID of the superproject checkout."""
|
||||||
cmd = ["rev-parse", self.revision]
|
cmd = ["rev-parse", self.revision]
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None, # project
|
None, # project
|
||||||
cmd,
|
cmd,
|
||||||
gitdir=self._work_git,
|
gitdir=self._work_git,
|
||||||
@@ -242,7 +241,7 @@ class Superproject:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
cmd = ["init", "--bare", tmp_git_name]
|
cmd = ["init", "--bare", tmp_git_name]
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
cmd,
|
cmd,
|
||||||
cwd=self._superproject_path,
|
cwd=self._superproject_path,
|
||||||
@@ -292,7 +291,7 @@ class Superproject:
|
|||||||
if not os.path.exists(self._work_git):
|
if not os.path.exists(self._work_git):
|
||||||
self._LogWarning("git fetch missing directory: {}", self._work_git)
|
self._LogWarning("git fetch missing directory: {}", self._work_git)
|
||||||
return False
|
return False
|
||||||
if not git_require((2, 28, 0)):
|
if not git_command.git_require((2, 28, 0)):
|
||||||
self._LogWarning(
|
self._LogWarning(
|
||||||
"superproject requires a git version 2.28 or later"
|
"superproject requires a git version 2.28 or later"
|
||||||
)
|
)
|
||||||
@@ -329,7 +328,7 @@ class Superproject:
|
|||||||
else f"{self.revision}:{self.revision}"
|
else f"{self.revision}:{self.revision}"
|
||||||
)
|
)
|
||||||
cmd.append(refspec)
|
cmd.append(refspec)
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
cmd,
|
cmd,
|
||||||
gitdir=self._work_git,
|
gitdir=self._work_git,
|
||||||
@@ -351,7 +350,7 @@ class Superproject:
|
|||||||
|
|
||||||
def _GetRef(self, ref: str) -> str:
|
def _GetRef(self, ref: str) -> str:
|
||||||
"""Resolve one local ref without loading the entire ref namespace."""
|
"""Resolve one local ref without loading the entire ref namespace."""
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
["rev-parse", "--verify", "--quiet", ref],
|
["rev-parse", "--verify", "--quiet", ref],
|
||||||
gitdir=self._work_git,
|
gitdir=self._work_git,
|
||||||
@@ -381,7 +380,7 @@ class Superproject:
|
|||||||
branch = "HEAD" if not self.revision else self.revision
|
branch = "HEAD" if not self.revision else self.revision
|
||||||
cmd = ["ls-tree", "-z", "-r", branch]
|
cmd = ["ls-tree", "-z", "-r", branch]
|
||||||
|
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
cmd,
|
cmd,
|
||||||
gitdir=self._work_git,
|
gitdir=self._work_git,
|
||||||
|
|||||||
@@ -14,8 +14,7 @@
|
|||||||
|
|
||||||
"""Event logging in the git trace2 EVENT format."""
|
"""Event logging in the git trace2 EVENT format."""
|
||||||
|
|
||||||
from git_command import GetEventTargetPath
|
import git_command
|
||||||
from git_command import RepoSourceVersion
|
|
||||||
from git_trace2_event_log_base import BaseEventLog
|
from git_trace2_event_log_base import BaseEventLog
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +36,9 @@ class EventLog(BaseEventLog):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(repo_source_version=RepoSourceVersion(), **kwargs)
|
super().__init__(
|
||||||
|
repo_source_version=git_command.RepoSourceVersion(), **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
def Write(self, path=None, **kwargs):
|
def Write(self, path=None, **kwargs):
|
||||||
if path is None:
|
if path is None:
|
||||||
@@ -45,4 +46,4 @@ class EventLog(BaseEventLog):
|
|||||||
return super().Write(path=path, **kwargs)
|
return super().Write(path=path, **kwargs)
|
||||||
|
|
||||||
def _GetEventTargetPath(self):
|
def _GetEventTargetPath(self):
|
||||||
return GetEventTargetPath()
|
return git_command.GetEventTargetPath()
|
||||||
@@ -19,8 +19,8 @@ import sys
|
|||||||
import traceback
|
import traceback
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from error import HookError
|
import error
|
||||||
from git_refs import HEAD
|
import git_refs
|
||||||
|
|
||||||
|
|
||||||
# The API we've documented to hook authors. Keep in sync with repo-hooks.md.
|
# The API we've documented to hook authors. Keep in sync with repo-hooks.md.
|
||||||
@@ -142,7 +142,7 @@ class RepoHook:
|
|||||||
# NOTE: Local (non-committed) changes will not be factored into this
|
# NOTE: Local (non-committed) changes will not be factored into this
|
||||||
# hash. I think this is OK, since we're really only worried about
|
# hash. I think this is OK, since we're really only worried about
|
||||||
# warning the user about upstream changes.
|
# warning the user about upstream changes.
|
||||||
return self._hooks_project.work_git.rev_parse(HEAD)
|
return self._hooks_project.work_git.rev_parse(git_refs.HEAD)
|
||||||
|
|
||||||
def _GetMustVerb(self):
|
def _GetMustVerb(self):
|
||||||
"""Return 'must' if the hook is required; 'should' if not."""
|
"""Return 'must' if the hook is required; 'should' if not."""
|
||||||
@@ -228,7 +228,7 @@ class RepoHook:
|
|||||||
|
|
||||||
# For anything else, we'll assume no approval.
|
# For anything else, we'll assume no approval.
|
||||||
if self._abort_if_user_denies:
|
if self._abort_if_user_denies:
|
||||||
raise HookError(
|
raise error.HookError(
|
||||||
"You must allow the %s hook or use --no-verify."
|
"You must allow the %s hook or use --no-verify."
|
||||||
% self._hook_type
|
% self._hook_type
|
||||||
)
|
)
|
||||||
@@ -326,14 +326,16 @@ class RepoHook:
|
|||||||
try:
|
try:
|
||||||
exec(compile(data, self._script_fullpath, "exec"), context)
|
exec(compile(data, self._script_fullpath, "exec"), context)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HookError(
|
raise error.HookError(
|
||||||
"%s\nFailed to import %s hook; see traceback above."
|
"%s\nFailed to import %s hook; see traceback above."
|
||||||
% (traceback.format_exc(), self._hook_type)
|
% (traceback.format_exc(), self._hook_type)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Running the script should have defined a main() function.
|
# Running the script should have defined a main() function.
|
||||||
if "main" not in context:
|
if "main" not in context:
|
||||||
raise HookError('Missing main() in: "%s"' % self._script_fullpath)
|
raise error.HookError(
|
||||||
|
'Missing main() in: "%s"' % self._script_fullpath
|
||||||
|
)
|
||||||
|
|
||||||
# Call the main function in the hook. If the hook should cause the
|
# Call the main function in the hook. If the hook should cause the
|
||||||
# build to fail, it will raise an Exception. We'll catch that convert
|
# build to fail, it will raise an Exception. We'll catch that convert
|
||||||
@@ -341,7 +343,7 @@ class RepoHook:
|
|||||||
try:
|
try:
|
||||||
context["main"](**kwargs)
|
context["main"](**kwargs)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HookError(
|
raise error.HookError(
|
||||||
"%s\nFailed to run main() for %s hook; see traceback "
|
"%s\nFailed to run main() for %s hook; see traceback "
|
||||||
"above." % (traceback.format_exc(), self._hook_type)
|
"above." % (traceback.format_exc(), self._hook_type)
|
||||||
)
|
)
|
||||||
@@ -394,7 +396,7 @@ class RepoHook:
|
|||||||
if interp:
|
if interp:
|
||||||
prog = os.path.basename(interp)
|
prog = os.path.basename(interp)
|
||||||
if prog.startswith("python2"):
|
if prog.startswith("python2"):
|
||||||
raise HookError("Python 2 is not supported")
|
raise error.HookError("Python 2 is not supported")
|
||||||
|
|
||||||
# Run the hook by importing directly.
|
# Run the hook by importing directly.
|
||||||
self._ExecuteHookViaImport(data, context, **kwargs)
|
self._ExecuteHookViaImport(data, context, **kwargs)
|
||||||
@@ -406,7 +408,7 @@ class RepoHook:
|
|||||||
def _CheckHook(self):
|
def _CheckHook(self):
|
||||||
# Bail with a nice error if we can't find the hook.
|
# Bail with a nice error if we can't find the hook.
|
||||||
if not os.path.isfile(self._script_fullpath):
|
if not os.path.isfile(self._script_fullpath):
|
||||||
raise HookError(
|
raise error.HookError(
|
||||||
"Couldn't find repo hook: %s" % self._script_fullpath
|
"Couldn't find repo hook: %s" % self._script_fullpath
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -473,7 +475,7 @@ class RepoHook:
|
|||||||
% (self._hook_type, str(e)),
|
% (self._hook_type, str(e)),
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
except HookError as e:
|
except error.HookError as e:
|
||||||
passed = False
|
passed = False
|
||||||
print("ERROR: %s" % str(e), file=sys.stderr)
|
print("ERROR: %s" % str(e), file=sys.stderr)
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import time
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -45,34 +45,22 @@ from color import SetDefaultColoring
|
|||||||
from command import InteractiveCommand
|
from command import InteractiveCommand
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from editor import Editor
|
from editor import Editor
|
||||||
from error import DownloadError
|
import error
|
||||||
from error import InvalidProjectGroupsError
|
|
||||||
from error import ManifestInvalidRevisionError
|
|
||||||
from error import ManifestParseError
|
|
||||||
from error import NoManifestException
|
|
||||||
from error import NoSuchProjectError
|
|
||||||
from error import RepoChangedException
|
|
||||||
from error import RepoError
|
|
||||||
from error import RepoExitError
|
|
||||||
from error import RepoUnhandledExceptionError
|
|
||||||
from error import SilentRepoExitError
|
|
||||||
import event_log
|
import event_log
|
||||||
from git_command import user_agent
|
import git_command
|
||||||
from git_config import RepoConfig
|
from git_config import RepoConfig
|
||||||
from git_trace2_event_log import EventLog
|
from git_trace2_event_log import EventLog
|
||||||
from manifest_xml import RepoClient
|
from manifest_xml import RepoClient
|
||||||
from pager import RunPager
|
from pager import RunPager
|
||||||
from pager import TerminatePager
|
from pager import TerminatePager
|
||||||
from repo_trace import SetTrace
|
import repo_trace
|
||||||
from repo_trace import SetTraceToStderr
|
|
||||||
from repo_trace import Trace
|
|
||||||
from subcmds import all_commands
|
from subcmds import all_commands
|
||||||
from subcmds.version import Version
|
from subcmds.version import Version
|
||||||
from wrapper import Wrapper
|
from wrapper import Wrapper
|
||||||
from wrapper import WrapperPath
|
from wrapper import WrapperPath
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
# NB: These do not need to be kept in sync with the repo launcher script.
|
# NB: These do not need to be kept in sync with the repo launcher script.
|
||||||
@@ -279,7 +267,7 @@ class _Repo:
|
|||||||
run = (
|
run = (
|
||||||
lambda: self._RunLong(name, gopts, argv, git_trace2_event_log) or 0
|
lambda: self._RunLong(name, gopts, argv, git_trace2_event_log) or 0
|
||||||
)
|
)
|
||||||
with Trace(
|
with repo_trace.Trace(
|
||||||
"starting new command: %s [sid=%s]",
|
"starting new command: %s [sid=%s]",
|
||||||
", ".join([name] + argv),
|
", ".join([name] + argv),
|
||||||
git_trace2_event_log.full_sid,
|
git_trace2_event_log.full_sid,
|
||||||
@@ -435,7 +423,7 @@ class _Repo:
|
|||||||
try:
|
try:
|
||||||
copts, cargs = cmd.OptionParser.parse_args(argv)
|
copts, cargs = cmd.OptionParser.parse_args(argv)
|
||||||
copts = cmd.ReadEnvironmentOptions(copts)
|
copts = cmd.ReadEnvironmentOptions(copts)
|
||||||
except NoManifestException as e:
|
except error.NoManifestException as e:
|
||||||
logger.error("error: in `%s`: %s", " ".join([name] + argv), e)
|
logger.error("error: in `%s`: %s", " ".join([name] + argv), e)
|
||||||
logger.error(
|
logger.error(
|
||||||
"error: manifest missing or unreadable -- please run init"
|
"error: manifest missing or unreadable -- please run init"
|
||||||
@@ -511,23 +499,23 @@ class _Repo:
|
|||||||
KeyboardInterrupt,
|
KeyboardInterrupt,
|
||||||
SystemExit,
|
SystemExit,
|
||||||
Exception,
|
Exception,
|
||||||
RepoExitError,
|
error.RepoExitError,
|
||||||
) as e:
|
) as e:
|
||||||
ok = isinstance(e, SystemExit) and not e.code
|
ok = isinstance(e, SystemExit) and not e.code
|
||||||
exception_name = type(e).__name__
|
exception_name = type(e).__name__
|
||||||
if isinstance(e, RepoUnhandledExceptionError):
|
if isinstance(e, error.RepoUnhandledExceptionError):
|
||||||
exception_name = type(e.error).__name__
|
exception_name = type(e.error).__name__
|
||||||
if isinstance(e, RepoExitError):
|
if isinstance(e, error.RepoExitError):
|
||||||
aggregated_errors = e.aggregate_errors or []
|
aggregated_errors = e.aggregate_errors or []
|
||||||
for error in aggregated_errors:
|
for err in aggregated_errors:
|
||||||
project = None
|
project = None
|
||||||
if isinstance(error, RepoError):
|
if isinstance(err, error.RepoError):
|
||||||
project = error.project
|
project = err.project
|
||||||
error_info = json.dumps(
|
error_info = json.dumps(
|
||||||
{
|
{
|
||||||
"ErrorType": type(error).__name__,
|
"ErrorType": type(err).__name__,
|
||||||
"Project": str(project),
|
"Project": str(project),
|
||||||
"Message": str(error),
|
"Message": str(err),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
git_trace2_event_log.ErrorEvent(
|
git_trace2_event_log.ErrorEvent(
|
||||||
@@ -542,24 +530,24 @@ class _Repo:
|
|||||||
try:
|
try:
|
||||||
execute_command()
|
execute_command()
|
||||||
except (
|
except (
|
||||||
DownloadError,
|
error.DownloadError,
|
||||||
ManifestInvalidRevisionError,
|
error.ManifestInvalidRevisionError,
|
||||||
ManifestParseError,
|
error.ManifestParseError,
|
||||||
NoManifestException,
|
error.NoManifestException,
|
||||||
) as e:
|
) as e:
|
||||||
logger.error("error: in `%s`: %s", " ".join([name] + argv), e)
|
logger.error("error: in `%s`: %s", " ".join([name] + argv), e)
|
||||||
if isinstance(e, NoManifestException):
|
if isinstance(e, error.NoManifestException):
|
||||||
logger.error(
|
logger.error(
|
||||||
"error: manifest missing or unreadable -- please run init"
|
"error: manifest missing or unreadable -- please run init"
|
||||||
)
|
)
|
||||||
result = e.exit_code
|
result = e.exit_code
|
||||||
except NoSuchProjectError as e:
|
except error.NoSuchProjectError as e:
|
||||||
if e.name:
|
if e.name:
|
||||||
logger.error("error: project %s not found", e.name)
|
logger.error("error: project %s not found", e.name)
|
||||||
else:
|
else:
|
||||||
logger.error("error: no project in current directory")
|
logger.error("error: no project in current directory")
|
||||||
result = e.exit_code
|
result = e.exit_code
|
||||||
except InvalidProjectGroupsError as e:
|
except error.InvalidProjectGroupsError as e:
|
||||||
if e.name:
|
if e.name:
|
||||||
logger.error(
|
logger.error(
|
||||||
"error: project group must be enabled for project %s",
|
"error: project group must be enabled for project %s",
|
||||||
@@ -578,7 +566,7 @@ class _Repo:
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
result = KEYBOARD_INTERRUPT_EXIT
|
result = KEYBOARD_INTERRUPT_EXIT
|
||||||
raise
|
raise
|
||||||
except RepoExitError as e:
|
except error.RepoExitError as e:
|
||||||
result = e.exit_code
|
result = e.exit_code
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -712,11 +700,11 @@ def _PruneOptions(argv, opt):
|
|||||||
|
|
||||||
class _UserAgentHandler(urllib.request.BaseHandler):
|
class _UserAgentHandler(urllib.request.BaseHandler):
|
||||||
def http_request(self, req):
|
def http_request(self, req):
|
||||||
req.add_header("User-Agent", user_agent.repo)
|
req.add_header("User-Agent", git_command.user_agent.repo)
|
||||||
return req
|
return req
|
||||||
|
|
||||||
def https_request(self, req):
|
def https_request(self, req):
|
||||||
req.add_header("User-Agent", user_agent.repo)
|
req.add_header("User-Agent", git_command.user_agent.repo)
|
||||||
return req
|
return req
|
||||||
|
|
||||||
|
|
||||||
@@ -943,20 +931,20 @@ def _Main(argv):
|
|||||||
name, gopts, argv = repo._ParseArgs(argv)
|
name, gopts, argv = repo._ParseArgs(argv)
|
||||||
|
|
||||||
if gopts.trace:
|
if gopts.trace:
|
||||||
SetTrace()
|
repo_trace.SetTrace()
|
||||||
|
|
||||||
if gopts.trace_to_stderr:
|
if gopts.trace_to_stderr:
|
||||||
SetTraceToStderr()
|
repo_trace.SetTraceToStderr()
|
||||||
|
|
||||||
result = repo._Run(name, gopts, argv) or 0
|
result = repo._Run(name, gopts, argv) or 0
|
||||||
except RepoExitError as e:
|
except error.RepoExitError as e:
|
||||||
if not isinstance(e, SilentRepoExitError):
|
if not isinstance(e, error.SilentRepoExitError):
|
||||||
logger.log_aggregated_errors(e)
|
logger.log_aggregated_errors(e)
|
||||||
result = e.exit_code
|
result = e.exit_code
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("aborted by user", file=sys.stderr)
|
print("aborted by user", file=sys.stderr)
|
||||||
result = KEYBOARD_INTERRUPT_EXIT
|
result = KEYBOARD_INTERRUPT_EXIT
|
||||||
except RepoChangedException as rce:
|
except error.RepoChangedException as rce:
|
||||||
# If repo changed, re-exec ourselves.
|
# If repo changed, re-exec ourselves.
|
||||||
argv = list(sys.argv)
|
argv = list(sys.argv)
|
||||||
argv.extend(rce.extra_args)
|
argv.extend(rce.extra_args)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||||
|
.TH REPO "1" "October 2026" "repo config" "Repo Manual"
|
||||||
|
.SH NAME
|
||||||
|
repo \- repo config - manual page for repo config
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B repo
|
||||||
|
\fI\,config \/\fR[\fI\,--type=<type>\/\fR] [\fI\,-z\/\fR] \fI\,get <name>\/\fR
|
||||||
|
.SH DESCRIPTION
|
||||||
|
Summary
|
||||||
|
.PP
|
||||||
|
Read and write per\-user repo settings
|
||||||
|
.PP
|
||||||
|
repo config [\-\-type=<type>] set <name> <value>
|
||||||
|
repo config unset <name>
|
||||||
|
repo config [\-z] list
|
||||||
|
.SH OPTIONS
|
||||||
|
.TP
|
||||||
|
\fB\-h\fR, \fB\-\-help\fR
|
||||||
|
show this help message and exit
|
||||||
|
.TP
|
||||||
|
\fB\-\-type\fR=\fI\,TYPE\/\fR
|
||||||
|
validate and canonicalize the value as this type for get and set (bool, int)
|
||||||
|
.TP
|
||||||
|
\fB\-z\fR, \fB\-\-null\fR
|
||||||
|
terminate output of get and list with NUL instead of newline, and separate key and value with a newline
|
||||||
|
.SS Logging options:
|
||||||
|
.TP
|
||||||
|
\fB\-v\fR, \fB\-\-verbose\fR
|
||||||
|
show all output
|
||||||
|
.TP
|
||||||
|
\fB\-q\fR, \fB\-\-quiet\fR
|
||||||
|
only show errors
|
||||||
|
.SS Multi\-manifest options:
|
||||||
|
.TP
|
||||||
|
\fB\-\-outer\-manifest\fR
|
||||||
|
operate starting at the outermost manifest
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-outer\-manifest\fR
|
||||||
|
do not operate on outer manifests
|
||||||
|
.TP
|
||||||
|
\fB\-\-this\-manifest\-only\fR
|
||||||
|
only operate on this (sub)manifest
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-this\-manifest\-only\fR, \fB\-\-all\-manifests\fR
|
||||||
|
operate on this manifest and its submanifests
|
||||||
|
.PP
|
||||||
|
Run `repo help config` to view the detailed manual.
|
||||||
|
.SH DETAILS
|
||||||
|
.PP
|
||||||
|
The 'repo config' command reads and writes repo's own per\-user settings. These
|
||||||
|
are separate from git's config and from a checkout's settings; see
|
||||||
|
docs/repoconfig.md for where they live and which settings repo understands.
|
||||||
|
.PP
|
||||||
|
Settings are named <section>.<name>, e.g. sync.smartsync. 'get' prints every
|
||||||
|
value of the setting and exits 1 without output if it is not set, like `git
|
||||||
|
config`.
|
||||||
|
.PP
|
||||||
|
Values are stored as strings. With \fB\-\-type\fR, 'set' validates the value and stores
|
||||||
|
it in canonical form (e.g. "on" as "true", "1k" as "1024"), and 'get' prints the
|
||||||
|
canonical form, like `git config \fB\-\-type\fR`.
|
||||||
+12
-1
@@ -1,5 +1,5 @@
|
|||||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||||
.TH REPO "1" "June 2026" "repo gc" "Repo Manual"
|
.TH REPO "1" "September 2026" "repo gc" "Repo Manual"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
repo \- repo gc - manual page for repo gc
|
repo \- repo gc - manual page for repo gc
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -22,6 +22,9 @@ answer yes to all safe prompts
|
|||||||
.TP
|
.TP
|
||||||
\fB\-\-repack\fR
|
\fB\-\-repack\fR
|
||||||
repack all projects that use partial clone with filter=blob:none
|
repack all projects that use partial clone with filter=blob:none
|
||||||
|
.TP
|
||||||
|
\fB\-\-keep\-manifest\-projects\fR
|
||||||
|
keep Git data for projects still in the manifests
|
||||||
.SS Logging options:
|
.SS Logging options:
|
||||||
.TP
|
.TP
|
||||||
\fB\-v\fR, \fB\-\-verbose\fR
|
\fB\-v\fR, \fB\-\-verbose\fR
|
||||||
@@ -44,3 +47,11 @@ only operate on this (sub)manifest
|
|||||||
operate on this manifest and its submanifests
|
operate on this manifest and its submanifests
|
||||||
.PP
|
.PP
|
||||||
Run `repo help gc` to view the detailed manual.
|
Run `repo help gc` to view the detailed manual.
|
||||||
|
.SH DETAILS
|
||||||
|
.PP
|
||||||
|
The 'repo gc' command removes internal Git data for projects that are no longer
|
||||||
|
active in the current configuration.
|
||||||
|
.PP
|
||||||
|
Projects excluded by the configured manifest groups are inactive by default. Use
|
||||||
|
\fB\-\-keep\-manifest\-projects\fR to keep Git data for projects that are still in the
|
||||||
|
manifests.
|
||||||
@@ -103,6 +103,12 @@ use the manifest superproject to sync projects; implies \fB\-c\fR
|
|||||||
\fB\-\-no\-use\-superproject\fR
|
\fB\-\-no\-use\-superproject\fR
|
||||||
disable use of manifest superprojects
|
disable use of manifest superprojects
|
||||||
.TP
|
.TP
|
||||||
|
\fB\-s\fR, \fB\-\-smart\-sync\fR
|
||||||
|
default to smart sync when syncing projects
|
||||||
|
.TP
|
||||||
|
\fB\-\-no\-smart\-sync\fR, \fB\-\-head\fR
|
||||||
|
do not default to smart sync when syncing projects
|
||||||
|
.TP
|
||||||
\fB\-\-clone\-bundle\fR
|
\fB\-\-clone\-bundle\fR
|
||||||
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS (default if not \fB\-\-partial\-clone\fR)
|
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS (default if not \fB\-\-partial\-clone\fR)
|
||||||
.TP
|
.TP
|
||||||
|
|||||||
+14
-11
@@ -1,5 +1,5 @@
|
|||||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||||
.TH REPO "1" "June 2026" "repo manifest" "Repo Manual"
|
.TH REPO "1" "August 2026" "repo manifest" "Repo Manual"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
repo \- repo manifest - manual page for repo manifest
|
repo \- repo manifest - manual page for repo manifest
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -121,18 +121,19 @@ include*)>
|
|||||||
<!ATTLIST remote revision CDATA #IMPLIED>
|
<!ATTLIST remote revision CDATA #IMPLIED>
|
||||||
.IP
|
.IP
|
||||||
<!ELEMENT default EMPTY>
|
<!ELEMENT default EMPTY>
|
||||||
<!ATTLIST default remote IDREF #IMPLIED>
|
<!ATTLIST default remote IDREF #IMPLIED>
|
||||||
<!ATTLIST default revision CDATA #IMPLIED>
|
<!ATTLIST default revision CDATA #IMPLIED>
|
||||||
<!ATTLIST default dest\-branch CDATA #IMPLIED>
|
<!ATTLIST default dest\-branch CDATA #IMPLIED>
|
||||||
<!ATTLIST default upstream CDATA #IMPLIED>
|
<!ATTLIST default upstream CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync\-j CDATA #IMPLIED>
|
<!ATTLIST default sync\-j CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync\-j\-max CDATA #IMPLIED>
|
<!ATTLIST default sync\-j\-max CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync\-c CDATA #IMPLIED>
|
<!ATTLIST default sync\-c CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync\-s CDATA #IMPLIED>
|
<!ATTLIST default sync\-s CDATA #IMPLIED>
|
||||||
<!ATTLIST default sync\-tags CDATA #IMPLIED>
|
<!ATTLIST default sync\-tags CDATA #IMPLIED>
|
||||||
|
<!ATTLIST default sync\-smartsync CDATA #IMPLIED>
|
||||||
.IP
|
.IP
|
||||||
<!ELEMENT manifest\-server EMPTY>
|
<!ELEMENT manifest\-server EMPTY>
|
||||||
<!ATTLIST manifest\-server url CDATA #REQUIRED>
|
<!ATTLIST manifest\-server url CDATA #REQUIRED>
|
||||||
<!ATTLIST manifest\-server helper CDATA #IMPLIED>
|
<!ATTLIST manifest\-server helper CDATA #IMPLIED>
|
||||||
.IP
|
.IP
|
||||||
<!ELEMENT submanifest EMPTY>
|
<!ELEMENT submanifest EMPTY>
|
||||||
@@ -336,6 +337,8 @@ Attribute `sync\-s`: Set to true to also sync sub\-projects.
|
|||||||
Attribute `sync\-tags`: Set to false to only sync the given Git branch (specified
|
Attribute `sync\-tags`: Set to false to only sync the given Git branch (specified
|
||||||
in the `revision` attribute) rather than the other ref tags.
|
in the `revision` attribute) rather than the other ref tags.
|
||||||
.PP
|
.PP
|
||||||
|
Attribute `sync\-smartsync`: Set to true to enable smart sync by default.
|
||||||
|
.PP
|
||||||
Element manifest\-server
|
Element manifest\-server
|
||||||
.PP
|
.PP
|
||||||
At most one manifest\-server may be specified. The url attribute is used to
|
At most one manifest\-server may be specified. The url attribute is used to
|
||||||
|
|||||||
+8
-1
@@ -1,5 +1,5 @@
|
|||||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||||
.TH REPO "1" "September 2026" "repo sync" "Repo Manual"
|
.TH REPO "1" "October 2026" "repo sync" "Repo Manual"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
repo \- repo sync - manual page for repo sync
|
repo \- repo sync - manual page for repo sync
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -125,6 +125,9 @@ do not run garbage collection on any projects (default)
|
|||||||
\fB\-s\fR, \fB\-\-smart\-sync\fR
|
\fB\-s\fR, \fB\-\-smart\-sync\fR
|
||||||
smart sync using manifest from the latest known good build
|
smart sync using manifest from the latest known good build
|
||||||
.TP
|
.TP
|
||||||
|
\fB\-\-no\-smart\-sync\fR, \fB\-\-head\fR
|
||||||
|
disable smart sync and sync to ToT instead
|
||||||
|
.TP
|
||||||
\fB\-t\fR SMART_TAG, \fB\-\-smart\-tag\fR=\fI\,SMART_TAG\/\fR
|
\fB\-t\fR SMART_TAG, \fB\-\-smart\-tag\fR=\fI\,SMART_TAG\/\fR
|
||||||
smart sync using manifest from a known tag
|
smart sync using manifest from a known tag
|
||||||
.SS Logging options:
|
.SS Logging options:
|
||||||
@@ -185,6 +188,10 @@ The \fB\-s\fR/\-\-smart\-sync option can be used to sync to a known good build a
|
|||||||
specified by the manifest\-server element in the current manifest. The
|
specified by the manifest\-server element in the current manifest. The
|
||||||
\fB\-t\fR/\-\-smart\-tag option is similar and allows you to specify a custom tag/label.
|
\fB\-t\fR/\-\-smart\-tag option is similar and allows you to specify a custom tag/label.
|
||||||
.PP
|
.PP
|
||||||
|
Whether 'repo sync' smart syncs by default can also be controlled with the
|
||||||
|
sync.smartsync per\-user setting; see docs/repoconfig.md. Explicit
|
||||||
|
\fB\-s\fR/\-\-no\-smart\-sync/\-t flags always override it.
|
||||||
|
.PP
|
||||||
The \fB\-u\fR/\-\-manifest\-server\-username and \fB\-p\fR/\-\-manifest\-server\-password options can
|
The \fB\-u\fR/\-\-manifest\-server\-username and \fB\-p\fR/\-\-manifest\-server\-password options can
|
||||||
be used to specify a username and password to authenticate with the manifest
|
be used to specify a username and password to authenticate with the manifest
|
||||||
server when using the \fB\-s\fR or \fB\-t\fR option.
|
server when using the \fB\-s\fR or \fB\-t\fR option.
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,5 @@
|
|||||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||||
.TH REPO "1" "August 2026" "repo upload" "Repo Manual"
|
.TH REPO "1" "October 2026" "repo upload" "Repo Manual"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
repo \- repo upload - manual page for repo upload
|
repo \- repo upload - manual page for repo upload
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -80,6 +80,9 @@ ignore untracked files in the working copy
|
|||||||
\fB\-\-no\-ignore\-untracked\-files\fR
|
\fB\-\-no\-ignore\-untracked\-files\fR
|
||||||
always ask about untracked files in the working copy
|
always ask about untracked files in the working copy
|
||||||
.TP
|
.TP
|
||||||
|
\fB\-\-ignore\-remote\-commits\fR
|
||||||
|
ignore commits already in remote branches
|
||||||
|
.TP
|
||||||
\fB\-\-no\-cert\-checks\fR
|
\fB\-\-no\-cert\-checks\fR
|
||||||
disable verifying ssl certs (unsafe)
|
disable verifying ssl certs (unsafe)
|
||||||
.SS Logging options:
|
.SS Logging options:
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,5 @@
|
|||||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||||
.TH REPO "1" "June 2026" "repo" "Repo Manual"
|
.TH REPO "1" "October 2026" "repo" "Repo Manual"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
repo \- repository management tool built on top of git
|
repo \- repository management tool built on top of git
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -65,6 +65,9 @@ Checkout a branch for development
|
|||||||
cherry\-pick
|
cherry\-pick
|
||||||
Cherry\-pick a change.
|
Cherry\-pick a change.
|
||||||
.TP
|
.TP
|
||||||
|
config
|
||||||
|
Read and write per\-user repo settings
|
||||||
|
.TP
|
||||||
diff
|
diff
|
||||||
Show changes between commit and working tree
|
Show changes between commit and working tree
|
||||||
.TP
|
.TP
|
||||||
|
|||||||
+79
-60
@@ -21,12 +21,9 @@ import sys
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import xml.dom.minidom
|
import xml.dom.minidom
|
||||||
|
|
||||||
from error import ManifestInvalidPathError
|
import error
|
||||||
from error import ManifestInvalidRevisionError
|
|
||||||
from error import ManifestParseError
|
|
||||||
from git_config import GitConfig
|
from git_config import GitConfig
|
||||||
from git_refs import HEAD
|
import git_refs
|
||||||
from git_refs import R_HEADS
|
|
||||||
from git_superproject import Superproject
|
from git_superproject import Superproject
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from project import Annotation
|
from project import Annotation
|
||||||
@@ -114,7 +111,9 @@ def XmlInt(node, attr, default=None):
|
|||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ManifestParseError(f'manifest: invalid {attr}="{value}" integer')
|
raise error.ManifestParseError(
|
||||||
|
f'manifest: invalid {attr}="{value}" integer'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def normalize_url(url: str) -> str:
|
def normalize_url(url: str) -> str:
|
||||||
@@ -159,6 +158,7 @@ class _Default:
|
|||||||
sync_c = False
|
sync_c = False
|
||||||
sync_s = False
|
sync_s = False
|
||||||
sync_tags = True
|
sync_tags = True
|
||||||
|
sync_smartsync = False
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
if not isinstance(other, _Default):
|
if not isinstance(other, _Default):
|
||||||
@@ -289,7 +289,7 @@ class _XmlSubmanifest:
|
|||||||
self.annotations = []
|
self.annotations = []
|
||||||
outer_client = parent._outer_client or parent
|
outer_client = parent._outer_client or parent
|
||||||
if self.remote and not self.project:
|
if self.remote and not self.project:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
f"Submanifest {name}: must specify project when remote is "
|
f"Submanifest {name}: must specify project when remote is "
|
||||||
"given."
|
"given."
|
||||||
)
|
)
|
||||||
@@ -423,7 +423,7 @@ class XmlManifest:
|
|||||||
# This avoids a trailing os.path.sep when submanifest_path is empty.
|
# This avoids a trailing os.path.sep when submanifest_path is empty.
|
||||||
self.topdir = os.path.join(self.topdir, submanifest_path)
|
self.topdir = os.path.join(self.topdir, submanifest_path)
|
||||||
if manifest_file != os.path.abspath(manifest_file):
|
if manifest_file != os.path.abspath(manifest_file):
|
||||||
raise ManifestParseError("manifest_file must be abspath")
|
raise error.ManifestParseError("manifest_file must be abspath")
|
||||||
self.manifestFile = manifest_file
|
self.manifestFile = manifest_file
|
||||||
if not outer_client or outer_client == self:
|
if not outer_client or outer_client == self:
|
||||||
# manifestFileOverrides only exists in the outer_client's manifest,
|
# manifestFileOverrides only exists in the outer_client's manifest,
|
||||||
@@ -437,7 +437,9 @@ class XmlManifest:
|
|||||||
|
|
||||||
if submanifest_path and not outer_client:
|
if submanifest_path and not outer_client:
|
||||||
# If passing a submanifest_path, there must be an outer_client.
|
# If passing a submanifest_path, there must be an outer_client.
|
||||||
raise ManifestParseError(f"Bad call to {self.__class__.__name__}")
|
raise error.ManifestParseError(
|
||||||
|
f"Bad call to {self.__class__.__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
# If self._outer_client is None, this is not a checkout that supports
|
# If self._outer_client is None, this is not a checkout that supports
|
||||||
# multi-tree.
|
# multi-tree.
|
||||||
@@ -476,13 +478,24 @@ class XmlManifest:
|
|||||||
if path is None:
|
if path is None:
|
||||||
path = os.path.join(self.manifestProject.worktree, name)
|
path = os.path.join(self.manifestProject.worktree, name)
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
raise ManifestParseError("manifest %s not found" % name)
|
raise error.ManifestParseError("manifest %s not found" % name)
|
||||||
|
|
||||||
self._load_local_manifests = load_local_manifests
|
self._load_local_manifests = load_local_manifests
|
||||||
self._outer_client.manifestFileOverrides[self.path_prefix] = path
|
self._outer_client.manifestFileOverrides[self.path_prefix] = path
|
||||||
self.Unload()
|
self.Unload()
|
||||||
self._Load()
|
self._Load()
|
||||||
|
|
||||||
|
def ClearOverride(self) -> None:
|
||||||
|
"""Stop overriding the manifest, reverting to the default one.
|
||||||
|
|
||||||
|
This is the inverse of Override(), and is a no-op if no override is in
|
||||||
|
effect. It is useful to recover from a failed Override(), which
|
||||||
|
registers the override before parsing it.
|
||||||
|
"""
|
||||||
|
self._outer_client.manifestFileOverrides.pop(self.path_prefix, None)
|
||||||
|
self._load_local_manifests = True
|
||||||
|
self.Unload()
|
||||||
|
|
||||||
def Link(self, name):
|
def Link(self, name):
|
||||||
"""Update the repo metadata to use a different manifest."""
|
"""Update the repo metadata to use a different manifest."""
|
||||||
self.Override(name)
|
self.Override(name)
|
||||||
@@ -644,6 +657,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if not d.sync_tags:
|
if not d.sync_tags:
|
||||||
have_default = True
|
have_default = True
|
||||||
e.setAttribute("sync-tags", "false")
|
e.setAttribute("sync-tags", "false")
|
||||||
|
if d.sync_smartsync:
|
||||||
|
have_default = True
|
||||||
|
e.setAttribute("sync-smartsync", "true")
|
||||||
if have_default:
|
if have_default:
|
||||||
root.appendChild(e)
|
root.appendChild(e)
|
||||||
root.appendChild(doc.createTextNode(""))
|
root.appendChild(doc.createTextNode(""))
|
||||||
@@ -694,7 +710,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if self.IsMirror:
|
if self.IsMirror:
|
||||||
value = p.bare_git.ResolveCommit(p.revisionExpr)
|
value = p.bare_git.ResolveCommit(p.revisionExpr)
|
||||||
else:
|
else:
|
||||||
value = p.work_git.ResolveCommit(HEAD)
|
value = p.work_git.ResolveCommit(git_refs.HEAD)
|
||||||
e.setAttribute("revision", value)
|
e.setAttribute("revision", value)
|
||||||
if peg_rev_upstream:
|
if peg_rev_upstream:
|
||||||
if p.upstream:
|
if p.upstream:
|
||||||
@@ -848,7 +864,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
elif child.nodeName in MULTI_ELEMENTS:
|
elif child.nodeName in MULTI_ELEMENTS:
|
||||||
ret.setdefault(child.nodeName, []).append(element)
|
ret.setdefault(child.nodeName, []).append(element)
|
||||||
else:
|
else:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
f'Unhandled element "{child.nodeName}"'
|
f'Unhandled element "{child.nodeName}"'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1173,7 +1189,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
|
|
||||||
def _Load(self, initial_client=None, submanifest_depth=0):
|
def _Load(self, initial_client=None, submanifest_depth=0):
|
||||||
if submanifest_depth > MAX_SUBMANIFEST_DEPTH:
|
if submanifest_depth > MAX_SUBMANIFEST_DEPTH:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"maximum submanifest depth %d exceeded." % MAX_SUBMANIFEST_DEPTH
|
"maximum submanifest depth %d exceeded." % MAX_SUBMANIFEST_DEPTH
|
||||||
)
|
)
|
||||||
if not self._loaded:
|
if not self._loaded:
|
||||||
@@ -1191,8 +1207,8 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
try:
|
try:
|
||||||
m = self.manifestProject
|
m = self.manifestProject
|
||||||
b = m.GetBranch(m.CurrentBranch).merge
|
b = m.GetBranch(m.CurrentBranch).merge
|
||||||
if b is not None and b.startswith(R_HEADS):
|
if b is not None and b.startswith(git_refs.R_HEADS):
|
||||||
b = b[len(R_HEADS) :]
|
b = b[len(git_refs.R_HEADS) :]
|
||||||
self.branch = b
|
self.branch = b
|
||||||
|
|
||||||
parent_groups = self.parent_groups.copy()
|
parent_groups = self.parent_groups.copy()
|
||||||
@@ -1245,7 +1261,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self._ParseManifest(nodes)
|
self._ParseManifest(nodes)
|
||||||
except ManifestParseError as e:
|
except error.ManifestParseError as e:
|
||||||
# There was a problem parsing, unload ourselves in case they
|
# There was a problem parsing, unload ourselves in case they
|
||||||
# catch this error and try again later, we will show the
|
# catch this error and try again later, we will show the
|
||||||
# correct error
|
# correct error
|
||||||
@@ -1307,10 +1323,12 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
try:
|
try:
|
||||||
root = xml.dom.minidom.parse(path)
|
root = xml.dom.minidom.parse(path)
|
||||||
except (OSError, xml.parsers.expat.ExpatError) as e:
|
except (OSError, xml.parsers.expat.ExpatError) as e:
|
||||||
raise ManifestParseError(f"error parsing manifest {path}: {e}")
|
raise error.ManifestParseError(
|
||||||
|
f"error parsing manifest {path}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
if not root or not root.childNodes:
|
if not root or not root.childNodes:
|
||||||
raise ManifestParseError(f"no root node in {path}")
|
raise error.ManifestParseError(f"no root node in {path}")
|
||||||
|
|
||||||
for manifest in root.childNodes:
|
for manifest in root.childNodes:
|
||||||
if (
|
if (
|
||||||
@@ -1319,7 +1337,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
):
|
):
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise ManifestParseError(f"no <manifest> in {path}")
|
raise error.ManifestParseError(f"no <manifest> in {path}")
|
||||||
|
|
||||||
nodes = []
|
nodes = []
|
||||||
for node in manifest.childNodes:
|
for node in manifest.childNodes:
|
||||||
@@ -1336,7 +1354,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if restrict_includes:
|
if restrict_includes:
|
||||||
msg = self._CheckLocalPath(name)
|
msg = self._CheckLocalPath(name)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<include> invalid "name": {name}: {msg}'
|
f'<include> invalid "name": {name}: {msg}'
|
||||||
)
|
)
|
||||||
include_groups = (parent_groups or set()).copy()
|
include_groups = (parent_groups or set()).copy()
|
||||||
@@ -1346,7 +1364,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
)
|
)
|
||||||
fp = os.path.join(include_root, name)
|
fp = os.path.join(include_root, name)
|
||||||
if not os.path.isfile(fp):
|
if not os.path.isfile(fp):
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"include [%s/]%s doesn't exist or isn't a file"
|
"include [%s/]%s doesn't exist or isn't a file"
|
||||||
% (include_root, name)
|
% (include_root, name)
|
||||||
)
|
)
|
||||||
@@ -1358,10 +1376,10 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
)
|
)
|
||||||
# should isolate this to the exact exception, but that's
|
# should isolate this to the exact exception, but that's
|
||||||
# tricky. actual parsing implementation may vary.
|
# tricky. actual parsing implementation may vary.
|
||||||
except (RuntimeError, ManifestParseError):
|
except (RuntimeError, error.ManifestParseError):
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
f"failed parsing included manifest {name}: {e}"
|
f"failed parsing included manifest {name}: {e}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1385,7 +1403,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if remote:
|
if remote:
|
||||||
if remote.name in self._remotes:
|
if remote.name in self._remotes:
|
||||||
if remote != self._remotes[remote.name]:
|
if remote != self._remotes[remote.name]:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"remote %s already exists with different "
|
"remote %s already exists with different "
|
||||||
"attributes" % (remote.name)
|
"attributes" % (remote.name)
|
||||||
)
|
)
|
||||||
@@ -1401,7 +1419,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if self._default is None:
|
if self._default is None:
|
||||||
self._default = new_default
|
self._default = new_default
|
||||||
elif not emptyDefault and new_default != self._default:
|
elif not emptyDefault and new_default != self._default:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate default in %s" % (self.manifestFile)
|
"duplicate default in %s" % (self.manifestFile)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1415,7 +1433,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if submanifest:
|
if submanifest:
|
||||||
if submanifest.name in self._submanifests:
|
if submanifest.name in self._submanifests:
|
||||||
if submanifest != self._submanifests[submanifest.name]:
|
if submanifest != self._submanifests[submanifest.name]:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"submanifest %s already exists with different "
|
"submanifest %s already exists with different "
|
||||||
"attributes" % (submanifest.name)
|
"attributes" % (submanifest.name)
|
||||||
)
|
)
|
||||||
@@ -1426,7 +1444,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
for node in itertools.chain(*node_list):
|
for node in itertools.chain(*node_list):
|
||||||
if node.nodeName == "notice":
|
if node.nodeName == "notice":
|
||||||
if self._notice is not None:
|
if self._notice is not None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate notice in %s" % (self.manifestFile)
|
"duplicate notice in %s" % (self.manifestFile)
|
||||||
)
|
)
|
||||||
self._notice = self._ParseNotice(node)
|
self._notice = self._ParseNotice(node)
|
||||||
@@ -1436,7 +1454,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
url = self._reqatt(node, "url")
|
url = self._reqatt(node, "url")
|
||||||
helper = node.getAttribute("helper") or None
|
helper = node.getAttribute("helper") or None
|
||||||
if self._manifest_server is not None:
|
if self._manifest_server is not None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate manifest-server in %s" % (self.manifestFile)
|
"duplicate manifest-server in %s" % (self.manifestFile)
|
||||||
)
|
)
|
||||||
self._manifest_server = url
|
self._manifest_server = url
|
||||||
@@ -1445,18 +1463,18 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
def recursively_add_projects(project):
|
def recursively_add_projects(project):
|
||||||
projects = self._projects.setdefault(project.name, [])
|
projects = self._projects.setdefault(project.name, [])
|
||||||
if project.relpath is None:
|
if project.relpath is None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"missing path for %s in %s"
|
"missing path for %s in %s"
|
||||||
% (project.name, self.manifestFile)
|
% (project.name, self.manifestFile)
|
||||||
)
|
)
|
||||||
if project.relpath in self._paths:
|
if project.relpath in self._paths:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate path %s in %s"
|
"duplicate path %s in %s"
|
||||||
% (project.relpath, self.manifestFile)
|
% (project.relpath, self.manifestFile)
|
||||||
)
|
)
|
||||||
for tree in submanifest_paths:
|
for tree in submanifest_paths:
|
||||||
if project.relpath.startswith(tree):
|
if project.relpath.startswith(tree):
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"project %s conflicts with submanifest path %s"
|
"project %s conflicts with submanifest path %s"
|
||||||
% (project.relpath, tree)
|
% (project.relpath, tree)
|
||||||
)
|
)
|
||||||
@@ -1476,7 +1494,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
name = self._reqatt(node, "name")
|
name = self._reqatt(node, "name")
|
||||||
|
|
||||||
if name not in self._projects:
|
if name not in self._projects:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"extend-project element specifies non-existent "
|
"extend-project element specifies non-existent "
|
||||||
"project: %s" % name
|
"project: %s" % name
|
||||||
)
|
)
|
||||||
@@ -1498,7 +1516,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
|
|
||||||
named_projects = self._projects[name]
|
named_projects = self._projects[name]
|
||||||
if dest_path and not path and len(named_projects) > 1:
|
if dest_path and not path and len(named_projects) > 1:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"extend-project cannot use dest-path when "
|
"extend-project cannot use dest-path when "
|
||||||
"matching multiple projects: %s" % name
|
"matching multiple projects: %s" % name
|
||||||
)
|
)
|
||||||
@@ -1555,7 +1573,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if node.nodeName == "repo-hooks":
|
if node.nodeName == "repo-hooks":
|
||||||
# Only one project can be the hooks project
|
# Only one project can be the hooks project
|
||||||
if repo_hooks_project is not None:
|
if repo_hooks_project is not None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate repo-hooks in %s" % (self.manifestFile)
|
"duplicate repo-hooks in %s" % (self.manifestFile)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1569,7 +1587,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
name = self._reqatt(node, "name")
|
name = self._reqatt(node, "name")
|
||||||
# There can only be one superproject.
|
# There can only be one superproject.
|
||||||
if self._superproject:
|
if self._superproject:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"duplicate superproject in %s" % (self.manifestFile)
|
"duplicate superproject in %s" % (self.manifestFile)
|
||||||
)
|
)
|
||||||
remote_name = node.getAttribute("remote")
|
remote_name = node.getAttribute("remote")
|
||||||
@@ -1578,7 +1596,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
else:
|
else:
|
||||||
remote = self._get_remote(node)
|
remote = self._get_remote(node)
|
||||||
if remote is None:
|
if remote is None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"no remote for superproject %s within %s"
|
"no remote for superproject %s within %s"
|
||||||
% (name, self.manifestFile)
|
% (name, self.manifestFile)
|
||||||
)
|
)
|
||||||
@@ -1586,7 +1604,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if not revision:
|
if not revision:
|
||||||
revision = self._default.revisionExpr
|
revision = self._default.revisionExpr
|
||||||
if not revision:
|
if not revision:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"no revision for superproject %s within %s"
|
"no revision for superproject %s within %s"
|
||||||
% (name, self.manifestFile)
|
% (name, self.manifestFile)
|
||||||
)
|
)
|
||||||
@@ -1609,7 +1627,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
|
|
||||||
# Name or path needed.
|
# Name or path needed.
|
||||||
if not name and not path:
|
if not name and not path:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"remove-project must have name and/or path"
|
"remove-project must have name and/or path"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1654,14 +1672,14 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
repo_hooks_project = None
|
repo_hooks_project = None
|
||||||
|
|
||||||
if not removed_project and not XmlBool(node, "optional", False):
|
if not removed_project and not XmlBool(node, "optional", False):
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"remove-project element specifies non-existent "
|
"remove-project element specifies non-existent "
|
||||||
"project: %s" % node.toxml()
|
"project: %s" % node.toxml()
|
||||||
)
|
)
|
||||||
|
|
||||||
if failed_revision_changes:
|
if failed_revision_changes:
|
||||||
fail_string = "\n".join(failed_revision_changes)
|
fail_string = "\n".join(failed_revision_changes)
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
f"detected base-revision mismatch, updates needed:\n"
|
f"detected base-revision mismatch, updates needed:\n"
|
||||||
f"{fail_string}",
|
f"{fail_string}",
|
||||||
)
|
)
|
||||||
@@ -1672,12 +1690,12 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
try:
|
try:
|
||||||
repo_hooks_projects = self._projects[repo_hooks_project]
|
repo_hooks_projects = self._projects[repo_hooks_project]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"project %s not found for repo-hooks" % (repo_hooks_project)
|
"project %s not found for repo-hooks" % (repo_hooks_project)
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(repo_hooks_projects) != 1:
|
if len(repo_hooks_projects) != 1:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"internal error parsing repo-hooks in %s"
|
"internal error parsing repo-hooks in %s"
|
||||||
% (self.manifestFile)
|
% (self.manifestFile)
|
||||||
)
|
)
|
||||||
@@ -1689,7 +1707,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
name = None
|
name = None
|
||||||
m_url = m.GetRemote().url
|
m_url = m.GetRemote().url
|
||||||
if m_url.endswith("/.git"):
|
if m_url.endswith("/.git"):
|
||||||
raise ManifestParseError("refusing to mirror %s" % m_url)
|
raise error.ManifestParseError("refusing to mirror %s" % m_url)
|
||||||
|
|
||||||
if self._default and self._default.remote:
|
if self._default and self._default.remote:
|
||||||
url = self._default.remote.resolvedFetchUrl
|
url = self._default.remote.resolvedFetchUrl
|
||||||
@@ -1774,14 +1792,14 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
|
|
||||||
d.sync_j = XmlInt(node, "sync-j", None)
|
d.sync_j = XmlInt(node, "sync-j", None)
|
||||||
if d.sync_j is not None and d.sync_j <= 0:
|
if d.sync_j is not None and d.sync_j <= 0:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
'%s: sync-j must be greater than 0, not "%s"'
|
'%s: sync-j must be greater than 0, not "%s"'
|
||||||
% (self.manifestFile, d.sync_j)
|
% (self.manifestFile, d.sync_j)
|
||||||
)
|
)
|
||||||
|
|
||||||
d.sync_j_max = XmlInt(node, "sync-j-max", None)
|
d.sync_j_max = XmlInt(node, "sync-j-max", None)
|
||||||
if d.sync_j_max is not None and d.sync_j_max <= 0:
|
if d.sync_j_max is not None and d.sync_j_max <= 0:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
'%s: sync-j-max must be greater than 0, not "%s"'
|
'%s: sync-j-max must be greater than 0, not "%s"'
|
||||||
% (self.manifestFile, d.sync_j_max)
|
% (self.manifestFile, d.sync_j_max)
|
||||||
)
|
)
|
||||||
@@ -1789,6 +1807,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
d.sync_c = XmlBool(node, "sync-c", False)
|
d.sync_c = XmlBool(node, "sync-c", False)
|
||||||
d.sync_s = XmlBool(node, "sync-s", False)
|
d.sync_s = XmlBool(node, "sync-s", False)
|
||||||
d.sync_tags = XmlBool(node, "sync-tags", True)
|
d.sync_tags = XmlBool(node, "sync-tags", True)
|
||||||
|
d.sync_smartsync = XmlBool(node, "sync-smartsync", False)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def _ParseNotice(self, node):
|
def _ParseNotice(self, node):
|
||||||
@@ -1856,20 +1875,20 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if revision:
|
if revision:
|
||||||
msg = self._CheckLocalPath(revision.split("/")[-1])
|
msg = self._CheckLocalPath(revision.split("/")[-1])
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
'<submanifest> invalid "revision": %s: %s'
|
'<submanifest> invalid "revision": %s: %s'
|
||||||
% (revision, msg)
|
% (revision, msg)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
msg = self._CheckLocalPath(name)
|
msg = self._CheckLocalPath(name)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<submanifest> invalid "name": {name}: {msg}'
|
f'<submanifest> invalid "name": {name}: {msg}'
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
msg = self._CheckLocalPath(path)
|
msg = self._CheckLocalPath(path)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<submanifest> invalid "path": {path}: {msg}'
|
f'<submanifest> invalid "path": {path}: {msg}'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1904,7 +1923,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
name = self._reqatt(node, "name")
|
name = self._reqatt(node, "name")
|
||||||
msg = self._CheckLocalPath(name, dir_ok=True)
|
msg = self._CheckLocalPath(name, dir_ok=True)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<project> invalid "name": {name}: {msg}'
|
f'<project> invalid "name": {name}: {msg}'
|
||||||
)
|
)
|
||||||
if parent:
|
if parent:
|
||||||
@@ -1914,7 +1933,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if remote is None:
|
if remote is None:
|
||||||
remote = self._default.remote
|
remote = self._default.remote
|
||||||
if remote is None:
|
if remote is None:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
f"no remote for project {name} within {self.manifestFile}"
|
f"no remote for project {name} within {self.manifestFile}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1922,7 +1941,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
if not revisionExpr:
|
if not revisionExpr:
|
||||||
revisionExpr = self._default.revisionExpr
|
revisionExpr = self._default.revisionExpr
|
||||||
if not revisionExpr:
|
if not revisionExpr:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"no revision for project %s within %s"
|
"no revision for project %s within %s"
|
||||||
% (name, self.manifestFile)
|
% (name, self.manifestFile)
|
||||||
)
|
)
|
||||||
@@ -1935,7 +1954,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
# Project.Sync_LocalHalf.
|
# Project.Sync_LocalHalf.
|
||||||
msg = self._CheckLocalPath(path, dir_ok=True, cwd_dot_ok=True)
|
msg = self._CheckLocalPath(path, dir_ok=True, cwd_dot_ok=True)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<project> invalid "path": {path}: {msg}'
|
f'<project> invalid "path": {path}: {msg}'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1946,7 +1965,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
|
|
||||||
clone_depth = XmlInt(node, "clone-depth")
|
clone_depth = XmlInt(node, "clone-depth")
|
||||||
if clone_depth is not None and clone_depth <= 0:
|
if clone_depth is not None and clone_depth <= 0:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
'%s: clone-depth must be greater than 0, not "%s"'
|
'%s: clone-depth must be greater than 0, not "%s"'
|
||||||
% (self.manifestFile, clone_depth)
|
% (self.manifestFile, clone_depth)
|
||||||
)
|
)
|
||||||
@@ -2240,7 +2259,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
# It is relative to the top of the repo client checkout.
|
# It is relative to the top of the repo client checkout.
|
||||||
msg = cls._CheckLocalPath(dest)
|
msg = cls._CheckLocalPath(dest)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<{element}> invalid "dest": {dest}: {msg}'
|
f'<{element}> invalid "dest": {dest}: {msg}'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2251,7 +2270,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
src, dir_ok=is_linkfile, cwd_dot_ok=is_linkfile
|
src, dir_ok=is_linkfile, cwd_dot_ok=is_linkfile
|
||||||
)
|
)
|
||||||
if msg:
|
if msg:
|
||||||
raise ManifestInvalidPathError(
|
raise error.ManifestInvalidPathError(
|
||||||
f'<{element}> invalid "src": {src}: {msg}'
|
f'<{element}> invalid "src": {src}: {msg}'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2280,10 +2299,10 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
value = self._reqatt(node, "value")
|
value = self._reqatt(node, "value")
|
||||||
try:
|
try:
|
||||||
keep = self._reqatt(node, "keep").lower()
|
keep = self._reqatt(node, "keep").lower()
|
||||||
except ManifestParseError:
|
except error.ManifestParseError:
|
||||||
keep = "true"
|
keep = "true"
|
||||||
if keep != "true" and keep != "false":
|
if keep != "true" and keep != "false":
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
'optional "keep" attribute must be ' '"true" or "false"'
|
'optional "keep" attribute must be ' '"true" or "false"'
|
||||||
)
|
)
|
||||||
element.AddAnnotation(name, value, keep)
|
element.AddAnnotation(name, value, keep)
|
||||||
@@ -2295,7 +2314,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
|
|
||||||
v = self._remotes.get(name)
|
v = self._remotes.get(name)
|
||||||
if not v:
|
if not v:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
f"remote {name} not defined in {self.manifestFile}"
|
f"remote {name} not defined in {self.manifestFile}"
|
||||||
)
|
)
|
||||||
return v
|
return v
|
||||||
@@ -2306,7 +2325,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
"""
|
"""
|
||||||
v = node.getAttribute(attname)
|
v = node.getAttribute(attname)
|
||||||
if not v:
|
if not v:
|
||||||
raise ManifestParseError(
|
raise error.ManifestParseError(
|
||||||
"no %s in <%s> within %s"
|
"no %s in <%s> within %s"
|
||||||
% (attname, node.nodeName, self.manifestFile)
|
% (attname, node.nodeName, self.manifestFile)
|
||||||
)
|
)
|
||||||
@@ -2344,7 +2363,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
|||||||
try:
|
try:
|
||||||
fromRevId = fromProj.GetCommitRevisionId()
|
fromRevId = fromProj.GetCommitRevisionId()
|
||||||
toRevId = toProj.GetCommitRevisionId()
|
toRevId = toProj.GetCommitRevisionId()
|
||||||
except ManifestInvalidRevisionError:
|
except error.ManifestInvalidRevisionError:
|
||||||
diff["unreachable"].append((fromProj, toProj))
|
diff["unreachable"].append((fromProj, toProj))
|
||||||
else:
|
else:
|
||||||
if fromRevId != toRevId:
|
if fromRevId != toRevId:
|
||||||
|
|||||||
+62
-80
@@ -12,32 +12,14 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from ctypes import addressof
|
import ctypes
|
||||||
from ctypes import byref
|
from ctypes import wintypes
|
||||||
from ctypes import c_buffer
|
|
||||||
from ctypes import c_ubyte
|
|
||||||
from ctypes import FormatError
|
|
||||||
from ctypes import get_last_error
|
|
||||||
from ctypes import Structure
|
|
||||||
from ctypes import Union
|
|
||||||
from ctypes import WinDLL
|
|
||||||
from ctypes import WinError
|
|
||||||
from ctypes.wintypes import BOOL
|
|
||||||
from ctypes.wintypes import BOOLEAN
|
|
||||||
from ctypes.wintypes import DWORD
|
|
||||||
from ctypes.wintypes import HANDLE
|
|
||||||
from ctypes.wintypes import LPCWSTR
|
|
||||||
from ctypes.wintypes import LPDWORD
|
|
||||||
from ctypes.wintypes import LPVOID
|
|
||||||
from ctypes.wintypes import ULONG
|
|
||||||
from ctypes.wintypes import USHORT
|
|
||||||
from ctypes.wintypes import WCHAR
|
|
||||||
import errno
|
import errno
|
||||||
|
|
||||||
|
|
||||||
kernel32 = WinDLL("kernel32", use_last_error=True)
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
|
||||||
UCHAR = c_ubyte
|
UCHAR = ctypes.c_ubyte
|
||||||
|
|
||||||
# Win32 error codes
|
# Win32 error codes
|
||||||
ERROR_SUCCESS = 0
|
ERROR_SUCCESS = 0
|
||||||
@@ -46,11 +28,11 @@ ERROR_PRIVILEGE_NOT_HELD = 1314
|
|||||||
|
|
||||||
# Win32 API entry points
|
# Win32 API entry points
|
||||||
CreateSymbolicLinkW = kernel32.CreateSymbolicLinkW
|
CreateSymbolicLinkW = kernel32.CreateSymbolicLinkW
|
||||||
CreateSymbolicLinkW.restype = BOOLEAN
|
CreateSymbolicLinkW.restype = wintypes.BOOLEAN
|
||||||
CreateSymbolicLinkW.argtypes = (
|
CreateSymbolicLinkW.argtypes = (
|
||||||
LPCWSTR, # lpSymlinkFileName In
|
wintypes.LPCWSTR, # lpSymlinkFileName In
|
||||||
LPCWSTR, # lpTargetFileName In
|
wintypes.LPCWSTR, # lpTargetFileName In
|
||||||
DWORD, # dwFlags In
|
wintypes.DWORD, # dwFlags In
|
||||||
)
|
)
|
||||||
|
|
||||||
# Symbolic link creation flags
|
# Symbolic link creation flags
|
||||||
@@ -61,44 +43,44 @@ SYMBOLIC_LINK_FLAG_DIRECTORY = 0x01
|
|||||||
SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE = 0x02
|
SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE = 0x02
|
||||||
|
|
||||||
GetFileAttributesW = kernel32.GetFileAttributesW
|
GetFileAttributesW = kernel32.GetFileAttributesW
|
||||||
GetFileAttributesW.restype = DWORD
|
GetFileAttributesW.restype = wintypes.DWORD
|
||||||
GetFileAttributesW.argtypes = (LPCWSTR,) # lpFileName In
|
GetFileAttributesW.argtypes = (wintypes.LPCWSTR,) # lpFileName In
|
||||||
|
|
||||||
INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF
|
INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF
|
||||||
FILE_ATTRIBUTE_REPARSE_POINT = 0x00400
|
FILE_ATTRIBUTE_REPARSE_POINT = 0x00400
|
||||||
|
|
||||||
CreateFileW = kernel32.CreateFileW
|
CreateFileW = kernel32.CreateFileW
|
||||||
CreateFileW.restype = HANDLE
|
CreateFileW.restype = wintypes.HANDLE
|
||||||
CreateFileW.argtypes = (
|
CreateFileW.argtypes = (
|
||||||
LPCWSTR, # lpFileName In
|
wintypes.LPCWSTR, # lpFileName In
|
||||||
DWORD, # dwDesiredAccess In
|
wintypes.DWORD, # dwDesiredAccess In
|
||||||
DWORD, # dwShareMode In
|
wintypes.DWORD, # dwShareMode In
|
||||||
LPVOID, # lpSecurityAttributes In_opt
|
wintypes.LPVOID, # lpSecurityAttributes In_opt
|
||||||
DWORD, # dwCreationDisposition In
|
wintypes.DWORD, # dwCreationDisposition In
|
||||||
DWORD, # dwFlagsAndAttributes In
|
wintypes.DWORD, # dwFlagsAndAttributes In
|
||||||
HANDLE, # hTemplateFile In_opt
|
wintypes.HANDLE, # hTemplateFile In_opt
|
||||||
)
|
)
|
||||||
|
|
||||||
CloseHandle = kernel32.CloseHandle
|
CloseHandle = kernel32.CloseHandle
|
||||||
CloseHandle.restype = BOOL
|
CloseHandle.restype = wintypes.BOOL
|
||||||
CloseHandle.argtypes = (HANDLE,) # hObject In
|
CloseHandle.argtypes = (wintypes.HANDLE,) # hObject In
|
||||||
|
|
||||||
INVALID_HANDLE_VALUE = HANDLE(-1).value
|
INVALID_HANDLE_VALUE = wintypes.HANDLE(-1).value
|
||||||
OPEN_EXISTING = 3
|
OPEN_EXISTING = 3
|
||||||
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
|
FILE_FLAG_BACKUP_SEMANTICS = 0x02000000
|
||||||
FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000
|
FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000
|
||||||
|
|
||||||
DeviceIoControl = kernel32.DeviceIoControl
|
DeviceIoControl = kernel32.DeviceIoControl
|
||||||
DeviceIoControl.restype = BOOL
|
DeviceIoControl.restype = wintypes.BOOL
|
||||||
DeviceIoControl.argtypes = (
|
DeviceIoControl.argtypes = (
|
||||||
HANDLE, # hDevice In
|
wintypes.HANDLE, # hDevice In
|
||||||
DWORD, # dwIoControlCode In
|
wintypes.DWORD, # dwIoControlCode In
|
||||||
LPVOID, # lpInBuffer In_opt
|
wintypes.LPVOID, # lpInBuffer In_opt
|
||||||
DWORD, # nInBufferSize In
|
wintypes.DWORD, # nInBufferSize In
|
||||||
LPVOID, # lpOutBuffer Out_opt
|
wintypes.LPVOID, # lpOutBuffer Out_opt
|
||||||
DWORD, # nOutBufferSize In
|
wintypes.DWORD, # nOutBufferSize In
|
||||||
LPDWORD, # lpBytesReturned Out_opt
|
wintypes.LPDWORD, # lpBytesReturned Out_opt
|
||||||
LPVOID, # lpOverlapped Inout_opt
|
wintypes.LPVOID, # lpOverlapped Inout_opt
|
||||||
)
|
)
|
||||||
|
|
||||||
# Device I/O control flags and options
|
# Device I/O control flags and options
|
||||||
@@ -108,45 +90,45 @@ IO_REPARSE_TAG_SYMLINK = 0xA000000C
|
|||||||
MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 0x4000
|
MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 0x4000
|
||||||
|
|
||||||
|
|
||||||
class GENERIC_REPARSE_BUFFER(Structure):
|
class GENERIC_REPARSE_BUFFER(ctypes.Structure):
|
||||||
_fields_ = (("DataBuffer", UCHAR * 1),)
|
_fields_ = (("DataBuffer", UCHAR * 1),)
|
||||||
|
|
||||||
|
|
||||||
class SYMBOLIC_LINK_REPARSE_BUFFER(Structure):
|
class SYMBOLIC_LINK_REPARSE_BUFFER(ctypes.Structure):
|
||||||
_fields_ = (
|
_fields_ = (
|
||||||
("SubstituteNameOffset", USHORT),
|
("SubstituteNameOffset", wintypes.USHORT),
|
||||||
("SubstituteNameLength", USHORT),
|
("SubstituteNameLength", wintypes.USHORT),
|
||||||
("PrintNameOffset", USHORT),
|
("PrintNameOffset", wintypes.USHORT),
|
||||||
("PrintNameLength", USHORT),
|
("PrintNameLength", wintypes.USHORT),
|
||||||
("Flags", ULONG),
|
("Flags", wintypes.ULONG),
|
||||||
("PathBuffer", WCHAR * 1),
|
("PathBuffer", wintypes.WCHAR * 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def PrintName(self):
|
def PrintName(self):
|
||||||
arrayt = WCHAR * (self.PrintNameLength // 2)
|
arrayt = wintypes.WCHAR * (self.PrintNameLength // 2)
|
||||||
offset = type(self).PathBuffer.offset + self.PrintNameOffset
|
offset = type(self).PathBuffer.offset + self.PrintNameOffset
|
||||||
return arrayt.from_address(addressof(self) + offset).value
|
return arrayt.from_address(ctypes.addressof(self) + offset).value
|
||||||
|
|
||||||
|
|
||||||
class MOUNT_POINT_REPARSE_BUFFER(Structure):
|
class MOUNT_POINT_REPARSE_BUFFER(ctypes.Structure):
|
||||||
_fields_ = (
|
_fields_ = (
|
||||||
("SubstituteNameOffset", USHORT),
|
("SubstituteNameOffset", wintypes.USHORT),
|
||||||
("SubstituteNameLength", USHORT),
|
("SubstituteNameLength", wintypes.USHORT),
|
||||||
("PrintNameOffset", USHORT),
|
("PrintNameOffset", wintypes.USHORT),
|
||||||
("PrintNameLength", USHORT),
|
("PrintNameLength", wintypes.USHORT),
|
||||||
("PathBuffer", WCHAR * 1),
|
("PathBuffer", wintypes.WCHAR * 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def PrintName(self):
|
def PrintName(self):
|
||||||
arrayt = WCHAR * (self.PrintNameLength // 2)
|
arrayt = wintypes.WCHAR * (self.PrintNameLength // 2)
|
||||||
offset = type(self).PathBuffer.offset + self.PrintNameOffset
|
offset = type(self).PathBuffer.offset + self.PrintNameOffset
|
||||||
return arrayt.from_address(addressof(self) + offset).value
|
return arrayt.from_address(ctypes.addressof(self) + offset).value
|
||||||
|
|
||||||
|
|
||||||
class REPARSE_DATA_BUFFER(Structure):
|
class REPARSE_DATA_BUFFER(ctypes.Structure):
|
||||||
class REPARSE_BUFFER(Union):
|
class REPARSE_BUFFER(ctypes.Union):
|
||||||
_fields_ = (
|
_fields_ = (
|
||||||
("SymbolicLinkReparseBuffer", SYMBOLIC_LINK_REPARSE_BUFFER),
|
("SymbolicLinkReparseBuffer", SYMBOLIC_LINK_REPARSE_BUFFER),
|
||||||
("MountPointReparseBuffer", MOUNT_POINT_REPARSE_BUFFER),
|
("MountPointReparseBuffer", MOUNT_POINT_REPARSE_BUFFER),
|
||||||
@@ -154,9 +136,9 @@ class REPARSE_DATA_BUFFER(Structure):
|
|||||||
)
|
)
|
||||||
|
|
||||||
_fields_ = (
|
_fields_ = (
|
||||||
("ReparseTag", ULONG),
|
("ReparseTag", wintypes.ULONG),
|
||||||
("ReparseDataLength", USHORT),
|
("ReparseDataLength", wintypes.USHORT),
|
||||||
("Reserved", USHORT),
|
("Reserved", wintypes.USHORT),
|
||||||
("ReparseBuffer", REPARSE_BUFFER),
|
("ReparseBuffer", REPARSE_BUFFER),
|
||||||
)
|
)
|
||||||
_anonymous_ = ("ReparseBuffer",)
|
_anonymous_ = ("ReparseBuffer",)
|
||||||
@@ -182,8 +164,8 @@ def _create_symlink(source, link_name, dwFlags):
|
|||||||
# "the unprivileged create flag is unsupported below Windows 10 (1703,
|
# "the unprivileged create flag is unsupported below Windows 10 (1703,
|
||||||
# v10.0.14972). retry without it."
|
# v10.0.14972). retry without it."
|
||||||
if not CreateSymbolicLinkW(link_name, source, dwFlags):
|
if not CreateSymbolicLinkW(link_name, source, dwFlags):
|
||||||
code = get_last_error()
|
code = ctypes.get_last_error()
|
||||||
error_desc = FormatError(code).strip()
|
error_desc = ctypes.FormatError(code).strip()
|
||||||
if code == ERROR_PRIVILEGE_NOT_HELD:
|
if code == ERROR_PRIVILEGE_NOT_HELD:
|
||||||
raise OSError(errno.EPERM, error_desc, link_name)
|
raise OSError(errno.EPERM, error_desc, link_name)
|
||||||
_raise_winerror(code, f'Error creating symbolic link "{link_name}"')
|
_raise_winerror(code, f'Error creating symbolic link "{link_name}"')
|
||||||
@@ -208,10 +190,10 @@ def readlink(path):
|
|||||||
)
|
)
|
||||||
if reparse_point_handle == INVALID_HANDLE_VALUE:
|
if reparse_point_handle == INVALID_HANDLE_VALUE:
|
||||||
_raise_winerror(
|
_raise_winerror(
|
||||||
get_last_error(), f'Error opening symbolic link "{path}"'
|
ctypes.get_last_error(), f'Error opening symbolic link "{path}"'
|
||||||
)
|
)
|
||||||
target_buffer = c_buffer(MAXIMUM_REPARSE_DATA_BUFFER_SIZE)
|
target_buffer = ctypes.c_buffer(MAXIMUM_REPARSE_DATA_BUFFER_SIZE)
|
||||||
n_bytes_returned = DWORD()
|
n_bytes_returned = wintypes.DWORD()
|
||||||
io_result = DeviceIoControl(
|
io_result = DeviceIoControl(
|
||||||
reparse_point_handle,
|
reparse_point_handle,
|
||||||
FSCTL_GET_REPARSE_POINT,
|
FSCTL_GET_REPARSE_POINT,
|
||||||
@@ -219,13 +201,13 @@ def readlink(path):
|
|||||||
0,
|
0,
|
||||||
target_buffer,
|
target_buffer,
|
||||||
len(target_buffer),
|
len(target_buffer),
|
||||||
byref(n_bytes_returned),
|
ctypes.byref(n_bytes_returned),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
CloseHandle(reparse_point_handle)
|
CloseHandle(reparse_point_handle)
|
||||||
if not io_result:
|
if not io_result:
|
||||||
_raise_winerror(
|
_raise_winerror(
|
||||||
get_last_error(), f'Error reading symbolic link "{path}"'
|
ctypes.get_last_error(), f'Error reading symbolic link "{path}"'
|
||||||
)
|
)
|
||||||
rdb = REPARSE_DATA_BUFFER.from_buffer(target_buffer)
|
rdb = REPARSE_DATA_BUFFER.from_buffer(target_buffer)
|
||||||
if rdb.ReparseTag == IO_REPARSE_TAG_SYMLINK:
|
if rdb.ReparseTag == IO_REPARSE_TAG_SYMLINK:
|
||||||
@@ -239,6 +221,6 @@ def readlink(path):
|
|||||||
|
|
||||||
|
|
||||||
def _raise_winerror(code, error_desc):
|
def _raise_winerror(code, error_desc):
|
||||||
win_error_desc = FormatError(code).strip()
|
win_error_desc = ctypes.FormatError(code).strip()
|
||||||
error_desc = f"{error_desc}: {win_error_desc}"
|
error_desc = f"{error_desc}: {win_error_desc}"
|
||||||
raise WinError(code, error_desc)
|
raise ctypes.WinError(code, error_desc)
|
||||||
+4
-4
@@ -22,7 +22,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
import dummy_threading as _threading
|
import dummy_threading as _threading
|
||||||
|
|
||||||
from repo_trace import IsTraceToStderr
|
import repo_trace
|
||||||
|
|
||||||
|
|
||||||
# Capture the original stderr stream. We use this exclusively for progress
|
# Capture the original stderr stream. We use this exclusively for progress
|
||||||
@@ -166,7 +166,7 @@ class Progress:
|
|||||||
msg = self._last_msg
|
msg = self._last_msg
|
||||||
self._last_msg = msg
|
self._last_msg = msg
|
||||||
|
|
||||||
if not _TTY or IsTraceToStderr() or self._quiet:
|
if not _TTY or repo_trace.IsTraceToStderr() or self._quiet:
|
||||||
return
|
return
|
||||||
|
|
||||||
elapsed_sec = time.time() - self._start
|
elapsed_sec = time.time() - self._start
|
||||||
@@ -211,7 +211,7 @@ class Progress:
|
|||||||
|
|
||||||
The progress bar is then redrawn on the next line.
|
The progress bar is then redrawn on the next line.
|
||||||
"""
|
"""
|
||||||
if not _TTY or IsTraceToStderr() or self._quiet:
|
if not _TTY or repo_trace.IsTraceToStderr() or self._quiet:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Erase the current line, print the message with a newline,
|
# Erase the current line, print the message with a newline,
|
||||||
@@ -227,7 +227,7 @@ class Progress:
|
|||||||
self._ended = True
|
self._ended = True
|
||||||
|
|
||||||
self._update_event.set()
|
self._update_event.set()
|
||||||
if not _TTY or IsTraceToStderr() or self._quiet:
|
if not _TTY or repo_trace.IsTraceToStderr() or self._quiet:
|
||||||
return
|
return
|
||||||
|
|
||||||
duration = duration_str(time.time() - self._start)
|
duration = duration_str(time.time() - self._start)
|
||||||
|
|||||||
+407
-309
File diff suppressed because it is too large.
Load diff
@@ -43,4 +43,5 @@ single_line_exclusions = ['abc', 'collections.abc', 'typing']
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
markers = """
|
markers = """
|
||||||
skip_cq: Skip tests in the CQ. Should be rarely used!
|
skip_cq: Skip tests in the CQ. Should be rarely used!
|
||||||
|
e2e: Hermetic end-to-end tests (tests/e2e/). Deselect with -m 'not e2e'.
|
||||||
"""
|
"""
|
||||||
@@ -39,7 +39,7 @@ MANDIR = TOPDIR.joinpath("man")
|
|||||||
|
|
||||||
# Load repo local modules.
|
# Load repo local modules.
|
||||||
sys.path.insert(0, str(TOPDIR))
|
sys.path.insert(0, str(TOPDIR))
|
||||||
from git_command import RepoSourceVersion
|
import git_command
|
||||||
import subcmds
|
import subcmds
|
||||||
|
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ def main(argv: List[str]) -> int:
|
|||||||
del subcmds.all_commands["branch"]
|
del subcmds.all_commands["branch"]
|
||||||
(MANDIR / "repo-branch.1").write_text(".so man1/repo-branches.1")
|
(MANDIR / "repo-branch.1").write_text(".so man1/repo-branches.1")
|
||||||
|
|
||||||
version = RepoSourceVersion()
|
version = git_command.RepoSourceVersion()
|
||||||
cmdlist = [
|
cmdlist = [
|
||||||
[
|
[
|
||||||
*help2man,
|
*help2man,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import platform
|
|||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple, Tuple
|
||||||
|
|
||||||
|
|
||||||
# These should never be newer than the main.py version since this needs to be a
|
# These should never be newer than the main.py version since this needs to be a
|
||||||
@@ -129,7 +129,7 @@ if not REPO_REV:
|
|||||||
BUG_URL = "https://issues.gerritcodereview.com/issues/new?component=1370071"
|
BUG_URL = "https://issues.gerritcodereview.com/issues/new?component=1370071"
|
||||||
|
|
||||||
# increment this whenever we make important changes to this script
|
# increment this whenever we make important changes to this script
|
||||||
VERSION = (2, 65)
|
VERSION = (2, 71)
|
||||||
|
|
||||||
# increment this if the MAINTAINER_KEYS block is modified
|
# increment this if the MAINTAINER_KEYS block is modified
|
||||||
KEYRING_VERSION = (2, 3)
|
KEYRING_VERSION = (2, 3)
|
||||||
@@ -220,6 +220,10 @@ repodir = ".repo" # name of repo's private directory
|
|||||||
S_repo = "repo" # special repo repository
|
S_repo = "repo" # special repo repository
|
||||||
S_manifests = "manifests" # special manifest repository
|
S_manifests = "manifests" # special manifest repository
|
||||||
REPO_MAIN = S_repo + "/main.py" # main script
|
REPO_MAIN = S_repo + "/main.py" # main script
|
||||||
|
# The automated release process deletes the stable branch and then recreates it
|
||||||
|
# at the new release, so a fetch can briefly miss it. Wait this long and
|
||||||
|
# refetch once before giving up on --repo-rev.
|
||||||
|
REV_REFETCH_DELAY_SEC = 10
|
||||||
|
|
||||||
|
|
||||||
import errno
|
import errno
|
||||||
@@ -228,6 +232,7 @@ import optparse
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
@@ -438,6 +443,20 @@ def InitParser(parser):
|
|||||||
dest="use_superproject",
|
dest="use_superproject",
|
||||||
help="disable use of manifest superprojects",
|
help="disable use of manifest superprojects",
|
||||||
)
|
)
|
||||||
|
group.add_option(
|
||||||
|
"-s",
|
||||||
|
"--smart-sync",
|
||||||
|
action="store_true",
|
||||||
|
default=None,
|
||||||
|
help="default to smart sync when syncing projects",
|
||||||
|
)
|
||||||
|
group.add_option(
|
||||||
|
"--no-smart-sync",
|
||||||
|
"--head",
|
||||||
|
action="store_false",
|
||||||
|
dest="smart_sync",
|
||||||
|
help="do not default to smart sync when syncing projects",
|
||||||
|
)
|
||||||
group.add_option(
|
group.add_option(
|
||||||
"--clone-bundle",
|
"--clone-bundle",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -631,6 +650,7 @@ def _Init(args):
|
|||||||
dst = dst_final + ".tmp"
|
dst = dst_final + ".tmp"
|
||||||
shutil.rmtree(dst, ignore_errors=True)
|
shutil.rmtree(dst, ignore_errors=True)
|
||||||
_Clone(url, dst, opt.clone_bundle, opt.quiet, opt.verbose)
|
_Clone(url, dst, opt.clone_bundle, opt.quiet, opt.verbose)
|
||||||
|
_RefetchIfRevMissing(url, dst, rev, opt.quiet, opt.verbose)
|
||||||
|
|
||||||
remote_ref, rev = check_repo_rev(
|
remote_ref, rev = check_repo_rev(
|
||||||
dst, rev, opt.repo_verify, quiet=opt.quiet
|
dst, rev, opt.repo_verify, quiet=opt.quiet
|
||||||
@@ -1005,7 +1025,43 @@ def _Clone(url, cwd, clone_bundle, quiet, verbose):
|
|||||||
_Fetch(url, cwd, "origin", quiet, verbose)
|
_Fetch(url, cwd, "origin", quiet, verbose)
|
||||||
|
|
||||||
|
|
||||||
def resolve_repo_rev(cwd, committish):
|
def _RefetchIfRevMissing(
|
||||||
|
url: str, cwd: str, rev: str, quiet: bool, verbose: bool
|
||||||
|
) -> None:
|
||||||
|
"""Refetch once if |rev| doesn't resolve.
|
||||||
|
|
||||||
|
This doesn't fail if |rev| still doesn't resolve; check_repo_rev reports
|
||||||
|
that.
|
||||||
|
|
||||||
|
Any unresolvable |rev| is retried, not just branches. Telling a branch
|
||||||
|
apart from a tag or commit isn't possible while it's missing, and the only
|
||||||
|
cost for a bad --repo-rev is a short delay before the same error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL of the repo source.
|
||||||
|
cwd: The git checkout to fetch into.
|
||||||
|
rev: The --repo-rev to wait for.
|
||||||
|
quiet: Whether to suppress fetch output.
|
||||||
|
verbose: Whether to show verbose fetch output.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resolve_repo_rev(cwd, rev, report_errors=False)
|
||||||
|
return
|
||||||
|
except CloneFailure:
|
||||||
|
pass
|
||||||
|
# Print even with --quiet since this explains the delay.
|
||||||
|
print(
|
||||||
|
f'repo: warning: unable to resolve "{rev}"; '
|
||||||
|
f"refetching in {REV_REFETCH_DELAY_SEC} seconds",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
time.sleep(REV_REFETCH_DELAY_SEC)
|
||||||
|
_Fetch(url, cwd, "origin", quiet, verbose)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repo_rev(
|
||||||
|
cwd: str, committish: str, report_errors: bool = True
|
||||||
|
) -> Tuple[str, str]:
|
||||||
"""Figure out what REPO_REV represents.
|
"""Figure out what REPO_REV represents.
|
||||||
|
|
||||||
We support:
|
We support:
|
||||||
@@ -1016,6 +1072,7 @@ def resolve_repo_rev(cwd, committish):
|
|||||||
Args:
|
Args:
|
||||||
cwd: The git checkout to run in.
|
cwd: The git checkout to run in.
|
||||||
committish: The REPO_REV argument to resolve.
|
committish: The REPO_REV argument to resolve.
|
||||||
|
report_errors: Whether to print an error if it can't be resolved.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A tuple of (remote ref, commit) as makes sense for the committish.
|
A tuple of (remote ref, commit) as makes sense for the committish.
|
||||||
@@ -1034,17 +1091,18 @@ def resolve_repo_rev(cwd, committish):
|
|||||||
)
|
)
|
||||||
return None if ret.returncode else ret.stdout.strip()
|
return None if ret.returncode else ret.stdout.strip()
|
||||||
|
|
||||||
|
def error(msg: str) -> CloneFailure:
|
||||||
|
if report_errors:
|
||||||
|
print(f"repo: error: {msg}", file=sys.stderr)
|
||||||
|
return CloneFailure()
|
||||||
|
|
||||||
# An explicit branch.
|
# An explicit branch.
|
||||||
if committish.startswith("refs/heads/"):
|
if committish.startswith("refs/heads/"):
|
||||||
remote_ref = committish
|
remote_ref = committish
|
||||||
committish = committish[len("refs/heads/") :]
|
committish = committish[len("refs/heads/") :]
|
||||||
rev = resolve("refs/remotes/origin/%s" % committish)
|
rev = resolve("refs/remotes/origin/%s" % committish)
|
||||||
if rev is None:
|
if rev is None:
|
||||||
print(
|
raise error(f'unknown branch "{committish}"')
|
||||||
f'repo: error: unknown branch "{committish}"',
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise CloneFailure()
|
|
||||||
return (remote_ref, rev)
|
return (remote_ref, rev)
|
||||||
|
|
||||||
# An explicit tag.
|
# An explicit tag.
|
||||||
@@ -1053,11 +1111,7 @@ def resolve_repo_rev(cwd, committish):
|
|||||||
committish = committish[len("refs/tags/") :]
|
committish = committish[len("refs/tags/") :]
|
||||||
rev = resolve(remote_ref)
|
rev = resolve(remote_ref)
|
||||||
if rev is None:
|
if rev is None:
|
||||||
print(
|
raise error(f'unknown tag "{committish}"')
|
||||||
f'repo: error: unknown tag "{committish}"',
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise CloneFailure()
|
|
||||||
return (remote_ref, rev)
|
return (remote_ref, rev)
|
||||||
|
|
||||||
# See if it's a short branch name.
|
# See if it's a short branch name.
|
||||||
@@ -1076,11 +1130,7 @@ def resolve_repo_rev(cwd, committish):
|
|||||||
return (rev, rev)
|
return (rev, rev)
|
||||||
|
|
||||||
# Give up!
|
# Give up!
|
||||||
print(
|
raise error(f'unable to resolve "{committish}"')
|
||||||
f'repo: error: unable to resolve "{committish}"',
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise CloneFailure()
|
|
||||||
|
|
||||||
|
|
||||||
def verify_rev(cwd, remote_ref, rev, quiet):
|
def verify_rev(cwd, remote_ref, rev, quiet):
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from color import Coloring
|
from color import Coloring
|
||||||
from error import RepoExitError
|
import error
|
||||||
|
|
||||||
|
|
||||||
SEPARATOR = "=" * 80
|
SEPARATOR = "=" * 80
|
||||||
@@ -71,7 +71,7 @@ class RepoLogger(logging.Logger):
|
|||||||
handler.setFormatter(_LogColoringFormatter(config))
|
handler.setFormatter(_LogColoringFormatter(config))
|
||||||
self.addHandler(handler)
|
self.addHandler(handler)
|
||||||
|
|
||||||
def log_aggregated_errors(self, err: RepoExitError):
|
def log_aggregated_errors(self, err: error.RepoExitError) -> None:
|
||||||
"""Print all aggregated logs."""
|
"""Print all aggregated logs."""
|
||||||
self.error(SEPARATOR)
|
self.error(SEPARATOR)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -57,10 +57,10 @@ def SetTraceToStderr():
|
|||||||
_TRACE_TO_STDERR = True
|
_TRACE_TO_STDERR = True
|
||||||
|
|
||||||
|
|
||||||
def SetTrace():
|
def SetTrace(value: bool = True) -> None:
|
||||||
"""Enables tracing."""
|
"""Enables by default, or disables tracing."""
|
||||||
global _TRACE
|
global _TRACE
|
||||||
_TRACE = True
|
_TRACE = value
|
||||||
|
|
||||||
|
|
||||||
def _SetTraceFile(quiet):
|
def _SetTraceFile(quiet):
|
||||||
|
|||||||
+8
-3
@@ -19,9 +19,14 @@
|
|||||||
# Hard versions are when we refuse to work the tool. Users will be shown an
|
# Hard versions are when we refuse to work the tool. Users will be shown an
|
||||||
# error message before we abort entirely.
|
# error message before we abort entirely.
|
||||||
|
|
||||||
# When deciding whether to upgrade a version requirement, check out the distro
|
# When deciding whether to upgrade a version requirement, check out what
|
||||||
# lists to see who will be impacted:
|
# versions major distros (e.g. Ubuntu LTS & Debian stable) ship and when they
|
||||||
# https://gerrit.googlesource.com/git-repo/+/HEAD/docs/release-process.md#Project-References
|
# go EOL to see who will be impacted:
|
||||||
|
# https://wiki.ubuntu.com/Releases
|
||||||
|
# https://wiki.debian.org/DebianReleases#Versions_of_Debian_stable
|
||||||
|
# https://repology.org/project/python/versions
|
||||||
|
# https://repology.org/project/git/versions
|
||||||
|
# https://repology.org/project/openssh/versions
|
||||||
|
|
||||||
{
|
{
|
||||||
# The repo launcher itself. This allows us to force people to upgrade as some
|
# The repo launcher itself. This allows us to force people to upgrade as some
|
||||||
|
|||||||
@@ -13,6 +13,18 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
# /// script
|
||||||
|
# requires-python = '>=3.11,<3.12'
|
||||||
|
# dependencies = [
|
||||||
|
# 'pytest==8.3.4',
|
||||||
|
# 'black==25.1.0',
|
||||||
|
# 'flake8==6.0.0',
|
||||||
|
# 'flake8-bugbear==24.12.12',
|
||||||
|
# 'flake8-comprehensions==3.16.0',
|
||||||
|
# 'isort==5.10.1'
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
|
||||||
"""Wrapper to run linters and pytest with the right settings."""
|
"""Wrapper to run linters and pytest with the right settings."""
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
@@ -42,10 +54,16 @@ def is_ci() -> bool:
|
|||||||
return os.getenv("LUCI_CQ") == "yes"
|
return os.getenv("LUCI_CQ") == "yes"
|
||||||
|
|
||||||
|
|
||||||
|
def ci_pytest_args() -> list[str]:
|
||||||
|
"""Returns extra pytest args for CI runs."""
|
||||||
|
if not is_ci():
|
||||||
|
return []
|
||||||
|
return ["-m", "not skip_cq", "--fail-on-skip"]
|
||||||
|
|
||||||
|
|
||||||
def run_pytest(argv: list[str]) -> int:
|
def run_pytest(argv: list[str]) -> int:
|
||||||
"""Returns the exit code from pytest."""
|
"""Returns the exit code from pytest."""
|
||||||
if is_ci():
|
argv = ci_pytest_args() + argv
|
||||||
argv = ["-m", "not skip_cq"] + argv
|
|
||||||
|
|
||||||
log_cmd("pytest", argv)
|
log_cmd("pytest", argv)
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
@@ -57,8 +75,7 @@ def run_pytest(argv: list[str]) -> int:
|
|||||||
|
|
||||||
def run_pytest_py38(argv: list[str]) -> int:
|
def run_pytest_py38(argv: list[str]) -> int:
|
||||||
"""Returns the exit code from pytest under Python 3.8."""
|
"""Returns the exit code from pytest under Python 3.8."""
|
||||||
if is_ci():
|
argv = ci_pytest_args() + argv
|
||||||
argv = ["-m", "not skip_cq"] + argv
|
|
||||||
|
|
||||||
log_cmd("[vpython 3.8] pytest", argv)
|
log_cmd("[vpython 3.8] pytest", argv)
|
||||||
try:
|
try:
|
||||||
@@ -135,7 +152,7 @@ def run_isort():
|
|||||||
cwd=ROOT_DIR,
|
cwd=ROOT_DIR,
|
||||||
)
|
)
|
||||||
|
|
||||||
argv = ["--check", ROOT_DIR]
|
argv = ["--check", "--diff", ROOT_DIR]
|
||||||
log_cmd("isort", argv)
|
log_cmd("isort", argv)
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
[sys.executable, "-m", "isort"] + argv,
|
[sys.executable, "-m", "isort"] + argv,
|
||||||
|
|||||||
Generated
+101
@@ -0,0 +1,101 @@
|
|||||||
|
attrs==24.2.0 \
|
||||||
|
--hash=sha256:37c4653e51383dc1249dba15635d59011ca11714a8e61db8cd6dc73011e4b603 \
|
||||||
|
--hash=sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2
|
||||||
|
# via flake8-bugbear
|
||||||
|
black==25.1.0 \
|
||||||
|
--hash=sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717 \
|
||||||
|
--hash=sha256:c9ed033e41998dd45d6f19bf9923e2444cb401185ccb26d1a2101a9049637c1f
|
||||||
|
click==8.0.3 \
|
||||||
|
--hash=sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3 \
|
||||||
|
--hash=sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3 \
|
||||||
|
--hash=sha256:b0a0492d07c8e7e4a88ee153ca6553d357a0746792d6868bfca79dc1a344bb1b
|
||||||
|
# via black
|
||||||
|
colorama==0.4.6 ; sys_platform == 'win32' \
|
||||||
|
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 \
|
||||||
|
--hash=sha256:8a5a9494346d7ae2d8f599e4661738007d2df56dbbf4ccbbf7a6a23679a3a302
|
||||||
|
# via
|
||||||
|
# click
|
||||||
|
# pytest
|
||||||
|
flake8==6.0.0 \
|
||||||
|
--hash=sha256:9b5bf0ebd06aff1601e38dfc4e91f9b99ba3cdf446e74ee25a9cbbe01aef81ee \
|
||||||
|
--hash=sha256:f34b5cb8bf908ff49e4673d3dbc557b2381a14132b637d53e9d3a59816f4b6ba
|
||||||
|
# via
|
||||||
|
# flake8-bugbear
|
||||||
|
# flake8-comprehensions
|
||||||
|
flake8-bugbear==24.12.12 \
|
||||||
|
--hash=sha256:1b6967436f65ca22a42e5373aaa6f2d87966ade9aa38d4baf2a1be550767545e
|
||||||
|
flake8-comprehensions==3.16.0 \
|
||||||
|
--hash=sha256:7c1eadc9d22e765f39857798febe7766b4d9c519793c6c149e3e13bf99693f70
|
||||||
|
iniconfig==1.1.1 \
|
||||||
|
--hash=sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3 \
|
||||||
|
--hash=sha256:9d81514370059186276e9a1799e5a9752e4c060233c254d6954b53fad2ff8a5e
|
||||||
|
# via pytest
|
||||||
|
isort==5.10.1 \
|
||||||
|
--hash=sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7 \
|
||||||
|
--hash=sha256:ec0da0d88470e89ade75bf1e2ea7b4349ec906f95c84aff08e4d41dea6864d25
|
||||||
|
mccabe==0.7.0 \
|
||||||
|
--hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e \
|
||||||
|
--hash=sha256:b85abd0a321cdd357d0be34adbf49cf01453a857a23e94d44612ec6f159cb261
|
||||||
|
# via flake8
|
||||||
|
mypy-extensions==0.4.3 \
|
||||||
|
--hash=sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d \
|
||||||
|
--hash=sha256:38cbac35a502c4f13b9d9b1d06258fa51e01da4f5828b171b4d1200d00378e7c
|
||||||
|
# via black
|
||||||
|
packaging==23.0 \
|
||||||
|
--hash=sha256:37cb5b4759d17ae6bad78ee3347f0ee1057452895fcd1bbff48fb12908ce00a1 \
|
||||||
|
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||||
|
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||||
|
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||||
|
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||||
|
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||||
|
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||||
|
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2 \
|
||||||
|
--hash=sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2
|
||||||
|
# via
|
||||||
|
# black
|
||||||
|
# pytest
|
||||||
|
pathspec==0.9.0 \
|
||||||
|
--hash=sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a \
|
||||||
|
--hash=sha256:aaf90f4ad38a16bd3ebe0fa64da7f6ee01819c881120db68cc6440941a9adc08
|
||||||
|
# via black
|
||||||
|
platformdirs==2.5.2 \
|
||||||
|
--hash=sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788 \
|
||||||
|
--hash=sha256:e257a04b5d0528cb9836b766c8a16a85368e249cba7ae42888a403976ccd7d2d
|
||||||
|
# via black
|
||||||
|
pluggy==1.5.0 \
|
||||||
|
--hash=sha256:40da5bf96b15e359e280fd760a80c11d7a8ea20c3c296c3f3e09f8be67cec10b \
|
||||||
|
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||||
|
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||||
|
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||||
|
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||||
|
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||||
|
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||||
|
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 \
|
||||||
|
--hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669
|
||||||
|
# via pytest
|
||||||
|
py==1.11.0 \
|
||||||
|
--hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378 \
|
||||||
|
--hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378
|
||||||
|
pycodestyle==2.10.0 \
|
||||||
|
--hash=sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610 \
|
||||||
|
--hash=sha256:aacf8d28ecea10fad31b2d14943a42d85d007f4d2fee8f212fc05751c8460647
|
||||||
|
# via flake8
|
||||||
|
pyflakes==3.0.1 \
|
||||||
|
--hash=sha256:a968b8baa43926124b692c7e76279bc5fa8c6ef9a2281e4a2a475be69f954bdf \
|
||||||
|
--hash=sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf
|
||||||
|
# via flake8
|
||||||
|
pyparsing==3.0.7 \
|
||||||
|
--hash=sha256:943c220ca64e93940b74dd070152ed9ef1402bc3b28fd1f899843bee34e83708 \
|
||||||
|
--hash=sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484
|
||||||
|
pytest==8.3.4 \
|
||||||
|
--hash=sha256:00cba7845d2dccfe5403025a688d60ceae228b1ddaa866d2eb86b2edb6923e73 \
|
||||||
|
--hash=sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6
|
||||||
|
toml==0.10.1 \
|
||||||
|
--hash=sha256:4099063d9b50219bf09bfa693ab8dcd96ce585cee9fdadce5a48a08ee23b0646 \
|
||||||
|
--hash=sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88
|
||||||
|
tomli==2.0.1 \
|
||||||
|
--hash=sha256:864f310e021d68415d6ba45b23e29808f99703b471d884267d6942eecdf8d6c5 \
|
||||||
|
--hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc
|
||||||
|
typing-extensions==4.3.0 \
|
||||||
|
--hash=sha256:09126dab54422810a541e078c37e62024e7260a022d4f67c075a6707d9553649 \
|
||||||
|
--hash=sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# This is a vpython "spec" file.
|
|
||||||
#
|
|
||||||
# Read more about `vpython` and how to modify this file here:
|
|
||||||
# https://chromium.googlesource.com/infra/infra/+/main/doc/users/vpython.md
|
|
||||||
# List of available wheels:
|
|
||||||
# https://chromium.googlesource.com/infra/infra/+/main/infra/tools/dockerbuild/wheels.md
|
|
||||||
|
|
||||||
python_version: "3.11"
|
|
||||||
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/pytest-py3"
|
|
||||||
version: "version:8.3.4"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by pytest==8.3.4
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/py-py2_py3"
|
|
||||||
version: "version:1.11.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by pytest==8.3.4
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/iniconfig-py3"
|
|
||||||
version: "version:1.1.1"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by pytest==8.3.4
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/packaging-py3"
|
|
||||||
version: "version:23.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by pytest==8.3.4
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/pluggy-py3"
|
|
||||||
version: "version:1.5.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by pytest==8.3.4
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/toml-py3"
|
|
||||||
version: "version:0.10.1"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by pytest==8.3.4
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/pyparsing-py3"
|
|
||||||
version: "version:3.0.7"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by pytest==8.3.4 and flake8-bugbear==24.12.12
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/attrs-py3"
|
|
||||||
version: "version:24.2.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
# NB: Keep in sync with constraints.txt.
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/black-py3"
|
|
||||||
version: "version:25.1.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by black==25.1.0
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/mypy-extensions-py3"
|
|
||||||
version: "version:0.4.3"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by black==25.1.0
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/tomli-py3"
|
|
||||||
version: "version:2.0.1"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by black==25.1.0
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/platformdirs-py3"
|
|
||||||
version: "version:2.5.2"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by black==25.1.0
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/pathspec-py3"
|
|
||||||
version: "version:0.9.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by black==25.1.0
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/typing-extensions-py3"
|
|
||||||
version: "version:4.3.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by black==25.1.0
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/click-py3"
|
|
||||||
version: "version:8.0.3"
|
|
||||||
>
|
|
||||||
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/flake8-py2_py3"
|
|
||||||
version: "version:6.0.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by flake8==6.0.0
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/mccabe-py2_py3"
|
|
||||||
version: "version:0.7.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by flake8==6.0.0
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/pyflakes-py2_py3"
|
|
||||||
version: "version:3.0.1"
|
|
||||||
>
|
|
||||||
|
|
||||||
# Required by flake8==6.0.0
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/pycodestyle-py2_py3"
|
|
||||||
version: "version:2.10.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/flake8-bugbear-py3"
|
|
||||||
version: "version:24.12.12"
|
|
||||||
>
|
|
||||||
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/flake8-comprehensions-py3"
|
|
||||||
version: "version:3.16.0"
|
|
||||||
>
|
|
||||||
|
|
||||||
wheel: <
|
|
||||||
name: "infra/python/wheels/isort-py3"
|
|
||||||
version: "version:5.10.1"
|
|
||||||
>
|
|
||||||
@@ -24,9 +24,9 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from git_command import git
|
import git_command
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from repo_trace import Trace
|
import repo_trace
|
||||||
|
|
||||||
|
|
||||||
PROXY_PATH = os.path.join(os.path.dirname(__file__), "git_ssh")
|
PROXY_PATH = os.path.join(os.path.dirname(__file__), "git_ssh")
|
||||||
@@ -201,7 +201,9 @@ class ProxyManager:
|
|||||||
# is the same as 'myhost.com' where "User git" is setup in the user's
|
# is the same as 'myhost.com' where "User git" is setup in the user's
|
||||||
# ~/.ssh/config file.
|
# ~/.ssh/config file.
|
||||||
check_command = command_base + ["-O", "check"]
|
check_command = command_base + ["-O", "check"]
|
||||||
with Trace("Call to ssh (check call): %s", " ".join(check_command)):
|
with repo_trace.Trace(
|
||||||
|
"Call to ssh (check call): %s", " ".join(check_command)
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
check_process = subprocess.Popen(
|
check_process = subprocess.Popen(
|
||||||
check_command,
|
check_command,
|
||||||
@@ -250,7 +252,7 @@ class ProxyManager:
|
|||||||
)
|
)
|
||||||
p = None
|
p = None
|
||||||
try:
|
try:
|
||||||
with Trace("Call to ssh: %s", " ".join(command)):
|
with repo_trace.Trace("Call to ssh: %s", " ".join(command)):
|
||||||
p = subprocess.Popen(command)
|
p = subprocess.Popen(command)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._master_broken.value = True
|
self._master_broken.value = True
|
||||||
@@ -357,7 +359,7 @@ def _get_git_protocol_version() -> str:
|
|||||||
if e.returncode == 1:
|
if e.returncode == 1:
|
||||||
# Exit code 1 means that the git config key was not found.
|
# Exit code 1 means that the git config key was not found.
|
||||||
# Try to imitate the defaults that git would have used.
|
# Try to imitate the defaults that git would have used.
|
||||||
git_version = git.version_tuple()
|
git_version = git_command.git.version_tuple()
|
||||||
if git_version >= (2, 26, 0):
|
if git_version >= (2, 26, 0):
|
||||||
# Since git version 2.26, protocol v2 is the default.
|
# Since git version 2.26, protocol v2 is the default.
|
||||||
return "2"
|
return "2"
|
||||||
|
|||||||
+9
-8
@@ -18,17 +18,16 @@ import itertools
|
|||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from error import RepoError
|
import error
|
||||||
from error import RepoExitError
|
import git_command
|
||||||
from git_command import IsValidBranchName
|
|
||||||
from progress import Progress
|
from progress import Progress
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class AbandonError(RepoExitError):
|
class AbandonError(error.RepoExitError):
|
||||||
"""Exit error when abandon command fails."""
|
"""Exit error when abandon command fails."""
|
||||||
|
|
||||||
|
|
||||||
@@ -58,7 +57,9 @@ It is equivalent to "git branch -D <branchname>".
|
|||||||
|
|
||||||
if not opt.all:
|
if not opt.all:
|
||||||
branches = args[0].split()
|
branches = args[0].split()
|
||||||
invalid_branches = [x for x in branches if not IsValidBranchName(x)]
|
invalid_branches = [
|
||||||
|
x for x in branches if not git_command.IsValidBranchName(x)
|
||||||
|
]
|
||||||
|
|
||||||
if invalid_branches:
|
if invalid_branches:
|
||||||
self.OptionParser.error(
|
self.OptionParser.error(
|
||||||
@@ -82,7 +83,7 @@ It is equivalent to "git branch -D <branchname>".
|
|||||||
status = None
|
status = None
|
||||||
try:
|
try:
|
||||||
status = project.AbandonBranch(name)
|
status = project.AbandonBranch(name)
|
||||||
except RepoError as e:
|
except error.RepoError as e:
|
||||||
status = False
|
status = False
|
||||||
errors.append(e)
|
errors.append(e)
|
||||||
if status is not None:
|
if status is not None:
|
||||||
|
|||||||
+9
-10
@@ -17,13 +17,12 @@ from typing import NamedTuple
|
|||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from error import GitError
|
import error
|
||||||
from error import RepoExitError
|
|
||||||
from progress import Progress
|
from progress import Progress
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class CheckoutBranchResult(NamedTuple):
|
class CheckoutBranchResult(NamedTuple):
|
||||||
@@ -33,11 +32,11 @@ class CheckoutBranchResult(NamedTuple):
|
|||||||
error: Exception
|
error: Exception
|
||||||
|
|
||||||
|
|
||||||
class CheckoutCommandError(RepoExitError):
|
class CheckoutCommandError(error.RepoExitError):
|
||||||
"""Exception thrown when checkout command fails."""
|
"""Exception thrown when checkout command fails."""
|
||||||
|
|
||||||
|
|
||||||
class MissingBranchError(RepoExitError):
|
class MissingBranchError(error.RepoExitError):
|
||||||
"""Exception thrown when no project has specified branch."""
|
"""Exception thrown when no project has specified branch."""
|
||||||
|
|
||||||
|
|
||||||
@@ -64,14 +63,14 @@ The command is equivalent to:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _ExecuteOne(cls, nb, project_idx):
|
def _ExecuteOne(cls, nb, project_idx):
|
||||||
"""Checkout one project."""
|
"""Checkout one project."""
|
||||||
error = None
|
err = None
|
||||||
result = None
|
result = None
|
||||||
project = cls.get_parallel_context()["projects"][project_idx]
|
project = cls.get_parallel_context()["projects"][project_idx]
|
||||||
try:
|
try:
|
||||||
result = project.CheckoutBranch(nb)
|
result = project.CheckoutBranch(nb)
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
error = e
|
err = e
|
||||||
return CheckoutBranchResult(result, project_idx, error)
|
return CheckoutBranchResult(result, project_idx, err)
|
||||||
|
|
||||||
def Execute(self, opt, args):
|
def Execute(self, opt, args):
|
||||||
nb = args[0]
|
nb = args[0]
|
||||||
|
|||||||
+13
-13
@@ -17,13 +17,13 @@ import sys
|
|||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from error import GitError
|
import error
|
||||||
from git_command import GitCommand
|
import git_command
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
CHANGE_ID_RE = re.compile(r"^\s*Change-Id: I([0-9a-f]{40})\s*$")
|
CHANGE_ID_RE = re.compile(r"^\s*Change-Id: I([0-9a-f]{40})\s*$")
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class CherryPick(Command):
|
class CherryPick(Command):
|
||||||
@@ -47,7 +47,7 @@ change id will be added.
|
|||||||
sha1, commit = self._ResolveReference(reference)
|
sha1, commit = self._ResolveReference(reference)
|
||||||
old_msg = self._StripHeader(commit)
|
old_msg = self._StripHeader(commit)
|
||||||
|
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
["cherry-pick", sha1],
|
["cherry-pick", sha1],
|
||||||
capture_stdout=True,
|
capture_stdout=True,
|
||||||
@@ -57,7 +57,7 @@ change id will be added.
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
p.Wait()
|
p.Wait()
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"NOTE: When committing (please see above) and editing the "
|
"NOTE: When committing (please see above) and editing the "
|
||||||
@@ -76,7 +76,7 @@ change id will be added.
|
|||||||
# the commit message.
|
# the commit message.
|
||||||
new_msg = self._Reformat(old_msg, sha1)
|
new_msg = self._Reformat(old_msg, sha1)
|
||||||
|
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
["commit", "--amend", "-F", "-"],
|
["commit", "--amend", "-F", "-"],
|
||||||
input=new_msg,
|
input=new_msg,
|
||||||
@@ -86,14 +86,14 @@ change id will be added.
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
p.Wait()
|
p.Wait()
|
||||||
except GitError:
|
except error.GitError:
|
||||||
logger.error("error: Failed to update commit message")
|
logger.error("error: Failed to update commit message")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _ResolveReference(self, reference: str) -> Tuple[str, str]:
|
def _ResolveReference(self, reference: str) -> Tuple[str, str]:
|
||||||
"""Resolve a commit and read it through one cat-file batch request."""
|
"""Resolve a commit and read it through one cat-file batch request."""
|
||||||
expression = f"{reference}^{{commit}}"
|
expression = f"{reference}^{{commit}}"
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
["cat-file", "--batch"],
|
["cat-file", "--batch"],
|
||||||
input=expression + "\n",
|
input=expression + "\n",
|
||||||
@@ -107,7 +107,7 @@ change id will be added.
|
|||||||
if not separator:
|
if not separator:
|
||||||
raise ValueError("missing cat-file header")
|
raise ValueError("missing cat-file header")
|
||||||
if header.endswith(" missing") or header.endswith(" ambiguous"):
|
if header.endswith(" missing") or header.endswith(" ambiguous"):
|
||||||
raise GitError(f"commit {reference} not found")
|
raise error.GitError(f"commit {reference} not found")
|
||||||
|
|
||||||
parts = header.split(" ", 2)
|
parts = header.split(" ", 2)
|
||||||
if len(parts) != 3 or parts[1] != "commit":
|
if len(parts) != 3 or parts[1] != "commit":
|
||||||
@@ -122,13 +122,13 @@ change id will be added.
|
|||||||
raise ValueError("truncated cat-file object")
|
raise ValueError("truncated cat-file object")
|
||||||
|
|
||||||
commit = output[:-1]
|
commit = output[:-1]
|
||||||
except (GitError, ValueError) as e:
|
except (error.GitError, ValueError) as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"error: Failed to resolve or read commit %s", reference
|
"error: Failed to resolve or read commit %s", reference
|
||||||
)
|
)
|
||||||
if isinstance(e, GitError):
|
if isinstance(e, error.GitError):
|
||||||
raise
|
raise
|
||||||
raise GitError(str(e)) from e
|
raise error.GitError(str(e)) from e
|
||||||
|
|
||||||
return sha1, commit
|
return sha1, commit
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Copyright (C) 2026 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import optparse
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import command
|
||||||
|
import error
|
||||||
|
import git_config
|
||||||
|
|
||||||
|
|
||||||
|
class Error(error.RepoExitError):
|
||||||
|
"""Exit error when the config command fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotSetError(error.SilentRepoExitError):
|
||||||
|
"""Exit quietly when a requested setting is not set, like `git config`."""
|
||||||
|
|
||||||
|
|
||||||
|
# Subcommand name -> number of positional arguments it takes.
|
||||||
|
_ACTIONS = {
|
||||||
|
"get": 1,
|
||||||
|
"set": 2,
|
||||||
|
"unset": 1,
|
||||||
|
"list": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _CanonicalizeBool(name: str, value: str) -> str:
|
||||||
|
"""Returns |value| as "true"/"false", or raises Error."""
|
||||||
|
ret = git_config.ParseBoolean(value)
|
||||||
|
if ret is None:
|
||||||
|
raise Error(f"error: {name}: '{value}' is not a boolean")
|
||||||
|
return "true" if ret else "false"
|
||||||
|
|
||||||
|
|
||||||
|
def _CanonicalizeInt(name: str, value: str) -> str:
|
||||||
|
"""Returns |value| as a plain decimal integer, or raises Error."""
|
||||||
|
ret = git_config.ParseInt(value)
|
||||||
|
if ret is None:
|
||||||
|
raise Error(f"error: {name}: '{value}' is not an integer")
|
||||||
|
return str(ret)
|
||||||
|
|
||||||
|
|
||||||
|
# --type name -> function turning a value into its canonical string form.
|
||||||
|
_TYPES = {
|
||||||
|
"bool": _CanonicalizeBool,
|
||||||
|
"int": _CanonicalizeInt,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Config(command.Command, command.MirrorSafeCommand):
|
||||||
|
"""Read and write repo's per-user settings."""
|
||||||
|
|
||||||
|
COMMON = True
|
||||||
|
MULTI_MANIFEST_SUPPORT = True
|
||||||
|
helpSummary = "Read and write per-user repo settings"
|
||||||
|
helpUsage = """
|
||||||
|
%prog [--type=<type>] [-z] get <name>
|
||||||
|
%prog [--type=<type>] set <name> <value>
|
||||||
|
%prog unset <name>
|
||||||
|
%prog [-z] list
|
||||||
|
"""
|
||||||
|
helpDescription = """
|
||||||
|
The '%prog' command reads and writes repo's own per-user settings. These
|
||||||
|
are separate from git's config and from a checkout's settings; see
|
||||||
|
docs/repoconfig.md for where they live and which settings repo understands.
|
||||||
|
|
||||||
|
Settings are named <section>.<name>, e.g. sync.smartsync. 'get' prints
|
||||||
|
every value of the setting and exits 1 without output if it is not set,
|
||||||
|
like `git config`.
|
||||||
|
|
||||||
|
Values are stored as strings. With --type, 'set' validates the value and
|
||||||
|
stores it in canonical form (e.g. "on" as "true", "1k" as "1024"), and
|
||||||
|
'get' prints the canonical form, like `git config --type`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _Options(self, p: optparse.OptionParser) -> None:
|
||||||
|
p.add_option(
|
||||||
|
"--type",
|
||||||
|
choices=sorted(_TYPES),
|
||||||
|
help="validate and canonicalize the value as this type for "
|
||||||
|
"get and set (%s)" % ", ".join(sorted(_TYPES)),
|
||||||
|
)
|
||||||
|
p.add_option(
|
||||||
|
"-z",
|
||||||
|
"--null",
|
||||||
|
action="store_true",
|
||||||
|
help="terminate output of get and list with NUL instead of "
|
||||||
|
"newline, and separate key and value with a newline",
|
||||||
|
)
|
||||||
|
|
||||||
|
def ValidateOptions(self, opt: optparse.Values, args: List[str]) -> None:
|
||||||
|
if not args:
|
||||||
|
self.Usage()
|
||||||
|
action, rest = args[0], args[1:]
|
||||||
|
if action not in _ACTIONS:
|
||||||
|
self.OptionParser.error(f"unknown action '{action}'")
|
||||||
|
if len(rest) != _ACTIONS[action]:
|
||||||
|
self.Usage()
|
||||||
|
if opt.type and action not in ("get", "set"):
|
||||||
|
self.OptionParser.error("--type only applies to get and set")
|
||||||
|
if opt.null and action not in ("get", "list"):
|
||||||
|
self.OptionParser.error("-z/--null only applies to get and list")
|
||||||
|
|
||||||
|
def Execute(self, opt: optparse.Values, args: List[str]) -> None:
|
||||||
|
config = git_config.RepoConfig.ForUser()
|
||||||
|
action, rest = args[0], args[1:]
|
||||||
|
dispatch = {
|
||||||
|
"get": self._ExecuteGet,
|
||||||
|
"set": self._ExecuteSet,
|
||||||
|
"unset": self._ExecuteUnset,
|
||||||
|
"list": self._ExecuteList,
|
||||||
|
}[action]
|
||||||
|
dispatch(opt, config, *rest)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _Print(opt: optparse.Values, text: str) -> None:
|
||||||
|
"""Prints |text| terminated by NUL with -z, else by newline."""
|
||||||
|
print(text, end="\0" if opt.null else "\n")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _Format(opt: optparse.Values, name: str, value: Optional[str]) -> str:
|
||||||
|
"""Formats a value for output, canonicalizing it per --type."""
|
||||||
|
if value is None:
|
||||||
|
# A key without `=` in the file has no value; git prints a blank.
|
||||||
|
return ""
|
||||||
|
if opt.type:
|
||||||
|
return _TYPES[opt.type](name, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _ExecuteGet(
|
||||||
|
self, opt: optparse.Values, config: git_config.RepoConfig, name: str
|
||||||
|
) -> None:
|
||||||
|
values = config.GetString(name, all_keys=True)
|
||||||
|
if not values:
|
||||||
|
raise NotSetError(exit_code=1)
|
||||||
|
for value in values:
|
||||||
|
self._Print(opt, self._Format(opt, name, value))
|
||||||
|
|
||||||
|
def _ExecuteSet(
|
||||||
|
self,
|
||||||
|
opt: optparse.Values,
|
||||||
|
config: git_config.RepoConfig,
|
||||||
|
name: str,
|
||||||
|
value: str,
|
||||||
|
) -> None:
|
||||||
|
if opt.type:
|
||||||
|
value = _TYPES[opt.type](name, value)
|
||||||
|
self._Write(config, name, value)
|
||||||
|
|
||||||
|
def _ExecuteUnset(
|
||||||
|
self, opt: optparse.Values, config: git_config.RepoConfig, name: str
|
||||||
|
) -> None:
|
||||||
|
self._Write(config, name, None)
|
||||||
|
|
||||||
|
def _ExecuteList(
|
||||||
|
self, opt: optparse.Values, config: git_config.RepoConfig
|
||||||
|
) -> None:
|
||||||
|
sep = "\n" if opt.null else "="
|
||||||
|
for key in sorted(config.DumpConfigDict()):
|
||||||
|
for value in config.GetString(key, all_keys=True):
|
||||||
|
self._Print(opt, key if value is None else f"{key}{sep}{value}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _Write(
|
||||||
|
config: git_config.RepoConfig, name: str, value: Optional[str]
|
||||||
|
) -> None:
|
||||||
|
"""Writes |value| (None to delete) via `git config`, which also
|
||||||
|
validates the name."""
|
||||||
|
try:
|
||||||
|
config.SetString(name, value)
|
||||||
|
except error.GitError as e:
|
||||||
|
raise Error(str(e))
|
||||||
+8
-10
@@ -16,17 +16,15 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from error import GitError
|
import error
|
||||||
from error import NoSuchProjectError
|
import repo_logging
|
||||||
from error import RepoExitError
|
|
||||||
from repo_logging import RepoLogger
|
|
||||||
|
|
||||||
|
|
||||||
CHANGE_RE = re.compile(r"^([1-9][0-9]*)(?:[/\.-]([1-9][0-9]*))?$")
|
CHANGE_RE = re.compile(r"^([1-9][0-9]*)(?:[/\.-]([1-9][0-9]*))?$")
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class DownloadCommandError(RepoExitError):
|
class DownloadCommandError(error.RepoExitError):
|
||||||
"""Error raised when download command fails."""
|
"""Error raised when download command fails."""
|
||||||
|
|
||||||
|
|
||||||
@@ -107,7 +105,7 @@ If no project is specified try to use current directory as a project.
|
|||||||
# If the cwd is one of the projects, assume they want that.
|
# If the cwd is one of the projects, assume they want that.
|
||||||
try:
|
try:
|
||||||
project = self.GetProjects(".")[0]
|
project = self.GetProjects(".")[0]
|
||||||
except NoSuchProjectError:
|
except error.NoSuchProjectError:
|
||||||
project = None
|
project = None
|
||||||
if project not in projects:
|
if project not in projects:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -121,7 +119,7 @@ If no project is specified try to use current directory as a project.
|
|||||||
project.RelPath(local=opt.this_manifest_only),
|
project.RelPath(local=opt.this_manifest_only),
|
||||||
project.revisionExpr,
|
project.revisionExpr,
|
||||||
)
|
)
|
||||||
raise NoSuchProjectError()
|
raise error.NoSuchProjectError()
|
||||||
else:
|
else:
|
||||||
project = projects[0]
|
project = projects[0]
|
||||||
print("Defaulting to cwd project", project.name)
|
print("Defaulting to cwd project", project.name)
|
||||||
@@ -143,7 +141,7 @@ If no project is specified try to use current directory as a project.
|
|||||||
try:
|
try:
|
||||||
self._ExecuteHelper(opt, args)
|
self._ExecuteHelper(opt, args)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if isinstance(e, RepoExitError):
|
if isinstance(e, error.RepoExitError):
|
||||||
raise e
|
raise e
|
||||||
raise DownloadCommandError(aggregate_errors=[e])
|
raise DownloadCommandError(aggregate_errors=[e])
|
||||||
|
|
||||||
@@ -202,7 +200,7 @@ If no project is specified try to use current directory as a project.
|
|||||||
else:
|
else:
|
||||||
project._Checkout(dl.commit)
|
project._Checkout(dl.commit)
|
||||||
|
|
||||||
except GitError:
|
except error.GitError:
|
||||||
logger.error(
|
logger.error(
|
||||||
"[%s] Could not complete the %s of %s",
|
"[%s] Could not complete the %s of %s",
|
||||||
project.name,
|
project.name,
|
||||||
|
|||||||
+2
-2
@@ -25,10 +25,10 @@ from color import Coloring
|
|||||||
from command import Command
|
from command import Command
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
_CAN_COLOR = [
|
_CAN_COLOR = [
|
||||||
"branch",
|
"branch",
|
||||||
"diff",
|
"diff",
|
||||||
|
|||||||
+34
-16
@@ -16,8 +16,7 @@ import os
|
|||||||
from typing import List, Set
|
from typing import List, Set
|
||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from git_command import git_require
|
import git_command
|
||||||
from git_command import GitCommand
|
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from progress import Progress
|
from progress import Progress
|
||||||
from project import Project
|
from project import Project
|
||||||
@@ -28,6 +27,14 @@ class Gc(Command):
|
|||||||
helpSummary = "Cleaning up internal repo and Git state."
|
helpSummary = "Cleaning up internal repo and Git state."
|
||||||
helpUsage = """
|
helpUsage = """
|
||||||
%prog
|
%prog
|
||||||
|
"""
|
||||||
|
helpDescription = """
|
||||||
|
The '%prog' command removes internal Git data for projects that are no
|
||||||
|
longer active in the current configuration.
|
||||||
|
|
||||||
|
Projects excluded by the configured manifest groups are inactive by
|
||||||
|
default. Use --keep-manifest-projects to keep Git data for projects that
|
||||||
|
are still in the manifests.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _Options(self, p):
|
def _Options(self, p):
|
||||||
@@ -53,6 +60,12 @@ class Gc(Command):
|
|||||||
help="repack all projects that use partial clone with "
|
help="repack all projects that use partial clone with "
|
||||||
"filter=blob:none",
|
"filter=blob:none",
|
||||||
)
|
)
|
||||||
|
p.add_option(
|
||||||
|
"--keep-manifest-projects",
|
||||||
|
default=False,
|
||||||
|
action="store_true",
|
||||||
|
help="keep Git data for projects still in the manifests",
|
||||||
|
)
|
||||||
|
|
||||||
def _find_git_to_delete(
|
def _find_git_to_delete(
|
||||||
self, to_keep: Set[str], start_dir: str
|
self, to_keep: Set[str], start_dir: str
|
||||||
@@ -186,13 +199,13 @@ class Gc(Command):
|
|||||||
|
|
||||||
# Prepare workspace for repacking - remove all unreachable refs and
|
# Prepare workspace for repacking - remove all unreachable refs and
|
||||||
# their objects.
|
# their objects.
|
||||||
GitCommand(
|
git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
["reflog", "expire", "--expire-unreachable=all"],
|
["reflog", "expire", "--expire-unreachable=all"],
|
||||||
verify_command=True,
|
verify_command=True,
|
||||||
).Wait()
|
).Wait()
|
||||||
pm.update(msg=f"{project.name} | gc", inc=0)
|
pm.update(msg=f"{project.name} | gc", inc=0)
|
||||||
GitCommand(
|
git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
["gc"],
|
["gc"],
|
||||||
verify_command=True,
|
verify_command=True,
|
||||||
@@ -200,7 +213,7 @@ class Gc(Command):
|
|||||||
|
|
||||||
# Get all objects that are reachable from the remote, and pack them.
|
# Get all objects that are reachable from the remote, and pack them.
|
||||||
pm.update(msg=f"{project.name} | generating list of objects", inc=0)
|
pm.update(msg=f"{project.name} | generating list of objects", inc=0)
|
||||||
remote_objects_cmd = GitCommand(
|
remote_objects_cmd = git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
[
|
[
|
||||||
"rev-list",
|
"rev-list",
|
||||||
@@ -215,7 +228,7 @@ class Gc(Command):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get all local objects and pack them.
|
# Get all local objects and pack them.
|
||||||
local_head_objects_cmd = GitCommand(
|
local_head_objects_cmd = git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
[
|
[
|
||||||
"rev-list",
|
"rev-list",
|
||||||
@@ -226,7 +239,7 @@ class Gc(Command):
|
|||||||
capture_stdout=True,
|
capture_stdout=True,
|
||||||
verify_command=True,
|
verify_command=True,
|
||||||
)
|
)
|
||||||
local_objects_cmd = GitCommand(
|
local_objects_cmd = git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
[
|
[
|
||||||
"rev-list",
|
"rev-list",
|
||||||
@@ -246,7 +259,7 @@ class Gc(Command):
|
|||||||
remote_objects_cmd.Wait()
|
remote_objects_cmd.Wait()
|
||||||
|
|
||||||
pm.update(msg=f"{project.name} | remote repack", inc=0)
|
pm.update(msg=f"{project.name} | remote repack", inc=0)
|
||||||
GitCommand(
|
git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
["pack-objects", os.path.join(pack_dir, "pack")],
|
["pack-objects", os.path.join(pack_dir, "pack")],
|
||||||
input=remote_objects_cmd.stdout,
|
input=remote_objects_cmd.stdout,
|
||||||
@@ -262,7 +275,7 @@ class Gc(Command):
|
|||||||
local_objects_cmd.Wait()
|
local_objects_cmd.Wait()
|
||||||
|
|
||||||
pm.update(msg=f"{project.name} | local repack", inc=0)
|
pm.update(msg=f"{project.name} | local repack", inc=0)
|
||||||
GitCommand(
|
git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
["pack-objects", os.path.join(pack_dir, "pack")],
|
["pack-objects", os.path.join(pack_dir, "pack")],
|
||||||
input=local_head_objects_cmd.stdout + local_objects_cmd.stdout,
|
input=local_head_objects_cmd.stdout + local_objects_cmd.stdout,
|
||||||
@@ -292,21 +305,26 @@ class Gc(Command):
|
|||||||
args, all_manifests=not opt.this_manifest_only
|
args, all_manifests=not opt.this_manifest_only
|
||||||
)
|
)
|
||||||
|
|
||||||
# If the user specified projects, fetch the global list separately
|
if opt.keep_manifest_projects:
|
||||||
# to avoid deleting untargeted projects.
|
projects_to_keep = self.GetProjects(
|
||||||
if args:
|
[],
|
||||||
all_projects = self.GetProjects(
|
groups="all",
|
||||||
|
missing_ok=True,
|
||||||
|
all_manifests=not opt.this_manifest_only,
|
||||||
|
)
|
||||||
|
elif args:
|
||||||
|
projects_to_keep = self.GetProjects(
|
||||||
[], all_manifests=not opt.this_manifest_only
|
[], all_manifests=not opt.this_manifest_only
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
all_projects = projects
|
projects_to_keep = projects
|
||||||
|
|
||||||
ret = self.delete_unused_projects(all_projects, opt)
|
ret = self.delete_unused_projects(projects_to_keep, opt)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
if opt.repack:
|
if opt.repack:
|
||||||
git_require((2, 17, 0), fail=True, msg="--repack")
|
git_command.git_require((2, 17, 0), fail=True, msg="--repack")
|
||||||
ret = self.repack_projects(projects, opt)
|
ret = self.repack_projects(projects, opt)
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
+13
-15
@@ -19,14 +19,12 @@ from typing import NamedTuple
|
|||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from command import PagedCommand
|
from command import PagedCommand
|
||||||
from error import GitError
|
import error
|
||||||
from error import InvalidArgumentsError
|
import git_command
|
||||||
from error import SilentRepoExitError
|
import repo_logging
|
||||||
from git_command import GitCommand
|
|
||||||
from repo_logging import RepoLogger
|
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class GrepColoring(Coloring):
|
class GrepColoring(Coloring):
|
||||||
@@ -43,10 +41,10 @@ class ExecuteOneResult(NamedTuple):
|
|||||||
rc: int
|
rc: int
|
||||||
stdout: str
|
stdout: str
|
||||||
stderr: str
|
stderr: str
|
||||||
error: GitError
|
error: error.GitError
|
||||||
|
|
||||||
|
|
||||||
class GrepCommandError(SilentRepoExitError):
|
class GrepCommandError(error.SilentRepoExitError):
|
||||||
"""Grep command failure. Since Grep command
|
"""Grep command failure. Since Grep command
|
||||||
output already outputs errors ensure that
|
output already outputs errors ensure that
|
||||||
aggregate errors exit silently."""
|
aggregate errors exit silently."""
|
||||||
@@ -265,7 +263,7 @@ contain a line that matches both expressions:
|
|||||||
"""Process one project."""
|
"""Process one project."""
|
||||||
project = cls.get_parallel_context()["projects"][project_idx]
|
project = cls.get_parallel_context()["projects"][project_idx]
|
||||||
try:
|
try:
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
project,
|
project,
|
||||||
cmd_argv,
|
cmd_argv,
|
||||||
bare=False,
|
bare=False,
|
||||||
@@ -273,16 +271,16 @@ contain a line that matches both expressions:
|
|||||||
capture_stderr=True,
|
capture_stderr=True,
|
||||||
verify_command=True,
|
verify_command=True,
|
||||||
)
|
)
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
return ExecuteOneResult(project_idx, -1, None, str(e), e)
|
return ExecuteOneResult(project_idx, -1, None, str(e), e)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
error = None
|
err = None
|
||||||
rc = p.Wait()
|
rc = p.Wait()
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
rc = 1
|
rc = 1
|
||||||
error = e
|
err = e
|
||||||
return ExecuteOneResult(project_idx, rc, p.stdout, p.stderr, error)
|
return ExecuteOneResult(project_idx, rc, p.stdout, p.stderr, err)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ProcessResults(
|
def _ProcessResults(
|
||||||
@@ -377,7 +375,7 @@ contain a line that matches both expressions:
|
|||||||
if "--cached" in cmd_argv:
|
if "--cached" in cmd_argv:
|
||||||
msg = "fatal: cannot combine --cached and --revision"
|
msg = "fatal: cannot combine --cached and --revision"
|
||||||
logger.error(msg)
|
logger.error(msg)
|
||||||
raise InvalidArgumentsError(msg)
|
raise error.InvalidArgumentsError(msg)
|
||||||
have_rev = True
|
have_rev = True
|
||||||
cmd_argv.extend(opt.revision)
|
cmd_argv.extend(opt.revision)
|
||||||
cmd_argv.append("--")
|
cmd_argv.append("--")
|
||||||
|
|||||||
+2
-2
@@ -19,12 +19,12 @@ import textwrap
|
|||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from command import PagedCommand
|
from command import PagedCommand
|
||||||
from error import RepoExitError
|
import error
|
||||||
from subcmds import all_commands
|
from subcmds import all_commands
|
||||||
from wrapper import Wrapper
|
from wrapper import Wrapper
|
||||||
|
|
||||||
|
|
||||||
class InvalidHelpCommand(RepoExitError):
|
class InvalidHelpCommand(error.RepoExitError):
|
||||||
"""Invalid command passed into help."""
|
"""Invalid command passed into help."""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -23,8 +23,7 @@ from typing import Any, Dict, List, NamedTuple, Optional, Tuple
|
|||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from command import PagedCommand
|
from command import PagedCommand
|
||||||
from git_refs import R_HEADS
|
import git_refs
|
||||||
from git_refs import R_M
|
|
||||||
|
|
||||||
|
|
||||||
class BranchInfo(NamedTuple):
|
class BranchInfo(NamedTuple):
|
||||||
@@ -333,9 +332,9 @@ class Info(PagedCommand):
|
|||||||
branch = project.manifest.manifestProject.config.GetBranch(
|
branch = project.manifest.manifestProject.config.GetBranch(
|
||||||
"default"
|
"default"
|
||||||
).merge
|
).merge
|
||||||
if branch.startswith(R_HEADS):
|
if branch.startswith(git_refs.R_HEADS):
|
||||||
branch = branch[len(R_HEADS) :]
|
branch = branch[len(git_refs.R_HEADS) :]
|
||||||
logTarget = R_M + branch
|
logTarget = git_refs.R_M + branch
|
||||||
|
|
||||||
localCommits, originCommits = cls._GetDiffCommits(
|
localCommits, originCommits = cls._GetDiffCommits(
|
||||||
project, logTarget
|
project, logTarget
|
||||||
|
|||||||
+12
-10
@@ -18,15 +18,14 @@ import sys
|
|||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import InteractiveCommand
|
from command import InteractiveCommand
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from error import RepoUnhandledExceptionError
|
import error
|
||||||
from error import UpdateManifestError
|
import git_command
|
||||||
from git_command import git_require
|
import repo_logging
|
||||||
from repo_logging import RepoLogger
|
|
||||||
from wrapper import Wrapper
|
from wrapper import Wrapper
|
||||||
from wrapper import WrapperDir
|
from wrapper import WrapperDir
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
_REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW")
|
_REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW")
|
||||||
|
|
||||||
@@ -164,6 +163,7 @@ to update the working directory files.
|
|||||||
clone_bundle=opt.clone_bundle,
|
clone_bundle=opt.clone_bundle,
|
||||||
git_lfs=opt.git_lfs,
|
git_lfs=opt.git_lfs,
|
||||||
use_superproject=opt.use_superproject,
|
use_superproject=opt.use_superproject,
|
||||||
|
smart_sync=opt.smart_sync,
|
||||||
verbose=opt.verbose,
|
verbose=opt.verbose,
|
||||||
current_branch_only=opt.current_branch_only,
|
current_branch_only=opt.current_branch_only,
|
||||||
tags=opt.tags,
|
tags=opt.tags,
|
||||||
@@ -173,7 +173,7 @@ to update the working directory files.
|
|||||||
use_local_gitdirs=opt.use_local_gitdirs,
|
use_local_gitdirs=opt.use_local_gitdirs,
|
||||||
):
|
):
|
||||||
manifest_name = opt.manifest_name
|
manifest_name = opt.manifest_name
|
||||||
raise UpdateManifestError(
|
raise error.UpdateManifestError(
|
||||||
f"Unable to sync manifest {manifest_name}"
|
f"Unable to sync manifest {manifest_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -347,9 +347,9 @@ to update the working directory files.
|
|||||||
wrapper = Wrapper()
|
wrapper = Wrapper()
|
||||||
|
|
||||||
reqs = wrapper.Requirements.from_dir(WrapperDir())
|
reqs = wrapper.Requirements.from_dir(WrapperDir())
|
||||||
git_require(reqs.get_hard_ver("git"), fail=True)
|
git_command.git_require(reqs.get_hard_ver("git"), fail=True)
|
||||||
min_git_version_soft = reqs.get_soft_ver("git")
|
min_git_version_soft = reqs.get_soft_ver("git")
|
||||||
if not git_require(min_git_version_soft):
|
if not git_command.git_require(min_git_version_soft):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"repo: warning: git-%s+ will soon be required; "
|
"repo: warning: git-%s+ will soon be required; "
|
||||||
"please upgrade your version of git to maintain "
|
"please upgrade your version of git to maintain "
|
||||||
@@ -378,7 +378,7 @@ to update the working directory files.
|
|||||||
err_msg = "fatal: double check your --repo-rev setting."
|
err_msg = "fatal: double check your --repo-rev setting."
|
||||||
logger.error(err_msg)
|
logger.error(err_msg)
|
||||||
self.git_event_log.ErrorEvent(err_msg)
|
self.git_event_log.ErrorEvent(err_msg)
|
||||||
raise RepoUnhandledExceptionError(e)
|
raise error.RepoUnhandledExceptionError(e)
|
||||||
|
|
||||||
branch = rp.GetBranch("default")
|
branch = rp.GetBranch("default")
|
||||||
branch.merge = remote_ref
|
branch.merge = remote_ref
|
||||||
@@ -388,7 +388,9 @@ to update the working directory files.
|
|||||||
if opt.worktree:
|
if opt.worktree:
|
||||||
# Older versions of git supported worktree, but had dangerous gc
|
# Older versions of git supported worktree, but had dangerous gc
|
||||||
# bugs.
|
# bugs.
|
||||||
git_require((2, 15, 0), fail=True, msg="git gc worktree corruption")
|
git_command.git_require(
|
||||||
|
(2, 15, 0), fail=True, msg="git gc worktree corruption"
|
||||||
|
)
|
||||||
|
|
||||||
# Provide a short notice that we're reinitializing an existing checkout.
|
# Provide a short notice that we're reinitializing an existing checkout.
|
||||||
# Sometimes developers might not realize that they're in one, or that
|
# Sometimes developers might not realize that they're in one, or that
|
||||||
|
|||||||
+2
-2
@@ -19,10 +19,10 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from command import PagedCommand
|
from command import PagedCommand
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class OutputFormat(enum.Enum):
|
class OutputFormat(enum.Enum):
|
||||||
|
|||||||
+6
-6
@@ -16,13 +16,13 @@ import sys
|
|||||||
|
|
||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import Command
|
from command import Command
|
||||||
from error import GitError
|
import error
|
||||||
from git_command import GitCommand
|
import git_command
|
||||||
from project import Project
|
from project import Project
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class RebaseColoring(Coloring):
|
class RebaseColoring(Coloring):
|
||||||
@@ -39,7 +39,7 @@ def _ResolveOntoManifest(project: Project) -> str:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return project.GetRemote().ToLocal(project.revisionExpr)
|
return project.GetRemote().ToLocal(project.revisionExpr)
|
||||||
except GitError:
|
except error.GitError:
|
||||||
return project.revisionExpr
|
return project.revisionExpr
|
||||||
|
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ branch but need to incorporate new upstream changes "underneath" them.
|
|||||||
out.nl()
|
out.nl()
|
||||||
out.flush()
|
out.flush()
|
||||||
|
|
||||||
if GitCommand(project, args).Wait() != 0:
|
if git_command.GitCommand(project, args).Wait() != 0:
|
||||||
ret += 1
|
ret += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -16,16 +16,16 @@ import optparse
|
|||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from error import RepoExitError
|
import error
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
from subcmds.sync import _PostRepoFetch
|
from subcmds.sync import _PostRepoFetch
|
||||||
from subcmds.sync import _PostRepoUpgrade
|
from subcmds.sync import _PostRepoUpgrade
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class SelfupdateError(RepoExitError):
|
class SelfupdateError(error.RepoExitError):
|
||||||
"""Exit error for failed selfupdate command."""
|
"""Exit error for failed selfupdate command."""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -16,11 +16,11 @@ import sys
|
|||||||
|
|
||||||
from color import Coloring
|
from color import Coloring
|
||||||
from command import InteractiveCommand
|
from command import InteractiveCommand
|
||||||
from git_command import GitCommand
|
import git_command
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class _ProjectList(Coloring):
|
class _ProjectList(Coloring):
|
||||||
@@ -129,5 +129,5 @@ The '%prog' command stages files to prepare the next commit.
|
|||||||
|
|
||||||
|
|
||||||
def _AddI(project):
|
def _AddI(project):
|
||||||
p = GitCommand(project, ["add", "--interactive"], bare=False)
|
p = git_command.GitCommand(project, ["add", "--interactive"], bare=False)
|
||||||
p.Wait()
|
p.Wait()
|
||||||
+9
-9
@@ -17,14 +17,14 @@ from typing import NamedTuple
|
|||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from error import RepoExitError
|
import error
|
||||||
from git_command import IsValidBranchName
|
import git_command
|
||||||
from git_config import IsImmutable
|
from git_config import IsImmutable
|
||||||
from progress import Progress
|
from progress import Progress
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class ExecuteOneResult(NamedTuple):
|
class ExecuteOneResult(NamedTuple):
|
||||||
@@ -32,7 +32,7 @@ class ExecuteOneResult(NamedTuple):
|
|||||||
error: Exception
|
error: Exception
|
||||||
|
|
||||||
|
|
||||||
class StartError(RepoExitError):
|
class StartError(error.RepoExitError):
|
||||||
"""Exit error for failed start command."""
|
"""Exit error for failed start command."""
|
||||||
|
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ revision specified in the manifest.
|
|||||||
self.Usage()
|
self.Usage()
|
||||||
|
|
||||||
nb = args[0]
|
nb = args[0]
|
||||||
if not IsValidBranchName(nb):
|
if not git_command.IsValidBranchName(nb):
|
||||||
self.OptionParser.error("'%s' is not a valid name" % nb)
|
self.OptionParser.error("'%s' is not a valid name" % nb)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -85,7 +85,7 @@ revision specified in the manifest.
|
|||||||
# a change, then we can't push back to it. Substitute with
|
# a change, then we can't push back to it. Substitute with
|
||||||
# dest_branch, if defined; or with manifest default revision instead.
|
# dest_branch, if defined; or with manifest default revision instead.
|
||||||
branch_merge = ""
|
branch_merge = ""
|
||||||
error = None
|
err = None
|
||||||
project = cls.get_parallel_context()["projects"][project_idx]
|
project = cls.get_parallel_context()["projects"][project_idx]
|
||||||
if IsImmutable(project.revisionExpr):
|
if IsImmutable(project.revisionExpr):
|
||||||
if project.dest_branch:
|
if project.dest_branch:
|
||||||
@@ -99,8 +99,8 @@ revision specified in the manifest.
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("error: unable to checkout %s: %s", project.name, e)
|
logger.error("error: unable to checkout %s: %s", project.name, e)
|
||||||
error = e
|
err = e
|
||||||
return ExecuteOneResult(project_idx, error)
|
return ExecuteOneResult(project_idx, err)
|
||||||
|
|
||||||
def Execute(self, opt, args):
|
def Execute(self, opt, args):
|
||||||
nb = args[0]
|
nb = args[0]
|
||||||
|
|||||||
+308
-101
@@ -57,19 +57,12 @@ from command import Command
|
|||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from command import WORKER_BATCH_SIZE
|
from command import WORKER_BATCH_SIZE
|
||||||
from error import GitError
|
import error
|
||||||
from error import RepoChangedException
|
|
||||||
from error import RepoError
|
|
||||||
from error import RepoExitError
|
|
||||||
from error import RepoUnhandledExceptionError
|
|
||||||
from error import SyncError
|
|
||||||
from error import UpdateManifestError
|
|
||||||
import event_log
|
import event_log
|
||||||
from git_command import git_require
|
import git_command
|
||||||
from git_command import GitCommand
|
|
||||||
from git_config import GetUrlCookieFile
|
from git_config import GetUrlCookieFile
|
||||||
from git_refs import HEAD
|
from git_config import RepoConfig
|
||||||
from git_refs import R_HEADS
|
import git_refs
|
||||||
import git_superproject
|
import git_superproject
|
||||||
from hooks import RepoHook
|
from hooks import RepoHook
|
||||||
from manifest_xml import XmlManifest
|
from manifest_xml import XmlManifest
|
||||||
@@ -81,8 +74,8 @@ from project import DeleteWorktreeError
|
|||||||
from project import Project
|
from project import Project
|
||||||
from project import RemoteSpec
|
from project import RemoteSpec
|
||||||
from project import SyncBuffer
|
from project import SyncBuffer
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
from repo_trace import Trace
|
import repo_trace
|
||||||
import ssh
|
import ssh
|
||||||
from wrapper import Wrapper
|
from wrapper import Wrapper
|
||||||
|
|
||||||
@@ -95,7 +88,70 @@ _BLOAT_PACK_COUNT_THRESHOLD = 10
|
|||||||
_BLOAT_SIZE_PACK_THRESHOLD_KB = 10 * 1024 * 1024 # 10 GiB in KiB
|
_BLOAT_SIZE_PACK_THRESHOLD_KB = 10 * 1024 * 1024 # 10 GiB in KiB
|
||||||
_BLOAT_SIZE_GARBAGE_THRESHOLD_KB = 1 * 1024 * 1024 # 1 GiB in KiB
|
_BLOAT_SIZE_GARBAGE_THRESHOLD_KB = 1 * 1024 * 1024 # 1 GiB in KiB
|
||||||
|
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
# Manifest server RPC that takes a single struct of named options. Servers
|
||||||
|
# that do not implement it are called via the positional GetApprovedManifest.
|
||||||
|
_GET_APPROVED_MANIFEST_V2 = "GetApprovedManifestV2"
|
||||||
|
|
||||||
|
|
||||||
|
def _IsMethodNotFoundFault(fault: xmlrpc.client.Fault, method: str) -> bool:
|
||||||
|
"""Whether |fault| means the server does not implement |method|."""
|
||||||
|
if fault.faultCode == xmlrpc.client.METHOD_NOT_FOUND:
|
||||||
|
return True
|
||||||
|
# Python's SimpleXMLRPCDispatcher uses faultCode 1 with this message.
|
||||||
|
return f'method "{method}" is not supported' in str(fault.faultString)
|
||||||
|
|
||||||
|
|
||||||
|
def _GetApprovedManifest(
|
||||||
|
server: xmlrpc.client.ServerProxy,
|
||||||
|
branch: str,
|
||||||
|
target: Optional[str],
|
||||||
|
best_effort: bool,
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""Requests the approved manifest from the manifest server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server: The manifest server proxy.
|
||||||
|
branch: The manifest branch.
|
||||||
|
target: The build target, if any.
|
||||||
|
best_effort: The client can proceed without a manifest (e.g. by
|
||||||
|
falling back to ToT), so the server may decline the request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A (success, manifest XML or error message) tuple.
|
||||||
|
"""
|
||||||
|
request = {"branch": branch}
|
||||||
|
if target:
|
||||||
|
request["target"] = target
|
||||||
|
if best_effort:
|
||||||
|
request["best_effort"] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = server.GetApprovedManifestV2(request)
|
||||||
|
except xmlrpc.client.Fault as e:
|
||||||
|
if not _IsMethodNotFoundFault(e, _GET_APPROVED_MANIFEST_V2):
|
||||||
|
raise
|
||||||
|
# Older server: |best_effort| has no equivalent and is dropped.
|
||||||
|
if target:
|
||||||
|
success, manifest_str = server.GetApprovedManifest(branch, target)
|
||||||
|
else:
|
||||||
|
success, manifest_str = server.GetApprovedManifest(branch)
|
||||||
|
return success, manifest_str
|
||||||
|
|
||||||
|
if not isinstance(response, dict):
|
||||||
|
raise SmartSyncError(
|
||||||
|
f"error: invalid {_GET_APPROVED_MANIFEST_V2} response: {response!r}"
|
||||||
|
)
|
||||||
|
if response.get("success"):
|
||||||
|
manifest_xml = response.get("manifest_xml")
|
||||||
|
if not isinstance(manifest_xml, str):
|
||||||
|
raise SmartSyncError(
|
||||||
|
f"error: {_GET_APPROVED_MANIFEST_V2} response has invalid "
|
||||||
|
f"manifest_xml: {manifest_xml!r}"
|
||||||
|
)
|
||||||
|
return True, manifest_xml
|
||||||
|
return False, response.get("error_message", "unknown error")
|
||||||
|
|
||||||
|
|
||||||
def _SafeCheckoutOrder(checkouts: List[Project]) -> List[List[Project]]:
|
def _SafeCheckoutOrder(checkouts: List[Project]) -> List[List[Project]]:
|
||||||
@@ -353,19 +409,19 @@ class _InterleavedSyncResult(NamedTuple):
|
|||||||
results: List[_SyncResult]
|
results: List[_SyncResult]
|
||||||
|
|
||||||
|
|
||||||
class SuperprojectError(SyncError):
|
class SuperprojectError(error.SyncError):
|
||||||
"""Superproject sync repo."""
|
"""Superproject sync repo."""
|
||||||
|
|
||||||
|
|
||||||
class SyncFailFastError(SyncError):
|
class SyncFailFastError(error.SyncError):
|
||||||
"""Sync exit error when --fail-fast set."""
|
"""Sync exit error when --fail-fast set."""
|
||||||
|
|
||||||
|
|
||||||
class SmartSyncError(SyncError):
|
class SmartSyncError(error.SyncError):
|
||||||
"""Smart sync exit error."""
|
"""Smart sync exit error."""
|
||||||
|
|
||||||
|
|
||||||
class ManifestInterruptError(RepoError):
|
class ManifestInterruptError(error.RepoError):
|
||||||
"""Aggregate Error to be logged when a user interrupts a manifest update."""
|
"""Aggregate Error to be logged when a user interrupts a manifest update."""
|
||||||
|
|
||||||
def __init__(self, output, **kwargs):
|
def __init__(self, output, **kwargs):
|
||||||
@@ -427,6 +483,10 @@ build as specified by the manifest-server element in the current
|
|||||||
manifest. The -t/--smart-tag option is similar and allows you to
|
manifest. The -t/--smart-tag option is similar and allows you to
|
||||||
specify a custom tag/label.
|
specify a custom tag/label.
|
||||||
|
|
||||||
|
Whether '%prog' smart syncs by default can also be controlled with the
|
||||||
|
sync.smartsync per-user setting; see docs/repoconfig.md. Explicit
|
||||||
|
-s/--no-smart-sync/-t flags always override it.
|
||||||
|
|
||||||
The -u/--manifest-server-username and -p/--manifest-server-password
|
The -u/--manifest-server-username and -p/--manifest-server-password
|
||||||
options can be used to specify a username and password to authenticate
|
options can be used to specify a username and password to authenticate
|
||||||
with the manifest server when using the -s or -t option.
|
with the manifest server when using the -s or -t option.
|
||||||
@@ -764,9 +824,17 @@ later is required to fix a server side protocol bug.
|
|||||||
"-s",
|
"-s",
|
||||||
"--smart-sync",
|
"--smart-sync",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
default=None,
|
||||||
help="smart sync using manifest from the latest known good "
|
help="smart sync using manifest from the latest known good "
|
||||||
"build",
|
"build",
|
||||||
)
|
)
|
||||||
|
p.add_option(
|
||||||
|
"--no-smart-sync",
|
||||||
|
"--head",
|
||||||
|
dest="smart_sync",
|
||||||
|
action="store_false",
|
||||||
|
help="disable smart sync and sync to ToT instead",
|
||||||
|
)
|
||||||
p.add_option(
|
p.add_option(
|
||||||
"-t",
|
"-t",
|
||||||
"--smart-tag",
|
"--smart-tag",
|
||||||
@@ -797,8 +865,8 @@ later is required to fix a server side protocol bug.
|
|||||||
"""
|
"""
|
||||||
b = manifest_project.GetBranch(manifest_project.CurrentBranch)
|
b = manifest_project.GetBranch(manifest_project.CurrentBranch)
|
||||||
branch = b.merge
|
branch = b.merge
|
||||||
if branch.startswith(R_HEADS):
|
if branch.startswith(git_refs.R_HEADS):
|
||||||
branch = branch[len(R_HEADS) :]
|
branch = branch[len(git_refs.R_HEADS) :]
|
||||||
return branch
|
return branch
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -819,13 +887,42 @@ later is required to fix a server side protocol bug.
|
|||||||
or opt.current_branch_only
|
or opt.current_branch_only
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _GetSuperprojectRevision(
|
||||||
|
self, opt: optparse.Values, manifest: XmlManifest
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Returns the --superproject-revision to apply to manifest.
|
||||||
|
|
||||||
|
The option only applies to the outer manifest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
opt: Program options returned from optparse. See _Options().
|
||||||
|
manifest: The manifest to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The requested superproject revision, or None if manifest's
|
||||||
|
superproject should not be pinned.
|
||||||
|
"""
|
||||||
|
if manifest == self.outer_manifest:
|
||||||
|
return opt.superproject_revision
|
||||||
|
return None
|
||||||
|
|
||||||
def _ConfigureSuperproject(
|
def _ConfigureSuperproject(
|
||||||
self,
|
self, opt: optparse.Values, manifest: XmlManifest
|
||||||
opt: optparse.Values,
|
|
||||||
manifest,
|
|
||||||
revision: Optional[str] = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Configure superproject with options."""
|
"""Configure superproject with options.
|
||||||
|
|
||||||
|
The configuration lives on the manifest's Superproject object, which
|
||||||
|
is discarded whenever the manifest is unloaded (see _ReloadManifest
|
||||||
|
and XmlManifest.Unload). Callers must therefore configure the
|
||||||
|
superproject again after every manifest reload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
opt: Program options returned from optparse. See _Options().
|
||||||
|
manifest: The manifest whose superproject should be configured.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Whether superproject messages should be printed.
|
||||||
|
"""
|
||||||
if not manifest.superproject:
|
if not manifest.superproject:
|
||||||
return False
|
return False
|
||||||
manifest.superproject.SetQuiet(not opt.verbose)
|
manifest.superproject.SetQuiet(not opt.verbose)
|
||||||
@@ -833,6 +930,7 @@ later is required to fix a server side protocol bug.
|
|||||||
opt.use_superproject, manifest
|
opt.use_superproject, manifest
|
||||||
)
|
)
|
||||||
manifest.superproject.SetPrintMessages(print_messages)
|
manifest.superproject.SetPrintMessages(print_messages)
|
||||||
|
revision = self._GetSuperprojectRevision(opt, manifest)
|
||||||
if revision:
|
if revision:
|
||||||
manifest.superproject.SetRevisionId(revision)
|
manifest.superproject.SetRevisionId(revision)
|
||||||
return print_messages
|
return print_messages
|
||||||
@@ -858,6 +956,11 @@ later is required to fix a server side protocol bug.
|
|||||||
m.superproject for m in manifest.all_children
|
m.superproject for m in manifest.all_children
|
||||||
)
|
)
|
||||||
if not have_superproject:
|
if not have_superproject:
|
||||||
|
superproject_logging_data.update(
|
||||||
|
superproject=False,
|
||||||
|
haslocalmanifests=bool(manifest.HasLocalManifests),
|
||||||
|
hassuperprojecttag=False,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if opt.local_only and manifest.superproject:
|
if opt.local_only and manifest.superproject:
|
||||||
@@ -882,7 +985,6 @@ later is required to fix a server side protocol bug.
|
|||||||
for p in all_projects:
|
for p in all_projects:
|
||||||
per_manifest[p.manifest.path_prefix].append(p)
|
per_manifest[p.manifest.path_prefix].append(p)
|
||||||
|
|
||||||
superproject_logging_data = {}
|
|
||||||
need_unload = False
|
need_unload = False
|
||||||
for m in self.ManifestList(opt):
|
for m in self.ManifestList(opt):
|
||||||
if m.path_prefix not in per_manifest:
|
if m.path_prefix not in per_manifest:
|
||||||
@@ -923,12 +1025,8 @@ later is required to fix a server side protocol bug.
|
|||||||
else:
|
else:
|
||||||
if print_messages:
|
if print_messages:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"%s: warning: Update of revisionId from superproject "
|
"error: Update of revsionId from superproject "
|
||||||
"has failed, repo sync will not use superproject to "
|
"has failed"
|
||||||
"fetch the source. Please resync with the "
|
|
||||||
"--no-use-superproject option to avoid this repo "
|
|
||||||
"warning.",
|
|
||||||
m.path_prefix,
|
|
||||||
)
|
)
|
||||||
if update_result.fatal and opt.use_superproject is not None:
|
if update_result.fatal and opt.use_superproject is not None:
|
||||||
raise SuperprojectError()
|
raise SuperprojectError()
|
||||||
@@ -1009,7 +1107,7 @@ later is required to fix a server side protocol bug.
|
|||||||
logger.error("Keyboard interrupt while processing %s", project.name)
|
logger.error("Keyboard interrupt while processing %s", project.name)
|
||||||
if not cls.is_multiprocessing_active():
|
if not cls.is_multiprocessing_active():
|
||||||
raise
|
raise
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
logger.error("error.GitError: Cannot fetch %s", e)
|
logger.error("error.GitError: Cannot fetch %s", e)
|
||||||
errors.append(e)
|
errors.append(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1215,7 +1313,7 @@ later is required to fix a server side protocol bug.
|
|||||||
if opt.network_only:
|
if opt.network_only:
|
||||||
# Bail out now; the rest touches the working tree.
|
# Bail out now; the rest touches the working tree.
|
||||||
if err_event.is_set():
|
if err_event.is_set():
|
||||||
e = SyncError(
|
e = error.SyncError(
|
||||||
"error: Exited sync due to fetch errors.",
|
"error: Exited sync due to fetch errors.",
|
||||||
aggregate_errors=errors,
|
aggregate_errors=errors,
|
||||||
)
|
)
|
||||||
@@ -1309,7 +1407,7 @@ later is required to fix a server side protocol bug.
|
|||||||
logger.error("Keyboard interrupt while processing %s", project.name)
|
logger.error("Keyboard interrupt while processing %s", project.name)
|
||||||
if not cls.is_multiprocessing_active():
|
if not cls.is_multiprocessing_active():
|
||||||
raise
|
raise
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"error.GitError: Cannot checkout %s: %s", project.name, e
|
"error.GitError: Cannot checkout %s: %s", project.name, e
|
||||||
)
|
)
|
||||||
@@ -1465,7 +1563,7 @@ later is required to fix a server side protocol bug.
|
|||||||
|
|
||||||
if expected != actual:
|
if expected != actual:
|
||||||
# If this is unexpected, log it and repair.
|
# If this is unexpected, log it and repair.
|
||||||
Trace(
|
repo_trace.Trace(
|
||||||
f"{relpath} expected preciousObjects={expected}, got {actual}"
|
f"{relpath} expected preciousObjects={expected}, got {actual}"
|
||||||
)
|
)
|
||||||
if expected:
|
if expected:
|
||||||
@@ -1475,7 +1573,7 @@ later is required to fix a server side protocol bug.
|
|||||||
% (relpath, project.name)
|
% (relpath, project.name)
|
||||||
)
|
)
|
||||||
|
|
||||||
if git_require((2, 7, 0)):
|
if git_command.git_require((2, 7, 0)):
|
||||||
project.EnableRepositoryExtension("preciousObjects")
|
project.EnableRepositoryExtension("preciousObjects")
|
||||||
else:
|
else:
|
||||||
# This isn't perfect, but it's the best we can do with old
|
# This isn't perfect, but it's the best we can do with old
|
||||||
@@ -1568,7 +1666,7 @@ later is required to fix a server side protocol bug.
|
|||||||
cls._RunOneGC(bare_git._project, config=config)
|
cls._RunOneGC(bare_git._project, config=config)
|
||||||
else:
|
else:
|
||||||
bare_git.pack_refs(config=config)
|
bare_git.pack_refs(config=config)
|
||||||
except GitError:
|
except error.GitError:
|
||||||
err_event.set()
|
err_event.set()
|
||||||
except Exception:
|
except Exception:
|
||||||
err_event.set()
|
err_event.set()
|
||||||
@@ -1608,26 +1706,12 @@ later is required to fix a server side protocol bug.
|
|||||||
"""
|
"""
|
||||||
project = cls.get_parallel_context()["projects"][project_index]
|
project = cls.get_parallel_context()["projects"][project_index]
|
||||||
|
|
||||||
if not project.Exists or not project.worktree:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Only check dirty or locally modified projects. These can't be
|
# Only check dirty or locally modified projects. These can't be
|
||||||
# freshly cloned and will accumulate garbage.
|
# freshly cloned and will accumulate garbage.
|
||||||
try:
|
try:
|
||||||
status = project._GetStatusSnapshot(
|
is_dirty, head_rev = project.GetDirtyAndHead()
|
||||||
untracked_files="normal", branch=True
|
|
||||||
)
|
|
||||||
if status is not None:
|
|
||||||
is_dirty = status.is_dirty(consider_untracked=True)
|
|
||||||
head_rev = status.branch_oid
|
|
||||||
else:
|
|
||||||
is_dirty = project.IsDirty(consider_untracked=True)
|
|
||||||
head_rev = project.work_git.rev_parse(HEAD)
|
|
||||||
|
|
||||||
if head_rev is None:
|
if head_rev is None:
|
||||||
# Porcelain v2 reports an unborn branch as "(initial)". The
|
# An unborn branch has no HEAD to compare, so skip it.
|
||||||
# legacy rev-parse path failed here and skipped the bloat
|
|
||||||
# calculation, so preserve that behavior.
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
manifest_rev = project.GetRevisionId(project.bare_ref.all)
|
manifest_rev = project.GetRevisionId(project.bare_ref.all)
|
||||||
@@ -1671,16 +1755,27 @@ later is required to fix a server side protocol bug.
|
|||||||
run 'git count-objects -v' and warn if the repository is accumulating
|
run 'git count-objects -v' and warn if the repository is accumulating
|
||||||
excessive pack files or garbage.
|
excessive pack files or garbage.
|
||||||
"""
|
"""
|
||||||
|
# --network-only promises not to touch worktrees, but git status and
|
||||||
|
# update-index --refresh can both rewrite the index.
|
||||||
|
if opt.network_only:
|
||||||
|
return
|
||||||
|
|
||||||
# We only care about bloated projects if we have a git version that
|
# We only care about bloated projects if we have a git version that
|
||||||
# supports --no-auto-gc (2.23.0+) since what we use to disable auto-gc
|
# supports --no-auto-gc (2.23.0+) since what we use to disable auto-gc
|
||||||
# in Project._RemoteFetch.
|
# in Project._RemoteFetch.
|
||||||
if not git_require((2, 23, 0)):
|
if not git_command.git_require((2, 23, 0)):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Skip projects with no worktree on disk, e.g. ones only ever synced
|
||||||
|
# with --network-only.
|
||||||
projects = [
|
projects = [
|
||||||
p
|
p
|
||||||
for p in projects
|
for p in projects
|
||||||
if p.clone_depth and not p.stateless_prune_needed
|
if p.clone_depth
|
||||||
|
and not p.stateless_prune_needed
|
||||||
|
and p.worktree
|
||||||
|
and p.Exists
|
||||||
|
and platform_utils.isdir(p.worktree)
|
||||||
]
|
]
|
||||||
if not projects:
|
if not projects:
|
||||||
return
|
return
|
||||||
@@ -2054,7 +2149,13 @@ later is required to fix a server side protocol bug.
|
|||||||
|
|
||||||
return server_url, transport
|
return server_url, transport
|
||||||
|
|
||||||
def _SmartSyncSetup(self, opt, smart_sync_manifest_path, manifest):
|
def _SmartSyncSetup(
|
||||||
|
self,
|
||||||
|
opt: optparse.Values,
|
||||||
|
smart_sync_manifest_path: str,
|
||||||
|
manifest: XmlManifest,
|
||||||
|
best_effort: bool = False,
|
||||||
|
) -> str:
|
||||||
if not manifest.manifest_server:
|
if not manifest.manifest_server:
|
||||||
raise SmartSyncError(
|
raise SmartSyncError(
|
||||||
"error: cannot smart sync: no manifest server defined in "
|
"error: cannot smart sync: no manifest server defined in "
|
||||||
@@ -2072,7 +2173,12 @@ later is required to fix a server side protocol bug.
|
|||||||
try:
|
try:
|
||||||
server = xmlrpc.client.Server(server_url, transport=transport)
|
server = xmlrpc.client.Server(server_url, transport=transport)
|
||||||
if opt.smart_sync:
|
if opt.smart_sync:
|
||||||
branch = self._GetBranch(manifest.manifestProject)
|
# SYNC_BRANCH overrides the branch sent to the manifest server
|
||||||
|
# (e.g. to select a specific CI branch that builds the same
|
||||||
|
# manifest branch). An empty value is treated as unset.
|
||||||
|
branch = os.environ.get("SYNC_BRANCH")
|
||||||
|
if not branch:
|
||||||
|
branch = self._GetBranch(manifest.manifestProject)
|
||||||
target = None
|
target = None
|
||||||
if "SYNC_TARGET" in os.environ:
|
if "SYNC_TARGET" in os.environ:
|
||||||
target = os.environ["SYNC_TARGET"]
|
target = os.environ["SYNC_TARGET"]
|
||||||
@@ -2095,12 +2201,9 @@ later is required to fix a server side protocol bug.
|
|||||||
os.environ["TARGET_BUILD_VARIANT"],
|
os.environ["TARGET_BUILD_VARIANT"],
|
||||||
)
|
)
|
||||||
|
|
||||||
if target:
|
success, manifest_str = _GetApprovedManifest(
|
||||||
[success, manifest_str] = server.GetApprovedManifest(
|
server, branch, target, best_effort
|
||||||
branch, target
|
)
|
||||||
)
|
|
||||||
else:
|
|
||||||
[success, manifest_str] = server.GetApprovedManifest(branch)
|
|
||||||
else:
|
else:
|
||||||
assert opt.smart_tag
|
assert opt.smart_tag
|
||||||
[success, manifest_str] = server.GetManifest(opt.smart_tag)
|
[success, manifest_str] = server.GetManifest(opt.smart_tag)
|
||||||
@@ -2156,7 +2259,7 @@ later is required to fix a server side protocol bug.
|
|||||||
mp: the manifestProject to query.
|
mp: the manifestProject to query.
|
||||||
manifest_name: Manifest file to be reloaded.
|
manifest_name: Manifest file to be reloaded.
|
||||||
"""
|
"""
|
||||||
if opt.superproject_revision and mp.manifest == self.outer_manifest:
|
if self._GetSuperprojectRevision(opt, mp.manifest):
|
||||||
self._SyncToSuperprojectRev(
|
self._SyncToSuperprojectRev(
|
||||||
opt, mp.manifest, mp, manifest_name, errors
|
opt, mp.manifest, mp, manifest_name, errors
|
||||||
)
|
)
|
||||||
@@ -2234,7 +2337,7 @@ later is required to fix a server side protocol bug.
|
|||||||
mp, event_log.TASK_SYNC_LOCAL, start, time.time(), clean
|
mp, event_log.TASK_SYNC_LOCAL, start, time.time(), clean
|
||||||
)
|
)
|
||||||
if not clean:
|
if not clean:
|
||||||
raise UpdateManifestError(aggregate_errors=errors)
|
raise error.UpdateManifestError(aggregate_errors=errors)
|
||||||
self._ReloadManifest(manifest_name, mp.manifest)
|
self._ReloadManifest(manifest_name, mp.manifest)
|
||||||
|
|
||||||
def ValidateOptions(self, opt, args):
|
def ValidateOptions(self, opt, args):
|
||||||
@@ -2252,10 +2355,6 @@ later is required to fix a server side protocol bug.
|
|||||||
if opt.manifest_name and opt.smart_tag:
|
if opt.manifest_name and opt.smart_tag:
|
||||||
self.OptionParser.error("cannot combine -m and -t")
|
self.OptionParser.error("cannot combine -m and -t")
|
||||||
if opt.manifest_server_username or opt.manifest_server_password:
|
if opt.manifest_server_username or opt.manifest_server_password:
|
||||||
if not (opt.smart_sync or opt.smart_tag):
|
|
||||||
self.OptionParser.error(
|
|
||||||
"-u and -p may only be combined with -s or -t"
|
|
||||||
)
|
|
||||||
if None in [
|
if None in [
|
||||||
opt.manifest_server_username,
|
opt.manifest_server_username,
|
||||||
opt.manifest_server_password,
|
opt.manifest_server_password,
|
||||||
@@ -2346,10 +2445,10 @@ later is required to fix a server side protocol bug.
|
|||||||
try:
|
try:
|
||||||
self._ExecuteHelper(opt, args, errors)
|
self._ExecuteHelper(opt, args, errors)
|
||||||
sync_duration_seconds = time.time() - start_time
|
sync_duration_seconds = time.time() - start_time
|
||||||
except (RepoExitError, RepoChangedException):
|
except (error.RepoExitError, error.RepoChangedException):
|
||||||
raise
|
raise
|
||||||
except (KeyboardInterrupt, Exception) as e:
|
except (KeyboardInterrupt, Exception) as e:
|
||||||
raise RepoUnhandledExceptionError(e, aggregate_errors=errors)
|
raise error.RepoUnhandledExceptionError(e, aggregate_errors=errors)
|
||||||
|
|
||||||
# Run post-sync hook only after successful sync
|
# Run post-sync hook only after successful sync
|
||||||
self._RunPostSyncHook(opt, sync_duration_seconds=sync_duration_seconds)
|
self._RunPostSyncHook(opt, sync_duration_seconds=sync_duration_seconds)
|
||||||
@@ -2372,25 +2471,26 @@ later is required to fix a server side protocol bug.
|
|||||||
def _SyncToSuperprojectRev(
|
def _SyncToSuperprojectRev(
|
||||||
self,
|
self,
|
||||||
opt: optparse.Values,
|
opt: optparse.Values,
|
||||||
manifest,
|
manifest: XmlManifest,
|
||||||
mp: Project,
|
mp: Project,
|
||||||
manifest_name: Optional[str],
|
manifest_name: Optional[str],
|
||||||
errors: List[Exception],
|
errors: List[Exception],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Sync to a specific superproject commit."""
|
"""Sync to a specific superproject commit.
|
||||||
if not manifest.superproject:
|
|
||||||
raise SyncError("superproject not defined in manifest")
|
|
||||||
|
|
||||||
self._ConfigureSuperproject(
|
Only valid for the outer manifest; see _GetSuperprojectRevision.
|
||||||
opt, manifest, revision=opt.superproject_revision
|
"""
|
||||||
)
|
if not manifest.superproject:
|
||||||
|
raise error.SyncError("superproject not defined in manifest")
|
||||||
|
|
||||||
|
self._ConfigureSuperproject(opt, manifest)
|
||||||
|
|
||||||
sync_result = manifest.superproject.Sync(self.git_event_log)
|
sync_result = manifest.superproject.Sync(self.git_event_log)
|
||||||
if not sync_result.success:
|
if not sync_result.success:
|
||||||
raise SyncError("failed to sync superproject")
|
raise error.SyncError("failed to sync superproject")
|
||||||
|
|
||||||
cmd = ["show", f"{opt.superproject_revision}:.supermanifest"]
|
cmd = ["show", f"{opt.superproject_revision}:.supermanifest"]
|
||||||
p = GitCommand(
|
p = git_command.GitCommand(
|
||||||
None,
|
None,
|
||||||
cmd,
|
cmd,
|
||||||
gitdir=manifest.superproject._work_git,
|
gitdir=manifest.superproject._work_git,
|
||||||
@@ -2399,23 +2499,75 @@ later is required to fix a server side protocol bug.
|
|||||||
capture_stderr=True,
|
capture_stderr=True,
|
||||||
)
|
)
|
||||||
if p.Wait() != 0:
|
if p.Wait() != 0:
|
||||||
raise SyncError(
|
raise error.SyncError(
|
||||||
f"failed to read .supermanifest from superproject: {p.stderr}"
|
f"failed to read .supermanifest from superproject: {p.stderr}"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_, _, manifest_commit = p.stdout.strip().split()
|
_, _, manifest_commit = p.stdout.strip().split()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise SyncError("could not parse .supermanifest")
|
raise error.SyncError("could not parse .supermanifest")
|
||||||
|
|
||||||
mp.SetRevision(manifest_commit)
|
mp.SetRevision(manifest_commit)
|
||||||
try:
|
try:
|
||||||
self._UpdateManifestProject(opt, mp, manifest_name, errors)
|
self._UpdateManifestProject(opt, mp, manifest_name, errors)
|
||||||
except UpdateManifestError as e:
|
except error.UpdateManifestError as e:
|
||||||
raise SyncError(
|
raise error.SyncError(
|
||||||
"failed to sync manifest project", aggregate_errors=[e]
|
"failed to sync manifest project", aggregate_errors=[e]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _ResolveSmartSyncOption(
|
||||||
|
self, opt: optparse.Values, manifest: XmlManifest
|
||||||
|
) -> bool:
|
||||||
|
"""Resolve opt.smart_sync from CLI flags, config, and manifest.
|
||||||
|
|
||||||
|
Precedence: CLI flags > per-checkout settings > per-user settings >
|
||||||
|
per-manifest settings.
|
||||||
|
See docs/repoconfig.md and docs/smart-sync.md for details.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if smart_sync was enabled implicitly by checkout or manifest
|
||||||
|
default, False otherwise. Smart sync enabled via per-user settings
|
||||||
|
counts as an explicit user request, not implicit.
|
||||||
|
"""
|
||||||
|
checkout_pref = getattr(manifest.manifestProject, "smart_sync", None)
|
||||||
|
if not isinstance(checkout_pref, bool):
|
||||||
|
checkout_pref = None
|
||||||
|
user_pref = RepoConfig.ForUser().GetBoolean("sync.smartsync")
|
||||||
|
default_allowed = (
|
||||||
|
opt.smart_sync is None
|
||||||
|
and not opt.smart_tag
|
||||||
|
and not opt.manifest_name
|
||||||
|
and not opt.superproject_revision
|
||||||
|
and not opt.local_only
|
||||||
|
)
|
||||||
|
default_smart_sync = (
|
||||||
|
checkout_pref
|
||||||
|
if checkout_pref is not None
|
||||||
|
else getattr(manifest.default, "sync_smartsync", False) is True
|
||||||
|
)
|
||||||
|
implicit = (
|
||||||
|
default_allowed
|
||||||
|
and (checkout_pref is not None or user_pref is None)
|
||||||
|
and default_smart_sync
|
||||||
|
)
|
||||||
|
if opt.smart_sync is None:
|
||||||
|
if checkout_pref is not None:
|
||||||
|
opt.smart_sync = implicit
|
||||||
|
elif user_pref is False:
|
||||||
|
opt.smart_sync = False
|
||||||
|
elif user_pref is True:
|
||||||
|
opt.smart_sync = default_allowed
|
||||||
|
else:
|
||||||
|
opt.smart_sync = implicit
|
||||||
|
|
||||||
|
if (
|
||||||
|
opt.manifest_server_username or opt.manifest_server_password
|
||||||
|
) and not (opt.smart_sync or opt.smart_tag):
|
||||||
|
raise SmartSyncError("-u and -p may only be combined with -s or -t")
|
||||||
|
|
||||||
|
return implicit
|
||||||
|
|
||||||
def _ExecuteHelper(self, opt, args, errors):
|
def _ExecuteHelper(self, opt, args, errors):
|
||||||
manifest = self.outer_manifest
|
manifest = self.outer_manifest
|
||||||
if not opt.outer_manifest:
|
if not opt.outer_manifest:
|
||||||
@@ -2430,11 +2582,62 @@ later is required to fix a server side protocol bug.
|
|||||||
if opt.clone_bundle is None:
|
if opt.clone_bundle is None:
|
||||||
opt.clone_bundle = manifest.CloneBundle
|
opt.clone_bundle = manifest.CloneBundle
|
||||||
|
|
||||||
|
# Without an explicit smart sync option, -l does not contact the
|
||||||
|
# manifest server, so it keeps the smart sync manifest the client last
|
||||||
|
# synced to, whether that came from -s or from the manifest's
|
||||||
|
# sync-smartsync default. This keeps `sync -n` + `sync -l` equivalent
|
||||||
|
# to a single sync. Computed before _ResolveSmartSyncOption, which
|
||||||
|
# makes an unset opt.smart_sync indistinguishable from --no-smart-sync.
|
||||||
|
reuse_smart_sync_override = (
|
||||||
|
opt.local_only
|
||||||
|
and opt.smart_sync is None
|
||||||
|
and not opt.smart_tag
|
||||||
|
and not opt.manifest_name
|
||||||
|
and not opt.superproject_revision
|
||||||
|
and os.path.isfile(smart_sync_manifest_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
implicit_smart_sync = self._ResolveSmartSyncOption(opt, manifest)
|
||||||
|
|
||||||
if opt.smart_sync or opt.smart_tag:
|
if opt.smart_sync or opt.smart_tag:
|
||||||
manifest_name = self._SmartSyncSetup(
|
try:
|
||||||
opt, smart_sync_manifest_path, manifest
|
manifest_name = self._SmartSyncSetup(
|
||||||
)
|
opt,
|
||||||
else:
|
smart_sync_manifest_path,
|
||||||
|
manifest,
|
||||||
|
best_effort=implicit_smart_sync,
|
||||||
|
)
|
||||||
|
except (SmartSyncError, error.ManifestParseError) as e:
|
||||||
|
if not implicit_smart_sync:
|
||||||
|
raise
|
||||||
|
# The user did not ask for a smart sync, so failing to get a
|
||||||
|
# usable manifest from the server should not fail the sync.
|
||||||
|
# Fall back to a regular ToT sync instead.
|
||||||
|
opt.smart_sync = False
|
||||||
|
# Override() records the override before parsing it, so an
|
||||||
|
# unparsable manifest would otherwise leave the client pinned
|
||||||
|
# to a file it cannot load.
|
||||||
|
manifest.ClearOverride()
|
||||||
|
if opt.verbose:
|
||||||
|
logger.warning(
|
||||||
|
"warning: smart sync failed; falling back to ToT: %s", e
|
||||||
|
)
|
||||||
|
elif reuse_smart_sync_override:
|
||||||
|
manifest_name = os.path.basename(smart_sync_manifest_path)
|
||||||
|
try:
|
||||||
|
self._ReloadManifest(manifest_name, manifest)
|
||||||
|
except error.ManifestParseError as e:
|
||||||
|
reuse_smart_sync_override = False
|
||||||
|
manifest_name = None
|
||||||
|
manifest.ClearOverride()
|
||||||
|
if opt.verbose:
|
||||||
|
logger.warning(
|
||||||
|
"warning: failed to load existing smart sync manifest; "
|
||||||
|
"falling back to ToT: %s",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (opt.smart_sync or opt.smart_tag or reuse_smart_sync_override):
|
||||||
if os.path.isfile(smart_sync_manifest_path):
|
if os.path.isfile(smart_sync_manifest_path):
|
||||||
try:
|
try:
|
||||||
platform_utils.remove(smart_sync_manifest_path)
|
platform_utils.remove(smart_sync_manifest_path)
|
||||||
@@ -2593,7 +2796,7 @@ later is required to fix a server side protocol bug.
|
|||||||
nested = _NestedProjects(projects)
|
nested = _NestedProjects(projects)
|
||||||
if not nested:
|
if not nested:
|
||||||
return
|
return
|
||||||
e = SyncError(
|
e = error.SyncError(
|
||||||
"error: repo.reprojectcmd does not support nested projects or "
|
"error: repo.reprojectcmd does not support nested projects or "
|
||||||
"submodules; found:\n"
|
"submodules; found:\n"
|
||||||
+ "\n".join(
|
+ "\n".join(
|
||||||
@@ -2717,7 +2920,7 @@ later is required to fix a server side protocol bug.
|
|||||||
logger.error(
|
logger.error(
|
||||||
'Try re-running with "-j1 --fail-fast" to exit at the first error.'
|
'Try re-running with "-j1 --fail-fast" to exit at the first error.'
|
||||||
)
|
)
|
||||||
raise SyncError(aggregate_errors=errors)
|
raise error.SyncError(aggregate_errors=errors)
|
||||||
|
|
||||||
def _SyncPhased(
|
def _SyncPhased(
|
||||||
self,
|
self,
|
||||||
@@ -2851,7 +3054,7 @@ later is required to fix a server side protocol bug.
|
|||||||
)
|
)
|
||||||
if not cls.is_multiprocessing_active():
|
if not cls.is_multiprocessing_active():
|
||||||
raise
|
raise
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
fetch_errors.append(e)
|
fetch_errors.append(e)
|
||||||
logger.error("error.GitError: Cannot fetch %s", e)
|
logger.error("error.GitError: Cannot fetch %s", e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2903,7 +3106,7 @@ later is required to fix a server side protocol bug.
|
|||||||
)
|
)
|
||||||
if not cls.is_multiprocessing_active():
|
if not cls.is_multiprocessing_active():
|
||||||
raise
|
raise
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
checkout_errors.append(e)
|
checkout_errors.append(e)
|
||||||
logger.error(
|
logger.error(
|
||||||
"error.GitError: Cannot checkout %s: %s",
|
"error.GitError: Cannot checkout %s: %s",
|
||||||
@@ -3271,7 +3474,7 @@ def _PostRepoFetch(rp, repo_verify=True, verbose=False):
|
|||||||
wrapper = Wrapper()
|
wrapper = Wrapper()
|
||||||
try:
|
try:
|
||||||
rev = rp.bare_git.describe(rp.GetRevisionId())
|
rev = rp.bare_git.describe(rp.GetRevisionId())
|
||||||
except GitError:
|
except error.GitError:
|
||||||
rev = None
|
rev = None
|
||||||
_, new_rev = wrapper.check_repo_rev(
|
_, new_rev = wrapper.check_repo_rev(
|
||||||
rp.gitdir, rev, repo_verify=repo_verify
|
rp.gitdir, rev, repo_verify=repo_verify
|
||||||
@@ -3289,15 +3492,18 @@ def _PostRepoFetch(rp, repo_verify=True, verbose=False):
|
|||||||
# Refresh index since reset --keep won't do it.
|
# Refresh index since reset --keep won't do it.
|
||||||
rp.work_git.update_index("-q", "--refresh")
|
rp.work_git.update_index("-q", "--refresh")
|
||||||
rp.work_git.reset("--keep", new_rev)
|
rp.work_git.reset("--keep", new_rev)
|
||||||
except GitError as e:
|
except error.GitError as e:
|
||||||
raise RepoUnhandledExceptionError(e)
|
raise error.RepoUnhandledExceptionError(e)
|
||||||
print("info: Restarting repo with latest version")
|
print("info: Restarting repo with latest version")
|
||||||
raise RepoChangedException(["--repo-upgraded"])
|
raise error.RepoChangedException(["--repo-upgraded"])
|
||||||
else:
|
else:
|
||||||
logger.warning("warning: Skipped upgrade to unverified version")
|
logger.warning("warning: Skipped upgrade to unverified version")
|
||||||
else:
|
else:
|
||||||
if verbose:
|
if verbose:
|
||||||
print("repo version %s is current" % rp.work_git.describe(HEAD))
|
print(
|
||||||
|
"repo version %s is current"
|
||||||
|
% rp.work_git.describe(git_refs.HEAD)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _FetchTimes:
|
class _FetchTimes:
|
||||||
@@ -3372,13 +3578,13 @@ class LocalSyncState:
|
|||||||
|
|
||||||
def _Get(self, project, key):
|
def _Get(self, project, key):
|
||||||
self._Load()
|
self._Load()
|
||||||
p = project.relpath
|
p = project.RelPath(local=False)
|
||||||
if p not in self._state:
|
if p not in self._state:
|
||||||
return
|
return
|
||||||
return self._state[p].get(key)
|
return self._state[p].get(key)
|
||||||
|
|
||||||
def _Set(self, project, key):
|
def _Set(self, project, key):
|
||||||
p = project.relpath
|
p = project.RelPath(local=False)
|
||||||
if p not in self._state:
|
if p not in self._state:
|
||||||
self._state[p] = {}
|
self._state[p] = {}
|
||||||
self._state[p][key] = self._time
|
self._state[p][key] = self._time
|
||||||
@@ -3406,8 +3612,9 @@ class LocalSyncState:
|
|||||||
if not self._state:
|
if not self._state:
|
||||||
return
|
return
|
||||||
delete = set()
|
delete = set()
|
||||||
|
outer_topdir = self._manifest.outer_client.topdir
|
||||||
for path in self._state:
|
for path in self._state:
|
||||||
gitdir = os.path.join(self._manifest.topdir, path, ".git")
|
gitdir = os.path.join(outer_topdir, path, ".git")
|
||||||
if not os.path.exists(gitdir) or os.path.islink(gitdir):
|
if not os.path.exists(gitdir) or os.path.islink(gitdir):
|
||||||
delete.add(path)
|
delete.add(path)
|
||||||
if not delete:
|
if not delete:
|
||||||
|
|||||||
+55
-27
@@ -22,22 +22,20 @@ from typing import List
|
|||||||
from command import DEFAULT_LOCAL_JOBS
|
from command import DEFAULT_LOCAL_JOBS
|
||||||
from command import InteractiveCommand
|
from command import InteractiveCommand
|
||||||
from editor import Editor
|
from editor import Editor
|
||||||
from error import GitError
|
import error
|
||||||
from error import SilentRepoExitError
|
import git_refs
|
||||||
from error import UploadError
|
|
||||||
from git_refs import R_HEADS
|
|
||||||
import git_superproject
|
import git_superproject
|
||||||
from hooks import RepoHook
|
from hooks import RepoHook
|
||||||
from project import ReviewableBranch
|
from project import ReviewableBranch
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
from subcmds.sync import LocalSyncState
|
from subcmds.sync import LocalSyncState
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_UNUSUAL_COMMIT_THRESHOLD = 5
|
_DEFAULT_UNUSUAL_COMMIT_THRESHOLD = 5
|
||||||
logger = RepoLogger(__file__)
|
logger = repo_logging.RepoLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class UploadExitError(SilentRepoExitError):
|
class UploadExitError(error.SilentRepoExitError):
|
||||||
"""Indicates that there is an upload command error requiring a sys exit."""
|
"""Indicates that there is an upload command error requiring a sys exit."""
|
||||||
|
|
||||||
|
|
||||||
@@ -371,6 +369,12 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
action="store_false",
|
action="store_false",
|
||||||
help="always ask about untracked files in the working copy",
|
help="always ask about untracked files in the working copy",
|
||||||
)
|
)
|
||||||
|
p.add_option(
|
||||||
|
"--ignore-remote-commits",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="ignore commits already in remote branches",
|
||||||
|
)
|
||||||
p.add_option(
|
p.add_option(
|
||||||
"--no-cert-checks",
|
"--no-cert-checks",
|
||||||
dest="validate_certs",
|
dest="validate_certs",
|
||||||
@@ -609,11 +613,11 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
)
|
)
|
||||||
|
|
||||||
full_dest = destination
|
full_dest = destination
|
||||||
if not full_dest.startswith(R_HEADS):
|
if not full_dest.startswith(git_refs.R_HEADS):
|
||||||
full_dest = R_HEADS + full_dest
|
full_dest = git_refs.R_HEADS + full_dest
|
||||||
full_revision = branch.project.revisionExpr
|
full_revision = branch.project.revisionExpr
|
||||||
if not full_revision.startswith(R_HEADS):
|
if not full_revision.startswith(git_refs.R_HEADS):
|
||||||
full_revision = R_HEADS + full_revision
|
full_revision = git_refs.R_HEADS + full_revision
|
||||||
|
|
||||||
# If the merge branch of the local branch is different from
|
# If the merge branch of the local branch is different from
|
||||||
# the project's revision AND destination, this might not be
|
# the project's revision AND destination, this might not be
|
||||||
@@ -661,6 +665,7 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
push_options=push_options,
|
push_options=push_options,
|
||||||
patchset_description=opt.patchset_description,
|
patchset_description=opt.patchset_description,
|
||||||
git_event_log=self.git_event_log,
|
git_event_log=self.git_event_log,
|
||||||
|
quiet=opt.quiet,
|
||||||
)
|
)
|
||||||
|
|
||||||
branch.uploaded = True
|
branch.uploaded = True
|
||||||
@@ -671,15 +676,17 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
for branch in todo:
|
for branch in todo:
|
||||||
try:
|
try:
|
||||||
self._UploadBranch(opt, branch, people)
|
self._UploadBranch(opt, branch, people)
|
||||||
except (UploadError, GitError) as e:
|
except (error.UploadError, error.GitError) as e:
|
||||||
self.git_event_log.ErrorEvent(f"upload error: {e}")
|
self.git_event_log.ErrorEvent(f"upload error: {e}")
|
||||||
branch.error = e
|
branch.error = e
|
||||||
aggregate_errors.append(e)
|
aggregate_errors.append(e)
|
||||||
branch.uploaded = False
|
branch.uploaded = False
|
||||||
have_errors = True
|
have_errors = True
|
||||||
|
|
||||||
print(file=sys.stderr)
|
quiet = getattr(opt, "quiet", False)
|
||||||
print("-" * 70, file=sys.stderr)
|
if not quiet or have_errors:
|
||||||
|
print(file=sys.stderr)
|
||||||
|
print("-" * 70, file=sys.stderr)
|
||||||
|
|
||||||
if have_errors:
|
if have_errors:
|
||||||
for branch in todo:
|
for branch in todo:
|
||||||
@@ -700,17 +707,18 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
)
|
)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
for branch in todo:
|
if not quiet:
|
||||||
if branch.uploaded:
|
for branch in todo:
|
||||||
print(
|
if branch.uploaded:
|
||||||
"[OK ] %-15s %s"
|
print(
|
||||||
% (
|
"[OK ] %-15s %s"
|
||||||
branch.project.RelPath(local=opt.this_manifest_only)
|
% (
|
||||||
+ "/",
|
branch.project.RelPath(local=opt.this_manifest_only)
|
||||||
branch.name,
|
+ "/",
|
||||||
),
|
branch.name,
|
||||||
file=sys.stderr,
|
),
|
||||||
)
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
if have_errors:
|
if have_errors:
|
||||||
raise UploadExitError(aggregate_errors=aggregate_errors)
|
raise UploadExitError(aggregate_errors=aggregate_errors)
|
||||||
@@ -736,12 +744,32 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
|||||||
cbr = None
|
cbr = None
|
||||||
if opt.current_branch:
|
if opt.current_branch:
|
||||||
cbr = project.CurrentBranch
|
cbr = project.CurrentBranch
|
||||||
up_branch = project.GetUploadableBranch(cbr)
|
up_branch = project.GetUploadableBranch(
|
||||||
|
cbr, ignore_remote_commits=opt.ignore_remote_commits
|
||||||
|
)
|
||||||
avail = [up_branch] if up_branch else None
|
avail = [up_branch] if up_branch else None
|
||||||
else:
|
else:
|
||||||
avail = project.GetUploadableBranches(opt.branch)
|
avail = project.GetUploadableBranches(
|
||||||
|
opt.branch, ignore_remote_commits=opt.ignore_remote_commits
|
||||||
|
)
|
||||||
return (project_idx, avail, cbr)
|
return (project_idx, avail, cbr)
|
||||||
|
|
||||||
|
def ValidateOptions(self, opt: optparse.Values, args: List[str]) -> None:
|
||||||
|
if opt.fix:
|
||||||
|
if opt.ignore_hooks:
|
||||||
|
self.OptionParser.error(
|
||||||
|
"--fix and --ignore-hooks are mutually exclusive options: "
|
||||||
|
"with --ignore-hooks, the original commits are uploaded, "
|
||||||
|
"so fixups would only modify the working tree and would "
|
||||||
|
"not be part of the upload"
|
||||||
|
)
|
||||||
|
if opt.bypass_hooks:
|
||||||
|
self.OptionParser.error(
|
||||||
|
"--fix and --no-verify are mutually exclusive options: "
|
||||||
|
"--no-verify skips the pre-upload hook, so --fix would "
|
||||||
|
"have no effect"
|
||||||
|
)
|
||||||
|
|
||||||
def Execute(self, opt, args):
|
def Execute(self, opt, args):
|
||||||
projects = self.GetProjects(
|
projects = self.GetProjects(
|
||||||
args, all_manifests=not opt.this_manifest_only
|
args, all_manifests=not opt.this_manifest_only
|
||||||
|
|||||||
+10
-13
@@ -18,11 +18,8 @@ from typing import Any, Tuple
|
|||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from command import MirrorSafeCommand
|
from command import MirrorSafeCommand
|
||||||
from git_command import git
|
import git_command
|
||||||
from git_command import git_require
|
import git_refs
|
||||||
from git_command import RepoSourceVersion
|
|
||||||
from git_command import user_agent
|
|
||||||
from git_refs import HEAD
|
|
||||||
from wrapper import Wrapper
|
from wrapper import Wrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -39,17 +36,17 @@ class Version(Command, MirrorSafeCommand):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _RepoVersion(project: Any) -> Tuple[str, str]:
|
def _RepoVersion(project: Any) -> Tuple[str, str]:
|
||||||
"""Return repo's describe string and commit date."""
|
"""Return repo's describe string and commit date."""
|
||||||
if git_require((2, 32, 0)):
|
if git_command.git_require((2, 32, 0)):
|
||||||
output = project.bare_git.log(
|
output = project.bare_git.log(
|
||||||
"-1", "--format=%(describe)%n%cD", HEAD
|
"-1", "--format=%(describe)%n%cD", git_refs.HEAD
|
||||||
)
|
)
|
||||||
description, commit_date = output.rstrip("\n").split("\n", 1)
|
description, commit_date = output.rstrip("\n").split("\n", 1)
|
||||||
if description:
|
if description:
|
||||||
return description, commit_date
|
return description, commit_date
|
||||||
|
|
||||||
return (
|
return (
|
||||||
project.bare_git.describe(HEAD),
|
project.bare_git.describe(git_refs.HEAD),
|
||||||
project.bare_git.log("-1", "--format=%cD", HEAD),
|
project.bare_git.log("-1", "--format=%cD", git_refs.HEAD),
|
||||||
)
|
)
|
||||||
|
|
||||||
def Execute(self, opt, args):
|
def Execute(self, opt, args):
|
||||||
@@ -58,7 +55,7 @@ class Version(Command, MirrorSafeCommand):
|
|||||||
branch = rp.GetBranch("default")
|
branch = rp.GetBranch("default")
|
||||||
|
|
||||||
# These might not be the same. Report them both.
|
# These might not be the same. Report them both.
|
||||||
src_ver = RepoSourceVersion()
|
src_ver = git_command.RepoSourceVersion()
|
||||||
rp_ver, commit_date = self._RepoVersion(rp)
|
rp_ver, commit_date = self._RepoVersion(rp)
|
||||||
print(f"repo version {rp_ver}")
|
print(f"repo version {rp_ver}")
|
||||||
print(f" (from {rem.url})")
|
print(f" (from {rem.url})")
|
||||||
@@ -72,9 +69,9 @@ class Version(Command, MirrorSafeCommand):
|
|||||||
if src_ver != rp_ver:
|
if src_ver != rp_ver:
|
||||||
print(f" (currently at {src_ver})")
|
print(f" (currently at {src_ver})")
|
||||||
|
|
||||||
print(f"repo User-Agent {user_agent.repo}")
|
print(f"repo User-Agent {git_command.user_agent.repo}")
|
||||||
print(f"git {git.version_tuple().full}")
|
print(f"git {git_command.git.version_tuple().full}")
|
||||||
print(f"git User-Agent {user_agent.git}")
|
print(f"git User-Agent {git_command.user_agent.git}")
|
||||||
print(f"Python {sys.version}")
|
print(f"Python {sys.version}")
|
||||||
uname = platform.uname()
|
uname = platform.uname()
|
||||||
if sys.version_info.major < 3:
|
if sys.version_info.major < 3:
|
||||||
|
|||||||
+3
-4
@@ -17,13 +17,12 @@ import sys
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from command import Command
|
from command import Command
|
||||||
from error import GitError
|
import error
|
||||||
from error import RepoExitError
|
|
||||||
import platform_utils
|
import platform_utils
|
||||||
from project import DeleteWorktreeError
|
from project import DeleteWorktreeError
|
||||||
|
|
||||||
|
|
||||||
class Error(RepoExitError):
|
class Error(error.RepoExitError):
|
||||||
"""Exit error when wipe command fails."""
|
"""Exit error when wipe command fails."""
|
||||||
|
|
||||||
|
|
||||||
@@ -101,7 +100,7 @@ Examples:
|
|||||||
try:
|
try:
|
||||||
if project.HasChanges():
|
if project.HasChanges():
|
||||||
uncommitted_projects.append(project.name)
|
uncommitted_projects.append(project.name)
|
||||||
except GitError:
|
except error.GitError:
|
||||||
uncommitted_projects.append(f"{project.name} (corrupted)")
|
uncommitted_projects.append(f"{project.name} (corrupted)")
|
||||||
|
|
||||||
users = objdir_map.get(project.objdir, {project.relpath})
|
users = objdir_map.get(project.objdir, {project.relpath})
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ should be written using [pytest] only.
|
|||||||
[subcmds/forall.py](../subcmds/forall.py) module.
|
[subcmds/forall.py](../subcmds/forall.py) module.
|
||||||
* [conftest.py](./conftest.py): Custom pytest fixtures for sharing.
|
* [conftest.py](./conftest.py): Custom pytest fixtures for sharing.
|
||||||
* [utils_for_test.py](./utils_for_test.py): Helpers for sharing in tests.
|
* [utils_for_test.py](./utils_for_test.py): Helpers for sharing in tests.
|
||||||
|
* [e2e/](./e2e/): Hermetic end-to-end tests that run the real `repo` against
|
||||||
|
a local git server.
|
||||||
|
|
||||||
|
|
||||||
[pytest]: https://pytest.org/
|
[pytest]: https://pytest.org/
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"""Common fixtures for pytests."""
|
"""Common fixtures for pytests."""
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from typing import Any, Generator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -23,9 +24,44 @@ import platform_utils
|
|||||||
import repo_trace
|
import repo_trace
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||||
|
parser.addoption(
|
||||||
|
"--fail-on-skip",
|
||||||
|
action="store_true",
|
||||||
|
help="fail skipped tests (run_tests sets this in CQ)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.hookimpl(hookwrapper=True)
|
||||||
|
def pytest_runtest_makereport(
|
||||||
|
item: pytest.Item,
|
||||||
|
) -> Generator[None, Any, None]:
|
||||||
|
"""Fail skipped tests with --fail-on-skip.
|
||||||
|
|
||||||
|
A skip in CQ usually means the builder is missing a dependency (e.g. a new
|
||||||
|
enough git), which would otherwise silently drop coverage. Tests that
|
||||||
|
should not run in CQ are deselected via the `skip_cq` marker instead.
|
||||||
|
"""
|
||||||
|
outcome = yield
|
||||||
|
report = outcome.get_result()
|
||||||
|
if (
|
||||||
|
item.config.getoption("fail_on_skip")
|
||||||
|
and report.skipped
|
||||||
|
# xfail outcomes are also reported as skipped.
|
||||||
|
and not hasattr(report, "wasxfail")
|
||||||
|
):
|
||||||
|
reason = report.longrepr
|
||||||
|
if isinstance(reason, tuple):
|
||||||
|
# (path, lineno, reason)
|
||||||
|
reason = reason[2]
|
||||||
|
report.outcome = "failed"
|
||||||
|
report.longrepr = f"Not allowed with --fail-on-skip: {reason}"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def disable_repo_trace(tmp_path):
|
def disable_repo_trace(tmp_path):
|
||||||
"""Set an environment marker to relax certain strict checks for test code.""" # noqa: E501
|
"""Set an environment marker to relax certain strict checks for test code.""" # noqa: E501
|
||||||
|
repo_trace.SetTrace(False)
|
||||||
repo_trace._TRACE_FILE = str(tmp_path / "TRACE_FILE_from_test")
|
repo_trace._TRACE_FILE = str(tmp_path / "TRACE_FILE_from_test")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Repo End-to-End Tests
|
||||||
|
|
||||||
|
These tests run the real `repo` launcher & code (as subprocesses) against a
|
||||||
|
local git smart HTTP server. They are hermetic: everything is served from
|
||||||
|
`127.0.0.1` out of temporary directories, and the user's git config &
|
||||||
|
environment are ignored.
|
||||||
|
|
||||||
|
They run as part of `./run_tests` by default, after the unit tests.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Everything (unit + e2e).
|
||||||
|
./run_tests
|
||||||
|
|
||||||
|
# Only the e2e tests.
|
||||||
|
./run_tests -m e2e
|
||||||
|
|
||||||
|
# Everything but the e2e tests.
|
||||||
|
./run_tests -m 'not e2e'
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite is skipped automatically when `git http-backend` isn't
|
||||||
|
available, when git is older than 2.28 (need default branch support and
|
||||||
|
superproject support), or when not run from a git checkout.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
* `harness.py`: the git server, server-side repos & the repo client.
|
||||||
|
* `test_init_sync.py`: core `repo init` & `repo sync` options.
|
||||||
|
* `test_partial_clone.py`: partial clones, incl. the standard Android
|
||||||
|
configuration from go/repo-init & go/sync.
|
||||||
|
* `test_sync_modes.py`: `sync -n`/`-l` & the other sync flags.
|
||||||
|
* `test_groups.py`: manifest groups.
|
||||||
|
* `test_failures.py`: failure paths (missing projects, dirty trees, ...).
|
||||||
|
* `test_workflows.py`: multi-step workflows.
|
||||||
|
* `test_manifest_regressions.py`: manifests that broke users in the past.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Copyright (C) 2026 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Fixtures for the hermetic end-to-end tests.
|
||||||
|
|
||||||
|
Tests normally only need the `host` fixture (to create server repos) and the
|
||||||
|
`client` fixture (to run repo). Override the `git_defaults` fixture via
|
||||||
|
parametrize to run under Git 3 defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Iterator, List
|
||||||
|
|
||||||
|
import harness
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# Run before pytest's own `-m` deselection so `-m e2e` & `-m 'not e2e'` see the
|
||||||
|
# marker.
|
||||||
|
@pytest.hookimpl(tryfirst=True)
|
||||||
|
def pytest_collection_modifyitems(items: List[pytest.Item]) -> None:
|
||||||
|
"""Mark tests in this directory as `e2e` & run them after unit tests.
|
||||||
|
|
||||||
|
Note: pytest passes every collected item (not just this directory's), so
|
||||||
|
this reorders the whole session.
|
||||||
|
"""
|
||||||
|
this_dir = Path(__file__).resolve().parent
|
||||||
|
for item in items:
|
||||||
|
if this_dir in item.path.resolve().parents:
|
||||||
|
item.add_marker(pytest.mark.e2e)
|
||||||
|
# Stable sort: unit tests keep their order & come first, then E2E tests.
|
||||||
|
items.sort(key=lambda item: item.get_closest_marker("e2e") is not None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def _require_e2e_support() -> None:
|
||||||
|
"""Skip the suite when the host can't run it."""
|
||||||
|
# The code under test is published from the git checkout (e.g. this isn't
|
||||||
|
# the case when running from an unpacked sdist).
|
||||||
|
if not (harness.ROOT_DIR / ".git").exists():
|
||||||
|
pytest.skip("E2E tests must run from a git checkout")
|
||||||
|
if not harness.has_http_backend():
|
||||||
|
pytest.skip("git http-backend is not available")
|
||||||
|
if harness.git_version() < (2, 28, 0):
|
||||||
|
pytest.skip("E2E tests require git 2.28+")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def git_server(
|
||||||
|
tmp_path_factory: pytest.TempPathFactory,
|
||||||
|
) -> Iterator[harness.GitHttpServer]:
|
||||||
|
"""A git smart HTTP server shared by all tests in the session."""
|
||||||
|
with harness.serve(tmp_path_factory.mktemp("git-server")) as server:
|
||||||
|
yield server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def launcher(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||||
|
"""A standalone copy of the repo launcher."""
|
||||||
|
return harness.install_launcher(tmp_path_factory.mktemp("bin"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def repo_source(git_server: harness.GitHttpServer) -> harness.ServerRepo:
|
||||||
|
"""The code under test, published as a repo on `git_server`."""
|
||||||
|
return harness.publish_repo_source(
|
||||||
|
git_server.root / "git-repo.git", f"{git_server.url}/git-repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NAMESPACE_IDS = itertools.count()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def host(git_server: harness.GitHttpServer) -> harness.GitHost:
|
||||||
|
"""A per-test namespace for creating repos on the server."""
|
||||||
|
return harness.GitHost(git_server, f"t{next(NAMESPACE_IDS)}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def git_defaults() -> str:
|
||||||
|
"""Client git defaults to emulate; a key of harness.GIT_DEFAULTS."""
|
||||||
|
return "git2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_env(tmp_path: Path, git_defaults: str) -> Dict[str, str]:
|
||||||
|
"""Isolated environment for running repo & git as a client."""
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
settings = {
|
||||||
|
"user.name": "E2E User",
|
||||||
|
"user.email": "user@example.com",
|
||||||
|
"color.ui": "false",
|
||||||
|
# Don't let background maintenance race with assertions.
|
||||||
|
"maintenance.auto": "false",
|
||||||
|
"gc.auto": "0",
|
||||||
|
}
|
||||||
|
if git_defaults == "git3" and harness.git_version() < (2, 47, 0):
|
||||||
|
# init.defaultRefFormat & init.defaultObjectFormat landed in git 2.47.
|
||||||
|
pytest.skip("git is too old to emulate Git 3 defaults")
|
||||||
|
settings.update(harness.GIT_DEFAULTS[git_defaults])
|
||||||
|
harness.write_config(home / ".gitconfig", settings)
|
||||||
|
# Python resolves `~` via USERPROFILE on Windows. The ceiling keeps git
|
||||||
|
# from finding a repo above the test dir (e.g. with `--basetemp=./tmp`).
|
||||||
|
return dict(
|
||||||
|
harness.clean_environ(),
|
||||||
|
HOME=str(home),
|
||||||
|
USERPROFILE=str(home),
|
||||||
|
GIT_CEILING_DIRECTORIES=str(tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(
|
||||||
|
tmp_path: Path,
|
||||||
|
launcher: Path,
|
||||||
|
repo_source: harness.ServerRepo,
|
||||||
|
client_env: Dict[str, str],
|
||||||
|
) -> harness.RepoClient:
|
||||||
|
"""A repo client checkout directory that uses the code under test."""
|
||||||
|
return harness.RepoClient(
|
||||||
|
tmp_path / "client", launcher, client_env, repo_source.url
|
||||||
|
)
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,195 @@
|
|||||||
|
# Copyright (C) 2026 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""E2E tests for failure paths: sync must fail loudly & leave a usable tree.
|
||||||
|
|
||||||
|
Note: repo retries failed fetches once after a few seconds, so each failing
|
||||||
|
fetch costs about 5s.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from harness import GitHost
|
||||||
|
from harness import manifest_xml
|
||||||
|
from harness import RepoClient
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sync_args",
|
||||||
|
[
|
||||||
|
pytest.param(["-j1"], id="interleaved"),
|
||||||
|
pytest.param(["-j1", "--fail-fast"], id="fail-fast"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_missing_project(
|
||||||
|
host: GitHost, client: RepoClient, sync_args: List[str]
|
||||||
|
) -> None:
|
||||||
|
"""A project missing on the server fails the sync with a useful error.
|
||||||
|
|
||||||
|
By default the other projects are still synced & creating the missing repo
|
||||||
|
lets the next sync recover; with `--fail-fast` the sync stops at the first
|
||||||
|
failure without contacting later repos.
|
||||||
|
"""
|
||||||
|
host.create_repo("platform/foo", {"f": "foo"})
|
||||||
|
host.create_repo("platform/bar", {"f": "bar"})
|
||||||
|
# Paths sort the missing project first, so `-j1` reaches it first.
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/missing" path="missing" />',
|
||||||
|
'<project name="platform/foo" path="present-foo" />',
|
||||||
|
'<project name="platform/bar" path="present-bar" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
|
||||||
|
with host.watch_requests() as requests:
|
||||||
|
result = client.sync(*sync_args, check=False)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
# The failing fetch & git's reason are reported either way.
|
||||||
|
assert "on platform/missing failed" in result.output
|
||||||
|
assert "not found" in result.output
|
||||||
|
assert not (client.top / "missing").exists()
|
||||||
|
# Repo requested the missing project (it's a 404, not a client bug).
|
||||||
|
assert {r.status for r in host.requests_for("platform/missing")} == {404}
|
||||||
|
|
||||||
|
if "--fail-fast" in sync_args:
|
||||||
|
assert "SyncFailFastError" in result.stderr
|
||||||
|
assert client.checkouts() == set()
|
||||||
|
assert host.requested_repos(requests) == {
|
||||||
|
"platform/manifest",
|
||||||
|
"platform/missing",
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
assert client.checkouts() == {"present-bar", "present-foo"}
|
||||||
|
assert "error: Unable to fully sync the tree" in result.stderr
|
||||||
|
assert "Failing repos (network):\nmissing" in result.stderr
|
||||||
|
assert 'Try re-running with "-j1 --fail-fast"' in result.stderr
|
||||||
|
|
||||||
|
# Once the missing project exists on the server, the next sync recovers.
|
||||||
|
host.create_repo("platform/missing", {"f": "missing"})
|
||||||
|
client.sync(*sync_args)
|
||||||
|
assert client.checkouts() == {"missing", "present-bar", "present-foo"}
|
||||||
|
assert (client.top / "missing/f").read_text() == "missing"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"revision,error",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"nonexistent",
|
||||||
|
"couldn't find remote ref refs/heads/nonexistent",
|
||||||
|
),
|
||||||
|
# A pinned commit that was never pushed.
|
||||||
|
("1" * 40, "1" * 40),
|
||||||
|
],
|
||||||
|
ids=["branch", "commit"],
|
||||||
|
)
|
||||||
|
def test_missing_project_revision(
|
||||||
|
host: GitHost, client: RepoClient, revision: str, error: str
|
||||||
|
) -> None:
|
||||||
|
"""A manifest revision that doesn't exist on the server fails the sync."""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "foo"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
f'<project name="platform/foo" path="foo" revision="{revision}" />',
|
||||||
|
'<project name="platform/foo" path="ok" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
|
||||||
|
result = client.sync(check=False)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "error: Unable to fully sync the tree" in result.stderr
|
||||||
|
assert "Failing repos (network):\nfoo" in result.stderr
|
||||||
|
assert error in result.stderr
|
||||||
|
assert not (client.top / "foo").exists()
|
||||||
|
assert client.head("ok") == foo.rev_parse("main")
|
||||||
|
|
||||||
|
# Fixing the missing branch on the server recovers on the next sync.
|
||||||
|
if revision == "nonexistent":
|
||||||
|
fixed = foo.commit({"f": "fixed"}, branch="nonexistent")
|
||||||
|
client.sync()
|
||||||
|
assert client.head("foo") == fixed
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_manifest_branch(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""`init -b` with a branch the manifest repo doesn't have fails cleanly.
|
||||||
|
|
||||||
|
The half-initialized manifest checkout is removed so a corrected `init`
|
||||||
|
starts from scratch.
|
||||||
|
"""
|
||||||
|
host.create_repo("platform/foo", {"f": "foo"})
|
||||||
|
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
|
||||||
|
result = client.init(host.manifest_url, "-b", "nonexistent", check=False)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "couldn't find remote ref refs/heads/nonexistent" in result.output
|
||||||
|
assert "fatal: cannot obtain manifest" in result.stderr
|
||||||
|
assert not (client.repodir / "manifests.git").exists()
|
||||||
|
|
||||||
|
client.init(host.manifest_url, "-b", "main")
|
||||||
|
client.sync()
|
||||||
|
assert (client.top / "platform/foo/f").read_text() == "foo"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("on_branch", [False, True])
|
||||||
|
def test_dirty_worktree_blocks_sync(
|
||||||
|
host: GitHost, client: RepoClient, on_branch: bool
|
||||||
|
) -> None:
|
||||||
|
"""Uncommitted changes that conflict with upstream fail the checkout.
|
||||||
|
|
||||||
|
The local change is preserved and the fetched objects are kept, so the
|
||||||
|
user can resolve it and run `repo sync -l`. On a detached HEAD,
|
||||||
|
`--force-checkout` discards the local change instead.
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
old = foo.rev_parse("main")
|
||||||
|
if on_branch:
|
||||||
|
client.run("start", "topic", "--all")
|
||||||
|
(client.top / "foo/f").write_text("local")
|
||||||
|
latest = foo.commit({"f": "2"})
|
||||||
|
|
||||||
|
result = client.sync(check=False)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "error: Checking out local projects failed" in result.stderr
|
||||||
|
assert "Failing repos (checkout):\nfoo" in result.stderr
|
||||||
|
if on_branch:
|
||||||
|
assert "contains uncommitted changes" in result.stderr
|
||||||
|
else:
|
||||||
|
assert f"platform/foo checkout {latest}" in result.stderr
|
||||||
|
assert (client.top / "foo/f").read_text() == "local"
|
||||||
|
assert client.head("foo") == old
|
||||||
|
assert client.git("foo", "rev-parse", "refs/remotes/origin/main") == latest
|
||||||
|
|
||||||
|
if not on_branch:
|
||||||
|
client.sync("--force-checkout")
|
||||||
|
else:
|
||||||
|
# Discarding the edit & running `sync -l` recovers without touching
|
||||||
|
# the network.
|
||||||
|
client.git("foo", "checkout", "--", "f")
|
||||||
|
with host.watch_requests() as requests, host.offline():
|
||||||
|
client.sync("-l")
|
||||||
|
assert requests == []
|
||||||
|
assert client.head("foo") == latest
|
||||||
|
assert (client.top / "foo/f").read_text() == "2"
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Copyright (C) 2026 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""E2E tests for manifest groups beyond `repo init -g`."""
|
||||||
|
|
||||||
|
import platform
|
||||||
|
from typing import Dict, List, Optional, Set
|
||||||
|
|
||||||
|
from harness import GitHost
|
||||||
|
from harness import Gitlink
|
||||||
|
from harness import manifest_xml
|
||||||
|
from harness import RepoClient
|
||||||
|
from harness import ServerRepo
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# `a` & `b` are in the default group; `c` & `d` are not.
|
||||||
|
GROUPED_MANIFEST = manifest_xml(
|
||||||
|
'<project name="platform/a" path="a" />',
|
||||||
|
'<project name="platform/b" path="b" groups="g1" />',
|
||||||
|
'<project name="platform/c" path="c" groups="g2,notdefault" />',
|
||||||
|
'<project name="platform/d" path="d" groups="notdefault" />',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_grouped_projects(host: GitHost) -> Dict[str, ServerRepo]:
|
||||||
|
"""Create the projects of `GROUPED_MANIFEST` & the manifest itself."""
|
||||||
|
repos = {}
|
||||||
|
for name in "abcd":
|
||||||
|
repos[name] = host.create_repo(f"platform/{name}", {"f": name})
|
||||||
|
host.create_manifest(GROUPED_MANIFEST)
|
||||||
|
return repos
|
||||||
|
|
||||||
|
|
||||||
|
def listed(client: RepoClient, *args: str) -> Set[str]:
|
||||||
|
"""The project paths printed by `repo list <args>`."""
|
||||||
|
return set(client.list_projects(*args))
|
||||||
|
|
||||||
|
|
||||||
|
def test_reinit_groups(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""Re-running `init -g` adds & removes projects on the next sync."""
|
||||||
|
create_grouped_projects(host)
|
||||||
|
this_platform = f"platform-{platform.system().lower()}"
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
assert client.checkouts() == {"a", "b"}
|
||||||
|
assert client.config(".repo/manifests", "manifest.groups") is None
|
||||||
|
|
||||||
|
client.init(host.manifest_url, "-g", "default,g2")
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.checkouts() == {"a", "b", "c"}
|
||||||
|
assert (
|
||||||
|
client.config(".repo/manifests", "manifest.groups")
|
||||||
|
== f"default,g2,{this_platform}"
|
||||||
|
)
|
||||||
|
|
||||||
|
client.init(host.manifest_url, "-g", "g2")
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.checkouts() == {"c"}
|
||||||
|
assert client.list_projects() == {"c": "platform/c"}
|
||||||
|
assert (
|
||||||
|
client.config(".repo/manifests", "manifest.groups")
|
||||||
|
== f"g2,{this_platform}"
|
||||||
|
)
|
||||||
|
# The object dirs of removed projects are kept for a later re-add.
|
||||||
|
assert (client.repodir / "project-objects/platform/a.git").is_dir()
|
||||||
|
|
||||||
|
# Back to the default groups.
|
||||||
|
client.init(host.manifest_url, "-g", "default")
|
||||||
|
client.sync()
|
||||||
|
assert client.checkouts() == {"a", "b"}
|
||||||
|
assert (client.top / "a/f").read_text() == "a"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"init_args,expected",
|
||||||
|
[
|
||||||
|
([], {"base"}),
|
||||||
|
(["-g", "default,extra"], {"base", "extra", "extra2"}),
|
||||||
|
(["-g", "default,extra,-g2"], {"base", "extra"}),
|
||||||
|
(["-g", "g2"], {"extra2"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_include_groups(
|
||||||
|
host: GitHost, client: RepoClient, init_args: List[str], expected: Set[str]
|
||||||
|
) -> None:
|
||||||
|
"""`<include groups=...>` adds its groups to every included project."""
|
||||||
|
for name in ("base", "extra", "extra2"):
|
||||||
|
host.create_repo(f"platform/{name}", {"f": name})
|
||||||
|
host.create_manifest(
|
||||||
|
{
|
||||||
|
"default.xml": manifest_xml(
|
||||||
|
'<project name="platform/base" path="base" />',
|
||||||
|
'<include name="extra.xml" groups="notdefault,extra" />',
|
||||||
|
),
|
||||||
|
"extra.xml": (
|
||||||
|
"<manifest>\n"
|
||||||
|
' <project name="platform/extra" path="extra" />\n'
|
||||||
|
' <project name="platform/extra2" path="extra2" '
|
||||||
|
'groups="g2" />\n'
|
||||||
|
"</manifest>\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
client.init(host.manifest_url, *init_args)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.checkouts() == expected
|
||||||
|
assert set(client.list_projects()) == expected
|
||||||
|
# The include's groups are merged with the project's own.
|
||||||
|
assert listed(client, "-a", "-g", "extra") == {"extra", "extra2"}
|
||||||
|
assert listed(client, "-a", "-g", "g2") == {"extra2"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"init_args,platforms",
|
||||||
|
[
|
||||||
|
# None means the platform the test runs on.
|
||||||
|
([], None),
|
||||||
|
(["--platform=all"], {"linux", "darwin", "windows"}),
|
||||||
|
(["--platform=none"], set()),
|
||||||
|
(["-p", "darwin"], {"darwin"}),
|
||||||
|
(["-g", "default,platform-linux", "--platform=none"], {"linux"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_platform_groups(
|
||||||
|
host: GitHost,
|
||||||
|
client: RepoClient,
|
||||||
|
init_args: List[str],
|
||||||
|
platforms: Optional[Set[str]],
|
||||||
|
) -> None:
|
||||||
|
"""`platform-*` groups follow the host OS unless `--platform` is given."""
|
||||||
|
names = ("linux", "darwin", "windows")
|
||||||
|
host.create_repo("platform/base", {"f": "base"})
|
||||||
|
for name in names:
|
||||||
|
host.create_repo(f"platform/{name}", {"f": name})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/base" path="base" />',
|
||||||
|
*(
|
||||||
|
f'<project name="platform/{name}" path="{name}" '
|
||||||
|
f'groups="notdefault,platform-{name}" />'
|
||||||
|
for name in names
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if platforms is None:
|
||||||
|
platforms = {platform.system().lower()}
|
||||||
|
|
||||||
|
client.init(host.manifest_url, *init_args)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.checkouts() == {"base"} | platforms
|
||||||
|
assert set(client.list_projects()) == {"base"} | platforms
|
||||||
|
|
||||||
|
|
||||||
|
def test_groups_with_superproject(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""Group selection applies on top of superproject pinning."""
|
||||||
|
pins = {}
|
||||||
|
for name in "ab":
|
||||||
|
repo = host.create_repo(f"platform/{name}", {"f": "v1"})
|
||||||
|
pins[name] = repo.rev_parse("main")
|
||||||
|
repo.commit({"f": "v2"})
|
||||||
|
host.create_repo(
|
||||||
|
"superproject", {name: Gitlink(rev) for name, rev in pins.items()}
|
||||||
|
)
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<superproject name="superproject" />',
|
||||||
|
'<project name="platform/a" path="a" />',
|
||||||
|
'<project name="platform/b" path="b" groups="notdefault,g1" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
client.init(host.manifest_url, "--use-superproject")
|
||||||
|
assert client.sync().stderr == ""
|
||||||
|
|
||||||
|
assert client.checkouts() == {"a"}
|
||||||
|
assert client.head("a") == pins["a"]
|
||||||
|
assert host.requests_for("platform/b") == []
|
||||||
|
|
||||||
|
client.init(host.manifest_url, "-g", "all")
|
||||||
|
assert client.sync().stderr == ""
|
||||||
|
|
||||||
|
assert client.checkouts() == {"a", "b"}
|
||||||
|
assert client.head("b") == pins["b"]
|
||||||
|
assert (client.top / "b/f").read_text() == "v1"
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Copyright (C) 2026 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Tests for the E2E harness helpers themselves."""
|
||||||
|
|
||||||
|
import http.client
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping, Tuple
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import harness
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _http_request(
|
||||||
|
server: harness.GitHttpServer, method: str, path: str
|
||||||
|
) -> Tuple[int, bytes]:
|
||||||
|
"""Send a request to `server` & return the response status & body."""
|
||||||
|
url = urllib.parse.urlsplit(server.url)
|
||||||
|
conn = http.client.HTTPConnection(url.hostname, url.port, timeout=30)
|
||||||
|
try:
|
||||||
|
conn.request(method, path)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
return resp.status, resp.read()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"key,value",
|
||||||
|
[
|
||||||
|
("core.bare", "true"),
|
||||||
|
('url.C:\\src\\#1;"x".insteadOf', 'C:\\dst\\ #2;"y"'),
|
||||||
|
("alias.multi", "a\nb"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_write_config(tmp_path: Path, key: str, value: str) -> None:
|
||||||
|
"""Subsections & values round-trip through git's config parser."""
|
||||||
|
path = tmp_path / "config"
|
||||||
|
harness.write_config(path, {key: value})
|
||||||
|
got = harness.run_git("config", "--file", str(path), "--get", key)
|
||||||
|
assert got == value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"files",
|
||||||
|
[
|
||||||
|
{"foo/bar": "x", "foo": None},
|
||||||
|
{"foo": None, "foo/bar": "x"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_commit_replace_file_with_dir(
|
||||||
|
tmp_path: Path, files: Mapping[str, harness.FileContent]
|
||||||
|
) -> None:
|
||||||
|
"""Deletions apply before additions regardless of dict order."""
|
||||||
|
repo = harness.ServerRepo.create(tmp_path / "repo.git", "unused")
|
||||||
|
repo.commit({"foo": "file"})
|
||||||
|
repo.commit(files)
|
||||||
|
assert repo.git("ls-tree", "-r", "--name-only", "main") == "foo/bar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_worktree_host_excludes(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""The developer's global excludes apply despite the isolated HOME."""
|
||||||
|
host_home = tmp_path / "host-home"
|
||||||
|
host_home.mkdir()
|
||||||
|
excludes = host_home / "excludes"
|
||||||
|
excludes.write_text("venv/\n")
|
||||||
|
# Set excludesFile explicitly so a system-wide one can't take precedence.
|
||||||
|
harness.write_config(
|
||||||
|
host_home / ".gitconfig", {"core.excludesFile": excludes.as_posix()}
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(harness, "HOST_ENVIRON", {"HOME": str(host_home)})
|
||||||
|
root = tmp_path / "checkout"
|
||||||
|
harness.run_git("init", "--quiet", str(root))
|
||||||
|
(root / ".gitignore").write_text("*.pyc\n")
|
||||||
|
(root / "main.py").write_text("")
|
||||||
|
(root / "main.pyc").write_text("")
|
||||||
|
(root / "venv").mkdir()
|
||||||
|
(root / "venv" / "lib.py").write_text("")
|
||||||
|
assert sorted(harness._snapshot_worktree(root)) == [".gitignore", "main.py"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"chunked,expect_continue",
|
||||||
|
[(False, False), (True, False), (True, True)],
|
||||||
|
)
|
||||||
|
def test_http_post_body(
|
||||||
|
git_server: harness.GitHttpServer, chunked: bool, expect_continue: bool
|
||||||
|
) -> None:
|
||||||
|
"""POST bodies reach git http-backend however curl encodes them."""
|
||||||
|
name = f"post-{int(chunked)}{int(expect_continue)}.git"
|
||||||
|
repo = harness.ServerRepo.create(git_server.root / name, "unused")
|
||||||
|
commit = repo.commit({"file": "x"})
|
||||||
|
# A protocol v2 ls-refs request: a command pkt-line & a flush-pkt.
|
||||||
|
body = b"0014command=ls-refs\n0000"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/x-git-upload-pack-request",
|
||||||
|
"Git-Protocol": "version=2",
|
||||||
|
}
|
||||||
|
if expect_continue:
|
||||||
|
headers["Expect"] = "100-continue"
|
||||||
|
url = urllib.parse.urlsplit(git_server.url)
|
||||||
|
conn = http.client.HTTPConnection(url.hostname, url.port, timeout=30)
|
||||||
|
try:
|
||||||
|
# http.client skips any interim `100 Continue` response.
|
||||||
|
conn.request(
|
||||||
|
"POST",
|
||||||
|
f"/{name}/git-upload-pack",
|
||||||
|
body=iter([body[:10], body[10:]]) if chunked else body,
|
||||||
|
headers=headers,
|
||||||
|
encode_chunked=chunked,
|
||||||
|
)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
assert resp.status == 200
|
||||||
|
assert f"{commit} refs/heads/main".encode() in resp.read()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_files(git_server: harness.GitHttpServer) -> None:
|
||||||
|
"""Published files are served as-is, and nothing outside of them."""
|
||||||
|
host = harness.GitHost(git_server, "static-files")
|
||||||
|
host.create_repo("repo", {"f": "x"})
|
||||||
|
url = host.publish_file("dir/default.xml", "<manifest />\n")
|
||||||
|
path = urllib.parse.urlsplit(url).path
|
||||||
|
assert _http_request(git_server, "GET", path) == (200, b"<manifest />\n")
|
||||||
|
|
||||||
|
# Publishing again replaces the file.
|
||||||
|
assert host.publish_file("dir/default.xml", b"v2") == url
|
||||||
|
assert _http_request(git_server, "GET", path) == (200, b"v2")
|
||||||
|
|
||||||
|
static = f"/{host.prefix}/{harness.STATIC_DIR}"
|
||||||
|
for bad_path in (
|
||||||
|
f"{static}/missing.xml",
|
||||||
|
f"{static}/dir",
|
||||||
|
f"{static}/../repo.git/config",
|
||||||
|
f"{static}/%2e%2e/repo.git/config",
|
||||||
|
f"{static}//etc/passwd",
|
||||||
|
):
|
||||||
|
assert _http_request(git_server, "GET", bad_path)[0] == 404, bad_path
|
||||||
|
assert _http_request(git_server, "POST", path)[0] == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline(git_server: harness.GitHttpServer) -> None:
|
||||||
|
"""Requests fail while a namespace is offline, and work again after."""
|
||||||
|
host = harness.GitHost(git_server, "offline")
|
||||||
|
repo = host.create_repo("platform/repo", {"f": "x"})
|
||||||
|
other = harness.GitHost(git_server, "online")
|
||||||
|
other_repo = other.create_repo("repo", {"f": "x"})
|
||||||
|
|
||||||
|
with host.offline():
|
||||||
|
with pytest.raises(AssertionError, match="503"):
|
||||||
|
harness.run_git("ls-remote", repo.url)
|
||||||
|
# Other namespaces are unaffected.
|
||||||
|
assert "refs/heads/main" in harness.run_git("ls-remote", other_repo.url)
|
||||||
|
assert {r.status for r in host.requests()} == {503}
|
||||||
|
|
||||||
|
# The outage ends even if the context exits with an exception.
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
with host.offline():
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
assert "refs/heads/main" in harness.run_git("ls-remote", repo.url)
|
||||||
|
|
||||||
|
|
||||||
|
def test_requested_repos(git_server: harness.GitHttpServer) -> None:
|
||||||
|
"""`requested_repos()` & `watch_requests()` track repo requests."""
|
||||||
|
host = harness.GitHost(git_server, "requested")
|
||||||
|
nested = host.create_repo("platform/nested", {"f": "x"})
|
||||||
|
top = host.create_repo("top", {"f": "x"})
|
||||||
|
host.publish_file("file.xml", "x")
|
||||||
|
|
||||||
|
harness.run_git("ls-remote", nested.url)
|
||||||
|
with host.watch_requests() as watched:
|
||||||
|
harness.run_git("ls-remote", top.url)
|
||||||
|
_http_request(git_server, "GET", f"/{host.prefix}/top/clone.bundle")
|
||||||
|
_http_request(git_server, "GET", f"/{host.prefix}/_static/file.xml")
|
||||||
|
|
||||||
|
assert host.requested_repos() == {"platform/nested", "top"}
|
||||||
|
assert host.requested_repos(watched) == {"top"}
|
||||||
|
assert host.requests_for("platform/nested", watched) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_config(tmp_path: Path) -> None:
|
||||||
|
"""`RepoClient.config()` tells unset keys apart from errors."""
|
||||||
|
# Don't let git find a repo above tmp_path (e.g. with --basetemp).
|
||||||
|
env = dict(harness.clean_environ(), GIT_CEILING_DIRECTORIES=str(tmp_path))
|
||||||
|
client = harness.RepoClient(tmp_path / "client", harness.LAUNCHER, env, "")
|
||||||
|
repo = client.top / "repo"
|
||||||
|
harness.run_git("init", "-q", str(repo))
|
||||||
|
harness.run_git("config", "-f", str(repo / ".git/config"), "a.key", "val")
|
||||||
|
(client.top / "not-a-repo").mkdir()
|
||||||
|
|
||||||
|
assert client.config("repo", "a.key") == "val"
|
||||||
|
assert client.config("repo", "a.unset") is None
|
||||||
|
with pytest.raises(AssertionError, match="failed in not-a-repo"):
|
||||||
|
client.config("not-a-repo", "a.key")
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
# Copyright (C) 2026 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""E2E tests for the core `repo init` & `repo sync` options."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Set
|
||||||
|
|
||||||
|
from harness import GIT_DEFAULTS_PARAMS
|
||||||
|
from harness import GitHost
|
||||||
|
from harness import manifest_xml
|
||||||
|
from harness import move_aside
|
||||||
|
from harness import RepoClient
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||||
|
def test_init_sync_basic(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""The basic `repo init && repo sync` flow."""
|
||||||
|
foo = host.create_repo("platform/foo", {"foo.txt": "foo"})
|
||||||
|
host.create_repo("platform/bar", {"bar.txt": "bar"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
'<project name="platform/bar" path="sub/bar" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert (client.top / "foo" / "foo.txt").read_text() == "foo"
|
||||||
|
assert (client.top / "sub" / "bar" / "bar.txt").read_text() == "bar"
|
||||||
|
assert client.head("foo") == foo.rev_parse("main")
|
||||||
|
assert client.list_projects() == {
|
||||||
|
"foo": "platform/foo",
|
||||||
|
"sub/bar": "platform/bar",
|
||||||
|
}
|
||||||
|
# The code under test is what got installed & is running.
|
||||||
|
assert "v99.0-e2e" in client.run("version").stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"init_args,sync_args,manifest_refs,project_refs",
|
||||||
|
[
|
||||||
|
# `init -c` is the default for the manifest project.
|
||||||
|
([], [], {"main"}, {"main", "other"}),
|
||||||
|
(["--no-current-branch"], [], {"main", "other"}, {"main", "other"}),
|
||||||
|
([], ["-c"], {"main"}, {"main"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_current_branch(
|
||||||
|
host: GitHost,
|
||||||
|
client: RepoClient,
|
||||||
|
init_args: List[str],
|
||||||
|
sync_args: List[str],
|
||||||
|
manifest_refs: Set[str],
|
||||||
|
project_refs: Set[str],
|
||||||
|
) -> None:
|
||||||
|
"""`init` limits the manifest fetch; `sync -c` limits projects."""
|
||||||
|
foo = host.create_repo("platform/foo", {"foo.txt": "main"})
|
||||||
|
foo.commit({"foo.txt": "other"}, branch="other")
|
||||||
|
manifest = host.create_manifest(
|
||||||
|
manifest_xml('<project name="platform/foo" />')
|
||||||
|
)
|
||||||
|
manifest.commit({"README": "other"}, branch="other")
|
||||||
|
|
||||||
|
client.init(host.manifest_url, *init_args)
|
||||||
|
assert client.remote_branches(".repo/manifests") == manifest_refs
|
||||||
|
|
||||||
|
client.sync(*sync_args)
|
||||||
|
assert client.remote_branches("platform/foo") == project_refs
|
||||||
|
assert (client.top / "platform/foo/foo.txt").read_text() == "main"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sync_args,expect_bundle", [([], True), (["--no-clone-bundle"], False)]
|
||||||
|
)
|
||||||
|
def test_clone_bundle(
|
||||||
|
host: GitHost,
|
||||||
|
client: RepoClient,
|
||||||
|
sync_args: List[str],
|
||||||
|
expect_bundle: bool,
|
||||||
|
) -> None:
|
||||||
|
"""repo bootstraps new projects from clone.bundle when available."""
|
||||||
|
foo = host.create_repo("platform/foo", {"foo.txt": "foo"})
|
||||||
|
# A commit that only the bundle has, to prove the bundle was applied.
|
||||||
|
bundle_only = foo.commit({"x": "bundle"}, branch="bundle-only")
|
||||||
|
foo.write_bundle()
|
||||||
|
foo.git("update-ref", "-d", "refs/heads/bundle-only")
|
||||||
|
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync(*sync_args)
|
||||||
|
|
||||||
|
bundle_gets = [
|
||||||
|
r
|
||||||
|
for r in host.requests()
|
||||||
|
if r.path.endswith("/platform/foo/clone.bundle")
|
||||||
|
]
|
||||||
|
assert [r.status for r in bundle_gets] == ([200] if expect_bundle else [])
|
||||||
|
assert client.has_object("platform/foo", bundle_only) == expect_bundle
|
||||||
|
assert client.head("platform/foo") == foo.rev_parse("main")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"init_args,project_attrs",
|
||||||
|
[
|
||||||
|
(["--depth=1"], ""),
|
||||||
|
([], 'clone-depth="1"'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_shallow(
|
||||||
|
host: GitHost,
|
||||||
|
client: RepoClient,
|
||||||
|
init_args: List[str],
|
||||||
|
project_attrs: str,
|
||||||
|
) -> None:
|
||||||
|
"""Shallow clones via `--depth` and manifest `clone-depth`."""
|
||||||
|
foo = host.create_repo("platform/foo", {"foo.txt": "1"})
|
||||||
|
foo.commit({"foo.txt": "2"})
|
||||||
|
foo.commit({"foo.txt": "3"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(f'<project name="platform/foo" {project_attrs} />')
|
||||||
|
)
|
||||||
|
|
||||||
|
client.init(host.manifest_url, *init_args)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
path = "platform/foo"
|
||||||
|
assert client.is_shallow(path)
|
||||||
|
assert client.git(path, "rev-list", "--count", "HEAD") == "1"
|
||||||
|
assert (client.top / path / "foo.txt").read_text() == "3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"init_args,expected",
|
||||||
|
[
|
||||||
|
([], {"a", "b"}),
|
||||||
|
(["-g", "all"], {"a", "b", "c", "d"}),
|
||||||
|
(["-g", "g2"], {"c"}),
|
||||||
|
(["-g", "default,-g1"], {"a"}),
|
||||||
|
(["-g", "default,g2"], {"a", "b", "c"}),
|
||||||
|
(["-g", "name:platform/d"], {"d"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_groups(
|
||||||
|
host: GitHost, client: RepoClient, init_args: List[str], expected: Set[str]
|
||||||
|
) -> None:
|
||||||
|
"""Manifest groups select which projects get synced."""
|
||||||
|
for name in "abcd":
|
||||||
|
host.create_repo(f"platform/{name}", {"f": name})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/a" path="a" />',
|
||||||
|
'<project name="platform/b" path="b" groups="g1" />',
|
||||||
|
'<project name="platform/c" path="c" groups="g2,notdefault" />',
|
||||||
|
'<project name="platform/d" path="d" groups="notdefault" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
client.init(host.manifest_url, *init_args)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert set(client.list_projects()) == expected
|
||||||
|
assert client.checkouts() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_manifests(host: GitHost, client: RepoClient) -> None:
|
||||||
|
""".repo/local_manifests/ can add, remove & extend projects."""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "main"})
|
||||||
|
stable = foo.commit({"f": "stable"}, branch="stable")
|
||||||
|
host.create_repo("platform/bar", {"f": "bar"})
|
||||||
|
host.create_repo("vendor/extra", {"f": "extra"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
'<project name="platform/bar" path="bar" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
local = client.repodir / "local_manifests"
|
||||||
|
local.mkdir()
|
||||||
|
(local / "local.xml").write_text(
|
||||||
|
"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<manifest>
|
||||||
|
<remove-project name="platform/bar" />
|
||||||
|
<extend-project name="platform/foo" revision="stable" />
|
||||||
|
<project name="vendor/extra" path="extra" />
|
||||||
|
</manifest>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.list_projects() == {
|
||||||
|
"extra": "vendor/extra",
|
||||||
|
"foo": "platform/foo",
|
||||||
|
}
|
||||||
|
assert client.head("foo") == stable
|
||||||
|
assert not (client.top / "bar").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_name(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""`-m` selects a manifest file other than default.xml."""
|
||||||
|
host.create_repo("platform/foo", {"f": "foo"})
|
||||||
|
host.create_repo("platform/bar", {"f": "bar"})
|
||||||
|
host.create_manifest(
|
||||||
|
{
|
||||||
|
"default.xml": manifest_xml('<project name="platform/foo" />'),
|
||||||
|
"other.xml": manifest_xml('<project name="platform/bar" />'),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
client.init(host.manifest_url, "-m", "other.xml")
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.list_projects() == {"platform/bar": "platform/bar"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("dissociate", [False, True])
|
||||||
|
def test_reference_mirror(
|
||||||
|
host: GitHost, client: RepoClient, dissociate: bool
|
||||||
|
) -> None:
|
||||||
|
"""`init --reference` borrows objects from a local `--mirror` checkout.
|
||||||
|
|
||||||
|
With `--dissociate` the objects are copied so the mirror can go away.
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "foo"})
|
||||||
|
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
mirror = RepoClient(
|
||||||
|
client.top.parent / "mirror",
|
||||||
|
client.launcher,
|
||||||
|
client.env,
|
||||||
|
client.repo_url,
|
||||||
|
)
|
||||||
|
mirror.init(host.manifest_url, "--mirror")
|
||||||
|
mirror.sync()
|
||||||
|
if dissociate:
|
||||||
|
# Dissociating the manifest repo itself currently fails (its
|
||||||
|
# preciousObjects setting blocks `git repack -a -d`), so only reference
|
||||||
|
# the mirror's projects.
|
||||||
|
move_aside(mirror.repodir / "manifests.git")
|
||||||
|
|
||||||
|
init_args = ["--reference", str(mirror.top)]
|
||||||
|
if dissociate:
|
||||||
|
init_args.append("--dissociate")
|
||||||
|
client.init(host.manifest_url, *init_args)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
path = "platform/foo"
|
||||||
|
assert client.head(path) == foo.rev_parse("main")
|
||||||
|
assert (client.top / path / "f").read_text() == "foo"
|
||||||
|
assert client.has_object(path, foo.rev_parse("main"))
|
||||||
|
objdir = client.repodir / "project-objects/platform/foo.git"
|
||||||
|
alternates = objdir / "objects/info/alternates"
|
||||||
|
assert client.config(".repo/manifests", "repo.reference") == str(mirror.top)
|
||||||
|
if dissociate:
|
||||||
|
assert not alternates.exists()
|
||||||
|
assert client.config(".repo/manifests", "repo.dissociate") == "true"
|
||||||
|
else:
|
||||||
|
expected = mirror.top / "platform/foo.git/objects"
|
||||||
|
assert Path(alternates.read_text().strip()) == expected
|
||||||
|
# Nothing had to be downloaded or copied into the local object dir.
|
||||||
|
counts = client.git(".", f"--git-dir={objdir}", "count-objects", "-v")
|
||||||
|
assert "count: 0\n" in counts
|
||||||
|
assert "in-pack: 0\n" in counts
|
||||||
|
# Updates still come from the server (the mirror isn't synced).
|
||||||
|
latest = foo.commit({"f": "2"})
|
||||||
|
client.sync()
|
||||||
|
assert client.head(path) == latest
|
||||||
|
|
||||||
|
# If the mirror goes away, `--dissociate` checkouts still have all
|
||||||
|
# historical objects, whereas shared checkouts lost the borrowed ones.
|
||||||
|
move_aside(mirror.top)
|
||||||
|
if dissociate:
|
||||||
|
assert client.git(path, "rev-list", "--count", "HEAD") == "2"
|
||||||
|
client.git(path, "rev-list", "--objects", "HEAD")
|
||||||
|
else:
|
||||||
|
with pytest.raises(AssertionError, match="rev-list"):
|
||||||
|
client.git(path, "rev-list", "--objects", "HEAD")
|
||||||
|
|
||||||
|
|
||||||
|
def test_standalone_manifest(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""`init --standalone-manifest` fetches a static manifest file over HTTP.
|
||||||
|
|
||||||
|
There's no manifest checkout to update, so syncs only fetch the projects.
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
host.create_repo("platform/bar", {"f": "bar"})
|
||||||
|
xml = manifest_xml('<project name="platform/foo" />', fetch=host.url)
|
||||||
|
manifest_url = host.publish_file("default.xml", xml)
|
||||||
|
|
||||||
|
client.init(manifest_url, "--standalone-manifest")
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
path = "platform/foo"
|
||||||
|
assert client.head(path) == foo.rev_parse("main")
|
||||||
|
assert (client.repodir / "manifests/default.xml").read_text() == xml
|
||||||
|
assert (
|
||||||
|
client.config(".repo/manifests.git", "manifest.standalone")
|
||||||
|
== manifest_url
|
||||||
|
)
|
||||||
|
# Updating the static file on the server does not affect `repo sync`,
|
||||||
|
# which only fetches the project.
|
||||||
|
host.publish_file(
|
||||||
|
"default.xml",
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" />',
|
||||||
|
'<project name="platform/bar" />',
|
||||||
|
fetch=host.url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
latest = foo.commit({"f": "2"})
|
||||||
|
with host.watch_requests() as requests:
|
||||||
|
client.sync()
|
||||||
|
assert client.head(path) == latest
|
||||||
|
assert client.list_projects() == {"platform/foo": "platform/foo"}
|
||||||
|
assert requests == host.requests_for("platform/foo", requests)
|
||||||
|
# Re-initializing without a manifest URL isn't allowed.
|
||||||
|
result = client.run("init", "--no-repo-verify", check=False)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "cannot be re-initialized without --manifest-url/-u" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_depth(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""`init --manifest-depth` shallow-clones the manifest repo only."""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
foo.commit({"f": "2"})
|
||||||
|
host.create_manifest(manifest_xml())
|
||||||
|
host.update_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
|
||||||
|
client.init(host.manifest_url, "--manifest-depth=1")
|
||||||
|
|
||||||
|
manifests = ".repo/manifests"
|
||||||
|
assert client.is_shallow(manifests)
|
||||||
|
assert client.git(manifests, "rev-list", "--count", "HEAD") == "1"
|
||||||
|
|
||||||
|
# `--nmu` skips updating the manifest project, keeping it shallow while
|
||||||
|
# syncing projects at full depth.
|
||||||
|
client.sync("--nmu")
|
||||||
|
assert client.is_shallow(manifests)
|
||||||
|
assert not client.is_shallow("platform/foo")
|
||||||
|
assert client.git("platform/foo", "rev-list", "--count", "HEAD") == "2"
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
# Copyright (C) 2026 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""E2E regression tests for manifests that broke users in the past.
|
||||||
|
|
||||||
|
Each test names the commit and/or bug that fixed the original breakage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from harness import GitHost
|
||||||
|
from harness import Gitlink
|
||||||
|
from harness import manifest_xml
|
||||||
|
from harness import RepoClient
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_manifest(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""A manifest without projects syncs (616e314, b/377546300)."""
|
||||||
|
host.create_manifest(manifest_xml())
|
||||||
|
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.list_projects() == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_extend_project_with_superproject(
|
||||||
|
host: GitHost, client: RepoClient
|
||||||
|
) -> None:
|
||||||
|
"""Local `extend-project` works with superproject overrides.
|
||||||
|
|
||||||
|
Regression test for 3c0e67b (b/470374343).
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "v1"})
|
||||||
|
pinned = foo.rev_parse("main")
|
||||||
|
host.create_repo("superproject", {"foo": Gitlink(pinned)})
|
||||||
|
foo.commit({"f": "v2"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<superproject name="superproject" />',
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url, "--use-superproject")
|
||||||
|
local = client.repodir / "local_manifests"
|
||||||
|
local.mkdir()
|
||||||
|
(local / "local.xml").write_text(
|
||||||
|
"<manifest>\n"
|
||||||
|
' <extend-project name="platform/foo" groups="extra" />\n'
|
||||||
|
"</manifest>\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
client.sync("--use-superproject")
|
||||||
|
|
||||||
|
assert client.head("foo") == pinned
|
||||||
|
|
||||||
|
|
||||||
|
# 3af9e2f fixed both the regular & the --optimized-fetch skip-fetch checks.
|
||||||
|
@pytest.mark.parametrize("sync_args", [[], ["--optimized-fetch"]])
|
||||||
|
def test_same_project_shallow_pinned_paths(
|
||||||
|
host: GitHost, client: RepoClient, sync_args: List[str]
|
||||||
|
) -> None:
|
||||||
|
"""One project at two paths pinned to SHAs with sync-c, one shallow.
|
||||||
|
|
||||||
|
The non-shallow path shares the shallow path's (depth-limited) object
|
||||||
|
store, so its fetch must not be skipped as already up to date, or its
|
||||||
|
history is left truncated.
|
||||||
|
|
||||||
|
Regression test for 3af9e2f (b/505072873).
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
host.create_manifest(manifest_xml())
|
||||||
|
|
||||||
|
def pin(rev: str) -> None:
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
f'<project name="platform/foo" path="a" revision="{rev}" '
|
||||||
|
'clone-depth="1" />',
|
||||||
|
f'<project name="platform/foo" path="b" revision="{rev}" />',
|
||||||
|
default='remote="origin" revision="main" sync-c="true"',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pin(foo.commit({"f": "2"}))
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync(*sync_args)
|
||||||
|
|
||||||
|
# Skip a commit so the shallow fetch alone would leave a gap in the shared
|
||||||
|
# history.
|
||||||
|
foo.commit({"f": "3"})
|
||||||
|
latest = foo.commit({"f": "4"})
|
||||||
|
pin(latest)
|
||||||
|
client.sync(*sync_args)
|
||||||
|
|
||||||
|
assert client.is_shallow("a")
|
||||||
|
for path in ("a", "b"):
|
||||||
|
assert client.head(path) == latest
|
||||||
|
# Fails to traverse the shared, truncated history if `path`'s fetch
|
||||||
|
# was skipped.
|
||||||
|
client.git(path, "rev-list", "HEAD")
|
||||||
|
|
||||||
|
|
||||||
|
def test_shallow_pinned_with_upstream(
|
||||||
|
host: GitHost, client: RepoClient
|
||||||
|
) -> None:
|
||||||
|
"""clone-depth + upstream + SHA revisions stay shallow.
|
||||||
|
|
||||||
|
The bug only triggered when the upstream branch was ahead of the pinned
|
||||||
|
SHA, so each pin leaves `main` one commit ahead.
|
||||||
|
|
||||||
|
Regression test for d9cc0a1 (b/427093249).
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
host.create_manifest(manifest_xml())
|
||||||
|
|
||||||
|
def pin(rev: str) -> None:
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
f'<project name="platform/foo" revision="{rev}" '
|
||||||
|
'upstream="main" clone-depth="1" />'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
path = "platform/foo"
|
||||||
|
pinned = foo.commit({"f": "2"})
|
||||||
|
foo.commit({"f": "2 ahead"})
|
||||||
|
pin(pinned)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
assert client.head(path) == pinned
|
||||||
|
assert client.git(path, "rev-list", "--count", "HEAD") == "1"
|
||||||
|
|
||||||
|
pinned = foo.commit({"f": "3"})
|
||||||
|
foo.commit({"f": "3 ahead"})
|
||||||
|
pin(pinned)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.head(path) == pinned
|
||||||
|
assert client.git(path, "rev-list", "--count", "HEAD") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_revision_refreshes_upstream(
|
||||||
|
host: GitHost, client: RepoClient
|
||||||
|
) -> None:
|
||||||
|
"""Pinned SHAs already present locally still refresh the upstream ref.
|
||||||
|
|
||||||
|
Regression test for d27d682: when the pinned commit was already in the
|
||||||
|
local object store (e.g. prefetched), `sync -c` skipped the fetch as an
|
||||||
|
immutable revision, so the stale tracking ref of the upstream branch was
|
||||||
|
never updated.
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
host.create_manifest(manifest_xml())
|
||||||
|
|
||||||
|
def pin(rev: str) -> None:
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
f'<project name="platform/foo" revision="{rev}" '
|
||||||
|
'upstream="main" />',
|
||||||
|
# The skip only happens when fetching the current branch only.
|
||||||
|
default='remote="origin" revision="main" sync-c="true"',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
initial = foo.rev_parse("main")
|
||||||
|
pin(initial)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
path = "platform/foo"
|
||||||
|
second = foo.commit({"f": "2"})
|
||||||
|
latest = foo.commit({"f": "3"})
|
||||||
|
# Emulate a background prefetch that doesn't touch the tracking refs.
|
||||||
|
client.git(
|
||||||
|
path, "fetch", "-q", "--refmap=", "origin", "+main:refs/prefetch/main"
|
||||||
|
)
|
||||||
|
assert client.has_object(path, second)
|
||||||
|
assert client.git(path, "rev-parse", "refs/remotes/origin/main") == initial
|
||||||
|
pin(second)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.head(path) == second
|
||||||
|
assert client.git(path, "rev-parse", "refs/remotes/origin/main") == latest
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_projects(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""Nested project paths are checked out and removed in the right order.
|
||||||
|
|
||||||
|
Regression test for 4679022 (b/325119758).
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "foo", "bar/f": "old"})
|
||||||
|
host.create_repo("platform/bar", {"f": "foo/bar"})
|
||||||
|
host.create_repo("platform/foo-bar", {"f": "foo-bar"})
|
||||||
|
# aaa shares foo/bar's project, so foo/bar is grouped with it & checked out
|
||||||
|
# early unless the checkout order puts it after its parent foo.
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/bar" path="aaa" />',
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
'<project name="platform/foo-bar" path="foo-bar" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
# Move foo's bar/f into the new nested project foo/bar.
|
||||||
|
foo.commit({"bar/f": None})
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/bar" path="aaa" />',
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
'<project name="platform/bar" path="foo/bar" />',
|
||||||
|
'<project name="platform/foo-bar" path="foo-bar" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.sync("-j1")
|
||||||
|
|
||||||
|
for path in ("foo", "foo/bar", "foo-bar"):
|
||||||
|
assert (client.top / path / "f").read_text() == path
|
||||||
|
assert client.git("foo/bar", "status", "--porcelain") == ""
|
||||||
|
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
'<project name="platform/foo-bar" path="foo-bar" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert set(client.list_projects()) == {"foo", "foo-bar"}
|
||||||
|
assert not (client.top / "foo/bar").exists()
|
||||||
|
assert (client.top / "foo/f").read_text() == "foo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_copyfile_linkfile(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""copyfile and linkfile are created, updated and cleaned up.
|
||||||
|
|
||||||
|
linkfile symlinks are relative (0184dcc) so trees can be moved.
|
||||||
|
"""
|
||||||
|
foo = host.create_repo(
|
||||||
|
"platform/foo", {"copy.txt": "v1", "dir/link.txt": "link"}
|
||||||
|
)
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" path="foo">'
|
||||||
|
'<copyfile src="copy.txt" dest="copied.txt" />'
|
||||||
|
'<linkfile src="dir/link.txt" dest="links/link.txt" />'
|
||||||
|
"</project>"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
link = client.top / "links/link.txt"
|
||||||
|
# Python <3.8 on Windows rejects Path objects here.
|
||||||
|
target = os.readlink(str(link))
|
||||||
|
assert target == os.path.join("..", "foo", "dir", "link.txt")
|
||||||
|
assert link.read_text() == "link"
|
||||||
|
assert (client.top / "copied.txt").read_text() == "v1"
|
||||||
|
|
||||||
|
foo.commit({"copy.txt": "v2"})
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" path="foo">'
|
||||||
|
'<copyfile src="copy.txt" dest="copied.txt" />'
|
||||||
|
"</project>"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert (client.top / "copied.txt").read_text() == "v2"
|
||||||
|
assert not os.path.lexists(link)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sync_args", [[], ["--no-interleaved"]])
|
||||||
|
def test_submodules(
|
||||||
|
host: GitHost, client: RepoClient, sync_args: List[str]
|
||||||
|
) -> None:
|
||||||
|
"""sync-s checks out & updates submodules, incl. for repeated projects.
|
||||||
|
|
||||||
|
Regression tests for 4623264 (b/444366154, `git submodule init` in
|
||||||
|
interleaved sync), 1b4e7a0 (the same repo checked out at multiple paths),
|
||||||
|
and 3a6e25a/3f087a8 (b/550074864, submodules follow their parent's new
|
||||||
|
revision in the same interleaved & phased sync).
|
||||||
|
"""
|
||||||
|
sub = host.create_repo("platform/sub", {"s": "1"})
|
||||||
|
gitmodules = '[submodule "sub"]\n\tpath = sub\n\turl = ../sub\n'
|
||||||
|
foo = host.create_repo(
|
||||||
|
"platform/foo",
|
||||||
|
{".gitmodules": gitmodules, "sub": Gitlink(sub.rev_parse("main"))},
|
||||||
|
)
|
||||||
|
paths = ("a", "b")
|
||||||
|
# Track a branch, as gitlinks are only stale (b/550074864) when the
|
||||||
|
# parent's revision resolves before it's fetched (unlike new pinned SHAs).
|
||||||
|
# With sync-c, phased syncs skip fetching the stale (present) revision.
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
*(
|
||||||
|
f'<project name="platform/foo" path="{p}" sync-s="true" '
|
||||||
|
'sync-c="true" />'
|
||||||
|
for p in paths
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync(*sync_args)
|
||||||
|
for path in paths:
|
||||||
|
assert (client.top / path / "sub/s").read_text() == "1"
|
||||||
|
assert client.git(path, "config", "--get", "submodule.sub.url")
|
||||||
|
|
||||||
|
latest = foo.commit({"sub": Gitlink(sub.commit({"s": "2"}))})
|
||||||
|
client.sync(*sync_args)
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
assert client.head(path) == latest
|
||||||
|
assert (client.top / path / "sub/s").read_text() == "2"
|
||||||
|
|
||||||
|
|
||||||
|
# The default layout reads HEAD from the ref cache instead of GetHead().
|
||||||
|
@pytest.mark.parametrize("init_args", [[], ["--worktree"]])
|
||||||
|
def test_prune_detached_head(
|
||||||
|
host: GitHost, client: RepoClient, init_args: List[str]
|
||||||
|
) -> None:
|
||||||
|
"""`repo prune` handles projects with a detached HEAD.
|
||||||
|
|
||||||
|
Regression test for 7f7d70e (b/434077990).
|
||||||
|
"""
|
||||||
|
host.create_repo("platform/foo", {"f": "foo"})
|
||||||
|
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
client.init(host.manifest_url, *init_args)
|
||||||
|
client.sync()
|
||||||
|
path = "platform/foo"
|
||||||
|
client.run("start", "topic", "--all")
|
||||||
|
client.git(path, "checkout", "-q", "--detach")
|
||||||
|
|
||||||
|
client.run("prune")
|
||||||
|
|
||||||
|
assert not client.git(path, "branch", "--list", "topic")
|
||||||
|
|
||||||
|
|
||||||
|
def test_submanifest_groups(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""Submanifests apply their own default groups.
|
||||||
|
|
||||||
|
Regression test for cc88be3 (b/382086294).
|
||||||
|
"""
|
||||||
|
for name in ("parent", "child", "child-extra"):
|
||||||
|
host.create_repo(f"platform/{name}", {"f": name})
|
||||||
|
host.create_repo(
|
||||||
|
"platform/child-manifest",
|
||||||
|
{
|
||||||
|
"default.xml": manifest_xml(
|
||||||
|
'<project name="platform/child" path="child" groups="g1" />',
|
||||||
|
'<project name="platform/child-extra" path="extra" />',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/parent" path="parent" />',
|
||||||
|
'<submanifest name="sub" project="platform/child-manifest" '
|
||||||
|
'revision="main" path="sub" default-groups="g1" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.list_projects() == {
|
||||||
|
"parent": "platform/parent",
|
||||||
|
"sub/child": "platform/child",
|
||||||
|
}
|
||||||
|
assert (client.top / "sub/child/f").read_text() == "child"
|
||||||
|
assert not (client.top / "sub/extra").exists()
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# Copyright (C) 2026 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""E2E tests for partial clones, incl. the standard Android configuration."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Dict, List, NamedTuple, Tuple
|
||||||
|
|
||||||
|
from harness import GIT_DEFAULTS_PARAMS
|
||||||
|
from harness import GitHost
|
||||||
|
from harness import Gitlink
|
||||||
|
from harness import manifest_xml
|
||||||
|
from harness import RepoClient
|
||||||
|
from harness import ServerRepo
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# The go/repo-init flags. The filter is 10M there; 10k keeps the test data
|
||||||
|
# small without changing the semantics.
|
||||||
|
ANDROID_INIT_ARGS = [
|
||||||
|
"-c",
|
||||||
|
"-b",
|
||||||
|
"main",
|
||||||
|
"--use-superproject",
|
||||||
|
"--partial-clone",
|
||||||
|
"--partial-clone-exclude=platform/frameworks/base",
|
||||||
|
"--clone-filter=blob:limit=10k",
|
||||||
|
]
|
||||||
|
|
||||||
|
# The go/sync flags.
|
||||||
|
ANDROID_SYNC_ARGS = ["-c", "-j20", "--no-tags", "--optimized-fetch", "--prune"]
|
||||||
|
|
||||||
|
# Larger than the 10k filter above, smaller than git's default big file
|
||||||
|
# threshold so it's still delta compressed & packed normally.
|
||||||
|
BIG = "x" * 20_000
|
||||||
|
|
||||||
|
# The git config key holding a partial clone's filter.
|
||||||
|
FILTER_KEY = "remote.origin.partialclonefilter"
|
||||||
|
|
||||||
|
|
||||||
|
def create_with_big_history(host: GitHost, name: str) -> Tuple[ServerRepo, str]:
|
||||||
|
"""Create `name` with a large blob that only exists in history.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The repo and the id of the historical large blob. The tip commit has
|
||||||
|
a different large blob (`big2.bin`) and a small file.
|
||||||
|
"""
|
||||||
|
repo = host.create_repo(name, {"big.bin": BIG, "small": "s"})
|
||||||
|
big_blob = repo.git("rev-parse", "main:big.bin")
|
||||||
|
repo.commit({"big.bin": None, "big2.bin": BIG + "2"})
|
||||||
|
return repo, big_blob
|
||||||
|
|
||||||
|
|
||||||
|
class AndroidTree(NamedTuple):
|
||||||
|
"""The server side of `create_android_tree`, keyed by project path."""
|
||||||
|
|
||||||
|
repos: Dict[str, ServerRepo]
|
||||||
|
superproject: ServerRepo
|
||||||
|
# The commit the superproject pins each project to.
|
||||||
|
pins: Dict[str, str]
|
||||||
|
# A large blob that only exists in each project's history.
|
||||||
|
blobs: Dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def create_android_tree(host: GitHost) -> AndroidTree:
|
||||||
|
"""Create a small AOSP-like tree with a superproject.
|
||||||
|
|
||||||
|
`platform/foo` is a regular project and `platform/frameworks/base` is the
|
||||||
|
one go/repo-init excludes from the partial clone. Both have moved past
|
||||||
|
the superproject's pins.
|
||||||
|
"""
|
||||||
|
repos, pins, blobs = {}, {}, {}
|
||||||
|
for path in ("foo", "frameworks/base"):
|
||||||
|
name = f"platform/{path}"
|
||||||
|
repos[path], blobs[path] = create_with_big_history(host, name)
|
||||||
|
pins[path] = repos[path].rev_parse("main")
|
||||||
|
superproject = host.create_repo(
|
||||||
|
"platform/superproject",
|
||||||
|
{path: Gitlink(rev) for path, rev in pins.items()},
|
||||||
|
)
|
||||||
|
for repo in repos.values():
|
||||||
|
repo.commit({"small": "ahead"})
|
||||||
|
repo.commit({"small": "other"}, branch="other")
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<superproject name="platform/superproject" />',
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
'<project name="platform/frameworks/base" '
|
||||||
|
'path="frameworks/base" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return AndroidTree(repos, superproject, pins, blobs)
|
||||||
|
|
||||||
|
|
||||||
|
def bundle_requests(host: GitHost) -> Dict[str, List[int]]:
|
||||||
|
"""Map repo name to the status codes of its `clone.bundle` requests."""
|
||||||
|
result: Dict[str, List[int]] = {}
|
||||||
|
for r in host.requests():
|
||||||
|
if r.path.endswith("/clone.bundle"):
|
||||||
|
name = r.path[len(f"/{host.prefix}/") : -len("/clone.bundle")]
|
||||||
|
result.setdefault(name, []).append(r.status)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||||
|
def test_android_init_sync(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""The go/repo-init flag combination followed by `repo sync -c -j32`.
|
||||||
|
|
||||||
|
Covers superproject pinning, `-c`, the clone filter on included projects,
|
||||||
|
no filter on the `--partial-clone-exclude`d project, and that historical
|
||||||
|
large blobs stay on the server while small historical blobs are fetched.
|
||||||
|
"""
|
||||||
|
tree = create_android_tree(host)
|
||||||
|
small_history_blob = tree.repos["foo"].commit_history_only_blob(
|
||||||
|
"small_old.txt", b"small history"
|
||||||
|
)
|
||||||
|
new_foo_pin = tree.repos["foo"].rev_parse("main")
|
||||||
|
tree.superproject.commit({"foo": Gitlink(new_foo_pin)})
|
||||||
|
tree.repos["foo"].commit({"small": "unpinned"})
|
||||||
|
pins = dict(tree.pins, foo=new_foo_pin)
|
||||||
|
|
||||||
|
client.init(host.manifest_url, *ANDROID_INIT_ARGS)
|
||||||
|
assert client.sync("-c", "-j32").stderr == ""
|
||||||
|
|
||||||
|
# init persisted the settings later syncs depend on.
|
||||||
|
manifests = ".repo/manifests"
|
||||||
|
assert client.config(manifests, "repo.superproject") == "true"
|
||||||
|
assert client.config(manifests, "repo.partialclone") == "true"
|
||||||
|
assert client.config(manifests, "repo.clonefilter") == "blob:limit=10k"
|
||||||
|
assert (
|
||||||
|
client.config(manifests, "repo.partialcloneexclude")
|
||||||
|
== "platform/frameworks/base"
|
||||||
|
)
|
||||||
|
for path, pinned in pins.items():
|
||||||
|
assert client.head(path) == pinned
|
||||||
|
assert (client.top / path / "big2.bin").read_text() == BIG + "2"
|
||||||
|
assert client.remote_branches(path) == {"main"}
|
||||||
|
override = client.repodir / "exp-superproject/superproject_override.xml"
|
||||||
|
assert override.exists()
|
||||||
|
# The included project is a partial clone (git normalizes the size), so
|
||||||
|
# the large historical blob was never downloaded, while the small
|
||||||
|
# historical blob (under the 10k limit) was downloaded.
|
||||||
|
assert client.config("foo", "extensions.partialclone") == "origin"
|
||||||
|
assert client.config("foo", FILTER_KEY) == "blob:limit=10240"
|
||||||
|
assert not client.has_object("foo", tree.blobs["foo"])
|
||||||
|
assert client.has_object("foo", small_history_blob)
|
||||||
|
# The excluded project is a full clone.
|
||||||
|
assert client.config("frameworks/base", "extensions.partialclone") is None
|
||||||
|
assert client.config("frameworks/base", FILTER_KEY) is None
|
||||||
|
assert client.has_object("frameworks/base", tree.blobs["frameworks/base"])
|
||||||
|
# Partial clones disable clone bundles, but repo still requests one for
|
||||||
|
# excluded projects (this server has none).
|
||||||
|
assert bundle_requests(host) == {"platform/frameworks/base": [404]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||||
|
def test_android_sync_flags(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""The go/sync flags on an initial & an incremental sync.
|
||||||
|
|
||||||
|
`-c -jN --no-tags --optimized-fetch --prune` on top of the go/repo-init
|
||||||
|
configuration. The incremental sync must fetch a project whose pin moved
|
||||||
|
and skip one whose pin didn't.
|
||||||
|
"""
|
||||||
|
tree = create_android_tree(host)
|
||||||
|
tree.repos["foo"].tag("v1", tree.pins["foo"])
|
||||||
|
|
||||||
|
client.init(host.manifest_url, *ANDROID_INIT_ARGS)
|
||||||
|
assert client.sync(*ANDROID_SYNC_ARGS).stderr == ""
|
||||||
|
|
||||||
|
for path, pinned in tree.pins.items():
|
||||||
|
assert client.head(path) == pinned
|
||||||
|
assert client.remote_branches(path) == {"main"}
|
||||||
|
assert client.tags(path) == set()
|
||||||
|
|
||||||
|
# Move foo's pin forward (on main) & tag it; frameworks/base keeps its pin.
|
||||||
|
new_pin = tree.repos["foo"].commit({"small": "new pin"})
|
||||||
|
tree.repos["foo"].tag("v2", new_pin)
|
||||||
|
tree.superproject.commit({"foo": Gitlink(new_pin)})
|
||||||
|
with host.watch_requests() as requests:
|
||||||
|
assert client.sync(*ANDROID_SYNC_ARGS).stderr == ""
|
||||||
|
|
||||||
|
assert client.head("foo") == new_pin
|
||||||
|
assert client.head("frameworks/base") == tree.pins["frameworks/base"]
|
||||||
|
assert client.tags("foo") == set()
|
||||||
|
assert (client.top / "foo/small").read_text() == "new pin"
|
||||||
|
# A project already at its (immutable) pin isn't fetched.
|
||||||
|
assert host.requested_repos(requests) == {
|
||||||
|
"platform/manifest",
|
||||||
|
"platform/superproject",
|
||||||
|
"platform/foo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_clone_default_filter(
|
||||||
|
host: GitHost, client: RepoClient
|
||||||
|
) -> None:
|
||||||
|
"""`--partial-clone` alone uses `blob:none` & disables clone bundles."""
|
||||||
|
foo, big_blob = create_with_big_history(host, "platform/foo")
|
||||||
|
small_history = foo.commit_history_only_blob("old_small.txt", b"tiny")
|
||||||
|
small_tip = foo.git("rev-parse", "main:small")
|
||||||
|
foo.write_bundle()
|
||||||
|
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
|
||||||
|
client.init(host.manifest_url, "--partial-clone")
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
path = "platform/foo"
|
||||||
|
assert client.config(".repo/manifests", "repo.partialclone") == "true"
|
||||||
|
assert client.config(".repo/manifests", "repo.clonefilter") == "blob:none"
|
||||||
|
assert client.config(".repo/manifests", FILTER_KEY) == "blob:none"
|
||||||
|
assert client.config(path, FILTER_KEY) == "blob:none"
|
||||||
|
assert client.config(path, "extensions.partialclone") == "origin"
|
||||||
|
# Blobs of the checked out tree were fetched on demand; history wasn't
|
||||||
|
# (even tiny blobs).
|
||||||
|
assert (client.top / path / "big2.bin").read_text() == BIG + "2"
|
||||||
|
assert client.has_object(path, small_tip)
|
||||||
|
assert not client.has_object(path, big_blob)
|
||||||
|
assert not client.has_object(path, small_history)
|
||||||
|
assert bundle_requests(host) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_clone_two_phase_sync(
|
||||||
|
host: GitHost, client: RepoClient
|
||||||
|
) -> None:
|
||||||
|
"""`sync -n` then `sync -l` with a partial clone (initial & incremental).
|
||||||
|
|
||||||
|
The network phase fetches no blobs, so the local phase has to fetch the
|
||||||
|
checked out blobs on demand: `-l` isn't offline for partial clones. When
|
||||||
|
the server is unreachable, `sync -l` fails cleanly & can be retried.
|
||||||
|
"""
|
||||||
|
foo, big_blob = create_with_big_history(host, "platform/foo")
|
||||||
|
small_blob = foo.git("rev-parse", "main:small")
|
||||||
|
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
client.init(host.manifest_url, "--partial-clone")
|
||||||
|
|
||||||
|
assert client.sync("-n").stderr == ""
|
||||||
|
path = "platform/foo"
|
||||||
|
assert not (client.top / path).exists()
|
||||||
|
gitdir = f"--git-dir={client.repodir / 'projects' / path}.git"
|
||||||
|
assert client.git(
|
||||||
|
".", gitdir, "rev-parse", "refs/remotes/origin/main"
|
||||||
|
) == foo.rev_parse("main")
|
||||||
|
objects = client.git(
|
||||||
|
".",
|
||||||
|
gitdir,
|
||||||
|
"cat-file",
|
||||||
|
"--batch-check=%(objectname)",
|
||||||
|
"--batch-all-objects",
|
||||||
|
).split()
|
||||||
|
assert foo.rev_parse("main") in objects
|
||||||
|
assert small_blob not in objects
|
||||||
|
|
||||||
|
# When the server is offline, `sync -l` fails cleanly without leaving a
|
||||||
|
# broken worktree behind.
|
||||||
|
with host.offline():
|
||||||
|
failed = client.sync("-l", check=False)
|
||||||
|
assert failed.returncode != 0
|
||||||
|
assert "Cannot initialize work tree for platform/foo" in failed.stderr
|
||||||
|
assert not os.path.lexists(client.top / path / ".git")
|
||||||
|
|
||||||
|
with host.watch_requests() as requests:
|
||||||
|
client.sync("-l")
|
||||||
|
|
||||||
|
assert client.head(path) == foo.rev_parse("main")
|
||||||
|
assert (client.top / path / "big2.bin").read_text() == BIG + "2"
|
||||||
|
assert client.has_object(path, small_blob)
|
||||||
|
assert not client.has_object(path, big_blob)
|
||||||
|
# The on-demand blob fetches are the only traffic of the local sync.
|
||||||
|
assert host.requested_repos(requests) == {"platform/foo"}
|
||||||
|
|
||||||
|
# Incremental `-n` then `-l` on the existing partial-clone checkout.
|
||||||
|
v2 = foo.commit({"small": "s2"})
|
||||||
|
s2_blob = foo.git("rev-parse", "main:small")
|
||||||
|
client.sync("-n")
|
||||||
|
assert (client.top / path / "small").read_text() == "s"
|
||||||
|
assert not client.has_object(path, s2_blob)
|
||||||
|
|
||||||
|
with host.watch_requests() as inc_requests:
|
||||||
|
client.sync("-l")
|
||||||
|
assert client.head(path) == v2
|
||||||
|
assert (client.top / path / "small").read_text() == "s2"
|
||||||
|
assert client.has_object(path, s2_blob)
|
||||||
|
assert host.requested_repos(inc_requests) == {"platform/foo"}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
# Copyright (C) 2026 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""E2E tests for `repo sync` modes (`-n`, `-l`) & sync flags."""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from harness import GIT_DEFAULTS_PARAMS
|
||||||
|
from harness import GitHost
|
||||||
|
from harness import Gitlink
|
||||||
|
from harness import manifest_xml
|
||||||
|
from harness import RepoClient
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# Interleaved (the default) & phased syncs implement `-n` & `-l` separately.
|
||||||
|
SYNC_MODES = [[], ["--no-interleaved"]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode_args", SYNC_MODES, ids=["interleaved", "phased"])
|
||||||
|
def test_network_only_then_local_only(
|
||||||
|
host: GitHost, client: RepoClient, mode_args: List[str]
|
||||||
|
) -> None:
|
||||||
|
"""`sync -n` on an existing checkout updates objects & remote refs only.
|
||||||
|
|
||||||
|
The worktree, HEAD & local branches don't change until `sync -l`, which
|
||||||
|
checks out what `-n` fetched without touching the network.
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
bar = host.create_repo("platform/bar", {"f": "1"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
'<project name="platform/bar" path="bar" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync(*mode_args)
|
||||||
|
old_foo = foo.rev_parse("main")
|
||||||
|
# Local work on a topic branch in bar.
|
||||||
|
client.run("start", "topic", "bar")
|
||||||
|
(client.top / "bar/local").write_text("local")
|
||||||
|
client.git("bar", "add", "local")
|
||||||
|
client.git("bar", "commit", "-q", "-m", "local change")
|
||||||
|
local_commit = client.head("bar")
|
||||||
|
fetched = {"foo": foo.commit({"f": "2"}), "bar": bar.commit({"f": "2"})}
|
||||||
|
|
||||||
|
assert client.sync("-n", *mode_args).stderr == ""
|
||||||
|
|
||||||
|
assert client.head("foo") == old_foo
|
||||||
|
assert (client.top / "foo/f").read_text() == "1"
|
||||||
|
assert client.head("bar") == local_commit
|
||||||
|
assert client.git("bar", "symbolic-ref", "--short", "HEAD") == "topic"
|
||||||
|
for path, commit in fetched.items():
|
||||||
|
assert client.has_object(path, commit)
|
||||||
|
assert client.git(path, "rev-parse", "refs/remotes/origin/main") == (
|
||||||
|
commit
|
||||||
|
)
|
||||||
|
assert client.git(path, "status", "--porcelain") == ""
|
||||||
|
|
||||||
|
# The server moves on, but `-l` must only use what `-n` fetched.
|
||||||
|
foo.commit({"f": "3"})
|
||||||
|
with host.watch_requests() as requests, host.offline():
|
||||||
|
client.sync("-l", *mode_args)
|
||||||
|
|
||||||
|
assert requests == []
|
||||||
|
assert client.head("foo") == fetched["foo"]
|
||||||
|
assert (client.top / "foo/f").read_text() == "2"
|
||||||
|
# The local commit on `bar` was rebased onto what `-n` fetched.
|
||||||
|
assert client.git("bar", "symbolic-ref", "--short", "HEAD") == "topic"
|
||||||
|
assert client.git("bar", "rev-parse", "HEAD~1") == fetched["bar"]
|
||||||
|
assert (client.top / "bar/local").read_text() == "local"
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_only_unfetched_project(
|
||||||
|
host: GitHost, client: RepoClient
|
||||||
|
) -> None:
|
||||||
|
"""`sync -l` fails clearly for a project that was never fetched.
|
||||||
|
|
||||||
|
The other projects are still checked out, no network requests are made,
|
||||||
|
and the error says how to fix it.
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
host.create_repo("platform/bar", {"f": "bar"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
v2 = foo.commit({"f": "2"})
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
'<project name="platform/bar" path="bar" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Update the manifest & fetch foo, but not the new project.
|
||||||
|
client.sync("-n", "platform/foo")
|
||||||
|
assert not (client.repodir / "projects/bar.git").exists()
|
||||||
|
|
||||||
|
with host.watch_requests() as requests, host.offline():
|
||||||
|
result = client.sync("-l", check=False)
|
||||||
|
|
||||||
|
assert requests == []
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert (
|
||||||
|
"Cannot checkout platform/bar due to missing network sync; Run "
|
||||||
|
"`repo sync -n platform/bar` first." in result.stderr
|
||||||
|
)
|
||||||
|
assert not (client.top / "bar").exists()
|
||||||
|
# The already-fetched project still got checked out.
|
||||||
|
assert client.head("foo") == v2
|
||||||
|
assert (client.top / "foo/f").read_text() == "2"
|
||||||
|
|
||||||
|
# Fetching the missing project fixes it.
|
||||||
|
client.sync("-n", "platform/bar")
|
||||||
|
with host.watch_requests() as requests, host.offline():
|
||||||
|
client.sync("-l")
|
||||||
|
assert requests == []
|
||||||
|
assert (client.top / "bar/f").read_text() == "bar"
|
||||||
|
assert client.list_projects() == {
|
||||||
|
"bar": "platform/bar",
|
||||||
|
"foo": "platform/foo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||||
|
def test_two_phase_sync_superproject(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""`sync -n` pins projects via the superproject; `sync -l` reuses the pins.
|
||||||
|
|
||||||
|
The local phase must not hit the network, even to re-read the
|
||||||
|
superproject after the server's superproject moved on.
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "v1"})
|
||||||
|
pinned = foo.rev_parse("main")
|
||||||
|
superproject = host.create_repo("superproject", {"foo": Gitlink(pinned)})
|
||||||
|
foo.commit({"f": "v2"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<superproject name="superproject" />',
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url, "--use-superproject")
|
||||||
|
|
||||||
|
assert client.sync("-n").stderr == ""
|
||||||
|
assert not (client.top / "foo").exists()
|
||||||
|
assert (
|
||||||
|
client.repodir / "exp-superproject/superproject_override.xml"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
# Advance the superproject on the server before running `sync -l`.
|
||||||
|
new_pin = foo.commit({"f": "v3"})
|
||||||
|
superproject.commit({"foo": Gitlink(new_pin)})
|
||||||
|
|
||||||
|
with host.watch_requests() as requests, host.offline():
|
||||||
|
client.sync("-l")
|
||||||
|
|
||||||
|
assert requests == []
|
||||||
|
assert client.head("foo") == pinned
|
||||||
|
assert (client.top / "foo/f").read_text() == "v1"
|
||||||
|
|
||||||
|
# The next two-phase sync picks up the new superproject pin.
|
||||||
|
client.sync("-n")
|
||||||
|
assert client.head("foo") == pinned
|
||||||
|
with host.watch_requests() as requests, host.offline():
|
||||||
|
client.sync("-l")
|
||||||
|
assert requests == []
|
||||||
|
assert client.head("foo") == new_pin
|
||||||
|
assert (client.top / "foo/f").read_text() == "v3"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sync_args,project_attrs,expect_tags",
|
||||||
|
[
|
||||||
|
# Despite `repo sync --help` calling `--no-tags` the default.
|
||||||
|
([], "", True),
|
||||||
|
# `-c` only limits branches: tags on other branches are still fetched
|
||||||
|
# unless `--no-tags` is passed.
|
||||||
|
(["-c"], "", True),
|
||||||
|
(["--tags"], "", True),
|
||||||
|
(["-c", "--no-tags"], "", False),
|
||||||
|
(["-c"], 'sync-tags="false"', False),
|
||||||
|
(["-c", "--tags"], 'sync-tags="false"', True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_tags(
|
||||||
|
host: GitHost,
|
||||||
|
client: RepoClient,
|
||||||
|
sync_args: List[str],
|
||||||
|
project_attrs: str,
|
||||||
|
expect_tags: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Tags are fetched unless `--no-tags` or the manifest says `sync-tags`."""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
foo.tag("v1", "main")
|
||||||
|
# A tag that isn't on the synced branch, like release tags usually are.
|
||||||
|
foo.commit({"f": "release"}, branch="release")
|
||||||
|
foo.tag("v2", "release")
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(f'<project name="platform/foo" {project_attrs} />')
|
||||||
|
)
|
||||||
|
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync(*sync_args)
|
||||||
|
|
||||||
|
assert client.tags("platform/foo") == (
|
||||||
|
{"v1", "v2"} if expect_tags else set()
|
||||||
|
)
|
||||||
|
expected_branches = {"main"} if "-c" in sync_args else {"main", "release"}
|
||||||
|
assert client.remote_branches("platform/foo") == expected_branches
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""Remote-tracking branches deleted on the server are pruned by default.
|
||||||
|
|
||||||
|
`--no-prune` keeps them.
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
foo.commit({"f": "stale"}, branch="stale")
|
||||||
|
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
path = "platform/foo"
|
||||||
|
assert client.remote_branches(path) == {"main", "stale"}
|
||||||
|
|
||||||
|
foo.git("update-ref", "-d", "refs/heads/stale")
|
||||||
|
client.sync("--no-prune")
|
||||||
|
assert client.remote_branches(path) == {"main", "stale"}
|
||||||
|
|
||||||
|
client.sync()
|
||||||
|
assert client.remote_branches(path) == {"main"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_sync(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""`--force-sync` re-creates a path whose project changed in the manifest.
|
||||||
|
|
||||||
|
The old gitdir points at another project's object dir, which sync refuses
|
||||||
|
to overwrite unless forced.
|
||||||
|
"""
|
||||||
|
host.create_repo("platform/foo", {"f": "foo"})
|
||||||
|
foo_v2 = host.create_repo("platform/foo-v2", {"f": "foo-v2"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml('<project name="platform/foo-v2" path="foo" />')
|
||||||
|
)
|
||||||
|
|
||||||
|
result = client.sync(check=False)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "--force-sync not enabled" in result.stderr
|
||||||
|
assert "`repo sync --force-sync foo`" in result.stderr
|
||||||
|
assert (client.top / "foo/f").read_text() == "foo"
|
||||||
|
|
||||||
|
client.sync("--force-sync")
|
||||||
|
|
||||||
|
assert client.head("foo") == foo_v2.rev_parse("main")
|
||||||
|
assert (client.top / "foo/f").read_text() == "foo-v2"
|
||||||
|
assert client.list_projects() == {"foo": "platform/foo-v2"}
|
||||||
|
assert client.git("foo", "status", "--porcelain") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_remove_dirty(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""Removed projects with uncommitted changes need `--force-remove-dirty`."""
|
||||||
|
host.create_repo("platform/foo", {"f": "foo"})
|
||||||
|
host.create_repo("platform/bar", {"f": "bar"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
'<project name="platform/bar" path="bar" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
(client.top / "bar/f").write_text("dirty")
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Repeat to check that the project stays tracked in project.list (rather
|
||||||
|
# than forgotten & left behind) after the first failure.
|
||||||
|
for _ in range(2):
|
||||||
|
result = client.sync(check=False)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert (
|
||||||
|
"bar: Cannot remove project: uncommitted changes are present"
|
||||||
|
in result.stderr
|
||||||
|
)
|
||||||
|
assert (client.top / "bar/f").read_text() == "dirty"
|
||||||
|
|
||||||
|
result = client.sync("--force-remove-dirty")
|
||||||
|
assert "bar: Removing dirty project" in result.stderr
|
||||||
|
assert not (client.top / "bar").exists()
|
||||||
|
assert client.list_projects() == {"foo": "platform/foo"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_detach(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""`sync -d` detaches projects on a topic branch at the manifest revision.
|
||||||
|
|
||||||
|
The topic branch & its local commit are left alone.
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
client.run("start", "topic", "--all")
|
||||||
|
(client.top / "foo/local").write_text("local")
|
||||||
|
client.git("foo", "add", "local")
|
||||||
|
client.git("foo", "commit", "-q", "-m", "local change")
|
||||||
|
local = client.head("foo")
|
||||||
|
latest = foo.commit({"f": "2"})
|
||||||
|
|
||||||
|
client.sync("-d")
|
||||||
|
|
||||||
|
assert client.is_detached("foo")
|
||||||
|
assert client.head("foo") == latest
|
||||||
|
assert (client.top / "foo/f").read_text() == "2"
|
||||||
|
assert not (client.top / "foo/local").exists()
|
||||||
|
assert client.git("foo", "rev-parse", "topic") == local
|
||||||
|
# A regular sync afterwards stays detached.
|
||||||
|
client.sync()
|
||||||
|
assert client.is_detached("foo")
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
# Copyright (C) 2026 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""E2E tests for multi-step workflows, many from past point releases."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from harness import GIT_DEFAULTS_PARAMS
|
||||||
|
from harness import GitHost
|
||||||
|
from harness import manifest_xml
|
||||||
|
from harness import RepoClient
|
||||||
|
from harness import run_git
|
||||||
|
from harness import ServerRepo
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"project_attrs",
|
||||||
|
["", 'clone-depth="1"', 'sync-strategy="stateless"'],
|
||||||
|
)
|
||||||
|
def test_two_phase_sync(
|
||||||
|
host: GitHost, client: RepoClient, project_attrs: str
|
||||||
|
) -> None:
|
||||||
|
"""`repo sync -n` then `repo sync -l` before worktrees exist.
|
||||||
|
|
||||||
|
Regression tests for b/565047698 (v2.68.1, incl. e1e215a for stateless
|
||||||
|
syncs).
|
||||||
|
"""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
f'<project name="platform/foo" path="foo" {project_attrs} />'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
|
||||||
|
# The b/565047698 regression only logs a warning & still exits 0, so also
|
||||||
|
# require a clean stderr.
|
||||||
|
assert client.sync("-n").stderr == ""
|
||||||
|
assert not (client.top / "foo").exists()
|
||||||
|
# A second network-only sync (after upstream moved) with no worktree yet.
|
||||||
|
latest = foo.commit({"f": "2"})
|
||||||
|
assert client.sync("-n").stderr == ""
|
||||||
|
assert not (client.top / "foo").exists()
|
||||||
|
|
||||||
|
client.sync("-l")
|
||||||
|
assert client.head("foo") == latest
|
||||||
|
assert (client.top / "foo/f").read_text() == "2"
|
||||||
|
|
||||||
|
# And the checkout keeps working with regular syncs afterwards.
|
||||||
|
latest = foo.commit({"f": "3"})
|
||||||
|
client.sync()
|
||||||
|
assert client.head("foo") == latest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"extra_init_args", [[], ["--manifest-upstream-branch", "main"]]
|
||||||
|
)
|
||||||
|
def test_init_manifest_commit(
|
||||||
|
host: GitHost, client: RepoClient, extra_init_args: List[str]
|
||||||
|
) -> None:
|
||||||
|
"""`repo init -b <sha>` pins the manifest to a raw commit.
|
||||||
|
|
||||||
|
Regression test for b/544041102 (v2.66.1).
|
||||||
|
"""
|
||||||
|
host.create_repo("platform/foo", {"f": "foo"})
|
||||||
|
host.create_repo("platform/bar", {"f": "bar"})
|
||||||
|
manifest = host.create_manifest(
|
||||||
|
manifest_xml('<project name="platform/foo" />')
|
||||||
|
)
|
||||||
|
pinned = manifest.rev_parse("main")
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" />',
|
||||||
|
'<project name="platform/bar" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
client.init(host.manifest_url, "-b", pinned, *extra_init_args)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.list_projects() == {"platform/foo": "platform/foo"}
|
||||||
|
assert client.git(".repo/manifests", "rev-parse", "HEAD") == pinned
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("git_defaults", GIT_DEFAULTS_PARAMS)
|
||||||
|
def test_mirror(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""`repo init --mirror && repo sync` creates bare mirrors (v2.57.1)."""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
foo.commit({"f": "other"}, branch="other")
|
||||||
|
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
|
||||||
|
client.init(host.manifest_url, "--mirror")
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
mirror = client.top / "platform/foo.git"
|
||||||
|
|
||||||
|
def mirror_rev(ref: str) -> str:
|
||||||
|
return run_git(f"--git-dir={mirror}", "rev-parse", ref, env=client.env)
|
||||||
|
|
||||||
|
assert not (client.top / "platform/foo").exists()
|
||||||
|
assert mirror_rev("refs/heads/main") == foo.rev_parse("main")
|
||||||
|
assert mirror_rev("refs/heads/other") == foo.rev_parse("other")
|
||||||
|
|
||||||
|
latest = foo.commit({"f": "2"})
|
||||||
|
client.sync()
|
||||||
|
assert mirror_rev("refs/heads/main") == latest
|
||||||
|
|
||||||
|
|
||||||
|
def test_incremental_sync(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""Syncs pick up new commits, new projects & removed projects."""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
host.create_repo("platform/bar", {"f": "bar"})
|
||||||
|
host.create_repo("platform/baz", {"f": "baz"})
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
'<project name="platform/bar" path="bar" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
latest = foo.commit({"f": "2", "new": "new"})
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
'<project name="platform/baz" path="baz" />',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.head("foo") == latest
|
||||||
|
assert (client.top / "foo/new").read_text() == "new"
|
||||||
|
assert (client.top / "baz/f").read_text() == "baz"
|
||||||
|
assert not (client.top / "bar").exists()
|
||||||
|
assert client.list_projects() == {
|
||||||
|
"baz": "platform/baz",
|
||||||
|
"foo": "platform/foo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebase_local_work(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""Local commits on a `repo start` branch are rebased by sync."""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "1"})
|
||||||
|
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
path = "platform/foo"
|
||||||
|
client.run("start", "topic", "--all")
|
||||||
|
(client.top / path / "local").write_text("local")
|
||||||
|
client.git(path, "add", "local")
|
||||||
|
client.git(path, "commit", "-q", "-m", "local change")
|
||||||
|
latest = foo.commit({"f": "2"})
|
||||||
|
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.git(path, "symbolic-ref", "--short", "HEAD") == "topic"
|
||||||
|
assert client.git(path, "rev-parse", "HEAD~1") == latest
|
||||||
|
assert (client.top / path / "local").read_text() == "local"
|
||||||
|
|
||||||
|
|
||||||
|
def test_switch_manifest_branch(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""Re-running `repo init -b` on an existing checkout switches branches."""
|
||||||
|
foo = host.create_repo("platform/foo", {"f": "main"})
|
||||||
|
release = foo.commit({"f": "release"}, branch="release")
|
||||||
|
host.create_manifest(
|
||||||
|
manifest_xml('<project name="platform/foo" path="foo" />')
|
||||||
|
)
|
||||||
|
host.update_manifest(
|
||||||
|
manifest_xml(
|
||||||
|
'<project name="platform/foo" path="foo" />',
|
||||||
|
default='remote="origin" revision="release"',
|
||||||
|
),
|
||||||
|
branch="release",
|
||||||
|
)
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
assert client.head("foo") == foo.rev_parse("main")
|
||||||
|
|
||||||
|
client.init(host.manifest_url, "-b", "release")
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
assert client.head("foo") == release
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"command",
|
||||||
|
[
|
||||||
|
["selfupdate", "--no-repo-verify"],
|
||||||
|
["sync", "--no-repo-verify"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_selfupdate(
|
||||||
|
host: GitHost,
|
||||||
|
client: RepoClient,
|
||||||
|
repo_source: ServerRepo,
|
||||||
|
command: List[str],
|
||||||
|
) -> None:
|
||||||
|
"""repo upgrades itself when its source moves forward."""
|
||||||
|
source = host.fork(repo_source, "git-repo")
|
||||||
|
client.repo_url = source.url
|
||||||
|
host.create_repo("platform/foo", {"f": "foo"})
|
||||||
|
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
# Start from an existing synced checkout before publishing the repo update.
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
latest = source.commit({"E2E_SELFUPDATE_MARKER": "new"})
|
||||||
|
# sync only checks for updates once a day.
|
||||||
|
fetch_head = client.repodir / "repo/.git/FETCH_HEAD"
|
||||||
|
old = time.time() - 2 * 24 * 60 * 60
|
||||||
|
os.utime(fetch_head, (old, old))
|
||||||
|
|
||||||
|
result = client.run(*command)
|
||||||
|
|
||||||
|
assert "A new version of repo is available" in result.stderr
|
||||||
|
assert client.git(".repo/repo", "rev-parse", "HEAD") == latest
|
||||||
|
assert (client.repodir / "repo/E2E_SELFUPDATE_MARKER").exists()
|
||||||
|
# The restarted (upgraded) repo keeps working.
|
||||||
|
assert "v99.0-e2e-1-g" in client.run("version").stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_commands_after_sync(host: GitHost, client: RepoClient) -> None:
|
||||||
|
"""Common read-only commands work on a freshly synced checkout."""
|
||||||
|
host.create_repo("platform/foo", {"f": "foo"})
|
||||||
|
host.create_manifest(manifest_xml('<project name="platform/foo" />'))
|
||||||
|
client.init(host.manifest_url)
|
||||||
|
client.sync()
|
||||||
|
|
||||||
|
for command, expected in (
|
||||||
|
(["status"], r"nothing to commit"),
|
||||||
|
(["info"], r"Manifest branch: main"),
|
||||||
|
(["branches"], r"\(no branches\)"),
|
||||||
|
# No local changes, so no output.
|
||||||
|
(["diff"], r"\A\Z"),
|
||||||
|
(["forall", "-c", "git", "log", "-1"], r"platform/foo: initial"),
|
||||||
|
# Projects are pinned to commit ids.
|
||||||
|
(["manifest", "-r"], r'revision="[0-9a-f]{40}"'),
|
||||||
|
):
|
||||||
|
output = client.run(*command).output
|
||||||
|
assert re.search(expected, output), (
|
||||||
|
f"`repo {' '.join(command)}` output {output!r} "
|
||||||
|
f"did not match {expected!r}"
|
||||||
|
)
|
||||||
@@ -201,6 +201,7 @@ class GitCommandStreamLogsTest(unittest.TestCase):
|
|||||||
|
|
||||||
@mock.patch("sys.stderr")
|
@mock.patch("sys.stderr")
|
||||||
def test_streams_stderr_when_no_stream_is_set(self, mock_stderr):
|
def test_streams_stderr_when_no_stream_is_set(self, mock_stderr):
|
||||||
|
mock_stderr.isatty.return_value = False
|
||||||
logs = "\n".join(
|
logs = "\n".join(
|
||||||
[
|
[
|
||||||
"Enumerating objects: 5, done.",
|
"Enumerating objects: 5, done.",
|
||||||
@@ -228,6 +229,28 @@ class GitCommandStreamLogsTest(unittest.TestCase):
|
|||||||
mock_stderr.write.assert_called_once_with(logs)
|
mock_stderr.write.assert_called_once_with(logs)
|
||||||
self.assertEqual(cmd.stderr, logs)
|
self.assertEqual(cmd.stderr, logs)
|
||||||
|
|
||||||
|
@mock.patch("sys.stderr")
|
||||||
|
def test_push_injects_progress_only_when_tty_and_not_suppressed(
|
||||||
|
self, mock_stderr: mock.MagicMock
|
||||||
|
) -> None:
|
||||||
|
mock_stderr.isatty.return_value = True
|
||||||
|
self.mock_process.stderr = io.BufferedReader(io.BytesIO(b""))
|
||||||
|
git_command.GitCommand(None, ["push"])
|
||||||
|
self.mock_popen.assert_called_once()
|
||||||
|
cmd = self.mock_popen.call_args[0][0]
|
||||||
|
self.assertEqual(cmd, ["git", "push", "--progress"])
|
||||||
|
|
||||||
|
@mock.patch("sys.stderr")
|
||||||
|
def test_push_does_not_inject_progress_when_no_progress_set(
|
||||||
|
self, mock_stderr: mock.MagicMock
|
||||||
|
) -> None:
|
||||||
|
mock_stderr.isatty.return_value = True
|
||||||
|
self.mock_process.stderr = io.BufferedReader(io.BytesIO(b""))
|
||||||
|
git_command.GitCommand(None, ["push", "--no-progress"])
|
||||||
|
self.mock_popen.assert_called_once()
|
||||||
|
cmd = self.mock_popen.call_args[0][0]
|
||||||
|
self.assertEqual(cmd, ["git", "push", "--no-progress"])
|
||||||
|
|
||||||
|
|
||||||
class GitCommandCaptureBytesTest(unittest.TestCase):
|
class GitCommandCaptureBytesTest(unittest.TestCase):
|
||||||
"""Tests the GitCommand class byte capture cases."""
|
"""Tests the GitCommand class byte capture cases."""
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"""Unittests for the git_config.py module."""
|
"""Unittests for the git_config.py module."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import utils_for_test
|
import utils_for_test
|
||||||
@@ -268,3 +268,81 @@ def test_remote_save_with_push_url_without_projectname(
|
|||||||
def test_is_id(rev: str, expected: bool) -> None:
|
def test_is_id(rev: str, expected: bool) -> None:
|
||||||
"""Test IsId identifies both SHA-1 and SHA-256 hashes."""
|
"""Test IsId identifies both SHA-1 and SHA-256 hashes."""
|
||||||
assert git_config.IsId(rev) == expected
|
assert git_config.IsId(rev) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_repo_config_for_user_is_not_shared_with_git_config(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_home_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""RepoConfig.ForUser() must not reuse GitConfig.ForUser()'s cache."""
|
||||||
|
monkeypatch.setattr(git_config.GitConfig, "_ForUser", None)
|
||||||
|
monkeypatch.setattr(git_config.RepoConfig, "_ForUser", None)
|
||||||
|
monkeypatch.delenv("REPO_CONFIG_DIR", raising=False)
|
||||||
|
|
||||||
|
# Populate the GitConfig cache first, as manifest loading does.
|
||||||
|
git_cfg = git_config.GitConfig.ForUser()
|
||||||
|
repo_cfg = git_config.RepoConfig.ForUser()
|
||||||
|
|
||||||
|
assert repo_cfg is not git_cfg
|
||||||
|
assert git_cfg.file == str(tmp_home_dir / ".gitconfig")
|
||||||
|
assert repo_cfg.file == str(tmp_home_dir / ".repoconfig" / "config")
|
||||||
|
# And the RepoConfig instance is cached on its own class.
|
||||||
|
assert git_config.RepoConfig.ForUser() is repo_cfg
|
||||||
|
assert git_config.GitConfig.ForUser() is git_cfg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value, expected",
|
||||||
|
(
|
||||||
|
("true", True),
|
||||||
|
("True", True),
|
||||||
|
("yes", True),
|
||||||
|
("on", True),
|
||||||
|
("1", True),
|
||||||
|
(" true ", True),
|
||||||
|
("false", False),
|
||||||
|
("No", False),
|
||||||
|
("off", False),
|
||||||
|
("0", False),
|
||||||
|
("oops", None),
|
||||||
|
("2", None),
|
||||||
|
("", None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_parse_boolean(value: str, expected: Optional[bool]) -> None:
|
||||||
|
"""Test ParseBoolean accepts the spellings `git config --type=bool` does."""
|
||||||
|
assert git_config.ParseBoolean(value) is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value, expected",
|
||||||
|
(
|
||||||
|
("10", 10),
|
||||||
|
("-7", -7),
|
||||||
|
("10k", 10 * 1024),
|
||||||
|
("10M", 10 * 1024 * 1024),
|
||||||
|
("10g", 10 * 1024 * 1024 * 1024),
|
||||||
|
("0x10", 16),
|
||||||
|
("0x10k", 16 * 1024),
|
||||||
|
(" 10 ", 10),
|
||||||
|
("oops", None),
|
||||||
|
("1x", None),
|
||||||
|
("", None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_parse_int(value: str, expected: Optional[int]) -> None:
|
||||||
|
"""Test ParseInt accepts the syntax `git config --type=int` does."""
|
||||||
|
assert git_config.ParseInt(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value, expected",
|
||||||
|
(("on", True), ("off", False), ("1", True), ("0", False)),
|
||||||
|
)
|
||||||
|
def test_get_boolean_git_spellings(
|
||||||
|
tmp_path: Path, value: str, expected: bool
|
||||||
|
) -> None:
|
||||||
|
"""Test GetBoolean accepts on/off/1/0 like git does."""
|
||||||
|
config_file = tmp_path / "config"
|
||||||
|
config_file.write_text(f"[section]\n\tkey = {value}\n")
|
||||||
|
config = git_config.GitConfig(config_file)
|
||||||
|
assert config.GetBoolean("section.key") is expected
|
||||||
@@ -23,6 +23,7 @@ from unittest import mock
|
|||||||
import pytest
|
import pytest
|
||||||
import utils_for_test
|
import utils_for_test
|
||||||
|
|
||||||
|
import git_command
|
||||||
import git_refs
|
import git_refs
|
||||||
|
|
||||||
|
|
||||||
@@ -99,8 +100,8 @@ def test_reads_head_with_root_refs_in_one_command(
|
|||||||
def Wait(self) -> int:
|
def Wait(self) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand)
|
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||||
monkeypatch.setattr(git_refs, "git_require", lambda _version: True)
|
monkeypatch.setattr(git_command, "git_require", lambda _version: True)
|
||||||
refs = git_refs.GitRefs("/nonexistent")
|
refs = git_refs.GitRefs("/nonexistent")
|
||||||
with mock.patch.object(refs, "_ReadSymbolicRef") as read_head:
|
with mock.patch.object(refs, "_ReadSymbolicRef") as read_head:
|
||||||
assert refs.get("HEAD") == head
|
assert refs.get("HEAD") == head
|
||||||
@@ -131,8 +132,8 @@ def test_root_ref_snapshot_falls_back_for_unborn_head(
|
|||||||
def Wait(self) -> int:
|
def Wait(self) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand)
|
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||||
monkeypatch.setattr(git_refs, "git_require", lambda _version: True)
|
monkeypatch.setattr(git_command, "git_require", lambda _version: True)
|
||||||
refs = git_refs.GitRefs("/nonexistent")
|
refs = git_refs.GitRefs("/nonexistent")
|
||||||
|
|
||||||
def read_head(name: str) -> None:
|
def read_head(name: str) -> None:
|
||||||
@@ -161,8 +162,8 @@ def test_old_git_keeps_separate_head_fallback(
|
|||||||
def Wait(self) -> int:
|
def Wait(self) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand)
|
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||||
monkeypatch.setattr(git_refs, "git_require", lambda _version: False)
|
monkeypatch.setattr(git_command, "git_require", lambda _version: False)
|
||||||
refs = git_refs.GitRefs("/nonexistent")
|
refs = git_refs.GitRefs("/nonexistent")
|
||||||
|
|
||||||
def read_head(name: str) -> None:
|
def read_head(name: str) -> None:
|
||||||
@@ -194,8 +195,8 @@ def test_for_each_ref_failure_falls_back_to_symbolic_ref(
|
|||||||
def Wait(self) -> int:
|
def Wait(self) -> int:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
monkeypatch.setattr(git_refs, "GitCommand", FakeGitCommand)
|
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||||
monkeypatch.setattr(git_refs, "git_require", lambda _version: True)
|
monkeypatch.setattr(git_command, "git_require", lambda _version: True)
|
||||||
refs = git_refs.GitRefs("/nonexistent")
|
refs = git_refs.GitRefs("/nonexistent")
|
||||||
|
|
||||||
def read_head(name: str) -> None:
|
def read_head(name: str) -> None:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from unittest import mock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import git_command
|
||||||
import git_status
|
import git_status
|
||||||
|
|
||||||
|
|
||||||
@@ -143,8 +144,8 @@ def test_get_status_uses_versioned_machine_options(
|
|||||||
def Wait(self) -> int:
|
def Wait(self) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
monkeypatch.setattr(git_status, "GitCommand", FakeGitCommand)
|
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||||
monkeypatch.setattr(git_status, "git_require", lambda _version: True)
|
monkeypatch.setattr(git_command, "git_require", lambda _version: True)
|
||||||
|
|
||||||
git_status.GetStatus(
|
git_status.GetStatus(
|
||||||
mock.sentinel.project,
|
mock.sentinel.project,
|
||||||
@@ -173,7 +174,7 @@ def test_get_status_uses_versioned_machine_options(
|
|||||||
def test_get_status_rejects_git_before_2_11(
|
def test_get_status_rejects_git_before_2_11(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(git_status, "git_require", lambda _version: False)
|
monkeypatch.setattr(git_command, "git_require", lambda _version: False)
|
||||||
|
|
||||||
with pytest.raises(git_status.UnsupportedStatusError):
|
with pytest.raises(git_status.UnsupportedStatusError):
|
||||||
git_status.GetStatus(mock.sentinel.project, mock.sentinel.gitdir)
|
git_status.GetStatus(mock.sentinel.project, mock.sentinel.gitdir)
|
||||||
@@ -194,9 +195,9 @@ def test_get_status_omits_stash_header_before_2_35(
|
|||||||
def Wait(self) -> int:
|
def Wait(self) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
monkeypatch.setattr(git_status, "GitCommand", FakeGitCommand)
|
monkeypatch.setattr(git_command, "GitCommand", FakeGitCommand)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
git_status,
|
git_command,
|
||||||
"git_require",
|
"git_require",
|
||||||
lambda version: version <= (2, 34, 0),
|
lambda version: version <= (2, 34, 0),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
|||||||
"""Test _Init succeeds and creates the work git dir."""
|
"""Test _Init succeeds and creates the work git dir."""
|
||||||
self.assertFalse(os.path.exists(self._superproject._work_git))
|
self.assertFalse(os.path.exists(self._superproject._work_git))
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"git_superproject.GitCommand", autospec=True
|
"git_command.GitCommand", autospec=True
|
||||||
) as mock_git_command:
|
) as mock_git_command:
|
||||||
instance = mock_git_command.return_value
|
instance = mock_git_command.return_value
|
||||||
instance.Wait.return_value = 0
|
instance.Wait.return_value = 0
|
||||||
@@ -492,7 +492,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
|||||||
os.mkdir(self._superproject._superproject_path)
|
os.mkdir(self._superproject._superproject_path)
|
||||||
os.mkdir(self._superproject._work_git)
|
os.mkdir(self._superproject._work_git)
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"git_superproject.GitCommand", autospec=True
|
"git_command.GitCommand", autospec=True
|
||||||
) as mock_git_command:
|
) as mock_git_command:
|
||||||
self.assertTrue(self._superproject._Init())
|
self.assertTrue(self._superproject._Init())
|
||||||
mock_git_command.assert_not_called()
|
mock_git_command.assert_not_called()
|
||||||
@@ -500,7 +500,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
|||||||
def test_Init_failure_cleans_up(self):
|
def test_Init_failure_cleans_up(self):
|
||||||
"""Test _Init cleans up the temporary directory if 'git init' fails."""
|
"""Test _Init cleans up the temporary directory if 'git init' fails."""
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"git_superproject.GitCommand", autospec=True
|
"git_command.GitCommand", autospec=True
|
||||||
) as mock_git_command:
|
) as mock_git_command:
|
||||||
instance = mock_git_command.return_value
|
instance = mock_git_command.return_value
|
||||||
instance.Wait.return_value = 1
|
instance.Wait.return_value = 1
|
||||||
@@ -540,7 +540,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
|||||||
os.mkdir(self._superproject._work_git)
|
os.mkdir(self._superproject._work_git)
|
||||||
with mock.patch.object(self._superproject, "_Init", return_value=True):
|
with mock.patch.object(self._superproject, "_Init", return_value=True):
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"git_superproject.GitCommand", autospec=True
|
"git_command.GitCommand", autospec=True
|
||||||
) as mock_git_command:
|
) as mock_git_command:
|
||||||
with mock.patch.object(
|
with mock.patch.object(
|
||||||
self._superproject, "_GetRef"
|
self._superproject, "_GetRef"
|
||||||
@@ -600,7 +600,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
|||||||
command = mock.MagicMock(stdout="1234\n")
|
command = mock.MagicMock(stdout="1234\n")
|
||||||
command.Wait.return_value = 0
|
command.Wait.return_value = 0
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"git_superproject.GitCommand", return_value=command
|
"git_command.GitCommand", return_value=command
|
||||||
) as git_command:
|
) as git_command:
|
||||||
self.assertEqual("1234", self._superproject._GetRef("HEAD"))
|
self.assertEqual("1234", self._superproject._GetRef("HEAD"))
|
||||||
|
|
||||||
|
|||||||
@@ -420,6 +420,62 @@ class TestXmlManifest:
|
|||||||
)
|
)
|
||||||
manifest.ToXml()
|
manifest.ToXml()
|
||||||
|
|
||||||
|
def test_sync_smartsync(self, repo_client: RepoClient) -> None:
|
||||||
|
"""Check sync-smartsync handling."""
|
||||||
|
manifest = repo_client.get_xml_manifest(
|
||||||
|
"<manifest><default /></manifest>"
|
||||||
|
)
|
||||||
|
assert manifest.default.sync_smartsync is False
|
||||||
|
|
||||||
|
manifest = repo_client.get_xml_manifest(
|
||||||
|
'<manifest><default sync-smartsync="true" /></manifest>'
|
||||||
|
)
|
||||||
|
assert manifest.default.sync_smartsync is True
|
||||||
|
assert (
|
||||||
|
manifest.ToXml().toxml() == '<?xml version="1.0" ?>'
|
||||||
|
'<manifest><default sync-smartsync="true"/></manifest>'
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = repo_client.get_xml_manifest(
|
||||||
|
'<manifest><default sync-smartsync="false" /></manifest>'
|
||||||
|
)
|
||||||
|
assert manifest.default.sync_smartsync is False
|
||||||
|
assert manifest.ToXml().toxml() == '<?xml version="1.0" ?><manifest/>'
|
||||||
|
|
||||||
|
def test_clear_override(self, repo_client: RepoClient) -> None:
|
||||||
|
"""Check ClearOverride reverts to the default manifest."""
|
||||||
|
manifest_xml_fmt = (
|
||||||
|
'<manifest><remote name="r" fetch="." />'
|
||||||
|
'<default remote="r" revision="main" />'
|
||||||
|
'<project name="%s" /></manifest>'
|
||||||
|
)
|
||||||
|
manifest = repo_client.get_xml_manifest(manifest_xml_fmt % "default")
|
||||||
|
(repo_client.manifest_dir / "good.xml").write_text(
|
||||||
|
manifest_xml_fmt % "override", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(repo_client.manifest_dir / "bad.xml").write_text(
|
||||||
|
"<manifest>", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No override in effect: a no-op.
|
||||||
|
manifest.ClearOverride()
|
||||||
|
assert list(manifest.paths) == ["default"]
|
||||||
|
|
||||||
|
manifest.Override("good.xml")
|
||||||
|
assert list(manifest.paths) == ["override"]
|
||||||
|
manifest.ClearOverride()
|
||||||
|
assert not manifest.manifestFileOverrides
|
||||||
|
assert list(manifest.paths) == ["default"]
|
||||||
|
|
||||||
|
# Override() registers the override before parsing it, so a failed
|
||||||
|
# Override() leaves the client pinned until ClearOverride().
|
||||||
|
with pytest.raises(error.ManifestParseError):
|
||||||
|
manifest.Override("bad.xml")
|
||||||
|
with pytest.raises(error.ManifestParseError):
|
||||||
|
manifest.Load()
|
||||||
|
manifest.ClearOverride()
|
||||||
|
assert list(manifest.paths) == ["default"]
|
||||||
|
|
||||||
|
|
||||||
class TestIncludeElement:
|
class TestIncludeElement:
|
||||||
"""Tests for <include>."""
|
"""Tests for <include>."""
|
||||||
|
|||||||
+550
-55
@@ -15,12 +15,13 @@
|
|||||||
"""Unittests for the project.py module."""
|
"""Unittests for the project.py module."""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ import utils_for_test
|
|||||||
import error
|
import error
|
||||||
import git_command
|
import git_command
|
||||||
import git_config
|
import git_config
|
||||||
|
import git_refs
|
||||||
import git_status
|
import git_status
|
||||||
import git_trace2_event_log
|
import git_trace2_event_log
|
||||||
import manifest_xml
|
import manifest_xml
|
||||||
@@ -113,6 +115,19 @@ class ReviewableBranchTests(unittest.TestCase):
|
|||||||
_, kwargs = proj.UploadForReview.call_args
|
_, kwargs = proj.UploadForReview.call_args
|
||||||
self.assertEqual(kwargs.get("git_event_log"), mock_event_log)
|
self.assertEqual(kwargs.get("git_event_log"), mock_event_log)
|
||||||
|
|
||||||
|
def test_upload_for_review_forwards_quiet(self) -> None:
|
||||||
|
"""Check ReviewableBranch.UploadForReview forwards quiet flag."""
|
||||||
|
proj = mock.MagicMock(spec=project.Project)
|
||||||
|
branch = mock.MagicMock()
|
||||||
|
branch.name = "work"
|
||||||
|
rb = project.ReviewableBranch(proj, branch, "main")
|
||||||
|
|
||||||
|
rb.UploadForReview(people=([], []), quiet=True)
|
||||||
|
|
||||||
|
proj.UploadForReview.assert_called_once()
|
||||||
|
_, kwargs = proj.UploadForReview.call_args
|
||||||
|
self.assertTrue(kwargs.get("quiet"))
|
||||||
|
|
||||||
def test_current_comes_from_branch_snapshot(self) -> None:
|
def test_current_comes_from_branch_snapshot(self) -> None:
|
||||||
"""Reviewable branches carry current state into report consumers."""
|
"""Reviewable branches carry current state into report consumers."""
|
||||||
branch = mock.MagicMock()
|
branch = mock.MagicMock()
|
||||||
@@ -122,6 +137,48 @@ class ReviewableBranchTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertTrue(rb.current)
|
self.assertTrue(rb.current)
|
||||||
|
|
||||||
|
def test_ignore_remote_commits(self) -> None:
|
||||||
|
"""Check ignore_remote_commits excludes commits in refs/remotes/*."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
fakeproj = FakeProject(tempdir)
|
||||||
|
|
||||||
|
with open(os.path.join(tempdir, "readme"), "w") as fp:
|
||||||
|
fp.write("init")
|
||||||
|
fakeproj.work_git.add("readme")
|
||||||
|
fakeproj.work_git.commit("-mInit")
|
||||||
|
|
||||||
|
# Create an upstream commit and record it under refs/remotes/.
|
||||||
|
fakeproj.work_git.checkout("-b", "upstream")
|
||||||
|
with open(os.path.join(tempdir, "upstream.txt"), "w") as fp:
|
||||||
|
fp.write("upstream")
|
||||||
|
fakeproj.work_git.add("upstream.txt")
|
||||||
|
fakeproj.work_git.commit("-mUpstream change")
|
||||||
|
fakeproj.work_git.update_ref(
|
||||||
|
"refs/remotes/upstream/main", "refs/heads/upstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge the remote branch into work on top of main.
|
||||||
|
fakeproj.work_git.checkout("-b", "work", "main")
|
||||||
|
fakeproj.work_git.merge(
|
||||||
|
"--no-ff", "-mMerge upstream", "refs/remotes/upstream/main"
|
||||||
|
)
|
||||||
|
|
||||||
|
rb_default = project.ReviewableBranch(
|
||||||
|
fakeproj, fakeproj.config.GetBranch("work"), "main"
|
||||||
|
)
|
||||||
|
self.assertEqual(2, len(rb_default.commits))
|
||||||
|
self.assertEqual(2, len(rb_default.unabbrev_commits))
|
||||||
|
|
||||||
|
rb_ignored = project.ReviewableBranch(
|
||||||
|
fakeproj,
|
||||||
|
fakeproj.config.GetBranch("work"),
|
||||||
|
"main",
|
||||||
|
ignore_remote_commits=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(rb_ignored.commits))
|
||||||
|
self.assertIn("Merge upstream", rb_ignored.commits[0])
|
||||||
|
self.assertEqual(1, len(rb_ignored.unabbrev_commits))
|
||||||
|
|
||||||
|
|
||||||
class ProjectTests(unittest.TestCase):
|
class ProjectTests(unittest.TestCase):
|
||||||
"""Check Project behavior."""
|
"""Check Project behavior."""
|
||||||
@@ -215,7 +272,7 @@ class ProjectTests(unittest.TestCase):
|
|||||||
"""Check UploadForReview emits repo.uploadstate trace2 data events."""
|
"""Check UploadForReview emits repo.uploadstate trace2 data events."""
|
||||||
proj, _ = self._create_project_for_upload_test()
|
proj, _ = self._create_project_for_upload_test()
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
|
with mock.patch("git_command.GitCommand") as mock_git_cmd, mock.patch(
|
||||||
"project.ReviewableBranch"
|
"project.ReviewableBranch"
|
||||||
) as mock_rb_cls:
|
) as mock_rb_cls:
|
||||||
mock_cmd = mock.MagicMock()
|
mock_cmd = mock.MagicMock()
|
||||||
@@ -255,7 +312,7 @@ class ProjectTests(unittest.TestCase):
|
|||||||
"""Check event emission when stderr contains no review URLs."""
|
"""Check event emission when stderr contains no review URLs."""
|
||||||
proj, _ = self._create_project_for_upload_test()
|
proj, _ = self._create_project_for_upload_test()
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
|
with mock.patch("git_command.GitCommand") as mock_git_cmd, mock.patch(
|
||||||
"project.ReviewableBranch"
|
"project.ReviewableBranch"
|
||||||
) as mock_rb_cls:
|
) as mock_rb_cls:
|
||||||
mock_cmd = mock.MagicMock()
|
mock_cmd = mock.MagicMock()
|
||||||
@@ -289,7 +346,7 @@ class ProjectTests(unittest.TestCase):
|
|||||||
"""Check UploadForReview succeeds when git_event_log is None."""
|
"""Check UploadForReview succeeds when git_event_log is None."""
|
||||||
proj, _ = self._create_project_for_upload_test()
|
proj, _ = self._create_project_for_upload_test()
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
|
with mock.patch("git_command.GitCommand") as mock_git_cmd, mock.patch(
|
||||||
"project.ReviewableBranch"
|
"project.ReviewableBranch"
|
||||||
) as mock_rb_cls:
|
) as mock_rb_cls:
|
||||||
mock_cmd = mock.MagicMock()
|
mock_cmd = mock.MagicMock()
|
||||||
@@ -312,7 +369,7 @@ class ProjectTests(unittest.TestCase):
|
|||||||
"""Check exceptions during tracing are caught and do not fail upload."""
|
"""Check exceptions during tracing are caught and do not fail upload."""
|
||||||
proj, _ = self._create_project_for_upload_test()
|
proj, _ = self._create_project_for_upload_test()
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
|
with mock.patch("git_command.GitCommand") as mock_git_cmd, mock.patch(
|
||||||
"project.ReviewableBranch"
|
"project.ReviewableBranch"
|
||||||
) as mock_rb_cls, mock.patch("project.logger") as mock_logger:
|
) as mock_rb_cls, mock.patch("project.logger") as mock_logger:
|
||||||
mock_cmd = mock.MagicMock()
|
mock_cmd = mock.MagicMock()
|
||||||
@@ -337,7 +394,7 @@ class ProjectTests(unittest.TestCase):
|
|||||||
"""Check integration with real git_trace2_event_log.EventLog."""
|
"""Check integration with real git_trace2_event_log.EventLog."""
|
||||||
proj, _ = self._create_project_for_upload_test()
|
proj, _ = self._create_project_for_upload_test()
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd, mock.patch(
|
with mock.patch("git_command.GitCommand") as mock_git_cmd, mock.patch(
|
||||||
"project.ReviewableBranch"
|
"project.ReviewableBranch"
|
||||||
) as mock_rb_cls:
|
) as mock_rb_cls:
|
||||||
mock_cmd = mock.MagicMock()
|
mock_cmd = mock.MagicMock()
|
||||||
@@ -375,6 +432,36 @@ class ProjectTests(unittest.TestCase):
|
|||||||
data_map.get("repo.uploadstate/files"), "file1.py,file2.py"
|
data_map.get("repo.uploadstate/files"), "file1.py,file2.py"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_upload_for_review_progress_flag(self) -> None:
|
||||||
|
"""Check UploadForReview passes --no-progress iff quiet=True."""
|
||||||
|
for quiet in (False, True):
|
||||||
|
with self.subTest(quiet=quiet):
|
||||||
|
proj, _ = self._create_project_for_upload_test()
|
||||||
|
with mock.patch(
|
||||||
|
"git_command.GitCommand"
|
||||||
|
) as mock_git_cmd, mock.patch("project.ReviewableBranch"):
|
||||||
|
mock_cmd = mock.MagicMock()
|
||||||
|
mock_cmd.Wait.return_value = 0
|
||||||
|
mock_cmd.stderr = ""
|
||||||
|
mock_git_cmd.return_value = mock_cmd
|
||||||
|
|
||||||
|
project.Project.UploadForReview(
|
||||||
|
proj,
|
||||||
|
branch="test-branch",
|
||||||
|
dryrun=True,
|
||||||
|
quiet=quiet,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_git_cmd.assert_called_once()
|
||||||
|
_, args, _ = mock_git_cmd.mock_calls[0]
|
||||||
|
cmd = args[1]
|
||||||
|
if quiet:
|
||||||
|
self.assertIn("--no-progress", cmd)
|
||||||
|
else:
|
||||||
|
self.assertNotIn("--no-progress", cmd)
|
||||||
|
self.assertNotIn("--progress", cmd)
|
||||||
|
self.assertNotIn("--quiet", cmd)
|
||||||
|
|
||||||
def test_get_head_revision_id(self):
|
def test_get_head_revision_id(self):
|
||||||
"""Check GetHeadRevisionId behavior."""
|
"""Check GetHeadRevisionId behavior."""
|
||||||
with utils_for_test.TempGitTree() as tempdir:
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
@@ -407,9 +494,9 @@ class ProjectTests(unittest.TestCase):
|
|||||||
command.Wait.return_value = 0
|
command.Wait.return_value = 0
|
||||||
|
|
||||||
with mock.patch.object(
|
with mock.patch.object(
|
||||||
project, "git_require", return_value=True
|
git_command, "git_require", return_value=True
|
||||||
), mock.patch.object(
|
), mock.patch.object(
|
||||||
project, "GitCommand", return_value=command
|
git_command, "GitCommand", return_value=command
|
||||||
) as cmd:
|
) as cmd:
|
||||||
self.assertEqual("1" * 40, git.ResolveCommit("topic"))
|
self.assertEqual("1" * 40, git.ResolveCommit("topic"))
|
||||||
|
|
||||||
@@ -436,7 +523,7 @@ class ProjectTests(unittest.TestCase):
|
|||||||
proj.name = "project"
|
proj.name = "project"
|
||||||
git = project.Project._GitGetByExec(proj, bare=True, gitdir="gitdir")
|
git = project.Project._GitGetByExec(proj, bare=True, gitdir="gitdir")
|
||||||
|
|
||||||
with mock.patch.object(project, "git_require", return_value=False):
|
with mock.patch.object(git_command, "git_require", return_value=False):
|
||||||
with self.assertRaises(error.GitError):
|
with self.assertRaises(error.GitError):
|
||||||
git.ResolveCommit("--not-a-revision")
|
git.ResolveCommit("--not-a-revision")
|
||||||
|
|
||||||
@@ -713,6 +800,18 @@ class ProjectTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
self.assertIsNone(proj._GetStatusSnapshot())
|
self.assertIsNone(proj._GetStatusSnapshot())
|
||||||
|
|
||||||
|
def test_get_status_snapshot_missing_worktree_is_quiet(self) -> None:
|
||||||
|
"""A missing worktree skips git status without a warning."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
proj = _create_mock_project(tempdir)
|
||||||
|
proj.worktree = os.path.join(tempdir, "missing")
|
||||||
|
with mock.patch.object(git_status, "GetStatus") as mock_get_status:
|
||||||
|
with mock.patch.object(project, "logger") as mock_logger:
|
||||||
|
self.assertIsNone(proj._GetStatusSnapshot())
|
||||||
|
|
||||||
|
mock_get_status.assert_not_called()
|
||||||
|
mock_logger.warning.assert_not_called()
|
||||||
|
|
||||||
def test_dirty_or_stash_uses_status_stash_header(self) -> None:
|
def test_dirty_or_stash_uses_status_stash_header(self) -> None:
|
||||||
"""A normal stash is detected without a second Git process."""
|
"""A normal stash is detected without a second Git process."""
|
||||||
with utils_for_test.TempGitTree() as tempdir:
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
@@ -722,7 +821,9 @@ class ProjectTests(unittest.TestCase):
|
|||||||
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
|
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
|
||||||
proj.HasStash = mock.MagicMock()
|
proj.HasStash = mock.MagicMock()
|
||||||
|
|
||||||
with mock.patch.object(project, "git_require", return_value=True):
|
with mock.patch.object(
|
||||||
|
git_command, "git_require", return_value=True
|
||||||
|
):
|
||||||
self.assertTrue(proj._HasDirtyOrStash())
|
self.assertTrue(proj._HasDirtyOrStash())
|
||||||
|
|
||||||
proj.HasStash.assert_not_called()
|
proj.HasStash.assert_not_called()
|
||||||
@@ -735,7 +836,9 @@ class ProjectTests(unittest.TestCase):
|
|||||||
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
|
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
|
||||||
proj.HasStash = mock.MagicMock()
|
proj.HasStash = mock.MagicMock()
|
||||||
|
|
||||||
with mock.patch.object(project, "git_require", return_value=True):
|
with mock.patch.object(
|
||||||
|
git_command, "git_require", return_value=True
|
||||||
|
):
|
||||||
self.assertFalse(proj._HasDirtyOrStash())
|
self.assertFalse(proj._HasDirtyOrStash())
|
||||||
|
|
||||||
proj.HasStash.assert_not_called()
|
proj.HasStash.assert_not_called()
|
||||||
@@ -748,7 +851,9 @@ class ProjectTests(unittest.TestCase):
|
|||||||
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
|
proj._GetStatusSnapshot = mock.MagicMock(return_value=status)
|
||||||
proj.HasStash = mock.MagicMock(return_value=True)
|
proj.HasStash = mock.MagicMock(return_value=True)
|
||||||
|
|
||||||
with mock.patch.object(project, "git_require", return_value=False):
|
with mock.patch.object(
|
||||||
|
git_command, "git_require", return_value=False
|
||||||
|
):
|
||||||
self.assertTrue(proj._HasDirtyOrStash())
|
self.assertTrue(proj._HasDirtyOrStash())
|
||||||
|
|
||||||
proj._GetStatusSnapshot.assert_called_once_with(
|
proj._GetStatusSnapshot.assert_called_once_with(
|
||||||
@@ -756,6 +861,66 @@ class ProjectTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
proj.HasStash.assert_called_once_with()
|
proj.HasStash.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_dirty_and_head_agree_across_status_paths(self) -> None:
|
||||||
|
"""Porcelain v2 and legacy plumbing report the same state."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
proj = _create_mock_project(tempdir)
|
||||||
|
|
||||||
|
def check(expected: Tuple[bool, Optional[str]]) -> None:
|
||||||
|
for use_status in (True, False):
|
||||||
|
with self.subTest(expected=expected, status=use_status):
|
||||||
|
with mock.patch.object(
|
||||||
|
git_command, "git_require", return_value=use_status
|
||||||
|
):
|
||||||
|
self.assertEqual(expected, proj.GetDirtyAndHead())
|
||||||
|
|
||||||
|
check((False, None))
|
||||||
|
Path(tempdir, "untracked").write_text("new")
|
||||||
|
check((True, None))
|
||||||
|
|
||||||
|
Path(tempdir, "tracked").write_text("initial")
|
||||||
|
proj.work_git.add("tracked")
|
||||||
|
proj.work_git.commit("-m", "initial")
|
||||||
|
head = proj.work_git.rev_parse("HEAD")
|
||||||
|
check((True, head))
|
||||||
|
os.remove(os.path.join(tempdir, "untracked"))
|
||||||
|
check((False, head))
|
||||||
|
|
||||||
|
def test_dirty_and_head_fallback_skips_second_snapshot(self) -> None:
|
||||||
|
"""A failed snapshot goes straight to the legacy plumbing."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
proj = _create_mock_project(tempdir)
|
||||||
|
proj._GetStatusSnapshot = mock.MagicMock(return_value=None)
|
||||||
|
proj._IsDirtyLegacy = mock.MagicMock(return_value=False)
|
||||||
|
proj.work_git = mock.MagicMock()
|
||||||
|
proj.work_git.rev_parse.return_value = "head"
|
||||||
|
|
||||||
|
self.assertEqual((False, "head"), proj.GetDirtyAndHead())
|
||||||
|
|
||||||
|
proj._GetStatusSnapshot.assert_called_once_with(
|
||||||
|
untracked_files="normal", branch=True
|
||||||
|
)
|
||||||
|
proj._IsDirtyLegacy.assert_called_once_with(consider_untracked=True)
|
||||||
|
proj.work_git.rev_parse.assert_called_once_with("HEAD")
|
||||||
|
|
||||||
|
def test_dirty_or_stash_fallback_skips_second_snapshot(self) -> None:
|
||||||
|
"""A failed snapshot goes straight to the legacy dirty check."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
proj = _create_mock_project(tempdir)
|
||||||
|
proj._GetStatusSnapshot = mock.MagicMock(return_value=None)
|
||||||
|
proj._IsDirtyLegacy = mock.MagicMock(return_value=False)
|
||||||
|
proj.HasStash = mock.MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
git_command, "git_require", return_value=True
|
||||||
|
):
|
||||||
|
self.assertFalse(proj._HasDirtyOrStash())
|
||||||
|
|
||||||
|
proj._GetStatusSnapshot.assert_called_once_with(
|
||||||
|
untracked_files="normal", show_stash=True
|
||||||
|
)
|
||||||
|
proj._IsDirtyLegacy.assert_called_once_with(consider_untracked=True)
|
||||||
|
|
||||||
def test_old_git_dirty_check_uses_legacy_plumbing(self) -> None:
|
def test_old_git_dirty_check_uses_legacy_plumbing(self) -> None:
|
||||||
"""Git clients before 2.11 retain the existing dirty-check path."""
|
"""Git clients before 2.11 retain the existing dirty-check path."""
|
||||||
with utils_for_test.TempGitTree() as tempdir:
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
@@ -763,7 +928,9 @@ class ProjectTests(unittest.TestCase):
|
|||||||
proj.work_git = mock.MagicMock()
|
proj.work_git = mock.MagicMock()
|
||||||
proj.work_git.DiffZ.side_effect = [{}, {"tracked": mock.sentinel}]
|
proj.work_git.DiffZ.side_effect = [{}, {"tracked": mock.sentinel}]
|
||||||
|
|
||||||
with mock.patch.object(project, "git_require", return_value=False):
|
with mock.patch.object(
|
||||||
|
git_command, "git_require", return_value=False
|
||||||
|
):
|
||||||
self.assertTrue(proj.IsDirty())
|
self.assertTrue(proj.IsDirty())
|
||||||
|
|
||||||
proj.work_git.update_index.assert_called_once_with(
|
proj.work_git.update_index.assert_called_once_with(
|
||||||
@@ -780,7 +947,7 @@ class ProjectTests(unittest.TestCase):
|
|||||||
with utils_for_test.TempGitTree() as tempdir:
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
proj = _create_mock_project(tempdir)
|
proj = _create_mock_project(tempdir)
|
||||||
with mock.patch.object(
|
with mock.patch.object(
|
||||||
project, "GitCommand", autospec=True
|
git_command, "GitCommand", autospec=True
|
||||||
) as run_git:
|
) as run_git:
|
||||||
run_git.return_value.Wait.return_value = 0
|
run_git.return_value.Wait.return_value = 0
|
||||||
proj._FastForward("revision")
|
proj._FastForward("revision")
|
||||||
@@ -1152,7 +1319,9 @@ class ProjectTests(unittest.TestCase):
|
|||||||
) as mock_get_up:
|
) as mock_get_up:
|
||||||
res = proj.GetUploadableBranches("feature")
|
res = proj.GetUploadableBranches("feature")
|
||||||
self.assertEqual(res, [mock_rb])
|
self.assertEqual(res, [mock_rb])
|
||||||
mock_get_up.assert_called_once_with("feature")
|
mock_get_up.assert_called_once_with(
|
||||||
|
"feature", ignore_remote_commits=False
|
||||||
|
)
|
||||||
|
|
||||||
def _get_derived_subproject_url(self, submodule_url):
|
def _get_derived_subproject_url(self, submodule_url):
|
||||||
with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
|
with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
|
||||||
@@ -1268,7 +1437,7 @@ class ProjectTests(unittest.TestCase):
|
|||||||
hex41 = "a" * 41
|
hex41 = "a" * 41
|
||||||
proj.SetRevision(hex41)
|
proj.SetRevision(hex41)
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git:
|
with mock.patch("git_command.GitCommand") as mock_git:
|
||||||
mock_cmd = mock.MagicMock()
|
mock_cmd = mock.MagicMock()
|
||||||
mock_cmd.Wait.return_value = 0
|
mock_cmd.Wait.return_value = 0
|
||||||
mock_git.return_value = mock_cmd
|
mock_git.return_value = mock_cmd
|
||||||
@@ -1751,6 +1920,9 @@ class ManifestPropertiesFetchedCorrectly(unittest.TestCase):
|
|||||||
fakeproj.config.SetBoolean("repo.superproject", False)
|
fakeproj.config.SetBoolean("repo.superproject", False)
|
||||||
self.assertFalse(fakeproj.use_superproject)
|
self.assertFalse(fakeproj.use_superproject)
|
||||||
|
|
||||||
|
fakeproj.config.SetBoolean("repo.smartsync", False)
|
||||||
|
self.assertFalse(fakeproj.smart_sync)
|
||||||
|
|
||||||
fakeproj.config.SetBoolean("repo.partialclone", False)
|
fakeproj.config.SetBoolean("repo.partialclone", False)
|
||||||
self.assertFalse(fakeproj.partial_clone)
|
self.assertFalse(fakeproj.partial_clone)
|
||||||
|
|
||||||
@@ -1968,8 +2140,8 @@ def test_default_branch_fallback(
|
|||||||
project._DefaultBranchFallback.cache_clear()
|
project._DefaultBranchFallback.cache_clear()
|
||||||
try:
|
try:
|
||||||
with mock.patch.object(
|
with mock.patch.object(
|
||||||
project, "git_require", return_value=supports_git_var
|
git_command, "git_require", return_value=supports_git_var
|
||||||
), mock.patch.object(project, "GitCommand", FakeGitCommand):
|
), mock.patch.object(git_command, "GitCommand", FakeGitCommand):
|
||||||
assert project._DefaultBranchFallback() == expected_ref
|
assert project._DefaultBranchFallback() == expected_ref
|
||||||
assert seen == expected_calls
|
assert seen == expected_calls
|
||||||
finally:
|
finally:
|
||||||
@@ -1991,6 +2163,87 @@ def test_metaproject_has_changes_bounds_revision_walk() -> None:
|
|||||||
meta._revlist.assert_called_once_with("-1", "^HEAD", "remote")
|
meta._revlist.assert_called_once_with("-1", "^HEAD", "remote")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"project_cls,revision,expected",
|
||||||
|
(
|
||||||
|
(project.RepoProject, "refs/remotes/origin/current", False),
|
||||||
|
(project.RepoProject, "refs/remotes/origin/newer", True),
|
||||||
|
(project.RepoProject, "refs/remotes/origin/divergent", True),
|
||||||
|
# A release rollback rewinds stable, and repo itself has to follow.
|
||||||
|
(project.RepoProject, "refs/remotes/origin/older", True),
|
||||||
|
# --repo-rev can pin repo to an annotated tag, which must be peeled.
|
||||||
|
(project.RepoProject, "refs/tags/current-tag", False),
|
||||||
|
# Other MetaProjects only update when the remote has new commits.
|
||||||
|
(project.ManifestProject, "refs/remotes/origin/current", False),
|
||||||
|
(project.ManifestProject, "refs/remotes/origin/newer", True),
|
||||||
|
(project.ManifestProject, "refs/remotes/origin/divergent", True),
|
||||||
|
(project.ManifestProject, "refs/remotes/origin/older", False),
|
||||||
|
),
|
||||||
|
ids=lambda v: v.__name__ if isinstance(v, type) else None,
|
||||||
|
)
|
||||||
|
def test_metaproject_has_changes(
|
||||||
|
project_cls: Type[project.MetaProject], revision: str, expected: bool
|
||||||
|
) -> None:
|
||||||
|
"""HasChanges compares the tracked revision with the checked out one."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
|
||||||
|
def git(*args: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", tempdir, *args],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
def commit(msg: str) -> str:
|
||||||
|
git("commit", "-q", "--allow-empty", "-m", msg)
|
||||||
|
return git("rev-parse", "HEAD")
|
||||||
|
|
||||||
|
# older -> current -> newer, plus divergent branching off older.
|
||||||
|
revs = {name: commit(name) for name in ("older", "current", "newer")}
|
||||||
|
git("checkout", "-q", "--detach", revs["older"])
|
||||||
|
revs["divergent"] = commit("divergent")
|
||||||
|
git("checkout", "-q", "main")
|
||||||
|
git("reset", "-q", "--hard", revs["current"])
|
||||||
|
for name, rev in revs.items():
|
||||||
|
git("update-ref", f"refs/remotes/origin/{name}", rev)
|
||||||
|
git("tag", "-a", "-m", "current-tag", "current-tag", revs["current"])
|
||||||
|
|
||||||
|
proj = _create_meta_project(tempdir, project_cls)
|
||||||
|
proj.revisionExpr = revision
|
||||||
|
|
||||||
|
assert proj.HasChanges is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"project_cls,expected_sleep",
|
||||||
|
(
|
||||||
|
(project.ManifestProject, 4.0),
|
||||||
|
# Repo waits longer for a briefly deleted stable branch to come back.
|
||||||
|
(project.RepoProject, 10.0),
|
||||||
|
),
|
||||||
|
ids=lambda v: v.__name__ if isinstance(v, type) else None,
|
||||||
|
)
|
||||||
|
def test_remote_fetch_retry_sleep(
|
||||||
|
project_cls: Type[project.MetaProject], expected_sleep: float
|
||||||
|
) -> None:
|
||||||
|
"""A failed fetch waits RETRY_SLEEP_INITIAL_SEC before retrying."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
proj = _create_meta_project(tempdir, project_cls)
|
||||||
|
proj.config.GetRemote("origin").ResetFetch()
|
||||||
|
proj.revisionExpr = "refs/heads/stable"
|
||||||
|
with mock.patch.object(git_command, "GitCommand") as mock_git:
|
||||||
|
# What git does while stable is deleted.
|
||||||
|
mock_git.return_value.Wait.return_value = 128
|
||||||
|
mock_git.return_value.stdout = (
|
||||||
|
"fatal: couldn't find remote ref refs/heads/stable\n"
|
||||||
|
)
|
||||||
|
with mock.patch.object(project.time, "sleep") as mock_sleep:
|
||||||
|
assert not proj._RemoteFetch(output_redir=io.StringIO())
|
||||||
|
|
||||||
|
assert mock_sleep.call_args_list == [mock.call(expected_sleep)]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"state,expect_failure",
|
"state,expect_failure",
|
||||||
[
|
[
|
||||||
@@ -2198,7 +2451,7 @@ class StatelessSyncTests(unittest.TestCase):
|
|||||||
proj.work_git = mock.MagicMock()
|
proj.work_git = mock.MagicMock()
|
||||||
syncbuf = project.SyncBuffer(proj.config)
|
syncbuf = project.SyncBuffer(proj.config)
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||||
mock_cmd_instance = mock.MagicMock()
|
mock_cmd_instance = mock.MagicMock()
|
||||||
mock_cmd_instance.Wait.return_value = 0
|
mock_cmd_instance.Wait.return_value = 0
|
||||||
mock_git_cmd.return_value = mock_cmd_instance
|
mock_git_cmd.return_value = mock_cmd_instance
|
||||||
@@ -2319,7 +2572,7 @@ class StatelessSyncTests(unittest.TestCase):
|
|||||||
proj.bare_ref.head = "refs/heads/topic"
|
proj.bare_ref.head = "refs/heads/topic"
|
||||||
proj.work_git = mock.MagicMock()
|
proj.work_git = mock.MagicMock()
|
||||||
proj.work_git.GetHead.return_value = "refs/heads/topic"
|
proj.work_git.GetHead.return_value = "refs/heads/topic"
|
||||||
proj.work_git.merge_base.side_effect = project.GitError("diverged")
|
proj.work_git.merge_base.side_effect = error.GitError("diverged")
|
||||||
branch = mock.MagicMock()
|
branch = mock.MagicMock()
|
||||||
branch.name = "topic"
|
branch.name = "topic"
|
||||||
branch.LocalMerge = "refs/remotes/origin/main"
|
branch.LocalMerge = "refs/remotes/origin/main"
|
||||||
@@ -2350,6 +2603,33 @@ class StatelessSyncTests(unittest.TestCase):
|
|||||||
self.assertTrue(res.success)
|
self.assertTrue(res.success)
|
||||||
self.assertFalse(getattr(proj, "stateless_prune_needed", False))
|
self.assertFalse(getattr(proj, "stateless_prune_needed", False))
|
||||||
|
|
||||||
|
def test_sync_network_half_stateless_skips_without_worktree(self) -> None:
|
||||||
|
"""Test stateless sync doesn't prune a project with no worktree."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
proj = self._get_project(tempdir)
|
||||||
|
proj.worktree = os.path.join(tempdir, "missing")
|
||||||
|
proj._HasDirtyOrStash = mock.MagicMock(return_value=False)
|
||||||
|
|
||||||
|
res = proj.Sync_NetworkHalf()
|
||||||
|
|
||||||
|
self.assertTrue(res.success)
|
||||||
|
self.assertFalse(proj.stateless_prune_needed)
|
||||||
|
proj._LsRemote.assert_not_called()
|
||||||
|
proj._HasDirtyOrStash.assert_not_called()
|
||||||
|
|
||||||
|
def test_ls_remote_runs_without_worktree(self) -> None:
|
||||||
|
"""Test ls-remote only needs the gitdir's remote config."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
proj = _create_mock_project(tempdir)
|
||||||
|
proj.work_git.commit("--allow-empty", "-m", "initial")
|
||||||
|
proj.work_git.config("remote.origin.url", tempdir)
|
||||||
|
head = proj.work_git.rev_parse("HEAD")
|
||||||
|
proj.worktree = os.path.join(tempdir, "missing")
|
||||||
|
# _create_mock_project() stubs this out.
|
||||||
|
del proj._LsRemote
|
||||||
|
|
||||||
|
self.assertEqual(f"{head}\tHEAD\n", proj._LsRemote("HEAD"))
|
||||||
|
|
||||||
def test_sync_network_half_stateless_skips_if_local_commits(self):
|
def test_sync_network_half_stateless_skips_if_local_commits(self):
|
||||||
"""Test stateless sync skips if there are local-only commits."""
|
"""Test stateless sync skips if there are local-only commits."""
|
||||||
with utils_for_test.TempGitTree() as tempdir:
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
@@ -2520,7 +2800,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
|||||||
if os.path.exists(shallow_path):
|
if os.path.exists(shallow_path):
|
||||||
os.unlink(shallow_path)
|
os.unlink(shallow_path)
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||||
mock_cmd_instance = mock.MagicMock()
|
mock_cmd_instance = mock.MagicMock()
|
||||||
mock_cmd_instance.Wait.return_value = 0
|
mock_cmd_instance.Wait.return_value = 0
|
||||||
mock_git_cmd.return_value = mock_cmd_instance
|
mock_git_cmd.return_value = mock_cmd_instance
|
||||||
@@ -2543,7 +2823,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
|||||||
with open(os.path.join(proj.gitdir, "shallow"), "w") as f:
|
with open(os.path.join(proj.gitdir, "shallow"), "w") as f:
|
||||||
f.write("")
|
f.write("")
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||||
res = proj._RemoteFetch(
|
res = proj._RemoteFetch(
|
||||||
current_branch_only=True,
|
current_branch_only=True,
|
||||||
depth=1,
|
depth=1,
|
||||||
@@ -2566,7 +2846,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
|||||||
with open(os.path.join(other.gitdir, "shallow"), "w") as f:
|
with open(os.path.join(other.gitdir, "shallow"), "w") as f:
|
||||||
f.write("")
|
f.write("")
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||||
mock_cmd_instance = mock.MagicMock()
|
mock_cmd_instance = mock.MagicMock()
|
||||||
mock_cmd_instance.Wait.return_value = 0
|
mock_cmd_instance.Wait.return_value = 0
|
||||||
mock_git_cmd.return_value = mock_cmd_instance
|
mock_git_cmd.return_value = mock_cmd_instance
|
||||||
@@ -2595,7 +2875,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
|||||||
with open(os.path.join(other.gitdir, "shallow"), "w") as f:
|
with open(os.path.join(other.gitdir, "shallow"), "w") as f:
|
||||||
f.write("")
|
f.write("")
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||||
res = proj._RemoteFetch(
|
res = proj._RemoteFetch(
|
||||||
current_branch_only=True,
|
current_branch_only=True,
|
||||||
depth=1,
|
depth=1,
|
||||||
@@ -2789,7 +3069,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
|||||||
mock_remote.PreConnectFetch.return_value = True
|
mock_remote.PreConnectFetch.return_value = True
|
||||||
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||||
mock_cmd_instance = mock.MagicMock()
|
mock_cmd_instance = mock.MagicMock()
|
||||||
mock_cmd_instance.Wait.return_value = 0
|
mock_cmd_instance.Wait.return_value = 0
|
||||||
mock_git_cmd.return_value = mock_cmd_instance
|
mock_git_cmd.return_value = mock_cmd_instance
|
||||||
@@ -2828,7 +3108,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
|||||||
mock_remote.PreConnectFetch.return_value = True
|
mock_remote.PreConnectFetch.return_value = True
|
||||||
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||||
mock_cmd_instance = mock.MagicMock()
|
mock_cmd_instance = mock.MagicMock()
|
||||||
mock_cmd_instance.Wait.return_value = 0
|
mock_cmd_instance.Wait.return_value = 0
|
||||||
mock_git_cmd.return_value = mock_cmd_instance
|
mock_git_cmd.return_value = mock_cmd_instance
|
||||||
@@ -2870,7 +3150,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
|||||||
mock_remote.PreConnectFetch.return_value = True
|
mock_remote.PreConnectFetch.return_value = True
|
||||||
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||||
mock_cmd_instance = mock.MagicMock()
|
mock_cmd_instance = mock.MagicMock()
|
||||||
mock_cmd_instance.Wait.return_value = 0
|
mock_cmd_instance.Wait.return_value = 0
|
||||||
mock_git_cmd.return_value = mock_cmd_instance
|
mock_git_cmd.return_value = mock_cmd_instance
|
||||||
@@ -2918,7 +3198,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
|||||||
mock_remote.PreConnectFetch.return_value = True
|
mock_remote.PreConnectFetch.return_value = True
|
||||||
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||||
|
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||||
mock_cmd_instance = mock.MagicMock()
|
mock_cmd_instance = mock.MagicMock()
|
||||||
mock_cmd_instance.Wait.return_value = 0
|
mock_cmd_instance.Wait.return_value = 0
|
||||||
mock_git_cmd.return_value = mock_cmd_instance
|
mock_git_cmd.return_value = mock_cmd_instance
|
||||||
@@ -3020,7 +3300,7 @@ class SyncOptimizationTests(unittest.TestCase):
|
|||||||
return inst
|
return inst
|
||||||
|
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"project.GitCommand", side_effect=_fake_git_command
|
"git_command.GitCommand", side_effect=_fake_git_command
|
||||||
):
|
):
|
||||||
res = proj._RemoteFetch(current_branch_only=True)
|
res = proj._RemoteFetch(current_branch_only=True)
|
||||||
|
|
||||||
@@ -3128,8 +3408,11 @@ class GetEnvVarsTests(unittest.TestCase):
|
|||||||
self.assertEqual(env["REPO_LREV"], "")
|
self.assertEqual(env["REPO_LREV"], "")
|
||||||
|
|
||||||
|
|
||||||
def _create_manifest_project(tempdir: str) -> project.ManifestProject:
|
def _create_meta_project(
|
||||||
"""Return a ManifestProject for a new .repo/ under |tempdir|."""
|
tempdir: str,
|
||||||
|
project_cls: Type[project.MetaProject] = project.ManifestProject,
|
||||||
|
) -> project.MetaProject:
|
||||||
|
"""Return a |project_cls| for a new .repo/ under |tempdir|."""
|
||||||
repodir = os.path.join(tempdir, ".repo")
|
repodir = os.path.join(tempdir, ".repo")
|
||||||
manifest_dir = os.path.join(repodir, "manifests")
|
manifest_dir = os.path.join(repodir, "manifests")
|
||||||
manifest_file = os.path.join(repodir, manifest_xml.MANIFEST_FILE_NAME)
|
manifest_file = os.path.join(repodir, manifest_xml.MANIFEST_FILE_NAME)
|
||||||
@@ -3137,7 +3420,7 @@ def _create_manifest_project(tempdir: str) -> project.ManifestProject:
|
|||||||
os.mkdir(manifest_dir)
|
os.mkdir(manifest_dir)
|
||||||
manifest = manifest_xml.XmlManifest(repodir, manifest_file)
|
manifest = manifest_xml.XmlManifest(repodir, manifest_file)
|
||||||
|
|
||||||
return project.ManifestProject(
|
return project_cls(
|
||||||
manifest, "test/manifest", os.path.join(tempdir, ".git"), tempdir
|
manifest, "test/manifest", os.path.join(tempdir, ".git"), tempdir
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3146,7 +3429,7 @@ class FetchCmdTests(unittest.TestCase):
|
|||||||
"""Tests for fetch_cmd feature."""
|
"""Tests for fetch_cmd feature."""
|
||||||
|
|
||||||
def setUpManifest(self, tempdir):
|
def setUpManifest(self, tempdir):
|
||||||
return _create_manifest_project(tempdir)
|
return _create_meta_project(tempdir)
|
||||||
|
|
||||||
def _get_project(self, tempdir):
|
def _get_project(self, tempdir):
|
||||||
proj = _create_mock_project(
|
proj = _create_mock_project(
|
||||||
@@ -3155,12 +3438,13 @@ class FetchCmdTests(unittest.TestCase):
|
|||||||
proj.GetRevisionId = mock.MagicMock(return_value="1234abcd")
|
proj.GetRevisionId = mock.MagicMock(return_value="1234abcd")
|
||||||
return proj
|
return proj
|
||||||
|
|
||||||
def test_fetch_cmd_execution(self):
|
def test_fetch_cmd_execution_unpinned_branch(self) -> None:
|
||||||
"""Test that fetch_cmd is executed with correct environment."""
|
"""Test fetch_cmd with an unpinned branch does not call ls-remote."""
|
||||||
with utils_for_test.TempGitTree() as tempdir:
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
proj = self._get_project(tempdir)
|
proj = self._get_project(tempdir)
|
||||||
|
proj.revisionId = None
|
||||||
proj.bare_git.rev_parse.return_value = "1234abcd"
|
proj.revisionExpr = "main"
|
||||||
|
proj.bare_git.ResolveCommit.return_value = "1234abcd"
|
||||||
mock_remote = mock.MagicMock()
|
mock_remote = mock.MagicMock()
|
||||||
mock_remote.ToLocal.return_value = "refs/remotes/origin/main"
|
mock_remote.ToLocal.return_value = "refs/remotes/origin/main"
|
||||||
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||||
@@ -3171,15 +3455,115 @@ class FetchCmdTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertTrue(res)
|
self.assertTrue(res)
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
|
proj._LsRemote.assert_not_called()
|
||||||
args, kwargs = mock_run.call_args
|
args, kwargs = mock_run.call_args
|
||||||
self.assertEqual(args[0], "echo hi")
|
self.assertEqual(args[0], "echo hi")
|
||||||
self.assertEqual(kwargs["shell"], True)
|
self.assertEqual(kwargs["shell"], True)
|
||||||
self.assertEqual(kwargs["cwd"], tempdir)
|
self.assertEqual(kwargs["cwd"], tempdir)
|
||||||
self.assertEqual(kwargs["env"]["REPO_TREV"], "1234abcd")
|
self.assertEqual(kwargs["env"]["REPO_TREV"], "")
|
||||||
|
self.assertEqual(kwargs["env"]["REPO_RREV"], "main")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
kwargs["env"]["REPO_PROJECT_FETCH_URL"],
|
kwargs["env"]["REPO_PROJECT_FETCH_URL"],
|
||||||
"http://example.com/repo",
|
"http://example.com/repo",
|
||||||
)
|
)
|
||||||
|
proj.bare_git.ResolveCommit.assert_any_call(
|
||||||
|
"refs/remotes/origin/main"
|
||||||
|
)
|
||||||
|
proj.bare_git.ResolveCommit.assert_any_call("FETCH_HEAD")
|
||||||
|
|
||||||
|
def test_fetch_cmd_execution_pinned_revision_id(self) -> None:
|
||||||
|
"""Test fetch_cmd populates REPO_TREV when revisionId is set."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
sha = "0123456789abcdef0123456789abcdef01234567"
|
||||||
|
proj = self._get_project(tempdir)
|
||||||
|
proj.revisionId = sha
|
||||||
|
proj.revisionExpr = "main"
|
||||||
|
proj.bare_git.ResolveCommit.return_value = sha
|
||||||
|
mock_remote = mock.MagicMock()
|
||||||
|
mock_remote.ToLocal.return_value = "refs/remotes/origin/main"
|
||||||
|
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||||
|
|
||||||
|
with mock.patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = mock.MagicMock(returncode=0, stderr="")
|
||||||
|
res = proj._CustomFetch()
|
||||||
|
|
||||||
|
self.assertTrue(res)
|
||||||
|
proj._LsRemote.assert_not_called()
|
||||||
|
_, kwargs = mock_run.call_args
|
||||||
|
self.assertEqual(kwargs["env"]["REPO_TREV"], sha)
|
||||||
|
|
||||||
|
def test_fetch_cmd_execution_pinned_revision_expr_sha(self) -> None:
|
||||||
|
"""Test fetch_cmd populates REPO_TREV when revisionExpr is a SHA."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
sha = "0123456789abcdef0123456789abcdef01234567"
|
||||||
|
proj = self._get_project(tempdir)
|
||||||
|
proj.revisionId = None
|
||||||
|
proj.revisionExpr = sha
|
||||||
|
proj.bare_git.ResolveCommit.return_value = sha
|
||||||
|
proj.GetRemote = mock.MagicMock()
|
||||||
|
|
||||||
|
with mock.patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = mock.MagicMock(returncode=0, stderr="")
|
||||||
|
res = proj._CustomFetch()
|
||||||
|
|
||||||
|
self.assertTrue(res)
|
||||||
|
proj._LsRemote.assert_not_called()
|
||||||
|
proj.GetRemote.assert_not_called()
|
||||||
|
_, kwargs = mock_run.call_args
|
||||||
|
self.assertEqual(kwargs["env"]["REPO_TREV"], sha)
|
||||||
|
proj.bare_git.ResolveCommit.assert_called_once_with("FETCH_HEAD")
|
||||||
|
|
||||||
|
def test_fetch_cmd_postcondition_fails_when_fetch_head_mismatches_ref(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""Test _CustomFetch fails when FETCH_HEAD disagrees with the ref."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
proj = self._get_project(tempdir)
|
||||||
|
proj.revisionId = None
|
||||||
|
proj.revisionExpr = "main"
|
||||||
|
proj.bare_git.ResolveCommit.side_effect = [
|
||||||
|
"11111111" * 5,
|
||||||
|
"22222222" * 5,
|
||||||
|
]
|
||||||
|
mock_remote = mock.MagicMock()
|
||||||
|
mock_remote.ToLocal.return_value = "refs/remotes/origin/main"
|
||||||
|
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||||
|
|
||||||
|
with mock.patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = mock.MagicMock(returncode=0, stderr="")
|
||||||
|
self.assertFalse(proj._CustomFetch())
|
||||||
|
|
||||||
|
def test_fetch_cmd_postcondition_fails_when_ref_mismatches_pinned_trev(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""Test _CustomFetch fails when the ref disagrees with REPO_TREV."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
proj = self._get_project(tempdir)
|
||||||
|
proj.revisionId = "11111111" * 5
|
||||||
|
proj.revisionExpr = "main"
|
||||||
|
proj.bare_git.ResolveCommit.return_value = "22222222" * 5
|
||||||
|
mock_remote = mock.MagicMock()
|
||||||
|
mock_remote.ToLocal.return_value = "refs/remotes/origin/main"
|
||||||
|
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||||
|
|
||||||
|
with mock.patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = mock.MagicMock(returncode=0, stderr="")
|
||||||
|
self.assertFalse(proj._CustomFetch())
|
||||||
|
|
||||||
|
def test_fetch_cmd_postcondition_fails_when_ref_missing(self) -> None:
|
||||||
|
"""Test _CustomFetch fails when tracking ref or FETCH_HEAD fails."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
proj = self._get_project(tempdir)
|
||||||
|
proj.revisionId = None
|
||||||
|
proj.revisionExpr = "main"
|
||||||
|
proj.bare_git.ResolveCommit.side_effect = error.GitError("missing")
|
||||||
|
mock_remote = mock.MagicMock()
|
||||||
|
mock_remote.ToLocal.return_value = "refs/remotes/origin/main"
|
||||||
|
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||||
|
|
||||||
|
with mock.patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = mock.MagicMock(returncode=0, stderr="")
|
||||||
|
self.assertFalse(proj._CustomFetch())
|
||||||
|
|
||||||
def test_sync_fetch_cmd_requires_use_local_gitdirs(self):
|
def test_sync_fetch_cmd_requires_use_local_gitdirs(self):
|
||||||
"""Test that fetch_cmd requires use_local_gitdirs."""
|
"""Test that fetch_cmd requires use_local_gitdirs."""
|
||||||
@@ -3200,6 +3584,31 @@ class FetchCmdTests(unittest.TestCase):
|
|||||||
result = fakeproj.Sync(use_local_gitdirs=False)
|
result = fakeproj.Sync(use_local_gitdirs=False)
|
||||||
self.assertFalse(result)
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_sync_network_half_skips_clone_bundle_with_fetch_cmd(self) -> None:
|
||||||
|
"""Test Sync_NetworkHalf skips clone.bundle when fetchcmd is set."""
|
||||||
|
with tempfile.TemporaryDirectory() as tempdir:
|
||||||
|
worktree = os.path.join(tempdir, "new-proj")
|
||||||
|
gitdir = os.path.join(worktree, ".git")
|
||||||
|
proj = _create_mock_project(
|
||||||
|
worktree,
|
||||||
|
use_local_gitdirs=True,
|
||||||
|
fetch_cmd="echo hi",
|
||||||
|
gitdir=gitdir,
|
||||||
|
objdir=gitdir,
|
||||||
|
)
|
||||||
|
proj._InitGitDir = mock.MagicMock()
|
||||||
|
proj._InitRemote = mock.MagicMock()
|
||||||
|
proj._InitMRef = mock.MagicMock()
|
||||||
|
proj._CustomFetch = mock.MagicMock(return_value=True)
|
||||||
|
proj._ApplyCloneBundle = mock.MagicMock(return_value=True)
|
||||||
|
|
||||||
|
res = proj.Sync_NetworkHalf(clone_bundle=True, is_new=True)
|
||||||
|
|
||||||
|
self.assertIsNone(res.error)
|
||||||
|
self.assertTrue(res.remote_fetched)
|
||||||
|
proj._ApplyCloneBundle.assert_not_called()
|
||||||
|
proj._CustomFetch.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class ReprojectCmdTests(unittest.TestCase):
|
class ReprojectCmdTests(unittest.TestCase):
|
||||||
"""Tests for the repo.reprojectcmd feature."""
|
"""Tests for the repo.reprojectcmd feature."""
|
||||||
@@ -3267,7 +3676,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
) -> mock.MagicMock:
|
) -> mock.MagicMock:
|
||||||
"""Run _Reproject against mocked Git; return the subprocess mock."""
|
"""Run _Reproject against mocked Git; return the subprocess mock."""
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"project.GitCommand", side_effect=self._git_command(**outputs)
|
"git_command.GitCommand", side_effect=self._git_command(**outputs)
|
||||||
), mock.patch("subprocess.run") as mock_run:
|
), mock.patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
||||||
proj._Reproject(self.REVID)
|
proj._Reproject(self.REVID)
|
||||||
@@ -3278,7 +3687,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
with utils_for_test.TempGitTree() as tempdir:
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
proj = self._get_project(tempdir)
|
proj = self._get_project(tempdir)
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"project.GitCommand", side_effect=self._git_command()
|
"git_command.GitCommand", side_effect=self._git_command()
|
||||||
), mock.patch("subprocess.run") as mock_run:
|
), mock.patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
||||||
proj._Reproject(self.REVID)
|
proj._Reproject(self.REVID)
|
||||||
@@ -3338,7 +3747,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
with utils_for_test.TempGitTree() as tempdir:
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
proj = self._get_project(tempdir)
|
proj = self._get_project(tempdir)
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"project.GitCommand", side_effect=self._git_command()
|
"git_command.GitCommand", side_effect=self._git_command()
|
||||||
), mock.patch("subprocess.run") as mock_run:
|
), mock.patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = mock.MagicMock(
|
mock_run.return_value = mock.MagicMock(
|
||||||
returncode=3, stdout="disk on fire\n"
|
returncode=3, stdout="disk on fire\n"
|
||||||
@@ -3354,7 +3763,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
proj = self._get_project(tempdir)
|
proj = self._get_project(tempdir)
|
||||||
proj.work_git.GetHead.side_effect = [self.HEAD_ID, self.REVID]
|
proj.work_git.GetHead.side_effect = [self.HEAD_ID, self.REVID]
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"project.GitCommand", side_effect=self._git_command()
|
"git_command.GitCommand", side_effect=self._git_command()
|
||||||
), mock.patch("subprocess.run") as mock_run:
|
), mock.patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
||||||
with self.assertRaises(project.LocalSyncFail) as e:
|
with self.assertRaises(project.LocalSyncFail) as e:
|
||||||
@@ -3372,7 +3781,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
self.REVID, # HEAD after command
|
self.REVID, # HEAD after command
|
||||||
]
|
]
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"project.GitCommand", side_effect=self._git_command()
|
"git_command.GitCommand", side_effect=self._git_command()
|
||||||
), mock.patch("subprocess.run") as mock_run:
|
), mock.patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
||||||
with self.assertRaises(project.LocalSyncFail) as e:
|
with self.assertRaises(project.LocalSyncFail) as e:
|
||||||
@@ -3384,7 +3793,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
with utils_for_test.TempGitTree() as tempdir:
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
proj = self._get_project(tempdir)
|
proj = self._get_project(tempdir)
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"project.GitCommand",
|
"git_command.GitCommand",
|
||||||
side_effect=self._git_command(diff="lib/a.c\n"),
|
side_effect=self._git_command(diff="lib/a.c\n"),
|
||||||
), mock.patch("subprocess.run") as mock_run:
|
), mock.patch("subprocess.run") as mock_run:
|
||||||
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
mock_run.return_value = mock.MagicMock(returncode=0, stdout="")
|
||||||
@@ -3428,8 +3837,10 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
|
|
||||||
proj.bare_ref = mock.MagicMock()
|
proj.bare_ref = mock.MagicMock()
|
||||||
if branch:
|
if branch:
|
||||||
proj.bare_ref.head = project.R_HEADS + branch
|
proj.bare_ref.head = git_refs.R_HEADS + branch
|
||||||
proj.bare_ref.all = {project.R_HEADS + branch: head} if head else {}
|
proj.bare_ref.all = (
|
||||||
|
{git_refs.R_HEADS + branch: head} if head else {}
|
||||||
|
)
|
||||||
# A branch that does not track upstream; tests that need one
|
# A branch that does not track upstream; tests that need one
|
||||||
# tracking upstream replace this with _tracking_branch().
|
# tracking upstream replace this with _tracking_branch().
|
||||||
proj.GetBranch = mock.MagicMock(
|
proj.GetBranch = mock.MagicMock(
|
||||||
@@ -3442,7 +3853,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
def _revlist(*args: Any, **kwargs: Any) -> List[str]:
|
def _revlist(*args: Any, **kwargs: Any) -> List[str]:
|
||||||
if kwargs.get("format"):
|
if kwargs.get("format"):
|
||||||
return list(local_changes)
|
return list(local_changes)
|
||||||
if project.not_rev(project.HEAD) in args:
|
if project.not_rev(git_refs.HEAD) in args:
|
||||||
if "--count" in args:
|
if "--count" in args:
|
||||||
return [str(len(upstream_gain))]
|
return [str(len(upstream_gain))]
|
||||||
return list(upstream_gain)
|
return list(upstream_gain)
|
||||||
@@ -3545,7 +3956,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
self.assertTrue(syncbuf.Finish())
|
self.assertTrue(syncbuf.Finish())
|
||||||
proj._Reproject.assert_called_once_with(self.REVID, verbose=False)
|
proj._Reproject.assert_called_once_with(self.REVID, verbose=False)
|
||||||
proj.work_git.UpdateRef.assert_called_once_with(
|
proj.work_git.UpdateRef.assert_called_once_with(
|
||||||
project.HEAD,
|
git_refs.HEAD,
|
||||||
self.REVID,
|
self.REVID,
|
||||||
old=self.HEAD_ID,
|
old=self.HEAD_ID,
|
||||||
message=f"merge {self.REVID}: Fast-forward",
|
message=f"merge {self.REVID}: Fast-forward",
|
||||||
@@ -3592,7 +4003,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
self.assertTrue(syncbuf.Finish())
|
self.assertTrue(syncbuf.Finish())
|
||||||
proj._Reproject.assert_called_once_with(self.REVID, verbose=False)
|
proj._Reproject.assert_called_once_with(self.REVID, verbose=False)
|
||||||
proj.work_git.UpdateRef.assert_called_once_with(
|
proj.work_git.UpdateRef.assert_called_once_with(
|
||||||
project.HEAD,
|
git_refs.HEAD,
|
||||||
self.REVID,
|
self.REVID,
|
||||||
old=self.HEAD_ID,
|
old=self.HEAD_ID,
|
||||||
message=f"reset: moving to {self.REVID}",
|
message=f"reset: moving to {self.REVID}",
|
||||||
@@ -3653,7 +4064,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
def test_metaproject_never_uses_the_command(self) -> None:
|
def test_metaproject_never_uses_the_command(self) -> None:
|
||||||
"""Test .repo/manifests and .repo/repo are checked out by Git."""
|
"""Test .repo/manifests and .repo/repo are checked out by Git."""
|
||||||
with utils_for_test.TempGitTree() as tempdir:
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
fakeproj = _create_manifest_project(tempdir)
|
fakeproj = _create_meta_project(tempdir)
|
||||||
fakeproj.config.SetString("repo.reprojectcmd", "echo hi")
|
fakeproj.config.SetString("repo.reprojectcmd", "echo hi")
|
||||||
fakeproj.config.SetBoolean("repo.uselocalgitdirs", True)
|
fakeproj.config.SetBoolean("repo.uselocalgitdirs", True)
|
||||||
self.assertFalse(fakeproj.UseReprojectCmd)
|
self.assertFalse(fakeproj.UseReprojectCmd)
|
||||||
@@ -3661,7 +4072,7 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
def test_sync_reproject_cmd_requires_use_local_gitdirs(self) -> None:
|
def test_sync_reproject_cmd_requires_use_local_gitdirs(self) -> None:
|
||||||
"""Test that repo.reprojectcmd requires repo.uselocalgitdirs."""
|
"""Test that repo.reprojectcmd requires repo.uselocalgitdirs."""
|
||||||
with utils_for_test.TempGitTree() as tempdir:
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
fakeproj = _create_manifest_project(tempdir)
|
fakeproj = _create_meta_project(tempdir)
|
||||||
|
|
||||||
class DummyManifest:
|
class DummyManifest:
|
||||||
is_submanifest = False
|
is_submanifest = False
|
||||||
@@ -3679,6 +4090,51 @@ class ReprojectCmdTests(unittest.TestCase):
|
|||||||
result = fakeproj.Sync(use_local_gitdirs=False)
|
result = fakeproj.Sync(use_local_gitdirs=False)
|
||||||
self.assertFalse(result)
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_get_revision_id_peels_tags_even_with_all_refs(self) -> None:
|
||||||
|
"""Test GetRevisionId resolves tags via ResolveCommit, not all_refs."""
|
||||||
|
with utils_for_test.TempGitTree() as tempdir:
|
||||||
|
proj = _create_mock_project(tempdir, use_local_gitdirs=True)
|
||||||
|
proj.revisionId = None
|
||||||
|
mock_remote = mock.MagicMock()
|
||||||
|
proj.GetRemote = mock.MagicMock(return_value=mock_remote)
|
||||||
|
proj.bare_git.ResolveCommit.return_value = self.REVID
|
||||||
|
|
||||||
|
# Branch ref in all_refs returns cached commit SHA directly.
|
||||||
|
proj.revisionExpr = "main"
|
||||||
|
mock_remote.ToLocal.return_value = "refs/remotes/origin/main"
|
||||||
|
all_refs = {
|
||||||
|
"refs/remotes/origin/main": self.HEAD_ID,
|
||||||
|
"refs/tags/v1.0": self.OTHER_ID,
|
||||||
|
}
|
||||||
|
self.assertEqual(proj.GetRevisionId(all_refs), self.HEAD_ID)
|
||||||
|
proj.bare_git.ResolveCommit.assert_not_called()
|
||||||
|
|
||||||
|
# Tag ref in all_refs may be an annotated tag object SHA, so it
|
||||||
|
# must fall through to ResolveCommit to peel to a commit SHA.
|
||||||
|
proj.revisionExpr = "refs/tags/v1.0"
|
||||||
|
mock_remote.ToLocal.return_value = "refs/tags/v1.0"
|
||||||
|
self.assertEqual(proj.GetRevisionId(all_refs), self.REVID)
|
||||||
|
proj.bare_git.ResolveCommit.assert_called_once_with(
|
||||||
|
"refs/tags/v1.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# GetCommitRevisionId skips _allrefs for tags and pinned revisionId.
|
||||||
|
with mock.patch.object(
|
||||||
|
project.Project, "_allrefs", new_callable=mock.PropertyMock
|
||||||
|
) as mock_allrefs:
|
||||||
|
proj.bare_git.ResolveCommit.reset_mock()
|
||||||
|
self.assertEqual(proj.GetCommitRevisionId(), self.REVID)
|
||||||
|
mock_allrefs.assert_not_called()
|
||||||
|
proj.bare_git.ResolveCommit.assert_called_once_with(
|
||||||
|
"refs/tags/v1.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
proj.revisionId = self.HEAD_ID
|
||||||
|
proj.bare_git.ResolveCommit.reset_mock()
|
||||||
|
self.assertEqual(proj.GetCommitRevisionId(), self.HEAD_ID)
|
||||||
|
mock_allrefs.assert_not_called()
|
||||||
|
proj.bare_git.ResolveCommit.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class ReprojectCmdGitTests(unittest.TestCase):
|
class ReprojectCmdGitTests(unittest.TestCase):
|
||||||
"""Tests running the reprojectcmd contract against a real Git checkout."""
|
"""Tests running the reprojectcmd contract against a real Git checkout."""
|
||||||
@@ -3951,6 +4407,45 @@ class ReprojectCmdGitTests(unittest.TestCase):
|
|||||||
[" M keep.txt", "?? junk"],
|
[" M keep.txt", "?? junk"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_sync_local_half_checks_out_an_annotated_tag(self) -> None:
|
||||||
|
"""Test Sync_LocalHalf peels an annotated tag to its commit SHA."""
|
||||||
|
with tempfile.TemporaryDirectory(prefix="repo-tests") as topdir:
|
||||||
|
marker = os.path.join(topdir, "ran")
|
||||||
|
proj, revid = self._make_client(
|
||||||
|
topdir, f"{self.READ_TREE} && touch {marker}"
|
||||||
|
)
|
||||||
|
remote = proj.remote.url
|
||||||
|
worktree = proj.worktree
|
||||||
|
self._git(
|
||||||
|
remote,
|
||||||
|
"-c",
|
||||||
|
"user.name=Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=test@example.com",
|
||||||
|
"tag",
|
||||||
|
"-a",
|
||||||
|
"-m",
|
||||||
|
"release v1.0",
|
||||||
|
"v1.0",
|
||||||
|
"HEAD",
|
||||||
|
)
|
||||||
|
self._git(
|
||||||
|
worktree, "fetch", "-q", remote, "refs/tags/v1.0:refs/tags/v1.0"
|
||||||
|
)
|
||||||
|
proj.revisionExpr = "refs/tags/v1.0"
|
||||||
|
proj.revisionId = None
|
||||||
|
|
||||||
|
clean, errors = self._sync(proj)
|
||||||
|
self.assertTrue(clean, errors)
|
||||||
|
self.assertTrue(os.path.exists(marker))
|
||||||
|
self.assertEqual(self._git(worktree, "rev-parse", "HEAD"), revid)
|
||||||
|
|
||||||
|
# Syncing a second time sees HEAD == revid and skips reprojectcmd.
|
||||||
|
os.remove(marker)
|
||||||
|
clean, errors = self._sync(proj)
|
||||||
|
self.assertTrue(clean, errors)
|
||||||
|
self.assertFalse(os.path.exists(marker))
|
||||||
|
|
||||||
|
|
||||||
class DownloadPatchSetTests(unittest.TestCase):
|
class DownloadPatchSetTests(unittest.TestCase):
|
||||||
"""Tests for Project.DownloadPatchSet."""
|
"""Tests for Project.DownloadPatchSet."""
|
||||||
@@ -3966,9 +4461,9 @@ class DownloadPatchSetTests(unittest.TestCase):
|
|||||||
def test_download_patch_set_with_no_filter_and_no_tags(self) -> None:
|
def test_download_patch_set_with_no_filter_and_no_tags(self) -> None:
|
||||||
"""Test DownloadPatchSet includes --no-filter and --no-tags."""
|
"""Test DownloadPatchSet includes --no-filter and --no-tags."""
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"project.git_require", return_value=True
|
"git_command.git_require", return_value=True
|
||||||
) as mock_git_require:
|
) as mock_git_require:
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||||
mock_cmd_instance = mock.MagicMock()
|
mock_cmd_instance = mock.MagicMock()
|
||||||
mock_cmd_instance.Wait.return_value = 0
|
mock_cmd_instance.Wait.return_value = 0
|
||||||
mock_git_cmd.return_value = mock_cmd_instance
|
mock_git_cmd.return_value = mock_cmd_instance
|
||||||
@@ -3994,8 +4489,8 @@ class DownloadPatchSetTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_download_patch_set_legacy_git_omits_no_filter(self) -> None:
|
def test_download_patch_set_legacy_git_omits_no_filter(self) -> None:
|
||||||
"""Test DownloadPatchSet omits --no-filter on git < 2.17."""
|
"""Test DownloadPatchSet omits --no-filter on git < 2.17."""
|
||||||
with mock.patch("project.git_require", return_value=False):
|
with mock.patch("git_command.git_require", return_value=False):
|
||||||
with mock.patch("project.GitCommand") as mock_git_cmd:
|
with mock.patch("git_command.GitCommand") as mock_git_cmd:
|
||||||
mock_cmd_instance = mock.MagicMock()
|
mock_cmd_instance = mock.MagicMock()
|
||||||
mock_cmd_instance.Wait.return_value = 0
|
mock_cmd_instance.Wait.return_value = 0
|
||||||
mock_git_cmd.return_value = mock_cmd_instance
|
mock_git_cmd.return_value = mock_cmd_instance
|
||||||
|
|||||||
@@ -23,16 +23,16 @@ from unittest import mock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from color import SetDefaultColoring
|
from color import SetDefaultColoring
|
||||||
from error import RepoExitError
|
import error
|
||||||
from repo_logging import RepoLogger
|
import repo_logging
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(RepoLogger, "error")
|
@mock.patch.object(repo_logging.RepoLogger, "error")
|
||||||
def test_log_aggregated_errors_logs_aggregated_errors(mock_error) -> None:
|
def test_log_aggregated_errors_logs_aggregated_errors(mock_error) -> None:
|
||||||
"""Test if log_aggregated_errors logs a list of aggregated errors."""
|
"""Test if log_aggregated_errors logs a list of aggregated errors."""
|
||||||
logger = RepoLogger(__name__)
|
logger = repo_logging.RepoLogger(__name__)
|
||||||
logger.log_aggregated_errors(
|
logger.log_aggregated_errors(
|
||||||
RepoExitError(
|
error.RepoExitError(
|
||||||
aggregate_errors=[
|
aggregate_errors=[
|
||||||
Exception("foo"),
|
Exception("foo"),
|
||||||
Exception("bar"),
|
Exception("bar"),
|
||||||
@@ -57,11 +57,11 @@ def test_log_aggregated_errors_logs_aggregated_errors(mock_error) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(RepoLogger, "error")
|
@mock.patch.object(repo_logging.RepoLogger, "error")
|
||||||
def test_log_aggregated_errors_logs_single_error(mock_error) -> None:
|
def test_log_aggregated_errors_logs_single_error(mock_error) -> None:
|
||||||
"""Test if log_aggregated_errors logs empty aggregated_errors."""
|
"""Test if log_aggregated_errors logs empty aggregated_errors."""
|
||||||
logger = RepoLogger(__name__)
|
logger = repo_logging.RepoLogger(__name__)
|
||||||
logger.log_aggregated_errors(RepoExitError())
|
logger.log_aggregated_errors(error.RepoExitError())
|
||||||
|
|
||||||
mock_error.assert_has_calls(
|
mock_error.assert_has_calls(
|
||||||
[
|
[
|
||||||
@@ -99,7 +99,7 @@ def test_log_with_format_string(level: int) -> None:
|
|||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
|
|
||||||
with contextlib.redirect_stderr(output):
|
with contextlib.redirect_stderr(output):
|
||||||
logger = RepoLogger(__name__)
|
logger = repo_logging.RepoLogger(__name__)
|
||||||
logger.log(level, "%s", "100% pass")
|
logger.log(level, "%s", "100% pass")
|
||||||
|
|
||||||
assert re.search(
|
assert re.search(
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ def test_trace_max_size_enforced(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
"""Check Trace behavior."""
|
"""Check Trace behavior."""
|
||||||
content = "git chicken"
|
content = "git chicken"
|
||||||
|
|
||||||
|
# Enable trace for the test, in case users have it disabled.
|
||||||
|
monkeypatch.setattr(repo_trace, "_TRACE", True)
|
||||||
|
|
||||||
with repo_trace.Trace(content, first_trace=True):
|
with repo_trace.Trace(content, first_trace=True):
|
||||||
pass
|
pass
|
||||||
first_trace_size = os.path.getsize(repo_trace._TRACE_FILE)
|
first_trace_size = os.path.getsize(repo_trace._TRACE_FILE)
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ from unittest import mock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from error import GitError
|
import error
|
||||||
from git_command import GitCommand
|
import git_command
|
||||||
from subcmds import cherry_pick
|
from subcmds import cherry_pick
|
||||||
|
|
||||||
|
|
||||||
@@ -30,12 +30,12 @@ def test_resolve_reference_uses_one_typed_batch_request(
|
|||||||
commit = "tree " + "2" * 40 + "\n\nSubject 🚀\n\nBody\n"
|
commit = "tree " + "2" * 40 + "\n\nSubject 🚀\n\nBody\n"
|
||||||
output = oid + " commit " + str(len(commit.encode("utf-8"))) + "\n"
|
output = oid + " commit " + str(len(commit.encode("utf-8"))) + "\n"
|
||||||
output += commit + "\n"
|
output += commit + "\n"
|
||||||
command = mock.create_autospec(GitCommand, instance=True)
|
command = mock.create_autospec(git_command.GitCommand, instance=True)
|
||||||
command.stdout = output
|
command.stdout = output
|
||||||
command.stderr = ""
|
command.stderr = ""
|
||||||
command.Wait.return_value = 0
|
command.Wait.return_value = 0
|
||||||
run_git = mock.create_autospec(GitCommand, return_value=command)
|
run_git = mock.create_autospec(git_command.GitCommand, return_value=command)
|
||||||
monkeypatch.setattr(cherry_pick, "GitCommand", run_git)
|
monkeypatch.setattr(git_command, "GitCommand", run_git)
|
||||||
|
|
||||||
resolved, contents = cherry_pick.CherryPick()._ResolveReference("topic")
|
resolved, contents = cherry_pick.CherryPick()._ResolveReference("topic")
|
||||||
|
|
||||||
@@ -54,32 +54,32 @@ def test_resolve_reference_uses_one_typed_batch_request(
|
|||||||
def test_resolve_reference_rejects_missing_object(
|
def test_resolve_reference_rejects_missing_object(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
command = mock.create_autospec(GitCommand, instance=True)
|
command = mock.create_autospec(git_command.GitCommand, instance=True)
|
||||||
command.stdout = "topic^{commit} missing\n"
|
command.stdout = "topic^{commit} missing\n"
|
||||||
command.stderr = ""
|
command.stderr = ""
|
||||||
command.Wait.return_value = 0
|
command.Wait.return_value = 0
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cherry_pick,
|
git_command,
|
||||||
"GitCommand",
|
"GitCommand",
|
||||||
mock.create_autospec(GitCommand, return_value=command),
|
mock.create_autospec(git_command.GitCommand, return_value=command),
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(GitError, match="commit topic not found"):
|
with pytest.raises(error.GitError, match="commit topic not found"):
|
||||||
cherry_pick.CherryPick()._ResolveReference("topic")
|
cherry_pick.CherryPick()._ResolveReference("topic")
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_reference_rejects_ambiguous_object(
|
def test_resolve_reference_rejects_ambiguous_object(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
command = mock.create_autospec(GitCommand, instance=True)
|
command = mock.create_autospec(git_command.GitCommand, instance=True)
|
||||||
command.stdout = "topic^{commit} ambiguous\n"
|
command.stdout = "topic^{commit} ambiguous\n"
|
||||||
command.stderr = ""
|
command.stderr = ""
|
||||||
command.Wait.return_value = 0
|
command.Wait.return_value = 0
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
cherry_pick,
|
git_command,
|
||||||
"GitCommand",
|
"GitCommand",
|
||||||
mock.create_autospec(GitCommand, return_value=command),
|
mock.create_autospec(git_command.GitCommand, return_value=command),
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(GitError, match="commit topic not found"):
|
with pytest.raises(error.GitError, match="commit topic not found"):
|
||||||
cherry_pick.CherryPick()._ResolveReference("topic")
|
cherry_pick.CherryPick()._ResolveReference("topic")
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
# Copyright (C) 2026 The Android Open Source Project
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Unittests for the subcmds/config.py module."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator, List
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import command
|
||||||
|
import error
|
||||||
|
import git_config
|
||||||
|
from subcmds import config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
[],
|
||||||
|
["get"],
|
||||||
|
["get", "a.b", "c"],
|
||||||
|
["set"],
|
||||||
|
["set", "a.b"],
|
||||||
|
["set", "a.b", "c", "d"],
|
||||||
|
["unset"],
|
||||||
|
["unset", "a.b", "c"],
|
||||||
|
["list", "a.b"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_options_usage(argv: List[str]) -> None:
|
||||||
|
"""Wrong number of arguments for an action shows usage."""
|
||||||
|
cmd = config.Config()
|
||||||
|
opts, args = cmd.OptionParser.parse_args(argv)
|
||||||
|
with pytest.raises(command.UsageError):
|
||||||
|
cmd.ValidateOptions(opts, args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
["bogus"],
|
||||||
|
["a.b", "c"],
|
||||||
|
["--type=bool", "unset", "a.b"],
|
||||||
|
["--type=bool", "list"],
|
||||||
|
["-z", "set", "a.b", "c"],
|
||||||
|
["-z", "unset", "a.b"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_options_error(argv: List[str]) -> None:
|
||||||
|
"""Unknown actions and inapplicable options are rejected."""
|
||||||
|
cmd = config.Config()
|
||||||
|
opts, args = cmd.OptionParser.parse_args(argv)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
cmd.ValidateOptions(opts, args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_options_unknown_type() -> None:
|
||||||
|
"""--type only accepts the supported types."""
|
||||||
|
cmd = config.Config()
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
cmd.OptionParser.parse_args(["--type=path", "get", "a.b"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
["get", "sync.smartsync"],
|
||||||
|
["set", "sync.smartsync", "true"],
|
||||||
|
["unset", "sync.smartsync"],
|
||||||
|
["list"],
|
||||||
|
["--type=bool", "get", "sync.smartsync"],
|
||||||
|
["--type=int", "set", "a.b", "1"],
|
||||||
|
["-z", "get", "sync.smartsync"],
|
||||||
|
["--null", "list"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_options_accepts(argv: List[str]) -> None:
|
||||||
|
"""Valid invocations pass validation."""
|
||||||
|
cmd = config.Config()
|
||||||
|
opts, args = cmd.OptionParser.parse_args(argv)
|
||||||
|
cmd.ValidateOptions(opts, args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cfg(tmp_path: Path) -> Iterator[git_config.RepoConfig]:
|
||||||
|
"""A RepoConfig backed by a temp file, used as the per-user config."""
|
||||||
|
cfg = git_config.RepoConfig(configfile=tmp_path / "config")
|
||||||
|
with mock.patch.object(git_config.RepoConfig, "ForUser", return_value=cfg):
|
||||||
|
yield cfg
|
||||||
|
|
||||||
|
|
||||||
|
def run(argv: List[str], capsys: pytest.CaptureFixture) -> str:
|
||||||
|
"""Runs `repo config` with |argv| and returns its stdout."""
|
||||||
|
cmd = config.Config()
|
||||||
|
opts, args = cmd.OptionParser.parse_args(argv)
|
||||||
|
cmd.ValidateOptions(opts, args)
|
||||||
|
capsys.readouterr()
|
||||||
|
cmd.Execute(opts, args)
|
||||||
|
return capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_creates_file(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Setting a value writes it to the config file."""
|
||||||
|
run(["set", "sync.smartsync", "true"], capsys)
|
||||||
|
assert "smartsync = true" in Path(cfg.file).read_text()
|
||||||
|
assert cfg.GetBoolean("sync.smartsync") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_then_get(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Getting a value prints what was set."""
|
||||||
|
run(["set", "sync.smartsync", "false"], capsys)
|
||||||
|
assert run(["get", "sync.smartsync"], capsys) == "false\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_overwrites(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Setting an existing key replaces its value."""
|
||||||
|
run(["set", "sync.smartsync", "true"], capsys)
|
||||||
|
run(["set", "sync.smartsync", "false"], capsys)
|
||||||
|
assert run(["get", "sync.smartsync"], capsys) == "false\n"
|
||||||
|
assert Path(cfg.file).read_text().count("smartsync") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_invalid_name(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""git's own validation of the name is surfaced as a clean error."""
|
||||||
|
with pytest.raises(config.Error) as e:
|
||||||
|
run(["set", "nodot", "true"], capsys)
|
||||||
|
assert "nodot" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value, expected",
|
||||||
|
[
|
||||||
|
("true", "true"),
|
||||||
|
("Yes", "true"),
|
||||||
|
("on", "true"),
|
||||||
|
("1", "true"),
|
||||||
|
("false", "false"),
|
||||||
|
("No", "false"),
|
||||||
|
("off", "false"),
|
||||||
|
("0", "false"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_set_type_bool(
|
||||||
|
cfg: git_config.RepoConfig,
|
||||||
|
capsys: pytest.CaptureFixture,
|
||||||
|
value: str,
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
|
"""--type=bool canonicalizes accepted spellings on set."""
|
||||||
|
run(["--type=bool", "set", "sync.smartsync", value], capsys)
|
||||||
|
assert run(["get", "sync.smartsync"], capsys) == f"{expected}\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value, expected",
|
||||||
|
[
|
||||||
|
("42", "42"),
|
||||||
|
("1k", "1024"),
|
||||||
|
("2M", "2097152"),
|
||||||
|
("1g", "1073741824"),
|
||||||
|
("0x10", "16"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_set_type_int(
|
||||||
|
cfg: git_config.RepoConfig,
|
||||||
|
capsys: pytest.CaptureFixture,
|
||||||
|
value: str,
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
|
"""--type=int canonicalizes accepted spellings on set."""
|
||||||
|
run(["--type=int", "set", "section.num", value], capsys)
|
||||||
|
assert run(["get", "section.num"], capsys) == f"{expected}\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
["--type=bool", "set", "sync.smartsync", "maybe"],
|
||||||
|
["--type=bool", "set", "sync.smartsync", "2"],
|
||||||
|
["--type=int", "set", "section.num", "lots"],
|
||||||
|
["--type=int", "set", "section.num", "1x"],
|
||||||
|
["--type=int", "set", "section.num", ""],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_set_type_rejects_bad_values(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture, argv: List[str]
|
||||||
|
) -> None:
|
||||||
|
"""--type rejects values that do not parse and writes nothing."""
|
||||||
|
with pytest.raises(config.Error):
|
||||||
|
run(argv, capsys)
|
||||||
|
assert not Path(cfg.file).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_type_canonicalizes(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""--type on get prints the canonical form of a stored value."""
|
||||||
|
Path(cfg.file).write_text("[a]\n\tb = Yes\n\tn = 1k\n")
|
||||||
|
assert run(["--type=bool", "get", "a.b"], capsys) == "true\n"
|
||||||
|
assert run(["--type=int", "get", "a.n"], capsys) == "1024\n"
|
||||||
|
with pytest.raises(config.Error):
|
||||||
|
run(["--type=int", "get", "a.b"], capsys)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_missing_exits_quietly(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Reading an unset key exits 1 without output, like git config."""
|
||||||
|
cmd = config.Config()
|
||||||
|
opts, args = cmd.OptionParser.parse_args(["get", "sync.smartsync"])
|
||||||
|
with pytest.raises(config.NotSetError) as e:
|
||||||
|
cmd.Execute(opts, args)
|
||||||
|
assert e.value.exit_code == 1
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_multiple_values(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""All values of a multi-valued key are printed."""
|
||||||
|
cfg.SetString("section.multi", ["one", "two"])
|
||||||
|
assert run(["get", "section.multi"], capsys) == "one\ntwo\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_valueless_key(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""A key present without a value prints an empty line, like git."""
|
||||||
|
Path(cfg.file).write_text("[sync]\n\tsmartsync\n")
|
||||||
|
assert run(["get", "sync.smartsync"], capsys) == "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_null(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""-z terminates each value with NUL."""
|
||||||
|
cfg.SetString("section.multi", ["one", "two"])
|
||||||
|
assert run(["-z", "get", "section.multi"], capsys) == "one\0two\0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unset(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""unset removes the key."""
|
||||||
|
run(["set", "sync.smartsync", "true"], capsys)
|
||||||
|
run(["unset", "sync.smartsync"], capsys)
|
||||||
|
assert cfg.GetString("sync.smartsync") is None
|
||||||
|
assert "smartsync" not in Path(cfg.file).read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unset_missing_is_noop(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""unset on a missing key does not fail or create the file."""
|
||||||
|
run(["unset", "sync.smartsync"], capsys)
|
||||||
|
assert not Path(cfg.file).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_list(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""list prints all keys sorted as key=value, with every value."""
|
||||||
|
run(["set", "sync.smartsync", "true"], capsys)
|
||||||
|
run(["set", "alias.ss", "sync -s"], capsys)
|
||||||
|
cfg.SetString("section.multi", ["one", "two"])
|
||||||
|
assert run(["list"], capsys) == (
|
||||||
|
"alias.ss=sync -s\n"
|
||||||
|
"section.multi=one\n"
|
||||||
|
"section.multi=two\n"
|
||||||
|
"sync.smartsync=true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_null(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""-z separates key and value with a newline and entries with NUL."""
|
||||||
|
Path(cfg.file).write_text("[alias]\n\tss = sync -s\n[sync]\n\tsmartsync\n")
|
||||||
|
assert run(["-z", "list"], capsys) == "alias.ss\nsync -s\0sync.smartsync\0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_valueless_key(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""list prints a bare key for a key without a value, like git."""
|
||||||
|
Path(cfg.file).write_text("[sync]\n\tsmartsync\n")
|
||||||
|
assert run(["list"], capsys) == "sync.smartsync\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_empty(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""list on a missing file prints nothing."""
|
||||||
|
assert run(["list"], capsys) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_with_spaces(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Values containing spaces round trip."""
|
||||||
|
run(["set", "alias.ss", "sync -s -j 4"], capsys)
|
||||||
|
assert run(["get", "alias.ss"], capsys) == "sync -s -j 4\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_is_case_insensitive(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Section and name are case-insensitive, like git."""
|
||||||
|
run(["set", "Sync.SmartSync", "true"], capsys)
|
||||||
|
assert run(["get", "sync.smartsync"], capsys) == "true\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_failure_is_reported(
|
||||||
|
cfg: git_config.RepoConfig, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""A git failure while writing becomes a clean error, not a traceback."""
|
||||||
|
with mock.patch.object(
|
||||||
|
cfg, "SetString", side_effect=error.GitError("locked")
|
||||||
|
):
|
||||||
|
with pytest.raises(config.Error) as e:
|
||||||
|
run(["set", "sync.smartsync", "true"], capsys)
|
||||||
|
assert "locked" in str(e.value)
|
||||||
@@ -14,9 +14,11 @@
|
|||||||
|
|
||||||
"""Unittests for the subcmds/gc.py module."""
|
"""Unittests for the subcmds/gc.py module."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import manifest_xml
|
||||||
from subcmds import gc
|
from subcmds import gc
|
||||||
|
|
||||||
|
|
||||||
@@ -80,3 +82,108 @@ class GcCommand(unittest.TestCase):
|
|||||||
ret = self.cmd.Execute(self.opt, [])
|
ret = self.cmd.Execute(self.opt, [])
|
||||||
self.assertEqual(ret, 1)
|
self.assertEqual(ret, 1)
|
||||||
self.mock_repack.assert_not_called()
|
self.mock_repack.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_gc_keep_manifest_projects(tmp_path: Path) -> None:
|
||||||
|
"""Keep inactive projects that are still in the manifest."""
|
||||||
|
repodir = tmp_path / ".repo"
|
||||||
|
manifest_dir = repodir / "manifests"
|
||||||
|
manifest_file = repodir / manifest_xml.MANIFEST_FILE_NAME
|
||||||
|
|
||||||
|
repodir.mkdir()
|
||||||
|
manifest_dir.mkdir()
|
||||||
|
|
||||||
|
manifest_gitdir = repodir / "manifests.git"
|
||||||
|
manifest_gitdir.mkdir()
|
||||||
|
(manifest_gitdir / "config").write_text(
|
||||||
|
"""[remote "origin"]
|
||||||
|
url = https://localhost:0/manifest
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest_file.write_text(
|
||||||
|
"""\
|
||||||
|
<manifest>
|
||||||
|
<remote name="origin" fetch="http://localhost" />
|
||||||
|
<default remote="origin" revision="refs/heads/main" />
|
||||||
|
<project name="included" groups="active-group" />
|
||||||
|
<project name="excluded" groups="other-group" />
|
||||||
|
</manifest>
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = manifest_xml.XmlManifest(str(repodir), str(manifest_file))
|
||||||
|
manifest.manifestProject.config.SetString(
|
||||||
|
"manifest.groups",
|
||||||
|
"active-group",
|
||||||
|
)
|
||||||
|
|
||||||
|
projects = {project.name: project for project in manifest.projects}
|
||||||
|
included = projects["included"]
|
||||||
|
excluded = projects["excluded"]
|
||||||
|
|
||||||
|
Path(included.gitdir).mkdir(parents=True)
|
||||||
|
Path(included.objdir).mkdir(parents=True)
|
||||||
|
|
||||||
|
# Simulate an inactive project with only its object cache left.
|
||||||
|
Path(excluded.objdir).mkdir(parents=True)
|
||||||
|
assert not excluded.Exists
|
||||||
|
|
||||||
|
orphan_gitdir = repodir / "projects" / "orphan.git"
|
||||||
|
orphan_objdir = repodir / "project-objects" / "orphan.git"
|
||||||
|
orphan_gitdir.mkdir()
|
||||||
|
orphan_objdir.mkdir()
|
||||||
|
|
||||||
|
cmd = gc.Gc(repodir=str(repodir), manifest=manifest)
|
||||||
|
|
||||||
|
opt, args = cmd.OptionParser.parse_args(
|
||||||
|
["--yes", "--keep-manifest-projects"]
|
||||||
|
)
|
||||||
|
opt.quiet = True
|
||||||
|
|
||||||
|
selected_projects = cmd.GetProjects([], all_manifests=True)
|
||||||
|
assert included in selected_projects
|
||||||
|
assert excluded not in selected_projects
|
||||||
|
|
||||||
|
cmd.Execute(opt, args)
|
||||||
|
|
||||||
|
assert included.Exists
|
||||||
|
assert Path(excluded.objdir).exists()
|
||||||
|
assert not orphan_gitdir.exists()
|
||||||
|
assert not orphan_objdir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_gc_keep_manifest_projects_preserves_repack_targets() -> None:
|
||||||
|
"""Keep manifest projects without expanding repack targets."""
|
||||||
|
cmd = gc.Gc()
|
||||||
|
opt, _ = cmd.OptionParser.parse_args(
|
||||||
|
["--keep-manifest-projects", "--repack"]
|
||||||
|
)
|
||||||
|
opt.this_manifest_only = False
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
cmd,
|
||||||
|
"GetProjects",
|
||||||
|
side_effect=[["projA"], ["manifest_projects"]],
|
||||||
|
) as get_projects, mock.patch.object(
|
||||||
|
cmd, "delete_unused_projects", return_value=0
|
||||||
|
) as delete, mock.patch.object(
|
||||||
|
cmd, "repack_projects", return_value=0
|
||||||
|
) as repack:
|
||||||
|
cmd.Execute(opt, ["projA"])
|
||||||
|
|
||||||
|
get_projects.assert_has_calls(
|
||||||
|
[
|
||||||
|
mock.call(["projA"], all_manifests=True),
|
||||||
|
mock.call(
|
||||||
|
[],
|
||||||
|
groups="all",
|
||||||
|
missing_ok=True,
|
||||||
|
all_manifests=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
delete.assert_called_once_with(["manifest_projects"], opt)
|
||||||
|
repack.assert_called_once_with(["projA"], opt)
|
||||||
@@ -23,7 +23,13 @@ from subcmds import init
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"argv",
|
"argv",
|
||||||
([],),
|
(
|
||||||
|
[],
|
||||||
|
["-s"],
|
||||||
|
["--smart-sync"],
|
||||||
|
["--no-smart-sync"],
|
||||||
|
["--head"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
def test_cli_parser_good(argv: List[str]) -> None:
|
def test_cli_parser_good(argv: List[str]) -> None:
|
||||||
"""Check valid command line options."""
|
"""Check valid command line options."""
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import io
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from error import GitError
|
import error
|
||||||
|
import git_command
|
||||||
from subcmds import rebase
|
from subcmds import rebase
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ def test_resolve_onto_manifest_fallback() -> None:
|
|||||||
project.revisionExpr = "main"
|
project.revisionExpr = "main"
|
||||||
|
|
||||||
remote = mock.MagicMock()
|
remote = mock.MagicMock()
|
||||||
remote.ToLocal.side_effect = GitError("Failed to resolve")
|
remote.ToLocal.side_effect = error.GitError("Failed to resolve")
|
||||||
project.GetRemote.return_value = remote
|
project.GetRemote.return_value = remote
|
||||||
|
|
||||||
res = rebase._ResolveOntoManifest(project)
|
res = rebase._ResolveOntoManifest(project)
|
||||||
@@ -77,11 +78,11 @@ def test_execute_delegates_autostash_to_rebase() -> None:
|
|||||||
onto_manifest=False,
|
onto_manifest=False,
|
||||||
this_manifest_only=False,
|
this_manifest_only=False,
|
||||||
)
|
)
|
||||||
git_command = mock.MagicMock()
|
command = mock.MagicMock()
|
||||||
git_command.Wait.return_value = 0
|
command.Wait.return_value = 0
|
||||||
|
|
||||||
with mock.patch.object(
|
with mock.patch.object(
|
||||||
rebase, "GitCommand", return_value=git_command
|
git_command, "GitCommand", return_value=command
|
||||||
) as run_git, contextlib.redirect_stdout(io.StringIO()):
|
) as run_git, contextlib.redirect_stdout(io.StringIO()):
|
||||||
assert cmd.Execute(opt, []) == 0
|
assert cmd.Execute(opt, []) == 0
|
||||||
|
|
||||||
|
|||||||
+1051
-56
File diff suppressed because it is too large.
Load diff
@@ -19,8 +19,7 @@ from unittest import mock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from error import GitError
|
import error
|
||||||
from error import UploadError
|
|
||||||
from subcmds import upload
|
from subcmds import upload
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +44,9 @@ def cmd() -> upload.Upload:
|
|||||||
def test_UploadAndReport_UploadError(cmd: upload.Upload) -> None:
|
def test_UploadAndReport_UploadError(cmd: upload.Upload) -> None:
|
||||||
"""Check UploadExitError raised when UploadError encountered."""
|
"""Check UploadExitError raised when UploadError encountered."""
|
||||||
opt, _ = cmd.OptionParser.parse_args([])
|
opt, _ = cmd.OptionParser.parse_args([])
|
||||||
with mock.patch.object(cmd, "_UploadBranch", side_effect=UploadError("")):
|
with mock.patch.object(
|
||||||
|
cmd, "_UploadBranch", side_effect=error.UploadError("")
|
||||||
|
):
|
||||||
with pytest.raises(upload.UploadExitError):
|
with pytest.raises(upload.UploadExitError):
|
||||||
cmd._UploadAndReport(opt, [mock.MagicMock()], _STUB_PEOPLE)
|
cmd._UploadAndReport(opt, [mock.MagicMock()], _STUB_PEOPLE)
|
||||||
|
|
||||||
@@ -53,7 +54,9 @@ def test_UploadAndReport_UploadError(cmd: upload.Upload) -> None:
|
|||||||
def test_UploadAndReport_GitError(cmd: upload.Upload) -> None:
|
def test_UploadAndReport_GitError(cmd: upload.Upload) -> None:
|
||||||
"""Check UploadExitError raised when GitError encountered."""
|
"""Check UploadExitError raised when GitError encountered."""
|
||||||
opt, _ = cmd.OptionParser.parse_args([])
|
opt, _ = cmd.OptionParser.parse_args([])
|
||||||
with mock.patch.object(cmd, "_UploadBranch", side_effect=GitError("")):
|
with mock.patch.object(
|
||||||
|
cmd, "_UploadBranch", side_effect=error.GitError("")
|
||||||
|
):
|
||||||
with pytest.raises(upload.UploadExitError):
|
with pytest.raises(upload.UploadExitError):
|
||||||
cmd._UploadAndReport(opt, [mock.MagicMock()], _STUB_PEOPLE)
|
cmd._UploadAndReport(opt, [mock.MagicMock()], _STUB_PEOPLE)
|
||||||
|
|
||||||
@@ -113,11 +116,46 @@ def test_GatherOne_returns_resolved_current_branch(
|
|||||||
"get_parallel_context",
|
"get_parallel_context",
|
||||||
lambda: {"projects": [project]},
|
lambda: {"projects": [project]},
|
||||||
)
|
)
|
||||||
opt = mock.MagicMock(current_branch=True)
|
opt = mock.MagicMock(current_branch=True, ignore_remote_commits=False)
|
||||||
|
|
||||||
assert upload.Upload._GatherOne(opt, 0) == (0, [branch], "topic")
|
assert upload.Upload._GatherOne(opt, 0) == (0, [branch], "topic")
|
||||||
|
|
||||||
project.GetUploadableBranch.assert_called_once_with("topic")
|
project.GetUploadableBranch.assert_called_once_with(
|
||||||
|
"topic", ignore_remote_commits=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_GatherOne_forwards_ignore_remote_commits(
|
||||||
|
cmd: upload.Upload,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""_GatherOne forwards --ignore-remote-commits to project methods."""
|
||||||
|
project = mock.MagicMock()
|
||||||
|
project.CurrentBranch = "topic"
|
||||||
|
branch = mock.sentinel.branch
|
||||||
|
project.GetUploadableBranch.return_value = branch
|
||||||
|
project.GetUploadableBranches.return_value = [branch]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
upload.Upload,
|
||||||
|
"get_parallel_context",
|
||||||
|
lambda: {"projects": [project]},
|
||||||
|
)
|
||||||
|
|
||||||
|
opt_cbr, _ = cmd.OptionParser.parse_args(
|
||||||
|
["--cbr", "--ignore-remote-commits"]
|
||||||
|
)
|
||||||
|
assert upload.Upload._GatherOne(opt_cbr, 0) == (0, [branch], "topic")
|
||||||
|
project.GetUploadableBranch.assert_called_once_with(
|
||||||
|
"topic", ignore_remote_commits=True
|
||||||
|
)
|
||||||
|
|
||||||
|
opt_br, _ = cmd.OptionParser.parse_args(
|
||||||
|
["--br", "topic", "--ignore-remote-commits"]
|
||||||
|
)
|
||||||
|
assert upload.Upload._GatherOne(opt_br, 0) == (0, [branch], None)
|
||||||
|
project.GetUploadableBranches.assert_called_once_with(
|
||||||
|
"topic", ignore_remote_commits=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _create_mock_branch(
|
def _create_mock_branch(
|
||||||
@@ -233,3 +271,97 @@ def test_MultipleBranches_yes_without_branch_or_cbr_uses_editor(
|
|||||||
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
|
cmd._MultipleBranches(opt, pending, _STUB_PEOPLE)
|
||||||
mock_edit.assert_called_once()
|
mock_edit.assert_called_once()
|
||||||
mock_upload.assert_called_once_with(opt, [branch1], _STUB_PEOPLE)
|
mock_upload.assert_called_once_with(opt, [branch1], _STUB_PEOPLE)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"argv, expected",
|
||||||
|
(
|
||||||
|
(["--fix", "--ignore-hooks"], "not be part of the upload"),
|
||||||
|
(["--fix", "--no-verify"], "--fix would have no effect"),
|
||||||
|
(
|
||||||
|
["--fix", "--ignore-hooks", "--no-verify"],
|
||||||
|
"--fix and --ignore-hooks are mutually exclusive",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_ValidateOptions_fix_conflicts(
|
||||||
|
cmd: upload.Upload,
|
||||||
|
capsys: pytest.CaptureFixture,
|
||||||
|
argv: List[str],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
|
"""Check --fix is rejected with --ignore-hooks or --no-verify."""
|
||||||
|
opt, args = cmd.OptionParser.parse_args(argv)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
cmd.ValidateOptions(opt, args)
|
||||||
|
assert expected in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"argv",
|
||||||
|
(
|
||||||
|
[],
|
||||||
|
["--fix"],
|
||||||
|
["--fix", "--verify"],
|
||||||
|
["--fix", "-y"],
|
||||||
|
["--ignore-hooks"],
|
||||||
|
["--no-verify"],
|
||||||
|
["--ignore-hooks", "--no-verify"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_ValidateOptions_allowed(cmd: upload.Upload, argv: List[str]) -> None:
|
||||||
|
"""Check valid hook option combinations are accepted."""
|
||||||
|
opt, args = cmd.OptionParser.parse_args(argv)
|
||||||
|
cmd.ValidateOptions(opt, args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("argv,expected_quiet", [([], False), (["-q"], True)])
|
||||||
|
def test_UploadBranch_forwards_quiet(
|
||||||
|
cmd: upload.Upload, argv: List[str], expected_quiet: bool
|
||||||
|
) -> None:
|
||||||
|
"""Check _UploadBranch forwards opt.quiet to branch.UploadForReview."""
|
||||||
|
opt, _ = cmd.OptionParser.parse_args(argv)
|
||||||
|
cmd.CommonValidateOptions(opt, [])
|
||||||
|
branch = mock.MagicMock()
|
||||||
|
branch.project.dest_branch = None
|
||||||
|
with mock.patch("git_superproject.UseSuperproject", return_value=False):
|
||||||
|
cmd._UploadBranch(opt, branch, _STUB_PEOPLE)
|
||||||
|
|
||||||
|
branch.UploadForReview.assert_called_once()
|
||||||
|
_, kwargs = branch.UploadForReview.call_args
|
||||||
|
assert kwargs["quiet"] is expected_quiet
|
||||||
|
|
||||||
|
|
||||||
|
def test_UploadAndReport_quiet_suppresses_ok_summary(
|
||||||
|
cmd: upload.Upload, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Check -q suppresses non-error [OK] summary output."""
|
||||||
|
opt, _ = cmd.OptionParser.parse_args(["-q"])
|
||||||
|
cmd.CommonValidateOptions(opt, [])
|
||||||
|
branch = mock.MagicMock()
|
||||||
|
branch.name = "topic"
|
||||||
|
branch.project.RelPath.return_value = "proj"
|
||||||
|
with mock.patch.object(cmd, "_UploadBranch"):
|
||||||
|
cmd._UploadAndReport(opt, [branch], _STUB_PEOPLE)
|
||||||
|
|
||||||
|
assert capsys.readouterr().err == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_UploadAndReport_quiet_still_prints_errors(
|
||||||
|
cmd: upload.Upload, capsys: pytest.CaptureFixture
|
||||||
|
) -> None:
|
||||||
|
"""Check -q still prints [FAILED] error summary when upload fails."""
|
||||||
|
opt, _ = cmd.OptionParser.parse_args(["-q"])
|
||||||
|
cmd.CommonValidateOptions(opt, [])
|
||||||
|
branch = mock.MagicMock()
|
||||||
|
branch.name = "topic"
|
||||||
|
branch.project.RelPath.return_value = "proj"
|
||||||
|
with mock.patch.object(
|
||||||
|
cmd, "_UploadBranch", side_effect=error.UploadError("boom")
|
||||||
|
):
|
||||||
|
with pytest.raises(upload.UploadExitError):
|
||||||
|
cmd._UploadAndReport(opt, [branch], _STUB_PEOPLE)
|
||||||
|
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "[FAILED]" in err
|
||||||
|
assert "[OK ]" not in err
|
||||||
@@ -18,6 +18,7 @@ from unittest import mock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import git_command
|
||||||
from subcmds import version
|
from subcmds import version
|
||||||
|
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ def test_repo_version_uses_one_pretty_format_call(
|
|||||||
) -> None:
|
) -> None:
|
||||||
project = mock.MagicMock()
|
project = mock.MagicMock()
|
||||||
project.bare_git.log.return_value = "v2.0-1-g12345678\nTue, 25 Aug\n"
|
project.bare_git.log.return_value = "v2.0-1-g12345678\nTue, 25 Aug\n"
|
||||||
monkeypatch.setattr(version, "git_require", lambda _version: True)
|
monkeypatch.setattr(git_command, "git_require", lambda _version: True)
|
||||||
|
|
||||||
result = version.Version._RepoVersion(project)
|
result = version.Version._RepoVersion(project)
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ def test_repo_version_keeps_old_git_fallback(
|
|||||||
project = mock.MagicMock()
|
project = mock.MagicMock()
|
||||||
project.bare_git.describe.return_value = "v2.0"
|
project.bare_git.describe.return_value = "v2.0"
|
||||||
project.bare_git.log.return_value = "Tue, 25 Aug"
|
project.bare_git.log.return_value = "Tue, 25 Aug"
|
||||||
monkeypatch.setattr(version, "git_require", lambda _version: False)
|
monkeypatch.setattr(git_command, "git_require", lambda _version: False)
|
||||||
|
|
||||||
result = version.Version._RepoVersion(project)
|
result = version.Version._RepoVersion(project)
|
||||||
|
|
||||||
|
|||||||
@@ -515,6 +515,62 @@ class TestResolveRepoRev:
|
|||||||
with pytest.raises(repo_wrapper.CloneFailure):
|
with pytest.raises(repo_wrapper.CloneFailure):
|
||||||
repo_wrapper.resolve_repo_rev(git_checkout.git_dir, "boooooooya")
|
repo_wrapper.resolve_repo_rev(git_checkout.git_dir, "boooooooya")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"committish",
|
||||||
|
["refs/heads/unknown", "refs/tags/unknown", "boooooooya"],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("report_errors", [True, False])
|
||||||
|
def test_report_errors(
|
||||||
|
self,
|
||||||
|
repo_wrapper: wrapper.Wrapper,
|
||||||
|
git_checkout: GitCheckout,
|
||||||
|
capsys: pytest.CaptureFixture,
|
||||||
|
committish: str,
|
||||||
|
report_errors: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Check errors are only printed when requested."""
|
||||||
|
with pytest.raises(repo_wrapper.CloneFailure):
|
||||||
|
repo_wrapper.resolve_repo_rev(
|
||||||
|
git_checkout.git_dir, committish, report_errors=report_errors
|
||||||
|
)
|
||||||
|
assert ("repo: error:" in capsys.readouterr().err) == report_errors
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefetchIfRevMissing:
|
||||||
|
"""Check _RefetchIfRevMissing behavior."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("missing", [False, True])
|
||||||
|
def test_refetch(
|
||||||
|
self,
|
||||||
|
repo_wrapper: wrapper.Wrapper,
|
||||||
|
capsys: pytest.CaptureFixture,
|
||||||
|
missing: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Check it refetches once only if the rev doesn't resolve."""
|
||||||
|
mock_resolve = mock.Mock(
|
||||||
|
side_effect=repo_wrapper.CloneFailure() if missing else None
|
||||||
|
)
|
||||||
|
with mock.patch.object(repo_wrapper, "_Fetch") as mock_fetch:
|
||||||
|
with mock.patch.object(repo_wrapper.time, "sleep") as mock_sleep:
|
||||||
|
with mock.patch.object(
|
||||||
|
repo_wrapper, "resolve_repo_rev", mock_resolve
|
||||||
|
):
|
||||||
|
repo_wrapper._RefetchIfRevMissing(
|
||||||
|
"url", "/dst", "stable", False, False
|
||||||
|
)
|
||||||
|
|
||||||
|
# It resolves quietly; check_repo_rev reports the error.
|
||||||
|
mock_resolve.assert_called_once_with(
|
||||||
|
"/dst", "stable", report_errors=False
|
||||||
|
)
|
||||||
|
fetches = int(missing)
|
||||||
|
fetch = mock.call("url", "/dst", "origin", False, False)
|
||||||
|
assert mock_fetch.call_args_list == [fetch] * fetches
|
||||||
|
delay = repo_wrapper.REV_REFETCH_DELAY_SEC
|
||||||
|
assert mock_sleep.call_args_list == [mock.call(delay)] * fetches
|
||||||
|
warnings = capsys.readouterr().err.count(f"refetching in {delay} ")
|
||||||
|
assert warnings == fetches
|
||||||
|
|
||||||
|
|
||||||
class TestCheckRepoVerify:
|
class TestCheckRepoVerify:
|
||||||
"""Check check_repo_verify behavior."""
|
"""Check check_repo_verify behavior."""
|
||||||
|
|||||||
Reference in new issue
Block a user