58 Commits
v0.1 ... 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
b6c06e29a4 Release v0.2 2021-11-24 19:50:45 +01:00
6bec0eda69 docs: Fix repos command 2021-11-24 19:42:33 +01:00
7541a74fa4 Update dependency locks 2021-11-24 19:37:35 +01:00
4ad4a55631 Use single quotes for remote/branch separator 2021-11-24 19:37:11 +01:00
e2db935c74 Add script to update cargo dependencies 2021-11-24 19:22:18 +01:00
99c4f33e28 cargo: Specify patch versions for dependencies 2021-11-24 19:22:18 +01:00
f50fc9aee2 Add github action to build docs 2021-11-24 19:22:18 +01:00
1cf5585e2c Add documentation 2021-11-24 19:22:18 +01:00
0c6a4a72ef Move repo-tree functionality into own subcommand 2021-11-24 19:22:18 +01:00
e516a652f5 Fix typo 2021-11-24 17:22:10 +01:00
ddce614009 Fix repo change detection 2021-11-24 17:22:10 +01:00
d677c2d41b Add "install" target to Justfile 2021-11-24 17:22:10 +01:00
3aecee3549 Set up tracking branches if required 2021-11-24 17:22:10 +01:00
667ea87c39 Do not pass as value needlessly 2021-11-24 17:22:10 +01:00
3e18caf719 Use "namespace" instead of "prefix" for branches 2021-11-24 17:22:10 +01:00
711d9131da Expand the worktree functionality 2021-11-22 21:19:12 +01:00
8ba214d6cf Fix setting for root if there is a root-level repo 2021-11-22 21:19:10 +01:00
77c00cee5f Do not output anything when no repos found 2021-11-22 21:19:10 +01:00
12cb18c528 Fix output for ahead/behind branches 2021-11-22 21:19:02 +01:00
6b80a0f2d5 Add test target to Justfile 2021-11-22 21:13:21 +01:00
06e7d68089 Add check target to Justfile 2021-11-22 21:13:21 +01:00
db0f91f32f Add cargo fmt to lint check 2021-11-22 21:11:41 +01:00
fa40f4d6aa Add some unit tests for path expansion 2021-11-22 21:11:41 +01:00
78a957268d Add a few simple integration tests 2021-11-22 21:11:41 +01:00
ca1f649ecf Linting & formatting 2021-11-22 21:11:31 +01:00
bbedc9d8a8 Fix debug output 2021-11-21 21:13:28 +01:00
09f22edf49 Add commands to manage worktrees 2021-11-21 17:10:30 +01:00
b0746c95b5 Report on untracked files 2021-11-21 17:10:30 +01:00
153d09f3ef Add functionality to check out worktree-ready repos 2021-11-21 17:10:25 +01:00
74a7772a29 Fix wrong report when ignored files exist 2021-11-20 18:04:02 +01:00
5df6dcb053 Shorten report for unchanged repo 2021-11-20 18:03:40 +01:00
32 changed files with 3384 additions and 332 deletions

29
.github/workflows/gh-pages.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: github pages
on:
push:
branches:
- master
pull_request:
jobs:
deploy:
runs-on: ubuntu-20.04
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
steps:
- uses: actions/checkout@v2
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v1
with:
mdbook-version: 'latest'
- run: cd ./docs && mdbook build
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
if: ${{ github.ref == 'refs/heads/master' }}
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/book

81
Cargo.lock generated
View File

