diff --git a/README.md b/README.md index f5112ea..169ad4c 100644 --- a/README.md +++ b/README.md @@ -36,47 +36,49 @@ cargo install --path . ## Usage ```text -worktree-link [OPTIONS] [TARGET] -wtl [OPTIONS] [TARGET] +worktree-link [OPTIONS] +wtl [OPTIONS] ``` -### Arguments - -| Argument | Description | Default | -|----------|-------------|---------| -| `SOURCE` | Source directory (main worktree) | Required | -| `TARGET` | Target directory (new worktree) | `.` (current directory) | - ### Options | Option | Description | Default | |--------|-------------|---------| +| `-s, --source ` | Source directory (main worktree) | Auto-detected via `git worktree list` | +| `-t, --target ` | Target directory (new worktree) | `.` (current directory) | | `-c, --config ` | Path to config file | `/.worktreelinks` | | `-n, --dry-run` | Show what would be done without making changes | `false` | | `-f, --force` | Overwrite existing files/symlinks | `false` | | `-v, --verbose` | Enable verbose logging | `false` | | `--unlink` | Remove symlinks previously created by worktree-link | `false` | +| `--no-ignore` | Do not respect .gitignore rules | `false` | ### Examples ```bash -# Create symlinks from main worktree to the current directory -wtl /path/to/main +# Create symlinks (auto-detect source from git, target is current directory) +wtl + +# Specify the source directory explicitly +wtl -s /path/to/main # Specify the target directory explicitly -wtl /path/to/main ./feature-branch +wtl -t /path/to/feature-branch -# Preview with dry-run before creating links -wtl --dry-run /path/to/main +# Specify both source and target +wtl -s /path/to/main -t ./feature-branch -# Then actually create the links -wtl /path/to/main +# Preview with dry-run before creating links +wtl --dry-run # Overwrite existing files/symlinks -wtl --force /path/to/main +wtl --force # Remove previously created symlinks -wtl --unlink /path/to/main +wtl --unlink + +# Disable .gitignore filtering +wtl --no-ignore ``` ## Configuration (`.worktreelinks`) @@ -145,7 +147,7 @@ cargo build cargo test # Debug run -cargo run -- --dry-run /path/to/source /path/to/target +cargo run -- --dry-run -s /path/to/source -t /path/to/target ``` ## License diff --git a/src/cli.rs b/src/cli.rs index d473637..1a60338 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,11 +5,13 @@ use std::path::PathBuf; #[derive(Parser, Debug)] #[command(name = "worktree-link", version, about)] pub struct Cli { - /// Source directory (main worktree) - pub source: PathBuf, + /// Source directory (main worktree). + /// Auto-detected via `git worktree list` if omitted. + #[arg(short, long)] + pub source: Option, /// Target directory (new worktree) - #[arg(default_value = ".")] + #[arg(short, long, default_value = ".")] pub target: PathBuf, /// Path to config file [default: /.worktreelinks] diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..323f83c --- /dev/null +++ b/src/git.rs @@ -0,0 +1,171 @@ +use anyhow::{bail, Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Detect the main worktree from a specific directory by running `git worktree list --porcelain`. +/// +/// The first entry in porcelain output is always the main worktree. +/// Returns the canonicalized path of the main worktree. +pub(crate) fn detect_main_worktree_in(dir: &Path) -> Result { + let output = Command::new("git") + .args(["worktree", "list", "--porcelain"]) + .current_dir(dir) + .output() + .context("Failed to run git. Use --source to specify the main worktree path.")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "Failed to detect main worktree: `git worktree list --porcelain` exited with {}.\nstderr:\n{}\nUse --source to specify the main worktree path.", + output.status, + stderr.trim_end(), + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_main_worktree(&stdout) +} + +/// Parse the first worktree path from `git worktree list --porcelain` output. +fn parse_main_worktree(porcelain_output: &str) -> Result { + for line in porcelain_output.lines() { + if let Some(path_str) = line.strip_prefix("worktree ") { + let path = PathBuf::from(path_str); + return fs::canonicalize(&path).with_context(|| { + format!( + "Main worktree not found at: {path_str}. Use --source to specify it manually." + ) + }); + } + } + + bail!("Failed to detect main worktree from git output. Use --source to specify it manually.") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn detect_main_worktree_returns_main_path() { + let main_dir = git_tempdir("detect_main"); + + // Create an initial commit so `git worktree add` works + fs::write(main_dir.join("README.md"), "# test").unwrap(); + let status = Command::new("git") + .args(["add", "."]) + .current_dir(&main_dir) + .status() + .unwrap(); + assert!(status.success()); + let status = Command::new("git") + .args(["commit", "-m", "init", "--quiet"]) + .current_dir(&main_dir) + .status() + .unwrap(); + assert!(status.success()); + + // Add a linked worktree + let wt_dir = std::env::temp_dir().join("worktree-link-test-detect_main_wt"); + let _ = fs::remove_dir_all(&wt_dir); + let status = Command::new("git") + .args(["worktree", "add", wt_dir.to_str().unwrap(), "-b", "test-wt"]) + .current_dir(&main_dir) + .status() + .unwrap(); + assert!(status.success()); + + // Detect from the linked worktree should return the main worktree path + let detected = detect_main_worktree_in(&wt_dir).unwrap(); + assert_eq!(detected, main_dir); + + // Cleanup + let _ = Command::new("git") + .args(["worktree", "remove", "--force", wt_dir.to_str().unwrap()]) + .current_dir(&main_dir) + .status(); + let _ = fs::remove_dir_all(&wt_dir); + } + + #[test] + fn detect_main_worktree_from_main_returns_self() { + let main_dir = git_tempdir("detect_self"); + + let detected = detect_main_worktree_in(&main_dir).unwrap(); + assert_eq!(detected, main_dir); + } + + #[test] + fn detect_main_worktree_outside_git_repo_fails() { + let dir = tempdir("detect_nogit"); + + let result = detect_main_worktree_in(&dir); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("--source"), + "Error message should mention --source, got: {err_msg}" + ); + } + + #[test] + fn parse_main_worktree_extracts_first_entry() { + let dir = git_tempdir("parse_first"); + let commit = Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(&dir) + .output() + .unwrap(); + assert!(commit.status.success(), "git commit failed"); + + // Get raw porcelain output + let output = Command::new("git") + .args(["worktree", "list", "--porcelain"]) + .current_dir(&dir) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + // Delegate to the function under test + let parsed = parse_main_worktree(&stdout).unwrap(); + assert_eq!(parsed, dir); + + let _ = fs::remove_dir_all(&dir); + } + + fn git_tempdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("worktree-link-test-{name}")); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let status = Command::new("git") + .args(["init", "--quiet"]) + .current_dir(&dir) + .status() + .expect("git init failed"); + assert!(status.success(), "git init exited with {status}"); + // Set user config for commits + let status = Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(&dir) + .status() + .unwrap(); + assert!(status.success()); + let status = Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&dir) + .status() + .unwrap(); + assert!(status.success()); + // Return canonical path so comparisons work + fs::canonicalize(&dir).unwrap() + } + + fn tempdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("worktree-link-test-{name}")); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::canonicalize(&dir).unwrap() + } +} diff --git a/src/main.rs b/src/main.rs index 49f372b..2f045d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod cli; mod config; +mod git; mod linker; mod walker; @@ -24,14 +25,6 @@ fn main() -> Result<()> { .without_time() .init(); - // Resolve source directory - let source = fs::canonicalize(&cli.source) - .with_context(|| format!("Source directory does not exist: {}", cli.source.display()))?; - - if !source.is_dir() { - bail!("Source is not a directory: {}", source.display()); - } - // Resolve target directory let target = fs::canonicalize(&cli.target) .with_context(|| format!("Target directory does not exist: {}", cli.target.display()))?; @@ -40,6 +33,19 @@ fn main() -> Result<()> { bail!("Target is not a directory: {}", target.display()); } + // Resolve source directory + let source = match cli.source { + Some(s) => { + let resolved = fs::canonicalize(&s) + .with_context(|| format!("Source directory does not exist: {}", s.display()))?; + if !resolved.is_dir() { + bail!("Source is not a directory: {}", resolved.display()); + } + resolved + } + None => git::detect_main_worktree_in(&target)?, + }; + if source == target { bail!("Source and target cannot be the same directory"); }