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..0e81986 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" @@ -180,6 +186,12 @@ dependencies = [ "roff", ] +[[package]] +name = "clonable-command" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94c477ad2f0c0b2772d1c1260972081012cf09d813f8d6ac26144658eed2bcd" + [[package]] name = "colorchoice" version = "1.0.2" @@ -188,16 +200,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" @@ -225,6 +244,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "dissimilar" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f8e79d1fbf76bdfbde321e902714bf6c49df88a7dda6fc682fc2979226962d" + [[package]] name = "dyn-clone" version = "1.0.17" @@ -237,6 +262,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 +278,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "expect-test" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e0be0a561335815e06dab7c62e50353134c796e7a6155402a64bcff66b6a5e0" +dependencies = [ + "dissimilar", + "once_cell", +] + +[[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 +313,40 @@ dependencies = [ "clap_complete", "clap_mangen", "command-error", + "common-path", "derive_more", + "expect-test", "fs-err", "indoc", + "itertools 0.13.0", "miette", "owo-colors 4.1.0", + "path-absolutize", + "pathdiff", "pretty_assertions", "regex", + "serde", + "shell-words", + "tap", + "tempfile", + "test-harness", + "toml", "tracing", "tracing-human-layer", "tracing-subscriber", "utf8-command", + "walkdir", + "which", + "winnow", + "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 +374,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 +442,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 +523,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 +599,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" @@ -499,9 +634,9 @@ checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pretty_assertions" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" dependencies = [ "diff", "yansi", @@ -516,6 +651,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 +758,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 +898,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.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix 0.38.37", + "windows-sys 0.59.0", +] + [[package]] name = "terminal_size" version = "0.2.6" @@ -733,6 +937,33 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "test-harness" +version = "0.1.0" +dependencies = [ + "camino", + "clonable-command", + "command-error", + "expect-test", + "fs-err", + "git-prole", + "itertools 0.13.0", + "miette", + "pretty_assertions", + "regex", + "shell-words", + "tempfile", + "test_bin", + "tracing", + "utf8-command", +] + +[[package]] +name = "test_bin" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e7a7de15468c6e65dd7db81cf3822c1ec94c71b2a3c1a976ea8e4696c91115c" + [[package]] name = "textwrap" version = "0.16.1" @@ -775,6 +1006,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 +1078,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 +1157,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 +1195,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 +1281,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,8 +1411,29 @@ 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" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/Cargo.toml b/Cargo.toml index b855a8a..f80c95a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,18 @@ +[workspace] +members = [ + "test-harness", +] +resolver = "2" + +# See: https://github.com/crate-ci/cargo-release/blob/master/docs/reference.md +[workspace.metadata.release] +# Set the commit message. +pre-release-commit-message = "Release {{crate_name}} version {{version}}" +consolidate-commits = false # One commit per crate. +tag = false # Don't tag commits. +push = false # Don't do `git push`. +publish = false # Don't do `cargo publish`. + [package] name = "git-prole" version = "0.1.0" @@ -9,6 +24,9 @@ license = "MIT" keywords = ["git"] categories = ["command-line-utilities"] +[lib] +path = "src/lib.rs" + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] @@ -18,19 +36,34 @@ 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" +winnow = "0.6.20" +xdg = "2.5.2" [dev-dependencies] +expect-test = "1.5.0" indoc = "2.0.5" pretty_assertions = "1.4.0" +test-harness = { path = "test-harness" } # See: https://github.com/crate-ci/cargo-release/blob/master/docs/reference.md [package.metadata.release] 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..9b48c92 --- /dev/null +++ b/config.toml @@ -0,0 +1,50 @@ +# 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 + +# When you run `git prole clone foo/bar`, if `enable_gh = true` and `gh` is +# installed, I'll run `gh repo clone foo/bar` as a shortcut for GitHub +# repositories. +# +# See: https://cli.github.com/ +enable_gh = false diff --git a/flake.lock b/flake.lock index b88e9df..6f12930 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": 1728492678, + "narHash": "sha256-9UTxR8eukdg+XZeHgxW5hQA9fIKHsKCdOIUycTryeVw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4f807e8940284ad7925ebd0a0993d2a1791acb2f", + "rev": "5633bcff0c6162b9e4b5f1264264611e950c8ec7", "type": "github" }, "original": { @@ -63,11 +63,11 @@ ] }, "locked": { - "lastModified": 1726194362, - "narHash": "sha256-cM7zFscFqdsA5KohUUYndzIp20kUqjj39qnj6Voj+f8=", + "lastModified": 1728613723, + "narHash": "sha256-zVVj0PKguM8ZMdLE43YW7dzer3tl9e6i5Qs1fr878+c=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "a71b1240e29f1ec68612ed5306c328086bed91f9", + "rev": "ca93f28abd2147dd9997261dcaeacc5a30dba463", "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..b7a0a75 100644 --- a/nix/packages/git-prole.nix +++ b/nix/packages/git-prole.nix @@ -12,13 +12,14 @@ installShellFiles, pkg-config, openssl, -}: let + bash, + git, +}: +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 +43,47 @@ # 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 // { + nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ + bash + git + ]; 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 + cargoTestExtraArgs = "--doc"; + } + ); + 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 +107,9 @@ // { cargoExtraArgs = "${commonArgs.cargoExtraArgs or ""} --features clap_mangen"; - nativeBuildInputs = commonArgs.nativeBuildInputs ++ [installShellFiles]; + nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ installShellFiles ]; + + doCheck = false; postInstall = (commonArgs.postInstall or "") @@ -115,27 +130,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..cbd6435 --- /dev/null +++ b/src/add.rs @@ -0,0 +1,387 @@ +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_git::AppGit; +use crate::cli::AddArgs; +use crate::format_bulleted_list::format_bulleted_list; +use crate::git::BranchRef; +use crate::git::Git; +use crate::git::LocalBranchRef; +use crate::normal_path::NormalPath; +use crate::AddWorktreeOpts; + +/// A plan for creating a new `git worktree`. +#[derive(Debug, Clone)] +pub struct WorktreePlan<'a> { + git: AppGit<'a>, + /// The directory to run commands from. + worktree: Utf8PathBuf, + destination: NormalPath, + branch: BranchStartPointPlan, + /// Relative paths to copy to the new worktree, if any. + copy_untracked: Vec, +} + +impl<'a> WorktreePlan<'a> { + pub fn new(git: AppGit<'a>, args: &'a 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 worktree = git.path().repo_root()?; + let branch = BranchStartPointPlan::new(&git, args)?; + let destination = Self::destination_plan(&git, args, branch.local_branch())?; + let copy_untracked = if git.config.file.copy_untracked() { + git.status().untracked_files()? + } else { + Vec::new() + }; + Ok(Self { + git, + worktree, + branch, + destination, + copy_untracked, + }) + } + + fn destination_plan( + git: &AppGit<'_>, + args: &AddArgs, + new_branch: &LocalBranchRef, + ) -> miette::Result { + match &args.inner.name_or_path { + Some(name_or_path) => { + if name_or_path.contains('/') { + // Test case: `add_by_path`. + NormalPath::from_cwd(name_or_path) + } else { + // Test case: `add_by_name_new_local`. + NormalPath::from_cwd( + git.worktree() + .container()? + .tap_mut(|p| p.push(name_or_path)), + ) + } + } + // Test case: `add_branch_new_local`. + None => NormalPath::from_cwd(git.worktree().path_for(new_branch.branch_name())?), + } + } + + fn command(&self, git: &Git) -> Command { + let (force_branch, track, create_branch) = match &self.branch { + BranchStartPointPlan::Existing(_) => (false, false, None), + BranchStartPointPlan::New { + force, + branch, + start, + } => { + let track = matches!(start, StartPoint::Branch(_)); + + (*force, track, Some(branch)) + } + }; + + git.with_directory(self.worktree.clone()) + .worktree() + .add_command( + &self.destination, + &AddWorktreeOpts { + force_branch, + create_branch, + track, + start_point: Some(match &self.branch { + BranchStartPointPlan::Existing(branch) => branch.branch_name(), + BranchStartPointPlan::New { start, .. } => match start { + StartPoint::Branch(start) => start.qualified_branch_name(), + StartPoint::Commitish(commitish) => commitish, + }, + }), + ..Default::default() + }, + ) + } + + 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.worktree.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) -> miette::Result<()> { + let mut command = self.command(&self.git); + + tracing::info!("{self}"); + tracing::debug!("{self:#?}"); + + if self.git.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 { + write!( + f, + "Creating worktree in {} for {}", + self.destination, self.branch, + )?; + + if !self.copy_untracked.is_empty() { + write!( + f, + "\nCopying {} untracked paths to new worktree", + self.copy_untracked.len() + )?; + } + + Ok(()) + } +} + +/// Where to start a worktree at. +#[derive(Debug, Clone)] +enum StartPoint { + /// An existing local or remote branch. The new branch should track this branch. + Branch(BranchRef), + /// A commit. + Commitish(String), +} + +impl StartPoint { + pub fn new(git: &AppGit<'_>, commitish: Option<&str>) -> miette::Result { + match commitish { + Some(commitish) => match git.branch().local_or_remote(commitish)? { + Some(branch) => Ok(Self::Branch(branch)), + None => Ok(Self::Commitish(commitish.to_owned())), + }, + None => Ok(Self::preferred(git)?), + } + } + + pub fn preferred(git: &AppGit) -> miette::Result { + Ok(Self::Branch(git.preferred_branch()?)) + } +} + +/// When creating a new `git worktree`, we can check out an existing branch or commit, or create a +/// new branch. Sometimes the cases are intertwined; for example, we can create a new local branch +/// tracking a remote branch. +/// +/// When creating a new branch, we can either use the default branch as the starting point, track +/// an existing branch, or start at a specific commit. +#[derive(Debug, Clone)] +enum BranchStartPointPlan { + /// Create a new branch. + New { + /// Whether to forcibly reset the branch if it already exists. + force: bool, + /// The branch to create or reset. + branch: LocalBranchRef, + /// The start-point for the new branch. + start: StartPoint, + }, + /// Check out an existing branch. + Existing(LocalBranchRef), +} + +impl BranchStartPointPlan { + /// Create a branch and start-point plan from the given arguments. + /// + /// There's a lot of permutations to this functionality, so here's a big table! + /// + /// In general, for a fragment `NAME`, we perform the following logic: + /// - If `NAME` is the name of a local branch, that branch is checked out. + /// - If `NAME` is the name of a remote branch, a new local branch with the same name is + /// created to track the remote branch. + /// - Otherwise, a new branch is created named `NAME` at the default starting point. + /// If an explicit start point is given, that's used instead. + /// + /// ```plain + /// --branch | NAME_OR_PATH | START_POINT | behavior | start | test case + /// -------- | ------------ | ----------- | ------------------ | ------------- | --------- + /// BRANCH | [ignored] | | new BRANCH | DEFAULT | add_branch_new + /// BRANCH | [ignored] | LOCAL_BRANCH | new BRANCH | LOCAL_BRANCH | add_branch_start_point_exiting_local + /// BRANCH | [ignored] | REMOTE_BRANCH | new BRANCH | REMOTE_BRANCH | add_branch_start_point_existing_remote + /// BRANCH | [ignored] | COMMITISH | new BRANCH | COMMITISH | add_branch_start_point_new_local + /// -------- | ------------ | ----------- | ------------------ | ------------- | --------- + /// | NAME | LOCAL_BRANCH | existing LOCAL_BRANCH | | add_start_point_existing_local + /// | NAME | REMOTE_BRANCH | new REMOTE_BRANCH | REMOTE_BRANCH | add_start_point_existing_remote + /// | NAME | COMMITISH | new NAME | COMMITISH | add_start_point_new_local + /// -------- | ------------ | ----------- | ------------------ | ------------- | --------- + /// | LOCAL_BRANCH | | existing LOCAL_BRANCH | | add_by_name_existing_local + /// | REMOTE_BRANCH | | new REMOTE_BRANCH | REMOTE_BRANCH | add_by_name_existing_remote + /// | BRANCH | | new BRANCH | DEFAULT | add_by_name_new_local + /// ``` + /// + /// This was very annoying to iron out, but hopefully it does what you want more of the time + /// than `git-worktree(1)`. + pub fn new(git: &AppGit<'_>, args: &AddArgs) -> miette::Result { + match (&args.inner.branch, &args.inner.force_branch) { + (Some(_), Some(_)) => Err(miette!( + "`--branch` and `--force-branch` are mutually exclusive." + )), + // `add --branch BRANCH [NAME_OR_PATH [COMMITISH]]` + (Some(branch), None) => Ok(Self::New { + force: false, + branch: LocalBranchRef::from(branch), + start: StartPoint::new(git, args.commitish.as_deref())?, + }), + // `add --force-branch BRANCH [NAME_OR_PATH [COMMITISH]]` + (None, Some(force_branch)) => Ok(Self::New { + force: true, + branch: LocalBranchRef::from(force_branch), + start: StartPoint::new(git, args.commitish.as_deref())?, + }), + (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 dirname = git.worktree().dirname_for(name_or_path); + + match &args.commitish { + Some(commitish) => match Self::from_commitish(git, commitish)? { + // `add NAME_OR_PATH LOCAL_BRANCH` + // `add NAME_OR_PATH REMOTE_BRANCH` + Some(plan) => Ok(plan), + // `add NAME_OR_PATH COMMITISH` + None => Self::new_branch_at(git, false, dirname, Some(commitish)), + }, + + // `add NAME_OR_PATH` + None => match Self::from_commitish(git, dirname)? { + // `add ../puppy/LOCAL_BRANCH` + // `add ../puppy/REMOTE_BRANCH` + Some(plan) => Ok(plan), + // `add ../puppy/SOMETHING_ELSE` + None => Self::new_branch_at(git, false, dirname, None), + }, + } + } + } + } + + fn new_branch_at( + git: &AppGit<'_>, + force: bool, + branch: &str, + commitish: Option<&str>, + ) -> miette::Result { + Ok(Self::New { + force, + branch: LocalBranchRef::new(branch.to_owned()), + start: StartPoint::new(git, commitish)?, + }) + } + + fn from_commitish(git: &AppGit<'_>, commitish: &str) -> miette::Result> { + Ok(git + .branch() + .local_or_remote(commitish)? + .map(Self::from_branch)) + } + + fn from_branch(branch: BranchRef) -> Self { + match branch { + BranchRef::Local(local_branch) => Self::Existing(local_branch), + BranchRef::Remote(remote_branch) => Self::New { + force: false, + branch: remote_branch.as_local(), + start: StartPoint::Branch(remote_branch.into()), + }, + } + } + + /// The local branch that will be checked out or created. + pub fn local_branch(&self) -> &LocalBranchRef { + match self { + BranchStartPointPlan::New { branch, .. } | BranchStartPointPlan::Existing(branch) => { + branch + } + } + } +} + +impl Display for BranchStartPointPlan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BranchStartPointPlan::Existing(branch) => { + write!( + f, + "{}", + branch + .branch_name() + .if_supports_color(Stream::Stdout, |text| text.cyan()) + ) + } + BranchStartPointPlan::New { + force: _, + branch, + start, + } => { + write!( + f, + "{}", + branch + .branch_name() + .if_supports_color(Stream::Stdout, |text| text.cyan()) + )?; + match start { + StartPoint::Branch(tracking) => { + write!( + f, + " tracking {}", + tracking + .qualified_branch_name() + .if_supports_color(Stream::Stdout, |text| text.cyan()) + ) + } + StartPoint::Commitish(commitish) => { + write!( + f, + " starting at {}", + 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..9dd1800 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,96 @@ +use calm_io::stdout; +use clap::CommandFactory; +use fs_err as fs; +use miette::miette; +use miette::IntoDiagnostic; + +use crate::add::WorktreePlan; +use crate::app_git::AppGit; +use crate::cli; +use crate::cli::ConfigCommand; +use crate::cli::ConfigGenerateArgs; +use crate::config::Config; +use crate::convert::ConvertPlan; +use crate::convert::ConvertPlanOpts; +use crate::git::Git; + +pub struct App { + config: Config, +} + +impl App { + pub fn new(config: Config) -> Self { + Self { config } + } + + pub fn git(&self) -> miette::Result> { + Ok(Git::from_current_dir()?.with_config(&self.config)) + } + + 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) => ConvertPlan::new( + self.git()?, + ConvertPlanOpts { + default_branch: args.default_branch.clone(), + }, + )? + .execute()?, + cli::Command::Clone(args) => crate::clone::clone(self.git()?, args.to_owned())?, + cli::Command::Add(args) => WorktreePlan::new(self.git()?, args)?.execute()?, + 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(()) + } +} diff --git a/src/app_git.rs b/src/app_git.rs new file mode 100644 index 0000000..34d47de --- /dev/null +++ b/src/app_git.rs @@ -0,0 +1,126 @@ +use std::collections::HashSet; +use std::fmt::Debug; +use std::ops::Deref; +use std::ops::DerefMut; + +use camino::Utf8PathBuf; +use miette::miette; +use tracing::instrument; + +use crate::config::Config; +use crate::git::BranchRef; +use crate::git::Git; +use crate::git::LocalBranchRef; + +/// A [`Git`] with borrowed [`Config`]. +#[derive(Clone)] +pub struct AppGit<'a> { + pub git: Git, + pub config: &'a Config, +} + +impl Debug for AppGit<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("AppGit") + .field(&self.git.get_directory()) + .finish() + } +} + +impl Deref for AppGit<'_> { + type Target = Git; + + fn deref(&self) -> &Self::Target { + &self.git + } +} + +impl DerefMut for AppGit<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.git + } +} + +impl AsRef for AppGit<'_> { + fn as_ref(&self) -> &Git { + &self.git + } +} + +impl AsRef for AppGit<'_> { + fn as_ref(&self) -> &Config { + self.config + } +} + +impl From> for Git { + fn from(value: AppGit<'_>) -> Self { + value.git + } +} + +impl<'a> AppGit<'a> { + pub fn with_directory(&self, path: Utf8PathBuf) -> Self { + Self { + git: self.git.with_directory(path), + config: self.config, + } + } + + /// Get a list of remotes in the user's preference order. + #[instrument(level = "trace")] + pub fn preferred_remotes(&self) -> miette::Result> { + let mut all_remotes = self.remote().list()?.into_iter().collect::>(); + + let mut sorted = Vec::with_capacity(all_remotes.len()); + + if let Some(default_remote) = self.remote().get_default()? { + 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) + } + + /// Get the user's preferred remote, if any. + #[instrument(level = "trace")] + pub fn preferred_remote(&self) -> miette::Result> { + Ok(self.preferred_remotes()?.first().cloned()) + } + + /// Get the user's preferred default branch. + #[instrument(level = "trace")] + pub fn preferred_branch(&self) -> miette::Result { + if let Some(default_remote) = self.preferred_remote()? { + return self + .remote() + .default_branch(&default_remote) + .map(BranchRef::from); + } + + let preferred_branches = self.config.file.default_branches(); + let all_branches = self.branch().list_local()?; + for preferred_branch in preferred_branches { + let preferred_branch = LocalBranchRef::new(preferred_branch); + if all_branches.contains(&preferred_branch) { + return Ok(preferred_branch.into()); + } else if let Some(remote_branch) = + self.remote().for_branch(preferred_branch.branch_name())? + { + return Ok(remote_branch.into()); + } + } + + Err(miette!( + "No default branch found; specify a `--default-branch` to check out" + )) + } +} diff --git a/src/cli.rs b/src/cli.rs index ddbec19..f640af6 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 and isn't an existing local path, + /// 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/clone.rs b/src/clone.rs new file mode 100644 index 0000000..ed5d8e5 --- /dev/null +++ b/src/clone.rs @@ -0,0 +1,50 @@ +use std::process::Command; + +use command_error::CommandExt; +use miette::miette; +use miette::IntoDiagnostic; +use which::which_global; + +use crate::app_git::AppGit; +use crate::cli::CloneArgs; +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; + +pub fn clone(git: AppGit<'_>, 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 git.config.cli.dry_run { + return Err(miette!("--dry-run is not supported for this command yet")); + } + + if git.config.file.enable_gh() + && looks_like_gh_url(&args.repository) + && which_global("gh").is_ok() + { + // TODO: Test this!!! + Command::new("gh") + .args(["repo", "clone", &args.repository, destination.as_str()]) + .args(args.clone_args) + .status_checked() + .into_diagnostic()?; + } else { + // Test case: `clone_simple`. + git.clone_repository(&args.repository, Some(&destination), &args.clone_args)?; + } + + ConvertPlan::new( + git.with_directory(destination), + ConvertPlanOpts { + default_branch: None, + }, + )? + .execute()?; + + Ok(()) +} 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..30c2ee8 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,130 @@ +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; +use crate::install_tracing::install_tracing; + +/// Configuration, both from the command-line and a user configuration file. +#[derive(Debug)] +pub struct Config { + /// User directories. + #[expect(dead_code)] + pub(crate) 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(); + // TODO: add tracing settings to the config file + install_tracing(&cli.log)?; + 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. +/// +/// Each configuration key should have two test cases: +/// - `config_{key}` for setting the value. +/// - `config_{key}_default` for the default value. +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +pub struct ConfigFile { + #[serde(default)] + remotes: Vec, + + #[serde(default)] + default_branches: Vec, + + #[serde(default)] + copy_untracked: Option, + + #[serde(default)] + enable_gh: 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) + } + + pub fn enable_gh(&self) -> bool { + self.enable_gh.unwrap_or(false) + } +} + +#[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), + enable_gh: Some(false), + } + ); + } +} diff --git a/src/convert.rs b/src/convert.rs new file mode 100644 index 0000000..b62db44 --- /dev/null +++ b/src/convert.rs @@ -0,0 +1,374 @@ +use std::fmt::Display; + +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_git::AppGit; +use crate::format_bulleted_list::format_bulleted_list; +use crate::git::BranchRef; +use crate::git::LocalBranchRef; +use crate::normal_path::NormalPath; +use crate::utf8tempdir::Utf8TempDir; +use crate::AddWorktreeOpts; + +#[derive(Debug)] +pub struct ConvertPlanOpts { + pub default_branch: Option, +} + +#[derive(Debug)] +pub struct ConvertPlan<'a> { + git: AppGit<'a>, + repo_name: String, + steps: Vec, +} + +impl Display for ConvertPlan<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", format_bulleted_list(&self.steps)) + } +} + +impl<'a> ConvertPlan<'a> { + pub fn new(git: AppGit<'a>, opts: ConvertPlanOpts) -> miette::Result { + // Figuring out which worktrees to create is non-trivial: + // - [x] We might already have worktrees. + // - [x] We might have any number of remotes. + // Pick a reasonable & configurable default to determine the default branch. + // - [x] We might already have the default branch checked out. + // - [x] We might _not_ have the default branch checked out. + // - [x] We might have unstaged/uncommitted work. + // TODO: The `git reset` causes staged changes to be lost; bring back the + // `git status push`/`pop`? + // - [x] We might not be on _any_ branch. + // - [x] There is no local branch for the default branch. + // (`convert_multiple_remotes`) + + let tempdir = NormalPath::from_cwd(Utf8TempDir::new()?.into_path())?; + let worktrees = git.worktree().list()?; + + // 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) => LocalBranchRef::new(default_branch).into(), + None => git.preferred_branch()?, + }; + tracing::debug!(%default_branch, "Default branch determined"); + let default_branch_dirname = git.worktree().dirname_for(default_branch.branch_name()); + let head = git.refs().head_kind()?; + tracing::debug!(%head, "HEAD determined"); + 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.branch_name()); + + // The path of the repository/main worktree before we start meddling with it. + let repo_root = NormalPath::from_cwd(git.path().repo_root()?)?; + let repo_name = repo_root + .file_name() + .ok_or_else(|| miette!("Repository has no basename: {repo_root}"))?; + // The path of the `.git` directory before we start meddling with it. + let repo_git_dir = NormalPath::from_cwd(git.path().git_common_dir()?)?; + // The path where we'll put the main worktree once we're done meddling with it. + let repo_worktree = repo_root.clone().tap_mut(|p| p.push(worktree_dirname)); + + // The path in the `tempdir` where we'll place the `.git` directory while we're setting up + // worktrees. + let temp_git_dir = tempdir.clone().tap_mut(|p| p.push(".git")); + // The path in the `tempdir` where we'll place the current worktree while we + // reassociate it with the (now-bare) repository. + let temp_worktree = tempdir.clone().tap_mut(|p| p.push(worktree_dirname)); + + // 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 mut steps = vec![ + Step::Move { + from: repo_git_dir.clone(), + to: temp_git_dir.clone(), + }, + Step::SetConfig { + repo: temp_git_dir.clone(), + key: "core.bare".to_owned(), + value: "true".to_owned(), + }, + Step::Move { + from: repo_root.clone(), + to: temp_worktree.clone(), + }, + Step::CreateDir { + path: repo_root.clone(), + }, + Step::Move { + from: temp_git_dir.clone(), + to: repo_git_dir.clone(), + }, + Step::CreateWorktreeNoCheckout { + repo: repo_git_dir.clone(), + path: repo_worktree.clone(), + commitish: head.commitish().to_owned(), + }, + Step::Reset { + repo: repo_worktree.clone(), + }, + Step::Move { + from: repo_worktree.clone().tap_mut(|p| p.push(".git")), + to: temp_worktree.clone().tap_mut(|p| p.push(".git")), + }, + Step::RemoveDirectory { + path: repo_worktree.clone(), + }, + Step::Move { + from: temp_worktree.clone(), + to: repo_worktree.clone(), + }, + ]; + + if !default_branch_is_checked_out { + let default_branch_root = repo_root + .clone() + .tap_mut(|p| p.push(default_branch_dirname)); + + steps.push(Step::CreateWorktree { + repo: repo_git_dir.clone(), + path: default_branch_root.clone(), + branch: default_branch, + }); + } + + Ok(Self { + steps, + git, + repo_name: repo_name.to_owned(), + }) + } + + pub fn execute(&self) -> miette::Result<()> { + tracing::info!("{self}"); + + if self.git.config.cli.dry_run { + return Ok(()); + } + + // TODO: Ask the user before we start messing around with their repo layout! + + 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().rename(from, to)?; + } + } + Step::CreateDir { path } => { + fs::create_dir_all(path).into_diagnostic()?; + } + Step::Move { from, to } => { + fs::rename(from, to).into_diagnostic()?; + } + Step::SetConfig { repo, key, value } => { + self.git + .with_directory(repo.as_path().to_owned()) + .config() + .set(key, value)?; + } + Step::CreateWorktree { + repo: repo_root, + path, + branch, + } => { + // If we're creating a worktree for a default branch from a + // remote, we may not have a corresponding local branch + // yet. + let (create_branch, start_point) = match branch { + BranchRef::Remote(remote_branch) => { + if self + .git + .branch() + .exists_local(remote_branch.branch_name())? + { + (None, &BranchRef::Local(remote_branch.as_local())) + } else { + tracing::warn!( + %remote_branch, + "Fetching the default branch" + ); + self.git.remote().fetch( + remote_branch.remote(), + Some(&format!( + "{:#}:{remote_branch:#}", + remote_branch.as_local() + )), + )?; + (Some(remote_branch.as_local()), branch) + } + } + BranchRef::Local(_) => (None, branch), + }; + + self.git + .with_directory(repo_root.as_path().to_owned()) + .worktree() + // .add(path.as_path(), commitish.qualified_branch_name())?; + .add( + path.as_path(), + &AddWorktreeOpts { + track: create_branch.is_some(), + create_branch: create_branch.as_ref(), + start_point: Some(start_point.qualified_branch_name()), + ..Default::default() + }, + )?; + } + Step::CreateWorktreeNoCheckout { + repo, + path, + commitish, + } => { + self.git + .with_directory(repo.as_path().to_owned()) + .worktree() + .add( + path, + &AddWorktreeOpts { + checkout: false, + start_point: Some(commitish), + ..Default::default() + }, + )?; + } + Step::Reset { repo } => { + self.git.with_directory(repo.as_path().to_owned()).reset()?; + } + Step::RemoveDirectory { path } => { + fs::remove_dir(path).into_diagnostic()?; + } + } + } + + 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 { + Move { + from: NormalPath, + to: NormalPath, + }, + SetConfig { + repo: NormalPath, + key: String, + value: String, + }, + CreateWorktreeNoCheckout { + repo: NormalPath, + path: NormalPath, + commitish: String, + }, + Reset { + repo: NormalPath, + }, + RemoveDirectory { + path: NormalPath, + }, + /// Will be needed for multiple worktree support. + #[expect(dead_code)] + MoveWorktree { + from: NormalPath, + to: NormalPath, + is_main: bool, + }, + CreateDir { + path: NormalPath, + }, + CreateWorktree { + repo: NormalPath, + path: NormalPath, + branch: BranchRef, + }, +} + +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::CreateDir { path } => { + write!(f, "Create directory {path}") + } + Step::CreateWorktree { + path, + branch: commitish, + repo: repo_root, + } => { + write!( + f, + "In {repo_root}, create a worktree for {} at {path}", + commitish.if_supports_color(Stream::Stdout, |branch| branch.cyan()) + ) + } + Step::SetConfig { repo, key, value } => { + write!( + f, + "In {repo}, set {}={}", + key.if_supports_color(Stream::Stdout, |text| text.cyan()), + value.if_supports_color(Stream::Stdout, |text| text.cyan()), + ) + } + Step::Move { from, to } => { + write!(f, "Move {from} to {to}") + } + Step::CreateWorktreeNoCheckout { + repo, + commitish, + path, + } => { + write!( + f, + "In {repo}, create but don't check out a worktree for {} at {path}", + commitish.if_supports_color(Stream::Stdout, |text| text.cyan()), + ) + } + Step::Reset { repo } => { + write!(f, "In {repo}, reset the index state") + } + Step::RemoveDirectory { path } => { + write!(f, "Remove {path}") + } + } + } +} diff --git a/src/copy_dir.rs b/src/copy_dir.rs new file mode 100644 index 0000000..2a124d0 --- /dev/null +++ b/src/copy_dir.rs @@ -0,0 +1,422 @@ +//! 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; +use tracing::instrument; + +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. +#[instrument(level = "trace", skip_all)] +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..1dd7b16 --- /dev/null +++ b/src/gh.rs @@ -0,0 +1,61 @@ +use std::ops::RangeInclusive; + +use camino::Utf8Path; +use winnow::combinator::eof; +use winnow::token::take_while; +use winnow::PResult; +use winnow::Parser; + +pub fn looks_like_gh_url(url: &str) -> bool { + parse_gh_url.parse(url).is_ok() && !Utf8Path::new(url).exists() +} + +pub fn parse_gh_url(input: &mut &str) -> PResult<()> { + /// Technically they're a little more restrictive than this, but it's fine. + /// + /// See: + const GITHUB_NAME_CHAR: ( + RangeInclusive, + RangeInclusive, + RangeInclusive, + char, + char, + char, + ) = ('a'..='z', 'A'..='Z', '0'..='9', '-', '_', '.'); + + let _organization = take_while(1..40, GITHUB_NAME_CHAR).parse_next(input)?; + let _ = '/'.parse_next(input)?; + let _repository = take_while(1..=100, GITHUB_NAME_CHAR).parse_next(input)?; + let _ = eof.parse_next(input)?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_looks_like_gh_url() { + assert!(looks_like_gh_url("9999years/git-prole")); + assert!(looks_like_gh_url("lf-/flakey-profile")); + assert!(looks_like_gh_url("soft/puppy_doggy")); + assert!(looks_like_gh_url("soft/puppy.doggy")); + + assert!(looks_like_gh_url(&format!( + "{}/{}", + "a".repeat(39), + "a".repeat(100) + ))); + assert!(!looks_like_gh_url(&format!( + "{}/{}", + "a".repeat(40), + "a".repeat(100) + ))); + assert!(!looks_like_gh_url(&format!( + "{}/{}", + "a".repeat(39), + "a".repeat(101) + ))); + } +} 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/branch.rs b/src/git/branch.rs new file mode 100644 index 0000000..9392630 --- /dev/null +++ b/src/git/branch.rs @@ -0,0 +1,94 @@ +use std::collections::HashSet; +use std::fmt::Debug; + +use command_error::CommandExt; +use command_error::OutputContext; +use miette::IntoDiagnostic; +use tracing::instrument; +use utf8_command::Utf8Output; + +use super::BranchRef; +use super::Git; +use super::LocalBranchRef; + +/// Git methods for dealing with worktrees. +#[repr(transparent)] +pub struct GitBranch<'a>(&'a Git); + +impl Debug for GitBranch<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self.0, f) + } +} + +impl<'a> GitBranch<'a> { + pub fn new(git: &'a Git) -> Self { + Self(git) + } + + /// Lists local branches. + #[instrument(level = "trace")] + pub fn list_local(&self) -> miette::Result> { + self.0 + .refs() + .for_each_ref(Some(&["refs/heads/**"]))? + .into_iter() + .map(LocalBranchRef::try_from) + .collect::, _>>() + } + + /// Lists local and remote branches. + #[instrument(level = "trace")] + pub fn list(&self) -> miette::Result> { + self.0 + .refs() + .for_each_ref(Some(&["refs/heads/**", "refs/remotes/**"]))? + .into_iter() + .map(BranchRef::try_from) + .collect::, _>>() + } + + /// Does a local branch exist? + #[instrument(level = "trace")] + pub fn exists_local(&self, branch: &str) -> miette::Result { + self.0 + .command() + .args(["show-ref", "--quiet", "--branches", branch]) + .output_checked_as(|context: OutputContext| { + Ok::<_, command_error::Error>(context.status().success()) + }) + .into_diagnostic() + } + + /// Does the given branch name exist as a local branch, a unique remote branch, or neither? + pub fn local_or_remote(&self, branch: &str) -> miette::Result> { + if self.exists_local(branch)? { + Ok(Some(LocalBranchRef::new(branch.to_owned()).into())) + } else if let Some(remote) = self.0.remote().for_branch(branch)? { + // This is the implicit behavior documented in `git-worktree(1)`. + Ok(Some(remote.into())) + } else { + Ok(None) + } + } + + pub fn current(&self) -> miette::Result> { + match self.0.refs().rev_parse_symbolic_full_name("HEAD")? { + Some(ref_name) => Ok(Some(LocalBranchRef::try_from(ref_name)?)), + None => Ok(None), + } + } + + /// Get the branch that a given branch is tracking. + pub fn upstream(&self, branch: &str) -> miette::Result> { + match self + .0 + .refs() + .rev_parse_symbolic_full_name(&format!("{branch}@{{upstream}}"))? + { + Some(ref_name) => Ok(Some(BranchRef::try_from(ref_name)?)), + // NOTE: `branch` may not exist at all! + None => Ok(None), + } + } +} diff --git a/src/git/commit_hash.rs b/src/git/commit_hash.rs new file mode 100644 index 0000000..cc9e6a8 --- /dev/null +++ b/src/git/commit_hash.rs @@ -0,0 +1,83 @@ +use std::fmt::Display; +use std::str::FromStr; + +use derive_more::{AsRef, Constructor, Deref, DerefMut, From, Into}; +use miette::miette; +use winnow::combinator::repeat; +use winnow::token::one_of; +use winnow::PResult; +use winnow::Parser; + +/// A Git commit hash. +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Into, 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] + } + + pub fn parser(input: &mut &str) -> PResult { + Ok(Self::from( + repeat(40, one_of(('0'..='9', 'a'..='f'))) + .map(|()| ()) + .take() + .parse_next(input)?, + )) + } +} + +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) + } + } +} + +impl From for CommitHash +where + S: AsRef, +{ + fn from(value: S) -> Self { + Self(value.as_ref().into()) + } +} + +impl FromStr for CommitHash { + type Err = miette::Report; + + fn from_str(s: &str) -> Result { + Self::parser.parse(s).map_err(|err| miette!("{err}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_commit_hash() { + assert_eq!( + CommitHash::from_str("1233def1234def1234def1234def1234def1234b").unwrap(), + CommitHash::new("1233def1234def1234def1234def1234def1234b".into()), + ); + + // Too short + assert!(CommitHash::from_str("1233def1234def1234def1234def1234def1234").is_err()); + + // Too long + assert!(CommitHash::from_str("1233def1234def1234def1234def1234def1234ab").is_err()); + + // Uppercase not allowed + assert!(CommitHash::from_str("1233DEF1234DEF1234DEF1234DEF1234DEF1234B").is_err()); + + // Illegal character + assert!(CommitHash::from_str("1233def1234def1234gef1234def1234def1234b").is_err()); + } +} diff --git a/src/git/commitish.rs b/src/git/commitish.rs new file mode 100644 index 0000000..b42a3d2 --- /dev/null +++ b/src/git/commitish.rs @@ -0,0 +1,22 @@ +use std::fmt::Display; + +use super::commit_hash::CommitHash; +use super::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/config.rs b/src/git/config.rs new file mode 100644 index 0000000..2ca3dcb --- /dev/null +++ b/src/git/config.rs @@ -0,0 +1,67 @@ +use std::fmt::Debug; + +use command_error::CommandExt; +use command_error::OutputContext; +use miette::IntoDiagnostic; +use tracing::instrument; +use utf8_command::Utf8Output; + +use super::Git; + +/// Git methods for dealing with config. +#[repr(transparent)] +pub struct GitConfig<'a>(&'a Git); + +impl Debug for GitConfig<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self.0, f) + } +} + +impl<'a> GitConfig<'a> { + pub fn new(git: &'a Git) -> Self { + Self(git) + } + + /// Get a config setting by name. + #[instrument(level = "trace")] + pub fn get(&self, key: &str) -> miette::Result> { + self.0 + .command() + .args(["config", "get", "--null", key]) + .output_checked_as(|context: OutputContext| { + if context.status().success() { + // TODO: Should this be a winnow parser? + 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() + } + + /// Set a local config setting. + #[instrument(level = "trace")] + pub fn set(&self, key: &str, value: &str) -> miette::Result<()> { + self.0 + .command() + .args(["config", "set", key, value]) + .output_checked_utf8() + .into_diagnostic()?; + Ok(()) + } +} diff --git a/src/git/head_state.rs b/src/git/head_state.rs new file mode 100644 index 0000000..d40e650 --- /dev/null +++ b/src/git/head_state.rs @@ -0,0 +1,47 @@ +use std::fmt::Display; + +use tracing::instrument; + +use super::CommitHash; +use super::LocalBranchRef; + +/// Is `HEAD` detached? +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HeadKind { + Detached(CommitHash), + Branch(LocalBranchRef), +} + +impl HeadKind { + pub fn commitish(&self) -> &str { + match &self { + HeadKind::Detached(commit) => commit.as_str(), + HeadKind::Branch(ref_name) => ref_name.name(), + } + } + + pub fn branch_name(&self) -> Option<&str> { + match &self { + HeadKind::Detached(_) => None, + // There's no way we can have a remote branch checked out. + HeadKind::Branch(branch) => Some(branch.branch_name()), + } + } + + #[instrument(level = "trace")] + pub fn is_on_branch(&self, branch_name: &str) -> bool { + match self { + HeadKind::Detached(_) => false, + HeadKind::Branch(checked_out) => checked_out.branch_name() == branch_name, + } + } +} + +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::Branch(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..044e0db --- /dev/null +++ b/src/git/mod.rs @@ -0,0 +1,188 @@ +use std::fmt::Debug; +use std::process::Command; + +use camino::Utf8Path; +use camino::Utf8PathBuf; +use command_error::CommandExt; +use miette::IntoDiagnostic; +use tracing::instrument; + +mod branch; +mod commit_hash; +mod commitish; +mod config; +mod head_state; +mod path; +mod refs; +mod remote; +mod repository_url_destination; +mod status; +mod worktree; + +pub use branch::GitBranch; +pub use commit_hash::CommitHash; +pub use commitish::ResolvedCommitish; +pub use config::GitConfig; +pub use head_state::HeadKind; +pub use path::GitPath; +pub use refs::BranchRef; +pub use refs::GitRefs; +pub use refs::LocalBranchRef; +pub use refs::Ref; +pub use refs::RemoteBranchRef; +pub use remote::GitRemote; +pub use repository_url_destination::repository_url_destination; +pub use status::GitStatus; +pub use status::Status; +pub use status::StatusCode; +pub use status::StatusEntry; +pub use worktree::AddWorktreeOpts; +pub use worktree::GitWorktree; +pub use worktree::Worktree; +pub use worktree::WorktreeHead; +pub use worktree::Worktrees; + +use crate::app_git::AppGit; +use crate::config::Config; +use crate::current_dir::current_dir_utf8; + +/// `git` CLI wrapper. +#[derive(Clone)] +pub struct Git { + current_dir: Utf8PathBuf, + env_variables: Vec<(String, String)>, + args: Vec, +} + +impl Debug for Git { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("Git").field(&self.current_dir).finish() + } +} + +impl Git { + pub fn from_path(current_dir: Utf8PathBuf) -> Self { + Self { + current_dir, + env_variables: Vec::new(), + args: Vec::new(), + } + } + + pub fn from_current_dir() -> miette::Result { + Ok(Self::from_path(current_dir_utf8()?)) + } + + pub fn with_config(self, config: &Config) -> AppGit<'_> { + AppGit { git: self, config } + } + + /// Get a `git` command. + pub fn command(&self) -> Command { + let mut command = Command::new("git"); + command.current_dir(&self.current_dir); + command.envs(self.env_variables.iter().map(|(key, value)| (key, value))); + command.args(&self.args); + command + } + + pub fn get_directory(&self) -> &Utf8Path { + &self.current_dir + } + + /// Set the current working directory for `git` commands to be run in. + pub fn set_directory(&mut self, path: Utf8PathBuf) { + self.current_dir = path; + } + + pub fn with_directory(&self, path: Utf8PathBuf) -> Self { + Self { + current_dir: path, + env_variables: self.env_variables.clone(), + args: self.args.clone(), + } + } + + pub fn env(&mut self, key: String, value: String) { + self.env_variables.push((key, value)); + } + + pub fn envs(&mut self, iter: impl IntoIterator) { + self.env_variables.extend(iter); + } + + pub fn arg(&mut self, arg: String) { + self.args.push(arg); + } + + pub fn args(&mut self, iter: impl IntoIterator) { + self.args.extend(iter); + } + + /// Methods for dealing with Git remotes. + pub fn remote(&self) -> GitRemote<'_> { + GitRemote::new(self) + } + + /// Methods for dealing with Git remotes. + pub fn path(&self) -> GitPath<'_> { + GitPath::new(self) + } + + /// Methods for dealing with Git remotes. + pub fn worktree(&self) -> GitWorktree<'_> { + GitWorktree::new(self) + } + + /// Methods for dealing with Git refs. + pub fn refs(&self) -> GitRefs<'_> { + GitRefs::new(self) + } + + /// Methods for dealing with Git statuses and the working tree. + pub fn status(&self) -> GitStatus<'_> { + GitStatus::new(self) + } + + /// Methods for dealing with Git statuses and the working tree. + pub fn config(&self) -> GitConfig<'_> { + GitConfig::new(self) + } + + /// Methods for dealing with Git statuses and the working tree. + pub fn branch(&self) -> GitBranch<'_> { + GitBranch::new(self) + } + + pub(crate) fn rev_parse_command(&self) -> Command { + let mut command = self.command(); + command.args(["rev-parse", "--path-format=absolute"]); + command + } + + #[instrument(level = "trace")] + 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(()) + } + + /// `git reset`. + #[instrument(level = "trace")] + pub fn reset(&self) -> miette::Result<()> { + self.command() + .arg("reset") + .output_checked_utf8() + .into_diagnostic()?; + Ok(()) + } +} diff --git a/src/git/path.rs b/src/git/path.rs new file mode 100644 index 0000000..5cc868c --- /dev/null +++ b/src/git/path.rs @@ -0,0 +1,62 @@ +use std::fmt::Debug; + +use camino::Utf8PathBuf; +use command_error::CommandExt; +use miette::Context; +use miette::IntoDiagnostic; +use tracing::instrument; + +use super::Git; + +/// Git methods for dealing with paths. +#[repr(transparent)] +pub struct GitPath<'a>(&'a Git); + +impl Debug for GitPath<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self.0, f) + } +} + +impl<'a> GitPath<'a> { + pub fn new(git: &'a Git) -> Self { + Self(git) + } + + /// `git rev-parse --show-toplevel` + #[instrument(level = "trace")] + pub fn repo_root(&self) -> miette::Result { + Ok(self + .0 + .rev_parse_command() + .arg("--show-toplevel") + .output_checked_utf8() + .into_diagnostic() + .wrap_err("Failed to get working directory of repository")? + .stdout + .trim() + .into()) + } + + /// Get the `.git` directory path. + #[expect(dead_code)] // #[instrument(level = "trace")] + pub(crate) fn get_git_dir(&self) -> miette::Result { + self.0 + .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. + #[instrument(level = "trace")] + pub fn git_common_dir(&self) -> miette::Result { + self.0 + .rev_parse_command() + .arg("--git-common-dir") + .output_checked_utf8() + .into_diagnostic() + .map(|output| Utf8PathBuf::from(output.stdout.trim())) + } +} diff --git a/src/git/refs/branch.rs b/src/git/refs/branch.rs new file mode 100644 index 0000000..d882b7a --- /dev/null +++ b/src/git/refs/branch.rs @@ -0,0 +1,136 @@ +use std::fmt::Display; +use std::ops::Deref; + +use miette::miette; + +use super::LocalBranchRef; +use super::Ref; +use super::RemoteBranchRef; + +/// A Git reference to a remote branch. +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum BranchRef { + /// A local branch. + Local(LocalBranchRef), + /// A remote-tracking branch. + Remote(RemoteBranchRef), +} + +impl BranchRef { + /// Get the qualified name of this branch. + pub fn qualified_branch_name(&self) -> &str { + match &self { + BranchRef::Local(ref_name) => ref_name.branch_name(), + BranchRef::Remote(ref_name) => ref_name.name(), + } + } + + /// Get the name of this branch. + pub fn branch_name(&self) -> &str { + match &self { + BranchRef::Local(ref_name) => ref_name.branch_name(), + BranchRef::Remote(ref_name) => ref_name.branch_name(), + } + } +} + +impl PartialEq for BranchRef { + fn eq(&self, other: &Ref) -> bool { + self.deref().eq(other) + } +} + +impl Display for BranchRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BranchRef::Local(ref_name) => Display::fmt(ref_name, f), + BranchRef::Remote(ref_name) => Display::fmt(ref_name, f), + } + } +} + +impl Deref for BranchRef { + type Target = Ref; + + fn deref(&self) -> &Self::Target { + match self { + BranchRef::Local(ref_name) => ref_name.deref(), + BranchRef::Remote(ref_name) => ref_name.deref(), + } + } +} + +impl TryFrom for BranchRef { + type Error = miette::Report; + + fn try_from(value: Ref) -> Result { + match value.kind() { + Ref::HEADS => Ok(Self::Local(LocalBranchRef::try_from(value)?)), + Ref::REMOTES => Ok(Self::Remote(RemoteBranchRef::try_from(value)?)), + _ => Err(miette!("Ref is not a local or remote branch: {value}")), + } + } +} + +impl From for BranchRef { + fn from(value: LocalBranchRef) -> Self { + Self::Local(value) + } +} + +impl From for BranchRef { + fn from(value: RemoteBranchRef) -> Self { + Self::Remote(value) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_branch_ref_try_from() { + let branch = BranchRef::try_from(Ref::from_str("refs/heads/puppy/doggy").unwrap()).unwrap(); + + assert_eq!(branch.branch_name(), "puppy/doggy",); + assert_eq!(branch.qualified_branch_name(), "puppy/doggy",); + + let branch = + BranchRef::try_from(Ref::from_str("refs/remotes/puppy/doggy").unwrap()).unwrap(); + + assert_eq!(branch.branch_name(), "doggy",); + assert_eq!(branch.qualified_branch_name(), "puppy/doggy",); + + assert!(BranchRef::try_from(Ref::from_str("refs/tags/v1.0.0").unwrap()).is_err()); + } + + #[test] + fn test_branch_qualified_branch_name() { + assert_eq!( + BranchRef::Remote(RemoteBranchRef::new("origin", "puppy")).qualified_branch_name(), + "origin/puppy", + ); + + assert_eq!( + BranchRef::Local(LocalBranchRef::new("puppy".into())).qualified_branch_name(), + "puppy", + ); + } + + #[test] + fn test_branch_branch_name() { + assert_eq!( + BranchRef::Remote(RemoteBranchRef::new("origin", "puppy")).branch_name(), + "puppy", + ); + + assert_eq!( + BranchRef::Local(LocalBranchRef::new("puppy".into())).branch_name(), + "puppy", + ); + } +} diff --git a/src/git/refs/local_branch.rs b/src/git/refs/local_branch.rs new file mode 100644 index 0000000..ae9c2fc --- /dev/null +++ b/src/git/refs/local_branch.rs @@ -0,0 +1,126 @@ +use std::fmt::Debug; +use std::fmt::Display; +use std::ops::Deref; + +use miette::miette; + +use super::Ref; +use super::RemoteBranchRef; + +/// A Git reference to a local branch. +#[derive(Clone, Hash, PartialEq, Eq)] +pub struct LocalBranchRef(Ref); + +impl Debug for LocalBranchRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(&self.0, f) + } +} + +impl PartialEq for LocalBranchRef { + fn eq(&self, other: &Ref) -> bool { + self.0.eq(other) + } +} + +impl LocalBranchRef { + pub fn new(name: String) -> Self { + Self(Ref::new(Ref::HEADS.to_owned(), name)) + } + + /// Get the name of this local branch. + pub fn branch_name(&self) -> &str { + self.0.name() + } + + pub fn on_remote(&self, remote: &str) -> RemoteBranchRef { + RemoteBranchRef::new(remote, self.branch_name()) + } +} + +impl Deref for LocalBranchRef { + type Target = Ref; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for LocalBranchRef { + type Error = miette::Report; + + fn try_from(value: Ref) -> Result { + if value.is_local_branch() { + Ok(Self(value)) + } else { + Err(miette!("Ref is not a local branch: {value}")) + } + } +} + +impl From for LocalBranchRef +where + S: AsRef, +{ + fn from(value: S) -> Self { + Self::new(value.as_ref().to_owned()) + } +} + +impl Display for LocalBranchRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.0, f) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn local_branch_ref_try_from() { + let branch = + LocalBranchRef::try_from(Ref::from_str("refs/heads/puppy/doggy").unwrap()).unwrap(); + + assert_eq!(branch.branch_name(), "puppy/doggy"); + } + + #[test] + fn local_branch_ref_from_str() { + let branch = LocalBranchRef::from("puppy"); + + assert_eq!(branch, Ref::from_str("refs/heads/puppy").unwrap()); + } + + #[test] + fn test_local_branch_new() { + assert_eq!( + LocalBranchRef::new("puppy".into()), + Ref::from_str("refs/heads/puppy").unwrap(), + ); + } + + #[test] + fn test_local_branch_branch_name() { + assert_eq!(LocalBranchRef::new("puppy".into()).branch_name(), "puppy",); + } + + #[test] + fn test_local_branch_on_remote() { + assert_eq!( + LocalBranchRef::new("puppy".into()).on_remote("origin"), + Ref::from_str("refs/remotes/origin/puppy").unwrap(), + ); + } + + #[test] + fn test_remote_branch_display() { + let branch = LocalBranchRef::new("puppy".into()); + assert_eq!(format!("{branch}"), "puppy"); + assert_eq!(format!("{branch:#}"), "refs/heads/puppy"); + } +} diff --git a/src/git/refs/mod.rs b/src/git/refs/mod.rs new file mode 100644 index 0000000..6f1ed44 --- /dev/null +++ b/src/git/refs/mod.rs @@ -0,0 +1,172 @@ +use std::fmt::Debug; +use std::str::FromStr; + +use command_error::CommandExt; +use command_error::OutputContext; +use miette::miette; +use miette::Context; +use miette::IntoDiagnostic; +use tap::Tap; +use tracing::instrument; +use utf8_command::Utf8Output; + +use super::commit_hash::CommitHash; +use super::commitish::ResolvedCommitish; +use super::head_state::HeadKind; +use super::Git; + +mod branch; +mod local_branch; +mod name; +mod remote_branch; + +pub use branch::BranchRef; +pub use local_branch::LocalBranchRef; +pub use name::Ref; +pub use remote_branch::RemoteBranchRef; + +/// Git methods for dealing with refs. +#[repr(transparent)] +pub struct GitRefs<'a>(&'a Git); + +impl Debug for GitRefs<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self.0, f) + } +} + +impl<'a> GitRefs<'a> { + pub fn new(git: &'a Git) -> Self { + Self(git) + } + + #[expect(dead_code)] // #[instrument(level = "trace")] + pub(crate) fn commit_message(&self, commit: &str) -> miette::Result { + Ok(self + .0 + .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. + #[instrument(level = "trace")] + pub fn get_head(&self) -> miette::Result { + Ok(self.parse("HEAD")?.expect("HEAD always exists")) + } + + /// Parse a `commitish` into a commit hash. + #[instrument(level = "trace")] + pub fn parse(&self, commitish: &str) -> miette::Result> { + self.0 + .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` + #[instrument(level = "trace")] + pub fn rev_parse_symbolic_full_name(&self, commitish: &str) -> miette::Result> { + self.0 + .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 { + match Ref::from_str(trimmed) { + Ok(parsed) => Ok(Some(parsed)), + Err(err) => { + if commitish.ends_with("HEAD") && trimmed == commitish { + tracing::debug!("{commitish} is detached"); + Ok(None) + } else { + Err(context.error_msg(err)) + } + } + } + } + } else { + Ok(None) + } + }) + .into_diagnostic() + } + + /// Determine if a given `` refers to a commit or a symbolic ref name. + #[instrument(level = "trace")] + 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.parse(commitish)?.ok_or_else(|| { + miette!("Commitish could not be resolved to a ref or commit hash: {commitish}") + })?, + )), + } + } + + #[instrument(level = "trace")] + pub fn is_head_detached(&self) -> miette::Result { + let output = self + .0 + .command() + .args(["symbolic-ref", "--quiet", "HEAD"]) + .output_checked_with_utf8::(|_output| Ok(())) + .into_diagnostic()?; + + Ok(!output.status.success()) + } + + /// Figure out what's going on with `HEAD`. + #[instrument(level = "trace")] + pub fn head_kind(&self) -> miette::Result { + Ok(if self.is_head_detached()? { + HeadKind::Detached(self.get_head()?) + } else { + HeadKind::Branch( + LocalBranchRef::try_from( + self.rev_parse_symbolic_full_name("HEAD")? + .expect("Non-detached HEAD should always be a valid ref"), + ) + .expect("Non-detached HEAD should always be a local branch"), + ) + }) + } + + #[instrument(level = "trace")] + pub fn for_each_ref(&self, globs: Option<&[&str]>) -> miette::Result> { + self.0 + .command() + .args(["for-each-ref", "--format=%(refname)"]) + .tap_mut(|c| { + globs.map(|globs| c.args(globs)); + }) + .output_checked_utf8() + .into_diagnostic()? + .stdout + .lines() + .map(Ref::from_str) + .collect() + } +} diff --git a/src/git/refs/name.rs b/src/git/refs/name.rs new file mode 100644 index 0000000..d23e803 --- /dev/null +++ b/src/git/refs/name.rs @@ -0,0 +1,137 @@ +use std::fmt::Debug; +use std::fmt::Display; +use std::str::FromStr; + +use miette::miette; +use winnow::combinator::rest; +use winnow::token::take_till; +use winnow::PResult; +use winnow::Parser; + +/// A Git reference. +/// +/// For branches, see: +/// - [`super::LocalBranchRef`] for `refs/heads/*`. +/// - [`super::RemoteBranchRef`] for `refs/remotes/*`. +/// - [`super::BranchRef`] to combine the above types. +#[derive(Clone, Hash, 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 Debug for Ref { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.to_string()) + } +} + +impl Ref { + /// The `kind` indicating a branch reference. + pub const HEADS: &str = "heads"; + /// The `kind` indicating a remote-tracking branch reference. + pub const REMOTES: &str = "remotes"; + /// The `kind` indicating a tag reference. + pub const TAGS: &str = "tags"; + + pub fn new(kind: String, name: String) -> Self { + Self { kind, name } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn kind(&self) -> &str { + &self.kind + } + + /// 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(crate) fn is_tag(&self) -> bool { + self.kind == Self::TAGS + } + + /// Parse a ref name like `refs/puppy/doggy`. + /// + /// Needs at least one slash after `refs/`; this does not treat `refs/puppy` as a valid ref + /// name. + pub fn parser(input: &mut &str) -> PResult { + let _refs_prefix = "refs/".parse_next(input)?; + + let kind = take_till(1.., '/').parse_next(input)?; + let _ = '/'.parse_next(input)?; + let name = rest.parse_next(input)?; + + Ok(Self { + kind: kind.to_owned(), + name: name.to_owned(), + }) + } +} + +impl FromStr for Ref { + type Err = miette::Report; + + fn from_str(input: &str) -> Result { + Self::parser.parse(input).map_err(|err| miette!("{err}")) + } +} + +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) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ref_parse_no_slash() { + assert!(Ref::from_str("refs/puppy").is_err()); + } + + #[test] + fn test_ref_parse_simple() { + assert_eq!( + Ref::from_str("refs/puppy/doggy").unwrap(), + Ref { + kind: "puppy".into(), + name: "doggy".into() + } + ); + } + + #[test] + fn test_ref_parse_multiple_slashes() { + assert_eq!( + Ref::from_str("refs/puppy/doggy/softie/cutie").unwrap(), + Ref { + kind: "puppy".into(), + name: "doggy/softie/cutie".into() + } + ); + } +} diff --git a/src/git/refs/remote_branch.rs b/src/git/refs/remote_branch.rs new file mode 100644 index 0000000..c5f3b10 --- /dev/null +++ b/src/git/refs/remote_branch.rs @@ -0,0 +1,152 @@ +use std::fmt::Debug; +use std::fmt::Display; +use std::ops::Deref; + +use miette::miette; + +use super::LocalBranchRef; +use super::Ref; + +/// A Git reference to a remote branch. +#[derive(Clone, Hash, PartialEq, Eq)] +pub struct RemoteBranchRef(Ref); + +impl Debug for RemoteBranchRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(&self.0, f) + } +} + +impl PartialEq for RemoteBranchRef { + fn eq(&self, other: &Ref) -> bool { + self.0.eq(other) + } +} + +impl RemoteBranchRef { + pub fn new(remote: &str, name: &str) -> Self { + Self(Ref::new( + Ref::REMOTES.to_owned(), + format!("{remote}/{name}"), + )) + } + + /// Get the qualified name of this branch, including the remote name. + pub fn qualified_branch_name(&self) -> &str { + self.name() + } + + /// Get the name of this remote and branch. + pub fn remote_and_branch(&self) -> (&str, &str) { + self.0 + .name() + .split_once('/') + .expect("A remote branch always has a remote and a branch") + } + + /// Get the name of this remote. + pub fn remote(&self) -> &str { + self.remote_and_branch().0 + } + + /// Get the name of this branch. + pub fn branch_name(&self) -> &str { + self.remote_and_branch().1 + } + + /// Get a local branch with the same name. + pub fn as_local(&self) -> LocalBranchRef { + LocalBranchRef::new(self.branch_name().to_owned()) + } +} + +impl Deref for RemoteBranchRef { + type Target = Ref; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom for RemoteBranchRef { + type Error = miette::Report; + + fn try_from(value: Ref) -> Result { + if value.is_remote_branch() { + Ok(Self(value)) + } else { + Err(miette!("Ref is not a remote branch: {value}")) + } + } +} + +impl Display for RemoteBranchRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.0, f) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn remote_branch_ref_try_from() { + let branch = + RemoteBranchRef::try_from(Ref::from_str("refs/remotes/puppy/doggy").unwrap()).unwrap(); + + assert_eq!(branch.remote(), "puppy"); + assert_eq!(branch.branch_name(), "doggy"); + } + + #[test] + fn test_remote_branch_new() { + assert_eq!( + RemoteBranchRef::new("origin", "puppy"), + Ref::from_str("refs/remotes/origin/puppy").unwrap(), + ); + } + + #[test] + fn test_remote_branch_qualified_branch_name() { + assert_eq!( + RemoteBranchRef::new("origin", "puppy").qualified_branch_name(), + "origin/puppy", + ); + } + + #[test] + fn test_remote_branch_remote_and_branch() { + assert_eq!( + RemoteBranchRef::new("origin", "puppy/doggy").remote_and_branch(), + ("origin", "puppy/doggy"), + ); + } + + #[test] + fn test_remote_branch_branch_name() { + assert_eq!( + RemoteBranchRef::new("origin", "puppy").branch_name(), + "puppy", + ); + } + + #[test] + fn test_remote_branch_as_local() { + assert_eq!( + RemoteBranchRef::new("origin", "puppy").as_local(), + Ref::from_str("refs/heads/puppy").unwrap(), + ); + } + + #[test] + fn test_remote_branch_display() { + let branch = RemoteBranchRef::new("origin", "puppy"); + assert_eq!(format!("{branch}"), "origin/puppy"); + assert_eq!(format!("{branch:#}"), "refs/remotes/origin/puppy"); + } +} diff --git a/src/git/remote.rs b/src/git/remote.rs new file mode 100644 index 0000000..28d6c82 --- /dev/null +++ b/src/git/remote.rs @@ -0,0 +1,231 @@ +use std::fmt::Debug; +use std::str::FromStr; + +use command_error::CommandExt; +use command_error::OutputContext; +use miette::miette; +use miette::Context; +use miette::IntoDiagnostic; +use tap::TryConv; +use tracing::instrument; +use utf8_command::Utf8Output; +use winnow::combinator::rest; +use winnow::token::take_till; +use winnow::PResult; +use winnow::Parser; + +use super::Git; +use super::LocalBranchRef; +use super::Ref; +use super::RemoteBranchRef; + +/// Git methods for dealing with remotes. +#[repr(transparent)] +pub struct GitRemote<'a>(&'a Git); + +impl Debug for GitRemote<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self.0, f) + } +} + +impl<'a> GitRemote<'a> { + pub fn new(git: &'a Git) -> Self { + Self(git) + } + + /// Get a list of all `git remote`s. + #[instrument(level = "trace")] + pub fn list(&self) -> miette::Result> { + Ok(self + .0 + .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)] // #[instrument(level = "trace")] + pub(crate) fn get_push_url(&self, remote: &str) -> miette::Result { + Ok(self + .0 + .command() + .args(["remote", "get-url", "--push", remote]) + .output_checked_utf8() + .into_diagnostic() + .wrap_err("Failed to get Git remote URL")? + .stdout + .trim() + .to_owned()) + } + + #[instrument(level = "trace")] + fn default_branch_symbolic_ref(&self, remote: &str) -> miette::Result { + self.0 + .command() + .args(["symbolic-ref", &format!("refs/remotes/{remote}/HEAD")]) + .output_checked_as(|context: OutputContext| { + if !context.status().success() { + Err(context.error()) + } else { + let output = context.output().stdout.trim_end(); + match Ref::from_str(output) { + Err(err) => Err(context.error_msg(err)), + Ok(ref_name) => match ref_name.try_conv::() { + Ok(remote_branch) => Ok(remote_branch), + Err(err) => Err(context.error_msg(format!("{err}"))), + }, + } + } + }) + .into_diagnostic() + } + + #[instrument(level = "trace")] + fn default_branch_ls_remote(&self, remote: &str) -> miette::Result { + let branch = self + .0 + .command() + .args(["ls-remote", "--symref", remote, "HEAD"]) + .output_checked_as(|context: OutputContext| { + if !context.status().success() { + Err(context.error()) + } else { + let output = &context.output().stdout; + match parse_ls_remote_symref.parse(output) { + Err(err) => { + let err = miette!("{err}"); + Err(context.error_msg(err)) + } + Ok(ref_name) => match ref_name.try_conv::() { + Ok(local_branch) => Ok(local_branch.on_remote(remote)), + Err(err) => Err(context.error_msg(format!("{err}"))), + }, + } + } + }) + .into_diagnostic()?; + + // To avoid talking to the remote next time, write a symbolic-ref. + self.0 + .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) + } + + /// Get the default branch for the given remote. + #[instrument(level = "trace")] + 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) + }) + } + + /// Get the `checkout.defaultRemote` setting. + #[instrument(level = "trace")] + pub fn get_default(&self) -> miette::Result> { + self.0.config().get("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`. + #[instrument(level = "trace")] + pub fn for_branch(&self, branch: &str) -> miette::Result> { + let mut exists_on_remotes = self + .0 + .refs() + .for_each_ref(Some(&[&format!("refs/remotes/*/{branch}")]))?; + + if exists_on_remotes.is_empty() { + Ok(None) + } else if exists_on_remotes.len() == 1 { + Ok(exists_on_remotes.pop().map(|ref_name| { + RemoteBranchRef::try_from(ref_name) + .expect("`for-each-ref` restricted to `refs/remotes/*` refs") + })) + } else if let Some(default_remote) = self.get_default()? { + // if-let chains when? + match exists_on_remotes + .into_iter() + .map(|ref_name| { + RemoteBranchRef::try_from(ref_name) + .expect("`for-each-ref` restricted to `refs/remotes/*` refs") + }) + .find(|branch| branch.remote() == default_remote) + { + Some(remote) => Ok(Some(remote)), + _ => Ok(None), + } + } else { + Ok(None) + } + } + + /// Fetch a refspec from a remote. + #[instrument(level = "trace")] + pub fn fetch(&self, remote: &str, refspec: Option<&str>) -> miette::Result<()> { + let mut command = self.0.command(); + command.args(["fetch", remote]); + if let Some(refspec) = refspec { + command.arg(refspec); + } + command.status_checked().into_diagnostic()?; + Ok(()) + } +} + +/// Parse a symbolic ref from the start of `git ls-remote --symref` output. +fn parse_ls_remote_symref(input: &mut &str) -> PResult { + let _ = "ref: ".parse_next(input)?; + let ref_name = take_till(1.., '\t') + .and_then(Ref::parser) + .parse_next(input)?; + let _ = '\t'.parse_next(input)?; + // Don't care about the rest! + let _ = rest.parse_next(input)?; + Ok(ref_name) +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_parse_ls_remote_symref() { + assert_eq!( + parse_ls_remote_symref + .parse(indoc!( + " + ref: refs/heads/main\tHEAD + 9afc843b4288394fe3a2680b13070cfd53164b92\tHEAD + " + )) + .unwrap(), + Ref::from_str("refs/heads/main").unwrap(), + ); + } +} diff --git a/src/git/repository_url_destination.rs b/src/git/repository_url_destination.rs new file mode 100644 index 0000000..10e7681 --- /dev/null +++ b/src/git/repository_url_destination.rs @@ -0,0 +1,39 @@ +/// 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) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_repository_url_destination() { + assert_eq!(repository_url_destination("puppy/doggy"), "doggy"); + + assert_eq!(repository_url_destination("puppy/doggy.git"), "doggy"); + assert_eq!(repository_url_destination("silly/puppy/doggy.git"), "doggy"); + assert_eq!( + repository_url_destination("git@github.com:silly/doggy.git"), + "doggy" + ); + assert_eq!( + repository_url_destination("git@github.com/silly/doggy.git"), + "doggy" + ); + assert_eq!( + repository_url_destination("https://github.com/silly/doggy.git"), + "doggy" + ); + } +} diff --git a/src/git/status.rs b/src/git/status.rs new file mode 100644 index 0000000..bac259f --- /dev/null +++ b/src/git/status.rs @@ -0,0 +1,377 @@ +use std::fmt::Debug; +use std::iter; +use std::str::FromStr; + +use camino::Utf8PathBuf; +use command_error::CommandExt; +use command_error::OutputContext; +use miette::miette; +use miette::IntoDiagnostic; +use tracing::instrument; +use utf8_command::Utf8Output; +use winnow::combinator::eof; +use winnow::combinator::opt; +use winnow::combinator::repeat_till; +use winnow::token::one_of; +use winnow::PResult; +use winnow::Parser; + +use crate::parse::till_null; + +use super::Git; + +/// Git methods for dealing with statuses and the working tree. +#[repr(transparent)] +pub struct GitStatus<'a>(&'a Git); + +impl Debug for GitStatus<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self.0, f) + } +} + +impl<'a> GitStatus<'a> { + pub fn new(git: &'a Git) -> Self { + Self(git) + } + + #[instrument(level = "trace")] + pub fn get(&self) -> miette::Result { + self.0 + .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() + } + + /// List untracked files and directories. + #[instrument(level = "trace")] + pub fn untracked_files(&self) -> miette::Result> { + Ok(self + .0 + .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()) + } +} + +/// The status code of a particular file. Each [`StatusEntry`] has two of these. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusCode { + /// ` ` + Unmodified, + /// `M` + Modified, + /// `T` + TypeChanged, + /// `A` + Added, + /// `D` + Deleted, + /// `R` + Renamed, + /// `C` + Copied, + /// `U` + Unmerged, + /// `?` + Untracked, + /// `!` + Ignored, +} + +impl StatusCode { + pub fn parser(input: &mut &str) -> PResult { + let code = one_of([' ', 'M', 'T', 'A', 'D', 'R', 'C', 'U', '?', '!']).parse_next(input)?; + Ok(match code { + ' ' => 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, + _ => { + unreachable!() + } + }) + } +} + +/// The status of a particular file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusEntry { + /// The status of the file in the index. + /// + /// 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, + /// The status of the file in the working tree. + /// + /// 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, + 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 + ) + }) + } + + pub fn parser(input: &mut &str) -> PResult { + let left = StatusCode::parser.parse_next(input)?; + let right = StatusCode::parser.parse_next(input)?; + let _ = ' '.parse_next(input)?; + let path = till_null.parse_next(input)?; + + let mut entry = Self { + left, + right, + path: Utf8PathBuf::from(path), + renamed_from: None, + }; + + if entry.is_renamed() { + let renamed_from = till_null.parse_next(input)?; + entry.renamed_from = Some(Utf8PathBuf::from(renamed_from)); + } + + Ok(entry) + } +} + +impl FromStr for StatusEntry { + type Err = miette::Report; + + fn from_str(input: &str) -> Result { + Self::parser.parse(input).map_err(|err| miette!("{err}")) + } +} + +/// 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, PartialEq, Eq)] +pub struct Status { + pub entries: Vec, +} + +impl Status { + #[instrument(level = "trace")] + pub fn is_clean(&self) -> bool { + self.entries.iter().all(|entry| !entry.is_modified()) + } + + pub fn parser(input: &mut &str) -> PResult { + if opt(eof).parse_next(input)?.is_some() { + return Ok(Self { + entries: Vec::new(), + }); + } + + let (entries, _eof) = repeat_till(1.., StatusEntry::parser, eof).parse_next(input)?; + Ok(Self { entries }) + } +} + +impl FromStr for Status { + type Err = miette::Report; + + fn from_str(input: &str) -> Result { + Self::parser.parse(input).map_err(|err| miette!("{err}")) + } +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_status_parse_empty() { + assert_eq!(Status::from_str("").unwrap().entries, vec![]); + } + + #[test] + fn test_status_parse_complex() { + assert_eq!( + Status::from_str( + &indoc!( + " 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/ + " + ) + .replace('\n', "\0") + ) + .unwrap() + .entries, + vec![ + StatusEntry { + left: StatusCode::Unmodified, + right: StatusCode::Modified, + path: "Cargo.lock".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Unmodified, + right: StatusCode::Modified, + path: "Cargo.toml".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Unmodified, + right: StatusCode::Modified, + path: "src/app.rs".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Unmodified, + right: StatusCode::Modified, + path: "src/cli.rs".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Unmodified, + right: StatusCode::Deleted, + path: "src/commit_hash.rs".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Unmodified, + right: StatusCode::Deleted, + path: "src/git.rs".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Unmodified, + right: StatusCode::Modified, + path: "src/main.rs".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Unmodified, + right: StatusCode::Deleted, + path: "src/ref_name.rs".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Unmodified, + right: StatusCode::Deleted, + path: "src/worktree.rs".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Untracked, + right: StatusCode::Untracked, + path: "src/config.rs".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Untracked, + right: StatusCode::Untracked, + path: "src/git/".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Untracked, + right: StatusCode::Untracked, + path: "src/utf8tempdir.rs".into(), + renamed_from: None, + }, + StatusEntry { + left: StatusCode::Ignored, + right: StatusCode::Ignored, + path: "target/".into(), + renamed_from: None, + }, + ] + ); + } + + #[test] + fn test_status_parse_renamed() { + assert_eq!( + Status::from_str("R PUPPY.md\0README.md\0") + .unwrap() + .entries, + vec![StatusEntry { + left: StatusCode::Renamed, + right: StatusCode::Unmodified, + path: "PUPPY.md".into(), + renamed_from: Some("README.md".into()), + }] + ); + } +} diff --git a/src/git/worktree.rs b/src/git/worktree.rs new file mode 100644 index 0000000..609423f --- /dev/null +++ b/src/git/worktree.rs @@ -0,0 +1,580 @@ +use std::collections::HashMap; +use std::fmt::Debug; +use std::fmt::Display; +use std::ops::Deref; +use std::process::Command; +use std::str::FromStr; + +use camino::Utf8Path; +use camino::Utf8PathBuf; +use command_error::CommandExt; +use command_error::OutputContext; +use miette::miette; +use miette::IntoDiagnostic; +use owo_colors::OwoColorize; +use owo_colors::Stream; +use tap::Tap; +use tracing::instrument; +use utf8_command::Utf8Output; +use winnow::combinator::alt; +use winnow::combinator::cut_err; +use winnow::combinator::eof; +use winnow::combinator::opt; +use winnow::combinator::repeat_till; +use winnow::PResult; +use winnow::Parser; + +use crate::parse::till_null; +use crate::NormalPath; + +use super::commit_hash::CommitHash; +use super::Git; +use super::LocalBranchRef; +use super::Ref; + +/// Git methods for dealing with worktrees. +#[repr(transparent)] +pub struct GitWorktree<'a>(&'a Git); + +impl Debug for GitWorktree<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self.0, f) + } +} + +impl<'a> GitWorktree<'a> { + pub fn new(git: &'a Git) -> Self { + Self(git) + } + + /// Get the 'main' worktree. There can only be one main worktree, and it contains the + /// common `.git` directory. + /// + /// See: + #[instrument(level = "trace")] + pub fn main(&self) -> miette::Result { + // Kinda wasteful; we parse all the worktrees and then throw them away. + let mut worktrees = self.list()?; + Ok(worktrees.inner.remove(&worktrees.main).unwrap()) + } + + /// Get the worktree container directory. + /// + /// This is the main worktree's parent, and is usually where all the other worktrees are + /// cloned as well. + #[instrument(level = "trace")] + pub fn container(&self) -> miette::Result { + // TODO: Write `.git-prole` to indicate worktree container root? + let main = self.main()?; + let mut path = if main.head == WorktreeHead::Bare { + // Git has a bug(?) where `git worktree list` will show the _parent_ of a + // bare worktree in a directory named `.git`. Work around it by getting the + // `.git` directory manually. + // + // See: https://lore.kernel.org/git/8f961645-2b70-4d45-a9f9-72e71c07bc11@app.fastmail.com/T/ + self.0.with_directory(main.path).path().git_common_dir()? + } else { + main.path + }; + + if !path.pop() { + Err(miette!("Main worktree path has no parent: {path}")) + } else { + Ok(path) + } + } + + /// List Git worktrees. + #[instrument(level = "trace")] + pub fn list(&self) -> miette::Result { + self.0 + .command() + .args(["worktree", "list", "--porcelain", "-z"]) + .output_checked_as(|context: OutputContext| { + if !context.status().success() { + Err(context.error()) + } else { + let output = &context.output().stdout; + match Worktrees::parser.parse(output) { + Ok(worktrees) => Ok(worktrees), + Err(err) => { + let err = miette!("{err}"); + Err(context.error_msg(err)) + } + } + } + }) + .into_diagnostic() + } + + #[instrument(level = "trace")] + pub fn add(&self, path: &Utf8Path, options: &AddWorktreeOpts<'_>) -> miette::Result<()> { + self.add_command(path, options) + .status_checked() + .into_diagnostic()?; + Ok(()) + } + + #[instrument(level = "trace")] + pub fn add_command(&self, path: &Utf8Path, options: &AddWorktreeOpts<'_>) -> Command { + let mut command = self.0.command(); + command.args(["worktree", "add"]); + + if let Some(branch) = options.create_branch { + command.arg(if options.force_branch { "-B" } else { "-b" }); + command.arg(branch.branch_name()); + } + + if !options.checkout { + command.arg("--no-checkout"); + } + + if options.guess_remote { + command.arg("--guess-remote"); + } + + if options.track { + command.arg("--track"); + } + + command.arg(path.as_str()); + + if let Some(start_point) = options.start_point { + command.arg(start_point); + } + + command + } + + #[instrument(level = "trace")] + pub fn rename(&self, from: &Utf8Path, to: &Utf8Path) -> miette::Result<()> { + self.0 + .command() + .current_dir(from) + .args(["worktree", "move", from.as_str(), to.as_str()]) + .status_checked() + .into_diagnostic()?; + Ok(()) + } + + #[instrument(level = "trace")] + pub fn repair(&self) -> miette::Result<()> { + self.0 + .command() + .args(["worktree", "repair"]) + .status_checked() + .into_diagnostic()?; + Ok(()) + } + + /// 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 dirname_for<'b>(&self, branch: &'b str) -> &'b 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::dirname_for`] to the [`Self::container`]. + #[instrument(level = "trace")] + pub fn path_for(&self, branch: &str) -> miette::Result { + Ok(self + .container()? + .tap_mut(|p| p.push(self.dirname_for(branch)))) + } +} + +/// Options for `git worktree add`. +#[derive(Clone, Copy, Debug)] +pub struct AddWorktreeOpts<'a> { + /// If true, use `-B` instead of `-b` for `create_branch`. + /// Default false. + pub force_branch: bool, + /// Create a new branch. + pub create_branch: Option<&'a LocalBranchRef>, + /// If false, use `--no-checkout`. + /// Default true. + pub checkout: bool, + /// If true, use `--guess-remote`. + /// Default false. + pub guess_remote: bool, + /// If true, use `--track`. + /// Default false. + pub track: bool, + /// The start point for the new worktree. + pub start_point: Option<&'a str>, +} + +impl<'a> Default for AddWorktreeOpts<'a> { + fn default() -> Self { + Self { + force_branch: false, + create_branch: None, + checkout: true, + guess_remote: false, + track: false, + start_point: None, + } + } +} + +/// A set of Git worktrees. +/// +/// Exactly one of the worktrees is the main worktree. +#[derive(Debug, Clone, 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 main(&self) -> &Utf8Path { + &self.main + } + + pub fn parser(input: &mut &str) -> PResult { + let mut main = Worktree::parser.parse_next(input)?; + main.is_main = true; + let main_path = main.path.clone(); + + let mut inner: HashMap<_, _> = repeat_till( + 0.., + Worktree::parser.map(|worktree| (worktree.path.clone(), worktree)), + eof, + ) + .map(|(inner, _eof)| inner) + .parse_next(input)?; + + inner.insert(main_path.clone(), main); + + Ok(Self { + main: main_path, + inner, + }) + } +} + +impl FromStr for Worktrees { + type Err = miette::Report; + + fn from_str(input: &str) -> Result { + Self::parser.parse(input).map_err(|err| miette!("{err}")) + } +} + +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(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreeHead { + Bare, + Detached(CommitHash), + Branch(CommitHash, Ref), +} + +impl WorktreeHead { + pub fn commit(&self) -> Option<&CommitHash> { + match self { + WorktreeHead::Bare => None, + WorktreeHead::Detached(commit) => Some(commit), + WorktreeHead::Branch(commit, _branch) => Some(commit), + } + } + + pub fn parser(input: &mut &str) -> PResult { + alt(("bare\0".map(|_| Self::Bare), Self::parse_non_bare)).parse_next(input) + } + + fn parse_non_bare(input: &mut &str) -> PResult { + let _ = "HEAD ".parse_next(input)?; + let head = till_null.and_then(CommitHash::parser).parse_next(input)?; + let branch = alt((Self::parse_branch, "detached\0".map(|_| None))).parse_next(input)?; + + Ok(match branch { + Some(branch) => Self::Branch(head, branch), + None => Self::Detached(head), + }) + } + + fn parse_branch(input: &mut &str) -> PResult> { + let _ = "branch ".parse_next(input)?; + let ref_name = cut_err(till_null.and_then(Ref::parser)).parse_next(input)?; + + Ok(Some(ref_name)) + } +} + +impl Display for WorktreeHead { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WorktreeHead::Bare => write!( + f, + "{}", + "bare".if_supports_color(Stream::Stdout, |text| text.dimmed()) + ), + WorktreeHead::Detached(commit) => { + write!( + f, + "{}", + commit.if_supports_color(Stream::Stdout, |text| text.cyan()) + ) + } + WorktreeHead::Branch(_, ref_name) => { + write!( + f, + "{}", + ref_name.if_supports_color(Stream::Stdout, |text| text.cyan()) + ) + } + } + } +} + +/// A Git worktree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Worktree { + pub path: Utf8PathBuf, + pub head: WorktreeHead, + pub is_main: bool, + pub locked: Option, + pub prunable: Option, +} + +impl Display for Worktree { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let path = NormalPath::from_cwd(&self.path) + .map(|path| path.to_string()) + .unwrap_or_else(|_| { + self.path + .if_supports_color(Stream::Stdout, |text| text.cyan()) + .to_string() + }); + write!(f, "{path} {}", self.head)?; + + if self.is_main { + write!( + f, + " [{}]", + "main".if_supports_color(Stream::Stdout, |text| text.cyan()) + )?; + } + + if let Some(reason) = &self.locked { + if reason.is_empty() { + write!(f, " (locked)")?; + } else { + write!(f, " (locked: {reason})")?; + } + } + + if let Some(reason) = &self.prunable { + if reason.is_empty() { + write!(f, " (prunable)")?; + } else { + write!(f, " (prunable: {reason})")?; + } + } + + Ok(()) + } +} + +impl Worktree { + pub fn parser(input: &mut &str) -> PResult { + let _ = "worktree ".parse_next(input)?; + let path = Utf8PathBuf::from(till_null.parse_next(input)?); + let head = WorktreeHead::parser.parse_next(input)?; + let locked = opt(Self::parse_locked).parse_next(input)?; + let prunable = opt(Self::parse_prunable).parse_next(input)?; + let _ = '\0'.parse_next(input)?; + + Ok(Self { + path, + head, + locked, + prunable, + is_main: false, + }) + } + + fn parse_locked(input: &mut &str) -> PResult { + let _ = "locked".parse_next(input)?; + let reason = Self::parse_reason.parse_next(input)?; + + Ok(reason) + } + + fn parse_prunable(input: &mut &str) -> PResult { + let _ = "prunable".parse_next(input)?; + let reason = Self::parse_reason.parse_next(input)?; + + Ok(reason) + } + + fn parse_reason(input: &mut &str) -> PResult { + let maybe_space = opt(' ').parse_next(input)?; + + match maybe_space { + None => { + let _ = '\0'.parse_next(input)?; + Ok(String::new()) + } + Some(_) => { + let reason = till_null.parse_next(input)?; + Ok(reason.into()) + } + } + } +} + +#[cfg(test)] +mod tests { + use indoc::indoc; + use itertools::Itertools; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_parse_worktrees_list() { + let worktrees = Worktrees::from_str( + &indoc!( + " + worktree /path/to/bare-source + bare + + worktree /Users/wiggles/cabal/accept + HEAD 0685cb3fec8b7144f865638cfd16768e15125fc2 + branch refs/heads/rebeccat/fix-accept-flag + + worktree /Users/wiggles/lix + HEAD 0d484aa498b3c839991d11afb31bc5fcf368493d + detached + + worktree /path/to/linked-worktree-locked-no-reason + HEAD 5678abc5678abc5678abc5678abc5678abc5678c + branch refs/heads/locked-no-reason + locked + + worktree /path/to/linked-worktree-locked-with-reason + HEAD 3456def3456def3456def3456def3456def3456b + branch refs/heads/locked-with-reason + locked reason why is locked + + worktree /path/to/linked-worktree-prunable + HEAD 1233def1234def1234def1234def1234def1234b + detached + prunable gitdir file points to non-existent location + + " + ) + .replace('\n', "\0"), + ) + .unwrap(); + + assert_eq!(worktrees.main(), "/path/to/bare-source"); + + let worktrees = worktrees + .inner + .into_values() + .sorted_by_key(|worktree| worktree.path.to_owned()) + .collect::>(); + + assert_eq!( + worktrees, + vec![ + Worktree { + path: "/Users/wiggles/cabal/accept".into(), + head: WorktreeHead::Branch( + CommitHash::from("0685cb3fec8b7144f865638cfd16768e15125fc2"), + Ref::from_str("refs/heads/rebeccat/fix-accept-flag").unwrap(), + ), + is_main: false, + locked: None, + prunable: None, + }, + Worktree { + path: "/Users/wiggles/lix".into(), + head: WorktreeHead::Detached(CommitHash::from( + "0d484aa498b3c839991d11afb31bc5fcf368493d" + )), + is_main: false, + locked: None, + prunable: None, + }, + Worktree { + path: "/path/to/bare-source".into(), + head: WorktreeHead::Bare, + is_main: true, + locked: None, + prunable: None, + }, + Worktree { + path: "/path/to/linked-worktree-locked-no-reason".into(), + head: WorktreeHead::Branch( + CommitHash::from("5678abc5678abc5678abc5678abc5678abc5678c"), + Ref::from_str("refs/heads/locked-no-reason").unwrap() + ), + is_main: false, + locked: Some("".into()), + prunable: None, + }, + Worktree { + path: "/path/to/linked-worktree-locked-with-reason".into(), + head: WorktreeHead::Branch( + CommitHash::from("3456def3456def3456def3456def3456def3456b"), + Ref::from_str("refs/heads/locked-with-reason").unwrap() + ), + is_main: false, + locked: Some("reason why is locked".into()), + prunable: None, + }, + Worktree { + path: "/path/to/linked-worktree-prunable".into(), + head: WorktreeHead::Detached(CommitHash::from( + "1233def1234def1234def1234def1234def1234b" + ),), + is_main: false, + locked: None, + prunable: Some("gitdir file points to non-existent location".into()), + }, + ] + ); + } +} 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/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6e5f20b --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,54 @@ +//! `git-prole` is a `git worktree` manager. +//! +//! The `git-prole` Rust library is a convenience and shouldn't be depended on. I do not +//! consider this to be a public/stable API and will make breaking changes here in minor version +//! bumps. If you'd like a stable `git-prole` Rust API for some reason, let me know and we can maybe +//! work something out. + +mod add; +mod app; +mod app_git; +mod cli; +mod clone; +mod config; +mod convert; +mod copy_dir; +mod current_dir; +mod format_bulleted_list; +mod gh; +mod git; +mod install_tracing; +mod normal_path; +mod parse; +mod topological_sort; +mod utf8tempdir; + +pub use app::App; +pub use app_git::AppGit; +pub use config::Config; +pub use format_bulleted_list::format_bulleted_list; +pub use git::repository_url_destination; +pub use git::AddWorktreeOpts; +pub use git::BranchRef; +pub use git::CommitHash; +pub use git::Git; +pub use git::GitBranch; +pub use git::GitConfig; +pub use git::GitPath; +pub use git::GitRefs; +pub use git::GitRemote; +pub use git::GitStatus; +pub use git::GitWorktree; +pub use git::HeadKind; +pub use git::LocalBranchRef; +pub use git::Ref; +pub use git::RemoteBranchRef; +pub use git::ResolvedCommitish; +pub use git::Status; +pub use git::StatusCode; +pub use git::StatusEntry; +pub use git::Worktree; +pub use git::WorktreeHead; +pub use git::Worktrees; +pub use normal_path::NormalPath; +pub use utf8tempdir::Utf8TempDir; diff --git a/src/main.rs b/src/main.rs index c9610b9..291e02c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,41 +1,7 @@ -mod cli; -mod commit_hash; -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; +use git_prole::App; +use git_prole::Config; 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()?; + 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/parse/mod.rs b/src/parse/mod.rs new file mode 100644 index 0000000..ea7160e --- /dev/null +++ b/src/parse/mod.rs @@ -0,0 +1,5 @@ +//! Parsing utilities. + +mod null; + +pub use null::till_null; diff --git a/src/parse/null.rs b/src/parse/null.rs new file mode 100644 index 0000000..86f90c5 --- /dev/null +++ b/src/parse/null.rs @@ -0,0 +1,18 @@ +use winnow::stream::AsChar; +use winnow::stream::Compare; +use winnow::stream::Stream; +use winnow::stream::StreamIsPartial; +use winnow::token::take_till; +use winnow::PResult; +use winnow::Parser; + +pub fn till_null(input: &mut I) -> PResult<::Slice> +where + I: Stream + StreamIsPartial + Compare, + ::Token: AsChar, + ::Token: AsChar, +{ + let ret = take_till(1.., '\0').parse_next(input)?; + let _ = '\0'.parse_next(input)?; + Ok(ret) +} 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..082c46b --- /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(crate) 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() + } +} diff --git a/test-harness/Cargo.toml b/test-harness/Cargo.toml new file mode 100644 index 0000000..9a03e99 --- /dev/null +++ b/test-harness/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "test-harness" +version = "0.1.0" +edition = "2021" +description = "Test harness for git-prole" +publish = false + +[dependencies] +camino = "1.1.9" +clonable-command = "0.2.0" +command-error = "0.4.1" +itertools = "0.13.0" +miette = { version = "*", default-features = false, features = ["fancy-no-backtrace"] } +regex = "*" +tempfile = "3.13.0" +test_bin = "*" +tracing = "*" +utf8-command = "1.0.1" +git-prole = { path = "../" } +fs-err = "2.11.0" +shell-words = "1.1.0" +pretty_assertions = "1.4.1" +expect-test = "1.5.0" + +# See: https://github.com/crate-ci/cargo-release/blob/master/docs/reference.md +[package.metadata.release] +release = false diff --git a/test-harness/src/helpers.rs b/test-harness/src/helpers.rs new file mode 100644 index 0000000..b86e8e0 --- /dev/null +++ b/test-harness/src/helpers.rs @@ -0,0 +1,38 @@ +use crate::GitProle; +use camino::Utf8Path; +use miette::miette; + +/// Set up a remote in `remote_path` with multiple other remotes as its siblings, and clone that +/// remote to `repo`. +pub fn setup_repo_multiple_remotes( + prole: &GitProle, + remote_path: &str, + repo: &str, +) -> miette::Result<()> { + prole.setup_repo(remote_path)?; + + let basename = Utf8Path::new(remote_path) + .file_name() + .ok_or_else(|| miette!("Remote has no basename: {remote_path}"))?; + + prole.sh(&format!( + r#" + for repo in a b c; do + pushd "{remote_path}/.." || exit + cp -r "{basename}" "$repo" + pushd "$repo" || exit + git switch -c "$repo" + git branch -D main + popd || exit + popd || exit + done + git clone "{remote_path}" "{repo}" + cd "{repo}" || exit + git remote add a ../my-remotes/a + git remote add b ../my-remotes/b + git remote add c ../my-remotes/c + "# + ))?; + + Ok(()) +} diff --git a/test-harness/src/lib.rs b/test-harness/src/lib.rs new file mode 100644 index 0000000..1629cbc --- /dev/null +++ b/test-harness/src/lib.rs @@ -0,0 +1,226 @@ +use std::ffi::OsString; +use std::process::Command; + +use camino::Utf8PathBuf; +use clonable_command::Command as ClonableCommand; +use command_error::CommandExt; +use expect_test::Expect; +use fs_err as fs; +use git_prole::format_bulleted_list; +use git_prole::Git; +use git_prole::Utf8TempDir; +use itertools::Itertools; +use miette::miette; +use miette::Context; +use miette::IntoDiagnostic; + +mod helpers; + +pub use helpers::*; + +/// `git-prole` session for integration testing. +pub struct GitProle { + command: ClonableCommand, + tempdir: Utf8TempDir, + git_prole: OsString, + git_prole_args: Vec, +} + +impl GitProle { + pub fn new() -> miette::Result { + let tempdir = Utf8TempDir::new()?; + + let gitconfig = tempdir.join(".gitconfig"); + fs::write( + &gitconfig, + "[user]\n\ + name = Puppy Doggy\n\ + email = dog@becca.ooo\n\ + \n\ + [init]\n\ + defaultBranch = main\n\ + ", + ) + .into_diagnostic()?; + + let git_prole = test_bin::get_test_bin("git-prole").get_program().to_owned(); + + let log_filters = ["debug", "git_prole=debug", "git_prole::git=trace"] + .into_iter() + .join(","); + + let git_prole_args = vec!["--log".to_owned(), log_filters]; + + let command = ClonableCommand::new("") + .envs([ + // > Whether to skip reading settings from the system-wide $(prefix)/etc/gitconfig file. + ("GIT_CONFIG_NOSYSTEM", "1"), + ("GIT_CONFIG_GLOBAL", gitconfig.as_str()), + ("GIT_AUTHOR_DATE", "2019-07-06T18:25:00-0700"), + ("GIT_COMMITTER_DATE", "2019-07-06T18:25:00-0700"), + ("HOME", tempdir.as_str()), + ]) + .current_dir(&tempdir); + + Ok(Self { + git_prole, + git_prole_args, + command, + tempdir, + }) + } + + fn any_command(&self, program: &str) -> Command { + let mut command = self.command.clone(); + command.name = program.into(); + command.to_std() + } + + pub fn cmd(&self) -> Command { + let mut command = self.command.clone(); + command.name = self.git_prole.clone(); + command = command.args(&self.git_prole_args); + command.to_std() + } + + #[track_caller] + pub fn cd_cmd(&self, current_dir: &str) -> Command { + let path = self.path(current_dir); + if !path.exists() { + panic!("A test requested a command to run in a nonexistent path: {current_dir}"); + } + let mut command = self.cmd(); + command.current_dir(self.path(current_dir)); + command + } + + pub fn path(&self, tail: &str) -> Utf8PathBuf { + self.tempdir.join(tail) + } + + pub fn exists(&self, path: &str) -> bool { + self.path(path).exists() + } + + pub fn contents(&self, path: &str) -> miette::Result { + fs::read_to_string(self.path(path)).into_diagnostic() + } + + #[track_caller] + pub fn assert_exists(&self, paths: &[&str]) { + let mut missing = Vec::new(); + for path in paths { + if !self.exists(path) { + missing.push(path); + } + } + + if !missing.is_empty() { + panic!( + "{:?}", + miette!("Paths are missing:\n{}", format_bulleted_list(missing)) + ) + } + } + + #[track_caller] + pub fn assert_contents(&self, contents: &[(&str, Expect)]) { + for (path, expect) in contents { + let actual = self.contents(path).unwrap(); + expect.assert_eq(&actual); + } + } + + pub fn sh(&self, script: &str) -> miette::Result<()> { + let tempfile = tempfile::NamedTempFile::new().into_diagnostic()?; + fs::write( + &tempfile, + format!( + "set -ex\n\ + {script}" + ), + ) + .into_diagnostic()?; + self.any_command("bash") + .arg("--norc") + .arg(tempfile.as_ref()) + .status_checked() + .into_diagnostic()?; + Ok(()) + } + + #[track_caller] + pub fn git(&self, directory: &str) -> Git { + let path = self.path(directory); + if !path.exists() { + panic!("A test requested a Git interface for a nonexistent path: {directory}"); + } + let mut git = Git::from_path(self.path(directory)); + git.envs(self.command.environment.iter().filter_map(|(key, value)| { + value.as_ref().map(|value| { + ( + key.to_owned().into_string().unwrap(), + value.to_owned().into_string().unwrap(), + ) + }) + })); + git + } + + pub fn current_branch_in(&self, directory: &str) -> miette::Result { + Ok(self + .git(directory) + .branch() + .current()? + .ok_or_else(|| miette!("HEAD is detached in {directory}"))? + .branch_name() + .to_owned()) + } + + pub fn upstream_for_branch_in(&self, directory: &str, branch: &str) -> miette::Result { + Ok(self + .git(directory) + .branch() + .upstream(branch)? + .ok_or_else(|| miette!("Branch {branch} has no upstream in {directory}"))? + .qualified_branch_name() + .to_owned()) + } + + /// Set up a new repository in `path` with a single commit. + pub fn setup_repo(&self, path: &str) -> miette::Result { + let path = self.path(path); + let path_quoted = shell_words::quote(path.as_str()); + self.sh(&format!( + r#" + mkdir -p {path_quoted} + cd {path_quoted} || exit + git init + echo "puppy doggy" > README.md + git add . + git commit -m "Initial commit" + "# + ))?; + Ok(path) + } + + pub fn setup_worktree_repo(&self, path: &str) -> miette::Result<()> { + self.setup_repo(path)?; + self.cmd() + .current_dir(self.path(path)) + .arg("convert") + .output_checked_utf8() + .into_diagnostic() + .wrap_err_with(|| format!("Failed to convert {path} to a worktree checkout"))?; + + Ok(()) + } + + pub fn write_config(&self, contents: &str) -> miette::Result<()> { + fs::create_dir_all(self.path(".config/git-prole")).into_diagnostic()?; + fs::write(self.path(".config/git-prole/config.toml"), contents) + .into_diagnostic() + .wrap_err("Failed to write `git-prole` configuration")?; + Ok(()) + } +} diff --git a/tests/add_branch_and_name.rs b/tests/add_branch_and_name.rs new file mode 100644 index 0000000..08aea02 --- /dev/null +++ b/tests/add_branch_and_name.rs @@ -0,0 +1,32 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_branch_and_name() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + + prole + .cd_cmd("my-repo/main") + .args(["add", "-c", "doggy", "puppy"]) + .status_checked() + .unwrap(); + + prole.assert_contents(&[( + "my-repo/puppy/README.md", + expect![[r#" + puppy doggy + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/puppy").unwrap(), "doggy"); + + assert_eq!( + prole + .upstream_for_branch_in("my-repo/puppy", "doggy") + .unwrap(), + "main" + ); +} diff --git a/tests/add_branch_and_path.rs b/tests/add_branch_and_path.rs new file mode 100644 index 0000000..5db28ba --- /dev/null +++ b/tests/add_branch_and_path.rs @@ -0,0 +1,32 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_branch_and_path() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + + prole + .cd_cmd("my-repo/main") + .args(["add", "-c", "doggy", "../puppy"]) + .status_checked() + .unwrap(); + + prole.assert_contents(&[( + "my-repo/puppy/README.md", + expect![[r#" + puppy doggy + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/puppy").unwrap(), "doggy"); + + assert_eq!( + prole + .upstream_for_branch_in("my-repo/puppy", "doggy") + .unwrap(), + "main" + ); +} diff --git a/tests/add_branch_force.rs b/tests/add_branch_force.rs new file mode 100644 index 0000000..aa3f65b --- /dev/null +++ b/tests/add_branch_force.rs @@ -0,0 +1,44 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_branch_force() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + + prole + .sh(" + cd my-repo/main || exit + git switch -c puppy + echo 'softy pup' > README.md + git commit -am 'cooler readme' + git switch main + ") + .unwrap(); + + // `-b` fails; the branch already exists. + prole + .cd_cmd("my-repo/main") + .args(["add", "-b", "puppy"]) + .status_checked() + .unwrap_err(); + + // -B works though! + prole + .cd_cmd("my-repo/main") + .args(["add", "-B", "puppy"]) + .status_checked() + .unwrap(); + + // Branch is reset, so we don't see the updated readme. + prole.assert_contents(&[( + "my-repo/puppy/README.md", + expect![[r#" + puppy doggy + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/puppy").unwrap(), "puppy"); +} diff --git a/tests/add_branch_new_local.rs b/tests/add_branch_new_local.rs new file mode 100644 index 0000000..cc8723c --- /dev/null +++ b/tests/add_branch_new_local.rs @@ -0,0 +1,24 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_branch_new_local() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + prole + .cd_cmd("my-repo/main") + .args(["add", "-b", "puppy"]) + .status_checked() + .unwrap(); + + prole.assert_contents(&[( + "my-repo/puppy/README.md", + expect![[r#" + puppy doggy + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/puppy").unwrap(), "puppy"); +} diff --git a/tests/add_branch_start_point_existing_local.rs b/tests/add_branch_start_point_existing_local.rs new file mode 100644 index 0000000..c4b17d0 --- /dev/null +++ b/tests/add_branch_start_point_existing_local.rs @@ -0,0 +1,43 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_branch_start_point_existing_local() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + + // Create a new branch and commit to base our new worktree off of. + prole + .sh(" + cd my-repo/main || exit + git switch -c doggy + echo 'cutie puppy' > README.md + git commit -am 'Cooler README' + git switch main + ") + .unwrap(); + + prole + .cd_cmd("my-repo/main") + .args(["add", "-b", "softy", "puppy", "doggy"]) + .status_checked() + .unwrap(); + + prole.assert_contents(&[( + "my-repo/puppy/README.md", + expect![[r#" + cutie puppy + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/puppy").unwrap(), "softy"); + + assert_eq!( + prole + .upstream_for_branch_in("my-repo/puppy", "softy") + .unwrap(), + "doggy" + ); +} diff --git a/tests/add_branch_start_point_existing_remote.rs b/tests/add_branch_start_point_existing_remote.rs new file mode 100644 index 0000000..c39e676 --- /dev/null +++ b/tests/add_branch_start_point_existing_remote.rs @@ -0,0 +1,50 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_branch_start_point_existing_remote() { + let prole = GitProle::new().unwrap(); + prole.setup_repo("my-remote/my-repo").unwrap(); + // Set up a `puppy` branch in the remote. + prole + .sh(" + cd my-remote/my-repo || exit + git switch -c puppy + echo 'softy pup' > README.md + git commit -am 'cooler readme' + git switch main + ") + .unwrap(); + + prole + .cmd() + .args(["clone", "my-remote/my-repo"]) + .status_checked() + .unwrap(); + + prole + .cd_cmd("my-repo/main") + .args(["add", "-b", "softie", "doggy", "puppy"]) + .status_checked() + .unwrap(); + + // We get a checkout for the remote-tracking branch! + prole.assert_contents(&[( + "my-repo/doggy/README.md", + expect![[r#" + softy pup + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/doggy").unwrap(), "softie"); + + // We're tracking the remote branch we expect. + assert_eq!( + prole + .upstream_for_branch_in("my-repo/doggy", "softie") + .unwrap(), + "origin/puppy" + ); +} diff --git a/tests/add_branch_start_point_new_local.rs b/tests/add_branch_start_point_new_local.rs new file mode 100644 index 0000000..843ddba --- /dev/null +++ b/tests/add_branch_start_point_new_local.rs @@ -0,0 +1,34 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_branch_start_point_new_local() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + + prole + .sh(" + cd my-repo/main || exit + git switch -c puppy + echo 'soft cutie' > README.md + git commit -am 'Cooler readme' + ") + .unwrap(); + + prole + .cd_cmd("my-repo/main") + .args(["add", "-c", "softy", "doggy", "@"]) + .status_checked() + .unwrap(); + + prole.assert_contents(&[( + "my-repo/doggy/README.md", + expect![[r#" + soft cutie + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/doggy").unwrap(), "softy"); +} diff --git a/tests/add_by_name_existing_local.rs b/tests/add_by_name_existing_local.rs new file mode 100644 index 0000000..c95634d --- /dev/null +++ b/tests/add_by_name_existing_local.rs @@ -0,0 +1,37 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_by_name_existing_local() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + + // Set up an existing `puppy` branch. + prole + .sh(" + cd my-repo/main || exit + git switch -c puppy + echo 'softy pup' > README.md + git commit -am 'cooler readme' + git switch main + ") + .unwrap(); + + prole + .cd_cmd("my-repo/main") + .args(["add", "puppy"]) + .status_checked() + .unwrap(); + + // We get a checkout for the existing branch. + prole.assert_contents(&[( + "my-repo/puppy/README.md", + expect![[r#" + softy pup + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/puppy").unwrap(), "puppy"); +} diff --git a/tests/add_by_name_existing_remote.rs b/tests/add_by_name_existing_remote.rs new file mode 100644 index 0000000..1274dad --- /dev/null +++ b/tests/add_by_name_existing_remote.rs @@ -0,0 +1,50 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_by_name_existing_remote() { + let prole = GitProle::new().unwrap(); + prole.setup_repo("my-remote/my-repo").unwrap(); + // Set up a `puppy` branch in the remote. + prole + .sh(" + cd my-remote/my-repo || exit + git switch -c puppy + echo 'softy pup' > README.md + git commit -am 'cooler readme' + git switch main + ") + .unwrap(); + + prole + .cmd() + .args(["clone", "my-remote/my-repo"]) + .status_checked() + .unwrap(); + + prole + .cd_cmd("my-repo/main") + .args(["add", "puppy"]) + .status_checked() + .unwrap(); + + // We get a checkout for the remote-tracking branch! + prole.assert_contents(&[( + "my-repo/puppy/README.md", + expect![[r#" + softy pup + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/puppy").unwrap(), "puppy"); + + // We're tracking the remote branch we expect. + assert_eq!( + prole + .upstream_for_branch_in("my-repo/puppy", "puppy") + .unwrap(), + "origin/puppy" + ); +} diff --git a/tests/add_by_name_new_local.rs b/tests/add_by_name_new_local.rs new file mode 100644 index 0000000..247fbf0 --- /dev/null +++ b/tests/add_by_name_new_local.rs @@ -0,0 +1,43 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_by_name_new_local() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + + // Create a new branch and commit to show that when `git prole add` creates a branch, it's + // based on the default branch by default. + prole + .sh(" + cd my-repo/main || exit + git switch -c doggy + echo 'cutie puppy' > README.md + git commit -am 'Cooler README' + ") + .unwrap(); + + prole + .cd_cmd("my-repo/main") + .args(["add", "puppy"]) + .status_checked() + .unwrap(); + + prole.assert_contents(&[( + "my-repo/puppy/README.md", + expect![[r#" + puppy doggy + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/puppy").unwrap(), "puppy"); + + assert_eq!( + prole + .upstream_for_branch_in("my-repo/puppy", "puppy") + .unwrap(), + "main" + ); +} diff --git a/tests/add_by_path.rs b/tests/add_by_path.rs new file mode 100644 index 0000000..59ab93f --- /dev/null +++ b/tests/add_by_path.rs @@ -0,0 +1,32 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_by_path() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + + prole + .cd_cmd("my-repo/main") + // Weird But Okay + .args(["add", "../../puppy"]) + .status_checked() + .unwrap(); + + prole.assert_contents(&[( + "puppy/README.md", + expect![[r#" + puppy doggy + "#]], + )]); + + // Last component of the path becomes the branch name. + assert_eq!(prole.current_branch_in("puppy").unwrap(), "puppy"); + + assert_eq!( + prole.upstream_for_branch_in("puppy", "puppy").unwrap(), + "main" + ); +} diff --git a/tests/add_by_path_existing_branch.rs b/tests/add_by_path_existing_branch.rs new file mode 100644 index 0000000..1fa00bf --- /dev/null +++ b/tests/add_by_path_existing_branch.rs @@ -0,0 +1,43 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_by_path_existing_branch() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + + // Set up an existing `puppy` branch. + prole + .sh(" + cd my-repo/main || exit + git switch -t -c puppy + echo 'softy pup' > README.md + git commit -am 'cooler readme' + git switch main + ") + .unwrap(); + + prole + .cd_cmd("my-repo/main") + // Weird But Okay + .args(["add", "../../puppy"]) + .status_checked() + .unwrap(); + + prole.assert_contents(&[( + "puppy/README.md", + expect![[r#" + softy pup + "#]], + )]); + + // Last component of the path becomes the branch name. + assert_eq!(prole.current_branch_in("puppy").unwrap(), "puppy"); + + assert_eq!( + prole.upstream_for_branch_in("puppy", "puppy").unwrap(), + "main" + ); +} diff --git a/tests/add_start_point_existing_local.rs b/tests/add_start_point_existing_local.rs new file mode 100644 index 0000000..bbb0a30 --- /dev/null +++ b/tests/add_start_point_existing_local.rs @@ -0,0 +1,36 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_start_point_existing_local() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + + // Create a new branch and commit to base our new worktree off of. + prole + .sh(" + cd my-repo/main || exit + git switch -c doggy + echo 'cutie puppy' > README.md + git commit -am 'Cooler README' + git switch main + ") + .unwrap(); + + prole + .cd_cmd("my-repo/main") + .args(["add", "puppy", "doggy"]) + .status_checked() + .unwrap(); + + prole.assert_contents(&[( + "my-repo/puppy/README.md", + expect![[r#" + cutie puppy + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/puppy").unwrap(), "doggy"); +} diff --git a/tests/add_start_point_existing_remote.rs b/tests/add_start_point_existing_remote.rs new file mode 100644 index 0000000..f968fd2 --- /dev/null +++ b/tests/add_start_point_existing_remote.rs @@ -0,0 +1,50 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_start_point_existing_remote() { + let prole = GitProle::new().unwrap(); + prole.setup_repo("my-remote/my-repo").unwrap(); + // Set up a `puppy` branch in the remote. + prole + .sh(" + cd my-remote/my-repo || exit + git switch -c puppy + echo 'softy pup' > README.md + git commit -am 'cooler readme' + git switch main + ") + .unwrap(); + + prole + .cmd() + .args(["clone", "my-remote/my-repo"]) + .status_checked() + .unwrap(); + + prole + .cd_cmd("my-repo/main") + .args(["add", "doggy", "puppy"]) + .status_checked() + .unwrap(); + + // We get a checkout for the remote-tracking branch! + prole.assert_contents(&[( + "my-repo/doggy/README.md", + expect![[r#" + softy pup + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/doggy").unwrap(), "puppy"); + + // We're tracking the remote branch we expect. + assert_eq!( + prole + .upstream_for_branch_in("my-repo/doggy", "puppy") + .unwrap(), + "origin/puppy" + ); +} diff --git a/tests/add_start_point_new_local.rs b/tests/add_start_point_new_local.rs new file mode 100644 index 0000000..043d3dc --- /dev/null +++ b/tests/add_start_point_new_local.rs @@ -0,0 +1,34 @@ +use command_error::CommandExt; +use expect_test::expect; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn add_start_point_new_local() { + let prole = GitProle::new().unwrap(); + prole.setup_worktree_repo("my-repo").unwrap(); + + prole + .sh(" + cd my-repo/main || exit + git switch -c puppy + echo 'soft cutie' > README.md + git commit -am 'Cooler readme' + ") + .unwrap(); + + prole + .cd_cmd("my-repo/main") + .args(["add", "doggy", "@"]) + .status_checked() + .unwrap(); + + prole.assert_contents(&[( + "my-repo/doggy/README.md", + expect![[r#" + soft cutie + "#]], + )]); + + assert_eq!(prole.current_branch_in("my-repo/doggy").unwrap(), "doggy"); +} diff --git a/tests/clone_simple.rs b/tests/clone_simple.rs new file mode 100644 index 0000000..1c3752d --- /dev/null +++ b/tests/clone_simple.rs @@ -0,0 +1,38 @@ +use command_error::CommandExt; +use expect_test::expect; +use test_harness::GitProle; + +#[test] +fn clone_simple() { + let prole = GitProle::new().unwrap(); + prole.setup_repo("remote/my-repo").unwrap(); + prole + .cmd() + .args(["clone", "remote/my-repo"]) + .status_checked() + .unwrap(); + + prole.assert_exists(&[ + "my-repo", + "my-repo/.git", + "my-repo/main", + "my-repo/main/README.md", + ]); + + assert_eq!( + prole + .git("my-repo/.git") + .config() + .get("core.bare") + .unwrap() + .unwrap(), + "true" + ); + + prole.assert_contents(&[( + "my-repo/main/README.md", + expect![[r#" + puppy doggy + "#]], + )]); +} diff --git a/tests/config_copy_untracked.rs b/tests/config_copy_untracked.rs new file mode 100644 index 0000000..97cb51e --- /dev/null +++ b/tests/config_copy_untracked.rs @@ -0,0 +1,43 @@ +use command_error::CommandExt; +use expect_test::expect; +use miette::IntoDiagnostic; +use test_harness::GitProle; + +#[test] +fn config_copy_untracked() -> miette::Result<()> { + let prole = GitProle::new()?; + + prole.setup_worktree_repo("my-repo")?; + + prole.write_config( + " + copy_untracked = false + ", + )?; + + prole.sh(" + cd my-repo/main || exit + echo 'puppy doggy' > animal-facts.txt + ")?; + + prole + .cd_cmd("my-repo/main") + .args(["add", "puppy"]) + .status_checked() + .into_diagnostic()?; + + // The untracked file is not copied to the new worktree. + assert!(!prole + .path("my-repo/puppy/animal-facts.txt") + .try_exists() + .unwrap()); + + prole.assert_contents(&[( + "my-repo/main/animal-facts.txt", + expect![[r#" + puppy doggy + "#]], + )]); + + Ok(()) +} diff --git a/tests/config_copy_untracked_default.rs b/tests/config_copy_untracked_default.rs new file mode 100644 index 0000000..906611a --- /dev/null +++ b/tests/config_copy_untracked_default.rs @@ -0,0 +1,40 @@ +use command_error::CommandExt; +use expect_test::expect; +use miette::IntoDiagnostic; +use test_harness::GitProle; + +#[test] +fn config_copy_untracked_default() -> miette::Result<()> { + let prole = GitProle::new()?; + + prole.setup_worktree_repo("my-repo")?; + + prole.sh(" + cd my-repo/main || exit + echo 'puppy doggy' > animal-facts.txt + ")?; + + prole + .cd_cmd("my-repo/main") + .args(["add", "puppy"]) + .status_checked() + .into_diagnostic()?; + + // The untracked file is copied to the new worktree. + prole.assert_contents(&[ + ( + "my-repo/main/animal-facts.txt", + expect![[r#" + puppy doggy + "#]], + ), + ( + "my-repo/puppy/animal-facts.txt", + expect![[r#" + puppy doggy + "#]], + ), + ]); + + Ok(()) +} diff --git a/tests/config_default_branches.rs b/tests/config_default_branches.rs new file mode 100644 index 0000000..bbec387 --- /dev/null +++ b/tests/config_default_branches.rs @@ -0,0 +1,54 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::GitProle; + +#[test] +fn config_default_branches() -> miette::Result<()> { + let prole = GitProle::new()?; + + prole.setup_repo("my-remotes/my-repo")?; + + prole.sh(" + pushd my-remotes/my-repo || exit + git switch -c master + git switch -c trunk + git branch -D main + git switch -c puppy + popd + + git clone my-remotes/my-repo + cd my-repo || exit + git remote rename origin elephant + ")?; + + prole.write_config( + r#" + default_branches = [ + "doggy", + "trunk", + ] + "#, + )?; + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + // We can't find a default remote, so we look for a default branch. + assert_eq!(prole.current_branch_in("my-repo/trunk")?, "trunk"); + assert_eq!( + prole.upstream_for_branch_in("my-repo/trunk", "trunk")?, + "elephant/trunk" + ); + // We also get a checkout for the default HEAD on the remote when we clone, so that + // sticks around. + assert_eq!(prole.current_branch_in("my-repo/puppy")?, "puppy"); + assert_eq!( + prole.upstream_for_branch_in("my-repo/puppy", "puppy")?, + "elephant/puppy" + ); + + Ok(()) +} diff --git a/tests/config_default_branches_default.rs b/tests/config_default_branches_default.rs new file mode 100644 index 0000000..ab67aab --- /dev/null +++ b/tests/config_default_branches_default.rs @@ -0,0 +1,48 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::GitProle; + +#[test] +fn config_default_branches_default() -> miette::Result<()> { + let prole = GitProle::new()?; + + prole.setup_repo("my-remotes/my-repo")?; + + prole.sh(" + pushd my-remotes/my-repo || exit + git switch -c master + git switch -c trunk + git branch -D main + git switch -c puppy + popd + + git clone my-remotes/my-repo + cd my-repo || exit + git remote rename origin puppy + ")?; + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + // We can't find a default remote, so we look for a default branch. We pull up `master` + // because that's listed after `main`. + // + // Note: We can find a `master` branch on a remote even if it doesn't exist locally! + assert_eq!(prole.current_branch_in("my-repo/master")?, "master"); + assert_eq!( + prole.upstream_for_branch_in("my-repo/master", "master")?, + "puppy/master" + ); + // But we also get a checkout for the default HEAD on the remote when we clone, so that + // sticks around. + assert_eq!(prole.current_branch_in("my-repo/puppy")?, "puppy"); + assert_eq!( + prole.upstream_for_branch_in("my-repo/puppy", "puppy")?, + "puppy/puppy" + ); + + Ok(()) +} diff --git a/tests/config_remotes.rs b/tests/config_remotes.rs new file mode 100644 index 0000000..30f1c38 --- /dev/null +++ b/tests/config_remotes.rs @@ -0,0 +1,31 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::setup_repo_multiple_remotes; +use test_harness::GitProle; + +#[test] +fn config_remotes() -> miette::Result<()> { + let prole = GitProle::new()?; + + setup_repo_multiple_remotes(&prole, "my-remotes/my-repo", "my-repo")?; + + prole.write_config( + r#" + remotes = [ + "a" + ] + "#, + )?; + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + assert_eq!(prole.current_branch_in("my-repo/main")?, "main"); + assert_eq!(prole.current_branch_in("my-repo/a")?, "a"); + assert_eq!(prole.upstream_for_branch_in("my-repo/a", "a")?, "a/a"); + + Ok(()) +} diff --git a/tests/config_remotes_default.rs b/tests/config_remotes_default.rs new file mode 100644 index 0000000..a9b921c --- /dev/null +++ b/tests/config_remotes_default.rs @@ -0,0 +1,33 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::setup_repo_multiple_remotes; +use test_harness::GitProle; + +#[test] +fn convert_multiple_remotes() -> miette::Result<()> { + let prole = GitProle::new()?; + setup_repo_multiple_remotes(&prole, "my-remotes/my-repo", "my-repo")?; + + prole.sh(" + cd my-repo || exit + git remote rename a upstream + ")?; + + // Okay, this leaves us with remotes `origin`, `upstream`, `b`, and `c`. + // + // The default config says `upstream` is more important than `origin`, so we use that! + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + assert_eq!(prole.current_branch_in("my-repo/a")?, "a"); + assert_eq!( + prole.upstream_for_branch_in("my-repo/a", "a")?, + "upstream/a" + ); + + Ok(()) +} diff --git a/tests/convert_default_branch_checked_out.rs b/tests/convert_default_branch_checked_out.rs new file mode 100644 index 0000000..4a27fef --- /dev/null +++ b/tests/convert_default_branch_checked_out.rs @@ -0,0 +1,35 @@ +use command_error::CommandExt; +use expect_test::expect; +use miette::IntoDiagnostic; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn convert_default_branch_checked_out() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("my-repo")?; + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole.assert_contents(&[( + "my-repo/main/README.md", + expect![[r#" + puppy doggy + "#]], + )]); + + assert_eq!( + prole + .git("my-repo/.git") + .config() + .get("core.bare")? + .unwrap(), + "true" + ); + + Ok(()) +} diff --git a/tests/convert_detached_head.rs b/tests/convert_detached_head.rs new file mode 100644 index 0000000..91237c2 --- /dev/null +++ b/tests/convert_detached_head.rs @@ -0,0 +1,49 @@ +use command_error::CommandExt; +use expect_test::expect; +use git_prole::HeadKind; +use miette::IntoDiagnostic; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn convert_detached_head() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("my-repo")?; + + prole.sh(" + cd my-repo + git switch --detach + ")?; + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole.assert_contents(&[ + ( + "my-repo/main/README.md", + expect![[r#" + puppy doggy + "#]], + ), + ( + "my-repo/work/README.md", + expect![[r#" + puppy doggy + "#]], + ), + ]); + + assert_eq!( + prole.git("my-repo/main").refs().head_kind()?, + HeadKind::Branch("main".into()) + ); + assert_eq!( + prole.git("my-repo/work").refs().head_kind()?, + HeadKind::Detached("4023d08019c45f462a9469778e78c3a1faad5013".into()) + ); + + Ok(()) +} diff --git a/tests/convert_multiple_remotes.rs b/tests/convert_multiple_remotes.rs new file mode 100644 index 0000000..3723948 --- /dev/null +++ b/tests/convert_multiple_remotes.rs @@ -0,0 +1,24 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::setup_repo_multiple_remotes; +use test_harness::GitProle; + +#[test] +fn convert_multiple_remotes() -> miette::Result<()> { + let prole = GitProle::new()?; + setup_repo_multiple_remotes(&prole, "my-remotes/my-repo", "my-repo")?; + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + assert_eq!(prole.current_branch_in("my-repo/main")?, "main"); + assert_eq!( + prole.upstream_for_branch_in("my-repo/main", "main")?, + "origin/main" + ); + + Ok(()) +} diff --git a/tests/convert_multiple_worktrees.rs b/tests/convert_multiple_worktrees.rs new file mode 100644 index 0000000..485e45e --- /dev/null +++ b/tests/convert_multiple_worktrees.rs @@ -0,0 +1,25 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::GitProle; + +#[test] +fn convert_multiple_worktrees() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("my-repo")?; + + prole.sh(" + cd my-repo || exit + git worktree add ../puppy + git worktree add ../doggy + ")?; + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic() + // Not implemented yet! + .unwrap_err(); + + Ok(()) +} diff --git a/tests/convert_non_default_branch_checked_out.rs b/tests/convert_non_default_branch_checked_out.rs new file mode 100644 index 0000000..46a00f4 --- /dev/null +++ b/tests/convert_non_default_branch_checked_out.rs @@ -0,0 +1,50 @@ +use command_error::CommandExt; +use expect_test::expect; +use miette::IntoDiagnostic; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn convert_non_default_branch_checked_out() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("my-repo")?; + + prole.sh(" + cd my-repo + git switch -c puppy + echo 'softie cutie' > README.md + git commit -am 'cooler readme' + ")?; + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole.assert_contents(&[ + ( + "my-repo/main/README.md", + expect![[r#" + puppy doggy + "#]], + ), + ( + "my-repo/puppy/README.md", + expect![[r#" + softie cutie + "#]], + ), + ]); + + assert_eq!( + prole + .git("my-repo/.git") + .config() + .get("core.bare")? + .unwrap(), + "true" + ); + + Ok(()) +} diff --git a/tests/convert_uncommitted_changes.rs b/tests/convert_uncommitted_changes.rs new file mode 100644 index 0000000..8e73b9a --- /dev/null +++ b/tests/convert_uncommitted_changes.rs @@ -0,0 +1,69 @@ +use std::str::FromStr; + +use command_error::CommandExt; +use expect_test::expect; +use git_prole::StatusEntry; +use miette::IntoDiagnostic; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn convert_uncommitted_changes() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("my-repo")?; + + prole.sh(" + cd my-repo + git switch -c puppy + echo 'softie cutie' > README.md + git add . + ")?; + + assert_eq!( + prole.git("my-repo").status().get()?.entries, + vec![StatusEntry::from_str("M README.md\0")?] + ); + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole.assert_contents(&[ + ( + "my-repo/main/README.md", + expect![[r#" + puppy doggy + "#]], + ), + ( + "my-repo/puppy/README.md", + expect![[r#" + softie cutie + "#]], + ), + ]); + + // /!\ /!\ /!\ /!\ /!\ /!\ + // TODO: This is a bug!! + // We run a `git reset`, so we lose the staged changes! + // Fix: Bring back the `git stash` if anything is staged? + // /!\ /!\ /!\ /!\ /!\ /!\ + assert_eq!( + prole.git("my-repo/puppy").status().get()?.entries, + vec![StatusEntry::from_str(" M README.md\0")?] + ); + + // Different contents, same commits! + assert_eq!( + prole.git("my-repo/main").refs().get_head()?.abbrev(), + "4023d080" + ); + assert_eq!( + prole.git("my-repo/puppy").refs().get_head()?.abbrev(), + "4023d080" + ); + + Ok(()) +} diff --git a/tests/convert_unstaged_changes.rs b/tests/convert_unstaged_changes.rs new file mode 100644 index 0000000..ec6a539 --- /dev/null +++ b/tests/convert_unstaged_changes.rs @@ -0,0 +1,63 @@ +use std::str::FromStr; + +use command_error::CommandExt; +use expect_test::expect; +use git_prole::StatusEntry; +use miette::IntoDiagnostic; +use pretty_assertions::assert_eq; +use test_harness::GitProle; + +#[test] +fn convert_unstaged_changes() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("my-repo")?; + + prole.sh(" + cd my-repo + git switch -c puppy + echo 'softie cutie' > README.md + ")?; + + assert_eq!( + prole.git("my-repo").status().get()?.entries, + vec![StatusEntry::from_str(" M README.md\0")?] + ); + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole.assert_contents(&[ + ( + "my-repo/main/README.md", + expect![[r#" + puppy doggy + "#]], + ), + ( + "my-repo/puppy/README.md", + expect![[r#" + softie cutie + "#]], + ), + ]); + + assert_eq!( + prole.git("my-repo/puppy").status().get()?.entries, + vec![StatusEntry::from_str(" M README.md\0")?] + ); + + // Different contents, same commits! + assert_eq!( + prole.git("my-repo/main").refs().get_head()?.abbrev(), + "4023d080" + ); + assert_eq!( + prole.git("my-repo/puppy").refs().get_head()?.abbrev(), + "4023d080" + ); + + Ok(()) +}