@@ -169,6 +169,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fuchsia-cprng"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.3" version = "0.2.3"
@@ -182,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",
@@ -191,14 +197,15 @@ dependencies = [
"regex", "regex",
"serde", "serde",
"shellexpand", "shellexpand",
"tempdir",
"toml", "toml",
] ]
[[package]] [[package]]
name = "git2" name = "git2"
version = "0.13.23" version = "0.13.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8057932925d3a9d9e4434ea016570d37420ddb1ceed45a174d577f24ed6700" checksum = "845e007a28f1fcac035715988a234e8ec5458fd825b20a20c7dec74237ef341f"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"libc", "libc",
@@ -280,15 +287,15 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.107" version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbe5e23404da5b4f555ef85ebed98fb4083e55a00c317800bc2a50ede9f3d219" checksum = "8521a1b57e76b1ec69af7599e75e38e7b7fad6610f037db8c79b127201b5d119"
[[package]] [[package]]
name = "libgit2-sys" name = "libgit2-sys"
version = "0.12.24+1.3.0" version = "0.12.25+1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddbd6021eef06fb289a8f54b3c2acfdd85ff2a585dfbb24b8576325373d2152c" checksum = "8f68169ef08d6519b2fe133ecc637408d933c0174b23b80bb2f79828966fbaab"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -399,9 +406,9 @@ checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.70" version = "0.9.71"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6517987b3f8226b5da3661dad65ff7f300cc59fb5ea8333ca191fc65fde3edf" checksum = "7df13d165e607909b363a4757a6f133f8a818a74e9d3a98d09c6128e15fa4c73"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"cc", "cc",
@@ -498,6 +505,43 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "rand"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
dependencies = [
"fuchsia-cprng",
"libc",
"rand_core 0.3.1",
"rdrand",
"winapi",
]
[[package]]
name = "rand_core"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
dependencies = [
"rand_core 0.4.2",
]
[[package]]
name = "rand_core"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
[[package]]
name = "rdrand"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
dependencies = [
"rand_core 0.3.1",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.2.10" version = "0.2.10"
@@ -534,6 +578,15 @@ version = "0.6.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]]
name = "remove_dir_all"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.1.0" version = "1.1.0"
@@ -640,6 +693,16 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "tempdir"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8"
dependencies = [
"rand",
"remove_dir_all",
]
[[package]] [[package]]
name = "termcolor" name = "termcolor"
version = "1.1.2" version = "1.1.2"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "git-repo-manager" name = "git-repo-manager"
version = "0.1.0" version = "0.4.0"
edition = "2021" edition = "2021"
authors = [ authors = [
"Hannes Körber <hannes@hkoerber.de>", "Hannes Körber <hannes@hkoerber.de>",
@@ -37,17 +37,17 @@ path = "src/main.rs"
[dependencies] [dependencies]
[dependencies.toml] [dependencies.toml]
version = "0.5" version = "0.5.8"
[dependencies.serde] [dependencies.serde]
version = "1.0" version = "1.0.130"
features = ["derive"] features = ["derive"]
[dependencies.git2] [dependencies.git2]
version = "0.13" version = "0.13.24"
[dependencies.shellexpand] [dependencies.shellexpand]
version = "2.1" version = "2.1.0"
[dependencies.clap] [dependencies.clap]
version = "3.0.0-beta.5" version = "3.0.0-beta.5"
@@ -56,7 +56,10 @@ version = "3.0.0-beta.5"
version = "0.15.0" version = "0.15.0"
[dependencies.regex] [dependencies.regex]
version = "1.5" version = "1.5.4"
[dependencies.comfy-table] [dependencies.comfy-table]
version = "5.0" version = "5.0.0"
[dev-dependencies.tempdir]
version = "0.3.7"

View File

@@ -1,8 +1,40 @@
lint: check: test
cargo clippy --no-deps cargo check
cargo fmt --check
cargo clippy --no-deps -- -Dwarnings
lint-fix: lint-fix:
cargo clippy --no-deps --fix cargo clippy --no-deps --fix
release: release:
cargo build --release cargo build --release
install:
cargo install --path .
test: test-unit test-integration test-e2e
test-unit:
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:
@cd ./depcheck \
&& python3 -m venv ./venv \
&& . ./venv/bin/activate \
&& pip --disable-pip-version-check install -r ./requirements.txt > /dev/null \
&& ./update-cargo-dependencies.py

View File

@@ -3,81 +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 |
+----------+------------+----------------------------------+--------+---------+
```
# Why? # Why?
@@ -117,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

2
depcheck/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/crates.io-index/
/venv/

View File

@@ -0,0 +1,2 @@
semver==2.13.0
tomlkit==0.7.2

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
import subprocess
import os
import json
import sys
import semver
import tomlkit
INDEX_DIR = "crates.io-index"
if os.path.exists(INDEX_DIR):
subprocess.run(
["git", "pull", "--depth=1", "origin"],
cwd=INDEX_DIR,
check=True,
capture_output=True,
)
else:
subprocess.run(
["git", "clone", "--depth=1", "https://github.com/rust-lang/crates.io-index"],
check=True,
capture_output=False, # to get some git output
)
with open("../Cargo.toml", "r") as cargo_config:
cargo = tomlkit.parse(cargo_config.read())
update_necessary = False
for tier in ["dependencies", "dev-dependencies"]:
for name, dependency in cargo[tier].items():
version = dependency["version"]
if len(name) >= 4:
info_file = f"{INDEX_DIR}/{name[0:2]}/{name[2:4]}/{name}"
elif len(name) == 3:
info_file = f"{INDEX_DIR}/3/{name[0]}/{name}"
elif len(name) == 2:
info_file = f"{INDEX_DIR}/2/{name}"
elif len(name) == 1:
info_file = f"{INDEX_DIR}/1/{name}"
current_version = semver.VersionInfo.parse(version)
latest_version = None
for version_entry in open(info_file, "r").readlines():
version = semver.VersionInfo.parse(json.loads(version_entry)["vers"])
if current_version.prerelease == "" and version.prerelease != "":
# skip prereleases, except when we are on a prerelease already
continue
if latest_version is None or version > latest_version:
latest_version = version
if latest_version != current_version:
update_necessary = True
if latest_version < current_version:
print(
f"{name}: Your current version is newer than the newest version on crates.io, the hell?"
)
else:
print(
f"{name}: New version found: {latest_version} (current {current_version})"
)
cargo[tier][name]["version"] = str(latest_version)
if update_necessary is True:
with open("../Cargo.toml", "w") as cargo_config:
cargo_config.write(tomlkit.dumps(cargo))
sys.exit(1)
else:
print("Everything up to date")
sys.exit(0)

1
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
book

9
docs/book.toml Normal file
View File

@@ -0,0 +1,9 @@
[book]
authors = ["Hannes Körber"]
language = "en"
multilingual = false
src = "src"
title = "Git Repo Manager"
[output.html]
mathjax-support = true

7
docs/src/SUMMARY.md Normal file
View File

@@ -0,0 +1,7 @@
# Summary
- [Overview](./overview.md)
- [Getting started](./getting_started.md)
- [Repository trees](./repos.md)
- [Git Worktrees](./worktrees.md)
- [FAQ](./faq.md)

10
docs/src/faq.md Normal file
View File

@@ -0,0 +1,10 @@
# FAQ
## Why is the nightly toolchain required?
Building GRM currently 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).
`is_symlink()` is actually available in rustc 1.57, so it will be on stable in
the near future. This would mean that GRM can be built using the stable toolchain!

View File

@@ -0,0 +1,22 @@
# Quickstart
## Installation
Building GRM currently requires the nightly Rust toolchain. The easiest way
is using [`rustup`](https://rustup.rs/). Make sure that rustup is properly installed.
Make sure that the nightly toolchain is installed:
```
$ rustup toolchain install nightly
```
```bash
$ cargo +nightly install --git https://github.com/hakoerber/git-repo-manager.git --branch master
```
If you're brave, you can also run the development build:
```bash
$ cargo +nightly install --git https://github.com/hakoerber/git-repo-manager.git --branch develop
```

33
docs/src/overview.md Normal file
View File

@@ -0,0 +1,33 @@
# Overview
Welcome! This is the documentation for [Git Repo
Manager](https://github.com/hakoerber/git-repo-manager/) (GRM for short), a
tool that helps you manage git repositories.
GRM helps you manage git repositories in a declarative way. Configure your
repositories in a TOML file, GRM does the rest. Take a look at [the example
configuration](https://github.com/hakoerber/git-repo-manager/blob/master/example.config.toml)
to get a feel for the way you configure your repositories. See the [repository
tree chapter](./repos.md) for details.
GRM also provides some tooling to work with single git repositories using
`git-worktree`. See [the worktree chapter](./worktree.md) for more details.
## Why use GRM?
If you're working with a lot of git repositories, GRM can help you to manage them
in an easy way:
* You want to easily clone many repositories to a new machine.
* You want to change remotes for multiple repositories (e.g. because your GitLab
domain changed).
* You want to get an overview over all repositories you have, and check whether
you forgot to commit or push something.
If you want to work with [git worktrees](https://git-scm.com/docs/git-worktree)
in a streamlined, easy way, GRM provides you with an opinionated workflow. It's
especially helpful when the following describes you:
* You're juggling a lot of git branches, switching between them a lot.
* When switching branches, you'd like to just leave your work as-is, without
using the stash or temporary commits.

76
docs/src/repos.md Normal file
View File

@@ -0,0 +1,76 @@
# Managing tree of git repositories
When managing multiple git repositories with GRM, you'll generally have a
configuration file containing information about all the repos you have. GRM then
makes sure that you repositories match that config. If they don't exist yet, it
will clone them. It will also make sure that all remotes are configured properly.
Let's try it out:
## Get the example configuration
```bash
$ curl --proto '=https' --tlsv1.2 -sSfO https://raw.githubusercontent.com/hakoerber/git-repo-manager/master/example.config.toml
```
Then, you're ready to run the first sync. This will clone all configured repositories
and set up the remotes.
```bash
$ grm repos 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 repos 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 repos 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 repos status --config example.config.toml
╭──────────────────┬──────────┬────────┬───────────────────┬────────┬─────────╮
│ Repo ┆ Worktree ┆ Status ┆ Branches ┆ HEAD ┆ Remotes │
╞══════════════════╪══════════╪════════╪═══════════════════╪════════╪═════════╡
│ git-repo-manager ┆ ┆ ✔ ┆ branch: master ┆ master ┆ github │
│ ┆ ┆ ┆ <origin/master> ✔ ┆ ┆ origin │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ dotfiles ┆ ┆ ✔ ┆ ┆ Empty ┆ origin │
╰──────────────────┴──────────┴────────┴───────────────────┴────────┴─────────╯
```
You can also use `status` without `--config` to check the repository you're currently
in:
```
$ cd ~/example-projects/dotfiles
$ grm repos status
╭──────────┬──────────┬────────┬──────────┬───────┬─────────╮
│ Repo ┆ Worktree ┆ Status ┆ Branches ┆ HEAD ┆ Remotes │
╞══════════╪══════════╪════════╪══════════╪═══════╪═════════╡
│ dotfiles ┆ ┆ ✔ ┆ ┆ Empty ┆ origin │
╰──────────┴──────────┴────────┴──────────┴───────┴─────────╯
```

227
docs/src/worktrees.md Normal file
View File

@@ -0,0 +1,227 @@
# Git Worktrees
## Why?
The default workflow when using git is having your repository in a single directory.
Then, you can check out a certain reference (usually a branch), which will update
the files in the directory to match the state of that reference. Most of the time,
this is exactly what you need and works perfectly. But especially when you're using
with branches a lot, you may notice that there is a lot of work required to make
everything run smootly.
Maybe you experienced the following: You're working on a feature branch. Then,
for some reason, you have to change branches (maybe to investigate some issue).
But you get the following:
```
error: Your local changes to the following files would be overwritten by checkout
```
Now you can create a temporary commit or stash your changes. In any case, you have
some mental overhead before you can work on something else. Especially with stashes,
you'll have to remember to do a `git stash pop` before resuming your work (I
cannot count the number of times where is "rediscovered" some code hidden in some
old stash I forgot about.
And even worse: If you're currently in the process of resolving merge conflicts or an
interactive rebase, there is just no way to "pause" this work to check out a
different branch.
Sometimes, it's crucial to have an unchanging state of your repository until some
long-running process finishes. I'm thinking of Ansible and Terraform runs. I'd
rather not change to a different branch while ansible or Terraform are running as
I have no idea how those tools would behave (and I'm not too eager to find out).
In any case, Git Worktrees are here for the rescue:
## What are git worktrees?
[Git Worktrees](https://git-scm.com/docs/git-worktree) allow you to have multiple
independent checkouts of your repository on different directories. You can have
multiple directories that correspond to different references in your repository.
Each worktree has it's independent working tree (duh) and index, so there is no
to run into conflicts. Changing to a different branch is just a `cd` away (if
the worktree is already set up).
## Worktrees in GRM
GRM exposes an opinionated way to use worktrees in your repositories. Opinionated,
because there is a single invariant that makes reasoning about your worktree
setup quite easy:
**The branch inside the worktree is always the same as the directory name of the worktree.**
In other words: If you're checking out branch `mybranch` into a new worktree, the
worktree directory will be named `mybranch`.
GRM can be used with both "normal" and worktree-enabled repositories. But note
that a single repository can be either the former or the latter. You'll have to
decide during the initial setup which way you want to go for that repository.
If you want to clone your repository in a worktree-enabled way, specify
`worktree_setup = true` for the repository in your `config.toml`:
```toml
[[trees.repos]]
name = "git-repo-manager"
worktree_setup = true
```
Now, when you run a `grm sync`, you'll notice that the directory of the repository
is empty! Well, not totally, there is a hidden directory called `.git-main-working-tree`.
This is where the repository actually "lives" (it's a bare checkout).
### Creating a new worktree
To actually work, you'll first have to create a new worktree checkout. All
worktree-related commands are available as subcommands of `grm worktree` (or
`grm wt` for short):
```
$ grm wt add mybranch
[✔] Worktree mybranch created
```
You'll see that there is now a directory called `mybranch` that contains a checkout
of your repository, using the branch `mybranch`
```bash
$ cd ./mybranch && git status
On branch mybranch
nothing to commit, working tree clean
```
You can work in this repository as usual. Make changes, commit them, revert them,
whatever you're up to :)
Just note that you *should* not change the branch inside the worktree
directory. There is nothing preventing you from doing so, but you will notice
that you'll run into problems when trying to remove a worktree (more on that
later). It may also lead to confusing behaviour, as there can be no two
worktrees that have the same branch checked out. So if you decide to use the
worktree setup, go all in, let `grm` manage your branches and bury `git branch`
(and `git checkout -b`).
You will notice that there is no tracking branch set up for the new branch. You
can of course set up one manually after creating the worktree, but there is an
easier way, using the `--track` flag during creation. Let's create another
worktree. Go back to the root of the repository, and run:
```bash
$ grm wt add mybranch2 --track origin/mybranch2
[] Worktree mybranch2 created
```
You'll see that this branch is now tracking `mybranch` on the `origin` remote:
```bash
$ cd ./mybranch2 && git status
On branch mybranch
Your branch is up to date with 'origin/mybranch2'.
nothing to commit, working tree clean
```
The behaviour of `--track` differs depending on the existence of the remote branch:
* If the remote branch already exists, `grm` uses it as the base of the new
local branch.
* If the remote branch does not exist (as in our example), `grm` will create a
new remote tracking branch, using the default branch (either `main` or `master`)
as the base
### Showing the status of your worktrees
There is a handy little command that will show your an overview over all worktrees
in a repository, including their status (i.e. changes files). Just run the following
in the root of your repository:
```
$ grm wt status
╭───────────┬────────┬──────────┬──────────────────╮
│ Worktree ┆ Status ┆ Branch ┆ Remote branch │
╞═══════════╪════════╪══════════╪══════════════════╡
│ mybranch ┆ ✔ ┆ mybranch ┆ │
│ mybranch2 ┆ ✔ ┆ mybranch ┆ origin/mybranch2 │
╰───────────┴────────┴──────────┴──────────────────╯
```
The "Status" column would show any uncommitted changes (new / modified / deleted
files) and the "Remote branch" would show differences to the remote branch (e.g.
if there are new pushes to the remote branch that are not yet incorporated into
your local branch).
### Deleting worktrees
If you're done with your worktrees, use `grm wt delete` to delete them. Let's
start with `mybranch2`:
```
$ grm wt delete mybranch2
[✔] Worktree mybranch2 deleted
```
Easy. On to `mybranch`:
```
$ grm wt delete mybranch
[!] Changes in worktree: No remote tracking branch for branch mybranch found. Refusing to delete
```
Hmmm. `grm` tells you:
"Hey, there is no remote branch that you could have pushed
your changes to. I'd rather not delete work that you cannot recover."
Note that `grm` is very cautious here. As your repository will not be deleted,
you could still recover the commits via [`git-reflog`](https://git-scm.com/docs/git-reflog).
But better safe then sorry! Note that you'd get a similar error message if your
worktree had any uncommitted files, for the same reason. Now you can either
commit & push your changes, or your tell `grm` that you know what you're doing:
```
$ grm wt delete mybranch --force
[✔] Worktree mybranch deleted
```
If you just want to delete all worktrees that do not contain any changes, you
can also use the following:
```
$ 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
GRM isn't doing any magic, it's just git under the hood. If you need to have access
to the underlying git repository, you can always do this:
```
$ git --git-dir ./.git-main-working-tree [...]
```
This should never be required (whenever you have to do this, you can consider
this a bug in GRM and open an [issue](https://github.com/hakoerber/git-repo-manager/issues/new),
but it may help in a pinch.

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

@@ -19,6 +19,20 @@ pub struct Opts {
#[derive(Parser)] #[derive(Parser)]
pub enum SubCommand { pub enum SubCommand {
#[clap(about = "Manage repositories")]
Repos(Repos),
#[clap(visible_alias = "wt", about = "Manage worktrees")]
Worktree(Worktree),
}
#[derive(Parser)]
pub struct Repos {
#[clap(subcommand, name = "action")]
pub action: ReposAction,
}
#[derive(Parser)]
pub enum ReposAction {
#[clap( #[clap(
visible_alias = "run", visible_alias = "run",
about = "Synchronize the repositories to the configured values" about = "Synchronize the repositories to the configured values"
@@ -45,11 +59,7 @@ pub struct Sync {
#[derive(Parser)] #[derive(Parser)]
#[clap()] #[clap()]
pub struct OptionalConfig { pub struct OptionalConfig {
#[clap( #[clap(short, long, about = "Path to the configuration file")]
short,
long,
about = "Path to the configuration file"
)]
pub config: Option<String>, pub config: Option<String>,
} }
@@ -59,6 +69,61 @@ pub struct Find {
pub path: String, pub path: String,
} }
#[derive(Parser)]
pub struct Worktree {
#[clap(subcommand, name = "action")]
pub action: WorktreeAction,
}
#[derive(Parser)]
pub enum WorktreeAction {
#[clap(about = "Add a new worktree")]
Add(WorktreeAddArgs),
#[clap(about = "Add an existing worktree")]
Delete(WorktreeDeleteArgs),
#[clap(about = "Show state of existing worktrees")]
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")]
Clean(WorktreeCleanArgs),
}
#[derive(Parser)]
pub struct WorktreeAddArgs {
#[clap(about = "Name of the worktree")]
pub name: String,
#[clap(
short = 'n',
long = "branch-namespace",
about = "Namespace of the branch"
)]
pub branch_namespace: Option<String>,
#[clap(short = 't', long = "track", about = "Remote branch to track")]
pub track: Option<String>,
}
#[derive(Parser)]
pub struct WorktreeDeleteArgs {
#[clap(about = "Name of the worktree")]
pub name: String,
#[clap(
long = "force",
about = "Force deletion, even when there are uncommitted/unpushed changes"
)]
pub force: bool,
}
#[derive(Parser)]
pub struct WorktreeStatusArgs {}
#[derive(Parser)]
pub struct WorktreeConvertArgs {}
#[derive(Parser)]
pub struct WorktreeCleanArgs {}
pub fn parse() -> Opts { pub fn parse() -> Opts {
Opts::parse() Opts::parse()
} }

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(),
}
));
} }
}; };

