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 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}