use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use anyhow::{Context, Result, anyhow, bail};
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
pub path: PathBuf,
pub branch: Option<String>,
pub is_current: bool,
}
pub(crate) fn absolute_git_dir(cwd: &Path) -> Result<PathBuf> {
let output = git_capture(cwd, ["rev-parse", "--absolute-git-dir"])?;
Ok(PathBuf::from(output.trim()))
}
pub fn repo_root(cwd: &Path) -> Result<PathBuf> {
let output = git_capture(cwd, ["rev-parse", "--show-toplevel"])?;
Ok(PathBuf::from(output.trim()))
}
pub fn current_branch(cwd: &Path) -> Result<String> {
let output = git_capture(cwd, ["branch", "--show-current"])?;
let branch = output.trim();
if branch.is_empty() {
bail!("repository is in detached HEAD state")
}
Ok(branch.to_owned())
}
pub fn origin_url(cwd: &Path) -> Result<String> {
let output = git_capture(cwd, ["remote", "get-url", "origin"])?;
Ok(output.trim().to_owned())
}
pub fn is_clean(cwd: &Path) -> Result<bool> {
let output = git_capture(cwd, ["status", "--porcelain"])?;
Ok(output.trim().is_empty())
}
pub fn fetch_origin(cwd: &Path) -> Result<()> {
git_status(cwd, ["fetch", "origin"]).map(|_| ())
}
pub fn has_origin(cwd: &Path) -> Result<bool> {
git_check(cwd, ["remote", "get-url", "origin"])
}
pub fn hard_reset_to_origin(cwd: &Path, branch: &str) -> Result<()> {
git_status(cwd, ["reset", "--hard", &format!("origin/{branch}")]).map(|_| ())
}
pub fn list_worktrees(cwd: &Path) -> Result<Vec<WorktreeInfo>> {
let output = git_capture(cwd, ["worktree", "list", "--porcelain"])?;
let root = repo_root(cwd)?;
let current = canonical_or_original(&root);
let mut worktrees = Vec::new();
let mut current_path: Option<PathBuf> = None;
let mut current_branch_name: Option<String> = None;
for line in output.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if let Some(path) = current_path.take() {
worktrees.push(WorktreeInfo {
is_current: canonical_or_original(&path) == current,
path,
branch: current_branch_name.take(),
});
}
current_path = Some(PathBuf::from(path));
current_branch_name = None;
continue;
}
if let Some(branch) = line.strip_prefix("branch refs/heads/") {
current_branch_name = Some(branch.to_owned());
}
}
if let Some(path) = current_path {
worktrees.push(WorktreeInfo {
is_current: canonical_or_original(&path) == current,
path,
branch: current_branch_name,
});
}
Ok(worktrees)
}
pub fn local_branch_exists(cwd: &Path, branch: &str) -> Result<bool> {
git_check(
cwd,
[
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
],
)
}
pub fn remote_branch_exists(cwd: &Path, branch: &str) -> Result<bool> {
git_check(
cwd,
[
"show-ref",
"--verify",
"--quiet",
&format!("refs/remotes/origin/{branch}"),
],
)
}
pub fn add_worktree(cwd: &Path, target: &Path, branch: &str) -> Result<()> {
let target_str = target_to_str(target)?;
if local_branch_exists(cwd, branch)? {
git_status(cwd, ["worktree", "add", target_str, branch]).map(|_| ())
} else if remote_branch_exists(cwd, branch)? {
git_status(
cwd,
[
"worktree",
"add",
"--track",
"-b",
branch,
target_str,
&format!("origin/{branch}"),
],
)
.map(|_| ())
} else {
git_status(cwd, ["worktree", "add", "-b", branch, target_str, "HEAD"]).map(|_| ())
}
}
pub fn add_worktree_from(cwd: &Path, target: &Path, branch: &str, base: &str) -> Result<()> {
let target_str = target_to_str(target)?;
if local_branch_exists(cwd, branch)? {
git_status(cwd, ["worktree", "add", target_str, branch]).map(|_| ())
} else {
git_status(cwd, ["worktree", "add", "-b", branch, target_str, base]).map(|_| ())
}
}
pub fn remove_worktree(cwd: &Path, target: &Path) -> Result<()> {
let target_str = target_to_str(target)?;
git_status(cwd, ["worktree", "remove", "--force", target_str]).map(|_| ())
}
pub fn prune_worktrees(cwd: &Path) -> Result<()> {
git_status(cwd, ["worktree", "prune"]).map(|_| ())
}
pub fn delete_local_branch(cwd: &Path, branch: &str) -> Result<()> {
git_status(cwd, ["branch", "-d", branch]).map(|_| ())
}
pub fn merged_local_branches(cwd: &Path) -> Result<Vec<String>> {
let output = git_capture(cwd, ["branch", "--format=%(refname:short)", "--merged"])?;
Ok(output
.lines()
.map(str::trim)
.filter(|branch| !branch.is_empty())
.map(ToOwned::to_owned)
.collect())
}
pub fn run_script(cwd: &Path, script: &Path, args: &[String]) -> Result<()> {
let status = Command::new(script)
.args(args)
.current_dir(cwd)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.with_context(|| format!("failed to execute script {}", script.display()))?;
if !status.success() {
bail!("script {} exited with status {status}", script.display())
}
Ok(())
}
pub fn run_script_text(cwd: &Path, text: &str, args: &[String], name: &str) -> Result<()> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned());
let mut command = Command::new(&shell);
command
.arg("-c")
.arg(text)
.arg(name)
.args(args)
.current_dir(cwd)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let status = command
.status()
.with_context(|| format!("failed to execute script '{name}' via {shell}"))?;
if !status.success() {
bail!("script '{name}' exited with status {status}")
}
Ok(())
}
fn git_capture<I, S>(cwd: &Path, args: I) -> Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let output = git_status(cwd, args)?;
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn git_check<I, S>(cwd: &Path, args: I) -> Result<bool>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let output = git_raw(cwd, args)?;
Ok(output.status.success())
}
fn git_status<I, S>(cwd: &Path, args: I) -> Result<Output>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let output = git_raw(cwd, args)?;
if output.status.success() {
return Ok(output);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let message = if !stderr.is_empty() { stderr } else { stdout };
Err(anyhow!(message))
}
fn git_raw<I, S>(cwd: &Path, args: I) -> Result<Output>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let args_vec: Vec<String> = args
.into_iter()
.map(|arg| arg.as_ref().to_owned())
.collect();
Command::new("git")
.args(&args_vec)
.current_dir(cwd)
.output()
.with_context(|| format!("failed to run git {}", args_vec.join(" ")))
}
fn canonical_or_original(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
pub(crate) fn target_to_str(target: &Path) -> Result<&str> {
target
.to_str()
.ok_or_else(|| anyhow!("worktree path is not valid UTF-8: {}", target.display()))
}