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:
Omar Sobh
2026-08-10 22:34:39 -07:00
co-authored by Claude Opus 5
parent f37c6b92d8
commit cb8184e784
2 changed files with 163 additions and 8 deletions
+68 -8
View File
@@ -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.
/// Split out so the rule is testable without a repository.
pub fn non_additive_changes(name_status: &str) -> Vec<String> {
/// The World draws a file orb per changed path, and `mission_delivery` records
/// the list — both need the same parse, so it lives in one place.
///
/// **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
.lines()
.filter(|l| !l.trim().is_empty())
.filter(|l| {
// Status is the first field: A/M/D/R###/C###.
!matches!(l.chars().next(), Some('A'))
.filter_map(|l| {
let mut fields = l.split('\t');
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()
}
@@ -367,6 +394,39 @@ mod tests {
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]
fn an_unknown_policy_never_grants_auto_merge() {
assert_eq!(MergePolicy::parse(None), MergePolicy::Never);