138 Commits

Author SHA1 Message Date
04753e8d9c Merge branch 'develop' 2022-09-23 07:19:03 +02:00
5811476e27 Release v0.7.8 2022-09-23 07:19:03 +02:00
5ac814b857 Cargo.lock: Updating idna v0.2.3 -> v0.3.0 2022-09-23 07:05:43 +02:00
3a87772606 Cargo.lock: Updating form_urlencoded v1.0.1 -> v1.1.0 2022-09-23 07:05:40 +02:00
001911bed9 Cargo.lock: Updating thiserror v1.0.32 -> v1.0.35 2022-09-23 07:05:37 +02:00
d1d729c33d Cargo.lock: Updating socket2 v0.4.6 -> v0.4.7 2022-09-23 07:05:35 +02:00
b4cabae4ec Cargo.lock: Updating lock_api v0.4.8 -> v0.4.9 2022-09-23 07:05:33 +02:00
196bbeb2c2 Cargo.lock: Updating aho-corasick v0.7.18 -> v0.7.19 2022-09-23 07:05:30 +02:00
4d60012f44 dependencies: Update serde_yaml to 0.9.13 2022-09-23 07:05:30 +02:00
b2f413b033 dependencies: Update clap to 3.2.22 2022-09-23 07:05:30 +02:00
c25bb8bf55 dependencies: Update serde to 1.0.145 2022-09-23 07:05:30 +02:00
3b923e3e13 Merge pull request #44 from vrischmann/name-in-error
catch the error returned by add_repo_status
2022-09-23 07:03:40 +02:00
Vincent Rischmann
061265bbd0 catch the error returned by add_repo_status
If add_repo_status returns an error it will eventually be printed to the
user but there won't be any repository information:

    [✘] Error getting status: No branch checked out

Catch the error earlier so we can print the repository name:

    [✘] Error: freebsd-src: Couldn't add repo status: No branch checked out
