git-parsec 0.3.0

Git worktree lifecycle manager — ticket to PR in one command. Parallel AI agent workflows with Jira & GitHub Issues integration.
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
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
mod commands;

use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;

use crate::output;

#[derive(Parser)]
#[command(
    name = "parsec",
    about = "Git worktree lifecycle manager for parallel AI agent workflows",
    long_about = "Git worktree lifecycle manager for parallel AI agent workflows.\n\nCreate isolated workspaces tied to issue tickets (Jira, GitHub Issues, GitLab),\nwork in parallel without lock conflicts, and ship with one command.\n\nQuick start:\n  parsec start PROJ-123          Create workspace for a ticket\n  parsec list                    See all active workspaces\n  parsec switch PROJ-123         Jump into a workspace\n  parsec ship PROJ-123           Push + PR + cleanup\n  parsec log                     View operation history\n  parsec undo                    Revert last operation",
    version,
    arg_required_else_help = true
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Command,

    /// Output in JSON format (machine-readable)
    #[arg(long, global = true)]
    pub json: bool,

    /// Suppress non-essential output
    #[arg(long, short, global = true)]
    pub quiet: bool,

    /// Target repository path (default: current directory)
    #[arg(long, global = true)]
    pub repo: Option<PathBuf>,
}

#[derive(Subcommand)]
pub enum Command {
    /// Create a new worktree for a ticket
    ///
    /// Creates an isolated git worktree linked to a ticket identifier.
    /// If a tracker (Jira/GitHub Issues) is configured, the ticket title
    /// is fetched automatically. Use --title to set it manually.
    Start {
        /// Ticket identifier (e.g., PROJ-1234, #42)
        ticket: String,

        /// Base branch to create from (default: main/master)
        #[arg(long, short)]
        base: Option<String>,

        /// Manually set the ticket title (skips tracker lookup)
        #[arg(long)]
        title: Option<String>,

        /// Create stacked on another ticket's branch
        #[arg(long)]
        on: Option<String>,

        /// Use an existing branch instead of creating a new one
        #[arg(long = "branch")]
        existing_branch: Option<String>,
    },

    /// List all active worktrees
    ///
    /// Shows a table of all parsec-managed worktrees with ticket, branch,
    /// status, creation time, and path. Use --json for machine-readable output.
    /// PR status is fetched automatically; use --no-pr to skip API calls.
    List {
        /// Skip PR status lookup (faster, works offline)
        #[arg(long)]
        no_pr: bool,
    },

    /// Show detailed status of a workspace
    ///
    /// Displays full details for one or all workspaces including ticket title,
    /// branch, base branch, status, and path.
    Status {
        /// Ticket identifier (optional, shows all if omitted)
        ticket: Option<String>,
    },

    /// View ticket details from tracker
    ///
    /// Fetches and displays ticket information (title, status, assignee)
    /// from the configured tracker. Auto-detects the ticket from the
    /// current worktree if no ticket is specified.
    Ticket {
        /// Ticket identifier (auto-detects from current worktree if omitted)
        ticket: Option<String>,

        /// Post a comment on the ticket
        #[arg(long)]
        comment: Option<String>,
    },

    /// Push, create PR/MR, and clean up a workspace
    ///
    /// Pushes the branch to remote, creates a GitHub PR or GitLab MR
    /// with the ticket title, and removes the worktree. The forge type
    /// is auto-detected from the remote URL.
    Ship {
        /// Ticket identifier
        ticket: String,

        /// Create PR as draft
        #[arg(long)]
        draft: bool,

        /// Skip PR creation, only push
        #[arg(long)]
        no_pr: bool,

        /// Target base branch for PR (default from config or worktree base)
        #[arg(long)]
        base: Option<String>,
    },

    /// Remove merged or stale worktrees
    ///
    /// By default, only removes worktrees whose branches have been merged
    /// into the base branch. Use --all to remove everything.
    Clean {
        /// Remove all worktrees (including unmerged)
        #[arg(long)]
        all: bool,

        /// Dry run - show what would be removed
        #[arg(long)]
        dry_run: bool,

        /// Remove orphan entries (state entries without existing directory)
        #[arg(long)]
        orphans: bool,
    },

