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 ~/.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 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: - 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/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 --full, summary = true, and commit.generation [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 --full, summary = true, 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 ↑ ↑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 ----- 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 ↑ ↑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 ----- 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 ----- 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 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 ↑ ↑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 ----- 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 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 ----- 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 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 ----- 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 ----- 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/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 ----- 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 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 ----- 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 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 ----- 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 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 ----- 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 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 ↑ ↑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 ----- 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 ↑ + feature-b ↑ + feature-c ↑ -+ feature/implement-oauth2-social-login ? – ++ feature/implement-oauth2-social-login ? – +1 ----- stderr -----