27 Commits
v0.2 ... v0.4

Author SHA1 Message Date
7aa45c7768 Merge branch 'develop' 2021-11-29 01:06:09 +01:00
a065de5b2d Release v0.4 2021-11-29 01:05:47 +01:00
d976ccefc2 Merge branch 'e2e' into develop 2021-11-29 00:54:25 +01:00
de186901d0 Support file remotes 2021-11-29 00:53:11 +01:00
c9f4d41780 Properly report push errors 2021-11-29 00:53:11 +01:00
b3906c646a Remove wrong error message about remote branch 2021-11-29 00:53:11 +01:00
43c47bdca6 Fail with non-zero exit code on clippy warnings 2021-11-29 00:45:41 +01:00
df0b5728fc Add test to "check" Justfile target 2021-11-29 00:42:36 +01:00
d0b78686e2 Add an E2E test suite 2021-11-29 00:42:36 +01:00
f02a0fc17a Format cargo update script with black 2021-11-29 00:42:36 +01:00
4e83aba672 Fix formatting of push error message 2021-11-29 00:42:36 +01:00
e2e55b8e79 Properly handle error during repo open 2021-11-29 00:42:36 +01:00
655379cd61 Return failures during sync 2021-11-29 00:42:36 +01:00
340085abf8 Detect change from worktree to non-worktree during sync 2021-11-29 00:42:36 +01:00
f5f8dfa188 Better error message when config not found 2021-11-29 00:42:36 +01:00
e43d4bf3cd Split unit and integ tests in Justfile 2021-11-29 00:23:42 +01:00
48f3bc0199 Support file remotes 2021-11-28 16:23:30 +01:00
0973ae36b8 Properly report push errors 2021-11-28 16:22:22 +01:00
09c67d4908 Remove wrong error message about remote branch 2021-11-28 16:22:22 +01:00
47841dadfb Release v0.3 2021-11-26 17:27:39 +01:00
102758c25c Release v0.3 2021-11-26 17:22:09 +01:00
6aa385b044 Merge branch 'develop' 2021-11-26 17:21:48 +01:00
e44b63edbb Remove duplicate docs between README and docs 2021-11-26 17:21:37 +01:00
1e6c9407b6 Do not remove worktree for default branch 2021-11-26 17:21:37 +01:00
b967b6dca3 Set git config properly for worktrees on init/clone
Close #1
2021-11-26 17:21:37 +01:00
83973f8a1a Fix unnecessary to_string() 2021-11-26 17:21:37 +01:00
ff32759058 Add subcommand that converts existing repository
Close #6
2021-11-26 17:21:37 +01:00
20 changed files with 1894 additions and 189 deletions

2
Cargo.lock generated
View File

