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 note = serde_json::to_string_pretty(atom)?;
163    run_git(
164        repository_root,
165        &[
166            "notes",
167            "--ref=refs/notes/lore",
168            "add",
169            "-f",
170            "-m",
171            &note,
172            source_commit,
173        ],
174    )?;
175
176    Ok(())
177}
178
179pub fn list_lore_refs(repository_root: impl AsRef<Path>) -> Result<Vec<(String, String)>> {
180    let output = run_git_output(
181        repository_root.as_ref(),
182        &[
183            "for-each-ref",
184            "refs/lore/accepted",
185            "--format=%(refname) %(objectname)",
186        ],
187    )?;
188
189    Ok(output
190        .lines()
191        .filter_map(|line| line.split_once(' '))
192        .map(|(name, hash)| (name.to_string(), hash.to_string()))
193        .collect())
194}
195
196pub fn collect_recent_decisions_for_path(
197    repository_root: impl AsRef<Path>,
198    file_path: impl AsRef<Path>,
199    limit: usize,
200) -> Result<Vec<HistoricalDecision>> {
201    let file_path = file_path.as_ref().to_path_buf();
202    let file_path_arg = file_path.to_string_lossy().to_string();
203    let output = run_git_output(
204        repository_root.as_ref(),
205        &[
206            "log",
207            "--follow",
208            "--format=%H%x1f%B%x1e",
209            "--",
210            file_path_arg.as_str(),
211        ],
212    )?;
213
214    let mut decisions = Vec::new();
215
216    for record in output.split('\x1e') {
217        let record = record.trim();
218        if record.is_empty() {
219            continue;
220        }
221
222        let Some((commit_hash, message)) = record.split_once('\x1f') else {
223            continue;
224        };
225
226        let parsed = parse_commit_message(message.trim());
227        for trailer in parsed.trailers.into_iter().filter(|trailer| trailer.key == "Lore-Decision") {
228            decisions.push(HistoricalDecision {
229                commit_hash: commit_hash.trim().to_string(),
230                subject: parsed.subject.clone(),
231                trailer,
232                file_path: file_path.clone(),
233            });
234
235            if decisions.len() >= limit {
236                return Ok(decisions);
237            }
238        }
239    }
240
241    Ok(decisions)
242}
243
244pub fn install_git_lore_integration(repository_root: impl AsRef<Path>) -> Result<()> {
245    let repository_root = repository_root.as_ref();
246    let git_dir = git_dir(repository_root)?;
247    let hooks_dir = git_dir.join("hooks");
248    fs::create_dir_all(&hooks_dir)?;
249
250    run_git(
251        repository_root,
252        &["config", "merge.lore.name", "Git-Lore Reasoning Merger"],
253    )?;
254    run_git(
255        repository_root,
256        &["config", "merge.lore.driver", "git-lore merge %O %A %B"],
257    )?;
258
259    write_hook(
260        &hooks_dir.join("pre-commit"),
261        "#!/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",
262    )?;
263    write_hook(
264        &hooks_dir.join("post-checkout"),
265        "#!/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",
266    )?;
267
268    Ok(())
269}
270
271pub fn validate_workspace_against_git(repository_root: impl AsRef<Path>, workspace: &Workspace) -> Result<Vec<String>> {
272    let repository_root = repository_root.as_ref();
273    let mut issues = Vec::new();
274
275    for issue in workspace.sanitize_report()? {
276        issues.push(format!(
277            "sensitive content in {}.{}: {}",
278            issue.atom_id, issue.field, issue.reason
279        ));
280    }
281
282    let state = workspace.load_state()?.atoms;
283    for violation in workspace.scan_prism_hard_locks(&state)? {
284        issues.push(format!("{} ({})", violation.message, violation.atom_ids.join(", ")));
285    }
286
287    for issue in workspace.validation_report()? {
288        issues.push(format!(
289            "validation failed for {}: {}",
290            issue.atom_id, issue.reason
291        ));
292    }
293
294    for (refname, objectname) in list_lore_refs(repository_root)? {
295        if refname.is_empty() || objectname.is_empty() {
296            issues.push("empty lore ref entry detected".to_string());
297        }
298    }
299
300    Ok(issues)
301}
302
303pub fn sync_workspace_from_git_history(
304    repository_root: impl AsRef<Path>,
305    workspace: &Workspace,
306) -> Result<Vec<LoreAtom>> {
307    let repository_root = repository_root.as_ref();
308    let state = workspace.load_state()?;
309    let mut atoms_by_id = BTreeMap::<String, LoreAtom>::new();
310
311    for atom in state.atoms {
312        upsert_atom(&mut atoms_by_id, atom);
313    }
314
315    for (refname, objectname) in list_lore_refs(repository_root)? {
316        if let Some(atom_id) = refname.rsplit('/').next() {
317            let candidate = LoreAtom {
318                id: atom_id.to_string(),
319                kind: LoreKind::Decision,
320                state: AtomState::Accepted,
321                title: format!("Synced accepted lore from {objectname}"),
322                body: Some(format!("Restored from {refname}")),
323                scope: None,
324                path: None,
325                validation_script: None,
326                created_unix_seconds: 0,
327            };
328
329            if let Some(existing) = atoms_by_id.get_mut(atom_id) {
330                if should_replace_with_candidate(existing, &candidate) {
331                    *existing = candidate;
332                }
333            } else {
334                atoms_by_id.insert(atom_id.to_string(), candidate);
335            }
336        }
337    }
338
339    let atoms = atoms_by_id.into_values().collect::<Vec<_>>();
340
341    workspace.set_state(&WorkspaceState {
342        version: state.version,
343        atoms: atoms.clone(),
344    })?;
345    Ok(atoms)
346}
347
348fn upsert_atom(atoms_by_id: &mut BTreeMap<String, LoreAtom>, atom: LoreAtom) {
349    match atoms_by_id.get(&atom.id) {
350        Some(existing) if !should_replace_with_candidate(existing, &atom) => {}
351        _ => {
352            atoms_by_id.insert(atom.id.clone(), atom);
353        }
354    }
355}
356
357fn should_replace_with_candidate(existing: &LoreAtom, candidate: &LoreAtom) -> bool {
358    if candidate.created_unix_seconds > existing.created_unix_seconds {
359        return true;
360    }
361
362    if candidate.created_unix_seconds < existing.created_unix_seconds {
363        return false;
364    }
365
366    atom_preference_score(candidate) > atom_preference_score(existing)
367}
368
369fn atom_preference_score(atom: &LoreAtom) -> u8 {
370    let mut score = 0u8;
371    if atom.path.is_some() {
372        score += 3;
373    }
374    if atom.scope.is_some() {
375        score += 2;
376    }
377    if atom.body.is_some() {
378        score += 2;
379    }
380    if atom.validation_script.is_some() {
381        score += 1;
382    }
383    if !is_synced_placeholder(atom) {
384        score += 1;
385    }
386    score
387}
388
389fn is_synced_placeholder(atom: &LoreAtom) -> bool {
390    atom.created_unix_seconds == 0
391        && atom.path.is_none()
392        && atom.scope.is_none()
393        && atom.title.starts_with("Synced accepted lore from ")
394}
395
396fn git_dir(repository_root: &Path) -> Result<PathBuf> {
397    let output = run_git_output(repository_root, &["rev-parse", "--git-dir"])?;
398    let git_dir = PathBuf::from(output.trim());
399    if git_dir.is_absolute() {
400        Ok(git_dir)
401    } else {
402        Ok(repository_root.join(git_dir))
403    }
404}
405
406fn write_hook(path: &Path, content: &str) -> Result<()> {
407    fs::write(path, content).with_context(|| format!("failed to write hook {}", path.display()))?;
408
409    #[cfg(unix)]
410    {
411        use std::os::unix::fs::PermissionsExt;
412        let mut permissions = fs::metadata(path)?.permissions();
413        permissions.set_mode(0o755);
414        fs::set_permissions(path, permissions)?;
415    }
416
417    Ok(())
418}
419
420fn run_git(repository_root: &Path, args: &[&str]) -> Result<()> {
421    let status = Command::new("git")
422        .arg("-C")
423        .arg(repository_root)
424        .args(args)
425        .status()
426        .with_context(|| format!("failed to execute git in {}", repository_root.display()))?;
427
428    if !status.success() {
429        return Err(anyhow::anyhow!("git command failed in {}: {:?}", repository_root.display(), args));
430    }
431
432    Ok(())
433}
434
435fn run_git_output(repository_root: &Path, args: &[&str]) -> Result<String> {
436    let output = Command::new("git")
437        .arg("-C")
438        .arg(repository_root)
439        .args(args)
440        .output()
441        .with_context(|| format!("failed to execute git in {}", repository_root.display()))?;
442
443    if !output.status.success() {
444        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
445        return Err(anyhow::anyhow!("git command failed in {}: {}", repository_root.display(), stderr));
446    }
447
448    Ok(String::from_utf8(output.stdout).with_context(|| format!("git returned invalid utf-8 in {}", repository_root.display()))?)
449}
450
451pub fn parse_commit_message(message: &str) -> CommitMessage {
452    let mut lines = message.lines().collect::<Vec<_>>();
453    let trailer_start = lines
454        .iter()
455        .rposition(|line| line.trim().is_empty())
456        .map(|index| index + 1)
457        .unwrap_or(lines.len());
458    let trailer_lines = if trailer_start < lines.len() {
459        lines.split_off(trailer_start)
460    } else {
461        Vec::new()
462    };
463
464    let subject = lines.first().copied().unwrap_or_default().to_string();
465    let body = if lines.len() > 2 {
466        Some(lines[2..].join("\n"))
467    } else {
468        None
469    };
470
471    let trailers = trailer_lines
472        .into_iter()
473        .filter_map(|line| {
474            let (key, value) = line.split_once(": ")?;
475            Some(CommitTrailer {
476                key: key.to_string(),
477                value: value.to_string(),
478            })
479        })
480        .collect();
481
482    CommitMessage {
483        subject,
484        body,
485        trailers,
486    }
487}
488
489fn trailer_key(kind: &LoreKind) -> &'static str {
490    match kind {
491        LoreKind::Decision => "Lore-Decision",
492        LoreKind::Assumption => "Lore-Assumption",
493        LoreKind::OpenQuestion => "Lore-Open-Question",
494        LoreKind::Signal => "Lore-Signal",
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use std::collections::BTreeSet;
502    use std::fs;
503    use uuid::Uuid;
504
505    #[test]
506    fn commit_message_round_trips_trailers() {
507        let atom = LoreAtom {
508            id: "ID-1".to_string(),
509            kind: LoreKind::Decision,
510            state: crate::lore::AtomState::Proposed,
511            title: "Use Postgres".to_string(),
512            body: None,
513            scope: None,
514            path: None,
515            validation_script: None,
516            created_unix_seconds: 0,
517        };
518
519        let message = build_commit_message("feat: add db layer", &[atom]);
520        let parsed = parse_commit_message(&message);
521
522        assert_eq!(parsed.subject, "feat: add db layer");
523        assert_eq!(parsed.trailers.len(), 1);
524        assert_eq!(parsed.trailers[0].key, "Lore-Decision");
525        assert_eq!(parsed.trailers[0].value, "[ID-1] Use Postgres");
526    }
527
528    #[test]
529    fn discovers_repository_root_from_nested_directory() {
530        let root = std::env::temp_dir().join(format!("git-lore-git-test-{}", Uuid::new_v4()));
531        fs::create_dir_all(&root).unwrap();
532        let status = Command::new("git").arg("-C").arg(&root).arg("init").status().unwrap();
533        assert!(status.success());
534
535        let nested = root.join("nested").join("folder");
536        fs::create_dir_all(&nested).unwrap();
537
538        let discovered_root = discover_repository(&nested).unwrap();
539        let expected_root = fs::canonicalize(&root).unwrap_or(root);
540
541        assert_eq!(discovered_root, expected_root);
542    }
543
544    #[test]
545    fn commit_lore_message_creates_commit() {
546        let root = std::env::temp_dir().join(format!("git-lore-commit-test-{}", Uuid::new_v4()));
547        fs::create_dir_all(&root).unwrap();
548        let status = Command::new("git").arg("-C").arg(&root).arg("init").status().unwrap();
549        assert!(status.success());
550
551        let file_path = root.join("README.md");
552        fs::write(&file_path, "hello\n").unwrap();
553        let add_status = Command::new("git")
554            .arg("-C")
555            .arg(&root)
556            .arg("add")
557            .arg("README.md")
558            .status()
559            .unwrap();
560        assert!(add_status.success());
561
562        let hash = commit_lore_message(&root, "feat: add readme", true).unwrap();
563        assert!(!hash.is_empty());
564    }
565
566    #[test]
567    fn sync_workspace_is_idempotent_across_repeated_runs() {
568        let root = std::env::temp_dir().join(format!("git-lore-sync-test-{}", Uuid::new_v4()));
569        fs::create_dir_all(&root).unwrap();
570
571        let init_status = Command::new("git")
572            .arg("-C")
573            .arg(&root)
574            .arg("init")
575            .status()
576            .unwrap();
577        assert!(init_status.success());
578
579        let workspace = Workspace::init(&root).unwrap();
580
581        let commit_hash = commit_lore_message(&root, "chore: seed lore refs", true).unwrap();
582        let ref_atom = LoreAtom {
583            id: "sync-id-1".to_string(),
584            kind: LoreKind::Decision,
585            state: AtomState::Accepted,
586            title: "Keep sync idempotent".to_string(),
587            body: Some("Accepted from git history".to_string()),
588            scope: Some("sync".to_string()),
589            path: Some(PathBuf::from("src/git/mod.rs")),
590            validation_script: None,
591            created_unix_seconds: 10,
592        };
593        write_lore_ref(&root, &ref_atom, &commit_hash).unwrap();
594
595        let first = sync_workspace_from_git_history(&root, &workspace).unwrap();
596        let second = sync_workspace_from_git_history(&root, &workspace).unwrap();
597
598        assert_eq!(first.len(), second.len());
599
600        let unique_ids = second
601            .iter()
602            .map(|atom| atom.id.clone())
603            .collect::<BTreeSet<_>>();
604        assert_eq!(unique_ids.len(), second.len());
605    }
606
607    #[test]
608    fn sync_workspace_compacts_existing_duplicate_atom_ids() {
609        let root = std::env::temp_dir().join(format!("git-lore-sync-dedupe-test-{}", Uuid::new_v4()));
610        fs::create_dir_all(&root).unwrap();
611
612        let init_status = Command::new("git")
613            .arg("-C")
614            .arg(&root)
615            .arg("init")
616            .status()
617            .unwrap();
618        assert!(init_status.success());
619
620        let workspace = Workspace::init(&root).unwrap();
621        let duplicate_id = "dup-1".to_string();
622
623        workspace
624            .set_state(&WorkspaceState {
625                version: 1,
626                atoms: vec![
627                    LoreAtom {
628                        id: duplicate_id.clone(),
629                        kind: LoreKind::Decision,
630                        state: AtomState::Proposed,
631                        title: "Older duplicate".to_string(),
632                        body: None,
633                        scope: None,
634                        path: None,
635                        validation_script: None,
636                        created_unix_seconds: 1,
637                    },
638                    LoreAtom {
639                        id: duplicate_id.clone(),
640                        kind: LoreKind::Decision,
641                        state: AtomState::Accepted,
642                        title: "Newer duplicate".to_string(),
643                        body: Some("more complete".to_string()),
644                        scope: Some("sync".to_string()),
645                        path: Some(PathBuf::from("src/git/mod.rs")),
646                        validation_script: None,
647                        created_unix_seconds: 2,
648                    },
649                ],
650            })
651            .unwrap();
652
653        let synced = sync_workspace_from_git_history(&root, &workspace).unwrap();
654
655        assert_eq!(synced.len(), 1);
656        assert_eq!(synced[0].id, duplicate_id);
657        assert_eq!(synced[0].title, "Newer duplicate");
658    }
659
660    #[test]
661    fn sync_workspace_preserves_existing_active_state_for_matching_refs() {
662        let root = std::env::temp_dir().join(format!("git-lore-sync-preserve-test-{}", Uuid::new_v4()));
663        fs::create_dir_all(&root).unwrap();
664
665        let init_status = Command::new("git")
666            .arg("-C")
667            .arg(&root)
668            .arg("init")
669            .status()
670            .unwrap();
671        assert!(init_status.success());
672
673        let workspace = Workspace::init(&root).unwrap();
674        let commit_hash = commit_lore_message(&root, "chore: seed lore refs", true).unwrap();
675
676        let ref_atom = LoreAtom {
677            id: "preserve-1".to_string(),
678            kind: LoreKind::Decision,
679            state: AtomState::Accepted,
680            title: "Keep sync stable".to_string(),
681            body: Some("Accepted from git history".to_string()),
682            scope: Some("sync".to_string()),
683            path: Some(PathBuf::from("src/git/mod.rs")),
684            validation_script: None,
685            created_unix_seconds: 10,
686        };
687        write_lore_ref(&root, &ref_atom, &commit_hash).unwrap();
688
689        workspace
690            .set_state(&WorkspaceState {
691                version: 1,
692                atoms: vec![LoreAtom {
693                    id: "preserve-1".to_string(),
694                    kind: LoreKind::Decision,
695                    state: AtomState::Deprecated,
696                    title: "Keep sync stable".to_string(),
697                    body: Some("Resolved locally".to_string()),
698                    scope: Some("sync".to_string()),
699                    path: Some(PathBuf::from("src/git/mod.rs")),
700                    validation_script: None,
701                    created_unix_seconds: 20,
702                }],
703            })
704            .unwrap();
705
706        let synced = sync_workspace_from_git_history(&root, &workspace).unwrap();
707
708        assert_eq!(synced.len(), 1);
709        assert_eq!(synced[0].id, "preserve-1");
710        assert_eq!(synced[0].state, AtomState::Deprecated);
711        assert_eq!(synced[0].body.as_deref(), Some("Resolved locally"));
712    }
713}