69d91d7707
Final 929 sweep: every YAML-shaped helper is gone. No production code
parses or writes YAML front matter anywhere.
Surface removed:
- db/yaml_legacy.rs (FrontMatter/StoryMetadata structs, parse_front_matter,
set_front_matter_field, yaml_residue marker) — file deleted.
- ItemMeta::from_yaml — deleted; callers pass typed ItemMeta::named(...) or
ItemMeta::default() and use typed CRDT setters (set_depends_on,
set_blocked, set_retry_count, set_agent, set_qa_mode, set_review_hold,
set_item_type, set_epic, set_mergemaster_attempted) for the rest.
- write_coverage_baseline_to_story_file + read_coverage_percent_from_json —
the coverage_baseline YAML field was write-only (nothing read it back);
removed along with its caller in agent_tools/lifecycle.rs.
- update_story_in_file's generic `front_matter` HashMap parameter —
tool_update_story now intercepts every known field name and routes it
to a typed CRDT setter; unknown keys are rejected with an explicit error
pointing at the typed setters. The function only takes user_story /
description sections now.
- All 117 ItemMeta::from_yaml callsites migrated. Where tests previously
passed a YAML-shaped content blob and relied on the helper to extract
name/depends_on/blocked/agent/qa, they now pass:
write_item_with_content(id, stage, content, ItemMeta::named("Foo"))
crate::crdt_state::set_depends_on(id, &[...]) // when needed
crate::crdt_state::set_blocked(id, true) // when needed
crate::crdt_state::set_agent(id, Some("...")) // when needed
- write_story_content + write_story_file (test helper) now take an
explicit `name: Option<&str>` instead of parsing it from content.
- db::ops::move_item_stage stopped re-parsing YAML on every stage
transition; metadata is read straight from the CRDT view when mirroring
the row into SQLite.
New CRDT setters added for symmetry:
- crdt_state::set_name (mirrors set_agent — explicit name updates).
cargo fmt --check, clippy --all-targets -- -D warnings, and the
2830-test suite all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
244 lines
7.7 KiB
Rust
244 lines
7.7 KiB
Rust
//! Handler for the `diff` command.
|
|
//!
|
|
//! Shows the git diff from the configured main branch to the story's worktree
|
|
//! HEAD, formatted for readability in chat.
|
|
|
|
use super::CommandContext;
|
|
use crate::chat::util::truncate_at_char_boundary;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
/// Display the git diff from the configured main branch to a story's worktree HEAD.
|
|
///
|
|
/// Usage: `diff <number>`
|
|
pub(super) fn handle_diff(ctx: &CommandContext) -> Option<String> {
|
|
let num_str = ctx.args.trim();
|
|
if num_str.is_empty() {
|
|
return Some(format!(
|
|
"Usage: `{} diff <number>`\n\nShows the git diff from the main branch to the story's worktree HEAD.",
|
|
ctx.services.bot_name
|
|
));
|
|
}
|
|
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
|
return Some(format!(
|
|
"Invalid story number: `{num_str}`. Usage: `{} diff <number>`",
|
|
ctx.services.bot_name
|
|
));
|
|
}
|
|
|
|
let story_id = match find_story_id(num_str) {
|
|
Some(id) => id,
|
|
None => {
|
|
return Some(format!(
|
|
"No story with number **{num_str}** found in the pipeline."
|
|
));
|
|
}
|
|
};
|
|
|
|
let wt_path = crate::worktree::worktree_path(ctx.effective_root(), &story_id);
|
|
if !wt_path.is_dir() {
|
|
return Some(format!(
|
|
"Story **{num_str}** has no worktree. The diff is only available once a coder has started working on it."
|
|
));
|
|
}
|
|
|
|
let base_branch = resolve_base_branch(ctx.effective_root());
|
|
let range = format!("{base_branch}...HEAD");
|
|
|
|
let stat = run_git(&wt_path, &["diff", "--stat", &range]);
|
|
let diff = run_git(&wt_path, &["diff", &range]);
|
|
|
|
let mut out = format!("## Diff — story {num_str} vs `{base_branch}`\n\n");
|
|
|
|
if stat.is_empty() && diff.is_empty() {
|
|
out.push_str("*(no changes relative to main branch)*\n");
|
|
return Some(out);
|
|
}
|
|
|
|
if !stat.is_empty() {
|
|
out.push_str("**Changed files:**\n```\n");
|
|
out.push_str(&stat);
|
|
out.push_str("\n```\n\n");
|
|
}
|
|
|
|
if !diff.is_empty() {
|
|
const MAX_DIFF_BYTES: usize = 8_000;
|
|
if diff.len() > MAX_DIFF_BYTES {
|
|
let truncated = truncate_at_char_boundary(&diff, MAX_DIFF_BYTES);
|
|
out.push_str("**Diff** *(truncated — showing first 8 KB)*:\n```diff\n");
|
|
out.push_str(truncated);
|
|
out.push_str("\n... (truncated)\n```\n");
|
|
} else {
|
|
out.push_str("**Diff:**\n```diff\n");
|
|
out.push_str(&diff);
|
|
out.push_str("\n```\n");
|
|
}
|
|
}
|
|
|
|
Some(out)
|
|
}
|
|
|
|
/// Find the story_id in the pipeline whose numeric prefix matches `num_str`.
|
|
fn find_story_id(num_str: &str) -> Option<String> {
|
|
let items = crate::pipeline_state::read_all_typed();
|
|
items.into_iter().find_map(|item| {
|
|
let file_num = item
|
|
.story_id
|
|
.0
|
|
.split('_')
|
|
.next()
|
|
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
|
.unwrap_or("");
|
|
if file_num == num_str {
|
|
Some(item.story_id.0.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Return the configured base branch, or auto-detect it from the project root HEAD.
|
|
fn resolve_base_branch(project_root: &Path) -> String {
|
|
crate::config::ProjectConfig::load(project_root)
|
|
.ok()
|
|
.and_then(|c| c.base_branch)
|
|
.unwrap_or_else(|| {
|
|
Command::new("git")
|
|
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
.current_dir(project_root)
|
|
.output()
|
|
.ok()
|
|
.filter(|o| o.status.success())
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
.unwrap_or_else(|| "master".to_string())
|
|
})
|
|
}
|
|
|
|
/// Run a git command in `dir`, returning trimmed stdout (empty string on failure).
|
|
fn run_git(dir: &Path, args: &[&str]) -> String {
|
|
Command::new("git")
|
|
.args(args)
|
|
.current_dir(dir)
|
|
.output()
|
|
.ok()
|
|
.filter(|o| o.status.success())
|
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use super::super::{CommandDispatch, try_handle_command};
|
|
|
|
fn diff_cmd(root: &std::path::Path, args: &str) -> Option<String> {
|
|
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
|
|
let room_id = "!test:example.com".to_string();
|
|
let dispatch = CommandDispatch {
|
|
services: &services,
|
|
project_root: &services.project_root,
|
|
bot_user_id: "@timmy:homeserver.local",
|
|
room_id: &room_id,
|
|
};
|
|
try_handle_command(&dispatch, &format!("@timmy diff {args}"))
|
|
}
|
|
|
|
#[test]
|
|
fn diff_command_is_registered() {
|
|
let found = super::super::commands().iter().any(|c| c.name == "diff");
|
|
assert!(found, "diff command must be in the registry");
|
|
}
|
|
|
|
#[test]
|
|
fn diff_command_appears_in_help() {
|
|
let result = super::super::tests::try_cmd_addressed(
|
|
"Timmy",
|
|
"@timmy:homeserver.local",
|
|
"@timmy help",
|
|
);
|
|
let output = result.unwrap();
|
|
assert!(
|
|
output.contains("diff"),
|
|
"help should list diff command: {output}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_command_no_args_returns_usage() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let output = diff_cmd(tmp.path(), "").unwrap();
|
|
assert!(
|
|
output.contains("Usage"),
|
|
"no args should show usage: {output}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_command_non_numeric_returns_error() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let output = diff_cmd(tmp.path(), "abc").unwrap();
|
|
assert!(
|
|
output.contains("Invalid"),
|
|
"non-numeric arg should return error: {output}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_command_story_not_found_returns_friendly_message() {
|
|
crate::db::ensure_content_store();
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let output = diff_cmd(tmp.path(), "99993").unwrap();
|
|
assert!(
|
|
output.contains("99993"),
|
|
"message should include story number: {output}"
|
|
);
|
|
assert!(
|
|
output.contains("found") || output.contains("pipeline"),
|
|
"message should explain not found: {output}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diff_command_no_worktree_returns_clear_error() {
|
|
use crate::chat::test_helpers::write_story_file;
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
write_story_file(
|
|
tmp.path(),
|
|
"2_current",
|
|
"55551_story_no_worktree.md",
|
|
"---\nname: No Worktree\n---\n",
|
|
None,
|
|
);
|
|
let output = diff_cmd(tmp.path(), "55551").unwrap();
|
|
assert!(
|
|
output.contains("worktree")
|
|
|| output.contains("no worktree")
|
|
|| output.contains("Worktree"),
|
|
"should report missing worktree: {output}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_at_char_boundary_short_string() {
|
|
let s = "hello";
|
|
assert_eq!(truncate_at_char_boundary(s, 100), "hello");
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_at_char_boundary_exact_limit() {
|
|
let s = "hello";
|
|
assert_eq!(truncate_at_char_boundary(s, 5), "hello");
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_at_char_boundary_over_limit() {
|
|
let s = "hello world";
|
|
assert_eq!(truncate_at_char_boundary(s, 5), "hello");
|
|
}
|
|
}
|