worktrunk 0.35.3

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
//! Work item generation and execution.
//!
//! Contains the flat parallelism infrastructure:
//! - `WorkItem` - unit of work for the thread pool
//! - `dispatch_task()` - route TaskKind to the correct Task implementation
//! - `work_items_for_worktree()` / `work_items_for_branch()` - generate work items
//! - `ExpectedResults` - track expected results for timeout diagnostics

use std::sync::Arc;

use crossbeam_channel as chan;
use worktrunk::git::{BranchRef, Repository, WorktreeInfo};

use super::CollectOptions;
use super::tasks::{
    AheadBehindTask, BranchDiffTask, CiStatusTask, CommitDetailsTask, CommittedTreesMatchTask,
    GitOperationTask, HasFileChangesTask, IsAncestorTask, MergeTreeConflictsTask,
    SummaryGenerateTask, Task, TaskContext, UpstreamTask, UrlStatusTask, UserMarkerTask,
    WorkingTreeConflictsTask, WorkingTreeDiffTask, WouldMergeAddTask,
};
use super::types::{TaskError, TaskKind, TaskResult};

// Tasks that are expensive because they require merge-base computation or merge simulation.
// These are skipped for branches that are far behind the default branch (in `wt switch` interactive picker).
// AheadBehind is NOT here - we use batch data for it instead of skipping.
// CommittedTreesMatch is NOT here - it's a cheap tree comparison that aids integration detection.
const EXPENSIVE_TASKS: &[TaskKind] = &[
    TaskKind::HasFileChanges,     // git diff with three-dot range
    TaskKind::IsAncestor,         // git merge-base --is-ancestor
    TaskKind::WouldMergeAdd,      // git merge-tree simulation
    TaskKind::BranchDiff,         // git diff with three-dot range
    TaskKind::MergeTreeConflicts, // git merge-tree simulation
];

/// Tasks that require a valid commit SHA. Skipped for unborn branches (no commits yet).
/// Without this, these tasks would fail on the null OID and show as errors in the table.
const COMMIT_TASKS: &[TaskKind] = &[
    TaskKind::CommitDetails,
    TaskKind::AheadBehind,
    TaskKind::CommittedTreesMatch,
    TaskKind::HasFileChanges,
    TaskKind::IsAncestor,
    TaskKind::BranchDiff,
    TaskKind::MergeTreeConflicts,
    TaskKind::WouldMergeAdd,
    TaskKind::CiStatus,
    TaskKind::Upstream,
];

// ============================================================================
// Work Item Dispatch (for flat parallelism)
// ============================================================================

/// A unit of work for the thread pool.
///
/// Each work item represents a single task to be executed. Work items are
/// collected upfront and then processed in parallel via Rayon's thread pool,
/// avoiding nested parallelism (Rayon par_iter → thread::scope).
#[derive(Clone)]
pub struct WorkItem {
    pub ctx: TaskContext,
    pub kind: TaskKind,
}

impl WorkItem {
    /// Execute this work item, returning the task result.
    pub fn execute(self) -> Result<TaskResult, TaskError> {
        let result = dispatch_task(self.kind, self.ctx);
        if let Ok(ref task_result) = result {
            debug_assert_eq!(TaskKind::from(task_result), self.kind);
        }
        result
    }
}

/// Dispatch a task by kind, calling the appropriate Task::compute().
fn dispatch_task(kind: TaskKind, ctx: TaskContext) -> Result<TaskResult, TaskError> {
    match kind {
        TaskKind::CommitDetails => CommitDetailsTask::compute(ctx),
        TaskKind::AheadBehind => AheadBehindTask::compute(ctx),
        TaskKind::CommittedTreesMatch => CommittedTreesMatchTask::compute(ctx),
        TaskKind::HasFileChanges => HasFileChangesTask::compute(ctx),
        TaskKind::WouldMergeAdd => WouldMergeAddTask::compute(ctx),
        TaskKind::IsAncestor => IsAncestorTask::compute(ctx),
        TaskKind::BranchDiff => BranchDiffTask::compute(ctx),
        TaskKind::WorkingTreeDiff => WorkingTreeDiffTask::compute(ctx),
        TaskKind::MergeTreeConflicts => MergeTreeConflictsTask::compute(ctx),
        TaskKind::WorkingTreeConflicts => WorkingTreeConflictsTask::compute(ctx),
        TaskKind::GitOperation => GitOperationTask::compute(ctx),
        TaskKind::UserMarker => UserMarkerTask::compute(ctx),
        TaskKind::Upstream => UpstreamTask::compute(ctx),
        TaskKind::CiStatus => CiStatusTask::compute(ctx),
        TaskKind::UrlStatus => UrlStatusTask::compute(ctx),
        TaskKind::SummaryGenerate => SummaryGenerateTask::compute(ctx),
    }
}

// ============================================================================
// Expected Results Tracking
// ============================================================================

/// Tracks expected result types per item for timeout diagnostics.
///
/// Populated at spawn time so we know exactly which results to expect,
/// without hardcoding result lists that could drift from the spawn functions.
#[derive(Default)]
pub(crate) struct ExpectedResults {
    inner: std::sync::Mutex<Vec<Vec<TaskKind>>>,
}

