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 existing.state != AtomState::Accepted {
331                    existing.state = AtomState::Accepted;
332                }
333
334                if should_replace_with_candidate(existing, &candidate) {
335                    *existing = candidate;
336                }
337            } else {
338                atoms_by_id.insert(atom_id.to_string(), candidate);
339            }
340        }
341    }
342
343    let atoms = atoms_by_id.into_values().collect::<Vec<_>>();
344
345    workspace.set_state(&WorkspaceState {
346        version: state.version,
347        atoms: atoms.clone(),
348    })?;
349    Ok(atoms)
350}
351
352fn upsert_atom(atoms_by_id: &mut BTreeMap<String, LoreAtom>, atom: LoreAtom) {
353    match atoms_by_id.get(&atom.id) {
354        Some(existing) if !should_replace_with_candidate(existing, &atom) => {}
355        _ => {
356            atoms_by_id.insert(atom.id.clone(), atom);
357        }
358    }
359}
360
361fn should_replace_with_candidate(existing: &LoreAtom, candidate: &LoreAtom) -> bool {
362    if candidate.created_unix_seconds > existing.created_unix_seconds {
363        return true;
364    }
365
366    if candidate.created_unix_seconds < existing.created_unix_seconds {
367        return false;
368    }
369
370    atom_preference_score(candidate) > atom_preference_score(existing)
371}
372
373fn atom_preference_score(atom: &LoreAtom) -> u8 {
374    let mut score = 0u8;
375    if atom.path.is_some() {
376        score += 3;
377    }
378    if atom.scope.is_some() {
379        score += 2;
380    }
381    if atom.body.is_some() {
382        score += 2;
383    }
384    if atom.validation_script.is_some() {
385        score += 1;
386    }
387    if !is_synced_placeholder(atom) {
388        score += 1;
389    }
390    score
391}
392
393fn is_synced_placeholder(atom: &LoreAtom) -> bool {
394    atom.created_unix_seconds == 0
395        && atom.path.is_none()
396        && atom.scope.is_none()
397        && atom.title.starts_with("Synced accepted lore from ")
398}
399
400fn git_dir(repository_root: &Path) -> Result<PathBuf> {
401    let output = run_git_output(repository_root, &["rev-parse", "--git-dir"])?;
402    let git_dir = PathBuf::from(output.trim());
403    if git_dir.is_absolute() {
404        Ok(git_dir)
405    } else {
406        Ok(repository_root.join(git_dir))
407    }
408}
409
410fn write_hook(path: &Path, content: &str) -> Result<()> {
411    fs::write(path, content).with_context(|| format!("failed to write hook {}", path.display()))?;
412
413    #[cfg(unix)]
414    {
415        use std::os::unix::fs::PermissionsExt;
416        let mut permissions = fs::metadata(path)?.permissions();
417        permissions.set_mode(0o755);
418        fs::set_permissions(path, permissions)?;
419    }
420
421    Ok(())
422}
423
424fn run_git(repository_root: &Path, args: &[&str]) -> Result<()> {
425    let status = Command::new("git")
426        .arg("-C")
427        .arg(repository_root)
428        .args(args)
429        .status()
430        .with_context(|| format!("failed to execute git in {}", repository_root.display()))?;
431
432    if !status.success() {
433        return Err(anyhow::anyhow!("git command failed in {}: {:?}", repository_root.display(), args));
434    }
435
436    Ok(())
437}
438
439fn run_git_output(repository_root: &Path, args: &[&str]) -> Result<String> {
440    let output = Command::new("git")
441        .arg("-C")
442        .arg(repository_root)
443        .args(args)
444        .output()
445        .with_context(|| format!("failed to execute git in {}", repository_root.display()))?;
446
447    if !output.status.success() {
448        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
449        return Err(anyhow::anyhow!("git command failed in {}: {}", repository_root.display(), stderr));
450    }
451
452    Ok(String::from_utf8(output.stdout).with_context(|| format!("git returned invalid utf-8 in {}", repository_root.display()))?)
453}
454
455pub fn parse_commit_message(message: &str) -> CommitMessage {
456    let mut lines = message.lines().collect::<Vec<_>>();
457    let trailer_start = lines
458        .iter()
459        .rposition(|line| line.trim().is_empty())
460        .map(|index| index + 1)
461        .unwrap_or(lines.len());
462    let trailer_lines = if trailer_start < lines.len() {
463        lines.split_off(trailer_start)
464    } else {
465        Vec::new()
466    };
467
468    let subject = lines.first().copied().unwrap_or_default().to_string();
469    let body = if lines.len() > 2 {
470        Some(lines[2..].join("\n"))
471    } else {
472        None
473    };
474
475    let trailers = trailer_lines
476        .into_iter()
477        .filter_map(|line| {
478            let (key, value) = line.split_once(": ")?;
479            Some(CommitTrailer {
480                key: key.to_string(),
481                value: value.to_string(),
482            })
483        })
484        .collect();
485
486    CommitMessage {
487        subject,
488        body,
489        trailers,
490    }
491}
492
493fn trailer_key(kind: &LoreKind) -> &'static str {
494    match kind {
495        LoreKind::Decision => "Lore-Decision",
496        LoreKind::Assumption => "Lore-Assumption",
497        LoreKind::OpenQuestion => "Lore-Open-Question",
498        LoreKind::Signal => "Lore-Signal",
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use std::collections::BTreeSet;
506    use std::fs;
507    use uuid::Uuid;
508
509    #[test]
510    fn commit_message_round_trips_trailers() {
511        let atom = LoreAtom {
512            id: "ID-1".to_string(),
513            kind: LoreKind::Decision,
514            state: crate::lore::AtomState::Proposed,
515            title: "Use Postgres".to_string(),
516            body: None,
517            scope: None,
518            path: None,
519            validation_script: None,
520            created_unix_seconds: 0,
521        };
522
523        let message = build_commit_message("feat: add db layer", &[atom]);
524        let parsed = parse_commit_message(&message);
525
526        assert_eq!(parsed.subject, "feat: add db layer");
527        assert_eq!(parsed.trailers.len(), 1);
528        assert_eq!(parsed.trailers[0].key, "Lore-Decision");
529        assert_eq!(parsed.trailers[0].value, "[ID-1] Use Postgres");
530    }
531
532    #[test]
533    fn discovers_repository_root_from_nested_directory() {
534        let root = std::env::temp_dir().join(format!("git-lore-git-test-{}", Uuid::new_v4()));
535        fs::create_dir_all(&root).unwrap();
536        let status = Command::new("git").arg("-C").arg(&root).arg("init").status().unwrap();
537        assert!(status.success());
538
539        let nested = root.join("nested").join("folder");
540        fs::create_dir_all(&nested).unwrap();
541
542        let discovered_root = discover_repository(&nested).unwrap();
543        let expected_root = fs::canonicalize(&root).unwrap_or(root);
544
545        assert_eq!(discovered_root, expected_root);
546    }
547
548    #[test]
549    fn commit_lore_message_creates_commit() {
550        let root = std::env::temp_dir().join(format!("git-lore-commit-test-{}", Uuid::new_v4()));
551        fs::create_dir_all(&root).unwrap();
552        let status = Command::new("git").arg("-C").arg(&root).arg("init").status().unwrap();
553        assert!(status.success());
554
555        let file_path = root.join("README.md");
556        fs::write(&file_path, "hello\n").unwrap();
557        let add_status = Command::new("git")
558            .arg("-C")
559            .arg(&root)
560            .arg("add")
561            .arg("README.md")
562            .status()
563            .unwrap();
564        assert!(add_status.success());
565
566        let hash = commit_lore_message(&root, "feat: add readme", true).unwrap();
567        assert!(!hash.is_empty());
568    }
569
570    #[test]
571    fn sync_workspace_is_idempotent_across_repeated_runs() {
572        let root = std::env::temp_dir().join(format!("git-lore-sync-test-{}", Uuid::new_v4()));
573        fs::create_dir_all(&root).unwrap();
574
575        let init_status = Command::new("git")
576            .arg("-C")
577            .arg(&root)
578            .arg("init")
579            .status()
580            .unwrap();
581        assert!(init_status.success());
582
583        let workspace = Workspace::init(&root).unwrap();
584
585        let commit_hash = commit_lore_message(&root, "chore: seed lore refs", true).unwrap();
586        let ref_atom = LoreAtom {
587            id: "sync-id-1".to_string(),
588            kind: LoreKind::Decision,
589            state: AtomState::Accepted,
590            title: "Keep sync idempotent".to_string(),
591            body: Some("Accepted from git history".to_string()),
592            scope: Some("sync".to_string()),
593            path: Some(PathBuf::from("src/git/mod.rs")),
594            validation_script: None,
595            created_unix_seconds: 10,
596        };
597        write_lore_ref(&root, &ref_atom, &commit_hash).unwrap();
598
599        let first = sync_workspace_from_git_history(&root, &workspace).unwrap();
600        let second = sync_workspace_from_git_history(&root, &workspace).unwrap();
601
602        assert_eq!(first.len(), second.len());
603
604        let unique_ids = second
605            .iter()
606            .map(|atom| atom.id.clone())
607            .collect::<BTreeSet<_>>();
608        assert_eq!(unique_ids.len(), second.len());
609    }
610
611    #[test]
612    fn sync_workspace_compacts_existing_duplicate_atom_ids() {
613        let root = std::env::temp_dir().join(format!("git-lore-sync-dedupe-test-{}", Uuid::new_v4()));
614        fs::create_dir_all(&root).unwrap();
615
616        let init_status = Command::new("git")
617            .arg("-C")
618            .arg(&root)
619            .arg("init")
620            .status()
621            .unwrap();
622        assert!(init_status.success());
623
624        let workspace = Workspace::init(&root).unwrap();
625        let duplicate_id = "dup-1".to_string();
626
627        workspace
628            .set_state(&WorkspaceState {
629                version: 1,
630                atoms: vec![
631                    LoreAtom {
632                        id: duplicate_id.clone(),
633                        kind: LoreKind::Decision,
634                        state: AtomState::Proposed,
635                        title: "Older duplicate".to_string(),
636                        body: None,
637                        scope: None,
638                        path: None,
639                        validation_script: None,
640                        created_unix_seconds: 1,
641                    },
642                    LoreAtom {
643                        id: duplicate_id.clone(),
644                        kind: LoreKind::Decision,
645                        state: AtomState::Accepted,
646                        title: "Newer duplicate".to_string(),
647                        body: Some("more complete".to_string()),
648                        scope: Some("sync".to_string()),
649                        path: Some(PathBuf::from("src/git/mod.rs")),
650                        validation_script: None,
651                        created_unix_seconds: 2,
652                    },
653                ],
654            })
655            .unwrap();
656
657        let synced = sync_workspace_from_git_history(&root, &workspace).unwrap();
658
659        assert_eq!(synced.len(), 1);
660        assert_eq!(synced[0].id, duplicate_id);
661        assert_eq!(synced[0].title, "Newer duplicate");
662    }
663}