Skip to main content

ljos_cli/
sync.rs

1//! Sharing the seat's memory across machines through the tracker repository
2//! that already travels between them.
3//!
4//! Each tracker repository names one scope in `.ljos/sync.toml`: the scope's
5//! name and the age recipients allowed to read it. Every machine writes only
6//! its own log, `.ljos/atoms/<host>.jsonl.age`, sealed to those recipients,
7//! so two machines never touch one file and git never has to merge a log.
8//! A log carries the machine's live atoms of that scope without their
9//! vectors; the receiving pack encodes them again. An atom's scope is its
10//! `scope:NAME` entity, else the machine's default scope.
11//!
12//! Import posts every atom of every other machine's log to this seat's pack,
13//! which answers a text it already holds with the claim it has, so an import
14//! twice is an import once. Retirement travels by difference: the texts an
15//! import took from a host are remembered, and a text that host no longer
16//! sends retires the local copy that came from it, never a claim this
17//! machine wrote itself.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22
23use anyhow::{bail, Context, Result};
24use serde_json::Value;
25
26/// The scope a tracker repository shares: `.ljos/sync.toml`.
27#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
28pub struct Scope {
29    /// The scope's name; an atom carrying `scope:NAME` belongs to it.
30    pub name: String,
31    /// The age public keys of the machines allowed to read this scope.
32    pub recipients: Vec<String>,
33    /// Projects of this repository whose lessons belong to another scope:
34    /// `[projects] tools = "shared"` sends a lesson written on a `tools`
35    /// issue with the `shared` scope's log instead of this one.
36    #[serde(default)]
37    pub projects: BTreeMap<String, String>,
38}
39
40impl Scope {
41    /// The scope a lesson on an issue of `project` belongs to.
42    #[must_use]
43    pub fn for_project(&self, project: &str) -> String {
44        self.projects
45            .get(project)
46            .cloned()
47            .unwrap_or_else(|| self.name.clone())
48    }
49}
50
51/// This machine's defaults: `~/.config/ljos/sync.toml`, `default_scope`.
52#[derive(Debug, Clone, Default, serde::Deserialize)]
53struct Local {
54    #[serde(default)]
55    default_scope: Option<String>,
56    /// The tracker repositories this machine shares, when more than the
57    /// tracker root: a machine can hold one scope's repository beside
58    /// another's.
59    #[serde(default)]
60    repos: Vec<String>,
61}
62
63fn local() -> Local {
64    std::fs::read_to_string(config_dir().join("sync.toml"))
65        .ok()
66        .and_then(|t| toml::from_str::<Local>(&t).ok())
67        .unwrap_or_default()
68}
69
70fn expand(path: &str) -> PathBuf {
71    match path.strip_prefix("~/") {
72        Some(rest) => std::env::var_os("HOME")
73            .map_or_else(|| PathBuf::from(path), |h| PathBuf::from(h).join(rest)),
74        None => PathBuf::from(path),
75    }
76}
77
78/// The entity an imported atom carries: which machine sent it.
79#[must_use]
80pub fn sender_entity(host: &str) -> String {
81    format!("sync:{host}")
82}
83
84/// This machine's short host name.
85#[must_use]
86pub fn host() -> String {
87    std::fs::read_to_string("/proc/sys/kernel/hostname")
88        .map(|h| h.trim().to_string())
89        .ok()
90        .filter(|h| !h.is_empty())
91        .unwrap_or_else(|| "host".into())
92}
93
94fn config_dir() -> PathBuf {
95    std::env::var_os("XDG_CONFIG_HOME")
96        .map(PathBuf::from)
97        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
98        .unwrap_or_else(|| PathBuf::from(".config"))
99        .join("ljos")
100}
101
102fn state_dir() -> PathBuf {
103    std::env::var_os("XDG_STATE_HOME")
104        .map(PathBuf::from)
105        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
106        .unwrap_or_else(|| PathBuf::from(".local/state"))
107        .join("ljos")
108        .join("sync")
109}
110
111/// This machine's age identity, made on first use.
112#[must_use]
113pub fn identity_path() -> PathBuf {
114    config_dir().join("age.key")
115}
116
117/// The public key of this machine's identity, making the identity first when
118/// there is none. This is what goes into a scope's `recipients`.
119///
120/// # Errors
121///
122/// `age-keygen` missing or refusing.
123pub fn public_key() -> Result<String> {
124    let key = identity_path();
125    if !key.exists() {
126        if let Some(dir) = key.parent() {
127            std::fs::create_dir_all(dir)?;
128        }
129        let made = Command::new("age-keygen")
130            .arg("-o")
131            .arg(&key)
132            .stdin(Stdio::null())
133            .output()
134            .context("age-keygen not on PATH; install age")?;
135        if !made.status.success() {
136            bail!(
137                "age-keygen: {}",
138                String::from_utf8_lossy(&made.stderr).trim()
139            );
140        }
141    }
142    let out = Command::new("age-keygen")
143        .arg("-y")
144        .arg(&key)
145        .output()
146        .context("age-keygen not on PATH; install age")?;
147    if !out.status.success() {
148        bail!(
149            "age-keygen -y: {}",
150            String::from_utf8_lossy(&out.stderr).trim()
151        );
152    }
153    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
154}
155
156/// The scope a tracker repository declares, when it declares one.
157///
158/// # Errors
159///
160/// A `.ljos/sync.toml` that is present and does not parse.
161pub fn scope_of_repo(root: &Path) -> Result<Option<Scope>> {
162    let path = root.join(".ljos").join("sync.toml");
163    match std::fs::read_to_string(&path) {
164        Ok(text) => Ok(Some(
165            toml::from_str(&text).with_context(|| path.display().to_string())?,
166        )),
167        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
168        Err(e) => Err(e).with_context(|| path.display().to_string()),
169    }
170}
171
172fn default_scope(repo_scope: &str) -> String {
173    local()
174        .default_scope
175        .unwrap_or_else(|| repo_scope.to_string())
176}
177
178/// The scope an atom belongs to: its `scope:NAME` entity, else `default`.
179#[must_use]
180pub fn atom_scope(atom: &Value, default: &str) -> String {
181    atom["entities"]
182        .as_array()
183        .into_iter()
184        .flatten()
185        .filter_map(Value::as_str)
186        .find_map(|e| e.strip_prefix("scope:"))
187        .map_or_else(|| default.to_string(), str::to_string)
188}
189
190/// An atom as it travels: without its vector, and without a sender tag of
191/// another machine, since a host sends only what it holds as its own.
192#[must_use]
193pub fn travelling(atom: &Value) -> Value {
194    let mut a = atom.clone();
195    if let Some(map) = a.as_object_mut() {
196        map.remove("embedding");
197    }
198    a
199}
200
201/// Whether this machine holds an atom as its own rather than as an import.
202#[must_use]
203pub fn is_own(atom: &Value) -> bool {
204    !atom["entities"]
205        .as_array()
206        .into_iter()
207        .flatten()
208        .filter_map(Value::as_str)
209        .any(|e| e.starts_with("sync:"))
210}
211
212/// The plaintext of this machine's log for a scope: its own live atoms of
213/// that scope, sorted by id, one JSON object a line.
214#[must_use]
215pub fn log_plaintext(atoms: &[Value], scope: &str, default: &str) -> String {
216    let mut mine: Vec<Value> = atoms
217        .iter()
218        .filter(|a| is_own(a) && atom_scope(a, default) == scope)
219        .map(travelling)
220        .collect();
221    mine.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
222    mine.iter().map(|a| format!("{a}\n")).collect()
223}
224
225fn log_dir(root: &Path) -> PathBuf {
226    root.join(".ljos").join("atoms")
227}
228
229/// Seal `plain` to the recipients into `out`.
230fn seal(plain: &str, recipients: &[String], out: &Path) -> Result<()> {
231    if recipients.is_empty() {
232        bail!("sync: the scope names no recipients; add this machine's `ljos sync --key` to .ljos/sync.toml");
233    }
234    let mut cmd = Command::new("age");
235    for r in recipients {
236        cmd.arg("-r").arg(r);
237    }
238    let tmp = out.with_extension("age.tmp");
239    let mut child = cmd
240        .arg("-o")
241        .arg(&tmp)
242        .stdin(Stdio::piped())
243        .stderr(Stdio::piped())
244        .spawn()
245        .context("age not on PATH; install age")?;
246    use std::io::Write;
247    child
248        .stdin
249        .take()
250        .context("age: no stdin")?
251        .write_all(plain.as_bytes())?;
252    let done = child.wait_with_output()?;
253    if !done.status.success() {
254        let _ = std::fs::remove_file(&tmp);
255        bail!("age: {}", String::from_utf8_lossy(&done.stderr).trim());
256    }
257    std::fs::rename(&tmp, out)?;
258    Ok(())
259}
260
261/// Open a sealed log with this machine's identity; none when this machine
262/// is not one of its recipients.
263fn open(path: &Path, identity: &Path) -> Result<Option<String>> {
264    let out = Command::new("age")
265        .arg("-d")
266        .arg("-i")
267        .arg(identity)
268        .arg(path)
269        .stdin(Stdio::null())
270        .output()
271        .context("age not on PATH; install age")?;
272    if out.status.success() {
273        Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()))
274    } else if String::from_utf8_lossy(&out.stderr).contains("no identity matched") {
275        Ok(None)
276    } else {
277        bail!(
278            "age -d {}: {}",
279            path.display(),
280            String::from_utf8_lossy(&out.stderr).trim()
281        )
282    }
283}
284
285/// One line when `age` is not installed. The seal is skipped; the rest of
286/// the sync still succeeds. The next finish tries again.
287fn age_missing_line() -> String {
288    "sync: age is not installed, so the log was not sealed; install age to sync it\n".into()
289}
290
291#[cfg(test)]
292thread_local! {
293    /// `Some` forces whether this thread treats `age` as installed.
294    /// `None` asks the real `PATH`. Hiding `age` stays on this thread so
295    /// a test does not empty `PATH` for every other test in the process.
296    static AGE_ON_PATH: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
297}
298
299/// Whether `age` can be run. A test may force the answer on its own thread.
300fn age_on_path() -> bool {
301    #[cfg(test)]
302    if let Some(present) = AGE_ON_PATH.with(|cell| cell.get()) {
303        return present;
304    }
305    crate::on_path("age")
306}
307
308/// Hide `age` on this thread until the guard drops. `PATH` stays as it is.
309#[cfg(test)]
310#[must_use]
311fn hide_age() -> impl Drop {
312    struct Guard;
313    impl Drop for Guard {
314        fn drop(&mut self) {
315            AGE_ON_PATH.with(|cell| cell.set(None));
316        }
317    }
318    AGE_ON_PATH.with(|cell| cell.set(Some(false)));
319    Guard
320}
321
322fn age_missing(err: &anyhow::Error) -> bool {
323    err.chain().any(|cause| {
324        cause
325            .downcast_ref::<std::io::Error>()
326            .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
327    })
328}
329
330/// Write this machine's log for the repository's scope when its atoms
331/// changed since the last write. Returns the line that says what happened.
332/// A missing `age` skips the seal and is not an error.
333///
334/// # Errors
335///
336/// The pack not answering, or age refusing to seal for a reason other than
337/// being absent.
338pub fn export(root: &Path, scope: &Scope, atoms: &[Value]) -> Result<String> {
339    let host = host();
340    let plain = log_plaintext(atoms, &scope.name, &default_scope(&scope.name));
341    let count = plain.lines().count();
342    let dir = log_dir(root);
343    std::fs::create_dir_all(&dir)?;
344    let stamp = dir.join(format!("{host}.digest"));
345    let digest = format!(
346        "{} {}\n",
347        crate::work_id(&plain),
348        crate::work_id(&scope.recipients.join(","))
349    );
350    if std::fs::read_to_string(&stamp).is_ok_and(|d| d == digest) {
351        return Ok(format!(
352            "sync: {count} {} atoms unchanged since the last export\n",
353            scope.name
354        ));
355    }
356    if !age_on_path() {
357        return Ok(age_missing_line());
358    }
359    if let Err(err) = seal(
360        &plain,
361        &scope.recipients,
362        &dir.join(format!("{host}.jsonl.age")),
363    ) {
364        if age_missing(&err) {
365            return Ok(age_missing_line());
366        }
367        return Err(err);
368    }
369    std::fs::write(&stamp, digest)?;
370    Ok(format!(
371        "sync: exported {count} {} atoms to .ljos/atoms/{host}.jsonl.age, sealed to {} recipient{}\n",
372        scope.name,
373        scope.recipients.len(),
374        if scope.recipients.len() == 1 { "" } else { "s" }
375    ))
376}
377
378/// What one host's log asks of this pack: the atoms to post, and the texts
379/// it sent last time and sends no longer.
380#[derive(Debug, Default, PartialEq)]
381pub struct Incoming {
382    pub post: Vec<Value>,
383    pub retired: Vec<String>,
384}
385
386/// Read one host's log against the texts taken from it last time
387/// (`before`, for what it retired) and the texts from it this pack
388/// already holds (`held`, which are not posted again).
389#[must_use]
390pub fn incoming(
391    plain: &str,
392    host: &str,
393    before: &BTreeSet<String>,
394    held: &BTreeSet<String>,
395) -> Incoming {
396    let mut post = Vec::new();
397    let mut now = BTreeSet::new();
398    for line in plain.lines().filter(|l| !l.trim().is_empty()) {
399        let Ok(mut atom) = serde_json::from_str::<Value>(line) else {
400            continue;
401        };
402        let text = atom["text"].as_str().unwrap_or("").to_string();
403        if text.is_empty() {
404            continue;
405        }
406        let id = crate::work_id(&text);
407        now.insert(id.clone());
408        if held.contains(&id) {
409            continue;
410        }
411        if let Some(map) = atom.as_object_mut() {
412            let mut entities: Vec<Value> = map
413                .get("entities")
414                .and_then(Value::as_array)
415                .cloned()
416                .unwrap_or_default();
417            let tag = sender_entity(host);
418            if !entities.iter().any(|e| e.as_str() == Some(tag.as_str())) {
419                entities.push(Value::String(tag));
420            }
421            map.insert("entities".into(), Value::Array(entities));
422            map.insert(
423                "origin".into(),
424                Value::String(crate::admit::ORIGIN_PEER.to_string()),
425            );
426            map.remove("id");
427        }
428        post.push(atom);
429    }
430    let retired = before.difference(&now).cloned().collect();
431    Incoming { post, retired }
432}
433
434fn taken_path(scope: &str, host: &str) -> PathBuf {
435    state_dir().join(scope).join(format!("{host}.taken"))
436}
437
438fn read_taken(scope: &str, host: &str) -> BTreeSet<String> {
439    std::fs::read_to_string(taken_path(scope, host))
440        .map(|t| t.lines().map(str::to_string).collect())
441        .unwrap_or_default()
442}
443
444fn write_taken(scope: &str, host: &str, plain: &str) -> Result<()> {
445    let path = taken_path(scope, host);
446    if let Some(dir) = path.parent() {
447        std::fs::create_dir_all(dir)?;
448    }
449    let ids: BTreeSet<String> = plain
450        .lines()
451        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
452        .filter_map(|a| a["text"].as_str().map(crate::work_id))
453        .collect();
454    std::fs::write(path, ids.into_iter().map(|i| i + "\n").collect::<String>())?;
455    Ok(())
456}
457
458/// Import every other machine's log of the repository's scope into this
459/// seat's pack. Returns one line per host.
460///
461/// # Errors
462///
463/// The pack not answering, or a log this machine should open failing to.
464pub fn import(root: &Path, scope: &Scope) -> Result<String> {
465    let me = host();
466    let identity = identity_path();
467    if !identity.exists() {
468        return Ok("sync: no age identity on this machine; `ljos sync --key` makes one\n".into());
469    }
470    let dir = log_dir(root);
471    let Ok(entries) = std::fs::read_dir(&dir) else {
472        return Ok(format!("sync: no {} logs yet\n", scope.name));
473    };
474    let client = crate::pack()?;
475    let mut out = String::new();
476    let mut paths: Vec<PathBuf> = entries
477        .flatten()
478        .map(|e| e.path())
479        .filter(|p| p.to_string_lossy().ends_with(".jsonl.age"))
480        .collect();
481    paths.sort();
482    for path in paths {
483        let host = path
484            .file_name()
485            .and_then(|n| n.to_str())
486            .and_then(|n| n.strip_suffix(".jsonl.age"))
487            .unwrap_or("")
488            .to_string();
489        if host.is_empty() || host == me {
490            continue;
491        }
492        let Some(plain) = open(&path, &identity)? else {
493            out.push_str(&format!(
494                "sync: {host}'s {} log is not sealed to this machine\n",
495                scope.name
496            ));
497            continue;
498        };
499        let workspace = client.workspace();
500        // The pack names a posted atom afresh, so a text already taken
501        // from this host would be held twice; what is held is skipped.
502        let tag = sender_entity(&host);
503        let held: BTreeSet<String> = crate::atoms_lean(&client, &workspace)?
504            .iter()
505            .filter(|a| {
506                a["entities"]
507                    .as_array()
508                    .into_iter()
509                    .flatten()
510                    .any(|e| e.as_str() == Some(tag.as_str()))
511            })
512            .filter_map(|a| a["text"].as_str().map(crate::work_id))
513            .collect();
514        let got = incoming(&plain, &host, &read_taken(&scope.name, &host), &held);
515        let (mut kept, mut refused) = (0usize, 0usize);
516        for mut atom in got.post {
517            if let Some(map) = atom.as_object_mut() {
518                map.insert("workspace".into(), Value::String(workspace.clone()));
519            }
520            match crate::admit::post_kept(&client, &atom) {
521                Ok(_) => kept += 1,
522                Err(_) => refused += 1,
523            }
524        }
525        let mut retired = 0usize;
526        if !got.retired.is_empty() {
527            for atom in crate::atoms_lean(&client, &workspace)? {
528                let text = atom["text"].as_str().unwrap_or("");
529                let from_host = atom["entities"]
530                    .as_array()
531                    .into_iter()
532                    .flatten()
533                    .any(|e| e.as_str() == Some(tag.as_str()));
534                if from_host && got.retired.contains(&crate::work_id(text)) {
535                    if let Some(id) = atom["id"].as_str() {
536                        if crate::packset_forget(id, None).is_ok() {
537                            retired += 1;
538                        }
539                    }
540                }
541            }
542        }
543        write_taken(&scope.name, &host, &plain)?;
544        out.push_str(&format!(
545            "sync: {host}: {kept} {} atoms taken, {refused} refused, {retired} retired\n",
546            scope.name
547        ));
548    }
549    Ok(out)
550}
551
552/// The scope of the repository that holds an issue, when it declares one.
553#[must_use]
554pub fn scope_for_issue(issue: &str) -> Option<String> {
555    let hit = vissue_core::Layout::resolve(None, None)
556        .and_then(vissue_core::Router::load)
557        .and_then(|router| router.find_by_id(issue))
558        .ok()?;
559    let root = hit
560        .path
561        .ancestors()
562        .find(|d| d.join(".git").exists())?
563        .to_path_buf();
564    scope_of_repo(&root)
565        .ok()
566        .flatten()
567        .map(|s| s.for_project(&hit.project))
568}
569
570/// The tracker repository this seat writes, its root directory.
571fn tracker_root() -> Result<PathBuf> {
572    let layout = vissue_core::Layout::resolve(None, None).map_err(anyhow::Error::from)?;
573    Ok(layout.root().to_path_buf())
574}
575
576fn git(root: &Path, args: &[&str]) -> std::io::Result<std::process::Output> {
577    Command::new("git")
578        .arg("-C")
579        .arg(root)
580        .args(args)
581        .stdin(Stdio::null())
582        .output()
583}
584
585/// Commit the log after an export. A skipped seal with no file yet has
586/// nothing to stage, and that is not a failure. `push` false keeps the
587/// commit on this machine.
588fn commit_if_sealed(root: &Path, scope: &str, exported: &str, push: bool) -> String {
589    let age = log_dir(root).join(format!("{}.jsonl.age", host()));
590    if exported.contains("age is not installed") && !age.is_file() {
591        return String::new();
592    }
593    commit_log(root, scope, push)
594}
595
596/// Push commits this checkout has not sent. Atoms that did not change used
597/// to return before this, so a `.gitattributes` commit sat unpushed.
598/// Nothing to send, and a checkout with no remote, stay quiet.
599fn push_pending(root: &Path) -> String {
600    let remotes = git(root, &["remote"])
601        .ok()
602        .filter(|o| o.status.success())
603        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
604        .unwrap_or_default();
605    if remotes.is_empty() || !commits_to_push(root) {
606        return String::new();
607    }
608    match crate::tracker_push::push_checkout(root) {
609        Ok(()) => "sync: pushed pending commits\n".into(),
610        Err(e) => format!("sync: push refused: {e}\n"),
611    }
612}
613
614/// Whether `HEAD` has a commit the upstream does not. No upstream, with a
615/// remote, is a branch that has never been pushed.
616fn commits_to_push(root: &Path) -> bool {
617    let ahead = |spec: &str| -> Option<u64> {
618        let out = git(root, &["rev-list", "--count", spec]).ok()?;
619        if !out.status.success() {
620            return None;
621        }
622        String::from_utf8_lossy(&out.stdout).trim().parse().ok()
623    };
624    if ahead("@{upstream}..HEAD").is_some_and(|n| n > 0) {
625        return true;
626    }
627    if git(root, &["rev-parse", "--abbrev-ref", "@{upstream}"]).is_ok_and(|o| o.status.success()) {
628        return false;
629    }
630    let branch = git(root, &["rev-parse", "--abbrev-ref", "HEAD"])
631        .ok()
632        .filter(|o| o.status.success())
633        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
634        .unwrap_or_default();
635    if branch.is_empty() || branch == "HEAD" {
636        return git(root, &["rev-parse", "--verify", "HEAD"]).is_ok_and(|o| o.status.success());
637    }
638    let remote_ref = format!("origin/{branch}");
639    match ahead(&format!("{remote_ref}..HEAD")) {
640        Some(n) => n > 0,
641        None => git(root, &["rev-parse", "--verify", "HEAD"]).is_ok_and(|o| o.status.success()),
642    }
643}
644
645/// Commit this host's log alone and push it to the upstream and every other
646/// remote that carries the branch. `push` false commits and pushes nothing,
647/// not even a commit already waiting.
648fn commit_log(root: &Path, scope: &str, push: bool) -> String {
649    let host = host();
650    let files = [
651        format!(".ljos/atoms/{host}.jsonl.age"),
652        format!(".ljos/atoms/{host}.digest"),
653    ];
654    // The same lock the tracker commits take: one checkout, many seats.
655    let common = git(root, &["rev-parse", "--git-common-dir"])
656        .ok()
657        .filter(|o| o.status.success())
658        .map(|o| root.join(String::from_utf8_lossy(&o.stdout).trim()))
659        .unwrap_or_else(|| root.join(".git"));
660    let held = crate::CommitLock::acquire(&common.join("ljos-commit.lock"));
661    let mut add = vec!["add", "--"];
662    add.extend(files.iter().map(String::as_str));
663    if git(root, &add).map_or(true, |o| !o.status.success()) {
664        return "sync: could not stage the log\n".into();
665    }
666    let staged = git(
667        root,
668        &["diff", "--cached", "--quiet", "--", &files[0], &files[1]],
669    );
670    if staged.is_ok_and(|o| o.status.success()) {
671        // Atoms did not change. A commit already made in this checkout,
672        // such as the tracker's merge attributes, still has to leave,
673        // unless pushes are off.
674        drop(held);
675        return if push {
676            push_pending(root)
677        } else {
678            String::new()
679        };
680    }
681    let message = format!("chore(sync): {host} {scope} atoms");
682    let mut commit = vec!["commit", "-q", "--only", "-m", message.as_str(), "--"];
683    commit.extend(files.iter().map(String::as_str));
684    match git(root, &commit) {
685        Ok(o) if o.status.success() => {}
686        Ok(o) => {
687            return format!(
688                "sync: commit refused: {}\n",
689                String::from_utf8_lossy(&o.stderr)
690                    .lines()
691                    .next()
692                    .unwrap_or("")
693            )
694        }
695        Err(e) => return format!("sync: git: {e}\n"),
696    }
697    drop(held);
698    if !push {
699        return format!("sync: committed {message}; not pushed (LJOS_TRACKER_GIT=commit)\n");
700    }
701    let remotes = git(root, &["remote"])
702        .ok()
703        .filter(|o| o.status.success())
704        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
705        .unwrap_or_default();
706    if remotes.is_empty() {
707        return format!(
708            "sync: committed {message}; no remote is configured, so nothing was pushed. \
709             Set one with `git -C {} remote add origin URL` and `git push -u origin HEAD`.\n",
710            root.display()
711        );
712    }
713    // No upstream yet: push_checkout sets it with `-u origin <branch>`.
714    // A sibling bot's push is rebased, and an org conflict keeps both
715    // headings. Failure stays a failure; the line does not say the log was
716    // pushed.
717    match crate::tracker_push::push_checkout(root) {
718        Ok(()) => format!("sync: committed {message}; pushed\n"),
719        Err(e) => format!("sync: committed {message}; push refused: {e}\n"),
720    }
721}
722
723/// Fetch every remote and fast-forward to each one that is ahead. Machines
724/// that push to different remotes of one tracker then read each other's
725/// logs whenever the history is linear; a real divergence stays for the
726/// doctor's split row and a person.
727fn catch_up(root: &Path) -> String {
728    if git(root, &["fetch", "-q", "--all"]).map_or(true, |o| !o.status.success()) {
729        return "sync: fetch failed; reading the logs as they are\n".into();
730    }
731    let Some(up) = crate::tracker_upstream(root) else {
732        return String::new();
733    };
734    let mut refs = vec![up.clone()];
735    refs.extend(
736        crate::tracker_mirrors(root, &up)
737            .unwrap_or_default()
738            .into_iter()
739            .map(|(r, b)| format!("{r}/{b}")),
740    );
741    let mut stuck = Vec::new();
742    for r in refs {
743        let forward = git(root, &["merge", "-q", "--ff-only", &r]);
744        if forward.is_ok_and(|o| o.status.success()) {
745            continue;
746        }
747        // Diverged: this host committed while another pushed. Merge, as the
748        // tracker's own push does; a merge that stops is undone and named.
749        let merged = git(root, &["merge", "-q", "--no-edit", &r]);
750        if !merged.is_ok_and(|o| o.status.success()) {
751            let _ = git(root, &["merge", "--abort"]);
752            stuck.push(r);
753        }
754    }
755    if stuck.is_empty() {
756        String::new()
757    } else {
758        format!(
759            "sync: could not merge {}; `ljos doctor` names the split\n",
760            stuck.join(", ")
761        )
762    }
763}
764
765/// One pass over the tracker repository's scope: pull, take every other
766/// machine's log, write and push this machine's. `pull_import` and `export`
767/// pick the halves; the sitting takes the first, finish the second.
768///
769/// # Errors
770///
771/// A scope file that does not parse, the pack not answering, or age
772/// refusing to seal.
773pub fn sync_repo(pull_import: bool, export_log: bool) -> Result<String> {
774    sync_repos(pull_import, export_log, true)
775}
776
777/// [`sync_repo`] as the sitting and finish run it: a repository with no
778/// scope file is skipped without a word. Sync is opt-in, and a hint on
779/// every sitting pointed agents at a remote the person had not asked for.
780/// `ljos sync` by hand still names the missing file.
781///
782/// # Errors
783///
784/// As [`sync_repo`].
785pub fn sync_repo_in_passing(pull_import: bool, export_log: bool) -> Result<String> {
786    sync_repos(pull_import, export_log, false)
787}
788
789fn sync_repos(pull_import: bool, export_log: bool, asked: bool) -> Result<String> {
790    let listed = local().repos;
791    let roots: Vec<PathBuf> = if listed.is_empty() {
792        vec![tracker_root()?]
793    } else {
794        listed.iter().map(|r| expand(r)).collect()
795    };
796    let mut out = String::new();
797    for root in roots {
798        out.push_str(&sync_one(&root, pull_import, export_log, asked)?);
799    }
800    Ok(out)
801}
802
803/// What a sync may do with the tracker's git.
804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805enum TrackerGit {
806    /// Commit and push.
807    Push,
808    /// Commit, push nothing.
809    Commit,
810    /// Leave the checkout alone.
811    Off,
812}
813
814/// `LJOS_TRACKER_GIT` read as the tracker commit reads it: `off`, `0` or
815/// `false` is off, `commit` commits without pushing, anything else pushes.
816fn tracker_git_value(value: Option<&str>) -> TrackerGit {
817    match value {
818        Some("off" | "0" | "false") => TrackerGit::Off,
819        Some("commit") => TrackerGit::Commit,
820        _ => TrackerGit::Push,
821    }
822}
823
824/// The git a sync may do. A sync in passing, from the sitting, finish or
825/// the server's exit, follows `LJOS_TRACKER_GIT` as the ticket commit
826/// does. Its push carries the whole branch, so a commit that variable kept
827/// local left with the log. `ljos sync` run by hand does what it was asked.
828fn tracker_git(asked: bool) -> TrackerGit {
829    if asked {
830        return TrackerGit::Push;
831    }
832    tracker_git_value(std::env::var("LJOS_TRACKER_GIT").ok().as_deref())
833}
834
835fn sync_one(root: &Path, pull_import: bool, export_log: bool, asked: bool) -> Result<String> {
836    let root = root.to_path_buf();
837    let mut out = String::new();
838    let mode = tracker_git(asked);
839    if mode == TrackerGit::Off {
840        // No merge attributes, no pull, no commit. A repository with no
841        // scope stays quiet, as it does with git on.
842        return Ok(match scope_of_repo(&root)? {
843            Some(_) => "sync: off (LJOS_TRACKER_GIT); `ljos sync` runs it by hand\n".into(),
844            None => String::new(),
845        });
846    }
847    // Every sync writes the tracker's merge attributes and registers the
848    // atoms driver. A later conflict then unions org files and re-exports
849    // this machine's atoms.
850    if let Err(e) = crate::tracker_push::prepare_tracker(&root, false, true) {
851        out.push_str(&format!("sync: merge setup: {e}\n"));
852    }
853    let Some(scope) = scope_of_repo(&root)? else {
854        if !asked {
855            return Ok(String::new());
856        }
857        out.push_str(&format!(
858            "sync: {} names no scope; write .ljos/sync.toml with name and recipients (`ljos sync --key` prints this machine's)\n",
859            root.display()
860        ));
861        return Ok(out);
862    };
863    if pull_import {
864        out.push_str(&catch_up(&root));
865        out.push_str(&import(&root, &scope)?);
866    }
867    if export_log {
868        let client = crate::pack()?;
869        let atoms = crate::atoms_lean(&client, &client.workspace())?;
870        let said = export(&root, &scope, &atoms)?;
871        out.push_str(&said);
872        out.push_str(&commit_if_sealed(
873            &root,
874            &scope.name,
875            &said,
876            mode == TrackerGit::Push,
877        ));
878    }
879    Ok(out)
880}
881
882/// Whether user-rule sync is off for `value` of `LJOS_USER_RULES_SYNC`.
883/// Unset is off in tests, so a hook test does not pull the login's tracker.
884/// Anywhere else, unset means on and `off` is the switch.
885fn user_rules_sync_off_value(value: Option<&str>) -> bool {
886    match value {
887        Some("off") => true,
888        Some(_) => false,
889        None => cfg!(test),
890    }
891}
892
893fn user_rules_sync_off() -> bool {
894    user_rules_sync_off_value(std::env::var("LJOS_USER_RULES_SYNC").ok().as_deref())
895}
896
897/// Tracker checkouts that carry user rules. A guessed working directory is
898/// not one: that is the tree the seat was started in, not the repository
899/// that travels between machines. `LJOS_USER_RULES_SYNC=off` names none.
900/// In tests the variable has to be set to something other than `off` before
901/// a tracker is read, so an unset shell does not sync the login's rules.
902#[must_use]
903pub fn user_rule_trackers() -> Vec<PathBuf> {
904    if user_rules_sync_off() {
905        return Vec::new();
906    }
907    if let Some(one) = std::env::var("LJOS_USER_RULES_TRACKER")
908        .ok()
909        .filter(|path| !path.trim().is_empty())
910    {
911        return vec![expand(one.trim())];
912    }
913    let listed = local().repos;
914    if !listed.is_empty() {
915        return listed.iter().map(|path| expand(path)).collect();
916    }
917    if let Some(root) = std::env::var_os("ISSUE_ROOT")
918        .or_else(|| std::env::var_os("VISSUE_ROOT"))
919        .filter(|root| !root.is_empty())
920    {
921        return vec![PathBuf::from(root)];
922    }
923    if let Some(root) = seat_tracker() {
924        return vec![root];
925    }
926    if let Ok(cwd) = std::env::current_dir() {
927        if cwd.join("vissue.toml").is_file() {
928            return vec![cwd];
929        }
930    }
931    Vec::new()
932}
933
934fn seat_tracker() -> Option<PathBuf> {
935    #[derive(serde::Deserialize)]
936    struct Seat {
937        root: Option<String>,
938    }
939    let path = std::env::var_os("VISSUE_CONFIG")
940        .filter(|raw| !raw.is_empty())
941        .map(PathBuf::from)
942        .or_else(|| Some(config_dir().parent()?.join("vissue").join("config.toml")))?;
943    let text = std::fs::read_to_string(path).ok()?;
944    let root = toml::from_str::<Seat>(&text).ok()?.root?;
945    let path = expand(root.trim());
946    path.is_dir().then_some(path)
947}
948
949fn machine_name(host: &str) -> String {
950    let clean: String = host
951        .chars()
952        .map(|c| {
953            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
954                c
955            } else {
956                '-'
957            }
958        })
959        .collect();
960    let clean = clean.trim_matches('.').to_string();
961    if clean.is_empty() {
962        "host".into()
963    } else {
964        clean
965    }
966}
967
968fn looks_like_git(root: &Path) -> bool {
969    let git = root.join(".git");
970    git.is_dir() || git.is_file()
971}
972
973fn repo_rows(root: &Path) -> Vec<crate::user_rules::UserRule> {
974    let dir = root.join(".ljos").join("user-rules");
975    let Ok(read) = std::fs::read_dir(&dir) else {
976        return Vec::new();
977    };
978    let mut paths: Vec<PathBuf> = read
979        .filter_map(|entry| entry.ok())
980        .map(|entry| entry.path())
981        .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
982        .collect();
983    paths.sort();
984    let mut rows = Vec::new();
985    for path in paths {
986        let Ok(text) = std::fs::read_to_string(&path) else {
987            continue;
988        };
989        if let Ok(parsed) = crate::user_rules::parse_rules(&text) {
990            rows.extend(parsed);
991        }
992    }
993    rows
994}
995
996fn git_within(
997    root: &Path,
998    args: &[&str],
999    deadline: std::time::Instant,
1000) -> Result<std::process::Output> {
1001    use std::os::unix::process::CommandExt;
1002    let budget = deadline.saturating_duration_since(std::time::Instant::now());
1003    if budget.is_zero() {
1004        bail!(
1005            "sync: user rules: no time left for git {}",
1006            args.first().copied().unwrap_or("")
1007        );
1008    }
1009    let mut cmd = Command::new("git");
1010    cmd.arg("-C")
1011        .arg(root)
1012        .args(args)
1013        .env("GIT_TERMINAL_PROMPT", "0")
1014        .stdin(Stdio::null())
1015        .stdout(Stdio::piped())
1016        .stderr(Stdio::piped());
1017    // SAFETY: the closure runs in the forked child before exec. setpgid
1018    // makes that child a group leader so a timed-out fetch can be killed
1019    // with the helpers it started. The child has no other threads.
1020    unsafe {
1021        cmd.pre_exec(|| {
1022            libc::setpgid(0, 0);
1023            Ok(())
1024        });
1025    }
1026    let child = cmd.spawn().with_context(|| {
1027        format!(
1028            "sync: user rules: git {}",
1029            args.first().copied().unwrap_or("")
1030        )
1031    })?;
1032    let pid = child.id();
1033    let (tx, rx) = std::sync::mpsc::channel();
1034    std::thread::spawn(move || {
1035        let _ = tx.send(child.wait_with_output());
1036    });
1037    match rx.recv_timeout(budget) {
1038        Ok(Ok(out)) => Ok(out),
1039        Ok(Err(err)) => Err(err).context("sync: user rules: git"),
1040        Err(_) => {
1041            // SAFETY: signal 9 to the child's process group, whose id is
1042            // the child's pid after setpgid, and to the child itself.
1043            unsafe {
1044                libc::kill(-(pid as i32), libc::SIGKILL);
1045                libc::kill(pid as i32, libc::SIGKILL);
1046            }
1047            bail!(
1048                "sync: user rules: the tracker did not answer within {} ms",
1049                budget.as_millis()
1050            )
1051        }
1052    }
1053}
1054
1055fn git_succeeded(out: &std::process::Output) -> bool {
1056    out.status.success()
1057}
1058
1059fn one_line(out: &std::process::Output) -> String {
1060    let text = String::from_utf8_lossy(&out.stderr);
1061    let text = if text.trim().is_empty() {
1062        String::from_utf8_lossy(&out.stdout)
1063    } else {
1064        text
1065    };
1066    text.lines()
1067        .find(|line| !line.trim().is_empty())
1068        .unwrap_or("")
1069        .trim()
1070        .to_string()
1071}
1072
1073fn integrate_remote(root: &Path, deadline: std::time::Instant) -> Result<()> {
1074    let fetched = git_within(root, &["fetch", "-q", "--all"], deadline)?;
1075    if !git_succeeded(&fetched) {
1076        bail!("sync: user rules: fetch failed: {}", one_line(&fetched));
1077    }
1078    let Some(up) = crate::tracker_upstream(root) else {
1079        return Ok(());
1080    };
1081    let forward = git_within(root, &["merge", "-q", "--ff-only", &up], deadline)?;
1082    if git_succeeded(&forward) {
1083        return Ok(());
1084    }
1085    let merged = git_within(root, &["merge", "-q", "--no-edit", &up], deadline)?;
1086    if git_succeeded(&merged) {
1087        return Ok(());
1088    }
1089    let _ = git_within(
1090        root,
1091        &["merge", "--abort"],
1092        std::time::Instant::now() + std::time::Duration::from_secs(2),
1093    );
1094    bail!(
1095        "sync: user rules: could not merge {up}: {}",
1096        one_line(&merged)
1097    )
1098}
1099
1100/// Commit `rels` alone, a deletion included, under the tracker's commit
1101/// lock. False when none of them changed.
1102fn commit_host(
1103    root: &Path,
1104    rels: &[String],
1105    host: &str,
1106    deadline: std::time::Instant,
1107) -> Result<bool> {
1108    let common = git(root, &["rev-parse", "--git-common-dir"])
1109        .ok()
1110        .filter(|out| out.status.success())
1111        .map(|out| {
1112            let rel_git = String::from_utf8_lossy(&out.stdout).trim().to_string();
1113            let path = PathBuf::from(&rel_git);
1114            if path.is_absolute() {
1115                path
1116            } else {
1117                root.join(path)
1118            }
1119        })
1120        .unwrap_or_else(|| root.join(".git"));
1121    let held = crate::CommitLock::acquire(&common.join("ljos-commit.lock"));
1122    let paths: Vec<&str> = rels.iter().map(String::as_str).collect();
1123    let mut add = vec!["add", "-A", "--"];
1124    add.extend(&paths);
1125    let added = git_within(root, &add, deadline)?;
1126    if !git_succeeded(&added) {
1127        bail!(
1128            "sync: user rules: could not stage {}: {}",
1129            rels.join(", "),
1130            one_line(&added)
1131        );
1132    }
1133    let mut diff = vec!["diff", "--cached", "--quiet", "--"];
1134    diff.extend(&paths);
1135    let staged = git_within(root, &diff, deadline)?;
1136    if git_succeeded(&staged) {
1137        drop(held);
1138        return Ok(false);
1139    }
1140    let message = format!("chore(sync): {host} user rules");
1141    let mut commit = vec!["commit", "-q", "--only", "-m", message.as_str(), "--"];
1142    commit.extend(&paths);
1143    let committed = git_within(root, &commit, deadline)?;
1144    drop(held);
1145    if git_succeeded(&committed) {
1146        Ok(true)
1147    } else {
1148        bail!("sync: user rules: commit refused: {}", one_line(&committed))
1149    }
1150}
1151
1152fn push_host(root: &Path, deadline: std::time::Instant) -> Result<()> {
1153    let upstream = git(root, &["rev-parse", "--abbrev-ref", "@{upstream}"])
1154        .ok()
1155        .is_some_and(|out| out.status.success());
1156    let args: &[&str] = if upstream {
1157        &["push", "-q"]
1158    } else {
1159        &["push", "-q", "-u", "origin", "HEAD"]
1160    };
1161    let pushed = git_within(root, args, deadline)?;
1162    if git_succeeded(&pushed) {
1163        return Ok(());
1164    }
1165    let said = format!(
1166        "{}\n{}",
1167        one_line(&pushed),
1168        String::from_utf8_lossy(&pushed.stderr)
1169    );
1170    if !crate::tracker_push::rejected(&said) {
1171        bail!("sync: user rules: push refused: {}", one_line(&pushed));
1172    }
1173    integrate_remote(root, deadline)?;
1174    let again = git_within(root, args, deadline)?;
1175    if git_succeeded(&again) {
1176        Ok(())
1177    } else {
1178        bail!("sync: user rules: push refused: {}", one_line(&again))
1179    }
1180}
1181
1182/// This host's sealed user-rule file in a tracker checkout.
1183fn sealed_rules_rel(name: &str) -> String {
1184    format!(".ljos/user-rules/{name}.json.age")
1185}
1186
1187/// The plaintext file a host wrote before user rules were sealed.
1188fn plain_rules_rel(name: &str) -> String {
1189    format!(".ljos/user-rules/{name}.json")
1190}
1191
1192/// Rows of every sealed user-rule file this machine can open, and how many
1193/// it could not: sealed to other machines, or `age` missing.
1194fn sealed_rows(root: &Path, identity: &Path) -> (Vec<crate::user_rules::UserRule>, usize) {
1195    let dir = root.join(".ljos").join("user-rules");
1196    let Ok(read) = std::fs::read_dir(&dir) else {
1197        return (Vec::new(), 0);
1198    };
1199    let mut paths: Vec<PathBuf> = read
1200        .filter_map(|entry| entry.ok())
1201        .map(|entry| entry.path())
1202        .filter(|path| path.to_string_lossy().ends_with(".json.age"))
1203        .collect();
1204    paths.sort();
1205    let mut rows = Vec::new();
1206    let mut closed = 0;
1207    for path in paths {
1208        let opened = if identity.exists() {
1209            open(&path, identity).ok().flatten()
1210        } else {
1211            None
1212        };
1213        match opened.and_then(|text| crate::user_rules::parse_rules(&text).ok()) {
1214            Some(parsed) => rows.extend(parsed),
1215            None => closed += 1,
1216        }
1217    }
1218    (rows, closed)
1219}
1220
1221/// Merge `rows` into the local file and return the merged rows. Nothing
1222/// read and nothing local leaves the file untouched.
1223fn merge_into_local(
1224    rows: Vec<crate::user_rules::UserRule>,
1225) -> Result<Vec<crate::user_rules::UserRule>> {
1226    let local = crate::user_rules::read_all()?;
1227    if rows.is_empty() {
1228        return Ok(local);
1229    }
1230    let mut all = rows;
1231    all.extend(local);
1232    let merged = crate::user_rules::merge_rows(all);
1233    crate::user_rules::replace_stored(&merged)?;
1234    Ok(merged)
1235}
1236
1237/// Where this machine remembers what it last sealed for a tracker, so an
1238/// unchanged set of rules is not sealed and committed again. It stays on
1239/// this machine: a digest of the rules in the tracker would say something
1240/// about them.
1241fn rules_stamp_path(root: &Path, name: &str) -> PathBuf {
1242    state_dir()
1243        .join("user-rules")
1244        .join(crate::work_id(&root.display().to_string()))
1245        .join(format!("{name}.digest"))
1246}
1247
1248/// What sealing this host's rules did.
1249#[derive(Debug, PartialEq, Eq)]
1250enum Sealed {
1251    Wrote,
1252    Unchanged,
1253    AgeMissing,
1254}
1255
1256fn seal_rules(root: &Path, scope: &Scope, rendered: &str, name: &str) -> Result<Sealed> {
1257    let out = root.join(sealed_rules_rel(name));
1258    let stamp = rules_stamp_path(root, name);
1259    let digest = format!(
1260        "{} {}\n",
1261        crate::work_id(rendered),
1262        crate::work_id(&scope.recipients.join(","))
1263    );
1264    if out.is_file() && std::fs::read_to_string(&stamp).is_ok_and(|have| have == digest) {
1265        return Ok(Sealed::Unchanged);
1266    }
1267    if !age_on_path() {
1268        return Ok(Sealed::AgeMissing);
1269    }
1270    if let Some(parent) = out.parent() {
1271        std::fs::create_dir_all(parent)?;
1272    }
1273    if let Err(err) = seal(rendered, &scope.recipients, &out) {
1274        if age_missing(&err) {
1275            return Ok(Sealed::AgeMissing);
1276        }
1277        return Err(err);
1278    }
1279    if let Some(parent) = stamp.parent() {
1280        std::fs::create_dir_all(parent)?;
1281    }
1282    std::fs::write(&stamp, digest)?;
1283    Ok(Sealed::Wrote)
1284}
1285
1286fn tracked(root: &Path, rel: &str) -> bool {
1287    git(root, &["ls-files", "--error-unmatch", "--", rel]).is_ok_and(|out| out.status.success())
1288}
1289
1290/// Share user rules through a tracker checkout. Nothing leaves this
1291/// machine unless the tracker's `.ljos/sync.toml` names a scope: then the
1292/// tracker is fetched, every host's rules are merged with the local file by
1293/// id, and this machine's rules are sealed with age to the scope's
1294/// recipients, committed as `.ljos/user-rules/<host>.json.age` and pushed.
1295/// A plaintext `<host>.json` this machine wrote before is removed in the
1296/// same commit. Plaintext files from other hosts are still read. With no
1297/// scope, or `LJOS_TRACKER_GIT=off` in passing, the rules already in the
1298/// checkout are read and the tracker is not touched. `LJOS_TRACKER_GIT=commit`
1299/// commits and pushes nothing. `asked` is `ljos prefer --user --sync`,
1300/// which pushes whatever `LJOS_TRACKER_GIT` says. `budget` bounds every
1301/// git call. A fetch that does not return leaves the local file as it was.
1302///
1303/// # Errors
1304///
1305/// The checkout is not git, the scope file does not parse, the fetch did
1306/// not answer, age refused to seal, or the local file cannot be written.
1307pub fn exchange_user_rules(
1308    root: &Path,
1309    host: &str,
1310    budget: std::time::Duration,
1311    asked: bool,
1312) -> Result<String> {
1313    if !root.is_dir() {
1314        bail!("sync: user rules: {} is not a directory", root.display());
1315    }
1316    if !looks_like_git(root) {
1317        bail!("sync: user rules: {} is not a git checkout", root.display());
1318    }
1319    let identity = identity_path();
1320    let mode = tracker_git(asked);
1321    let scope = scope_of_repo(root)?;
1322    let Some(scope) = scope.filter(|_| mode != TrackerGit::Off) else {
1323        let (mut rows, _) = sealed_rows(root, &identity);
1324        rows.extend(repo_rows(root));
1325        let merged = merge_into_local(rows)?;
1326        if !asked {
1327            return Ok(String::new());
1328        }
1329        let live = merged.iter().filter(|rule| !rule.deleted).count();
1330        return Ok(format!(
1331            "sync: user rules: {} names no scope, so they stay on this machine; write .ljos/sync.toml with name and recipients (`ljos sync --key` prints this machine's)\n\
1332             sync: user rules: {live} on this machine\n",
1333            root.display()
1334        ));
1335    };
1336    let deadline = std::time::Instant::now() + budget;
1337    let remote = git(root, &["remote"])
1338        .ok()
1339        .filter(|out| out.status.success())
1340        .is_some_and(|out| !String::from_utf8_lossy(&out.stdout).trim().is_empty());
1341    if remote {
1342        integrate_remote(root, deadline)?;
1343    }
1344    let (mut rows, closed) = sealed_rows(root, &identity);
1345    rows.extend(repo_rows(root));
1346    let merged = merge_into_local(rows)?;
1347    let mut out = String::new();
1348    if remote {
1349        out.push_str("sync: user rules: fetched\n");
1350    }
1351    if closed > 0 {
1352        out.push_str(&format!(
1353            "sync: user rules: {closed} sealed file{} not opened on this machine\n",
1354            if closed == 1 { "" } else { "s" }
1355        ));
1356    }
1357    let live = merged.iter().filter(|rule| !rule.deleted).count();
1358    if merged.is_empty() && !root.join(plain_rules_rel(&machine_name(host))).exists() {
1359        out.push_str("sync: user rules: 0 on this machine\n");
1360        return Ok(out);
1361    }
1362    let name = machine_name(host);
1363    match seal_rules(
1364        root,
1365        &scope,
1366        &crate::user_rules::render_rules(&merged)?,
1367        &name,
1368    )? {
1369        Sealed::AgeMissing => {
1370            out.push_str("sync: user rules: age is not installed, so they were not sealed or sent; install age to sync them\n");
1371            out.push_str(&format!("sync: user rules: {live} on this machine\n"));
1372            return Ok(out);
1373        }
1374        Sealed::Wrote | Sealed::Unchanged => {}
1375    }
1376    let mut rels = vec![sealed_rules_rel(&name)];
1377    let plain = plain_rules_rel(&name);
1378    if root.join(&plain).exists() {
1379        std::fs::remove_file(root.join(&plain))?;
1380    }
1381    if tracked(root, &plain) {
1382        rels.push(plain);
1383    }
1384    let committed = commit_host(root, &rels, &name, deadline)?;
1385    match mode {
1386        TrackerGit::Push if remote && (committed || commits_to_push(root)) => {
1387            push_host(root, deadline)?;
1388            out.push_str("sync: user rules: sealed and pushed\n");
1389        }
1390        TrackerGit::Commit if committed => {
1391            out.push_str(
1392                "sync: user rules: sealed and committed; not pushed (LJOS_TRACKER_GIT=commit)\n",
1393            );
1394        }
1395        _ => {}
1396    }
1397    out.push_str(&format!("sync: user rules: {live} on this machine\n"));
1398    Ok(out)
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403    use super::*;
1404
1405    fn atom(id: &str, text: &str, entities: &[&str]) -> Value {
1406        serde_json::json!({"id": id, "text": text, "kind": "lesson",
1407            "entities": entities, "embedding": [0.1, 0.2]})
1408    }
1409
1410    #[test]
1411    fn an_unset_user_rules_sync_is_off_in_tests() {
1412        assert!(user_rules_sync_off_value(None));
1413        assert!(user_rules_sync_off_value(Some("off")));
1414        assert!(!user_rules_sync_off_value(Some("on")));
1415    }
1416
1417    #[test]
1418    fn a_repository_with_no_scope_is_named_only_when_sync_is_asked_for() {
1419        let _scratch = tempfile::tempdir().unwrap();
1420        let root = _scratch.path().to_path_buf();
1421        let asked = sync_one(&root, true, false, true).unwrap();
1422        assert!(asked.contains("names no scope"), "{asked}");
1423        assert_eq!(sync_one(&root, true, false, false).unwrap(), "");
1424        assert_eq!(sync_one(&root, false, true, false).unwrap(), "");
1425        let _ = std::fs::remove_dir(&root);
1426    }
1427
1428    /// A tracker with no remote used to say the log was pushed to nothing.
1429    #[test]
1430    fn a_log_with_no_remote_says_how_to_set_one() {
1431        let _g = crate::test_env_lock();
1432        let dir = tempfile::tempdir().unwrap();
1433        let root = dir.path();
1434        let git = |args: &[&str]| {
1435            let out = std::process::Command::new("git")
1436                .arg("-C")
1437                .arg(root)
1438                .args(args)
1439                .output()
1440                .unwrap();
1441            assert!(
1442                out.status.success(),
1443                "git {args:?}: {}",
1444                String::from_utf8_lossy(&out.stderr)
1445            );
1446        };
1447        git(&["init", "-q"]);
1448        git(&["config", "user.email", "seat@example.invalid"]);
1449        git(&["config", "user.name", "seat"]);
1450        git(&["config", "core.hooksPath", "/dev/null"]);
1451        let atoms = root.join(".ljos/atoms");
1452        std::fs::create_dir_all(&atoms).unwrap();
1453        let name = host();
1454        std::fs::write(atoms.join(format!("{name}.jsonl.age")), "sealed\n").unwrap();
1455        std::fs::write(atoms.join(format!("{name}.digest")), "digest\n").unwrap();
1456        let said = commit_log(root, "surf", true);
1457        assert!(said.contains("no remote is configured"), "{said}");
1458        assert!(said.contains("remote add origin"), "{said}");
1459        assert!(said.contains("git push -u origin HEAD"), "{said}");
1460        assert!(!said.contains("pushed to nothing"), "{said}");
1461        assert_eq!(commit_log(root, "surf", true), "");
1462    }
1463
1464    /// Atoms that did not change still push a commit already waiting,
1465    /// such as the tracker's merge attributes.
1466    #[test]
1467    fn unchanged_atoms_still_push_a_pending_commit() {
1468        let _g = crate::test_env_lock();
1469        let dir = tempfile::tempdir().unwrap();
1470        let remote = dir.path().join("remote.git");
1471        let root = dir.path().join("local");
1472        let git = |cwd: &std::path::Path, args: &[&str]| {
1473            let out = std::process::Command::new("git")
1474                .arg("-C")
1475                .arg(cwd)
1476                .args(args)
1477                .output()
1478                .unwrap();
1479            assert!(
1480                out.status.success(),
1481                "git {args:?}: {}",
1482                String::from_utf8_lossy(&out.stderr)
1483            );
1484        };
1485        std::fs::create_dir_all(&remote).unwrap();
1486        git(&remote, &["init", "-q", "--bare"]);
1487        std::fs::create_dir_all(&root).unwrap();
1488        git(&root, &["init", "-q"]);
1489        git(&root, &["config", "user.email", "seat@example.invalid"]);
1490        git(&root, &["config", "user.name", "seat"]);
1491        git(&root, &["config", "core.hooksPath", "/dev/null"]);
1492        let atoms = root.join(".ljos/atoms");
1493        std::fs::create_dir_all(&atoms).unwrap();
1494        let name = host();
1495        std::fs::write(atoms.join(format!("{name}.jsonl.age")), "sealed\n").unwrap();
1496        std::fs::write(atoms.join(format!("{name}.digest")), "digest\n").unwrap();
1497        std::fs::write(root.join("README"), "seed\n").unwrap();
1498        git(&root, &["add", "."]);
1499        git(&root, &["commit", "-q", "-m", "seed"]);
1500        git(
1501            &root,
1502            &["remote", "add", "origin", remote.to_str().unwrap()],
1503        );
1504        git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
1505        std::fs::write(root.join(".gitattributes"), "*.org merge=union\n").unwrap();
1506        git(&root, &["add", ".gitattributes"]);
1507        git(
1508            &root,
1509            &[
1510                "commit",
1511                "-q",
1512                "-m",
1513                "chore(tracker): union org files and re-export atoms on merge",
1514            ],
1515        );
1516        let said = commit_log(&root, "surf", true);
1517        assert!(
1518            said.contains("pushed pending commits"),
1519            "an unchanged atom log must still push: {said}"
1520        );
1521        let remote_log = std::process::Command::new("git")
1522            .arg("-C")
1523            .arg(&remote)
1524            .args(["log", "--format=%s"])
1525            .output()
1526            .unwrap();
1527        let subjects = String::from_utf8_lossy(&remote_log.stdout);
1528        assert!(
1529            subjects.contains("union org files"),
1530            "the pending commit was not pushed: {subjects}"
1531        );
1532        assert_eq!(commit_log(&root, "surf", true), "");
1533    }
1534
1535    #[test]
1536    fn tracker_git_reads_as_the_ticket_commit_reads_it() {
1537        assert_eq!(tracker_git_value(None), TrackerGit::Push);
1538        assert_eq!(tracker_git_value(Some("")), TrackerGit::Push);
1539        assert_eq!(tracker_git_value(Some("commit")), TrackerGit::Commit);
1540        for off in ["off", "0", "false"] {
1541            assert_eq!(tracker_git_value(Some(off)), TrackerGit::Off, "{off}");
1542        }
1543        assert_eq!(tracker_git(true), TrackerGit::Push);
1544    }
1545
1546    /// `LJOS_TRACKER_GIT=commit` kept the ticket commit local, and the sync
1547    /// at finish pushed it anyway with the log.
1548    #[test]
1549    fn a_commit_only_tracker_commits_the_log_and_pushes_nothing() {
1550        let _g = crate::test_env_lock();
1551        let dir = tempfile::tempdir().unwrap();
1552        let remote = dir.path().join("remote.git");
1553        let root = dir.path().join("local");
1554        let git = |cwd: &std::path::Path, args: &[&str]| {
1555            let out = std::process::Command::new("git")
1556                .arg("-C")
1557                .arg(cwd)
1558                .args(args)
1559                .output()
1560                .unwrap();
1561            assert!(
1562                out.status.success(),
1563                "git {args:?}: {}",
1564                String::from_utf8_lossy(&out.stderr)
1565            );
1566            String::from_utf8_lossy(&out.stdout).into_owned()
1567        };
1568        std::fs::create_dir_all(&remote).unwrap();
1569        git(&remote, &["init", "-q", "--bare"]);
1570        std::fs::create_dir_all(&root).unwrap();
1571        git(&root, &["init", "-q"]);
1572        git(&root, &["config", "user.email", "seat@example.invalid"]);
1573        git(&root, &["config", "user.name", "seat"]);
1574        git(&root, &["config", "core.hooksPath", "/dev/null"]);
1575        std::fs::write(root.join("README"), "seed\n").unwrap();
1576        git(&root, &["add", "."]);
1577        git(&root, &["commit", "-q", "-m", "seed"]);
1578        git(
1579            &root,
1580            &["remote", "add", "origin", remote.to_str().unwrap()],
1581        );
1582        git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
1583        std::fs::write(root.join("issues.org"), "* TODO kept local\n").unwrap();
1584        git(&root, &["add", "issues.org"]);
1585        git(&root, &["commit", "-q", "-m", "chore(issues): kept local"]);
1586        let atoms = root.join(".ljos/atoms");
1587        std::fs::create_dir_all(&atoms).unwrap();
1588        let name = host();
1589        std::fs::write(atoms.join(format!("{name}.jsonl.age")), "sealed\n").unwrap();
1590        std::fs::write(atoms.join(format!("{name}.digest")), "digest\n").unwrap();
1591        let said = commit_log(&root, "surf", false);
1592        assert!(
1593            said.contains("not pushed (LJOS_TRACKER_GIT=commit)"),
1594            "{said}"
1595        );
1596        assert!(git(&root, &["log", "-1", "--format=%s"]).contains("chore(sync)"));
1597        assert_eq!(commit_log(&root, "surf", false), "");
1598        let subjects = git(&remote, &["log", "--format=%s"]);
1599        assert!(!subjects.contains("kept local"), "{subjects}");
1600        assert!(!subjects.contains("chore(sync)"), "{subjects}");
1601    }
1602
1603    /// `LJOS_TRACKER_GIT=off` leaves the tracker as it is in passing: no
1604    /// attributes, no commit, one line when a scope is set up.
1605    #[test]
1606    fn an_off_tracker_is_left_alone_in_passing() {
1607        let _g = crate::test_env_lock();
1608        let before = std::env::var_os("LJOS_TRACKER_GIT");
1609        std::env::set_var("LJOS_TRACKER_GIT", "off");
1610        let dir = tempfile::tempdir().unwrap();
1611        let root = dir.path();
1612        let init = std::process::Command::new("git")
1613            .arg("-C")
1614            .arg(root)
1615            .args(["init", "-q"])
1616            .output()
1617            .unwrap();
1618        assert!(init.status.success());
1619        let quiet = sync_one(root, true, true, false).unwrap();
1620        std::fs::create_dir_all(root.join(".ljos")).unwrap();
1621        std::fs::write(
1622            root.join(".ljos/sync.toml"),
1623            "name = \"surf\"\nrecipients = []\n",
1624        )
1625        .unwrap();
1626        let said = sync_one(root, true, true, false).unwrap();
1627        match before {
1628            Some(value) => std::env::set_var("LJOS_TRACKER_GIT", value),
1629            None => std::env::remove_var("LJOS_TRACKER_GIT"),
1630        }
1631        assert_eq!(quiet, "");
1632        assert!(said.contains("sync: off (LJOS_TRACKER_GIT)"), "{said}");
1633        assert!(!root.join(".gitattributes").exists());
1634        assert!(!root.join(".ljos/atoms").exists());
1635    }
1636
1637    /// Finish's sync used to print an os error when `age` was not installed.
1638    #[test]
1639    fn a_missing_age_skips_the_seal_without_an_os_error() {
1640        let _hide = hide_age();
1641        let root = tempfile::tempdir().unwrap();
1642        let scope: Scope =
1643            toml::from_str("name = \"surf\"\nrecipients = [\"age1test\"]\n").unwrap();
1644        let said = export(root.path(), &scope, &[atom("a", "kept", &[])]).unwrap();
1645        assert!(said.contains("age is not installed"), "{said}");
1646        assert!(said.contains("install age"), "{said}");
1647        assert!(!said.contains("os error"), "{said}");
1648        assert!(!said.to_lowercase().contains("not found"), "{said}");
1649        assert!(!log_dir(root.path())
1650            .join(format!("{}.jsonl.age", host()))
1651            .is_file());
1652        assert_eq!(commit_if_sealed(root.path(), "surf", &said, true), "");
1653    }
1654
1655    #[test]
1656    fn a_log_carries_own_atoms_of_its_scope_without_vectors() {
1657        let atoms = vec![
1658            atom("b", "second", &["seat:x"]),
1659            atom("a", "first", &[]),
1660            atom("c", "personal", &["scope:personal"]),
1661            atom("d", "imported", &["sync:otherhost"]),
1662        ];
1663        let plain = log_plaintext(&atoms, "surf", "surf");
1664        let lines: Vec<Value> = plain
1665            .lines()
1666            .map(|l| serde_json::from_str(l).unwrap())
1667            .collect();
1668        assert_eq!(lines.len(), 2, "{plain}");
1669        assert_eq!(lines[0]["id"], "a");
1670        assert_eq!(lines[1]["id"], "b");
1671        assert!(lines.iter().all(|a| a.get("embedding").is_none()));
1672        assert_eq!(log_plaintext(&atoms, "personal", "surf").lines().count(), 1);
1673    }
1674
1675    #[test]
1676    fn a_project_named_in_the_scope_file_takes_its_own_scope() {
1677        let scope: Scope = toml::from_str(
1678            "name = \"personal\"\nrecipients = [\"age1x\"]\n[projects]\ntools = \"shared\"\n",
1679        )
1680        .unwrap();
1681        assert_eq!(scope.for_project("tools"), "shared");
1682        assert_eq!(scope.for_project("garden"), "personal");
1683        let bare: Scope = toml::from_str("name = \"shared\"\nrecipients = []\n").unwrap();
1684        assert!(bare.projects.is_empty());
1685    }
1686
1687    #[test]
1688    fn an_import_tags_the_sender_and_names_what_it_retired() {
1689        let plain = format!(
1690            "{}\n{}\n",
1691            atom("a", "kept", &["seat:x"]),
1692            atom("b", "new", &[])
1693        );
1694        let before: BTreeSet<String> = [crate::work_id("kept"), crate::work_id("dropped")].into();
1695        let got = incoming(&plain, "rglat", &before, &BTreeSet::new());
1696        assert_eq!(got.post.len(), 2);
1697        let again = incoming(&plain, "rglat", &before, &[crate::work_id("kept")].into());
1698        assert_eq!(
1699            again.post.len(),
1700            1,
1701            "a text already held is not posted again"
1702        );
1703        assert_eq!(again.post[0]["text"], "new");
1704        assert!(got.post.iter().all(|a| a.get("id").is_none()));
1705        assert!(got.post.iter().all(|a| a["entities"]
1706            .as_array()
1707            .unwrap()
1708            .iter()
1709            .any(|e| e == "sync:rglat")));
1710        assert!(got.post.iter().all(|a| a["origin"] == "peer"));
1711        assert_eq!(got.retired, vec![crate::work_id("dropped")]);
1712        let claimed = "{\"id\":\"c\",\"text\":\"claimed\",\"kind\":\"lesson\",\"origin\":\"user-declared\",\"entities\":[]}\n";
1713        let overwrote = incoming(claimed, "rglat", &BTreeSet::new(), &BTreeSet::new());
1714        assert_eq!(overwrote.post[0]["origin"], "peer");
1715    }
1716
1717    #[test]
1718    fn a_sealed_log_opens_for_a_recipient_and_not_for_another() {
1719        let _g = crate::test_env_lock();
1720        let dir = tempfile::tempdir().unwrap();
1721        let key = |name: &str| {
1722            let path = dir.path().join(name);
1723            let made = Command::new("age-keygen")
1724                .arg("-o")
1725                .arg(&path)
1726                .output()
1727                .unwrap();
1728            assert!(made.status.success(), "age-keygen must be installed");
1729            let public = Command::new("age-keygen")
1730                .arg("-y")
1731                .arg(&path)
1732                .output()
1733                .unwrap();
1734            (
1735                path,
1736                String::from_utf8_lossy(&public.stdout).trim().to_string(),
1737            )
1738        };
1739        let (mine, my_public) = key("mine.key");
1740        let (other, _) = key("other.key");
1741        let out = dir.path().join("host.jsonl.age");
1742        seal("{\"text\":\"x\"}\n", &[my_public], &out).unwrap();
1743        assert_eq!(
1744            open(&out, &mine).unwrap().as_deref(),
1745            Some("{\"text\":\"x\"}\n")
1746        );
1747        assert_eq!(open(&out, &other).unwrap(), None);
1748        assert!(seal("x", &[], &out).is_err(), "no recipients is refused");
1749    }
1750
1751    #[test]
1752    fn an_atoms_scope_is_its_entity_or_the_default() {
1753        assert_eq!(
1754            atom_scope(&atom("a", "t", &["scope:personal"]), "surf"),
1755            "personal"
1756        );
1757        assert_eq!(atom_scope(&atom("a", "t", &["seat:x"]), "surf"), "surf");
1758    }
1759}