Skip to content

Commit 934aad1

Browse files
max-sixtyclaude
andauthored
fix(list): stop repeating the failure count in the list footer (#3467)
Follow-up to #3442. A failed task printed the same count twice on adjacent lines — the summary footer's `N tasks failed` clause and the `▲ N tasks failed:` warning header directly below it that names each failure: ``` ○ Showing 5 worktrees, 3 ahead; 6 tasks failed ▲ 6 tasks failed: (detached): working-tree conflict check fatal: bad object HEAD ``` The footer clause was never a distinct signal: its count is exactly `non_timeout_errors.len()`, and that vector being non-empty is precisely the condition under which the warning renders. So the number was duplicated in 100% of cases. Rather than suppress the duplicate when a warning happens to follow, this splits the two by what each reports. The footer counts what's *missing* from the table — timed-out tasks, which leave an empty cell and have no message to print, so a count is the only signal they can carry (and the warning deliberately filters them out). Failures are named in the warning with git's full error text. `format_summary_message` drops its `error_count` parameter entirely; the footer now reads: ``` ○ Showing 5 worktrees, 3 ahead ▲ 6 tasks failed: ... ``` Also corrects a stale claim in the `status_symbols` module spec: it said the footer "lists which tasks did not finish per item," giving a mapping from a `·` cell back to the timed-out `TaskKind`. No such mapping ever existed — the footer only carried counts, and budget-based truncation produces no diagnostic at all by design. One narrow behavior note: in progressive TTY mode the footer is stdout (a repainted table row) while the warning is stderr, so `wt list 2>/dev/null` on a TTY no longer shows a bare failure count. That case was already discarding the error detail, and discarding stderr discards narration by definition; the ordinary piped case (both on stderr) is unchanged. ## Testing The `format_summary_message` unit test now covers the timeout-only footer variants; the two failure-warning integration snapshots are updated to show the deduplicated footer. Verified end-to-end against the `index.lock` scenario (dirty worktree + pre-existing lock file). > _This was written by Claude Code on behalf of max_ Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c919ab6 commit 934aad1

5 files changed

Lines changed: 26 additions & 41 deletions

src/commands/list/collect/mod.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1987,8 +1987,6 @@ pub fn collect(
19871987
item.refresh_status_symbols(primary_target);
19881988
}
19891989

1990-
// Count errors for summary
1991-
let error_count = errors.len();
19921990
let timed_out_count = errors.iter().filter(|e| e.is_timeout()).count();
19931991

19941992
let table_render = render_table.then(|| TableRenderPlan {
@@ -2002,7 +2000,6 @@ pub fn collect(
20022000
&all_items,
20032001
show_branches || show_remotes,
20042002
layout.hidden_column_count,
2005-
error_count,
20062003
timed_out_count,
20072004
),
20082005
});

src/commands/list/mod.rs

Lines changed: 19 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -365,12 +365,17 @@ impl SummaryMetrics {
365365
}
366366
}
367367

