use anyhow::{bail, Context, Result};
use std::path::Path;
use std::process::Command;
use crate::component;
use crate::snapshot;
const EXCHANGE_COMPONENT: &str = "exchange";
struct HistoryEntry {
commit: String,
date: String,
summary: String,
}
fn extract_exchange(doc: &str) -> Option<String> {
let components = component::parse(doc).ok()?;
let exchange = components.iter().find(|c| c.name == EXCHANGE_COMPONENT)?;
Some(exchange.content(doc).to_string())
}
fn resolve_git_paths(file: &Path) -> Result<(std::path::PathBuf, String)> {
let canonical = file
.canonicalize()
.with_context(|| format!("file not found: {}", file.display()))?;
let parent = canonical.parent().unwrap_or(Path::new("/"));
let output = Command::new("git")
.current_dir(parent)
.args(["rev-parse", "--show-toplevel"])
.output()
.context("failed to run git rev-parse")?;
if !output.status.success() {
bail!("file is not in a git repository: {}", file.display());
}
let git_root = std::path::PathBuf::from(
String::from_utf8_lossy(&output.stdout).trim(),
);
let rel_path = canonical
.strip_prefix(&git_root)
.with_context(|| format!(
"file {} is not under git root {}",
canonical.display(),
git_root.display()
))?
.to_string_lossy()
.to_string();
Ok((git_root, rel_path))
}
pub fn list(file: &Path) -> Result<()> {
let (git_root, rel_path) = resolve_git_paths(file)?;
let output = Command::new("git")
.current_dir(&git_root)
.args(["log", "--format=%H %ai", "--", &rel_path])
.output()
.context("failed to run git log")?;
if !output.status.success() {
bail!("git log failed for {}", file.display());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
if lines.is_empty() {
eprintln!("No git history found for {}", file.display());
return Ok(());
}
let mut entries: Vec<HistoryEntry> = Vec::new();
for line in &lines {
let parts: Vec<&str> = line.splitn(2, ' ').collect();
if parts.len() < 2 {
continue;
}
let commit = parts[0].to_string();
let date = parts[1].to_string();
let show_output = Command::new("git")
.current_dir(&git_root)
.args(["show", &format!("{}:{}", commit, rel_path)])
.output();
let summary = match show_output {
Ok(ref o) if o.status.success() => {
let content = String::from_utf8_lossy(&o.stdout);
match extract_exchange(&content) {
Some(exchange) => {
let first_line = exchange
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.to_string();
if first_line.len() > 72 {
format!("{}...", &first_line[..72])
} else {
first_line
}
}
None => "(no exchange component)".to_string(),
}
}
_ => "(file not available)".to_string(),
};
entries.push(HistoryEntry {
commit,
date,
summary,
});
}
if entries.is_empty() {
eprintln!("No commits found for {}", file.display());
return Ok(());
}
println!("{:<12} {:<26} EXCHANGE", "COMMIT", "DATE");
println!("{}", "-".repeat(80));
for entry in &entries {
let short_commit = &entry.commit[..12.min(entry.commit.len())];
println!("{:<12} {:<26} {}", short_commit, entry.date, entry.summary);
}
Ok(())
}
pub fn restore(file: &Path, commit: &str) -> Result<()> {
let (git_root, rel_path) = resolve_git_paths(file)?;
let output = Command::new("git")
.current_dir(&git_root)
.args(["cat-file", "-t", commit])
.output()
.context("failed to verify commit")?;
if !output.status.success() {
bail!("commit does not exist: {}", commit);
}
let output = Command::new("git")
.current_dir(&git_root)
.args(["show", &format!("{}:{}", commit, rel_path)])
.output()
.context("failed to run git show")?;
if !output.status.success() {
bail!(
"file {} not found in commit {}",
file.display(),
commit
);
}
let old_content = String::from_utf8_lossy(&output.stdout).to_string();
let old_exchange = extract_exchange(&old_content)
.with_context(|| format!("no exchange component found in commit {}", commit))?;
if old_exchange.trim().is_empty() {
bail!("exchange component is empty in commit {}", commit);
}
let current_content = std::fs::read_to_string(file)
.with_context(|| format!("failed to read {}", file.display()))?;
let components = component::parse(¤t_content)
.with_context(|| "failed to parse current document")?;
let exchange = components
.iter()
.find(|c| c.name == EXCHANGE_COMPONENT)
.with_context(|| "no exchange component in current document")?;
let current_exchange = exchange.content(¤t_content);
let new_exchange = if current_exchange.trim().is_empty() {
old_exchange
} else {
format!("{}\n---\n\n{}", old_exchange.trim_end(), current_exchange)
};
let new_doc = exchange.replace_content(¤t_content, &new_exchange);
let parent = file.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.with_context(|| format!("failed to create temp file in {}", parent.display()))?;
std::io::Write::write_all(&mut tmp, new_doc.as_bytes())
.context("failed to write temp file")?;
tmp.persist(file)
.with_context(|| format!("failed to persist to {}", file.display()))?;
if let Err(e) = snapshot::save(file, &new_doc) {
eprintln!("[history] Warning: failed to update snapshot: {}", e);
}
let short_commit = &commit[..12.min(commit.len())];
eprintln!(
"[history] Restored exchange from {} into {}",
short_commit,
file.display()
);
Ok(())
}
pub fn log(file: &Path) -> Result<()> {
let (git_root, rel_path) = resolve_git_paths(file)?;
let doc_name = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("doc");
let pattern = format!("agent-doc/{}/pre-compact-*", doc_name);
let tag_out = Command::new("git")
.current_dir(&git_root)
.args(["tag", "-l", "--format=%(refname:short) %(objectname:short)", &pattern])
.output()
.unwrap_or_else(|_| std::process::Output {
status: std::process::ExitStatus::default(),
stdout: vec![],
stderr: vec![],
});
let mut tag_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
if tag_out.status.success() {
for line in String::from_utf8_lossy(&tag_out.stdout).lines() {
let parts: Vec<&str> = line.splitn(2, ' ').collect();
if parts.len() == 2 {
let tag_name = parts[0].to_string();
let short_hash = parts[1].to_string();
if let Ok(full_out) = Command::new("git")
.current_dir(&git_root)
.args(["rev-list", "-n1", &tag_name])
.output()
{
let full = String::from_utf8_lossy(&full_out.stdout).trim().to_string();
if !full.is_empty() {
tag_map.insert(full[..full.len().min(12)].to_string(), tag_name.clone());
tag_map.insert(short_hash, tag_name);
}
}
}
}
}
let output = Command::new("git")
.current_dir(&git_root)
.args(["log", "--format=%H%x00%ai%x00%s", "--", &rel_path])
.output()
.context("failed to run git log")?;
if !output.status.success() {
bail!("git log failed for {}", file.display());
}
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let entries: Vec<[&str; 3]> = stdout
.lines()
.filter_map(|line| {
let mut parts = line.splitn(3, '\0');
let hash = parts.next()?;
let date = parts.next()?;
let subject = parts.next()?;
Some([hash, date, subject])
})
.collect();
if entries.is_empty() {
eprintln!("No git history found for {}", file.display());
return Ok(());
}
println!("{:<12} {:<32} {:<30} TAG", "COMMIT", "DATE", "SUBJECT");
println!("{}", "-".repeat(90));
for [commit, date, subject] in &entries {
let short = &commit[..commit.len().min(12)];
let tag = tag_map.get(short).map(|s| s.as_str()).unwrap_or("");
let subj_display = if subject.len() > 30 {
format!("{}...", &subject[..28])
} else {
subject.to_string()
};
println!("{:<12} {:<32} {:<30} {}", short, date, subj_display, tag);
}
Ok(())
}
pub fn show(
file: &Path,
back: Option<usize>,
at: Option<usize>,
tag: Option<&str>,
) -> Result<()> {
let (git_root, rel_path) = resolve_git_paths(file)?;
let commit_ref = if let Some(t) = tag {
let out = Command::new("git")
.current_dir(&git_root)
.args(["rev-list", "-n1", t])
.output()
.with_context(|| format!("failed to resolve tag {}", t))?;
if !out.status.success() {
bail!("tag not found: {}", t);
}
String::from_utf8_lossy(&out.stdout).trim().to_string()
} else if let Some(n) = back {
format!("HEAD~{}", n)
} else if let Some(n) = at {
let out = Command::new("git")
.current_dir(&git_root)
.args(["log", "--format=%H", "--", &rel_path])
.output()
.context("failed to run git log")?;
if !out.status.success() {
bail!("git log failed");
}
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let commits_owned: Vec<String> = stdout.lines().map(|l| l.to_string()).collect();
if n >= commits_owned.len() {
bail!("--at {} exceeds history length ({})", n, commits_owned.len());
}
commits_owned[n].clone()
} else {
"HEAD".to_string()
};
let out = Command::new("git")
.current_dir(&git_root)
.args(["show", &format!("{}:{}", commit_ref, rel_path)])
.output()
.context("failed to run git show")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
bail!("git show failed: {}", stderr.trim());
}
print!("{}", String::from_utf8_lossy(&out.stdout));
Ok(())
}
pub fn git_diff(file: &Path, from: &str, to: &str) -> Result<()> {
let (git_root, rel_path) = resolve_git_paths(file)?;
let output = Command::new("git")
.current_dir(&git_root)
.args(["diff", &format!("{}..{}", from, to), "--", &rel_path])
.output()
.context("failed to run git diff")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git diff failed: {}", stderr.trim());
}
print!("{}", String::from_utf8_lossy(&output.stdout));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn extract_exchange_found() {
let doc = "\
# Title
<!-- agent:exchange -->
Hello world
Second line
<!-- /agent:exchange -->
Footer
";
let content = extract_exchange(doc).unwrap();
assert_eq!(content, "Hello world\nSecond line\n");
}
#[test]
fn extract_exchange_not_found() {
let doc = "# Just a plain doc\n";
assert!(extract_exchange(doc).is_none());
}
#[test]
fn extract_exchange_empty() {
let doc = "<!-- agent:exchange --><!-- /agent:exchange -->\n";
let content = extract_exchange(doc).unwrap();
assert_eq!(content, "");
}
#[test]
fn extract_exchange_nested_components() {
let doc = "\
<!-- agent:outer -->
<!-- agent:exchange -->
inner content
<!-- /agent:exchange -->
<!-- /agent:outer -->
";
let content = extract_exchange(doc).unwrap();
assert_eq!(content, "inner content\n");
}
#[test]
fn list_in_git_repo() {
let dir = tempfile::TempDir::new().unwrap();
let doc = dir.path().join("test.md");
let content = "\
---
title: Test
---
<!-- agent:exchange -->
First exchange
<!-- /agent:exchange -->
";
fs::write(&doc, content).unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["init"])
.output()
.unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["add", "test.md"])
.output()
.unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["commit", "-m", "initial", "--no-verify"])
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
let result = list(&doc);
assert!(result.is_ok(), "list failed: {:?}", result.err());
}
#[test]
fn restore_prepends_exchange() {
let dir = tempfile::TempDir::new().unwrap();
let doc = dir.path().join("test.md");
let v1 = "\
<!-- agent:exchange -->
Old exchange content
<!-- /agent:exchange -->
";
fs::write(&doc, v1).unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["init"])
.output()
.unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["add", "test.md"])
.output()
.unwrap();
let commit_out = Command::new("git")
.current_dir(dir.path())
.args(["commit", "-m", "v1", "--no-verify"])
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
assert!(commit_out.status.success(), "commit v1 failed");
let log_out = Command::new("git")
.current_dir(dir.path())
.args(["log", "--format=%H", "-1"])
.output()
.unwrap();
let v1_commit = String::from_utf8_lossy(&log_out.stdout).trim().to_string();
let v2 = "\
<!-- agent:exchange -->
New exchange content
<!-- /agent:exchange -->
";
fs::write(&doc, v2).unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["add", "test.md"])
.output()
.unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["commit", "-m", "v2", "--no-verify"])
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
let result = restore(&doc, &v1_commit);
assert!(result.is_ok(), "restore failed: {:?}", result.err());
let restored = fs::read_to_string(&doc).unwrap();
assert!(
restored.contains("Old exchange content"),
"should contain old content"
);
assert!(
restored.contains("New exchange content"),
"should still contain new content"
);
assert!(
restored.contains("---"),
"should contain separator"
);
let old_pos = restored.find("Old exchange content").unwrap();
let new_pos = restored.find("New exchange content").unwrap();
assert!(
old_pos < new_pos,
"old content should be prepended before new content"
);
}
#[test]
fn restore_into_empty_exchange() {
let dir = tempfile::TempDir::new().unwrap();
let doc = dir.path().join("test.md");
let v1 = "\
<!-- agent:exchange -->
Historical content
<!-- /agent:exchange -->
";
fs::write(&doc, v1).unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["init"])
.output()
.unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["add", "test.md"])
.output()
.unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["commit", "-m", "v1", "--no-verify"])
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
let log_out = Command::new("git")
.current_dir(dir.path())
.args(["log", "--format=%H", "-1"])
.output()
.unwrap();
let v1_commit = String::from_utf8_lossy(&log_out.stdout).trim().to_string();
let v2 = "\
<!-- agent:exchange -->
<!-- /agent:exchange -->
";
fs::write(&doc, v2).unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["add", "test.md"])
.output()
.unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["commit", "-m", "v2", "--no-verify"])
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
let result = restore(&doc, &v1_commit);
assert!(result.is_ok(), "restore failed: {:?}", result.err());
let restored = fs::read_to_string(&doc).unwrap();
assert!(
restored.contains("Historical content"),
"should contain restored content"
);
assert!(
!restored.contains("---"),
"should not have separator when restoring into empty exchange"
);
}
#[test]
fn restore_nonexistent_commit_fails() {
let dir = tempfile::TempDir::new().unwrap();
let doc = dir.path().join("test.md");
fs::write(&doc, "<!-- agent:exchange -->\n<!-- /agent:exchange -->\n").unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["init"])
.output()
.unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["add", "test.md"])
.output()
.unwrap();
Command::new("git")
.current_dir(dir.path())
.args(["commit", "-m", "init", "--no-verify"])
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.unwrap();
let result = restore(&doc, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
assert!(result.is_err(), "should fail for nonexistent commit");
}
#[test]
fn list_file_not_in_git_fails() {
let dir = tempfile::TempDir::new().unwrap();
let doc = dir.path().join("test.md");
fs::write(&doc, "content").unwrap();
let result = list(&doc);
assert!(result.is_err(), "should fail when file is not in git repo");
}
}