use std::path::Path;
use std::process::Command;
use anyhow::{bail, Context, Result};
pub fn git(repo: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(["-C", &repo.to_string_lossy()])
.args(args)
.output()
.context("failed to execute git")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git {} failed: {}", args.join(" "), stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
pub fn repo_root(from: &Path) -> Result<std::path::PathBuf> {
let root = git(from, &["rev-parse", "--show-toplevel"])?;
Ok(std::path::PathBuf::from(root))
}
pub fn repo_root_cwd() -> Result<std::path::PathBuf> {
repo_root(&std::env::current_dir()?)
}
pub fn has_remote(repo: &Path) -> Result<bool> {
let remotes = git(repo, &["remote"])?;
Ok(!remotes.is_empty())
}
pub fn current_branch(repo: &Path) -> Result<String> {
git(repo, &["branch", "--show-current"])
}
pub fn fetch(repo: &Path) -> Result<()> {
git(repo, &["fetch", "-q"])?;
Ok(())
}
pub fn add(repo: &Path, files: &[&str]) -> Result<()> {
let mut args = vec!["add", "--"];
args.extend(files);
git(repo, &args)?;
Ok(())
}
pub fn commit(repo: &Path, message: &str) -> Result<()> {
git(repo, &["commit", "-qm", message])?;
Ok(())
}
pub enum PushResult {
Success,
Rejected,
Failed(String),
}
pub fn push(repo: &Path) -> Result<PushResult> {
let output = Command::new("git")
.args(["-C", &repo.to_string_lossy(), "push", "-q"])
.output()
.context("failed to execute git push")?;
if output.status.success() {
return Ok(PushResult::Success);
}
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if stderr.contains("non-fast-forward") || stderr.contains("fetch first") {
Ok(PushResult::Rejected)
} else {
Ok(PushResult::Failed(stderr.trim().to_string()))
}
}
pub fn push_with_retry(repo: &Path) -> Result<()> {
match push(repo)? {
PushResult::Success => return Ok(()),
PushResult::Failed(stderr) => {
bail!("push failed: {}", stderr);
}
PushResult::Rejected => {}
}
pull_rebase(repo)?;
match push(repo)? {
PushResult::Success => Ok(()),
PushResult::Failed(stderr) => {
bail!("push failed after rebase: {}", stderr);
}
PushResult::Rejected => {
bail!("push rejected twice — resolve upstream state manually");
}
}
}
pub fn pull_rebase(repo: &Path) -> Result<()> {
git(repo, &["pull", "--rebase", "-q"])?;
Ok(())
}
pub fn undo_commit(repo: &Path) -> Result<()> {
let added = git(
repo,
&["diff", "--name-only", "--diff-filter=A", "HEAD~1", "HEAD"],
)
.unwrap_or_default();
let modified = git(
repo,
&["diff", "--name-only", "--diff-filter=M", "HEAD~1", "HEAD"],
)
.unwrap_or_default();
git(repo, &["reset", "HEAD~1"])?;
for file in added.lines() {
if file.starts_with(".tickets/") {
let path = repo.join(file);
let _ = std::fs::remove_file(&path);
}
}
for file in modified.lines() {
if file.starts_with(".tickets/") {
let _ = git(repo, &["checkout", "HEAD", "--", file]);
}
}
Ok(())
}
pub fn remote_ticket_names(repo: &Path) -> Vec<String> {
let output = Command::new("git")
.args([
"-C",
&repo.to_string_lossy(),
"ls-tree",
"--name-only",
"origin/main",
".tickets/",
])
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.filter_map(|l| {
l.strip_prefix(".tickets/")
.or(Some(l)) .filter(|name| name.ends_with(".md"))
.map(|s| s.to_string())
})
.collect(),
_ => Vec::new(),
}
}