Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 21 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,47 +36,49 @@ cargo install --path .
## Usage

```text
worktree-link [OPTIONS] <SOURCE> [TARGET]
wtl [OPTIONS] <SOURCE> [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 <DIR>` | Source directory (main worktree) | Auto-detected via `git worktree list` |
| `-t, --target <DIR>` | Target directory (new worktree) | `.` (current directory) |
| `-c, --config <FILE>` | Path to config file | `<SOURCE>/.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`)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,

/// Target directory (new worktree)
#[arg(default_value = ".")]
#[arg(short, long, default_value = ".")]
pub target: PathBuf,

/// Path to config file [default: <SOURCE>/.worktreelinks]
Expand Down
171 changes: 171 additions & 0 deletions src/git.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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<PathBuf> {
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()
}
}
22 changes: 14 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod cli;
mod config;
mod git;
mod linker;
mod walker;

Expand All @@ -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()))?;
Expand All @@ -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");
}
Expand Down