huskies: merge 1029

This commit is contained in:
dave
2026-05-14 12:53:01 +00:00
parent 0a45805f7b
commit a80d0a497a
4 changed files with 293 additions and 16 deletions
+74 -16
View File
@@ -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<String, crate::service::git_ops::DirtyFiles> = 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<String> = 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 &section_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<String, &'a crate::agents::AgentInfo>,
cost_by_story: &'a HashMap<String, f64>,
config: &'a Option<ProjectConfig>,
running_merges: &'a HashSet<String>,
merge_failures: &'a HashMap<String, String>,
dirty_files_by_story: &'a HashMap<String, crate::service::git_ops::DirtyFiles>,
}
/// Render a single status line for one pipeline item.
fn render_item_line(
item: &PipelineItem,
all_items: &[PipelineItem],
active_map: &HashMap<String, &crate::agents::AgentInfo>,
cost_by_story: &HashMap<String, f64>,
config: &Option<ProjectConfig>,
running_merges: &HashSet<String>,
merge_failures: &HashMap<String, String>,
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}")
}
}