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 ¬e,
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}