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

@@ -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"
);
}