Skip to main content

mati_core/store/db/
slug.rs

1//! Slug derivation from a repo root.
2
3use super::*;
4
5/// Git's own answer to "what repo is this", discovered once per invocation.
6///
7/// `commondir` is git's *shared* git-dir: for a linked worktree this is the
8/// main checkout's `.git/`, so every worktree of one clone reads the same
9/// `remote` and hashes to the same slug — worktrees share history, so they
10/// share a store. For a submodule it is the submodule's own private dir
11/// under `.git/modules/`, carrying the submodule's own remote and therefore
12/// its own, different slug — a submodule is a different repo with a
13/// disjoint path namespace, so its `file:*` keys must never land in the
14/// parent's store.
15///
16/// `workdir` is `None` for a bare repository (no working tree to resolve
17/// record paths against) or when no repo is discoverable at all.
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct RepoIdent {
20    pub workdir: Option<PathBuf>,
21    pub commondir: Option<PathBuf>,
22    pub remote: Option<String>,
23}
24
25impl RepoIdent {
26    /// Discover the identity of the repo containing (or rooted at) `path`.
27    /// One `git2::Repository::discover` call.
28    ///
29    /// A caller that needs more than one derived value ([`Self::slug`],
30    /// [`Self::slug_root`], a worktree tag, ...) for the same invocation
31    /// should discover once and reuse the result rather than calling this
32    /// again — see `cli::hook_decide::entry::run_inner`.
33    pub fn discover(path: &Path) -> Self {
34        git2::Repository::discover(path)
35            .map(|repo| Self::from_repo(&repo))
36            .unwrap_or_default()
37    }
38
39    /// [`Self::discover`], plus the live `git2::Repository` handle for a
40    /// caller that also needs actual git operations (blob/commit lookups),
41    /// not just identity facts — e.g. [`crate::health::staleness::StalenessAnalyzer`].
42    /// Still exactly one `discover` call.
43    pub fn discover_with_repo(path: &Path) -> (Self, Option<git2::Repository>) {
44        match git2::Repository::discover(path) {
45            Ok(repo) => {
46                let ident = Self::from_repo(&repo);
47                (ident, Some(repo))
48            }
49            Err(_) => (Self::default(), None),
50        }
51    }
52
53    fn from_repo(repo: &git2::Repository) -> Self {
54        // libgit2 sometimes returns workdir()/commondir() with a trailing
55        // separator. Trim it explicitly — do not rely on `Path` methods to
56        // swallow it, they don't, and a stray separator hashes differently
57        // from the same path without one.
58        let workdir = repo.workdir().map(trim_trailing_sep);
59        let commondir = trim_trailing_sep(repo.commondir());
60        // `commondir` is already the git-dir itself — for a plain repo
61        // (`.git/`) and for a submodule's private dir
62        // (`.git/modules/<name>/`) alike — so `config` sits directly under
63        // it in both cases.
64        let remote = read_remote_url(&commondir.join("config"));
65        Self {
66            workdir,
67            commondir: Some(commondir),
68            remote,
69        }
70    }
71
72    /// The slug this identity hashes to: the first 8 hex characters of
73    /// SHA-256(remote URL); falling back to SHA-256(workdir) when the repo
74    /// has no remote; falling back further to SHA-256(canonicalized
75    /// `fallback_path`) when git found no working tree to key on at all —
76    /// no repo, or a bare one.
77    ///
78    /// The no-remote fallback hashes `workdir` itself, never a git-internal
79    /// path like `commondir`: a `.git` that is itself a symlink must not
80    /// leak its target's location into the project's identity, and hashing
81    /// the same field [`Self::slug_root`] returns makes the two agree by
82    /// construction rather than by two independent computations that merely
83    /// happen to match.
84    pub fn slug(&self, fallback_path: &Path) -> String {
85        let input = self
86            .remote
87            .clone()
88            .or_else(|| self.workdir.as_ref().map(|p| path_to_string(p)))
89            .unwrap_or_else(|| path_to_string(&canonicalize_or_self(fallback_path)));
90        let digest = Sha256::digest(input.as_bytes());
91        hex::encode(&digest[..4])
92    }
93
94    /// The root record paths resolve against: [`Self::workdir`], or the
95    /// canonicalized `fallback_path` when there is none (no repo, or a bare
96    /// one — a bare repo has no working tree to offer).
97    ///
98    /// A bare repo's `fallback_path` is its own location, never an
99    /// ancestor's: `discover` recognizes a bare repository in its own right
100    /// and does not walk past it looking for an enclosing one, so a bare
101    /// clone nested under an unrelated working repo can never inherit that
102    /// repo's root (or its store).
103    pub fn slug_root(&self, fallback_path: &Path) -> PathBuf {
104        self.workdir
105            .clone()
106            .unwrap_or_else(|| canonicalize_or_self(fallback_path))
107    }
108}
109
110fn trim_trailing_sep(p: &Path) -> PathBuf {
111    match p.to_str() {
112        Some(s) => PathBuf::from(s.trim_end_matches('/')),
113        None => p.to_path_buf(),
114    }
115}
116
117fn path_to_string(p: &Path) -> String {
118    p.to_string_lossy().into_owned()
119}
120
121fn canonicalize_or_self(p: &Path) -> PathBuf {
122    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
123}
124
125/// Derive a project slug from the repo root.
126///
127/// Discovers the repo via `git2` and hashes [`RepoIdent::slug`] — the first
128/// `url =` line of the config the repo's *common* git-dir names (shared
129/// across linked worktrees, private per submodule), falling back to the
130/// working tree path, and finally to the canonicalized input when git finds
131/// no working tree at all.
132///
133/// Returns the first 8 hex characters of the SHA-256 digest.
134pub fn derive_slug(repo_root: &Path) -> String {
135    RepoIdent::discover(repo_root).slug(repo_root)
136}
137
138/// The repo root [`derive_slug`] keys on, for callers that must resolve
139/// repo-relative record paths against the same root the store was named for.
140///
141/// Always `git2`'s own `workdir()` for a repo with a working tree — the same
142/// value [`derive_slug`] falls back to when the repo has no remote — so the
143/// two can never name different repos. A submodule and its parent, or a
144/// bare repo and any working clone, are never conflated: see
145/// [`RepoIdent::slug`] and [`RepoIdent::slug_root`].
146pub fn slug_root(repo_root: &Path) -> PathBuf {
147    RepoIdent::discover(repo_root).slug_root(repo_root)
148}
149
150/// Attempt to extract the first `url =` line from a git config file.
151fn read_remote_url(config_path: &Path) -> Option<String> {
152    let config = std::fs::read_to_string(config_path).ok()?;
153    config
154        .lines()
155        .find(|l| l.trim_start().starts_with("url ="))
156        .map(|l| {
157            l.split_once('=')
158                .map(|(_, v)| v.trim().to_owned())
159                .unwrap_or_default()
160        })
161}