1use 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#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
28pub struct Scope {
29 pub name: String,
31 pub recipients: Vec<String>,
33 #[serde(default)]
37 pub projects: BTreeMap<String, String>,
38}
39
40impl Scope {
41 #[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#[derive(Debug, Clone, Default, serde::Deserialize)]
53struct Local {
54 #[serde(default)]
55 default_scope: Option<String>,
56 #[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#[must_use]
80pub fn sender_entity(host: &str) -> String {
81 format!("sync:{host}")
82}
83
84#[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#[must_use]
113pub fn identity_path() -> PathBuf {
114 config_dir().join("age.key")
115}
116
117pub 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
156pub 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#[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#[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#[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#[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
229fn 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
261fn 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
285fn 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 static AGE_ON_PATH: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
297}
298
299fn 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#[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
330pub 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#[derive(Debug, Default, PartialEq)]
381pub struct Incoming {
382 pub post: Vec<Value>,
383 pub retired: Vec<String>,
384}
385
386#[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
458pub 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 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#[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
570fn 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
585fn 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
596fn 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
614fn 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
645fn 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 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 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 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
723fn 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 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
765pub fn sync_repo(pull_import: bool, export_log: bool) -> Result<String> {
774 sync_repos(pull_import, export_log, true)
775}
776
777pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805enum TrackerGit {
806 Push,
808 Commit,
810 Off,
812}
813
814fn 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
824fn 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 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 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
882fn 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#[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 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 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
1100fn 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
1182fn sealed_rules_rel(name: &str) -> String {
1184 format!(".ljos/user-rules/{name}.json.age")
1185}
1186
1187fn plain_rules_rel(name: &str) -> String {
1189 format!(".ljos/user-rules/{name}.json")
1190}
1191
1192fn 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
1221fn 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
1237fn 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#[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
1290pub 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 #[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 #[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 #[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 #[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 #[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}