From f8c92fe82d926f16dd1c8761b4eb4a490558c2fb Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sat, 25 Jul 2026 14:22:32 -0700 Subject: [PATCH 1/2] feat(list): flag a branch checked out in more than one worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git worktree add --force` can put one branch in two worktrees, and #3480 made the resulting ambiguity visible only at resolution time: a command that looks the branch up warns once, then picks whichever worktree git lists first. `wt list` showed nothing, so the two identical `feature` rows read as a rendering glitch. Both rows now carry `⧉` in the Status column's worktree-state position, ranked above the informational `⚑` mismatch flag (a force-added duplicate usually sits off-template too) and below `⊟`/`⊞`. The Path column, which previously appeared only for a location mismatch, now also appears for a duplicate: with the branch name repeated, the path is the only thing telling the rows apart. JSON reports it in both schemas: schema 1 as a `worktree.state` value, schema 2 as its own `worktree.duplicate_branch` flag alongside `branch_mismatch`. Co-Authored-By: Claude Opus 5 (1M context) --- docs/content/list.md | 8 +- .../skills/worktrunk/reference/list.md | 8 +- skills/worktrunk/reference/list.md | 8 +- src/cli/mod.rs | 8 +- src/commands/list/collect/mod.rs | 9 +++ src/commands/list/json_output.rs | 5 +- src/commands/list/json_v2.rs | 8 +- src/commands/list/layout.rs | 30 ++++--- src/commands/list/model/item.rs | 30 +++++-- src/commands/list/model/state.rs | 7 +- src/commands/list/model/status_symbols.rs | 24 +++--- src/git/mod.rs | 4 +- src/git/repository/mod.rs | 1 + src/git/repository/tests.rs | 14 +++- src/git/repository/worktrees.rs | 17 ++++ src/md_help.rs | 4 +- tests/integration_tests/list.rs | 79 +++++++++++++++++++ ...tegration_tests__help__help_list_long.snap | 59 +++++++------- ...tion_tests__help__help_list_narrow_80.snap | 47 ++++++----- ...on_tests__list__list_duplicate_branch.snap | 59 ++++++++++++++ ...ts__list__list_json_schema_2_envelope.snap | 8 ++ 21 files changed, 342 insertions(+), 95 deletions(-) create mode 100644 tests/snapshots/integration__integration_tests__list__list_duplicate_branch.snap diff --git a/docs/content/list.md b/docs/content/list.md index abbc5c4610..1c1252fa62 100644 --- a/docs/content/list.md +++ b/docs/content/list.md @@ -164,7 +164,7 @@ Independent flags from `git status`; several can show at once (e.g. `+!?`). Each ### Worktree -An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⚑ > /`): +An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /`): | Symbol | JSON | Meaning | |--------|------|---------| @@ -172,6 +172,7 @@ An in-progress git operation, a worktree-location attribute, or a branch with no | `↻` | `operation_state` `"rebase"`, `"merge"`, `"cherry_pick"`, `"revert"`, `"bisect"` | A git operation is in progress; `git status` names it | | `⊟` | `worktree.state` `"prunable"` | Prunable (worktree directory missing) | | `⊞` | `worktree.state` `"locked"` | Locked worktree | +| `⧉` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree; `wt` resolves it to whichever git lists first, and every affected row is flagged | | `⚑` | `worktree.state` `"branch_worktree_mismatch"` | Branch name doesn't match the worktree path | | `/` | `kind` `"branch"` | Branch without a worktree (no `worktree` object) | @@ -243,6 +244,7 @@ One envelope object. Items carry independent facts; rendered strings "committed_at": "2025-01-01T08:00:00Z"}, "worktree": {"path": "/home/user/repo.feature", "main": false, "current": true, "previous": false, "detached": false, "branch_mismatch": false, + "duplicate_branch": false, "changes": {"staged": false, "modified": true, "untracked": false, "renamed": false, "deleted": false, "conflicted": false, "diff": {"added": 10, "deleted": 2}}}, @@ -274,7 +276,7 @@ Item fields: | `branch` | Branch name; null for a detached-HEAD worktree. Remote rows carry the bare name with the remote in `remote` | | `remote` | Remote name, present only on remote-only branch rows | | `head` | `{sha, short_sha, subject, committed_at}`; null for unborn branches. `committed_at` is RFC 3339 UTC | -| `worktree` | `{path, main, current, previous, detached, locked, prunable, branch_mismatch, operation, changes}`; absent on branch-only rows. `locked`/`prunable` are `{reason}` objects and can co-occur; `operation` is `"rebase"` or `"merge"`; `changes` holds the five working-tree flags plus `conflicted` and `diff {added, deleted}` | +| `worktree` | `{path, main, current, previous, detached, locked, prunable, branch_mismatch, duplicate_branch, operation, changes}`; absent on branch-only rows. `locked`/`prunable` are `{reason}` objects and can co-occur; `operation` is `"rebase"` or `"merge"`; `changes` holds the five working-tree flags plus `conflicted` and `diff {added, deleted}` | | `default_branch` | Relation to the default branch: `{ahead, behind, diff, orphan, integration, merge_conflicts}`; absent on the default branch itself. `integration.reason` is one of `same_commit`, `ancestor`, `no_added_changes`, `trees_match`, `merge_adds_nothing`, `patch_id_match`; a dirty tree skips the checks, leaving `integration` null | | `upstream` | Tracking branch: `{remote, branch, ahead, behind}`; absent when none is configured | | `pr` | Open PR/MR: `{number, url, review, mergeable, repo}`; collected with `--full` or a listed `ci` column. `review` uses the schema 1 `ci.review_state` vocabulary; `mergeable` is false when the forge reports conflicts, null otherwise | @@ -374,7 +376,7 @@ Present only for worktree-kind items. `state` is the worktree-location attribute | Field | Type | Description | |-------|------|-------------| -| `state` | string | `"branch_worktree_mismatch"`, `"prunable"`, or `"locked"` (absent when normal) | +| `state` | string | `"branch_worktree_mismatch"`, `"duplicate_branch"`, `"prunable"`, or `"locked"` (absent when normal) | | `reason` | string | Reason for locked/prunable state | | `detached` | boolean | HEAD is detached | diff --git a/plugins/worktrunk/skills/worktrunk/reference/list.md b/plugins/worktrunk/skills/worktrunk/reference/list.md index 85e5b49427..a3a1c238a7 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/list.md +++ b/plugins/worktrunk/skills/worktrunk/reference/list.md @@ -149,7 +149,7 @@ Independent flags from `git status`; several can show at once (e.g. `+!?`). Each ### Worktree -An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⚑ > /`): +An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /`): | Symbol | JSON | Meaning | |--------|------|---------| @@ -157,6 +157,7 @@ An in-progress git operation, a worktree-location attribute, or a branch with no | `↻` | `operation_state` `"rebase"`, `"merge"`, `"cherry_pick"`, `"revert"`, `"bisect"` | A git operation is in progress; `git status` names it | | `⊟` | `worktree.state` `"prunable"` | Prunable (worktree directory missing) | | `⊞` | `worktree.state` `"locked"` | Locked worktree | +| `⧉` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree; `wt` resolves it to whichever git lists first, and every affected row is flagged | | `⚑` | `worktree.state` `"branch_worktree_mismatch"` | Branch name doesn't match the worktree path | | `/` | `kind` `"branch"` | Branch without a worktree (no `worktree` object) | @@ -228,6 +229,7 @@ One envelope object. Items carry independent facts; rendered strings "committed_at": "2025-01-01T08:00:00Z"}, "worktree": {"path": "/home/user/repo.feature", "main": false, "current": true, "previous": false, "detached": false, "branch_mismatch": false, + "duplicate_branch": false, "changes": {"staged": false, "modified": true, "untracked": false, "renamed": false, "deleted": false, "conflicted": false, "diff": {"added": 10, "deleted": 2}}}, @@ -259,7 +261,7 @@ Item fields: | `branch` | Branch name; null for a detached-HEAD worktree. Remote rows carry the bare name with the remote in `remote` | | `remote` | Remote name, present only on remote-only branch rows | | `head` | `{sha, short_sha, subject, committed_at}`; null for unborn branches. `committed_at` is RFC 3339 UTC | -| `worktree` | `{path, main, current, previous, detached, locked, prunable, branch_mismatch, operation, changes}`; absent on branch-only rows. `locked`/`prunable` are `{reason}` objects and can co-occur; `operation` is `"rebase"` or `"merge"`; `changes` holds the five working-tree flags plus `conflicted` and `diff {added, deleted}` | +| `worktree` | `{path, main, current, previous, detached, locked, prunable, branch_mismatch, duplicate_branch, operation, changes}`; absent on branch-only rows. `locked`/`prunable` are `{reason}` objects and can co-occur; `operation` is `"rebase"` or `"merge"`; `changes` holds the five working-tree flags plus `conflicted` and `diff {added, deleted}` | | `default_branch` | Relation to the default branch: `{ahead, behind, diff, orphan, integration, merge_conflicts}`; absent on the default branch itself. `integration.reason` is one of `same_commit`, `ancestor`, `no_added_changes`, `trees_match`, `merge_adds_nothing`, `patch_id_match`; a dirty tree skips the checks, leaving `integration` null | | `upstream` | Tracking branch: `{remote, branch, ahead, behind}`; absent when none is configured | | `pr` | Open PR/MR: `{number, url, review, mergeable, repo}`; collected with `--full` or a listed `ci` column. `review` uses the schema 1 `ci.review_state` vocabulary; `mergeable` is false when the forge reports conflicts, null otherwise | @@ -395,7 +397,7 @@ Present only for worktree-kind items. `state` is the worktree-location attribute | Field | Type | Description | |-------|------|-------------| -| `state` | string | `"branch_worktree_mismatch"`, `"prunable"`, or `"locked"` (absent when normal) | +| `state` | string | `"branch_worktree_mismatch"`, `"duplicate_branch"`, `"prunable"`, or `"locked"` (absent when normal) | | `reason` | string | Reason for locked/prunable state | | `detached` | boolean | HEAD is detached | diff --git a/skills/worktrunk/reference/list.md b/skills/worktrunk/reference/list.md index 85e5b49427..a3a1c238a7 100644 --- a/skills/worktrunk/reference/list.md +++ b/skills/worktrunk/reference/list.md @@ -149,7 +149,7 @@ Independent flags from `git status`; several can show at once (e.g. `+!?`). Each ### Worktree -An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⚑ > /`): +An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /`): | Symbol | JSON | Meaning | |--------|------|---------| @@ -157,6 +157,7 @@ An in-progress git operation, a worktree-location attribute, or a branch with no | `↻` | `operation_state` `"rebase"`, `"merge"`, `"cherry_pick"`, `"revert"`, `"bisect"` | A git operation is in progress; `git status` names it | | `⊟` | `worktree.state` `"prunable"` | Prunable (worktree directory missing) | | `⊞` | `worktree.state` `"locked"` | Locked worktree | +| `⧉` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree; `wt` resolves it to whichever git lists first, and every affected row is flagged | | `⚑` | `worktree.state` `"branch_worktree_mismatch"` | Branch name doesn't match the worktree path | | `/` | `kind` `"branch"` | Branch without a worktree (no `worktree` object) | @@ -228,6 +229,7 @@ One envelope object. Items carry independent facts; rendered strings "committed_at": "2025-01-01T08:00:00Z"}, "worktree": {"path": "/home/user/repo.feature", "main": false, "current": true, "previous": false, "detached": false, "branch_mismatch": false, + "duplicate_branch": false, "changes": {"staged": false, "modified": true, "untracked": false, "renamed": false, "deleted": false, "conflicted": false, "diff": {"added": 10, "deleted": 2}}}, @@ -259,7 +261,7 @@ Item fields: | `branch` | Branch name; null for a detached-HEAD worktree. Remote rows carry the bare name with the remote in `remote` | | `remote` | Remote name, present only on remote-only branch rows | | `head` | `{sha, short_sha, subject, committed_at}`; null for unborn branches. `committed_at` is RFC 3339 UTC | -| `worktree` | `{path, main, current, previous, detached, locked, prunable, branch_mismatch, operation, changes}`; absent on branch-only rows. `locked`/`prunable` are `{reason}` objects and can co-occur; `operation` is `"rebase"` or `"merge"`; `changes` holds the five working-tree flags plus `conflicted` and `diff {added, deleted}` | +| `worktree` | `{path, main, current, previous, detached, locked, prunable, branch_mismatch, duplicate_branch, operation, changes}`; absent on branch-only rows. `locked`/`prunable` are `{reason}` objects and can co-occur; `operation` is `"rebase"` or `"merge"`; `changes` holds the five working-tree flags plus `conflicted` and `diff {added, deleted}` | | `default_branch` | Relation to the default branch: `{ahead, behind, diff, orphan, integration, merge_conflicts}`; absent on the default branch itself. `integration.reason` is one of `same_commit`, `ancestor`, `no_added_changes`, `trees_match`, `merge_adds_nothing`, `patch_id_match`; a dirty tree skips the checks, leaving `integration` null | | `upstream` | Tracking branch: `{remote, branch, ahead, behind}`; absent when none is configured | | `pr` | Open PR/MR: `{number, url, review, mergeable, repo}`; collected with `--full` or a listed `ci` column. `review` uses the schema 1 `ci.review_state` vocabulary; `mergeable` is false when the forge reports conflicts, null otherwise | @@ -395,7 +397,7 @@ Present only for worktree-kind items. `state` is the worktree-location attribute | Field | Type | Description | |-------|------|-------------| -| `state` | string | `"branch_worktree_mismatch"`, `"prunable"`, or `"locked"` (absent when normal) | +| `state` | string | `"branch_worktree_mismatch"`, `"duplicate_branch"`, `"prunable"`, or `"locked"` (absent when normal) | | `reason` | string | Reason for locked/prunable state | | `detached` | boolean | HEAD is detached | diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3357c96983..26510cd21d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -905,7 +905,7 @@ Independent flags from `git status`; several can show at once (e.g. `+!?`). Each ### Worktree -An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⚑ > /`): +An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /`): | Symbol | JSON | Meaning | |--------|------|---------| @@ -913,6 +913,7 @@ An in-progress git operation, a worktree-location attribute, or a branch with no | `↻` | `operation_state` `"rebase"`, `"merge"`, `"cherry_pick"`, `"revert"`, `"bisect"` | A git operation is in progress; `git status` names it | | `⊟` | `worktree.state` `"prunable"` | Prunable (worktree directory missing) | | `⊞` | `worktree.state` `"locked"` | Locked worktree | +| `⧉` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree; `wt` resolves it to whichever git lists first, and every affected row is flagged | | `⚑` | `worktree.state` `"branch_worktree_mismatch"` | Branch name doesn't match the worktree path | | `/` | `kind` `"branch"` | Branch without a worktree (no `worktree` object) | @@ -984,6 +985,7 @@ One envelope object. Items carry independent facts; rendered strings "committed_at": "2025-01-01T08:00:00Z"}, "worktree": {"path": "/home/user/repo.feature", "main": false, "current": true, "previous": false, "detached": false, "branch_mismatch": false, + "duplicate_branch": false, "changes": {"staged": false, "modified": true, "untracked": false, "renamed": false, "deleted": false, "conflicted": false, "diff": {"added": 10, "deleted": 2}}}, @@ -1015,7 +1017,7 @@ Item fields: | `branch` | Branch name; null for a detached-HEAD worktree. Remote rows carry the bare name with the remote in `remote` | | `remote` | Remote name, present only on remote-only branch rows | | `head` | `{sha, short_sha, subject, committed_at}`; null for unborn branches. `committed_at` is RFC 3339 UTC | -| `worktree` | `{path, main, current, previous, detached, locked, prunable, branch_mismatch, operation, changes}`; absent on branch-only rows. `locked`/`prunable` are `{reason}` objects and can co-occur; `operation` is `"rebase"` or `"merge"`; `changes` holds the five working-tree flags plus `conflicted` and `diff {added, deleted}` | +| `worktree` | `{path, main, current, previous, detached, locked, prunable, branch_mismatch, duplicate_branch, operation, changes}`; absent on branch-only rows. `locked`/`prunable` are `{reason}` objects and can co-occur; `operation` is `"rebase"` or `"merge"`; `changes` holds the five working-tree flags plus `conflicted` and `diff {added, deleted}` | | `default_branch` | Relation to the default branch: `{ahead, behind, diff, orphan, integration, merge_conflicts}`; absent on the default branch itself. `integration.reason` is one of `same_commit`, `ancestor`, `no_added_changes`, `trees_match`, `merge_adds_nothing`, `patch_id_match`; a dirty tree skips the checks, leaving `integration` null | | `upstream` | Tracking branch: `{remote, branch, ahead, behind}`; absent when none is configured | | `pr` | Open PR/MR: `{number, url, review, mergeable, repo}`; collected with `--full` or a listed `ci` column. `review` uses the schema 1 `ci.review_state` vocabulary; `mergeable` is false when the forge reports conflicts, null otherwise | @@ -1151,7 +1153,7 @@ Present only for worktree-kind items. `state` is the worktree-location attribute | Field | Type | Description | |-------|------|-------------| -| `state` | string | `"branch_worktree_mismatch"`, `"prunable"`, or `"locked"` (absent when normal) | +| `state` | string | `"branch_worktree_mismatch"`, `"duplicate_branch"`, `"prunable"`, or `"locked"` (absent when normal) | | `reason` | string | Reason for locked/prunable state | | `detached` | boolean | HEAD is detached | diff --git a/src/commands/list/collect/mod.rs b/src/commands/list/collect/mod.rs index 8e3cabde48..04701f5426 100644 --- a/src/commands/list/collect/mod.rs +++ b/src/commands/list/collect/mod.rs @@ -1132,6 +1132,11 @@ pub fn collect( // (paths from git worktree list may differ based on symlinks or working directory) let main_worktree_canonical = canonicalize(&main_worktree.path).ok(); + // Branches living in more than one worktree. Every row on such a branch + // is flagged, including the one `wt` resolves to: that choice is git's + // listing order, so no row is the legitimate one. + let duplicated = worktrunk::git::duplicated_branches(worktrees); + // URL template already fetched in parallel join (layout needs to know if column is needed) // Initialize worktree items with identity fields and None for computed fields let mut all_items: Vec = sorted_worktrees @@ -1158,6 +1163,10 @@ pub fn collect( let mut worktree_data = WorktreeData::from_worktree(wt, is_main, is_current, is_previous); worktree_data.branch_worktree_mismatch = branch_worktree_mismatch; + worktree_data.duplicate_branch = wt + .branch + .as_deref() + .is_some_and(|branch| duplicated.contains(branch)); // URL expanded post-skeleton to minimize time-to-skeleton ListItem { diff --git a/src/commands/list/json_output.rs b/src/commands/list/json_output.rs index d8bca56b8b..755a960384 100644 --- a/src/commands/list/json_output.rs +++ b/src/commands/list/json_output.rs @@ -214,7 +214,8 @@ pub struct JsonRemote { /// Worktree-specific state #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct JsonWorktree { - /// Worktree state: "branch_worktree_mismatch", "prunable", "locked" (absent when normal) + /// Worktree state: "branch_worktree_mismatch", "duplicate_branch", + /// "prunable", "locked" (absent when normal) #[serde(skip_serializing_if = "Option::is_none")] pub state: Option<&'static str>, @@ -455,6 +456,7 @@ fn worktree_state_to_json( Some(WorktreeState::BranchWorktreeMismatch) => { return (Some("branch_worktree_mismatch"), None); } + Some(WorktreeState::DuplicateBranch) => return (Some("duplicate_branch"), None), Some(WorktreeState::Prunable) => return (Some("prunable"), data.prunable.clone()), Some(WorktreeState::Locked) => return (Some("locked"), data.locked.clone()), } @@ -825,6 +827,7 @@ mod tests { has_working_tree_conflicts: None, git_operation: Some(None), branch_worktree_mismatch: false, + duplicate_branch: false, } } diff --git a/src/commands/list/json_v2.rs b/src/commands/list/json_v2.rs index 234207ec30..7c4a9d8327 100644 --- a/src/commands/list/json_v2.rs +++ b/src/commands/list/json_v2.rs @@ -179,8 +179,8 @@ pub struct JsonHead { } /// Worktree facts. Location attributes (`locked`, `prunable`, -/// `branch_mismatch`) are independent fields — unlike schema 1's single -/// `state`, they can co-occur. +/// `branch_mismatch`, `duplicate_branch`) are independent fields — unlike +/// schema 1's single `state`, they can co-occur. #[derive(Debug, Serialize, JsonSchema)] pub struct JsonWorktreeV2 { /// Filesystem path. @@ -210,6 +210,9 @@ pub struct JsonWorktreeV2 { /// created for. pub branch_mismatch: bool, + /// Another worktree has the same branch checked out. + pub duplicate_branch: bool, + /// In-progress operation: `"rebase"`, `"merge"`, `"cherry_pick"`, /// `"revert"`, or `"bisect"`; absent when none, null while unresolved. #[serde(skip_serializing_if = "Tri::is_absent")] @@ -596,6 +599,7 @@ fn json_worktree(data: &WorktreeData) -> JsonWorktreeV2 { locked: reason(&data.locked), prunable: reason(&data.prunable), branch_mismatch: data.branch_worktree_mismatch, + duplicate_branch: data.duplicate_branch, operation, changes, } diff --git a/src/commands/list/layout.rs b/src/commands/list/layout.rs index dcd0a73f47..e6c6652201 100644 --- a/src/commands/list/layout.rs +++ b/src/commands/list/layout.rs @@ -95,7 +95,9 @@ //! which means empty penalties don't apply in progressive mode. //! //! Exceptions that we can compute instantly from items: -//! - `path`: true only if any worktree has `branch_worktree_mismatch` (computed from items) +//! - `path`: true only if some worktree's path carries information the branch +//! column doesn't — `branch_worktree_mismatch` or `duplicate_branch` (computed +//! from items) //! - `branch_diff`/`ci_status`: false if their required task is skipped //! //! Other columns (status, working_diff, ahead_behind, upstream) require expensive git operations, @@ -295,7 +297,7 @@ pub struct ColumnDataFlags { pub upstream: bool, pub url: bool, pub ci_status: bool, - pub path: bool, // True if any worktree has branch_worktree_mismatch + pub path: bool, // True if a worktree is off-template or shares its branch } /// Layout metadata including position mask for Status column @@ -664,7 +666,7 @@ fn estimate_url_width(url_template: Option<&str>, hyperlinks_supported: bool) -> fn build_estimated_widths( max_branch: usize, tasks: &HashSet, - has_branch_worktree_mismatch: bool, + path_is_informative: bool, url_width: usize, max_pr_number: Option, custom_widths: Vec, @@ -695,7 +697,7 @@ fn build_estimated_widths( // before the data arrives, so empty penalties don't apply properly. // // Exceptions that we can compute instantly from items: - // - path: true only if any worktree has branch_worktree_mismatch + // - path: true only if a worktree is off-template or shares its branch // - branch_diff/ci_status: false if their task isn't in the run plan let data_flags = ColumnDataFlags { status: true, @@ -705,7 +707,7 @@ fn build_estimated_widths( upstream: true, url: tasks.contains(&TaskKind::UrlStatus), ci_status: tasks.contains(&TaskKind::CiStatus), - path: has_branch_worktree_mismatch, + path: path_is_informative, }; // URL width estimated from template + longest branch (or fallback) @@ -1106,12 +1108,13 @@ pub fn calculate_layout_with_width( .unwrap_or(0); let max_path_width = fit_header(ColumnKind::Path.header(), path_data_width); - // Check if any worktree has a branch-worktree mismatch. - // Path column is only useful when there's a mismatch; otherwise it's redundant with branch. - let has_branch_worktree_mismatch = items + // The Path column is redundant with Branch unless a path says something the + // branch name doesn't: the worktree sits off-template, or two worktrees share + // the branch and the path is the only thing telling their rows apart. + let path_is_informative = items .iter() .filter_map(|item| item.worktree_data()) - .any(|data| data.branch_worktree_mismatch); + .any(|data| data.branch_worktree_mismatch || data.duplicate_branch); // Estimate URL width from template (heuristic, no expansion needed) let url_width = estimate_url_width(url_template, supports_hyperlinks(Stream::Stdout)); @@ -1140,7 +1143,7 @@ pub fn calculate_layout_with_width( let metadata = build_estimated_widths( max_branch, tasks, - has_branch_worktree_mismatch, + path_is_informative, url_width, max_pr_number, custom_widths, @@ -1414,7 +1417,7 @@ mod tests { fn test_pre_allocated_width_estimates() { // Test that build_estimated_widths() returns correct pre-allocated estimates // Full run plan means all tasks are computed (equivalent to --full) - // has_branch_worktree_mismatch=true to test the path flag is passed through + // path_is_informative=true to test the path flag is passed through // url_width=0 since we're not testing URL column here let metadata = build_estimated_widths(20, &full_run_tasks(), true, 0, None, Vec::new()); let widths = metadata.widths; @@ -1554,6 +1557,7 @@ mod tests { is_current: false, is_previous: false, branch_worktree_mismatch: false, + duplicate_branch: false, })), }; @@ -1670,6 +1674,7 @@ mod tests { is_current: false, is_previous: false, branch_worktree_mismatch: false, + duplicate_branch: false, })), }; @@ -1798,6 +1803,7 @@ mod tests { is_current: false, is_previous: false, branch_worktree_mismatch: false, + duplicate_branch: false, })), } } @@ -2264,6 +2270,7 @@ mod tests { is_current: false, is_previous: false, branch_worktree_mismatch: false, + duplicate_branch: false, })), } } @@ -2420,6 +2427,7 @@ mod tests { is_current, is_previous: false, branch_worktree_mismatch: false, + duplicate_branch: false, })), } }; diff --git a/src/commands/list/model/item.rs b/src/commands/list/model/item.rs index a1c223c19f..8b7ed5404a 100644 --- a/src/commands/list/model/item.rs +++ b/src/commands/list/model/item.rs @@ -21,13 +21,16 @@ use crate::commands::list::layout::format_url_cell; /// /// Used by `refresh_status_symbols` to resolve the worktree-state position /// (Gate 2) from metadata alone. The decision priority is: -/// `prunable` > `locked` > `branch_worktree_mismatch` > `None` — the yellow -/// actionable states outrank the informational (dim yellow) mismatch flag. +/// `prunable` > `locked` > `duplicate_branch` > `branch_worktree_mismatch` > +/// `None` — the yellow actionable states outrank the informational (dim +/// yellow) mismatch flag. fn metadata_worktree_state(data: &WorktreeData) -> WorktreeState { if data.is_prunable() { WorktreeState::Prunable } else if data.locked.is_some() { WorktreeState::Locked + } else if data.duplicate_branch { + WorktreeState::DuplicateBranch } else if data.branch_worktree_mismatch { WorktreeState::BranchWorktreeMismatch } else { @@ -66,6 +69,12 @@ pub struct WorktreeData { /// Whether the worktree is at an unexpected location (branch-worktree mismatch). /// Only true when: has branch name, not main worktree, and path differs from template. pub branch_worktree_mismatch: bool, + /// Whether another worktree has the same branch checked out. Only + /// `git worktree add --force` produces this state; worktrunk assumes a + /// branch ⇔ worktree bijection and resolves the branch to whichever + /// worktree git lists first (see `worktree_for_branch`), so every + /// worktree on the branch carries the flag, resolved one included. + pub duplicate_branch: bool, } impl WorktreeData { @@ -854,7 +863,9 @@ mod tests { use super::*; /// The yellow actionable states outrank the informational (dim yellow) - /// mismatch flag, so a demoted `⚑` can never mask `⊟` or `⊞`. + /// mismatch flag, so a demoted `⚑` can never mask `⊟`, `⊞`, or `⧉`. + /// A force-added duplicate usually sits off-template too, so the two + /// flags routinely co-occur and the ordering decides which one shows. #[test] fn test_metadata_worktree_state_priority() { let mismatched = WorktreeData { @@ -866,15 +877,24 @@ mod tests { WorktreeState::BranchWorktreeMismatch ); + let duplicate = WorktreeData { + duplicate_branch: true, + ..mismatched.clone() + }; + assert_eq!( + metadata_worktree_state(&duplicate), + WorktreeState::DuplicateBranch + ); + let prunable = WorktreeData { prunable: Some("gone".to_string()), - ..mismatched.clone() + ..duplicate.clone() }; assert_eq!(metadata_worktree_state(&prunable), WorktreeState::Prunable); let locked = WorktreeData { locked: Some("pinned".to_string()), - ..mismatched.clone() + ..duplicate.clone() }; assert_eq!(metadata_worktree_state(&locked), WorktreeState::Locked); } diff --git a/src/commands/list/model/state.rs b/src/commands/list/model/state.rs index 5ea52bcecd..eb1d5b5714 100644 --- a/src/commands/list/model/state.rs +++ b/src/commands/list/model/state.rs @@ -74,7 +74,8 @@ impl Divergence { /// - For worktrees: whether the path matches the template, or has issues /// - For branches (without worktree): shows / to distinguish from worktrees /// -/// Priority order for worktrees: Prunable > Locked > BranchWorktreeMismatch +/// Priority order for worktrees: Prunable > Locked > DuplicateBranch > +/// BranchWorktreeMismatch #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, strum::IntoStaticStr)] pub enum WorktreeState { #[strum(serialize = "")] @@ -83,6 +84,8 @@ pub enum WorktreeState { None, /// Branch-worktree mismatch: path doesn't match what the template would generate BranchWorktreeMismatch, + /// The branch is checked out in more than one worktree + DuplicateBranch, /// Prunable (worktree directory missing) Prunable, /// Locked (protected from removal) @@ -96,6 +99,7 @@ impl std::fmt::Display for WorktreeState { match self { Self::None => Ok(()), Self::BranchWorktreeMismatch => write!(f, "⚑"), + Self::DuplicateBranch => write!(f, "⧉"), Self::Prunable => write!(f, "⊟"), Self::Locked => write!(f, "⊞"), Self::Branch => write!(f, "/"), @@ -520,6 +524,7 @@ mod tests { fn test_worktree_state_display() { assert_eq!(format!("{}", WorktreeState::None), ""); assert_eq!(format!("{}", WorktreeState::BranchWorktreeMismatch), "⚑"); + assert_eq!(format!("{}", WorktreeState::DuplicateBranch), "⧉"); assert_eq!(format!("{}", WorktreeState::Prunable), "⊟"); assert_eq!(format!("{}", WorktreeState::Locked), "⊞"); assert_eq!(format!("{}", WorktreeState::Branch), "/"); diff --git a/src/commands/list/model/status_symbols.rs b/src/commands/list/model/status_symbols.rs index c7ea5735a9..025c21fa87 100644 --- a/src/commands/list/model/status_symbols.rs +++ b/src/commands/list/model/status_symbols.rs @@ -22,7 +22,7 @@ //! | 0 | `STAGED` | `+` | Are there staged changes? | //! | 1 | `MODIFIED` | `!` | Are there unstaged modifications? | //! | 2 | `UNTRACKED` | `?` | Are there untracked files? | -//! | 3 | `WORKTREE_STATE` | `✘ ↻ ⊟ ⊞ ⚑ /` | Operation / worktree attribute | +//! | 3 | `WORKTREE_STATE` | `✘ ↻ ⊟ ⊞ ⧉ ⚑ /` | Operation / worktree attribute | //! | 4 | `MAIN_STATE` | `^ _ ⊂ ✗ – ↕ ↑ ↓` | Relationship to the default branch | //! | 5 | `UPSTREAM_DIVERGENCE` | \| ⇅ ⇡ ⇣ | Relationship to the tracked remote | //! | 6 | `USER_MARKER` | emoji / text | User-defined annotation | @@ -55,12 +55,13 @@ //! //! # Gate 2: Worktree state (position 3) //! -//! **Renders:** at most one of `✘ ↻ ⊟ ⊞ ⚑ /`, priority -//! `✘ > ↻ > ⊟ > ⊞ > ⚑ > /`. The operation family (`✘↻`) comes from live -//! task data; the attribute family (`⊟⊞⚑/`) is metadata, always known. +//! **Renders:** at most one of `✘ ↻ ⊟ ⊞ ⧉ ⚑ /`, priority +//! `✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /`. The operation family (`✘↻`) comes from live +//! task data; the attribute family (`⊟⊞⧉⚑/`) is metadata, always known. //! //! **Inputs:** `data.has_conflicts`, `data.git_operation`, plus metadata -//! (`locked`, `prunable`, `branch_worktree_mismatch`, `ItemKind::Branch`). +//! (`locked`, `prunable`, `duplicate_branch`, `branch_worktree_mismatch`, +//! `ItemKind::Branch`). //! //! **Rule — short-circuit on priority:** a higher-priority signal, once known //! to be positive, resolves the gate immediately without waiting for @@ -72,7 +73,7 @@ //! `↻`, whichever operation it is. //! 3. `has_conflicts == Some(false)` and `git_operation == Some(None)` and //! metadata says prunable → `⊟`. -//! 4. …continuing down through `⊞`, `⚑`, `/`, nothing. +//! 4. …continuing down through `⊞`, `⧉`, `⚑`, `/`, nothing. //! //! Until both `has_conflicts` and `git_operation` are known, we cannot rule //! out `✘/↻`, so the position renders `·` even if metadata would otherwise @@ -264,7 +265,7 @@ impl PositionMask { pub(crate) const STAGED: usize = 0; // + (staged changes) pub(crate) const MODIFIED: usize = 1; // ! (modified files) pub(crate) const UNTRACKED: usize = 2; // ? (untracked files) - pub(crate) const WORKTREE_STATE: usize = 3; // Worktree: ✘↻/⚑⊟⊞ + pub(crate) const WORKTREE_STATE: usize = 3; // Worktree: ✘↻/⚑⧉⊟⊞ pub(crate) const MAIN_STATE: usize = 4; // Main relationship: ^_⊂✗↕↑↓ pub(crate) const UPSTREAM_DIVERGENCE: usize = 5; // Remote: |⇅⇡⇣ pub(crate) const USER_MARKER: usize = 6; @@ -276,7 +277,7 @@ impl PositionMask { 1, // STAGED: + (1 char) 1, // MODIFIED: ! (1 char) 1, // UNTRACKED: ? (1 char) - 1, // WORKTREE_STATE: ✘↻/⊟⊞⚑ (1 char, priority: conflicts > in-progress operation > prunable > locked > branch_worktree_mismatch > branch) + 1, // WORKTREE_STATE: ✘↻/⊟⊞⧉⚑ (1 char, priority: conflicts > in-progress operation > prunable > locked > duplicate_branch > branch_worktree_mismatch > branch) 1, // MAIN_STATE: ^_⊂✗–↕↑↓ (1 char, priority: is_main > orphan > empty > integrated > would_conflict > same_commit > diverged > ahead > behind) 1, // UPSTREAM_DIVERGENCE: |⇡⇣⇅ (1 char) 2, // USER_MARKER: single emoji or two chars (allocate 2) @@ -353,7 +354,7 @@ impl WorkingTreeStatus { /// Symbols are categorized to enable vertical alignment in table output. /// Display order (left to right): /// - Working tree: +, !, ? (staged, modified, untracked - NOT mutually exclusive) -/// - Worktree state: ✘, ↻, /, ⚑, ⊟, ⊞ (operations + location) +/// - Worktree state: ✘, ↻, /, ⚑, ⧉, ⊟, ⊞ (operations + location) /// - Main state: ^, ✗, _, ⊂, ↕, ↑, ↓ (relationship to default branch - single-stroke vertical arrows) /// - Upstream divergence: |, ⇅, ⇡, ⇣ (relationship to remote - vertical arrows) /// - User marker: custom labels, emoji @@ -361,11 +362,12 @@ impl WorkingTreeStatus { /// ## Mutual Exclusivity /// /// **Worktree state (operations take priority over location):** -/// Priority: ✘ > ↻ > ⊟ > ⊞ > ⚑ > / +/// Priority: ✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > / /// - ✘: Actual conflicts (must resolve) /// - ↻: A git operation is in progress (rebase, merge, cherry-pick, revert, bisect) /// - ⊟: Prunable (directory missing) /// - ⊞: Locked worktree +/// - ⧉: Branch checked out in another worktree too /// - ⚑: Branch-worktree mismatch (informational, dim yellow) /// - /: Branch without worktree /// @@ -547,7 +549,7 @@ impl StatusSymbols { }; // Gate 2 — worktree state (position 3). Operation family (`✘↻`) - // takes priority over metadata family (`⚑⊟⊞/`). The gate is + // takes priority over metadata family (`⚑⧉⊟⊞/`). The gate is // `Loading` iff `operation_state` is still `None` — even when // `worktree_state` metadata would yield `⊟`, we cannot safely show // it without ruling out a pending operation signal. Once diff --git a/src/git/mod.rs b/src/git/mod.rs index b3c5328a83..b1744a90be 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -81,8 +81,8 @@ pub use remove::{ pub use repository::sha_cache; pub use repository::{ Branch, BranchDiffSpec, CommitMessageDetail, InProgressOperation, IntegrationTargets, - RefSnapshot, Repository, ResolvedWorktree, TempIndex, WorkingTree, resolve_input_path, - select_comparison_base, set_base_path, + RefSnapshot, Repository, ResolvedWorktree, TempIndex, WorkingTree, duplicated_branches, + resolve_input_path, select_comparison_base, set_base_path, }; pub use url::parse_owner_repo; pub use url::{GitRemoteUrl, GitRepoInfo, GitRepoProvider}; diff --git a/src/git/repository/mod.rs b/src/git/repository/mod.rs index 44c86b61a6..95b9b702f2 100644 --- a/src/git/repository/mod.rs +++ b/src/git/repository/mod.rs @@ -166,6 +166,7 @@ pub use integration::{BranchDiffSpec, IntegrationTargets, select_comparison_base pub use ref_snapshot::RefSnapshot; pub(super) use working_tree::path_to_logging_context; pub use working_tree::{InProgressOperation, TempIndex, WorkingTree}; +pub use worktrees::duplicated_branches; // ============================================================================ // Repository Cache diff --git a/src/git/repository/tests.rs b/src/git/repository/tests.rs index cce5b32ed1..3f20ce1b6c 100644 --- a/src/git/repository/tests.rs +++ b/src/git/repository/tests.rs @@ -1160,10 +1160,11 @@ fn prewarm_still_caches_preload_when_worktree_config_disabled() { #[test] fn test_worktree_paths_for_branch_detects_duplicates() { - use super::worktrees::worktree_paths_for_branch; + use super::worktrees::{duplicated_branches, worktree_paths_for_branch}; // Two worktrees on `feature` — the state `git worktree add --force` // produces. Porcelain retains every entry; only resolution collapses it. + // The detached worktree has no branch to duplicate. let output = "worktree /path/to/main HEAD abcd1234 branch refs/heads/main @@ -1176,6 +1177,10 @@ worktree /path/to/feature-dup HEAD efgh5678 branch refs/heads/feature +worktree /path/to/detached +HEAD efgh5678 +detached + "; let worktrees = WorktreeInfo::parse_porcelain_list(output).unwrap(); @@ -1195,6 +1200,13 @@ branch refs/heads/feature ); // A branch with no worktree yields nothing. assert!(worktree_paths_for_branch(&worktrees, "absent").is_empty()); + + // The set form `wt list` uses to flag rows names only the branch that + // repeats — a single-worktree branch and a detached head can't duplicate. + assert_eq!( + duplicated_branches(&worktrees), + std::collections::HashSet::from(["feature"]) + ); } #[test] diff --git a/src/git/repository/worktrees.rs b/src/git/repository/worktrees.rs index 33a5429dc7..e6707a1f0d 100644 --- a/src/git/repository/worktrees.rs +++ b/src/git/repository/worktrees.rs @@ -312,6 +312,23 @@ pub(crate) fn worktree_paths_for_branch(worktrees: &[WorktreeInfo], branch: &str .collect() } +/// Every branch checked out in more than one worktree. +/// +/// The all-at-once counterpart to `worktree_paths_for_branch`, for callers +/// classifying the whole list rather than resolving one branch — `wt list` +/// flags each affected row with `⧉` so the ambiguity is visible before a +/// command resolves the branch and warns. +pub fn duplicated_branches(worktrees: &[WorktreeInfo]) -> HashSet<&str> { + let mut seen = HashSet::new(); + let mut duplicated = HashSet::new(); + for branch in worktrees.iter().filter_map(|wt| wt.branch.as_deref()) { + if !seen.insert(branch) { + duplicated.insert(branch); + } + } + duplicated +} + /// Warn once per process that `branch` resolves ambiguously across worktrees. /// /// Worktrunk addresses worktrees by branch name and resolves an ambiguous diff --git a/src/md_help.rs b/src/md_help.rs index 6f07f7efe5..6c974bb0f6 100644 --- a/src/md_help.rs +++ b/src/md_help.rs @@ -485,9 +485,11 @@ fn colorize_status_symbols(text: &str) -> String { result = replace_dim(result, "↻", warning); result = replace_dim(result, "✗", warning); - // Worktree state: Prunable/Locked (yellow), BranchWorktreeMismatch (dim yellow) + // Worktree state: Prunable/Locked/DuplicateBranch (yellow), + // BranchWorktreeMismatch (dim yellow) result = replace_dim(result, "⊟", warning); result = replace_dim(result, "⊞", warning); + result = replace_dim(result, "⧉", warning); result = replace_dim(result, "⚑", warning.dimmed()); // CI legend samples: replace dimmed `#` followed by a color name diff --git a/tests/integration_tests/list.rs b/tests/integration_tests/list.rs index 2e308a70ca..430cc56f21 100644 --- a/tests/integration_tests/list.rs +++ b/tests/integration_tests/list.rs @@ -149,6 +149,85 @@ fn test_list_detached_head_in_worktree(mut repo: TestRepo) { assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path())); } +/// Adds a second worktree on `branch` via `git worktree add --force`, which +/// bypasses git's "already used by worktree" guard. Worktrunk never creates +/// this state itself. +fn force_duplicate_worktree(repo: &TestRepo, branch: &str) -> std::path::PathBuf { + let dup_path = repo + .root_path() + .parent() + .unwrap() + .join(format!("repo.{branch}-dup")); + repo.run_git(&[ + "worktree", + "add", + "--force", + dup_path.to_str().unwrap(), + branch, + ]); + dup_path +} + +/// A branch checked out in two worktrees flags both rows with `⧉`, and the +/// Path column earns its place: the branch name no longer identifies the row. +/// The duplicate also sits off-template, so its row exercises `⧉` outranking +/// the `⚑` mismatch flag. +#[rstest] +fn test_list_duplicate_branch(mut repo: TestRepo) { + repo.add_worktree("feature"); + force_duplicate_worktree(&repo, "feature"); + + assert_cmd_snapshot!(list_snapshots::command(&repo, repo.root_path())); +} + +/// Both JSON schemas report the duplicate: schema 1 through the single +/// `worktree.state` value, schema 2 through its own orthogonal flag. +#[rstest] +fn test_list_duplicate_branch_json(mut repo: TestRepo) { + repo.add_worktree("feature"); + force_duplicate_worktree(&repo, "feature"); + + repo.write_test_config("[list]\njson-schema = 1\n"); + let output = repo + .wt_command() + .args(["list", "--format=json"]) + .output() + .unwrap(); + assert!(output.status.success()); + let items: Vec = serde_json::from_slice(&output.stdout).unwrap(); + let flagged: Vec<_> = items + .iter() + .filter(|item| item["worktree"]["state"] == "duplicate_branch") + .collect(); + assert_eq!(flagged.len(), 2, "both rows carry the state: {items:#?}"); + assert!( + flagged.iter().all(|item| item["branch"] == "feature"), + "only the duplicated branch is flagged: {items:#?}" + ); + + repo.write_test_config("[list]\njson-schema = 2\n"); + let output = repo + .wt_command() + .args(["list", "--format=json"]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let items = json["items"].as_array().unwrap(); + let flagged: Vec<_> = items + .iter() + .filter(|item| item["worktree"]["duplicate_branch"] == true) + .collect(); + assert_eq!(flagged.len(), 2, "both rows carry the flag: {items:#?}"); + assert!( + items + .iter() + .filter(|item| item["branch"] == "main") + .all(|item| item["worktree"]["duplicate_branch"] == false), + "the unduplicated branch stays clear: {items:#?}" + ); +} + #[rstest] fn test_list_locked_worktree(mut repo: TestRepo) { repo.add_worktree("locked-feature"); diff --git a/tests/snapshots/integration__integration_tests__help__help_list_long.snap b/tests/snapshots/integration__integration_tests__help__help_list_long.snap index 2dabb4febd..415717eda6 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_long.snap @@ -8,6 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -219,16 +220,17 @@ Independent flags from git status; several can show at once (e.g. +! Worktree -An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (✘ > ↻ > ⊟ > ⊞ > ⚑ > /): +An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /): - Symbol JSON Meaning - ────── ──────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────── - ✘ operation_state "conflicts" Merge conflicts - ↻ operation_state "rebase", "merge", "cherry_pick", "revert", "bisect" A git operation is in progress; git status names it - ⊟ worktree.state "prunable" Prunable (worktree directory missing) - ⊞ worktree.state "locked" Locked worktree - ⚑ worktree.state "branch_worktree_mismatch" Branch name doesn't match the worktree path - / kind "branch" Branch without a worktree (no worktree object) + Symbol JSON Meaning + ────── ──────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ✘ operation_state "conflicts" Merge conflicts + ↻ operation_state "rebase", "merge", "cherry_pick", "revert", "bisect" A git operation is in progress; git status names it + ⊟ worktree.state "prunable" Prunable (worktree directory missing) + ⊞ worktree.state "locked" Locked worktree + ⧉ worktree.state "duplicate_branch" Branch checked out in more than one worktree; wt resolves it to whichever git lists first, and every affected row is flagged + ⚑ worktree.state "branch_worktree_mismatch" Branch name doesn't match the worktree path + / kind "branch" Branch without a worktree (no worktree object) Default branch @@ -297,6 +299,7 @@ One envelope object. Items carry independent facts; rendered strings    "committed_at": "2025-01-01T08:00:00Z"},    "worktree": {"path": "/home/user/repo.feature", "main": false, "current": true,    "previous": false, "detached": false, "branch_mismatch": false, +   "duplicate_branch": false,    "changes": {"staged": false, "modified": true, "untracked": false,    "renamed": false, "deleted": false, "conflicted": false,    "diff": {"added": 10, "deleted": 2}}}, @@ -322,20 +325,20 @@ no null checks; has() distinguishes the two when it matters. Item fields: - Field Description - ────────────── ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - branch Branch name; null for a detached-HEAD worktree. Remote rows carry the bare name with the remote in remote - remote Remote name, present only on remote-only branch rows - head {sha, short_sha, subject, committed_at}; null for unborn branches. committed_at is RFC 3339 UTC - worktree {path, main, current, previous, detached, locked, prunable, branch_mismatch, operation, changes}; absent on branch-only rows. locked/prunable are {reason} objects and can co-occur; operation is "rebase" or "merge"; changes holds the five working-tree flags plus conflicted and diff {added, deleted} - default_branch Relation to the default branch: {ahead, behind, diff, orphan, integration, merge_conflicts}; absent on the default branch itself. integration.reason is one of same_commit, ancestor, no_added_changes, trees_match, merge_adds_nothing, patch_id_match; a dirty tree skips the checks, leaving integration null - upstream Tracking branch: {remote, branch, ahead, behind}; absent when none is configured - pr Open PR/MR: {number, url, review, mergeable, repo}; collected with --full or a listed ci column. review uses the schema 1 ci.review_state vocabulary; mergeable is false when the forge reports conflicts, null otherwise - checks CI pipeline: {status, source, stale}; status is passed, running, or failed — null when a conflicts report masks it - dev_server {url, listening} from the project's list.url template - summary LLM branch summary (requires [list] summary = true) - vars Per-branch variables from wt config state vars - display Rendered strings: state (schema 1's main_state vocabulary), symbols, statusline (with ANSI colors and OSC 8 hyperlinks), columns (custom-column cells keyed by header) + Field Description + ────────────── ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + branch Branch name; null for a detached-HEAD worktree. Remote rows carry the bare name with the remote in remote + remote Remote name, present only on remote-only branch rows + head {sha, short_sha, subject, committed_at}; null for unborn branches. committed_at is RFC 3339 UTC + worktree {path, main, current, previous, detached, locked, prunable, branch_mismatch, duplicate_branch, operation, changes}; absent on branch-only rows. locked/prunable are {reason} objects and can co-occur; operation is "rebase" or "merge"; changes holds the five working-tree flags plus conflicted and diff {added, deleted} + default_branch Relation to the default branch: {ahead, behind, diff, orphan, integration, merge_conflicts}; absent on the default branch itself. integration.reason is one of same_commit, ancestor, no_added_changes, trees_match, merge_adds_nothing, patch_id_match; a dirty tree skips the checks, leaving integration null + upstream Tracking branch: {remote, branch, ahead, behind}; absent when none is configured + pr Open PR/MR: {number, url, review, mergeable, repo}; collected with --full or a listed ci column. review uses the schema 1 ci.review_state vocabulary; mergeable is false when the forge reports conflicts, null otherwise + checks CI pipeline: {status, source, stale}; status is passed, running, or failed — null when a conflicts report masks it + dev_server {url, listening} from the project's list.url template + summary LLM branch summary (requires [list] summary = true) + vars Per-branch variables from wt config state vars + display Rendered strings: state (schema 1's main_state vocabulary), symbols, statusline (with ANSI colors and OSC 8 hyperlinks), columns (custom-column cells keyed by header) Schema 1 names map directly: commit → head, working_tree → worktree.changes, main + main_state → default_branch + @@ -457,11 +460,11 @@ The five change flags map to the Working tree symbols (renamed and d Present only for worktree-kind items. state is the worktree-location attribute — see Worktree for its symbols: - Field Type Description - ──────── ─────── ──────────────────────────────────────────────────────────────────────── - state string "branch_worktree_mismatch", "prunable", or "locked" (absent when normal) - reason string Reason for locked/prunable state - detached boolean HEAD is detached + Field Type Description + ──────── ─────── ──────────────────────────────────────────────────────────────────────────────────────────── + state string "branch_worktree_mismatch", "duplicate_branch", "prunable", or "locked" (absent when normal) + reason string Reason for locked/prunable state + detached boolean HEAD is detached ci object diff --git a/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap b/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap index cda40fa154..14fa5cc46f 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap @@ -8,6 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "80" + GIT_ALLOW_PROTOCOL: file LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -269,20 +270,25 @@ maps to a boolean in the working_tree object: Worktree An in-progress git operation, a worktree-location attribute, or a branch with no - worktree. One symbol shows, highest priority first (✘ > ↻ > ⊟ > ⊞ > ⚑ > /): - - Symbol JSON Meaning - ────── ───────────────────────────── ───────────────────────────────────────── - ✘ operation_state "conflicts" Merge conflicts - ↻ operation_state "rebase", A git operation is in progress; - "merge", "cherry_pick", git status names it - "revert", "bisect" - ⊟ worktree.state "prunable" Prunable (worktree directory missing) - ⊞ worktree.state "locked" Locked worktree - ⚑ worktree.state Branch name doesn't match the worktree - "branch_worktree_mismatch" path - / kind "branch" Branch without a worktree (no worktree - object) + worktree. One symbol shows, highest priority first (✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /): + + Symb JSON Meaning + ol + ──── ────────────────────────────── ────────────────────────────────────────── + ✘ operation_state "conflicts" Merge conflicts + ↻ operation_state "rebase", A git operation is in progress; git status + "merge", "cherry_pick", names it + "revert", "bisect" + ⊟ worktree.state "prunable" Prunable (worktree directory missing) + ⊞ worktree.state "locked" Locked worktree + ⧉ worktree.state Branch checked out in more than one + "duplicate_branch" worktree; wt resolves it to whichever git + lists first, and every affected row is + flagged + ⚑ worktree.state Branch name doesn't match the worktree + "branch_worktree_mismatch" path + / kind "branch" Branch without a worktree (no worktree + object) Default branch @@ -363,6 +369,7 @@ One envelope object. Items carry independent facts; rendered strings    "worktree": {"path": "/home/user/repo.feature", "main": false, "current":   true,    "previous": false, "detached": false, "branch_mismatch": false, +   "duplicate_branch": false,    "changes": {"staged": false, "modified": true, "untracked": false,    "renamed": false, "deleted": false, "conflicted": false,    "diff": {"added": 10, "deleted": 2}}}, @@ -398,10 +405,10 @@ Item fields: head {sha, short_sha, subject, committed_at}; null for unborn branches. committed_at is RFC 3339 UTC worktre {path, main, current, previous, detached, locked, prunable, branch_mis - e match, operation, changes}; absent on branch-only rows. locked/ - prunable are {reason} objects and can co-occur; operation is "rebase" - or "merge"; changes holds the five working-tree flags plus conflicted - and diff {added, deleted} + e match, duplicate_branch, operation, changes}; absent on branch-only + rows. locked/prunable are {reason} objects and can co-occur; operation + is "rebase" or "merge"; changes holds the five working-tree flags plus + conflicted and diff {added, deleted} default Relation to the default branch: _branch {ahead, behind, diff, orphan, integration, merge_conflicts}; absent on the default branch itself. integration.reason is one of same_commit, @@ -568,8 +575,8 @@ Present only for worktree-kind items. state is the worktree-location att Field Type Description ──────── ─────── ───────────────────────────────────────────────────────────── - state string "branch_worktree_mismatch", "prunable", or "locked" (absent - when normal) + state string "branch_worktree_mismatch", "duplicate_branch", "prunable", + or "locked" (absent when normal) reason string Reason for locked/prunable state detached boolean HEAD is detached diff --git a/tests/snapshots/integration__integration_tests__list__list_duplicate_branch.snap b/tests/snapshots/integration__integration_tests__list__list_duplicate_branch.snap new file mode 100644 index 0000000000..d7528e5038 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__list__list_duplicate_branch.snap @@ -0,0 +1,59 @@ +--- +source: tests/integration_tests/list.rs +info: + program: wt + args: + - list + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file + 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 ----- + Branch Status HEAD± main↕ main…± Remote⇅ Path Commit Age Message +@ main ^| | . 05a4a45d 16h Initial commit ++ feature ⧉_ ../repo.feature 05a4a45d 16h Initial commit ++ feature-a ↑ ↑1 +1 ../repo.feature-a 1b87d473 16h Add feature-a file ++ feature-b ↑ ↑1 +1 ../repo.feature-b f62940fc 16h Add feature-b file ++ feature-c ↑ ↑1 +1 ../repo.feature-c 345c7c93 16h Add feature-c file ++ feature ⧉_ ../repo.feature-dup 05a4a45d 16h Initial commit + +----- stderr ----- + +○ Showing 6 worktrees, 3 ahead diff --git a/tests/snapshots/integration__integration_tests__list__list_json_schema_2_envelope.snap b/tests/snapshots/integration__integration_tests__list__list_json_schema_2_envelope.snap index e1dfd5593a..f32bd1a3ac 100644 --- a/tests/snapshots/integration__integration_tests__list__list_json_schema_2_envelope.snap +++ b/tests/snapshots/integration__integration_tests__list__list_json_schema_2_envelope.snap @@ -10,6 +10,7 @@ info: CLAUDE_CONFIG_DIR: "[TEST_CLAUDE_CONFIG]" CLICOLOR_FORCE: "1" COLUMNS: "500" + GIT_ALLOW_PROTOCOL: file GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" @@ -36,6 +37,7 @@ info: 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" @@ -70,6 +72,7 @@ exit_code: 0 "previous": false, "detached": false, "branch_mismatch": false, + "duplicate_branch": false, "changes": { "staged": false, "modified": false, @@ -110,6 +113,7 @@ exit_code: 0 "previous": false, "detached": false, "branch_mismatch": false, + "duplicate_branch": false, "changes": { "staged": false, "modified": false, @@ -154,6 +158,7 @@ exit_code: 0 "previous": false, "detached": false, "branch_mismatch": false, + "duplicate_branch": false, "changes": { "staged": false, "modified": false, @@ -198,6 +203,7 @@ exit_code: 0 "previous": false, "detached": false, "branch_mismatch": false, + "duplicate_branch": false, "changes": { "staged": false, "modified": false, @@ -242,6 +248,7 @@ exit_code: 0 "previous": false, "detached": false, "branch_mismatch": false, + "duplicate_branch": false, "changes": { "staged": false, "modified": false, @@ -292,6 +299,7 @@ exit_code: 0 "reason": "Testing" }, "branch_mismatch": false, + "duplicate_branch": false, "changes": { "staged": false, "modified": false, From a89cb580cbe9b86461dd497f79180aa945460261 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sat, 25 Jul 2026 15:06:28 -0700 Subject: [PATCH 2/2] =?UTF-8?q?refactor(list):=20fold=20the=20duplicate-br?= =?UTF-8?q?anch=20flag=20into=20the=20existing=20=E2=9A=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seventh glyph in the worktree-state position bought little: the Status column is a dense alphabet the reader has to learn, and a duplicated branch is rare. Both states say one thing — this worktree's place in the branch ⇔ worktree map is irregular — and the table already tells them apart, since a repeated Branch cell is the duplicate and an odd Path cell the mismatch. `⚑` now covers both, at its existing dim-yellow styling (a symbol's color is part of its identity, so sharing the glyph means sharing the color). The worktree-state alphabet is back to six. The data model keeps the distinction: JSON has no cardinality budget, and reporting a duplicate sitting at the template path as `branch_worktree_mismatch` would be false — its path does match. Schema 1's `worktree.state` still names the cause, schema 2 keeps its own `duplicate_branch` bool, and the priority between the two ⚑ states now decides only which cause JSON reports. Co-Authored-By: Claude Opus 5 (1M context) --- docs/content/list.md | 4 +-- .../skills/worktrunk/reference/list.md | 4 +-- skills/worktrunk/reference/list.md | 4 +-- src/cli/mod.rs | 4 +-- src/commands/list/model/item.rs | 11 +++--- src/commands/list/model/state.rs | 13 +++++-- src/commands/list/model/status_symbols.rs | 35 ++++++++++--------- src/git/repository/worktrees.rs | 2 +- src/md_help.rs | 5 ++- tests/integration_tests/list.rs | 9 ++--- ...tegration_tests__help__help_list_long.snap | 22 ++++++------ ...tion_tests__help__help_list_narrow_80.snap | 10 +++--- ...on_tests__list__list_duplicate_branch.snap | 4 +-- 13 files changed, 70 insertions(+), 57 deletions(-) diff --git a/docs/content/list.md b/docs/content/list.md index 1c1252fa62..f594244e3b 100644 --- a/docs/content/list.md +++ b/docs/content/list.md @@ -164,7 +164,7 @@ Independent flags from `git status`; several can show at once (e.g. `+!?`). Each ### Worktree -An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /`): +An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⚑ > /`): | Symbol | JSON | Meaning | |--------|------|---------| @@ -172,7 +172,7 @@ An in-progress git operation, a worktree-location attribute, or a branch with no | `↻` | `operation_state` `"rebase"`, `"merge"`, `"cherry_pick"`, `"revert"`, `"bisect"` | A git operation is in progress; `git status` names it | | `⊟` | `worktree.state` `"prunable"` | Prunable (worktree directory missing) | | `⊞` | `worktree.state` `"locked"` | Locked worktree | -| `⧉` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree; `wt` resolves it to whichever git lists first, and every affected row is flagged | +| `⚑` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree, so `wt` resolves it to whichever git lists first; every worktree on the branch is flagged | | `⚑` | `worktree.state` `"branch_worktree_mismatch"` | Branch name doesn't match the worktree path | | `/` | `kind` `"branch"` | Branch without a worktree (no `worktree` object) | diff --git a/plugins/worktrunk/skills/worktrunk/reference/list.md b/plugins/worktrunk/skills/worktrunk/reference/list.md index a3a1c238a7..377a67f32a 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/list.md +++ b/plugins/worktrunk/skills/worktrunk/reference/list.md @@ -149,7 +149,7 @@ Independent flags from `git status`; several can show at once (e.g. `+!?`). Each ### Worktree -An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /`): +An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⚑ > /`): | Symbol | JSON | Meaning | |--------|------|---------| @@ -157,7 +157,7 @@ An in-progress git operation, a worktree-location attribute, or a branch with no | `↻` | `operation_state` `"rebase"`, `"merge"`, `"cherry_pick"`, `"revert"`, `"bisect"` | A git operation is in progress; `git status` names it | | `⊟` | `worktree.state` `"prunable"` | Prunable (worktree directory missing) | | `⊞` | `worktree.state` `"locked"` | Locked worktree | -| `⧉` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree; `wt` resolves it to whichever git lists first, and every affected row is flagged | +| `⚑` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree, so `wt` resolves it to whichever git lists first; every worktree on the branch is flagged | | `⚑` | `worktree.state` `"branch_worktree_mismatch"` | Branch name doesn't match the worktree path | | `/` | `kind` `"branch"` | Branch without a worktree (no `worktree` object) | diff --git a/skills/worktrunk/reference/list.md b/skills/worktrunk/reference/list.md index a3a1c238a7..377a67f32a 100644 --- a/skills/worktrunk/reference/list.md +++ b/skills/worktrunk/reference/list.md @@ -149,7 +149,7 @@ Independent flags from `git status`; several can show at once (e.g. `+!?`). Each ### Worktree -An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /`): +An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⚑ > /`): | Symbol | JSON | Meaning | |--------|------|---------| @@ -157,7 +157,7 @@ An in-progress git operation, a worktree-location attribute, or a branch with no | `↻` | `operation_state` `"rebase"`, `"merge"`, `"cherry_pick"`, `"revert"`, `"bisect"` | A git operation is in progress; `git status` names it | | `⊟` | `worktree.state` `"prunable"` | Prunable (worktree directory missing) | | `⊞` | `worktree.state` `"locked"` | Locked worktree | -| `⧉` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree; `wt` resolves it to whichever git lists first, and every affected row is flagged | +| `⚑` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree, so `wt` resolves it to whichever git lists first; every worktree on the branch is flagged | | `⚑` | `worktree.state` `"branch_worktree_mismatch"` | Branch name doesn't match the worktree path | | `/` | `kind` `"branch"` | Branch without a worktree (no `worktree` object) | diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 26510cd21d..67aba58593 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -905,7 +905,7 @@ Independent flags from `git status`; several can show at once (e.g. `+!?`). Each ### Worktree -An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /`): +An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (`✘ > ↻ > ⊟ > ⊞ > ⚑ > /`): | Symbol | JSON | Meaning | |--------|------|---------| @@ -913,7 +913,7 @@ An in-progress git operation, a worktree-location attribute, or a branch with no | `↻` | `operation_state` `"rebase"`, `"merge"`, `"cherry_pick"`, `"revert"`, `"bisect"` | A git operation is in progress; `git status` names it | | `⊟` | `worktree.state` `"prunable"` | Prunable (worktree directory missing) | | `⊞` | `worktree.state` `"locked"` | Locked worktree | -| `⧉` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree; `wt` resolves it to whichever git lists first, and every affected row is flagged | +| `⚑` | `worktree.state` `"duplicate_branch"` | Branch checked out in more than one worktree, so `wt` resolves it to whichever git lists first; every worktree on the branch is flagged | | `⚑` | `worktree.state` `"branch_worktree_mismatch"` | Branch name doesn't match the worktree path | | `/` | `kind` `"branch"` | Branch without a worktree (no `worktree` object) | diff --git a/src/commands/list/model/item.rs b/src/commands/list/model/item.rs index 8b7ed5404a..ec81381447 100644 --- a/src/commands/list/model/item.rs +++ b/src/commands/list/model/item.rs @@ -23,7 +23,10 @@ use crate::commands::list::layout::format_url_cell; /// (Gate 2) from metadata alone. The decision priority is: /// `prunable` > `locked` > `duplicate_branch` > `branch_worktree_mismatch` > /// `None` — the yellow actionable states outrank the informational (dim -/// yellow) mismatch flag. +/// yellow) `⚑`. The last two both render `⚑`, so their order decides only +/// which cause the JSON `worktree.state` names; a duplicate wins because a +/// force-added worktree lands off-template as a side effect of being +/// force-added, not as the fact worth reporting. fn metadata_worktree_state(data: &WorktreeData) -> WorktreeState { if data.is_prunable() { WorktreeState::Prunable @@ -863,9 +866,9 @@ mod tests { use super::*; /// The yellow actionable states outrank the informational (dim yellow) - /// mismatch flag, so a demoted `⚑` can never mask `⊟`, `⊞`, or `⧉`. - /// A force-added duplicate usually sits off-template too, so the two - /// flags routinely co-occur and the ordering decides which one shows. + /// `⚑`, so a demoted flag can never mask `⊟` or `⊞`. A force-added + /// duplicate lands off-template too, so the two `⚑` states routinely + /// co-occur and their order picks the cause the JSON reports. #[test] fn test_metadata_worktree_state_priority() { let mismatched = WorktreeData { diff --git a/src/commands/list/model/state.rs b/src/commands/list/model/state.rs index eb1d5b5714..9d404de16a 100644 --- a/src/commands/list/model/state.rs +++ b/src/commands/list/model/state.rs @@ -76,6 +76,13 @@ impl Divergence { /// /// Priority order for worktrees: Prunable > Locked > DuplicateBranch > /// BranchWorktreeMismatch +/// +/// `DuplicateBranch` and `BranchWorktreeMismatch` share the `⚑` glyph: both +/// say this worktree's place in the branch ⇔ worktree map is irregular, and +/// the table already distinguishes them — a repeated Branch cell is the +/// duplicate, an off-template Path cell the mismatch. The variants stay +/// separate because the JSON `worktree.state` names the cause, where there +/// is no glyph budget to spend. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, strum::IntoStaticStr)] pub enum WorktreeState { #[strum(serialize = "")] @@ -98,8 +105,7 @@ impl std::fmt::Display for WorktreeState { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Self::None => Ok(()), - Self::BranchWorktreeMismatch => write!(f, "⚑"), - Self::DuplicateBranch => write!(f, "⧉"), + Self::BranchWorktreeMismatch | Self::DuplicateBranch => write!(f, "⚑"), Self::Prunable => write!(f, "⊟"), Self::Locked => write!(f, "⊞"), Self::Branch => write!(f, "/"), @@ -524,7 +530,8 @@ mod tests { fn test_worktree_state_display() { assert_eq!(format!("{}", WorktreeState::None), ""); assert_eq!(format!("{}", WorktreeState::BranchWorktreeMismatch), "⚑"); - assert_eq!(format!("{}", WorktreeState::DuplicateBranch), "⧉"); + // Shares the mismatch glyph; the JSON state names which cause it was. + assert_eq!(format!("{}", WorktreeState::DuplicateBranch), "⚑"); assert_eq!(format!("{}", WorktreeState::Prunable), "⊟"); assert_eq!(format!("{}", WorktreeState::Locked), "⊞"); assert_eq!(format!("{}", WorktreeState::Branch), "/"); diff --git a/src/commands/list/model/status_symbols.rs b/src/commands/list/model/status_symbols.rs index 025c21fa87..a53065efe0 100644 --- a/src/commands/list/model/status_symbols.rs +++ b/src/commands/list/model/status_symbols.rs @@ -22,7 +22,7 @@ //! | 0 | `STAGED` | `+` | Are there staged changes? | //! | 1 | `MODIFIED` | `!` | Are there unstaged modifications? | //! | 2 | `UNTRACKED` | `?` | Are there untracked files? | -//! | 3 | `WORKTREE_STATE` | `✘ ↻ ⊟ ⊞ ⧉ ⚑ /` | Operation / worktree attribute | +//! | 3 | `WORKTREE_STATE` | `✘ ↻ ⊟ ⊞ ⚑ /` | Operation / worktree attribute | //! | 4 | `MAIN_STATE` | `^ _ ⊂ ✗ – ↕ ↑ ↓` | Relationship to the default branch | //! | 5 | `UPSTREAM_DIVERGENCE` | \| ⇅ ⇡ ⇣ | Relationship to the tracked remote | //! | 6 | `USER_MARKER` | emoji / text | User-defined annotation | @@ -55,9 +55,11 @@ //! //! # Gate 2: Worktree state (position 3) //! -//! **Renders:** at most one of `✘ ↻ ⊟ ⊞ ⧉ ⚑ /`, priority -//! `✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /`. The operation family (`✘↻`) comes from live -//! task data; the attribute family (`⊟⊞⧉⚑/`) is metadata, always known. +//! **Renders:** at most one of `✘ ↻ ⊟ ⊞ ⚑ /`, priority +//! `✘ > ↻ > ⊟ > ⊞ > ⚑ > /`. The operation family (`✘↻`) comes from live +//! task data; the attribute family (`⊟⊞⚑/`) is metadata, always known. +//! `⚑` covers both irregular-mapping states — a duplicated branch outranks +//! an off-template path, and the JSON `worktree.state` names which. //! //! **Inputs:** `data.has_conflicts`, `data.git_operation`, plus metadata //! (`locked`, `prunable`, `duplicate_branch`, `branch_worktree_mismatch`, @@ -73,7 +75,7 @@ //! `↻`, whichever operation it is. //! 3. `has_conflicts == Some(false)` and `git_operation == Some(None)` and //! metadata says prunable → `⊟`. -//! 4. …continuing down through `⊞`, `⧉`, `⚑`, `/`, nothing. +//! 4. …continuing down through `⊞`, `⚑`, `/`, nothing. //! //! Until both `has_conflicts` and `git_operation` are known, we cannot rule //! out `✘/↻`, so the position renders `·` even if metadata would otherwise @@ -265,7 +267,7 @@ impl PositionMask { pub(crate) const STAGED: usize = 0; // + (staged changes) pub(crate) const MODIFIED: usize = 1; // ! (modified files) pub(crate) const UNTRACKED: usize = 2; // ? (untracked files) - pub(crate) const WORKTREE_STATE: usize = 3; // Worktree: ✘↻/⚑⧉⊟⊞ + pub(crate) const WORKTREE_STATE: usize = 3; // Worktree: ✘↻/⚑⊟⊞ pub(crate) const MAIN_STATE: usize = 4; // Main relationship: ^_⊂✗↕↑↓ pub(crate) const UPSTREAM_DIVERGENCE: usize = 5; // Remote: |⇅⇡⇣ pub(crate) const USER_MARKER: usize = 6; @@ -277,7 +279,7 @@ impl PositionMask { 1, // STAGED: + (1 char) 1, // MODIFIED: ! (1 char) 1, // UNTRACKED: ? (1 char) - 1, // WORKTREE_STATE: ✘↻/⊟⊞⧉⚑ (1 char, priority: conflicts > in-progress operation > prunable > locked > duplicate_branch > branch_worktree_mismatch > branch) + 1, // WORKTREE_STATE: ✘↻/⊟⊞⚑ (1 char, priority: conflicts > in-progress operation > prunable > locked > duplicate_branch > branch_worktree_mismatch > branch) 1, // MAIN_STATE: ^_⊂✗–↕↑↓ (1 char, priority: is_main > orphan > empty > integrated > would_conflict > same_commit > diverged > ahead > behind) 1, // UPSTREAM_DIVERGENCE: |⇡⇣⇅ (1 char) 2, // USER_MARKER: single emoji or two chars (allocate 2) @@ -354,7 +356,7 @@ impl WorkingTreeStatus { /// Symbols are categorized to enable vertical alignment in table output. /// Display order (left to right): /// - Working tree: +, !, ? (staged, modified, untracked - NOT mutually exclusive) -/// - Worktree state: ✘, ↻, /, ⚑, ⧉, ⊟, ⊞ (operations + location) +/// - Worktree state: ✘, ↻, /, ⚑, ⊟, ⊞ (operations + location) /// - Main state: ^, ✗, _, ⊂, ↕, ↑, ↓ (relationship to default branch - single-stroke vertical arrows) /// - Upstream divergence: |, ⇅, ⇡, ⇣ (relationship to remote - vertical arrows) /// - User marker: custom labels, emoji @@ -362,13 +364,14 @@ impl WorkingTreeStatus { /// ## Mutual Exclusivity /// /// **Worktree state (operations take priority over location):** -/// Priority: ✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > / +/// Priority: ✘ > ↻ > ⊟ > ⊞ > ⚑ > / /// - ✘: Actual conflicts (must resolve) /// - ↻: A git operation is in progress (rebase, merge, cherry-pick, revert, bisect) /// - ⊟: Prunable (directory missing) /// - ⊞: Locked worktree -/// - ⧉: Branch checked out in another worktree too -/// - ⚑: Branch-worktree mismatch (informational, dim yellow) +/// - ⚑: Irregular branch ⇔ worktree mapping — the branch is checked out in +/// more than one worktree, or the path is off-template (informational, dim +/// yellow) /// - /: Branch without worktree /// /// **Main state (single position with priority):** @@ -549,7 +552,7 @@ impl StatusSymbols { }; // Gate 2 — worktree state (position 3). Operation family (`✘↻`) - // takes priority over metadata family (`⚑⧉⊟⊞/`). The gate is + // takes priority over metadata family (`⚑⊟⊞/`). The gate is // `Loading` iff `operation_state` is still `None` — even when // `worktree_state` metadata would yield `⊟`, we cannot safely show // it without ruling out a pending operation signal. Once @@ -566,10 +569,10 @@ impl StatusSymbols { Some(WorktreeState::Branch) => { SlotState::Visible(cformat!("{}", WorktreeState::Branch)) } - Some(WorktreeState::BranchWorktreeMismatch) => SlotState::Visible(cformat!( - "{}", - WorktreeState::BranchWorktreeMismatch - )), + Some( + state + @ (WorktreeState::BranchWorktreeMismatch | WorktreeState::DuplicateBranch), + ) => SlotState::Visible(cformat!("{state}")), Some(other) => SlotState::Visible(cformat!("{}", other)), }, }; diff --git a/src/git/repository/worktrees.rs b/src/git/repository/worktrees.rs index e6707a1f0d..b79f08e9fa 100644 --- a/src/git/repository/worktrees.rs +++ b/src/git/repository/worktrees.rs @@ -316,7 +316,7 @@ pub(crate) fn worktree_paths_for_branch(worktrees: &[WorktreeInfo], branch: &str /// /// The all-at-once counterpart to `worktree_paths_for_branch`, for callers /// classifying the whole list rather than resolving one branch — `wt list` -/// flags each affected row with `⧉` so the ambiguity is visible before a +/// flags each affected row with `⚑` so the ambiguity is visible before a /// command resolves the branch and warns. pub fn duplicated_branches(worktrees: &[WorktreeInfo]) -> HashSet<&str> { let mut seen = HashSet::new(); diff --git a/src/md_help.rs b/src/md_help.rs index 6c974bb0f6..75a40fe81e 100644 --- a/src/md_help.rs +++ b/src/md_help.rs @@ -485,11 +485,10 @@ fn colorize_status_symbols(text: &str) -> String { result = replace_dim(result, "↻", warning); result = replace_dim(result, "✗", warning); - // Worktree state: Prunable/Locked/DuplicateBranch (yellow), - // BranchWorktreeMismatch (dim yellow) + // Worktree state: Prunable/Locked (yellow), the irregular-mapping flag + // (dim yellow) result = replace_dim(result, "⊟", warning); result = replace_dim(result, "⊞", warning); - result = replace_dim(result, "⧉", warning); result = replace_dim(result, "⚑", warning.dimmed()); // CI legend samples: replace dimmed `#` followed by a color name diff --git a/tests/integration_tests/list.rs b/tests/integration_tests/list.rs index 430cc56f21..313b951164 100644 --- a/tests/integration_tests/list.rs +++ b/tests/integration_tests/list.rs @@ -168,10 +168,11 @@ fn force_duplicate_worktree(repo: &TestRepo, branch: &str) -> std::path::PathBuf dup_path } -/// A branch checked out in two worktrees flags both rows with `⧉`, and the -/// Path column earns its place: the branch name no longer identifies the row. -/// The duplicate also sits off-template, so its row exercises `⧉` outranking -/// the `⚑` mismatch flag. +/// A branch checked out in two worktrees flags both rows with `⚑`, the same +/// irregular-mapping flag an off-template path earns, and the Path column +/// earns its place: the branch name no longer identifies the row. Before +/// this, only the off-template duplicate was flagged, and the worktree `wt` +/// actually resolves to showed nothing. #[rstest] fn test_list_duplicate_branch(mut repo: TestRepo) { repo.add_worktree("feature"); diff --git a/tests/snapshots/integration__integration_tests__help__help_list_long.snap b/tests/snapshots/integration__integration_tests__help__help_list_long.snap index 415717eda6..bf8eeaaa3e 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_long.snap @@ -220,17 +220,17 @@ Independent flags from git status; several can show at once (e.g. +! Worktree -An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /): - - Symbol JSON Meaning - ────── ──────────────────────────────────────────────────────────────────── ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - ✘ operation_state "conflicts" Merge conflicts - ↻ operation_state "rebase", "merge", "cherry_pick", "revert", "bisect" A git operation is in progress; git status names it - ⊟ worktree.state "prunable" Prunable (worktree directory missing) - ⊞ worktree.state "locked" Locked worktree - ⧉ worktree.state "duplicate_branch" Branch checked out in more than one worktree; wt resolves it to whichever git lists first, and every affected row is flagged - ⚑ worktree.state "branch_worktree_mismatch" Branch name doesn't match the worktree path - / kind "branch" Branch without a worktree (no worktree object) +An in-progress git operation, a worktree-location attribute, or a branch with no worktree. One symbol shows, highest priority first (✘ > ↻ > ⊟ > ⊞ > ⚑ > /): + + Symbol JSON Meaning + ────── ──────────────────────────────────────────────────────────────────── ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ✘ operation_state "conflicts" Merge conflicts + ↻ operation_state "rebase", "merge", "cherry_pick", "revert", "bisect" A git operation is in progress; git status names it + ⊟ worktree.state "prunable" Prunable (worktree directory missing) + ⊞ worktree.state "locked" Locked worktree + ⚑ worktree.state "duplicate_branch" Branch checked out in more than one worktree, so wt resolves it to whichever git lists first; every worktree on the branch is flagged + ⚑ worktree.state "branch_worktree_mismatch" Branch name doesn't match the worktree path + / kind "branch" Branch without a worktree (no worktree object) Default branch diff --git a/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap b/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap index 14fa5cc46f..ef23f2312e 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap @@ -270,7 +270,7 @@ maps to a boolean in the working_tree object: Worktree An in-progress git operation, a worktree-location attribute, or a branch with no - worktree. One symbol shows, highest priority first (✘ > ↻ > ⊟ > ⊞ > ⧉ > ⚑ > /): + worktree. One symbol shows, highest priority first (✘ > ↻ > ⊟ > ⊞ > ⚑ > /): Symb JSON Meaning ol @@ -281,10 +281,10 @@ An in-progress git operation, a worktree-location attribute, or a branch with no "revert", "bisect" ⊟ worktree.state "prunable" Prunable (worktree directory missing) ⊞ worktree.state "locked" Locked worktree - ⧉ worktree.state Branch checked out in more than one - "duplicate_branch" worktree; wt resolves it to whichever git - lists first, and every affected row is - flagged + ⚑ worktree.state Branch checked out in more than one + "duplicate_branch" worktree, so wt resolves it to whichever + git lists first; every worktree on the + branch is flagged ⚑ worktree.state Branch name doesn't match the worktree "branch_worktree_mismatch" path / kind "branch" Branch without a worktree (no worktree diff --git a/tests/snapshots/integration__integration_tests__list__list_duplicate_branch.snap b/tests/snapshots/integration__integration_tests__list__list_duplicate_branch.snap index d7528e5038..651baae20b 100644 --- a/tests/snapshots/integration__integration_tests__list__list_duplicate_branch.snap +++ b/tests/snapshots/integration__integration_tests__list__list_duplicate_branch.snap @@ -48,11 +48,11 @@ exit_code: 0 ----- stdout ----- Branch Status HEAD± main↕ main…± Remote⇅ Path Commit Age Message @ main ^| | . 05a4a45d 16h Initial commit -+ feature ⧉_ ../repo.feature 05a4a45d 16h Initial commit ++ feature ⚑_ ../repo.feature 05a4a45d 16h Initial commit + feature-a ↑ ↑1 +1 ../repo.feature-a 1b87d473 16h Add feature-a file + feature-b ↑ ↑1 +1 ../repo.feature-b f62940fc 16h Add feature-b file + feature-c ↑ ↑1 +1 ../repo.feature-c 345c7c93 16h Add feature-c file -+ feature ⧉_ ../repo.feature-dup 05a4a45d 16h Initial commit ++ feature ⚑_ ../repo.feature-dup 05a4a45d 16h Initial commit ----- stderr -----