91 Commits

Author SHA1 Message Date
b9051d5afb Merge branch 'develop' 2023-11-06 20:18:10 +01:00
6c6295651f Release v0.7.15 2023-11-06 20:18:10 +01:00
8c418ff846 Update dependencies 2023-11-06 20:17:18 +01:00
29b3bd3581 Merge pull request #63 from jgarte/jgarte-patch-1
Fix typo
2023-08-28 11:00:06 +02:00
jgart
012c6efb03 Fix typo 2023-08-28 01:00:10 -05:00
241bf473a7 Merge branch 'develop' 2023-08-09 00:32:33 +02:00
8fd663462e Release v0.7.14 2023-08-09 00:32:33 +02:00
4beacbf65d Reformat with new black version 2023-08-09 00:30:57 +02:00
102f5561a8 Use new compose call 2023-08-09 00:30:57 +02:00
e04f065d42 Drop nightly requirement 2023-08-09 00:30:57 +02:00
941dd50868 Cargo.lock: Updating pin-project v1.0.12 -> v1.1.3 2023-08-09 00:30:57 +02:00
d20dabc91e Cargo.lock: Updating pin-project-lite v0.2.9 -> v0.2.11 2023-08-09 00:30:57 +02:00
0e63a1c6bf Cargo.lock: Updating curl-sys v0.4.61+curl-8.0.1 -> v0.4.65+curl-8.2.1 2023-08-09 00:30:57 +02:00
9792c09850 Cargo.lock: Updating async-channel v1.8.0 -> v1.9.0 2023-08-09 00:30:57 +02:00
a1519a6bc5 dependencies: Update serde_json to 1.0.104 2023-08-09 00:30:57 +02:00
36535dcaec dependencies: Update serde_yaml to 0.9.25 2023-08-09 00:30:57 +02:00
32f94b1ef5 dependencies: Update comfy-table to 7.0.1 2023-08-09 00:30:57 +02:00
913df16f28 dependencies: Update regex to 1.9.3 2023-08-09 00:30:57 +02:00
f66a512a83 dependencies: Update console to 0.15.7 2023-08-09 00:30:57 +02:00
de15e799ac dependencies: Update clap to 4.3.21 2023-08-09 00:30:57 +02:00
a8736ed37f dependencies: Update git2 to 0.17.2 2023-08-09 00:30:57 +02:00
1a45887fb6 dependencies: Update serde to 1.0.183 2023-08-09 00:30:57 +02:00
9403156edf dependencies: Update toml to 0.7.6 2023-08-09 00:30:57 +02:00
21e3a9b9bb Merge branch 'develop' 2023-05-06 19:15:46 +02:00
ca0c9c28fd Release v0.7.13 2023-05-06 19:15:46 +02:00
1edc61d6e6 Fix tests related to clap changes 2023-05-06 19:13:53 +02:00
b20bba529a Cargo.lock: Updating http v0.2.8 -> v0.2.9 2023-05-06 17:16:58 +02:00
fb0948787a Cargo.lock: Updating futures-lite v1.12.0 -> v1.13.0 2023-05-06 17:16:58 +02:00
625457e474 Cargo.lock: Updating futures-io v0.3.25 -> v0.3.28 2023-05-06 17:16:58 +02:00
d4b7cabcf2 Cargo.lock: Updating fastrand v1.8.0 -> v1.9.0 2023-05-06 17:16:58 +02:00
b2727c7a96 Cargo.lock: Updating encoding_rs v0.8.31 -> v0.8.32 2023-05-06 17:16:58 +02:00
ff3cbfbdba Cargo.lock: Updating curl-sys v0.4.59+curl-7.86.0 -> v0.4.61+curl-8.0.1 2023-05-06 17:16:58 +02:00
44602e7bc2 Cargo.lock: Updating bytes v1.3.0 -> v1.4.0 2023-05-06 17:16:58 +02:00
1706df7236 Cargo.lock: Updating concurrent-queue v2.0.0 -> v2.2.0 2023-05-06 17:16:58 +02:00
80fc28c44a dependencies: Update serde_json to 1.0.96 2023-05-06 17:16:58 +02:00
7335c0fc62 dependencies: Update serde_yaml to 0.9.21 2023-05-06 17:16:58 +02:00
a536e688c9 dependencies: Update comfy-table to 6.1.4 2023-05-06 17:16:58 +02:00
0d22b43ed0 dependencies: Update regex to 1.8.1 2023-05-06 17:16:58 +02:00
9d7f566209 dependencies: Update console to 0.15.5 2023-05-06 17:16:58 +02:00
1e6f965f7a dependencies: Update clap to 4.2.7 2023-05-06 17:16:58 +02:00
6183a58204 dependencies: Update shellexpand to 3.1.0 2023-05-06 17:16:58 +02:00
2a4934b01a dependencies: Update git2 to 0.17.1 2023-05-06 17:16:58 +02:00
fc4261b7ac dependencies: Update serde to 1.0.162 2023-05-06 17:16:58 +02:00
7d248c5ea3 dependencies: Update toml to 0.7.3 2023-05-06 17:16:58 +02:00
8d4af73364 Merge pull request #54 from BapRx/feat/add-verbosity-repo-detection
feat: Return an error if the remote type cannot be detected
2023-05-04 14:36:12 +02:00
Hannes Körber
4c738d027a Always use cargo +nightly in Justfile 2023-05-04 13:45:57 +02:00
Hannes Körber
f2fa3411d8 Fix const Option::unwrap_or()
Fixes #57
2023-05-04 11:27:56 +02:00
Baptiste Roux
19443bc4ca chore: Update warning message 2023-02-10 18:02:13 +01:00
60a777276f Merge pull request #51 from BapRx/feat/exclude-paths-based-on-regex
chore(repo/find): Exlude paths based on regex
2023-02-07 22:50:02 +01:00
Baptiste Roux
1262ec5a33 chore: code format 2023-02-07 16:56:17 +01:00
Baptiste Roux
4c6b69e125 chore: Add linting exclusion 2023-02-07 16:55:23 +01:00
Baptiste Roux
28881a23a9 chore: Remove condition between default and exlude arguments 2023-02-07 16:50:45 +01:00
Baptiste Roux
e796362e6b chore: Avoid passing unnecessary reference 2023-02-07 16:49:21 +01:00
Baptiste Roux
37094a3295 feat: Return an error if the remote type cannot be detected 2023-02-02 23:11:04 +01:00
Baptiste Roux
100bac8f87 chore: Return error if the regex is invalid 2023-02-01 18:24:39 +01:00
Baptiste Roux
fdafa3aa81 chore: Pass regex pattern as slice instead of string 2023-02-01 18:14:56 +01:00
Baptiste Roux
d267564bca docs: Document the --exclude flag 2023-02-01 17:56:43 +01:00
Baptiste Roux
2cc477e551 test: Add e2e test for the path regex exclusion 2023-02-01 17:56:22 +01:00
Baptiste Roux
8cbdd9f408 chore: Add fmt in justfile; Update doc 2023-02-01 03:50:54 +01:00
Baptiste Roux
21be3e40dd fix: Rollback change that broke test 2023-02-01 03:48:59 +01:00
Baptiste Roux
a3824c2671 chore: Specify channel and target used in the project 2023-02-01 03:35:26 +01:00
Baptiste Roux
8eeb010c3a chore(e2e_tests): make the linter happier 2023-01-26 16:51:46 +01:00
Baptiste Roux
956b172426 chore(repo/find): Exlude paths based on regex 2023-01-14 14:40:11 +01:00
9b4ed2837e Merge branch 'develop' 2022-12-12 17:41:41 +01:00
701e64df6f Release v0.7.12 2022-12-12 17:41:41 +01:00
23fc942db7 Warn on empty filters
Closes #29
2022-12-12 15:43:27 +01:00
38bba1472e Improve error messages during sync errors
Closes #46
2022-12-12 15:21:42 +01:00
7d131bbacf 'Enable deny_unknown_fields for all config structs 2022-12-12 15:10:00 +01:00
6e79dd318a Make clippy happy 2022-12-12 14:46:08 +01:00
5fc1d2148f Cargo.lock: Updating polling v2.3.0 -> v2.5.1 2022-12-12 14:41:07 +01:00
de184de5a0 Cargo.lock: Updating futures-io v0.3.24 -> v0.3.25 2022-12-12 14:41:07 +01:00
8a3b2ae1c5 Cargo.lock: Updating curl-sys v0.4.56+curl-7.83.1 -> v0.4.59+curl-7.86.0 2022-12-12 14:41:07 +01:00
93e38b0572 Cargo.lock: Updating cc v1.0.73 -> v1.0.77 2022-12-12 14:41:07 +01:00
43bbb8e143 Cargo.lock: Updating crossbeam-utils v0.8.12 -> v0.8.14 2022-12-12 14:41:07 +01:00
0fd9ce68b8 Cargo.lock: Updating async-channel v1.7.1 -> v1.8.0 2022-12-12 14:41:07 +01:00
68f2b81e3f dependencies: Update parse_link_header to 0.3.3 2022-12-12 14:41:07 +01:00
d7a39fa4e4 dependencies: Update serde_json to 1.0.89 2022-12-12 14:41:07 +01:00
1f646fd5f8 dependencies: Update serde_yaml to 0.9.14 2022-12-12 14:41:07 +01:00
96cbf8c568 dependencies: Update comfy-table to 6.1.3 2022-12-12 14:41:07 +01:00
bf199c1b17 dependencies: Update regex to 1.7.0 2022-12-12 14:41:07 +01:00
0f7a70c895 dependencies: Update clap to 4.0.29 2022-12-12 14:41:07 +01:00
3151b97bc0 dependencies: Update shellexpand to 3.0.0 2022-12-12 14:41:07 +01:00
8ce5cfecd4 dependencies: Update serde to 1.0.150 2022-12-12 13:55:05 +01:00
6da27c6444 Merge branch 'develop' 2022-12-12 13:53:02 +01:00
3026b3e6de Release v0.7.11 2022-12-12 13:53:02 +01:00
725414cc71 release-script: Fix missing newline 2022-12-12 13:43:34 +01:00
defb8fafca Merge branch 'develop' 2022-10-10 18:50:40 +02:00
f747c085c9 Release v0.7.10 2022-10-10 18:50:40 +02:00
85dd794b53 Cargo.lock: Updating tracing v0.1.36 -> v0.1.37 2022-10-10 18:06:27 +02:00
be8d85cb66 dependencies: Update serde_json to 1.0.86 2022-10-10 18:06:25 +02:00
0b7527fc7d dependencies: Update clap to 4.0.11 2022-10-10 18:06:25 +02:00
40 changed files with 843 additions and 642 deletions