impl ExpectedResults {
    /// Record that we expect a result of the given kind for the given item.
    /// Called internally by `TaskSpawner::spawn()`.
    pub fn expect(&self, item_idx: usize, kind: TaskKind) {
        let mut inner = self.inner.lock().unwrap();
        if inner.len() <= item_idx {
            inner.resize_with(item_idx + 1, Vec::new);
        }
        inner[item_idx].push(kind);
    }

    /// Total number of expected results (for progress display).
    pub fn count(&self) -> usize {
        self.inner.lock().unwrap().iter().map(|v| v.len()).sum()
    }

    /// Expected results for a specific item.
    pub fn results_for(&self, item_idx: usize) -> Vec<TaskKind> {
        self.inner
            .lock()
            .unwrap()
            .get(item_idx)
            .cloned()
            .unwrap_or_default()
    }
}

// ============================================================================
// Work Item Generation
// ============================================================================

/// Generate work items for a worktree.
///
/// Returns a list of work items representing all tasks that should run for this
/// worktree. Expected results are registered internally as each work item is added.
/// The caller is responsible for executing the work items.
///
/// Task preconditions (stale branch, unborn branch, missing llm_command) are
/// enforced here — not in callers. This function is called from both `collect()`
/// and `populate_item()`, so guards must live here to cover all entry points.
///
/// The `repo` parameter is cloned into each TaskContext, sharing its cache via Arc.
pub fn work_items_for_worktree(
    repo: &Repository,
    wt: &WorktreeInfo,
    item_idx: usize,
    options: &CollectOptions,
    expected_results: &Arc<ExpectedResults>,
    tx: &chan::Sender<Result<TaskResult, TaskError>>,
) -> Vec<WorkItem> {
    // Skip git operations for prunable worktrees (directory missing).
    if wt.is_prunable() {
        return vec![];
    }

    let skip = &options.skip_tasks;

    let include_url = !skip.contains(&TaskKind::UrlStatus);

    // Expand URL template for this item (only if URL status is enabled).
    let item_url = if include_url {
        options.url_template.as_ref().and_then(|template| {
            wt.branch.as_ref().and_then(|branch| {
                let mut vars = std::collections::HashMap::new();
                vars.insert("branch", branch.as_str());
                worktrunk::config::expand_template(template, &vars, false, repo, "url-template")
                    .ok()
            })
        })
    } else {
        None
    };

    // Send URL immediately (before health check) so it appears right away.
    // The UrlStatusTask will later update with active status.
    if include_url && let Some(ref url) = item_url {
        expected_results.expect(item_idx, TaskKind::UrlStatus);
        let _ = tx.send(Ok(TaskResult::UrlStatus {
            item_idx,
            url: Some(url.clone()),
            active: None,
        }));
    }

    let ctx = TaskContext {
        repo: repo.clone(),
        branch_ref: BranchRef::from(wt),
        item_idx,
        item_url,
        llm_command: options.llm_command.clone(),
    };

    // Check if this branch is stale and should skip expensive tasks.
    let is_stale = wt
        .branch
        .as_deref()
        .is_some_and(|b| options.stale_branches.contains(b));

    let has_commits = wt.has_commits();

    let mut items = Vec::with_capacity(15);

    // Helper to add a work item and register the expected result
    let mut add_item = |kind: TaskKind| {
        expected_results.expect(item_idx, kind);
        items.push(WorkItem {
            ctx: ctx.clone(),
            kind,
        });
    };

    for kind in [
        TaskKind::CommitDetails,
        TaskKind::AheadBehind,
        TaskKind::CommittedTreesMatch,
        TaskKind::HasFileChanges,
        TaskKind::IsAncestor,
        TaskKind::Upstream,
        TaskKind::WorkingTreeDiff,
        TaskKind::GitOperation,
        TaskKind::UserMarker,
        TaskKind::WorkingTreeConflicts,
        TaskKind::BranchDiff,
        TaskKind::MergeTreeConflicts,
        TaskKind::CiStatus,
        TaskKind::WouldMergeAdd,
        TaskKind::SummaryGenerate,
    ] {
        if skip.contains(&kind) {
            continue;
        }
        // Skip expensive tasks for stale branches (far behind default branch)
        if is_stale && EXPENSIVE_TASKS.contains(&kind) {
            continue;
        }
        // Skip commit-dependent tasks for unborn branches (no commits yet)
        if !has_commits && COMMIT_TASKS.contains(&kind) {
            continue;
        }
        // Skip SummaryGenerate when no LLM command is configured
        if kind == TaskKind::SummaryGenerate && options.llm_command.is_none() {
            continue;
        }
        add_item(kind);
    }
    // URL status health check task (if we have a URL).
    // Note: We already registered and sent an immediate UrlStatus above with url + active=None.
    // This work item will send a second UrlStatus with active=Some(bool) after health check.
    // Both results must be registered and expected.
    if include_url && ctx.item_url.is_some() {
        expected_results.expect(item_idx, TaskKind::UrlStatus);
        items.push(WorkItem {
            ctx: ctx.clone(),
            kind: TaskKind::UrlStatus,
        });
    }

    items
}

