Skip to main content

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;