View File

@@ -1,2 +1,3 @@
nonnominandus nonnominandus
Maximilian Volk Maximilian Volk
Baptiste (@BapRx)

813
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "git-repo-manager" name = "git-repo-manager"
version = "0.7.9" version = "0.7.15"
edition = "2021" edition = "2021"
authors = [ authors = [
@@ -23,7 +23,7 @@ repository = "https://github.com/hakoerber/git-repo-manager"
readme = "README.md" readme = "README.md"
# Required for `std::path::Path::is_symlink()`. Will be released with 1.57. # Required for `std::path::Path::is_symlink()`. Will be released with 1.57.
rust-version = "1.57" rust-version = "1.58"
license = "GPL-3.0-only" license = "GPL-3.0-only"
@@ -41,36 +41,36 @@ path = "src/grm/main.rs"
[dependencies] [dependencies]
[dependencies.toml] [dependencies.toml]
version = "=0.5.9" version = "=0.8.6"
[dependencies.serde] [dependencies.serde]
version = "=1.0.145" version = "=1.0.190"
features = ["derive"] features = ["derive"]
[dependencies.git2] [dependencies.git2]
version = "=0.15.0" version = "=0.18.1"
[dependencies.shellexpand] [dependencies.shellexpand]
version = "=2.1.2" version = "=3.1.0"
[dependencies.clap] [dependencies.clap]
version = "=4.0.10" version = "=4.4.7"
features = ["derive", "cargo"] features = ["derive", "cargo"]
[dependencies.console] [dependencies.console]
version = "=0.15.2" version = "=0.15.7"
[dependencies.regex] [dependencies.regex]
version = "=1.6.0" version = "=1.10.2"
[dependencies.comfy-table] [dependencies.comfy-table]
version = "=6.1.0" version = "=7.1.0"
[dependencies.serde_yaml] [dependencies.serde_yaml]
version = "=0.9.13" version = "=0.9.27"
[dependencies.serde_json] [dependencies.serde_json]
version = "=1.0.85" version = "=1.0.108"
[dependencies.isahc] [dependencies.isahc]
version = "=1.7.2" version = "=1.7.2"
@@ -78,7 +78,7 @@ default-features = false
features = ["json", "http2", "text-decoding"] features = ["json", "http2", "text-decoding"]
[dependencies.parse_link_header] [dependencies.parse_link_header]
version = "=0.3.2" version = "=0.3.3"
[dependencies.url-escape] [dependencies.url-escape]
version = "=0.1.1" version = "=0.1.1"

View File

@@ -4,35 +4,39 @@ set shell := ["/bin/bash", "-c"]
static_target := "x86_64-unknown-linux-musl" static_target := "x86_64-unknown-linux-musl"
cargo := "cargo"
check: fmt-check lint test check: fmt-check lint test
cargo check {{cargo}} check
clean: clean:
cargo clean {{cargo}} clean
git clean -f -d -X git clean -f -d -X
fmt: fmt:
cargo fmt {{cargo}} fmt
git ls-files | grep '\.py$' | xargs isort
git ls-files | grep '\.py$' | xargs black git ls-files | grep '\.py$' | xargs black
git ls-files | grep '\.sh$' | xargs -L 1 shfmt --indent 4 --write git ls-files | grep '\.sh$' | xargs -L 1 shfmt --indent 4 --write
fmt-check: fmt-check:
cargo fmt --check {{cargo}} fmt --check
git ls-files | grep '\.py$' | xargs black --check git ls-files | grep '\.py$' | xargs black --check
git ls-files | grep '\.sh$' | xargs -L 1 shfmt --indent 4 --diff git ls-files | grep '\.sh$' | xargs -L 1 shfmt --indent 4 --diff
lint: lint:
cargo clippy --no-deps -- -Dwarnings {{cargo}} clippy --no-deps -- -Dwarnings
git ls-files | grep '\.py$' | xargs ruff --ignore E501
git ls-files | grep '\.sh$' | xargs -L 1 shellcheck --norc git ls-files | grep '\.sh$' | xargs -L 1 shellcheck --norc
lint-fix: lint-fix:
cargo clippy --no-deps --fix {{cargo}} clippy --no-deps --fix
build-release: build-release:
cargo build --release {{cargo}} build --release
build-release-static: build-release-static:
cargo build --release --target {{static_target}} --features=static-build {{cargo}} build --release --target {{static_target}} --features=static-build
pushall: pushall:
for r in $(git remote) ; do \ for r in $(git remote) ; do \
@@ -48,38 +52,38 @@ test-binary:
env \ env \
GITHUB_API_BASEURL=http://rest:5000/github \ GITHUB_API_BASEURL=http://rest:5000/github \
GITLAB_API_BASEURL=http://rest:5000/gitlab \ GITLAB_API_BASEURL=http://rest:5000/gitlab \
cargo build --profile e2e-tests --target {{static_target}} --features=static-build {{cargo}} build --profile e2e-tests --target {{static_target}} --features=static-build
install: install:
cargo install --path . {{cargo}} install --path .
install-static: install-static:
cargo install --target {{static_target}} --features=static-build --path . {{cargo}} install --target {{static_target}} --features=static-build --path .
build: build:
cargo build {{cargo}} build
build-static: build-static:
cargo build --target {{static_target}} --features=static-build {{cargo}} build --target {{static_target}} --features=static-build
test: test-unit test-integration test-e2e test: test-unit test-integration test-e2e
test-unit +tests="": test-unit +tests="":
cargo test --lib --bins -- --show-output {{tests}} {{cargo}} test --lib --bins -- --show-output {{tests}}
test-integration: test-integration:
cargo test --test "*" {{cargo}} test --test "*"
test-e2e +tests=".": test-binary test-e2e +tests=".": test-binary
cd ./e2e_tests \ cd ./e2e_tests \
&& docker-compose rm --stop -f \ && docker compose rm --stop -f \
&& docker-compose build \ && docker compose build \
&& docker-compose run \ && docker compose run \
--rm \ --rm \
-v $PWD/../target/x86_64-unknown-linux-musl/e2e-tests/grm:/grm \ -v $PWD/../target/x86_64-unknown-linux-musl/e2e-tests/grm:/grm \
pytest \ pytest \
"GRM_BINARY=/grm ALTERNATE_DOMAIN=alternate-rest python3 -m pytest --exitfirst -p no:cacheprovider --color=yes "$@"" \ "GRM_BINARY=/grm ALTERNATE_DOMAIN=alternate-rest python3 -m pytest --exitfirst -p no:cacheprovider --color=yes "$@"" \
&& docker-compose rm --stop -f && docker compose rm --stop -f
update-dependencies: update-cargo-dependencies update-dependencies: update-cargo-dependencies

View File

@@ -34,7 +34,7 @@ in once place?
This is how GRM came to be. I'm a fan of infrastructure-as-code, and GRM is a bit This is how GRM came to be. I'm a fan of infrastructure-as-code, and GRM is a bit
like Terraform for your local git repositories. Write a config, run the tool, and like Terraform for your local git repositories. Write a config, run the tool, and
your repos are ready. The only thing that is tracked by git it the list of your repos are ready. The only thing that is tracked by git is the list of
repositories itself. repositories itself.
# Crates # Crates

View File

@@ -1,9 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import subprocess
import os
import json import json
import sys import os
import subprocess
import semver import semver
import tomlkit import tomlkit
@@ -94,15 +93,7 @@ for tier in ["dependencies", "dev-dependencies"]:
try: try:
cmd = subprocess.run( cmd = subprocess.run(
[ ["cargo", "update", "--offline", "--aggressive", "--package", name],
"cargo",
"update",
"-Z",
"no-index-update",
"--aggressive",
"--package",
name,
],
check=True, check=True,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -136,15 +127,7 @@ while True:
spec = f"{package['name']}:{package['version']}" spec = f"{package['name']}:{package['version']}"
try: try:
cmd = subprocess.run( cmd = subprocess.run(
[ ["cargo", "update", "--offline", "--aggressive", "--package", spec],
"cargo",
"update",
"-Z",
"no-index-update",
"--aggressive",
"--package",
spec,
],
check=True, check=True,
capture_output=True, capture_output=True,
text=True, text=True,

View File

@@ -28,14 +28,14 @@ Feature branches are not required, there are also changes happening directly on
You will need the following tools: You will need the following tools:
* Rust (obviously) (easiest via `rustup`), with the nightly toolchain * Rust (obviously) (easiest via `rustup`)
* Python3 * Python3
* [`just`](https://github.com/casey/just), a command runner like `make`. See * [`just`](https://github.com/casey/just), a command runner like `make`. See
[here](https://github.com/casey/just#installation) for installation [here](https://github.com/casey/just#installation) for installation
instructions (it's most likely just a simple `cargo install just`). instructions (it's most likely just a simple `cargo install just`).
* Docker & docker-compose for the e2e tests * Docker & docker-compose for the e2e tests
* `black` and `shfmt` for formatting. * `isort`, `black` and `shfmt` for formatting.
* `shellcheck` for shell script linting * `ruff` and `shellcheck` for linting.
* `mdbook` for the documentation * `mdbook` for the documentation
Here are the tools: Here are the tools:
@@ -52,18 +52,3 @@ mvdan.cc/sh/v3/cmd/shfmt@latest`, depending on your go build environment.
For details about rustup and the toolchains, see [the installation For details about rustup and the toolchains, see [the installation
section](./installation.md). section](./installation.md).
## FAQ
### Why nightly?
For now, GRM requires the nightly toolchain for two reasons:
* [`io_error_more`](https://github.com/rust-lang/rust/issues/86442) to get
better error messages on IO errors
* [`const_option_ext`](https://github.com/rust-lang/rust/issues/91930) to have
static variables read from the environment that fall back to hard coded
defaults
Honestly, both of those are not really necessary or can be handled without
nightly. It's just that I'm using nightly anyway.

View File

@@ -2,14 +2,14 @@
## Installation ## Installation
Building GRM currently requires the nightly Rust toolchain. The easiest way is Building GRM requires the Rust toolchain to be installed. The easiest way is
using [`rustup`](https://rustup.rs/). Make sure that rustup is properly using [`rustup`](https://rustup.rs/). Make sure that rustup is properly
installed. installed.
Make sure that the nightly toolchain is installed: Make sure that the stable toolchain is installed:
``` ```
$ rustup toolchain install nightly $ rustup toolchain install stable
``` ```
Then, install the build dependencies: Then, install the build dependencies:
@@ -22,13 +22,13 @@ Then, install the build dependencies:
Then, it's a simple command to install the latest stable version: Then, it's a simple command to install the latest stable version:
```bash ```bash
$ cargo +nightly install git-repo-manager $ cargo install git-repo-manager
``` ```
If you're brave, you can also run the development build: If you're brave, you can also run the development build:
```bash ```bash
$ cargo +nightly install --git https://github.com/hakoerber/git-repo-manager.git --branch develop $ cargo install --git https://github.com/hakoerber/git-repo-manager.git --branch develop
``` ```
## Static build ## Static build
@@ -47,11 +47,11 @@ need `musl` and a few other build dependencies installed installed:
The, add the musl target via `rustup`: The, add the musl target via `rustup`:
``` ```
$ rustup +nightly target add x86_64-unknown-linux-musl $ rustup target add x86_64-unknown-linux-musl
``` ```
Then, use a modified build command to get a statically linked binary: Then, use a modified build command to get a statically linked binary:
``` ```
$ cargo +nightly install git-repo-manager --target x86_64-unknown-linux-musl --features=static-build $ cargo install git-repo-manager --target x86_64-unknown-linux-musl --features=static-build
``` ```

View File

@@ -11,7 +11,7 @@ Let's try it out:
## Get the example configuration ## Get the example configuration
```bash ```bash
$ curl --proto '=https' --tlsv1.2 -sSfO https://raw.githubusercontent.com/hakoerber/git-repo-manager/master/example.config.toml curl --proto '=https' --tlsv1.2 -sSfO https://raw.githubusercontent.com/hakoerber/git-repo-manager/master/example.config.toml
``` ```
Then, you're ready to run the first sync. This will clone all configured Then, you're ready to run the first sync. This will clone all configured
@@ -30,7 +30,7 @@ $ grm repos sync config --config example.config.toml
If you run it again, it will report no changes: If you run it again, it will report no changes:
``` ```bash
$ grm repos sync config -c example.config.toml $ grm repos sync config -c example.config.toml
[] git-repo-manager: OK [] git-repo-manager: OK
[] dotfiles: OK [] dotfiles: OK
@@ -43,11 +43,18 @@ write a configuration from scratch. Luckily, GRM has a way to generate a
configuration from an existing file tree: configuration from an existing file tree:
```bash ```bash
$ grm repos find local ~/your/project/root > config.toml grm repos find local ~/your/project/root > config.toml
``` ```
This will detect all repositories and remotes and write them to `config.toml`. This will detect all repositories and remotes and write them to `config.toml`.
You can exclude repositories from the generated configuration by providing
a regex that will be test against the path of each discovered repository:
```bash
grm repos find local ~/your/project/root --exclude "^.*/subdir/match-(foo|bar)/.*$" > config.toml
```
### Show the state of your projects ### Show the state of your projects
```bash ```bash
@@ -65,7 +72,7 @@ $ grm repos status --config example.config.toml
You can also use `status` without `--config` to check the repository you're You can also use `status` without `--config` to check the repository you're
currently in: currently in:
``` ```bash
$ cd ~/example-projects/dotfiles $ cd ~/example-projects/dotfiles
$ grm repos status $ grm repos status
╭──────────┬──────────┬────────┬──────────┬───────┬─────────╮ ╭──────────┬──────────┬────────┬──────────┬───────┬─────────╮

View File

@@ -49,7 +49,7 @@ Note: You will most likely not need to read this.
Each test parameter will exponentially increase the number of tests that will be Each test parameter will exponentially increase the number of tests that will be
run. As a general rule, comprehensiveness is more important than test suite run. As a general rule, comprehensiveness is more important than test suite
runtime (so if in doubt, better to add another parameter to catch every edge runtime (so if in doubt, better to add another parameter to catch every edge
case). But try to keep the total runtime sane. Currently, the whole `just e2e` case). But try to keep the total runtime sane. Currently, the whole `just test-e2e`
target runs ~8'000 tests and takes around 5 minutes on my machine, exlucding target runs ~8'000 tests and takes around 5 minutes on my machine, exlucding
binary and docker build time. I'd say that keeping it under 10 minutes is a good binary and docker build time. I'd say that keeping it under 10 minutes is a good
idea. idea.

View File

@@ -1,7 +1,5 @@
import os import os
from helpers import *
def pytest_configure(config): def pytest_configure(config):
os.environ["GIT_AUTHOR_NAME"] = "Example user" os.environ["GIT_AUTHOR_NAME"] = "Example user"

View File

@@ -3,5 +3,5 @@ from flask import Flask
app = Flask(__name__) app = Flask(__name__)
app.url_map.strict_slashes = False app.url_map.strict_slashes = False
import github import github # noqa: E402,F401
import gitlab import gitlab # noqa: E402,F401

View File

@@ -1,10 +1,8 @@
import os.path import os.path
from app import app
from flask import Flask, request, abort, jsonify, make_response
import jinja2 import jinja2
from app import app
from flask import abort, jsonify, make_response, request
def check_headers(): def check_headers():
@@ -48,7 +46,7 @@ def add_pagination(response, page, last_page):
def read_project_files(namespaces=[]): def read_project_files(namespaces=[]):
last_page = 4 last_page = 4
page = username = int(request.args.get("page", "1")) page = int(request.args.get("page", "1"))
response_file = f"./github_api_page_{page}.json.j2" response_file = f"./github_api_page_{page}.json.j2"
if not os.path.exists(response_file): if not os.path.exists(response_file):
return jsonify([]) return jsonify([])

View File

@@ -1,10 +1,8 @@
import os.path import os.path
from app import app
from flask import Flask, request, abort, jsonify, make_response
import jinja2 import jinja2
from app import app
from flask import abort, jsonify, make_response, request
def check_headers(): def check_headers():
@@ -48,7 +46,7 @@ def add_pagination(response, page, last_page):
def read_project_files(namespaces=[]): def read_project_files(namespaces=[]):
last_page = 4 last_page = 4
page = username = int(request.args.get("page", "1")) page = int(request.args.get("page", "1"))
response_file = f"./gitlab_api_page_{page}.json" response_file = f"./gitlab_api_page_{page}.json"
if not os.path.exists(response_file): if not os.path.exists(response_file):
return jsonify([]) return jsonify([])

View File

@@ -1,12 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import hashlib
import inspect
import os import os
import os.path import os.path
import shutil
import subprocess import subprocess
import tempfile import tempfile
import hashlib
import shutil
import inspect
import git import git
@@ -176,7 +176,6 @@ class TempGitRemote:
newobj.remoteid = remoteid newobj.remoteid = remoteid
return newobj, remoteid return newobj, remoteid
else: else:
refresh = False
if cachekey not in cls.obj: if cachekey not in cls.obj:
tmpdir = get_temporary_directory() tmpdir = get_temporary_directory()
shell( shell(

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from helpers import * from helpers import grm
def test_invalid_command(): def test_invalid_command():

View File

@@ -1,12 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os
import re
import tempfile import tempfile
import toml
import pytest import pytest
import toml
import yaml import yaml
from helpers import NonExistentPath, TempGitRepository, grm, shell
from helpers import *
def test_repos_find_nonexistent(): def test_repos_find_nonexistent():
@@ -40,7 +41,7 @@ def test_repos_find_invalid_format():
) )
assert cmd.returncode != 0 assert cmd.returncode != 0
assert len(cmd.stdout) == 0 assert len(cmd.stdout) == 0
assert "isn't a valid value" in cmd.stderr assert "invalid value 'invalidformat'" in cmd.stderr
def test_repos_find_non_git_repos(): def test_repos_find_non_git_repos():
@@ -63,9 +64,10 @@ def test_repos_find_non_git_repos():
assert len(cmd.stderr) != 0 assert len(cmd.stderr) != 0
@pytest.mark.parametrize("default", [True, False]) @pytest.mark.parametrize("default_format", [True, False])
@pytest.mark.parametrize("configtype", ["toml", "yaml"]) @pytest.mark.parametrize("configtype", ["toml", "yaml"])
def test_repos_find(configtype, default): @pytest.mark.parametrize("exclude", [None, "^.*/repo2$", "^not_matching$"])
def test_repos_find(configtype, exclude, default_format):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
shell( shell(
f""" f"""
@@ -99,13 +101,19 @@ def test_repos_find(configtype, default):
) )
args = ["repos", "find", "local", tmpdir] args = ["repos", "find", "local", tmpdir]
if not default: if not default_format:
args += ["--format", configtype] args += ["--format", configtype]
if exclude:
args += ["--exclude", exclude]
cmd = grm(args) cmd = grm(args)
assert cmd.returncode == 0 assert cmd.returncode == 0
if exclude == "^.*/repo2$":
assert re.match(r"^.*\[skipped\] .*\/repo2$", cmd.stderr.lower())
assert "repo2" in cmd.stderr.lower()
else:
assert len(cmd.stderr) == 0 assert len(cmd.stderr) == 0
if default or configtype == "toml": if default_format or configtype == "toml":
output = toml.loads(cmd.stdout) output = toml.loads(cmd.stdout)
elif configtype == "yaml": elif configtype == "yaml":
output = yaml.safe_load(cmd.stdout) output = yaml.safe_load(cmd.stdout)
@@ -120,7 +128,7 @@ def test_repos_find(configtype, default):
assert set(tree.keys()) == {"root", "repos"} assert set(tree.keys()) == {"root", "repos"}
assert tree["root"] == tmpdir assert tree["root"] == tmpdir
assert isinstance(tree["repos"], list) assert isinstance(tree["repos"], list)
assert len(tree["repos"]) == 2 assert len(tree["repos"]) == (1 if exclude == "^.*/repo2$" else 2)
repo1 = [r for r in tree["repos"] if r["name"] == "repo1"][0] repo1 = [r for r in tree["repos"] if r["name"] == "repo1"][0]
assert repo1["worktree_setup"] is False assert repo1["worktree_setup"] is False
@@ -137,6 +145,9 @@ def test_repos_find(configtype, default):
assert someremote["type"] == "ssh" assert someremote["type"] == "ssh"
assert someremote["url"] == "ssh://example.com/repo2.git" assert someremote["url"] == "ssh://example.com/repo2.git"
if exclude == "^.*/repo2$":
assert [r for r in tree["repos"] if r["name"] == "repo2"] == []
else:
repo2 = [r for r in tree["repos"] if r["name"] == "repo2"][0] repo2 = [r for r in tree["repos"] if r["name"] == "repo2"][0]
assert repo2["worktree_setup"] is False assert repo2["worktree_setup"] is False
assert isinstance(repo1["remotes"], list) assert isinstance(repo1["remotes"], list)
@@ -148,19 +159,18 @@ def test_repos_find(configtype, default):
assert origin["url"] == "https://example.com/repo2.git" assert origin["url"] == "https://example.com/repo2.git"
@pytest.mark.parametrize("default", [True, False]) @pytest.mark.parametrize("default_format", [True, False])
@pytest.mark.parametrize("configtype", ["toml", "yaml"]) @pytest.mark.parametrize("configtype", ["toml", "yaml"])
def test_repos_find_in_root(configtype, default): def test_repos_find_in_root(configtype, default_format):
with TempGitRepository() as repo_dir: with TempGitRepository() as repo_dir:
args = ["repos", "find", "local", repo_dir] args = ["repos", "find", "local", repo_dir]
if not default: if not default_format:
args += ["--format", configtype] args += ["--format", configtype]
cmd = grm(args) cmd = grm(args)
assert cmd.returncode == 0 assert cmd.returncode == 0
assert len(cmd.stderr) == 0 assert len(cmd.stderr) == 0
if default or configtype == "toml": if default_format or configtype == "toml":
output = toml.loads(cmd.stdout) output = toml.loads(cmd.stdout)
elif configtype == "yaml": elif configtype == "yaml":
output = yaml.safe_load(cmd.stdout) output = yaml.safe_load(cmd.stdout)
@@ -194,8 +204,8 @@ def test_repos_find_in_root(configtype, default):
@pytest.mark.parametrize("configtype", ["toml", "yaml"]) @pytest.mark.parametrize("configtype", ["toml", "yaml"])
@pytest.mark.parametrize("default", [True, False]) @pytest.mark.parametrize("default_format", [True, False])
def test_repos_find_with_invalid_repo(configtype, default): def test_repos_find_with_invalid_repo(configtype, default_format):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
shell( shell(
f""" f"""
@@ -229,13 +239,13 @@ def test_repos_find_with_invalid_repo(configtype, default):
) )
args = ["repos", "find", "local", tmpdir] args = ["repos", "find", "local", tmpdir]
if not default: if not default_format:
args += ["--format", configtype] args += ["--format", configtype]
cmd = grm(args) cmd = grm(args)
assert cmd.returncode == 0 assert cmd.returncode == 0
assert "broken" in cmd.stderr assert "broken" in cmd.stderr
if default or configtype == "toml": if default_format or configtype == "toml":
output = toml.loads(cmd.stdout) output = toml.loads(cmd.stdout)
elif configtype == "yaml": elif configtype == "yaml":
output = yaml.safe_load(cmd.stdout) output = yaml.safe_load(cmd.stdout)

View File

@@ -1,14 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import re
import os import os
import re
import tempfile
import toml
import pytest import pytest
import toml
import yaml import yaml
from helpers import grm
from helpers import *
ALTERNATE_DOMAIN = os.environ["ALTERNATE_DOMAIN"] ALTERNATE_DOMAIN = os.environ["ALTERNATE_DOMAIN"]
PROVIDERS = ["github", "gitlab"] PROVIDERS = ["github", "gitlab"]
@@ -44,7 +43,9 @@ def test_repos_find_remote_invalid_provider(use_config):
assert cmd.returncode != 0 assert cmd.returncode != 0
assert len(cmd.stdout) == 0 assert len(cmd.stdout) == 0
if not use_config: if not use_config:
assert re.match(".*isn't a valid value for.*provider", cmd.stderr) assert re.match(
".*invalid value 'thisproviderdoesnotexist' for.*provider", cmd.stderr
)
@pytest.mark.parametrize("provider", PROVIDERS) @pytest.mark.parametrize("provider", PROVIDERS)
@@ -67,7 +68,7 @@ def test_repos_find_remote_invalid_format(provider):
) )
assert cmd.returncode != 0 assert cmd.returncode != 0
assert len(cmd.stdout) == 0 assert len(cmd.stdout) == 0
assert "isn't a valid value" in cmd.stderr assert "invalid value 'invalidformat'" in cmd.stderr
@pytest.mark.parametrize("provider", PROVIDERS) @pytest.mark.parametrize("provider", PROVIDERS)
@@ -166,7 +167,7 @@ def test_repos_find_remote_no_filter(provider, configtype, default, use_config):
cmd = grm(args) cmd = grm(args)
assert cmd.returncode == 0 assert cmd.returncode == 0
assert len(cmd.stderr) == 0 assert "did not specify any filters" in cmd.stderr.lower()
if default or configtype == "toml": if default or configtype == "toml":
output = toml.loads(cmd.stdout) output = toml.loads(cmd.stdout)
@@ -275,9 +276,9 @@ def test_repos_find_remote_user(
if not worktree_default: if not worktree_default:
cfg += f"worktree = {str(worktree).lower()}\n" cfg += f"worktree = {str(worktree).lower()}\n"
if force_ssh: if force_ssh:
cfg += f"force_ssh = true\n" cfg += "force_ssh = true\n"
if override_remote_name: if override_remote_name:
cfg += f'remote_name = "otherremote"\n' cfg += 'remote_name = "otherremote"\n'
if use_owner: if use_owner:
cfg += """ cfg += """
[filters] [filters]
@@ -475,7 +476,7 @@ def test_repos_find_remote_group(
if not worktree_default: if not worktree_default:
cfg += f"worktree = {str(worktree).lower()}\n" cfg += f"worktree = {str(worktree).lower()}\n"
if force_ssh: if force_ssh:
cfg += f"force_ssh = true\n" cfg += "force_ssh = true\n"
if use_alternate_endpoint: if use_alternate_endpoint:
cfg += f'api_url = "http://{ALTERNATE_DOMAIN}:5000/{provider}"\n' cfg += f'api_url = "http://{ALTERNATE_DOMAIN}:5000/{provider}"\n'
cfg += """ cfg += """
@@ -591,7 +592,7 @@ def test_repos_find_remote_user_and_group(
if not worktree_default: if not worktree_default:
cfg += f"worktree = {str(worktree).lower()}\n" cfg += f"worktree = {str(worktree).lower()}\n"
if force_ssh: if force_ssh:
cfg += f"force_ssh = true\n" cfg += "force_ssh = true\n"
if use_alternate_endpoint: if use_alternate_endpoint:
cfg += f'api_url = "http://{ALTERNATE_DOMAIN}:5000/{provider}"\n' cfg += f'api_url = "http://{ALTERNATE_DOMAIN}:5000/{provider}"\n'
cfg += """ cfg += """
@@ -742,7 +743,7 @@ def test_repos_find_remote_owner(
if not worktree_default: if not worktree_default:
cfg += f"worktree = {str(worktree).lower()}\n" cfg += f"worktree = {str(worktree).lower()}\n"
if force_ssh: if force_ssh:
cfg += f"force_ssh = true\n" cfg += "force_ssh = true\n"
if use_alternate_endpoint: if use_alternate_endpoint:
cfg += f'api_url = "http://{ALTERNATE_DOMAIN}:5000/{provider}"\n' cfg += f'api_url = "http://{ALTERNATE_DOMAIN}:5000/{provider}"\n'
cfg += """ cfg += """
@@ -873,13 +874,11 @@ def test_repos_find_remote_owner(
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == "origin"
if force_ssh: if force_ssh:
assert ( assert (
repo["remotes"][0]["url"] == f"ssh://git@example.com/myuser2/myproject3.git" repo["remotes"][0]["url"] == "ssh://git@example.com/myuser2/myproject3.git"
) )
assert repo["remotes"][0]["type"] == "ssh" assert repo["remotes"][0]["type"] == "ssh"
else: else:
assert ( assert repo["remotes"][0]["url"] == "https://example.com/myuser2/myproject3.git"
repo["remotes"][0]["url"] == f"https://example.com/myuser2/myproject3.git"
)
assert repo["remotes"][0]["type"] == "https" assert repo["remotes"][0]["type"] == "https"
group_namespace_1 = [t for t in output["trees"] if t["root"] == "/myroot/mygroup1"][ group_namespace_1 = [t for t in output["trees"] if t["root"] == "/myroot/mygroup1"][
@@ -923,13 +922,13 @@ def test_repos_find_remote_owner(
if force_ssh: if force_ssh:
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]
== f"ssh://git@example.com/mygroup1/myproject4.git" == "ssh://git@example.com/mygroup1/myproject4.git"
) )
assert repo["remotes"][0]["type"] == "ssh" assert repo["remotes"][0]["type"] == "ssh"
else: else:
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]
== f"https://example.com/mygroup1/myproject4.git" == "https://example.com/mygroup1/myproject4.git"
) )
assert repo["remotes"][0]["type"] == "https" assert repo["remotes"][0]["type"] == "https"
@@ -948,12 +947,11 @@ def test_repos_find_remote_owner(
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == "origin"
if force_ssh: if force_ssh:
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"] == "ssh://git@example.com/mygroup2/myproject5.git"
== f"ssh://git@example.com/mygroup2/myproject5.git"
) )
assert repo["remotes"][0]["type"] == "ssh" assert repo["remotes"][0]["type"] == "ssh"
else: else:
assert ( assert (
repo["remotes"][0]["url"] == f"https://example.com/mygroup2/myproject5.git" repo["remotes"][0]["url"] == "https://example.com/mygroup2/myproject5.git"
) )
assert repo["remotes"][0]["type"] == "https" assert repo["remotes"][0]["type"] == "https"

View File

@@ -1,8 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import tempfile from helpers import RepoTree, grm
from helpers import *
def test_repos_sync_worktree_clone(): def test_repos_sync_worktree_clone():

View File

@@ -1,14 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import tempfile import os
import re import re
import subprocess
import tempfile
import textwrap import textwrap
import pytest
import toml
import git import git
import pytest
from helpers import * from helpers import (
NonExistentPath,
TempGitFileRemote,
TempGitRepository,
checksum_directory,
grm,
shell,
)
templates = { templates = {
"repo_simple": { "repo_simple": {
@@ -291,7 +298,9 @@ def test_repos_sync_unmanaged_repos(configtype):
# this removes the prefix (root) from the path (unmanaged_repo) # this removes the prefix (root) from the path (unmanaged_repo)
unmanaged_repo_name = os.path.relpath(unmanaged_repo, root) unmanaged_repo_name = os.path.relpath(unmanaged_repo, root)
regex = f".*unmanaged.*{unmanaged_repo_name}" regex = f".*unmanaged.*{unmanaged_repo_name}"
assert any([re.match(regex, l) for l in cmd.stderr.lower().split("\n")]) assert any(
[re.match(regex, line) for line in cmd.stderr.lower().split("\n")]
)
@pytest.mark.parametrize("configtype", ["toml", "yaml"]) @pytest.mark.parametrize("configtype", ["toml", "yaml"])
@@ -303,7 +312,7 @@ def test_repos_sync_root_is_file(configtype):
cmd = grm(["repos", "sync", "config", "--config", config.name]) cmd = grm(["repos", "sync", "config", "--config", config.name])
assert cmd.returncode != 0 assert cmd.returncode != 0
assert "not a directory" in cmd.stderr.lower() assert "notadirectory" in cmd.stderr.lower()
@pytest.mark.parametrize("configtype", ["toml", "yaml"]) @pytest.mark.parametrize("configtype", ["toml", "yaml"])
@@ -374,7 +383,7 @@ def test_repos_sync_repo_in_subdirectory(configtype):
assert urls[0] == f"file://{remote}" assert urls[0] == f"file://{remote}"
cmd = grm(["repos", "sync", "config", "--config", config.name]) cmd = grm(["repos", "sync", "config", "--config", config.name])
assert not "found unmanaged repository" in cmd.stderr.lower() assert "found unmanaged repository" not in cmd.stderr.lower()
@pytest.mark.parametrize("configtype", ["toml", "yaml"]) @pytest.mark.parametrize("configtype", ["toml", "yaml"])
@@ -419,7 +428,7 @@ def test_repos_sync_nested_clone(configtype):
cmd = grm(["repos", "sync", "config", "--config", config.name]) cmd = grm(["repos", "sync", "config", "--config", config.name])
print(cmd.stdout) print(cmd.stdout)
print(cmd.stderr) print(cmd.stderr)
assert not "found unmanaged repository" in cmd.stderr.lower() assert "found unmanaged repository" not in cmd.stderr.lower()
@pytest.mark.parametrize("configtype", ["toml", "yaml"]) @pytest.mark.parametrize("configtype", ["toml", "yaml"])
@@ -720,14 +729,14 @@ def test_repos_sync_invalid_syntax(configtype):
with open(config.name, "w") as f: with open(config.name, "w") as f:
if configtype == "toml": if configtype == "toml":
f.write( f.write(
f""" """
[[trees]] [[trees]]
root = invalid as there are no quotes ;) root = invalid as there are no quotes ;)
""" """
) )
elif configtype == "yaml": elif configtype == "yaml":
f.write( f.write(
f""" """
trees: trees:
wrong: wrong:
indentation: indentation:
@@ -779,8 +788,6 @@ def test_repos_sync_normal_change_to_worktree(configtype):
cmd = grm(["repos", "sync", "config", "--config", config.name]) cmd = grm(["repos", "sync", "config", "--config", config.name])
assert cmd.returncode == 0 assert cmd.returncode == 0
git_dir = os.path.join(target, "test")
with open(config.name, "w") as f: with open(config.name, "w") as f:
f.write( f.write(
templates["worktree_repo_with_remote"][configtype].format( templates["worktree_repo_with_remote"][configtype].format(
@@ -810,8 +817,6 @@ def test_repos_sync_worktree_change_to_normal(configtype):
cmd = grm(["repos", "sync", "config", "--config", config.name]) cmd = grm(["repos", "sync", "config", "--config", config.name])
assert cmd.returncode == 0 assert cmd.returncode == 0
git_dir = os.path.join(target, "test")
with open(config.name, "w") as f: with open(config.name, "w") as f:
f.write( f.write(
templates["repo_with_remote"][configtype].format( templates["repo_with_remote"][configtype].format(

View File

@@ -1,8 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import pytest import os
from helpers import * import pytest
from helpers import (
NonGitDir,
TempGitRepository,
TempGitRepositoryWorktree,
checksum_directory,
funcname,
grm,
shell,
)
def test_worktree_clean(): def test_worktree_clean():
@@ -153,13 +162,13 @@ def test_worktree_clean_configured_default_branch(
with open(os.path.join(base_dir, "grm.toml"), "w") as f: with open(os.path.join(base_dir, "grm.toml"), "w") as f:
if branch_list_empty: if branch_list_empty:
f.write( f.write(
f""" """
persistent_branches = [] persistent_branches = []
""" """
) )
else: else:
f.write( f.write(
f""" """
persistent_branches = [ persistent_branches = [
"mybranch" "mybranch"
] ]

View File

@@ -2,7 +2,8 @@
import os.path import os.path
from helpers import * import git
from helpers import TempGitRepositoryWorktree, checksum_directory, funcname, grm, shell
def test_worktree_never_clean_persistent_branches(): def test_worktree_never_clean_persistent_branches():

View File

@@ -1,8 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import tempfile import os
from helpers import * from helpers import (
EmptyDir,
NonGitDir,
TempGitRepository,
TempGitRepositoryWorktree,
checksum_directory,
funcname,
grm,
)
def test_convert(): def test_convert():

View File

@@ -1,11 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from helpers import *
import re import re
import pytest
import git import git
import pytest
from helpers import (
EmptyDir,
TempGitFileRemote,
TempGitRepositoryWorktree,
funcname,
grm,
shell,
)
def test_worktree_fetch(): def test_worktree_fetch():

View File

@@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from helpers import * import os
import re import re
import pytest
import git import git
import pytest
from helpers import TempGitRepositoryWorktree, funcname, grm, shell
@pytest.mark.parametrize("pull", [True, False]) @pytest.mark.parametrize("pull", [True, False])

View File

@@ -1,10 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os
import re import re
from helpers import *
import pytest import pytest
from helpers import (
NonGitDir,
TempGitRepository,
TempGitRepositoryWorktree,
funcname,
grm,
shell,
)
@pytest.mark.parametrize("has_config", [True, False]) @pytest.mark.parametrize("has_config", [True, False])

View File

@@ -1,12 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from helpers import * import datetime
import os.path
import git import git
import pytest import pytest
import datetime from helpers import (
TempGitRepositoryWorktree,
import os.path checksum_directory,
funcname,
grm,
shell,
tempfile,
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -151,7 +157,8 @@ def test_worktree_add(
] ]
) )
cachefn = lambda nr: "_".join( def cachefn(nr):
return "_".join(
[ [
str(nr), str(nr),
str(default_remote), str(default_remote),
@@ -163,6 +170,7 @@ def test_worktree_add(
str(worktree_name), str(worktree_name),
] ]
) )
remote1_cache_key = cachefn(1) remote1_cache_key = cachefn(1)
remote2_cache_key = cachefn(2) remote2_cache_key = cachefn(2)
@@ -281,7 +289,7 @@ def test_worktree_add(
and remotes_differ and remotes_differ
): ):
assert ( assert (
f"branch exists on multiple remotes, but they deviate" "branch exists on multiple remotes, but they deviate"
in cmd.stderr.lower() in cmd.stderr.lower()
) )
assert len(cmd.stderr.strip().split("\n")) == base + 1 assert len(cmd.stderr.strip().split("\n")) == base + 1

View File

@@ -67,7 +67,7 @@ fi
for remote in $(git remote); do for remote in $(git remote); do
if git ls-remote --tags "${remote}" | grep -q "refs/tags/v${new_version}$"; then if git ls-remote --tags "${remote}" | grep -q "refs/tags/v${new_version}$"; then
printf 'Tag %s already exists on %s' "v${new_version}" "${remote}" >&2 printf 'Tag %s already exists on %s\n' "v${new_version}" "${remote}" >&2
exit 1 exit 1
fi fi
done done

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
targets = ["x86_64-unknown-linux-musl"]

View File

@@ -33,6 +33,7 @@ pub struct ConfigTrees {
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigProviderFilter { pub struct ConfigProviderFilter {
pub access: Option<bool>, pub access: Option<bool>,
pub owner: Option<bool>, pub owner: Option<bool>,
@@ -41,6 +42,7 @@ pub struct ConfigProviderFilter {
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigProvider { pub struct ConfigProvider {
pub provider: RemoteProvider, pub provider: RemoteProvider,
pub token_command: String, pub token_command: String,
@@ -181,6 +183,12 @@ impl Config {
filters.access.unwrap_or(false), filters.access.unwrap_or(false),
); );
if filter.empty() {
print_warning(
"The configuration does not contain any filters, so no repos will match",
);
}
let repos = match config.provider { let repos = match config.provider {
RemoteProvider::Github => { RemoteProvider::Github => {
match provider::Github::new(filter, token, config.api_url) { match provider::Github::new(filter, token, config.api_url) {
@@ -240,7 +248,7 @@ impl Config {
pub fn normalize(&mut self) { pub fn normalize(&mut self) {
if let Config::ConfigTrees(config) = self { if let Config::ConfigTrees(config) = self {
let home = path::env_home().display().to_string(); let home = path::env_home();
for tree in &mut config.trees_mut().iter_mut() { for tree in &mut config.trees_mut().iter_mut() {
if tree.root.starts_with(&home) { if tree.root.starts_with(&home) {
// The tilde is not handled differently, it's just a normal path component for `Path`. // The tilde is not handled differently, it's just a normal path component for `Path`.
@@ -298,7 +306,7 @@ pub fn read_config<'a, T>(path: &str) -> Result<T, String>
where where
T: for<'de> serde::Deserialize<'de>, T: for<'de> serde::Deserialize<'de>,
{ {
let content = match std::fs::read_to_string(&path) { let content = match std::fs::read_to_string(path) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
return Err(format!( return Err(format!(

View File

@@ -63,6 +63,14 @@ pub struct FindLocalArgs {
#[clap(help = "The path to search through")] #[clap(help = "The path to search through")]
pub path: String, pub path: String,
#[clap(
short,
long,
help = "Exclude repositories that match the given regex",
name = "REGEX"
)]
pub exclude: Option<String>,
#[clap( #[clap(
value_enum, value_enum,
short, short,

View File

@@ -38,7 +38,7 @@ fn main() {
} }
} }
Err(error) => { Err(error) => {
print_error(&format!("Error syncing trees: {}", error)); print_error(&format!("Sync error: {}", error));
process::exit(1); process::exit(1);
} }
} }
@@ -55,6 +55,10 @@ fn main() {
let filter = let filter =
provider::Filter::new(args.users, args.groups, args.owner, args.access); provider::Filter::new(args.users, args.groups, args.owner, args.access);
if filter.empty() {
print_warning("You did not specify any filters, so no repos will match");
}
let worktree = args.worktree == "true"; let worktree = args.worktree == "true";
let repos = match args.provider { let repos = match args.provider {
@@ -62,7 +66,7 @@ fn main() {
match provider::Github::new(filter, token, args.api_url) { match provider::Github::new(filter, token, args.api_url) {
Ok(provider) => provider, Ok(provider) => provider,
Err(error) => { Err(error) => {
print_error(&format!("Error: {}", error)); print_error(&format!("Sync error: {}", error));
process::exit(1); process::exit(1);
} }
} }
@@ -76,7 +80,7 @@ fn main() {
match provider::Gitlab::new(filter, token, args.api_url) { match provider::Gitlab::new(filter, token, args.api_url) {
Ok(provider) => provider, Ok(provider) => provider,
Err(error) => { Err(error) => {
print_error(&format!("Error: {}", error)); print_error(&format!("Sync error: {}", error));
process::exit(1); process::exit(1);
} }
} }
@@ -112,13 +116,13 @@ fn main() {
} }
} }
Err(error) => { Err(error) => {
print_error(&format!("Error syncing trees: {}", error)); print_error(&format!("Sync error: {}", error));
process::exit(1); process::exit(1);
} }
} }
} }
Err(error) => { Err(error) => {
print_error(&format!("Error: {}", error)); print_error(&format!("Sync error: {}", error));
process::exit(1); process::exit(1);
} }
} }
@@ -195,7 +199,8 @@ fn main() {
} }
}; };
let (found_repos, warnings) = match find_in_tree(&path) { let (found_repos, warnings) = match find_in_tree(&path, args.exclude.as_deref())
{
Ok((repos, warnings)) => (repos, warnings), Ok((repos, warnings)) => (repos, warnings),
Err(error) => { Err(error) => {
print_error(&error); print_error(&error);
@@ -278,6 +283,10 @@ fn main() {
filters.access.unwrap_or(false), filters.access.unwrap_or(false),
); );
if filter.empty() {
print_warning("You did not specify any filters, so no repos will match");
}
let repos = match config.provider { let repos = match config.provider {
provider::RemoteProvider::Github => { provider::RemoteProvider::Github => {
match match provider::Github::new(filter, token, config.api_url) { match match provider::Github::new(filter, token, config.api_url) {
@@ -383,6 +392,10 @@ fn main() {
let filter = let filter =
provider::Filter::new(args.users, args.groups, args.owner, args.access); provider::Filter::new(args.users, args.groups, args.owner, args.access);
if filter.empty() {
print_warning("You did not specify any filters, so no repos will match");
}
let worktree = args.worktree == "true"; let worktree = args.worktree == "true";
let repos = match args.provider { let repos = match args.provider {

View File

@@ -1,5 +1,3 @@
#![feature(io_error_more)]
#![feature(const_option_ext)]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use std::path::Path; use std::path::Path;
@@ -19,12 +17,22 @@ pub mod worktree;
/// The bool in the return value specifies whether there is a repository /// The bool in the return value specifies whether there is a repository
/// in root itself. /// in root itself.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
fn find_repos(root: &Path) -> Result<Option<(Vec<repo::Repo>, Vec<String>, bool)>, String> { fn find_repos(
root: &Path,
exclusion_pattern: Option<&str>,
) -> Result<Option<(Vec<repo::Repo>, Vec<String>, bool)>, String> {
let mut repos: Vec<repo::Repo> = Vec::new(); let mut repos: Vec<repo::Repo> = Vec::new();
let mut repo_in_root = false; let mut repo_in_root = false;
let mut warnings = Vec::new(); let mut warnings = Vec::new();
let exlusion_regex: regex::Regex = regex::Regex::new(exclusion_pattern.unwrap_or(r"^$"))
.map_err(|e| format!("invalid regex: {e}"))?;
for path in tree::find_repo_paths(root)? { for path in tree::find_repo_paths(root)? {
if exclusion_pattern.is_some() && exlusion_regex.is_match(&path::path_as_string(&path)) {
warnings.push(format!("[skipped] {}", &path::path_as_string(&path)));
continue;
}
let is_worktree = repo::RepoHandle::detect_worktree(&path); let is_worktree = repo::RepoHandle::detect_worktree(&path);
if path == root { if path == root {
repo_in_root = true; repo_in_root = true;
@@ -63,12 +71,13 @@ fn find_repos(root: &Path) -> Result<Option<(Vec<repo::Repo>, Vec<String>, bool)
let name = remote.name(); let name = remote.name();
let url = remote.url(); let url = remote.url();
let remote_type = match repo::detect_remote_type(&url) { let remote_type = match repo::detect_remote_type(&url) {
Some(t) => t, Ok(t) => t,
None => { Err(e) => {
warnings.push(format!( warnings.push(format!(
"{}: Could not detect remote type of \"{}\"", "{}: Could not handle URL {}. Reason: {}",
&path::path_as_string(&path), &path::path_as_string(&path),
&url &url,
e
)); ));
continue; continue;
} }
@@ -106,7 +115,7 @@ fn find_repos(root: &Path) -> Result<Option<(Vec<repo::Repo>, Vec<String>, bool)
}, },
) )
} else { } else {
let name = path.strip_prefix(&root).unwrap(); let name = path.strip_prefix(root).unwrap();
let namespace = name.parent().unwrap(); let namespace = name.parent().unwrap();
( (
if namespace != Path::new("") { if namespace != Path::new("") {
@@ -130,10 +139,14 @@ fn find_repos(root: &Path) -> Result<Option<(Vec<repo::Repo>, Vec<String>, bool)
Ok(Some((repos, warnings, repo_in_root))) Ok(Some((repos, warnings, repo_in_root)))
} }
pub fn find_in_tree(path: &Path) -> Result<(tree::Tree, Vec<String>), String> { pub fn find_in_tree(
path: &Path,
exclusion_pattern: Option<&str>,
) -> Result<(tree::Tree, Vec<String>), String> {
let mut warnings = Vec::new(); let mut warnings = Vec::new();
let (repos, repo_in_root): (Vec<repo::Repo>, bool) = match find_repos(path)? { let (repos, repo_in_root): (Vec<repo::Repo>, bool) = match find_repos(path, exclusion_pattern)?
{
Some((vec, mut repo_warnings, repo_in_root)) => { Some((vec, mut repo_warnings, repo_in_root)) => {
warnings.append(&mut repo_warnings); warnings.append(&mut repo_warnings);
(vec, repo_in_root) (vec, repo_in_root)

View File

@@ -47,9 +47,9 @@ pub fn path_as_string(path: &Path) -> String {
path.to_path_buf().into_os_string().into_string().unwrap() path.to_path_buf().into_os_string().into_string().unwrap()
} }
pub fn env_home() -> PathBuf { pub fn env_home() -> String {
match std::env::var("HOME") { match std::env::var("HOME") {
Ok(path) => Path::new(&path).to_path_buf(), Ok(path) => path,
Err(e) => { Err(e) => {
print_error(&format!("Unable to read HOME: {}", e)); print_error(&format!("Unable to read HOME: {}", e));
process::exit(1); process::exit(1);
@@ -58,16 +58,12 @@ pub fn env_home() -> PathBuf {
} }
pub fn expand_path(path: &Path) -> PathBuf { pub fn expand_path(path: &Path) -> PathBuf {
fn home_dir() -> Option<PathBuf> {
Some(env_home())
}
let expanded_path = match shellexpand::full_with_context( let expanded_path = match shellexpand::full_with_context(
&path_as_string(path), &path_as_string(path),
home_dir, || Some(env_home()),
|name| -> Result<Option<String>, &'static str> { |name| -> Result<Option<String>, &'static str> {
match name { match name {
"HOME" => Ok(Some(path_as_string(home_dir().unwrap().as_path()))), "HOME" => Ok(Some(env_home())),
_ => Ok(None), _ => Ok(None),
} }
}, },

View File

@@ -9,8 +9,10 @@ use super::Project;
use super::Provider; use super::Provider;
const ACCEPT_HEADER_JSON: &str = "application/vnd.github.v3+json"; const ACCEPT_HEADER_JSON: &str = "application/vnd.github.v3+json";
const GITHUB_API_BASEURL: &str = const GITHUB_API_BASEURL: &str = match option_env!("GITHUB_API_BASEURL") {
option_env!("GITHUB_API_BASEURL").unwrap_or("https://api.github.com"); Some(url) => url,
None => "https://api.github.com",
};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct GithubProject { pub struct GithubProject {

View File

@@ -9,7 +9,10 @@ use super::Project;
use super::Provider; use super::Provider;
const ACCEPT_HEADER_JSON: &str = "application/json"; const ACCEPT_HEADER_JSON: &str = "application/json";
const GITLAB_API_BASEURL: &str = option_env!("GITLAB_API_BASEURL").unwrap_or("https://gitlab.com"); const GITLAB_API_BASEURL: &str = match option_env!("GITLAB_API_BASEURL") {
Some(url) => url,
None => "https://gitlab.com",
};
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]

View File

@@ -89,6 +89,10 @@ impl Filter {
access, access,
} }
} }
pub fn empty(&self) -> bool {
self.users.is_empty() && self.groups.is_empty() && !self.owner && !self.access
}
} }
pub enum ApiErrorResponse<T> pub enum ApiErrorResponse<T>
@@ -270,11 +274,15 @@ pub trait Provider {
} }
for group in &self.filter().groups { for group in &self.filter().groups {
let group_projects = self let group_projects = self.get_group_projects(group).map_err(|error| {
.get_group_projects(group) format!(
.map_err(|error| match error { "group \"{}\": {}",
group,
match error {
ApiErrorResponse::Json(x) => x.to_string(), ApiErrorResponse::Json(x) => x.to_string(),
ApiErrorResponse::String(s) => s, ApiErrorResponse::String(s) => s,
}
)
})?; })?;
for group_project in group_projects { for group_project in group_projects {
let mut already_present = false; let mut already_present = false;
@@ -305,7 +313,7 @@ pub trait Provider {
// about the data exchange format here. // about the data exchange format here.
repo.remove_namespace(); repo.remove_namespace();
ret.entry(namespace).or_insert(vec![]).push(repo); ret.entry(namespace).or_default().push(repo);
} }
Ok(ret) Ok(ret)

View File

@@ -406,50 +406,78 @@ mod tests {
fn check_ssh_remote() { fn check_ssh_remote() {
assert_eq!( assert_eq!(
detect_remote_type("ssh://git@example.com"), detect_remote_type("ssh://git@example.com"),
Some(RemoteType::Ssh) Ok(RemoteType::Ssh)
); );
assert_eq!(detect_remote_type("git@example.git"), Some(RemoteType::Ssh)); assert_eq!(detect_remote_type("git@example.git"), Ok(RemoteType::Ssh));
} }
#[test] #[test]
fn check_https_remote() { fn check_https_remote() {
assert_eq!( assert_eq!(
detect_remote_type("https://example.com"), detect_remote_type("https://example.com"),
Some(RemoteType::Https) Ok(RemoteType::Https)
); );
assert_eq!( assert_eq!(
detect_remote_type("https://example.com/test.git"), detect_remote_type("https://example.com/test.git"),
Some(RemoteType::Https) Ok(RemoteType::Https)
); );
} }
#[test] #[test]
fn check_file_remote() { fn check_file_remote() {
assert_eq!( assert_eq!(detect_remote_type("file:///somedir"), Ok(RemoteType::File));
detect_remote_type("file:///somedir"),
Some(RemoteType::File)
);
} }
#[test] #[test]
fn check_invalid_remotes() { fn check_invalid_remotes() {
assert_eq!(detect_remote_type("https//example.com"), None); assert_eq!(
assert_eq!(detect_remote_type("https:example.com"), None); detect_remote_type("https//example.com"),
assert_eq!(detect_remote_type("ssh//example.com"), None); Err(String::from(
assert_eq!(detect_remote_type("ssh:example.com"), None); "The remote URL starts with an unimplemented protocol"
assert_eq!(detect_remote_type("git@example.com"), None); ))
);
assert_eq!(
detect_remote_type("https:example.com"),
Err(String::from(
"The remote URL starts with an unimplemented protocol",
))
);
assert_eq!(
detect_remote_type("ssh//example.com"),
Err(String::from(
"The remote URL starts with an unimplemented protocol",
))
);
assert_eq!(
detect_remote_type("ssh:example.com"),
Err(String::from(
"The remote URL starts with an unimplemented protocol",
))
);
assert_eq!(
detect_remote_type("git@example.com"),
Err(String::from(
"The remote URL starts with an unimplemented protocol",
))
);
} }
#[test] #[test]
#[should_panic]
fn check_unsupported_protocol_http() { fn check_unsupported_protocol_http() {
detect_remote_type("http://example.com"); assert_eq!(
detect_remote_type("http://example.com"),
Err(String::from(
"Remotes using HTTP protocol are not supported",
))
);
} }
#[test] #[test]
#[should_panic]
fn check_unsupported_protocol_git() { fn check_unsupported_protocol_git() {
detect_remote_type("git://example.com"); assert_eq!(
detect_remote_type("git://example.com"),
Err(String::from("Remotes using git protocol are not supported"))
);
} }
#[test] #[test]
@@ -473,27 +501,31 @@ mod tests {
} }
} }
pub fn detect_remote_type(remote_url: &str) -> Option<RemoteType> { pub fn detect_remote_type(remote_url: &str) -> Result<RemoteType, String> {
let git_regex = regex::Regex::new(r"^[a-zA-Z]+@.*$").unwrap(); let git_regex = regex::Regex::new(r"^[a-zA-Z]+@.*$").unwrap();
if remote_url.starts_with("ssh://") { if remote_url.starts_with("ssh://") {
return Some(RemoteType::Ssh); return Ok(RemoteType::Ssh);
} }
if git_regex.is_match(remote_url) && remote_url.ends_with(".git") { if git_regex.is_match(remote_url) && remote_url.ends_with(".git") {
return Some(RemoteType::Ssh); return Ok(RemoteType::Ssh);
} }
if remote_url.starts_with("https://") { if remote_url.starts_with("https://") {
return Some(RemoteType::Https); return Ok(RemoteType::Https);
} }
if remote_url.starts_with("file://") { if remote_url.starts_with("file://") {
return Some(RemoteType::File); return Ok(RemoteType::File);
} }
if remote_url.starts_with("http://") { if remote_url.starts_with("http://") {
unimplemented!("Remotes using HTTP protocol are not supported"); return Err(String::from(
"Remotes using HTTP protocol are not supported",
));
} }
if remote_url.starts_with("git://") { if remote_url.starts_with("git://") {
unimplemented!("Remotes using git protocol are not supported"); return Err(String::from("Remotes using git protocol are not supported"));
} }
None Err(String::from(
"The remote URL starts with an unimplemented protocol",
))
} }
pub struct RepoHandle(git2::Repository); pub struct RepoHandle(git2::Repository);
@@ -785,7 +817,7 @@ impl RepoHandle {
)) ))
})?; })?;
for entry in match std::fs::read_dir(&root_dir) { for entry in match std::fs::read_dir(root_dir) {
Ok(iterator) => iterator, Ok(iterator) => iterator,
Err(error) => { Err(error) => {
return Err(WorktreeConversionFailureReason::Error(format!( return Err(WorktreeConversionFailureReason::Error(format!(
@@ -1343,7 +1375,7 @@ impl RepoHandle {
}, },
}) })
{ {
let repo_dir = &directory.join(&worktree.name()); let repo_dir = &directory.join(worktree.name());
if repo_dir.exists() { if repo_dir.exists() {
match self.remove_worktree( match self.remove_worktree(
directory, directory,
@@ -1387,12 +1419,12 @@ impl RepoHandle {
.map_err(|error| format!("Getting worktrees failed: {}", error))?; .map_err(|error| format!("Getting worktrees failed: {}", error))?;
let mut unmanaged_worktrees = Vec::new(); let mut unmanaged_worktrees = Vec::new();
for entry in std::fs::read_dir(&directory).map_err(|error| error.to_string())? { for entry in std::fs::read_dir(directory).map_err(|error| error.to_string())? {
let dirname = path::path_as_string( let dirname = path::path_as_string(
entry entry
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
.path() .path()
.strip_prefix(&directory) .strip_prefix(directory)
// that unwrap() is safe as each entry is // that unwrap() is safe as each entry is
// guaranteed to be a subentry of &directory // guaranteed to be a subentry of &directory
.unwrap(), .unwrap(),
@@ -1588,7 +1620,7 @@ impl RemoteHandle<'_> {
pub fn is_pushable(&self) -> Result<bool, String> { pub fn is_pushable(&self) -> Result<bool, String> {
let remote_type = detect_remote_type(self.0.url().expect("Remote name is not valid utf-8")) let remote_type = detect_remote_type(self.0.url().expect("Remote name is not valid utf-8"))
.ok_or_else(|| String::from("Could not detect remote type"))?; .expect("Could not detect remote type");
Ok(matches!(remote_type, RemoteType::Ssh | RemoteType::File)) Ok(matches!(remote_type, RemoteType::Ssh | RemoteType::File))
} }

View File

@@ -4,6 +4,7 @@ use super::repo;
use comfy_table::{Cell, Table}; use comfy_table::{Cell, Table};
use std::fmt::Write;
use std::path::Path; use std::path::Path;
fn add_table_header(table: &mut Table) { fn add_table_header(table: &mut Table) {
@@ -56,9 +57,10 @@ fn add_repo_status(
repo_status repo_status
.branches .branches
.iter() .iter()
.map(|(branch_name, remote_branch)| { .fold(String::new(), |mut s, (branch_name, remote_branch)| {
format!( writeln!(
"branch: {}{}\n", &mut s,
"branch: {}{}",
&branch_name, &branch_name,
&match remote_branch { &match remote_branch {
None => String::from(" <!local>"), None => String::from(" <!local>"),
@@ -78,8 +80,9 @@ fn add_repo_status(
} }
} }
) )
.unwrap();
s
}) })
.collect::<String>()
.trim(), .trim(),
&match is_worktree { &match is_worktree {
true => String::from(""), true => String::from(""),
@@ -91,8 +94,10 @@ fn add_repo_status(
repo_status repo_status
.remotes .remotes
.iter() .iter()
.map(|r| format!("{}\n", r)) .fold(String::new(), |mut s, r| {
.collect::<String>() writeln!(&mut s, "{r}").unwrap();
s
})
.trim(), .trim(),
]); ]);
@@ -111,7 +116,7 @@ pub fn get_worktree_status_table(
add_worktree_table_header(&mut table); add_worktree_table_header(&mut table);
for worktree in &worktrees { for worktree in &worktrees {
let worktree_dir = &directory.join(&worktree.name()); let worktree_dir = &directory.join(worktree.name());
if worktree_dir.exists() { if worktree_dir.exists() {
let repo = match repo::RepoHandle::open(worktree_dir, false) { let repo = match repo::RepoHandle::open(worktree_dir, false) {
Ok(repo) => repo, Ok(repo) => repo,

View File

@@ -128,8 +128,6 @@ pub fn find_repo_paths(path: &Path) -> Result<Vec<PathBuf>, String> {
"Failed to open \"{}\": {}", "Failed to open \"{}\": {}",
&path.display(), &path.display(),
match e.kind() { match e.kind() {
std::io::ErrorKind::NotADirectory =>
String::from("directory expected, but path is not a directory"),
std::io::ErrorKind::NotFound => String::from("not found"), std::io::ErrorKind::NotFound => String::from("not found"),
_ => format!("{:?}", e.kind()), _ => format!("{:?}", e.kind()),
} }
@@ -181,7 +179,7 @@ fn sync_repo(root_path: &Path, repo: &repo::Repo, init_worktree: bool) -> Result
"Repo already exists, but is not using a worktree setup", "Repo already exists, but is not using a worktree setup",
)); ));
}; };
} else if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().is_empty() { } else if repo.remotes.is_none() || repo.remotes.as_ref().unwrap().is_empty() {
print_repo_action( print_repo_action(
&repo.name, &repo.name,
"Repository does not have remotes configured, initializing new", "Repository does not have remotes configured, initializing new",