The great storkit name conversion
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user