    /// Detect file conflicts across active worktrees
    ///
    /// Compares modified files across all active worktrees and reports
    /// any files that are being edited in more than one workspace.
    Conflicts,

    /// Check PR/MR CI and review status
    ///
    /// Shows CI check results, review approvals, and merge status for
    /// shipped PRs. Requires a GitHub/GitLab token.
    PrStatus {
        /// Ticket identifier (shows all shipped if omitted)
        ticket: Option<String>,
    },

    /// Merge a ticket's PR on GitHub and clean up
    ///
    /// Merges the PR via the GitHub API. By default uses squash merge
    /// and waits for CI to pass before merging. Cleans up the local
    /// worktree after a successful merge.
    Merge {
        /// Ticket identifier (auto-detects current worktree if omitted)
        ticket: Option<String>,
        /// Use rebase merge instead of squash
        #[arg(long)]
        rebase: bool,
        /// Skip waiting for CI to pass
        #[arg(long)]
        no_wait: bool,
        /// Keep remote branch after merge (default: delete)
        #[arg(long)]
        no_delete_branch: bool,
    },

    /// Check CI/CD pipeline status for a ticket's PR
    ///
    /// Shows individual check runs with status, duration, and overall
    /// summary. Auto-detects the current worktree if no ticket is given.
    /// Use --watch to poll until all checks complete.
    Ci {
        /// Ticket identifier (auto-detects current worktree if omitted)
        ticket: Option<String>,
        /// Watch CI in real-time until completion (refresh every 5s)
        #[arg(long)]
        watch: bool,
        /// Show CI for all shipped PRs
        #[arg(long)]
        all: bool,
    },

    /// View changes in a worktree compared to base branch
    ///
    /// Shows the diff between the worktree branch and its base branch
    /// using the merge-base as the comparison point.
    Diff {
        /// Ticket identifier (auto-detects current worktree if omitted)
        ticket: Option<String>,
        /// Show file-level summary only
        #[arg(long)]
        stat: bool,
        /// List changed file names only
        #[arg(long)]
        name_only: bool,
    },

    /// Print workspace path for a ticket (use with cd)
    ///
    /// Outputs the absolute path to the worktree for a given ticket.
    /// When called without a ticket, shows an interactive picker.
    /// With shell integration (eval "$(parsec init zsh)"),
    /// this command changes your directory automatically.
    Switch {
        /// Ticket identifier (interactive picker if omitted)
        ticket: Option<String>,
    },

    /// Sync worktree with latest base branch changes
    ///
    /// Fetches the latest changes from the remote base branch and rebases
    /// (or merges) the worktree branch on top. Use --all to sync every
    /// active worktree at once. Strategy is configurable in config.toml.
    Sync {
        /// Ticket identifier (syncs current worktree if omitted)
        ticket: Option<String>,

        /// Sync all active worktrees
        #[arg(long)]
        all: bool,

        /// Sync strategy: rebase or merge (default: rebase)
        #[arg(long, default_value = "rebase")]
        strategy: String,
    },

    /// Open PR/MR or ticket page in browser
    ///
    /// Opens the associated PR/MR URL (if shipped) or the ticket tracker
    /// page in your default browser. Use --pr to force opening the PR,
    /// or --ticket to force opening the tracker page.
    Open {
        /// Ticket identifier
        ticket: String,

        /// Force open the PR/MR page
        #[arg(long)]
        pr: bool,

        /// Force open the ticket tracker page
        #[arg(long)]
        ticket_page: bool,
    },

    /// Import an existing branch into parsec management
    ///
    /// Brings an existing branch under parsec lifecycle management.
    /// Useful when you started work before using parsec or when
    /// taking over someone else's branch.
    Adopt {
        /// Ticket identifier to associate with the branch
        ticket: String,

        /// Branch name to adopt (default: current branch)
        #[arg(long, short)]
        branch: Option<String>,

        /// Ticket title (optional)
        #[arg(long)]
        title: Option<String>,
    },

