Story 46: Deterministic Story Mutations with Auto-Commit
- Add git_stage_and_commit() helper for deterministic commits - move_story_to_current() auto-commits on start_agent - accept_story auto-commits move to archived/ - New MCP tools: check_criterion, set_test_plan (total: 21) - create_story MCP always auto-commits - Tests for check_criterion and set_test_plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -203,6 +203,9 @@ impl AgentPool {
|
||||
status: "pending".to_string(),
|
||||
});
|
||||
|
||||
// Move story from upcoming/ to current/ and auto-commit before creating the worktree.
|
||||
move_story_to_current(project_root, story_id)?;
|
||||
|
||||
// Create worktree
|
||||
let wt_info = worktree::create_worktree(project_root, story_id, &config, self.port).await?;
|
||||
|
||||
@@ -578,6 +581,7 @@ impl AgentPool {
|
||||
}
|
||||
|
||||
/// Return the port this server is running on.
|
||||
#[allow(dead_code)]
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
@@ -652,9 +656,84 @@ impl AgentPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a story file from current/ to archived/ (human accept action).
|
||||
/// Stage one or more file paths and create a deterministic commit in the given git root.
|
||||
///
|
||||
/// * If the story is in current/, it is renamed to archived/.
|
||||
/// Pass deleted paths too so git stages their removal alongside any new files.
|
||||
pub fn git_stage_and_commit(
|
||||
git_root: &Path,
|
||||
paths: &[&Path],
|
||||
message: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut add_cmd = Command::new("git");
|
||||
add_cmd.arg("add").current_dir(git_root);
|
||||
for path in paths {
|
||||
add_cmd.arg(path.to_string_lossy().as_ref());
|
||||
}
|
||||
let output = add_cmd.output().map_err(|e| format!("git add: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"git add failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["commit", "-m", message])
|
||||
.current_dir(git_root)
|
||||
.output()
|
||||
.map_err(|e| format!("git commit: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"git commit failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move a story file from upcoming/ to current/ and auto-commit to master.
|
||||
///
|
||||
/// Idempotent: if the story is already in current/, returns Ok without committing.
|
||||
/// If the story is not found in upcoming/, logs a warning and returns Ok (e.g. if
|
||||
/// the user moved it manually before calling start_agent).
|
||||
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||
let stories_dir = project_root.join(".story_kit").join("stories");
|
||||
let upcoming_path = stories_dir.join("upcoming").join(format!("{story_id}.md"));
|
||||
let current_path = stories_dir.join("current").join(format!("{story_id}.md"));
|
||||
|
||||
if current_path.exists() {
|
||||
// Already in current/ — idempotent, nothing to do.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !upcoming_path.exists() {
|
||||
eprintln!(
|
||||
"[lifecycle] Story '{story_id}' not found in upcoming/; skipping move to current/"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_dir = stories_dir.join("current");
|
||||
std::fs::create_dir_all(¤t_dir)
|
||||
.map_err(|e| format!("Failed to create current stories directory: {e}"))?;
|
||||
|
||||
std::fs::rename(&upcoming_path, ¤t_path)
|
||||
.map_err(|e| format!("Failed to move story '{story_id}' to current/: {e}"))?;
|
||||
|
||||
eprintln!("[lifecycle] Moved story '{story_id}' from upcoming/ to current/");
|
||||
|
||||
let msg = format!("story-kit: start story {story_id}");
|
||||
git_stage_and_commit(
|
||||
project_root,
|
||||
&[current_path.as_path(), upcoming_path.as_path()],
|
||||
&msg,
|
||||
)
|
||||
}
|
||||
|
||||
/// Move a story file from current/ to archived/ (human accept action) and auto-commit.
|
||||
///
|
||||
/// * If the story is in current/, it is renamed to archived/ and committed.
|
||||
/// * If the story is already in archived/, this is a no-op (idempotent).
|
||||
/// * If the story is not found in current/ or archived/, an error is returned.
|
||||
pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||
@@ -674,6 +753,13 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(),
|
||||
std::fs::rename(¤t_path, &archived_path)
|
||||
.map_err(|e| format!("Failed to move story '{story_id}' to archived/: {e}"))?;
|
||||
eprintln!("[lifecycle] Moved story '{story_id}' from current/ to archived/");
|
||||
|
||||
let msg = format!("story-kit: accept story {story_id}");
|
||||
git_stage_and_commit(
|
||||
project_root,
|
||||
&[archived_path.as_path(), current_path.as_path()],
|
||||
&msg,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1110,4 +1196,128 @@ mod tests {
|
||||
"expected 'uncommitted' in: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── move_story_to_current tests ────────────────────────────────────────────
|
||||
|
||||
fn init_git_repo(repo: &std::path::Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_current_moves_file_and_commits() {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
let upcoming = repo.join(".story_kit/stories/upcoming");
|
||||
let current = repo.join(".story_kit/stories/current");
|
||||
fs::create_dir_all(&upcoming).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
|
||||
let story_file = upcoming.join("10_my_story.md");
|
||||
fs::write(&story_file, "---\nname: Test\ntest_plan: pending\n---\n").unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add story"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
move_story_to_current(repo, "10_my_story").unwrap();
|
||||
|
||||
assert!(!story_file.exists(), "upcoming file should be gone");
|
||||
assert!(
|
||||
current.join("10_my_story.md").exists(),
|
||||
"current file should exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_current_is_idempotent_when_already_current() {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
let current = repo.join(".story_kit/stories/current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(
|
||||
current.join("11_my_story.md"),
|
||||
"---\nname: Test\ntest_plan: pending\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should succeed without error even though there's nothing to move
|
||||
move_story_to_current(repo, "11_my_story").unwrap();
|
||||
|
||||
assert!(current.join("11_my_story.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_current_noop_when_not_in_upcoming() {
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
// Story doesn't exist anywhere — should return Ok (lenient)
|
||||
let result = move_story_to_current(repo, "99_missing");
|
||||
assert!(result.is_ok(), "should return Ok when story is not found");
|
||||
}
|
||||
|
||||
// ── git_stage_and_commit tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn git_stage_and_commit_creates_commit() {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_git_repo(repo);
|
||||
|
||||
let file = repo.join("hello.txt");
|
||||
fs::write(&file, "hello").unwrap();
|
||||
|
||||
git_stage_and_commit(repo, &[file.as_path()], "story-kit: test commit").unwrap();
|
||||
|
||||
// Verify the commit exists
|
||||
let output = Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(log.contains("story-kit: test commit"), "commit should appear in log: {log}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user