Skip to main content

aft/
symbol_diff.rs

1//! Deterministic symbol-level summaries for Git revision ranges.
2//!
3//! This module reads blobs directly from Git so delivery-review packets describe
4//! committed revisions rather than the caller's working tree.
5
6use std::collections::BTreeMap;
7use std::ffi::OsString;
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11
12use crate::commands::outline::symbol_to_entry;
13use crate::parser::{
14    detect_language, extract_symbols_from_tree, parse_source_with_cached_parser, LangId,
15};
16use crate::symbols::Symbol;
17
18/// Fixed statement carried by every range packet.
19pub const RANGE_SYMBOL_PACKET_DISCLAIMER: &str =
20    "Derived from the diff; proves what changed, not that it works.";
21
22/// One changed symbol rendered with the fields used by the outline machinery.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24pub struct SymbolDiffEntry {
25    pub name: String,
26    pub kind: String,
27    pub signature_line: Option<String>,
28    /// Dot-separated outline scope, such as `Trait for Type` or `Outer.Inner`.
29    pub container_path: String,
30}
31
32/// Symbol and line-count changes for one file.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct FileSymbolDiff {
35    pub symbols_unavailable: bool,
36    pub old_line_count: usize,
37    pub new_line_count: usize,
38    pub line_count_delta: i64,
39    pub added: Vec<SymbolDiffEntry>,
40    pub removed: Vec<SymbolDiffEntry>,
41    pub modified: Vec<SymbolDiffEntry>,
42}
43
44/// The file-level change represented by a range packet entry.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "snake_case")]
47pub enum FileChangeKind {
48    Added,
49    Removed,
50    Modified,
51}
52
53/// A root-relative file entry in a range packet.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct RangeFileSymbolDiff {
56    /// Git's root-relative pathname, normalized to forward slashes for display.
57    pub path: String,
58    pub change: FileChangeKind,
59    /// Set on the destination half of a rename; the packet represents it as an added file.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub renamed_from: Option<String>,
62    /// Set on the source half of a rename; the packet represents it as a removed file.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub renamed_to: Option<String>,
65    #[serde(flatten)]
66    pub diff: FileSymbolDiff,
67}
68
69/// A deterministic delivery-review packet for one Git revision range.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71pub struct RangeSymbolPacket {
72    pub base_sha: String,
73    pub tip_sha: String,
74    pub files: Vec<RangeFileSymbolDiff>,
75    pub disclaimer: &'static str,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
79struct SymbolIdentity {
80    container_path: String,
81    name: String,
82    kind: String,
83}
84
85#[derive(Debug, Clone)]
86struct ParsedSymbol {
87    entry: SymbolDiffEntry,
88    body: Option<Vec<u8>>,
89}
90
91#[derive(Debug)]
92enum GitFileChange {
93    Added(Vec<u8>),
94    Removed(Vec<u8>),
95    Modified(Vec<u8>),
96    Renamed { old: Vec<u8>, new: Vec<u8> },
97    Copied { old: Vec<u8>, new: Vec<u8> },
98}
99
100/// Compare two file blobs using the shared tree-sitter parser and symbol extractors.
101///
102/// JSON and YAML are deliberately treated as data files here even though the outline
103/// command can offer lightweight summaries for them. A delivery packet must not imply
104/// source-symbol coverage where there is no code-symbol contract to review.
105pub(crate) fn symbol_diff_file(
106    lang: LangId,
107    path: &Path,
108    old: Option<&[u8]>,
109    new: Option<&[u8]>,
110) -> FileSymbolDiff {
111    let old_line_count = line_count(old.unwrap_or_default());
112    let new_line_count = line_count(new.unwrap_or_default());
113
114    if !supports_symbol_diff(lang) {
115        return unavailable_file_diff(old_line_count, new_line_count);
116    }
117
118    let old_by_identity = match old {
119        Some(source) => match parse_symbols(path, source, lang) {
120            Some(symbols) => symbols,
121            None => return unavailable_file_diff(old_line_count, new_line_count),
122        },
123        None => BTreeMap::new(),
124    };
125    let new_by_identity = match new {
126        Some(source) => match parse_symbols(path, source, lang) {
127            Some(symbols) => symbols,
128            None => return unavailable_file_diff(old_line_count, new_line_count),
129        },
130        None => BTreeMap::new(),
131    };
132
133    let mut old_by_identity = old_by_identity;
134    let mut added = Vec::new();
135    let mut modified = Vec::new();
136
137    for (identity, new_symbol) in new_by_identity {
138        match old_by_identity.remove(&identity) {
139            None => added.push(new_symbol.entry),
140            Some(old_symbol) if symbol_changed(&old_symbol, &new_symbol) => {
141                modified.push(new_symbol.entry)
142            }
143            Some(_) => {}
144        }
145    }
146
147    let removed = old_by_identity
148        .into_values()
149        .map(|symbol| symbol.entry)
150        .collect();
151
152    FileSymbolDiff {
153        symbols_unavailable: false,
154        old_line_count,
155        new_line_count,
156        line_count_delta: line_count_delta(old_line_count, new_line_count),
157        added,
158        removed,
159        modified,
160    }
161}
162
163/// Build a deterministic packet for `base_sha..tip_sha` without reading the worktree.
164///
165/// An unreadable Git range produces an empty packet rather than a partial packet. The
166/// caller can distinguish that case from an unchanged valid range by resolving the
167/// requested revisions before calling this low-level, infallible engine entry point.
168pub fn symbol_diff_range(repo_root: &Path, base_sha: &str, tip_sha: &str) -> RangeSymbolPacket {
169    let mut files = BTreeMap::new();
170
171    if let Some(changes) = git_name_status(repo_root, base_sha, tip_sha) {
172        for change in changes {
173            match change {
174                GitFileChange::Added(path) => {
175                    insert_file_entry(
176                        &mut files,
177                        repo_root,
178                        &path,
179                        None,
180                        read_git_blob(repo_root, tip_sha, &path),
181                        FileChangeKind::Added,
182                        None,
183                        None,
184                    );
185                }
186                GitFileChange::Removed(path) => {
187                    insert_file_entry(
188                        &mut files,
189                        repo_root,
190                        &path,
191                        read_git_blob(repo_root, base_sha, &path),
192                        None,
193                        FileChangeKind::Removed,
194                        None,
195                        None,
196                    );
197                }
198                GitFileChange::Modified(path) => {
199                    insert_file_entry(
200                        &mut files,
201                        repo_root,
202                        &path,
203                        read_git_blob(repo_root, base_sha, &path),
204                        read_git_blob(repo_root, tip_sha, &path),
205                        FileChangeKind::Modified,
206                        None,
207                        None,
208                    );
209                }
210                GitFileChange::Renamed { old, new } => {
211                    let old_display = git_path_display(&old);
212                    let new_display = git_path_display(&new);
213                    insert_file_entry(
214                        &mut files,
215                        repo_root,
216                        &old,
217                        read_git_blob(repo_root, base_sha, &old),
218                        None,
219                        FileChangeKind::Removed,
220                        None,
221                        Some(new_display),
222                    );
223                    insert_file_entry(
224                        &mut files,
225                        repo_root,
226                        &new,
227                        None,
228                        read_git_blob(repo_root, tip_sha, &new),
229                        FileChangeKind::Added,
230                        Some(old_display),
231                        None,
232                    );
233                }
234                GitFileChange::Copied { old, new } => {
235                    insert_file_entry(
236                        &mut files,
237                        repo_root,
238                        &new,
239                        None,
240                        read_git_blob(repo_root, tip_sha, &new),
241                        FileChangeKind::Added,
242                        Some(git_path_display(&old)),
243                        None,
244                    );
245                }
246            }
247        }
248    }
249
250    RangeSymbolPacket {
251        base_sha: base_sha.to_string(),
252        tip_sha: tip_sha.to_string(),
253        files: files.into_values().collect(),
254        disclaimer: RANGE_SYMBOL_PACKET_DISCLAIMER,
255    }
256}
257
258fn supports_symbol_diff(lang: LangId) -> bool {
259    !matches!(lang, LangId::Json | LangId::Yaml)
260}
261
262fn unavailable_file_diff(old_line_count: usize, new_line_count: usize) -> FileSymbolDiff {
263    FileSymbolDiff {
264        symbols_unavailable: true,
265        old_line_count,
266        new_line_count,
267        line_count_delta: line_count_delta(old_line_count, new_line_count),
268        added: Vec::new(),
269        removed: Vec::new(),
270        modified: Vec::new(),
271    }
272}
273
274fn parse_symbols(
275    path: &Path,
276    source: &[u8],
277    lang: LangId,
278) -> Option<BTreeMap<SymbolIdentity, ParsedSymbol>> {
279    let source = std::str::from_utf8(source).ok()?;
280    let tree = parse_source_with_cached_parser(path, source, lang).ok()?;
281    let symbols = extract_symbols_from_tree(source, &tree, lang).ok()?;
282
283    let mut parsed = BTreeMap::new();
284    for symbol in symbols {
285        let (identity, parsed_symbol) = parsed_symbol(source, symbol);
286        // Extractors already deduplicate outline entries. Keeping the first value makes
287        // an unexpected duplicate deterministic without inventing a new display key.
288        parsed.entry(identity).or_insert(parsed_symbol);
289    }
290    Some(parsed)
291}
292
293fn parsed_symbol(source: &str, symbol: Symbol) -> (SymbolIdentity, ParsedSymbol) {
294    let outline_entry = symbol_to_entry(&symbol);
295    let container_path = symbol.scope_chain.join(".");
296    let identity = SymbolIdentity {
297        container_path: container_path.clone(),
298        name: outline_entry.name.clone(),
299        kind: outline_entry.kind.clone(),
300    };
301    let entry = SymbolDiffEntry {
302        name: outline_entry.name,
303        kind: outline_entry.kind,
304        signature_line: outline_entry.signature,
305        container_path,
306    };
307    let body = source_bytes_for_symbol(source, &symbol);
308    (identity, ParsedSymbol { entry, body })
309}
310
311fn source_bytes_for_symbol(source: &str, symbol: &Symbol) -> Option<Vec<u8>> {
312    let start = byte_offset_at(source, symbol.range.start_line, symbol.range.start_col)?;
313    let end = byte_offset_at(source, symbol.range.end_line, symbol.range.end_col)?;
314    (start <= end).then(|| source.as_bytes()[start..end].to_vec())
315}
316
317fn byte_offset_at(source: &str, line: u32, column: u32) -> Option<usize> {
318    let bytes = source.as_bytes();
319    let mut line_start = 0;
320    for _ in 0..line {
321        let newline = bytes[line_start..].iter().position(|byte| *byte == b'\n')?;
322        line_start = line_start.checked_add(newline + 1)?;
323    }
324    let offset = line_start.checked_add(column as usize)?;
325    (offset <= bytes.len()).then_some(offset)
326}
327
328fn symbol_changed(old: &ParsedSymbol, new: &ParsedSymbol) -> bool {
329    old.body != new.body || old.entry.signature_line != new.entry.signature_line
330}
331
332fn line_count(source: &[u8]) -> usize {
333    if source.is_empty() {
334        return 0;
335    }
336    source.iter().filter(|byte| **byte == b'\n').count() + usize::from(!source.ends_with(b"\n"))
337}
338
339fn line_count_delta(old_line_count: usize, new_line_count: usize) -> i64 {
340    let delta = new_line_count as i128 - old_line_count as i128;
341    delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64
342}
343
344fn insert_file_entry(
345    files: &mut BTreeMap<Vec<u8>, RangeFileSymbolDiff>,
346    _repo_root: &Path,
347    path_bytes: &[u8],
348    old: Option<Vec<u8>>,
349    new: Option<Vec<u8>>,
350    change: FileChangeKind,
351    renamed_from: Option<String>,
352    renamed_to: Option<String>,
353) {
354    let path = path_from_git_bytes(path_bytes);
355    let diff = match detect_language(&path) {
356        Some(lang) => symbol_diff_file(lang, &path, old.as_deref(), new.as_deref()),
357        None => unavailable_file_diff(
358            line_count(old.as_deref().unwrap_or_default()),
359            line_count(new.as_deref().unwrap_or_default()),
360        ),
361    };
362    files.insert(
363        path_bytes.to_vec(),
364        RangeFileSymbolDiff {
365            path: git_path_display(path_bytes),
366            change,
367            renamed_from,
368            renamed_to,
369            diff,
370        },
371    );
372}
373
374fn git_name_status(repo_root: &Path, base_sha: &str, tip_sha: &str) -> Option<Vec<GitFileChange>> {
375    let range = format!("{base_sha}..{tip_sha}");
376    let output = crate::effective_path::new_command("git")
377        .arg("-C")
378        .arg(repo_root)
379        .args([
380            "-c",
381            "core.quotepath=false",
382            "diff",
383            "--name-status",
384            "-z",
385            "-M",
386            "--no-ext-diff",
387            &range,
388        ])
389        .output()
390        .ok()?;
391    output
392        .status
393        .success()
394        .then(|| parse_name_status(&output.stdout))
395}
396
397fn parse_name_status(output: &[u8]) -> Vec<GitFileChange> {
398    let fields = output
399        .split(|byte| *byte == 0)
400        .filter(|field| !field.is_empty())
401        .collect::<Vec<_>>();
402    let mut changes = Vec::new();
403    let mut index = 0;
404
405    while let Some(status) = fields.get(index) {
406        index += 1;
407        let kind = status.first().copied();
408        match kind {
409            Some(b'R') => {
410                let (Some(old), Some(new)) = (fields.get(index), fields.get(index + 1)) else {
411                    break;
412                };
413                changes.push(GitFileChange::Renamed {
414                    old: (*old).to_vec(),
415                    new: (*new).to_vec(),
416                });
417                index += 2;
418            }
419            Some(b'C') => {
420                let (Some(old), Some(new)) = (fields.get(index), fields.get(index + 1)) else {
421                    break;
422                };
423                changes.push(GitFileChange::Copied {
424                    old: (*old).to_vec(),
425                    new: (*new).to_vec(),
426                });
427                index += 2;
428            }
429            Some(kind) => {
430                let Some(path) = fields.get(index) else {
431                    break;
432                };
433                let change = match kind {
434                    b'A' => GitFileChange::Added((*path).to_vec()),
435                    b'D' => GitFileChange::Removed((*path).to_vec()),
436                    _ => GitFileChange::Modified((*path).to_vec()),
437                };
438                changes.push(change);
439                index += 1;
440            }
441            None => break,
442        }
443    }
444
445    changes
446}
447
448fn read_git_blob(repo_root: &Path, sha: &str, path_bytes: &[u8]) -> Option<Vec<u8>> {
449    let object = git_object_spec(sha, path_bytes);
450    let output = crate::effective_path::new_command("git")
451        .arg("-C")
452        .arg(repo_root)
453        .args(["-c", "core.quotepath=false", "show", "--no-textconv"])
454        .arg(object)
455        .output()
456        .ok()?;
457    output.status.success().then_some(output.stdout)
458}
459
460fn git_path_display(path_bytes: &[u8]) -> String {
461    String::from_utf8_lossy(path_bytes).replace('\\', "/")
462}
463
464fn path_from_git_bytes(bytes: &[u8]) -> PathBuf {
465    #[cfg(unix)]
466    {
467        use std::os::unix::ffi::OsStringExt;
468
469        PathBuf::from(OsString::from_vec(bytes.to_vec()))
470    }
471
472    #[cfg(not(unix))]
473    {
474        PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
475    }
476}
477
478fn git_object_spec(sha: &str, path_bytes: &[u8]) -> OsString {
479    #[cfg(unix)]
480    {
481        use std::os::unix::ffi::OsStringExt;
482
483        let mut bytes = sha.as_bytes().to_vec();
484        bytes.push(b':');
485        bytes.extend_from_slice(path_bytes);
486        OsString::from_vec(bytes)
487    }
488
489    #[cfg(not(unix))]
490    {
491        OsString::from(format!("{sha}:{}", String::from_utf8_lossy(path_bytes)))
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use std::fs;
498    use std::path::Path;
499    use std::process::Command;
500    use std::time::{Duration, SystemTime};
501
502    use filetime::FileTime;
503    use tempfile::TempDir;
504
505    use super::*;
506
507    const RUST_BASE: &str = r#"
508pub fn unchanged() -> i32 { 0 }
509pub fn body_changed() -> i32 { 1 }
510pub fn signature_changed(value: i32) -> i32 { value }
511pub fn removed() -> i32 { 4 }
512"#;
513    const RUST_TIP: &str = r#"
514pub fn unchanged() -> i32 { 0 }
515pub fn body_changed() -> i32 { 2 }
516pub fn signature_changed(value: i64) -> i64 { value }
517pub fn added() -> i32 { 3 }
518"#;
519    const TS_BASE: &str = r#"
520export function unchanged(): number { return 0; }
521export function bodyChanged(): number { return 1; }
522export function signatureChanged(value: number): number { return value; }
523export function removed(): number { return 4; }
524"#;
525    const TS_TIP: &str = r#"
526export function unchanged(): number { return 0; }
527export function bodyChanged(): number { return 2; }
528export function signatureChanged(value: string): string { return value; }
529export function added(): number { return 3; }
530"#;
531
532    #[test]
533    fn rust_classifies_added_removed_and_both_kinds_of_modification() {
534        let diff = symbol_diff_file(
535            LangId::Rust,
536            Path::new("multi.rs"),
537            Some(RUST_BASE.as_bytes()),
538            Some(RUST_TIP.as_bytes()),
539        );
540
541        assert!(!diff.symbols_unavailable);
542        assert_eq!(entry_names(&diff.added), ["added"]);
543        assert_eq!(entry_names(&diff.removed), ["removed"]);
544        assert_eq!(
545            entry_names(&diff.modified),
546            ["body_changed", "signature_changed"]
547        );
548        assert_eq!(
549            diff.modified[1].signature_line.as_deref(),
550            Some("pub fn signature_changed(value: i64) -> i64 { value }")
551        );
552    }
553
554    #[test]
555    fn typescript_classifies_added_removed_and_both_kinds_of_modification() {
556        let diff = symbol_diff_file(
557            LangId::TypeScript,
558            Path::new("multi.ts"),
559            Some(TS_BASE.as_bytes()),
560            Some(TS_TIP.as_bytes()),
561        );
562
563        assert!(!diff.symbols_unavailable);
564        assert_eq!(entry_names(&diff.added), ["added"]);
565        assert_eq!(entry_names(&diff.removed), ["removed"]);
566        assert_eq!(
567            entry_names(&diff.modified),
568            ["bodyChanged", "signatureChanged"]
569        );
570        assert_eq!(
571            diff.modified[1].signature_line.as_deref(),
572            Some("function signatureChanged(value: string): string { return value; }")
573        );
574    }
575
576    #[test]
577    fn json_returns_an_honest_line_count_only_fallback() {
578        let diff = symbol_diff_file(
579            LangId::Json,
580            Path::new("data.json"),
581            Some(b"{\"one\": 1}\n"),
582            Some(b"{\"one\": 1}\n{\"two\": 2}\n"),
583        );
584
585        assert!(diff.symbols_unavailable);
586        assert_eq!(diff.old_line_count, 1);
587        assert_eq!(diff.new_line_count, 2);
588        assert_eq!(diff.line_count_delta, 1);
589        assert!(diff.added.is_empty());
590        assert!(diff.removed.is_empty());
591        assert!(diff.modified.is_empty());
592    }
593
594    #[test]
595    fn range_is_stable_across_renders_and_worktree_mtime_changes() {
596        let _git_env = crate::test_env::hermetic_git_env_guard();
597        let (temp, base, tip) = committed_range("src/lib.rs", RUST_BASE, RUST_TIP);
598        let first = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
599            .expect("serialize first packet");
600        let second = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
601            .expect("serialize second packet");
602        assert_eq!(first, second);
603
604        let source = temp.path().join("src/lib.rs");
605        filetime::set_file_mtime(
606            &source,
607            FileTime::from_system_time(SystemTime::now() + Duration::from_secs(60)),
608        )
609        .expect("change worktree mtime");
610        let third = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
611            .expect("serialize third packet");
612        assert_eq!(first, third);
613    }
614
615    #[test]
616    fn range_uses_nul_delimited_unicode_paths_and_expands_renames() {
617        let _git_env = crate::test_env::hermetic_git_env_guard();
618        let temp = init_git_fixture();
619        let old_name = "src/before ü name.rs";
620        let new_name = "src/after ü name.rs";
621        write_file(temp.path(), old_name, "pub fn renamed() {}\n");
622        let base = commit_all(temp.path(), "base");
623        fs::rename(temp.path().join(old_name), temp.path().join(new_name)).expect("rename fixture");
624        let tip = commit_all(temp.path(), "rename");
625
626        let packet = symbol_diff_range(temp.path(), &base, &tip);
627        assert_eq!(packet.files.len(), 2);
628        assert_eq!(packet.files[0].path, new_name);
629        assert_eq!(packet.files[0].change, FileChangeKind::Added);
630        assert_eq!(packet.files[0].renamed_from.as_deref(), Some(old_name));
631        assert_eq!(entry_names(&packet.files[0].diff.added), ["renamed"]);
632        assert_eq!(packet.files[1].path, old_name);
633        assert_eq!(packet.files[1].change, FileChangeKind::Removed);
634        assert_eq!(packet.files[1].renamed_to.as_deref(), Some(new_name));
635        assert_eq!(entry_names(&packet.files[1].diff.removed), ["renamed"]);
636    }
637
638    #[test]
639    fn range_packet_disclaimer_is_byte_exact_and_bare_repositories_work() {
640        let _git_env = crate::test_env::hermetic_git_env_guard();
641        let (temp, base, tip) = committed_range("src/lib.rs", RUST_BASE, RUST_TIP);
642        let bare = tempfile::tempdir().expect("create bare parent");
643        let bare_repo = bare.path().join("range.git");
644        let mut command = Command::new("git");
645        let status = crate::test_env::apply_hermetic_git_env(command.current_dir(temp.path()))
646            .args(["clone", "--bare", "."])
647            .arg(&bare_repo)
648            .status()
649            .expect("clone bare fixture");
650        assert!(status.success(), "clone bare fixture failed");
651        let packet = symbol_diff_range(&bare_repo, &base, &tip);
652        let bytes = serde_json::to_vec(&packet).expect("serialize packet");
653
654        assert_eq!(packet.disclaimer, RANGE_SYMBOL_PACKET_DISCLAIMER);
655        assert!(bytes
656            .windows(RANGE_SYMBOL_PACKET_DISCLAIMER.len())
657            .any(|window| window == RANGE_SYMBOL_PACKET_DISCLAIMER.as_bytes()));
658        assert!(!String::from_utf8(bytes)
659            .expect("packet is UTF-8 JSON")
660            .contains(&temp.path().display().to_string()));
661    }
662
663    fn entry_names(entries: &[SymbolDiffEntry]) -> Vec<&str> {
664        entries.iter().map(|entry| entry.name.as_str()).collect()
665    }
666
667    fn committed_range(
668        path: &str,
669        base_source: &str,
670        tip_source: &str,
671    ) -> (TempDir, String, String) {
672        let temp = init_git_fixture();
673        write_file(temp.path(), path, base_source);
674        let base = commit_all(temp.path(), "base");
675        write_file(temp.path(), path, tip_source);
676        let tip = commit_all(temp.path(), "tip");
677        (temp, base, tip)
678    }
679
680    fn init_git_fixture() -> TempDir {
681        let temp = tempfile::tempdir().expect("create git fixture");
682        run_git(temp.path(), ["init"].as_slice());
683        run_git(
684            temp.path(),
685            ["config", "user.email", "test@example.com"].as_slice(),
686        );
687        run_git(temp.path(), ["config", "user.name", "AFT Test"].as_slice());
688        temp
689    }
690
691    fn write_file(root: &Path, relative_path: &str, content: &str) {
692        let path = root.join(relative_path);
693        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
694        fs::write(path, content).expect("write fixture");
695    }
696
697    fn commit_all(root: &Path, message: &str) -> String {
698        run_git(root, ["add", "."].as_slice());
699        run_git(root, ["commit", "-m", message].as_slice());
700        run_git(root, ["rev-parse", "HEAD"].as_slice())
701    }
702
703    fn run_git(root: &Path, args: &[&str]) -> String {
704        let mut command = Command::new("git");
705        let output = crate::test_env::apply_hermetic_git_env(command.current_dir(root))
706            .args(args)
707            .output()
708            .expect("run git");
709        assert!(
710            output.status.success(),
711            "git {:?} failed: {}",
712            args,
713            String::from_utf8_lossy(&output.stderr)
714        );
715        String::from_utf8(output.stdout)
716            .expect("git stdout is UTF-8")
717            .trim()
718            .to_string()
719    }
720}