Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
39 changes: 39 additions & 0 deletions src/git/repository/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1165,3 +1165,42 @@ fn prewarm_still_caches_preload_when_worktree_config_disabled() {
"prewarm should preload normal repos (no extensions.worktreeConfig)"
);
}

#[test]
fn test_worktree_paths_for_branch_detects_duplicates() {
use super::worktrees::worktree_paths_for_branch;

// Two worktrees on `feature` — the state `git worktree add --force`
// produces. Porcelain retains every entry; only resolution collapses it.
let output = "worktree /path/to/main
HEAD abcd1234
branch refs/heads/main

worktree /path/to/feature
HEAD efgh5678
branch refs/heads/feature

worktree /path/to/feature-dup
HEAD efgh5678
branch refs/heads/feature

";
let worktrees = WorktreeInfo::parse_porcelain_list(output).unwrap();

// The duplicated branch yields both paths, in git's listing order — the
// first is what resolution uses, the rest are what the warning surfaces.
assert_eq!(
worktree_paths_for_branch(&worktrees, "feature"),
vec![
PathBuf::from("/path/to/feature"),
PathBuf::from("/path/to/feature-dup"),
]
);
// A branch with a single worktree is unambiguous (len 1, no warning).
assert_eq!(
worktree_paths_for_branch(&worktrees, "main"),
vec![PathBuf::from("/path/to/main")]
);
// A branch with no worktree yields nothing.
assert!(worktree_paths_for_branch(&worktrees, "absent").is_empty());
}
76 changes: 71 additions & 5 deletions src/git/repository/worktrees.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
//! Worktree management operations for Repository.

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};

use color_print::cformat;
use dunce::canonicalize;

use super::{GitError, Repository, ResolvedWorktree, WorktreeInfo};
use crate::path::{format_path_for_display, paths_match};
use crate::styling::{eprintln, format_with_gutter, hint_message, warning_message};

impl Repository {
/// List all worktrees for this repository.
Expand Down Expand Up @@ -61,13 +64,20 @@ impl Repository {
}

/// Find the worktree path for a given branch, if one exists.
///
/// A branch normally maps to at most one worktree, but `git worktree add
/// --force <path> <branch>` bypasses git's "already used by worktree" guard
/// and lets the same branch live in several at once. Worktrunk never creates
/// that state; when it exists, this resolves to the first worktree git lists
/// (roughly creation order) and warns once per branch so the otherwise-silent
/// choice is visible. See [`warn_duplicate_checkout`].
pub fn worktree_for_branch(&self, branch: &str) -> anyhow::Result<Option<PathBuf>> {
let worktrees = self.list_worktrees()?;

Ok(worktrees
.iter()
.find(|wt| wt.branch.as_deref() == Some(branch))
.map(|wt| wt.path.clone()))
let paths = worktree_paths_for_branch(worktrees, branch);
if paths.len() > 1 {
warn_duplicate_checkout(branch, &paths);
}
Ok(paths.into_iter().next())
}

/// The "home" worktree — main worktree for normal repos, default branch worktree for bare.
Expand Down Expand Up @@ -288,3 +298,59 @@ impl Repository {
.map_or_else(|| self.repo_path().map(|p| p.to_path_buf()), Ok)
}
}

/// Paths of every worktree checked out on `branch`, in git's listing order.
///
/// At most one under normal use; more than one only when the user ran
/// `git worktree add --force <path> <branch>`, which bypasses git's
/// "already used by worktree" guard. Worktrunk never creates that state.
pub(crate) fn worktree_paths_for_branch(worktrees: &[WorktreeInfo], branch: &str) -> Vec<PathBuf> {
worktrees
.iter()
.filter(|wt| wt.branch.as_deref() == Some(branch))
.map(|wt| wt.path.clone())
.collect()
}

