diff --git a/Cargo.lock b/Cargo.lock index 7499fde..ebd5139 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "0.6.20" @@ -130,6 +136,21 @@ dependencies = [ "serde", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.39" @@ -221,6 +242,44 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "difflib" version = "0.4.0" @@ -244,6 +303,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -285,6 +356,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -303,7 +380,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -327,6 +404,17 @@ dependencies = [ "url", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -475,6 +563,24 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +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 = "itoa" version = "1.0.15" @@ -555,12 +661,31 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "memchr" version = "2.7.6" @@ -576,6 +701,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -640,6 +777,35 @@ dependencies = [ "supports-color 3.0.2", ] +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -721,6 +887,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.11.3" @@ -757,9 +952,11 @@ dependencies = [ "assert_cmd", "clap", "color-eyre", + "crossterm", "git2", "owo-colors", "predicates", + "ratatui", "serde", "serde_json", "tempfile", @@ -784,12 +981,24 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[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.226" @@ -848,24 +1057,92 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "supports-color" version = "2.1.0" @@ -992,6 +1269,29 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "url" version = "2.5.7" @@ -1037,6 +1337,12 @@ dependencies = [ "libc", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasi" version = "0.14.7+wasi-0.2.4" @@ -1055,12 +1361,43 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[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-link" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1079,6 +1416,21 @@ dependencies = [ "windows-targets 0.53.4", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1112,6 +1464,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1124,6 +1482,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -1136,6 +1500,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1160,6 +1530,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -1172,6 +1548,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -1184,6 +1566,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -1196,6 +1584,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 56fdfbe..1bb8007 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ clap = { version = "4.5", features = ["derive"] } color-eyre = "0.6" owo-colors = { version = "4.0", features = ["supports-colors"] } git2 = "0.20" +crossterm = "0.27" +ratatui = { version = "0.26", default-features = false, features = ["crossterm"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9aec75d..d02bb01 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -9,6 +9,7 @@ use crate::{ commands::{ cd::CdCommand, create::CreateCommand, + interactive, list::ListCommand, merge_pr_github::MergePrGithubCommand, pr_github::{PrGithubCommand, PrGithubOptions}, @@ -31,6 +32,9 @@ enum Commands { Ls, /// Open a shell in the given worktree. Cd(CdArgs), + /// Interactively browse and open worktrees. + #[command(alias = "i")] + Interactive, /// Remove a worktree tracked in `.rsworktree`. Rm(RmArgs), /// Create a GitHub pull request for the worktree's branch using the GitHub CLI. @@ -97,6 +101,9 @@ struct PrGithubArgs { struct MergePrGithubArgs { /// Name of the worktree to merge the PR for (defaults to the current worktree) name: Option, + /// Remove the remote branch after merging + #[arg(long = "remove")] + remove_remote: bool, } pub fn run() -> color_eyre::Result<()> { @@ -116,6 +123,9 @@ pub fn run() -> color_eyre::Result<()> { let command = CdCommand::new(args.name, args.print); command.execute(&repo)?; } + Commands::Interactive => { + interactive::run(&repo)?; + } Commands::Rm(args) => { let command = RemoveCommand::new(args.name, args.force); command.execute(&repo)?; @@ -138,6 +148,9 @@ pub fn run() -> color_eyre::Result<()> { Commands::MergePrGithub(args) => { let worktree_name = resolve_worktree_name(args.name, &repo, "merge-pr-github")?; let mut command = MergePrGithubCommand::new(worktree_name); + if args.remove_remote { + command.enable_remove_remote(); + } command.execute(&repo)?; } } @@ -195,6 +208,7 @@ mod tests { use super::*; use std::{env, fs, path::Path, process::Command as StdCommand}; + use clap::Parser; use color_eyre::eyre::{self, WrapErr}; use tempfile::TempDir; @@ -266,6 +280,19 @@ mod tests { Ok(()) } + #[test] + fn parses_interactive_command_and_alias() -> color_eyre::Result<()> { + let interactive = Cli::try_parse_from(["rsworktree", "interactive"]) + .expect("interactive subcommand should parse"); + assert!(matches!(interactive.command, Commands::Interactive)); + + let alias = + Cli::try_parse_from(["rsworktree", "i"]).expect("interactive alias should parse"); + assert!(matches!(alias.command, Commands::Interactive)); + + Ok(()) + } + #[test] fn resolve_worktree_name_infers_from_cwd_inside_worktree() -> color_eyre::Result<()> { let repo_dir = TempDir::new()?; diff --git a/src/commands/create/mod.rs b/src/commands/create/mod.rs index 04425db..2cf232c 100644 --- a/src/commands/create/mod.rs +++ b/src/commands/create/mod.rs @@ -14,32 +14,59 @@ pub struct CreateCommand { base: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreateOutcome { + AlreadyExists, + Created, +} + impl CreateCommand { pub fn new(name: String, base: Option) -> Self { Self { name, base } } pub fn execute(&self, repo: &Repo) -> color_eyre::Result<()> { + let outcome = self.create_internal(repo, false)?; + match outcome { + CreateOutcome::Created | CreateOutcome::AlreadyExists => self.enter_worktree(repo), + } + } + + pub fn create_without_enter( + &self, + repo: &Repo, + quiet: bool, + ) -> color_eyre::Result { + self.create_internal(repo, quiet) + } + + fn enter_worktree(&self, repo: &Repo) -> color_eyre::Result<()> { + CdCommand::new(self.name.clone(), false).execute(repo) + } + + fn create_internal(&self, repo: &Repo, quiet: bool) -> color_eyre::Result { let worktrees_dir = repo.ensure_worktrees_dir()?; let worktree_path = worktrees_dir.join(&self.name); let target_branch = self.name.as_str(); let base_branch = self.base.as_deref(); if worktree_path.exists() { - let name = format!( - "{}", - self.name - .as_str() - .if_supports_color(Stream::Stdout, |text| { - format!("{}", text.cyan().bold()) - }) - ); - println!( - "Worktree `{}` already exists at `{}`.", - name, - worktree_path.display() - ); - return self.enter_worktree(repo); + if !quiet { + let name = format!( + "{}", + self.name + .as_str() + .if_supports_color(Stream::Stdout, |text| { + format!("{}", text.cyan().bold()) + }) + ); + println!( + "Worktree `{}` already exists at `{}`.", + name, + worktree_path.display() + ); + } + return Ok(CreateOutcome::AlreadyExists); } if let Some(parent) = worktree_path.parent() { @@ -63,36 +90,34 @@ impl CreateCommand { ) })?; - let name = format!( - "{}", - target_branch.if_supports_color(Stream::Stdout, |text| { - format!("{}", text.green().bold()) - }) - ); - let path_raw = format!("{}", worktree_path.display()); - let path = format!( - "{}", - path_raw - .as_str() - .if_supports_color(Stream::Stdout, |text| { format!("{}", text.blue()) }) - ); - if let Some(base) = base_branch { - let base = format!( + if !quiet { + let name = format!( "{}", - base.if_supports_color(Stream::Stdout, |text| { - format!("{}", text.magenta().bold()) + target_branch.if_supports_color(Stream::Stdout, |text| { + format!("{}", text.green().bold()) }) ); - println!("Created worktree `{}` at `{}` from `{}`.", name, path, base); - } else { - println!("Created worktree `{}` at `{}`.", name, path); + let path_raw = format!("{}", worktree_path.display()); + let path = format!( + "{}", + path_raw + .as_str() + .if_supports_color(Stream::Stdout, |text| { format!("{}", text.blue()) }) + ); + if let Some(base) = base_branch { + let base = format!( + "{}", + base.if_supports_color(Stream::Stdout, |text| { + format!("{}", text.magenta().bold()) + }) + ); + println!("Created worktree `{}` at `{}` from `{}`.", name, path, base); + } else { + println!("Created worktree `{}` at `{}`.", name, path); + } } - self.enter_worktree(repo) - } - - fn enter_worktree(&self, repo: &Repo) -> color_eyre::Result<()> { - CdCommand::new(self.name.clone(), false).execute(repo) + Ok(CreateOutcome::Created) } } diff --git a/src/commands/interactive/command.rs b/src/commands/interactive/command.rs new file mode 100644 index 0000000..20c439f --- /dev/null +++ b/src/commands/interactive/command.rs @@ -0,0 +1,605 @@ +use std::path::PathBuf; + +use color_eyre::{Result, eyre::WrapErr}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::{Terminal, backend::Backend, widgets::ListState}; + +use super::{ + Action, EventSource, Focus, StatusMessage, WorktreeEntry, + dialog::{CreateDialog, CreateDialogFocus, Dialog}, + view::{DetailData, DialogView, Snapshot}, +}; + +pub struct InteractiveCommand +where + B: Backend, + E: EventSource, +{ + pub(crate) terminal: Terminal, + events: E, + worktrees_dir: PathBuf, + pub(crate) worktrees: Vec, + pub(crate) selected: Option, + pub(crate) focus: Focus, + pub(crate) action_selected: usize, + pub(crate) global_action_selected: usize, + pub(crate) branches: Vec, + pub(crate) default_branch: Option, + pub(crate) status: Option, + pub(crate) dialog: Option, +} + +impl InteractiveCommand +where + B: Backend, + E: EventSource, +{ + pub fn new( + terminal: Terminal, + events: E, + worktrees_dir: PathBuf, + worktrees: Vec, + mut branches: Vec, + default_branch: Option, + ) -> Self { + let selected = if worktrees.is_empty() { None } else { Some(0) }; + + branches.sort(); + branches.dedup(); + + Self { + terminal, + events, + worktrees_dir, + worktrees, + selected, + focus: Focus::Worktrees, + action_selected: 0, + global_action_selected: 0, + branches, + default_branch, + status: None, + dialog: None, + } + } + + pub fn run(mut self, mut on_remove: F, mut on_create: G) -> Result> + where + F: FnMut(&str) -> Result<()>, + G: FnMut(&str, Option<&str>) -> Result<()>, + { + self.terminal + .hide_cursor() + .wrap_err("failed to hide cursor")?; + + let result = self.event_loop(&mut on_remove, &mut on_create); + + self.terminal + .show_cursor() + .wrap_err("failed to show cursor")?; + + result + } + + fn event_loop(&mut self, on_remove: &mut F, on_create: &mut G) -> Result> + where + F: FnMut(&str) -> Result<()>, + G: FnMut(&str, Option<&str>) -> Result<()>, + { + let mut state = ListState::default(); + self.sync_selection(&mut state); + + loop { + let snapshot = self.snapshot(); + self.terminal + .draw(|frame| snapshot.render(frame, &mut state))?; + let event = self.events.next()?; + + match self.process_event(event, &mut state, on_remove, on_create)? { + LoopControl::Continue => {} + LoopControl::Exit(outcome) => return Ok(outcome), + } + } + } + + fn process_event( + &mut self, + event: Event, + state: &mut ListState, + on_remove: &mut F, + on_create: &mut G, + ) -> Result + where + F: FnMut(&str) -> Result<()>, + G: FnMut(&str, Option<&str>) -> Result<()>, + { + if let Some(dialog) = self.dialog.clone() { + match dialog { + Dialog::ConfirmRemove { index } => { + if let Event::Key(key) = event { + if key.kind == KeyEventKind::Press { + self.handle_confirm(index, key.code, state, on_remove)?; + } + } + return Ok(LoopControl::Continue); + } + Dialog::Info { .. } => { + if let Event::Key(key) = event { + if key.kind == KeyEventKind::Press && key.code == KeyCode::Enter { + self.dialog = None; + } + } + return Ok(LoopControl::Continue); + } + Dialog::Create(_) => { + if let Event::Key(key) = event { + if key.kind == KeyEventKind::Press { + self.handle_create_key(key, state, on_create)?; + } + } + return Ok(LoopControl::Continue); + } + } + } + + let Event::Key(key) = event else { + return Ok(LoopControl::Continue); + }; + + if key.kind != KeyEventKind::Press { + return Ok(LoopControl::Continue); + } + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => Ok(LoopControl::Exit(None)), + KeyCode::Tab | KeyCode::BackTab => { + if key.code == KeyCode::Tab { + self.focus = self.focus.next(); + } else { + self.focus = self.focus.prev(); + } + Ok(LoopControl::Continue) + } + KeyCode::Up | KeyCode::Char('k') => { + self.handle_up(state); + Ok(LoopControl::Continue) + } + KeyCode::Down | KeyCode::Char('j') => { + self.handle_down(state); + Ok(LoopControl::Continue) + } + KeyCode::Left => { + match self.focus { + Focus::Actions => self.move_action(-1), + Focus::GlobalActions => self.move_global_action(-1), + Focus::Worktrees => {} + } + Ok(LoopControl::Continue) + } + KeyCode::Right => { + match self.focus { + Focus::Actions => self.move_action(1), + Focus::GlobalActions => self.move_global_action(1), + Focus::Worktrees => {} + } + Ok(LoopControl::Continue) + } + KeyCode::Enter => self.handle_enter(), + _ => Ok(LoopControl::Continue), + } + } + + fn handle_enter(&mut self) -> Result { + match self.focus { + Focus::Worktrees => { + if let Some(index) = self.selected { + return Ok(LoopControl::Exit( + self.worktrees.get(index).map(|entry| entry.name.clone()), + )); + } + } + Focus::Actions => { + let action = Action::from_index(self.action_selected); + match action { + Action::Open => { + if let Some(entry) = self.current_entry() { + return Ok(LoopControl::Exit(Some(entry.name.clone()))); + } + self.status = Some(StatusMessage::info("No worktree selected.")); + } + Action::Remove => { + if let Some(index) = self.selected { + self.dialog = Some(Dialog::ConfirmRemove { index }); + } else { + self.status = + Some(StatusMessage::info("No worktree selected to remove.")); + } + } + } + } + Focus::GlobalActions => { + if self.global_action_selected == 0 { + let dialog = + CreateDialog::new(&self.branches, &self.worktrees, self.default_branch()); + self.dialog = Some(Dialog::Create(dialog)); + } + } + } + + Ok(LoopControl::Continue) + } + + fn handle_confirm( + &mut self, + index: usize, + code: KeyCode, + state: &mut ListState, + on_remove: &mut F, + ) -> Result<()> + where + F: FnMut(&str) -> Result<()>, + { + match code { + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + if let Some(entry) = self.worktrees.get(index).cloned() { + match on_remove(&entry.name) { + Ok(()) => { + self.worktrees.remove(index); + let removal_dir = entry + .path + .parent() + .map(|parent| parent.display().to_string()) + .unwrap_or_else(|| entry.path.display().to_string()); + let message = format!( + "Removed worktree `{}` from `{}`.", + entry.name, removal_dir + ); + self.selected = None; + self.focus = Focus::Worktrees; + self.sync_selection(state); + self.status = None; + self.dialog = Some(Dialog::Info { message }); + return Ok(()); + } + Err(err) => { + self.status = Some(StatusMessage::error(format!( + "Failed to remove `{}`: {err}", + entry.name + ))); + self.dialog = None; + return Ok(()); + } + } + } + self.dialog = None; + } + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.status = Some(StatusMessage::info("Removal cancelled.")); + self.dialog = None; + } + _ => {} + } + + Ok(()) + } + + fn handle_create_key( + &mut self, + key: KeyEvent, + state: &mut ListState, + on_create: &mut G, + ) -> Result<()> + where + G: FnMut(&str, Option<&str>) -> Result<()>, + { + let mut close_dialog = false; + let mut status_message: Option = None; + let mut submit_requested = false; + + { + let Some(dialog) = self.dialog.as_mut().and_then(|dialog| { + if let Dialog::Create(dialog) = dialog { + Some(dialog) + } else { + None + } + }) else { + return Ok(()); + }; + + let modifiers = key.modifiers; + + if key.code == KeyCode::Esc { + close_dialog = true; + status_message = Some(StatusMessage::info("Creation cancelled.")); + self.focus = Focus::Worktrees; + dialog.error = None; + dialog.name_input.clear(); + } else if key.code == KeyCode::Tab { + dialog.focus_next(); + return Ok(()); + } else if key.code == KeyCode::BackTab { + dialog.focus_prev(); + return Ok(()); + } + + if close_dialog { + // Skip additional handling when dialog marked to close. + } else { + match dialog.focus { + CreateDialogFocus::Name => match key.code { + KeyCode::Char(c) + if !modifiers.intersects( + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, + ) => + { + dialog.name_input.push(c); + dialog.error = None; + } + KeyCode::Backspace => { + dialog.name_input.pop(); + dialog.error = None; + } + KeyCode::Enter => { + dialog.focus = CreateDialogFocus::Base; + } + _ => {} + }, + CreateDialogFocus::Base => match key.code { + KeyCode::Up | KeyCode::Char('k') => dialog.move_base(-1), + KeyCode::Down | KeyCode::Char('j') => dialog.move_base(1), + KeyCode::Enter => { + dialog.focus = CreateDialogFocus::Buttons; + dialog.buttons_selected = 0; + } + _ => {} + }, + CreateDialogFocus::Buttons => match key.code { + KeyCode::Left => { + if dialog.buttons_selected > 0 { + dialog.buttons_selected -= 1; + } + } + KeyCode::Right => { + if dialog.buttons_selected < 1 { + dialog.buttons_selected += 1; + } + } + KeyCode::Enter => { + if dialog.buttons_selected == 0 { + submit_requested = true; + } else { + close_dialog = true; + status_message = Some(StatusMessage::info("Creation cancelled.")); + self.focus = Focus::Worktrees; + } + } + _ => {} + }, + } + } + } + + if submit_requested { + if let Some((name, base_label)) = self.perform_create_submission(state, on_create)? { + close_dialog = true; + status_message = Some(StatusMessage::info(format!( + "Created `{}` from {}", + name, base_label + ))); + } + } + + if close_dialog { + self.dialog = None; + self.focus = Focus::Worktrees; + self.status = status_message; + } + + Ok(()) + } + + fn submit_create( + &mut self, + dialog: &mut CreateDialog, + state: &mut ListState, + on_create: &mut G, + ) -> Result> + where + G: FnMut(&str, Option<&str>) -> Result<()>, + { + dialog.error = None; + + let name_trimmed = dialog.name_input.trim(); + if name_trimmed.is_empty() { + dialog.error = Some("Worktree name cannot be empty.".into()); + dialog.focus = CreateDialogFocus::Name; + return Ok(None); + } + + if self + .worktrees + .iter() + .any(|entry| entry.name == name_trimmed) + { + dialog.error = Some(format!("Worktree `{}` already exists.", name_trimmed)); + dialog.focus = CreateDialogFocus::Name; + return Ok(None); + } + + let base_option = dialog.base_option(); + let base_value = base_option.and_then(|opt| opt.value.as_deref()); + let base_label = base_option + .map(|opt| opt.label.clone()) + .unwrap_or_else(|| "HEAD".into()); + + if let Err(err) = on_create(name_trimmed, base_value) { + dialog.error = Some(err.to_string()); + dialog.focus = CreateDialogFocus::Name; + return Ok(None); + } + + let name_owned = name_trimmed.to_string(); + + if !self.branches.iter().any(|branch| branch == &name_owned) { + self.branches.push(name_owned.clone()); + self.branches.sort(); + self.branches.dedup(); + } + + let path = self.worktrees_dir.join(&name_owned); + self.worktrees + .push(WorktreeEntry::new(name_owned.clone(), path)); + self.worktrees.sort_by(|a, b| a.name.cmp(&b.name)); + self.selected = self + .worktrees + .iter() + .position(|entry| entry.name == name_owned); + self.focus = Focus::Worktrees; + self.global_action_selected = 0; + self.sync_selection(state); + + Ok(Some((name_owned, base_label))) + } + + fn perform_create_submission( + &mut self, + state: &mut ListState, + on_create: &mut G, + ) -> Result> + where + G: FnMut(&str, Option<&str>) -> Result<()>, + { + if let Some(Dialog::Create(mut dialog)) = self.dialog.take() { + let outcome = self.submit_create(&mut dialog, state, on_create)?; + + if outcome.is_none() { + self.dialog = Some(Dialog::Create(dialog)); + } + + Ok(outcome) + } else { + Ok(None) + } + } + + fn handle_up(&mut self, state: &mut ListState) { + match self.focus { + Focus::Worktrees => { + if self.worktrees.is_empty() { + return; + } + let next = match self.selected { + Some(0) => Some(self.worktrees.len() - 1), + Some(idx) => Some(idx - 1), + None => Some(self.worktrees.len() - 1), + }; + self.selected = next; + self.sync_selection(state); + } + Focus::Actions => self.move_action(-1), + Focus::GlobalActions => self.move_global_action(-1), + } + } + + fn handle_down(&mut self, state: &mut ListState) { + match self.focus { + Focus::Worktrees => { + if self.worktrees.is_empty() { + return; + } + let next = match self.selected { + Some(idx) => (idx + 1) % self.worktrees.len(), + None => 0, + }; + self.selected = Some(next); + self.sync_selection(state); + } + Focus::Actions => self.move_action(1), + Focus::GlobalActions => self.move_global_action(1), + } + } + + fn move_action(&mut self, delta: isize) { + let len = Action::ALL.len() as isize; + let current = self.action_selected as isize; + let next = (current + delta).rem_euclid(len); + self.action_selected = next as usize; + } + + fn move_global_action(&mut self, delta: isize) { + let len = super::GLOBAL_ACTIONS.len() as isize; + if len == 0 { + return; + } + let current = self.global_action_selected as isize; + let next = (current + delta).rem_euclid(len); + self.global_action_selected = next as usize; + } + + fn current_entry(&self) -> Option<&WorktreeEntry> { + self.selected.and_then(|idx| self.worktrees.get(idx)) + } + + fn sync_selection(&mut self, state: &mut ListState) { + if let Some(idx) = self.selected { + if self.worktrees.is_empty() { + self.selected = None; + } else if idx >= self.worktrees.len() { + self.selected = Some(self.worktrees.len() - 1); + } + } + + if self.worktrees.is_empty() { + self.selected = None; + } + + state.select(self.selected); + } + + fn default_branch(&self) -> Option<&str> { + self.default_branch.as_deref() + } + + fn snapshot(&self) -> Snapshot { + let items = self + .worktrees + .iter() + .map(|entry| entry.name.clone()) + .collect::>(); + + let detail = self.current_entry().map(|entry| DetailData { + name: entry.name.clone(), + path: entry.path.display().to_string(), + }); + + let dialog = match self.dialog.clone() { + Some(Dialog::ConfirmRemove { index }) => { + self.worktrees + .get(index) + .map(|entry| DialogView::ConfirmRemove { + name: entry.name.clone(), + }) + } + Some(Dialog::Info { message }) => Some(DialogView::Info { message }), + Some(Dialog::Create(dialog)) => Some(DialogView::Create(dialog.into())), + None => None, + }; + + Snapshot::new( + items, + detail, + self.focus, + self.action_selected, + self.global_action_selected, + self.status.clone(), + dialog, + !self.worktrees.is_empty(), + ) + } +} + +enum LoopControl { + Continue, + Exit(Option), +} diff --git a/src/commands/interactive/dialog.rs b/src/commands/interactive/dialog.rs new file mode 100644 index 0000000..1062e38 --- /dev/null +++ b/src/commands/interactive/dialog.rs @@ -0,0 +1,197 @@ +use super::WorktreeEntry; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CreateDialogFocus { + Name, + Base, + Buttons, +} + +#[derive(Clone, Debug)] +pub(crate) struct BaseOption { + pub(crate) label: String, + pub(crate) value: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct BaseOptionGroup { + pub(crate) title: String, + pub(crate) options: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct CreateDialog { + pub(crate) name_input: String, + pub(crate) focus: CreateDialogFocus, + pub(crate) buttons_selected: usize, + pub(crate) base_groups: Vec, + pub(crate) base_indices: Vec<(usize, usize)>, + pub(crate) base_selected: usize, + pub(crate) error: Option, +} + +impl CreateDialog { + pub(crate) fn new( + branches: &[String], + worktrees: &[WorktreeEntry], + default_branch: Option<&str>, + ) -> Self { + let mut groups = Vec::new(); + + if !branches.is_empty() { + let options = branches + .iter() + .map(|branch| BaseOption { + label: format!("branch: {branch}"), + value: Some(branch.clone()), + }) + .collect(); + groups.push(BaseOptionGroup { + title: "Branches".into(), + options, + }); + } + + let mut worktree_options = worktrees + .iter() + .map(|entry| BaseOption { + label: format!("worktree: {}", entry.name), + value: Some(entry.name.clone()), + }) + .collect::>(); + worktree_options.sort_by(|a, b| a.label.cmp(&b.label)); + + if !worktree_options.is_empty() { + groups.push(BaseOptionGroup { + title: "Worktrees".into(), + options: worktree_options, + }); + } + + if groups.is_empty() { + groups.push(BaseOptionGroup { + title: "General".into(), + options: vec![BaseOption { + label: "HEAD".into(), + value: None, + }], + }); + } + + let mut base_indices = Vec::new(); + for (group_idx, group) in groups.iter().enumerate() { + for (option_idx, _) in group.options.iter().enumerate() { + base_indices.push((group_idx, option_idx)); + } + } + + let mut base_selected = 0; + if let Some(default) = default_branch { + if let Some((idx, _)) = + base_indices + .iter() + .enumerate() + .find(|(_, (group_idx, option_idx))| { + groups[*group_idx].options[*option_idx] + .value + .as_deref() + .map_or(false, |value| value == default) + }) + { + base_selected = idx; + } + } + + if base_indices.is_empty() { + base_indices.push((0, 0)); + base_selected = 0; + } + + Self { + name_input: String::new(), + focus: CreateDialogFocus::Name, + buttons_selected: 0, + base_groups: groups, + base_indices, + base_selected, + error: None, + } + } + + pub(crate) fn base_option(&self) -> Option<&BaseOption> { + self.base_indices + .get(self.base_selected) + .map(|(group_idx, option_idx)| &self.base_groups[*group_idx].options[*option_idx]) + } + + pub(crate) fn focus_next(&mut self) { + self.focus = match self.focus { + CreateDialogFocus::Name => CreateDialogFocus::Base, + CreateDialogFocus::Base => CreateDialogFocus::Buttons, + CreateDialogFocus::Buttons => CreateDialogFocus::Name, + }; + } + + pub(crate) fn focus_prev(&mut self) { + self.focus = match self.focus { + CreateDialogFocus::Name => CreateDialogFocus::Buttons, + CreateDialogFocus::Base => CreateDialogFocus::Name, + CreateDialogFocus::Buttons => CreateDialogFocus::Base, + }; + } + + pub(crate) fn move_base(&mut self, delta: isize) { + if self.base_indices.is_empty() { + return; + } + + let len = self.base_indices.len() as isize; + let current = self.base_selected as isize; + let next = (current + delta).rem_euclid(len); + self.base_selected = next as usize; + } +} + +#[derive(Clone, Debug)] +pub(crate) struct CreateDialogView { + pub(crate) name_input: String, + pub(crate) focus: CreateDialogFocus, + pub(crate) buttons_selected: usize, + pub(crate) base_groups: Vec, + pub(crate) base_selected: usize, + pub(crate) base_indices: Vec<(usize, usize)>, + pub(crate) error: Option, +} + +impl From<&CreateDialog> for CreateDialogView { + fn from(dialog: &CreateDialog) -> Self { + Self { + name_input: dialog.name_input.clone(), + focus: dialog.focus, + buttons_selected: dialog.buttons_selected, + base_groups: dialog.base_groups.clone(), + base_selected: dialog.base_selected, + base_indices: dialog.base_indices.clone(), + error: dialog.error.clone(), + } + } +} + +impl From for CreateDialogView { + fn from(dialog: CreateDialog) -> Self { + Self::from(&dialog) + } +} + +impl CreateDialogView { + pub(crate) fn base_indices(&self) -> &[(usize, usize)] { + &self.base_indices + } +} + +#[derive(Clone, Debug)] +pub(crate) enum Dialog { + ConfirmRemove { index: usize }, + Info { message: String }, + Create(CreateDialog), +} diff --git a/src/commands/interactive/mod.rs b/src/commands/interactive/mod.rs new file mode 100644 index 0000000..e7b5346 --- /dev/null +++ b/src/commands/interactive/mod.rs @@ -0,0 +1,120 @@ +mod command; +mod dialog; +mod runtime; +mod view; + +#[allow(unused_imports)] +pub use command::InteractiveCommand; +#[allow(unused_imports)] +pub use runtime::{CrosstermEvents, run}; + +use std::path::PathBuf; + +use crossterm::event::Event; +use ratatui::style::{Color, Modifier, Style}; + +pub trait EventSource { + fn next(&mut self) -> color_eyre::Result; +} + +#[derive(Clone, Debug)] +pub(crate) struct WorktreeEntry { + pub(crate) name: String, + pub(crate) path: PathBuf, +} + +impl WorktreeEntry { + pub(crate) fn new(name: String, path: PathBuf) -> Self { + Self { name, path } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Focus { + Worktrees, + Actions, + GlobalActions, +} + +impl Focus { + pub(crate) fn next(self) -> Self { + match self { + Focus::Worktrees => Focus::Actions, + Focus::Actions => Focus::GlobalActions, + Focus::GlobalActions => Focus::Worktrees, + } + } + + pub(crate) fn prev(self) -> Self { + match self { + Focus::Worktrees => Focus::GlobalActions, + Focus::Actions => Focus::Worktrees, + Focus::GlobalActions => Focus::Actions, + } + } +} + +pub(crate) const GLOBAL_ACTIONS: [&str; 1] = ["Create"]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Action { + Open, + Remove, +} + +impl Action { + pub(crate) const ALL: [Action; 2] = [Action::Open, Action::Remove]; + + pub(crate) fn label(self) -> &'static str { + match self { + Action::Open => "Open", + Action::Remove => "Remove", + } + } + + pub(crate) fn requires_selection(self) -> bool { + matches!(self, Action::Open | Action::Remove) + } + + pub(crate) fn from_index(index: usize) -> Self { + Self::ALL[index % Self::ALL.len()] + } +} + +#[derive(Clone, Debug)] +pub(crate) struct StatusMessage { + pub(crate) text: String, + pub(crate) kind: StatusKind, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum StatusKind { + Info, + Error, +} + +impl StatusMessage { + pub(crate) fn info(text: impl Into) -> Self { + Self { + text: text.into(), + kind: StatusKind::Info, + } + } + + pub(crate) fn error(text: impl Into) -> Self { + Self { + text: text.into(), + kind: StatusKind::Error, + } + } + + pub(crate) fn style(&self) -> Style { + match self.kind { + StatusKind::Info => Style::default().fg(Color::Gray), + StatusKind::Error => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/commands/interactive/runtime.rs b/src/commands/interactive/runtime.rs new file mode 100644 index 0000000..b2f531b --- /dev/null +++ b/src/commands/interactive/runtime.rs @@ -0,0 +1,147 @@ +use std::io; + +use color_eyre::{Result, eyre::WrapErr}; +use crossterm::{ + event::Event, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; + +use crate::{ + Repo, + commands::{ + cd::CdCommand, + create::{CreateCommand, CreateOutcome}, + list::{find_worktrees, format_worktree}, + rm::RemoveCommand, + }, +}; + +use super::{EventSource, WorktreeEntry, command::InteractiveCommand}; + +pub struct CrosstermEvents; + +impl Default for CrosstermEvents { + fn default() -> Self { + Self + } +} + +impl EventSource for CrosstermEvents { + fn next(&mut self) -> Result { + crossterm::event::read().wrap_err("failed to read terminal event") + } +} + +pub fn run(repo: &Repo) -> Result<()> { + let worktrees_dir = repo.ensure_worktrees_dir()?; + let raw_entries = find_worktrees(&worktrees_dir)?; + let worktrees = raw_entries + .into_iter() + .map(|path| { + let display = format_worktree(&path); + WorktreeEntry::new(display, worktrees_dir.join(&path)) + }) + .collect::>(); + + let (branches, default_branch) = load_branches(repo)?; + + enable_raw_mode().wrap_err("failed to enable raw mode")?; + execute!(io::stdout(), EnterAlternateScreen).wrap_err("failed to enter alternate screen")?; + + let backend = CrosstermBackend::new(io::stdout()); + let terminal = Terminal::new(backend).wrap_err("failed to initialize terminal")?; + let events = CrosstermEvents::default(); + + let command = InteractiveCommand::new( + terminal, + events, + worktrees_dir.clone(), + worktrees, + branches, + default_branch, + ); + let result = command.run( + |name| { + let command = RemoveCommand::new(name.to_owned(), false).with_quiet(true); + command.execute(repo) + }, + |name, base| { + let command = CreateCommand::new(name.to_owned(), base.map(|b| b.to_owned())); + match command.create_without_enter(repo, true)? { + CreateOutcome::Created => Ok(()), + CreateOutcome::AlreadyExists => Err(color_eyre::eyre::eyre!( + "Worktree `{}` already exists.", + name + )), + } + }, + ); + let cleanup_result = cleanup_terminal(); + + let selection = match (result, cleanup_result) { + (Ok(selection), Ok(())) => selection, + (Err(run_err), Ok(())) => return Err(run_err), + (Ok(_), Err(cleanup_err)) => return Err(cleanup_err), + (Err(run_err), Err(cleanup_err)) => { + return Err(color_eyre::eyre::eyre!( + "interactive session failed ({run_err}); cleanup failed: {cleanup_err}" + )); + } + }; + + if let Some(name) = selection { + let command = CdCommand::new(name, false); + command.execute(repo)?; + } + + Ok(()) +} + +fn cleanup_terminal() -> Result<()> { + disable_raw_mode().wrap_err("failed to disable raw mode")?; + execute!(io::stdout(), LeaveAlternateScreen).wrap_err("failed to leave alternate screen")?; + Ok(()) +} + +fn load_branches(repo: &Repo) -> Result<(Vec, Option)> { + use std::collections::BTreeSet; + + use git2::BranchType; + + let git_repo = repo.git(); + let mut set = BTreeSet::new(); + let mut default_branch = None; + + if let Ok(head) = git_repo.head() { + if head.is_branch() { + if let Some(name) = head.shorthand() { + let branch = name.to_string(); + set.insert(branch.clone()); + default_branch = Some(branch); + } + } + } + + let mut iter = git_repo.branches(Some(BranchType::Local))?; + while let Some(branch_result) = iter.next() { + let (branch, _) = branch_result?; + if let Some(name) = branch.name()? { + if !name.is_empty() { + set.insert(name.to_string()); + } + } + } + + let branches: Vec = set.into_iter().collect(); + let default_branch = default_branch.and_then(|branch| { + if branches.iter().any(|candidate| candidate == &branch) { + Some(branch) + } else { + None + } + }); + + Ok((branches, default_branch)) +} diff --git a/src/commands/interactive/tests.rs b/src/commands/interactive/tests.rs new file mode 100644 index 0000000..cee28c2 --- /dev/null +++ b/src/commands/interactive/tests.rs @@ -0,0 +1,238 @@ +use super::*; +use std::{collections::VecDeque, path::PathBuf}; + +use color_eyre::{Result, eyre}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, backend::TestBackend}; + +struct StubEvents { + events: VecDeque, +} + +impl StubEvents { + fn new(events: Vec) -> Self { + Self { + events: events.into_iter().collect(), + } + } +} + +impl EventSource for StubEvents { + fn next(&mut self) -> Result { + self.events + .pop_front() + .ok_or_else(|| eyre::eyre!("no more events")) + } +} + +fn key(code: KeyCode) -> Event { + Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) +} + +fn char_key(c: char) -> Event { + Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)) +} + +fn entries(names: &[&str]) -> Vec { + names + .iter() + .map(|name| WorktreeEntry::new((*name).into(), PathBuf::from(format!("/tmp/{name}")))) + .collect() +} + +#[test] +fn returns_first_worktree_when_enter_pressed_immediately() -> Result<()> { + let backend = TestBackend::new(40, 10); + let terminal = Terminal::new(backend)?; + let events = StubEvents::new(vec![key(KeyCode::Enter)]); + let worktrees = entries(&["alpha", "beta"]); + let command = InteractiveCommand::new( + terminal, + events, + PathBuf::from("/tmp/worktrees"), + worktrees, + vec![String::from("main")], + Some(String::from("main")), + ); + + let selection = command + .run(|_| Ok(()), |_, _| panic!("create should not be called"))? + .expect("expected selection"); + assert_eq!(selection, "alpha"); + + Ok(()) +} + +#[test] +fn navigates_down_before_selecting() -> Result<()> { + let backend = TestBackend::new(40, 10); + let terminal = Terminal::new(backend)?; + let events = StubEvents::new(vec![key(KeyCode::Down), key(KeyCode::Enter)]); + let worktrees = entries(&["alpha", "beta", "gamma"]); + let command = InteractiveCommand::new( + terminal, + events, + PathBuf::from("/tmp/worktrees"), + worktrees, + vec![String::from("main")], + Some(String::from("main")), + ); + + let selection = command + .run(|_| Ok(()), |_, _| panic!("create should not be called"))? + .expect("expected selection"); + assert_eq!(selection, "beta"); + + Ok(()) +} + +#[test] +fn tabbing_to_actions_removes_selected_worktree() -> Result<()> { + let backend = TestBackend::new(40, 12); + let terminal = Terminal::new(backend)?; + let events = StubEvents::new(vec![ + key(KeyCode::Down), + key(KeyCode::Tab), + key(KeyCode::Down), + key(KeyCode::Enter), + char_key('y'), + key(KeyCode::Enter), + key(KeyCode::Esc), + ]); + let worktrees = entries(&["alpha", "beta", "gamma"]); + let command = InteractiveCommand::new( + terminal, + events, + PathBuf::from("/tmp/worktrees"), + worktrees, + vec![String::from("main")], + Some(String::from("main")), + ); + + let mut removed = Vec::new(); + let result = command.run( + |name| { + removed.push(name.to_owned()); + Ok(()) + }, + |_, _| panic!("create should not be called"), + )?; + + assert!( + result.is_none(), + "expected interactive session to exit without opening" + ); + assert_eq!(removed, vec!["beta"]); + + Ok(()) +} + +#[test] +fn cancelling_remove_keeps_worktree() -> Result<()> { + let backend = TestBackend::new(40, 12); + let terminal = Terminal::new(backend)?; + let events = StubEvents::new(vec![ + key(KeyCode::Tab), + key(KeyCode::Down), + key(KeyCode::Enter), + key(KeyCode::Esc), + key(KeyCode::Esc), + ]); + let worktrees = entries(&["alpha", "beta"]); + let command = InteractiveCommand::new( + terminal, + events, + PathBuf::from("/tmp/worktrees"), + worktrees, + vec![String::from("main")], + Some(String::from("main")), + ); + + let mut removed = Vec::new(); + let result = command.run( + |name| { + removed.push(name.to_owned()); + Ok(()) + }, + |_, _| panic!("create should not be called"), + )?; + + assert!(result.is_none()); + assert!(removed.is_empty()); + + Ok(()) +} + +#[test] +fn create_action_adds_new_worktree() -> Result<()> { + let backend = TestBackend::new(60, 18); + let terminal = Terminal::new(backend)?; + let events = StubEvents::new(vec![ + key(KeyCode::Tab), + key(KeyCode::Tab), + key(KeyCode::Enter), + char_key('n'), + char_key('e'), + char_key('w'), + key(KeyCode::Tab), + key(KeyCode::Tab), + key(KeyCode::Enter), + key(KeyCode::Enter), + ]); + + let worktrees = entries(&["alpha"]); + let command = InteractiveCommand::new( + terminal, + events, + PathBuf::from("/tmp/worktrees"), + worktrees, + vec![String::from("main")], + Some(String::from("main")), + ); + + let mut created = Vec::new(); + let result = command.run( + |_| Ok(()), + |name, base| { + created.push((name.to_string(), base.map(|b| b.to_string()))); + Ok(()) + }, + )?; + + assert_eq!(result, Some(String::from("new"))); + assert_eq!( + created, + vec![(String::from("new"), Some(String::from("main")))] + ); + + Ok(()) +} + +#[test] +fn cancelling_create_leaves_state_unchanged() -> Result<()> { + let backend = TestBackend::new(60, 18); + let terminal = Terminal::new(backend)?; + let events = StubEvents::new(vec![ + key(KeyCode::Tab), + key(KeyCode::Tab), + key(KeyCode::Enter), + key(KeyCode::Esc), + key(KeyCode::Esc), + ]); + + let worktrees = entries(&["alpha"]); + let command = InteractiveCommand::new( + terminal, + events, + PathBuf::from("/tmp/worktrees"), + worktrees, + vec![String::from("main")], + Some(String::from("main")), + ); + + let result = command.run(|_| Ok(()), |_, _| panic!("create should not be called"))?; + + assert!(result.is_none()); + + Ok(()) +} diff --git a/src/commands/interactive/view.rs b/src/commands/interactive/view.rs new file mode 100644 index 0000000..5578f0a --- /dev/null +++ b/src/commands/interactive/view.rs @@ -0,0 +1,364 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, +}; + +use super::{ + Action, Focus, StatusMessage, + dialog::{CreateDialogFocus, CreateDialogView}, +}; + +pub(crate) struct Snapshot { + items: Vec, + detail: Option, + focus: Focus, + action_selected: usize, + global_action_selected: usize, + status: Option, + dialog: Option, + has_worktrees: bool, +} + +#[derive(Clone, Debug)] +pub(crate) struct DetailData { + pub(crate) name: String, + pub(crate) path: String, +} + +#[derive(Clone, Debug)] +pub(crate) enum DialogView { + ConfirmRemove { name: String }, + Info { message: String }, + Create(CreateDialogView), +} + +impl Snapshot { + pub(crate) fn new( + items: Vec, + detail: Option, + focus: Focus, + action_selected: usize, + global_action_selected: usize, + status: Option, + dialog: Option, + has_worktrees: bool, + ) -> Self { + Self { + items, + detail, + focus, + action_selected, + global_action_selected, + status, + dialog, + has_worktrees, + } + } + + pub(crate) fn render(&self, frame: &mut Frame, state: &mut ListState) { + let size = frame.size(); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(size); + + let left = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(3)]) + .split(columns[0]); + + self.render_global_actions(frame, left[0]); + self.render_list(frame, left[1], state); + self.render_details(frame, columns[1]); + + if let Some(dialog) = &self.dialog { + match dialog { + DialogView::ConfirmRemove { name } => self.render_confirmation(frame, size, name), + DialogView::Info { message } => self.render_info(frame, size, message), + DialogView::Create(create) => self.render_create(frame, size, create), + } + } + } + + fn render_list(&self, frame: &mut Frame, area: Rect, state: &mut ListState) { + let items: Vec = if self.items.is_empty() { + vec![ListItem::new("(no worktrees)")] + } else { + self.items.iter().cloned().map(ListItem::new).collect() + }; + + let list = List::new(items) + .block(Block::default().title("Worktrees").borders(Borders::ALL)) + .highlight_symbol("▶ ") + .highlight_style(self.list_highlight_style()); + + frame.render_stateful_widget(list, area, state); + } + + fn render_details(&self, frame: &mut Frame, area: Rect) { + let detail_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(5), Constraint::Length(3)]) + .split(area); + + let mut lines = Vec::new(); + if let Some(detail) = &self.detail { + lines.push(Line::from(vec![ + Span::styled("Name: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(detail.name.clone()), + ])); + lines.push(Line::from(vec![ + Span::styled("Path: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(detail.path.clone()), + ])); + } else { + lines.push(Line::from("No worktree selected.")); + } + + lines.push(Line::from("")); + if let Some(status) = &self.status { + lines.push(Line::from(Span::styled( + status.text.clone(), + status.style(), + ))); + } else { + lines.push(Line::from("Use Tab to focus actions. Esc exits.")); + } + + let info = + Paragraph::new(lines).block(Block::default().title("Details").borders(Borders::ALL)); + frame.render_widget(info, detail_chunks[0]); + + let mut spans = Vec::new(); + for (idx, action) in Action::ALL.iter().enumerate() { + if idx > 0 { + spans.push(Span::raw(" ")); + } + + let mut style = Style::default(); + if action.requires_selection() && !self.has_worktrees { + style = style.add_modifier(Modifier::DIM); + } + + if self.focus == Focus::Actions && self.action_selected == idx { + style = style + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + } + + spans.push(Span::styled(format!("[{}]", action.label()), style)); + } + + let actions = Paragraph::new(Line::from(spans)).block( + Block::default() + .title("Worktree Actions") + .borders(Borders::ALL), + ); + frame.render_widget(actions, detail_chunks[1]); + } + + fn render_confirmation(&self, frame: &mut Frame, area: Rect, name: &str) { + let popup_area = centered_rect(60, 30, area); + frame.render_widget(Clear, popup_area); + + let lines = vec![ + Line::from(format!("Remove `{}`?", name)), + Line::from("Press Y/Enter to confirm or Esc to cancel."), + ]; + + let popup = Paragraph::new(lines).block( + Block::default() + .title("Confirm removal") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)), + ); + frame.render_widget(popup, popup_area); + } + + fn render_info(&self, frame: &mut Frame, area: Rect, message: &str) { + let popup_area = centered_rect(60, 30, area); + frame.render_widget(Clear, popup_area); + + let lines = vec![ + Line::from(message.to_owned()), + Line::from(""), + Line::from(Span::styled( + "[ OK ]", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + )), + Line::from("Press Enter to continue."), + ]; + + let popup = Paragraph::new(lines).block( + Block::default() + .title("Complete") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)), + ); + frame.render_widget(popup, popup_area); + } + + fn render_global_actions(&self, frame: &mut Frame, area: Rect) { + let mut spans = Vec::new(); + for (idx, label) in super::GLOBAL_ACTIONS.iter().enumerate() { + if idx > 0 { + spans.push(Span::raw(" ")); + } + + let mut style = Style::default(); + if self.focus == Focus::GlobalActions && self.global_action_selected == idx { + style = style + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + } + + spans.push(Span::styled(format!("[{label}]"), style)); + } + + let actions = Paragraph::new(Line::from(spans)).block( + Block::default() + .title("Global Actions") + .borders(Borders::ALL), + ); + frame.render_widget(actions, area); + } + + fn render_create(&self, frame: &mut Frame, area: Rect, dialog: &CreateDialogView) { + let popup_area = centered_rect(70, 70, area); + frame.render_widget(Clear, popup_area); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(6), + Constraint::Length(3), + ]) + .split(popup_area); + + let mut name_block = Block::default() + .title("Worktree Name") + .borders(Borders::ALL); + if dialog.focus == CreateDialogFocus::Name { + name_block = name_block.border_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ); + } + + let name_value = if dialog.name_input.is_empty() { + Span::styled("", Style::default().fg(Color::DarkGray)) + } else { + Span::raw(dialog.name_input.clone()) + }; + let name_line = Line::from(vec![ + Span::styled("Name: ", Style::default().add_modifier(Modifier::BOLD)), + name_value, + ]); + frame.render_widget(Paragraph::new(name_line).block(name_block), layout[0]); + + let mut base_lines = Vec::new(); + for (group_idx, group) in dialog.base_groups.iter().enumerate() { + base_lines.push(Line::from(vec![Span::styled( + group.title.clone(), + Style::default().add_modifier(Modifier::BOLD), + )])); + + for (option_idx, option) in group.options.iter().enumerate() { + let selected = dialog + .base_indices() + .iter() + .position(|&(g, o)| g == group_idx && o == option_idx) + .map_or(false, |idx| idx == dialog.base_selected); + + let mut style = Style::default(); + if selected { + style = style.fg(Color::Cyan).add_modifier(Modifier::BOLD); + } + + base_lines.push(Line::from(vec![Span::styled(option.label.clone(), style)])); + } + + base_lines.push(Line::from("")); + } + + let mut base_block = Block::default() + .title("Base Reference") + .borders(Borders::ALL); + if dialog.focus == CreateDialogFocus::Base { + base_block = base_block.border_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ); + } + frame.render_widget(Paragraph::new(base_lines).block(base_block), layout[1]); + + let mut footer_lines = Vec::new(); + if let Some(error) = &dialog.error { + footer_lines.push(Line::from(Span::styled( + error.clone(), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ))); + footer_lines.push(Line::from("")); + } + + let mut button_spans = Vec::new(); + for (idx, label) in ["Create", "Cancel"].iter().enumerate() { + if idx > 0 { + button_spans.push(Span::raw(" ")); + } + + let mut style = Style::default(); + if dialog.focus == CreateDialogFocus::Buttons && dialog.buttons_selected == idx { + style = style + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + } + + button_spans.push(Span::styled(format!("[{label}]"), style)); + } + footer_lines.push(Line::from(button_spans)); + + let footer = Paragraph::new(footer_lines) + .block(Block::default().title("Actions").borders(Borders::ALL)); + frame.render_widget(footer, layout[2]); + } + + fn list_highlight_style(&self) -> Style { + match self.focus { + Focus::Worktrees => Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + Focus::Actions | Focus::GlobalActions => Style::default().add_modifier(Modifier::DIM), + } + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(area); + + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(horizontal[1]); + + vertical[1] +} diff --git a/src/commands/list/mod.rs b/src/commands/list/mod.rs index 2fb4092..aa3e4e0 100644 --- a/src/commands/list/mod.rs +++ b/src/commands/list/mod.rs @@ -56,7 +56,7 @@ impl ListCommand { } } -fn find_worktrees(base: &Path) -> color_eyre::Result> { +pub(crate) fn find_worktrees(base: &Path) -> color_eyre::Result> { let mut results = Vec::new(); let mut queue = VecDeque::new(); queue.push_back(base.to_path_buf()); @@ -91,7 +91,7 @@ fn find_worktrees(base: &Path) -> color_eyre::Result> { Ok(results) } -fn format_worktree(path: &Path) -> String { +pub(crate) fn format_worktree(path: &Path) -> String { path.components() .map(|component| component.as_os_str().to_string_lossy().into_owned()) .collect::>() diff --git a/src/commands/merge_pr_github/mod.rs b/src/commands/merge_pr_github/mod.rs index e7a6eaf..4dbb790 100644 --- a/src/commands/merge_pr_github/mod.rs +++ b/src/commands/merge_pr_github/mod.rs @@ -12,6 +12,7 @@ use crate::{ #[derive(Debug)] pub struct MergePrGithubCommand { name: String, + remove_remote_branch: bool, runner: R, } @@ -26,7 +27,15 @@ where R: CommandRunner, { pub fn with_runner(name: String, runner: R) -> Self { - Self { name, runner } + Self { + name, + remove_remote_branch: false, + runner, + } + } + + pub fn enable_remove_remote(&mut self) { + self.remove_remote_branch = true; } pub fn execute(&mut self, repo: &Repo) -> color_eyre::Result<()> { @@ -171,6 +180,10 @@ where } self.restore_worktree_branch(worktree_path, branch)?; + + if self.remove_remote_branch { + self.delete_remote_branch(repo_path, branch)?; + } println!("Merged PR {} for branch `{}`.", pr_label, branch_label); Ok(()) } @@ -192,6 +205,33 @@ where Ok(()) } + + fn delete_remote_branch(&mut self, repo_path: &Path, branch: &str) -> color_eyre::Result<()> { + let args = vec![ + "push".to_owned(), + "origin".to_owned(), + "--delete".to_owned(), + branch.to_owned(), + ]; + + let output = self + .runner + .run("git", repo_path, &args) + .wrap_err("failed to delete remote branch with `git push`")?; + + let branch_label = format_with_color(branch, |text| format!("{}", text.magenta().bold())); + + if !output.success { + if remote_branch_already_gone(&output) { + println!("Remote branch `{}` was already removed.", branch_label); + return Ok(()); + } + return Err(command_failure("git", &args, &output)); + } + + println!("Removed remote branch `{}`.", branch_label); + Ok(()) + } } fn gh_branch_delete_failure(output: &CommandOutput) -> bool { @@ -203,6 +243,15 @@ fn gh_branch_delete_failure(output: &CommandOutput) -> bool { stderr.contains("failed to delete local branch") || stderr.contains("cannot delete branch") } +fn remote_branch_already_gone(output: &CommandOutput) -> bool { + if output.success { + return false; + } + + let combined = format!("{}{}", output.stderr, output.stdout).to_lowercase(); + combined.contains("remote ref does not exist") +} + fn command_failure(program: &str, args: &[String], output: &CommandOutput) -> color_eyre::Report { let command_line = format_command(program, args); let status = match output.status_code { @@ -414,6 +463,216 @@ mod tests { Ok(()) } + #[test] + fn removes_remote_branch_when_requested() -> color_eyre::Result<()> { + let repo_dir = TempDir::new()?; + init_git_repo(&repo_dir)?; + let repo = Repo::discover_from(repo_dir.path())?; + let repo_root = repo.root().to_path_buf(); + let worktree_path = repo.worktrees_dir().join("feature/remove"); + fs::create_dir_all(&worktree_path)?; + + let mut runner = MockCommandRunner::default(); + runner.responses.extend([ + Ok(CommandOutput { + stdout: "feature/remove\n".into(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: "[{\"number\":99}]".into(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: String::new(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: String::new(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: String::new(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + ]); + + let mut command = MergePrGithubCommand::with_runner("feature/remove".into(), runner); + command.enable_remove_remote(); + command.execute(&repo)?; + + assert_eq!( + command.runner.calls, + vec![ + RecordedCall { + program: "git".into(), + dir: worktree_path.clone(), + args: vec!["rev-parse".into(), "--abbrev-ref".into(), "HEAD".into()], + }, + RecordedCall { + program: "gh".into(), + dir: repo_root.clone(), + args: vec![ + "pr".into(), + "list".into(), + "--head".into(), + "feature/remove".into(), + "--state".into(), + "open".into(), + "--json".into(), + "number".into(), + "--limit".into(), + "1".into(), + ], + }, + RecordedCall { + program: "gh".into(), + dir: repo_root.clone(), + args: vec![ + "pr".into(), + "merge".into(), + "99".into(), + "--merge".into(), + "--delete-branch".into(), + ], + }, + RecordedCall { + program: "git".into(), + dir: worktree_path.clone(), + args: vec!["switch".into(), "feature/remove".into()], + }, + RecordedCall { + program: "git".into(), + dir: repo_root, + args: vec![ + "push".into(), + "origin".into(), + "--delete".into(), + "feature/remove".into(), + ], + }, + ] + ); + + Ok(()) + } + + #[test] + fn treat_missing_remote_branch_as_success() -> color_eyre::Result<()> { + let repo_dir = TempDir::new()?; + init_git_repo(&repo_dir)?; + let repo = Repo::discover_from(repo_dir.path())?; + let worktree_path = repo.worktrees_dir().join("feature/missing"); + fs::create_dir_all(&worktree_path)?; + + let mut runner = MockCommandRunner::default(); + runner.responses.extend([ + Ok(CommandOutput { + stdout: "feature/missing\n".into(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: "[{\"number\":7}]".into(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: String::new(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: String::new(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: + "To origin\n - [deleted] feature/missing\nerror: failed to push some refs\n" + .into(), + stderr: "error: unable to delete 'feature/missing': remote ref does not exist\n" + .into(), + success: false, + status_code: Some(1), + }), + ]); + + let mut command = MergePrGithubCommand::with_runner("feature/missing".into(), runner); + command.enable_remove_remote(); + command.execute(&repo)?; + + assert_eq!(command.runner.calls.len(), 5); + + Ok(()) + } + + #[test] + fn surface_remote_branch_deletion_failures() -> color_eyre::Result<()> { + let repo_dir = TempDir::new()?; + init_git_repo(&repo_dir)?; + let repo = Repo::discover_from(repo_dir.path())?; + let worktree_path = repo.worktrees_dir().join("feature/error"); + fs::create_dir_all(&worktree_path)?; + + let mut runner = MockCommandRunner::default(); + runner.responses.extend([ + Ok(CommandOutput { + stdout: "feature/error\n".into(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: "[{\"number\":13}]".into(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: String::new(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: String::new(), + stderr: String::new(), + success: true, + status_code: Some(0), + }), + Ok(CommandOutput { + stdout: String::new(), + stderr: "error: unable to delete branch due to permissions\n".into(), + success: false, + status_code: Some(1), + }), + ]); + + let mut command = MergePrGithubCommand::with_runner("feature/error".into(), runner); + command.enable_remove_remote(); + let result = command.execute(&repo); + assert!( + result.is_err(), + "expected deletion failure to surface as error" + ); + + Ok(()) + } + #[test] fn treats_branch_delete_failure_as_success() -> color_eyre::Result<()> { let repo_dir = TempDir::new()?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c4e32d2..7046a89 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod cd; pub mod create; +pub mod interactive; pub mod list; pub mod merge_pr_github; diff --git a/src/commands/rm/mod.rs b/src/commands/rm/mod.rs index 62cc018..b89f6bf 100644 --- a/src/commands/rm/mod.rs +++ b/src/commands/rm/mod.rs @@ -14,11 +14,21 @@ use crate::commands::cd::SHELL_OVERRIDE_ENV; pub struct RemoveCommand { name: String, force: bool, + quiet: bool, } impl RemoveCommand { pub fn new(name: String, force: bool) -> Self { - Self { name, force } + Self { + name, + force, + quiet: false, + } + } + + pub fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + self } pub fn execute(&self, repo: &Repo) -> color_eyre::Result<()> { @@ -30,10 +40,12 @@ impl RemoveCommand { dir.as_str() .if_supports_color(Stream::Stdout, |text| format!("{}", text.blue())) ); - println!( - "No worktrees directory found at `{}`; nothing to remove.", - dir - ); + if !self.quiet { + println!( + "No worktrees directory found at `{}`; nothing to remove.", + dir + ); + } return Ok(()); } @@ -47,11 +59,13 @@ impl RemoveCommand { .as_str() .if_supports_color(Stream::Stdout, |text| format!("{}", text.cyan())) ); - println!( - "Worktree `{}` does not exist under `{}`.", - name, - worktrees_dir.display() - ); + if !self.quiet { + println!( + "Worktree `{}` does not exist under `{}`.", + name, + worktrees_dir.display() + ); + } return Ok(()); } @@ -65,11 +79,13 @@ impl RemoveCommand { .as_str() .if_supports_color(Stream::Stdout, |text| format!("{}", text.cyan())) ); - println!( - "Worktree `{}` does not exist under `{}`.", - name, - worktrees_dir.display() - ); + if !self.quiet { + println!( + "Worktree `{}` does not exist under `{}`.", + name, + worktrees_dir.display() + ); + } return Ok(()); } }; @@ -106,11 +122,13 @@ impl RemoveCommand { .as_str() .if_supports_color(Stream::Stdout, |text| format!("{}", text.red().bold())) ); - println!( - "Removed worktree `{}` from `{}`.", - name, - worktrees_dir.display() - ); + if !self.quiet { + println!( + "Removed worktree `{}` from `{}`.", + name, + worktrees_dir.display() + ); + } let need_reposition = match std::env::current_dir() { Ok(dir) => { @@ -135,7 +153,9 @@ impl RemoveCommand { .as_str() .if_supports_color(Stream::Stdout, |text| format!("{}", text.blue().bold())) ); - println!("Now in root `{}`.", root_display); + if !self.quiet { + println!("Now in root `{}`.", root_display); + } let (program, args) = shell_command(); let status = Command::new(&program)