use std::collections::HashSet;
use std::path::Path;
use anyhow::{Context, Result};
use colored::Colorize;
use crate::config::{ParsecConfig, TrackerProvider};
use crate::conflict;
use crate::git;
use crate::github;
use crate::gitlab;
use crate::output::{self, BoardTicketDisplay, Mode};
use crate::tracker;
use crate::tracker::jira::JiraTracker;
use crate::worktree::WorktreeManager;
pub async fn start(
repo: &Path,
ticket: &str,
base: Option<&str>,
title: Option<String>,
on: Option<&str>,
existing_branch: Option<&str>,
mode: Mode,
) -> Result<()> {
let mut config = ParsecConfig::load()?;
let repo_root = git::get_repo_root(repo)?;
config.resolve_for_repo(&repo_root);
let ticket_title = if let Some(t) = title {
Some(t)
} else {
match tracker::fetch_ticket(&config, ticket, Some(&repo_root)).await {
Ok(info) => info.map(|t| t.title),
Err(e) => {
eprintln!("warning: could not fetch ticket info: {e}");
None
}
}
};
let manager = WorktreeManager::new(repo, &config)?;
let workspace = manager.create(ticket, base, ticket_title, on, existing_branch)?;
output::print_start(&workspace, mode);
if let Some(ref auto) = config.tracker.auto_transition {
if let Some(ref status) = auto.on_start {
tracker::try_transition(&config, ticket, status).await;
}
}
if let Err(e) = crate::oplog::record(
manager.repo_root(),
crate::oplog::OpKind::Start,
Some(ticket),
&format!("Created workspace at {}", workspace.path.display()),
Some(crate::oplog::UndoInfo {
branch: Some(workspace.branch.clone()),
base_branch: Some(workspace.base_branch.clone()),
path: Some(workspace.path.clone()),
ticket_title: workspace.ticket_title.clone(),
}),
) {
eprintln!("warning: failed to write oplog: {e}");
}
Ok(())
}
pub async fn adopt(
repo: &Path,
ticket: &str,
branch: Option<&str>,
title: Option<String>,
mode: Mode,
) -> Result<()> {
let mut config = ParsecConfig::load()?;
let repo_root = git::get_repo_root(repo)?;
config.resolve_for_repo(&repo_root);
let ticket_title = if let Some(t) = title {
Some(t)
} else {
match tracker::fetch_ticket(&config, ticket, Some(&repo_root)).await {
Ok(info) => info.map(|t| t.title),
Err(_) => None,
}
};
let manager = WorktreeManager::new(repo, &config)?;
let workspace = manager.adopt(ticket, branch, ticket_title)?;
output::print_adopt(&workspace, mode);
if let Err(e) = crate::oplog::record(
manager.repo_root(),
crate::oplog::OpKind::Adopt,
Some(ticket),
&format!(
"Adopted branch '{}' at {}",
workspace.branch,
workspace.path.display()
),
Some(crate::oplog::UndoInfo {
branch: Some(workspace.branch.clone()),
base_branch: Some(workspace.base_branch.clone()),
path: Some(workspace.path.clone()),
ticket_title: workspace.ticket_title.clone(),
}),
) {
eprintln!("warning: failed to write oplog: {e}");
}
if let Ok(remote_url) = git::run_output(manager.repo_root(), &["remote", "get-url", "origin"]) {
if let Ok(Some(pr_number)) =
github::find_pr_by_branch(&remote_url, &workspace.branch, &config).await
{
let pr_url = if let Some(remote) = github::parse_github_remote(&remote_url) {
format!(
"https://{}/{}/{}/pull/{}",
remote.host, remote.owner, remote.repo, pr_number
)
} else {
format!("pull/{}", pr_number)
};
if let Err(e) = crate::oplog::record(
manager.repo_root(),
crate::oplog::OpKind::Ship,
Some(ticket),
&format!("Adopted branch '{}' -> {}", workspace.branch, pr_url),
None,
) {
eprintln!("warning: failed to record PR in oplog: {e}");
} else if mode != Mode::Quiet {
eprintln!(" Detected existing PR #{}", pr_number);
}
}
}
Ok(())
}
pub async fn list(repo: &Path, no_pr: bool, mode: Mode) -> Result<()> {
let config = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config)?;
let workspaces = manager.list()?;
let mut pr_map: std::collections::HashMap<String, (u64, String)> =
std::collections::HashMap::new();
if !no_pr {
if let Ok(oplog) = crate::oplog::OpLog::load(manager.repo_root()) {
let remote_url = git::get_remote_url(manager.repo_root()).ok();
for entry in &oplog.entries {
if matches!(entry.op, crate::oplog::OpKind::Ship) {
if let Some(ref ticket) = entry.ticket {
if let Some(pr_url) = extract_pr_url(&entry.detail) {
if let Some(pr_num) = extract_pr_number(&pr_url) {
pr_map
.entry(ticket.clone())
.or_insert((pr_num, "open".to_string()));
}
}
}
}
}
if let Some(ref remote_url) = remote_url {
for (_ticket, (pr_num, state)) in pr_map.iter_mut() {
if let Ok(Some(status)) =
github::get_pr_status(remote_url, *pr_num, &config).await
{
*state = status.state;
}
}
}
}
}
output::print_list(&workspaces, &pr_map, mode);
Ok(())
}
fn extract_pr_url(detail: &str) -> Option<String> {
detail
.split(" -> ")
.nth(1)
.map(|s| s.trim().to_string())
.filter(|s| s.starts_with("http"))
}
fn extract_pr_number(url: &str) -> Option<u64> {
url.rsplit('/').next().and_then(|s| s.parse().ok())
}
pub async fn status(repo: &Path, ticket: Option<&str>, mode: Mode) -> Result<()> {
let config = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config)?;
let workspaces = match ticket {
Some(t) => vec![manager.get(t)?],
None => manager.list()?,
};
output::print_status(&workspaces, mode);
Ok(())
}
pub async fn ship(
repo: &Path,
ticket: &str,
draft: bool,
no_pr: bool,
base_override: Option<String>,
mode: Mode,
) -> Result<()> {
let mut config = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config)?;
config.resolve_for_repo(manager.repo_root());
let mut result = manager.ship_push(ticket)?;
if let Some(base) = base_override {
result.base_branch = base;
} else if let Some(ref default_base) = config.ship.default_base {
result.base_branch = default_base.clone();
}
let mut pr_failed = false;
if !no_pr && config.ship.auto_pr {
let (ticket_title, ticket_url) =
match tracker::fetch_ticket(&config, ticket, Some(manager.repo_root())).await {
Ok(Some(t)) => (Some(t.title), t.url),
_ => (None, None),
};
let effective_title = ticket_title.as_deref().or(result.ticket_title.as_deref());
let pr_title = effective_title
.map(|t| format!("{}: {}", result.ticket, t))
.unwrap_or_else(|| result.ticket.clone());
let pr_body = build_pr_body(&result.ticket, effective_title, ticket_url.as_deref());
let remote_url = git::get_remote_url(manager.repo_root());
if let Ok(ref remote_url) = remote_url {
if let Ok(Some(existing_pr)) =
github::find_pr_by_branch(remote_url, &result.branch, &config).await
{
let remote = github::parse_github_remote(remote_url);
let pr_url = if let Some(r) = remote {
format!(
"https://{}/{}/{}/pull/{}",
r.host, r.owner, r.repo, existing_pr
)
} else {
format!("PR #{}", existing_pr)
};
result.pr_url = Some(pr_url);
} else {
match github::create_pr(
remote_url,
&result.branch,
&result.base_branch,
&pr_title,
&pr_body,
draft || config.ship.draft,
&config,
)
.await
{
Ok(Some(pr)) => {
result.pr_url = Some(pr.url);
}
Ok(None) => {
match gitlab::create_mr(
remote_url,
&result.branch,
&result.base_branch,
&pr_title,
&pr_body,
draft || config.ship.draft,
)
.await
{
Ok(Some(mr)) => {
result.pr_url = Some(mr.url);
}
Ok(None) => {
eprintln!(
"note: PR/MR creation skipped — no token found.\n \
Set PARSEC_GITHUB_TOKEN or PARSEC_GITLAB_TOKEN to enable."
);
pr_failed = true;
}
Err(e) => {
eprintln!("error: GitLab MR creation failed: {e}");
pr_failed = true;
}
}
}
Err(e) => {
eprintln!("error: PR creation failed: {e}");
pr_failed = true;
}
}
}
}
}
if config.tracker.comment_on_ship {
if let Some(ref pr_url) = result.pr_url {
let comment_body = format!("PR opened: {}", pr_url);
if let Err(e) =
tracker::post_comment(&config, ticket, &comment_body, Some(manager.repo_root()))
.await
{
eprintln!("warning: failed to post comment on ticket: {e}");
}
}
}
if pr_failed {
eprintln!(
"note: worktree preserved at {} — fix the issue and retry `parsec ship {}`",
manager
.get(ticket)
.map(|ws| ws.path.display().to_string())
.unwrap_or_default(),
ticket
);
}
output::print_ship(&result, mode);
if let Some(ref auto) = config.tracker.auto_transition {
if let Some(ref status) = auto.on_ship {
tracker::try_transition(&config, ticket, status).await;
}
}
if let Err(e) = crate::oplog::record(
manager.repo_root(),
crate::oplog::OpKind::Ship,
Some(ticket),
&format!(
"Shipped branch '{}'{}{}",
result.branch,
result
.pr_url
.as_ref()
.map(|u| format!(" -> {}", u))
.unwrap_or_default(),
if pr_failed {
" (partial: PR failed)"
} else {
""
},
),
Some(crate::oplog::UndoInfo {
branch: Some(result.branch.clone()),
base_branch: Some(result.base_branch.clone()),
path: None,
ticket_title: result.ticket_title.clone(),
}),
) {
eprintln!("warning: failed to write oplog: {e}");
}
if pr_failed {
anyhow::bail!("Ship partial: branch pushed but PR/MR creation failed. Worktree preserved.");
}
Ok(())
}
pub async fn clean(repo: &Path, all: bool, dry_run: bool, orphans: bool, mode: Mode) -> Result<()> {
let config = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config)?;
if orphans {
let orphan_list = manager.clean_orphans(dry_run)?;
output::print_clean(&orphan_list, dry_run, mode);
return Ok(());
}
let orphan_list = manager.clean_orphans(true)?; if !orphan_list.is_empty() {
eprintln!(
"note: {} orphan state entry(ies) found (directory missing). Use `parsec clean --orphans` to remove.",
orphan_list.len()
);
}
let removed = manager.clean(all, dry_run)?;
output::print_clean(&removed, dry_run, mode);
if !dry_run && !removed.is_empty() {
for ws in &removed {
if let Err(e) = crate::oplog::record(
manager.repo_root(),
crate::oplog::OpKind::Clean,
Some(&ws.ticket),
&format!("Cleaned workspace for branch '{}'", ws.branch),
Some(crate::oplog::UndoInfo {
branch: Some(ws.branch.clone()),
base_branch: Some(ws.base_branch.clone()),
path: Some(ws.path.clone()),
ticket_title: ws.ticket_title.clone(),
}),
) {
eprintln!("warning: failed to write oplog: {e}");
}
}
}
Ok(())
}
pub async fn open(
repo: &Path,
ticket: &str,
force_pr: bool,
force_ticket: bool,
mode: Mode,
) -> Result<()> {
let mut config = ParsecConfig::load()?;
let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?;
config.resolve_for_repo(&repo_root);
let oplog = crate::oplog::OpLog::load(&repo_root)?;
let pr_url: Option<String> = oplog.get_entries(Some(ticket)).iter().rev().find_map(|e| {
if matches!(e.op, crate::oplog::OpKind::Ship) {
e.detail.split(" -> ").nth(1).map(|s| s.to_string())
} else {
None
}
});
use crate::config::TrackerProvider;
let ticket_url = match config.tracker.provider {
TrackerProvider::Jira => config
.tracker
.jira
.as_ref()
.map(|j| format!("{}/browse/{}", j.base_url.trim_end_matches('/'), ticket)),
TrackerProvider::Github => git::run_output(repo, &["remote", "get-url", "origin"])
.ok()
.map(|url| {
let url = url
.trim_end_matches(".git")
.replace("git@github.com:", "https://github.com/");
format!("{}/issues/{}", url, ticket.trim_start_matches('#'))
}),
TrackerProvider::Gitlab => config.tracker.gitlab.as_ref().map(|g| {
let base = g.base_url.trim_end_matches('/');
git::run_output(repo, &["remote", "get-url", "origin"])
.ok()
.and_then(|url| {
let path = url
.trim_end_matches(".git")
.rsplit_once("gitlab.com")
.map(|(_, p)| p.trim_start_matches([':', '/']))?;
Some(format!("{}/{}/-/issues/{}", base, path, ticket))
})
.unwrap_or_else(|| format!("{}/-/issues/{}", base, ticket))
}),
TrackerProvider::None => None,
};
let url = if force_pr {
pr_url.ok_or_else(|| anyhow::anyhow!("no PR found for ticket {ticket}. Ship it first."))?
} else if force_ticket {
ticket_url
.ok_or_else(|| anyhow::anyhow!("no ticket URL for {ticket}. Configure a tracker."))?
} else {
pr_url.or(ticket_url).ok_or_else(|| {
anyhow::anyhow!("no URL found for {ticket}. Ship it or configure a tracker.")
})?
};
#[cfg(target_os = "macos")]
let open_cmd = "open";
#[cfg(not(target_os = "macos"))]
let open_cmd = "xdg-open";
std::process::Command::new(open_cmd)
.arg(&url)
.spawn()
.with_context(|| format!("failed to open browser with {open_cmd}"))?;
if mode == Mode::Json {
let value = serde_json::json!({ "action": "open", "ticket": ticket, "url": url });
println!("{}", value);
} else if mode != Mode::Quiet {
println!("Opening {}", url);
}
Ok(())
}
pub async fn pr_status(repo: &Path, ticket: Option<&str>, mode: Mode) -> Result<()> {
let config = ParsecConfig::load()?;
let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?;
let oplog = crate::oplog::OpLog::load(&repo_root)?;
let remote_url = git::run_output(repo, &["remote", "get-url", "origin"])?;
let entries: Vec<_> = oplog
.get_entries(ticket)
.into_iter()
.filter(|e| matches!(e.op, crate::oplog::OpKind::Ship))
.filter_map(|e| {
let url = e.detail.split(" -> ").nth(1)?;
let number = url.rsplit('/').next()?.parse::<u64>().ok()?;
Some((
e.ticket.clone().unwrap_or_default(),
number,
url.to_string(),
))
})
.collect();
let mut all_entries = entries;
if all_entries.is_empty() {
let manager = WorktreeManager::new(repo, &config)?;
let workspaces = match ticket {
Some(t) => vec![manager.get(t)?],
None => manager.list()?,
};
for ws in &workspaces {
if let Ok(Some(pr_number)) =
github::find_pr_by_branch(&remote_url, &ws.branch, &config).await
{
all_entries.push((ws.ticket.clone(), pr_number, String::new()));
}
}
if all_entries.is_empty() {
if let Some(t) = ticket {
anyhow::bail!("no PR found for {t}. Ship it first with `parsec ship {t}`, or check your GitHub token.");
} else {
anyhow::bail!("no PRs found. Ship a ticket first with `parsec ship`, or check your GitHub token.");
}
}
}
let mut statuses = Vec::new();
for (ticket_id, pr_number, _url) in &all_entries {
match crate::github::get_pr_status(&remote_url, *pr_number, &config).await? {
Some(status) => statuses.push((ticket_id.clone(), status)),
None => {
anyhow::bail!("no GitHub token found. Set PARSEC_GITHUB_TOKEN.");
}
}
}
output::print_pr_status(&statuses, mode);
Ok(())
}
pub async fn merge(
repo: &Path,
ticket: Option<&str>,
rebase: bool,
no_wait: bool,
no_delete_branch: bool,
mode: Mode,
) -> Result<()> {
let config = ParsecConfig::load()?;
let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?;
let remote_url = git::run_output(repo, &["remote", "get-url", "origin"])?;
let oplog = crate::oplog::OpLog::load(&repo_root)?;
let manager = WorktreeManager::new(repo, &config)?;
let ticket_id = if let Some(t) = ticket {
t.to_string()
} else {
let cwd = std::env::current_dir()?;
let all_ws = manager.list()?;
let found = all_ws
.into_iter()
.find(|w| cwd.starts_with(&w.path))
.ok_or_else(|| anyhow::anyhow!("not inside a parsec worktree. Specify a ticket."))?;
found.ticket
};
let pr_number = {
let shipped_pr = oplog
.get_entries(Some(&ticket_id))
.into_iter()
.rev()
.filter(|e| matches!(e.op, crate::oplog::OpKind::Ship))
.find_map(|e| {
let url = e.detail.split(" -> ").nth(1)?;
url.rsplit('/').next()?.parse::<u64>().ok()
});
if let Some(pr) = shipped_pr {
pr
} else {
let ws = manager.get(&ticket_id).with_context(|| {
format!("ticket {ticket_id} not found in active workspaces or oplog")
})?;
github::find_pr_by_branch(&remote_url, &ws.branch, &config)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"no open PR found for {ticket_id} (branch '{}'). Either ship it with `parsec ship {ticket_id}`, or check that PARSEC_GITHUB_TOKEN is set.",
ws.branch
)
})?
}
};
if !no_wait {
if mode == Mode::Human {
eprint!("Waiting for CI to pass...");
}
loop {
match github::get_check_runs(&remote_url, pr_number, &config).await? {
Some(ci) => {
if ci.overall == "passing" {
if mode == Mode::Human {
eprintln!(" {}", "✓".green());
}
break;
} else if ci.overall == "failing" {
if mode == Mode::Human {
eprintln!(" {}", "✗".red());
}
anyhow::bail!(
"CI is failing for PR #{}. Fix CI or use --no-wait to merge anyway.",
pr_number
);
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
None => {
anyhow::bail!("no GitHub token found. Set PARSEC_GITHUB_TOKEN.");
}
}
}
}
let method = if rebase { "rebase" } else { "squash" };
let delete_branch = !no_delete_branch;
match github::merge_pr(&remote_url, pr_number, method, delete_branch, &config).await? {
Some(result) => {
output::print_merge(&ticket_id, pr_number, &result, method, mode);
if delete_branch {
if let Err(e) = git::fetch(&repo_root) {
eprintln!("warning: failed to prune remote-tracking references: {e}");
}
}
if let Some(ref auto) = config.tracker.auto_transition {
if let Some(ref status) = auto.on_merge {
tracker::try_transition(&config, &ticket_id, status).await;
}
}
if config.ship.auto_cleanup {
if let Ok(ws) = manager.get(&ticket_id) {
if ws.path.exists() {
if let Err(e) = git::worktree_remove(&repo_root, &ws.path) {
eprintln!("warning: failed to remove worktree: {e}");
}
}
let mut state = crate::worktree::ParsecState::load(&repo_root)?;
state.remove_workspace(&ticket_id);
state.save(&repo_root)?;
if let Some(branch) = &oplog
.get_entries(Some(&ticket_id))
.last()
.and_then(|e| e.undo_info.as_ref())
.and_then(|u| u.branch.clone())
{
if let Err(e) = git::delete_branch(&repo_root, branch) {
eprintln!("warning: failed to delete local branch '{}': {}", branch, e);
}
}
if mode == Mode::Human {
println!(" {}", "Local worktree cleaned up.".dimmed());
}
}
}
if let Err(e) = crate::oplog::record(
&repo_root,
crate::oplog::OpKind::Clean,
Some(&ticket_id),
&format!("Merged PR #{} ({})", pr_number, method),
None,
) {
eprintln!("warning: failed to write oplog: {e}");
}
}
None => {
anyhow::bail!("no GitHub token found. Set PARSEC_GITHUB_TOKEN.");
}
}
Ok(())
}
pub async fn ci(
repo: &Path,
ticket: Option<&str>,
watch: bool,
all: bool,
mode: Mode,
) -> Result<()> {
let config = ParsecConfig::load()?;
let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?;
let remote_url = git::run_output(repo, &["remote", "get-url", "origin"])?;
let oplog = crate::oplog::OpLog::load(&repo_root)?;
let manager = WorktreeManager::new(repo, &config)?;
let mut targets: Vec<(String, u64)> = Vec::new();
if all {
let entries: Vec<_> = oplog
.get_entries(None)
.into_iter()
.filter(|e| matches!(e.op, crate::oplog::OpKind::Ship))
.filter_map(|e| {
let url = e.detail.split(" -> ").nth(1)?;
let number = url.rsplit('/').next()?.parse::<u64>().ok()?;
Some((e.ticket.clone().unwrap_or_default(), number))
})
.collect();
if entries.is_empty() {
anyhow::bail!("no shipped PRs found. Ship a ticket first with `parsec ship`.");
}
targets = entries;
} else {
let ticket_id = if let Some(t) = ticket {
t.to_string()
} else {
let cwd = std::env::current_dir()?;
let all_ws = manager.list()?;
let found = all_ws
.into_iter()
.find(|w| cwd.starts_with(&w.path))
.ok_or_else(|| {
anyhow::anyhow!("not inside a parsec worktree. Specify a ticket or use --all.")
})?;
found.ticket
};
let shipped_pr = oplog
.get_entries(Some(&ticket_id))
.into_iter()
.rev()
.filter(|e| matches!(e.op, crate::oplog::OpKind::Ship))
.find_map(|e| {
let url = e.detail.split(" -> ").nth(1)?;
url.rsplit('/').next()?.parse::<u64>().ok()
});
if let Some(pr_number) = shipped_pr {
targets.push((ticket_id, pr_number));
} else {
let ws = manager.get(&ticket_id).with_context(|| {
format!("ticket {ticket_id} not found in active workspaces or oplog")
})?;
match github::find_pr_by_branch(&remote_url, &ws.branch, &config).await? {
Some(pr_number) => targets.push((ticket_id, pr_number)),
None => {
anyhow::bail!(
"no PR found for {ticket_id}. Push and create a PR first, or ship with `parsec ship {ticket_id}`."
);
}
}
}
}
loop {
let mut statuses: Vec<(String, crate::github::CiStatus)> = Vec::new();
for (ticket_id, pr_number) in &targets {
match github::get_check_runs(&remote_url, *pr_number, &config).await? {
Some(ci) => statuses.push((ticket_id.clone(), ci)),
None => {
anyhow::bail!("no GitHub token found. Set PARSEC_GITHUB_TOKEN.");
}
}
}
if watch && mode == Mode::Human {
print!("\x1B[2J\x1B[H");
}
output::print_ci_status(&statuses, mode);
if !watch || mode != Mode::Human {
let has_failure = statuses.iter().any(|(_t, ci)| ci.overall == "failing");
if has_failure {
std::process::exit(1);
}
return Ok(());
}
let all_completed = statuses
.iter()
.all(|(_t, ci)| ci.checks.iter().all(|c| c.status == "completed"));
if all_completed {
let has_failure = statuses.iter().any(|(_t, ci)| ci.overall == "failing");
if has_failure {
std::process::exit(1);
}
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
pub async fn diff(
repo: &Path,
ticket: Option<&str>,
stat: bool,
name_only: bool,
mode: Mode,
) -> Result<()> {
let config = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config)?;
let ws = if let Some(t) = ticket {
manager.get(t)?
} else {
let cwd = std::env::current_dir()?;
let all_ws = manager.list()?;
all_ws
.into_iter()
.find(|w| cwd.starts_with(&w.path))
.ok_or_else(|| anyhow::anyhow!("not inside a parsec worktree. Specify a ticket."))?
};
let merge_base = git::run_output(
&ws.path,
&["merge-base", &format!("origin/{}", ws.base_branch), "HEAD"],
)?;
let merge_base = merge_base.trim();
if name_only {
let output = git::run_output(&ws.path, &["diff", "--name-only", merge_base])?;
let files: Vec<String> = output.lines().map(|l| l.to_string()).collect();
output::print_diff_names(&files, &ws.ticket, mode);
} else if stat {
let output = git::run_output(&ws.path, &["diff", "--stat", merge_base])?;
output::print_diff_stat(&output, &ws.ticket, mode);
} else {
if mode == Mode::Json {
let output = git::run_output(&ws.path, &["diff", "--name-status", merge_base])?;
let files: Vec<(String, String)> = output
.lines()
.filter_map(|l| {
let mut parts = l.splitn(2, '\t');
let status = parts.next()?.to_string();
let file = parts.next()?.to_string();
Some((status, file))
})
.collect();
output::print_diff_full_json(&files, &ws.ticket);
} else if mode == Mode::Human {
let _ = std::process::Command::new("git")
.args(["diff", "--color=always", merge_base])
.current_dir(&ws.path)
.status();
}
}
Ok(())
}
pub async fn conflicts(repo: &Path, mode: Mode) -> Result<()> {
let config = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config)?;
let workspaces = manager.list()?;
let conflicts = conflict::detect(&workspaces)?;
output::print_conflicts(&conflicts, mode);
Ok(())
}
pub async fn sync(
repo: &Path,
ticket: Option<&str>,
all: bool,
strategy: &str,
mode: Mode,
) -> Result<()> {
let config = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config)?;
let workspaces = if all {
let ws = manager.list()?;
if ws.is_empty() {
anyhow::bail!("no active workspaces to sync");
}
ws
} else if let Some(t) = ticket {
vec![manager.get(t)?]
} else {
let cwd = std::env::current_dir()?;
let all_ws = manager.list()?;
let found = all_ws
.into_iter()
.find(|w| cwd.starts_with(&w.path))
.ok_or_else(|| {
anyhow::anyhow!("not inside a parsec worktree. Specify a ticket or use --all.")
})?;
vec![found]
};
let mut synced = Vec::new();
let mut failed = Vec::new();
for ws in &workspaces {
let ws_path = std::path::Path::new(&ws.path);
if let Err(e) = git::run(ws_path, &["fetch", "origin", &ws.base_branch]) {
failed.push((ws.ticket.clone(), format!("fetch failed: {e}")));
continue;
}
let remote_base = format!("origin/{}", ws.base_branch);
let result = match strategy {
"merge" => git::run(ws_path, &["merge", &remote_base]),
_ => git::run(ws_path, &["rebase", &remote_base]),
};
match result {
Ok(()) => synced.push(ws.ticket.clone()),
Err(e) => {
if strategy != "merge" {
let _ = git::run(ws_path, &["rebase", "--abort"]);
} else {
let _ = git::run(ws_path, &["merge", "--abort"]);
}
failed.push((ws.ticket.clone(), format!("{strategy} failed: {e}")));
}
}
}
output::print_sync(&synced, &failed, strategy, mode);
Ok(())
}
pub async fn switch(repo: &Path, ticket: Option<&str>, mode: Mode) -> Result<()> {
let config = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config)?;
let ticket = match ticket {
Some(t) => t.to_string(),
None => {
let workspaces = manager.list()?;
if workspaces.is_empty() {
anyhow::bail!("no active workspaces. Run `parsec start <ticket>` to create one.");
}
let items: Vec<String> = workspaces
.iter()
.map(|w| {
let title = w
.ticket_title
.as_deref()
.map(|t| format!(" — {t}"))
.unwrap_or_default();
format!("{}{title}", w.ticket)
})
.collect();
let selection = dialoguer::Select::new()
.with_prompt("Switch to workspace")
.items(&items)
.default(0)
.interact()?;
workspaces[selection].ticket.clone()
}
};
let workspace = manager.get(&ticket)?;
output::print_switch(&workspace, mode);
Ok(())
}
pub async fn log(repo: &Path, ticket: Option<&str>, last: usize, mode: Mode) -> Result<()> {
let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?;
let oplog = crate::oplog::OpLog::load(&repo_root)?;
let entries = oplog.get_entries(ticket);
let start = entries.len().saturating_sub(last);
let entries: Vec<_> = entries[start..].to_vec();
output::print_log(&entries, mode);
Ok(())
}
pub async fn undo(repo: &Path, dry_run: bool, mode: Mode) -> Result<()> {
let config = ParsecConfig::load()?;
let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?;
let mut oplog = crate::oplog::OpLog::load(&repo_root)?;
let last = oplog.last_entry().cloned().ok_or_else(|| {
anyhow::anyhow!("nothing to undo. Run `parsec log` to see operation history.")
})?;
let undo_info = last.undo_info.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"last operation ({}) cannot be undone — no undo info recorded.",
last.op
)
})?;
if dry_run {
output::print_undo_preview(&last, mode);
return Ok(());
}
match last.op {
crate::oplog::OpKind::Start | crate::oplog::OpKind::Adopt => {
let ticket = last
.ticket
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no ticket in oplog entry"))?;
if let Some(path) = &undo_info.path {
if path.exists() {
git::worktree_remove(&repo_root, path)
.with_context(|| format!("failed to remove worktree at {:?}", path))?;
}
}
if let Some(branch) = &undo_info.branch {
if let Err(e) = git::delete_branch(&repo_root, branch) {
eprintln!("warning: failed to delete branch '{}': {e}", branch);
}
}
let mut state = crate::worktree::ParsecState::load(&repo_root)?;
state.remove_workspace(ticket);
state.save(&repo_root)?;
}
crate::oplog::OpKind::Ship | crate::oplog::OpKind::Clean => {
let ticket = last
.ticket
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no ticket in oplog entry"))?;
let branch = undo_info
.branch
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no branch info to restore"))?;
let base_branch = undo_info.base_branch.as_deref().unwrap_or("main");
let branch_exists_locally = git::run_output(
&repo_root,
&["rev-parse", "--verify", &format!("refs/heads/{}", branch)],
)
.is_ok();
if !branch_exists_locally {
let remote_ref = format!("origin/{}", branch);
if git::run_output(&repo_root, &["rev-parse", "--verify", &remote_ref]).is_ok() {
git::run(&repo_root, &["branch", branch, &remote_ref])?;
} else {
anyhow::bail!(
"branch '{}' not found locally or on remote. Cannot restore workspace.",
branch
);
}
}
let worktree_path = match config.workspace.layout {
crate::config::WorktreeLayout::Sibling => {
let repo_name = repo_root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "repo".to_string());
repo_root
.parent()
.unwrap_or(&repo_root)
.join(format!("{}.{}", repo_name, ticket))
}
crate::config::WorktreeLayout::Internal => {
repo_root.join(&config.workspace.base_dir).join(ticket)
}
};
git::run(
&repo_root,
&[
"worktree",
"add",
worktree_path.to_str().unwrap_or(""),
branch,
],
)?;
let workspace = crate::worktree::Workspace {
ticket: ticket.to_owned(),
path: worktree_path,
branch: branch.to_owned(),
base_branch: base_branch.to_owned(),
created_at: chrono::Utc::now(),
ticket_title: undo_info.ticket_title.clone(),
status: crate::worktree::WorkspaceStatus::Active,
parent_ticket: None,
};
let mut state = crate::worktree::ParsecState::load(&repo_root)?;
state.add_workspace(workspace);
state.save(&repo_root)?;
}
crate::oplog::OpKind::Undo => {
anyhow::bail!("cannot undo an undo operation");
}
}
oplog.append(
crate::oplog::OpKind::Undo,
last.ticket.clone(),
format!("Undid {} operation", last.op),
None,
);
oplog.save(&repo_root)?;
output::print_undo(&last, mode);
Ok(())
}
pub async fn stack(repo: &Path, mode: Mode) -> Result<()> {
let config = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config)?;
let workspaces = manager.list()?;
let stacked: Vec<_> = workspaces
.iter()
.filter(|w| {
w.parent_ticket.is_some()
|| workspaces
.iter()
.any(|other| other.parent_ticket.as_deref() == Some(&w.ticket))
})
.cloned()
.collect();
if stacked.is_empty() {
if mode == Mode::Human {
println!(
"No stacked worktrees. Use `parsec start <ticket> --on <parent>` to create a stack."
);
}
return Ok(());
}
output::print_stack(&stacked, mode);
Ok(())
}
pub async fn stack_sync(repo: &Path, mode: Mode) -> Result<()> {
let config = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config)?;
let workspaces = manager.list()?;
let mut synced = Vec::new();
let mut failed = Vec::new();
let roots: Vec<_> = workspaces
.iter()
.filter(|w| {
w.parent_ticket.is_none()
&& workspaces
.iter()
.any(|other| other.parent_ticket.as_deref() == Some(&w.ticket))
})
.collect();
if roots.is_empty() {
if mode == Mode::Human {
println!(
"No stacked worktrees to sync. Use `parsec start <ticket> --on <parent>` to create a stack."
);
}
return Ok(());
}
for root in &roots {
if let Err(e) = git::run(&root.path, &["fetch", "origin", &root.base_branch]) {
failed.push((root.ticket.clone(), format!("fetch failed: {e}")));
continue;
}
let remote_base = format!("origin/{}", root.base_branch);
if let Err(e) = git::run(&root.path, &["rebase", &remote_base]) {
let _ = git::run(&root.path, &["rebase", "--abort"]);
failed.push((root.ticket.clone(), format!("rebase failed: {e}")));
continue;
}
synced.push(root.ticket.clone());
let mut queue: Vec<&str> = vec![&root.ticket];
while let Some(parent_ticket) = queue.first().copied() {
queue.remove(0);
let children: Vec<_> = workspaces
.iter()
.filter(|w| w.parent_ticket.as_deref() == Some(parent_ticket))
.collect();
for child in &children {
let parent_ws = workspaces
.iter()
.find(|w| w.ticket == parent_ticket)
.unwrap();
if let Err(e) = git::run(&child.path, &["rebase", &parent_ws.branch]) {
let _ = git::run(&child.path, &["rebase", "--abort"]);
failed.push((
child.ticket.clone(),
format!("rebase onto {} failed: {e}", parent_ticket),
));
} else {
synced.push(child.ticket.clone());
queue.push(&child.ticket);
}
}
}
}
output::print_sync(&synced, &failed, "rebase (stack)", mode);
Ok(())
}
pub async fn ticket(
repo: &Path,
ticket_override: Option<&str>,
comment: Option<String>,
mode: Mode,
) -> Result<()> {
let mut config = ParsecConfig::load()?;
let repo_root = git::get_repo_root(repo)?;
config.resolve_for_repo(&repo_root);
let ticket_id = if let Some(t) = ticket_override {
t.to_string()
} else {
let manager = WorktreeManager::new(repo, &config)?;
let workspaces = manager.list()?;
let current_dir = std::env::current_dir()?;
workspaces
.iter()
.find(|ws| current_dir.starts_with(&ws.path))
.map(|ws| ws.ticket.clone())
.ok_or_else(|| {
anyhow::anyhow!(
"Not inside a parsec worktree. Specify a ticket: `parsec ticket <TICKET>`"
)
})?
};
if let Some(comment_text) = comment {
tracker::post_comment(&config, &ticket_id, &comment_text, Some(&repo_root)).await?;
output::print_comment(&ticket_id, mode);
return Ok(());
}
let ticket = tracker::fetch_ticket(&config, &ticket_id, Some(repo))
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"Could not fetch ticket '{}'. Check your tracker configuration.",
ticket_id
)
})?;
output::print_ticket(&ticket, mode);
Ok(())
}
pub async fn inbox(repo: &Path, pick: bool, mode: Mode) -> Result<()> {
let mut config = ParsecConfig::load()?;
if let Ok(repo_root) = git::get_repo_root(repo) {
config.resolve_for_repo(&repo_root);
}
if !matches!(
config.tracker.provider,
TrackerProvider::Jira | TrackerProvider::None
) {
anyhow::bail!("Inbox currently supports Jira only.");
}
tracker::load_atlassian_env();
let base_url = config
.tracker
.jira
.as_ref()
.map(|j| j.base_url.clone())
.or_else(|| std::env::var(crate::env::JIRA_BASE_URL).ok())
.ok_or_else(|| {
anyhow::anyhow!(
"Jira not configured. Run `parsec config init` or set {}.",
crate::env::JIRA_BASE_URL,
)
})?;
let email = config.tracker.jira.as_ref().and_then(|j| j.email.clone());
let jira = JiraTracker::new(&base_url, email.as_deref());
let jql =
"assignee = currentUser() AND status in (\"To Do\", \"In Progress\") ORDER BY priority DESC";
let tickets = jira.search_assigned_issues(jql).await?;
let manager = WorktreeManager::new(repo, &config)?;
let active_tickets: HashSet<String> =
manager.list()?.iter().map(|ws| ws.ticket.clone()).collect();
let inbox_tickets: Vec<_> = tickets
.into_iter()
.filter(|t| !active_tickets.contains(&t.key))
.collect();
if pick {
if inbox_tickets.is_empty() {
anyhow::bail!("No assigned tickets without active worktrees.");
}
let items: Vec<String> = inbox_tickets
.iter()
.map(|t| format!("{} — {} [{}]", t.key, t.summary, t.priority))
.collect();
let selection = dialoguer::Select::new()
.with_prompt("Pick a ticket to start")
.items(&items)
.default(0)
.interact()?;
let chosen = &inbox_tickets[selection];
eprintln!("Starting workspace for {} ...", chosen.key.bold());
return start(
repo,
&chosen.key,
None,
Some(chosen.summary.clone()),
None,
None,
mode,
)
.await;
}
output::print_inbox(&inbox_tickets, mode);
Ok(())
}
pub async fn board(
repo: &Path,
board_id_override: Option<u64>,
project_override: Option<String>,
assignee_override: Option<String>,
show_all: bool,
mode: Mode,
) -> Result<()> {
let mut config = ParsecConfig::load()?;
if let Ok(repo_root) = git::get_repo_root(repo) {
config.resolve_for_repo(&repo_root);
}
if !matches!(
config.tracker.provider,
TrackerProvider::Jira | TrackerProvider::None
) {
anyhow::bail!("Board view currently supports Jira only.");
}
tracker::load_atlassian_env();
let base_url = config
.tracker
.jira
.as_ref()
.map(|j| j.base_url.clone())
.or_else(|| std::env::var(crate::env::JIRA_BASE_URL).ok())
.ok_or_else(|| {
anyhow::anyhow!(
"Jira not configured. Run `parsec config init` or set {}.",
crate::env::JIRA_BASE_URL,
)
})?;
let email = config.tracker.jira.as_ref().and_then(|j| j.email.clone());
let jira = JiraTracker::new(&base_url, email.as_deref());
let project = if let Some(p) = project_override {
p
} else if let Ok(p) = std::env::var(crate::env::PARSEC_JIRA_PROJECT) {
p
} else if let Some(p) = config.tracker.jira.as_ref().and_then(|j| j.project.clone()) {
p
} else {
let config2 = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config2)?;
let workspaces = manager.list()?;
workspaces
.iter()
.find_map(|ws| ws.ticket.split('-').next().map(String::from))
.ok_or_else(|| {
anyhow::anyhow!(
"Could not infer project key. Use --project <KEY>, set {}, or start a worktree first.",
crate::env::PARSEC_JIRA_PROJECT,
)
})?
};
let board_id = if let Some(id) = board_id_override {
id
} else if let Ok(id_str) = std::env::var(crate::env::PARSEC_JIRA_BOARD_ID) {
id_str.parse::<u64>().map_err(|_| {
anyhow::anyhow!(
"{} must be a valid number, got: {}",
crate::env::PARSEC_JIRA_BOARD_ID,
id_str,
)
})?
} else if let Some(id) = config.tracker.jira.as_ref().and_then(|j| j.board_id) {
id
} else {
jira.fetch_board_id(&project).await?
};
let assignee_filter = if show_all {
None
} else if let Some(a) = assignee_override {
Some(a)
} else if let Ok(a) = std::env::var(crate::env::PARSEC_JIRA_ASSIGNEE) {
Some(a)
} else {
config
.tracker
.jira
.as_ref()
.and_then(|j| j.assignee.clone())
};
let sprint = jira.fetch_active_sprint(board_id).await?;
let tickets = jira.fetch_sprint_issues(sprint.id).await?;
let config3 = ParsecConfig::load()?;
let manager = WorktreeManager::new(repo, &config3)?;
let active_worktree_tickets: HashSet<String> =
manager.list()?.iter().map(|ws| ws.ticket.clone()).collect();
let repo_root = git::get_main_repo_root(repo).or_else(|_| git::get_repo_root(repo))?;
let oplog = crate::oplog::OpLog::load(&repo_root)?;
let shipped_tickets: HashSet<String> = oplog
.entries
.iter()
.filter(|e| matches!(e.op, crate::oplog::OpKind::Ship))
.filter_map(|e| e.ticket.clone())
.collect();
let mut column_map: Vec<(String, Vec<BoardTicketDisplay>)> = Vec::new();
let mut seen_statuses: Vec<String> = Vec::new();
for ticket in &tickets {
if !seen_statuses.contains(&ticket.status) {
seen_statuses.push(ticket.status.clone());
}
}
for status in &seen_statuses {
let col_tickets: Vec<BoardTicketDisplay> = tickets
.iter()
.filter(|t| &t.status == status)
.filter(|t| {
if let Some(ref filter) = assignee_filter {
t.assignee.as_deref() == Some(filter.as_str())
} else {
true
}
})
.map(|t| BoardTicketDisplay {
key: t.key.clone(),
summary: t.summary.clone(),
assignee: t.assignee.clone(),
has_worktree: active_worktree_tickets.contains(&t.key),
has_pr: shipped_tickets.contains(&t.key),
url: Some(format!(
"{}/browse/{}",
base_url.trim_end_matches('/'),
t.key
)),
})
.collect();
if !col_tickets.is_empty() {
column_map.push((status.clone(), col_tickets));
}
}
output::print_board(Some(&sprint), &column_map, mode);
Ok(())
}
pub async fn config_init(mode: Mode) -> Result<()> {
let config = ParsecConfig::init_interactive()?;
config.save()?;
output::print_config_init(mode);
Ok(())
}
pub async fn config_show(mode: Mode) -> Result<()> {
let config = ParsecConfig::load()?;
output::print_config_show(&config, mode);
Ok(())
}
pub async fn root(repo_path: &Path) -> Result<()> {
let repo_root = git::get_main_repo_root(repo_path)?;
print!("{}", repo_root.display());
Ok(())
}
pub async fn init_shell(shell: &str) -> Result<()> {
let script = match shell {
"bash" => INIT_SHELL_BASH,
_ => INIT_SHELL_ZSH,
};
print!("{}", script);
Ok(())
}
pub async fn config_shell(shell: &str, _mode: Mode) -> Result<()> {
let script = match shell {
"bash" => SHELL_INTEGRATION_BASH,
_ => SHELL_INTEGRATION_ZSH,
};
print!("{}", script);
Ok(())
}
const SHELL_INTEGRATION_ZSH: &str = r#"
# parsec shell integration - add to ~/.zshrc
# eval "$(parsec config shell zsh)"
function parsec() {
if [[ "$1" == "switch" && -n "$2" ]]; then
local dir
dir=$(command parsec switch "${@:2}" 2>&1)
if [[ $? -eq 0 && -d "$dir" ]]; then
cd "$dir"
else
echo "$dir" >&2
return 1
fi
else
command parsec "$@"
fi
}
"#;
const SHELL_INTEGRATION_BASH: &str = r#"
# parsec shell integration - add to ~/.bashrc
# eval "$(parsec config shell bash)"
function parsec() {
if [[ "$1" == "switch" && -n "$2" ]]; then
local dir
dir=$(command parsec switch "${@:2}" 2>&1)
if [[ $? -eq 0 && -d "$dir" ]]; then
cd "$dir"
else
echo "$dir" >&2
return 1
fi
else
command parsec "$@"
fi
}
"#;
const INIT_SHELL_ZSH: &str = r#"
# parsec shell integration - add to ~/.zshrc
# eval "$(parsec init zsh)"
function parsec() {
if [[ "$1" == "switch" && -n "$2" ]]; then
local dir
dir=$(command parsec switch "${@:2}" 2>&1)
if [[ $? -eq 0 && -d "$dir" ]]; then
cd "$dir"
else
echo "$dir" >&2
return 1
fi
else
# Save repo root before merge (CWD may be deleted after)
local saved_root=""
if [[ "$1" == "merge" ]]; then
saved_root=$(command parsec root 2>/dev/null)
fi
command parsec "$@"
local exit_code=$?
# After merge, if CWD was deleted (worktree cleaned up), cd to main repo
if [[ "$1" == "merge" && $exit_code -eq 0 ]] && [[ ! -d "$(pwd)" ]]; then
if [[ -n "$saved_root" && -d "$saved_root" ]]; then
cd "$saved_root"
echo " cd $saved_root"
fi
fi
return $exit_code
fi
}
"#;
const INIT_SHELL_BASH: &str = r#"
# parsec shell integration - add to ~/.bashrc
# eval "$(parsec init bash)"
function parsec() {
if [[ "$1" == "switch" && -n "$2" ]]; then
local dir
dir=$(command parsec switch "${@:2}" 2>&1)
if [[ $? -eq 0 && -d "$dir" ]]; then
cd "$dir"
else
echo "$dir" >&2
return 1
fi
else
# Save repo root before merge (CWD may be deleted after)
local saved_root=""
if [[ "$1" == "merge" ]]; then
saved_root=$(command parsec root 2>/dev/null)
fi
command parsec "$@"
local exit_code=$?
# After merge, if CWD was deleted (worktree cleaned up), cd to main repo
if [[ "$1" == "merge" && $exit_code -eq 0 ]] && [[ ! -d "$(pwd)" ]]; then
if [[ -n "$saved_root" && -d "$saved_root" ]]; then
cd "$saved_root"
echo " cd $saved_root"
fi
fi
return $exit_code
fi
}
"#;
pub async fn config_man(dir: &Path) -> Result<()> {
use clap::CommandFactory;
let cmd = super::Cli::command();
let man = clap_mangen::Man::new(cmd);
let mut buf = Vec::new();
man.render(&mut buf)?;
let man1_dir = dir.join("man1");
std::fs::create_dir_all(&man1_dir)
.with_context(|| format!("Failed to create directory {}", man1_dir.display()))?;
let path = man1_dir.join("parsec.1");
std::fs::write(&path, buf)
.with_context(|| format!("Failed to write man page to {}", path.display()))?;
println!("Man page installed to {}", path.display());
println!("Try: man parsec");
Ok(())
}
pub async fn config_completions(shell: clap_complete::Shell) -> Result<()> {
use clap::CommandFactory;
let mut cmd = super::Cli::command();
clap_complete::generate(shell, &mut cmd, "parsec", &mut std::io::stdout());
Ok(())
}
fn build_pr_body(ticket: &str, title: Option<&str>, ticket_url: Option<&str>) -> String {
let mut body = String::new();
if let Some(title) = title {
body.push_str(&format!("## {}\n\n", title));
}
if let Some(url) = ticket_url {
body.push_str(&format!("**Ticket**: [{ticket}]({url})\n\n"));
}
body.push_str(&format!("Shipped via `parsec ship {ticket}`\n"));
body
}