Skip to main content

grit_lib/
worktree.rs

1//! Linked worktree registry: discovery, listing, and admin-dir layout.
2//!
3//! Git stores linked worktrees under `<common-git-dir>/worktrees/<id>/` with
4//! `gitdir`, `commondir`, `HEAD`, and optional `locked` / `prunable` files.
5//! This module reads that layout; lifecycle mutations (`add` / `remove`) remain
6//! in the CLI for now and will move here in Phase 1.2.
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::config::ConfigSet;
12use crate::error::{Error, Result};
13use crate::repo::{common_git_dir_for_config, Repository};
14use crate::state::{resolve_head, HeadState};
15
16/// One row returned by [`list_worktrees`].
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct WorktreeEntry {
19    /// Absolute path to the working tree (for bare main worktree, the common git dir).
20    pub path: PathBuf,
21    /// Resolved HEAD for this worktree.
22    pub head: HeadState,
23    /// Whether this worktree is bare (only the main entry can be bare).
24    pub is_bare: bool,
25    /// True when `<admin>/locked` exists.
26    pub is_locked: bool,
27    /// Contents of the `locked` file when non-empty.
28    pub lock_reason: Option<String>,
29    /// Administrative directory: common git dir for main, else `worktrees/<id>/`.
30    pub admin_dir: PathBuf,
31}
32
33/// Shared git directory for `git_dir` (follows `commondir` for linked worktrees).
34#[must_use]
35pub fn common_git_dir(git_dir: &Path) -> PathBuf {
36    common_git_dir_for_config(git_dir)
37}
38
39/// Resolve HEAD for a linked worktree admin dir (`HEAD` local, branch refs in `common`).
40#[must_use]
41pub fn resolve_linked_head(admin: &Path, _common: &Path) -> HeadState {
42    resolve_head(admin).unwrap_or(HeadState::Invalid)
43}
44
45/// Number of registered worktrees (main + linked entries under `worktrees/`).
46#[must_use]
47pub fn registered_worktree_count(common: &Path) -> usize {
48    let worktrees_dir = common.join("worktrees");
49    if !worktrees_dir.is_dir() {
50        return 1;
51    }
52    let linked = fs::read_dir(&worktrees_dir)
53        .into_iter()
54        .flatten()
55        .flatten()
56        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
57        .count();
58    1 + linked
59}
60
61/// Whether `common` is configured as a bare repository (`core.bare=true`).
62#[must_use]
63pub fn is_bare_repository(common: &Path) -> bool {
64    ConfigSet::load(Some(common), true)
65        .ok()
66        .and_then(|cfg| cfg.get_bool("core.bare"))
67        .and_then(|r| r.ok())
68        .unwrap_or_else(|| {
69            // Heuristic when config is missing: bare repos usually are not named `.git`.
70            !common.ends_with(".git") && common.join("config").is_file()
71        })
72}
73
74/// Enumerate the main and linked worktrees for `repo`.
75///
76/// Order matches Git: main worktree first, then linked worktrees sorted by admin id.
77pub fn list_worktrees(repo: &Repository) -> Result<Vec<WorktreeEntry>> {
78    let common = common_git_dir(&repo.git_dir);
79    let mut entries = Vec::new();
80
81    let bare = is_bare_repository(&common);
82    let main_path = if bare {
83        common.clone()
84    } else if let Some(wt) = repo.work_tree.as_ref() {
85        // When opened from the main worktree, use the discovered work tree path.
86        if repo.git_dir == common || !repo.git_dir.starts_with(common.join("worktrees")) {
87            wt.clone()
88        } else {
89            common.parent().unwrap_or(&common).to_path_buf()
90        }
91    } else {
92        common.parent().unwrap_or(&common).to_path_buf()
93    };
94
95    let main_head = resolve_head(&common).unwrap_or(HeadState::Invalid);
96    entries.push(WorktreeEntry {
97        path: main_path,
98        head: main_head,
99        is_bare: bare,
100        is_locked: false,
101        lock_reason: None,
102        admin_dir: common.clone(),
103    });
104
105    let worktrees_dir = common.join("worktrees");
106    if !worktrees_dir.is_dir() {
107        return Ok(entries);
108    }
109
110    let mut names: Vec<String> = fs::read_dir(&worktrees_dir)
111        .map_err(Error::Io)?
112        .filter_map(|e| e.ok())
113        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
114        .map(|e| e.file_name().to_string_lossy().into_owned())
115        .collect();
116    names.sort();
117
118    for name in names {
119        let admin = worktrees_dir.join(&name);
120        let wt_head = resolve_linked_head(&admin, &common);
121        let wt_path = read_worktree_path(&admin)?;
122        let (is_locked, lock_reason) = read_lock_state(&admin)?;
123        entries.push(WorktreeEntry {
124            path: wt_path,
125            head: wt_head,
126            is_bare: false,
127            is_locked,
128            lock_reason,
129            admin_dir: admin,
130        });
131    }
132
133    Ok(entries)
134}
135
136/// Last path component of `path`, without trailing directory separators (Git `worktree_basename`).
137#[must_use]
138pub fn worktree_path_basename(path: &Path) -> String {
139    let s = path.to_string_lossy();
140    let trimmed = s.trim_end_matches(['/', '\\']);
141    trimmed
142        .rsplit(['/', '\\'])
143        .next()
144        .unwrap_or(trimmed)
145        .to_owned()
146}
147
148/// Sanitize a path basename for use as `worktrees/<id>/` (Git `sanitize_refname_component`).
149#[must_use]
150pub fn sanitize_worktree_id_component(name: &str) -> String {
151    if name == "@" {
152        return "-".to_owned();
153    }
154
155    let mut out = String::new();
156    let mut last = '\0';
157    let chars: Vec<char> = name.chars().collect();
158    let mut i = 0;
159    while i < chars.len() {
160        let ch = chars[i];
161        if ch.is_ascii_control()
162            || matches!(ch, ':' | '?' | '[' | '\\' | '^' | '~' | ' ' | '\t' | '*')
163        {
164            if out.is_empty() && last != '-' {
165                out.push('-');
166            } else if !out.is_empty() {
167                out.push('-');
168            }
169            last = '-';
170            i += 1;
171            continue;
172        }
173        if ch == '.' && i + 1 < chars.len() && chars[i + 1] == '.' {
174            if last == '.' {
175                out.pop();
176            } else {
177                out.push('.');
178                last = '.';
179            }
180            i += 2;
181            continue;
182        }
183        if ch == '@' && i + 1 < chars.len() && chars[i + 1] == '{' {
184            if let Some(last_ch) = out.pop() {
185                if last_ch != '-' {
186                    out.push('-');
187                }
188            }
189            last = '-';
190            i += 2;
191            continue;
192        }
193        if ch == '.' && out.is_empty() {
194            out.push('-');
195            last = '-';
196            i += 1;
197            continue;
198        }
199        out.push(ch);
200        last = ch;
201        i += 1;
202    }
203
204    const LOCK_SUFFIX: &str = ".lock";
205    while out.ends_with(LOCK_SUFFIX) {
206        out.truncate(out.len() - LOCK_SUFFIX.len());
207    }
208    while out.ends_with('.') {
209        out.pop();
210    }
211    out
212}
213
214/// Pick a unique `<common>/worktrees/<id>/` directory for a new linked worktree at `wt_path`.
215///
216/// Git uses the sanitized basename and appends `1`, `2`, … when the admin dir already exists.
217#[must_use]
218pub fn allocate_worktree_admin_dir(common: &Path, wt_path: &Path) -> PathBuf {
219    let worktrees_dir = common.join("worktrees");
220    let base = sanitize_worktree_id_component(&worktree_path_basename(wt_path));
221    let base = if base.is_empty() {
222        "worktree".to_owned()
223    } else {
224        base
225    };
226
227    let mut counter = 0u32;
228    loop {
229        let id = if counter == 0 {
230            base.clone()
231        } else {
232            format!("{base}{counter}")
233        };
234        let admin = worktrees_dir.join(&id);
235        if !admin.exists() {
236            return admin;
237        }
238        counter = counter.saturating_add(1);
239        if counter == 0 {
240            break;
241        }
242    }
243    worktrees_dir.join(format!("{base}{}", std::process::id()))
244}
245
246/// Copy `config.worktree` into a linked worktree admin dir, stripping keys Git omits
247/// when `extensions.worktreeConfig` is enabled (`core.bare`, `core.worktree`).
248pub fn copy_filtered_worktree_config(source_git_dir: &Path, admin_dir: &Path) -> Result<()> {
249    let src = source_git_dir.join("config.worktree");
250    if !src.is_file() {
251        return Ok(());
252    }
253    let dst = admin_dir.join("config.worktree");
254    fs::copy(&src, &dst).map_err(Error::Io)?;
255    strip_worktree_config_keys(&dst, &["core.bare", "core.worktree"])?;
256    Ok(())
257}
258
259fn strip_worktree_config_keys(path: &Path, keys: &[&str]) -> Result<()> {
260    let content = fs::read_to_string(path).map_err(Error::Io)?;
261    let mut kept = Vec::new();
262    let mut section: Option<String> = None;
263    for line in content.lines() {
264        let trimmed = line.trim();
265        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
266            kept.push(line);
267            continue;
268        }
269        if trimmed.starts_with('[') {
270            let end = trimmed.find(']').unwrap_or(trimmed.len());
271            let name = trimmed[1..end].trim().to_ascii_lowercase();
272            section = Some(name);
273            kept.push(line);
274            continue;
275        }
276        if let Some((key, _)) = trimmed.split_once('=') {
277            let key = key.trim().to_ascii_lowercase();
278            let full = match section.as_deref() {
279                Some(sec) => format!("{sec}.{key}"),
280                None => key.clone(),
281            };
282            if keys.iter().any(|k| full.eq_ignore_ascii_case(k)) {
283                continue;
284            }
285        } else if keys.iter().any(|k| trimmed.eq_ignore_ascii_case(k)) {
286            continue;
287        }
288        kept.push(line);
289    }
290    let mut out = kept.join("\n");
291    if !out.is_empty() {
292        out.push('\n');
293    }
294    fs::write(path, out).map_err(Error::Io)
295}
296
297/// Read the working tree path from `<admin>/gitdir` (parent of the worktree `.git` file).
298pub fn read_worktree_path(admin: &Path) -> Result<PathBuf> {
299    let gitdir_path = admin.join("gitdir");
300    if !gitdir_path.is_file() {
301        return Ok(admin.to_path_buf());
302    }
303    let raw = fs::read_to_string(&gitdir_path).map_err(Error::Io)?;
304    let mut p = PathBuf::from(raw.trim());
305    if p.is_relative() {
306        p = admin.join(p);
307    }
308    let parent = p.parent().unwrap_or(&p).to_path_buf();
309    Ok(parent.canonicalize().unwrap_or(parent))
310}
311
312fn read_lock_state(admin: &Path) -> Result<(bool, Option<String>)> {
313    let locked_file = admin.join("locked");
314    if !locked_file.is_file() {
315        return Ok((false, None));
316    }
317    let content = fs::read_to_string(&locked_file).map_err(Error::Io)?;
318    let reason = content.trim();
319    if reason.is_empty() {
320        Ok((true, None))
321    } else {
322        Ok((true, Some(reason.to_owned())))
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::repo::Repository;
330    use std::fs;
331    use tempfile::TempDir;
332
333    #[test]
334    fn list_main_worktree_only() {
335        let tmp = TempDir::new().unwrap();
336        let root = tmp.path().join("repo");
337        fs::create_dir_all(root.join(".git")).unwrap();
338        fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
339        fs::create_dir_all(root.join(".git/objects")).unwrap();
340        fs::write(
341            root.join(".git/config"),
342            "[core]\n\trepositoryformatversion = 0\n",
343        )
344        .unwrap();
345
346        let repo = Repository::open(&root.join(".git"), Some(&root)).unwrap();
347        let list = list_worktrees(&repo).unwrap();
348        assert_eq!(list.len(), 1);
349        assert_eq!(list[0].path, root.canonicalize().unwrap());
350        assert!(!list[0].is_bare);
351    }
352
353    #[test]
354    fn allocate_unique_worktree_id() {
355        let tmp = TempDir::new().unwrap();
356        let common = tmp.path().join("git");
357        fs::create_dir_all(common.join("worktrees/here")).unwrap();
358        let admin = allocate_worktree_admin_dir(&common, Path::new("/tmp/sub/here"));
359        assert_eq!(admin, common.join("worktrees/here1"));
360    }
361
362    #[test]
363    fn strip_worktree_config_removes_core_bare_and_worktree() {
364        let tmp = TempDir::new().unwrap();
365        let path = tmp.path().join("config.worktree");
366        fs::write(
367            &path,
368            "[core]\n\tbare = true\n\tworktree = /wt\n[bogus]\n\tkey = value\n",
369        )
370        .unwrap();
371        strip_worktree_config_keys(&path, &["core.bare", "core.worktree"]).unwrap();
372        let out = fs::read_to_string(&path).unwrap();
373        assert!(out.contains("bogus"));
374        assert!(!out.contains("bare"));
375        assert!(!out.contains("worktree"));
376    }
377
378    #[test]
379    fn sanitize_funny_worktree_name() {
380        assert_eq!(
381            sanitize_worktree_id_component(".  weird*..?.lock.lock"),
382            "---weird-.-"
383        );
384    }
385
386    #[test]
387    fn read_worktree_path_from_gitdir_file() {
388        let tmp = TempDir::new().unwrap();
389        let admin = tmp.path().join("wt-admin");
390        fs::create_dir_all(&admin).unwrap();
391        let wt = tmp.path().join("linked");
392        fs::create_dir_all(wt.join(".git")).unwrap();
393        fs::write(
394            admin.join("gitdir"),
395            format!("{}\n", wt.join(".git").display()),
396        )
397        .unwrap();
398        let path = read_worktree_path(&admin).unwrap();
399        assert_eq!(path, wt.canonicalize().unwrap());
400    }
401}