2022-09-22 21:10:16 +02:00
a08a8d2000 Merge branch 'develop' 2022-08-29 21:10:53 +02:00
fea0299c95 Release v0.7.7 2022-08-29 21:10:53 +02:00
444930199c Update README 2022-08-29 20:25:34 +02:00
95704b9a40 Cargo.lock: Updating pin-project v1.0.11 -> v1.0.12 2022-08-29 20:05:54 +02:00
8d300827d0 Cargo.lock: Updating futures-io v0.3.21 -> v0.3.24 2022-08-29 20:05:51 +02:00
9ab79b120a Cargo.lock: Updating fastrand v1.7.0 -> v1.8.0 2022-08-29 20:05:48 +02:00
8cc9470aca Cargo.lock: Updating curl v0.4.43 -> v0.4.44 2022-08-29 20:05:45 +02:00
9e95701a6e Cargo.lock: Updating crossbeam-utils v0.8.10 -> v0.8.11 2022-08-29 20:05:43 +02:00
fe90401688 Cargo.lock: Updating bytes v1.2.0 -> v1.2.1 2022-08-29 20:05:42 +02:00
aeaaee9915 Cargo.lock: Updating async-channel v1.6.1 -> v1.7.1 2022-08-29 20:05:41 +02:00
52b024c1ba dependencies: Update serde_json to 1.0.85 2022-08-29 20:05:40 +02:00
3a95613132 dependencies: Update serde_yaml to 0.9.10 2022-08-29 20:05:40 +02:00
2ea2c994d8 dependencies: Update comfy-table to 6.1.0 2022-08-29 20:05:40 +02:00
04686b6dfa dependencies: Update console to 0.15.1 2022-08-29 20:05:40 +02:00
78201d4759 dependencies: Update clap to 3.2.18 2022-08-29 20:05:40 +02:00
5fe6600dc3 dependencies: Update shellexpand to 2.1.2 2022-08-29 20:05:40 +02:00
2a65f78cd4 dependencies: Update git2 to 0.15.0 2022-08-29 20:05:40 +02:00
4852dad71e dependencies: Update serde to 1.0.144 2022-08-29 20:05:40 +02:00
0746be904a Merge branch 'develop' 2022-07-21 20:05:57 +02:00
53c2ee404c Release v0.7.6 2022-07-21 20:05:57 +02:00
bd694c3f7d just: Add pushall target for easier releases 2022-07-21 20:05:22 +02:00
95e9fcbffe Cargo.lock: Updating pin-project v1.0.10 -> v1.0.11 2022-07-21 19:49:53 +02:00
98665a3231 Cargo.lock: Updating openssl-sys v0.9.74 -> v0.9.75 2022-07-21 19:49:48 +02:00
7a51ad135f Cargo.lock: Updating rustversion v1.0.7 -> v1.0.8 2022-07-21 19:49:46 +02:00
e386935bc7 Cargo.lock: Updating bytes v1.1.0 -> v1.2.0 2022-07-21 19:49:44 +02:00
c62562e6f0 dependencies: Update serde_yaml to 0.8.26 2022-07-21 19:49:43 +02:00
00e37996b7 dependencies: Update regex to 1.6.0 2022-07-21 19:49:43 +02:00
a7e2c61984 dependencies: Update clap to 3.2.14 2022-07-21 19:49:43 +02:00
58919b2d58 dependencies: Update serde to 1.0.140 2022-07-21 19:49:42 +02:00
dd36eb886f Merge pull request #42 from hakoerber/dependabot/cargo/openssl-src-111.22.01.1.1q
build(deps): bump openssl-src from 111.21.0+1.1.1p to 111.22.0+1.1.1q
2022-07-07 08:01:05 +02:00
dependabot[bot]
d2e01db0ae build(deps): bump openssl-src from 111.21.0+1.1.1p to 111.22.0+1.1.1q
Bumps [openssl-src](https://github.com/alexcrichton/openssl-src-rs) from 111.21.0+1.1.1p to 111.22.0+1.1.1q.
- [Release notes](https://github.com/alexcrichton/openssl-src-rs/releases)
- [Commits](https://github.com/alexcrichton/openssl-src-rs/commits)

---
updated-dependencies:
- dependency-name: openssl-src
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-07 05:55:19 +00:00
bfd7b01ea4 Use en_US for spelling 2022-06-30 20:33:51 +02:00
da7a499da0 Merge branch 'develop' 2022-06-30 20:26:24 +02:00
64965c32dd Release v0.7.5 2022-06-30 20:26:24 +02:00
3207bdfdfb Add wait helper to Justfile 2022-06-30 20:02:36 +02:00
d8dd604174 Use safer method to remove empty directory 2022-06-30 19:59:46 +02:00
7ca9459675 Update release script to not run "just check" 2022-06-30 19:58:04 +02:00
989b0cdcce e2e: Refactor worktree delete removal tests 2022-06-30 19:56:29 +02:00
64d8397092 Remove debug output 2022-06-30 19:56:22 +02:00
a1b054a672 e2e: Fix method name 2022-06-30 19:56:22 +02:00
193c96c5aa e2e: Check for stdout on "worktree delete" 2022-06-30 19:56:22 +02:00
ee973432be Update documentation 2022-06-30 19:34:07 +02:00
38d0252101 dependencies: Update clap to 3.2.8 2022-06-30 19:17:19 +02:00
280048264e Cargo.lock: Updating smallvec v1.8.1 -> v1.9.0 2022-06-30 19:08:03 +02:00
129111273d Add Justfile target for release 2022-06-30 19:08:03 +02:00
d62a19d741 Do not update dependencies on each release 2022-06-30 19:08:03 +02:00
e34a6243c0 Add pretection against accidential 1.0 release 2022-06-30 19:08:03 +02:00
4464bb607b Fix usage output of release.sh 2022-06-30 19:08:03 +02:00
48fa888f9b Print each unmanaged repo only once 2022-06-30 19:08:03 +02:00
b4eafd0b41 Merge branch 'develop' 2022-06-29 23:58:31 +02:00
fa83063c61 Release v0.7.4 2022-06-29 23:58:31 +02:00
7d8fbb844e Properly handle deletion of nested worktrees 2022-06-29 23:40:23 +02:00
494c6ecb3e Cargo.lock: Updating linked-hash-map v0.5.4 -> v0.5.6 2022-06-29 23:36:50 +02:00
91a37cb12d Cargo.lock: Updating smallvec v1.8.0 -> v1.8.1 2022-06-29 23:36:46 +02:00
4e21a3daad dependencies: Update serde_json to 1.0.82 2022-06-29 23:34:55 +02:00
0e9c8d0c01 dependencies: Update clap to 3.2.7 2022-06-29 23:34:55 +02:00
512de5e187 e2e: Reduce number of tests by removing redundant ones 2022-06-29 22:47:04 +02:00
f027191896 Update worktree handling
That's a big one, see the module-level comment for details.
2022-06-23 19:21:05 +02:00
ee44fa40fd Add method to get owned commit of branch 2022-06-23 19:21:05 +02:00
e78dcf471a Print warning when giving --track and --no-track 2022-06-23 19:21:05 +02:00
056480f65a e2e: Update test for worktree adding 2022-06-23 19:21:05 +02:00
3eabc0e8f8 e2e: Update test for invalid remote name 2022-06-23 19:21:05 +02:00
d7ab3c4d6b e2e: Remove unnecessary output 2022-06-23 19:21:05 +02:00
09ce9f043e e2e: Add test case for invalid tracks 2022-06-23 19:21:05 +02:00
eac22148c5 e2e: Move invalid subdirectory test 2022-06-23 19:21:05 +02:00
92ec2e1a2d e2e: Test worktree names with whitespace 2022-06-23 19:21:05 +02:00
88961e1c6b e2e: Add caching to git repositories
It's very expensive to create new repositories from scratch. To avoid
this, a new repo & remotes are only created if necessary (depending on a
cache key given on request). If not created, they are simply copied from
a stored, clean repository / remote.
2022-06-23 19:21:05 +02:00
8c384741b3 e2e: Fix warning about default branch name 2022-06-23 19:00:22 +02:00
2053512559 e2e: Print stdout/stderr on error 2022-06-23 18:58:13 +02:00
ad7ef9277e e2e: Use pipefail for test scripts 2022-06-23 18:57:58 +02:00
95da48b5e6 e2e: Don't install recommended packages in docker 2022-06-23 18:56:35 +02:00
664cfb8965 e2e: Exit on first test error 2022-06-23 18:55:39 +02:00
ba4240720c Use static binary for e2e tests 2022-06-23 18:55:19 +02:00
ec04618a73 Use release builds for e2e tests 2022-06-23 18:54:49 +02:00
6dc298146a Cargo.lock: Updating openssl-src v111.20.0+1.1.1o -> v111.21.0+1.1.1p 2022-06-23 18:47:40 +02:00
09606cfc27 Cargo.lock: Updating crossbeam-utils v0.8.9 -> v0.8.10 2022-06-23 18:47:32 +02:00
465f877d6a Cargo.lock: Updating mio v0.8.3 -> v0.8.4 2022-06-23 18:47:26 +02:00
763e014b44 dependencies: Update clap to 3.2.6 2022-06-23 18:47:21 +02:00
474e0b60f9 Cargo.lock: Updating crossbeam-utils v0.8.8 -> v0.8.9 2022-06-17 02:25:41 +02:00
10af4d7448 Cargo.lock: Updating strum_macros v0.24.1 -> v0.24.0 2022-06-17 02:25:39 +02:00
94bfe971b3 Add FUNDING.yml 2022-06-17 02:24:15 +02:00
b77c442f56 Forbid unsafe code 2022-06-17 02:24:15 +02:00
a3f9c9fda1 e2e: Remove redundant test 2022-06-17 02:24:15 +02:00
2a0a591194 e2e: Add test for invalid worktree names 2022-06-17 02:24:15 +02:00
23526ae62b e2e: Update tests for worktree subdirectory handling 2022-06-17 02:24:15 +02:00
addff12c17 Run e2e tests again dynamically linked dev binary
This makes the build much faster.
2022-06-17 01:50:01 +02:00
c56765ce26 Match branches with worktrees always, even with slashes 2022-06-17 01:50:01 +02:00
d18c49982e Merge branch 'develop' 2022-06-16 00:55:13 +02:00
58db521b5b Release v0.7.3 2022-06-16 00:55:13 +02:00
c21fb5813b just: Remove redunant commands from check target 2022-06-16 00:39:57 +02:00
33a5a1a262 Add short doc snipper about "just check" 2022-06-16 00:39:43 +02:00
df8e69bce2 Enable autoformatting for shell scripts 2022-06-16 00:39:31 +02:00
58fdcfba9f Enable linting for shell scripts 2022-06-16 00:32:16 +02:00
27ef86c1b4 forge: Use "origin" as the default remote name
Close #33
2022-06-15 20:49:15 +02:00
9fc34e6989 just: Add clean target 2022-06-15 20:39:54 +02:00
4b79b6dd1d just: Update targets for static builds 2022-06-15 20:39:54 +02:00
d0cbc2f985 forge: Add option to specify remote name
Close #32
2022-06-15 20:39:54 +02:00
d53e28668b Cargo.lock: Updating http v0.2.7 -> v0.2.8 2022-06-15 20:39:54 +02:00
0b8896d11d Cargo.lock: Updating getrandom v0.2.6 -> v0.2.7 2022-06-15 20:39:54 +02:00
8c0c3ad169 dependencies: Update clap to 3.2.5 2022-06-15 20:39:54 +02:00
aebed5639d Add Max to contributors 2022-06-14 09:37:51 +02:00
4514de9ff5 Add release script 2022-06-14 00:35:03 +02:00
31b9757ef3 Merge branch 'develop' 2022-06-14 00:32:08 +02:00
defb3d1b7d Release v0.7.2 2022-06-14 00:32:08 +02:00
e6b654e990 Cargo.lock: Updating libz-sys v1.1.6 -> v1.1.8 2022-06-14 00:15:15 +02:00
29ddc647e3 dependencies: Update comfy-table to 6.0.0 2022-06-14 00:15:15 +02:00
67c3e40108 just: Update check target to be pre-commit ready 2022-06-14 00:15:15 +02:00
7363ed48b4 Add clippy suggestions 2022-06-14 00:15:15 +02:00
96943c1483 Use new cargo fmt 2022-06-14 00:15:15 +02:00
9f7195282f Enable output in rust unit tests 2022-06-14 00:15:15 +02:00
30480fb568 Update handling of branches on worktree setup 2022-06-14 00:15:15 +02:00
c3aaea3332 Quote branch name on output 2022-06-14 00:15:15 +02:00
fad6f71876 Improve default branch guessing 2022-06-14 00:15:15 +02:00
73158e3d47 Print ok-ish stuff to stdout 2022-06-14 00:15:15 +02:00
6f4ae88260 Add some comments about repo syncing 2022-06-14 00:15:15 +02:00
a8f8803a92 Do not fail on empty clone target 2022-06-14 00:15:15 +02:00
581a513ebd Initialize local branches on clone 2022-06-14 00:15:15 +02:00
f1e212ead9 Add function to get all remote branches 2022-06-14 00:15:15 +02:00
bc3001a4e6 Add function to get basename of branch 2022-06-14 00:15:15 +02:00
c4fd1d0452 Refactor default_branch() for readability 2022-06-14 00:15:15 +02:00
1a65a163a1 Use opaque type for auth token
So we cannot accidentially output it, as it does not implement
`Display`.
2022-06-14 00:15:15 +02:00
4f68a563c6 providers: Use references for field access 2022-06-14 00:15:15 +02:00
e04e8ceeeb Use opaque type for auth token
So we cannot accidentially output it, as it does not implement
`Display`.
2022-06-14 00:15:15 +02:00
Max Volk
b2542b341e Reword some of the documentation and spelling fixes 2022-06-14 00:15:15 +02:00
d402c1f8ce Remove accidentially added file 2022-05-28 22:06:52 +02:00
55 changed files with 3269 additions and 1374 deletions

1
.github/FUNDING.yml vendored Normal file
View File

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

View File

@@ -1,49 +1,3 @@
# Contributing
GRM is still in very early development. I started GRM mainly to scratch my own
itches (and am heavily dogfooding it). If you have a new use case for GRM, go
for it!
The branching strategy is a simplified
[git-flow](https://nvie.com/posts/a-successful-git-branching-model/).
* `master` is the "production" branch. Each commit is a new release.
* `develop` is the branch where new stuff is coming in.
* feature branches branch off of `develop` and merge back into it.
So to contribute, just fork the repo and create a pull request against
`develop`. If you plan bigger changes, please consider opening an issue first,
so we can discuss it.
If you want, add yourself to the `CONTRIBUTORS` file in your pull request.
## Code formatting
For Rust, just use `cargo fmt`. For Python, use
[black](https://github.com/psf/black). I'd rather not spend any effort in
configuring the formatters (not possible for black anyway).
## Tooling
GRM uses [`just`](https://github.com/casey/just) as a command runner. See
[here](https://github.com/casey/just#installation) for installation
instructions (it's most likely just a simple `cargo install just`).
## Testing
There are two distinct test suites: One for unit test (`just test-unit`) and
integration tests (`just test-integration`) that is part of the rust crate, and
a separate e2e test suite in python (`just test-e2e`).
To run all tests, run `just test`.
When contributing, consider whether it makes sense to add tests that to prevent
regressions in the future. When fixing bugs, it makes sense to add tests that
expose the wrong behaviour beforehand.
## Documentation
The documentation lives in `docs` and uses
[mdBook](https://github.com/rust-lang/mdBook). Please document new user-facing
features here!
Check out [the developer
documentation](https://hakoerber.github.io/git-repo-manager/developing.html) it
you want to contribute!

View File

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

356
Cargo.lock generated
View File

@@ -4,18 +4,18 @@ version = 3
[[package]]
name = "aho-corasick"
version = "0.7.18"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f"
checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e"
dependencies = [
"memchr",
]
[[package]]
name = "async-channel"
version = "1.6.1"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2114d64672151c0c5eaa5e131ec84a74f06e1e559830dabba01ca30605d66319"
checksum = "e14485364214912d3b19cc3435dde4df66065127f05fa0d75c712f36f12c2f28"
dependencies = [
"concurrent-queue",
"event-listener",
@@ -47,9 +47,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bytes"
version = "1.1.0"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8"
checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db"
[[package]]
name = "cache-padded"
@@ -80,16 +80,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clap"
version = "3.1.18"
version = "3.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2dbdf4bdacb33466e854ce889eee8dfd5729abf7ccd7664d0a2d60cd384440b"
checksum = "86447ad904c7fb335a790c9d7fe3d0d971dc523b8ccd1561a520de9a85302750"
dependencies = [
"atty",
"bitflags",
"clap_derive",
"clap_lex",
"indexmap",
"lazy_static",
"once_cell",
"strsim",
"termcolor",
"textwrap",
@@ -97,11 +97,11 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "3.1.18"
version = "3.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25320346e922cffe59c0bbc5410c8d8784509efb321488971081313cb1e1a33c"
checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65"
dependencies = [
"heck 0.4.0",
"heck",
"proc-macro-error",
"proc-macro2",
"quote",
@@ -110,18 +110,18 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.2.0"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a37c35f1112dad5e6e0b1adaff798507497a18fceeb30cceb3bae7d1427b9213"
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
dependencies = [
"os_str_bytes",
]
[[package]]
name = "comfy-table"
version = "5.0.1"
version = "6.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b103d85ca6e209388771bfb7aa6b68a7aeec4afbf6f0a0264bfbf50360e5212e"
checksum = "85914173c2f558d61613bfbbf1911f14e630895087a7ed2fafc0f5319e1536e7"
dependencies = [
"crossterm",
"strum",
@@ -131,23 +131,22 @@ dependencies = [
[[package]]
name = "concurrent-queue"
version = "1.2.2"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30ed07550be01594c6026cff2a1d7fe9c8f683caa798e12b68694ac9e88286a3"
checksum = "af4780a44ab5696ea9e28294517f1fffb421a83a25af521333c838635509db9c"
dependencies = [
"cache-padded",
]
[[package]]
name = "console"
version = "0.15.0"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28b32d32ca44b70c3e4acd7db1babf555fa026e385fb95f18028f88848b3c31"
checksum = "89eab4d20ce20cea182308bca13088fecea9c05f6776cf287205d41a0ed3c847"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"regex",
"terminal_size",
"unicode-width",
"winapi",
@@ -155,19 +154,19 @@ dependencies = [
[[package]]
name = "crossbeam-utils"
version = "0.8.8"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38"
checksum = "51887d4adc7b564537b15adcfb307936f8075dfcd5f00dde9a9f1d29383682bc"
dependencies = [
"cfg-if",
"lazy_static",
"once_cell",
]
[[package]]
name = "crossterm"
version = "0.23.2"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2102ea4f781910f8a5b98dd061f4c2023f479ce7bb1236330099ceb5a93cf17"
checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
dependencies = [
"bitflags",
"crossterm_winapi",
@@ -190,9 +189,9 @@ dependencies = [
[[package]]
name = "curl"
version = "0.4.43"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37d855aeef205b43f65a5001e0997d81f8efca7badad4fad7d897aa7f0d0651f"
checksum = "509bd11746c7ac09ebd19f0b17782eae80aadee26237658a6b4808afb5c11a22"
dependencies = [
"curl-sys",
"libc",
@@ -205,9 +204,9 @@ dependencies = [
[[package]]
name = "curl-sys"
version = "0.4.55+curl-7.83.1"
version = "0.4.56+curl-7.83.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23734ec77368ec583c2e61dd3f0b0e5c98b93abe6d2a004ca06b91dd7e3e2762"
checksum = "6093e169dd4de29e468fa649fbae11cdcd5551c81fe5bf1b0677adad7ef3d26f"
dependencies = [
"cc",
"libc",
@@ -220,20 +219,19 @@ dependencies = [
]
[[package]]
name = "dirs-next"
version = "2.0.0"
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"cfg-if",
"dirs-sys-next",
"dirs-sys",
]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users",
@@ -257,15 +255,15 @@ dependencies = [
[[package]]
name = "event-listener"
version = "2.5.2"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]]
name = "fastrand"
version = "1.7.0"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf"
checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499"
dependencies = [
"instant",
]
@@ -278,11 +276,10 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "form_urlencoded"
version = "1.0.1"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191"
checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8"
dependencies = [
"matches",
"percent-encoding",
]
@@ -294,15 +291,15 @@ checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
[[package]]
name = "futures-core"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3"
checksum = "4e5aa3de05362c3fb88de6531e6296e85cde7739cccad4b9dfeeb7f6ebce56bf"
[[package]]
name = "futures-io"
version = "0.3.21"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b"
checksum = "bbf4d2a7a308fd4578637c0b17c7e1c7ba127b8f6ba00b29f717e9655d85eb68"
[[package]]
name = "futures-lite"
@@ -321,18 +318,18 @@ dependencies = [
[[package]]
name = "getrandom"
version = "0.2.6"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad"
checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6"
dependencies = [
"cfg-if",
"libc",
"wasi 0.10.2+wasi-snapshot-preview1",
"wasi",
]
[[package]]
name = "git-repo-manager"
version = "0.7.1"
version = "0.7.8"
dependencies = [
"clap",
"comfy-table",
@@ -352,9 +349,9 @@ dependencies = [
[[package]]
name = "git2"
version = "0.14.4"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0155506aab710a86160ddb504a480d2964d7ab5b9e62419be69e0032bc5931c"
checksum = "2994bee4a3a6a51eb90c218523be382fd7ea09b16380b9312e9dbe955ff7c7d1"
dependencies = [
"bitflags",
"libc",
@@ -367,18 +364,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.11.2"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
[[package]]
name = "heck"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
dependencies = [
"unicode-segmentation",
]
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "heck"
@@ -397,9 +385,9 @@ dependencies = [
[[package]]
name = "http"
version = "0.2.7"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff8670570af52249509a86f5e3e18a08c60b177071826898fde8997cf5f6bfbb"
checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399"
dependencies = [
"bytes",
"fnv",
@@ -408,20 +396,19 @@ dependencies = [
[[package]]
name = "idna"
version = "0.2.3"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6"
dependencies = [
"matches",
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "indexmap"
version = "1.8.1"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f647032dfaa1f8b6dc29bd3edb7bbef4861b8b8007ebb118d6db284fd59f6ee"
checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
dependencies = [
"autocfg",
"hashbrown",
@@ -467,9 +454,9 @@ dependencies = [
[[package]]
name = "itoa"
version = "1.0.2"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d"
checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754"
[[package]]
name = "jobserver"
@@ -488,15 +475,15 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.126"
version = "0.2.133"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836"
checksum = "c0f80d65747a3e43d1596c7c5492d95d5edddaabd45a7fcdb02b95f644164966"
[[package]]
name = "libgit2-sys"
version = "0.13.4+1.4.2"
version = "0.14.0+1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0fa6563431ede25f5cc7f6d803c6afbc1c5d3ad3d4925d12c882bf2b526f5d1"
checksum = "47a00859c70c8a4f7218e6d1cc32875c4b55f6799445b842b0d8ed5e4c3d959b"
dependencies = [
"cc",
"libc",
@@ -532,9 +519,9 @@ dependencies = [
[[package]]
name = "libz-sys"
version = "1.1.6"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e7e15d7610cce1d9752e137625f14e61a28cd45929b6e12e47b50fe154ee2e"
checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf"
dependencies = [
"cc",
"libc",
@@ -542,17 +529,11 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linked-hash-map"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
[[package]]
name = "lock_api"
version = "0.4.7"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53"
checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df"
dependencies = [
"autocfg",
"scopeguard",
@@ -567,12 +548,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "matches"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
[[package]]
name = "memchr"
version = "2.5.0"
@@ -587,21 +562,21 @@ checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d"
[[package]]
name = "mio"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "713d550d9b44d89174e066b7a6217ae06234c10cb47819a88290d2b353c31799"
checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf"
dependencies = [
"libc",
"log",
"wasi 0.11.0+wasi-snapshot-preview1",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.12.0"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225"
checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1"
[[package]]
name = "openssl-probe"
@@ -611,18 +586,18 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-src"
version = "111.20.0+1.1.1o"
version = "111.22.0+1.1.1q"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92892c4f87d56e376e469ace79f1128fdaded07646ddf73aa0be4706ff712dec"
checksum = "8f31f0d509d1c1ae9cada2f9539ff8f37933831fd5098879e482aa687d659853"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.73"
version = "0.9.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d5fd19fb3e0a8191c1e34935718976a3e70c112ab9a24af6d7cadccd9d90bc0"
checksum = "e5f9bd0c2710541a3cda73d6f9ac4f1b240de4ae261065d309dbe73d9dceb42f"
dependencies = [
"autocfg",
"cc",
@@ -634,9 +609,9 @@ dependencies = [
[[package]]
name = "os_str_bytes"
version = "6.1.0"
version = "6.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21326818e99cfe6ce1e524c2a805c189a99b5ae555a35d19f9a284b427d86afa"
checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff"
[[package]]
name = "parking"
@@ -646,9 +621,9 @@ checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72"
[[package]]
name = "parking_lot"
version = "0.12.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58"
checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
dependencies = [
"lock_api",
"parking_lot_core",
@@ -680,24 +655,24 @@ dependencies = [
[[package]]
name = "percent-encoding"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e"
[[package]]
name = "pin-project"
version = "1.0.10"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58ad3879ad3baf4e44784bc6a718a8698867bb991f8ce24d1bcbe2cfb4c3a75e"
checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.0.10"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "744b6f092ba29c3650faf274db506afd39944f48420f6c86b17cfe0ee1cb36bb"
checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55"
dependencies = [
"proc-macro2",
"quote",
@@ -718,10 +693,11 @@ checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"
[[package]]
name = "polling"
version = "2.2.0"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685404d509889fade3e86fe3a5803bca2ec09b0c0778d5ada6ec8bf7a8de5259"
checksum = "899b00b9c8ab553c743b3e11e87c5c7d423b2a2de229ba95b24a756344748011"
dependencies = [
"autocfg",
"cfg-if",
"libc",
"log",
@@ -755,18 +731,18 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.39"
version = "1.0.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f"
checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.18"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1"
checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179"
dependencies = [
"proc-macro2",
]
@@ -810,9 +786,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.2.13"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42"
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
dependencies = [
"bitflags",
]
@@ -830,9 +806,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.5.6"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1"
checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b"
dependencies = [
"aho-corasick",
"memchr",
@@ -841,9 +817,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.6.26"
version = "0.6.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64"
checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244"
[[package]]
name = "remove_dir_all"
@@ -856,15 +832,15 @@ dependencies = [
[[package]]
name = "rustversion"
version = "1.0.6"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2cc38e8fa666e2de3c4aba7edeb5ffc5246c1c2ed0e3d17e560aeeba736b23f"
checksum = "97477e48b4cf8603ad5f7aaf897467cf42ab4218a38ef76fb14c2d6773a6d6a8"
[[package]]
name = "ryu"
version = "1.0.10"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695"
checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09"
[[package]]
name = "schannel"
@@ -884,18 +860,18 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "serde"
version = "1.0.137"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ea8d54c77f8315140a05f4c7237403bf38b72704d031543aa1d16abbf517d1"
checksum = "728eb6351430bccb993660dfffc5a72f91ccc1295abaa8ce19b27ebe4f75568b"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.137"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f26faba0c3959972377d3b2d306ee9f71faee9714294e41bb777f83f88578be"
checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c"
dependencies = [
"proc-macro2",
"quote",
@@ -904,9 +880,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.81"
version = "1.0.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c"
checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44"
dependencies = [
"itoa",
"ryu",
@@ -915,23 +891,24 @@ dependencies = [
[[package]]
name = "serde_yaml"
version = "0.8.24"
version = "0.9.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707d15895415db6628332b737c838b88c598522e4dc70647e59b72312924aebc"
checksum = "8613d593412a0deb7bbd8de9d908efff5a0cb9ccd8f62c641e7b2ed2f57291d1"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"yaml-rust",
"unsafe-libyaml",
]
[[package]]
name = "shellexpand"
version = "2.1.0"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83bdb7831b2d85ddf4a7b148aa19d0587eddbe8671a436b7bd1182eaad0f2829"
checksum = "7ccc8076840c4da029af4f87e4e8daeb0fca6b87bbb02e10cb60b791450e11e4"
dependencies = [
"dirs-next",
"dirs",
]
[[package]]
@@ -966,9 +943,12 @@ dependencies = [
[[package]]
name = "slab"
version = "0.4.6"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32"
checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef"
dependencies = [
"autocfg",
]
[[package]]
name = "sluice"
@@ -983,15 +963,15 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.8.0"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83"
checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1"
[[package]]
name = "socket2"
version = "0.4.4"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0"
checksum = "02e2d2db9033d13a1567121ddd7a095ee144db4e1ca1b1bda3419bc0da294ebd"
dependencies = [
"libc",
"winapi",
@@ -1005,17 +985,17 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strum"
version = "0.23.0"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cae14b91c7d11c9a851d3fbc80a963198998c2a64eec840477fa92d8ce9b70bb"
checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f"
[[package]]
name = "strum_macros"
version = "0.23.1"
version = "0.24.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bb0dc7ee9c15cea6199cde9a127fa16a4c5819af85395457ad72d68edc85a38"
checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59"
dependencies = [
"heck 0.3.3",
"heck",
"proc-macro2",
"quote",
"rustversion",
@@ -1024,9 +1004,9 @@ dependencies = [
[[package]]
name = "syn"
version = "1.0.95"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbaf6116ab8924f39d52792136fb74fd60a80194cf1b1c6ffa6453eef1c3f942"
checksum = "52205623b1b0f064a4e71182c3b18ae902267282930c6d5462c91b859668426e"
dependencies = [
"proc-macro2",
"quote",
@@ -1064,24 +1044,24 @@ dependencies = [
[[package]]
name = "textwrap"
version = "0.15.0"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb"
checksum = "949517c0cf1bf4ee812e2e07e08ab448e3ae0d23472aee8a06c985f0c8815b16"
[[package]]
name = "thiserror"
version = "1.0.31"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a"
checksum = "c53f98874615aea268107765aa1ed8f6116782501d18e53d08b471733bea6c85"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.31"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a"
checksum = "f8b463991b4eab2d801e724172285ec4195c650e8ec79b149e6c2a8e6dd3f783"
dependencies = [
"proc-macro2",
"quote",
@@ -1114,9 +1094,9 @@ dependencies = [
[[package]]
name = "tracing"
version = "0.1.34"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0ecdcb44a79f0fe9844f0c4f33a342cbcbb5117de8001e6ba0dc2351327d09"
checksum = "2fce9567bd60a67d08a16488756721ba392f24f29006402881e43b19aac64307"
dependencies = [
"cfg-if",
"log",
@@ -1127,9 +1107,9 @@ dependencies = [
[[package]]
name = "tracing-attributes"
version = "0.1.21"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6b8ad3567499f98a1db7a752b07a7c8c7c7c34c332ec00effb2b0027974b7c"
checksum = "11c75893af559bc8e10716548bdef5cb2b983f8e637db9d0e15126b61b484ee2"
dependencies = [
"proc-macro2",
"quote",
@@ -1138,11 +1118,11 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.26"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f54c8ca710e81886d498c2fd3331b56c93aa248d49de2222ad2742247c60072f"
checksum = "5aeea4303076558a00714b823f9ad67d58a3bbda1df83d8827d21193156e22f7"
dependencies = [
"lazy_static",
"once_cell",
]
[[package]]
@@ -1163,40 +1143,39 @@ checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992"
[[package]]
name = "unicode-ident"
version = "1.0.0"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee"
checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd"
[[package]]
name = "unicode-normalization"
version = "0.1.19"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9"
checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.9.0"
name = "unicode-width"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99"
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
[[package]]
name = "unicode-width"
version = "0.1.9"
name = "unsafe-libyaml"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973"
checksum = "c1e5fa573d8ac5f1a856f8d7be41d390ee973daf97c806b2c1a465e4e1406e68"
[[package]]
name = "url"
version = "2.2.2"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643"
dependencies = [
"form_urlencoded",
"idna",
"matches",
"percent-encoding",
]
@@ -1227,12 +1206,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
@@ -1321,12 +1294,3 @@ name = "windows_x86_64_msvc"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680"
[[package]]
name = "yaml-rust"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
dependencies = [
"linked-hash-map",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "git-repo-manager"
version = "0.7.1"
version = "0.7.8"
edition = "2021"
authors = [
@@ -28,7 +28,7 @@ rust-version = "1.57"
license = "GPL-3.0-only"
[profile.e2e-tests]
inherits = "release"
inherits = "dev"
[lib]
name = "grm"
@@ -44,33 +44,33 @@ path = "src/grm/main.rs"
version = "=0.5.9"
[dependencies.serde]
version = "=1.0.137"
version = "=1.0.145"
features = ["derive"]
[dependencies.git2]
version = "=0.14.4"
version = "=0.15.0"
[dependencies.shellexpand]
version = "=2.1.0"
version = "=2.1.2"
[dependencies.clap]
version = "=3.1.18"
version = "=3.2.22"
features = ["derive", "cargo"]
[dependencies.console]
version = "=0.15.0"
version = "=0.15.1"
[dependencies.regex]
version = "=1.5.6"
version = "=1.6.0"
[dependencies.comfy-table]
version = "=5.0.1"
version = "=6.1.0"
[dependencies.serde_yaml]
version = "=0.8.24"
version = "=0.9.13"
[dependencies.serde_json]
version = "=1.0.81"
version = "=1.0.85"
[dependencies.isahc]
version = "=1.7.2"

View File

@@ -1,47 +1,69 @@
set positional-arguments
target := "x86_64-unknown-linux-musl"
static_target := "x86_64-unknown-linux-musl"
check: test
check: fmt-check lint test
cargo check
cargo fmt --check
cargo clippy --no-deps -- -Dwarnings
clean:
cargo clean
git clean -f -d -X
fmt:
cargo fmt
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:
cargo clippy --no-deps
cargo clippy --no-deps -- -Dwarnings
git ls-files | grep '\.sh$' | xargs -L 1 shellcheck --norc
lint-fix:
cargo clippy --no-deps --fix
release:
cargo build --release --target {{target}}
build-release:
cargo build --release
build-release-static:
cargo build --release --target {{static_target}} --features=static-build
pushall:
for r in $(git remote) ; do \
for branch in develop master ; do \
git push $r $branch ; \
done ; \
done
release-patch:
./release.sh patch
test-binary:
env \
GITHUB_API_BASEURL=http://rest:5000/github \
GITLAB_API_BASEURL=http://rest:5000/gitlab \
cargo build --target {{target}} --profile e2e-tests --features=static-build
cargo build --profile e2e-tests --target {{static_target}} --features=static-build
install:
cargo install --path .
install-static:
cargo install --target {{target}} --features=static-build --path .
cargo install --target {{static_target}} --features=static-build --path .
build:
cargo build
build-static:
cargo build --target {{target}} --features=static-build
cargo build --target {{static_target}} --features=static-build
test: test-unit test-integration test-e2e
test-unit:
cargo test --lib --bins
test-unit +tests="":
cargo test --lib --bins -- --show-output {{tests}}
test-integration:
cargo test --test "*"
@@ -52,9 +74,9 @@ test-e2e +tests=".": test-binary
&& docker-compose build \
&& docker-compose run \
--rm \
-v $PWD/../target/{{target}}/e2e-tests/grm:/grm \
-v $PWD/../target/x86_64-unknown-linux-musl/e2e-tests/grm:/grm \
pytest \
"GRM_BINARY=/grm ALTERNATE_DOMAIN=alternate-rest python3 -m pytest -p no:cacheprovider --color=yes "$@"" \
"GRM_BINARY=/grm ALTERNATE_DOMAIN=alternate-rest python3 -m pytest --exitfirst -p no:cacheprovider --color=yes "$@"" \
&& docker-compose rm --stop -f
update-dependencies: update-cargo-dependencies
@@ -65,3 +87,6 @@ update-cargo-dependencies:
&& . ./venv/bin/activate \
&& pip --disable-pip-version-check install -r ./requirements.txt > /dev/null \
&& ./update-cargo-dependencies.py
wait:
read -p "[ENTER] to continue "

View File

@@ -1,7 +1,10 @@
# GRM — Git Repository Manager
GRM helps you manage git repositories in a declarative way. Configure your
repositories in a [TOML](https://toml.io/) file, GRM does the rest.
repositories in a [TOML](https://toml.io/) or YAML file, GRM does the rest.
Also, GRM can be used to work with git worktrees in an opinionated,
straightforward fashion.
**Take a look at the [official documentation](https://hakoerber.github.io/git-repo-manager/)
for installation & quickstart.**
@@ -34,23 +37,26 @@ like Terraform for your local git repositories. Write a config, run the tool, an
your repos are ready. The only thing that is tracked by git it the list of
repositories itself.
# Future & Ideas
* Operations over all repos (e.g. pull)
* Show status of managed repositories (dirty, compare to remotes, ...)
# Optional Features
* Support multiple file formats (YAML, JSON).
* Add systemd timer unit to run regular syncs
# Crates
* [`toml`](https://docs.rs/toml/) for the configuration file
* [`serde`](https://docs.rs/serde/) because we're using Rust, after all
* [`git2`](https://docs.rs/git2/), a safe wrapper around `libgit2`, for all git operations
* [`clap`](https://docs.rs/clap/), [`console`](https://docs.rs/console/) and [`shellexpand`](https://docs.rs/shellexpand) for good UX
* [`toml`](https://docs.rs/toml/) for the configuration file.
* [`serde`](https://docs.rs/serde/), together with
[`serde_yaml`](https://docs.rs/serde_yaml/) and
[`serde_json`](https://docs.rs/serde_json/). Because we're using Rust, after
all.
* [`git2`](https://docs.rs/git2/), a safe wrapper around `libgit2`, for all git operations.
* [`clap`](https://docs.rs/clap/), [`console`](https://docs.rs/console/), [`comfy_table`](https://docs.rs/comfy-table/) and [`shellexpand`](https://docs.rs/shellexpand) for good UX.
* [`isahc`](https://docs.rs/isahc/) as the HTTP client for forge integrations.
# Links
* [crates.io](https://crates.io/crates/git-repo-manager)
# Mirrors
This repository can be found on multiple forges:
* https://github.com/hakoerber/git-repo-manager
* https://code.hkoerber.de/hannes/git-repo-manager/
* https://codeberg.org/hakoerber/git-repo-manager
* https://git.sr.ht/~hkoerber/git-repo-manager

View File

@@ -7,3 +7,8 @@ title = "Git Repo Manager"
[output.html]
mathjax-support = true
# [output.linkcheck]
# follow-web-links = true
# traverse-parent-directories = false
# warning-policy = "error"

View File

@@ -1,9 +1,20 @@
# Summary
- [Overview](./overview.md)
[Overview](./overview.md)
- [Installation](./installation.md)
- [Repository trees](./repos.md)
- [Tutorial](./tutorial.md)
- [Managing Repositories](./repos.md)
- [Local Configuration](./local_configuration.md)
- [Forge Integrations](./forge_integration.md)
- [Git Worktrees](./worktrees.md)
- [Forge Integrations](./forge_integration.md)
- [Working with Worktrees](./worktree_working.md)
- [Worktrees and Remotes](./worktree_remotes.md)
- [Behavior Details](./worktree_behavior.md)
- [FAQ](./faq.md)
- [Contributing](./contributing.md)
- [Developer Documentation](./developing.md)
- [Testing](./testing.md)
- [Dependency updates](./dependency_updates.md)
- [Releases](./releases.md)
- [Formatting & Style](./formatting_and_style.md)
- [The Docs Themselves](./documentation.md)

View File

@@ -1 +0,0 @@
../../CONTRIBUTING.md

View File

@@ -0,0 +1,10 @@
# Dependency updates
Rust has the same problem as the node ecosystem, just a few magnitudes smaller:
Dependency sprawl. GRM has a dozen direct dependencies, but over 150 transitive
ones.
To keep them up to date, there is a script:
`depcheck/update-cargo-dependencies.py`. It updates direct dependencies to the
latest stable version and updates transitive dependencies where possible. To run
it, use `just update-dependencies`, which will create commits for each update.

69
docs/src/developing.md Normal file
View File

@@ -0,0 +1,69 @@
# Overview
GRM is still in very early development. I started GRM mainly to scratch my own
itches (and am heavily dogfooding it). If you have a new use case for GRM, go
for it!
## Contributing
To contribute, just fork the repo and create a pull request against `develop`.
If you plan bigger changes, please consider opening an issue first, so we can
discuss it.
If you want, add yourself to the `CONTRIBUTORS` file in your pull request.
## Branching strategy
The branching strategy is a simplified
[git-flow](https://nvie.com/posts/a-successful-git-branching-model/).
* `master` is the "production" branch. Each commit is a new release.
* `develop` is the branch where new stuff is coming in.
* feature branches branch off of `develop` and merge back into it.
Feature branches are not required, there are also changes happening directly on
`develop`.
## Required tooling
You will need the following tools:
* Rust (obviously) (easiest via `rustup`), with the nightly toolchain
* Python3
* [`just`](https://github.com/casey/just), a command runner like `make`. See
[here](https://github.com/casey/just#installation) for installation
instructions (it's most likely just a simple `cargo install just`).
* Docker & docker-compose for the e2e tests
* `black` and `shfmt` for formatting.
* `shellcheck` for shell script linting
* `mdbook` for the documentation
Here are the tools:
| Distribution | Command |
| ------------- | --------------------------------------------------------------------------------------------------- |
| Arch Linux | `pacman -S --needed python3 rustup just docker docker-compose python-black shfmt shellcheck mdbook` |
| Ubuntu/Debian | `apt-get install --no-install-recommends python3 docker.io docker-compose black shellcheck` |
Note that you will have to install `just` and `mdbook` manually on Ubuntu (e.g.
via `cargo install just mdbook` if your rust build environment is set up
correctly). Same for `shfmt`, which may just be a `go install
mvdan.cc/sh/v3/cmd/shfmt@latest`, depending on your go build environment.
For details about rustup and the toolchains, see [the installation
section](./installation.md).
## FAQ
### Why nightly?
For now, GRM requires the nightly toolchain for two reasons:
* [`io_error_more`](https://github.com/rust-lang/rust/issues/86442) to get
better error messages on IO errors
* [`const_option_ext`](https://github.com/rust-lang/rust/issues/91930) to have
static variables read from the environment that fall back to hard coded
defaults
Honestly, both of those are not really necessary or can be handled without
nightly. It's just that I'm using nightly anyway.

11
docs/src/documentation.md Normal file
View File

@@ -0,0 +1,11 @@
# Documentation
The documentation lives in the `docs` folder and uses
[mdBook](https://github.com/rust-lang/mdBook). Please document new user-facing
features here!
Using [GitHub actions](https://github.com/features/actions), the documentation
on `master` is automatically published to [the project
homepage](https://hakoerber.github.io/git-repo-manager/) via GitHub pages. See
`.github/workflows/gh-pages.yml` for the configuration of GitHub Actions.

View File

@@ -1,10 +1,3 @@
# FAQ
## Why is the nightly toolchain required?
Building GRM currently requires nightly features due to the usage of
[`std::path::Path::is_symlink()`](https://doc.rust-lang.org/std/fs/struct.FileType.html#method.is_symlink).
See the [tracking issue](https://github.com/rust-lang/rust/issues/85748).
`is_symlink()` is actually available in rustc 1.57, so it will be on stable in
the near future. This would mean that GRM can be built using the stable toolchain!
Currently empty, as there are no questions that are asked frequently :D

View File

@@ -1,23 +1,24 @@
# Forge Integrations
In addition to manging repositories locally, `grm` also integrates with source
In addition to managing repositories locally, `grm` also integrates with source
code hosting platforms. Right now, the following platforms are supported:
* [GitHub](https://github.com/)
* [GitLab](https://gitlab.com/)
Imagine you are just starting out with `grm` and want to clone all your repositories
from GitHub. This is as simple as:
Imagine you are just starting out with `grm` and want to clone all your
repositories from GitHub. This is as simple as:
```bash
$ grm repos sync remote --provider github --owner --token-command "pass show github_grm_access_token" --path ~/projects
```
You will end up with your projects cloned into `~/projects/{your_github_username}/`
You will end up with your projects cloned into
`~/projects/{your_github_username}/`
## Authentication
The only currently supported authentication option is using personal access
The only currently supported authentication option is using a personal access
token.
### GitHub
@@ -27,15 +28,15 @@ See the GitHub documentation for personal access tokens:
The only required permission is the "repo" scope.
### GitHub
### GitLab
See the GitLab documentation for personal access tokens:
[Link](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html).
The required scopes are a bit weird. Actually, the following should suffice:
* * `read_user` to get user information (required to get the current authenticated
user name for the `--owner` filter.
* `read_user` to get user information (required to get the current
authenticated user name for the `--owner` filter.
* A scope that allows reading private repositories. (`read_repository` is just
for *cloning* private repos). This unfortunately does not exist.
@@ -106,7 +107,7 @@ The options in the file map to the command line options of the `grm repos sync
remote` command.
You'd then run the `grm repos sync` command the same way as with a list of
repositories in a config:
repositories in a configuration:
```bash
$ grm repos sync --config example.config.toml
@@ -120,11 +121,11 @@ $ grm repos find config --config example.config.toml > repos.toml
$ grm repos sync config --config repos.toml
```
## Using with selfhosted GitLab
## Using with self-hosted GitLab
By default, `grm` uses the default GitLab API endpoint
([https://gitlab.com](https://gitlab.com)). You can override the
endpoint by specifying the `--api-url` parameter. Like this:
([https://gitlab.com](https://gitlab.com)). You can override the endpoint by
specifying the `--api-url` parameter. Like this:
```bash
$ grm repos sync remote --provider gitlab --api-url https://gitlab.example.com [...]
@@ -138,26 +139,28 @@ can be overridden with the `--force-ssh` switch.
## About the token command
To ensure maximum flexibility, `grm` has a single way to get the token it uses
to authenticate: Specify a command that returns the token via stdout. This easily
integrates with password managers like [`pass`](https://www.passwordstore.org/).
to authenticate: Specify a command that returns the token via stdout. This
easily integrates with password managers like
[`pass`](https://www.passwordstore.org/).
Of course, you are also free to specify something like `echo mytoken` as the
command, as long as you are ok with the security implications (like having the
token in cleartext in your shell history). It may be better to have the token
command, as long as you are OK with the security implications (like having the
token in clear text in your shell history). It may be better to have the token
in a file instead and read it: `cat ~/.gitlab_token`.
Generally, use whatever you want. The command just has to return sucessfully and
return the token as the first line of stdout.
Generally, use whatever you want. The command just has to return successfully
and return the token as the first line of stdout.
## Examples
Maybe you just want to locally clone all repos from your github user?
Maybe you just want to locally clone all repos from your GitHub user?
```bash
$ grm repos sync remote --provider github --owner --root ~/github_projects --token-command "pass show github_grm_access_token"
```
This will clone all repositories into `~/github_projects/{your_github_username}`.
This will clone all repositories into
`~/github_projects/{your_github_username}`.
If instead you want to clone **all** repositories you have access to (e.g. via
organizations or other users' private repos you have access to), just change the
@@ -172,12 +175,13 @@ $ grm repos sync remote --provider github --access --root ~/github_projects --to
### GitHub
Unfortunately, GitHub does not have a nice API endpoint to get **private**
repositories for a certain user ([`/users/{user}/repos/`](https://docs.github.com/en/rest/repos/repos#list-repositories-for-a-user) only returns public
repositories).
repositories for a certain user
([`/users/{user}/repos/`](https://docs.github.com/en/rest/repos/repos#list-repositories-for-a-user)
only returns public repositories).
Therefore, using `--user {user}` will only show public repositories for GitHub.
Note that this does not apply to `--access`: If you have access to another user's
private repository, it will be listed.
Note that this does not apply to `--access`: If you have access to another
user's private repository, it will be listed.
## Adding integrations
@@ -197,9 +201,9 @@ Each repo has to have the following properties:
* A name (which also acts as the identifier for diff between local and remote
repositories)
* An SSH url to push to
* An HTTPS url to clone and fetch from
* An SSH URL to push to
* An HTTPS URL to clone and fetch from
* A flag that marks the repository as private
If you plan to implement another forge, please first open an issue so we can
go through the required setup. I'm happy to help!
If you plan to implement another forge, please first open an issue so we can go
through the required setup. I'm happy to help!

View File

@@ -0,0 +1,45 @@
# Formatting & Style
## Code formatting
I'm allergic to discussions about formatting. I'd rather make the computer do it
for me.
For Rust, just use `cargo fmt`. For Python, use
[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,
use [`shfmt`](https://github.com/mvdan/sh).
To autoformat all code, use `just fmt`
## Style
Honestly, no idea about style. I'm still learning Rust, so I'm trying to find a
good style. Just try to keep it consistent when you add code.
## Linting
You can use `just lint` to run all lints.
### Rust
Clippy is the guard that prevents shitty code from getting into the code base.
When running `just check`, any clippy suggestions will make the command fail.
So make clippy happy! The easiest way:
* Commit your changes (so clippy can change safely).
* Run `cargo clippy --fix` to do the easy changes automatically.
* Run `cargo clippy` and take a look at the messages.
Until now, I had no need to override or silence any clippy suggestions.
### Shell
`shellcheck` lints all shell scripts. As they change very rarely, this is not
too important.
## Unsafe code
Any `unsafe` code is forbidden for now globally via `#![forbid(unsafe_code)]`.
I cannot think of any reason GRM may need `unsafe`. If it comes up, it needs to
be discussed.

View File

@@ -2,8 +2,9 @@
## Installation
Building GRM currently requires the nightly Rust toolchain. The easiest way
is using [`rustup`](https://rustup.rs/). Make sure that rustup is properly installed.
Building GRM currently requires the nightly Rust toolchain. The easiest way is
using [`rustup`](https://rustup.rs/). Make sure that rustup is properly
installed.
Make sure that the nightly toolchain is installed:
@@ -15,7 +16,7 @@ Then, install the build dependencies:
| Distribution | Command |
| ------------- | ------------------------------------------------------------------------------ |
| Archlinux | `pacman -S --needed gcc openssl pkg-config` |
| Arch Linux | `pacman -S --needed gcc openssl pkg-config` |
| Ubuntu/Debian | `apt-get install --no-install-recommends pkg-config gcc libssl-dev zlib1g-dev` |
Then, it's a simple command to install the latest stable version:
@@ -33,12 +34,12 @@ $ cargo +nightly install --git https://github.com/hakoerber/git-repo-manager.git
## Static build
Note that by default, you will get a dynamically linked executable.
Alternatively, you can also build a statically linked binary. For this, you
will need `musl` and a few other build dependencies installed installed:
Alternatively, you can also build a statically linked binary. For this, you will
need `musl` and a few other build dependencies installed installed:
| Distribution | Command |
| ------------- | --------------------------------------------------------------------------- |
| Archlinux | `pacman -S --needed gcc musl perl make` |
| Arch Linux | `pacman -S --needed gcc musl perl make` |
| Ubuntu/Debian | `apt-get install --no-install-recommends gcc musl-tools libc-dev perl make` |
(`perl` and `make` are required for the OpenSSL build script)

View File

@@ -0,0 +1,83 @@
# Local Configuration
When managing multiple git repositories with GRM, you'll generally have a
configuration file containing information about all the repos you have. GRM then
makes sure that you repositories match that configuration. If they don't exist
yet, it will clone them. It will also make sure that all remotes are configured
properly.
Let's try it out:
## Get the example configuration
```bash
$ curl --proto '=https' --tlsv1.2 -sSfO https://raw.githubusercontent.com/hakoerber/git-repo-manager/master/example.config.toml
```
Then, you're ready to run the first sync. This will clone all configured
repositories and set up the remotes.
```bash
$ grm repos sync config --config example.config.toml
[] Cloning into "/home/me/projects/git-repo-manager" from "https://code.hkoerber.de/hannes/git-repo-manager.git"
[] git-repo-manager: Repository successfully cloned
[] git-repo-manager: Setting up new remote "github" to "https://github.com/hakoerber/git-repo-manager.git"
[] git-repo-manager: OK
[] Cloning into "/home/me/projects/dotfiles" from "https://github.com/hakoerber/dotfiles.git"
[] dotfiles: Repository successfully cloned
[] dotfiles: OK
```
If you run it again, it will report no changes:
```
$ grm repos sync config -c example.config.toml
[✔] git-repo-manager: OK
[✔] dotfiles: OK
```
### Generate your own configuration
Now, if you already have a few repositories, it would be quite laborious to
write a configuration from scratch. Luckily, GRM has a way to generate a
configuration from an existing file tree:
```bash
$ grm repos find local ~/your/project/root > config.toml
```
This will detect all repositories and remotes and write them to `config.toml`.
### Show the state of your projects
```bash
$ grm repos status --config example.config.toml
╭──────────────────┬──────────┬────────┬───────────────────┬────────┬─────────╮
│ Repo ┆ Worktree ┆ Status ┆ Branches ┆ HEAD ┆ Remotes │
╞══════════════════╪══════════╪════════╪═══════════════════╪════════╪═════════╡
│ git-repo-manager ┆ ┆ ✔ ┆ branch: master ┆ master ┆ github │
│ ┆ ┆ ┆ <origin/master> ✔ ┆ ┆ origin │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ dotfiles ┆ ┆ ✔ ┆ ┆ Empty ┆ origin │
╰──────────────────┴──────────┴────────┴───────────────────┴────────┴─────────╯
```
You can also use `status` without `--config` to check the repository you're
currently in:
```
$ cd ~/example-projects/dotfiles
$ grm repos status
╭──────────┬──────────┬────────┬──────────┬───────┬─────────╮
│ Repo ┆ Worktree ┆ Status ┆ Branches ┆ HEAD ┆ Remotes │
╞══════════╪══════════╪════════╪══════════╪═══════╪═════════╡
│ dotfiles ┆ ┆ ✔ ┆ ┆ Empty ┆ origin │
╰──────────┴──────────┴────────┴──────────┴───────┴─────────╯
```
## YAML
By default, the repo configuration uses TOML. If you prefer YAML, just give it a
YAML file instead (file ending does not matter, `grm` will figure out the
format). For generating a configuration, pass `--format yaml` to `grm repo
find` which generates a YAML configuration instead of a TOML configuration.

View File

@@ -1,8 +1,8 @@
# Overview
Welcome! This is the documentation for [Git Repo
Manager](https://github.com/hakoerber/git-repo-manager/) (GRM for short), a
tool that helps you manage git repositories.
Manager](https://github.com/hakoerber/git-repo-manager/) (GRM for short), a tool
that helps you manage git repositories in a declarative way.
GRM helps you manage git repositories in a declarative way. Configure your
repositories in a TOML or YAML file, GRM does the rest. Take a look at [the
@@ -12,12 +12,12 @@ to get a feel for the way you configure your repositories. See the [repository
tree chapter](./repos.md) for details.
GRM also provides some tooling to work with single git repositories using
`git-worktree`. See [the worktree chapter](./worktree.md) for more details.
`git-worktree`. See [the worktree chapter](./worktrees.md) for more details.
## Why use GRM?
If you're working with a lot of git repositories, GRM can help you to manage them
in an easy way:
If you're working with a lot of git repositories, GRM can help you to manage
them in an easy way:
* You want to easily clone many repositories to a new machine.
* You want to change remotes for multiple repositories (e.g. because your GitLab

27
docs/src/releases.md Normal file
View File

@@ -0,0 +1,27 @@
# Releases
To make a release, make sure you are on a clean `develop` branch, sync your
remotes and then run `./release (major|minor|patch)`. It will handle a
git-flow-y release, meaning that it will perform a merge from `develop` to
`master`, create a git tag, sync all remotes and run `cargo publish`.
Make sure to run `just check` before releasing to make sure that nothing is
broken.
As GRM is still `v0.x`, there is not much consideration for backwards
compatibility. Generally, update the patch version for small stuff and the minor
version for bigger / backwards incompatible changes.
Generally, it's good to regularly release a new patch release with [updated
dependencies](./dependency_updates.md). As `./release.sh patch` is exposed as a
Justfile target (`release-patch`), it's possible to do both in one step:
```bash
$ just update-dependencies check release-patch
```
## Release notes
There are currently no release notes. Things are changing quite quickly and
there is simply no need for a record of changes (except the git history of
course).

View File

@@ -1,82 +1,13 @@
# Managing tree of git repositories
# Managing Repositories
When managing multiple git repositories with GRM, you'll generally have a
configuration file containing information about all the repos you have. GRM then
makes sure that you repositories match that config. If they don't exist yet, it
will clone them. It will also make sure that all remotes are configured properly.
GRM helps you manage a bunch of git repositories easily. There are generally two
ways to go about that:
Let's try it out:
You can either manage a list of repositories in a TOML or YAML file, and use GRM
to sync the configuration with the state of the repository.
## Get the example configuration
Or, you can pull repository information from a forge (e.g. GitHub, GitLab) and
clone the repositories.
```bash
$ curl --proto '=https' --tlsv1.2 -sSfO https://raw.githubusercontent.com/hakoerber/git-repo-manager/master/example.config.toml
```
Then, you're ready to run the first sync. This will clone all configured repositories
and set up the remotes.
```bash
$ grm repos sync config --config example.config.toml
[] Cloning into "/home/me/projects/git-repo-manager" from "https://code.hkoerber.de/hannes/git-repo-manager.git"
[] git-repo-manager: Repository successfully cloned
[] git-repo-manager: Setting up new remote "github" to "https://github.com/hakoerber/git-repo-manager.git"
[] git-repo-manager: OK
[] Cloning into "/home/me/projects/dotfiles" from "https://github.com/hakoerber/dotfiles.git"
[] dotfiles: Repository successfully cloned
[] dotfiles: OK
```
If you run it again, it will report no changes:
```
$ grm repos sync config -c example.config.toml
[✔] git-repo-manager: OK
[✔] dotfiles: OK
```
### Generate your own configuration
Now, if you already have a few repositories, it would be quite laborious to write
a configuration from scratch. Luckily, GRM has a way to generate a configuration
from an existing file tree:
```bash
$ grm repos find local ~/your/project/root > config.toml
```
This will detect all repositories and remotes and write them to `config.toml`.
### Show the state of your projects
```bash
$ grm repos status --config example.config.toml
╭──────────────────┬──────────┬────────┬───────────────────┬────────┬─────────╮
│ Repo ┆ Worktree ┆ Status ┆ Branches ┆ HEAD ┆ Remotes │
╞══════════════════╪══════════╪════════╪═══════════════════╪════════╪═════════╡
│ git-repo-manager ┆ ┆ ✔ ┆ branch: master ┆ master ┆ github │
│ ┆ ┆ ┆ <origin/master> ✔ ┆ ┆ origin │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┤
│ dotfiles ┆ ┆ ✔ ┆ ┆ Empty ┆ origin │
╰──────────────────┴──────────┴────────┴───────────────────┴────────┴─────────╯
```
You can also use `status` without `--config` to check the repository you're currently
in:
```
$ cd ~/example-projects/dotfiles
$ grm repos status
╭──────────┬──────────┬────────┬──────────┬───────┬─────────╮
│ Repo ┆ Worktree ┆ Status ┆ Branches ┆ HEAD ┆ Remotes │
╞══════════╪══════════╪════════╪══════════╪═══════╪═════════╡
│ dotfiles ┆ ┆ ✔ ┆ ┆ Empty ┆ origin │
╰──────────┴──────────┴────────┴──────────┴───────┴─────────╯
```
## YAML
By default, the repo configuration uses TOML. If you prefer YAML, just give it
a YAML file instead (file ending does not matter, `grm` will figure out the format
itself). For generating a configuration, pass `--format yaml` to `grm repo find`
to generate YAML instead of TOML.
There are also hybrid modes where you pull information from a forge and create a
configuration file that you can use later.

124
docs/src/testing.md Normal file
View File

@@ -0,0 +1,124 @@
# Testing
There are two distinct test suites: One for unit test (`just test-unit`) and
integration tests (`just test-integration`) that is part of the rust crate, and
a separate e2e test suite in python (`just test-e2e`).
To run all tests, run `just test`.
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 tests
that expose the wrong behavior beforehand.
The unit and integration tests are very small and only test a few self-contained
functions (like validation of certain input).
## E2E tests
The main focus of the testing setup lays on the e2e tests. Each user-facing
behavior *should* have a corresponding e2e test. These are the most important
tests, as they test functionality the user will use in the end.
The test suite is written in python and uses
[pytest](https://docs.pytest.org/en/stable/). There are helper functions that
set up temporary git repositories and remotes in a `tmpfs`.
Effectively, each tests works like this:
* Set up some prerequisites (e.g. different git repositories or configuration
files)
* Run `grm`
* Check that everything is according to expected behavior (e.g. that `grm` had
certain output and exit code, that the target repositories have certain
branches, heads and remotes, ...)
As there are many different scenarios, the tests make heavy use of the
[`@pytest.mark.parametrize`](https://docs.pytest.org/en/stable/how-to/parametrize.html#pytest-mark-parametrize)
decorator to get all permutations of input parameters (e.g. whether a
configuration exists, what a config value is set to, how the repository looks
like, ...)
Whenever you write a new test, think about the different circumstances that can
happen. What are the failure modes? What affects the behavior? Parametrize each
of these behaviors.
### Optimization
Note: You will most likely not need to read this.
Each test parameter will exponentially increase the number of tests that will be
run. As a general rule, comprehensiveness is more important than test suite
runtime (so if in doubt, better to add another parameter to catch every edge
case). But try to keep the total runtime sane. Currently, the whole `just e2e`
target runs ~8'000 tests and takes around 5 minutes on my machine, exlucding
binary and docker build time. I'd say that keeping it under 10 minutes is a good
idea.
To optimize tests, look out for two patterns: Dependency and Orthogonality
#### Dependency
If a parameter depends on another one, it makes little sense to handle them
independently. Example: You have a paramter that specifies whether a
configuration is used, and another parameter that sets a certain value in that
configuration file. It might look something like this:
```python
@pytest.mark.parametrize("use_config", [True, False])
@pytest.mark.parametrize("use_value", ["0", "1"])
def test(...):
```
This leads to 4 tests being instantiated. But there is little point in setting a
configuration value when no config is used, so the combinations `(False, "0")`
and `(False, "1")` are redundant. To remedy this, spell out the optimized
permutation manually:
```python
@pytest.mark.parametrize("config", ((True, "0"), (True, "1"), (False, None)))
def test(...):
(use_config, use_value) = config
```
This cuts down the number of tests by 25%. If you have more dependent parameters
(e.g. additional configuration values), this gets even better. Generally, this
will cut down the number of tests to
\\[ \frac{1}{o \cdot c} + \frac{1}{(o \cdot c) ^ {(n + 1)}} \\]
with \\( o \\) being the number of values of a parent parameters a parameter is
dependent on, \\( c \\) being the cardinality of the test input (so you can
assume \\( o = 1 \\) and \\( c = 2 \\) for boolean parameters), and \\( n \\)
being the number of parameters that are optimized, i.e. folded into their
dependent parameter.
As an example: Folding down two boolean parameters into one dependent parent
boolean parameter will cut down the number of tests to 62.5%!
#### Orthogonality
If different test parameters are independent of each other, there is little
point in testing their combinations. Instead, split them up into different test
functions. For boolean parameters, this will cut the number of tests in half.
So instead of this:
```python
@pytest.mark.parametrize("param1", [True, False])
@pytest.mark.parametrize("param2", [True, False])
def test(...):
```
Rather do this:
```python
@pytest.mark.parametrize("param1", [True, False])
def test_param1(...):
@pytest.mark.parametrize("param2", [True, False])
def test_param2(...):
```
The tests are running in Docker via docker-compose. This is mainly needed to
test networking functionality like GitLab integration, with the GitLab API being
mocked by a simple flask container.

183
docs/src/tutorial.md Normal file
View File

@@ -0,0 +1,183 @@
# Tutorial
Here, you'll find a quick overview over the most common functionality of GRM.
## Managing existing repositories
Let's say you have your git repositories at `~/code`. To start managing them via
GRM, first create a configuration:
```bash
grm repos find local ~/code --format yaml > ~/code/config.yml
```
The result may look something like this:
```yaml
---
trees:
- root: ~/code
repos:
- name: git-repo-manager
worktree_setup: true
remotes:
- name: origin
url: "https://github.com/hakoerber/git-repo-manager.git"
type: https
```
To apply the configuration and check whether all repositories are in sync, run
the following:
```bash
$ grm repos sync config --config ~/code/config.yml
[] git-repo-manager: OK
```
Well, obiously there are no changes. To check how changes would be applied,
let's change the name of the remote (currently `origin`):
```bash
$ sed -i 's/name: origin/name: github/' ~/code/config.yml
$ grm repos sync config --config ~/code/config.yml
[] git-repo-manager: Setting up new remote "github" to "https://github.com/hakoerber/git-repo-manager.git"
[] git-repo-manager: Deleting remote "origin"
[] git-repo-manager: OK
```
GRM replaced the `origin` remote with `github`.
The configuration (`~/code/config.yml` in this example) would usually be
something you'd track in git or synchronize between machines via some other
means. Then, on every machine, all your repositories are a single `grm repos
sync` away!
## Getting repositories from a forge
Let's say you have a bunch of repositories on GitHub and you'd like to clone
them all to your local machine.
To authenticate, you'll need to get a personal access token, as described in
[the forge documentation](./forge_integration.md#github). Let's assume you put
your token into `~/.github_token` (please don't if you're doing this "for
real"!)
Let's first see what kind of repos we can find:
```bash
$ grm repos sync remote --provider github --token-command "cat ~/.github_token" --root ~/code/github.com/ --format yaml
---
trees: []
$
```
Ummm, ok? No repos? This is because you have to *tell* GRM what to look for (if
you don't, GRM will just relax, as it's lazy).
There are different filters (see [the forge
documentation](./forge_integration.md#filters) for more info). In our case,
we'll just use the `--owner` filter to get all repos that belong to us:
```bash
$ grm repos find remote --provider github --token-command "cat ~/.github_token" --root ~/code/github.com/ --format yaml
---
trees:
- root: ~/code/github.com
repos:
- name: git-repo-manager
worktree_setup: false
remotes:
- name: origin
url: "https://github.com/hakoerber/git-repo-manager.git"
type: https
```
Nice! The format is the same as we got from `grm repos find local` above. So if
we wanted, we could save this file and use it with `grm repos sync config` as
above. But there is an even easier way: We can directly clone the repositories!
```bash
$ grm repos sync remote --provider github --token-command "cat ~/.github_token" --root ~/code/github.com/
[] Cloning into "~/code/github.com/git-repo-manager" from "https://github.com/hakoerber/git-repo-manager.git"
[] git-repo-manager: Repository successfully cloned
[] git-repo-manager: OK
```
Nice! Just to make sure, let's run the same command again:
```bash
$ grm repos sync remote --provider github --token-command "cat ~/.github_token" --root ~/code/github.com/
[] git-repo-manager: OK
```
GRM saw that the repository is already there and did nothing (remember, it's
lazy).
## Using worktrees
Worktrees are something that make it easier to work with multiple branches at
the same time in a repository. Let's say we wanted to hack on the codebase of
GRM:
```bash
$ cd ~/code/github.com/git-repo-manager
$ ls
.gitignore
Cargo.toml
...
```
Well, this is just a normal git repository. But let's try worktrees! First, we
have to convert the existing repository to use the special worktree setup. For
all worktree operations, we will use `grm worktree` (or `grm wt` for short):
```bash
$ grm wt convert
[] Conversion done
$ ls
$
```
So, the code is gone? Not really, there is just no active worktree right now. So
let's add one for `master`:
```bash
$ grm wt add master --track origin/master
[] Conversion done
$ ls
master
$ (cd ./master && git status)
On branch master
nothing to commit, working tree clean
```
Now, a single worktree is kind of pointless (if we only have one, we could also
just use the normal setup, without worktrees). So let's another one for
`develop`:
```bash
$ grm wt add develop --track origin/develop
[] Conversion done
$ ls
develop
master
$ (cd ./develop && git status)
On branch develop
nothing to commit, working tree clean
```
What's the point? The cool thing is that we can now start working in the
`develop` worktree, without affecting the `master` worktree at all. If you're
working on `develop` and want to quickly see what a certain file looks like in
`master`, just look inside `./master`, it's all there!
This becomes especially interesting when you have many feature branches and are
working on multiple features at the same time.
There are a lot of options that influence how worktrees are handled. Maybe you
want to automatically track `origin/master` when you add a worktree called
`master`? Maybe you want your feature branches to have a prefix, so when you're
working on the `feature1` worktree, the remote branch will be
`origin/awesomefeatures/feature1`? Check out [the chapter on
worktrees](./worktrees.md) for all the things that are possible.

View File

@@ -0,0 +1,32 @@
# Behavior Details
When working with worktrees and GRM, there is a lot going on under the hood.
Each time you create a new worktree, GRM has to figure out what commit to set
your new branch to and how to configure any potential remote branches.
To state again, the most important guideline is the following:
**The branch inside the worktree is always the same as the directory name of the
worktree.**
The second set of guidelines relates to the commit to check out, and the remote
branches to use:
* When a branch already exists, you will get a worktree for that branch
* Existing local branches are never changed
* Only do remote operations if specifically requested (via configuration file or
command line parameters)
* When you specify `--track`, you will get that exact branch as the tracking
branch
* When you specify `--no-track`, you will get no tracking branch
Apart from that, GRM tries to do The Right Thing<sup>TM</sup>. It should be as
little surprising as possible.
In 99% of the cases, you will not have to care about the details, as the normal
workflows are covered by the rules above. In case you want to know the exact
behavior "specification", take a look at the [module documentation for
`grm::worktree`](https://docs.rs/git-repo-manager/latest/grm/worktree/index.html).
If you think existing behavior is super-duper confusing and you have a better
idea, do not hesitate to open a GitHub issue to discuss this!

View File

@@ -0,0 +1,75 @@
# Worktrees and Remotes
To fetch all remote references from all remotes in a worktree setup, you can use
the following command:
```
$ grm wt fetch
[✔] Fetched from all remotes
```
This is equivalent to running `git fetch --all` in any of the worktrees.
Often, you may want to pull all remote changes into your worktrees. For this,
use the `git pull` equivalent:
```
$ grm wt pull
[✔] master: Done
[✔] my-cool-branch: Done
```
This will refuse when there are local changes, or if the branch cannot be fast
forwarded. If you want to rebase your local branches, use the `--rebase` switch:
```
$ grm wt pull --rebase
[✔] master: Done
[✔] my-cool-branch: Done
```
As noted, this will fail if there are any local changes in your worktree. If you
want to stash these changes automatically before the pull (and unstash them
afterwards), use the `--stash` option.
This will rebase your changes onto the upstream branch. This is mainly helpful
for persistent branches that change on the remote side.
There is a similar rebase feature that rebases onto the **default** branch
instead:
```
$ grm wt rebase
[✔] master: Done
[✔] my-cool-branch: Done
```
This is super helpful for feature branches. If you want to incorporate changes
made on the remote branches, use `grm wt rebase` and all your branches will be
up to date. If you want to also update to remote tracking branches in one go,
use the `--pull` flag, and `--rebase` if you want to rebase instead of aborting
on non-fast-forwards:
```
$ grm wt rebase --pull --rebase
[✔] master: Done
[✔] my-cool-branch: Done
```
"So, what's the difference between `pull --rebase` and `rebase --pull`? Why the
hell is there a `--rebase` flag in the `rebase` command?"
Yes, it's kind of weird. Remember that `pull` only ever updates each worktree to
their remote branch, if possible. `rebase` rebases onto the **default** branch
instead. The switches to `rebase` are just convenience, so you do not have to
run two commands.
* `rebase --pull` is the same as `pull` && `rebase`
* `rebase --pull --rebase` is the same as `pull --rebase` && `rebase`
I understand that the UX is not the most intuitive. If you can think of an
improvement, please let me know (e.g. via an GitHub issue)!
As with `pull`, `rebase` will also refuse to run when there are changes in your
worktree. And you can also use the `--stash` option to stash/unstash changes
automatically.

View File

@@ -0,0 +1,173 @@
# Working with Worktrees
## Creating a new worktree
To actually work, you'll first have to create a new worktree checkout. All
worktree-related commands are available as subcommands of `grm worktree` (or
`grm wt` for short):
```
$ grm wt add mybranch
[✔] Worktree mybranch created
```
You'll see that there is now a directory called `mybranch` that contains a
checkout of your repository, using the branch `mybranch`
```bash
$ cd ./mybranch && git status
On branch mybranch
nothing to commit, working tree clean
```
You can work in this repository as usual. Make changes, commit them, revert
them, whatever you're up to :)
Just note that you *should* not change the branch inside the worktree directory.
There is nothing preventing you from doing so, but you will notice that you'll
run into problems when trying to remove a worktree (more on that later). It may
also lead to confusing behavior, as there can be no two worktrees that have the
same branch checked out. So if you decide to use the worktree setup, go all in,
let `grm` manage your branches and bury `git branch` (and `git checkout -b`).
You will notice that there is no tracking branch set up for the new branch. You
can of course set up one manually after creating the worktree, but there is an
easier way, using the `--track` flag during creation. Let's create another
worktree. Go back to the root of the repository, and run:
```bash
$ grm wt add mybranch2 --track origin/mybranch2
[] Worktree mybranch2 created
```
You'll see that this branch is now tracking `mybranch` on the `origin` remote:
```bash
$ cd ./mybranch2 && git status
On branch mybranch
Your branch is up to date with 'origin/mybranch2'.
nothing to commit, working tree clean
```
The behavior of `--track` differs depending on the existence of the remote
branch:
* If the remote branch already exists, `grm` uses it as the base of the new
local branch.
* If the remote branch does not exist (as in our example), `grm` will create a
new remote tracking branch, using the default branch (either `main` or
`master`) as the base
Often, you'll have a workflow that uses tracking branches by default. It would
be quite tedious to add `--track` every single time. Luckily, the `grm.toml`
file supports defaults for the tracking behavior. See this for an example:
```toml
[track]
default = true
default_remote = "origin"
```
This will set up a tracking branch on `origin` that has the same name as the
local branch.
Sometimes, you might want to have a certain prefix for all your tracking
branches. Maybe to prevent collisions with other contributors. You can simply
set `default_remote_prefix` in `grm.toml`:
```toml
[track]
default = true
default_remote = "origin"
default_remote_prefix = "myname"
```
When using branch `my-feature-branch`, the remote tracking branch would be
`origin/myname/my-feature-branch` in this case.
Note that `--track` overrides any configuration in `grm.toml`. If you want to
disable tracking, use `--no-track`.
## Showing the status of your worktrees
There is a handy little command that will show your an overview over all
worktrees in a repository, including their status (i.e. changes files). Just run
the following in the root of your repository:
```
$ grm wt status
╭───────────┬────────┬──────────┬──────────────────╮
│ Worktree ┆ Status ┆ Branch ┆ Remote branch │
╞═══════════╪════════╪══════════╪══════════════════╡
│ mybranch ┆ ✔ ┆ mybranch ┆ │
│ mybranch2 ┆ ✔ ┆ mybranch ┆ origin/mybranch2 │
╰───────────┴────────┴──────────┴──────────────────╯
```
The "Status" column would show any uncommitted changes (new / modified / deleted
files) and the "Remote branch" would show differences to the remote branch (e.g.
if there are new pushes to the remote branch that are not yet incorporated into
your local branch).
## Deleting worktrees
If you're done with your worktrees, use `grm wt delete` to delete them. Let's
start with `mybranch2`:
```
$ grm wt delete mybranch2
[✔] Worktree mybranch2 deleted
```
Easy. On to `mybranch`:
```
$ grm wt delete mybranch
[!] Changes in worktree: No remote tracking branch for branch mybranch found. Refusing to delete
```
Hmmm. `grm` tells you:
"Hey, there is no remote branch that you could have pushed your changes to. I'd
rather not delete work that you cannot recover."
Note that `grm` is very cautious here. As your repository will not be deleted,
you could still recover the commits via
[`git-reflog`](https://git-scm.com/docs/git-reflog). But better safe than
sorry! Note that you'd get a similar error message if your worktree had any
uncommitted files, for the same reason. Now you can either commit & push your
changes, or your tell `grm` that you know what you're doing:
```
$ grm wt delete mybranch --force
[✔] Worktree mybranch deleted
```
If you just want to delete all worktrees that do not contain any changes, you
can also use the following:
```
$ grm wt clean
```
Note that this will not delete the default branch of the repository. It can of
course still be delete with `grm wt delete` if necessary.
### Converting an existing repository
It is possible to convert an existing directory to a worktree setup, using `grm
wt convert`. This command has to be run in the root of the repository you want
to convert:
```
$ grm wt convert
[✔] Conversion successful
```
This command will refuse to run if you have any changes in your repository.
Commit them and try again!
Afterwards, the directory is empty, as there are no worktrees checked out yet.
Now you can use the usual commands to set up worktrees.

View File

@@ -1,58 +1,60 @@
# Git Worktrees
## Why?
The default workflow when using git is having your repository in a single
directory. Then, you can check out a certain reference (usually a branch),
which will update the files in the directory to match the state of that
reference. Most of the time, this is exactly what you need and works perfectly.
But especially when you're working with branches a lot, you may notice that
there is a lot of work required to make everything run smoothly.
The default workflow when using git is having your repository in a single directory.
Then, you can check out a certain reference (usually a branch), which will update
the files in the directory to match the state of that reference. Most of the time,
this is exactly what you need and works perfectly. But especially when you're using
with branches a lot, you may notice that there is a lot of work required to make
everything run smootly.
Maybe you experienced the following: You're working on a feature branch. Then,
for some reason, you have to change branches (maybe to investigate some issue).
But you get the following:
Maybe you have experienced the following: You're working on a feature branch.
Then, for some reason, you have to change branches (maybe to investigate some
issue). But you get the following:
```
error: Your local changes to the following files would be overwritten by checkout
```
Now you can create a temporary commit or stash your changes. In any case, you have
some mental overhead before you can work on something else. Especially with stashes,
you'll have to remember to do a `git stash pop` before resuming your work (I
cannot count the number of times where is "rediscovered" some code hidden in some
old stash I forgot about.
Now you can create a temporary commit or stash your changes. In any case, you
have some mental overhead before you can work on something else. Especially with
stashes, you'll have to remember to do a `git stash pop` before resuming your
work (I cannot count the number of times where I "rediscovered" some code hidden
in some old stash I forgot about). Also, conflicts on a `git stash pop` are just
horrible.
And even worse: If you're currently in the process of resolving merge conflicts or an
interactive rebase, there is just no way to "pause" this work to check out a
different branch.
And even worse: If you're currently in the process of resolving merge conflicts
or an interactive rebase, there is just no way to "pause" this work to check out
a different branch.
Sometimes, it's crucial to have an unchanging state of your repository until some
long-running process finishes. I'm thinking of Ansible and Terraform runs. I'd
rather not change to a different branch while ansible or Terraform are running as
I have no idea how those tools would behave (and I'm not too eager to find out).
Sometimes, it's crucial to have an unchanging state of your repository until
some long-running process finishes. I'm thinking of Ansible and Terraform runs.
I'd rather not change to a different branch while ansible or Terraform are
running as I have no idea how those tools would behave (and I'm not too eager to
find out).
In any case, Git Worktrees are here for the rescue:
## What are git worktrees?
[Git Worktrees](https://git-scm.com/docs/git-worktree) allow you to have multiple
independent checkouts of your repository on different directories. You can have
multiple directories that correspond to different references in your repository.
Each worktree has it's independent working tree (duh) and index, so there is no
to run into conflicts. Changing to a different branch is just a `cd` away (if
the worktree is already set up).
[Git Worktrees](https://git-scm.com/docs/git-worktree) allow you to have
multiple independent checkouts of your repository on different directories. You
can have multiple directories that correspond to different references in your
repository. Each worktree has it's independent working tree (duh) and index, so
there is no way to run into conflicts. Changing to a different branch is just a
`cd` away (if the worktree is already set up).
## Worktrees in GRM
GRM exposes an opinionated way to use worktrees in your repositories. Opinionated,
because there is a single invariant that makes reasoning about your worktree
setup quite easy:
GRM exposes an opinionated way to use worktrees in your repositories.
Opinionated, because there is a single invariant that makes reasoning about your
worktree setup quite easy:
**The branch inside the worktree is always the same as the directory name of the worktree.**
**The branch inside the worktree is always the same as the directory name of the
worktree.**
In other words: If you're checking out branch `mybranch` into a new worktree, the
worktree directory will be named `mybranch`.
In other words: If you're checking out branch `mybranch` into a new worktree,
the worktree directory will be named `mybranch`.
GRM can be used with both "normal" and worktree-enabled repositories. But note
that a single repository can be either the former or the latter. You'll have to
@@ -67,303 +69,27 @@ name = "git-repo-manager"
worktree_setup = true
```
Now, when you run a `grm sync`, you'll notice that the directory of the repository
is empty! Well, not totally, there is a hidden directory called `.git-main-working-tree`.
This is where the repository actually "lives" (it's a bare checkout).
Now, when you run a `grm sync`, you'll notice that the directory of the
repository is empty! Well, not totally, there is a hidden directory called
`.git-main-working-tree`. This is where the repository actually "lives" (it's a
bare checkout).
Note that there are few specific things you can configure for a certain
workspace. This is all done in an optional `grm.toml` file right in the root
of the worktree. More on that later.
workspace. This is all done in an optional `grm.toml` file right in the root of
the worktree. More on that later.
### Creating a new worktree
To actually work, you'll first have to create a new worktree checkout. All
worktree-related commands are available as subcommands of `grm worktree` (or
`grm wt` for short):
## Manual access
```
$ grm wt add mybranch
[✔] Worktree mybranch created
```
You'll see that there is now a directory called `mybranch` that contains a checkout
of your repository, using the branch `mybranch`
```bash
$ cd ./mybranch && git status
On branch mybranch
nothing to commit, working tree clean
```
You can work in this repository as usual. Make changes, commit them, revert them,
whatever you're up to :)
Just note that you *should* not change the branch inside the worktree
directory. There is nothing preventing you from doing so, but you will notice
that you'll run into problems when trying to remove a worktree (more on that
later). It may also lead to confusing behaviour, as there can be no two
worktrees that have the same branch checked out. So if you decide to use the
worktree setup, go all in, let `grm` manage your branches and bury `git branch`
(and `git checkout -b`).
You will notice that there is no tracking branch set up for the new branch. You
can of course set up one manually after creating the worktree, but there is an
easier way, using the `--track` flag during creation. Let's create another
worktree. Go back to the root of the repository, and run:
```bash
$ grm wt add mybranch2 --track origin/mybranch2
[] Worktree mybranch2 created
```
You'll see that this branch is now tracking `mybranch` on the `origin` remote:
```bash
$ cd ./mybranch2 && git status
On branch mybranch
Your branch is up to date with 'origin/mybranch2'.
nothing to commit, working tree clean
```
The behaviour of `--track` differs depending on the existence of the remote branch:
* If the remote branch already exists, `grm` uses it as the base of the new
local branch.
* If the remote branch does not exist (as in our example), `grm` will create a
new remote tracking branch, using the default branch (either `main` or `master`)
as the base
Often, you'll have a workflow that uses tracking branches by default. It would
be quite tedious to add `--track` every single time. Luckily, the `grm.toml` file
supports defaults for the tracking behaviour. See this for an example:
```toml
[track]
default = true
default_remote = "origin"
```
This will set up a tracking branch on `origin` that has the same name as the local
branch.
Sometimes, you might want to have a certain prefix for all your tracking branches.
Maybe to prevent collissions with other contributors. You can simply set
`default_remote_prefix` in `grm.toml`:
```toml
[track]
default = true
default_remote = "origin"
default_remote_prefix = "myname"
```
When using branch `my-feature-branch`, the remote tracking branch would be
`origin/myname/my-feature-branch` in this case.
Note that `--track` overrides any configuration in `grm.toml`. If you want to
disable tracking, use `--no-track`.
### Showing the status of your worktrees
There is a handy little command that will show your an overview over all worktrees
in a repository, including their status (i.e. changes files). Just run the following
in the root of your repository:
```
$ grm wt status
╭───────────┬────────┬──────────┬──────────────────╮
│ Worktree ┆ Status ┆ Branch ┆ Remote branch │
╞═══════════╪════════╪══════════╪══════════════════╡
│ mybranch ┆ ✔ ┆ mybranch ┆ │
│ mybranch2 ┆ ✔ ┆ mybranch ┆ origin/mybranch2 │
╰───────────┴────────┴──────────┴──────────────────╯
```
The "Status" column would show any uncommitted changes (new / modified / deleted
files) and the "Remote branch" would show differences to the remote branch (e.g.
if there are new pushes to the remote branch that are not yet incorporated into
your local branch).
### Deleting worktrees
If you're done with your worktrees, use `grm wt delete` to delete them. Let's
start with `mybranch2`:
```
$ grm wt delete mybranch2
[✔] Worktree mybranch2 deleted
```
Easy. On to `mybranch`:
```
$ grm wt delete mybranch
[!] Changes in worktree: No remote tracking branch for branch mybranch found. Refusing to delete
```
Hmmm. `grm` tells you:
"Hey, there is no remote branch that you could have pushed
your changes to. I'd rather not delete work that you cannot recover."
Note that `grm` is very cautious here. As your repository will not be deleted,
you could still recover the commits via [`git-reflog`](https://git-scm.com/docs/git-reflog).
But better safe then sorry! Note that you'd get a similar error message if your
worktree had any uncommitted files, for the same reason. Now you can either
commit & push your changes, or your tell `grm` that you know what you're doing:
```
$ grm wt delete mybranch --force
[✔] Worktree mybranch deleted
```
If you just want to delete all worktrees that do not contain any changes, you
can also use the following:
```
$ grm wt clean
```
Note that this will not delete the default branch of the repository. It can of
course still be delete with `grm wt delete` if neccessary.
### Persistent branches
You most likely have a few branches that are "special", that you don't want to
clean up and that are the usual target for feature branches to merge into. GRM
calls them "persistent branches" and treats them a bit differently:
* Their worktrees will never be deleted by `grm wt clean`
* If the branches in other worktrees are merged into them, they will be cleaned
up, even though they may not be in line with their upstream. Same goes for
`grm wt delete`, which will not require a `--force` flag. Note that of
course, actual changes in the worktree will still block an automatic cleanup!
* As soon as you enable persistent branches, non-persistent branches will only
ever cleaned up when merged into a persistent branch.
To elaborate: This is mostly relevant for a feature-branch workflow. Whenever a
feature branch is merged, it can usually be thrown away. As merging is usually
done on some remote code management platform (GitHub, GitLab, ...), this means
that you usually keep a branch around until it is merged into one of the "main"
branches (`master`, `main`, `develop`, ...)
Enable persistent branches by setting the following in the `grm.toml` in the
worktree root:
```toml
persistent_branches = [
"master",
"develop",
]
```
Note that setting persistent branches will disable any detection of "default"
branches. The first entry will be considered your repositories' default branch.
### Converting an existing repository
It is possible to convert an existing directory to a worktree setup, using `grm
wt convert`. This command has to be run in the root of the repository you want
to convert:
```
$ grm wt convert
[✔] Conversion successful
```
This command will refuse to run if you have any changes in your repository.
Commit them and try again!
Afterwards, the directory is empty, as there are no worktrees checked out yet.
Now you can use the usual commands to set up worktrees.
### Working with remotes
To fetch all remote references from all remotes in a worktree setup, you can
use the following command:
```
$ grm wt fetch
[✔] Fetched from all remotes
```
This is equivalent to running `git fetch --all` in any of the worktrees.
Often, you may want to pull all remote changes into your worktrees. For this,
use the `git pull` equivalent:
```
$ grm wt pull
[✔] master: Done
[✔] my-cool-branch: Done
```
This will refuse when there are local changes, or if the branch cannot be fast
forwarded. If you want to rebase your local branches, use the `--rebase` switch:
```
$ grm wt pull --rebase
[✔] master: Done
[✔] my-cool-branch: Done
```
As noted, this will fail if there are any local changes in your worktree. If you
want to stash these changes automatically before the pull (and unstash them
afterwards), use the `--stash` option.
This will rebase your changes onto the upstream branch. This is mainly helpful
for persistent branches that change on the remote side.
There is a similar rebase feature that rebases onto the **default** branch instead:
```
$ grm wt rebase
[✔] master: Done
[✔] my-cool-branch: Done
```
This is super helpful for feature branches. If you want to incorporate changes
made on the remote branches, use `grm wt rebase` and all your branches will
be up to date. If you want to also update to remote tracking branches in one go,
use the `--pull` flag, and `--rebase` if you want to rebase instead of aborting
on non-fast-forwards:
```
$ grm wt rebase --pull --rebase
[✔] master: Done
[✔] my-cool-branch: Done
```
"So, what's the difference between `pull --rebase` and `rebase --pull`? Why the
hell is there a `--rebase` flag in the `rebase` command?"
Yes, it's kind of weird. Remember that `pull` only ever updates each worktree
to their remote branch, if possible. `rebase` rabases onto the **default** branch
instead. The switches to `rebase` are just convenience, so you do not have to
run two commands.
* `rebase --pull` is the same as `pull` && `rebase`
* `rebase --pull --rebase` is the same as `pull --rebase` && `rebase`
I understand that the UX is not the most intuitive. If you can think of an
improvement, please let me know (e.g. via an GitHub issue)!
As with `pull`, `rebase` will also refuse to run when there are changes in your
worktree. And you can also use the `--stash` option to stash/unstash changes
automatically.
### Manual access
GRM isn't doing any magic, it's just git under the hood. If you need to have access
to the underlying git repository, you can always do this:
GRM isn't doing any magic, it's just git under the hood. If you need to have
access to the underlying git repository, you can always do this:
```
$ git --git-dir ./.git-main-working-tree [...]
```
This should never be required (whenever you have to do this, you can consider
this a bug in GRM and open an [issue](https://github.com/hakoerber/git-repo-manager/issues/new),
but it may help in a pinch.
this a bug in GRM and open an
[issue](https://github.com/hakoerber/git-repo-manager/issues/new), but it may
help in a pinch.

View File

@@ -1,8 +1,14 @@
import os
from helpers import *
def pytest_configure(config):
os.environ["GIT_AUTHOR_NAME"] = "Example user"
os.environ["GIT_AUTHOR_EMAIL"] = "user@example.com"
os.environ["GIT_COMMITTER_NAME"] = "Example user"
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
RUN apt-get update \
&& apt-get install -y \
&& apt-get install -y --no-install-recommends \
python3-pytest \
python3-toml \
python3-git \

View File

@@ -5,12 +5,26 @@ import os.path
import subprocess
import tempfile
import hashlib
import shutil
import inspect
import git
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):
cmd = subprocess.run([binary] + args, cwd=cwd, capture_output=True, text=True)
if not is_invalid:
@@ -25,8 +39,12 @@ def grm(args, cwd=None, is_invalid=False):
def shell(script):
script = "set -o errexit\nset -o nounset\n" + script
subprocess.run(["bash"], input=script, text=True, check=True)
script = "set -o errexit\nset -o nounset\nset -o pipefail\n" + script
cmd = subprocess.run(["bash"], input=script, text=True, capture_output=True)
if cmd.returncode != 0:
print(cmd.stdout)
print(cmd.stderr)
cmd.check_returncode()
def checksum_directory(path):
@@ -112,78 +130,204 @@ def checksum_directory(path):
class TempGitRepository:
def __init__(self, dir=None):
self.dir = dir
pass
def __enter__(self):
self.tmpdir = tempfile.TemporaryDirectory(dir=self.dir)
self.remote_1_dir = tempfile.TemporaryDirectory()
self.remote_2_dir = tempfile.TemporaryDirectory()
shell(
f"""
self.tmpdir = get_temporary_directory(self.dir)
self.remote_1 = get_temporary_directory()
self.remote_2 = get_temporary_directory()
cmd = f"""
cd {self.tmpdir.name}
git init
git -c init.defaultBranch=master init
echo test > root-commit
git add root-commit
git commit -m "root-commit"
git remote add origin file://{self.remote_1_dir.name}
git remote add otherremote file://{self.remote_2_dir.name}
git remote add origin file://{self.remote_1.name}
git remote add otherremote file://{self.remote_2.name}
"""
)
shell(cmd)
return self.tmpdir.name
def __exit__(self, exc_type, exc_val, exc_tb):
del self.tmpdir
del self.remote_1_dir
del self.remote_2_dir
pass
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:
def __init__(self):
pass
obj = {}
def __enter__(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.remote_1_dir = tempfile.TemporaryDirectory()
self.remote_2_dir = tempfile.TemporaryDirectory()
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 {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
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 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)
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):
return (self.tmpdir.name, self.commit)
def __exit__(self, exc_type, exc_val, exc_tb):
del self.tmpdir
del self.remote_1_dir
del self.remote_2_dir
pass
class RepoTree:
@@ -191,7 +335,7 @@ class RepoTree:
pass
def __enter__(self):
self.root = tempfile.TemporaryDirectory()
self.root = get_temporary_directory()
self.config = tempfile.NamedTemporaryFile()
with open(self.config.name, "w") as f:
f.write(
@@ -222,7 +366,7 @@ class EmptyDir:
pass
def __enter__(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.tmpdir = get_temporary_directory()
return self.tmpdir.name
def __exit__(self, exc_type, exc_val, exc_tb):
@@ -234,7 +378,7 @@ class NonGitDir:
pass
def __enter__(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.tmpdir = get_temporary_directory()
shell(
f"""
cd {self.tmpdir.name}
@@ -254,11 +398,11 @@ class TempGitFileRemote:
pass
def __enter__(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.tmpdir = get_temporary_directory()
shell(
f"""
cd {self.tmpdir.name}
git init
git -c init.defaultBranch=master init
echo test > root-commit-in-remote-1
git add 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
(
cd ./repo1
git init
git -c init.defaultBranch=master init
echo test > test
git add test
git commit -m "commit1"
@@ -83,7 +83,7 @@ def test_repos_find(configtype, default):
mkdir repo2
(
cd ./repo2
git init
git -c init.defaultBranch=master init
git checkout -b main
echo test > test
git add test
@@ -203,7 +203,7 @@ def test_repos_find_with_invalid_repo(configtype, default):
mkdir repo1
(
cd ./repo1
git init
git -c init.defaultBranch=master init
echo test > test
git add test
git commit -m "commit1"
@@ -213,7 +213,7 @@ def test_repos_find_with_invalid_repo(configtype, default):
mkdir repo2
(
cd ./repo2
git init
git -c init.defaultBranch=master init
git checkout -b main
echo test > test
git add test

View File

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

View File

@@ -303,7 +303,6 @@ def test_repos_sync_root_is_file(configtype):
cmd = grm(["repos", "sync", "config", "--config", config.name])
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
assert "not a directory" in cmd.stderr.lower()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,96 +4,565 @@ from helpers import *
import git
import pytest
import datetime
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("has_config", [True, False])
@pytest.mark.parametrize("has_default", [True, False])
@pytest.mark.parametrize("has_prefix", [True, False])
def test_worktree_add_simple(
remote_branch_already_exists, has_config, has_default, has_prefix
@pytest.mark.parametrize("remote_branch_with_prefix_already_exists", [True, False])
@pytest.mark.parametrize(
"remote_setup",
(
(0, "origin", False),
(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,
):
with TempGitRepositoryWorktree() as (base_dir, _commit):
if has_config:
(remote_count, default_remote, remotes_differ) = remote_setup
(
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 behavior 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 behavior 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:
f.write(
f"""
[track]
default = {str(has_default).lower()}
default_remote = "origin"
default = {str(config_has_default_track_enabled).lower()}
default_remote = "{default_remote}"
"""
)
if has_prefix:
if config_has_default_remote_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
if local_branch_exists:
if has_remotes and local_branch_has_tracking_branch:
origin = repo.remote(default_remote)
if remote_count >= 2:
otherremote = repo.remote("otherremote")
br = list(filter(lambda x: x.name == worktree_name, repo.branches))[0]
assert os.path.exists(base_dir)
if track_differs_from_existing_branch_upstream:
origin.push(
f"{worktree_name}:someothername", force=True, set_upstream=True
)
git --git-dir ./.git-main-working-tree worktree remove tmp
"""
if remote_count >= 2:
otherremote.push(
f"{worktree_name}:someothername",
force=True,
set_upstream=True,
)
cmd = grm(["wt", "add", "test"], cwd=base_dir)
br.set_tracking_branch(
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 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)
if has_config is True:
if config_enabled 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
if worktree_with_slash:
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:
assert len(files) == 2
if worktree_with_slash:
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, "test"))
repo = git.Repo(os.path.join(base_dir, worktree_name))
assert not repo.bare
assert not repo.is_dirty()
if has_config and has_default:
if has_prefix and not remote_branch_already_exists:
# assert not repo.is_dirty()
assert str(repo.head.ref) == worktree_name
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 (
str(repo.active_branch.tracking_branch()) == "origin/myprefix/test"
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 str(repo.active_branch.tracking_branch()) == "origin/test"
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 (
str(repo.active_branch.tracking_branch())
== f"{default_remote}/myprefix/{worktree_name}"
)
else:
assert (
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:
assert repo.active_branch.tracking_branch() is None
def test_worktree_add_into_subdirectory():
with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "dir/test"], cwd=base_dir)
def test_worktree_add_invalid_name():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit):
for worktree_name in [
"/absolute/path",
"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())
def test_worktree_add_invalid_track():
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit):
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())
@pytest.mark.parametrize("use_track", [True, False])
@pytest.mark.parametrize("use_configuration", [True, False])
@pytest.mark.parametrize("use_configuration_default", [True, False])
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
files = os.listdir(base_dir)
assert len(files) == 2
assert set(files) == {".git-main-working-tree", "dir"}
files = os.listdir(os.path.join(base_dir, "dir"))
assert set(files) == {"test"}
repo = git.Repo(os.path.join(base_dir, "dir", "test"))
assert not repo.bare
assert not repo.is_dirty()
assert repo.active_branch.tracking_branch() is None
assert len(cmd.stderr) == 0
def test_worktree_add_into_invalid_subdirectory():
with TempGitRepositoryWorktree() as (base_dir, _commit):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit):
cmd = grm(["wt", "add", "/dir/test"], cwd=base_dir)
assert cmd.returncode == 1
assert "dir" not in os.listdir(base_dir)
@@ -104,200 +573,57 @@ def test_worktree_add_into_invalid_subdirectory():
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():
with TempGitRepositoryWorktree() as (base_dir, _commit):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode == 0
assert len(cmd.stdout.strip().split("\n")) == 1
assert "test" not in os.listdir(base_dir)
cmd = grm(["wt", "add", "check"], cwd=base_dir)
assert cmd.returncode == 0
repo = git.Repo(os.path.join(base_dir, ".git-main-working-tree"))
print(repo.branches)
assert "test" not in [str(b) for b in repo.branches]
@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
assert len(cmd.stdout.strip().split("\n")) == 1
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():
with TempGitRepositoryWorktree() as (base_dir, _commit):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit):
cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
@@ -306,94 +632,45 @@ def test_worktree_delete_refusal_no_tracking_branch():
assert before == after
def test_worktree_delete_refusal_uncommited_changes_new_file():
with TempGitRepositoryWorktree() as (base_dir, _commit):
@pytest.mark.parametrize(
"reason",
(
"new_file",
"changed_file",
"deleted_file",
"new_commit",
"tracking_branch_mismatch",
),
)
def test_worktree_delete_refusal(reason):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
if reason == "new_file":
shell(f"cd {base_dir}/test && touch changed_file")
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_delete_refusal_uncommited_changes_changed_file():
with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
shell(f"cd {base_dir}/test && git ls-files | shuf | head | xargs rm -rf")
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_delete_refusal_uncommited_changes_deleted_file():
with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
elif reason == "changed_file":
shell(
f"cd {base_dir}/test && git ls-files | shuf | head | while read f ; do echo $RANDOM > $f ; done"
)
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_delete_refusal_commited_changes():
with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
elif reason == "deleted_file":
shell(f"cd {base_dir}/test && git ls-files | shuf | head | xargs rm -rf")
elif reason == "new_commit":
shell(
f'cd {base_dir}/test && touch changed_file && git add changed_file && git commit -m "commitmsg"'
)
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
after = checksum_directory(f"{base_dir}/test")
assert before == after
def test_worktree_delete_refusal_tracking_branch_mismatch():
with TempGitRepositoryWorktree() as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
elif reason == "tracking_branch_mismatch":
shell(
f"cd {base_dir}/test && git push origin test && git reset --hard origin/test^"
)
else:
raise NotImplementedError()
before = checksum_directory(f"{base_dir}/test")
cmd = grm(["wt", "delete", "test"], cwd=base_dir)
assert cmd.returncode != 0
assert len(cmd.stdout) == 0
stderr = cmd.stderr.lower()
assert "refuse" in stderr or "refusing" in stderr
assert "test" in os.listdir(base_dir)
@@ -403,17 +680,18 @@ def test_worktree_delete_refusal_tracking_branch_mismatch():
def test_worktree_delete_force_refusal():
with TempGitRepositoryWorktree() as (base_dir, _commit):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit):
cmd = grm(["wt", "add", "test"], cwd=base_dir)
assert cmd.returncode == 0
cmd = grm(["wt", "delete", "test", "--force"], cwd=base_dir)
assert cmd.returncode == 0
assert len(cmd.stdout.strip().split("\n")) == 1
assert "test" not in os.listdir(base_dir)
def test_worktree_add_delete_add():
with TempGitRepositoryWorktree() as (base_dir, _commit):
with TempGitRepositoryWorktree.get(funcname()) as (base_dir, _commit):
cmd = grm(["wt", "add", "test", "--track", "origin/test"], cwd=base_dir)
assert cmd.returncode == 0
assert "test" in os.listdir(base_dir)

163
release.sh Executable file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env bash
set -o nounset
set -o errexit
set -o pipefail
usage() {
printf '%s\n' "usage: $0 (major|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
printf '%s\n' "Are you sure you want to release 1.x?" >&2
exit 1
;;
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
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

@@ -1,6 +1,15 @@
use std::process;
pub fn get_token_from_command(command: &str) -> Result<String, String> {
#[derive(Clone)]
pub struct AuthToken(String);
impl AuthToken {
pub fn access(&self) -> &str {
&self.0
}
}
pub fn get_token_from_command(command: &str) -> Result<AuthToken, String> {
let output = process::Command::new("/usr/bin/env")
.arg("sh")
.arg("-c")
@@ -32,5 +41,5 @@ pub fn get_token_from_command(command: &str) -> Result<String, String> {
.next()
.ok_or_else(|| String::from("Output did not contain any newline"))?;
Ok(token.to_string())
Ok(AuthToken(token.to_string()))
}

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
#![forbid(unsafe_code)]
use std::path::Path;
use std::process;
@@ -64,7 +66,11 @@ fn main() {
process::exit(1);
}
}
.get_repos(worktree, args.force_ssh)
.get_repos(
worktree,
args.force_ssh,
args.remote_name,
)
}
cmd::RemoteProvider::Gitlab => {
match provider::Gitlab::new(filter, token, args.api_url) {
@@ -74,7 +80,11 @@ fn main() {
process::exit(1);
}
}
.get_repos(worktree, args.force_ssh)
.get_repos(
worktree,
args.force_ssh,
args.remote_name,
)
}
};
@@ -280,6 +290,7 @@ fn main() {
.get_repos(
config.worktree.unwrap_or(false),
config.force_ssh.unwrap_or(false),
config.remote_name,
) {
Ok(provider) => provider,
Err(error) => {
@@ -299,6 +310,7 @@ fn main() {
.get_repos(
config.worktree.unwrap_or(false),
config.force_ssh.unwrap_or(false),
config.remote_name,
) {
Ok(provider) => provider,
Err(error) => {
@@ -382,7 +394,11 @@ fn main() {
process::exit(1);
}
}
.get_repos(worktree, args.force_ssh)
.get_repos(
worktree,
args.force_ssh,
args.remote_name,
)
}
cmd::RemoteProvider::Gitlab => {
match provider::Gitlab::new(filter, token, args.api_url) {
@@ -392,7 +408,11 @@ fn main() {
process::exit(1);
}
}
.get_repos(worktree, args.force_ssh)
.get_repos(
worktree,
args.force_ssh,
args.remote_name,
)
}
};
@@ -463,6 +483,9 @@ fn main() {
match args.action {
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 {
Some(branch) => {
let split = branch.split_once('/');
@@ -484,29 +507,20 @@ fn main() {
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(
&cwd,
name,
subdirectory,
&action_args.name,
track,
action_args.no_track,
) {
Ok(_) => print_success(&format!("Worktree {} created", &action_args.name)),
Ok(warnings) => {
if let Some(warnings) = warnings {
for warning in warnings {
print_warning(&warning);
}
}
print_success(&format!("Worktree {} created", &action_args.name));
}
Err(error) => {
print_error(&format!("Error creating worktree: {}", error));
process::exit(1);
@@ -514,8 +528,6 @@ fn main() {
}
}
cmd::WorktreeAction::Delete(action_args) => {
let worktree_dir = cwd.join(&action_args.name);
let worktree_config = match repo::read_worktree_root_config(&cwd) {
Ok(config) => config,
Err(error) => {
@@ -533,8 +545,9 @@ fn main() {
});
match repo.remove_worktree(
&cwd,
&action_args.name,
&worktree_dir,
Path::new(&action_args.name),
action_args.force,
&worktree_config,
) {

View File

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

View File

@@ -20,12 +20,12 @@ pub fn print_repo_action(repo: &str, message: &str) {
}
pub fn print_action(message: &str) {
let stderr = Term::stderr();
let stdout = Term::stdout();
let mut style = Style::new().yellow();
if stderr.is_term() {
if stdout.is_term() {
style = style.force_styling(true);
}
stderr
stdout
.write_line(&format!("[{}] {}", style.apply_to('\u{2699}'), &message))
.unwrap();
}
@@ -46,13 +46,13 @@ pub fn print_repo_success(repo: &str, message: &str) {
}
pub fn print_success(message: &str) {
let stderr = Term::stderr();
let stdout = Term::stdout();
let mut style = Style::new().green();
if stderr.is_term() {
if stdout.is_term() {
style = style.force_styling(true);
}
stderr
stdout
.write_line(&format!("[{}] {}", style.apply_to('\u{2714}'), &message))
.unwrap();
}

View File

@@ -1,14 +1,13 @@
use serde::Deserialize;
use super::auth;
use super::escape;
use super::ApiErrorResponse;
use super::Filter;
use super::JsonError;
use super::Project;
use super::Provider;
use super::SecretToken;
const PROVIDER_NAME: &str = "github";
const ACCEPT_HEADER_JSON: &str = "application/vnd.github.v3+json";
const GITHUB_API_BASEURL: &str =
option_env!("GITHUB_API_BASEURL").unwrap_or("https://api.github.com");
@@ -67,7 +66,7 @@ impl JsonError for GithubApiErrorResponse {
pub struct Github {
filter: Filter,
secret_token: SecretToken,
secret_token: auth::AuthToken,
}
impl Provider for Github {
@@ -76,7 +75,7 @@ impl Provider for Github {
fn new(
filter: Filter,
secret_token: SecretToken,
secret_token: auth::AuthToken,
api_url_override: Option<String>,
) -> Result<Self, String> {
if api_url_override.is_some() {
@@ -88,20 +87,16 @@ impl Provider for Github {
})
}
fn name(&self) -> String {
String::from(PROVIDER_NAME)
fn filter(&self) -> &Filter {
&self.filter
}
fn filter(&self) -> Filter {
self.filter.clone()
fn secret_token(&self) -> &auth::AuthToken {
&self.secret_token
}
fn secret_token(&self) -> SecretToken {
self.secret_token.clone()
}
fn auth_header_key() -> String {
"token".to_string()
fn auth_header_key() -> &'static str {
"token"
}
fn get_user_projects(
@@ -136,8 +131,8 @@ impl Provider for Github {
fn get_current_user(&self) -> Result<String, ApiErrorResponse<GithubApiErrorResponse>> {
Ok(super::call::<GithubUser, GithubApiErrorResponse>(
&format!("{GITHUB_API_BASEURL}/user"),
&Self::auth_header_key(),
&self.secret_token(),
Self::auth_header_key(),
self.secret_token(),
Some(ACCEPT_HEADER_JSON),
)?
.username)

View File

@@ -1,14 +1,13 @@
use serde::Deserialize;
use super::auth;
use super::escape;
use super::ApiErrorResponse;
use super::Filter;
use super::JsonError;
use super::Project;
use super::Provider;
use super::SecretToken;
const PROVIDER_NAME: &str = "gitlab";
const ACCEPT_HEADER_JSON: &str = "application/json";
const GITLAB_API_BASEURL: &str = option_env!("GITLAB_API_BASEURL").unwrap_or("https://gitlab.com");
@@ -75,7 +74,7 @@ impl JsonError for GitlabApiErrorResponse {
pub struct Gitlab {
filter: Filter,
secret_token: SecretToken,
secret_token: auth::AuthToken,
api_url_override: Option<String>,
}
@@ -95,7 +94,7 @@ impl Provider for Gitlab {
fn new(
filter: Filter,
secret_token: SecretToken,
secret_token: auth::AuthToken,
api_url_override: Option<String>,
) -> Result<Self, String> {
Ok(Self {
@@ -105,20 +104,16 @@ impl Provider for Gitlab {
})
}
fn name(&self) -> String {
String::from(PROVIDER_NAME)
fn filter(&self) -> &Filter {
&self.filter
}
fn filter(&self) -> Filter {
self.filter.clone()
fn secret_token(&self) -> &auth::AuthToken {
&self.secret_token
}
fn secret_token(&self) -> SecretToken {
self.secret_token.clone()
}
fn auth_header_key() -> String {
"bearer".to_string()
fn auth_header_key() -> &'static str {
"bearer"
}
fn get_user_projects(
@@ -157,8 +152,8 @@ impl Provider for Gitlab {
fn get_current_user(&self) -> Result<String, ApiErrorResponse<GitlabApiErrorResponse>> {
Ok(super::call::<GitlabUser, GitlabApiErrorResponse>(
&format!("{}/api/v4/user", self.api_url()),
&Self::auth_header_key(),
&self.secret_token(),
Self::auth_header_key(),
self.secret_token(),
Some(ACCEPT_HEADER_JSON),
)?
.username)

View File

@@ -9,10 +9,13 @@ pub mod gitlab;
pub use github::Github;
pub use gitlab::Gitlab;
use super::auth;
use super::repo;
use std::collections::HashMap;
const DEFAULT_REMOTE_NAME: &str = "origin";
#[derive(Debug, Deserialize, Serialize, clap::ArgEnum, Clone)]
pub enum RemoteProvider {
#[serde(alias = "github", alias = "GitHub")]
@@ -69,8 +72,6 @@ pub trait Project {
fn private(&self) -> bool;
}
type SecretToken = String;
#[derive(Clone)]
pub struct Filter {
users: Vec<String>,
@@ -117,16 +118,15 @@ pub trait Provider {
fn new(
filter: Filter,
secret_token: SecretToken,
secret_token: auth::AuthToken,
api_url_override: Option<String>,
) -> Result<Self, String>
where
Self: Sized;
fn name(&self) -> String;
fn filter(&self) -> Filter;
fn secret_token(&self) -> SecretToken;
fn auth_header_key() -> String;
fn filter(&self) -> &Filter;
fn secret_token(&self) -> &auth::AuthToken;
fn auth_header_key() -> &'static str;
fn get_user_projects(
&self,
@@ -167,7 +167,11 @@ pub trait Provider {
.header("accept", accept_header.unwrap_or("application/json"))
.header(
"authorization",
format!("{} {}", Self::auth_header_key(), &self.secret_token()),
format!(
"{} {}",
Self::auth_header_key(),
&self.secret_token().access()
),
)
.body(())
.map_err(|error| error.to_string())?;
@@ -210,6 +214,7 @@ pub trait Provider {
&self,
worktree_setup: bool,
force_ssh: bool,
remote_name: Option<String>,
) -> Result<HashMap<Option<String>, Vec<repo::Repo>>, String> {
let mut repos = vec![];
@@ -289,10 +294,12 @@ pub trait Provider {
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 {
let namespace = repo.namespace();
let mut repo = repo.into_repo_config(&self.name(), worktree_setup, force_ssh);
let mut repo = repo.into_repo_config(&remote_name, worktree_setup, force_ssh);
// Namespace is already part of the hashmap key. I'm not too happy
// about the data exchange format here.
@@ -308,7 +315,7 @@ pub trait Provider {
fn call<T, U>(
uri: &str,
auth_header_key: &str,
secret_token: &str,
secret_token: &auth::AuthToken,
accept_header: Option<&str>,
) -> Result<T, ApiErrorResponse<U>>
where
@@ -322,7 +329,7 @@ where
.header("accept", accept_header.unwrap_or("application/json"))
.header(
"authorization",
format!("{} {}", &auth_header_key, &secret_token),
format!("{} {}", &auth_header_key, &secret_token.access()),
)
.body(())
.map_err(|error| ApiErrorResponse::String(error.to_string()))?;

View File

@@ -233,7 +233,7 @@ impl Worktree {
let operation = operation.map_err(convert_libgit2_error)?;
// This is required to preserve the commiter of the rebased
// commits, which is the expected behaviour.
// commits, which is the expected behavior.
let rebased_commit = repo
.0
.find_commit(operation.id())
@@ -357,7 +357,7 @@ impl Worktree {
let operation = operation.map_err(convert_libgit2_error)?;
// This is required to preserve the commiter of the rebased
// commits, which is the expected behaviour.
// commits, which is the expected behavior.
let rebased_commit = repo
.0
.find_commit(operation.id())
@@ -659,6 +659,14 @@ impl RepoHandle {
.collect::<Result<Vec<Branch>, String>>()
}
pub fn remote_branches(&self) -> Result<Vec<Branch>, String> {
self.0
.branches(Some(git2::BranchType::Remote))
.map_err(convert_libgit2_error)?
.map(|branch| Ok(Branch(branch.map_err(convert_libgit2_error)?.0)))
.collect::<Result<Vec<Branch>, String>>()
}
pub fn fetch(&self, remote_name: &str) -> Result<(), String> {
let mut remote = self
.0
@@ -1034,16 +1042,82 @@ impl RepoHandle {
})
}
pub fn default_branch(&self) -> Result<Branch, String> {
match self.0.find_branch("main", git2::BranchType::Local) {
Ok(branch) => Ok(Branch(branch)),
Err(_) => match self.0.find_branch("master", git2::BranchType::Local) {
Ok(branch) => Ok(Branch(branch)),
Err(_) => Err(String::from("Could not determine default branch")),
},
pub fn get_remote_default_branch(&self, remote_name: &str) -> Result<Option<Branch>, String> {
// libgit2's `git_remote_default_branch()` and `Remote::default_branch()`
// need an actual connection to the remote, so they may fail.
if let Some(mut remote) = self.find_remote(remote_name)? {
if remote.connected() {
let remote = remote; // unmut
if let Ok(remote_default_branch) = remote.default_branch() {
return Ok(Some(self.find_local_branch(&remote_default_branch)?));
};
}
}
// 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.
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(local_branch_name) =
pointer_name.strip_prefix(&format!("refs/remotes/{}/", remote_name))
{
return Ok(Some(self.find_local_branch(local_branch_name)?));
} else {
eprintln!("Remote HEAD ({}) pointer is invalid", pointer_name);
}
} else {
eprintln!("Remote HEAD does not point to a symbolic target");
}
}
Ok(None)
}
pub fn default_branch(&self) -> Result<Branch, String> {
// This is a bit of a guessing game.
//
// In the best case, there is only one remote. Then, we can check <remote>/HEAD to get the
// default remote branch.
//
// If there are multiple remotes, we first check whether they all have the same
// <remote>/HEAD branch. If yes, good! If not, we use whatever "origin" uses, if that
// exists. If it does not, there is no way to reliably get a remote default branch.
//
// In this case, we just try to guess a local branch from a list. If even that does not
// work, well, bad luck.
let remotes = self.remotes()?;
if remotes.len() == 1 {
let remote_name = &remotes[0];
if let Some(default_branch) = self.get_remote_default_branch(remote_name)? {
return Ok(default_branch);
}
} else {
let mut default_branches: Vec<Branch> = vec![];
for remote_name in remotes {
if let Some(default_branch) = self.get_remote_default_branch(&remote_name)? {
default_branches.push(default_branch)
}
}
if !default_branches.is_empty()
&& (default_branches.len() == 1
|| default_branches
.windows(2)
.all(|w| w[0].name() == w[1].name()))
{
return Ok(default_branches.remove(0));
}
}
for branch_name in &vec!["main", "master"] {
if let Ok(branch) = self.0.find_branch(branch_name, git2::BranchType::Local) {
return Ok(Branch(branch));
}
}
Err(String::from("Could not determine default branch"))
}
// Looks like there is no distinguishing between the error cases
// "no such remote" and "failed to get remote for some reason".
// May be a good idea to handle this explicitly, by returning a
@@ -1079,18 +1153,21 @@ impl RepoHandle {
pub fn remove_worktree(
&self,
base_dir: &Path,
name: &str,
worktree_dir: &Path,
force: bool,
worktree_config: &Option<WorktreeRootConfig>,
) -> Result<(), WorktreeRemoveFailureReason> {
if !worktree_dir.exists() {
let fullpath = base_dir.join(worktree_dir);
if !fullpath.exists() {
return Err(WorktreeRemoveFailureReason::Error(format!(
"{} does not exist",
name
)));
}
let worktree_repo = RepoHandle::open(worktree_dir, false).map_err(|error| {
let worktree_repo = RepoHandle::open(&fullpath, false).map_err(|error| {
WorktreeRemoveFailureReason::Error(format!("Error opening repo: {}", error))
})?;
@@ -1102,12 +1179,11 @@ impl RepoHandle {
WorktreeRemoveFailureReason::Error(format!("Failed getting name of branch: {}", error))
})?;
if branch_name != name
&& !branch_name.ends_with(&format!("{}{}", super::BRANCH_NAMESPACE_SEPARATOR, name))
{
if branch_name != name {
return Err(WorktreeRemoveFailureReason::Error(format!(
"Branch {} is checked out in worktree, this does not look correct",
&branch_name
"Branch \"{}\" is checked out in worktree \"{}\", this does not look correct",
&branch_name,
&worktree_dir.display(),
)));
}
@@ -1177,13 +1253,46 @@ impl RepoHandle {
}
}
if let Err(e) = std::fs::remove_dir_all(&worktree_dir) {
// worktree_dir is a relative path, starting from base_dir. We walk it
// 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!(
"Error deleting {}: {}",
&worktree_dir.display(),
e
)));
}
if let Some(current_dir) = worktree_dir.parent() {
for current_dir in current_dir.ancestors() {
let current_dir = base_dir.join(current_dir);
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(&current_dir) {
return Err(WorktreeRemoveFailureReason::Error(format!(
"Error deleting {}: {}",
&worktree_dir.display(),
e
)));
}
} else {
break;
}
}
}
self.prune_worktree(name)
.map_err(WorktreeRemoveFailureReason::Error)?;
branch
@@ -1236,7 +1345,13 @@ impl RepoHandle {
{
let repo_dir = &directory.join(&worktree.name());
if repo_dir.exists() {
match self.remove_worktree(worktree.name(), repo_dir, false, &config) {
match self.remove_worktree(
directory,
worktree.name(),
Path::new(worktree.name()),
false,
&config,
) {
Ok(_) => print_success(&format!("Worktree {} deleted", &worktree.name())),
Err(error) => match error {
WorktreeRemoveFailureReason::Changes(changes) => {
@@ -1361,7 +1476,7 @@ impl<'a> Branch<'a> {
}
}
impl Branch<'_> {
impl<'a> Branch<'a> {
pub fn commit(&self) -> Result<Commit, String> {
Ok(Commit(
self.0
@@ -1371,6 +1486,15 @@ impl Branch<'_> {
))
}
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> {
self.0
.set_upstream(Some(&format!("{}/{}", remote_name, branch_name)))
@@ -1394,6 +1518,15 @@ impl Branch<'_> {
self.0.delete().map_err(convert_libgit2_error)
}
pub fn basename(&self) -> Result<String, String> {
let name = self.name()?;
if let Some((_prefix, basename)) = name.split_once('/') {
Ok(basename.to_string())
} else {
Ok(name)
}
}
// only used internally in this module, exposes libgit2 details
fn as_reference(&self) -> &git2::Reference {
self.0.get()
@@ -1439,6 +1572,20 @@ impl RemoteHandle<'_> {
.to_string()
}
pub fn connected(&mut self) -> bool {
self.0.connected()
}
pub fn default_branch(&self) -> Result<String, String> {
Ok(self
.0
.default_branch()
.map_err(convert_libgit2_error)?
.as_str()
.expect("Remote branch name is not valid utf-8")
.to_string())
}
pub fn is_pushable(&self) -> Result<bool, String> {
let remote_type = detect_remote_type(self.0.url().expect("Remote name is not valid utf-8"))
.ok_or_else(|| String::from("Could not detect remote type"))?;
@@ -1529,6 +1676,24 @@ pub fn clone_repo(
repo.rename_remote(&origin, &remote.name)?;
}
// Initialize local branches. For all remote branches, we set up local
// tracking branches with the same name (just without the remote prefix).
for remote_branch in repo.remote_branches()? {
let local_branch_name = remote_branch.basename()?;
if repo.find_local_branch(&local_branch_name).is_ok() {
continue;
}
// Ignore <remote>/HEAD, as this is not something we can check out
if local_branch_name == "HEAD" {
continue;
}
let mut local_branch = repo.create_branch(&local_branch_name, &remote_branch.commit()?)?;
local_branch.set_upstream(&remote.name, &local_branch_name)?;
}
// If there is no head_branch, we most likely cloned an empty repository and
// there is no point in setting any upstreams.
if let Ok(mut active_branch) = repo.head_branch() {

View File

@@ -185,7 +185,11 @@ pub fn get_status_table(config: config::Config) -> Result<(Vec<Table>, Vec<Strin
}
};
add_repo_status(&mut table, &repo.name, &repo_handle, repo.worktree_setup)?;
if let Err(err) =
add_repo_status(&mut table, &repo.name, &repo_handle, repo.worktree_setup)
{
errors.push(format!("{}: Couldn't add repo status: {}", &repo.name, err));
}
}
tables.push(table);

View File

@@ -60,7 +60,11 @@ pub fn sync_trees(config: config::Config, init_worktree: bool) -> Result<bool, S
match find_unmanaged_repos(&root_path, &repos) {
Ok(repos) => {
unmanaged_repos_absolute_paths.extend(repos);
for path in repos.into_iter() {
if !unmanaged_repos_absolute_paths.contains(&path) {
unmanaged_repos_absolute_paths.push(path);
}
}
}
Err(error) => {
print_error(&format!("Error getting unmanaged repos: {}", error));
@@ -143,7 +147,35 @@ fn sync_repo(root_path: &Path, repo: &repo::Repo, init_worktree: bool) -> Result
let mut newly_created = false;
if repo_path.exists() {
// Syncing a repository can have a few different flows, depending on the repository
// that is to be cloned and the local directory:
//
// * If the local directory already exists, we have to make sure that it matches the
// worktree configuration, as there is no way to convert. If the sync is supposed
// to be worktree-aware, but the local directory is not, we abort. Note that we could
// also automatically convert here. In any case, the other direction (converting a
// worktree repository to non-worktree) cannot work, as we'd have to throw away the
// worktrees.
//
// * If the local directory does not yet exist, we have to actually do something ;). If
// no remote is specified, we just initialize a new repository (git init) and are done.
//
// If there are (potentially multiple) remotes configured, we have to clone. We assume
// that the first remote is the canonical one that we do the first clone from. After
// cloning, we just add the other remotes as usual (as if they were added to the config
// afterwards)
//
// Branch handling:
//
// Handling the branches on checkout is a bit magic. For minimum surprises, we just set
// up local tracking branches for all remote branches.
if repo_path.exists()
&& repo_path
.read_dir()
.map_err(|error| error.to_string())?
.next()
.is_some()
{
if repo.worktree_setup && !actual_git_directory.exists() {
return Err(String::from(
"Repo already exists, but is not using a worktree setup",
@@ -194,7 +226,7 @@ fn sync_repo(root_path: &Path, repo: &repo::Repo, init_worktree: bool) -> Result
if newly_created && repo.worktree_setup && init_worktree {
match repo_handle.default_branch() {
Ok(branch) => {
worktree::add_worktree(&repo_path, &branch.name()?, None, None, false)?;
worktree::add_worktree(&repo_path, &branch.name()?, None, false)?;
}
Err(_error) => print_repo_error(
&repo.name,

View File

@@ -1,16 +1,584 @@
//! 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 behavior 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 behavior 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
//! behavior 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 super::output::*;
use super::repo;
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 behavior 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(
directory: &Path,
name: &str,
subdirectory: Option<&Path>,
track: Option<(&str, &str)>,
no_track: bool,
) -> Result<(), String> {
) -> Result<Option<Vec<String>>, String> {
let mut warnings: Vec<String> = vec![];
validate_worktree_name(name)?;
let repo = repo::RepoHandle::open(directory, true).map_err(|error| match error.kind {
repo::RepoErrorKind::NotFound => {
String::from("Current directory does not contain a worktree setup")
@@ -18,149 +586,195 @@ pub fn add_worktree(
_ => format!("Error opening repo: {}", error),
})?;
let remotes = &repo.remotes()?;
let config = repo::read_worktree_root_config(directory)?;
if repo.find_worktree(name).is_ok() {
return Err(format!("Worktree {} already exists", &name));
}
let path = match subdirectory {
Some(dir) => directory.join(dir).join(name),
None => directory.join(Path::new(name)),
let track_config = config.and_then(|config| config.track);
let prefix = track_config
.as_ref()
.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 mut remote_branch_exists = false;
let default_checkout = || repo.default_branch()?.to_commit();
let checkout_commit;
if no_track {
checkout_commit = default_checkout()?;
let worktree = if worktree.local_branch_already_exists() {
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 {
match track {
Some((remote_name, remote_branch_name)) => {
let remote_branch = repo.find_remote_branch(remote_name, remote_branch_name);
match remote_branch {
Ok(branch) => {
remote_branch_exists = true;
checkout_commit = branch.to_commit()?;
}
Err(_) => {
remote_branch_exists = false;
checkout_commit = default_checkout()?;
}
}
}
None => match &config {
None => checkout_commit = default_checkout()?,
Some(config) => match &config.track {
None => checkout_commit = default_checkout()?,
Some(track_config) => {
if track_config.default {
let remote_branch =
repo.find_remote_branch(&track_config.default_remote, name);
match remote_branch {
Ok(branch) => {
remote_branch_exists = true;
checkout_commit = branch.to_commit()?;
}
Err(_) => {
checkout_commit = default_checkout()?;
}
worktree.select_commit(Some(Box::new(default_branch_head)))
}
} else {
checkout_commit = default_checkout()?;
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 commit = if let Some(ref default_remote) = default_remote {
if let Some(ref prefix) = prefix {
if let Ok(remote_branch) = repo
.find_remote_branch(default_remote, &format!("{prefix}/{name}"))
{
Some(Box::new(remote_branch.commit_owned()?))
} else {
None
}
} else {
None
}
.or({
if let Ok(remote_branch) =
repo.find_remote_branch(default_remote, name)
{
Some(Box::new(remote_branch.commit_owned()?))
} else {
None
}
})
} else {
None
}.or({
let mut commits = vec![];
for remote_name in remotes.iter() {
let remote_head: Option<Box<repo::Commit>> = ({
if let Some(ref prefix) = prefix {
if let Ok(remote_branch) = repo.find_remote_branch(
remote_name,
&format!("{prefix}/{name}"),
) {
Some(Box::new(remote_branch.commit_owned()?))
} else {
None
}
} else {
None
}
})
.or({
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 target_branch = match repo.find_local_branch(name) {
Ok(branchref) => branchref,
Err(_) => repo.create_branch(name, &checkout_commit)?,
let mut commits = commits
.into_iter()
.flatten()
// 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)
}
}
};
fn push(
remote: &mut repo::RemoteHandle,
branch_name: &str,
remote_branch_name: &str,
repo: &repo::RepoHandle,
) -> Result<(), String> {
if !remote.is_pushable()? {
return Err(format!(
"Cannot push to non-pushable remote {}",
remote.url()
));
}
remote.push(branch_name, remote_branch_name, repo)
}
if !no_track {
if let Some((remote_name, remote_branch_name)) = track {
if remote_branch_exists {
target_branch.set_upstream(remote_name, remote_branch_name)?;
let worktree = if no_track {
worktree.set_remote_tracking_branch(None, prefix.map(|s| s.as_str()))
} else if let Some((remote_name, remote_branch_name)) = track {
worktree.set_remote_tracking_branch(
Some((remote_name, remote_branch_name)),
None, // Always disable prefixing when explicitly given --track
)
} else if !enable_tracking {
worktree.set_remote_tracking_branch(None, prefix.map(|s| s.as_str()))
} else {
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))?;
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)?;
match remotes.len() {
0 => worktree.set_remote_tracking_branch(None, prefix.map(|s| s.as_str())),
1 => worktree
.set_remote_tracking_branch(Some((&remotes[0], name)), prefix.map(|s| s.as_str())),
_ => {
if let Some(default_remote) = default_remote {
worktree.set_remote_tracking_branch(
Some((&default_remote, name)),
prefix.map(|s| s.as_str()),
)
} else {
let remote_branch_name = match track_config.default_remote_prefix {
Some(prefix) => {
format!("{}{}{}", &prefix, super::BRANCH_NAMESPACE_SEPARATOR, &name)
worktree.set_remote_tracking_branch(None, prefix.map(|s| s.as_str()))
}
}
}
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))?;
worktree.create(directory)?;
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)?;
}
}
}
}
}
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(())
Ok(if warnings.is_empty() {
None
} else {
Some(warnings)
})
}