1use std::fs;
2use std::collections::BTreeMap;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8
9use crate::lore::{AtomState, LoreAtom, LoreKind, Workspace, WorkspaceState};
10
11#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
12pub struct CommitTrailer {
13 pub key: String,
14 pub value: String,
15}
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct CommitMessage {
19 pub subject: String,
20 pub body: Option<String>,
21 pub trailers: Vec<CommitTrailer>,
22}
23
24#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct HistoricalDecision {
26 pub commit_hash: String,
27 pub subject: String,
28 pub trailer: CommitTrailer,
29 pub file_path: PathBuf,
30}
31
32pub fn discover_repository(path: impl AsRef<Path>) -> Result<PathBuf> {
33 let output = Command::new("git")
34 .arg("-C")
35 .arg(path.as_ref())
36 .arg("rev-parse")
37 .arg("--show-toplevel")
38 .output()
39 .with_context(|| format!("failed to execute git for {}", path.as_ref().display()))?;
40
41 if !output.status.success() {
42 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
43 return Err(anyhow::anyhow!(
44 "failed to discover git repository from {}: {}",
45 path.as_ref().display(),
46 stderr
47 ));
48 }
49
50 let root = String::from_utf8(output.stdout)
51 .with_context(|| format!("git returned invalid utf-8 for {}", path.as_ref().display()))?;
52 let root = PathBuf::from(root.trim());
53 Ok(fs::canonicalize(&root).unwrap_or(root))
54}
55
56pub fn repository_root(repository: &Path) -> PathBuf {
57 repository.to_path_buf()
58}
59
60pub fn render_commit_trailers(atoms: &[LoreAtom]) -> String {
61 atoms
62 .iter()
63 .map(|atom| format!("{}: [{}] {}", trailer_key(&atom.kind), atom.id, atom.title))
64 .collect::<Vec<_>>()
65 .join("\n")
66}
67
68pub fn build_commit_message(subject: impl AsRef<str>, atoms: &[LoreAtom]) -> String {
69 let subject = subject.as_ref().trim();
70 let trailers = render_commit_trailers(atoms);
71
72 if trailers.is_empty() {
73 subject.to_string()
74 } else {
75 format!("{subject}\n\n{trailers}")
76 }
77}
78
79pub fn commit_lore_message(
80 repository_root: impl AsRef<Path>,
81 message: impl AsRef<str>,
82 allow_empty: bool,
83) -> Result<String> {
84 let repository_root = repository_root.as_ref();
85 let mut command = Command::new("git");
86 command
87 .arg("-C")
88 .arg(repository_root)
89 .arg("-c")
90 .arg("user.name=Git-Lore")
91 .arg("-c")
92 .arg("user.email=git-lore@localhost")
93 .arg("commit")
94 .arg("--cleanup=verbatim")
95 .arg("-F")
96 .arg("-");
97
98 if allow_empty {
99 command.arg("--allow-empty");
100 }
101
102 let mut child = command
103 .stdin(std::process::Stdio::piped())
104 .stdout(std::process::Stdio::piped())
105 .stderr(std::process::Stdio::piped())
106 .spawn()
107 .with_context(|| format!("failed to spawn git commit in {}", repository_root.display()))?;
108
109 if let Some(stdin) = child.stdin.as_mut() {
110 use std::io::Write;
111 stdin
112 .write_all(message.as_ref().as_bytes())
113 .with_context(|| format!("failed to write commit message in {}", repository_root.display()))?;
114 }
115
116 let output = child
117 .wait_with_output()
118 .with_context(|| format!("failed to finish git commit in {}", repository_root.display()))?;
119
120 if !output.status.success() {
121 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
122 return Err(anyhow::anyhow!(
123 "git commit failed in {}: {}",
124 repository_root.display(),
125 stderr
126 ));
127 }
128
129 let hash_output = Command::new("git")
130 .arg("-C")
131 .arg(repository_root)
132 .arg("rev-parse")
133 .arg("HEAD")
134 .output()
135 .with_context(|| format!("failed to read commit hash in {}", repository_root.display()))?;
136
137 if !hash_output.status.success() {
138 let stderr = String::from_utf8_lossy(&hash_output.stderr).trim().to_string();
139 return Err(anyhow::anyhow!(
140 "git rev-parse failed in {}: {}",
141 repository_root.display(),
142 stderr
143 ));
144 }
145
146 let hash = String::from_utf8(hash_output.stdout)
147 .with_context(|| format!("git returned invalid utf-8 in {}", repository_root.display()))?;
148 Ok(hash.trim().to_string())
149}
150
151pub fn write_lore_ref(repository_root: impl AsRef<Path>, atom: &LoreAtom, source_commit: &str) -> Result<()> {
152 let repository_root = repository_root.as_ref();
153 run_git(
154 repository_root,
155 &[
156 "update-ref",
157 &format!("refs/lore/accepted/{}", atom.id),
158 source_commit,
159 ],
160 )?;
161
162 let mut note_atoms = run_git_output(
163 repository_root,
164 &["notes", "--ref=refs/notes/lore", "show", source_commit],
165 )
166 .ok()
167 .map(|note| parse_note_atoms(¬e))
168 .unwrap_or_default();
169 note_atoms.insert(atom.id.clone(), atom.clone());
170
171 let note = serde_json::to_string_pretty(¬e_atoms)?;
172 run_git(
173 repository_root,
174 &[
175 "notes",
176 "--ref=refs/notes/lore",
177 "add",
178 "-f",
179 "-m",
180 ¬e,
181 source_commit,
182 ],
183 )?;
184
185 Ok(())
186}
187
188pub fn list_lore_refs(repository_root: impl AsRef<Path>) -> Result<Vec<(String, String)>> {
189 let output = run_git_output(
190 repository_root.as_ref(),
191 &[
192 "for-each-ref",
193 "refs/lore/accepted",
194 "--format=%(refname) %(objectname)",
195 ],
196 )?;
197
198 Ok(output
199 .lines()
200 .filter_map(|line| line.split_once(' '))
201 .map(|(name, hash)| (name.to_string(), hash.to_string()))
202 .collect())
203}
204
205fn load_lore_atom_from_note(
206 repository_root: &Path,
207 source_commit: &str,
208 atom_id: &str,
209) -> Option<LoreAtom> {
210 let note = run_git_output(
211 repository_root,
212 &["notes", "--ref=refs/notes/lore", "show", source_commit],
213 )
214 .ok()?;
215
216 let mut note_atoms = parse_note_atoms(¬e);
217 let mut atom = note_atoms.remove(atom_id)?;
218 atom.id = atom_id.to_string();
219 atom.state = AtomState::Accepted;
220 Some(atom)
221}
222
223fn load_lore_atom_from_commit_trailer(
224 repository_root: &Path,
225 source_commit: &str,
226 atom_id: &str,
227) -> Option<LoreAtom> {
228 let message = run_git_output(repository_root, &["show", "-s", "--format=%B", source_commit]).ok()?;
229 let parsed = parse_commit_message(message.trim());
230
231 for trailer in parsed.trailers {
232 let Some((trailer_atom_id, title)) = parse_trailer_atom_value(&trailer.value) else {
233 continue;
234 };
235
236 if trailer_atom_id != atom_id {
237 continue;
238 }
239
240 let kind = match trailer.key.as_str() {
241 "Lore-Decision" => LoreKind::Decision,
242 "Lore-Assumption" => LoreKind::Assumption,
243 "Lore-Open-Question" => LoreKind::OpenQuestion,
244 "Lore-Signal" => LoreKind::Signal,
245 _ => LoreKind::Decision,
246 };
247
248 return Some(LoreAtom {
249 id: atom_id.to_string(),
250 kind,
251 state: AtomState::Accepted,
252 title,
253 body: Some(format!("Recovered from commit trailer {source_commit}")),
254 scope: Some(format!("sync:{}", atom_id)),
255 path: None,
256 validation_script: None,
257 created_unix_seconds: 0,
258 });
259 }
260
261 None
262}
263
264fn parse_trailer_atom_value(value: &str) -> Option<(&str, String)> {
265 let trimmed = value.trim();
266 let id_end = trimmed.find(']')?;
267 if !trimmed.starts_with('[') || id_end <= 1 {
268 return None;
269 }
270
271 let atom_id = &trimmed[1..id_end];
272 let title = trimmed[id_end + 1..].trim().to_string();
273 if title.is_empty() {
274 return None;
275 }
276
277 Some((atom_id, title))
278}
279
280fn parse_note_atoms(note: &str) -> BTreeMap<String, LoreAtom> {
281 let trimmed = note.trim();
282 if let Ok(map) = serde_json::from_str::<BTreeMap<String, LoreAtom>>(trimmed) {
283 return map;
284 }
285
286 if let Ok(atom) = serde_json::from_str::<LoreAtom>(trimmed) {
287 let mut map = BTreeMap::new();
288 map.insert(atom.id.clone(), atom);
289 return map;
290 }
291
292 BTreeMap::new()
293}
294
295pub fn collect_recent_decisions_for_path(
296 repository_root: impl AsRef<Path>,
297 file_path: impl AsRef<Path>,
298 limit: usize,
299) -> Result<Vec<HistoricalDecision>> {
300 let file_path = file_path.as_ref().to_path_buf();
301 let file_path_arg = file_path.to_string_lossy().to_string();
302 let output = run_git_output(
303 repository_root.as_ref(),
304 &[
305 "log",
306 "--follow",
307 "--format=%H%x1f%B%x1e",
308 "--",
309 file_path_arg.as_str(),
310 ],
311 )?;
312
313 let mut decisions = Vec::new();
314
315 for record in output.split('\x1e') {
316 let record = record.trim();
317 if record.is_empty() {
318 continue;
319 }
320
321 let Some((commit_hash, message)) = record.split_once('\x1f') else {
322 continue;
323 };
324
325 let parsed = parse_commit_message(message.trim());
326 for trailer in parsed.trailers.into_iter().filter(|trailer| trailer.key == "Lore-Decision") {
327 decisions.push(HistoricalDecision {
328 commit_hash: commit_hash.trim().to_string(),
329 subject: parsed.subject.clone(),
330 trailer,
331 file_path: file_path.clone(),
332 });
333
334 if decisions.len() >= limit {
335 return Ok(decisions);
336 }
337 }
338 }
339
340 Ok(decisions)
341}
342
343pub fn install_git_lore_integration(repository_root: impl AsRef<Path>) -> Result<()> {
344 let repository_root = repository_root.as_ref();
345 let git_dir = git_dir(repository_root)?;
346 let hooks_dir = git_dir.join("hooks");
347 fs::create_dir_all(&hooks_dir)?;
348
349 run_git(
350 repository_root,
351 &["config", "merge.lore.name", "Git-Lore Reasoning Merger"],
352 )?;
353 run_git(
354 repository_root,
355 &["config", "merge.lore.driver", "git-lore merge %O %A %B"],
356 )?;
357
358 write_hook(
359 &hooks_dir.join("pre-commit"),
360 "#!/bin/sh\nset -eu\nROOT=\"$(git rev-parse --show-toplevel)\"\nif [ -x \"$ROOT/git-lore\" ]; then\n \"$ROOT/git-lore\" validate .\nelse\n git-lore validate .\nfi\n",
361 )?;
362 write_hook(
363 &hooks_dir.join("post-checkout"),
364 "#!/bin/sh\nset -eu\nROOT=\"$(git rev-parse --show-toplevel)\"\nif [ -x \"$ROOT/git-lore\" ]; then\n \"$ROOT/git-lore\" sync .\nelse\n git-lore sync .\nfi\n",
365 )?;
366
367 Ok(())
368}
369
370pub fn validate_workspace_against_git(repository_root: impl AsRef<Path>, workspace: &Workspace) -> Result<Vec<String>> {
371 let repository_root = repository_root.as_ref();
372 let mut issues = Vec::new();
373
374 for issue in workspace.sanitize_report()? {
375 issues.push(format!(
376 "sensitive content in {}.{}: {}",
377 issue.atom_id, issue.field, issue.reason
378 ));
379 }
380
381 let state = workspace.load_state()?.atoms;
382 for violation in workspace.scan_prism_hard_locks(&state)? {
383 issues.push(format!("{} ({})", violation.message, violation.atom_ids.join(", ")));
384 }
385
386 for issue in workspace.validation_report()? {
387 issues.push(format!(
388 "validation failed for {}: {}",
389 issue.atom_id, issue.reason
390 ));
391 }
392
393 for (refname, objectname) in list_lore_refs(repository_root)? {
394 if refname.is_empty() || objectname.is_empty() {
395 issues.push("empty lore ref entry detected".to_string());
396 }
397 }
398
399 Ok(issues)
400}
401
402pub fn sync_workspace_from_git_history(
403 repository_root: impl AsRef<Path>,
404 workspace: &Workspace,
405) -> Result<Vec<LoreAtom>> {
406 let repository_root = repository_root.as_ref();
407 let state = workspace.load_state()?;
408 let mut atoms_by_id = BTreeMap::<String, LoreAtom>::new();
409
410 for atom in state.atoms {
411 upsert_atom(&mut atoms_by_id, atom);
412 }
413
414 for (refname, objectname) in list_lore_refs(repository_root)? {
415 if let Some(atom_id) = refname.rsplit('/').next() {
416 let candidate = load_lore_atom_from_note(repository_root, &objectname, atom_id)
417 .or_else(|| {
418 load_lore_atom_from_commit_trailer(repository_root, &objectname, atom_id)
419 })
420 .unwrap_or_else(|| LoreAtom {
421 id: atom_id.to_string(),
422 kind: LoreKind::Decision,
423 state: AtomState::Accepted,
424 title: format!("Synced accepted lore from {objectname}"),
425 body: Some(format!("Restored from {refname}")),
426 scope: Some(format!("sync:{}", atom_id)),
427 path: None,
428 validation_script: None,
429 created_unix_seconds: 0,
430 });
431
432 if let Some(existing) = atoms_by_id.get_mut(atom_id) {
433 if should_replace_with_candidate(existing, &candidate) {
434 *existing = candidate;
435 }
436 } else {
437 atoms_by_id.insert(atom_id.to_string(), candidate);
438 }
439 }
440 }
441
442 let atoms = atoms_by_id.into_values().collect::<Vec<_>>();
443
444 workspace.set_state(&WorkspaceState {
445 version: state.version,
446 atoms: atoms.clone(),
447 })?;
448 Ok(atoms)
449}
450
451fn upsert_atom(atoms_by_id: &mut BTreeMap<String, LoreAtom>, atom: LoreAtom) {
452 match atoms_by_id.get(&atom.id) {
453 Some(existing) if !should_replace_with_candidate(existing, &atom) => {}
454 _ => {
455 atoms_by_id.insert(atom.id.clone(), atom);
456 }
457 }
458}
459
460fn should_replace_with_candidate(existing: &LoreAtom, candidate: &LoreAtom) -> bool {
461 if candidate.created_unix_seconds > existing.created_unix_seconds {
462 return true;
463 }
464
465 if candidate.created_unix_seconds < existing.created_unix_seconds {
466 return false;
467 }
468
469 atom_preference_score(candidate) > atom_preference_score(existing)
470}
471
472fn atom_preference_score(atom: &LoreAtom) -> u8 {
473 let mut score = 0u8;
474 if atom.path.is_some() {
475 score += 3;
476 }
477 if atom.scope.is_some() && !is_synced_placeholder(atom) {
478 score += 2;
479 }
480 if atom.body.is_some() {
481 score += 2;
482 }
483 if atom.validation_script.is_some() {
484 score += 1;
485 }
486 if !is_synced_placeholder(atom) {
487 score += 1;
488 }
489 score
490}
491
492fn is_synced_placeholder(atom: &LoreAtom) -> bool {
493 atom.created_unix_seconds == 0
494 && atom.path.is_none()
495 && atom.title.starts_with("Synced accepted lore from ")
496}
497
498fn git_dir(repository_root: &Path) -> Result<PathBuf> {
499 let output = run_git_output(repository_root, &["rev-parse", "--git-dir"])?;
500 let git_dir = PathBuf::from(output.trim());
501 if git_dir.is_absolute() {
502 Ok(git_dir)
503 } else {
504 Ok(repository_root.join(git_dir))
505 }
506}
507
508fn write_hook(path: &Path, content: &str) -> Result<()> {
509 fs::write(path, content).with_context(|| format!("failed to write hook {}", path.display()))?;
510
511 #[cfg(unix)]
512 {
513 use std::os::unix::fs::PermissionsExt;
514 let mut permissions = fs::metadata(path)?.permissions();
515 permissions.set_mode(0o755);
516 fs::set_permissions(path, permissions)?;
517 }
518
519 Ok(())
520}
521
522fn run_git(repository_root: &Path, args: &[&str]) -> Result<()> {
523 let status = Command::new("git")
524 .arg("-C")
525 .arg(repository_root)
526 .args(args)
527 .status()
528 .with_context(|| format!("failed to execute git in {}", repository_root.display()))?;
529
530 if !status.success() {
531 return Err(anyhow::anyhow!("git command failed in {}: {:?}", repository_root.display(), args));
532 }
533
534 Ok(())
535}
536
537fn run_git_output(repository_root: &Path, args: &[&str]) -> Result<String> {
538 let output = Command::new("git")
539 .arg("-C")
540 .arg(repository_root)
541 .args(args)
542 .output()
543 .with_context(|| format!("failed to execute git in {}", repository_root.display()))?;
544
545 if !output.status.success() {
546 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
547 return Err(anyhow::anyhow!("git command failed in {}: {}", repository_root.display(), stderr));
548 }
549
550 Ok(String::from_utf8(output.stdout).with_context(|| format!("git returned invalid utf-8 in {}", repository_root.display()))?)
551}
552
553pub fn parse_commit_message(message: &str) -> CommitMessage {
554 let mut lines = message.lines().collect::<Vec<_>>();
555 let trailer_start = lines
556 .iter()
557 .rposition(|line| line.trim().is_empty())
558 .map(|index| index + 1)
559 .unwrap_or(lines.len());
560 let trailer_lines = if trailer_start < lines.len() {
561 lines.split_off(trailer_start)
562 } else {
563 Vec::new()
564 };
565
566 let subject = lines.first().copied().unwrap_or_default().to_string();
567 let body = if lines.len() > 2 {
568 Some(lines[2..].join("\n"))
569 } else {
570 None
571 };
572
573 let trailers = trailer_lines
574 .into_iter()
575 .filter_map(|line| {
576 let (key, value) = line.split_once(": ")?;
577 Some(CommitTrailer {
578 key: key.to_string(),
579 value: value.to_string(),
580 })
581 })
582 .collect();
583
584 CommitMessage {
585 subject,
586 body,
587 trailers,
588 }
589}
590
591fn trailer_key(kind: &LoreKind) -> &'static str {
592 match kind {
593 LoreKind::Decision => "Lore-Decision",
594 LoreKind::Assumption => "Lore-Assumption",
595 LoreKind::OpenQuestion => "Lore-Open-Question",
596 LoreKind::Signal => "Lore-Signal",
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use std::collections::BTreeSet;
604 use std::fs;
605 use uuid::Uuid;
606
607 #[test]
608 fn commit_message_round_trips_trailers() {
609 let atom = LoreAtom {
610 id: "ID-1".to_string(),
611 kind: LoreKind::Decision,
612 state: crate::lore::AtomState::Proposed,
613 title: "Use Postgres".to_string(),
614 body: None,
615 scope: None,
616 path: None,
617 validation_script: None,
618 created_unix_seconds: 0,
619 };
620
621 let message = build_commit_message("feat: add db layer", &[atom]);
622 let parsed = parse_commit_message(&message);
623
624 assert_eq!(parsed.subject, "feat: add db layer");
625 assert_eq!(parsed.trailers.len(), 1);
626 assert_eq!(parsed.trailers[0].key, "Lore-Decision");
627 assert_eq!(parsed.trailers[0].value, "[ID-1] Use Postgres");
628 }
629
630 #[test]
631 fn discovers_repository_root_from_nested_directory() {
632 let root = std::env::temp_dir().join(format!("git-lore-git-test-{}", Uuid::new_v4()));
633 fs::create_dir_all(&root).unwrap();
634 let status = Command::new("git").arg("-C").arg(&root).arg("init").status().unwrap();
635 assert!(status.success());
636
637 let nested = root.join("nested").join("folder");
638 fs::create_dir_all(&nested).unwrap();
639
640 let discovered_root = discover_repository(&nested).unwrap();
641 let expected_root = fs::canonicalize(&root).unwrap_or(root);
642
643 assert_eq!(discovered_root, expected_root);
644 }
645
646 #[test]
647 fn commit_lore_message_creates_commit() {
648 let root = std::env::temp_dir().join(format!("git-lore-commit-test-{}", Uuid::new_v4()));
649 fs::create_dir_all(&root).unwrap();
650 let status = Command::new("git").arg("-C").arg(&root).arg("init").status().unwrap();
651 assert!(status.success());
652
653 let file_path = root.join("README.md");
654 fs::write(&file_path, "hello\n").unwrap();
655 let add_status = Command::new("git")
656 .arg("-C")
657 .arg(&root)
658 .arg("add")
659 .arg("README.md")
660 .status()
661 .unwrap();
662 assert!(add_status.success());
663
664 let hash = commit_lore_message(&root, "feat: add readme", true).unwrap();
665 assert!(!hash.is_empty());
666 }
667
668 #[test]
669 fn sync_workspace_is_idempotent_across_repeated_runs() {
670 let root = std::env::temp_dir().join(format!("git-lore-sync-test-{}", Uuid::new_v4()));
671 fs::create_dir_all(&root).unwrap();
672
673 let init_status = Command::new("git")
674 .arg("-C")
675 .arg(&root)
676 .arg("init")
677 .status()
678 .unwrap();
679 assert!(init_status.success());
680
681 let workspace = Workspace::init(&root).unwrap();
682
683 let commit_hash = commit_lore_message(&root, "chore: seed lore refs", true).unwrap();
684 let ref_atom = LoreAtom {
685 id: "sync-id-1".to_string(),
686 kind: LoreKind::Decision,
687 state: AtomState::Accepted,
688 title: "Keep sync idempotent".to_string(),
689 body: Some("Accepted from git history".to_string()),
690 scope: Some("sync".to_string()),
691 path: Some(PathBuf::from("src/git/mod.rs")),
692 validation_script: None,
693 created_unix_seconds: 10,
694 };
695 write_lore_ref(&root, &ref_atom, &commit_hash).unwrap();
696
697 let first = sync_workspace_from_git_history(&root, &workspace).unwrap();
698 let second = sync_workspace_from_git_history(&root, &workspace).unwrap();
699
700 assert_eq!(first.len(), second.len());
701
702 let unique_ids = second
703 .iter()
704 .map(|atom| atom.id.clone())
705 .collect::<BTreeSet<_>>();
706 assert_eq!(unique_ids.len(), second.len());
707 }
708
709 #[test]
710 fn sync_workspace_compacts_existing_duplicate_atom_ids() {
711 let root = std::env::temp_dir().join(format!("git-lore-sync-dedupe-test-{}", Uuid::new_v4()));
712 fs::create_dir_all(&root).unwrap();
713
714 let init_status = Command::new("git")
715 .arg("-C")
716 .arg(&root)
717 .arg("init")
718 .status()
719 .unwrap();
720 assert!(init_status.success());
721
722 let workspace = Workspace::init(&root).unwrap();
723 let duplicate_id = "dup-1".to_string();
724
725 workspace
726 .set_state(&WorkspaceState {
727 version: 1,
728 atoms: vec![
729 LoreAtom {
730 id: duplicate_id.clone(),
731 kind: LoreKind::Decision,
732 state: AtomState::Proposed,
733 title: "Older duplicate".to_string(),
734 body: None,
735 scope: None,
736 path: None,
737 validation_script: None,
738 created_unix_seconds: 1,
739 },
740 LoreAtom {
741 id: duplicate_id.clone(),
742 kind: LoreKind::Decision,
743 state: AtomState::Accepted,
744 title: "Newer duplicate".to_string(),
745 body: Some("more complete".to_string()),
746 scope: Some("sync".to_string()),
747 path: Some(PathBuf::from("src/git/mod.rs")),
748 validation_script: None,
749 created_unix_seconds: 2,
750 },
751 ],
752 })
753 .unwrap();
754
755 let synced = sync_workspace_from_git_history(&root, &workspace).unwrap();
756
757 assert_eq!(synced.len(), 1);
758 assert_eq!(synced[0].id, duplicate_id);
759 assert_eq!(synced[0].title, "Newer duplicate");
760 }
761
762 #[test]
763 fn sync_workspace_restores_atom_metadata_from_git_notes() {
764 let root = std::env::temp_dir().join(format!("git-lore-sync-notes-test-{}", Uuid::new_v4()));
765 fs::create_dir_all(&root).unwrap();
766
767 let init_status = Command::new("git")
768 .arg("-C")
769 .arg(&root)
770 .arg("init")
771 .status()
772 .unwrap();
773 assert!(init_status.success());
774
775 let workspace = Workspace::init(&root).unwrap();
776 let commit_hash = commit_lore_message(&root, "chore: seed lore refs", true).unwrap();
777 let ref_atom = LoreAtom {
778 id: "sync-note-1".to_string(),
779 kind: LoreKind::Decision,
780 state: AtomState::Accepted,
781 title: "Use deterministic parser scope".to_string(),
782 body: Some("Recovered from git note".to_string()),
783 scope: Some("parser".to_string()),
784 path: Some(PathBuf::from("src/parser/mod.rs")),
785 validation_script: None,
786 created_unix_seconds: 42,
787 };
788 write_lore_ref(&root, &ref_atom, &commit_hash).unwrap();
789
790 workspace
791 .set_state(&WorkspaceState {
792 version: 1,
793 atoms: Vec::new(),
794 })
795 .unwrap();
796
797 let synced = sync_workspace_from_git_history(&root, &workspace).unwrap();
798
799 assert_eq!(synced.len(), 1);
800 assert_eq!(synced[0].id, ref_atom.id);
801 assert_eq!(synced[0].title, ref_atom.title);
802 assert_eq!(synced[0].body, ref_atom.body);
803 assert_eq!(synced[0].scope, ref_atom.scope);
804 assert_eq!(synced[0].path, ref_atom.path);
805 assert_eq!(synced[0].state, AtomState::Accepted);
806 }
807
808 #[test]
809 fn sync_workspace_restores_multiple_atoms_from_same_commit_note() {
810 let root = std::env::temp_dir().join(format!("git-lore-sync-multi-notes-test-{}", Uuid::new_v4()));
811 fs::create_dir_all(&root).unwrap();
812
813 let init_status = Command::new("git")
814 .arg("-C")
815 .arg(&root)
816 .arg("init")
817 .status()
818 .unwrap();
819 assert!(init_status.success());
820
821 let workspace = Workspace::init(&root).unwrap();
822 let commit_hash = commit_lore_message(&root, "chore: seed lore refs", true).unwrap();
823
824 let first = LoreAtom {
825 id: "sync-note-a".to_string(),
826 kind: LoreKind::Decision,
827 state: AtomState::Accepted,
828 title: "Keep parser deterministic".to_string(),
829 body: Some("A".to_string()),
830 scope: Some("parser".to_string()),
831 path: Some(PathBuf::from("src/parser/mod.rs")),
832 validation_script: None,
833 created_unix_seconds: 11,
834 };
835 let second = LoreAtom {
836 id: "sync-note-b".to_string(),
837 kind: LoreKind::Decision,
838 state: AtomState::Accepted,
839 title: "Use explicit transitions".to_string(),
840 body: Some("B".to_string()),
841 scope: Some("lore".to_string()),
842 path: Some(PathBuf::from("src/lore/mod.rs")),
843 validation_script: None,
844 created_unix_seconds: 12,
845 };
846
847 write_lore_ref(&root, &first, &commit_hash).unwrap();
848 write_lore_ref(&root, &second, &commit_hash).unwrap();
849
850 workspace
851 .set_state(&WorkspaceState {
852 version: 1,
853 atoms: Vec::new(),
854 })
855 .unwrap();
856
857 let synced = sync_workspace_from_git_history(&root, &workspace).unwrap();
858 assert_eq!(synced.len(), 2);
859
860 let by_id = synced
861 .into_iter()
862 .map(|atom| (atom.id.clone(), atom))
863 .collect::<BTreeMap<_, _>>();
864
865 assert_eq!(by_id["sync-note-a"].title, first.title);
866 assert_eq!(by_id["sync-note-a"].scope, first.scope);
867 assert_eq!(by_id["sync-note-b"].title, second.title);
868 assert_eq!(by_id["sync-note-b"].scope, second.scope);
869 }
870
871 #[test]
872 fn sync_workspace_falls_back_to_commit_trailers_without_notes() {
873 let root = std::env::temp_dir().join(format!("git-lore-sync-trailer-test-{}", Uuid::new_v4()));
874 fs::create_dir_all(&root).unwrap();
875
876 let init_status = Command::new("git")
877 .arg("-C")
878 .arg(&root)
879 .arg("init")
880 .status()
881 .unwrap();
882 assert!(init_status.success());
883
884 let workspace = Workspace::init(&root).unwrap();
885
886 let atoms = vec![
887 LoreAtom {
888 id: "sync-trailer-a".to_string(),
889 kind: LoreKind::Decision,
890 state: AtomState::Accepted,
891 title: "Use parser scope".to_string(),
892 body: None,
893 scope: None,
894 path: None,
895 validation_script: None,
896 created_unix_seconds: 1,
897 },
898 LoreAtom {
899 id: "sync-trailer-b".to_string(),
900 kind: LoreKind::Assumption,
901 state: AtomState::Accepted,
902 title: "Keep merge deterministic".to_string(),
903 body: None,
904 scope: None,
905 path: None,
906 validation_script: None,
907 created_unix_seconds: 1,
908 },
909 ];
910
911 let message = build_commit_message("chore: seed lore refs", &atoms);
912 let commit_hash = commit_lore_message(&root, &message, true).unwrap();
913
914 run_git(
915 &root,
916 &["update-ref", "refs/lore/accepted/sync-trailer-a", &commit_hash],
917 )
918 .unwrap();
919 run_git(
920 &root,
921 &["update-ref", "refs/lore/accepted/sync-trailer-b", &commit_hash],
922 )
923 .unwrap();
924
925 workspace
926 .set_state(&WorkspaceState {
927 version: 1,
928 atoms: Vec::new(),
929 })
930 .unwrap();
931
932 let synced = sync_workspace_from_git_history(&root, &workspace).unwrap();
933 assert_eq!(synced.len(), 2);
934
935 let by_id = synced
936 .into_iter()
937 .map(|atom| (atom.id.clone(), atom))
938 .collect::<BTreeMap<_, _>>();
939
940 assert_eq!(by_id["sync-trailer-a"].title, "Use parser scope");
941 assert_eq!(by_id["sync-trailer-b"].title, "Keep merge deterministic");
942 assert_eq!(by_id["sync-trailer-a"].kind, LoreKind::Decision);
943 assert_eq!(by_id["sync-trailer-b"].kind, LoreKind::Assumption);
944 }
945
946 #[test]
947 fn sync_workspace_preserves_existing_active_state_for_matching_refs() {
948 let root = std::env::temp_dir().join(format!("git-lore-sync-preserve-test-{}", Uuid::new_v4()));
949 fs::create_dir_all(&root).unwrap();
950
951 let init_status = Command::new("git")
952 .arg("-C")
953 .arg(&root)
954 .arg("init")
955 .status()
956 .unwrap();
957 assert!(init_status.success());
958
959 let workspace = Workspace::init(&root).unwrap();
960 let commit_hash = commit_lore_message(&root, "chore: seed lore refs", true).unwrap();
961
962 let ref_atom = LoreAtom {
963 id: "preserve-1".to_string(),
964 kind: LoreKind::Decision,
965 state: AtomState::Accepted,
966 title: "Keep sync stable".to_string(),
967 body: Some("Accepted from git history".to_string()),
968 scope: Some("sync".to_string()),
969 path: Some(PathBuf::from("src/git/mod.rs")),
970 validation_script: None,
971 created_unix_seconds: 10,
972 };
973 write_lore_ref(&root, &ref_atom, &commit_hash).unwrap();
974
975 workspace
976 .set_state(&WorkspaceState {
977 version: 1,
978 atoms: vec![LoreAtom {
979 id: "preserve-1".to_string(),
980 kind: LoreKind::Decision,
981 state: AtomState::Deprecated,
982 title: "Keep sync stable".to_string(),
983 body: Some("Resolved locally".to_string()),
984 scope: Some("sync".to_string()),
985 path: Some(PathBuf::from("src/git/mod.rs")),
986 validation_script: None,
987 created_unix_seconds: 20,
988 }],
989 })
990 .unwrap();
991
992 let synced = sync_workspace_from_git_history(&root, &workspace).unwrap();
993
994 assert_eq!(synced.len(), 1);
995 assert_eq!(synced[0].id, "preserve-1");
996 assert_eq!(synced[0].state, AtomState::Deprecated);
997 assert_eq!(synced[0].body.as_deref(), Some("Resolved locally"));
998 }
999}