huskies: merge 1042

This commit is contained in:
dave
2026-05-14 14:19:16 +00:00
parent 8faf19f3ab
commit 5a3f94cae1
3 changed files with 180 additions and 20 deletions
+4 -15
View File
@@ -5,9 +5,6 @@ use crate::config::ProjectConfig;
use crate::pipeline_state::{ArchiveReason, PipelineItem, Stage};
use std::collections::{HashMap, HashSet};
/// Maximum number of dirty file paths shown inline per story before truncating.
const MAX_DIRTY_FILES_SHOWN: usize = 20;
/// Map a stage to its display section label, or `None` to skip it entirely.
///
/// This is the single source of truth for the "where does this item appear"
@@ -218,9 +215,10 @@ pub(crate) fn build_status_from_items(
out
}
/// Render working tree summary lines for a story with uncommitted changes.
/// Render the one-line working tree summary for a story with uncommitted changes.
///
/// Returns an empty string when the working tree is clean.
/// Returns an empty string when the working tree is clean. File paths are not
/// listed here; use `status N` (triage) for the per-file breakdown.
fn render_working_tree_lines(info: &crate::service::git_ops::DirtyFiles) -> String {
if info.is_clean() {
return String::new();
@@ -230,16 +228,7 @@ fn render_working_tree_lines(info: &crate::service::git_ops::DirtyFiles) -> Stri
(0, n) => format!("{n} new"),
(m, n) => format!("{m} modified, {n} new"),
};
let mut out = format!(" Working tree: {summary} (uncommitted)\n");
let shown = info.paths.len().min(MAX_DIRTY_FILES_SHOWN);
for path in &info.paths[..shown] {
out.push_str(&format!(" {path}\n"));
}
if info.paths.len() > MAX_DIRTY_FILES_SHOWN {
let remaining = info.paths.len() - MAX_DIRTY_FILES_SHOWN;
out.push_str(&format!(" ...and {remaining} more\n"));
}
out
format!(" Working tree: {summary} (uncommitted)\n")
}
/// Shared lookup tables passed to [`render_item_line`] to keep the argument count manageable.
+10 -5
View File
@@ -1232,10 +1232,10 @@ fn status_shows_working_tree_info_when_coder_has_uncommitted_changes() {
output.contains("(uncommitted)"),
"working tree line should say '(uncommitted)': {output}"
);
// Should list the dirty files.
// Overview must NOT list individual file paths — those belong in `status N`.
assert!(
output.contains("README.md") || output.contains("new_feature.rs"),
"status should list at least one dirty file: {output}"
!output.contains("README.md") && !output.contains("new_feature.rs"),
"overview must not list individual dirty file paths: {output}"
);
}
@@ -1309,8 +1309,13 @@ fn status_dirty_files_capped_at_twenty_with_overflow_line() {
let agents = AgentPool::new_test(3000);
let output = build_status_from_items(project_root, &agents, &items);
// Overview shows only the summary line — no path listing, no overflow.
assert!(
output.contains("...and 5 more"),
"overflow line should appear when more than 20 files: {output}"
!output.contains("...and 5 more"),
"overview must not show overflow line — paths belong in triage view: {output}"
);
assert!(
output.contains("Working tree:") && output.contains("(uncommitted)"),
"overview should still show the summary line when dirty: {output}"
);
}
+166
View File
@@ -15,6 +15,9 @@ use super::CommandContext;
use std::path::Path;
use std::process::Command;
/// Maximum number of dirty file paths listed per story before truncating.
const MAX_DIRTY_FILES_SHOWN: usize = 20;
/// Handle `{bot_name} status {number}`.
pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
@@ -163,6 +166,28 @@ fn build_triage_dump(
} else {
out.push_str("**Recent commits (branch only):** *(none yet)*\n\n");
}
// ---- Uncommitted working tree changes ----
let dirty = crate::service::git_ops::io::read_dirty_files_sync(&wt_path);
if dirty.is_clean() {
out.push_str("**Working tree:** working tree clean\n\n");
} else {
let summary = match (dirty.modified, dirty.new) {
(m, 0) => format!("{m} modified"),
(0, n) => format!("{n} new"),
(m, n) => format!("{m} modified, {n} new"),
};
out.push_str(&format!("**Working tree:** {summary} (uncommitted)\n"));
let shown = dirty.paths.len().min(MAX_DIRTY_FILES_SHOWN);
for path in &dirty.paths[..shown] {
out.push_str(&format!(" {path}\n"));
}
if dirty.paths.len() > MAX_DIRTY_FILES_SHOWN {
let remaining = dirty.paths.len() - MAX_DIRTY_FILES_SHOWN;
out.push_str(&format!(" ...and {remaining} more\n"));
}
out.push('\n');
}
} else {
out.push_str(&format!("**Branch:** `{branch}`\n"));
out.push_str("**Worktree:** *(not yet created)*\n\n");
@@ -500,4 +525,145 @@ mod tests {
let result = parse_acceptance_criteria(input);
assert!(result.is_empty());
}
// -- working tree state in triage output --------------------------------
/// Initialise a bare-minimum git repo in `dir` with one commit.
fn init_git_repo(dir: &std::path::Path) {
use std::process::Command as Cmd;
Cmd::new("git")
.args(["init", "-b", "main"])
.current_dir(dir)
.output()
.unwrap();
Cmd::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(dir)
.output()
.unwrap();
Cmd::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir)
.output()
.unwrap();
std::fs::write(dir.join("README.md"), "# test").unwrap();
Cmd::new("git")
.args(["add", "README.md"])
.current_dir(dir)
.output()
.unwrap();
Cmd::new("git")
.args(["commit", "-m", "init"])
.current_dir(dir)
.output()
.unwrap();
}
#[test]
fn triage_shows_dirty_files_when_worktree_is_dirty() {
use std::fs;
use tempfile::TempDir;
crate::db::ensure_content_store();
let tmp = TempDir::new().unwrap();
let project_root = tmp.path();
let story_id = "8801_story_triage_dirty";
write_story_file(
project_root,
"2_current",
&format!("{story_id}.md"),
"",
Some("Triage Dirty"),
);
let wt_path = project_root
.join(".huskies")
.join("worktrees")
.join(story_id);
fs::create_dir_all(&wt_path).unwrap();
init_git_repo(&wt_path);
fs::write(wt_path.join("README.md"), "# changed").unwrap();
fs::write(wt_path.join("new_file.rs"), "fn foo() {}").unwrap();
let output = status_triage_cmd(project_root, "8801").unwrap();
assert!(
output.contains("Working tree:") && output.contains("(uncommitted)"),
"triage should show working tree summary when dirty: {output}"
);
assert!(
output.contains("README.md") || output.contains("new_file.rs"),
"triage should list dirty file paths: {output}"
);
}
#[test]
fn triage_shows_clean_when_worktree_is_clean() {
use std::fs;
use tempfile::TempDir;
crate::db::ensure_content_store();
let tmp = TempDir::new().unwrap();
let project_root = tmp.path();
let story_id = "8802_story_triage_clean";
write_story_file(
project_root,
"2_current",
&format!("{story_id}.md"),
"",
Some("Triage Clean"),
);
let wt_path = project_root
.join(".huskies")
.join("worktrees")
.join(story_id);
fs::create_dir_all(&wt_path).unwrap();
init_git_repo(&wt_path);
let output = status_triage_cmd(project_root, "8802").unwrap();
assert!(
output.contains("working tree clean"),
"triage should show 'working tree clean' when no uncommitted changes: {output}"
);
}
#[test]
fn triage_dirty_files_capped_at_twenty_with_overflow() {
use std::fs;
use tempfile::TempDir;
crate::db::ensure_content_store();
let tmp = TempDir::new().unwrap();
let project_root = tmp.path();
let story_id = "8803_story_triage_many";
write_story_file(
project_root,
"2_current",
&format!("{story_id}.md"),
"",
Some("Triage Many"),
);
let wt_path = project_root
.join(".huskies")
.join("worktrees")
.join(story_id);
fs::create_dir_all(&wt_path).unwrap();
init_git_repo(&wt_path);
for i in 0..25 {
fs::write(wt_path.join(format!("file_{i:02}.rs")), "fn f() {}").unwrap();
}
let output = status_triage_cmd(project_root, "8803").unwrap();
assert!(
output.contains("...and 5 more"),
"triage overflow line should appear when more than 20 dirty files: {output}"
);
}
}