use anyhow::Result;
use fs2::FileExt;
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
use std::process::Command;
struct CommitLock {
_file: File,
}
impl CommitLock {
fn acquire(doc: &Path) -> Option<Self> {
let canonical = doc.canonicalize().ok()?;
let root = crate::snapshot::find_project_root(&canonical)?;
let hash = crate::snapshot::doc_hash(doc).ok()?;
let lock_dir = root.join(".agent-doc/locks");
if let Err(e) = std::fs::create_dir_all(&lock_dir) {
eprintln!("[commit] commit-lock dir create failed: {} (proceeding unlocked)", e);
return None;
}
let lock_path = lock_dir.join(format!("commit-{}.lock", hash));
let file = match OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
{
Ok(f) => f,
Err(e) => {
eprintln!("[commit] commit-lock open failed: {} (proceeding unlocked)", e);
return None;
}
};
if let Err(e) = file.lock_exclusive() {
eprintln!("[commit] commit-lock flock failed: {} (proceeding unlocked)", e);
return None;
}
Some(Self { _file: file })
}
}
impl Drop for CommitLock {
fn drop(&mut self) {
let _ = self._file.unlock();
}
}
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 fn resolve_pane_cwd(file: &Path) -> std::path::PathBuf {
if let Ok((super_root, resolved)) = resolve_to_git_root(file) {
let (git_root, in_submodule) = narrow_to_submodule(&super_root, &resolved);
if in_submodule {
return git_root;
}
return super_root;
}
std::env::current_dir().unwrap_or_default()
}
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 _commit_lock = CommitLock::acquire(file);
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_reposition = std::time::Instant::now();
let _snap_changed = reposition_boundary_in_snapshot(file);
if let Ok(Some(reloaded)) = crate::snapshot::load(file) {
snapshot_content = Some(reloaded);
}
let elapsed_reposition = t_reposition.elapsed().as_millis();
if elapsed_reposition > 0 {
eprintln!("[perf] commit.reposition: {}ms", elapsed_reposition);
}
let t_staging = std::time::Instant::now();
if let Some(ref snap) = snapshot_content {
let staged_content = strip_head_markers(snap);
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
&& let Err(e) = crate::snapshot::save(file, &clean_snap) {
eprintln!("[commit] failed to clean snapshot: {}", e);
}
}
crate::write::try_ipc_reposition_boundary(file);
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;
}
let mut changed = false;
let head_doc = crate::git::show_head(file).ok().flatten();
let baseline_headings: std::collections::HashSet<String> = head_doc
.as_deref()
.map(crate::template::exchange_baseline_headings)
.unwrap_or_default();
let baseline_opt = Some(&baseline_headings);
if let Ok(Some(snap_content)) = crate::snapshot::load(file) {
let new_snap = crate::template::reposition_boundary_to_end_with_baseline(
&snap_content,
None,
baseline_opt,
);
if new_snap != snap_content {
match crate::snapshot::save(file, &new_snap) {
Ok(()) => {
eprintln!("[commit] repositioned boundary in snapshot");
changed = true;
}
Err(e) => {
eprintln!("[commit] failed to update snapshot after boundary reposition: {}", e);
}
}
}
}
if let Ok(working) = std::fs::read_to_string(file) {
let repositioned = crate::template::reposition_boundary_to_end_with_baseline(
&working,
None,
baseline_opt,
);
if repositioned != working {
match crate::write::atomic_write_pub(file, &repositioned) {
Ok(()) => {
eprintln!("[commit] repositioned boundary in working tree");
changed = true;
}
Err(e) => {
eprintln!("[commit] failed to reposition boundary in working tree: {}", e);
}
}
}
}
changed
}
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 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 (super_root, resolved) = resolve_to_git_root(file)?;
let (git_root, _in_submodule) = narrow_to_submodule(&super_root, &resolved);
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 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 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 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 initial_content = "---\nagent_doc_session: test\n---\n\n## User\n\nHello\n\n";
fs::write(&doc, initial_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 post_response = "---\nagent_doc_session: test\n---\n\n## User\n\nHello\n\n## Assistant\n\nResponse\n\n## User\n\n";
fs::write(&doc, post_response).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, post_response).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_staged_blob_has_no_head_markers() {
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 initial = "---\nagent_doc_session: test\n---\n\n<!-- agent:exchange -->\n### Re: older\nold body\n<!-- /agent:exchange -->\n";
fs::write(&doc, initial).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, initial).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 cycle1 = "---\nagent_doc_session: test\n---\n\n<!-- agent:exchange -->\n### Re: older\nold body\n\n### Re: newer (HEAD)\nnew body\n<!-- /agent:exchange -->\n";
fs::write(&doc, cycle1).unwrap();
fs::write(&snap_abs, cycle1).unwrap();
commit(&doc).expect("commit should succeed");
let show = Command::new("git")
.current_dir(root)
.args(["show", "HEAD:session.md"])
.output()
.unwrap();
assert!(show.status.success(), "git show HEAD:session.md failed");
let blob = String::from_utf8_lossy(&show.stdout);
assert!(
!blob.contains("(HEAD)"),
"committed blob must not contain (HEAD); got:\n{blob}"
);
assert!(
blob.contains("### Re: newer\n"),
"committed blob should contain the clean new heading; got:\n{blob}"
);
assert!(
blob.contains("### Re: older\n"),
"committed blob should still contain the older heading; got:\n{blob}"
);
let working = fs::read_to_string(&doc).unwrap();
assert!(
working.contains("### Re: newer (HEAD)"),
"working tree should retain (HEAD) on current boundary; got:\n{working}"
);
assert_eq!(
working.matches("(HEAD)").count(),
1,
"working tree should have exactly one (HEAD) — the current boundary"
);
}
#[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 resolve_pane_cwd_returns_git_root_for_file_in_repo() {
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("plan.md");
fs::write(&doc, "# Plan\n").unwrap();
let cwd = resolve_pane_cwd(&doc);
assert_eq!(cwd, root, "cwd should be the git root for a file inside a plain repo");
}
#[test]
fn resolve_pane_cwd_falls_back_to_process_cwd_for_non_git_path() {
let dir = tempfile::TempDir::new().unwrap();
let non_git_file = dir.path().join("notes.md");
std::fs::write(&non_git_file, "notes\n").unwrap();
let cwd = resolve_pane_cwd(&non_git_file);
assert!(cwd.exists() || cwd == std::env::current_dir().unwrap_or_default(),
"fallback cwd should be the process cwd or an existing path");
}
#[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"
);
}
}