use anyhow::Result;
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use std::path::{Path, PathBuf};
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("/"));
if let Some(superproject) = git_superproject_at(parent) {
return Ok((superproject, file.to_path_buf()));
}
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()))
}
pub(crate) 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(crate) fn git_superproject_at(dir: &Path) -> Option<std::path::PathBuf> {
Command::new("git")
.current_dir(dir)
.args(["rev-parse", "--show-superproject-working-tree"])
.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(crate) fn narrow_to_submodule(super_root: &Path, file: &Path) -> (PathBuf, bool) {
let parent = file.parent().unwrap_or(Path::new("/"));
if let Some(inner) = git_toplevel_at(parent)
&& inner != super_root
&& inner.starts_with(super_root)
{
return (inner, true);
}
(super_root.to_path_buf(), false)
}
fn update_parent_submodule_pointer(super_root: &Path, submodule_root: &Path, msg: &str) {
let rel = match submodule_root.strip_prefix(super_root) {
Ok(r) => r,
Err(_) => {
eprintln!("[commit] cannot compute submodule relative path; skipping pointer update");
return;
}
};
let rel_str = rel.to_string_lossy().to_string();
let add = Command::new("git")
.current_dir(super_root)
.args(["add", "--", &rel_str])
.output();
match add {
Ok(o) if o.status.success() => {}
Ok(o) => {
eprintln!(
"[commit] parent git add for submodule pointer failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
);
return;
}
Err(e) => {
eprintln!("[commit] parent git add error: {}", e);
return;
}
}
let parent_msg = format!("{} (submodule pointer)", msg);
let commit = Command::new("git")
.current_dir(super_root)
.args(["commit", "-m", &parent_msg, "--no-verify", "--", &rel_str])
.output();
match commit {
Ok(o) if o.status.success() => {
for line in String::from_utf8_lossy(&o.stdout).lines() {
let t = line.trim();
if t.starts_with('[') && t.contains(']') {
eprintln!("{}", line);
}
}
eprintln!("[commit] parent submodule pointer updated for {}", rel_str);
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
let stdout = String::from_utf8_lossy(&o.stdout);
if stderr.contains("nothing to commit")
|| stdout.contains("nothing to commit")
|| stderr.contains("no changes added")
{
return;
}
eprintln!(
"[commit] parent submodule pointer commit failed: {}",
stderr.trim()
);
}
Err(e) => eprintln!("[commit] parent submodule pointer commit error: {}", e),
}
}
pub(crate) fn is_in_git_repo(file: &Path) -> bool {
let dir = if file.is_absolute() {
file.parent().unwrap_or(Path::new("/")).to_path_buf()
} else {
std::env::current_dir().unwrap_or_default()
};
Command::new("git")
.current_dir(&dir)
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn commit(file: &Path) -> Result<()> {
let t_total = std::time::Instant::now();
let (super_root, resolved) = resolve_to_git_root(file)?;
let (git_root, in_submodule) = narrow_to_submodule(&super_root, &resolved);
if in_submodule {
eprintln!(
"[commit] file is in submodule {} — running git ops there",
git_root.display()
);
crate::ops_log::log_op(file, &format!(
"submodule_route file={} submodule={}",
file.display(), git_root.display()
));
}
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 mut snapshot_content = crate::snapshot::load(file)?;
let file_content = std::fs::read_to_string(file).unwrap_or_default();
let file_len = file_content.len();
let snap_len = snapshot_content.as_ref().map(|s| s.len()).unwrap_or(0);
crate::ops_log::log_op(file, &format!(
"commit_staging file={} snap_len={} file_len={}",
file.display(), snap_len, file_len
));
if snap_len > 0 && file_len > snap_len {
let drift = file_len - snap_len;
crate::ops_log::log_op(file, &format!(
"out_of_band_write file={} drift={} snap_len={} file_len={}",
file.display(), drift, snap_len, file_len
));
if drift > 100 {
eprintln!(
"[commit] WARNING: file is {} bytes larger than snapshot for {} — possible out-of-band write (snap={}, file={})",
drift, file.display(), snap_len, file_len
);
crate::ops_log::log_op(file, &format!(
"drift_warning file={} drift={} snap_len={} file_len={}",
file.display(), drift, snap_len, file_len
));
if file_len > snap_len * 5 {
eprintln!(
"[commit] Extreme drift detected ({}x) — re-syncing snapshot from file content (likely file move)",
file_len / snap_len.max(1)
);
crate::ops_log::log_op(file, &format!(
"snapshot_resync file={} old_snap_len={} new_snap_len={}",
file.display(), snap_len, file_len
));
crate::snapshot::save(file, &file_content)?;
snapshot_content = Some(file_content.clone());
}
}
}
if snapshot_content.is_none() && !file_content.is_empty() {
eprintln!(
"[commit] WARNING: no snapshot exists for {}. Creating from file content.",
file.display()
);
crate::snapshot::save(file, &file_content)?;
snapshot_content = Some(file_content.clone());
}
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 mut commit_attempts = 0u32;
let commit_output = loop {
let out = Command::new("git")
.current_dir(&git_root)
.args(["commit", "-m", &msg, "--no-verify"])
.output();
match &out {
Ok(o) if !o.status.success() && commit_attempts < 3 => {
let stderr = String::from_utf8_lossy(&o.stderr);
if stderr.contains("index.lock") || stderr.contains("Unable to create") {
commit_attempts += 1;
eprintln!("[commit] index.lock contention, retry {}/3", commit_attempts);
std::thread::sleep(std::time::Duration::from_millis(50 * (1 << commit_attempts)));
continue;
}
}
_ => {}
}
break out;
};
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() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('[') && trimmed.contains(']') {
eprintln!("{}", line);
}
}
}
match &commit_status {
Ok(s) if s.success() => {
crate::ops_log::log_cycle(file, "commit", None, None);
crate::ops_log::log_op(file, &format!("commit_success file={}", file.display()));
let session_id = crate::frontmatter::read_session_id(file).unwrap_or_default();
crate::hooks::fire_post_commit(file, &session_id);
crate::hooks::fire_doc_event(file, "post_commit");
}
Ok(s) => {
crate::ops_log::log_op(file, &format!(
"commit_failed file={} exit_code={}",
file.display(),
s.code().unwrap_or(-1)
));
}
Err(e) => {
crate::ops_log::log_op(file, &format!(
"commit_error file={} err={}",
file.display(), e
));
}
}
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 {
eprintln!("[commit] stripping (HEAD) markers from snapshot ({} chars removed)", snap.len() - clean_snap.len());
if let Err(e) = crate::snapshot::save(file, &clean_snap) {
eprintln!("[commit] failed to clean snapshot: {}", e);
}
}
}
if let Ok(working) = std::fs::read_to_string(file) {
let clean_working = strip_head_markers(&working);
if clean_working != working {
eprintln!("[commit] WARNING: (HEAD) found in working tree — stripping");
if let Err(e) = crate::write::atomic_write_pub(file, &clean_working) {
eprintln!("[commit] failed to clean working tree: {}", 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);
}
if let Ok(canonical) = file.canonicalize() {
let project_root = crate::snapshot::find_project_root(&canonical)
.unwrap_or_else(|| canonical.parent().unwrap_or(Path::new(".")).to_path_buf());
let signal_file = project_root.join(".agent-doc/patches/vcs-refresh.signal");
if signal_file.parent().is_some_and(|p| p.exists()) {
match std::fs::write(&signal_file, "") {
Ok(()) => eprintln!("[commit] VCS refresh signal written"),
Err(e) => eprintln!("[commit] VCS refresh signal failed: {} (non-fatal)", e),
}
}
}
if in_submodule {
update_parent_submodule_pointer(&super_root, &git_root, &msg);
}
}
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 code_block_byte_ranges(content: &str) -> Vec<std::ops::Range<usize>> {
let parser = Parser::new_ext(content, Options::empty()).into_offset_iter();
let mut ranges = Vec::new();
let mut start: Option<usize> = None;
for (event, range) in parser {
match event {
Event::Start(Tag::CodeBlock(_)) => {
start = Some(range.start);
}
Event::End(TagEnd::CodeBlock) => {
if let Some(s) = start.take() {
ranges.push(s..range.end);
}
}
_ => {}
}
}
ranges
}
#[inline]
fn is_in_code_block(ranges: &[std::ops::Range<usize>], offset: usize) -> bool {
ranges.iter().any(|r| r.contains(&offset))
}
fn strip_head_markers(content: &str) -> String {
let code_ranges = code_block_byte_ranges(content);
let mut result_lines: Vec<&str> = Vec::new();
let mut offset = 0usize;
for line in content.lines() {
let trimmed = line.trim_start();
if !is_in_code_block(&code_ranges, offset)
&& let Some(stripped) = line.strip_suffix(" (HEAD)") {
if trimmed.starts_with('#') {
result_lines.push(stripped);
offset += line.len() + 1;
continue;
}
let without_suffix = stripped.trim_end();
if trimmed.starts_with("**") && without_suffix.trim_start().ends_with("**") {
result_lines.push(stripped);
offset += line.len() + 1;
continue;
}
}
result_lines.push(line);
offset += line.len() + 1;
}
let result = result_lines.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 content_code_ranges = code_block_byte_ranges(content);
let mut cleaned_lines: Vec<String> = Vec::new();
let mut offset = 0usize;
for line in content.lines() {
let trimmed = line.trim_start();
if !is_in_code_block(&content_code_ranges, offset) && trimmed.ends_with(" (HEAD)") {
if trimmed.starts_with('#') {
cleaned_lines.push(line[..line.len() - 7].to_string());
offset += line.len() + 1;
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());
offset += line.len() + 1;
continue;
}
}
cleaned_lines.push(line.to_string());
offset += line.len() + 1;
}
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 cleaned_code_ranges = code_block_byte_ranges(&cleaned);
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 !is_in_code_block(&cleaned_code_ranges, offset) && 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 {
let head_code_ranges = code_block_byte_ranges(hc);
let head_heading_counts: std::collections::HashMap<&str, usize> = {
let mut counts = std::collections::HashMap::new();
let mut head_offset = 0usize;
for line in hc.lines() {
let trimmed = line.trim_start();
let line_end = head_offset + line.len();
if !is_in_code_block(&head_code_ranges, head_offset) && trimmed.starts_with('#') {
let level = trimmed.chars().take_while(|c| *c == '#').count();
if level <= 6 && trimmed.len() > level && trimmed.as_bytes()[level] == b' ' {
*counts.entry(line).or_insert(0) += 1;
}
}
head_offset = line_end + 1;
}
counts
};
let mut seen_counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
heading_positions.into_iter().filter(|(start, end, _)| {
let heading_text = &cleaned[*start..*end];
let seen = seen_counts.entry(heading_text).or_insert(0);
*seen += 1;
let head_count = head_heading_counts.get(heading_text).copied().unwrap_or(0);
*seen > head_count
}).collect()
} else {
vec![*heading_positions.last().unwrap()]
};
if new_headings.is_empty() {
if let Some(ref head) = head_content {
let head_code_ranges_for_reapply = code_block_byte_ranges(head);
let head_marker_count = {
let mut count = 0usize;
let mut h_offset = 0usize;
for l in head.lines() {
let t = l.trim_start();
if !is_in_code_block(&head_code_ranges_for_reapply, h_offset)
&& t.ends_with(" (HEAD)")
&& t.starts_with('#')
{
count += 1;
}
h_offset += l.len() + 1;
}
count
};
if head_marker_count <= 3 {
let mut result = cleaned;
let mut h_offset = 0usize;
for line in head.lines() {
let trimmed = line.trim_start();
let h_line_end = h_offset + line.len();
if trimmed.ends_with(" (HEAD)")
&& trimmed.starts_with('#')
&& !is_in_code_block(&head_code_ranges_for_reapply, h_offset)
{
let without_head = &line[..line.len() - 7];
let search = format!("\n{}\n", without_head);
if let Some(pos) = result.find(&search) {
let insert_at = pos + 1 + without_head.len();
result.insert_str(insert_at, " (HEAD)");
} else if result.starts_with(&format!("{}\n", without_head)) {
result.insert_str(without_head.len(), " (HEAD)");
}
}
h_offset = h_line_end + 1;
}
return result;
} else {
eprintln!(
"[commit] Skipping (HEAD) re-application — {} markers in HEAD (stale, likely from file move)",
head_marker_count
);
}
}
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 add_head_marker_duplicate_heading_text() {
let content = "### Re: Implementation complete\nOld response.\n### Re: Other\nMiddle.\n### Re: Implementation complete\nNew response.\n";
let result = add_head_marker(content, Path::new("/nonexistent/file.md"));
assert!(
result.ends_with("### Re: Implementation complete (HEAD)\nNew response.\n"),
"last heading should get (HEAD), 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 add_head_marker_ignores_fenced_code_hash() {
let content = "### Re: Implementation\nSome response.\n```yaml\n# this is a yaml comment\nkey: value\n```\n";
let result = add_head_marker(content, Path::new("/nonexistent/file.md"));
assert!(
result.contains("### Re: Implementation (HEAD)"),
"real heading should get (HEAD), got:\n{result}"
);
assert!(
!result.contains("# this is a yaml comment (HEAD)"),
"fenced code comment must NOT get (HEAD), got:\n{result}"
);
}
#[test]
fn strip_head_markers_ignores_fenced_code_hash() {
let input = "### Re: Answer (HEAD)\nResponse.\n```bash\n# comment (HEAD)\n```\n";
let result = strip_head_markers(input);
assert_eq!(
result,
"### Re: Answer\nResponse.\n```bash\n# comment (HEAD)\n```\n",
"fenced (HEAD) must be preserved, got:\n{result}"
);
}
#[test]
fn add_head_marker_bash_comment_inside_plain_fence() {
let content = concat!(
"### Re: previous (HEAD)\n", "Old response.\n",
"```\n", "```bash\n", "some terminal output\n",
"```\n", "### Re: new heading\n", "Description.\n",
"```bash\n", "# On the server — run once\n", "git config pull.rebase true\n",
"```\n",
);
let result = add_head_marker(content, Path::new("/nonexistent/file.md"));
assert!(
result.contains("### Re: new heading (HEAD)"),
"real new heading must get (HEAD), got:\n{result}"
);
assert!(
!result.contains("# On the server — run once (HEAD)"),
"bash comment inside fenced block must NOT get (HEAD), got:\n{result}"
);
}
#[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");
}
#[test]
fn is_stale_baseline_write_path_user_edits_in_baseline_not_stale() {
let snapshot = "<!-- agent:exchange patch=append -->\n\
### Re: Response\n\
Agent response text.\n\
<!-- /agent:exchange -->\n";
let baseline_with_user_edits = "<!-- agent:exchange patch=append -->\n\
### Re: Response\n\
Agent response text.\n\
Implement agent-kit changes.\n\
Implement updates to agent-doc.\n\
<!-- /agent:exchange -->\n";
assert!(
!crate::write::is_stale_baseline(baseline_with_user_edits, snapshot),
"baseline with user edits should NOT be stale (it contains snapshot content)"
);
}
#[test]
fn is_stale_baseline_write_path_stale_baseline_detected() {
let current_snapshot = "<!-- agent:exchange patch=append -->\n\
### Re: Response 1\n\
First response.\n\
### Re: Response 2\n\
Second response.\n\
<!-- /agent:exchange -->\n";
let old_baseline = "<!-- agent:exchange patch=append -->\n\
### Re: Response 1\n\
First response.\n\
<!-- /agent:exchange -->\n";
assert!(
crate::write::is_stale_baseline(old_baseline, current_snapshot),
"baseline missing committed response should be stale"
);
}
#[test]
fn is_in_git_repo_true_inside_repo() {
use std::fs;
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
Command::new("git").current_dir(root).args(["init"]).output().unwrap();
Command::new("git").current_dir(root).args(["config", "user.email", "test@test.com"]).output().unwrap();
Command::new("git").current_dir(root).args(["config", "user.name", "Test"]).output().unwrap();
let doc = root.join("doc.md");
fs::write(&doc, "# test\n").unwrap();
assert!(is_in_git_repo(&doc), "file inside git repo should return true");
}
#[test]
fn is_in_git_repo_false_outside_repo() {
use std::fs;
let dir = tempfile::TempDir::new().unwrap();
let doc = dir.path().join("doc.md");
fs::write(&doc, "# test\n").unwrap();
assert!(!is_in_git_repo(&doc), "file outside git repo should return false");
}
#[test]
fn write_commit_lifecycle() {
use std::fs;
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
Command::new("git").current_dir(root).args(["init"]).output().unwrap();
Command::new("git").current_dir(root).args(["config", "user.email", "test@test.com"]).output().unwrap();
Command::new("git").current_dir(root).args(["config", "user.name", "Test"]).output().unwrap();
let readme = root.join("README.md");
fs::write(&readme, "# test\n").unwrap();
Command::new("git").current_dir(root).args(["add", "README.md"]).output().unwrap();
Command::new("git").current_dir(root).args(["commit", "-m", "initial", "--no-verify"]).output().unwrap();
let doc = root.join("session.md");
let doc_content = "---\nagent_doc_session: test\n---\n\n## User\n\nHello\n\n## Assistant\n\nResponse\n\n## User\n\n";
fs::write(&doc, doc_content).unwrap();
let snap_path = crate::snapshot::path_for(&doc).unwrap();
let snap_abs = root.join(&snap_path);
fs::create_dir_all(snap_abs.parent().unwrap()).unwrap();
fs::write(&snap_abs, doc_content).unwrap();
Command::new("git").current_dir(root).args(["add", "session.md"]).output().unwrap();
Command::new("git").current_dir(root).args(["commit", "-m", "add doc", "--no-verify"]).output().unwrap();
commit(&doc).expect("commit should succeed");
let log = Command::new("git")
.current_dir(root)
.args(["log", "--oneline", "-3"])
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(
log_str.contains("agent-doc(session):"),
"git log should contain agent-doc commit, got:\n{log_str}"
);
}
#[test]
fn commit_retry_logic_handles_index_lock_error() {
assert_eq!(50u64 * (1u64 << 1u32), 100, "retry 1 backoff should be 100ms");
assert_eq!(50u64 * (1u64 << 2u32), 200, "retry 2 backoff should be 200ms");
assert_eq!(50u64 * (1u64 << 3u32), 400, "retry 3 backoff should be 400ms");
}
#[test]
fn commit_succeeds_when_no_lock_contention() {
use std::fs;
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
Command::new("git").current_dir(root).args(["init"]).output().unwrap();
Command::new("git").current_dir(root).args(["config", "user.email", "test@test.com"]).output().unwrap();
Command::new("git").current_dir(root).args(["config", "user.name", "Test"]).output().unwrap();
let readme = root.join("README.md");
fs::write(&readme, "# test\n").unwrap();
Command::new("git").current_dir(root).args(["add", "README.md"]).output().unwrap();
Command::new("git").current_dir(root).args(["commit", "-m", "initial", "--no-verify"]).output().unwrap();
let doc = root.join("session.md");
let content = "---\nagent_doc_session: test\n---\n\n## Assistant\n\nResponse\n\n## User\n\n";
fs::write(&doc, content).unwrap();
let snap_path = crate::snapshot::path_for(&doc).unwrap();
let snap_abs = root.join(&snap_path);
fs::create_dir_all(snap_abs.parent().unwrap()).unwrap();
fs::write(&snap_abs, content).unwrap();
Command::new("git").current_dir(root).args(["add", "session.md"]).output().unwrap();
Command::new("git").current_dir(root).args(["commit", "-m", "add doc", "--no-verify"]).output().unwrap();
let result = commit(&doc);
assert!(result.is_ok(), "commit without lock should succeed: {:?}", result.err());
}
#[test]
fn commit_in_submodule_routes_through_submodule_repo() {
use std::fs;
let outer_dir = tempfile::TempDir::new().unwrap();
let outer = outer_dir.path();
let sub_dir = tempfile::TempDir::new().unwrap();
let sub_origin = sub_dir.path();
Command::new("git").current_dir(sub_origin).args(["init"]).output().unwrap();
Command::new("git").current_dir(sub_origin).args(["config", "user.email", "test@test.com"]).output().unwrap();
Command::new("git").current_dir(sub_origin).args(["config", "user.name", "Test"]).output().unwrap();
Command::new("git").current_dir(sub_origin).args(["config", "protocol.file.allow", "always"]).output().unwrap();
fs::write(sub_origin.join("README.md"), "# sub\n").unwrap();
Command::new("git").current_dir(sub_origin).args(["add", "README.md"]).output().unwrap();
Command::new("git").current_dir(sub_origin).args(["commit", "-m", "init sub", "--no-verify"]).output().unwrap();
Command::new("git").current_dir(outer).args(["init"]).output().unwrap();
Command::new("git").current_dir(outer).args(["config", "user.email", "test@test.com"]).output().unwrap();
Command::new("git").current_dir(outer).args(["config", "user.name", "Test"]).output().unwrap();
Command::new("git").current_dir(outer).args(["config", "protocol.file.allow", "always"]).output().unwrap();
fs::write(outer.join("README.md"), "# outer\n").unwrap();
Command::new("git").current_dir(outer).args(["add", "README.md"]).output().unwrap();
Command::new("git").current_dir(outer).args(["commit", "-m", "init outer", "--no-verify"]).output().unwrap();
let sub_url = format!("file://{}", sub_origin.display());
let sub_status = Command::new("git")
.current_dir(outer)
.args(["-c", "protocol.file.allow=always", "submodule", "add", &sub_url, "src/sub"])
.output()
.unwrap();
assert!(
sub_status.status.success(),
"submodule add failed: {}",
String::from_utf8_lossy(&sub_status.stderr)
);
Command::new("git").current_dir(outer).args(["commit", "-m", "add submodule", "--no-verify"]).output().unwrap();
let submodule_path = outer.join("src/sub");
Command::new("git").current_dir(&submodule_path).args(["config", "user.email", "test@test.com"]).output().unwrap();
Command::new("git").current_dir(&submodule_path).args(["config", "user.name", "Test"]).output().unwrap();
let doc = submodule_path.join("session.md");
let content = "---\nagent_doc_session: test\n---\n\n## Assistant\n\nresponse\n\n## User\n\n";
fs::write(&doc, content).unwrap();
let (narrowed, in_sub) = narrow_to_submodule(outer, &doc);
assert!(in_sub, "doc inside src/sub should be detected as submodule");
assert_eq!(narrowed, submodule_path, "narrowed root should be the submodule toplevel");
Command::new("git").current_dir(&submodule_path).args(["add", "session.md"]).output().unwrap();
Command::new("git").current_dir(&submodule_path).args(["commit", "-m", "add doc", "--no-verify"]).output().unwrap();
let new_content = "---\nagent_doc_session: test\n---\n\n## Assistant\n\nresponse\n\n## Assistant\n\nupdated\n\n## User\n\n";
fs::write(&doc, new_content).unwrap();
let snap_rel = crate::snapshot::path_for(&doc).unwrap();
let project_root = crate::snapshot::find_project_root(&doc.canonicalize().unwrap())
.unwrap_or_else(|| outer.to_path_buf());
let snap_abs = project_root.join(&snap_rel);
fs::create_dir_all(snap_abs.parent().unwrap()).unwrap();
fs::write(&snap_abs, new_content).unwrap();
let result = commit(&doc);
assert!(result.is_ok(), "commit should succeed for submodule file: {:?}", result.err());
let sub_log = Command::new("git")
.current_dir(&submodule_path)
.args(["log", "--oneline", "-5"])
.output()
.unwrap();
let sub_log_str = String::from_utf8_lossy(&sub_log.stdout);
assert!(
sub_log_str.contains("agent-doc(session)"),
"submodule git log should contain agent-doc commit, got:\n{sub_log_str}"
);
let outer_log = Command::new("git")
.current_dir(outer)
.args(["log", "--oneline", "-5"])
.output()
.unwrap();
let outer_log_str = String::from_utf8_lossy(&outer_log.stdout);
assert!(
outer_log_str.contains("(submodule pointer)"),
"parent git log should contain pointer-update commit, got:\n{outer_log_str}"
);
}
#[test]
fn narrow_to_submodule_returns_super_root_for_non_submodule_file() {
use std::fs;
let dir = tempfile::TempDir::new().unwrap();
let root = dir.path();
Command::new("git").current_dir(root).args(["init"]).output().unwrap();
let doc = root.join("session.md");
fs::write(&doc, "x").unwrap();
let (narrowed, in_sub) = narrow_to_submodule(root, &doc);
assert!(!in_sub, "non-submodule file should not be detected as in-submodule");
assert_eq!(narrowed, root);
}
#[test]
fn is_stale_baseline_write_path_replace_edits_ignored() {
let snapshot = "<!-- agent:status patch=replace -->\nOriginal\n<!-- /agent:status -->\n\
<!-- agent:exchange patch=append -->\nResponse.\n<!-- /agent:exchange -->\n";
let baseline = "<!-- agent:status patch=replace -->\nUser changed\n<!-- /agent:status -->\n\
<!-- agent:exchange patch=append -->\nResponse.\nUser question\n<!-- /agent:exchange -->\n";
assert!(
!crate::write::is_stale_baseline(baseline, snapshot),
"user edits in replace + append components should NOT be stale"
);
}
}