use anyhow::{Context, Result};
use chrono::Utc;
use std::path::{Path, PathBuf};
use super::lifecycle::{ParsecState, ShipResult, Workspace, WorkspaceStatus};
use crate::config::ParsecConfig;
use crate::git;
pub struct WorktreeManager {
repo_root: PathBuf,
config: ParsecConfig,
}
impl WorktreeManager {
pub fn new(repo: &Path, config: &ParsecConfig) -> Result<Self> {
let repo_root = git::get_main_repo_root(repo)
.or_else(|_| git::get_repo_root(repo))
.with_context(|| format!("failed to locate git repository root from {:?}", repo))?;
Ok(Self {
repo_root,
config: config.clone(),
})
}
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
pub fn create(
&self,
ticket: &str,
base: Option<&str>,
ticket_title: Option<String>,
parent_ticket: Option<&str>,
existing_branch: Option<&str>,
) -> Result<Workspace> {
let base_branch = match base {
Some(b) => b.to_owned(),
None => {
if let Some(parent) = parent_ticket {
let parent_ws = self.get(parent)?;
parent_ws.branch.clone()
} else if let Some(ref default_base) = self.config.workspace.default_base {
default_base.clone()
} else {
git::get_default_branch(&self.repo_root)
.context("failed to detect default branch")?
}
}
};
let worktree_path = match self.config.workspace.layout {
crate::config::WorktreeLayout::Sibling => {
let repo_name = self
.repo_root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "repo".to_string());
self.repo_root
.parent()
.unwrap_or(&self.repo_root)
.join(format!("{}.{}", repo_name, ticket))
}
crate::config::WorktreeLayout::Internal => self
.repo_root
.join(&self.config.workspace.base_dir)
.join(ticket),
};
git::fetch_if_remote(&self.repo_root)?;
let branch = if let Some(eb) = existing_branch {
let local_name = eb.strip_prefix("origin/").unwrap_or(eb).to_owned();
git::worktree_add_existing(&self.repo_root, &worktree_path, eb).with_context(|| {
format!(
"failed to create worktree from existing branch '{}' for ticket '{}' at {:?}",
eb, ticket, worktree_path
)
})?;
local_name
} else {
let branch = format!("{}{}", self.config.workspace.branch_prefix, ticket);
git::worktree_add(&self.repo_root, &worktree_path, &branch, &base_branch)
.with_context(|| {
format!(
"failed to create worktree for ticket '{}' at {:?}",
ticket, worktree_path
)
})?;
branch
};
let workspace = Workspace {
ticket: ticket.to_owned(),
path: worktree_path.clone(),
branch,
base_branch,
created_at: Utc::now(),
ticket_title,
status: WorkspaceStatus::Active,
parent_ticket: parent_ticket.map(|s| s.to_owned()),
};
let mut state =
ParsecState::load(&self.repo_root).context("failed to load parsec state")?;
state.add_workspace(workspace.clone());
state
.save(&self.repo_root)
.context("failed to save parsec state")?;
if !self.config.hooks.post_create.is_empty() {
let skip_prompt = std::env::var("PARSEC_YES")
.map(|v| v == "1")
.unwrap_or(false);
let confirmed = if skip_prompt {
true
} else {
eprintln!("The following post-create hooks will be executed:");
for hook_cmd in &self.config.hooks.post_create {
eprintln!(" - {}", hook_cmd);
}
eprint!("Run these hooks? [y/N] ");
let mut input = String::new();
match std::io::stdin().read_line(&mut input) {
Ok(_) => input.trim().eq_ignore_ascii_case("y"),
Err(_) => false,
}
};
if confirmed {
for hook_cmd in &self.config.hooks.post_create {
eprintln!("Running post-create hook: {}", hook_cmd);
let status = std::process::Command::new("sh")
.args(["-c", hook_cmd])
.current_dir(&worktree_path)
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => eprintln!("warning: hook '{}' exited with {}", hook_cmd, s),
Err(e) => eprintln!("warning: failed to run hook '{}': {}", hook_cmd, e),
}
}
} else {
eprintln!("Skipping post-create hooks.");
}
}
Ok(workspace)
}
pub fn adopt(
&self,
ticket: &str,
branch: Option<&str>,
ticket_title: Option<String>,
) -> Result<Workspace> {
let mut state =
ParsecState::load(&self.repo_root).context("failed to load parsec state")?;
if state.get_workspace(ticket).is_some() {
anyhow::bail!(
"ticket '{}' is already managed by parsec. Use `parsec status {}` to see it.",
ticket,
ticket
);
}
let branch_name = match branch {
Some(b) => b.to_owned(),
None => {
let candidate = format!("{}{}", self.config.workspace.branch_prefix, ticket);
match git::run_output(
&self.repo_root,
&[
"rev-parse",
"--verify",
&format!("refs/heads/{}", candidate),
],
) {
Ok(_) => candidate,
Err(_) => {
let current = git::run_output(
&self.repo_root,
&["rev-parse", "--abbrev-ref", "HEAD"],
)
.context("could not detect branch. Specify one with --branch <name>")?;
if current == "HEAD" || current == "main" || current == "master" {
anyhow::bail!(
"no branch found for ticket '{}'. Specify one with: parsec adopt {} --branch <branch-name>",
ticket, ticket
);
}
current
}
}
}
};
git::run_output(
&self.repo_root,
&[
"rev-parse",
"--verify",
&format!("refs/heads/{}", branch_name),
],
)
.with_context(|| {
format!(
"branch '{}' does not exist. Create it first or check the name.",
branch_name
)
})?;
let base_branch =
git::get_default_branch(&self.repo_root).unwrap_or_else(|_| "main".to_owned());
let worktree_path = self.find_worktree_for_branch(&branch_name);
let path = match worktree_path {
Some(p) => p,
None => {
let wt_path = match self.config.workspace.layout {
crate::config::WorktreeLayout::Sibling => {
let repo_name = self
.repo_root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "repo".to_string());
self.repo_root
.parent()
.unwrap_or(&self.repo_root)
.join(format!("{}.{}", repo_name, ticket))
}
crate::config::WorktreeLayout::Internal => self
.repo_root
.join(&self.config.workspace.base_dir)
.join(ticket),
};
git::run(
&self.repo_root,
&[
"worktree",
"add",
wt_path.to_str().unwrap_or(""),
&branch_name,
],
)
.with_context(|| {
format!(
"failed to create worktree for branch '{}' at {:?}",
branch_name, wt_path
)
})?;
wt_path
}
};
let workspace = Workspace {
ticket: ticket.to_owned(),
path,
branch: branch_name,
base_branch,
created_at: Utc::now(),
ticket_title,
status: WorkspaceStatus::Active,
parent_ticket: None,
};
state.add_workspace(workspace.clone());
state
.save(&self.repo_root)
.context("failed to save parsec state after adopt")?;
Ok(workspace)
}
fn find_worktree_for_branch(&self, branch: &str) -> Option<PathBuf> {
let output = git::run_output(&self.repo_root, &["worktree", "list", "--porcelain"]).ok()?;
let mut current_path: Option<String> = None;
for line in output.lines() {
if let Some(val) = line.strip_prefix("worktree ") {
current_path = Some(val.to_owned());
} else if let Some(val) = line.strip_prefix("branch ") {
let wt_branch = val.strip_prefix("refs/heads/").unwrap_or(val);
if wt_branch == branch {
return current_path.map(PathBuf::from);
}
} else if line.is_empty() {
current_path = None;
}
}
None
}
pub fn list(&self) -> Result<Vec<Workspace>> {
let state = ParsecState::load(&self.repo_root).context("failed to load parsec state")?;
let mut workspaces: Vec<Workspace> = state.workspaces.into_values().collect();
workspaces.sort_by_key(|w| w.created_at);
Ok(workspaces)
}
pub fn get(&self, ticket: &str) -> Result<Workspace> {
let state = ParsecState::load(&self.repo_root).context("failed to load parsec state")?;
state
.get_workspace(ticket)
.cloned()
.ok_or_else(|| {
anyhow::anyhow!(
"no workspace found for ticket '{}'. Run `parsec list` to see active workspaces, or `parsec adopt {}` to import an existing branch.",
ticket, ticket
)
})
}
pub fn ship_push(&self, ticket: &str) -> Result<ShipResult> {
let state = ParsecState::load(&self.repo_root).context("failed to load parsec state")?;
let workspace = state
.get_workspace(ticket)
.cloned()
.ok_or_else(|| {
anyhow::anyhow!(
"no workspace found for ticket '{}'. Run `parsec list` to see active workspaces, or `parsec adopt {}` to import an existing branch.",
ticket, ticket
)
})?;
git::push_branch(&workspace.path, &workspace.branch)
.with_context(|| format!("failed to push branch '{}'", workspace.branch))?;
Ok(ShipResult {
ticket: ticket.to_owned(),
branch: workspace.branch,
base_branch: workspace.base_branch,
ticket_title: workspace.ticket_title,
pr_url: None,
cleaned_up: false,
})
}
pub fn ship_cleanup(&self, ticket: &str) -> Result<bool> {
if !self.config.ship.auto_cleanup {
return Ok(false);
}
let state = ParsecState::load(&self.repo_root).context("failed to load parsec state")?;
let workspace = match state.get_workspace(ticket) {
Some(ws) => ws.clone(),
None => return Ok(false), };
let cleaned_up = match git::worktree_remove(&self.repo_root, &workspace.path) {
Ok(()) => {
if let Err(e) = git::delete_branch(&self.repo_root, &workspace.branch) {
eprintln!(
"warning: failed to delete branch '{}': {e}",
workspace.branch
);
}
true
}
Err(e) => {
eprintln!("warning: failed to remove worktree: {e}");
false
}
};
let mut state =
ParsecState::load(&self.repo_root).context("failed to load parsec state")?;
if cleaned_up {
state.remove_workspace(ticket);
} else if let Some(ws) = state.workspaces.get_mut(ticket) {
ws.status = WorkspaceStatus::Shipped;
}
state
.save(&self.repo_root)
.context("failed to save parsec state after ship cleanup")?;
Ok(cleaned_up)
}
#[allow(dead_code)]
pub fn ship(&self, ticket: &str) -> Result<ShipResult> {
let mut result = self.ship_push(ticket)?;
let cleaned_up = self.ship_cleanup(ticket)?;
result.cleaned_up = cleaned_up;
Ok(result)
}
pub fn clean(&self, all: bool, dry_run: bool) -> Result<Vec<Workspace>> {
let mut state =
ParsecState::load(&self.repo_root).context("failed to load parsec state")?;
let candidates: Vec<Workspace> = state
.workspaces
.values()
.filter(|ws| {
if all {
return true;
}
git::is_branch_merged(&self.repo_root, &ws.branch, &ws.base_branch).unwrap_or(false)
})
.cloned()
.collect();
if !dry_run {
for ws in &candidates {
match git::worktree_remove(&self.repo_root, &ws.path) {
Ok(()) => {
if let Err(e) = git::delete_branch(&self.repo_root, &ws.branch) {
eprintln!("warning: failed to delete branch '{}': {e}", ws.branch);
}
}
Err(e) => {
eprintln!(
"warning: failed to remove worktree for '{}': {e}",
ws.ticket
);
}
}
state.remove_workspace(&ws.ticket);
}
state
.save(&self.repo_root)
.context("failed to save parsec state after clean")?;
}
Ok(candidates)
}
pub fn clean_orphans(&self, dry_run: bool) -> Result<Vec<Workspace>> {
let mut state =
ParsecState::load(&self.repo_root).context("failed to load parsec state")?;
let orphans: Vec<Workspace> = state
.workspaces
.values()
.filter(|ws| !ws.path.exists())
.cloned()
.collect();
if !dry_run {
for ws in &orphans {
state.remove_workspace(&ws.ticket);
}
state
.save(&self.repo_root)
.context("failed to save parsec state after orphan cleanup")?;
}
Ok(orphans)
}
}