2026-04-04 15:20:35 +00:00
|
|
|
use std::path::Path;
|
2026-03-22 19:07:07 +00:00
|
|
|
use std::process::Command;
|
|
|
|
|
|
|
|
|
|
use crate::io::story_metadata::{clear_front_matter_field, write_rejection_notes};
|
|
|
|
|
use crate::slog;
|
|
|
|
|
|
|
|
|
|
pub(super) fn item_type_from_id(item_id: &str) -> &'static str {
|
|
|
|
|
// New format: {digits}_{type}_{slug}
|
|
|
|
|
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
|
|
|
|
|
if after_num.starts_with("_bug_") {
|
|
|
|
|
"bug"
|
|
|
|
|
} else if after_num.starts_with("_spike_") {
|
|
|
|
|
"spike"
|
|
|
|
|
} else {
|
|
|
|
|
"story"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
/// Move `{story_id}.md` from the first matching `sources` dir to `target_dir`, clearing
|
|
|
|
|
/// `fields_to_clear`. Returns `Ok(Some(src_dir))` on move, `Ok(None)` if idempotent or missing_ok.
|
|
|
|
|
fn move_item<'a>(
|
|
|
|
|
project_root: &Path,
|
|
|
|
|
story_id: &str,
|
|
|
|
|
sources: &'a [&'a str],
|
|
|
|
|
target_dir: &str,
|
|
|
|
|
extra_done_dirs: &[&str],
|
|
|
|
|
missing_ok: bool,
|
|
|
|
|
fields_to_clear: &[&str],
|
|
|
|
|
) -> Result<Option<&'a str>, String> {
|
2026-04-03 16:12:52 +01:00
|
|
|
let sk = project_root.join(".huskies").join("work");
|
2026-04-04 15:20:35 +00:00
|
|
|
let target_dir_path = sk.join(target_dir);
|
|
|
|
|
let target_path = target_dir_path.join(format!("{story_id}.md"));
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
if target_path.exists()
|
|
|
|
|
|| extra_done_dirs
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|d| sk.join(d).join(format!("{story_id}.md")).exists())
|
|
|
|
|
{
|
|
|
|
|
return Ok(None);
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
let (src_dir, src_path) = match sources.iter().find_map(|&s| {
|
|
|
|
|
let p = sk.join(s).join(format!("{story_id}.md"));
|
|
|
|
|
p.exists().then_some((s, p))
|
|
|
|
|
}) {
|
|
|
|
|
Some(t) => t,
|
|
|
|
|
None if missing_ok => {
|
|
|
|
|
slog!("[lifecycle] Work item '{story_id}' not found; skipping move to work/{target_dir}/");
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
let locs = sources.iter().map(|s| format!("work/{s}/")).collect::<Vec<_>>().join(" or ");
|
|
|
|
|
return Err(format!("Work item '{story_id}' not found in {locs}."));
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
std::fs::create_dir_all(&target_dir_path)
|
|
|
|
|
.map_err(|e| format!("Failed to create work/{target_dir}/ directory: {e}"))?;
|
|
|
|
|
std::fs::rename(&src_path, &target_path)
|
|
|
|
|
.map_err(|e| format!("Failed to move '{story_id}' to work/{target_dir}/: {e}"))?;
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
for field in fields_to_clear {
|
|
|
|
|
if let Err(e) = clear_front_matter_field(&target_path, field) {
|
|
|
|
|
slog!("[lifecycle] Warning: could not clear {field} from '{story_id}': {e}");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-08 01:14:55 +00:00
|
|
|
// Write state through CRDT ops (and legacy shadow table) so subscribers
|
|
|
|
|
// are notified of the stage transition without relying on the filesystem watcher.
|
|
|
|
|
crate::db::shadow_write(story_id, target_dir, &target_path);
|
2026-04-07 13:09:48 +00:00
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
slog!("[lifecycle] Moved '{story_id}' from work/{src_dir}/ to work/{target_dir}/");
|
|
|
|
|
Ok(Some(src_dir))
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
/// Move a work item (story, bug, or spike) from `work/1_backlog/` to `work/2_current/`.
|
|
|
|
|
///
|
|
|
|
|
/// Idempotent: if already in `2_current/`, returns Ok. If not found in `1_backlog/`, logs and returns Ok.
|
|
|
|
|
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
|
|
|
|
|
move_item(project_root, story_id, &["1_backlog"], "2_current", &[], true, &[]).map(|_| ())
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check whether a feature branch `feature/story-{story_id}` exists and has
|
|
|
|
|
/// commits that are not yet on master. Returns `true` when there is unmerged
|
|
|
|
|
/// work, `false` when there is no branch or all its commits are already
|
|
|
|
|
/// reachable from master.
|
|
|
|
|
pub fn feature_branch_has_unmerged_changes(project_root: &Path, story_id: &str) -> bool {
|
|
|
|
|
let branch = format!("feature/story-{story_id}");
|
|
|
|
|
|
|
|
|
|
// Check if the branch exists.
|
|
|
|
|
let branch_check = Command::new("git")
|
|
|
|
|
.args(["rev-parse", "--verify", &branch])
|
|
|
|
|
.current_dir(project_root)
|
|
|
|
|
.output();
|
|
|
|
|
match branch_check {
|
|
|
|
|
Ok(out) if out.status.success() => {}
|
|
|
|
|
_ => return false, // No feature branch → nothing to merge.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if the branch has commits not reachable from master.
|
|
|
|
|
let log = Command::new("git")
|
|
|
|
|
.args(["log", &format!("master..{branch}"), "--oneline"])
|
|
|
|
|
.current_dir(project_root)
|
|
|
|
|
.output();
|
|
|
|
|
match log {
|
|
|
|
|
Ok(out) => {
|
|
|
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
|
|
|
!stdout.trim().is_empty()
|
|
|
|
|
}
|
|
|
|
|
Err(_) => false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
/// Move a story from `work/2_current/` or `work/4_merge/` to `work/5_done/`.
|
2026-03-22 19:07:07 +00:00
|
|
|
///
|
2026-04-04 15:20:35 +00:00
|
|
|
/// Idempotent if already in `5_done/` or `6_archived/`. Errors if not found in `2_current/` or `4_merge/`.
|
2026-03-28 12:37:03 +00:00
|
|
|
pub fn move_story_to_done(project_root: &Path, story_id: &str) -> Result<(), String> {
|
2026-04-04 15:20:35 +00:00
|
|
|
move_item(
|
|
|
|
|
project_root,
|
|
|
|
|
story_id,
|
|
|
|
|
&["2_current", "4_merge"],
|
|
|
|
|
"5_done",
|
|
|
|
|
&["6_archived"],
|
|
|
|
|
false,
|
|
|
|
|
&["merge_failure", "retry_count", "blocked"],
|
|
|
|
|
)
|
|
|
|
|
.map(|_| ())
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Move a story/bug from `work/2_current/` or `work/3_qa/` to `work/4_merge/`.
|
|
|
|
|
///
|
2026-04-04 15:20:35 +00:00
|
|
|
/// Idempotent if already in `4_merge/`. Errors if not found in `2_current/` or `3_qa/`.
|
2026-03-22 19:07:07 +00:00
|
|
|
pub fn move_story_to_merge(project_root: &Path, story_id: &str) -> Result<(), String> {
|
2026-04-04 15:20:35 +00:00
|
|
|
move_item(
|
|
|
|
|
project_root,
|
|
|
|
|
story_id,
|
|
|
|
|
&["2_current", "3_qa"],
|
|
|
|
|
"4_merge",
|
|
|
|
|
&[],
|
|
|
|
|
false,
|
|
|
|
|
&["retry_count", "blocked"],
|
|
|
|
|
)
|
|
|
|
|
.map(|_| ())
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
/// Move a story/bug from `work/2_current/` to `work/3_qa/`.
|
2026-03-22 19:07:07 +00:00
|
|
|
///
|
2026-04-04 15:20:35 +00:00
|
|
|
/// Idempotent if already in `3_qa/`. Errors if not found in `2_current/`.
|
2026-03-22 19:07:07 +00:00
|
|
|
pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), String> {
|
2026-04-04 15:20:35 +00:00
|
|
|
move_item(
|
|
|
|
|
project_root,
|
|
|
|
|
story_id,
|
|
|
|
|
&["2_current"],
|
|
|
|
|
"3_qa",
|
|
|
|
|
&[],
|
|
|
|
|
false,
|
|
|
|
|
&["retry_count", "blocked"],
|
|
|
|
|
)
|
|
|
|
|
.map(|_| ())
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
/// Move a story from `work/3_qa/` back to `work/2_current/`, clearing `review_hold` and writing notes.
|
|
|
|
|
pub fn reject_story_from_qa(project_root: &Path, story_id: &str, notes: &str) -> Result<(), String> {
|
|
|
|
|
let moved = move_item(project_root, story_id, &["3_qa"], "2_current", &[], false, &["review_hold"])?;
|
|
|
|
|
if moved.is_some() && !notes.is_empty() {
|
|
|
|
|
let path = project_root.join(".huskies/work/2_current").join(format!("{story_id}.md"));
|
|
|
|
|
if let Err(e) = write_rejection_notes(&path, notes) {
|
|
|
|
|
slog!("[lifecycle] Warning: could not write rejection notes to '{story_id}': {e}");
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Move any work item to an arbitrary pipeline stage by searching all stages.
|
|
|
|
|
///
|
|
|
|
|
/// Accepts `target_stage` as one of: `backlog`, `current`, `qa`, `merge`, `done`.
|
|
|
|
|
/// Idempotent: if the item is already in the target stage, returns Ok.
|
|
|
|
|
/// Returns `(from_stage, to_stage)` on success.
|
|
|
|
|
pub fn move_story_to_stage(
|
|
|
|
|
project_root: &Path,
|
|
|
|
|
story_id: &str,
|
|
|
|
|
target_stage: &str,
|
|
|
|
|
) -> Result<(String, String), String> {
|
2026-04-04 15:20:35 +00:00
|
|
|
const STAGES: &[(&str, &str)] = &[
|
2026-03-22 19:07:07 +00:00
|
|
|
("backlog", "1_backlog"),
|
|
|
|
|
("current", "2_current"),
|
|
|
|
|
("qa", "3_qa"),
|
|
|
|
|
("merge", "4_merge"),
|
|
|
|
|
("done", "5_done"),
|
2026-04-04 15:20:35 +00:00
|
|
|
("archived", "6_archived"),
|
2026-03-22 19:07:07 +00:00
|
|
|
];
|
|
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
let target_dir = STAGES
|
2026-03-22 19:07:07 +00:00
|
|
|
.iter()
|
2026-04-04 15:20:35 +00:00
|
|
|
.filter(|(name, _)| *name != "archived")
|
2026-03-22 19:07:07 +00:00
|
|
|
.find(|(name, _)| *name == target_stage)
|
|
|
|
|
.map(|(_, dir)| *dir)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"Invalid target_stage '{target_stage}'. Must be one of: backlog, current, qa, merge, done"
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
let all_dirs: Vec<&str> = STAGES.iter().map(|(_, dir)| *dir).collect();
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
match move_item(project_root, story_id, &all_dirs, target_dir, &[], false, &[])
|
|
|
|
|
.map_err(|_| format!("Work item '{story_id}' not found in any pipeline stage."))?
|
|
|
|
|
{
|
|
|
|
|
Some(src_dir) => {
|
|
|
|
|
let from_stage = STAGES
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|(_, dir)| *dir == src_dir)
|
|
|
|
|
.map(|(name, _)| *name)
|
|
|
|
|
.unwrap_or(src_dir);
|
|
|
|
|
Ok((from_stage.to_string(), target_stage.to_string()))
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
2026-04-04 15:20:35 +00:00
|
|
|
None => Ok((target_stage.to_string(), target_stage.to_string())),
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:20:35 +00:00
|
|
|
/// Move a bug from `work/2_current/` or `work/1_backlog/` to `work/5_done/`.
|
2026-03-22 19:07:07 +00:00
|
|
|
///
|
2026-04-04 15:20:35 +00:00
|
|
|
/// Idempotent if already in `5_done/`. Errors if not found in `2_current/` or `1_backlog/`.
|
2026-03-22 19:07:07 +00:00
|
|
|
pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), String> {
|
2026-04-04 15:20:35 +00:00
|
|
|
move_item(
|
|
|
|
|
project_root,
|
|
|
|
|
bug_id,
|
|
|
|
|
&["2_current", "1_backlog"],
|
|
|
|
|
"5_done",
|
|
|
|
|
&[],
|
|
|
|
|
false,
|
|
|
|
|
&[],
|
|
|
|
|
)
|
|
|
|
|
.map(|_| ())
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
// ── move_story_to_current tests ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_current_moves_file() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let backlog = root.join(".huskies/work/1_backlog");
|
|
|
|
|
let current = root.join(".huskies/work/2_current");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(&backlog).unwrap();
|
|
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::write(backlog.join("10_story_foo.md"), "test").unwrap();
|
|
|
|
|
|
|
|
|
|
move_story_to_current(root, "10_story_foo").unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(!backlog.join("10_story_foo.md").exists());
|
|
|
|
|
assert!(current.join("10_story_foo.md").exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_current_is_idempotent_when_already_current() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let current = root.join(".huskies/work/2_current");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::write(current.join("11_story_foo.md"), "test").unwrap();
|
|
|
|
|
|
|
|
|
|
move_story_to_current(root, "11_story_foo").unwrap();
|
|
|
|
|
assert!(current.join("11_story_foo.md").exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_current_noop_when_not_in_backlog() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
assert!(move_story_to_current(tmp.path(), "99_missing").is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_bug_to_current_moves_from_backlog() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let backlog = root.join(".huskies/work/1_backlog");
|
|
|
|
|
let current = root.join(".huskies/work/2_current");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(&backlog).unwrap();
|
|
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::write(backlog.join("1_bug_test.md"), "# Bug 1\n").unwrap();
|
|
|
|
|
|
|
|
|
|
move_story_to_current(root, "1_bug_test").unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(!backlog.join("1_bug_test.md").exists());
|
|
|
|
|
assert!(current.join("1_bug_test.md").exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── close_bug_to_archive tests ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn close_bug_moves_from_current_to_archive() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let current = root.join(".huskies/work/2_current");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::write(current.join("2_bug_test.md"), "# Bug 2\n").unwrap();
|
|
|
|
|
|
|
|
|
|
close_bug_to_archive(root, "2_bug_test").unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(!current.join("2_bug_test.md").exists());
|
2026-04-03 16:12:52 +01:00
|
|
|
assert!(root.join(".huskies/work/5_done/2_bug_test.md").exists());
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn close_bug_moves_from_backlog_when_not_started() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let backlog = root.join(".huskies/work/1_backlog");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(&backlog).unwrap();
|
|
|
|
|
fs::write(backlog.join("3_bug_test.md"), "# Bug 3\n").unwrap();
|
|
|
|
|
|
|
|
|
|
close_bug_to_archive(root, "3_bug_test").unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(!backlog.join("3_bug_test.md").exists());
|
2026-04-03 16:12:52 +01:00
|
|
|
assert!(root.join(".huskies/work/5_done/3_bug_test.md").exists());
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── item_type_from_id tests ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn item_type_from_id_detects_types() {
|
|
|
|
|
assert_eq!(item_type_from_id("1_bug_test"), "bug");
|
|
|
|
|
assert_eq!(item_type_from_id("1_spike_research"), "spike");
|
|
|
|
|
assert_eq!(item_type_from_id("50_story_my_story"), "story");
|
|
|
|
|
assert_eq!(item_type_from_id("1_story_simple"), "story");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── move_story_to_merge tests ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_merge_moves_file() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let current = root.join(".huskies/work/2_current");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::write(current.join("20_story_foo.md"), "test").unwrap();
|
|
|
|
|
|
|
|
|
|
move_story_to_merge(root, "20_story_foo").unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(!current.join("20_story_foo.md").exists());
|
2026-04-03 16:12:52 +01:00
|
|
|
assert!(root.join(".huskies/work/4_merge/20_story_foo.md").exists());
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_merge_from_qa_dir() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let qa_dir = root.join(".huskies/work/3_qa");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(&qa_dir).unwrap();
|
|
|
|
|
fs::write(qa_dir.join("40_story_test.md"), "test").unwrap();
|
|
|
|
|
|
|
|
|
|
move_story_to_merge(root, "40_story_test").unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(!qa_dir.join("40_story_test.md").exists());
|
2026-04-03 16:12:52 +01:00
|
|
|
assert!(root.join(".huskies/work/4_merge/40_story_test.md").exists());
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_merge_idempotent_when_already_in_merge() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let merge_dir = root.join(".huskies/work/4_merge");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(&merge_dir).unwrap();
|
|
|
|
|
fs::write(merge_dir.join("21_story_test.md"), "test").unwrap();
|
|
|
|
|
|
|
|
|
|
move_story_to_merge(root, "21_story_test").unwrap();
|
|
|
|
|
assert!(merge_dir.join("21_story_test.md").exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_merge_errors_when_not_in_current_or_qa() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let result = move_story_to_merge(tmp.path(), "99_nonexistent");
|
|
|
|
|
assert!(result.unwrap_err().contains("not found in work/2_current/ or work/3_qa/"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── move_story_to_qa tests ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_qa_moves_file() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let current = root.join(".huskies/work/2_current");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::write(current.join("30_story_qa.md"), "test").unwrap();
|
|
|
|
|
|
|
|
|
|
move_story_to_qa(root, "30_story_qa").unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(!current.join("30_story_qa.md").exists());
|
2026-04-03 16:12:52 +01:00
|
|
|
assert!(root.join(".huskies/work/3_qa/30_story_qa.md").exists());
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_qa_idempotent_when_already_in_qa() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let qa_dir = root.join(".huskies/work/3_qa");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(&qa_dir).unwrap();
|
|
|
|
|
fs::write(qa_dir.join("31_story_test.md"), "test").unwrap();
|
|
|
|
|
|
|
|
|
|
move_story_to_qa(root, "31_story_test").unwrap();
|
|
|
|
|
assert!(qa_dir.join("31_story_test.md").exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_qa_errors_when_not_in_current() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let result = move_story_to_qa(tmp.path(), "99_nonexistent");
|
|
|
|
|
assert!(result.unwrap_err().contains("not found in work/2_current/"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 12:37:03 +00:00
|
|
|
// ── move_story_to_done tests ──────────────────────────────────────────
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 12:37:03 +00:00
|
|
|
fn move_story_to_done_finds_in_merge_dir() {
|
2026-03-22 19:07:07 +00:00
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let merge_dir = root.join(".huskies/work/4_merge");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(&merge_dir).unwrap();
|
|
|
|
|
fs::write(merge_dir.join("22_story_test.md"), "test").unwrap();
|
|
|
|
|
|
2026-03-28 12:37:03 +00:00
|
|
|
move_story_to_done(root, "22_story_test").unwrap();
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
assert!(!merge_dir.join("22_story_test.md").exists());
|
2026-04-03 16:12:52 +01:00
|
|
|
assert!(root.join(".huskies/work/5_done/22_story_test.md").exists());
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 12:37:03 +00:00
|
|
|
fn move_story_to_done_error_when_not_in_current_or_merge() {
|
2026-03-22 19:07:07 +00:00
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
2026-03-28 12:37:03 +00:00
|
|
|
let result = move_story_to_done(tmp.path(), "99_nonexistent");
|
2026-03-22 19:07:07 +00:00
|
|
|
assert!(result.unwrap_err().contains("4_merge"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── feature_branch_has_unmerged_changes tests ────────────────────────────
|
|
|
|
|
|
|
|
|
|
fn init_git_repo(repo: &std::path::Path) {
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["init"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["config", "user.email", "test@test.com"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["config", "user.name", "Test"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["commit", "--allow-empty", "-m", "init"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bug 226: feature_branch_has_unmerged_changes returns true when the
|
|
|
|
|
/// feature branch has commits not on master.
|
|
|
|
|
#[test]
|
|
|
|
|
fn feature_branch_has_unmerged_changes_detects_unmerged_code() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let repo = tmp.path();
|
|
|
|
|
init_git_repo(repo);
|
|
|
|
|
|
|
|
|
|
// Create a feature branch with a code commit.
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["checkout", "-b", "feature/story-50_story_test"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
fs::write(repo.join("feature.rs"), "fn main() {}").unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["add", "."])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["commit", "-m", "add feature"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
Command::new("git")
|
|
|
|
|
.args(["checkout", "master"])
|
|
|
|
|
.current_dir(repo)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
feature_branch_has_unmerged_changes(repo, "50_story_test"),
|
|
|
|
|
"should detect unmerged changes on feature branch"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bug 226: feature_branch_has_unmerged_changes returns false when no
|
|
|
|
|
/// feature branch exists.
|
|
|
|
|
#[test]
|
|
|
|
|
fn feature_branch_has_unmerged_changes_false_when_no_branch() {
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
let tmp = tempdir().unwrap();
|
|
|
|
|
let repo = tmp.path();
|
|
|
|
|
init_git_repo(repo);
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
!feature_branch_has_unmerged_changes(repo, "99_nonexistent"),
|
|
|
|
|
"should return false when no feature branch"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── reject_story_from_qa tests ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn reject_story_from_qa_moves_to_current() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let qa_dir = root.join(".huskies/work/3_qa");
|
|
|
|
|
let current_dir = root.join(".huskies/work/2_current");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(&qa_dir).unwrap();
|
|
|
|
|
fs::create_dir_all(¤t_dir).unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
qa_dir.join("50_story_test.md"),
|
|
|
|
|
"---\nname: Test\nreview_hold: true\n---\n# Story\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
reject_story_from_qa(root, "50_story_test", "Button color wrong").unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(!qa_dir.join("50_story_test.md").exists());
|
|
|
|
|
assert!(current_dir.join("50_story_test.md").exists());
|
|
|
|
|
let contents = fs::read_to_string(current_dir.join("50_story_test.md")).unwrap();
|
|
|
|
|
assert!(contents.contains("Button color wrong"));
|
|
|
|
|
assert!(contents.contains("## QA Rejection Notes"));
|
|
|
|
|
assert!(!contents.contains("review_hold"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn reject_story_from_qa_errors_when_not_in_qa() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let result = reject_story_from_qa(tmp.path(), "99_nonexistent", "notes");
|
|
|
|
|
assert!(result.unwrap_err().contains("not found in work/3_qa/"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn reject_story_from_qa_idempotent_when_in_current() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let current_dir = root.join(".huskies/work/2_current");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(¤t_dir).unwrap();
|
|
|
|
|
fs::write(current_dir.join("51_story_test.md"), "---\nname: Test\n---\n# Story\n").unwrap();
|
|
|
|
|
|
|
|
|
|
reject_story_from_qa(root, "51_story_test", "notes").unwrap();
|
|
|
|
|
assert!(current_dir.join("51_story_test.md").exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── move_story_to_stage tests ─────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_stage_moves_from_backlog_to_current() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let backlog = root.join(".huskies/work/1_backlog");
|
|
|
|
|
let current = root.join(".huskies/work/2_current");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(&backlog).unwrap();
|
|
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::write(backlog.join("60_story_move.md"), "test").unwrap();
|
|
|
|
|
|
|
|
|
|
let (from, to) = move_story_to_stage(root, "60_story_move", "current").unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(from, "backlog");
|
|
|
|
|
assert_eq!(to, "current");
|
|
|
|
|
assert!(!backlog.join("60_story_move.md").exists());
|
|
|
|
|
assert!(current.join("60_story_move.md").exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_stage_moves_from_current_to_backlog() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let current = root.join(".huskies/work/2_current");
|
|
|
|
|
let backlog = root.join(".huskies/work/1_backlog");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::create_dir_all(&backlog).unwrap();
|
|
|
|
|
fs::write(current.join("61_story_back.md"), "test").unwrap();
|
|
|
|
|
|
|
|
|
|
let (from, to) = move_story_to_stage(root, "61_story_back", "backlog").unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(from, "current");
|
|
|
|
|
assert_eq!(to, "backlog");
|
|
|
|
|
assert!(!current.join("61_story_back.md").exists());
|
|
|
|
|
assert!(backlog.join("61_story_back.md").exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_stage_idempotent_when_already_in_target() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let current = root.join(".huskies/work/2_current");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::write(current.join("62_story_idem.md"), "test").unwrap();
|
|
|
|
|
|
|
|
|
|
let (from, to) = move_story_to_stage(root, "62_story_idem", "current").unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(from, "current");
|
|
|
|
|
assert_eq!(to, "current");
|
|
|
|
|
assert!(current.join("62_story_idem.md").exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_stage_invalid_target_returns_error() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let result = move_story_to_stage(tmp.path(), "1_story_test", "invalid");
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
assert!(result.unwrap_err().contains("Invalid target_stage"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_stage_not_found_returns_error() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let result = move_story_to_stage(tmp.path(), "99_story_ghost", "current");
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
assert!(result.unwrap_err().contains("not found in any pipeline stage"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn move_story_to_stage_finds_in_qa_dir() {
|
|
|
|
|
use std::fs;
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path();
|
2026-04-03 16:12:52 +01:00
|
|
|
let qa_dir = root.join(".huskies/work/3_qa");
|
|
|
|
|
let backlog = root.join(".huskies/work/1_backlog");
|
2026-03-22 19:07:07 +00:00
|
|
|
fs::create_dir_all(&qa_dir).unwrap();
|
|
|
|
|
fs::create_dir_all(&backlog).unwrap();
|
|
|
|
|
fs::write(qa_dir.join("63_story_qa.md"), "test").unwrap();
|
|
|
|
|
|
|
|
|
|
let (from, to) = move_story_to_stage(root, "63_story_qa", "backlog").unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(from, "qa");
|
|
|
|
|
assert_eq!(to, "backlog");
|
|
|
|
|
assert!(!qa_dir.join("63_story_qa.md").exists());
|
|
|
|
|
assert!(backlog.join("63_story_qa.md").exists());
|
|
|
|
|
}
|
|
|
|
|
}
|