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.
|
||||
/// 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);
|
||||
|
||||
Reference in New Issue
Block a user