From bc0bb91a837435185a3f3a7fb1310a0c348c132f Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 17 Mar 2026 18:17:39 +0000 Subject: [PATCH] story-kit: merge 272_story_clear_merge_error_front_matter_when_story_leaves_merge_stage --- server/src/io/watcher.rs | 132 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/server/src/io/watcher.rs b/server/src/io/watcher.rs index 231a0b3..0f91e9e 100644 --- a/server/src/io/watcher.rs +++ b/server/src/io/watcher.rs @@ -20,6 +20,7 @@ //! the event so connected clients stay in sync. use crate::config::{ProjectConfig, WatcherConfig}; +use crate::io::story_metadata::clear_front_matter_field; use crate::slog; use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, recommended_watcher}; use serde::Serialize; @@ -190,6 +191,15 @@ fn flush_pending( ("remove", item.to_string(), format!("story-kit: remove {item}")) }; + // Strip stale merge_failure front matter from any story that has left 4_merge/. + for (path, stage) in &additions { + if *stage != "4_merge" + && let Err(e) = clear_front_matter_field(path, "merge_failure") + { + slog!("[watcher] Warning: could not clear merge_failure from {}: {e}", path.display()); + } + } + slog!("[watcher] flush: {commit_msg}"); match git_add_work_and_commit(git_root, &commit_msg) { Ok(committed) => { @@ -672,6 +682,128 @@ mod tests { assert!(rx.try_recv().is_err(), "no event for empty pending map"); } + // ── flush_pending clears merge_failure ───────────────────────────────────── + + #[test] + fn flush_pending_clears_merge_failure_when_leaving_merge_stage() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let stage_dir = make_stage_dir(tmp.path(), "2_current"); + let story_path = stage_dir.join("50_story_retry.md"); + fs::write( + &story_path, + "---\nname: Retry Story\nmerge_failure: \"conflicts detected\"\n---\n# Story\n", + ) + .unwrap(); + + let (tx, _rx) = tokio::sync::broadcast::channel(16); + let mut pending = HashMap::new(); + pending.insert(story_path.clone(), "2_current".to_string()); + + flush_pending(&pending, tmp.path(), &tx); + + let contents = fs::read_to_string(&story_path).unwrap(); + assert!( + !contents.contains("merge_failure"), + "merge_failure should be stripped when story lands in 2_current" + ); + assert!(contents.contains("name: Retry Story")); + } + + #[test] + fn flush_pending_clears_merge_failure_when_moving_to_upcoming() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let stage_dir = make_stage_dir(tmp.path(), "1_upcoming"); + let story_path = stage_dir.join("51_story_reset.md"); + fs::write( + &story_path, + "---\nname: Reset Story\nmerge_failure: \"gate failed\"\n---\n# Story\n", + ) + .unwrap(); + + let (tx, _rx) = tokio::sync::broadcast::channel(16); + let mut pending = HashMap::new(); + pending.insert(story_path.clone(), "1_upcoming".to_string()); + + flush_pending(&pending, tmp.path(), &tx); + + let contents = fs::read_to_string(&story_path).unwrap(); + assert!( + !contents.contains("merge_failure"), + "merge_failure should be stripped when story lands in 1_upcoming" + ); + } + + #[test] + fn flush_pending_clears_merge_failure_when_moving_to_done() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let stage_dir = make_stage_dir(tmp.path(), "5_done"); + let story_path = stage_dir.join("52_story_done.md"); + fs::write( + &story_path, + "---\nname: Done Story\nmerge_failure: \"stale error\"\n---\n# Story\n", + ) + .unwrap(); + + let (tx, _rx) = tokio::sync::broadcast::channel(16); + let mut pending = HashMap::new(); + pending.insert(story_path.clone(), "5_done".to_string()); + + flush_pending(&pending, tmp.path(), &tx); + + let contents = fs::read_to_string(&story_path).unwrap(); + assert!( + !contents.contains("merge_failure"), + "merge_failure should be stripped when story lands in 5_done" + ); + } + + #[test] + fn flush_pending_preserves_merge_failure_when_in_merge_stage() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let stage_dir = make_stage_dir(tmp.path(), "4_merge"); + let story_path = stage_dir.join("53_story_merging.md"); + fs::write( + &story_path, + "---\nname: Merging Story\nmerge_failure: \"conflicts\"\n---\n# Story\n", + ) + .unwrap(); + + let (tx, _rx) = tokio::sync::broadcast::channel(16); + let mut pending = HashMap::new(); + pending.insert(story_path.clone(), "4_merge".to_string()); + + flush_pending(&pending, tmp.path(), &tx); + + let contents = fs::read_to_string(&story_path).unwrap(); + assert!( + contents.contains("merge_failure"), + "merge_failure should be preserved when story is in 4_merge" + ); + } + + #[test] + fn flush_pending_no_op_when_no_merge_failure() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let stage_dir = make_stage_dir(tmp.path(), "2_current"); + let story_path = stage_dir.join("54_story_clean.md"); + let original = "---\nname: Clean Story\n---\n# Story\n"; + fs::write(&story_path, original).unwrap(); + + let (tx, _rx) = tokio::sync::broadcast::channel(16); + let mut pending = HashMap::new(); + pending.insert(story_path.clone(), "2_current".to_string()); + + flush_pending(&pending, tmp.path(), &tx); + + let contents = fs::read_to_string(&story_path).unwrap(); + assert_eq!(contents, original, "file without merge_failure should be unchanged"); + } + // ── stage_for_path (additional edge cases) ──────────────────────────────── #[test]