    /// Show operation history
    ///
    /// Displays a table of all recorded parsec operations (start, adopt,
    /// ship, clean, undo) with timestamps. Filter by ticket or limit
    /// the number of entries shown.
    Log {
        /// Filter by ticket identifier
        ticket: Option<String>,

        /// Show last N entries (default: 20)
        #[arg(long, short = 'n', default_value = "20")]
        last: usize,
    },

    /// Undo the last parsec operation
    ///
    /// Reverses the most recent parsec operation:
    ///   start/adopt → removes worktree and deletes branch
    ///   ship/clean  → restores worktree from branch
    /// Use --dry-run to preview before executing.
    Undo {
        /// Preview what would be undone without making changes
        #[arg(long)]
        dry_run: bool,
    },

    /// List assigned tickets without active worktrees
    ///
    /// Fetches tickets assigned to you from Jira that don't yet have a
    /// parsec worktree. Shows a table of ticket key, title, priority,
    /// and status. Use --pick to interactively select one and auto-start
    /// a workspace.
    Inbox {
        /// Interactively pick a ticket and run `parsec start`
        #[arg(long)]
        pick: bool,
    },

    /// Show the sprint board as a Kanban view
    ///
    /// Fetches the active sprint from Jira and displays tickets grouped
    /// by status column. Active worktrees are marked with [wt] and
    /// shipped PRs with [pr]. Currently supports Jira only.
    Board {
        /// Jira board ID (auto-detected from project if omitted)
        #[arg(long)]
        board_id: Option<u64>,

        /// Jira project key (inferred from active worktrees if omitted)
        #[arg(long, short)]
        project: Option<String>,

        /// Filter by assignee (default from config/env)
        #[arg(long)]
        assignee: Option<String>,

        /// Show all tickets (ignore assignee filter)
        #[arg(long)]
        all: bool,
    },

    /// Show or manage stacked PR dependencies
    ///
    /// Displays the dependency graph of worktrees created with --on.
    /// Use `parsec stack --sync` to rebase the entire chain.
    Stack {
        /// Sync the entire stack (rebase chain)
        #[arg(long)]
        sync: bool,
    },

    /// Print the main repository root path
    ///
    /// Outputs the absolute path to the main (non-worktree) repository root.
    /// Useful for scripting and shell integration after worktree cleanup.
    Root,

    /// Output shell integration script
    ///
    /// Prints a shell function that wraps parsec for auto-cd on switch
    /// and auto-recovery after merge cleanup. Supports zsh and bash.
    /// Add eval "$(parsec init zsh)" to your ~/.zshrc.
    Init {
        /// Shell type (zsh or bash)
        #[arg(default_value = "zsh")]
        shell: String,
    },

    /// Configure parsec
    ///
    /// Manage parsec configuration: run interactive setup, view current
    /// settings, or output shell integration scripts.
    Config {
        #[command(subcommand)]
        action: ConfigAction,
    },
}

#[derive(Subcommand)]
pub enum ConfigAction {
    /// Interactive configuration setup
    ///
    /// Walks through tracker provider, branch prefix, ship behavior,
    /// and other settings interactively.
    Init,
    /// Show current configuration
    ///
    /// Prints the active configuration from ~/.config/parsec/config.toml.
    Show,
    /// Output shell integration script (deprecated: use `parsec init` instead)
    ///
    /// Prints a shell function that wraps parsec switch to auto-cd.
    /// Prefer `parsec init zsh` which also handles merge CWD recovery.
    Shell {
        /// Shell type (zsh or bash)
        #[arg(default_value = "zsh")]
        shell: String,
    },
    /// Install man page
    ///
    /// Generates and installs the parsec(1) man page so that
    /// `man parsec` works. Requires write access to the man directory.
    Man {
        /// Man page base directory (default: /usr/local/share/man)
        #[arg(long, default_value = "/usr/local/share/man")]
        dir: PathBuf,
    },
    /// Output shell completions
    ///
    /// Generates tab-completion scripts for your shell.
    /// Add eval "$(parsec config completions zsh)" to your ~/.zshrc.
    Completions {
        /// Shell type (zsh, bash, fish, elvish, powershell)
        shell: clap_complete::Shell,
    },
}