/// Warn once per process that `branch` resolves ambiguously across worktrees.
///
/// Worktrunk addresses worktrees by branch name and resolves an ambiguous
/// branch to the first worktree git lists, leaving the others unreachable by
/// name. The warning surfaces that otherwise-silent choice — naming every
/// path — without changing which worktree is used. Deduplicated per branch so
/// a command that resolves the same branch repeatedly (the picker, `wt list`)
/// warns only once.
fn warn_duplicate_checkout(branch: &str, paths: &[PathBuf]) {
static WARNED: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
// A poisoned lock must not abort resolution; skip the warning instead.
let Ok(mut warned) = WARNED.lock() else {
return;
};
if !warned.insert(branch.to_string()) {
return;
}
drop(warned);

let listing = paths
.iter()
.map(|p| format_path_for_display(p))
.collect::<Vec<_>>()
.join("\n");
eprintln!(
"{}",
warning_message(cformat!(
"Branch <bold>{branch}</> is checked out in {} worktrees; wt uses the first:",
paths.len()
))
);
eprintln!("{}", format_with_gutter(&listing, None));
if let Some(extra) = paths.get(1) {
eprintln!(
"{}",
hint_message(cformat!(
"To drop a duplicate, run <underline>git worktree remove {}</>",
format_path_for_display(extra)
))
);
}
Comment thread
worktrunk-bot marked this conversation as resolved.
Outdated
}
26 changes: 26 additions & 0 deletions tests/integration_tests/step_diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,32 @@ fn test_step_diff_branch_empty(repo: TestRepo) {
));
}

/// A branch checked out in two worktrees (as `git worktree add --force`
/// produces) resolves to the first, warning once and naming every path so the
/// otherwise-silent choice is visible.
#[rstest]
fn test_step_diff_duplicate_branch_warns(mut repo: TestRepo) {
repo.add_worktree("feature");
let dup_path = repo.root_path().parent().unwrap().join("repo.feature-dup");
repo.run_git(&[
"worktree",
"add",
"--force",
dup_path.to_str().unwrap(),
"feature",
]);

let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();

assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"step",
&["diff", "--branch=feature"],
None,
));
}

fn git_status(repo: &TestRepo, dir: &Path) -> String {
let output = repo
.git_command()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
source: tests/integration_tests/step_diff.rs
info:
program: wt
args:
- step
- diff
- "--branch=feature"
env:
APPDATA: "[TEST_CONFIG_HOME]"
CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]"
CLICOLOR_FORCE: "1"
COLUMNS: "500"
GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z"
GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z"
GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]"
GIT_CONFIG_SYSTEM: /dev/null
GIT_TERMINAL_PROMPT: "0"
HOME: "[TEST_HOME]"
LANG: C
LC_ALL: C
LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]"
MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]"
OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]"
PATH: "[PATH]"
TERM: alacritty
USERPROFILE: "[TEST_HOME]"
WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]"
WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]"
WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]"
WORKTRUNK_TEST_BASH_INSTALLED: "0"
WORKTRUNK_TEST_CLAUDE_INSTALLED: "0"
WORKTRUNK_TEST_CODEX_INSTALLED: "0"
WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1"
WORKTRUNK_TEST_EPOCH: "1735776000"
WORKTRUNK_TEST_FISH_INSTALLED: "0"
WORKTRUNK_TEST_GEMINI_INSTALLED: "0"
WORKTRUNK_TEST_NUSHELL_ENV: "0"
WORKTRUNK_TEST_OPENCODE_INSTALLED: "0"
WORKTRUNK_TEST_PARENT_SHELL: ""
WORKTRUNK_TEST_POWERSHELL_ENV: "0"
WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0"
WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1"
WORKTRUNK_TEST_ZSH_INSTALLED: "0"
XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]"
---
success: true
exit_code: 0
----- stdout -----

----- stderr -----
▲ Branch feature is checked out in 2 worktrees; wt uses the first:
  _REPO_.feature
  _REPO_.feature-dup
↳ To drop a duplicate, run git worktree remove _REPO_.feature-dup
Loading