From a80d0a497ade8d863804f4c56eb443c207a95ea3 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 14 May 2026 12:45:56 +0000 Subject: [PATCH] huskies: merge 1029 --- server/src/chat/commands/status/render.rs | 90 +++++++++--- server/src/chat/commands/status/tests.rs | 159 ++++++++++++++++++++++ server/src/service/git_ops/io.rs | 59 ++++++++ server/src/service/git_ops/mod.rs | 1 + 4 files changed, 293 insertions(+), 16 deletions(-) diff --git a/server/src/chat/commands/status/render.rs b/server/src/chat/commands/status/render.rs index a0fbe9f9..4ef5f1eb 100644 --- a/server/src/chat/commands/status/render.rs +++ b/server/src/chat/commands/status/render.rs @@ -5,6 +5,9 @@ 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" @@ -114,6 +117,21 @@ pub(crate) fn build_status_from_items( let config = ProjectConfig::load(project_root).ok(); + // Pre-fetch working tree state for all Coding-stage items whose worktrees exist. + let dirty_files_by_story: HashMap = items + .iter() + .filter(|i| matches!(i.stage, Stage::Coding { .. })) + .filter_map(|i| { + let wt = crate::worktree::worktree_path(project_root, &i.story_id.0); + if wt.is_dir() { + let info = crate::service::git_ops::io::read_dirty_files_sync(&wt); + Some((i.story_id.0.clone(), info)) + } else { + None + } + }) + .collect(); + // Pre-fetch merge-specific state: deterministic merges in flight and // any merge_failure text persisted to the story's front matter. let running_merges: HashSet = agents @@ -152,16 +170,16 @@ pub(crate) fn build_status_from_items( if section_items.is_empty() { out.push_str(" *(none)*\n"); } else { + let ctx = ItemRenderCtx { + active_map: &active_map, + cost_by_story: &cost_by_story, + config: &config, + running_merges: &running_merges, + merge_failures: &merge_failures, + dirty_files_by_story: &dirty_files_by_story, + }; for item in §ion_items { - out.push_str(&render_item_line( - item, - items, - &active_map, - &cost_by_story, - &config, - &running_merges, - &merge_failures, - )); + out.push_str(&render_item_line(item, items, &ctx)); } } out.push('\n'); @@ -200,16 +218,52 @@ pub(crate) fn build_status_from_items( out } +/// Render working tree summary lines for a story with uncommitted changes. +/// +/// Returns an empty string when the working tree is clean. +fn render_working_tree_lines(info: &crate::service::git_ops::DirtyFiles) -> String { + if info.is_clean() { + return String::new(); + } + let summary = match (info.modified, info.new) { + (m, 0) => format!("{m} modified"), + (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 +} + +/// Shared lookup tables passed to [`render_item_line`] to keep the argument count manageable. +struct ItemRenderCtx<'a> { + active_map: &'a HashMap, + cost_by_story: &'a HashMap, + config: &'a Option, + running_merges: &'a HashSet, + merge_failures: &'a HashMap, + dirty_files_by_story: &'a HashMap, +} + /// Render a single status line for one pipeline item. fn render_item_line( item: &PipelineItem, all_items: &[PipelineItem], - active_map: &HashMap, - cost_by_story: &HashMap, - config: &Option, - running_merges: &HashSet, - merge_failures: &HashMap, + ctx: &ItemRenderCtx<'_>, ) -> String { + let active_map = ctx.active_map; + let cost_by_story = ctx.cost_by_story; + let config = ctx.config; + let running_merges = ctx.running_merges; + let merge_failures = ctx.merge_failures; + let dirty_files_by_story = ctx.dirty_files_by_story; let story_id = &item.story_id.0; let name_opt = if item.name.is_empty() { None @@ -335,6 +389,10 @@ fn render_item_line( .and_then(|a| a.throttled) .is_some_and(|until| until > chrono::Utc::now()); let dot = super::traffic_light_dot(blocked, throttled, agent.is_some()); + let wt_lines = dirty_files_by_story + .get(story_id) + .map(render_working_tree_lines) + .unwrap_or_default(); if let Some(agent) = agent { let model_str = config .as_ref() @@ -342,10 +400,10 @@ fn render_item_line( .and_then(|ac| ac.model.as_ref().map(|m| m.as_str())) .unwrap_or("?"); format!( - " {dot}{display}{cost_suffix}{dep_suffix} — {} ({model_str})\n", + " {dot}{display}{cost_suffix}{dep_suffix} — {} ({model_str})\n{wt_lines}", agent.agent_name ) } else { - format!(" {dot}{display}{cost_suffix}{dep_suffix}\n") + format!(" {dot}{display}{cost_suffix}{dep_suffix}\n{wt_lines}") } } diff --git a/server/src/chat/commands/status/tests.rs b/server/src/chat/commands/status/tests.rs index 3f02908a..e6cc5a0b 100644 --- a/server/src/chat/commands/status/tests.rs +++ b/server/src/chat/commands/status/tests.rs @@ -1155,3 +1155,162 @@ fn display_section_returns_closed_for_new_terminal_variants() { Some("Closed") ); } + +// -- Story 1029: working tree dirty-files summary in status output ------- + +/// Initialise a bare-minimum git repo in `dir` with one commit. +fn init_git_repo(dir: &std::path::Path) { + use std::process::Command; + Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(dir) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .unwrap(); + // Create an initial commit so the repo has a HEAD. + std::fs::write(dir.join("README.md"), "# test").unwrap(); + Command::new("git") + .args(["add", "README.md"]) + .current_dir(dir) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(dir) + .output() + .unwrap(); +} + +#[test] +fn status_shows_working_tree_info_when_coder_has_uncommitted_changes() { + use std::fs; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let project_root = tmp.path(); + let story_id = "1029_story_dirty_worktree"; + + // Set up a fake worktree with committed + uncommitted changes. + let wt_path = project_root + .join(".huskies") + .join("worktrees") + .join(story_id); + fs::create_dir_all(&wt_path).unwrap(); + init_git_repo(&wt_path); + + // Modify a tracked file (unstaged) and add an untracked file. + fs::write(wt_path.join("README.md"), "# changed").unwrap(); + fs::write(wt_path.join("new_feature.rs"), "fn foo() {}").unwrap(); + + let items = vec![make_item( + story_id, + "Dirty Worktree Story", + Stage::Coding { + claim: None, + plan: Default::default(), + retries: 0, + }, + )]; + + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(project_root, &agents, &items); + + assert!( + output.contains("Working tree:"), + "status should show working tree summary when changes exist: {output}" + ); + assert!( + output.contains("(uncommitted)"), + "working tree line should say '(uncommitted)': {output}" + ); + // Should list the dirty files. + assert!( + output.contains("README.md") || output.contains("new_feature.rs"), + "status should list at least one dirty file: {output}" + ); +} + +#[test] +fn status_clean_worktree_shows_no_working_tree_line() { + use std::fs; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let project_root = tmp.path(); + let story_id = "1029_story_clean_worktree"; + + // Set up a clean worktree with only committed changes. + 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 items = vec![make_item( + story_id, + "Clean Worktree Story", + Stage::Coding { + claim: None, + plan: Default::default(), + retries: 0, + }, + )]; + + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(project_root, &agents, &items); + + assert!( + !output.contains("Working tree:"), + "status should not show working tree line when working tree is clean: {output}" + ); +} + +#[test] +fn status_dirty_files_capped_at_twenty_with_overflow_line() { + use std::fs; + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let project_root = tmp.path(); + let story_id = "1029_story_many_files"; + + let wt_path = project_root + .join(".huskies") + .join("worktrees") + .join(story_id); + fs::create_dir_all(&wt_path).unwrap(); + init_git_repo(&wt_path); + + // Create 25 untracked files. + for i in 0..25 { + fs::write(wt_path.join(format!("file_{i:02}.rs")), "fn f() {}").unwrap(); + } + + let items = vec![make_item( + story_id, + "Many Files Story", + Stage::Coding { + claim: None, + plan: Default::default(), + retries: 0, + }, + )]; + + let agents = AgentPool::new_test(3000); + let output = build_status_from_items(project_root, &agents, &items); + + assert!( + output.contains("...and 5 more"), + "overflow line should appear when more than 20 files: {output}" + ); +} diff --git a/server/src/service/git_ops/io.rs b/server/src/service/git_ops/io.rs index 8c004d45..c36fac25 100644 --- a/server/src/service/git_ops/io.rs +++ b/server/src/service/git_ops/io.rs @@ -88,3 +88,62 @@ pub async fn run_git_owned(args: Vec, dir: PathBuf) -> Result, +} + +impl DirtyFiles { + /// Returns `true` when the working tree has no uncommitted changes. + pub fn is_clean(&self) -> bool { + self.paths.is_empty() + } +} + +/// Run `git status --porcelain=v1 -u` synchronously in `dir` and return dirty file info. +/// +/// Returns a zeroed [`DirtyFiles`] if `dir` is not a git repo, git is unavailable, or the +/// working tree is clean. +pub fn read_dirty_files_sync(dir: &Path) -> DirtyFiles { + let output = match std::process::Command::new("git") + .args(["status", "--porcelain=v1", "-u"]) + .current_dir(dir) + .output() + { + Ok(o) => o, + Err(_) => return DirtyFiles::default(), + }; + if !output.status.success() && output.stdout.is_empty() { + return DirtyFiles::default(); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let (staged, unstaged, untracked) = super::porcelain::parse_git_status_porcelain(&stdout); + let mut seen = std::collections::HashSet::new(); + let mut paths = Vec::new(); + let mut modified_set = std::collections::HashSet::new(); + for path in staged.iter().chain(unstaged.iter()) { + modified_set.insert(path.clone()); + if seen.insert(path.clone()) { + paths.push(path.clone()); + } + } + for path in &untracked { + if seen.insert(path.clone()) { + paths.push(path.clone()); + } + } + DirtyFiles { + modified: modified_set.len(), + new: untracked.len(), + paths, + } +} diff --git a/server/src/service/git_ops/mod.rs b/server/src/service/git_ops/mod.rs index 21e73c06..6adc469e 100644 --- a/server/src/service/git_ops/mod.rs +++ b/server/src/service/git_ops/mod.rs @@ -14,6 +14,7 @@ pub mod path_guard; /// Pure git porcelain output parsers. pub mod porcelain; +pub use io::DirtyFiles; #[allow(unused_imports)] pub use path_guard::is_under_root; pub use porcelain::parse_git_status_porcelain;