worktrunk 0.37.1

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
//! List command for displaying worktrees and branches.
//!
//! # Performance
//!
//! `wt list` runs multiple git commands per worktree in parallel using Rayon. Performance
//! depends heavily on git's internal caches, not worktrunk-specific caching.
//!
//! ## Time to First Information
//!
//! Before displaying actual data, we perform these sequential operations:
//!
//! 1. **`git worktree list --porcelain`** (~5-15ms)
//!    - Lists all worktrees with their paths, HEADs, and branches
//!    - Uses ref cache, very fast
//!
//! 2. **Default branch lookup** (~1-5ms)
//!    - Reads cached value from `git config worktrunk.default-branch`
//!    - Falls back to git's remote HEAD ref (e.g., `origin/HEAD`) if available
//!    - If still unknown, queries `git ls-remote --symref <remote> HEAD` (~100ms-2s network)
//!    - Clear cache with `wt config state default-branch clear` to force re-detection
//!
//! 3. **Sort worktrees** (<1ms)
//!    - Orders by: current → main → rest by timestamp (most recent first)
//!    - Pure Rust, no git calls
//!
//! 4. **Branch listing** (only with `--branches`, ~10-30ms)
//!    - `git for-each-ref refs/heads` - lists local branches
//!    - Filters out branches that already have worktrees
//!
//! 5. **Remote branch listing** (only with `--remotes`, ~10-50ms)
//!    - `git for-each-ref refs/remotes` - lists remote branches
//!    - Scales with number of remotes/branches
//!
//! 6. **Layout calculation** (<1ms)
//!    - Determines column widths from branch names and paths
//!    - Pure Rust, no git calls
//!
//! **Time to skeleton: ~50ms** (placeholder rows with loading indicators)
//!
//! Skeleton timing is stable at ~46-52ms regardless of worktree count (1-8) or cache state.
//! This is because skeleton operations (worktree list, default branch lookup, layout) use
//! git's ref cache rather than packed-refs or the index.
//!
//! **Time to complete: ~60-170ms** (all data filled in, depends on worktree count)
//!
//! First run in a repo without cached default branch adds ~100-300ms for network lookup.
//!
//! After the skeleton appears, cells fill in progressively as git operations complete.
//! The slowest operation (CI status) only runs with `--full`.
//!
//! ## Git Commands Per Worktree
//!
//! 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)
//! - `git diff --shortstat <base>...<head>` - Branch line diffs (uses tree objects)
//! - `git rev-parse <ref>` - Ref resolution (uses ref cache)
//!
//! Plus one global command:
//! - `git worktree list --porcelain` - List all worktrees (uses ref cache)
//!
//! ## Git's Internal Caches
//!
//! Git caches significantly affect performance. These caches are maintained by git itself:
//!
//! 1. **Index (`.git/index`)** - Cached file metadata (mtime, size, mode)
//!    - Speeds up `git status` by avoiding full file content comparisons
//!    - Invalidated when files change or staging area updates
//!    - Cold: ~100ms per worktree, Warm: ~10ms per worktree (typical repo)
//!
//! 2. **Commit graph (`.git/objects/info/commit-graph`)** - Precomputed commit metadata
//!    - Speeds up `git rev-list --count` by avoiding commit object parsing
//!    - Generated by `git commit-graph write` (some repos auto-generate)
//!    - Without: O(commits) parsing, With: O(1) metadata lookup
//!    - Cold: ~50ms for 1000 commits, Warm: ~5ms (with commit graph)
//!
//! 3. **Ref cache (`.git/refs/` and `.git/packed-refs`)** - Cached ref resolutions
//!    - Speeds up `git rev-parse` and branch lookups
//!    - Packed refs improve performance in repos with many branches
//!    - Cold: ~10ms, Warm: <1ms
//!
//! 4. **Filesystem cache (OS-level)** - Recently accessed files stay in memory
//!    - Speeds up all git operations after first access
//!    - Cleared when system is under memory pressure
//!    - Most impactful for large repos with many pack files
//!
//! 5. **Pack files (`.git/objects/pack/`)** - Compressed object storage
//!    - Faster than loose objects for bulk access
//!    - `git gc` consolidates loose objects into packs
//!    - More efficient for tree/blob access in diffs
//!
//! ## Worktrunk's Only Cache: Default Branch
//!
//! Worktrunk caches only the default branch name (main/master) in
//! `git config worktrunk.default-branch`. The remote HEAD ref (e.g., `origin/HEAD`)
//! is git's cache; worktrunk reads it but does not set it. All other data is fetched
//! fresh on each `wt list` invocation.
//!
//! Clear cache with: `wt config state default-branch clear`
//!
//! ## Performance Characteristics
//!
//! Scaling (from benches/list.rs):
//! - Linear with worktree count due to parallelization (Rayon thread pool)
//! - Dominated by git command overhead, not Rust code
//! - Cold caches: ~150-300ms per worktree (typical repo, 500 commits, 100 files)
//! - Warm caches: ~20-50ms per worktree
//! - Real-world: rust-lang/rust repo with 8 worktrees: ~400ms (warm caches)
//!
//! 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
//!
//! Optimization tips:
//! - Run `git commit-graph write --reachable --changed-paths` to speed up commit counting
//! - Run `git gc` periodically to consolidate objects into pack files
//! - Minimize uncommitted changes across worktrees (each dirty worktree adds diff overhead)

