use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{anyhow, bail, Context, Result};
#[derive(Debug, Clone)]
pub struct Worktree {
pub path: PathBuf,
pub head: Option<String>,
pub branch: Option<String>,
pub bare: bool,
pub detached: bool,
pub locked: bool,
}
impl Worktree {
pub fn name(&self) -> String {
self.branch.clone().unwrap_or_else(|| {
self.path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| self.path.display().to_string())
})
}
pub fn short_head(&self) -> String {
match &self.head {
Some(sha) => sha.chars().take(7).collect(),
None => "-".to_string(),
}
}
}
pub const REPO_ENV: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_COMMON_DIR",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_PREFIX",
];
fn git_in(dir: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.current_dir(dir);
for var in REPO_ENV {
cmd.env_remove(var);
}
cmd
}
pub fn output<I, S>(dir: &Path, args: I) -> Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let out = git_in(dir)
.args(args)
.output()
.context("failed to run `git` (is it installed and on PATH?)")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
bail!("git failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
}
pub fn run<I, S>(dir: &Path, args: I) -> Result<()>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let out = git_in(dir)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.output()
.context("failed to run `git` (is it installed and on PATH?)")?;
let chatter = String::from_utf8_lossy(&out.stdout);
if !chatter.trim().is_empty() {
eprint!("{chatter}");
}
if !out.status.success() {
bail!("git exited with status {}", out.status);
}
Ok(())
}
fn check<I, S>(dir: &Path, args: I) -> bool
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
git_in(dir)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn main_worktree(cwd: &Path) -> Result<PathBuf> {
if !check(cwd, ["rev-parse", "--git-dir"]) {
bail!("not inside a git repository");
}
let list = list_worktrees(cwd)?;
list.into_iter()
.next()
.map(|w| w.path)
.ok_or_else(|| anyhow!("could not determine the main worktree"))
}
pub fn list_worktrees(cwd: &Path) -> Result<Vec<Worktree>> {
let out = output(cwd, ["worktree", "list", "--porcelain"])?;
Ok(parse_worktree_list(&out))
}
pub fn parse_worktree_list(porcelain: &str) -> Vec<Worktree> {
let mut worktrees = Vec::new();
let mut current: Option<Worktree> = None;
for line in porcelain.lines() {
let line = line.trim_end();
if line.is_empty() {
worktrees.extend(current.take());
continue;
}
let (key, value) = match line.split_once(' ') {
Some((k, v)) => (k, v),
None => (line, ""),
};
match key {
"worktree" => {
worktrees.extend(current.take());
current = Some(Worktree {
path: PathBuf::from(value),
head: None,
branch: None,
bare: false,
detached: false,
locked: false,
});
}
_ => {
let Some(wt) = current.as_mut() else { continue };
match key {
"HEAD" => wt.head = Some(value.to_string()),
"branch" => {
wt.branch = Some(
value
.strip_prefix("refs/heads/")
.unwrap_or(value)
.to_string(),
)
}
"bare" => wt.bare = true,
"detached" => wt.detached = true,
"locked" => wt.locked = true,
_ => {}
}
}
}
}
worktrees.extend(current);
worktrees
}
pub fn local_branch_exists(cwd: &Path, branch: &str) -> bool {
check(
cwd,
[
"show-ref",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
],
)
}
pub fn remote_branches_matching(cwd: &Path, branch: &str) -> Result<Vec<String>> {
let out = output(
cwd,
[
"for-each-ref",
"--format=%(refname:short)",
&format!("refs/remotes/*/{branch}"),
],
)?;
Ok(out
.lines()
.map(|l| l.to_string())
.filter(|l| !l.is_empty())
.collect())
}
pub fn local_branches(cwd: &Path) -> Result<Vec<String>> {
let out = output(
cwd,
["for-each-ref", "--format=%(refname:short)", "refs/heads"],
)?;
Ok(non_empty_lines(&out))
}
pub fn remote_branches(cwd: &Path) -> Result<Vec<(String, String)>> {
let out = output(
cwd,
[
"for-each-ref",
"--format=%(refname:short)%09%(refname:strip=3)",
"refs/remotes",
],
)?;
Ok(out
.lines()
.filter_map(|line| line.split_once('\t'))
.filter(|(_, short)| *short != "HEAD" && !short.is_empty())
.map(|(full, short)| (full.to_string(), short.to_string()))
.collect())
}
pub fn start_points(cwd: &Path) -> Result<Vec<String>> {
let out = output(
cwd,
[
"for-each-ref",
"--format=%(refname:short)",
"refs/heads",
"refs/tags",
"refs/remotes",
],
)?;
Ok(non_empty_lines(&out))
}
fn non_empty_lines(out: &str) -> Vec<String> {
out.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
pub fn is_dirty(path: &Path) -> Result<bool> {
Ok(!output(path, ["status", "--porcelain"])?.is_empty())
}
pub fn is_merged(main: &Path, branch: &str) -> Result<bool> {
Ok(merged_branches(main)?.iter().any(|b| b == branch))
}
pub fn merged_branches(main: &Path) -> Result<Vec<String>> {
let out = output(
main,
["branch", "--merged", "HEAD", "--format=%(refname:short)"],
)?;
Ok(non_empty_lines(&out))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tracking {
Untracked,
Gone,
Pushed,
Ahead(usize),
}
pub fn tracking(main: &Path) -> Result<BTreeMap<String, Tracking>> {
let out = output(
main,
[
"for-each-ref",
"--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)",
"refs/heads/",
],
)?;
let mut map = BTreeMap::new();
for line in out.lines() {
let mut fields = line.split('\t');
let (Some(branch), Some(upstream)) = (fields.next(), fields.next()) else {
continue;
};
if branch.is_empty() {
continue;
}
map.insert(
branch.to_string(),
parse_tracking(upstream, fields.next().unwrap_or_default()),
);
}
Ok(map)
}
fn parse_tracking(upstream: &str, track: &str) -> Tracking {
if upstream.is_empty() {
return Tracking::Untracked;
}
if track.contains("gone") {
return Tracking::Gone;
}
match track
.trim_start_matches('[')
.split(',')
.map(str::trim)
.find_map(|part| part.strip_prefix("ahead "))
.and_then(|n| n.trim_end_matches(']').parse().ok())
{
Some(ahead) => Tracking::Ahead(ahead),
None => Tracking::Pushed,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tracking_tells_apart_never_pushed_and_level() {
assert_eq!(parse_tracking("", ""), Tracking::Untracked);
assert_eq!(parse_tracking("origin/feat", ""), Tracking::Pushed);
}
#[test]
fn tracking_reads_the_ahead_count() {
assert_eq!(
parse_tracking("origin/feat", "[ahead 3]"),
Tracking::Ahead(3)
);
assert_eq!(
parse_tracking("origin/feat", "[ahead 1, behind 2]"),
Tracking::Ahead(1)
);
assert_eq!(
parse_tracking("origin/feat", "[behind 2]"),
Tracking::Pushed
);
assert_eq!(parse_tracking("origin/feat", "[gone]"), Tracking::Gone);
}
}