Count untracked files in list diffs (#3925)

`HEAD±` stopped counting untracked files to avoid creating blobs, which
made a moved file look like a large deletion. This uses a temporary
intent-to-add index so Git can pair moves before counting line changes,
while keeping the real index and object database unchanged.

The behavior now applies to default and full list output, the picker,
and statusline. Tests cover exact and edited moves, unusual path bytes,
sparse checkouts, and generated output.

> _This was written by Codex on behalf of @max-sixty_
This commit is contained in:
Maximilian Roos
2026-08-26 17:43:14 -07:00
committed by GitHub
parent eb0432aec3
commit bcdd44d133
39 changed files with 413 additions and 188 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
```
+3 -3
View File
@@ -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
```
+1 -1
View File
@@ -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) <span class="badge-experimental"></span> |
+1 -1
View File
@@ -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
@@ -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
```
@@ -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
```
@@ -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] |
@@ -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
+2 -2
View File
@@ -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
```
+3 -3
View File
@@ -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
```
+1 -1
View File
@@ -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] |
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
-4
View File
@@ -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);
+40 -59
View File
@@ -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<std::sync::Arc<worktrunk::git::RefSnapshot>>,
/// 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
+3 -10
View File
@@ -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<Arc<RefSnapshot>>,
/// 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 {
+10 -2
View File
@@ -55,10 +55,17 @@
//! For each worktree, we execute:
//! - `git status --porcelain` - Working tree state (uses index cache)
//! - `git rev-list --count <base>..<head>` - 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 <base>...<head>` - Branch line diffs (uses tree objects)
//! - `git rev-parse <ref>` - 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
-4
View File
@@ -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)
}
}
+260 -63
View File
@@ -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<u8>),
Rename { old: Vec<u8>, new: Vec<u8> },
}
impl NumstatEntry {
fn paths(&self) -> impl Iterator<Item = &[u8]> {
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<usize> {
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<Vec<NumstatEntry>> {
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<LineDiff> {
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<LineDiff> {
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<LineDiff> {
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<String> = 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<u8>) -> 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<String>) -> anyhow::Result<()> {
let temp_dir = canonicalize_with_parents(
self.temp
@@ -1099,14 +1177,22 @@ impl TempIndex {
&self,
args: impl IntoIterator<Item = impl Into<String>>,
) -> anyhow::Result<String> {
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<Item = impl Into<String>>,
) -> anyhow::Result<std::process::Output> {
self.run_command_output_with_input(args, Vec::new())
}
fn run_command_output_with_input(
&self,
args: impl IntoIterator<Item = impl Into<String>>,
stdin: Vec<u8>,
) -> anyhow::Result<String> {
) -> anyhow::Result<std::process::Output> {
let args: Vec<String> = 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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>();
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")
);
}
+54
View File
@@ -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::<Vec<_>>()
.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]
@@ -194,7 +194,7 @@ Creates ~/.config/worktrunk/config.toml with the following content:
  #
  # - `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
@@ -242,7 +242,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
@@ -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 --full, summary = true, and commit.generation [experimental]
@@ -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 --full, summary = true, and
@@ -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": "!?↕"
},
{
@@ -60,9 +60,9 @@ exit_code: 0
+ feature-a ↑ ↑1 +1 ../repo.feature-a 1b87d47 16h Add feature-a file
+ feature-b ↑ ↑1 +1 ../repo.feature-b f62940f 16h Add feature-b file
+ feature-c ↑ ↑1 +1 ../repo.feature-c 345c7c9 16h Add feature-c file
+ fix ?  💬 ../repo.fix 05a4a45 16h Initial commit
+ fix ?  💬 +1 ../repo.fix 05a4a45 16h Initial commit
+ diverged ! ↑ 💬 +40 -60 ↑1 +60 ../repo.diverged 96d1fd9 1d Diverged commit
+ feature-changes !? ↑ 🤖 +50 -100 ↑1 +100 ../repo.feature-changes f5306de 1d Add 100 lines
+ feature-changes !? ↑ 🤖 +230 -100 ↑1 +100 ../repo.feature-changes f5306de 1d Add 100 lines
----- stderr -----
@@ -60,7 +60,7 @@ exit_code: 0
+ feature-a ↑ ↑1 +1 ../repo.feature-a 1b87d47 16h Add feature-a file
+ feature-b ↑ ↑1 +1 ../repo.feature-b f62940f 16h Add feature-b file
+ feature-c ↑ ↑1 +1 ../repo.feature-c 345c7c9 16h Add feature-c file
+ feature +!? ↑ +2 -3 ↑1 +4 ../repo.feature da422a1 1d Add files
+ feature +!? ↑ +3 -3 ↑1 +4 ../repo.feature da422a1 1d Add files
----- stderr -----
@@ -57,7 +57,7 @@ exit_code: 0
----- stdout -----
Branch Status HEAD± main↕ main…± Remote⇅ Path Commit Age Message
@ feature ⚑_ ./.worktrees/feature 05a4a45 16h Initial commit
^ main ? ^| | . 05a4a45 16h Initial commit
^ main ? ^| +1 | . 05a4a45 16h Initial commit
+ feature-a ↑ ↑1 +1 ../repo.feature-a 1b87d47 16h Add feature-a file
+ feature-b ↑ ↑1 +1 ../repo.feature-b f62940f 16h Add feature-b file
+ feature-c ↑ ↑1 +1 ../repo.feature-c 345c7c9 16h Add feature-c file
@@ -62,7 +62,7 @@ exit_code: 0
+ feature-c ↑ ↑1 +1 ../repo.feature-c 345c7c9 16h Add feature-c file
+ main-symbol ↑ 💬 ↑1 +1 ../repo.main-symbol 113f1df 1d Symbol commit
+ pr-link ↑ 🤖 ↑1 +1 ../repo.pr-link 152b5de 1d PR commit
+ wli-sequence !? ↑ 🤖 +1 -112 ↑1 +200 ../repo.wli-sequence 4353e28 1d Initial content
+ wli-sequence !? ↑ 🤖 +165 -112 ↑1 +200 ../repo.wli-sequence 4353e28 1d Initial content
----- stderr -----
@@ -58,8 +58,8 @@ exit_code: 0
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
----- stderr -----
@@ -58,8 +58,8 @@ exit_code: 0
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
----- stderr -----
@@ -56,7 +56,7 @@ success: true
exit_code: 0
----- stdout -----
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
@@ -56,7 +56,7 @@ success: true
exit_code: 0
----- stdout -----
Branch Status HEAD± main↕ main…± Remote⇅ Path Commit Age Message
@ main ? ^| | . 05a4a45 16h Initial commit
@ main ? ^| +1 | . 05a4a45 16h Initial commit
+ feature-a ↑ ↑1 +1 ../repo.feature-a 1b87d47 16h Add feature-a file
+ feature-b ↑ ↑1 +1 ../repo.feature-b f62940f 16h Add feature-b file
+ feature-c ↑ ↑1 +1 ../repo.feature-c 345c7c9 16h Add feature-c file
@@ -56,7 +56,7 @@ success: true
exit_code: 0
----- stdout -----
Branch Status HEAD± main↕ main…± Remote⇅ Path Commit Age Message
@ main ? ^| | . 05a4a45 16h Initial commit
@ main ? ^| +2 | . 05a4a45 16h Initial commit
+ feature-a ↑ ↑1 +1 ../repo.feature-a 1b87d47 16h Add feature-a file
+ feature-b ↑ ↑1 +1 ../repo.feature-b f62940f 16h Add feature-b file
+ feature-c ↑ ↑1 +1 ../repo.feature-c 345c7c9 16h Add feature-c file
@@ -56,7 +56,7 @@ success: true
exit_code: 0
----- stdout -----
Branch Status HEAD± main↕ main…± Remote⇅ Path URL Commit Age Message
@ main ? ^| | . http://localhost:12107 05a4a45 16h Initial commit
@ main ? ^| +2 | . http://localhost:12107 05a4a45 16h Initial commit
+ feature-a ↑ ↑1 +1 ../repo.feature-a http://localhost:11521 1b87d47 16h Add feature-a file
+ feature-b ↑ ↑1 +1 ../repo.feature-b http://localhost:14072 f62940f 16h Add feature-b file
+ feature-c ↑ ↑1 +1 ../repo.feature-c http://localhost:14303 345c7c9 16h Add feature-c file
@@ -57,7 +57,7 @@ exit_code: 0
----- stdout -----
Branch Status HEAD± main↕ main…± Remote⇅ Path Commit Age Message
@ main ^| | . 05a4a45 16h Initial commit
+ feature-a + ? ↑ +120 -1 ↑1 +1 ../repo.feature-a 1b87d47 16h Add feature-a file
+ feature-a + ? ↑ +121 -1 ↑1 +1 ../repo.feature-a 1b87d47 16h Add feature-a file
+ feature-b ↑ -1 ↑1 +1 ../repo.feature-b f62940f 16h Add feature-b file
+ feature-c ↑ ↑1 +1 ../repo.feature-c 345c7c9 16h Add feature-c file
+ 日本語 _ ../repo.日本語 05a4a45 16h Initial commit
@@ -60,7 +60,7 @@ exit_code: 0
+ feature-a ↑ ↑1 +1 1b87d47 16h Add feature-a…
+ feature-b ↑ ↑1 +1 f62940f 16h Add feature-b…
+ feature-c ↑ ↑1 +1 345c7c9 16h Add feature-c…
+ feature/implement-oauth2-social-login ?  05a4a45 16h Initial commit
+ feature/implement-oauth2-social-login ?  +1 05a4a45 16h Initial commit
----- stderr -----
@@ -51,7 +51,7 @@ exit_code: 0
+ feature-a ↑
+ feature-b ↑
+ feature-c ↑
+ feature/implement-oauth2-social-login ? 
+ feature/implement-oauth2-social-login ?  +1
----- stderr -----