pub mod ci_status;
pub(crate) mod collect;
pub(crate) mod columns;
pub mod json_output;
pub(crate) mod layout;
pub mod model;
pub mod progressive;
mod progressive_table;
pub(crate) mod render;

#[cfg(test)]
mod spacing_test;

// Layout is calculated in collect.rs
use anstyle::Style;
use anyhow::Context;
use model::{ListData, ListItem};
use progressive::RenderMode;
use worktrunk::git::Repository;
use worktrunk::styling::INFO_SYMBOL;

// Re-export for statusline and other consumers
pub use collect::{CollectOptions, build_worktree_item, populate_item};
pub use model::StatuslineSegment;

pub fn handle_list(
    repo: Repository,
    format: crate::OutputFormat,
    cli_branches: bool,
    cli_remotes: bool,
    cli_full: bool,
    render_mode: RenderMode,
) -> anyhow::Result<()> {
    // Progressive rendering only for table format with Progressive mode
    let show_progress = match format {
        crate::OutputFormat::Table | crate::OutputFormat::ClaudeCode => {
            render_mode == RenderMode::Progressive
        }
        crate::OutputFormat::Json => false, // JSON never shows progress
    };

    // Render table in collect() for all table modes (progressive + buffered)
    let render_table = matches!(
        format,
        crate::OutputFormat::Table | crate::OutputFormat::ClaudeCode
    );

    let list_data = collect::collect(
        &repo,
        collect::ShowConfig::DeferredToParallel {
            cli_branches,
            cli_remotes,
            cli_full,
        },
        show_progress,
        render_table,
    )?;

    let Some(ListData { items, .. }) = list_data else {
        return Ok(());
    };

    match format {
        crate::OutputFormat::Json => {
            // Convert to new JSON structure
            let json_items = json_output::to_json_items(&items, &repo);
            let json =
                serde_json::to_string_pretty(&json_items).context("Failed to serialize to JSON")?;
            println!("{}", json);
        }
        crate::OutputFormat::Table | crate::OutputFormat::ClaudeCode => {
            // Table and summary already rendered in collect() for all modes
            // Nothing to do here - collect() handles the complete table rendering
        }
    }

    Ok(())
}

#[derive(Default)]
pub(super) struct SummaryMetrics {
    worktrees: usize,
    local_branches: usize,
    remote_branches: usize,
    dirty_worktrees: usize,
    ahead_items: usize,
}

impl SummaryMetrics {
    pub(super) fn from_items(items: &[ListItem]) -> Self {
        let mut metrics = Self::default();
        for item in items {
            metrics.update(item);
        }
        metrics
    }

