1use crate::types::{Action, Actor, CommitId, DiffStats, DocType, FileChange, LogEntry};
2
3type ParsedCommit = (
4 Action,
5 String,
6 Actor,
7 Option<String>,
8 Vec<(PathBuf, Action, DocType)>,
9);
10use anyhow::{bail, Context, Result};
11use chrono::{TimeZone, Utc};
12use git2::{DiffOptions, Oid, Repository, RepositoryInitOptions, Signature, StatusOptions, Tree};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, LazyLock, Mutex};
16
17fn store_git_lock(workdir: &Path) -> Arc<Mutex<()>> {
20 static LOCKS: LazyLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> =
21 LazyLock::new(|| Mutex::new(HashMap::new()));
22 let mut locks = LOCKS.lock().expect("store git lock map poisoned");
23 locks
24 .entry(workdir.to_path_buf())
25 .or_insert_with(|| Arc::new(Mutex::new(())))
26 .clone()
27}
28
29pub struct CommitInfo {
30 pub action: Action,
31 pub files: Vec<(PathBuf, Action, DocType)>,
32 pub actor: Actor,
33 pub summary: String,
34 pub agent_name: Option<String>,
35 pub session_id: Option<String>,
36}
37
38pub struct GitStore {
39 repo: Repository,
40 pub workdir: PathBuf,
42}
43
44impl GitStore {
45 pub fn init(store_root: &Path) -> Result<Self> {
48 let git_dir = store_root.join(".agent-trace").join("repo");
49
50 let mut opts = RepositoryInitOptions::new();
51 opts.bare(false);
52 opts.workdir_path(store_root);
53 opts.no_reinit(false);
54
55 let repo = Repository::init_opts(&git_dir, &opts)
56 .with_context(|| format!("Initialising git repo at {}", git_dir.display()))?;
57
58 let exclude = git_dir.join("info").join("exclude");
60 std::fs::create_dir_all(exclude.parent().unwrap())?;
61 std::fs::write(
62 &exclude,
63 ".agent-trace/\n.venv/\nvenv/\nnode_modules/\n__pycache__/\n*.pyc\n",
64 )?;
65
66 let store = Self {
67 repo,
68 workdir: store_root.to_path_buf(),
69 };
70
71 store.create_empty_commit("agent-trace store initialized")?;
73
74 Ok(store)
75 }
76
77 pub fn open(store_root: &Path) -> Result<Self> {
78 let git_dir = store_root.join(".agent-trace").join("repo");
79 if !git_dir.exists() {
80 bail!(
81 "Not an agent-trace store: .agent-trace/repo not found in {}",
82 store_root.display()
83 );
84 }
85 let repo = Repository::open(&git_dir)
86 .with_context(|| format!("Opening git repo at {}", git_dir.display()))?;
87 Ok(Self {
88 repo,
89 workdir: store_root.to_path_buf(),
90 })
91 }
92
93 fn create_empty_commit(&self, message: &str) -> Result<Oid> {
94 let lock = store_git_lock(&self.workdir);
95 let _guard = lock.lock().expect("store git lock poisoned");
96 let sig = Signature::now("agent-trace", "system@agent-trace")?;
97 let tree_oid = {
98 let mut index = self.repo.index()?;
99 index.write_tree()?
100 };
101 let tree = self.repo.find_tree(tree_oid)?;
102
103 let oid = self.repo.commit(
104 Some("HEAD"),
105 &sig,
106 &sig,
107 message,
108 &tree,
109 &[], )?;
111 Ok(oid)
112 }
113
114 pub fn detect_changes(&self) -> Result<Vec<FileChange>> {
117 let lock = store_git_lock(&self.workdir);
118 let _guard = lock.lock().expect("store git lock poisoned");
119 let mut opts = StatusOptions::new();
120 opts.include_untracked(true)
121 .recurse_untracked_dirs(true)
122 .include_ignored(false)
123 .exclude_submodules(true)
124 .renames_from_rewrites(true)
125 .renames_index_to_workdir(true)
126 .renames_head_to_index(true);
127
128 let statuses = self.repo.statuses(Some(&mut opts))?;
129 let mut changes = Vec::new();
130
131 for entry in statuses.iter() {
132 let s = entry.status();
133
134 if s.is_index_renamed() || s.is_wt_renamed() {
136 let new_path = match entry.path() {
137 Some(p) => PathBuf::from(p),
138 None => continue,
139 };
140 let old_path = entry
141 .head_to_index()
142 .and_then(|d| d.old_file().path())
143 .or_else(|| entry.index_to_workdir().and_then(|d| d.old_file().path()))
144 .map(PathBuf::from)
145 .unwrap_or_else(|| new_path.clone());
146
147 if !should_track_activity(&new_path) && !should_track_activity(&old_path) {
148 continue;
149 }
150 changes.push(FileChange::Renamed {
151 from: old_path,
152 to: new_path,
153 });
154 continue;
155 }
156
157 let path = match entry.path() {
158 Some(p) => PathBuf::from(p),
159 None => continue,
160 };
161
162 if !should_track_activity(&path) {
163 continue;
164 }
165
166 if s.is_wt_new() || s.is_index_new() {
167 changes.push(FileChange::New(path));
168 } else if s.is_wt_modified() || s.is_index_modified() {
169 changes.push(FileChange::Modified(path));
170 } else if s.is_wt_deleted() || s.is_index_deleted() {
171 changes.push(FileChange::Deleted(path));
172 }
173 }
174
175 Ok(changes)
176 }
177
178 pub fn commit(&self, info: &CommitInfo) -> Result<Oid> {
181 let lock = store_git_lock(&self.workdir);
182 let _guard = lock.lock().expect("store git lock poisoned");
183 let mut index = self.repo.index()?;
184
185 for (path, action, _doc_type) in &info.files {
186 match action {
187 Action::Delete => {
188 if let Err(e) = index.remove_path(path) {
189 tracing::warn!("Could not remove {} from index: {}", path.display(), e);
190 }
191 }
192 _ => {
193 index
194 .add_path(path)
195 .with_context(|| format!("Staging {}", path.display()))?;
196 }
197 }
198 }
199 index.write()?;
200 let tree_oid = index.write_tree()?;
201 let tree = self.repo.find_tree(tree_oid)?;
202
203 let parent_commit = self.head_commit()?;
204 let parents = vec![&parent_commit];
205
206 let author_name = info.actor.git_author_name();
207 let author_email = info.actor.git_author_email();
208 let sig = Signature::now(&author_name, author_email)?;
209
210 let message = build_commit_message(info);
211
212 let oid = self
213 .repo
214 .commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)?;
215 Ok(oid)
216 }
217
218 fn head_commit(&self) -> Result<git2::Commit<'_>> {
219 let head = self.repo.head()?;
220 let commit = head.peel_to_commit()?;
221 Ok(commit)
222 }
223
224 pub fn head_oid(&self) -> Result<Oid> {
226 let lock = store_git_lock(&self.workdir);
227 let _guard = lock.lock().expect("store git lock poisoned");
228 Ok(self.head_commit()?.id())
229 }
230
231 pub fn commits_since(&self, since: Oid) -> Result<Vec<LogEntry>> {
233 let lock = store_git_lock(&self.workdir);
234 let _guard = lock.lock().expect("store git lock poisoned");
235
236 if self.head_commit()?.id() == since {
237 return Ok(Vec::new());
238 }
239
240 let mut walk = self.repo.revwalk()?;
241 walk.push_head()?;
242 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
243
244 let mut entries = Vec::new();
245 for oid_result in walk {
246 let oid = oid_result?;
247 if oid == since {
248 break;
249 }
250 let commit = self.repo.find_commit(oid)?;
251 if let Some(entry) = parse_commit(&commit) {
252 entries.push(entry);
253 }
254 }
255 entries.reverse();
256 Ok(entries)
257 }
258
259 pub fn log(&self, limit: usize) -> Result<Vec<LogEntry>> {
262 let mut walk = self.repo.revwalk()?;
263 walk.push_head()?;
264 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
265
266 let mut entries = Vec::new();
267 for oid_result in walk {
268 if entries.len() >= limit {
269 break;
270 }
271 let oid = oid_result?;
272 let commit = self.repo.find_commit(oid)?;
273 if let Some(entry) = parse_commit(&commit) {
274 entries.push(entry);
275 }
276 }
277 Ok(entries)
278 }
279
280 pub fn log_file(&self, path: &Path, limit: usize) -> Result<Vec<LogEntry>> {
281 let mut walk = self.repo.revwalk()?;
282 walk.push_head()?;
283 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
284
285 let path_str = path.to_string_lossy().to_string();
286 let mut entries = Vec::new();
287
288 for oid_result in walk {
289 if entries.len() >= limit {
290 break;
291 }
292 let oid = oid_result?;
293 let commit = self.repo.find_commit(oid)?;
294
295 if !commit_touches_file(&self.repo, &commit, &path_str)? {
297 continue;
298 }
299 if let Some(entry) = parse_commit(&commit) {
300 entries.push(entry);
301 }
302 }
303 Ok(entries)
304 }
305
306 pub fn version_count(&self, path: &Path) -> Result<u32> {
307 Ok(self.count_file_commits(path)? as u32)
308 }
309
310 pub fn count_file_commits(&self, path: &Path) -> Result<usize> {
312 let mut walk = self.repo.revwalk()?;
313 walk.push_head()?;
314 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
315
316 let path_str = path.to_string_lossy().to_string();
317 let mut count = 0usize;
318
319 for oid_result in walk {
320 let oid = oid_result?;
321 let commit = self.repo.find_commit(oid)?;
322 if commit_touches_file(&self.repo, &commit, &path_str)? {
323 count += 1;
324 }
325 }
326 Ok(count)
327 }
328
329 pub fn diff_file(&self, path: &Path, v1: Option<u32>, v2: Option<u32>) -> Result<String> {
333 let path_str = path.to_string_lossy().to_string();
334
335 let (old_tree, new_tree) = self.resolve_version_trees(path, v1, v2)?;
336
337 let mut diff_opts = DiffOptions::new();
338 diff_opts.pathspec(&path_str);
339
340 let diff = self.repo.diff_tree_to_tree(
341 old_tree.as_ref(),
342 new_tree.as_ref(),
343 Some(&mut diff_opts),
344 )?;
345
346 let mut output = String::new();
347 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
348 let origin = line.origin();
349 let content = std::str::from_utf8(line.content()).unwrap_or("");
350 match origin {
351 '+' | '-' | ' ' => output.push(origin),
352 _ => {}
353 }
354 output.push_str(content);
355 true
356 })?;
357
358 if output.is_empty() {
359 output = "No changes".to_string();
360 }
361 Ok(output)
362 }
363
364 pub fn diff_stats(&self, path: &Path, v1: Option<u32>, v2: Option<u32>) -> Result<DiffStats> {
365 let path_str = path.to_string_lossy().to_string();
366 let (old_tree, new_tree) = self.resolve_version_trees(path, v1, v2)?;
367
368 let mut diff_opts = DiffOptions::new();
369 diff_opts.pathspec(&path_str);
370
371 let diff = self.repo.diff_tree_to_tree(
372 old_tree.as_ref(),
373 new_tree.as_ref(),
374 Some(&mut diff_opts),
375 )?;
376
377 let stats = diff.stats()?;
378 Ok(DiffStats {
379 lines_added: stats.insertions(),
380 lines_removed: stats.deletions(),
381 })
382 }
383
384 pub fn show_file_at_version(&self, path: &Path, version: u32) -> Result<String> {
385 let history = self.log_file(path, usize::MAX)?;
386 if version == 0 || version as usize > history.len() {
387 bail!("Version {} does not exist for {}", version, path.display());
388 }
389 let idx = history.len() - version as usize;
391 let commit_id = &history[idx].commit_id;
392 let oid = Oid::from_str(&commit_id.0)?;
393 let commit = self.repo.find_commit(oid)?;
394 let tree = commit.tree()?;
395
396 let path_str = path.to_string_lossy();
397 let entry = tree
398 .get_path(Path::new(path_str.as_ref()))
399 .with_context(|| format!("File {} not found at v{}", path.display(), version))?;
400 let blob = self.repo.find_blob(entry.id())?;
401 Ok(std::str::from_utf8(blob.content())?.to_string())
402 }
403
404 fn resolve_version_trees(
406 &self,
407 path: &Path,
408 v1: Option<u32>,
409 v2: Option<u32>,
410 ) -> Result<(Option<Tree<'_>>, Option<Tree<'_>>)> {
411 let history = self.log_file(path, usize::MAX)?;
412 let n = history.len();
413
414 let tree_at = |v: u32| -> Result<Tree<'_>> {
415 if v == 0 || v as usize > n {
416 bail!("Version {v} does not exist");
417 }
418 let idx = n - v as usize;
419 let oid = Oid::from_str(&history[idx].commit_id.0)?;
420 let commit = self.repo.find_commit(oid)?;
421 Ok(commit.tree()?)
422 };
423
424 let old = match v1 {
425 Some(v) => Some(tree_at(v)?),
426 None => {
427 if n == 0 {
429 None
430 } else {
431 Some(tree_at(n as u32)?)
432 }
433 }
434 };
435
436 let new = match v2 {
437 Some(v) => Some(tree_at(v)?),
438 None => None, };
440
441 Ok((old, new))
442 }
443
444 pub fn restore_file(&self, path: &Path, version: u32, doc_type: DocType) -> Result<Oid> {
447 let content = self.show_file_at_version(path, version)?;
448 let full_path = self.workdir.join(path);
449 if let Some(parent) = full_path.parent() {
450 std::fs::create_dir_all(parent)?;
451 }
452 std::fs::write(&full_path, &content)?;
453
454 let info = CommitInfo {
455 action: Action::Restore,
456 files: vec![(path.to_path_buf(), Action::Modify, doc_type)],
457 actor: Actor::System,
458 summary: format!("restore {}: from version {}", path.display(), version),
459 agent_name: None,
460 session_id: None,
461 };
462 self.commit(&info)
463 }
464
465 pub fn revert_file(&self, path: &Path) -> Result<()> {
466 let lock = store_git_lock(&self.workdir);
467 let _guard = lock.lock().expect("store git lock poisoned");
468 let head = self.head_commit()?;
469 let tree = head.tree()?;
470 let path_str = path.to_string_lossy();
471 let entry = tree
472 .get_path(Path::new(path_str.as_ref()))
473 .with_context(|| format!("File {} not found in HEAD", path.display()))?;
474 let blob = self.repo.find_blob(entry.id())?;
475 let full_path = self.workdir.join(path);
476 std::fs::write(&full_path, blob.content())?;
477 Ok(())
478 }
479
480 pub fn head_md_files(&self) -> Result<Vec<PathBuf>> {
482 let head = self.head_commit()?;
483 let tree = head.tree()?;
484 let mut paths = Vec::new();
485 tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
486 if entry.kind() == Some(git2::ObjectType::Blob) {
487 let name = entry.name().unwrap_or("");
488 if name.ends_with(".md") {
489 let rel = if root.is_empty() {
490 PathBuf::from(name)
491 } else {
492 PathBuf::from(root).join(name)
493 };
494 paths.push(rel);
495 }
496 }
497 git2::TreeWalkResult::Ok
498 })?;
499 Ok(paths)
500 }
501
502 pub fn save_rejected(&self, path: &Path, content: &str) -> Result<()> {
503 let rejected_dir = self.workdir.join(".agent-trace").join("rejected");
504 std::fs::create_dir_all(&rejected_dir)?;
505 let filename = format!(
506 "{}-{}.rejected",
507 path.file_stem().unwrap_or_default().to_string_lossy(),
508 chrono::Utc::now().timestamp()
509 );
510 std::fs::write(rejected_dir.join(filename), content)?;
511 Ok(())
512 }
513}
514
515fn build_commit_message(info: &CommitInfo) -> String {
518 let first_file = info.files.first();
520 let subject = if let Some((path, _action, doc_type)) = first_file {
521 format!(
522 "[agent-trace] {} {}: {}",
523 info.action,
524 doc_type,
525 path.display()
526 )
527 } else {
528 format!("[agent-trace] {}", info.action)
529 };
530
531 let mut body = format!("summary: {}\n", info.summary);
532 body.push_str(&format!("actor: {}\n", info.actor));
533 if let Some(agent) = &info.agent_name {
534 body.push_str(&format!("agent: {agent}\n"));
535 }
536 if let Some(session) = &info.session_id {
537 body.push_str(&format!("session: {session}\n"));
538 }
539 for (path, action, doc_type) in &info.files {
540 body.push_str(&format!(
541 "file:\t{}\t{}\t{}\n",
542 path.display(),
543 action,
544 doc_type
545 ));
546 }
547
548 format!("{subject}\n\n{body}")
549}
550
551fn parse_commit(commit: &git2::Commit<'_>) -> Option<LogEntry> {
552 let message = commit.message().unwrap_or("");
553 let timestamp = Utc.timestamp_opt(commit.time().seconds(), 0).single()?;
554 let commit_id = CommitId(commit.id().to_string());
555
556 let lines: Vec<&str> = message.lines().collect();
558 let subject = lines.first().unwrap_or(&"");
559
560 let (action, summary, actor, agent_name, files) = if subject.starts_with("[agent-trace]") {
561 parse_structured_message(message)
562 } else {
563 (
564 Action::Unknown,
565 message.to_string(),
566 Actor::System,
567 None,
568 Vec::new(),
569 )
570 };
571
572 Some(LogEntry {
573 commit_id,
574 timestamp,
575 action,
576 actor,
577 agent_name,
578 files,
579 summary,
580 })
581}
582
583fn parse_structured_message(message: &str) -> ParsedCommit {
584 let mut action = Action::Unknown;
585 let mut summary = message.to_string();
586 let mut actor = Actor::System;
587 let mut agent_name: Option<String> = None;
588 let mut files = Vec::new();
589
590 let parts: Vec<&str> = message.splitn(2, "\n\n").collect();
591 let subject = parts[0];
592 let body = parts.get(1).copied().unwrap_or("");
593
594 if let Some(rest) = subject.strip_prefix("[agent-trace] ") {
596 let first_word = rest.split_whitespace().next().unwrap_or("");
597 action = first_word.parse().unwrap_or(Action::Unknown);
598 }
599
600 for line in body.lines() {
601 if let Some(val) = line.strip_prefix("summary: ") {
602 summary = val.to_string();
603 } else if let Some(val) = line.strip_prefix("actor: ") {
604 actor = parse_actor_str(val);
605 } else if let Some(val) = line.strip_prefix("agent: ") {
606 agent_name = Some(val.to_string());
607 } else if let Some(val) = line.strip_prefix("file:") {
608 if let Some(entry) = parse_file_line(val) {
609 files.push(entry);
610 }
611 }
612 }
613
614 (action, summary, actor, agent_name, files)
615}
616
617fn parse_actor_str(s: &str) -> Actor {
618 if s == "user" {
619 Actor::User
620 } else if s == "system" {
621 Actor::System
622 } else if let Some(name) = s.strip_prefix("agent:") {
623 Actor::Agent {
624 name: name.to_string(),
625 }
626 } else {
627 Actor::System
628 }
629}
630
631fn parse_file_line(s: &str) -> Option<(PathBuf, Action, DocType)> {
632 let s = s.trim_start_matches('\t');
643 let parts: Vec<&str> = s.splitn(3, '\t').collect();
644 if parts.len() < 3 {
645 return None;
646 }
647 let path = PathBuf::from(parts[0]);
648 let action = parts[1].parse().unwrap_or(Action::Unknown);
649 let doc_type = parts[2].trim_end().parse().unwrap_or(DocType::Scratch);
650 Some((path, action, doc_type))
651}
652
653pub fn should_track_activity(path: &Path) -> bool {
655 for component in path.components() {
656 let name = component.as_os_str().to_string_lossy();
657 if matches!(
658 name.as_ref(),
659 ".agent-trace"
660 | ".git"
661 | ".venv"
662 | "venv"
663 | "node_modules"
664 | "__pycache__"
665 | "target"
666 | "dist"
667 ) {
668 return false;
669 }
670 }
671 if path
672 .file_name()
673 .is_some_and(|n| n.to_string_lossy() == ".DS_Store")
674 {
675 return false;
676 }
677 if path.extension().and_then(|e| e.to_str()) == Some("pyc") {
678 return false;
679 }
680 true
681}
682
683fn commit_touches_file(repo: &Repository, commit: &git2::Commit<'_>, path: &str) -> Result<bool> {
684 let tree = commit.tree()?;
685 let parent_tree: Option<Tree<'_>> = if commit.parent_count() > 0 {
686 Some(commit.parent(0)?.tree()?)
687 } else {
688 None
689 };
690
691 let mut diff_opts = DiffOptions::new();
692 diff_opts.pathspec(path);
693
694 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
695
696 Ok(diff.deltas().count() > 0)
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use tempfile::TempDir;
703
704 fn setup_store() -> (TempDir, GitStore) {
705 let tmp = TempDir::new().unwrap();
706 std::fs::create_dir_all(tmp.path().join(".agent-trace")).unwrap();
707 let store = GitStore::init(tmp.path()).unwrap();
708 (tmp, store)
709 }
710
711 fn write_md(store: &GitStore, name: &str, content: &str) -> PathBuf {
712 let path = store.workdir.join(name);
713 std::fs::write(&path, content).unwrap();
714 PathBuf::from(name)
715 }
716
717 fn commit_file(store: &GitStore, rel_path: &Path, action: Action) {
718 let info = CommitInfo {
719 action: action.clone(),
720 files: vec![(rel_path.to_path_buf(), action, DocType::Plan)],
721 actor: Actor::System,
722 summary: format!("test commit {}", rel_path.display()),
723 agent_name: None,
724 session_id: None,
725 };
726 store.commit(&info).unwrap();
727 }
728
729 #[test]
730 fn test_init_creates_repo() {
731 let tmp = TempDir::new().unwrap();
732 std::fs::create_dir_all(tmp.path().join(".agent-trace")).unwrap();
733 let store = GitStore::init(tmp.path()).unwrap();
734 assert!(tmp.path().join(".agent-trace").join("repo").exists());
735 assert_eq!(store.workdir, tmp.path());
736 }
737
738 #[test]
739 fn test_open_nonexistent_fails() {
740 let tmp = TempDir::new().unwrap();
741 assert!(GitStore::open(tmp.path()).is_err());
742 }
743
744 #[test]
745 fn test_detect_changes_new_md() {
746 let (_tmp, store) = setup_store();
747 write_md(&store, "notes.md", "# hello");
748 let changes = store.detect_changes().unwrap();
749 assert_eq!(changes.len(), 1);
750 assert!(matches!(changes[0], FileChange::New(_)));
751 }
752
753 #[test]
754 fn test_detect_changes_tracks_non_md() {
755 let (_tmp, store) = setup_store();
756 std::fs::write(store.workdir.join("script.py"), "print('hi')").unwrap();
757 let changes = store.detect_changes().unwrap();
758 assert_eq!(changes.len(), 1);
759 assert!(matches!(changes[0], FileChange::New(_)));
760 }
761
762 #[test]
763 fn test_detect_changes_excludes_venv() {
764 let (_tmp, store) = setup_store();
765 std::fs::create_dir_all(store.workdir.join(".venv/lib")).unwrap();
766 std::fs::write(store.workdir.join(".venv/lib/site.py"), "x").unwrap();
767 let changes = store.detect_changes().unwrap();
768 assert!(changes.is_empty());
769 }
770
771 #[test]
772 fn test_should_track_activity_rules() {
773 assert!(should_track_activity(&PathBuf::from("src/main.rs")));
774 assert!(should_track_activity(&PathBuf::from("notes.md")));
775 assert!(!should_track_activity(&PathBuf::from(".venv/lib/x.py")));
776 assert!(!should_track_activity(&PathBuf::from(
777 "node_modules/pkg/index.js"
778 )));
779 assert!(!should_track_activity(&PathBuf::from(
780 ".agent-trace/config.toml"
781 )));
782 }
783
784 #[test]
785 fn test_detect_changes_no_non_md() {
786 let (_tmp, store) = setup_store();
787 std::fs::write(store.workdir.join(".DS_Store"), "ignored").unwrap();
788 let changes = store.detect_changes().unwrap();
789 assert!(changes.is_empty());
790 }
791
792 #[test]
793 fn test_detect_changes_modified() {
794 let (_tmp, store) = setup_store();
795 let rel = write_md(&store, "plan.md", "v1");
796 commit_file(&store, &rel, Action::Create);
797 std::fs::write(store.workdir.join("plan.md"), "v2").unwrap();
798 let changes = store.detect_changes().unwrap();
799 assert!(changes.iter().any(|c| matches!(c, FileChange::Modified(_))));
800 }
801
802 #[test]
803 fn test_commit_attribution() {
804 let (_tmp, store) = setup_store();
805 let rel = write_md(&store, "prd.md", "content");
806 let info = CommitInfo {
807 action: Action::Create,
808 files: vec![(rel.clone(), Action::Create, DocType::Plan)],
809 actor: Actor::Agent {
810 name: "claude-code".into(),
811 },
812 summary: "add prd".into(),
813 agent_name: Some("claude-code".into()),
814 session_id: None,
815 };
816 store.commit(&info).unwrap();
817
818 let head = store.head_commit().unwrap();
819 assert_eq!(head.author().name().unwrap(), "Agent: claude-code");
820 assert_eq!(head.author().email().unwrap(), "agent@agent-trace");
821 }
822
823 #[test]
824 fn test_log_returns_entries() {
825 let (_tmp, store) = setup_store();
826 let r1 = write_md(&store, "a.md", "a");
827 commit_file(&store, &r1, Action::Create);
828 let r2 = write_md(&store, "b.md", "b");
829 commit_file(&store, &r2, Action::Create);
830
831 let entries = store.log(10).unwrap();
832 assert!(entries.len() >= 2);
834 }
835
836 #[test]
837 fn test_log_file_filters() {
838 let (_tmp, store) = setup_store();
839 let r1 = write_md(&store, "prd.md", "v1");
840 commit_file(&store, &r1, Action::Create);
841 let r2 = write_md(&store, "other.md", "x");
842 commit_file(&store, &r2, Action::Create);
843 std::fs::write(store.workdir.join("prd.md"), "v2").unwrap();
844 commit_file(&store, &r1, Action::Modify);
845
846 let entries = store.log_file(&PathBuf::from("prd.md"), 10).unwrap();
847 assert_eq!(entries.len(), 2);
848 }
849
850 #[test]
851 fn test_version_count() {
852 let (_tmp, store) = setup_store();
853 let rel = write_md(&store, "prd.md", "v1");
854 commit_file(&store, &rel, Action::Create);
855 std::fs::write(store.workdir.join("prd.md"), "v2").unwrap();
856 commit_file(&store, &rel, Action::Modify);
857 assert_eq!(store.version_count(&PathBuf::from("prd.md")).unwrap(), 2);
858 }
859
860 #[test]
861 fn test_show_file_at_version() {
862 let (_tmp, store) = setup_store();
863 let rel = write_md(&store, "prd.md", "version one");
864 commit_file(&store, &rel, Action::Create);
865 std::fs::write(store.workdir.join("prd.md"), "version two").unwrap();
866 commit_file(&store, &rel, Action::Modify);
867
868 let v1 = store
869 .show_file_at_version(&PathBuf::from("prd.md"), 1)
870 .unwrap();
871 assert_eq!(v1.trim(), "version one");
872 let v2 = store
873 .show_file_at_version(&PathBuf::from("prd.md"), 2)
874 .unwrap();
875 assert_eq!(v2.trim(), "version two");
876 }
877
878 #[test]
879 fn test_revert_file() {
880 let (_tmp, store) = setup_store();
881 let rel = write_md(&store, "prd.md", "original");
882 commit_file(&store, &rel, Action::Create);
883 std::fs::write(store.workdir.join("prd.md"), "unauthorized change").unwrap();
884 store.revert_file(&PathBuf::from("prd.md")).unwrap();
885 let content = std::fs::read_to_string(store.workdir.join("prd.md")).unwrap();
886 assert_eq!(content.trim(), "original");
887 }
888
889 #[test]
890 fn test_commit_message_format() {
891 let (_tmp, store) = setup_store();
892 let rel = write_md(&store, "prd.md", "content");
893 let info = CommitInfo {
894 action: Action::Modify,
895 files: vec![(rel.clone(), Action::Modify, DocType::Plan)],
896 actor: Actor::User,
897 summary: "updated prd".into(),
898 agent_name: None,
899 session_id: None,
900 };
901 store.commit(&info).unwrap();
902 let head = store.head_commit().unwrap();
903 let msg = head.message().unwrap();
904 assert!(
905 msg.contains("[agent-trace] modify plan: prd.md"),
906 "Got: {msg}"
907 );
908 assert!(msg.contains("actor: user"));
909 }
910
911 #[test]
914 fn test_parse_file_line_normal() {
915 let s = "\tprd.md\tmodify\tplan";
917 let result = parse_file_line(s);
918 assert!(result.is_some(), "Expected Some, got None");
919 let (path, action, doc_type) = result.unwrap();
920 assert_eq!(path, PathBuf::from("prd.md"));
921 assert!(matches!(action, Action::Modify), "action = {action:?}");
922 assert!(matches!(doc_type, DocType::Plan), "doc_type = {doc_type:?}");
923 }
924
925 #[test]
926 fn test_parse_file_line_path_with_spaces() {
927 let s = "\tmy plan.md\tcreate\tplan";
929 let result = parse_file_line(s);
930 assert!(
931 result.is_some(),
932 "Expected Some for path-with-spaces, got None"
933 );
934 let (path, action, doc_type) = result.unwrap();
935 assert_eq!(path, PathBuf::from("my plan.md"));
936 assert!(matches!(action, Action::Create), "action = {action:?}");
937 assert!(matches!(doc_type, DocType::Plan), "doc_type = {doc_type:?}");
938 }
939
940 #[test]
941 fn test_parse_file_line_unknown_action() {
942 let s = "\tnotes.md\tfrob\tscratch";
944 let result = parse_file_line(s);
945 assert!(result.is_some(), "Expected Some even with unknown action");
946 let (path, action, _doc_type) = result.unwrap();
947 assert_eq!(path, PathBuf::from("notes.md"));
948 assert!(
949 matches!(action, Action::Unknown),
950 "Expected Unknown, got {action:?}"
951 );
952 }
953
954 #[test]
955 fn test_parse_file_line_unknown_doc_type() {
956 let s = "\tnotes.md\tmodify\tfluxcapacitor";
958 let result = parse_file_line(s);
959 assert!(result.is_some(), "Expected Some even with unknown doc_type");
960 let (_path, _action, doc_type) = result.unwrap();
961 assert!(
962 matches!(doc_type, DocType::Scratch),
963 "Expected Scratch fallback, got {doc_type:?}"
964 );
965 }
966
967 #[test]
968 fn test_parse_file_line_roundtrip_via_commit() {
969 let (_tmp, store) = setup_store();
971 let rel = write_md(&store, "my plan.md", "content with spaces in name");
972 let info = CommitInfo {
973 action: Action::Create,
974 files: vec![(rel.clone(), Action::Create, DocType::Plan)],
975 actor: Actor::User,
976 summary: "add my plan".into(),
977 agent_name: None,
978 session_id: None,
979 };
980 store.commit(&info).unwrap();
981 let entries = store.log_file(&rel, 5).unwrap();
982 assert_eq!(entries.len(), 1);
983 let entry = &entries[0];
984 assert_eq!(entry.files.len(), 1);
985 assert_eq!(entry.files[0].0, PathBuf::from("my plan.md"));
986 assert!(matches!(entry.files[0].2, DocType::Plan));
987 }
988
989 #[test]
990 fn test_count_file_commits() {
991 let (_tmp, store) = setup_store();
992 let rel = write_md(&store, "prd.md", "v1");
993 commit_file(&store, &rel, Action::Create);
994 std::fs::write(store.workdir.join("prd.md"), "v2").unwrap();
995 commit_file(&store, &rel, Action::Modify);
996 let count = store.count_file_commits(&PathBuf::from("prd.md")).unwrap();
997 assert_eq!(count, 2);
998 }
999}