use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitInfo {
pub root: PathBuf,
pub branch: String,
pub dirty: bool,
pub untracked: bool,
pub ahead: u32,
pub behind: u32,
pub worktree_index: u32,
}
impl GitInfo {
pub fn label(&self, ascii: bool) -> String {
let mut s = format!("{}{}", if ascii { "git:" } else { "⎇ " }, self.branch);
if self.dirty {
s.push('*');
}
if self.untracked {
s.push('?');
}
if self.ahead > 0 {
s.push_str(&format!(" {}{}", if ascii { "+" } else { "↑" }, self.ahead));
}
if self.behind > 0 {
s.push_str(&format!(" {}{}", if ascii { "-" } else { "↓" }, self.behind));
}
s
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Worktree {
pub repo: PathBuf,
pub path: PathBuf,
pub branch: String,
pub base: String,
#[serde(default)]
pub index: u32,
}
impl Worktree {
pub fn name(&self) -> String {
self.branch.strip_prefix("kraal/").unwrap_or(&self.branch).to_string()
}
}
fn git(dir: &Path, args: &[&str], timeout: Duration) -> Result<String> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args).env("GIT_OPTIONAL_LOCKS", "0").env("GIT_TERMINAL_PROMPT", "0");
let out = crate::exec::run(&mut cmd, timeout, 8 << 20).context("running git")?;
if out.timed_out {
bail!("git {} took too long", args.join(" "));
}
if !out.success() {
bail!("git {}: {}", args.join(" "), out.stderr_text().trim());
}
Ok(out.stdout_text())
}
pub fn status(cwd: &Path) -> Option<GitInfo> {
let base = ["status", "--porcelain=v2", "--branch"];
let (out, listed) = match git(cwd, &[&base[..], &["-unormal"]].concat(), Duration::from_secs(5)) {
Ok(out) => (out, true),
Err(_) => (git(cwd, &[&base[..], &["-uno"]].concat(), Duration::from_secs(2)).ok()?, false),
};
let root = git(cwd, &["rev-parse", "--show-toplevel"], Duration::from_secs(1)).ok()?;
let mut info = parse_status(&out)?;
info.root = PathBuf::from(root.trim());
info.untracked |= !listed;
if info.branch.starts_with("kraal/") {
info.worktree_index = worktree_index(cwd, &info.branch).unwrap_or(0);
}
Some(info)
}
pub fn parse_status(text: &str) -> Option<GitInfo> {
let mut branch = None;
let mut oid = None;
let (mut ahead, mut behind, mut dirty, mut untracked) = (0, 0, false, false);
for line in text.lines() {
if let Some(h) = line.strip_prefix("# branch.head ") {
if h != "(detached)" {
branch = Some(h.to_string());
}
} else if let Some(o) = line.strip_prefix("# branch.oid ") {
oid = Some(o.chars().take(7).collect::<String>());
} else if let Some(ab) = line.strip_prefix("# branch.ab ") {
for part in ab.split_whitespace() {
if let Some(n) = part.strip_prefix('+') {
ahead = n.parse().unwrap_or(0);
} else if let Some(n) = part.strip_prefix('-') {
behind = n.parse().unwrap_or(0);
}
}
} else if line.starts_with("1 ") || line.starts_with("2 ") || line.starts_with("u ") {
dirty = true;
} else if line.starts_with("? ") {
untracked = true;
}
}
let branch = branch.or(oid)?;
Some(GitInfo { root: PathBuf::new(), branch, dirty, untracked, ahead, behind, worktree_index: 0 })
}
pub fn main_checkout(cwd: &Path) -> Option<PathBuf> {
let common = git(cwd, &["rev-parse", "--path-format=absolute", "--git-common-dir"], Duration::from_secs(1)).ok()?;
let common = PathBuf::from(common.trim());
if common.file_name().is_some_and(|n| n == ".git") {
return common.parent().map(Path::to_path_buf);
}
git(cwd, &["rev-parse", "--show-toplevel"], Duration::from_secs(1)).ok().map(|s| PathBuf::from(s.trim()))
}
pub fn project_path(cwd: &Path) -> PathBuf {
if let Some(hit) = PROJECT_PATHS.lock().ok().and_then(|c| c.as_ref().and_then(|m| m.get(cwd).cloned())) {
return hit;
}
let Some(found) = find_project_path(cwd) else { return cwd.to_path_buf() };
if let Ok(mut c) = PROJECT_PATHS.lock() {
c.get_or_insert_with(Default::default).insert(cwd.to_path_buf(), found.clone());
}
found
}
static PROJECT_PATHS: std::sync::Mutex<Option<std::collections::HashMap<PathBuf, PathBuf>>> =
std::sync::Mutex::new(None);
pub fn known_project_path(cwd: &Path) -> Option<PathBuf> {
PROJECT_PATHS.lock().ok().and_then(|c| c.as_ref().and_then(|m| m.get(cwd).cloned()))
}
fn find_project_path(cwd: &Path) -> Option<PathBuf> {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(cwd)
.args(["rev-parse", "--path-format=absolute", "--git-common-dir", "--show-toplevel"])
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C");
let out = match crate::exec::run(&mut cmd, Duration::from_secs(5), 64 << 10) {
Ok(out) => out,
Err(_) => return project_path_from_files(cwd).ok(),
};
if out.timed_out {
return None;
}
let text = out.stdout_text();
let mut lines = text.lines();
let (Some(common), Some(top), true) = (lines.next(), lines.next(), out.success()) else {
return project_path_from_files(cwd).ok();
};
let (common, top) = (PathBuf::from(common), PathBuf::from(top));
let main = match common.file_name() {
Some(n) if n == ".git" => common.parent().map(Path::to_path_buf).unwrap_or_else(|| top.clone()),
_ => top.clone(),
};
Some(project_path_in(cwd, &top, &main))
}
fn project_path_in(cwd: &Path, top: &Path, main: &Path) -> PathBuf {
if top == main {
return cwd.to_path_buf();
}
let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
match canonical.strip_prefix(top) {
Ok(rest) => main.join(rest),
Err(_) => main.to_path_buf(),
}
}
fn project_path_from_files(cwd: &Path) -> std::io::Result<PathBuf> {
let canonical = cwd.canonicalize()?;
let mut dir = canonical.as_path();
loop {
let dot = dir.join(".git");
match std::fs::symlink_metadata(&dot) {
Ok(m) if m.is_dir() => return Ok(cwd.to_path_buf()),
Ok(_) => {
let text = std::fs::read_to_string(&dot)?;
let Some(gitdir) = text.lines().next().and_then(|l| l.strip_prefix("gitdir:")) else {
return Err(std::io::Error::other(format!("{}: not a git link", dot.display())));
};
let gitdir = dir.join(gitdir.trim());
if !std::fs::metadata(&gitdir)?.is_dir() {
return Err(std::io::Error::other(format!("{}: not a directory", gitdir.display())));
}
let common = match std::fs::read_to_string(gitdir.join("commondir")) {
Ok(c) => gitdir.join(c.trim()).canonicalize()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(cwd.to_path_buf()),
Err(e) => return Err(e),
};
let main = match common.file_name() {
Some(n) if n == ".git" => {
common.parent().map(Path::to_path_buf).unwrap_or_else(|| dir.to_path_buf())
}
_ => dir.to_path_buf(),
};
return Ok(project_path_in(cwd, dir, &main));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => match dir.parent() {
Some(up) => dir = up,
None => return Ok(cwd.to_path_buf()),
},
Err(e) => return Err(e),
}
}
}
pub fn valid_worktree_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 60
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
&& !name.starts_with(['.', '-'])
}
pub fn worktrees_dir(repo: &Path) -> PathBuf {
let name = repo.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "repo".into());
repo.with_file_name(format!("{name}.worktrees"))
}
pub fn start_point(cwd: &Path, base: Option<&str>) -> Result<(PathBuf, String)> {
let repo = main_checkout(cwd).context("not inside a git repository")?;
let base = match base {
Some(b) => b.to_string(),
None => match git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"], Duration::from_secs(2))?.trim() {
"HEAD" => git(cwd, &["rev-parse", "HEAD"], Duration::from_secs(2))?.trim().to_string(),
branch => branch.to_string(),
},
};
Ok((repo, base))
}
pub fn add_worktree(cwd: &Path, name: &str, base: Option<&str>) -> Result<Worktree> {
if !valid_worktree_name(name) {
bail!("invalid worktree name {name:?} (letters, digits, - _ .)");
}
let (repo, base) = start_point(cwd, base)?;
let branch = format!("kraal/{name}");
let path = worktrees_dir(&repo).join(name);
if path.exists() {
bail!("{} already exists", path.display());
}
let path_s = path.display().to_string();
git(&repo, &["worktree", "add", "-b", &branch, &path_s, &base], Duration::from_secs(60))?;
let index = record_worktree(&repo, &branch, &base)?;
Ok(Worktree { repo, path, branch, base, index })
}
fn record_worktree(repo: &Path, branch: &str, base: &str) -> Result<u32> {
let common = git(repo, &["rev-parse", "--path-format=absolute", "--git-common-dir"], Duration::from_secs(2))?;
let lock = PathBuf::from(common.trim()).join("kraal-worktrees");
crate::paths::locked(&lock, || {
set_config(repo, &format!("branch.{branch}.kraalbase"), base)?;
let used: std::collections::HashSet<u32> = list_worktrees(repo)?.iter().map(|w| w.index).collect();
let index = (1..).find(|n| !used.contains(n)).unwrap_or(1);
set_config(repo, &format!("branch.{branch}.kraalindex"), &index.to_string())?;
Ok(index)
})?
}
fn set_config(repo: &Path, key: &str, value: &str) -> Result<()> {
let mut tries = 0;
loop {
match git(repo, &["config", key, value], Duration::from_secs(5)) {
Ok(_) => return Ok(()),
Err(e) if tries < 40 && format!("{e:#}").contains(".lock") => {
tries += 1;
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => return Err(e),
}
}
}
pub fn worktree_index(repo: &Path, branch: &str) -> Option<u32> {
let out = git(repo, &["config", "--get", &format!("branch.{branch}.kraalindex")], Duration::from_secs(2)).ok()?;
out.trim().parse().ok().filter(|n| *n > 0)
}
pub fn file_at(repo: &Path, rev: &str, path: &str, cap: usize) -> Result<Option<String>> {
let entry = git(repo, &["ls-tree", "-z", rev, "--", path], Duration::from_secs(5))?;
let Some((meta, _)) = entry.split('\0').next().and_then(|e| e.split_once('\t')) else { return Ok(None) };
let fields: Vec<&str> = meta.split(' ').collect();
let [mode, "blob", id] = fields[..] else { bail!("{path} in {rev} is not a file") };
if mode != "100644" && mode != "100755" {
bail!("{path} in {rev} is not a regular file");
}
let content = git(repo, &["cat-file", "blob", id], Duration::from_secs(5))?;
if content.len() > cap {
bail!("{path} in {rev} is larger than {} KiB", cap >> 10);
}
Ok(Some(content))
}
pub fn worktree_base(repo: &Path, branch: &str) -> Option<String> {
let out = git(repo, &["config", "--get", &format!("branch.{branch}.kraalbase")], Duration::from_secs(2)).ok()?;
let base = out.trim().to_string();
(!base.is_empty()).then_some(base)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct WorktreeState {
pub changes: usize,
pub unmerged: usize,
pub identity: u64,
}
impl WorktreeState {
pub fn is_clean(&self) -> bool {
self.changes == 0 && self.unmerged == 0
}
pub fn describe(&self) -> String {
let n = |count: usize, what: &str| format!("{count} {what}{}", if count == 1 { "" } else { "s" });
match (self.changes, self.unmerged) {
(0, 0) => "nothing to lose".into(),
(c, 0) => n(c, "uncommitted change"),
(0, u) => n(u, "unmerged commit"),
(c, u) => format!("{} and {}", n(c, "uncommitted change"), n(u, "unmerged commit")),
}
}
}
pub fn worktree_state(w: &Worktree) -> Result<WorktreeState> {
use std::hash::{Hash, Hasher};
let mut identity = std::collections::hash_map::DefaultHasher::new();
let status = git(&w.path, &["status", "--porcelain", "-z", "--untracked-files=all"], Duration::from_secs(10))?;
let mut changes = 0;
let mut fields = status.split('\0').filter(|f| !f.is_empty());
while let Some(field) = fields.next() {
changes += 1;
if matches!(field.as_bytes().first(), Some(b'R' | b'C')) {
fields.next();
}
let path = field.get(3..).unwrap_or_default();
path.hash(&mut identity);
hash_content(&w.path.join(path), &mut identity);
}
git(&w.path, &["diff", "--cached", "--raw", "-z", "--no-abbrev", "--no-renames"], Duration::from_secs(10))?
.hash(&mut identity);
let base = if w.base.is_empty() {
worktree_base(&w.repo, &w.branch)
.or_else(|| git(&w.repo, &["rev-parse", "--abbrev-ref", "HEAD"], Duration::from_secs(2)).ok())
.map(|b| b.trim().to_string())
.context("no base branch")?
} else {
w.base.clone()
};
let mut tips = Vec::new();
let branch_ref = format!("refs/heads/{}", w.branch);
if let Ok(tip) = git(&w.repo, &["rev-parse", "--verify", "-q", &branch_ref], Duration::from_secs(5)) {
tips.push(tip.trim().to_string());
}
if git(&w.path, &["symbolic-ref", "-q", "HEAD"], Duration::from_secs(5)).is_err() {
tips.push(git(&w.path, &["rev-parse", "HEAD"], Duration::from_secs(5))?.trim().to_string());
}
tips.hash(&mut identity);
let unmerged = if tips.is_empty() {
0
} else {
let mut args = vec!["rev-list", "--count"];
args.extend(tips.iter().map(String::as_str));
args.extend(["--not", base.as_str()]);
git(&w.repo, &args, Duration::from_secs(10))?.trim().parse().unwrap_or(0)
};
Ok(WorktreeState { changes, unmerged, identity: identity.finish() })
}
fn hash_content(path: &Path, h: &mut impl std::hash::Hasher) {
use std::hash::Hash;
use std::io::Read;
match path.symlink_metadata() {
Ok(m) if m.file_type().is_symlink() => std::fs::read_link(path).ok().hash(h),
Ok(m) if m.is_file() => {
let Ok(mut f) = std::fs::File::open(path) else { return "unreadable".hash(h) };
let mut buf = [0u8; 64 << 10];
loop {
match f.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => h.write(&buf[..n]),
}
}
}
Ok(_) => "other".hash(h),
Err(_) => "missing".hash(h),
}
}
pub fn remove_worktree(w: &Worktree, force: bool) -> Result<()> {
if !force {
let state = worktree_state(w)?;
if !state.is_clean() {
bail!("{} has {}; remove it with force to discard them", w.name(), state.describe());
}
}
let path = w.path.display().to_string();
let mut args = vec!["worktree", "remove", path.as_str()];
if force {
args.push("--force");
}
git(&w.repo, &args, Duration::from_secs(30))?;
git(&w.repo, &["branch", "-D", &w.branch], Duration::from_secs(10))?;
Ok(())
}
pub fn worktree_states(cwd: &Path) -> Result<Vec<(Worktree, Result<WorktreeState, String>)>> {
let list = list_worktrees(cwd)?;
Ok(list
.into_iter()
.map(|w| {
let state = worktree_state(&w).map_err(|e| format!("{e:#}"));
(w, state)
})
.collect())
}
pub fn list_worktrees(cwd: &Path) -> Result<Vec<Worktree>> {
let repo = main_checkout(cwd).context("not inside a git repository")?;
let out = git(&repo, &["worktree", "list", "--porcelain"], Duration::from_secs(5))?;
let mut list = Vec::new();
let mut path: Option<PathBuf> = None;
for line in out.lines() {
if let Some(p) = line.strip_prefix("worktree ") {
path = Some(PathBuf::from(p));
} else if let Some(b) = line.strip_prefix("branch refs/heads/")
&& b.starts_with("kraal/")
&& let Some(p) = path.take()
{
let base = worktree_base(&repo, b).unwrap_or_default();
let index = worktree_index(&repo, b).unwrap_or(0);
list.push(Worktree { repo: repo.clone(), path: p, branch: b.to_string(), base, index });
}
}
Ok(list)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_porcelain_v2() {
let s = "# branch.oid 3f58e39c80027dcb\n# branch.head main\n# branch.upstream origin/main\n# branch.ab +2 -1\n1 .M N... 100644 100644 100644 a b f\n? new.txt\n";
let i = parse_status(s).unwrap();
assert_eq!((i.branch.as_str(), i.dirty, i.untracked, i.ahead, i.behind), ("main", true, true, 2, 1));
assert_eq!(i.label(false), "⎇ main*? ↑2 ↓1");
assert_eq!(i.label(true), "git:main*? +2 -1");
let d = parse_status("# branch.oid 3f58e39c80027dcb\n# branch.head (detached)\n").unwrap();
assert_eq!((d.branch.as_str(), d.dirty), ("3f58e39", false));
assert!(parse_status("").is_none());
}
#[test]
fn a_worktree_is_found_even_when_git_cannot_say() {
if Command::new("git").arg("--version").output().is_err() {
return;
}
let dir = std::env::temp_dir().join(format!("kraal-gitfiles-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dir = dir.canonicalize().unwrap();
let repo = dir.join("repo");
std::fs::create_dir_all(&repo).unwrap();
let git = |args: &[&str]| {
let ok = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(ok.status.success(), "{args:?}: {}", String::from_utf8_lossy(&ok.stderr));
};
git(&["init", "-q", "-b", "main"]);
git(&["commit", "-q", "--allow-empty", "-m", "one"]);
let wt = dir.join("far/feature");
git(&["worktree", "add", "-q", "-b", "feature", wt.to_str().unwrap()]);
std::fs::create_dir_all(wt.join("src")).unwrap();
let repo = repo.canonicalize().unwrap();
assert_eq!(project_path_from_files(&wt.join("src")).unwrap(), repo.join("src"));
assert_eq!(project_path_from_files(&repo).unwrap(), repo);
assert_eq!(project_path_from_files(&dir).unwrap(), dir, "not in a repository");
let config = repo.join(".git/config");
let mut text = std::fs::read_to_string(&config).unwrap();
text.push_str("\n[[broken\n");
std::fs::write(&config, text).unwrap();
assert_eq!(find_project_path(&wt.join("src")), Some(repo.join("src")));
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(wt.join(".git"), std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_to_string(wt.join(".git")).is_err() {
assert_eq!(find_project_path(&wt.join("src")), None);
}
std::fs::set_permissions(wt.join(".git"), std::fs::Permissions::from_mode(0o644)).unwrap();
std::fs::rename(&repo, dir.join("moved")).unwrap();
assert_eq!(find_project_path(&wt.join("src")), None);
assert!(project_path_from_files(&wt.join("src")).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn worktrees_get_numbers_and_merged_ones_are_told_apart() {
if Command::new("git").arg("--version").output().is_err() {
return;
}
let dir = std::env::temp_dir().join(format!("kraal-gitnum-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let repo = dir.join("app");
std::fs::create_dir_all(&repo).unwrap();
let run = |dir: &Path, args: &[&str]| {
let ok = Command::new("git")
.arg("-C")
.arg(dir)
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(ok.status.success(), "{args:?}: {}", String::from_utf8_lossy(&ok.stderr));
};
run(&repo, &["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "1").unwrap();
run(&repo, &["add", "a.txt"]);
run(&repo, &["commit", "-q", "-m", "one"]);
let made: Vec<Worktree> = std::thread::scope(|s| {
let jobs: Vec<_> =
["a", "b", "c"].iter().map(|n| s.spawn(|| add_worktree(&repo, n, None).unwrap())).collect();
jobs.into_iter().map(|j| j.join().unwrap()).collect()
});
let mut numbers: Vec<u32> = made.iter().map(|w| w.index).collect();
numbers.sort();
assert_eq!(numbers, vec![1, 2, 3]);
let by_name = |n: &str| made.iter().find(|w| w.name() == n).unwrap().clone();
let (a, b, c) = (by_name("a"), by_name("b"), by_name("c"));
assert_eq!(status(&b.path).unwrap().worktree_index, b.index, "git tells a pane's directory");
assert_eq!(status(&repo).unwrap().worktree_index, 0, "the main checkout has none");
let listed = list_worktrees(&repo).unwrap();
assert!(listed.iter().all(|w| made.contains(w)), "{listed:?}");
remove_worktree(&a, false).unwrap();
assert_eq!(add_worktree(&repo, "d", None).unwrap().index, a.index);
std::fs::write(b.path.join("b.txt"), "b").unwrap();
run(&b.path, &["add", "b.txt"]);
run(&b.path, &["commit", "-q", "-m", "b"]);
std::fs::write(c.path.join("a.txt"), "changed").unwrap();
let clean = |name: &str| {
let states = worktree_states(&repo).unwrap();
let (_, state) = states.into_iter().find(|(w, _)| w.name() == name).unwrap();
state.unwrap().is_clean()
};
assert!(!clean("b") && !clean("c"));
assert!(clean("d"), "nothing done there: nothing to lose");
run(&repo, &["merge", "-q", "--no-ff", "-m", "merge b", "kraal/b"]);
assert!(clean("b"), "merged into its base");
assert!(!clean("c"));
std::fs::create_dir_all(repo.join(".kraal")).unwrap();
std::fs::write(repo.join(".kraal/worktree.toml"), "setup = []\n").unwrap();
std::os::unix::fs::symlink("/etc/passwd", repo.join(".kraal/link.toml")).unwrap();
run(&repo, &["add", ".kraal"]);
run(&repo, &["commit", "-q", "-m", "kraal"]);
assert_eq!(file_at(&repo, "main", ".kraal/worktree.toml", 1024).unwrap().as_deref(), Some("setup = []\n"));
assert_eq!(file_at(&repo, "main", ".kraal/none.toml", 1024).unwrap(), None);
assert_eq!(file_at(&repo, "main~1", ".kraal/worktree.toml", 1024).unwrap(), None, "not in that commit");
assert!(file_at(&repo, "main", ".kraal/link.toml", 1024).is_err(), "a link is not followed");
assert!(file_at(&repo, "main", ".kraal", 1024).is_err(), "a directory is no file");
assert!(file_at(&repo, "main", ".kraal/worktree.toml", 4).is_err(), "too large");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn worktree_lifecycle() {
if Command::new("git").arg("--version").output().is_err() {
return;
}
let dir = std::env::temp_dir().join(format!("kraal-git-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let dir = dir.canonicalize().unwrap();
let repo = dir.join("app");
std::fs::create_dir_all(&repo).unwrap();
let run = |args: &[&str]| {
let ok = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(ok.status.success(), "{args:?}: {}", String::from_utf8_lossy(&ok.stderr));
};
run(&["init", "-q", "-b", "main"]);
std::fs::write(repo.join("a.txt"), "1").unwrap();
run(&["add", "a.txt"]);
run(&["commit", "-q", "-m", "one"]);
let info = status(&repo).unwrap();
assert_eq!((info.branch.as_str(), info.dirty), ("main", false));
let w = add_worktree(&repo, "fix-login", None).unwrap();
assert_eq!(w.branch, "kraal/fix-login");
assert_eq!(w.base, "main");
assert_eq!(w.path, dir.join("app.worktrees").join("fix-login"));
assert_eq!(main_checkout(&w.path).unwrap(), std::fs::canonicalize(&repo).unwrap());
std::fs::create_dir_all(w.path.join("src")).unwrap();
let canonical_repo = std::fs::canonicalize(&repo).unwrap();
assert_eq!(project_path(&w.path.join("src")), canonical_repo.join("src"), "rules see the repository");
assert_eq!(project_path(&repo), repo, "a main checkout is itself");
assert_eq!(known_project_path(&w.path.join("src")), Some(canonical_repo.join("src")), "remembered");
let decoy = dir.join("other.worktrees/fix");
run(&["worktree", "add", "-q", "-b", "decoy", decoy.to_str().unwrap()]);
assert_eq!(known_project_path(&decoy), None, "only git can tell");
assert_eq!(project_path(&decoy), canonical_repo, "its repository, not `other`");
assert_eq!(known_project_path(&decoy), Some(canonical_repo.clone()), "remembered");
assert_eq!(known_project_path(&dir.join("anywhere")), None, "only git can tell");
assert_eq!(project_path(&dir.join("anywhere")), dir.join("anywhere"), "outside any repository");
let wi = status(&w.path).unwrap();
assert_eq!(wi.branch, "kraal/fix-login");
let f = add_worktree(&w.path, "followup", None).unwrap();
assert_eq!(f.base, "kraal/fix-login");
remove_worktree(&f, true).unwrap();
let in_tree = |args: &[&str]| {
let ok = Command::new("git")
.arg("-C")
.arg(&w.path)
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
.args(args)
.output()
.unwrap();
assert!(ok.status.success(), "{args:?}");
};
std::fs::write(w.path.join("b.txt"), "b").unwrap();
assert!(status(&w.path).unwrap().untracked);
in_tree(&["add", "b.txt"]);
in_tree(&["commit", "-q", "-m", "two"]);
let counts = |w: &Worktree| {
let s = worktree_state(w).unwrap();
(s.changes, s.unmerged)
};
assert_eq!(counts(&w), (0, 1));
std::fs::write(w.path.join("a.txt"), "2").unwrap();
assert!(status(&w.path).unwrap().dirty);
let listed = list_worktrees(&repo).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].base, "main", "the base is remembered");
assert!(add_worktree(&repo, "fix-login", None).is_err(), "names are unique");
assert!(add_worktree(&repo, "../escape", None).is_err());
assert_eq!(counts(&w), (1, 1));
let before = worktree_state(&w).unwrap();
std::thread::sleep(Duration::from_millis(20));
std::fs::write(w.path.join("a.txt"), "22").unwrap();
assert_eq!(counts(&w), (1, 1));
assert_ne!(worktree_state(&w).unwrap(), before, "a new edit changes the identity");
let before = worktree_state(&w).unwrap();
let file = w.path.join("a.txt");
let mtime = file.metadata().unwrap().modified().unwrap();
std::fs::write(&file, "33").unwrap();
std::fs::File::options().write(true).open(&file).unwrap().set_modified(mtime).unwrap();
assert_ne!(worktree_state(&w).unwrap(), before, "content counts, not size and time");
let before = worktree_state(&w).unwrap();
in_tree(&["add", "a.txt"]);
assert_eq!(counts(&w), (1, 1));
assert_ne!(worktree_state(&w).unwrap(), before, "what is staged counts");
in_tree(&["stash", "-q"]);
in_tree(&["checkout", "-q", "--detach"]);
std::fs::write(w.path.join("c.txt"), "c").unwrap();
in_tree(&["add", "c.txt"]);
in_tree(&["commit", "-q", "-m", "detached"]);
assert_eq!(counts(&w), (0, 2), "the branch's commit and the detached one");
in_tree(&["checkout", "-q", "kraal/fix-login"]);
in_tree(&["stash", "pop", "-q"]);
let refused = remove_worktree(&w, false).unwrap_err().to_string();
assert!(refused.contains("1 uncommitted change and 1 unmerged commit"), "{refused}");
assert!(w.path.exists(), "nothing removed without force");
remove_worktree(&w, true).unwrap();
assert!(!w.path.exists());
assert!(list_worktrees(&repo).unwrap().is_empty());
let _ = std::fs::remove_dir_all(dir);
}
}