diff --git a/dev/config.example.toml b/dev/config.example.toml
index c7cf95132..26cff5b2a 100644
--- a/dev/config.example.toml
+++ b/dev/config.example.toml
@@ -118,7 +118,7 @@
#
# - `branch` — The branch name
# - `status` — Git status symbols, plus any user-defined status
-# - `working-diff` — Uncommitted line changes against `HEAD` (header `HEAD±`)
+# - `working-diff` — Uncommitted line changes against `HEAD`, including untracked files (header `HEAD±`)
# - `ahead-behind` — Commits ahead of and behind the default branch (header `main↕`)
# - `branch-diff` — Line changes against the default branch (header `main…±`)
# - `summary` — An LLM-generated summary of the branch
diff --git a/docs/src/content/docs/claude-code.md b/docs/src/content/docs/claude-code.md
index f97384b5e..e300c3348 100644
--- a/docs/src/content/docs/claude-code.md
+++ b/docs/src/content/docs/claude-code.md
@@ -82,8 +82,8 @@ $ wt list
Branch Status HEAD± main↕ main…± Remote⇅ Path Commit Age Message
@ main ^⇡ ⇡1 . 33323bc 1d Initial commit
+ feature-api ↑ 🤖 ↑1 +1 ../repo.feature-api 70343f0 1d Add REST API endpoints
-+ review-ui ? ↑ 💬 ↑1 +1 ../repo.review-ui a585d6e 1d Add dashboard component
-+ wip-docs ? – ../repo.wip-docs 33323bc 1d Initial commit
++ review-ui ? ↑ 💬 +1 ↑1 +1 ../repo.review-ui a585d6e 1d Add dashboard component
++ wip-docs ? – +1 ../repo.wip-docs 33323bc 1d Initial commit
○ Showing 4 worktrees, 2 with changes, 2 ahead
```
diff --git a/docs/src/content/docs/config.md b/docs/src/content/docs/config.md
index c8ea38eb4..4fa635d39 100644
--- a/docs/src/content/docs/config.md
+++ b/docs/src/content/docs/config.md
@@ -211,7 +211,7 @@ Valid built-in names:
- `branch` — The branch name
- `status` — Git status symbols, plus any user-defined status
-- `working-diff` — Uncommitted line changes against `HEAD` (header `HEAD±`)
+- `working-diff` — Uncommitted line changes against `HEAD`, including untracked files (header `HEAD±`)
- `ahead-behind` — Commits ahead of and behind the default branch (header `main↕`)
- `branch-diff` — Line changes against the default branch (header `main…±`)
- `summary` — An LLM-generated summary of the branch
@@ -1390,8 +1390,8 @@ $ wt list
Branch Status HEAD± main↕ main…± Remote⇅ Commit Age Message
@ main ^⇡ ⇡1 33323bc 1d Initial commit
+ feature-api ↑ 🤖 ↑1 +1 70343f0 1d Add REST API endp…
-+ review-ui ? ↑ 💬 ↑1 +1 a585d6e 1d Add dashboard com…
-+ wip-docs ? – 33323bc 1d Initial commit
++ review-ui ? ↑ 💬 +1 ↑1 +1 a585d6e 1d Add dashboard com…
++ wip-docs ? – +1 33323bc 1d Initial commit
○ Showing 4 worktrees, 2 with changes, 2 ahead, 1 column hidden
```
diff --git a/docs/src/content/docs/list.md b/docs/src/content/docs/list.md
index 0e1c2bddb..b9dc9e815 100644
--- a/docs/src/content/docs/list.md
+++ b/docs/src/content/docs/list.md
@@ -78,7 +78,7 @@ $ wt list --format=json
|--------|-------|
| Branch | Branch name; a detached worktree has none, so it shows its short hash in dim yellow |
| Status | Compact symbols (see below) |
-| HEAD± | Uncommitted changes: +added -deleted lines |
+| HEAD± | Uncommitted changes, including untracked files: +added -deleted lines |
| main↕ | Commits ahead/behind default branch |
| main…± | Line diffs since the merge-base (three-dot) with the default branch |
| Summary | LLM-generated branch summary; requires `--full`, `summary = true`, and [`commit.generation`](/config/#commit) |
diff --git a/docs/src/content/docs/tips-patterns.md b/docs/src/content/docs/tips-patterns.md
index b00c9f2af..334a826a8 100644
--- a/docs/src/content/docs/tips-patterns.md
+++ b/docs/src/content/docs/tips-patterns.md
@@ -67,7 +67,7 @@ The URL column in `wt list` shows each worktree's dev server:
```console
$ wt list
Branch Status HEAD± main↕ main…± Remote⇅ URL Commit
-@ main ? ^⇅ ⇡1 ⇣1 http://localhost:12107 41ee083
+@ main ? ^⇅ +5 ⇡1 ⇣1 http://localhost:12107 41ee083
+ feature-api + ↕⇡ +54 -5 ↑4 ↓1 +234 -24 ⇡3 http://localhost:10703 6814f02
+ fix-auth ↕| ↑2 ↓1 +25 -11 | http://localhost:16460 b772e68
+ fix-typos _| | http://localhost:14301 41ee083
diff --git a/plugins/worktrunk/skills/worktrunk/reference/claude-code.md b/plugins/worktrunk/skills/worktrunk/reference/claude-code.md
index 9ae352832..21a6163d6 100644
--- a/plugins/worktrunk/skills/worktrunk/reference/claude-code.md
+++ b/plugins/worktrunk/skills/worktrunk/reference/claude-code.md
@@ -76,8 +76,8 @@ $ wt list
Branch Status HEAD± main↕ main…± Remote⇅ Path Commit Age Message
@ main ^⇡ ⇡1 . 33323bc 1d Initial commit
+ feature-api ↑ 🤖 ↑1 +1 ../repo.feature-api 70343f0 1d Add REST API endpoints
-+ review-ui ? ↑ 💬 ↑1 +1 ../repo.review-ui a585d6e 1d Add dashboard component
-+ wip-docs ? – ../repo.wip-docs 33323bc 1d Initial commit
++ review-ui ? ↑ 💬 +1 ↑1 +1 ../repo.review-ui a585d6e 1d Add dashboard component
++ wip-docs ? – +1 ../repo.wip-docs 33323bc 1d Initial commit
○ Showing 4 worktrees, 2 with changes, 2 ahead
```
diff --git a/plugins/worktrunk/skills/worktrunk/reference/config.md b/plugins/worktrunk/skills/worktrunk/reference/config.md
index 65fe54baa..d59539f25 100644
--- a/plugins/worktrunk/skills/worktrunk/reference/config.md
+++ b/plugins/worktrunk/skills/worktrunk/reference/config.md
@@ -205,7 +205,7 @@ Valid built-in names:
- `branch` — The branch name
- `status` — Git status symbols, plus any user-defined status
-- `working-diff` — Uncommitted line changes against `HEAD` (header `HEAD±`)
+- `working-diff` — Uncommitted line changes against `HEAD`, including untracked files (header `HEAD±`)
- `ahead-behind` — Commits ahead of and behind the default branch (header `main↕`)
- `branch-diff` — Line changes against the default branch (header `main…±`)
- `summary` — An LLM-generated summary of the branch
@@ -1378,8 +1378,8 @@ $ wt list
Branch Status HEAD± main↕ main…± Remote⇅ Commit Age Message
@ main ^⇡ ⇡1 33323bc 1d Initial commit
+ feature-api ↑ 🤖 ↑1 +1 70343f0 1d Add REST API endp…
-+ review-ui ? ↑ 💬 ↑1 +1 a585d6e 1d Add dashboard com…
-+ wip-docs ? – 33323bc 1d Initial commit
++ review-ui ? ↑ 💬 +1 ↑1 +1 a585d6e 1d Add dashboard com…
++ wip-docs ? – +1 33323bc 1d Initial commit
○ Showing 4 worktrees, 2 with changes, 2 ahead, 1 column hidden
```
diff --git a/plugins/worktrunk/skills/worktrunk/reference/list.md b/plugins/worktrunk/skills/worktrunk/reference/list.md
index 79d90d519..3d4a102b8 100644
--- a/plugins/worktrunk/skills/worktrunk/reference/list.md
+++ b/plugins/worktrunk/skills/worktrunk/reference/list.md
@@ -65,7 +65,7 @@ $ wt list --format=json
|--------|-------|
| Branch | Branch name; a detached worktree has none, so it shows its short hash in dim yellow |
| Status | Compact symbols (see below) |
-| HEAD± | Uncommitted changes: +added -deleted lines |
+| HEAD± | Uncommitted changes, including untracked files: +added -deleted lines |
| main↕ | Commits ahead/behind default branch |
| main…± | Line diffs since the merge-base (three-dot) with the default branch |
| Summary | LLM-generated branch summary; requires `--full`, `summary = true`, and [`commit.generation`](https://worktrunk.dev/config/#commit) [experimental] |
diff --git a/plugins/worktrunk/skills/worktrunk/reference/tips-patterns.md b/plugins/worktrunk/skills/worktrunk/reference/tips-patterns.md
index 8ae3bd0c7..9d9d274fc 100644
--- a/plugins/worktrunk/skills/worktrunk/reference/tips-patterns.md
+++ b/plugins/worktrunk/skills/worktrunk/reference/tips-patterns.md
@@ -61,7 +61,7 @@ The URL column in `wt list` shows each worktree's dev server:
```console
$ wt list
Branch Status HEAD± main↕ main…± Remote⇅ URL Commit
-@ main ? ^⇅ ⇡1 ⇣1 http://localhost:12107 41ee083
+@ main ? ^⇅ +5 ⇡1 ⇣1 http://localhost:12107 41ee083
+ feature-api + ↕⇡ +54 -5 ↑4 ↓1 +234 -24 ⇡3 http://localhost:10703 6814f02
+ fix-auth ↕| ↑2 ↓1 +25 -11 | http://localhost:16460 b772e68
+ fix-typos _| | http://localhost:14301 41ee083
diff --git a/skills/worktrunk/reference/claude-code.md b/skills/worktrunk/reference/claude-code.md
index 9ae352832..21a6163d6 100644
--- a/skills/worktrunk/reference/claude-code.md
+++ b/skills/worktrunk/reference/claude-code.md
@@ -76,8 +76,8 @@ $ wt list
Branch Status HEAD± main↕ main…± Remote⇅ Path Commit Age Message
@ main ^⇡ ⇡1 . 33323bc 1d Initial commit
+ feature-api ↑ 🤖 ↑1 +1 ../repo.feature-api 70343f0 1d Add REST API endpoints
-+ review-ui ? ↑ 💬 ↑1 +1 ../repo.review-ui a585d6e 1d Add dashboard component
-+ wip-docs ? – ../repo.wip-docs 33323bc 1d Initial commit
++ review-ui ? ↑ 💬 +1 ↑1 +1 ../repo.review-ui a585d6e 1d Add dashboard component
++ wip-docs ? – +1 ../repo.wip-docs 33323bc 1d Initial commit
○ Showing 4 worktrees, 2 with changes, 2 ahead
```
diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md
index 65fe54baa..d59539f25 100644
--- a/skills/worktrunk/reference/config.md
+++ b/skills/worktrunk/reference/config.md
@@ -205,7 +205,7 @@ Valid built-in names:
- `branch` — The branch name
- `status` — Git status symbols, plus any user-defined status
-- `working-diff` — Uncommitted line changes against `HEAD` (header `HEAD±`)
+- `working-diff` — Uncommitted line changes against `HEAD`, including untracked files (header `HEAD±`)
- `ahead-behind` — Commits ahead of and behind the default branch (header `main↕`)
- `branch-diff` — Line changes against the default branch (header `main…±`)
- `summary` — An LLM-generated summary of the branch
@@ -1378,8 +1378,8 @@ $ wt list
Branch Status HEAD± main↕ main…± Remote⇅ Commit Age Message
@ main ^⇡ ⇡1 33323bc 1d Initial commit
+ feature-api ↑ 🤖 ↑1 +1 70343f0 1d Add REST API endp…
-+ review-ui ? ↑ 💬 ↑1 +1 a585d6e 1d Add dashboard com…
-+ wip-docs ? – 33323bc 1d Initial commit
++ review-ui ? ↑ 💬 +1 ↑1 +1 a585d6e 1d Add dashboard com…
++ wip-docs ? – +1 33323bc 1d Initial commit
○ Showing 4 worktrees, 2 with changes, 2 ahead, 1 column hidden
```
diff --git a/skills/worktrunk/reference/list.md b/skills/worktrunk/reference/list.md
index 79d90d519..3d4a102b8 100644
--- a/skills/worktrunk/reference/list.md
+++ b/skills/worktrunk/reference/list.md
@@ -65,7 +65,7 @@ $ wt list --format=json
|--------|-------|
| Branch | Branch name; a detached worktree has none, so it shows its short hash in dim yellow |
| Status | Compact symbols (see below) |
-| HEAD± | Uncommitted changes: +added -deleted lines |
+| HEAD± | Uncommitted changes, including untracked files: +added -deleted lines |
| main↕ | Commits ahead/behind default branch |
| main…± | Line diffs since the merge-base (three-dot) with the default branch |
| Summary | LLM-generated branch summary; requires `--full`, `summary = true`, and [`commit.generation`](https://worktrunk.dev/config/#commit) [experimental] |
diff --git a/skills/worktrunk/reference/tips-patterns.md b/skills/worktrunk/reference/tips-patterns.md
index 8ae3bd0c7..9d9d274fc 100644
--- a/skills/worktrunk/reference/tips-patterns.md
+++ b/skills/worktrunk/reference/tips-patterns.md
@@ -61,7 +61,7 @@ The URL column in `wt list` shows each worktree's dev server:
```console
$ wt list
Branch Status HEAD± main↕ main…± Remote⇅ URL Commit
-@ main ? ^⇅ ⇡1 ⇣1 http://localhost:12107 41ee083
+@ main ? ^⇅ +5 ⇡1 ⇣1 http://localhost:12107 41ee083
+ feature-api + ↕⇡ +54 -5 ↑4 ↓1 +234 -24 ⇡3 http://localhost:10703 6814f02
+ fix-auth ↕| ↑2 ↓1 +25 -11 | http://localhost:16460 b772e68
+ fix-typos _| | http://localhost:14301 41ee083
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 6a460250c..b65d3f5cf 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -830,7 +830,7 @@ $ wt list --format=json
|--------|-------|
| Branch | Branch name; a detached worktree has none, so it shows its short hash in dim yellow |
| Status | Compact symbols (see below) |
-| HEAD± | Uncommitted changes: +added -deleted lines |
+| HEAD± | Uncommitted changes, including untracked files: +added -deleted lines |
| main↕ | Commits ahead/behind default branch |
| main…± | Line diffs since the merge-base (three-dot) with the default branch |
| Summary | LLM-generated branch summary; requires `--full`, `summary = true`, and [`commit.generation`](/config/#commit) [experimental] |
@@ -2100,7 +2100,7 @@ Valid built-in names:
- `branch` — The branch name
- `status` — Git status symbols, plus any user-defined status
-- `working-diff` — Uncommitted line changes against `HEAD` (header `HEAD±`)
+- `working-diff` — Uncommitted line changes against `HEAD`, including untracked files (header `HEAD±`)
- `ahead-behind` — Commits ahead of and behind the default branch (header `main↕`)
- `branch-diff` — Line changes against the default branch (header `main…±`)
- `summary` — An LLM-generated summary of the branch
diff --git a/src/commands/list/collect/execution.rs b/src/commands/list/collect/execution.rs
index 0d49cee95..9a5839db0 100644
--- a/src/commands/list/collect/execution.rs
+++ b/src/commands/list/collect/execution.rs
@@ -383,7 +383,6 @@ pub fn work_items_for_worktree(
default_branch: options.default_branch.clone(),
integration_targets: options.integration_targets.clone(),
snapshot: options.snapshot.clone(),
- include_untracked_in_working_diff: options.include_untracked_in_working_diff,
};
let has_commits = wt.has_commits();
@@ -495,9 +494,6 @@ pub fn work_items_for_branch(
default_branch: options.default_branch.clone(),
integration_targets: options.integration_targets.clone(),
snapshot: options.snapshot.clone(),
- // Branches have no working tree; the flag is only consumed by
- // WorkingTreeDiffTask, which doesn't run for branch items.
- include_untracked_in_working_diff: false,
};
let mut items = Vec::with_capacity(11);
diff --git a/src/commands/list/collect/mod.rs b/src/commands/list/collect/mod.rs
index 937b4bd9d..d40b013ef 100644
--- a/src/commands/list/collect/mod.rs
+++ b/src/commands/list/collect/mod.rs
@@ -470,11 +470,6 @@ pub struct CollectOptions {
/// plain ref snapshot and let per-row tasks fall back to per-pair
/// queries. `None` when capture failed (degraded mode).
pub snapshot: Option>,
-
- /// Whether `WorkingTreeDiffTask` should include untracked files in
- /// `HEAD±`. Set by `wt list --full` and `wt statusline`; consumed
- /// in `tasks.rs` where the cost/cutover rationale lives.
- pub include_untracked_in_working_diff: bool,
}
impl CollectOptions {
@@ -503,7 +498,6 @@ impl CollectOptions {
default_branch: None,
integration_targets: None,
snapshot: None,
- include_untracked_in_working_diff: false,
}
}
}
@@ -897,61 +891,49 @@ pub fn collect(
let url_template = url_template_cell.into_inner().flatten();
// Resolve show flags: merge CLI overrides with config (warmed in parallel phase)
- let (
- show_branches,
- show_remotes,
- show_full,
- collect_deadline,
- list_width,
- progressive_handler,
- include_untracked_in_working_diff,
- ) = match show_config {
- ShowConfig::Resolved {
- show_branches,
- show_remotes,
- collect_deadline,
- list_width,
- progressive_handler,
- } => (
- show_branches,
- show_remotes,
- // Picker is the only `Resolved` caller and is `wt list --full`: it
- // fetches every field for its preview tabs regardless of which
- // columns render. Like default `wt list` (but unlike `--full`) it
- // opts out of the untracked-inclusive working diff — the last tuple
- // field — so the two `show_full`-shaped values aren't the same bucket.
- true,
- collect_deadline,
- list_width,
- progressive_handler,
- false,
- ),
- ShowConfig::DeferredToParallel {
- cli_branches,
- cli_remotes,
- cli_full,
- } => {
- let config = repo.config();
- let show_branches = cli_branches || config.list.branches();
- let show_remotes = cli_remotes || config.list.remotes();
- let show_full = cli_full || config.list.full();
- // Resolve the collect budget from merged config (--full disables it)
- let collect_deadline = if show_full {
- None
- } else {
- config.list.timeout().map(|d| std::time::Instant::now() + d)
- };
- (
+ let (show_branches, show_remotes, show_full, collect_deadline, list_width, progressive_handler) =
+ match show_config {
+ ShowConfig::Resolved {
show_branches,
show_remotes,
- show_full,
collect_deadline,
- None,
- None,
- show_full,
- )
- }
- };
+ list_width,
+ progressive_handler,
+ } => (
+ show_branches,
+ show_remotes,
+ // Picker is the only `Resolved` caller and fetches every field for
+ // its preview tabs regardless of which columns render.
+ true,
+ collect_deadline,
+ list_width,
+ progressive_handler,
+ ),
+ ShowConfig::DeferredToParallel {
+ cli_branches,
+ cli_remotes,
+ cli_full,
+ } => {
+ let config = repo.config();
+ let show_branches = cli_branches || config.list.branches();
+ let show_remotes = cli_remotes || config.list.remotes();
+ let show_full = cli_full || config.list.full();
+ // Resolve the collect budget from merged config (--full disables it)
+ let collect_deadline = if show_full {
+ None
+ } else {
+ config.list.timeout().map(|d| std::time::Instant::now() + d)
+ };
+ (
+ show_branches,
+ show_remotes,
+ show_full,
+ collect_deadline,
+ None,
+ None,
+ )
+ }
+ };
// The picker (`wt switch`) drives a skim TUI that owns the terminal while
// collect runs on a background thread. Any stderr write from collect
@@ -1392,7 +1374,6 @@ pub fn collect(
default_branch: default_branch.clone(),
integration_targets: None,
snapshot: None,
- include_untracked_in_working_diff,
};
// Track expected results per item - populated as spawns are queued
diff --git a/src/commands/list/collect/tasks.rs b/src/commands/list/collect/tasks.rs
index 79a85b959..04d42a290 100644
--- a/src/commands/list/collect/tasks.rs
+++ b/src/commands/list/collect/tasks.rs
@@ -85,9 +85,6 @@ pub struct TaskContext {
/// ref→SHA cache. `None` when snapshot capture failed (degraded
/// mode — tasks fall back to ref-taking methods).
pub snapshot: Option>,
- /// Whether `WorkingTreeDiffTask` should include untracked files in
- /// its `HEAD±` line counts. See `CollectOptions` for rationale.
- pub include_untracked_in_working_diff: bool,
}
impl TaskContext {
@@ -579,15 +576,11 @@ impl Task for WorkingTreeDiffTask {
let (working_tree_status, is_dirty, has_conflicts) =
parse_working_tree_status(&status_output);
- // The default `wt list` path keeps `HEAD±` as a fast `git diff
- // --shortstat HEAD` over tracked files only. `--full` and statusline
- // ask for the same untracked-inclusive stat that `wt step diff`
- // shows; honour it only when the cached porcelain actually has
- // untracked entries (otherwise the fast path is identical and we
- // skip the index copy + intent-to-add walk).
+ // Only untracked entries need the temporary index. Tracked-only
+ // changes keep the ordinary tracked diff fast path.
let working_tree_diff = if !is_dirty {
LineDiff::default()
- } else if ctx.include_untracked_in_working_diff && working_tree_status.untracked {
+ } else if working_tree_status.untracked {
wt.working_tree_diff_stats_with_untracked()
.map_err(|e| ctx.error(Self::KIND, &e))?
} else {
diff --git a/src/commands/list/mod.rs b/src/commands/list/mod.rs
index c00f8d617..95cce35b2 100644
--- a/src/commands/list/mod.rs
+++ b/src/commands/list/mod.rs
@@ -55,10 +55,17 @@
//! For each worktree, we execute:
//! - `git status --porcelain` - Working tree state (uses index cache)
//! - `git rev-list --count ..` - Ahead/behind counts (uses commit graph)
-//! - `git diff --shortstat HEAD` - Working tree line diffs (uses index + tree objects)
+//! - Working tree line diffs:
+//! - Without untracked files: `git diff --shortstat --find-renames HEAD`
+//! - With untracked files: `git ls-files --others`, a temporary index copy plus
+//! `git add --intent-to-add`, and two `git diff --numstat -z --find-renames HEAD` calls
//! - `git diff --shortstat ...` - Branch line diffs (uses tree objects)
//! - `git rev-parse [` - Ref resolution (uses ref cache)
//!
+//! `HEAD±` always enables rename detection because pairing a tracked deletion with an
+//! untracked destination makes a move line-neutral. `main…±` compares committed trees
+//! and continues to use the user's configured rename policy.
+//!
//! Plus one global command:
//! - `git worktree list --porcelain` - List all worktrees (uses ref cache)
//!
@@ -111,7 +118,8 @@
//! Bottlenecks:
//! 1. `git status --porcelain` - Slowest when index is cold or many files changed
//! 2. `git rev-list --count` - Slow without commit graph in repos with deep history
-//! 3. `git diff --shortstat` - Slow for large diffs or when pack files aren't cached
+//! 3. Working tree diff - Slow for large tracked diffs or cold pack files; untracked
+//! paths also require enumeration, a temporary index write, and two numstat diffs
//!
//! Optimization tips:
//! - Run `git commit-graph write --reachable --changed-paths` to speed up commit counting
diff --git a/src/commands/statusline.rs b/src/commands/statusline.rs
index dfcd4ecb3..e783e5d49 100644
--- a/src/commands/statusline.rs
+++ b/src/commands/statusline.rs
@@ -876,10 +876,6 @@ fn statusline_options(repo: &Repository) -> CollectOptions {
};
CollectOptions {
url_template,
- // Match `wt list --full`: include untracked files in the working
- // diff (`HEAD±`) so the segment counts the same lines `wt step
- // diff` would show, consistent with the rest of the statusline data.
- include_untracked_in_working_diff: true,
..CollectOptions::for_columns(list::columns::all_columns(), &gates)
}
}
diff --git a/src/git/repository/working_tree.rs b/src/git/repository/working_tree.rs
index d72bcadac..669513785 100644
--- a/src/git/repository/working_tree.rs
+++ b/src/git/repository/working_tree.rs
@@ -1,9 +1,10 @@
//! WorkingTree - a borrowed handle for worktree-specific git operations.
+use std::collections::HashSet;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
-use anyhow::Context;
+use anyhow::{Context, bail};
use dashmap::mapref::entry::Entry;
use path_slash::PathExt;
@@ -13,10 +14,72 @@ use dunce::canonicalize;
use super::{GitError, LineDiff, Repository};
use crate::git::CommandError;
-use crate::git::parse_numstat_line;
const TEMP_INDEX_PREFIX: &str = "worktrunk-temp-index-";
+#[derive(Debug)]
+struct NumstatEntry {
+ diff: LineDiff,
+ paths: NumstatPaths,
+}
+
+#[derive(Debug)]
+enum NumstatPaths {
+ Single(Vec),
+ Rename { old: Vec, new: Vec },
+}
+
+impl NumstatEntry {
+ fn paths(&self) -> impl Iterator]- {
+ let (first, second) = match &self.paths {
+ NumstatPaths::Single(path) => (path.as_slice(), None),
+ NumstatPaths::Rename { old, new } => (old.as_slice(), Some(new.as_slice())),
+ };
+ [Some(first), second].into_iter().flatten()
+ }
+}
+
+fn take_numstat_field<'a>(input: &mut &'a [u8], separator: u8) -> anyhow::Result<&'a [u8]> {
+ let Some(index) = input.iter().position(|&byte| byte == separator) else {
+ bail!("Malformed git diff --numstat -z output")
+ };
+ let (field, remainder) = input.split_at(index);
+ *input = &remainder[1..];
+ Ok(field)
+}
+
+fn parse_numstat_count(field: &[u8]) -> anyhow::Result {
+ if field == b"-" {
+ return Ok(0);
+ }
+ std::str::from_utf8(field)
+ .context("Malformed git diff --numstat count")?
+ .parse()
+ .context("Malformed git diff --numstat count")
+}
+
+fn parse_numstat_entries(mut output: &[u8]) -> anyhow::Result> {
+ let mut entries = Vec::new();
+ while !output.is_empty() {
+ let added = parse_numstat_count(take_numstat_field(&mut output, b'\t')?)?;
+ let deleted = parse_numstat_count(take_numstat_field(&mut output, b'\t')?)?;
+ let first_path = take_numstat_field(&mut output, 0)?;
+ let paths = if first_path.is_empty() {
+ NumstatPaths::Rename {
+ old: take_numstat_field(&mut output, 0)?.to_vec(),
+ new: take_numstat_field(&mut output, 0)?.to_vec(),
+ }
+ } else {
+ NumstatPaths::Single(first_path.to_vec())
+ };
+ entries.push(NumstatEntry {
+ diff: LineDiff { added, deleted },
+ paths,
+ });
+ }
+ Ok(entries)
+}
+
/// Quote a path component for Git's `glob` pathspec magic. The caller adds
/// the one intentional wildcard after the escaped literal.
fn escape_pathspec_glob_literal(path: &str) -> String {
@@ -769,68 +832,68 @@ impl<'a> WorkingTree<'a> {
/// Get line diff statistics for working tree changes (unstaged + staged).
pub fn working_tree_diff_stats(&self) -> anyhow::Result {
- let stdout = self.run_command(&["diff", "--shortstat", "HEAD"])?;
+ let stdout = self.run_command(&["diff", "--shortstat", "--find-renames", "HEAD"])?;
Ok(LineDiff::from_shortstat(&stdout))
}
- /// Working-tree diff stats vs HEAD that also count untracked files,
- /// matching the diff `wt step diff` shows.
+ /// Working-tree diff stats vs HEAD that also count untracked files.
+ /// The scope matches `wt step diff`; explicit rename detection keeps
+ /// `HEAD±` stable across user Git configuration.
///
- /// Tracked changes come from the normal `git diff HEAD` path. Untracked
- /// files are staged separately in a [`TempIndex`] and diffed by path, so
- /// the real index is untouched and Git does not need to rediscover tracked
- /// modifications from a copied index.
+ /// Untracked paths enter a temporary index as intent-to-add entries, which
+ /// lets one diff pair them with tracked deletions as renames without writing
+ /// their contents as blobs. The real-index diff fills any tracked entry the
+ /// copied index misses because of racy stat data.
pub fn working_tree_diff_stats_with_untracked(&self) -> anyhow::Result {
- let mut stats = self.working_tree_diff_stats()?;
- let untracked = self.untracked_diff_stats()?;
- stats.added += untracked.added;
- stats.deleted += untracked.deleted;
- Ok(stats)
- }
-
- fn untracked_diff_stats(&self) -> anyhow::Result {
let exclusions = self.observation_path_exclusions()?;
let mut args = vec!["ls-files", "--others", "--exclude-standard", "-z"];
args.extend(exclusions.iter().map(String::as_str));
- let output = self.run_command_output(&args)?;
- if !output.status.success() {
- return Err(CommandError::from_failed_output("git", &args, &output).into());
+ let untracked_output = self.run_command_output(&args)?;
+ if !untracked_output.status.success() {
+ return Err(CommandError::from_failed_output("git", &args, &untracked_output).into());
+ }
+ if untracked_output.stdout.is_empty() {
+ return self.working_tree_diff_stats();
}
- let paths: Vec = output
- .stdout
- .split(|&b| b == 0)
- .filter(|path| !path.is_empty())
- .map(|path| String::from_utf8_lossy(path).into_owned())
- .collect();
- if paths.is_empty() {
- return Ok(LineDiff::default());
+ let numstat_args = [
+ "diff",
+ "--numstat",
+ "-z",
+ "--find-renames",
+ "--end-of-options",
+ "HEAD",
+ ];
+ let tracked_output = self.run_command_output(&numstat_args)?;
+ if !tracked_output.status.success() {
+ return Err(
+ CommandError::from_failed_output("git", &numstat_args, &tracked_output).into(),
+ );
}
+ let tracked_entries = parse_numstat_entries(&tracked_output.stdout)?;
let idx = self.temp_index()?;
- let add_args = [
- "add",
- "--sparse",
- "--pathspec-from-file=-",
- "--pathspec-file-nul",
- ];
- idx.run_command_with_input(add_args, output.stdout)?;
-
- let mut args = vec![
- "diff".to_string(),
- "--cached".to_string(),
- "--numstat".to_string(),
- "HEAD".to_string(),
- "--".to_string(),
- ];
- args.extend(paths);
- let output = idx.run_command(&args)?;
+ idx.register_untracked_paths(untracked_output.stdout)?;
+ let combined_output = idx.run_command_output(numstat_args)?;
+ let combined_entries = parse_numstat_entries(&combined_output.stdout)?;
let mut stats = LineDiff::default();
- for line in output.lines() {
- if let Some((added, deleted)) = parse_numstat_line(line) {
- stats.added += added;
- stats.deleted += deleted;
+ for entry in &combined_entries {
+ stats.added += entry.diff.added;
+ stats.deleted += entry.diff.deleted;
+ }
+
+ // A copied index can trust stale stat data that the real index treats
+ // as racily clean. The combined diff owns rename pairing, while the
+ // real-index diff supplies tracked records absent from it.
+ let covered_paths: HashSet<&[u8]> = combined_entries
+ .iter()
+ .flat_map(NumstatEntry::paths)
+ .collect();
+ for entry in tracked_entries {
+ if entry.paths().all(|path| !covered_paths.contains(path)) {
+ stats.added += entry.diff.added;
+ stats.deleted += entry.diff.deleted;
}
}
Ok(stats)
@@ -1002,7 +1065,7 @@ impl<'a> WorkingTree<'a> {
/// real index, point `GIT_INDEX_FILE` at the copy, and run those
/// operations there. Today the callers are
/// [`WorkingTree::working_tree_diff_stats_with_untracked`] (HEAD± with
-/// untracked, used by `wt list --full` / `wt statusline`),
+/// untracked, used by list and statusline),
/// `WorkingTreeConflictsTask` (write-tree of tracked changes, for
/// merge-conflict probing), `wt step diff` (diff vs target merge-base with
/// untracked), `wt step commit --dry-run` (mirror its `--stage` mode without
@@ -1077,6 +1140,21 @@ impl TempIndex {
Ok(())
}
+ /// Register an exact NUL-separated set of untracked paths as intent-to-add.
+ fn register_untracked_paths(&self, paths: Vec) -> anyhow::Result<()> {
+ self.run_command_output_with_input(
+ [
+ "add",
+ "--intent-to-add",
+ "--sparse",
+ "--pathspec-from-file=-",
+ "--pathspec-file-nul",
+ ],
+ paths,
+ )?;
+ Ok(())
+ }
+
fn append_temp_index_exclusion(&self, args: &mut Vec) -> anyhow::Result<()> {
let temp_dir = canonicalize_with_parents(
self.temp
@@ -1099,14 +1177,22 @@ impl TempIndex {
&self,
args: impl IntoIterator
- >,
) -> anyhow::Result {
- self.run_command_with_input(args, Vec::new())
+ let output = self.run_command_output(args)?;
+ Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
- fn run_command_with_input(
+ fn run_command_output(
+ &self,
+ args: impl IntoIterator
- >,
+ ) -> anyhow::Result {
+ self.run_command_output_with_input(args, Vec::new())
+ }
+
+ fn run_command_output_with_input(
&self,
args: impl IntoIterator
- >,
stdin: Vec,
- ) -> anyhow::Result {
+ ) -> anyhow::Result {
let args: Vec = args.into_iter().map(Into::into).collect();
let mut command = self.command(args.iter().cloned());
if !stdin.is_empty() {
@@ -1118,7 +1204,7 @@ impl TempIndex {
if !output.status.success() {
return Err(CommandError::from_failed_output("git", &args, &output).into());
}
- Ok(String::from_utf8_lossy(&output.stdout).into_owned())
+ Ok(output)
}
/// Build a `git` command pointed at this temp index.
@@ -1148,7 +1234,7 @@ impl TempIndex {
#[cfg(test)]
mod tests {
use super::has_initialized_submodules_from_status;
- use crate::git::Repository;
+ use crate::git::{LineDiff, Repository};
use crate::shell_exec::Cmd;
use crate::testing::TestRepo;
@@ -1389,6 +1475,112 @@ mod tests {
);
}
+ #[test]
+ fn working_tree_diff_stats_without_untracked_files_still_finds_renames() {
+ let test = TestRepo::with_initial_commit();
+ let source = (1..=100)
+ .map(|line| format!("line {line}"))
+ .collect::>()
+ .join("\n");
+ std::fs::write(test.root_path().join("source.txt"), format!("{source}\n")).unwrap();
+ test.run_git(&["add", "source.txt"]);
+ test.run_git(&["commit", "-m", "add source"]);
+ test.run_git(&["config", "diff.renames", "false"]);
+
+ std::fs::create_dir(test.root_path().join("moved")).unwrap();
+ test.run_git(&["mv", "source.txt", "moved/source.txt"]);
+
+ let repo = Repository::at(test.root_path()).unwrap();
+ assert_eq!(
+ repo.current_worktree()
+ .working_tree_diff_stats_with_untracked()
+ .unwrap(),
+ LineDiff::default(),
+ "HEAD± uses one rename policy whether or not untracked files exist"
+ );
+ }
+
+ #[test]
+ fn working_tree_diff_stats_with_untracked_reports_only_a_moves_line_delta() {
+ let test = TestRepo::with_initial_commit();
+ let source = (1..=100)
+ .map(|line| format!("line {line}"))
+ .collect::>()
+ .join("\n");
+ std::fs::write(test.root_path().join("source.txt"), format!("{source}\n")).unwrap();
+ test.run_git(&["add", "source.txt"]);
+ test.run_git(&["commit", "-m", "add source"]);
+
+ std::fs::create_dir(test.root_path().join("moved")).unwrap();
+ std::fs::rename(
+ test.root_path().join("source.txt"),
+ test.root_path().join("moved/source.txt"),
+ )
+ .unwrap();
+
+ let repo = Repository::at(test.root_path())
+ .unwrap()
+ .redirect_objects_for_observation()
+ .unwrap();
+ let wt = repo.current_worktree();
+ let real_index = wt.git_dir().unwrap().join("index");
+ let index_before = std::fs::read(&real_index).unwrap();
+ let objects_before = test.git_output(&["count-objects", "-v"]);
+
+ assert_eq!(
+ wt.working_tree_diff_stats_with_untracked().unwrap(),
+ LineDiff::default(),
+ "an exact move changes paths but no lines"
+ );
+
+ let mut moved = source.lines().map(str::to_string).collect::>();
+ moved[49] = "changed line".to_string();
+ moved.push("added line".to_string());
+ std::fs::write(
+ test.root_path().join("moved/source.txt"),
+ format!("{}\n", moved.join("\n")),
+ )
+ .unwrap();
+ assert_eq!(
+ wt.working_tree_diff_stats_with_untracked().unwrap(),
+ LineDiff {
+ added: 2,
+ deleted: 1,
+ },
+ "an edited move reports only the edits"
+ );
+
+ assert_eq!(std::fs::read(&real_index).unwrap(), index_before);
+ assert_eq!(test.git_output(&["count-objects", "-v"]), objects_before);
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn working_tree_diff_stats_with_untracked_preserves_rename_path_bytes() {
+ let test = TestRepo::with_initial_commit();
+ let source = "source\tname\n.txt";
+ let destination = "moved\tname\n.txt";
+ std::fs::write(test.root_path().join(source), "one\ntwo\nthree\n").unwrap();
+ test.run_git(&["add", source]);
+ test.run_git(&["commit", "-m", "add unusual path"]);
+ std::fs::rename(
+ test.root_path().join(source),
+ test.root_path().join(destination),
+ )
+ .unwrap();
+
+ let repo = Repository::at(test.root_path())
+ .unwrap()
+ .redirect_objects_for_observation()
+ .unwrap();
+ assert_eq!(
+ repo.current_worktree()
+ .working_tree_diff_stats_with_untracked()
+ .unwrap(),
+ LineDiff::default()
+ );
+ }
+
#[test]
fn observation_object_directory_is_excluded_from_status_and_diff() {
let test = TestRepo::with_initial_commit();
@@ -1432,11 +1624,14 @@ mod tests {
}
#[test]
- fn untracked_diff_stats_crosses_sparse_checkout_boundary() {
+ fn working_tree_diff_stats_with_untracked_crosses_sparse_checkout_boundary() {
let test = sparse_checkout_with_untracked_file();
let repo = Repository::at(test.root_path()).unwrap();
- let stats = repo.current_worktree().untracked_diff_stats().unwrap();
+ let stats = repo
+ .current_worktree()
+ .working_tree_diff_stats_with_untracked()
+ .unwrap();
assert_eq!(stats.added, 1);
assert_eq!(stats.deleted, 0);
}
@@ -1473,21 +1668,23 @@ mod tests {
}
#[test]
- fn untracked_diff_stats_unborn_head_is_command_error() {
- // With an unborn HEAD the untracked files stage fine into the temp
- // index, but `git diff --cached --numstat HEAD` cannot resolve HEAD —
- // the failure must surface as a typed `CommandError`.
+ fn working_tree_diff_stats_with_untracked_unborn_head_is_command_error() {
+ // With an unborn HEAD the tracked diff cannot resolve its comparison
+ // revision. The failure must surface as a typed `CommandError`.
let test = TestRepo::new();
std::fs::write(test.root_path().join("new.txt"), "hello\n").unwrap();
let repo = Repository::at(test.root_path()).unwrap();
- let err = repo.current_worktree().untracked_diff_stats().unwrap_err();
+ let err = repo
+ .current_worktree()
+ .working_tree_diff_stats_with_untracked()
+ .unwrap_err();
let cmd_err =
crate::git::CommandError::find_in(&err).expect("error should carry a CommandError");
assert!(
cmd_err
.command_string()
- .starts_with("git diff --cached --numstat HEAD")
+ .starts_with("git diff --numstat -z --find-renames --end-of-options HEAD")
);
}
diff --git a/tests/integration_tests/list.rs b/tests/integration_tests/list.rs
index b0cd74ad9..cd1a884c8 100644
--- a/tests/integration_tests/list.rs
+++ b/tests/integration_tests/list.rs
@@ -3484,6 +3484,60 @@ fn test_list_ignores_untracked_paths_for_conflict_estimate(mut repo: TestRepo) {
assert_eq!(feature["default_branch"]["merge_conflicts"], false);
}
+/// `HEAD±` describes the whole working tree in every list mode. A move whose
+/// destination is still untracked changes no lines, while an unrelated new file
+/// contributes its added lines.
+#[rstest]
+fn test_list_counts_untracked_files_and_move_deltas_by_default(repo: TestRepo) {
+ repo.write_test_config("[list]\njson-schema = 2\n");
+ let source = (1..=100)
+ .map(|line| format!("line {line}"))
+ .collect::>()
+ .join("\n");
+ std::fs::write(repo.root_path().join("source.txt"), format!("{source}\n")).unwrap();
+ repo.run_git(&["add", "source.txt"]);
+ repo.run_git(&["commit", "-m", "Add source"]);
+
+ std::fs::create_dir(repo.root_path().join("moved")).unwrap();
+ std::fs::rename(
+ repo.root_path().join("source.txt"),
+ repo.root_path().join("moved/source.txt"),
+ )
+ .unwrap();
+ std::fs::write(repo.root_path().join("new.txt"), "one\ntwo\n").unwrap();
+
+ for args in [
+ &["list", "--format=json"][..],
+ &["list", "--full", "--format=json"][..],
+ ] {
+ let output = repo
+ .wt_command()
+ .args(args)
+ .current_dir(repo.root_path())
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "{} should succeed; stderr:\n{}",
+ args.join(" "),
+ String::from_utf8_lossy(&output.stderr)
+ );
+
+ let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ let main = json["items"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|item| item["branch"] == "main")
+ .expect("main row");
+ assert_eq!(
+ main["worktree"]["changes"]["diff"],
+ serde_json::json!({ "added": 2, "deleted": 0 }),
+ "entry: {main}"
+ );
+ }
+}
+
/// Ignoring untracked paths must still fall back to the committed conflict
/// probe rather than treating an untracked-only worktree as conflict-free.
#[rstest]
diff --git a/tests/snapshots/integration__integration_tests__help__help_config_create.snap b/tests/snapshots/integration__integration_tests__help__help_config_create.snap
index a77487b59..b733f7c3c 100644
--- a/tests/snapshots/integration__integration_tests__help__help_config_create.snap
+++ b/tests/snapshots/integration__integration_tests__help__help_config_create.snap
@@ -194,7 +194,7 @@ Creates [2m~/.config/worktrunk/config.toml[0m with the following content:
[107m [0m [2m#[0m
[107m [0m [2m# - `branch` — The branch name[0m
[107m [0m [2m# - `status` — Git status symbols, plus any user-defined status[0m
-[107m [0m [2m# - `working-diff` — Uncommitted line changes against `HEAD` (header `HEAD±`)[0m
+[107m [0m [2m# - `working-diff` — Uncommitted line changes against `HEAD`, including untracked files (header `HEAD±`)[0m
[107m [0m [2m# - `ahead-behind` — Commits ahead of and behind the default branch (header `main↕`)[0m
[107m [0m [2m# - `branch-diff` — Line changes against the default branch (header `main…±`)[0m
[107m [0m [2m# - `summary` — An LLM-generated summary of the branch[0m
diff --git a/tests/snapshots/integration__integration_tests__help__help_config_long.snap b/tests/snapshots/integration__integration_tests__help__help_config_long.snap
index 1e251879f..10eba5d6a 100644
--- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap
+++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap
@@ -242,7 +242,7 @@ Valid built-in names:
- [2mbranch[0m — The branch name
- [2mstatus[0m — Git status symbols, plus any user-defined status
-- [2mworking-diff[0m — Uncommitted line changes against [2mHEAD[0m (header [2mHEAD±[0m)
+- [2mworking-diff[0m — Uncommitted line changes against [2mHEAD[0m, including untracked files (header [2mHEAD±[0m)
- [2mahead-behind[0m — Commits ahead of and behind the default branch (header [2mmain↕[0m)
- [2mbranch-diff[0m — Line changes against the default branch (header [2mmain…±[0m)
- [2msummary[0m — An LLM-generated summary of the branch
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 8214fecd9..7d7060de5 100644
--- a/tests/snapshots/integration__integration_tests__help__help_list_long.snap
+++ b/tests/snapshots/integration__integration_tests__help__help_list_long.snap
@@ -145,7 +145,7 @@ Output as JSON for scripting:
──────── ───────────────────────────────────────────────────────────────────────────────────────────────────
Branch Branch name; a detached worktree has none, so it shows its short hash in dim yellow
Status Compact symbols (see below)
- HEAD± Uncommitted changes: +added -deleted lines
+ HEAD± Uncommitted changes, including untracked files: +added -deleted lines
main↕ Commits ahead/behind default branch
main…± Line diffs since the merge-base (three-dot) with the default branch
Summary LLM-generated branch summary; requires [2m--full[0m, [2msummary = true[0m, and [2mcommit.generation[0m [experimental]
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 3a76b74d6..b9bf41cb0 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
@@ -157,7 +157,7 @@ Output as JSON for scripting:
Branch Branch name; a detached worktree has none, so it shows its short hash
in dim yellow
Status Compact symbols (see below)
- HEAD± Uncommitted changes: +added -deleted lines
+ HEAD± Uncommitted changes, including untracked files: +added -deleted lines
main↕ Commits ahead/behind default branch
main…± Line diffs since the merge-base (three-dot) with the default branch
Summary LLM-generated branch summary; requires [2m--full[0m, [2msummary = true[0m, and
diff --git a/tests/snapshots/integration__integration_tests__list__list_json_with_display_fields.snap b/tests/snapshots/integration__integration_tests__list__list_json_with_display_fields.snap
index 51c01385a..c14419056 100644
--- a/tests/snapshots/integration__integration_tests__list__list_json_with_display_fields.snap
+++ b/tests/snapshots/integration__integration_tests__list__list_json_with_display_fields.snap
@@ -217,7 +217,7 @@ exit_code: 0
"renamed": false,
"deleted": false,
"diff": {
- "added": 1,
+ "added": 2,
"deleted": 1
}
},
@@ -236,7 +236,7 @@ exit_code: 0
"is_main": false,
"is_current": false,
"is_previous": false,
- "statusline": "feature-ahead \u001b[36m!\u001b[39m\u001b[36m?\u001b[39m\u001b[2m↕\u001b[22m @\u001b[32m+1\u001b[0m \u001b[31m-1\u001b[0m \u001b[32m↑2\u001b[0m \u001b[2m\u001b[31m↓2\u001b[0m ^\u001b[32m+1\u001b[0m",
+ "statusline": "feature-ahead \u001b[36m!\u001b[39m\u001b[36m?\u001b[39m\u001b[2m↕\u001b[22m @\u001b[32m+2\u001b[0m \u001b[31m-1\u001b[0m \u001b[32m↑2\u001b[0m \u001b[2m\u001b[31m↓2\u001b[0m ^\u001b[32m+1\u001b[0m",
"symbols": "!?↕"
},
{
diff --git a/tests/snapshots/integration__integration_tests__list__list_large_diffs_alignment.snap b/tests/snapshots/integration__integration_tests__list__list_large_diffs_alignment.snap
index 3659f3dc0..e9fdd72f2 100644
--- a/tests/snapshots/integration__integration_tests__list__list_large_diffs_alignment.snap
+++ b/tests/snapshots/integration__integration_tests__list__list_large_diffs_alignment.snap
@@ -60,9 +60,9 @@ exit_code: 0
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
-+ fix [36m?[39m [2m–[22m 💬 ../repo.fix [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
++ fix [36m?[39m [2m–[22m 💬 [32m+1[0m ../repo.fix [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
+ diverged [36m![39m [2m↑[22m 💬 [32m+40[0m [31m-60[0m [32m↑1[0m [32m+60[0m ../repo.diverged [2m96d1fd9[0m [2m1d[0m [2mDiverged commit[0m
-+ feature-changes [36m![39m[36m?[39m [2m↑[22m 🤖 [32m+50[0m [31m-100[0m [32m↑1[0m [32m+100[0m ../repo.feature-changes [2mf5306de[0m [2m1d[0m [2mAdd 100 lines[0m
++ feature-changes [36m![39m[36m?[39m [2m↑[22m 🤖 [32m+230[0m [31m-100[0m [32m↑1[0m [32m+100[0m ../repo.feature-changes [2mf5306de[0m [2m1d[0m [2mAdd 100 lines[0m
----- stderr -----
diff --git a/tests/snapshots/integration__integration_tests__list__list_maximum_working_tree_symbols.snap b/tests/snapshots/integration__integration_tests__list__list_maximum_working_tree_symbols.snap
index e5a39298a..80e5d4206 100644
--- a/tests/snapshots/integration__integration_tests__list__list_maximum_working_tree_symbols.snap
+++ b/tests/snapshots/integration__integration_tests__list__list_maximum_working_tree_symbols.snap
@@ -60,7 +60,7 @@ exit_code: 0
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
-+ feature [36m+[39m[36m![39m[36m?[39m [2m↑[22m [32m+2[0m [31m-3[0m [32m↑1[0m [32m+4[0m ../repo.feature [2mda422a1[0m [2m1d[0m [2mAdd files[0m
++ feature [36m+[39m[36m![39m[36m?[39m [2m↑[22m [32m+3[0m [31m-3[0m [32m↑1[0m [32m+4[0m ../repo.feature [2mda422a1[0m [2m1d[0m [2mAdd files[0m
----- stderr -----
diff --git a/tests/snapshots/integration__integration_tests__list__list_nested_worktree_current_indicator.snap b/tests/snapshots/integration__integration_tests__list__list_nested_worktree_current_indicator.snap
index 4202b349e..c844b30ab 100644
--- a/tests/snapshots/integration__integration_tests__list__list_nested_worktree_current_indicator.snap
+++ b/tests/snapshots/integration__integration_tests__list__list_nested_worktree_current_indicator.snap
@@ -57,7 +57,7 @@ exit_code: 0
----- stdout -----
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
@ [2mfeature[0m [33m[2m⚑[39m[22m[2m_[22m [2m./.worktrees/feature[0m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
-^ main [36m?[39m [2m^[22m[2m|[22m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
+^ main [36m?[39m [2m^[22m[2m|[22m [32m+1[0m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
diff --git a/tests/snapshots/integration__integration_tests__list__list_status_column_padding_with_emoji.snap b/tests/snapshots/integration__integration_tests__list__list_status_column_padding_with_emoji.snap
index a985d0823..09923004e 100644
--- a/tests/snapshots/integration__integration_tests__list__list_status_column_padding_with_emoji.snap
+++ b/tests/snapshots/integration__integration_tests__list__list_status_column_padding_with_emoji.snap
@@ -62,7 +62,7 @@ exit_code: 0
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
+ main-symbol [2m↑[22m 💬 [32m↑1[0m [32m+1[0m ../repo.main-symbol [2m113f1df[0m [2m1d[0m [2mSymbol commit[0m
+ pr-link [2m↑[22m 🤖 [32m↑1[0m [32m+1[0m ../repo.pr-link [2m152b5de[0m [2m1d[0m [2mPR commit[0m
-+ wli-sequence [36m![39m[36m?[39m [2m↑[22m 🤖 [32m+1[0m [31m-112[0m [32m↑1[0m [32m+200[0m ../repo.wli-sequence [2m4353e28[0m [2m1d[0m [2mInitial content[0m
++ wli-sequence [36m![39m[36m?[39m [2m↑[22m 🤖 [32m+165[0m [31m-112[0m [32m↑1[0m [32m+200[0m ../repo.wli-sequence [2m4353e28[0m [2m1d[0m [2mInitial content[0m
----- stderr -----
diff --git a/tests/snapshots/integration__integration_tests__list__list_with_user_marker.snap b/tests/snapshots/integration__integration_tests__list__list_with_user_marker.snap
index 25f4a6da1..96ba18d37 100644
--- a/tests/snapshots/integration__integration_tests__list__list_with_user_marker.snap
+++ b/tests/snapshots/integration__integration_tests__list__list_with_user_marker.snap
@@ -58,8 +58,8 @@ exit_code: 0
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
@ main [2m^[22m[2m⇡[22m [32m⇡1[0m . [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
+ feature-api [2m↑[22m 🤖 [32m↑1[0m [32m+1[0m ../repo.feature-api [2m70343f0[0m [2m1d[0m [2mAdd REST API endpoints[0m
-+ review-ui [36m?[39m [2m↑[22m 💬 [32m↑1[0m [32m+1[0m ../repo.review-ui [2ma585d6e[0m [2m1d[0m [2mAdd dashboard component[0m
-+ wip-docs [36m?[39m [2m–[22m ../repo.wip-docs [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
++ review-ui [36m?[39m [2m↑[22m 💬 [32m+1[0m [32m↑1[0m [32m+1[0m ../repo.review-ui [2ma585d6e[0m [2m1d[0m [2mAdd dashboard component[0m
++ wip-docs [36m?[39m [2m–[22m [32m+1[0m ../repo.wip-docs [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
----- stderr -----
diff --git a/tests/snapshots/integration__integration_tests__list__readme_example_list_marker.snap b/tests/snapshots/integration__integration_tests__list__readme_example_list_marker.snap
index 283c0d3a3..5e2670962 100644
--- a/tests/snapshots/integration__integration_tests__list__readme_example_list_marker.snap
+++ b/tests/snapshots/integration__integration_tests__list__readme_example_list_marker.snap
@@ -58,8 +58,8 @@ exit_code: 0
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
@ main [2m^[22m[2m⇡[22m [32m⇡1[0m [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
+ feature-api [2m↑[22m 🤖 [32m↑1[0m [32m+1[0m [2m70343f0[0m [2m1d[0m [2mAdd REST API endp…[0m
-+ review-ui [36m?[39m [2m↑[22m 💬 [32m↑1[0m [32m+1[0m [2ma585d6e[0m [2m1d[0m [2mAdd dashboard com…[0m
-+ wip-docs [36m?[39m [2m–[22m [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
++ review-ui [36m?[39m [2m↑[22m 💬 [32m+1[0m [32m↑1[0m [32m+1[0m [2ma585d6e[0m [2m1d[0m [2mAdd dashboard com…[0m
++ wip-docs [36m?[39m [2m–[22m [32m+1[0m [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
----- stderr -----
diff --git a/tests/snapshots/integration__integration_tests__list__tips_dev_server_workflow.snap b/tests/snapshots/integration__integration_tests__list__tips_dev_server_workflow.snap
index 402603786..bc27c90a0 100644
--- a/tests/snapshots/integration__integration_tests__list__tips_dev_server_workflow.snap
+++ b/tests/snapshots/integration__integration_tests__list__tips_dev_server_workflow.snap
@@ -56,7 +56,7 @@ success: true
exit_code: 0
----- stdout -----
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mURL[0m [1mCommit[0m
-@ main [36m?[39m [2m^[22m[2m⇅[22m [32m⇡1[0m [2m[31m⇣1[0m [2mhttp://localhost:12107[0m [2m41ee083[0m
+@ main [36m?[39m [2m^[22m[2m⇅[22m [32m+5[0m [32m⇡1[0m [2m[31m⇣1[0m [2mhttp://localhost:12107[0m [2m41ee083[0m
+ feature-api [36m+[39m [2m↕[22m[2m⇡[22m [32m+54[0m [31m-5[0m [32m↑4[0m [2m[31m↓1[0m [32m+234[0m [31m-24[0m [32m⇡3[0m [2mhttp://localhost:10703[0m [2m6814f02[0m
+ fix-auth [2m↕[22m[2m|[22m [32m↑2[0m [2m[31m↓1[0m [32m+25[0m [31m-11[0m [2m|[0m [2mhttp://localhost:16460[0m [2mb772e68[0m
+ [2mfix-typos[0m [2m_[22m[2m|[22m [2m|[0m [2mhttp://localhost:14301[0m [2m41ee083[0m
diff --git a/tests/snapshots/integration__integration_tests__list_config__list_config_malformed_system_config_non_section_field.snap b/tests/snapshots/integration__integration_tests__list_config__list_config_malformed_system_config_non_section_field.snap
index 99564fd67..2dc11e502 100644
--- a/tests/snapshots/integration__integration_tests__list_config__list_config_malformed_system_config_non_section_field.snap
+++ b/tests/snapshots/integration__integration_tests__list_config__list_config_malformed_system_config_non_section_field.snap
@@ -56,7 +56,7 @@ success: true
exit_code: 0
----- stdout -----
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
-@ main [36m?[39m [2m^[22m[2m|[22m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
+@ main [36m?[39m [2m^[22m[2m|[22m [32m+1[0m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
diff --git a/tests/snapshots/integration__integration_tests__list_config__list_config_malformed_system_config_warns_on_stderr.snap b/tests/snapshots/integration__integration_tests__list_config__list_config_malformed_system_config_warns_on_stderr.snap
index 72776c7da..8581df0a5 100644
--- a/tests/snapshots/integration__integration_tests__list_config__list_config_malformed_system_config_warns_on_stderr.snap
+++ b/tests/snapshots/integration__integration_tests__list_config__list_config_malformed_system_config_warns_on_stderr.snap
@@ -56,7 +56,7 @@ success: true
exit_code: 0
----- stdout -----
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
-@ main [36m?[39m [2m^[22m[2m|[22m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
+@ main [36m?[39m [2m^[22m[2m|[22m [32m+2[0m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
diff --git a/tests/snapshots/integration__integration_tests__list_config__list_project_url_column.snap b/tests/snapshots/integration__integration_tests__list_config__list_project_url_column.snap
index aa8507a95..e261db44b 100644
--- a/tests/snapshots/integration__integration_tests__list_config__list_project_url_column.snap
+++ b/tests/snapshots/integration__integration_tests__list_config__list_project_url_column.snap
@@ -56,7 +56,7 @@ success: true
exit_code: 0
----- stdout -----
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mURL[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
-@ main [36m?[39m [2m^[22m[2m|[22m [2m|[0m . [2mhttp://localhost:12107[0m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
+@ main [36m?[39m [2m^[22m[2m|[22m [32m+2[0m [2m|[0m . [2mhttp://localhost:12107[0m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2mhttp://localhost:11521[0m [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mhttp://localhost:14072[0m [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2mhttp://localhost:14303[0m [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
diff --git a/tests/snapshots/integration__integration_tests__list_layout__diverse_rows.snap b/tests/snapshots/integration__integration_tests__list_layout__diverse_rows.snap
index bfcb396d8..15e79a499 100644
--- a/tests/snapshots/integration__integration_tests__list_layout__diverse_rows.snap
+++ b/tests/snapshots/integration__integration_tests__list_layout__diverse_rows.snap
@@ -57,7 +57,7 @@ exit_code: 0
----- stdout -----
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
@ main [2m^[22m[2m|[22m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
-+ feature-a [36m+[39m [36m?[39m [2m↑[22m [32m+120[0m [31m-1[0m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
++ feature-a [36m+[39m [36m?[39m [2m↑[22m [32m+121[0m [31m-1[0m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
+ feature-b [2m↑[22m [31m-1[0m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
+ [2m日本語[0m [2m_[22m [2m../repo.日本語[0m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
diff --git a/tests/snapshots/integration__integration_tests__list_layout__width_120.snap b/tests/snapshots/integration__integration_tests__list_layout__width_120.snap
index 2b721239f..b910bdeee 100644
--- a/tests/snapshots/integration__integration_tests__list_layout__width_120.snap
+++ b/tests/snapshots/integration__integration_tests__list_layout__width_120.snap
@@ -60,7 +60,7 @@ exit_code: 0
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m [2m1b87d47[0m [2m16h[0m [2mAdd feature-a…[0m
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m [2mf62940f[0m [2m16h[0m [2mAdd feature-b…[0m
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m [2m345c7c9[0m [2m16h[0m [2mAdd feature-c…[0m
-+ feature/implement-oauth2-social-login [36m?[39m [2m–[22m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
++ feature/implement-oauth2-social-login [36m?[39m [2m–[22m [32m+1[0m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
----- stderr -----
diff --git a/tests/snapshots/integration__integration_tests__list_layout__width_60.snap b/tests/snapshots/integration__integration_tests__list_layout__width_60.snap
index debe0dc5d..043ea1ac0 100644
--- a/tests/snapshots/integration__integration_tests__list_layout__width_60.snap
+++ b/tests/snapshots/integration__integration_tests__list_layout__width_60.snap
@@ -51,7 +51,7 @@ exit_code: 0
+ feature-a [2m↑[22m
+ feature-b [2m↑[22m
+ feature-c [2m↑[22m
-+ feature/implement-oauth2-social-login [36m?[39m [2m–[22m
++ feature/implement-oauth2-social-login [36m?[39m [2m–[22m [32m+1[0m
----- stderr -----