huskies: merge 1029
This commit is contained in:
@@ -5,6 +5,9 @@ use crate::config::ProjectConfig;
|
|||||||
use crate::pipeline_state::{ArchiveReason, PipelineItem, Stage};
|
use crate::pipeline_state::{ArchiveReason, PipelineItem, Stage};
|
||||||
use std::collections::{HashMap, HashSet};
|
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.
|
/// 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"
|
/// 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();
|
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
|
// Pre-fetch merge-specific state: deterministic merges in flight and
|
||||||
// any merge_failure text persisted to the story's front matter.
|
// any merge_failure text persisted to the story's front matter.
|
||||||
let running_merges: HashSet<String> = agents
|
let running_merges: HashSet<String> = agents
|
||||||
@@ -152,16 +170,16 @@ pub(crate) fn build_status_from_items(
|
|||||||
if section_items.is_empty() {
|
if section_items.is_empty() {
|
||||||
out.push_str(" *(none)*\n");
|
out.push_str(" *(none)*\n");
|
||||||
} else {
|
} 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 {
|
for item in §ion_items {
|
||||||
out.push_str(&render_item_line(
|
out.push_str(&render_item_line(item, items, &ctx));
|
||||||
item,
|
|
||||||
items,
|
|
||||||
&active_map,
|
|
||||||
&cost_by_story,
|
|
||||||
&config,
|
|
||||||
&running_merges,
|
|
||||||
&merge_failures,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
@@ -200,16 +218,52 @@ pub(crate) fn build_status_from_items(
|
|||||||
out
|
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.
|
/// Render a single status line for one pipeline item.
|
||||||
fn render_item_line(
|
fn render_item_line(
|
||||||
item: &PipelineItem,
|
item: &PipelineItem,
|
||||||
all_items: &[PipelineItem],
|
all_items: &[PipelineItem],
|
||||||
active_map: &HashMap<String, &crate::agents::AgentInfo>,
|
ctx: &ItemRenderCtx<'_>,
|
||||||
cost_by_story: &HashMap<String, f64>,
|
|
||||||
config: &Option<ProjectConfig>,
|
|
||||||
running_merges: &HashSet<String>,
|
|
||||||
merge_failures: &HashMap<String, String>,
|
|
||||||
) -> String {
|
) -> 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 story_id = &item.story_id.0;
|
||||||
let name_opt = if item.name.is_empty() {
|
let name_opt = if item.name.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -335,6 +389,10 @@ fn render_item_line(
|
|||||||
.and_then(|a| a.throttled)
|
.and_then(|a| a.throttled)
|
||||||
.is_some_and(|until| until > chrono::Utc::now());
|
.is_some_and(|until| until > chrono::Utc::now());
|
||||||
let dot = super::traffic_light_dot(blocked, throttled, agent.is_some());
|
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 {
|
if let Some(agent) = agent {
|
||||||
let model_str = config
|
let model_str = config
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -342,10 +400,10 @@ fn render_item_line(
|
|||||||
.and_then(|ac| ac.model.as_ref().map(|m| m.as_str()))
|
.and_then(|ac| ac.model.as_ref().map(|m| m.as_str()))
|
||||||
.unwrap_or("?");
|
.unwrap_or("?");
|
||||||
format!(
|
format!(
|
||||||
" {dot}{display}{cost_suffix}{dep_suffix} — {} ({model_str})\n",
|
" {dot}{display}{cost_suffix}{dep_suffix} — {} ({model_str})\n{wt_lines}",
|
||||||
agent.agent_name
|
agent.agent_name
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(" {dot}{display}{cost_suffix}{dep_suffix}\n")
|
format!(" {dot}{display}{cost_suffix}{dep_suffix}\n{wt_lines}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1155,3 +1155,162 @@ fn display_section_returns_closed_for_new_terminal_variants() {
|
|||||||
Some("Closed")
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,3 +88,62 @@ pub async fn run_git_owned(args: Vec<String>, dir: PathBuf) -> Result<Output, Er
|
|||||||
.map_err(|e| Error::UpstreamFailure(format!("Task join error: {e}")))?
|
.map_err(|e| Error::UpstreamFailure(format!("Task join error: {e}")))?
|
||||||
.map_err(|e| Error::Io(format!("Failed to run git: {e}")))
|
.map_err(|e| Error::Io(format!("Failed to run git: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Summary of uncommitted changes in a git working tree.
|
||||||
|
///
|
||||||
|
/// Returned by [`read_dirty_files_sync`] for use in status rendering.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct DirtyFiles {
|
||||||
|
/// Number of tracked files that have uncommitted changes (staged or unstaged).
|
||||||
|
pub modified: usize,
|
||||||
|
/// Number of untracked files.
|
||||||
|
pub new: usize,
|
||||||
|
/// All dirty file paths in the order `git status` reports them (deduplicated).
|
||||||
|
pub paths: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod path_guard;
|
|||||||
/// Pure git porcelain output parsers.
|
/// Pure git porcelain output parsers.
|
||||||
pub mod porcelain;
|
pub mod porcelain;
|
||||||
|
|
||||||
|
pub use io::DirtyFiles;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use path_guard::is_under_root;
|
pub use path_guard::is_under_root;
|
||||||
pub use porcelain::parse_git_status_porcelain;
|
pub use porcelain::parse_git_status_porcelain;
|
||||||
|
|||||||
Reference in New Issue
Block a user