use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rayon::prelude::*;
use serde::Serialize;
use crate::discover;
use crate::git;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Worktree {
pub path: PathBuf,
pub name: String,
pub branch: Option<String>,
pub head: Option<String>,
#[serde(skip_serializing_if = "is_false")]
pub bare: bool,
#[serde(skip_serializing_if = "is_false")]
pub locked: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub locked_reason: Option<String>,
#[serde(skip_serializing_if = "is_false")]
pub prunable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub prunable_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Status {
#[serde(flatten)]
pub worktree: Worktree,
pub dirty_files: usize,
pub upstream: Option<Upstream>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Upstream {
pub name: String,
pub ahead: u32,
pub behind: u32,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!b
}
pub fn list(repo: &Path) -> Result<Vec<Worktree>> {
let out = git::capture(repo, &["worktree", "list", "--porcelain"])?;
Ok(parse_porcelain(&out))
}
pub fn require(repo: &Path, name: &str) -> Result<Worktree> {
let worktrees = list(repo)?;
if let Some(w) = worktrees.iter().find(|w| w.name == name) {
return Ok(w.clone());
}
let hint = suggest_name(name, &worktrees);
anyhow::bail!("no worktree named '{name}'{hint}; try: `limb list` to see available")
}
fn suggest_name(name: &str, worktrees: &[Worktree]) -> String {
const MIN_SIMILARITY: f64 = 0.72;
worktrees
.iter()
.filter(|w| !w.bare)
.map(|w| (strsim::jaro_winkler(name, &w.name), &w.name))
.filter(|(score, _)| *score >= MIN_SIMILARITY)
.max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
.map(|(_, n)| format!("; did you mean '{n}'?"))
.unwrap_or_default()
}
pub fn list_with_status(repo: &Path) -> Result<Vec<Status>> {
let trees = list(repo)?;
Ok(trees.par_iter().map(compute_status).collect())
}
#[derive(Debug, Clone, Serialize)]
pub struct RepoWorktree {
pub repo: String,
#[serde(flatten)]
pub worktree: Worktree,
}
#[must_use]
pub fn list_all(roots: &[PathBuf]) -> Vec<RepoWorktree> {
let repos: Vec<(String, PathBuf)> = roots
.iter()
.filter_map(|root| {
if !root.is_dir() {
eprintln!("warning: {} is not a directory. Skipping", root.display());
return None;
}
discover::repos_under(root)
.map_err(|e| {
eprintln!("warning: {}: {e}", root.display());
})
.ok()
})
.flatten()
.map(|r| (discover::repo_name(&r), r))
.collect();
repos
.par_iter()
.flat_map(|(name, repo_dir)| {
let Some(anchor) = discover::anchor_for(repo_dir) else {
return Vec::new();
};
list(&anchor)
.unwrap_or_default()
.into_iter()
.map(|w| RepoWorktree {
repo: name.clone(),
worktree: w,
})
.collect::<Vec<_>>()
})
.collect()
}
#[must_use]
pub fn list_all_with_status(roots: &[PathBuf]) -> Vec<RepoStatus> {
let flat = list_all(roots);
flat.par_iter()
.map(|rw| {
let status = compute_status(&rw.worktree);
RepoStatus {
repo: rw.repo.clone(),
status,
}
})
.collect()
}
#[derive(Debug, Clone, Serialize)]
pub struct RepoStatus {
pub repo: String,
#[serde(flatten)]
pub status: Status,
}
pub fn target_path(repo: &Path, name: &str, base_dir: &Path) -> Result<PathBuf> {
if base_dir.is_absolute() {
Ok(base_dir.join(name))
} else if base_dir == Path::new("..") {
let parent = repo
.parent()
.context("repo has no parent directory; cannot place sibling worktree")?;
Ok(parent.join(name))
} else {
Ok(repo.join(base_dir).join(name))
}
}
#[derive(Debug, Default, Clone, Copy)]
#[allow(clippy::struct_excessive_bools)]
pub struct AddOptions<'a> {
pub branch: Option<&'a str>,
pub from: Option<&'a str>,
pub track: bool,
pub no_track: bool,
pub detach: bool,
pub orphan: bool,
pub lock: bool,
pub reason: Option<&'a str>,
pub force_branch: Option<&'a str>,
pub guess_remote: bool,
pub no_guess_remote: bool,
pub no_checkout: bool,
pub relative_paths: bool,
pub no_relative_paths: bool,
pub quiet: bool,
}
pub fn add(repo: &Path, name: &str, base_dir: &Path, opts: AddOptions<'_>) -> Result<PathBuf> {
let target = target_path(repo, name, base_dir)?;
let target_str = target.to_string_lossy().into_owned();
let mut args: Vec<String> = vec!["worktree".into(), "add".into()];
if opts.lock {
args.push("--lock".into());
if let Some(reason) = opts.reason {
args.push("--reason".into());
args.push(reason.into());
}
}
if opts.quiet {
args.push("--quiet".into());
}
if opts.no_checkout {
args.push("--no-checkout".into());
}
if opts.relative_paths {
args.push("--relative-paths".into());
} else if opts.no_relative_paths {
args.push("--no-relative-paths".into());
}
if opts.detach {
args.push("--detach".into());
args.push(target_str);
if let Some(commit) = opts.from {
args.push(commit.into());
}
} else if opts.orphan {
args.push("--orphan".into());
args.push(target_str);
} else if let Some(fb) = opts.force_branch {
args.push("-B".into());
args.push(fb.into());
push_branch_modifier_flags(&mut args, &opts);
args.push(target_str);
if let Some(f) = opts.from {
args.push(f.into());
}
} else {
match (opts.branch, opts.from) {
(None, None) => {
args.push(target_str);
}
(Some(b), None) => {
args.push(target_str);
args.push(b.into());
}
(None, Some(f)) => {
args.push("-b".into());
args.push(name.into());
push_branch_modifier_flags(&mut args, &opts);
args.push(target_str);
args.push(f.into());
}
(Some(b), Some(f)) => {
args.push("-b".into());
args.push(b.into());
push_branch_modifier_flags(&mut args, &opts);
args.push(target_str);
args.push(f.into());
}
}
}
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
git::run(repo, &refs)?;
Ok(target)
}
fn push_branch_modifier_flags(args: &mut Vec<String>, opts: &AddOptions<'_>) {
if opts.track {
args.push("--track".into());
} else if opts.no_track {
args.push("--no-track".into());
}
if opts.guess_remote {
args.push("--guess-remote".into());
} else if opts.no_guess_remote {
args.push("--no-guess-remote".into());
}
}
pub fn remove(repo: &Path, name: &str, force: bool, quiet: bool) -> Result<()> {
let w = require(repo, name)?;
let path_str = w.path.to_string_lossy().into_owned();
let mut args = vec!["worktree", "remove"];
if force {
args.push("--force");
}
if quiet {
args.push("--quiet");
}
args.push(&path_str);
git::run(repo, &args)
}
pub fn fetch(repo: &Path, quiet: bool) -> Result<()> {
let mut args = vec!["fetch", "--all", "--prune"];
if quiet {
args.push("--quiet");
}
git::run(repo, &args)
}
pub fn fast_forward(worktree: &Path, quiet: bool) -> Result<()> {
let mut args = vec!["merge", "--ff-only", "@{upstream}"];
if quiet {
args.push("--quiet");
}
git::run(worktree, &args)
}
fn compute_status(w: &Worktree) -> Status {
let dirty_files = if w.bare {
0
} else {
git::capture(&w.path, &["status", "--porcelain=v1"]).map_or(0, |s| s.lines().count())
};
let upstream = upstream_for(&w.path);
Status {
worktree: w.clone(),
dirty_files,
upstream,
}
}
fn upstream_for(path: &Path) -> Option<Upstream> {
let name = git::capture(
path,
&[
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
)
.ok()?
.trim()
.to_string();
if name.is_empty() {
return None;
}
let counts = git::capture(
path,
&["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
)
.ok()?;
let mut it = counts.split_whitespace();
let behind: u32 = it.next()?.parse().ok()?;
let ahead: u32 = it.next()?.parse().ok()?;
Some(Upstream {
name,
ahead,
behind,
})
}
#[must_use]
pub fn parse_porcelain(s: &str) -> Vec<Worktree> {
let mut out = Vec::new();
let mut cur: Option<Worktree> = None;
for line in s.lines() {
if line.is_empty() {
if let Some(t) = cur.take() {
out.push(t);
}
continue;
}
let (key, val) = line.split_once(' ').unwrap_or((line, ""));
match key {
"worktree" => {
let path = PathBuf::from(val);
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
cur = Some(Worktree {
path,
name,
branch: None,
head: None,
bare: false,
locked: false,
locked_reason: None,
prunable: false,
prunable_reason: None,
});
}
"HEAD" => {
if let Some(t) = cur.as_mut() {
t.head = Some(val.to_string());
}
}
"branch" => {
if let Some(t) = cur.as_mut() {
let short = val.strip_prefix("refs/heads/").unwrap_or(val);
t.branch = Some(short.to_string());
}
}
"bare" => {
if let Some(t) = cur.as_mut() {
t.bare = true;
}
}
"locked" => {
if let Some(t) = cur.as_mut() {
t.locked = true;
if !val.is_empty() {
t.locked_reason = Some(val.to_string());
}
}
}
"prunable" => {
if let Some(t) = cur.as_mut() {
t.prunable = true;
if !val.is_empty() {
t.prunable_reason = Some(val.to_string());
}
}
}
_ => {}
}
}
if let Some(t) = cur.take() {
out.push(t);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_single_worktree() {
let input = "worktree /home/u/proj\nHEAD abc123\nbranch refs/heads/main\n\n";
let out = parse_porcelain(input);
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "proj");
assert_eq!(out[0].head.as_deref(), Some("abc123"));
assert_eq!(out[0].branch.as_deref(), Some("main"));
assert!(!out[0].bare);
}
#[test]
fn parses_multiple_worktrees() {
let input = "worktree /a/main\nHEAD a\nbranch refs/heads/main\n\nworktree /a/feat\nHEAD b\nbranch refs/heads/feat\n\n";
let out = parse_porcelain(input);
assert_eq!(out.len(), 2);
assert_eq!(out[0].name, "main");
assert_eq!(out[1].name, "feat");
}
#[test]
fn parses_bare_repo() {
let input = "worktree /a/.bare\nbare\n\n";
let out = parse_porcelain(input);
assert_eq!(out.len(), 1);
assert!(out[0].bare);
assert!(out[0].branch.is_none());
}
#[test]
fn parses_locked_and_prunable() {
let input = "worktree /a/w\nHEAD h\nbranch refs/heads/x\nlocked\nprunable gone\n\n";
let out = parse_porcelain(input);
assert_eq!(out.len(), 1);
assert!(out[0].locked);
assert!(out[0].locked_reason.is_none());
assert!(out[0].prunable);
assert_eq!(out[0].prunable_reason.as_deref(), Some("gone"));
}
#[test]
fn parses_locked_with_reason() {
let input = "worktree /a/w\nHEAD h\nbranch refs/heads/x\nlocked in use by build\n\n";
let out = parse_porcelain(input);
assert_eq!(out.len(), 1);
assert!(out[0].locked);
assert_eq!(out[0].locked_reason.as_deref(), Some("in use by build"));
}
#[test]
fn parses_detached_head() {
let input = "worktree /a/w\nHEAD abc\ndetached\n\n";
let out = parse_porcelain(input);
assert_eq!(out.len(), 1);
assert!(out[0].branch.is_none());
assert_eq!(out[0].head.as_deref(), Some("abc"));
}
#[test]
fn trailing_newline_optional() {
let input = "worktree /a\nHEAD x\nbranch refs/heads/main\n";
let out = parse_porcelain(input);
assert_eq!(out.len(), 1);
}
#[test]
fn parses_path_with_spaces() {
let input = "worktree /my folder/repo\nHEAD abc\nbranch refs/heads/main\n\n";
let out = parse_porcelain(input);
assert_eq!(out.len(), 1);
assert_eq!(out[0].path, PathBuf::from("/my folder/repo"));
assert_eq!(out[0].name, "repo");
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn format_porcelain(w: &Worktree) -> String {
let mut s = String::new();
s.push_str("worktree ");
s.push_str(&w.path.to_string_lossy());
s.push('\n');
if let Some(h) = &w.head {
s.push_str("HEAD ");
s.push_str(h);
s.push('\n');
}
if let Some(b) = &w.branch {
s.push_str("branch refs/heads/");
s.push_str(b);
s.push('\n');
}
if w.bare {
s.push_str("bare\n");
}
if w.locked {
s.push_str("locked");
if let Some(r) = &w.locked_reason {
s.push(' ');
s.push_str(r);
}
s.push('\n');
}
if w.prunable {
s.push_str("prunable");
if let Some(r) = &w.prunable_reason {
s.push(' ');
s.push_str(r);
}
s.push('\n');
}
s.push('\n');
s
}
prop_compose! {
fn arb_worktree()(
segments in prop::collection::vec("[a-z][a-z0-9_-]{0,8}", 1..4),
branch in prop::option::of("[a-z][a-z0-9/_-]{0,16}"),
head in prop::option::of("[0-9a-f]{7,40}"),
bare in any::<bool>(),
locked in any::<bool>(),
locked_reason in prop::option::of("[a-z][a-z0-9]{0,15}"),
prunable in any::<bool>(),
prunable_reason in prop::option::of("[a-z][a-z0-9]{0,15}"),
) -> Worktree {
let path = PathBuf::from(format!("/{}", segments.join("/")));
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let (branch, head) = if bare { (None, None) } else { (branch, head) };
let locked_reason = if locked { locked_reason } else { None };
let prunable_reason = if prunable { prunable_reason } else { None };
Worktree {
path,
name,
branch,
head,
bare,
locked,
locked_reason,
prunable,
prunable_reason,
}
}
}
proptest! {
#[test]
fn porcelain_round_trips(w in arb_worktree()) {
let s = format_porcelain(&w);
let parsed = parse_porcelain(&s);
prop_assert_eq!(parsed.len(), 1);
prop_assert_eq!(&parsed[0], &w);
}
}
}