    fn update(&mut self, item: &ListItem) {
        if let Some(_data) = item.worktree_data() {
            self.worktrees += 1;
            // Use status_symbols.working_tree which includes untracked files,
            // not just working_tree_diff which only has tracked changes.
            // `None` means gate 1 hasn't resolved yet — don't count.
            if item
                .status_symbols
                .working_tree
                .is_some_and(|wt| wt.is_dirty())
            {
                self.dirty_worktrees += 1;
            }
        } else {
            // Distinguish local vs remote branches by presence of '/' in name
            // Remote branches are like "origin/feature", local are like "feature"
            if item.branch.as_ref().is_some_and(|b| b.contains('/')) {
                self.remote_branches += 1;
            } else {
                self.local_branches += 1;
            }
        }

        if item.counts.is_some_and(|c| c.ahead > 0) {
            self.ahead_items += 1;
        }
    }

    pub(super) fn summary_parts(
        &self,
        include_branches: bool,
        hidden_columns: usize,
    ) -> Vec<String> {
        let mut parts = Vec::new();

        if include_branches {
            parts.push(format!("{} worktrees", self.worktrees));
            if self.local_branches > 0 {
                parts.push(format!("{} branches", self.local_branches));
            }
            if self.remote_branches > 0 {
                parts.push(format!("{} remote branches", self.remote_branches));
            }
        } else {
            let plural = if self.worktrees == 1 { "" } else { "s" };
            parts.push(format!("{} worktree{}", self.worktrees, plural));
        }

        if self.dirty_worktrees > 0 {
            parts.push(format!("{} with changes", self.dirty_worktrees));
        }

        if self.ahead_items > 0 {
            parts.push(format!("{} ahead", self.ahead_items));
        }

        if hidden_columns > 0 {
            let plural = if hidden_columns == 1 {
                "column"
            } else {
                "columns"
            };
            parts.push(format!("{} {} hidden", hidden_columns, plural));
        }

        parts
    }
}

