feat(delivery): record WHICH files a phase touched, not just how many
`capture_phase_diff_at` parsed `git diff --stat` down to three integers and
threw the filenames away. Nothing downstream could name a single file a coding
phase changed: the World can draw a coding station but nothing underneath it,
and an operator reading a mission sees "11 files" with no way to learn which.
A second `--name-status` call now records the paths into the code_diff metadata
and into `names.txt` beside `diffstat.txt`, so raw evidence survives
independently of the JSONB.
Three ways this could have been wrong, each guarded:
- Different revision or excludes from the `--stat` call would make
`files_changed` and the path list describe different diffs, with no way to
tell which lied. A source-walk test pins both to the same `base_sha` and
the same `excludes`.
- Running after `git reset --quiet` would drop newly CREATED files, since
`--intent-to-add` is what makes them visible to diff at all — and the stat
would still count them, so the list would look merely incomplete rather
than wrong. A test asserts the ordering.
- A rename is `R100\told\tnew` — three fields. Taking field two records where
the file USED to be, naming a path nobody can open, and the bug is
invisible in any repo where nothing was renamed. `changed_paths` is now
shared with auto_merge (which had the same parse) and takes the NEW path,
with tests for renames and copies.
The list is capped at 500 paths with `files_truncated` beside it: a cap that
silently clips is worse than no cap, because "touched 12 files" and "touched at
least 500" would look identical.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f37c6b92d8
commit
cb8184e784
@@ -71,19 +71,46 @@ impl MergeOutcome {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify a `git diff --name-status` body.
|
/// Every path in a `git diff --name-status` body, with its status letter.
|
||||||
///
|
///
|
||||||
/// Returns the offending entries, empty when every change is an addition.
|
/// The World draws a file orb per changed path, and `mission_delivery` records
|
||||||
/// Split out so the rule is testable without a repository.
|
/// the list — both need the same parse, so it lives in one place.
|
||||||
pub fn non_additive_changes(name_status: &str) -> Vec<String> {
|
///
|
||||||
|
/// **Renames are three fields**: `R100\told\tnew`. The path that changed is the
|
||||||
|
/// NEW one; splitting on the first tab and taking field two records where the
|
||||||
|
/// file used to be, which then matches nothing anyone can open. Copies (`C###`)
|
||||||
|
/// have the same shape.
|
||||||
|
pub fn changed_paths(name_status: &str) -> Vec<(char, String)> {
|
||||||
name_status
|
name_status
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|l| !l.trim().is_empty())
|
.filter(|l| !l.trim().is_empty())
|
||||||
.filter(|l| {
|
.filter_map(|l| {
|
||||||
// Status is the first field: A/M/D/R###/C###.
|
let mut fields = l.split('\t');
|
||||||
!matches!(l.chars().next(), Some('A'))
|
let status = fields.next()?.trim();
|
||||||
|
let letter = status.chars().next()?;
|
||||||
|
let first = fields.next()?.trim();
|
||||||
|
// R/C carry old THEN new; everything else has a single path.
|
||||||
|
let path = match letter {
|
||||||
|
'R' | 'C' => fields.next().map(str::trim).unwrap_or(first),
|
||||||
|
_ => first,
|
||||||
|
};
|
||||||
|
if path.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((letter, path.to_string()))
|
||||||
})
|
})
|
||||||
.map(|l| l.trim().to_string())
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a `git diff --name-status` body.
|
||||||
|
///
|
||||||
|
/// Returns the offending entries, empty when every change is an addition.
|
||||||
|
/// Built on `changed_paths` so the two cannot disagree about what a line means.
|
||||||
|
pub fn non_additive_changes(name_status: &str) -> Vec<String> {
|
||||||
|
changed_paths(name_status)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(letter, _)| *letter != 'A')
|
||||||
|
.map(|(letter, path)| format!("{letter}\t{path}"))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +394,39 @@ mod tests {
|
|||||||
assert_eq!(non_additive_changes("R100\ta.md\tb.md\n").len(), 1);
|
assert_eq!(non_additive_changes("R100\ta.md\tb.md\n").len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A rename records the NEW path.
|
||||||
|
///
|
||||||
|
/// `R100\told\tnew` is three fields. Reading field two — which is what a
|
||||||
|
/// split-on-first-tab gives you — records where the file USED to be, so the
|
||||||
|
/// World would draw an orb for a path that no longer exists and the
|
||||||
|
/// delivered file list would name something nobody can open. The bug is
|
||||||
|
/// invisible in any repo where nothing was renamed.
|
||||||
|
#[test]
|
||||||
|
fn a_rename_records_where_the_file_ended_up() {
|
||||||
|
let paths = changed_paths("R100\tsrc/old.rs\tsrc/new.rs\n");
|
||||||
|
assert_eq!(paths, vec![('R', "src/new.rs".to_string())]);
|
||||||
|
|
||||||
|
let copied = changed_paths("C075\tsrc/a.rs\tsrc/b.rs\n");
|
||||||
|
assert_eq!(copied, vec![('C', "src/b.rs".to_string())]);
|
||||||
|
|
||||||
|
// Ordinary two-field lines are unaffected.
|
||||||
|
assert_eq!(
|
||||||
|
changed_paths("A\tone.md\nM\ttwo.md\nD\tthree.md\n"),
|
||||||
|
vec![
|
||||||
|
('A', "one.md".to_string()),
|
||||||
|
('M', "two.md".to_string()),
|
||||||
|
('D', "three.md".to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `files_changed` and the path list must agree, or nobody can tell which
|
||||||
|
/// one lied. git counts a rename as ONE changed file; so must we.
|
||||||
|
#[test]
|
||||||
|
fn a_rename_counts_once() {
|
||||||
|
assert_eq!(changed_paths("R100\ta.rs\tb.rs\n").len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_policy_never_grants_auto_merge() {
|
fn an_unknown_policy_never_grants_auto_merge() {
|
||||||
assert_eq!(MergePolicy::parse(None), MergePolicy::Never);
|
assert_eq!(MergePolicy::parse(None), MergePolicy::Never);
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ pub(crate) const EXCLUDED_PATHS: &[&str] = &[
|
|||||||
/// generated or vendored got committed), and the head of it is what an
|
/// generated or vendored got committed), and the head of it is what an
|
||||||
/// operator needs to see to work out what happened.
|
/// operator needs to see to work out what happened.
|
||||||
const MAX_PATCH_BYTES: usize = 4 * 1024 * 1024;
|
const MAX_PATCH_BYTES: usize = 4 * 1024 * 1024;
|
||||||
|
/// Cap on the recorded path list. A cap that silently truncates is worse than
|
||||||
|
/// no cap, so the metadata carries `files_truncated` beside it — a reader must
|
||||||
|
/// be able to tell "touched 12 files" from "touched at least 500".
|
||||||
|
const MAX_CAPTURED_PATHS: usize = 500;
|
||||||
|
|
||||||
/// Who delivery commits as.
|
/// Who delivery commits as.
|
||||||
///
|
///
|
||||||
@@ -121,6 +125,13 @@ pub struct Capture {
|
|||||||
/// callers deciding anything on the strength of "no changes" must not.
|
/// callers deciding anything on the strength of "no changes" must not.
|
||||||
pub diff_error: Option<String>,
|
pub diff_error: Option<String>,
|
||||||
pub truncated: bool,
|
pub truncated: bool,
|
||||||
|
/// The paths this phase touched, with their `--name-status` letter. The
|
||||||
|
/// diffstat gives counts only; this is what lets anything downstream say
|
||||||
|
/// WHICH files changed.
|
||||||
|
pub files: Vec<(char, String)>,
|
||||||
|
/// The path list hit `MAX_CAPTURED_PATHS`. Recorded so a reader can tell a
|
||||||
|
/// complete list from a clipped one.
|
||||||
|
pub files_truncated: bool,
|
||||||
pub patch_path: PathBuf,
|
pub patch_path: PathBuf,
|
||||||
/// Set once the work has been committed to a mission branch.
|
/// Set once the work has been committed to a mission branch.
|
||||||
pub committed: Option<Commit>,
|
pub committed: Option<Commit>,
|
||||||
@@ -274,11 +285,38 @@ pub async fn capture_phase_diff_at(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The paths themselves, not just the counts.
|
||||||
|
//
|
||||||
|
// The diffstat gives three integers and throws the filenames away, so
|
||||||
|
// nothing downstream could say WHICH files a phase touched — the World
|
||||||
|
// could draw a "coding" station but nothing under it. Same `base_sha` and
|
||||||
|
// the same excludes as the `--stat` call above: if the two disagreed,
|
||||||
|
// `files_changed` and this list would contradict each other and nobody
|
||||||
|
// could tell which one lied.
|
||||||
|
//
|
||||||
|
// Must run BEFORE the reset below — `--intent-to-add` is what makes newly
|
||||||
|
// created files visible to diff at all.
|
||||||
|
let mut name_args = vec!["diff", base_sha.as_str(), "--name-status", "--"];
|
||||||
|
name_args.extend(excludes.iter().map(String::as_str));
|
||||||
|
let name_status = match git(&repo, &name_args).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
note_diff_failure("name-status", e);
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Put the index back. `--intent-to-add` is a mutation of the agent's
|
// Put the index back. `--intent-to-add` is a mutation of the agent's
|
||||||
// workspace, and capture must not change what a later commit would see.
|
// workspace, and capture must not change what a later commit would see.
|
||||||
let _ = git(&repo, &["reset", "--quiet"]).await;
|
let _ = git(&repo, &["reset", "--quiet"]).await;
|
||||||
|
|
||||||
let (files_changed, insertions, deletions) = parse_diffstat(&diffstat);
|
let (files_changed, insertions, deletions) = parse_diffstat(&diffstat);
|
||||||
|
// Shared with auto_merge so the two cannot disagree about what a
|
||||||
|
// `--name-status` line means (renames are three fields; the NEW path is the
|
||||||
|
// one that changed).
|
||||||
|
let all_paths = crate::auto_merge::changed_paths(&name_status);
|
||||||
|
let files_truncated = all_paths.len() > MAX_CAPTURED_PATHS;
|
||||||
|
let files: Vec<(char, String)> = all_paths.into_iter().take(MAX_CAPTURED_PATHS).collect();
|
||||||
let empty = patch.trim().is_empty();
|
let empty = patch.trim().is_empty();
|
||||||
let truncated = patch.len() > MAX_PATCH_BYTES;
|
let truncated = patch.len() > MAX_PATCH_BYTES;
|
||||||
let stored = if truncated {
|
let stored = if truncated {
|
||||||
@@ -297,6 +335,9 @@ pub async fn capture_phase_diff_at(
|
|||||||
let patch_path = dir.join("diff.patch");
|
let patch_path = dir.join("diff.patch");
|
||||||
std::fs::write(&patch_path, &stored)
|
std::fs::write(&patch_path, &stored)
|
||||||
.map_err(|e| format!("write {}: {e}", patch_path.display()))?;
|
.map_err(|e| format!("write {}: {e}", patch_path.display()))?;
|
||||||
|
// Raw evidence on disk, independent of the JSONB. When the metadata and
|
||||||
|
// the picture disagree, this is the tiebreaker.
|
||||||
|
let _ = std::fs::write(dir.join("names.txt"), &name_status);
|
||||||
std::fs::write(dir.join("diffstat.txt"), &diffstat)
|
std::fs::write(dir.join("diffstat.txt"), &diffstat)
|
||||||
.map_err(|e| format!("write diffstat: {e}"))?;
|
.map_err(|e| format!("write diffstat: {e}"))?;
|
||||||
|
|
||||||
@@ -457,6 +498,13 @@ pub async fn capture_phase_diff_at(
|
|||||||
// did nothing" must check this first.
|
// did nothing" must check this first.
|
||||||
"diff_error": diff_error,
|
"diff_error": diff_error,
|
||||||
"truncated": truncated,
|
"truncated": truncated,
|
||||||
|
// WHICH files, not just how many. Same base_sha and the same excludes
|
||||||
|
// as `files_changed`, so the two describe the same diff.
|
||||||
|
"files": files
|
||||||
|
.iter()
|
||||||
|
.map(|(st, path)| serde_json::json!({ "status": st.to_string(), "path": path }))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
"files_truncated": files_truncated,
|
||||||
"excluded_paths": EXCLUDED_PATHS,
|
"excluded_paths": EXCLUDED_PATHS,
|
||||||
});
|
});
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
@@ -508,6 +556,8 @@ pub async fn capture_phase_diff_at(
|
|||||||
empty,
|
empty,
|
||||||
diff_error,
|
diff_error,
|
||||||
truncated,
|
truncated,
|
||||||
|
files,
|
||||||
|
files_truncated,
|
||||||
patch_path,
|
patch_path,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -1147,6 +1197,51 @@ fn untrusted_empty_reason(empty: bool, diff_error: Option<&str>) -> Option<Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod changed_path_capture_tests {
|
||||||
|
/// The path list and `files_changed` must describe the SAME diff.
|
||||||
|
///
|
||||||
|
/// They come from two separate git invocations — `--stat` and
|
||||||
|
/// `--name-status`. If those are ever given different revisions or
|
||||||
|
/// different exclude pathspecs, the count and the list disagree and there
|
||||||
|
/// is no way to tell which is right: both look like plausible output.
|
||||||
|
#[test]
|
||||||
|
fn both_diff_calls_use_the_same_revision_and_excludes() {
|
||||||
|
let src = include_str!("mission_delivery.rs");
|
||||||
|
let body = src
|
||||||
|
.split("let mut stat_args")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split("let (files_changed").next())
|
||||||
|
.expect("the capture block");
|
||||||
|
assert!(
|
||||||
|
body.contains("let mut name_args = vec![\"diff\", base_sha.as_str(), \"--name-status\", \"--\"]"),
|
||||||
|
"the name-status call must use the same base_sha as --stat"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("name_args.extend(excludes.iter().map(String::as_str))"),
|
||||||
|
"and the same excludes, or files_changed and the path list describe \
|
||||||
|
different diffs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--name-status` must run before the index is put back, or newly created
|
||||||
|
/// files — which `--intent-to-add` is what makes visible — vanish from the
|
||||||
|
/// list while still being counted by the stat.
|
||||||
|
#[test]
|
||||||
|
fn paths_are_read_before_the_index_reset() {
|
||||||
|
let src = include_str!("mission_delivery.rs");
|
||||||
|
let name_at = src.find("--name-status").expect("name-status call");
|
||||||
|
let reset_at = src
|
||||||
|
.find("git(&repo, &[\"reset\", \"--quiet\"])")
|
||||||
|
.expect("index reset");
|
||||||
|
assert!(
|
||||||
|
name_at < reset_at,
|
||||||
|
"the path list must be captured while --intent-to-add is still in \
|
||||||
|
effect, or created files are invisible to it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user