diff --git a/Cargo.lock b/Cargo.lock index 4b3e337..b87428c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,6 +195,7 @@ dependencies = [ "git2", "regex", "serde", + "serde_yaml", "shellexpand", "tempdir", "toml", @@ -336,6 +337,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" + [[package]] name = "lock_api" version = "0.4.5" @@ -592,6 +599,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "ryu" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" + [[package]] name = "scopeguard" version = "1.1.0" @@ -600,24 +613,36 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.134" +version = "1.0.135" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b3c34c1690edf8174f5b289a336ab03f568a4460d8c6df75f2f3a692b3bc6a" +checksum = "2cf9235533494ea2ddcdb794665461814781c53f19d87b76e571a1c35acbad2b" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.134" +version = "1.0.135" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784ed1fbfa13fe191077537b0d70ec8ad1e903cfe04831da608aa36457cb653d" +checksum = "8dcde03d87d4c973c04be249e7d8f0b35db1c848c487bd43032808e59dd8328d" dependencies = [ "proc-macro2", "quote", "syn", ] +[[package]] +name = "serde_yaml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a521f2940385c165a24ee286aa8599633d162077a54bdcae2a6fd5a7bfa7a0" +dependencies = [ + "indexmap", + "ryu", + "serde", + "yaml-rust", +] + [[package]] name = "shellexpand" version = "2.1.0" @@ -850,3 +875,12 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] diff --git a/Cargo.toml b/Cargo.toml index 2f6d8ff..27c5e13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ path = "src/grm/main.rs" version = "=0.5.8" [dependencies.serde] -version = "=1.0.134" +version = "=1.0.135" features = ["derive"] [dependencies.git2] @@ -62,5 +62,8 @@ version = "=1.5.4" [dependencies.comfy-table] version = "=5.0.0" +[dependencies.serde_yaml] +version = "=0.8.23" + [dev-dependencies.tempdir] version = "=0.3.7" diff --git a/Justfile b/Justfile index 3f97304..bf74f55 100644 --- a/Justfile +++ b/Justfile @@ -52,3 +52,6 @@ check-pip-requirements: e2e-venv @cd ./e2e_tests \ && . ./venv/bin/activate \ && pip list --outdated | grep -q '.' && exit 1 || exit 0 + +clean: + cargo clean diff --git a/depcheck/update-cargo-dependencies.py b/depcheck/update-cargo-dependencies.py index 8f8f533..5041fe7 100755 --- a/depcheck/update-cargo-dependencies.py +++ b/depcheck/update-cargo-dependencies.py @@ -14,7 +14,13 @@ AUTOUPDATE_DISABLED = [] if os.path.exists(INDEX_DIR): subprocess.run( - ["git", "pull", "--depth=1", "origin"], + ["git", "fetch", "--depth=1", "origin"], + cwd=INDEX_DIR, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "reset", "--hard", "origin/master"], cwd=INDEX_DIR, check=True, capture_output=True, @@ -33,7 +39,7 @@ update_necessary = False # This updates the crates.io index, see https://github.com/rust-lang/cargo/issues/3377 subprocess.run( - ["cargo", "search", "--limit", "0"], + ["cargo", "update", "--dry-run"], check=True, capture_output=False, # to get some git output ) diff --git a/docs/src/overview.md b/docs/src/overview.md index 7583d19..082678a 100644 --- a/docs/src/overview.md +++ b/docs/src/overview.md @@ -5,7 +5,8 @@ Manager](https://github.com/hakoerber/git-repo-manager/) (GRM for short), a tool that helps you manage git repositories. GRM helps you manage git repositories in a declarative way. Configure your -repositories in a TOML file, GRM does the rest. Take a look at [the example +repositories in a TOML or YAML file, GRM does the rest. Take a look at [the +example configuration](https://github.com/hakoerber/git-repo-manager/blob/master/example.config.toml) to get a feel for the way you configure your repositories. See the [repository tree chapter](./repos.md) for details. diff --git a/docs/src/repos.md b/docs/src/repos.md index 32d6ccb..69004f8 100644 --- a/docs/src/repos.md +++ b/docs/src/repos.md @@ -74,3 +74,9 @@ $ grm repos status ╰──────────┴──────────┴────────┴──────────┴───────┴─────────╯ ``` +## YAML + +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 +itself). For generating a configuration, pass `--format yaml` to `grm repo find` +to generate YAML instead of TOML. diff --git a/docs/src/worktrees.md b/docs/src/worktrees.md index 847cdad..72258f8 100644 --- a/docs/src/worktrees.md +++ b/docs/src/worktrees.md @@ -309,6 +309,10 @@ grm wt pull --rebase [✔] my-cool-branch: Done ``` +As noted, this will fail if there are any local changes in your worktree. If you +want to stash these changes automatically before the pull (and unstash them +afterwards), use the `--stash` option. + This will rebase your changes onto the upstream branch. This is mainly helpful for persistent branches that change on the remote side. @@ -346,6 +350,10 @@ run two commands. I understand that the UX is not the most intuitive. If you can think of an improvement, please let me know (e.g. via an GitHub issue)! +As with `pull`, `rebase` will also refuse to run when there are changes in your +worktree. And you can also use the `--stash` option to stash/unstash changes +automatically. + ### Manual access GRM isn't doing any magic, it's just git under the hood. If you need to have access diff --git a/e2e_tests/requirements.txt b/e2e_tests/requirements.txt index aeccd82..fe7936d 100644 --- a/e2e_tests/requirements.txt +++ b/e2e_tests/requirements.txt @@ -7,6 +7,7 @@ pluggy==1.0.0 py==1.11.0 pyparsing==3.0.7 pytest==6.2.5 +PyYAML==6.0 smmap==5.0.0 toml==0.10.2 typing_extensions==4.0.1 diff --git a/e2e_tests/test_repos_find.py b/e2e_tests/test_repos_find.py index 2939e95..cf6e692 100644 --- a/e2e_tests/test_repos_find.py +++ b/e2e_tests/test_repos_find.py @@ -3,6 +3,8 @@ import tempfile import toml +import pytest +import yaml from helpers import * @@ -30,6 +32,16 @@ def test_repos_find_empty(): assert len(cmd.stderr) != 0 +def test_repos_find_invalid_format(): + with tempfile.TemporaryDirectory() as tmpdir: + cmd = grm( + ["repos", "find", tmpdir, "--format", "invalidformat"], is_invalid=True + ) + assert cmd.returncode != 0 + assert len(cmd.stdout) == 0 + assert "isn't a valid value" in cmd.stderr + + def test_repos_find_non_git_repos(): with tempfile.TemporaryDirectory() as tmpdir: shell( @@ -50,7 +62,9 @@ def test_repos_find_non_git_repos(): assert len(cmd.stderr) != 0 -def test_repos_find(): +@pytest.mark.parametrize("default", [True, False]) +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_find(configtype, default): with tempfile.TemporaryDirectory() as tmpdir: shell( f""" @@ -83,11 +97,19 @@ def test_repos_find(): """ ) - cmd = grm(["repos", "find", tmpdir]) + args = ["repos", "find", tmpdir] + if not default: + args += ["--format", configtype] + cmd = grm(args) assert cmd.returncode == 0 assert len(cmd.stderr) == 0 - output = toml.loads(cmd.stdout) + if default or configtype == "toml": + output = toml.loads(cmd.stdout) + elif configtype == "yaml": + output = yaml.safe_load(cmd.stdout) + else: + raise NotImplementedError() assert isinstance(output, dict) assert set(output.keys()) == {"trees"} @@ -125,14 +147,24 @@ def test_repos_find(): assert origin["url"] == "https://example.com/repo2.git" -def test_repos_find_in_root(): +@pytest.mark.parametrize("default", [True, False]) +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_find_in_root(configtype, default): with TempGitRepository() as repo_dir: - cmd = grm(["repos", "find", repo_dir]) + args = ["repos", "find", repo_dir] + if not default: + args += ["--format", configtype] + cmd = grm(args) assert cmd.returncode == 0 assert len(cmd.stderr) == 0 - output = toml.loads(cmd.stdout) + if default or configtype == "toml": + output = toml.loads(cmd.stdout) + elif configtype == "yaml": + output = yaml.safe_load(cmd.stdout) + else: + raise NotImplementedError() assert isinstance(output, dict) assert set(output.keys()) == {"trees"} @@ -160,7 +192,9 @@ def test_repos_find_in_root(): assert someremote["type"] == "file" -def test_repos_find_with_invalid_repo(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +@pytest.mark.parametrize("default", [True, False]) +def test_repos_find_with_invalid_repo(configtype, default): with tempfile.TemporaryDirectory() as tmpdir: shell( f""" @@ -193,11 +227,19 @@ def test_repos_find_with_invalid_repo(): """ ) - cmd = grm(["repos", "find", tmpdir]) + args = ["repos", "find", tmpdir] + if not default: + args += ["--format", configtype] + cmd = grm(args) assert cmd.returncode == 0 assert "broken" in cmd.stderr - output = toml.loads(cmd.stdout) + if default or configtype == "toml": + output = toml.loads(cmd.stdout) + elif configtype == "yaml": + output = yaml.safe_load(cmd.stdout) + else: + raise NotImplementedError() assert isinstance(output, dict) assert set(output.keys()) == {"trees"} diff --git a/e2e_tests/test_repos_sync.py b/e2e_tests/test_repos_sync.py index 62e0cca..2586943 100644 --- a/e2e_tests/test_repos_sync.py +++ b/e2e_tests/test_repos_sync.py @@ -2,6 +2,7 @@ import tempfile import re +import textwrap import pytest import toml @@ -9,8 +10,134 @@ import git from helpers import * +templates = { + "repo_simple": { + "toml": """ + [[trees]] + root = "{root}" -def test_repos_sync_config_is_valid_symlink(): + [[trees.repos]] + name = "test" + """, + "yaml": """ + trees: + - root: "{root}" + repos: + - name: "test" + """, + }, + "repo_with_remote": { + "toml": """ + [[trees]] + root = "{root}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "{remotename}" + url = "file://{remote}" + type = "file" + """, + "yaml": textwrap.dedent( + """ + trees: + - root: "{root}" + repos: + - name: test + remotes: + - name: "{remotename}" + url: "file://{remote}" + type: "file" + """ + ), + }, + "repo_with_two_remotes": { + "toml": """ + [[trees]] + root = "{root}" + + [[trees.repos]] + name = "test" + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote1}" + type = "file" + + [[trees.repos.remotes]] + name = "origin2" + url = "file://{remote2}" + type = "file" + """, + "yaml": textwrap.dedent( + """ + trees: + - root: "{root}" + repos: + - name: "test" + remotes: + - name: "origin" + url: "file://{remote1}" + type: "file" + - name: "origin2" + url: "file://{remote2}" + type: "file" + """ + ), + }, + "worktree_repo_simple": { + "toml": """ + [[trees]] + root = "{root}" + + [[trees.repos]] + name = "test" + worktree_setup = true + """, + "yaml": textwrap.dedent( + """ + trees: + - root: "{root}" + repos: + - name: test + worktree_setup: true + """ + ), + }, + "worktree_repo_with_remote": { + "toml": """ + [[trees]] + root = "{root}" + + [[trees.repos]] + name = "test" + worktree_setup = true + + [[trees.repos.remotes]] + name = "origin" + url = "file://{remote}" + type = "file" + """, + "yaml": textwrap.dedent( + """ + trees: + - root: "{root}" + repos: + - name: test + worktree_setup: true + remotes: + - name: origin + url: "file://{remote}" + type: "file" + """ + ), + }, +} + + +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_config_is_valid_symlink(configtype): with tempfile.TemporaryDirectory() as target: with TempGitFileRemote() as (remote, head_commit_sha): with tempfile.NamedTemporaryFile() as config: @@ -20,20 +147,13 @@ def test_repos_sync_config_is_valid_symlink(): with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote}" - type = "file" - """ + templates["repo_with_remote"][configtype].format( + root=target, remote=remote, remotename="origin" + ) ) + subprocess.run(["cat", config.name]) + cmd = grm(["repos", "sync", "--config", config_symlink]) assert cmd.returncode == 0 @@ -85,20 +205,13 @@ def test_repos_sync_config_is_unreadable(): assert "permission denied" in cmd.stderr.lower() -def test_repos_sync_unmanaged_repos(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_unmanaged_repos(configtype): with tempfile.TemporaryDirectory() as root: with TempGitRepository(dir=root) as unmanaged_repo: with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: - f.write( - f""" - [[trees]] - root = "{root}" - - [[trees.repos]] - name = "test" - """ - ) + f.write(templates["repo_simple"][configtype].format(root=root)) cmd = grm(["repos", "sync", "--config", config.name]) assert cmd.returncode == 0 @@ -112,19 +225,12 @@ def test_repos_sync_unmanaged_repos(): assert any([re.match(regex, l) for l in cmd.stderr.lower().split("\n")]) -def test_repos_sync_root_is_file(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_root_is_file(configtype): with tempfile.NamedTemporaryFile() as target: with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: - f.write( - f""" - [[trees]] - root = "{target.name}" - - [[trees.repos]] - name = "test" - """ - ) + f.write(templates["repo_simple"][configtype].format(root=target.name)) cmd = grm(["repos", "sync", "--config", config.name]) assert cmd.returncode != 0 @@ -132,30 +238,17 @@ def test_repos_sync_root_is_file(): assert "not a directory" in cmd.stderr.lower() -def test_repos_sync_normal_clone(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_normal_clone(configtype): with tempfile.TemporaryDirectory() as target: with TempGitFileRemote() as (remote1, remote1_head_commit_sha): with TempGitFileRemote() as (remote2, remote2_head_commit_sha): with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote1}" - type = "file" - - [[trees.repos.remotes]] - name = "origin2" - url = "file://{remote2}" - type = "file" - """ + templates["repo_with_two_remotes"][configtype].format( + root=target, remote1=remote1, remote2=remote2 + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -183,19 +276,12 @@ def test_repos_sync_normal_clone(): assert urls[0] == f"file://{remote2}" -def test_repos_sync_normal_init(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_normal_init(configtype): with tempfile.TemporaryDirectory() as target: with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: - f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - """ - ) + f.write(templates["repo_simple"][configtype].format(root=target)) cmd = grm(["repos", "sync", "--config", config.name]) assert cmd.returncode == 0 @@ -210,25 +296,17 @@ def test_repos_sync_normal_init(): assert not repo.head.is_valid() -def test_repos_sync_normal_add_remote(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_normal_add_remote(configtype): with tempfile.TemporaryDirectory() as target: with TempGitFileRemote() as (remote1, remote1_head_commit_sha): with TempGitFileRemote() as (remote2, remote2_head_commit_sha): with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote1}" - type = "file" - """ + templates["repo_with_remote"][configtype].format( + root=target, remote=remote1, remotename="origin" + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -246,23 +324,9 @@ def test_repos_sync_normal_add_remote(): with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote1}" - type = "file" - - [[trees.repos.remotes]] - name = "origin2" - url = "file://{remote2}" - type = "file" - """ + templates["repo_with_two_remotes"][configtype].format( + root=target, remote1=remote1, remote2=remote2 + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -282,30 +346,17 @@ def test_repos_sync_normal_add_remote(): assert urls[0] == f"file://{remote2}" -def test_repos_sync_normal_remove_remote(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_normal_remove_remote(configtype): with tempfile.TemporaryDirectory() as target: with TempGitFileRemote() as (remote1, remote1_head_commit_sha): with TempGitFileRemote() as (remote2, remote2_head_commit_sha): with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote1}" - type = "file" - - [[trees.repos.remotes]] - name = "origin2" - url = "file://{remote2}" - type = "file" - """ + templates["repo_with_two_remotes"][configtype].format( + root=target, remote1=remote1, remote2=remote2 + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -326,18 +377,9 @@ def test_repos_sync_normal_remove_remote(): with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin2" - url = "file://{remote2}" - type = "file" - """ + templates["repo_with_remote"][configtype].format( + root=target, remote=remote2, remotename="origin2" + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -369,25 +411,17 @@ def test_repos_sync_normal_remove_remote(): assert urls[0] == f"file://{remote2}" -def test_repos_sync_normal_change_remote_url(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_normal_change_remote_url(configtype): with tempfile.TemporaryDirectory() as target: with TempGitFileRemote() as (remote1, remote1_head_commit_sha): with TempGitFileRemote() as (remote2, remote2_head_commit_sha): with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote1}" - type = "file" - """ + templates["repo_with_remote"][configtype].format( + root=target, remote=remote1, remotename="origin" + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -405,18 +439,9 @@ def test_repos_sync_normal_change_remote_url(): with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote2}" - type = "file" - """ + templates["repo_with_remote"][configtype].format( + root=target, remote=remote2, remotename="origin" + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -429,25 +454,17 @@ def test_repos_sync_normal_change_remote_url(): assert urls[0] == f"file://{remote2}" -def test_repos_sync_normal_change_remote_name(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_normal_change_remote_name(configtype): with tempfile.TemporaryDirectory() as target: with TempGitFileRemote() as (remote1, remote1_head_commit_sha): with TempGitFileRemote() as (remote2, remote2_head_commit_sha): with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote1}" - type = "file" - """ + templates["repo_with_remote"][configtype].format( + root=target, remote=remote1, remotename="origin" + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -465,18 +482,9 @@ def test_repos_sync_normal_change_remote_name(): with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin2" - url = "file://{remote1}" - type = "file" - """ + templates["repo_with_remote"][configtype].format( + root=target, remote=remote1, remotename="origin2" + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -492,25 +500,16 @@ def test_repos_sync_normal_change_remote_name(): assert urls[0] == f"file://{remote1}" -def test_repos_sync_worktree_clone(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_worktree_clone(configtype): with tempfile.TemporaryDirectory() as target: with TempGitFileRemote() as (remote, head_commit_sha): with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - worktree_setup = true - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote}" - type = "file" - """ + templates["worktree_repo_with_remote"][configtype].format( + root=target, remote=remote, remotename="origin" + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -530,19 +529,13 @@ def test_repos_sync_worktree_clone(): assert str(repo.head.commit) == head_commit_sha -def test_repos_sync_worktree_init(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_worktree_init(configtype): with tempfile.TemporaryDirectory() as target: with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - worktree_setup = true - """ + templates["worktree_repo_simple"][configtype].format(root=target) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -559,43 +552,42 @@ def test_repos_sync_worktree_init(): assert not repo.head.is_valid() -def test_repos_sync_invalid_toml(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_invalid_syntax(configtype): with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: - f.write( - f""" - [[trees]] - root = invalid as there are no quotes ;) - """ - ) + if configtype == "toml": + f.write( + f""" + [[trees]] + root = invalid as there are no quotes ;) + """ + ) + elif configtype == "yaml": + f.write( + f""" + trees: + wrong: + indentation: + """ + ) + else: + raise NotImplementedError() cmd = grm(["repos", "sync", "--config", config.name]) assert cmd.returncode != 0 -def test_repos_sync_unchanged(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_unchanged(configtype): with tempfile.TemporaryDirectory() as target: with TempGitFileRemote() as (remote1, remote1_head_commit_sha): with TempGitFileRemote() as (remote2, remote2_head_commit_sha): with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote1}" - type = "file" - - [[trees.repos.remotes]] - name = "origin2" - url = "file://{remote2}" - type = "file" - """ + templates["repo_with_two_remotes"][configtype].format( + root=target, remote1=remote1, remote2=remote2 + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -609,25 +601,17 @@ def test_repos_sync_unchanged(): assert before == after -def test_repos_sync_normal_change_to_worktree(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_normal_change_to_worktree(configtype): with tempfile.TemporaryDirectory() as target: with TempGitFileRemote() as (remote1, remote1_head_commit_sha): with TempGitFileRemote() as (remote2, remote2_head_commit_sha): with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote1}" - type = "file" - """ + templates["repo_with_remote"][configtype].format( + root=target, remote=remote1, remotename="origin" + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -637,19 +621,9 @@ def test_repos_sync_normal_change_to_worktree(): with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - worktree_setup = true - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote1}" - type = "file" - """ + templates["worktree_repo_with_remote"][configtype].format( + root=target, remote=remote1, remotename="origin" + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -658,26 +632,17 @@ def test_repos_sync_normal_change_to_worktree(): assert "not using a worktree setup" in cmd.stderr -def test_repos_sync_worktree_change_to_normal(): +@pytest.mark.parametrize("configtype", ["toml", "yaml"]) +def test_repos_sync_worktree_change_to_normal(configtype): with tempfile.TemporaryDirectory() as target: with TempGitFileRemote() as (remote1, remote1_head_commit_sha): with TempGitFileRemote() as (remote2, remote2_head_commit_sha): with tempfile.NamedTemporaryFile() as config: with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - worktree_setup = true - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote1}" - type = "file" - """ + templates["worktree_repo_with_remote"][configtype].format( + root=target, remote=remote1, remotename="origin" + ) ) cmd = grm(["repos", "sync", "--config", config.name]) @@ -687,18 +652,9 @@ def test_repos_sync_worktree_change_to_normal(): with open(config.name, "w") as f: f.write( - f""" - [[trees]] - root = "{target}" - - [[trees.repos]] - name = "test" - - [[trees.repos.remotes]] - name = "origin" - url = "file://{remote1}" - type = "file" - """ + templates["repo_with_remote"][configtype].format( + root=target, remote=remote1, remotename="origin" + ) ) cmd = grm(["repos", "sync", "--config", config.name]) diff --git a/e2e_tests/test_worktree_fetch.py b/e2e_tests/test_worktree_fetch.py index 1c165a9..df01462 100644 --- a/e2e_tests/test_worktree_fetch.py +++ b/e2e_tests/test_worktree_fetch.py @@ -2,6 +2,8 @@ from helpers import * +import re + import pytest import git @@ -51,7 +53,9 @@ def test_worktree_fetch(): @pytest.mark.parametrize("rebase", [True, False]) @pytest.mark.parametrize("ffable", [True, False]) -def test_worktree_pull(rebase, ffable): +@pytest.mark.parametrize("has_changes", [True, False]) +@pytest.mark.parametrize("stash", [True, False]) +def test_worktree_pull(rebase, ffable, has_changes, stash): with TempGitRepositoryWorktree() as (base_dir, root_commit): with TempGitFileRemote() as (remote_path, _remote_sha): shell( @@ -94,51 +98,79 @@ def test_worktree_pull(rebase, ffable): """ ) + if has_changes: + shell( + f""" + cd {base_dir}/master + echo change >> root-commit-in-worktree-1 + echo uncommitedchange > uncommitedchange + """ + ) + args = ["wt", "pull"] if rebase: args += ["--rebase"] + if stash: + args += ["--stash"] cmd = grm(args, cwd=base_dir) - assert cmd.returncode == 0 - - assert repo.commit("upstream/master").hexsha == remote_commit - assert repo.commit("origin/master").hexsha == root_commit - assert ( - repo.commit("master").hexsha != repo.commit("origin/master").hexsha - ) - - if not rebase: - if ffable: - assert ( - repo.commit("master").hexsha - != repo.commit("origin/master").hexsha - ) - assert ( - repo.commit("master").hexsha - == repo.commit("upstream/master").hexsha - ) - assert repo.commit("upstream/master").hexsha == remote_commit - else: - assert "cannot be fast forwarded" in cmd.stderr - assert ( - repo.commit("master").hexsha - != repo.commit("origin/master").hexsha - ) - assert repo.commit("master").hexsha != remote_commit - assert repo.commit("upstream/master").hexsha == remote_commit + if has_changes and not stash: + assert cmd.returncode != 0 + assert re.match(r".*master.*contains changes.*", cmd.stderr) else: - if ffable: - assert ( - repo.commit("master").hexsha - != repo.commit("origin/master").hexsha - ) - assert ( - repo.commit("master").hexsha - == repo.commit("upstream/master").hexsha - ) - assert repo.commit("upstream/master").hexsha == remote_commit + assert repo.commit("upstream/master").hexsha == remote_commit + assert repo.commit("origin/master").hexsha == root_commit + assert ( + repo.commit("master").hexsha + != repo.commit("origin/master").hexsha + ) + if has_changes: + assert ["uncommitedchange"] == repo.untracked_files + assert repo.is_dirty() else: - assert ( - repo.commit("master").message.strip() - == "local-commit-in-master" - ) - assert repo.commit("master~1").hexsha == remote_commit + assert not repo.is_dirty() + + if not rebase: + if ffable: + assert cmd.returncode == 0 + assert ( + repo.commit("master").hexsha + != repo.commit("origin/master").hexsha + ) + assert ( + repo.commit("master").hexsha + == repo.commit("upstream/master").hexsha + ) + assert ( + repo.commit("upstream/master").hexsha == remote_commit + ) + else: + assert cmd.returncode != 0 + assert "cannot be fast forwarded" in cmd.stderr + assert ( + repo.commit("master").hexsha + != repo.commit("origin/master").hexsha + ) + assert repo.commit("master").hexsha != remote_commit + assert ( + repo.commit("upstream/master").hexsha == remote_commit + ) + else: + assert cmd.returncode == 0 + if ffable: + assert ( + repo.commit("master").hexsha + != repo.commit("origin/master").hexsha + ) + assert ( + repo.commit("master").hexsha + == repo.commit("upstream/master").hexsha + ) + assert ( + repo.commit("upstream/master").hexsha == remote_commit + ) + else: + assert ( + repo.commit("master").message.strip() + == "local-commit-in-master" + ) + assert repo.commit("master~1").hexsha == remote_commit diff --git a/e2e_tests/test_worktree_rebase.py b/e2e_tests/test_worktree_rebase.py index c913aaf..a64f755 100644 --- a/e2e_tests/test_worktree_rebase.py +++ b/e2e_tests/test_worktree_rebase.py @@ -2,15 +2,18 @@ from helpers import * -import pytest +import re +import pytest import git @pytest.mark.parametrize("pull", [True, False]) @pytest.mark.parametrize("rebase", [True, False]) @pytest.mark.parametrize("ffable", [True, False]) -def test_worktree_rebase(pull, rebase, ffable): +@pytest.mark.parametrize("has_changes", [True, False]) +@pytest.mark.parametrize("stash", [True, False]) +def test_worktree_rebase(pull, rebase, ffable, has_changes, stash): with TempGitRepositoryWorktree() as (base_dir, _root_commit): with open(os.path.join(base_dir, "grm.toml"), "w") as f: f.write('persistent_branches = ["mybasebranch"]') @@ -83,6 +86,14 @@ def test_worktree_rebase(pull, rebase, ffable): """ ) + if has_changes: + shell( + f""" + cd {base_dir}/myfeatbranch + echo uncommitedchange > uncommitedchange + """ + ) + grm(["wt", "delete", "--force", "tmp"], cwd=base_dir) repo = git.Repo(f"{base_dir}/.git-main-working-tree") @@ -133,17 +144,23 @@ def test_worktree_rebase(pull, rebase, ffable): args += ["--pull"] if rebase: args += ["--rebase"] + if stash: + args += ["--stash"] cmd = grm(args, cwd=base_dir) - print(args) if rebase and not pull: assert cmd.returncode != 0 assert len(cmd.stderr) != 0 + elif has_changes and not stash: + assert cmd.returncode != 0 + assert re.match(r".*myfeatbranch.*contains changes.*", cmd.stderr) else: - assert cmd.returncode == 0 repo = git.Repo(f"{base_dir}/myfeatbranch") + if has_changes: + assert ["uncommitedchange"] == repo.untracked_files if pull: if rebase: + assert cmd.returncode == 0 if ffable: assert ( repo.commit("HEAD").message.strip() @@ -190,6 +207,7 @@ def test_worktree_rebase(pull, rebase, ffable): assert repo.commit("HEAD~6").message.strip() == "commit-root" else: if ffable: + assert cmd.returncode == 0 assert ( repo.commit("HEAD").message.strip() == "commit-in-feat-remote" @@ -208,6 +226,7 @@ def test_worktree_rebase(pull, rebase, ffable): ) assert repo.commit("HEAD~4").message.strip() == "commit-root" else: + assert cmd.returncode != 0 assert ( repo.commit("HEAD").message.strip() == "commit-in-feat-local-no-ff" @@ -226,6 +245,7 @@ def test_worktree_rebase(pull, rebase, ffable): ) assert repo.commit("HEAD~4").message.strip() == "commit-root" else: + assert cmd.returncode == 0 if ffable: assert repo.commit("HEAD").message.strip() == "commit-in-feat-local" assert ( diff --git a/e2e_tests/update_requirementstxt.sh b/e2e_tests/update_requirementstxt.sh index 294da97..b2eca83 100755 --- a/e2e_tests/update_requirementstxt.sh +++ b/e2e_tests/update_requirementstxt.sh @@ -9,6 +9,8 @@ source ./venv/bin/activate pip --disable-pip-version-check install -r ./requirements.txt pip3 list --outdated --format=freeze | grep -v '^\-e' | cut -d = -f 1 | while read -r package ; do + [[ package == "pip" ]] && continue + [[ package == "setuptools" ]] && continue pip install --upgrade "${package}" version="$(pip show "${package}" | grep '^Version' | cut -d ' ' -f 2)" message="e2e_tests/pip: Update ${package} to ${version}" diff --git a/example.config.yaml b/example.config.yaml new file mode 100644 index 0000000..2c4152d --- /dev/null +++ b/example.config.yaml @@ -0,0 +1,16 @@ +trees: +- root: "~/example-projects/" + repos: + - name: "git-repo-manager" + remotes: + - name: "origin" + url: "https://code.hkoerber.de/hannes/git-repo-manager.git" + type: "https" + - name: "github" + url: "https://github.com/hakoerber/git-repo-manager.git" + type: "https" + - name: "dotfiles" + remotes: + - name: "origin" + url: "https://github.com/hakoerber/dotfiles.git" + type: "https" diff --git a/src/config.rs b/src/config.rs index efcf6e6..e81c6a0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,6 +36,10 @@ impl Config { Err(error) => Err(error.to_string()), } } + + pub fn as_yaml(&self) -> Result { + serde_yaml::to_string(self).map_err(|e| e.to_string()) + } } #[derive(Debug, Serialize, Deserialize)] @@ -62,12 +66,15 @@ pub fn read_config(path: &str) -> Result { let config: Config = match toml::from_str(&content) { Ok(c) => c, - Err(e) => { - return Err(format!( - "Error parsing configuration file \"{}\": {}", - path, e - )) - } + Err(_) => match serde_yaml::from_str(&content) { + Ok(c) => c, + Err(e) => { + return Err(format!( + "Error parsing configuration file \"{}\": {}", + path, e + )) + } + }, }; Ok(config) diff --git a/src/grm/cmd.rs b/src/grm/cmd.rs index 7588499..1ea9a02 100644 --- a/src/grm/cmd.rs +++ b/src/grm/cmd.rs @@ -61,10 +61,25 @@ pub struct OptionalConfig { pub config: Option, } +#[derive(clap::ArgEnum, Clone)] +pub enum ConfigFormat { + Yaml, + Toml, +} + #[derive(Parser)] pub struct Find { #[clap(help = "The path to search through")] pub path: String, + + #[clap( + arg_enum, + short, + long, + help = "Format to produce", + default_value_t = ConfigFormat::Toml, + )] + pub format: ConfigFormat, } #[derive(Parser)] @@ -132,6 +147,8 @@ pub struct WorktreeFetchArgs {} pub struct WorktreePullArgs { #[clap(long = "--rebase", help = "Perform a rebase instead of a fast-forward")] pub rebase: bool, + #[clap(long = "--stash", help = "Stash & unstash changes before & after pull")] + pub stash: bool, } #[derive(Parser)] @@ -140,6 +157,11 @@ pub struct WorktreeRebaseArgs { pub pull: bool, #[clap(long = "--rebase", help = "Perform a rebase when doing a pull")] pub rebase: bool, + #[clap( + long = "--stash", + help = "Stash & unstash changes before & after rebase" + )] + pub stash: bool, } pub fn parse() -> Opts { diff --git a/src/grm/main.rs b/src/grm/main.rs index 9a2996a..50547da 100644 --- a/src/grm/main.rs +++ b/src/grm/main.rs @@ -119,15 +119,34 @@ fn main() { } else { let config = trees.to_config(); - let toml = match config.as_toml() { - Ok(toml) => toml, - Err(error) => { - print_error(&format!("Failed converting config to TOML: {}", &error)); - process::exit(1); + match find.format { + cmd::ConfigFormat::Toml => { + let toml = match config.as_toml() { + Ok(toml) => toml, + Err(error) => { + print_error(&format!( + "Failed converting config to TOML: {}", + &error + )); + process::exit(1); + } + }; + print!("{}", toml); } - }; - - print!("{}", toml); + cmd::ConfigFormat::Yaml => { + let yaml = match config.as_yaml() { + Ok(yaml) => yaml, + Err(error) => { + print_error(&format!( + "Failed converting config to YAML: {}", + &error + )); + process::exit(1); + } + }; + print!("{}", yaml); + } + } } for warning in warnings { print_warning(&warning); @@ -350,26 +369,27 @@ fn main() { process::exit(1); }); + let mut failures = false; for worktree in repo.get_worktrees().unwrap_or_else(|error| { print_error(&format!("Error getting worktrees: {}", error)); process::exit(1); }) { - if let Some(warning) = - worktree - .forward_branch(args.rebase) - .unwrap_or_else(|error| { - print_error(&format!( - "Error updating worktree branch: {}", - error - )); - process::exit(1); - }) + if let Some(warning) = worktree + .forward_branch(args.rebase, args.stash) + .unwrap_or_else(|error| { + print_error(&format!("Error updating worktree branch: {}", error)); + process::exit(1); + }) { print_warning(&format!("{}: {}", worktree.name(), warning)); + failures = true; } else { print_success(&format!("{}: Done", worktree.name())); } } + if failures { + process::exit(1); + } } cmd::WorktreeAction::Rebase(args) => { if args.rebase && !args.pull { @@ -406,10 +426,12 @@ fn main() { process::exit(1); }); + let mut failures = false; + for worktree in &worktrees { if args.pull { if let Some(warning) = worktree - .forward_branch(args.rebase) + .forward_branch(args.rebase, args.stash) .unwrap_or_else(|error| { print_error(&format!( "Error updating worktree branch: {}", @@ -418,28 +440,29 @@ fn main() { process::exit(1); }) { + failures = true; print_warning(&format!("{}: {}", worktree.name(), warning)); } } } for worktree in &worktrees { - if let Some(warning) = - worktree - .rebase_onto_default(&config) - .unwrap_or_else(|error| { - print_error(&format!( - "Error rebasing worktree branch: {}", - error - )); - process::exit(1); - }) + if let Some(warning) = worktree + .rebase_onto_default(&config, args.stash) + .unwrap_or_else(|error| { + print_error(&format!("Error rebasing worktree branch: {}", error)); + process::exit(1); + }) { + failures = true; print_warning(&format!("{}: {}", worktree.name(), warning)); } else { print_success(&format!("{}: Done", worktree.name())); } } + if failures { + process::exit(1); + } } } } diff --git a/src/repo.rs b/src/repo.rs index 4e35698..f2cc015 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -181,17 +181,30 @@ impl Worktree { &self.name } - pub fn forward_branch(&self, rebase: bool) -> Result, String> { + pub fn forward_branch(&self, rebase: bool, stash: bool) -> Result, String> { let repo = Repo::open(Path::new(&self.name), false) .map_err(|error| format!("Error opening worktree: {}", error))?; if let Ok(remote_branch) = repo.find_local_branch(&self.name)?.upstream() { let status = repo.status(false)?; + let mut stashed_changes = false; if !status.clean() { - return Ok(Some(String::from("Worktree contains changes"))); + if stash { + repo.stash()?; + stashed_changes = true; + } else { + return Ok(Some(String::from("Worktree contains changes"))); + } } + let unstash = || -> Result<(), String> { + if stashed_changes { + repo.stash_pop()?; + } + Ok(()) + }; + let remote_annotated_commit = repo .0 .find_annotated_commit(remote_branch.commit()?.id().0) @@ -231,6 +244,7 @@ impl Worktree { continue; } rebase.abort().map_err(convert_libgit2_error)?; + unstash()?; return Err(convert_libgit2_error(error)); } } @@ -243,9 +257,11 @@ impl Worktree { .map_err(convert_libgit2_error)?; if analysis.is_up_to_date() { + unstash()?; return Ok(None); } if !analysis.is_fast_forward() { + unstash()?; return Ok(Some(String::from("Worktree cannot be fast forwarded"))); } @@ -257,15 +273,18 @@ impl Worktree { ) .map_err(convert_libgit2_error)?; } + unstash()?; } else { return Ok(Some(String::from("No remote branch to rebase onto"))); }; + Ok(None) } pub fn rebase_onto_default( &self, config: &Option, + stash: bool, ) -> Result, String> { let repo = Repo::open(Path::new(&self.name), false) .map_err(|error| format!("Error opening worktree: {}", error))?; @@ -291,6 +310,25 @@ impl Worktree { }, }; + let status = repo.status(false)?; + let mut stashed_changes = false; + + if !status.clean() { + if stash { + repo.stash()?; + stashed_changes = true; + } else { + return Ok(Some(String::from("Worktree contains changes"))); + } + } + + let unstash = || -> Result<(), String> { + if stashed_changes { + repo.stash_pop()?; + } + Ok(()) + }; + let base_branch = repo.find_local_branch(&default_branch_name)?; let base_annotated_commit = repo .0 @@ -330,11 +368,13 @@ impl Worktree { continue; } rebase.abort().map_err(convert_libgit2_error)?; + unstash()?; return Err(convert_libgit2_error(error)); } } rebase.finish(None).map_err(convert_libgit2_error)?; + unstash()?; Ok(None) } } @@ -456,6 +496,35 @@ impl Repo { } } + pub fn stash(&self) -> Result<(), String> { + let head_branch = self.head_branch()?; + let head = head_branch.commit()?; + let author = head.author(); + + // This is honestly quite horrible. The problem is that all stash operations expect a + // mutable reference (as they, well, mutate the repo after all). But we are heavily using + // immutable references a lot with this struct. I'm really not sure how to best solve this. + // Right now, we just open the repo AGAIN. It is safe, as we are only accessing the stash + // with the second reference, so there are no cross effects. But it just smells. Also, + // using `unwrap()` here as we are already sure that the repo is openable(?). + let mut repo = Repo::open(self.0.path(), false).unwrap(); + repo.0 + .stash_save2(&author, None, Some(git2::StashFlags::INCLUDE_UNTRACKED)) + .map_err(convert_libgit2_error)?; + Ok(()) + } + + pub fn stash_pop(&self) -> Result<(), String> { + let mut repo = Repo::open(self.0.path(), false).unwrap(); + repo.0 + .stash_pop( + 0, + Some(git2::StashApplyOptions::new().reinstantiate_index()), + ) + .map_err(convert_libgit2_error)?; + Ok(()) + } + pub fn rename_remote(&self, remote: &RemoteHandle, new_name: &str) -> Result<(), String> { let failed_refspecs = self .0 @@ -1253,6 +1322,10 @@ impl Commit<'_> { pub fn id(&self) -> Oid { Oid(self.0.id()) } + + pub(self) fn author(&self) -> git2::Signature { + self.0.author() + } } impl<'a> Branch<'a> { @@ -1323,9 +1396,7 @@ fn get_remote_callbacks() -> git2::RemoteCallbacks<'static> { Some(username) => username, None => panic!("Could not get username. This is a bug"), }; - git2::Cred::ssh_key_from_agent(username).or_else(|_| { - git2::Cred::ssh_key(username, None, &crate::env_home().join(".ssh/id_rsa"), None) - }) + git2::Cred::ssh_key_from_agent(username) }); callbacks