mirror of
https://gerrit.googlesource.com/git-repo
synced 2026-09-03 05:20:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9da609d8c | ||
|
|
4bec297eb6 | ||
|
|
54fa31cd84 | ||
|
|
29b6630a65 | ||
|
|
0b82311632 | ||
|
|
06c4f9e1cc | ||
|
|
dd1130352b | ||
|
|
eeba6f268d | ||
|
|
1729aaebae | ||
|
|
978adb7ea5 | ||
|
|
0398c6718e | ||
|
|
3bb4871c44 | ||
|
|
35bbf701d0 | ||
|
|
881af15cdc | ||
|
|
fbc9c79192 | ||
|
|
ead4b2d7aa | ||
|
|
e7cac4bca6 | ||
|
|
91986011b0 | ||
|
|
a27dbcdb7b | ||
|
|
547dc9985c | ||
|
|
88a7e88e54 | ||
|
|
c21a41c7cc | ||
|
|
6586efe79a | ||
|
|
3af9e2f146 | ||
|
|
d32b70275c | ||
|
|
7f58543703 | ||
|
|
39c0b60900 | ||
|
|
cd307a6089 | ||
|
|
4b462634e0 | ||
|
|
d9d86fb595 | ||
|
|
f7a24df00c | ||
|
|
e0bd39c691 | ||
|
|
c883613e31 | ||
|
|
384c059f9e | ||
|
|
b8531133de | ||
|
|
2d54384a5e | ||
|
|
1b4e7a04be | ||
|
|
a94c9e2b52 | ||
|
|
51021fb209 | ||
|
|
d453273f06 | ||
|
|
0129ac1c46 | ||
|
|
bf8c59f4a0 | ||
|
|
003f0407fc | ||
|
|
1db50e49fb | ||
|
|
c97a306fe7 | ||
|
|
5534f164d6 | ||
|
|
67e52a120b | ||
|
|
d5037230e9 | ||
|
|
7cc99b24c1 | ||
|
|
12ad396f67 | ||
|
|
a6bc1b7cf0 | ||
|
|
a98e422113 | ||
|
|
5d8585012f | ||
|
|
e8deeabf2a | ||
|
|
11428ae984 | ||
|
|
27d2232eb3 | ||
|
|
e3eadd3728 | ||
|
|
12cfc6036a | ||
|
|
134eeb024b | ||
|
|
03fb18109f | ||
|
|
5af71ce907 | ||
|
|
baa281d99e | ||
|
|
7e9079b7cf | ||
|
|
61bd6b3ccb | ||
|
|
32e7327ca6 | ||
|
|
4f707ff91e | ||
|
|
e2671c184e | ||
|
|
b43a20b859 | ||
|
|
8869a30283 | ||
|
|
3b0eebeccf | ||
|
|
00991bfb42 | ||
|
|
e8338b54bd | ||
|
|
951666fb23 | ||
|
|
854b330967 | ||
|
|
654690e1b8 | ||
|
|
ac2be4c089 | ||
|
|
3d819e8e3e | ||
|
|
573983948a | ||
|
|
3f3c681a02 | ||
|
|
242e97d9dd | ||
|
|
ade45de770 | ||
|
|
0251fb33c4 | ||
|
|
0176586544 | ||
|
|
582804a59e | ||
|
|
afc3d55d39 | ||
|
|
f24bc7aed5 | ||
|
|
83b8ebdbbe | ||
|
|
a0abfd7339 | ||
|
|
403fedfeb5 | ||
|
|
f14c577fce | ||
|
|
67881c0c3b | ||
|
|
551087cd98 | ||
|
|
8da56a0cc5 | ||
|
|
0f01cd24e9 | ||
|
|
1ee98667cc | ||
|
|
6f9622fe1c | ||
|
|
5cb0251248 | ||
|
|
a214fd31bd | ||
|
|
62cd0de6cf | ||
|
|
b60512a75a | ||
|
|
5d88972390 | ||
|
|
3c0e67bbc5 | ||
|
|
3b7b20ac1d | ||
|
|
e71a8c6dd8 | ||
|
|
c687b5df9e | ||
|
|
1dd9c57a28 | ||
|
|
4525c2e0ad | ||
|
|
45dcd738b7 | ||
|
|
1dad86dc00 |
@@ -0,0 +1,16 @@
|
||||
# GitHub actions workflow.
|
||||
# https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions
|
||||
# https://black.readthedocs.io/en/stable/integrations/github_actions.html
|
||||
|
||||
name: Format
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: psf/black@stable
|
||||
@@ -27,6 +27,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install tox tox-gh-actions
|
||||
- name: Test with tox
|
||||
run: tox
|
||||
python -m pip install pytest
|
||||
- name: Run tests
|
||||
run: python -m pytest
|
||||
|
||||
@@ -6,8 +6,14 @@ __pycache__
|
||||
/dist
|
||||
.repopickle_*
|
||||
/repoc
|
||||
/.cipd_bin
|
||||
/.tox
|
||||
/.venv
|
||||
|
||||
# PyCharm related
|
||||
/.idea/
|
||||
|
||||
# AI tool related.
|
||||
/AGENTS.md
|
||||
/CLAUDE.md
|
||||
/GEMINI.md
|
||||
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
# Copyright (C) 2023 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.
|
||||
|
||||
# Config file for the isort python module.
|
||||
# This is used to enforce import sorting standards.
|
||||
#
|
||||
# https://pycqa.github.io/isort/docs/configuration/options.html
|
||||
|
||||
[settings]
|
||||
# Be compatible with `black` since it also matches what we want.
|
||||
profile = black
|
||||
|
||||
line_length = 80
|
||||
length_sort = false
|
||||
force_single_line = true
|
||||
lines_after_imports = 2
|
||||
from_first = false
|
||||
case_sensitive = false
|
||||
force_sort_within_sections = true
|
||||
order_by_type = false
|
||||
|
||||
# Ignore generated files.
|
||||
extend_skip_glob = *_pb2.py
|
||||
|
||||
# Allow importing multiple classes on a single line from these modules.
|
||||
# https://google.github.io/styleguide/pyguide#s2.2-imports
|
||||
single_line_exclusions =
|
||||
abc,
|
||||
collections.abc,
|
||||
typing,
|
||||
@@ -5,6 +5,5 @@
|
||||
<pydev_pathproperty name="org.python.pydev.PROJECT_SOURCE_PATH">
|
||||
<path>/git-repo</path>
|
||||
</pydev_pathproperty>
|
||||
<pydev_property name="org.python.pydev.PYTHON_PROJECT_VERSION">python 2.7</pydev_property>
|
||||
<pydev_property name="org.python.pydev.PYTHON_PROJECT_INTERPRETER">Default</pydev_property>
|
||||
</pydev_project>
|
||||
|
||||
+8
-17
@@ -24,7 +24,7 @@ However there are some differences, so please review and familiarize
|
||||
yourself with the following relevant bits.
|
||||
|
||||
|
||||
## Make separate commits for logically separate changes.
|
||||
## Make separate commits for logically separate changes
|
||||
|
||||
Unless your patch is really trivial, you should not be sending out a patch that
|
||||
was generated between your working tree and your commit head.
|
||||
@@ -43,17 +43,12 @@ probably need to split up your commit to finer grained pieces.
|
||||
|
||||
Lint any changes by running:
|
||||
```sh
|
||||
$ tox -e lint -- file.py
|
||||
$ flake8
|
||||
```
|
||||
|
||||
And format with:
|
||||
```sh
|
||||
$ tox -e format -- file.py
|
||||
```
|
||||
|
||||
Or format everything:
|
||||
```sh
|
||||
$ tox -e format
|
||||
$ black file.py
|
||||
```
|
||||
|
||||
Repo uses [black](https://black.readthedocs.io/) with line length of 80 as its
|
||||
@@ -73,15 +68,11 @@ the entire project in the included `.flake8` file.
|
||||
[PEP 8]: https://www.python.org/dev/peps/pep-0008/
|
||||
[flake8 documentation]: https://flake8.pycqa.org/en/3.1.1/user/ignoring-errors.html#in-line-ignoring-errors
|
||||
|
||||
|
||||
## Running tests
|
||||
|
||||
We use [pytest](https://pytest.org/) and [tox](https://tox.readthedocs.io/) for
|
||||
running tests. You should make sure to install those first.
|
||||
|
||||
To run the full suite against all supported Python versions, simply execute:
|
||||
```sh
|
||||
$ tox -p auto
|
||||
```
|
||||
We use [pytest](https://pytest.org/) for running tests. You should make sure to
|
||||
install that first.
|
||||
|
||||
We have [`./run_tests`](./run_tests) which is a simple wrapper around `pytest`:
|
||||
```sh
|
||||
@@ -127,7 +118,7 @@ your patch. It is virtually impossible to remove a patch once it
|
||||
has been applied and pushed out.
|
||||
|
||||
|
||||
## Sending your patches.
|
||||
## Sending your patches
|
||||
|
||||
Do not email your patches to anyone.
|
||||
|
||||
@@ -143,7 +134,7 @@ they have right to redistribute your work under the Apache License:
|
||||
|
||||
Ensure you have obtained an HTTP password to authenticate:
|
||||
|
||||
https://gerrit-review.googlesource.com/new-password
|
||||
https://www.googlesource.com/new-password
|
||||
|
||||
Ensure that you have the local commit hook installed to automatically
|
||||
add a ChangeId to your commits:
|
||||
|
||||
@@ -49,6 +49,43 @@ $ curl https://storage.googleapis.com/git-repo-downloads/repo > ~/.bin/repo
|
||||
$ chmod a+rx ~/.bin/repo
|
||||
```
|
||||
|
||||
## Shell Completion
|
||||
|
||||
Repo includes completion scripts for Bash and Zsh.
|
||||
|
||||
### Bash
|
||||
|
||||
To enable completion in Bash, source `completion.bash` in your `~/.bashrc`:
|
||||
|
||||
```sh
|
||||
source /path/to/git-repo/completion.bash
|
||||
```
|
||||
|
||||
### Zsh
|
||||
|
||||
To enable completion in Zsh, you can either:
|
||||
|
||||
1. Copy or symlink `completion.zsh` to a file named `_repo` in a directory in your `$fpath`:
|
||||
```sh
|
||||
mkdir -p ~/.zsh/completion
|
||||
# You can copy the file:
|
||||
cp /path/to/git-repo/completion.zsh ~/.zsh/completion/_repo
|
||||
# Or symlink it:
|
||||
ln -s /path/to/git-repo/completion.zsh ~/.zsh/completion/_repo
|
||||
```
|
||||
Then add that directory to your `fpath` in `~/.zshrc` before `compinit`:
|
||||
```zsh
|
||||
fpath=(~/.zsh/completion $fpath)
|
||||
autoload -Uz compinit
|
||||
compinit
|
||||
```
|
||||
|
||||
2. Or source the file directly and call `compdef` in your `~/.zshrc`:
|
||||
```zsh
|
||||
source /path/to/git-repo/completion.zsh
|
||||
compdef _repo repo
|
||||
```
|
||||
|
||||
|
||||
[new-bug]: https://issues.gerritcodereview.com/issues/new?component=1370071
|
||||
[issue tracker]: https://issues.gerritcodereview.com/issues?q=is:open%20componentid:1370071
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# git-repo AI Review Agents (WIP)
|
||||
|
||||
**Note:** This project is a work in progress and is subject to change.
|
||||
|
||||
This directory contains configurations and skills for AI review agents that
|
||||
automatically analyze changes in the `git-repo` codebase.
|
||||
|
||||
These agents help maintain code quality, enforce style guidelines, and catch
|
||||
common pitfalls before code is merged.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
* [`agent_configs.txtpb`](agent_configs.txtpb): Defines the active AI
|
||||
agents, their configurations, and which skills they are equipped with.
|
||||
* [`skills/`](skills/): Contains the "skills" (rules, guidelines, and traps)
|
||||
used by the agents.
|
||||
* [`code_review_workflow/`](skills/code_review_workflow/SKILL.md):
|
||||
Guidelines for code review processes, commit messages, and testing.
|
||||
* [`core_internals/`](skills/core_internals/SKILL.md): Technical
|
||||
guidelines for `git-repo` core logic (sync, manifest, git integration,
|
||||
etc.).
|
||||
|
||||
## How It Works
|
||||
|
||||
The agents defined in `agent_configs.txtpb` are configured to run
|
||||
automatically on new changes. They analyze the diffs against the rules defined
|
||||
in their respective skills and provide feedback in the code review interface
|
||||
(e.g., Gerrit).
|
||||
|
||||
## Contributing
|
||||
|
||||
To improve the agent's review quality or add new rules:
|
||||
|
||||
1. **Update existing skills**: Modify the `SKILL.md` files under `skills/` to
|
||||
add new rules, "What" explanations, "Why" rationales, and "Traps"
|
||||
(Don't/Do code examples).
|
||||
2. **Add new skills**:
|
||||
* Create a new directory under `skills/`.
|
||||
* Add a `SKILL.md` following the established format (see existing skills
|
||||
for reference).
|
||||
* Register the new skill in `agent_configs.txtpb` by adding it to the
|
||||
`skills` field of an agent configuration.
|
||||
@@ -0,0 +1,26 @@
|
||||
# proto-file: google/corp/android/engprod/codereviewagentconfiguration/v1/agent.proto
|
||||
# proto-message: HostAgents
|
||||
|
||||
# Code Review Workflow Agent
|
||||
configs {
|
||||
id: "code-review-workflow"
|
||||
display_name: "Code Review Workflow"
|
||||
description: "Analyzes git-repo Gerrit submission labeling, commit metadata, Python linting, and testing strategy."
|
||||
skills: "code_review_workflow"
|
||||
include_filters {
|
||||
project: "git-repo"
|
||||
}
|
||||
automatic: false
|
||||
}
|
||||
|
||||
# Core Internals Agent
|
||||
configs {
|
||||
id: "core-internals"
|
||||
display_name: "Core Internals"
|
||||
description: "Analyzes git-repo synchronization, multiprocessing, manifest parsing, git integration, worktree layouts, and CLI commands."
|
||||
skills: "core_internals"
|
||||
include_filters {
|
||||
project: "git-repo"
|
||||
}
|
||||
automatic: false
|
||||
}
|
||||
@@ -0,0 +1,968 @@
|
||||
---
|
||||
name: code-review-workflow
|
||||
description: Provides guidance and best practices on Gerrit submission labeling, CI builder execution, Python code formatting/linting, commit metadata standardization, and testing strategy in git-repo.
|
||||
---
|
||||
|
||||
# Code Review Workflow Engineering Guide
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Welcome to the authoritative engineering guide for the Code Review Workflow.
|
||||
This living repository exists to capture critical folk knowledge, prevent the
|
||||
recurrence of historical failure modes, and enforce strict architectural and
|
||||
procedural boundaries across our integration pipeline. By standardizing these
|
||||
protocols, we ensure high development velocity while maintaining rock-solid
|
||||
codebase stability and traceability.
|
||||
|
||||
This guide covers the complete lifecycle of a change list (CL) from local
|
||||
development to automated submission. It defines the strict Gerrit labeling
|
||||
mechanisms required to trigger the Commit-Queue, mandates comprehensive CI
|
||||
builder environment checks, and enforces centralized Python static analysis.
|
||||
Furthermore, it outlines uncompromising standards for atomic commit metadata and
|
||||
pragmatic testing state isolation to guarantee that every integration is fully
|
||||
bisectable and verifiable.
|
||||
|
||||
For incoming engineers, adherence to these mandates eliminates the friction of
|
||||
stalled pipelines, unreviewable monolithic changes, and silent CI regressions.
|
||||
Treat this guide as your primary roadmap for navigating the repository's strict
|
||||
submission requirements, enabling seamless transitions from peer approval to
|
||||
successfully integrated code.
|
||||
|
||||
## Summary
|
||||
|
||||
| Chapter Theme / Title | Scope & Objective |
|
||||
| :------------------------------- | :---------------------------------------- |
|
||||
| **Gerrit Submission and Labeling | Dictates strict access controls, review |
|
||||
: Workflow** : enforcement protocols, and Gerrit :
|
||||
: : labeling mechanisms required to advance :
|
||||
: : changes through the CI pipeline, ensuring :
|
||||
: : seamless transitions to automated :
|
||||
: : integration via the Commit-Queue. :
|
||||
| **CI Builder Environment and | Defines guidelines for ensuring build |
|
||||
: Execution Integrity** : script resilience against missing :
|
||||
: : dependencies and managing process :
|
||||
: : execution contexts within LUCI and local :
|
||||
: : testing environments to prevent silent :
|
||||
: : builder failures. :
|
||||
| **Python Code Formatting and | Governs the automated enforcement of |
|
||||
: Linting** : Python style guidelines, mandating strict :
|
||||
: : PEP-8 compliance, import sorting, and :
|
||||
: : consistent string quoting to ensure :
|
||||
: : codebase uniformity and prevent CI :
|
||||
: : regressions. :
|
||||
| **Commit Metadata and History | Establishes the structural composition |
|
||||
: Standardization** : and metadata formatting of change lists :
|
||||
: : (CLs) to ensure precise issue tracker :
|
||||
: : integration, reliable CI/CD parsing, and :
|
||||
: : an atomic, bisectable repository history. :
|
||||
| **Testing Strategy and State | Outlines test implementation boundaries, |
|
||||
: Isolation** : emphasizing pragmatic mocking limits to :
|
||||
: : prevent false positives and detailing :
|
||||
: : acceptable workflows for deferred test :
|
||||
: : coverage while maintaining verification :
|
||||
: : integrity. :
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
## Chapter: Gerrit Submission and Labeling Workflow
|
||||
|
||||
**Context:** This domain dictates the strict access controls, review enforcement
|
||||
protocols, and specific Gerrit labeling mechanisms required to advance changes
|
||||
through the CI pipeline. Adherence ensures seamless transitions from peer
|
||||
approval to automated integration via the Commit-Queue.
|
||||
|
||||
### Summary
|
||||
|
||||
| Rule ID | Principle / Constraint | Priority | Primary Symptom / |
|
||||
: : : : Trap :
|
||||
| :-------- | :------------------------------ | :------- | :----------------- |
|
||||
| **T1-01** | Explicit Labeling for Gerrit | High | Leaving a change |
|
||||
: : Automated Submission : : idle after :
|
||||
: : : : addressing :
|
||||
: : : : comments or :
|
||||
: : : : receiving a :
|
||||
: : : : reviewer's LGTM, :
|
||||
: : : : expecting the :
|
||||
: : : : reviewer to merge :
|
||||
: : : : it. :
|
||||
| **T1-02** | Automated Submission via | Medium | Requesting a |
|
||||
: : Commit-Queue (CQ) : : manual push or :
|
||||
: : : : direct submit from :
|
||||
: : : : repository :
|
||||
: : : : maintainers after :
|
||||
: : : : receiving code :
|
||||
: : : : review approval. :
|
||||
| **T1-03** | Gerrit Trusted Contributor | Medium | Relying on a |
|
||||
: : Review Enforcement Verification : : standard +2 vote :
|
||||
: : : : from a non-trusted :
|
||||
: : : : contributor to :
|
||||
: : : : fulfill strict :
|
||||
: : : : Review-Enforcement :
|
||||
: : : : requirements. :
|
||||
| **T1-04** | Mandatory Gerrit Labels for | High | Acknowledging an |
|
||||
: : Automated Submission : : approval but :
|
||||
: : : : failing to apply :
|
||||
: : : : the appropriate :
|
||||
: : : : Gerrit labels to :
|
||||
: : : : initiate the merge :
|
||||
: : : : pipeline. :
|
||||
| **T1-05** | Gerrit Automated Submission | Medium | Leaving an |
|
||||
: : Triggers : : approved patchset :
|
||||
: : : : idle and waiting :
|
||||
: : : : for maintainers to :
|
||||
: : : : manually merge it. :
|
||||
| **T1-06** | Active Reviewer Rerouting for | Medium | Waiting weeks or |
|
||||
: : Stalled Changes : : months for an :
|
||||
: : : : inactive or OOO :
|
||||
: : : : reviewer to :
|
||||
: : : : respond to a :
|
||||
: : : : patchset update. :
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
### Rules
|
||||
|
||||
#### T1-01: Explicit Labeling for Gerrit Automated Submission
|
||||
|
||||
> **Rule:** Always apply `Verified+1` and `Commit-Queue+2` explicitly to trigger
|
||||
> the final submission phase. Never assume a code approval automatically
|
||||
> initiates the pipeline.
|
||||
>
|
||||
> **What:** Changes are not merged automatically upon receiving approval;
|
||||
> contributors must explicitly set the `Verified+1` and `Commit-Queue+2` labels
|
||||
> to trigger the final submission phase.
|
||||
>
|
||||
> **Applies To:** Gerrit review UI and change submission pipeline as defined in
|
||||
> `CONTRIBUTING.md`.
|
||||
>
|
||||
> **Why:** Contributors often mistakenly assume an LGTM implies an immediate
|
||||
> merge, leading to stalled changes. The project relies on explicitly triggering
|
||||
> the Commit-Queue to finalize CI checks and perform the merge. Failing to
|
||||
> adhere to this typically results in **Stalled Submission Pipeline**.
|
||||
|
||||
**Trap 1: Leaving a change idle after addressing comments or receiving a
|
||||
reviewer's LGTM, expecting the reviewer to merge it.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Waiting indefinitely after reviewer posts 'LGTM'.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Vote `Verified+1` and `Commit-Queue+2` manually to submit the change to the
|
||||
automated queue.
|
||||
|
||||
**Exceptions:** Contributors lacking trusted permissions must ping a repository
|
||||
maintainer to apply the final `Commit-Queue+2` vote.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T1-02: Automated Submission via Commit-Queue (CQ)
|
||||
|
||||
> **Rule:** Must utilize the Gerrit Commit-Queue (CQ) labeling system to merge
|
||||
> code. Maintainers must never perform direct manual submissions.
|
||||
>
|
||||
> **What:** Merging code must be triggered via the Gerrit Commit-Queue (CQ)
|
||||
> labeling system rather than relying on direct manual submission by
|
||||
> maintainers.
|
||||
>
|
||||
> **Applies To:** Gerrit code review UI and CI/CD submission workflow.
|
||||
>
|
||||
> **Why:** Contributors would request maintainers to directly merge patches once
|
||||
> approved, bypassing the automated commit-queue pipeline, which guarantees that
|
||||
> final integration tests pass before pushing to the target branch. Failing to
|
||||
> adhere to this typically results in **Bypassed CI / Direct Submit**.
|
||||
|
||||
**Trap 1: Requesting a manual push or direct submit from repository maintainers
|
||||
after receiving code review approval.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Leaving a comment: "I believe everything is ready for integrating this. So
|
||||
if either of you can submit it, it would be appreciated."
|
||||
|
||||
**Do:**
|
||||
|
||||
* Applying the `Commit-Queue+2` (CQ+2) label in Gerrit, which delegates
|
||||
testing and the final merge operation to the automated bot.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T1-03: Gerrit Trusted Contributor Review Enforcement Verification
|
||||
|
||||
> **Rule:** Verify review enforcement requirements are satisfied by contributors
|
||||
> within the explicitly configured trusted group. Never cast misleading +2 votes
|
||||
> if you lack valid trusted group privileges.
|
||||
>
|
||||
> **What:** Gerrit submission requirements may mandate specific approval levels
|
||||
> (e.g., two trusted contributors). Votes from users with +2 access who are not
|
||||
> in the designated 'trusted' group do not satisfy the 'Review-Enforcement'
|
||||
> submit requirement.
|
||||
>
|
||||
> **Applies To:** Gerrit repository administration and code review voting
|
||||
> workflows.
|
||||
>
|
||||
> **Why:** Non-trusted contributors with +2 rights were casting +2 votes on
|
||||
> changes. These votes did not fulfill the 'Two trusted contributors'
|
||||
> Review-Enforcement requirement, leading to stalled submissions and confusion
|
||||
> regarding why the UI showed a +2 but blocked submission. Failing to adhere to
|
||||
> this typically results in **Blocked Submission / Silent Requirement Failure**.
|
||||
|
||||
**Trap 1: Relying on a standard +2 vote from a non-trusted contributor to
|
||||
fulfill strict Review-Enforcement requirements.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Leaving a +2 vote on a change as a non-trusted contributor, creating the
|
||||
false appearance that the Review-Enforcement requirement has been partially
|
||||
or fully met.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Verifying the reviewer is in the explicitly configured trusted group for the
|
||||
repository. If not, the reviewer should manually downgrade their invalid +2
|
||||
vote to a +1 to clearly indicate that their vote does not count toward the
|
||||
enforcement threshold.
|
||||
|
||||
**Exceptions:** Repositories where specific non-employee groups have been
|
||||
explicitly added to the trusted administrators list.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T1-04: Mandatory Gerrit Labels for Automated Submission
|
||||
|
||||
> **Rule:** Always apply `Verified+1` and `Commit-Queue+2` labels to initiate
|
||||
> the CI merge process. Never leave an approved CL in a technically unlabeled
|
||||
> state.
|
||||
>
|
||||
> **What:** A code change must receive explicit `Verified+1` and
|
||||
> `Commit-Queue+2` labels by the author or reviewer to trigger the automated CI
|
||||
> merge process.
|
||||
>
|
||||
> **Applies To:** Gerrit workflow / Merge execution phase.
|
||||
>
|
||||
> **Why:** Historically, leaving a Change List (CL) in an approved but unlabeled
|
||||
> state causes the integration pipeline to stall indefinitely, requiring manual
|
||||
> intervention or reviewer pinging to trigger the CI queue. Failing to adhere to
|
||||
> this typically results in **Merge Pipeline Stall**.
|
||||
|
||||
**Trap 1: Acknowledging an approval but failing to apply the appropriate Gerrit
|
||||
labels to initiate the merge pipeline.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Leaving the CL in an approved state and waiting for auto-submission without
|
||||
applying the `Verified+1` or `Commit-Queue+2` labels.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Explicitly applying `Verified+1` (and `Commit-Queue+2` if ready) once
|
||||
reviewers have approved the logic, to instruct the automation to merge the
|
||||
code.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T1-05: Gerrit Automated Submission Triggers
|
||||
|
||||
> **Rule:** Must actively signal patch readiness to Gerrit systems using proper
|
||||
> label thresholds. Avoid leaving patchsets idle assuming upstream maintainer
|
||||
> action.
|
||||
>
|
||||
> **What:** A patchset requires specific label thresholds ('Verified+1' and
|
||||
> 'Commit-Queue+2') to trigger automated submission in the Gerrit workflow.
|
||||
>
|
||||
> **Applies To:** Gerrit review UI and automated CI/CD submission process for
|
||||
> the git-repo codebase.
|
||||
>
|
||||
> **Why:** Contributors frequently asked how to integrate changes after
|
||||
> receiving an approval, leading to stalled patches because the automated
|
||||
> pipeline was not explicitly triggered. Failing to adhere to this typically
|
||||
> results in **Stalled Patch Integration**.
|
||||
|
||||
**Trap 1: Leaving an approved patchset idle and waiting for maintainers to
|
||||
manually merge it.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Waiting indefinitely after receiving an 'LGTM' without setting workflow
|
||||
labels.
|
||||
|
||||
**Do:**
|
||||
|
||||
* The patch author manually sets the 'Verified' flag (if locally tested) and
|
||||
applies the 'Commit-Queue+2' vote to signal readiness for automated merge.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T1-06: Active Reviewer Rerouting for Stalled Changes
|
||||
|
||||
> **Rule:** Actively reroute reviews stalled by unresponsive or out-of-office
|
||||
> (OOO) primary reviewers. Must explicitly tag alternate maintainers and
|
||||
> document the absence to prevent lifecycle stalls.
|
||||
>
|
||||
> **What:** If the primary reviewer is out-of-office (OOO) or unresponsive for
|
||||
> an extended period, contributors must actively CC and reroute the review to
|
||||
> another active maintainer.
|
||||
>
|
||||
> **Applies To:** Gerrit review cycle and reviewer assignment process.
|
||||
>
|
||||
> **Why:** Patchsets have historically stalled for over a month due to reviewers
|
||||
> taking extended leave without actively delegating their review queues. Failing
|
||||
> to adhere to this typically results in **Indefinite Review Stalls**.
|
||||
|
||||
**Trap 1: Waiting weeks or months for an inactive or OOO reviewer to respond to
|
||||
a patchset update.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Leaving a review assigned strictly to an unresponsive reviewer without
|
||||
notifying other maintainers or attempting to escalate.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Tag a new reviewer with 'PTAL' (Please Take A Look) in the thread,
|
||||
explicitly noting the original reviewer's absence, and confirm alignment
|
||||
with the original author.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
### Cross-Domain Dependencies
|
||||
|
||||
* **Upstream:** T4 | Python Code Formatting and Linting - *Proper formatting
|
||||
and static analysis are enforced before changes become eligible for final
|
||||
Gerrit review and automated integration.*
|
||||
* **Upstream:** T5 | Commit Metadata and History Standardization - *Accurate
|
||||
commit messaging and isolated history must be validated by reviewers prior
|
||||
to receiving approval labels.*
|
||||
* **Downstream:** T3 | CI Builder Environment and Execution Integrity -
|
||||
*Triggering the Commit-Queue directly invokes downstream LUCI environments
|
||||
to guarantee execution integrity prior to branch merge.*
|
||||
|
||||
## Chapter: CI Builder Environment and Execution Integrity
|
||||
|
||||
**Context:** This section defines strict guidelines for ensuring the resilience
|
||||
of build scripts against missing dependencies and managing process execution
|
||||
contexts within LUCI and local testing environments. Adherence guarantees robust
|
||||
verification across diverse operating systems and CI pipelines while preventing
|
||||
silent builder failures.
|
||||
|
||||
### Summary
|
||||
|
||||
| Rule ID | Principle / Constraint | Priority | Primary Symptom / Trap |
|
||||
| :-------- | :------------------------ | :------- | :------------------------ |
|
||||
| **T3-01** | Verification Against | High | Running a standard local |
|
||||
: : Breaking Change Build : : `make` without testing :
|
||||
: : Configurations : : strict configurations or :
|
||||
: : : : breaking-change flags. :
|
||||
| **T3-02** | Windows Developer Mode | Medium | Attempting to run full |
|
||||
: : Requirements for Tool : : local verification on a :
|
||||
: : Verification : : standard Windows user :
|
||||
: : : : account. :
|
||||
| **T3-03** | Graceful Degradation for | Medium | Assuming all local |
|
||||
: : Missing Builder Utilities : : developer utilities exist :
|
||||
: : : : in the strict CI builder :
|
||||
: : : : environment and :
|
||||
: : : : unconditionally executing :
|
||||
: : : : them. :
|
||||
| **T3-04** | Contextual Diagnostic | High | Observing a generic CI |
|
||||
: : Logging for LUCI CI : : failure without isolating :
|
||||
: : Failures : : the specific process :
|
||||
: : : : execution context or :
|
||||
: : : : dependency resolution :
|
||||
: : : : step. :
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
### Rules
|
||||
|
||||
#### T3-01: Verification Against Breaking Change Build Configurations
|
||||
|
||||
> **Rule:** Always explicitly test core build structure modifications with
|
||||
> breaking changes enabled to ensure forward compatibility.
|
||||
>
|
||||
> **What:** When modifying core build structures, the build must be tested
|
||||
> explicitly with breaking changes enabled to ensure forward compatibility and
|
||||
> correct regeneration of generated files.
|
||||
>
|
||||
> **Applies To:** Local build environments and Makefile targets.
|
||||
>
|
||||
> **Why:** Changes might succeed in a standard default build but fail when
|
||||
> breaking change toggles are activated, hiding underlying dependency or
|
||||
> regeneration issues. Failing to adhere to this typically results in **Build
|
||||
> Breakage / Stale Artifacts**.
|
||||
|
||||
**Trap 1: Running a standard local `make` without testing strict configurations
|
||||
or breaking-change flags.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
```bash
|
||||
make -j
|
||||
```
|
||||
|
||||
**Do:**
|
||||
|
||||
```bash
|
||||
make -j WITH_BREAKING_CHANGES=1
|
||||
```
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T3-02: Windows Developer Mode Requirements for Tool Verification
|
||||
|
||||
> **Rule:** Must execute local tool verification on Windows (gWindows) using an
|
||||
> Administrator account to enable Developer Mode.
|
||||
>
|
||||
> **What:** Local verification of git-repo tooling on Windows (gWindows)
|
||||
> explicitly requires the host environment to be running with Administrator
|
||||
> privileges to enable Developer Mode.
|
||||
>
|
||||
> **Applies To:** Windows (gWindows) test environments verifying file system
|
||||
> operations.
|
||||
>
|
||||
> **Why:** Without Developer Mode enabled (which necessitates Admin rights),
|
||||
> features relying on advanced OS-level file system operations (like symlinks)
|
||||
> cannot execute, permanently blocking full local test suite execution on
|
||||
> standard accounts. Failing to adhere to this typically results in
|
||||
> **Verification Blocked / OS Permission Error**.
|
||||
|
||||
**Trap 1: Attempting to run full local verification on a standard Windows user
|
||||
account.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Executing the test suite from a non-elevated command prompt on Windows
|
||||
without Developer Mode.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Elevate to an Administrator account to enable Developer Mode before
|
||||
executing the test suite on gWindows.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T3-03: Graceful Degradation for Missing Builder Utilities
|
||||
|
||||
> **Rule:** Always implement auto-skip logic for optional utilities in build
|
||||
> scripts rather than hard-failing when unavailable on the CI builder.
|
||||
>
|
||||
> **What:** Build scripts and test suites must implement auto-skip logic for
|
||||
> optional, environment-specific utilities rather than hard-failing when the
|
||||
> utility is unavailable on the CI builder.
|
||||
>
|
||||
> **Applies To:** CI Builder environment scripts and test suites, specifically
|
||||
> testing external CLI utilities (e.g., `help2man`).
|
||||
>
|
||||
> **Why:** When a required utility was not pre-installed on the CI builder
|
||||
> image, the build hard-failed. Adding auto-skip logic allows the CI pipeline to
|
||||
> remain unblocked while still providing local testing benefits for developers
|
||||
> who have the tool installed. Failing to adhere to this typically results in
|
||||
> **Build Failure / Blocked CI**.
|
||||
|
||||
**Trap 1: Assuming all local developer utilities exist in the strict CI builder
|
||||
environment and unconditionally executing them.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
```python
|
||||
# BAD: Hard failure if utility is missing
|
||||
subprocess.run(["help2man", "repo"], check=True)
|
||||
```
|
||||
|
||||
**Do:**
|
||||
|
||||
```python
|
||||
# GOOD: Auto-skip test if utility is missing in the environment
|
||||
if not shutil.which("help2man"):
|
||||
self.skipTest("help2man not installed")
|
||||
subprocess.run(["help2man", "repo"], check=True)
|
||||
```
|
||||
|
||||
**Exceptions:** Core dependencies required for fundamental build steps cannot be
|
||||
skipped and must be installed on the bot image.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T3-04: Contextual Diagnostic Logging for LUCI CI Failures
|
||||
|
||||
> **Rule:** Must investigate CI builder failures by extracting and analyzing
|
||||
> full execution context logs to isolate environmental roadblocks.
|
||||
>
|
||||
> **What:** CI builder failures must be investigated using full execution
|
||||
> context logs (e.g., LUCI context, vpython3 resolution, and retcode outputs) to
|
||||
> isolate environmental roadblocks.
|
||||
>
|
||||
> **Applies To:** LUCI builder execution environment, vpython3 resolution, and
|
||||
> CI pipeline debugging.
|
||||
>
|
||||
> **Why:** CI commands failed with `retcode 1` due to external factors like
|
||||
> specific URLs being flagged as suspect by internal security tools, breaking
|
||||
> the build environment. Failing to adhere to this typically results in **Silent
|
||||
> Builder Failure**.
|
||||
|
||||
**Trap 1: Observing a generic CI failure without isolating the specific process
|
||||
execution context or dependency resolution step.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Restarting the CI pipeline blindly when a job fails with a generic retcode,
|
||||
ignoring potential external network or security blockers.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Extract the step-by-step LUCI context log, verify path resolution (e.g.,
|
||||
CIPD packages), and explicitly document external blockers like security
|
||||
flags in the review.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
### Cross-Domain Dependencies
|
||||
|
||||
* **Upstream:** T6 | Testing Strategy and State Isolation - *Test
|
||||
implementation dictates how missing builder utilities are mocked or
|
||||
gracefully skipped during execution.*
|
||||
* **Downstream:** T1 | Gerrit Submission and Labeling Workflow - *Automated
|
||||
Verified+1 labels rely entirely on the stable, unblocked execution of CI
|
||||
builder pipelines.*
|
||||
|
||||
## Chapter: Python Code Formatting and Linting
|
||||
|
||||
**Context:** This domain governs the automated enforcement of Python style
|
||||
guidelines, mandating strict PEP-8 compliance, import sorting, and consistent
|
||||
string quoting. All Python modifications must pass centralized static analysis
|
||||
pipelines before integration to ensure codebase uniformity and prevent CI
|
||||
regressions.
|
||||
|
||||
### Summary
|
||||
|
||||
| Rule ID | Principle / Constraint | Priority | Primary Symptom / Trap |
|
||||
| :-------- | :----------------------- | :------- | :------------------------ |
|
||||
| **T4-01** | Automated Flake8 | Medium | Relying purely on manual |
|
||||
: : Post-Submit Verification : : code review or sporadic :
|
||||
: : : : local linting without a :
|
||||
: : : : continuous integration :
|
||||
: : : : check. :
|
||||
| **T4-02** | Mandatory Python | High | Using single quotes for |
|
||||
: : Formatting and Import : : strings and appending new :
|
||||
: : Sorting : : imports to the bottom of :
|
||||
: : : : the import block without :
|
||||
: : : : alphabetical or :
|
||||
: : : : categorical sorting. :
|
||||
| **T4-03** | Strict Python Import | High | Mixing local application |
|
||||
: : Ordering : : imports with standard :
|
||||
: : : : library imports, causing :
|
||||
: : : : linting tools to fail the :
|
||||
: : : : CQ job. :
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
### Rules
|
||||
|
||||
#### T4-01: Automated Flake8 Post-Submit Verification
|
||||
|
||||
> **Rule:** Always configure and maintain centralized CI workflows to
|
||||
> automatically run static analysis and validate Python code styling
|
||||
> post-submit.
|
||||
>
|
||||
> **What:** Static analysis and Python linting must be automated via a
|
||||
> centralized CI pipeline (e.g., Flake8 post-submit workflows) to enforce
|
||||
> consistent style and prevent basic errors.
|
||||
>
|
||||
> **Applies To:** All Python files in the git-repo codebase; specifically
|
||||
> validated via `.github/workflows/flake8-postsubmit.yml`.
|
||||
>
|
||||
> **Why:** Relying strictly on manual code review to catch styling and linting
|
||||
> violations is error-prone. Automation ensures a baseline of code quality on
|
||||
> every code push without consuming human review cycles. Failing to adhere to
|
||||
> this typically results in **Linting Regression / Style Violation**.
|
||||
|
||||
**Trap 1: Relying purely on manual code review or sporadic local linting without
|
||||
a continuous integration check.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Committing Python code without an active CI linting workflow configuration.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Maintain `.github/workflows/flake8-postsubmit.yml` to automatically run
|
||||
flake8 on target branches.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T4-02: Mandatory Python Formatting and Import Sorting
|
||||
|
||||
> **Rule:** Must format Python code to enforce double-quoted strings and
|
||||
> alphabetically sorted import blocks to satisfy automated formatting checks.
|
||||
>
|
||||
> **What:** Python code modifications must pass automated style and linting
|
||||
> checks ('Verify git-repo CL'), which strictly enforce string quote conventions
|
||||
> (preferring double quotes), import block sorting, and PEP-8 style formatting.
|
||||
>
|
||||
> **Applies To:** All Python source files modified in the git-repo codebase.
|
||||
>
|
||||
> **Why:** Developers submitting patches with single-quoted strings or unsorted
|
||||
> imports triggered automated CI failures in the `Verify git-repo CL` job,
|
||||
> completely blocking code submission until formatting tools were executed
|
||||
> locally. Failing to adhere to this typically results in **CI Pipeline
|
||||
> Failure**.
|
||||
|
||||
**Trap 1: Using single quotes for strings and appending new imports to the
|
||||
bottom of the import block without alphabetical or categorical sorting.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
```python
|
||||
import sys
|
||||
import os
|
||||
|
||||
msg = 'This is an error'
|
||||
```
|
||||
|
||||
**Do:**
|
||||
|
||||
```python
|
||||
import os
|
||||
import sys
|
||||
|
||||
msg = "This is an error"
|
||||
```
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T4-03: Strict Python Import Ordering
|
||||
|
||||
> **Rule:** Always segment and order Python imports strictly according to
|
||||
> project standards (standard library, third-party, local) to prevent CQ
|
||||
> pipeline failures.
|
||||
>
|
||||
> **What:** Python module imports must adhere strictly to the project's
|
||||
> formatting rules (e.g., standard library, third-party, local module ordering)
|
||||
> to pass automated Commit-Queue (CQ) checks.
|
||||
>
|
||||
> **Applies To:** Python source files.
|
||||
>
|
||||
> **Why:** Non-standard import blocks cause the automated CI/CQ linting pipeline
|
||||
> to fail, completely blocking submission even if the core functional logic of
|
||||
> the patch is flawless. Failing to adhere to this typically results in **CI
|
||||
> Linting Failure**.
|
||||
|
||||
**Trap 1: Mixing local application imports with standard library imports,
|
||||
causing linting tools to fail the CQ job.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
```python
|
||||
import sys
|
||||
import my_local_module
|
||||
import os
|
||||
```
|
||||
|
||||
**Do:**
|
||||
|
||||
```python
|
||||
import os
|
||||
import sys
|
||||
|
||||
import my_local_module
|
||||
```
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
### Cross-Domain Dependencies
|
||||
|
||||
* **Upstream:** T3 | CI Builder Environment and Execution Integrity -
|
||||
*Reliable CI builder environments must be available to execute the static
|
||||
analysis and Python formatting verifications.*
|
||||
* **Downstream:** T1 | Gerrit Submission and Labeling Workflow - *Formatting
|
||||
and linting rules must be fully satisfied before automated mechanisms like
|
||||
the Commit-Queue (CQ+2) will merge code into the repository.*
|
||||
|
||||
## Chapter: Commit Metadata and History Standardization
|
||||
|
||||
**Context:** This domain governs the structural composition and metadata
|
||||
formatting of change lists (CLs) within the git-repo codebase. Strict adherence
|
||||
ensures precise issue tracker integration, reliable CI/CD parsing, and atomic,
|
||||
bisectable repository history.
|
||||
|
||||
### Summary
|
||||
|
||||
| Rule ID | Principle / Constraint | Priority | Primary Symptom / Trap |
|
||||
| :-------- | :------------------------ | :------- | :------------------------ |
|
||||
| **T5-01** | Strict Commit Message Bug | Medium | Providing free-text |
|
||||
: : Tag Formatting : : descriptions, arbitrary :
|
||||
: : : : prefixes, or non-standard :
|
||||
: : : : bug references in the :
|
||||
: : : : commit block. :
|
||||
| **T5-02** | Atomic and Bisectable | High | Waiting for an entire |
|
||||
: : Change Integration : : feature stack of multiple :
|
||||
: : : : interdependent CLs to be :
|
||||
: : : : approved before merging :
|
||||
: : : : the base commits. :
|
||||
| **T5-03** | Explicit Bug Tracker | Medium | Submitting a fix or |
|
||||
: : Linking for Context : : revert without :
|
||||
: : Restoration : : referencing the :
|
||||
: : : : corresponding bug tracker :
|
||||
: : : : issue detailing the :
|
||||
: : : : specific regression or :
|
||||
: : : : stack trace. :
|
||||
| **T5-04** | Atomic Change List | Medium | Submitting a single large |
|
||||
: : Decomposition : : CL that touches multiple :
|
||||
: : : : isolated components or :
|
||||
: : : : implements several :
|
||||
: : : : distinct features :
|
||||
: : : : simultaneously. :
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
### Rules
|
||||
|
||||
#### T5-01: Strict Commit Message Bug Tag Formatting
|
||||
|
||||
> **Rule:** Must use the exact `Bug: <number>` syntax in commit messages to
|
||||
> properly link issue trackers.
|
||||
>
|
||||
> **What:** Commit messages must link directly to issue trackers using the
|
||||
> explicit 'Bug: <number>' syntax to allow reliable parsing by CI/CD and history
|
||||
> tracking systems.
|
||||
>
|
||||
> **Applies To:** Commit messages across all git-repo changes.
|
||||
>
|
||||
> **Why:** Improperly formatted bug tags fail to link with the external issue
|
||||
> tracker, severing historical context and breaking automated post-submit
|
||||
> tracking workflows. Failing to adhere to this typically results in **Broken
|
||||
> Traceability / Pre-submit Failure**.
|
||||
|
||||
**Trap 1: Providing free-text descriptions, arbitrary prefixes, or non-standard
|
||||
bug references in the commit block.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
```text
|
||||
Fixes bug 486536908
|
||||
Closes issue 486536908
|
||||
```
|
||||
|
||||
**Do:**
|
||||
|
||||
```text
|
||||
Bug: 486536908
|
||||
```
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T5-02: Atomic and Bisectable Change Integration
|
||||
|
||||
> **Rule:** Always submit code incrementally as isolated, functional units
|
||||
> rather than hoarding monolithic stacks.
|
||||
>
|
||||
> **What:** Code changes must be submitted incrementally as isolated, functional
|
||||
> units rather than waiting to merge a massive interdependent stack all at once.
|
||||
>
|
||||
> **Applies To:** Git commit history, PR structuring, and stack-based code
|
||||
> integration.
|
||||
>
|
||||
> **Why:** Contributors accustomed to integrating full monolithic stacks at once
|
||||
> held off on landing initial, stable changes. This practice hinders the ability
|
||||
> to isolate regressions via `git bisect` and prevents foundational code from
|
||||
> "baking" in production. Failing to adhere to this typically results in
|
||||
> **Bisection Breakage / Monolithic Rollbacks**.
|
||||
|
||||
**Trap 1: Waiting for an entire feature stack of multiple interdependent CLs to
|
||||
be approved before merging the base commits.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Holding all changes in a stack locally or in code review until the final
|
||||
feature patch is approved, then landing 10+ patches simultaneously.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Landing initial, independent CLs one-by-one as soon as they are approved.
|
||||
Ensuring each commit is independently usable and does not break the build.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T5-03: Explicit Bug Tracker Linking for Context Restoration
|
||||
|
||||
> **Rule:** Must include a direct URL to the relevant bug tracker issue
|
||||
> documenting the failure traceback when submitting a regression fix or revert.
|
||||
>
|
||||
> **What:** When submitting a change (especially a revert or bug fix) addressing
|
||||
> a specific runtime regression, the commit metadata or patchset-level comments
|
||||
> must include a direct link to the bug tracker issue documenting the failure
|
||||
> traceback.
|
||||
>
|
||||
> **Applies To:** Commit messages and patchset documentation during code
|
||||
> reviews, particularly for reverts.
|
||||
>
|
||||
> **Why:** A previous commit caused a runtime regression (e.g., an
|
||||
> AttributeError related to a missing object attribute). Without linking the
|
||||
> specific issue containing the traceback, reviewers lacked the necessary
|
||||
> context to justify restoring the previous codebase state. Failing to adhere to
|
||||
> this typically results in **Undocumented Regression / Context Loss**.
|
||||
|
||||
**Trap 1: Submitting a fix or revert without referencing the corresponding bug
|
||||
tracker issue detailing the specific regression or stack trace.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Reverting a change with a vague description like "Fixing previous breakage"
|
||||
or "Reverting due to pipeline failure" without providing the traceback
|
||||
source.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Linking the specific issue tracker URL containing the exact failure mode.
|
||||
Example: "for more context, see
|
||||
https://g-issues.gerritcodereview.com/issues/[ISSUE_ID]#comment4"
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T5-04: Atomic Change List Decomposition
|
||||
|
||||
> **Rule:** Never submit large, monolithic change lists; always decompose them
|
||||
> into logically independent patchsets.
|
||||
>
|
||||
> **What:** Large, monolithic change lists (CLs) must be broken down into
|
||||
> smaller, logically independent patchsets to ensure accurate review and
|
||||
> historical bisectability.
|
||||
>
|
||||
> **Applies To:** Version control history and code review scoping.
|
||||
>
|
||||
> **Why:** Massive CLs heavily increase reviewer cognitive load, making thorough
|
||||
> reviews impossible and complicating future `git bisect` operations when
|
||||
> tracking down the origin of a regression. Failing to adhere to this typically
|
||||
> results in **Unreviewable Monolithic Change**.
|
||||
|
||||
**Trap 1: Submitting a single large CL that touches multiple isolated components
|
||||
or implements several distinct features simultaneously.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* A single CL containing sweeping refactoring, new feature implementation, and
|
||||
unrelated bug fixes.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Breaking the monolithic change into smaller, logically dependent or
|
||||
independent CLs where each addresses one specific piece of the feature or
|
||||
refactor.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
### Cross-Domain Dependencies
|
||||
|
||||
* **Downstream:** T1 | Gerrit Submission and Labeling Workflow - *Gerrit and
|
||||
CI pipelines strictly rely on standardized commit metadata to link tracking
|
||||
issues and depend on atomic patchsets to execute automated review and
|
||||
verification correctly.*
|
||||
|
||||
## Chapter: Testing Strategy and State Isolation
|
||||
|
||||
**Context:** This chapter governs test implementation boundaries, emphasizing
|
||||
pragmatic mocking limits to prevent false positives and detailing acceptable
|
||||
workflows for deferred test coverage. Strict adherence ensures robust state
|
||||
isolation and maintains development velocity without compromising verification
|
||||
integrity.
|
||||
|
||||
### Summary
|
||||
|
||||
| Rule ID | Principle / Constraint | Priority | Primary Symptom / Trap |
|
||||
| :-------- | :----------------------- | :------- | :------------------------- |
|
||||
| **T6-01** | Pragmatic Mocking | Medium | Mocking the entire core |
|
||||
: : Boundaries in Unit Tests : : state or framework :
|
||||
: : : : dependencies just to force :
|
||||
: : : : a unit test for a highly :
|
||||
: : : : integrated function. :
|
||||
| **T6-02** | Deferred Test | Medium | Submitting functional code |
|
||||
: : Implementation via : : without matching test :
|
||||
: : Follow-up : : coverage and stalling the :
|
||||
: : : : merge while complex tests :
|
||||
: : : : are written. :
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
### Rules
|
||||
|
||||
#### T6-01: Pragmatic Mocking Boundaries in Unit Tests
|
||||
|
||||
> **Rule:** Always restrict unit tests to isolated methods and avoid aggressive
|
||||
> mocking of core functionality to prevent brittle, false-positive verification.
|
||||
>
|
||||
> **What:** Do not aggressively mock core functionality in unit tests; restrict
|
||||
> unit tests to isolated methods to avoid creating brittle tests based on false
|
||||
> assumptions when an integration framework is unavailable.
|
||||
>
|
||||
> **Applies To:** Test suite implementation (Unit vs. Integration testing
|
||||
> boundaries).
|
||||
>
|
||||
> **Why:** Over-mocking complex systems in unit tests leads to scenarios where
|
||||
> tests pass but the core integration fails in production because the unit test
|
||||
> mocks assumed incorrect behavior about the underlying environment. Failing to
|
||||
> adhere to this typically results in **False Positive Test Passage**.
|
||||
|
||||
**Trap 1: Mocking the entire core state or framework dependencies just to force
|
||||
a unit test for a highly integrated function.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Mocking file systems, external processes, and global state heavily to test a
|
||||
core workflow orchestrator in a unit test suite.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Limiting unit tests strictly to isolated utility methods (e.g., adding
|
||||
promisor files) and explicitly documenting testing gaps that require
|
||||
integration test frameworks.
|
||||
|
||||
**Exceptions:** Isolated helper methods or purely functional data
|
||||
transformations should be fully unit tested with appropriate mocked inputs.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
#### T6-02: Deferred Test Implementation via Follow-up
|
||||
|
||||
> **Rule:** Never stall critical feature merges indefinitely for test
|
||||
> implementation if maintainers authorize formalized, immediate follow-up test
|
||||
> coverage.
|
||||
>
|
||||
> **What:** New logic requires automated tests; however, reviewers may permit
|
||||
> test coverage to be implemented in a subsequent follow-up CL to maintain
|
||||
> development velocity.
|
||||
>
|
||||
> **Applies To:** Feature development, regression testing, and code review
|
||||
> criteria.
|
||||
>
|
||||
> **Why:** Reviewers identified a lack of test coverage for new functionality
|
||||
> but opted not to block the immediate patchset, instead formalizing the test
|
||||
> requirement as a near-term follow-up task. Failing to adhere to this typically
|
||||
> results in **Missing Test Coverage**.
|
||||
|
||||
**Trap 1: Submitting functional code without matching test coverage and stalling
|
||||
the merge while complex tests are written.**
|
||||
|
||||
**Don't:**
|
||||
|
||||
* Blocking a necessary feature indefinitely due to missing unit tests when a
|
||||
follow-up CL is viable and acceptable to maintainers.
|
||||
|
||||
**Do:**
|
||||
|
||||
* Approve the feature with an explicit, documented 'TODO' for a follow-up CL
|
||||
dedicated strictly to adding the corresponding automated tests.
|
||||
|
||||
**Exceptions:** Critical path features or security fixes where a lack of
|
||||
immediate coverage introduces an unacceptable regression risk.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
### Cross-Domain Dependencies
|
||||
|
||||
* **Upstream:** T1 | Gerrit Submission and Labeling Workflow - *Reviewer
|
||||
approval mechanisms and label enforcement dictate when a feature can merge
|
||||
while deferring tests to a follow-up CL.*
|
||||
* **Downstream:** T3 | CI Builder Environment and Execution Integrity -
|
||||
*Pragmatically bounded unit and integration tests ensure reliable CI
|
||||
pipeline execution without false-positive success markers.*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# This file contains version pins of a few tools used by run_tests.
|
||||
|
||||
# Pin resolved versions in the repo, to reduce trust in the CIPD backend.
|
||||
#
|
||||
# Most of these tools are generated via builders at
|
||||
# https://ci.chromium.org/p/infra/g/infra/console
|
||||
#
|
||||
# For these, the git revision is the one of
|
||||
# https://chromium.googlesource.com/infra/infra.git.
|
||||
#
|
||||
# To regenerate them (after modifying this file):
|
||||
# cipd ensure-file-resolve -ensure-file cipd_manifest.txt
|
||||
$ResolvedVersions cipd_manifest.versions
|
||||
|
||||
# Supported platforms. Feel free to add more as long as the tools work.
|
||||
$VerifiedPlatform linux-amd64 linux-arm64 mac-amd64 mac-arm64
|
||||
|
||||
infra/3pp/tools/help2man/${platform} version:3@1.49.3
|
||||
@@ -0,0 +1,18 @@
|
||||
# This file is auto-generated by 'cipd ensure-file-resolve'.
|
||||
# Do not modify manually. All changes will be overwritten.
|
||||
|
||||
infra/3pp/tools/help2man/linux-amd64
|
||||
version:3@1.49.3
|
||||
82ySLC4--mvjnmuwj_j_2odHLj5zFaNFpWvfCyMunY0C
|
||||
|
||||
infra/3pp/tools/help2man/linux-arm64
|
||||
version:3@1.49.3
|
||||
szfWfjI4RyCT6gIB48CeUOGG6_Un4wfbIb0Vvs5M9MUC
|
||||
|
||||
infra/3pp/tools/help2man/mac-amd64
|
||||
version:3@1.49.3
|
||||
t7iyQx5TnGbuJfj6zMTvjPdBl-Cb_w5papUh80NI2S8C
|
||||
|
||||
infra/3pp/tools/help2man/mac-arm64
|
||||
version:3@1.49.3
|
||||
t5eD-eLKRpnSifZdbi072U8bnPfGye1pIMmQ07nxJ7kC
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import pager
|
||||
|
||||
@@ -84,28 +85,43 @@ def _Color(fg=None, bg=None, attr=None):
|
||||
DEFAULT = None
|
||||
|
||||
|
||||
def SetDefaultColoring(state):
|
||||
class _CheckConsoleSentinel:
|
||||
"""Sentinel for checking console coloring."""
|
||||
|
||||
|
||||
# Placholder value that indicates we need to check if the user is in an
|
||||
# interactive terminal session to determine if we turn on color or not.
|
||||
_CHECK_CONSOLE = _CheckConsoleSentinel()
|
||||
|
||||
# https://git-scm.com/docs/git-config#Documentation/git-config.txt-colorui
|
||||
_CONFIG_TO_COLOR_SETTING = {
|
||||
"false": False,
|
||||
"never": False,
|
||||
"no": False,
|
||||
"auto": _CHECK_CONSOLE,
|
||||
"true": _CHECK_CONSOLE,
|
||||
"yes": _CHECK_CONSOLE,
|
||||
"always": True,
|
||||
}
|
||||
|
||||
|
||||
def SetDefaultColoring(state: Optional[str]) -> None:
|
||||
"""Set coloring behavior to |state|.
|
||||
|
||||
This is useful for overriding config options via the command line.
|
||||
"""
|
||||
if state is None:
|
||||
# Leave it alone -- return quick!
|
||||
return
|
||||
|
||||
global DEFAULT
|
||||
state = state.lower()
|
||||
if state in ("auto",):
|
||||
|
||||
if isinstance(state, str):
|
||||
state = state.lower()
|
||||
if state in _CONFIG_TO_COLOR_SETTING:
|
||||
DEFAULT = state
|
||||
elif state in ("always", "yes", "true", True):
|
||||
DEFAULT = "always"
|
||||
elif state in ("never", "no", "false", False):
|
||||
DEFAULT = "never"
|
||||
|
||||
|
||||
class Coloring:
|
||||
def __init__(self, config, section_type):
|
||||
self._section = "color.%s" % section_type
|
||||
self._section = f"color.{section_type}"
|
||||
self._config = config
|
||||
self._out = sys.stdout
|
||||
|
||||
@@ -114,16 +130,12 @@ class Coloring:
|
||||
on = self._config.GetString(self._section)
|
||||
if on is None:
|
||||
on = self._config.GetString("color.ui")
|
||||
if isinstance(on, str):
|
||||
on = on.lower()
|
||||
|
||||
if on == "auto":
|
||||
if pager.active or os.isatty(1):
|
||||
self._on = True
|
||||
else:
|
||||
self._on = False
|
||||
elif on in ("true", "always"):
|
||||
self._on = True
|
||||
else:
|
||||
self._on = False
|
||||
self._on = _CONFIG_TO_COLOR_SETTING.get(on, _CHECK_CONSOLE)
|
||||
if self._on is _CHECK_CONSOLE:
|
||||
self._on = pager.active or os.isatty(1)
|
||||
|
||||
def redirect(self, out):
|
||||
self._out = out
|
||||
|
||||
+49
-5
@@ -17,6 +17,7 @@ import multiprocessing
|
||||
import optparse
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from error import InvalidProjectGroupsError
|
||||
from error import NoSuchProjectError
|
||||
@@ -25,6 +26,10 @@ from event_log import EventLog
|
||||
import progress
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from project import Project
|
||||
|
||||
|
||||
# Are we generating man-pages?
|
||||
GENERATE_MANPAGES = os.environ.get("_REPO_GENERATE_MANPAGES_") == " indeed! "
|
||||
|
||||
@@ -61,6 +66,10 @@ class Command:
|
||||
# command to show short-vs-full summaries.
|
||||
COMMON = False
|
||||
|
||||
# Whether this command should respect the smart sync override manifest if
|
||||
# it exists.
|
||||
RESPECT_SMART_SYNC_OVERRIDE = True
|
||||
|
||||
# Whether this command supports running in parallel. If greater than 0,
|
||||
# it is the number of parallel jobs to default to.
|
||||
PARALLEL_JOBS = None
|
||||
@@ -101,6 +110,11 @@ class Command:
|
||||
def WantPager(self, _opt):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_multiprocessing_active() -> bool:
|
||||
"""Whether the current process is a worker in a pool."""
|
||||
return multiprocessing.current_process().name != "MainProcess"
|
||||
|
||||
def ReadEnvironmentOptions(self, opts):
|
||||
"""Set options from environment variables."""
|
||||
|
||||
@@ -237,6 +251,12 @@ class Command:
|
||||
# from the user's perspective.
|
||||
opt.outer_manifest = True
|
||||
|
||||
if self.RESPECT_SMART_SYNC_OVERRIDE:
|
||||
if self.manifest:
|
||||
self.TryOverrideManifestWithSmartSync(self.manifest)
|
||||
if self.outer_manifest and self.outer_manifest != self.manifest:
|
||||
self.TryOverrideManifestWithSmartSync(self.outer_manifest)
|
||||
|
||||
def ValidateOptions(self, opt, args):
|
||||
"""Validate the user options & arguments before executing.
|
||||
|
||||
@@ -370,7 +390,7 @@ class Command:
|
||||
manifest=None,
|
||||
groups="",
|
||||
missing_ok=False,
|
||||
submodules_ok=False,
|
||||
submodules_ok=None,
|
||||
all_manifests=False,
|
||||
):
|
||||
"""A list of projects that match the arguments.
|
||||
@@ -380,7 +400,9 @@ class Command:
|
||||
manifest: an XmlManifest, the manifest to use, or None for default.
|
||||
groups: a string, the manifest groups in use.
|
||||
missing_ok: a boolean, whether to allow missing projects.
|
||||
submodules_ok: a boolean, whether to allow submodules.
|
||||
submodules_ok: whether to allow submodules. True allows them for
|
||||
all projects, False disallows them for all projects, and None
|
||||
defers to each project's sync-s setting.
|
||||
all_manifests: a boolean, if True then all manifests and
|
||||
submanifests are used. If False, then only the local
|
||||
(sub)manifest is used.
|
||||
@@ -398,6 +420,11 @@ class Command:
|
||||
all_projects_list = manifest.projects
|
||||
result = []
|
||||
|
||||
def should_include_submodules(project: "Project") -> bool:
|
||||
if submodules_ok is None:
|
||||
return project.sync_s
|
||||
return submodules_ok
|
||||
|
||||
if not groups:
|
||||
groups = manifest.GetManifestGroupsStr()
|
||||
groups = [x for x in re.split(r"[,\s]+", groups) if x]
|
||||
@@ -405,9 +432,10 @@ class Command:
|
||||
if not args:
|
||||
derived_projects = {}
|
||||
for project in all_projects_list:
|
||||
if submodules_ok or project.sync_s:
|
||||
if should_include_submodules(project):
|
||||
derived_projects.update(
|
||||
(p.name, p) for p in project.GetDerivedSubprojects()
|
||||
(p.RelPath(local=False), p)
|
||||
for p in project.GetDerivedSubprojects()
|
||||
)
|
||||
all_projects_list.extend(derived_projects.values())
|
||||
for project in all_projects_list:
|
||||
@@ -446,7 +474,7 @@ class Command:
|
||||
if (
|
||||
project
|
||||
and not project.Derived
|
||||
and (submodules_ok or project.sync_s)
|
||||
and should_include_submodules(project)
|
||||
):
|
||||
search_again = False
|
||||
for subproject in project.GetDerivedSubprojects():
|
||||
@@ -511,6 +539,22 @@ class Command:
|
||||
)
|
||||
return result
|
||||
|
||||
def GetSmartSyncOverridePath(self, manifest=None) -> str:
|
||||
"""Return the path where smart sync writes its override manifest."""
|
||||
if manifest is None:
|
||||
manifest = self.manifest
|
||||
return os.path.join(
|
||||
manifest.manifestProject.worktree, "smart_sync_override.xml"
|
||||
)
|
||||
|
||||
def TryOverrideManifestWithSmartSync(self, manifest=None) -> None:
|
||||
"""Override manifest with smart_sync_override.xml if it exists."""
|
||||
if manifest is None:
|
||||
manifest = self.manifest
|
||||
smart_sync_manifest_path = self.GetSmartSyncOverridePath(manifest)
|
||||
if os.path.isfile(smart_sync_manifest_path):
|
||||
manifest.Override(smart_sync_manifest_path)
|
||||
|
||||
def ManifestList(self, opt):
|
||||
"""Yields all of the manifests to traverse.
|
||||
|
||||
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
#compdef _repo repo
|
||||
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
_repo() {
|
||||
local context state line
|
||||
typeset -A opt_args
|
||||
|
||||
local -a subcommands
|
||||
subcommands=(
|
||||
'abandon:Abandon a development branch'
|
||||
'branches:View current topic branches'
|
||||
'checkout:Checkout a branch'
|
||||
'cherry-pick:Cherry-pick a change'
|
||||
'diff:Show changes between commit and working tree'
|
||||
'diffmanifests:Show differences between two manifests'
|
||||
'download:Download and find a change'
|
||||
'forall:Run a shell command in each project'
|
||||
'gc:Garbage collect'
|
||||
'grep:Print lines matching a pattern'
|
||||
'help:Display help about a command'
|
||||
'info:Get info about the manifest'
|
||||
'init:Initialize a repo client'
|
||||
'list:List projects and their names'
|
||||
'manifest:Manifest management'
|
||||
'overview:Display overview of topic branches'
|
||||
'prune:Prune topic branches'
|
||||
'rebase:Rebase local branches'
|
||||
'selfupdate:Update repo'
|
||||
'smartsync:Update working tree to latest known good revision'
|
||||
'stage:Stage file(s) for commit'
|
||||
'start:Start a new branch for development'
|
||||
'status:Show the working tree status'
|
||||
'sync:Update working tree'
|
||||
'upload:Upload changes to the review server'
|
||||
'version:Display version'
|
||||
'wipe:Wipe uncommitted changes'
|
||||
)
|
||||
|
||||
local -a global_opts
|
||||
global_opts=(
|
||||
'(-h --help)'{-h,--help}'[Show help message]'
|
||||
'--help-all[Show help message for all commands]'
|
||||
'(-p --paginate)'{-p,--paginate}'[Pipe all output into less]'
|
||||
'--no-pager[Do not pipe output into a pager]'
|
||||
'--color=[Control color output]:color:(always never auto)'
|
||||
'--trace[Trace git command execution]'
|
||||
'--trace-to-stderr[Trace git command execution to stderr]'
|
||||
'--trace-python[Trace python execution]'
|
||||
'--time[Time execute of command]'
|
||||
'--version[Show version]'
|
||||
'--show-toplevel[Show top level of workspace]'
|
||||
'--event-log=[File to log events to]:file:_files'
|
||||
'--git-trace2-event-log=[File to log git trace2 events to]:file:_files'
|
||||
'--submanifest-path=[Path to submanifest]:path:_files'
|
||||
)
|
||||
|
||||
_arguments -C \
|
||||
${global_opts} \
|
||||
'1: :->cmds' \
|
||||
'*:: :->subcmds'
|
||||
|
||||
case ${state} in
|
||||
cmds)
|
||||
_describe -t commands 'repo command' subcommands
|
||||
;;
|
||||
subcmds)
|
||||
local cmd=${words[1]}
|
||||
curcontext="${curcontext%:*}-${cmd}:"
|
||||
|
||||
local -a common_opts
|
||||
common_opts=(
|
||||
'(-h --help)'{-h,--help}'[Show help message]'
|
||||
'(-v --verbose)'{-v,--verbose}'[Show verbose messages]'
|
||||
'(-q --quiet)'{-q,--quiet}'[Show only errors]'
|
||||
'(-j --jobs)'{-j,--jobs=}'[Number of jobs to run in parallel]:jobs:'
|
||||
'--outer-manifest[Use outer manifest]'
|
||||
'--no-outer-manifest[Do not use outer manifest]'
|
||||
'--this-manifest-only[Only use this manifest]'
|
||||
'--no-this-manifest-only[Do not only use this manifest]'
|
||||
'--all-manifests[Use all manifests]'
|
||||
)
|
||||
|
||||
case ${cmd} in
|
||||
abandon)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--all[Abandon all branches]' \
|
||||
'1: :->branch' \
|
||||
'*: :->project'
|
||||
;;
|
||||
branches)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'*: :->project'
|
||||
;;
|
||||
checkout)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'1: :->branch' \
|
||||
'*: :->project'
|
||||
;;
|
||||
cherry-pick)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'1: :_message "sha1"'
|
||||
;;
|
||||
diff)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-u --absolute)'{-u,--absolute}'[Show absolute paths]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
diffmanifests)
|
||||
_arguments \
|
||||
'--raw[Show raw diff]' \
|
||||
'--no-color[Do not show color]' \
|
||||
'--pretty-format=[Pretty format]:format:' \
|
||||
'1: :_files -g "*.xml"' \
|
||||
'2: :_files -g "*.xml"'
|
||||
;;
|
||||
download)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-b --branch)'{-b,--branch=}'[One or more branches to check]:branch:' \
|
||||
'(-c --cherry-pick)'{-c,--cherry-pick}'[Cherry-pick the change]' \
|
||||
'(-x --record-origin)'{-x,--record-origin}'[Record origin of cherry-pick]' \
|
||||
'(-r --revert)'{-r,--revert}'[Revert the change]' \
|
||||
'(-f --ff-only)'{-f,--ff-only}'[Force fast-forward]' \
|
||||
'*: :_message "change[/patchset]"'
|
||||
;;
|
||||
forall)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-r --regex)'{-r,--regex}'[Execute command only on projects matching regex]' \
|
||||
'(-i --inverse-regex)'{-i,--inverse-regex}'[Execute command only on projects not matching regex]' \
|
||||
'(-g --groups)'{-g,--groups=}'[Execute command only on projects matching groups]:groups:' \
|
||||
'(-c --command)'{-c,--command}'[Command to execute]' \
|
||||
'(-e --abort-on-errors)'{-e,--abort-on-errors}'[Abort on errors]' \
|
||||
'--ignore-missing[Ignore missing projects]' \
|
||||
'--interactive[Run interactively]' \
|
||||
'-p[Show project headers]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
gc)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-n --dry-run)'{-n,--dry-run}'[Dry run]' \
|
||||
'(-y --yes)'{-y,--yes}'[Answer yes to all prompts]' \
|
||||
'--repack[Repack objects]'
|
||||
;;
|
||||
grep)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--cached[Search cached files]' \
|
||||
'(-r --revision)'{-r,--revision=}'[Search in revision]:revision:' \
|
||||
'-e[Pattern]' \
|
||||
'(-i --ignore-case)'{-i,--ignore-case}'[Ignore case]' \
|
||||
'(-a --text)'{-a,--text}'[Treat all files as text]' \
|
||||
'-I[Do not match binary files]' \
|
||||
'(-w --word-regexp)'{-w,--word-regexp}'[Match word boundaries]' \
|
||||
'(-v --invert-match)'{-v,--invert-match}'[Invert match]' \
|
||||
'(-G --basic-regexp)'{-G,--basic-regexp}'[Basic regexp]' \
|
||||
'(-E --extended-regexp)'{-E,--extended-regexp}'[Extended regexp]' \
|
||||
'(-F --fixed-strings)'{-F,--fixed-strings}'[Fixed strings]' \
|
||||
'--all-match[All match]' \
|
||||
'--and[And]' \
|
||||
'--or[Or]' \
|
||||
'--not[Not]' \
|
||||
'-([Open paren]' \
|
||||
'-)[Close paren]' \
|
||||
'-n[Line number]' \
|
||||
'-C[Context]:lines:' \
|
||||
'-B[Before context]:lines:' \
|
||||
'-A[After context]:lines:' \
|
||||
'-l[Name only]' \
|
||||
'--name-only[Name only]' \
|
||||
'--files-with-matches[Files with matches]' \
|
||||
'-L[Files without match]' \
|
||||
'--files-without-match[Files without match]' \
|
||||
'1: :->pattern' \
|
||||
'*: :->project'
|
||||
;;
|
||||
help)
|
||||
_arguments \
|
||||
'(-a --all)'{-a,--all}'[Show all commands]' \
|
||||
'--help-all[Show help message for all commands]' \
|
||||
'1: :->help_cmds'
|
||||
;;
|
||||
info)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-d --diff)'{-d,--diff}'[Show diff]' \
|
||||
'(-o --overview)'{-o,--overview}'[Show overview]' \
|
||||
'(-c --current-branch)'{-c,--current-branch}'[Show current branch]' \
|
||||
'--no-current-branch[Do not show current branch]' \
|
||||
'(-l --local-only)'{-l,--local-only}'[Local only]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
init)
|
||||
_arguments \
|
||||
'(-u --manifest-url)'{-u,--manifest-url=}'[Manifest URL]:url:' \
|
||||
'(-b --manifest-branch)'{-b,--manifest-branch=}'[Manifest branch]:branch:' \
|
||||
'--manifest-upstream-branch=[Manifest upstream branch]:branch:' \
|
||||
'(-m --manifest-name)'{-m,--manifest-name=}'[Manifest name]:file:_files -g "*.xml"' \
|
||||
'(-g --groups)'{-g,--groups=}'[Restrict manifest projects to groups]:groups:' \
|
||||
'(-p --platform)'{-p,--platform=}'[Restrict manifest projects to platform]:platform:' \
|
||||
'--submodules[Sync submodules]' \
|
||||
'--standalone-manifest[Standalone manifest]' \
|
||||
'--manifest-depth=[Manifest depth]:depth:' \
|
||||
'(-c --current-branch)'{-c,--current-branch}'[Sync current branch only]' \
|
||||
'--no-current-branch[Do not sync current branch only]' \
|
||||
'--tags[Sync tags]' \
|
||||
'--no-tags[Do not sync tags]' \
|
||||
'--mirror[Mirror]' \
|
||||
'--archive[Archive]' \
|
||||
'--worktree[Worktree]' \
|
||||
'--reference=[Reference repository]:repository:_files -/' \
|
||||
'--dissociate[Dissociate from reference]' \
|
||||
'--depth=[Depth]:depth:' \
|
||||
'--partial-clone[Partial clone]' \
|
||||
'--no-partial-clone[Do not partial clone]' \
|
||||
'--partial-clone-exclude=[Exclude from partial clone]:projects:' \
|
||||
'--clone-filter=[Clone filter]:filter:' \
|
||||
'--use-superproject[Use superproject]' \
|
||||
'--no-use-superproject[Do not use superproject]' \
|
||||
'--clone-bundle[Use clone bundle]' \
|
||||
'--no-clone-bundle[Do not use clone bundle]' \
|
||||
'--git-lfs[Use git lfs]' \
|
||||
'--no-git-lfs[Do not use git lfs]' \
|
||||
'--repo-url=[Repo URL]:url:' \
|
||||
'--repo-rev=[Repo revision]:revision:' \
|
||||
'--no-repo-verify[Do not verify repo]' \
|
||||
'--config-name[Use config name]'
|
||||
;;
|
||||
list)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-r --regex)'{-r,--regex}'[Filter by regex]' \
|
||||
'(-g --groups)'{-g,--groups=}'[Filter by groups]:groups:' \
|
||||
'(-a --all)'{-a,--all}'[Show all projects]' \
|
||||
'(-n --name-only)'{-n,--name-only}'[Show only names]' \
|
||||
'(-p --path-only)'{-p,--path-only}'[Show only paths]' \
|
||||
'(-f --fullpath)'{-f,--fullpath}'[Show full paths]' \
|
||||
'--relative-to=[Show paths relative to]:path:_files -/' \
|
||||
'*: :->project'
|
||||
;;
|
||||
manifest)
|
||||
_arguments \
|
||||
'(-r --revision-as-HEAD)'{-r,--revision-as-HEAD}'[Save revisions as current HEAD]' \
|
||||
'(-m --manifest-name)'{-m,--manifest-name=}'[Manifest name]:file:_files -g "*.xml"' \
|
||||
'--suppress-upstream-revision[Suppress upstream revision]' \
|
||||
'--suppress-dest-branch[Suppress dest branch]' \
|
||||
'--format=[Output format]:format:(xml json)' \
|
||||
'--pretty[Pretty print]' \
|
||||
'--no-local-manifests[Ignore local manifests]' \
|
||||
'(-o --output-file)'{-o,--output-file=}'[Output file]:file:_files'
|
||||
;;
|
||||
overview)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-c --current-branch)'{-c,--current-branch}'[Show current branch]' \
|
||||
'--no-current-branch[Do not show current branch]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
prune)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'*: :->project'
|
||||
;;
|
||||
rebase)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--fail-fast[Fail fast]' \
|
||||
'(-f --force-rebase)'{-f,--force-rebase}'[Force rebase]' \
|
||||
'--no-ff[No fast forward]' \
|
||||
'--autosquash[Autosquash]' \
|
||||
'--whitespace=[Whitespace option]:option:' \
|
||||
'--auto-stash[Auto stash]' \
|
||||
'(-m --onto-manifest)'{-m,--onto-manifest}'[Rebase onto manifest]' \
|
||||
'(-i --interactive)'{-i,--interactive}'[Interactive rebase]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
selfupdate)
|
||||
_arguments \
|
||||
'--no-repo-verify[Do not verify repo]'
|
||||
;;
|
||||
smartsync | sync)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--jobs-network=[Number of network jobs]:jobs:' \
|
||||
'--jobs-checkout=[Number of checkout jobs]:jobs:' \
|
||||
'(-f --force-broken)'{-f,--force-broken}'[Continue sync even if a project fails]' \
|
||||
'--fail-fast[Fail fast]' \
|
||||
'--force-sync[Overwrite existing git directories]' \
|
||||
'--force-checkout[Force checkout]' \
|
||||
'--force-remove-dirty[Force remove dirty]' \
|
||||
'--rebase[Rebase local branches]' \
|
||||
'(-l --local-only)'{-l,--local-only}'[Only use local files]' \
|
||||
'--no-manifest-update[Do not update manifest]' \
|
||||
'--nmu[Do not update manifest]' \
|
||||
'--interleaved[Interleave output]' \
|
||||
'--no-interleaved[Do not interleave output]' \
|
||||
'(-n --network-only)'{-n,--network-only}'[Only fetch]' \
|
||||
'(-d --detach)'{-d,--detach}'[Detach HEAD]' \
|
||||
'(-c --current-branch)'{-c,--current-branch}'[Fetch only current branch]' \
|
||||
'--no-current-branch[Do not fetch only current branch]' \
|
||||
'(-m --manifest-name)'{-m,--manifest-name=}'[Manifest name]:file:_files -g "*.xml"' \
|
||||
'--clone-bundle[Use clone bundle]' \
|
||||
'--no-clone-bundle[Do not use clone bundle]' \
|
||||
'(-u --manifest-server-username)'{-u,--manifest-server-username=}'[Username for manifest server]:username:' \
|
||||
'(-p --manifest-server-password)'{-p,--manifest-server-password=}'[Password for manifest server]:password:' \
|
||||
'--recurse-submodules[Sync submodules]' \
|
||||
'--no-recurse-submodules[Do not sync submodules]' \
|
||||
'--fetch-submodules[Deprecated alias for --recurse-submodules]' \
|
||||
'--no-fetch-submodules[Deprecated alias for --no-recurse-submodules]' \
|
||||
'--use-superproject[Use superproject]' \
|
||||
'--no-use-superproject[Do not use superproject]' \
|
||||
'--tags[Sync tags]' \
|
||||
'--no-tags[Do not sync tags]' \
|
||||
'--optimized-fetch[Optimized fetch]' \
|
||||
'--retry-fetches=[Retry fetches]:count:' \
|
||||
'--prune[Prune]' \
|
||||
'--no-prune[Do not prune]' \
|
||||
'--auto-gc[Run auto gc]' \
|
||||
'--no-auto-gc[Do not run auto gc]' \
|
||||
'(-s --smart-sync)'{-s,--smart-sync}'[Smart sync]' \
|
||||
'(-t --smart-tag)'{-t,--smart-tag=}'[Smart tag]:tag:' \
|
||||
'--no-repo-verify[Do not verify repo]' \
|
||||
'--no-verify[Do not verify]' \
|
||||
'--verify[Verify]' \
|
||||
'--ignore-hooks[Ignore hooks]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
stage)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-i --interactive)'{-i,--interactive}'[Interactive staging]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
start)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'--all[Start branch in all projects]' \
|
||||
'(-r --rev --revision)'{-r,--rev=,--revision=}'[Revision]:revision:' \
|
||||
'--head[Head]' \
|
||||
'--HEAD[Head]' \
|
||||
'1: :->newbranch' \
|
||||
'*: :->project'
|
||||
;;
|
||||
status)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-o --orphans)'{-o,--orphans}'[Show orphans]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
upload)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-t --topic-branch)'{-t,--topic-branch}'[Topic branch]' \
|
||||
'--topic=[Topic]:topic:' \
|
||||
'--hashtag=[Hashtag]:hashtag:' \
|
||||
'--ht=[Hashtag]:hashtag:' \
|
||||
'--hashtag-branch[Hashtag branch]' \
|
||||
'--htb[Hashtag branch]' \
|
||||
'(-l --label)'{-l,--label=}'[Label]:label:' \
|
||||
'--pd=[Patchset description]:description:' \
|
||||
'--patchset-description=[Patchset description]:description:' \
|
||||
'--re=[Reviewers]:reviewers:' \
|
||||
'--reviewers=[Reviewers]:reviewers:' \
|
||||
'--cc=[CC]:cc:' \
|
||||
'--br=[Branch]:branch:' \
|
||||
'--branch=[Branch]:branch:' \
|
||||
'(-c --current-branch)'{-c,--current-branch}'[Upload current branch]' \
|
||||
'--no-current-branch[Do not upload current branch]' \
|
||||
'--ne[No emails]' \
|
||||
'--no-emails[No emails]' \
|
||||
'(-p --private)'{-p,--private}'[Private]' \
|
||||
'(-w --wip)'{-w,--wip}'[Work in progress]' \
|
||||
'(-r --ready)'{-r,--ready}'[Ready]' \
|
||||
'(-o --push-option)'{-o,--push-option=}'[Push option]:option:' \
|
||||
'(-D --destination --dest)'{-D,--destination=,--dest=}'[Destination]:destination:' \
|
||||
'(-n --dry-run)'{-n,--dry-run}'[Dry run]' \
|
||||
'(-y --yes)'{-y,--yes}'[Answer yes to all prompts]' \
|
||||
'--ignore-untracked-files[Ignore untracked files]' \
|
||||
'--no-ignore-untracked-files[Do not ignore untracked files]' \
|
||||
'--no-cert-checks[Do not check certificates]' \
|
||||
'--no-verify[Do not verify]' \
|
||||
'--verify[Verify]' \
|
||||
'--ignore-hooks[Ignore hooks]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
version)
|
||||
_arguments ${common_opts}
|
||||
;;
|
||||
wipe)
|
||||
_arguments \
|
||||
${common_opts} \
|
||||
'(-f --force)'{-f,--force}'[Force wipe]' \
|
||||
'--force-uncommitted[Force uncommitted]' \
|
||||
'--force-shared[Force shared]' \
|
||||
'*: :->project'
|
||||
;;
|
||||
esac
|
||||
|
||||
# Handle states for positional arguments.
|
||||
case ${state} in
|
||||
branch)
|
||||
[[ ${PREFIX} != -* ]] && _repo_branches
|
||||
;;
|
||||
project)
|
||||
[[ ${PREFIX} != -* ]] && _repo_projects
|
||||
;;
|
||||
pattern)
|
||||
_message 'pattern'
|
||||
;;
|
||||
newbranch)
|
||||
_message 'new branch name'
|
||||
;;
|
||||
help_cmds)
|
||||
_describe -t commands 'repo command' subcommands
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_repo_branches() {
|
||||
local -a branches
|
||||
branches=(${(f)"$(_call_program branches repo branches 2>/dev/null | sed -E 's/^.*[[:space:]]([^[:space:]]+)[[:space:]]+\|.*$/\1/' | awk '{print $1}')"})
|
||||
_describe -t branches 'branch' branches
|
||||
}
|
||||
|
||||
_repo_projects() {
|
||||
local -a projects
|
||||
projects=(${(f)"$(_call_program projects repo list -n 2>/dev/null)"})
|
||||
_describe -t projects 'project' projects
|
||||
}
|
||||
|
||||
_repo "$@"
|
||||
@@ -0,0 +1,63 @@
|
||||
# Fetch Command Contract
|
||||
|
||||
The `repo.fetchcmd` configuration allows specifying a custom command to be
|
||||
executed during `repo sync` to fetch objects, instead of using standard
|
||||
`git fetch`. This is particularly useful in environments with virtualized
|
||||
filesystems or lazy checkouts where fetching metadata and downloading file
|
||||
contents should be decoupled.
|
||||
|
||||
## Configuration
|
||||
|
||||
To use this feature, set the following in `.repo/manifests.git/config`:
|
||||
```ini
|
||||
[repo]
|
||||
fetchcmd = "your custom command here"
|
||||
uselocalgitdirs = true
|
||||
```
|
||||
Setting `repo.fetchcmd` **requires** `repo.uselocalgitdirs` to be set to `true`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The custom command is executed in a subshell populated with standard
|
||||
project-context environment variables. For details on standard variables (such
|
||||
as `REPO_PROJECT`, `REPO_PATH`, `REPO_PROJECT_FETCH_URL`, etc.), see the
|
||||
Environment section in `repo help forall` or `subcmds/forall.py`.
|
||||
|
||||
The following environment variable is specific to `repo.fetchcmd`:
|
||||
|
||||
* `REPO_TREV`: The target revision resolved to a full commit hash.
|
||||
|
||||
## Contract
|
||||
|
||||
### Postconditions on exit 0
|
||||
|
||||
After the fetch command exits with status 0, `repo` expects the following
|
||||
postconditions to be met:
|
||||
|
||||
1. `git cat-file -e REPO_TREV` succeeds (the commit must exist in the object
|
||||
store).
|
||||
2. The mapped local tracking ref (e.g. `refs/remotes/REPO_REMOTE/<branch>`
|
||||
for a branch revision, or the tag ref itself for a tag) must point to
|
||||
`REPO_TREV`.
|
||||
3. `FETCH_HEAD` must point to `REPO_TREV`.
|
||||
4. The commit graph from `REPO_TREV` must be reachable far enough to compute
|
||||
merge bases with local branches.
|
||||
|
||||
### Invariants
|
||||
|
||||
* The command should be idempotent; fetching the same `REPO_TREV` twice should
|
||||
be a no-op.
|
||||
* Only `FETCH_HEAD` and `refs/remotes/*` should be modified to preserve
|
||||
`repo sync --network-only` semantics. `HEAD` and local branches must not be
|
||||
touched by the fetch command.
|
||||
* Dirty worktree state must be preserved.
|
||||
* The command is **not** executed for `MetaProject`s (i.e. the internal `repo`
|
||||
repository itself at `.repo/repo` and the `manifests` repository at
|
||||
`.repo/manifests`).
|
||||
|
||||
### Failure
|
||||
|
||||
* A non-zero exit status aborts the project's sync, and the command's stderr
|
||||
is surfaced to the user.
|
||||
* `repo` verifies the tracking ref and target reachability after exit 0. Any
|
||||
mismatch is treated as a failure.
|
||||
@@ -182,7 +182,7 @@ User controlled settings are initialized when running `repo init`.
|
||||
| user.email | `--config-name` | User's e-mail address; Copied into `.git/config` when checking out a new project |
|
||||
| user.name | `--config-name` | User's name; Copied into `.git/config` when checking out a new project |
|
||||
|
||||
[partial git clones]: https://git-scm.com/docs/gitrepository-layout#_code_partialclone_code
|
||||
[partial git clones]: https://git-scm.com/docs/partial-clone
|
||||
[superproject]: https://en.wikibooks.org/wiki/Git/Submodules_and_Superprojects
|
||||
|
||||
### Repo hooks settings
|
||||
|
||||
+44
-13
@@ -51,12 +51,14 @@ following DTD:
|
||||
<!ATTLIST default dest-branch CDATA #IMPLIED>
|
||||
<!ATTLIST default upstream CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-j CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-j-max CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-c CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-s CDATA #IMPLIED>
|
||||
<!ATTLIST default sync-tags CDATA #IMPLIED>
|
||||
|
||||
<!ELEMENT manifest-server EMPTY>
|
||||
<!ATTLIST manifest-server url CDATA #REQUIRED>
|
||||
<!ATTLIST manifest-server helper CDATA #IMPLIED>
|
||||
|
||||
<!ELEMENT submanifest EMPTY>
|
||||
<!ATTLIST submanifest name ID #REQUIRED>
|
||||
@@ -72,18 +74,19 @@ following DTD:
|
||||
project*,
|
||||
copyfile*,
|
||||
linkfile*)>
|
||||
<!ATTLIST project name CDATA #REQUIRED>
|
||||
<!ATTLIST project path CDATA #IMPLIED>
|
||||
<!ATTLIST project remote IDREF #IMPLIED>
|
||||
<!ATTLIST project revision CDATA #IMPLIED>
|
||||
<!ATTLIST project dest-branch CDATA #IMPLIED>
|
||||
<!ATTLIST project groups CDATA #IMPLIED>
|
||||
<!ATTLIST project sync-c CDATA #IMPLIED>
|
||||
<!ATTLIST project sync-s CDATA #IMPLIED>
|
||||
<!ATTLIST project sync-tags CDATA #IMPLIED>
|
||||
<!ATTLIST project upstream CDATA #IMPLIED>
|
||||
<!ATTLIST project clone-depth CDATA #IMPLIED>
|
||||
<!ATTLIST project force-path CDATA #IMPLIED>
|
||||
<!ATTLIST project name CDATA #REQUIRED>
|
||||
<!ATTLIST project path CDATA #IMPLIED>
|
||||
<!ATTLIST project remote IDREF #IMPLIED>
|
||||
<!ATTLIST project revision CDATA #IMPLIED>
|
||||
<!ATTLIST project dest-branch CDATA #IMPLIED>
|
||||
<!ATTLIST project groups CDATA #IMPLIED>
|
||||
<!ATTLIST project sync-c CDATA #IMPLIED>
|
||||
<!ATTLIST project sync-s CDATA #IMPLIED>
|
||||
<!ATTLIST project sync-tags CDATA #IMPLIED>
|
||||
<!ATTLIST project upstream CDATA #IMPLIED>
|
||||
<!ATTLIST project clone-depth CDATA #IMPLIED>
|
||||
<!ATTLIST project force-path CDATA #IMPLIED>
|
||||
<!ATTLIST project sync-strategy CDATA #IMPLIED>
|
||||
|
||||
<!ELEMENT annotation EMPTY>
|
||||
<!ATTLIST annotation name CDATA #REQUIRED>
|
||||
@@ -213,7 +216,9 @@ can be found. Used when syncing a revision locked manifest in
|
||||
-c mode to avoid having to sync the entire ref space. Project elements
|
||||
not setting their own `upstream` will inherit this value.
|
||||
|
||||
Attribute `sync-j`: Number of parallel jobs to use when synching.
|
||||
Attribute `sync-j`: Number of parallel jobs to use when syncing.
|
||||
|
||||
Attribute `sync-j-max`: Maximum number of parallel jobs to use when syncing.
|
||||
|
||||
Attribute `sync-c`: Set to true to only sync the given Git
|
||||
branch (specified in the `revision` attribute) rather than the
|
||||
@@ -235,6 +240,11 @@ XML RPC service.
|
||||
|
||||
See the [smart sync documentation](./smart-sync.md) for more details.
|
||||
|
||||
Attribute `url`: The URL of the manifest server.
|
||||
|
||||
Attribute `helper`: Optional name of a remote helper binary to execute to
|
||||
resolve proxying or authentication for the manifest server.
|
||||
|
||||
|
||||
### Element submanifest
|
||||
|
||||
@@ -386,6 +396,22 @@ rather than the `name` attribute. This attribute only applies to the
|
||||
local mirrors syncing, it will be ignored when syncing the projects in a
|
||||
client working directory.
|
||||
|
||||
Attribute `sync-strategy`: Set the sync strategy used when fetching this
|
||||
project. Currently the only supported value is `stateless`. When set to
|
||||
`stateless`, repo will run a reflog expiration and aggressive garbage collection
|
||||
at the end of the sync process. This is useful for projects that contain
|
||||
large binary files and use `clone-depth="1"`, where garbage can accumulate
|
||||
as binaries are added, deleted, or modified across successive syncs.
|
||||
|
||||
During a stateless sync, repo checks the following before cleaning up:
|
||||
1. The project does not share an object directory with other projects.
|
||||
2. The working tree is clean (no uncommitted changes, no untracked files).
|
||||
3. There are no unpushed local commits.
|
||||
4. There is no Git stash.
|
||||
|
||||
If any of these conditions are not met, repo falls back to a standard
|
||||
sync without garbage collection.
|
||||
|
||||
### Element extend-project
|
||||
|
||||
Modify the attributes of the named project.
|
||||
@@ -395,6 +421,11 @@ attributes of an existing project without completely replacing the
|
||||
existing project definition. This makes the local manifest more robust
|
||||
against changes to the original manifest.
|
||||
|
||||
The `extend-project` element can also contain `annotation`, `copyfile`, and
|
||||
`linkfile` child elements. These are added to the project's definition. A
|
||||
`copyfile` or `linkfile` with a `dest` that already exists in the project
|
||||
will overwrite the original.
|
||||
|
||||
Attribute `path`: If specified, limit the change to projects checked out
|
||||
at the specified path, rather than all projects with the given name.
|
||||
|
||||
|
||||
+12
-2
@@ -83,6 +83,13 @@ then check it directly. Hooks should not normally modify the active git repo
|
||||
the user. Although user interaction is discouraged in the common case, it can
|
||||
be useful when deploying automatic fixes.
|
||||
|
||||
### Safe Prompts
|
||||
|
||||
If the repo command that triggered the hook supports a "yes" option (e.g.,
|
||||
`repo upload --yes`), this option is propagated to the hook's `main` function
|
||||
as `yes` parameter (defaulting to `False`). Hooks can use this to bypass
|
||||
interactive confirmation prompts when they can automatically fix issues.
|
||||
|
||||
### Shebang Handling
|
||||
|
||||
*** note
|
||||
@@ -119,7 +126,7 @@ This hook runs when people run `repo upload`.
|
||||
The `pre-upload.py` file should be defined like:
|
||||
|
||||
```py
|
||||
def main(project_list, worktree_list=None, **kwargs):
|
||||
def main(project_list, worktree_list=None, yes=False, **kwargs):
|
||||
"""Main function invoked directly by repo.
|
||||
|
||||
We must use the name "main" as that is what repo requires.
|
||||
@@ -130,6 +137,8 @@ def main(project_list, worktree_list=None, **kwargs):
|
||||
project_list, so that each entry in project_list matches with a
|
||||
directory in worktree_list. If None, we will attempt to calculate
|
||||
the directories automatically.
|
||||
yes: Whether to answer yes to all safe prompts (see
|
||||
[Safe Prompts](#safe-prompts)).
|
||||
kwargs: Leave this here for forward-compatibility.
|
||||
"""
|
||||
```
|
||||
@@ -163,13 +172,14 @@ Example:
|
||||
The `post-sync.py` file should be defined like:
|
||||
|
||||
```py
|
||||
def main(repo_topdir=None, **kwargs):
|
||||
def main(repo_topdir=None, sync_duration_seconds=None, **kwargs):
|
||||
"""Main function invoked directly by repo.
|
||||
|
||||
We must use the name "main" as that is what repo requires.
|
||||
|
||||
Args:
|
||||
repo_topdir: The absolute path to the top-level directory of the repo workspace.
|
||||
sync_duration_seconds: The duration of the sync operation in seconds.
|
||||
kwargs: Leave this here for forward-compatibility.
|
||||
"""
|
||||
```
|
||||
|
||||
+32
-1
@@ -27,13 +27,44 @@ the [`<manifest-server>` element](manifest-format.md#Element-manifest_server)
|
||||
element. This is how the client knows what service to talk to.
|
||||
|
||||
```xml
|
||||
<manifest-server url="https://example.com/your/manifest/server/url" />
|
||||
<manifest-server url="https://example.com/your/manifest/server/url"
|
||||
helper="repo-remote-helper-name" />
|
||||
```
|
||||
|
||||
If the URL starts with `persistent-`, then the
|
||||
[`git-remote-persistent-https` helper](https://github.com/git/git/blob/HEAD/contrib/persistent-https/README)
|
||||
is used to communicate with the server.
|
||||
|
||||
### Pluggable Remote Helpers
|
||||
|
||||
For custom proxying or authentication, Repo supports pluggable remote helpers.
|
||||
You can declare a helper binary via the optional `helper` attribute on the
|
||||
`<manifest-server>` element.
|
||||
|
||||
If the `helper` attribute is present in `<manifest-server>`:
|
||||
1. Repo searches your system `PATH` for the specified helper binary (e.g.,
|
||||
`repo-remote-sso`). If the helper cannot be found in `PATH`, Repo will raise
|
||||
a `SmartSyncError` and abort the sync.
|
||||
2. The helper is executed with the manifest server URL as its first argument:
|
||||
```bash
|
||||
<helper-binary-name> <url>
|
||||
```
|
||||
3. The helper must output a single-line JSON object on stdout and exit:
|
||||
* On success, return `status: "ok"` and a loopback proxy URL (including
|
||||
scheme, e.g., `http://127.0.0.1:999`):
|
||||
```json
|
||||
{"status": "ok", "message": "http://127.0.0.1:999"}
|
||||
```
|
||||
* On failure, return `status: "error"` and a detailed error message:
|
||||
```json
|
||||
{"status": "error", "message": "unauthorized"}
|
||||
```
|
||||
4. Repo routes the XML-RPC request through the returned proxy address.
|
||||
|
||||
**Timeout Constraint**: The remote helper must complete and exit within 10
|
||||
seconds. If it hangs or exceeds this limit, Repo will force-terminate (`kill`)
|
||||
the process and abort the synchronization.
|
||||
|
||||
## Credentials
|
||||
|
||||
Credentials may be specified directly in typical `username:password`
|
||||
|
||||
+6
-3
@@ -50,8 +50,11 @@ Git worktrees (see the previous section for more info).
|
||||
Repo will use symlinks heavily internally.
|
||||
On *NIX platforms, this isn't an issue, but Windows makes it a bit difficult.
|
||||
|
||||
There are some documents out there for how to do this, but usually the easiest
|
||||
answer is to run your shell as an Administrator and invoke repo/git in that.
|
||||
The easiest method to allow users to create symlinks is by enabling
|
||||
[Windows Developer Mode](https://learn.microsoft.com/en-us/windows/advanced-settings/developer-mode).
|
||||
|
||||
The next easiest answer is to run your shell as an Administrator and invoke
|
||||
repo/git in that.
|
||||
|
||||
This isn't a great solution, but Windows doesn't make this easy, so here we are.
|
||||
|
||||
@@ -142,7 +145,7 @@ You will have to specify this flag every time you upload.
|
||||
|
||||
[mintty]: https://github.com/mintty/mintty/issues/56
|
||||
|
||||
### repohooks always fail with an close_fds error.
|
||||
### repohooks always fail with an close_fds error
|
||||
|
||||
When using the [reference repohooks project][repohooks] included in AOSP,
|
||||
you might see errors like this when running `repo upload`:
|
||||
|
||||
@@ -34,7 +34,7 @@ def fetch_file(url, verbose=False):
|
||||
"""
|
||||
scheme = urlparse(url).scheme
|
||||
if scheme == "gs":
|
||||
cmd = ["gsutil", "cat", url]
|
||||
cmd = ["gcloud", "storage", "cat", url]
|
||||
errors = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -42,7 +42,7 @@ def fetch_file(url, verbose=False):
|
||||
)
|
||||
if result.stderr and verbose:
|
||||
print(
|
||||
'warning: non-fatal error running "gsutil": %s'
|
||||
'warning: non-fatal error running "gcloud storage": %s'
|
||||
% result.stderr,
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -50,7 +50,8 @@ def fetch_file(url, verbose=False):
|
||||
except subprocess.CalledProcessError as e:
|
||||
errors.append(e)
|
||||
print(
|
||||
'fatal: error running "gsutil": %s' % e.stderr, file=sys.stderr
|
||||
'fatal: error running "gcloud storage": %s' % e.stderr,
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise FetchFileError(aggregate_errors=errors)
|
||||
with urlopen(url) as f:
|
||||
|
||||
+4
-5
@@ -22,7 +22,6 @@ from typing import Any, Optional
|
||||
|
||||
from error import GitError
|
||||
from error import RepoExitError
|
||||
from git_refs import HEAD
|
||||
from git_trace2_event_log_base import BaseEventLog
|
||||
import platform_utils
|
||||
from repo_logging import RepoLogger
|
||||
@@ -48,7 +47,7 @@ logger = RepoLogger(__file__)
|
||||
|
||||
|
||||
class _GitCall:
|
||||
@functools.lru_cache(maxsize=None)
|
||||
@functools.lru_cache(maxsize=None) # noqa: B019
|
||||
def version_tuple(self):
|
||||
ret = Wrapper().ParseGitVersion()
|
||||
if ret is None:
|
||||
@@ -83,7 +82,7 @@ def RepoSourceVersion():
|
||||
proj = os.path.dirname(os.path.abspath(__file__))
|
||||
env[GIT_DIR] = os.path.join(proj, ".git")
|
||||
result = subprocess.run(
|
||||
[GIT, "describe", HEAD],
|
||||
[GIT, "describe", "HEAD"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
encoding="utf-8",
|
||||
@@ -96,7 +95,7 @@ def RepoSourceVersion():
|
||||
ver = ver[1:]
|
||||
else:
|
||||
ver = "unknown"
|
||||
setattr(RepoSourceVersion, "version", ver)
|
||||
RepoSourceVersion.version = ver
|
||||
|
||||
return ver
|
||||
|
||||
@@ -612,7 +611,7 @@ class GitCommandError(GitError):
|
||||
self.git_stderr = git_stderr
|
||||
|
||||
@property
|
||||
@functools.lru_cache(maxsize=None)
|
||||
@functools.lru_cache(maxsize=None) # noqa: B019
|
||||
def suggestion(self):
|
||||
"""Returns helpful next steps for the given stderr."""
|
||||
if not self.git_stderr:
|
||||
|
||||
+16
-7
@@ -40,17 +40,17 @@ from repo_trace import Trace
|
||||
# that is saved in the config.
|
||||
SYNC_STATE_PREFIX = "repo.syncstate."
|
||||
|
||||
ID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
ID_RE = re.compile(r"^[0-9a-f]{40,64}$")
|
||||
|
||||
REVIEW_CACHE = dict()
|
||||
REVIEW_CACHE = {}
|
||||
|
||||
|
||||
def IsChange(rev):
|
||||
return rev.startswith(R_CHANGES)
|
||||
|
||||
|
||||
def IsId(rev):
|
||||
return ID_RE.match(rev)
|
||||
def IsId(rev: str) -> bool:
|
||||
return bool(ID_RE.match(rev))
|
||||
|
||||
|
||||
def IsTag(rev):
|
||||
@@ -111,7 +111,7 @@ class GitConfig:
|
||||
return cls(configfile=os.path.join(gitdir, "config"), defaults=defaults)
|
||||
|
||||
def __init__(self, configfile, defaults=None, jsonFile=None):
|
||||
self.file = configfile
|
||||
self.file = str(configfile)
|
||||
self.defaults = defaults
|
||||
self._cache_dict = None
|
||||
self._section_dict = None
|
||||
@@ -222,6 +222,12 @@ class GitConfig:
|
||||
value = "true" if value else "false"
|
||||
self.SetString(name, value)
|
||||
|
||||
def SetInt(self, name: str, value: int) -> None:
|
||||
"""Set an integer value for a key."""
|
||||
if value is not None:
|
||||
value = str(value)
|
||||
self.SetString(name, value)
|
||||
|
||||
def GetString(self, name: str, all_keys: bool = False) -> Union[str, None]:
|
||||
"""Get the first value for a key, or None if it is not defined.
|
||||
|
||||
@@ -432,7 +438,7 @@ class GitConfig:
|
||||
if p.Wait() == 0:
|
||||
return p.stdout
|
||||
else:
|
||||
raise GitError(f"git config {str(args)}: {p.stderr}")
|
||||
raise GitError(f"git {' '.join(command)}: {p.stderr}")
|
||||
|
||||
|
||||
class RepoConfig(GitConfig):
|
||||
@@ -718,7 +724,10 @@ class Remote:
|
||||
def Save(self):
|
||||
"""Save this remote to the configuration."""
|
||||
self._Set("url", self.url)
|
||||
if self.pushUrl is not None:
|
||||
# projectname is initialized for projects listed in the manifest, but
|
||||
# not for others (e.g. the manifest project). This class is used for
|
||||
# all of them.
|
||||
if self.pushUrl is not None and self.projectname is not None:
|
||||
self._Set("pushurl", self.pushUrl + "/" + self.projectname)
|
||||
else:
|
||||
self._Set("pushurl", self.pushUrl)
|
||||
|
||||
+88
-57
@@ -14,6 +14,7 @@
|
||||
|
||||
import os
|
||||
|
||||
from git_command import GitCommand
|
||||
import platform_utils
|
||||
from repo_trace import Trace
|
||||
|
||||
@@ -86,9 +87,8 @@ class GitRefs:
|
||||
self._symref = {}
|
||||
self._mtime = {}
|
||||
|
||||
self._ReadPackedRefs()
|
||||
self._ReadLoose("refs/")
|
||||
self._ReadLoose1(os.path.join(self._gitdir, HEAD), HEAD)
|
||||
self._ReadRefs()
|
||||
self._ReadSymbolicRef(HEAD)
|
||||
|
||||
scan = self._symref
|
||||
attempts = 0
|
||||
@@ -102,64 +102,95 @@ class GitRefs:
|
||||
scan = scan_next
|
||||
attempts += 1
|
||||
|
||||
def _ReadPackedRefs(self):
|
||||
path = os.path.join(self._gitdir, "packed-refs")
|
||||
self._TrackMtime(HEAD)
|
||||
self._TrackMtime("config")
|
||||
self._TrackMtime("packed-refs")
|
||||
self._TrackTreeMtimes("refs")
|
||||
self._TrackTreeMtimes("reftable")
|
||||
|
||||
@staticmethod
|
||||
def _IsNullRef(ref_id: str) -> bool:
|
||||
"""Check if a ref_id is a null object ID."""
|
||||
return ref_id and all(ch == "0" for ch in ref_id)
|
||||
|
||||
def _ReadRefs(self) -> None:
|
||||
"""Read all references using git for-each-ref."""
|
||||
p = GitCommand(
|
||||
None,
|
||||
["for-each-ref", "--format=%(objectname)%00%(refname)%00%(symref)"],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
bare=True,
|
||||
gitdir=self._gitdir,
|
||||
)
|
||||
if p.Wait() != 0:
|
||||
return
|
||||
|
||||
for line in p.stdout.splitlines():
|
||||
ref_id, name, symref = line.split("\0")
|
||||
if symref:
|
||||
self._symref[name] = symref
|
||||
elif ref_id and not self._IsNullRef(ref_id):
|
||||
self._phyref[name] = ref_id
|
||||
|
||||
def _ReadSymbolicRef(self, name: str) -> None:
|
||||
"""Read a symbolic reference."""
|
||||
p = GitCommand(
|
||||
None,
|
||||
["symbolic-ref", "-q", name],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
bare=True,
|
||||
gitdir=self._gitdir,
|
||||
)
|
||||
if p.Wait() == 0:
|
||||
ref = p.stdout.strip()
|
||||
if ref:
|
||||
self._symref[name] = ref
|
||||
return
|
||||
|
||||
p = GitCommand(
|
||||
None,
|
||||
["rev-parse", "--verify", "-q", name],
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
bare=True,
|
||||
gitdir=self._gitdir,
|
||||
)
|
||||
if p.Wait() == 0:
|
||||
ref_id = p.stdout.strip()
|
||||
if ref_id:
|
||||
self._phyref[name] = ref_id
|
||||
|
||||
def _TrackMtime(self, name: str) -> None:
|
||||
"""Track the modification time of a specific gitdir path."""
|
||||
path = os.path.join(self._gitdir, name)
|
||||
try:
|
||||
fd = open(path)
|
||||
mtime = os.path.getmtime(path)
|
||||
self._mtime[name] = os.path.getmtime(path)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
def _TrackTreeMtimes(self, root: str) -> None:
|
||||
"""Recursively track modification times for a directory tree."""
|
||||
root_path = os.path.join(self._gitdir, root)
|
||||
try:
|
||||
for line in fd:
|
||||
line = str(line)
|
||||
if line[0] == "#":
|
||||
continue
|
||||
if line[0] == "^":
|
||||
continue
|
||||
|
||||
line = line[:-1]
|
||||
p = line.split(" ")
|
||||
ref_id = p[0]
|
||||
name = p[1]
|
||||
|
||||
self._phyref[name] = ref_id
|
||||
finally:
|
||||
fd.close()
|
||||
self._mtime["packed-refs"] = mtime
|
||||
|
||||
def _ReadLoose(self, prefix):
|
||||
base = os.path.join(self._gitdir, prefix)
|
||||
for name in platform_utils.listdir(base):
|
||||
p = os.path.join(base, name)
|
||||
# We don't implement the full ref validation algorithm, just the
|
||||
# simple rules that would show up in local filesystems.
|
||||
# https://git-scm.com/docs/git-check-ref-format
|
||||
if name.startswith(".") or name.endswith(".lock"):
|
||||
pass
|
||||
elif platform_utils.isdir(p):
|
||||
self._mtime[prefix] = os.path.getmtime(base)
|
||||
self._ReadLoose(prefix + name + "/")
|
||||
else:
|
||||
self._ReadLoose1(p, prefix + name)
|
||||
|
||||
def _ReadLoose1(self, path, name):
|
||||
try:
|
||||
with open(path) as fd:
|
||||
mtime = os.path.getmtime(path)
|
||||
ref_id = fd.readline()
|
||||
except (OSError, UnicodeError):
|
||||
if not platform_utils.isdir(root_path):
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
try:
|
||||
ref_id = ref_id.decode()
|
||||
except AttributeError:
|
||||
pass
|
||||
if not ref_id:
|
||||
return
|
||||
ref_id = ref_id[:-1]
|
||||
to_scan = [root]
|
||||
while to_scan:
|
||||
name = to_scan.pop()
|
||||
self._TrackMtime(name)
|
||||
path = os.path.join(self._gitdir, name)
|
||||
if not platform_utils.isdir(path):
|
||||
continue
|
||||
|
||||
if ref_id.startswith("ref: "):
|
||||
self._symref[name] = ref_id[5:]
|
||||
else:
|
||||
self._phyref[name] = ref_id
|
||||
self._mtime[name] = mtime
|
||||
for child in platform_utils.listdir(path):
|
||||
child_name = os.path.join(name, child)
|
||||
child_path = os.path.join(self._gitdir, child_name)
|
||||
if platform_utils.isdir(child_path):
|
||||
to_scan.append(child_name)
|
||||
else:
|
||||
self._TrackMtime(child_name)
|
||||
|
||||
+74
-23
@@ -23,17 +23,21 @@ Examples:
|
||||
"""
|
||||
|
||||
import functools
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
import urllib.parse
|
||||
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
from git_config import IsId
|
||||
from git_config import RepoConfig
|
||||
from git_refs import GitRefs
|
||||
import platform_utils
|
||||
|
||||
|
||||
_SUPERPROJECT_GIT_NAME = "superproject.git"
|
||||
@@ -97,7 +101,7 @@ class Superproject:
|
||||
self._manifest = manifest
|
||||
self.name = name
|
||||
self.remote = remote
|
||||
self.revision = self._branch = revision
|
||||
self.revision = revision
|
||||
self._repodir = manifest.repodir
|
||||
self._superproject_dir = superproject_dir
|
||||
self._superproject_path = manifest.SubmanifestInfoDir(
|
||||
@@ -129,6 +133,10 @@ class Superproject:
|
||||
"""Set the _print_messages attribute."""
|
||||
self._print_messages = value
|
||||
|
||||
def SetRevisionId(self, revision_id: str) -> None:
|
||||
"""Set the revisionId of the superproject to sync to."""
|
||||
self.revision = revision_id
|
||||
|
||||
@property
|
||||
def commit_id(self):
|
||||
"""Returns the commit ID of the superproject checkout."""
|
||||
@@ -196,7 +204,8 @@ class Superproject:
|
||||
def _LogMessagePrefix(self):
|
||||
"""Returns the prefix string to be logged in each log message"""
|
||||
return (
|
||||
f"repo superproject branch: {self._branch} url: {self._remote_url}"
|
||||
f"repo superproject revision: {self.revision} "
|
||||
f"url: {self._remote_url}"
|
||||
)
|
||||
|
||||
def _LogError(self, fmt, *inputs):
|
||||
@@ -215,30 +224,63 @@ class Superproject:
|
||||
"""
|
||||
if not os.path.exists(self._superproject_path):
|
||||
os.mkdir(self._superproject_path)
|
||||
if not self._quiet and not os.path.exists(self._work_git):
|
||||
|
||||
if os.path.exists(self._work_git):
|
||||
return True
|
||||
|
||||
if not self._quiet:
|
||||
print(
|
||||
"%s: Performing initial setup for superproject; this might "
|
||||
"take several minutes." % self._work_git
|
||||
)
|
||||
cmd = ["init", "--bare", self._work_git_name]
|
||||
p = GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
cwd=self._superproject_path,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
|
||||
tmp_gitdir_prefix = ".tmp-superproject-initgitdir-"
|
||||
tmp_gitdir = tempfile.mkdtemp(
|
||||
prefix=tmp_gitdir_prefix,
|
||||
dir=self._superproject_path,
|
||||
)
|
||||
retval = p.Wait()
|
||||
if retval:
|
||||
self._LogWarning(
|
||||
"git init call failed, command: git {}, "
|
||||
"return code: {}, stderr: {}",
|
||||
tmp_git_name = os.path.basename(tmp_gitdir)
|
||||
|
||||
try:
|
||||
cmd = ["init", "--bare", tmp_git_name]
|
||||
p = GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
retval,
|
||||
p.stderr,
|
||||
cwd=self._superproject_path,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
retval = p.Wait()
|
||||
if retval:
|
||||
self._LogWarning(
|
||||
"git init call failed, command: git {}, "
|
||||
"return code: {}, stderr: {}",
|
||||
cmd,
|
||||
retval,
|
||||
p.stderr,
|
||||
)
|
||||
return False
|
||||
|
||||
platform_utils.rename(tmp_gitdir, self._work_git)
|
||||
tmp_gitdir = None
|
||||
return True
|
||||
finally:
|
||||
# Clean up the temporary directory created during the process,
|
||||
# as well as any stale ones left over from previous attempts.
|
||||
if tmp_gitdir and os.path.exists(tmp_gitdir):
|
||||
platform_utils.rmtree(tmp_gitdir)
|
||||
|
||||
age_threshold = 60 * 60 * 24 # 1 day in seconds
|
||||
now = time.time()
|
||||
for tmp_dir in glob.glob(
|
||||
os.path.join(self._superproject_path, f"{tmp_gitdir_prefix}*")
|
||||
):
|
||||
try:
|
||||
mtime = os.path.getmtime(tmp_dir)
|
||||
if now - mtime > age_threshold:
|
||||
platform_utils.rmtree(tmp_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _Fetch(self):
|
||||
"""Fetches a superproject for the manifest based on |_remote_url|.
|
||||
@@ -276,8 +318,15 @@ class Superproject:
|
||||
if rev_commit:
|
||||
cmd.extend(["--negotiation-tip", rev_commit])
|
||||
|
||||
if self._branch:
|
||||
cmd += [self._branch + ":" + self._branch]
|
||||
if self.revision:
|
||||
# If revision is a commit hash, fetch it directly to avoid
|
||||
# creating a local branch of the same name.
|
||||
refspec = (
|
||||
self.revision
|
||||
if IsId(self.revision)
|
||||
else f"{self.revision}:{self.revision}"
|
||||
)
|
||||
cmd.append(refspec)
|
||||
p = GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
@@ -312,7 +361,7 @@ class Superproject:
|
||||
)
|
||||
return None
|
||||
data = None
|
||||
branch = "HEAD" if not self._branch else self._branch
|
||||
branch = "HEAD" if not self.revision else self.revision
|
||||
cmd = ["ls-tree", "-z", "-r", branch]
|
||||
|
||||
p = GitCommand(
|
||||
@@ -364,6 +413,8 @@ class Superproject:
|
||||
|
||||
if not self._Init():
|
||||
return SyncResult(False, should_exit)
|
||||
if IsId(self.revision) and self.commit_id:
|
||||
return SyncResult(True, False)
|
||||
if not self._Fetch():
|
||||
return SyncResult(False, should_exit)
|
||||
if not self._quiet:
|
||||
@@ -422,7 +473,7 @@ class Superproject:
|
||||
)
|
||||
return None
|
||||
manifest_str = self._manifest.ToXml(
|
||||
filter_groups=self._manifest.GetManifestGroupsStr(),
|
||||
filter_groups="all",
|
||||
omit_local=True,
|
||||
).toxml()
|
||||
manifest_path = self._manifest_path
|
||||
|
||||
@@ -68,6 +68,7 @@ class BaseEventLog:
|
||||
global p_init_count
|
||||
p_init_count += 1
|
||||
self._log = []
|
||||
self.verbose = False
|
||||
# Try to get session-id (sid) from environment (setup in repo launcher).
|
||||
KEY = "GIT_TRACE2_PARENT_SID"
|
||||
if env is None:
|
||||
@@ -194,7 +195,13 @@ class BaseEventLog:
|
||||
|
||||
def GetDataEventName(self, value):
|
||||
"""Returns 'data-json' if the value is an array else returns 'data'."""
|
||||
return "data-json" if value[0] == "[" and value[-1] == "]" else "data"
|
||||
return (
|
||||
"data-json"
|
||||
if isinstance(value, str)
|
||||
and value.startswith("[")
|
||||
and value.endswith("]")
|
||||
else "data"
|
||||
)
|
||||
|
||||
def LogDataConfigEvents(self, config, prefix):
|
||||
"""Append a 'data' event for each entry in |config| to the current log.
|
||||
@@ -309,10 +316,12 @@ class BaseEventLog:
|
||||
# ignore the attempt and continue to DGRAM below. Otherwise,
|
||||
# issue a warning.
|
||||
if err.errno != errno.EPROTOTYPE:
|
||||
print(
|
||||
f"repo: warning: git trace2 logging failed: {err}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if self.verbose:
|
||||
print(
|
||||
"repo: warning: git trace2 logging failed:",
|
||||
f"{err}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
if socket_type == socket.SOCK_DGRAM or socket_type is None:
|
||||
try:
|
||||
@@ -322,18 +331,20 @@ class BaseEventLog:
|
||||
self._WriteLog(lambda bs: sock.sendto(bs, path))
|
||||
return f"af_unix:dgram:{path}"
|
||||
except OSError as err:
|
||||
print(
|
||||
f"repo: warning: git trace2 logging failed: {err}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if self.verbose:
|
||||
print(
|
||||
f"repo: warning: git trace2 logging failed: {err}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
# Tried to open a socket but couldn't connect (SOCK_STREAM) or write
|
||||
# (SOCK_DGRAM).
|
||||
print(
|
||||
"repo: warning: git trace2 logging failed: could not write to "
|
||||
"socket",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if self.verbose:
|
||||
print(
|
||||
"repo: warning: git trace2 logging failed: could not"
|
||||
"write to socket",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
# Path is an absolute path
|
||||
@@ -348,9 +359,10 @@ class BaseEventLog:
|
||||
self._WriteLog(f.write)
|
||||
log_path = f.name
|
||||
except FileExistsError as err:
|
||||
print(
|
||||
"repo: warning: git trace2 logging failed: %r" % err,
|
||||
file=sys.stderr,
|
||||
)
|
||||
if self.verbose:
|
||||
print(
|
||||
"repo: warning: git trace2 logging failed: %r" % err,
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
return log_path
|
||||
|
||||
@@ -25,7 +25,7 @@ from git_refs import HEAD
|
||||
# The API we've documented to hook authors. Keep in sync with repo-hooks.md.
|
||||
_API_ARGS = {
|
||||
"pre-upload": {"project_list", "worktree_list"},
|
||||
"post-sync": {"repo_topdir"},
|
||||
"post-sync": {"repo_topdir", "sync_duration_seconds"},
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ class RepoHook:
|
||||
allow_all_hooks=False,
|
||||
ignore_hooks=False,
|
||||
abort_if_user_denies=False,
|
||||
yes=False,
|
||||
):
|
||||
"""RepoHook constructor.
|
||||
|
||||
@@ -89,6 +90,7 @@ class RepoHook:
|
||||
ignore_hooks: If True, then 'Do not abort action if hooks fail'.
|
||||
abort_if_user_denies: If True, we'll abort running the hook if the
|
||||
user doesn't allow us to run the hook.
|
||||
yes: If True, then 'Yes' is assumed for any prompts.
|
||||
"""
|
||||
self._hook_type = hook_type
|
||||
self._hooks_project = hooks_project
|
||||
@@ -99,6 +101,7 @@ class RepoHook:
|
||||
self._allow_all_hooks = allow_all_hooks
|
||||
self._ignore_hooks = ignore_hooks
|
||||
self._abort_if_user_denies = abort_if_user_denies
|
||||
self._yes = yes
|
||||
|
||||
# Store the full path to the script for convenience.
|
||||
self._script_fullpath = None
|
||||
@@ -374,8 +377,11 @@ class RepoHook:
|
||||
# def main(project_list, **kwargs):
|
||||
#
|
||||
# This allows us to later expand the API without breaking old hooks.
|
||||
kwargs = kwargs.copy()
|
||||
kwargs["hook_should_take_kwargs"] = True
|
||||
kwargs = {
|
||||
**kwargs,
|
||||
"hook_should_take_kwargs": True,
|
||||
"yes": self._yes,
|
||||
}
|
||||
|
||||
# See what version of python the hook has been written against.
|
||||
data = open(self._script_fullpath).read()
|
||||
@@ -497,6 +503,7 @@ class RepoHook:
|
||||
"origin"
|
||||
).url,
|
||||
"bug_url": manifest.contactinfo.bugurl,
|
||||
"yes": getattr(opt, "yes", False),
|
||||
}
|
||||
)
|
||||
return cls(*args, **kwargs)
|
||||
|
||||
@@ -19,6 +19,7 @@ People shouldn't run this directly; instead, they should use the `repo` wrapper
|
||||
which takes care of execing this entry point.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import getpass
|
||||
import json
|
||||
import netrc
|
||||
@@ -29,6 +30,7 @@ import signal
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from typing import Optional
|
||||
import urllib.request
|
||||
|
||||
from repo_logging import RepoLogger
|
||||
@@ -292,6 +294,102 @@ class _Repo:
|
||||
result = run()
|
||||
return result
|
||||
|
||||
def _autocorrect_command_name(
|
||||
self, name: str, config: RepoConfig
|
||||
) -> Optional[str]:
|
||||
"""Autocorrect command name based on user's git config."""
|
||||
|
||||
close_commands = difflib.get_close_matches(
|
||||
name, self.commands.keys(), n=5, cutoff=0.7
|
||||
)
|
||||
|
||||
if not close_commands:
|
||||
logger.error(
|
||||
"repo: '%s' is not a repo command. See 'repo help'.", name
|
||||
)
|
||||
return None
|
||||
|
||||
assumed = close_commands[0]
|
||||
autocorrect = config.GetString("help.autocorrect")
|
||||
|
||||
# If there are multiple close matches, git won't automatically run one.
|
||||
# We'll always prompt instead of guessing.
|
||||
if len(close_commands) > 1:
|
||||
autocorrect = "prompt"
|
||||
|
||||
# Handle git configuration boolean values:
|
||||
# 0, "false", "off", "no", "show": show suggestion (default)
|
||||
# 1, "true", "on", "yes", "immediate": run suggestion immediately
|
||||
# "never": don't run or show any suggested command
|
||||
# "prompt": show the suggestion and prompt for confirmation
|
||||
# positive number > 1: run suggestion after specified deciseconds
|
||||
if autocorrect is None:
|
||||
autocorrect = "0"
|
||||
|
||||
autocorrect = autocorrect.lower()
|
||||
|
||||
if autocorrect in ("0", "false", "off", "no", "show"):
|
||||
autocorrect = 0
|
||||
elif autocorrect in ("true", "on", "yes", "immediate"):
|
||||
autocorrect = -1 # immediate
|
||||
elif autocorrect == "never":
|
||||
return None
|
||||
elif autocorrect == "prompt":
|
||||
logger.warning(
|
||||
"You called a repo command named "
|
||||
"'%s', which does not exist.",
|
||||
name,
|
||||
)
|
||||
try:
|
||||
resp = input(f"Run '{assumed}' instead [y/N]? ")
|
||||
if resp.lower().startswith("y"):
|
||||
return assumed
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
pass
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
autocorrect = int(autocorrect)
|
||||
except ValueError:
|
||||
autocorrect = 0
|
||||
|
||||
if autocorrect != 0:
|
||||
if autocorrect < 0:
|
||||
logger.warning(
|
||||
"You called a repo command named "
|
||||
"'%s', which does not exist.\n"
|
||||
"Continuing assuming that "
|
||||
"you meant '%s'.",
|
||||
name,
|
||||
assumed,
|
||||
)
|
||||
else:
|
||||
delay = autocorrect * 0.1
|
||||
logger.warning(
|
||||
"You called a repo command named "
|
||||
"'%s', which does not exist.\n"
|
||||
"Continuing in %.1f seconds, assuming "
|
||||
"that you meant '%s'.",
|
||||
name,
|
||||
delay,
|
||||
assumed,
|
||||
)
|
||||
try:
|
||||
time.sleep(delay)
|
||||
except KeyboardInterrupt:
|
||||
return None
|
||||
return assumed
|
||||
|
||||
logger.error(
|
||||
"repo: '%s' is not a repo command. See 'repo help'.", name
|
||||
)
|
||||
logger.warning(
|
||||
"The most similar command%s\n\t%s",
|
||||
"s are" if len(close_commands) > 1 else " is",
|
||||
"\n\t".join(close_commands),
|
||||
)
|
||||
return None
|
||||
|
||||
def _RunLong(self, name, gopts, argv, git_trace2_event_log):
|
||||
"""Execute the (longer running) requested subcommand."""
|
||||
result = 0
|
||||
@@ -306,20 +404,22 @@ class _Repo:
|
||||
outer_client=outer_client,
|
||||
)
|
||||
|
||||
try:
|
||||
cmd = self.commands[name](
|
||||
repodir=self.repodir,
|
||||
client=repo_client,
|
||||
manifest=repo_client.manifest,
|
||||
outer_client=outer_client,
|
||||
outer_manifest=outer_client.manifest,
|
||||
git_event_log=git_trace2_event_log,
|
||||
if name not in self.commands:
|
||||
corrected_name = self._autocorrect_command_name(
|
||||
name, outer_client.globalConfig
|
||||
)
|
||||
except KeyError:
|
||||
logger.error(
|
||||
"repo: '%s' is not a repo command. See 'repo help'.", name
|
||||
)
|
||||
return 1
|
||||
if not corrected_name:
|
||||
return 1
|
||||
name = corrected_name
|
||||
|
||||
cmd = self.commands[name](
|
||||
repodir=self.repodir,
|
||||
client=repo_client,
|
||||
manifest=repo_client.manifest,
|
||||
outer_client=outer_client,
|
||||
outer_manifest=outer_client.manifest,
|
||||
git_event_log=git_trace2_event_log,
|
||||
)
|
||||
|
||||
Editor.globalConfig = cmd.client.globalConfig
|
||||
|
||||
@@ -337,6 +437,9 @@ class _Repo:
|
||||
)
|
||||
return 1
|
||||
|
||||
cmd.CommonValidateOptions(copts, cargs)
|
||||
git_trace2_event_log.verbose = copts.verbose
|
||||
|
||||
if gopts.pager is not False and not isinstance(cmd, InteractiveCommand):
|
||||
config = cmd.client.globalConfig
|
||||
if gopts.pager:
|
||||
@@ -359,7 +462,6 @@ class _Repo:
|
||||
Execute the subcommand.
|
||||
"""
|
||||
nonlocal result
|
||||
cmd.CommonValidateOptions(copts, cargs)
|
||||
cmd.ValidateOptions(copts, cargs)
|
||||
|
||||
this_manifest_only = copts.this_manifest_only
|
||||
|
||||
+2
-3
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo abandon" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo abandon" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo abandon - manual page for repo abandon
|
||||
.SH SYNOPSIS
|
||||
@@ -20,8 +20,7 @@ It is equivalent to "git branch \fB\-D\fR <branchname>".
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.TP
|
||||
\fB\-\-all\fR
|
||||
delete all branches in all projects
|
||||
|
||||
+2
-3
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo branches" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo branches" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo branches - manual page for repo branches
|
||||
.SH SYNOPSIS
|
||||
@@ -46,8 +46,7 @@ is shown, then the branch appears in all projects.
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
|
||||
+2
-3
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo checkout" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo checkout" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo checkout - manual page for repo checkout
|
||||
.SH SYNOPSIS
|
||||
@@ -15,8 +15,7 @@ Checkout a branch for development
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
|
||||
+2
-3
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo diff" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo diff" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo diff - manual page for repo diff
|
||||
.SH SYNOPSIS
|
||||
@@ -19,8 +19,7 @@ to the Unix 'patch' command.
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.TP
|
||||
\fB\-u\fR, \fB\-\-absolute\fR
|
||||
paths are relative to the repository root
|
||||
|
||||
+13
-11
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo forall" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo forall" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo forall - manual page for repo forall
|
||||
.SH SYNOPSIS
|
||||
@@ -17,20 +17,16 @@ repo forall \fB\-r\fR str1 [str2] ... \fB\-c\fR <command> [<arg>...]
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.TP
|
||||
\fB\-r\fR, \fB\-\-regex\fR
|
||||
execute the command only on projects matching regex or
|
||||
wildcard expression
|
||||
execute the command only on projects matching regex or wildcard expression
|
||||
.TP
|
||||
\fB\-i\fR, \fB\-\-inverse\-regex\fR
|
||||
execute the command only on projects not matching
|
||||
regex or wildcard expression
|
||||
execute the command only on projects not matching regex or wildcard expression
|
||||
.TP
|
||||
\fB\-g\fR GROUPS, \fB\-\-groups\fR=\fI\,GROUPS\/\fR
|
||||
execute the command only on projects matching the
|
||||
specified groups
|
||||
execute the command only on projects matching the specified groups
|
||||
.TP
|
||||
\fB\-c\fR, \fB\-\-command\fR
|
||||
command (and arguments) to execute
|
||||
@@ -39,8 +35,7 @@ command (and arguments) to execute
|
||||
abort if a command exits unsuccessfully
|
||||
.TP
|
||||
\fB\-\-ignore\-missing\fR
|
||||
silently skip & do not exit non\-zero due missing
|
||||
checkouts
|
||||
silently skip & do not exit non\-zero due missing checkouts
|
||||
.TP
|
||||
\fB\-\-interactive\fR
|
||||
force interactive usage
|
||||
@@ -120,6 +115,13 @@ git command, use REPO_LREV.
|
||||
REPO_RREV is the name of the revision from the manifest, exactly as written in
|
||||
the manifest.
|
||||
.PP
|
||||
REPO_UPSTREAM is the name of the upstream branch as specified in the manifest.
|
||||
.PP
|
||||
REPO_DEST_BRANCH is the name of the destination branch for code review, as
|
||||
specified in the manifest.
|
||||
.PP
|
||||
REPO_PROJECT_FETCH_URL is the full resolved fetch URL for the project.
|
||||
.PP
|
||||
REPO_COUNT is the total number of projects being iterated.
|
||||
.PP
|
||||
REPO_I is the current (1\-based) iteration count. Can be used in conjunction with
|
||||
|
||||
+2
-3
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "April 2025" "repo gc" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo gc" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo gc - manual page for repo gc
|
||||
.SH SYNOPSIS
|
||||
@@ -21,8 +21,7 @@ do everything except actually delete
|
||||
answer yes to all safe prompts
|
||||
.TP
|
||||
\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
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
|
||||
+2
-3
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo grep" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo grep" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo grep - manual page for repo grep
|
||||
.SH SYNOPSIS
|
||||
@@ -15,8 +15,7 @@ Print lines matching a pattern
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-\-verbose\fR
|
||||
|
||||
+21
-4
@@ -1,10 +1,10 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo info" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo info" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo info - manual page for repo info
|
||||
.SH SYNOPSIS
|
||||
.B repo
|
||||
\fI\,info \/\fR[\fI\,-dl\/\fR] [\fI\,-o \/\fR[\fI\,-c\/\fR]] [\fI\,<project>\/\fR...]
|
||||
\fI\,info \/\fR[\fI\,-dl\/\fR] [\fI\,-o \/\fR[\fI\,-c\/\fR]] [\fI\,--format=<format>\/\fR] [\fI\,<project>\/\fR...]
|
||||
.SH DESCRIPTION
|
||||
Summary
|
||||
.PP
|
||||
@@ -14,13 +14,27 @@ Get info on the manifest branch, current branch or unmerged branches
|
||||
\fB\-h\fR, \fB\-\-help\fR
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.TP
|
||||
\fB\-d\fR, \fB\-\-diff\fR
|
||||
show full info and commit diff including remote
|
||||
branches
|
||||
show full info and commit diff including remote branches
|
||||
.TP
|
||||
\fB\-o\fR, \fB\-\-overview\fR
|
||||
show overview of all local commits
|
||||
.TP
|
||||
\fB\-\-include\-summary\fR
|
||||
include manifest summary (default: true)
|
||||
.TP
|
||||
\fB\-\-no\-include\-summary\fR
|
||||
exclude manifest summary
|
||||
.TP
|
||||
\fB\-\-include\-projects\fR
|
||||
include project details (default: true)
|
||||
.TP
|
||||
\fB\-\-no\-include\-projects\fR
|
||||
exclude project details
|
||||
.TP
|
||||
\fB\-c\fR, \fB\-\-current\-branch\fR
|
||||
consider only checked out branches
|
||||
.TP
|
||||
@@ -29,6 +43,9 @@ consider all local branches
|
||||
.TP
|
||||
\fB\-l\fR, \fB\-\-local\-only\fR
|
||||
disable all remote operations
|
||||
.TP
|
||||
\fB\-\-format\fR=\fI\,FORMAT\/\fR
|
||||
output format: text, json (default: text)
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
|
||||
+19
-30
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "December 2025" "repo init" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo init" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo init - manual page for repo init
|
||||
.SH SYNOPSIS
|
||||
@@ -29,36 +29,29 @@ manifest repository location
|
||||
manifest branch or revision (use HEAD for default)
|
||||
.TP
|
||||
\fB\-\-manifest\-upstream\-branch\fR=\fI\,BRANCH\/\fR
|
||||
when a commit is provided to \fB\-\-manifest\-branch\fR, this
|
||||
is the name of the git ref in which the commit can be
|
||||
found
|
||||
when a commit is provided to \fB\-\-manifest\-branch\fR, this is the name of the git ref in which the commit can be found
|
||||
.TP
|
||||
\fB\-m\fR NAME.xml, \fB\-\-manifest\-name\fR=\fI\,NAME\/\fR.xml
|
||||
initial manifest file
|
||||
.TP
|
||||
\fB\-g\fR GROUP, \fB\-\-groups\fR=\fI\,GROUP\/\fR
|
||||
restrict manifest projects to ones with specified
|
||||
group(s) [default|all|G1,G2,G3|G4,\-G5,\-G6]
|
||||
restrict manifest projects to ones with specified group(s) [default|all|G1,G2,G3|G4,\-G5,\-G6]
|
||||
.TP
|
||||
\fB\-p\fR PLATFORM, \fB\-\-platform\fR=\fI\,PLATFORM\/\fR
|
||||
restrict manifest projects to ones with a specified
|
||||
platform group [auto|all|none|linux|darwin|...]
|
||||
restrict manifest projects to ones with a specified platform group [auto|all|none|linux|darwin|...]
|
||||
.TP
|
||||
\fB\-\-submodules\fR
|
||||
sync any submodules associated with the manifest repo
|
||||
.TP
|
||||
\fB\-\-standalone\-manifest\fR
|
||||
download the manifest as a static file rather then
|
||||
create a git checkout of the manifest repo
|
||||
download the manifest as a static file rather then create a git checkout of the manifest repo
|
||||
.TP
|
||||
\fB\-\-manifest\-depth\fR=\fI\,DEPTH\/\fR
|
||||
create a shallow clone of the manifest repo with given
|
||||
depth (0 for full clone); see git clone (default: 1)
|
||||
create a shallow clone of the manifest repo with given depth (0 for full clone); see git clone (default: 0)
|
||||
.SS Manifest (only) checkout options:
|
||||
.TP
|
||||
\fB\-c\fR, \fB\-\-current\-branch\fR
|
||||
fetch only current manifest branch from server
|
||||
(default)
|
||||
fetch only current manifest branch from server (default)
|
||||
.TP
|
||||
\fB\-\-no\-current\-branch\fR
|
||||
fetch all manifest branches from server
|
||||
@@ -71,15 +64,16 @@ don't fetch tags in the manifest
|
||||
.SS Checkout modes:
|
||||
.TP
|
||||
\fB\-\-mirror\fR
|
||||
create a replica of the remote repositories rather
|
||||
than a client working directory
|
||||
create a replica of the remote repositories rather than a client working directory
|
||||
.TP
|
||||
\fB\-\-archive\fR
|
||||
checkout an archive instead of a git repository for
|
||||
each project. See git archive.
|
||||
checkout an archive instead of a git repository for each project. See git archive.
|
||||
.TP
|
||||
\fB\-\-worktree\fR
|
||||
use git\-worktree to manage projects
|
||||
.TP
|
||||
\fB\-\-use\-local\-gitdirs\fR
|
||||
bypass .repo/projects/ and use standard Git layout in working tree
|
||||
.SS Project checkout optimizations:
|
||||
.TP
|
||||
\fB\-\-reference\fR=\fI\,DIR\/\fR
|
||||
@@ -92,33 +86,28 @@ dissociate from reference mirrors after clone
|
||||
create a shallow clone with given depth; see git clone
|
||||
.TP
|
||||
\fB\-\-partial\-clone\fR
|
||||
perform partial clone (https://gitscm.com/docs/gitrepositorylayout#_code_partialclone_code)
|
||||
perform partial clone (https://git\-scm.com/docs/partial\-clone)
|
||||
.TP
|
||||
\fB\-\-no\-partial\-clone\fR
|
||||
disable use of partial clone (https://gitscm.com/docs/gitrepositorylayout#_code_partialclone_code)
|
||||
disable use of partial clone (https://git\-scm.com/docs/partial\-clone)
|
||||
.TP
|
||||
\fB\-\-partial\-clone\-exclude\fR=\fI\,PARTIAL_CLONE_EXCLUDE\/\fR
|
||||
exclude the specified projects (a comma\-delimited
|
||||
project names) from partial clone (https://gitscm.com/docs/gitrepositorylayout#_code_partialclone_code)
|
||||
exclude the specified projects (a comma\-delimited project names) from partial clone (https://git\-scm.com/docs/partial\-clone)
|
||||
.TP
|
||||
\fB\-\-clone\-filter\fR=\fI\,CLONE_FILTER\/\fR
|
||||
filter for use with \fB\-\-partial\-clone\fR [default:
|
||||
blob:none]
|
||||
filter for use with \fB\-\-partial\-clone\fR [default: blob:none]
|
||||
.TP
|
||||
\fB\-\-use\-superproject\fR
|
||||
use the manifest superproject to sync projects;
|
||||
implies \fB\-c\fR
|
||||
use the manifest superproject to sync projects; implies \fB\-c\fR
|
||||
.TP
|
||||
\fB\-\-no\-use\-superproject\fR
|
||||
disable use of manifest superprojects
|
||||
.TP
|
||||
\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
|
||||
\fB\-\-no\-clone\-bundle\fR
|
||||
disable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS (default if
|
||||
\fB\-\-partial\-clone\fR)
|
||||
disable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS (default if \fB\-\-partial\-clone\fR)
|
||||
.TP
|
||||
\fB\-\-git\-lfs\fR
|
||||
enable Git LFS support
|
||||
|
||||
+5
-9
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo list" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo list" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo list - manual page for repo list
|
||||
.SH SYNOPSIS
|
||||
@@ -17,12 +17,10 @@ repo list [\-f] \fB\-r\fR str1 [str2]...
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-r\fR, \fB\-\-regex\fR
|
||||
filter the project list based on regex or wildcard
|
||||
matching of strings
|
||||
filter the project list based on regex or wildcard matching of strings
|
||||
.TP
|
||||
\fB\-g\fR GROUPS, \fB\-\-groups\fR=\fI\,GROUPS\/\fR
|
||||
filter the project list based on the groups the
|
||||
project is in
|
||||
filter the project list based on the groups the project is in
|
||||
.TP
|
||||
\fB\-a\fR, \fB\-\-all\fR
|
||||
show projects regardless of checkout state
|
||||
@@ -34,12 +32,10 @@ display only the name of the repository
|
||||
display only the path of the repository
|
||||
.TP
|
||||
\fB\-f\fR, \fB\-\-fullpath\fR
|
||||
display the full work tree path instead of the
|
||||
relative path
|
||||
display the full work tree path instead of the relative path
|
||||
.TP
|
||||
\fB\-\-relative\-to\fR=\fI\,PATH\/\fR
|
||||
display paths relative to this one (default: top of
|
||||
repo client checkout)
|
||||
display paths relative to this one (default: top of repo client checkout)
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
|
||||
+59
-18
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "December 2025" "repo manifest" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo manifest" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo manifest - manual page for repo manifest
|
||||
.SH SYNOPSIS
|
||||
@@ -21,14 +21,10 @@ save revisions as current HEAD
|
||||
temporary manifest to use for this sync
|
||||
.TP
|
||||
\fB\-\-suppress\-upstream\-revision\fR
|
||||
if in \fB\-r\fR mode, do not write the upstream field (only
|
||||
of use if the branch names for a sha1 manifest are
|
||||
sensitive)
|
||||
if in \fB\-r\fR mode, do not write the upstream field (only of use if the branch names for a sha1 manifest are sensitive)
|
||||
.TP
|
||||
\fB\-\-suppress\-dest\-branch\fR
|
||||
if in \fB\-r\fR mode, do not write the dest\-branch field
|
||||
(only of use if the branch names for a sha1 manifest
|
||||
are sensitive)
|
||||
if in \fB\-r\fR mode, do not write the dest\-branch field (only of use if the branch names for a sha1 manifest are sensitive)
|
||||
.TP
|
||||
\fB\-\-format\fR=\fI\,FORMAT\/\fR
|
||||
output format: xml, json (default: xml)
|
||||
@@ -40,8 +36,7 @@ format output for humans to read
|
||||
ignore local manifests
|
||||
.TP
|
||||
\fB\-o\fR \-|NAME.xml, \fB\-\-output\-file\fR=\fI\,\-\/\fR|NAME.xml
|
||||
file to save the manifest to. (Filename prefix for
|
||||
multi\-tree.)
|
||||
file to save the manifest to. (Filename prefix for multi\-tree.)
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
@@ -131,12 +126,14 @@ include*)>
|
||||
<!ATTLIST default dest\-branch CDATA #IMPLIED>
|
||||
<!ATTLIST default upstream CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-j CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-j\-max CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-c CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-s CDATA #IMPLIED>
|
||||
<!ATTLIST default sync\-tags CDATA #IMPLIED>
|
||||
.IP
|
||||
<!ELEMENT manifest\-server EMPTY>
|
||||
<!ATTLIST manifest\-server url CDATA #REQUIRED>
|
||||
<!ATTLIST manifest\-server helper CDATA #IMPLIED>
|
||||
.IP
|
||||
<!ELEMENT submanifest EMPTY>
|
||||
<!ATTLIST submanifest name ID #REQUIRED>
|
||||
@@ -164,15 +161,32 @@ IDREF #IMPLIED>
|
||||
.TP
|
||||
<!ATTLIST project revision
|
||||
CDATA #IMPLIED>
|
||||
.TP
|
||||
<!ATTLIST project dest\-branch
|
||||
CDATA #IMPLIED>
|
||||
.TP
|
||||
<!ATTLIST project groups
|
||||
CDATA #IMPLIED>
|
||||
.TP
|
||||
<!ATTLIST project sync\-c
|
||||
CDATA #IMPLIED>
|
||||
.TP
|
||||
<!ATTLIST project sync\-s
|
||||
CDATA #IMPLIED>
|
||||
.TP
|
||||
<!ATTLIST project sync\-tags
|
||||
CDATA #IMPLIED>
|
||||
.TP
|
||||
<!ATTLIST project upstream
|
||||
CDATA #IMPLIED>
|
||||
.TP
|
||||
<!ATTLIST project clone\-depth
|
||||
CDATA #IMPLIED>
|
||||
.TP
|
||||
<!ATTLIST project force\-path
|
||||
CDATA #IMPLIED>
|
||||
.IP
|
||||
<!ATTLIST project dest\-branch CDATA #IMPLIED>
|
||||
<!ATTLIST project groups CDATA #IMPLIED>
|
||||
<!ATTLIST project sync\-c CDATA #IMPLIED>
|
||||
<!ATTLIST project sync\-s CDATA #IMPLIED>
|
||||
<!ATTLIST project sync\-tags CDATA #IMPLIED>
|
||||
<!ATTLIST project upstream CDATA #IMPLIED>
|
||||
<!ATTLIST project clone\-depth CDATA #IMPLIED>
|
||||
<!ATTLIST project force\-path CDATA #IMPLIED>
|
||||
<!ATTLIST project sync\-strategy CDATA #IMPLIED>
|
||||
.IP
|
||||
<!ELEMENT annotation EMPTY>
|
||||
<!ATTLIST annotation name CDATA #REQUIRED>
|
||||
@@ -309,7 +323,9 @@ when syncing a revision locked manifest in \fB\-c\fR mode to avoid having to syn
|
||||
entire ref space. Project elements not setting their own `upstream` will inherit
|
||||
this value.
|
||||
.PP
|
||||
Attribute `sync\-j`: Number of parallel jobs to use when synching.
|
||||
Attribute `sync\-j`: Number of parallel jobs to use when syncing.
|
||||
.PP
|
||||
Attribute `sync\-j\-max`: Maximum number of parallel jobs to use when syncing.
|
||||
.PP
|
||||
Attribute `sync\-c`: Set to true to only sync the given Git branch (specified in
|
||||
the `revision` attribute) rather than the whole ref space. Project elements
|
||||
@@ -327,6 +343,11 @@ specify the URL of a manifest server, which is an XML RPC service.
|
||||
.PP
|
||||
See the [smart sync documentation](./smart\-sync.md) for more details.
|
||||
.PP
|
||||
Attribute `url`: The URL of the manifest server.
|
||||
.PP
|
||||
Attribute `helper`: Optional name of a remote helper binary to execute to
|
||||
resolve proxying or authentication for the manifest server.
|
||||
.PP
|
||||
Element submanifest
|
||||
.PP
|
||||
One or more submanifest elements may be specified. Each element describes a
|
||||
@@ -466,6 +487,21 @@ mirror repository according to its `path` attribute (if supplied) rather than
|
||||
the `name` attribute. This attribute only applies to the local mirrors syncing,
|
||||
it will be ignored when syncing the projects in a client working directory.
|
||||
.PP
|
||||
Attribute `sync\-strategy`: Set the sync strategy used when fetching this
|
||||
project. Currently the only supported value is `stateless`. When set to
|
||||
`stateless`, repo will run a reflog expiration and aggressive garbage collection
|
||||
at the end of the sync process. This is useful for projects that contain large
|
||||
binary files and use `clone\-depth="1"`, where garbage can accumulate as binaries
|
||||
are added, deleted, or modified across successive syncs.
|
||||
.PP
|
||||
During a stateless sync, repo checks the following before cleaning up: 1. The
|
||||
project does not share an object directory with other projects. 2. The working
|
||||
tree is clean (no uncommitted changes, no untracked files). 3. There are no
|
||||
unpushed local commits. 4. There is no Git stash.
|
||||
.PP
|
||||
If any of these conditions are not met, repo falls back to a standard sync
|
||||
without garbage collection.
|
||||
.PP
|
||||
Element extend\-project
|
||||
.PP
|
||||
Modify the attributes of the named project.
|
||||
@@ -475,6 +511,11 @@ of an existing project without completely replacing the existing project
|
||||
definition. This makes the local manifest more robust against changes to the
|
||||
original manifest.
|
||||
.PP
|
||||
The `extend\-project` element can also contain `annotation`, `copyfile`, and
|
||||
`linkfile` child elements. These are added to the project's definition. A
|
||||
`copyfile` or `linkfile` with a `dest` that already exists in the project will
|
||||
overwrite the original.
|
||||
.PP
|
||||
Attribute `path`: If specified, limit the change to projects checked out at the
|
||||
specified path, rather than all projects with the given name.
|
||||
.PP
|
||||
|
||||
+2
-3
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo prune" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo prune" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo prune - manual page for repo prune
|
||||
.SH SYNOPSIS
|
||||
@@ -15,8 +15,7 @@ Prune (delete) already merged topics
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
|
||||
+2
-4
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo rebase" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo rebase" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo rebase - manual page for repo rebase
|
||||
.SH SYNOPSIS
|
||||
@@ -33,9 +33,7 @@ pass \fB\-\-whitespace\fR to git rebase
|
||||
stash local modifications before starting
|
||||
.TP
|
||||
\fB\-m\fR, \fB\-\-onto\-manifest\fR
|
||||
rebase onto the manifest version instead of upstream
|
||||
HEAD (this helps to make sure the local tree stays
|
||||
consistent if you previously synced to a manifest)
|
||||
rebase onto the manifest version instead of upstream HEAD (this helps to make sure the local tree stays consistent if you previously synced to a manifest)
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
|
||||
+24
-30
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "August 2025" "repo smartsync" "Repo Manual"
|
||||
.TH REPO "1" "July 2026" "repo smartsync" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo smartsync - manual page for repo smartsync
|
||||
.SH SYNOPSIS
|
||||
@@ -15,16 +15,13 @@ Update working tree to the latest known good revision
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.TP
|
||||
\fB\-\-jobs\-network\fR=\fI\,JOBS\/\fR
|
||||
number of network jobs to run in parallel (defaults to
|
||||
\fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
number of network jobs to run in parallel (defaults to \fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-\-jobs\-checkout\fR=\fI\,JOBS\/\fR
|
||||
number of local checkout jobs to run in parallel
|
||||
(defaults to \fB\-\-jobs\fR or 8). Ignored unless \fB\-\-nointerleaved\fR is set
|
||||
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or 8). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-f\fR, \fB\-\-force\-broken\fR
|
||||
obsolete option (to be deleted in the future)
|
||||
@@ -33,30 +30,22 @@ obsolete option (to be deleted in the future)
|
||||
stop syncing after first error is hit
|
||||
.TP
|
||||
\fB\-\-force\-sync\fR
|
||||
overwrite an existing git directory if it needs to
|
||||
point to a different object directory. WARNING: this
|
||||
may cause loss of data
|
||||
overwrite an existing git directory if it needs to point to a different object directory. WARNING: this may cause loss of data
|
||||
.TP
|
||||
\fB\-\-force\-checkout\fR
|
||||
force checkout even if it results in throwing away
|
||||
uncommitted modifications. WARNING: this may cause
|
||||
loss of data
|
||||
force checkout even if it results in throwing away uncommitted modifications. WARNING: this may cause loss of data
|
||||
.TP
|
||||
\fB\-\-force\-remove\-dirty\fR
|
||||
force remove projects with uncommitted modifications
|
||||
if projects no longer exist in the manifest. WARNING:
|
||||
this may cause loss of data
|
||||
force remove projects with uncommitted modifications if projects no longer exist in the manifest. WARNING: this may cause loss of data
|
||||
.TP
|
||||
\fB\-\-rebase\fR
|
||||
rebase local commits regardless of whether they are
|
||||
published
|
||||
rebase local commits regardless of whether they are published
|
||||
.TP
|
||||
\fB\-l\fR, \fB\-\-local\-only\fR
|
||||
only update working tree, don't fetch
|
||||
.TP
|
||||
\fB\-\-no\-manifest\-update\fR, \fB\-\-nmu\fR
|
||||
use the existing manifest checkout as\-is. (do not
|
||||
update to the latest revision)
|
||||
use the existing manifest checkout as\-is. (do not update to the latest revision)
|
||||
.TP
|
||||
\fB\-\-interleaved\fR
|
||||
fetch and checkout projects in parallel (default)
|
||||
@@ -79,6 +68,9 @@ fetch all branches from server
|
||||
\fB\-m\fR NAME.xml, \fB\-\-manifest\-name\fR=\fI\,NAME\/\fR.xml
|
||||
temporary manifest to use for this sync
|
||||
.TP
|
||||
\fB\-g\fR GROUP, \fB\-\-groups\fR=\fI\,GROUP\/\fR
|
||||
sync projects matching the specific groups. Not persistent unlike when used on init
|
||||
.TP
|
||||
\fB\-\-clone\-bundle\fR
|
||||
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS
|
||||
.TP
|
||||
@@ -91,16 +83,21 @@ username to authenticate with the manifest server
|
||||
\fB\-p\fR MANIFEST_SERVER_PASSWORD, \fB\-\-manifest\-server\-password\fR=\fI\,MANIFEST_SERVER_PASSWORD\/\fR
|
||||
password to authenticate with the manifest server
|
||||
.TP
|
||||
\fB\-\-fetch\-submodules\fR
|
||||
fetch submodules from server
|
||||
\fB\-\-recurse\-submodules\fR
|
||||
sync submodules from server
|
||||
.TP
|
||||
\fB\-\-no\-recurse\-submodules\fR
|
||||
don't sync submodules from server
|
||||
.TP
|
||||
\fB\-\-use\-superproject\fR
|
||||
use the manifest superproject to sync projects;
|
||||
implies \fB\-c\fR
|
||||
use the manifest superproject to sync projects; implies \fB\-c\fR
|
||||
.TP
|
||||
\fB\-\-no\-use\-superproject\fR
|
||||
disable use of manifest superprojects
|
||||
.TP
|
||||
\fB\-\-superproject\-revision\fR=\fI\,SUPERPROJECT_REVISION\/\fR
|
||||
sync to superproject revision (applies to outer manifest)
|
||||
.TP
|
||||
\fB\-\-tags\fR
|
||||
fetch tags
|
||||
.TP
|
||||
@@ -108,15 +105,13 @@ fetch tags
|
||||
don't fetch tags (default)
|
||||
.TP
|
||||
\fB\-\-optimized\-fetch\fR
|
||||
only fetch projects fixed to sha1 if revision does not
|
||||
exist locally
|
||||
only fetch projects fixed to sha1 if revision does not exist locally
|
||||
.TP
|
||||
\fB\-\-retry\-fetches\fR=\fI\,RETRY_FETCHES\/\fR
|
||||
number of times to retry fetches on transient errors
|
||||
.TP
|
||||
\fB\-\-prune\fR
|
||||
delete refs that no longer exist on the remote
|
||||
(default)
|
||||
delete refs that no longer exist on the remote (default)
|
||||
.TP
|
||||
\fB\-\-no\-prune\fR
|
||||
do not delete refs that no longer exist on the remote
|
||||
@@ -125,8 +120,7 @@ do not delete refs that no longer exist on the remote
|
||||
run garbage collection on all synced projects
|
||||
.TP
|
||||
\fB\-\-no\-auto\-gc\fR
|
||||
do not run garbage collection on any projects
|
||||
(default)
|
||||
do not run garbage collection on any projects (default)
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
|
||||
+2
-3
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo start" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo start" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo start - manual page for repo start
|
||||
.SH SYNOPSIS
|
||||
@@ -15,8 +15,7 @@ Start a new branch for development
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.TP
|
||||
\fB\-\-all\fR
|
||||
begin branch in all projects
|
||||
|
||||
+12
-5
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "July 2022" "repo status" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo status" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo status - manual page for repo status
|
||||
.SH SYNOPSIS
|
||||
@@ -15,12 +15,10 @@ Show the working tree status
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.TP
|
||||
\fB\-o\fR, \fB\-\-orphans\fR
|
||||
include objects in working directory outside of repo
|
||||
projects
|
||||
include objects in working directory outside of repo projects
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
@@ -70,6 +68,15 @@ branch devwork
|
||||
\fB\-m\fR
|
||||
subcmds/status.py
|
||||
.PP
|
||||
If the branch is tracking an upstream branch, the number of commits ahead and/or
|
||||
behind is also shown:
|
||||
.TP
|
||||
project repo/
|
||||
branch devwork [ahead 1, behind 2]
|
||||
.TP
|
||||
\fB\-m\fR
|
||||
subcmds/status.py
|
||||
.PP
|
||||
The first column explains how the staging area (index) differs from the last
|
||||
commit (HEAD). Its values are always displayed in upper case and have the
|
||||
following meanings:
|
||||
|
||||
+31
-34
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "August 2025" "repo sync" "Repo Manual"
|
||||
.TH REPO "1" "July 2026" "repo sync" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo sync - manual page for repo sync
|
||||
.SH SYNOPSIS
|
||||
@@ -15,16 +15,13 @@ Update working tree to the latest revision
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.TP
|
||||
\fB\-\-jobs\-network\fR=\fI\,JOBS\/\fR
|
||||
number of network jobs to run in parallel (defaults to
|
||||
\fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
number of network jobs to run in parallel (defaults to \fB\-\-jobs\fR or 1). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-\-jobs\-checkout\fR=\fI\,JOBS\/\fR
|
||||
number of local checkout jobs to run in parallel
|
||||
(defaults to \fB\-\-jobs\fR or 8). Ignored unless \fB\-\-nointerleaved\fR is set
|
||||
number of local checkout jobs to run in parallel (defaults to \fB\-\-jobs\fR or 8). Ignored unless \fB\-\-no\-interleaved\fR is set
|
||||
.TP
|
||||
\fB\-f\fR, \fB\-\-force\-broken\fR
|
||||
obsolete option (to be deleted in the future)
|
||||
@@ -33,30 +30,22 @@ obsolete option (to be deleted in the future)
|
||||
stop syncing after first error is hit
|
||||
.TP
|
||||
\fB\-\-force\-sync\fR
|
||||
overwrite an existing git directory if it needs to
|
||||
point to a different object directory. WARNING: this
|
||||
may cause loss of data
|
||||
overwrite an existing git directory if it needs to point to a different object directory. WARNING: this may cause loss of data
|
||||
.TP
|
||||
\fB\-\-force\-checkout\fR
|
||||
force checkout even if it results in throwing away
|
||||
uncommitted modifications. WARNING: this may cause
|
||||
loss of data
|
||||
force checkout even if it results in throwing away uncommitted modifications. WARNING: this may cause loss of data
|
||||
.TP
|
||||
\fB\-\-force\-remove\-dirty\fR
|
||||
force remove projects with uncommitted modifications
|
||||
if projects no longer exist in the manifest. WARNING:
|
||||
this may cause loss of data
|
||||
force remove projects with uncommitted modifications if projects no longer exist in the manifest. WARNING: this may cause loss of data
|
||||
.TP
|
||||
\fB\-\-rebase\fR
|
||||
rebase local commits regardless of whether they are
|
||||
published
|
||||
rebase local commits regardless of whether they are published
|
||||
.TP
|
||||
\fB\-l\fR, \fB\-\-local\-only\fR
|
||||
only update working tree, don't fetch
|
||||
.TP
|
||||
\fB\-\-no\-manifest\-update\fR, \fB\-\-nmu\fR
|
||||
use the existing manifest checkout as\-is. (do not
|
||||
update to the latest revision)
|
||||
use the existing manifest checkout as\-is. (do not update to the latest revision)
|
||||
.TP
|
||||
\fB\-\-interleaved\fR
|
||||
fetch and checkout projects in parallel (default)
|
||||
@@ -79,6 +68,9 @@ fetch all branches from server
|
||||
\fB\-m\fR NAME.xml, \fB\-\-manifest\-name\fR=\fI\,NAME\/\fR.xml
|
||||
temporary manifest to use for this sync
|
||||
.TP
|
||||
\fB\-g\fR GROUP, \fB\-\-groups\fR=\fI\,GROUP\/\fR
|
||||
sync projects matching the specific groups. Not persistent unlike when used on init
|
||||
.TP
|
||||
\fB\-\-clone\-bundle\fR
|
||||
enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS
|
||||
.TP
|
||||
@@ -91,16 +83,21 @@ username to authenticate with the manifest server
|
||||
\fB\-p\fR MANIFEST_SERVER_PASSWORD, \fB\-\-manifest\-server\-password\fR=\fI\,MANIFEST_SERVER_PASSWORD\/\fR
|
||||
password to authenticate with the manifest server
|
||||
.TP
|
||||
\fB\-\-fetch\-submodules\fR
|
||||
fetch submodules from server
|
||||
\fB\-\-recurse\-submodules\fR
|
||||
sync submodules from server
|
||||
.TP
|
||||
\fB\-\-no\-recurse\-submodules\fR
|
||||
don't sync submodules from server
|
||||
.TP
|
||||
\fB\-\-use\-superproject\fR
|
||||
use the manifest superproject to sync projects;
|
||||
implies \fB\-c\fR
|
||||
use the manifest superproject to sync projects; implies \fB\-c\fR
|
||||
.TP
|
||||
\fB\-\-no\-use\-superproject\fR
|
||||
disable use of manifest superprojects
|
||||
.TP
|
||||
\fB\-\-superproject\-revision\fR=\fI\,SUPERPROJECT_REVISION\/\fR
|
||||
sync to superproject revision (applies to outer manifest)
|
||||
.TP
|
||||
\fB\-\-tags\fR
|
||||
fetch tags
|
||||
.TP
|
||||
@@ -108,15 +105,13 @@ fetch tags
|
||||
don't fetch tags (default)
|
||||
.TP
|
||||
\fB\-\-optimized\-fetch\fR
|
||||
only fetch projects fixed to sha1 if revision does not
|
||||
exist locally
|
||||
only fetch projects fixed to sha1 if revision does not exist locally
|
||||
.TP
|
||||
\fB\-\-retry\-fetches\fR=\fI\,RETRY_FETCHES\/\fR
|
||||
number of times to retry fetches on transient errors
|
||||
.TP
|
||||
\fB\-\-prune\fR
|
||||
delete refs that no longer exist on the remote
|
||||
(default)
|
||||
delete refs that no longer exist on the remote (default)
|
||||
.TP
|
||||
\fB\-\-no\-prune\fR
|
||||
do not delete refs that no longer exist on the remote
|
||||
@@ -125,12 +120,10 @@ do not delete refs that no longer exist on the remote
|
||||
run garbage collection on all synced projects
|
||||
.TP
|
||||
\fB\-\-no\-auto\-gc\fR
|
||||
do not run garbage collection on any projects
|
||||
(default)
|
||||
do not run garbage collection on any projects (default)
|
||||
.TP
|
||||
\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
|
||||
\fB\-t\fR SMART_TAG, \fB\-\-smart\-tag\fR=\fI\,SMART_TAG\/\fR
|
||||
smart sync using manifest from a known tag
|
||||
@@ -225,8 +218,12 @@ bootstrap a new Git repository from a resumeable bundle file on a content
|
||||
delivery network. This may be necessary if there are problems with the local
|
||||
Python HTTP client or proxy configuration, but the Git binary works.
|
||||
.PP
|
||||
The \fB\-\-fetch\-submodules\fR option enables fetching Git submodules of a project from
|
||||
server.
|
||||
The \fB\-\-recurse\-submodules\fR option enables syncing Git submodules of all projects
|
||||
from the server. The \fB\-\-no\-recurse\-submodules\fR option disables syncing Git
|
||||
submodules, even when a project has sync\-s="true" in the manifest.
|
||||
.PP
|
||||
The \fB\-\-fetch\-submodules\fR and \fB\-\-no\-fetch\-submodules\fR options are deprecated aliases
|
||||
for \fB\-\-recurse\-submodules\fR and \fB\-\-no\-recurse\-submodules\fR, respectively.
|
||||
.PP
|
||||
The \fB\-c\fR/\-\-current\-branch option can be used to only fetch objects that are on the
|
||||
branch specified by a project's revision.
|
||||
|
||||
+2
-3
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "June 2024" "repo upload" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo upload" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo upload - manual page for repo upload
|
||||
.SH SYNOPSIS
|
||||
@@ -15,8 +15,7 @@ Upload changes for code review
|
||||
show this help message and exit
|
||||
.TP
|
||||
\fB\-j\fR JOBS, \fB\-\-jobs\fR=\fI\,JOBS\/\fR
|
||||
number of jobs to run in parallel (default: based on
|
||||
number of CPU cores)
|
||||
number of jobs to run in parallel (default: based on number of CPU cores)
|
||||
.TP
|
||||
\fB\-t\fR, \fB\-\-topic\-branch\fR
|
||||
set the topic to the local branch name
|
||||
|
||||
+2
-3
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "November 2025" "repo wipe" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo wipe" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repo wipe - manual page for repo wipe
|
||||
.SH SYNOPSIS
|
||||
@@ -21,8 +21,7 @@ force wipe shared projects and uncommitted changes
|
||||
force wipe even if there are uncommitted changes
|
||||
.TP
|
||||
\fB\-\-force\-shared\fR
|
||||
force wipe even if the project shares an object
|
||||
directory
|
||||
force wipe even if the project shares an object directory
|
||||
.SS Logging options:
|
||||
.TP
|
||||
\fB\-v\fR, \fB\-\-verbose\fR
|
||||
|
||||
+3
-5
@@ -1,5 +1,5 @@
|
||||
.\" DO NOT MODIFY THIS FILE! It was generated by help2man.
|
||||
.TH REPO "1" "November 2025" "repo" "Repo Manual"
|
||||
.TH REPO "1" "June 2026" "repo" "Repo Manual"
|
||||
.SH NAME
|
||||
repo \- repository management tool built on top of git
|
||||
.SH SYNOPSIS
|
||||
@@ -26,8 +26,7 @@ control color usage: auto, always, never
|
||||
trace git command execution (REPO_TRACE=1)
|
||||
.TP
|
||||
\fB\-\-trace\-to\-stderr\fR
|
||||
trace outputs go to stderr in addition to
|
||||
\&.repo/TRACE_FILE
|
||||
trace outputs go to stderr in addition to .repo/TRACE_FILE
|
||||
.TP
|
||||
\fB\-\-trace\-python\fR
|
||||
trace python command execution
|
||||
@@ -39,8 +38,7 @@ time repo command execution
|
||||
display this version of repo
|
||||
.TP
|
||||
\fB\-\-show\-toplevel\fR
|
||||
display the path of the top\-level directory of the
|
||||
repo client checkout
|
||||
display the path of the top\-level directory of the repo client checkout
|
||||
.TP
|
||||
\fB\-\-event\-log\fR=\fI\,EVENT_LOG\/\fR
|
||||
filename of event log to append timeline to
|
||||
|
||||
+55
-11
@@ -155,6 +155,7 @@ class _Default:
|
||||
upstreamExpr = None
|
||||
remote = None
|
||||
sync_j = None
|
||||
sync_j_max = None
|
||||
sync_c = False
|
||||
sync_s = False
|
||||
sync_tags = True
|
||||
@@ -631,6 +632,9 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if d.sync_j is not None:
|
||||
have_default = True
|
||||
e.setAttribute("sync-j", "%d" % d.sync_j)
|
||||
if d.sync_j_max is not None:
|
||||
have_default = True
|
||||
e.setAttribute("sync-j-max", "%d" % d.sync_j_max)
|
||||
if d.sync_c:
|
||||
have_default = True
|
||||
e.setAttribute("sync-c", "true")
|
||||
@@ -647,6 +651,8 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if self._manifest_server:
|
||||
e = doc.createElement("manifest-server")
|
||||
e.setAttribute("url", self._manifest_server)
|
||||
if self._manifest_server_helper:
|
||||
e.setAttribute("helper", self._manifest_server_helper)
|
||||
root.appendChild(e)
|
||||
root.appendChild(doc.createTextNode(""))
|
||||
|
||||
@@ -755,14 +761,17 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
if p.clone_depth:
|
||||
e.setAttribute("clone-depth", str(p.clone_depth))
|
||||
|
||||
if p.sync_strategy:
|
||||
e.setAttribute("sync-strategy", str(p.sync_strategy))
|
||||
|
||||
self._output_manifest_project_extras(p, e)
|
||||
|
||||
if p.subprojects:
|
||||
subprojects = {subp.name for subp in p.subprojects}
|
||||
output_projects(p, e, list(sorted(subprojects)))
|
||||
output_projects(p, e, sorted(subprojects))
|
||||
|
||||
projects = {p.name for p in self._paths.values() if not p.parent}
|
||||
output_projects(None, root, list(sorted(projects)))
|
||||
output_projects(None, root, sorted(projects))
|
||||
|
||||
if self._repo_hooks_project:
|
||||
root.appendChild(doc.createTextNode(""))
|
||||
@@ -819,7 +828,6 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
"submanifest",
|
||||
# These are children of 'project' nodes.
|
||||
"annotation",
|
||||
"project",
|
||||
"copyfile",
|
||||
"linkfile",
|
||||
}
|
||||
@@ -993,6 +1001,11 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
self._Load()
|
||||
return self._manifest_server
|
||||
|
||||
@property
|
||||
def manifest_server_helper(self):
|
||||
self._Load()
|
||||
return self._manifest_server_helper
|
||||
|
||||
@property
|
||||
def CloneBundle(self):
|
||||
clone_bundle = self.manifestProject.clone_bundle
|
||||
@@ -1049,6 +1062,10 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
def UseGitWorktrees(self):
|
||||
return self.manifestProject.use_worktree
|
||||
|
||||
@property
|
||||
def UseLocalGitDirs(self):
|
||||
return self.manifestProject.use_local_gitdirs
|
||||
|
||||
@property
|
||||
def IsArchive(self):
|
||||
return self.manifestProject.archive
|
||||
@@ -1147,6 +1164,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
self._notice = None
|
||||
self.branch = None
|
||||
self._manifest_server = None
|
||||
self._manifest_server_helper = None
|
||||
|
||||
def Load(self):
|
||||
"""Read the manifest into memory."""
|
||||
@@ -1416,11 +1434,13 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
for node in itertools.chain(*node_list):
|
||||
if node.nodeName == "manifest-server":
|
||||
url = self._reqatt(node, "url")
|
||||
helper = node.getAttribute("helper") or None
|
||||
if self._manifest_server is not None:
|
||||
raise ManifestParseError(
|
||||
"duplicate manifest-server in %s" % (self.manifestFile)
|
||||
)
|
||||
self._manifest_server = url
|
||||
self._manifest_server_helper = helper
|
||||
|
||||
def recursively_add_projects(project):
|
||||
projects = self._projects.setdefault(project.name, [])
|
||||
@@ -1487,6 +1507,14 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
continue
|
||||
if groups:
|
||||
p.groups |= groups
|
||||
# Drop local groups so we don't mistakenly omit this
|
||||
# project from the superproject override manifest.
|
||||
p.groups = {
|
||||
g
|
||||
for g in p.groups
|
||||
if not g.startswith(LOCAL_MANIFEST_GROUP_PREFIX)
|
||||
}
|
||||
|
||||
if revision:
|
||||
if base_revision:
|
||||
if p.revisionExpr != base_revision:
|
||||
@@ -1755,6 +1783,13 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
% (self.manifestFile, d.sync_j)
|
||||
)
|
||||
|
||||
d.sync_j_max = XmlInt(node, "sync-j-max", None)
|
||||
if d.sync_j_max is not None and d.sync_j_max <= 0:
|
||||
raise ManifestParseError(
|
||||
'%s: sync-j-max must be greater than 0, not "%s"'
|
||||
% (self.manifestFile, d.sync_j_max)
|
||||
)
|
||||
|
||||
d.sync_c = XmlBool(node, "sync-c", False)
|
||||
d.sync_s = XmlBool(node, "sync-s", False)
|
||||
d.sync_tags = XmlBool(node, "sync-tags", True)
|
||||
@@ -1920,6 +1955,8 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
% (self.manifestFile, clone_depth)
|
||||
)
|
||||
|
||||
sync_strategy = node.getAttribute("sync-strategy") or None
|
||||
|
||||
dest_branch = (
|
||||
node.getAttribute("dest-branch") or self._default.destBranchExpr
|
||||
)
|
||||
@@ -1966,6 +2003,7 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
sync_s=sync_s,
|
||||
sync_tags=sync_tags,
|
||||
clone_depth=clone_depth,
|
||||
sync_strategy=sync_strategy,
|
||||
upstream=upstream,
|
||||
parent=parent,
|
||||
dest_branch=dest_branch,
|
||||
@@ -2018,15 +2056,21 @@ https://gerrit.googlesource.com/git-repo/+/HEAD/docs/manifest-format.md
|
||||
else:
|
||||
namepath = f"{name}.git"
|
||||
worktree = os.path.join(self.topdir, path).replace("\\", "/")
|
||||
gitdir = os.path.join(self.subdir, "projects", "%s.git" % path)
|
||||
# We allow people to mix git worktrees & non-git worktrees for now.
|
||||
# This allows for in situ migration of repo clients.
|
||||
if os.path.exists(gitdir) or not self.UseGitWorktrees:
|
||||
objdir = os.path.join(self.repodir, "project-objects", namepath)
|
||||
else:
|
||||
use_git_worktrees = True
|
||||
gitdir = os.path.join(self.repodir, "worktrees", namepath)
|
||||
if self.UseLocalGitDirs:
|
||||
gitdir = os.path.join(worktree, ".git")
|
||||
objdir = gitdir
|
||||
else:
|
||||
gitdir = os.path.join(self.subdir, "projects", "%s.git" % path)
|
||||
# We allow people to mix git worktrees & non-git worktrees for
|
||||
# now. This allows for in situ migration of repo clients.
|
||||
if os.path.exists(gitdir) or not self.UseGitWorktrees:
|
||||
objdir = os.path.join(
|
||||
self.repodir, "project-objects", namepath
|
||||
)
|
||||
else:
|
||||
use_git_worktrees = True
|
||||
gitdir = os.path.join(self.repodir, "worktrees", namepath)
|
||||
objdir = gitdir
|
||||
return relpath, worktree, gitdir, objdir, use_git_worktrees
|
||||
|
||||
def GetProjectsWithName(self, name, all_manifests=False):
|
||||
|
||||
@@ -222,6 +222,43 @@ def rmdir(path):
|
||||
os.rmdir(_makelongpath(path))
|
||||
|
||||
|
||||
def removedirs(path: str) -> None:
|
||||
"""Remove a directory tree of symlinks and empty directories.
|
||||
|
||||
Walks |path| bottom-up without following symlinks. Removes symlinks
|
||||
and empty directories. Stops at (and preserves) regular files and
|
||||
non-empty directories. Silently succeeds if |path| does not exist.
|
||||
|
||||
Availability: Unix, Windows.
|
||||
"""
|
||||
if islink(path):
|
||||
remove(path)
|
||||
return
|
||||
|
||||
if not isdir(path):
|
||||
return
|
||||
|
||||
for dirpath, dirnames, filenames in walk(path, topdown=False):
|
||||
for name in dirnames:
|
||||
entry = os.path.join(dirpath, name)
|
||||
if islink(entry):
|
||||
remove(entry)
|
||||
else:
|
||||
try:
|
||||
rmdir(entry)
|
||||
except OSError:
|
||||
pass
|
||||
for name in filenames:
|
||||
entry = os.path.join(dirpath, name)
|
||||
if islink(entry):
|
||||
remove(entry)
|
||||
|
||||
try:
|
||||
rmdir(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def isdir(path):
|
||||
"""os.path.isdir(path) wrapper with support for long paths on Windows.
|
||||
|
||||
|
||||
@@ -159,6 +159,8 @@ class Progress:
|
||||
inc: The number of items completed.
|
||||
msg: The message to display. If None, use the last message.
|
||||
"""
|
||||
if self._ended:
|
||||
return
|
||||
self._done += inc
|
||||
if msg is None:
|
||||
msg = self._last_msg
|
||||
|
||||
+832
-258
File diff suppressed because it is too large
Load Diff
+24
-1
@@ -14,9 +14,32 @@
|
||||
|
||||
[tool.black]
|
||||
line-length = 80
|
||||
# NB: Keep in sync with tox.ini.
|
||||
target-version = ['py36', 'py37', 'py38', 'py39', 'py310', 'py311'] #, 'py312'
|
||||
|
||||
# Config file for the isort python module.
|
||||
# This is used to enforce import sorting standards.
|
||||
#
|
||||
# https://pycqa.github.io/isort/docs/configuration/options.html
|
||||
[tool.isort]
|
||||
# Be compatible with `black` since it also matches what we want.
|
||||
profile = 'black'
|
||||
|
||||
line_length = 80
|
||||
length_sort = false
|
||||
force_single_line = true
|
||||
lines_after_imports = 2
|
||||
from_first = false
|
||||
case_sensitive = false
|
||||
force_sort_within_sections = true
|
||||
order_by_type = false
|
||||
|
||||
# Ignore generated files.
|
||||
extend_skip_glob = '*_pb2.py'
|
||||
|
||||
# Allow importing multiple classes on a single line from these modules.
|
||||
# https://google.github.io/styleguide/pyguide#s2.2-imports
|
||||
single_line_exclusions = ['abc', 'collections.abc', 'typing']
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = """
|
||||
skip_cq: Skip tests in the CQ. Should be rarely used!
|
||||
|
||||
@@ -90,7 +90,10 @@ def check_license(path: Path, lines: list[str]) -> bool:
|
||||
|
||||
def check_path(opts: argparse.Namespace, path: Path) -> bool:
|
||||
"""Check a single path."""
|
||||
data = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
data = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
lines = data.splitlines()
|
||||
# NB: Use list comprehension and not a generator so we run all the checks.
|
||||
return all(
|
||||
@@ -103,7 +106,7 @@ def check_path(opts: argparse.Namespace, path: Path) -> bool:
|
||||
def check_paths(opts: argparse.Namespace, paths: list[Path]) -> bool:
|
||||
"""Check all the paths."""
|
||||
# NB: Use list comprehension and not a generator so we check all paths.
|
||||
return all([check_path(opts, x) for x in paths])
|
||||
return all([check_path(opts, x) for x in paths]) # noqa: C419
|
||||
|
||||
|
||||
def find_files(opts: argparse.Namespace) -> list[Path]:
|
||||
|
||||
@@ -27,6 +27,9 @@ import sys
|
||||
import util
|
||||
|
||||
|
||||
assert sys.version_info >= (3, 9), "Release framework requires Python 3.9+"
|
||||
|
||||
|
||||
def sign(opts):
|
||||
"""Sign the launcher!"""
|
||||
output = ""
|
||||
@@ -82,18 +85,18 @@ Repo launcher bucket:
|
||||
gs://git-repo-downloads/
|
||||
|
||||
You should first upload it with a specific version:
|
||||
gsutil cp -a public-read {opts.launcher} gs://git-repo-downloads/repo-{version}
|
||||
gsutil cp -a public-read {opts.launcher}.asc gs://git-repo-downloads/repo-{version}.asc
|
||||
gcloud storage cp --predefined-acl=publicRead {opts.launcher} gs://git-repo-downloads/repo-{version}
|
||||
gcloud storage cp --predefined-acl=publicRead {opts.launcher}.asc gs://git-repo-downloads/repo-{version}.asc
|
||||
|
||||
Then to make it the public default:
|
||||
gsutil cp -a public-read gs://git-repo-downloads/repo-{version} gs://git-repo-downloads/repo
|
||||
gsutil cp -a public-read gs://git-repo-downloads/repo-{version}.asc gs://git-repo-downloads/repo.asc
|
||||
gcloud storage cp --predefined-acl=publicRead gs://git-repo-downloads/repo-{version} gs://git-repo-downloads/repo
|
||||
gcloud storage cp --predefined-acl=publicRead gs://git-repo-downloads/repo-{version}.asc gs://git-repo-downloads/repo.asc
|
||||
|
||||
NB: If a rollback is necessary, the GS bucket archives old versions, and may be
|
||||
accessed by specifying their unique id number.
|
||||
gsutil ls -la gs://git-repo-downloads/repo gs://git-repo-downloads/repo.asc
|
||||
gsutil cp -a public-read gs://git-repo-downloads/repo#<unique id> gs://git-repo-downloads/repo
|
||||
gsutil cp -a public-read gs://git-repo-downloads/repo.asc#<unique id> gs://git-repo-downloads/repo.asc
|
||||
gcloud storage ls --long --all-versions gs://git-repo-downloads/repo gs://git-repo-downloads/repo.asc
|
||||
gcloud storage cp --predefined-acl=publicRead gs://git-repo-downloads/repo#<unique id> gs://git-repo-downloads/repo
|
||||
gcloud storage cp --predefined-acl=publicRead gs://git-repo-downloads/repo.asc#<unique id> gs://git-repo-downloads/repo.asc
|
||||
""" # noqa: E501
|
||||
)
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ import sys
|
||||
import util
|
||||
|
||||
|
||||
assert sys.version_info >= (3, 9), "Release framework requires Python 3.9+"
|
||||
|
||||
|
||||
# We currently sign with the old DSA key as it's been around the longest.
|
||||
# We should transition to RSA by Jun 2020, and ECC by Jun 2021.
|
||||
KEYID = util.KEYID_DSA
|
||||
|
||||
@@ -24,7 +24,7 @@ from typing import List, Optional
|
||||
import urllib.request
|
||||
|
||||
|
||||
assert sys.version_info >= (3, 8), "Python 3.8+ required"
|
||||
assert sys.version_info >= (3, 9), "Release framework requires Python 3.9+"
|
||||
|
||||
|
||||
TOPDIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -30,6 +30,9 @@ import tempfile
|
||||
from typing import List
|
||||
|
||||
|
||||
assert sys.version_info >= (3, 9), "Release framework requires Python 3.9+"
|
||||
|
||||
|
||||
THIS_FILE = Path(__file__).resolve()
|
||||
TOPDIR = THIS_FILE.parent.parent
|
||||
MANDIR = TOPDIR.joinpath("man")
|
||||
@@ -62,13 +65,18 @@ def main(argv: List[str]) -> int:
|
||||
parser = get_parser()
|
||||
opts = parser.parse_args(argv)
|
||||
|
||||
if not shutil.which("help2man"):
|
||||
help2man = ["help2man"]
|
||||
cipd_help2man = TOPDIR / ".cipd_bin/bin/help2man"
|
||||
if cipd_help2man.exists():
|
||||
help2man = [cipd_help2man]
|
||||
elif not shutil.which("help2man"):
|
||||
sys.exit("Please install help2man to continue.")
|
||||
|
||||
# Let repo know we're generating man pages so it can avoid some dynamic
|
||||
# behavior (like probing active number of CPUs). We use a weird name &
|
||||
# value to make it less likely for users to set this var themselves.
|
||||
os.environ["_REPO_GENERATE_MANPAGES_"] = " indeed! "
|
||||
os.environ["COLUMNS"] = "10000"
|
||||
|
||||
# "repo branch" is an alias for "repo branches".
|
||||
del subcmds.all_commands["branch"]
|
||||
@@ -77,7 +85,7 @@ def main(argv: List[str]) -> int:
|
||||
version = RepoSourceVersion()
|
||||
cmdlist = [
|
||||
[
|
||||
"help2man",
|
||||
*help2man,
|
||||
"-N",
|
||||
"-n",
|
||||
f"repo {cmd} - manual page for repo {cmd}",
|
||||
@@ -96,7 +104,7 @@ def main(argv: List[str]) -> int:
|
||||
]
|
||||
cmdlist.append(
|
||||
[
|
||||
"help2man",
|
||||
*help2man,
|
||||
"-N",
|
||||
"-n",
|
||||
"repository management tool built on top of git",
|
||||
@@ -174,7 +182,6 @@ def replace_regex(data):
|
||||
"""
|
||||
regex = (
|
||||
(r"(It was generated by help2man) [0-9.]+", r"\g<1>."),
|
||||
(r"^\033\[[0-9;]*m([^\033]*)\033\[m", r"\g<1>"),
|
||||
(r"^\.IP\n(.*:)\n", r".SS \g<1>\n"),
|
||||
(r"^\.PP\nDescription", r".SH DETAILS"),
|
||||
)
|
||||
|
||||
@@ -129,7 +129,7 @@ if not REPO_REV:
|
||||
BUG_URL = "https://issues.gerritcodereview.com/issues/new?component=1370071"
|
||||
|
||||
# increment this whenever we make important changes to this script
|
||||
VERSION = (2, 61)
|
||||
VERSION = (2, 65)
|
||||
|
||||
# increment this if the MAINTAINER_KEYS block is modified
|
||||
KEYRING_VERSION = (2, 3)
|
||||
@@ -325,7 +325,7 @@ def InitParser(parser):
|
||||
group.add_option(
|
||||
"--manifest-depth",
|
||||
type="int",
|
||||
default=1,
|
||||
default=0,
|
||||
metavar="DEPTH",
|
||||
help="create a shallow clone of the manifest repo with "
|
||||
"given depth (0 for full clone); see git clone "
|
||||
@@ -379,6 +379,11 @@ def InitParser(parser):
|
||||
action="store_true",
|
||||
help="use git-worktree to manage projects",
|
||||
)
|
||||
group.add_option(
|
||||
"--use-local-gitdirs",
|
||||
action="store_true",
|
||||
help="bypass .repo/projects/ and use standard Git layout in working tree",
|
||||
)
|
||||
|
||||
# These are fundamentally different ways of structuring the checkout.
|
||||
group = parser.add_option_group("Project checkout optimizations")
|
||||
@@ -400,20 +405,20 @@ def InitParser(parser):
|
||||
"--partial-clone",
|
||||
action="store_true",
|
||||
help="perform partial clone (https://git-scm.com/"
|
||||
"docs/gitrepository-layout#_code_partialclone_code)",
|
||||
"docs/partial-clone)",
|
||||
)
|
||||
group.add_option(
|
||||
"--no-partial-clone",
|
||||
action="store_false",
|
||||
help="disable use of partial clone (https://git-scm.com/"
|
||||
"docs/gitrepository-layout#_code_partialclone_code)",
|
||||
"docs/partial-clone)",
|
||||
)
|
||||
group.add_option(
|
||||
"--partial-clone-exclude",
|
||||
action="store",
|
||||
help="exclude the specified projects (a comma-delimited "
|
||||
"project names) from partial clone (https://git-scm.com"
|
||||
"/docs/gitrepository-layout#_code_partialclone_code)",
|
||||
"/docs/partial-clone)",
|
||||
)
|
||||
group.add_option(
|
||||
"--clone-filter",
|
||||
@@ -742,6 +747,28 @@ def SetGitTrace2ParentSid(env=None):
|
||||
_setenv(KEY, value, env=env)
|
||||
|
||||
|
||||
def _NormalizeGpgHomePath(path: str) -> str:
|
||||
"""Convert path for GPG on Windows if running in MSYS/MinGW.
|
||||
|
||||
If GPG is MSYS-based (common in Git Bash/Cygwin), it expects POSIX-like paths
|
||||
(e.g. /c/Users/... instead of C:\\Users\\...). Native Windows Python uses
|
||||
Windows-style paths, which MSYS GPG will incorrectly treat as relative paths.
|
||||
We use cygpath to query the active MSYS/Cygwin mount configuration and resolve
|
||||
the correct POSIX path.
|
||||
"""
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["cygpath", "-u", path],
|
||||
stderr=subprocess.DEVNULL,
|
||||
universal_newlines=True,
|
||||
)
|
||||
return out.strip()
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
def _setenv(key, value, env=None):
|
||||
"""Set |key| in the OS environment |env| to |value|."""
|
||||
if env is None:
|
||||
@@ -1074,7 +1101,7 @@ def verify_rev(cwd, remote_ref, rev, quiet):
|
||||
print(file=sys.stderr)
|
||||
|
||||
env = os.environ.copy()
|
||||
_setenv("GNUPGHOME", gpg_dir, env)
|
||||
_setenv("GNUPGHOME", _NormalizeGpgHomePath(gpg_dir), env)
|
||||
run_git("tag", "-v", cur, cwd=cwd, env=env)
|
||||
return "%s^0" % cur
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
|
||||
# NB: While tests/* support Python >=3.6 to match requirements.json for `repo`,
|
||||
# the higher level runner logic does not need to be held back.
|
||||
assert sys.version_info >= (3, 9), "Test/release framework requires Python 3.9+"
|
||||
|
||||
|
||||
ROOT_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||
@@ -38,7 +42,7 @@ def is_ci() -> bool:
|
||||
return os.getenv("LUCI_CQ") == "yes"
|
||||
|
||||
|
||||
def run_pytest(argv: List[str]) -> int:
|
||||
def run_pytest(argv: list[str]) -> int:
|
||||
"""Returns the exit code from pytest."""
|
||||
if is_ci():
|
||||
argv = ["-m", "not skip_cq"] + argv
|
||||
@@ -51,7 +55,7 @@ def run_pytest(argv: List[str]) -> int:
|
||||
).returncode
|
||||
|
||||
|
||||
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."""
|
||||
if is_ci():
|
||||
argv = ["-m", "not skip_cq"] + argv
|
||||
@@ -77,6 +81,14 @@ def run_pytest_py38(argv: List[str]) -> int:
|
||||
|
||||
def run_black():
|
||||
"""Returns the exit code from black."""
|
||||
argv = ["--version"]
|
||||
log_cmd("black", argv)
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "black"] + argv,
|
||||
check=True,
|
||||
cwd=ROOT_DIR,
|
||||
)
|
||||
|
||||
# Black by default only matches .py files. We have to list standalone
|
||||
# scripts manually.
|
||||
extra_programs = [
|
||||
@@ -96,6 +108,14 @@ def run_black():
|
||||
|
||||
def run_flake8():
|
||||
"""Returns the exit code from flake8."""
|
||||
argv = ["--version"]
|
||||
log_cmd("flake8", argv)
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "flake8"] + argv,
|
||||
check=True,
|
||||
cwd=ROOT_DIR,
|
||||
)
|
||||
|
||||
argv = [ROOT_DIR]
|
||||
log_cmd("flake8", argv)
|
||||
return subprocess.run(
|
||||
@@ -107,6 +127,14 @@ def run_flake8():
|
||||
|
||||
def run_isort():
|
||||
"""Returns the exit code from isort."""
|
||||
argv = ["--version-number"]
|
||||
log_cmd("isort", argv)
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "isort"] + argv,
|
||||
check=True,
|
||||
cwd=ROOT_DIR,
|
||||
)
|
||||
|
||||
argv = ["--check", ROOT_DIR]
|
||||
log_cmd("isort", argv)
|
||||
return subprocess.run(
|
||||
@@ -129,11 +157,6 @@ def run_check_metadata():
|
||||
|
||||
def run_update_manpages() -> int:
|
||||
"""Returns the exit code from release/update-manpages."""
|
||||
# Allow this to fail on CI, but not local devs.
|
||||
if is_ci() and not shutil.which("help2man"):
|
||||
print("update-manpages: help2man not found; skipping test")
|
||||
return 0
|
||||
|
||||
argv = ["--check"]
|
||||
log_cmd("release/update-manpages", argv)
|
||||
return subprocess.run(
|
||||
@@ -143,8 +166,24 @@ def run_update_manpages() -> int:
|
||||
).returncode
|
||||
|
||||
|
||||
def cipd_bootstrap() -> None:
|
||||
"""Install packages via cipd if available."""
|
||||
argv = ["ensure", "-root", ".cipd_bin", "-ensure-file", "cipd_manifest.txt"]
|
||||
log_cmd("cipd", argv)
|
||||
try:
|
||||
subprocess.run(
|
||||
["cipd"] + argv,
|
||||
check=True,
|
||||
cwd=ROOT_DIR,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
# Skip if the user doesn't have cipd from depot_tools.
|
||||
return
|
||||
|
||||
|
||||
def main(argv):
|
||||
"""The main entry."""
|
||||
cipd_bootstrap()
|
||||
checks = (
|
||||
functools.partial(run_pytest, argv),
|
||||
functools.partial(run_pytest_py38, argv),
|
||||
|
||||
+13
-3
@@ -48,10 +48,10 @@ wheel: <
|
||||
version: "version:3.0.7"
|
||||
>
|
||||
|
||||
# Required by pytest==8.3.4
|
||||
# Required by pytest==8.3.4 and flake8-bugbear==24.12.12
|
||||
wheel: <
|
||||
name: "infra/python/wheels/attrs-py2_py3"
|
||||
version: "version:21.4.0"
|
||||
name: "infra/python/wheels/attrs-py3"
|
||||
version: "version:24.2.0"
|
||||
>
|
||||
|
||||
# NB: Keep in sync with constraints.txt.
|
||||
@@ -119,6 +119,16 @@ wheel: <
|
||||
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"
|
||||
|
||||
@@ -52,12 +52,12 @@ def _parse_ssh_version(ver_str=None):
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def version():
|
||||
"""return ssh version as a tuple"""
|
||||
"""Return ssh version as a tuple.
|
||||
|
||||
If ssh is not available, a FileNotFoundError will be raised.
|
||||
"""
|
||||
try:
|
||||
return _parse_ssh_version()
|
||||
except FileNotFoundError:
|
||||
print("fatal: ssh not installed", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(
|
||||
"fatal: unable to detect ssh version"
|
||||
@@ -102,9 +102,18 @@ class ProxyManager:
|
||||
self._clients = manager.list()
|
||||
# Path to directory for holding master sockets.
|
||||
self._sock_path = None
|
||||
# See if ssh is usable.
|
||||
self._ssh_installed = False
|
||||
|
||||
def __enter__(self):
|
||||
"""Enter a new context."""
|
||||
# Check which version of ssh is available.
|
||||
try:
|
||||
version()
|
||||
self._ssh_installed = True
|
||||
except FileNotFoundError:
|
||||
self._ssh_installed = False
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
@@ -140,7 +149,7 @@ class ProxyManager:
|
||||
while True:
|
||||
try:
|
||||
procs.pop(0)
|
||||
except: # noqa: E722
|
||||
except IndexError:
|
||||
break
|
||||
|
||||
def close(self):
|
||||
@@ -282,6 +291,9 @@ class ProxyManager:
|
||||
|
||||
def preconnect(self, url):
|
||||
"""If |uri| will create a ssh connection, setup the ssh master for it.""" # noqa: E501
|
||||
if not self._ssh_installed:
|
||||
return False
|
||||
|
||||
m = URI_ALL.match(url)
|
||||
if m:
|
||||
scheme = m.group(1)
|
||||
@@ -306,6 +318,9 @@ class ProxyManager:
|
||||
|
||||
This has all the master sockets so clients can talk to them.
|
||||
"""
|
||||
if not self._ssh_installed:
|
||||
return None
|
||||
|
||||
if self._sock_path is None:
|
||||
if not create:
|
||||
return None
|
||||
|
||||
+10
-28
@@ -25,7 +25,6 @@ from color import Coloring
|
||||
from command import Command
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from command import MirrorSafeCommand
|
||||
from error import ManifestInvalidRevisionError
|
||||
from repo_logging import RepoLogger
|
||||
|
||||
|
||||
@@ -102,6 +101,14 @@ revision to a locally executed git command, use REPO_LREV.
|
||||
REPO_RREV is the name of the revision from the manifest, exactly
|
||||
as written in the manifest.
|
||||
|
||||
REPO_UPSTREAM is the name of the upstream branch as specified in the
|
||||
manifest.
|
||||
|
||||
REPO_DEST_BRANCH is the name of the destination branch for code review,
|
||||
as specified in the manifest.
|
||||
|
||||
REPO_PROJECT_FETCH_URL is the full resolved fetch URL for the project.
|
||||
|
||||
REPO_COUNT is the total number of projects being iterated.
|
||||
|
||||
REPO_I is the current (1-based) iteration count. Can be used in
|
||||
@@ -236,14 +243,6 @@ without iterating through the remaining projects.
|
||||
|
||||
mirror = self.manifest.IsMirror
|
||||
|
||||
smart_sync_manifest_name = "smart_sync_override.xml"
|
||||
smart_sync_manifest_path = os.path.join(
|
||||
self.manifest.manifestProject.worktree, smart_sync_manifest_name
|
||||
)
|
||||
|
||||
if os.path.isfile(smart_sync_manifest_path):
|
||||
self.manifest.Override(smart_sync_manifest_path)
|
||||
|
||||
if opt.regex:
|
||||
projects = self.FindProjects(args, all_manifests=all_trees)
|
||||
elif opt.inverse_regex:
|
||||
@@ -339,25 +338,8 @@ def DoWork(project, mirror, opt, cmd, shell, cnt, config):
|
||||
val = ""
|
||||
env[name] = val
|
||||
|
||||
setenv("REPO_PROJECT", project.name)
|
||||
setenv("REPO_OUTERPATH", project.manifest.path_prefix)
|
||||
setenv("REPO_INNERPATH", project.relpath)
|
||||
setenv("REPO_PATH", project.RelPath(local=opt.this_manifest_only))
|
||||
setenv("REPO_REMOTE", project.remote.name)
|
||||
try:
|
||||
# If we aren't in a fully synced state and we don't have the ref the
|
||||
# manifest wants, then this will fail. Ignore it for the purposes of
|
||||
# this code.
|
||||
lrev = "" if mirror else project.GetRevisionId()
|
||||
except ManifestInvalidRevisionError:
|
||||
lrev = ""
|
||||
setenv("REPO_LREV", lrev)
|
||||
setenv("REPO_RREV", project.revisionExpr)
|
||||
setenv("REPO_UPSTREAM", project.upstream)
|
||||
setenv("REPO_DEST_BRANCH", project.dest_branch)
|
||||
setenv("REPO_I", str(cnt + 1))
|
||||
for annotation in project.annotations:
|
||||
setenv("REPO__%s" % (annotation.name), annotation.value)
|
||||
env.update(project.GetEnvVars(local=opt.this_manifest_only))
|
||||
env["REPO_I"] = str(cnt + 1)
|
||||
|
||||
if mirror:
|
||||
setenv("GIT_DIR", project.gitdir)
|
||||
|
||||
+23
-5
@@ -16,6 +16,7 @@ import os
|
||||
from typing import List, Set
|
||||
|
||||
from command import Command
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
import platform_utils
|
||||
from progress import Progress
|
||||
@@ -204,6 +205,7 @@ class Gc(Command):
|
||||
[
|
||||
"rev-list",
|
||||
"--objects",
|
||||
"--missing=allow-promisor",
|
||||
f"--remotes={project.remote.name}",
|
||||
"--filter=blob:none",
|
||||
"--tags",
|
||||
@@ -215,7 +217,12 @@ class Gc(Command):
|
||||
# Get all local objects and pack them.
|
||||
local_head_objects_cmd = GitCommand(
|
||||
project,
|
||||
["rev-list", "--objects", "HEAD^{tree}"],
|
||||
[
|
||||
"rev-list",
|
||||
"--objects",
|
||||
"--missing=allow-promisor",
|
||||
"HEAD^{tree}",
|
||||
],
|
||||
capture_stdout=True,
|
||||
verify_command=True,
|
||||
)
|
||||
@@ -224,6 +231,7 @@ class Gc(Command):
|
||||
[
|
||||
"rev-list",
|
||||
"--objects",
|
||||
"--missing=allow-promisor",
|
||||
"--all",
|
||||
"--reflog",
|
||||
"--indexed-objects",
|
||||
@@ -284,11 +292,21 @@ class Gc(Command):
|
||||
args, all_manifests=not opt.this_manifest_only
|
||||
)
|
||||
|
||||
ret = self.delete_unused_projects(projects, opt)
|
||||
# If the user specified projects, fetch the global list separately
|
||||
# to avoid deleting untargeted projects.
|
||||
if args:
|
||||
all_projects = self.GetProjects(
|
||||
[], all_manifests=not opt.this_manifest_only
|
||||
)
|
||||
else:
|
||||
all_projects = projects
|
||||
|
||||
ret = self.delete_unused_projects(all_projects, opt)
|
||||
if ret != 0:
|
||||
return ret
|
||||
|
||||
if not opt.repack:
|
||||
return
|
||||
if opt.repack:
|
||||
git_require((2, 17, 0), fail=True, msg="--repack")
|
||||
ret = self.repack_projects(projects, opt)
|
||||
|
||||
return self.repack_projects(projects, opt)
|
||||
return ret
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ contain a line that matches both expressions:
|
||||
pt = getattr(parser.values, "cmd_argv", None)
|
||||
if pt is None:
|
||||
pt = []
|
||||
setattr(parser.values, "cmd_argv", pt)
|
||||
parser.values.cmd_argv = pt
|
||||
|
||||
if opt_str == "-(":
|
||||
pt.append("(")
|
||||
|
||||
+3
-5
@@ -59,7 +59,7 @@ Displays detailed usage information about a command.
|
||||
|
||||
def PrintAllCommandsBody(self):
|
||||
print("The complete list of recognized repo commands is:")
|
||||
commandNames = list(sorted(all_commands))
|
||||
commandNames = sorted(all_commands)
|
||||
self._PrintCommands(commandNames)
|
||||
print(
|
||||
"See 'repo help <command>' for more information on a "
|
||||
@@ -74,10 +74,8 @@ Displays detailed usage information about a command.
|
||||
def PrintCommonCommandsBody(self):
|
||||
print("The most commonly used repo commands are:")
|
||||
|
||||
commandNames = list(
|
||||
sorted(
|
||||
name for name, command in all_commands.items() if command.COMMON
|
||||
)
|
||||
commandNames = sorted(
|
||||
name for name, command in all_commands.items() if command.COMMON
|
||||
)
|
||||
self._PrintCommands(commandNames)
|
||||
|
||||
|
||||
+343
-124
@@ -12,14 +12,41 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import enum
|
||||
import functools
|
||||
import io
|
||||
import json
|
||||
import optparse
|
||||
import sys
|
||||
from typing import Any, Dict, List, NamedTuple
|
||||
|
||||
from color import Coloring
|
||||
from command import DEFAULT_LOCAL_JOBS
|
||||
from command import PagedCommand
|
||||
from git_refs import R_HEADS
|
||||
from git_refs import R_M
|
||||
|
||||
|
||||
class BranchInfo(NamedTuple):
|
||||
"""Holds information about a branch in a project."""
|
||||
|
||||
relpath: str
|
||||
name: str
|
||||
commits: Any
|
||||
date: str
|
||||
is_current: bool
|
||||
|
||||
|
||||
class OutputFormat(enum.Enum):
|
||||
"""Type for the requested output format."""
|
||||
|
||||
# Human-readable text output.
|
||||
TEXT = enum.auto()
|
||||
|
||||
# Machine-readable JSON output.
|
||||
JSON = enum.auto()
|
||||
|
||||
|
||||
class _Coloring(Coloring):
|
||||
def __init__(self, config):
|
||||
Coloring.__init__(self, config, "status")
|
||||
@@ -27,10 +54,11 @@ class _Coloring(Coloring):
|
||||
|
||||
class Info(PagedCommand):
|
||||
COMMON = True
|
||||
PARALLEL_JOBS = DEFAULT_LOCAL_JOBS
|
||||
helpSummary = (
|
||||
"Get info on the manifest branch, current branch or unmerged branches"
|
||||
)
|
||||
helpUsage = "%prog [-dl] [-o [-c]] [<project>...]"
|
||||
helpUsage = "%prog [-dl] [-o [-c]] [--format=<format>] [<project>...]"
|
||||
|
||||
def _Options(self, p):
|
||||
p.add_option(
|
||||
@@ -46,6 +74,30 @@ class Info(PagedCommand):
|
||||
action="store_true",
|
||||
help="show overview of all local commits",
|
||||
)
|
||||
p.add_option(
|
||||
"--include-summary",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="include manifest summary (default: true)",
|
||||
)
|
||||
p.add_option(
|
||||
"--no-include-summary",
|
||||
dest="include_summary",
|
||||
action="store_false",
|
||||
help="exclude manifest summary",
|
||||
)
|
||||
p.add_option(
|
||||
"--include-projects",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="include project details (default: true)",
|
||||
)
|
||||
p.add_option(
|
||||
"--no-include-projects",
|
||||
dest="include_projects",
|
||||
action="store_false",
|
||||
help="exclude project details",
|
||||
)
|
||||
p.add_option(
|
||||
"-c",
|
||||
"--current-branch",
|
||||
@@ -72,8 +124,37 @@ class Info(PagedCommand):
|
||||
action="store_true",
|
||||
help="disable all remote operations",
|
||||
)
|
||||
formats = tuple(x.lower() for x in OutputFormat.__members__.keys())
|
||||
p.add_option(
|
||||
"--format",
|
||||
default=OutputFormat.TEXT.name.lower(),
|
||||
choices=formats,
|
||||
help=f"output format: {', '.join(formats)} (default: %default)",
|
||||
)
|
||||
|
||||
def WantPager(self, opt):
|
||||
return OutputFormat[opt.format.upper()] == OutputFormat.TEXT
|
||||
|
||||
def ValidateOptions(self, opt, args):
|
||||
output_format = OutputFormat[opt.format.upper()]
|
||||
if output_format == OutputFormat.JSON:
|
||||
if opt.all:
|
||||
self.OptionParser.error("--diff is not supported with JSON")
|
||||
if opt.overview:
|
||||
self.OptionParser.error("--overview is not supported with JSON")
|
||||
|
||||
def Execute(self, opt, args):
|
||||
if not opt.this_manifest_only:
|
||||
self.manifest = self.manifest.outer_client
|
||||
|
||||
output_format = OutputFormat[opt.format.upper()]
|
||||
if output_format == OutputFormat.JSON:
|
||||
self._ExecuteJson(opt, args)
|
||||
else:
|
||||
self._ExecuteText(opt, args)
|
||||
|
||||
def _ExecuteText(self, opt, args) -> None:
|
||||
"""Output info as human-readable text."""
|
||||
self.out = _Coloring(self.client.globalConfig)
|
||||
self.heading = self.out.printer("heading", attr="bold")
|
||||
self.headtext = self.out.nofmt_printer("headtext", fg="yellow")
|
||||
@@ -84,157 +165,295 @@ class Info(PagedCommand):
|
||||
|
||||
self.opt = opt
|
||||
|
||||
if not opt.this_manifest_only:
|
||||
self.manifest = self.manifest.outer_client
|
||||
manifestConfig = self.manifest.manifestProject.config
|
||||
mergeBranch = manifestConfig.GetBranch("default").merge
|
||||
manifestGroups = self.manifest.GetManifestGroupsStr()
|
||||
if opt.include_summary:
|
||||
self._printSummary()
|
||||
|
||||
self.heading("Manifest branch: ")
|
||||
if self.manifest.default.revisionExpr:
|
||||
self.headtext(self.manifest.default.revisionExpr)
|
||||
self.out.nl()
|
||||
self.heading("Manifest merge branch: ")
|
||||
# The manifest might not have a merge branch if it isn't in a git repo,
|
||||
# e.g. if `repo init --standalone-manifest` is used.
|
||||
self.headtext(mergeBranch or "")
|
||||
self.out.nl()
|
||||
self.heading("Manifest groups: ")
|
||||
self.headtext(manifestGroups)
|
||||
self.out.nl()
|
||||
sp = self.manifest.superproject
|
||||
srev = sp.commit_id if sp and sp.commit_id else "None"
|
||||
self.heading("Superproject revision: ")
|
||||
self.headtext(srev)
|
||||
self.out.nl()
|
||||
|
||||
self.printSeparator()
|
||||
|
||||
if not opt.overview:
|
||||
if not opt.include_projects:
|
||||
return
|
||||
elif not opt.overview:
|
||||
self._printDiffInfo(opt, args)
|
||||
else:
|
||||
self._printCommitOverview(opt, args)
|
||||
|
||||
def _getSummaryData(self) -> Dict[str, Any]:
|
||||
"""Gather manifest summary data as a dict."""
|
||||
manifestConfig = self.manifest.manifestProject.config
|
||||
mergeBranch = manifestConfig.GetBranch("default").merge
|
||||
manifestGroups = self.manifest.GetManifestGroupsStr()
|
||||
sp = self.manifest.superproject
|
||||
srev = sp.commit_id if sp and sp.commit_id else None
|
||||
return {
|
||||
"manifest_branch": self.manifest.default.revisionExpr or "",
|
||||
"manifest_merge_branch": mergeBranch or "",
|
||||
"manifest_groups": manifestGroups,
|
||||
"superproject_revision": srev,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _getProjectData(cls, project) -> Dict[str, Any]:
|
||||
"""Gather project data as a dict."""
|
||||
data = {
|
||||
"name": project.name,
|
||||
"mount_path": project.worktree,
|
||||
"current_revision": project.GetHeadRevisionId()
|
||||
or project.GetRevisionId(),
|
||||
"manifest_revision": project.revisionExpr,
|
||||
"local_branches": list(project.GetBranches()),
|
||||
}
|
||||
currentBranch = project.CurrentBranch
|
||||
if currentBranch:
|
||||
data["current_branch"] = currentBranch
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def _ProjectDataHelper(cls, project_idx: int) -> Dict[str, Any]:
|
||||
"""Helper to get project data in parallel."""
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
return cls._getProjectData(project)
|
||||
|
||||
def _ExecuteJson(self, opt, args) -> None:
|
||||
"""Output info as JSON."""
|
||||
result = {}
|
||||
if opt.include_summary:
|
||||
result["summary"] = self._getSummaryData()
|
||||
if opt.include_projects:
|
||||
projs = self.GetProjects(
|
||||
args, all_manifests=not opt.this_manifest_only
|
||||
)
|
||||
project_data = []
|
||||
|
||||
def _ProcessResults(_pool, _output, results):
|
||||
project_data.extend(results)
|
||||
|
||||
with self.ParallelContext():
|
||||
self.get_parallel_context()["projects"] = projs
|
||||
self.ExecuteInParallel(
|
||||
opt.jobs,
|
||||
self._ProjectDataHelper,
|
||||
range(len(projs)),
|
||||
callback=_ProcessResults,
|
||||
ordered=True,
|
||||
chunksize=1,
|
||||
)
|
||||
result["projects"] = project_data
|
||||
|
||||
json_settings = {
|
||||
# JSON style guide says Unicode characters are fully allowed.
|
||||
"ensure_ascii": False,
|
||||
# We use 2 space indent to match JSON style guide.
|
||||
"indent": 2,
|
||||
"separators": (",", ": "),
|
||||
"sort_keys": True,
|
||||
}
|
||||
sys.stdout.write(json.dumps(result, **json_settings) + "\n")
|
||||
|
||||
def _printSummary(self) -> None:
|
||||
"""Print manifest summary in text format."""
|
||||
data = self._getSummaryData()
|
||||
self.heading("Manifest branch: ")
|
||||
self.headtext(data["manifest_branch"])
|
||||
self.out.nl()
|
||||
self.heading("Manifest merge branch: ")
|
||||
self.headtext(data["manifest_merge_branch"])
|
||||
self.out.nl()
|
||||
self.heading("Manifest groups: ")
|
||||
self.headtext(data["manifest_groups"])
|
||||
self.out.nl()
|
||||
self.heading("Superproject revision: ")
|
||||
self.headtext(data["superproject_revision"] or "None")
|
||||
self.out.nl()
|
||||
self.printSeparator()
|
||||
|
||||
def printSeparator(self):
|
||||
self.text("----------------------------")
|
||||
self.out.nl()
|
||||
|
||||
@classmethod
|
||||
def _DiffHelper(cls, project_idx: int, opt: Any) -> str:
|
||||
"""Helper for ParallelContext to get diff info for a project."""
|
||||
buf = io.StringIO()
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
config = cls.get_parallel_context()["config"]
|
||||
|
||||
out = _Coloring(config)
|
||||
out.redirect(buf)
|
||||
|
||||
heading = out.printer("heading", attr="bold")
|
||||
headtext = out.nofmt_printer("headtext", fg="yellow")
|
||||
redtext = out.printer("redtext", fg="red")
|
||||
sha = out.printer("sha", fg="yellow")
|
||||
text = out.nofmt_printer("text")
|
||||
dimtext = out.printer("dimtext", attr="dim")
|
||||
|
||||
heading("Project: ")
|
||||
headtext(project.name)
|
||||
out.nl()
|
||||
|
||||
heading("Mount path: ")
|
||||
headtext(project.worktree)
|
||||
out.nl()
|
||||
|
||||
heading("Current revision: ")
|
||||
headtext(project.GetHeadRevisionId() or project.GetRevisionId())
|
||||
out.nl()
|
||||
|
||||
currentBranch = project.CurrentBranch
|
||||
if currentBranch:
|
||||
heading("Current branch: ")
|
||||
headtext(currentBranch)
|
||||
out.nl()
|
||||
|
||||
heading("Manifest revision: ")
|
||||
headtext(project.revisionExpr)
|
||||
out.nl()
|
||||
|
||||
localBranches = list(project.GetBranches().keys())
|
||||
heading("Local Branches: ")
|
||||
redtext(str(len(localBranches)))
|
||||
if localBranches:
|
||||
text(" [")
|
||||
text(", ".join(localBranches))
|
||||
text("]")
|
||||
out.nl()
|
||||
|
||||
if opt.all:
|
||||
if not opt.local:
|
||||
project.Sync_NetworkHalf(quiet=True, current_branch_only=True)
|
||||
|
||||
branch = project.manifest.manifestProject.config.GetBranch(
|
||||
"default"
|
||||
).merge
|
||||
if branch.startswith(R_HEADS):
|
||||
branch = branch[len(R_HEADS) :]
|
||||
logTarget = R_M + branch
|
||||
|
||||
bareTmp = project.bare_git._bare
|
||||
project.bare_git._bare = False
|
||||
localCommits = project.bare_git.rev_list(
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
logTarget + "..",
|
||||
"--",
|
||||
)
|
||||
|
||||
originCommits = project.bare_git.rev_list(
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
".." + logTarget,
|
||||
"--",
|
||||
)
|
||||
project.bare_git._bare = bareTmp
|
||||
|
||||
heading("Local Commits: ")
|
||||
redtext(str(len(localCommits)))
|
||||
dimtext(" (on current branch)")
|
||||
out.nl()
|
||||
|
||||
for c in localCommits:
|
||||
split = c.split()
|
||||
sha(split[0] + " ")
|
||||
text(" ".join(split[1:]))
|
||||
out.nl()
|
||||
|
||||
text("----------------------------")
|
||||
out.nl()
|
||||
|
||||
heading("Remote Commits: ")
|
||||
redtext(str(len(originCommits)))
|
||||
out.nl()
|
||||
|
||||
for c in originCommits:
|
||||
split = c.split()
|
||||
sha(split[0] + " ")
|
||||
text(" ".join(split[1:]))
|
||||
out.nl()
|
||||
|
||||
text("----------------------------")
|
||||
out.nl()
|
||||
|
||||
return buf.getvalue()
|
||||
|
||||
def _printDiffInfo(self, opt, args):
|
||||
# We let exceptions bubble up to main as they'll be well structured.
|
||||
projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
|
||||
|
||||
for p in projs:
|
||||
self.heading("Project: ")
|
||||
self.headtext(p.name)
|
||||
self.out.nl()
|
||||
def _ProcessResults(_pool, _output, results):
|
||||
for output in results:
|
||||
if output:
|
||||
print(output, end="")
|
||||
|
||||
self.heading("Mount path: ")
|
||||
self.headtext(p.worktree)
|
||||
self.out.nl()
|
||||
with self.ParallelContext():
|
||||
self.get_parallel_context()["projects"] = projs
|
||||
self.get_parallel_context()[
|
||||
"config"
|
||||
] = self.manifest.manifestProject.config
|
||||
|
||||
self.heading("Current revision: ")
|
||||
self.headtext(p.GetRevisionId())
|
||||
self.out.nl()
|
||||
self.ExecuteInParallel(
|
||||
opt.jobs,
|
||||
functools.partial(self._DiffHelper, opt=opt),
|
||||
range(len(projs)),
|
||||
callback=_ProcessResults,
|
||||
ordered=True,
|
||||
chunksize=1,
|
||||
)
|
||||
|
||||
currentBranch = p.CurrentBranch
|
||||
if currentBranch:
|
||||
self.heading("Current branch: ")
|
||||
self.headtext(currentBranch)
|
||||
self.out.nl()
|
||||
@classmethod
|
||||
def _OverviewHelper(cls, project_idx: int, opt: Any) -> List[BranchInfo]:
|
||||
"""Helper to get overview of uploadable branches."""
|
||||
project = cls.get_parallel_context()["projects"][project_idx]
|
||||
|
||||
self.heading("Manifest revision: ")
|
||||
self.headtext(p.revisionExpr)
|
||||
self.out.nl()
|
||||
branches = []
|
||||
br = [project.GetUploadableBranch(x) for x in project.GetBranches()]
|
||||
br = [x for x in br if x]
|
||||
if opt.current_branch:
|
||||
br = [x for x in br if x.name == project.CurrentBranch]
|
||||
|
||||
localBranches = list(p.GetBranches().keys())
|
||||
self.heading("Local Branches: ")
|
||||
self.redtext(str(len(localBranches)))
|
||||
if localBranches:
|
||||
self.text(" [")
|
||||
self.text(", ".join(localBranches))
|
||||
self.text("]")
|
||||
self.out.nl()
|
||||
|
||||
if self.opt.all:
|
||||
self.findRemoteLocalDiff(p)
|
||||
|
||||
self.printSeparator()
|
||||
|
||||
def findRemoteLocalDiff(self, project):
|
||||
# Fetch all the latest commits.
|
||||
if not self.opt.local:
|
||||
project.Sync_NetworkHalf(quiet=True, current_branch_only=True)
|
||||
|
||||
branch = self.manifest.manifestProject.config.GetBranch("default").merge
|
||||
if branch.startswith(R_HEADS):
|
||||
branch = branch[len(R_HEADS) :]
|
||||
logTarget = R_M + branch
|
||||
|
||||
bareTmp = project.bare_git._bare
|
||||
project.bare_git._bare = False
|
||||
localCommits = project.bare_git.rev_list(
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
logTarget + "..",
|
||||
"--",
|
||||
)
|
||||
|
||||
originCommits = project.bare_git.rev_list(
|
||||
"--abbrev=8",
|
||||
"--abbrev-commit",
|
||||
"--pretty=oneline",
|
||||
".." + logTarget,
|
||||
"--",
|
||||
)
|
||||
project.bare_git._bare = bareTmp
|
||||
|
||||
self.heading("Local Commits: ")
|
||||
self.redtext(str(len(localCommits)))
|
||||
self.dimtext(" (on current branch)")
|
||||
self.out.nl()
|
||||
|
||||
for c in localCommits:
|
||||
split = c.split()
|
||||
self.sha(split[0] + " ")
|
||||
self.text(" ".join(split[1:]))
|
||||
self.out.nl()
|
||||
|
||||
self.printSeparator()
|
||||
|
||||
self.heading("Remote Commits: ")
|
||||
self.redtext(str(len(originCommits)))
|
||||
self.out.nl()
|
||||
|
||||
for c in originCommits:
|
||||
split = c.split()
|
||||
self.sha(split[0] + " ")
|
||||
self.text(" ".join(split[1:]))
|
||||
self.out.nl()
|
||||
for b in br:
|
||||
branches.append(
|
||||
BranchInfo(
|
||||
relpath=project.RelPath(local=opt.this_manifest_only),
|
||||
name=b.name,
|
||||
commits=b.commits,
|
||||
date=b.date,
|
||||
is_current=b.name == project.CurrentBranch,
|
||||
)
|
||||
)
|
||||
return branches
|
||||
|
||||
def _printCommitOverview(self, opt, args):
|
||||
projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
|
||||
|
||||
all_branches = []
|
||||
for project in self.GetProjects(
|
||||
args, all_manifests=not opt.this_manifest_only
|
||||
):
|
||||
br = [project.GetUploadableBranch(x) for x in project.GetBranches()]
|
||||
br = [x for x in br if x]
|
||||
if self.opt.current_branch:
|
||||
br = [x for x in br if x.name == project.CurrentBranch]
|
||||
all_branches.extend(br)
|
||||
|
||||
def _ProcessResults(_pool, _output, results):
|
||||
for branches in results:
|
||||
all_branches.extend(branches)
|
||||
|
||||
with self.ParallelContext():
|
||||
self.get_parallel_context()["projects"] = projs
|
||||
|
||||
self.ExecuteInParallel(
|
||||
opt.jobs,
|
||||
functools.partial(self._OverviewHelper, opt=opt),
|
||||
range(len(projs)),
|
||||
callback=_ProcessResults,
|
||||
ordered=True,
|
||||
chunksize=1,
|
||||
)
|
||||
|
||||
if not all_branches:
|
||||
return
|
||||
|
||||
self.out.nl()
|
||||
self.heading("Projects Overview")
|
||||
project = None
|
||||
current_relpath = None
|
||||
|
||||
for branch in all_branches:
|
||||
if project != branch.project:
|
||||
project = branch.project
|
||||
if current_relpath != branch.relpath:
|
||||
current_relpath = branch.relpath
|
||||
self.out.nl()
|
||||
self.headtext(project.RelPath(local=opt.this_manifest_only))
|
||||
self.headtext(current_relpath)
|
||||
self.out.nl()
|
||||
|
||||
commits = branch.commits
|
||||
@@ -242,7 +461,7 @@ class Info(PagedCommand):
|
||||
self.text(
|
||||
"%s %-33s (%2d commit%s, %s)"
|
||||
% (
|
||||
branch.name == project.CurrentBranch and "*" or " ",
|
||||
branch.is_current and "*" or " ",
|
||||
branch.name,
|
||||
len(commits),
|
||||
len(commits) != 1 and "s" or "",
|
||||
|
||||
@@ -33,6 +33,7 @@ _REPO_ALLOW_SHALLOW = os.environ.get("REPO_ALLOW_SHALLOW")
|
||||
|
||||
class Init(InteractiveCommand, MirrorSafeCommand):
|
||||
COMMON = True
|
||||
RESPECT_SMART_SYNC_OVERRIDE = False
|
||||
MULTI_MANIFEST_SUPPORT = True
|
||||
helpSummary = "Initialize a repo client checkout in the current directory"
|
||||
helpUsage = """
|
||||
@@ -169,6 +170,7 @@ to update the working directory files.
|
||||
depth=opt.depth,
|
||||
git_event_log=self.git_event_log,
|
||||
manifest_name=opt.manifest_name,
|
||||
use_local_gitdirs=opt.use_local_gitdirs,
|
||||
):
|
||||
manifest_name = opt.manifest_name
|
||||
raise UpdateManifestError(
|
||||
|
||||
+14
-1
@@ -16,7 +16,9 @@ import sys
|
||||
|
||||
from color import Coloring
|
||||
from command import Command
|
||||
from error import GitError
|
||||
from git_command import GitCommand
|
||||
from project import Project
|
||||
from repo_logging import RepoLogger
|
||||
|
||||
|
||||
@@ -30,6 +32,17 @@ class RebaseColoring(Coloring):
|
||||
self.fail = self.printer("fail", fg="red")
|
||||
|
||||
|
||||
def _ResolveOntoManifest(project: Project) -> str:
|
||||
"""Resolve project's revisionExpr to a local tracking branch.
|
||||
|
||||
Falls back to the raw revisionExpr if ToLocal fails or raises GitError.
|
||||
"""
|
||||
try:
|
||||
return project.GetRemote().ToLocal(project.revisionExpr)
|
||||
except GitError:
|
||||
return project.revisionExpr
|
||||
|
||||
|
||||
class Rebase(Command):
|
||||
COMMON = True
|
||||
helpSummary = "Rebase local branches on upstream branch"
|
||||
@@ -162,7 +175,7 @@ branch but need to incorporate new upstream changes "underneath" them.
|
||||
args = common_args[:]
|
||||
if opt.onto_manifest:
|
||||
args.append("--onto")
|
||||
args.append(project.revisionExpr)
|
||||
args.append(_ResolveOntoManifest(project))
|
||||
|
||||
args.append(upbranch.LocalMerge)
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@ project 'repo' on branch 'devwork':
|
||||
project repo/ branch devwork
|
||||
-m subcmds/status.py
|
||||
|
||||
If the branch is tracking an upstream branch, the number of commits
|
||||
ahead and/or behind is also shown:
|
||||
|
||||
project repo/ branch devwork [ahead 1, behind 2]
|
||||
-m subcmds/status.py
|
||||
|
||||
The first column explains how the staging area (index) differs from
|
||||
the last commit (HEAD). Its values are always displayed in upper
|
||||
case and have the following meanings:
|
||||
|
||||
+424
-100
@@ -23,6 +23,8 @@ import netrc
|
||||
import optparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -64,6 +66,7 @@ from error import SyncError
|
||||
from error import UpdateManifestError
|
||||
import event_log
|
||||
from git_command import git_require
|
||||
from git_command import GitCommand
|
||||
from git_config import GetUrlCookieFile
|
||||
from git_refs import HEAD
|
||||
from git_refs import R_HEADS
|
||||
@@ -95,19 +98,27 @@ logger = RepoLogger(__file__)
|
||||
|
||||
|
||||
def _SafeCheckoutOrder(checkouts: List[Project]) -> List[List[Project]]:
|
||||
"""Generate a sequence of checkouts that is safe to perform. The client
|
||||
should checkout everything from n-th index before moving to n+1.
|
||||
"""Generate a sequence of checkouts that is safe to perform.
|
||||
|
||||
The client should checkout everything from n-th index before moving to
|
||||
n+1.
|
||||
|
||||
This is only useful if manifest contains nested projects.
|
||||
|
||||
E.g. if foo, foo/bar and foo/bar/baz are project paths, then foo needs to
|
||||
finish before foo/bar can proceed, and foo/bar needs to finish before
|
||||
foo/bar/baz."""
|
||||
res = [[]]
|
||||
current = res[0]
|
||||
foo/bar/baz.
|
||||
|
||||
# depth_stack contains a current stack of parent paths.
|
||||
Discovered submodules have an additional constraint: sibling submodules in
|
||||
the same parent repository must not be checked out in parallel because they
|
||||
all run `git submodule init` against the same parent .git/config.
|
||||
"""
|
||||
res = [[]]
|
||||
|
||||
# depth_stack contains the current stack of parent paths together with the
|
||||
# effective checkout level assigned to each path.
|
||||
depth_stack = []
|
||||
submodule_parent_level = {}
|
||||
# Checkouts are iterated in the hierarchical order. That way, it can easily
|
||||
# be determined if the previous checkout is parent of the current checkout.
|
||||
# We are splitting by the path separator so the final result is
|
||||
@@ -118,21 +129,29 @@ def _SafeCheckoutOrder(checkouts: List[Project]) -> List[List[Project]]:
|
||||
checkout_path = Path(checkout.relpath)
|
||||
while depth_stack:
|
||||
try:
|
||||
checkout_path.relative_to(depth_stack[-1])
|
||||
checkout_path.relative_to(depth_stack[-1][0])
|
||||
except ValueError:
|
||||
# Path.relative_to returns ValueError if paths are not relative.
|
||||
# TODO(sokcevic): Switch to is_relative_to once min supported
|
||||
# version is py3.9.
|
||||
depth_stack.pop()
|
||||
else:
|
||||
if len(depth_stack) >= len(res):
|
||||
# Another depth created.
|
||||
res.append([])
|
||||
break
|
||||
|
||||
current = res[len(depth_stack)]
|
||||
level = depth_stack[-1][1] + 1 if depth_stack else 0
|
||||
parent = checkout.parent
|
||||
if parent is not None:
|
||||
level = max(
|
||||
level,
|
||||
submodule_parent_level.get(parent.worktree, level - 1) + 1,
|
||||
)
|
||||
submodule_parent_level[parent.worktree] = level
|
||||
if level >= len(res):
|
||||
res.extend([] for _ in range(level + 1 - len(res)))
|
||||
|
||||
current = res[level]
|
||||
current.append(checkout)
|
||||
depth_stack.append(checkout_path)
|
||||
depth_stack.append((checkout_path, level))
|
||||
|
||||
return res
|
||||
|
||||
@@ -293,6 +312,7 @@ class TeeStringIO(io.StringIO):
|
||||
|
||||
class Sync(Command, MirrorSafeCommand):
|
||||
COMMON = True
|
||||
RESPECT_SMART_SYNC_OVERRIDE = False
|
||||
MULTI_MANIFEST_SUPPORT = True
|
||||
helpSummary = "Update working tree to the latest revision"
|
||||
helpUsage = """
|
||||
@@ -359,8 +379,12 @@ resumeable bundle file on a content delivery network. This
|
||||
may be necessary if there are problems with the local Python
|
||||
HTTP client or proxy configuration, but the Git binary works.
|
||||
|
||||
The --fetch-submodules option enables fetching Git submodules
|
||||
of a project from server.
|
||||
The --recurse-submodules option enables syncing Git submodules of all projects
|
||||
from the server. The --no-recurse-submodules option disables syncing Git
|
||||
submodules, even when a project has sync-s="true" in the manifest.
|
||||
|
||||
The --fetch-submodules and --no-fetch-submodules options are deprecated aliases
|
||||
for --recurse-submodules and --no-recurse-submodules, respectively.
|
||||
|
||||
The -c/--current-branch option can be used to only fetch objects that
|
||||
are on the branch specified by a project's revision.
|
||||
@@ -408,6 +432,15 @@ later is required to fix a server side protocol bug.
|
||||
|
||||
_JOBS_WARN_THRESHOLD = 100
|
||||
|
||||
@staticmethod
|
||||
def _deprecated_submodules_option(option, opt_str, _value, parser):
|
||||
enabled = opt_str == "--fetch-submodules"
|
||||
replacement = (
|
||||
"--recurse-submodules" if enabled else "--no-recurse-submodules"
|
||||
)
|
||||
logger.warning("%s is deprecated; use %s instead", opt_str, replacement)
|
||||
setattr(parser.values, option.dest, enabled)
|
||||
|
||||
def _Options(self, p, show_smart=True):
|
||||
p.add_option(
|
||||
"--jobs-network",
|
||||
@@ -526,6 +559,13 @@ later is required to fix a server side protocol bug.
|
||||
help="temporary manifest to use for this sync",
|
||||
metavar="NAME.xml",
|
||||
)
|
||||
p.add_option(
|
||||
"-g",
|
||||
"--groups",
|
||||
help="sync projects matching the specific groups. Not persistent "
|
||||
"unlike when used on init",
|
||||
metavar="GROUP",
|
||||
)
|
||||
p.add_option(
|
||||
"--clone-bundle",
|
||||
action="store_true",
|
||||
@@ -550,9 +590,29 @@ later is required to fix a server side protocol bug.
|
||||
help="password to authenticate with the manifest server",
|
||||
)
|
||||
p.add_option(
|
||||
"--fetch-submodules",
|
||||
"--recurse-submodules",
|
||||
action="store_true",
|
||||
help="fetch submodules from server",
|
||||
help="sync submodules from server",
|
||||
)
|
||||
p.add_option(
|
||||
"--no-recurse-submodules",
|
||||
dest="recurse_submodules",
|
||||
action="store_false",
|
||||
help="don't sync submodules from server",
|
||||
)
|
||||
p.add_option(
|
||||
"--fetch-submodules",
|
||||
dest="recurse_submodules",
|
||||
action="callback",
|
||||
callback=self._deprecated_submodules_option,
|
||||
help=optparse.SUPPRESS_HELP,
|
||||
)
|
||||
p.add_option(
|
||||
"--no-fetch-submodules",
|
||||
dest="recurse_submodules",
|
||||
action="callback",
|
||||
callback=self._deprecated_submodules_option,
|
||||
help=optparse.SUPPRESS_HELP,
|
||||
)
|
||||
p.add_option(
|
||||
"--use-superproject",
|
||||
@@ -565,6 +625,11 @@ later is required to fix a server side protocol bug.
|
||||
dest="use_superproject",
|
||||
help="disable use of manifest superprojects",
|
||||
)
|
||||
p.add_option(
|
||||
"--superproject-revision",
|
||||
action="store",
|
||||
help="sync to superproject revision (applies to outer manifest)",
|
||||
)
|
||||
p.add_option("--tags", action="store_true", help="fetch tags")
|
||||
p.add_option(
|
||||
"--no-tags",
|
||||
@@ -668,6 +733,24 @@ later is required to fix a server side protocol bug.
|
||||
or opt.current_branch_only
|
||||
)
|
||||
|
||||
def _ConfigureSuperproject(
|
||||
self,
|
||||
opt: optparse.Values,
|
||||
manifest,
|
||||
revision: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Configure superproject with options."""
|
||||
if not manifest.superproject:
|
||||
return False
|
||||
manifest.superproject.SetQuiet(not opt.verbose)
|
||||
print_messages = git_superproject.PrintMessages(
|
||||
opt.use_superproject, manifest
|
||||
)
|
||||
manifest.superproject.SetPrintMessages(print_messages)
|
||||
if revision:
|
||||
manifest.superproject.SetRevisionId(revision)
|
||||
return print_messages
|
||||
|
||||
def _UpdateProjectsRevisionId(
|
||||
self, opt, args, superproject_logging_data, manifest
|
||||
):
|
||||
@@ -699,8 +782,9 @@ later is required to fix a server side protocol bug.
|
||||
|
||||
all_projects = self.GetProjects(
|
||||
args,
|
||||
groups=opt.groups,
|
||||
missing_ok=True,
|
||||
submodules_ok=opt.fetch_submodules,
|
||||
submodules_ok=opt.recurse_submodules,
|
||||
manifest=manifest,
|
||||
all_manifests=not opt.this_manifest_only,
|
||||
)
|
||||
@@ -741,11 +825,7 @@ later is required to fix a server side protocol bug.
|
||||
|
||||
if not use_super:
|
||||
continue
|
||||
m.superproject.SetQuiet(not opt.verbose)
|
||||
print_messages = git_superproject.PrintMessages(
|
||||
opt.use_superproject, m
|
||||
)
|
||||
m.superproject.SetPrintMessages(print_messages)
|
||||
print_messages = self._ConfigureSuperproject(opt, m)
|
||||
update_result = m.superproject.UpdateProjectsRevisionId(
|
||||
per_manifest[m.path_prefix], git_event_log=self.git_event_log
|
||||
)
|
||||
@@ -808,6 +888,7 @@ later is required to fix a server side protocol bug.
|
||||
quiet=opt.quiet,
|
||||
verbose=opt.verbose,
|
||||
output_redir=buf,
|
||||
use_superproject=opt.use_superproject,
|
||||
current_branch_only=cls._GetCurrentBranchOnly(
|
||||
opt, project.manifest
|
||||
),
|
||||
@@ -840,6 +921,8 @@ later is required to fix a server side protocol bug.
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
logger.error("Keyboard interrupt while processing %s", project.name)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
logger.error("error.GitError: Cannot fetch %s", e)
|
||||
errors.append(e)
|
||||
@@ -946,7 +1029,7 @@ later is required to fix a server side protocol bug.
|
||||
"sync_dict"
|
||||
] = multiprocessing.Manager().dict()
|
||||
|
||||
objdir_project_map = dict()
|
||||
objdir_project_map = {}
|
||||
for index, project in enumerate(projects):
|
||||
objdir_project_map.setdefault(project.objdir, []).append(index)
|
||||
projects_list = list(objdir_project_map.values())
|
||||
@@ -1029,8 +1112,9 @@ later is required to fix a server side protocol bug.
|
||||
self._ReloadManifest(None, manifest)
|
||||
all_projects = self.GetProjects(
|
||||
args,
|
||||
groups=opt.groups,
|
||||
missing_ok=True,
|
||||
submodules_ok=opt.fetch_submodules,
|
||||
submodules_ok=opt.recurse_submodules,
|
||||
manifest=manifest,
|
||||
all_manifests=not opt.this_manifest_only,
|
||||
)
|
||||
@@ -1103,6 +1187,8 @@ later is required to fix a server side protocol bug.
|
||||
errors.extend(syncbuf.errors)
|
||||
except KeyboardInterrupt:
|
||||
logger.error("Keyboard interrupt while processing %s", project.name)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
logger.error(
|
||||
"error.GitError: Cannot checkout %s: %s", project.name, e
|
||||
@@ -1243,14 +1329,15 @@ later is required to fix a server side protocol bug.
|
||||
|
||||
return False
|
||||
|
||||
def _SetPreciousObjectsState(self, project: Project, opt):
|
||||
@classmethod
|
||||
def _SetPreciousObjectsState(cls, project: Project, opt):
|
||||
"""Correct the preciousObjects state for the project.
|
||||
|
||||
Args:
|
||||
project: the project to examine, and possibly correct.
|
||||
opt: options given to sync.
|
||||
"""
|
||||
expected = self._GetPreciousObjectsState(project, opt)
|
||||
expected = cls._GetPreciousObjectsState(project, opt)
|
||||
actual = (
|
||||
project.config.GetBoolean("extensions.preciousObjects") or False
|
||||
)
|
||||
@@ -1284,7 +1371,22 @@ later is required to fix a server side protocol bug.
|
||||
project.config.SetString("extensions.preciousObjects", None)
|
||||
project.config.SetString("gc.pruneExpire", None)
|
||||
|
||||
def _GCProjects(self, projects, opt, err_event):
|
||||
@staticmethod
|
||||
def _RunOneGC(project: Project, config: Optional[dict] = None) -> None:
|
||||
"""Run auto GC on a single project."""
|
||||
local_config = {}
|
||||
if config:
|
||||
local_config.update(config)
|
||||
local_config["gc.autoDetach"] = "false"
|
||||
project.bare_git.gc("--auto", config=local_config)
|
||||
|
||||
@classmethod
|
||||
def _GCProjects(
|
||||
cls,
|
||||
projects: List[Project],
|
||||
opt: optparse.Values,
|
||||
err_event: _threading.Event,
|
||||
) -> None:
|
||||
"""Perform garbage collection.
|
||||
|
||||
If We are skipping garbage collection (opt.auto_gc not set), we still
|
||||
@@ -1294,7 +1396,7 @@ later is required to fix a server side protocol bug.
|
||||
if not opt.auto_gc:
|
||||
# Just repair preciousObjects state, and return.
|
||||
for project in projects:
|
||||
self._SetPreciousObjectsState(project, opt)
|
||||
cls._SetPreciousObjectsState(project, opt)
|
||||
return
|
||||
|
||||
pm = Progress(
|
||||
@@ -1304,9 +1406,8 @@ later is required to fix a server side protocol bug.
|
||||
|
||||
tidy_dirs = {}
|
||||
for project in projects:
|
||||
self._SetPreciousObjectsState(project, opt)
|
||||
cls._SetPreciousObjectsState(project, opt)
|
||||
|
||||
project.config.SetString("gc.autoDetach", "false")
|
||||
# Only call git gc once per objdir, but call pack-refs for the
|
||||
# remainder.
|
||||
if project.objdir not in tidy_dirs:
|
||||
@@ -1327,7 +1428,7 @@ later is required to fix a server side protocol bug.
|
||||
pm.update(msg=bare_git._project.name)
|
||||
|
||||
if run_gc:
|
||||
bare_git.gc("--auto")
|
||||
cls._RunOneGC(bare_git._project)
|
||||
else:
|
||||
bare_git.pack_refs()
|
||||
pm.end()
|
||||
@@ -1344,7 +1445,7 @@ later is required to fix a server side protocol bug.
|
||||
try:
|
||||
try:
|
||||
if run_gc:
|
||||
bare_git.gc("--auto", config=config)
|
||||
cls._RunOneGC(bare_git._project, config=config)
|
||||
else:
|
||||
bare_git.pack_refs(config=config)
|
||||
except GitError:
|
||||
@@ -1443,11 +1544,14 @@ later is required to fix a server side protocol bug.
|
||||
if not git_require((2, 23, 0)):
|
||||
return
|
||||
|
||||
projects = [p for p in projects if p.clone_depth]
|
||||
projects = [
|
||||
p
|
||||
for p in projects
|
||||
if p.clone_depth and not p.stateless_prune_needed
|
||||
]
|
||||
if not projects:
|
||||
return
|
||||
|
||||
bloated_projects = []
|
||||
pm = Progress(
|
||||
"Checking for bloat", len(projects), delay=False, quiet=opt.quiet
|
||||
)
|
||||
@@ -1455,7 +1559,7 @@ later is required to fix a server side protocol bug.
|
||||
def _ProcessResults(pool, pm, results):
|
||||
for result in results:
|
||||
if result:
|
||||
bloated_projects.append(result)
|
||||
self._bloated_projects.append(result)
|
||||
pm.update(msg="")
|
||||
|
||||
with self.ParallelContext():
|
||||
@@ -1470,15 +1574,6 @@ later is required to fix a server side protocol bug.
|
||||
)
|
||||
pm.end()
|
||||
|
||||
for project_name in bloated_projects:
|
||||
warn_msg = (
|
||||
f'warning: Project "{project_name}" is accumulating '
|
||||
'unoptimized data. Please run "repo sync --auto-gc" or '
|
||||
'"repo gc --repack" to clean up.'
|
||||
)
|
||||
self.git_event_log.ErrorEvent(warn_msg)
|
||||
logger.warning(warn_msg)
|
||||
|
||||
def _UpdateRepoProject(self, opt, manifest, errors):
|
||||
"""Fetch the repo project and check for updates."""
|
||||
if opt.local_only:
|
||||
@@ -1499,6 +1594,7 @@ later is required to fix a server side protocol bug.
|
||||
quiet=opt.quiet,
|
||||
verbose=opt.verbose,
|
||||
output_redir=buf,
|
||||
use_superproject=opt.use_superproject,
|
||||
current_branch_only=self._GetCurrentBranchOnly(
|
||||
opt, manifest
|
||||
),
|
||||
@@ -1616,9 +1712,10 @@ later is required to fix a server side protocol bug.
|
||||
new_paths = {}
|
||||
new_linkfile_paths = []
|
||||
new_copyfile_paths = []
|
||||
for project in self.GetProjects(
|
||||
projects = self.GetProjects(
|
||||
None, missing_ok=True, manifest=manifest, all_manifests=False
|
||||
):
|
||||
)
|
||||
for project in projects:
|
||||
new_linkfile_paths.extend(x.dest for x in project.linkfiles)
|
||||
new_copyfile_paths.extend(x.dest for x in project.copyfiles)
|
||||
|
||||
@@ -1654,29 +1751,136 @@ later is required to fix a server side protocol bug.
|
||||
)
|
||||
|
||||
for need_remove_file in need_remove_files:
|
||||
# Try to remove the updated copyfile or linkfile.
|
||||
# So, if the file is not exist, nothing need to do.
|
||||
platform_utils.remove(
|
||||
os.path.join(self.client.topdir, need_remove_file),
|
||||
missing_ok=True,
|
||||
need_remove_path = os.path.join(
|
||||
self.client.topdir, need_remove_file
|
||||
)
|
||||
if os.path.isfile(need_remove_path):
|
||||
platform_utils.remove(need_remove_path)
|
||||
else:
|
||||
platform_utils.removedirs(need_remove_path)
|
||||
|
||||
# Also try to remove empty parent directories.
|
||||
parent = os.path.dirname(need_remove_path)
|
||||
while parent != self.client.topdir:
|
||||
try:
|
||||
os.rmdir(parent)
|
||||
except OSError:
|
||||
break
|
||||
parent = os.path.dirname(parent)
|
||||
|
||||
# Create copy-link-files.json, save dest path of "copyfile" and
|
||||
# "linkfile".
|
||||
with open(copylinkfile_path, "w", encoding="utf-8") as fp:
|
||||
json.dump(new_paths, fp)
|
||||
|
||||
# Retry linkfile/copyfile creation for all projects. In
|
||||
# interleaved sync mode, _CopyAndLinkFiles runs before this
|
||||
# cleanup, so linkfiles whose dest was blocked by an old
|
||||
# directory may have failed. _CopyAndLinkFiles is idempotent
|
||||
# and skips dests that are already correct.
|
||||
for project in projects:
|
||||
project._CopyAndLinkFiles()
|
||||
|
||||
return True
|
||||
|
||||
def _SmartSyncSetup(self, opt, smart_sync_manifest_path, manifest):
|
||||
if not manifest.manifest_server:
|
||||
raise SmartSyncError(
|
||||
"error: cannot smart sync: no manifest server defined in "
|
||||
"manifest"
|
||||
)
|
||||
def _ResolveManifestServerTransport(self, opt, manifest):
|
||||
"""Resolves the manifest server URL and transport.
|
||||
|
||||
Returns:
|
||||
Tuple[str, xmlrpc.client.Transport]: The resolved server URL and
|
||||
transport.
|
||||
|
||||
Raises:
|
||||
SmartSyncError: If resolution fails (e.g. helper missing, helper
|
||||
error, unsupported scheme).
|
||||
"""
|
||||
manifest_server = manifest.manifest_server
|
||||
if not opt.quiet:
|
||||
print("Using manifest server %s" % manifest_server)
|
||||
helper_binary = manifest.manifest_server_helper
|
||||
|
||||
if helper_binary:
|
||||
if not shutil.which(helper_binary):
|
||||
raise SmartSyncError(
|
||||
f"error: helper binary '{helper_binary}' declared in "
|
||||
"manifest was not found in your PATH."
|
||||
)
|
||||
|
||||
if not opt.quiet:
|
||||
print(f"Using remote helper {helper_binary}")
|
||||
|
||||
p = None
|
||||
try:
|
||||
p = subprocess.Popen(
|
||||
[helper_binary, manifest_server],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
stdout, stderr = p.communicate(timeout=10)
|
||||
output = stdout.strip()
|
||||
stderr_content = stderr.strip() if stderr else ""
|
||||
except subprocess.TimeoutExpired as e:
|
||||
err_msg = f"helper {helper_binary} timed out after 10 seconds"
|
||||
timeout_stderr = e.stderr.strip() if e.stderr else ""
|
||||
if timeout_stderr:
|
||||
err_msg += f". Stderr: {timeout_stderr}"
|
||||
raise SmartSyncError(err_msg)
|
||||
except OSError as e:
|
||||
raise SmartSyncError(
|
||||
"failed to start or communicate with helper "
|
||||
f"{helper_binary}: {e}"
|
||||
)
|
||||
finally:
|
||||
if p and p.poll() is None:
|
||||
p.kill()
|
||||
p.wait()
|
||||
|
||||
if p.returncode != 0:
|
||||
err_msg = (
|
||||
f"helper {helper_binary} exited with exit code "
|
||||
f"{p.returncode}."
|
||||
)
|
||||
if stderr_content:
|
||||
err_msg += f" Stderr: {stderr_content}"
|
||||
raise SmartSyncError(err_msg)
|
||||
|
||||
try:
|
||||
res = json.loads(output)
|
||||
status = res.get("status")
|
||||
msg = res.get("message")
|
||||
except json.JSONDecodeError as e:
|
||||
err_msg = (
|
||||
f"failed to parse JSON from helper {helper_binary}: {e}. "
|
||||
f"Output was: {output}"
|
||||
)
|
||||
if stderr_content:
|
||||
err_msg += f"\nStderr was: {stderr_content}"
|
||||
raise SmartSyncError(err_msg)
|
||||
|
||||
if status != "ok":
|
||||
err_msg = f"helper {helper_binary} returned error: {msg}"
|
||||
if stderr_content:
|
||||
err_msg += f"\nStderr was: {stderr_content}"
|
||||
raise SmartSyncError(err_msg)
|
||||
|
||||
proxy_url = msg
|
||||
transport = PersistentTransport(manifest_server, proxy=proxy_url)
|
||||
server_url = manifest_server
|
||||
if server_url.startswith("persistent-"):
|
||||
server_url = server_url[len("persistent-") :]
|
||||
return server_url, transport
|
||||
|
||||
# Fallback path if helper isn't specified
|
||||
scheme = urllib.parse.urlparse(manifest_server).scheme
|
||||
if scheme not in (
|
||||
"http",
|
||||
"https",
|
||||
"persistent-http",
|
||||
"persistent-https",
|
||||
):
|
||||
raise SmartSyncError(
|
||||
f"error: unsupported manifest server scheme '{scheme}'."
|
||||
)
|
||||
|
||||
if "@" not in manifest_server:
|
||||
username = None
|
||||
@@ -1711,20 +1915,34 @@ later is required to fix a server side protocol bug.
|
||||
)
|
||||
|
||||
transport = PersistentTransport(manifest_server)
|
||||
if manifest_server.startswith("persistent-"):
|
||||
manifest_server = manifest_server[len("persistent-") :]
|
||||
server_url = manifest_server
|
||||
if server_url.startswith("persistent-"):
|
||||
server_url = server_url[len("persistent-") :]
|
||||
|
||||
return server_url, transport
|
||||
|
||||
def _SmartSyncSetup(self, opt, smart_sync_manifest_path, manifest):
|
||||
if not manifest.manifest_server:
|
||||
raise SmartSyncError(
|
||||
"error: cannot smart sync: no manifest server defined in "
|
||||
"manifest"
|
||||
)
|
||||
|
||||
if not opt.quiet:
|
||||
print("Using manifest server %s" % manifest.manifest_server)
|
||||
|
||||
server_url, transport = self._ResolveManifestServerTransport(
|
||||
opt, manifest
|
||||
)
|
||||
|
||||
# Changes in behavior should update docs/smart-sync.md accordingly.
|
||||
try:
|
||||
server = xmlrpc.client.Server(manifest_server, transport=transport)
|
||||
server = xmlrpc.client.Server(server_url, transport=transport)
|
||||
if opt.smart_sync:
|
||||
branch = self._GetBranch(manifest.manifestProject)
|
||||
|
||||
target = None
|
||||
if "SYNC_TARGET" in os.environ:
|
||||
target = os.environ["SYNC_TARGET"]
|
||||
[success, manifest_str] = server.GetApprovedManifest(
|
||||
branch, target
|
||||
)
|
||||
elif (
|
||||
"TARGET_PRODUCT" in os.environ
|
||||
and "TARGET_BUILD_VARIANT" in os.environ
|
||||
@@ -1735,9 +1953,6 @@ later is required to fix a server side protocol bug.
|
||||
os.environ["TARGET_RELEASE"],
|
||||
os.environ["TARGET_BUILD_VARIANT"],
|
||||
)
|
||||
[success, manifest_str] = server.GetApprovedManifest(
|
||||
branch, target
|
||||
)
|
||||
elif (
|
||||
"TARGET_PRODUCT" in os.environ
|
||||
and "TARGET_BUILD_VARIANT" in os.environ
|
||||
@@ -1746,6 +1961,8 @@ later is required to fix a server side protocol bug.
|
||||
os.environ["TARGET_PRODUCT"],
|
||||
os.environ["TARGET_BUILD_VARIANT"],
|
||||
)
|
||||
|
||||
if target:
|
||||
[success, manifest_str] = server.GetApprovedManifest(
|
||||
branch, target
|
||||
)
|
||||
@@ -1767,25 +1984,34 @@ later is required to fix a server side protocol bug.
|
||||
aggregate_errors=[e],
|
||||
)
|
||||
self._ReloadManifest(manifest_name, manifest)
|
||||
else:
|
||||
raise SmartSyncError(
|
||||
"error: manifest server RPC call failed: %s" % manifest_str
|
||||
)
|
||||
return manifest_name
|
||||
|
||||
raise SmartSyncError(
|
||||
"error: manifest server RPC call failed: %s" % manifest_str
|
||||
)
|
||||
except (OSError, xmlrpc.client.Fault) as e:
|
||||
if manifest.manifest_server_helper:
|
||||
raise SmartSyncError(
|
||||
"error: failed to communicate with manifest server via "
|
||||
f"helper: {e}"
|
||||
)
|
||||
raise SmartSyncError(
|
||||
"error: cannot connect to manifest server %s:\n%s"
|
||||
% (manifest.manifest_server, e),
|
||||
aggregate_errors=[e],
|
||||
)
|
||||
except xmlrpc.client.ProtocolError as e:
|
||||
if manifest.manifest_server_helper:
|
||||
raise SmartSyncError(
|
||||
"error: failed to communicate with manifest server via "
|
||||
f"helper: {e}"
|
||||
)
|
||||
raise SmartSyncError(
|
||||
"error: cannot connect to manifest server %s:\n%d %s"
|
||||
% (manifest.manifest_server, e.errcode, e.errmsg),
|
||||
aggregate_errors=[e],
|
||||
)
|
||||
|
||||
return manifest_name
|
||||
|
||||
def _UpdateAllManifestProjects(self, opt, mp, manifest_name, errors):
|
||||
"""Fetch & update the local manifest project.
|
||||
|
||||
@@ -1797,7 +2023,11 @@ later is required to fix a server side protocol bug.
|
||||
mp: the manifestProject to query.
|
||||
manifest_name: Manifest file to be reloaded.
|
||||
"""
|
||||
if not mp.standalone_manifest_url:
|
||||
if opt.superproject_revision and mp.manifest == self.outer_manifest:
|
||||
self._SyncToSuperprojectRev(
|
||||
opt, mp.manifest, mp, manifest_name, errors
|
||||
)
|
||||
elif not mp.standalone_manifest_url:
|
||||
self._UpdateManifestProject(opt, mp, manifest_name, errors)
|
||||
|
||||
if mp.manifest.submanifests:
|
||||
@@ -1830,6 +2060,7 @@ later is required to fix a server side protocol bug.
|
||||
quiet=not opt.verbose,
|
||||
output_redir=buf,
|
||||
verbose=opt.verbose,
|
||||
use_superproject=opt.use_superproject,
|
||||
current_branch_only=self._GetCurrentBranchOnly(
|
||||
opt, mp.manifest
|
||||
),
|
||||
@@ -1940,15 +2171,33 @@ later is required to fix a server side protocol bug.
|
||||
opt.jobs_network = min(opt.jobs_network, jobs_soft_limit)
|
||||
opt.jobs_checkout = min(opt.jobs_checkout, jobs_soft_limit)
|
||||
|
||||
# Warn once if effective job counts seem excessively high.
|
||||
sync_j_max = mp.manifest.default.sync_j_max or None
|
||||
|
||||
# Check for shared options.
|
||||
# Prioritize --jobs, then --jobs-network, then --jobs-checkout.
|
||||
job_options_to_check = (
|
||||
("--jobs", opt.jobs),
|
||||
("--jobs-network", opt.jobs_network),
|
||||
("--jobs-checkout", opt.jobs_checkout),
|
||||
job_attributes = (
|
||||
("--jobs", "jobs"),
|
||||
("--jobs-network", "jobs_network"),
|
||||
("--jobs-checkout", "jobs_checkout"),
|
||||
)
|
||||
for name, value in job_options_to_check:
|
||||
if value > self._JOBS_WARN_THRESHOLD:
|
||||
|
||||
warned = False
|
||||
limit_warned = False
|
||||
for name, attr in job_attributes:
|
||||
value = getattr(opt, attr)
|
||||
|
||||
if sync_j_max and value > sync_j_max:
|
||||
if not limit_warned:
|
||||
logger.warning(
|
||||
"warning: manifest limits %s to %d",
|
||||
name,
|
||||
sync_j_max,
|
||||
)
|
||||
limit_warned = True
|
||||
setattr(opt, attr, sync_j_max)
|
||||
value = sync_j_max
|
||||
|
||||
if not warned and value > self._JOBS_WARN_THRESHOLD:
|
||||
logger.warning(
|
||||
"High job count (%d > %d) specified for %s; this may "
|
||||
"lead to excessive resource usage or diminishing returns.",
|
||||
@@ -1956,21 +2205,23 @@ later is required to fix a server side protocol bug.
|
||||
self._JOBS_WARN_THRESHOLD,
|
||||
name,
|
||||
)
|
||||
break
|
||||
warned = True
|
||||
|
||||
def Execute(self, opt, args):
|
||||
errors = []
|
||||
start_time = time.time()
|
||||
try:
|
||||
self._ExecuteHelper(opt, args, errors)
|
||||
sync_duration_seconds = time.time() - start_time
|
||||
except (RepoExitError, RepoChangedException):
|
||||
raise
|
||||
except (KeyboardInterrupt, Exception) as e:
|
||||
raise RepoUnhandledExceptionError(e, aggregate_errors=errors)
|
||||
|
||||
# Run post-sync hook only after successful sync
|
||||
self._RunPostSyncHook(opt)
|
||||
self._RunPostSyncHook(opt, sync_duration_seconds=sync_duration_seconds)
|
||||
|
||||
def _RunPostSyncHook(self, opt):
|
||||
def _RunPostSyncHook(self, opt, sync_duration_seconds=None):
|
||||
"""Run post-sync hook if configured in manifest <repo-hooks>."""
|
||||
hook = RepoHook.FromSubcmd(
|
||||
hook_type="post-sync",
|
||||
@@ -1978,10 +2229,60 @@ later is required to fix a server side protocol bug.
|
||||
opt=opt,
|
||||
abort_if_user_denies=False,
|
||||
)
|
||||
success = hook.Run(repo_topdir=self.client.topdir)
|
||||
success = hook.Run(
|
||||
repo_topdir=self.client.topdir,
|
||||
sync_duration_seconds=sync_duration_seconds,
|
||||
)
|
||||
if not success:
|
||||
print("Warning: post-sync hook reported failure.")
|
||||
|
||||
def _SyncToSuperprojectRev(
|
||||
self,
|
||||
opt: optparse.Values,
|
||||
manifest,
|
||||
mp: Project,
|
||||
manifest_name: Optional[str],
|
||||
errors: List[Exception],
|
||||
) -> None:
|
||||
"""Sync to a specific superproject commit."""
|
||||
if not manifest.superproject:
|
||||
raise SyncError("superproject not defined in manifest")
|
||||
|
||||
self._ConfigureSuperproject(
|
||||
opt, manifest, revision=opt.superproject_revision
|
||||
)
|
||||
|
||||
sync_result = manifest.superproject.Sync(self.git_event_log)
|
||||
if not sync_result.success:
|
||||
raise SyncError("failed to sync superproject")
|
||||
|
||||
cmd = ["show", f"{opt.superproject_revision}:.supermanifest"]
|
||||
p = GitCommand(
|
||||
None,
|
||||
cmd,
|
||||
gitdir=manifest.superproject._work_git,
|
||||
bare=True,
|
||||
capture_stdout=True,
|
||||
capture_stderr=True,
|
||||
)
|
||||
if p.Wait() != 0:
|
||||
raise SyncError(
|
||||
f"failed to read .supermanifest from superproject: {p.stderr}"
|
||||
)
|
||||
|
||||
try:
|
||||
_, _, manifest_commit = p.stdout.strip().split()
|
||||
except ValueError:
|
||||
raise SyncError("could not parse .supermanifest")
|
||||
|
||||
mp.SetRevision(manifest_commit)
|
||||
try:
|
||||
self._UpdateManifestProject(opt, mp, manifest_name, errors)
|
||||
except UpdateManifestError as e:
|
||||
raise SyncError(
|
||||
"failed to sync manifest project", aggregate_errors=[e]
|
||||
)
|
||||
|
||||
def _ExecuteHelper(self, opt, args, errors):
|
||||
manifest = self.outer_manifest
|
||||
if not opt.outer_manifest:
|
||||
@@ -1991,9 +2292,7 @@ later is required to fix a server side protocol bug.
|
||||
manifest.Override(opt.manifest_name)
|
||||
|
||||
manifest_name = opt.manifest_name
|
||||
smart_sync_manifest_path = os.path.join(
|
||||
manifest.manifestProject.worktree, "smart_sync_override.xml"
|
||||
)
|
||||
smart_sync_manifest_path = self.GetSmartSyncOverridePath(manifest)
|
||||
|
||||
if opt.clone_bundle is None:
|
||||
opt.clone_bundle = manifest.CloneBundle
|
||||
@@ -2042,7 +2341,7 @@ later is required to fix a server side protocol bug.
|
||||
):
|
||||
mp.ConfigureCloneFilterForDepth("blob:none")
|
||||
|
||||
if opt.mp_update:
|
||||
if opt.mp_update or opt.superproject_revision:
|
||||
self._UpdateAllManifestProjects(opt, mp, manifest_name, errors)
|
||||
else:
|
||||
print("Skipping update of local manifest project.")
|
||||
@@ -2061,8 +2360,9 @@ later is required to fix a server side protocol bug.
|
||||
|
||||
all_projects = self.GetProjects(
|
||||
args,
|
||||
groups=opt.groups,
|
||||
missing_ok=True,
|
||||
submodules_ok=opt.fetch_submodules,
|
||||
submodules_ok=opt.recurse_submodules,
|
||||
manifest=manifest,
|
||||
all_manifests=not opt.this_manifest_only,
|
||||
)
|
||||
@@ -2076,6 +2376,7 @@ later is required to fix a server side protocol bug.
|
||||
|
||||
self._fetch_times = _FetchTimes(manifest)
|
||||
self._local_sync_state = LocalSyncState(manifest)
|
||||
self._bloated_projects = []
|
||||
|
||||
if opt.interleaved:
|
||||
sync_method = self._SyncInterleaved
|
||||
@@ -2092,6 +2393,9 @@ later is required to fix a server side protocol bug.
|
||||
superproject_logging_data,
|
||||
)
|
||||
|
||||
if not opt.quiet:
|
||||
print("Finalizing sync state...")
|
||||
|
||||
# Log the previous sync analysis state from the config.
|
||||
self.git_event_log.LogDataConfigEvents(
|
||||
mp.config.GetSyncAnalysisStateData(), "previous_sync_state"
|
||||
@@ -2113,6 +2417,15 @@ later is required to fix a server side protocol bug.
|
||||
if existing:
|
||||
self._CheckForBloatedProjects(all_projects, opt)
|
||||
|
||||
for project_name in sorted(self._bloated_projects):
|
||||
warn_msg = (
|
||||
f'warning: Project "{project_name}" is accumulating '
|
||||
'unoptimized data. Please run "git repack -a -d" in the '
|
||||
'project directory or "repo gc --repack" to clean up.'
|
||||
)
|
||||
self.git_event_log.ErrorEvent(warn_msg)
|
||||
logger.warning(warn_msg)
|
||||
|
||||
if not opt.quiet:
|
||||
print("repo sync has finished successfully.")
|
||||
|
||||
@@ -2338,6 +2651,7 @@ later is required to fix a server side protocol bug.
|
||||
quiet=opt.quiet,
|
||||
verbose=opt.verbose,
|
||||
output_redir=network_output_capture,
|
||||
use_superproject=opt.use_superproject,
|
||||
current_branch_only=cls._GetCurrentBranchOnly(
|
||||
opt, project.manifest
|
||||
),
|
||||
@@ -2361,6 +2675,8 @@ later is required to fix a server side protocol bug.
|
||||
logger.error(
|
||||
"Keyboard interrupt while processing %s", project.name
|
||||
)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
fetch_errors.append(e)
|
||||
logger.error("error.GitError: Cannot fetch %s", e)
|
||||
@@ -2411,6 +2727,8 @@ later is required to fix a server side protocol bug.
|
||||
logger.error(
|
||||
"Keyboard interrupt while processing %s", project.name
|
||||
)
|
||||
if not cls.is_multiprocessing_active():
|
||||
raise
|
||||
except GitError as e:
|
||||
checkout_errors.append(e)
|
||||
logger.error(
|
||||
@@ -2635,7 +2953,7 @@ later is required to fix a server side protocol bug.
|
||||
if previously_pending_relpaths == pending_relpaths:
|
||||
stalled_projects_str = "\n".join(
|
||||
f" - {path}"
|
||||
for path in sorted(list(pending_relpaths))
|
||||
for path in sorted(pending_relpaths)
|
||||
)
|
||||
logger.error(
|
||||
"The following projects failed and could "
|
||||
@@ -2706,8 +3024,9 @@ later is required to fix a server side protocol bug.
|
||||
self._ReloadManifest(None, manifest)
|
||||
project_list = self.GetProjects(
|
||||
args,
|
||||
groups=opt.groups,
|
||||
missing_ok=True,
|
||||
submodules_ok=opt.fetch_submodules,
|
||||
submodules_ok=opt.recurse_submodules,
|
||||
manifest=manifest,
|
||||
all_manifests=not opt.this_manifest_only,
|
||||
)
|
||||
@@ -2932,14 +3251,15 @@ class LocalSyncState:
|
||||
# request to request like the normal transport, the real url
|
||||
# is passed during initialization.
|
||||
class PersistentTransport(xmlrpc.client.Transport):
|
||||
def __init__(self, orig_host):
|
||||
def __init__(self, orig_host, proxy=None):
|
||||
super().__init__()
|
||||
self.orig_host = orig_host
|
||||
self.proxy = proxy
|
||||
|
||||
def request(self, host, handler, request_body, verbose=False):
|
||||
with GetUrlCookieFile(self.orig_host, not verbose) as (
|
||||
cookiefile,
|
||||
proxy,
|
||||
cookie_proxy,
|
||||
):
|
||||
# Python doesn't understand cookies with the #HttpOnly_ prefix
|
||||
# Since we're only using them for HTTP, copy the file temporarily,
|
||||
@@ -2965,10 +3285,11 @@ class PersistentTransport(xmlrpc.client.Transport):
|
||||
else:
|
||||
cookiejar = cookielib.CookieJar()
|
||||
|
||||
active_proxy = self.proxy or cookie_proxy
|
||||
proxyhandler = urllib.request.ProxyHandler
|
||||
if proxy:
|
||||
if active_proxy:
|
||||
proxyhandler = urllib.request.ProxyHandler(
|
||||
{"http": proxy, "https": proxy}
|
||||
{"http": active_proxy, "https": active_proxy}
|
||||
)
|
||||
|
||||
opener = urllib.request.build_opener(
|
||||
@@ -2981,13 +3302,16 @@ class PersistentTransport(xmlrpc.client.Transport):
|
||||
scheme = parse_results.scheme
|
||||
if scheme == "persistent-http":
|
||||
scheme = "http"
|
||||
if scheme == "persistent-https":
|
||||
elif scheme == "persistent-https":
|
||||
# If we're proxying through persistent-https, use http. The
|
||||
# proxy itself will do the https.
|
||||
if proxy:
|
||||
if active_proxy:
|
||||
scheme = "http"
|
||||
else:
|
||||
scheme = "https"
|
||||
elif scheme not in ("http", "https"):
|
||||
if active_proxy:
|
||||
scheme = "http"
|
||||
|
||||
# Parse out any authentication information using the base class.
|
||||
host, extra_headers, _ = self.get_host_info(parse_results.netloc)
|
||||
|
||||
+6
-4
@@ -27,6 +27,7 @@ from error import SilentRepoExitError
|
||||
from error import UploadError
|
||||
from git_command import GitCommand
|
||||
from git_refs import R_HEADS
|
||||
import git_superproject
|
||||
from hooks import RepoHook
|
||||
from project import ReviewableBranch
|
||||
from repo_logging import RepoLogger
|
||||
@@ -627,7 +628,7 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
# If using superproject, add the root repo as a push option.
|
||||
manifest = branch.project.manifest
|
||||
push_options = list(opt.push_options)
|
||||
if manifest.manifestProject.use_superproject:
|
||||
if git_superproject.UseSuperproject(None, manifest):
|
||||
sp = manifest.superproject
|
||||
if sp:
|
||||
r_id = sp.repo_id
|
||||
@@ -802,9 +803,10 @@ Gerrit Code Review: https://www.gerritcodereview.com/
|
||||
project_list=pending_proj_names, worktree_list=pending_worktrees
|
||||
):
|
||||
if LocalSyncState(manifest).IsPartiallySynced():
|
||||
logger.error(
|
||||
"Partially synced tree detected. Syncing all projects "
|
||||
"may resolve issues you're seeing."
|
||||
logger.info(
|
||||
"Tip: A partially synced tree was detected. "
|
||||
"If this failure involves cross-project dependencies, "
|
||||
"a full `repo sync` might help."
|
||||
)
|
||||
ret = 1
|
||||
if ret:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Repo Tests
|
||||
|
||||
There is a mixture of [pytest] & [Python unittest] in here. We adopted [pytest]
|
||||
later on but didn't migrate existing tests (since they still work). New tests
|
||||
should be written using [pytest] only.
|
||||
|
||||
## File layout
|
||||
|
||||
* `test_xxx.py`: Unittests for the `xxx` module in the main repo codebase.
|
||||
Modules that are in subdirs normalize the `/` into `_`.
|
||||
For example, [test_error.py](./test_error.py) is for the
|
||||
[error.py](../error.py) module, and
|
||||
[test_subcmds_forall.py](./test_subcmds_forall.py) is for the
|
||||
[subcmds/forall.py](../subcmds/forall.py) module.
|
||||
* [conftest.py](./conftest.py): Custom pytest fixtures for sharing.
|
||||
* [utils_for_test.py](./utils_for_test.py): Helpers for sharing in tests.
|
||||
|
||||
|
||||
[pytest]: https://pytest.org/
|
||||
[Python unittest]: https://docs.python.org/3/library/unittest.html#unittest.TestCase
|
||||
@@ -18,6 +18,7 @@ import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
import color
|
||||
import platform_utils
|
||||
import repo_trace
|
||||
|
||||
@@ -28,6 +29,16 @@ def disable_repo_trace(tmp_path):
|
||||
repo_trace._TRACE_FILE = str(tmp_path / "TRACE_FILE_from_test")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_default_coloring() -> None:
|
||||
"""Force disable color for reproducible test behavior.
|
||||
|
||||
The few tests that cover color behavior can still reset/change the color as
|
||||
needed.
|
||||
"""
|
||||
color.SetDefaultColoring("never")
|
||||
|
||||
|
||||
# adapted from pytest-home 0.5.1
|
||||
def _set_home(monkeypatch, path: pathlib.Path):
|
||||
"""
|
||||
|
||||
+123
-43
@@ -14,61 +14,141 @@
|
||||
|
||||
"""Unittests for the color.py module."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
import color
|
||||
import git_config
|
||||
|
||||
|
||||
def fixture(*paths):
|
||||
"""Return a path relative to test/fixtures."""
|
||||
return os.path.join(os.path.dirname(__file__), "fixtures", *paths)
|
||||
@pytest.fixture
|
||||
def coloring() -> color.Coloring:
|
||||
"""Create a Coloring object for testing."""
|
||||
return _make_coloring("always")
|
||||
|
||||
|
||||
class ColoringTests(unittest.TestCase):
|
||||
"""tests of the Coloring class."""
|
||||
def _make_coloring(default_state: str) -> color.Coloring:
|
||||
"""Set the default color mode and return a Coloring using test config."""
|
||||
config_fixture = utils_for_test.FIXTURES_DIR / "test.gitconfig"
|
||||
config = git_config.GitConfig(config_fixture)
|
||||
color.SetDefaultColoring(default_state)
|
||||
return color.Coloring(config, "status")
|
||||
|
||||
def setUp(self):
|
||||
"""Create a GitConfig object using the test.gitconfig fixture."""
|
||||
config_fixture = fixture("test.gitconfig")
|
||||
self.config = git_config.GitConfig(config_fixture)
|
||||
color.SetDefaultColoring("true")
|
||||
self.color = color.Coloring(self.config, "status")
|
||||
|
||||
def test_Color_Parse_all_params_none(self):
|
||||
"""all params are None"""
|
||||
val = self.color._parse(None, None, None, None)
|
||||
self.assertEqual("", val)
|
||||
def test_Color_Parse_all_params_none(coloring: color.Coloring) -> None:
|
||||
"""all params are None"""
|
||||
val = coloring._parse(None, None, None, None)
|
||||
assert val == ""
|
||||
|
||||
def test_Color_Parse_first_parameter_none(self):
|
||||
"""check fg & bg & attr"""
|
||||
val = self.color._parse(None, "black", "red", "ul")
|
||||
self.assertEqual("\x1b[4;30;41m", val)
|
||||
|
||||
def test_Color_Parse_one_entry(self):
|
||||
"""check fg"""
|
||||
val = self.color._parse("one", None, None, None)
|
||||
self.assertEqual("\033[33m", val)
|
||||
def test_Color_Parse_first_parameter_none(coloring: color.Coloring) -> None:
|
||||
"""check fg & bg & attr"""
|
||||
val = coloring._parse(None, "black", "red", "ul")
|
||||
assert val == "\x1b[4;30;41m"
|
||||
|
||||
def test_Color_Parse_two_entry(self):
|
||||
"""check fg & bg"""
|
||||
val = self.color._parse("two", None, None, None)
|
||||
self.assertEqual("\033[35;46m", val)
|
||||
|
||||
def test_Color_Parse_three_entry(self):
|
||||
"""check fg & bg & attr"""
|
||||
val = self.color._parse("three", None, None, None)
|
||||
self.assertEqual("\033[4;30;41m", val)
|
||||
def test_Color_Parse_one_entry(coloring: color.Coloring) -> None:
|
||||
"""check fg"""
|
||||
val = coloring._parse("one", None, None, None)
|
||||
assert val == "\033[33m"
|
||||
|
||||
def test_Color_Parse_reset_entry(self):
|
||||
"""check reset entry"""
|
||||
val = self.color._parse("reset", None, None, None)
|
||||
self.assertEqual("\033[m", val)
|
||||
|
||||
def test_Color_Parse_empty_entry(self):
|
||||
"""check empty entry"""
|
||||
val = self.color._parse("none", "blue", "white", "dim")
|
||||
self.assertEqual("\033[2;34;47m", val)
|
||||
val = self.color._parse("empty", "green", "white", "bold")
|
||||
self.assertEqual("\033[1;32;47m", val)
|
||||
def test_Color_Parse_two_entry(coloring: color.Coloring) -> None:
|
||||
"""check fg & bg"""
|
||||
val = coloring._parse("two", None, None, None)
|
||||
assert val == "\033[35;46m"
|
||||
|
||||
|
||||
def test_Color_Parse_three_entry(coloring: color.Coloring) -> None:
|
||||
"""check fg & bg & attr"""
|
||||
val = coloring._parse("three", None, None, None)
|
||||
assert val == "\033[4;30;41m"
|
||||
|
||||
|
||||
def test_Color_Parse_reset_entry(coloring: color.Coloring) -> None:
|
||||
"""check reset entry"""
|
||||
val = coloring._parse("reset", None, None, None)
|
||||
assert val == "\033[m"
|
||||
|
||||
|
||||
def test_Color_Parse_empty_entry(coloring: color.Coloring) -> None:
|
||||
"""check empty entry"""
|
||||
val = coloring._parse("none", "blue", "white", "dim")
|
||||
assert val == "\033[2;34;47m"
|
||||
val = coloring._parse("empty", "green", "white", "bold")
|
||||
assert val == "\033[1;32;47m"
|
||||
|
||||
|
||||
class TestSetDefaultColoring:
|
||||
"""Tests for SetDefaultColoring."""
|
||||
|
||||
def test_none_leaves_default_unchanged(self) -> None:
|
||||
color.DEFAULT = "auto"
|
||||
color.SetDefaultColoring(None)
|
||||
assert color.DEFAULT == "auto"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
(
|
||||
# auto/true/yes all store their lowercase form.
|
||||
("auto", "auto"),
|
||||
("Auto", "auto"),
|
||||
("true", "true"),
|
||||
("True", "true"),
|
||||
("yes", "yes"),
|
||||
("Yes", "yes"),
|
||||
# "always" stores as "always".
|
||||
("always", "always"),
|
||||
("Always", "always"),
|
||||
# never/no/false store their lowercase form.
|
||||
("never", "never"),
|
||||
("no", "no"),
|
||||
("false", "false"),
|
||||
),
|
||||
)
|
||||
def test_maps_to_expected(self, value: str, expected: str) -> None:
|
||||
color.SetDefaultColoring(value)
|
||||
assert color.DEFAULT == expected
|
||||
|
||||
def test_unrecognised_leaves_default_unchanged(self) -> None:
|
||||
color.DEFAULT = "auto"
|
||||
color.SetDefaultColoring("garbage")
|
||||
assert color.DEFAULT == "auto"
|
||||
|
||||
|
||||
class TestColoringInit:
|
||||
"""Tests for Coloring.__init__ color mode logic."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"state, isatty, pager_active, expected",
|
||||
(
|
||||
# "always" enables color unconditionally.
|
||||
("always", False, False, True),
|
||||
# "never" disables color unconditionally.
|
||||
("never", True, True, False),
|
||||
# auto/true/yes enable color only on a TTY or active pager.
|
||||
("auto", True, False, True),
|
||||
("auto", False, False, False),
|
||||
("auto", False, True, True),
|
||||
("true", True, False, True),
|
||||
("true", False, False, False),
|
||||
("true", False, True, True),
|
||||
("yes", True, False, True),
|
||||
("yes", False, False, False),
|
||||
("yes", False, True, True),
|
||||
),
|
||||
)
|
||||
def test_color_mode(
|
||||
self,
|
||||
state: str,
|
||||
isatty: bool,
|
||||
pager_active: bool,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
with mock.patch("os.isatty", return_value=isatty), mock.patch(
|
||||
"pager.active", pager_active
|
||||
):
|
||||
c = _make_coloring(state)
|
||||
assert c.is_on is expected
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unittests for the command.py module."""
|
||||
|
||||
import pytest
|
||||
|
||||
from command import Command
|
||||
|
||||
|
||||
class FakeProject:
|
||||
"""Minimal project double for Command.GetProjects tests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
relpath,
|
||||
*,
|
||||
gitdir=None,
|
||||
derived_subprojects=None,
|
||||
sync_s=False,
|
||||
):
|
||||
self.name = name
|
||||
self.relpath = relpath
|
||||
self.gitdir = gitdir or f"/git/{relpath}"
|
||||
self.sync_s = sync_s
|
||||
self.Exists = True
|
||||
self._derived_subprojects = derived_subprojects or []
|
||||
|
||||
def GetDerivedSubprojects(self):
|
||||
return list(self._derived_subprojects)
|
||||
|
||||
def MatchesGroups(self, _groups):
|
||||
return True
|
||||
|
||||
def RelPath(self, local=True):
|
||||
return self.relpath
|
||||
|
||||
|
||||
class FakeManifest:
|
||||
"""Minimal manifest double for Command.GetProjects tests."""
|
||||
|
||||
def __init__(self, projects):
|
||||
self.projects = projects
|
||||
|
||||
def GetManifestGroupsStr(self):
|
||||
return "default"
|
||||
|
||||
|
||||
def test_get_projects_keeps_derived_subprojects_for_repeated_repo():
|
||||
"""Derived subprojects are keyed by checkout path, not repo identity."""
|
||||
submodule_a = FakeProject(
|
||||
"submodule",
|
||||
"src/one/submodule",
|
||||
gitdir="/shared/modules/submodule.git",
|
||||
)
|
||||
submodule_b = FakeProject(
|
||||
"submodule",
|
||||
"src/two/submodule",
|
||||
gitdir="/shared/modules/submodule.git",
|
||||
)
|
||||
project_a = FakeProject(
|
||||
"project",
|
||||
"src/one",
|
||||
derived_subprojects=[submodule_a],
|
||||
sync_s=True,
|
||||
)
|
||||
project_b = FakeProject(
|
||||
"project",
|
||||
"src/two",
|
||||
derived_subprojects=[submodule_b],
|
||||
sync_s=True,
|
||||
)
|
||||
manifest = FakeManifest([project_a, project_b])
|
||||
cmd = Command(manifest=manifest)
|
||||
|
||||
projects = cmd.GetProjects([])
|
||||
|
||||
assert set(projects) == {project_a, project_b, submodule_a, submodule_b}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"submodules_ok, sync_s, includes_submodule",
|
||||
[
|
||||
(None, False, False),
|
||||
(None, True, True),
|
||||
(True, False, True),
|
||||
(True, True, True),
|
||||
(False, False, False),
|
||||
(False, True, False),
|
||||
],
|
||||
)
|
||||
def test_get_projects_submodule_override(
|
||||
submodules_ok, sync_s, includes_submodule
|
||||
):
|
||||
"""The CLI override takes precedence over a project's sync-s setting."""
|
||||
submodule = FakeProject("submodule", "project/submodule")
|
||||
project = FakeProject(
|
||||
"project",
|
||||
"project",
|
||||
derived_subprojects=[submodule],
|
||||
sync_s=sync_s,
|
||||
)
|
||||
cmd = Command(manifest=FakeManifest([project]))
|
||||
|
||||
projects = cmd.GetProjects([], submodules_ok=submodules_ok)
|
||||
|
||||
assert (submodule in projects) is includes_submodule
|
||||
+18
-29
@@ -14,43 +14,32 @@
|
||||
|
||||
"""Unittests for the editor.py module."""
|
||||
|
||||
import unittest
|
||||
import pytest
|
||||
|
||||
from editor import Editor
|
||||
|
||||
|
||||
class EditorTestCase(unittest.TestCase):
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_editor() -> None:
|
||||
"""Take care of resetting Editor state across tests."""
|
||||
|
||||
def setUp(self):
|
||||
self.setEditor(None)
|
||||
|
||||
def tearDown(self):
|
||||
self.setEditor(None)
|
||||
|
||||
@staticmethod
|
||||
def setEditor(editor):
|
||||
Editor._editor = editor
|
||||
Editor._editor = None
|
||||
yield
|
||||
Editor._editor = None
|
||||
|
||||
|
||||
class GetEditor(EditorTestCase):
|
||||
"""Check GetEditor behavior."""
|
||||
|
||||
def test_basic(self):
|
||||
"""Basic checking of _GetEditor."""
|
||||
self.setEditor(":")
|
||||
self.assertEqual(":", Editor._GetEditor())
|
||||
def test_basic() -> None:
|
||||
"""Basic checking of _GetEditor."""
|
||||
Editor._editor = ":"
|
||||
assert Editor._GetEditor() == ":"
|
||||
|
||||
|
||||
class EditString(EditorTestCase):
|
||||
"""Check EditString behavior."""
|
||||
def test_no_editor() -> None:
|
||||
"""Check behavior when no editor is available."""
|
||||
Editor._editor = ":"
|
||||
assert Editor.EditString("foo") == "foo"
|
||||
|
||||
def test_no_editor(self):
|
||||
"""Check behavior when no editor is available."""
|
||||
self.setEditor(":")
|
||||
self.assertEqual("foo", Editor.EditString("foo"))
|
||||
|
||||
def test_cat_editor(self):
|
||||
"""Check behavior when editor is `cat`."""
|
||||
self.setEditor("cat")
|
||||
self.assertEqual("foo", Editor.EditString("foo"))
|
||||
def test_cat_editor() -> None:
|
||||
"""Check behavior when editor is `cat`."""
|
||||
Editor._editor = "cat"
|
||||
assert Editor.EditString("foo") == "foo"
|
||||
|
||||
+33
-32
@@ -16,7 +16,9 @@
|
||||
|
||||
import inspect
|
||||
import pickle
|
||||
import unittest
|
||||
from typing import Iterator, Type
|
||||
|
||||
import pytest
|
||||
|
||||
import command
|
||||
import error
|
||||
@@ -26,7 +28,7 @@ import project
|
||||
from subcmds import all_modules
|
||||
|
||||
|
||||
imports = all_modules + [
|
||||
_IMPORTS = all_modules + [
|
||||
error,
|
||||
project,
|
||||
git_command,
|
||||
@@ -35,36 +37,35 @@ imports = all_modules + [
|
||||
]
|
||||
|
||||
|
||||
class PickleTests(unittest.TestCase):
|
||||
"""Make sure all our custom exceptions can be pickled."""
|
||||
def get_exceptions() -> Iterator[Type[Exception]]:
|
||||
"""Return all our custom exceptions."""
|
||||
for entry in _IMPORTS:
|
||||
for name in dir(entry):
|
||||
cls = getattr(entry, name)
|
||||
if isinstance(cls, type) and issubclass(cls, Exception):
|
||||
yield cls
|
||||
|
||||
def getExceptions(self):
|
||||
"""Return all our custom exceptions."""
|
||||
for entry in imports:
|
||||
for name in dir(entry):
|
||||
cls = getattr(entry, name)
|
||||
if isinstance(cls, type) and issubclass(cls, Exception):
|
||||
yield cls
|
||||
|
||||
def testExceptionLookup(self):
|
||||
"""Make sure our introspection logic works."""
|
||||
classes = list(self.getExceptions())
|
||||
self.assertIn(error.HookError, classes)
|
||||
# Don't assert the exact number to avoid being a change-detector test.
|
||||
self.assertGreater(len(classes), 10)
|
||||
def test_exception_lookup() -> None:
|
||||
"""Make sure our introspection logic works."""
|
||||
classes = list(get_exceptions())
|
||||
assert error.HookError in classes
|
||||
# Don't assert the exact number to avoid being a change-detector test.
|
||||
assert len(classes) > 10
|
||||
|
||||
def testPickle(self):
|
||||
"""Try to pickle all the exceptions."""
|
||||
for cls in self.getExceptions():
|
||||
args = inspect.getfullargspec(cls.__init__).args[1:]
|
||||
obj = cls(*args)
|
||||
p = pickle.dumps(obj)
|
||||
try:
|
||||
newobj = pickle.loads(p)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
self.fail(
|
||||
"Class %s is unable to be pickled: %s\n"
|
||||
"Incomplete super().__init__(...) call?" % (cls, e)
|
||||
)
|
||||
self.assertIsInstance(newobj, cls)
|
||||
self.assertEqual(str(obj), str(newobj))
|
||||
|
||||
@pytest.mark.parametrize("cls", get_exceptions())
|
||||
def test_pickle(cls: Type[Exception]) -> None:
|
||||
"""Try to pickle all the exceptions."""
|
||||
args = inspect.getfullargspec(cls.__init__).args[1:]
|
||||
obj = cls(*args)
|
||||
p = pickle.dumps(obj)
|
||||
try:
|
||||
newobj = pickle.loads(p)
|
||||
except Exception as e:
|
||||
pytest.fail(
|
||||
f"Class {cls} is unable to be pickled: {e}\n"
|
||||
"Incomplete super().__init__(...) call?"
|
||||
)
|
||||
assert isinstance(newobj, cls)
|
||||
assert str(obj) == str(newobj)
|
||||
|
||||
+232
-152
@@ -14,177 +14,257 @@
|
||||
|
||||
"""Unittests for the git_config.py module."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
import git_config
|
||||
|
||||
|
||||
def fixture(*paths):
|
||||
"""Return a path relative to test/fixtures."""
|
||||
return os.path.join(os.path.dirname(__file__), "fixtures", *paths)
|
||||
@pytest.fixture
|
||||
def readonly_config() -> git_config.GitConfig:
|
||||
"""Create a GitConfig object using the test.gitconfig fixture."""
|
||||
config_fixture = utils_for_test.FIXTURES_DIR / "test.gitconfig"
|
||||
return git_config.GitConfig(config_fixture)
|
||||
|
||||
|
||||
class GitConfigReadOnlyTests(unittest.TestCase):
|
||||
"""Read-only tests of the GitConfig class."""
|
||||
def test_get_string_with_empty_config_values(
|
||||
readonly_config: git_config.GitConfig,
|
||||
) -> None:
|
||||
"""Test config entries with no value.
|
||||
|
||||
def setUp(self):
|
||||
"""Create a GitConfig object using the test.gitconfig fixture."""
|
||||
config_fixture = fixture("test.gitconfig")
|
||||
self.config = git_config.GitConfig(config_fixture)
|
||||
[section]
|
||||
empty
|
||||
|
||||
def test_GetString_with_empty_config_values(self):
|
||||
"""
|
||||
Test config entries with no value.
|
||||
|
||||
[section]
|
||||
empty
|
||||
|
||||
"""
|
||||
val = self.config.GetString("section.empty")
|
||||
self.assertEqual(val, None)
|
||||
|
||||
def test_GetString_with_true_value(self):
|
||||
"""
|
||||
Test config entries with a string value.
|
||||
|
||||
[section]
|
||||
nonempty = true
|
||||
|
||||
"""
|
||||
val = self.config.GetString("section.nonempty")
|
||||
self.assertEqual(val, "true")
|
||||
|
||||
def test_GetString_from_missing_file(self):
|
||||
"""
|
||||
Test missing config file
|
||||
"""
|
||||
config_fixture = fixture("not.present.gitconfig")
|
||||
config = git_config.GitConfig(config_fixture)
|
||||
val = config.GetString("empty")
|
||||
self.assertEqual(val, None)
|
||||
|
||||
def test_GetBoolean_undefined(self):
|
||||
"""Test GetBoolean on key that doesn't exist."""
|
||||
self.assertIsNone(self.config.GetBoolean("section.missing"))
|
||||
|
||||
def test_GetBoolean_invalid(self):
|
||||
"""Test GetBoolean on invalid boolean value."""
|
||||
self.assertIsNone(self.config.GetBoolean("section.boolinvalid"))
|
||||
|
||||
def test_GetBoolean_true(self):
|
||||
"""Test GetBoolean on valid true boolean."""
|
||||
self.assertTrue(self.config.GetBoolean("section.booltrue"))
|
||||
|
||||
def test_GetBoolean_false(self):
|
||||
"""Test GetBoolean on valid false boolean."""
|
||||
self.assertFalse(self.config.GetBoolean("section.boolfalse"))
|
||||
|
||||
def test_GetInt_undefined(self):
|
||||
"""Test GetInt on key that doesn't exist."""
|
||||
self.assertIsNone(self.config.GetInt("section.missing"))
|
||||
|
||||
def test_GetInt_invalid(self):
|
||||
"""Test GetInt on invalid integer value."""
|
||||
self.assertIsNone(self.config.GetBoolean("section.intinvalid"))
|
||||
|
||||
def test_GetInt_valid(self):
|
||||
"""Test GetInt on valid integers."""
|
||||
TESTS = (
|
||||
("inthex", 16),
|
||||
("inthexk", 16384),
|
||||
("int", 10),
|
||||
("intk", 10240),
|
||||
("intm", 10485760),
|
||||
("intg", 10737418240),
|
||||
)
|
||||
for key, value in TESTS:
|
||||
self.assertEqual(value, self.config.GetInt(f"section.{key}"))
|
||||
"""
|
||||
val = readonly_config.GetString("section.empty")
|
||||
assert val is None
|
||||
|
||||
|
||||
class GitConfigReadWriteTests(unittest.TestCase):
|
||||
"""Read/write tests of the GitConfig class."""
|
||||
def test_get_string_with_true_value(
|
||||
readonly_config: git_config.GitConfig,
|
||||
) -> None:
|
||||
"""Test config entries with a string value.
|
||||
|
||||
def setUp(self):
|
||||
self.tmpfile = tempfile.NamedTemporaryFile()
|
||||
self.config = self.get_config()
|
||||
[section]
|
||||
nonempty = true
|
||||
|
||||
def get_config(self):
|
||||
"""Get a new GitConfig instance."""
|
||||
return git_config.GitConfig(self.tmpfile.name)
|
||||
"""
|
||||
val = readonly_config.GetString("section.nonempty")
|
||||
assert val == "true"
|
||||
|
||||
def test_SetString(self):
|
||||
"""Test SetString behavior."""
|
||||
# Set a value.
|
||||
self.assertIsNone(self.config.GetString("foo.bar"))
|
||||
self.config.SetString("foo.bar", "val")
|
||||
self.assertEqual("val", self.config.GetString("foo.bar"))
|
||||
|
||||
# Make sure the value was actually written out.
|
||||
config = self.get_config()
|
||||
self.assertEqual("val", config.GetString("foo.bar"))
|
||||
def test_get_string_from_missing_file() -> None:
|
||||
"""Test missing config file."""
|
||||
config_fixture = utils_for_test.FIXTURES_DIR / "not.present.gitconfig"
|
||||
config = git_config.GitConfig(config_fixture)
|
||||
val = config.GetString("empty")
|
||||
assert val is None
|
||||
|
||||
# Update the value.
|
||||
self.config.SetString("foo.bar", "valll")
|
||||
self.assertEqual("valll", self.config.GetString("foo.bar"))
|
||||
config = self.get_config()
|
||||
self.assertEqual("valll", config.GetString("foo.bar"))
|
||||
|
||||
# Delete the value.
|
||||
self.config.SetString("foo.bar", None)
|
||||
self.assertIsNone(self.config.GetString("foo.bar"))
|
||||
config = self.get_config()
|
||||
self.assertIsNone(config.GetString("foo.bar"))
|
||||
def test_get_boolean_undefined(readonly_config: git_config.GitConfig) -> None:
|
||||
"""Test GetBoolean on key that doesn't exist."""
|
||||
assert readonly_config.GetBoolean("section.missing") is None
|
||||
|
||||
def test_SetBoolean(self):
|
||||
"""Test SetBoolean behavior."""
|
||||
# Set a true value.
|
||||
self.assertIsNone(self.config.GetBoolean("foo.bar"))
|
||||
for val in (True, 1):
|
||||
self.config.SetBoolean("foo.bar", val)
|
||||
self.assertTrue(self.config.GetBoolean("foo.bar"))
|
||||
|
||||
# Make sure the value was actually written out.
|
||||
config = self.get_config()
|
||||
self.assertTrue(config.GetBoolean("foo.bar"))
|
||||
self.assertEqual("true", config.GetString("foo.bar"))
|
||||
def test_get_boolean_invalid(readonly_config: git_config.GitConfig) -> None:
|
||||
"""Test GetBoolean on invalid boolean value."""
|
||||
assert readonly_config.GetBoolean("section.boolinvalid") is None
|
||||
|
||||
# Set a false value.
|
||||
for val in (False, 0):
|
||||
self.config.SetBoolean("foo.bar", val)
|
||||
self.assertFalse(self.config.GetBoolean("foo.bar"))
|
||||
|
||||
# Make sure the value was actually written out.
|
||||
config = self.get_config()
|
||||
self.assertFalse(config.GetBoolean("foo.bar"))
|
||||
self.assertEqual("false", config.GetString("foo.bar"))
|
||||
def test_get_boolean_true(readonly_config: git_config.GitConfig) -> None:
|
||||
"""Test GetBoolean on valid true boolean."""
|
||||
assert readonly_config.GetBoolean("section.booltrue") is True
|
||||
|
||||
# Delete the value.
|
||||
self.config.SetBoolean("foo.bar", None)
|
||||
self.assertIsNone(self.config.GetBoolean("foo.bar"))
|
||||
config = self.get_config()
|
||||
self.assertIsNone(config.GetBoolean("foo.bar"))
|
||||
|
||||
def test_GetSyncAnalysisStateData(self):
|
||||
"""Test config entries with a sync state analysis data."""
|
||||
superproject_logging_data = {}
|
||||
superproject_logging_data["test"] = False
|
||||
options = type("options", (object,), {})()
|
||||
options.verbose = "true"
|
||||
options.mp_update = "false"
|
||||
TESTS = (
|
||||
("superproject.test", "false"),
|
||||
("options.verbose", "true"),
|
||||
("options.mpupdate", "false"),
|
||||
("main.version", "1"),
|
||||
)
|
||||
self.config.UpdateSyncAnalysisState(options, superproject_logging_data)
|
||||
sync_data = self.config.GetSyncAnalysisStateData()
|
||||
for key, value in TESTS:
|
||||
self.assertEqual(
|
||||
sync_data[f"{git_config.SYNC_STATE_PREFIX}{key}"], value
|
||||
)
|
||||
self.assertTrue(
|
||||
sync_data[f"{git_config.SYNC_STATE_PREFIX}main.synctime"]
|
||||
)
|
||||
def test_get_boolean_false(readonly_config: git_config.GitConfig) -> None:
|
||||
"""Test GetBoolean on valid false boolean."""
|
||||
assert readonly_config.GetBoolean("section.boolfalse") is False
|
||||
|
||||
|
||||
def test_get_int_undefined(readonly_config: git_config.GitConfig) -> None:
|
||||
"""Test GetInt on key that doesn't exist."""
|
||||
assert readonly_config.GetInt("section.missing") is None
|
||||
|
||||
|
||||
def test_get_int_invalid(readonly_config: git_config.GitConfig) -> None:
|
||||
"""Test GetInt on invalid integer value."""
|
||||
assert readonly_config.GetInt("section.intinvalid") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key, expected",
|
||||
(
|
||||
("inthex", 16),
|
||||
("inthexk", 16384),
|
||||
("int", 10),
|
||||
("intk", 10240),
|
||||
("intm", 10485760),
|
||||
("intg", 10737418240),
|
||||
),
|
||||
)
|
||||
def test_get_int_valid(
|
||||
readonly_config: git_config.GitConfig, key: str, expected: int
|
||||
) -> None:
|
||||
"""Test GetInt on valid integers."""
|
||||
assert readonly_config.GetInt(f"section.{key}") == expected
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rw_config_file(tmp_path: Path) -> Path:
|
||||
"""Return a path to a temporary config file."""
|
||||
return tmp_path / "config"
|
||||
|
||||
|
||||
def test_set_string(rw_config_file: Path) -> None:
|
||||
"""Test SetString behavior."""
|
||||
config = git_config.GitConfig(str(rw_config_file))
|
||||
|
||||
# Set a value.
|
||||
assert config.GetString("foo.bar") is None
|
||||
config.SetString("foo.bar", "val")
|
||||
assert config.GetString("foo.bar") == "val"
|
||||
|
||||
# Make sure the value was actually written out.
|
||||
config2 = git_config.GitConfig(str(rw_config_file))
|
||||
assert config2.GetString("foo.bar") == "val"
|
||||
|
||||
# Update the value.
|
||||
config.SetString("foo.bar", "valll")
|
||||
assert config.GetString("foo.bar") == "valll"
|
||||
config3 = git_config.GitConfig(str(rw_config_file))
|
||||
assert config3.GetString("foo.bar") == "valll"
|
||||
|
||||
# Delete the value.
|
||||
config.SetString("foo.bar", None)
|
||||
assert config.GetString("foo.bar") is None
|
||||
config4 = git_config.GitConfig(str(rw_config_file))
|
||||
assert config4.GetString("foo.bar") is None
|
||||
|
||||
|
||||
def test_set_boolean(rw_config_file: Path) -> None:
|
||||
"""Test SetBoolean behavior."""
|
||||
config = git_config.GitConfig(str(rw_config_file))
|
||||
|
||||
# Set a true value.
|
||||
assert config.GetBoolean("foo.bar") is None
|
||||
for val in (True, 1):
|
||||
config.SetBoolean("foo.bar", val)
|
||||
assert config.GetBoolean("foo.bar") is True
|
||||
|
||||
# Make sure the value was actually written out.
|
||||
config2 = git_config.GitConfig(str(rw_config_file))
|
||||
assert config2.GetBoolean("foo.bar") is True
|
||||
assert config2.GetString("foo.bar") == "true"
|
||||
|
||||
# Set a false value.
|
||||
for val in (False, 0):
|
||||
config.SetBoolean("foo.bar", val)
|
||||
assert config.GetBoolean("foo.bar") is False
|
||||
|
||||
# Make sure the value was actually written out.
|
||||
config3 = git_config.GitConfig(str(rw_config_file))
|
||||
assert config3.GetBoolean("foo.bar") is False
|
||||
assert config3.GetString("foo.bar") == "false"
|
||||
|
||||
# Delete the value.
|
||||
config.SetBoolean("foo.bar", None)
|
||||
assert config.GetBoolean("foo.bar") is None
|
||||
config4 = git_config.GitConfig(str(rw_config_file))
|
||||
assert config4.GetBoolean("foo.bar") is None
|
||||
|
||||
|
||||
def test_set_int(rw_config_file: Path) -> None:
|
||||
"""Test SetInt behavior."""
|
||||
config = git_config.GitConfig(str(rw_config_file))
|
||||
|
||||
# Set a value.
|
||||
assert config.GetInt("foo.bar") is None
|
||||
config.SetInt("foo.bar", 10)
|
||||
assert config.GetInt("foo.bar") == 10
|
||||
|
||||
# Make sure the value was actually written out.
|
||||
config2 = git_config.GitConfig(str(rw_config_file))
|
||||
assert config2.GetInt("foo.bar") == 10
|
||||
assert config2.GetString("foo.bar") == "10"
|
||||
|
||||
# Update the value.
|
||||
config.SetInt("foo.bar", 20)
|
||||
assert config.GetInt("foo.bar") == 20
|
||||
config3 = git_config.GitConfig(str(rw_config_file))
|
||||
assert config3.GetInt("foo.bar") == 20
|
||||
|
||||
# Delete the value.
|
||||
config.SetInt("foo.bar", None)
|
||||
assert config.GetInt("foo.bar") is None
|
||||
config4 = git_config.GitConfig(str(rw_config_file))
|
||||
assert config4.GetInt("foo.bar") is None
|
||||
|
||||
|
||||
def test_get_sync_analysis_state_data(rw_config_file: Path) -> None:
|
||||
"""Test config entries with a sync state analysis data."""
|
||||
config = git_config.GitConfig(str(rw_config_file))
|
||||
superproject_logging_data: dict[str, Any] = {"test": False}
|
||||
|
||||
class Options:
|
||||
"""Container for testing."""
|
||||
|
||||
options = Options()
|
||||
options.verbose = "true"
|
||||
options.mp_update = "false"
|
||||
|
||||
TESTS = (
|
||||
("superproject.test", "false"),
|
||||
("options.verbose", "true"),
|
||||
("options.mpupdate", "false"),
|
||||
("main.version", "1"),
|
||||
)
|
||||
config.UpdateSyncAnalysisState(options, superproject_logging_data)
|
||||
sync_data = config.GetSyncAnalysisStateData()
|
||||
for key, value in TESTS:
|
||||
assert sync_data[f"{git_config.SYNC_STATE_PREFIX}{key}"] == value
|
||||
assert sync_data[f"{git_config.SYNC_STATE_PREFIX}main.synctime"]
|
||||
|
||||
|
||||
def test_remote_save_with_push_url_without_projectname(
|
||||
rw_config_file: Path,
|
||||
) -> None:
|
||||
"""Test saving a remote pushUrl when projectname is unset."""
|
||||
config = git_config.GitConfig(str(rw_config_file))
|
||||
remote = config.GetRemote("origin")
|
||||
remote.url = "https://example.com/repo"
|
||||
remote.pushUrl = "ssh://example.com"
|
||||
remote.projectname = None
|
||||
|
||||
remote.Save()
|
||||
|
||||
written_config = git_config.GitConfig(str(rw_config_file))
|
||||
assert (
|
||||
written_config.GetString("remote.origin.pushurl") == "ssh://example.com"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rev, expected",
|
||||
(
|
||||
("a" * 40, True),
|
||||
("0" * 40, True),
|
||||
("f" * 40, True),
|
||||
("a" * 64, True),
|
||||
("0" * 64, True),
|
||||
("f" * 64, True),
|
||||
("a" * 39, False),
|
||||
("a" * 41, True),
|
||||
("a" * 63, True),
|
||||
("a" * 65, False),
|
||||
("g" * 40, False),
|
||||
("g" * 64, False),
|
||||
("refs/heads/master", False),
|
||||
("refs/tags/v1.0", False),
|
||||
),
|
||||
)
|
||||
def test_is_id(rev: str, expected: bool) -> None:
|
||||
"""Test IsId identifies both SHA-1 and SHA-256 hashes."""
|
||||
assert git_config.IsId(rev) == expected
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unittests for the git_refs.py module."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
import git_refs
|
||||
|
||||
|
||||
def _run(repo, *args):
|
||||
return subprocess.run(
|
||||
["git", "-C", repo, *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _init_repo(tmp_path, reftable=False):
|
||||
repo = os.path.join(tmp_path, "repo")
|
||||
ref_format = "reftable" if reftable else "files"
|
||||
utils_for_test.init_git_tree(repo, ref_format=ref_format)
|
||||
|
||||
Path(os.path.join(repo, "a")).write_text("1")
|
||||
_run(repo, "add", "a")
|
||||
_run(repo, "commit", "-q", "-m", "init")
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reftable", [False, True])
|
||||
def test_reads_refs(tmp_path, reftable):
|
||||
if reftable and not utils_for_test.supports_reftable():
|
||||
pytest.skip("reftable not supported")
|
||||
|
||||
repo = _init_repo(tmp_path, reftable=reftable)
|
||||
gitdir = os.path.join(repo, ".git")
|
||||
refs = git_refs.GitRefs(gitdir)
|
||||
|
||||
branch = _run(repo, "symbolic-ref", "--short", "HEAD")
|
||||
head = _run(repo, "rev-parse", "HEAD")
|
||||
assert refs.symref("HEAD") == f"refs/heads/{branch}"
|
||||
assert refs.get("HEAD") == head
|
||||
assert refs.get(f"refs/heads/{branch}") == head
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reftable", [False, True])
|
||||
def test_updates_when_refs_change(tmp_path, reftable):
|
||||
if reftable and not utils_for_test.supports_reftable():
|
||||
pytest.skip("reftable not supported")
|
||||
|
||||
repo = _init_repo(tmp_path, reftable=reftable)
|
||||
gitdir = os.path.join(repo, ".git")
|
||||
refs = git_refs.GitRefs(gitdir)
|
||||
|
||||
head = _run(repo, "rev-parse", "HEAD")
|
||||
assert refs.get("refs/heads/topic") == ""
|
||||
_run(repo, "branch", "topic")
|
||||
assert refs.get("refs/heads/topic") == head
|
||||
_run(repo, "branch", "-D", "topic")
|
||||
assert refs.get("refs/heads/topic") == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not utils_for_test.supports_refs_migrate(),
|
||||
reason="git refs migrate reftable support is required for this test",
|
||||
)
|
||||
def test_updates_when_storage_backend_toggles(tmp_path):
|
||||
repo = _init_repo(tmp_path, reftable=False)
|
||||
gitdir = os.path.join(repo, ".git")
|
||||
refs = git_refs.GitRefs(gitdir)
|
||||
|
||||
head = _run(repo, "rev-parse", "HEAD")
|
||||
assert refs.get("refs/heads/reftable-branch") == ""
|
||||
_run(repo, "refs", "migrate", "--ref-format=reftable")
|
||||
_run(repo, "branch", "reftable-branch")
|
||||
assert refs.get("refs/heads/reftable-branch") == head
|
||||
|
||||
assert refs.get("refs/heads/files-branch") == ""
|
||||
_run(repo, "refs", "migrate", "--ref-format=files")
|
||||
_run(repo, "branch", "files-branch")
|
||||
assert refs.get("refs/heads/files-branch") == head
|
||||
@@ -190,7 +190,7 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
),
|
||||
revision="refs/heads/main",
|
||||
)
|
||||
with mock.patch.object(self._superproject, "_branch", "junk"):
|
||||
with mock.patch.object(self._superproject, "revision", "junk"):
|
||||
sync_result = self._superproject.Sync(self.git_event_log)
|
||||
self.assertFalse(sync_result.success)
|
||||
self.assertTrue(sync_result.fatal)
|
||||
@@ -461,6 +461,62 @@ class SuperprojectTestCase(unittest.TestCase):
|
||||
"</manifest>",
|
||||
)
|
||||
|
||||
def test_Init_success(self):
|
||||
"""Test _Init succeeds and creates the work git dir."""
|
||||
self.assertFalse(os.path.exists(self._superproject._work_git))
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", autospec=True
|
||||
) as mock_git_command:
|
||||
instance = mock_git_command.return_value
|
||||
instance.Wait.return_value = 0
|
||||
|
||||
self.assertTrue(self._superproject._Init())
|
||||
|
||||
mock_git_command.assert_called_once()
|
||||
args, kwargs = mock_git_command.call_args
|
||||
self.assertEqual(args[1][:2], ["init", "--bare"])
|
||||
|
||||
tmp_git_name = args[1][2]
|
||||
self.assertTrue(
|
||||
tmp_git_name.startswith(".tmp-superproject-initgitdir-")
|
||||
)
|
||||
self.assertTrue(os.path.exists(self._superproject._work_git))
|
||||
|
||||
tmp_git_path = os.path.join(
|
||||
self._superproject._superproject_path, tmp_git_name
|
||||
)
|
||||
self.assertFalse(os.path.exists(tmp_git_path))
|
||||
|
||||
def test_Init_already_exists(self):
|
||||
"""Test _Init returns early if the work git dir already exists."""
|
||||
os.mkdir(self._superproject._superproject_path)
|
||||
os.mkdir(self._superproject._work_git)
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", autospec=True
|
||||
) as mock_git_command:
|
||||
self.assertTrue(self._superproject._Init())
|
||||
mock_git_command.assert_not_called()
|
||||
|
||||
def test_Init_failure_cleans_up(self):
|
||||
"""Test _Init cleans up the temporary directory if 'git init' fails."""
|
||||
with mock.patch(
|
||||
"git_superproject.GitCommand", autospec=True
|
||||
) as mock_git_command:
|
||||
instance = mock_git_command.return_value
|
||||
instance.Wait.return_value = 1
|
||||
instance.stderr = "mock git init failure"
|
||||
|
||||
self.assertFalse(self._superproject._Init())
|
||||
mock_git_command.assert_called_once()
|
||||
args, kwargs = mock_git_command.call_args
|
||||
tmp_git_name = args[1][2]
|
||||
|
||||
tmp_git_path = os.path.join(
|
||||
self._superproject._superproject_path, tmp_git_name
|
||||
)
|
||||
self.assertFalse(os.path.exists(tmp_git_path))
|
||||
self.assertFalse(os.path.exists(self._superproject._work_git))
|
||||
|
||||
def test_Fetch(self):
|
||||
manifest = self.getXmlManifest(
|
||||
"""
|
||||
|
||||
+417
-334
@@ -14,19 +14,28 @@
|
||||
|
||||
"""Unittests for the git_trace2_event_log.py module."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import git_trace2_event_log
|
||||
import platform_utils
|
||||
|
||||
|
||||
def serverLoggingThread(socket_path, server_ready, received_traces):
|
||||
def server_logging_thread(
|
||||
socket_path: str,
|
||||
server_ready: threading.Condition,
|
||||
received_traces: List[str],
|
||||
) -> None:
|
||||
"""Helper function to receive logs over a Unix domain socket.
|
||||
|
||||
Appends received messages on the provided socket and appends to
|
||||
@@ -55,352 +64,426 @@ def serverLoggingThread(socket_path, server_ready, received_traces):
|
||||
received_traces.extend(data.decode("utf-8").splitlines())
|
||||
|
||||
|
||||
class EventLogTestCase(unittest.TestCase):
|
||||
"""TestCase for the EventLog module."""
|
||||
PARENT_SID_KEY = "GIT_TRACE2_PARENT_SID"
|
||||
PARENT_SID_VALUE = "parent_sid"
|
||||
SELF_SID_REGEX = r"repo-\d+T\d+Z-.*"
|
||||
FULL_SID_REGEX = rf"^{PARENT_SID_VALUE}/{SELF_SID_REGEX}"
|
||||
|
||||
PARENT_SID_KEY = "GIT_TRACE2_PARENT_SID"
|
||||
PARENT_SID_VALUE = "parent_sid"
|
||||
SELF_SID_REGEX = r"repo-\d+T\d+Z-.*"
|
||||
FULL_SID_REGEX = rf"^{PARENT_SID_VALUE}/{SELF_SID_REGEX}"
|
||||
|
||||
def setUp(self):
|
||||
"""Load the event_log module every time."""
|
||||
self._event_log_module = None
|
||||
# By default we initialize with the expected case where
|
||||
# repo launches us (so GIT_TRACE2_PARENT_SID is set).
|
||||
env = {
|
||||
self.PARENT_SID_KEY: self.PARENT_SID_VALUE,
|
||||
}
|
||||
self._event_log_module = git_trace2_event_log.EventLog(env=env)
|
||||
self._log_data = None
|
||||
@pytest.fixture
|
||||
def event_log() -> git_trace2_event_log.EventLog:
|
||||
"""Fixture for the EventLog module."""
|
||||
# By default we initialize with the expected case where
|
||||
# repo launches us (so GIT_TRACE2_PARENT_SID is set).
|
||||
env = {PARENT_SID_KEY: PARENT_SID_VALUE}
|
||||
return git_trace2_event_log.EventLog(env=env)
|
||||
|
||||
def verifyCommonKeys(
|
||||
self, log_entry, expected_event_name=None, full_sid=True
|
||||
|
||||
def verify_common_keys(
|
||||
log_entry: Dict[str, Any],
|
||||
expected_event_name: Optional[str] = None,
|
||||
full_sid: bool = True,
|
||||
) -> None:
|
||||
"""Helper function to verify common event log keys."""
|
||||
assert "event" in log_entry
|
||||
assert "sid" in log_entry
|
||||
assert "thread" in log_entry
|
||||
assert "time" in log_entry
|
||||
|
||||
# Do basic data format validation.
|
||||
if expected_event_name:
|
||||
assert expected_event_name == log_entry["event"]
|
||||
if full_sid:
|
||||
assert re.match(FULL_SID_REGEX, log_entry["sid"])
|
||||
else:
|
||||
assert re.match(SELF_SID_REGEX, log_entry["sid"])
|
||||
assert re.match(r"^\d+-\d+-\d+T\d+:\d+:\d+\.\d+\+00:00$", log_entry["time"])
|
||||
|
||||
|
||||
def read_log(log_path: str) -> List[Dict[str, Any]]:
|
||||
"""Helper function to read log data into a list."""
|
||||
log_data = []
|
||||
with open(log_path, mode="rb") as f:
|
||||
for line in f:
|
||||
log_data.append(json.loads(line))
|
||||
return log_data
|
||||
|
||||
|
||||
def remove_prefix(s: str, prefix: str) -> str:
|
||||
"""Return a copy string after removing |prefix| from |s|, if present or
|
||||
the original string."""
|
||||
if s.startswith(prefix):
|
||||
return s[len(prefix) :]
|
||||
else:
|
||||
return s
|
||||
|
||||
|
||||
def test_initial_state_with_parent_sid(
|
||||
event_log: git_trace2_event_log.EventLog,
|
||||
) -> None:
|
||||
"""Test initial state when 'GIT_TRACE2_PARENT_SID' is set by parent."""
|
||||
assert re.match(FULL_SID_REGEX, event_log.full_sid)
|
||||
|
||||
|
||||
def test_initial_state_no_parent_sid() -> None:
|
||||
"""Test initial state when 'GIT_TRACE2_PARENT_SID' is not set."""
|
||||
# Setup an empty environment dict (no parent sid).
|
||||
event_log = git_trace2_event_log.EventLog(env={})
|
||||
assert re.match(SELF_SID_REGEX, event_log.full_sid)
|
||||
|
||||
|
||||
def test_version_event(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
"""Test 'version' event data is valid.
|
||||
|
||||
Verify that the 'version' event is written even when no other
|
||||
events are added.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
"""
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = event_log.Write(path=tempdir)
|
||||
log_data = read_log(log_path)
|
||||
|
||||
# A log with no added events should only have the version entry.
|
||||
assert len(log_data) == 1
|
||||
version_event = log_data[0]
|
||||
verify_common_keys(version_event, expected_event_name="version")
|
||||
# Check for 'version' event specific fields.
|
||||
assert "evt" in version_event
|
||||
assert "exe" in version_event
|
||||
# Verify "evt" version field is a string.
|
||||
assert isinstance(version_event["evt"], str)
|
||||
|
||||
|
||||
def test_start_event(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
"""Test and validate 'start' event data is valid.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<start event>
|
||||
"""
|
||||
event_log.StartEvent([])
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = event_log.Write(path=tempdir)
|
||||
log_data = read_log(log_path)
|
||||
|
||||
assert len(log_data) == 2
|
||||
start_event = log_data[1]
|
||||
verify_common_keys(log_data[0], expected_event_name="version")
|
||||
verify_common_keys(start_event, expected_event_name="start")
|
||||
# Check for 'start' event specific fields.
|
||||
assert "argv" in start_event
|
||||
assert isinstance(start_event["argv"], list)
|
||||
|
||||
|
||||
def test_exit_event_result_none(
|
||||
event_log: git_trace2_event_log.EventLog,
|
||||
) -> None:
|
||||
"""Test 'exit' event data is valid when result is None.
|
||||
|
||||
We expect None result to be converted to 0 in the exit event data.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<exit event>
|
||||
"""
|
||||
event_log.ExitEvent(None)
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = event_log.Write(path=tempdir)
|
||||
log_data = read_log(log_path)
|
||||
|
||||
assert len(log_data) == 2
|
||||
exit_event = log_data[1]
|
||||
verify_common_keys(log_data[0], expected_event_name="version")
|
||||
verify_common_keys(exit_event, expected_event_name="exit")
|
||||
# Check for 'exit' event specific fields.
|
||||
assert "code" in exit_event
|
||||
# 'None' result should convert to 0 (successful) return code.
|
||||
assert exit_event["code"] == 0
|
||||
|
||||
|
||||
def test_exit_event_result_integer(
|
||||
event_log: git_trace2_event_log.EventLog,
|
||||
) -> None:
|
||||
"""Test 'exit' event data is valid when result is an integer.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<exit event>
|
||||
"""
|
||||
event_log.ExitEvent(2)
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = event_log.Write(path=tempdir)
|
||||
log_data = read_log(log_path)
|
||||
|
||||
assert len(log_data) == 2
|
||||
exit_event = log_data[1]
|
||||
verify_common_keys(log_data[0], expected_event_name="version")
|
||||
verify_common_keys(exit_event, expected_event_name="exit")
|
||||
# Check for 'exit' event specific fields.
|
||||
assert "code" in exit_event
|
||||
assert exit_event["code"] == 2
|
||||
|
||||
|
||||
def test_command_event(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
"""Test and validate 'command' event data is valid.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<command event>
|
||||
"""
|
||||
event_log.CommandEvent(name="repo", subcommands=["init", "this"])
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = event_log.Write(path=tempdir)
|
||||
log_data = read_log(log_path)
|
||||
|
||||
assert len(log_data) == 2
|
||||
command_event = log_data[1]
|
||||
verify_common_keys(log_data[0], expected_event_name="version")
|
||||
verify_common_keys(command_event, expected_event_name="cmd_name")
|
||||
# Check for 'command' event specific fields.
|
||||
assert "name" in command_event
|
||||
assert command_event["name"] == "repo-init-this"
|
||||
|
||||
|
||||
def test_def_params_event_repo_config(
|
||||
event_log: git_trace2_event_log.EventLog,
|
||||
) -> None:
|
||||
"""Test 'def_params' event data outputs only repo config keys.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<def_param event>
|
||||
<def_param event>
|
||||
"""
|
||||
config = {
|
||||
"git.foo": "bar",
|
||||
"repo.partialclone": "true",
|
||||
"repo.partialclonefilter": "blob:none",
|
||||
}
|
||||
event_log.DefParamRepoEvents(config)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = event_log.Write(path=tempdir)
|
||||
log_data = read_log(log_path)
|
||||
|
||||
assert len(log_data) == 3
|
||||
def_param_events = log_data[1:]
|
||||
verify_common_keys(log_data[0], expected_event_name="version")
|
||||
|
||||
for event in def_param_events:
|
||||
verify_common_keys(event, expected_event_name="def_param")
|
||||
# Check for 'def_param' event specific fields.
|
||||
assert "param" in event
|
||||
assert "value" in event
|
||||
assert event["param"].startswith("repo.")
|
||||
|
||||
|
||||
def test_def_params_event_no_repo_config(
|
||||
event_log: git_trace2_event_log.EventLog,
|
||||
) -> None:
|
||||
"""Test 'def_params' event data won't output non-repo config keys.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
"""
|
||||
config = {
|
||||
"git.foo": "bar",
|
||||
"git.core.foo2": "baz",
|
||||
}
|
||||
event_log.DefParamRepoEvents(config)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = event_log.Write(path=tempdir)
|
||||
log_data = read_log(log_path)
|
||||
|
||||
assert len(log_data) == 1
|
||||
verify_common_keys(log_data[0], expected_event_name="version")
|
||||
|
||||
|
||||
def test_data_event_config(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
"""Test 'data' event data outputs all config keys.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<data event>
|
||||
<data event>
|
||||
"""
|
||||
config = {
|
||||
"git.foo": "bar",
|
||||
"repo.partialclone": "false",
|
||||
"repo.syncstate.superproject.hassuperprojecttag": "true",
|
||||
"repo.syncstate.superproject.sys.argv": ["--", "sync", "protobuf"],
|
||||
"repo.syncstate.emptykey": "",
|
||||
}
|
||||
prefix_value = "prefix"
|
||||
event_log.LogDataConfigEvents(config, prefix_value)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = event_log.Write(path=tempdir)
|
||||
log_data = read_log(log_path)
|
||||
|
||||
assert len(log_data) == 6
|
||||
data_events = log_data[1:]
|
||||
verify_common_keys(log_data[0], expected_event_name="version")
|
||||
|
||||
for event in data_events:
|
||||
verify_common_keys(event)
|
||||
# Check for 'data' event specific fields.
|
||||
assert "key" in event
|
||||
assert "value" in event
|
||||
key = event["key"]
|
||||
key = remove_prefix(key, f"{prefix_value}/")
|
||||
value = event["value"]
|
||||
assert event_log.GetDataEventName(value) == event["event"]
|
||||
assert key in config
|
||||
assert value == config[key]
|
||||
|
||||
|
||||
def test_error_event(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
"""Test and validate 'error' event data is valid.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<error event>
|
||||
"""
|
||||
msg = "invalid option: --cahced"
|
||||
fmt = "invalid option: %s"
|
||||
event_log.ErrorEvent(msg, fmt)
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = event_log.Write(path=tempdir)
|
||||
log_data = read_log(log_path)
|
||||
|
||||
assert len(log_data) == 2
|
||||
error_event = log_data[1]
|
||||
verify_common_keys(log_data[0], expected_event_name="version")
|
||||
verify_common_keys(error_event, expected_event_name="error")
|
||||
# Check for 'error' event specific fields.
|
||||
assert "msg" in error_event
|
||||
assert "fmt" in error_event
|
||||
assert error_event["msg"] == f"RepoErrorEvent:{msg}"
|
||||
assert error_event["fmt"] == f"RepoErrorEvent:{fmt}"
|
||||
|
||||
|
||||
def test_write_with_filename(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
"""Test Write() with a path to a file exits with None."""
|
||||
assert event_log.Write(path="path/to/file") is None
|
||||
|
||||
|
||||
def test_write_with_git_config(
|
||||
tmp_path,
|
||||
event_log: git_trace2_event_log.EventLog,
|
||||
) -> None:
|
||||
"""Test Write() uses the git config path when 'git config' call succeeds."""
|
||||
with mock.patch.object(
|
||||
event_log,
|
||||
"_GetEventTargetPath",
|
||||
return_value=str(tmp_path),
|
||||
):
|
||||
"""Helper function to verify common event log keys."""
|
||||
self.assertIn("event", log_entry)
|
||||
self.assertIn("sid", log_entry)
|
||||
self.assertIn("thread", log_entry)
|
||||
self.assertIn("time", log_entry)
|
||||
assert os.path.dirname(event_log.Write()) == str(tmp_path)
|
||||
|
||||
# Do basic data format validation.
|
||||
if expected_event_name:
|
||||
self.assertEqual(expected_event_name, log_entry["event"])
|
||||
if full_sid:
|
||||
self.assertRegex(log_entry["sid"], self.FULL_SID_REGEX)
|
||||
else:
|
||||
self.assertRegex(log_entry["sid"], self.SELF_SID_REGEX)
|
||||
self.assertRegex(
|
||||
log_entry["time"], r"^\d+-\d+-\d+T\d+:\d+:\d+\.\d+\+00:00$"
|
||||
|
||||
def test_write_no_git_config(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
"""Test Write() with no git config variable present exits with None."""
|
||||
with mock.patch.object(event_log, "_GetEventTargetPath", return_value=None):
|
||||
assert event_log.Write() is None
|
||||
|
||||
|
||||
def test_write_non_string(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
"""Test Write() with non-string type for |path| throws TypeError."""
|
||||
with pytest.raises(TypeError):
|
||||
event_log.Write(path=1234)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not hasattr(socket, "AF_UNIX"), reason="Requires AF_UNIX sockets"
|
||||
)
|
||||
def test_write_socket(event_log: git_trace2_event_log.EventLog) -> None:
|
||||
"""Test Write() with Unix domain socket and validate received traces."""
|
||||
received_traces: List[str] = []
|
||||
with tempfile.TemporaryDirectory(prefix="test_server_sockets") as tempdir:
|
||||
socket_path = os.path.join(tempdir, "server.sock")
|
||||
server_ready = threading.Condition()
|
||||
# Start "server" listening on Unix domain socket at socket_path.
|
||||
server_thread = threading.Thread(
|
||||
target=server_logging_thread,
|
||||
args=(socket_path, server_ready, received_traces),
|
||||
)
|
||||
try:
|
||||
server_thread.start()
|
||||
|
||||
def readLog(self, log_path):
|
||||
"""Helper function to read log data into a list."""
|
||||
log_data = []
|
||||
with open(log_path, mode="rb") as f:
|
||||
for line in f:
|
||||
log_data.append(json.loads(line))
|
||||
return log_data
|
||||
with server_ready:
|
||||
server_ready.wait(timeout=120)
|
||||
|
||||
def remove_prefix(self, s, prefix):
|
||||
"""Return a copy string after removing |prefix| from |s|, if present or
|
||||
the original string."""
|
||||
if s.startswith(prefix):
|
||||
return s[len(prefix) :]
|
||||
else:
|
||||
return s
|
||||
event_log.StartEvent([])
|
||||
path = event_log.Write(path=f"af_unix:{socket_path}")
|
||||
finally:
|
||||
server_thread.join(timeout=5)
|
||||
|
||||
def test_initial_state_with_parent_sid(self):
|
||||
"""Test initial state when 'GIT_TRACE2_PARENT_SID' is set by parent."""
|
||||
self.assertRegex(self._event_log_module.full_sid, self.FULL_SID_REGEX)
|
||||
assert path == f"af_unix:stream:{socket_path}"
|
||||
assert len(received_traces) == 2
|
||||
version_event = json.loads(received_traces[0])
|
||||
start_event = json.loads(received_traces[1])
|
||||
verify_common_keys(version_event, expected_event_name="version")
|
||||
verify_common_keys(start_event, expected_event_name="start")
|
||||
# Check for 'start' event specific fields.
|
||||
assert "argv" in start_event
|
||||
assert isinstance(start_event["argv"], list)
|
||||
|
||||
def test_initial_state_no_parent_sid(self):
|
||||
"""Test initial state when 'GIT_TRACE2_PARENT_SID' is not set."""
|
||||
# Setup an empty environment dict (no parent sid).
|
||||
self._event_log_module = git_trace2_event_log.EventLog(env={})
|
||||
self.assertRegex(self._event_log_module.full_sid, self.SELF_SID_REGEX)
|
||||
|
||||
def test_version_event(self):
|
||||
"""Test 'version' event data is valid.
|
||||
class TestEventLogVerbose:
|
||||
"""TestCase for the EventLog module verbose logging."""
|
||||
|
||||
Verify that the 'version' event is written even when no other
|
||||
events are addded.
|
||||
def test_write_socket_error_no_verbose(self) -> None:
|
||||
"""Test Write() suppression of socket errors when not verbose."""
|
||||
event_log = git_trace2_event_log.EventLog(env={})
|
||||
event_log.verbose = False
|
||||
with contextlib.redirect_stderr(
|
||||
io.StringIO()
|
||||
) as mock_stderr, mock.patch("socket.socket", side_effect=OSError):
|
||||
event_log.Write(path="af_unix:stream:/tmp/test_sock")
|
||||
assert mock_stderr.getvalue() == ""
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
"""
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = self._event_log_module.Write(path=tempdir)
|
||||
self._log_data = self.readLog(log_path)
|
||||
|
||||
# A log with no added events should only have the version entry.
|
||||
self.assertEqual(len(self._log_data), 1)
|
||||
version_event = self._log_data[0]
|
||||
self.verifyCommonKeys(version_event, expected_event_name="version")
|
||||
# Check for 'version' event specific fields.
|
||||
self.assertIn("evt", version_event)
|
||||
self.assertIn("exe", version_event)
|
||||
# Verify "evt" version field is a string.
|
||||
self.assertIsInstance(version_event["evt"], str)
|
||||
|
||||
def test_start_event(self):
|
||||
"""Test and validate 'start' event data is valid.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<start event>
|
||||
"""
|
||||
self._event_log_module.StartEvent([])
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = self._event_log_module.Write(path=tempdir)
|
||||
self._log_data = self.readLog(log_path)
|
||||
|
||||
self.assertEqual(len(self._log_data), 2)
|
||||
start_event = self._log_data[1]
|
||||
self.verifyCommonKeys(self._log_data[0], expected_event_name="version")
|
||||
self.verifyCommonKeys(start_event, expected_event_name="start")
|
||||
# Check for 'start' event specific fields.
|
||||
self.assertIn("argv", start_event)
|
||||
self.assertTrue(isinstance(start_event["argv"], list))
|
||||
|
||||
def test_exit_event_result_none(self):
|
||||
"""Test 'exit' event data is valid when result is None.
|
||||
|
||||
We expect None result to be converted to 0 in the exit event data.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<exit event>
|
||||
"""
|
||||
self._event_log_module.ExitEvent(None)
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = self._event_log_module.Write(path=tempdir)
|
||||
self._log_data = self.readLog(log_path)
|
||||
|
||||
self.assertEqual(len(self._log_data), 2)
|
||||
exit_event = self._log_data[1]
|
||||
self.verifyCommonKeys(self._log_data[0], expected_event_name="version")
|
||||
self.verifyCommonKeys(exit_event, expected_event_name="exit")
|
||||
# Check for 'exit' event specific fields.
|
||||
self.assertIn("code", exit_event)
|
||||
# 'None' result should convert to 0 (successful) return code.
|
||||
self.assertEqual(exit_event["code"], 0)
|
||||
|
||||
def test_exit_event_result_integer(self):
|
||||
"""Test 'exit' event data is valid when result is an integer.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<exit event>
|
||||
"""
|
||||
self._event_log_module.ExitEvent(2)
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = self._event_log_module.Write(path=tempdir)
|
||||
self._log_data = self.readLog(log_path)
|
||||
|
||||
self.assertEqual(len(self._log_data), 2)
|
||||
exit_event = self._log_data[1]
|
||||
self.verifyCommonKeys(self._log_data[0], expected_event_name="version")
|
||||
self.verifyCommonKeys(exit_event, expected_event_name="exit")
|
||||
# Check for 'exit' event specific fields.
|
||||
self.assertIn("code", exit_event)
|
||||
self.assertEqual(exit_event["code"], 2)
|
||||
|
||||
def test_command_event(self):
|
||||
"""Test and validate 'command' event data is valid.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<command event>
|
||||
"""
|
||||
self._event_log_module.CommandEvent(
|
||||
name="repo", subcommands=["init", "this"]
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = self._event_log_module.Write(path=tempdir)
|
||||
self._log_data = self.readLog(log_path)
|
||||
|
||||
self.assertEqual(len(self._log_data), 2)
|
||||
command_event = self._log_data[1]
|
||||
self.verifyCommonKeys(self._log_data[0], expected_event_name="version")
|
||||
self.verifyCommonKeys(command_event, expected_event_name="cmd_name")
|
||||
# Check for 'command' event specific fields.
|
||||
self.assertIn("name", command_event)
|
||||
self.assertEqual(command_event["name"], "repo-init-this")
|
||||
|
||||
def test_def_params_event_repo_config(self):
|
||||
"""Test 'def_params' event data outputs only repo config keys.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<def_param event>
|
||||
<def_param event>
|
||||
"""
|
||||
config = {
|
||||
"git.foo": "bar",
|
||||
"repo.partialclone": "true",
|
||||
"repo.partialclonefilter": "blob:none",
|
||||
}
|
||||
self._event_log_module.DefParamRepoEvents(config)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = self._event_log_module.Write(path=tempdir)
|
||||
self._log_data = self.readLog(log_path)
|
||||
|
||||
self.assertEqual(len(self._log_data), 3)
|
||||
def_param_events = self._log_data[1:]
|
||||
self.verifyCommonKeys(self._log_data[0], expected_event_name="version")
|
||||
|
||||
for event in def_param_events:
|
||||
self.verifyCommonKeys(event, expected_event_name="def_param")
|
||||
# Check for 'def_param' event specific fields.
|
||||
self.assertIn("param", event)
|
||||
self.assertIn("value", event)
|
||||
self.assertTrue(event["param"].startswith("repo."))
|
||||
|
||||
def test_def_params_event_no_repo_config(self):
|
||||
"""Test 'def_params' event data won't output non-repo config keys.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
"""
|
||||
config = {
|
||||
"git.foo": "bar",
|
||||
"git.core.foo2": "baz",
|
||||
}
|
||||
self._event_log_module.DefParamRepoEvents(config)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = self._event_log_module.Write(path=tempdir)
|
||||
self._log_data = self.readLog(log_path)
|
||||
|
||||
self.assertEqual(len(self._log_data), 1)
|
||||
self.verifyCommonKeys(self._log_data[0], expected_event_name="version")
|
||||
|
||||
def test_data_event_config(self):
|
||||
"""Test 'data' event data outputs all config keys.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<data event>
|
||||
<data event>
|
||||
"""
|
||||
config = {
|
||||
"git.foo": "bar",
|
||||
"repo.partialclone": "false",
|
||||
"repo.syncstate.superproject.hassuperprojecttag": "true",
|
||||
"repo.syncstate.superproject.sys.argv": ["--", "sync", "protobuf"],
|
||||
}
|
||||
prefix_value = "prefix"
|
||||
self._event_log_module.LogDataConfigEvents(config, prefix_value)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = self._event_log_module.Write(path=tempdir)
|
||||
self._log_data = self.readLog(log_path)
|
||||
|
||||
self.assertEqual(len(self._log_data), 5)
|
||||
data_events = self._log_data[1:]
|
||||
self.verifyCommonKeys(self._log_data[0], expected_event_name="version")
|
||||
|
||||
for event in data_events:
|
||||
self.verifyCommonKeys(event)
|
||||
# Check for 'data' event specific fields.
|
||||
self.assertIn("key", event)
|
||||
self.assertIn("value", event)
|
||||
key = event["key"]
|
||||
key = self.remove_prefix(key, f"{prefix_value}/")
|
||||
value = event["value"]
|
||||
self.assertEqual(
|
||||
self._event_log_module.GetDataEventName(value), event["event"]
|
||||
)
|
||||
self.assertTrue(key in config and value == config[key])
|
||||
|
||||
def test_error_event(self):
|
||||
"""Test and validate 'error' event data is valid.
|
||||
|
||||
Expected event log:
|
||||
<version event>
|
||||
<error event>
|
||||
"""
|
||||
msg = "invalid option: --cahced"
|
||||
fmt = "invalid option: %s"
|
||||
self._event_log_module.ErrorEvent(msg, fmt)
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
log_path = self._event_log_module.Write(path=tempdir)
|
||||
self._log_data = self.readLog(log_path)
|
||||
|
||||
self.assertEqual(len(self._log_data), 2)
|
||||
error_event = self._log_data[1]
|
||||
self.verifyCommonKeys(self._log_data[0], expected_event_name="version")
|
||||
self.verifyCommonKeys(error_event, expected_event_name="error")
|
||||
# Check for 'error' event specific fields.
|
||||
self.assertIn("msg", error_event)
|
||||
self.assertIn("fmt", error_event)
|
||||
self.assertEqual(error_event["msg"], f"RepoErrorEvent:{msg}")
|
||||
self.assertEqual(error_event["fmt"], f"RepoErrorEvent:{fmt}")
|
||||
|
||||
def test_write_with_filename(self):
|
||||
"""Test Write() with a path to a file exits with None."""
|
||||
self.assertIsNone(self._event_log_module.Write(path="path/to/file"))
|
||||
|
||||
def test_write_with_git_config(self):
|
||||
"""Test Write() uses the git config path when 'git config' call
|
||||
succeeds."""
|
||||
with tempfile.TemporaryDirectory(prefix="event_log_tests") as tempdir:
|
||||
with mock.patch.object(
|
||||
self._event_log_module,
|
||||
"_GetEventTargetPath",
|
||||
return_value=tempdir,
|
||||
):
|
||||
self.assertEqual(
|
||||
os.path.dirname(self._event_log_module.Write()), tempdir
|
||||
)
|
||||
|
||||
def test_write_no_git_config(self):
|
||||
"""Test Write() with no git config variable present exits with None."""
|
||||
with mock.patch.object(
|
||||
self._event_log_module, "_GetEventTargetPath", return_value=None
|
||||
def test_write_socket_error_verbose(self) -> None:
|
||||
"""Test Write() printing of socket errors when verbose."""
|
||||
event_log = git_trace2_event_log.EventLog(env={})
|
||||
event_log.verbose = True
|
||||
with contextlib.redirect_stderr(
|
||||
io.StringIO()
|
||||
) as mock_stderr, mock.patch(
|
||||
"socket.socket", side_effect=OSError("Mock error")
|
||||
):
|
||||
self.assertIsNone(self._event_log_module.Write())
|
||||
|
||||
def test_write_non_string(self):
|
||||
"""Test Write() with non-string type for |path| throws TypeError."""
|
||||
with self.assertRaises(TypeError):
|
||||
self._event_log_module.Write(path=1234)
|
||||
|
||||
def test_write_socket(self):
|
||||
"""Test Write() with Unix domain socket for |path| and validate received
|
||||
traces."""
|
||||
received_traces = []
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="test_server_sockets"
|
||||
) as tempdir:
|
||||
socket_path = os.path.join(tempdir, "server.sock")
|
||||
server_ready = threading.Condition()
|
||||
# Start "server" listening on Unix domain socket at socket_path.
|
||||
server_thread = threading.Thread(
|
||||
target=serverLoggingThread,
|
||||
args=(socket_path, server_ready, received_traces),
|
||||
event_log.Write(path="af_unix:stream:/tmp/test_sock")
|
||||
assert (
|
||||
"git trace2 logging failed: Mock error"
|
||||
in mock_stderr.getvalue()
|
||||
)
|
||||
try:
|
||||
server_thread.start()
|
||||
|
||||
with server_ready:
|
||||
server_ready.wait(timeout=120)
|
||||
def test_write_file_error_no_verbose(self) -> None:
|
||||
"""Test Write() suppression of file errors when not verbose."""
|
||||
event_log = git_trace2_event_log.EventLog(env={})
|
||||
event_log.verbose = False
|
||||
with contextlib.redirect_stderr(
|
||||
io.StringIO()
|
||||
) as mock_stderr, mock.patch(
|
||||
"tempfile.NamedTemporaryFile", side_effect=FileExistsError
|
||||
):
|
||||
event_log.Write(path="/tmp")
|
||||
assert mock_stderr.getvalue() == ""
|
||||
|
||||
self._event_log_module.StartEvent([])
|
||||
path = self._event_log_module.Write(
|
||||
path=f"af_unix:{socket_path}"
|
||||
)
|
||||
finally:
|
||||
server_thread.join(timeout=5)
|
||||
|
||||
self.assertEqual(path, f"af_unix:stream:{socket_path}")
|
||||
self.assertEqual(len(received_traces), 2)
|
||||
version_event = json.loads(received_traces[0])
|
||||
start_event = json.loads(received_traces[1])
|
||||
self.verifyCommonKeys(version_event, expected_event_name="version")
|
||||
self.verifyCommonKeys(start_event, expected_event_name="start")
|
||||
# Check for 'start' event specific fields.
|
||||
self.assertIn("argv", start_event)
|
||||
self.assertIsInstance(start_event["argv"], list)
|
||||
def test_write_file_error_verbose(self) -> None:
|
||||
"""Test Write() printing of file errors when verbose."""
|
||||
event_log = git_trace2_event_log.EventLog(env={})
|
||||
event_log.verbose = True
|
||||
with contextlib.redirect_stderr(
|
||||
io.StringIO()
|
||||
) as mock_stderr, mock.patch(
|
||||
"tempfile.NamedTemporaryFile",
|
||||
side_effect=FileExistsError("Mock error"),
|
||||
):
|
||||
event_log.Write(path="/tmp")
|
||||
assert (
|
||||
"git trace2 logging failed: FileExistsError"
|
||||
in mock_stderr.getvalue()
|
||||
)
|
||||
|
||||
+118
-32
@@ -14,42 +14,128 @@
|
||||
|
||||
"""Unittests for the hooks.py module."""
|
||||
|
||||
import unittest
|
||||
from io import StringIO
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import hooks
|
||||
|
||||
|
||||
class RepoHookShebang(unittest.TestCase):
|
||||
"""Check shebang parsing in RepoHook."""
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
(
|
||||
"",
|
||||
"#\n# foo\n",
|
||||
"# Bad shebang in script\n#!/foo\n",
|
||||
),
|
||||
)
|
||||
def test_no_shebang(data: str) -> None:
|
||||
"""Lines w/out shebangs should be rejected."""
|
||||
assert hooks.RepoHook._ExtractInterpFromShebang(data) is None
|
||||
|
||||
def test_no_shebang(self):
|
||||
"""Lines w/out shebangs should be rejected."""
|
||||
DATA = ("", "#\n# foo\n", "# Bad shebang in script\n#!/foo\n")
|
||||
for data in DATA:
|
||||
self.assertIsNone(hooks.RepoHook._ExtractInterpFromShebang(data))
|
||||
|
||||
def test_direct_interp(self):
|
||||
"""Lines whose shebang points directly to the interpreter."""
|
||||
DATA = (
|
||||
("#!/foo", "/foo"),
|
||||
("#! /foo", "/foo"),
|
||||
("#!/bin/foo ", "/bin/foo"),
|
||||
("#! /usr/foo ", "/usr/foo"),
|
||||
("#! /usr/foo -args", "/usr/foo"),
|
||||
)
|
||||
for shebang, interp in DATA:
|
||||
self.assertEqual(
|
||||
hooks.RepoHook._ExtractInterpFromShebang(shebang), interp
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"shebang, interp",
|
||||
(
|
||||
("#!/foo", "/foo"),
|
||||
("#! /foo", "/foo"),
|
||||
("#!/bin/foo ", "/bin/foo"),
|
||||
("#! /usr/foo ", "/usr/foo"),
|
||||
("#! /usr/foo -args", "/usr/foo"),
|
||||
),
|
||||
)
|
||||
def test_direct_interp(shebang: str, interp: str) -> None:
|
||||
"""Lines whose shebang points directly to the interpreter."""
|
||||
assert hooks.RepoHook._ExtractInterpFromShebang(shebang) == interp
|
||||
|
||||
def test_env_interp(self):
|
||||
"""Lines whose shebang launches through `env`."""
|
||||
DATA = (
|
||||
("#!/usr/bin/env foo", "foo"),
|
||||
("#!/bin/env foo", "foo"),
|
||||
("#! /bin/env /bin/foo ", "/bin/foo"),
|
||||
)
|
||||
for shebang, interp in DATA:
|
||||
self.assertEqual(
|
||||
hooks.RepoHook._ExtractInterpFromShebang(shebang), interp
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"shebang, interp",
|
||||
(
|
||||
("#!/usr/bin/env foo", "foo"),
|
||||
("#!/bin/env foo", "foo"),
|
||||
("#! /bin/env /bin/foo ", "/bin/foo"),
|
||||
),
|
||||
)
|
||||
def test_env_interp(shebang: str, interp: str) -> None:
|
||||
"""Lines whose shebang launches through `env`."""
|
||||
assert hooks.RepoHook._ExtractInterpFromShebang(shebang) == interp
|
||||
|
||||
|
||||
def test_post_sync_argument_validation() -> None:
|
||||
"""Test that post-sync hook requires exact API arguments."""
|
||||
|
||||
class FakeProject:
|
||||
|
||||
def __init__(self):
|
||||
self.worktree = "/some/path"
|
||||
self.enabled_repo_hooks = ["post-sync"]
|
||||
|
||||
hook = hooks.RepoHook(
|
||||
hook_type="post-sync",
|
||||
hooks_project=FakeProject(),
|
||||
repo_topdir="/topdir",
|
||||
manifest_url="https://gerrit",
|
||||
allow_all_hooks=True,
|
||||
)
|
||||
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = StringIO()
|
||||
|
||||
try:
|
||||
# Call with missing arg `sync_duration_seconds`
|
||||
res = hook.Run(repo_topdir="/topdir")
|
||||
assert res is False
|
||||
assert "hook 'post-sync' called incorrectly" in sys.stderr.getvalue()
|
||||
|
||||
# Mock _CheckHook and _ExecuteHook to test success path
|
||||
hook._CheckHook = lambda: None
|
||||
|
||||
executed_kwargs = {}
|
||||
|
||||
def fake_execute(**kw):
|
||||
executed_kwargs.update(kw)
|
||||
|
||||
hook._ExecuteHook = fake_execute
|
||||
|
||||
res = hook.Run(repo_topdir="/topdir", sync_duration_seconds=12.345)
|
||||
assert res is True
|
||||
assert executed_kwargs.get("sync_duration_seconds") == 12.345
|
||||
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("yes_val", (True, False))
|
||||
def test_repo_upload_yes_arg(tmp_path, yes_val: bool) -> None:
|
||||
"""Test that yes is passed in kwargs during hook execution."""
|
||||
|
||||
class FakeProject:
|
||||
def __init__(self, worktree):
|
||||
self.worktree = worktree
|
||||
self.enabled_repo_hooks = ["pre-upload"]
|
||||
self.config = None
|
||||
|
||||
hook_file = tmp_path / "pre-upload.py"
|
||||
|
||||
hook_content = """
|
||||
def main(project_list, **kwargs):
|
||||
project_list.append(kwargs.get("yes"))
|
||||
"""
|
||||
hook_file.write_text(hook_content)
|
||||
|
||||
hook = hooks.RepoHook(
|
||||
hook_type="pre-upload",
|
||||
hooks_project=FakeProject(str(tmp_path)),
|
||||
repo_topdir=str(tmp_path),
|
||||
manifest_url="https://gerrit",
|
||||
allow_all_hooks=True,
|
||||
yes=yes_val,
|
||||
)
|
||||
|
||||
project_list = []
|
||||
res = hook.Run(project_list=project_list, worktree_list=[])
|
||||
|
||||
assert res is True
|
||||
assert project_list == [yes_val]
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# 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 main repo script and subcommand routing."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from main import _Repo
|
||||
|
||||
|
||||
@pytest.fixture(name="repo")
|
||||
def fixture_repo():
|
||||
repo = _Repo("repodir")
|
||||
# Overriding the command list here ensures that we are only testing
|
||||
# against a fixed set of commands, reducing fragility to new
|
||||
# subcommands being added to the main repo tool.
|
||||
repo.commands = {"start": None, "sync": None, "smart": None}
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_config")
|
||||
def fixture_mock_config():
|
||||
return mock.MagicMock()
|
||||
|
||||
|
||||
@mock.patch("time.sleep")
|
||||
def test_autocorrect_delay(mock_sleep, repo, mock_config):
|
||||
"""Test autocorrect with positive delay."""
|
||||
mock_config.GetString.return_value = "10"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
mock_config.GetString.assert_called_with("help.autocorrect")
|
||||
mock_sleep.assert_called_with(1.0)
|
||||
assert res == "start"
|
||||
|
||||
|
||||
@mock.patch("time.sleep")
|
||||
def test_autocorrect_delay_one(mock_sleep, repo, mock_config):
|
||||
"""Test autocorrect with '1' (0.1s delay, not immediate)."""
|
||||
mock_config.GetString.return_value = "1"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
mock_sleep.assert_called_with(0.1)
|
||||
assert res == "start"
|
||||
|
||||
|
||||
@mock.patch("time.sleep", side_effect=KeyboardInterrupt())
|
||||
def test_autocorrect_delay_interrupt(mock_sleep, repo, mock_config):
|
||||
"""Test autocorrect handles KeyboardInterrupt during delay."""
|
||||
mock_config.GetString.return_value = "10"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
mock_sleep.assert_called_with(1.0)
|
||||
assert res is None
|
||||
|
||||
|
||||
@mock.patch("time.sleep")
|
||||
def test_autocorrect_immediate(mock_sleep, repo, mock_config):
|
||||
"""Test autocorrect with immediate/negative delay."""
|
||||
# Test numeric negative.
|
||||
mock_config.GetString.return_value = "-1"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
mock_sleep.assert_not_called()
|
||||
assert res == "start"
|
||||
|
||||
# Test string boolean "true".
|
||||
mock_config.GetString.return_value = "true"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
mock_sleep.assert_not_called()
|
||||
assert res == "start"
|
||||
|
||||
# Test string boolean "yes".
|
||||
mock_config.GetString.return_value = "YES"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
mock_sleep.assert_not_called()
|
||||
assert res == "start"
|
||||
|
||||
# Test string boolean "immediate".
|
||||
mock_config.GetString.return_value = "Immediate"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
mock_sleep.assert_not_called()
|
||||
assert res == "start"
|
||||
|
||||
|
||||
def test_autocorrect_zero_or_show(repo, mock_config):
|
||||
"""Test autocorrect with zero delay (suggestions only)."""
|
||||
# Test numeric zero.
|
||||
mock_config.GetString.return_value = "0"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
assert res is None
|
||||
|
||||
# Test string boolean "false".
|
||||
mock_config.GetString.return_value = "False"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
assert res is None
|
||||
|
||||
# Test string boolean "show".
|
||||
mock_config.GetString.return_value = "show"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_autocorrect_never(repo, mock_config):
|
||||
"""Test autocorrect with 'never'."""
|
||||
mock_config.GetString.return_value = "never"
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
assert res is None
|
||||
|
||||
|
||||
@mock.patch("builtins.input", return_value="y")
|
||||
def test_autocorrect_prompt_yes(mock_input, repo, mock_config):
|
||||
"""Test autocorrect with prompt and user answers yes."""
|
||||
mock_config.GetString.return_value = "prompt"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
assert res == "start"
|
||||
|
||||
|
||||
@mock.patch("builtins.input", return_value="n")
|
||||
def test_autocorrect_prompt_no(mock_input, repo, mock_config):
|
||||
"""Test autocorrect with prompt and user answers no."""
|
||||
mock_config.GetString.return_value = "prompt"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
assert res is None
|
||||
|
||||
|
||||
@mock.patch("builtins.input", return_value="y")
|
||||
def test_autocorrect_multiple_candidates(mock_input, repo, mock_config):
|
||||
"""Test autocorrect with multiple matches forces a prompt."""
|
||||
mock_config.GetString.return_value = "10" # Normally just delay
|
||||
|
||||
# 'snart' matches both 'start' and 'smart' with > 0.7 ratio
|
||||
res = repo._autocorrect_command_name("snart", mock_config)
|
||||
|
||||
# Because there are multiple candidates, it should prompt
|
||||
mock_input.assert_called_once()
|
||||
assert res == "start"
|
||||
|
||||
|
||||
@mock.patch("builtins.input", side_effect=KeyboardInterrupt())
|
||||
def test_autocorrect_prompt_interrupt(mock_input, repo, mock_config):
|
||||
"""Test autocorrect with prompt and user interrupts."""
|
||||
mock_config.GetString.return_value = "prompt"
|
||||
|
||||
res = repo._autocorrect_command_name("tart", mock_config)
|
||||
|
||||
assert res is None
|
||||
+639
-465
File diff suppressed because it is too large
Load Diff
@@ -14,39 +14,103 @@
|
||||
|
||||
"""Unittests for the platform_utils.py module."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import platform_utils
|
||||
|
||||
|
||||
class RemoveTests(unittest.TestCase):
|
||||
"""Check remove() helper."""
|
||||
def test_remove_missing_ok(tmp_path: Path) -> None:
|
||||
"""Check missing_ok handling."""
|
||||
path = tmp_path / "test"
|
||||
|
||||
def testMissingOk(self):
|
||||
"""Check missing_ok handling."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "test")
|
||||
# Should not fail.
|
||||
platform_utils.remove(path, missing_ok=True)
|
||||
|
||||
# Should not fail.
|
||||
platform_utils.remove(path, missing_ok=True)
|
||||
# Should fail.
|
||||
with pytest.raises(OSError):
|
||||
platform_utils.remove(path)
|
||||
with pytest.raises(OSError):
|
||||
platform_utils.remove(path, missing_ok=False)
|
||||
|
||||
# Should fail.
|
||||
self.assertRaises(OSError, platform_utils.remove, path)
|
||||
self.assertRaises(
|
||||
OSError, platform_utils.remove, path, missing_ok=False
|
||||
)
|
||||
# Should not fail if it exists.
|
||||
path.touch()
|
||||
platform_utils.remove(path, missing_ok=True)
|
||||
assert not path.exists()
|
||||
|
||||
# Should not fail if it exists.
|
||||
open(path, "w").close()
|
||||
platform_utils.remove(path, missing_ok=True)
|
||||
self.assertFalse(os.path.exists(path))
|
||||
path.touch()
|
||||
platform_utils.remove(path)
|
||||
assert not path.exists()
|
||||
|
||||
open(path, "w").close()
|
||||
platform_utils.remove(path)
|
||||
self.assertFalse(os.path.exists(path))
|
||||
path.touch()
|
||||
platform_utils.remove(path, missing_ok=False)
|
||||
assert not path.exists()
|
||||
|
||||
open(path, "w").close()
|
||||
platform_utils.remove(path, missing_ok=False)
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
def test_removedirs_nonexistent(tmp_path: Path) -> None:
|
||||
"""removedirs should silently succeed on nonexistent paths."""
|
||||
platform_utils.removedirs(tmp_path / "does-not-exist")
|
||||
|
||||
|
||||
def test_removedirs_symlink(tmp_path: Path) -> None:
|
||||
"""removedirs should remove a symlink."""
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to("target")
|
||||
platform_utils.removedirs(link)
|
||||
assert not link.exists()
|
||||
|
||||
|
||||
def test_removedirs_empty_dir(tmp_path: Path) -> None:
|
||||
"""removedirs should remove an empty directory."""
|
||||
d = tmp_path / "empty"
|
||||
d.mkdir()
|
||||
platform_utils.removedirs(d)
|
||||
assert not d.exists()
|
||||
|
||||
|
||||
def test_removedirs_nested_empty_dirs(tmp_path: Path) -> None:
|
||||
"""removedirs should remove nested empty directories."""
|
||||
d = tmp_path / "a" / "b" / "c"
|
||||
d.mkdir(parents=True)
|
||||
platform_utils.removedirs(tmp_path / "a")
|
||||
assert not (tmp_path / "a").exists()
|
||||
|
||||
|
||||
def test_removedirs_symlinks_inside_dir(tmp_path: Path) -> None:
|
||||
"""removedirs should remove symlinks inside a directory."""
|
||||
d = tmp_path / "dir"
|
||||
d.mkdir()
|
||||
(d / "link1").symlink_to("target1")
|
||||
(d / "link2").symlink_to("target2")
|
||||
platform_utils.removedirs(d)
|
||||
assert not d.exists()
|
||||
|
||||
|
||||
def test_removedirs_preserves_user_files(tmp_path: Path) -> None:
|
||||
"""removedirs should not delete regular files or their parent dirs."""
|
||||
d = tmp_path / "dir"
|
||||
d.mkdir()
|
||||
(d / "link").symlink_to("target")
|
||||
(d / "user-file.txt").write_text("keep me")
|
||||
platform_utils.removedirs(d)
|
||||
assert d.exists()
|
||||
assert not (d / "link").exists()
|
||||
assert (d / "user-file.txt").read_text() == "keep me"
|
||||
|
||||
|
||||
def test_removedirs_deep_nested_with_symlinks(tmp_path: Path) -> None:
|
||||
"""removedirs should handle deep nesting: sub/dir/target."""
|
||||
d = tmp_path / "sub" / "dir"
|
||||
d.mkdir(parents=True)
|
||||
(d / "link").symlink_to("target")
|
||||
platform_utils.removedirs(tmp_path / "sub")
|
||||
assert not (tmp_path / "sub").exists()
|
||||
|
||||
|
||||
def test_removedirs_regular_file_noop(tmp_path: Path) -> None:
|
||||
"""removedirs should not delete a regular file."""
|
||||
f = tmp_path / "file.txt"
|
||||
f.write_text("data")
|
||||
platform_utils.removedirs(f)
|
||||
assert f.exists()
|
||||
|
||||
+1047
-22
File diff suppressed because it is too large
Load Diff
+77
-71
@@ -12,90 +12,96 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unit test for repo_logging module."""
|
||||
"""Unittests for the repo_logging.py module."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import logging
|
||||
import unittest
|
||||
import re
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from color import SetDefaultColoring
|
||||
from error import RepoExitError
|
||||
from repo_logging import RepoLogger
|
||||
|
||||
|
||||
class TestRepoLogger(unittest.TestCase):
|
||||
@mock.patch.object(RepoLogger, "error")
|
||||
def test_log_aggregated_errors_logs_aggregated_errors(self, mock_error):
|
||||
"""Test if log_aggregated_errors logs a list of aggregated errors."""
|
||||
logger = RepoLogger(__name__)
|
||||
logger.log_aggregated_errors(
|
||||
RepoExitError(
|
||||
aggregate_errors=[
|
||||
Exception("foo"),
|
||||
Exception("bar"),
|
||||
Exception("baz"),
|
||||
Exception("hello"),
|
||||
Exception("world"),
|
||||
Exception("test"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
mock_error.assert_has_calls(
|
||||
[
|
||||
mock.call("=" * 80),
|
||||
mock.call(
|
||||
"Repo command failed due to the following `%s` errors:",
|
||||
"RepoExitError",
|
||||
),
|
||||
mock.call("foo\nbar\nbaz\nhello\nworld"),
|
||||
mock.call("+%d additional errors...", 1),
|
||||
@mock.patch.object(RepoLogger, "error")
|
||||
def test_log_aggregated_errors_logs_aggregated_errors(mock_error) -> None:
|
||||
"""Test if log_aggregated_errors logs a list of aggregated errors."""
|
||||
logger = RepoLogger(__name__)
|
||||
logger.log_aggregated_errors(
|
||||
RepoExitError(
|
||||
aggregate_errors=[
|
||||
Exception("foo"),
|
||||
Exception("bar"),
|
||||
Exception("baz"),
|
||||
Exception("hello"),
|
||||
Exception("world"),
|
||||
Exception("test"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
@mock.patch.object(RepoLogger, "error")
|
||||
def test_log_aggregated_errors_logs_single_error(self, mock_error):
|
||||
"""Test if log_aggregated_errors logs empty aggregated_errors."""
|
||||
mock_error.assert_has_calls(
|
||||
[
|
||||
mock.call("=" * 80),
|
||||
mock.call(
|
||||
"Repo command failed due to the following `%s` errors:",
|
||||
"RepoExitError",
|
||||
),
|
||||
mock.call("foo\nbar\nbaz\nhello\nworld"),
|
||||
mock.call("+%d additional errors...", 1),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(RepoLogger, "error")
|
||||
def test_log_aggregated_errors_logs_single_error(mock_error) -> None:
|
||||
"""Test if log_aggregated_errors logs empty aggregated_errors."""
|
||||
logger = RepoLogger(__name__)
|
||||
logger.log_aggregated_errors(RepoExitError())
|
||||
|
||||
mock_error.assert_has_calls(
|
||||
[
|
||||
mock.call("=" * 80),
|
||||
mock.call("Repo command failed: %s", "RepoExitError"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"level",
|
||||
(
|
||||
logging.INFO,
|
||||
logging.WARN,
|
||||
logging.ERROR,
|
||||
),
|
||||
)
|
||||
def test_log_with_format_string(level: int) -> None:
|
||||
"""Test different log levels with format strings."""
|
||||
name = logging.getLevelName(level)
|
||||
|
||||
# Set color output to "always" for consistent test results.
|
||||
# This ensures the logger's behavior is uniform across different
|
||||
# environments and git configurations.
|
||||
SetDefaultColoring("always")
|
||||
|
||||
# Regex pattern to match optional ANSI color codes.
|
||||
# \033 - Escape character
|
||||
# \[ - Opening square bracket
|
||||
# [0-9;]* - Zero or more digits or semicolons
|
||||
# m - Ending 'm' character
|
||||
# ? - Makes the entire group optional
|
||||
opt_color = r"(\033\[[0-9;]*m)?"
|
||||
|
||||
output = io.StringIO()
|
||||
|
||||
with contextlib.redirect_stderr(output):
|
||||
logger = RepoLogger(__name__)
|
||||
logger.log_aggregated_errors(RepoExitError())
|
||||
logger.log(level, "%s", "100% pass")
|
||||
|
||||
mock_error.assert_has_calls(
|
||||
[
|
||||
mock.call("=" * 80),
|
||||
mock.call("Repo command failed: %s", "RepoExitError"),
|
||||
]
|
||||
)
|
||||
|
||||
def test_log_with_format_string(self):
|
||||
"""Test different log levels with format strings."""
|
||||
|
||||
# Set color output to "always" for consistent test results.
|
||||
# This ensures the logger's behavior is uniform across different
|
||||
# environments and git configurations.
|
||||
SetDefaultColoring("always")
|
||||
|
||||
# Regex pattern to match optional ANSI color codes.
|
||||
# \033 - Escape character
|
||||
# \[ - Opening square bracket
|
||||
# [0-9;]* - Zero or more digits or semicolons
|
||||
# m - Ending 'm' character
|
||||
# ? - Makes the entire group optional
|
||||
opt_color = r"(\033\[[0-9;]*m)?"
|
||||
|
||||
for level in (logging.INFO, logging.WARN, logging.ERROR):
|
||||
name = logging.getLevelName(level)
|
||||
|
||||
with self.subTest(level=level, name=name):
|
||||
output = io.StringIO()
|
||||
|
||||
with contextlib.redirect_stderr(output):
|
||||
logger = RepoLogger(__name__)
|
||||
logger.log(level, "%s", "100% pass")
|
||||
|
||||
self.assertRegex(
|
||||
output.getvalue().strip(),
|
||||
f"^{opt_color}100% pass{opt_color}$",
|
||||
f"failed for level {name}",
|
||||
)
|
||||
assert re.search(
|
||||
f"^{opt_color}100% pass{opt_color}$", output.getvalue().strip()
|
||||
), f"failed for level {name}"
|
||||
|
||||
+24
-33
@@ -15,46 +15,37 @@
|
||||
"""Unittests for the repo_trace.py module."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import repo_trace
|
||||
|
||||
|
||||
class TraceTests(unittest.TestCase):
|
||||
def test_trace_max_size_enforced(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Check Trace behavior."""
|
||||
content = "git chicken"
|
||||
|
||||
def testTrace_MaxSizeEnforced(self):
|
||||
content = "git chicken"
|
||||
with repo_trace.Trace(content, first_trace=True):
|
||||
pass
|
||||
first_trace_size = os.path.getsize(repo_trace._TRACE_FILE)
|
||||
|
||||
with repo_trace.Trace(content, first_trace=True):
|
||||
pass
|
||||
first_trace_size = os.path.getsize(repo_trace._TRACE_FILE)
|
||||
with repo_trace.Trace(content):
|
||||
pass
|
||||
assert os.path.getsize(repo_trace._TRACE_FILE) > first_trace_size
|
||||
|
||||
with repo_trace.Trace(content):
|
||||
pass
|
||||
self.assertGreater(
|
||||
os.path.getsize(repo_trace._TRACE_FILE), first_trace_size
|
||||
)
|
||||
# Check we clear everything if the last chunk is larger than _MAX_SIZE.
|
||||
monkeypatch.setattr(repo_trace, "_MAX_SIZE", 0)
|
||||
with repo_trace.Trace(content, first_trace=True):
|
||||
pass
|
||||
assert os.path.getsize(repo_trace._TRACE_FILE) == first_trace_size
|
||||
|
||||
# Check we clear everything is the last chunk is larger than _MAX_SIZE.
|
||||
with mock.patch("repo_trace._MAX_SIZE", 0):
|
||||
with repo_trace.Trace(content, first_trace=True):
|
||||
pass
|
||||
self.assertEqual(
|
||||
first_trace_size, os.path.getsize(repo_trace._TRACE_FILE)
|
||||
)
|
||||
# Check we only clear the chunks we need to.
|
||||
new_max = (first_trace_size + 1) / (1024 * 1024)
|
||||
monkeypatch.setattr(repo_trace, "_MAX_SIZE", new_max)
|
||||
with repo_trace.Trace(content, first_trace=True):
|
||||
pass
|
||||
assert os.path.getsize(repo_trace._TRACE_FILE) == first_trace_size * 2
|
||||
|
||||
# Check we only clear the chunks we need to.
|
||||
repo_trace._MAX_SIZE = (first_trace_size + 1) / (1024 * 1024)
|
||||
with repo_trace.Trace(content, first_trace=True):
|
||||
pass
|
||||
self.assertEqual(
|
||||
first_trace_size * 2, os.path.getsize(repo_trace._TRACE_FILE)
|
||||
)
|
||||
|
||||
with repo_trace.Trace(content, first_trace=True):
|
||||
pass
|
||||
self.assertEqual(
|
||||
first_trace_size * 2, os.path.getsize(repo_trace._TRACE_FILE)
|
||||
)
|
||||
with repo_trace.Trace(content, first_trace=True):
|
||||
pass
|
||||
assert os.path.getsize(repo_trace._TRACE_FILE) == first_trace_size * 2
|
||||
|
||||
+64
-45
@@ -16,65 +16,84 @@
|
||||
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
import unittest
|
||||
from typing import Tuple
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import ssh
|
||||
|
||||
|
||||
class SshTests(unittest.TestCase):
|
||||
"""Tests the ssh functions."""
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_ssh_version_cache() -> None:
|
||||
"""Clear the ssh version cache before each test."""
|
||||
ssh.version.cache_clear()
|
||||
|
||||
def test_parse_ssh_version(self):
|
||||
"""Check _parse_ssh_version() handling."""
|
||||
ver = ssh._parse_ssh_version("Unknown\n")
|
||||
self.assertEqual(ver, ())
|
||||
ver = ssh._parse_ssh_version("OpenSSH_1.0\n")
|
||||
self.assertEqual(ver, (1, 0))
|
||||
ver = ssh._parse_ssh_version(
|
||||
"OpenSSH_6.6.1p1 Ubuntu-2ubuntu2.13, OpenSSL 1.0.1f 6 Jan 2014\n"
|
||||
)
|
||||
self.assertEqual(ver, (6, 6, 1))
|
||||
ver = ssh._parse_ssh_version(
|
||||
"OpenSSH_7.6p1 Ubuntu-4ubuntu0.3, OpenSSL 1.0.2n 7 Dec 2017\n"
|
||||
)
|
||||
self.assertEqual(ver, (7, 6))
|
||||
ver = ssh._parse_ssh_version("OpenSSH_9.0p1, LibreSSL 3.3.6\n")
|
||||
self.assertEqual(ver, (9, 0))
|
||||
|
||||
def test_version(self):
|
||||
"""Check version() handling."""
|
||||
with mock.patch("ssh._run_ssh_version", return_value="OpenSSH_1.2\n"):
|
||||
self.assertEqual(ssh.version(), (1, 2))
|
||||
@pytest.mark.parametrize(
|
||||
"input_str, expected",
|
||||
(
|
||||
("Unknown\n", ()),
|
||||
("OpenSSH_1.0\n", (1, 0)),
|
||||
(
|
||||
"OpenSSH_6.6.1p1 Ubuntu-2ubuntu2.13, OpenSSL 1.0.1f 6 Jan 2014\n",
|
||||
(6, 6, 1),
|
||||
),
|
||||
(
|
||||
"OpenSSH_7.6p1 Ubuntu-4ubuntu0.3, OpenSSL 1.0.2n 7 Dec 2017\n",
|
||||
(7, 6),
|
||||
),
|
||||
("OpenSSH_9.0p1, LibreSSL 3.3.6\n", (9, 0)),
|
||||
),
|
||||
)
|
||||
def test_parse_ssh_version(input_str: str, expected: Tuple[int, ...]) -> None:
|
||||
"""Check _parse_ssh_version() handling."""
|
||||
assert ssh._parse_ssh_version(input_str) == expected
|
||||
|
||||
def test_context_manager_empty(self):
|
||||
"""Verify context manager with no clients works correctly."""
|
||||
with multiprocessing.Manager() as manager:
|
||||
with ssh.ProxyManager(manager):
|
||||
pass
|
||||
|
||||
def test_context_manager_child_cleanup(self):
|
||||
"""Verify orphaned clients & masters get cleaned up."""
|
||||
with multiprocessing.Manager() as manager:
|
||||
def test_version() -> None:
|
||||
"""Check version() handling."""
|
||||
with mock.patch("ssh._run_ssh_version", return_value="OpenSSH_1.2\n"):
|
||||
assert ssh.version() == (1, 2)
|
||||
|
||||
|
||||
def test_context_manager_empty() -> None:
|
||||
"""Verify context manager with no clients works correctly."""
|
||||
with multiprocessing.Manager() as manager:
|
||||
with ssh.ProxyManager(manager):
|
||||
pass
|
||||
|
||||
|
||||
def test_context_manager_child_cleanup() -> None:
|
||||
"""Verify orphaned clients & masters get cleaned up."""
|
||||
with multiprocessing.Manager() as manager:
|
||||
with mock.patch("ssh.version", return_value=(1, 2)):
|
||||
with ssh.ProxyManager(manager) as ssh_proxy:
|
||||
client = subprocess.Popen(["sleep", "964853320"])
|
||||
ssh_proxy.add_client(client)
|
||||
master = subprocess.Popen(["sleep", "964853321"])
|
||||
ssh_proxy.add_master(master)
|
||||
# If the process still exists, these will throw timeout errors.
|
||||
client.wait(0)
|
||||
master.wait(0)
|
||||
# If the process still exists, these will throw timeout errors.
|
||||
client.wait(0)
|
||||
master.wait(0)
|
||||
|
||||
def test_ssh_sock(self):
|
||||
"""Check sock() function."""
|
||||
manager = multiprocessing.Manager()
|
||||
|
||||
def test_ssh_sock(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Check sock() function."""
|
||||
with multiprocessing.Manager() as manager:
|
||||
proxy = ssh.ProxyManager(manager)
|
||||
with mock.patch("tempfile.mkdtemp", return_value="/tmp/foo"):
|
||||
# Old ssh version uses port.
|
||||
with mock.patch("ssh.version", return_value=(6, 6)):
|
||||
self.assertTrue(proxy.sock().endswith("%p"))
|
||||
monkeypatch.setattr(
|
||||
"tempfile.mkdtemp", lambda *args, **kwargs: "/tmp/foo"
|
||||
)
|
||||
|
||||
proxy._sock_path = None
|
||||
# New ssh version uses hash.
|
||||
with mock.patch("ssh.version", return_value=(6, 7)):
|
||||
self.assertTrue(proxy.sock().endswith("%C"))
|
||||
# Old ssh version uses port.
|
||||
with mock.patch("ssh.version", return_value=(6, 6)):
|
||||
with proxy as ssh_proxy:
|
||||
assert ssh_proxy.sock().endswith("%p")
|
||||
|
||||
proxy._sock_path = None
|
||||
# New ssh version uses hash.
|
||||
with mock.patch("ssh.version", return_value=(6, 7)):
|
||||
with proxy as ssh_proxy:
|
||||
assert ssh_proxy.sock().endswith("%C")
|
||||
proxy._sock_path = None
|
||||
|
||||
+128
-87
@@ -15,123 +15,164 @@
|
||||
"""Unittests for the subcmds module (mostly __init__.py than subcommands)."""
|
||||
|
||||
import optparse
|
||||
import unittest
|
||||
from typing import Type
|
||||
|
||||
import pytest
|
||||
|
||||
from command import Command
|
||||
import subcmds
|
||||
|
||||
|
||||
class AllCommands(unittest.TestCase):
|
||||
"""Check registered all_commands."""
|
||||
# NB: We don't test all subcommands as we want to avoid "change detection"
|
||||
# tests, so we just look for the most common/important ones here that are
|
||||
# unlikely to ever change.
|
||||
@pytest.mark.parametrize(
|
||||
"cmd", ("cherry-pick", "help", "init", "start", "sync", "upload")
|
||||
)
|
||||
def test_required_basic(cmd: str) -> None:
|
||||
"""Basic checking of registered commands."""
|
||||
assert cmd in subcmds.all_commands
|
||||
|
||||
def test_required_basic(self):
|
||||
"""Basic checking of registered commands."""
|
||||
# NB: We don't test all subcommands as we want to avoid "change
|
||||
# detection" tests, so we just look for the most common/important ones
|
||||
# here that are unlikely to ever change.
|
||||
for cmd in {"cherry-pick", "help", "init", "start", "sync", "upload"}:
|
||||
self.assertIn(cmd, subcmds.all_commands)
|
||||
|
||||
def test_naming(self):
|
||||
"""Verify we don't add things that we shouldn't."""
|
||||
for cmd in subcmds.all_commands:
|
||||
# Reject filename suffixes like "help.py".
|
||||
self.assertNotIn(".", cmd)
|
||||
@pytest.mark.parametrize("name", subcmds.all_commands.keys())
|
||||
def test_naming(name: str) -> None:
|
||||
"""Verify we don't add things that we shouldn't."""
|
||||
# Reject filename suffixes like "help.py".
|
||||
assert "." not in name
|
||||
|
||||
# Make sure all '_' were converted to '-'.
|
||||
self.assertNotIn("_", cmd)
|
||||
# Make sure all '_' were converted to '-'.
|
||||
assert "_" not in name
|
||||
|
||||
# Reject internal python paths like "__init__".
|
||||
self.assertFalse(cmd.startswith("__"))
|
||||
# Reject internal python paths like "__init__".
|
||||
assert not name.startswith("__")
|
||||
|
||||
def test_help_desc_style(self):
|
||||
"""Force some consistency in option descriptions.
|
||||
|
||||
Python's optparse & argparse has a few default options like --help.
|
||||
Their option description text uses lowercase sentence fragments, so
|
||||
enforce our options follow the same style so UI is consistent.
|
||||
@pytest.mark.parametrize("name, cls", subcmds.all_commands.items())
|
||||
def test_help_desc_style(name: str, cls: Type[Command]) -> None:
|
||||
"""Force some consistency in option descriptions.
|
||||
|
||||
We enforce:
|
||||
* Text starts with lowercase.
|
||||
* Text doesn't end with period.
|
||||
"""
|
||||
for name, cls in subcmds.all_commands.items():
|
||||
cmd = cls()
|
||||
parser = cmd.OptionParser
|
||||
for option in parser.option_list:
|
||||
if option.help == optparse.SUPPRESS_HELP:
|
||||
continue
|
||||
Python's optparse & argparse has a few default options like --help.
|
||||
Their option description text uses lowercase sentence fragments, so
|
||||
enforce our options follow the same style so UI is consistent.
|
||||
|
||||
c = option.help[0]
|
||||
self.assertEqual(
|
||||
c.lower(),
|
||||
c,
|
||||
msg=f"subcmds/{name}.py: {option.get_opt_string()}: "
|
||||
f'help text should start with lowercase: "{option.help}"',
|
||||
)
|
||||
We enforce:
|
||||
* Text starts with lowercase.
|
||||
* Text doesn't end with period.
|
||||
"""
|
||||
cmd = cls()
|
||||
parser = cmd.OptionParser
|
||||
for option in parser.option_list:
|
||||
if option.help == optparse.SUPPRESS_HELP or not option.help:
|
||||
continue
|
||||
|
||||
self.assertNotEqual(
|
||||
option.help[-1],
|
||||
".",
|
||||
msg=f"subcmds/{name}.py: {option.get_opt_string()}: "
|
||||
f'help text should not end in a period: "{option.help}"',
|
||||
)
|
||||
c = option.help[0]
|
||||
assert c.lower() == c, (
|
||||
f"subcmds/{name}.py: {option.get_opt_string()}: "
|
||||
f'help text should start with lowercase: "{option.help}"'
|
||||
)
|
||||
|
||||
def test_cli_option_style(self):
|
||||
"""Force some consistency in option flags."""
|
||||
for name, cls in subcmds.all_commands.items():
|
||||
cmd = cls()
|
||||
parser = cmd.OptionParser
|
||||
for option in parser.option_list:
|
||||
for opt in option._long_opts:
|
||||
self.assertNotIn(
|
||||
"_",
|
||||
opt,
|
||||
msg=f"subcmds/{name}.py: {opt}: only use dashes in "
|
||||
"options, not underscores",
|
||||
)
|
||||
assert option.help[-1] != ".", (
|
||||
f"subcmds/{name}.py: {option.get_opt_string()}: "
|
||||
f'help text should not end in a period: "{option.help}"'
|
||||
)
|
||||
|
||||
def test_cli_option_dest(self):
|
||||
"""Block redundant dest= arguments."""
|
||||
|
||||
def _check_dest(opt):
|
||||
"""Check the dest= setting."""
|
||||
# If the destination is not set, nothing to check.
|
||||
# If long options are not set, then there's no implicit destination.
|
||||
# If callback is used, then a destination might be needed because
|
||||
# optparse cannot assume a value is always stored.
|
||||
if opt.dest is None or not opt._long_opts or opt.callback:
|
||||
return
|
||||
@pytest.mark.parametrize("name, cls", subcmds.all_commands.items())
|
||||
def test_cli_option_style(name: str, cls: Type[Command]) -> None:
|
||||
"""Force some consistency in option flags."""
|
||||
cmd = cls()
|
||||
parser = cmd.OptionParser
|
||||
for option in parser.option_list:
|
||||
for opt in option._long_opts:
|
||||
assert "_" not in opt, (
|
||||
f"subcmds/{name}.py: {opt}: only use dashes in "
|
||||
"options, not underscores"
|
||||
)
|
||||
|
||||
long = opt._long_opts[0]
|
||||
assert long.startswith("--")
|
||||
# This matches optparse's behavior.
|
||||
implicit_dest = long[2:].replace("-", "_")
|
||||
if implicit_dest == opt.dest:
|
||||
bad_opts.append((str(opt), opt.dest))
|
||||
|
||||
# Hook the option check list.
|
||||
optparse.Option.CHECK_METHODS.insert(0, _check_dest)
|
||||
def test_cli_option_dest() -> None:
|
||||
"""Block redundant dest= arguments."""
|
||||
bad_opts: list[tuple[str, str]] = []
|
||||
|
||||
def _check_dest(opt: optparse.Option) -> None:
|
||||
"""Check the dest= setting."""
|
||||
# If the destination is not set, nothing to check.
|
||||
# If long options are not set, then there's no implicit destination.
|
||||
# If callback is used, then a destination might be needed because
|
||||
# optparse cannot assume a value is always stored.
|
||||
if opt.dest is None or not opt._long_opts or opt.callback:
|
||||
return
|
||||
|
||||
long = opt._long_opts[0]
|
||||
assert long.startswith("--")
|
||||
# This matches optparse's behavior.
|
||||
implicit_dest = long[2:].replace("-", "_")
|
||||
if implicit_dest == opt.dest:
|
||||
bad_opts.append((str(opt), opt.dest))
|
||||
|
||||
# Hook the option check list.
|
||||
optparse.Option.CHECK_METHODS.insert(0, _check_dest)
|
||||
|
||||
try:
|
||||
# Gather all the bad options up front so people can see all bad options
|
||||
# instead of failing at the first one.
|
||||
all_bad_opts = {}
|
||||
all_bad_opts: dict[str, list[tuple[str, str]]] = {}
|
||||
for name, cls in subcmds.all_commands.items():
|
||||
bad_opts = all_bad_opts[name] = []
|
||||
bad_opts = []
|
||||
cmd = cls()
|
||||
# Trigger construction of parser.
|
||||
cmd.OptionParser
|
||||
_ = cmd.OptionParser
|
||||
all_bad_opts[name] = bad_opts
|
||||
|
||||
errmsg = None
|
||||
for name, bad_opts in sorted(all_bad_opts.items()):
|
||||
if bad_opts:
|
||||
errmsg = ""
|
||||
for name, bad_opts_list in sorted(all_bad_opts.items()):
|
||||
if bad_opts_list:
|
||||
if not errmsg:
|
||||
errmsg = "Omit redundant dest= when defining options.\n"
|
||||
errmsg += f"\nSubcommand {name} (subcmds/{name}.py):\n"
|
||||
errmsg += "".join(
|
||||
f" {opt}: dest='{dest}'\n" for opt, dest in bad_opts
|
||||
f" {opt}: dest='{dest}'\n" for opt, dest in bad_opts_list
|
||||
)
|
||||
if errmsg:
|
||||
self.fail(errmsg)
|
||||
|
||||
pytest.fail(errmsg)
|
||||
finally:
|
||||
# Make sure we aren't popping the wrong stuff.
|
||||
assert optparse.Option.CHECK_METHODS.pop(0) is _check_dest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name, cls", subcmds.all_commands.items())
|
||||
def test_common_validate_options(name: str, cls: Type[Command]) -> None:
|
||||
"""Verify CommonValidateOptions sets up expected fields."""
|
||||
cmd = cls()
|
||||
opts, args = cmd.OptionParser.parse_args([])
|
||||
|
||||
# Verify the fields don't exist yet.
|
||||
assert not hasattr(
|
||||
opts, "verbose"
|
||||
), f"{name}: has verbose before validation"
|
||||
assert not hasattr(opts, "quiet"), f"{name}: has quiet before validation"
|
||||
|
||||
cmd.CommonValidateOptions(opts, args)
|
||||
|
||||
# Verify the fields exist now.
|
||||
assert hasattr(opts, "verbose"), f"{name}: missing verbose after validation"
|
||||
assert hasattr(opts, "quiet"), f"{name}: missing quiet after validation"
|
||||
assert hasattr(
|
||||
opts, "outer_manifest"
|
||||
), f"{name}: missing outer_manifest after validation"
|
||||
|
||||
|
||||
def test_attribute_error_repro() -> None:
|
||||
"""Confirm that accessing verbose before CommonValidateOptions fails."""
|
||||
from subcmds.sync import Sync
|
||||
|
||||
cmd = Sync()
|
||||
opts, args = cmd.OptionParser.parse_args([])
|
||||
|
||||
# This confirms that without the fix in main.py, an AttributeError
|
||||
# would be raised because CommonValidateOptions hasn't been called yet.
|
||||
with pytest.raises(AttributeError):
|
||||
_ = opts.verbose
|
||||
|
||||
cmd.CommonValidateOptions(opts, args)
|
||||
assert hasattr(opts, "verbose")
|
||||
|
||||
+73
-122
@@ -14,143 +14,94 @@
|
||||
|
||||
"""Unittests for the forall subcmd."""
|
||||
|
||||
from io import StringIO
|
||||
import os
|
||||
from shutil import rmtree
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
import contextlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import utils_for_test
|
||||
|
||||
import git_command
|
||||
import manifest_xml
|
||||
import project
|
||||
import subcmds
|
||||
|
||||
|
||||
class AllCommands(unittest.TestCase):
|
||||
"""Check registered all_commands."""
|
||||
def _create_manifest_with_8_projects(
|
||||
topdir: Path,
|
||||
) -> manifest_xml.XmlManifest:
|
||||
"""Create a setup of 8 projects to execute forall."""
|
||||
repodir = topdir / ".repo"
|
||||
manifest_dir = repodir / "manifests"
|
||||
manifest_file = repodir / manifest_xml.MANIFEST_FILE_NAME
|
||||
|
||||
def setUp(self):
|
||||
"""Common setup."""
|
||||
self.tempdirobj = tempfile.TemporaryDirectory(prefix="forall_tests")
|
||||
self.tempdir = self.tempdirobj.name
|
||||
self.repodir = os.path.join(self.tempdir, ".repo")
|
||||
self.manifest_dir = os.path.join(self.repodir, "manifests")
|
||||
self.manifest_file = os.path.join(
|
||||
self.repodir, manifest_xml.MANIFEST_FILE_NAME
|
||||
)
|
||||
self.local_manifest_dir = os.path.join(
|
||||
self.repodir, manifest_xml.LOCAL_MANIFESTS_DIR_NAME
|
||||
)
|
||||
os.mkdir(self.repodir)
|
||||
os.mkdir(self.manifest_dir)
|
||||
repodir.mkdir()
|
||||
manifest_dir.mkdir()
|
||||
|
||||
def tearDown(self):
|
||||
"""Common teardown."""
|
||||
rmtree(self.tempdir, ignore_errors=True)
|
||||
# Set up a manifest git dir for parsing to work.
|
||||
gitdir = repodir / "manifests.git"
|
||||
gitdir.mkdir()
|
||||
(gitdir / "config").write_text(
|
||||
"""[remote "origin"]
|
||||
url = https://localhost:0/manifest
|
||||
verbose = false
|
||||
"""
|
||||
)
|
||||
|
||||
def initTempGitTree(self, git_dir):
|
||||
"""Create a new empty git checkout for testing."""
|
||||
# Add the manifest data.
|
||||
manifest_file.write_text(
|
||||
"""
|
||||
<manifest>
|
||||
<remote name="origin" fetch="http://localhost" />
|
||||
<default remote="origin" revision="refs/heads/main" />
|
||||
<project name="project1" path="tests/path1" />
|
||||
<project name="project2" path="tests/path2" />
|
||||
<project name="project3" path="tests/path3" />
|
||||
<project name="project4" path="tests/path4" />
|
||||
<project name="project5" path="tests/path5" />
|
||||
<project name="project6" path="tests/path6" />
|
||||
<project name="project7" path="tests/path7" />
|
||||
<project name="project8" path="tests/path8" />
|
||||
</manifest>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Tests need to assume, that main is default branch at init,
|
||||
# which is not supported in config until 2.28.
|
||||
cmd = ["git", "init", "-q"]
|
||||
if git_command.git_require((2, 28, 0)):
|
||||
cmd += ["--initial-branch=main"]
|
||||
else:
|
||||
# Use template dir for init
|
||||
templatedir = os.path.join(self.tempdirobj.name, ".test-template")
|
||||
os.makedirs(templatedir)
|
||||
with open(os.path.join(templatedir, "HEAD"), "w") as fp:
|
||||
fp.write("ref: refs/heads/main\n")
|
||||
cmd += ["--template", templatedir]
|
||||
cmd += [git_dir]
|
||||
subprocess.check_call(cmd)
|
||||
# Set up 8 empty projects to match the manifest.
|
||||
for x in range(1, 9):
|
||||
(repodir / "projects" / "tests" / f"path{x}.git").mkdir(parents=True)
|
||||
(repodir / "project-objects" / f"project{x}.git").mkdir(parents=True)
|
||||
git_path = topdir / "tests" / f"path{x}"
|
||||
utils_for_test.init_git_tree(git_path)
|
||||
|
||||
def getXmlManifestWith8Projects(self):
|
||||
"""Create and return a setup of 8 projects with enough dummy
|
||||
files and setup to execute forall."""
|
||||
return manifest_xml.XmlManifest(str(repodir), str(manifest_file))
|
||||
|
||||
# Set up a manifest git dir for parsing to work
|
||||
gitdir = os.path.join(self.repodir, "manifests.git")
|
||||
os.mkdir(gitdir)
|
||||
with open(os.path.join(gitdir, "config"), "w") as fp:
|
||||
fp.write(
|
||||
"""[remote "origin"]
|
||||
url = https://localhost:0/manifest
|
||||
verbose = false
|
||||
"""
|
||||
)
|
||||
|
||||
# Add the manifest data
|
||||
manifest_data = """
|
||||
<manifest>
|
||||
<remote name="origin" fetch="http://localhost" />
|
||||
<default remote="origin" revision="refs/heads/main" />
|
||||
<project name="project1" path="tests/path1" />
|
||||
<project name="project2" path="tests/path2" />
|
||||
<project name="project3" path="tests/path3" />
|
||||
<project name="project4" path="tests/path4" />
|
||||
<project name="project5" path="tests/path5" />
|
||||
<project name="project6" path="tests/path6" />
|
||||
<project name="project7" path="tests/path7" />
|
||||
<project name="project8" path="tests/path8" />
|
||||
</manifest>
|
||||
"""
|
||||
with open(self.manifest_file, "w", encoding="utf-8") as fp:
|
||||
fp.write(manifest_data)
|
||||
def test_forall_all_projects_called_once(tmp_path: Path) -> None:
|
||||
"""Test that all projects get a command run once each."""
|
||||
manifest = _create_manifest_with_8_projects(tmp_path)
|
||||
|
||||
# Set up 8 empty projects to match the manifest
|
||||
for x in range(1, 9):
|
||||
os.makedirs(
|
||||
os.path.join(
|
||||
self.repodir, "projects/tests/path" + str(x) + ".git"
|
||||
)
|
||||
)
|
||||
os.makedirs(
|
||||
os.path.join(
|
||||
self.repodir, "project-objects/project" + str(x) + ".git"
|
||||
)
|
||||
)
|
||||
git_path = os.path.join(self.tempdir, "tests/path" + str(x))
|
||||
self.initTempGitTree(git_path)
|
||||
cmd = subcmds.forall.Forall()
|
||||
cmd.manifest = manifest
|
||||
|
||||
return manifest_xml.XmlManifest(self.repodir, self.manifest_file)
|
||||
# Use echo project names as the test of forall.
|
||||
opts, args = cmd.OptionParser.parse_args(["-c", "echo $REPO_PROJECT"])
|
||||
opts.verbose = False
|
||||
|
||||
# Use mock to capture stdout from the forall run
|
||||
@unittest.mock.patch("sys.stdout", new_callable=StringIO)
|
||||
def test_forall_all_projects_called_once(self, mock_stdout):
|
||||
"""Test that all projects get a command run once each."""
|
||||
# Set revisionId directly so GetRevisionId() short-circuits without
|
||||
# touching git. Using mock.patch.object on the class does not work
|
||||
# with Python 3.14+, which defaults to "forkserver" on Linux —
|
||||
# class-level patches do not survive into forkserver worker processes.
|
||||
for proj in manifest.projects:
|
||||
proj.revisionId = "refs/heads/main"
|
||||
|
||||
manifest_with_8_projects = self.getXmlManifestWith8Projects()
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
# Run the forall command.
|
||||
cmd.Execute(opts, args)
|
||||
|
||||
cmd = subcmds.forall.Forall()
|
||||
cmd.manifest = manifest_with_8_projects
|
||||
output = stdout.getvalue()
|
||||
# Verify that we got every project name in the output.
|
||||
for x in range(1, 9):
|
||||
assert f"project{x}" in output
|
||||
|
||||
# Use echo project names as the test of forall
|
||||
opts, args = cmd.OptionParser.parse_args(["-c", "echo $REPO_PROJECT"])
|
||||
opts.verbose = False
|
||||
|
||||
# Mock to not have the Execute fail on remote check
|
||||
with mock.patch.object(
|
||||
project.Project, "GetRevisionId", return_value="refs/heads/main"
|
||||
):
|
||||
# Run the forall command
|
||||
cmd.Execute(opts, args)
|
||||
|
||||
# Verify that we got every project name in the prints
|
||||
for x in range(1, 9):
|
||||
self.assertIn("project" + str(x), mock_stdout.getvalue())
|
||||
|
||||
# Split the captured output into lines to count them
|
||||
line_count = 0
|
||||
for line in mock_stdout.getvalue().split("\n"):
|
||||
# A commented out print to stderr as a reminder
|
||||
# that stdout is mocked, include sys and uncomment if needed
|
||||
# print(line, file=sys.stderr)
|
||||
if len(line) > 0:
|
||||
line_count += 1
|
||||
|
||||
# Verify that we didn't get more lines than expected
|
||||
assert line_count == 8
|
||||
# Split the captured output into lines to count them.
|
||||
line_count = sum(1 for x in output.splitlines() if x)
|
||||
# Verify that we didn't get more lines than expected.
|
||||
assert line_count == 8
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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/gc.py module."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from subcmds import gc
|
||||
|
||||
|
||||
class GcCommand(unittest.TestCase):
|
||||
"""Tests for gc command."""
|
||||
|
||||
def setUp(self):
|
||||
self.cmd = gc.Gc(manifest=mock.MagicMock())
|
||||
self.opt, self.args = self.cmd.OptionParser.parse_args([])
|
||||
self.opt.this_manifest_only = False
|
||||
self.opt.repack = False
|
||||
|
||||
self.mock_get_projects = mock.patch.object(
|
||||
self.cmd, "GetProjects"
|
||||
).start()
|
||||
|
||||
self.mock_delete = mock.patch.object(
|
||||
self.cmd, "delete_unused_projects", return_value=0
|
||||
).start()
|
||||
|
||||
self.mock_repack = mock.patch.object(
|
||||
self.cmd, "repack_projects", return_value=0
|
||||
).start()
|
||||
|
||||
def tearDown(self):
|
||||
mock.patch.stopall()
|
||||
|
||||
def test_gc_no_args(self):
|
||||
"""Test gc without specific projects."""
|
||||
self.mock_get_projects.return_value = ["all_projects"]
|
||||
|
||||
self.cmd.Execute(self.opt, [])
|
||||
|
||||
self.mock_get_projects.assert_called_once_with([], all_manifests=True)
|
||||
self.mock_delete.assert_called_once_with(["all_projects"], self.opt)
|
||||
self.mock_repack.assert_not_called()
|
||||
|
||||
def test_gc_with_args(self):
|
||||
"""Test gc with specific projects uses all_projects for delete."""
|
||||
self.mock_get_projects.side_effect = [["projA"], ["all_projects"]]
|
||||
self.opt.repack = True
|
||||
|
||||
self.cmd.Execute(self.opt, ["projA"])
|
||||
|
||||
self.mock_get_projects.assert_has_calls(
|
||||
[
|
||||
mock.call(["projA"], all_manifests=True),
|
||||
mock.call([], all_manifests=True),
|
||||
]
|
||||
)
|
||||
|
||||
self.mock_delete.assert_called_once_with(["all_projects"], self.opt)
|
||||
self.mock_repack.assert_called_once_with(["projA"], self.opt)
|
||||
|
||||
def test_gc_exit_on_delete_failure(self):
|
||||
"""Test gc exits if delete_unused_projects fails."""
|
||||
self.mock_get_projects.return_value = ["all_projects"]
|
||||
self.mock_delete.return_value = 1
|
||||
self.opt.repack = True
|
||||
|
||||
ret = self.cmd.Execute(self.opt, [])
|
||||
self.assertEqual(ret, 1)
|
||||
self.mock_repack.assert_not_called()
|
||||
@@ -0,0 +1,255 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unittests for the subcmds/info.py module."""
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from subcmds import info
|
||||
|
||||
|
||||
def _get_cmd() -> info.Info:
|
||||
"""Build a mock-backed Info command for testing."""
|
||||
manifest = mock.MagicMock()
|
||||
manifest.default.revisionExpr = "refs/heads/main"
|
||||
manifest.manifestProject.config.GetBranch.return_value.merge = (
|
||||
"refs/heads/main"
|
||||
)
|
||||
manifest.GetManifestGroupsStr.return_value = "all"
|
||||
manifest.superproject = None
|
||||
manifest.outer_client = manifest
|
||||
|
||||
client = mock.MagicMock()
|
||||
git_event_log = mock.MagicMock()
|
||||
|
||||
return info.Info(
|
||||
manifest=manifest,
|
||||
client=client,
|
||||
git_event_log=git_event_log,
|
||||
)
|
||||
|
||||
|
||||
def test_include_options_default_true() -> None:
|
||||
"""Both include options should default to True."""
|
||||
opts, _ = _get_cmd().OptionParser.parse_args([])
|
||||
assert opts.include_summary
|
||||
assert opts.include_projects
|
||||
|
||||
|
||||
def test_no_include_summary_parses() -> None:
|
||||
"""--no-include-summary should set include_summary to False."""
|
||||
opts, _ = _get_cmd().OptionParser.parse_args(["--no-include-summary"])
|
||||
assert not opts.include_summary
|
||||
|
||||
|
||||
def test_no_include_projects_parses() -> None:
|
||||
"""--no-include-projects should set include_projects to False."""
|
||||
opts, _ = _get_cmd().OptionParser.parse_args(["--no-include-projects"])
|
||||
assert not opts.include_projects
|
||||
|
||||
|
||||
def test_format_default_text() -> None:
|
||||
"""Default format should be text."""
|
||||
opts, _ = _get_cmd().OptionParser.parse_args([])
|
||||
assert opts.format == "text"
|
||||
|
||||
|
||||
def test_format_json_parses() -> None:
|
||||
"""--format=json should be accepted."""
|
||||
opts, _ = _get_cmd().OptionParser.parse_args(["--format=json"])
|
||||
assert opts.format == "json"
|
||||
|
||||
|
||||
def test_no_include_projects_skips_projects() -> None:
|
||||
"""--no-include-projects should skip project iteration."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(["--no-include-projects"])
|
||||
|
||||
with mock.patch.object(
|
||||
cmd, "_printDiffInfo"
|
||||
) as mock_diff, mock.patch.object(
|
||||
cmd, "_printCommitOverview"
|
||||
) as mock_overview:
|
||||
cmd.Execute(opts, args)
|
||||
mock_diff.assert_not_called()
|
||||
mock_overview.assert_not_called()
|
||||
|
||||
|
||||
def test_no_include_summary_skips_summary() -> None:
|
||||
"""--no-include-summary should not query or print manifest metadata."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(["--no-include-summary"])
|
||||
|
||||
with mock.patch.object(cmd, "_printDiffInfo"):
|
||||
cmd.Execute(opts, args)
|
||||
cmd.manifest.GetManifestGroupsStr.assert_not_called()
|
||||
|
||||
|
||||
def test_default_calls_diff_info() -> None:
|
||||
"""Default options should call _printDiffInfo."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args([])
|
||||
|
||||
with mock.patch.object(
|
||||
cmd, "_printDiffInfo"
|
||||
) as mock_diff, mock.patch.object(
|
||||
cmd, "_printCommitOverview"
|
||||
) as mock_overview:
|
||||
cmd.Execute(opts, args)
|
||||
mock_diff.assert_called_once_with(opts, args)
|
||||
mock_overview.assert_not_called()
|
||||
|
||||
|
||||
def test_overview_calls_commit_overview() -> None:
|
||||
"""--overview should call _printCommitOverview, not _printDiffInfo."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(["--overview"])
|
||||
|
||||
with mock.patch.object(
|
||||
cmd, "_printDiffInfo"
|
||||
) as mock_diff, mock.patch.object(
|
||||
cmd, "_printCommitOverview"
|
||||
) as mock_overview:
|
||||
cmd.Execute(opts, args)
|
||||
mock_diff.assert_not_called()
|
||||
mock_overview.assert_called_once_with(opts, args)
|
||||
|
||||
|
||||
def test_no_include_projects_with_overview() -> None:
|
||||
"""--no-include-projects should take priority over --overview."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(
|
||||
["--no-include-projects", "--overview"]
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
cmd, "_printDiffInfo"
|
||||
) as mock_diff, mock.patch.object(
|
||||
cmd, "_printCommitOverview"
|
||||
) as mock_overview:
|
||||
cmd.Execute(opts, args)
|
||||
mock_diff.assert_not_called()
|
||||
mock_overview.assert_not_called()
|
||||
|
||||
|
||||
def test_json_summary_only(capsys) -> None:
|
||||
"""--format=json --no-include-projects should emit only summary."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(
|
||||
["--format=json", "--no-include-projects"]
|
||||
)
|
||||
cmd.Execute(opts, args)
|
||||
data = json.loads(capsys.readouterr().out)
|
||||
assert "summary" in data
|
||||
assert "projects" not in data
|
||||
assert data["summary"]["manifest_branch"] == "refs/heads/main"
|
||||
assert data["summary"]["manifest_groups"] == "all"
|
||||
|
||||
|
||||
def test_json_no_summary(capsys) -> None:
|
||||
"""--format=json --no-include-summary should omit summary."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(
|
||||
["--format=json", "--no-include-summary", "--no-include-projects"]
|
||||
)
|
||||
cmd.Execute(opts, args)
|
||||
data = json.loads(capsys.readouterr().out)
|
||||
assert "summary" not in data
|
||||
|
||||
|
||||
def test_json_rejects_diff() -> None:
|
||||
"""--format=json --diff should be rejected."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(["--format=json", "--diff"])
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.ValidateOptions(opts, args)
|
||||
|
||||
|
||||
def test_json_rejects_overview() -> None:
|
||||
"""--format=json --overview should be rejected."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(["--format=json", "--overview"])
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.ValidateOptions(opts, args)
|
||||
|
||||
|
||||
def test_json_disables_pager() -> None:
|
||||
"""--format=json should disable the pager."""
|
||||
cmd = _get_cmd()
|
||||
opts, _ = cmd.OptionParser.parse_args(["--format=json"])
|
||||
assert not cmd.WantPager(opts)
|
||||
|
||||
|
||||
def test_text_enables_pager() -> None:
|
||||
"""Default text format should enable the pager."""
|
||||
cmd = _get_cmd()
|
||||
opts, _ = cmd.OptionParser.parse_args([])
|
||||
assert cmd.WantPager(opts)
|
||||
|
||||
|
||||
def test_get_project_data_uses_head_revision() -> None:
|
||||
"""_getProjectData should use GetHeadRevisionId if available."""
|
||||
cmd = _get_cmd()
|
||||
project = mock.MagicMock()
|
||||
project.name = "foo"
|
||||
project.worktree = "/path/to/foo"
|
||||
project.revisionExpr = "refs/heads/main"
|
||||
project.GetBranches.return_value = []
|
||||
|
||||
# GetHeadRevisionId() returns a SHA, it should be used.
|
||||
project.GetHeadRevisionId.return_value = "head_sha_12345"
|
||||
project.GetRevisionId.return_value = "manifest_sha_54321"
|
||||
|
||||
data = cmd._getProjectData(project)
|
||||
assert data["current_revision"] == "head_sha_12345"
|
||||
project.GetHeadRevisionId.assert_called_once()
|
||||
|
||||
# GetHeadRevisionId() is None, fall back to GetRevisionId().
|
||||
project.GetHeadRevisionId.reset_mock()
|
||||
project.GetHeadRevisionId.return_value = None
|
||||
data = cmd._getProjectData(project)
|
||||
assert data["current_revision"] == "manifest_sha_54321"
|
||||
|
||||
|
||||
def test_json_with_projects(capsys) -> None:
|
||||
"""--format=json should emit project data."""
|
||||
cmd = _get_cmd()
|
||||
opts, args = cmd.OptionParser.parse_args(["--format=json"])
|
||||
opts.jobs = 1 # To avoid multiprocessing pickle issues with mocks
|
||||
|
||||
project = mock.MagicMock()
|
||||
project.name = "foo"
|
||||
project.worktree = "/path/to/foo"
|
||||
project.revisionExpr = "refs/heads/main"
|
||||
project.GetBranches.return_value = {"branch1": mock.MagicMock()}
|
||||
project.GetHeadRevisionId.return_value = "head_sha_12345"
|
||||
project.CurrentBranch = "branch1"
|
||||
|
||||
cmd.GetProjects = mock.MagicMock(return_value=[project])
|
||||
|
||||
cmd.Execute(opts, args)
|
||||
|
||||
data = json.loads(capsys.readouterr().out)
|
||||
assert "projects" in data
|
||||
assert len(data["projects"]) == 1
|
||||
project_data = data["projects"][0]
|
||||
assert project_data["name"] == "foo"
|
||||
assert project_data["mount_path"] == "/path/to/foo"
|
||||
assert project_data["current_revision"] == "head_sha_12345"
|
||||
assert project_data["manifest_revision"] == "refs/heads/main"
|
||||
assert project_data["local_branches"] == ["branch1"]
|
||||
assert project_data["current_branch"] == "branch1"
|
||||
+27
-24
@@ -14,33 +14,36 @@
|
||||
|
||||
"""Unittests for the subcmds/init.py module."""
|
||||
|
||||
import unittest
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from subcmds import init
|
||||
|
||||
|
||||
class InitCommand(unittest.TestCase):
|
||||
"""Check registered all_commands."""
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
([],),
|
||||
)
|
||||
def test_cli_parser_good(argv: List[str]) -> None:
|
||||
"""Check valid command line options."""
|
||||
cmd = init.Init()
|
||||
opts, args = cmd.OptionParser.parse_args(argv)
|
||||
cmd.ValidateOptions(opts, args)
|
||||
|
||||
def setUp(self):
|
||||
self.cmd = init.Init()
|
||||
|
||||
def test_cli_parser_good(self):
|
||||
"""Check valid command line options."""
|
||||
ARGV = ([],)
|
||||
for argv in ARGV:
|
||||
opts, args = self.cmd.OptionParser.parse_args(argv)
|
||||
self.cmd.ValidateOptions(opts, args)
|
||||
|
||||
def test_cli_parser_bad(self):
|
||||
"""Check invalid command line options."""
|
||||
ARGV = (
|
||||
# Too many arguments.
|
||||
["url", "asdf"],
|
||||
# Conflicting options.
|
||||
["--mirror", "--archive"],
|
||||
)
|
||||
for argv in ARGV:
|
||||
opts, args = self.cmd.OptionParser.parse_args(argv)
|
||||
with self.assertRaises(SystemExit):
|
||||
self.cmd.ValidateOptions(opts, args)
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
(
|
||||
# Too many arguments.
|
||||
["url", "asdf"],
|
||||
# Conflicting options.
|
||||
["--mirror", "--archive"],
|
||||
),
|
||||
)
|
||||
def test_cli_parser_bad(argv: List[str]) -> None:
|
||||
"""Check invalid command line options."""
|
||||
cmd = init.Init()
|
||||
opts, args = cmd.OptionParser.parse_args(argv)
|
||||
with pytest.raises(SystemExit):
|
||||
cmd.ValidateOptions(opts, args)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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/rebase.py module."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from error import GitError
|
||||
from subcmds import rebase
|
||||
|
||||
|
||||
def test_resolve_onto_manifest_success() -> None:
|
||||
"""Test _ResolveOntoManifest when ToLocal succeeds."""
|
||||
project = mock.MagicMock()
|
||||
project.revisionExpr = "main"
|
||||
|
||||
remote = mock.MagicMock()
|
||||
remote.ToLocal.return_value = "refs/remotes/goog/main"
|
||||
project.GetRemote.return_value = remote
|
||||
|
||||
res = rebase._ResolveOntoManifest(project)
|
||||
assert res == "refs/remotes/goog/main"
|
||||
project.GetRemote.assert_called_once()
|
||||
remote.ToLocal.assert_called_once_with("main")
|
||||
|
||||
|
||||
def test_resolve_onto_manifest_fallback() -> None:
|
||||
"""Test _ResolveOntoManifest when ToLocal raises GitError."""
|
||||
project = mock.MagicMock()
|
||||
project.revisionExpr = "main"
|
||||
|
||||
remote = mock.MagicMock()
|
||||
remote.ToLocal.side_effect = GitError("Failed to resolve")
|
||||
project.GetRemote.return_value = remote
|
||||
|
||||
res = rebase._ResolveOntoManifest(project)
|
||||
assert res == "main"
|
||||
project.GetRemote.assert_called_once()
|
||||
remote.ToLocal.assert_called_once_with("main")
|
||||
@@ -0,0 +1,453 @@
|
||||
# Copyright (C) 2026 The Android Open Source Project
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Unittests for the status subcmd."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import List, Tuple
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import utils_for_test
|
||||
|
||||
import manifest_xml
|
||||
import subcmds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo_client_checkout(
|
||||
tmp_path: Path,
|
||||
) -> Tuple[Path, manifest_xml.XmlManifest]:
|
||||
"""Create a basic repo client checkout for status tests."""
|
||||
# Create in a subdir to avoid noise (like the repo_trace file).
|
||||
topdir = tmp_path / "client_checkout"
|
||||
repodir = topdir / ".repo"
|
||||
manifest_dir = repodir / "manifests"
|
||||
manifest_file = repodir / manifest_xml.MANIFEST_FILE_NAME
|
||||
|
||||
repodir.mkdir(parents=True)
|
||||
manifest_dir.mkdir()
|
||||
|
||||
gitdir = repodir / "manifests.git"
|
||||
gitdir.mkdir()
|
||||
(gitdir / "config").write_text(
|
||||
"""[remote "origin"]
|
||||
url = https://localhost:0/manifest
|
||||
verbose = false
|
||||
"""
|
||||
)
|
||||
|
||||
_init_temp_git_tree(manifest_dir)
|
||||
|
||||
manifest_file.write_text(
|
||||
"""
|
||||
<manifest>
|
||||
<remote name="origin" fetch="http://localhost" />
|
||||
<default remote="origin" revision="refs/heads/main" />
|
||||
<project name="proj" path="src/proj" />
|
||||
</manifest>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
(repodir / "projects" / "src" / "proj.git").mkdir(parents=True)
|
||||
(repodir / "project-objects" / "proj.git").mkdir(parents=True)
|
||||
|
||||
worktree = topdir / "src" / "proj"
|
||||
worktree.parent.mkdir(parents=True, exist_ok=True)
|
||||
_init_temp_git_tree(worktree)
|
||||
|
||||
manifest = manifest_xml.XmlManifest(str(repodir), str(manifest_file))
|
||||
return topdir, manifest
|
||||
|
||||
|
||||
def _init_temp_git_tree(git_dir: Path) -> None:
|
||||
"""Create a new git checkout with an initial commit for testing."""
|
||||
utils_for_test.init_git_tree(git_dir)
|
||||
(git_dir / "README").write_text("init")
|
||||
subprocess.check_call(["git", "add", "README"], cwd=git_dir)
|
||||
subprocess.check_call(["git", "commit", "-q", "-m", "init"], cwd=git_dir)
|
||||
|
||||
|
||||
def _run_status(manifest: manifest_xml.XmlManifest, argv: List[str]) -> None:
|
||||
"""Run the status subcommand with parsed options against a test manifest."""
|
||||
cmd = subcmds.status.Status()
|
||||
cmd.manifest = manifest
|
||||
cmd.client = mock.MagicMock(globalConfig=manifest.globalConfig)
|
||||
|
||||
opts, args = cmd.OptionParser.parse_args(argv + ["--jobs=1"])
|
||||
cmd.CommonValidateOptions(opts, args)
|
||||
|
||||
cmd.Execute(opts, args)
|
||||
|
||||
|
||||
def _status_lines(output: str) -> List[str]:
|
||||
"""Normalize path separators and split command output into lines."""
|
||||
return output.replace(os.sep, "/").splitlines()
|
||||
|
||||
|
||||
def _assert_project_header(line: str, project_path: str, branch: str) -> None:
|
||||
"""Assert a status project header line for a project and branch."""
|
||||
expected = f"project {(project_path + '/ '):<40}branch {branch}"
|
||||
assert line == expected
|
||||
|
||||
|
||||
def _assert_project_header_with_ahead_behind(
|
||||
line: str,
|
||||
project_path: str,
|
||||
branch: str,
|
||||
ahead_behind: str,
|
||||
) -> None:
|
||||
"""Assert a status project header line includes ahead/behind info."""
|
||||
suffix = f"branch {branch}{ahead_behind}"
|
||||
expected = f"project {(project_path + '/ '):<40}{suffix}"
|
||||
assert line == expected
|
||||
|
||||
|
||||
def _assert_orphan_block(lines: List[str], expected: List[str]) -> None:
|
||||
"""Assert orphan block header and entries, independent of entry ordering."""
|
||||
assert lines
|
||||
assert lines[0] == ("Objects not within a project (orphans)")
|
||||
orphan_lines = lines[1:]
|
||||
assert len(orphan_lines) == len(expected)
|
||||
assert sorted(orphan_lines) == sorted(expected)
|
||||
|
||||
|
||||
def test_orphans_basic(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify -o output includes project header and orphan block."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
|
||||
(topdir / "src" / "orphan_dir").mkdir(parents=True)
|
||||
(topdir / "orphan.txt").write_text("data")
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, ["-o"])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
_assert_project_header(lines[0], project_path, "main")
|
||||
_assert_orphan_block(
|
||||
lines[1:],
|
||||
[
|
||||
" --\torphan.txt",
|
||||
" --\tsrc/orphan_dir/",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_empty_status_without_orphans(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify clean status without -o prints only the project header line."""
|
||||
_, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 1
|
||||
_assert_project_header(lines[0], project_path, "main")
|
||||
|
||||
|
||||
def test_status_without_orphans(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify modified tracked file appears in status output without -o."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
|
||||
(topdir / project_path / "README").write_text("updated")
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 2
|
||||
_assert_project_header(lines[0], project_path, "main")
|
||||
assert lines[1] == " -m\tREADME"
|
||||
|
||||
|
||||
def test_status_with_orphans_and_modified_file(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify modified-file status plus orphan block."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
|
||||
(topdir / project_path / "README").write_text("updated")
|
||||
(topdir / "src" / "orphan_dir").mkdir(parents=True)
|
||||
(topdir / "orphan.txt").write_text("data")
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, ["-o"])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
_assert_project_header(lines[0], project_path, "main")
|
||||
assert lines[1] == " -m\tREADME"
|
||||
_assert_orphan_block(
|
||||
lines[2:],
|
||||
[
|
||||
" --\torphan.txt",
|
||||
" --\tsrc/orphan_dir/",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_empty_status_after_start_shows_started_branch(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify status shows the started branch name when the tree is clean."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
project_worktree = topdir / project_path
|
||||
started_branch = "topic/test-status-branch"
|
||||
subprocess.check_call(
|
||||
["git", "checkout", "-q", "-b", started_branch], cwd=project_worktree
|
||||
)
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 1
|
||||
_assert_project_header(lines[0], project_path, started_branch)
|
||||
|
||||
|
||||
def _setup_remote_tracking_branch(
|
||||
manifest: manifest_xml.XmlManifest,
|
||||
branch_name: str,
|
||||
) -> None:
|
||||
"""Create a branch tracking a remote ref, like ``repo start``.
|
||||
|
||||
In a real repo checkout, ``repo start`` creates a branch that
|
||||
tracks a remote tracking ref (e.g. refs/remotes/origin/main).
|
||||
This sets up the same config in both the worktree and the
|
||||
project gitdir (where repo reads its config from).
|
||||
"""
|
||||
proj = list(manifest.paths.values())[0]
|
||||
worktree = Path(proj.worktree)
|
||||
proj_gitdir = Path(proj.gitdir)
|
||||
|
||||
# Create the remote tracking ref in the worktree.
|
||||
subprocess.check_call(
|
||||
["git", "update-ref", "refs/remotes/origin/main", "main"],
|
||||
cwd=worktree,
|
||||
)
|
||||
# Create the new branch from main in the worktree.
|
||||
subprocess.check_call(
|
||||
["git", "checkout", "-q", "-b", branch_name, "main"],
|
||||
cwd=worktree,
|
||||
)
|
||||
# Write remote and branch config into the *project gitdir* config,
|
||||
# which is where repo's Project.config reads from.
|
||||
cfg = str(proj_gitdir / "config")
|
||||
subprocess.check_call(
|
||||
[
|
||||
"git",
|
||||
"config",
|
||||
"-f",
|
||||
cfg,
|
||||
"remote.origin.url",
|
||||
"http://localhost/fake",
|
||||
],
|
||||
)
|
||||
subprocess.check_call(
|
||||
[
|
||||
"git",
|
||||
"config",
|
||||
"-f",
|
||||
cfg,
|
||||
"remote.origin.fetch",
|
||||
"+refs/heads/*:refs/remotes/origin/*",
|
||||
],
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "config", "-f", cfg, f"branch.{branch_name}.remote", "origin"],
|
||||
)
|
||||
subprocess.check_call(
|
||||
[
|
||||
"git",
|
||||
"config",
|
||||
"-f",
|
||||
cfg,
|
||||
f"branch.{branch_name}.merge",
|
||||
"refs/heads/main",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_status_branch_ahead_of_upstream(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify status shows [ahead N] for local commits."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
project_worktree = topdir / project_path
|
||||
|
||||
_setup_remote_tracking_branch(manifest, "feature")
|
||||
subprocess.check_call(
|
||||
["git", "commit", "-q", "--allow-empty", "-m", "c1"],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "commit", "-q", "--allow-empty", "-m", "c2"],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 1
|
||||
_assert_project_header_with_ahead_behind(
|
||||
lines[0], project_path, "feature", " [ahead 2]"
|
||||
)
|
||||
|
||||
|
||||
def test_status_branch_behind_upstream(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify status shows [behind N] when upstream is ahead."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
project_worktree = topdir / project_path
|
||||
|
||||
_setup_remote_tracking_branch(manifest, "feature")
|
||||
# Advance the remote tracking ref past the feature branch.
|
||||
subprocess.check_call(
|
||||
["git", "checkout", "-q", "main"], cwd=project_worktree
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "commit", "-q", "--allow-empty", "-m", "upstream"],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "update-ref", "refs/remotes/origin/main", "main"],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "checkout", "-q", "feature"],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 1
|
||||
_assert_project_header_with_ahead_behind(
|
||||
lines[0], project_path, "feature", " [behind 1]"
|
||||
)
|
||||
|
||||
|
||||
def test_status_branch_ahead_and_behind(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify [ahead N, behind M] when branch has diverged."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
project_worktree = topdir / project_path
|
||||
|
||||
_setup_remote_tracking_branch(manifest, "feature")
|
||||
# Add a local commit on feature.
|
||||
subprocess.check_call(
|
||||
["git", "commit", "-q", "--allow-empty", "-m", "local"],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
# Advance the remote tracking ref independently.
|
||||
subprocess.check_call(
|
||||
["git", "checkout", "-q", "main"], cwd=project_worktree
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "commit", "-q", "--allow-empty", "-m", "upstream"],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "update-ref", "refs/remotes/origin/main", "main"],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "checkout", "-q", "feature"],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 1
|
||||
_assert_project_header_with_ahead_behind(
|
||||
lines[0],
|
||||
project_path,
|
||||
"feature",
|
||||
" [ahead 1, behind 1]",
|
||||
)
|
||||
|
||||
|
||||
def test_status_branch_no_tracking_no_ahead_behind(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify no ahead/behind when branch has no upstream."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
project_worktree = topdir / project_path
|
||||
|
||||
subprocess.check_call(
|
||||
[
|
||||
"git",
|
||||
"checkout",
|
||||
"-q",
|
||||
"-b",
|
||||
"orphan-branch",
|
||||
"--no-track",
|
||||
"main",
|
||||
],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "commit", "-q", "--allow-empty", "-m", "c1"],
|
||||
cwd=project_worktree,
|
||||
)
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 1
|
||||
_assert_project_header(lines[0], project_path, "orphan-branch")
|
||||
|
||||
|
||||
def test_status_branch_synced_no_ahead_behind(
|
||||
repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
|
||||
) -> None:
|
||||
"""Verify no ahead/behind when branch is fully synced."""
|
||||
topdir, manifest = repo_client_checkout
|
||||
project_path = next(iter(manifest.paths.keys()))
|
||||
|
||||
_setup_remote_tracking_branch(manifest, "synced")
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||
_run_status(manifest, [])
|
||||
|
||||
lines = _status_lines(stdout.getvalue())
|
||||
assert len(lines) == 1
|
||||
_assert_project_header(lines[0], project_path, "synced")
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user