The great storkit name conversion

This commit is contained in:
Dave
2026-03-20 12:26:02 +00:00
parent 51d878e117
commit c4e45b2841
25 changed files with 1522 additions and 1333 deletions

View File

@@ -11,7 +11,7 @@ const KEY_KNOWN_PROJECTS: &str = "known_projects";
const STORY_KIT_README: &str = include_str!("../../../.storkit/README.md");
const STORY_KIT_CONTEXT: &str = "<!-- story-kit:scaffold-template -->\n\
const STORY_KIT_CONTEXT: &str = "<!-- storkit:scaffold-template -->\n\
# Project Context\n\
\n\
## High-Level Goal\n\
@@ -30,7 +30,7 @@ TODO: Define the key domain concepts and entities.\n\
\n\
TODO: Define abbreviations and technical terms.\n";
const STORY_KIT_STACK: &str = "<!-- story-kit:scaffold-template -->\n\
const STORY_KIT_STACK: &str = "<!-- storkit:scaffold-template -->\n\
# Tech Stack & Constraints\n\
\n\
## Core Stack\n\
@@ -51,7 +51,7 @@ TODO: List approved libraries and their purpose.\n";
const STORY_KIT_SCRIPT_TEST: &str = "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's test commands here.\n# Story Kit agents invoke this script as the canonical test runner.\n# Exit 0 on success, non-zero on failure.\necho \"No tests configured\"\n";
const STORY_KIT_CLAUDE_MD: &str = "<!-- story-kit:scaffold-template -->\n\
const STORY_KIT_CLAUDE_MD: &str = "<!-- storkit:scaffold-template -->\n\
Never chain shell commands with `&&`, `||`, or `;` in a single Bash call. \
The permission system validates the entire command string, and chained commands \
won't match allow rules like `Bash(git *)`. Use separate Bash calls instead — \
@@ -90,7 +90,7 @@ const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{
"Bash(./script/test:*)",
"Edit",
"Write",
"mcp__story-kit__*"
"mcp__storkit__*"
]
},
"enabledMcpjsonServers": [
@@ -422,8 +422,7 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
];
for stage in &work_stages {
let dir = story_kit_root.join("work").join(stage);
fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create work/{}: {}", stage, e))?;
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create work/{}: {}", stage, e))?;
write_file_if_missing(&dir.join(".gitkeep"), "")?;
}
@@ -464,7 +463,14 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
}
let add_output = std::process::Command::new("git")
.args(["add", ".storkit", "script", ".gitignore", "CLAUDE.md", ".claude"])
.args([
"add",
".storkit",
"script",
".gitignore",
"CLAUDE.md",
".claude",
])
.current_dir(root)
.output()
.map_err(|e| format!("Failed to run git add: {}", e))?;
@@ -478,7 +484,7 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
let commit_output = std::process::Command::new("git")
.args([
"-c",
"user.email=story-kit@localhost",
"user.email=storkit@localhost",
"-c",
"user.name=Story Kit",
"commit",
@@ -526,7 +532,10 @@ pub async fn open_project(
{
// TRACE:MERGE-DEBUG — remove once root cause is found
crate::slog!("[MERGE-DEBUG] open_project: setting project_root to {:?}", p);
crate::slog!(
"[MERGE-DEBUG] open_project: setting project_root to {:?}",
p
);
let mut root = state.project_root.lock().map_err(|e| e.to_string())?;
*root = Some(p);
}
@@ -807,12 +816,7 @@ mod tests {
let store = make_store(&dir);
let state = SessionState::default();
let result = open_project(
project_dir.to_string_lossy().to_string(),
&state,
&store,
)
.await;
let result = open_project(project_dir.to_string_lossy().to_string(), &state, &store).await;
assert!(result.is_ok());
let root = state.get_project_root().unwrap();
@@ -831,13 +835,9 @@ mod tests {
let store = make_store(&dir);
let state = SessionState::default();
open_project(
project_dir.to_string_lossy().to_string(),
&state,
&store,
)
.await
.unwrap();
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
.await
.unwrap();
let mcp_path = project_dir.join(".mcp.json");
assert!(
@@ -898,13 +898,9 @@ mod tests {
let store = make_store(&dir);
let state = SessionState::default();
open_project(
project_dir.to_string_lossy().to_string(),
&state,
&store,
)
.await
.unwrap();
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
.await
.unwrap();
let projects = get_known_projects(&store).unwrap();
assert_eq!(projects.len(), 1);
@@ -978,7 +974,9 @@ mod tests {
let dir = tempdir().unwrap();
let file = dir.path().join("sub").join("output.txt");
write_file_impl(file.clone(), "content".to_string()).await.unwrap();
write_file_impl(file.clone(), "content".to_string())
.await
.unwrap();
assert_eq!(fs::read_to_string(&file).unwrap(), "content");
}
@@ -1089,7 +1087,14 @@ mod tests {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
let stages = ["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"];
let stages = [
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
for stage in &stages {
let path = dir.path().join(".storkit/work").join(stage);
assert!(path.is_dir(), "work/{} should be a directory", stage);
@@ -1106,8 +1111,7 @@ mod tests {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content =
fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
assert!(content.contains("[[agent]]"));
assert!(content.contains("stage = \"coder\""));
assert!(content.contains("stage = \"qa\""));
@@ -1120,9 +1124,8 @@ mod tests {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content =
fs::read_to_string(dir.path().join(".storkit/specs/00_CONTEXT.md")).unwrap();
assert!(content.contains("<!-- story-kit:scaffold-template -->"));
let content = fs::read_to_string(dir.path().join(".storkit/specs/00_CONTEXT.md")).unwrap();
assert!(content.contains("<!-- storkit:scaffold-template -->"));
assert!(content.contains("## High-Level Goal"));
assert!(content.contains("## Core Features"));
assert!(content.contains("## Domain Definition"));
@@ -1137,9 +1140,8 @@ mod tests {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content =
fs::read_to_string(dir.path().join(".storkit/specs/tech/STACK.md")).unwrap();
assert!(content.contains("<!-- story-kit:scaffold-template -->"));
let content = fs::read_to_string(dir.path().join(".storkit/specs/tech/STACK.md")).unwrap();
assert!(content.contains("<!-- storkit:scaffold-template -->"));
assert!(content.contains("## Core Stack"));
assert!(content.contains("## Coding Standards"));
assert!(content.contains("## Quality Gates"));
@@ -1183,10 +1185,8 @@ mod tests {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
let readme_content =
fs::read_to_string(dir.path().join(".storkit/README.md")).unwrap();
let toml_content =
fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
let readme_content = fs::read_to_string(dir.path().join(".storkit/README.md")).unwrap();
let toml_content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
// Run again — must not change content or add duplicate .gitignore entries
scaffold_story_kit(dir.path()).unwrap();
@@ -1207,8 +1207,7 @@ mod tests {
.filter(|l| l.trim() == "worktrees/")
.count();
assert_eq!(
count,
1,
count, 1,
".storkit/.gitignore should not have duplicate entries"
);
}
@@ -1249,8 +1248,7 @@ mod tests {
let log = String::from_utf8_lossy(&log_output.stdout);
let commit_count = log.lines().count();
assert_eq!(
commit_count,
1,
commit_count, 1,
"scaffold should not create a commit in an existing git repo"
);
}
@@ -1261,15 +1259,14 @@ mod tests {
scaffold_story_kit(dir.path()).unwrap();
// .storkit/.gitignore must contain relative patterns for files under .storkit/
let sk_content =
fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap();
let sk_content = fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap();
assert!(sk_content.contains("worktrees/"));
assert!(sk_content.contains("merge_workspace/"));
assert!(sk_content.contains("coverage/"));
// Must NOT contain absolute .storkit/ prefixed paths
assert!(!sk_content.contains(".storkit/"));
// Root .gitignore must contain root-level story-kit entries
// Root .gitignore must contain root-level storkit entries
let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(root_content.contains(".storkit_port"));
assert!(root_content.contains("store.json"));
@@ -1292,17 +1289,10 @@ mod tests {
scaffold_story_kit(dir.path()).unwrap();
let content =
fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap();
let worktrees_count = content
.lines()
.filter(|l| l.trim() == "worktrees/")
.count();
let content = fs::read_to_string(dir.path().join(".storkit/.gitignore")).unwrap();
let worktrees_count = content.lines().filter(|l| l.trim() == "worktrees/").count();
assert_eq!(worktrees_count, 1, "worktrees/ should not be duplicated");
let coverage_count = content
.lines()
.filter(|l| l.trim() == "coverage/")
.count();
let coverage_count = content.lines().filter(|l| l.trim() == "coverage/").count();
assert_eq!(coverage_count, 1, "coverage/ should not be duplicated");
// The missing entry must have been added
assert!(content.contains("merge_workspace/"));
@@ -1316,11 +1306,14 @@ mod tests {
scaffold_story_kit(dir.path()).unwrap();
let claude_md = dir.path().join("CLAUDE.md");
assert!(claude_md.exists(), "CLAUDE.md should be created at project root");
assert!(
claude_md.exists(),
"CLAUDE.md should be created at project root"
);
let content = fs::read_to_string(&claude_md).unwrap();
assert!(
content.contains("<!-- story-kit:scaffold-template -->"),
content.contains("<!-- storkit:scaffold-template -->"),
"CLAUDE.md should contain the scaffold sentinel"
);
assert!(
@@ -1358,13 +1351,9 @@ mod tests {
let store = make_store(&dir);
let state = SessionState::default();
open_project(
project_dir.to_string_lossy().to_string(),
&state,
&store,
)
.await
.unwrap();
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
.await
.unwrap();
// .storkit/ should have been created automatically
assert!(project_dir.join(".storkit").is_dir());
@@ -1381,13 +1370,9 @@ mod tests {
let store = make_store(&dir);
let state = SessionState::default();
open_project(
project_dir.to_string_lossy().to_string(),
&state,
&store,
)
.await
.unwrap();
open_project(project_dir.to_string_lossy().to_string(), &state, &store)
.await
.unwrap();
// Existing .storkit/ content should not be overwritten
assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content");
@@ -1451,7 +1436,11 @@ mod tests {
#[test]
fn detect_cargo_toml_generates_rust_component() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"\n").unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"test\"\n",
)
.unwrap();
let toml = detect_components_toml(dir.path());
assert!(toml.contains("name = \"server\""));
@@ -1482,7 +1471,11 @@ mod tests {
#[test]
fn detect_pyproject_toml_generates_python_component() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("pyproject.toml"), "[project]\nname = \"test\"\n").unwrap();
fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"test\"\n",
)
.unwrap();
let toml = detect_components_toml(dir.path());
assert!(toml.contains("name = \"python\""));
@@ -1512,7 +1505,11 @@ mod tests {
#[test]
fn detect_gemfile_generates_ruby_component() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap();
fs::write(
dir.path().join("Gemfile"),
"source \"https://rubygems.org\"\n",
)
.unwrap();
let toml = detect_components_toml(dir.path());
assert!(toml.contains("name = \"ruby\""));
@@ -1522,7 +1519,11 @@ mod tests {
#[test]
fn detect_multiple_markers_generates_multiple_components() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"server\"\n").unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"server\"\n",
)
.unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
let toml = detect_components_toml(dir.path());
@@ -1565,12 +1566,15 @@ mod tests {
fn scaffold_project_toml_contains_detected_components() {
let dir = tempdir().unwrap();
// Place a Cargo.toml in the project root before scaffolding
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"myapp\"\n").unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"myapp\"\n",
)
.unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content =
fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
assert!(
content.contains("[[component]]"),
"project.toml should contain a component entry"
@@ -1590,8 +1594,7 @@ mod tests {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
let content =
fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
let content = fs::read_to_string(dir.path().join(".storkit/project.toml")).unwrap();
assert!(
content.contains("[[component]]"),
"project.toml should always have at least one component"

View File

@@ -4,7 +4,7 @@ use std::path::Path;
/// Only untouched templates contain this marker — real project content
/// will never include it, so it avoids false positives when the project
/// itself is an "Agentic AI Code Assistant".
const TEMPLATE_SENTINEL: &str = "<!-- story-kit:scaffold-template -->";
const TEMPLATE_SENTINEL: &str = "<!-- storkit:scaffold-template -->";
/// Marker found in the default `script/test` scaffold output.
const TEMPLATE_MARKER_SCRIPT: &str = "No tests configured";
@@ -107,12 +107,12 @@ mod tests {
// Write content that includes the scaffold sentinel
fs::write(
root.join(".storkit/specs/00_CONTEXT.md"),
"<!-- story-kit:scaffold-template -->\n# Project Context\nPlaceholder...",
"<!-- storkit:scaffold-template -->\n# Project Context\nPlaceholder...",
)
.unwrap();
fs::write(
root.join(".storkit/specs/tech/STACK.md"),
"<!-- story-kit:scaffold-template -->\n# Tech Stack\nPlaceholder...",
"<!-- storkit:scaffold-template -->\n# Tech Stack\nPlaceholder...",
)
.unwrap();
@@ -229,11 +229,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
fs::write(
root.join(".storkit/project.toml"),
"# empty config\n",
)
.unwrap();
fs::write(root.join(".storkit/project.toml"), "# empty config\n").unwrap();
let status = check_onboarding_status(&root);
assert!(status.needs_project_toml);
@@ -301,7 +297,7 @@ mod tests {
// Context still has sentinel
fs::write(
root.join(".storkit/specs/00_CONTEXT.md"),
"<!-- story-kit:scaffold-template -->\n# Project Context\nPlaceholder...",
"<!-- storkit:scaffold-template -->\n# Project Context\nPlaceholder...",
)
.unwrap();
// Stack is customised (no sentinel)

View File

@@ -78,12 +78,12 @@ pub fn is_config_file(path: &Path, git_root: &Path) -> bool {
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> {
let (action, prefix) = match stage {
"1_backlog" => ("create", format!("story-kit: create {item_id}")),
"2_current" => ("start", format!("story-kit: start {item_id}")),
"3_qa" => ("qa", format!("story-kit: queue {item_id} for QA")),
"4_merge" => ("merge", format!("story-kit: queue {item_id} for merge")),
"5_done" => ("done", format!("story-kit: done {item_id}")),
"6_archived" => ("accept", format!("story-kit: accept {item_id}")),
"1_backlog" => ("create", format!("storkit: create {item_id}")),
"2_current" => ("start", format!("storkit: start {item_id}")),
"3_qa" => ("qa", format!("storkit: queue {item_id} for QA")),
"4_merge" => ("merge", format!("storkit: queue {item_id} for merge")),
"5_done" => ("done", format!("storkit: done {item_id}")),
"6_archived" => ("accept", format!("storkit: accept {item_id}")),
_ => return None,
};
Some((action, prefix))
@@ -97,10 +97,7 @@ fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)>
/// auto-committed to master by the watcher.
fn stage_for_path(path: &Path) -> Option<String> {
// Reject any path that passes through the worktrees directory.
if path
.components()
.any(|c| c.as_os_str() == "worktrees")
{
if path.components().any(|c| c.as_os_str() == "worktrees") {
return None;
}
@@ -111,8 +108,11 @@ fn stage_for_path(path: &Path) -> Option<String> {
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())?;
matches!(stage, "1_backlog" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived")
.then(|| stage.to_string())
matches!(
stage,
"1_backlog" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived"
)
.then(|| stage.to_string())
}
/// Stage all changes in the work directory and commit with the given message.
@@ -190,7 +190,10 @@ fn flush_pending(
// Pick the commit message from the first addition (the meaningful side of a move).
// If there are only deletions, use a generic message.
let (action, item_id, commit_msg) = if let Some((path, stage)) = additions.first() {
let item = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
let item = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
if let Some((act, msg)) = stage_metadata(stage, item) {
(act, item.to_string(), msg)
} else {
@@ -201,8 +204,15 @@ fn flush_pending(
let Some((path, _)) = pending.iter().next() else {
return;
};
let item = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
("remove", item.to_string(), format!("story-kit: remove {item}"))
let item = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
(
"remove",
item.to_string(),
format!("storkit: remove {item}"),
)
};
// Strip stale merge_failure front matter from any story that has left 4_merge/.
@@ -210,7 +220,10 @@ fn flush_pending(
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] Warning: could not clear merge_failure from {}: {e}",
path.display()
);
}
}
@@ -261,7 +274,6 @@ fn flush_pending(
/// Called periodically from the watcher thread. File moves will trigger normal
/// watcher events, which `flush_pending` will commit and broadcast.
fn sweep_done_to_archived(work_dir: &Path, git_root: &Path, done_retention: Duration) {
// ── Part 1: promote old items from 5_done/ → 6_archived/ ───────────────
let done_dir = work_dir.join("5_done");
if done_dir.exists() {
@@ -281,9 +293,7 @@ fn sweep_done_to_archived(work_dir: &Path, git_root: &Path, done_retention: Dura
Err(_) => continue,
};
let age = SystemTime::now()
.duration_since(mtime)
.unwrap_or_default();
let age = SystemTime::now().duration_since(mtime).unwrap_or_default();
if age >= done_retention {
if let Err(e) = std::fs::create_dir_all(&archived_dir) {
@@ -372,7 +382,10 @@ pub fn start_watcher(
if config_file.exists()
&& let Err(e) = watcher.watch(&config_file, RecursiveMode::NonRecursive)
{
slog!("[watcher] failed to watch config file {}: {e}", config_file.display());
slog!(
"[watcher] failed to watch config file {}: {e}",
config_file.display()
);
}
slog!("[watcher] watching {}", work_dir.display());
@@ -453,13 +466,10 @@ pub fn start_watcher(
// Hot-reload sweep config from project.toml.
match ProjectConfig::load(&git_root) {
Ok(cfg) => {
let new_sweep =
Duration::from_secs(cfg.watcher.sweep_interval_secs);
let new_sweep = Duration::from_secs(cfg.watcher.sweep_interval_secs);
let new_retention =
Duration::from_secs(cfg.watcher.done_retention_secs);
if new_sweep != sweep_interval
|| new_retention != done_retention
{
if new_sweep != sweep_interval || new_retention != done_retention {
slog!(
"[watcher] hot-reload: sweep_interval={}s done_retention={}s",
cfg.watcher.sweep_interval_secs,
@@ -535,14 +545,14 @@ mod tests {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "2_current");
fs::write(
stage_dir.join("42_story_foo.md"),
"---\nname: test\n---\n",
)
.unwrap();
fs::write(stage_dir.join("42_story_foo.md"), "---\nname: test\n---\n").unwrap();
let result = git_add_work_and_commit(tmp.path(), "story-kit: start 42_story_foo");
assert_eq!(result, Ok(true), "should return Ok(true) when a commit was made");
let result = git_add_work_and_commit(tmp.path(), "storkit: start 42_story_foo");
assert_eq!(
result,
Ok(true),
"should return Ok(true) when a commit was made"
);
}
#[test]
@@ -550,17 +560,13 @@ mod tests {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "2_current");
fs::write(
stage_dir.join("42_story_foo.md"),
"---\nname: test\n---\n",
)
.unwrap();
fs::write(stage_dir.join("42_story_foo.md"), "---\nname: test\n---\n").unwrap();
// First commit — should succeed.
git_add_work_and_commit(tmp.path(), "story-kit: start 42_story_foo").unwrap();
git_add_work_and_commit(tmp.path(), "storkit: start 42_story_foo").unwrap();
// Second call with no changes — should return Ok(false).
let result = git_add_work_and_commit(tmp.path(), "story-kit: start 42_story_foo");
let result = git_add_work_and_commit(tmp.path(), "storkit: start 42_story_foo");
assert_eq!(
result,
Ok(false),
@@ -595,7 +601,7 @@ mod tests {
assert_eq!(stage, "1_backlog");
assert_eq!(item_id, "42_story_foo");
assert_eq!(action, "create");
assert_eq!(commit_msg, "story-kit: create 42_story_foo");
assert_eq!(commit_msg, "storkit: create 42_story_foo");
}
other => panic!("unexpected event: {other:?}"),
}
@@ -608,7 +614,7 @@ mod tests {
.expect("git log");
let log_msg = String::from_utf8_lossy(&log.stdout);
assert!(
log_msg.contains("story-kit: create 42_story_foo"),
log_msg.contains("storkit: create 42_story_foo"),
"terminal stage should produce a git commit"
);
}
@@ -639,7 +645,7 @@ mod tests {
assert_eq!(stage, "2_current");
assert_eq!(item_id, "42_story_foo");
assert_eq!(action, "start");
assert_eq!(commit_msg, "story-kit: start 42_story_foo");
assert_eq!(commit_msg, "storkit: start 42_story_foo");
}
other => panic!("unexpected event: {other:?}"),
}
@@ -652,7 +658,7 @@ mod tests {
.expect("git log");
let log_msg = String::from_utf8_lossy(&log.stdout);
assert!(
!log_msg.contains("story-kit:"),
!log_msg.contains("storkit:"),
"intermediate stage should NOT produce a git commit"
);
}
@@ -660,11 +666,11 @@ mod tests {
#[test]
fn flush_pending_broadcasts_for_all_pipeline_stages() {
let stages = [
("1_backlog", "create", "story-kit: create 10_story_x"),
("3_qa", "qa", "story-kit: queue 10_story_x for QA"),
("4_merge", "merge", "story-kit: queue 10_story_x for merge"),
("5_done", "done", "story-kit: done 10_story_x"),
("6_archived", "accept", "story-kit: accept 10_story_x"),
("1_backlog", "create", "storkit: create 10_story_x"),
("3_qa", "qa", "storkit: queue 10_story_x for QA"),
("4_merge", "merge", "storkit: queue 10_story_x for merge"),
("5_done", "done", "storkit: done 10_story_x"),
("6_archived", "accept", "storkit: accept 10_story_x"),
];
for (stage, expected_action, expected_msg) in stages {
@@ -714,7 +720,9 @@ mod tests {
flush_pending(&pending, tmp.path(), &tx);
// Even when nothing was committed (file never existed), an event is broadcast.
let evt = rx.try_recv().expect("expected a broadcast event for deletion");
let evt = rx
.try_recv()
.expect("expected a broadcast event for deletion");
match evt {
WatcherEvent::WorkItem {
action, item_id, ..
@@ -882,7 +890,10 @@ mod tests {
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");
assert_eq!(
contents, original,
"file without merge_failure should be unchanged"
);
}
// ── stage_for_path (additional edge cases) ────────────────────────────────
@@ -929,7 +940,9 @@ mod tests {
// A path that only contains the word "worktrees" as part of a longer
// segment (not an exact component) must NOT be filtered out.
assert_eq!(
stage_for_path(&PathBuf::from("/proj/.storkit/work/2_current/not_worktrees_story.md")),
stage_for_path(&PathBuf::from(
"/proj/.storkit/work/2_current/not_worktrees_story.md"
)),
Some("2_current".to_string()),
);
}
@@ -952,15 +965,15 @@ mod tests {
fn stage_metadata_returns_correct_actions() {
let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap();
assert_eq!(action, "start");
assert_eq!(msg, "story-kit: start 42_story_foo");
assert_eq!(msg, "storkit: start 42_story_foo");
let (action, msg) = stage_metadata("5_done", "42_story_foo").unwrap();
assert_eq!(action, "done");
assert_eq!(msg, "story-kit: done 42_story_foo");
assert_eq!(msg, "storkit: done 42_story_foo");
let (action, msg) = stage_metadata("6_archived", "42_story_foo").unwrap();
assert_eq!(action, "accept");
assert_eq!(msg, "story-kit: accept 42_story_foo");
assert_eq!(msg, "storkit: accept 42_story_foo");
assert!(stage_metadata("unknown", "id").is_none());
}
@@ -976,9 +989,8 @@ mod tests {
fn is_config_file_rejects_worktree_copies() {
let git_root = PathBuf::from("/proj");
// project.toml inside a worktree must NOT be treated as the root config.
let worktree_config = PathBuf::from(
"/proj/.storkit/worktrees/42_story_foo/.storkit/project.toml",
);
let worktree_config =
PathBuf::from("/proj/.storkit/worktrees/42_story_foo/.storkit/project.toml");
assert!(!is_config_file(&worktree_config, &git_root));
}
@@ -1019,14 +1031,16 @@ mod tests {
let past = SystemTime::now()
.checked_sub(Duration::from_secs(5 * 60 * 60))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)).unwrap();
let retention = Duration::from_secs(4 * 60 * 60);
// tmp.path() has no worktrees dir — prune_worktree_sync is a no-op.
sweep_done_to_archived(&work_dir, tmp.path(), retention);
assert!(!story_path.exists(), "old item should be moved out of 5_done/");
assert!(
!story_path.exists(),
"old item should be moved out of 5_done/"
);
assert!(
archived_dir.join("10_story_old.md").exists(),
"old item should appear in 6_archived/"
@@ -1064,8 +1078,7 @@ mod tests {
let past = SystemTime::now()
.checked_sub(Duration::from_secs(120))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)).unwrap();
// With a 1-minute retention, the 2-minute-old file should be swept.
sweep_done_to_archived(&work_dir, tmp.path(), Duration::from_secs(60));
@@ -1093,8 +1106,7 @@ mod tests {
let past = SystemTime::now()
.checked_sub(Duration::from_secs(30))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)).unwrap();
// With a 1-minute retention, the 30-second-old file should stay.
sweep_done_to_archived(&work_dir, tmp.path(), Duration::from_secs(60));
@@ -1138,8 +1150,7 @@ mod tests {
let past = SystemTime::now()
.checked_sub(Duration::from_secs(5 * 60 * 60))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)).unwrap();
// Create a real git worktree for this story.
let wt_path = crate::worktree::worktree_path(&git_root, story_id);
@@ -1151,16 +1162,19 @@ mod tests {
sweep_done_to_archived(&work_dir, &git_root, retention);
// Story must be archived.
assert!(!story_path.exists(), "story should be moved out of 5_done/");
assert!(
!story_path.exists(),
"story should be moved out of 5_done/"
);
assert!(
work_dir.join("6_archived").join(format!("{story_id}.md")).exists(),
work_dir
.join("6_archived")
.join(format!("{story_id}.md"))
.exists(),
"story should appear in 6_archived/"
);
// Worktree must be removed.
assert!(!wt_path.exists(), "worktree should be removed after archiving");
assert!(
!wt_path.exists(),
"worktree should be removed after archiving"
);
}
#[test]
@@ -1190,9 +1204,15 @@ mod tests {
sweep_done_to_archived(&work_dir, &git_root, retention);
// Stale worktree should be pruned.
assert!(!wt_path.exists(), "stale worktree should be pruned by sweep");
assert!(
!wt_path.exists(),
"stale worktree should be pruned by sweep"
);
// Story file must remain untouched.
assert!(story_path.exists(), "archived story file must not be removed");
assert!(
story_path.exists(),
"archived story file must not be removed"
);
}
#[test]
@@ -1214,8 +1234,7 @@ mod tests {
let past = SystemTime::now()
.checked_sub(Duration::from_secs(5 * 60 * 60))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past))
.unwrap();
filetime::set_file_mtime(&story_path, filetime::FileTime::from_system_time(past)).unwrap();
// Create a plain directory at the expected worktree path — not a real
// git worktree, so `git worktree remove` will fail.
@@ -1231,7 +1250,10 @@ mod tests {
"story should be archived even when worktree removal fails"
);
assert!(
work_dir.join("6_archived").join(format!("{story_id}.md")).exists(),
work_dir
.join("6_archived")
.join(format!("{story_id}.md"))
.exists(),
"story should appear in 6_archived/ despite worktree removal failure"
);
}