use std::path::{Path, PathBuf};
use std::process::Command;
use super::model::{BranchInfo, Commit, Contributor, FileChange, RepoInfo, RepoStatus, Worktree};
fn run(cwd: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.map_err(|e| format!("git not found: {e}"))?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn is_repo(cwd: &Path) -> bool {
run(cwd, &["rev-parse", "--is-inside-work-tree"])
.map(|s| s.trim() == "true")
.unwrap_or(false)
}
pub fn common_dir(cwd: &Path) -> Option<PathBuf> {
let raw = run(cwd, &["rev-parse", "--git-common-dir"]).ok()?;
let p = PathBuf::from(raw.trim());
let abs = if p.is_absolute() { p } else { cwd.join(p) };
Some(std::fs::canonicalize(&abs).unwrap_or(abs))
}
pub fn worktrees(cwd: &Path) -> Result<Vec<Worktree>, String> {
Ok(parse_worktrees(&run(
cwd,
&["worktree", "list", "--porcelain"],
)?))
}
fn parse_worktrees(raw: &str) -> Vec<Worktree> {
let mut out: Vec<Worktree> = Vec::new();
let mut path: Option<PathBuf> = None;
let mut head = String::new();
let mut branch: Option<String> = None;
let flush = |path: &mut Option<PathBuf>,
head: &mut String,
branch: &mut Option<String>,
out: &mut Vec<Worktree>| {
if let Some(p) = path.take() {
let is_main = out.is_empty(); out.push(Worktree {
path: p,
branch: branch.take(),
head: std::mem::take(head),
is_main,
});
}
};
for line in raw.lines() {
if let Some(p) = line.strip_prefix("worktree ") {
flush(&mut path, &mut head, &mut branch, &mut out);
path = Some(PathBuf::from(p));
} else if let Some(h) = line.strip_prefix("HEAD ") {
head = h.to_string();
} else if let Some(b) = line.strip_prefix("branch ") {
branch = Some(b.strip_prefix("refs/heads/").unwrap_or(b).to_string());
}
}
flush(&mut path, &mut head, &mut branch, &mut out);
out
}
pub fn worktree_add(repo: &Path, path: &Path, branch: &str) -> Result<(), String> {
let ps = path.to_string_lossy().to_string();
run(repo, &["worktree", "add", "-b", branch, &ps])
.or_else(|_| run(repo, &["worktree", "add", &ps, branch]))
.map(|_| ())
}
pub fn worktree_remove(repo: &Path, path: &Path) -> Result<(), String> {
run(repo, &["worktree", "remove", &path.to_string_lossy()]).map(|_| ())
}
pub fn status(cwd: &Path) -> Result<RepoStatus, String> {
let raw = run(cwd, &["status", "--porcelain=v1", "--branch"])?;
let mut st = RepoStatus::default();
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("## ") {
parse_branch_line(rest, &mut st);
} else if let Some(path) = line.strip_prefix("?? ") {
st.untracked.push(path.to_string());
} else if line.len() > 3 {
let bytes = line.as_bytes();
let (x, y) = (bytes[0] as char, bytes[1] as char);
let path = line[3..].to_string();
if x != ' ' && x != '?' {
st.staged.push(FileChange {
code: x,
path: path.clone(),
});
}
if y != ' ' && y != '?' {
st.unstaged.push(FileChange { code: y, path });
}
}
}
st.stashes = run(cwd, &["stash", "list"])
.map(|s| s.lines().map(str::to_string).collect())
.unwrap_or_default();
Ok(st)
}
fn parse_branch_line(rest: &str, st: &mut RepoStatus) {
let (head, track) = match rest.split_once(" [") {
Some((h, t)) => (h, Some(t.trim_end_matches(']'))),
None => (rest, None),
};
let (branch, upstream) = match head.split_once("...") {
Some((b, u)) => (b, Some(u.to_string())),
None => (head, None),
};
st.branch = branch.trim().to_string();
st.upstream = upstream;
if let Some(t) = track {
for part in t.split(',') {
let part = part.trim();
if let Some(n) = part.strip_prefix("ahead ") {
st.ahead = n.trim().parse().unwrap_or(0);
} else if let Some(n) = part.strip_prefix("behind ") {
st.behind = n.trim().parse().unwrap_or(0);
}
}
}
}
const FIELD: &str = "\u{1f}";
pub fn branches(cwd: &Path) -> Result<Vec<BranchInfo>, String> {
let fmt = format!(
"%(HEAD){F}%(refname:short){F}%(upstream:track){F}%(contents:subject){F}%(authorname){F}%(committerdate:relative)",
F = FIELD
);
let raw = run(
cwd,
&[
"for-each-ref",
"--sort=-committerdate",
&format!("--format={fmt}"),
"refs/heads",
],
)?;
Ok(raw
.lines()
.filter_map(|line| {
let f: Vec<&str> = line.split(FIELD).collect();
if f.len() < 6 {
return None;
}
let (ahead, behind) = parse_track(f[2]);
Some(BranchInfo {
is_head: f[0] == "*",
name: f[1].to_string(),
ahead,
behind,
subject: f[3].to_string(),
author: f[4].to_string(),
when: f[5].to_string(),
})
})
.collect())
}
fn parse_track(s: &str) -> (u32, u32) {
let inner = s.trim_start_matches('[').trim_end_matches(']');
let (mut a, mut b) = (0, 0);
for part in inner.split(',') {
let part = part.trim();
if let Some(n) = part.strip_prefix("ahead ") {
a = n.trim().parse().unwrap_or(0);
} else if let Some(n) = part.strip_prefix("behind ") {
b = n.trim().parse().unwrap_or(0);
}
}
(a, b)
}
pub fn commits(cwd: &Path, n: usize, all: bool) -> Result<Vec<Commit>, String> {
let fmt = format!("%h{F}%s{F}%an{F}%ar{F}%d", F = FIELD);
let count = format!("-n{n}");
let pretty = format!("--pretty=format:{fmt}");
let mut args: Vec<&str> = vec!["log", "--graph", &count, &pretty];
if all {
args.push("--all");
}
let raw = run(cwd, &args)?;
Ok(raw
.lines()
.filter_map(|line| {
match line.split_once(FIELD) {
Some((head, rest)) => {
let trimmed = head.trim_end();
let sha_start = trimmed.rfind(' ').map(|i| i + 1).unwrap_or(0);
let graph = head[..sha_start].to_string();
let sha = trimmed[sha_start..].to_string();
let f: Vec<&str> = rest.split(FIELD).collect();
Some(Commit {
sha,
graph,
subject: f.first().copied().unwrap_or("").to_string(),
author: f.get(1).copied().unwrap_or("").to_string(),
when: f.get(2).copied().unwrap_or("").to_string(),
refs: f.get(3).copied().unwrap_or("").trim().to_string(),
})
}
None => None,
}
})
.collect())
}
pub fn checkout(cwd: &Path, branch: &str) -> Result<(), String> {
run(cwd, &["switch", branch]).map(|_| ())
}
#[derive(Debug, PartialEq)]
pub enum MergeOutcome {
Merged,
Conflict(Vec<String>),
}
fn run_status(cwd: &Path, args: &[&str]) -> Result<(bool, String, String), String> {
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.map_err(|e| format!("git not found: {e}"))?;
Ok((
out.status.success(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
))
}
fn branch_exists(repo: &Path, branch: &str) -> bool {
run(
repo,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
],
)
.is_ok()
}
pub fn default_branch(repo: &Path) -> String {
for b in ["main", "master"] {
if branch_exists(repo, b) {
return b.to_string();
}
}
run(repo, &["rev-parse", "--abbrev-ref", "HEAD"])
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "main".to_string())
}
fn ensure_worktree(repo: &Path, dir: &Path, branch: &str, base: &str) -> Result<PathBuf, String> {
if let Ok(wts) = worktrees(repo) {
if let Some(w) = wts
.into_iter()
.find(|w| w.branch.as_deref() == Some(branch))
{
return Ok(w.path);
}
}
if let Some(parent) = dir.parent() {
let _ = std::fs::create_dir_all(parent);
}
let ds = dir.to_string_lossy().to_string();
if branch_exists(repo, branch) {
run(repo, &["worktree", "add", &ds, branch])?;
} else {
run(repo, &["worktree", "add", "-b", branch, &ds, base])?;
}
Ok(dir.to_path_buf())
}
pub fn integrate_branch(
repo: &Path,
integ_dir: &Path,
integ_branch: &str,
base: &str,
topic: &str,
) -> Result<MergeOutcome, String> {
let integ_path = ensure_worktree(repo, integ_dir, integ_branch, base)?;
let (ok, _out, err) = run_status(&integ_path, &["merge", "--no-ff", "--no-edit", topic])?;
if ok {
return Ok(MergeOutcome::Merged);
}
let conflicts: Vec<String> = run(&integ_path, &["diff", "--name-only", "--diff-filter=U"])
.unwrap_or_default()
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let _ = run(&integ_path, &["merge", "--abort"]);
if conflicts.is_empty() {
return Err(err.trim().to_string());
}
Ok(MergeOutcome::Conflict(conflicts))
}
pub fn repo_info(cwd: &Path) -> Result<RepoInfo, String> {
let remote_url = run(cwd, &["remote", "get-url", "origin"])
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let (host, slug) = remote_url
.as_deref()
.map(parse_remote)
.unwrap_or((None, None));
let total_commits = run(cwd, &["rev-list", "--count", "HEAD"])
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let age = run(
cwd,
&["log", "--reverse", "--format=%cr", "--max-parents=0"],
)
.ok()
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
.filter(|s| !s.is_empty());
let contributors = run(cwd, &["shortlog", "-s", "-n", "-e", "HEAD"])
.map(|out| parse_contributors(&out))
.unwrap_or_default();
Ok(RepoInfo {
remote_url,
slug,
host,
total_commits,
age,
contributors,
})
}
fn parse_remote(url: &str) -> (Option<String>, Option<String>) {
let body = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.or_else(|| url.strip_prefix("ssh://"))
.map(|s| s.to_string())
.unwrap_or_else(|| url.replacen(':', "/", 1));
let body = body.rsplit('@').next().unwrap_or(&body);
let body = body
.strip_suffix(".git")
.unwrap_or(body)
.trim_end_matches('/');
let mut parts = body.splitn(2, '/');
let host = parts.next().filter(|h| !h.is_empty()).map(str::to_string);
let slug = parts.next().filter(|s| s.contains('/')).map(str::to_string);
(host, slug)
}
fn parse_contributors(out: &str) -> Vec<Contributor> {
out.lines()
.filter_map(|line| {
let (count, rest) = line.trim_start().split_once('\t')?;
let commits: u32 = count.trim().parse().ok()?;
let (name, email) = match rest.rsplit_once(" <") {
Some((n, e)) => (n.trim().to_string(), e.trim_end_matches('>').to_string()),
None => (rest.trim().to_string(), String::new()),
};
Some(Contributor {
name,
email,
commits,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_remote_forms() {
assert_eq!(
parse_remote("git@github.com:owner/repo.git"),
(Some("github.com".into()), Some("owner/repo".into()))
);
assert_eq!(
parse_remote("https://github.com/owner/repo.git"),
(Some("github.com".into()), Some("owner/repo".into()))
);
assert_eq!(
parse_remote("https://gitlab.com/group/sub/repo"),
(Some("gitlab.com".into()), Some("group/sub/repo".into()))
);
}
#[test]
fn parses_shortlog() {
let out = " 8\tAda <ada@x.com>\n 3\tLin <lin@y.com>\n";
let c = parse_contributors(out);
assert_eq!(c.len(), 2);
assert_eq!(c[0].name, "Ada");
assert_eq!(c[0].email, "ada@x.com");
assert_eq!(c[0].commits, 8);
}
#[test]
fn parses_worktree_porcelain() {
let out = "\
worktree /repo/main
HEAD aaaa1111
branch refs/heads/main
worktree /repo/../wt-feature
HEAD bbbb2222
branch refs/heads/feature
worktree /repo/detached
HEAD cccc3333
detached
";
let wts = parse_worktrees(out);
assert_eq!(wts.len(), 3);
assert!(wts[0].is_main, "first listed worktree is the main one");
assert_eq!(wts[0].branch.as_deref(), Some("main"));
assert_eq!(wts[1].branch.as_deref(), Some("feature"));
assert!(!wts[1].is_main);
assert_eq!(wts[2].branch, None, "detached worktree has no branch");
assert_eq!(wts[2].head, "cccc3333");
}
#[test]
fn worktree_and_repo_share_common_dir() {
let base = std::env::temp_dir().join(format!("bohay-wtcommon-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let repo = base.join("repo");
std::fs::create_dir_all(&repo).unwrap();
let git = |dir: &Path, args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap();
};
git(&repo, &["init", "-q", "-b", "main"]);
git(
&repo,
&[
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"--allow-empty",
"-m",
"init",
],
);
let wt = base.join("wt");
git(
&repo,
&["worktree", "add", "-q", "-b", "feat", wt.to_str().unwrap()],
);
let a = common_dir(&repo);
let b = common_dir(&wt);
assert!(a.is_some(), "repo has a common dir");
assert_eq!(a, b, "the worktree shares the repo's common dir");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn integrate_branch_merges_clean_then_flags_a_conflict() {
let base_dir = std::env::temp_dir().join(format!("bohay-merge-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base_dir);
let repo = base_dir.join("repo");
std::fs::create_dir_all(&repo).unwrap();
let g = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(&repo)
.output()
.unwrap();
};
let write = |name: &str, content: &str| std::fs::write(repo.join(name), content).unwrap();
g(&["init", "-q", "-b", "main"]);
g(&["config", "user.email", "t@t"]);
g(&["config", "user.name", "t"]);
write("X", "base\n");
g(&["add", "."]);
g(&["commit", "-q", "-m", "base"]);
g(&["checkout", "-q", "-b", "feat1"]);
write("X", "one\n");
write("A", "a\n");
g(&["add", "."]);
g(&["commit", "-q", "-m", "feat1"]);
g(&["checkout", "-q", "main"]);
g(&["checkout", "-q", "-b", "feat2"]);
write("X", "two\n");
g(&["add", "."]);
g(&["commit", "-q", "-m", "feat2"]);
g(&["checkout", "-q", "main"]);
let integ_dir = base_dir.join("integ");
let r1 = integrate_branch(&repo, &integ_dir, "bohay/integration", "main", "feat1").unwrap();
assert_eq!(r1, MergeOutcome::Merged);
assert_eq!(std::fs::read_to_string(repo.join("X")).unwrap(), "base\n");
let r2 = integrate_branch(&repo, &integ_dir, "bohay/integration", "main", "feat2").unwrap();
match r2 {
MergeOutcome::Conflict(files) => {
assert!(
files.iter().any(|f| f == "X"),
"X should conflict: {files:?}"
)
}
other => panic!("expected a conflict, got {other:?}"),
}
assert_eq!(std::fs::read_to_string(repo.join("X")).unwrap(), "base\n");
let _ = std::fs::remove_dir_all(&base_dir);
}
}