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}