use std::path::{Component, Path, PathBuf};
use crate::error::{Error, Result};
use crate::git::cli::GitCli;
use crate::git::porcelain::{RawWorktree, parse_worktree_list};
pub(crate) fn enumerate(git: &dyn GitCli, dir: &Path) -> Result<Vec<RawWorktree>> {
let output = git.run(dir, &["worktree", "list", "--porcelain"])?;
let mut worktrees = parse_worktree_list(&output);
for wt in &mut worktrees {
wt.is_missing = !wt.is_bare && !wt.path.exists();
}
Ok(worktrees)
}
const IN_PROGRESS_MARKERS: [(&str, &str); 7] = [
("rebase-merge", "rebase"),
("rebase-apply", "rebase"),
("MERGE_HEAD", "merge"),
("CHERRY_PICK_HEAD", "cherry-pick"),
("REVERT_HEAD", "revert"),
("sequencer", "cherry-pick or revert"),
("BISECT_LOG", "bisect"),
];
const IN_PROGRESS_BRANCH_FILES: [(&str, &str); 3] = [
("rebase-merge/head-name", "being rebased"),
("rebase-apply/head-name", "being rebased"),
("BISECT_START", "being bisected"),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HeldBranch {
pub(crate) name: String,
pub(crate) activity: &'static str,
}
fn admin_dir(git: &dyn GitCli, repo_dir: &Path, worktree: &Path, missing: bool) -> Result<PathBuf> {
if missing {
return missing_admin_dir(git, repo_dir, worktree);
}
let git_dir = git.run(worktree, &["rev-parse", "--absolute-git-dir"])?;
Ok(PathBuf::from(git_dir.trim()))
}
fn missing_admin_dir(git: &dyn GitCli, repo_dir: &Path, worktree: &Path) -> Result<PathBuf> {
let common = git.run(repo_dir, &["rev-parse", "--git-common-dir"])?;
let entries = repo_dir.join(common.trim()).join("worktrees");
let target = worktree.join(".git");
for entry in std::fs::read_dir(&entries)? {
let admin = entry?.path();
let recorded = match std::fs::read_to_string(admin.join("gitdir")) {
Ok(recorded) => recorded,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
if normalize(&admin.join(recorded.trim())) == target {
return Ok(admin);
}
}
Err(Error::operation(format!(
"no worktree admin directory records {}",
worktree.display()
)))
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
pub(crate) fn in_progress_op(
git: &dyn GitCli,
repo_dir: &Path,
worktree: &Path,
missing: bool,
) -> Result<Option<&'static str>> {
let git_dir = admin_dir(git, repo_dir, worktree, missing)?;
Ok(IN_PROGRESS_MARKERS
.iter()
.find(|(marker, _)| git_dir.join(marker).exists())
.map(|(_, op)| *op))
}
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
pub(crate) fn in_progress_branch(
git: &dyn GitCli,
repo_dir: &Path,
worktree: &Path,
missing: bool,
) -> Result<Option<HeldBranch>> {
let git_dir = admin_dir(git, repo_dir, worktree, missing)?;
for (file, activity) in IN_PROGRESS_BRANCH_FILES {
let contents = match std::fs::read_to_string(git_dir.join(file)) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
let name = contents.trim();
let name = name.strip_prefix("refs/heads/").unwrap_or(name);
if !name.is_empty() {
return Ok(Some(HeldBranch {
name: name.to_string(),
activity,
}));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::cli::RealGit;
use crate::testutil::TestRepo;
#[test]
fn enumerates_main_and_linked() {
let repo = TestRepo::init();
repo.add_worktree("feature/x", "../wt-x");
repo.add_worktree("feature/y", "../wt-y");
let wts = enumerate(&RealGit, repo.root()).unwrap();
assert_eq!(wts.len(), 3);
assert!(wts[0].is_main);
let branches: Vec<_> = wts.iter().filter_map(|w| w.branch.clone()).collect();
assert!(branches.contains(&"feature/x".to_string()));
assert!(branches.contains(&"feature/y".to_string()));
assert!(wts.iter().all(|w| !w.is_missing));
}
#[test]
fn detects_missing_worktree() {
let repo = TestRepo::init();
repo.add_worktree("gone", "../wt-gone");
let linked = repo.root().parent().unwrap().join("wt-gone");
std::fs::remove_dir_all(&linked).unwrap();
let wts = enumerate(&RealGit, repo.root()).unwrap();
let missing = wts
.iter()
.find(|w| w.branch.as_deref() == Some("gone"))
.unwrap();
assert!(missing.is_missing);
}
#[test]
fn in_progress_op_names_each_marker_in_a_linked_worktree() {
let repo = TestRepo::init();
repo.add_worktree("busy", "../wt-busy");
let linked = repo.root().parent().unwrap().join("wt-busy");
assert_eq!(
in_progress_op(&RealGit, repo.root(), &linked, false).unwrap(),
None
);
let admin = RealGit
.run(&linked, &["rev-parse", "--absolute-git-dir"])
.unwrap();
let admin = Path::new(admin.trim());
for (marker, op) in IN_PROGRESS_MARKERS {
let path = admin.join(marker);
std::fs::write(&path, "").unwrap();
assert_eq!(
in_progress_op(&RealGit, repo.root(), &linked, false).unwrap(),
Some(op)
);
std::fs::remove_file(&path).unwrap();
}
assert_eq!(
in_progress_op(&RealGit, repo.root(), repo.root(), false).unwrap(),
None
);
}
#[test]
fn in_progress_op_detects_a_real_rebase_stop() {
let repo = TestRepo::init();
repo.git(&["checkout", "-q", "-b", "topic"]);
repo.write("a.txt", "topic\n");
repo.commit_all("topic a");
repo.git(&["checkout", "-q", "main"]);
repo.write("a.txt", "main\n");
repo.commit_all("main a");
repo.git(&["checkout", "-q", "topic"]);
let out = RealGit.run_raw(repo.root(), &["rebase", "main"]).unwrap();
assert!(!out.success);
assert_eq!(
in_progress_op(&RealGit, repo.root(), repo.root(), false).unwrap(),
Some("rebase")
);
assert_eq!(
in_progress_branch(&RealGit, repo.root(), repo.root(), false)
.unwrap()
.map(|held| (held.name, held.activity)),
Some(("topic".to_string(), "being rebased"))
);
}
#[test]
fn in_progress_branch_names_the_branch_a_bisect_started_from() {
let repo = TestRepo::init();
repo.write("a.txt", "1\n");
repo.commit_all("c1");
assert_eq!(
in_progress_branch(&RealGit, repo.root(), repo.root(), false).unwrap(),
None
);
repo.git(&["bisect", "start", "HEAD", "HEAD~1"]);
assert_eq!(
in_progress_branch(&RealGit, repo.root(), repo.root(), false)
.unwrap()
.map(|held| (held.name, held.activity)),
Some(("main".to_string(), "being bisected"))
);
assert!(
in_progress_branch(
&RealGit,
Path::new("/nonexistent/wt"),
Path::new("/nonexistent/wt"),
false
)
.is_err()
);
}
#[test]
fn in_progress_branch_errors_when_a_state_file_cannot_be_read() {
let repo = TestRepo::init();
let admin = RealGit
.run(repo.root(), &["rev-parse", "--absolute-git-dir"])
.unwrap();
let rebase = Path::new(admin.trim()).join("rebase-merge");
std::fs::create_dir(&rebase).unwrap();
assert_eq!(
in_progress_branch(&RealGit, repo.root(), repo.root(), false).unwrap(),
None
);
std::fs::create_dir(rebase.join("head-name")).unwrap();
assert!(in_progress_branch(&RealGit, repo.root(), repo.root(), false).is_err());
std::fs::remove_dir(rebase.join("head-name")).unwrap();
std::fs::write(rebase.join("head-name"), b"refs/heads/\xff\xfe").unwrap();
assert!(in_progress_branch(&RealGit, repo.root(), repo.root(), false).is_err());
}
#[test]
fn a_missing_worktree_state_is_read_through_the_repository() {
let repo = TestRepo::init();
repo.add_worktree("gone", "../wt-gone");
repo.add_worktree("other", "../wt-other");
let linked = repo.root().parent().unwrap().join("wt-gone");
let admin = RealGit
.run(&linked, &["rev-parse", "--absolute-git-dir"])
.unwrap();
let admin = PathBuf::from(admin.trim());
std::fs::remove_dir_all(&linked).unwrap();
assert!(in_progress_op(&RealGit, repo.root(), &linked, false).is_err());
assert_eq!(
in_progress_op(&RealGit, repo.root(), &linked, true).unwrap(),
None
);
std::fs::create_dir(admin.join("rebase-merge")).unwrap();
std::fs::write(admin.join("rebase-merge/head-name"), "refs/heads/gone\n").unwrap();
assert_eq!(
in_progress_op(&RealGit, repo.root(), &linked, true).unwrap(),
Some("rebase")
);
assert_eq!(
in_progress_branch(&RealGit, repo.root(), &linked, true)
.unwrap()
.map(|held| held.name)
.as_deref(),
Some("gone")
);
let stranger = repo.root().parent().unwrap().join("wt-stranger");
assert!(in_progress_op(&RealGit, repo.root(), &stranger, true).is_err());
}
#[test]
fn normalize_resolves_dot_components_lexically() {
assert_eq!(
normalize(Path::new("/r/.git/worktrees/x/../../../wt/./.git")),
PathBuf::from("/r/wt/.git")
);
}
#[test]
fn in_progress_op_errors_outside_a_repository() {
let dir = tempfile::tempdir().unwrap();
assert!(in_progress_op(&RealGit, dir.path(), dir.path(), false).is_err());
}
#[test]
fn single_worktree_repo() {
let repo = TestRepo::init();
let wts = enumerate(&RealGit, repo.root()).unwrap();
assert_eq!(wts.len(), 1);
assert!(wts[0].is_main);
assert_eq!(wts[0].branch.as_deref(), Some("main"));
}
}