From 5a3f94cae164550ddb82e4c4e31dd5e4db0ee0b4 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 14 May 2026 14:19:16 +0000 Subject: [PATCH] huskies: merge 1042 --- server/src/chat/commands/status/render.rs | 19 +-- server/src/chat/commands/status/tests.rs | 15 +- server/src/chat/commands/triage.rs | 166 ++++++++++++++++++++++ 3 files changed, 180 insertions(+), 20 deletions(-) diff --git a/server/src/chat/commands/status/render.rs b/server/src/chat/commands/status/render.rs index 4ef5f1eb..80f5486b 100644 --- a/server/src/chat/commands/status/render.rs +++ b/server/src/chat/commands/status/render.rs @@ -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. diff --git a/server/src/chat/commands/status/tests.rs b/server/src/chat/commands/status/tests.rs index e6cc5a0b..e7d24061 100644 --- a/server/src/chat/commands/status/tests.rs +++ b/server/src/chat/commands/status/tests.rs @@ -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}" ); } diff --git a/server/src/chat/commands/triage.rs b/server/src/chat/commands/triage.rs index e746ec5b..1af936ed 100644 --- a/server/src/chat/commands/triage.rs +++ b/server/src/chat/commands/triage.rs @@ -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 { 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}" + ); + } }