use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn run(repo: &Path, args: &[&str]) -> Result<()> {
let status = Command::new("git")
.args(args)
.current_dir(repo)
.status()
.with_context(|| format!("failed to spawn git {:?}", args))?;
if status.success() {
Ok(())
} else {
Err(anyhow!(
"git {:?} exited with status {}",
args,
status.code().unwrap_or(-1)
))
}
}
pub fn run_output(repo: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.with_context(|| format!("failed to spawn git {:?}", args))?;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)
.with_context(|| format!("git {:?} produced non-UTF-8 output", args))?;
Ok(stdout.trim().to_owned())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow!(
"git {:?} exited with status {}: {}",
args,
output.status.code().unwrap_or(-1),
stderr.trim()
))
}
}
pub fn get_default_branch(repo: &Path) -> Result<String> {
let result = Command::new("git")
.args(["rev-parse", "--verify", "refs/heads/main"])
.current_dir(repo)
.output()
.context("failed to spawn git rev-parse")?;
if result.status.success() {
return Ok("main".to_owned());
}
let result = Command::new("git")
.args(["rev-parse", "--verify", "refs/heads/master"])
.current_dir(repo)
.output()
.context("failed to spawn git rev-parse")?;
if result.status.success() {
return Ok("master".to_owned());
}
Err(anyhow!(
"could not detect default branch: neither 'main' nor 'master' exists in {:?}",
repo
))
}
pub fn get_repo_root(path: &Path) -> Result<PathBuf> {
let out = run_output(path, &["rev-parse", "--show-toplevel"])?;
Ok(PathBuf::from(out))
}
pub fn get_main_repo_root(path: &Path) -> Result<PathBuf> {
let common_dir = run_output(path, &["rev-parse", "--git-common-dir"])?;
let common_path = PathBuf::from(&common_dir);
let abs_common = if common_path.is_absolute() {
common_path
} else {
let toplevel = get_repo_root(path)?;
toplevel.join(&common_path)
};
let canonical = abs_common
.canonicalize()
.with_context(|| format!("failed to canonicalize git common dir: {:?}", abs_common))?;
canonical
.parent()
.map(|p| p.to_path_buf())
.ok_or_else(|| anyhow!("git common dir has no parent: {:?}", canonical))
}
pub fn get_uncommitted_files(repo: &Path) -> Result<Vec<String>> {
let staged = run_output(repo, &["diff", "--name-only", "--cached"])?;
let unstaged = run_output(repo, &["diff", "--name-only"])?;
let mut files: std::collections::HashSet<String> = std::collections::HashSet::new();
for line in staged.lines().chain(unstaged.lines()) {
let line = line.trim();
if !line.is_empty() {
files.insert(line.to_owned());
}
}
Ok(files.into_iter().collect())
}
pub fn is_branch_merged(repo: &Path, branch: &str, base: &str) -> Result<bool> {
let output = run_output(repo, &["branch", "--merged", base])?;
Ok(output
.lines()
.any(|line| line.trim().trim_start_matches('*').trim() == branch))
}
pub fn get_changed_files(repo: &Path, base: &str, head: &str) -> Result<Vec<String>> {
let range = format!("{}..{}", base, head);
let output = run_output(repo, &["diff", "--name-only", &range])?;
Ok(output
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_owned())
.collect())
}
pub fn get_remote_url(repo: &Path) -> Result<String> {
run_output(repo, &["remote", "get-url", "origin"])
}
pub fn push_branch(repo: &Path, branch: &str) -> Result<()> {
run(
repo,
&["push", "--force-with-lease", "-u", "origin", branch],
)
}
#[allow(dead_code)]
pub fn get_current_branch(repo: &Path) -> Result<String> {
run_output(repo, &["rev-parse", "--abbrev-ref", "HEAD"])
}
pub fn worktree_add(repo: &Path, path: &Path, branch: &str, base: &str) -> Result<()> {
let path_str = path
.to_str()
.ok_or_else(|| anyhow!("worktree path is not valid UTF-8: {:?}", path))?;
run(repo, &["worktree", "add", "-b", branch, path_str, base])
}
pub fn worktree_add_existing(repo: &Path, path: &Path, branch: &str) -> Result<()> {
let path_str = path
.to_str()
.ok_or_else(|| anyhow!("worktree path is not valid UTF-8: {:?}", path))?;
let local_exists = run_output(
repo,
&["rev-parse", "--verify", &format!("refs/heads/{}", branch)],
)
.is_ok();
if local_exists {
return run(repo, &["worktree", "add", path_str, branch]);
}
let remote_ref = if branch.starts_with("origin/") {
branch.to_owned()
} else {
format!("origin/{}", branch)
};
let remote_exists = run_output(
repo,
&[
"rev-parse",
"--verify",
&format!("refs/remotes/{}", remote_ref),
],
)
.is_ok();
if remote_exists {
let local_name = branch.strip_prefix("origin/").unwrap_or(branch);
run(
repo,
&["worktree", "add", "-b", local_name, path_str, &remote_ref],
)
} else {
Err(anyhow!(
"branch '{}' not found locally or on remote. Use `parsec start {}` without --branch to create a new branch.",
branch,
branch.strip_prefix("origin/").unwrap_or(branch)
))
}
}
pub fn worktree_remove(repo: &Path, path: &Path) -> Result<()> {
let path_str = path
.to_str()
.ok_or_else(|| anyhow!("worktree path is not valid UTF-8: {:?}", path))?;
run(repo, &["worktree", "remove", path_str])
}
#[allow(dead_code)]
pub fn worktree_list(repo: &Path) -> Result<Vec<(String, String, String)>> {
let output = run_output(repo, &["worktree", "list", "--porcelain"])?;
let mut result = Vec::new();
let mut wt_path = String::new();
let mut wt_head = String::new();
let mut wt_branch = String::new();
for line in output.lines() {
if let Some(val) = line.strip_prefix("worktree ") {
if !wt_path.is_empty() {
result.push((wt_path.clone(), wt_branch.clone(), wt_head.clone()));
}
wt_path = val.to_owned();
wt_head.clear();
wt_branch.clear();
} else if let Some(val) = line.strip_prefix("HEAD ") {
wt_head = val.to_owned();
} else if let Some(val) = line.strip_prefix("branch ") {
wt_branch = val.strip_prefix("refs/heads/").unwrap_or(val).to_owned();
} else if line == "detached" {
wt_branch = "(detached)".to_owned();
}
}
if !wt_path.is_empty() {
result.push((wt_path, wt_branch, wt_head));
}
Ok(result)
}
pub fn get_merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
run_output(repo, &["merge-base", a, b])
}
pub fn delete_branch(repo: &Path, branch: &str) -> Result<()> {
run(repo, &["branch", "-D", branch])
}
pub fn fetch(repo: &Path) -> Result<()> {
run(repo, &["fetch", "origin", "--prune"])
}
pub fn fetch_if_remote(repo: &Path) -> Result<()> {
let has_remote = std::process::Command::new("git")
.args(["remote"])
.current_dir(repo)
.output()
.map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
.unwrap_or(false);
if has_remote {
if let Err(e) = fetch(repo) {
eprintln!("warning: fetch failed (continuing anyway): {}", e);
}
}
Ok(())
}