mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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> |
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
Generated
+3
-3
@@ -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
|
||||
```
|
||||
|
||||
Generated
+1
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [2m~/.config/worktrunk/config.toml[0m with the following content:
|
||||
[107m [0m [2m#[0m
|
||||
[107m [0m [2m# - `branch` — The branch name[0m
|
||||
[107m [0m [2m# - `status` — Git status symbols, plus any user-defined status[0m
|
||||
[107m [0m [2m# - `working-diff` — Uncommitted line changes against `HEAD` (header `HEAD±`)[0m
|
||||
[107m [0m [2m# - `working-diff` — Uncommitted line changes against `HEAD`, including untracked files (header `HEAD±`)[0m
|
||||
[107m [0m [2m# - `ahead-behind` — Commits ahead of and behind the default branch (header `main↕`)[0m
|
||||
[107m [0m [2m# - `branch-diff` — Line changes against the default branch (header `main…±`)[0m
|
||||
[107m [0m [2m# - `summary` — An LLM-generated summary of the branch[0m
|
||||
|
||||
@@ -242,7 +242,7 @@ Valid built-in names:
|
||||
|
||||
- [2mbranch[0m — The branch name
|
||||
- [2mstatus[0m — Git status symbols, plus any user-defined status
|
||||
- [2mworking-diff[0m — Uncommitted line changes against [2mHEAD[0m (header [2mHEAD±[0m)
|
||||
- [2mworking-diff[0m — Uncommitted line changes against [2mHEAD[0m, including untracked files (header [2mHEAD±[0m)
|
||||
- [2mahead-behind[0m — Commits ahead of and behind the default branch (header [2mmain↕[0m)
|
||||
- [2mbranch-diff[0m — Line changes against the default branch (header [2mmain…±[0m)
|
||||
- [2msummary[0m — An LLM-generated summary of the branch
|
||||
|
||||
@@ -145,7 +145,7 @@ Output as JSON for scripting:
|
||||
──────── ───────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Branch Branch name; a detached worktree has none, so it shows its short hash in dim yellow
|
||||
Status Compact symbols (see below)
|
||||
HEAD± Uncommitted changes: +added -deleted lines
|
||||
HEAD± Uncommitted changes, including untracked files: +added -deleted lines
|
||||
main↕ Commits ahead/behind default branch
|
||||
main…± Line diffs since the merge-base (three-dot) with the default branch
|
||||
Summary LLM-generated branch summary; requires [2m--full[0m, [2msummary = true[0m, and [2mcommit.generation[0m [experimental]
|
||||
|
||||
@@ -157,7 +157,7 @@ Output as JSON for scripting:
|
||||
Branch Branch name; a detached worktree has none, so it shows its short hash
|
||||
in dim yellow
|
||||
Status Compact symbols (see below)
|
||||
HEAD± Uncommitted changes: +added -deleted lines
|
||||
HEAD± Uncommitted changes, including untracked files: +added -deleted lines
|
||||
main↕ Commits ahead/behind default branch
|
||||
main…± Line diffs since the merge-base (three-dot) with the default branch
|
||||
Summary LLM-generated branch summary; requires [2m--full[0m, [2msummary = true[0m, and
|
||||
|
||||
+2
-2
@@ -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": "!?↕"
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -60,9 +60,9 @@ exit_code: 0
|
||||
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
|
||||
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
|
||||
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
|
||||
+ fix [36m?[39m [2m–[22m 💬 ../repo.fix [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
+ fix [36m?[39m [2m–[22m 💬 [32m+1[0m ../repo.fix [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
+ diverged [36m![39m [2m↑[22m 💬 [32m+40[0m [31m-60[0m [32m↑1[0m [32m+60[0m ../repo.diverged [2m96d1fd9[0m [2m1d[0m [2mDiverged commit[0m
|
||||
+ feature-changes [36m![39m[36m?[39m [2m↑[22m 🤖 [32m+50[0m [31m-100[0m [32m↑1[0m [32m+100[0m ../repo.feature-changes [2mf5306de[0m [2m1d[0m [2mAdd 100 lines[0m
|
||||
+ feature-changes [36m![39m[36m?[39m [2m↑[22m 🤖 [32m+230[0m [31m-100[0m [32m↑1[0m [32m+100[0m ../repo.feature-changes [2mf5306de[0m [2m1d[0m [2mAdd 100 lines[0m
|
||||
|
||||
----- stderr -----
|
||||
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ exit_code: 0
|
||||
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
|
||||
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
|
||||
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
|
||||
+ feature [36m+[39m[36m![39m[36m?[39m [2m↑[22m [32m+2[0m [31m-3[0m [32m↑1[0m [32m+4[0m ../repo.feature [2mda422a1[0m [2m1d[0m [2mAdd files[0m
|
||||
+ feature [36m+[39m[36m![39m[36m?[39m [2m↑[22m [32m+3[0m [31m-3[0m [32m↑1[0m [32m+4[0m ../repo.feature [2mda422a1[0m [2m1d[0m [2mAdd files[0m
|
||||
|
||||
----- stderr -----
|
||||
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ exit_code: 0
|
||||
----- stdout -----
|
||||
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
|
||||
@ [2mfeature[0m [33m[2m⚑[39m[22m[2m_[22m [2m./.worktrees/feature[0m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
^ main [36m?[39m [2m^[22m[2m|[22m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
^ main [36m?[39m [2m^[22m[2m|[22m [32m+1[0m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
|
||||
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
|
||||
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ exit_code: 0
|
||||
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
|
||||
+ main-symbol [2m↑[22m 💬 [32m↑1[0m [32m+1[0m ../repo.main-symbol [2m113f1df[0m [2m1d[0m [2mSymbol commit[0m
|
||||
+ pr-link [2m↑[22m 🤖 [32m↑1[0m [32m+1[0m ../repo.pr-link [2m152b5de[0m [2m1d[0m [2mPR commit[0m
|
||||
+ wli-sequence [36m![39m[36m?[39m [2m↑[22m 🤖 [32m+1[0m [31m-112[0m [32m↑1[0m [32m+200[0m ../repo.wli-sequence [2m4353e28[0m [2m1d[0m [2mInitial content[0m
|
||||
+ wli-sequence [36m![39m[36m?[39m [2m↑[22m 🤖 [32m+165[0m [31m-112[0m [32m↑1[0m [32m+200[0m ../repo.wli-sequence [2m4353e28[0m [2m1d[0m [2mInitial content[0m
|
||||
|
||||
----- stderr -----
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ exit_code: 0
|
||||
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
|
||||
@ main [2m^[22m[2m⇡[22m [32m⇡1[0m . [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
|
||||
+ feature-api [2m↑[22m 🤖 [32m↑1[0m [32m+1[0m ../repo.feature-api [2m70343f0[0m [2m1d[0m [2mAdd REST API endpoints[0m
|
||||
+ review-ui [36m?[39m [2m↑[22m 💬 [32m↑1[0m [32m+1[0m ../repo.review-ui [2ma585d6e[0m [2m1d[0m [2mAdd dashboard component[0m
|
||||
+ wip-docs [36m?[39m [2m–[22m ../repo.wip-docs [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
|
||||
+ review-ui [36m?[39m [2m↑[22m 💬 [32m+1[0m [32m↑1[0m [32m+1[0m ../repo.review-ui [2ma585d6e[0m [2m1d[0m [2mAdd dashboard component[0m
|
||||
+ wip-docs [36m?[39m [2m–[22m [32m+1[0m ../repo.wip-docs [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
|
||||
|
||||
----- stderr -----
|
||||
|
||||
|
||||
+2
-2
@@ -58,8 +58,8 @@ exit_code: 0
|
||||
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
|
||||
@ main [2m^[22m[2m⇡[22m [32m⇡1[0m [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
|
||||
+ feature-api [2m↑[22m 🤖 [32m↑1[0m [32m+1[0m [2m70343f0[0m [2m1d[0m [2mAdd REST API endp…[0m
|
||||
+ review-ui [36m?[39m [2m↑[22m 💬 [32m↑1[0m [32m+1[0m [2ma585d6e[0m [2m1d[0m [2mAdd dashboard com…[0m
|
||||
+ wip-docs [36m?[39m [2m–[22m [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
|
||||
+ review-ui [36m?[39m [2m↑[22m 💬 [32m+1[0m [32m↑1[0m [32m+1[0m [2ma585d6e[0m [2m1d[0m [2mAdd dashboard com…[0m
|
||||
+ wip-docs [36m?[39m [2m–[22m [32m+1[0m [2m33323bc[0m [2m1d[0m [2mInitial commit[0m
|
||||
|
||||
----- stderr -----
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mURL[0m [1mCommit[0m
|
||||
@ main [36m?[39m [2m^[22m[2m⇅[22m [32m⇡1[0m [2m[31m⇣1[0m [2mhttp://localhost:12107[0m [2m41ee083[0m
|
||||
@ main [36m?[39m [2m^[22m[2m⇅[22m [32m+5[0m [32m⇡1[0m [2m[31m⇣1[0m [2mhttp://localhost:12107[0m [2m41ee083[0m
|
||||
+ feature-api [36m+[39m [2m↕[22m[2m⇡[22m [32m+54[0m [31m-5[0m [32m↑4[0m [2m[31m↓1[0m [32m+234[0m [31m-24[0m [32m⇡3[0m [2mhttp://localhost:10703[0m [2m6814f02[0m
|
||||
+ fix-auth [2m↕[22m[2m|[22m [32m↑2[0m [2m[31m↓1[0m [32m+25[0m [31m-11[0m [2m|[0m [2mhttp://localhost:16460[0m [2mb772e68[0m
|
||||
+ [2mfix-typos[0m [2m_[22m[2m|[22m [2m|[0m [2mhttp://localhost:14301[0m [2m41ee083[0m
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
|
||||
@ main [36m?[39m [2m^[22m[2m|[22m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
@ main [36m?[39m [2m^[22m[2m|[22m [32m+1[0m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
|
||||
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
|
||||
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
|
||||
@ main [36m?[39m [2m^[22m[2m|[22m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
@ main [36m?[39m [2m^[22m[2m|[22m [32m+2[0m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
|
||||
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
|
||||
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mURL[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
|
||||
@ main [36m?[39m [2m^[22m[2m|[22m [2m|[0m . [2mhttp://localhost:12107[0m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
@ main [36m?[39m [2m^[22m[2m|[22m [32m+2[0m [2m|[0m . [2mhttp://localhost:12107[0m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-a [2mhttp://localhost:11521[0m [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
|
||||
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mhttp://localhost:14072[0m [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
|
||||
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2mhttp://localhost:14303[0m [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
|
||||
|
||||
@@ -57,7 +57,7 @@ exit_code: 0
|
||||
----- stdout -----
|
||||
[1mBranch[0m [1mStatus[0m [1mHEAD±[0m [1mmain↕[0m [1mmain…±[0m [1mRemote⇅[0m [1mPath[0m [1mCommit[0m [1mAge[0m [1mMessage[0m
|
||||
@ main [2m^[22m[2m|[22m [2m|[0m . [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
+ feature-a [36m+[39m [36m?[39m [2m↑[22m [32m+120[0m [31m-1[0m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
|
||||
+ feature-a [36m+[39m [36m?[39m [2m↑[22m [32m+121[0m [31m-1[0m [32m↑1[0m [32m+1[0m ../repo.feature-a [2m1b87d47[0m [2m16h[0m [2mAdd feature-a file[0m
|
||||
+ feature-b [2m↑[22m [31m-1[0m [32m↑1[0m [32m+1[0m ../repo.feature-b [2mf62940f[0m [2m16h[0m [2mAdd feature-b file[0m
|
||||
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m ../repo.feature-c [2m345c7c9[0m [2m16h[0m [2mAdd feature-c file[0m
|
||||
+ [2m日本語[0m [2m_[22m [2m../repo.日本語[0m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
|
||||
@@ -60,7 +60,7 @@ exit_code: 0
|
||||
+ feature-a [2m↑[22m [32m↑1[0m [32m+1[0m [2m1b87d47[0m [2m16h[0m [2mAdd feature-a…[0m
|
||||
+ feature-b [2m↑[22m [32m↑1[0m [32m+1[0m [2mf62940f[0m [2m16h[0m [2mAdd feature-b…[0m
|
||||
+ feature-c [2m↑[22m [32m↑1[0m [32m+1[0m [2m345c7c9[0m [2m16h[0m [2mAdd feature-c…[0m
|
||||
+ feature/implement-oauth2-social-login [36m?[39m [2m–[22m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
+ feature/implement-oauth2-social-login [36m?[39m [2m–[22m [32m+1[0m [2m05a4a45[0m [2m16h[0m [2mInitial commit[0m
|
||||
|
||||
----- stderr -----
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ exit_code: 0
|
||||
+ feature-a [2m↑[22m
|
||||
+ feature-b [2m↑[22m
|
||||
+ feature-c [2m↑[22m
|
||||
+ feature/implement-oauth2-social-login [36m?[39m [2m–[22m
|
||||
+ feature/implement-oauth2-social-login [36m?[39m [2m–[22m [32m+1[0m
|
||||
|
||||
----- stderr -----
|
||||
|
||||
|
||||
Reference in New Issue
Block a user