Skip to main content

majordomus_cli/worktree/
identity.rs

1//! Who this repository is, resolved once, from anywhere inside it.
2//!
3//! The whole subsystem turns on one question: run from `~/dev/foo-wt/feature/x/apps/src`,
4//! which repository is that? `git rev-parse --show-toplevel` answers "the linked worktree",
5//! which is true and useless — deriving a container from it would produce
6//! `~/dev/foo-wt/feature/x-wt`, a new container per worktree, recursively. The primary
7//! checkout is what the container is named after, and git names it: every work tree of one
8//! repository shares a *common* git directory, and `git worktree list --porcelain` lists the
9//! main work tree first. So the identity is read from the common directory and the porcelain
10//! list, never from the current directory, and the answer is the same from every one of them.
11//!
12//! Paths are kept twice on purpose. `path` is what git reported, which is what a person
13//! recognises and what git accepts back. `real` is that path canonicalised, which is what
14//! containment and equality are decided on, so that a symlink cannot make two names for one
15//! directory look like two directories — or one directory outside the container look like
16//! one inside it.
17
18use std::path::{Path, PathBuf};
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23use super::error::{Result, WorktreeError};
24use super::git;
25use super::topology::{self, WorktreeRecord};
26
27/// A path as git names it and as the filesystem resolves it.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ResolvedPath {
30    /// As reported: what a person reads and what git accepts back.
31    pub path: PathBuf,
32    /// Canonicalised, when the path exists: what comparisons use.
33    pub real: PathBuf,
34}
35
36impl ResolvedPath {
37    /// Resolve `path`. A path that does not exist (a prunable worktree, a destination not
38    /// yet created) canonicalises to its lexically normalised self — with as much of its
39    /// existing prefix resolved as exists — so a comparison still has something total to
40    /// work with.
41    pub fn of(path: impl Into<PathBuf>) -> Self {
42        let path = path.into();
43        let real = std::fs::canonicalize(&path).unwrap_or_else(|_| partial_canonical(&path));
44        ResolvedPath { path, real }
45    }
46
47    /// Is this path the same directory as `other`, whatever it is called?
48    pub fn same_as(&self, other: &ResolvedPath) -> bool {
49        self.real == other.real
50    }
51
52    /// Is this path strictly below `root` — a descendant, not `root` itself?
53    ///
54    /// Decided on the canonical forms, so `foo-wt/link -> /tmp/elsewhere` is not under
55    /// `foo-wt` however its name reads.
56    pub fn is_inside(&self, root: &ResolvedPath) -> bool {
57        self.real != root.real && self.real.starts_with(&root.real)
58    }
59}
60
61/// Canonicalise the longest existing prefix of a path and append the rest lexically
62/// normalised. Never fails and never invents: a symlinked parent of a path that does not
63/// exist yet is still resolved, which is what makes "the expected path equals this
64/// registered path" decidable before the expected path is created.
65fn partial_canonical(p: &Path) -> PathBuf {
66    let normalised = lexical(p);
67    let mut existing = normalised.clone();
68    let mut rest: Vec<std::ffi::OsString> = Vec::new();
69    loop {
70        if let Ok(real) = std::fs::canonicalize(&existing) {
71            let mut out = real;
72            for c in rest.iter().rev() {
73                out.push(c);
74            }
75            return out;
76        }
77        match (existing.file_name(), existing.parent()) {
78            (Some(name), Some(parent)) if !parent.as_os_str().is_empty() => {
79                rest.push(name.to_os_string());
80                existing = parent.to_path_buf();
81            }
82            _ => return normalised,
83        }
84    }
85}
86
87/// Lexical normalisation for a path that does not exist: drop `.`, resolve `..` against the
88/// component before it, and leave everything else alone. Never touches the filesystem.
89fn lexical(p: &Path) -> PathBuf {
90    let mut out = PathBuf::new();
91    for c in p.components() {
92        match c {
93            std::path::Component::CurDir => {}
94            std::path::Component::ParentDir => {
95                let at_root = out.parent().is_none() && out.has_root();
96                if !at_root && !out.pop() {
97                    out.push("..");
98                }
99            }
100            other => out.push(other.as_os_str()),
101        }
102    }
103    out
104}
105
106/// Where the trunk was learned from, in the order it is looked for.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
108#[serde(rename_all = "snake_case")]
109pub enum TrunkSource {
110    /// `refs/remotes/<remote>/HEAD`: what the remote calls its default branch.
111    RemoteHead,
112    /// `init.defaultBranch` in the git configuration, and that branch exists locally.
113    DefaultBranchConfig,
114    /// Exactly one of `main` and `master` exists locally.
115    ConventionalName,
116    /// The branch the primary checkout holds, because nothing else said.
117    PrimaryCheckout,
118    /// Nothing said; the trunk is unknown and every check that needs it says so.
119    Unknown,
120}
121
122/// The trunk branch and how it was decided. Discovered state, never domain logic: `master`
123/// here is a fact about this repository and `main` would be one about another.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Trunk {
126    /// The branch, short, when one was found.
127    pub branch: Option<String>,
128    /// How it was found.
129    pub source: TrunkSource,
130}
131
132impl Trunk {
133    /// Is `branch` the trunk?
134    pub fn is(&self, branch: &str) -> bool {
135        self.branch.as_deref() == Some(branch)
136    }
137}
138
139/// The repository a command is operating on, and every work tree git has registered for it.
140///
141/// Built once per command and passed around. A handful of subprocesses go into it and no
142/// more: the identity questions, one `worktree list`, and the trunk lookups.
143#[derive(Debug, Clone)]
144pub struct RepositoryIdentity {
145    git_common_dir: ResolvedPath,
146    primary: ResolvedPath,
147    current: ResolvedPath,
148    records: Vec<WorktreeRecord>,
149    trunk: Trunk,
150}
151
152impl RepositoryIdentity {
153    /// Resolve the repository from any directory inside any of its work trees.
154    pub fn discover(start: &Path) -> Result<Self> {
155        let probe = git::try_run(
156            start,
157            &["rev-parse", "--is-bare-repository", "--git-common-dir"],
158        )?;
159        if probe.status != Some(0) {
160            return Err(WorktreeError::NotInGitRepository {
161                start: start.to_path_buf(),
162            });
163        }
164        let text = probe.text()?;
165        let mut lines = text.lines();
166        let bare = lines.next().unwrap_or("false").trim() == "true";
167        let common_raw = lines.next().unwrap_or("").trim().to_string();
168        if common_raw.is_empty() {
169            return Err(WorktreeError::NotInGitRepository {
170                start: start.to_path_buf(),
171            });
172        }
173        // `--git-common-dir` is relative to the current directory when the current directory
174        // is inside the work tree, and absolute otherwise. Both forms are answers.
175        let common = {
176            let p = PathBuf::from(&common_raw);
177            if p.is_absolute() {
178                p
179            } else {
180                start.join(p)
181            }
182        };
183        let git_common_dir = ResolvedPath::of(common);
184        if bare {
185            return Err(WorktreeError::BareRepositoryUnsupported {
186                git_dir: git_common_dir.path.clone(),
187            });
188        }
189
190        // The current work tree: `--show-toplevel` is exactly this question, and here it is
191        // the right one — this is the only place the current directory decides anything.
192        let top = git::run(start, &["rev-parse", "--show-toplevel"])?.text()?;
193        let current = ResolvedPath::of(PathBuf::from(top));
194
195        let records = topology::read(start)?;
196        let primary = ResolvedPath::of(topology::primary(&records, &git_common_dir.path)?);
197        let trunk = detect_trunk(&primary.path, &records)?;
198        Ok(RepositoryIdentity {
199            git_common_dir,
200            primary,
201            current,
202            records,
203            trunk,
204        })
205    }
206
207    /// Re-read the registered work trees. Under the lock, before a mutation, because the
208    /// topology may have changed since discovery.
209    pub fn refresh(&mut self) -> Result<()> {
210        self.records = topology::read(&self.primary.path)?;
211        Ok(())
212    }
213
214    /// The common git directory every work tree of this repository shares. It is the
215    /// repository's identity: two paths belong to one repository exactly when this matches.
216    pub fn git_common_dir(&self) -> &ResolvedPath {
217        &self.git_common_dir
218    }
219
220    /// The primary checkout — the main work tree, never a linked one, whatever directory the
221    /// command was run from.
222    pub fn primary_worktree(&self) -> &ResolvedPath {
223        &self.primary
224    }
225
226    /// The work tree the command was run inside.
227    pub fn current_worktree(&self) -> &ResolvedPath {
228        &self.current
229    }
230
231    /// The trunk, as discovered.
232    pub fn trunk(&self) -> &Trunk {
233        &self.trunk
234    }
235
236    /// The primary checkout's directory name: what the container is named after.
237    pub fn repository_name(&self) -> String {
238        self.primary
239            .path
240            .file_name()
241            .map(|n| n.to_string_lossy().to_string())
242            .unwrap_or_default()
243    }
244
245    /// Every work tree git has registered, primary first, exactly as the porcelain listed
246    /// them.
247    pub fn registered_worktrees(&self) -> &[WorktreeRecord] {
248        &self.records
249    }
250
251    /// Is this record the primary checkout?
252    pub fn is_primary(&self, record: &WorktreeRecord) -> bool {
253        ResolvedPath::of(&record.path).same_as(&self.primary)
254    }
255
256    /// Is the command running inside this record's work tree?
257    pub fn is_current(&self, record: &WorktreeRecord) -> bool {
258        ResolvedPath::of(&record.path).same_as(&self.current)
259    }
260
261    /// The record of the work tree the command runs in, when git lists it.
262    pub fn current_record(&self) -> Option<&WorktreeRecord> {
263        self.records.iter().find(|r| self.is_current(r))
264    }
265
266    /// The record holding a branch, when one does.
267    pub fn record_of_branch(&self, branch: &str) -> Option<&WorktreeRecord> {
268        self.records
269            .iter()
270            .find(|r| r.branch.as_deref() == Some(branch))
271    }
272
273    /// The record registered at a path, whatever the path is called.
274    pub fn record_at(&self, path: &ResolvedPath) -> Option<&WorktreeRecord> {
275        self.records
276            .iter()
277            .find(|r| ResolvedPath::of(&r.path).same_as(path))
278    }
279}
280
281/// The trunk of a repository, found in the order [`TrunkSource`] lists. Never hardcoded:
282/// the remote's HEAD is asked first, then the configuration, then the two conventional
283/// names, then the primary checkout's own branch.
284pub fn detect_trunk(primary: &Path, records: &[WorktreeRecord]) -> Result<Trunk> {
285    // 1. what the remote calls its default branch
286    let remotes = git::try_run(primary, &["remote"])?;
287    if remotes.status == Some(0) {
288        let text = remotes.text()?;
289        for remote in text.lines().map(str::trim).filter(|r| !r.is_empty()) {
290            let head = git::try_run(
291                primary,
292                &["symbolic-ref", "-q", &format!("refs/remotes/{remote}/HEAD")],
293            )?;
294            if head.status == Some(0) {
295                let full = head.text()?;
296                if let Some(short) = full.strip_prefix(&format!("refs/remotes/{remote}/")) {
297                    if !short.is_empty() {
298                        return Ok(Trunk {
299                            branch: Some(short.to_string()),
300                            source: TrunkSource::RemoteHead,
301                        });
302                    }
303                }
304            }
305        }
306    }
307    // 2. the configured default branch, when it exists here
308    let configured = git::try_run(primary, &["config", "--get", "init.defaultBranch"])?;
309    if configured.status == Some(0) {
310        let name = configured.text()?;
311        if !name.is_empty() && git::branch_exists(primary, &name)? {
312            return Ok(Trunk {
313                branch: Some(name),
314                source: TrunkSource::DefaultBranchConfig,
315            });
316        }
317    }
318    // 3. exactly one of the conventional names
319    let conventional: Vec<&str> = ["main", "master"]
320        .into_iter()
321        .filter(|b| git::branch_exists(primary, b).unwrap_or(false))
322        .collect();
323    if conventional.len() == 1 {
324        return Ok(Trunk {
325            branch: Some(conventional[0].to_string()),
326            source: TrunkSource::ConventionalName,
327        });
328    }
329    // 4. the primary checkout's own branch
330    if let Some(branch) = records.first().and_then(|r| r.branch.clone()) {
331        return Ok(Trunk {
332            branch: Some(branch),
333            source: TrunkSource::PrimaryCheckout,
334        });
335    }
336    Ok(Trunk {
337        branch: None,
338        source: TrunkSource::Unknown,
339    })
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn lexical_normalisation_resolves_dot_and_dotdot_without_the_filesystem() {
348        assert_eq!(lexical(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
349        assert_eq!(lexical(Path::new("/a/../..")), PathBuf::from("/"));
350        assert_eq!(lexical(Path::new("../x")), PathBuf::from("../x"));
351    }
352
353    #[test]
354    fn containment_is_decided_on_the_resolved_form_and_excludes_the_root_itself() {
355        let root = ResolvedPath {
356            path: "/a/foo-wt".into(),
357            real: "/a/foo-wt".into(),
358        };
359        let inside = ResolvedPath {
360            path: "/a/foo-wt/feature/x".into(),
361            real: "/a/foo-wt/feature/x".into(),
362        };
363        let escaped = ResolvedPath {
364            path: "/a/foo-wt/link".into(),
365            real: "/tmp/elsewhere".into(),
366        };
367        assert!(inside.is_inside(&root));
368        assert!(!root.is_inside(&root));
369        assert!(!escaped.is_inside(&root));
370    }
371
372    #[test]
373    fn a_sibling_prefix_is_not_containment() {
374        let root = ResolvedPath {
375            path: "/a/foo-wt".into(),
376            real: "/a/foo-wt".into(),
377        };
378        let sibling = ResolvedPath {
379            path: "/a/foo-wt2".into(),
380            real: "/a/foo-wt2".into(),
381        };
382        assert!(!sibling.is_inside(&root));
383    }
384
385    #[test]
386    fn a_path_that_does_not_exist_yet_resolves_through_its_existing_parent() {
387        let dir = tempfile::tempdir().unwrap();
388        let real_dir = dir.path().canonicalize().unwrap();
389        let target = dir.path().join("not/yet/here");
390        let r = ResolvedPath::of(&target);
391        assert_eq!(r.real, real_dir.join("not/yet/here"));
392        // and through a symlinked parent, which is the case that matters on macOS where
393        // /tmp is a link to /private/tmp
394        #[cfg(unix)]
395        {
396            std::os::unix::fs::symlink(&real_dir, dir.path().join("link")).unwrap();
397            let via = ResolvedPath::of(dir.path().join("link/deeper/x"));
398            assert_eq!(via.real, real_dir.join("deeper/x"));
399        }
400    }
401}