12 Commits

Author SHA1 Message Date
Hannes Körber
0f45708e81 Improve default branch guessing 2022-06-13 22:37:36 +02:00
Hannes Körber
d036b53037 Print ok-ish stuff to stdout 2022-06-13 22:37:27 +02:00
2be1dec818 Add some comments about repo syncing 2022-06-13 22:37:19 +02:00
f89c9c2ca5 Do not fail on empty clone target 2022-06-13 22:37:11 +02:00
62c2fc24cd Initialize local branches on clone 2022-06-13 22:37:06 +02:00
d0425e2fdb Add function to get all remote branches 2022-06-13 22:37:03 +02:00
a8665ae741 Add function to get basename of branch 2022-06-13 22:36:54 +02:00
d26b6e799c Refactor default_branch() for readability 2022-06-13 22:36:47 +02:00
60eb059f60 providers: Use references for field access 2022-06-13 22:32:31 +02:00
d9f416018d Use opaque type for auth token
So we cannot accidentially output it, as it does not implement
`Display`.
2022-06-13 22:32:31 +02:00
Max Volk
461c69dacb Reword some of the documentation and spelling fixes 2022-06-13 22:32:26 +02:00
ef21f9cad4 Remove accidentially added file 2022-06-13 22:32:26 +02:00
29 changed files with 627 additions and 1951 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
github: hakoerber

View File

