use anyhow::Result;
use std::path::Path;
use std::process::Command;
pub(crate) fn resolve_to_git_root(file: &Path) -> Result<(std::path::PathBuf, std::path::PathBuf)> {
if file.is_absolute() {
let parent = file.parent().unwrap_or(Path::new("/"));
let root = git_toplevel_at(parent)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
return Ok((root, file.to_path_buf()));
}
let output = Command::new("git")
.args(["rev-parse", "--show-superproject-working-tree"])
.output();
if let Ok(ref o) = output {
let root = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !root.is_empty() {
let root_path = std::path::PathBuf::from(&root);
let resolved = root_path.join(file);
if resolved.exists() {
return Ok((root_path, resolved));
}
}
}
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output();
if let Ok(ref o) = output {
let root = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !root.is_empty() {
let root_path = std::path::PathBuf::from(&root);
let resolved = root_path.join(file);
if resolved.exists() {
return Ok((root_path, resolved));
}
}
}
let cwd = std::env::current_dir().unwrap_or_default();
Ok((cwd, file.to_path_buf()))
}
fn git_toplevel_at(dir: &Path) -> Option<std::path::PathBuf> {
Command::new("git")
.current_dir(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if s.is_empty() { None } else { Some(std::path::PathBuf::from(s)) }
})
}
pub fn commit(file: &Path) -> Result<()> {
let t_total = std::time::Instant::now();
let (git_root, resolved) = resolve_to_git_root(file)?;
let timestamp = chrono_timestamp();
let doc_name = file
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let msg = format!("agent-doc({}): {}", doc_name, timestamp);
let snapshot_content = crate::snapshot::load(file)?;
let t_staging = std::time::Instant::now();
if let Some(ref snap) = snapshot_content {
let staged_content = add_head_marker(snap, file);
let rel_path = resolved.strip_prefix(&git_root)
.unwrap_or(&resolved);
if let Ok(hash) = hash_object(&git_root, &staged_content) {
let cacheinfo = format!("100644,{},{}", hash, rel_path.to_string_lossy());
let status = Command::new("git")
.current_dir(&git_root)
.args(["update-index", "--add", "--cacheinfo", &cacheinfo])
.status()?;
if !status.success() {
eprintln!("[commit] update-index failed, falling back to git add");
git_add_force(&git_root, &resolved)?;
}
} else {
git_add_force(&git_root, &resolved)?;
}
} else {
git_add_force(&git_root, &resolved)?;
}
let elapsed_staging = t_staging.elapsed().as_millis();
if elapsed_staging > 0 {
eprintln!("[perf] commit.staging (hash_object+update-index): {}ms", elapsed_staging);
}
let t_commit = std::time::Instant::now();
let commit_output = Command::new("git")
.current_dir(&git_root)
.args(["commit", "-m", &msg, "--no-verify"])
.output();
let commit_status = commit_output.as_ref().map(|o| o.status);
let elapsed_commit = t_commit.elapsed().as_millis();
if elapsed_commit > 0 {
eprintln!("[perf] commit.git_commit: {}ms", elapsed_commit);
}
if let Ok(ref o) = commit_output {
let stdout = String::from_utf8_lossy(&o.stdout);
for line in stdout.lines() {
if !line.trim().is_empty() {
eprintln!("{}", line);
}
}
}
if let Ok(ref s) = commit_status
&& s.success()
{
if let Some(ref snap) = snapshot_content {
let clean_snap = strip_head_markers(snap);
if clean_snap != *snap
&& let Err(e) = crate::snapshot::save(file, &clean_snap)
{
eprintln!("[commit] failed to clean snapshot: {}", e);
}
}
let t_reposition = std::time::Instant::now();
let snap_changed = reposition_boundary_in_snapshot(file);
if snap_changed {
crate::write::try_ipc_reposition_boundary(file);
}
let elapsed_reposition = t_reposition.elapsed().as_millis();
if elapsed_reposition > 0 {
eprintln!("[perf] commit.reposition: {}ms", elapsed_reposition);
}
}
let elapsed_total = t_total.elapsed().as_millis();
if elapsed_total > 0 {
eprintln!("[perf] commit total: {}ms", elapsed_total);
}
Ok(())
}
fn reposition_boundary_in_snapshot(file: &Path) -> bool {
if let Ok(canonical) = file.canonicalize()
&& let Ok(pending_path) = crate::snapshot::pending_path_for(&canonical)
&& pending_path.exists()
{
eprintln!("[commit] skipping boundary reposition — active run detected");
return false;
}
if let Ok(Some(snap_content)) = crate::snapshot::load(file) {
let new_snap = crate::template::reposition_boundary_to_end(&snap_content);
if new_snap != snap_content {
if let Err(e) = crate::snapshot::save(file, &new_snap) {
eprintln!("[commit] failed to update snapshot after boundary reposition: {}", e);
return false;
}
eprintln!("[commit] repositioned boundary in snapshot");
return true;
}
}
false
}
fn strip_head_markers(content: &str) -> String {
let mut lines: Vec<&str> = Vec::new();
for line in content.lines() {
lines.push(line);
}
let result: String = lines.iter().map(|line| {
let trimmed = line.trim_start();
if let Some(stripped) = line.strip_suffix(" (HEAD)") {
if trimmed.starts_with('#') {
return stripped;
}
let without_suffix = stripped.trim_end();
if trimmed.starts_with("**") && without_suffix.trim_start().ends_with("**") {
return stripped;
}
}
line
}).collect::<Vec<&str>>().join("\n");
if content.ends_with('\n') { format!("{}\n", result) } else { result }
}
fn add_head_marker(content: &str, file: &Path) -> String {
let head_content = show_head(file).ok().flatten();
let mut cleaned_lines: Vec<String> = Vec::new();
for line in content.lines() {
let trimmed = line.trim_start();
if trimmed.ends_with(" (HEAD)") {
if trimmed.starts_with('#') {
cleaned_lines.push(line[..line.len() - 7].to_string());
continue;
}
let without_suffix = line[..line.len() - 7].trim_end();
if trimmed.starts_with("**") && without_suffix.trim_start().ends_with("**") {
cleaned_lines.push(line[..line.len() - 7].to_string());
continue;
}
}
cleaned_lines.push(line.to_string());
}
let cleaned = cleaned_lines.join("\n");
let cleaned = if content.ends_with('\n') && !cleaned.ends_with('\n') {
format!("{}\n", cleaned)
} else {
cleaned
};
let head_cleaned = head_content.as_ref().map(|h| {
h.lines()
.map(|line| {
let trimmed = line.trim_start();
if trimmed.ends_with(" (HEAD)") {
if trimmed.starts_with('#') {
return &line[..line.len() - 7];
}
let without_suffix = line[..line.len() - 7].trim_end();
if trimmed.starts_with("**") && without_suffix.trim_start().ends_with("**") {
return &line[..line.len() - 7];
}
}
line
})
.collect::<Vec<&str>>()
.join("\n")
});
let mut heading_positions: Vec<(usize, usize, usize)> = Vec::new();
let mut offset = 0usize;
for line in cleaned.lines() {
let trimmed = line.trim_start();
let line_end = offset + line.len();
if trimmed.starts_with('#') {
let level = trimmed.chars().take_while(|c| *c == '#').count();
if level <= 6 && trimmed.len() > level && trimmed.as_bytes()[level] == b' ' {
heading_positions.push((offset, line_end, level));
}
}
offset = line_end + 1;
}
if heading_positions.is_empty() {
let mut offset = 0usize;
for line in cleaned.lines() {
let trimmed = line.trim_start();
let line_end = offset + line.len();
let trimmed_end = trimmed.trim_end();
if trimmed_end.starts_with("**") && trimmed_end.ends_with("**") && trimmed_end.len() > 4 {
heading_positions.push((offset, line_end, 99));
}
offset = line_end + 1;
}
}
if heading_positions.is_empty() {
return cleaned;
}
let new_headings: Vec<(usize, usize, usize)> = if let Some(ref hc) = head_cleaned {
heading_positions.into_iter().filter(|(start, end, _)| {
let heading_text = &cleaned[*start..*end];
!hc.contains(heading_text)
}).collect()
} else {
vec![*heading_positions.last().unwrap()]
};
if new_headings.is_empty() {
return cleaned;
}
let min_level = new_headings.iter().map(|(_, _, level)| *level).min().unwrap();
let root_ends: Vec<usize> = new_headings.iter()
.filter(|(_, _, level)| *level == min_level)
.map(|(_, end, _)| *end)
.collect();
let mut result = cleaned;
for pos in root_ends.iter().rev() {
result.insert_str(*pos, " (HEAD)");
}
result
}
fn hash_object(git_root: &Path, content: &str) -> Result<String> {
let output = Command::new("git")
.current_dir(git_root)
.args(["hash-object", "-w", "--stdin"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(content.as_bytes())?;
}
child.wait_with_output()
})?;
if !output.status.success() {
anyhow::bail!("git hash-object failed");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn git_add_force(git_root: &Path, resolved: &Path) -> Result<()> {
let status = Command::new("git")
.current_dir(git_root)
.args(["add", "-f", &resolved.to_string_lossy()])
.status()?;
if !status.success() {
anyhow::bail!("git add failed");
}
Ok(())
}
pub fn create_branch(file: &Path) -> Result<()> {
let stem = file
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "session".to_string());
let branch_name = format!("agent-doc/{}", stem);
let status = Command::new("git")
.args(["checkout", "-b", &branch_name])
.status()?;
if !status.success() {
let status = Command::new("git")
.args(["checkout", &branch_name])
.status()?;
if !status.success() {
anyhow::bail!("failed to create or switch to branch {}", branch_name);
}
}
Ok(())
}
pub fn squash_session(file: &Path) -> Result<()> {
let file_str = file.to_string_lossy();
let output = Command::new("git")
.args([
"log",
"--oneline",
"--reverse",
"--grep=^agent-doc",
"--",
&file_str,
])
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let first_line = stdout.lines().next();
let first_hash = match first_line {
Some(line) => line.split_whitespace().next().unwrap_or(""),
None => {
eprintln!("No agent-doc commits found for {}", file.display());
return Ok(());
}
};
let status = Command::new("git")
.args(["reset", "--soft", &format!("{}~1", first_hash)])
.status()?;
if !status.success() {
anyhow::bail!("git reset failed");
}
let status = Command::new("git")
.args([
"commit",
"-m",
&format!("agent-doc: squashed session for {}", file.display()),
"--no-verify",
])
.status()?;
if !status.success() {
anyhow::bail!("git commit failed during squash");
}
eprintln!("Squashed agent-doc commits for {}", file.display());
Ok(())
}
pub fn show_head(file: &Path) -> Result<Option<String>> {
let (git_root, resolved) = resolve_to_git_root(file)?;
let rel_path = if resolved.is_absolute() {
resolved
.strip_prefix(&git_root)
.unwrap_or(&resolved)
.to_path_buf()
} else {
resolved.clone()
};
let output = Command::new("git")
.current_dir(&git_root)
.args(["show", &format!("HEAD:{}", rel_path.to_string_lossy())])
.output()?;
if !output.status.success() {
return Ok(None);
}
Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
}
pub fn last_commit_mtime(file: &Path) -> Result<Option<std::time::SystemTime>> {
let (git_root, resolved) = resolve_to_git_root(file)?;
let rel_path = if resolved.is_absolute() {
resolved
.strip_prefix(&git_root)
.unwrap_or(&resolved)
.to_path_buf()
} else {
resolved.clone()
};
let output = Command::new("git")
.current_dir(&git_root)
.args([
"log",
"-1",
"--format=%ct",
"--",
&rel_path.to_string_lossy(),
])
.output()?;
if !output.status.success() {
return Ok(None);
}
let ts_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
if ts_str.is_empty() {
return Ok(None);
}
let epoch: u64 = ts_str.parse().unwrap_or(0);
if epoch == 0 {
return Ok(None);
}
Ok(Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(epoch)))
}
fn chrono_timestamp() -> String {
let output = Command::new("date")
.args(["+%Y-%m-%d %H:%M:%S"])
.output()
.ok();
match output {
Some(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
None => "unknown".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_head_markers_from_headings() {
let input = "# Title\n### Re: Foo (HEAD)\nSome text with (HEAD) in it\n### Re: Bar (HEAD)\n";
let result = strip_head_markers(input);
assert_eq!(result, "# Title\n### Re: Foo\nSome text with (HEAD) in it\n### Re: Bar\n");
}
#[test]
fn strip_head_markers_preserves_non_heading_lines() {
let input = "Normal line (HEAD)\n### Heading (HEAD)\n";
let result = strip_head_markers(input);
assert_eq!(result, "Normal line (HEAD)\n### Heading\n");
}
#[test]
fn add_head_marker_strips_old_markers() {
let content = "### Re: Old (HEAD)\n### Re: New\n";
let result = add_head_marker(content, Path::new("/nonexistent/file.md"));
assert!(!result.contains("### Re: Old (HEAD)"), "old heading should not have (HEAD)");
assert!(result.contains("### Re: New (HEAD)") || result.contains("### Re: Old\n"), "old (HEAD) should be stripped");
}
#[test]
fn add_head_marker_bold_text_fallback() {
let content = "Some intro text.\n**Re: Something**\nBody paragraph.\n";
let result = add_head_marker(content, Path::new("/nonexistent/file.md"));
assert!(
result.contains("**Re: Something** (HEAD)"),
"bold-text pseudo-header should get (HEAD) marker, got: {result}"
);
}
#[test]
fn add_head_marker_prefers_real_headings() {
let content = "### Re: Something\n**Bold text**\nBody.\n";
let result = add_head_marker(content, Path::new("/nonexistent/file.md"));
assert!(
result.contains("### Re: Something (HEAD)"),
"real heading should get (HEAD), got: {result}"
);
assert!(
!result.contains("**Bold text** (HEAD)"),
"bold text should NOT get (HEAD) when real headings exist, got: {result}"
);
}
#[test]
fn strip_head_markers_bold_text() {
let input = "**Re: Something** (HEAD)\nSome text.\n";
let result = strip_head_markers(input);
assert_eq!(result, "**Re: Something**\nSome text.\n");
}
#[test]
fn reposition_boundary_to_end_basic() {
let content = "<!-- agent:exchange patch=append -->\nResponse.\n<!-- agent:boundary:abc123 -->\nUser prompt.\n<!-- /agent:exchange -->\n";
let result = agent_doc::template::reposition_boundary_to_end(content);
assert!(result.contains("User prompt.\n<!-- agent:boundary:"));
assert!(result.contains("-->\n<!-- /agent:exchange -->"));
assert!(!result.contains("abc123"));
}
#[test]
fn reposition_boundary_no_exchange() {
let content = "# No exchange component\nJust text.\n";
let result = agent_doc::template::reposition_boundary_to_end(content);
assert_eq!(result.trim(), content.trim());
}
#[test]
fn reposition_boundary_preserves_user_edits() {
let content = "<!-- agent:exchange patch=append -->\n### Re: Answer\nAgent response.\n<!-- agent:boundary:old-id -->\nUser's new prompt here.\nMore user text.\n<!-- /agent:exchange -->\n";
let result = agent_doc::template::reposition_boundary_to_end(content);
assert!(result.contains("User's new prompt here."), "user edit must be preserved");
assert!(result.contains("More user text."), "user edit must be preserved");
let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
let user_pos = result.find("User's new prompt here.").unwrap();
assert!(boundary_pos > user_pos, "boundary must be after user text");
}
#[test]
fn reposition_boundary_cleans_multiple_stale() {
let content = "<!-- agent:exchange patch=append -->\n\
First response.\n\
<!-- agent:boundary:aaa111 -->\n\
Second response.\n\
<!-- agent:boundary:bbb222 -->\n\
User prompt.\n\
<!-- /agent:exchange -->\n";
let result = agent_doc::template::reposition_boundary_to_end(content);
assert!(!result.contains("aaa111"), "first stale boundary must be removed");
assert!(!result.contains("bbb222"), "second stale boundary must be removed");
let boundary_count = result.matches("<!-- agent:boundary:").count();
assert_eq!(boundary_count, 1, "exactly one boundary marker should remain");
let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
let user_pos = result.find("User prompt.").unwrap();
assert!(boundary_pos > user_pos, "boundary must be after user text");
}
}