diff --git a/CLAUDE.md b/CLAUDE.md index a06e688..1579ee9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,10 @@ 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 @@ -52,6 +56,12 @@ 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 ``` ### Installation @@ -112,17 +122,26 @@ source /path/to/git-workers/shell/gw.sh ``` src/ ├── main.rs # CLI entry point and main menu loop -├── lib.rs # Library exports +├── lib.rs # Library exports and module re-exports ├── commands.rs # Command implementations for menu items -├── git.rs # Git worktree operations (git2 + process::Command) ├── menu.rs # MenuItem enum and icon definitions ├── config.rs # .git-workers.toml configuration management -├── hooks.rs # Hook system (post-create, pre-remove, etc.) ├── repository_info.rs # Repository information display ├── input_esc_raw.rs # Custom input handling with ESC support ├── constants.rs # Centralized constants (strings, formatting) -├── file_copy.rs # File copy functionality for gitignored files -└── utils.rs # Common utilities (error display, etc.) +├── 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 ``` ### Technology Stack @@ -193,15 +212,18 @@ Since Git lacks native rename functionality: ### Testing Considerations -- Integration tests in `tests/` directory (30 test files) +- 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 -- New test files added: - - `worktree_path_test.rs`: Tests for path resolution and edge cases - - `create_worktree_integration_test.rs`: Integration tests for worktree creation - - `create_worktree_from_tag_test.rs`: Tests for tag listing and worktree creation from tags + +### 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 @@ -411,3 +433,40 @@ 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 + +## 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/docs/coding-style.md b/docs/coding-style.md new file mode 100644 index 0000000..8369aa6 --- /dev/null +++ b/docs/coding-style.md @@ -0,0 +1,386 @@ +# Git Workers - コーディングスタイルガイド + +このドキュメントでは、Git Workers プロジェクトにおけるコードの統一感とスタイル規則を定義します。 + +## 基本原則 + +### 1. 一貫性の優先 + +- 既存のコードパターンに合わせる +- 新しいパターンを導入する際は全体に適用する +- 部分的な変更より全体的な統一感を重視 + +### 2. 可読性の重視 + +- 意図が明確なコード +- 適切な命名規則 +- 冗長性の排除 + +## フォーマット規則 + +### String フォーマット + +**インライン変数構文の使用(必須)** + +```rust +// ✅ 推奨 +format!("Created worktree '{name}' at {path}") +println!("Found {count} items") +eprintln!("Error: {error}") + +// ❌ 非推奨(古い形式) +format!("Created worktree '{}' at {}", name, path) +println!("Found {} items", count) +eprintln!("Error: {}", error) +``` + +**適用範囲** + +- `format!` +- `println!`, `eprintln!` +- `log::info!`, `log::warn!`, `log::error!` +- `panic!` +- その他すべてのフォーマット系マクロ + +**例外** +format! マクロで文字列リテラルが必要な場合(anyhow! エラーなど)は従来形式を使用: + +```rust +// anyhow! では文字列リテラルが必要 +anyhow!("Invalid path: {}", path) // OK +``` + +## 定数管理 + +### 定数の集約化 + +**constants.rs での集中管理** + +```rust +// ✅ 推奨 - constants.rs に定義 +pub const ERROR_USER_CANCELLED: &str = "User cancelled operation"; +pub const MSG_WORKTREE_CREATED: &str = "Worktree created successfully"; + +// 使用箇所 +return Err(anyhow!(ERROR_USER_CANCELLED)); +``` + +**ハードコーディングの禁止** + +```rust +// ❌ 避ける - 直接文字列 +return Err(anyhow!("User cancelled operation")); + +// ✅ 推奨 - 定数使用 +return Err(anyhow!(ERROR_USER_CANCELLED)); +``` + +### 定数の命名規則 + +**プレフィックス規則** + +- `ERROR_*`: エラーメッセージ +- `MSG_*`: 一般的なメッセージ +- `ICON_*`: アイコン文字 +- `FORMAT_*`: フォーマット文字列 +- `DEFAULT_*`: デフォルト値 + +**例** + +```rust +pub const ERROR_WORKTREE_NOT_FOUND: &str = "Worktree not found"; +pub const MSG_WORKTREE_DELETED: &str = "Worktree deleted successfully"; +pub const ICON_CURRENT_BRANCH: &str = " "; +pub const DEFAULT_BRANCH_NAME: &str = "main"; +``` + +### テスト定数 + +**テスト専用定数の管理** + +```rust +// ✅ 推奨 - cfg(test) アノテーション使用 +#[cfg(test)] +const TEST_WORKTREE_NAME: &str = "test-worktree"; +#[cfg(test)] +const TEST_BRANCH_NAME: &str = "test-branch"; + +// テスト内で使用 +#[test] +fn test_create_worktree() { + let name = TEST_WORKTREE_NAME; + // ... +} +``` + +## エラーハンドリング + +### 統一的なエラーメッセージ + +**メッセージ構造** + +```rust +// 基本パターン: "動作が失敗した理由" +ERROR_WORKTREE_CREATE_FAILED: "Failed to create worktree" +ERROR_BRANCH_NOT_FOUND: "Branch not found" +ERROR_PERMISSION_DENIED: "Permission denied" + +// 詳細パターン: "動作が失敗した理由: {詳細}" +ERROR_CONFIG_READ_FAILED: "Failed to read configuration: {}" +ERROR_WORKTREE_PATH_INVALID: "Invalid worktree path: {}" +``` + +**anyhow! エラーの統一** + +```rust +// ✅ 推奨パターン +return Err(anyhow!("Failed to create worktree: {}", error)); + +// 避けるパターン +return Err(anyhow!("Worktree creation error: {}", error)); +return Err(anyhow!("Error creating worktree: {}", error)); +``` + +## コード構造 + +### ファイル構成 + +**モジュール構成の原則** + +``` +src/ +├── commands/ # コマンド実装(機能別) +├── core/ # コアロジック +├── infrastructure/ # 外部依存(Git, ファイルシステム) +├── constants.rs # 全定数の集約 +├── ui.rs # UI 抽象化 +└── utils.rs # ユーティリティ +``` + +**import の順序** + +```rust +// 1. 標準ライブラリ +use std::path::PathBuf; +use std::collections::HashMap; + +// 2. 外部クレート(アルファベット順) +use anyhow::Result; +use colored::Colorize; + +// 3. 内部モジュール(階層順) +use crate::constants::*; +use crate::core::validation; +use crate::ui::UserInterface; +``` + +### 関数設計 + +**命名規則** + +```rust +// ✅ 動詞 + 目的語パターン +fn create_worktree() -> Result<()> +fn delete_branch() -> Result<()> +fn validate_path() -> Result<()> + +// ✅ is/has などの述語パターン +fn is_current_worktree() -> bool +fn has_uncommitted_changes() -> bool +``` + +**引数の順序** + +```rust +// 1. 主要オブジェクト +// 2. 設定・オプション +// 3. UI インターフェース +fn create_worktree( + manager: &WorktreeManager, + name: &str, + options: &CreateOptions, + ui: &dyn UserInterface +) -> Result +``` + +## テストコード + +### テスト構成 + +**テストファイル命名** + +- 単体テスト: `tests/unit/module_name.rs` +- 統合テスト: `tests/integration/feature_name.rs` +- E2E テスト: `tests/e2e/workflow_name.rs` + +**テスト関数命名** + +```rust +// パターン: test_[対象]_[条件]_[期待結果] +#[test] +fn test_create_worktree_with_valid_name_succeeds() -> Result<()> + +#[test] +fn test_delete_worktree_when_not_exists_fails() -> Result<()> +``` + +### Mock の使用 + +**UI Mock パターン** + +```rust +let ui = TestUI::new() + .with_input(TEST_WORKTREE_NAME) + .with_selection(0) + .with_confirmation(true); +``` + +## リファクタリング指針 + +### 段階的改善 + +1. **動作変更なし**のリファクタリングを先行 +2. **機能追加**は別コミット +3. **テスト追加**で安全性確保 + +### 品質チェック + +**必須チェック項目** + +```bash +# フォーマット確認 +cargo fmt --check + +# Clippy 警告ゼロ +cargo clippy --all-features -- -D warnings + +# テスト通過 +cargo test --all-features + +# 型チェック +cargo check --all-features +``` + +## コメント規則 + +### コメントの言語統一 + +**英語コメントの徹底使用(必須)** + +```rust +// ✅ 推奨 - 英語コメント +// Check if the worktree exists before deletion +fn delete_worktree(name: &str) -> Result<()> { + // Validate worktree name format + if name.is_empty() { + return Err(anyhow!("Worktree name cannot be empty")); + } + + // Execute deletion command + // ... +} + +// ❌ 非推奨 - 日本語コメント +// ワークツリーが存在するかチェック +fn delete_worktree(name: &str) -> Result<()> { + // ワークツリー名のフォーマットを検証 + // ... +} +``` + +**コメント品質基準** + +```rust +// ✅ Good - Clear and concise +// Calculate relative path from project root +let relative_path = calculate_relative_path(&base_path, &target_path)?; + +// ✅ Good - Explains complex logic +// Use fuzzy matching for branch selection to improve UX +// when dealing with large numbers of branches +let selection = fuzzy_select_branch(&branches)?; + +// ❌ Avoid - Stating the obvious +// Set variable to true +let is_valid = true; + +// ❌ Avoid - Outdated or incorrect comments +// TODO: This will be removed in v2.0 (but never removed) +``` + +**ドキュメントコメント(///)の使用** + +````rust +/// Creates a new worktree with the specified configuration +/// +/// # Arguments +/// * `manager` - The worktree manager instance +/// * `name` - Name for the new worktree +/// * `config` - Creation configuration options +/// +/// # Returns +/// * `Ok(true)` - Worktree created and switched to +/// * `Ok(false)` - Worktree created but not switched +/// * `Err(_)` - Creation failed +/// +/// # Examples +/// ``` +/// let result = create_worktree(&manager, "feature-branch", &config)?; +/// ``` +pub fn create_worktree( + manager: &WorktreeManager, + name: &str, + config: &CreateConfig +) -> Result { + // Implementation... +} +```` + +**TODO/FIXME/NOTE コメント** + +```rust +// TODO: Add support for custom hooks in v0.7.0 +// FIXME: Handle edge case when git directory is corrupted +// NOTE: This behavior matches Git's native worktree command +// HACK: Temporary workaround for upstream bug #1234 +``` + +### 既存コメントの英語化 + +**段階的移行方針** + +1. 新規コード:英語コメント必須 +2. 既存コード:修正時に英語化 +3. 重要なコメント:優先的に英語化 + +**英語化対象の優先順位** + +1. 公開 API(`pub`)のドキュメントコメント +2. 複雑なロジックの説明コメント +3. TODO/FIXME コメント +4. テストコメント +5. 一般的なインラインコメント + +## 今後の拡張 + +### 新機能追加時の指針 + +1. **constants.rs** への文字列集約 +2. **インライン変数構文** の徹底使用 +3. **統一的なエラーメッセージ** フォーマット +4. **適切なテスト** カバレッジ +5. **英語コメント** の使用 + +### 既存コードの改善 + +定期的に以下を実行: + +- format! マクロの統一性チェック +- ハードコード文字列の constants.rs 移行 +- エラーメッセージの統一性確認 +- テスト定数の整理 +- **日本語コメントの英語化** + +--- + +このガイドラインに従うことで、Git Workers プロジェクトの品質と保守性を継続的に向上させることができます。 diff --git a/src/commands/create.rs b/src/commands/create.rs index d4338ee..f62a599 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -11,9 +11,13 @@ use crate::constants::{ DEFAULT_MENU_SELECTION, DEFAULT_REPO_NAME, 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, TAG_MESSAGE_TRUNCATE_LENGTH, WORKTREES_SUBDIR, + PROMPT_WORKTREE_NAME, REPO_NAME_FALLBACK, SLASH_CHAR, STRING_CUSTOM, STRING_SAME_LEVEL, + STRING_SUBDIRECTORY, TAG_MESSAGE_TRUNCATE_LENGTH, WORKTREES_SUBDIR, WORKTREE_LOCATION_CUSTOM_PATH, WORKTREE_LOCATION_SAME_LEVEL, WORKTREE_LOCATION_SUBDIRECTORY, }; use crate::file_copy; @@ -49,7 +53,7 @@ pub enum BranchSource { /// Validate worktree location type pub fn validate_worktree_location(location: &str) -> Result<()> { match location { - "same-level" | "subdirectory" | "custom" => Ok(()), + STRING_SAME_LEVEL | STRING_SUBDIRECTORY | STRING_CUSTOM => Ok(()), _ => Err(anyhow!("Invalid worktree location type: {}", location)), } } @@ -64,30 +68,30 @@ pub fn determine_worktree_path( validate_worktree_location(location)?; match location { - "same-level" => { + STRING_SAME_LEVEL => { let path = git_dir .parent() .ok_or_else(|| anyhow!("Cannot determine parent directory"))? .join(name); - Ok((path, "same-level".to_string())) + Ok((path, STRING_SAME_LEVEL.to_string())) } - "subdirectory" => { + STRING_SUBDIRECTORY => { let repo_name = git_dir .file_name() .and_then(|n| n.to_str()) - .unwrap_or("repo"); + .unwrap_or(REPO_NAME_FALLBACK); let path = git_dir .parent() .ok_or_else(|| anyhow!("Cannot determine parent directory"))? .join(repo_name) .join(WORKTREES_SUBDIR) .join(name); - Ok((path, "subdirectory".to_string())) + Ok((path, STRING_SUBDIRECTORY.to_string())) } - "custom" => { + STRING_CUSTOM => { let path = custom_path .ok_or_else(|| anyhow!("Custom path required when location is 'custom'"))?; - Ok((git_dir.join(path), "custom".to_string())) + Ok((git_dir.join(path), STRING_CUSTOM.to_string())) } _ => Err(anyhow!("Invalid location type: {}", location)), } @@ -214,7 +218,7 @@ pub fn create_worktree_with_ui( // If this is the first worktree, let user choose the pattern let final_name = if !has_worktrees { println!(); - let msg = "First worktree - choose location:".bright_cyan(); + let msg = MSG_FIRST_WORKTREE_CHOOSE.bright_cyan(); println!("{msg}"); // Get repository name for display @@ -226,9 +230,12 @@ pub fn create_worktree_with_ui( .unwrap_or(DEFAULT_REPO_NAME); let options = vec![ - format!("Same level as repository (../{name})"), - format!("In subdirectory ({repo_name}/{WORKTREES_SUBDIR}/{name})"), - "Custom path (specify relative to project root)".to_string(), + format!("Same level as repository (../{})", name), + format!( + "In subdirectory ({}/{}/{})", + repo_name, WORKTREES_SUBDIR, name + ), + OPTION_CUSTOM_PATH_FULL.to_string(), ]; let selection = match ui.select_with_default( @@ -246,11 +253,32 @@ pub fn create_worktree_with_ui( WORKTREE_LOCATION_CUSTOM_PATH => { // Custom path input println!(); - let msg = "Enter custom path (relative to project root):".bright_cyan(); + let msg = MSG_SPECIFY_DIRECTORY_PATH.bright_cyan(); println!("{msg}"); - let examples = - "Examples: ../custom-dir/worktree-name, temp/worktrees/name".dimmed(); - println!("{examples}"); + + // 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(), @@ -262,13 +290,25 @@ pub fn create_worktree_with_ui( 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(&custom_path) { + if let Err(e) = validate_custom_path(&final_path) { utils::print_error(&format!("Invalid custom path: {e}")); return Ok(false); } - custom_path + final_path } _ => format!("{WORKTREES_SUBDIR}/{name}"), // Default fallback } @@ -279,9 +319,9 @@ pub fn create_worktree_with_ui( // Branch handling println!(); let branch_options = vec![ - "Create from current HEAD".to_string(), - "Select branch".to_string(), - "Select tag".to_string(), + 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( diff --git a/src/constants.rs b/src/constants.rs index 8eed4d4..882f0ba 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -203,7 +203,7 @@ pub const PROMPT_SELECT_BRANCH: &str = "Select a branch"; pub const PROMPT_SELECT_TAG: &str = "Select a tag"; pub const PROMPT_DAYS_TO_KEEP: &str = "Days to keep"; pub const PROMPT_SWITCH_WORKTREE: &str = "Switch to the new worktree?"; -pub const PROMPT_CUSTOM_PATH: &str = "Custom path (relative to repository root)"; +pub const PROMPT_CUSTOM_PATH: &str = "Directory path"; pub const PROMPT_NEW_BRANCH_NAME: &str = "New branch name"; pub const PROMPT_BASE_BRANCH: &str = "Base branch for new branch"; pub const PROMPT_FIRST_WORKTREE: &str = "First worktree - choose location:"; @@ -661,24 +661,134 @@ pub const EXT_JSON: &str = ".json"; pub const EXT_YAML: &str = ".yaml"; pub const EXT_YML: &str = ".yml"; +// Test constants +pub const TEST_TITLE: &str = "Test Title"; +pub const TEST_EQUALS_SIGN: &str = "="; +pub const TEST_PERCENT_SIGN: &str = "%"; +pub const TEST_GIT_HEAD: &str = "HEAD"; +pub const TEST_GIT_REFS: &str = "refs"; + +// Hardcoded string values from create.rs +pub const STRING_SAME_LEVEL: &str = "same-level"; +pub const STRING_SUBDIRECTORY: &str = "subdirectory"; +pub const STRING_CUSTOM: &str = "custom"; +pub const ERROR_INVALID_WORKTREE_LOCATION: &str = "Invalid worktree location type: {}"; +pub const ERROR_CUSTOM_PATH_REQUIRED: &str = "Custom path required when location is 'custom'"; +pub const ERROR_INVALID_LOCATION_TYPE: &str = "Invalid location type: {}"; +pub const ERROR_CANNOT_DETERMINE_PARENT_DIR: &str = "Cannot determine parent directory"; + +// Git command format strings +pub const FORMAT_REFS_TAGS: &str = "refs/tags/{}"; +pub const FORMAT_WORKTREE_PATH_SAME_LEVEL: &str = "../{}"; +pub const FORMAT_WORKTREE_PATH_SUBDIRECTORY: &str = "{}/{}"; +pub const FORMAT_CUSTOM_PATH_WITH_NAME: &str = "{}/{}"; +pub const FORMAT_DOT_WITH_NAME: &str = "./{}"; + +// UI text strings from create.rs +pub const MSG_FIRST_WORKTREE_CHOOSE: &str = "First worktree - choose location:"; +pub const MSG_SPECIFY_DIRECTORY_PATH: &str = "Specify directory path (relative to project root):"; +pub const MSG_EXAMPLES_WORKTREE_NAME: &str = "Examples (worktree name: '{}'):"; +pub const MSG_EXAMPLE_BRANCH: &str = "branch/"; +pub const MSG_EXAMPLE_HOTFIX: &str = "hotfix/"; +pub const MSG_EXAMPLE_PARENT: &str = "../"; +pub const MSG_EXAMPLE_DOT: &str = "./"; +pub const MSG_CREATES_AT_BRANCH: &str = "→ creates at ./branch/{}"; +pub const MSG_CREATES_AT_HOTFIX: &str = "→ creates at ./hotfix/{}"; +pub const MSG_CREATES_AT_PARENT: &str = "→ creates at ../{} (outside project)"; +pub const MSG_CREATES_AT_DOT: &str = "→ creates at ./{} (project root)"; + +// Branch options text +pub const OPTION_CREATE_FROM_HEAD_FULL: &str = "Create from current HEAD"; +pub const OPTION_SELECT_BRANCH_FULL: &str = "Select branch"; +pub const OPTION_SELECT_TAG_FULL: &str = "Select tag"; + +// Worktree location options text +pub const FORMAT_SAME_LEVEL_OPTION: &str = "Same level as repository (../{})"; +pub const FORMAT_SUBDIRECTORY_OPTION: &str = "In subdirectory ({}/{}/{})"; +pub const OPTION_CUSTOM_PATH_FULL: &str = "Custom path (specify relative to project root)"; + +// Branch list formatting +pub const FORMAT_BRANCH_WITH_WORKTREE: &str = "{}{}"; +pub const FORMAT_BRANCH_WITHOUT_WORKTREE: &str = "{}{}"; +pub const MSG_BRANCH_IN_USE_BY: &str = " (in use by '{}')"; + +// Conflict action messages +pub const MSG_CREATE_NEW_BRANCH_FROM: &str = "Create new branch '{}' from '{}'"; +pub const MSG_CHANGE_BRANCH_NAME: &str = "Change the branch name"; +pub const MSG_CANCEL: &str = "Cancel"; +pub const MSG_USE_EXISTING_LOCAL: &str = "Use the existing local branch instead"; +pub const MSG_USE_EXISTING_LOCAL_IN_USE: &str = + "Use the existing local branch instead (in use by '{}')"; +pub const MSG_ENTER_NEW_BRANCH_NAME: &str = "Enter new branch name (base: {})"; +pub const MSG_BRANCH_ALREADY_EXISTS: &str = "Branch '{}' already exists"; +pub const MSG_BRANCH_NAME_CANNOT_BE_EMPTY: &str = "Branch name cannot be empty"; +pub const MSG_BRANCH_ALREADY_CHECKED_OUT: &str = + "Branch '{}' is already checked out in worktree '{}'"; +pub const MSG_LOCAL_BRANCH_EXISTS: &str = "A local branch '{}' already exists for remote '{}'"; +pub const MSG_CREATE_NEW_BRANCH_FROM_REMOTE: &str = "Create new branch '{}' from '{}{}' "; +pub const MSG_PLEASE_SELECT_DIFFERENT: &str = "Please select a different option."; + +// Tag formatting +pub const FORMAT_TAG_WITH_MESSAGE: &str = "{}{} - {}"; +pub const FORMAT_TAG_WITHOUT_MESSAGE: &str = "{}{}"; +pub const MSG_SEARCH_TAGS_FUZZY: &str = "Type to search tags (fuzzy search enabled):"; +pub const MSG_SEARCH_BRANCHES_FUZZY: &str = "Type to search branches (fuzzy search enabled):"; + +// Preview labels and messages +pub const LABEL_PREVIEW_HEADER: &str = "Preview:"; +pub const LABEL_NAME_PREVIEW: &str = "Name:"; +pub const LABEL_NEW_BRANCH_PREVIEW: &str = "New Branch:"; +pub const LABEL_BRANCH_PREVIEW: &str = "Branch:"; +pub const LABEL_FROM_PREVIEW: &str = "From:"; +pub const MSG_FROM_CURRENT_HEAD: &str = "Current HEAD"; +pub const MSG_FROM_TAG_PREFIX: &str = "tag: "; + +// Progress messages +pub const MSG_CREATING_WORKTREE: &str = "Creating worktree..."; + +// Success messages +pub const FORMAT_WORKTREE_CREATED: &str = "Created worktree '{}' at {}"; +pub const MSG_COPYING_CONFIGURED_FILES: &str = "Copying configured files..."; +pub const FORMAT_COPIED_FILES_COUNT: &str = "Copied {} files"; +pub const MSG_COPIED_FILE_PREFIX: &str = " ✓ "; + +// Switch messages +pub const MSG_SWITCH_TO_NEW_WORKTREE: &str = "Switch to the new worktree?"; +pub const MSG_SWITCHING_TO_WORKTREE: &str = "+ Switching to worktree '{}'"; + +// Error messages +pub const FORMAT_FAILED_CREATE_WORKTREE: &str = "Failed to create worktree: {}"; +pub const FORMAT_FAILED_COPY_FILES: &str = "Failed to copy files: {}"; +pub const FORMAT_HOOK_EXECUTION_WARNING: &str = "Hook execution warning: {}"; +pub const FORMAT_INVALID_WORKTREE_NAME: &str = "Invalid worktree name: {}"; +pub const FORMAT_INVALID_CUSTOM_PATH: &str = "Invalid custom path: {}"; + +// Default values +pub const REPO_NAME_FALLBACK: &str = "repo"; +pub const SWITCH_CONFIRM_DEFAULT: bool = true; + +// Path manipulation +pub const SLASH_CHAR: char = '/'; +pub const ELLIPSIS: &str = "..."; + #[cfg(test)] mod tests { use super::*; #[test] fn test_section_header() { - let header = section_header("Test Title"); - assert!(header.contains("Test Title")); - assert!(header.contains("=")); + let header = section_header(TEST_TITLE); + assert!(header.contains(TEST_TITLE)); + assert!(header.contains(TEST_EQUALS_SIGN)); } #[test] fn test_header_separator() { let separator = header_separator(); // The separator should contain 50 equals signs - assert!(separator.contains(&"=".repeat(HEADER_SEPARATOR_WIDTH))); + assert!(separator.contains(&TEST_EQUALS_SIGN.repeat(HEADER_SEPARATOR_WIDTH))); // The actual length may vary due to ANSI color codes - assert!(separator.contains("=")); + assert!(separator.contains(TEST_EQUALS_SIGN)); } #[test] @@ -748,7 +858,7 @@ mod tests { fn test_time_format() { // Test that time format is valid assert!(!TIME_FORMAT.is_empty()); - assert!(TIME_FORMAT.contains("%")); + assert!(TIME_FORMAT.contains(TEST_PERCENT_SIGN)); } #[test] @@ -756,8 +866,8 @@ mod tests { fn test_git_reserved_names() { // Test that git reserved names array is not empty assert!(!GIT_RESERVED_NAMES.is_empty()); - assert!(GIT_RESERVED_NAMES.contains(&"HEAD")); - assert!(GIT_RESERVED_NAMES.contains(&"refs")); + assert!(GIT_RESERVED_NAMES.contains(&TEST_GIT_HEAD)); + assert!(GIT_RESERVED_NAMES.contains(&TEST_GIT_REFS)); } #[test] diff --git a/src/ui.rs b/src/ui.rs index 7052d6e..5e60c5b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -4,11 +4,41 @@ //! allowing for testable code by separating business logic from UI dependencies. use anyhow::Result; -use dialoguer::{Confirm, FuzzySelect, Input, MultiSelect, Select}; +use dialoguer::{Confirm, FuzzySelect, MultiSelect, Select}; use std::collections::VecDeque; +use crate::input_esc_raw::{input_esc_raw, input_esc_with_default_raw}; use crate::utils::get_theme; +// Error messages +const ERROR_USER_CANCELLED_SELECTION: &str = "User cancelled selection"; +const ERROR_USER_CANCELLED_FUZZY_SELECTION: &str = "User cancelled fuzzy selection"; +const ERROR_USER_CANCELLED_INPUT: &str = "User cancelled input"; +const ERROR_USER_CANCELLED_CONFIRMATION: &str = "User cancelled confirmation"; +const ERROR_USER_CANCELLED_MULTISELECTION: &str = "User cancelled multiselection"; +const ERROR_NO_MORE_SELECTIONS: &str = "No more selections configured for MockUI"; +const ERROR_NO_MORE_INPUTS: &str = "No more inputs configured for MockUI"; +const ERROR_NO_MORE_CONFIRMATIONS: &str = "No more confirmations configured for MockUI"; +const ERROR_NO_MORE_MULTISELECTS: &str = "No more multiselects configured for MockUI"; + +// Test constants +#[cfg(test)] +const TEST_PROMPT: &str = "test"; +#[cfg(test)] +const TEST_OPTION_A: &str = "a"; +#[cfg(test)] +const TEST_OPTION_B: &str = "b"; +#[cfg(test)] +const TEST_INPUT_BRANCH: &str = "test-branch"; +#[cfg(test)] +const TEST_INPUT_FEATURE: &str = "feature-branch"; +#[cfg(test)] +const TEST_INPUT_CUSTOM: &str = "custom-input"; +#[cfg(test)] +const TEST_INPUT_DEFAULT: &str = "default"; +#[cfg(test)] +const TEST_INPUT_FALLBACK: &str = "fallback"; + /// Trait for user interface interactions /// /// This trait abstracts all user input operations, making the code testable @@ -51,7 +81,7 @@ impl UserInterface for DialoguerUI { .with_prompt(prompt) .items(items) .interact_opt()?; - selection.ok_or_else(|| anyhow::anyhow!("User cancelled selection")) + selection.ok_or_else(|| anyhow::anyhow!(ERROR_USER_CANCELLED_SELECTION)) } fn select_with_default(&self, prompt: &str, items: &[String], default: usize) -> Result { @@ -60,7 +90,7 @@ impl UserInterface for DialoguerUI { .items(items) .default(default) .interact_opt()?; - selection.ok_or_else(|| anyhow::anyhow!("User cancelled selection")) + selection.ok_or_else(|| anyhow::anyhow!(ERROR_USER_CANCELLED_SELECTION)) } fn fuzzy_select(&self, prompt: &str, items: &[String]) -> Result { @@ -68,29 +98,23 @@ impl UserInterface for DialoguerUI { .with_prompt(prompt) .items(items) .interact_opt()?; - selection.ok_or_else(|| anyhow::anyhow!("User cancelled fuzzy selection")) + selection.ok_or_else(|| anyhow::anyhow!(ERROR_USER_CANCELLED_FUZZY_SELECTION)) } fn input(&self, prompt: &str) -> Result { - let input = Input::::with_theme(&get_theme()) - .with_prompt(prompt) - .interact_text()?; - Ok(input) + input_esc_raw(prompt).ok_or_else(|| anyhow::anyhow!(ERROR_USER_CANCELLED_INPUT)) } fn input_with_default(&self, prompt: &str, default: &str) -> Result { - let input = Input::::with_theme(&get_theme()) - .with_prompt(prompt) - .default(default.to_string()) - .interact_text()?; - Ok(input) + input_esc_with_default_raw(prompt, default) + .ok_or_else(|| anyhow::anyhow!(ERROR_USER_CANCELLED_INPUT)) } fn confirm(&self, prompt: &str) -> Result { let confirmed = Confirm::with_theme(&get_theme()) .with_prompt(prompt) .interact_opt()?; - confirmed.ok_or_else(|| anyhow::anyhow!("User cancelled confirmation")) + confirmed.ok_or_else(|| anyhow::anyhow!(ERROR_USER_CANCELLED_CONFIRMATION)) } fn confirm_with_default(&self, prompt: &str, default: bool) -> Result { @@ -98,7 +122,7 @@ impl UserInterface for DialoguerUI { .with_prompt(prompt) .default(default) .interact_opt()?; - confirmed.ok_or_else(|| anyhow::anyhow!("User cancelled confirmation")) + confirmed.ok_or_else(|| anyhow::anyhow!(ERROR_USER_CANCELLED_CONFIRMATION)) } fn multiselect(&self, prompt: &str, items: &[String]) -> Result> { @@ -106,7 +130,7 @@ impl UserInterface for DialoguerUI { .with_prompt(prompt) .items(items) .interact_opt()?; - selections.ok_or_else(|| anyhow::anyhow!("User cancelled multiselection")) + selections.ok_or_else(|| anyhow::anyhow!(ERROR_USER_CANCELLED_MULTISELECTION)) } } @@ -181,7 +205,7 @@ impl UserInterface for MockUI { self.selections .borrow_mut() .pop_front() - .ok_or_else(|| anyhow::anyhow!("No more selections configured for MockUI")) + .ok_or_else(|| anyhow::anyhow!(ERROR_NO_MORE_SELECTIONS)) } fn select_with_default( @@ -194,7 +218,7 @@ impl UserInterface for MockUI { self.selections .borrow_mut() .pop_front() - .ok_or_else(|| anyhow::anyhow!("No more selections configured for MockUI")) + .ok_or_else(|| anyhow::anyhow!(ERROR_NO_MORE_SELECTIONS)) } fn fuzzy_select(&self, _prompt: &str, _items: &[String]) -> Result { @@ -202,14 +226,14 @@ impl UserInterface for MockUI { self.selections .borrow_mut() .pop_front() - .ok_or_else(|| anyhow::anyhow!("No more selections configured for MockUI")) + .ok_or_else(|| anyhow::anyhow!(ERROR_NO_MORE_SELECTIONS)) } fn input(&self, _prompt: &str) -> Result { self.inputs .borrow_mut() .pop_front() - .ok_or_else(|| anyhow::anyhow!("No more inputs configured for MockUI")) + .ok_or_else(|| anyhow::anyhow!(ERROR_NO_MORE_INPUTS)) } fn input_with_default(&self, _prompt: &str, default: &str) -> Result { @@ -225,7 +249,7 @@ impl UserInterface for MockUI { self.confirms .borrow_mut() .pop_front() - .ok_or_else(|| anyhow::anyhow!("No more confirmations configured for MockUI")) + .ok_or_else(|| anyhow::anyhow!(ERROR_NO_MORE_CONFIRMATIONS)) } fn confirm_with_default(&self, _prompt: &str, default: bool) -> Result { @@ -241,7 +265,7 @@ impl UserInterface for MockUI { self.multiselects .borrow_mut() .pop_front() - .ok_or_else(|| anyhow::anyhow!("No more multiselects configured for MockUI")) + .ok_or_else(|| anyhow::anyhow!(ERROR_NO_MORE_MULTISELECTS)) } } @@ -253,7 +277,7 @@ mod tests { fn test_mock_ui_creation() { let mock_ui = MockUI::new() .with_selection(1) - .with_input("test-branch") + .with_input(TEST_INPUT_BRANCH) .with_confirm(true) .with_multiselect(vec![0, 2]); @@ -285,24 +309,33 @@ mod tests { let mock_ui = MockUI::new() .with_selection(2) .with_selection(3) // For fuzzy_select - .with_input("feature-branch") + .with_input(TEST_INPUT_FEATURE) .with_confirm(false) .with_confirm(true) // For confirm_with_default fallback .with_multiselect(vec![1, 3]); // Test that the methods return configured values assert_eq!( - mock_ui.select("test", &["a".to_string(), "b".to_string()])?, + mock_ui.select( + TEST_PROMPT, + &[TEST_OPTION_A.to_string(), TEST_OPTION_B.to_string()] + )?, 2 ); assert_eq!( - mock_ui.fuzzy_select("test", &["a".to_string(), "b".to_string()])?, + mock_ui.fuzzy_select( + TEST_PROMPT, + &[TEST_OPTION_A.to_string(), TEST_OPTION_B.to_string()] + )?, 3 ); - assert_eq!(mock_ui.input("test")?, "feature-branch"); - assert!(!mock_ui.confirm("test")?); - assert!(mock_ui.confirm_with_default("test", false)?); - assert_eq!(mock_ui.multiselect("test", &["a".to_string()])?, vec![1, 3]); + assert_eq!(mock_ui.input(TEST_PROMPT)?, TEST_INPUT_FEATURE); + assert!(!mock_ui.confirm(TEST_PROMPT)?); + assert!(mock_ui.confirm_with_default(TEST_PROMPT, false)?); + assert_eq!( + mock_ui.multiselect(TEST_PROMPT, &[TEST_OPTION_A.to_string()])?, + vec![1, 3] + ); // Now the mock should be exhausted assert!(mock_ui.is_exhausted()); @@ -312,16 +345,19 @@ mod tests { #[test] fn test_mock_ui_input_with_default() -> Result<()> { - let mock_ui = MockUI::new().with_input("custom-input"); + let mock_ui = MockUI::new().with_input(TEST_INPUT_CUSTOM); // Should return configured input assert_eq!( - mock_ui.input_with_default("test", "default")?, - "custom-input" + mock_ui.input_with_default(TEST_PROMPT, TEST_INPUT_DEFAULT)?, + TEST_INPUT_CUSTOM ); // Should now fall back to default since no more inputs configured - assert_eq!(mock_ui.input_with_default("test", "fallback")?, "fallback"); + assert_eq!( + mock_ui.input_with_default(TEST_PROMPT, TEST_INPUT_FALLBACK)?, + TEST_INPUT_FALLBACK + ); Ok(()) } @@ -331,10 +367,10 @@ mod tests { let mock_ui = MockUI::new().with_confirm(false); // Should return configured confirmation - assert!(!mock_ui.confirm_with_default("test", true)?); + assert!(!mock_ui.confirm_with_default(TEST_PROMPT, true)?); // Should now fall back to default since no more confirmations configured - assert!(mock_ui.confirm_with_default("test", true)?); + assert!(mock_ui.confirm_with_default(TEST_PROMPT, true)?); Ok(()) } @@ -344,10 +380,16 @@ mod tests { let mock_ui = MockUI::new(); // Should error when no responses are configured - assert!(mock_ui.select("test", &["a".to_string()]).is_err()); - assert!(mock_ui.fuzzy_select("test", &["a".to_string()]).is_err()); - assert!(mock_ui.input("test").is_err()); - assert!(mock_ui.confirm("test").is_err()); - assert!(mock_ui.multiselect("test", &["a".to_string()]).is_err()); + assert!(mock_ui + .select(TEST_PROMPT, &[TEST_OPTION_A.to_string()]) + .is_err()); + assert!(mock_ui + .fuzzy_select(TEST_PROMPT, &[TEST_OPTION_A.to_string()]) + .is_err()); + assert!(mock_ui.input(TEST_PROMPT).is_err()); + assert!(mock_ui.confirm(TEST_PROMPT).is_err()); + assert!(mock_ui + .multiselect(TEST_PROMPT, &[TEST_OPTION_A.to_string()]) + .is_err()); } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..42518e1 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,7 @@ +//! Common test utilities + +mod test_repo; +mod test_ui; + +pub use test_repo::TestRepo; +pub use test_ui::TestUI; diff --git a/tests/common/test_repo.rs b/tests/common/test_repo.rs new file mode 100644 index 0000000..c068054 --- /dev/null +++ b/tests/common/test_repo.rs @@ -0,0 +1,78 @@ +//! Test repository setup utilities + +use anyhow::Result; +use git2::{Repository, Signature}; +use git_workers::git::GitWorktreeManager; +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +/// A test repository with helper methods +pub struct TestRepo { + path: PathBuf, + repo: Repository, +} + +impl TestRepo { + /// Create a new test repository + pub fn new(temp_dir: &TempDir) -> Result { + let path = temp_dir.path().join("test-repo"); + fs::create_dir_all(&path)?; + + let repo = Repository::init(&path)?; + + // Create initial commit + let sig = Signature::now("Test User", "test@example.com")?; + let tree_id = { + let mut index = repo.index()?; + index.write()?; + index.write_tree()? + }; + + let tree = repo.find_tree(tree_id)?; + repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])?; + + // Drop tree before moving repo + drop(tree); + + Ok(Self { path, repo }) + } + + /// Get the repository path + pub fn path(&self) -> &Path { + &self.path + } + + /// Create a GitWorktreeManager for this test repository + pub fn manager(&self) -> Result { + GitWorktreeManager::new_from_path(&self.path) + } + + /// Create a new branch + pub fn create_branch(&self, name: &str) -> Result<()> { + let head = self.repo.head()?.target().unwrap(); + let commit = self.repo.find_commit(head)?; + self.repo.branch(name, &commit, false)?; + Ok(()) + } + + /// Create a new commit + #[allow(dead_code)] + pub fn create_commit(&self, message: &str) -> Result<()> { + let sig = Signature::now("Test User", "test@example.com")?; + let tree_id = { + let mut index = self.repo.index()?; + index.write()?; + index.write_tree()? + }; + + let tree = self.repo.find_tree(tree_id)?; + let parent = self.repo.head()?.target().unwrap(); + let parent_commit = self.repo.find_commit(parent)?; + + self.repo + .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent_commit])?; + + Ok(()) + } +} diff --git a/tests/common/test_ui.rs b/tests/common/test_ui.rs new file mode 100644 index 0000000..6a72358 --- /dev/null +++ b/tests/common/test_ui.rs @@ -0,0 +1,148 @@ +//! Test implementation of UserInterface for testing + +use anyhow::Result; +use git_workers::ui::UserInterface; +use std::sync::{Arc, Mutex}; + +/// Test implementation of UserInterface that provides pre-programmed responses +#[derive(Clone)] +pub struct TestUI { + inputs: Arc>>, + selections: Arc>>, + confirmations: Arc>>, + expect_error: Arc>, +} + +impl TestUI { + /// Create a new TestUI with no pre-programmed responses + pub fn new() -> Self { + Self { + inputs: Arc::new(Mutex::new(Vec::new())), + selections: Arc::new(Mutex::new(Vec::new())), + confirmations: Arc::new(Mutex::new(Vec::new())), + expect_error: Arc::new(Mutex::new(false)), + } + } + + /// Add an input response + pub fn with_input(self, input: &str) -> Self { + self.inputs.lock().unwrap().push(input.to_string()); + self + } + + /// Add a selection response + pub fn with_selection(self, selection: usize) -> Self { + self.selections.lock().unwrap().push(selection); + self + } + + /// Add a confirmation response + pub fn with_confirmation(self, confirm: bool) -> Self { + self.confirmations.lock().unwrap().push(confirm); + self + } + + /// Indicate that the next operation should simulate an error/cancellation + pub fn with_error(self) -> Self { + *self.expect_error.lock().unwrap() = true; + self + } +} + +impl UserInterface for TestUI { + fn multiselect(&self, _prompt: &str, items: &[String]) -> Result> { + // For tests, we don't support multiselect - just return empty selection + let _ = items; + Ok(vec![]) + } + fn input(&self, _prompt: &str) -> Result { + let mut inputs = self.inputs.lock().unwrap(); + if *self.expect_error.lock().unwrap() { + *self.expect_error.lock().unwrap() = false; + return Err(anyhow::anyhow!("User cancelled input")); + } + if inputs.is_empty() { + return Err(anyhow::anyhow!("No more test inputs")); + } + Ok(inputs.remove(0)) + } + + fn input_with_default(&self, _prompt: &str, default: &str) -> Result { + let mut inputs = self.inputs.lock().unwrap(); + if *self.expect_error.lock().unwrap() { + *self.expect_error.lock().unwrap() = false; + return Err(anyhow::anyhow!("User cancelled input")); + } + if inputs.is_empty() { + Ok(default.to_string()) + } else { + Ok(inputs.remove(0)) + } + } + + fn select(&self, _prompt: &str, _items: &[String]) -> Result { + let mut selections = self.selections.lock().unwrap(); + if *self.expect_error.lock().unwrap() { + *self.expect_error.lock().unwrap() = false; + return Err(anyhow::anyhow!("User cancelled selection")); + } + if selections.is_empty() { + return Err(anyhow::anyhow!("No more test selections")); + } + Ok(selections.remove(0)) + } + + fn select_with_default( + &self, + _prompt: &str, + _items: &[String], + default: usize, + ) -> Result { + let mut selections = self.selections.lock().unwrap(); + if *self.expect_error.lock().unwrap() { + *self.expect_error.lock().unwrap() = false; + return Err(anyhow::anyhow!("User cancelled selection")); + } + if selections.is_empty() { + Ok(default) + } else { + Ok(selections.remove(0)) + } + } + + fn fuzzy_select(&self, _prompt: &str, items: &[String]) -> Result { + // For tests, fuzzy select behaves like regular select + self.select(_prompt, items) + } + + fn confirm(&self, _prompt: &str) -> Result { + let mut confirmations = self.confirmations.lock().unwrap(); + if *self.expect_error.lock().unwrap() { + *self.expect_error.lock().unwrap() = false; + return Err(anyhow::anyhow!("User cancelled confirmation")); + } + if confirmations.is_empty() { + return Err(anyhow::anyhow!("No more test confirmations")); + } + Ok(confirmations.remove(0)) + } + + fn confirm_with_default(&self, _prompt: &str, default: bool) -> Result { + let mut confirmations = self.confirmations.lock().unwrap(); + if *self.expect_error.lock().unwrap() { + *self.expect_error.lock().unwrap() = false; + return Err(anyhow::anyhow!("User cancelled confirmation")); + } + if confirmations.is_empty() { + Ok(default) + } else { + Ok(confirmations.remove(0)) + } + } +} + +impl Default for TestUI { + fn default() -> Self { + Self::new() + } +} diff --git a/tests/custom_path_behavior_test.rs b/tests/custom_path_behavior_test.rs new file mode 100644 index 0000000..bd70c2e --- /dev/null +++ b/tests/custom_path_behavior_test.rs @@ -0,0 +1,440 @@ +//! Tests for custom path behavior in worktree creation +//! +//! This test suite ensures that the custom path feature behaves correctly: +//! - Always treats input as directory path and appends worktree name +//! - Handles special cases like "./" and "../" correctly +//! - Validates paths for security and compatibility + +use anyhow::Result; +use git_workers::commands::create_worktree_with_ui; +use tempfile::TempDir; + +mod common; +use common::{TestRepo, TestUI}; + +/// Test that custom path always appends worktree name +#[test] +fn test_custom_path_appends_worktree_name() -> Result<()> { + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + // Test case 1: "branch/" -> "branch/feature-x" + let ui = TestUI::new() + .with_input("feature-x") // worktree name + .with_selection(2) // custom path option + .with_input("branch/") // directory path + .with_selection(0) // create from HEAD + .with_confirmation(false); // don't switch + + let result = create_worktree_with_ui(&manager, &ui)?; + assert!(!result); // didn't switch + + // Verify worktree was created at correct location + let worktrees = manager.list_worktrees()?; + assert!(worktrees.iter().any(|w| w.name == "feature-x")); + assert!(worktrees + .iter() + .any(|w| w.path.ends_with("branch/feature-x"))); + + Ok(()) +} + +/// Test that "./" creates worktree in project root +#[test] +fn test_dot_slash_creates_in_project_root() -> Result<()> { + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + let ui = TestUI::new() + .with_input("my-feature") // worktree name + .with_selection(2) // custom path option + .with_input("./") // current directory + .with_selection(0) // create from HEAD + .with_confirmation(false); // don't switch + + let result = create_worktree_with_ui(&manager, &ui)?; + assert!(!result); + + // Verify worktree was created at ./my-feature + let worktrees = manager.list_worktrees()?; + assert!(worktrees.iter().any(|w| w.name == "my-feature")); + + // The path should be in the project root (contains repo name and worktree name) + let worktree = worktrees.iter().find(|w| w.name == "my-feature").unwrap(); + let path_str = worktree.path.to_string_lossy(); + assert!(path_str.contains("test-repo/my-feature") || path_str.ends_with("/my-feature")); + + Ok(()) +} + +/// Test that "../" creates worktree outside project +#[test] +fn test_parent_directory_creates_outside_project() -> Result<()> { + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + let ui = TestUI::new() + .with_input("external-feature") // worktree name + .with_selection(2) // custom path option + .with_input("../") // parent directory + .with_selection(0) // create from HEAD + .with_confirmation(false); // don't switch + + let result = create_worktree_with_ui(&manager, &ui)?; + assert!(!result); + + // Verify worktree was created at ../external-feature + let worktrees = manager.list_worktrees()?; + assert!(worktrees.iter().any(|w| w.name == "external-feature")); + + let worktree = worktrees + .iter() + .find(|w| w.name == "external-feature") + .unwrap(); + let repo_parent = test_repo.path().parent().unwrap(); + let expected_path = repo_parent.join("external-feature"); + assert_eq!(worktree.path.canonicalize()?, expected_path.canonicalize()?); + + Ok(()) +} + +/// Test nested directory paths +#[test] +fn test_nested_directory_paths() -> Result<()> { + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + // Test case: "features/ui/" -> "features/ui/button" + let ui = TestUI::new() + .with_input("button") // worktree name + .with_selection(2) // custom path option + .with_input("features/ui/") // nested directory + .with_selection(0) // create from HEAD + .with_confirmation(false); // don't switch + + let result = create_worktree_with_ui(&manager, &ui)?; + assert!(!result); + + let worktrees = manager.list_worktrees()?; + assert!(worktrees.iter().any(|w| w.name == "button")); + assert!(worktrees + .iter() + .any(|w| w.path.ends_with("features/ui/button"))); + + Ok(()) +} + +/// Test that paths without trailing slash still work as directories +#[test] +fn test_path_without_trailing_slash_treated_as_directory() -> Result<()> { + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + // "hotfix" (no slash) should behave like "hotfix/" + let ui = TestUI::new() + .with_input("urgent-fix") // worktree name + .with_selection(2) // custom path option + .with_input("hotfix") // no trailing slash + .with_selection(0) // create from HEAD + .with_confirmation(false); // don't switch + + let result = create_worktree_with_ui(&manager, &ui)?; + assert!(!result); + + let worktrees = manager.list_worktrees()?; + assert!(worktrees.iter().any(|w| w.name == "urgent-fix")); + assert!(worktrees + .iter() + .any(|w| w.path.ends_with("hotfix/urgent-fix"))); + + Ok(()) +} + +/// Test empty input defaults to worktree name only +#[test] +fn test_empty_path_uses_worktree_name_only() -> Result<()> { + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + let ui = TestUI::new() + .with_input("simple") // worktree name + .with_selection(2) // custom path option + .with_input("") // empty path + .with_error(); // should error on empty path + + let result = create_worktree_with_ui(&manager, &ui); + assert!(result.is_ok()); // Function succeeds but returns false + assert!(!result.unwrap()); // Operation was cancelled due to empty path + + // Verify no worktree was created + let worktrees = manager.list_worktrees()?; + assert!(!worktrees.iter().any(|w| w.name == "simple")); + + Ok(()) +} + +/// Test path validation prevents dangerous paths +#[test] +fn test_path_validation_prevents_dangerous_paths() -> Result<()> { + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + // Test absolute path (should fail) + let ui = TestUI::new() + .with_input("test") // worktree name + .with_selection(2) // custom path option + .with_input("/tmp/evil") // absolute path + .with_error(); // should error + + let result = create_worktree_with_ui(&manager, &ui); + assert!(result.is_ok() && !result.unwrap()); + + // Test path traversal (should fail) + let ui = TestUI::new() + .with_input("test") + .with_selection(2) + .with_input("../../../../../../etc") // path traversal + .with_error(); + + let result = create_worktree_with_ui(&manager, &ui); + assert!(result.is_ok() && !result.unwrap()); + + // Verify no worktrees were created + let worktrees = manager.list_worktrees()?; + assert_eq!(worktrees.len(), 0); + + Ok(()) +} + +/// Test special case: just "/" becomes worktree name +#[test] +fn test_single_slash_becomes_worktree_name() -> Result<()> { + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + let ui = TestUI::new() + .with_input("root-level") // worktree name + .with_selection(2) // custom path option + .with_input("/") // just a slash + .with_selection(0) // create from HEAD + .with_confirmation(false); // don't switch + + let result = create_worktree_with_ui(&manager, &ui)?; + assert!(!result); + + // Should create at the default location with just the worktree name + let worktrees = manager.list_worktrees()?; + assert!(worktrees.iter().any(|w| w.name == "root-level")); + + Ok(()) +} + +/// Test that custom paths work with branch selection too +#[test] +fn test_custom_path_with_branch_selection() -> Result<()> { + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + // Create a test branch + test_repo.create_branch("test-branch")?; + + let ui = TestUI::new() + .with_input("branch-feature") // worktree name + .with_selection(2) // custom path option + .with_input("branches/") // directory for branches + .with_selection(1) // select branch + .with_selection(0) // select first branch (test-branch) + .with_confirmation(false); // don't switch + + let result = create_worktree_with_ui(&manager, &ui)?; + assert!(!result); + + let worktrees = manager.list_worktrees()?; + let worktree = worktrees + .iter() + .find(|w| w.name == "branch-feature") + .unwrap(); + assert!(worktree.path.ends_with("branches/branch-feature")); + // New branch was created with worktree name, not using test-branch directly + assert_eq!(worktree.branch, "branch-feature"); + + Ok(()) +} + +/// Test UI examples match actual behavior +#[test] +fn test_ui_examples_are_accurate() -> Result<()> { + // Test each example independently to avoid state pollution + let examples = vec![ + ("example1", "branch/", "branch/example1"), + ("example2", "hotfix/", "hotfix/example2"), + ("example3", "../", "/example3"), // Absolute path will end with just the worktree name + ("example4", "./", "example4"), // Relative to root ends with just the worktree name + ]; + + for (name, input, expected_suffix) in examples.into_iter() { + // Create a fresh test environment for each example + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + let ui = TestUI::new() + .with_input(name) // worktree name + .with_selection(2) // custom path option + .with_input(input) // directory path + .with_selection(0) // create from HEAD + .with_confirmation(false); // don't switch + + let result = create_worktree_with_ui(&manager, &ui)?; + assert!(!result); + + let worktrees = manager.list_worktrees()?; + let worktree = worktrees.iter().find(|w| w.name == name).unwrap(); + + // Normalize paths for comparison + let path_str = worktree.path.to_string_lossy(); + let path_str = path_str.replace('\\', "/"); // Handle Windows paths + + assert!( + path_str.ends_with(expected_suffix), + "Expected path to end with '{expected_suffix}', but got '{path_str}'" + ); + } + + Ok(()) +} + +/// Test that the same behavior works for subsequent worktrees +#[test] +fn test_custom_path_for_subsequent_worktrees() -> Result<()> { + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + // Create first worktree with custom path + let ui = TestUI::new() + .with_input("first") + .with_selection(2) // custom path + .with_input("work/") + .with_selection(0) + .with_confirmation(false); + + create_worktree_with_ui(&manager, &ui)?; + + // Create second worktree (should still offer custom path option) + let ui = TestUI::new() + .with_input("second") + .with_selection(2) // custom path should still be available + .with_input("work/") // same directory + .with_selection(0) + .with_confirmation(false); + + let result = create_worktree_with_ui(&manager, &ui)?; + assert!(!result); + + // Both should be in the work/ directory + let worktrees = manager.list_worktrees()?; + assert!(worktrees.iter().any(|w| w.path.ends_with("work/first"))); + assert!(worktrees.iter().any(|w| w.path.ends_with("work/second"))); + + Ok(()) +} + +/// Test edge case: single dot "." behaves like "./" +#[test] +fn test_single_dot_behaves_like_dot_slash() -> Result<()> { + let temp_dir = TempDir::new()?; + let test_repo = TestRepo::new(&temp_dir)?; + let manager = test_repo.manager()?; + + let ui = TestUI::new() + .with_input("dot-test") + .with_selection(2) // custom path + .with_input(".") // just a dot + .with_selection(0) + .with_confirmation(false); + + create_worktree_with_ui(&manager, &ui)?; + + // Should behave same as "./" + let worktrees = manager.list_worktrees()?; + let worktree = worktrees.iter().find(|w| w.name == "dot-test").unwrap(); + + // Should be in project root + let path_str = worktree.path.to_string_lossy(); + assert!(path_str.contains("test-repo/dot-test") || path_str.ends_with("/dot-test")); + + Ok(()) +} + +#[cfg(test)] +mod validation_tests { + use super::*; + use git_workers::core::validate_custom_path; + + #[test] + fn test_validate_custom_path_accepts_valid_paths() { + // Valid relative paths + assert!(validate_custom_path("features/ui").is_ok()); + assert!(validate_custom_path("../external").is_ok()); + assert!(validate_custom_path("./local").is_ok()); + assert!(validate_custom_path("work/2024/january").is_ok()); + + // Paths with dots + assert!(validate_custom_path("versions/v1.2.3").is_ok()); + assert!(validate_custom_path("config.old/backup").is_ok()); + } + + #[test] + fn test_validate_custom_path_rejects_invalid_paths() { + // Absolute paths + assert!(validate_custom_path("/absolute/path").is_err()); + assert!(validate_custom_path("C:\\Windows\\Path").is_err()); + + // Invalid characters + assert!(validate_custom_path("path*with*asterisk").is_err()); + assert!(validate_custom_path("path?with?question").is_err()); + assert!(validate_custom_path("path:with:colon").is_err()); + + // Git reserved names - these should be allowed as directory names + // Only top-level git reserved names are blocked + assert!(validate_custom_path("gitignore").is_ok()); // This should be ok + assert!(validate_custom_path("HEAD").is_err()); // This should be blocked + assert!(validate_custom_path("refs").is_err()); // This should be blocked + + // Path traversal + assert!(validate_custom_path("../../../../../../../etc/passwd").is_err()); + } + + #[test] + fn test_path_normalization() { + // The implementation should handle these cases correctly + let temp_dir = TempDir::new().unwrap(); + let test_repo = TestRepo::new(&temp_dir).unwrap(); + let manager = test_repo.manager().unwrap(); + + // Test trailing slashes are handled + let ui = TestUI::new() + .with_input("test") + .with_selection(2) + .with_input("path/with/trailing/////") // Multiple trailing slashes + .with_selection(0) + .with_confirmation(false); + + let result = create_worktree_with_ui(&manager, &ui).unwrap(); + assert!(!result); + + let worktrees = manager.list_worktrees().unwrap(); + let worktree = worktrees.iter().find(|w| w.name == "test").unwrap(); + assert!(worktree.path.ends_with("path/with/trailing/test")); + } +}