From ada90bb4190875d46b74db596f77b487df905276 Mon Sep 17 00:00:00 2001 From: Rebecca Turner Date: Tue, 24 Sep 2024 16:43:38 -0700 Subject: [PATCH] work --- .envrc | 1 + Cargo.lock | 333 +++++++++++++++- Cargo.toml | 12 + README.md | 10 - config.toml | 43 ++ flake.lock | 24 +- nix/makePackages.nix | 12 +- nix/packages/git-prole.nix | 101 ++--- src/add.rs | 303 ++++++++++++++ src/app.rs | 225 +++++++++++ src/cli.rs | 120 +++++- src/commit_hash.rs | 26 -- src/config.rs | 120 ++++++ src/convert.rs | 263 ++++++++++++ src/copy_dir.rs | 420 ++++++++++++++++++++ src/current_dir.rs | 20 + src/format_bulleted_list.rs | 14 + src/gh.rs | 18 + src/git.rs | 165 -------- src/git/commit_hash.rs | 26 ++ src/git/commitish.rs | 22 ++ src/git/head_state.rs | 54 +++ src/git/mod.rs | 549 ++++++++++++++++++++++++++ src/git/ref_name.rs | 93 +++++ src/git/repository_url_destination.rs | 12 + src/git/status.rs | 170 ++++++++ src/git/worktree.rs | 268 +++++++++++++ src/install_tracing.rs | 2 + src/main.rs | 52 +-- src/normal_path.rs | 170 ++++++++ src/topological_sort.rs | 167 ++++++++ src/utf8tempdir.rs | 47 +++ 32 files changed, 3559 insertions(+), 303 deletions(-) create mode 100644 .envrc create mode 100644 config.toml create mode 100644 src/add.rs create mode 100644 src/app.rs delete mode 100644 src/commit_hash.rs create mode 100644 src/config.rs create mode 100644 src/convert.rs create mode 100644 src/copy_dir.rs create mode 100644 src/current_dir.rs create mode 100644 src/format_bulleted_list.rs create mode 100644 src/gh.rs delete mode 100644 src/git.rs create mode 100644 src/git/commit_hash.rs create mode 100644 src/git/commitish.rs create mode 100644 src/git/head_state.rs create mode 100644 src/git/mod.rs create mode 100644 src/git/ref_name.rs create mode 100644 src/git/repository_url_destination.rs create mode 100644 src/git/status.rs create mode 100644 src/git/worktree.rs create mode 100644 src/normal_path.rs create mode 100644 src/topological_sort.rs create mode 100644 src/utf8tempdir.rs diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/Cargo.lock b/Cargo.lock index dda8a6a..261ac20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "clap" version = "4.5.17" @@ -188,16 +194,23 @@ checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" [[package]] name = "command-error" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273afb523dba4bf7f4955398fe1be1d25704ad8003ef6de46436b4e5662e9020" +checksum = "fd0ad563d6960fd12af36a93f24b381e58d6269e55e9617e4c7a3d0f05d7a9ca" dependencies = [ "dyn-clone", + "process-wrap", "shell-words", "tracing", "utf8-command", ] +[[package]] +name = "common-path" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2382f75942f4b3be3690fe4f86365e9c853c1587d6ee58212cebf6e2a9ccd101" + [[package]] name = "derive_more" version = "1.0.0" @@ -237,6 +250,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "errno" version = "0.3.9" @@ -247,6 +266,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + [[package]] name = "fs-err" version = "2.11.0" @@ -266,19 +291,37 @@ dependencies = [ "clap_complete", "clap_mangen", "command-error", + "common-path", "derive_more", "fs-err", "indoc", + "itertools 0.13.0", "miette", "owo-colors 4.1.0", + "path-absolutize", + "pathdiff", "pretty_assertions", "regex", + "serde", + "shell-words", + "tap", + "tempfile", + "toml", "tracing", "tracing-human-layer", "tracing-subscriber", "utf8-command", + "walkdir", + "which", + "xdg", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "heck" version = "0.5.0" @@ -306,6 +349,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "indexmap" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indoc" version = "2.0.5" @@ -355,6 +417,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -427,6 +498,18 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -491,6 +574,33 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "path-absolutize" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" +dependencies = [ + "path-dedot", +] + +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +dependencies = [ + "camino", +] + [[package]] name = "pin-project-lite" version = "0.2.14" @@ -516,6 +626,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38ee68ae331824036479c84060534b18254c864fa73366c58d86db3b7b811619" +dependencies = [ + "indexmap", + "nix", + "tracing", + "windows", +] + [[package]] name = "quote" version = "1.0.37" @@ -611,12 +733,50 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -713,6 +873,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix 0.38.37", + "windows-sys 0.59.0", +] + [[package]] name = "terminal_size" version = "0.2.6" @@ -775,6 +954,40 @@ dependencies = [ "once_cell", ] +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tracing" version = "0.1.40" @@ -813,7 +1026,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f92b4e494498c79204e07cbdea4ad7a39a806198fa95a3e578223963ba59ab" dependencies = [ - "itertools", + "itertools 0.11.0", "owo-colors 3.5.0", "parking_lot", "textwrap", @@ -892,6 +1105,28 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.37", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -908,12 +1143,74 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -932,6 +1229,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -1053,6 +1359,27 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "xdg" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" + [[package]] name = "yansi" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index b855a8a..634f0e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,15 +18,27 @@ clap = { version = "4.5.4", features = ["derive", "wrap_help", "env"] } clap_complete = "4.5.1" clap_mangen = { version = "0.2.20", optional = true } command-error = { version = "0.4.0", features = [ "tracing" ] } +common-path = "1.0.0" derive_more = { version = "1.0.0", features = ["as_ref", "constructor", "deref", "deref_mut", "display", "from", "into"] } fs-err = "2.11.0" +itertools = "0.13.0" miette = { version = "7.2.0", default-features = false, features = ["fancy-no-backtrace"] } owo-colors = { version = "4.0.0", features = ["supports-colors"] } +path-absolutize = "3.1.1" +pathdiff = { version = "0.2.1", features = ["camino"] } regex = "1.10.6" +serde = { version = "1.0.210", features = ["derive"] } +shell-words = "1.1.0" +tap = "1.0.1" +tempfile = "3.12.0" +toml = "0.8.19" tracing = { version = "0.1.40", features = ["attributes"] } tracing-human-layer = "0.1.3" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "registry"] } utf8-command = "1.0.1" +walkdir = "2.5.0" +which = "6.0.3" +xdg = "2.5.2" [dev-dependencies] indoc = "2.0.5" diff --git a/README.md b/README.md index be1eb3f..c18685f 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,6 @@ A [`git-worktree(1)`][git-worktree] manager. ## Features -(This is a TODO list.) - A normal Git checkout looks like this: ``` @@ -30,11 +28,3 @@ my-repo/ + my-feature-branch/ + ... ``` - -- [ ] Convert a Git checkout to a worktree checkout. -- [ ] Clone a repo into a worktree, using the main branch name from the remote. -- [ ] Add a worktree. The worktree should be associated with the main upstream - branch, unless another is given (rather than the default of the - currently-checked-out branch). - - [ ] Copy over files, like `.envrc` or `.nvim.lua`. -- [ ] Remove a worktree. (This will just be an alias for `git worktree remove`.) diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..6dbe1ce --- /dev/null +++ b/config.toml @@ -0,0 +1,43 @@ +# Configuration for `git-prole(1)`, a `git-worktree(1)` manager. +# See: https://github.com/9999years/git-prole +# All settings are listed here with their default values. + +# When determining a default remote or branch for a repository, the following +# remotes will be tried, in order: +# +# 1. Git's `checkout.defaultRemote` setting. +# 2. Any remotes listed here. +# +# This is used to pick a default branch; see `default_branches`. +remotes = [ + "upstream", + "origin", +] + +# When determining a default branch for a repository, the following branches +# will be tried in order: +# +# 1. The default branch of the default remote (see `remotes`), as determined by +# `git ls-remote --symref "$REMOTE" HEAD`. +# 2. Any branches listed here. +# +# When `git prole convert` is used to convert a repository to a worktree +# checkout, the main worktree will be checked out to the default branch. +# +# When `git prole add` is used to create a new worktree, if a new branch is +# created, the branch will be set to the default branch unless another starting +# point is given explicitly. +default_branches = [ + "main", + "master", + "trunk", +] + +# When `git prole add` is used to create a new worktree, untracked files are +# copied to the new worktree from the current worktree by default. +# +# This will allow you to get started quickly by copying build products and +# other configuration files over to the new worktree. However, copying these +# files can take some time, so this setting can be used to disable this +# behavior if needed. +copy_untracked = true diff --git a/flake.lock b/flake.lock index b88e9df..a983c0a 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "advisory-db": { "flake": false, "locked": { - "lastModified": 1725883717, - "narHash": "sha256-QifFNLfu5bzKPO4iznCj1h+nHhqGZ8NR2Lo7tzh9FRc=", + "lastModified": 1728429239, + "narHash": "sha256-k1KRRgmfKNhO9eU55FMkkzkneqAlwz5oLC5NSiEfGxs=", "owner": "rustsec", "repo": "advisory-db", - "rev": "7fbf1e630ae52b7b364791a107b5bee5ff929496", + "rev": "acb7ce45817b13dd34cb32540ff18be4e1f3ba09", "type": "github" }, "original": { @@ -18,11 +18,11 @@ }, "crane": { "locked": { - "lastModified": 1725409566, - "narHash": "sha256-PrtLmqhM6UtJP7v7IGyzjBFhbG4eOAHT6LPYOFmYfbk=", + "lastModified": 1728344376, + "narHash": "sha256-lxTce2XE6mfJH8Zk6yBbqsbu9/jpwdymbSH5cCbiVOA=", "owner": "ipetkov", "repo": "crane", - "rev": "7e4586bad4e3f8f97a9271def747cf58c4b68f3c", + "rev": "fd86b78f5f35f712c72147427b1eb81a9bd55d0b", "type": "github" }, "original": { @@ -33,11 +33,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1726062873, - "narHash": "sha256-IiA3jfbR7K/B5+9byVi9BZGWTD4VSbWe8VLpp9B/iYk=", + "lastModified": 1728241625, + "narHash": "sha256-yumd4fBc/hi8a9QgA9IT8vlQuLZ2oqhkJXHPKxH/tRw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4f807e8940284ad7925ebd0a0993d2a1791acb2f", + "rev": "c31898adf5a8ed202ce5bea9f347b1c6871f32d1", "type": "github" }, "original": { @@ -63,11 +63,11 @@ ] }, "locked": { - "lastModified": 1726194362, - "narHash": "sha256-cM7zFscFqdsA5KohUUYndzIp20kUqjj39qnj6Voj+f8=", + "lastModified": 1728354625, + "narHash": "sha256-r+Sa1NRRT7LXKzCaVaq75l1GdZcegODtF06uaxVVVbI=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "a71b1240e29f1ec68612ed5306c328086bed91f9", + "rev": "d216ade5a0091ce60076bf1f8bc816433a1fc5da", "type": "github" }, "original": { diff --git a/nix/makePackages.nix b/nix/makePackages.nix index 78b5daa..68273db 100644 --- a/nix/makePackages.nix +++ b/nix/makePackages.nix @@ -5,9 +5,11 @@ }: lib.makeScope newScope ( self: - {inherit inputs;} - // (lib.packagesFromDirectoryRecursive { - directory = inputs.self + "/nix/packages"; - inherit (self) callPackage; - }) + { + inherit inputs; + } + // (lib.packagesFromDirectoryRecursive { + directory = inputs.self + "/nix/packages"; + inherit (self) callPackage; + }) ) diff --git a/nix/packages/git-prole.nix b/nix/packages/git-prole.nix index 613f4be..7e00c62 100644 --- a/nix/packages/git-prole.nix +++ b/nix/packages/git-prole.nix @@ -12,13 +12,12 @@ installShellFiles, pkg-config, openssl, -}: let +}: +let src = lib.cleanSourceWith { src = craneLib.path ../../.; # Keep test data. - filter = path: type: - lib.hasInfix "/data" path - || (craneLib.filterCargoSources path type); + filter = path: type: lib.hasInfix "/data" path || (craneLib.filterCargoSources path type); }; commonArgs' = { @@ -42,35 +41,43 @@ # all of that work (e.g. via cachix) when running in CI cargoArtifacts = craneLib.buildDepsOnly commonArgs'; - commonArgs = - commonArgs' - // { - inherit cargoArtifacts; - }; + commonArgs = commonArgs' // { + inherit cargoArtifacts; + }; checks = { - git-prole-nextest = craneLib.cargoNextest (commonArgs + git-prole-nextest = craneLib.cargoNextest ( + commonArgs // { NEXTEST_HIDE_PROGRESS_BAR = "true"; - }); - git-prole-doctest = craneLib.cargoTest (commonArgs + } + ); + git-prole-doctest = craneLib.cargoTest ( + commonArgs // { cargoTestArgs = "--doc"; - }); - git-prole-clippy = craneLib.cargoClippy (commonArgs + } + ); + git-prole-clippy = craneLib.cargoClippy ( + commonArgs // { cargoClippyExtraArgs = "--all-targets -- --deny warnings"; - }); - git-prole-rustdoc = craneLib.cargoDoc (commonArgs + } + ); + git-prole-rustdoc = craneLib.cargoDoc ( + commonArgs // { cargoDocExtraArgs = "--document-private-items"; RUSTDOCFLAGS = "-D warnings"; - }); + } + ); git-prole-fmt = craneLib.cargoFmt commonArgs; - git-prole-audit = craneLib.cargoAudit (commonArgs + git-prole-audit = craneLib.cargoAudit ( + commonArgs // { inherit (inputs) advisory-db; - }); + } + ); }; devShell = craneLib.devShell { @@ -94,7 +101,9 @@ // { cargoExtraArgs = "${commonArgs.cargoExtraArgs or ""} --features clap_mangen"; - nativeBuildInputs = commonArgs.nativeBuildInputs ++ [installShellFiles]; + nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ installShellFiles ]; + + doCheck = false; postInstall = (commonArgs.postInstall or "") @@ -115,27 +124,31 @@ } ); in - # Build the actual crate itself, reusing the dependency - # artifacts from above. - craneLib.buildPackage (commonArgs - // { - # Don't run tests; we'll do that in a separate derivation. - doCheck = false; - - postInstall = - (commonArgs.postInstall or "") - + '' - cp -r ${git-prole-man}/share $out/share - # What: - chmod -R +w $out/share - ''; - - passthru = { - inherit - checks - devShell - commonArgs - craneLib - ; - }; - }) +# Build the actual crate itself, reusing the dependency +# artifacts from above. +craneLib.buildPackage ( + commonArgs + // { + # Don't run tests; we'll do that in a separate derivation. + doCheck = false; + + postInstall = + (commonArgs.postInstall or "") + + '' + cp -r ${git-prole-man}/share $out/share + # For some reason this is needed to strip references: + # stripping references to cargoVendorDir from share/man/man1/git-prole.1.gz + # sed: couldn't open temporary file share/man/man1/sedwVs75O: Permission denied + chmod -R +w $out/share + ''; + + passthru = { + inherit + checks + devShell + commonArgs + craneLib + ; + }; + } +) diff --git a/src/add.rs b/src/add.rs new file mode 100644 index 0000000..c22ff41 --- /dev/null +++ b/src/add.rs @@ -0,0 +1,303 @@ +use std::fmt::Display; +use std::process::Command; + +use camino::Utf8PathBuf; +use command_error::CommandExt; +use command_error::Utf8ProgramAndArgs; +use miette::miette; +use miette::Context; +use miette::IntoDiagnostic; +use owo_colors::OwoColorize; +use owo_colors::Stream; +use tap::Tap; + +use crate::app::App; +use crate::cli::AddArgs; +use crate::format_bulleted_list::format_bulleted_list; +use crate::git::Git; +use crate::normal_path::NormalPath; + +pub fn add(app: &App, args: AddArgs) -> miette::Result<()> { + // TODO: Check if there's more than 1 worktree and (offer to?) convert if not? + // TODO: Allow user to run commands, e.g. `direnv allow`? + + let plan = WorktreePlan::new(app, &args, app.git.repo_root()?)?; + plan.execute(app)?; + + Ok(()) +} + +/// A plan for creating a new `git worktree`. +#[derive(Debug, Clone)] +struct WorktreePlan { + /// The directory to run commands from. + repo_root: Utf8PathBuf, + branch: BranchPlan, + destination: NormalPath, + start_point: StartPointPlan, + /// Relative paths to copy to the new worktree, if any. + copy_untracked: Vec, +} + +impl WorktreePlan { + pub fn new(app: &App, args: &AddArgs, repo_root: Utf8PathBuf) -> miette::Result { + let branch = BranchPlan::new(app, args)?; + let start_point = StartPointPlan::new(app, args, &branch)?; + let destination = Self::destination_plan(app, args, &branch)?; + let copy_untracked = if app.config.file.copy_untracked() { + app.git.untracked_files()? + } else { + Vec::new() + }; + Ok(Self { + repo_root, + branch, + destination, + start_point, + copy_untracked, + }) + } + + pub fn destination_plan( + app: &App, + args: &AddArgs, + branch_plan: &BranchPlan, + ) -> miette::Result { + match &args.inner.name_or_path { + Some(name_or_path) => { + if name_or_path.contains('/') { + NormalPath::from_cwd(name_or_path) + } else { + NormalPath::from_cwd( + app.git + .worktree_container()? + .tap_mut(|p| p.push(name_or_path)), + ) + } + } + None => NormalPath::from_cwd(app.branch_path(branch_plan.branch_name())?), + } + } + + pub fn command(&self, git: &Git) -> Command { + let mut command = git.with_directory(self.repo_root.clone()).command(); + command.args(["worktree", "add"]); + + match &self.branch { + BranchPlan::New(branch) => { + command.args(["-b", branch]); + } + BranchPlan::NewForce(branch) => { + command.args(["-B", branch]); + } + BranchPlan::Existing(_) => { + // TODO: Do we need `--track` here? + } + } + + command.args([self.destination.as_str(), self.start_point.commitish()]); + + command + } + + pub fn copy_untracked(&self) -> miette::Result<()> { + if self.copy_untracked.is_empty() { + return Ok(()); + } + tracing::info!("Copying untracked files to {}", self.destination); + for path in &self.copy_untracked { + let from = self.repo_root.join(path); + let to = self.destination.join(path); + tracing::trace!( + %path, + %from, %to, + "Copying untracked file" + ); + let errors = crate::copy_dir::copy_dir(&from, &to) + .into_diagnostic() + .wrap_err_with(|| format!("Failed to copy untracked files from {from} to {to}"))?; + if !errors.is_empty() { + tracing::debug!( + "Errors encountered while copying untracked files:\n{}", + format_bulleted_list(errors) + ); + } + } + Ok(()) + } + + pub fn execute(&self, app: &App) -> miette::Result<()> { + let mut command = self.command(&app.git); + + tracing::info!("{self}"); + + if app.config.cli.dry_run { + tracing::info!("$ {}", Utf8ProgramAndArgs::from(&command)); + } else { + command.status_checked().into_diagnostic()?; + self.copy_untracked()?; + } + Ok(()) + } +} + +impl Display for WorktreePlan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.branch { + BranchPlan::Existing(_) => { + write!( + f, + "Creating worktree in {} for {}", + self.destination, self.branch, + )?; + } + _ => { + write!( + f, + "Creating worktree in {} for {} starting at {}", + self.destination, self.branch, self.start_point, + )?; + } + } + + if !self.copy_untracked.is_empty() { + write!( + f, + "\nCopying {} untracked paths to new worktree", + self.copy_untracked.len() + )?; + } + + Ok(()) + } +} + +/// The branch to checkout or create for a new `git worktree`. +#[derive(Debug, Clone)] +enum BranchPlan { + /// Create a new branch with `-b`. + New(String), + /// Create (and forcibly reset) a new branch with `-B`. + NewForce(String), + /// Use an existing local or remote branch. + Existing(String), +} + +impl BranchPlan { + pub fn new(app: &App, args: &AddArgs) -> miette::Result { + match (&args.inner.branch, &args.inner.force_branch) { + (Some(_), Some(_)) => Err(miette!( + "`--branch` and `--force-branch` are mutually exclusive." + )), + (Some(branch), None) => Ok(Self::New(branch.to_owned())), + (None, Some(force_branch)) => Ok(Self::NewForce(force_branch.to_owned())), + (None, None) => { + let name_or_path = args + .inner + .name_or_path + .as_deref() + .expect("If `--branch` is not given, `NAME_OR_PATH` must be given"); + let branch = App::branch_dirname(name_or_path); + if app.git.local_branch_exists(branch)? { + Ok(Self::Existing(branch.to_owned())) + } else if let Some(remote) = app.git.find_remote_for_branch(branch)? { + // This is implicit behavior documented in `git-worktree(1)`. + Ok(Self::Existing(format!("{remote}/{branch}"))) + } else { + // Otherwise, create a new branch with the given name. + Ok(Self::New(branch.to_owned())) + } + } + } + } + + pub fn branch_name(&self) -> &str { + match self { + BranchPlan::New(branch_name) + | BranchPlan::NewForce(branch_name) + | BranchPlan::Existing(branch_name) => branch_name, + } + } +} + +impl Display for BranchPlan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BranchPlan::New(branch) | BranchPlan::NewForce(branch) => { + write!( + f, + "new branch {}", + branch.if_supports_color(Stream::Stdout, |text| text.cyan()) + ) + } + BranchPlan::Existing(branch) => { + write!( + f, + "branch {}", + branch.if_supports_color(Stream::Stdout, |text| text.cyan()) + ) + } + } + } +} + +/// The commit or branch to start a new `git worktree` at. +#[derive(Debug, Clone)] +enum StartPointPlan { + /// Check out an existing branch. + Existing(String), + /// Use the default branch. + Default(String), + /// The user specified a start point explicitly. + Explicit(String), +} + +impl StartPointPlan { + pub fn new(app: &App, args: &AddArgs, branch_plan: &BranchPlan) -> miette::Result { + match &args.commitish { + Some(commitish) => Ok(Self::Explicit(commitish.to_owned())), + None => match branch_plan { + BranchPlan::New(_) | BranchPlan::NewForce(_) => { + Ok(Self::Default(app.pick_default_branch()?)) + } + BranchPlan::Existing(branch) => Ok(Self::Existing(branch.clone())), + }, + } + } + + pub fn commitish(&self) -> &str { + match self { + StartPointPlan::Existing(commitish) + | StartPointPlan::Default(commitish) + | StartPointPlan::Explicit(commitish) => commitish, + } + } +} + +impl Display for StartPointPlan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StartPointPlan::Existing(branch) => { + write!( + f, + "branch {}", + branch.if_supports_color(Stream::Stdout, |text| text.cyan()) + ) + } + StartPointPlan::Default(branch) => { + write!( + f, + "default branch {}", + branch.if_supports_color(Stream::Stdout, |text| text.cyan()) + ) + } + StartPointPlan::Explicit(commitish) => { + write!( + f, + "{}", + commitish.if_supports_color(Stream::Stdout, |text| text.cyan()) + ) + } + } + } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..d4f60cd --- /dev/null +++ b/src/app.rs @@ -0,0 +1,225 @@ +use std::collections::HashSet; +use std::process::Command; + +use calm_io::stdout; +use camino::Utf8PathBuf; +use clap::CommandFactory; +use command_error::CommandExt; +use fs_err as fs; +use miette::miette; +use miette::IntoDiagnostic; +use tap::Tap; +use which::which_global; + +use crate::cli; +use crate::cli::AddArgs; +use crate::cli::CloneArgs; +use crate::cli::ConfigCommand; +use crate::cli::ConfigGenerateArgs; +use crate::cli::ConvertArgs; +use crate::config::Config; +use crate::convert::ConvertPlan; +use crate::convert::ConvertPlanOpts; +use crate::current_dir::current_dir_utf8; +use crate::gh::looks_like_gh_url; +use crate::git::repository_url_destination::repository_url_destination; +use crate::git::Git; + +pub struct App { + pub git: Git, + pub config: Config, +} + +impl App { + pub fn new(config: Config) -> Self { + Self { + config, + git: Git::new(), + } + } + + pub fn run(self) -> miette::Result<()> { + match &self.config.cli.command { + cli::Command::Completions { shell } => { + let mut clap_command = cli::Cli::command(); + clap_complete::generate( + *shell, + &mut clap_command, + "git-prole", + &mut std::io::stdout(), + ); + } + #[cfg(feature = "clap_mangen")] + cli::Command::Manpages { out_dir } => { + use miette::Context; + let clap_command = cli::Cli::command(); + clap_mangen::generate_to(clap_command, out_dir) + .into_diagnostic() + .wrap_err("Failed to generate man pages")?; + } + cli::Command::Convert(args) => self.convert(args.to_owned())?, + cli::Command::Clone(args) => self.clone(args.to_owned())?, + cli::Command::Add(args) => self.add(args.to_owned())?, + cli::Command::Config(ConfigCommand::Generate(args)) => { + self.config_generate(args.to_owned())? + } + } + + Ok(()) + } + + fn config_generate(&self, args: ConfigGenerateArgs) -> miette::Result<()> { + let path = match &args.output { + Some(path) => { + if path == "-" { + stdout!("{}", Config::DEFAULT).into_diagnostic()?; + return Ok(()); + } else { + path + } + } + None => &self.config.path, + }; + + if path.exists() { + return Err(miette!("Default configuration file already exists: {path}")); + } + + tracing::info!( + %path, + "Writing default configuration file" + ); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).into_diagnostic()?; + } + + fs::write(path, Config::DEFAULT).into_diagnostic()?; + + Ok(()) + } + + fn add(&self, args: AddArgs) -> miette::Result<()> { + crate::add::add(self, args) + } + + fn clone(&self, args: CloneArgs) -> miette::Result<()> { + let destination = match args.directory { + Some(directory) => directory.to_owned(), + None => current_dir_utf8()?.join(repository_url_destination(&args.repository)), + }; + + if self.config.cli.dry_run { + return Err(miette!("--dry-run is not supported for this command yet")); + } + + if looks_like_gh_url(&args.repository) && which_global("gh").is_ok() { + Command::new("gh") + .args([&args.repository, destination.as_str()]) + .args(args.clone_args) + .status_checked() + .into_diagnostic()?; + } else { + self.git + .clone_repository(&args.repository, Some(&destination), &args.clone_args)?; + } + + self.convert(ConvertArgs { + default_branch: None, + })?; + Ok(()) + } + + fn convert(&self, args: ConvertArgs) -> miette::Result<()> { + let plan = ConvertPlan::new( + self, + ConvertPlanOpts { + repository: current_dir_utf8()?, + default_branch: args.default_branch, + }, + )?; + + tracing::info!("{plan}"); + + // TODO: Ask the user before we start messing around with their repo layout! + if !self.config.cli.dry_run { + plan.execute()?; + } + + Ok(()) + } + + pub fn pick_default_branch(&self) -> miette::Result { + if let Some(default_remote) = self.pick_default_remote()? { + return self.git.default_branch(&default_remote); + } + + let preferred_branches = self.config.file.default_branches(); + let all_branches = self.git.list_local_branches()?; + for branch in preferred_branches { + if all_branches.contains(&branch) { + return Ok(branch); + } + } + + Err(miette!( + "No default branch found; specify a `--default-branch` to check out" + )) + } + + pub fn pick_default_remote(&self) -> miette::Result> { + Ok(self.sorted_remotes()?.first().cloned()) + } + + pub fn sorted_remotes(&self) -> miette::Result> { + let mut all_remotes = self.git.remotes()?.into_iter().collect::>(); + + let mut sorted = Vec::with_capacity(all_remotes.len()); + + if let Some(default_remote) = self.git.default_remote()? { + if let Some(remote) = all_remotes.take(&default_remote) { + sorted.push(remote); + } + } + + let preferred_remotes = self.config.file.remotes(); + for remote in preferred_remotes { + if let Some(remote) = all_remotes.take(&remote) { + sorted.push(remote); + } + } + + Ok(sorted) + } + + /// The directory name, nested under the worktree parent directory, where the given + /// branch's worktree will be placed. + /// + /// E.g. to convert a repo `~/puppy` with default branch `main`, this will return `main`, + /// to indicate a worktree to be placed in `~/puppy/main`. + /// + /// TODO: Should support some configurable regex filtering or other logic? + pub fn branch_dirname(branch: &str) -> &str { + match branch.rsplit_once('/') { + Some((_left, right)) => { + tracing::warn!( + %branch, + worktree = %right, + "Branch contains a `/`, using trailing component for worktree directory name" + ); + right + } + None => branch, + } + } + + /// Get the full path for a new worktree with the given branch name. + /// + /// This appends the [`Self::branch_dirname`] to the [`Git::worktree_container`]. + pub fn branch_path(&self, branch: &str) -> miette::Result { + Ok(self + .git + .worktree_container()? + .tap_mut(|p| p.push(Self::branch_dirname(branch)))) + } +} diff --git a/src/cli.rs b/src/cli.rs index ddbec19..ce2a9d0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,5 @@ +use camino::Utf8PathBuf; +use clap::Args; use clap::Parser; use clap::Subcommand; @@ -5,14 +7,23 @@ use clap::Subcommand; #[derive(Debug, Clone, Parser)] #[command(version, author, about)] #[command(max_term_width = 100, disable_help_subcommand = true)] -pub struct Opts { +pub struct Cli { /// Log filter directives, of the form `target[span{field=value}]=level`, where all components /// except the level are optional. /// /// Try `debug` or `trace`. - #[arg(long, default_value = "info", env = "GIT_PROLE_LOG")] + #[arg(long, default_value = "info", env = "GIT_PROLE_LOG", global = true)] pub log: String, + /// If set, do not perform any actions, and instead only construct and print a plan. + #[arg(long, visible_alias = "dry", default_value = "false", global = true)] + pub dry_run: bool, + + /// The location to read the configuration file from. Defaults to + /// `~/.config/git-prole/config.toml`. + #[arg(long, global = true)] + pub config: Option, + #[command(subcommand)] pub command: Command, } @@ -20,12 +31,34 @@ pub struct Opts { #[allow(rustdoc::bare_urls)] #[derive(Debug, Clone, Subcommand)] pub enum Command { - Add {}, + /// Convert a checkout into a worktree checkout. + Convert(ConvertArgs), + + /// Clone a repository into a worktree checkout. + /// + /// If you have `gh` installed and the URL looks `gh`-like, I'll pass the repository URL + /// to that. + Clone(CloneArgs), + + /// Add a new worktree. + /// + /// Unlike `git worktree add`, this will set new worktrees to start at and track the default + /// branch by default, rather than the checked out commit or branch of the worktree the command + /// is run from. + /// + /// By default, untracked files are copied to the new worktree. + Add(AddArgs), + + /// Initialize the configuration file. + #[command(subcommand)] + Config(ConfigCommand), + /// Generate shell completions. Completions { /// Shell to generate completions for. shell: clap_complete::shells::Shell, }, + /// Generate man pages. #[cfg(feature = "clap_mangen")] Manpages { @@ -33,3 +66,84 @@ pub enum Command { out_dir: camino::Utf8PathBuf, }, } + +#[derive(Args, Clone, Debug)] +pub struct ConvertArgs { + /// A default branch to use as the main checkout. + /// + /// The `.git` directory will live in this worktree. + #[arg(long)] + pub default_branch: Option, +} + +#[derive(Args, Clone, Debug)] +pub struct CloneArgs { + /// The repository URL to clone. + #[arg()] + pub repository: String, + + /// The directory to setup the worktrees in. + /// + /// Defaults to the last component of the repository URL, with a trailing `.git` removed. + #[arg()] + pub directory: Option, + + /// Extra arguments to forward to `git clone`. + #[arg(last = true)] + pub clone_args: Vec, +} + +#[derive(Args, Clone, Debug)] +pub struct AddArgs { + #[command(flatten)] + pub inner: AddArgsInner, + + /// The commit to check out in the new worktree. + #[arg()] + pub commitish: Option, + + /// Extra arguments to forward to `git worktree add`. + #[arg(last = true)] + pub worktree_add_args: Vec, +} + +#[derive(Args, Clone, Debug)] +#[group(required = true, multiple = true)] +pub struct AddArgsInner { + /// Create a new branch with the given name instead of checking out an existing branch. + /// + /// This will refuse to reset a branch if it already exists; use `--force-branch`/`-B` to + /// reset existing branches. + #[arg(long, short = 'b', visible_alias = "create", visible_short_alias = 'c')] + pub branch: Option, + + /// Create a new branch with the given name, overwriting any existing branch with the same + /// name. + #[arg( + long, + short = 'B', + visible_alias = "force-create", + visible_short_alias = 'C' + )] + pub force_branch: Option, + + /// The new worktree's name or path. + /// + /// If this doesn't contain a `/`, it's assumed to be a directory name, and the worktree + /// will be placed adjacent to the other worktrees. + #[arg()] + pub name_or_path: Option, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum ConfigCommand { + /// Initialize a default configuration file. + Generate(ConfigGenerateArgs), +} + +#[derive(Args, Clone, Debug)] +pub struct ConfigGenerateArgs { + /// The location to write the configuration file. Can be `-` for stdout. Defaults to + /// `~/.config/git-prole/config.toml`. + pub output: Option, +} diff --git a/src/commit_hash.rs b/src/commit_hash.rs deleted file mode 100644 index e63a3e5..0000000 --- a/src/commit_hash.rs +++ /dev/null @@ -1,26 +0,0 @@ -use derive_more::{AsRef, Constructor, Deref, DerefMut, Display, From, Into}; - -/// A Git commit hash. -#[derive( - Debug, - Clone, - PartialEq, - Eq, - PartialOrd, - Ord, - Display, - Into, - From, - AsRef, - Deref, - DerefMut, - Constructor, -)] -pub struct CommitHash(String); - -impl CommitHash { - /// Get an abbreviated 8-character Git hash. - pub fn abbrev(&self) -> &str { - &self.0[..8] - } -} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..f6da45f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,120 @@ +use camino::Utf8PathBuf; +use clap::Parser; +use fs_err as fs; +use miette::Context; +use miette::IntoDiagnostic; +use serde::Deserialize; +use xdg::BaseDirectories; + +use crate::cli::Cli; + +/// Configuration, both from the command-line and a user configuration file. +#[derive(Debug)] +pub struct Config { + /// User directories. + #[expect(dead_code)] + pub dirs: BaseDirectories, + /// User configuration file. + pub file: ConfigFile, + /// User configuration file path. + pub path: Utf8PathBuf, + /// Command-line options. + pub cli: Cli, +} + +impl Config { + /// The contents of the default configuration file. + pub const DEFAULT: &str = include_str!("../config.toml"); + + pub fn new() -> miette::Result { + let cli = Cli::parse(); + let dirs = BaseDirectories::with_prefix("git-prole").into_diagnostic()?; + const CONFIG_FILE_NAME: &str = "config.toml"; + // TODO: Use `git config` for configuration? + let path = cli + .config + .as_ref() + .map(|path| Ok(path.join(CONFIG_FILE_NAME))) + .unwrap_or_else(|| dirs.get_config_file(CONFIG_FILE_NAME).try_into()) + .into_diagnostic()?; + let file = { + if !path.exists() { + ConfigFile::default() + } else { + toml::from_str( + &fs::read_to_string(&path) + .into_diagnostic() + .wrap_err("Failed to read configuration file")?, + ) + .into_diagnostic() + .wrap_err("Failed to deserialize configuration file")? + } + }; + Ok(Self { + dirs, + path, + file, + cli, + }) + } +} + +/// Configuration file format. +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +pub struct ConfigFile { + /// Remotes to pick a default branch from, in order. + #[serde(default)] + remotes: Vec, + + /// Default branches to use, in order. + /// + /// Only used if no remotes are found. + #[serde(default)] + default_branches: Vec, + + /// Copy untracked files when creating new worktrees. + #[serde(default)] + copy_untracked: Option, +} + +impl ConfigFile { + pub fn remotes(&self) -> Vec { + // Yeah this basically sucks. But how big could these lists really be? + if self.remotes.is_empty() { + vec!["upstream".to_owned(), "origin".to_owned()] + } else { + self.remotes.clone() + } + } + + pub fn default_branches(&self) -> Vec { + // Yeah this basically sucks. But how big could these lists really be? + if self.default_branches.is_empty() { + vec!["main".to_owned(), "master".to_owned(), "trunk".to_owned()] + } else { + self.default_branches.clone() + } + } + + pub fn copy_untracked(&self) -> bool { + self.copy_untracked.unwrap_or(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_default_config_file_parse() { + assert_eq!( + toml::from_str::(Config::DEFAULT).unwrap(), + ConfigFile { + remotes: vec!["upstream".to_owned(), "origin".to_owned(),], + default_branches: vec!["main".to_owned(), "master".to_owned(), "trunk".to_owned(),], + copy_untracked: Some(true), + } + ); + } +} diff --git a/src/convert.rs b/src/convert.rs new file mode 100644 index 0000000..4ad1ae7 --- /dev/null +++ b/src/convert.rs @@ -0,0 +1,263 @@ +use std::fmt::Display; + +use camino::Utf8PathBuf; +use fs_err as fs; +use miette::miette; +use miette::IntoDiagnostic; +use owo_colors::OwoColorize; +use owo_colors::Stream; +use tap::Tap; + +use crate::app::App; +use crate::format_bulleted_list::format_bulleted_list; +use crate::git::Git; +use crate::normal_path::NormalPath; +use crate::utf8tempdir::Utf8TempDir; + +#[derive(Debug)] +pub struct ConvertPlanOpts { + pub repository: Utf8PathBuf, + pub default_branch: Option, +} + +#[derive(Debug)] +pub struct ConvertPlan { + repo_name: String, + steps: Vec, + git: Git, +} + +impl Display for ConvertPlan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", format_bulleted_list(&self.steps)) + } +} + +impl ConvertPlan { + pub fn new(app: &App, opts: ConvertPlanOpts) -> miette::Result { + // Figuring out which worktrees to create is non-trivial: + // - We might already have worktrees. + // - We might have any number of remotes. + // Pick a reasonable & configurable default to determine the default branch. + // - We might already have the default branch checked out. + // - We might _not_ have the default branch checked out. + // - We might have unstaged/uncommitted work. + // - We might not be on _any_ branch. + + let tempdir = NormalPath::from_cwd(Utf8TempDir::new()?.into_path())?; + let git = app.git.with_directory(opts.repository); + + let repo_root = NormalPath::from_cwd(git.repo_root()?)?; + let repo_name = repo_root + .file_name() + .ok_or_else(|| miette!("Repository has no basename: {repo_root}"))?; + let worktrees = git.worktree_list()?; + let temp_repo_dir = tempdir.clone().tap_mut(|p| p.push(repo_name)); + + // TODO: + // - toposort worktrees + // - resolve them all into unique directory names + + if worktrees.len() != 1 { + return Err(miette!( + "Cannot convert a repository with multiple worktrees into a `git-prole` checkout:\n{worktrees}", + )); + } + + let default_branch = match opts.default_branch { + Some(default_branch) => default_branch, + None => app.pick_default_branch()?, + }; + let default_branch_dirname = App::branch_dirname(&default_branch); + let head = git.head_state()?; + let worktree_dirname = head.branch_name().unwrap_or("work"); + // TODO: Is this sufficient if handling multiple worktrees? + let default_branch_is_checked_out = head.is_on_branch(&default_branch); + + // I don't know, what if you have `fix/main` (not a `fix` remote, but a + // branch named `fix/main`!) checked out, and the default branch is `main`? + if !default_branch_is_checked_out && worktree_dirname == default_branch_dirname { + return Err( + miette!("Worktree directory names for default branch ({default_branch_dirname}) and current branch ({worktree_dirname}) would conflict") + ); + } + + let new_root = repo_root + .clone() + .tap_mut(|p| p.push(default_branch_dirname)); + + let worktree_dir = repo_root.clone().tap_mut(|p| p.push(worktree_dirname)); + + let mut steps = Vec::new(); + + if !default_branch_is_checked_out { + if !head.is_clean() { + steps.push(Step::StashPush { + repo_root: repo_root.clone(), + }); + } + + steps.push(Step::Switch { + repo_root: repo_root.clone(), + branch: default_branch.clone(), + }); + } + + steps.push(Step::MoveWorktree { + from: repo_root.clone(), + to: temp_repo_dir.clone(), + // This will change when we support multiple worktrees! + is_main: true, + }); + + steps.push(Step::CreateDir { + path: repo_root.clone(), + }); + steps.push(Step::MoveWorktree { + from: temp_repo_dir.clone(), + to: new_root.clone(), + // This will change when we support multiple worktrees! + is_main: true, + }); + + if !default_branch_is_checked_out { + steps.push(Step::CreateWorktree { + repo_root: new_root.clone(), + path: worktree_dir.clone(), + commitish: head.commitish().to_owned(), + }); + + if !head.is_clean() { + steps.push(Step::StashPop { + repo_root: worktree_dir.clone(), + }); + } + } + + Ok(Self { + steps, + git, + repo_name: repo_name.to_owned(), + }) + } + + pub fn execute(&self) -> miette::Result<()> { + for step in &self.steps { + tracing::debug!(%step, "Performing step"); + match step { + Step::MoveWorktree { from, to, is_main } => { + if *is_main { + // The main worktree cannot be moved with `git worktree move`. + fs::rename(from, to).into_diagnostic()?; + self.git + .with_directory(to.as_path().to_owned()) + .worktree_repair()?; + } else { + self.git.worktree_move(from, to)?; + } + } + Step::StashPush { repo_root } => self + .git + .with_directory(repo_root.as_path().to_owned()) + .stash_push()?, + Step::Switch { repo_root, branch } => self + .git + .with_directory(repo_root.as_path().to_owned()) + .switch(branch)?, + Step::CreateDir { path } => { + fs::create_dir_all(path).into_diagnostic()?; + } + Step::CreateWorktree { + repo_root, + path, + commitish, + } => { + self.git + .with_directory(repo_root.as_path().to_owned()) + .worktree_add(path.as_path(), commitish)?; + } + Step::StashPop { repo_root } => { + self.git + .with_directory(repo_root.as_path().to_owned()) + .stash_pop()?; + } + } + } + + tracing::info!( + "{} has been converted to a worktree checkout", + self.repo_name + ); + tracing::info!("You may need to `cd .` to refresh your shell"); + + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub enum Step { + MoveWorktree { + from: NormalPath, + to: NormalPath, + is_main: bool, + }, + StashPush { + repo_root: NormalPath, + }, + Switch { + repo_root: NormalPath, + branch: String, + }, + CreateDir { + path: NormalPath, + }, + CreateWorktree { + repo_root: NormalPath, + path: NormalPath, + commitish: String, + }, + StashPop { + repo_root: NormalPath, + }, +} + +impl Display for Step { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Step::MoveWorktree { + from, + to, + is_main: _, + } => { + write!(f, "Move {from} to {to}") + } + Step::StashPush { repo_root } => { + write!(f, "In {repo_root}, stash changes") + } + Step::Switch { repo_root, branch } => { + write!( + f, + "In {repo_root}, switch to branch {}", + branch.if_supports_color(Stream::Stdout, |branch| branch.cyan()) + ) + } + Step::CreateDir { path } => { + write!(f, "Create directory {path}") + } + Step::CreateWorktree { + path, + commitish, + repo_root, + } => { + write!( + f, + "In {repo_root}, create a worktree for {} at {path}", + commitish.if_supports_color(Stream::Stdout, |branch| branch.cyan()) + ) + } + Step::StashPop { repo_root } => { + write!(f, "In {repo_root}, restore changes") + } + } + } +} diff --git a/src/copy_dir.rs b/src/copy_dir.rs new file mode 100644 index 0000000..be1edb7 --- /dev/null +++ b/src/copy_dir.rs @@ -0,0 +1,420 @@ +//! This is a fork of the `copy_dir` crate which can preserve symlinks when copying. +//! +//! Symlink destinations are always transformed into absolute paths. + +use fs_err as fs; +use std::io::{Error, ErrorKind, Result}; +use std::path::Path; + +macro_rules! push_error { + ($expr:expr, $vec:ident) => { + match $expr { + Err(error) => $vec.push(error), + Ok(_) => {} + } + }; +} + +macro_rules! make_err { + ($text:expr, $kind:expr) => { + Error::new($kind, $text) + }; + + ($text:expr) => { + make_err!($text, ErrorKind::Other) + }; +} + +/// Copy a directory and its contents +/// +/// Unlike e.g. the `cp -r` command, the behavior of this function is simple +/// and easy to understand. The file or directory at the source path is copied +/// to the destination path. If the source path points to a directory, it will +/// be copied recursively with its contents. +/// +/// # Errors +/// +/// * It's possible for many errors to occur during the recursive copy +/// operation. These errors are all returned in a `Vec`. They may or may +/// not be helpful or useful. +/// * If the source path does not exist. +/// * If the destination path exists. +/// * If something goes wrong with copying a regular file, as with +/// `std::fs::copy()`. +/// * If something goes wrong creating the new root directory when copying +/// a directory, as with `std::fs::create_dir`. +/// * If you try to copy a directory to a path prefixed by itself i.e. +/// `copy_dir(".", "./foo")`. See below for more details. +/// +/// # Caveats/Limitations +/// +/// I would like to add some flexibility around how "edge cases" in the copying +/// operation are handled, but for now there is no flexibility and the following +/// caveats and limitations apply (not by any means an exhaustive list): +/// +/// * You cannot currently copy a directory into itself i.e. +/// `copy_dir(".", "./foo")`. This is because we are recursively walking +/// the directory to be copied *while* we're copying it, so in this edge +/// case you get an infinite recursion. Fixing this is the top of my list +/// of things to do with this crate. +/// * Hard links are not accounted for, i.e. if more than one hard link +/// pointing to the same inode are to be copied, the data will be copied +/// twice. +/// * Filesystem boundaries may be crossed. +/// * Symbolic links will be copied, not followed. +pub fn copy_dir, P: AsRef>(from: P, to: Q) -> Result> { + if !from.as_ref().exists() { + return Err(make_err!("source path does not exist", ErrorKind::NotFound)); + } else if to.as_ref().exists() { + return Err(make_err!("target path exists", ErrorKind::AlreadyExists)); + } + + let mut errors = Vec::new(); + + // copying a regular file is EZ + if from.as_ref().is_file() { + return fs::copy(&from, &to).map(|_| Vec::new()); + } + + fs::create_dir(&to)?; + + // The approach taken by this code (i.e. walkdir) will not gracefully + // handle copying a directory into itself, so we're going to simply + // disallow it by checking the paths. This is a thornier problem than I + // wish it was, and I'd like to find a better solution, but for now I + // would prefer to return an error rather than having the copy blow up + // in users' faces. Ultimately I think a solution to this will involve + // not using walkdir at all, and might come along with better handling + // of hard links. + let target_is_under_source = from + .as_ref() + .canonicalize() + .and_then(|fc| to.as_ref().canonicalize().map(|tc| (fc, tc))) + .map(|(fc, tc)| tc.starts_with(fc))?; + + if target_is_under_source { + fs::remove_dir(&to)?; + + return Err(make_err!( + "cannot copy to a path prefixed by the source path" + )); + } + + for entry in walkdir::WalkDir::new(&from) + .min_depth(1) + .into_iter() + .filter_map(|e| e.ok()) + { + let relative_path = match entry.path().strip_prefix(&from) { + Ok(rp) => rp, + Err(_) => panic!("strip_prefix failed; this is a probably a bug in copy_dir"), + }; + + let target_path = { + let mut target_path = to.as_ref().to_path_buf(); + target_path.push(relative_path); + target_path + }; + + let source_metadata = match entry.metadata() { + Err(error) => { + errors.push(make_err!(format!( + "walkdir metadata error for {:?}: {error}", + entry.path() + ))); + + continue; + } + + Ok(md) => md, + }; + + if source_metadata.is_dir() { + tracing::trace!( + from=?entry.path(), + to=?target_path, + "Copying directory" + ); + push_error!(fs::create_dir(&target_path), errors); + push_error!( + fs::set_permissions(&target_path, source_metadata.permissions()), + errors + ); + } else if entry.path_is_symlink() { + // We need to get the result from the `read_link` call here, so we can't use the + // `push_error!` macro. + let dest = match fs::read_link(entry.path()) { + Ok(dest) => { + if dest.is_absolute() { + dest + } else { + entry.path().join(dest) + } + } + Err(error) => { + errors.push(error); + continue; + } + }; + + tracing::trace!( + from=?entry.path(), + to=?target_path, + link_dest=?dest, + "Copying symlink" + ); + push_error!(fs::os::unix::fs::symlink(dest, &target_path), errors); + push_error!( + fs::set_permissions(&target_path, source_metadata.permissions()), + errors + ); + } else { + tracing::trace!( + from=?entry.path(), + to=?target_path, + "Copying file" + ); + push_error!(fs::copy(entry.path(), &target_path), errors); + } + } + + Ok(errors) +} + +#[cfg(test)] +mod tests { + #![allow(unused_variables)] + + use fs_err as fs; + use std::path::Path; + use std::process::Command; + use tempfile::TempDir; + + #[test] + fn single_file() { + let file = File("foo.file"); + assert_we_match_the_real_thing(&file, true, None); + } + + #[test] + fn directory_with_file() { + let dir = Dir( + "foo", + vec![File("bar"), Dir("baz", vec![File("quux"), File("fobe")])], + ); + assert_we_match_the_real_thing(&dir, true, None); + } + + #[test] + fn source_does_not_exist() { + let base_dir = TempDir::new().unwrap(); + let source_path = base_dir.as_ref().join("noexist.file"); + match super::copy_dir(&source_path, "dest.file") { + Ok(_) => panic!("expected Err"), + Err(err) => match err.kind() { + std::io::ErrorKind::NotFound => (), + _ => panic!("expected kind NotFound"), + }, + } + } + + #[test] + fn target_exists() { + let base_dir = TempDir::new().unwrap(); + let source_path = base_dir.as_ref().join("exist.file"); + let target_path = base_dir.as_ref().join("exist2.file"); + + { + fs::File::create(&source_path).unwrap(); + fs::File::create(&target_path).unwrap(); + } + + match super::copy_dir(&source_path, &target_path) { + Ok(_) => panic!("expected Err"), + Err(err) => match err.kind() { + std::io::ErrorKind::AlreadyExists => (), + _ => panic!("expected kind AlreadyExists"), + }, + } + } + + #[test] + fn attempt_copy_under_self() { + let base_dir = TempDir::new().unwrap(); + let dir = Dir( + "foo", + vec![File("bar"), Dir("baz", vec![File("quux"), File("fobe")])], + ); + dir.create(&base_dir).unwrap(); + + let from = base_dir.as_ref().join("foo"); + let to = from.as_path().join("beez"); + + let copy_result = super::copy_dir(&from, &to); + assert!(copy_result.is_err()); + + let copy_err = copy_result.unwrap_err(); + assert_eq!(copy_err.kind(), std::io::ErrorKind::Other); + } + + // utility stuff below here + + enum DirMaker<'a> { + Dir(&'a str, Vec>), + File(&'a str), + } + + use self::DirMaker::*; + + impl<'a> DirMaker<'a> { + fn create>(&self, base: P) -> std::io::Result<()> { + match *self { + Dir(ref name, ref contents) => { + let path = base.as_ref().join(name); + fs::create_dir(&path)?; + + for thing in contents { + thing.create(&path)?; + } + } + + File(ref name) => { + let path = base.as_ref().join(name); + fs::File::create(path)?; + } + } + + Ok(()) + } + + fn name(&self) -> &str { + match *self { + Dir(name, _) => name, + File(name) => name, + } + } + } + + fn assert_dirs_same>(a: P, b: P) { + let mut wa = walkdir::WalkDir::new(a.as_ref()) + .sort_by_file_name() + .into_iter(); + let mut wb = walkdir::WalkDir::new(b.as_ref()) + .sort_by_file_name() + .into_iter(); + + loop { + let o_na = wa.next(); + let o_nb = wb.next(); + + if o_na.is_some() && o_nb.is_some() { + let r_na = o_na.unwrap(); + let r_nb = o_nb.unwrap(); + + if r_na.is_ok() && r_nb.is_ok() { + let na = r_na.unwrap(); + let nb = r_nb.unwrap(); + + assert_eq!( + na.path().strip_prefix(a.as_ref()), + nb.path().strip_prefix(b.as_ref()) + ); + + assert_eq!(na.file_type(), nb.file_type()); + + // TODO test permissions + } + } else if o_na.is_none() && o_nb.is_none() { + return; + } else { + panic!() + } + } + } + + fn assert_we_match_the_real_thing( + dir: &DirMaker, + explicit_name: bool, + o_pre_state: Option<&DirMaker>, + ) { + let base_dir = TempDir::new().unwrap(); + + let source_dir = base_dir.as_ref().join("source"); + let our_dir = base_dir.as_ref().join("ours"); + let their_dir = base_dir.as_ref().join("theirs"); + + fs::create_dir(&source_dir).unwrap(); + fs::create_dir(&our_dir).unwrap(); + fs::create_dir(&their_dir).unwrap(); + + dir.create(&source_dir).unwrap(); + let source_path = source_dir.as_path().join(dir.name()); + + let (our_target, their_target) = if explicit_name { + ( + our_dir.as_path().join(dir.name()), + their_dir.as_path().join(dir.name()), + ) + } else { + (our_dir.clone(), their_dir.clone()) + }; + + if let Some(pre_state) = o_pre_state { + pre_state.create(&our_dir).unwrap(); + pre_state.create(&their_dir).unwrap(); + } + + let we_good = super::copy_dir(&source_path, &our_target).is_ok(); + + let their_status = Command::new("cp") + .arg("-r") + .arg(source_path.as_os_str()) + .arg(their_target.as_os_str()) + .status() + .unwrap(); + + // TODO any way to ask cp whether it worked or not? + // portability? + // assert_eq!(we_good, their_status.success()); + assert_dirs_same(&their_dir, &our_dir); + } + + #[test] + fn dir_maker_and_assert_dirs_same_baseline() { + let dir = Dir("foobar", vec![File("bar"), Dir("baz", Vec::new())]); + + let base_dir = TempDir::new().unwrap(); + + let a_path = base_dir.as_ref().join("a"); + let b_path = base_dir.as_ref().join("b"); + + fs::create_dir(&a_path).unwrap(); + fs::create_dir(&b_path).unwrap(); + + dir.create(&a_path).unwrap(); + dir.create(&b_path).unwrap(); + + assert_dirs_same(&a_path, &b_path); + } + + #[test] + #[should_panic] + fn assert_dirs_same_properly_fails() { + let dir = Dir("foobar", vec![File("bar"), Dir("baz", Vec::new())]); + + let dir2 = Dir("foobar", vec![File("fobe"), File("beez")]); + + let base_dir = TempDir::new().unwrap(); + + let a_path = base_dir.as_ref().join("a"); + let b_path = base_dir.as_ref().join("b"); + + fs::create_dir(&a_path).unwrap(); + fs::create_dir(&b_path).unwrap(); + + dir.create(&a_path).unwrap(); + dir2.create(&b_path).unwrap(); + + assert_dirs_same(&a_path, &b_path); + } +} diff --git a/src/current_dir.rs b/src/current_dir.rs new file mode 100644 index 0000000..30c2746 --- /dev/null +++ b/src/current_dir.rs @@ -0,0 +1,20 @@ +use std::path::PathBuf; + +use camino::Utf8PathBuf; +use miette::miette; +use miette::Context; +use miette::IntoDiagnostic; + +/// Get the current working directory of the process with [`std::env::current_dir`]. +pub fn current_dir() -> miette::Result { + std::env::current_dir() + .into_diagnostic() + .wrap_err("Failed to get current directory") +} + +/// Get the current working directory of the process as a [`Utf8PathBuf`]. +pub fn current_dir_utf8() -> miette::Result { + current_dir()? + .try_into() + .map_err(|path| miette!("Current directory isn't valid UTF-8: {path:?}")) +} diff --git a/src/format_bulleted_list.rs b/src/format_bulleted_list.rs new file mode 100644 index 0000000..91d8a89 --- /dev/null +++ b/src/format_bulleted_list.rs @@ -0,0 +1,14 @@ +use std::fmt::Display; + +use itertools::Itertools; + +/// Format an iterator of items into a bulleted list with line breaks between elements. +pub fn format_bulleted_list(items: impl IntoIterator) -> String { + let mut items = items.into_iter().peekable(); + if items.peek().is_none() { + String::new() + } else { + // This kind of sucks. + format!("• {}", items.join("\n• ")) + } +} diff --git a/src/gh.rs b/src/gh.rs new file mode 100644 index 0000000..6f15d19 --- /dev/null +++ b/src/gh.rs @@ -0,0 +1,18 @@ +use std::sync::OnceLock; + +use regex::Regex; + +pub fn looks_like_gh_url(url: &str) -> bool { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r"(?xm) + ^ + [[:word:]]{1,39}(/[[:word:]]+)? + $ + ", + ) + .expect("Regex parses") + }) + .is_match(url) +} diff --git a/src/git.rs b/src/git.rs deleted file mode 100644 index 5a0c41b..0000000 --- a/src/git.rs +++ /dev/null @@ -1,165 +0,0 @@ -use std::process::Command; -use std::sync::OnceLock; - -use camino::Utf8PathBuf; -use command_error::CommandExt; -use miette::miette; -use miette::Context; -use miette::IntoDiagnostic; -use regex::Regex; - -use crate::commit_hash::CommitHash; - -/// `git` CLI wrapper. -#[derive(Debug, Default)] -pub struct Git {} - -impl Git { - #[expect(dead_code)] - pub fn new() -> Self { - Default::default() - } - - /// Get a `git` command. - pub fn command(&self) -> Command { - Command::new("git") - } - - /// Get a list of all `git remote`s. - #[expect(dead_code)] - pub fn remotes(&self) -> miette::Result> { - Ok(self - .command() - .arg("remote") - .output_checked_utf8() - .into_diagnostic() - .wrap_err("Failed to list Git remotes")? - .stdout - .lines() - .map(|line| line.to_owned()) - .collect()) - } - - /// Get the (push) URL for the given remote. - #[expect(dead_code)] - pub fn remote_url(&self, remote: &str) -> miette::Result { - Ok(self - .command() - .args(["remote", "get-url", "--push", remote]) - .output_checked_utf8() - .into_diagnostic() - .wrap_err("Failed to get Git remote URL")? - .stdout - .trim() - .to_owned()) - } - - fn default_branch_symbolic_ref(&self, remote: &str) -> miette::Result { - let output = self - .command() - .args([ - "symbolic-ref", - "--short", - &format!("refs/remotes/{remote}/HEAD"), - ]) - .output_checked_utf8() - .into_diagnostic()? - .stdout; - - static RE: OnceLock = OnceLock::new(); - let captures = RE - .get_or_init(|| { - Regex::new( - r"(?xm) - ^ - (?P[[:word:]]+)/(?P[[:word:]]+) - $ - ", - ) - .expect("Regex parses") - }) - .captures(&output); - - match captures { - Some(captures) => Ok(captures["branch"].to_owned()), - None => Err(miette!( - "Could not parse `git symbolic-ref` output:\n{output}" - )), - } - } - - fn default_branch_ls_remote(&self, remote: &str) -> miette::Result { - let output = self - .command() - .args(["ls-remote", "--symref", remote, "HEAD"]) - .output_checked_utf8() - .into_diagnostic()? - .stdout; - - static RE: OnceLock = OnceLock::new(); - let captures = RE - .get_or_init(|| { - Regex::new( - r"(?xm) - ^ - ref: refs/heads/(?P[[:word:]]+)\tHEAD - $ - ", - ) - .expect("Regex parses") - }) - .captures(&output); - - match captures { - Some(captures) => Ok(captures["branch"].to_owned()), - None => Err(miette!("Could not parse `git ls-remote` output:\n{output}")), - } - } - - #[expect(dead_code)] - pub fn default_branch(&self, remote: &str) -> miette::Result { - self.default_branch_symbolic_ref(remote).or_else(|err| { - tracing::debug!("Failed to get default branch: {err}"); - self.default_branch_ls_remote(remote) - }) - } - - #[expect(dead_code)] - pub fn commit_message(&self, commit: &str) -> miette::Result { - Ok(self - .command() - .args(["show", "--no-patch", "--format=%B", commit]) - .output_checked_utf8() - .into_diagnostic() - .wrap_err("Failed to get commit message")? - .stdout) - } - - /// Get the `HEAD` commit hash. - #[expect(dead_code)] - pub fn get_head(&self) -> miette::Result { - self.rev_parse("HEAD") - } - - /// Get the `.git` directory path. - #[expect(dead_code)] - pub fn get_git_dir(&self) -> miette::Result { - self.command() - .args(["rev-parse", "--git-dir"]) - .output_checked_utf8() - .into_diagnostic() - .map(|output| Utf8PathBuf::from(output.stdout.trim())) - } - - pub fn rev_parse(&self, commitish: &str) -> miette::Result { - Ok(CommitHash::new( - self.command() - .args(["rev-parse", commitish]) - .output_checked_utf8() - .into_diagnostic()? - .stdout - .trim() - .to_owned(), - )) - } -} diff --git a/src/git/commit_hash.rs b/src/git/commit_hash.rs new file mode 100644 index 0000000..b69f322 --- /dev/null +++ b/src/git/commit_hash.rs @@ -0,0 +1,26 @@ +use std::fmt::Display; + +use derive_more::{AsRef, Constructor, Deref, DerefMut, From, Into}; + +/// A Git commit hash. +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Into, From, AsRef, Deref, DerefMut, Constructor, +)] +pub struct CommitHash(String); + +impl CommitHash { + /// Get an abbreviated 8-character Git hash. + pub fn abbrev(&self) -> &str { + &self.0[..8] + } +} + +impl Display for CommitHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if f.alternate() { + Display::fmt(&self.0, f) + } else { + Display::fmt(self.abbrev(), f) + } + } +} diff --git a/src/git/commitish.rs b/src/git/commitish.rs new file mode 100644 index 0000000..d853b28 --- /dev/null +++ b/src/git/commitish.rs @@ -0,0 +1,22 @@ +use std::fmt::Display; + +use super::commit_hash::CommitHash; +use super::ref_name::Ref; + +/// A resolved ``, which can either be a commit hash or a ref name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedCommitish { + /// A commit hash. + Commit(CommitHash), + /// A ref name. + Ref(Ref), +} + +impl Display for ResolvedCommitish { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResolvedCommitish::Commit(commit) => Display::fmt(commit, f), + ResolvedCommitish::Ref(ref_name) => Display::fmt(ref_name, f), + } + } +} diff --git a/src/git/head_state.rs b/src/git/head_state.rs new file mode 100644 index 0000000..bcd8eb1 --- /dev/null +++ b/src/git/head_state.rs @@ -0,0 +1,54 @@ +use std::fmt::Display; + +use super::commit_hash::CommitHash; +use super::ref_name::Ref; +use super::status::Status; + +/// What, exactly, is `HEAD` doing? +#[derive(Debug)] +pub struct Head { + pub status: Status, + pub kind: HeadKind, +} + +impl Head { + pub fn is_clean(&self) -> bool { + self.status.is_clean() + } + + pub fn commitish(&self) -> &str { + match &self.kind { + HeadKind::Detached(commit) => commit.as_str(), + HeadKind::Ref(ref_name) => ref_name.name(), + } + } + + pub fn branch_name(&self) -> Option<&str> { + match &self.kind { + HeadKind::Detached(_) => None, + // There's no way we can have a remote branch checked out. + HeadKind::Ref(ref_name) => ref_name.local_branch_name(), + } + } + + pub fn is_on_branch(&self, branch: &str) -> bool { + self.branch_name() + .map_or(false, |checked_out| branch == checked_out) + } +} + +/// Is `HEAD` detached? +#[derive(Debug, PartialEq, Eq)] +pub enum HeadKind { + Detached(CommitHash), + Ref(Ref), +} + +impl Display for HeadKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HeadKind::Detached(commit) => Display::fmt(commit, f), + HeadKind::Ref(ref_name) => Display::fmt(ref_name, f), + } + } +} diff --git a/src/git/mod.rs b/src/git/mod.rs new file mode 100644 index 0000000..dcc561e --- /dev/null +++ b/src/git/mod.rs @@ -0,0 +1,549 @@ +use std::collections::HashSet; +use std::process::Command; +use std::str::FromStr; +use std::sync::OnceLock; + +use camino::Utf8Path; +use camino::Utf8PathBuf; +use command_error::CommandExt; +use command_error::OutputContext; +use commitish::ResolvedCommitish; +use head_state::Head; +use head_state::HeadKind; +use miette::miette; +use miette::Context; +use miette::IntoDiagnostic; +use ref_name::Ref; +use regex::Regex; +use status::Status; +use tracing::instrument; +use utf8_command::Utf8Output; + +pub mod commit_hash; +pub mod commitish; +pub mod head_state; +pub mod ref_name; +pub mod repository_url_destination; +pub mod status; +pub mod worktree; + +use commit_hash::CommitHash; +use worktree::Worktrees; + +/// `git` CLI wrapper. +#[derive(Debug, Default, Clone)] +pub struct Git { + current_dir: Option, +} + +impl Git { + pub fn new() -> Self { + Default::default() + } + + /// Get a `git` command. + #[instrument(level = "trace")] + pub fn command(&self) -> Command { + let mut command = Command::new("git"); + if let Some(current_dir) = &self.current_dir { + command.current_dir(current_dir); + } + command + } + + /// Set the current working directory for `git` commands to be run in. + pub fn set_directory(&mut self, path: Utf8PathBuf) { + self.current_dir = Some(path); + } + + pub fn with_directory(&self, path: Utf8PathBuf) -> Self { + let mut ret = self.clone(); + ret.set_directory(path); + ret + } + + fn rev_parse_command(&self) -> Command { + let mut command = self.command(); + command.args(["rev-parse", "--path-format=absolute"]); + command + } + + /// `git rev-parse --show-toplevel` + pub fn repo_root(&self) -> miette::Result { + Ok(self + .rev_parse_command() + .arg("--show-toplevel") + .output_checked_utf8() + .into_diagnostic() + .wrap_err("Failed to get working directory of repository")? + .stdout + .trim() + .into()) + } + + /// Get a list of all `git remote`s. + pub fn remotes(&self) -> miette::Result> { + Ok(self + .command() + .arg("remote") + .output_checked_utf8() + .into_diagnostic() + .wrap_err("Failed to list Git remotes")? + .stdout + .lines() + .map(|line| line.to_owned()) + .collect()) + } + + /// Get the (push) URL for the given remote. + #[expect(dead_code)] + pub fn remote_url(&self, remote: &str) -> miette::Result { + Ok(self + .command() + .args(["remote", "get-url", "--push", remote]) + .output_checked_utf8() + .into_diagnostic() + .wrap_err("Failed to get Git remote URL")? + .stdout + .trim() + .to_owned()) + } + + fn default_branch_symbolic_ref(&self, remote: &str) -> miette::Result { + let output = self + .command() + .args([ + "symbolic-ref", + "--short", + &format!("refs/remotes/{remote}/HEAD"), + ]) + .output_checked_utf8() + .into_diagnostic()? + .stdout; + + static RE: OnceLock = OnceLock::new(); + let captures = RE + .get_or_init(|| { + Regex::new( + r"(?xm) + ^ + (?P[[:word:]]+)/(?P.+) + $ + ", + ) + .expect("Regex parses") + }) + .captures(&output); + + match captures { + Some(captures) => Ok(captures["branch"].to_owned()), + None => Err(miette!( + "Could not parse `git symbolic-ref` output:\n{output}" + )), + } + } + + fn default_branch_ls_remote(&self, remote: &str) -> miette::Result { + let output = self + .command() + .args(["ls-remote", "--symref", remote, "HEAD"]) + .output_checked_utf8() + .into_diagnostic()? + .stdout; + + static RE: OnceLock = OnceLock::new(); + let captures = RE + .get_or_init(|| { + Regex::new( + r"(?xm) + ^ + ref:\ refs/heads/(?P[^\t]+)\tHEAD + $ + ", + ) + .expect("Regex parses") + }) + .captures(&output); + + let branch = match captures { + Some(captures) => Ok(captures["branch"].to_owned()), + None => Err(miette!("Could not parse `git ls-remote` output:\n{output}")), + }?; + + // To avoid talking to the remote next time, write a symbolic-ref. + self.command() + .args([ + "symbolic-ref", + &format!("refs/remotes/{remote}/HEAD"), + &format!("refs/remotes/{remote}/{branch}"), + ]) + .output_checked_utf8() + .into_diagnostic() + .wrap_err_with(|| { + format!("Failed to store symbolic ref for default branch for remote {remote}") + })?; + + Ok(branch) + } + + pub fn default_branch(&self, remote: &str) -> miette::Result { + self.default_branch_symbolic_ref(remote).or_else(|err| { + tracing::debug!("Failed to get default branch: {err}"); + self.default_branch_ls_remote(remote) + }) + } + + #[expect(dead_code)] + pub fn commit_message(&self, commit: &str) -> miette::Result { + Ok(self + .command() + .args(["show", "--no-patch", "--format=%B", commit]) + .output_checked_utf8() + .into_diagnostic() + .wrap_err("Failed to get commit message")? + .stdout) + } + + /// Get the `HEAD` commit hash. + pub fn get_head(&self) -> miette::Result { + Ok(self.rev_parse("HEAD")?.expect("HEAD always exists")) + } + + /// Get the `.git` directory path. + #[expect(dead_code)] + pub fn get_git_dir(&self) -> miette::Result { + self.rev_parse_command() + .arg("--git-dir") + .output_checked_utf8() + .into_diagnostic() + .map(|output| Utf8PathBuf::from(output.stdout.trim())) + } + + /// Get the common `.git` directory for all worktrees. + pub fn git_common_dir(&self) -> miette::Result { + self.rev_parse_command() + .arg("--git-common-dir") + .output_checked_utf8() + .into_diagnostic() + .map(|output| Utf8PathBuf::from(output.stdout.trim())) + } + + /// Parse a `commitish` into a commit hash. + pub fn rev_parse(&self, commitish: &str) -> miette::Result> { + self.rev_parse_command() + .args(["--verify", "--quiet", "--end-of-options", commitish]) + .output_checked_as(|context: OutputContext| { + if context.status().success() { + Ok::<_, command_error::Error>(Some(CommitHash::new( + context.output().stdout.trim().to_owned(), + ))) + } else { + Ok(None) + } + }) + .into_diagnostic() + } + + /// `git rev-parse --symbolic-full-name` + pub fn rev_parse_symbolic_full_name(&self, commitish: &str) -> miette::Result> { + self.rev_parse_command() + .args([ + "--symbolic-full-name", + "--verify", + "--quiet", + "--end-of-options", + commitish, + ]) + .output_checked_as(|context: OutputContext| { + if context.status().success() { + let trimmed = context.output().stdout.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Ref::from_str(trimmed) + .map(Some) + .map_err(|err| context.error_msg(err)) + } + } else { + Ok(None) + } + }) + .into_diagnostic() + } + + /// Determine if a given `` refers to a commit or a symbolic ref name. + #[expect(dead_code)] + pub fn resolve_commitish(&self, commitish: &str) -> miette::Result { + match self.rev_parse_symbolic_full_name(commitish)? { + Some(ref_name) => Ok(ResolvedCommitish::Ref(ref_name)), + None => Ok(ResolvedCommitish::Commit( + self.rev_parse(commitish)?.ok_or_else(|| { + miette!("Commitish could not be resolved to a ref or commit hash: {commitish}") + })?, + )), + } + } + + /// Get the 'main' worktree. There can only be one main worktree, and it contains the + /// common `.git` directory. + /// + /// See: + pub fn main_worktree(&self) -> miette::Result { + let mut worktree = self.git_common_dir()?; + // This seems incredibly buggy, given that bare checkouts are a thing and Git has + // mechanisms for keeping the `.git` directory and the working tree in different + // places, but it's what the Git source code does! + // + // See: https://github.com/git/git/blob/90fe3800b92a49173530828c0a17951abd30f0e1/worktree.c#L76 + // See: https://stackoverflow.com/a/21085415 + if worktree.ends_with(".git") { + worktree.pop(); + } + Ok(worktree) + } + + /// Get the worktree container directory. + /// + /// This is the main worktree's parent, and is usually where all the other worktrees are cloned + /// as well. + pub fn worktree_container(&self) -> miette::Result { + // TODO: Write `.git-prole` to indicate worktree container root? + let mut container = self.main_worktree()?; + if !container.pop() { + Err(miette!("Main worktree path has no parent: {container}")) + } else { + Ok(container) + } + } + + /// List Git worktrees. + pub fn worktree_list(&self) -> miette::Result { + Worktrees::from_git(self) + } + + pub fn is_head_detached(&self) -> miette::Result { + let output = self + .command() + .args(["symbolic-ref", "--quiet", "HEAD"]) + .output_checked_with_utf8::(|_output| Ok(())) + .into_diagnostic()?; + + Ok(!output.status.success()) + } + + pub fn stash_push(&self) -> miette::Result<()> { + self.command() + .args(["stash", "push", "--all"]) + .output_checked_utf8() + .into_diagnostic()?; + Ok(()) + } + + pub fn stash_pop(&self) -> miette::Result<()> { + self.command() + .args(["stash", "pop", "--all"]) + .output_checked_utf8() + .into_diagnostic()?; + Ok(()) + } + + pub fn status(&self) -> miette::Result { + self.command() + .args(["status", "--porcelain=v1", "--ignored=traditional", "-z"]) + .output_checked_as(|context: OutputContext| { + if context.status().success() { + Status::from_str(&context.output().stdout).map_err(|err| context.error_msg(err)) + } else { + Err(context.error()) + } + }) + .into_diagnostic() + } + + /// Figure out what's going on with `HEAD`. + pub fn head_state(&self) -> miette::Result { + let status = self.status()?; + let kind = if self.is_head_detached()? { + HeadKind::Detached(self.get_head()?) + } else { + HeadKind::Ref( + self.rev_parse_symbolic_full_name("HEAD")? + .expect("HEAD should always be a valid ref"), + ) + }; + Ok(Head { status, kind }) + } + + /// List untracked files and directories. + pub fn untracked_files(&self) -> miette::Result> { + Ok(self + .command() + .args([ + "ls-files", + // Show untracked (e.g. ignored) files. + "--others", + // If a whole directory is classified as other, show just its name and not its + // whole contents. + "--directory", + "-z", + ]) + .output_checked_utf8() + .into_diagnostic()? + .stdout + .split('\0') + .filter(|path| !path.is_empty()) + .map(Utf8PathBuf::from) + .collect()) + } + + /// Lists local branches. + pub fn list_local_branches(&self) -> miette::Result> { + Ok(self + .command() + .args(["branch", "--format=%(refname:short)"]) + .output_checked_utf8() + .into_diagnostic()? + .stdout + .lines() + .map(|line| line.to_owned()) + .collect()) + } + + pub fn local_branch_exists(&self, branch: &str) -> miette::Result { + self.command() + .args(["show-ref", "--quiet", "--branches", branch]) + .output_checked_as(|context: OutputContext| { + Ok::<_, command_error::Error>(context.status().success()) + }) + .into_diagnostic() + } + + /// Get the `checkout.defaultRemote` setting. + pub fn default_remote(&self) -> miette::Result> { + self.get_config("checkout.defaultRemote") + } + + /// Find a unique remote branch by name. + /// + /// The discovered remote, if any, is returned. + /// + /// This is (hopefully!) how Git determines which remote-tracking branch you want when you do a + /// `git switch` or `git worktree add`. + pub fn find_remote_for_branch(&self, branch: &str) -> miette::Result> { + let refs = self + .command() + .args([ + "for-each-ref", + "--format=%(refname)", + &format!("refs/remotes/*/{branch}"), + ]) + .output_checked_utf8() + .into_diagnostic()? + .stdout; + + let mut exists_on_remotes = Vec::new(); + + for ref_name in refs.lines() { + let parsed_ref = Ref::from_str(ref_name)?; + match parsed_ref.remote_and_branch() { + Some((remote, _branch)) => { + exists_on_remotes.push(remote.to_owned()); + } + None => { + unreachable!() + } + } + } + + if exists_on_remotes.is_empty() { + Ok(None) + } else if exists_on_remotes.len() == 1 { + Ok(exists_on_remotes.pop()) + } else if let Some(default_remote) = self.default_remote()? { + // if-let chains when? + if exists_on_remotes.contains(&default_remote) { + Ok(Some(default_remote)) + } else { + Ok(None) + } + } else { + Ok(None) + } + } + + pub fn switch(&self, branch: &str) -> miette::Result<()> { + self.command() + .args(["switch", branch]) + .status_checked() + .into_diagnostic()?; + Ok(()) + } + + pub fn worktree_add(&self, path: &Utf8Path, commitish: &str) -> miette::Result<()> { + self.command() + .args(["worktree", "add", path.as_str(), commitish]) + .status_checked() + .into_diagnostic()?; + Ok(()) + } + + pub fn worktree_move(&self, from: &Utf8Path, to: &Utf8Path) -> miette::Result<()> { + self.command() + .current_dir(from) + .args(["worktree", "move", from.as_str(), to.as_str()]) + .status_checked() + .into_diagnostic()?; + Ok(()) + } + + pub fn worktree_repair(&self) -> miette::Result<()> { + self.command() + .args(["worktree", "repair"]) + .status_checked() + .into_diagnostic()?; + Ok(()) + } + + pub fn clone_repository( + &self, + repository: &str, + destination: Option<&Utf8Path>, + args: &[String], + ) -> miette::Result<()> { + let mut command = self.command(); + command.arg("clone").args(args).arg(repository); + if let Some(destination) = destination { + command.arg(destination); + } + command.status_checked().into_diagnostic()?; + Ok(()) + } + + /// Get a config setting by name. + pub fn get_config(&self, key: &str) -> miette::Result> { + self.command() + .args(["config", "get", "--null", key]) + .output_checked_as(|context: OutputContext| { + if context.status().success() { + match context.output().stdout.as_str().split_once('\0') { + Some((value, rest)) => { + if !rest.is_empty() { + tracing::warn!( + %key, + data=rest, + "Trailing data in `git config` output" + ); + } + Ok(Some(value.to_owned())) + } + None => Err(context.error_msg("Output didn't contain any null bytes")), + } + } else if let Some(1) = context.status().code() { + Ok(None) + } else { + Err(context.error()) + } + }) + .into_diagnostic() + } +} diff --git a/src/git/ref_name.rs b/src/git/ref_name.rs new file mode 100644 index 0000000..769935e --- /dev/null +++ b/src/git/ref_name.rs @@ -0,0 +1,93 @@ +use std::fmt::Display; +use std::str::FromStr; + +use miette::miette; + +/// A Git ref (a file under `refs`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ref { + /// The ref kind; usually `heads`, `remotes`, or `tags`. + /// + /// Other kinds: + /// - `stash` + /// - `bisect` + kind: String, + /// The ref name; everything after the kind. + name: String, +} + +impl Ref { + /// The `kind` indicating a branch reference. + const HEADS: &str = "heads"; + /// The `kind` indicating a remote-tracking branch reference. + const REMOTES: &str = "remotes"; + /// The `kind` indicating a tag reference. + const TAGS: &str = "tags"; + + pub fn name(&self) -> &str { + &self.name + } + + /// Determine if this is a remote branch, i.e. its kind is [`Self::REMOTES`]. + pub fn is_remote_branch(&self) -> bool { + self.kind == Self::REMOTES + } + + /// Determine if this is a local branch, i.e. its kind is [`Self::HEADS`]. + pub fn is_local_branch(&self) -> bool { + self.kind == Self::HEADS + } + + /// Determine if this is a tag, i.e. its kind is [`Self::TAGS`]. + #[expect(dead_code)] + pub fn is_tag(&self) -> bool { + self.kind == Self::TAGS + } + + /// If this is a local branch ref, return the branch name. + pub fn local_branch_name(&self) -> Option<&str> { + if self.is_local_branch() { + Some(&self.name) + } else { + None + } + } + + /// If this is a remote branch ref, return a pair of the remote and branch names. + pub fn remote_and_branch(&self) -> Option<(&str, &str)> { + if self.is_remote_branch() { + self.name.split_once('/') + } else { + None + } + } +} + +impl FromStr for Ref { + type Err = miette::Report; + + fn from_str(original: &str) -> Result { + let rest = original + .strip_prefix("refs/") + .ok_or_else(|| miette!("Refs must start with `refs/`: {original}"))?; + + let (kind, name) = rest.split_once('/').ok_or_else(|| { + miette!("Ref names should have at least one `/` after `refs/`: {original}") + })?; + + Ok(Self { + kind: kind.to_owned(), + name: name.to_owned(), + }) + } +} + +impl Display for Ref { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if f.alternate() { + write!(f, "refs/{}/{}", self.kind, self.name) + } else { + write!(f, "{}", self.name) + } + } +} diff --git a/src/git/repository_url_destination.rs b/src/git/repository_url_destination.rs new file mode 100644 index 0000000..c19a1c5 --- /dev/null +++ b/src/git/repository_url_destination.rs @@ -0,0 +1,12 @@ +/// Where will `url` be cloned to? +/// +/// It's always in the current directory. +pub fn repository_url_destination(url: &str) -> &str { + let last_component = match url.rsplit_once('/') { + Some((_before, after)) => after, + None => url, + }; + last_component + .strip_suffix(".git") + .unwrap_or(last_component) +} diff --git a/src/git/status.rs b/src/git/status.rs new file mode 100644 index 0000000..3a55202 --- /dev/null +++ b/src/git/status.rs @@ -0,0 +1,170 @@ +use std::iter; +use std::str::FromStr; + +use camino::Utf8PathBuf; +use miette::miette; + +/// The status code of a particular file. Each [`StatusEntry`] has two of these. +#[derive(Debug, Clone, Copy)] +pub enum StatusCode { + /// ` ` + Unmodified, + /// `M` + Modified, + /// `T` + TypeChanged, + /// `A` + Added, + /// `D` + Deleted, + /// `R` + Renamed, + /// `C` + Copied, + /// `U` + Unmerged, + /// `?` + Untracked, + /// `!` + Ignored, +} + +impl StatusCode { + pub fn parse(status: char) -> Option { + Some(match status { + ' ' => Self::Unmodified, + 'M' => Self::Modified, + 'T' => Self::TypeChanged, + 'A' => Self::Added, + 'D' => Self::Deleted, + 'R' => Self::Renamed, + 'C' => Self::Copied, + 'U' => Self::Unmerged, + '?' => Self::Untracked, + '!' => Self::Ignored, + _ => { + return None; + } + }) + } +} + +/// The status of a particular file. +#[derive(Debug, Clone)] +pub struct StatusEntry { + /// If no merge is occurring, or a merge was successful, this indicates the status of the + /// index. + /// + /// If a merge conflict has occured and is not resolved, this is the left head of th + /// merge. + left: StatusCode, + /// If no merge is occurring, or a merge was successful, this indicates the status of the + /// working tree. + /// + /// If a merge conflict has occured and is not resolved, this is the right head of th + /// merge. + right: StatusCode, + #[expect(dead_code)] + path: Utf8PathBuf, + renamed_from: Option, +} + +impl StatusEntry { + pub fn codes(&self) -> impl Iterator { + iter::once(self.left).chain(iter::once(self.right)) + } + + pub fn is_renamed(&self) -> bool { + self.codes().any(|code| matches!(code, StatusCode::Renamed)) + } + + /// True if the file is not ignored, untracked, or unmodified. + pub fn is_modified(&self) -> bool { + self.codes().any(|code| { + !matches!( + code, + StatusCode::Ignored | StatusCode::Untracked | StatusCode::Unmodified + ) + }) + } +} + +/// A `git status` listing. +/// +/// ```plain +/// M Cargo.lock +/// M Cargo.toml +/// M src/app.rs +/// M src/cli.rs +/// D src/commit_hash.rs +/// D src/git.rs +/// M src/main.rs +/// D src/ref_name.rs +/// D src/worktree.rs +/// ?? src/config.rs +/// ?? src/git/ +/// ?? src/utf8tempdir.rs +/// !! target/ +/// ``` +#[derive(Debug, Clone)] +pub struct Status { + entries: Vec, +} + +impl Status { + pub fn is_clean(&self) -> bool { + self.entries.iter().all(|entry| !entry.is_modified()) + } +} + +impl FromStr for Status { + type Err = miette::Report; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Ok(Self { + entries: Vec::new(), + }); + } + + let mut entries = Vec::new(); + let mut tokens = s.trim_end_matches('\0').split('\0'); + + while let Some(token) = tokens.next() { + let (status, path) = token + .split_at_checked(2) + .ok_or_else(|| miette!("`git status` output is weird: {token:?}"))?; + + let mut status_chars = status.chars(); + let left = status_chars + .next() + .ok_or_else(|| miette!("`git status` output missing status: {token:?}"))?; + let left = StatusCode::parse(left) + .ok_or_else(|| miette!("Unknown `git status` code {left} in: {token:?}"))?; + let right = status_chars + .next() + .ok_or_else(|| miette!("`git status` output missing status: {token:?}"))?; + let right = StatusCode::parse(right) + .ok_or_else(|| miette!("Unknown `git status` code {right} in: {token:?}"))?; + + let mut entry = StatusEntry { + left, + right, + path: Utf8PathBuf::from(path), + renamed_from: None, + }; + + if entry.is_renamed() { + let renamed_from = tokens.next().ok_or_else(|| { + miette!("Renamed `git status` entry has no 'renamed from' path: {token:?}") + })?; + + entry.renamed_from = Some(Utf8PathBuf::from(renamed_from)); + } + + entries.push(entry); + } + + Ok(Self { entries }) + } +} diff --git a/src/git/worktree.rs b/src/git/worktree.rs new file mode 100644 index 0000000..d04e082 --- /dev/null +++ b/src/git/worktree.rs @@ -0,0 +1,268 @@ +use std::collections::HashMap; +use std::fmt::Display; +use std::ops::Deref; +use std::str::FromStr; + +use camino::Utf8PathBuf; +use command_error::CommandExt; +use command_error::OutputContext; +use miette::miette; +use miette::Context; +use miette::IntoDiagnostic; +use utf8_command::Utf8Output; + +use super::commit_hash::CommitHash; +use super::ref_name::Ref; +use super::Git; + +/// A set of Git worktrees. +/// +/// Exactly one of the worktrees is the main worktree. +#[derive(Debug, PartialEq, Eq)] +pub struct Worktrees { + /// The path of the main worktree. This contains the common `.git` directory. + main: Utf8PathBuf, + /// A map from worktree paths to worktree information. + inner: HashMap, +} + +impl Worktrees { + pub fn from_git(git: &Git) -> miette::Result { + let main = git.main_worktree()?; + + let worktrees = git + .command() + .args(["worktree", "list", "--porcelain"]) + .output_checked_as(|context: OutputContext| { + Worktree::from_git_output_all(&context.output().stdout) + .map_err(|err| context.error_msg(err)) + }) + .into_diagnostic()?; + + let mut worktrees = Self { + main, + inner: worktrees, + }; + + match worktrees.inner.get_mut(&worktrees.main) { + Some(main_worktree) => { + main_worktree.is_main = true; + } + None => { + tracing::warn!( + main = %worktrees.main, + %worktrees, + "No main worktree found in `git worktree list` output" + ); + } + } + + Ok(worktrees) + } +} + +impl Deref for Worktrees { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl Display for Worktrees { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut trees = self.values().peekable(); + while let Some(tree) = trees.next() { + if trees.peek().is_none() { + write!(f, "{tree}")?; + } else { + writeln!(f, "{tree}")?; + } + } + Ok(()) + } +} + +/// A Git worktree. +#[derive(Debug, PartialEq, Eq)] +pub struct Worktree { + pub path: Utf8PathBuf, + pub head: CommitHash, + pub branch: Option, + pub is_main: bool, +} + +impl Display for Worktree { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.path, self.head.abbrev())?; + match &self.branch { + None => { + write!(f, " [detached]")?; + } + Some(ref_name) => { + write!(f, " [{ref_name}]")?; + } + } + if self.is_main { + write!(f, " [main]")?; + } + Ok(()) + } +} + +impl Worktree { + /// Parses `git worktree list --porcelain` output, like this: + /// + /// ```plain + /// worktree /Users/wiggles/cabal/master + /// HEAD c53a03ae672c7d2d33ad9aa2469c1e38f3a052ce + /// branch refs/heads/master + /// + /// worktree /Users/wiggles/cabal/accept + /// HEAD 0685cb3fec8b7144f865638cfd16768e15125fc2 + /// branch refs/heads/rebeccat/fix-accept-flag + /// + /// ``` + /// + /// Note the trailing newlines! + fn from_git_output_all(mut output: &str) -> miette::Result> { + let mut worktrees = HashMap::new(); + + while !output.is_empty() { + let (worktree, rest) = Self::from_git_output(output)?; + output = rest; + worktrees.insert(worktree.path.clone(), worktree); + } + + Ok(worktrees) + } + + pub fn from_git_output(output: &str) -> miette::Result<(Self, &str)> { + Self::from_git_output_inner(output) + .wrap_err_with(|| format!("Failed to parse worktrees:\n{output}")) + } + + fn from_git_output_inner(output: &str) -> miette::Result<(Self, &str)> { + // TODO: Pull in a parsing library? + let output = take_prefix(output, "worktree ")?; + let (path, output) = take_rest_of_line(output)?; + + let output = take_prefix(output, "HEAD ")?; + let (head, output) = take_rest_of_line(output)?; + let head = CommitHash::from(head.to_owned()); + + let (output, branch) = if output.starts_with("detached") { + let output = take_prefix(output, "detached")?; + let (_, output) = take_rest_of_line(output)?; + (output, None) + } else { + let output = take_prefix(output, "branch ")?; + let (branch, output) = take_rest_of_line(output)?; + (output, Some(Ref::from_str(branch)?)) + }; + let output = take_prefix(output, "\n")?; + + Ok(( + Self { + path: Utf8PathBuf::from(path), + head, + branch, + is_main: false, + }, + output, + )) + } +} + +fn take_rest_of_line(input: &str) -> miette::Result<(&str, &str)> { + input + .split_once('\n') + .ok_or_else(|| miette!("Expected text and then a newline")) +} + +fn take_prefix<'i>(input: &'i str, prefix: &str) -> miette::Result<&'i str> { + input + .strip_prefix(prefix) + .ok_or_else(|| miette!("Expected {prefix:?}")) +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use itertools::Itertools; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_from_git_output() { + assert_eq!( + Worktree::from_git_output(indoc!( + " + worktree /Users/wiggles/cabal/master + HEAD c53a03ae672c7d2d33ad9aa2469c1e38f3a052ce + branch refs/heads/master + + ooga booga + + " + )) + .unwrap(), + ( + Worktree { + path: "/Users/wiggles/cabal/master".into(), + head: CommitHash::from("c53a03ae672c7d2d33ad9aa2469c1e38f3a052ce".to_owned()), + branch: Some(Ref::from_str("refs/heads/master").unwrap()), + is_main: false, + }, + "ooga booga\n\n" + ) + ); + } + + #[test] + fn test_from_git_output_all() { + assert_eq!( + Worktree::from_git_output_all(indoc!( + " + worktree /Users/wiggles/cabal/master + HEAD c53a03ae672c7d2d33ad9aa2469c1e38f3a052ce + branch refs/heads/master + + worktree /Users/wiggles/cabal/accept + HEAD 0685cb3fec8b7144f865638cfd16768e15125fc2 + branch refs/heads/rebeccat/fix-accept-flag + + worktree /Users/wiggles/lix + HEAD 0d484aa498b3c839991d11afb31bc5fcf368493d + detached + + " + )) + .unwrap() + .into_values() + .sorted_by_key(|worktree| worktree.path.to_owned()) + .collect::>(), + vec![ + Worktree { + path: "/Users/wiggles/cabal/accept".into(), + head: CommitHash::from("0685cb3fec8b7144f865638cfd16768e15125fc2".to_owned()), + branch: Some(Ref::from_str("refs/heads/rebeccat/fix-accept-flag").unwrap()), + is_main: false, + }, + Worktree { + path: "/Users/wiggles/cabal/master".into(), + head: CommitHash::from("c53a03ae672c7d2d33ad9aa2469c1e38f3a052ce".to_owned()), + branch: Some(Ref::from_str("refs/heads/master").unwrap()), + is_main: false, + }, + Worktree { + path: "/Users/wiggles/lix".into(), + head: CommitHash::from("0d484aa498b3c839991d11afb31bc5fcf368493d".to_owned()), + branch: None, + is_main: false, + }, + ] + ); + } +} diff --git a/src/install_tracing.rs b/src/install_tracing.rs index 8e14a51..a8e5c12 100644 --- a/src/install_tracing.rs +++ b/src/install_tracing.rs @@ -1,4 +1,5 @@ use miette::IntoDiagnostic; +use tracing_subscriber::fmt::format::FmtSpan; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::Layer; @@ -7,6 +8,7 @@ pub fn install_tracing(filter_directives: &str) -> miette::Result<()> { let env_filter = tracing_subscriber::EnvFilter::try_new(filter_directives).into_diagnostic()?; let human_layer = tracing_human_layer::HumanLayer::new() + .with_span_events(FmtSpan::NEW | FmtSpan::EXIT) .with_output_writer(std::io::stderr()) .with_filter(env_filter); diff --git a/src/main.rs b/src/main.rs index c9610b9..9d37b2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,41 +1,23 @@ +use config::Config; +use install_tracing::install_tracing; + +mod add; +mod app; mod cli; -mod commit_hash; +mod config; +mod convert; +mod copy_dir; +mod current_dir; +mod format_bulleted_list; +mod gh; mod git; mod install_tracing; - -use clap::CommandFactory; -use clap::Parser; -use cli::Opts; -use install_tracing::install_tracing; - -#[allow(unused_imports)] -use miette::Context; -#[allow(unused_imports)] -use miette::IntoDiagnostic; +mod normal_path; +mod topological_sort; +mod utf8tempdir; fn main() -> miette::Result<()> { - let opts = Opts::parse(); - install_tracing(&opts.log)?; - - match opts.command { - cli::Command::Completions { shell } => { - let mut clap_command = cli::Opts::command(); - clap_complete::generate( - shell, - &mut clap_command, - "git-prole", - &mut std::io::stdout(), - ); - } - #[cfg(feature = "clap_mangen")] - cli::Command::Manpages { out_dir } => { - let clap_command = cli::Opts::command(); - clap_mangen::generate_to(clap_command, out_dir) - .into_diagnostic() - .wrap_err("Failed to generate man pages")?; - } - cli::Command::Add {} => todo!(), - } - - Ok(()) + let config = Config::new()?; + install_tracing(&config.cli.log)?; + app::App::new(config).run() } diff --git a/src/normal_path.rs b/src/normal_path.rs new file mode 100644 index 0000000..6c74be3 --- /dev/null +++ b/src/normal_path.rs @@ -0,0 +1,170 @@ +use std::borrow::Borrow; +use std::env; +use std::fmt::Debug; +use std::fmt::Display; +use std::hash::Hash; +use std::ops::Deref; +use std::path::Path; + +use camino::Utf8Path; +use camino::Utf8PathBuf; +use common_path::common_path; +use miette::miette; +use miette::IntoDiagnostic; +use owo_colors::OwoColorize; +use owo_colors::Stream::Stdout; +use path_absolutize::Absolutize; + +use crate::current_dir::current_dir_utf8; + +/// A normalized [`Utf8PathBuf`] in tandem with a relative path. +/// +/// Normalized paths are absolute paths with dots removed; see [`path_dedot`][path_dedot] and +/// [`path_absolutize`] for more details. +/// +/// These paths are [`Display`]ed as the relative path but compared ([`Hash`], [`Eq`], [`Ord`]) as +/// the normalized path. +/// +/// [path_dedot]: https://docs.rs/path-dedot/latest/path_dedot/ +#[derive(Debug, Clone)] +pub struct NormalPath { + normal: Utf8PathBuf, + relative: Option, +} + +impl NormalPath { + /// Creates a new normalized path relative to the given base path. + pub fn new(original: impl AsRef, base: impl AsRef) -> miette::Result { + let base = base.as_ref(); + let normal = original.as_ref().absolutize_from(base).into_diagnostic()?; + let normal = normal + .into_owned() + .try_into() + .map_err(|err| miette!("{err}"))?; + let relative = if common_path(&normal, base).is_some() { + pathdiff::diff_utf8_paths(&normal, base) + } else { + None + }; + Ok(Self { normal, relative }) + } + + /// Create a new normalized path relative to the current working directory. + pub fn from_cwd(original: impl AsRef) -> miette::Result { + Self::new(original, current_dir_utf8()?) + } + + /// Get a reference to the absolute (normalized) path, borrowed as a [`Utf8Path`]. + pub fn absolute(&self) -> &Utf8Path { + self.normal.as_path() + } + + /// Get a reference to the relative path, borrowed as a [`Utf8Path`]. + /// + /// If no relative path is present, the absolute (normalized) path is used instead. + pub fn relative(&self) -> &Utf8Path { + self.relative.as_deref().unwrap_or_else(|| self.absolute()) + } + + pub fn push(&mut self, component: impl AsRef) { + let component = component.as_ref(); + self.normal.push(component); + if let Some(path) = self.relative.as_mut() { + path.push(component); + } + } +} + +// Hash, Eq, and Ord delegate to the normalized path. +impl Hash for NormalPath { + fn hash(&self, state: &mut H) { + Hash::hash(&self.normal, state); + } +} + +impl PartialEq for NormalPath { + fn eq(&self, other: &Self) -> bool { + PartialEq::eq(&self.normal, &other.normal) + } +} + +impl Eq for NormalPath {} + +impl PartialOrd for NormalPath { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for NormalPath { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + Ord::cmp(&self.normal, &other.normal) + } +} + +impl Display for NormalPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let path = match &self.relative { + Some(path) => path.as_path(), + None => self.normal.as_path(), + }; + if path.as_str().is_empty() { + write!( + f, + "{}", + "$PWD".if_supports_color(Stdout, |text| text.cyan()) + ) + } else { + let temp_dir = Utf8PathBuf::try_from(env::temp_dir()).ok(); + write!( + f, + "{}", + &match temp_dir.and_then(|temp_dir| self.normal.strip_prefix(temp_dir).ok()) { + Some(after_tmpdir) => { + format!("$TMPDIR{}{}", std::path::MAIN_SEPARATOR_STR, after_tmpdir) + } + None => path.as_str().to_owned(), + } + .if_supports_color(Stdout, |text| text.cyan()) + ) + } + } +} + +impl From for Utf8PathBuf { + fn from(value: NormalPath) -> Self { + value.normal + } +} + +impl AsRef for NormalPath { + fn as_ref(&self) -> &Utf8Path { + &self.normal + } +} + +impl AsRef for NormalPath { + fn as_ref(&self) -> &Path { + self.normal.as_std_path() + } +} + +impl Borrow for NormalPath { + fn borrow(&self) -> &Utf8PathBuf { + &self.normal + } +} + +impl Borrow for NormalPath { + fn borrow(&self) -> &Utf8Path { + self.normal.as_path() + } +} + +impl Deref for NormalPath { + type Target = Utf8PathBuf; + + fn deref(&self) -> &Self::Target { + &self.normal + } +} diff --git a/src/topological_sort.rs b/src/topological_sort.rs new file mode 100644 index 0000000..ab2b1be --- /dev/null +++ b/src/topological_sort.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use camino::Utf8Path; +use camino::Utf8PathBuf; +use miette::miette; + +/// Topologically sort a set of paths. +/// +/// If there are two paths `x` and `y` in the input where `x` contains `y` (e.g. `x` is `/puppy` +/// and `y` is `/puppy/doggy`), then there is an edge from `y` to `x`. +/// +/// This function errors if any input path is relative. +/// +/// This implements Kahn's algorithm. +/// +/// See: +#[cfg_attr(not(test), expect(dead_code))] +pub fn topological_sort

(paths: &[P]) -> miette::Result> +where + P: AsRef, +{ + if paths.is_empty() { + return Ok(Vec::new()); + } + + // Compute edges. + let mut edges = HashMap::<&Utf8Path, HashSet<&Utf8Path>>::new(); + let mut incoming_edges = HashMap::<&Utf8Path, HashSet<&Utf8Path>>::new(); + for (i, path1) in paths[..paths.len()].iter().enumerate() { + let path1 = path1.as_ref(); + if path1.is_relative() { + return Err(miette!("Path is relative: {path1}")); + } + + for path2 in &paths[i + 1..] { + let path2 = path2.as_ref(); + + if path1 == path2 { + // Fucked up. + tracing::warn!("Duplicate paths: {path1}"); + continue; + } + + if path1.starts_with(path2) { + edges.entry(path1).or_default().insert(path2); + incoming_edges.entry(path2).or_default().insert(path1); + } else if path2.starts_with(path1) { + edges.entry(path2).or_default().insert(path1); + incoming_edges.entry(path1).or_default().insert(path2); + } + } + } + + // The inner loop above doesn't hit the last path, so we check if it's relative here. + if let Some(path) = paths.last() { + let path = path.as_ref(); + if path.is_relative() { + return Err(miette!("Path is relative: {path}")); + } + } + + // Get the starting set of nodes with no incoming edges. + // TODO: This can contain duplicate paths. + let mut queue = paths + .iter() + .map(|path| path.as_ref()) + .filter(|path| { + incoming_edges + .get(path) + .map(|edges_to_path| edges_to_path.is_empty()) + .unwrap_or(true) + }) + .collect::>(); + + // Collect the sorted list. + let mut sorted = Vec::new(); + while let Some(path) = queue.pop() { + sorted.push(path.to_owned()); + + if let Some(path_edges) = edges.remove(path) { + for next in path_edges { + // There is an edge from `path` to `next`. + // Remove `next <- path` incoming edge. + if let Some(next_incoming_edges) = incoming_edges.get_mut(next) { + next_incoming_edges.remove(path); + if next_incoming_edges.is_empty() { + incoming_edges.remove(next); + queue.push(next); + } + } + } + } + } + + if edges.values().map(|edges| edges.len()).sum::() > 0 { + unreachable!("The graph formed by common prefixes in directory names has cycles, which should not be possible") + } else { + Ok(sorted) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_topological_sort_empty() { + assert_eq!( + topological_sort(&Vec::<&Utf8Path>::new()).unwrap(), + Vec::::new() + ); + } + + #[test] + fn test_topological_sort_unrelated() { + assert_eq!( + topological_sort(&[ + Utf8Path::new("/puppy"), + Utf8Path::new("/doggy"), + Utf8Path::new("/softie"), + Utf8Path::new("/cutie"), + ]) + .unwrap(), + vec![ + // TODO: This probably depends on the hash function. >:( + Utf8PathBuf::from("/cutie"), + Utf8PathBuf::from("/softie"), + Utf8PathBuf::from("/doggy"), + Utf8PathBuf::from("/puppy"), + ] + ); + } + + #[test] + fn test_topological_sort_mixed() { + assert_eq!( + topological_sort(&[ + Utf8Path::new("/puppy"), + Utf8Path::new("/puppy/doggy/cutie"), + Utf8Path::new("/puppy/softie"), + Utf8Path::new("/puppy/doggy"), + Utf8Path::new("/silly"), + Utf8Path::new("/silly/goofy"), + ]) + .unwrap(), + vec![ + // TODO: This probably depends on the hash function. >:( + Utf8PathBuf::from("/silly/goofy"), + Utf8PathBuf::from("/silly"), + Utf8PathBuf::from("/puppy/softie"), + Utf8PathBuf::from("/puppy/doggy/cutie"), + Utf8PathBuf::from("/puppy/doggy"), + Utf8PathBuf::from("/puppy"), + ] + ); + } + + #[test] + fn test_topological_sort_duplicate() { + // This also warns the user. + assert_eq!( + topological_sort(&[Utf8Path::new("/puppy"), Utf8Path::new("/puppy")]).unwrap(), + vec![Utf8PathBuf::from("/puppy"), Utf8PathBuf::from("/puppy")] + ); + } +} diff --git a/src/utf8tempdir.rs b/src/utf8tempdir.rs new file mode 100644 index 0000000..b602a4a --- /dev/null +++ b/src/utf8tempdir.rs @@ -0,0 +1,47 @@ +use std::ops::Deref; +use std::path::Path; + +use camino::Utf8Path; +use camino::Utf8PathBuf; +use miette::IntoDiagnostic; +use tempfile::TempDir; + +#[derive(Debug)] +pub struct Utf8TempDir { + #[allow(dead_code)] + inner: TempDir, + path: Utf8PathBuf, +} + +impl Utf8TempDir { + pub fn new() -> miette::Result { + let inner = tempfile::tempdir().into_diagnostic()?; + let path = inner.path().to_owned().try_into().into_diagnostic()?; + Ok(Self { inner, path }) + } + + /// Keep this directory when it goes out of scope. + pub fn into_path(self) -> Utf8PathBuf { + let _ = self.inner.into_path(); + self.path + } + + #[expect(dead_code)] + pub fn as_path(&self) -> &Utf8Path { + &self.path + } +} + +impl Deref for Utf8TempDir { + type Target = Utf8Path; + + fn deref(&self) -> &Self::Target { + &self.path + } +} + +impl AsRef for Utf8TempDir { + fn as_ref(&self) -> &Path { + self.as_std_path() + } +}