Skip to main content

majordomus_cli/mesh/
repository.rs

1//! Which repository and which runtime a mesh participant is — independently of any path.
2//!
3//! A repository's mesh identity must be the same on every machine that holds a clone of
4//! it and different for every other repository. The path of the git directory (what
5//! `server.status` digests to tell checkouts apart) is neither: a macOS checkout path and
6//! the Linux checkout path of the same clone are one repository with two spellings. So the
7//! mesh identity is derived from
8//! the repository's **root commits** — content every full clone shares and no unrelated
9//! repository has — or, when a person declares one, from the declared identity
10//! (`cooperation.repository`), which is what a shallow clone needs and what separates a
11//! fork that should not cooperate with its origin.
12//!
13//! A runtime is one server of one node: one per checkout. Its id is a digest of the
14//! checkout id, so a restarted server of the same checkout is the same runtime (a new
15//! instance of it), and two worktrees of one machine are two runtimes.
16//!
17//! ```
18//! use majordomus_cli::mesh::repository::{declared, of_root_commits, runtime_id};
19//!
20//! let a = of_root_commits(&["b".repeat(40), "a".repeat(40)]);
21//! let b = of_root_commits(&["a".repeat(40), "b".repeat(40)]);
22//! assert_eq!(a.id, b.id, "the order git lists roots in does not matter");
23//! assert_ne!(a.id, declared("majordomus").unwrap().id);
24//! assert_eq!(runtime_id("checkout-1").len(), 16);
25//! ```
26
27use std::path::Path;
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31use sha2::{Digest, Sha256};
32
33use super::MeshError;
34
35const REPOSITORY_DOMAIN: &str = "majordomus-mesh-repository/v1\n";
36const RUNTIME_DOMAIN: &str = "majordomus-mesh-runtime/v1\n";
37
38/// What the repository identity was derived from, which a reader needs in order to judge
39/// it: an identity taken from the root commits is a fact about the history two clones
40/// share, while a declared one is a decision somebody made, and only the second can be
41/// changed by editing a file.
42///
43/// ```
44/// use majordomus_cli::mesh::repository::{declared, of_root_commits, MeshRepositoryBasis};
45///
46/// assert_eq!(declared("majordomus").unwrap().basis, MeshRepositoryBasis::Declared);
47/// assert_eq!(
48///     of_root_commits(&["a".repeat(40)]).basis,
49///     MeshRepositoryBasis::RootCommits,
50/// );
51/// ```
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
53#[serde(rename_all = "snake_case")]
54pub enum MeshRepositoryBasis {
55    /// `cooperation.repository` in the mesh declaration.
56    Declared,
57    /// The repository's root commits.
58    RootCommits,
59}
60
61/// A repository's mesh identity, carried by every advertisement, link and event, together
62/// with the account of where it came from. The `detail` exists so that a person told two
63/// checkouts are not the same repository can see what each one was derived from instead of
64/// comparing two opaque digests.
65///
66/// ```
67/// use majordomus_cli::mesh::repository::MeshRepositoryIdentity;
68/// use majordomus_cli::mesh::repository::of_root_commits;
69///
70/// let identity: MeshRepositoryIdentity = of_root_commits(&["a".repeat(40)]);
71/// assert_eq!(identity.id.len(), 32, "32 hex characters, on every machine");
72/// assert_eq!(identity.detail, "aaaaaaaaaaaa", "the evidence, short enough to read");
73/// ```
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75pub struct MeshRepositoryIdentity {
76    /// 32 hex: what advertisements, links and events carry.
77    pub id: String,
78    /// What it was derived from.
79    pub basis: MeshRepositoryBasis,
80    /// The evidence, human-readable: the declared value, or the root commits.
81    pub detail: String,
82}
83
84fn digest(text: &str, hex_chars: usize) -> String {
85    let hash = Sha256::digest(text.as_bytes());
86    hash.iter()
87        .take(hex_chars / 2)
88        .map(|b| format!("{b:02x}"))
89        .collect()
90}
91
92/// The identity a declared value yields: what a shallow clone, which cannot see its own
93/// root commit, must use, and what separates a fork that should not cooperate with the
94/// repository it came from. The value is a name chosen by the people who run the mesh, not
95/// a digest, so it is bounded and must not be empty — an empty declaration would put every
96/// repository that forgot to fill it in on one mesh.
97///
98/// ```
99/// use majordomus_cli::mesh::repository::declared;
100///
101/// let a = declared("majordomus").unwrap();
102/// assert_eq!(a.id, declared("  majordomus  ").unwrap().id, "surrounding space is noise");
103/// assert_ne!(a.id, declared("majordomus-fork").unwrap().id);
104/// assert!(declared("").is_err(), "an empty declaration would merge unrelated repositories");
105/// ```
106pub fn declared(value: &str) -> Result<MeshRepositoryIdentity, MeshError> {
107    let value = value.trim();
108    if value.is_empty() || value.len() > 128 {
109        return Err(MeshError::Config(
110            "a declared repository identity is 1 to 128 characters".into(),
111        ));
112    }
113    Ok(MeshRepositoryIdentity {
114        id: digest(&format!("{REPOSITORY_DOMAIN}declared\n{value}"), 32),
115        basis: MeshRepositoryBasis::Declared,
116        detail: value.into(),
117    })
118}
119
120/// The identity a set of root commits yields. The roots are treated as a set rather than a
121/// list, because `git rev-list` makes no promise about the order it prints them in and two
122/// machines that disagreed about that order would decide they held different repositories;
123/// for the same reason the same root named twice is one root.
124///
125/// ```
126/// use majordomus_cli::mesh::repository::of_root_commits;
127///
128/// let a = of_root_commits(&["b".repeat(40), "a".repeat(40)]);
129/// let b = of_root_commits(&["a".repeat(40), "b".repeat(40), "a".repeat(40)]);
130/// assert_eq!(a.id, b.id, "a set: order and repetition are not information");
131/// assert_ne!(a.id, of_root_commits(&["a".repeat(40)]).id);
132/// ```
133pub fn of_root_commits(roots: &[String]) -> MeshRepositoryIdentity {
134    // A set, so the digest reads the same on every machine whatever order the commits were
135    // listed in, and the same root named twice is one root.
136    let sorted: Vec<&str> = roots
137        .iter()
138        .map(|r| r.trim())
139        .collect::<std::collections::BTreeSet<_>>()
140        .into_iter()
141        .collect();
142    let joined = sorted.join("\n");
143    MeshRepositoryIdentity {
144        id: digest(&format!("{REPOSITORY_DOMAIN}roots\n{joined}"), 32),
145        basis: MeshRepositoryBasis::RootCommits,
146        detail: sorted
147            .iter()
148            .map(|r| r.chars().take(12).collect::<String>())
149            .collect::<Vec<_>>()
150            .join(", "),
151    }
152}
153
154/// Resolve the mesh identity of the repository at `root`: the declared value when there
155/// is one; otherwise its root commits, refusing a shallow clone (whose "root" is only
156/// where its history was cut) and a repository with no commit yet.
157///
158/// The declaration is consulted first and answers on its own, so a caller that has one
159/// never runs git — which is what lets a shallow clone, a worktree of one, or a directory
160/// that is not a repository at all still take part in a mesh.
161///
162/// ```
163/// use majordomus_cli::mesh::repository::{declared, resolve};
164///
165/// let nowhere = tempfile::tempdir().unwrap();
166/// let stated = resolve(nowhere.path(), Some("majordomus")).unwrap();
167/// assert_eq!(stated.id, declared("majordomus").unwrap().id, "git was never asked");
168///
169/// // without one, the history has to answer, and a directory holding no history cannot
170/// assert!(resolve(nowhere.path(), None).is_err());
171/// ```
172pub fn resolve(
173    root: &Path,
174    declared_value: Option<&str>,
175) -> Result<MeshRepositoryIdentity, MeshError> {
176    if let Some(value) = declared_value {
177        return declared(value);
178    }
179    let git = |args: &[&str]| -> Result<String, MeshError> {
180        let out = std::process::Command::new("git")
181            .arg("-C")
182            .arg(root)
183            .args(args)
184            .output()
185            .map_err(|e| MeshError::Config(format!("git: {e}")))?;
186        if !out.status.success() {
187            return Err(MeshError::Config(format!(
188                "git {}: {}",
189                args.join(" "),
190                String::from_utf8_lossy(&out.stderr).trim()
191            )));
192        }
193        Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
194    };
195    if git(&["rev-parse", "--is-shallow-repository"])? == "true" {
196        return Err(MeshError::Config(
197            "a shallow clone's root commit is where its history was cut, not the repository's: declare cooperation.repository in the mesh declaration".into(),
198        ));
199    }
200    let roots: Vec<String> = git(&["rev-list", "--max-parents=0", "HEAD"])
201        .map_err(|_| {
202            MeshError::Config("the repository has no commit yet: nothing identifies it".into())
203        })?
204        .lines()
205        .map(str::to_string)
206        .collect();
207    if roots.is_empty() {
208        return Err(MeshError::Config(
209            "the repository has no root commit: nothing identifies it".into(),
210        ));
211    }
212    Ok(of_root_commits(&roots))
213}
214
215/// The runtime slot of a checkout: 16 hex of a digest of its checkout id. A runtime is one
216/// server of one node, so this is what makes a restarted server the same runtime as the
217/// one it replaced — a new instance of it rather than a new participant — while two
218/// worktrees of one machine stay two runtimes that can claim against each other.
219///
220/// ```
221/// use majordomus_cli::mesh::repository::runtime_id;
222///
223/// assert_eq!(runtime_id("checkout-1"), runtime_id("checkout-1"), "a restart is not a new runtime");
224/// assert_ne!(runtime_id("checkout-1"), runtime_id("checkout-2"));
225/// assert_eq!(runtime_id("checkout-1").len(), 16);
226/// ```
227pub fn runtime_id(checkout_id: &str) -> String {
228    digest(&format!("{RUNTIME_DOMAIN}{checkout_id}"), 16)
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn git(dir: &Path, args: &[&str]) {
236        let status = std::process::Command::new("git")
237            .arg("-C")
238            .arg(dir)
239            .args(args)
240            .env("GIT_AUTHOR_NAME", "t")
241            .env("GIT_AUTHOR_EMAIL", "t@t")
242            .env("GIT_COMMITTER_NAME", "t")
243            .env("GIT_COMMITTER_EMAIL", "t@t")
244            .output()
245            .expect("git runs");
246        assert!(
247            status.status.success(),
248            "git {args:?}: {}",
249            String::from_utf8_lossy(&status.stderr)
250        );
251    }
252
253    fn repo(dir: &Path, content: &str) {
254        std::fs::create_dir_all(dir).unwrap();
255        git(dir, &["init", "-q"]);
256        std::fs::write(dir.join("f"), content).unwrap();
257        git(dir, &["add", "f"]);
258        git(dir, &["commit", "-q", "-m", "root"]);
259    }
260
261    #[test]
262    fn two_clones_at_different_paths_are_one_repository_and_another_is_not() {
263        let tmp = tempfile::tempdir().unwrap();
264        let origin = tmp.path().join("machine-a/repo");
265        repo(&origin, "one");
266        let clone = tmp.path().join("machine-b/elsewhere/repo");
267        std::fs::create_dir_all(clone.parent().unwrap()).unwrap();
268        git(
269            tmp.path(),
270            &[
271                "clone",
272                "-q",
273                origin.to_str().unwrap(),
274                clone.to_str().unwrap(),
275            ],
276        );
277        let other = tmp.path().join("machine-a/other");
278        repo(&other, "two");
279
280        let a = resolve(&origin, None).unwrap();
281        let b = resolve(&clone, None).unwrap();
282        let c = resolve(&other, None).unwrap();
283        assert_eq!(a.id, b.id, "same history, different paths: one repository");
284        assert_ne!(a.id, c.id, "different history: different repositories");
285        assert_eq!(a.basis, MeshRepositoryBasis::RootCommits);
286        // A declaration overrides the history, both ways.
287        assert_eq!(
288            resolve(&origin, Some("x")).unwrap().id,
289            resolve(&other, Some("x")).unwrap().id
290        );
291        assert_ne!(resolve(&origin, Some("x")).unwrap().id, a.id);
292    }
293
294    #[test]
295    fn a_shallow_clone_and_an_empty_repository_are_refused_with_the_remedy() {
296        let tmp = tempfile::tempdir().unwrap();
297        let origin = tmp.path().join("origin");
298        repo(&origin, "one");
299        std::fs::write(origin.join("f"), "two").unwrap();
300        git(&origin, &["commit", "-q", "-am", "second"]);
301        let shallow = tmp.path().join("shallow");
302        git(
303            tmp.path(),
304            &[
305                "clone",
306                "-q",
307                "--depth",
308                "1",
309                &format!("file://{}", origin.display()),
310                shallow.to_str().unwrap(),
311            ],
312        );
313        let error = resolve(&shallow, None).unwrap_err().to_string();
314        assert!(error.contains("cooperation.repository"), "{error}");
315        assert!(resolve(&shallow, Some("declared")).is_ok());
316
317        let empty = tmp.path().join("empty");
318        std::fs::create_dir_all(&empty).unwrap();
319        git(&empty, &["init", "-q"]);
320        assert!(resolve(&empty, None).is_err());
321    }
322
323    #[test]
324    fn a_runtime_is_stable_per_checkout_and_distinct_across_checkouts() {
325        assert_eq!(runtime_id("c1"), runtime_id("c1"));
326        assert_ne!(runtime_id("c1"), runtime_id("c2"));
327        assert!(runtime_id("c1").bytes().all(|b| b.is_ascii_hexdigit()));
328        assert!(declared("").is_err());
329    }
330}