368-
/// Format a summary message for the given items (used by both collect/mod.rs and mod.rs)
368+
/// Format the summary line that closes the table.
369+
///
370+
/// The footer is the only home a timed-out task has: it leaves an empty cell
371+
/// and no message to print, so the count here is all that reports it. Tasks
372+
/// that failed instead get a named entry in the warning that follows the
373+
/// table, and that warning carries its own count — repeating it here would
374+
/// print the same number twice on adjacent lines.
369375
pub(crate) fn format_summary_message(
370376
items: &[ListItem],
371377
show_branches: bool,
372378
hidden_column_count: usize,
373-
error_count: usize,
374379
timed_out_count: usize,
375380
) -> String {
376381
let metrics = SummaryMetrics::from_items(items);
@@ -379,25 +384,11 @@ pub(crate) fn format_summary_message(
379384
.summary_parts(show_branches, hidden_column_count)
380385
.join(", ");
381386

382-
if error_count > 0 {
383-
// "failed" and "timed out" are disjoint here, matching the warning
384-
// that details the non-timeout failures after the table.
385-
let failed_count = error_count - timed_out_count;
386-
let failure_msg = match (failed_count, timed_out_count) {
387-
(0, t) => {
388-
let plural = if t == 1 { "" } else { "s" };
389-
format!("{t} task{plural} timed out")
390-
}
391-
(f, 0) => {
392-
let plural = if f == 1 { "" } else { "s" };
393-
format!("{f} task{plural} failed")
394-
}
395-
(f, t) => {
396-
let plural = if f == 1 { "" } else { "s" };
397-
format!("{f} task{plural} failed, {t} timed out")
398-
}
399-
};
400-
format!("{INFO_SYMBOL} {dim}Showing {summary}; {failure_msg}{dim:#}")
387+
if timed_out_count > 0 {
388+
let plural = if timed_out_count == 1 { "" } else { "s" };
389+
format!(
390+
"{INFO_SYMBOL} {dim}Showing {summary}; {timed_out_count} task{plural} timed out{dim:#}"
391+
)
401392
} else {
402393
format!("{INFO_SYMBOL} {dim}Showing {summary}{dim:#}")
403394
}
@@ -538,20 +529,15 @@ mod tests {
538529
}
539530

540531
#[test]
541-
fn test_format_summary_message_error_variants() {
532+
fn test_format_summary_message_timeout_variants() {
542533
use insta::assert_snapshot;
543534

544-
// No errors
545-
assert_snapshot!(format_summary_message(&[], false, 0, 0, 0), @"○ Showing 0 worktrees");
546-
// All timeouts
547-
assert_snapshot!(format_summary_message(&[], false, 0, 3, 3), @"○ Showing 0 worktrees; 3 tasks timed out");
548-
// Mixed errors and timeouts
549-
assert_snapshot!(format_summary_message(&[], false, 0, 5, 3), @"○ Showing 0 worktrees; 2 tasks failed, 3 timed out");
550-
// Only failures, no timeouts
551-
assert_snapshot!(format_summary_message(&[], false, 0, 2, 0), @"○ Showing 0 worktrees; 2 tasks failed");
552-
// Single error
553-
assert_snapshot!(format_summary_message(&[], false, 0, 1, 0), @"○ Showing 0 worktrees; 1 task failed");
535+
// Nothing timed out. Failures alone leave the footer untouched — the
536+
// warning after the table names them and carries their count.
537+
assert_snapshot!(format_summary_message(&[], false, 0, 0), @"○ Showing 0 worktrees");
554538
// Single timeout
555-
assert_snapshot!(format_summary_message(&[], false, 0, 1, 1), @"○ Showing 0 worktrees; 1 task timed out");
539+
assert_snapshot!(format_summary_message(&[], false, 0, 1), @"○ Showing 0 worktrees; 1 task timed out");
540+
// Several timeouts
541+
assert_snapshot!(format_summary_message(&[], false, 0, 3), @"○ Showing 0 worktrees; 3 tasks timed out");
556542
}
557543
}

src/commands/list/model/status_symbols.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,11 @@
186186
//!
187187
//! 1. Each position's last-known state is displayed: resolved positions show
188188
//! their symbol; unresolved positions show `·`.
189-
//! 2. The diagnostic footer already lists which tasks did not finish per
190-
//! item — this continues unchanged and gives the user the mapping from
191-
//! "`·` in position X" back to "`TaskKind::Foo` timed out."
189+
//! 2. The `·` is the whole signal — budget truncation is deliberate, so it
190+
//! goes unreported, and nothing maps a `·` in position X back to the
191+
//! `TaskKind` that didn't finish. (A task whose own git command times out
192+
//! is a separate path: it reaches the summary footer as one of "N tasks
193+
//! timed out", a count that likewise doesn't name the task.)
192194
//! 3. JSON output omits fields that correspond to unresolved gates
193195
//! (`working_tree`, `main_state`, `operation_state`, `upstream_divergence`,
194196
//! etc.) so machine consumers can distinguish "loading / timeout" from

tests/snapshots/integration__integration_tests__list__list_shows_warning_on_git_error.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ exit_code: 0
5454
----- stderr -----
5555
▲ Failed to batch-fetch commit details: fatal: bad object 0000000000000000000000000000000000000001
5656

57-
[2m○[22m [2mShowing 5 worktrees, 3 ahead; 10 tasks failed[0m
57+
[2m○[22m [2mShowing 5 worktrees, 3 ahead[0m
5858
▲ 10 tasks failed:
5959
  main: upstream status (fatal: missing object 0000000000000000000000000000000000000001 for refs/heads/feature)
6060
  feature: ahead/behind counts (git merge-base failed for 05a4a45d0b981dad5c27db59dca482836d59f89e 0000000000000000000000000000000000000001: fatal: Not a valid commit name 0000000000000000000000000000000000000001)

tests/snapshots/integration__integration_tests__list__list_warns_when_commit_details_batch_fails.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ exit_code: 0
5454
----- stderr -----
5555
▲ Failed to batch-fetch commit details: fatal: bad object 0000000000000000000000000000000000000001
5656

57-
[2m○[22m [2mShowing 5 worktrees, 3 ahead; 6 tasks failed[0m
57+
[2m○[22m [2mShowing 5 worktrees, 3 ahead[0m
5858
▲ 6 tasks failed:
5959
  (detached): ahead/behind counts (git merge-base failed for 05a4a45d0b981dad5c27db59dca482836d59f89e 0000000000000000000000000000000000000001: fatal: Not a valid commit name 0000000000000000000000000000000000000001)
6060
  (detached): trees-match check (fatal: Needed a single revision)

0 commit comments

Comments
 (0)