use std::path::{Path, PathBuf};
use std::process::Command;
fn lines_of(workspace: &Path, args: &[&str]) -> Vec<String> {
match Command::new("git")
.args(args)
.current_dir(workspace)
.output()
{
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect(),
_ => Vec::new(),
}
}
pub fn local_branches(workspace: &Path) -> Vec<String> {
lines_of(
workspace,
&["for-each-ref", "--format=%(refname:short)", "refs/heads"],
)
}
pub fn local_branches_by_recent(workspace: &Path) -> Vec<String> {
lines_of(
workspace,
&[
"for-each-ref",
"--sort=-committerdate",
"--format=%(refname:short)",
"refs/heads",
],
)
}
pub fn tags(workspace: &Path) -> Vec<String> {
let mut t = lines_of(
workspace,
&["for-each-ref", "--format=%(refname:short)", "refs/tags"],
);
t.sort();
t
}
#[derive(Debug, Clone)]
pub struct StashRow {
pub id: String,
pub summary: String,
}
pub fn stashes(workspace: &Path) -> Vec<StashRow> {
lines_of(workspace, &["stash", "list", "--format=%gd\x1f%gs"])
.into_iter()
.filter_map(|line| {
line.split_once('\x1f').map(|(id, summary)| StashRow {
id: id.to_string(),
summary: summary.to_string(),
})
})
.collect()
}
pub fn remote_branches(workspace: &Path) -> Vec<String> {
lines_of(
workspace,
&["for-each-ref", "--format=%(refname:short)", "refs/remotes"],
)
.into_iter()
.filter(|b| !b.ends_with("/HEAD"))
.collect()
}
pub fn current(workspace: &Path) -> Option<String> {
let out = Command::new("git")
.args(["symbolic-ref", "--short", "-q", "HEAD"])
.current_dir(workspace)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let b = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!b.is_empty()).then_some(b)
}
#[derive(Debug, Clone)]
pub struct Worktree {
pub path: PathBuf,
pub label: String,
pub is_current: bool,
}
pub fn worktrees(workspace: &Path) -> Vec<Worktree> {
let out = match Command::new("git")
.args(["worktree", "list", "--porcelain"])
.current_dir(workspace)
.output()
{
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
let here = workspace.canonicalize().ok();
let mut out_v = Vec::new();
let mut path: Option<PathBuf> = None;
let mut label = String::from("(detached)");
let flush = |path: &mut Option<PathBuf>, label: &mut String, v: &mut Vec<Worktree>| {
if let Some(p) = path.take() {
let is_current = here
.as_ref()
.and_then(|h| p.canonicalize().ok().map(|c| &c == h))
== Some(true);
v.push(Worktree {
path: p,
label: std::mem::replace(label, String::from("(detached)")),
is_current,
});
} else {
*label = String::from("(detached)");
}
};
for line in String::from_utf8_lossy(&out.stdout).lines() {
if let Some(p) = line.strip_prefix("worktree ") {
flush(&mut path, &mut label, &mut out_v);
path = Some(PathBuf::from(p));
} else if let Some(b) = line.strip_prefix("branch ") {
label = b.strip_prefix("refs/heads/").unwrap_or(b).to_string();
} else if line == "bare" {
label = "(bare)".to_string();
} else if line == "detached" {
label = "(detached)".to_string();
}
}
flush(&mut path, &mut label, &mut out_v);
out_v
}
fn run(workspace: &Path, args: &[&str]) -> Result<(), String> {
let out = Command::new("git")
.args(args)
.current_dir(workspace)
.output()
.map_err(|e| format!("git: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr)
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("git failed")
.to_string())
}
}
pub fn checkout(workspace: &Path, branch: &str) -> Result<(), String> {
run(workspace, &["switch", branch])
}
pub fn checkout_track(workspace: &Path, remote: &str) -> Result<(), String> {
run(workspace, &["checkout", "--track", remote])
}
pub fn create(workspace: &Path, name: &str) -> Result<(), String> {
run(workspace, &["checkout", "-b", name, "--"])
}
pub fn create_from(workspace: &Path, name: &str, source: &str) -> Result<(), String> {
run(workspace, &["checkout", "-b", name, source, "--"])
}
pub fn delete_branch(workspace: &Path, name: &str) -> Result<(), String> {
run(workspace, &["branch", "-D", "--", name])
}
pub fn merge(workspace: &Path, branch: &str) -> Result<(), String> {
run(workspace, &["merge", "--no-edit", "--", branch])
}
pub fn rebase(workspace: &Path, branch: &str) -> Result<(), String> {
run(workspace, &["rebase", "--", branch])
}
pub fn worktree_add(workspace: &Path, path: &Path, branch: &str) -> Result<(), String> {
run(
workspace,
&["worktree", "add", "--", &path.to_string_lossy(), branch],
)
}
pub fn worktree_remove(workspace: &Path, path: &Path) -> Result<(), String> {
run(workspace, &["worktree", "remove", &path.to_string_lossy()])
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn init_repo() -> tempfile::TempDir {
let d = tempfile::tempdir().unwrap();
for args in [
&["init", "-q", "-b", "main"][..],
&["config", "user.email", "t@example.com"][..],
&["config", "user.name", "Test"][..],
&["config", "commit.gpgsign", "false"][..],
] {
let _ = Command::new("git")
.args(args)
.current_dir(d.path())
.output();
}
d
}
#[test]
fn empty_on_non_repo() {
let d = tempfile::tempdir().unwrap();
assert!(local_branches(d.path()).is_empty());
assert!(remote_branches(d.path()).is_empty());
assert!(worktrees(d.path()).is_empty());
assert!(current(d.path()).is_none());
}
#[test]
fn create_from_branches_off_named_source() {
let d = init_repo();
std::fs::write(d.path().join("a.txt"), "alpha").unwrap();
let _ = Command::new("git")
.args(["add", "."])
.current_dir(d.path())
.output();
let _ = Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(d.path())
.output();
assert!(create(d.path(), "feature/x").is_ok());
std::fs::write(d.path().join("a.txt"), "alpha2").unwrap();
let _ = Command::new("git")
.args(["commit", "-am", "feat"])
.current_dir(d.path())
.output();
let _ = Command::new("git")
.args(["checkout", "main"])
.current_dir(d.path())
.output();
assert!(create_from(d.path(), "hotfix/y", "main").is_ok());
assert_eq!(current(d.path()).as_deref(), Some("hotfix/y"));
}
}