Skip to content

Commit 1864d35

Browse files
committed
feat: add custom path option for worktree creation
1 parent 87007c4 commit 1864d35

File tree

5 files changed

+463
-8
lines changed

5 files changed

+463
-8
lines changed

CLAUDE.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,26 @@ source /path/to/git-workers/shell/gw.sh
8181
- Reduced from 3 options to 2: "Create from current HEAD" and "Select branch (smart mode)"
8282
- Smart mode automatically handles branch conflicts and offers appropriate actions
8383

84+
### Custom Path Support
85+
86+
- Added third option for first worktree creation: "Custom path (specify relative to project root)"
87+
- Allows users to specify arbitrary relative paths for worktree creation
88+
- Comprehensive path validation with security checks:
89+
- Prevents absolute paths
90+
- Validates against filesystem-incompatible characters
91+
- Blocks git reserved names in path components
92+
- Prevents excessive path traversal (max one level above project root)
93+
- Cross-platform compatibility checks
94+
8495
### Key Methods Added/Modified
8596

8697
- **`get_branch_worktree_map()`**: Maps branch names to worktree names, including main worktree detection
8798
- **`list_all_branches()`**: Returns both local and remote branches (remote without "origin/" prefix)
8899
- **`create_worktree_with_new_branch()`**: Creates worktree with new branch from base branch (supports git-flow style workflows)
89100
- **`copy_configured_files()`**: Copies files specified in config to new worktrees
90101
- **`create_worktree_from_head()`**: Fixed path resolution for non-bare repositories (converts relative paths to absolute)
102+
- **`validate_custom_path()`**: Validates custom paths for security and compatibility
103+
- **`create_worktree_internal()`**: Enhanced with custom path input option
91104

92105
## Architecture
93106

@@ -275,15 +288,26 @@ Implemented file-based locking to prevent race conditions:
275288
- **Error Messages**: Clear feedback when another process is creating worktrees
276289
- **Automatic Cleanup**: Lock files are automatically removed when operations complete
277290

291+
#### Custom Path Validation
292+
293+
Added comprehensive validation for user-specified worktree paths:
294+
295+
- **Path Security**: Validates against path traversal attacks and excessive directory navigation
296+
- **Cross-Platform Compatibility**: Checks for Windows reserved characters even on non-Windows systems
297+
- **Git Reserved Names**: Prevents conflicts with git internal directories in path components
298+
- **Path Format Validation**: Ensures proper relative path format (no absolute paths, no trailing slashes)
299+
278300
**Solution**: Convert relative paths to absolute paths before passing them to the git command, ensuring consistent behavior regardless of the working directory.
279301

280302
## Test Coverage
281303

282304
The following test files have been added/updated for v0.3.0:
283305

284306
- `tests/worktree_path_test.rs`: 10 tests for path resolution edge cases
285-
- `tests/create_worktree_integration_test.rs`: 5 integration tests including bare repository scenarios
307+
- `tests/create_worktree_integration_test.rs`: 5 integration tests including bare repository scenarios
286308
- `tests/worktree_commands_test.rs`: 3 new tests for HEAD creation patterns
287309
- `tests/validate_worktree_name_test.rs`: 7 tests for name validation including edge cases
288310
- `tests/file_copy_size_test.rs`: 6 tests for file size limits and copying behavior
289311
- `tests/worktree_lock_test.rs`: 5 tests for concurrent access control
312+
- `tests/validate_custom_path_test.rs`: 9 tests for custom path validation including security checks
313+
- Enhanced `tests/create_worktree_integration_test.rs`: 2 additional tests for custom path creation

src/commands.rs

