spike(92): stop auto-committing intermediate pipeline moves
Filter flush_pending() to only git-commit for terminal stages (1_upcoming and 6_archived) while still broadcasting WatcherEvents for all stages so the frontend stays in sync. Reduces pipeline commits from 5+ to 2 per story run. No system dependencies on intermediate commits were found. Preserves merge_failure front matter cleanup from master. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -155,11 +155,25 @@ fn git_add_work_and_commit(git_root: &Path, message: &str) -> Result<bool, Strin
|
||||
Err(format!("git commit failed: {stderr}"))
|
||||
}
|
||||
|
||||
/// Stages that represent meaningful git checkpoints (creation and archival).
|
||||
/// Intermediate stages (current, qa, merge, done) are transient pipeline state
|
||||
/// that don't need to be committed — they're only relevant while the server is
|
||||
/// running and are broadcast to WebSocket clients for real-time UI updates.
|
||||
const COMMIT_WORTHY_STAGES: &[&str] = &["1_upcoming", "6_archived"];
|
||||
|
||||
/// Return `true` if changes in `stage` should be committed to git.
|
||||
fn should_commit_stage(stage: &str) -> bool {
|
||||
COMMIT_WORTHY_STAGES.contains(&stage)
|
||||
}
|
||||
|
||||
/// Process a batch of pending (path → stage) entries: commit and broadcast.
|
||||
///
|
||||
/// Only files that still exist on disk are used to derive the commit message
|
||||
/// (they represent the destination of a move or a new file). Deletions are
|
||||
/// captured by `git add -A .story_kit/work/` automatically.
|
||||
///
|
||||
/// Only terminal stages (`1_upcoming` and `6_archived`) trigger git commits.
|
||||
/// All stages broadcast a [`WatcherEvent`] so the frontend stays in sync.
|
||||
fn flush_pending(
|
||||
pending: &HashMap<PathBuf, String>,
|
||||
git_root: &Path,
|
||||
@@ -200,27 +214,37 @@ fn flush_pending(
|
||||
}
|
||||
}
|
||||
|
||||
slog!("[watcher] flush: {commit_msg}");
|
||||
match git_add_work_and_commit(git_root, &commit_msg) {
|
||||
Ok(committed) => {
|
||||
if committed {
|
||||
slog!("[watcher] committed: {commit_msg}");
|
||||
} else {
|
||||
slog!("[watcher] skipped (already committed): {commit_msg}");
|
||||
// Only commit for terminal stages; intermediate moves are broadcast-only.
|
||||
let dest_stage = additions.first().map_or("unknown", |(_, s)| *s);
|
||||
let should_commit = should_commit_stage(dest_stage);
|
||||
|
||||
if should_commit {
|
||||
slog!("[watcher] flush: {commit_msg}");
|
||||
match git_add_work_and_commit(git_root, &commit_msg) {
|
||||
Ok(committed) => {
|
||||
if committed {
|
||||
slog!("[watcher] committed: {commit_msg}");
|
||||
} else {
|
||||
slog!("[watcher] skipped (already committed): {commit_msg}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[watcher] git error: {e}");
|
||||
return;
|
||||
}
|
||||
let stage = additions.first().map_or("unknown", |(_, s)| s);
|
||||
let evt = WatcherEvent::WorkItem {
|
||||
stage: stage.to_string(),
|
||||
item_id,
|
||||
action: action.to_string(),
|
||||
commit_msg,
|
||||
};
|
||||
let _ = event_tx.send(evt);
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[watcher] git error: {e}");
|
||||
}
|
||||
} else {
|
||||
slog!("[watcher] flush (broadcast-only): {commit_msg}");
|
||||
}
|
||||
|
||||
// Always broadcast the event so connected WebSocket clients stay in sync.
|
||||
let evt = WatcherEvent::WorkItem {
|
||||
stage: dest_stage.to_string(),
|
||||
item_id,
|
||||
action: action.to_string(),
|
||||
commit_msg,
|
||||
};
|
||||
let _ = event_tx.send(evt);
|
||||
}
|
||||
|
||||
/// Scan `work/5_done/` and move any `.md` files whose mtime is older than
|
||||
@@ -547,7 +571,50 @@ mod tests {
|
||||
// ── flush_pending ─────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn flush_pending_commits_and_broadcasts_work_item_for_addition() {
|
||||
fn flush_pending_commits_and_broadcasts_for_terminal_stage() {
|
||||
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("42_story_foo.md");
|
||||
fs::write(&story_path, "---\nname: test\n---\n").unwrap();
|
||||
|
||||
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
|
||||
let mut pending = HashMap::new();
|
||||
pending.insert(story_path, "1_upcoming".to_string());
|
||||
|
||||
flush_pending(&pending, tmp.path(), &tx);
|
||||
|
||||
let evt = rx.try_recv().expect("expected a broadcast event");
|
||||
match evt {
|
||||
WatcherEvent::WorkItem {
|
||||
stage,
|
||||
item_id,
|
||||
action,
|
||||
commit_msg,
|
||||
} => {
|
||||
assert_eq!(stage, "1_upcoming");
|
||||
assert_eq!(item_id, "42_story_foo");
|
||||
assert_eq!(action, "create");
|
||||
assert_eq!(commit_msg, "story-kit: create 42_story_foo");
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
|
||||
// Verify the file was actually committed.
|
||||
let log = std::process::Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git log");
|
||||
let log_msg = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(
|
||||
log_msg.contains("story-kit: create 42_story_foo"),
|
||||
"terminal stage should produce a git commit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_pending_broadcasts_without_commit_for_intermediate_stage() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
init_git_repo(tmp.path());
|
||||
let stage_dir = make_stage_dir(tmp.path(), "2_current");
|
||||
@@ -560,6 +627,7 @@ mod tests {
|
||||
|
||||
flush_pending(&pending, tmp.path(), &tx);
|
||||
|
||||
// Event should still be broadcast for frontend sync.
|
||||
let evt = rx.try_recv().expect("expected a broadcast event");
|
||||
match evt {
|
||||
WatcherEvent::WorkItem {
|
||||
@@ -575,6 +643,18 @@ mod tests {
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
|
||||
// Verify NO git commit was made (only the initial empty commit should exist).
|
||||
let log = std::process::Command::new("git")
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git log");
|
||||
let log_msg = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(
|
||||
!log_msg.contains("story-kit:"),
|
||||
"intermediate stage should NOT produce a git commit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -600,6 +680,7 @@ mod tests {
|
||||
|
||||
flush_pending(&pending, tmp.path(), &tx);
|
||||
|
||||
// All stages should broadcast events regardless of commit behavior.
|
||||
let evt = rx.try_recv().expect("expected broadcast for stage {stage}");
|
||||
match evt {
|
||||
WatcherEvent::WorkItem {
|
||||
@@ -853,6 +934,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_commit_stage_only_for_terminal_stages() {
|
||||
// Terminal stages — should commit.
|
||||
assert!(should_commit_stage("1_upcoming"));
|
||||
assert!(should_commit_stage("6_archived"));
|
||||
// Intermediate stages — broadcast-only, no commit.
|
||||
assert!(!should_commit_stage("2_current"));
|
||||
assert!(!should_commit_stage("3_qa"));
|
||||
assert!(!should_commit_stage("4_merge"));
|
||||
assert!(!should_commit_stage("5_done"));
|
||||
// Unknown — no commit.
|
||||
assert!(!should_commit_stage("unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_metadata_returns_correct_actions() {
|
||||
let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap();
|
||||
|
||||
Reference in New Issue
Block a user