pub async fn run(cli: Cli) -> Result<()> {
    let repo_path = cli.repo.unwrap_or_else(|| PathBuf::from("."));
    let output_mode = if cli.json {
        output::Mode::Json
    } else if cli.quiet {
        output::Mode::Quiet
    } else {
        output::Mode::Human
    };

    match cli.command {
        Command::Start {
            ticket,
            base,
            title,
            on,
            existing_branch,
        } => {
            commands::start(
                &repo_path,
                &ticket,
                base.as_deref(),
                title,
                on.as_deref(),
                existing_branch.as_deref(),
                output_mode,
            )
            .await
        }
        Command::List { no_pr } => commands::list(&repo_path, no_pr, output_mode).await,
        Command::Status { ticket } => {
            commands::status(&repo_path, ticket.as_deref(), output_mode).await
        }
        Command::Ticket { ticket, comment } => {
            commands::ticket(&repo_path, ticket.as_deref(), comment, output_mode).await
        }
        Command::Ship {
            ticket,
            draft,
            no_pr,
            base,
        } => commands::ship(&repo_path, &ticket, draft, no_pr, base, output_mode).await,
        Command::Clean {
            all,
            dry_run,
            orphans,
        } => commands::clean(&repo_path, all, dry_run, orphans, output_mode).await,
        Command::Sync {
            ticket,
            all,
            strategy,
        } => commands::sync(&repo_path, ticket.as_deref(), all, &strategy, output_mode).await,
        Command::Adopt {
            ticket,
            branch,
            title,
        } => commands::adopt(&repo_path, &ticket, branch.as_deref(), title, output_mode).await,
        Command::Open {
            ticket,
            pr,
            ticket_page,
        } => commands::open(&repo_path, &ticket, pr, ticket_page, output_mode).await,
        Command::PrStatus { ticket } => {
            commands::pr_status(&repo_path, ticket.as_deref(), output_mode).await
        }
        Command::Merge {
            ticket,
            rebase,
            no_wait,
            no_delete_branch,
        } => {
            commands::merge(
                &repo_path,
                ticket.as_deref(),
                rebase,
                no_wait,
                no_delete_branch,
                output_mode,
            )
            .await
        }
        Command::Ci { ticket, watch, all } => {
            commands::ci(&repo_path, ticket.as_deref(), watch, all, output_mode).await
        }
        Command::Diff {
            ticket,
            stat,
            name_only,
        } => commands::diff(&repo_path, ticket.as_deref(), stat, name_only, output_mode).await,
        Command::Conflicts => commands::conflicts(&repo_path, output_mode).await,
        Command::Switch { ticket } => {
            commands::switch(&repo_path, ticket.as_deref(), output_mode).await
        }
        Command::Log { ticket, last } => {
            commands::log(&repo_path, ticket.as_deref(), last, output_mode).await
        }
        Command::Undo { dry_run } => commands::undo(&repo_path, dry_run, output_mode).await,
        Command::Inbox { pick } => commands::inbox(&repo_path, pick, output_mode).await,
        Command::Board {
            board_id,
            project,
            assignee,
            all,
        } => commands::board(&repo_path, board_id, project, assignee, all, output_mode).await,
        Command::Stack { sync } => {
            if sync {
                commands::stack_sync(&repo_path, output_mode).await
            } else {
                commands::stack(&repo_path, output_mode).await
            }
        }
        Command::Root => commands::root(&repo_path).await,
        Command::Init { shell } => commands::init_shell(&shell).await,
        Command::Config { action } => match action {
            ConfigAction::Init => commands::config_init(output_mode).await,
            ConfigAction::Show => commands::config_show(output_mode).await,
            ConfigAction::Shell { shell } => commands::config_shell(&shell, output_mode).await,
            ConfigAction::Man { dir } => commands::config_man(&dir).await,
            ConfigAction::Completions { shell } => commands::config_completions(shell).await,
        },
    }
}