/// Generate work items for a branch (no worktree).
///
/// Returns a list of work items representing all tasks that should run for this
/// branch. Branches have fewer tasks than worktrees (no working tree operations).
///
/// Task preconditions are enforced here, same as [`work_items_for_worktree`].
///
/// The `repo` parameter is cloned into each TaskContext, sharing its cache via Arc.
/// The `is_remote` flag indicates whether this is a remote-tracking branch (e.g., "origin/feature")
/// vs a local branch. This is known definitively at collection time and avoids guessing later.
pub fn work_items_for_branch(
    repo: &Repository,
    branch_name: &str,
    commit_sha: &str,
    item_idx: usize,
    is_remote: bool,
    options: &CollectOptions,
    expected_results: &Arc<ExpectedResults>,
) -> Vec<WorkItem> {
    let skip = &options.skip_tasks;

    let branch_ref = if is_remote {
        BranchRef::remote_branch(branch_name, commit_sha)
    } else {
        BranchRef::local_branch(branch_name, commit_sha)
    };

    let ctx = TaskContext {
        repo: repo.clone(),
        branch_ref,
        item_idx,
        item_url: None, // Branches without worktrees don't have URLs
        llm_command: options.llm_command.clone(),
    };

    // Check if this branch is stale and should skip expensive tasks.
    let is_stale = options.stale_branches.contains(branch_name);

    let mut items = Vec::with_capacity(11);

    // Helper to add a work item and register the expected result
    let mut add_item = |kind: TaskKind| {
        expected_results.expect(item_idx, kind);
        items.push(WorkItem {
            ctx: ctx.clone(),
            kind,
        });
    };

    for kind in [
        TaskKind::CommitDetails,
        TaskKind::AheadBehind,
        TaskKind::CommittedTreesMatch,
        TaskKind::HasFileChanges,
        TaskKind::IsAncestor,
        TaskKind::Upstream,
        TaskKind::BranchDiff,
        TaskKind::MergeTreeConflicts,
        TaskKind::CiStatus,
        TaskKind::WouldMergeAdd,
        TaskKind::SummaryGenerate,
    ] {
        if skip.contains(&kind) {
            continue;
        }
        // Skip expensive tasks for stale branches (far behind default branch)
        if is_stale && EXPENSIVE_TASKS.contains(&kind) {
            continue;
        }
        // Skip SummaryGenerate when no LLM command is configured
        if kind == TaskKind::SummaryGenerate && options.llm_command.is_none() {
            continue;
        }
        add_item(kind);
    }

    items
}

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

    #[test]
    fn test_skip_url_status_suppresses_placeholder_and_task() {
        let test = worktrunk::testing::TestRepo::new();
        let repo = Repository::at(test.path()).expect("repo");
        let wt = WorktreeInfo {
            path: test.path().to_path_buf(),
            head: "deadbeef".to_string(),
            branch: Some("main".to_string()),
            bare: false,
            detached: false,
            locked: None,
            prunable: None,
        };

        let skip_tasks: HashSet<TaskKind> = [TaskKind::UrlStatus].into_iter().collect();
        let options = CollectOptions {
            skip_tasks,
            url_template: Some("http://localhost/{{ branch }}".to_string()),
            llm_command: None,
            stale_branches: HashSet::new(),
        };

        let expected_results = Arc::new(ExpectedResults::default());
        let (tx, rx) = chan::unbounded::<Result<TaskResult, TaskError>>();

        let items = work_items_for_worktree(&repo, &wt, 0, &options, &expected_results, &tx);

        // No placeholder sent
        assert!(rx.try_recv().is_err());
        // No UrlStatus work item created
        assert!(!items.iter().any(|item| item.kind == TaskKind::UrlStatus));
        // No UrlStatus in expected results
        assert!(
            !expected_results
                .results_for(0)
                .contains(&TaskKind::UrlStatus)
        );
        // item_url is None for all items
        assert!(items.iter().all(|item| item.ctx.item_url.is_none()));
    }

    #[test]
    fn test_no_llm_command_skips_summary_generate() {
        let test = worktrunk::testing::TestRepo::new();
        let repo = Repository::at(test.path()).expect("repo");
        let wt = WorktreeInfo {
            path: test.path().to_path_buf(),
            head: "deadbeef".to_string(),
            branch: Some("main".to_string()),
            bare: false,
            detached: false,
            locked: None,
            prunable: None,
        };

        // No llm_command, no skip_tasks — SummaryGenerate should still be skipped
        let options = CollectOptions {
            skip_tasks: HashSet::new(),
            llm_command: None,
            stale_branches: HashSet::new(),
            ..Default::default()
        };

        let expected_results = Arc::new(ExpectedResults::default());
        let (tx, _rx) = chan::unbounded::<Result<TaskResult, TaskError>>();

        let items = work_items_for_worktree(&repo, &wt, 0, &options, &expected_results, &tx);

        assert!(
            !items
                .iter()
                .any(|item| item.kind == TaskKind::SummaryGenerate),
            "SummaryGenerate should be skipped when llm_command is None"
        );
    }
}