Skip to main content

agent_trace/state/
git.rs

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
17/// Serialize git index/commit operations per store root (background summary refresh
18/// and the poll loop share the same repo directory).
19fn 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    /// The working directory (store root).
41    pub workdir: PathBuf,
42}
43
44impl GitStore {
45    // ── Init / Open ───────────────────────────────────────────────────────
46
47    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        // Write exclude file so .agent-trace itself is never tracked.
59        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        // Create initial empty commit.
72        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            &[], // no parents for root commit
110        )?;
111        Ok(oid)
112    }
113
114    // ── Status Detection ─────────────────────────────────────────────────
115
116    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            // Rename: has both head_to_index and index_to_workdir paths.
135            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    // ── Commit Operations ─────────────────────────────────────────────────
179
180    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    /// Current HEAD commit OID (for TUI poll dedup).
225    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    /// Commits reachable from HEAD but not including `since`, oldest first.
232    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    // ── Log / History ─────────────────────────────────────────────────────
260
261    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            // Check if this commit touches the file.
296            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    /// Count the number of commits that touched `path` without loading them into memory.
311    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    // ── Diff Operations ───────────────────────────────────────────────────
330
331    /// Diff a file between versions. None = working tree / latest commit.
332    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        // history is newest-first; version 1 = oldest
390        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    /// Resolve (old_tree, new_tree) for a diff. Returns Option<Tree> so None means the working tree.
405    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                // Default: compare latest commit with working tree (None means worktree).
428                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, // working tree
439        };
440
441        Ok((old, new))
442    }
443
444    // ── Restore / Revert ──────────────────────────────────────────────────
445
446    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    /// Return all .md file paths tracked in the git HEAD tree (relative to workdir).
481    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
515// ── Commit message helpers ────────────────────────────────────────────────────
516
517fn build_commit_message(info: &CommitInfo) -> String {
518    // Subject: [agent-trace] <action> <type>: <file>
519    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    // Try to parse structured message.
557    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    // Parse action from subject: "[agent-trace] modify plan: prd.md"
595    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    // Tab-delimited: "{path}\t{action}\t{doc_type}"
633    // Note: the "file:\t" prefix is already stripped by strip_prefix("file: ") ... wait,
634    // actually strip_prefix("file: ") won't match "file:\t". The caller uses
635    // strip_prefix("file: ") but we changed the format to "file:\t". We need to handle
636    // the raw value after the "file:\t" prefix, which means s here is already the remainder.
637    // The format written is "file:\t{path}\t{action}\t{doc_type}\n"
638    // The body line is "file:\t{path}\t{action}\t{doc_type}"
639    // strip_prefix("file: ") won't match, so we handle stripping in parse_structured_message.
640    // s here is whatever comes after "file:" is stripped — so s = "\t{path}\t{action}\t{doc_type}"
641    // We split on '\t', skip the first empty/whitespace element from the leading tab.
642    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
653/// Whether a filesystem path should count as an activity event (poll / ops counter).
654pub 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        // root commit + 2 file commits
833        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    // ── parse_file_line unit tests ──────────────────────────────────────────
912
913    #[test]
914    fn test_parse_file_line_normal() {
915        // Round-trip: path without spaces
916        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        // Path containing spaces must round-trip correctly with tab delimiter
928        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        // Unknown action string should fall back to Action::Unknown gracefully
943        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        // Unknown doc_type string should fall back to DocType::Scratch gracefully
957        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        // Full round-trip: write a commit with a path containing spaces, read it back
970        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}