@@ -21,8 +21,7 @@ If you want, add yourself to the `CONTRIBUTORS` file in your pull request.
For Rust, just use `cargo fmt`. For Python, use For Rust, just use `cargo fmt`. For Python, use
[black](https://github.com/psf/black). I'd rather not spend any effort in [black](https://github.com/psf/black). I'd rather not spend any effort in
configuring the formatters (not possible for black anyway). For shell scripts, configuring the formatters (not possible for black anyway).
use [`shfmt`](https://github.com/mvdan/sh).
## Tooling ## Tooling
@@ -42,9 +41,6 @@ When contributing, consider whether it makes sense to add tests which could
prevent regressions in the future. When fixing bugs, it makes sense to add prevent regressions in the future. When fixing bugs, it makes sense to add
tests that expose the wrong behaviour beforehand. tests that expose the wrong behaviour beforehand.
To also ensure proper formatting and that the linter is happy, use `just check`.
If that succeeds, your code is most likely fine to push!
## Documentation ## Documentation
The documentation lives in `docs` and uses The documentation lives in `docs` and uses

View File

@@ -1,2 +1 @@
nonnominandus nonnominandus
Maximilian Volk

145
Cargo.lock generated
View File

@@ -80,16 +80,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]] [[package]]
name = "clap" name = "clap"
version = "3.2.7" version = "3.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b7b16274bb247b45177db843202209b12191b631a14a9d06e41b3777d6ecf14" checksum = "d2dbdf4bdacb33466e854ce889eee8dfd5729abf7ccd7664d0a2d60cd384440b"
dependencies = [ dependencies = [
"atty", "atty",
"bitflags", "bitflags",
"clap_derive", "clap_derive",
"clap_lex", "clap_lex",
"indexmap", "indexmap",
"once_cell", "lazy_static",
"strsim", "strsim",
"termcolor", "termcolor",
"textwrap", "textwrap",
@@ -97,11 +97,11 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "3.2.7" version = "3.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "759bf187376e1afa7b85b959e6a664a3e7a95203415dba952ad19139e798f902" checksum = "25320346e922cffe59c0bbc5410c8d8784509efb321488971081313cb1e1a33c"
dependencies = [ dependencies = [
"heck", "heck 0.4.0",
"proc-macro-error", "proc-macro-error",
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -110,18 +110,18 @@ dependencies = [
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "0.2.4" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" checksum = "a37c35f1112dad5e6e0b1adaff798507497a18fceeb30cceb3bae7d1427b9213"
dependencies = [ dependencies = [
"os_str_bytes", "os_str_bytes",
] ]
[[package]] [[package]]
name = "comfy-table" name = "comfy-table"
version = "6.0.0" version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121d8a5b0346092c18a4b2fd6f620d7a06f0eb7ac0a45860939a0884bc579c56" checksum = "b103d85ca6e209388771bfb7aa6b68a7aeec4afbf6f0a0264bfbf50360e5212e"
dependencies = [ dependencies = [
"crossterm", "crossterm",
"strum", "strum",
@@ -155,12 +155,12 @@ dependencies = [
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.10" version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d82ee10ce34d7bc12c2122495e7593a9c41347ecdd64185af4ecf72cb1a7f83" checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "lazy_static",
] ]
[[package]] [[package]]
@@ -321,18 +321,18 @@ dependencies = [
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.7" version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"wasi", "wasi 0.10.2+wasi-snapshot-preview1",
] ]
[[package]] [[package]]
name = "git-repo-manager" name = "git-repo-manager"
version = "0.7.4" version = "0.7.1"
dependencies = [ dependencies = [
"clap", "clap",
"comfy-table", "comfy-table",
@@ -367,9 +367,18 @@ dependencies = [
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.1" version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3" checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
[[package]]
name = "heck"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
dependencies = [
"unicode-segmentation",
]
[[package]] [[package]]
name = "heck" name = "heck"
@@ -388,9 +397,9 @@ dependencies = [
[[package]] [[package]]
name = "http" name = "http"
version = "0.2.8" version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" checksum = "ff8670570af52249509a86f5e3e18a08c60b177071826898fde8997cf5f6bfbb"
dependencies = [ dependencies = [
"bytes", "bytes",
"fnv", "fnv",
@@ -410,9 +419,9 @@ dependencies = [
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.1" version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"hashbrown", "hashbrown",
@@ -523,9 +532,9 @@ dependencies = [
[[package]] [[package]]
name = "libz-sys" name = "libz-sys"
version = "1.1.8" version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf" checksum = "92e7e15d7610cce1d9752e137625f14e61a28cd45929b6e12e47b50fe154ee2e"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -535,9 +544,9 @@ dependencies = [
[[package]] [[package]]
name = "linked-hash-map" name = "linked-hash-map"
version = "0.5.6" version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
@@ -578,13 +587,13 @@ checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d"
[[package]] [[package]]
name = "mio" name = "mio"
version = "0.8.4" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" checksum = "713d550d9b44d89174e066b7a6217ae06234c10cb47819a88290d2b353c31799"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
"wasi", "wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys", "windows-sys",
] ]
@@ -602,18 +611,18 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]] [[package]]
name = "openssl-src" name = "openssl-src"
version = "111.21.0+1.1.1p" version = "111.20.0+1.1.1o"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d0a8313729211913936f1b95ca47a5fc7f2e04cd658c115388287f8a8361008" checksum = "92892c4f87d56e376e469ace79f1128fdaded07646ddf73aa0be4706ff712dec"
dependencies = [ dependencies = [
"cc", "cc",
] ]
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.74" version = "0.9.73"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "835363342df5fba8354c5b453325b110ffd54044e588c539cf2f20a8014e4cb1" checksum = "9d5fd19fb3e0a8191c1e34935718976a3e70c112ab9a24af6d7cadccd9d90bc0"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"cc", "cc",
@@ -637,9 +646,9 @@ checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.1" version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58"
dependencies = [ dependencies = [
"lock_api", "lock_api",
"parking_lot_core", "parking_lot_core",
@@ -746,18 +755,18 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.40" version = "1.0.39"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.20" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -847,9 +856,9 @@ dependencies = [
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.7" version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0a5f7c728f5d284929a1cccb5bc19884422bfe6ef4d6c409da2c41838983fcf" checksum = "f2cc38e8fa666e2de3c4aba7edeb5ffc5246c1c2ed0e3d17e560aeeba736b23f"
[[package]] [[package]]
name = "ryu" name = "ryu"
@@ -895,9 +904,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.82" version = "1.0.81"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7" checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c"
dependencies = [ dependencies = [
"itoa", "itoa",
"ryu", "ryu",
@@ -974,9 +983,9 @@ dependencies = [
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.8.1" version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc88c725d61fc6c3132893370cac4a0200e3fedf5da8331c570664b1987f5ca2" checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83"
[[package]] [[package]]
name = "socket2" name = "socket2"
@@ -996,17 +1005,17 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]] [[package]]
name = "strum" name = "strum"
version = "0.24.1" version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" checksum = "cae14b91c7d11c9a851d3fbc80a963198998c2a64eec840477fa92d8ce9b70bb"
[[package]] [[package]]
name = "strum_macros" name = "strum_macros"
version = "0.24.2" version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4faebde00e8ff94316c01800f9054fd2ba77d30d9e922541913051d1d978918b" checksum = "5bb0dc7ee9c15cea6199cde9a127fa16a4c5819af85395457ad72d68edc85a38"
dependencies = [ dependencies = [
"heck", "heck 0.3.3",
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustversion", "rustversion",
@@ -1015,9 +1024,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.98" version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" checksum = "fbaf6116ab8924f39d52792136fb74fd60a80194cf1b1c6ffa6453eef1c3f942"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1105,9 +1114,9 @@ dependencies = [
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.35" version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160" checksum = "5d0ecdcb44a79f0fe9844f0c4f33a342cbcbb5117de8001e6ba0dc2351327d09"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"log", "log",
@@ -1129,11 +1138,11 @@ dependencies = [
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.28" version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b7358be39f2f274f322d2aaed611acc57f382e8eb1e5b48cb9ae30933495ce7" checksum = "f54c8ca710e81886d498c2fd3331b56c93aa248d49de2222ad2742247c60072f"
dependencies = [ dependencies = [
"once_cell", "lazy_static",
] ]
[[package]] [[package]]
@@ -1154,19 +1163,25 @@ checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.1" version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee"
[[package]] [[package]]
name = "unicode-normalization" name = "unicode-normalization"
version = "0.1.20" version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81dee68f85cab8cf68dec42158baf3a79a1cdc065a8b103025965d6ccb7f6cbd" checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9"
dependencies = [ dependencies = [
"tinyvec", "tinyvec",
] ]
[[package]]
name = "unicode-segmentation"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.1.9" version = "0.1.9"
@@ -1212,6 +1227,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca"
[[package]]
name = "wasi"
version = "0.10.2+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
[[package]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.0+wasi-snapshot-preview1" version = "0.11.0+wasi-snapshot-preview1"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "git-repo-manager" name = "git-repo-manager"
version = "0.7.4" version = "0.7.1"
edition = "2021" edition = "2021"
authors = [ authors = [
@@ -28,7 +28,7 @@ rust-version = "1.57"
license = "GPL-3.0-only" license = "GPL-3.0-only"
[profile.e2e-tests] [profile.e2e-tests]
inherits = "dev" inherits = "release"
[lib] [lib]
name = "grm" name = "grm"
@@ -54,7 +54,7 @@ version = "=0.14.4"
version = "=2.1.0" version = "=2.1.0"
[dependencies.clap] [dependencies.clap]
version = "=3.2.7" version = "=3.1.18"
features = ["derive", "cargo"] features = ["derive", "cargo"]
[dependencies.console] [dependencies.console]
@@ -64,13 +64,13 @@ version = "=0.15.0"
version = "=1.5.6" version = "=1.5.6"
[dependencies.comfy-table] [dependencies.comfy-table]
version = "=6.0.0" version = "=5.0.1"
[dependencies.serde_yaml] [dependencies.serde_yaml]
version = "=0.8.24" version = "=0.8.24"
[dependencies.serde_json] [dependencies.serde_json]
version = "=1.0.82" version = "=1.0.81"
[dependencies.isahc] [dependencies.isahc]
version = "=1.7.2" version = "=1.7.2"

View File

@@ -1,59 +1,47 @@
set positional-arguments set positional-arguments
static_target := "x86_64-unknown-linux-musl" target := "x86_64-unknown-linux-musl"
check: fmt-check lint test check: test
cargo check cargo check
cargo fmt --check
clean: cargo clippy --no-deps -- -Dwarnings
cargo clean
git clean -f -d -X
fmt: fmt:
cargo fmt cargo fmt
git ls-files | grep '\.py$' | xargs black git ls-files | grep '\.py$' | xargs black
git ls-files | grep '\.sh$' | xargs -L 1 shfmt --indent 4 --write
fmt-check:
cargo fmt --check
git ls-files | grep '\.py$' | xargs black --check
git ls-files | grep '\.sh$' | xargs -L 1 shfmt --indent 4 --diff
lint: lint:
cargo clippy --no-deps -- -Dwarnings cargo clippy --no-deps
git ls-files | grep '\.sh$' | xargs -L 1 shellcheck --norc
lint-fix: lint-fix:
cargo clippy --no-deps --fix cargo clippy --no-deps --fix
release: release:
cargo build --release cargo build --release --target {{target}}
release-static:
cargo build --release --target {{static_target}} --features=static-build
test-binary: test-binary:
env \ env \
GITHUB_API_BASEURL=http://rest:5000/github \ GITHUB_API_BASEURL=http://rest:5000/github \
GITLAB_API_BASEURL=http://rest:5000/gitlab \ GITLAB_API_BASEURL=http://rest:5000/gitlab \
cargo build --profile e2e-tests --target {{static_target}} --features=static-build cargo build --target {{target}} --profile e2e-tests --features=static-build
install: install:
cargo install --path . cargo install --path .
install-static: install-static:
cargo install --target {{static_target}} --features=static-build --path . cargo install --target {{target}} --features=static-build --path .
build: build:
cargo build cargo build
build-static: build-static:
cargo build --target {{static_target}} --features=static-build cargo build --target {{target}} --features=static-build
test: test-unit test-integration test-e2e test: test-unit test-integration test-e2e
test-unit +tests="": test-unit:
cargo test --lib --bins -- --show-output {{tests}} cargo test --lib --bins
test-integration: test-integration:
cargo test --test "*" cargo test --test "*"
@@ -64,9 +52,9 @@ test-e2e +tests=".": test-binary
&& docker-compose build \ && docker-compose build \
&& docker-compose run \ && docker-compose run \
--rm \ --rm \
-v $PWD/../target/x86_64-unknown-linux-musl/e2e-tests/grm:/grm \ -v $PWD/../target/{{target}}/e2e-tests/grm:/grm \
pytest \ pytest \
"GRM_BINARY=/grm ALTERNATE_DOMAIN=alternate-rest python3 -m pytest --exitfirst -p no:cacheprovider --color=yes "$@"" \ "GRM_BINARY=/grm ALTERNATE_DOMAIN=alternate-rest python3 -m pytest -p no:cacheprovider --color=yes "$@"" \
&& docker-compose rm --stop -f && docker-compose rm --stop -f
update-dependencies: update-cargo-dependencies update-dependencies: update-cargo-dependencies

View File

@@ -1,14 +1,8 @@
import os import os
from helpers import *
def pytest_configure(config): def pytest_configure(config):
os.environ["GIT_AUTHOR_NAME"] = "Example user" os.environ["GIT_AUTHOR_NAME"] = "Example user"
os.environ["GIT_AUTHOR_EMAIL"] = "user@example.com" os.environ["GIT_AUTHOR_EMAIL"] = "user@example.com"
os.environ["GIT_COMMITTER_NAME"] = "Example user" os.environ["GIT_COMMITTER_NAME"] = "Example user"
os.environ["GIT_COMMITTER_EMAIL"] = "user@example.com" os.environ["GIT_COMMITTER_EMAIL"] = "user@example.com"
def pytest_unconfigure(config):
pass

View File

@@ -1,7 +1,7 @@
FROM docker.io/debian:11.3 FROM docker.io/debian:11.3
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y \
python3-pytest \ python3-pytest \
python3-toml \ python3-toml \
python3-git \ python3-git \

View File

@@ -5,26 +5,12 @@ import os.path
import subprocess import subprocess
import tempfile import tempfile
import hashlib import hashlib
import shutil
import inspect
import git import git
binary = os.environ["GRM_BINARY"] binary = os.environ["GRM_BINARY"]
def funcname():
return inspect.stack()[1][3]
def copytree(src, dest):
shutil.copytree(src, dest, dirs_exist_ok=True)
def get_temporary_directory(dir=None):
return tempfile.TemporaryDirectory(dir=dir)
def grm(args, cwd=None, is_invalid=False): def grm(args, cwd=None, is_invalid=False):
cmd = subprocess.run([binary] + args, cwd=cwd, capture_output=True, text=True) cmd = subprocess.run([binary] + args, cwd=cwd, capture_output=True, text=True)
if not is_invalid: if not is_invalid:
@@ -39,12 +25,8 @@ def grm(args, cwd=None, is_invalid=False):
def shell(script): def shell(script):
script = "set -o errexit\nset -o nounset\nset -o pipefail\n" + script script = "set -o errexit\nset -o nounset\n" + script
cmd = subprocess.run(["bash"], input=script, text=True, capture_output=True) subprocess.run(["bash"], input=script, text=True, check=True)
if cmd.returncode != 0:
print(cmd.stdout)
print(cmd.stderr)
cmd.check_returncode()
def checksum_directory(path): def checksum_directory(path):
@@ -130,204 +112,78 @@ def checksum_directory(path):
class TempGitRepository: class TempGitRepository:
def __init__(self, dir=None): def __init__(self, dir=None):
self.dir = dir self.dir = dir
pass
def __enter__(self): def __enter__(self):
self.tmpdir = get_temporary_directory(self.dir) self.tmpdir = tempfile.TemporaryDirectory(dir=self.dir)
self.remote_1 = get_temporary_directory() self.remote_1_dir = tempfile.TemporaryDirectory()
self.remote_2 = get_temporary_directory() self.remote_2_dir = tempfile.TemporaryDirectory()
cmd = f""" shell(
f"""
cd {self.tmpdir.name} cd {self.tmpdir.name}
git -c init.defaultBranch=master init git init
echo test > root-commit echo test > root-commit
git add root-commit git add root-commit
git commit -m "root-commit" git commit -m "root-commit"
git remote add origin file://{self.remote_1.name} git remote add origin file://{self.remote_1_dir.name}
git remote add otherremote file://{self.remote_2.name} git remote add otherremote file://{self.remote_2_dir.name}
""" """
)
shell(cmd)
return self.tmpdir.name return self.tmpdir.name
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
pass del self.tmpdir
del self.remote_1_dir
del self.remote_2_dir
class TempGitRemote:
obj = {}
def __init__(self, tmpdir, remoteid=None):
self.tmpdir = tmpdir
self.remoteid = remoteid
@classmethod
def get(cls, cachekey=None, initfunc=None):
if cachekey is None:
tmpdir = get_temporary_directory()
shell(
f"""
cd {tmpdir.name}
git -c init.defaultBranch=master init --bare
"""
)
newobj = cls(tmpdir)
remoteid = None
if initfunc is not None:
remoteid = newobj.init(initfunc)
newobj.remoteid = remoteid
return newobj, remoteid
else:
refresh = False
if cachekey not in cls.obj:
tmpdir = get_temporary_directory()
shell(
f"""
cd {tmpdir.name}
git -c init.defaultBranch=master init --bare
"""
)
newobj = cls(tmpdir)
remoteid = newobj.init(initfunc)
newobj.remoteid = remoteid
cls.obj[cachekey] = newobj
return cls.clone(cls.obj[cachekey])
@classmethod
def clone(cls, source):
new_remote = get_temporary_directory()
copytree(source.tmpdir.name, new_remote.name)
return cls(new_remote, source.remoteid), source.remoteid
def init(self, func):
return func(self.tmpdir.name)
def __enter__(self):
return self.tmpdir
def __exit__(self, exc_type, exc_val, exc_tb):
pass
class TempGitRepositoryWorktree: class TempGitRepositoryWorktree:
obj = {} def __init__(self):
pass
def __init__(self, remotes, tmpdir, commit, remote1, remote2, remote1id, remote2id):
self.remotes = remotes
self.tmpdir = tmpdir
self.commit = commit
self.remote1 = remote1
self.remote2 = remote2
self.remote1id = remote1id
self.remote2id = remote2id
@classmethod
def get(cls, cachekey, branch=None, remotes=2, basedir=None, remote_setup=None):
if cachekey not in cls.obj:
tmpdir = get_temporary_directory()
shell(
f"""
cd {tmpdir.name}
git -c init.defaultBranch=master init
echo test > root-commit-in-worktree-1
git add root-commit-in-worktree-1
git commit -m "root-commit-in-worktree-1"
echo test > root-commit-in-worktree-2
git add root-commit-in-worktree-2
git commit -m "root-commit-in-worktree-2"
git ls-files | xargs rm -rf
mv .git .git-main-working-tree
git --git-dir .git-main-working-tree config core.bare true
"""
)
repo = git.Repo(f"{tmpdir.name}/.git-main-working-tree")
commit = repo.head.commit.hexsha
if branch is not None:
repo.create_head(branch)
remote1 = None
remote2 = None
remote1id = None
remote2id = None
if remotes >= 1:
cachekeyremote, initfunc = (remote_setup or ((None, None),))[0]
remote1, remote1id = TempGitRemote.get(
cachekey=cachekeyremote, initfunc=initfunc
)
remote1 = remote1
remote1id = remote1id
shell(
f"""
cd {tmpdir.name}
git --git-dir .git-main-working-tree remote add origin file://{remote1.tmpdir.name}
"""
)
repo.remotes.origin.fetch()
repo.remotes.origin.push("master")
if remotes >= 2:
cachekeyremote, initfunc = (remote_setup or (None, (None, None)))[1]
remote2, remote2id = TempGitRemote.get(
cachekey=cachekeyremote, initfunc=initfunc
)
remote2 = remote2
remote2id = remote2id
shell(
f"""
cd {tmpdir.name}
git --git-dir .git-main-working-tree remote add otherremote file://{remote2.tmpdir.name}
"""
)
repo.remotes.otherremote.fetch()
repo.remotes.otherremote.push("master")
cls.obj[cachekey] = cls(
remotes, tmpdir, commit, remote1, remote2, remote1id, remote2id
)
return cls.clone(cls.obj[cachekey], remote_setup=remote_setup)
@classmethod
def clone(cls, source, remote_setup):
newdir = get_temporary_directory()
copytree(source.tmpdir.name, newdir.name)
remote1 = None
remote2 = None
remote1id = None
remote2id = None
repo = git.Repo(os.path.join(newdir.name, ".git-main-working-tree"))
if source.remotes >= 1:
cachekey, initfunc = (remote_setup or ((None, None),))[0]
remote1, remote1id = TempGitRemote.get(cachekey=cachekey, initfunc=initfunc)
if remote1id != source.remote1id:
repo.remotes.origin.fetch()
repo.remotes.origin.push("master")
if source.remotes >= 2:
cachekey, initfunc = (remote_setup or (None, (None, None)))[1]
remote2, remote2id = TempGitRemote.get(cachekey=cachekey, initfunc=initfunc)
if remote2id != source.remote2id:
repo.remotes.otherremote.fetch()
repo.remotes.otherremote.push("master")
return cls(
source.remotes,
newdir,
source.commit,
remote1,
remote2,
remote1id,
remote2id,
)
def __enter__(self): def __enter__(self):
return (self.tmpdir.name, self.commit) 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 > root-commit-in-worktree-1
git add root-commit-in-worktree-1
git commit -m "root-commit-in-worktree-1"
echo test > root-commit-in-worktree-2
git add root-commit-in-worktree-2
git commit -m "root-commit-in-worktree-2"
git remote add origin file://{self.remote_1_dir.name}
git remote add otherremote file://{self.remote_2_dir.name}
git push origin HEAD:master
git ls-files | xargs rm -rf
mv .git .git-main-working-tree
git --git-dir .git-main-working-tree config core.bare true
"""
)
commit = git.Repo(
f"{self.tmpdir.name}/.git-main-working-tree"
).head.commit.hexsha
return (self.tmpdir.name, commit)
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
pass del self.tmpdir
del self.remote_1_dir
del self.remote_2_dir
class RepoTree: class RepoTree:
@@ -335,7 +191,7 @@ class RepoTree:
pass pass
def __enter__(self): def __enter__(self):
self.root = get_temporary_directory() self.root = tempfile.TemporaryDirectory()
self.config = tempfile.NamedTemporaryFile() self.config = tempfile.NamedTemporaryFile()
with open(self.config.name, "w") as f: with open(self.config.name, "w") as f:
f.write( f.write(
@@ -366,7 +222,7 @@ class EmptyDir:
pass pass
def __enter__(self): def __enter__(self):
self.tmpdir = get_temporary_directory() self.tmpdir = tempfile.TemporaryDirectory()
return self.tmpdir.name return self.tmpdir.name
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
@@ -378,7 +234,7 @@ class NonGitDir:
pass pass
def __enter__(self): def __enter__(self):
self.tmpdir = get_temporary_directory() self.tmpdir = tempfile.TemporaryDirectory()
shell( shell(
f""" f"""
cd {self.tmpdir.name} cd {self.tmpdir.name}
@@ -398,11 +254,11 @@ class TempGitFileRemote:
pass pass
def __enter__(self): def __enter__(self):
self.tmpdir = get_temporary_directory() self.tmpdir = tempfile.TemporaryDirectory()
shell( shell(
f""" f"""
cd {self.tmpdir.name} cd {self.tmpdir.name}
git -c init.defaultBranch=master init git init
echo test > root-commit-in-remote-1 echo test > root-commit-in-remote-1
git add root-commit-in-remote-1 git add root-commit-in-remote-1
git commit -m "root-commit-in-remote-1" git commit -m "root-commit-in-remote-1"

View File

@@ -73,7 +73,7 @@ def test_repos_find(configtype, default):
mkdir repo1 mkdir repo1
( (
cd ./repo1 cd ./repo1
git -c init.defaultBranch=master init git init
echo test > test echo test > test
git add test git add test
git commit -m "commit1" git commit -m "commit1"
@@ -83,7 +83,7 @@ def test_repos_find(configtype, default):
mkdir repo2 mkdir repo2
( (
cd ./repo2 cd ./repo2
git -c init.defaultBranch=master init git init
git checkout -b main git checkout -b main
echo test > test echo test > test
git add test git add test
@@ -203,7 +203,7 @@ def test_repos_find_with_invalid_repo(configtype, default):
mkdir repo1 mkdir repo1
( (
cd ./repo1 cd ./repo1
git -c init.defaultBranch=master init git init
echo test > test echo test > test
git add test git add test
git commit -m "commit1" git commit -m "commit1"
@@ -213,7 +213,7 @@ def test_repos_find_with_invalid_repo(configtype, default):
mkdir repo2 mkdir repo2
( (
cd ./repo2 cd ./repo2
git -c init.defaultBranch=master init git init
git checkout -b main git checkout -b main
echo test > test echo test > test
git add test git add test

View File

@@ -248,7 +248,6 @@ def test_repos_find_remote_user_empty(
@pytest.mark.parametrize("force_ssh", [True, False]) @pytest.mark.parametrize("force_ssh", [True, False])
@pytest.mark.parametrize("use_alternate_endpoint", [True, False]) @pytest.mark.parametrize("use_alternate_endpoint", [True, False])
@pytest.mark.parametrize("use_config", [True, False]) @pytest.mark.parametrize("use_config", [True, False])
@pytest.mark.parametrize("override_remote_name", [True, False])
def test_repos_find_remote_user( def test_repos_find_remote_user(
provider, provider,
configtype, configtype,
@@ -259,7 +258,6 @@ def test_repos_find_remote_user(
force_ssh, force_ssh,
use_alternate_endpoint, use_alternate_endpoint,
use_config, use_config,
override_remote_name,
): ):
if use_config: if use_config:
with tempfile.NamedTemporaryFile() as config: with tempfile.NamedTemporaryFile() as config:
@@ -276,8 +274,6 @@ def test_repos_find_remote_user(
cfg += f"worktree = {str(worktree).lower()}\n" cfg += f"worktree = {str(worktree).lower()}\n"
if force_ssh: if force_ssh:
cfg += f"force_ssh = true\n" cfg += f"force_ssh = true\n"
if override_remote_name:
cfg += f'remote_name = "otherremote"\n'
if use_owner: if use_owner:
cfg += """ cfg += """
[filters] [filters]
@@ -314,8 +310,6 @@ def test_repos_find_remote_user(
args += ["--user", "myuser1"] args += ["--user", "myuser1"]
if force_ssh: if force_ssh:
args += ["--force-ssh"] args += ["--force-ssh"]
if override_remote_name:
args += ["--remote-name", "otherremote"]
if not worktree_default: if not worktree_default:
args += ["--worktree", str(worktree).lower()] args += ["--worktree", str(worktree).lower()]
if use_alternate_endpoint: if use_alternate_endpoint:
@@ -356,10 +350,7 @@ def test_repos_find_remote_user(
assert repo["worktree_setup"] is (not worktree_default and worktree) assert repo["worktree_setup"] is (not worktree_default and worktree)
assert isinstance(repo["remotes"], list) assert isinstance(repo["remotes"], list)
assert len(repo["remotes"]) == 1 assert len(repo["remotes"]) == 1
if override_remote_name: assert repo["remotes"][0]["name"] == provider
assert repo["remotes"][0]["name"] == "otherremote"
else:
assert repo["remotes"][0]["name"] == "origin"
if force_ssh or i == 1: if force_ssh or i == 1:
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]
@@ -544,14 +535,14 @@ def test_repos_find_remote_group(
assert isinstance(repo["remotes"], list) assert isinstance(repo["remotes"], list)
assert len(repo["remotes"]) == 1 assert len(repo["remotes"]) == 1
if force_ssh or i == 1: if force_ssh or i == 1:
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == provider
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]
== f"ssh://git@example.com/mygroup1/myproject{i}.git" == f"ssh://git@example.com/mygroup1/myproject{i}.git"
) )
assert repo["remotes"][0]["type"] == "ssh" assert repo["remotes"][0]["type"] == "ssh"
else: else:
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == provider
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]
== f"https://example.com/mygroup1/myproject{i}.git" == f"https://example.com/mygroup1/myproject{i}.git"
@@ -668,7 +659,7 @@ def test_repos_find_remote_user_and_group(
assert repo["worktree_setup"] is (not worktree_default and worktree) assert repo["worktree_setup"] is (not worktree_default and worktree)
assert isinstance(repo["remotes"], list) assert isinstance(repo["remotes"], list)
assert len(repo["remotes"]) == 1 assert len(repo["remotes"]) == 1
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == provider
if force_ssh or i == 1: if force_ssh or i == 1:
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]
@@ -693,7 +684,7 @@ def test_repos_find_remote_user_and_group(
assert repo["worktree_setup"] is (not worktree_default and worktree) assert repo["worktree_setup"] is (not worktree_default and worktree)
assert isinstance(repo["remotes"], list) assert isinstance(repo["remotes"], list)
assert len(repo["remotes"]) == 1 assert len(repo["remotes"]) == 1
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == provider
if force_ssh or i == 1: if force_ssh or i == 1:
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]
@@ -823,7 +814,7 @@ def test_repos_find_remote_owner(
assert repo["worktree_setup"] is (not worktree_default and worktree) assert repo["worktree_setup"] is (not worktree_default and worktree)
assert isinstance(repo["remotes"], list) assert isinstance(repo["remotes"], list)
assert len(repo["remotes"]) == 1 assert len(repo["remotes"]) == 1
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == provider
if force_ssh or i == 1: if force_ssh or i == 1:
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]
@@ -846,7 +837,7 @@ def test_repos_find_remote_owner(
assert repo["worktree_setup"] is (not worktree_default and worktree) assert repo["worktree_setup"] is (not worktree_default and worktree)
assert isinstance(repo["remotes"], list) assert isinstance(repo["remotes"], list)
assert len(repo["remotes"]) == 1 assert len(repo["remotes"]) == 1
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == provider
if force_ssh or i == 1: if force_ssh or i == 1:
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]
@@ -870,7 +861,7 @@ def test_repos_find_remote_owner(
assert repo["worktree_setup"] is (not worktree_default and worktree) assert repo["worktree_setup"] is (not worktree_default and worktree)
assert isinstance(repo["remotes"], list) assert isinstance(repo["remotes"], list)
assert len(repo["remotes"]) == 1 assert len(repo["remotes"]) == 1
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == provider
if force_ssh: if force_ssh:
assert ( assert (
repo["remotes"][0]["url"] == f"ssh://git@example.com/myuser2/myproject3.git" repo["remotes"][0]["url"] == f"ssh://git@example.com/myuser2/myproject3.git"
@@ -899,7 +890,7 @@ def test_repos_find_remote_owner(
assert repo["worktree_setup"] is (not worktree_default and worktree) assert repo["worktree_setup"] is (not worktree_default and worktree)
assert isinstance(repo["remotes"], list) assert isinstance(repo["remotes"], list)
assert len(repo["remotes"]) == 1 assert len(repo["remotes"]) == 1
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == provider
if force_ssh or i == 1: if force_ssh or i == 1:
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]
@@ -919,7 +910,7 @@ def test_repos_find_remote_owner(
assert repo["worktree_setup"] is (not worktree_default and worktree) assert repo["worktree_setup"] is (not worktree_default and worktree)
assert isinstance(repo["remotes"], list) assert isinstance(repo["remotes"], list)
assert len(repo["remotes"]) == 1 assert len(repo["remotes"]) == 1
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == provider
if force_ssh: if force_ssh:
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]
@@ -945,7 +936,7 @@ def test_repos_find_remote_owner(
assert repo["worktree_setup"] is (not worktree_default and worktree) assert repo["worktree_setup"] is (not worktree_default and worktree)
assert isinstance(repo["remotes"], list) assert isinstance(repo["remotes"], list)
assert len(repo["remotes"]) == 1 assert len(repo["remotes"]) == 1
assert repo["remotes"][0]["name"] == "origin" assert repo["remotes"][0]["name"] == provider
if force_ssh: if force_ssh:
assert ( assert (
repo["remotes"][0]["url"] repo["remotes"][0]["url"]

View File

@@ -6,7 +6,7 @@ from helpers import *
def test_worktree_clean(): def test_worktree_clean():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
assert "test" in os.listdir(base_dir) assert "test" in os.listdir(base_dir)
@@ -17,7 +17,7 @@ def test_worktree_clean():
def test_worktree_clean_refusal_no_tracking_branch(): def test_worktree_clean_refusal_no_tracking_branch():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test"], cwd=base_dir) cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -31,7 +31,7 @@ def test_worktree_clean_refusal_no_tracking_branch():
def test_worktree_clean_refusal_uncommited_changes_new_file(): def test_worktree_clean_refusal_uncommited_changes_new_file():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -47,7 +47,7 @@ def test_worktree_clean_refusal_uncommited_changes_new_file():
def test_worktree_clean_refusal_uncommited_changes_changed_file(): def test_worktree_clean_refusal_uncommited_changes_changed_file():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -63,7 +63,7 @@ def test_worktree_clean_refusal_uncommited_changes_changed_file():
def test_worktree_clean_refusal_uncommited_changes_cleand_file(): def test_worktree_clean_refusal_uncommited_changes_cleand_file():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -81,7 +81,7 @@ def test_worktree_clean_refusal_uncommited_changes_cleand_file():
def test_worktree_clean_refusal_commited_changes(): def test_worktree_clean_refusal_commited_changes():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -99,7 +99,7 @@ def test_worktree_clean_refusal_commited_changes():
def test_worktree_clean_refusal_tracking_branch_mismatch(): def test_worktree_clean_refusal_tracking_branch_mismatch():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -117,7 +117,7 @@ def test_worktree_clean_refusal_tracking_branch_mismatch():
def test_worktree_clean_fail_from_subdir(): def test_worktree_clean_fail_from_subdir():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test"], cwd=base_dir) cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -148,7 +148,7 @@ def test_worktree_clean_non_git():
def test_worktree_clean_configured_default_branch( def test_worktree_clean_configured_default_branch(
configure_default_branch, branch_list_empty configure_default_branch, branch_list_empty
): ):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
if configure_default_branch: if configure_default_branch:
with open(os.path.join(base_dir, "grm.toml"), "w") as f: with open(os.path.join(base_dir, "grm.toml"), "w") as f:
if branch_list_empty: if branch_list_empty:

View File

@@ -6,7 +6,7 @@ from helpers import *
def test_worktree_never_clean_persistent_branches(): def test_worktree_never_clean_persistent_branches():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
with open(os.path.join(base_dir, "grm.toml"), "w") as f: with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write( f.write(
""" """
@@ -33,7 +33,7 @@ def test_worktree_never_clean_persistent_branches():
def test_worktree_clean_branch_merged_into_persistent(): def test_worktree_clean_branch_merged_into_persistent():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
with open(os.path.join(base_dir, "grm.toml"), "w") as f: with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write( f.write(
""" """
@@ -72,7 +72,7 @@ def test_worktree_clean_branch_merged_into_persistent():
def test_worktree_no_clean_unmerged_branch(): def test_worktree_no_clean_unmerged_branch():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
with open(os.path.join(base_dir, "grm.toml"), "w") as f: with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write( f.write(
""" """
@@ -105,7 +105,7 @@ def test_worktree_no_clean_unmerged_branch():
def test_worktree_delete_branch_merged_into_persistent(): def test_worktree_delete_branch_merged_into_persistent():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
with open(os.path.join(base_dir, "grm.toml"), "w") as f: with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write( f.write(
""" """

View File

@@ -23,7 +23,7 @@ def test_convert():
def test_convert_already_worktree(): def test_convert_already_worktree():
with TempGitRepositoryWorktree.get(funcname()) as (git_dir, _commit): with TempGitRepositoryWorktree() as (git_dir, _commit):
before = checksum_directory(git_dir) before = checksum_directory(git_dir)
cmd = grm(["wt", "convert"], cwd=git_dir) cmd = grm(["wt", "convert"], cwd=git_dir)

View File

@@ -9,7 +9,7 @@ import git
def test_worktree_fetch(): def test_worktree_fetch():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, root_commit): with TempGitRepositoryWorktree() as (base_dir, root_commit):
with TempGitFileRemote() as (remote_path, _remote_sha): with TempGitFileRemote() as (remote_path, _remote_sha):
shell( shell(
f""" f"""
@@ -56,7 +56,7 @@ def test_worktree_fetch():
@pytest.mark.parametrize("has_changes", [True, False]) @pytest.mark.parametrize("has_changes", [True, False])
@pytest.mark.parametrize("stash", [True, False]) @pytest.mark.parametrize("stash", [True, False])
def test_worktree_pull(rebase, ffable, has_changes, stash): def test_worktree_pull(rebase, ffable, has_changes, stash):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, root_commit): with TempGitRepositoryWorktree() as (base_dir, root_commit):
with TempGitFileRemote() as (remote_path, _remote_sha): with TempGitFileRemote() as (remote_path, _remote_sha):
shell( shell(
f""" f"""

View File

@@ -14,7 +14,7 @@ import git
@pytest.mark.parametrize("has_changes", [True, False]) @pytest.mark.parametrize("has_changes", [True, False])
@pytest.mark.parametrize("stash", [True, False]) @pytest.mark.parametrize("stash", [True, False])
def test_worktree_rebase(pull, rebase, ffable, has_changes, stash): def test_worktree_rebase(pull, rebase, ffable, has_changes, stash):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _root_commit): with TempGitRepositoryWorktree() as (base_dir, _root_commit):
with open(os.path.join(base_dir, "grm.toml"), "w") as f: with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write('persistent_branches = ["mybasebranch"]') f.write('persistent_branches = ["mybasebranch"]')

View File

@@ -9,7 +9,7 @@ import pytest
@pytest.mark.parametrize("has_config", [True, False]) @pytest.mark.parametrize("has_config", [True, False])
def test_worktree_status(has_config): def test_worktree_status(has_config):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
if has_config: if has_config:
with open(os.path.join(base_dir, "grm.toml"), "w") as f: with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write("") f.write("")
@@ -24,7 +24,7 @@ def test_worktree_status(has_config):
def test_worktree_status_fail_from_subdir(): def test_worktree_status_fail_from_subdir():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test"], cwd=base_dir) cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -51,7 +51,7 @@ def test_worktree_status_non_git():
def test_worktree_status_warn_with_non_worktree_dir(): def test_worktree_status_warn_with_non_worktree_dir():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test"], cwd=base_dir) cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0

View File

@@ -4,565 +4,96 @@ from helpers import *
import git import git
import pytest import pytest
import datetime
import os.path import os.path
@pytest.mark.parametrize(
"config_setup",
(
(False, False, False),
(True, False, False),
(True, False, True),
(True, True, False),
(True, True, True),
),
)
@pytest.mark.parametrize("explicit_notrack", [True, False])
@pytest.mark.parametrize("explicit_track", [True, False])
@pytest.mark.parametrize(
"local_branch_setup", ((False, False), (True, False), (True, True))
)
@pytest.mark.parametrize("remote_branch_already_exists", [True, False]) @pytest.mark.parametrize("remote_branch_already_exists", [True, False])
@pytest.mark.parametrize("remote_branch_with_prefix_already_exists", [True, False]) @pytest.mark.parametrize("has_config", [True, False])
@pytest.mark.parametrize( @pytest.mark.parametrize("has_default", [True, False])
"remote_setup", @pytest.mark.parametrize("has_prefix", [True, False])
( def test_worktree_add_simple(
(0, "origin", False), remote_branch_already_exists, has_config, has_default, has_prefix
(1, "origin", False),
(2, "origin", False),
(2, "otherremote", False),
(2, "origin", True),
(2, "otherremote", True),
),
)
@pytest.mark.parametrize("track_differs_from_existing_branch_upstream", [True, False])
@pytest.mark.parametrize("worktree_with_slash", [True, False])
def test_worktree_add(
config_setup,
explicit_notrack,
explicit_track,
local_branch_setup,
remote_branch_already_exists,
remote_branch_with_prefix_already_exists,
remote_setup,
track_differs_from_existing_branch_upstream,
worktree_with_slash,
): ):
(remote_count, default_remote, remotes_differ) = remote_setup with TempGitRepositoryWorktree() as (base_dir, _commit):
( if has_config:
config_enabled,
config_has_default_remote_prefix,
config_has_default_track_enabled,
) = config_setup
(local_branch_exists, local_branch_has_tracking_branch) = local_branch_setup
has_remotes = True if remote_count > 0 else False
if worktree_with_slash:
worktree_name = "dir/nested/test"
else:
worktree_name = "test"
if track_differs_from_existing_branch_upstream:
explicit_track_branch_name = f"{default_remote}/somethingelse"
else:
explicit_track_branch_name = f"{default_remote}/{worktree_name}"
timestamp = datetime.datetime.now().replace(microsecond=0).isoformat()
# GitPython has some weird behaviour here. It is not possible to use kwargs
# to set the commit and author date.
#
# `committer_date=x` (which is documented) does not work, as `git commit`
# does not accept --committer-date
#
# `author_date=x` does not work, as it's now called --date in `git commit`
#
# `date=x` should work, but is refused by GitPython, as it does not know
# about the new behaviour in `git commit`
#
# Fortunately, there are env variables that control those timestamps.
os.environ["GIT_COMMITTER_DATE"] = str(timestamp)
os.environ["GIT_AUTHOR_DATE"] = str(timestamp)
def setup_remote1(directory):
if remote_branch_already_exists:
with tempfile.TemporaryDirectory() as cloned:
repo = git.Repo.clone_from(directory, cloned)
newfile = os.path.join(cloned, "change")
open(newfile, "w").close()
repo.index.add([newfile])
repo.index.commit("commit")
repo.remotes.origin.push(f"HEAD:{worktree_name}", force=True)
if remote_branch_with_prefix_already_exists:
with tempfile.TemporaryDirectory() as cloned:
repo = git.Repo.clone_from(directory, cloned)
newfile = os.path.join(cloned, "change2")
open(newfile, "w").close()
repo.index.add([newfile])
repo.index.commit("commit")
repo.remotes.origin.push(f"HEAD:myprefix/{worktree_name}", force=True)
return "_".join(
[
str(worktree_with_slash),
str(remote_branch_already_exists),
str(remote_branch_with_prefix_already_exists),
str(remotes_differ),
]
)
def setup_remote2(directory):
if remote_branch_already_exists:
with tempfile.TemporaryDirectory() as cloned:
repo = git.Repo.clone_from(directory, cloned)
newfile = os.path.join(cloned, "change")
open(newfile, "w").close()
repo.index.add([newfile])
repo.index.commit("commit")
if remotes_differ:
newfile = os.path.join(cloned, "change_on_second_remote")
open(newfile, "w").close()
repo.index.add([newfile])
repo.index.commit("commit_on_second_remote")
repo.remotes.origin.push(f"HEAD:{worktree_name}", force=True)
if remote_branch_with_prefix_already_exists:
with tempfile.TemporaryDirectory() as cloned:
repo = git.Repo.clone_from(directory, cloned)
newfile = os.path.join(cloned, "change2")
open(newfile, "w").close()
repo.index.add([newfile])
repo.index.commit("commit")
if remotes_differ:
newfile = os.path.join(cloned, "change_on_second_remote2")
open(newfile, "w").close()
repo.index.add([newfile])
repo.index.commit("commit_on_second_remote2")
repo.remotes.origin.push(f"HEAD:myprefix/{worktree_name}", force=True)
return "_".join(
[
str(worktree_with_slash),
str(remote_branch_already_exists),
str(remote_branch_with_prefix_already_exists),
str(remotes_differ),
]
)
cachefn = lambda nr: "_".join(
[
str(nr),
str(default_remote),
str(local_branch_exists),
str(remote_branch_already_exists),
str(remote_branch_with_prefix_already_exists),
str(remote_count),
str(remotes_differ),
str(worktree_name),
]
)
remote1_cache_key = cachefn(1)
remote2_cache_key = cachefn(2)
cachekey = "_".join(
[
str(local_branch_exists),
str(local_branch_has_tracking_branch),
str(remote_branch_already_exists),
str(remote_branch_with_prefix_already_exists),
str(remote_count),
str(remotes_differ),
str(worktree_name),
]
)
with TempGitRepositoryWorktree.get(
cachekey=cachekey,
branch=worktree_name if local_branch_exists else None,
remotes=remote_count,
remote_setup=[
[remote1_cache_key, setup_remote1],
[remote2_cache_key, setup_remote2],
],
) as (base_dir, initial_commit):
repo = git.Repo(os.path.join(base_dir, ".git-main-working-tree"))
if config_enabled:
with open(os.path.join(base_dir, "grm.toml"), "w") as f: with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write( f.write(
f""" f"""
[track] [track]
default = {str(config_has_default_track_enabled).lower()} default = {str(has_default).lower()}
default_remote = "{default_remote}" default_remote = "origin"
""" """
) )
if has_prefix:
if config_has_default_remote_prefix:
f.write( f.write(
""" """
default_remote_prefix = "myprefix" default_remote_prefix = "myprefix"
""" """
) )
if local_branch_exists: if remote_branch_already_exists:
if has_remotes and local_branch_has_tracking_branch: shell(
origin = repo.remote(default_remote) f"""
if remote_count >= 2: cd {base_dir}
otherremote = repo.remote("otherremote") git --git-dir ./.git-main-working-tree worktree add tmp
br = list(filter(lambda x: x.name == worktree_name, repo.branches))[0] (
assert os.path.exists(base_dir) cd tmp
if track_differs_from_existing_branch_upstream: touch change
origin.push( git add change
f"{worktree_name}:someothername", force=True, set_upstream=True git commit -m commit
) git push origin HEAD:test
if remote_count >= 2: #git reset --hard 'HEAD@{1}'
otherremote.push( git branch -va
f"{worktree_name}:someothername", )
force=True, git --git-dir ./.git-main-working-tree worktree remove tmp
set_upstream=True, """
) )
br.set_tracking_branch( cmd = grm(["wt", "add", "test"], cwd=base_dir)
list(
filter(
lambda x: x.remote_head == "someothername", origin.refs
)
)[0]
)
else:
origin.push(
f"{worktree_name}:{worktree_name}",
force=True,
set_upstream=True,
)
if remote_count >= 2:
otherremote.push(
f"{worktree_name}:{worktree_name}",
force=True,
set_upstream=True,
)
br.set_tracking_branch(
list(
filter(
lambda x: x.remote_head == worktree_name, origin.refs
)
)[0]
)
args = ["wt", "add", worktree_name]
if explicit_track:
args.extend(["--track", explicit_track_branch_name])
if explicit_notrack:
args.extend(["--no-track"])
cmd = grm(args, cwd=base_dir)
if explicit_track and not explicit_notrack and not has_remotes:
assert cmd.returncode != 0
assert f'remote "{default_remote}" not found' in cmd.stderr.lower()
return
assert cmd.returncode == 0 assert cmd.returncode == 0
assert len(cmd.stdout.strip().split("\n")) == 1
assert f"worktree {worktree_name} created" in cmd.stdout.lower()
def check_deviation_error(base):
if (
not local_branch_exists
and (explicit_notrack or (not explicit_notrack and not explicit_track))
and (
remote_branch_already_exists
or (
config_enabled
and config_has_default_remote_prefix
and remote_branch_with_prefix_already_exists
)
)
and remote_count >= 2
and remotes_differ
):
assert (
f"branch exists on multiple remotes, but they deviate"
in cmd.stderr.lower()
)
assert len(cmd.stderr.strip().split("\n")) == base + 1
else:
if base == 0:
assert len(cmd.stderr) == base
else:
assert len(cmd.stderr.strip().split("\n")) == base
if explicit_track and explicit_notrack:
assert "--track will be ignored" in cmd.stderr.lower()
check_deviation_error(1)
else:
check_deviation_error(0)
files = os.listdir(base_dir) files = os.listdir(base_dir)
if config_enabled is True: if has_config is True:
if worktree_with_slash:
assert set(files) == {".git-main-working-tree", "grm.toml", "dir"}
else:
assert set(files) == {".git-main-working-tree", "grm.toml", "test"}
assert len(files) == 3 assert len(files) == 3
if worktree_with_slash: assert set(files) == {".git-main-working-tree", "grm.toml", "test"}
assert set(files) == {".git-main-working-tree", "grm.toml", "dir"}
assert set(os.listdir(os.path.join(base_dir, "dir"))) == {"nested"}
assert set(os.listdir(os.path.join(base_dir, "dir/nested"))) == {"test"}
else:
assert set(files) == {".git-main-working-tree", "grm.toml", "test"}
else: else:
assert len(files) == 2 assert len(files) == 2
if worktree_with_slash: assert set(files) == {".git-main-working-tree", "test"}
assert set(files) == {".git-main-working-tree", "dir"}
assert set(os.listdir(os.path.join(base_dir, "dir"))) == {"nested"}
assert set(os.listdir(os.path.join(base_dir, "dir/nested"))) == {"test"}
else:
assert set(files) == {".git-main-working-tree", "test"}
repo = git.Repo(os.path.join(base_dir, worktree_name)) repo = git.Repo(os.path.join(base_dir, "test"))
assert not repo.bare assert not repo.bare
# assert not repo.is_dirty() assert not repo.is_dirty()
assert str(repo.head.ref) == worktree_name if has_config and has_default:
if has_prefix and not remote_branch_already_exists:
local_commit = repo.head.commit.hexsha
if not has_remotes:
assert local_commit == initial_commit
elif local_branch_exists:
assert local_commit == initial_commit
elif explicit_track and not explicit_notrack:
assert local_commit == repo.commit(explicit_track_branch_name).hexsha
elif explicit_notrack:
if config_enabled and config_has_default_remote_prefix:
if remote_branch_with_prefix_already_exists:
assert (
local_commit
== repo.commit(
f"{default_remote}/myprefix/{worktree_name}"
).hexsha
)
elif remote_branch_already_exists:
assert (
local_commit
== repo.commit(f"{default_remote}/{worktree_name}").hexsha
)
else:
assert local_commit == initial_commit
elif remote_count == 1:
if config_enabled and config_has_default_remote_prefix:
if remote_branch_with_prefix_already_exists:
assert (
local_commit
== repo.commit(
f"{default_remote}/myprefix/{worktree_name}"
).hexsha
)
elif remote_branch_already_exists:
assert (
local_commit
== repo.commit(f"{default_remote}/{worktree_name}").hexsha
)
else:
assert local_commit == initial_commit
elif remote_branch_already_exists:
assert (
local_commit
== repo.commit(f"{default_remote}/{worktree_name}").hexsha
)
else:
assert local_commit == initial_commit
elif remotes_differ:
if config_enabled: # we have a default remote
if (
config_has_default_remote_prefix
and remote_branch_with_prefix_already_exists
):
assert (
local_commit
== repo.commit(
f"{default_remote}/myprefix/{worktree_name}"
).hexsha
)
elif remote_branch_already_exists:
assert (
local_commit
== repo.commit(f"{default_remote}/{worktree_name}").hexsha
)
else:
assert local_commit == initial_commit
else:
assert local_commit == initial_commit
else:
if config_enabled and config_has_default_remote_prefix:
if remote_branch_with_prefix_already_exists:
assert (
local_commit
== repo.commit(
f"{default_remote}/myprefix/{worktree_name}"
).hexsha
)
elif remote_branch_already_exists:
assert (
local_commit
== repo.commit(f"{default_remote}/{worktree_name}").hexsha
)
else:
assert local_commit == initial_commit
elif config_enabled:
if not config_has_default_remote_prefix:
if config_has_default_track_enabled:
assert (
local_commit
== repo.commit(f"{default_remote}/{worktree_name}").hexsha
)
else:
if remote_branch_already_exists:
assert (
local_commit
== repo.commit(f"{default_remote}/{worktree_name}").hexsha
)
else:
assert local_commit == initial_commit
else:
if remote_branch_with_prefix_already_exists:
assert (
local_commit
== repo.commit(
f"{default_remote}/myprefix/{worktree_name}"
).hexsha
)
elif remote_branch_already_exists:
assert (
local_commit
== repo.commit(f"{default_remote}/{worktree_name}").hexsha
)
elif config_has_default_track_enabled:
assert (
local_commit
== repo.commit(
f"{default_remote}/myprefix/{worktree_name}"
).hexsha
)
else:
assert local_commit == initial_commit
elif remote_branch_already_exists and not remotes_differ:
assert (
local_commit == repo.commit(f"{default_remote}/{worktree_name}").hexsha
)
else:
assert local_commit == initial_commit
# Check whether tracking is ok
if not has_remotes:
assert repo.active_branch.tracking_branch() is None
elif explicit_notrack:
if local_branch_exists and local_branch_has_tracking_branch:
if track_differs_from_existing_branch_upstream:
assert (
str(repo.active_branch.tracking_branch())
== f"{default_remote}/someothername"
)
else:
assert (
str(repo.active_branch.tracking_branch())
== f"{default_remote}/{worktree_name}"
)
else:
assert repo.active_branch.tracking_branch() is None
elif explicit_track:
assert (
str(repo.active_branch.tracking_branch()) == explicit_track_branch_name
)
elif config_enabled and config_has_default_track_enabled:
if config_has_default_remote_prefix:
assert ( assert (
str(repo.active_branch.tracking_branch()) str(repo.active_branch.tracking_branch()) == "origin/myprefix/test"
== f"{default_remote}/myprefix/{worktree_name}"
) )
else: else:
assert ( assert str(repo.active_branch.tracking_branch()) == "origin/test"
str(repo.active_branch.tracking_branch())
== f"{default_remote}/{worktree_name}"
)
elif local_branch_exists and local_branch_has_tracking_branch:
if track_differs_from_existing_branch_upstream:
assert (
str(repo.active_branch.tracking_branch())
== f"{default_remote}/someothername"
)
else:
assert (
str(repo.active_branch.tracking_branch())
== f"{default_remote}/{worktree_name}"
)
else: else:
assert repo.active_branch.tracking_branch() is None assert repo.active_branch.tracking_branch() is None
def test_worktree_add_invalid_name(): def test_worktree_add_into_subdirectory():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
for worktree_name in [ cmd = grm(["wt", "add", "dir/test"], cwd=base_dir)
"/absolute/path", assert cmd.returncode == 0
"trailingslash/",
"with spaces",
"with\t tabs",
"with\nnewline",
]:
args = ["wt", "add", worktree_name]
cmd = grm(args, cwd=base_dir)
assert cmd.returncode != 0
assert not os.path.exists(worktree_name)
assert not os.path.exists(os.path.join(base_dir, worktree_name))
assert "invalid worktree name" in str(cmd.stderr.lower())
files = os.listdir(base_dir)
assert len(files) == 2
assert set(files) == {".git-main-working-tree", "dir"}
def test_worktree_add_invalid_track(): files = os.listdir(os.path.join(base_dir, "dir"))
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): assert set(files) == {"test"}
for track in ["/absolute/path", "trailingslash/", "/"]:
args = ["wt", "add", "foo", "--track", track]
cmd = grm(args, cwd=base_dir)
assert cmd.returncode != 0
assert len(cmd.stderr.strip().split("\n")) == 1
assert not os.path.exists("foo")
assert not os.path.exists(os.path.join(base_dir, "foo"))
assert "tracking branch" in str(cmd.stderr.lower())
repo = git.Repo(os.path.join(base_dir, "dir", "test"))
@pytest.mark.parametrize("use_track", [True, False]) assert not repo.bare
@pytest.mark.parametrize("use_configuration", [True, False]) assert not repo.is_dirty()
@pytest.mark.parametrize("use_configuration_default", [True, False]) assert repo.active_branch.tracking_branch() is None
def test_worktree_add_invalid_remote_name(
use_track, use_configuration, use_configuration_default
):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit):
if use_configuration:
with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write(
f"""
[track]
default = {str(use_configuration_default).lower()}
default_remote = "thisremotedoesnotexist"
"""
)
args = ["wt", "add", "foo"]
if use_track:
args.extend(["--track", "thisremotedoesnotexist/master"])
cmd = grm(args, cwd=base_dir)
if use_track or (use_configuration and use_configuration_default):
assert cmd.returncode != 0
assert "thisremotedoesnotexist" in cmd.stderr
else:
assert cmd.returncode == 0
assert len(cmd.stderr) == 0
def test_worktree_add_into_invalid_subdirectory(): def test_worktree_add_into_invalid_subdirectory():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "/dir/test"], cwd=base_dir) cmd = grm(["wt", "add", "/dir/test"], cwd=base_dir)
assert cmd.returncode == 1 assert cmd.returncode == 1
assert "dir" not in os.listdir(base_dir) assert "dir" not in os.listdir(base_dir)
@@ -573,8 +104,177 @@ def test_worktree_add_into_invalid_subdirectory():
assert "dir" not in os.listdir(base_dir) assert "dir" not in os.listdir(base_dir)
@pytest.mark.parametrize("remote_branch_already_exists", [True, False])
@pytest.mark.parametrize("has_config", [True, False])
@pytest.mark.parametrize("has_default", [True, False])
@pytest.mark.parametrize("has_prefix", [True, False])
def test_worktree_add_with_tracking(
remote_branch_already_exists, has_config, has_default, has_prefix
):
with TempGitRepositoryWorktree() as (base_dir, _commit):
if has_config:
with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write(
f"""
[track]
default = {str(has_default).lower()}
default_remote = "origin"
"""
)
if has_prefix:
f.write(
"""
default_remote_prefix = "myprefix"
"""
)
if remote_branch_already_exists:
shell(
f"""
cd {base_dir}
git --git-dir ./.git-main-working-tree worktree add tmp
(
cd tmp
touch change
git add change
git commit -m commit
git push origin HEAD:test
#git reset --hard 'HEAD@{1}'
git branch -va
)
git --git-dir ./.git-main-working-tree worktree remove tmp
"""
)
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
print(cmd.stderr)
assert cmd.returncode == 0
files = os.listdir(base_dir)
if has_config is True:
assert len(files) == 3
assert set(files) == {".git-main-working-tree", "grm.toml", "test"}
else:
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"
@pytest.mark.parametrize("has_config", [True, False])
@pytest.mark.parametrize("has_default", [True, False])
@pytest.mark.parametrize("has_prefix", [True, False])
@pytest.mark.parametrize("track", [True, False])
def test_worktree_add_with_explicit_no_tracking(
has_config, has_default, has_prefix, track
):
with TempGitRepositoryWorktree() as (base_dir, _commit):
if has_config:
with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write(
f"""
[track]
default = {str(has_default).lower()}
default_remote = "origin"
"""
)
if has_prefix:
f.write(
"""
default_remote_prefix = "myprefix"
"""
)
if track is True:
cmd = grm(
["wt", "add", "test", "--track", "origin/test", "--no-track"],
cwd=base_dir,
)
else:
cmd = grm(["wt", "add", "test", "--no-track"], cwd=base_dir)
print(cmd.stderr)
assert cmd.returncode == 0
files = os.listdir(base_dir)
if has_config is True:
assert len(files) == 3
assert set(files) == {".git-main-working-tree", "grm.toml", "test"}
else:
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
@pytest.mark.parametrize("remote_branch_already_exists", [True, False])
@pytest.mark.parametrize("has_default", [True, False])
@pytest.mark.parametrize("has_prefix", [True, False])
def test_worktree_add_with_config(
remote_branch_already_exists, has_default, has_prefix
):
with TempGitRepositoryWorktree() as (base_dir, _commit):
with open(os.path.join(base_dir, "grm.toml"), "w") as f:
f.write(
f"""
[track]
default = {str(has_default).lower()}
default_remote = "origin"
"""
)
if has_prefix:
f.write(
"""
default_remote_prefix = "myprefix"
"""
)
if remote_branch_already_exists:
shell(
f"""
cd {base_dir}
git --git-dir ./.git-main-working-tree worktree add tmp
(
cd tmp
touch change
git add change
git commit -m commit
git push origin HEAD:test
#git reset --hard 'HEAD@{1}'
git branch -va
)
git --git-dir ./.git-main-working-tree worktree remove tmp
"""
)
cmd = grm(["wt", "add", "test"], cwd=base_dir)
print(cmd.stderr)
assert cmd.returncode == 0
files = os.listdir(base_dir)
assert len(files) == 3
assert set(files) == {".git-main-working-tree", "grm.toml", "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"
if has_default:
if has_prefix and not remote_branch_already_exists:
assert (
str(repo.active_branch.tracking_branch()) == "origin/myprefix/test"
)
else:
assert str(repo.active_branch.tracking_branch()) == "origin/test"
else:
assert repo.active_branch.tracking_branch() is None
def test_worktree_delete(): def test_worktree_delete():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
assert "test" in os.listdir(base_dir) assert "test" in os.listdir(base_dir)
@@ -586,35 +286,12 @@ def test_worktree_delete():
cmd = grm(["wt", "add", "check"], cwd=base_dir) cmd = grm(["wt", "add", "check"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
repo = git.Repo(os.path.join(base_dir, ".git-main-working-tree")) 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] assert "test" not in [str(b) for b in repo.branches]
@pytest.mark.parametrize("has_other_worktree", [True, False])
def test_worktree_delete_in_subfolder(has_other_worktree):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit):
cmd = grm(["wt", "add", "dir/test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
assert "dir" in os.listdir(base_dir)
if has_other_worktree is True:
cmd = grm(
["wt", "add", "dir/test2", "--track", "origin/test"], cwd=base_dir
)
assert cmd.returncode == 0
assert {"test", "test2"} == set(os.listdir(os.path.join(base_dir, "dir")))
else:
assert {"test"} == set(os.listdir(os.path.join(base_dir, "dir")))
cmd = grm(["wt", "delete", "dir/test"], cwd=base_dir)
assert cmd.returncode == 0
if has_other_worktree is True:
assert {"test2"} == set(os.listdir(os.path.join(base_dir, "dir")))
else:
assert "dir" not in os.listdir(base_dir)
def test_worktree_delete_refusal_no_tracking_branch(): def test_worktree_delete_refusal_no_tracking_branch():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test"], cwd=base_dir) cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -630,7 +307,7 @@ def test_worktree_delete_refusal_no_tracking_branch():
def test_worktree_delete_refusal_uncommited_changes_new_file(): def test_worktree_delete_refusal_uncommited_changes_new_file():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -648,7 +325,7 @@ def test_worktree_delete_refusal_uncommited_changes_new_file():
def test_worktree_delete_refusal_uncommited_changes_changed_file(): def test_worktree_delete_refusal_uncommited_changes_changed_file():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -666,7 +343,7 @@ def test_worktree_delete_refusal_uncommited_changes_changed_file():
def test_worktree_delete_refusal_uncommited_changes_deleted_file(): def test_worktree_delete_refusal_uncommited_changes_deleted_file():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -686,7 +363,7 @@ def test_worktree_delete_refusal_uncommited_changes_deleted_file():
def test_worktree_delete_refusal_commited_changes(): def test_worktree_delete_refusal_commited_changes():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -706,7 +383,7 @@ def test_worktree_delete_refusal_commited_changes():
def test_worktree_delete_refusal_tracking_branch_mismatch(): def test_worktree_delete_refusal_tracking_branch_mismatch():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -726,7 +403,7 @@ def test_worktree_delete_refusal_tracking_branch_mismatch():
def test_worktree_delete_force_refusal(): def test_worktree_delete_force_refusal():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test"], cwd=base_dir) cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
@@ -736,7 +413,7 @@ def test_worktree_delete_force_refusal():
def test_worktree_add_delete_add(): def test_worktree_add_delete_add():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit): with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir) cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0 assert cmd.returncode == 0
assert "test" in os.listdir(base_dir) assert "test" in os.listdir(base_dir)

View File

@@ -1,164 +0,0 @@
#!/usr/bin/env bash
set -o nounset
set -o errexit
set -o pipefail
usage() {
printf '%s\n' "usage: $0 (master|minor|patch)" >&2
}
if (($# != 1)); then
usage
exit 1
fi
current_version="$(grep '^version \?=' Cargo.toml | head -1 | cut -d '=' -f 2 | tr -d " '"'"')"
major="$(printf '%s' "${current_version}" | grep -oP '^\d+')"
minor="$(printf '%s' "${current_version}" | grep -oP '\.\d+\.' | tr -d '.')"
patch="$(printf '%s' "${current_version}" | grep -oP '\d+$' | tr -d '.')"
case "$1" in
major)
((major++)) || true
minor=0
patch=0
;;
minor)
((minor++)) || true
patch=0
;;
patch)
((patch++)) || true
;;
*)
usage
exit 1
;;
esac
new_version="${major}.${minor}.${patch}"
if ! [[ "${new_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
printf '%s\n' 'Version has to a complete semver' >&2
exit 1
fi
current_branch="$(git rev-parse --abbrev-ref HEAD)"
if [[ "${current_branch}" != "develop" ]]; then
printf '%s\n' 'You need to be on develop' >&2
exit 1
fi
gitstatus="$(git status --porcelain)"
if [[ -n "${gitstatus}" ]]; then
printf '%s\n' 'There are uncommitted changes' >&2
exit 1
fi
if git tag --list "v${new_version}" | grep -q .; then
printf 'Tag %s already exists\n' "v${new_version}" >&2
exit 1
fi
for remote in $(git remote); do
if git ls-remote --tags "${remote}" | grep -q "refs/tags/v${new_version}$"; then
printf 'Tag %s already exists on %s' "v${new_version}" "${remote}" >&2
exit 1
fi
done
git fetch --all
for remote in $(git remote); do
for branch in master develop; do
if ! git diff --quiet "${remote}/${branch}..${branch}"; then
printf 'Remote branch %s/%s not up to date, synchronize first!\n' "${remote}" "${branch}" >&2
exit 1
fi
done
done
if ! git merge-base --is-ancestor master develop; then
printf '%s\n' 'Develop is not a straight descendant of master, rebase!' >&2
exit 1
fi
changes="$(git log --oneline master..develop | wc -l)"
if ((changes == 0)); then
printf '%s\n' 'No changes between master and develop?' >&2
exit 1
fi
just update-dependencies
just check
sed -i "0,/^version/{s/^version.*$/version = \"${new_version}\"/}" Cargo.toml
cargo update --package git-repo-manager --precise "${new_version}"
diff="$(git diff --numstat)"
if (($(printf '%s\n' "${diff}" | wc -l || true) != 2)); then
printf '%s\n' 'Weird changes detected, bailing' >&2
exit 1
fi
if ! printf '%s\n' "${diff}" | grep -Pq '^1\s+1\s+Cargo.lock$'; then
printf '%s\n' 'Weird changes detected, bailing' >&2
exit 1
fi
if ! printf '%s\n' "${diff}" | grep -Pq '^1\s+1\s+Cargo.toml$'; then
printf '%s\n' 'Weird changes detected, bailing' >&2
exit 1
fi
git add Cargo.lock Cargo.toml
git commit -m "Release v${new_version}"
git switch master 2>/dev/null || { [[ -d "../master" ]] && cd "../master"; } || {
printf '%s\n' 'Could not change to master' >&2
exit 1
}
current_branch="$(git rev-parse --abbrev-ref HEAD)"
if [[ "${current_branch}" != "master" ]]; then
printf '%s\n' 'Looks like branch switching to master did not work' >&2
exit 1
fi
git merge --no-ff --no-edit develop
git tag "v${new_version}"
for remote in $(git remote); do
while ! git push "${remote}" "v${new_version}" master; do
:
done
done
git switch develop 2>/dev/null || { [[ -d "../develop" ]] && cd "../develop"; } || {
printf '%s\n' 'Could not change to develop' >&2
exit 1
}
current_branch="$(git rev-parse --abbrev-ref HEAD)"
if [[ "${current_branch}" != "develop" ]]; then
printf '%s\n' 'Looks like branch switching to develop did not work' >&2
exit 1
fi
git merge --ff-only master
for remote in $(git remote); do
while ! git push "${remote}" develop; do
:
done
done
cargo publish
printf 'Published %s successfully\n' "${new_version}"
exit 0

View File

@@ -53,8 +53,6 @@ pub struct ConfigProvider {
pub worktree: Option<bool>, pub worktree: Option<bool>,
pub init_worktree: Option<bool>, pub init_worktree: Option<bool>,
pub remote_name: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -194,7 +192,6 @@ impl Config {
.get_repos( .get_repos(
config.worktree.unwrap_or(false), config.worktree.unwrap_or(false),
config.force_ssh.unwrap_or(false), config.force_ssh.unwrap_or(false),
config.remote_name,
)? )?
} }
RemoteProvider::Gitlab => { RemoteProvider::Gitlab => {
@@ -208,7 +205,6 @@ impl Config {
.get_repos( .get_repos(
config.worktree.unwrap_or(false), config.worktree.unwrap_or(false),
config.force_ssh.unwrap_or(false), config.force_ssh.unwrap_or(false),
config.remote_name,
)? )?
} }
}; };

View File

@@ -103,9 +103,6 @@ pub struct FindRemoteArgs {
#[clap(arg_enum, short, long, help = "Remote provider to use")] #[clap(arg_enum, short, long, help = "Remote provider to use")]
pub provider: RemoteProvider, pub provider: RemoteProvider,
#[clap(short, long, help = "Name of the remote to use")]
pub remote_name: Option<String>,
#[clap( #[clap(
multiple_occurrences = true, multiple_occurrences = true,
name = "user", name = "user",
@@ -192,9 +189,6 @@ pub struct SyncRemoteArgs {
#[clap(arg_enum, short, long, help = "Remote provider to use")] #[clap(arg_enum, short, long, help = "Remote provider to use")]
pub provider: RemoteProvider, pub provider: RemoteProvider,
#[clap(short, long, help = "Name of the remote to use")]
pub remote_name: Option<String>,
#[clap( #[clap(
multiple_occurrences = true, multiple_occurrences = true,
name = "user", name = "user",

View File

@@ -1,5 +1,3 @@
#![forbid(unsafe_code)]
use std::path::Path; use std::path::Path;
use std::process; use std::process;
@@ -66,11 +64,7 @@ fn main() {
process::exit(1); process::exit(1);
} }
} }
.get_repos( .get_repos(worktree, args.force_ssh)
worktree,
args.force_ssh,
args.remote_name,
)
} }
cmd::RemoteProvider::Gitlab => { cmd::RemoteProvider::Gitlab => {
match provider::Gitlab::new(filter, token, args.api_url) { match provider::Gitlab::new(filter, token, args.api_url) {
@@ -80,11 +74,7 @@ fn main() {
process::exit(1); process::exit(1);
} }
} }
.get_repos( .get_repos(worktree, args.force_ssh)
worktree,
args.force_ssh,
args.remote_name,
)
} }
}; };
@@ -290,7 +280,6 @@ fn main() {
.get_repos( .get_repos(
config.worktree.unwrap_or(false), config.worktree.unwrap_or(false),
config.force_ssh.unwrap_or(false), config.force_ssh.unwrap_or(false),
config.remote_name,
) { ) {
Ok(provider) => provider, Ok(provider) => provider,
Err(error) => { Err(error) => {
@@ -310,7 +299,6 @@ fn main() {
.get_repos( .get_repos(
config.worktree.unwrap_or(false), config.worktree.unwrap_or(false),
config.force_ssh.unwrap_or(false), config.force_ssh.unwrap_or(false),
config.remote_name,
) { ) {
Ok(provider) => provider, Ok(provider) => provider,
Err(error) => { Err(error) => {
@@ -394,11 +382,7 @@ fn main() {
process::exit(1); process::exit(1);
} }
} }
.get_repos( .get_repos(worktree, args.force_ssh)
worktree,
args.force_ssh,
args.remote_name,
)
} }
cmd::RemoteProvider::Gitlab => { cmd::RemoteProvider::Gitlab => {
match provider::Gitlab::new(filter, token, args.api_url) { match provider::Gitlab::new(filter, token, args.api_url) {
@@ -408,11 +392,7 @@ fn main() {
process::exit(1); process::exit(1);
} }
} }
.get_repos( .get_repos(worktree, args.force_ssh)
worktree,
args.force_ssh,
args.remote_name,
)
} }
}; };
@@ -483,9 +463,6 @@ fn main() {
match args.action { match args.action {
cmd::WorktreeAction::Add(action_args) => { cmd::WorktreeAction::Add(action_args) => {
if action_args.track.is_some() && action_args.no_track {
print_warning("You are using --track and --no-track at the same time. --track will be ignored");
}
let track = match &action_args.track { let track = match &action_args.track {
Some(branch) => { Some(branch) => {
let split = branch.split_once('/'); let split = branch.split_once('/');
@@ -507,20 +484,29 @@ fn main() {
None => None, None => None,
}; };
let mut name: &str = &action_args.name;
let subdirectory;
let split = name.split_once('/');
match split {
None => subdirectory = None,
Some(split) => {
if split.0.is_empty() || split.1.is_empty() {
print_error("Worktree name cannot start or end with a slash");
process::exit(1);
} else {
(subdirectory, name) = (Some(Path::new(split.0)), split.1);
}
}
}
match worktree::add_worktree( match worktree::add_worktree(
&cwd, &cwd,
&action_args.name, name,
subdirectory,
track, track,
action_args.no_track, action_args.no_track,
) { ) {
Ok(warnings) => { Ok(_) => print_success(&format!("Worktree {} created", &action_args.name)),
if let Some(warnings) = warnings {
for warning in warnings {
print_warning(&warning);
}
}
print_success(&format!("Worktree {} created", &action_args.name));
}
Err(error) => { Err(error) => {
print_error(&format!("Error creating worktree: {}", error)); print_error(&format!("Error creating worktree: {}", error));
process::exit(1); process::exit(1);
@@ -528,6 +514,8 @@ fn main() {
} }
} }
cmd::WorktreeAction::Delete(action_args) => { cmd::WorktreeAction::Delete(action_args) => {
let worktree_dir = cwd.join(&action_args.name);
let worktree_config = match repo::read_worktree_root_config(&cwd) { let worktree_config = match repo::read_worktree_root_config(&cwd) {
Ok(config) => config, Ok(config) => config,
Err(error) => { Err(error) => {
@@ -545,9 +533,8 @@ fn main() {
}); });
match repo.remove_worktree( match repo.remove_worktree(
&cwd,
&action_args.name, &action_args.name,
Path::new(&action_args.name), &worktree_dir,
action_args.force, action_args.force,
&worktree_config, &worktree_config,
) { ) {

View File

@@ -1,6 +1,5 @@
#![feature(io_error_more)] #![feature(io_error_more)]
#![feature(const_option_ext)] #![feature(const_option_ext)]
#![forbid(unsafe_code)]
use std::path::Path; use std::path::Path;
@@ -14,6 +13,8 @@ pub mod table;
pub mod tree; pub mod tree;
pub mod worktree; pub mod worktree;
const BRANCH_NAMESPACE_SEPARATOR: &str = "/";
/// Find all git repositories under root, recursively /// Find all git repositories under root, recursively
/// ///
/// The bool in the return value specifies whether there is a repository /// The bool in the return value specifies whether there is a repository

View File

@@ -8,6 +8,7 @@ use super::JsonError;
use super::Project; use super::Project;
use super::Provider; use super::Provider;
const PROVIDER_NAME: &str = "github";
const ACCEPT_HEADER_JSON: &str = "application/vnd.github.v3+json"; const ACCEPT_HEADER_JSON: &str = "application/vnd.github.v3+json";
const GITHUB_API_BASEURL: &str = const GITHUB_API_BASEURL: &str =
option_env!("GITHUB_API_BASEURL").unwrap_or("https://api.github.com"); option_env!("GITHUB_API_BASEURL").unwrap_or("https://api.github.com");
@@ -87,6 +88,10 @@ impl Provider for Github {
}) })
} }
fn name(&self) -> &str {
PROVIDER_NAME
}
fn filter(&self) -> &Filter { fn filter(&self) -> &Filter {
&self.filter &self.filter
} }
@@ -131,8 +136,8 @@ impl Provider for Github {
fn get_current_user(&self) -> Result<String, ApiErrorResponse<GithubApiErrorResponse>> { fn get_current_user(&self) -> Result<String, ApiErrorResponse<GithubApiErrorResponse>> {
Ok(super::call::<GithubUser, GithubApiErrorResponse>( Ok(super::call::<GithubUser, GithubApiErrorResponse>(
&format!("{GITHUB_API_BASEURL}/user"), &format!("{GITHUB_API_BASEURL}/user"),
Self::auth_header_key(), &Self::auth_header_key(),
self.secret_token(), &self.secret_token(),
Some(ACCEPT_HEADER_JSON), Some(ACCEPT_HEADER_JSON),
)? )?
.username) .username)

View File

@@ -8,6 +8,7 @@ use super::JsonError;
use super::Project; use super::Project;
use super::Provider; use super::Provider;
const PROVIDER_NAME: &str = "gitlab";
const ACCEPT_HEADER_JSON: &str = "application/json"; const ACCEPT_HEADER_JSON: &str = "application/json";
const GITLAB_API_BASEURL: &str = option_env!("GITLAB_API_BASEURL").unwrap_or("https://gitlab.com"); const GITLAB_API_BASEURL: &str = option_env!("GITLAB_API_BASEURL").unwrap_or("https://gitlab.com");
@@ -104,6 +105,10 @@ impl Provider for Gitlab {
}) })
} }
fn name(&self) -> &str {
PROVIDER_NAME
}
fn filter(&self) -> &Filter { fn filter(&self) -> &Filter {
&self.filter &self.filter
} }
@@ -152,8 +157,8 @@ impl Provider for Gitlab {
fn get_current_user(&self) -> Result<String, ApiErrorResponse<GitlabApiErrorResponse>> { fn get_current_user(&self) -> Result<String, ApiErrorResponse<GitlabApiErrorResponse>> {
Ok(super::call::<GitlabUser, GitlabApiErrorResponse>( Ok(super::call::<GitlabUser, GitlabApiErrorResponse>(
&format!("{}/api/v4/user", self.api_url()), &format!("{}/api/v4/user", self.api_url()),
Self::auth_header_key(), &Self::auth_header_key(),
self.secret_token(), &self.secret_token(),
Some(ACCEPT_HEADER_JSON), Some(ACCEPT_HEADER_JSON),
)? )?
.username) .username)

View File

@@ -9,13 +9,11 @@ pub mod gitlab;
pub use github::Github; pub use github::Github;
pub use gitlab::Gitlab; pub use gitlab::Gitlab;
use super::auth;
use super::repo; use super::repo;
use super::auth;
use std::collections::HashMap; use std::collections::HashMap;
const DEFAULT_REMOTE_NAME: &str = "origin";
#[derive(Debug, Deserialize, Serialize, clap::ArgEnum, Clone)] #[derive(Debug, Deserialize, Serialize, clap::ArgEnum, Clone)]
pub enum RemoteProvider { pub enum RemoteProvider {
#[serde(alias = "github", alias = "GitHub")] #[serde(alias = "github", alias = "GitHub")]
@@ -124,6 +122,7 @@ pub trait Provider {
where where
Self: Sized; Self: Sized;
fn name(&self) -> &str;
fn filter(&self) -> &Filter; fn filter(&self) -> &Filter;
fn secret_token(&self) -> &auth::AuthToken; fn secret_token(&self) -> &auth::AuthToken;
fn auth_header_key() -> &'static str; fn auth_header_key() -> &'static str;
@@ -167,11 +166,7 @@ pub trait Provider {
.header("accept", accept_header.unwrap_or("application/json")) .header("accept", accept_header.unwrap_or("application/json"))
.header( .header(
"authorization", "authorization",
format!( format!("{} {}", Self::auth_header_key(), &self.secret_token().access()),
"{} {}",
Self::auth_header_key(),
&self.secret_token().access()
),
) )
.body(()) .body(())
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@@ -214,7 +209,6 @@ pub trait Provider {
&self, &self,
worktree_setup: bool, worktree_setup: bool,
force_ssh: bool, force_ssh: bool,
remote_name: Option<String>,
) -> Result<HashMap<Option<String>, Vec<repo::Repo>>, String> { ) -> Result<HashMap<Option<String>, Vec<repo::Repo>>, String> {
let mut repos = vec![]; let mut repos = vec![];
@@ -294,12 +288,10 @@ pub trait Provider {
let mut ret: HashMap<Option<String>, Vec<repo::Repo>> = HashMap::new(); let mut ret: HashMap<Option<String>, Vec<repo::Repo>> = HashMap::new();
let remote_name = remote_name.unwrap_or_else(|| DEFAULT_REMOTE_NAME.to_string());
for repo in repos { for repo in repos {
let namespace = repo.namespace(); let namespace = repo.namespace();
let mut repo = repo.into_repo_config(&remote_name, worktree_setup, force_ssh); let mut repo = repo.into_repo_config(&self.name(), worktree_setup, force_ssh);
// Namespace is already part of the hashmap key. I'm not too happy // Namespace is already part of the hashmap key. I'm not too happy
// about the data exchange format here. // about the data exchange format here.

View File

@@ -1056,12 +1056,12 @@ impl RepoHandle {
// Note that <remote>/HEAD only exists after a normal clone, there is no way to get the // Note that <remote>/HEAD only exists after a normal clone, there is no way to get the
// remote HEAD afterwards. So this is a "best effort" approach. // remote HEAD afterwards. So this is a "best effort" approach.
if let Ok(remote_head) = self.find_remote_branch(remote_name, "HEAD") { if let Ok(remote_head) = self.find_remote_branch(&remote_name, "HEAD") {
if let Some(pointer_name) = remote_head.as_reference().symbolic_target() { if let Some(pointer_name) = remote_head.as_reference().symbolic_target() {
if let Some(local_branch_name) = if let Some(local_branch_name) =
pointer_name.strip_prefix(&format!("refs/remotes/{}/", remote_name)) pointer_name.strip_prefix(&format!("refs/remotes/{}/", remote_name))
{ {
return Ok(Some(self.find_local_branch(local_branch_name)?)); return Ok(Some(self.find_local_branch(&local_branch_name)?));
} else { } else {
eprintln!("Remote HEAD ({}) pointer is invalid", pointer_name); eprintln!("Remote HEAD ({}) pointer is invalid", pointer_name);
} }
@@ -1088,7 +1088,7 @@ impl RepoHandle {
if remotes.len() == 1 { if remotes.len() == 1 {
let remote_name = &remotes[0]; let remote_name = &remotes[0];
if let Some(default_branch) = self.get_remote_default_branch(remote_name)? { if let Some(default_branch) = self.get_remote_default_branch(&remote_name)? {
return Ok(default_branch); return Ok(default_branch);
} }
} else { } else {
@@ -1099,13 +1099,17 @@ impl RepoHandle {
} }
} }
if !default_branches.is_empty() if !default_branches.is_empty() {
&& (default_branches.len() == 1 if default_branches.len() == 1 {
|| default_branches return Ok(default_branches.remove(0));
} else {
if default_branches
.windows(2) .windows(2)
.all(|w| w[0].name() == w[1].name())) .all(|w| w[0].name() == w[1].name())
{ {
return Ok(default_branches.remove(0)); return Ok(default_branches.remove(0));
}
}
} }
} }
@@ -1153,21 +1157,18 @@ impl RepoHandle {
pub fn remove_worktree( pub fn remove_worktree(
&self, &self,
base_dir: &Path,
name: &str, name: &str,
worktree_dir: &Path, worktree_dir: &Path,
force: bool, force: bool,
worktree_config: &Option<WorktreeRootConfig>, worktree_config: &Option<WorktreeRootConfig>,
) -> Result<(), WorktreeRemoveFailureReason> { ) -> Result<(), WorktreeRemoveFailureReason> {
let fullpath = base_dir.join(worktree_dir); if !worktree_dir.exists() {
if !fullpath.exists() {
return Err(WorktreeRemoveFailureReason::Error(format!( return Err(WorktreeRemoveFailureReason::Error(format!(
"{} does not exist", "{} does not exist",
name name
))); )));
} }
let worktree_repo = RepoHandle::open(&fullpath, false).map_err(|error| { let worktree_repo = RepoHandle::open(worktree_dir, false).map_err(|error| {
WorktreeRemoveFailureReason::Error(format!("Error opening repo: {}", error)) WorktreeRemoveFailureReason::Error(format!("Error opening repo: {}", error))
})?; })?;
@@ -1179,11 +1180,12 @@ impl RepoHandle {
WorktreeRemoveFailureReason::Error(format!("Failed getting name of branch: {}", error)) WorktreeRemoveFailureReason::Error(format!("Failed getting name of branch: {}", error))
})?; })?;
if branch_name != name { if branch_name != name
&& !branch_name.ends_with(&format!("{}{}", super::BRANCH_NAMESPACE_SEPARATOR, name))
{
return Err(WorktreeRemoveFailureReason::Error(format!( return Err(WorktreeRemoveFailureReason::Error(format!(
"Branch \"{}\" is checked out in worktree \"{}\", this does not look correct", "Branch {} is checked out in worktree, this does not look correct",
&branch_name, &branch_name
&worktree_dir.display(),
))); )));
} }
@@ -1253,47 +1255,13 @@ impl RepoHandle {
} }
} }
// worktree_dir is a relative path, starting from base_dir. We walk it if let Err(e) = std::fs::remove_dir_all(&worktree_dir) {
// upwards (from subdirectory to parent directories) and remove each
// component, in case it is empty. Only the leaf directory can be
// removed unconditionally (as it contains the worktree itself).
if let Err(e) = std::fs::remove_dir_all(&fullpath) {
return Err(WorktreeRemoveFailureReason::Error(format!( return Err(WorktreeRemoveFailureReason::Error(format!(
"Error deleting {}: {}", "Error deleting {}: {}",
&worktree_dir.display(), &worktree_dir.display(),
e e
))); )));
} }
if let Some(current_dir) = worktree_dir.parent() {
for current_dir in current_dir.ancestors() {
let current_dir = base_dir.join(current_dir);
println!("deleting {}", current_dir.display());
if current_dir
.read_dir()
.map_err(|error| {
WorktreeRemoveFailureReason::Error(format!(
"Error reading {}: {}",
&current_dir.display(),
error
))
})?
.next()
.is_none()
{
if let Err(e) = std::fs::remove_dir_all(&current_dir) {
return Err(WorktreeRemoveFailureReason::Error(format!(
"Error deleting {}: {}",
&worktree_dir.display(),
e
)));
}
} else {
break;
}
}
}
self.prune_worktree(name) self.prune_worktree(name)
.map_err(WorktreeRemoveFailureReason::Error)?; .map_err(WorktreeRemoveFailureReason::Error)?;
branch branch
@@ -1346,13 +1314,7 @@ impl RepoHandle {
{ {
let repo_dir = &directory.join(&worktree.name()); let repo_dir = &directory.join(&worktree.name());
if repo_dir.exists() { if repo_dir.exists() {
match self.remove_worktree( match self.remove_worktree(worktree.name(), repo_dir, false, &config) {
directory,
worktree.name(),
Path::new(worktree.name()),
false,
&config,
) {
Ok(_) => print_success(&format!("Worktree {} deleted", &worktree.name())), Ok(_) => print_success(&format!("Worktree {} deleted", &worktree.name())),
Err(error) => match error { Err(error) => match error {
WorktreeRemoveFailureReason::Changes(changes) => { WorktreeRemoveFailureReason::Changes(changes) => {
@@ -1477,7 +1439,7 @@ impl<'a> Branch<'a> {
} }
} }
impl<'a> Branch<'a> { impl Branch<'_> {
pub fn commit(&self) -> Result<Commit, String> { pub fn commit(&self) -> Result<Commit, String> {
Ok(Commit( Ok(Commit(
self.0 self.0
@@ -1487,15 +1449,6 @@ impl<'a> Branch<'a> {
)) ))
} }
pub fn commit_owned(self) -> Result<Commit<'a>, String> {
Ok(Commit(
self.0
.into_reference()
.peel_to_commit()
.map_err(convert_libgit2_error)?,
))
}
pub fn set_upstream(&mut self, remote_name: &str, branch_name: &str) -> Result<(), String> { pub fn set_upstream(&mut self, remote_name: &str, branch_name: &str) -> Result<(), String> {
self.0 self.0
.set_upstream(Some(&format!("{}/{}", remote_name, branch_name))) .set_upstream(Some(&format!("{}/{}", remote_name, branch_name)))

View File

@@ -222,7 +222,7 @@ fn sync_repo(root_path: &Path, repo: &repo::Repo, init_worktree: bool) -> Result
if newly_created && repo.worktree_setup && init_worktree { if newly_created && repo.worktree_setup && init_worktree {
match repo_handle.default_branch() { match repo_handle.default_branch() {
Ok(branch) => { Ok(branch) => {
worktree::add_worktree(&repo_path, &branch.name()?, None, false)?; worktree::add_worktree(&repo_path, &branch.name()?, None, None, false)?;
} }
Err(_error) => print_repo_error( Err(_error) => print_repo_error(
&repo.name, &repo.name,

View File

@@ -1,584 +1,16 @@
//! This handles worktrees for repositories. Some considerations to take care
//! of:
//!
//! * Which branch to check out / create
//! * Which commit to check out
//! * Whether to track a remote branch, and which
//!
//! There are a general rules. The main goal is to do the least surprising thing
//! in each situation, and to never change existing setups (e.g. tracking,
//! branch states) except when explicitly told to. In 99% of all cases, the
//! workflow will be quite straightforward.
//!
//! * The name of the worktree (and therefore the path) is **always** the same
//! as the name of the branch.
//! * Never modify existing local branches
//! * Only modify tracking branches for existing local branches if explicitly
//! requested
//! * By default, do not do remote operations. This means that we do no do any
//! tracking setup (but of course, the local branch can already have a
//! tracking branch set up, which will just be left alone)
//! * Be quite lax with finding a remote tracking branch (as using an existing
//! branch is most likely preferred to creating a new branch)
//!
//! There are a few different options that can be given:
//!
//! * Explicit track (`--track`) and explicit no-track (`--no-track`)
//! * A configuration may specify to enable tracking a remote branch by default
//! * A configuration may specify a prefix for remote branches
//!
//! # How to handle the local branch?
//!
//! That one is easy: If a branch with the desired name already exists, all is
//! well. If not, we create a new one.
//!
//! # Which commit should be checked out?
//!
//! The most imporant rule: If the local branch already existed, just leave it
//! as it is. Only if a new branch is created do we need to answer the question
//! which commit to set it to. Generally, we set the branch to whatever the
//! "default" branch of the repository is (something like "main" or "master").
//! But there are a few cases where we can use remote branches to make the
//! result less surprising.
//!
//! First, if tracking is explicitly disabled, we still try to guess! But we
//! *do* ignore `--track`, as this is how it's done everywhere else.
//!
//! As an example: If `origin/foobar` exists and we run `grm worktree add foobar
//! --no-track`, we create a new worktree called `foobar` that's on the same
//! state as `origin/foobar` (but we will not set up tracking, see below).
//!
//! If tracking is explicitly requested to a certain state, we use that remote
//! branch. If it exists, easy. If not, no more guessing!
//!
//! Now, it's important to select the correct remote. In the easiest case, there
//! is only one remote, so we just use that one. If there is more than one
//! remote, we check whether there is a default remote configured via
//! `track.default_remote`. If yes, we use that one. If not, we have to do the
//! selection process below *for each of them*. If only one of them returns
//! some branch to track, we use that one. If more than one remote returns
//! information, we only use it if it's identical for each. Otherwise we bail,
//! as there is no point in guessing.
//!
//! The commit selection process looks like this:
//!
//! * If a prefix is specified in the configuration, we look for
//! `{remote}/{prefix}/{worktree_name}`
//!
//! * We look for `{remote}/{worktree_name}` (yes, this means that even when a
//! prefix is configured, we use a branch *without* a prefix if one with
//! prefix does not exist)
//!
//! Note that we may select different branches for different remotes when
//! prefixes is used. If remote1 has a branch with a prefix and remote2 only has
//! a branch *without* a prefix, we select them both when a prefix is used. This
//! could lead to the following situation:
//!
//! * There is `origin/prefix/foobar` and `remote2/foobar`, with different
//! states
//! * You set `track.default_prefix = "prefix"` (and no default remote!)
//! * You run `grm worktree add `prefix/foobar`
//! * Instead of just picking `origin/prefix/foobar`, grm will complain because
//! it also selected `remote2/foobar`.
//!
//! This is just emergent behaviour of the logic above. Fixing it would require
//! additional logic for that edge case. I assume that it's just so rare to get
//! that behaviour that it's acceptable for now.
//!
//! Now we either have a commit, we aborted, or we do not have commit. In the
//! last case, as stated above, we check out the "default" branch.
//!
//! # The remote tracking branch
//!
//! First, the only remote operations we do is branch creation! It's
//! unfortunately not possible to defer remote branch creation until the first
//! `git push`, which would be ideal. The remote tracking branch has to already
//! exist, so we have to do the equivalent of `git push --set-upstream` during
//! worktree creation.
//!
//! Whether (and which) remote branch to track works like this:
//!
//! * If `--no-track` is given, we never track a remote branch, except when
//! branch already has a tracking branch. So we'd be done already!
//!
//! * If `--track` is given, we always track this branch, regardless of anything
//! else. If the branch exists, cool, otherwise we create it.
//!
//! If neither is given, we only set up tracking if requested in the
//! configuration file (`track.default = true`)
//!
//! The rest of the process is similar to the commit selection above. The only
//! difference is the remote selection. If there is only one, we use it, as
//! before. Otherwise, we try to use `default_remote` from the configuration, if
//! available. If not, we do not set up a remote tracking branch. It works like
//! this:
//!
//! * If a prefix is specified in the configuration, we use
//! `{remote}/{prefix}/{worktree_name}`
//!
//! * If no prefix is specified in the configuration, we use
//! `{remote}/{worktree_name}`
//!
//! Now that we have a remote, we use the same process as above:
//!
//! * If a prefix is specified in the configuration, we use for
//! `{remote}/{prefix}/{worktree_name}`
//! * We use for `{remote}/{worktree_name}`
//!
//! ---
//!
//! All this means that in some weird situation, you may end up with the state
//! of a remote branch while not actually tracking that branch. This can only
//! happen in repositories with more than one remote. Imagine the following:
//!
//! The repository has two remotes (`remote1` and `remote2`) which have the
//! exact same remote state. But there is no `default_remote` in the
//! configuration (or no configuration at all). There is a remote branch
//! `foobar`. As both `remote1/foobar` and `remote2/foobar` as the same, the new
//! worktree will use that as the state of the new branch. But as `grm` cannot
//! tell which remote branch to track, it will not set up remote tracking. This
//! behaviour may be a bit confusing, but first, there is no good way to resolve
//! this, and second, the situation should be really rare (when having multiple
//! remotes, you would generally have a `default_remote` configured).
//!
//! # Implementation
//!
//! To reduce the chance of bugs, the implementation uses the [typestate
//! pattern](http://cliffle.com/blog/rust-typestate/). Here are the states we
//! are moving through linearily:
//!
//! * Init
//! * A local branch name is set
//! * A local commit to set the new branch to is selected
//! * A remote tracking branch is selected
//! * The new branch is created with all the required settings
//!
//! Don't worry about the lifetime stuff: There is only one single lifetime, as
//! everything (branches, commits) is derived from the single repo::Repo
//! instance
//!
//! # Testing
//!
//! There are two types of input to the tests:
//!
//! 1) The parameters passed to `grm`, either via command line or via
//! configuration file
//! 2) The circumstances in the repository and remotes
//!
//! ## Parameters
//!
//! * The name of the worktree
//! * Whether it contains slashes or not
//! * Whether it is invalid
//! * `--track` and `--no-track`
//! * Whether there is a configuration file and what it contains
//! * Whether `track.default` is enabled or disabled
//! * Whether `track.default_remote_prefix` is there or missing
//! * Whether `track.default_remote` is there or missing
//! * Whether that remote exists or not
//!
//! ## Situations
//!
//! ### The local branch
//!
//! * Whether the branch already exists
//! * Whether the branch has a remote tracking branch and whether it differs
//! from the desired tracking branch (i.e. `--track` or config)
//!
//! ### Remotes
//!
//! * How many remotes there are, if any
//! * If more than two remotes exist, whether their desired tracking branch
//! differs
//!
//! ### The remote tracking branch branch
//!
//! * Whether a remote branch with the same name as the worktree exists
//! * Whether a remote branch with the same name as the worktree plus prefix
//! exists
//!
//! ## Outcomes
//!
//! We have to check the following afterwards:
//!
//! * Does the worktree exist in the correct location?
//! * Does the local branch have the same name as the worktree?
//! * Does the local branch have the correct commit?
//! * Does the local branch track the correct remote branch?
//! * Does that remote branch also exist?
use std::cell::RefCell;
use std::path::Path; use std::path::Path;
// use super::output::*;
use super::repo; use super::repo;
pub const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree"; pub const GIT_MAIN_WORKTREE_DIRECTORY: &str = ".git-main-working-tree";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_worktree_names() {
assert!(add_worktree(Path::new("/tmp/"), "/leadingslash", None, false).is_err());
assert!(add_worktree(Path::new("/tmp/"), "trailingslash/", None, false).is_err());
assert!(add_worktree(Path::new("/tmp/"), "//", None, false).is_err());
assert!(add_worktree(Path::new("/tmp/"), "test//test", None, false).is_err());
assert!(add_worktree(Path::new("/tmp/"), "test test", None, false).is_err());
assert!(add_worktree(Path::new("/tmp/"), "test\ttest", None, false).is_err());
}
}
struct Init;
struct WithLocalBranchName<'a> {
local_branch_name: String,
/// Outer option: Is there a computed value?
/// Inner option: Is there actually a branch?
///
/// None => No computed value yet
/// Some(None) => No branch
/// Some(Some(_)) => Branch
local_branch: RefCell<Option<Option<repo::Branch<'a>>>>,
}
struct WithLocalTargetSelected<'a> {
local_branch_name: String,
local_branch: Option<repo::Branch<'a>>,
target_commit: Option<Box<repo::Commit<'a>>>,
}
struct WithRemoteTrackingBranch<'a> {
local_branch_name: String,
local_branch: Option<repo::Branch<'a>>,
target_commit: Option<Box<repo::Commit<'a>>>,
remote_tracking_branch: Option<(String, String)>,
prefix: Option<String>,
}
struct Worktree<'a, S: WorktreeState> {
repo: &'a repo::RepoHandle,
extra: S,
}
impl<'a> WithLocalBranchName<'a> {
fn new(name: String) -> Self {
Self {
local_branch_name: name,
local_branch: RefCell::new(None),
}
}
}
trait WorktreeState {}
impl WorktreeState for Init {}
impl<'a> WorktreeState for WithLocalBranchName<'a> {}
impl<'a> WorktreeState for WithLocalTargetSelected<'a> {}
impl<'a> WorktreeState for WithRemoteTrackingBranch<'a> {}
impl<'a> Worktree<'a, Init> {
fn new(repo: &'a repo::RepoHandle) -> Self {
Self {
repo,
extra: Init {},
}
}
fn set_local_branch_name(self, name: &str) -> Worktree<'a, WithLocalBranchName<'a>> {
Worktree::<WithLocalBranchName> {
repo: self.repo,
extra: WithLocalBranchName::new(name.to_string()),
}
}
}
impl<'a, 'b> Worktree<'a, WithLocalBranchName<'b>>
where
'a: 'b,
{
fn check_local_branch(&self) {
let mut branchref = self.extra.local_branch.borrow_mut();
if branchref.is_none() {
let branch = self.repo.find_local_branch(&self.extra.local_branch_name);
*branchref = Some(if let Ok(branch) = branch {
Some(branch)
} else {
None
});
}
}
fn local_branch_already_exists(&self) -> bool {
if let Some(branch) = &*self.extra.local_branch.borrow() {
return branch.is_some();
}
self.check_local_branch();
// As we just called `check_local_branch`, we can be sure that
// `self.extra.local_branch` is set to some `Some` value
(*self.extra.local_branch.borrow())
.as_ref()
.unwrap()
.is_some()
}
fn select_commit(
self,
commit: Option<Box<repo::Commit<'b>>>,
) -> Worktree<'a, WithLocalTargetSelected<'b>> {
self.check_local_branch();
Worktree::<'a, WithLocalTargetSelected> {
repo: self.repo,
extra: WithLocalTargetSelected::<'b> {
local_branch_name: self.extra.local_branch_name,
// As we just called `check_local_branch`, we can be sure that
// `self.extra.local_branch` is set to some `Some` value
local_branch: self.extra.local_branch.into_inner().unwrap(),
target_commit: commit,
},
}
}
}
impl<'a> Worktree<'a, WithLocalTargetSelected<'a>> {
fn set_remote_tracking_branch(
self,
branch: Option<(&str, &str)>,
prefix: Option<&str>,
) -> Worktree<'a, WithRemoteTrackingBranch<'a>> {
Worktree::<WithRemoteTrackingBranch> {
repo: self.repo,
extra: WithRemoteTrackingBranch {
local_branch_name: self.extra.local_branch_name,
local_branch: self.extra.local_branch,
target_commit: self.extra.target_commit,
remote_tracking_branch: branch.map(|(s1, s2)| (s1.to_string(), s2.to_string())),
prefix: prefix.map(|prefix| prefix.to_string()),
},
}
}
}
impl<'a> Worktree<'a, WithRemoteTrackingBranch<'a>> {
fn create(self, directory: &Path) -> Result<Option<Vec<String>>, String> {
let mut warnings: Vec<String> = vec![];
let mut branch = if let Some(branch) = self.extra.local_branch {
branch
} else {
self.repo.create_branch(
&self.extra.local_branch_name,
// TECHDEBT
// We must not call this with `Some()` without a valid target.
// I'm sure this can be improved, just not sure how.
&*self.extra.target_commit.unwrap(),
)?
};
if let Some((remote_name, remote_branch_name)) = self.extra.remote_tracking_branch {
let remote_branch_with_prefix = if let Some(ref prefix) = self.extra.prefix {
if let Ok(remote_branch) = self
.repo
.find_remote_branch(&remote_name, &format!("{prefix}/{remote_branch_name}"))
{
Some(remote_branch)
} else {
None
}
} else {
None
};
let remote_branch_without_prefix = if let Ok(remote_branch) = self
.repo
.find_remote_branch(&remote_name, &remote_branch_name)
{
Some(remote_branch)
} else {
None
};
let remote_branch = if let Some(ref _prefix) = self.extra.prefix {
remote_branch_with_prefix
} else {
remote_branch_without_prefix
};
match remote_branch {
Some(remote_branch) => {
if branch.commit()?.id().hex_string()
!= remote_branch.commit()?.id().hex_string()
{
warnings.push(format!("The local branch \"{}\" and the remote branch \"{}/{}\" differ. Make sure to push/pull afterwards!", &self.extra.local_branch_name, &remote_name, &remote_branch_name));
}
branch.set_upstream(&remote_name, &remote_branch.basename()?)?;
}
None => {
let mut remote = match self.repo.find_remote(&remote_name)? {
Some(remote) => remote,
None => return Err(format!("Remote \"{remote_name}\" not found")),
};
if !remote.is_pushable()? {
return Err(format!(
"Cannot push to non-pushable remote \"{remote_name}\""
));
}
if let Some(prefix) = self.extra.prefix {
remote.push(
&self.extra.local_branch_name,
&format!("{}/{}", prefix, remote_branch_name),
self.repo,
)?;
branch.set_upstream(
&remote_name,
&format!("{}/{}", prefix, remote_branch_name),
)?;
} else {
remote.push(
&self.extra.local_branch_name,
&remote_branch_name,
self.repo,
)?;
branch.set_upstream(&remote_name, &remote_branch_name)?;
}
}
}
}
// We have to create subdirectories first, otherwise adding the worktree
// will fail
if self.extra.local_branch_name.contains('/') {
let path = Path::new(&self.extra.local_branch_name);
if let Some(base) = path.parent() {
// This is a workaround of a bug in libgit2 (?)
//
// When *not* doing this, we will receive an error from the `Repository::worktree()`
// like this:
//
// > failed to make directory '/{repo}/.git-main-working-tree/worktrees/dir/test
//
// This is a discrepancy between the behaviour of libgit2 and the
// git CLI when creating worktrees with slashes:
//
// The git CLI will create the worktree's configuration directory
// inside {git_dir}/worktrees/{last_path_component}. Look at this:
//
// ```
// $ git worktree add 1/2/3 -b 1/2/3
// $ ls .git/worktrees
// 3
// ```
//
// Interesting: When adding a worktree with a different name but the
// same final path component, git starts adding a counter suffix to
// the worktree directories:
//
// ```
// $ git worktree add 1/3/3 -b 1/3/3
// $ git worktree add 1/4/3 -b 1/4/3
// $ ls .git/worktrees
// 3
// 31
// 32
// ```
//
// I *guess* that the mapping back from the worktree directory under .git to the actual
// worktree directory is done via the `gitdir` file inside `.git/worktrees/{worktree}.
// This means that the actual directory would not matter. You can verify this by
// just renaming it:
//
// ```
// $ mv .git/worktrees/3 .git/worktrees/foobar
// $ git worktree list
// /tmp/ fcc8a2a7 [master]
// /tmp/1/2/3 fcc8a2a7 [1/2/3]
// /tmp/1/3/3 fcc8a2a7 [1/3/3]
// /tmp/1/4/3 fcc8a2a7 [1/4/3]
// ```
//
// => Still works
//
// Anyway, libgit2 does not do this: It tries to create the worktree
// directory inside .git with the exact name of the worktree, including
// any slashes. It should be this code:
//
// https://github.com/libgit2/libgit2/blob/f98dd5438f8d7bfd557b612fdf1605b1c3fb8eaf/src/libgit2/worktree.c#L346
//
// As a workaround, we can create the base directory manually for now.
//
// Tracking upstream issue: https://github.com/libgit2/libgit2/issues/6327
std::fs::create_dir_all(
directory
.join(GIT_MAIN_WORKTREE_DIRECTORY)
.join("worktrees")
.join(base),
)
.map_err(|error| error.to_string())?;
std::fs::create_dir_all(base).map_err(|error| error.to_string())?;
}
}
self.repo.new_worktree(
&self.extra.local_branch_name,
&directory.join(&self.extra.local_branch_name),
&branch,
)?;
Ok(if warnings.is_empty() {
None
} else {
Some(warnings)
})
}
}
/// A branch name must never start or end with a slash, and it cannot have two
/// consecutive slashes
fn validate_worktree_name(name: &str) -> Result<(), String> {
if name.starts_with('/') || name.ends_with('/') {
return Err(format!(
"Invalid worktree name: {}. It cannot start or end with a slash",
name
));
}
if name.contains("//") {
return Err(format!(
"Invalid worktree name: {}. It cannot contain two consecutive slashes",
name
));
}
if name.contains(char::is_whitespace) {
return Err(format!(
"Invalid worktree name: {}. It cannot contain whitespace",
name
));
}
Ok(())
}
// TECHDEBT
//
// Instead of opening the repo & reading configuration inside the function, it
// should be done by the caller and given as a parameter
pub fn add_worktree( pub fn add_worktree(
directory: &Path, directory: &Path,
name: &str, name: &str,
subdirectory: Option<&Path>,
track: Option<(&str, &str)>, track: Option<(&str, &str)>,
no_track: bool, no_track: bool,
) -> Result<Option<Vec<String>>, String> { ) -> Result<(), String> {
let mut warnings: Vec<String> = vec![];
validate_worktree_name(name)?;
let repo = repo::RepoHandle::open(directory, true).map_err(|error| match error.kind { let repo = repo::RepoHandle::open(directory, true).map_err(|error| match error.kind {
repo::RepoErrorKind::NotFound => { repo::RepoErrorKind::NotFound => {
String::from("Current directory does not contain a worktree setup") String::from("Current directory does not contain a worktree setup")
@@ -586,195 +18,149 @@ pub fn add_worktree(
_ => format!("Error opening repo: {}", error), _ => format!("Error opening repo: {}", error),
})?; })?;
let remotes = &repo.remotes()?;
let config = repo::read_worktree_root_config(directory)?; let config = repo::read_worktree_root_config(directory)?;
if repo.find_worktree(name).is_ok() { if repo.find_worktree(name).is_ok() {
return Err(format!("Worktree {} already exists", &name)); return Err(format!("Worktree {} already exists", &name));
} }
let track_config = config.and_then(|config| config.track); let path = match subdirectory {
let prefix = track_config Some(dir) => directory.join(dir).join(name),
.as_ref() None => directory.join(Path::new(name)),
.and_then(|track| track.default_remote_prefix.as_ref());
let enable_tracking = track_config.as_ref().map_or(false, |track| track.default);
let default_remote = track_config
.as_ref()
.map(|track| track.default_remote.clone());
// Note that we have to define all variables that borrow from `repo`
// *first*, otherwise we'll receive "borrowed value does not live long
// enough" errors. This is due to the `repo` reference inside `Worktree` that is
// passed through each state type.
//
// The `commit` variable will be dropped at the end of the scope, together with all
// worktree variables. It will be done in the opposite direction of delcaration (FILO).
//
// So if we define `commit` *after* the respective worktrees, it will be dropped first while
// still being borrowed by `Worktree`.
let default_branch_head = repo.default_branch()?.commit_owned()?;
let worktree = Worktree::<Init>::new(&repo).set_local_branch_name(name);
let get_remote_head = |remote_name: &str,
remote_branch_name: &str|
-> Result<Option<Box<repo::Commit>>, String> {
if let Ok(remote_branch) = repo.find_remote_branch(remote_name, remote_branch_name) {
Ok(Some(Box::new(remote_branch.commit_owned()?)))
} else {
Ok(None)
}
}; };
let worktree = if worktree.local_branch_already_exists() { let mut remote_branch_exists = false;
worktree.select_commit(None)
} else if let Some((remote_name, remote_branch_name)) = if no_track { None } else { track } {
if let Ok(remote_branch) = repo.find_remote_branch(remote_name, remote_branch_name) {
worktree.select_commit(Some(Box::new(remote_branch.commit_owned()?)))
} else {
worktree.select_commit(Some(Box::new(default_branch_head)))
}
} else {
match remotes.len() {
0 => worktree.select_commit(Some(Box::new(default_branch_head))),
1 => {
let remote_name = &remotes[0];
let commit: Option<Box<repo::Commit>> = ({
if let Some(prefix) = prefix {
get_remote_head(remote_name, &format!("{prefix}/{name}"))?
} else {
None
}
})
.or(get_remote_head(remote_name, name)?)
.or_else(|| Some(Box::new(default_branch_head)));
worktree.select_commit(commit) let default_checkout = || repo.default_branch()?.to_commit();
}
_ => { let checkout_commit;
let commit = if let Some(ref default_remote) = default_remote { if no_track {
if let Some(ref prefix) = prefix { checkout_commit = default_checkout()?;
if let Ok(remote_branch) = repo } else {
.find_remote_branch(default_remote, &format!("{prefix}/{name}")) match track {
{ Some((remote_name, remote_branch_name)) => {
Some(Box::new(remote_branch.commit_owned()?)) let remote_branch = repo.find_remote_branch(remote_name, remote_branch_name);
} else { match remote_branch {
None Ok(branch) => {
} remote_branch_exists = true;
} else { checkout_commit = branch.to_commit()?;
None
} }
.or({ Err(_) => {
if let Ok(remote_branch) = remote_branch_exists = false;
repo.find_remote_branch(default_remote, name) checkout_commit = default_checkout()?;
{ }
Some(Box::new(remote_branch.commit_owned()?)) }
} else { }
None None => match &config {
} None => checkout_commit = default_checkout()?,
}) Some(config) => match &config.track {
} else { None => checkout_commit = default_checkout()?,
None Some(track_config) => {
}.or({ if track_config.default {
let mut commits = vec![]; let remote_branch =
for remote_name in remotes.iter() { repo.find_remote_branch(&track_config.default_remote, name);
let remote_head: Option<Box<repo::Commit>> = ({ match remote_branch {
if let Some(ref prefix) = prefix { Ok(branch) => {
if let Ok(remote_branch) = repo.find_remote_branch( remote_branch_exists = true;
remote_name, checkout_commit = branch.to_commit()?;
&format!("{prefix}/{name}"), }
) { Err(_) => {
Some(Box::new(remote_branch.commit_owned()?)) checkout_commit = default_checkout()?;
} else {
None
} }
} else {
None
} }
}) } else {
.or({ checkout_commit = default_checkout()?;
if let Ok(remote_branch) = }
repo.find_remote_branch(remote_name, name)
{
Some(Box::new(remote_branch.commit_owned()?))
} else {
None
}
})
.or(None);
commits.push(remote_head);
} }
},
},
};
}
let mut commits = commits let mut target_branch = match repo.find_local_branch(name) {
.into_iter() Ok(branchref) => branchref,
.flatten() Err(_) => repo.create_branch(name, &checkout_commit)?,
// have to collect first because the `flatten()` return
// typedoes not implement `windows()`
.collect::<Vec<Box<repo::Commit>>>();
// `flatten()` takes care of `None` values here. If all
// remotes return None for the branch, we do *not* abort, we
// continue!
if commits.is_empty() {
Some(Box::new(default_branch_head))
} else if commits.len() == 1 {
Some(commits.swap_remove(0))
} else if commits.windows(2).any(|window| {
let c1 = &window[0];
let c2 = &window[1];
(*c1).id().hex_string() != (*c2).id().hex_string()
}) {
warnings.push(
// TODO this should also include the branch
// name. BUT: the branch name may be different
// between the remotes. Let's just leave it
// until I get around to fix that inconsistency
// (see module-level doc about), which might be
// never, as it's such a rare edge case.
"Branch exists on multiple remotes, but they deviate. Selecting default branch instead".to_string()
);
Some(Box::new(default_branch_head))
} else {
Some(commits.swap_remove(0))
}
});
worktree.select_commit(commit)
}
}
}; };
let worktree = if no_track { fn push(
worktree.set_remote_tracking_branch(None, prefix.map(|s| s.as_str())) remote: &mut repo::RemoteHandle,
} else if let Some((remote_name, remote_branch_name)) = track { branch_name: &str,
worktree.set_remote_tracking_branch( remote_branch_name: &str,
Some((remote_name, remote_branch_name)), repo: &repo::RepoHandle,
None, // Always disable prefixing when explicitly given --track ) -> Result<(), String> {
) if !remote.is_pushable()? {
} else if !enable_tracking { return Err(format!(
worktree.set_remote_tracking_branch(None, prefix.map(|s| s.as_str())) "Cannot push to non-pushable remote {}",
} else { remote.url()
match remotes.len() { ));
0 => worktree.set_remote_tracking_branch(None, prefix.map(|s| s.as_str())), }
1 => worktree remote.push(branch_name, remote_branch_name, repo)
.set_remote_tracking_branch(Some((&remotes[0], name)), prefix.map(|s| s.as_str())), }
_ => {
if let Some(default_remote) = default_remote { if !no_track {
worktree.set_remote_tracking_branch( if let Some((remote_name, remote_branch_name)) = track {
Some((&default_remote, name)), if remote_branch_exists {
prefix.map(|s| s.as_str()), target_branch.set_upstream(remote_name, remote_branch_name)?;
) } else {
} else { let mut remote = repo
worktree.set_remote_tracking_branch(None, prefix.map(|s| s.as_str())) .find_remote(remote_name)
.map_err(|error| format!("Error getting remote {}: {}", remote_name, error))?
.ok_or_else(|| format!("Remote {} not found", remote_name))?;
push(
&mut remote,
&target_branch.name()?,
remote_branch_name,
&repo,
)?;
target_branch.set_upstream(remote_name, remote_branch_name)?;
}
} else if let Some(config) = config {
if let Some(track_config) = config.track {
if track_config.default {
let remote_name = track_config.default_remote;
if remote_branch_exists {
target_branch.set_upstream(&remote_name, name)?;
} else {
let remote_branch_name = match track_config.default_remote_prefix {
Some(prefix) => {
format!("{}{}{}", &prefix, super::BRANCH_NAMESPACE_SEPARATOR, &name)
}
None => name.to_string(),
};
let mut remote = repo
.find_remote(&remote_name)
.map_err(|error| {
format!("Error getting remote {}: {}", remote_name, error)
})?
.ok_or_else(|| format!("Remote {} not found", remote_name))?;
if !remote.is_pushable()? {
return Err(format!(
"Cannot push to non-pushable remote {}",
remote.url()
));
}
push(
&mut remote,
&target_branch.name()?,
&remote_branch_name,
&repo,
)?;
target_branch.set_upstream(&remote_name, &remote_branch_name)?;
}
} }
} }
} }
}; }
worktree.create(directory)?; if let Some(subdirectory) = subdirectory {
std::fs::create_dir_all(subdirectory).map_err(|error| error.to_string())?;
}
repo.new_worktree(name, &path, &target_branch)?;
Ok(if warnings.is_empty() { Ok(())
None
} else {
Some(warnings)
})
} }