@@ -188,7 +188,7 @@ dependencies = [
[[package]] [[package]]
name = "git-repo-manager" name = "git-repo-manager"
version = "0.1.0" version = "0.4.0"
dependencies = [ dependencies = [
"clap", "clap",
"comfy-table", "comfy-table",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "git-repo-manager" name = "git-repo-manager"
version = "0.2.0" version = "0.4.0"
edition = "2021" edition = "2021"
authors = [ authors = [
"Hannes Körber <hannes@hkoerber.de>", "Hannes Körber <hannes@hkoerber.de>",

View File

@@ -1,7 +1,7 @@
check: check: test
cargo check cargo check
cargo fmt --check cargo fmt --check
cargo clippy --no-deps cargo clippy --no-deps -- -Dwarnings
lint-fix: lint-fix:
cargo clippy --no-deps --fix cargo clippy --no-deps --fix
@@ -12,9 +12,26 @@ release:
install: install:
cargo install --path . cargo install --path .
test: test: test-unit test-integration test-e2e
test-unit:
cargo test --lib --bins cargo test --lib --bins
test-integration:
cargo test --test "*"
e2e-venv:
cd ./e2e_tests \
&& python3 -m venv venv \
&& . ./venv/bin/activate \
&& pip --disable-pip-version-check install -r ./requirements.txt >/dev/null
test-e2e: e2e-venv release
cd ./e2e_tests \
&& . ./venv/bin/activate \
&& python -m pytest .
update-dependencies: update-dependencies:
@cd ./depcheck \ @cd ./depcheck \
&& python3 -m venv ./venv \ && python3 -m venv ./venv \

110
README.md
View File

@@ -3,110 +3,8 @@
GRM helps you manage git repositories in a declarative way. Configure your GRM helps you manage git repositories in a declarative way. Configure your
repositories in a [TOML](https://toml.io/) file, GRM does the rest. repositories in a [TOML](https://toml.io/) file, GRM does the rest.
## Quickstart **Take a look at the [official documentation](https://hakoerber.github.io/git-repo-manager/)
for installation & quickstart.**
See [the example configuration](example.config.toml) to get a feel for the way
you configure your repositories.
### Install
```bash
$ cargo install --git https://github.com/hakoerber/git-repo-manager.git --branch master
```
### Get the example configuration
```bash
$ curl --proto '=https' --tlsv1.2 -sSfO https://raw.githubusercontent.com/hakoerber/git-repo-manager/master/example.config.toml
```
### Run it!
```bash
$ grm sync --config example.config.toml
[] Cloning into "/home/me/projects/git-repo-manager" from "https://code.hkoerber.de/hannes/git-repo-manager.git"
[] git-repo-manager: Repository successfully cloned
[] git-repo-manager: Setting up new remote "github" to "https://github.com/hakoerber/git-repo-manager.git"
[] git-repo-manager: OK
[] Cloning into "/home/me/projects/dotfiles" from "https://github.com/hakoerber/dotfiles.git"
[] dotfiles: Repository successfully cloned
[] dotfiles: OK
```
If you run it again, it will report no changes:
```
$ grm sync --config example.config.toml
[✔] git-repo-manager: OK
[✔] dotfiles: OK
```
### Generate your own configuration
Now, if you already have a few repositories, it would be quite laborious to write
a configuration from scratch. Luckily, GRM has a way to generate a configuration
from an existing file tree:
```bash
$ grm find ~/your/project/root > config.toml
```
This will detect all repositories and remotes and write them to `config.toml`.
### Show the state of your projects
```bash
$ grm status --config example.config.toml
+------------------+------------+----------------------------------+--------+---------+
| Repo | Status | Branches | HEAD | Remotes |
+=====================================================================================+
| git-repo-manager | | branch: master <origin/master> ✔ | master | github |
| | | | | origin |
|------------------+------------+----------------------------------+--------+---------|
| dotfiles | No changes | branch: master <origin/master> ✔ | master | origin |
+------------------+------------+----------------------------------+--------+---------+
```
You can also use `status` without `--config` to check the current directory:
```
$ cd ./dotfiles
$ grm status
+----------+------------+----------------------------------+--------+---------+
| Repo | Status | Branches | HEAD | Remotes |
+=============================================================================+
| dotfiles | No changes | branch: master <origin/master> ✔ | master | origin |
+----------+------------+----------------------------------+--------+---------+
```
### Manage worktrees for projects
Optionally, GRM can also set up a repository to support multiple worktrees. See
[the git documentation](https://git-scm.com/docs/git-worktree) for details about
worktrees. Long story short: Worktrees allow you to have multiple independent
checkouts of the same repository in different directories, backed by a single
git repository.
To use this, specify `worktree_setup = true` for a repo in your configuration.
After the sync, you will see that the target directory is empty. Actually, the
repository was bare-cloned into a hidden directory: `.git-main-working-tree`.
Don't touch it! GRM provides a command to manage working trees.
Use `grm worktree add <name>` to create a new checkout of a new branch into
a subdirectory. An example:
```bash
$ grm worktree add mybranch
$ cd ./mybranch
$ git status
On branch mybranch
nothing to commit, working tree clean
```
If you're done with your worktree, use `grm worktree delete <name>` to remove it.
GRM will refuse to delete worktrees that contain uncommitted or unpushed changes,
otherwise you might lose work.
# Why? # Why?
@@ -146,10 +44,6 @@ repositories itself.
* Support multiple file formats (YAML, JSON). * Support multiple file formats (YAML, JSON).
* Add systemd timer unit to run regular syncs * Add systemd timer unit to run regular syncs
# Dev Notes
It requires nightly features due to the usage of [`std::path::Path::is_symlink()`](https://doc.rust-lang.org/std/fs/struct.FileType.html#method.is_symlink). See the [tracking issue](https://github.com/rust-lang/rust/issues/85748).
# Crates # Crates
* [`toml`](https://docs.rs/toml/) for the configuration file * [`toml`](https://docs.rs/toml/) for the configuration file

View File

@@ -192,6 +192,26 @@ can also use the following:
$ grm wt clean $ grm wt clean
``` ```
Note that this will not delete the default branch of the repository. It can of
course still be delete with `grm wt delete` if neccessary.
### Converting an existing repository
It is possible to convert an existing directory to a worktree setup, using `grm
wt convert`. This command has to be run in the root of the repository you want
to convert:
```
grm wt convert
[✔] Conversion successful
```
This command will refuse to run if you have any changes in your repository.
Commit them and try again!
Afterwards, the directory is empty, as there are no worktrees checked out yet.
Now you can use the usual commands to set up worktrees.
### Manual access ### Manual access
GRM isn't doing any magic, it's just git under the hood. If you need to have access GRM isn't doing any magic, it's just git under the hood. If you need to have access

2
e2e_tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/venv/
/__pycache__/

229
e2e_tests/helpers.py Normal file
View File

@@ -0,0 +1,229 @@
#!/usr/bin/env python3
import os
import os.path
import subprocess
import tempfile
import hashlib
import git
binary = os.path.join(
os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "target/release/grm"
)
def grm(args, cwd=None, is_invalid=False):
cmd = subprocess.run([binary] + args, cwd=cwd, capture_output=True, text=True)
if not is_invalid:
assert "USAGE" not in cmd.stderr
print(f"grmcmd: {args}")
print(f"stdout:\n{cmd.stdout}")
print(f"stderr:\n{cmd.stderr}")
assert "panicked" not in cmd.stderr
return cmd
def shell(script):
script = "set -o errexit\nset -o nounset\n" + script
subprocess.run(["bash"], input=script, text=True, check=True)
def checksum_directory(path):
"""
Gives a "checksum" of a directory that includes all files & directories
recursively, including owner/group/permissions. Useful to compare that a
directory did not change after a command was run.
The following makes it a bit complicated:
> Whether or not the lists are sorted depends on the file system.
- https://docs.python.org/3/library/os.html#os.walk
This means we have to first get a list of all hashes of files and
directories, then sort the hashes and then create the hash for the whole
directory.
"""
path = os.path.realpath(path)
hashes = []
if not os.path.exists(path):
raise f"{path} not found"
def get_stat_hash(path):
stat = bytes(str(os.stat(path).__hash__()), "ascii")
return stat
for root, dirs, files in os.walk(path):
for file in files:
checksum = hashlib.md5()
filepath = os.path.join(root, file)
checksum.update(str.encode(filepath))
checksum.update(get_stat_hash(filepath))
with open(filepath, "rb") as f:
while True:
data = f.read(8192)
if not data:
break
checksum.update(data)
hashes.append(checksum.digest())
for d in dirs:
checksum = hashlib.md5()
dirpath = os.path.join(root, d)
checksum.update(get_stat_hash(dirpath))
hashes.append(checksum.digest())
checksum = hashlib.md5()
for c in sorted(hashes):
checksum.update(c)
return checksum.hexdigest()
class TempGitRepository:
def __init__(self, dir=None):
self.dir = dir
pass
def __enter__(self):
self.tmpdir = tempfile.TemporaryDirectory(dir=self.dir)
self.remote_1_dir = tempfile.TemporaryDirectory()
self.remote_2_dir = tempfile.TemporaryDirectory()
shell(
f"""
cd {self.tmpdir.name}
git init
echo test > test
git add test
git commit -m "commit1"
git remote add origin file://{self.remote_1_dir.name}
git remote add otherremote file://{self.remote_2_dir.name}
"""
)
return self.tmpdir.name
def __exit__(self, exc_type, exc_val, exc_tb):
del self.tmpdir
del self.remote_1_dir
del self.remote_2_dir
class TempGitRepositoryWorktree:
def __init__(self):
pass
def __enter__(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.remote_1_dir = tempfile.TemporaryDirectory()
self.remote_2_dir = tempfile.TemporaryDirectory()
shell(
f"""
cd {self.remote_1_dir.name}
git init --bare
"""
)
shell(
f"""
cd {self.remote_2_dir.name}
git init --bare
"""
)
shell(
f"""
cd {self.tmpdir.name}
git init
echo test > test
git add test
git commit -m "commit1"
echo test > test2
git add test2
git commit -m "commit2"
git remote add origin file://{self.remote_1_dir.name}
git remote add otherremote file://{self.remote_2_dir.name}
git ls-files | xargs rm -rf
mv .git .git-main-working-tree
git --git-dir .git-main-working-tree config core.bare true
"""
)
return self.tmpdir.name
def __exit__(self, exc_type, exc_val, exc_tb):
del self.tmpdir
del self.remote_1_dir
del self.remote_2_dir
class EmptyDir:
def __init__(self):
pass
def __enter__(self):
self.tmpdir = tempfile.TemporaryDirectory()
return self.tmpdir.name
def __exit__(self, exc_type, exc_val, exc_tb):
del self.tmpdir
class NonGitDir:
def __init__(self):
pass
def __enter__(self):
self.tmpdir = tempfile.TemporaryDirectory()
shell(
f"""
cd {self.tmpdir.name}
mkdir testdir
touch testdir/test
touch test2
"""
)
return self.tmpdir.name
def __exit__(self, exc_type, exc_val, exc_tb):
del self.tmpdir
class TempGitFileRemote:
def __init__(self):
pass
def __enter__(self):
self.tmpdir = tempfile.TemporaryDirectory()
shell(
f"""
cd {self.tmpdir.name}
git init
echo test > test
git add test
git commit -m "commit1"
echo test > test2
git add test2
git commit -m "commit2"
git ls-files | xargs rm -rf
mv .git/* .
git config core.bare true
"""
)
head_commit_sha = git.Repo(self.tmpdir.name).head.commit.hexsha
return (self.tmpdir.name, head_commit_sha)
def __exit__(self, exc_type, exc_val, exc_tb):
del self.tmpdir
class NonExistentPath:
def __init__(self):
pass
def __enter__(self):
self.dir = "/doesnotexist"
if os.path.exists(self.dir):
raise f"{self.dir} exists for some reason"
return self.dir
def __exit__(self, exc_type, exc_val, exc_tb):
pass

View File

@@ -0,0 +1,12 @@
attrs==21.2.0
gitdb==4.0.9
GitPython==3.1.24
iniconfig==1.1.1
packaging==21.3
pluggy==1.0.0
py==1.11.0
pyparsing==3.0.6
pytest==6.2.5
smmap==5.0.0
toml==0.10.2
typing-extensions==4.0.0

13
e2e_tests/test_basic.py Normal file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
from helpers import *
def test_invalid_command():
cmd = grm(["whatever"], is_invalid=True)
assert "USAGE" in cmd.stderr
def test_help():
cmd = grm(["--help"])
assert "USAGE" in cmd.stdout

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
import tempfile
import toml
from helpers import *
def test_repos_find_nonexistent():
with NonExistentPath() as nonexistent_dir:
cmd = grm(["repos", "find", nonexistent_dir])
assert "does not exist" in cmd.stderr.lower()
assert cmd.returncode != 0
assert not os.path.exists(nonexistent_dir)
def test_repos_find_file():
with tempfile.NamedTemporaryFile() as tmpfile:
cmd = grm(["repos", "find", tmpfile.name])
assert "not a directory" in cmd.stderr.lower()
assert cmd.returncode != 0
def test_repos_find_empty():
with tempfile.TemporaryDirectory() as tmpdir:
cmd = grm(["repos", "find", tmpdir])
assert cmd.returncode == 0
assert len(cmd.stdout) == 0
assert len(cmd.stderr) != 0
def test_repos_find_non_git_repos():
with tempfile.TemporaryDirectory() as tmpdir:
shell(
f"""
cd {tmpdir}
mkdir non_git
(
cd ./non_git
echo test > test
)
"""
)
cmd = grm(["repos", "find", tmpdir])
assert cmd.returncode == 0
assert len(cmd.stdout) == 0
assert len(cmd.stderr) != 0
def test_repos_find():
with tempfile.TemporaryDirectory() as tmpdir:
shell(
f"""
cd {tmpdir}
mkdir repo1
(
cd ./repo1
git init
echo test > test
git add test
git commit -m "commit1"
git remote add origin https://example.com/repo2.git
git remote add someremote ssh://example.com/repo2.git
)
mkdir repo2
(
cd ./repo2
git init
git co -b main
echo test > test
git add test
git commit -m "commit1"
git remote add origin https://example.com/repo2.git
)
mkdir non_git
(
cd non_git
echo test > test
)
"""
)
cmd = grm(["repos", "find", tmpdir])
assert cmd.returncode == 0
assert len(cmd.stderr) == 0
output = toml.loads(cmd.stdout)
assert isinstance(output, dict)
assert set(output.keys()) == {"trees"}
assert isinstance(output["trees"], list)
assert len(output["trees"]) == 1
for tree in output["trees"]:
assert set(tree.keys()) == {"root", "repos"}
assert tree["root"] == tmpdir
assert isinstance(tree["repos"], list)
assert len(tree["repos"]) == 2
repo1 = [r for r in tree["repos"] if r["name"] == "repo1"][0]
assert repo1["worktree_setup"] is False
assert isinstance(repo1["remotes"], list)
assert len(repo1["remotes"]) == 2
origin = [r for r in repo1["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"
someremote = [r for r in repo1["remotes"] if r["name"] == "someremote"][0]
assert set(origin.keys()) == {"name", "type", "url"}
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
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"
def test_repos_find_in_root():
with TempGitRepository() as repo_dir:
cmd = grm(["repos", "find", repo_dir])
assert cmd.returncode == 0
assert len(cmd.stderr) == 0
output = toml.loads(cmd.stdout)
assert isinstance(output, dict)
assert set(output.keys()) == {"trees"}
assert isinstance(output["trees"], list)
assert len(output["trees"]) == 1
for tree in output["trees"]:
assert set(tree.keys()) == {"root", "repos"}
assert tree["root"] == os.path.dirname(repo_dir)
assert isinstance(tree["repos"], list)
assert len(tree["repos"]) == 1
repo1 = [
r for r in tree["repos"] if r["name"] == os.path.basename(repo_dir)
][0]
assert repo1["worktree_setup"] is False
assert isinstance(repo1["remotes"], list)
assert len(repo1["remotes"]) == 2
origin = [r for r in repo1["remotes"] if r["name"] == "origin"][0]
assert set(origin.keys()) == {"name", "type", "url"}
assert origin["type"] == "file"
someremote = [r for r in repo1["remotes"] if r["name"] == "otherremote"][0]
assert set(origin.keys()) == {"name", "type", "url"}
assert someremote["type"] == "file"

View File

@@ -0,0 +1,707 @@
#!/usr/bin/env python3
import tempfile
import re
import pytest
import toml
import git
from helpers import *
def test_repos_sync_config_is_valid_symlink():
with tempfile.TemporaryDirectory() as target:
with TempGitFileRemote() as (remote, head_commit_sha):
with tempfile.NamedTemporaryFile() as config:
with tempfile.TemporaryDirectory() as config_dir:
config_symlink = os.path.join(config_dir, "cfglink")
os.symlink(config.name, config_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"
"""
)
cmd = grm(["repos", "sync", "--config", config_symlink])
assert cmd.returncode == 0
git_dir = os.path.join(target, "test")
assert os.path.exists(git_dir)
with git.Repo(git_dir) as repo:
assert not repo.bare
assert not repo.is_dirty()
assert set([str(r) for r in repo.remotes]) == {"origin"}
assert str(repo.active_branch) == "master"
assert str(repo.head.commit) == head_commit_sha
def test_repos_sync_config_is_invalid_symlink():
with tempfile.TemporaryDirectory() as target:
with tempfile.TemporaryDirectory() as config_dir:
with NonExistentPath() as nonexistent_dir:
config_symlink = os.path.join(config_dir, "cfglink")
os.symlink(nonexistent_dir, config_symlink)
cmd = grm(["repos", "sync", "--config", config_symlink])
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
assert "not found" in cmd.stderr.lower()
assert not os.path.exists(os.path.join(target, "test"))
assert not os.path.exists(os.path.join(target, "test"))
def test_repos_sync_config_is_directory():
with tempfile.TemporaryDirectory() as config:
cmd = grm(["repos", "sync", "--config", config])
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
assert "is a directory" in cmd.stderr.lower()
def test_repos_sync_config_is_unreadable():
with tempfile.TemporaryDirectory() as config_dir:
config_path = os.path.join(config_dir, "cfg")
open(config_path, "w")
os.chmod(config_path, 0o0000)
cmd = grm(["repos", "sync", "--config", config_path])
assert os.path.exists(config_path)
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
assert "permission denied" in cmd.stderr.lower()
def test_repos_sync_unmanaged_repos():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
git_dir = os.path.join(root, "test")
assert os.path.exists(git_dir)
# 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")])
def test_repos_sync_root_is_file():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
assert "not a directory" in cmd.stderr.lower()
def test_repos_sync_normal_clone():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
git_dir = os.path.join(target, "test")
assert os.path.exists(git_dir)
with git.Repo(git_dir) as repo:
assert not repo.bare
assert not repo.is_dirty()
assert set([str(r) for r in repo.remotes]) == {
"origin",
"origin2",
}
assert str(repo.active_branch) == "master"
assert str(repo.head.commit) == remote1_head_commit_sha
assert len(repo.remotes) == 2
urls = list(repo.remote("origin").urls)
assert len(urls) == 1
assert urls[0] == f"file://{remote1}"
urls = list(repo.remote("origin2").urls)
assert len(urls) == 1
assert urls[0] == f"file://{remote2}"
def test_repos_sync_normal_init():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
git_dir = os.path.join(target, "test")
assert os.path.exists(git_dir)
with git.Repo(git_dir) as repo:
assert not repo.bare
assert not repo.is_dirty()
# as there are no commits yet, HEAD does not point to anything
# valid
assert not repo.head.is_valid()
def test_repos_sync_normal_add_remote():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
git_dir = os.path.join(target, "test")
assert os.path.exists(git_dir)
with git.Repo(git_dir) as repo:
assert not repo.bare
assert not repo.is_dirty()
assert set([str(r) for r in repo.remotes]) == {"origin"}
assert str(repo.active_branch) == "master"
assert str(repo.head.commit) == remote1_head_commit_sha
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
with git.Repo(git_dir) as repo:
assert set([str(r) for r in repo.remotes]) == {
"origin",
"origin2",
}
urls = list(repo.remote("origin").urls)
assert len(urls) == 1
assert urls[0] == f"file://{remote1}"
urls = list(repo.remote("origin2").urls)
assert len(urls) == 1
assert urls[0] == f"file://{remote2}"
def test_repos_sync_normal_remove_remote():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
git_dir = os.path.join(target, "test")
assert os.path.exists(git_dir)
with git.Repo(git_dir) as repo:
assert not repo.bare
assert not repo.is_dirty()
assert set([str(r) for r in repo.remotes]) == {
"origin",
"origin2",
}
assert str(repo.active_branch) == "master"
assert str(repo.head.commit) == remote1_head_commit_sha
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
shell(f"cd {git_dir} && git remote -v")
with git.Repo(git_dir) as repo:
"""
There is some bug(?) in GitPython. It does not properly
detect removed remotes. It will still report the old
remove in repo.remotes.
So instead, we make sure that we get an Exception when
we try to access the old remove via repo.remote().
Note that repo.remote() checks the actual repo lazily.
Even `exists()` seems to just check against repo.remotes
and will return True even if the remote is not actually
configured. So we have to force GitPython to hit the filesystem.
calling Remotes.urls does. But it returns an iterator
that first has to be unwrapped via list(). Only THEN
do we actually get an exception of the remotes does not
exist.
"""
with pytest.raises(git.exc.GitCommandError):
list(repo.remote("origin").urls)
urls = list(repo.remote("origin2").urls)
assert len(urls) == 1
assert urls[0] == f"file://{remote2}"
def test_repos_sync_normal_change_remote_url():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
git_dir = os.path.join(target, "test")
assert os.path.exists(git_dir)
with git.Repo(git_dir) as repo:
assert not repo.bare
assert not repo.is_dirty()
assert set([str(r) for r in repo.remotes]) == {"origin"}
assert str(repo.active_branch) == "master"
assert str(repo.head.commit) == remote1_head_commit_sha
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
with git.Repo(git_dir) as repo:
assert set([str(r) for r in repo.remotes]) == {"origin"}
urls = list(repo.remote("origin").urls)
assert len(urls) == 1
assert urls[0] == f"file://{remote2}"
def test_repos_sync_normal_change_remote_name():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
git_dir = os.path.join(target, "test")
assert os.path.exists(git_dir)
with git.Repo(git_dir) as repo:
assert not repo.bare
assert not repo.is_dirty()
assert set([str(r) for r in repo.remotes]) == {"origin"}
assert str(repo.active_branch) == "master"
assert str(repo.head.commit) == remote1_head_commit_sha
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
with git.Repo(git_dir) as repo:
# See the note in `test_repos_sync_normal_remove_remote()`
# about repo.remotes
with pytest.raises(git.exc.GitCommandError):
list(repo.remote("origin").urls)
urls = list(repo.remote("origin2").urls)
assert len(urls) == 1
assert urls[0] == f"file://{remote1}"
def test_repos_sync_worktree_clone():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
worktree_dir = f"{target}/test"
assert os.path.exists(worktree_dir)
assert set(os.listdir(worktree_dir)) == {".git-main-working-tree"}
with git.Repo(
os.path.join(worktree_dir, ".git-main-working-tree")
) as repo:
assert repo.bare
assert set([str(r) for r in repo.remotes]) == {"origin"}
assert str(repo.active_branch) == "master"
assert str(repo.head.commit) == head_commit_sha
def test_repos_sync_worktree_init():
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
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
worktree_dir = f"{target}/test"
assert os.path.exists(worktree_dir)
assert set(os.listdir(worktree_dir)) == {".git-main-working-tree"}
with git.Repo(os.path.join(worktree_dir, ".git-main-working-tree")) as repo:
assert repo.bare
# as there are no commits yet, HEAD does not point to anything
# valid
assert not repo.head.is_valid()
def test_repos_sync_invalid_toml():
with tempfile.NamedTemporaryFile() as config:
with open(config.name, "w") as f:
f.write(
f"""
[[trees]]
root = invalid as there are no quotes ;)
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode != 0
def test_repos_sync_unchanged():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
before = checksum_directory(target)
cmd = grm(["repos", "sync", "--config", config.name])
after = checksum_directory(target)
assert cmd.returncode == 0
assert before == after
def test_repos_sync_normal_change_to_worktree():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
git_dir = os.path.join(target, "test")
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode != 0
assert "already exists" in cmd.stderr
assert "not using a worktree setup" in cmd.stderr
def test_repos_sync_worktree_change_to_normal():
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode == 0
git_dir = os.path.join(target, "test")
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"
"""
)
cmd = grm(["repos", "sync", "--config", config.name])
assert cmd.returncode != 0
assert "already exists" in cmd.stderr
assert "using a worktree setup" in cmd.stderr

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
from helpers import *
def test_worktree_clean():
with TempGitRepositoryWorktree() as base_dir:
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)
cmd = grm(["wt", "clean"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" not in os.listdir(base_dir)
def test_worktree_clean_refusal_no_tracking_branch():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "clean"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_clean_refusal_uncommited_changes_new_file():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(f"cd {base_dir}/test && touch changed_file")
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "clean"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_clean_refusal_uncommited_changes_changed_file():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(f"cd {base_dir}/test && git ls-files | shuf | head | xargs rm -rf")
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "clean"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_clean_refusal_uncommited_changes_cleand_file():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(
f"cd {base_dir}/test && git ls-files | shuf | head | while read f ; do echo $RANDOM > $f ; done"
)
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "clean"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_clean_refusal_commited_changes():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(
f'cd {base_dir}/test && touch changed_file && git add changed_file && git commit -m "commitmsg"'
)
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "clean"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_clean_refusal_tracking_branch_mismatch():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(
f"cd {base_dir}/test && git push origin test && git reset --hard origin/test^"
)
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "clean"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_clean_fail_from_subdir():
with TempGitRepositoryWorktree() as base_dir:
cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0
cmd = grm(["wt", "clean"], cwd=f"{base_dir}/test")
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
assert len(cmd.stderr) != 0
def test_worktree_clean_non_worktree():
with TempGitRepository() as git_dir:
cmd = grm(["wt", "clean"], cwd=git_dir)
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
assert len(cmd.stderr) != 0
def test_worktree_clean_non_git():
with NonGitDir() as base_dir:
cmd = grm(["wt", "clean"], cwd=base_dir)
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
assert len(cmd.stderr) != 0

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
import tempfile
from helpers import *
def test_convert():
with TempGitRepository() as git_dir:
cmd = grm(["wt", "convert"], cwd=git_dir)
assert cmd.returncode == 0
files = os.listdir(git_dir)
assert len(files) == 1
assert files[0] == ".git-main-working-tree"
cmd = grm(["wt", "add", "test"], cwd=git_dir)
assert cmd.returncode == 0
files = os.listdir(git_dir)
assert len(files) == 2
assert set(files) == {".git-main-working-tree", "test"}
def test_convert_already_worktree():
with TempGitRepositoryWorktree() as git_dir:
before = checksum_directory(git_dir)
cmd = grm(["wt", "convert"], cwd=git_dir)
assert cmd.returncode != 0
after = checksum_directory(git_dir)
assert before == after
def test_convert_non_git():
with NonGitDir() as dir:
before = checksum_directory(dir)
cmd = grm(["wt", "convert"], cwd=dir)
assert cmd.returncode != 0
after = checksum_directory(dir)
assert before == after
def test_convert_empty():
with EmptyDir() as dir:
before = checksum_directory(dir)
cmd = grm(["wt", "convert"], cwd=dir)
assert cmd.returncode != 0
after = checksum_directory(dir)
assert before == after

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env python3
from helpers import *
def test_worktree_status():
with TempGitRepositoryWorktree() as base_dir:
cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0
cmd = grm(["wt", "status"], cwd=base_dir)
assert cmd.returncode == 0
assert len(cmd.stderr) == 0
stdout = cmd.stdout.lower()
assert "test" in stdout
def test_worktree_status_fail_from_subdir():
with TempGitRepositoryWorktree() as base_dir:
cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0
cmd = grm(["wt", "status"], cwd=f"{base_dir}/test")
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
assert len(cmd.stderr) != 0
def test_worktree_status_non_worktree():
with TempGitRepository() as git_dir:
cmd = grm(["wt", "status"], cwd=git_dir)
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
assert len(cmd.stderr) != 0
def test_worktree_status_non_git():
with NonGitDir() as base_dir:
cmd = grm(["wt", "status"], cwd=base_dir)
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
assert len(cmd.stderr) != 0

210
e2e_tests/test_worktrees.py Normal file
View File

@@ -0,0 +1,210 @@
#!/usr/bin/env python3
from helpers import *
import git
def test_worktree_add_simple():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0
files = os.listdir(base_dir)
assert len(files) == 2
assert set(files) == {".git-main-working-tree", "test"}
repo = git.Repo(os.path.join(base_dir, "test"))
assert not repo.bare
assert not repo.is_dirty()
assert str(repo.active_branch) == "test"
assert repo.active_branch.tracking_branch() is None
def test_worktree_add_with_tracking():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
print(cmd.stderr)
assert cmd.returncode == 0
files = os.listdir(base_dir)
assert len(files) == 2
assert set(files) == {".git-main-working-tree", "test"}
repo = git.Repo(os.path.join(base_dir, "test"))
assert not repo.bare
assert not repo.is_dirty()
assert str(repo.active_branch) == "test"
assert str(repo.active_branch.tracking_branch()) == "origin/test"
def test_worktree_delete():
with TempGitRepositoryWorktree() as base_dir:
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" not in os.listdir(base_dir)
cmd = grm(["wt", "add", "check"], cwd=base_dir)
assert cmd.returncode == 0
repo = git.Repo(os.path.join(base_dir, ".git-main-working-tree"))
print(repo.branches)
assert "test" not in [str(b) for b in repo.branches]
def test_worktree_delete_refusal_no_tracking_branch():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_delete_refusal_uncommited_changes_new_file():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(f"cd {base_dir}/test && touch changed_file")
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_delete_refusal_uncommited_changes_changed_file():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(f"cd {base_dir}/test && git ls-files | shuf | head | xargs rm -rf")
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_delete_refusal_uncommited_changes_deleted_file():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(
f"cd {base_dir}/test && git ls-files | shuf | head | while read f ; do echo $RANDOM > $f ; done"
)
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_delete_refusal_commited_changes():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(
f'cd {base_dir}/test && touch changed_file && git add changed_file && git commit -m "commitmsg"'
)
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_delete_refusal_tracking_branch_mismatch():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(
f"cd {base_dir}/test && git push origin test && git reset --hard origin/test^"
)
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_delete_force_refusal():
with TempGitRepositoryWorktree() as base_dir:
before = checksum_directory(base_dir)
cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0
cmd = grm(["wt", "delete", "test", "--force"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" not in os.listdir(base_dir)
def test_worktree_add_delete_add():
with TempGitRepositoryWorktree() as base_dir:
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" not in os.listdir(base_dir)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)

View File

@@ -83,6 +83,8 @@ pub enum WorktreeAction {
Delete(WorktreeDeleteArgs), Delete(WorktreeDeleteArgs),
#[clap(about = "Show state of existing worktrees")] #[clap(about = "Show state of existing worktrees")]
Status(WorktreeStatusArgs), Status(WorktreeStatusArgs),
#[clap(about = "Convert a normal repository to a worktree setup")]
Convert(WorktreeConvertArgs),
#[clap(about = "Clean all worktrees that do not contain uncommited/unpushed changes")] #[clap(about = "Clean all worktrees that do not contain uncommited/unpushed changes")]
Clean(WorktreeCleanArgs), Clean(WorktreeCleanArgs),
} }
@@ -116,6 +118,9 @@ pub struct WorktreeDeleteArgs {
#[derive(Parser)] #[derive(Parser)]
pub struct WorktreeStatusArgs {} pub struct WorktreeStatusArgs {}
#[derive(Parser)]
pub struct WorktreeConvertArgs {}
#[derive(Parser)] #[derive(Parser)]
pub struct WorktreeCleanArgs {} pub struct WorktreeCleanArgs {}

View File

@@ -21,8 +21,12 @@ pub fn read_config(path: &str) -> Result<Config, String> {
Err(e) => { Err(e) => {
return Err(format!( return Err(format!(
"Error reading configuration file \"{}\": {}", "Error reading configuration file \"{}\": {}",
path, e path,
)) match e.kind() {
std::io::ErrorKind::NotFound => String::from("not found"),
_ => e.to_string(),
}
));
} }
}; };

View File

@@ -1,3 +1,5 @@
#![feature(io_error_more)]
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process; use std::process;
@@ -13,13 +15,16 @@ use output::*;
use comfy_table::{Cell, Table}; use comfy_table::{Cell, Table};
use repo::{ use repo::{
clone_repo, detect_remote_type, get_repo_status, init_repo, open_repo, Remote, clone_repo, detect_remote_type, get_repo_status, init_repo, open_repo, repo_make_bare,
RemoteTrackingStatus, Repo, RepoErrorKind, repo_set_config_push, Remote, RemoteTrackingStatus, Repo, RepoErrorKind,
}; };
const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree"; const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree";
const BRANCH_NAMESPACE_SEPARATOR: &str = "/"; const BRANCH_NAMESPACE_SEPARATOR: &str = "/";
const GIT_CONFIG_BARE_KEY: &str = "core.bare";
const GIT_CONFIG_PUSH_DEFAULT: &str = "push.default";
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -110,7 +115,8 @@ fn get_default_branch(repo: &git2::Repository) -> Result<git2::Branch, String> {
} }
} }
fn sync_trees(config: Config) { fn sync_trees(config: Config) -> bool {
let mut failures = false;
for tree in config.trees { for tree in config.trees {
let repos = tree.repos.unwrap_or_default(); let repos = tree.repos.unwrap_or_default();
@@ -128,17 +134,29 @@ fn sync_trees(config: Config) {
&repo.name, &repo.name,
"Repo already exists, but is not using a worktree setup", "Repo already exists, but is not using a worktree setup",
); );
process::exit(1); failures = true;
continue;
} }
repo_handle = Some(open_repo(&repo_path, repo.worktree_setup).unwrap_or_else( repo_handle = match open_repo(&repo_path, repo.worktree_setup) {
|error| { Ok(repo) => Some(repo),
Err(error) => {
if !repo.worktree_setup {
if open_repo(&repo_path, true).is_ok() {
print_repo_error(
&repo.name,
"Repo already exists, but is using a worktree setup",
);
}
} else {
print_repo_error( print_repo_error(
&repo.name, &repo.name,
&format!("Opening repository failed: {}", error), &format!("Opening repository failed: {}", error),
); );
process::exit(1); }
}, failures = true;
)); continue;
}
};
} else if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().is_empty() { } else if matches!(&repo.remotes, None) || repo.remotes.as_ref().unwrap().is_empty() {
print_repo_action( print_repo_action(
&repo.name, &repo.name,
@@ -185,6 +203,7 @@ fn sync_trees(config: Config) {
&repo.name, &repo.name,
&format!("Repository failed during getting the remotes: {}", e), &format!("Repository failed during getting the remotes: {}", e),
); );
failures = true;
continue; continue;
} }
} }
@@ -207,6 +226,7 @@ fn sync_trees(config: Config) {
&repo.name, &repo.name,
&format!("Repository failed during setting the remotes: {}", e), &format!("Repository failed during setting the remotes: {}", e),
); );
failures = true;
continue; continue;
} }
} else { } else {
@@ -215,6 +235,7 @@ fn sync_trees(config: Config) {
Some(url) => url, Some(url) => url,
None => { None => {
print_repo_error(&repo.name, &format!("Repository failed during getting of the remote URL for remote \"{}\". This is most likely caused by a non-utf8 remote name", remote.name)); print_repo_error(&repo.name, &format!("Repository failed during getting of the remote URL for remote \"{}\". This is most likely caused by a non-utf8 remote name", remote.name));
failures = true;
continue; continue;
} }
}; };
@@ -225,6 +246,7 @@ fn sync_trees(config: Config) {
); );
if let Err(e) = repo_handle.remote_set_url(&remote.name, &remote.url) { if let Err(e) = repo_handle.remote_set_url(&remote.name, &remote.url) {
print_repo_error(&repo.name, &format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e)); print_repo_error(&repo.name, &format!("Repository failed during setting of the remote URL for remote \"{}\": {}", &remote.name, e));
failures = true;
continue; continue;
}; };
} }
@@ -245,6 +267,7 @@ fn sync_trees(config: Config) {
&current_remote, e &current_remote, e
), ),
); );
failures = true;
continue; continue;
} }
} }
@@ -254,7 +277,15 @@ fn sync_trees(config: Config) {
print_repo_success(&repo.name, "OK"); print_repo_success(&repo.name, "OK");
} }
let current_repos = find_repos_without_details(&root_path).unwrap(); let current_repos = match find_repos_without_details(&root_path) {
Ok(repos) => repos,
Err(error) => {
print_error(&error.to_string());
failures = true;
continue;
}
};
for (repo, _) in current_repos { for (repo, _) in current_repos {
let name = path_as_string(repo.strip_prefix(&root_path).unwrap()); let name = path_as_string(repo.strip_prefix(&root_path).unwrap());
if !repos.iter().any(|r| r.name == name) { if !repos.iter().any(|r| r.name == name) {
@@ -262,9 +293,11 @@ fn sync_trees(config: Config) {
} }
} }
} }
!failures
} }
fn find_repos_without_details(path: &Path) -> Option<Vec<(PathBuf, bool)>> { fn find_repos_without_details(path: &Path) -> Result<Vec<(PathBuf, bool)>, String> {
let mut repos: Vec<(PathBuf, bool)> = Vec::new(); let mut repos: Vec<(PathBuf, bool)> = Vec::new();
let git_dir = path.join(".git"); let git_dir = path.join(".git");
@@ -285,26 +318,34 @@ fn find_repos_without_details(path: &Path) -> Option<Vec<(PathBuf, bool)>> {
continue; continue;
} }
if path.is_dir() { if path.is_dir() {
if let Some(mut r) = find_repos_without_details(&path) { match find_repos_without_details(&path) {
repos.append(&mut r); Ok(ref mut r) => repos.append(r),
}; Err(error) => return Err(error),
}
} }
} }
Err(e) => { Err(e) => {
print_error(&format!("Error accessing directory: {}", e)); return Err(format!("Error accessing directory: {}", e));
continue;
} }
}; };
} }
} }
Err(e) => { Err(e) => {
print_error(&format!("Failed to open \"{}\": {}", &path.display(), &e)); return Err(format!(
return None; "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()),
}
));
} }
}; };
} }
Some(repos) Ok(repos)
} }
fn get_actual_git_directory(path: &Path, is_worktree: bool) -> PathBuf { fn get_actual_git_directory(path: &Path, is_worktree: bool) -> PathBuf {
@@ -631,7 +672,7 @@ fn show_single_repo_status(path: &Path, is_worktree: bool) {
if let Err(error) = repo_handle { if let Err(error) = repo_handle {
if error.kind == RepoErrorKind::NotFound { if error.kind == RepoErrorKind::NotFound {
print_error(&"Directory is not a git directory".to_string()); print_error("Directory is not a git directory");
} else { } else {
print_error(&format!("Opening repository failed: {}", error)); print_error(&format!("Opening repository failed: {}", error));
} }
@@ -806,7 +847,9 @@ pub fn run() {
process::exit(1); process::exit(1);
} }
}; };
sync_trees(config); if !sync_trees(config) {
process::exit(1);
}
} }
cmd::ReposAction::Status(args) => match &args.config { cmd::ReposAction::Status(args) => match &args.config {
Some(config_path) => { Some(config_path) => {
@@ -868,7 +911,8 @@ pub fn run() {
} }
}; };
let repo = match open_repo(&dir, true) { fn get_repo(dir: &Path) -> git2::Repository {
match open_repo(dir, true) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
match e.kind { match e.kind {
@@ -879,17 +923,21 @@ pub fn run() {
} }
process::exit(1); process::exit(1);
} }
}; }
}
let worktrees = repo fn get_worktrees(repo: &git2::Repository) -> Vec<String> {
.worktrees() repo.worktrees()
.unwrap() .unwrap()
.iter() .iter()
.map(|e| e.unwrap().to_string()) .map(|e| e.unwrap().to_string())
.collect::<Vec<String>>(); .collect::<Vec<String>>()
}
match args.action { match args.action {
cmd::WorktreeAction::Add(action_args) => { cmd::WorktreeAction::Add(action_args) => {
let repo = get_repo(&dir);
let worktrees = get_worktrees(&repo);
if worktrees.contains(&action_args.name) { if worktrees.contains(&action_args.name) {
print_error("Worktree already exists"); print_error("Worktree already exists");
process::exit(1); process::exit(1);
@@ -941,10 +989,6 @@ pub fn run() {
.set_upstream(Some(&upstream_branch_name)) .set_upstream(Some(&upstream_branch_name))
.unwrap(); .unwrap();
} else { } else {
print_error(&format!(
"Remote branch {} not found",
&upstream_branch_name
));
let split_at = upstream_branch_name.find('/').unwrap_or(0); let split_at = upstream_branch_name.find('/').unwrap_or(0);
if split_at == 0 || split_at >= upstream_branch_name.len() - 1 { if split_at == 0 || split_at >= upstream_branch_name.len() - 1 {
print_error("Tracking branch needs to match the pattern <remote>/<branch_name>"); print_error("Tracking branch needs to match the pattern <remote>/<branch_name>");
@@ -989,7 +1033,15 @@ pub fn run() {
); );
remote remote
.push(&[push_refspec], Some(&mut push_options)) .push(&[push_refspec], Some(&mut push_options))
.unwrap(); .unwrap_or_else(|error| {
print_error(&format!(
"Pushing to {} ({}) failed: {}",
remote_name,
remote.url().unwrap(),
error
));
process::exit(1);
});
target_branch target_branch
.set_upstream(Some(&upstream_branch_name)) .set_upstream(Some(&upstream_branch_name))
@@ -1014,6 +1066,7 @@ pub fn run() {
cmd::WorktreeAction::Delete(action_args) => { cmd::WorktreeAction::Delete(action_args) => {
let worktree_dir = dir.join(&action_args.name); let worktree_dir = dir.join(&action_args.name);
let repo = get_repo(&dir);
match remove_worktree( match remove_worktree(
&action_args.name, &action_args.name,
@@ -1040,6 +1093,8 @@ pub fn run() {
} }
} }
cmd::WorktreeAction::Status(_args) => { cmd::WorktreeAction::Status(_args) => {
let repo = get_repo(&dir);
let worktrees = get_worktrees(&repo);
let mut table = Table::new(); let mut table = Table::new();
add_worktree_table_header(&mut table); add_worktree_table_header(&mut table);
for worktree in &worktrees { for worktree in &worktrees {
@@ -1081,8 +1136,100 @@ pub fn run() {
} }
println!("{}", table); println!("{}", table);
} }
cmd::WorktreeAction::Convert(_args) => {
// Converting works like this:
// * Check whether there are uncommitted/unpushed changes
// * Move the contents of .git dir to the worktree directory
// * Remove all files
// * Set `core.bare` to `true`
let repo = open_repo(&dir, false).unwrap_or_else(|error| {
if error.kind == RepoErrorKind::NotFound {
print_error("Directory does not contain a git repository");
} else {
print_error(&format!("Opening repository failed: {}", error));
}
process::exit(1);
});
let status = get_repo_status(&repo, false);
if status.changes.unwrap().is_some() {
print_error("Changes found in repository, refusing to convert");
}
if let Err(error) = std::fs::rename(".git", GIT_MAIN_WORKTREE_DIRECTORY) {
print_error(&format!("Error moving .git directory: {}", error));
}
for entry in match std::fs::read_dir(&dir) {
Ok(iterator) => iterator,
Err(error) => {
print_error(&format!("Opening directory failed: {}", error));
process::exit(1);
}
} {
match entry {
Ok(entry) => {
let path = entry.path();
// The path will ALWAYS have a file component
if path.file_name().unwrap() == GIT_MAIN_WORKTREE_DIRECTORY {
continue;
}
if path.is_file() || path.is_symlink() {
if let Err(error) = std::fs::remove_file(&path) {
print_error(&format!("Failed removing {}", error));
process::exit(1);
}
} else if let Err(error) = std::fs::remove_dir_all(&path) {
print_error(&format!("Failed removing {}", error));
process::exit(1);
}
}
Err(error) => {
print_error(&format!("Error getting directory entry: {}", error));
process::exit(1);
}
}
}
let worktree_repo = open_repo(&dir, true).unwrap_or_else(|error| {
print_error(&format!(
"Opening newly converted repository failed: {}",
error
));
process::exit(1);
});
repo_make_bare(&worktree_repo, true).unwrap_or_else(|error| {
print_error(&format!("Error: {}", error));
process::exit(1);
});
repo_set_config_push(&worktree_repo, "upstream").unwrap_or_else(|error| {
print_error(&format!("Error: {}", error));
process::exit(1);
});
print_success("Conversion done");
}
cmd::WorktreeAction::Clean(_args) => { cmd::WorktreeAction::Clean(_args) => {
for worktree in &worktrees { let repo = get_repo(&dir);
let worktrees = get_worktrees(&repo);
let default_branch = match get_default_branch(&repo) {
Ok(branch) => branch,
Err(error) => {
print_error(&format!("Failed getting default branch: {}", error));
process::exit(1);
}
};
let default_branch_name = default_branch.name().unwrap().unwrap();
for worktree in worktrees
.iter()
.filter(|worktree| *worktree != default_branch_name)
{
let repo_dir = &dir.join(&worktree); let repo_dir = &dir.join(&worktree);
if repo_dir.exists() { if repo_dir.exists() {
match remove_worktree(worktree, repo_dir, false, &repo) { match remove_worktree(worktree, repo_dir, false, &repo) {
@@ -1108,6 +1255,7 @@ pub fn run() {
)); ));
} }
} }
for entry in std::fs::read_dir(&dir).unwrap() { for entry in std::fs::read_dir(&dir).unwrap() {
let dirname = path_as_string( let dirname = path_as_string(
&entry &entry
@@ -1120,6 +1268,9 @@ pub fn run() {
if dirname == GIT_MAIN_WORKTREE_DIRECTORY { if dirname == GIT_MAIN_WORKTREE_DIRECTORY {
continue; continue;
} }
if dirname == default_branch_name {
continue;
}
if !&worktrees.contains(&dirname) { if !&worktrees.contains(&dirname) {
print_warning(&format!( print_warning(&format!(
"Found {}, which is not a valid worktree directory!", "Found {}, which is not a valid worktree directory!",

View File

@@ -10,6 +10,7 @@ use crate::output::*;
pub enum RemoteType { pub enum RemoteType {
Ssh, Ssh,
Https, Https,
File,
} }
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
@@ -127,6 +128,14 @@ mod tests {
); );
} }
#[test]
fn check_file_remote() {
assert_eq!(
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!(detect_remote_type("https//example.com"), None);
@@ -147,12 +156,6 @@ mod tests {
fn check_unsupported_protocol_git() { fn check_unsupported_protocol_git() {
detect_remote_type("git://example.com"); detect_remote_type("git://example.com");
} }
#[test]
#[should_panic]
fn check_unsupported_protocol_file() {
detect_remote_type("file:///");
}
} }
pub fn detect_remote_type(remote_url: &str) -> Option<RemoteType> { pub fn detect_remote_type(remote_url: &str) -> Option<RemoteType> {
@@ -166,15 +169,15 @@ pub fn detect_remote_type(remote_url: &str) -> Option<RemoteType> {
if remote_url.starts_with("https://") { if remote_url.starts_with("https://") {
return Some(RemoteType::Https); return Some(RemoteType::Https);
} }
if remote_url.starts_with("file://") {
return Some(RemoteType::File);
}
if remote_url.starts_with("http://") { if remote_url.starts_with("http://") {
unimplemented!("Remotes using HTTP protocol are not supported"); unimplemented!("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"); unimplemented!("Remotes using git protocol are not supported");
} }
if remote_url.starts_with("file://") || remote_url.starts_with('/') {
unimplemented!("Remotes using local protocol are not supported");
}
None None
} }
@@ -199,16 +202,43 @@ pub fn open_repo(path: &Path, is_worktree: bool) -> Result<Repository, RepoError
} }
pub fn init_repo(path: &Path, is_worktree: bool) -> Result<Repository, Box<dyn std::error::Error>> { pub fn init_repo(path: &Path, is_worktree: bool) -> Result<Repository, Box<dyn std::error::Error>> {
match is_worktree { let repo = match is_worktree {
false => match Repository::init(path) { false => Repository::init(path)?,
Ok(r) => Ok(r), true => Repository::init_bare(path.join(super::GIT_MAIN_WORKTREE_DIRECTORY))?,
Err(e) => Err(Box::new(e)), };
},
true => match Repository::init_bare(path.join(super::GIT_MAIN_WORKTREE_DIRECTORY)) { if is_worktree {
Ok(r) => Ok(r), repo_set_config_push(&repo, "upstream")?;
Err(e) => Err(Box::new(e)),
},
} }
Ok(repo)
}
pub fn get_repo_config(repo: &git2::Repository) -> Result<git2::Config, String> {
repo.config()
.map_err(|error| format!("Failed getting repository configuration: {}", error))
}
pub fn repo_make_bare(repo: &git2::Repository, value: bool) -> Result<(), String> {
let mut config = get_repo_config(repo)?;
config
.set_bool(super::GIT_CONFIG_BARE_KEY, value)
.map_err(|error| format!("Could not set {}: {}", super::GIT_CONFIG_BARE_KEY, error))
}
pub fn repo_set_config_push(repo: &git2::Repository, value: &str) -> Result<(), String> {
let mut config = get_repo_config(repo)?;
config
.set_str(super::GIT_CONFIG_PUSH_DEFAULT, value)
.map_err(|error| {
format!(
"Could not set {}: {}",
super::GIT_CONFIG_PUSH_DEFAULT,
error
)
})
} }
pub fn clone_repo( pub fn clone_repo(
@@ -227,13 +257,10 @@ pub fn clone_repo(
&remote.url &remote.url
)); ));
match remote.remote_type { match remote.remote_type {
RemoteType::Https => { RemoteType::Https | RemoteType::File => {
let mut builder = git2::build::RepoBuilder::new(); let mut builder = git2::build::RepoBuilder::new();
builder.bare(is_worktree); builder.bare(is_worktree);
match builder.clone(&remote.url, &clone_target) { builder.clone(&remote.url, &clone_target)?;
Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)),
}
} }
RemoteType::Ssh => { RemoteType::Ssh => {
let mut callbacks = RemoteCallbacks::new(); let mut callbacks = RemoteCallbacks::new();
@@ -248,12 +275,16 @@ pub fn clone_repo(
builder.bare(is_worktree); builder.bare(is_worktree);
builder.fetch_options(fo); builder.fetch_options(fo);
match builder.clone(&remote.url, &clone_target) { builder.clone(&remote.url, &clone_target)?;
Ok(_) => Ok(()),
Err(e) => Err(Box::new(e)),
} }
} }
if is_worktree {
let repo = open_repo(&clone_target, false)?;
repo_set_config_push(&repo, "upstream")?;
} }
Ok(())
} }
pub fn get_repo_status(repo: &git2::Repository, is_worktree: bool) -> RepoStatus { pub fn get_repo_status(repo: &git2::Repository, is_worktree: bool) -> RepoStatus {