Merge branch 'develop'

This commit is contained in:
2023-05-06 19:15:46 +02:00
32 changed files with 689 additions and 474 deletions

View File

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

667
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.12" version = "0.7.13"
edition = "2021" edition = "2021"
authors = [ authors = [
@@ -41,36 +41,36 @@ path = "src/grm/main.rs"
[dependencies] [dependencies]
[dependencies.toml] [dependencies.toml]
version = "=0.5.9" version = "=0.7.3"
[dependencies.serde] [dependencies.serde]
version = "=1.0.150" version = "=1.0.162"
features = ["derive"] features = ["derive"]
[dependencies.git2] [dependencies.git2]
version = "=0.15.0" version = "=0.17.1"
[dependencies.shellexpand] [dependencies.shellexpand]
version = "=3.0.0" version = "=3.1.0"
[dependencies.clap] [dependencies.clap]
version = "=4.0.29" version = "=4.2.7"
features = ["derive", "cargo"] features = ["derive", "cargo"]
[dependencies.console] [dependencies.console]
version = "=0.15.2" version = "=0.15.5"
[dependencies.regex] [dependencies.regex]
version = "=1.7.0" version = "=1.8.1"
[dependencies.comfy-table] [dependencies.comfy-table]
version = "=6.1.3" version = "=6.1.4"
[dependencies.serde_yaml] [dependencies.serde_yaml]
version = "=0.9.14" version = "=0.9.21"
[dependencies.serde_json] [dependencies.serde_json]
version = "=1.0.89" version = "=1.0.96"
[dependencies.isahc] [dependencies.isahc]
version = "=1.7.2" version = "=1.7.2"

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 +nightly"
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,27 +52,27 @@ 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 \

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

View File

@@ -34,8 +34,8 @@ You will need the following tools:
[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:

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
╭──────────┬──────────┬────────┬──────────┬───────┬─────────╮ ╭──────────┬──────────┬────────┬──────────┬───────┬─────────╮
@@ -79,5 +86,5 @@ $ grm repos status
By default, the repo configuration uses TOML. If you prefer YAML, just give it a 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 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. 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 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
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) 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,30 +145,33 @@ 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"
repo2 = [r for r in tree["repos"] if r["name"] == "repo2"][0] if exclude == "^.*/repo2$":
assert repo2["worktree_setup"] is False assert [r for r in tree["repos"] if r["name"] == "repo2"] == []
assert isinstance(repo1["remotes"], list) else:
assert len(repo2["remotes"]) == 1 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] origin = [r for r in repo2["remotes"] if r["name"] == "origin"][0]
assert set(origin.keys()) == {"name", "type", "url"} assert set(origin.keys()) == {"name", "type", "url"}
assert origin["type"] == "https" assert origin["type"] == "https"
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 +205,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 +240,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,7 @@ 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 +66,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)
@@ -275,9 +274,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 +474,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 +590,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 +741,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 +872,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 +920,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 +945,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"])
@@ -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,18 +157,20 @@ def test_worktree_add(
] ]
) )
cachefn = lambda nr: "_".join( def cachefn(nr):
[ return "_".join(
str(nr), [
str(default_remote), str(nr),
str(local_branch_exists), str(default_remote),
str(remote_branch_already_exists), str(local_branch_exists),
str(remote_branch_with_prefix_already_exists), str(remote_branch_already_exists),
str(remote_count), str(remote_branch_with_prefix_already_exists),
str(remotes_differ), str(remote_count),
str(worktree_name), str(remotes_differ),
] 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

3
rust-toolchain.toml Normal file
View File

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

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

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

View File

@@ -1,5 +1,4 @@
#![feature(io_error_more)] #![feature(io_error_more)]
#![feature(const_option_ext)]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
use std::path::Path; use std::path::Path;
@@ -19,12 +18,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 +72,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;
} }
@@ -130,10 +140,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

@@ -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

@@ -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);
@@ -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))
} }