huskies: merge 758

This commit is contained in:
dave
2026-04-27 22:16:12 +00:00
parent 03738f35f5
commit 225c4f2b46
2 changed files with 20 additions and 746 deletions
+20 -545
View File
@@ -1,550 +1,6 @@
use super::*;
use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
/// Initialise a minimal git repo so commit operations work.
fn init_git_repo(dir: &std::path::Path) {
use std::process::Command;
Command::new("git")
.args(["init"])
.current_dir(dir)
.output()
.expect("git init");
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir)
.output()
.expect("git config email");
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir)
.output()
.expect("git config name");
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir)
.output()
.expect("git initial commit");
}
/// Create the `.huskies/work/{stage}/` dir tree inside `root`.
fn make_stage_dir(root: &std::path::Path, stage: &str) -> PathBuf {
let dir = root.join(".huskies").join("work").join(stage);
fs::create_dir_all(&dir).expect("create stage dir");
dir
}
// ── git_add_work_and_commit ───────────────────────────────────────────────
#[test]
fn git_commit_returns_true_when_file_added() {
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();
let result = git_add_work_and_commit(tmp.path(), "huskies: start 42_story_foo");
assert_eq!(
result,
Ok(true),
"should return Ok(true) when a commit was made"
);
}
#[test]
fn git_commit_returns_false_when_nothing_to_commit() {
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();
// First commit — should succeed.
git_add_work_and_commit(tmp.path(), "huskies: start 42_story_foo").unwrap();
// Second call with no changes — should return Ok(false).
let result = git_add_work_and_commit(tmp.path(), "huskies: start 42_story_foo");
assert_eq!(
result,
Ok(false),
"should return Ok(false) when nothing to commit"
);
}
// ── flush_pending ─────────────────────────────────────────────────────────
#[test]
fn flush_pending_commits_and_broadcasts_for_terminal_stage() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "1_backlog");
let story_path = stage_dir.join("42_story_foo.md");
fs::write(&story_path, "---\nname: test\n---\n").unwrap();
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path, "1_backlog".to_string());
flush_pending(&pending, tmp.path(), &tx);
let evt = rx.try_recv().expect("expected a broadcast event");
match evt {
WatcherEvent::WorkItem {
stage,
item_id,
action,
commit_msg,
..
} => {
assert_eq!(stage, "1_backlog");
assert_eq!(item_id, "42_story_foo");
assert_eq!(action, "create");
assert_eq!(commit_msg, "huskies: create 42_story_foo");
}
other => panic!("unexpected event: {other:?}"),
}
// Verify the file was actually committed.
let log = std::process::Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(tmp.path())
.output()
.expect("git log");
let log_msg = String::from_utf8_lossy(&log.stdout);
assert!(
log_msg.contains("huskies: create 42_story_foo"),
"terminal stage should produce a git commit"
);
}
#[test]
fn flush_pending_broadcasts_without_commit_for_intermediate_stage() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "2_current");
let story_path = stage_dir.join("42_story_foo.md");
fs::write(&story_path, "---\nname: test\n---\n").unwrap();
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path, "2_current".to_string());
flush_pending(&pending, tmp.path(), &tx);
// Event should still be broadcast for frontend sync.
let evt = rx.try_recv().expect("expected a broadcast event");
match evt {
WatcherEvent::WorkItem {
stage,
item_id,
action,
commit_msg,
..
} => {
assert_eq!(stage, "2_current");
assert_eq!(item_id, "42_story_foo");
assert_eq!(action, "start");
assert_eq!(commit_msg, "huskies: start 42_story_foo");
}
other => panic!("unexpected event: {other:?}"),
}
// Verify NO git commit was made (only the initial empty commit should exist).
let log = std::process::Command::new("git")
.args(["log", "--oneline"])
.current_dir(tmp.path())
.output()
.expect("git log");
let log_msg = String::from_utf8_lossy(&log.stdout);
assert!(
!log_msg.contains("huskies:"),
"intermediate stage should NOT produce a git commit"
);
}
#[test]
fn flush_pending_broadcasts_for_all_pipeline_stages() {
let stages = [
("1_backlog", "create", "huskies: create 10_story_x"),
("3_qa", "qa", "huskies: queue 10_story_x for QA"),
("4_merge", "merge", "huskies: queue 10_story_x for merge"),
("5_done", "done", "huskies: done 10_story_x"),
("6_archived", "accept", "huskies: accept 10_story_x"),
];
for (stage, expected_action, expected_msg) in stages {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), stage);
let story_path = stage_dir.join("10_story_x.md");
fs::write(&story_path, "---\nname: test\n---\n").unwrap();
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path, stage.to_string());
flush_pending(&pending, tmp.path(), &tx);
// All stages should broadcast events regardless of commit behavior.
let evt = rx.try_recv().expect("expected broadcast for stage {stage}");
match evt {
WatcherEvent::WorkItem {
action, commit_msg, ..
} => {
assert_eq!(action, expected_action, "stage {stage}");
assert_eq!(commit_msg, expected_msg, "stage {stage}");
}
other => panic!("unexpected event for stage {stage}: {other:?}"),
}
}
}
#[test]
fn flush_pending_deletion_only_broadcasts_remove_event() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
// Create the work dir tree but NOT the file (simulates a deletion).
make_stage_dir(tmp.path(), "2_current");
let deleted_path = tmp
.path()
.join(".huskies")
.join("work")
.join("2_current")
.join("42_story_foo.md");
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(deleted_path, "2_current".to_string());
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");
match evt {
WatcherEvent::WorkItem {
action, item_id, ..
} => {
assert_eq!(action, "remove");
assert_eq!(item_id, "42_story_foo");
}
other => panic!("unexpected event: {other:?}"),
}
}
#[test]
fn flush_pending_skips_unknown_stage_for_addition() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
// File sits in an unrecognised directory.
let unknown_dir = tmp.path().join(".huskies").join("work").join("9_unknown");
fs::create_dir_all(&unknown_dir).unwrap();
let path = unknown_dir.join("42_story_foo.md");
fs::write(&path, "---\nname: test\n---\n").unwrap();
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(path, "9_unknown".to_string());
flush_pending(&pending, tmp.path(), &tx);
// No event should be broadcast because stage_metadata returns None for unknown stages.
assert!(
rx.try_recv().is_err(),
"no event should be broadcast for unknown stage"
);
}
#[test]
fn flush_pending_empty_pending_does_nothing() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
make_stage_dir(tmp.path(), "2_current");
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let pending: HashMap<PathBuf, String> = HashMap::new();
// Should not panic and should not broadcast anything.
flush_pending(&pending, tmp.path(), &tx);
assert!(rx.try_recv().is_err(), "no event for empty pending map");
}
// ── flush_pending clears merge_failure ─────────────────────────────────────
#[test]
fn flush_pending_clears_merge_failure_when_leaving_merge_stage() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "2_current");
let story_path = stage_dir.join("50_story_retry.md");
fs::write(
&story_path,
"---\nname: Retry Story\nmerge_failure: \"conflicts detected\"\n---\n# Story\n",
)
.unwrap();
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path.clone(), "2_current".to_string());
flush_pending(&pending, tmp.path(), &tx);
let contents = fs::read_to_string(&story_path).unwrap();
assert!(
!contents.contains("merge_failure"),
"merge_failure should be stripped when story lands in 2_current"
);
assert!(contents.contains("name: Retry Story"));
}
#[test]
fn flush_pending_clears_merge_failure_when_moving_to_backlog() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "1_backlog");
let story_path = stage_dir.join("51_story_reset.md");
fs::write(
&story_path,
"---\nname: Reset Story\nmerge_failure: \"gate failed\"\n---\n# Story\n",
)
.unwrap();
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path.clone(), "1_backlog".to_string());
flush_pending(&pending, tmp.path(), &tx);
let contents = fs::read_to_string(&story_path).unwrap();
assert!(
!contents.contains("merge_failure"),
"merge_failure should be stripped when story lands in 1_backlog"
);
}
#[test]
fn flush_pending_clears_merge_failure_when_moving_to_done() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "5_done");
let story_path = stage_dir.join("52_story_done.md");
fs::write(
&story_path,
"---\nname: Done Story\nmerge_failure: \"stale error\"\n---\n# Story\n",
)
.unwrap();
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path.clone(), "5_done".to_string());
flush_pending(&pending, tmp.path(), &tx);
let contents = fs::read_to_string(&story_path).unwrap();
assert!(
!contents.contains("merge_failure"),
"merge_failure should be stripped when story lands in 5_done"
);
}
#[test]
fn flush_pending_preserves_merge_failure_when_in_merge_stage() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "4_merge");
let story_path = stage_dir.join("53_story_merging.md");
fs::write(
&story_path,
"---\nname: Merging Story\nmerge_failure: \"conflicts\"\n---\n# Story\n",
)
.unwrap();
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path.clone(), "4_merge".to_string());
flush_pending(&pending, tmp.path(), &tx);
let contents = fs::read_to_string(&story_path).unwrap();
assert!(
contents.contains("merge_failure"),
"merge_failure should be preserved when story is in 4_merge"
);
}
#[test]
fn flush_pending_no_op_when_no_merge_failure() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "2_current");
let story_path = stage_dir.join("54_story_clean.md");
let original = "---\nname: Clean Story\n---\n# Story\n";
fs::write(&story_path, original).unwrap();
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path.clone(), "2_current".to_string());
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"
);
}
// ── flush_pending from_stage ─────────────────────────────────────────────
/// AC3: when a pending map contains both a deletion (source stage) and a
/// creation (dest stage) for the same item_id, the broadcast event should
/// have `from_stage` set to the source stage key.
#[test]
fn flush_pending_sets_from_stage_for_move_operations() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
// Destination exists (file moved here).
let merge_dir = make_stage_dir(tmp.path(), "4_merge");
let merge_path = merge_dir.join("42_story_foo.md");
fs::write(&merge_path, "---\nname: test\n---\n").unwrap();
// Source path does NOT exist (file was moved away).
make_stage_dir(tmp.path(), "3_qa");
let qa_path = tmp
.path()
.join(".huskies")
.join("work")
.join("3_qa")
.join("42_story_foo.md");
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(merge_path, "4_merge".to_string()); // addition
pending.insert(qa_path, "3_qa".to_string()); // deletion
flush_pending(&pending, tmp.path(), &tx);
let evt = rx.try_recv().expect("expected event");
match evt {
WatcherEvent::WorkItem {
stage, from_stage, ..
} => {
assert_eq!(stage, "4_merge");
assert_eq!(from_stage, Some("3_qa".to_string()));
}
other => panic!("unexpected event: {other:?}"),
}
}
/// AC3: when a pending map has only an addition (creation, not a move),
/// `from_stage` should be `None`.
#[test]
fn flush_pending_sets_from_stage_to_none_for_creations() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "2_current");
let story_path = stage_dir.join("55_story_new.md");
fs::write(&story_path, "---\nname: New Story\n---\n").unwrap();
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path, "2_current".to_string());
flush_pending(&pending, tmp.path(), &tx);
let evt = rx.try_recv().expect("expected event");
match evt {
WatcherEvent::WorkItem { from_stage, .. } => {
assert_eq!(from_stage, None, "creation should have no from_stage");
}
other => panic!("unexpected event: {other:?}"),
}
}
// ── stage_for_path (additional edge cases) ────────────────────────────────
#[test]
fn stage_for_path_recognises_pipeline_dirs() {
let base = PathBuf::from("/proj/.huskies/work");
assert_eq!(
stage_for_path(&base.join("2_current/42_story_foo.md")),
Some("2_current".to_string())
);
assert_eq!(
stage_for_path(&base.join("5_done/10_bug_bar.md")),
Some("5_done".to_string())
);
assert_eq!(
stage_for_path(&base.join("6_archived/10_bug_bar.md")),
Some("6_archived".to_string())
);
assert_eq!(stage_for_path(&base.join("other/file.md")), None);
assert_eq!(
stage_for_path(&base.join("2_current/42_story_foo.txt")),
None
);
}
#[test]
fn stage_for_path_ignores_worktree_paths() {
let worktrees = PathBuf::from("/proj/.huskies/worktrees");
// Code changes inside a worktree must be ignored.
assert_eq!(
stage_for_path(&worktrees.join("42_story_foo/server/src/main.rs")),
None,
);
// Even if a worktree happens to contain a path component that looks
// like a pipeline stage, it must still be ignored.
assert_eq!(
stage_for_path(&worktrees.join("42_story_foo/.huskies/work/2_current/42_story_foo.md")),
None,
);
// 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/.huskies/work/2_current/not_worktrees_story.md"
)),
Some("2_current".to_string()),
);
}
#[test]
fn should_commit_stage_only_for_terminal_stages() {
// Terminal stages — should commit.
assert!(should_commit_stage("1_backlog"));
assert!(should_commit_stage("5_done"));
assert!(should_commit_stage("6_archived"));
// Intermediate stages — broadcast-only, no commit.
assert!(!should_commit_stage("2_current"));
assert!(!should_commit_stage("3_qa"));
assert!(!should_commit_stage("4_merge"));
// Unknown — no commit.
assert!(!should_commit_stage("unknown"));
}
#[test]
fn stage_metadata_returns_correct_actions() {
let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap();
assert_eq!(action, "start");
assert_eq!(msg, "huskies: start 42_story_foo");
let (action, msg) = stage_metadata("5_done", "42_story_foo").unwrap();
assert_eq!(action, "done");
assert_eq!(msg, "huskies: done 42_story_foo");
let (action, msg) = stage_metadata("6_archived", "42_story_foo").unwrap();
assert_eq!(action, "accept");
assert_eq!(msg, "huskies: accept 42_story_foo");
assert!(stage_metadata("unknown", "id").is_none());
}
// ── is_config_file ────────────────────────────────────────────────────────
#[test]
fn is_config_file_identifies_root_project_toml() {
@@ -590,6 +46,25 @@ fn is_config_file_rejects_wrong_root() {
assert!(!is_config_file(&other_root_config, &git_root));
}
// ── stage_metadata ────────────────────────────────────────────────────────
#[test]
fn stage_metadata_returns_correct_actions() {
let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap();
assert_eq!(action, "start");
assert_eq!(msg, "huskies: start 42_story_foo");
let (action, msg) = stage_metadata("5_done", "42_story_foo").unwrap();
assert_eq!(action, "done");
assert_eq!(msg, "huskies: done 42_story_foo");
let (action, msg) = stage_metadata("6_archived", "42_story_foo").unwrap();
assert_eq!(action, "accept");
assert_eq!(msg, "huskies: accept 42_story_foo");
assert!(stage_metadata("unknown", "id").is_none());
}
// ── sweep_done_to_archived (CRDT-based) ─────────────────────────────────
//
// The sweep function reads from `read_all_typed()` and checks