Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7aa45c7768 | |||
| a065de5b2d | |||
| d976ccefc2 | |||
| de186901d0 | |||
| c9f4d41780 | |||
| b3906c646a | |||
| 43c47bdca6 | |||
| df0b5728fc | |||
| d0b78686e2 | |||
| f02a0fc17a | |||
| 4e83aba672 | |||
| e2e55b8e79 | |||
| 655379cd61 | |||
| 340085abf8 | |||
| f5f8dfa188 | |||
| e43d4bf3cd | |||
| 48f3bc0199 | |||
| 0973ae36b8 | |||
| 09c67d4908 | |||
| 47841dadfb | |||
| 102758c25c | |||
| 6aa385b044 | |||
| e44b63edbb | |||
| 1e6c9407b6 | |||
| b967b6dca3 | |||
| 83973f8a1a | |||
| ff32759058 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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>",
|
||||||
|
|||||||
23
Justfile
23
Justfile
@@ -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
110
README.md
@@ -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
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ else:
|
|||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "clone", "--depth=1", "https://github.com/rust-lang/crates.io-index"],
|
["git", "clone", "--depth=1", "https://github.com/rust-lang/crates.io-index"],
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=False, # to get some git output
|
capture_output=False, # to get some git output
|
||||||
)
|
)
|
||||||
|
|
||||||
with open("../Cargo.toml", "r") as cargo_config:
|
with open("../Cargo.toml", "r") as cargo_config:
|
||||||
|
|||||||
@@ -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
2
e2e_tests/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/venv/
|
||||||
|
/__pycache__/
|
||||||
229
e2e_tests/helpers.py
Normal file
229
e2e_tests/helpers.py
Normal 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
|
||||||
12
e2e_tests/requirements.txt
Normal file
12
e2e_tests/requirements.txt
Normal 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
13
e2e_tests/test_basic.py
Normal 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
|
||||||
160
e2e_tests/test_repos_find.py
Normal file
160
e2e_tests/test_repos_find.py
Normal 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"
|
||||||
707
e2e_tests/test_repos_sync.py
Normal file
707
e2e_tests/test_repos_sync.py
Normal 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
|
||||||
153
e2e_tests/test_worktree_clean.py
Normal file
153
e2e_tests/test_worktree_clean.py
Normal 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
|
||||||
55
e2e_tests/test_worktree_conversion.py
Normal file
55
e2e_tests/test_worktree_conversion.py
Normal 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
|
||||||
42
e2e_tests/test_worktree_status.py
Normal file
42
e2e_tests/test_worktree_status.py
Normal 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
210
e2e_tests/test_worktrees.py
Normal 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)
|
||||||
@@ -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 {}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
243
src/lib.rs
243
src/lib.rs
@@ -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),
|
||||||
print_repo_error(
|
Err(error) => {
|
||||||
&repo.name,
|
if !repo.worktree_setup {
|
||||||
&format!("Opening repository failed: {}", error),
|
if open_repo(&repo_path, true).is_ok() {
|
||||||
);
|
print_repo_error(
|
||||||
process::exit(1);
|
&repo.name,
|
||||||
},
|
"Repo already exists, but is using a worktree setup",
|
||||||
));
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print_repo_error(
|
||||||
|
&repo.name,
|
||||||
|
&format!("Opening repository failed: {}", error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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) {
|
|||||||
¤t_remote, e
|
¤t_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,28 +911,33 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let repo = match open_repo(&dir, true) {
|
fn get_repo(dir: &Path) -> git2::Repository {
|
||||||
Ok(r) => r,
|
match open_repo(dir, true) {
|
||||||
Err(e) => {
|
Ok(r) => r,
|
||||||
match e.kind {
|
Err(e) => {
|
||||||
RepoErrorKind::NotFound => {
|
match e.kind {
|
||||||
print_error("Current directory does not contain a worktree setup")
|
RepoErrorKind::NotFound => {
|
||||||
|
print_error("Current directory does not contain a worktree setup")
|
||||||
|
}
|
||||||
|
_ => print_error(&format!("Error opening repo: {}", e)),
|
||||||
}
|
}
|
||||||
_ => print_error(&format!("Error opening repo: {}", e)),
|
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!",
|
||||||
|
|||||||
85
src/repo.rs
85
src/repo.rs
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user