Lines changed: 212 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -395,26 +395,36 @@ fn search_worktrees_internal(manager: &GitWorktreeManager) -> Result<bool> {
395395
/// 2. **Location Selection** (first worktree only):
396396
/// - Same level as repository: `../worktree-name`
397397
/// - In subdirectory (recommended): `../repo/worktrees/worktree-name`
398+
/// - Custom path: User-specified relative path (e.g., `../custom/path`)
398399
/// 3. **Branch Selection**:
399400
/// - Create from current HEAD
400401
/// - Create from existing branch (shows branch list)
401402
/// 4. **Creation**: Creates the worktree with progress indication
402-
/// 5. **Post-create Hooks**: Executes any configured post-create hooks
403-
/// 6. **Switch Option**: Asks if user wants to switch to the new worktree
403+
/// 5. **File Copy**: Copies configured files (e.g., `.env`) from main worktree
404+
/// 6. **Post-create Hooks**: Executes any configured post-create hooks
405+
/// 7. **Switch Option**: Asks if user wants to switch to the new worktree
404406
///
405407
/// # Worktree Patterns
406408
///
407409
/// The first worktree establishes the pattern for subsequent worktrees.
408410
/// If the first worktree is created at the same level as the repository,
409-
/// all future worktrees follow that pattern.
411+
/// all future worktrees follow that pattern. The custom path option allows
412+
/// breaking this pattern for special cases.
410413
///
411414
/// # Path Handling
412415
///
413416
/// - "Same level" paths (`../name`) are canonicalized for clean display without `..`
414417
/// - "In subdirectory" paths create worktrees in `worktrees/` folder within the repository
418+
/// - "Custom path" allows any relative path, validated for security and compatibility
415419
/// - All paths are resolved to absolute canonical paths before creation
416420
/// - Parent directories are created automatically if needed
417421
///
422+
/// # Custom Path Examples
423+
///
424+
/// - `../experiments/feature-x` - Organize experiments separately
425+
/// - `temp/quick-fix` - Temporary worktrees in project
426+
/// - `../../shared-worktrees/project-a` - Shared location (max one level up from parent)
427+
///
418428
/// # Returns
419429
///
420430
/// Returns `true` if the user created and switched to a new worktree,
@@ -426,6 +436,7 @@ fn search_worktrees_internal(manager: &GitWorktreeManager) -> Result<bool> {
426436
/// - Git repository operations fail
427437
/// - File system operations fail
428438
/// - User input is invalid
439+
/// - Custom path validation fails
429440
pub fn create_worktree() -> Result<bool> {
430441
let manager = GitWorktreeManager::new()?;
431442
create_worktree_internal(&manager)
@@ -443,15 +454,32 @@ pub fn create_worktree() -> Result<bool> {
443454
/// - Detects existing worktree patterns for consistency
444455
/// - Handles both branch and HEAD-based creation
445456
/// - Executes lifecycle hooks at appropriate times
457+
/// - Supports custom path input for flexible worktree organization
446458
///
447459
/// # Path Handling
448460
///
449-
/// For first-time worktree creation, offers two location patterns:
450-
/// - Same level as repository (`../name`): Creates worktrees as siblings
451-
/// - In subdirectory (`worktrees/name`): Creates within repository structure
461+
/// For first-time worktree creation, offers three location patterns:
462+
/// 1. **Same level as repository** (`../name`): Creates worktrees as siblings to the main repository
463+
/// 2. **In subdirectory** (`worktrees/name`): Creates within repository structure (recommended)
464+
/// 3. **Custom path**: Allows users to specify any relative path, validated by `validate_custom_path()`
452465
///
453466
/// The chosen pattern is then used for subsequent worktrees when simple names
454467
/// are provided, ensuring consistent organization.
468+
///
469+
/// # Custom Path Feature
470+
///
471+
/// When users select "Custom path", they can specify any relative path for the worktree.
472+
/// This enables flexible project organization such as:
473+
/// - Grouping by feature type: `features/ui/new-button`, `features/api/auth`
474+
/// - Temporary locations: `../temp/experiment-123`
475+
/// - Project-specific conventions: `workspaces/team-a/feature`
476+
///
477+
/// All custom paths are validated for security and compatibility before use.
478+
///
479+
/// # Returns
480+
///
481+
/// * `true` - If a worktree was created and the user switched to it
482+
/// * `false` - If the operation was cancelled or user chose not to switch
455483
fn create_worktree_internal(manager: &GitWorktreeManager) -> Result<bool> {
456484
println!();
457485
println!("{}", section_header("Create New Worktree"));
@@ -497,6 +525,7 @@ fn create_worktree_internal(manager: &GitWorktreeManager) -> Result<bool> {
497525
let options = vec![
498526
format!("Same level as repository (../{name})"),
499527
format!("In subdirectory ({repo_name}/{}/{name})", WORKTREES_SUBDIR),
528+
"Custom path (specify relative to project root)".to_string(),
500529
];
501530

502531
let selection = match Select::with_theme(&get_theme())
@@ -511,7 +540,38 @@ fn create_worktree_internal(manager: &GitWorktreeManager) -> Result<bool> {
511540

512541
match selection {
513542
0 => format!("../{}", name), // Same level
514-
_ => format!("{}/{}", WORKTREES_SUBDIR, name), // Subdirectory pattern
543+
1 => format!("{}/{}", WORKTREES_SUBDIR, name), // Subdirectory pattern
544+
2 => {
545+
// Custom path input
546+
println!();
547+
println!(
548+
"{}",
549+
"Enter custom path (relative to project root):".bright_cyan()
550+
);
551+
println!(
552+
"{}",
553+
"Examples: ../custom-dir/worktree-name, temp/worktrees/name".dimmed()
554+
);
555+
556+
let custom_path = match input_esc("Custom path") {
557+
Some(path) => path.trim().to_string(),
558+
None => return Ok(false),
559+
};
560+
561+
if custom_path.is_empty() {
562+
utils::print_error("Custom path cannot be empty");
563+
return Ok(false);
564+
}
565+
566+
// Validate custom path
567+
if let Err(e) = validate_custom_path(&custom_path) {
568+
utils::print_error(&format!("Invalid custom path: {}", e));
569+
return Ok(false);
570+
}
571+
572+
custom_path
573+
}
574+
_ => format!("{}/{}", WORKTREES_SUBDIR, name), // Default fallback
515575
}
516576
} else {
517577
name.clone()
@@ -1847,6 +1907,151 @@ pub fn validate_worktree_name(name: &str) -> Result<String> {
18471907
Ok(name.to_string())
18481908
}
18491909

1910+
/// Validates a custom path for worktree creation
1911+
///
1912+
/// This function ensures that the custom path is safe and valid for use
1913+
/// as a worktree path, preventing potential security issues and file system
1914+
/// incompatibilities. It performs comprehensive validation to ensure the path
1915+
/// works across different operating systems and doesn't conflict with Git internals.
1916+
///
1917+
/// # Arguments
1918+
///
1919+
/// * `path` - The custom path to validate (must be relative to project root)
1920+
///
1921+
/// # Returns
1922+
///
1923+
/// * `Ok(())` - The path is valid and safe to use
1924+
/// * `Err` - The path violates one or more validation rules
1925+
///
1926+
/// # Validation Rules
1927+
///
1928+
/// 1. **Empty paths**: Path cannot be empty or contain only whitespace
1929+
/// 2. **Null bytes**: Path cannot contain null bytes (`\0`)
1930+
/// 3. **Reserved characters**: Cannot contain Windows reserved characters for cross-platform compatibility:
1931+
/// - `<` `>` `:` `"` `|` `?` `*`
1932+
/// 4. **Absolute paths**: Must be relative, not absolute (e.g., `/path` or `C:\path` are invalid)
1933+
/// 5. **Path traversal**: Limited to one level above project root (e.g., `../sibling` is ok, `../../parent` is not)
1934+
/// 6. **Path format**: Cannot contain consecutive slashes (`//`) or start/end with slash
1935+
/// 7. **Reserved names**: Path components cannot be Git reserved names (case-insensitive):
1936+
/// - `.git`, `HEAD`, `refs`, `hooks`, `info`, `objects`, `logs`
1937+
///
1938+
/// # Security Considerations
1939+
///
1940+
/// This function is designed to prevent:
1941+
/// - Path traversal attacks that could access system files
1942+
/// - Creation of worktrees in inappropriate locations
1943+
/// - Conflicts with Git's internal directory structure
1944+
/// - File system incompatibilities across platforms
1945+
///
1946+
/// # Examples
1947+
///
1948+
/// ```no_run
1949+
/// use git_workers::commands::validate_custom_path;
1950+
///
1951+
/// // Valid paths
1952+
/// assert!(validate_custom_path("../my-worktree").is_ok());
1953+
/// assert!(validate_custom_path("temp/feature-branch").is_ok());
1954+
/// assert!(validate_custom_path("worktrees/experiment").is_ok());
1955+
/// assert!(validate_custom_path("./local-test").is_ok());
1956+
///
1957+
/// // Invalid paths
1958+
/// assert!(validate_custom_path("/absolute/path").is_err()); // Absolute path
1959+
/// assert!(validate_custom_path("../../too-far").is_err()); // Too many parent dirs
1960+
/// assert!(validate_custom_path("path/with//double").is_err()); // Consecutive slashes
1961+
/// assert!(validate_custom_path("ends/with/").is_err()); // Trailing slash
1962+
/// assert!(validate_custom_path("has:colon").is_err()); // Reserved character
1963+
/// assert!(validate_custom_path("path/.git/config").is_err()); // Contains .git
1964+
/// ```
1965+
///
1966+
/// # Usage in Worktree Creation
1967+
///
1968+
/// This function is called when users select the "Custom path" option during
1969+
/// worktree creation. It ensures that user-provided paths are safe before
1970+
/// passing them to Git's worktree creation commands.
1971+
pub fn validate_custom_path(path: &str) -> Result<()> {
1972+
use std::path::Path;
1973+
1974+
// Trim the path
1975+
let path = path.trim();
1976+
1977+
// Check for empty path
1978+
if path.is_empty() {
1979+
return Err(anyhow!("Custom path cannot be empty"));
1980+
}
1981+
1982+
// Check for null bytes
1983+
if path.contains('\0') {
1984+
return Err(anyhow!("Custom path cannot contain null bytes"));
1985+
}
1986+
1987+
// Check for Windows reserved characters (for cross-platform compatibility)
1988+
const RESERVED_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
1989+
if path.chars().any(|c| RESERVED_CHARS.contains(&c)) {
1990+
return Err(anyhow!(
1991+
"Custom path contains reserved characters: {}",
1992+
RESERVED_CHARS.iter().collect::<String>()
1993+
));
1994+
}
1995+
1996+
// Check if path is absolute
1997+
if Path::new(path).is_absolute() {
1998+
return Err(anyhow!("Custom path must be relative, not absolute"));
1999+
}
2000+
2001+
// Check for consecutive slashes
2002+
if path.contains("//") {
2003+
return Err(anyhow!("Custom path cannot contain consecutive slashes"));
2004+
}
2005+
2006+
// Check for starting or ending with slash
2007+
if path.starts_with('/') || path.ends_with('/') {
2008+
return Err(anyhow!("Custom path cannot start or end with slash"));
2009+
}
2010+
2011+
// Check for dangerous path traversal
2012+
let path_obj = Path::new(path);
2013+
let mut depth = 0i32;
2014+
2015+
for component in path_obj.components() {
2016+
match component {
2017+
std::path::Component::Normal(name) => {
2018+
depth += 1;
2019+
2020+
// Check for reserved names in path components
2021+
if let Some(name_str) = name.to_str() {
2022+
const RESERVED_NAMES: &[&str] =
2023+
&[".git", "HEAD", "refs", "hooks", "info", "objects", "logs"];
2024+
let name_lower = name_str.to_lowercase();
2025+
if RESERVED_NAMES
2026+
.iter()
2027+
.any(|&reserved| reserved.to_lowercase() == name_lower)
2028+
{
2029+
return Err(anyhow!("Path component '{}' is reserved by git", name_str));
2030+
}
2031+
}
2032+
}
2033+
std::path::Component::ParentDir => {
2034+
depth -= 1;
2035+
// Allow going up to project parent, but not further
2036+
// depth can be -1 (one level up from project root) but not -2 or less
2037+
if depth < -1 {
2038+
return Err(anyhow!(
2039+
"Custom path cannot go above project root (too many '../')"
2040+
));
2041+
}
2042+
}
2043+
std::path::Component::CurDir => {
2044+
// ./ is allowed but not very useful
2045+
}
2046+
_ => {
2047+
return Err(anyhow!("Custom path contains invalid components"));
2048+
}
2049+
}
2050+
}
2051+
2052+
Ok(())
2053+
}
2054+
18502055
/// Finds the configuration file path using the same logic as Config::load()
18512056
///
18522057
/// This function follows the exact same discovery order as Config::load_from_main_repository_only()

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
//! - **Batch Operations**: Perform operations on multiple worktrees at once
1313
//! - **Search Functionality**: Fuzzy search through worktrees
1414
//! - **Rename Support**: Complete worktree renaming including Git metadata
15+
//! - **Custom Paths**: Flexible worktree placement with validated custom paths
16+
//! - **File Copying**: Automatically copy configured files to new worktrees
1517
//!
1618
//! # Architecture
1719
//!

tests/create_worktree_integration_test.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,46 @@ fn test_create_worktree_pattern_detection() -> Result<()> {
145145

146146
Ok(())
147147
}
148+
149+
#[test]
150+
fn test_create_worktree_custom_path() -> Result<()> {
151+
let (_temp_dir, manager) = setup_test_environment()?;
152+
153+
// Test custom relative path
154+
let custom_worktree = manager.create_worktree("../custom-location/my-worktree", None)?;
155+
156+
// Verify worktree was created at the specified custom location
157+
assert!(custom_worktree.exists());
158+
assert_eq!(
159+
custom_worktree.file_name().unwrap().to_str().unwrap(),
160+
"my-worktree"
161+
);
162+
163+
// Verify it's in the custom directory structure
164+
assert!(custom_worktree
165+
.to_string_lossy()
166+
.contains("custom-location"));
167+
168+
Ok(())
169+
}
170+
171+
#[test]
172+
fn test_create_worktree_custom_subdirectory() -> Result<()> {
173+
let (_temp_dir, manager) = setup_test_environment()?;
174+
175+
// Test custom subdirectory path
176+
let custom_worktree = manager.create_worktree("temp/experiments/test-feature", None)?;
177+
178+
// Verify worktree was created at the specified location
179+
assert!(custom_worktree.exists());
180+
assert_eq!(
181+
custom_worktree.file_name().unwrap().to_str().unwrap(),
182+
"test-feature"
183+
);
184+
185+
// Verify it's in the correct subdirectory structure
186+
assert!(custom_worktree.to_string_lossy().contains("temp"));
187+
assert!(custom_worktree.to_string_lossy().contains("experiments"));
188+
189+
Ok(())
190+
}

0 commit comments

Comments
 (0)