63 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
37 changed files with 751 additions and 677 deletions

View File

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

840
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "git-repo-manager"
version = "0.7.12"
version = "0.7.15"
edition = "2021"
authors = [
@@ -23,7 +23,7 @@ repository = "https://github.com/hakoerber/git-repo-manager"
readme = "README.md"
# 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"
@@ -41,36 +41,36 @@ path = "src/grm/main.rs"
[dependencies]
[dependencies.toml]
version = "=0.5.9"
version = "=0.8.6"
[dependencies.serde]
version = "=1.0.150"
version = "=1.0.190"
features = ["derive"]
[dependencies.git2]
version = "=0.15.0"
version = "=0.18.1"
[dependencies.shellexpand]
version = "=3.0.0"
version = "=3.1.0"
[dependencies.clap]
version = "=4.0.29"
version = "=4.4.7"
features = ["derive", "cargo"]
[dependencies.console]
version = "=0.15.2"
version = "=0.15.7"
[dependencies.regex]
version = "=1.7.0"
version = "=1.10.2"
[dependencies.comfy-table]
version = "=6.1.3"
version = "=7.1.0"
[dependencies.serde_yaml]
version = "=0.9.14"
version = "=0.9.27"
[dependencies.serde_json]
version = "=1.0.89"
version = "=1.0.108"
[dependencies.isahc]
version = "=1.7.2"

View File

@@ -4,35 +4,39 @@ set shell := ["/bin/bash", "-c"]
static_target := "x86_64-unknown-linux-musl"
cargo := "cargo"
check: fmt-check lint test
cargo check
{{cargo}} check
clean:
cargo clean
{{cargo}} clean
git clean -f -d -X
fmt:
cargo fmt
{{cargo}} fmt
git ls-files | grep '\.py$' | xargs isort
git ls-files | grep '\.py$' | xargs black
git ls-files | grep '\.sh$' | xargs -L 1 shfmt --indent 4 --write
fmt-check:
cargo fmt --check
{{cargo}} fmt --check
git ls-files | grep '\.py$' | xargs black --check
git ls-files | grep '\.sh$' | xargs -L 1 shfmt --indent 4 --diff
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
lint-fix:
cargo clippy --no-deps --fix
{{cargo}} clippy --no-deps --fix
build-release:
cargo build --release
{{cargo}} build --release
build-release-static:
cargo build --release --target {{static_target}} --features=static-build
{{cargo}} build --release --target {{static_target}} --features=static-build
pushall:
for r in $(git remote) ; do \
@@ -48,38 +52,38 @@ test-binary:
env \
GITHUB_API_BASEURL=http://rest:5000/github \
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:
cargo install --path .
{{cargo}} install --path .
install-static:
cargo install --target {{static_target}} --features=static-build --path .
{{cargo}} install --target {{static_target}} --features=static-build --path .
build:
cargo build
{{cargo}} build
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-unit +tests="":
cargo test --lib --bins -- --show-output {{tests}}
{{cargo}} test --lib --bins -- --show-output {{tests}}
test-integration:
cargo test --test "*"
{{cargo}} test --test "*"
test-e2e +tests=".": test-binary
cd ./e2e_tests \
&& docker-compose rm --stop -f \
&& docker-compose build \
&& docker-compose run \
&& docker compose rm --stop -f \
&& docker compose build \
&& docker compose run \
--rm \
-v $PWD/../target/x86_64-unknown-linux-musl/e2e-tests/grm:/grm \
pytest \
"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

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
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.
# Crates

View File

@@ -1,9 +1,8 @@
#!/usr/bin/env python3
import subprocess
import os
import json
import sys
import os
import subprocess
import semver
import tomlkit
@@ -94,15 +93,7 @@ for tier in ["dependencies", "dev-dependencies"]:
try:
cmd = subprocess.run(
[
"cargo",
"update",
"-Z",
"no-index-update",
"--aggressive",
"--package",
name,
],
["cargo", "update", "--offline", "--aggressive", "--package", name],
check=True,
capture_output=True,
text=True,
@@ -136,15 +127,7 @@ while True:
spec = f"{package['name']}:{package['version']}"
try:
cmd = subprocess.run(
[
"cargo",
"update",
"-Z",
"no-index-update",
"--aggressive",
"--package",
spec,
],
["cargo", "update", "--offline", "--aggressive", "--package", spec],
check=True,
capture_output=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:
* Rust (obviously) (easiest via `rustup`), with the nightly toolchain
* Rust (obviously) (easiest via `rustup`)
* Python3
* [`just`](https://github.com/casey/just), a command runner like `make`. See
[here](https://github.com/casey/just#installation) for installation
instructions (it's most likely just a simple `cargo install just`).
* Docker & docker-compose for the e2e tests
* `black` and `shfmt` for formatting.
* `shellcheck` for shell script linting
* `isort`, `black` and `shfmt` for formatting.
* `ruff` and `shellcheck` for linting.
* `mdbook` for the documentation
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
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
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
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:
@@ -22,13 +22,13 @@ Then, install the build dependencies:
Then, it's a simple command to install the latest stable version:
```bash
$ cargo +nightly install git-repo-manager
$ cargo install git-repo-manager
```
If you're brave, you can also run the development build:
```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
@@ -47,11 +47,11 @@ need `musl` and a few other build dependencies installed installed:
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:
```
$ 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
```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
@@ -30,7 +30,7 @@ $ grm repos sync config --config example.config.toml
If you run it again, it will report no changes:
```
```bash
$ grm repos sync config -c example.config.toml
[] git-repo-manager: 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:
```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`.
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
```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
currently in:
```
```bash
$ cd ~/example-projects/dotfiles
$ grm repos status
╭──────────┬──────────┬────────┬──────────┬───────┬─────────╮
@@ -79,5 +86,5 @@ $ grm repos status
By default, the repo configuration uses TOML. If you prefer YAML, just give it a
YAML file instead (file ending does not matter, `grm` will figure out the
format). For generating a configuration, pass `--format yaml` to `grm repo
format). For generating a configuration, pass `--format yaml` to `grm repo
find` which generates a YAML configuration instead of a TOML configuration.

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
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
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
binary and docker build time. I'd say that keeping it under 10 minutes is a good
idea.

View File

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

View File

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

View File

@@ -1,10 +1,8 @@
import os.path
from app import app
from flask import Flask, request, abort, jsonify, make_response
import jinja2
from app import app
from flask import abort, jsonify, make_response, request
def check_headers():
@@ -48,7 +46,7 @@ def add_pagination(response, page, last_page):
def read_project_files(namespaces=[]):
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"
if not os.path.exists(response_file):
return jsonify([])

View File

@@ -1,10 +1,8 @@
import os.path
from app import app
from flask import Flask, request, abort, jsonify, make_response
import jinja2
from app import app
from flask import abort, jsonify, make_response, request
def check_headers():
@@ -48,7 +46,7 @@ def add_pagination(response, page, last_page):
def read_project_files(namespaces=[]):
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"
if not os.path.exists(response_file):
return jsonify([])

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
#!/usr/bin/env python3
import os
import re
import tempfile
import toml
import pytest
import toml
import yaml
from helpers import *
from helpers import NonExistentPath, TempGitRepository, grm, shell
def test_repos_find_nonexistent():
@@ -40,7 +41,7 @@ def test_repos_find_invalid_format():
)
assert cmd.returncode != 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():
@@ -63,9 +64,10 @@ def test_repos_find_non_git_repos():
assert len(cmd.stderr) != 0
@pytest.mark.parametrize("default", [True, False])
@pytest.mark.parametrize("default_format", [True, False])
@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:
shell(
f"""
@@ -99,13 +101,19 @@ def test_repos_find(configtype, default):
)
args = ["repos", "find", "local", tmpdir]
if not default:
if not default_format:
args += ["--format", configtype]
if exclude:
args += ["--exclude", exclude]
cmd = grm(args)
assert cmd.returncode == 0
assert len(cmd.stderr) == 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
if default or configtype == "toml":
if default_format or configtype == "toml":
output = toml.loads(cmd.stdout)
elif configtype == "yaml":
output = yaml.safe_load(cmd.stdout)
@@ -120,7 +128,7 @@ def test_repos_find(configtype, default):
assert set(tree.keys()) == {"root", "repos"}
assert tree["root"] == tmpdir
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]
assert repo1["worktree_setup"] is False
@@ -137,30 +145,32 @@ def test_repos_find(configtype, default):
assert someremote["type"] == "ssh"
assert someremote["url"] == "ssh://example.com/repo2.git"
repo2 = [r for r in tree["repos"] if r["name"] == "repo2"][0]
assert repo2["worktree_setup"] is False
assert isinstance(repo1["remotes"], list)
assert len(repo2["remotes"]) == 1
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]
assert repo2["worktree_setup"] is False
assert isinstance(repo1["remotes"], list)
assert len(repo2["remotes"]) == 1
origin = [r for r in repo2["remotes"] if r["name"] == "origin"][0]
assert set(origin.keys()) == {"name", "type", "url"}
assert origin["type"] == "https"
assert origin["url"] == "https://example.com/repo2.git"
origin = [r for r in repo2["remotes"] if r["name"] == "origin"][0]
assert set(origin.keys()) == {"name", "type", "url"}
assert origin["type"] == "https"
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"])
def test_repos_find_in_root(configtype, default):
def test_repos_find_in_root(configtype, default_format):
with TempGitRepository() as repo_dir:
args = ["repos", "find", "local", repo_dir]
if not default:
if not default_format:
args += ["--format", configtype]
cmd = grm(args)
assert cmd.returncode == 0
assert len(cmd.stderr) == 0
if default or configtype == "toml":
if default_format or configtype == "toml":
output = toml.loads(cmd.stdout)
elif configtype == "yaml":
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("default", [True, False])
def test_repos_find_with_invalid_repo(configtype, default):
@pytest.mark.parametrize("default_format", [True, False])
def test_repos_find_with_invalid_repo(configtype, default_format):
with tempfile.TemporaryDirectory() as tmpdir:
shell(
f"""
@@ -229,13 +239,13 @@ def test_repos_find_with_invalid_repo(configtype, default):
)
args = ["repos", "find", "local", tmpdir]
if not default:
if not default_format:
args += ["--format", configtype]
cmd = grm(args)
assert cmd.returncode == 0
assert "broken" in cmd.stderr
if default or configtype == "toml":
if default_format or configtype == "toml":
output = toml.loads(cmd.stdout)
elif configtype == "yaml":
output = yaml.safe_load(cmd.stdout)

View File

@@ -1,14 +1,13 @@
#!/usr/bin/env python3
import re
import os
import re
import tempfile
import toml
import pytest
import toml
import yaml
from helpers import *
from helpers import grm
ALTERNATE_DOMAIN = os.environ["ALTERNATE_DOMAIN"]
PROVIDERS = ["github", "gitlab"]
@@ -44,7 +43,9 @@ def test_repos_find_remote_invalid_provider(use_config):
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
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)
@@ -67,7 +68,7 @@ def test_repos_find_remote_invalid_format(provider):
)
assert cmd.returncode != 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)
@@ -275,9 +276,9 @@ def test_repos_find_remote_user(
if not worktree_default:
cfg += f"worktree = {str(worktree).lower()}\n"
if force_ssh:
cfg += f"force_ssh = true\n"
cfg += "force_ssh = true\n"
if override_remote_name:
cfg += f'remote_name = "otherremote"\n'
cfg += 'remote_name = "otherremote"\n'
if use_owner:
cfg += """
[filters]
@@ -475,7 +476,7 @@ def test_repos_find_remote_group(
if not worktree_default:
cfg += f"worktree = {str(worktree).lower()}\n"
if force_ssh:
cfg += f"force_ssh = true\n"
cfg += "force_ssh = true\n"
if use_alternate_endpoint:
cfg += f'api_url = "http://{ALTERNATE_DOMAIN}:5000/{provider}"\n'
cfg += """
@@ -591,7 +592,7 @@ def test_repos_find_remote_user_and_group(
if not worktree_default:
cfg += f"worktree = {str(worktree).lower()}\n"
if force_ssh:
cfg += f"force_ssh = true\n"
cfg += "force_ssh = true\n"
if use_alternate_endpoint:
cfg += f'api_url = "http://{ALTERNATE_DOMAIN}:5000/{provider}"\n'
cfg += """
@@ -742,7 +743,7 @@ def test_repos_find_remote_owner(
if not worktree_default:
cfg += f"worktree = {str(worktree).lower()}\n"
if force_ssh:
cfg += f"force_ssh = true\n"
cfg += "force_ssh = true\n"
if use_alternate_endpoint:
cfg += f'api_url = "http://{ALTERNATE_DOMAIN}:5000/{provider}"\n'
cfg += """
@@ -873,13 +874,11 @@ def test_repos_find_remote_owner(
assert repo["remotes"][0]["name"] == "origin"
if force_ssh:
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"
else:
assert (
repo["remotes"][0]["url"] == f"https://example.com/myuser2/myproject3.git"
)
assert repo["remotes"][0]["url"] == "https://example.com/myuser2/myproject3.git"
assert repo["remotes"][0]["type"] == "https"
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:
assert (
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"
else:
assert (
repo["remotes"][0]["url"]
== f"https://example.com/mygroup1/myproject4.git"
== "https://example.com/mygroup1/myproject4.git"
)
assert repo["remotes"][0]["type"] == "https"
@@ -948,12 +947,11 @@ def test_repos_find_remote_owner(
assert repo["remotes"][0]["name"] == "origin"
if force_ssh:
assert (
repo["remotes"][0]["url"]
== f"ssh://git@example.com/mygroup2/myproject5.git"
repo["remotes"][0]["url"] == "ssh://git@example.com/mygroup2/myproject5.git"
)
assert repo["remotes"][0]["type"] == "ssh"
else:
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"

View File

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

View File

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

View File

@@ -1,8 +1,17 @@
#!/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():
@@ -153,13 +162,13 @@ def test_worktree_clean_configured_default_branch(
with open(os.path.join(base_dir, "grm.toml"), "w") as f:
if branch_list_empty:
f.write(
f"""
"""
persistent_branches = []
"""
)
else:
f.write(
f"""
"""
persistent_branches = [
"mybranch"
]

View File

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

View File

@@ -1,8 +1,16 @@
#!/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():

View File

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

View File

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

View File

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

View File

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

3
rust-toolchain.toml Normal file
View File

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

View File

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

View File

@@ -199,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),
Err(error) => {
print_error(&error);

View File

@@ -1,5 +1,3 @@
#![feature(io_error_more)]
#![feature(const_option_ext)]
#![forbid(unsafe_code)]
use std::path::Path;
@@ -19,12 +17,22 @@ pub mod worktree;
/// The bool in the return value specifies whether there is a repository
/// in root itself.
#[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 repo_in_root = false;
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)? {
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);
if path == root {
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 url = remote.url();
let remote_type = match repo::detect_remote_type(&url) {
Some(t) => t,
None => {
Ok(t) => t,
Err(e) => {
warnings.push(format!(
"{}: Could not detect remote type of \"{}\"",
"{}: Could not handle URL {}. Reason: {}",
&path::path_as_string(&path),
&url
&url,
e
));
continue;
}
@@ -130,10 +139,14 @@ fn find_repos(root: &Path) -> Result<Option<(Vec<repo::Repo>, Vec<String>, bool)
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 (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)) => {
warnings.append(&mut repo_warnings);
(vec, repo_in_root)

View File

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

View File

@@ -9,7 +9,10 @@ use super::Project;
use super::Provider;
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)]
#[serde(rename_all = "lowercase")]

View File

@@ -313,7 +313,7 @@ pub trait Provider {
// about the data exchange format here.
repo.remove_namespace();
ret.entry(namespace).or_insert(vec![]).push(repo);
ret.entry(namespace).or_default().push(repo);
}
Ok(ret)

View File

@@ -406,50 +406,78 @@ mod tests {
fn check_ssh_remote() {
assert_eq!(
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]
fn check_https_remote() {
assert_eq!(
detect_remote_type("https://example.com"),
Some(RemoteType::Https)
Ok(RemoteType::Https)
);
assert_eq!(
detect_remote_type("https://example.com/test.git"),
Some(RemoteType::Https)
Ok(RemoteType::Https)
);
}
#[test]
fn check_file_remote() {
assert_eq!(
detect_remote_type("file:///somedir"),
Some(RemoteType::File)
);
assert_eq!(detect_remote_type("file:///somedir"), Ok(RemoteType::File));
}
#[test]
fn check_invalid_remotes() {
assert_eq!(detect_remote_type("https//example.com"), None);
assert_eq!(detect_remote_type("https:example.com"), None);
assert_eq!(detect_remote_type("ssh//example.com"), None);
assert_eq!(detect_remote_type("ssh:example.com"), None);
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("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]
#[should_panic]
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]
#[should_panic]
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]
@@ -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();
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") {
return Some(RemoteType::Ssh);
return Ok(RemoteType::Ssh);
}
if remote_url.starts_with("https://") {
return Some(RemoteType::Https);
return Ok(RemoteType::Https);
}
if remote_url.starts_with("file://") {
return Some(RemoteType::File);
return Ok(RemoteType::File);
}
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://") {
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);
@@ -1588,7 +1620,7 @@ impl RemoteHandle<'_> {
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"))
.ok_or_else(|| String::from("Could not detect remote type"))?;
.expect("Could not detect remote type");
Ok(matches!(remote_type, RemoteType::Ssh | RemoteType::File))
}

View File

@@ -4,6 +4,7 @@ use super::repo;
use comfy_table::{Cell, Table};
use std::fmt::Write;
use std::path::Path;
fn add_table_header(table: &mut Table) {
@@ -56,9 +57,10 @@ fn add_repo_status(
repo_status
.branches
.iter()
.map(|(branch_name, remote_branch)| {
format!(
"branch: {}{}\n",
.fold(String::new(), |mut s, (branch_name, remote_branch)| {
writeln!(
&mut s,
"branch: {}{}",
&branch_name,
&match remote_branch {
None => String::from(" <!local>"),
@@ -78,8 +80,9 @@ fn add_repo_status(
}
}
)
.unwrap();
s
})
.collect::<String>()
.trim(),
&match is_worktree {
true => String::from(""),
@@ -91,8 +94,10 @@ fn add_repo_status(
repo_status
.remotes
.iter()
.map(|r| format!("{}\n", r))
.collect::<String>()
.fold(String::new(), |mut s, r| {
writeln!(&mut s, "{r}").unwrap();
s
})
.trim(),
]);

View File

@@ -128,8 +128,6 @@ pub fn find_repo_paths(path: &Path) -> Result<Vec<PathBuf>, String> {
"Failed to open \"{}\": {}",
&path.display(),
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"),
_ => 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",
));
};
} 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(
&repo.name,
"Repository does not have remotes configured, initializing new",