Skip to main content

git_lore/git/
mod.rs

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(&note))
168    .unwrap_or_default();
169    note_atoms.insert(atom.id.clone(), atom.clone());
170
171    let note = serde_json::to_string_pretty(&note_atoms)?;
172    run_git(
173        repository_root,
174        &[
175            "notes",
176            "--ref=refs/notes/lore",
177            "add",
178            "-f",
179            "-m",
180            &note,
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(&note);
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}