The great storkit name conversion
This commit is contained in:
@@ -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