ocy_core/git.rs
1//! Records that git keeps for linked worktrees.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6/// Directory under `.git` holding one record per linked worktree.
7const WORKTREES_DIR: &str = "worktrees";
8
9/// File in a record pointing at the `.git` file inside the checkout.
10const GITDIR_POINTER: &str = "gitdir";
11
12/// Marker file that makes `git worktree prune` leave a record alone.
13const LOCK_MARKER: &str = "locked";
14
15/// Records for linked worktrees whose checkout no longer exists.
16///
17/// `git worktree add` writes `.git/worktrees/<name>/`, whose [`GITDIR_POINTER`] file names
18/// the `.git` file inside the checkout. Deleting the checkout leaves the record behind,
19/// and removing those leftovers is exactly what `git worktree prune` does.
20///
21/// Anything that cannot be positively confirmed as stale is left alone, so a record with
22/// an unreadable pointer or an explicit lock is never returned.
23pub fn stale_worktree_records(git_dir: &Path) -> Vec<PathBuf> {
24 let Ok(entries) = fs::read_dir(git_dir.join(WORKTREES_DIR)) else {
25 return Vec::new();
26 };
27
28 entries
29 .filter_map(Result::ok)
30 .map(|entry| entry.path())
31 .filter(|record| is_stale(record))
32 .collect()
33}
34
35/// Checkout directories of linked worktrees that still exist.
36///
37/// Where a worktree lives is a matter of local convention -- `.worktrees/`, `.claude/`,
38/// a sibling directory -- and any of those may be hidden. Guessing the directory name
39/// means missing whichever convention was not guessed, so the records are read instead.
40/// Each record's [`GITDIR_POINTER`] names the `.git` file inside the checkout, whose
41/// parent is the checkout itself.
42pub fn linked_worktree_paths(git_dir: &Path) -> Vec<PathBuf> {
43 let Ok(entries) = fs::read_dir(git_dir.join(WORKTREES_DIR)) else {
44 return Vec::new();
45 };
46
47 entries
48 .filter_map(Result::ok)
49 .filter_map(|entry| checkout_path(&entry.path()))
50 .collect()
51}
52
53fn checkout_path(record: &Path) -> Option<PathBuf> {
54 let pointer = fs::read_to_string(record.join(GITDIR_POINTER)).ok()?;
55 let git_file = Path::new(pointer.trim());
56
57 if git_file.exists() {
58 git_file.parent().map(Path::to_path_buf)
59 } else {
60 None
61 }
62}
63
64fn is_stale(record: &Path) -> bool {
65 if record.join(LOCK_MARKER).exists() {
66 false
67 } else {
68 match fs::read_to_string(record.join(GITDIR_POINTER)) {
69 Ok(pointer) => !Path::new(pointer.trim()).exists(),
70 // An unreadable pointer cannot be verified, so treat the record as live.
71 Err(_) => false,
72 }
73 }
74}
75
76#[cfg(test)]
77mod tests;