Skip to main content

majordomus_cli/
repository.rs

1//! Repository discovery: the nearest ancestor of the start directory that carries
2//! `.ai/manifest.yaml` is the root, and the manifest is the only thing read to find it.
3//!
4//! `.git` is not a marker: an arbitrary git repository is not a Majordomus repository. A
5//! `.majordomus/` directory is not a marker either; it is an optional installation of the
6//! tool, or the pre-`.ai` layout, which is refused by name.
7//!
8//! # Why the marker is the manifest
9//!
10//! Every other candidate marker is ambiguous. `.git` says a directory is version
11//! controlled, which almost every directory a person runs this in will be; a `.ai`
12//! directory alone says nothing about whether it is *this* layer. The manifest declares its
13//! own schema, so discovery either finds a layer this executable can read or it says which
14//! schema it found and which it reads — never a half-open repository.
15//!
16//! # Errors
17//!
18//! [`crate::Error::RepositoryNotFound`] when no ancestor carries the manifest, naming where
19//! the search began; [`crate::Error::LegacyLayout`] when it finds the pre-`.ai` layout,
20//! naming the migration; and the manifest's own parse and schema errors, each naming the
21//! file. None of them is a diagnostic: without a manifest nothing can be discovered at all.
22//!
23//! ```
24//! use majordomus_cli::Repository;
25//!
26//! // a directory that is not a repository is an error that says where it looked
27//! let empty = tempfile::tempdir().unwrap();
28//! let err = Repository::discover(empty.path()).unwrap_err();
29//! assert!(matches!(err, majordomus_cli::Error::RepositoryNotFound { .. }));
30//! assert_eq!(err.exit_code(), 12, "the code for something that is not there");
31//!
32//! // and a git repository is not one either: the marker is the manifest and nothing else
33//! std::fs::create_dir(empty.path().join(".git")).unwrap();
34//! assert!(Repository::discover(empty.path()).is_err());
35//! ```
36
37use std::collections::BTreeMap;
38use std::path::{Path, PathBuf};
39
40use serde::Deserialize;
41
42use crate::error::{Error, Result};
43use crate::metadata::yaml;
44
45/// The file whose presence makes a directory the root of a Majordomus repository.
46pub const MANIFEST: &str = ".ai/manifest.yaml";
47
48/// The layer schema this executable reads.
49pub const LAYER_SCHEMA: &str = "ai-repository/v1";
50
51#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
52#[serde(deny_unknown_fields)]
53/// The tracked half of the layer.
54pub struct RepoHalf {
55    /// Relative to `.ai/`.
56    pub path: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
60#[serde(deny_unknown_fields)]
61/// The checkout-local half of the layer, never a source.
62pub struct LocalHalf {
63    /// Relative to `.ai/`.
64    pub path: String,
65    /// Tracked in git? The contract says `false`.
66    pub tracked: bool,
67    /// Loaded into a worker's context implicitly? The contract says `false`.
68    pub implicit_context: bool,
69}
70
71/// The scoped context documents: file names that must carry the context contract
72/// (`schema: context/v1`) wherever they appear under the layer.
73#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
74#[serde(deny_unknown_fields)]
75pub struct ContextConventions {
76    #[serde(default)]
77    /// File names, e.g. `README.md`.
78    pub documents: Vec<String>,
79}
80
81/// `.ai/manifest.yaml`, typed. Unknown keys are refused through `share/allow/manifest.txt`.
82#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct Manifest {
85    /// The layer schema, [`LAYER_SCHEMA`].
86    pub schema: String,
87    /// Where the tracked half is.
88    pub repo: RepoHalf,
89    /// Where the checkout-local half is.
90    pub local: LocalHalf,
91    /// Section name to path, relative to `.ai/`. Sorted by name.
92    pub sections: BTreeMap<String, String>,
93    /// Optional: absent in a layer written before context documents existed.
94    #[serde(default)]
95    pub context: Option<ContextConventions>,
96}
97
98impl Manifest {
99    /// Parse the manifest. The typed struct refuses a key it does not read and names it;
100    /// the distribution's `manifest` schema is applied by the application once the share
101    /// directory is located, for the constraints a type cannot express.
102    pub fn parse(path: &Path, text: &str) -> Result<Self> {
103        let map = yaml::parse_mapping(text).map_err(|reason| Error::InvalidManifest {
104            path: path.to_path_buf(),
105            reason,
106        })?;
107        let schema = map
108            .get("schema")
109            .and_then(|v| v.as_str())
110            .unwrap_or_default()
111            .to_string();
112        if schema != LAYER_SCHEMA {
113            return Err(Error::UnsupportedSchema {
114                path: path.to_path_buf(),
115                found: schema,
116                supported: LAYER_SCHEMA.to_string(),
117            });
118        }
119        serde_json::from_value(serde_json::Value::Object(map)).map_err(|e| {
120            let reason = e.to_string();
121            match reason.strip_prefix("unknown field `") {
122                Some(rest) => Error::UnknownKeys {
123                    path: path.to_path_buf(),
124                    keys: vec![rest.split('`').next().unwrap_or(rest).to_string()],
125                },
126                None => Error::InvalidManifest {
127                    path: path.to_path_buf(),
128                    reason,
129                },
130            }
131        })
132    }
133}
134
135/// A located repository with its manifest read.
136#[derive(Debug, Clone)]
137pub struct Repository {
138    root: PathBuf,
139    manifest: Manifest,
140}
141
142impl Repository {
143    /// Walk from `start` upward to the nearest directory carrying [`MANIFEST`]. A manifest
144    /// that does not parse stops the search with its error: a nearer broken layer is never
145    /// skipped in favour of a farther working one.
146    pub fn discover(start: &Path) -> Result<Self> {
147        let start = start.canonicalize().map_err(|e| Error::io(start, e))?;
148        for dir in start.ancestors() {
149            let manifest = dir.join(MANIFEST);
150            if manifest.is_file() {
151                return Self::open(dir);
152            }
153            if is_legacy_layout(dir) {
154                return Err(Error::LegacyLayout {
155                    root: dir.to_path_buf(),
156                });
157            }
158        }
159        Err(Error::RepositoryNotFound { start })
160    }
161
162    /// Open a directory known to be a root.
163    pub fn open(root: &Path) -> Result<Self> {
164        let manifest_path = root.join(MANIFEST);
165        let text =
166            std::fs::read_to_string(&manifest_path).map_err(|e| Error::io(&manifest_path, e))?;
167        let manifest = Manifest::parse(&manifest_path, &text)?;
168        Ok(Repository {
169            root: root.to_path_buf(),
170            manifest,
171        })
172    }
173
174    /// The root, absolute and canonical.
175    pub fn root(&self) -> &Path {
176        &self.root
177    }
178
179    /// The manifest, typed.
180    pub fn manifest(&self) -> &Manifest {
181        &self.manifest
182    }
183
184    /// The manifest as a JSON value, for schema validation.
185    pub fn manifest_value(&self) -> Result<serde_json::Value> {
186        let path = self.root.join(MANIFEST);
187        let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
188        let map =
189            yaml::parse_mapping(&text).map_err(|reason| Error::InvalidManifest { path, reason })?;
190        Ok(serde_json::Value::Object(map))
191    }
192
193    /// `.ai/`, absolute.
194    pub fn ai_dir(&self) -> PathBuf {
195        self.root.join(".ai")
196    }
197
198    /// The repository-relative path of a section, e.g. `.ai/repo/rules`, or `None` when
199    /// the manifest does not name it.
200    pub fn section_path(&self, name: &str) -> Option<String> {
201        self.manifest.sections.get(name).map(|p| format!(".ai/{p}"))
202    }
203
204    /// The repository-relative path of the checkout-local half, never a source.
205    pub fn local_path(&self) -> String {
206        format!(".ai/{}", self.manifest.local.path)
207    }
208
209    /// The repository-relative path of the tracked half.
210    pub fn repo_path(&self) -> String {
211        format!(".ai/{}", self.manifest.repo.path)
212    }
213
214    /// The manifest section a repository-relative path falls under, by longest prefix.
215    pub fn section_of(&self, rel_path: &str) -> Option<&str> {
216        let mut best: Option<(&str, usize)> = None;
217        for (name, p) in &self.manifest.sections {
218            let full = format!(".ai/{p}");
219            let matches = rel_path == full || rel_path.starts_with(&format!("{full}/"));
220            if matches && best.is_none_or(|(_, len)| full.len() > len) {
221                best = Some((name.as_str(), full.len()));
222            }
223        }
224        best.map(|(n, _)| n)
225    }
226}
227
228/// Project data under `.majordomus/` with no `.ai/manifest.yaml` is the pre-`.ai` layout.
229/// A `.majordomus/` that holds a tool distribution (`bin/majordomus`) is an installation.
230fn is_legacy_layout(dir: &Path) -> bool {
231    let mj = dir.join(".majordomus");
232    mj.is_dir() && !mj.join("bin/majordomus").is_file()
233}
234
235/// A repository's identity over the wire: a digest of its absolute root.
236///
237/// A server on a loopback socket has to be able to say *which* repository it serves, so
238/// that a second process can tell a live server for this repository from one for another.
239/// The root itself would answer that and would also tell whoever reached the socket where
240/// the checkout sits on the host, which is of no use to them and of some use to somebody
241/// else. A digest answers the question and discloses nothing: a process that already knows
242/// the root can compute it and compare, and a process that does not cannot invert it.
243///
244/// Two repositories at one path are one repository, which is exactly the identity a lease
245/// needs. A repository that moves is a different one, which is also right: the lease of the
246/// old path names a server that is no longer serving that path.
247///
248/// ```
249/// use majordomus_cli::repository::identity;
250/// use std::path::Path;
251/// assert_eq!(identity(Path::new("/a/b")), identity(Path::new("/a/b")));
252/// assert_ne!(identity(Path::new("/a/b")), identity(Path::new("/a/c")));
253/// assert_eq!(identity(Path::new("/a/b")).len(), 32);
254/// assert!(!identity(Path::new("/a/b")).contains('/'));
255/// ```
256pub fn identity(root: &Path) -> String {
257    digest(root)
258}
259
260fn digest(path: &Path) -> String {
261    use sha2::{Digest, Sha256};
262    let mut hasher = Sha256::new();
263    hasher.update(path.as_os_str().as_encoded_bytes());
264    format!("{:x}", hasher.finalize())[..32].to_string()
265}
266
267/// The git repository a checkout belongs to, as distinct from the checkout itself.
268///
269/// [`identity`] names a checkout: one server, one lease, one index per checkout root. Every
270/// linked worktree of one git repository is a checkout of its own by that measure, and
271/// nothing in it said that they belong together. This does: the git directory every work
272/// tree shares (`git rev-parse --git-common-dir`) is the repository's identity, its digest
273/// is one value for every checkout of that repository, and `linked` says whether this
274/// checkout is the primary one or a work tree hanging off it. `None` where git cannot be
275/// asked: a repository of the layer does not have to be version controlled.
276///
277/// ```
278/// use majordomus_cli::repository::{git_identity, identity, GitIdentity};
279/// use std::process::Command;
280/// let dir = tempfile::tempdir().unwrap();
281/// let root = dir.path().join("repo");
282/// std::fs::create_dir_all(&root).unwrap();
283/// let git = |args: &[&str]| assert!(Command::new("git").arg("-C").arg(&root).args(args).status().unwrap().success());
284/// git(&["init", "-q", "."]);
285/// git(&["-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "-q", "--allow-empty", "-m", "init"]);
286/// let wt = dir.path().join("repo-wt");
287/// git(&["worktree", "add", "-q", "-b", "feature/x", wt.to_str().unwrap()]);
288///
289/// let primary: GitIdentity = git_identity(&root).expect("a work tree");
290/// let linked: GitIdentity = git_identity(&wt).expect("a linked work tree");
291/// assert_eq!(primary.id, linked.id, "one repository");
292/// assert_ne!(identity(&root), identity(&wt), "two checkouts");
293/// assert!(!primary.linked && linked.linked);
294/// assert!(!primary.id.contains('/'), "a digest, never a path");
295/// ```
296#[derive(
297    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
298)]
299pub struct GitIdentity {
300    /// The git directory every work tree of the repository shares, canonical.
301    pub common_dir: PathBuf,
302    /// Its digest: the same for every checkout of one repository, different across
303    /// repositories, and never a path.
304    pub id: String,
305    /// Whether this checkout is a linked work tree rather than the primary one.
306    pub linked: bool,
307}
308
309/// Ask git which repository the checkout at `root` belongs to. One subprocess; `None`
310/// when git is absent or the root is not a work tree.
311///
312/// ```
313/// use majordomus_cli::repository::git_identity;
314/// let plain = tempfile::tempdir().unwrap();
315/// assert!(git_identity(plain.path()).is_none(), "a plain directory belongs to no repository");
316/// ```
317pub fn git_identity(root: &Path) -> Option<GitIdentity> {
318    let out = std::process::Command::new("git")
319        .arg("-C")
320        .arg(root)
321        .args(["rev-parse", "--git-common-dir"])
322        .output()
323        .ok()?;
324    if !out.status.success() {
325        return None;
326    }
327    let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
328    if raw.is_empty() {
329        return None;
330    }
331    // relative to the current directory when the current directory is inside the work
332    // tree, absolute otherwise; both forms are answers
333    let common = PathBuf::from(&raw);
334    let common = if common.is_absolute() {
335        common
336    } else {
337        root.join(common)
338    };
339    let common = common.canonicalize().ok()?;
340    Some(GitIdentity {
341        id: digest(&common),
342        linked: root.join(".git").is_file(),
343        common_dir: common,
344    })
345}
346
347/// The branch the checkout at `root` has checked out, read from its `HEAD` file instead of
348/// asked of a subprocess. `None` when HEAD is detached, when the file cannot be read, or
349/// when `root` is not a checkout at all — the same three silences `git symbolic-ref -q
350/// --short HEAD` answers with, at the cost of at most two small reads.
351///
352/// It exists because a `git` subprocess is not free: on a loaded machine one costs tens of
353/// milliseconds, which is more than the whole rest of a narrow `server.status` reading.
354/// The linked-work-tree case is why it is not a single read — a linked checkout's `.git` is
355/// a file naming the per-work-tree git directory, and HEAD lives there.
356///
357/// ```
358/// use majordomus_cli::repository::branch_at;
359/// use std::process::Command;
360///
361/// let dir = tempfile::tempdir().unwrap();
362/// let root = dir.path().join("repo");
363/// std::fs::create_dir_all(&root).unwrap();
364/// let git = |args: &[&str]| assert!(
365///     Command::new("git").arg("-C").arg(&root).args(args).status().unwrap().success()
366/// );
367/// git(&["init", "-q", "-b", "trunk", "."]);
368/// assert_eq!(branch_at(&root).as_deref(), Some("trunk"), "the same name git would print");
369/// assert!(branch_at(dir.path()).is_none(), "a plain directory has no branch");
370/// ```
371pub fn branch_at(root: &Path) -> Option<String> {
372    let dot_git = root.join(".git");
373    let git_dir = if dot_git.is_dir() {
374        dot_git
375    } else {
376        // a linked work tree: `.git` is a file naming the git directory git keeps for it
377        let text = std::fs::read_to_string(&dot_git).ok()?;
378        let named = PathBuf::from(text.trim().strip_prefix("gitdir:")?.trim());
379        if named.is_absolute() {
380            named
381        } else {
382            root.join(named)
383        }
384    };
385    let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
386    let name = head.trim().strip_prefix("ref:")?.trim();
387    Some(name.strip_prefix("refs/heads/").unwrap_or(name).to_string())
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    const MANIFEST_TEXT: &str = "schema: ai-repository/v1\nrepo:\n  path: repo\nlocal:\n  path: local\n  tracked: false\n  implicit_context: false\nsections:\n  policy: repo/policy.yaml\n  rules: repo/rules\n";
395
396    #[test]
397    fn manifest_parses_and_sections_resolve() {
398        let m = Manifest::parse(Path::new("m"), MANIFEST_TEXT).unwrap();
399        assert_eq!(m.schema, LAYER_SCHEMA);
400        assert_eq!(m.sections["rules"], "repo/rules");
401        assert_eq!(m.context, None);
402        let with_context = format!("{MANIFEST_TEXT}context:\n  documents: [README.md]\n");
403        let m = Manifest::parse(Path::new("m"), &with_context).unwrap();
404        assert_eq!(m.context.unwrap().documents, vec!["README.md"]);
405    }
406
407    #[test]
408    fn manifest_unknown_key_is_named() {
409        let bad = format!("{MANIFEST_TEXT}extra: 1\n");
410        match Manifest::parse(Path::new("m"), &bad) {
411            Err(Error::UnknownKeys { keys, .. }) => assert_eq!(keys, vec!["extra"]),
412            other => panic!("{other:?}"),
413        }
414    }
415
416    #[test]
417    fn manifest_wrong_schema_is_unsupported() {
418        let bad = MANIFEST_TEXT.replace("v1", "v2");
419        assert!(matches!(
420            Manifest::parse(Path::new("m"), &bad),
421            Err(Error::UnsupportedSchema { .. })
422        ));
423    }
424
425    #[test]
426    fn manifest_malformed_yaml_is_invalid() {
427        assert!(matches!(
428            Manifest::parse(Path::new("m"), "schema:\tai-repository/v1\n"),
429            Err(Error::InvalidManifest { .. })
430        ));
431    }
432}