1032
src/lib.rs

File diff suppressed because it is too large Load Diff

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)]
@@ -46,10 +47,18 @@ pub struct Remote {
pub remote_type: RemoteType, pub remote_type: RemoteType,
} }
fn worktree_setup_default() -> bool {
false
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Repo { pub struct Repo {
pub name: String, pub name: String,
#[serde(default = "worktree_setup_default")]
pub worktree_setup: bool,
pub remotes: Option<Vec<Remote>>, pub remotes: Option<Vec<Remote>>,
} }
@@ -82,11 +91,14 @@ pub struct RepoStatus {
pub head: Option<String>, pub head: Option<String>,
pub changes: Option<RepoChanges>, // None(_) => Could not get changes (e.g. because it's a worktree setup)
// Some(None) => No changes
// Some(Some(_)) => Changes
pub changes: Option<Option<RepoChanges>>,
pub worktrees: usize, pub worktrees: usize,
pub submodules: Vec<(String, SubmoduleStatus)>, pub submodules: Option<Vec<(String, SubmoduleStatus)>>,
pub branches: Vec<(String, Option<(String, RemoteTrackingStatus)>)>, pub branches: Vec<(String, Option<(String, RemoteTrackingStatus)>)>,
} }
@@ -116,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);
@@ -136,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> {
@@ -155,20 +169,28 @@ 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
} }
pub fn open_repo(path: &Path) -> Result<Repository, RepoError> { pub fn open_repo(path: &Path, is_worktree: bool) -> Result<Repository, RepoError> {
match Repository::open(path) { let open_func = match is_worktree {
true => Repository::open_bare,
false => Repository::open,
};
let path = match is_worktree {
true => path.join(super::GIT_MAIN_WORKTREE_DIRECTORY),
false => path.to_path_buf(),
};
match open_func(path) {
Ok(r) => Ok(r), Ok(r) => Ok(r),
Err(e) => match e.code() { Err(e) => match e.code() {
git2::ErrorCode::NotFound => Err(RepoError::new(RepoErrorKind::NotFound)), git2::ErrorCode::NotFound => Err(RepoError::new(RepoErrorKind::NotFound)),
@@ -179,24 +201,67 @@ pub fn open_repo(path: &Path) -> Result<Repository, RepoError> {
} }
} }
pub fn init_repo(path: &Path) -> Result<Repository, Box<dyn std::error::Error>> { pub fn init_repo(path: &Path, is_worktree: bool) -> Result<Repository, Box<dyn std::error::Error>> {
match Repository::init(path) { let repo = match is_worktree {
Ok(r) => Ok(r), false => Repository::init(path)?,
Err(e) => Err(Box::new(e)), true => Repository::init_bare(path.join(super::GIT_MAIN_WORKTREE_DIRECTORY))?,
};
if is_worktree {
repo_set_config_push(&repo, "upstream")?;
} }
Ok(repo)
} }
pub fn clone_repo(remote: &Remote, path: &Path) -> Result<(), Box<dyn std::error::Error>> { 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(
remote: &Remote,
path: &Path,
is_worktree: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let clone_target = match is_worktree {
false => path.to_path_buf(),
true => path.join(super::GIT_MAIN_WORKTREE_DIRECTORY),
};
print_action(&format!( print_action(&format!(
"Cloning into \"{}\" from \"{}\"", "Cloning into \"{}\" from \"{}\"",
&path.display(), &clone_target.display(),
&remote.url &remote.url
)); ));
match remote.remote_type { match remote.remote_type {
RemoteType::Https => match Repository::clone(&remote.url, &path) { RemoteType::Https | RemoteType::File => {
Ok(_) => Ok(()), let mut builder = git2::build::RepoBuilder::new();
Err(e) => Err(Box::new(e)), builder.bare(is_worktree);
}, builder.clone(&remote.url, &clone_target)?;
}
RemoteType::Ssh => { RemoteType::Ssh => {
let mut callbacks = RemoteCallbacks::new(); let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| { callbacks.credentials(|_url, username_from_url, _allowed_types| {
@@ -207,17 +272,22 @@ pub fn clone_repo(remote: &Remote, path: &Path) -> Result<(), Box<dyn std::error
fo.remote_callbacks(callbacks); fo.remote_callbacks(callbacks);
let mut builder = git2::build::RepoBuilder::new(); let mut builder = git2::build::RepoBuilder::new();
builder.bare(is_worktree);
builder.fetch_options(fo); builder.fetch_options(fo);
match builder.clone(&remote.url, path) { 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) -> RepoStatus { pub fn get_repo_status(repo: &git2::Repository, is_worktree: bool) -> RepoStatus {
let operation = match repo.state() { let operation = match repo.state() {
git2::RepositoryState::Clean => None, git2::RepositoryState::Clean => None,
state => Some(state), state => Some(state),
@@ -232,73 +302,100 @@ pub fn get_repo_status(repo: &git2::Repository) -> RepoStatus {
.map(|repo_name| repo_name.unwrap().to_string()) .map(|repo_name| repo_name.unwrap().to_string())
.collect::<Vec<String>>(); .collect::<Vec<String>>();
let head = match empty { let head = match is_worktree {
true => None, true => None,
false => Some(repo.head().unwrap().shorthand().unwrap().to_string()), false => match empty {
true => None,
false => Some(repo.head().unwrap().shorthand().unwrap().to_string()),
},
}; };
let statuses = repo.statuses(None).unwrap(); let changes = match is_worktree {
let changes = match statuses.is_empty() {
true => None, true => None,
false => { false => {
let mut files_new = 0; let statuses = repo
let mut files_modified = 0; .statuses(Some(
let mut files_deleted = 0; git2::StatusOptions::new()
for status in statuses.iter() { .include_ignored(false)
let status_bits = status.status(); .include_untracked(true),
if status_bits.intersects( ))
git2::Status::INDEX_MODIFIED .unwrap();
| git2::Status::INDEX_RENAMED
| git2::Status::INDEX_TYPECHANGE match statuses.is_empty() {
| git2::Status::WT_MODIFIED true => Some(None),
| git2::Status::WT_RENAMED false => {
| git2::Status::WT_TYPECHANGE, let mut files_new = 0;
) { let mut files_modified = 0;
files_modified += 1; let mut files_deleted = 0;
} else if status_bits.intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW) { for status in statuses.iter() {
files_new += 1; let status_bits = status.status();
} else if status_bits if status_bits.intersects(
.intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED) git2::Status::INDEX_MODIFIED
{ | git2::Status::INDEX_RENAMED
files_deleted += 1; | git2::Status::INDEX_TYPECHANGE
| git2::Status::WT_MODIFIED
| git2::Status::WT_RENAMED
| git2::Status::WT_TYPECHANGE,
) {
files_modified += 1;
} else if status_bits
.intersects(git2::Status::INDEX_NEW | git2::Status::WT_NEW)
{
files_new += 1;
} else if status_bits
.intersects(git2::Status::INDEX_DELETED | git2::Status::WT_DELETED)
{
files_deleted += 1;
}
}
if (files_new, files_modified, files_deleted) == (0, 0, 0) {
panic!(
"is_empty() returned true, but no file changes were detected. This is a bug!"
);
}
Some(Some(RepoChanges {
files_new,
files_modified,
files_deleted,
}))
} }
} }
Some(RepoChanges {
files_new,
files_modified,
files_deleted,
})
} }
}; };
let worktrees = repo.worktrees().unwrap().len(); let worktrees = repo.worktrees().unwrap().len();
let mut submodules = Vec::new(); let submodules = match is_worktree {
for submodule in repo.submodules().unwrap() { true => None,
let submodule_name = submodule.name().unwrap().to_string(); false => {
let mut submodules = Vec::new();
for submodule in repo.submodules().unwrap() {
let submodule_name = submodule.name().unwrap().to_string();
let submodule_status; let submodule_status;
let status = repo let status = repo
.submodule_status(submodule.name().unwrap(), git2::SubmoduleIgnore::None) .submodule_status(submodule.name().unwrap(), git2::SubmoduleIgnore::None)
.unwrap(); .unwrap();
if status.intersects( if status.intersects(
git2::SubmoduleStatus::WD_INDEX_MODIFIED git2::SubmoduleStatus::WD_INDEX_MODIFIED
| git2::SubmoduleStatus::WD_WD_MODIFIED | git2::SubmoduleStatus::WD_WD_MODIFIED
| git2::SubmoduleStatus::WD_UNTRACKED, | git2::SubmoduleStatus::WD_UNTRACKED,
) { ) {
submodule_status = SubmoduleStatus::Changed; submodule_status = SubmoduleStatus::Changed;
} else if status.is_wd_uninitialized() { } else if status.is_wd_uninitialized() {
submodule_status = SubmoduleStatus::Uninitialized; submodule_status = SubmoduleStatus::Uninitialized;
} else if status.is_wd_modified() { } else if status.is_wd_modified() {
submodule_status = SubmoduleStatus::OutOfDate; submodule_status = SubmoduleStatus::OutOfDate;
} else { } else {
submodule_status = SubmoduleStatus::Clean; submodule_status = SubmoduleStatus::Clean;
}
submodules.push((submodule_name, submodule_status));
}
Some(submodules)
} }
};
submodules.push((submodule_name, submodule_status));
}
let mut branches = Vec::new(); let mut branches = Vec::new();
for (local_branch, _) in repo for (local_branch, _) in repo

11
tests/helpers.rs Normal file
View File

@@ -0,0 +1,11 @@
use tempdir::TempDir;
pub fn init_tmpdir() -> TempDir {
let tmp_dir = TempDir::new("grm-test").unwrap();
println!("Temporary directory: {}", tmp_dir.path().display());
tmp_dir
}
pub fn cleanup_tmpdir(tempdir: TempDir) {
tempdir.close().unwrap();
}

43
tests/repo.rs Normal file
View File

@@ -0,0 +1,43 @@
use grm::repo::*;
mod helpers;
use helpers::*;
#[test]
fn open_empty_repo() {
let tmpdir = init_tmpdir();
assert!(matches!(
open_repo(tmpdir.path(), true),
Err(RepoError {
kind: RepoErrorKind::NotFound
})
));
assert!(matches!(
open_repo(tmpdir.path(), false),
Err(RepoError {
kind: RepoErrorKind::NotFound
})
));
cleanup_tmpdir(tmpdir);
}
#[test]
fn create_repo() -> Result<(), Box<dyn std::error::Error>> {
let tmpdir = init_tmpdir();
let repo = init_repo(tmpdir.path(), false)?;
assert!(!repo.is_bare());
assert!(repo.is_empty()?);
cleanup_tmpdir(tmpdir);
Ok(())
}
#[test]
fn create_repo_with_worktree() -> Result<(), Box<dyn std::error::Error>> {
let tmpdir = init_tmpdir();
let repo = init_repo(tmpdir.path(), true)?;
assert!(repo.is_bare());
assert!(repo.is_empty()?);
cleanup_tmpdir(tmpdir);
Ok(())
}