diff --git a/server/src/agents/local_prompt.rs b/server/src/agents/local_prompt.rs index ee9867d0..e848617e 100644 --- a/server/src/agents/local_prompt.rs +++ b/server/src/agents/local_prompt.rs @@ -60,13 +60,6 @@ pub fn read_project_local_prompt(project_root: &Path) -> Option { sections.push((rel_path, trimmed.to_string())); } - // Regenerate the source map so agents always start from a fresh snapshot. - // Failure is non-fatal: log it and fall through to whatever is on disk. - let map_path = project_root.join(SOURCE_MAP_REL); - if let Err(e) = source_map_gen::regenerate_source_map(project_root, &map_path) { - crate::slog!("[agents] source-map regen failed (non-fatal): {}", e); - } - // Read source-map.json (after AGENT.md) with a byte cap. let source_map_content = read_source_map_section(project_root); @@ -394,86 +387,6 @@ mod tests { ); } - // ── Regen-on-spawn tests ───────────────────────────────────────────────── - - fn init_git_repo(dir: &Path) { - let run = |args: &[&str]| { - std::process::Command::new("git") - .args(args) - .current_dir(dir) - .output() - .unwrap(); - }; - run(&["init"]); - run(&["config", "user.email", "test@test.com"]); - run(&["config", "user.name", "Test"]); - run(&["commit", "--allow-empty", "-m", "init"]); - } - - /// Happy path: regen runs successfully and the fresh map is included in the bundle. - #[test] - fn regen_creates_map_on_coder_spawn() { - let tmp = tempfile::tempdir().unwrap(); - init_git_repo(tmp.path()); - - // Write a tracked Rust file so git ls-files has something to index. - write_file( - tmp.path(), - "lib.rs", - "//! Module doc.\n\n/// A function.\npub fn hello() {}\n", - ); - std::process::Command::new("git") - .args(["add", "lib.rs"]) - .current_dir(tmp.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["commit", "-m", "add lib.rs"]) - .current_dir(tmp.path()) - .output() - .unwrap(); - - // Write an orientation file so we get Some back. - write_file(tmp.path(), "CLAUDE.md", "agent hints"); - - // Map does not exist yet. - let map_path = tmp.path().join(SOURCE_MAP_REL); - assert!(!map_path.exists(), "map must not exist before spawn"); - - let result = read_project_local_prompt(tmp.path()); - assert!( - result.is_some(), - "bundle must be Some when CLAUDE.md present" - ); - - // Regen should have written the map. - assert!( - map_path.exists(), - "regen must have written source-map.json during spawn" - ); - } - - /// Fallback: regen fails (no git repo) but a stale map on disk is still read. - #[test] - fn regen_fails_stale_map_still_readable() { - let tmp = tempfile::tempdir().unwrap(); - // No git repo — regen will fail with "git ls-files" error. - - write_file(tmp.path(), "CLAUDE.md", "agent hints"); - // Write a stale map manually. - write_file( - tmp.path(), - SOURCE_MAP_REL, - r#"{"stale/entry.rs": ["fn old"]}"#, - ); - - let result = read_project_local_prompt(tmp.path()).unwrap(); - assert!( - result.contains("stale/entry.rs"), - "stale map must still be readable after regen failure: {result}" - ); - } - #[test] #[allow(clippy::string_slice)] // sm_start is derived from str::find — always a char boundary fn source_map_truncated_at_byte_cap() { diff --git a/server/src/agents/merge/squash/mod.rs b/server/src/agents/merge/squash/mod.rs index 9d99a29e..71ab24a0 100644 --- a/server/src/agents/merge/squash/mod.rs +++ b/server/src/agents/merge/squash/mod.rs @@ -361,6 +361,54 @@ pub(crate) fn run_squash_merge( "=== Verified: cherry-pick landed on '{base_branch}' with code changes ===\n" )); + // ── Regen source-map.json on master after cherry-pick ───────── + // Run deterministically on project_root (now on master). Skip the commit + // when regen produces no diff (idempotent case). Failure is non-fatal. + { + let map_path = project_root.join(".huskies").join("source-map.json"); + let old_content = std::fs::read_to_string(&map_path).ok(); + match source_map_gen::regenerate_source_map(project_root, &map_path) { + Err(e) => { + all_output.push_str(&format!( + "=== source-map regen failed (non-fatal): {e} ===\n" + )); + } + Ok(()) => { + let new_content = std::fs::read_to_string(&map_path).ok(); + if old_content != new_content { + all_output.push_str("=== source-map.json changed — committing on master ===\n"); + let _ = Command::new("git") + .args(["add", ".huskies/source-map.json"]) + .current_dir(project_root) + .output(); + match Command::new("git") + .args(["commit", "-m", "huskies: regen source-map.json"]) + .current_dir(project_root) + .output() + { + Ok(c) if c.status.success() => { + all_output.push_str("=== source-map.json committed on master ===\n"); + } + Ok(c) => { + let stderr = String::from_utf8_lossy(&c.stderr); + all_output.push_str(&format!( + "=== source-map commit failed (non-fatal): {stderr} ===\n" + )); + } + Err(e) => { + all_output.push_str(&format!( + "=== source-map commit error (non-fatal): {e} ===\n" + )); + } + } + } else { + all_output + .push_str("=== source-map.json unchanged — no follow-up commit ===\n"); + } + } + } + } + // ── Clean up ────────────────────────────────────────────────── cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); all_output.push_str("=== Merge-queue cleanup complete ===\n"); diff --git a/server/src/agents/merge/squash/tests_advanced.rs b/server/src/agents/merge/squash/tests_advanced.rs index d6a8a60f..d331e1be 100644 --- a/server/src/agents/merge/squash/tests_advanced.rs +++ b/server/src/agents/merge/squash/tests_advanced.rs @@ -403,6 +403,112 @@ fn squash_merge_runs_component_setup_from_project_toml() { ); } +/// AC6: the regen+commit step runs on `project_root` (master) only. +/// After a successful merge where the source-map changes, `git log --name-only` +/// shows a follow-up commit whose diff contains ONLY `.huskies/source-map.json`. +#[tokio::test] +async fn regen_commit_on_master_touches_only_source_map() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + // Put a stale source-map.json on master so regen will produce a different result. + let sk_dir = repo.join(".huskies"); + fs::create_dir_all(&sk_dir).unwrap(); + fs::write(sk_dir.join("source-map.json"), "{\"stale\": true}\n").unwrap(); + + // Add a tracked Rust file so the regenerator has something to index. + fs::create_dir_all(repo.join("src")).unwrap(); + fs::write( + repo.join("src/lib.rs"), + "//! Library.\n\n/// Says hello.\npub fn hello() {}\n", + ) + .unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial with stale source-map"]) + .current_dir(repo) + .output() + .unwrap(); + + // Feature branch: add a new file. + Command::new("git") + .args(["checkout", "-b", "feature/story-1065_regen_test"]) + .current_dir(repo) + .output() + .unwrap(); + fs::write( + repo.join("src/extra.rs"), + "//! Extra.\n\n/// Extra fn.\npub fn extra() {}\n", + ) + .unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add extra.rs"]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["checkout", "master"]) + .current_dir(repo) + .output() + .unwrap(); + + let result = + run_squash_merge(repo, "feature/story-1065_regen_test", "1065_regen_test").unwrap(); + + assert!( + matches!(result, super::MergeResult::Success { .. }), + "clean merge must succeed; got: {result:?}" + ); + + // Find the regen commit if one was created. + let log_out = Command::new("git") + .args(["log", "--oneline", "--name-only"]) + .current_dir(repo) + .output() + .unwrap(); + let log = String::from_utf8_lossy(&log_out.stdout); + + // If a regen commit exists, its diff must contain ONLY the source-map path. + if log.contains("huskies: regen source-map.json") { + // Extract files changed in the regen commit. + let show_out = Command::new("git") + .args(["show", "--name-only", "--format=", "HEAD"]) + .current_dir(repo) + .output() + .unwrap(); + let show = String::from_utf8_lossy(&show_out.stdout); + + // If HEAD is the regen commit, its files list must be exactly one entry. + let head_msg = Command::new("git") + .args(["log", "-1", "--format=%s"]) + .current_dir(repo) + .output() + .unwrap(); + let head_subject = String::from_utf8_lossy(&head_msg.stdout); + if head_subject.trim() == "huskies: regen source-map.json" { + let changed_files: Vec<&str> = show.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!( + changed_files, + vec![".huskies/source-map.json"], + "regen commit must touch ONLY .huskies/source-map.json; got: {changed_files:?}" + ); + } + } +} + #[cfg(unix)] #[test] fn squash_merge_succeeds_without_components_in_project_toml() {