From 3dceaa42abd6dbb838e580fe87cc721cb2f2c6ca Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 12 Apr 2026 15:22:08 +0900 Subject: [PATCH 1/6] refactor: app flow into usecases and adapters --- docs/refactoring-plan.md | 1022 +++++++++++++++++++ src/adapters/config/loader.rs | 52 + src/adapters/config/mod.rs | 3 + src/adapters/filesystem/file_copy.rs | 1 + src/adapters/filesystem/mod.rs | 5 + src/adapters/filesystem/ops.rs | 1 + src/adapters/git/git_worktree_repository.rs | 1 + src/adapters/git/mod.rs | 7 + src/adapters/git/repo_discovery.rs | 12 + src/adapters/git/worktree_lock.rs | 1 + src/adapters/hooks/mod.rs | 1 + src/adapters/mod.rs | 6 + src/adapters/shell/editor.rs | 21 + src/adapters/shell/mod.rs | 2 + src/adapters/shell/switch_file.rs | 18 + src/adapters/ui/dialoguer.rs | 1 + src/adapters/ui/mod.rs | 3 + src/app/actions.rs | 57 ++ src/app/menu.rs | 60 ++ src/app/mod.rs | 6 + src/app/presenter.rs | 43 + src/app/run.rs | 65 ++ src/commands/create.rs | 883 +--------------- src/commands/delete.rs | 380 +------ src/commands/list.rs | 702 +------------ src/commands/mod.rs | 2 +- src/commands/rename.rs | 591 +---------- src/commands/shared.rs | 806 +-------------- src/commands/switch.rs | 366 +------ src/domain/branch.rs | 1 + src/domain/mod.rs | 5 + src/domain/paths.rs | 3 + src/domain/repo_context.rs | 249 +++++ src/domain/validation.rs | 1 + src/domain/worktree.rs | 1 + src/lib.rs | 5 + src/main.rs | 381 +------ src/menu.rs | 219 +--- src/repository_info.rs | 322 +----- src/support/mod.rs | 2 + src/support/styles.rs | 8 + src/support/terminal.rs | 53 + src/usecases/cleanup_worktrees.rs | 53 + src/usecases/create_worktree.rs | 779 ++++++++++++++ src/usecases/delete_worktree.rs | 575 +++++++++++ src/usecases/edit_hooks.rs | 110 ++ src/usecases/list_worktrees.rs | 488 +++++++++ src/usecases/mod.rs | 8 + src/usecases/rename_worktree.rs | 520 ++++++++++ src/usecases/search_worktrees.rs | 181 ++++ src/usecases/switch_worktree.rs | 323 ++++++ 51 files changed, 4777 insertions(+), 4628 deletions(-) create mode 100644 docs/refactoring-plan.md create mode 100644 src/adapters/config/loader.rs create mode 100644 src/adapters/config/mod.rs create mode 100644 src/adapters/filesystem/file_copy.rs create mode 100644 src/adapters/filesystem/mod.rs create mode 100644 src/adapters/filesystem/ops.rs create mode 100644 src/adapters/git/git_worktree_repository.rs create mode 100644 src/adapters/git/mod.rs create mode 100644 src/adapters/git/repo_discovery.rs create mode 100644 src/adapters/git/worktree_lock.rs create mode 100644 src/adapters/hooks/mod.rs create mode 100644 src/adapters/mod.rs create mode 100644 src/adapters/shell/editor.rs create mode 100644 src/adapters/shell/mod.rs create mode 100644 src/adapters/shell/switch_file.rs create mode 100644 src/adapters/ui/dialoguer.rs create mode 100644 src/adapters/ui/mod.rs create mode 100644 src/app/actions.rs create mode 100644 src/app/menu.rs create mode 100644 src/app/mod.rs create mode 100644 src/app/presenter.rs create mode 100644 src/app/run.rs create mode 100644 src/domain/branch.rs create mode 100644 src/domain/mod.rs create mode 100644 src/domain/paths.rs create mode 100644 src/domain/repo_context.rs create mode 100644 src/domain/validation.rs create mode 100644 src/domain/worktree.rs create mode 100644 src/support/mod.rs create mode 100644 src/support/styles.rs create mode 100644 src/support/terminal.rs create mode 100644 src/usecases/cleanup_worktrees.rs create mode 100644 src/usecases/create_worktree.rs create mode 100644 src/usecases/delete_worktree.rs create mode 100644 src/usecases/edit_hooks.rs create mode 100644 src/usecases/list_worktrees.rs create mode 100644 src/usecases/mod.rs create mode 100644 src/usecases/rename_worktree.rs create mode 100644 src/usecases/search_worktrees.rs create mode 100644 src/usecases/switch_worktree.rs diff --git a/docs/refactoring-plan.md b/docs/refactoring-plan.md new file mode 100644 index 0000000..3d2e7ac --- /dev/null +++ b/docs/refactoring-plan.md @@ -0,0 +1,1022 @@ +# Git Workers 本体リファクタリング計画 + +## 目的 + +このドキュメントは、`git-workers` の本体コードを安全にリファクタリングするための実装計画書です。 + +重要: + +- 現在の動作を 100% 数学的に保証することはできない +- その代わり、この計画では「現在の動作を壊す変更は merge させない」ためのガードレールを最大化する +- 挙動差分の疑いが少しでも出た場合は、先へ進まず test 追加または計画修正を優先する + +今回の目的は次の 4 つです。 + +1. `main` を薄くし、`menu loop` と command dispatch を application 層へ移す +2. `commands` に混在している `UI / use case / pure logic / side effect` を分離する +3. 巨大化した `infrastructure/git.rs` を責務ごとに分割する +4. 既存の挙動と `public API` を段階的に維持したまま、将来の機能追加と refactor を容易にする + +この計画は「構造変更」と「動作変更」を明確に分離して進める前提で作成する。 + +最初の実装では、`constants.rs` と `ui.rs` の全面移設は行わない。これらは参照箇所が広く、早い段階で動かすと diff が不要に大きくなるため、まずは `app / usecases / adapters` の責務境界を作ることを優先する。 + +## スコープ + +対象: + +- `src/main.rs` +- `src/lib.rs` +- `src/menu.rs` +- `src/commands/*.rs` +- `src/core/*.rs` +- `src/config.rs` +- `src/repository_info.rs` +- `src/utils.rs` +- `src/ui.rs` +- `src/git_interface.rs` +- `src/infrastructure/*.rs` + +非対象: + +- shell wrapper 自体の挙動変更 +- 新機能追加 +- CLI 引数仕様の拡張 +- hook の仕様変更 +- config format の互換性破壊 + +## 最重要原則: 挙動固定 + +このリファクタリングの最優先事項は、現在の挙動を固定したまま内部構造だけを改善することである。 + +そのため、初期フェーズでは次を必須ルールとする。 + +1. 構造変更 PR では、既存 test の期待値を変更しない +2. 構造変更 PR では、ユーザー向け文言を変更しない +3. 構造変更 PR では、hook 実行順・switch file 書き込み順・config 探索順を変更しない +4. 既存 test で守れていない挙動に触る前には、先に test を追加する +5. 差分の安全性を説明できない変更は commit しない + +## 保証の定義 + +この計画でいう「現在の動作を担保する」とは、少なくとも次を満たす状態を指す。 + +- 既存の public API 呼び出し結果が変わらない +- 既存 test suite が green のまま維持される +- command ごとの主要フローで観測可能な side effect が変わらない +- shell integration, hook, config discovery, repository info 表示の契約が変わらない + +観測可能な side effect には次を含む。 + +- return value +- error の有無 +- worktree / branch / path の最終状態 +- `GW_SWITCH_FILE` への書き込み +- hook の発火有無と順序 +- config file の探索結果 +- list / switch / create / delete / rename のユーザー向け表示 + +## 挙動固定のための非交渉ルール + +以下は妥協しない。 + +- `red test` のまま次フェーズへ進まない +- `構造変更` と `動作変更` を同じ PR に混ぜない +- test で未固定の挙動に触る場合、先に回帰 test を足す +- 「たぶん同じ動作」の推測で merge しない +- 差分説明で `internal change only` と書く PR は、観測可能な差分が 0 であることを test で示す + +## 現状診断 + +### 1. `main.rs` が厚い + +現在の [main.rs](/Users/a12622/git/git-workers/src/main.rs) は次の責務を同時に持つ。 + +- `clap` による引数処理 +- terminal 初期化 +- 画面クリア +- header 表示 +- menu item 構築 +- menu 選択処理 +- command dispatch +- `switch` 後の特別な exit 制御 + +このため、`CLI entry point` の変更が UI 全体や command 呼び出しと強く結びついている。 + +### 2. `commands` が use case と adapter を兼務している + +現在の `commands` 層は、例えば [create.rs](/Users/a12622/git/git-workers/src/commands/create.rs) で次を同時に実施している。 + +- prompt 文言の表示 +- `DialoguerUI` の呼び出し +- validation +- worktree path 判定 +- Git 操作 orchestration +- hook 実行 +- file copy +- switch file 書き込み +- progress 表示 + +同様の混在は [delete.rs](/Users/a12622/git/git-workers/src/commands/delete.rs), [rename.rs](/Users/a12622/git/git-workers/src/commands/rename.rs), [switch.rs](/Users/a12622/git/git-workers/src/commands/switch.rs), [list.rs](/Users/a12622/git/git-workers/src/commands/list.rs), [shared.rs](/Users/a12622/git/git-workers/src/commands/shared.rs) に広く存在する。 + +### 3. `shared.rs` が責務の受け皿になっている + +[shared.rs](/Users/a12622/git/git-workers/src/commands/shared.rs) には次が同居している。 + +- search +- batch delete +- cleanup +- hook editor 起動 +- config path 探索 +- worktree icon 表示 + +これは `共通化` というより `分類待ちの処理の集積` に近く、変更時の見通しが悪い。 + +### 4. `infrastructure/git.rs` が巨大で、内部責務が曖昧 + +[git.rs](/Users/a12622/git/git-workers/src/infrastructure/git.rs) は巨大であり、少なくとも次の責務を含む。 + +- repository open / discovery +- worktree list / create / remove / rename +- branch 操作 +- tag 取得 +- path / parent 判定 +- lock file 管理 +- 表示補助に近い情報集約 + +これは `Git adapter` と `domain 判断` と `運用上の便宜` が 1 箇所に集まっている状態である。 + +### 5. `config` と `repository_info` に command 実行が散っている + +[config.rs](/Users/a12622/git/git-workers/src/config.rs) と [repository_info.rs](/Users/a12622/git/git-workers/src/repository_info.rs) は、設定探索や repository context 判定のために `git` command を直接実行している。 + +その結果、 + +- path 探索ロジック +- repository type 判定 +- 表示向け文字列整形 + +が密結合している。 + +### 6. `public API` は既に広い + +[lib.rs](/Users/a12622/git/git-workers/src/lib.rs) と [commands/mod.rs](/Users/a12622/git/git-workers/src/commands/mod.rs) は広めの re-export を提供しており、test もこれに依存している。 + +特に次の API は互換維持の影響が大きい。 + +- `git_workers::commands::*` +- `git_workers::git::GitWorktreeManager` +- `git_workers::repository_info::get_repository_info` +- `git_workers::commands::find_config_file_path` + +### 7. 現在の挙動を十分に固定できていない箇所がまだある + +既存 test suite は強くなってきているが、リファクタリングの安全網としては次の契約をさらに明文化する余地がある。 + +- command ごとの標準フローの表示 +- hook 実行順 +- config 探索順 +- repository info 表示の完全な分岐 +- list 表示順と marker + +したがって、実装前に「この refactor で絶対に壊したくない挙動」を test で固定する作業を継続する。 + +## リファクタリング方針 + +### 基本原則 + +1. 最初の数 PR は `構造変更のみ` とし、挙動変更を混ぜない +2. 旧 API はいったん `facade` と `re-export` で維持する +3. use case 単位で module を移し、毎回 test を通す +4. `UI` と `Git` の両方を直接触る関数は減らす +5. `pure function` は `domain` か `support` へ移し、I/O から切り離す + +### 完了後に目指す構造 + +```text +src/ + main.rs + lib.rs + + app/ + mod.rs + run.rs + menu.rs + actions.rs + presenter.rs + + domain/ + mod.rs + worktree.rs + branch.rs + repo_context.rs + paths.rs + validation.rs + + usecases/ + mod.rs + create_worktree.rs + delete_worktree.rs + rename_worktree.rs + switch_worktree.rs + list_worktrees.rs + search_worktrees.rs + cleanup_worktrees.rs + edit_hooks.rs + + adapters/ + mod.rs + git/ + mod.rs + git_worktree_repository.rs + repo_discovery.rs + worktree_lock.rs + ui/ + mod.rs + dialoguer.rs + config/ + mod.rs + loader.rs + shell/ + mod.rs + switch_file.rs + editor.rs + filesystem/ + mod.rs + file_copy.rs + ops.rs + + support/ + mod.rs + terminal.rs + styles.rs +``` + +補足: + +- `src/constants.rs` は中盤以降まで現位置維持でよい +- `src/ui.rs` も trait の互換維持のため、しばらく facade として残してよい +- `src/core/*.rs` も中盤までは現位置維持でよく、最初は rename より依存の薄化を優先する + +## 設計原則 + +### `app` + +役割: + +- 対話フロー +- menu loop +- command dispatch +- 表示文字列の composition + +禁止事項: + +- Git repository への直接アクセス +- config file 探索 +- branch / path の判断ロジック + +### `usecases` + +役割: + +- 1 つのユーザー操作に対応する orchestration +- `input -> decision -> side effect -> result` の流れをまとめる + +禁止事項: + +- `println!` ベタ書き +- `dialoguer` の直接使用 +- `git2` の直接使用 + +移行期ルール: + +- PR 3 から PR 5 の間は、既存コードを安全に移すため `UserInterface` trait と既存 terminal helper への依存を一時許容する +- ただし新規に `dialoguer` や `git2` を `usecases` へ直接持ち込まない +- 最終的な純化は `presenter` と adapter 境界が揃った後に行う + +### `domain` + +役割: + +- 純粋な validation +- path 解決 +- rename / delete / switch 可否の判定 +- repository context の model 化 + +禁止事項: + +- file I/O +- process 実行 +- terminal 出力 + +補足: + +- hook 実行は `domain` に置かない +- hook の event 名や payload の model 化だけが必要なら `domain` に置けるが、実行本体は `adapters` か `usecases` に置く + +### `adapters` + +役割: + +- `git2`, `std::process::Command`, `dialoguer`, filesystem などの外部依存 + +禁止事項: + +- UI フローの全体制御 +- 複数ユースケースにまたがる分岐の保持 + +### `support` + +役割: + +- constants +- terminal helper +- style helper +- 共通の小さな util + +## 旧構造から新構造への対応表 + +| 現在 | 移行先 | 備考 | +| --- | --- | --- | +| `src/main.rs` | `src/app/run.rs` + 薄い `src/main.rs` | `main` は entry point に限定 | +| `src/menu.rs` | `src/app/menu.rs` | menu 定義のみ保持 | +| `src/commands/create.rs` | `src/usecases/create_worktree.rs` + `src/core/validation.rs` + `src/domain/paths.rs` | 初期フェーズでは `core` を残す | +| `src/commands/delete.rs` | `src/usecases/delete_worktree.rs` | batch delete と共通化候補あり | +| `src/commands/rename.rs` | `src/usecases/rename_worktree.rs` | rename 判定は `domain` へ | +| `src/commands/switch.rs` | `src/usecases/switch_worktree.rs` | shell integration は adapter へ | +| `src/commands/list.rs` | `src/usecases/list_worktrees.rs` + `src/app/presenter.rs` | table 表示を presenter 化 | +| `src/commands/shared.rs` | 複数ファイルへ解体 | そのまま残さない | +| `src/config.rs` | `src/adapters/config/loader.rs` + `Config` 定義残留 or 後続で model 分離 | loader 分離が先 | +| `src/repository_info.rs` | `src/domain/repo_context.rs` + `src/app/presenter.rs` | 判定と表示を分離 | +| `src/utils.rs` | `src/support/terminal.rs` + `src/adapters/shell/switch_file.rs` | 小さく分けるが、初手では一括移動しない | +| `src/ui.rs` | `src/adapters/ui/dialoguer.rs` + `src/ui.rs` 互換 facade | trait は当面 `src/ui.rs` から見せ続ける | +| `src/git_interface.rs` | `src/adapters/git/` に寄せる or 互換 facade | 現状は活用度が低いので後回し可 | +| `src/infrastructure/git.rs` | `src/adapters/git/*.rs` | 最初は facade 維持 | + +## 実装フェーズ + +### Phase 0: 前提固定 + +目的: + +- 以後の refactor に対する安全網を固定する + +実施: + +- 現在の test suite を baseline とする +- `cargo test --all-features -- --test-threads=1` を基準コマンドとして固定 +- 既存 public API 依存を一覧化して、削除禁止対象を明示 +- command 契約を追加で固定する必要がある箇所を洗い出す +- `構造変更前の観測可能な挙動` を先に test 化する + +完了条件: + +- baseline test が green +- 本ドキュメントに互換維持対象を明記済み +- refactor 対象 command の挙動について、少なくとも主要フローの契約 test がある + +### Phase 1: 受け皿 module 作成 + +目的: + +- 新しい module 構造を追加し、実体の移動前に依存先を用意する + +実施: + +- `src/app/mod.rs` +- `src/domain/mod.rs` +- `src/usecases/mod.rs` +- `src/adapters/mod.rs` +- `src/support/mod.rs` + +この段階では、新規 module は空に近い状態でよい。`pub mod` と最小限の `pub use` のみを配置する。 + +注意: + +- `src/constants.rs` はまだ動かさない +- `src/ui.rs` はまだ動かさない +- `src/core/*.rs` はまだ rename しない +- `src/infrastructure/mod.rs` の公開面は維持する + +完了条件: + +- build が通る +- 既存 import path を壊さない +- 追加した受け皿 module が runtime behavior に影響していないことを test で確認できる + +### Phase 2: `main` の簡素化 + +目的: + +- `main.rs` から loop と dispatch を外す + +実施: + +- `src/app/run.rs` を作成 +- `MenuAction` を `app` 配下へ移す +- menu loop を `app::run()` へ移す +- [main.rs](/Users/a12622/git/git-workers/src/main.rs) は次だけを持つ + - CLI parse + - `--version` + - `app::run()` + +この phase では command の実体や import path は変えない。移すのは loop と dispatch だけに限定する。 + +完了条件: + +- 挙動不変 +- `main.rs` が薄くなる +- `main.rs` の test 不要部分が減る +- 起動後の menu 表示順、exit 挙動、`switch` 後の終了動作が一致する + +### Phase 3: menu と dispatch の整理 + +目的: + +- menu 表示定義と action 実行を分離する + +実施: + +- [menu.rs](/Users/a12622/git/git-workers/src/menu.rs) を `app/menu.rs` へ移す +- `MenuItem` に対応する action dispatch を `app/actions.rs` へ分離 +- menu 表示文字列と実行関数の対応を 1 箇所に集約 + +完了条件: + +- menu item の追加時に修正箇所が明確 +- `main` と command module の直接結合が減る + +### Phase 4: `shared.rs` の解体 + +目的: + +- いちばん曖昧な file をなくす + +分解先: + +- `search_worktrees` -> `usecases/search_worktrees.rs` +- `batch_delete_worktrees` -> `usecases/delete_worktree.rs` or `usecases/batch_delete_worktrees.rs` +- `cleanup_old_worktrees` -> `usecases/cleanup_worktrees.rs` +- `edit_hooks` -> `usecases/edit_hooks.rs` +- `find_config_file_path` -> 当面 `commands/shared.rs` から facade しつつ、移設先は `adapters/config/loader.rs` +- `get_worktree_icon` -> presenter 導入後に `app/presenter.rs` + +順序ルール: + +- `shared.rs` は一気に消さず、先に移設先 module を作ってから機能ごとに縮退させる +- `presenter` 未導入の段階では `get_worktree_icon` だけを無理に動かさない +- `config loader` 未導入の段階では `find_config_file_path` を facade 経由で保持する + +完了条件: + +- `shared.rs` が空になるか削除可能になる +- 新しい module 名だけで責務が推測できる +- 移設した責務について、移設前後で観測可能な挙動差分がない + +### Phase 5: command ごとの use case 化 + +目的: + +- `commands` を `usecases` へ段階移行する + +実施順: + +1. `switch` +2. `list` +3. `delete` +4. `rename` +5. `create` + +理由: + +- `switch` は比較的小さく、shell integration 切り出しの起点になる +- `list` は表示責務の分離に向く +- `delete` と `rename` は中規模で、共通の pattern を確立しやすい +- `create` は副作用が最も多く、最後に回す方が安全 + +完了条件: + +- 各旧 file は `pub use` の facade に縮退できる +- `usecases` が UI 非依存の構造へ寄る + +### Phase 6: `GitWorktreeManager` の分割 + +目的: + +- 巨大な Git adapter を責務ごとに小さくする + +第一段階: + +- `worktree_lock.rs` +- `repo_discovery.rs` +- `git_worktree_repository.rs` + +第二段階: + +- branch 操作 +- tag 取得 +- status / metadata 集約 + +方針: + +- `GitWorktreeManager` 自体の型名はしばらく維持する +- 実装を内側 module へ委譲し、利用側の breakage を防ぐ + +完了条件: + +- `infrastructure/git.rs` が facade レベルまで縮小 +- 各責務ごとに unit test 可能 + +### Phase 7: `config / repository_info / shell integration` 分離 + +目的: + +- repository context と表示を切り離す +- shell integration を adapter として独立させる + +実施: + +- `find_config_file_path*` を `adapters/config/loader.rs` へ +- `get_repository_info*` の判定ロジックを `domain/repo_context.rs` へ +- 表示用フォーマットを `app/presenter.rs` へ +- `write_switch_path` を `adapters/shell/switch_file.rs` へ +- editor 起動を `adapters/shell/editor.rs` へ + +完了条件: + +- context 判定ロジックを pure に近づけられる +- shell 依存部が局所化される + +### Phase 8: 公開 API の整理 + +目的: + +- 互換性を維持しつつ、今後の公開境界を明確化する + +実施: + +- [lib.rs](/Users/a12622/git/git-workers/src/lib.rs) の re-export を棚卸し +- 内部専用 API を `pub(crate)` 化 +- 互換維持が必要なものは deprecation comment を付けて facade 経由にする + +完了条件: + +- `lib.rs` から見て「何が public API か」が読みやすい +- `commands` と `infrastructure` の将来的な縮退余地ができる + +## フェーズごとの PR 推奨分割 + +### PR 1 + +対象: + +- `app / domain / usecases / adapters / support` の受け皿追加 +- `main.rs` の簡素化 +- `menu` と dispatch の切り出し + +具体的に触る file の目安: + +- `src/main.rs` +- `src/lib.rs` +- `src/menu.rs` +- `src/app/mod.rs` +- `src/app/run.rs` +- `src/app/menu.rs` +- `src/app/actions.rs` + +種類: + +- 構造変更のみ + +### PR 2 + +対象: + +- `shared.rs` の解体 +- `search / batch delete / cleanup / edit hooks / config path` の再配置 + +種類: + +- 構造変更のみ + +### PR 3 + +対象: + +- `switch` と `list` の use case 化 +- `switch_file` adapter 導入 +- presenter 導入 + +種類: + +- 構造変更のみ + +### PR 4 + +対象: + +- `delete` と `rename` の use case 化 + +種類: + +- 構造変更中心 + +### PR 5 + +対象: + +- `create` の use case 化 +- `file_copy / hooks / branch source` まわりの境界整理 + +種類: + +- 構造変更中心 + +### PR 6 + +対象: + +- `GitWorktreeManager` の内部分割 +- `config / repository_info` の分離 + +種類: + +- 構造変更中心 + +## 互換維持ルール + +以下は初期フェーズでは壊さない。 + +- `git_workers::commands` module path +- `git_workers::commands::*` +- `git_workers::git` module path +- `git_workers::git::GitWorktreeManager` +- `git_workers::git::WorktreeInfo` +- `git_workers::infrastructure` module path +- `git_workers::infrastructure::git::GitWorktreeManager` +- `git_workers::infrastructure::WorktreeInfo` +- `git_workers::hooks` module path +- `git_workers::file_copy` module path +- `git_workers::filesystem` module path +- `git_workers::repository_info::get_repository_info` +- `git_workers::repository_info::get_repository_info_at_path` +- `git_workers::commands::find_config_file_path` +- `git_workers::ui` module path +- `git_workers::config` module path +- `git_workers::menu` module path +- `git_workers::core` module path +- 既存 test が import している型と関数名 + +方針: + +- module path と名前を残す +- 実体だけ新しい module に移す +- import path の変更は最後にまとめて行う + +## 実装ルール + +### 1. 先に facade を作る + +例: + +- `commands/switch.rs` はいきなり削除しない +- 中身を `usecases/switch_worktree.rs` に移し、`commands/switch.rs` は再公開のみへ縮退させる + +### 2. pure logic は先に切り出す + +候補: + +- validation +- rename / delete 可否判定 +- path resolve +- display sort + +補足: + +- 既存の [core/validation.rs](/Users/a12622/git/git-workers/src/core/validation.rs) は、当面 `domain` の最終形に無理に移さず、まず `commands` からの依存を薄くすることを優先する +- `core -> domain` の rename は中盤以降でもよい +- `core` を無理に rename しないことで、初期 PR の diff と import 変更を抑える + +### 3. side effect を adapter に寄せる + +候補: + +- `dialoguer` +- `git2` +- `Command::new` +- `write_switch_path` +- editor 起動 +- file copy + +補足: + +- hook 実行も side effect として扱い、最終的には `adapters` または adapter を呼ぶ `usecases` 側へ寄せる +- hook 実行本体を `domain` に置かない + +### 4. 文言整理は構造変更と分ける + +理由: + +- test failure の原因切り分けを容易にするため + +## test 戦略 + +各 PR で最低限行うこと: + +- `cargo test --all-features -- --test-threads=1` + +必要に応じて追加: + +- command 単位の targeted test +- `cargo fmt --check` +- `cargo clippy --all-features -- -D warnings` + +重点監視対象: + +- `tests/custom_path_behavior_test.rs` +- `tests/unit/commands/*.rs` +- `tests/integration/worktree_lifecycle.rs` +- `tests/integration/repository_info_display_test.rs` +- `tests/unit/infrastructure/git.rs` + +追加で強化対象: + +- `main` loop と menu dispatch の契約 test +- `search / switch / list` の表示契約 test +- `find_config_file_path` の探索順契約 test +- `repository_info` の分岐ごとの表示契約 test +- `create` の hook / file copy / switch file の順序契約 test + +### test gate + +各 PR は、少なくとも次の gate をすべて満たしたときのみ次へ進める。 + +1. `cargo fmt --check` +2. `cargo test --all-features -- --test-threads=1` +3. その PR で触った責務に対応する targeted test の成功 + +必要なら追加で行うこと: + +- `cargo clippy --all-features -- -D warnings` +- 実際の CLI 手動確認 + +### 変更前後比較の原則 + +構造変更 PR では、変更前後で次を比較する。 + +- 公開関数の return value +- file system side effect +- config discovery result +- hook side effect +- 表示文字列 + +比較を自動化できるものは test で自動化し、自動化しにくいものは PR の説明に比較観点を明記する。 + +## 想定リスク + +### 1. re-export の循環依存 + +リスク: + +- `commands -> usecases -> adapters -> lib re-export` の循環が起きる + +対策: + +- `crate::` の参照方向を最初に統一する +- `lib.rs` は末端で re-export するだけに寄せる + +### 2. `GitWorktreeManager` 分割時の可視性崩れ + +リスク: + +- internal helper を移した瞬間に `pub(crate)` 不足で compile error が大量発生する + +対策: + +- 先に facade method を残す +- 内部 module に切り出すのは method 単位で行う + +### 3. `repository_info` の挙動差分 + +リスク: + +- bare / worktree / regular repo の表示が subtle に変わる + +対策: + +- 既存 integration test を保持 +- 文字列整形と context 判定を分ける + +### 4. `create` の副作用順序崩れ + +リスク: + +- hook 実行順 +- file copy の source 判定 +- switch file 書き込みのタイミング + +対策: + +- `create` は最後に移す +- 現行の回帰 test を増やした上で着手する + +### 5. 最終形の理想を先に入れすぎて移行 PR が膨らむ + +リスク: + +- `core -> domain` +- `ui -> adapters/ui` +- `utils -> support` + +を同時に進めて、構造変更だけのはずが大規模 rename になる + +対策: + +- 初期 PR は rename より facade と委譲を優先する +- `core`, `ui`, `constants` は据え置き前提で進める +- 「最終配置」と「今回動かす範囲」を毎 PR で分けて記述する + +### 6. 「構造変更だけ」のつもりで観測可能な差分を入れてしまう + +リスク: + +- 表示順 +- error message +- hook 実行順 +- config 探索順 + +のような契約を unintentionally 変える + +対策: + +- 触る前に契約 test を足す +- 観測可能な差分がありうる箇所は PR 説明で列挙する +- 少しでも差分が疑わしい場合は、その PR を `構造変更` ではなく `動作変更` に格上げする + +## anti-goals + +この計画では、次をやらない。 + +- 1 PR で 2 つ以上の大きな責務移行を同時に完了させようとしない +- 構造変更 PR で文言整理や error message 変更をしない +- facade を外す前に import path の一括変更をしない +- `GitWorktreeManager` の型名を early phase で変えない +- shell integration の仕様を変えない + +## 各 PR の Definition of Done + +### PR 1 DoD + +- `main.rs` が entry point だけになっている +- menu loop と dispatch が `app` 配下へ移っている +- 既存の menu 表示順と `switch` 後の終了挙動が一致している +- `cargo test --all-features -- --test-threads=1` が green +- `main` の変更前後で観測可能な動作差分がないことを説明できる + +### PR 2 DoD + +- `shared.rs` から少なくとも 2 つ以上の明確な責務が移設されている +- `shared.rs` に新しい関数を追加していない +- `find_config_file_path` と `search / cleanup / hooks` の移設先が文書化されている +- `cargo test --all-features -- --test-threads=1` が green + +### PR 3-5 DoD + +- 対象 command file が facade 化または薄い wrapper 化されている +- `usecases` 側に移った orchestration が test で保護されている +- 直接の `dialoguer` / `git2` 依存を増やしていない +- `cargo test --all-features -- --test-threads=1` が green + +### PR 6 DoD + +- `GitWorktreeManager` は型名維持のまま内部分割されている +- `config / repository_info / shell` の adapter 境界が読み取れる +- 既存 module path の互換が維持されている +- `cargo test --all-features -- --test-threads=1` が green + +## レビュー観点の強化版 + +各 PR のレビューでは、通常の動作確認に加えて次を見る。 + +1. 依存方向が一方向になっているか +2. facade が暫定措置として薄いか +3. 構造変更 PR に挙動変更が混ざっていないか +4. import path 互換と module path 互換の両方が守られているか +5. 次の PR でさらに削れる場所が増えているか + +## rollback 方針 + +もし途中の PR で設計が合わないと判明した場合は、次の順で戻す。 + +1. facade を残したまま新しい module の利用だけ止める +2. `app` や `usecases` に移した実体を旧 module に戻すのではなく、旧 module から再委譲する +3. import path を戻さず、実体配置だけ調整する + +この方針により、revert しても外部 API の揺れを最小化できる。 + +## 100 点に近づけるための運用ルール + +この計画を 70 点から 100 点に近づけるため、実装時は次を守る。 + +1. 各 PR の冒頭に「今回動かす責務 / 動かさない責務」を明記する +2. 各 PR で facade を 1 段階以上薄くする +3. 新しい module を作るたびに、その module が直接依存してよいものを明記する +4. `final shape` ではなく `next safe step` を優先して commit を切る +5. 迷ったときは rename より wrapper 化、wrapper 化より委譲の明確化を優先する +6. 挙動固定の根拠を test 名で言えない変更は、まだ安全ではないとみなす +7. `保証` という言葉は、test と比較観点で裏づけできるときだけ使う + +## 実装開始前チェックリスト + +- [ ] baseline test が green +- [ ] 互換維持対象 API を再確認した +- [ ] 今回の PR が `構造変更` か `動作変更` か明記した +- [ ] 新しい module 名が責務を表している +- [ ] `shared` のような曖昧名を増やしていない +- [ ] `main.rs` に新しい責務を戻していない +- [ ] 新しい code path に最低 1 つ以上の test がある + +## PR 1 の実装タスク詳細 + +### タスク 1: `app` module 追加 + +- `src/app/mod.rs` を追加する +- `run`, `menu`, `actions` を公開する +- ここでは business logic を持たせない + +### タスク 2: `app::menu` 新設 + +- `src/app/menu.rs` を追加する +- 既存の `MenuItem` をここへ移す +- [src/menu.rs](/Users/a12622/git/git-workers/src/menu.rs) は一時的に `pub use crate::app::menu::*;` の facade にする + +### タスク 3: `app::actions` 新設 + +- `handle_menu_item` と `MenuAction` を `src/app/actions.rs` へ移す +- 既存 command 呼び出しの順序と分岐は変えない +- `ExitAfterSwitch` の扱いも現状維持する + +### タスク 4: `app::run` 新設 + +- menu loop 全体を `src/app/run.rs` へ移す +- header 表示と screen clear もここで扱う +- `DialoguerUI` の生成位置はこの phase では維持でよい + +### タスク 5: `main.rs` を薄くする + +- `Cli` の parse +- `--version` 処理 +- `app::run()` 呼び出し + +ここまでに縮小する。 + +### タスク 6: 互換 export を整える + +- [src/lib.rs](/Users/a12622/git/git-workers/src/lib.rs) で `pub mod app;` を追加する +- ただし既存の `commands`, `infrastructure`, `repository_info` の export は変更しない + +### タスク 7: 検証 + +- `cargo fmt` +- `cargo test --all-features -- --test-threads=1` + +## PR 1 のレビュー用チェックポイント + +1. [src/main.rs](/Users/a12622/git/git-workers/src/main.rs) に loop や dispatch が残っていないか +2. `MenuItem` の表示順と表示文字列が変わっていないか +3. `switch` 実行後の終了挙動が変わっていないか +4. 既存 test の import path が壊れていないか +5. `app` が command 実装詳細に依存しすぎていないか + +## 最初の実装対象 + +最初に着手する作業は次の組み合わせが最も安全である。 + +1. `src/app/mod.rs` と `src/app/run.rs` を追加 +2. `main.rs` の menu loop を `app::run()` へ移動 +3. `src/app/menu.rs` を作り、既存 `menu.rs` を facade 化 +4. `src/app/actions.rs` に command dispatch を移動 + +この 4 点なら、比較的少ない file 変更で構造改善の土台を作れる。 + +## レビュー観点 + +レビューでは、次を優先して見る。 + +1. 挙動差分がないか +2. 依存方向が改善しているか +3. 新しい module 名が今後も耐えられるか +4. facade が一時対応として妥当か +5. 次の PR でさらに分解しやすくなっているか + +## 保留事項 + +この計画では、以下は意図的に後回しにする。 + +- 非対話 CLI mode の導入 +- trait ベースの use case 入力モデル再設計 +- 全 message / constants の再編成 +- `git_interface.rs` の全面見直し +- benchmark / debug test の配置整理 + +これらは本体の責務整理が終わってからの方が安全である。 diff --git a/src/adapters/config/loader.rs b/src/adapters/config/loader.rs new file mode 100644 index 0000000..9e5999a --- /dev/null +++ b/src/adapters/config/loader.rs @@ -0,0 +1,52 @@ +use anyhow::{anyhow, Result}; + +use crate::constants::{CONFIG_FILE_NAME, GIT_DIR}; +use crate::git::GitWorktreeManager; + +pub use crate::config::Config; + +pub fn find_config_file_path(manager: &GitWorktreeManager) -> Result { + find_config_file_path_internal(manager.repo()) +} + +pub fn find_config_file_path_internal(repo: &git2::Repository) -> Result { + if repo.is_bare() { + if let Ok(cwd) = std::env::current_dir() { + let current_config = cwd.join(CONFIG_FILE_NAME); + if current_config.exists() { + return Ok(current_config); + } + + Ok(cwd.join(CONFIG_FILE_NAME)) + } else { + Err(anyhow!("Cannot determine current directory")) + } + } else if let Ok(cwd) = std::env::current_dir() { + let current_config = cwd.join(CONFIG_FILE_NAME); + if current_config.exists() { + return Ok(current_config); + } + + if let Some(workdir) = repo.workdir() { + let workdir_path = workdir.to_path_buf(); + + if cwd == workdir_path { + return Ok(workdir_path.join(CONFIG_FILE_NAME)); + } + + let git_path = workdir_path.join(GIT_DIR); + if git_path.is_dir() && workdir_path.exists() { + let config_path = workdir_path.join(CONFIG_FILE_NAME); + if config_path.exists() { + return Ok(config_path); + } + } + } + + Ok(cwd.join(CONFIG_FILE_NAME)) + } else { + repo.workdir() + .map(|path| path.join(CONFIG_FILE_NAME)) + .ok_or_else(|| anyhow!("No working directory found")) + } +} diff --git a/src/adapters/config/mod.rs b/src/adapters/config/mod.rs new file mode 100644 index 0000000..aa9f0cf --- /dev/null +++ b/src/adapters/config/mod.rs @@ -0,0 +1,3 @@ +pub mod loader; + +pub use loader::{find_config_file_path, find_config_file_path_internal}; diff --git a/src/adapters/filesystem/file_copy.rs b/src/adapters/filesystem/file_copy.rs new file mode 100644 index 0000000..2b629d2 --- /dev/null +++ b/src/adapters/filesystem/file_copy.rs @@ -0,0 +1 @@ +pub use crate::infrastructure::file_copy::copy_configured_files; diff --git a/src/adapters/filesystem/mod.rs b/src/adapters/filesystem/mod.rs new file mode 100644 index 0000000..d9fdf5e --- /dev/null +++ b/src/adapters/filesystem/mod.rs @@ -0,0 +1,5 @@ +pub mod file_copy; +pub mod ops; + +pub use file_copy::copy_configured_files; +pub use ops::{FileSystem, RealFileSystem}; diff --git a/src/adapters/filesystem/ops.rs b/src/adapters/filesystem/ops.rs new file mode 100644 index 0000000..56526ca --- /dev/null +++ b/src/adapters/filesystem/ops.rs @@ -0,0 +1 @@ +pub use crate::infrastructure::filesystem::{FileSystem, RealFileSystem}; diff --git a/src/adapters/git/git_worktree_repository.rs b/src/adapters/git/git_worktree_repository.rs new file mode 100644 index 0000000..ac7ef3d --- /dev/null +++ b/src/adapters/git/git_worktree_repository.rs @@ -0,0 +1 @@ +pub use crate::infrastructure::git::{GitWorktreeManager, WorktreeInfo}; diff --git a/src/adapters/git/mod.rs b/src/adapters/git/mod.rs new file mode 100644 index 0000000..f544a17 --- /dev/null +++ b/src/adapters/git/mod.rs @@ -0,0 +1,7 @@ +pub mod git_worktree_repository; +pub mod repo_discovery; +pub mod worktree_lock; + +pub use git_worktree_repository::{GitWorktreeManager, WorktreeInfo}; +pub use repo_discovery::{open_repository_at_path, open_repository_from_env}; +pub use worktree_lock::WorktreeLock; diff --git a/src/adapters/git/repo_discovery.rs b/src/adapters/git/repo_discovery.rs new file mode 100644 index 0000000..eac6304 --- /dev/null +++ b/src/adapters/git/repo_discovery.rs @@ -0,0 +1,12 @@ +use anyhow::Result; +use std::path::Path; + +use crate::adapters::git::git_worktree_repository::GitWorktreeManager; + +pub fn open_repository_from_env() -> Result { + GitWorktreeManager::new() +} + +pub fn open_repository_at_path(path: &Path) -> Result { + GitWorktreeManager::new_from_path(path) +} diff --git a/src/adapters/git/worktree_lock.rs b/src/adapters/git/worktree_lock.rs new file mode 100644 index 0000000..28554a1 --- /dev/null +++ b/src/adapters/git/worktree_lock.rs @@ -0,0 +1 @@ +pub use crate::infrastructure::git::WorktreeLock; diff --git a/src/adapters/hooks/mod.rs b/src/adapters/hooks/mod.rs new file mode 100644 index 0000000..3d0c3f3 --- /dev/null +++ b/src/adapters/hooks/mod.rs @@ -0,0 +1 @@ +pub use crate::hooks::{execute_hooks, execute_hooks_with_ui, HookContext}; diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs new file mode 100644 index 0000000..e594c44 --- /dev/null +++ b/src/adapters/mod.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod filesystem; +pub mod git; +pub mod hooks; +pub mod shell; +pub mod ui; diff --git a/src/adapters/shell/editor.rs b/src/adapters/shell/editor.rs new file mode 100644 index 0000000..ddbba47 --- /dev/null +++ b/src/adapters/shell/editor.rs @@ -0,0 +1,21 @@ +use std::process::Command; + +use anyhow::Result; + +use crate::constants::{DEFAULT_EDITOR_UNIX, DEFAULT_EDITOR_WINDOWS, ENV_EDITOR, ENV_VISUAL}; + +pub fn preferred_editor() -> String { + std::env::var(ENV_EDITOR) + .or_else(|_| std::env::var(ENV_VISUAL)) + .unwrap_or_else(|_| { + if cfg!(target_os = "windows") { + DEFAULT_EDITOR_WINDOWS.to_string() + } else { + DEFAULT_EDITOR_UNIX.to_string() + } + }) +} + +pub fn open_in_editor(path: &std::path::Path) -> Result { + Ok(Command::new(preferred_editor()).arg(path).status()?) +} diff --git a/src/adapters/shell/mod.rs b/src/adapters/shell/mod.rs new file mode 100644 index 0000000..b2480aa --- /dev/null +++ b/src/adapters/shell/mod.rs @@ -0,0 +1,2 @@ +pub mod editor; +pub mod switch_file; diff --git a/src/adapters/shell/switch_file.rs b/src/adapters/shell/switch_file.rs new file mode 100644 index 0000000..bdc0df7 --- /dev/null +++ b/src/adapters/shell/switch_file.rs @@ -0,0 +1,18 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::constants::{ENV_GW_SWITCH_FILE, MSG_SWITCH_FILE_WARNING_PREFIX, SWITCH_TO_PREFIX}; + +pub fn write_switch_path(path: &Path) -> Result<()> { + if let Ok(switch_file) = std::env::var(ENV_GW_SWITCH_FILE) { + if let Err(e) = std::fs::write(&switch_file, path.display().to_string()) { + eprintln!("{MSG_SWITCH_FILE_WARNING_PREFIX}{e}"); + } + } else { + let path_display = path.display(); + println!("{SWITCH_TO_PREFIX}{path_display}"); + } + + Ok(()) +} diff --git a/src/adapters/ui/dialoguer.rs b/src/adapters/ui/dialoguer.rs new file mode 100644 index 0000000..3c274bc --- /dev/null +++ b/src/adapters/ui/dialoguer.rs @@ -0,0 +1 @@ +pub use crate::ui::{DialoguerUI, MockUI, UserInterface}; diff --git a/src/adapters/ui/mod.rs b/src/adapters/ui/mod.rs new file mode 100644 index 0000000..5ae0ae6 --- /dev/null +++ b/src/adapters/ui/mod.rs @@ -0,0 +1,3 @@ +pub mod dialoguer; + +pub use dialoguer::{DialoguerUI, MockUI, UserInterface}; diff --git a/src/app/actions.rs b/src/app/actions.rs new file mode 100644 index 0000000..22a44cf --- /dev/null +++ b/src/app/actions.rs @@ -0,0 +1,57 @@ +use anyhow::Result; +use console::Term; + +use crate::app::menu::MenuItem; +use crate::support::terminal::clear_screen; +use crate::usecases; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MenuAction { + Continue, + Exit, + ExitAfterSwitch, +} + +pub fn handle_menu_item(item: &MenuItem, term: &Term) -> Result { + clear_screen(term); + + match item { + MenuItem::ListWorktrees => usecases::list_worktrees::list_worktrees()?, + MenuItem::CreateWorktree => { + if usecases::create_worktree::create_worktree()? { + return Ok(MenuAction::ExitAfterSwitch); + } + } + MenuItem::DeleteWorktree => usecases::delete_worktree::delete_worktree()?, + MenuItem::SwitchWorktree => { + if usecases::switch_worktree::switch_worktree()? { + return Ok(MenuAction::ExitAfterSwitch); + } + } + MenuItem::SearchWorktrees => { + if usecases::search_worktrees::search_worktrees()? { + return Ok(MenuAction::ExitAfterSwitch); + } + } + MenuItem::BatchDelete => usecases::delete_worktree::batch_delete_worktrees()?, + MenuItem::CleanupOldWorktrees => usecases::cleanup_worktrees::cleanup_old_worktrees()?, + MenuItem::RenameWorktree => usecases::rename_worktree::rename_worktree()?, + MenuItem::EditHooks => usecases::edit_hooks::edit_hooks()?, + MenuItem::Exit => return Ok(MenuAction::Exit), + } + + Ok(MenuAction::Continue) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_handle_menu_item_exit() -> Result<()> { + let term = Term::stdout(); + let result = handle_menu_item(&MenuItem::Exit, &term)?; + assert_eq!(result, MenuAction::Exit); + Ok(()) + } +} diff --git a/src/app/menu.rs b/src/app/menu.rs new file mode 100644 index 0000000..b55815a --- /dev/null +++ b/src/app/menu.rs @@ -0,0 +1,60 @@ +use crate::constants::*; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MenuItem { + ListWorktrees, + SearchWorktrees, + CreateWorktree, + DeleteWorktree, + BatchDelete, + CleanupOldWorktrees, + SwitchWorktree, + RenameWorktree, + EditHooks, + Exit, +} + +impl fmt::Display for MenuItem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MenuItem::ListWorktrees => write!(f, "{MENU_LIST_WORKTREES}"), + MenuItem::SearchWorktrees => write!(f, "{MENU_SEARCH_WORKTREES}"), + MenuItem::CreateWorktree => write!(f, "{MENU_CREATE_WORKTREE}"), + MenuItem::DeleteWorktree => write!(f, "{MENU_DELETE_WORKTREE}"), + MenuItem::BatchDelete => write!(f, "{MENU_BATCH_DELETE}"), + MenuItem::CleanupOldWorktrees => write!(f, "{MENU_CLEANUP_OLD}"), + MenuItem::SwitchWorktree => write!(f, "{MENU_SWITCH_WORKTREE}"), + MenuItem::RenameWorktree => write!(f, "{MENU_RENAME_WORKTREE}"), + MenuItem::EditHooks => write!(f, "{MENU_EDIT_HOOKS}"), + MenuItem::Exit => write!(f, "{MENU_EXIT}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_menu_item_display_variants() { + let items = [ + (MenuItem::ListWorktrees, MENU_LIST_WORKTREES), + (MenuItem::SearchWorktrees, MENU_SEARCH_WORKTREES), + (MenuItem::CreateWorktree, MENU_CREATE_WORKTREE), + (MenuItem::DeleteWorktree, MENU_DELETE_WORKTREE), + (MenuItem::BatchDelete, MENU_BATCH_DELETE), + (MenuItem::CleanupOldWorktrees, MENU_CLEANUP_OLD), + (MenuItem::SwitchWorktree, MENU_SWITCH_WORKTREE), + (MenuItem::RenameWorktree, MENU_RENAME_WORKTREE), + (MenuItem::EditHooks, MENU_EDIT_HOOKS), + (MenuItem::Exit, MENU_EXIT), + ]; + + for (item, expected) in items { + let formatted = format!("{item}"); + assert!(!formatted.is_empty()); + assert!(formatted.contains(expected)); + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..7876f1a --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,6 @@ +pub mod actions; +pub mod menu; +pub mod presenter; +pub mod run; + +pub use run::run; diff --git a/src/app/presenter.rs b/src/app/presenter.rs new file mode 100644 index 0000000..4e06eb2 --- /dev/null +++ b/src/app/presenter.rs @@ -0,0 +1,43 @@ +use colored::*; + +use crate::constants::{ + header_separator, DEFAULT_BRANCH_DETACHED, EMOJI_DETACHED, EMOJI_FOLDER, EMOJI_HOME, + EMOJI_LOCKED, +}; +use crate::domain::repo_context; +use crate::git::WorktreeInfo; + +pub fn build_header_lines() -> Vec { + let version = env!("CARGO_PKG_VERSION"); + let title = format!("Git Workers v{version} - Interactive Worktree Manager") + .bright_cyan() + .bold() + .to_string(); + let separator = header_separator(); + let repo_info = repo_context::get_repository_info(); + let repository_line = format!( + "{} {}", + "Repository:".bright_white(), + repo_info.bright_yellow().bold() + ); + + vec![ + String::new(), + title, + separator, + repository_line, + String::new(), + ] +} + +pub fn get_worktree_icon(worktree: &WorktreeInfo) -> &'static str { + if worktree.is_current { + EMOJI_HOME + } else if worktree.is_locked { + EMOJI_LOCKED + } else if worktree.branch == DEFAULT_BRANCH_DETACHED { + EMOJI_DETACHED + } else { + EMOJI_FOLDER + } +} diff --git a/src/app/run.rs b/src/app/run.rs new file mode 100644 index 0000000..d45daf1 --- /dev/null +++ b/src/app/run.rs @@ -0,0 +1,65 @@ +use anyhow::Result; +use colored::*; +use console::Term; +use std::io::{self, Write}; + +use crate::app::actions::{handle_menu_item, MenuAction}; +use crate::app::menu::MenuItem; +use crate::app::presenter::build_header_lines; +use crate::constants; +use crate::support::terminal::{clear_screen, setup_terminal_config}; +use crate::ui::{DialoguerUI, UserInterface}; + +pub fn run() -> Result<()> { + let term = Term::stdout(); + setup_terminal_config(); + + loop { + clear_screen(&term); + let _ = io::stdout().flush(); + + for line in build_header_lines() { + println!("{line}"); + } + + let menu_items = [ + MenuItem::ListWorktrees, + MenuItem::SwitchWorktree, + MenuItem::SearchWorktrees, + MenuItem::CreateWorktree, + MenuItem::DeleteWorktree, + MenuItem::BatchDelete, + MenuItem::CleanupOldWorktrees, + MenuItem::RenameWorktree, + MenuItem::EditHooks, + MenuItem::Exit, + ]; + + let display_items: Vec = menu_items.iter().map(ToString::to_string).collect(); + let ui = DialoguerUI; + let selection = match ui.select_with_default( + constants::PROMPT_ACTION, + &display_items, + constants::DEFAULT_MENU_SELECTION, + ) { + Ok(selection) => selection, + Err(_) => { + clear_screen(&term); + println!("{}", constants::INFO_EXITING.bright_black()); + break; + } + }; + + match handle_menu_item(&menu_items[selection], &term)? { + MenuAction::Continue => continue, + MenuAction::Exit => { + clear_screen(&term); + println!("{}", constants::INFO_EXITING.bright_black()); + break; + } + MenuAction::ExitAfterSwitch => break, + } + } + + Ok(()) +} diff --git a/src/commands/create.rs b/src/commands/create.rs index 364912c..54c6e53 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -1,882 +1 @@ -use anyhow::{anyhow, Result}; -use colored::*; -use indicatif::{ProgressBar, ProgressStyle}; -use std::path::PathBuf; -use std::time::Duration; - -use super::super::core::{validate_custom_path, validate_worktree_name}; -use crate::config::Config; -use crate::constants::{ - section_header, BRANCH_OPTION_SELECT_BRANCH, BRANCH_OPTION_SELECT_TAG, DEFAULT_EMPTY_STRING, - DEFAULT_MENU_SELECTION, ERROR_CUSTOM_PATH_EMPTY, ERROR_WORKTREE_NAME_EMPTY, - FUZZY_SEARCH_THRESHOLD, GIT_REMOTE_PREFIX, HEADER_CREATE_WORKTREE, HOOK_POST_CREATE, - HOOK_POST_SWITCH, ICON_LOCAL_BRANCH, ICON_REMOTE_BRANCH, ICON_TAG_INDICATOR, - MSG_EXAMPLE_BRANCH, MSG_EXAMPLE_DOT, MSG_EXAMPLE_HOTFIX, MSG_EXAMPLE_PARENT, - MSG_FIRST_WORKTREE_CHOOSE, MSG_SPECIFY_DIRECTORY_PATH, OPTION_CREATE_FROM_HEAD_FULL, - OPTION_CUSTOM_PATH_FULL, OPTION_SELECT_BRANCH_FULL, OPTION_SELECT_TAG_FULL, - PROGRESS_BAR_TICK_MILLIS, PROMPT_CONFLICT_ACTION, PROMPT_CUSTOM_PATH, PROMPT_SELECT_BRANCH, - PROMPT_SELECT_BRANCH_OPTION, PROMPT_SELECT_TAG, PROMPT_SELECT_WORKTREE_LOCATION, - PROMPT_WORKTREE_NAME, SLASH_CHAR, STRING_CUSTOM, STRING_SAME_LEVEL, - TAG_MESSAGE_TRUNCATE_LENGTH, WORKTREE_LOCATION_CUSTOM_PATH, WORKTREE_LOCATION_SAME_LEVEL, -}; -use crate::file_copy; -use crate::git::GitWorktreeManager; -use crate::hooks::{self, HookContext}; -use crate::ui::{DialoguerUI, UserInterface}; -use crate::utils::{self, press_any_key_to_continue, write_switch_path}; - -/// Configuration for worktree creation -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct WorktreeCreateConfig { - pub name: String, - pub path: PathBuf, - pub branch_source: BranchSource, - pub switch_to_new: bool, -} - -/// Source for creating the worktree branch -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub enum BranchSource { - /// Create from current HEAD - Head, - /// Create from existing branch - Branch(String), - /// Create from tag - Tag(String), - /// Create new branch from base - NewBranch { name: String, base: String }, -} - -/// Validate worktree location type -pub fn validate_worktree_location(location: &str) -> Result<()> { - match location { - STRING_SAME_LEVEL | STRING_CUSTOM => Ok(()), - _ => Err(anyhow!("Invalid worktree location type: {}", location)), - } -} - -/// Pure business logic for determining worktree path -pub fn determine_worktree_path( - git_dir: &std::path::Path, - name: &str, - location: &str, - custom_path: Option, -) -> Result<(PathBuf, String)> { - validate_worktree_location(location)?; - - match location { - STRING_SAME_LEVEL => { - let path = git_dir - .parent() - .ok_or_else(|| anyhow!("Cannot determine parent directory"))? - .join(name); - Ok((path, STRING_SAME_LEVEL.to_string())) - } - STRING_CUSTOM => { - let path = custom_path - .ok_or_else(|| anyhow!("Custom path required when location is 'custom'"))?; - Ok((git_dir.join(path), STRING_CUSTOM.to_string())) - } - _ => Err(anyhow!("Invalid location type: {}", location)), - } -} - -/// Pure business logic for determining worktree path (legacy) -#[allow(dead_code)] -pub fn determine_worktree_path_legacy( - name: &str, - location_choice: usize, - custom_path: Option<&str>, - _repo_name: &str, -) -> Result { - match location_choice { - WORKTREE_LOCATION_SAME_LEVEL => Ok(PathBuf::from(format!("../{name}"))), - WORKTREE_LOCATION_CUSTOM_PATH => { - let path = custom_path.ok_or_else(|| anyhow!("Custom path not provided"))?; - validate_custom_path(path)?; - Ok(PathBuf::from(path)) - } - _ => Err(anyhow!("Invalid location choice")), - } -} - -/// Pure business logic for worktree creation validation -#[allow(dead_code)] -pub fn validate_worktree_creation( - name: &str, - path: &PathBuf, - existing_worktrees: &[crate::git::WorktreeInfo], -) -> Result<()> { - // Check for name conflicts - if existing_worktrees.iter().any(|w| w.name == name) { - return Err(anyhow!("Worktree '{name}' already exists")); - } - - // Check for path conflicts - if existing_worktrees.iter().any(|w| w.path == *path) { - return Err(anyhow!("Path '{}' already in use", path.display())); - } - - Ok(()) -} - -pub fn create_worktree() -> Result { - let manager = GitWorktreeManager::new()?; - let ui = DialoguerUI; - create_worktree_with_ui(&manager, &ui) -} - -/// Internal implementation of create_worktree with dependency injection -/// -/// # Arguments -/// -/// * `manager` - Git worktree manager instance -/// * `ui` - User interface implementation for testability -/// -/// # Implementation Notes -/// -/// - Validates worktree name (non-empty) -/// - Detects existing worktree patterns for consistency -/// - Handles both branch and HEAD-based creation -/// - Executes lifecycle hooks at appropriate times -/// - Supports custom path input for flexible worktree organization -/// -/// # Path Handling -/// -/// For first-time worktree creation, offers two location patterns: -/// 1. **Same level as repository** (`../name`): Creates worktrees as siblings to the repository -/// 2. **Custom path**: Allows users to specify any relative path, validated by `validate_custom_path()` -/// -/// The chosen pattern is then used for subsequent worktrees when simple names -/// are provided, ensuring consistent organization. -/// -/// # Custom Path Feature -/// -/// When users select "Custom path", they can specify any relative path for the worktree. -/// This enables flexible project organization such as: -/// - Grouping by feature type: `features/ui/new-button`, `features/api/auth` -/// - Temporary locations: `../temp/experiment-123` -/// - Project-specific conventions: `workspaces/team-a/feature` -/// -/// All custom paths are validated for security and compatibility before use. -/// -/// # Returns -/// -/// * `true` - If a worktree was created and the user switched to it -/// * `false` - If the operation was cancelled or user chose not to switch -pub fn create_worktree_with_ui( - manager: &GitWorktreeManager, - ui: &dyn UserInterface, -) -> Result { - println!(); - let header = section_header(HEADER_CREATE_WORKTREE); - println!("{header}"); - println!(); - - // Get existing worktrees to detect pattern - let existing_worktrees = manager.list_worktrees()?; - let has_worktrees = !existing_worktrees.is_empty(); - - // Get worktree name - let name = match ui.input(PROMPT_WORKTREE_NAME) { - Ok(name) => name.trim().to_string(), - Err(_) => return Ok(false), - }; - - if name.is_empty() { - utils::print_error(ERROR_WORKTREE_NAME_EMPTY); - return Ok(false); - } - - // Validate worktree name - let name = match validate_worktree_name(&name) { - Ok(validated_name) => validated_name, - Err(e) => { - utils::print_error(&format!("Invalid worktree name: {e}")); - return Ok(false); - } - }; - - // If this is the first worktree, let user choose the pattern - let final_name = if !has_worktrees { - println!(); - let msg = MSG_FIRST_WORKTREE_CHOOSE.bright_cyan(); - println!("{msg}"); - - let options = vec![ - format!("Same level as repository (../{})", name), - OPTION_CUSTOM_PATH_FULL.to_string(), - ]; - - let selection = match ui.select_with_default( - PROMPT_SELECT_WORKTREE_LOCATION, - &options, - DEFAULT_MENU_SELECTION, - ) { - Ok(selection) => selection, - Err(_) => return Ok(false), - }; - - match selection { - WORKTREE_LOCATION_SAME_LEVEL => format!("../{name}"), // Same level - WORKTREE_LOCATION_CUSTOM_PATH => { - // Custom path input - println!(); - let msg = MSG_SPECIFY_DIRECTORY_PATH.bright_cyan(); - println!("{msg}"); - - // Show more helpful examples with actual worktree name - println!(); - println!( - "{}:", - format!("Examples (worktree name: '{name}'):").bright_black() - ); - println!( - " • {} → creates at ./branch/{name}", - MSG_EXAMPLE_BRANCH.green() - ); - println!( - " • {} → creates at ./hotfix/{name}", - MSG_EXAMPLE_HOTFIX.green() - ); - println!( - " • {} → creates at ../{name} (outside project)", - MSG_EXAMPLE_PARENT.green() - ); - println!( - " • {} → creates at ./{name} (project root)", - MSG_EXAMPLE_DOT.green() - ); - println!(); - - let custom_path = match ui.input(PROMPT_CUSTOM_PATH) { - Ok(path) => path.trim().to_string(), - Err(_) => return Ok(false), - }; - - if custom_path.is_empty() { - utils::print_error(ERROR_CUSTOM_PATH_EMPTY); - return Ok(false); - } - - // Always treat custom path as a directory and append worktree name - let custom_path = custom_path.trim_end_matches(SLASH_CHAR); - let final_path = if custom_path.is_empty() { - // Just "/" was entered - use worktree name directly - name.clone() - } else if custom_path == "." { - // "./" was entered - create in project root - format!("./{name}") - } else { - format!("{custom_path}/{name}") - }; - - // Validate custom path - if let Err(e) = validate_custom_path(&final_path) { - utils::print_error(&format!("Invalid custom path: {e}")); - return Ok(false); - } - - final_path - } - _ => { - // This should never happen with only 2 menu options - utils::print_error(&format!( - "Invalid location selection: {selection}. Expected 0 or 1." - )); - return Ok(false); - } - } - } else { - name.clone() - }; - - // Branch handling - println!(); - let branch_options = vec![ - OPTION_CREATE_FROM_HEAD_FULL.to_string(), - OPTION_SELECT_BRANCH_FULL.to_string(), - OPTION_SELECT_TAG_FULL.to_string(), - ]; - - let branch_choice = match ui.select_with_default( - PROMPT_SELECT_BRANCH_OPTION, - &branch_options, - DEFAULT_MENU_SELECTION, - ) { - Ok(choice) => choice, - Err(_) => return Ok(false), - }; - - let (branch, new_branch_name) = match branch_choice { - BRANCH_OPTION_SELECT_BRANCH => { - // Select branch - let (local_branches, remote_branches) = manager.list_all_branches()?; - if local_branches.is_empty() && remote_branches.is_empty() { - utils::print_warning("No branches found, creating from HEAD"); - (None, None) - } else { - // Start of branch selection logic - // Get branch to worktree mapping - let branch_worktree_map = manager.get_branch_worktree_map()?; - - // Create items for fuzzy search (plain text for search, formatted for display) - let mut branch_items: Vec = Vec::new(); - let mut branch_refs: Vec<(String, bool)> = Vec::new(); // (branch_name, is_remote) - - // Add local branches with laptop icon (laptop emoji takes 2 columns) - for branch in &local_branches { - if let Some(worktree) = branch_worktree_map.get(branch) { - branch_items.push(format!( - "{ICON_LOCAL_BRANCH}{branch} (in use by '{worktree}')" - )); - } else { - branch_items.push(format!("{ICON_LOCAL_BRANCH}{branch}")); - } - branch_refs.push((branch.clone(), false)); - } - - // Add remote branches with cloud icon (cloud emoji should align with laptop) - for branch in &remote_branches { - let full_remote_name = format!("{GIT_REMOTE_PREFIX}{branch}"); - if let Some(worktree) = branch_worktree_map.get(&full_remote_name) { - branch_items.push(format!( - "{ICON_REMOTE_BRANCH}{full_remote_name} (in use by '{worktree}')" - )); - } else { - branch_items.push(format!("{ICON_REMOTE_BRANCH}{full_remote_name}")); - } - branch_refs.push((branch.clone(), true)); - } - - println!(); - - // Use FuzzySelect for better search experience when there are many branches - let selection_result = if branch_items.len() > FUZZY_SEARCH_THRESHOLD { - println!("Type to search branches (fuzzy search enabled):"); - ui.fuzzy_select(PROMPT_SELECT_BRANCH, &branch_items) - } else { - ui.select_with_default( - PROMPT_SELECT_BRANCH, - &branch_items, - DEFAULT_MENU_SELECTION, - ) - }; - let selection_result = selection_result.ok(); - - match selection_result { - Some(selection) => { - let (selected_branch, is_remote): (&String, &bool) = - (&branch_refs[selection].0, &branch_refs[selection].1); - - if !is_remote { - // Local branch - check if already checked out - if let Some(worktree) = branch_worktree_map.get(selected_branch) { - // Branch is in use, offer to create a new branch - println!(); - utils::print_warning(&format!( - "Branch '{}' is already checked out in worktree '{}'", - selected_branch.yellow(), - worktree.bright_red() - )); - println!(); - - let action_options = vec![ - format!( - "Create new branch '{}' from '{}'", - name, selected_branch - ), - "Change the branch name".to_string(), - "Cancel".to_string(), - ]; - - match ui.select_with_default( - PROMPT_CONFLICT_ACTION, - &action_options, - DEFAULT_MENU_SELECTION, - ) { - Ok(0) => { - // Use worktree name as new branch name - (Some(selected_branch.clone()), Some(name.clone())) - } - Ok(1) => { - // Ask for custom branch name - println!(); - let new_branch = match ui.input_with_default( - &format!( - "Enter new branch name (base: {})", - selected_branch.yellow() - ), - &name, - ) { - Ok(name) => name.trim().to_string(), - Err(_) => return Ok(false), - }; - - if new_branch.is_empty() { - utils::print_error("Branch name cannot be empty"); - return Ok(false); - } - - if local_branches.contains(&new_branch) { - utils::print_error(&format!( - "Branch '{new_branch}' already exists" - )); - return Ok(false); - } - - (Some(selected_branch.clone()), Some(new_branch)) - } - _ => return Ok(false), - } - } else { - (Some(selected_branch.clone()), None) - } - } else { - // Remote branch - check if local branch with same name exists - if local_branches.contains(selected_branch) { - // Local branch with same name exists - println!(); - utils::print_warning(&format!( - "A local branch '{}' already exists for remote '{}'", - selected_branch.yellow(), - format!("{GIT_REMOTE_PREFIX}{selected_branch}").bright_blue() - )); - println!(); - - let use_local_option = if let Some(worktree) = - branch_worktree_map.get(selected_branch) - { - format!( - "Use the existing local branch instead (in use by '{}')", - worktree.bright_red() - ) - } else { - "Use the existing local branch instead".to_string() - }; - - let action_options = vec![ - format!( - "Create new branch '{}' from '{}{}'", - name, GIT_REMOTE_PREFIX, selected_branch - ), - use_local_option, - "Cancel".to_string(), - ]; - - match ui.select_with_default( - PROMPT_CONFLICT_ACTION, - &action_options, - DEFAULT_MENU_SELECTION, - ) { - Ok(0) => { - // Create new branch with worktree name - ( - Some(format!("{GIT_REMOTE_PREFIX}{selected_branch}")), - Some(name.clone()), - ) - } - Ok(1) => { - // Use local branch instead - but check if it's already in use - if let Some(worktree) = - branch_worktree_map.get(selected_branch) - { - println!(); - utils::print_error(&format!( - "Branch '{}' is already checked out in worktree '{}'", - selected_branch.yellow(), - worktree.bright_red() - )); - println!("Please select a different option."); - return Ok(false); - } - (Some(selected_branch.clone()), None) - } - _ => return Ok(false), - } - } else { - // No conflict, proceed normally - (Some(format!("{GIT_REMOTE_PREFIX}{selected_branch}")), None) - } - } - } - None => return Ok(false), - } - } - } - BRANCH_OPTION_SELECT_TAG => { - // Select tag - let tags = manager.list_all_tags()?; - if tags.is_empty() { - utils::print_warning("No tags found, creating from HEAD"); - (None, None) - } else { - // Create items for tag selection with message preview - let tag_items: Vec = tags - .iter() - .map(|(name, message)| { - if let Some(msg) = message { - // Truncate message to first line for display - let first_line = msg.lines().next().unwrap_or(DEFAULT_EMPTY_STRING); - let truncated = if first_line.len() > TAG_MESSAGE_TRUNCATE_LENGTH { - format!("{}...", &first_line[..TAG_MESSAGE_TRUNCATE_LENGTH]) - } else { - first_line.to_string() - }; - format!("{ICON_TAG_INDICATOR}{name} - {truncated}") - } else { - format!("{ICON_TAG_INDICATOR}{name}") - } - }) - .collect(); - - println!(); - - // Use FuzzySelect for better search experience when there are many tags - let selection_result = if tag_items.len() > FUZZY_SEARCH_THRESHOLD { - println!("Type to search tags (fuzzy search enabled):"); - ui.fuzzy_select(PROMPT_SELECT_TAG, &tag_items) - } else { - ui.select_with_default(PROMPT_SELECT_TAG, &tag_items, DEFAULT_MENU_SELECTION) - }; - let selection_result = selection_result.ok(); - - match selection_result { - Some(selection) => { - let selected_tag = &tags[selection].0; - // For tags, we always create a new branch named after the worktree - (Some(selected_tag.clone()), Some(name.clone())) - } - None => return Ok(false), - } - } - } - _ => { - // Create from current HEAD - (None, None) - } - }; - - // Show preview - println!(); - let preview_label = "Preview:".bright_white(); - println!("{preview_label}"); - let name_label = "Name:".bright_black(); - let name_value = final_name.bright_green(); - println!(" {name_label} {name_value}"); - if let Some(new_branch) = &new_branch_name { - let base_branch_name = branch.as_ref().unwrap(); - // Check if the base branch is a tag - if manager - .repo() - .find_reference(&format!("refs/tags/{base_branch_name}")) - .is_ok() - { - let branch_label = "New Branch:".bright_black(); - let branch_value = new_branch.yellow(); - let tag_value = format!("tag: {base_branch_name}").bright_cyan(); - println!(" {branch_label} {branch_value} (from {tag_value})"); - } else { - let branch_label = "New Branch:".bright_black(); - let branch_value = new_branch.yellow(); - let base_value = base_branch_name.bright_black(); - println!(" {branch_label} {branch_value} (from {base_value})"); - } - } else if let Some(branch_name) = &branch { - let branch_label = "Branch:".bright_black(); - let branch_value = branch_name.yellow(); - println!(" {branch_label} {branch_value}"); - } else { - let from_label = "From:".bright_black(); - println!(" {from_label} Current HEAD"); - } - println!(); - - // Create worktree with progress bar - let pb = ProgressBar::new_spinner(); - pb.set_style( - ProgressStyle::default_spinner() - .template("{spinner:.green} {msg}") - .unwrap(), - ); - pb.set_message("Creating worktree..."); - pb.enable_steady_tick(Duration::from_millis(PROGRESS_BAR_TICK_MILLIS)); - - let result = if let Some(new_branch) = &new_branch_name { - // Create worktree with new branch from base branch - manager.create_worktree_with_new_branch(&final_name, new_branch, branch.as_ref().unwrap()) - } else { - // Create worktree with existing branch or from HEAD - manager.create_worktree(&final_name, branch.as_deref()) - }; - - match result { - Ok(path) => { - pb.finish_and_clear(); - let name_green = name.bright_green(); - let path_display = path.display(); - utils::print_success(&format!( - "Created worktree '{name_green}' at {path_display}" - )); - - // Copy configured files - let config = Config::load()?; - if !config.files.copy.is_empty() { - println!(); - println!("Copying configured files..."); - match file_copy::copy_configured_files(&config.files, &path, manager) { - Ok(copied) => { - if !copied.is_empty() { - let copied_count = copied.len(); - utils::print_success(&format!("Copied {copied_count} files")); - for file in &copied { - println!(" ✓ {file}"); - } - } - } - Err(e) => { - utils::print_warning(&format!("Failed to copy files: {e}")); - } - } - } - - // Execute post-create hooks - if let Err(e) = hooks::execute_hooks( - HOOK_POST_CREATE, - &HookContext { - worktree_name: name.clone(), - worktree_path: path.clone(), - }, - ) { - utils::print_warning(&format!("Hook execution warning: {e}")); - } - - // Ask if user wants to switch to the new worktree - println!(); - let switch = ui - .confirm_with_default("Switch to the new worktree?", true) - .unwrap_or(false); - - if switch { - // Switch to the new worktree - write_switch_path(&path); - - println!(); - let plus_sign = "+".green(); - let worktree_name = name.bright_white().bold(); - println!("{plus_sign} Switching to worktree '{worktree_name}'"); - - // Execute post-switch hooks - if let Err(e) = hooks::execute_hooks( - HOOK_POST_SWITCH, - &HookContext { - worktree_name: name, - worktree_path: path, - }, - ) { - utils::print_warning(&format!("Hook execution warning: {e}")); - } - - Ok(true) // Indicate that we switched - } else { - println!(); - press_any_key_to_continue()?; - Ok(false) - } - } - Err(e) => { - pb.finish_and_clear(); - utils::print_error(&format!("Failed to create worktree: {e}")); - println!(); - press_any_key_to_continue()?; - Ok(false) - } - } -} - -#[cfg(test)] // Re-enabled tests with corrections -mod tests { - use super::*; - use std::path::PathBuf; - use tempfile::TempDir; - - #[test] - fn test_validate_worktree_location_valid() { - // Test valid location types - assert!(validate_worktree_location("same-level").is_ok()); - assert!(validate_worktree_location("custom").is_ok()); - } - - #[test] - fn test_validate_worktree_location_invalid() { - // Test invalid location types - assert!(validate_worktree_location("invalid").is_err()); - assert!(validate_worktree_location("").is_err()); - assert!(validate_worktree_location("wrong-type").is_err()); - } - - #[test] - fn test_determine_worktree_path_same_level() { - let temp_dir = TempDir::new().unwrap(); - let git_dir = temp_dir.path().join("project"); - std::fs::create_dir_all(&git_dir).unwrap(); - - let result = determine_worktree_path(&git_dir, "test-worktree", "same-level", None); - assert!(result.is_ok()); - - let (path, pattern) = result.unwrap(); - assert_eq!(pattern, "same-level"); - assert!(path.to_string_lossy().ends_with("test-worktree")); - } - - #[test] - fn test_determine_worktree_path_custom() { - let temp_dir = TempDir::new().unwrap(); - let git_dir = temp_dir.path().join("project"); - std::fs::create_dir_all(&git_dir).unwrap(); - - let custom_path = PathBuf::from("custom/path"); - let result = determine_worktree_path( - &git_dir, - "test-worktree", - "custom", - Some(custom_path.clone()), - ); - assert!(result.is_ok()); - - let (path, pattern) = result.unwrap(); - assert_eq!(pattern, "custom"); - assert!(path.to_string_lossy().contains("custom/path")); - } - - #[test] - fn test_determine_worktree_path_legacy_same_level() { - let result = - determine_worktree_path_legacy("test", WORKTREE_LOCATION_SAME_LEVEL, None, "repo"); - assert!(result.is_ok()); - let path = result.unwrap(); - assert_eq!(path, PathBuf::from("../test")); - } - - #[test] - fn test_determine_worktree_path_legacy_custom() { - let result = determine_worktree_path_legacy( - "test", - WORKTREE_LOCATION_CUSTOM_PATH, - Some("../custom/test"), - "repo", - ); - assert!(result.is_ok()); - let path = result.unwrap(); - assert_eq!(path, PathBuf::from("../custom/test")); - } - - #[test] - fn test_determine_worktree_path_legacy_invalid_choice() { - let result = determine_worktree_path_legacy("test", 999, None, "repo"); - assert!(result.is_err()); - } - - #[test] - fn test_validate_worktree_creation_no_conflicts() { - let existing_worktrees = vec![]; - let path = PathBuf::from("/tmp/new-worktree"); - - let result = validate_worktree_creation("new-worktree", &path, &existing_worktrees); - assert!(result.is_ok()); - } - - #[test] - #[ignore = "WorktreeInfo struct fields need to be updated"] - fn test_validate_worktree_creation_name_conflict() { - // TODO: Update WorktreeInfo struct initialization to match actual fields - let existing_worktrees = vec![]; - let path = PathBuf::from("/tmp/new-worktree"); - - let result = validate_worktree_creation("test", &path, &existing_worktrees); - assert!(result.is_ok()); // Temporary assertion until struct is fixed - } - - #[test] - #[ignore = "WorktreeInfo struct fields need to be updated"] - fn test_validate_worktree_creation_path_conflict() { - // TODO: Update WorktreeInfo struct initialization to match actual fields - let existing_path = PathBuf::from("/tmp/existing"); - let existing_worktrees = vec![]; - - let result = - validate_worktree_creation("new-worktree", &existing_path, &existing_worktrees); - assert!(result.is_ok()); // Temporary assertion until struct is fixed - } - - // Add 6 new tests for better coverage - #[test] - fn test_determine_worktree_path_custom_missing_path() { - let temp_dir = tempfile::TempDir::new().unwrap(); - let git_dir = temp_dir.path().join("project"); - std::fs::create_dir_all(&git_dir).unwrap(); - - let result = determine_worktree_path(&git_dir, "test-worktree", "custom", None); - assert!(result.is_err()); - } - - #[test] - fn test_determine_worktree_path_invalid_location() { - let temp_dir = tempfile::TempDir::new().unwrap(); - let git_dir = temp_dir.path().join("project"); - std::fs::create_dir_all(&git_dir).unwrap(); - - let invalid_location = "invalid-location"; - let result = determine_worktree_path(&git_dir, "test-worktree", invalid_location, None); - assert!(result.is_err()); - } - - #[test] - fn test_validate_worktree_location_all_valid() { - let valid_locations = vec!["same-level", "custom"]; - for location in valid_locations { - assert!(validate_worktree_location(location).is_ok()); - } - } - - #[test] - fn test_determine_worktree_path_legacy_custom_missing_path() { - let repo_name = "repo"; - let result = - determine_worktree_path_legacy("test", WORKTREE_LOCATION_CUSTOM_PATH, None, repo_name); - assert!(result.is_err()); - } - - #[test] - fn test_branch_source_enum_variants() { - let test_branch = "main"; - let test_tag = "v1.0.0"; - let test_new_branch = "feature"; - let test_base = "develop"; - - let sources = vec![ - BranchSource::Head, - BranchSource::Branch(test_branch.to_string()), - BranchSource::Tag(test_tag.to_string()), - BranchSource::NewBranch { - name: test_new_branch.to_string(), - base: test_base.to_string(), - }, - ]; - - // Test that all enum variants can be created and matched - for source in sources { - match source { - BranchSource::Head => {} - BranchSource::Branch(ref branch) => assert_eq!(branch, test_branch), - BranchSource::Tag(ref tag) => assert_eq!(tag, test_tag), - BranchSource::NewBranch { ref name, ref base } => { - assert_eq!(name, test_new_branch); - assert_eq!(base, test_base); - } - } - } - } -} +pub use crate::usecases::create_worktree::*; diff --git a/src/commands/delete.rs b/src/commands/delete.rs index 5b85a47..b9c16b5 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -1,379 +1 @@ -use anyhow::{anyhow, Result}; -use colored::*; - -use crate::constants::{section_header, DEFAULT_MENU_SELECTION, HOOK_PRE_REMOVE}; -use crate::git::{GitWorktreeManager, WorktreeInfo}; -use crate::hooks::{self, HookContext}; -use crate::ui::{DialoguerUI, UserInterface}; -use crate::utils::{self, press_any_key_to_continue}; - -/// Validate deletion target -#[allow(dead_code)] -pub fn validate_deletion_target(name: &str) -> Result<()> { - if name.is_empty() { - return Err(anyhow!("Worktree name cannot be empty")); - } - - if name == "main" || name == "master" { - return Err(anyhow!("Cannot delete main worktree")); - } - - Ok(()) -} - -/// Check if orphaned branch should be deleted -#[allow(dead_code)] -pub fn should_delete_orphaned_branch( - is_branch_unique: bool, - branch_name: &str, - worktree_name: &str, -) -> bool { - is_branch_unique && branch_name == worktree_name -} - -/// Configuration for worktree deletion -#[derive(Debug, Clone)] -pub struct WorktreeDeleteConfig { - pub name: String, - pub path: std::path::PathBuf, - pub branch: String, - pub delete_branch: bool, -} - -/// Result of deletion analysis -#[derive(Debug, Clone)] -pub struct DeletionAnalysis { - pub worktree: WorktreeInfo, - pub is_branch_unique: bool, - pub delete_branch_recommended: bool, -} - -/// Pure business logic for filtering deletable worktrees -pub fn get_deletable_worktrees(worktrees: &[WorktreeInfo]) -> Vec<&WorktreeInfo> { - worktrees.iter().filter(|w| !w.is_current).collect() -} - -/// Pure business logic for analyzing deletion requirements -pub fn analyze_deletion( - worktree: &WorktreeInfo, - manager: &GitWorktreeManager, -) -> Result { - let is_branch_unique = - manager.is_branch_unique_to_worktree(&worktree.branch, &worktree.name)?; - - Ok(DeletionAnalysis { - worktree: worktree.clone(), - is_branch_unique, - delete_branch_recommended: is_branch_unique, - }) -} - -/// Pure business logic for executing deletion -pub fn execute_deletion(config: &WorktreeDeleteConfig, manager: &GitWorktreeManager) -> Result<()> { - // Execute pre-remove hooks - if let Err(e) = hooks::execute_hooks( - HOOK_PRE_REMOVE, - &HookContext { - worktree_name: config.name.clone(), - worktree_path: config.path.clone(), - }, - ) { - utils::print_warning(&format!("Hook execution warning: {e}")); - } - - // Delete the worktree - manager - .remove_worktree(&config.name) - .map_err(|e| anyhow!("Failed to delete worktree: {e}"))?; - - let name_red = config.name.bright_red(); - utils::print_success(&format!("Deleted worktree '{name_red}'")); - - // Delete branch if requested - if config.delete_branch { - match manager.delete_branch(&config.branch) { - Ok(_) => { - let branch_red = config.branch.bright_red(); - utils::print_success(&format!("Deleted branch '{branch_red}'")); - } - Err(e) => { - utils::print_warning(&format!("Failed to delete branch: {e}")); - } - } - } - - Ok(()) -} - -/// Deletes a single worktree interactively -/// -/// Presents a list of deletable worktrees (excluding the current one) -/// and guides the user through the deletion process: -/// -/// 1. **Selection**: Choose a worktree from the list -/// 2. **Branch Check**: If the branch is unique to this worktree, offers to delete it -/// 3. **Confirmation**: Shows worktree details and confirms deletion -/// 4. **Pre-remove Hooks**: Executes any configured pre-remove hooks -/// 5. **Deletion**: Removes the worktree and optionally its branch -/// -/// # Safety -/// -/// - Cannot delete the current worktree -/// - Requires explicit confirmation -/// - Shows all relevant information before deletion -/// -/// # Returns -/// -/// Returns `Ok(())` on successful completion (including cancellation). -/// -/// # Errors -/// -/// Returns an error if: -/// - Git repository operations fail -/// - File system operations fail during deletion -pub fn delete_worktree() -> Result<()> { - let manager = GitWorktreeManager::new()?; - let ui = DialoguerUI; - delete_worktree_with_ui(&manager, &ui) -} - -/// Internal implementation of delete_worktree with dependency injection -/// -/// # Arguments -/// -/// * `manager` - Git worktree manager instance -/// * `ui` - User interface implementation for testability -/// -/// # Deletion Process -/// -/// 1. Filters out current worktree (cannot be deleted) -/// 2. Presents selection list to user -/// 3. Checks if branch is unique to the worktree -/// 4. Confirms deletion with detailed preview -/// 5. Executes pre-remove hooks -/// 6. Performs deletion of worktree and optionally branch -pub fn delete_worktree_with_ui(manager: &GitWorktreeManager, ui: &dyn UserInterface) -> Result<()> { - let worktrees = manager.list_worktrees()?; - - if worktrees.is_empty() { - println!(); - let msg = "• No worktrees to delete.".yellow(); - println!("{msg}"); - println!(); - press_any_key_to_continue()?; - return Ok(()); - } - - // Use business logic to filter deletable worktrees - let deletable_worktrees = get_deletable_worktrees(&worktrees); - - if deletable_worktrees.is_empty() { - println!(); - let msg = "• No worktrees available for deletion.".yellow(); - println!("{msg}"); - println!( - "{}", - " (Cannot delete the current worktree)".bright_black() - ); - println!(); - press_any_key_to_continue()?; - return Ok(()); - } - - println!(); - let header = section_header("Delete Worktree"); - println!("{header}"); - println!(); - - let items: Vec = deletable_worktrees - .iter() - .map(|w| format!("{} ({})", w.name, w.branch)) - .collect(); - - let selection = match ui.select_with_default( - "Select a worktree to delete (ESC to cancel)", - &items, - DEFAULT_MENU_SELECTION, - ) { - Ok(selection) => selection, - Err(_) => return Ok(()), - }; - - let worktree_to_delete = deletable_worktrees[selection]; - - // Use business logic to analyze deletion requirements - let analysis = analyze_deletion(worktree_to_delete, manager)?; - - // Show confirmation with details - println!(); - let warning = "⚠ Warning".red().bold(); - println!("{warning}"); - let name_label = "Name:".bright_white(); - let name_value = analysis.worktree.name.yellow(); - println!(" {name_label} {name_value}"); - let path_label = "Path:".bright_white(); - let path_value = analysis.worktree.path.display(); - println!(" {path_label} {path_value}"); - let branch_label = "Branch:".bright_white(); - let branch_value = analysis.worktree.branch.yellow(); - println!(" {branch_label} {branch_value}"); - println!(); - - // Ask about branch deletion if it's unique to this worktree - let mut delete_branch = false; - if analysis.is_branch_unique { - let msg = "This branch is only used by this worktree.".yellow(); - println!("{msg}"); - delete_branch = ui - .confirm_with_default("Also delete the branch?", false) - .unwrap_or(false); - println!(); - } - - let confirm = ui - .confirm_with_default("Are you sure you want to delete this worktree?", false) - .unwrap_or(false); - - if !confirm { - return Ok(()); - } - - // Create deletion configuration - let config = WorktreeDeleteConfig { - name: analysis.worktree.git_name.clone(), // Use git_name for internal operations - path: analysis.worktree.path.clone(), - branch: analysis.worktree.branch.clone(), - delete_branch, - }; - - // Execute deletion using business logic - match execute_deletion(&config, manager) { - Ok(_) => { - println!(); - press_any_key_to_continue()?; - Ok(()) - } - Err(e) => { - utils::print_error(&format!("{e}")); - println!(); - press_any_key_to_continue()?; - Ok(()) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - #[test] - fn test_validate_deletion_target_valid() { - assert!(validate_deletion_target("feature-branch").is_ok()); - assert!(validate_deletion_target("bugfix-123").is_ok()); - assert!(validate_deletion_target("valid-name").is_ok()); - } - - #[test] - fn test_validate_deletion_target_invalid() { - assert!(validate_deletion_target("").is_err()); - assert!(validate_deletion_target("main").is_err()); - assert!(validate_deletion_target("master").is_err()); - } - - #[test] - fn test_should_delete_orphaned_branch_true() { - // Branch name matches worktree name and is unique - assert!(should_delete_orphaned_branch(true, "feature", "feature")); - } - - #[test] - fn test_should_delete_orphaned_branch_false_not_unique() { - // Branch is not unique - assert!(!should_delete_orphaned_branch(false, "feature", "feature")); - } - - #[test] - fn test_should_delete_orphaned_branch_false_name_mismatch() { - // Branch name doesn't match worktree name - assert!(!should_delete_orphaned_branch(true, "main", "feature")); - } - - #[test] - fn test_get_deletable_worktrees_filter_main() { - let worktrees = vec![ - WorktreeInfo { - name: "main".to_string(), - git_name: "main".to_string(), - path: PathBuf::from("/tmp/main"), - branch: "main".to_string(), - is_current: true, - has_changes: false, - last_commit: None, - ahead_behind: None, - is_locked: false, - }, - WorktreeInfo { - name: "feature".to_string(), - git_name: "feature".to_string(), - path: PathBuf::from("/tmp/feature"), - branch: "feature".to_string(), - is_current: false, - has_changes: false, - last_commit: None, - ahead_behind: None, - is_locked: false, - }, - ]; - let deletable = get_deletable_worktrees(&worktrees); - assert_eq!(deletable.len(), 1); - assert_eq!(deletable[0].name, "feature"); - } - - #[test] - fn test_get_deletable_worktrees_empty() { - let worktrees = vec![]; - let deletable = get_deletable_worktrees(&worktrees); - assert!(deletable.is_empty()); - } - - #[test] - fn test_deletion_analysis_creation() { - let worktree = WorktreeInfo { - name: "feature".to_string(), - git_name: "feature".to_string(), - path: PathBuf::from("/tmp/feature"), - branch: "feature".to_string(), - is_current: false, - has_changes: false, - last_commit: None, - ahead_behind: None, - is_locked: false, - }; - - let analysis = DeletionAnalysis { - worktree: worktree.clone(), - is_branch_unique: true, - delete_branch_recommended: true, - }; - - assert_eq!(analysis.worktree.name, "feature"); - assert!(analysis.is_branch_unique); - assert!(analysis.delete_branch_recommended); - } - - #[test] - fn test_execute_deletion_config() { - let config = WorktreeDeleteConfig { - name: "test-worktree".to_string(), - path: PathBuf::from("/tmp/test"), - branch: "test-branch".to_string(), - delete_branch: false, - }; - - // Basic config creation test - assert_eq!(config.name, "test-worktree"); - assert_eq!(config.branch, "test-branch"); - assert!(!config.delete_branch); - } -} +pub use crate::usecases::delete_worktree::*; diff --git a/src/commands/list.rs b/src/commands/list.rs index b7565db..74f3d09 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,701 +1 @@ -use anyhow::Result; -use colored::*; - -use crate::constants::{ - section_header, CURRENT_MARKER, ICON_CURRENT_WORKTREE, ICON_OTHER_WORKTREE, MODIFIED_STATUS_NO, - MODIFIED_STATUS_YES, TABLE_HEADER_BRANCH, TABLE_HEADER_MODIFIED, TABLE_HEADER_NAME, - TABLE_HEADER_PATH, TABLE_SEPARATOR, WARNING_NO_WORKTREES, -}; -use crate::git::{GitWorktreeManager, WorktreeInfo}; -use crate::repository_info::get_repository_info; -use crate::ui::{DialoguerUI, UserInterface}; -use crate::utils::press_any_key_to_continue; - -/// Format worktree display string -#[allow(dead_code)] -pub fn format_worktree_display(worktree: &WorktreeInfo, verbose: bool) -> String { - let mut parts = vec![worktree.name.clone()]; - - if worktree.is_current { - parts.push("(current)".to_string()); - } - - if worktree.is_locked { - parts.push("(locked)".to_string()); - } - - if worktree.has_changes { - parts.push("(changes)".to_string()); - } - - if verbose { - parts.push(format!("- {}", worktree.path.display())); - - if let Some(ref commit) = worktree.last_commit { - parts.push(format!("[{}]", commit.id)); - } - - if let Some((ahead, behind)) = worktree.ahead_behind { - parts.push(format!("↑{ahead} ↓{behind}")); - } - } - - parts.join(" ") -} - -/// Check if worktree should be shown based on filters -#[allow(dead_code)] -pub fn should_show_worktree(worktree: &WorktreeInfo, show_all: bool, filter: Option<&str>) -> bool { - // If filter is provided, check if it matches - if let Some(f) = filter { - return worktree.name.contains(f); - } - - // If show_all is true, show everything - if show_all { - return true; - } - - // Otherwise, only show worktrees with changes - worktree.has_changes -} - -/// Lists all worktrees with detailed information -/// -/// Displays a comprehensive list of all worktrees in the repository, -/// including the current worktree and their paths, branches, and status. -/// -/// # Display Format -/// -/// The list shows: -/// - Repository information (URL, default branch) -/// - Formatted table of worktrees with: -/// - Name (highlighted if current) -/// - Branch name (colored by type) -/// - Path (absolute path to worktree) -/// - Modified status indicator -/// -/// # Returns -/// -/// Returns `Ok(())` on successful completion. -/// -/// # Errors -/// -/// Returns an error if Git repository operations fail. -pub fn list_worktrees() -> Result<()> { - let manager = GitWorktreeManager::new()?; - let ui = DialoguerUI; - list_worktrees_with_ui(&manager, &ui) -} - -/// Internal implementation of list_worktrees with dependency injection -/// -/// # Arguments -/// -/// * `manager` - Git worktree manager instance -/// * `ui` - User interface implementation for testability -pub fn list_worktrees_with_ui(manager: &GitWorktreeManager, _ui: &dyn UserInterface) -> Result<()> { - let worktrees = manager.list_worktrees()?; - - if worktrees.is_empty() { - println!(); - let msg = WARNING_NO_WORKTREES.yellow(); - println!("{msg}"); - println!(); - press_any_key_to_continue()?; - return Ok(()); - } - - // Sort worktrees: current first, then alphabetically - let mut sorted_worktrees = worktrees; - sorted_worktrees.sort_by(|a, b| { - if a.is_current && !b.is_current { - std::cmp::Ordering::Less - } else if !a.is_current && b.is_current { - std::cmp::Ordering::Greater - } else { - a.name.cmp(&b.name) - } - }); - - // Print header - println!(); - let header = section_header("Worktrees"); - println!("{header}"); - println!(); - - // Display repository info - let repo_info = get_repository_info(); - println!("Repository: {}", repo_info.bright_cyan()); - - // Calculate column widths - let max_name_len = sorted_worktrees - .iter() - .map(|w| w.name.len()) - .max() - .unwrap_or(0) - .max(10); - let max_branch_len = sorted_worktrees - .iter() - .map(|w| w.branch.len()) - .max() - .unwrap_or(0) - .max(10) - + 10; // Extra space for [current] marker - - println!(); - println!( - " {: bool { - user_wants_rename && worktree_name == branch_name -} - -/// Configuration for worktree renaming (simplified for tests) -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct RenameConfig { - pub old_name: String, - pub new_name: String, - pub old_path: std::path::PathBuf, - pub new_path: std::path::PathBuf, - pub rename_branch: bool, -} - -/// Configuration for worktree renaming -#[derive(Debug, Clone)] -pub struct WorktreeRenameConfig { - pub old_name: String, - pub new_name: String, - pub old_path: std::path::PathBuf, - pub new_path: std::path::PathBuf, - pub old_branch: String, - pub new_branch: Option, - pub rename_branch: bool, -} - -/// Result of rename analysis -#[derive(Debug, Clone)] -pub struct RenameAnalysis { - pub worktree: WorktreeInfo, - pub can_rename_branch: bool, - pub suggested_branch_name: Option, - pub is_feature_branch: bool, -} - -/// Pure business logic for filtering renameable worktrees -pub fn get_renameable_worktrees(worktrees: &[WorktreeInfo]) -> Vec<&WorktreeInfo> { - worktrees.iter().filter(|w| !w.is_current).collect() -} - -/// Pure business logic for analyzing rename requirements -pub fn analyze_rename_requirements(worktree: &WorktreeInfo) -> Result { - let can_rename_branch = worktree.branch != DEFAULT_BRANCH_DETACHED - && worktree.branch != DEFAULT_BRANCH_UNKNOWN - && (worktree.branch == worktree.name - || worktree.branch == format!("feature/{}", worktree.name)); - - let is_feature_branch = worktree.branch.starts_with("feature/"); - let suggested_branch_name = if can_rename_branch { - Some(if is_feature_branch { - format!("feature/{}", worktree.name) - } else { - worktree.name.clone() - }) - } else { - None - }; - - Ok(RenameAnalysis { - worktree: worktree.clone(), - can_rename_branch, - suggested_branch_name, - is_feature_branch, - }) -} - -/// Pure business logic for validating rename operation -pub fn validate_rename_operation(old_name: &str, new_name: &str) -> Result<()> { - if old_name.is_empty() { - return Err(anyhow!("Old name cannot be empty")); - } - - if new_name.is_empty() { - return Err(anyhow!("New name cannot be empty")); - } - - if new_name == old_name { - return Err(anyhow!("New name must be different from the current name")); - } - - if old_name == "main" || old_name == "master" { - return Err(anyhow!("Cannot rename main worktree")); - } - - if new_name == "main" || new_name == "master" { - return Err(anyhow!("Cannot rename to main")); - } - - Ok(()) -} - -/// Pure business logic for executing rename operation -pub fn execute_rename(config: &WorktreeRenameConfig, manager: &GitWorktreeManager) -> Result<()> { - // Rename worktree - manager - .rename_worktree(&config.old_name, &config.new_name) - .map_err(|e| anyhow!("Failed to rename worktree: {e}"))?; - - utils::print_success(&format!( - "Worktree renamed from '{}' to '{}'!", - config.old_name.yellow(), - config.new_name.bright_green() - )); - - // Rename branch if requested - if config.rename_branch { - if let Some(ref new_branch) = config.new_branch { - utils::print_progress(&format!("Renaming branch to '{new_branch}'...")); - - match manager.rename_branch(&config.old_branch, new_branch) { - Ok(_) => { - utils::print_success(&format!( - "Branch renamed from '{}' to '{}'!", - config.old_branch.yellow(), - new_branch.bright_green() - )); - } - Err(e) => { - return Err(anyhow!("Failed to rename branch: {e}")); - } - } - } - } - - Ok(()) -} - -/// Renames an existing worktree -/// -/// Provides functionality to rename a worktree and optionally its -/// associated branch. This is useful when refactoring feature names -/// or reorganizing worktrees. -/// -/// # Rename Process -/// -/// 1. **Selection**: Choose a worktree to rename (current excluded) -/// 2. **New Name**: Enter the new name for the worktree -/// 3. **Branch Rename**: If branch name matches worktree name, offers to rename it -/// 4. **Preview**: Shows before/after comparison -/// 5. **Execution**: Renames worktree directory and updates Git metadata -/// -/// # Branch Renaming Logic -/// -/// The branch is offered for renaming if: -/// - Branch name equals worktree name -/// - Branch name equals `feature/{worktree-name}` -/// -/// # Limitations -/// -/// - Cannot rename the current worktree -/// - Cannot rename worktrees with detached HEAD -/// - New name must be unique -/// -/// # Returns -/// -/// Returns `Ok(())` on successful completion or cancellation. -/// -/// # Errors -/// -/// Returns an error if: -/// - File system operations fail -/// - Git metadata update fails -/// - New name conflicts with existing worktree -pub fn rename_worktree() -> Result<()> { - let manager = GitWorktreeManager::new()?; - let ui = DialoguerUI; - rename_worktree_with_ui(&manager, &ui) -} - -/// Internal implementation of rename_worktree with dependency injection -/// -/// # Arguments -/// -/// * `manager` - Git worktree manager instance -/// * `ui` - User interface implementation for testability -/// -/// # Implementation Details -/// -/// - Updates worktree directory name -/// - Updates .git/worktrees/`` metadata -/// - Updates gitdir references -/// - Optionally renames associated branch -pub fn rename_worktree_with_ui(manager: &GitWorktreeManager, ui: &dyn UserInterface) -> Result<()> { - let worktrees = manager.list_worktrees()?; - - if worktrees.is_empty() { - println!(); - let msg = "• No worktrees to rename.".yellow(); - println!("{msg}"); - println!(); - press_any_key_to_continue()?; - return Ok(()); - } - - // Use business logic to filter renameable worktrees - let renameable_worktrees = get_renameable_worktrees(&worktrees); - - if renameable_worktrees.is_empty() { - println!(); - let msg = "• No worktrees available for renaming.".yellow(); - println!("{msg}"); - println!( - "{}", - " (Cannot rename the current worktree)".bright_black() - ); - println!(); - press_any_key_to_continue()?; - return Ok(()); - } - - println!(); - let header = section_header("Rename Worktree"); - println!("{header}"); - println!(); - - let items: Vec = renameable_worktrees - .iter() - .map(|w| format!("{} ({})", w.name, w.branch)) - .collect(); - - let selection = match ui.select_with_default( - "Select a worktree to rename (ESC to cancel)", - &items, - DEFAULT_MENU_SELECTION, - ) { - Ok(selection) => selection, - Err(_) => return Ok(()), - }; - - let worktree = renameable_worktrees[selection]; - - // Get new name - println!(); - let new_name = match ui.input(&format!("New name for '{}' (ESC to cancel)", worktree.name)) { - Ok(name) => name.trim().to_string(), - Err(_) => return Ok(()), - }; - - // Use business logic to validate rename operation - if let Err(e) = validate_rename_operation(&worktree.git_name, &new_name) { - utils::print_warning(&e.to_string()); - return Ok(()); - } - - // Validate new name format - let new_name = match validate_worktree_name(&new_name) { - Ok(validated_name) => validated_name, - Err(e) => { - utils::print_error(&format!("Invalid worktree name: {e}")); - println!(); - press_any_key_to_continue()?; - return Ok(()); - } - }; - - // Use business logic to analyze rename requirements - let analysis = analyze_rename_requirements(worktree)?; - - // Ask about branch renaming if applicable - let rename_branch = if analysis.can_rename_branch { - println!(); - match ui.confirm_with_default("Also rename the associated branch?", true) { - Ok(confirm) => confirm, - Err(_) => return Ok(()), - } - } else { - false - }; - - let new_branch = if rename_branch { - if analysis.is_feature_branch { - Some(format!("feature/{new_name}")) - } else { - Some(new_name.clone()) - } - } else { - None - }; - - // Show preview - println!(); - let preview_label = "Preview:".bright_white(); - println!("{preview_label}"); - let worktree_label = "Worktree:".bright_white(); - let old_name = &worktree.name; - let new_name_green = new_name.bright_green(); - println!(" {worktree_label} {old_name} → {new_name_green}"); - - let new_path = worktree.path.parent().unwrap().join(&new_name); - let path_label = "Path:".bright_white(); - let old_path = worktree.path.display(); - let new_path_green = new_path.display().to_string().bright_green(); - println!(" {path_label} {old_path} → {new_path_green}"); - - if let Some(ref new_branch_name) = new_branch { - let branch_label = "Branch:".bright_white(); - let old_branch = &worktree.branch; - let new_branch_green = new_branch_name.bright_green(); - println!(" {branch_label} {old_branch} → {new_branch_green}"); - } - - println!(); - let confirm = match ui.confirm_with_default("Proceed with rename?", false) { - Ok(confirm) => confirm, - Err(_) => return Ok(()), - }; - - if !confirm { - return Ok(()); - } - - // Create rename configuration - let config = WorktreeRenameConfig { - old_name: worktree.git_name.clone(), // Use git_name for internal operations - new_name: new_name.clone(), - old_path: worktree.path.clone(), - new_path, - old_branch: worktree.branch.clone(), - new_branch, - rename_branch, - }; - - // Perform the rename using business logic - utils::print_progress(&format!("Renaming worktree to '{new_name}'...")); - - match execute_rename(&config, manager) { - Ok(_) => { - println!(); - press_any_key_to_continue()?; - Ok(()) - } - Err(e) => { - utils::print_error(&format!("{e}")); - println!(); - press_any_key_to_continue()?; - Ok(()) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - #[test] - fn test_should_rename_branch_true() { - let test_name = "feature"; - // User wants rename and names match - assert!(should_rename_branch(test_name, test_name, true)); - } - - #[test] - fn test_should_rename_branch_false_user_doesnt_want() { - let test_name = "feature"; - // User doesn't want rename even if names match - assert!(!should_rename_branch(test_name, test_name, false)); - } - - #[test] - fn test_should_rename_branch_false_names_mismatch() { - let feature_name = "feature"; - let main_name = "main"; - // Names don't match even if user wants rename - assert!(!should_rename_branch(feature_name, main_name, true)); - } - - #[test] - fn test_rename_config_creation() { - let old_name = "old-name"; - let new_name = "new-name"; - let config = RenameConfig { - old_name: old_name.to_string(), - new_name: new_name.to_string(), - old_path: PathBuf::from("/tmp/old"), - new_path: PathBuf::from("/tmp/new"), - rename_branch: true, - }; - - assert_eq!(config.old_name, old_name); - assert_eq!(config.new_name, new_name); - assert!(config.rename_branch); - } - - #[test] - fn test_worktree_rename_config_creation() { - let old_worktree = "old-worktree"; - let new_worktree = "new-worktree"; - let old_branch = "old-branch"; - let new_branch = "new-branch"; - let config = WorktreeRenameConfig { - old_name: old_worktree.to_string(), - new_name: new_worktree.to_string(), - old_path: PathBuf::from("/tmp/old"), - new_path: PathBuf::from("/tmp/new"), - old_branch: old_branch.to_string(), - new_branch: Some(new_branch.to_string()), - rename_branch: true, - }; - - assert_eq!(config.old_name, old_worktree); - assert_eq!(config.new_name, new_worktree); - assert_eq!(config.old_branch, old_branch); - assert_eq!(config.new_branch, Some(new_branch.to_string())); - assert!(config.rename_branch); - } - - #[test] - fn test_get_renameable_worktrees_filter_current() { - let main_name = "main"; - let feature_name = "feature"; - let worktrees = vec![ - WorktreeInfo { - name: main_name.to_string(), - git_name: main_name.to_string(), - path: PathBuf::from("/tmp/main"), - branch: main_name.to_string(), - is_current: true, // Current worktree - should be filtered out - has_changes: false, - last_commit: None, - ahead_behind: None, - is_locked: false, - }, - WorktreeInfo { - name: feature_name.to_string(), - git_name: feature_name.to_string(), - path: PathBuf::from("/tmp/feature"), - branch: feature_name.to_string(), - is_current: false, - has_changes: false, - last_commit: None, - ahead_behind: None, - is_locked: false, - }, - ]; - - let renameable = get_renameable_worktrees(&worktrees); - assert_eq!(renameable.len(), 1); - assert_eq!(renameable[0].name, feature_name); - } - - #[test] - fn test_analyze_rename_requirements_basic() { - let feature_name = "feature"; - let worktree = WorktreeInfo { - name: feature_name.to_string(), - git_name: feature_name.to_string(), - path: PathBuf::from("/tmp/feature"), - branch: feature_name.to_string(), - is_current: false, - has_changes: false, - last_commit: None, - ahead_behind: None, - is_locked: false, - }; - - let analysis = analyze_rename_requirements(&worktree).unwrap(); - - assert_eq!(analysis.worktree.name, feature_name); - assert!(analysis.can_rename_branch); - assert_eq!( - analysis.suggested_branch_name, - Some(feature_name.to_string()) - ); - } - - #[test] - fn test_validate_rename_operation_valid() { - let old_name = "old-name"; - let new_name = "new-name"; - let result = validate_rename_operation(old_name, new_name); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_rename_operation_same_name() { - let same_name = "same-name"; - let result = validate_rename_operation(same_name, same_name); - assert!(result.is_err()); - } - - #[test] - fn test_validate_rename_operation_main_worktree() { - let main_name = "main"; - let new_name = "new-name"; - let result = validate_rename_operation(main_name, new_name); - assert!(result.is_err()); - - let old_name = "old-name"; - let result = validate_rename_operation(old_name, main_name); - assert!(result.is_err()); - } - - // Add 5 new tests for better coverage - #[test] - fn test_analyze_rename_requirements_feature_branch() { - let worktree_name = "auth"; - let feature_branch = "feature/auth"; - let worktree = WorktreeInfo { - name: worktree_name.to_string(), - git_name: worktree_name.to_string(), - path: PathBuf::from("/tmp/auth"), - branch: feature_branch.to_string(), - is_current: false, - has_changes: false, - last_commit: None, - ahead_behind: None, - is_locked: false, - }; - - let analysis = analyze_rename_requirements(&worktree).unwrap(); - assert!(analysis.can_rename_branch); - assert!(analysis.is_feature_branch); - assert_eq!( - analysis.suggested_branch_name, - Some(format!("feature/{worktree_name}")) - ); - } - - #[test] - fn test_analyze_rename_requirements_detached_head() { - let worktree = WorktreeInfo { - name: "detached".to_string(), - git_name: "detached".to_string(), - path: PathBuf::from("/tmp/detached"), - branch: DEFAULT_BRANCH_DETACHED.to_string(), - is_current: false, - has_changes: false, - last_commit: None, - ahead_behind: None, - is_locked: false, - }; - - let analysis = analyze_rename_requirements(&worktree).unwrap(); - assert!(!analysis.can_rename_branch); - assert!(analysis.suggested_branch_name.is_none()); - } - - #[test] - fn test_validate_rename_operation_empty_names() { - let empty_string = ""; - let valid_name = "valid-name"; - - // Empty old name - assert!(validate_rename_operation(empty_string, valid_name).is_err()); - - // Empty new name - assert!(validate_rename_operation(valid_name, empty_string).is_err()); - } - - #[test] - fn test_validate_rename_operation_master_worktree() { - let master_name = "master"; - let new_name = "new-name"; - let old_name = "old-name"; - - // Cannot rename master worktree - assert!(validate_rename_operation(master_name, new_name).is_err()); - - // Cannot rename to master - assert!(validate_rename_operation(old_name, master_name).is_err()); - } - - #[test] - fn test_get_renameable_worktrees_empty_list() { - let worktrees: Vec = vec![]; - let renameable = get_renameable_worktrees(&worktrees); - assert!(renameable.is_empty()); - } -} +pub use crate::usecases::rename_worktree::*; diff --git a/src/commands/shared.rs b/src/commands/shared.rs index 30a200a..3329b2e 100644 --- a/src/commands/shared.rs +++ b/src/commands/shared.rs @@ -1,803 +1,21 @@ -use anyhow::{anyhow, Result}; -use colored::*; -use dialoguer::{Confirm, FuzzySelect, MultiSelect}; -use std::process::Command; - -/// Configuration for search operations -#[derive(Debug, Clone)] -pub struct SearchConfig { - pub query: String, - pub show_current_indicator: bool, -} - -/// Configuration for batch delete operations -#[derive(Debug, Clone)] -pub struct BatchDeleteConfig { - pub selected_worktrees: Vec, - pub delete_orphaned_branches: bool, -} - -/// Result of search analysis -#[derive(Debug, Clone)] -pub struct SearchAnalysis { - pub items: Vec, - pub total_count: usize, - pub has_current: bool, -} -use crate::constants::{ - section_header, CONFIG_FILE_NAME, DEFAULT_BRANCH_DETACHED, DEFAULT_EDITOR_UNIX, - DEFAULT_EDITOR_WINDOWS, DEFAULT_WORKTREE_CLEANUP_DAYS, EMOJI_DETACHED, EMOJI_FOLDER, - EMOJI_HOME, EMOJI_LOCKED, ENV_EDITOR, ENV_VISUAL, GIT_DIR, HEADER_SEARCH_WORKTREES, - HOOK_POST_SWITCH, HOOK_PRE_REMOVE, MSG_ALREADY_IN_WORKTREE, MSG_NO_WORKTREES_TO_SEARCH, - MSG_SEARCH_FUZZY_ENABLED, PROMPT_SELECT_WORKTREE_SWITCH, SEARCH_CURRENT_INDICATOR, +pub use crate::adapters::config::loader::{find_config_file_path, find_config_file_path_internal}; +pub use crate::app::presenter::get_worktree_icon; +pub use crate::usecases::cleanup_worktrees::cleanup_old_worktrees; +pub use crate::usecases::delete_worktree::{ + batch_delete_worktrees, prepare_batch_delete_items, BatchDeleteConfig, +}; +pub use crate::usecases::edit_hooks::edit_hooks; +pub use crate::usecases::search_worktrees::{ + create_search_items, search_worktrees, validate_search_selection, SearchAnalysis, SearchConfig, }; -use crate::git::{GitWorktreeManager, WorktreeInfo}; -use crate::hooks::{self, HookContext}; -use crate::input_esc_raw::input_esc_with_default_raw as input_esc_with_default; -use crate::utils::{self, get_theme, press_any_key_to_continue, write_switch_path}; - -/// Pure business logic for creating search items -pub fn create_search_items(worktrees: &[WorktreeInfo]) -> SearchAnalysis { - let items: Vec = worktrees - .iter() - .map(|wt| { - let mut item = format!("{} ({})", wt.name, wt.branch); - if wt.is_current { - item.push_str(SEARCH_CURRENT_INDICATOR); - } - item - }) - .collect(); - - let has_current = worktrees.iter().any(|w| w.is_current); - - SearchAnalysis { - total_count: worktrees.len(), - has_current, - items, - } -} - -/// Pure business logic for validating search selection -pub fn validate_search_selection( - worktrees: &[WorktreeInfo], - selection_index: usize, -) -> Result<&WorktreeInfo> { - if selection_index >= worktrees.len() { - return Err(anyhow!("Invalid selection index")); - } - - let selected = &worktrees[selection_index]; - Ok(selected) -} - -/// Pure business logic for filtering deletable worktrees for batch operations -pub fn prepare_batch_delete_items(worktrees: &[WorktreeInfo]) -> Vec { - worktrees - .iter() - .filter(|w| !w.is_current) - .map(|w| format!("{} ({})", w.name, w.branch)) - .collect() -} - -/// Searches and switches to worktrees using fuzzy search -/// -/// Provides an interactive fuzzy search interface for finding and switching -/// to worktrees by name or branch. This is useful when you have many worktrees -/// and want to quickly navigate between them. -/// -/// # Search Features -/// -/// - **Fuzzy Search**: Type partial matches to filter worktrees -/// - **Current Indicator**: Current worktree is marked with indicator -/// - **Branch Display**: Shows both worktree name and branch name -/// - **Quick Navigation**: Switch directly without going through menus -/// -/// # Example Search Patterns -/// -/// - `feat` matches "feature/login", "feature/logout" -/// - `lgn` matches "login", "feature/login" (fuzzy matching) -pub fn search_worktrees() -> Result { - let manager = GitWorktreeManager::new()?; - search_worktrees_internal(&manager) -} - -/// Internal implementation of search_worktrees -/// -/// # Arguments -/// -/// * `manager` - Git worktree manager instance -/// -/// # Returns -/// -/// Returns `true` if a worktree was selected and switched to, `false` otherwise -/// (includes ESC cancellation or selecting current worktree). -fn search_worktrees_internal(manager: &GitWorktreeManager) -> Result { - let worktrees = manager.list_worktrees()?; - - if worktrees.is_empty() { - println!(); - let msg = MSG_NO_WORKTREES_TO_SEARCH.yellow(); - println!("{msg}"); - println!(); - press_any_key_to_continue()?; - return Ok(false); - } - - println!(); - let header = section_header(HEADER_SEARCH_WORKTREES); - println!("{header}"); - println!(); - - // Use business logic to create search items - let analysis = create_search_items(&worktrees); - - // Use FuzzySelect for interactive search - println!("{MSG_SEARCH_FUZZY_ENABLED}"); - let selection = match FuzzySelect::with_theme(&get_theme()) - .with_prompt(PROMPT_SELECT_WORKTREE_SWITCH) - .items(&analysis.items) - .interact_opt()? - { - Some(selection) => selection, - None => return Ok(false), - }; - - // Use business logic to validate selection - let selected_worktree = validate_search_selection(&worktrees, selection)?; - - if selected_worktree.is_current { - println!(); - let msg = MSG_ALREADY_IN_WORKTREE.yellow(); - println!("{msg}"); - println!(); - press_any_key_to_continue()?; - return Ok(false); - } - - // Switch to the selected worktree - write_switch_path(&selected_worktree.path); - - println!(); - let plus_sign = "+".green(); - let worktree_name = selected_worktree.name.bright_white().bold(); - println!("{plus_sign} Switching to worktree '{worktree_name}'"); - let path_label = "Path:".bright_black(); - let path_display = selected_worktree.path.display(); - println!(" {path_label} {path_display}"); - let branch_label = "Branch:".bright_black(); - let branch_name = selected_worktree.branch.yellow(); - println!(" {branch_label} {branch_name}"); - - // Execute post-switch hooks - if let Err(e) = hooks::execute_hooks( - HOOK_POST_SWITCH, - &HookContext { - worktree_name: selected_worktree.name.clone(), - worktree_path: selected_worktree.path.clone(), - }, - ) { - utils::print_warning(&format!("Hook execution warning: {e}")); - } - - Ok(true) -} - -/// Batch deletes multiple worktrees with optional branch cleanup -/// -/// Provides a multi-select interface for deleting multiple worktrees -/// in a single operation. This is useful for cleaning up multiple -/// feature branches or experimental worktrees. The function automatically -/// detects branches that would become orphaned and offers to delete them. -/// -/// # Selection Interface -/// -/// - Space: Toggle selection on current item -/// - Enter: Confirm and proceed with deletion -/// - ESC: Cancel operation -/// -/// # Deletion Process -/// -/// 1. **Multi-select**: Choose multiple worktrees (current excluded) -/// 2. **Branch Analysis**: Identifies branches unique to selected worktrees -/// 3. **Summary**: Shows selected worktrees and orphaned branches -/// 4. **Confirmation**: Confirms worktree deletion -/// 5. **Branch Confirmation**: If orphaned branches exist, asks to delete them -/// 6. **Batch Execution**: Deletes worktrees and optionally their branches -/// -/// # Branch Management -/// -/// - Uses `is_branch_unique_to_worktree` to identify orphaned branches -/// - Lists orphaned branches separately in the summary -/// - Only deletes branches for successfully deleted worktrees -/// - Reports branch deletion results separately -/// -/// # Safety -/// -/// - Cannot select/delete the current worktree -/// - Shows comprehensive summary before deletion -/// - Separate confirmations for worktrees and branches -/// - Executes pre-remove hooks for each worktree -/// - Continues with remaining deletions if one fails -/// -/// # Returns -/// -/// Returns `Ok(())` on completion. Individual deletion failures are -/// reported but don't stop the batch operation. -/// -/// # Errors -/// -/// Returns an error only if the operation cannot start (e.g., repository access fails). -pub fn batch_delete_worktrees() -> Result<()> { - let manager = GitWorktreeManager::new()?; - batch_delete_worktrees_internal(&manager) -} - -/// Internal implementation of batch_delete_worktrees -/// -/// # Arguments -/// -/// * `manager` - Git worktree manager instance -/// -/// # Implementation Details -/// -/// Uses dialoguer's MultiSelect for the selection interface and provides -/// comprehensive feedback during the deletion process. The function handles -/// errors gracefully and continues with remaining deletions even if some fail. -fn batch_delete_worktrees_internal(manager: &GitWorktreeManager) -> Result<()> { - let worktrees = manager.list_worktrees()?; - - if worktrees.is_empty() { - println!(); - let msg = "• No worktrees to delete.".yellow(); - println!("{msg}"); - println!(); - press_any_key_to_continue()?; - return Ok(()); - } - - // Filter out current worktree - let deletable_worktrees: Vec<&WorktreeInfo> = - worktrees.iter().filter(|w| !w.is_current).collect(); - - if deletable_worktrees.is_empty() { - println!(); - let msg = "• No worktrees available for deletion.".yellow(); - println!("{msg}"); - println!( - "{}", - " (Cannot delete the current worktree)".bright_black() - ); - println!(); - press_any_key_to_continue()?; - return Ok(()); - } - - println!(); - let header = section_header("Batch Delete Worktrees"); - println!("{header}"); - println!(); - - let items: Vec = deletable_worktrees - .iter() - .map(|w| format!("{} ({})", w.name, w.branch)) - .collect(); - - let selections = MultiSelect::with_theme(&get_theme()) - .with_prompt( - "Select worktrees to delete (Space to toggle, Enter to confirm, ESC to cancel)", - ) - .items(&items) - .interact_opt()?; - - let selections = match selections { - Some(s) if !s.is_empty() => s, - _ => return Ok(()), - }; - - let selected_worktrees: Vec<&WorktreeInfo> = - selections.iter().map(|&i| deletable_worktrees[i]).collect(); - - // Check for branches that will become orphaned - let mut branches_to_delete = Vec::new(); - for wt in &selected_worktrees { - if manager.is_branch_unique_to_worktree(&wt.branch, &wt.name)? { - branches_to_delete.push((wt.branch.clone(), wt.name.clone())); - } - } - - // Show summary - println!(); - let summary_label = "Summary:".bright_white(); - println!("{summary_label}"); - println!(); - let worktrees_label = "Selected worktrees:".bright_cyan(); - println!("{worktrees_label}"); - for wt in &selected_worktrees { - let name = wt.name.bright_red(); - let branch = &wt.branch; - println!(" • {name} ({branch})"); - } - - if !branches_to_delete.is_empty() { - println!(); - let branches_label = "Branches that will become orphaned:".bright_yellow(); - println!("{branches_label}"); - for (branch, _) in &branches_to_delete { - let branch_yellow = branch.bright_yellow(); - println!(" • {branch_yellow}"); - } - } - - println!(); - let warning = "⚠ Warning".red().bold(); - println!("{warning}"); - let selected_count = selected_worktrees.len(); - println!("This will delete {selected_count} worktree(s) and their files."); - if !branches_to_delete.is_empty() { - let branch_count = branches_to_delete.len(); - println!("This action will also make {branch_count} branch(es) orphaned."); - } - println!(); - - let confirm = Confirm::with_theme(&get_theme()) - .with_prompt("Are you sure you want to delete these worktrees?") - .default(false) - .interact_opt()? - .unwrap_or(false); - - if !confirm { - return Ok(()); - } - - // Ask about branch deletion if there are orphaned branches - let delete_branches = if !branches_to_delete.is_empty() { - println!(); - Confirm::with_theme(&get_theme()) - .with_prompt("Also delete the orphaned branches?") - .default(false) - .interact_opt()? - .unwrap_or(false) - } else { - false - }; - - // Delete worktrees - println!(); - let mut success_count = 0; - let mut error_count = 0; - let mut deleted_worktrees = Vec::new(); - - for wt in &selected_worktrees { - // Execute pre-remove hooks - if let Err(e) = hooks::execute_hooks( - HOOK_PRE_REMOVE, - &HookContext { - worktree_name: wt.name.clone(), - worktree_path: wt.path.clone(), - }, - ) { - utils::print_warning(&format!("Hook execution warning: {e}")); - } - - match manager.remove_worktree(&wt.git_name) { - Ok(_) => { - let name_red = wt.name.bright_red(); - utils::print_success(&format!("Deleted worktree '{name_red}'")); - deleted_worktrees.push((wt.branch.clone(), wt.name.clone())); - success_count += 1; - } - Err(e) => { - let name = &wt.name; - utils::print_error(&format!("Failed to delete '{name}': {e}")); - error_count += 1; - } - } - } - - // Delete branches if requested - if delete_branches { - let mut branch_success = 0; - let mut branch_error = 0; - - println!(); - for (branch, worktree_name) in &branches_to_delete { - // Only delete branches for successfully deleted worktrees - if deleted_worktrees - .iter() - .any(|(b, w)| b == branch && w == worktree_name) - { - match manager.delete_branch(branch) { - Ok(_) => { - let branch_red = branch.bright_red(); - utils::print_success(&format!("Deleted branch '{branch_red}'")); - branch_success += 1; - } - Err(e) => { - utils::print_error(&format!("Failed to delete branch '{branch}': {e}")); - branch_error += 1; - } - } - } - } - - if branch_success > 0 || branch_error > 0 { - println!(); - println!( - "{} Deleted {} branch(es), {} failed", - "•".bright_green(), - branch_success, - branch_error - ); - } - } - - println!(); - println!( - "{} Deleted {} worktree(s), {} failed", - "•".bright_green(), - success_count, - error_count - ); - - println!(); - press_any_key_to_continue()?; - - Ok(()) -} - -/// Cleans up old worktrees based on age -/// -/// **Note**: This feature is currently not implemented and serves as -/// a placeholder for future functionality. -/// -/// When implemented, this function will: -/// - Identify worktrees older than a specified number of days -/// - Show a preview of worktrees to be deleted -/// - Allow batch deletion of old worktrees -/// -/// # Current Behavior -/// -/// Displays a message indicating the feature is not yet implemented. -/// -/// # Returns -/// -/// Always returns `Ok(())` after displaying the message. -pub fn cleanup_old_worktrees() -> Result<()> { - let manager = GitWorktreeManager::new()?; - cleanup_old_worktrees_internal(&manager) -} - -/// Internal implementation of cleanup_old_worktrees -/// -/// # Arguments -/// -/// * `manager` - Git worktree manager instance -/// -/// # Future Implementation -/// -/// Will require: -/// - Tracking worktree creation dates (possibly in .git-workers.toml) -/// - Age calculation logic -/// - Preview and confirmation UI -fn cleanup_old_worktrees_internal(manager: &GitWorktreeManager) -> Result<()> { - let worktrees = manager.list_worktrees()?; - - if worktrees.is_empty() { - println!(); - let msg = "• No worktrees to clean up.".yellow(); - println!("{msg}"); - println!(); - press_any_key_to_continue()?; - return Ok(()); - } - - println!(); - let header = section_header("Cleanup Old Worktrees"); - println!("{header}"); - println!(); - - // Get age threshold - let _days = match input_esc_with_default( - "Delete worktrees older than (days)", - DEFAULT_WORKTREE_CLEANUP_DAYS, - ) { - Some(days_str) => match days_str.parse::() { - Ok(d) => d, - Err(_) => { - utils::print_error("Invalid number"); - return Ok(()); - } - }, - None => return Ok(()), - }; - - // Find old worktrees (mock implementation - would need actual age tracking) - println!(); - utils::print_warning("Age-based cleanup is not yet implemented."); - println!( - "{}", - "This feature requires tracking worktree creation dates.".bright_black() - ); - - println!(); - press_any_key_to_continue()?; - - Ok(()) -} - -/// Edits the hooks configuration file -/// -/// Opens the `.git-workers.toml` configuration file in the user's -/// preferred editor, allowing them to configure lifecycle hooks. -/// -/// # Configuration File Location -/// -/// The function uses the exact same configuration file discovery logic as `Config::load()`, -/// ensuring consistency across all features. The search order depends on repository type: -/// -/// ## Bare Repositories -/// 1. Current directory -/// 2. Default branch subdirectories (e.g., `./main/.git-workers.toml`) -/// 3. Existing worktree pattern detection via `git worktree list` -/// 4. Common directory fallbacks (`branch/`, `worktrees/`) -/// 5. Sibling directories -/// -/// ## Non-bare Repositories -/// 1. Current directory (current worktree) -/// 2. Main repository directory (where `.git` is a directory) -/// 3. `main/` or `master/` subdirectories in parent paths -/// -/// This ensures hooks configuration is found in the same location as other -/// configurations, maintaining consistency across all git-workers features. -/// -/// # Editor Selection -/// -/// Uses the following priority for editor selection: -/// 1. `EDITOR` environment variable -/// 2. `VISUAL` environment variable -/// 3. Platform default (vi on Unix, notepad on Windows) -/// -/// # File Creation -/// -/// If the configuration file doesn't exist, offers to create it -/// with a template containing example hooks for all lifecycle events. -/// -/// # Template -/// -/// The generated template includes: -/// - Repository URL configuration (optional) -/// - Post-create hooks example -/// - Pre-remove hooks example -/// - Post-switch hooks example -/// - Documentation for template variables -/// -/// # Returns -/// -/// Returns `Ok(())` after editing is complete or cancelled. -/// -/// # Errors -/// -/// Returns an error if: -/// - Not in a Git repository -/// - Cannot determine configuration file location -/// - Editor fails to launch -pub fn edit_hooks() -> Result<()> { - println!(); - let header = section_header("Edit Hooks Configuration"); - println!("{header}"); - println!(); - - // Find the config file location using the same logic as Config::load() - let config_path = if let Ok(repo) = git2::Repository::discover(".") { - find_config_file_path_internal(&repo)? - } else { - utils::print_error("Not in a git repository"); - println!(); - press_any_key_to_continue()?; - return Ok(()); - }; - - // Create the file if it doesn't exist - if !config_path.exists() { - let msg = "• No configuration file found.".yellow(); - println!("{msg}"); - println!(); - - let create = Confirm::with_theme(&get_theme()) - .with_prompt(format!("Create {CONFIG_FILE_NAME}?")) - .default(true) - .interact_opt()? - .unwrap_or(false); - - if create { - // Create a template configuration - let template = r#"# Git Workers configuration file - -[repository] -# Repository URL for identification (optional) -# This ensures hooks only run in the intended repository -# url = "https://github.com/owner/repo.git" - -[hooks] -# Run after creating a new worktree -post-create = [ - # "npm install", - # "cp .env.example .env" -] - -# Run before removing a worktree -pre-remove = [ - # "rm -rf node_modules" -] - -# Run after switching to a worktree -post-switch = [ - # "echo 'Switched to {{worktree_name}}'" -] - -[files] -# Optional: Specify a custom source directory -# If not specified, automatically finds the main worktree -# source = "/path/to/custom/source" -# source = "./templates" # Relative to repository root - -# Files to copy when creating new worktrees -copy = [ - # ".env", - # ".env.local" -] -"#; - - std::fs::write(&config_path, template)?; - utils::print_success(&format!("Created {CONFIG_FILE_NAME} with template")); - } else { - return Ok(()); - } - } - - // Get the user's preferred editor - let editor = std::env::var(ENV_EDITOR) - .or_else(|_| std::env::var(ENV_VISUAL)) - .unwrap_or_else(|_| { - if cfg!(target_os = "windows") { - DEFAULT_EDITOR_WINDOWS.to_string() - } else { - DEFAULT_EDITOR_UNIX.to_string() - } - }); - - println!( - "{} Opening {} with {}...", - "•".bright_blue(), - config_path.display().to_string().bright_white(), - editor.bright_yellow() - ); - println!(); - - // Open the editor - let status = Command::new(&editor).arg(&config_path).status(); - - match status { - Ok(status) if status.success() => { - utils::print_success("Configuration file edited successfully"); - } - Ok(_) => { - utils::print_warning("Editor exited with non-zero status"); - } - Err(e) => { - utils::print_error(&format!("Failed to open editor: {e}")); - println!(); - println!("You can manually edit the file at:"); - let path_str = config_path.display().to_string().bright_white(); - println!(" {path_str}"); - } - } - - println!(); - press_any_key_to_continue()?; - - Ok(()) -} - -/// Finds the configuration file path using GitWorktreeManager -/// -/// This is a convenience wrapper around find_config_file_path_internal -/// that works with GitWorktreeManager instances. -pub fn find_config_file_path(manager: &GitWorktreeManager) -> Result { - find_config_file_path_internal(manager.repo()) -} - -// Helper function to find config file path with the same logic as Config::load() -pub fn find_config_file_path_internal(repo: &git2::Repository) -> Result { - if repo.is_bare() { - // For bare repositories - use complex discovery logic - if let Ok(cwd) = std::env::current_dir() { - // 1. Check current directory first - let current_config = cwd.join(CONFIG_FILE_NAME); - if current_config.exists() { - return Ok(current_config); - } - - // Default: use current directory for creation - Ok(cwd.join(CONFIG_FILE_NAME)) - } else { - // Can't get current directory - Err(anyhow::anyhow!("Cannot determine current directory")) - } - } else { - // For non-bare repositories - same logic as Config::load_from_main_repository_only() - if let Ok(cwd) = std::env::current_dir() { - // 1. First check current directory - let current_config = cwd.join(CONFIG_FILE_NAME); - if current_config.exists() { - return Ok(current_config); - } - - // 2. Check if this is the main worktree - if let Some(workdir) = repo.workdir() { - let workdir_path = workdir.to_path_buf(); - - // Check if current directory is the main worktree - if cwd == workdir_path { - return Ok(workdir_path.join(CONFIG_FILE_NAME)); - } - - // If not, check if the main worktree exists - let git_path = workdir_path.join(GIT_DIR); - if git_path.is_dir() && workdir_path.exists() { - let config_path = workdir_path.join(CONFIG_FILE_NAME); - if config_path.exists() { - return Ok(config_path); - } - } - } - - // Default: use current directory for creation - Ok(cwd.join(CONFIG_FILE_NAME)) - } else { - // Final fallback: use repository working directory - repo.workdir() - .map(|p| p.join(CONFIG_FILE_NAME)) - .ok_or_else(|| anyhow::anyhow!("No working directory found")) - } - } -} - -/// Returns an appropriate icon for a worktree based on its status -/// -/// This function provides visual indicators for different worktree states -/// using emoji icons. The returned icon helps users quickly identify the -/// state of each worktree in listings and menus. -/// -/// # Arguments -/// -/// * `worktree` - The worktree information containing status flags -/// -/// # Returns -/// -/// Returns a static string containing the appropriate emoji: -/// - `🏠` for current worktree -/// - `🔒` for locked worktree -/// - `🔗` for detached HEAD worktree -/// - `📁` for regular worktree -#[allow(dead_code)] -pub fn get_worktree_icon(worktree: &WorktreeInfo) -> &'static str { - if worktree.is_current { - EMOJI_HOME - } else if worktree.is_locked { - EMOJI_LOCKED - } else if worktree.branch == DEFAULT_BRANCH_DETACHED { - EMOJI_DETACHED - } else { - EMOJI_FOLDER - } -} #[cfg(test)] mod tests { use super::*; + use crate::constants::SEARCH_CURRENT_INDICATOR; + use crate::git::WorktreeInfo; use crate::infrastructure::git::GitWorktreeManager; + use anyhow::Result; use std::fs; use std::process::Command; use tempfile::TempDir; diff --git a/src/commands/switch.rs b/src/commands/switch.rs index d533ccc..294720e 100644 --- a/src/commands/switch.rs +++ b/src/commands/switch.rs @@ -1,365 +1 @@ -use anyhow::{anyhow, Result}; -use colored::*; - -use crate::constants::{ - section_header, DEFAULT_MENU_SELECTION, HOOK_POST_SWITCH, MSG_ALREADY_IN_WORKTREE, -}; -use crate::git::{GitWorktreeManager, WorktreeInfo}; -use crate::hooks::{self, HookContext}; -use crate::ui::{DialoguerUI, UserInterface}; -use crate::utils::{self, press_any_key_to_continue, write_switch_path}; - -/// Validate switch target -#[allow(dead_code)] -pub fn validate_switch_target(name: &str) -> Result<()> { - if name.is_empty() { - return Err(anyhow!("Switch target cannot be empty")); - } - - if name.contains(' ') || name.contains('\t') || name.contains('\n') { - return Err(anyhow!("Invalid worktree name: contains whitespace")); - } - - Ok(()) -} - -/// Check if already in the target worktree -#[allow(dead_code)] -pub fn is_already_in_worktree(current: &Option, target: &str) -> bool { - match current { - Some(current_name) => current_name == target, - None => false, - } -} - -/// Configuration for worktree switching (simplified for tests) -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct SwitchConfig { - pub target_name: String, - pub target_path: std::path::PathBuf, - pub source_name: Option, - pub save_changes: bool, -} - -/// Configuration for worktree switching -#[derive(Debug, Clone)] -pub struct WorktreeSwitchConfig { - pub target_name: String, - pub target_path: std::path::PathBuf, - pub target_branch: String, -} - -/// Result of switch analysis -#[derive(Debug, Clone)] -pub struct SwitchAnalysis { - pub worktrees: Vec, - pub current_worktree_index: Option, - pub is_already_current: bool, -} - -/// Pure business logic for sorting worktrees for display -pub fn sort_worktrees_for_display(mut worktrees: Vec) -> Vec { - worktrees.sort_by(|a, b| { - if a.is_current && !b.is_current { - std::cmp::Ordering::Less - } else if !a.is_current && b.is_current { - std::cmp::Ordering::Greater - } else { - a.name.cmp(&b.name) - } - }); - worktrees -} - -/// Pure business logic for analyzing switch target -pub fn analyze_switch_target( - worktrees: &[WorktreeInfo], - selected_index: usize, -) -> Result { - if selected_index >= worktrees.len() { - return Err(anyhow!("Invalid selection index")); - } - - let current_index = worktrees.iter().position(|w| w.is_current); - let selected_worktree = &worktrees[selected_index]; - - Ok(SwitchAnalysis { - worktrees: worktrees.to_vec(), - current_worktree_index: current_index, - is_already_current: selected_worktree.is_current, - }) -} - -/// Pure business logic for executing switch operation -pub fn execute_switch(config: &WorktreeSwitchConfig) -> Result<()> { - // Write switch path for shell integration - write_switch_path(&config.target_path); - - // Execute post-switch hooks - if let Err(e) = hooks::execute_hooks( - HOOK_POST_SWITCH, - &HookContext { - worktree_name: config.target_name.clone(), - worktree_path: config.target_path.clone(), - }, - ) { - utils::print_warning(&format!("Hook execution warning: {e}")); - } - - Ok(()) -} - -/// Switches to a different worktree -/// -/// Displays a list of all worktrees with the current one marked, -/// allowing the user to select and switch to a different worktree. -/// -/// # Switching Process -/// -/// 1. Shows all worktrees (current one marked) -/// 2. User selects target worktree -/// 3. Writes target path for shell integration -/// 4. Executes post-switch hooks -/// -/// # Shell Integration -/// -/// The actual directory change is handled by the shell wrapper. -/// This function writes the target path to either: -/// - File specified by `GW_SWITCH_FILE` environment variable -/// - Standard output with `SWITCH_TO:` prefix (legacy mode) -/// -/// # Returns -/// -/// Returns `true` if the user switched worktrees, `false` otherwise -/// (includes selecting current worktree or cancellation). -/// -/// # Errors -/// -/// Returns an error if: -/// - Git repository operations fail -/// - File write operations fail -pub fn switch_worktree() -> Result { - let manager = GitWorktreeManager::new()?; - let ui = DialoguerUI; - switch_worktree_with_ui(&manager, &ui) -} - -/// Internal implementation of switch_worktree with dependency injection -/// -/// # Arguments -/// -/// * `manager` - Git worktree manager instance -/// * `ui` - User interface implementation for testability -/// -/// # Returns -/// -/// Returns `true` if a switch occurred, `false` if cancelled or already in selected worktree -pub fn switch_worktree_with_ui( - manager: &GitWorktreeManager, - ui: &dyn UserInterface, -) -> Result { - let worktrees = manager.list_worktrees()?; - - if worktrees.is_empty() { - println!(); - let msg = "• No worktrees available.".yellow(); - println!("{msg}"); - println!(); - press_any_key_to_continue()?; - return Ok(false); - } - - println!(); - let header = section_header("Switch Worktree"); - println!("{header}"); - println!(); - - // Use business logic to sort worktrees for display - let sorted_worktrees = sort_worktrees_for_display(worktrees); - - let items: Vec = sorted_worktrees - .iter() - .map(|w| { - if w.is_current { - format!("{} ({}) [current]", w.name, w.branch) - } else { - format!("{} ({})", w.name, w.branch) - } - }) - .collect(); - - let selection = match ui.select_with_default( - "Select a worktree to switch to (ESC to cancel)", - &items, - DEFAULT_MENU_SELECTION, - ) { - Ok(selection) => selection, - Err(_) => return Ok(false), - }; - - // Use business logic to analyze the switch target - let analysis = analyze_switch_target(&sorted_worktrees, selection)?; - - if analysis.is_already_current { - println!(); - let msg = MSG_ALREADY_IN_WORKTREE.yellow(); - println!("{msg}"); - println!(); - press_any_key_to_continue()?; - return Ok(false); - } - - let selected_worktree = &sorted_worktrees[selection]; - - // Create switch configuration - let config = WorktreeSwitchConfig { - target_name: selected_worktree.name.clone(), - target_path: selected_worktree.path.clone(), - target_branch: selected_worktree.branch.clone(), - }; - - println!(); - let plus_sign = "+".green(); - let worktree_name = config.target_name.bright_white().bold(); - println!("{plus_sign} Switching to worktree '{worktree_name}'"); - let path_label = "Path:".bright_black(); - let path_display = config.target_path.display(); - println!(" {path_label} {path_display}"); - let branch_label = "Branch:".bright_black(); - let branch_name = config.target_branch.yellow(); - println!(" {branch_label} {branch_name}"); - - // Execute switch using business logic - execute_switch(&config)?; - - Ok(true) -} - -#[cfg(test)] // Re-enabled tests with corrected WorktreeInfo fields -mod tests { - use super::*; - use std::path::PathBuf; - - #[test] - fn test_validate_switch_target_valid() { - assert!(validate_switch_target("feature-branch").is_ok()); - assert!(validate_switch_target("main").is_ok()); - assert!(validate_switch_target("valid_name").is_ok()); - assert!(validate_switch_target("123").is_ok()); - } - - #[test] - fn test_validate_switch_target_invalid() { - assert!(validate_switch_target("").is_err()); - assert!(validate_switch_target("name with space").is_err()); - assert!(validate_switch_target("name\twith\ttab").is_err()); - assert!(validate_switch_target("name\nwith\nnewline").is_err()); - } - - #[test] - fn test_is_already_in_worktree_true() { - let current = Some("feature".to_string()); - assert!(is_already_in_worktree(¤t, "feature")); - } - - #[test] - fn test_is_already_in_worktree_false_different_name() { - let current = Some("main".to_string()); - assert!(!is_already_in_worktree(¤t, "feature")); - } - - #[test] - fn test_is_already_in_worktree_false_no_current() { - let current = None; - assert!(!is_already_in_worktree(¤t, "feature")); - } - - #[test] - fn test_switch_config_creation() { - let config = SwitchConfig { - target_name: "feature".to_string(), - target_path: PathBuf::from("/tmp/feature"), - source_name: Some("main".to_string()), - save_changes: true, - }; - - assert_eq!(config.target_name, "feature"); - assert_eq!(config.source_name, Some("main".to_string())); - assert!(config.save_changes); - } - - #[test] - fn test_worktree_switch_config_creation() { - let config = WorktreeSwitchConfig { - target_name: "feature".to_string(), - target_path: PathBuf::from("/tmp/feature"), - target_branch: "feature-branch".to_string(), - }; - - assert_eq!(config.target_name, "feature"); - assert_eq!(config.target_branch, "feature-branch"); - assert_eq!(config.target_path, PathBuf::from("/tmp/feature")); - } - - #[test] - fn test_sort_worktrees_for_display() { - let worktrees = vec![ - WorktreeInfo { - name: "zzz-last".to_string(), - git_name: "zzz-last".to_string(), - path: PathBuf::from("/tmp/zzz"), - branch: "zzz-branch".to_string(), - is_locked: false, - is_current: false, - has_changes: false, - last_commit: None, - ahead_behind: None, - }, - WorktreeInfo { - name: "aaa-first".to_string(), - git_name: "aaa-first".to_string(), - path: PathBuf::from("/tmp/aaa"), - branch: "aaa-branch".to_string(), - is_locked: false, - is_current: true, - has_changes: false, - last_commit: None, - ahead_behind: None, - }, - ]; - - let sorted = sort_worktrees_for_display(worktrees); - assert_eq!(sorted[0].name, "aaa-first"); // Current worktree should be first - assert_eq!(sorted[1].name, "zzz-last"); - assert!(sorted[0].is_current); - assert!(!sorted[1].is_current); - } - - #[test] - fn test_analyze_switch_target_basic() { - let worktrees = vec![WorktreeInfo { - name: "main".to_string(), - git_name: "main".to_string(), - path: PathBuf::from("/tmp/main"), - branch: "main".to_string(), - is_locked: false, - is_current: false, - has_changes: false, - last_commit: None, - ahead_behind: None, - }]; - - let analysis = analyze_switch_target(&worktrees, 0).unwrap(); - assert_eq!(analysis.worktrees[0].name, "main"); - assert!(!analysis.is_already_current); - assert_eq!(analysis.current_worktree_index, None); - } - - #[test] - fn test_analyze_switch_target_invalid_index() { - let worktrees = vec![]; - let result = analyze_switch_target(&worktrees, 0); - assert!(result.is_err()); - } -} +pub use crate::usecases::switch_worktree::*; diff --git a/src/domain/branch.rs b/src/domain/branch.rs new file mode 100644 index 0000000..4550fb7 --- /dev/null +++ b/src/domain/branch.rs @@ -0,0 +1 @@ +pub use crate::git_interface::{BranchInfo, TagInfo}; diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..ccd0777 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,5 @@ +pub mod branch; +pub mod paths; +pub mod repo_context; +pub mod validation; +pub mod worktree; diff --git a/src/domain/paths.rs b/src/domain/paths.rs new file mode 100644 index 0000000..0a7a052 --- /dev/null +++ b/src/domain/paths.rs @@ -0,0 +1,3 @@ +pub use crate::usecases::create_worktree::{ + determine_worktree_path, validate_worktree_creation, validate_worktree_location, +}; diff --git a/src/domain/repo_context.rs b/src/domain/repo_context.rs new file mode 100644 index 0000000..e9c8698 --- /dev/null +++ b/src/domain/repo_context.rs @@ -0,0 +1,249 @@ +#[cfg(not(test))] +use crate::constants::MAIN_SUFFIX; +use crate::constants::UNKNOWN_VALUE; +use std::env; +#[cfg(not(test))] +use std::path::Path; +use std::process::Command; + +#[cfg(not(test))] +fn paths_equal(lhs: &std::path::Path, rhs: &std::path::Path) -> bool { + match (lhs.canonicalize(), rhs.canonicalize()) { + (Ok(lhs), Ok(rhs)) => lhs == rhs, + _ => lhs == rhs, + } +} + +#[cfg(not(test))] +fn get_repo_name_from_git_at_path(path: &Path) -> Option { + let output = Command::new("git") + .args(["rev-parse", "--git-dir"]) + .current_dir(path) + .output() + .ok()?; + + if output.status.success() { + let git_dir = String::from_utf8(output.stdout).ok()?; + let git_path = std::path::PathBuf::from(git_dir.trim()); + + if git_path.file_name().and_then(|name| name.to_str()) == Some(".git") { + let toplevel_output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(path) + .output() + .ok()?; + + if toplevel_output.status.success() { + let toplevel = String::from_utf8(toplevel_output.stdout).ok()?; + let path = std::path::PathBuf::from(toplevel.trim()); + return path + .file_name() + .and_then(|name| name.to_str()) + .map(|s| s.to_string()); + } + } + + if git_path.as_os_str() == "." { + if let Some(current_dir_name) = path.file_name().and_then(|name| name.to_str()) { + if let Some(stripped) = current_dir_name.strip_suffix(".bare") { + return Some(stripped.to_string()); + } else { + return Some(current_dir_name.to_string()); + } + } + } + + if let Some(parent) = git_path.parent() { + if parent.file_name().and_then(|name| name.to_str()) == Some("worktrees") { + if let Some(repo_dir) = parent.parent() { + if let Some(repo_name) = repo_dir.file_name().and_then(|name| name.to_str()) { + if let Some(stripped) = repo_name.strip_suffix(".bare") { + return Some(stripped.to_string()); + } else if repo_name == ".git" { + if let Some(actual_repo_dir) = repo_dir.parent() { + if let Some(actual_repo_name) = + actual_repo_dir.file_name().and_then(|name| name.to_str()) + { + return Some(actual_repo_name.to_string()); + } + } + } else { + return Some(repo_name.to_string()); + } + } + } + } + } + + if let Some(git_dir_name) = git_path.file_name().and_then(|name| name.to_str()) { + if let Some(stripped) = git_dir_name.strip_suffix(".bare") { + return Some(stripped.to_string()); + } + } + + None + } else { + None + } +} + +pub fn get_repository_info() -> String { + get_repository_info_at_path(&env::current_dir().unwrap_or_else(|_| UNKNOWN_VALUE.into())) +} + +#[cfg(test)] +pub fn get_repository_info_at_path(path: &std::path::Path) -> String { + use std::process::Stdio; + + let git_dir_output = Command::new("git") + .args(["rev-parse", "--git-dir"]) + .current_dir(path) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(); + + if let Ok(output) = git_dir_output { + if output.status.success() { + if let Ok(git_dir) = String::from_utf8(output.stdout) { + let git_path = std::path::PathBuf::from(git_dir.trim()); + + if git_path.file_name().and_then(|name| name.to_str()) == Some(".git") { + let toplevel_output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(path) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output(); + + if let Ok(toplevel_out) = toplevel_output { + if toplevel_out.status.success() { + if let Ok(toplevel) = String::from_utf8(toplevel_out.stdout) { + let toplevel_path = std::path::PathBuf::from(toplevel.trim()); + if let Some(repo_name) = + toplevel_path.file_name().and_then(|name| name.to_str()) + { + return repo_name.to_string(); + } + } + } + } + } + + if git_path.as_os_str() == "." { + if let Some(dir_name) = path.file_name().and_then(|name| name.to_str()) { + if let Some(stripped) = dir_name.strip_suffix(".bare") { + return stripped.to_string(); + } else { + return dir_name.to_string(); + } + } + } + + if let Some(parent) = git_path.parent() { + if parent.file_name().and_then(|name| name.to_str()) == Some("worktrees") { + if let Some(repo_dir) = parent.parent() { + if let Some(repo_name) = + repo_dir.file_name().and_then(|name| name.to_str()) + { + let base_repo_name = + if let Some(stripped) = repo_name.strip_suffix(".bare") { + stripped.to_string() + } else if repo_name == ".git" { + if let Some(actual_repo_dir) = repo_dir.parent() { + if let Some(actual_repo_name) = actual_repo_dir + .file_name() + .and_then(|name| name.to_str()) + { + actual_repo_name.to_string() + } else { + UNKNOWN_VALUE.to_string() + } + } else { + UNKNOWN_VALUE.to_string() + } + } else { + repo_name.to_string() + }; + + if let Some(worktree_name) = + path.file_name().and_then(|name| name.to_str()) + { + return format!("{base_repo_name} ({worktree_name})"); + } + } + } + } + } + } + } + } + + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(UNKNOWN_VALUE) + .to_string() +} + +#[cfg(not(test))] +pub fn get_repository_info_at_path(path: &std::path::Path) -> String { + if let Ok(repo) = git2::Repository::discover(path) { + let current_dir = path.to_path_buf(); + + let repo_name = get_repo_name_from_git_at_path(path).unwrap_or_else(|| { + if repo.is_bare() { + repo.path() + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.strip_suffix(".bare").unwrap_or(name).to_string()) + .unwrap_or_else(|| UNKNOWN_VALUE.to_string()) + } else if let Some(workdir) = repo.workdir() { + workdir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(UNKNOWN_VALUE) + .to_string() + } else { + current_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(UNKNOWN_VALUE) + .to_string() + } + }); + + if repo.is_bare() { + repo_name + } else { + let worktree_name = current_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(UNKNOWN_VALUE); + + let dot_git_path = current_dir.join(".git"); + if dot_git_path.is_file() { + format!("{repo_name} ({worktree_name})") + } else if repo + .path() + .join(crate::constants::WORKTREES_SUBDIR) + .exists() + { + if let Some(workdir) = repo.workdir() { + if paths_equal(workdir, ¤t_dir) { + repo_name + } else { + format!("{repo_name}{MAIN_SUFFIX}") + } + } else { + format!("{repo_name}{MAIN_SUFFIX}") + } + } else { + repo_name + } + } + } else { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(UNKNOWN_VALUE) + .to_string() + } +} diff --git a/src/domain/validation.rs b/src/domain/validation.rs new file mode 100644 index 0000000..50fe78e --- /dev/null +++ b/src/domain/validation.rs @@ -0,0 +1 @@ +pub use crate::core::{validate_custom_path, validate_worktree_name}; diff --git a/src/domain/worktree.rs b/src/domain/worktree.rs new file mode 100644 index 0000000..c61a013 --- /dev/null +++ b/src/domain/worktree.rs @@ -0,0 +1 @@ +pub use crate::git::WorktreeInfo; diff --git a/src/lib.rs b/src/lib.rs index e4f202d..0cc0f99 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,16 +44,21 @@ //! let result = commands::list_worktrees(); //! ``` +pub mod adapters; +pub mod app; pub mod commands; pub mod config; pub mod constants; pub mod core; +pub mod domain; pub mod git_interface; pub mod infrastructure; pub mod input_esc_raw; pub mod menu; pub mod repository_info; +pub mod support; pub mod ui; +pub mod usecases; pub mod utils; // Re-export infrastructure modules for backward compatibility diff --git a/src/main.rs b/src/main.rs index 965d68c..aebea3b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,97 +1,18 @@ //! Git Workers - Interactive Git Worktree Manager -//! -//! This is the main entry point for the Git Workers CLI application. -//! It provides an interactive menu-driven interface for managing Git worktrees. -//! -#![allow(dead_code)] -//! # Overview -//! -//! Git Workers simplifies the management of Git worktrees by providing an intuitive -//! TUI (Terminal User Interface) that handles common worktree operations: -//! -//! - Creating new worktrees from branches or HEAD -//! - Switching between worktrees with automatic directory changes -//! - Deleting worktrees (individually or in batch) -//! - Renaming worktrees and their associated branches -//! - Searching through worktrees with fuzzy matching -//! - Managing lifecycle hooks for automation -//! -//! # Architecture -//! -//! The application follows a simple architecture: -//! -//! 1. **Main Loop**: Displays the menu and handles user selection -//! 2. **Command Handlers**: Execute the selected operations (see [`commands`] module) -//! 3. **Git Integration**: Interfaces with Git via git2 and process commands (see [`git`] module) -//! 4. **Shell Integration**: Enables automatic directory switching when changing worktrees -//! -//! # Shell Integration -//! -//! The application supports automatic directory switching through shell functions. -//! When switching worktrees, it writes the target path to a file specified by -//! the `GW_SWITCH_FILE` environment variable. The shell wrapper then reads this -//! file and executes the `cd` command. -//! -//! # Exit Codes -//! -//! - `0`: Successful execution -//! - `1`: Error during execution (displayed to user) use anyhow::Result; use clap::Parser; -use colored::*; -use console::Term; -use std::env; -use std::io::{self, Write}; -use git_workers::{commands, constants, menu, repository_info}; +use git_workers::app; -use constants::header_separator; -use git_workers::ui::{DialoguerUI, UserInterface}; -use menu::MenuItem; -use repository_info::get_repository_info; - -/// Command-line arguments for Git Workers -/// -/// Currently supports minimal CLI arguments as the application is primarily -/// interactive. Future versions may add support for direct command execution. #[derive(Parser)] #[command(name = "gw")] #[command(about = "Interactive Git Worktree Manager", long_about = None)] struct Cli { - /// Print version information and exit - /// - /// When specified, prints the version number from Cargo.toml and exits - /// without entering the interactive mode. #[arg(short, long)] version: bool, } -/// Main entry point for Git Workers -/// -/// Initializes the CLI, handles version flag, and runs the main interactive loop. -/// The application will continue running until the user selects "Exit" or presses ESC. -/// -/// # Flow -/// -/// 1. Parse command-line arguments -/// 2. Handle version flag if present -/// 3. Configure terminal settings for optimal display -/// 4. Enter the main menu loop: -/// - Clear screen and display header -/// - Show repository information -/// - Display menu options -/// - Handle user selection -/// - Execute selected command -/// - Repeat until exit -/// -/// # Errors -/// -/// Returns an error if: -/// - Terminal operations fail (rare) -/// - Command execution encounters an unrecoverable error -/// -/// Most errors are handled gracefully within the loop and displayed to the user. fn main() -> Result<()> { let cli = Cli::parse(); @@ -101,303 +22,5 @@ fn main() -> Result<()> { return Ok(()); } - // Terminal check removed - we'll handle errors gracefully when they occur - // Some terminal environments may not be detected correctly by is_terminal() - - // Create terminal instance for consistent handling - let term = console::Term::stdout(); - - // Configure terminal and color output - setup_terminal_config(); - - loop { - // Clear screen and show header for each iteration - clear_screen(&term); - - // Force flush to ensure screen is cleared before new content - let _ = io::stdout().flush(); - - // Print clean header with repository info - let repo_info = get_repository_info(); - - println!(); - let version = env!("CARGO_PKG_VERSION"); - let title = format!("Git Workers v{version} - Interactive Worktree Manager") - .bright_cyan() - .bold(); - println!("{title}"); - let separator = header_separator(); - println!("{separator}"); - let label = "Repository:".bright_white(); - let info = repo_info.bright_yellow().bold(); - println!("{label} {info}"); - println!(); - - // Build menu items - let menu_items = [ - MenuItem::ListWorktrees, - MenuItem::SwitchWorktree, - MenuItem::SearchWorktrees, - MenuItem::CreateWorktree, - MenuItem::DeleteWorktree, - MenuItem::BatchDelete, - MenuItem::CleanupOldWorktrees, - MenuItem::RenameWorktree, - MenuItem::EditHooks, - MenuItem::Exit, - ]; - - // Convert to display strings - let display_items: Vec = menu_items.iter().map(|item| item.to_string()).collect(); - - // Show menu with List worktrees as default selection - let ui = DialoguerUI; - let selection = match ui.select_with_default( - constants::PROMPT_ACTION, - &display_items, - constants::DEFAULT_MENU_SELECTION, - ) { - Ok(selection) => selection, - Err(_) => { - // User pressed ESC - exit cleanly - clear_screen(&term); - let exit_msg = constants::INFO_EXITING.bright_black(); - println!("{exit_msg}"); - break; - } - }; - - let selected_item = &menu_items[selection]; - - match handle_menu_item(selected_item, &term)? { - MenuAction::Continue => continue, - MenuAction::Exit => { - clear_screen(&term); - let exit_msg = constants::INFO_EXITING.bright_black(); - println!("{exit_msg}"); - break; - } - MenuAction::ExitAfterSwitch => { - // Exit without clearing screen (to preserve switch message) - break; - } - } - } - - Ok(()) -} - -/// Represents the action to take after handling a menu item -/// -/// This enum controls the flow of the main loop, determining whether -/// to continue showing the menu or exit the application. -enum MenuAction { - /// Continue the main loop and show the menu again - Continue, - /// Exit the application with a farewell message - Exit, - /// Exit after switching worktree (preserves switch message) - /// - /// This special exit mode is used when the user switches to a different - /// worktree. It exits without clearing the screen to preserve the switch - /// information for the shell wrapper to process. - ExitAfterSwitch, -} - -/// Handles the selected menu item and returns the next action -/// -/// This function is the central dispatcher for all menu commands. It clears -/// the screen, executes the appropriate command, and determines the next -/// action based on the result. -/// -/// # Arguments -/// -/// * `item` - The selected menu item to execute -/// * `term` - Terminal instance for screen operations -/// -/// # Returns -/// -/// Returns a [`MenuAction`] indicating whether to: -/// - Continue showing the menu -/// - Exit the application -/// - Exit after a worktree switch (special case) -/// -/// # Errors -/// -/// Propagates any errors from the command execution. These are typically -/// handled by displaying an error message to the user. -fn handle_menu_item(item: &MenuItem, term: &Term) -> Result { - clear_screen(term); - - match item { - MenuItem::ListWorktrees => commands::list_worktrees()?, - MenuItem::CreateWorktree => { - if commands::create_worktree()? { - // User created and switched to new worktree - return Ok(MenuAction::ExitAfterSwitch); - } - } - MenuItem::DeleteWorktree => commands::delete_worktree()?, - MenuItem::SwitchWorktree => { - if commands::switch_worktree()? { - // User switched worktree - exit to apply the change - return Ok(MenuAction::ExitAfterSwitch); - } - } - MenuItem::SearchWorktrees => { - if commands::search_worktrees()? { - // User switched worktree via search - return Ok(MenuAction::ExitAfterSwitch); - } - } - MenuItem::BatchDelete => commands::batch_delete_worktrees()?, - MenuItem::CleanupOldWorktrees => commands::cleanup_old_worktrees()?, - MenuItem::RenameWorktree => commands::rename_worktree()?, - MenuItem::EditHooks => commands::edit_hooks()?, - MenuItem::Exit => return Ok(MenuAction::Exit), - } - - Ok(MenuAction::Continue) -} - -/// Clears the terminal screen with proper error handling -/// -/// This function wraps the terminal clear operation to gracefully handle -/// any potential errors. Errors are ignored as screen clearing is not -/// critical to the application's functionality. -/// -/// # Arguments -/// -/// * `term` - Terminal instance to clear -fn clear_screen(term: &Term) { - let _ = term.clear_screen(); -} - -/// Configures terminal settings for optimal display -/// -/// This function sets up the terminal environment for the best possible -/// user experience across different platforms and terminal emulators. -/// -/// # Configuration -/// -/// 1. **Windows**: Enables ANSI color support on Windows terminals -/// 2. **Color Mode**: Respects environment variables for color output: -/// - `NO_COLOR`: Disables all color output when set -/// - `FORCE_COLOR` or `CLICOLOR_FORCE=1`: Forces color output even in pipes -/// -/// # Environment Variables -/// -/// The following environment variables control color output: -/// - `NO_COLOR`: When set (any value), disables colored output -/// - `FORCE_COLOR`: When set (any value), forces colored output -/// - `CLICOLOR_FORCE`: When set to "1", forces colored output -fn setup_terminal_config() { - // Enable ANSI colors on Windows - #[cfg(windows)] - { - let _ = colored::control::set_virtual_terminal(true); - } - - // Set color mode based on environment - if env::var(constants::ENV_NO_COLOR).is_ok() { - colored::control::set_override(false); - } else if env::var(constants::ENV_FORCE_COLOR).is_ok() - || env::var(constants::ENV_CLICOLOR_FORCE).unwrap_or_default() - == constants::ENV_CLICOLOR_FORCE_VALUE - { - colored::control::set_override(true); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use console::Term; - - #[test] - fn test_menu_action_enum_creation() { - // Test that MenuAction variants can be created - let continue_action = MenuAction::Continue; - let exit_action = MenuAction::Exit; - let exit_after_switch = MenuAction::ExitAfterSwitch; - - // These should compile and not panic - match continue_action { - MenuAction::Continue => { /* expected */ } - _ => unreachable!("Expected MenuAction::Continue"), - } - - match exit_action { - MenuAction::Exit => { /* expected */ } - _ => unreachable!("Expected MenuAction::Exit"), - } - - match exit_after_switch { - MenuAction::ExitAfterSwitch => { /* expected */ } - _ => unreachable!("Expected MenuAction::ExitAfterSwitch"), - } - } - - #[test] - fn test_clear_screen_basic() { - // Test that clear_screen doesn't panic - // We can't easily test the actual clearing without mocking terminal - let term = Term::stdout(); - clear_screen(&term); - // If we get here without panic, the function works - } - - #[test] - fn test_setup_terminal_config_basic() { - // Test that setup_terminal_config doesn't panic - setup_terminal_config(); - // If we get here without panic, the function works - } - - #[test] - fn test_setup_terminal_config_with_no_color() { - // Test NO_COLOR environment variable handling - std::env::set_var(constants::ENV_NO_COLOR, "1"); - setup_terminal_config(); - std::env::remove_var(constants::ENV_NO_COLOR); - - // Function executed without panic - } - - #[test] - fn test_setup_terminal_config_with_force_color() { - // Test FORCE_COLOR environment variable handling - std::env::set_var(constants::ENV_FORCE_COLOR, "1"); - setup_terminal_config(); - std::env::remove_var(constants::ENV_FORCE_COLOR); - - // Function executed without panic - } - - #[test] - fn test_setup_terminal_config_with_clicolor_force() { - // Test CLICOLOR_FORCE environment variable handling - std::env::set_var( - constants::ENV_CLICOLOR_FORCE, - constants::ENV_CLICOLOR_FORCE_VALUE, - ); - setup_terminal_config(); - std::env::remove_var(constants::ENV_CLICOLOR_FORCE); - - // Function executed without panic - } - - #[test] - fn test_handle_menu_item_exit() -> Result<()> { - // Test handling of Exit menu item - let term = Term::stdout(); - let result = handle_menu_item(&MenuItem::Exit, &term)?; - - match result { - MenuAction::Exit => { /* expected */ } - _ => panic!("Expected MenuAction::Exit"), - } - - Ok(()) - } + app::run() } diff --git a/src/menu.rs b/src/menu.rs index f3b15cd..65649e3 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -1,218 +1 @@ -//! Menu item definitions and display formatting -//! -//! This module defines the menu items available in the interactive interface -//! and their display representations. Each menu item corresponds to a specific -//! worktree management operation. -//! -//! # Design -//! -//! Menu items are represented as an enum to ensure type safety and make it -//! easy to add new operations. Each item has a consistent display format with -//! an icon prefix for visual clarity. - -use crate::constants::*; -use std::fmt; - -/// Available menu items in the interactive interface -/// -/// Each variant represents a distinct operation that can be performed -/// on Git worktrees. The order of variants matches the typical workflow -/// and frequency of use. -/// -/// # Display Format -/// -/// Each menu item is displayed with: -/// - A unique icon/symbol prefix -/// - A descriptive label -/// - Consistent spacing for alignment -/// -/// # Example -/// -/// ``` -/// use git_workers::menu::MenuItem; -/// -/// let item = MenuItem::CreateWorktree; -/// println!("{}", item); // Output: "+ Create worktree" -/// ``` -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum MenuItem { - /// List all worktrees with status information - ListWorktrees, - /// Search through worktrees using fuzzy matching - SearchWorktrees, - /// Create a new worktree - CreateWorktree, - /// Delete a single worktree - DeleteWorktree, - /// Delete multiple worktrees at once - BatchDelete, - /// Remove worktrees older than a specified age - CleanupOldWorktrees, - /// Switch to a different worktree (changes directory) - SwitchWorktree, - /// Rename an existing worktree - RenameWorktree, - /// Edit hooks configuration - EditHooks, - /// Exit the application - Exit, -} - -impl fmt::Display for MenuItem { - /// Formats menu items with consistent icons and spacing - /// - /// Each menu item is formatted with a distinctive icon followed by - /// two spaces and a descriptive label. This creates a visually - /// appealing and easy-to-scan menu. - /// - /// # Icon Meanings - /// - /// - `•` List - Bullet point for viewing items - /// - `?` Search - Question mark for queries - /// - `+` Create - Plus sign for adding - /// - `-` Delete - Minus sign for removing - /// - `=` Batch - Equals sign for multiple items - /// - `~` Cleanup - Tilde for maintenance tasks - /// - `→` Switch - Arrow for navigation - /// - `*` Rename - Asterisk for modification - /// - `⚙` Settings - Gear for configuration - /// - `x` Exit - X for closing - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - MenuItem::ListWorktrees => write!(f, "{MENU_LIST_WORKTREES}"), - MenuItem::SearchWorktrees => write!(f, "{MENU_SEARCH_WORKTREES}"), - MenuItem::CreateWorktree => write!(f, "{MENU_CREATE_WORKTREE}"), - MenuItem::DeleteWorktree => write!(f, "{MENU_DELETE_WORKTREE}"), - MenuItem::BatchDelete => write!(f, "{MENU_BATCH_DELETE}"), - MenuItem::CleanupOldWorktrees => write!(f, "{MENU_CLEANUP_OLD}"), - MenuItem::SwitchWorktree => write!(f, "{MENU_SWITCH_WORKTREE}"), - MenuItem::RenameWorktree => write!(f, "{MENU_RENAME_WORKTREE}"), - MenuItem::EditHooks => write!(f, "{MENU_EDIT_HOOKS}"), - MenuItem::Exit => write!(f, "{MENU_EXIT}"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_fmt_list_worktrees() { - let item = MenuItem::ListWorktrees; - let formatted = format!("{item}"); - assert!(!formatted.is_empty()); - assert!(formatted.contains(MENU_LIST_WORKTREES)); - } - - #[test] - fn test_fmt_search_worktrees() { - let item = MenuItem::SearchWorktrees; - let formatted = format!("{item}"); - assert!(!formatted.is_empty()); - assert!(formatted.contains(MENU_SEARCH_WORKTREES)); - } - - #[test] - fn test_fmt_create_worktree() { - let item = MenuItem::CreateWorktree; - let formatted = format!("{item}"); - assert!(!formatted.is_empty()); - assert!(formatted.contains(MENU_CREATE_WORKTREE)); - } - - #[test] - fn test_fmt_delete_worktree() { - let item = MenuItem::DeleteWorktree; - let formatted = format!("{item}"); - assert!(!formatted.is_empty()); - assert!(formatted.contains(MENU_DELETE_WORKTREE)); - } - - #[test] - fn test_fmt_batch_delete() { - let item = MenuItem::BatchDelete; - let formatted = format!("{item}"); - assert!(!formatted.is_empty()); - assert!(formatted.contains(MENU_BATCH_DELETE)); - } - - #[test] - fn test_fmt_cleanup_old() { - let item = MenuItem::CleanupOldWorktrees; - let formatted = format!("{item}"); - assert!(!formatted.is_empty()); - assert!(formatted.contains(MENU_CLEANUP_OLD)); - } - - #[test] - fn test_fmt_switch_worktree() { - let item = MenuItem::SwitchWorktree; - let formatted = format!("{item}"); - assert!(!formatted.is_empty()); - assert!(formatted.contains(MENU_SWITCH_WORKTREE)); - } - - #[test] - fn test_fmt_rename_worktree() { - let item = MenuItem::RenameWorktree; - let formatted = format!("{item}"); - assert!(!formatted.is_empty()); - assert!(formatted.contains(MENU_RENAME_WORKTREE)); - } - - #[test] - fn test_fmt_edit_hooks() { - let item = MenuItem::EditHooks; - let formatted = format!("{item}"); - assert!(!formatted.is_empty()); - assert!(formatted.contains(MENU_EDIT_HOOKS)); - } - - #[test] - fn test_fmt_exit() { - let item = MenuItem::Exit; - let formatted = format!("{item}"); - assert!(!formatted.is_empty()); - assert!(formatted.contains(MENU_EXIT)); - } - - #[test] - fn test_menu_item_enum_properties() { - // Test that the enum variants are properly defined - let items = [ - MenuItem::ListWorktrees, - MenuItem::SearchWorktrees, - MenuItem::CreateWorktree, - MenuItem::DeleteWorktree, - MenuItem::BatchDelete, - MenuItem::CleanupOldWorktrees, - MenuItem::SwitchWorktree, - MenuItem::RenameWorktree, - MenuItem::EditHooks, - MenuItem::Exit, - ]; - - // Test that all items can be formatted without panic - for item in items { - let formatted = format!("{item}"); - assert!( - !formatted.is_empty(), - "Menu item should format to non-empty string" - ); - } - } - - #[test] - fn test_menu_item_equality() { - assert_eq!(MenuItem::CreateWorktree, MenuItem::CreateWorktree); - assert_ne!(MenuItem::CreateWorktree, MenuItem::DeleteWorktree); - } - - #[test] - fn test_menu_item_clone() { - let item = MenuItem::CreateWorktree; - let cloned = item; - assert_eq!(item, cloned); - } -} +pub use crate::app::menu::*; diff --git a/src/repository_info.rs b/src/repository_info.rs index c7bccdd..b5f4aba 100644 --- a/src/repository_info.rs +++ b/src/repository_info.rs @@ -1,348 +1,33 @@ -//! Repository information display functionality -//! -//! This module provides functions to determine and format repository -//! information for display in the application header. It intelligently -//! detects the repository type and current context to provide meaningful -//! information to the user. -//! -//! # Repository Types -//! -//! The module handles several repository configurations: -//! - **Bare repositories**: Repositories without working directories -//! - **Main worktrees**: The primary working directory with worktrees -//! - **Worktrees**: Secondary working directories linked to a main repository -//! - **Standard repositories**: Regular Git repositories without worktrees -//! - **Non-Git directories**: Fallback for directories outside Git control +//! Repository information display compatibility facade. -use crate::constants::{MAIN_SUFFIX, UNKNOWN_VALUE}; -#[cfg(not(test))] -use crate::git::GitWorktreeManager; -use std::env; -use std::process::Command; - -/// Get repository name using git directory analysis -/// -/// Uses `git rev-parse --git-dir` to find the git directory and traces back -/// to find the actual repository name, handling both bare and worktree cases. -#[cfg(not(test))] -fn get_repo_name_from_git() -> Option { - let output = Command::new("git") - .args(["rev-parse", "--git-dir"]) - .output() - .ok()?; - - if output.status.success() { - let git_dir = String::from_utf8(output.stdout).ok()?; - let git_path = std::path::PathBuf::from(git_dir.trim()); - - // If git_dir is just ".git", get the repository name from toplevel - if git_path.file_name().and_then(|name| name.to_str()) == Some(".git") { - // For regular repositories, get toplevel - let toplevel_output = Command::new("git") - .args(["rev-parse", "--show-toplevel"]) - .output() - .ok()?; - - if toplevel_output.status.success() { - let toplevel = String::from_utf8(toplevel_output.stdout).ok()?; - let path = std::path::PathBuf::from(toplevel.trim()); - return path - .file_name() - .and_then(|name| name.to_str()) - .map(|s| s.to_string()); - } - } - - // If git_dir is ".", we're in a bare repository root - if git_path.as_os_str() == "." { - let current_dir = env::current_dir().ok()?; - if let Some(current_dir_name) = current_dir.file_name().and_then(|name| name.to_str()) { - if let Some(stripped) = current_dir_name.strip_suffix(".bare") { - return Some(stripped.to_string()); - } else { - return Some(current_dir_name.to_string()); - } - } - } - - // For worktrees, the git_dir path looks like: - // /Users/user/repo.bare/worktrees/name (bare repository) - // or /Users/user/repo/.git/worktrees/name (regular repository) - if let Some(parent) = git_path.parent() { - // Check if this is a worktree structure - if parent.file_name().and_then(|name| name.to_str()) == Some("worktrees") { - if let Some(repo_dir) = parent.parent() { - if let Some(repo_name) = repo_dir.file_name().and_then(|name| name.to_str()) { - if let Some(stripped) = repo_name.strip_suffix(".bare") { - // Bare repository: /repo.bare/worktrees/name - return Some(stripped.to_string()); - } else if repo_name == ".git" { - // Non-bare repository: /repo/.git/worktrees/name - // Need to get the parent of .git directory - if let Some(actual_repo_dir) = repo_dir.parent() { - if let Some(actual_repo_name) = - actual_repo_dir.file_name().and_then(|name| name.to_str()) - { - return Some(actual_repo_name.to_string()); - } - } - } else { - // Regular repository case (shouldn't happen but fallback) - return Some(repo_name.to_string()); - } - } - } - } - } - - // Check if git_dir is a bare repository directory directly - if let Some(git_dir_name) = git_path.file_name().and_then(|name| name.to_str()) { - if let Some(stripped) = git_dir_name.strip_suffix(".bare") { - return Some(stripped.to_string()); - } - } - - // Fallback: try to get repository name from current path analysis - None - } else { - None - } -} - -/// Gets a formatted string representing the current repository context -/// -/// This function determines whether we're in a bare repository, a worktree, -/// or the main working directory and formats the information accordingly. -/// -/// # Return Format -/// -/// - For bare repositories: `"repo-name.bare"` -/// - For worktrees: `"parent-repo (worktree-name)"` -/// - For main worktree: `"repo-name (main)"` -/// - For non-git directories: `"directory-name"` -/// -/// # Example Output -/// -/// ```text -/// my-project.bare // Bare repository -/// my-project (feature-branch) // Worktree -/// my-project (main) // Main worktree with other worktrees -/// my-project // Regular repository without worktrees -/// ``` -pub fn get_repository_info() -> String { - get_repository_info_at_path(&env::current_dir().unwrap_or_else(|_| UNKNOWN_VALUE.into())) -} - -/// Gets repository info for a specific path (internal, used for testing) -#[cfg(test)] -pub fn get_repository_info_at_path(path: &std::path::Path) -> String { - use std::process::Stdio; - - // Run git commands in the specified directory - let git_dir_output = Command::new("git") - .args(["rev-parse", "--git-dir"]) - .current_dir(path) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output(); - - if let Ok(output) = git_dir_output { - if output.status.success() { - if let Ok(git_dir) = String::from_utf8(output.stdout) { - let git_path = std::path::PathBuf::from(git_dir.trim()); - - // If git_dir is just ".git", get the repository name from toplevel - if git_path.file_name().and_then(|name| name.to_str()) == Some(".git") { - // For regular repositories, get toplevel - let toplevel_output = Command::new("git") - .args(["rev-parse", "--show-toplevel"]) - .current_dir(path) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output(); - - if let Ok(toplevel_out) = toplevel_output { - if toplevel_out.status.success() { - if let Ok(toplevel) = String::from_utf8(toplevel_out.stdout) { - let toplevel_path = std::path::PathBuf::from(toplevel.trim()); - if let Some(repo_name) = - toplevel_path.file_name().and_then(|name| name.to_str()) - { - return repo_name.to_string(); - } - } - } - } - } - - // If git_dir is ".", we're in a bare repository root - if git_path.as_os_str() == "." { - if let Some(dir_name) = path.file_name().and_then(|name| name.to_str()) { - if let Some(stripped) = dir_name.strip_suffix(".bare") { - return stripped.to_string(); - } else { - return dir_name.to_string(); - } - } - } - - // Handle worktree cases - if let Some(parent) = git_path.parent() { - if parent.file_name().and_then(|name| name.to_str()) == Some("worktrees") { - if let Some(repo_dir) = parent.parent() { - if let Some(repo_name) = - repo_dir.file_name().and_then(|name| name.to_str()) - { - let base_repo_name = - if let Some(stripped) = repo_name.strip_suffix(".bare") { - stripped.to_string() - } else if repo_name == ".git" { - // Non-bare repository worktree - if let Some(actual_repo_dir) = repo_dir.parent() { - if let Some(actual_repo_name) = actual_repo_dir - .file_name() - .and_then(|name| name.to_str()) - { - actual_repo_name.to_string() - } else { - UNKNOWN_VALUE.to_string() - } - } else { - UNKNOWN_VALUE.to_string() - } - } else { - repo_name.to_string() - }; - - // Get worktree name - if let Some(worktree_name) = - path.file_name().and_then(|name| name.to_str()) - { - return format!("{base_repo_name} ({worktree_name})"); - } - } - } - } - } - } - } - } - - // Fallback to directory name - path.file_name() - .and_then(|name| name.to_str()) - .unwrap_or(UNKNOWN_VALUE) - .to_string() -} - -/// Gets repository info for production (non-test) use -#[cfg(not(test))] -pub fn get_repository_info_at_path(_path: &std::path::Path) -> String { - // Try to get Git repository information - if let Ok(manager) = GitWorktreeManager::new() { - let repo = manager.repo(); - let current_dir = env::current_dir().unwrap_or_else(|_| UNKNOWN_VALUE.into()); - - // Try to get repository name from git command first - let repo_name = get_repo_name_from_git().unwrap_or_else(|| { - if repo.is_bare() { - // Fallback for bare repository - repo.path() - .parent() - .and_then(|parent| parent.file_name()) - .and_then(|name| name.to_str()) - .unwrap_or(UNKNOWN_VALUE) - .to_string() - } else { - // Fallback to current directory name - current_dir - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(UNKNOWN_VALUE) - .to_string() - } - }); - - if repo.is_bare() { - // For bare repository, always show just the repo name - repo_name - } else { - let worktree_name = current_dir - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(UNKNOWN_VALUE); - - // Check if we're in a worktree by looking for .git file (not directory) - let dot_git_path = current_dir.join(".git"); - if dot_git_path.is_file() { - // This is a worktree - show repo name with worktree name - format!("{repo_name} ({worktree_name})") - } else if repo - .path() - .join(crate::constants::WORKTREES_SUBDIR) - .exists() - { - // This is the main worktree of a repository with worktrees - // Check if we are actually in the main repository root, not a subdirectory - if let Some(workdir) = repo.workdir() { - if workdir == current_dir { - // We're in the main repo root, don't show (main) suffix - repo_name - } else { - format!("{repo_name}{MAIN_SUFFIX}") - } - } else { - format!("{repo_name}{MAIN_SUFFIX}") - } - } else { - // Regular repository without worktrees - repo_name - } - } - } else { - // Not in a git repository - // Fall back to showing the current directory name - let current_dir = env::current_dir().unwrap_or_else(|_| UNKNOWN_VALUE.into()); - current_dir - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(UNKNOWN_VALUE) - .to_string() - } -} +pub use crate::domain::repo_context::{get_repository_info, get_repository_info_at_path}; #[cfg(test)] mod tests { use super::*; + use crate::constants::{MAIN_SUFFIX, UNKNOWN_VALUE}; #[test] fn test_get_repository_info_non_git() { - // This test will succeed in a non-git context let info = get_repository_info(); assert!(!info.is_empty()); - // Should return some directory name } #[test] fn test_constants_are_used() { - // Test that our constants are defined and accessible assert_eq!(UNKNOWN_VALUE, "unknown"); assert_eq!(MAIN_SUFFIX, " (main)"); } #[test] fn test_repository_info_function_exists() { - // Test that the function can be called without panicking let _info = get_repository_info(); - // Function should not panic - // Test passes if function completes without panicking } #[test] fn test_bare_repository_name_extraction() { use std::path::PathBuf; - // Test bare repository path extraction logic let bare_repo_path = PathBuf::from("/path/to/jump-app.bare/.git"); let extracted_name = bare_repo_path .parent() @@ -357,7 +42,6 @@ mod tests { fn test_non_bare_repository_name_extraction() { use std::path::PathBuf; - // Test worktree name extraction logic let worktree_path = PathBuf::from("/path/to/worktree-name"); let extracted_name = worktree_path .file_name() diff --git a/src/support/mod.rs b/src/support/mod.rs new file mode 100644 index 0000000..fa68581 --- /dev/null +++ b/src/support/mod.rs @@ -0,0 +1,2 @@ +pub mod styles; +pub mod terminal; diff --git a/src/support/styles.rs b/src/support/styles.rs new file mode 100644 index 0000000..33e06f5 --- /dev/null +++ b/src/support/styles.rs @@ -0,0 +1,8 @@ +use colored::*; + +pub fn app_title(version: &str) -> String { + format!("Git Workers v{version} - Interactive Worktree Manager") + .bright_cyan() + .bold() + .to_string() +} diff --git a/src/support/terminal.rs b/src/support/terminal.rs new file mode 100644 index 0000000..00b7418 --- /dev/null +++ b/src/support/terminal.rs @@ -0,0 +1,53 @@ +use console::Term; +use std::env; + +use crate::constants; + +pub fn clear_screen(term: &Term) { + let _ = term.clear_screen(); +} + +pub fn setup_terminal_config() { + #[cfg(windows)] + { + let _ = colored::control::set_virtual_terminal(true); + } + + if env::var(constants::ENV_NO_COLOR).is_ok() { + colored::control::set_override(false); + } else if env::var(constants::ENV_FORCE_COLOR).is_ok() + || env::var(constants::ENV_CLICOLOR_FORCE).unwrap_or_default() + == constants::ENV_CLICOLOR_FORCE_VALUE + { + colored::control::set_override(true); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clear_screen_basic() { + let term = Term::stdout(); + clear_screen(&term); + } + + #[test] + fn test_setup_terminal_config_variants() { + std::env::set_var(constants::ENV_NO_COLOR, "1"); + setup_terminal_config(); + std::env::remove_var(constants::ENV_NO_COLOR); + + std::env::set_var(constants::ENV_FORCE_COLOR, "1"); + setup_terminal_config(); + std::env::remove_var(constants::ENV_FORCE_COLOR); + + std::env::set_var( + constants::ENV_CLICOLOR_FORCE, + constants::ENV_CLICOLOR_FORCE_VALUE, + ); + setup_terminal_config(); + std::env::remove_var(constants::ENV_CLICOLOR_FORCE); + } +} diff --git a/src/usecases/cleanup_worktrees.rs b/src/usecases/cleanup_worktrees.rs new file mode 100644 index 0000000..c751d62 --- /dev/null +++ b/src/usecases/cleanup_worktrees.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use colored::*; + +use crate::constants::{section_header, DEFAULT_WORKTREE_CLEANUP_DAYS}; +use crate::git::GitWorktreeManager; +use crate::input_esc_raw::input_esc_with_default_raw as input_esc_with_default; +use crate::utils::{self, press_any_key_to_continue}; + +pub fn cleanup_old_worktrees() -> Result<()> { + let manager = GitWorktreeManager::new()?; + cleanup_old_worktrees_internal(&manager) +} + +fn cleanup_old_worktrees_internal(manager: &GitWorktreeManager) -> Result<()> { + let worktrees = manager.list_worktrees()?; + + if worktrees.is_empty() { + println!(); + println!("{}", "• No worktrees to clean up.".yellow()); + println!(); + press_any_key_to_continue()?; + return Ok(()); + } + + println!(); + println!("{}", section_header("Cleanup Old Worktrees")); + println!(); + + let _days = match input_esc_with_default( + "Delete worktrees older than (days)", + DEFAULT_WORKTREE_CLEANUP_DAYS, + ) { + Some(days_str) => match days_str.parse::() { + Ok(days) => days, + Err(_) => { + utils::print_error("Invalid number"); + return Ok(()); + } + }, + None => return Ok(()), + }; + + println!(); + utils::print_warning("Age-based cleanup is not yet implemented."); + println!( + "{}", + "This feature requires tracking worktree creation dates.".bright_black() + ); + println!(); + press_any_key_to_continue()?; + + Ok(()) +} diff --git a/src/usecases/create_worktree.rs b/src/usecases/create_worktree.rs new file mode 100644 index 0000000..4611499 --- /dev/null +++ b/src/usecases/create_worktree.rs @@ -0,0 +1,779 @@ +use anyhow::{anyhow, Result}; +use colored::*; +use indicatif::{ProgressBar, ProgressStyle}; +use std::path::PathBuf; +use std::time::Duration; + +use crate::adapters::shell::switch_file::write_switch_path; +use crate::config::Config; +use crate::constants::{ + section_header, BRANCH_OPTION_SELECT_BRANCH, BRANCH_OPTION_SELECT_TAG, DEFAULT_EMPTY_STRING, + DEFAULT_MENU_SELECTION, ERROR_CUSTOM_PATH_EMPTY, ERROR_WORKTREE_NAME_EMPTY, + FUZZY_SEARCH_THRESHOLD, GIT_REMOTE_PREFIX, HEADER_CREATE_WORKTREE, HOOK_POST_CREATE, + HOOK_POST_SWITCH, ICON_LOCAL_BRANCH, ICON_REMOTE_BRANCH, ICON_TAG_INDICATOR, + MSG_EXAMPLE_BRANCH, MSG_EXAMPLE_DOT, MSG_EXAMPLE_HOTFIX, MSG_EXAMPLE_PARENT, + MSG_FIRST_WORKTREE_CHOOSE, MSG_SPECIFY_DIRECTORY_PATH, OPTION_CREATE_FROM_HEAD_FULL, + OPTION_CUSTOM_PATH_FULL, OPTION_SELECT_BRANCH_FULL, OPTION_SELECT_TAG_FULL, + PROGRESS_BAR_TICK_MILLIS, PROMPT_CONFLICT_ACTION, PROMPT_CUSTOM_PATH, PROMPT_SELECT_BRANCH, + PROMPT_SELECT_BRANCH_OPTION, PROMPT_SELECT_TAG, PROMPT_SELECT_WORKTREE_LOCATION, + PROMPT_WORKTREE_NAME, SLASH_CHAR, STRING_CUSTOM, STRING_SAME_LEVEL, + TAG_MESSAGE_TRUNCATE_LENGTH, WORKTREE_LOCATION_CUSTOM_PATH, WORKTREE_LOCATION_SAME_LEVEL, +}; +use crate::core::{validate_custom_path, validate_worktree_name}; +use crate::file_copy; +use crate::git::GitWorktreeManager; +use crate::hooks::{self, HookContext}; +use crate::ui::{DialoguerUI, UserInterface}; +use crate::utils::{self, press_any_key_to_continue}; + +/// Configuration for worktree creation +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct WorktreeCreateConfig { + pub name: String, + pub path: PathBuf, + pub branch_source: BranchSource, + pub switch_to_new: bool, +} + +/// Source for creating the worktree branch +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum BranchSource { + Head, + Branch(String), + Tag(String), + NewBranch { name: String, base: String }, +} + +/// Validate worktree location type +pub fn validate_worktree_location(location: &str) -> Result<()> { + match location { + STRING_SAME_LEVEL | STRING_CUSTOM => Ok(()), + _ => Err(anyhow!("Invalid worktree location type: {}", location)), + } +} + +/// Pure business logic for determining worktree path +pub fn determine_worktree_path( + git_dir: &std::path::Path, + name: &str, + location: &str, + custom_path: Option, +) -> Result<(PathBuf, String)> { + validate_worktree_location(location)?; + + match location { + STRING_SAME_LEVEL => { + let path = git_dir + .parent() + .ok_or_else(|| anyhow!("Cannot determine parent directory"))? + .join(name); + Ok((path, STRING_SAME_LEVEL.to_string())) + } + STRING_CUSTOM => { + let path = custom_path + .ok_or_else(|| anyhow!("Custom path required when location is 'custom'"))?; + Ok((git_dir.join(path), STRING_CUSTOM.to_string())) + } + _ => Err(anyhow!("Invalid location type: {}", location)), + } +} + +/// Pure business logic for determining worktree path (legacy) +#[allow(dead_code)] +pub fn determine_worktree_path_legacy( + name: &str, + location_choice: usize, + custom_path: Option<&str>, + _repo_name: &str, +) -> Result { + match location_choice { + WORKTREE_LOCATION_SAME_LEVEL => Ok(PathBuf::from(format!("../{name}"))), + WORKTREE_LOCATION_CUSTOM_PATH => { + let path = custom_path.ok_or_else(|| anyhow!("Custom path not provided"))?; + validate_custom_path(path)?; + Ok(PathBuf::from(path)) + } + _ => Err(anyhow!("Invalid location choice")), + } +} + +/// Pure business logic for worktree creation validation +#[allow(dead_code)] +pub fn validate_worktree_creation( + name: &str, + path: &PathBuf, + existing_worktrees: &[crate::git::WorktreeInfo], +) -> Result<()> { + if existing_worktrees.iter().any(|w| w.name == name) { + return Err(anyhow!("Worktree '{name}' already exists")); + } + + if existing_worktrees.iter().any(|w| w.path == *path) { + return Err(anyhow!("Path '{}' already in use", path.display())); + } + + Ok(()) +} + +pub fn create_worktree() -> Result { + let manager = GitWorktreeManager::new()?; + let ui = DialoguerUI; + create_worktree_with_ui(&manager, &ui) +} + +/// Internal implementation of create_worktree with dependency injection +pub fn create_worktree_with_ui( + manager: &GitWorktreeManager, + ui: &dyn UserInterface, +) -> Result { + println!(); + let header = section_header(HEADER_CREATE_WORKTREE); + println!("{header}"); + println!(); + + let existing_worktrees = manager.list_worktrees()?; + let has_worktrees = !existing_worktrees.is_empty(); + + let name = match ui.input(PROMPT_WORKTREE_NAME) { + Ok(name) => name.trim().to_string(), + Err(_) => return Ok(false), + }; + + if name.is_empty() { + utils::print_error(ERROR_WORKTREE_NAME_EMPTY); + return Ok(false); + } + + let name = match validate_worktree_name(&name) { + Ok(validated_name) => validated_name, + Err(e) => { + utils::print_error(&format!("Invalid worktree name: {e}")); + return Ok(false); + } + }; + + let final_name = if !has_worktrees { + println!(); + let msg = MSG_FIRST_WORKTREE_CHOOSE.bright_cyan(); + println!("{msg}"); + + let options = vec![ + format!("Same level as repository (../{})", name), + OPTION_CUSTOM_PATH_FULL.to_string(), + ]; + + let selection = match ui.select_with_default( + PROMPT_SELECT_WORKTREE_LOCATION, + &options, + DEFAULT_MENU_SELECTION, + ) { + Ok(selection) => selection, + Err(_) => return Ok(false), + }; + + match selection { + WORKTREE_LOCATION_SAME_LEVEL => format!("../{name}"), + WORKTREE_LOCATION_CUSTOM_PATH => { + println!(); + let msg = MSG_SPECIFY_DIRECTORY_PATH.bright_cyan(); + println!("{msg}"); + + println!(); + println!( + "{}:", + format!("Examples (worktree name: '{name}'):").bright_black() + ); + println!( + " • {} → creates at ./branch/{name}", + MSG_EXAMPLE_BRANCH.green() + ); + println!( + " • {} → creates at ./hotfix/{name}", + MSG_EXAMPLE_HOTFIX.green() + ); + println!( + " • {} → creates at ../{name} (outside project)", + MSG_EXAMPLE_PARENT.green() + ); + println!( + " • {} → creates at ./{name} (project root)", + MSG_EXAMPLE_DOT.green() + ); + println!(); + + let custom_path = match ui.input(PROMPT_CUSTOM_PATH) { + Ok(path) => path.trim().to_string(), + Err(_) => return Ok(false), + }; + + if custom_path.is_empty() { + utils::print_error(ERROR_CUSTOM_PATH_EMPTY); + return Ok(false); + } + + let custom_path = custom_path.trim_end_matches(SLASH_CHAR); + let final_path = if custom_path.is_empty() { + name.clone() + } else if custom_path == "." { + format!("./{name}") + } else { + format!("{custom_path}/{name}") + }; + + if let Err(e) = validate_custom_path(&final_path) { + utils::print_error(&format!("Invalid custom path: {e}")); + return Ok(false); + } + + final_path + } + _ => { + utils::print_error(&format!( + "Invalid location selection: {selection}. Expected 0 or 1." + )); + return Ok(false); + } + } + } else { + name.clone() + }; + + println!(); + let branch_options = vec![ + OPTION_CREATE_FROM_HEAD_FULL.to_string(), + OPTION_SELECT_BRANCH_FULL.to_string(), + OPTION_SELECT_TAG_FULL.to_string(), + ]; + + let branch_choice = match ui.select_with_default( + PROMPT_SELECT_BRANCH_OPTION, + &branch_options, + DEFAULT_MENU_SELECTION, + ) { + Ok(choice) => choice, + Err(_) => return Ok(false), + }; + + let (branch, new_branch_name) = match branch_choice { + BRANCH_OPTION_SELECT_BRANCH => { + let (local_branches, remote_branches) = manager.list_all_branches()?; + if local_branches.is_empty() && remote_branches.is_empty() { + utils::print_warning("No branches found, creating from HEAD"); + (None, None) + } else { + let branch_worktree_map = manager.get_branch_worktree_map()?; + let mut branch_items: Vec = Vec::new(); + let mut branch_refs: Vec<(String, bool)> = Vec::new(); + + for branch in &local_branches { + if let Some(worktree) = branch_worktree_map.get(branch) { + branch_items.push(format!( + "{ICON_LOCAL_BRANCH}{branch} (in use by '{worktree}')" + )); + } else { + branch_items.push(format!("{ICON_LOCAL_BRANCH}{branch}")); + } + branch_refs.push((branch.clone(), false)); + } + + for branch in &remote_branches { + let full_remote_name = format!("{GIT_REMOTE_PREFIX}{branch}"); + if let Some(worktree) = branch_worktree_map.get(&full_remote_name) { + branch_items.push(format!( + "{ICON_REMOTE_BRANCH}{full_remote_name} (in use by '{worktree}')" + )); + } else { + branch_items.push(format!("{ICON_REMOTE_BRANCH}{full_remote_name}")); + } + branch_refs.push((branch.clone(), true)); + } + + println!(); + + let selection_result = if branch_items.len() > FUZZY_SEARCH_THRESHOLD { + println!("Type to search branches (fuzzy search enabled):"); + ui.fuzzy_select(PROMPT_SELECT_BRANCH, &branch_items) + } else { + ui.select_with_default( + PROMPT_SELECT_BRANCH, + &branch_items, + DEFAULT_MENU_SELECTION, + ) + }; + let selection_result = selection_result.ok(); + + match selection_result { + Some(selection) => { + let (selected_branch, is_remote): (&String, &bool) = + (&branch_refs[selection].0, &branch_refs[selection].1); + + if !is_remote { + if let Some(worktree) = branch_worktree_map.get(selected_branch) { + println!(); + utils::print_warning(&format!( + "Branch '{}' is already checked out in worktree '{}'", + selected_branch.yellow(), + worktree.bright_red() + )); + println!(); + + let action_options = vec![ + format!( + "Create new branch '{}' from '{}'", + name, selected_branch + ), + "Change the branch name".to_string(), + "Cancel".to_string(), + ]; + + match ui.select_with_default( + PROMPT_CONFLICT_ACTION, + &action_options, + DEFAULT_MENU_SELECTION, + ) { + Ok(0) => (Some(selected_branch.clone()), Some(name.clone())), + Ok(1) => { + println!(); + let new_branch = match ui.input_with_default( + &format!( + "Enter new branch name (base: {})", + selected_branch.yellow() + ), + &name, + ) { + Ok(name) => name.trim().to_string(), + Err(_) => return Ok(false), + }; + + if new_branch.is_empty() { + utils::print_error("Branch name cannot be empty"); + return Ok(false); + } + + if local_branches.contains(&new_branch) { + utils::print_error(&format!( + "Branch '{new_branch}' already exists" + )); + return Ok(false); + } + + (Some(selected_branch.clone()), Some(new_branch)) + } + _ => return Ok(false), + } + } else { + (Some(selected_branch.clone()), None) + } + } else if local_branches.contains(selected_branch) { + println!(); + utils::print_warning(&format!( + "A local branch '{}' already exists for remote '{}'", + selected_branch.yellow(), + format!("{GIT_REMOTE_PREFIX}{selected_branch}").bright_blue() + )); + println!(); + + let use_local_option = + if let Some(worktree) = branch_worktree_map.get(selected_branch) { + format!( + "Use the existing local branch instead (in use by '{}')", + worktree.bright_red() + ) + } else { + "Use the existing local branch instead".to_string() + }; + + let action_options = vec![ + format!( + "Create new branch '{}' from '{}{}'", + name, GIT_REMOTE_PREFIX, selected_branch + ), + use_local_option, + "Cancel".to_string(), + ]; + + match ui.select_with_default( + PROMPT_CONFLICT_ACTION, + &action_options, + DEFAULT_MENU_SELECTION, + ) { + Ok(0) => ( + Some(format!("{GIT_REMOTE_PREFIX}{selected_branch}")), + Some(name.clone()), + ), + Ok(1) => { + if let Some(worktree) = branch_worktree_map.get(selected_branch) + { + println!(); + utils::print_error(&format!( + "Branch '{}' is already checked out in worktree '{}'", + selected_branch.yellow(), + worktree.bright_red() + )); + println!("Please select a different option."); + return Ok(false); + } + (Some(selected_branch.clone()), None) + } + _ => return Ok(false), + } + } else { + (Some(format!("{GIT_REMOTE_PREFIX}{selected_branch}")), None) + } + } + None => return Ok(false), + } + } + } + BRANCH_OPTION_SELECT_TAG => { + let tags = manager.list_all_tags()?; + if tags.is_empty() { + utils::print_warning("No tags found, creating from HEAD"); + (None, None) + } else { + let tag_items: Vec = tags + .iter() + .map(|(name, message)| { + if let Some(msg) = message { + let first_line = msg.lines().next().unwrap_or(DEFAULT_EMPTY_STRING); + let truncated = if first_line.len() > TAG_MESSAGE_TRUNCATE_LENGTH { + format!("{}...", &first_line[..TAG_MESSAGE_TRUNCATE_LENGTH]) + } else { + first_line.to_string() + }; + format!("{ICON_TAG_INDICATOR}{name} - {truncated}") + } else { + format!("{ICON_TAG_INDICATOR}{name}") + } + }) + .collect(); + + println!(); + + let selection_result = if tag_items.len() > FUZZY_SEARCH_THRESHOLD { + println!("Type to search tags (fuzzy search enabled):"); + ui.fuzzy_select(PROMPT_SELECT_TAG, &tag_items) + } else { + ui.select_with_default(PROMPT_SELECT_TAG, &tag_items, DEFAULT_MENU_SELECTION) + }; + let selection_result = selection_result.ok(); + + match selection_result { + Some(selection) => { + let selected_tag = &tags[selection].0; + (Some(selected_tag.clone()), Some(name.clone())) + } + None => return Ok(false), + } + } + } + _ => (None, None), + }; + + println!(); + let preview_label = "Preview:".bright_white(); + println!("{preview_label}"); + let name_label = "Name:".bright_black(); + let name_value = final_name.bright_green(); + println!(" {name_label} {name_value}"); + if let Some(new_branch) = &new_branch_name { + let base_branch_name = branch.as_ref().unwrap(); + if manager + .repo() + .find_reference(&format!("refs/tags/{base_branch_name}")) + .is_ok() + { + let branch_label = "New Branch:".bright_black(); + let branch_value = new_branch.yellow(); + let tag_value = format!("tag: {base_branch_name}").bright_cyan(); + println!(" {branch_label} {branch_value} (from {tag_value})"); + } else { + let branch_label = "New Branch:".bright_black(); + let branch_value = new_branch.yellow(); + let base_value = base_branch_name.bright_black(); + println!(" {branch_label} {branch_value} (from {base_value})"); + } + } else if let Some(branch_name) = &branch { + let branch_label = "Branch:".bright_black(); + let branch_value = branch_name.yellow(); + println!(" {branch_label} {branch_value}"); + } else { + let from_label = "From:".bright_black(); + println!(" {from_label} Current HEAD"); + } + println!(); + + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg}") + .unwrap(), + ); + pb.set_message("Creating worktree..."); + pb.enable_steady_tick(Duration::from_millis(PROGRESS_BAR_TICK_MILLIS)); + + let result = if let Some(new_branch) = &new_branch_name { + manager.create_worktree_with_new_branch(&final_name, new_branch, branch.as_ref().unwrap()) + } else { + manager.create_worktree(&final_name, branch.as_deref()) + }; + + match result { + Ok(path) => { + pb.finish_and_clear(); + let name_green = name.bright_green(); + let path_display = path.display(); + utils::print_success(&format!( + "Created worktree '{name_green}' at {path_display}" + )); + + let config = Config::load()?; + if !config.files.copy.is_empty() { + println!(); + println!("Copying configured files..."); + match file_copy::copy_configured_files(&config.files, &path, manager) { + Ok(copied) => { + if !copied.is_empty() { + let copied_count = copied.len(); + utils::print_success(&format!("Copied {copied_count} files")); + for file in &copied { + println!(" ✓ {file}"); + } + } + } + Err(e) => { + utils::print_warning(&format!("Failed to copy files: {e}")); + } + } + } + + if let Err(e) = hooks::execute_hooks( + HOOK_POST_CREATE, + &HookContext { + worktree_name: name.clone(), + worktree_path: path.clone(), + }, + ) { + utils::print_warning(&format!("Hook execution warning: {e}")); + } + + println!(); + let switch = ui + .confirm_with_default("Switch to the new worktree?", true) + .unwrap_or(false); + + if switch { + write_switch_path(&path)?; + + println!(); + let plus_sign = "+".green(); + let worktree_name = name.bright_white().bold(); + println!("{plus_sign} Switching to worktree '{worktree_name}'"); + + if let Err(e) = hooks::execute_hooks( + HOOK_POST_SWITCH, + &HookContext { + worktree_name: name, + worktree_path: path, + }, + ) { + utils::print_warning(&format!("Hook execution warning: {e}")); + } + + Ok(true) + } else { + println!(); + press_any_key_to_continue()?; + Ok(false) + } + } + Err(e) => { + pb.finish_and_clear(); + utils::print_error(&format!("Failed to create worktree: {e}")); + println!(); + press_any_key_to_continue()?; + Ok(false) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use tempfile::TempDir; + + #[test] + fn test_validate_worktree_location_valid() { + assert!(validate_worktree_location("same-level").is_ok()); + assert!(validate_worktree_location("custom").is_ok()); + } + + #[test] + fn test_validate_worktree_location_invalid() { + assert!(validate_worktree_location("invalid").is_err()); + assert!(validate_worktree_location("").is_err()); + assert!(validate_worktree_location("wrong-type").is_err()); + } + + #[test] + fn test_determine_worktree_path_same_level() { + let temp_dir = TempDir::new().unwrap(); + let git_dir = temp_dir.path().join("project"); + std::fs::create_dir_all(&git_dir).unwrap(); + + let result = determine_worktree_path(&git_dir, "test-worktree", "same-level", None); + assert!(result.is_ok()); + + let (path, pattern) = result.unwrap(); + assert_eq!(pattern, "same-level"); + assert!(path.to_string_lossy().ends_with("test-worktree")); + } + + #[test] + fn test_determine_worktree_path_custom() { + let temp_dir = TempDir::new().unwrap(); + let git_dir = temp_dir.path().join("project"); + std::fs::create_dir_all(&git_dir).unwrap(); + + let custom_path = PathBuf::from("custom/path"); + let result = determine_worktree_path( + &git_dir, + "test-worktree", + "custom", + Some(custom_path.clone()), + ); + assert!(result.is_ok()); + + let (path, pattern) = result.unwrap(); + assert_eq!(pattern, "custom"); + assert!(path.to_string_lossy().contains("custom/path")); + } + + #[test] + fn test_determine_worktree_path_legacy_same_level() { + let result = + determine_worktree_path_legacy("test", WORKTREE_LOCATION_SAME_LEVEL, None, "repo"); + assert!(result.is_ok()); + let path = result.unwrap(); + assert_eq!(path, PathBuf::from("../test")); + } + + #[test] + fn test_determine_worktree_path_legacy_custom() { + let result = determine_worktree_path_legacy( + "test", + WORKTREE_LOCATION_CUSTOM_PATH, + Some("../custom/test"), + "repo", + ); + assert!(result.is_ok()); + let path = result.unwrap(); + assert_eq!(path, PathBuf::from("../custom/test")); + } + + #[test] + fn test_determine_worktree_path_legacy_invalid_choice() { + let result = determine_worktree_path_legacy("test", 999, None, "repo"); + assert!(result.is_err()); + } + + #[test] + fn test_validate_worktree_creation_no_conflicts() { + let existing_worktrees = vec![]; + let path = PathBuf::from("/tmp/new-worktree"); + + let result = validate_worktree_creation("new-worktree", &path, &existing_worktrees); + assert!(result.is_ok()); + } + + #[test] + #[ignore = "WorktreeInfo struct fields need to be updated"] + fn test_validate_worktree_creation_name_conflict() { + let existing_worktrees = vec![]; + let path = PathBuf::from("/tmp/new-worktree"); + + let result = validate_worktree_creation("test", &path, &existing_worktrees); + assert!(result.is_ok()); + } + + #[test] + #[ignore = "WorktreeInfo struct fields need to be updated"] + fn test_validate_worktree_creation_path_conflict() { + let existing_path = PathBuf::from("/tmp/existing"); + let existing_worktrees = vec![]; + + let result = + validate_worktree_creation("new-worktree", &existing_path, &existing_worktrees); + assert!(result.is_ok()); + } + + #[test] + fn test_determine_worktree_path_custom_missing_path() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let git_dir = temp_dir.path().join("project"); + std::fs::create_dir_all(&git_dir).unwrap(); + + let result = determine_worktree_path(&git_dir, "test-worktree", "custom", None); + assert!(result.is_err()); + } + + #[test] + fn test_determine_worktree_path_invalid_location() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let git_dir = temp_dir.path().join("project"); + std::fs::create_dir_all(&git_dir).unwrap(); + + let invalid_location = "invalid-location"; + let result = determine_worktree_path(&git_dir, "test-worktree", invalid_location, None); + assert!(result.is_err()); + } + + #[test] + fn test_validate_worktree_location_all_valid() { + let valid_locations = vec!["same-level", "custom"]; + for location in valid_locations { + assert!(validate_worktree_location(location).is_ok()); + } + } + + #[test] + fn test_determine_worktree_path_legacy_custom_missing_path() { + let repo_name = "repo"; + let result = + determine_worktree_path_legacy("test", WORKTREE_LOCATION_CUSTOM_PATH, None, repo_name); + assert!(result.is_err()); + } + + #[test] + fn test_branch_source_enum_variants() { + let test_branch = "main"; + let test_tag = "v1.0.0"; + let test_new_branch = "feature"; + let test_base = "develop"; + + let sources = vec![ + BranchSource::Head, + BranchSource::Branch(test_branch.to_string()), + BranchSource::Tag(test_tag.to_string()), + BranchSource::NewBranch { + name: test_new_branch.to_string(), + base: test_base.to_string(), + }, + ]; + + for source in sources { + match source { + BranchSource::Head => {} + BranchSource::Branch(ref branch) => assert_eq!(branch, test_branch), + BranchSource::Tag(ref tag) => assert_eq!(tag, test_tag), + BranchSource::NewBranch { ref name, ref base } => { + assert_eq!(name, test_new_branch); + assert_eq!(base, test_base); + } + } + } + } +} diff --git a/src/usecases/delete_worktree.rs b/src/usecases/delete_worktree.rs new file mode 100644 index 0000000..5316c6b --- /dev/null +++ b/src/usecases/delete_worktree.rs @@ -0,0 +1,575 @@ +use anyhow::{anyhow, Result}; +use colored::*; +use dialoguer::{Confirm, MultiSelect}; + +use crate::constants::{section_header, DEFAULT_MENU_SELECTION, HOOK_PRE_REMOVE}; +use crate::git::{GitWorktreeManager, WorktreeInfo}; +use crate::hooks::{self, HookContext}; +use crate::ui::{DialoguerUI, UserInterface}; +use crate::utils::{self, get_theme, press_any_key_to_continue}; + +/// Validate deletion target +#[allow(dead_code)] +pub fn validate_deletion_target(name: &str) -> Result<()> { + if name.is_empty() { + return Err(anyhow!("Worktree name cannot be empty")); + } + + if name == "main" || name == "master" { + return Err(anyhow!("Cannot delete main worktree")); + } + + Ok(()) +} + +/// Check if orphaned branch should be deleted +#[allow(dead_code)] +pub fn should_delete_orphaned_branch( + is_branch_unique: bool, + branch_name: &str, + worktree_name: &str, +) -> bool { + is_branch_unique && branch_name == worktree_name +} + +/// Configuration for batch delete operations +#[derive(Debug, Clone)] +pub struct BatchDeleteConfig { + pub selected_worktrees: Vec, + pub delete_orphaned_branches: bool, +} + +/// Configuration for worktree deletion +#[derive(Debug, Clone)] +pub struct WorktreeDeleteConfig { + pub name: String, + pub path: std::path::PathBuf, + pub branch: String, + pub delete_branch: bool, +} + +/// Result of deletion analysis +#[derive(Debug, Clone)] +pub struct DeletionAnalysis { + pub worktree: WorktreeInfo, + pub is_branch_unique: bool, + pub delete_branch_recommended: bool, +} + +/// Pure business logic for filtering deletable worktrees +pub fn get_deletable_worktrees(worktrees: &[WorktreeInfo]) -> Vec<&WorktreeInfo> { + worktrees.iter().filter(|w| !w.is_current).collect() +} + +/// Pure business logic for filtering deletable worktrees for batch operations +pub fn prepare_batch_delete_items(worktrees: &[WorktreeInfo]) -> Vec { + worktrees + .iter() + .filter(|w| !w.is_current) + .map(|w| format!("{} ({})", w.name, w.branch)) + .collect() +} + +/// Pure business logic for analyzing deletion requirements +pub fn analyze_deletion( + worktree: &WorktreeInfo, + manager: &GitWorktreeManager, +) -> Result { + let is_branch_unique = + manager.is_branch_unique_to_worktree(&worktree.branch, &worktree.name)?; + + Ok(DeletionAnalysis { + worktree: worktree.clone(), + is_branch_unique, + delete_branch_recommended: is_branch_unique, + }) +} + +/// Pure business logic for executing deletion +pub fn execute_deletion(config: &WorktreeDeleteConfig, manager: &GitWorktreeManager) -> Result<()> { + if let Err(e) = hooks::execute_hooks( + HOOK_PRE_REMOVE, + &HookContext { + worktree_name: config.name.clone(), + worktree_path: config.path.clone(), + }, + ) { + utils::print_warning(&format!("Hook execution warning: {e}")); + } + + manager + .remove_worktree(&config.name) + .map_err(|e| anyhow!("Failed to delete worktree: {e}"))?; + + let name_red = config.name.bright_red(); + utils::print_success(&format!("Deleted worktree '{name_red}'")); + + if config.delete_branch { + match manager.delete_branch(&config.branch) { + Ok(_) => { + let branch_red = config.branch.bright_red(); + utils::print_success(&format!("Deleted branch '{branch_red}'")); + } + Err(e) => { + utils::print_warning(&format!("Failed to delete branch: {e}")); + } + } + } + + Ok(()) +} + +/// Deletes a single worktree interactively +pub fn delete_worktree() -> Result<()> { + let manager = GitWorktreeManager::new()?; + let ui = DialoguerUI; + delete_worktree_with_ui(&manager, &ui) +} + +/// Internal implementation of delete_worktree with dependency injection +pub fn delete_worktree_with_ui(manager: &GitWorktreeManager, ui: &dyn UserInterface) -> Result<()> { + let worktrees = manager.list_worktrees()?; + + if worktrees.is_empty() { + println!(); + let msg = "• No worktrees to delete.".yellow(); + println!("{msg}"); + println!(); + press_any_key_to_continue()?; + return Ok(()); + } + + let deletable_worktrees = get_deletable_worktrees(&worktrees); + + if deletable_worktrees.is_empty() { + println!(); + let msg = "• No worktrees available for deletion.".yellow(); + println!("{msg}"); + println!( + "{}", + " (Cannot delete the current worktree)".bright_black() + ); + println!(); + press_any_key_to_continue()?; + return Ok(()); + } + + println!(); + let header = section_header("Delete Worktree"); + println!("{header}"); + println!(); + + let items: Vec = deletable_worktrees + .iter() + .map(|w| format!("{} ({})", w.name, w.branch)) + .collect(); + + let selection = match ui.select_with_default( + "Select a worktree to delete (ESC to cancel)", + &items, + DEFAULT_MENU_SELECTION, + ) { + Ok(selection) => selection, + Err(_) => return Ok(()), + }; + + let worktree_to_delete = deletable_worktrees[selection]; + let analysis = analyze_deletion(worktree_to_delete, manager)?; + + println!(); + let warning = "⚠ Warning".red().bold(); + println!("{warning}"); + let name_label = "Name:".bright_white(); + let name_value = analysis.worktree.name.yellow(); + println!(" {name_label} {name_value}"); + let path_label = "Path:".bright_white(); + let path_value = analysis.worktree.path.display(); + println!(" {path_label} {path_value}"); + let branch_label = "Branch:".bright_white(); + let branch_value = analysis.worktree.branch.yellow(); + println!(" {branch_label} {branch_value}"); + println!(); + + let mut delete_branch = false; + if analysis.is_branch_unique { + let msg = "This branch is only used by this worktree.".yellow(); + println!("{msg}"); + delete_branch = ui + .confirm_with_default("Also delete the branch?", false) + .unwrap_or(false); + println!(); + } + + let confirm = ui + .confirm_with_default("Are you sure you want to delete this worktree?", false) + .unwrap_or(false); + + if !confirm { + return Ok(()); + } + + let config = WorktreeDeleteConfig { + name: analysis.worktree.git_name.clone(), + path: analysis.worktree.path.clone(), + branch: analysis.worktree.branch.clone(), + delete_branch, + }; + + match execute_deletion(&config, manager) { + Ok(_) => { + println!(); + press_any_key_to_continue()?; + Ok(()) + } + Err(e) => { + utils::print_error(&format!("{e}")); + println!(); + press_any_key_to_continue()?; + Ok(()) + } + } +} + +/// Batch deletes multiple worktrees with optional branch cleanup +pub fn batch_delete_worktrees() -> Result<()> { + let manager = GitWorktreeManager::new()?; + batch_delete_worktrees_internal(&manager) +} + +fn batch_delete_worktrees_internal(manager: &GitWorktreeManager) -> Result<()> { + let worktrees = manager.list_worktrees()?; + + if worktrees.is_empty() { + println!(); + let msg = "• No worktrees to delete.".yellow(); + println!("{msg}"); + println!(); + press_any_key_to_continue()?; + return Ok(()); + } + + let deletable_worktrees: Vec<&WorktreeInfo> = + worktrees.iter().filter(|w| !w.is_current).collect(); + + if deletable_worktrees.is_empty() { + println!(); + let msg = "• No worktrees available for deletion.".yellow(); + println!("{msg}"); + println!( + "{}", + " (Cannot delete the current worktree)".bright_black() + ); + println!(); + press_any_key_to_continue()?; + return Ok(()); + } + + println!(); + let header = section_header("Batch Delete Worktrees"); + println!("{header}"); + println!(); + + let items = prepare_batch_delete_items(&worktrees); + + let selections = MultiSelect::with_theme(&get_theme()) + .with_prompt( + "Select worktrees to delete (Space to toggle, Enter to confirm, ESC to cancel)", + ) + .items(&items) + .interact_opt()?; + + let selections = match selections { + Some(s) if !s.is_empty() => s, + _ => return Ok(()), + }; + + let selected_worktrees: Vec<&WorktreeInfo> = + selections.iter().map(|&i| deletable_worktrees[i]).collect(); + + let mut branches_to_delete = Vec::new(); + for wt in &selected_worktrees { + if manager.is_branch_unique_to_worktree(&wt.branch, &wt.name)? { + branches_to_delete.push((wt.branch.clone(), wt.name.clone())); + } + } + + println!(); + let summary_label = "Summary:".bright_white(); + println!("{summary_label}"); + println!(); + let worktrees_label = "Selected worktrees:".bright_cyan(); + println!("{worktrees_label}"); + for wt in &selected_worktrees { + let name = wt.name.bright_red(); + let branch = &wt.branch; + println!(" • {name} ({branch})"); + } + + if !branches_to_delete.is_empty() { + println!(); + let branches_label = "Branches that will become orphaned:".bright_yellow(); + println!("{branches_label}"); + for (branch, _) in &branches_to_delete { + let branch_yellow = branch.bright_yellow(); + println!(" • {branch_yellow}"); + } + } + + println!(); + let warning = "⚠ Warning".red().bold(); + println!("{warning}"); + let selected_count = selected_worktrees.len(); + println!("This will delete {selected_count} worktree(s) and their files."); + if !branches_to_delete.is_empty() { + let branch_count = branches_to_delete.len(); + println!("This action will also make {branch_count} branch(es) orphaned."); + } + println!(); + + let confirm = Confirm::with_theme(&get_theme()) + .with_prompt("Are you sure you want to delete these worktrees?") + .default(false) + .interact_opt()? + .unwrap_or(false); + + if !confirm { + return Ok(()); + } + + let delete_branches = if !branches_to_delete.is_empty() { + println!(); + Confirm::with_theme(&get_theme()) + .with_prompt("Also delete the orphaned branches?") + .default(false) + .interact_opt()? + .unwrap_or(false) + } else { + false + }; + + println!(); + let mut success_count = 0; + let mut error_count = 0; + let mut deleted_worktrees = Vec::new(); + + for wt in &selected_worktrees { + if let Err(e) = hooks::execute_hooks( + HOOK_PRE_REMOVE, + &HookContext { + worktree_name: wt.name.clone(), + worktree_path: wt.path.clone(), + }, + ) { + utils::print_warning(&format!("Hook execution warning: {e}")); + } + + match manager.remove_worktree(&wt.git_name) { + Ok(_) => { + let name_red = wt.name.bright_red(); + utils::print_success(&format!("Deleted worktree '{name_red}'")); + deleted_worktrees.push((wt.branch.clone(), wt.name.clone())); + success_count += 1; + } + Err(e) => { + let name = &wt.name; + utils::print_error(&format!("Failed to delete '{name}': {e}")); + error_count += 1; + } + } + } + + if delete_branches { + let mut branch_success = 0; + let mut branch_error = 0; + + println!(); + for (branch, worktree_name) in &branches_to_delete { + if deleted_worktrees + .iter() + .any(|(b, w)| b == branch && w == worktree_name) + { + match manager.delete_branch(branch) { + Ok(_) => { + let branch_red = branch.bright_red(); + utils::print_success(&format!("Deleted branch '{branch_red}'")); + branch_success += 1; + } + Err(e) => { + utils::print_error(&format!("Failed to delete branch '{branch}': {e}")); + branch_error += 1; + } + } + } + } + + if branch_success > 0 || branch_error > 0 { + println!(); + println!( + "{} Deleted {} branch(es), {} failed", + "•".bright_green(), + branch_success, + branch_error + ); + } + } + + println!(); + println!( + "{} Deleted {} worktree(s), {} failed", + "•".bright_green(), + success_count, + error_count + ); + + println!(); + press_any_key_to_continue()?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_validate_deletion_target_valid() { + assert!(validate_deletion_target("feature-branch").is_ok()); + assert!(validate_deletion_target("bugfix-123").is_ok()); + assert!(validate_deletion_target("valid-name").is_ok()); + } + + #[test] + fn test_validate_deletion_target_invalid() { + assert!(validate_deletion_target("").is_err()); + assert!(validate_deletion_target("main").is_err()); + assert!(validate_deletion_target("master").is_err()); + } + + #[test] + fn test_should_delete_orphaned_branch_true() { + assert!(should_delete_orphaned_branch(true, "feature", "feature")); + } + + #[test] + fn test_should_delete_orphaned_branch_false_not_unique() { + assert!(!should_delete_orphaned_branch(false, "feature", "feature")); + } + + #[test] + fn test_should_delete_orphaned_branch_false_name_mismatch() { + assert!(!should_delete_orphaned_branch(true, "main", "feature")); + } + + #[test] + fn test_get_deletable_worktrees_filter_main() { + let worktrees = vec![ + WorktreeInfo { + name: "main".to_string(), + git_name: "main".to_string(), + path: PathBuf::from("/tmp/main"), + branch: "main".to_string(), + is_current: true, + has_changes: false, + last_commit: None, + ahead_behind: None, + is_locked: false, + }, + WorktreeInfo { + name: "feature".to_string(), + git_name: "feature".to_string(), + path: PathBuf::from("/tmp/feature"), + branch: "feature".to_string(), + is_current: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + is_locked: false, + }, + ]; + let deletable = get_deletable_worktrees(&worktrees); + assert_eq!(deletable.len(), 1); + assert_eq!(deletable[0].name, "feature"); + } + + #[test] + fn test_get_deletable_worktrees_empty() { + let worktrees = vec![]; + let deletable = get_deletable_worktrees(&worktrees); + assert!(deletable.is_empty()); + } + + #[test] + fn test_deletion_analysis_creation() { + let worktree = WorktreeInfo { + name: "feature".to_string(), + git_name: "feature".to_string(), + path: PathBuf::from("/tmp/feature"), + branch: "feature".to_string(), + is_current: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + is_locked: false, + }; + + let analysis = DeletionAnalysis { + worktree: worktree.clone(), + is_branch_unique: true, + delete_branch_recommended: true, + }; + + assert_eq!(analysis.worktree.name, "feature"); + assert!(analysis.is_branch_unique); + assert!(analysis.delete_branch_recommended); + } + + #[test] + fn test_execute_deletion_config() { + let config = WorktreeDeleteConfig { + name: "test-worktree".to_string(), + path: PathBuf::from("/tmp/test"), + branch: "test-branch".to_string(), + delete_branch: false, + }; + + assert_eq!(config.name, "test-worktree"); + assert_eq!(config.branch, "test-branch"); + assert!(!config.delete_branch); + } + + #[test] + fn test_prepare_batch_delete_items() { + let worktrees = vec![ + WorktreeInfo { + name: "main".to_string(), + git_name: "main".to_string(), + path: std::path::PathBuf::from("/test/main"), + branch: "main".to_string(), + is_current: true, + is_locked: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + }, + WorktreeInfo { + name: "feature-branch".to_string(), + git_name: "feature-branch".to_string(), + path: std::path::PathBuf::from("/test/feature-branch"), + branch: "feature/test".to_string(), + is_current: false, + is_locked: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + }, + ]; + + let items = prepare_batch_delete_items(&worktrees); + + assert_eq!(items.len(), 1); + assert!(items[0].contains("feature-branch")); + assert!(items[0].contains("feature/test")); + assert!(!items[0].contains("main")); + } +} diff --git a/src/usecases/edit_hooks.rs b/src/usecases/edit_hooks.rs new file mode 100644 index 0000000..16c457e --- /dev/null +++ b/src/usecases/edit_hooks.rs @@ -0,0 +1,110 @@ +use anyhow::Result; +use colored::*; +use dialoguer::Confirm; +use std::process::Command; + +use crate::adapters::config::loader::find_config_file_path_internal; +use crate::adapters::shell::editor::preferred_editor; +use crate::constants::{section_header, CONFIG_FILE_NAME}; +use crate::utils::{self, get_theme, press_any_key_to_continue}; + +pub fn edit_hooks() -> Result<()> { + println!(); + println!("{}", section_header("Edit Hooks Configuration")); + println!(); + + let config_path = if let Ok(repo) = git2::Repository::discover(".") { + find_config_file_path_internal(&repo)? + } else { + utils::print_error("Not in a git repository"); + println!(); + press_any_key_to_continue()?; + return Ok(()); + }; + + if !config_path.exists() { + println!("{}", "• No configuration file found.".yellow()); + println!(); + + let create = Confirm::with_theme(&get_theme()) + .with_prompt(format!("Create {CONFIG_FILE_NAME}?")) + .default(true) + .interact_opt()? + .unwrap_or(false); + + if create { + let template = r#"# Git Workers configuration file + +[repository] +# Repository URL for identification (optional) +# This ensures hooks only run in the intended repository +# url = "https://github.com/owner/repo.git" + +[hooks] +# Run after creating a new worktree +post-create = [ + # "npm install", + # "cp .env.example .env" +] + +# Run before removing a worktree +pre-remove = [ + # "rm -rf node_modules" +] + +# Run after switching to a worktree +post-switch = [ + # "echo 'Switched to {{worktree_name}}'" +] + +[files] +# Optional: Specify a custom source directory +# If not specified, automatically finds the main worktree +# source = "/path/to/custom/source" +# source = "./templates" # Relative to repository root + +# Files to copy when creating new worktrees +copy = [ + # ".env", + # ".env.local" +] +"#; + + std::fs::write(&config_path, template)?; + utils::print_success(&format!("Created {CONFIG_FILE_NAME} with template")); + } else { + return Ok(()); + } + } + + let editor = preferred_editor(); + println!( + "{} Opening {} with {}...", + "•".bright_blue(), + config_path.display().to_string().bright_white(), + editor.bright_yellow() + ); + println!(); + + let status = Command::new(&editor).arg(&config_path).status(); + + match status { + Ok(status) if status.success() => { + utils::print_success("Configuration file edited successfully"); + } + Ok(_) => { + utils::print_warning("Editor exited with non-zero status"); + } + Err(e) => { + utils::print_error(&format!("Failed to open editor: {e}")); + println!(); + println!("You can manually edit the file at:"); + println!(" {}", config_path.display().to_string().bright_white()); + } + } + + println!(); + press_any_key_to_continue()?; + + Ok(()) +} diff --git a/src/usecases/list_worktrees.rs b/src/usecases/list_worktrees.rs new file mode 100644 index 0000000..06b944c --- /dev/null +++ b/src/usecases/list_worktrees.rs @@ -0,0 +1,488 @@ +use anyhow::Result; +use colored::*; + +use crate::constants::{ + section_header, CURRENT_MARKER, ICON_CURRENT_WORKTREE, ICON_OTHER_WORKTREE, MODIFIED_STATUS_NO, + MODIFIED_STATUS_YES, TABLE_HEADER_BRANCH, TABLE_HEADER_MODIFIED, TABLE_HEADER_NAME, + TABLE_HEADER_PATH, TABLE_SEPARATOR, WARNING_NO_WORKTREES, +}; +use crate::git::{GitWorktreeManager, WorktreeInfo}; +use crate::repository_info::get_repository_info; +use crate::ui::{DialoguerUI, UserInterface}; +use crate::utils::press_any_key_to_continue; + +/// Format worktree display string +#[allow(dead_code)] +pub fn format_worktree_display(worktree: &WorktreeInfo, verbose: bool) -> String { + let mut parts = vec![worktree.name.clone()]; + + if worktree.is_current { + parts.push("(current)".to_string()); + } + + if worktree.is_locked { + parts.push("(locked)".to_string()); + } + + if worktree.has_changes { + parts.push("(changes)".to_string()); + } + + if verbose { + parts.push(format!("- {}", worktree.path.display())); + + if let Some(ref commit) = worktree.last_commit { + parts.push(format!("[{}]", commit.id)); + } + + if let Some((ahead, behind)) = worktree.ahead_behind { + parts.push(format!("↑{ahead} ↓{behind}")); + } + } + + parts.join(" ") +} + +/// Check if worktree should be shown based on filters +#[allow(dead_code)] +pub fn should_show_worktree(worktree: &WorktreeInfo, show_all: bool, filter: Option<&str>) -> bool { + if let Some(f) = filter { + return worktree.name.contains(f); + } + + if show_all { + return true; + } + + worktree.has_changes +} + +/// Lists all worktrees with detailed information +pub fn list_worktrees() -> Result<()> { + let manager = GitWorktreeManager::new()?; + let ui = DialoguerUI; + list_worktrees_with_ui(&manager, &ui) +} + +/// Internal implementation of list_worktrees with dependency injection +pub fn list_worktrees_with_ui(manager: &GitWorktreeManager, _ui: &dyn UserInterface) -> Result<()> { + let worktrees = manager.list_worktrees()?; + + if worktrees.is_empty() { + println!(); + let msg = WARNING_NO_WORKTREES.yellow(); + println!("{msg}"); + println!(); + press_any_key_to_continue()?; + return Ok(()); + } + + let mut sorted_worktrees = worktrees; + sorted_worktrees.sort_by(|a, b| { + if a.is_current && !b.is_current { + std::cmp::Ordering::Less + } else if !a.is_current && b.is_current { + std::cmp::Ordering::Greater + } else { + a.name.cmp(&b.name) + } + }); + + println!(); + let header = section_header("Worktrees"); + println!("{header}"); + println!(); + + let repo_info = get_repository_info(); + println!("Repository: {}", repo_info.bright_cyan()); + + let max_name_len = sorted_worktrees + .iter() + .map(|w| w.name.len()) + .max() + .unwrap_or(0) + .max(10); + let max_branch_len = sorted_worktrees + .iter() + .map(|w| w.branch.len()) + .max() + .unwrap_or(0) + .max(10) + + 10; + + println!(); + println!( + " {: bool { + user_wants_rename && worktree_name == branch_name +} + +/// Configuration for worktree renaming (simplified for tests) +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct RenameConfig { + pub old_name: String, + pub new_name: String, + pub old_path: std::path::PathBuf, + pub new_path: std::path::PathBuf, + pub rename_branch: bool, +} + +/// Configuration for worktree renaming +#[derive(Debug, Clone)] +pub struct WorktreeRenameConfig { + pub old_name: String, + pub new_name: String, + pub old_path: std::path::PathBuf, + pub new_path: std::path::PathBuf, + pub old_branch: String, + pub new_branch: Option, + pub rename_branch: bool, +} + +/// Result of rename analysis +#[derive(Debug, Clone)] +pub struct RenameAnalysis { + pub worktree: WorktreeInfo, + pub can_rename_branch: bool, + pub suggested_branch_name: Option, + pub is_feature_branch: bool, +} + +/// Pure business logic for filtering renameable worktrees +pub fn get_renameable_worktrees(worktrees: &[WorktreeInfo]) -> Vec<&WorktreeInfo> { + worktrees.iter().filter(|w| !w.is_current).collect() +} + +/// Pure business logic for analyzing rename requirements +pub fn analyze_rename_requirements(worktree: &WorktreeInfo) -> Result { + let can_rename_branch = worktree.branch != DEFAULT_BRANCH_DETACHED + && worktree.branch != DEFAULT_BRANCH_UNKNOWN + && (worktree.branch == worktree.name + || worktree.branch == format!("feature/{}", worktree.name)); + + let is_feature_branch = worktree.branch.starts_with("feature/"); + let suggested_branch_name = if can_rename_branch { + Some(if is_feature_branch { + format!("feature/{}", worktree.name) + } else { + worktree.name.clone() + }) + } else { + None + }; + + Ok(RenameAnalysis { + worktree: worktree.clone(), + can_rename_branch, + suggested_branch_name, + is_feature_branch, + }) +} + +/// Pure business logic for validating rename operation +pub fn validate_rename_operation(old_name: &str, new_name: &str) -> Result<()> { + if old_name.is_empty() { + return Err(anyhow!("Old name cannot be empty")); + } + + if new_name.is_empty() { + return Err(anyhow!("New name cannot be empty")); + } + + if new_name == old_name { + return Err(anyhow!("New name must be different from the current name")); + } + + if old_name == "main" || old_name == "master" { + return Err(anyhow!("Cannot rename main worktree")); + } + + if new_name == "main" || new_name == "master" { + return Err(anyhow!("Cannot rename to main")); + } + + Ok(()) +} + +/// Pure business logic for executing rename operation +pub fn execute_rename(config: &WorktreeRenameConfig, manager: &GitWorktreeManager) -> Result<()> { + manager + .rename_worktree(&config.old_name, &config.new_name) + .map_err(|e| anyhow!("Failed to rename worktree: {e}"))?; + + utils::print_success(&format!( + "Worktree renamed from '{}' to '{}'!", + config.old_name.yellow(), + config.new_name.bright_green() + )); + + if config.rename_branch { + if let Some(ref new_branch) = config.new_branch { + utils::print_progress(&format!("Renaming branch to '{new_branch}'...")); + + match manager.rename_branch(&config.old_branch, new_branch) { + Ok(_) => { + utils::print_success(&format!( + "Branch renamed from '{}' to '{}'!", + config.old_branch.yellow(), + new_branch.bright_green() + )); + } + Err(e) => { + return Err(anyhow!("Failed to rename branch: {e}")); + } + } + } + } + + Ok(()) +} + +/// Renames an existing worktree +pub fn rename_worktree() -> Result<()> { + let manager = GitWorktreeManager::new()?; + let ui = DialoguerUI; + rename_worktree_with_ui(&manager, &ui) +} + +/// Internal implementation of rename_worktree with dependency injection +pub fn rename_worktree_with_ui(manager: &GitWorktreeManager, ui: &dyn UserInterface) -> Result<()> { + let worktrees = manager.list_worktrees()?; + + if worktrees.is_empty() { + println!(); + let msg = "• No worktrees to rename.".yellow(); + println!("{msg}"); + println!(); + press_any_key_to_continue()?; + return Ok(()); + } + + let renameable_worktrees = get_renameable_worktrees(&worktrees); + + if renameable_worktrees.is_empty() { + println!(); + let msg = "• No worktrees available for renaming.".yellow(); + println!("{msg}"); + println!( + "{}", + " (Cannot rename the current worktree)".bright_black() + ); + println!(); + press_any_key_to_continue()?; + return Ok(()); + } + + println!(); + let header = section_header("Rename Worktree"); + println!("{header}"); + println!(); + + let items: Vec = renameable_worktrees + .iter() + .map(|w| format!("{} ({})", w.name, w.branch)) + .collect(); + + let selection = match ui.select_with_default( + "Select a worktree to rename (ESC to cancel)", + &items, + DEFAULT_MENU_SELECTION, + ) { + Ok(selection) => selection, + Err(_) => return Ok(()), + }; + + let worktree = renameable_worktrees[selection]; + + println!(); + let new_name = match ui.input(&format!("New name for '{}' (ESC to cancel)", worktree.name)) { + Ok(name) => name.trim().to_string(), + Err(_) => return Ok(()), + }; + + if let Err(e) = validate_rename_operation(&worktree.git_name, &new_name) { + utils::print_warning(&e.to_string()); + return Ok(()); + } + + let new_name = match validate_worktree_name(&new_name) { + Ok(validated_name) => validated_name, + Err(e) => { + utils::print_error(&format!("Invalid worktree name: {e}")); + println!(); + press_any_key_to_continue()?; + return Ok(()); + } + }; + + let analysis = analyze_rename_requirements(worktree)?; + + let rename_branch = if analysis.can_rename_branch { + println!(); + match ui.confirm_with_default("Also rename the associated branch?", true) { + Ok(confirm) => confirm, + Err(_) => return Ok(()), + } + } else { + false + }; + + let new_branch = if rename_branch { + if analysis.is_feature_branch { + Some(format!("feature/{new_name}")) + } else { + Some(new_name.clone()) + } + } else { + None + }; + + println!(); + let preview_label = "Preview:".bright_white(); + println!("{preview_label}"); + let worktree_label = "Worktree:".bright_white(); + let old_name = &worktree.name; + let new_name_green = new_name.bright_green(); + println!(" {worktree_label} {old_name} → {new_name_green}"); + + let new_path = worktree.path.parent().unwrap().join(&new_name); + let path_label = "Path:".bright_white(); + let old_path = worktree.path.display(); + let new_path_green = new_path.display().to_string().bright_green(); + println!(" {path_label} {old_path} → {new_path_green}"); + + if let Some(ref new_branch_name) = new_branch { + let branch_label = "Branch:".bright_white(); + let old_branch = &worktree.branch; + let new_branch_green = new_branch_name.bright_green(); + println!(" {branch_label} {old_branch} → {new_branch_green}"); + } + + println!(); + let confirm = match ui.confirm_with_default("Proceed with rename?", false) { + Ok(confirm) => confirm, + Err(_) => return Ok(()), + }; + + if !confirm { + return Ok(()); + } + + let config = WorktreeRenameConfig { + old_name: worktree.git_name.clone(), + new_name: new_name.clone(), + old_path: worktree.path.clone(), + new_path, + old_branch: worktree.branch.clone(), + new_branch, + rename_branch, + }; + + utils::print_progress(&format!("Renaming worktree to '{new_name}'...")); + + match execute_rename(&config, manager) { + Ok(_) => { + println!(); + press_any_key_to_continue()?; + Ok(()) + } + Err(e) => { + utils::print_error(&format!("{e}")); + println!(); + press_any_key_to_continue()?; + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_should_rename_branch_true() { + let test_name = "feature"; + assert!(should_rename_branch(test_name, test_name, true)); + } + + #[test] + fn test_should_rename_branch_false_user_doesnt_want() { + let test_name = "feature"; + assert!(!should_rename_branch(test_name, test_name, false)); + } + + #[test] + fn test_should_rename_branch_false_names_mismatch() { + let feature_name = "feature"; + let main_name = "main"; + assert!(!should_rename_branch(feature_name, main_name, true)); + } + + #[test] + fn test_rename_config_creation() { + let old_name = "old-name"; + let new_name = "new-name"; + let config = RenameConfig { + old_name: old_name.to_string(), + new_name: new_name.to_string(), + old_path: PathBuf::from("/tmp/old"), + new_path: PathBuf::from("/tmp/new"), + rename_branch: true, + }; + + assert_eq!(config.old_name, old_name); + assert_eq!(config.new_name, new_name); + assert!(config.rename_branch); + } + + #[test] + fn test_worktree_rename_config_creation() { + let old_worktree = "old-worktree"; + let new_worktree = "new-worktree"; + let old_branch = "old-branch"; + let new_branch = "new-branch"; + let config = WorktreeRenameConfig { + old_name: old_worktree.to_string(), + new_name: new_worktree.to_string(), + old_path: PathBuf::from("/tmp/old"), + new_path: PathBuf::from("/tmp/new"), + old_branch: old_branch.to_string(), + new_branch: Some(new_branch.to_string()), + rename_branch: true, + }; + + assert_eq!(config.old_name, old_worktree); + assert_eq!(config.new_name, new_worktree); + assert_eq!(config.old_branch, old_branch); + assert_eq!(config.new_branch, Some(new_branch.to_string())); + assert!(config.rename_branch); + } + + #[test] + fn test_get_renameable_worktrees_filter_current() { + let main_name = "main"; + let feature_name = "feature"; + let worktrees = vec![ + WorktreeInfo { + name: main_name.to_string(), + git_name: main_name.to_string(), + path: PathBuf::from("/tmp/main"), + branch: main_name.to_string(), + is_current: true, + has_changes: false, + last_commit: None, + ahead_behind: None, + is_locked: false, + }, + WorktreeInfo { + name: feature_name.to_string(), + git_name: feature_name.to_string(), + path: PathBuf::from("/tmp/feature"), + branch: feature_name.to_string(), + is_current: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + is_locked: false, + }, + ]; + + let renameable = get_renameable_worktrees(&worktrees); + assert_eq!(renameable.len(), 1); + assert_eq!(renameable[0].name, feature_name); + } + + #[test] + fn test_analyze_rename_requirements_basic() { + let feature_name = "feature"; + let worktree = WorktreeInfo { + name: feature_name.to_string(), + git_name: feature_name.to_string(), + path: PathBuf::from("/tmp/feature"), + branch: feature_name.to_string(), + is_current: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + is_locked: false, + }; + + let analysis = analyze_rename_requirements(&worktree).unwrap(); + + assert_eq!(analysis.worktree.name, feature_name); + assert!(analysis.can_rename_branch); + assert_eq!( + analysis.suggested_branch_name, + Some(feature_name.to_string()) + ); + } + + #[test] + fn test_validate_rename_operation_valid() { + let old_name = "old-name"; + let new_name = "new-name"; + let result = validate_rename_operation(old_name, new_name); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_rename_operation_same_name() { + let same_name = "same-name"; + let result = validate_rename_operation(same_name, same_name); + assert!(result.is_err()); + } + + #[test] + fn test_validate_rename_operation_main_worktree() { + let main_name = "main"; + let new_name = "new-name"; + let result = validate_rename_operation(main_name, new_name); + assert!(result.is_err()); + + let old_name = "old-name"; + let result = validate_rename_operation(old_name, main_name); + assert!(result.is_err()); + } + + #[test] + fn test_analyze_rename_requirements_feature_branch() { + let worktree_name = "auth"; + let feature_branch = "feature/auth"; + let worktree = WorktreeInfo { + name: worktree_name.to_string(), + git_name: worktree_name.to_string(), + path: PathBuf::from("/tmp/auth"), + branch: feature_branch.to_string(), + is_current: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + is_locked: false, + }; + + let analysis = analyze_rename_requirements(&worktree).unwrap(); + assert!(analysis.can_rename_branch); + assert!(analysis.is_feature_branch); + assert_eq!( + analysis.suggested_branch_name, + Some(format!("feature/{worktree_name}")) + ); + } + + #[test] + fn test_analyze_rename_requirements_detached_head() { + let worktree = WorktreeInfo { + name: "detached".to_string(), + git_name: "detached".to_string(), + path: PathBuf::from("/tmp/detached"), + branch: DEFAULT_BRANCH_DETACHED.to_string(), + is_current: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + is_locked: false, + }; + + let analysis = analyze_rename_requirements(&worktree).unwrap(); + assert!(!analysis.can_rename_branch); + assert!(analysis.suggested_branch_name.is_none()); + } + + #[test] + fn test_validate_rename_operation_empty_names() { + let empty_string = ""; + let valid_name = "valid-name"; + + assert!(validate_rename_operation(empty_string, valid_name).is_err()); + assert!(validate_rename_operation(valid_name, empty_string).is_err()); + } + + #[test] + fn test_validate_rename_operation_master_worktree() { + let master_name = "master"; + let new_name = "new-name"; + let old_name = "old-name"; + + assert!(validate_rename_operation(master_name, new_name).is_err()); + assert!(validate_rename_operation(old_name, master_name).is_err()); + } + + #[test] + fn test_get_renameable_worktrees_empty_list() { + let worktrees: Vec = vec![]; + let renameable = get_renameable_worktrees(&worktrees); + assert!(renameable.is_empty()); + } +} diff --git a/src/usecases/search_worktrees.rs b/src/usecases/search_worktrees.rs new file mode 100644 index 0000000..b2a929c --- /dev/null +++ b/src/usecases/search_worktrees.rs @@ -0,0 +1,181 @@ +use anyhow::{anyhow, Result}; +use colored::*; +use dialoguer::FuzzySelect; + +use crate::constants::{ + section_header, HEADER_SEARCH_WORKTREES, HOOK_POST_SWITCH, MSG_ALREADY_IN_WORKTREE, + MSG_NO_WORKTREES_TO_SEARCH, MSG_SEARCH_FUZZY_ENABLED, PROMPT_SELECT_WORKTREE_SWITCH, + SEARCH_CURRENT_INDICATOR, +}; +use crate::git::{GitWorktreeManager, WorktreeInfo}; +use crate::hooks::{self, HookContext}; +use crate::utils::{self, get_theme, press_any_key_to_continue}; + +#[derive(Debug, Clone)] +pub struct SearchConfig { + pub query: String, + pub show_current_indicator: bool, +} + +#[derive(Debug, Clone)] +pub struct SearchAnalysis { + pub items: Vec, + pub total_count: usize, + pub has_current: bool, +} + +pub fn create_search_items(worktrees: &[WorktreeInfo]) -> SearchAnalysis { + let items: Vec = worktrees + .iter() + .map(|wt| { + let mut item = format!("{} ({})", wt.name, wt.branch); + if wt.is_current { + item.push_str(SEARCH_CURRENT_INDICATOR); + } + item + }) + .collect(); + + let has_current = worktrees.iter().any(|w| w.is_current); + + SearchAnalysis { + items, + total_count: worktrees.len(), + has_current, + } +} + +pub fn validate_search_selection( + worktrees: &[WorktreeInfo], + selection_index: usize, +) -> Result<&WorktreeInfo> { + if selection_index >= worktrees.len() { + return Err(anyhow!("Invalid selection index")); + } + + Ok(&worktrees[selection_index]) +} + +pub fn search_worktrees() -> Result { + let manager = GitWorktreeManager::new()?; + search_worktrees_internal(&manager) +} + +fn search_worktrees_internal(manager: &GitWorktreeManager) -> Result { + let worktrees = manager.list_worktrees()?; + + if worktrees.is_empty() { + println!(); + println!("{}", MSG_NO_WORKTREES_TO_SEARCH.yellow()); + println!(); + press_any_key_to_continue()?; + return Ok(false); + } + + println!(); + println!("{}", section_header(HEADER_SEARCH_WORKTREES)); + println!(); + + let analysis = create_search_items(&worktrees); + + println!("{MSG_SEARCH_FUZZY_ENABLED}"); + let selection = match FuzzySelect::with_theme(&get_theme()) + .with_prompt(PROMPT_SELECT_WORKTREE_SWITCH) + .items(&analysis.items) + .interact_opt()? + { + Some(selection) => selection, + None => return Ok(false), + }; + + let selected_worktree = validate_search_selection(&worktrees, selection)?; + + if selected_worktree.is_current { + println!(); + println!("{}", MSG_ALREADY_IN_WORKTREE.yellow()); + println!(); + press_any_key_to_continue()?; + return Ok(false); + } + + crate::adapters::shell::switch_file::write_switch_path(&selected_worktree.path)?; + + println!(); + println!( + "{} Switching to worktree '{}'", + "+".green(), + selected_worktree.name.bright_white().bold() + ); + println!( + " {} {}", + "Path:".bright_black(), + selected_worktree.path.display() + ); + println!( + " {} {}", + "Branch:".bright_black(), + selected_worktree.branch.yellow() + ); + + if let Err(e) = hooks::execute_hooks( + HOOK_POST_SWITCH, + &HookContext { + worktree_name: selected_worktree.name.clone(), + worktree_path: selected_worktree.path.clone(), + }, + ) { + utils::print_warning(&format!("Hook execution warning: {e}")); + } + + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_search_items() { + let worktrees = vec![WorktreeInfo { + name: "feature-branch".to_string(), + git_name: "feature-branch".to_string(), + path: std::path::PathBuf::from("/test/feature-branch"), + branch: "feature/test".to_string(), + is_current: true, + is_locked: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + }]; + + let analysis = create_search_items(&worktrees); + + assert_eq!(analysis.total_count, 1); + assert!(analysis.has_current); + assert_eq!(analysis.items.len(), 1); + assert!(analysis.items[0].contains("feature-branch")); + assert!(analysis.items[0].contains("feature/test")); + assert!(analysis.items[0].contains(SEARCH_CURRENT_INDICATOR)); + } + + #[test] + fn test_validate_search_selection() -> Result<()> { + let worktrees = vec![WorktreeInfo { + name: "feature-branch".to_string(), + git_name: "feature-branch".to_string(), + path: std::path::PathBuf::from("/test/feature-branch"), + branch: "feature/test".to_string(), + is_current: false, + is_locked: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + }]; + + let selected = validate_search_selection(&worktrees, 0)?; + assert_eq!(selected.name, "feature-branch"); + assert!(validate_search_selection(&worktrees, 1).is_err()); + + Ok(()) + } +} diff --git a/src/usecases/switch_worktree.rs b/src/usecases/switch_worktree.rs new file mode 100644 index 0000000..3a7d667 --- /dev/null +++ b/src/usecases/switch_worktree.rs @@ -0,0 +1,323 @@ +use anyhow::{anyhow, Result}; +use colored::*; + +use crate::adapters::shell::switch_file::write_switch_path; +use crate::constants::{ + section_header, DEFAULT_MENU_SELECTION, HOOK_POST_SWITCH, MSG_ALREADY_IN_WORKTREE, +}; +use crate::git::{GitWorktreeManager, WorktreeInfo}; +use crate::hooks::{self, HookContext}; +use crate::ui::{DialoguerUI, UserInterface}; +use crate::utils::{self, press_any_key_to_continue}; + +/// Validate switch target +#[allow(dead_code)] +pub fn validate_switch_target(name: &str) -> Result<()> { + if name.is_empty() { + return Err(anyhow!("Switch target cannot be empty")); + } + + if name.contains(' ') || name.contains('\t') || name.contains('\n') { + return Err(anyhow!("Invalid worktree name: contains whitespace")); + } + + Ok(()) +} + +/// Check if already in the target worktree +#[allow(dead_code)] +pub fn is_already_in_worktree(current: &Option, target: &str) -> bool { + match current { + Some(current_name) => current_name == target, + None => false, + } +} + +/// Configuration for worktree switching (simplified for tests) +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SwitchConfig { + pub target_name: String, + pub target_path: std::path::PathBuf, + pub source_name: Option, + pub save_changes: bool, +} + +/// Configuration for worktree switching +#[derive(Debug, Clone)] +pub struct WorktreeSwitchConfig { + pub target_name: String, + pub target_path: std::path::PathBuf, + pub target_branch: String, +} + +/// Result of switch analysis +#[derive(Debug, Clone)] +pub struct SwitchAnalysis { + pub worktrees: Vec, + pub current_worktree_index: Option, + pub is_already_current: bool, +} + +/// Pure business logic for sorting worktrees for display +pub fn sort_worktrees_for_display(mut worktrees: Vec) -> Vec { + worktrees.sort_by(|a, b| { + if a.is_current && !b.is_current { + std::cmp::Ordering::Less + } else if !a.is_current && b.is_current { + std::cmp::Ordering::Greater + } else { + a.name.cmp(&b.name) + } + }); + worktrees +} + +/// Pure business logic for analyzing switch target +pub fn analyze_switch_target( + worktrees: &[WorktreeInfo], + selected_index: usize, +) -> Result { + if selected_index >= worktrees.len() { + return Err(anyhow!("Invalid selection index")); + } + + let current_index = worktrees.iter().position(|w| w.is_current); + let selected_worktree = &worktrees[selected_index]; + + Ok(SwitchAnalysis { + worktrees: worktrees.to_vec(), + current_worktree_index: current_index, + is_already_current: selected_worktree.is_current, + }) +} + +/// Pure business logic for executing switch operation +pub fn execute_switch(config: &WorktreeSwitchConfig) -> Result<()> { + write_switch_path(&config.target_path)?; + + if let Err(e) = hooks::execute_hooks( + HOOK_POST_SWITCH, + &HookContext { + worktree_name: config.target_name.clone(), + worktree_path: config.target_path.clone(), + }, + ) { + utils::print_warning(&format!("Hook execution warning: {e}")); + } + + Ok(()) +} + +/// Switches to a different worktree +pub fn switch_worktree() -> Result { + let manager = GitWorktreeManager::new()?; + let ui = DialoguerUI; + switch_worktree_with_ui(&manager, &ui) +} + +/// Internal implementation of switch_worktree with dependency injection +pub fn switch_worktree_with_ui( + manager: &GitWorktreeManager, + ui: &dyn UserInterface, +) -> Result { + let worktrees = manager.list_worktrees()?; + + if worktrees.is_empty() { + println!(); + let msg = "• No worktrees available.".yellow(); + println!("{msg}"); + println!(); + press_any_key_to_continue()?; + return Ok(false); + } + + println!(); + let header = section_header("Switch Worktree"); + println!("{header}"); + println!(); + + let sorted_worktrees = sort_worktrees_for_display(worktrees); + + let items: Vec = sorted_worktrees + .iter() + .map(|w| { + if w.is_current { + format!("{} ({}) [current]", w.name, w.branch) + } else { + format!("{} ({})", w.name, w.branch) + } + }) + .collect(); + + let selection = match ui.select_with_default( + "Select a worktree to switch to (ESC to cancel)", + &items, + DEFAULT_MENU_SELECTION, + ) { + Ok(selection) => selection, + Err(_) => return Ok(false), + }; + + let analysis = analyze_switch_target(&sorted_worktrees, selection)?; + + if analysis.is_already_current { + println!(); + let msg = MSG_ALREADY_IN_WORKTREE.yellow(); + println!("{msg}"); + println!(); + press_any_key_to_continue()?; + return Ok(false); + } + + let selected_worktree = &sorted_worktrees[selection]; + + let config = WorktreeSwitchConfig { + target_name: selected_worktree.name.clone(), + target_path: selected_worktree.path.clone(), + target_branch: selected_worktree.branch.clone(), + }; + + println!(); + let plus_sign = "+".green(); + let worktree_name = config.target_name.bright_white().bold(); + println!("{plus_sign} Switching to worktree '{worktree_name}'"); + let path_label = "Path:".bright_black(); + let path_display = config.target_path.display(); + println!(" {path_label} {path_display}"); + let branch_label = "Branch:".bright_black(); + let branch_name = config.target_branch.yellow(); + println!(" {branch_label} {branch_name}"); + + execute_switch(&config)?; + + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_validate_switch_target_valid() { + assert!(validate_switch_target("feature-branch").is_ok()); + assert!(validate_switch_target("main").is_ok()); + assert!(validate_switch_target("valid_name").is_ok()); + assert!(validate_switch_target("123").is_ok()); + } + + #[test] + fn test_validate_switch_target_invalid() { + assert!(validate_switch_target("").is_err()); + assert!(validate_switch_target("name with space").is_err()); + assert!(validate_switch_target("name\twith\ttab").is_err()); + assert!(validate_switch_target("name\nwith\nnewline").is_err()); + } + + #[test] + fn test_is_already_in_worktree_true() { + let current = Some("feature".to_string()); + assert!(is_already_in_worktree(¤t, "feature")); + } + + #[test] + fn test_is_already_in_worktree_false_different_name() { + let current = Some("main".to_string()); + assert!(!is_already_in_worktree(¤t, "feature")); + } + + #[test] + fn test_is_already_in_worktree_false_no_current() { + let current = None; + assert!(!is_already_in_worktree(¤t, "feature")); + } + + #[test] + fn test_switch_config_creation() { + let config = SwitchConfig { + target_name: "feature".to_string(), + target_path: PathBuf::from("/tmp/feature"), + source_name: Some("main".to_string()), + save_changes: true, + }; + + assert_eq!(config.target_name, "feature"); + assert_eq!(config.source_name, Some("main".to_string())); + assert!(config.save_changes); + } + + #[test] + fn test_worktree_switch_config_creation() { + let config = WorktreeSwitchConfig { + target_name: "feature".to_string(), + target_path: PathBuf::from("/tmp/feature"), + target_branch: "feature-branch".to_string(), + }; + + assert_eq!(config.target_name, "feature"); + assert_eq!(config.target_branch, "feature-branch"); + assert_eq!(config.target_path, PathBuf::from("/tmp/feature")); + } + + #[test] + fn test_sort_worktrees_for_display() { + let worktrees = vec![ + WorktreeInfo { + name: "zzz-last".to_string(), + git_name: "zzz-last".to_string(), + path: PathBuf::from("/tmp/zzz"), + branch: "zzz-branch".to_string(), + is_locked: false, + is_current: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + }, + WorktreeInfo { + name: "aaa-first".to_string(), + git_name: "aaa-first".to_string(), + path: PathBuf::from("/tmp/aaa"), + branch: "aaa-branch".to_string(), + is_locked: false, + is_current: true, + has_changes: false, + last_commit: None, + ahead_behind: None, + }, + ]; + + let sorted = sort_worktrees_for_display(worktrees); + assert_eq!(sorted[0].name, "aaa-first"); + assert_eq!(sorted[1].name, "zzz-last"); + assert!(sorted[0].is_current); + assert!(!sorted[1].is_current); + } + + #[test] + fn test_analyze_switch_target_basic() { + let worktrees = vec![WorktreeInfo { + name: "main".to_string(), + git_name: "main".to_string(), + path: PathBuf::from("/tmp/main"), + branch: "main".to_string(), + is_locked: false, + is_current: false, + has_changes: false, + last_commit: None, + ahead_behind: None, + }]; + + let analysis = analyze_switch_target(&worktrees, 0).unwrap(); + assert_eq!(analysis.worktrees[0].name, "main"); + assert!(!analysis.is_already_current); + assert_eq!(analysis.current_worktree_index, None); + } + + #[test] + fn test_analyze_switch_target_invalid_index() { + let worktrees = vec![]; + let result = analyze_switch_target(&worktrees, 0); + assert!(result.is_err()); + } +} From 3c39301f938f3763a89cc84642fd5c8736be87b0 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 12 Apr 2026 15:33:12 +0900 Subject: [PATCH 2/6] docs: refresh agent guidance docs --- AGENTS.md | 327 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 293 ++++++++++++------------------------------------ 2 files changed, 399 insertions(+), 221 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..569fe29 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,327 @@ +# AGENTS.md + +This file provides guidance to coding agents when working with code in this repository. + +## Project Overview + +Git Workers is an interactive CLI tool for managing Git worktrees, written in Rust. It provides a menu-driven interface for creating, deleting, switching, and renaming worktrees, with shell integration for automatic directory switching. + +## Development Commands + +### Build and Run + +```bash +# Development build +cargo build + +# Release build +cargo build --release + +# Run directly (development) +cargo run + +# Run the binary +./target/debug/gw +./target/release/gw + +# Run tests +cargo test + +# Run specific test +cargo test test_name + +# Run tests single-threaded (for flaky tests) +cargo test -- --test-threads=1 + +# Run tests with output for debugging +cargo test test_name -- --nocapture + +# Run with logging enabled +RUST_LOG=debug cargo run +RUST_LOG=git_workers=trace cargo run +``` + +### Quality Checks + +```bash +# Format check and apply +cargo fmt --check +cargo fmt + +# Clippy (linter) +cargo clippy --all-features -- -D warnings + +# Type check +cargo check --all-features + +# Generate documentation +cargo doc --no-deps --open + +# Run all checks (using bun if available) +bun run check + +# Coverage report (requires cargo-llvm-cov) +cargo llvm-cov --html --lib --ignore-filename-regex '(tests/|src/main\.rs|src/bin/)' --open +``` + +### Commit Conventions + +- Follow `Conventional Commits` for all commit messages +- Format: `()?: ` +- Common types in this repository: + - `feat`: user-facing feature additions + - `fix`: bug fixes and behavior corrections + - `refactor`: structural changes without behavior changes + - `test`: test additions or test-only refactors + - `docs`: documentation-only changes + - `chore`: maintenance work with no product behavior impact + - `ci`: CI or automation workflow changes + - `build`: build system or dependency management changes +- Keep the subject concise, imperative, and lowercase where natural +- Do not mix structural changes and behavior changes in the same commit +- Examples: + - `refactor(app): move menu dispatch into app module` + - `fix(create): preserve selected tag when creating worktree` + - `test(rename): cover cancel flow in rename prompt` + +### Installation + +```bash +# Install locally from source +cargo install --path . + +# Setup shell integration +./setup.sh + +# Or manually add to ~/.bashrc or ~/.zshrc: +source /path/to/git-workers/shell/gw.sh +``` + +## Current Focus Areas + +- Interactive worktree operations are driven from the `app` layer and delegated into `usecases` +- Existing public paths such as `commands`, `infrastructure`, and `repository_info` are kept as compatibility facades +- The project supports shell-assisted directory switching, lifecycle hooks, file copying, tag-based creation, and validated custom paths +- Current refactoring policy prioritizes preserving observable behavior over aggressively removing compatibility layers + +## Architecture + +### Core Module Structure + +``` +src/ +├── main.rs # Thin CLI entry point (`--version` + app startup) +├── lib.rs # Public module exports and backward-compatible re-exports +├── app/ # Menu loop, action dispatch, presenter helpers +├── usecases/ # Main worktree operations (create/delete/list/rename/switch/search) +├── adapters/ # Config, shell, filesystem, Git, UI, and hook adapters +├── domain/ # Repository context and domain-level helpers +├── commands/ # Backward-compatible facades over usecases +├── config.rs # Configuration model and access helpers +├── repository_info.rs # Backward-compatible facade for repo context display +├── infrastructure/ # Backward-compatible exports for older module paths +├── core/ # Legacy core logic retained during migration +├── ui.rs # User interface abstraction used by prompts and tests +├── input_esc_raw.rs # ESC-aware input helpers +├── constants.rs # Centralized strings and formatting constants +├── support/ # Terminal and styling support utilities +└── utils.rs # Shared utilities and compatibility helpers +``` + +### Dependency Direction + +- `main` -> `app` +- `app` -> `usecases` +- `usecases` -> `adapters`, `domain`, `ui`, `config`, `infrastructure` +- `commands` and `repository_info` should stay thin and delegate to the newer modules +- Public compatibility paths are intentionally preserved unless a breaking change is explicitly planned + +### Technology Stack + +- **dialoguer + console**: Interactive CLI (Select, Confirm, Input prompts) +- **git2**: Git repository operations (branch listing, commit info) +- **std::process::Command**: Git CLI invocation (worktree add/prune) +- **colored**: Terminal output coloring +- **fuzzy-matcher**: Worktree search functionality +- **indicatif**: Progress bar display + +### Shell Integration System + +Automatic directory switching on worktree change requires special implementation due to Unix process restrictions: + +1. Binary writes path to file specified by `GW_SWITCH_FILE` env var +2. Shell function (`shell/gw.sh`) reads the file and executes `cd` +3. Legacy fallback: `SWITCH_TO:/path` marker on stdout + +### Hook System Design + +Define lifecycle hooks in `.git-workers.toml`: + +```toml +[hooks] +post-create = ["npm install", "cp .env.example .env"] +pre-remove = ["rm -rf node_modules"] +post-switch = ["echo 'Switched to {{worktree_name}}'"] +``` + +Template variables: + +- `{{worktree_name}}`: The worktree name +- `{{worktree_path}}`: Absolute path to worktree + +### Worktree Patterns + +First worktree creation offers two options: + +1. **Same level as repository**: `../worktree-name` - Creates worktrees as siblings to the repository +2. **Custom path**: User specifies any relative path (e.g., `main`, `branches/feature`, `worktrees/name`) + +For bare repositories with `.bare` pattern, use custom path to create worktrees inside the project directory: +- Custom path: `main` → `my-project/main/` +- Custom path: `feature-1` → `my-project/feature-1/` + +Subsequent worktrees follow the established pattern automatically. + +### ESC Key Handling + +All interactive prompts support ESC cancellation through custom `input_esc_raw` module: + +- `input_esc_raw()` returns `Option` (None on ESC) +- `Select::interact_opt()` for menu selections +- `Confirm::interact_opt()` for confirmations + +### Worktree Rename Implementation + +Since Git lacks native rename functionality: + +1. Move directory with `fs::rename` +2. Update `.git/worktrees/` metadata directory +3. Update gitdir files in both directions +4. Optionally rename associated branch if it matches worktree name + +### CI/CD Configuration + +- **GitHub Actions**: `.github/workflows/ci.yml` (test, lint, build) +- **Release workflow**: `.github/workflows/release.yml` (automated releases) +- **Homebrew tap**: Updates `wasabeef/homebrew-gw-tap` on release +- **Pre-commit hooks**: `lefthook.yml` (format, clippy) + +### Testing Considerations + +- The repository currently has 51 test files across `unit`, `integration`, `e2e`, and `performance` +- The safest full verification command is `cargo test --all-features -- --test-threads=1` +- `cargo fmt --check` and `cargo clippy --all-features -- -D warnings` are expected before shipping significant changes +- Some tests remain sensitive to parallel execution because they manipulate Git repositories and process-wide state +- Use `--nocapture` when debugging interactive or repository-context behavior + +### Common Error Patterns and Solutions + +1. **"Permission denied" when running tests**: Tests create temporary directories; ensure proper permissions +2. **"Repository not found" errors**: Tests require git to be configured (`git config --global user.name/email`) +3. **Flaky test failures**: Use `--test-threads=1` to avoid race conditions in worktree operations +4. **"Lock file exists" errors**: Clean up `.git/git-workers-worktree.lock` if tests are interrupted + +### String Formatting + +- **ALWAYS use inline variable syntax in format! macros**: `format!("{variable}")` instead of `format!("{}", variable)` +- This applies to ALL format-like macros: `format!`, `println!`, `eprintln!`, `log::info!`, `log::warn!`, `log::error!`, etc. +- Examples: + + ```rust + // ✅ Correct + format!("Device {name} created successfully") + println!("Found {count} devices") + log::info!("Starting device {identifier}") + + // ❌ Incorrect + format!("Device {} created successfully", name) + println!("Found {} devices", count) + log::info!("Starting device {}", identifier) + ``` + +- This rule is enforced by `clippy::uninlined_format_args` which treats violations as errors in CI +- Apply this consistently across ALL files including main source, tests, examples, and binary targets + +### Important Constraints + +- Only works within Git repositories +- Requires initial commit (bare repositories supported) +- Cannot rename current worktree +- Cannot rename worktrees with detached HEAD +- Shell integration supports Bash/Zsh only +- No Windows support (macOS and Linux only) +- The CLI is primarily interactive, with `--version` as the supported non-interactive flag + +### Configuration Loading Priority + +**Bare repositories:** + +- Check main/master worktree directories only + +**Non-bare repositories:** + +1. Current directory (current worktree) +2. Main/master worktree directories (fallback) + +### File Copy Behavior + +Ignored files such as `.env` can be copied into new worktrees through `.git-workers.toml`. + +```toml +[files] +copy = [".env", ".env.local", "config/local.json"] +# source = "path/to/source" +``` + +- Copy runs after worktree creation and before post-create hooks +- Missing source files warn but do not abort worktree creation +- Paths are validated to prevent traversal and invalid destinations +- File handling includes symlink checks, depth limits, and permission preservation where applicable + +## CI/CD and Tooling + +- `.github/workflows/ci.yml` runs the main validation pipeline +- `.github/workflows/release.yml` handles release automation +- `lefthook.yml` runs pre-commit checks such as `fmt` and `clippy` +- `package.json` provides helper scripts: + - `bun run format` + - `bun run lint` + - `bun run test` + - `bun run check` + +## Key Implementation Patterns + +### Git Operations + +The codebase uses two approaches for Git operations: + +1. **git2 library**: For read operations (listing branches, getting commit info) +2. **std::process::Command**: For write operations (worktree add/remove) to ensure compatibility + +Example pattern: + +```rust +// Read operation using git2 +let repo = Repository::open(".")?; +let branches = repo.branches(Some(BranchType::Local))?; + +// Write operation using Command +Command::new("git") + .args(&["worktree", "add", path, branch]) + .output()?; +``` + +### Error Handling Philosophy + +- Use `anyhow::Result` for application-level errors +- Provide context with `.context()` for better error messages +- Show user-friendly messages via `utils::display_error()` +- Never panic in production code; handle all error cases gracefully + +### UI Abstraction + +The `ui::UserInterface` trait enables testing of interactive features: + +- Mock implementations for tests +- Real implementation wraps dialoguer +- All user interactions go through this abstraction diff --git a/CLAUDE.md b/CLAUDE.md index accb1e1..f007294 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,26 @@ bun run check cargo llvm-cov --html --lib --ignore-filename-regex '(tests/|src/main\.rs|src/bin/)' --open ``` +### Commit Conventions + +- Follow `Conventional Commits` for all commit messages +- Format: `()?: ` +- Common types in this repository: + - `feat`: user-facing feature additions + - `fix`: bug fixes and behavior corrections + - `refactor`: structural changes without behavior changes + - `test`: test additions or test-only refactors + - `docs`: documentation-only changes + - `chore`: maintenance work with no product behavior impact + - `ci`: CI or automation workflow changes + - `build`: build system or dependency management changes +- Keep the subject concise, imperative, and lowercase where natural +- Do not mix structural changes and behavior changes in the same commit +- Examples: + - `refactor(app): move menu dispatch into app module` + - `fix(create): preserve selected tag when creating worktree` + - `test(rename): cover cancel flow in rename prompt` + ### Installation ```bash @@ -77,43 +97,12 @@ cargo install --path . source /path/to/git-workers/shell/gw.sh ``` -## Recent Changes - -### v0.3.0 File Copy Feature - -- Automatically copy gitignored files (like `.env`) from main worktree to new worktrees -- Configurable via `[files]` section in `.git-workers.toml` -- Security validation to prevent path traversal attacks -- Follows same discovery priority as configuration files - -### Branch Option Enhancement - -- Enhanced from 2 options to 3: "Create from current HEAD", "Select branch", and "Select tag" -- Branch selection automatically handles conflicts and offers appropriate actions -- Tag selection allows creating worktrees from specific versions - -### Custom Path Support - -- Added third option for first worktree creation: "Custom path (specify relative to project root)" -- Allows users to specify arbitrary relative paths for worktree creation -- Comprehensive path validation with security checks: - - Prevents absolute paths - - Validates against filesystem-incompatible characters - - Blocks git reserved names in path components - - Prevents excessive path traversal (max one level above project root) - - Cross-platform compatibility checks +## Current Focus Areas -### Key Methods Added/Modified - -- **`get_branch_worktree_map()`**: Maps branch names to worktree names, including main worktree detection -- **`list_all_branches()`**: Returns both local and remote branches (remote without "origin/" prefix) -- **`list_all_tags()`**: Returns all tags with optional messages for annotated tags -- **`create_worktree_with_new_branch()`**: Creates worktree with new branch from base branch (supports git-flow style workflows) -- **`create_worktree_with_branch()`**: Enhanced to handle tag references for creating worktrees at specific versions -- **`copy_configured_files()`**: Copies files specified in config to new worktrees -- **`create_worktree_from_head()`**: Fixed path resolution for non-bare repositories (converts relative paths to absolute) -- **`validate_custom_path()`**: Validates custom paths for security and compatibility -- **`create_worktree_internal()`**: Enhanced with custom path input option and tag selection +- Interactive worktree operations are driven from the `app` layer and delegated into `usecases` +- Existing public paths such as `commands`, `infrastructure`, and `repository_info` are kept as compatibility facades +- The project supports shell-assisted directory switching, lifecycle hooks, file copying, tag-based creation, and validated custom paths +- Current refactoring policy prioritizes preserving observable behavior over aggressively removing compatibility layers ## Architecture @@ -121,29 +110,32 @@ source /path/to/git-workers/shell/gw.sh ``` src/ -├── main.rs # CLI entry point and main menu loop -├── lib.rs # Library exports and module re-exports -├── commands.rs # Command implementations for menu items -├── menu.rs # MenuItem enum and icon definitions -├── config.rs # .git-workers.toml configuration management -├── repository_info.rs # Repository information display -├── input_esc_raw.rs # Custom input handling with ESC support -├── constants.rs # Centralized constants (strings, formatting) -├── utils.rs # Common utilities (error display, etc.) -├── ui.rs # User interface abstraction layer -├── git_interface.rs # Git operations trait abstraction -├── core/ # Core business logic (UI/infra independent) -│ ├── mod.rs # Module exports -│ ├── models.rs # Core data models (Worktree, Branch, etc.) -│ └── validation.rs # Validation logic for names and paths -└── infrastructure/ # Infrastructure implementations - ├── mod.rs # Module exports - ├── git.rs # Git worktree operations (git2 + process::Command) - ├── hooks.rs # Hook system (post-create, pre-remove, etc.) - ├── file_copy.rs # File copy functionality for gitignored files - └── filesystem.rs # Filesystem operations and utilities +├── main.rs # Thin CLI entry point (`--version` + app startup) +├── lib.rs # Public module exports and backward-compatible re-exports +├── app/ # Menu loop, action dispatch, presenter helpers +├── usecases/ # Main worktree operations (create/delete/list/rename/switch/search) +├── adapters/ # Config, shell, filesystem, Git, UI, and hook adapters +├── domain/ # Repository context and domain-level helpers +├── commands/ # Backward-compatible facades over usecases +├── config.rs # Configuration model and access helpers +├── repository_info.rs # Backward-compatible facade for repo context display +├── infrastructure/ # Backward-compatible exports for older module paths +├── core/ # Legacy core logic retained during migration +├── ui.rs # User interface abstraction used by prompts and tests +├── input_esc_raw.rs # ESC-aware input helpers +├── constants.rs # Centralized strings and formatting constants +├── support/ # Terminal and styling support utilities +└── utils.rs # Shared utilities and compatibility helpers ``` +### Dependency Direction + +- `main` -> `app` +- `app` -> `usecases` +- `usecases` -> `adapters`, `domain`, `ui`, `config`, `infrastructure` +- `commands` and `repository_info` should stay thin and delegate to the newer modules +- Public compatibility paths are intentionally preserved unless a breaking change is explicitly planned + ### Technology Stack - **dialoguer + console**: Interactive CLI (Select, Confirm, Input prompts) @@ -216,11 +208,11 @@ Since Git lacks native rename functionality: ### Testing Considerations -- Integration tests in `tests/` directory (17 test files after consolidation) -- Some tests are flaky in parallel execution (marked with `#[ignore]`) -- CI sets `CI=true` environment variable to skip flaky tests -- Run with `--test-threads=1` for reliable results -- Use `--nocapture` to see test output for debugging +- The repository currently has 51 test files across `unit`, `integration`, `e2e`, and `performance` +- The safest full verification command is `cargo test --all-features -- --test-threads=1` +- `cargo fmt --check` and `cargo clippy --all-features -- -D warnings` are expected before shipping significant changes +- Some tests remain sensitive to parallel execution because they manipulate Git repositories and process-wide state +- Use `--nocapture` when debugging interactive or repository-context behavior ### Common Error Patterns and Solutions @@ -258,7 +250,7 @@ Since Git lacks native rename functionality: - Cannot rename worktrees with detached HEAD - Shell integration supports Bash/Zsh only - No Windows support (macOS and Linux only) -- Recent breaking change: CLI arguments removed in favor of menu-only interface +- The CLI is primarily interactive, with `--version` as the supported non-interactive flag ### Configuration Loading Priority @@ -271,172 +263,31 @@ Since Git lacks native rename functionality: 1. Current directory (current worktree) 2. Main/master worktree directories (fallback) -## v0.3.0 File Copy Feature (Implemented) - -### Overview +### File Copy Behavior -Automatically copy ignored files (like `.env`) from main worktree to new worktrees during creation. - -### Configuration +Ignored files such as `.env` can be copied into new worktrees through `.git-workers.toml`. ```toml [files] -# Files to copy when creating new worktrees copy = [".env", ".env.local", "config/local.json"] - -# Optional: source directory (defaults to main worktree) # source = "path/to/source" ``` -### Implementation Details - -1. **Config Structure**: `FilesConfig` struct with `copy` and `source` fields (destination is always worktree root) -2. **File Detection**: Uses same priority as config file discovery for finding source files -3. **Copy Logic**: Executes after worktree creation but before post-create hooks -4. **Error Handling**: Warns on missing files but continues with worktree creation -5. **Security**: Validates paths to prevent directory traversal attacks -6. **Features**: - - Supports both files and directories - - Recursive directory copying - - Symlink detection with warnings - - Maximum directory depth limit (50 levels) - - Preserves file permissions - -## Bug Fixes - -### v0.3.0 Worktree Creation Path Resolution - -Fixed an issue where creating worktrees from HEAD in non-bare repositories could fail when using relative paths like `../worktree-name`. The fix ensures that relative paths are resolved from the current working directory rather than from the git directory. - -**Root Cause**: The `git worktree add` command was being executed with `current_dir` set to the git directory, causing relative paths to be interpreted incorrectly. - -### v0.3.0 Security and Robustness Improvements - -#### Worktree Name Validation - -Added comprehensive validation for worktree names to prevent issues: - -- **Invalid Characters**: Rejects filesystem-incompatible characters (`/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`, `\0`) -- **Reserved Names**: Prevents conflicts with Git internals (`.git`, `HEAD`, `refs`, etc.) -- **Non-ASCII Warning**: Warns users about potential compatibility issues with non-ASCII characters -- **Length Limits**: Enforces 255-character maximum for filesystem compatibility -- **Hidden Files**: Prevents names starting with `.` to avoid hidden file conflicts - -#### File Copy Size Limits - -Enhanced file copy functionality with safety checks: - -- **Large File Skipping**: Automatically skips files larger than 100MB with warnings -- **Performance Protection**: Prevents accidental copying of build artifacts or large binaries -- **User Feedback**: Clear warnings when files are skipped due to size - -#### Concurrent Access Control - -Implemented file-based locking to prevent race conditions: - -- **Process Locking**: Uses `.git/git-workers-worktree.lock` to prevent concurrent worktree creation -- **Stale Lock Cleanup**: Automatically removes locks older than 5 minutes -- **Error Messages**: Clear feedback when another process is creating worktrees -- **Automatic Cleanup**: Lock files are automatically removed when operations complete - -#### Custom Path Validation - -Added comprehensive validation for user-specified worktree paths: - -- **Path Security**: Validates against path traversal attacks and excessive directory navigation -- **Cross-Platform Compatibility**: Checks for Windows reserved characters even on non-Windows systems -- **Git Reserved Names**: Prevents conflicts with git internal directories in path components -- **Path Format Validation**: Ensures proper relative path format (no absolute paths, no trailing slashes) - -**Solution**: Convert relative paths to absolute paths before passing them to the git command, ensuring consistent behavior regardless of the working directory. - -## Test Coverage and CI Integration - -### Test File Consolidation (v0.5.1+) - -Major test restructuring completed to improve maintainability and reduce duplication: - -- **File Reduction**: Consolidated from 64 to 40 test files -- **Unified Structure**: Created `unified_*_comprehensive_test.rs` files grouping related functionality -- **Duplication Removal**: Eliminated 15+ duplicate test cases -- **Comment Translation**: Converted all Japanese comments to English for consistency - -### CI/CD Configuration - -**GitHub Actions Workflows:** - -- `.github/workflows/ci.yml`: Comprehensive test, lint, build, and coverage analysis -- `.github/workflows/release.yml`: Automated releases with Homebrew tap updates - -**Pre-commit Hooks (lefthook.yml):** - -```yaml -pre-commit: - parallel: false - commands: - fmt: - glob: '*.rs' - run: cargo fmt --all - stage_fixed: true - clippy: - glob: '*.rs' - run: cargo clippy --all-targets --all-features -- -D warnings -``` - -**Test Configuration:** - -- Single-threaded execution (`--test-threads=1`) to prevent race conditions -- CI environment variable automatically set for non-interactive test execution -- Coverage analysis with `cargo-tarpaulin` including proper concurrency control - -### Package Management Integration - -**Bun Integration (package.json):** - -```json -{ - "scripts": { - "test": "bun ./scripts/run-tests.js", - "format": "cargo fmt --all && prettier --write .", - "lint": "cargo clippy --all-targets --all-features -- -D warnings", - "check": "bun run format && bun run lint && bun run test" - } -} -``` - -**Test Runner Scripts:** - -- `scripts/run-tests.js`: Bun-compatible test wrapper with proper exit handling -- `scripts/test.sh`: Bash fallback for direct cargo test execution - -### Test Structure - -**Unified Test Files (40 total):** - -- `unified_*_comprehensive_test.rs`: Consolidated functionality tests -- `api_contract_basic_test.rs`: Contract-based testing -- Security, edge cases, and integration tests with proper error handling - -**Coverage Analysis:** - -- Single-threaded execution prevents worktree lock conflicts -- Directory restoration with fallback handling for CI environments -- Error handling for temporary directory cleanup - -### Test Execution Best Practices - -- Use `CI=true` environment variable for non-interactive execution -- Single-threaded execution prevents resource conflicts -- Comprehensive error handling for CI environment limitations -- Automated cleanup of temporary files and directories - -### Legacy Test Files (Pre-consolidation) - -The following test files were consolidated into unified versions: - -- Individual component tests → `unified_*_comprehensive_test.rs` -- Duplicate functionality tests → Removed -- Japanese comments → Translated to English +- Copy runs after worktree creation and before post-create hooks +- Missing source files warn but do not abort worktree creation +- Paths are validated to prevent traversal and invalid destinations +- File handling includes symlink checks, depth limits, and permission preservation where applicable + +## CI/CD and Tooling + +- `.github/workflows/ci.yml` runs the main validation pipeline +- `.github/workflows/release.yml` handles release automation +- `lefthook.yml` runs pre-commit checks such as `fmt` and `clippy` +- `package.json` provides helper scripts: + - `bun run format` + - `bun run lint` + - `bun run test` + - `bun run check` ## Key Implementation Patterns From f354c4004207430db25d315764d8013eb2a20a17 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 12 Apr 2026 15:48:41 +0900 Subject: [PATCH 3/6] refactor(core): route worktree flows through adapters and domain --- src/adapters/config/loader.rs | 2 +- src/adapters/filesystem/file_copy.rs | 14 +- src/adapters/git/git_worktree_repository.rs | 2 +- src/adapters/git/mod.rs | 2 +- src/adapters/hooks/mod.rs | 18 +- src/app/presenter.rs | 2 +- src/core/validation.rs | 256 +------------------- src/domain/paths.rs | 56 ++++- src/domain/validation.rs | 148 ++++++++++- src/domain/worktree.rs | 2 +- src/lib.rs | 16 +- src/usecases/cleanup_worktrees.rs | 2 +- src/usecases/create_worktree.rs | 68 +----- src/usecases/delete_worktree.rs | 5 +- src/usecases/list_worktrees.rs | 5 +- src/usecases/rename_worktree.rs | 5 +- src/usecases/search_worktrees.rs | 5 +- src/usecases/switch_worktree.rs | 5 +- tests/unit/commands/mod.rs | 21 +- tests/unit/infrastructure/hooks.rs | 86 +++++++ 20 files changed, 379 insertions(+), 341 deletions(-) diff --git a/src/adapters/config/loader.rs b/src/adapters/config/loader.rs index 9e5999a..0932e2a 100644 --- a/src/adapters/config/loader.rs +++ b/src/adapters/config/loader.rs @@ -1,7 +1,7 @@ use anyhow::{anyhow, Result}; +use crate::adapters::git::GitWorktreeManager; use crate::constants::{CONFIG_FILE_NAME, GIT_DIR}; -use crate::git::GitWorktreeManager; pub use crate::config::Config; diff --git a/src/adapters/filesystem/file_copy.rs b/src/adapters/filesystem/file_copy.rs index 2b629d2..afd4341 100644 --- a/src/adapters/filesystem/file_copy.rs +++ b/src/adapters/filesystem/file_copy.rs @@ -1 +1,13 @@ -pub use crate::infrastructure::file_copy::copy_configured_files; +use anyhow::Result; +use std::path::Path; + +use crate::adapters::git::GitWorktreeManager; +use crate::config::FilesConfig; + +pub fn copy_configured_files( + config: &FilesConfig, + destination_path: &Path, + manager: &GitWorktreeManager, +) -> Result> { + crate::infrastructure::file_copy::copy_configured_files(config, destination_path, manager) +} diff --git a/src/adapters/git/git_worktree_repository.rs b/src/adapters/git/git_worktree_repository.rs index ac7ef3d..fb7d581 100644 --- a/src/adapters/git/git_worktree_repository.rs +++ b/src/adapters/git/git_worktree_repository.rs @@ -1 +1 @@ -pub use crate::infrastructure::git::{GitWorktreeManager, WorktreeInfo}; +pub use crate::infrastructure::git::{CommitInfo, GitWorktreeManager, WorktreeInfo}; diff --git a/src/adapters/git/mod.rs b/src/adapters/git/mod.rs index f544a17..21316c4 100644 --- a/src/adapters/git/mod.rs +++ b/src/adapters/git/mod.rs @@ -2,6 +2,6 @@ pub mod git_worktree_repository; pub mod repo_discovery; pub mod worktree_lock; -pub use git_worktree_repository::{GitWorktreeManager, WorktreeInfo}; +pub use git_worktree_repository::{CommitInfo, GitWorktreeManager, WorktreeInfo}; pub use repo_discovery::{open_repository_at_path, open_repository_from_env}; pub use worktree_lock::WorktreeLock; diff --git a/src/adapters/hooks/mod.rs b/src/adapters/hooks/mod.rs index 3d0c3f3..82823c0 100644 --- a/src/adapters/hooks/mod.rs +++ b/src/adapters/hooks/mod.rs @@ -1 +1,17 @@ -pub use crate::hooks::{execute_hooks, execute_hooks_with_ui, HookContext}; +use anyhow::Result; + +use crate::ui::UserInterface; + +pub type HookContext = crate::infrastructure::hooks::HookContext; + +pub fn execute_hooks(hook_type: &str, context: &HookContext) -> Result<()> { + crate::infrastructure::hooks::execute_hooks(hook_type, context) +} + +pub fn execute_hooks_with_ui( + hook_type: &str, + context: &HookContext, + ui: &dyn UserInterface, +) -> Result<()> { + crate::infrastructure::hooks::execute_hooks_with_ui(hook_type, context, ui) +} diff --git a/src/app/presenter.rs b/src/app/presenter.rs index 4e06eb2..f9d8d69 100644 --- a/src/app/presenter.rs +++ b/src/app/presenter.rs @@ -5,7 +5,7 @@ use crate::constants::{ EMOJI_LOCKED, }; use crate::domain::repo_context; -use crate::git::WorktreeInfo; +use crate::domain::worktree::WorktreeInfo; pub fn build_header_lines() -> Vec { let version = env!("CARGO_PKG_VERSION"); diff --git a/src/core/validation.rs b/src/core/validation.rs index b5a799d..e5bdd14 100644 --- a/src/core/validation.rs +++ b/src/core/validation.rs @@ -1,258 +1,6 @@ -//! Validation logic for git-workers -//! -//! This module contains all validation logic for worktree names and paths, -//! ensuring safety and compatibility across different filesystems. +//! Legacy validation facade kept for backward compatibility. -use anyhow::{anyhow, Result}; - -// Import constants from the parent module -use crate::constants::{ - GIT_RESERVED_NAMES, INVALID_FILESYSTEM_CHARS, MAX_WORKTREE_NAME_LENGTH, WINDOWS_RESERVED_CHARS, -}; - -/// Validates a worktree name for safety and compatibility -/// -/// # Arguments -/// -/// * `name` - The worktree name to validate -/// -/// # Returns -/// -/// * `Ok(String)` - The validated name (possibly with warnings shown) -/// * `Err(anyhow::Error)` - If the name is invalid -/// -/// # Validation Rules -/// -/// 1. **Non-empty**: Name must not be empty or whitespace-only -/// 2. **Length limit**: Must not exceed 255 characters (filesystem limit) -/// 3. **Reserved names**: Cannot be Git internal names (.git, HEAD, refs, etc.) -/// 4. **Invalid characters**: Cannot contain filesystem-incompatible characters -/// 5. **Hidden files**: Names starting with '.' are not allowed -/// 6. **Unicode**: Non-ASCII characters are not allowed for compatibility -/// -/// # Security -/// -/// This function prevents directory traversal attacks and ensures -/// compatibility across different filesystems and operating systems. -/// -/// # Examples -/// -/// ```rust -/// use git_workers::core::validate_worktree_name; -/// -/// // Valid names -/// assert!(validate_worktree_name("feature-branch").is_ok()); -/// assert!(validate_worktree_name("bugfix-123").is_ok()); -/// -/// // Invalid names -/// assert!(validate_worktree_name("").is_err()); -/// assert!(validate_worktree_name(".git").is_err()); -/// assert!(validate_worktree_name("branch:name").is_err()); -/// ``` -pub fn validate_worktree_name(name: &str) -> Result { - let trimmed = name.trim(); - - // Check if empty - if trimmed.is_empty() { - return Err(anyhow!("Worktree name cannot be empty")); - } - - // Check length - if trimmed.len() > MAX_WORKTREE_NAME_LENGTH { - return Err(anyhow!( - "Worktree name cannot exceed {MAX_WORKTREE_NAME_LENGTH} characters" - )); - } - - // Check for Git reserved names (case insensitive) - let trimmed_lower = trimmed.to_lowercase(); - for reserved in GIT_RESERVED_NAMES { - if trimmed_lower == reserved.to_lowercase() { - return Err(anyhow!( - "'{}' is a reserved Git name and cannot be used as a worktree name", - trimmed - )); - } - } - - // Check for path separators and dangerous characters - for &ch in INVALID_FILESYSTEM_CHARS { - if trimmed.contains(ch) { - return Err(anyhow!( - "Worktree name cannot contain '{}' (filesystem incompatible)", - ch - )); - } - } - - // Check for Windows reserved characters (even on non-Windows systems for portability) - for &ch in WINDOWS_RESERVED_CHARS { - if trimmed.contains(ch) { - return Err(anyhow!( - "Worktree name cannot contain '{}' (Windows incompatible)", - ch - )); - } - } - - // Check for null bytes - if trimmed.contains('\0') { - return Err(anyhow!("Worktree name cannot contain null bytes")); - } - - // Check if name starts with a dot (hidden file - not allowed) - if trimmed.starts_with('.') { - return Err(anyhow!( - "Worktree name cannot start with '.' (hidden files not allowed)" - )); - } - - // Check for non-ASCII characters (not allowed in test environment) - if !trimmed.is_ascii() { - return Err(anyhow!( - "Worktree name must contain only ASCII characters for compatibility" - )); - } - - Ok(trimmed.to_string()) -} - -/// Validates a custom path for worktree creation -/// -/// # Arguments -/// -/// * `path` - The custom path to validate -/// -/// # Returns -/// -/// * `Ok(())` - If the path is valid -/// * `Err(anyhow::Error)` - If the path is invalid with explanation -/// -/// # Validation Rules -/// -/// 1. **Relative paths only**: Must not be absolute -/// 2. **Path traversal prevention**: Limited directory traversal -/// 3. **Reserved names**: Cannot contain Git reserved names in path components -/// 4. **Character validation**: Must not contain filesystem-incompatible characters -/// 5. **Cross-platform compatibility**: Validates against Windows reserved characters -/// 6. **Format validation**: Must not end with path separators -/// -/// # Security -/// -/// This function is critical for preventing directory traversal attacks -/// and ensuring the worktree is created in a safe location relative -/// to the repository root. -/// -/// # Examples -/// -/// ```rust -/// use git_workers::core::validate_custom_path; -/// -/// // Valid paths -/// assert!(validate_custom_path("../sibling-dir/worktree").is_ok()); -/// assert!(validate_custom_path("subdir/worktree").is_ok()); -/// assert!(validate_custom_path("../parent/child").is_ok()); -/// -/// // Invalid paths -/// assert!(validate_custom_path("/absolute/path").is_err()); -/// assert!(validate_custom_path("../../etc/passwd").is_err()); -/// assert!(validate_custom_path("path/.git/config").is_ok()); // .git as path component is allowed -/// ``` -pub fn validate_custom_path(path: &str) -> Result<()> { - let trimmed = path.trim(); - - // Check if empty - if trimmed.is_empty() { - return Err(anyhow!("Custom path cannot be empty")); - } - - // Check if absolute path - if trimmed.starts_with('/') || (trimmed.len() > 1 && trimmed.chars().nth(1) == Some(':')) { - return Err(anyhow!("Custom path must be relative, not absolute")); - } - - // Check for Windows UNC paths - if trimmed.starts_with("\\\\") { - return Err(anyhow!("UNC paths are not supported")); - } - - // Check if path ends with separator (not allowed) - if trimmed.ends_with('/') || trimmed.ends_with('\\') { - return Err(anyhow!("Custom path cannot end with a path separator")); - } - - // Split path into components for validation - let components: Vec<&str> = trimmed.split('/').collect(); - - // Check for excessive directory traversal (security) - let mut depth = 0; - for component in &components { - if *component == ".." { - depth -= 1; - if depth < -1 { - // Allow one level up but not more for security - return Err(anyhow!( - "Excessive directory traversal (..) is not allowed for security reasons" - )); - } - } else if *component != "." && !component.is_empty() { - depth += 1; - } - } - - // Validate each path component - for component in &components { - // Skip empty components, current dir (.), and parent dir (..) - if component.is_empty() || *component == "." || *component == ".." { - continue; - } - - // Check for Git reserved names in path components - if GIT_RESERVED_NAMES.contains(component) { - return Err(anyhow!( - "Path component '{}' is a reserved Git name", - component - )); - } - - // Check for filesystem-incompatible characters (except backslash for Windows compatibility) - for &ch in INVALID_FILESYSTEM_CHARS { - // Allow backslash in custom paths for Windows compatibility - if ch == '\\' { - continue; - } - if component.contains(ch) { - return Err(anyhow!( - "Path component '{}' contains invalid character '{}'", - component, - ch - )); - } - } - - // Check for Windows reserved characters - for &ch in WINDOWS_RESERVED_CHARS { - if component.contains(ch) { - return Err(anyhow!( - "Path component '{}' contains Windows-incompatible character '{}'", - component, - ch - )); - } - } - - // Check component length - if component.len() > MAX_WORKTREE_NAME_LENGTH { - return Err(anyhow!( - "Path component '{}' exceeds maximum length of {} characters", - component, - MAX_WORKTREE_NAME_LENGTH - )); - } - } - - Ok(()) -} +pub use crate::domain::validation::{validate_custom_path, validate_worktree_name}; #[cfg(test)] mod tests { diff --git a/src/domain/paths.rs b/src/domain/paths.rs index 0a7a052..640d20d 100644 --- a/src/domain/paths.rs +++ b/src/domain/paths.rs @@ -1,3 +1,53 @@ -pub use crate::usecases::create_worktree::{ - determine_worktree_path, validate_worktree_creation, validate_worktree_location, -}; +use anyhow::{anyhow, Result}; +use std::path::{Path, PathBuf}; + +use crate::constants::{STRING_CUSTOM, STRING_SAME_LEVEL}; +use crate::domain::worktree::WorktreeInfo; + +pub fn validate_worktree_location(location: &str) -> Result<()> { + match location { + STRING_SAME_LEVEL | STRING_CUSTOM => Ok(()), + _ => Err(anyhow!("Invalid worktree location type: {location}")), + } +} + +pub fn determine_worktree_path( + git_dir: &Path, + name: &str, + location: &str, + custom_path: Option, +) -> Result<(PathBuf, String)> { + validate_worktree_location(location)?; + + match location { + STRING_SAME_LEVEL => { + let path = git_dir + .parent() + .ok_or_else(|| anyhow!("Cannot determine parent directory"))? + .join(name); + Ok((path, STRING_SAME_LEVEL.to_string())) + } + STRING_CUSTOM => { + let path = custom_path + .ok_or_else(|| anyhow!("Custom path required when location is 'custom'"))?; + Ok((git_dir.join(path), STRING_CUSTOM.to_string())) + } + _ => Err(anyhow!("Invalid location type: {location}")), + } +} + +pub fn validate_worktree_creation( + name: &str, + path: &PathBuf, + existing_worktrees: &[WorktreeInfo], +) -> Result<()> { + if existing_worktrees.iter().any(|w| w.name == name) { + return Err(anyhow!("Worktree '{name}' already exists")); + } + + if existing_worktrees.iter().any(|w| w.path == *path) { + return Err(anyhow!("Path '{}' already in use", path.display())); + } + + Ok(()) +} diff --git a/src/domain/validation.rs b/src/domain/validation.rs index 50fe78e..9714b56 100644 --- a/src/domain/validation.rs +++ b/src/domain/validation.rs @@ -1 +1,147 @@ -pub use crate::core::{validate_custom_path, validate_worktree_name}; +use anyhow::{anyhow, Result}; + +use crate::constants::{ + GIT_RESERVED_NAMES, INVALID_FILESYSTEM_CHARS, MAX_WORKTREE_NAME_LENGTH, WINDOWS_RESERVED_CHARS, +}; + +pub fn validate_worktree_name(name: &str) -> Result { + let trimmed = name.trim(); + + if trimmed.is_empty() { + return Err(anyhow!("Worktree name cannot be empty")); + } + + if trimmed.len() > MAX_WORKTREE_NAME_LENGTH { + return Err(anyhow!( + "Worktree name cannot exceed {MAX_WORKTREE_NAME_LENGTH} characters" + )); + } + + let trimmed_lower = trimmed.to_lowercase(); + for reserved in GIT_RESERVED_NAMES { + if trimmed_lower == reserved.to_lowercase() { + return Err(anyhow!( + "'{}' is a reserved Git name and cannot be used as a worktree name", + trimmed + )); + } + } + + for &ch in INVALID_FILESYSTEM_CHARS { + if trimmed.contains(ch) { + return Err(anyhow!( + "Worktree name cannot contain '{}' (filesystem incompatible)", + ch + )); + } + } + + for &ch in WINDOWS_RESERVED_CHARS { + if trimmed.contains(ch) { + return Err(anyhow!( + "Worktree name cannot contain '{}' (Windows incompatible)", + ch + )); + } + } + + if trimmed.contains('\0') { + return Err(anyhow!("Worktree name cannot contain null bytes")); + } + + if trimmed.starts_with('.') { + return Err(anyhow!( + "Worktree name cannot start with '.' (hidden files not allowed)" + )); + } + + if !trimmed.is_ascii() { + return Err(anyhow!( + "Worktree name must contain only ASCII characters for compatibility" + )); + } + + Ok(trimmed.to_string()) +} + +pub fn validate_custom_path(path: &str) -> Result<()> { + let trimmed = path.trim(); + + if trimmed.is_empty() { + return Err(anyhow!("Custom path cannot be empty")); + } + + if trimmed.starts_with('/') || (trimmed.len() > 1 && trimmed.chars().nth(1) == Some(':')) { + return Err(anyhow!("Custom path must be relative, not absolute")); + } + + if trimmed.starts_with("\\\\") { + return Err(anyhow!("UNC paths are not supported")); + } + + if trimmed.ends_with('/') || trimmed.ends_with('\\') { + return Err(anyhow!("Custom path cannot end with a path separator")); + } + + let components: Vec<&str> = trimmed.split('/').collect(); + + let mut depth = 0; + for component in &components { + if *component == ".." { + depth -= 1; + if depth < -1 { + return Err(anyhow!( + "Excessive directory traversal (..) is not allowed for security reasons" + )); + } + } else if *component != "." && !component.is_empty() { + depth += 1; + } + } + + for component in &components { + if component.is_empty() || *component == "." || *component == ".." { + continue; + } + + if GIT_RESERVED_NAMES.contains(component) { + return Err(anyhow!( + "Path component '{}' is a reserved Git name", + component + )); + } + + for &ch in INVALID_FILESYSTEM_CHARS { + if ch == '\\' { + continue; + } + if component.contains(ch) { + return Err(anyhow!( + "Path component '{}' contains invalid character '{}'", + component, + ch + )); + } + } + + for &ch in WINDOWS_RESERVED_CHARS { + if component.contains(ch) { + return Err(anyhow!( + "Path component '{}' contains Windows-incompatible character '{}'", + component, + ch + )); + } + } + + if component.len() > MAX_WORKTREE_NAME_LENGTH { + return Err(anyhow!( + "Path component '{}' exceeds maximum length of {} characters", + component, + MAX_WORKTREE_NAME_LENGTH + )); + } + } + + Ok(()) +} diff --git a/src/domain/worktree.rs b/src/domain/worktree.rs index c61a013..f483149 100644 --- a/src/domain/worktree.rs +++ b/src/domain/worktree.rs @@ -1 +1 @@ -pub use crate::git::WorktreeInfo; +pub use crate::adapters::git::WorktreeInfo; diff --git a/src/lib.rs b/src/lib.rs index 0cc0f99..a5a4359 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,13 +20,17 @@ //! //! The library is organized into several modules: //! -//! - [`core`] - Core business logic, independent of UI and infrastructure -//! - [`commands`] - Command implementations for menu items +//! - [`app`] - Interactive application flow, menu wiring, and presentation helpers +//! - [`usecases`] - Worktree-oriented orchestration for create/delete/list/rename/switch flows +//! - [`adapters`] - Bridges to Git, filesystem, shell, hooks, config loading, and UI +//! - [`domain`] - Validation, path logic, and repository-context helpers +//! - [`core`] - Legacy core logic retained for compatibility during migration +//! - [`commands`] - Backward-compatible facades for the public command API //! - [`config`] - Configuration file management -//! - [`git`] - Core Git operations and worktree management -//! - [`hooks`] - Hook system for custom commands +//! - [`git`] - Backward-compatible re-export of Git worktree management types +//! - [`hooks`] - Backward-compatible re-export of hook execution APIs //! - [`menu`] - Menu item definitions -//! - [`repository_info`] - Repository context detection +//! - [`repository_info`] - Backward-compatible repository context helpers //! - [`utils`] - Utility functions for terminal output //! - [`input_esc_raw`] - Custom input handling with ESC key support //! - [`ui`] - User interface abstraction layer for testability @@ -61,5 +65,5 @@ pub mod ui; pub mod usecases; pub mod utils; -// Re-export infrastructure modules for backward compatibility +// Re-export legacy module paths for backward compatibility. pub use infrastructure::{file_copy, filesystem, git, hooks}; diff --git a/src/usecases/cleanup_worktrees.rs b/src/usecases/cleanup_worktrees.rs index c751d62..306ccd0 100644 --- a/src/usecases/cleanup_worktrees.rs +++ b/src/usecases/cleanup_worktrees.rs @@ -1,8 +1,8 @@ use anyhow::Result; use colored::*; +use crate::adapters::git::GitWorktreeManager; use crate::constants::{section_header, DEFAULT_WORKTREE_CLEANUP_DAYS}; -use crate::git::GitWorktreeManager; use crate::input_esc_raw::input_esc_with_default_raw as input_esc_with_default; use crate::utils::{self, press_any_key_to_continue}; diff --git a/src/usecases/create_worktree.rs b/src/usecases/create_worktree.rs index 4611499..e49a47a 100644 --- a/src/usecases/create_worktree.rs +++ b/src/usecases/create_worktree.rs @@ -4,7 +4,10 @@ use indicatif::{ProgressBar, ProgressStyle}; use std::path::PathBuf; use std::time::Duration; +use crate::adapters::git::GitWorktreeManager; +use crate::adapters::hooks::HookContext; use crate::adapters::shell::switch_file::write_switch_path; +use crate::adapters::{filesystem::copy_configured_files, hooks}; use crate::config::Config; use crate::constants::{ section_header, BRANCH_OPTION_SELECT_BRANCH, BRANCH_OPTION_SELECT_TAG, DEFAULT_EMPTY_STRING, @@ -16,13 +19,10 @@ use crate::constants::{ OPTION_CUSTOM_PATH_FULL, OPTION_SELECT_BRANCH_FULL, OPTION_SELECT_TAG_FULL, PROGRESS_BAR_TICK_MILLIS, PROMPT_CONFLICT_ACTION, PROMPT_CUSTOM_PATH, PROMPT_SELECT_BRANCH, PROMPT_SELECT_BRANCH_OPTION, PROMPT_SELECT_TAG, PROMPT_SELECT_WORKTREE_LOCATION, - PROMPT_WORKTREE_NAME, SLASH_CHAR, STRING_CUSTOM, STRING_SAME_LEVEL, - TAG_MESSAGE_TRUNCATE_LENGTH, WORKTREE_LOCATION_CUSTOM_PATH, WORKTREE_LOCATION_SAME_LEVEL, + PROMPT_WORKTREE_NAME, SLASH_CHAR, TAG_MESSAGE_TRUNCATE_LENGTH, WORKTREE_LOCATION_CUSTOM_PATH, + WORKTREE_LOCATION_SAME_LEVEL, }; -use crate::core::{validate_custom_path, validate_worktree_name}; -use crate::file_copy; -use crate::git::GitWorktreeManager; -use crate::hooks::{self, HookContext}; +use crate::domain::validation::{validate_custom_path, validate_worktree_name}; use crate::ui::{DialoguerUI, UserInterface}; use crate::utils::{self, press_any_key_to_continue}; @@ -46,39 +46,9 @@ pub enum BranchSource { NewBranch { name: String, base: String }, } -/// Validate worktree location type -pub fn validate_worktree_location(location: &str) -> Result<()> { - match location { - STRING_SAME_LEVEL | STRING_CUSTOM => Ok(()), - _ => Err(anyhow!("Invalid worktree location type: {}", location)), - } -} - -/// Pure business logic for determining worktree path -pub fn determine_worktree_path( - git_dir: &std::path::Path, - name: &str, - location: &str, - custom_path: Option, -) -> Result<(PathBuf, String)> { - validate_worktree_location(location)?; - - match location { - STRING_SAME_LEVEL => { - let path = git_dir - .parent() - .ok_or_else(|| anyhow!("Cannot determine parent directory"))? - .join(name); - Ok((path, STRING_SAME_LEVEL.to_string())) - } - STRING_CUSTOM => { - let path = custom_path - .ok_or_else(|| anyhow!("Custom path required when location is 'custom'"))?; - Ok((git_dir.join(path), STRING_CUSTOM.to_string())) - } - _ => Err(anyhow!("Invalid location type: {}", location)), - } -} +pub use crate::domain::paths::{ + determine_worktree_path, validate_worktree_creation, validate_worktree_location, +}; /// Pure business logic for determining worktree path (legacy) #[allow(dead_code)] @@ -99,24 +69,6 @@ pub fn determine_worktree_path_legacy( } } -/// Pure business logic for worktree creation validation -#[allow(dead_code)] -pub fn validate_worktree_creation( - name: &str, - path: &PathBuf, - existing_worktrees: &[crate::git::WorktreeInfo], -) -> Result<()> { - if existing_worktrees.iter().any(|w| w.name == name) { - return Err(anyhow!("Worktree '{name}' already exists")); - } - - if existing_worktrees.iter().any(|w| w.path == *path) { - return Err(anyhow!("Path '{}' already in use", path.display())); - } - - Ok(()) -} - pub fn create_worktree() -> Result { let manager = GitWorktreeManager::new()?; let ui = DialoguerUI; @@ -533,7 +485,7 @@ pub fn create_worktree_with_ui( if !config.files.copy.is_empty() { println!(); println!("Copying configured files..."); - match file_copy::copy_configured_files(&config.files, &path, manager) { + match copy_configured_files(&config.files, &path, manager) { Ok(copied) => { if !copied.is_empty() { let copied_count = copied.len(); diff --git a/src/usecases/delete_worktree.rs b/src/usecases/delete_worktree.rs index 5316c6b..1fc4ca9 100644 --- a/src/usecases/delete_worktree.rs +++ b/src/usecases/delete_worktree.rs @@ -2,9 +2,10 @@ use anyhow::{anyhow, Result}; use colored::*; use dialoguer::{Confirm, MultiSelect}; +use crate::adapters::git::GitWorktreeManager; +use crate::adapters::hooks::{self, HookContext}; use crate::constants::{section_header, DEFAULT_MENU_SELECTION, HOOK_PRE_REMOVE}; -use crate::git::{GitWorktreeManager, WorktreeInfo}; -use crate::hooks::{self, HookContext}; +use crate::domain::worktree::WorktreeInfo; use crate::ui::{DialoguerUI, UserInterface}; use crate::utils::{self, get_theme, press_any_key_to_continue}; diff --git a/src/usecases/list_worktrees.rs b/src/usecases/list_worktrees.rs index 06b944c..e436aab 100644 --- a/src/usecases/list_worktrees.rs +++ b/src/usecases/list_worktrees.rs @@ -1,12 +1,13 @@ use anyhow::Result; use colored::*; +use crate::adapters::git::GitWorktreeManager; use crate::constants::{ section_header, CURRENT_MARKER, ICON_CURRENT_WORKTREE, ICON_OTHER_WORKTREE, MODIFIED_STATUS_NO, MODIFIED_STATUS_YES, TABLE_HEADER_BRANCH, TABLE_HEADER_MODIFIED, TABLE_HEADER_NAME, TABLE_HEADER_PATH, TABLE_SEPARATOR, WARNING_NO_WORKTREES, }; -use crate::git::{GitWorktreeManager, WorktreeInfo}; +use crate::domain::worktree::WorktreeInfo; use crate::repository_info::get_repository_info; use crate::ui::{DialoguerUI, UserInterface}; use crate::utils::press_any_key_to_continue; @@ -332,7 +333,7 @@ mod tests { branch: "feature".to_string(), is_current: false, has_changes: false, - last_commit: Some(crate::infrastructure::git::CommitInfo { + last_commit: Some(crate::adapters::git::CommitInfo { id: test_commit_id.to_string(), message: "Add feature".to_string(), author: "test@example.com".to_string(), diff --git a/src/usecases/rename_worktree.rs b/src/usecases/rename_worktree.rs index 5d83496..1b80e8f 100644 --- a/src/usecases/rename_worktree.rs +++ b/src/usecases/rename_worktree.rs @@ -1,11 +1,12 @@ use anyhow::{anyhow, Result}; use colored::*; +use crate::adapters::git::GitWorktreeManager; use crate::constants::{ section_header, DEFAULT_BRANCH_DETACHED, DEFAULT_BRANCH_UNKNOWN, DEFAULT_MENU_SELECTION, }; -use crate::core::validate_worktree_name; -use crate::git::{GitWorktreeManager, WorktreeInfo}; +use crate::domain::validation::validate_worktree_name; +use crate::domain::worktree::WorktreeInfo; use crate::ui::{DialoguerUI, UserInterface}; use crate::utils::{self, press_any_key_to_continue}; diff --git a/src/usecases/search_worktrees.rs b/src/usecases/search_worktrees.rs index b2a929c..346a31e 100644 --- a/src/usecases/search_worktrees.rs +++ b/src/usecases/search_worktrees.rs @@ -2,13 +2,14 @@ use anyhow::{anyhow, Result}; use colored::*; use dialoguer::FuzzySelect; +use crate::adapters::git::GitWorktreeManager; +use crate::adapters::hooks::{self, HookContext}; use crate::constants::{ section_header, HEADER_SEARCH_WORKTREES, HOOK_POST_SWITCH, MSG_ALREADY_IN_WORKTREE, MSG_NO_WORKTREES_TO_SEARCH, MSG_SEARCH_FUZZY_ENABLED, PROMPT_SELECT_WORKTREE_SWITCH, SEARCH_CURRENT_INDICATOR, }; -use crate::git::{GitWorktreeManager, WorktreeInfo}; -use crate::hooks::{self, HookContext}; +use crate::domain::worktree::WorktreeInfo; use crate::utils::{self, get_theme, press_any_key_to_continue}; #[derive(Debug, Clone)] diff --git a/src/usecases/switch_worktree.rs b/src/usecases/switch_worktree.rs index 3a7d667..70d3551 100644 --- a/src/usecases/switch_worktree.rs +++ b/src/usecases/switch_worktree.rs @@ -1,12 +1,13 @@ use anyhow::{anyhow, Result}; use colored::*; +use crate::adapters::git::GitWorktreeManager; +use crate::adapters::hooks::{self, HookContext}; use crate::adapters::shell::switch_file::write_switch_path; use crate::constants::{ section_header, DEFAULT_MENU_SELECTION, HOOK_POST_SWITCH, MSG_ALREADY_IN_WORKTREE, }; -use crate::git::{GitWorktreeManager, WorktreeInfo}; -use crate::hooks::{self, HookContext}; +use crate::domain::worktree::WorktreeInfo; use crate::ui::{DialoguerUI, UserInterface}; use crate::utils::{self, press_any_key_to_continue}; diff --git a/tests/unit/commands/mod.rs b/tests/unit/commands/mod.rs index d8501c3..c2c620f 100644 --- a/tests/unit/commands/mod.rs +++ b/tests/unit/commands/mod.rs @@ -80,7 +80,8 @@ struct CurrentDirGuard { impl CurrentDirGuard { fn change_to(path: &Path) -> Result { - let original = std::env::current_dir()?; + let original = + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR"))); std::env::set_current_dir(path)?; Ok(Self { original }) } @@ -226,6 +227,24 @@ fn test_find_config_file_path_prefers_current_worktree_directory() -> Result<()> Ok(()) } +#[test] +#[serial] +fn test_find_config_file_path_falls_back_to_main_worktree_from_subdirectory() -> Result<()> { + let (temp_dir, manager) = setup_non_bare_repo()?; + + let config_path = temp_dir.path().join(".git-workers.toml"); + fs::write(&config_path, "[worktree]\npattern = \"same-level\"")?; + + let nested_dir = temp_dir.path().join("src").join("nested"); + fs::create_dir_all(&nested_dir)?; + + let _guard = CurrentDirGuard::change_to(&nested_dir)?; + let found_path = find_config_file_path(&manager)?; + assert_eq!(found_path.canonicalize()?, config_path.canonicalize()?); + + Ok(()) +} + // ============================================================================ // Path Validation Tests // ============================================================================ diff --git a/tests/unit/infrastructure/hooks.rs b/tests/unit/infrastructure/hooks.rs index 81c3c1b..63def39 100644 --- a/tests/unit/infrastructure/hooks.rs +++ b/tests/unit/infrastructure/hooks.rs @@ -6,10 +6,30 @@ use anyhow::Result; use git_workers::infrastructure::hooks::{execute_hooks, execute_hooks_with_ui, HookContext}; use git_workers::ui::MockUI; +use serial_test::serial; use std::fs; use std::path::PathBuf; use tempfile::TempDir; +struct CurrentDirGuard { + original: PathBuf, +} + +impl CurrentDirGuard { + fn change_to(path: &std::path::Path) -> Result { + let original = + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR"))); + std::env::set_current_dir(path)?; + Ok(Self { original }) + } +} + +impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.original); + } +} + // ============================================================================ // Hook Context Tests // ============================================================================ @@ -427,3 +447,69 @@ post-create = [] assert!(result.is_ok()); Ok(()) } + +#[test] +#[serial] +fn test_execute_hooks_preserves_order_and_expands_templates() -> Result<()> { + let temp_dir = TempDir::new()?; + git2::Repository::init(temp_dir.path())?; + let worktree_path = temp_dir.path().join("feature-x"); + let log_path = worktree_path.join("hook.log"); + fs::create_dir_all(&worktree_path)?; + + let config_content = r#" +[hooks] +post-create = [ + "printf 'first:{{worktree_name}}\n' >> hook.log", + "printf 'second:{{worktree_path}}\n' >> hook.log" +] +"#; + fs::write(temp_dir.path().join(".git-workers.toml"), config_content)?; + + let _guard = CurrentDirGuard::change_to(temp_dir.path())?; + let context = HookContext { + worktree_name: "feature-x".to_string(), + worktree_path: worktree_path.clone(), + }; + let ui = MockUI::new().with_confirm(true); + + execute_hooks_with_ui("post-create", &context, &ui)?; + + let log = fs::read_to_string(&log_path)?; + let expected = format!( + "first:feature-x\nsecond:{}\n", + context.worktree_path.display() + ); + assert_eq!(log, expected); + + Ok(()) +} + +#[test] +#[serial] +fn test_execute_hooks_skips_commands_when_confirmation_is_rejected() -> Result<()> { + let temp_dir = TempDir::new()?; + git2::Repository::init(temp_dir.path())?; + let worktree_path = temp_dir.path().join("feature-x"); + let log_path = worktree_path.join("hook.log"); + fs::create_dir_all(&worktree_path)?; + + let config_content = r#" +[hooks] +post-create = ["printf 'should-not-run\n' >> hook.log"] +"#; + fs::write(temp_dir.path().join(".git-workers.toml"), config_content)?; + + let _guard = CurrentDirGuard::change_to(temp_dir.path())?; + let context = HookContext { + worktree_name: "feature-x".to_string(), + worktree_path, + }; + let ui = MockUI::new().with_confirm(false); + + execute_hooks_with_ui("post-create", &context, &ui)?; + + assert!(!log_path.exists()); + + Ok(()) +} From c53ff7c182656fd5414614e592257050346cfd34 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 12 Apr 2026 15:48:54 +0900 Subject: [PATCH 4/6] docs: refresh architecture and refactoring guidance --- README.md | 8 ++------ docs/coding-style.md | 10 +++++++--- docs/refactoring-plan.md | 24 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5c381aa..51c42fc 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,8 @@ For regular repositories, configuration is loaded from: ```toml [repository] -# Optional: Specify repository URL to ensure hooks only run in the intended repository -# url = "https://github.com/owner/repo.git" - -[repository] -# Repository URL for identification (optional) -# This ensures hooks only run in the intended repository +# Optional: repository URL for identification +# Hooks run only when the current repository matches this URL url = "https://github.com/wasabeef/git-workers.git" [hooks] diff --git a/docs/coding-style.md b/docs/coding-style.md index 8369aa6..8fa32a7 100644 --- a/docs/coding-style.md +++ b/docs/coding-style.md @@ -149,9 +149,13 @@ return Err(anyhow!("Error creating worktree: {}", error)); ``` src/ -├── commands/ # コマンド実装(機能別) -├── core/ # コアロジック -├── infrastructure/ # 外部依存(Git, ファイルシステム) +├── app/ # 対話フロー、menu、presenter +├── usecases/ # ユーザー操作ごとの orchestration +├── adapters/ # 外部依存(Git, ファイルシステム、shell、UI) +├── domain/ # validation、path、repo context +├── commands/ # 互換 facade +├── core/ # 旧 core ロジック(移行中の互換層) +├── infrastructure/ # 旧 infrastructure 実装(互換層) ├── constants.rs # 全定数の集約 ├── ui.rs # UI 抽象化 └── utils.rs # ユーティリティ diff --git a/docs/refactoring-plan.md b/docs/refactoring-plan.md index 3d2e7ac..f22e828 100644 --- a/docs/refactoring-plan.md +++ b/docs/refactoring-plan.md @@ -21,6 +21,25 @@ 最初の実装では、`constants.rs` と `ui.rs` の全面移設は行わない。これらは参照箇所が広く、早い段階で動かすと diff が不要に大きくなるため、まずは `app / usecases / adapters` の責務境界を作ることを優先する。 +## 実施状況 + +2026-04-12 時点で、以下は完了済み。 + +- `main` の簡素化と `app` 層への menu loop / dispatch の移動 +- `create / delete / list / rename / switch / search / cleanup / edit_hooks` の `usecases` 化 +- `domain` への validation / path logic / repository context の集約 +- `adapters` の導入と `switch_file / config loader / editor / hooks / file_copy` の境界整理 +- 既存 `commands`, `repository_info`, `infrastructure` path の互換維持 +- hook 実行順と config fallback の契約 test 追加 + +現時点で残っているのは「必須の refactoring 作業」ではなく、次期フェーズとしての任意改善である。 + +任意の次フェーズ候補: + +- `infrastructure` の実体をさらに `adapters` へ移す +- 互換 facade の縮小方針を決める +- `lib.rs` の public API を将来的な deprecation 方針込みで整理する + ## スコープ 対象: @@ -566,6 +585,11 @@ src/ ## フェーズごとの PR 推奨分割 +補足: + +- 実施時にはこの分割をベースに進めたが、互換性維持と test 固定を優先して一部はまとめて着地している +- 今後この計画を再利用する場合は、「完了済み phase」と「任意の後続改善」を分けて読むこと + ### PR 1 対象: From 34971b8948c4f745db4923c782712afa90bdfcd1 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 12 Apr 2026 16:05:24 +0900 Subject: [PATCH 5/6] refactor(infra): extract repo discovery and worktree lock --- src/adapters/git/repo_discovery.rs | 15 +++++- src/adapters/git/worktree_lock.rs | 57 ++++++++++++++++++++- src/infrastructure/git.rs | 81 +++++------------------------- 3 files changed, 81 insertions(+), 72 deletions(-) diff --git a/src/adapters/git/repo_discovery.rs b/src/adapters/git/repo_discovery.rs index eac6304..a04414c 100644 --- a/src/adapters/git/repo_discovery.rs +++ b/src/adapters/git/repo_discovery.rs @@ -1,12 +1,23 @@ use anyhow::Result; +use git2::Repository; use std::path::Path; use crate::adapters::git::git_worktree_repository::GitWorktreeManager; +pub(crate) fn discover_repository_from_env() -> Result { + Ok(Repository::open_from_env()?) +} + +pub(crate) fn open_repository_at_path_raw(path: &Path) -> Result { + Ok(Repository::open(path)?) +} + pub fn open_repository_from_env() -> Result { - GitWorktreeManager::new() + let repo = discover_repository_from_env()?; + Ok(GitWorktreeManager { repo }) } pub fn open_repository_at_path(path: &Path) -> Result { - GitWorktreeManager::new_from_path(path) + let repo = open_repository_at_path_raw(path)?; + Ok(GitWorktreeManager { repo }) } diff --git a/src/adapters/git/worktree_lock.rs b/src/adapters/git/worktree_lock.rs index 28554a1..ed10221 100644 --- a/src/adapters/git/worktree_lock.rs +++ b/src/adapters/git/worktree_lock.rs @@ -1 +1,56 @@ -pub use crate::infrastructure::git::WorktreeLock; +use anyhow::{anyhow, Result}; +use std::fs::{self, File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::constants::{ + ERROR_LOCK_CREATE, ERROR_LOCK_EXISTS, LOCK_FILE_NAME, STALE_LOCK_TIMEOUT_SECS, +}; + +const STALE_LOCK_TIMEOUT: Duration = Duration::from_secs(STALE_LOCK_TIMEOUT_SECS); + +pub struct WorktreeLock { + lock_path: PathBuf, + _file: Option, +} + +impl WorktreeLock { + pub fn acquire(git_dir: &Path) -> Result { + let lock_path = git_dir.join(LOCK_FILE_NAME); + + if lock_path.exists() { + if let Ok(metadata) = lock_path.metadata() { + if let Ok(modified) = metadata.modified() { + if let Ok(elapsed) = modified.elapsed() { + if elapsed > STALE_LOCK_TIMEOUT { + let _ = fs::remove_file(&lock_path); + } + } + } + } + } + + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::AlreadyExists { + anyhow!(ERROR_LOCK_EXISTS) + } else { + anyhow!("{}", ERROR_LOCK_CREATE.replace("{}", &e.to_string())) + } + })?; + + Ok(Self { + lock_path, + _file: Some(file), + }) + } +} + +impl Drop for WorktreeLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.lock_path); + } +} diff --git a/src/infrastructure/git.rs b/src/infrastructure/git.rs index 6b46e2a..7a9ff3b 100644 --- a/src/infrastructure/git.rs +++ b/src/infrastructure/git.rs @@ -35,79 +35,22 @@ use anyhow::{anyhow, Result}; use git2::{BranchType, Repository}; -use std::fs::{self, File, OpenOptions}; use std::path::{Path, PathBuf}; -use std::time::Duration; use super::super::constants::{ COMMIT_ID_SHORT_LENGTH, DEFAULT_AUTHOR_UNKNOWN, DEFAULT_BRANCH_DETACHED, - DEFAULT_BRANCH_UNKNOWN, DEFAULT_MESSAGE_NONE, ERROR_LOCK_CREATE, ERROR_LOCK_EXISTS, - ERROR_NO_PARENT_BARE_REPO, ERROR_NO_PARENT_DIR, ERROR_NO_REPO_DIR, ERROR_NO_REPO_WORKING_DIR, - ERROR_NO_WORKING_DIR, ERROR_WORKTREE_CREATE, ERROR_WORKTREE_PATH_EXISTS, GIT_ADD, GIT_BRANCH, - GIT_BRANCH_NOT_FOUND_MSG, GIT_CANNOT_FIND_PARENT, GIT_CANNOT_RENAME_CURRENT, - GIT_CANNOT_RENAME_DETACHED, GIT_CMD, GIT_COMMIT_AUTHOR_UNKNOWN, GIT_COMMIT_MESSAGE_NONE, - GIT_DEFAULT_MAIN_WORKTREE, GIT_DIR, GIT_GITDIR_PREFIX, GIT_GITDIR_SUFFIX, GIT_HEAD_INDEX, - GIT_NEW_NAME_NO_SPACES, GIT_OPT_BRANCH, GIT_OPT_GIT_COMMON_DIR, GIT_OPT_RENAME, GIT_ORIGIN, - GIT_REFS_REMOTES, GIT_REFS_TAGS, GIT_REPAIR, GIT_RESERVED_NAMES, GIT_REV_PARSE, GIT_WORKTREE, - LOCK_FILE_NAME, STALE_LOCK_TIMEOUT_SECS, TIME_FORMAT, WINDOW_FIRST_INDEX, WINDOW_SECOND_INDEX, - WINDOW_SIZE_PAIRS, + DEFAULT_BRANCH_UNKNOWN, DEFAULT_MESSAGE_NONE, ERROR_NO_PARENT_BARE_REPO, ERROR_NO_PARENT_DIR, + ERROR_NO_REPO_DIR, ERROR_NO_REPO_WORKING_DIR, ERROR_NO_WORKING_DIR, ERROR_WORKTREE_CREATE, + ERROR_WORKTREE_PATH_EXISTS, GIT_ADD, GIT_BRANCH, GIT_BRANCH_NOT_FOUND_MSG, + GIT_CANNOT_FIND_PARENT, GIT_CANNOT_RENAME_CURRENT, GIT_CANNOT_RENAME_DETACHED, GIT_CMD, + GIT_COMMIT_AUTHOR_UNKNOWN, GIT_COMMIT_MESSAGE_NONE, GIT_DEFAULT_MAIN_WORKTREE, GIT_DIR, + GIT_GITDIR_PREFIX, GIT_GITDIR_SUFFIX, GIT_HEAD_INDEX, GIT_NEW_NAME_NO_SPACES, GIT_OPT_BRANCH, + GIT_OPT_GIT_COMMON_DIR, GIT_OPT_RENAME, GIT_ORIGIN, GIT_REFS_REMOTES, GIT_REFS_TAGS, + GIT_REPAIR, GIT_RESERVED_NAMES, GIT_REV_PARSE, GIT_WORKTREE, TIME_FORMAT, WINDOW_FIRST_INDEX, + WINDOW_SECOND_INDEX, WINDOW_SIZE_PAIRS, }; use super::filesystem::FileSystem; - -// Create Duration from constant for stale lock timeout -const STALE_LOCK_TIMEOUT: Duration = Duration::from_secs(STALE_LOCK_TIMEOUT_SECS); - -/// Simple lock structure for worktree operations -pub struct WorktreeLock { - lock_path: PathBuf, - _file: Option, -} - -impl WorktreeLock { - /// Attempts to acquire a lock for worktree operations - pub fn acquire(git_dir: &Path) -> Result { - let lock_path = git_dir.join(LOCK_FILE_NAME); - - // Check for stale lock - if lock_path.exists() { - if let Ok(metadata) = lock_path.metadata() { - if let Ok(modified) = metadata.modified() { - if let Ok(elapsed) = modified.elapsed() { - if elapsed > STALE_LOCK_TIMEOUT { - // Remove stale lock - let _ = fs::remove_file(&lock_path); - } - } - } - } - } - - // Try to create lock file exclusively - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&lock_path) - .map_err(|e| { - if e.kind() == std::io::ErrorKind::AlreadyExists { - anyhow!(ERROR_LOCK_EXISTS) - } else { - anyhow!("{}", ERROR_LOCK_CREATE.replace("{}", &e.to_string())) - } - })?; - - Ok(WorktreeLock { - lock_path, - _file: Some(file), - }) - } -} - -impl Drop for WorktreeLock { - fn drop(&mut self) { - // Clean up lock file when lock is released - let _ = fs::remove_file(&self.lock_path); - } -} +pub use crate::adapters::git::worktree_lock::WorktreeLock; /// Finds the common parent directory of all worktrees /// @@ -194,7 +137,7 @@ impl GitWorktreeManager { /// let manager = GitWorktreeManager::new().expect("Failed to open repository"); /// ``` pub fn new() -> Result { - let repo = Repository::open_from_env()?; + let repo = crate::adapters::git::repo_discovery::discover_repository_from_env()?; Ok(Self { repo }) } @@ -204,7 +147,7 @@ impl GitWorktreeManager { /// that needs to create a manager from a specific repository path. #[allow(dead_code)] pub fn new_from_path(path: &Path) -> Result { - let repo = Repository::open(path)?; + let repo = crate::adapters::git::repo_discovery::open_repository_at_path_raw(path)?; Ok(Self { repo }) } From 1f895ea02aaca382a7fa7690d9eaff224fd0600c Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 12 Apr 2026 16:22:34 +0900 Subject: [PATCH 6/6] fix(cli): restore detailed help output --- src/main.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main.rs b/src/main.rs index aebea3b..9a3637e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,10 +5,18 @@ use clap::Parser; use git_workers::app; +/// Command-line arguments for Git Workers +/// +/// Currently supports minimal CLI arguments as the application is primarily +/// interactive. Future versions may add support for direct command execution. #[derive(Parser)] #[command(name = "gw")] #[command(about = "Interactive Git Worktree Manager", long_about = None)] struct Cli { + /// Print version information and exit + /// + /// When specified, prints the version number from Cargo.toml and exits + /// without entering the interactive mode. #[arg(short, long)] version: bool, } @@ -24,3 +32,20 @@ fn main() -> Result<()> { app::run() } + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn test_help_includes_version_description() { + let mut command = Cli::command(); + let help = command.render_long_help().to_string(); + + assert!(help.contains("Print version information and exit")); + assert!( + help.contains("When specified, prints the version number from Cargo.toml and exits") + ); + } +}