/// Format a summary message for the given items (used by both collect.rs and mod.rs)
pub(crate) fn format_summary_message(
    items: &[ListItem],
    show_branches: bool,
    hidden_column_count: usize,
    error_count: usize,
    timed_out_count: usize,
) -> String {
    let metrics = SummaryMetrics::from_items(items);
    let dim = Style::new().dimmed();
    let summary = metrics
        .summary_parts(show_branches, hidden_column_count)
        .join(", ");

    if error_count > 0 {
        let failure_msg = if error_count == timed_out_count {
            // All failures are timeouts
            let plural = if timed_out_count == 1 { "" } else { "s" };
            format!("{timed_out_count} task{plural} timed out")
        } else if timed_out_count > 0 {
            // Mix of timeouts and other errors
            let plural = if error_count == 1 { "" } else { "s" };
            format!("{error_count} task{plural} failed ({timed_out_count} timed out)")
        } else {
            // No timeouts, just other errors
            let plural = if error_count == 1 { "" } else { "s" };
            format!("{error_count} task{plural} failed")
        };
        format!("{INFO_SYMBOL} {dim}Showing {summary}. {failure_msg}{dim:#}")
    } else {
        format!("{INFO_SYMBOL} {dim}Showing {summary}{dim:#}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_summary_metrics_default() {
        let metrics = SummaryMetrics::default();
        assert_eq!(metrics.worktrees, 0);
        assert_eq!(metrics.local_branches, 0);
        assert_eq!(metrics.remote_branches, 0);
        assert_eq!(metrics.dirty_worktrees, 0);
        assert_eq!(metrics.ahead_items, 0);
    }

    #[test]
    fn test_summary_metrics_summary_parts_single_worktree() {
        let metrics = SummaryMetrics {
            worktrees: 1,
            local_branches: 0,
            remote_branches: 0,
            dirty_worktrees: 0,
            ahead_items: 0,
        };
        let parts = metrics.summary_parts(false, 0);
        assert_eq!(parts, vec!["1 worktree"]);
    }

    #[test]
    fn test_summary_metrics_summary_parts_multiple_worktrees() {
        let metrics = SummaryMetrics {
            worktrees: 3,
            local_branches: 0,
            remote_branches: 0,
            dirty_worktrees: 0,
            ahead_items: 0,
        };
        let parts = metrics.summary_parts(false, 0);
        assert_eq!(parts, vec!["3 worktrees"]);
    }

    #[test]
    fn test_summary_metrics_summary_parts_with_branches() {
        let metrics = SummaryMetrics {
            worktrees: 2,
            local_branches: 5,
            remote_branches: 10,
            dirty_worktrees: 0,
            ahead_items: 0,
        };
        let parts = metrics.summary_parts(true, 0);
        assert_eq!(
            parts,
            vec!["2 worktrees", "5 branches", "10 remote branches"]
        );
    }

    #[test]
    fn test_summary_metrics_summary_parts_with_dirty() {
        let metrics = SummaryMetrics {
            worktrees: 3,
            local_branches: 0,
            remote_branches: 0,
            dirty_worktrees: 2,
            ahead_items: 0,
        };
        let parts = metrics.summary_parts(false, 0);
        assert_eq!(parts, vec!["3 worktrees", "2 with changes"]);
    }

    #[test]
    fn test_summary_metrics_summary_parts_with_ahead() {
        let metrics = SummaryMetrics {
            worktrees: 2,
            local_branches: 0,
            remote_branches: 0,
            dirty_worktrees: 0,
            ahead_items: 1,
        };
        let parts = metrics.summary_parts(false, 0);
        assert_eq!(parts, vec!["2 worktrees", "1 ahead"]);
    }

    #[test]
    fn test_summary_metrics_summary_parts_with_hidden_columns() {
        let metrics = SummaryMetrics {
            worktrees: 1,
            local_branches: 0,
            remote_branches: 0,
            dirty_worktrees: 0,
            ahead_items: 0,
        };
        let parts = metrics.summary_parts(false, 1);
        assert_eq!(parts, vec!["1 worktree", "1 column hidden"]);

        let parts = metrics.summary_parts(false, 3);
        assert_eq!(parts, vec!["1 worktree", "3 columns hidden"]);
    }

    #[test]
    fn test_summary_metrics_summary_parts_branches_no_local() {
        let metrics = SummaryMetrics {
            worktrees: 2,
            local_branches: 0,
            remote_branches: 5,
            dirty_worktrees: 0,
            ahead_items: 0,
        };
        let parts = metrics.summary_parts(true, 0);
        assert_eq!(parts, vec!["2 worktrees", "5 remote branches"]);
    }

    #[test]
    fn test_summary_metrics_summary_parts_all_features() {
        let metrics = SummaryMetrics {
            worktrees: 5,
            local_branches: 3,
            remote_branches: 8,
            dirty_worktrees: 2,
            ahead_items: 4,
        };
        let parts = metrics.summary_parts(true, 2);
        assert_eq!(
            parts,
            vec![
                "5 worktrees",
                "3 branches",
                "8 remote branches",
                "2 with changes",
                "4 ahead",
                "2 columns hidden"
            ]
        );
    }

    #[test]
    fn test_format_summary_message_error_variants() {
        use insta::assert_snapshot;

        // No errors
        assert_snapshot!(format_summary_message(&[], false, 0, 0, 0), @"â—‹ Showing 0 worktrees");
        // All timeouts
        assert_snapshot!(format_summary_message(&[], false, 0, 3, 3), @"â—‹ Showing 0 worktrees. 3 tasks timed out");
        // Mixed errors and timeouts
        assert_snapshot!(format_summary_message(&[], false, 0, 5, 3), @"â—‹ Showing 0 worktrees. 5 tasks failed (3 timed out)");
        // Only failures, no timeouts
        assert_snapshot!(format_summary_message(&[], false, 0, 2, 0), @"â—‹ Showing 0 worktrees. 2 tasks failed");
        // Single error
        assert_snapshot!(format_summary_message(&[], false, 0, 1, 0), @"â—‹ Showing 0 worktrees. 1 task failed");
        // Single timeout
        assert_snapshot!(format_summary_message(&[], false, 0, 1, 1), @"â—‹ Showing 0 worktrees. 1 task timed out");
    }
}