1use 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
18pub const RANGE_SYMBOL_PACKET_DISCLAIMER: &str =
20 "Derived from the diff; proves what changed, not that it works.";
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24pub struct SymbolDiffEntry {
25 pub name: String,
26 pub kind: String,
27 pub signature_line: Option<String>,
28 pub container_path: String,
30}
31
32#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct RangeFileSymbolDiff {
56 pub path: String,
58 pub change: FileChangeKind,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub renamed_from: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub renamed_to: Option<String>,
65 #[serde(flatten)]
66 pub diff: FileSymbolDiff,
67}
68
69#[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
100pub(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
163pub 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 let line_starts = line_start_offsets(source);
283
284 let mut parsed = BTreeMap::new();
285 for symbol in symbols {
286 let (identity, parsed_symbol) = parsed_symbol(source, &line_starts, symbol);
287 parsed.entry(identity).or_insert(parsed_symbol);
290 }
291 Some(parsed)
292}
293
294fn parsed_symbol(
295 source: &str,
296 line_starts: &[usize],
297 symbol: Symbol,
298) -> (SymbolIdentity, ParsedSymbol) {
299 let outline_entry = symbol_to_entry(&symbol);
300 let container_path = symbol.scope_chain.join(".");
301 let identity = SymbolIdentity {
302 container_path: container_path.clone(),
303 name: outline_entry.name.clone(),
304 kind: outline_entry.kind.clone(),
305 };
306 let entry = SymbolDiffEntry {
307 name: outline_entry.name,
308 kind: outline_entry.kind,
309 signature_line: outline_entry.signature,
310 container_path,
311 };
312 let body = source_bytes_for_symbol(source, line_starts, &symbol);
313 (identity, ParsedSymbol { entry, body })
314}
315
316fn line_start_offsets(source: &str) -> Vec<usize> {
317 let mut offsets = vec![0];
318 offsets.extend(
319 source
320 .bytes()
321 .enumerate()
322 .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
323 );
324 offsets
325}
326
327fn source_bytes_for_symbol(
328 source: &str,
329 line_starts: &[usize],
330 symbol: &Symbol,
331) -> Option<Vec<u8>> {
332 let start = byte_offset_at(
333 source,
334 line_starts,
335 symbol.range.start_line,
336 symbol.range.start_col,
337 )?;
338 let end = byte_offset_at(
339 source,
340 line_starts,
341 symbol.range.end_line,
342 symbol.range.end_col,
343 )?;
344 (start <= end).then(|| source.as_bytes()[start..end].to_vec())
345}
346
347fn byte_offset_at(source: &str, line_starts: &[usize], line: u32, column: u32) -> Option<usize> {
348 let line_start = *line_starts.get(line as usize)?;
349 let offset = line_start.checked_add(column as usize)?;
350 (offset <= source.len()).then_some(offset)
351}
352
353fn symbol_changed(old: &ParsedSymbol, new: &ParsedSymbol) -> bool {
354 old.body != new.body || old.entry.signature_line != new.entry.signature_line
355}
356
357fn line_count(source: &[u8]) -> usize {
358 if source.is_empty() {
359 return 0;
360 }
361 source.iter().filter(|byte| **byte == b'\n').count() + usize::from(!source.ends_with(b"\n"))
362}
363
364fn line_count_delta(old_line_count: usize, new_line_count: usize) -> i64 {
365 let delta = new_line_count as i128 - old_line_count as i128;
366 delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64
367}
368
369fn insert_file_entry(
370 files: &mut BTreeMap<Vec<u8>, RangeFileSymbolDiff>,
371 _repo_root: &Path,
372 path_bytes: &[u8],
373 old: Option<Vec<u8>>,
374 new: Option<Vec<u8>>,
375 change: FileChangeKind,
376 renamed_from: Option<String>,
377 renamed_to: Option<String>,
378) {
379 let path = path_from_git_bytes(path_bytes);
380 let diff = match detect_language(&path) {
381 Some(lang) => symbol_diff_file(lang, &path, old.as_deref(), new.as_deref()),
382 None => unavailable_file_diff(
383 line_count(old.as_deref().unwrap_or_default()),
384 line_count(new.as_deref().unwrap_or_default()),
385 ),
386 };
387 files.insert(
388 path_bytes.to_vec(),
389 RangeFileSymbolDiff {
390 path: git_path_display(path_bytes),
391 change,
392 renamed_from,
393 renamed_to,
394 diff,
395 },
396 );
397}
398
399fn git_name_status(repo_root: &Path, base_sha: &str, tip_sha: &str) -> Option<Vec<GitFileChange>> {
400 let range = format!("{base_sha}..{tip_sha}");
401 let output = crate::effective_path::new_command("git")
402 .arg("-C")
403 .arg(repo_root)
404 .args([
405 "-c",
406 "core.quotepath=false",
407 "diff",
408 "--name-status",
409 "-z",
410 "-M",
411 "--no-ext-diff",
412 &range,
413 ])
414 .output()
415 .ok()?;
416 output
417 .status
418 .success()
419 .then(|| parse_name_status(&output.stdout))
420}
421
422fn parse_name_status(output: &[u8]) -> Vec<GitFileChange> {
423 let fields = output
424 .split(|byte| *byte == 0)
425 .filter(|field| !field.is_empty())
426 .collect::<Vec<_>>();
427 let mut changes = Vec::new();
428 let mut index = 0;
429
430 while let Some(status) = fields.get(index) {
431 index += 1;
432 let kind = status.first().copied();
433 match kind {
434 Some(b'R') => {
435 let (Some(old), Some(new)) = (fields.get(index), fields.get(index + 1)) else {
436 break;
437 };
438 changes.push(GitFileChange::Renamed {
439 old: (*old).to_vec(),
440 new: (*new).to_vec(),
441 });
442 index += 2;
443 }
444 Some(b'C') => {
445 let (Some(old), Some(new)) = (fields.get(index), fields.get(index + 1)) else {
446 break;
447 };
448 changes.push(GitFileChange::Copied {
449 old: (*old).to_vec(),
450 new: (*new).to_vec(),
451 });
452 index += 2;
453 }
454 Some(kind) => {
455 let Some(path) = fields.get(index) else {
456 break;
457 };
458 let change = match kind {
459 b'A' => GitFileChange::Added((*path).to_vec()),
460 b'D' => GitFileChange::Removed((*path).to_vec()),
461 _ => GitFileChange::Modified((*path).to_vec()),
462 };
463 changes.push(change);
464 index += 1;
465 }
466 None => break,
467 }
468 }
469
470 changes
471}
472
473fn read_git_blob(repo_root: &Path, sha: &str, path_bytes: &[u8]) -> Option<Vec<u8>> {
474 let object = git_object_spec(sha, path_bytes);
475 let output = crate::effective_path::new_command("git")
476 .arg("-C")
477 .arg(repo_root)
478 .args(["-c", "core.quotepath=false", "show", "--no-textconv"])
479 .arg(object)
480 .output()
481 .ok()?;
482 output.status.success().then_some(output.stdout)
483}
484
485fn git_path_display(path_bytes: &[u8]) -> String {
486 String::from_utf8_lossy(path_bytes).replace('\\', "/")
487}
488
489fn path_from_git_bytes(bytes: &[u8]) -> PathBuf {
490 #[cfg(unix)]
491 {
492 use std::os::unix::ffi::OsStringExt;
493
494 PathBuf::from(OsString::from_vec(bytes.to_vec()))
495 }
496
497 #[cfg(not(unix))]
498 {
499 PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
500 }
501}
502
503fn git_object_spec(sha: &str, path_bytes: &[u8]) -> OsString {
504 #[cfg(unix)]
505 {
506 use std::os::unix::ffi::OsStringExt;
507
508 let mut bytes = sha.as_bytes().to_vec();
509 bytes.push(b':');
510 bytes.extend_from_slice(path_bytes);
511 OsString::from_vec(bytes)
512 }
513
514 #[cfg(not(unix))]
515 {
516 OsString::from(format!("{sha}:{}", String::from_utf8_lossy(path_bytes)))
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use std::fs;
523 use std::path::Path;
524 use std::process::Command;
525 use std::time::{Duration, SystemTime};
526
527 use filetime::FileTime;
528 use tempfile::TempDir;
529
530 use super::*;
531
532 const RUST_BASE: &str = r#"
533pub fn unchanged() -> i32 { 0 }
534pub fn body_changed() -> i32 { 1 }
535pub fn signature_changed(value: i32) -> i32 { value }
536pub fn removed() -> i32 { 4 }
537"#;
538 const RUST_TIP: &str = r#"
539pub fn unchanged() -> i32 { 0 }
540pub fn body_changed() -> i32 { 2 }
541pub fn signature_changed(value: i64) -> i64 { value }
542pub fn added() -> i32 { 3 }
543"#;
544 const TS_BASE: &str = r#"
545export function unchanged(): number { return 0; }
546export function bodyChanged(): number { return 1; }
547export function signatureChanged(value: number): number { return value; }
548export function removed(): number { return 4; }
549"#;
550 const TS_TIP: &str = r#"
551export function unchanged(): number { return 0; }
552export function bodyChanged(): number { return 2; }
553export function signatureChanged(value: string): string { return value; }
554export function added(): number { return 3; }
555"#;
556
557 #[test]
558 fn rust_classifies_added_removed_and_both_kinds_of_modification() {
559 let diff = symbol_diff_file(
560 LangId::Rust,
561 Path::new("multi.rs"),
562 Some(RUST_BASE.as_bytes()),
563 Some(RUST_TIP.as_bytes()),
564 );
565
566 assert!(!diff.symbols_unavailable);
567 assert_eq!(entry_names(&diff.added), ["added"]);
568 assert_eq!(entry_names(&diff.removed), ["removed"]);
569 assert_eq!(
570 entry_names(&diff.modified),
571 ["body_changed", "signature_changed"]
572 );
573 assert_eq!(
574 diff.modified[1].signature_line.as_deref(),
575 Some("pub fn signature_changed(value: i64) -> i64 { value }")
576 );
577 }
578
579 #[test]
580 fn typescript_classifies_added_removed_and_both_kinds_of_modification() {
581 let diff = symbol_diff_file(
582 LangId::TypeScript,
583 Path::new("multi.ts"),
584 Some(TS_BASE.as_bytes()),
585 Some(TS_TIP.as_bytes()),
586 );
587
588 assert!(!diff.symbols_unavailable);
589 assert_eq!(entry_names(&diff.added), ["added"]);
590 assert_eq!(entry_names(&diff.removed), ["removed"]);
591 assert_eq!(
592 entry_names(&diff.modified),
593 ["bodyChanged", "signatureChanged"]
594 );
595 assert_eq!(
596 diff.modified[1].signature_line.as_deref(),
597 Some("function signatureChanged(value: string): string { return value; }")
598 );
599 }
600
601 #[test]
602 fn indexed_byte_offsets_match_the_scanning_contract() {
603 let sources = ["", "a", "a\n", "alpha\nbeta", "α\nβ\r\nlast"];
604 let columns = [0, 1, 2, 3, 8, 32];
605
606 for source in sources {
607 let line_starts = line_start_offsets(source);
608 let last_line = source.bytes().filter(|byte| *byte == b'\n').count() as u32;
609 for line in 0..=last_line + 2 {
610 for column in columns {
611 assert_eq!(
612 byte_offset_at(source, &line_starts, line, column),
613 scanning_byte_offset_at(source, line, column),
614 "source={source:?}, line={line}, column={column}"
615 );
616 }
617 }
618 }
619 }
620
621 #[test]
622 fn json_returns_an_honest_line_count_only_fallback() {
623 let diff = symbol_diff_file(
624 LangId::Json,
625 Path::new("data.json"),
626 Some(b"{\"one\": 1}\n"),
627 Some(b"{\"one\": 1}\n{\"two\": 2}\n"),
628 );
629
630 assert!(diff.symbols_unavailable);
631 assert_eq!(diff.old_line_count, 1);
632 assert_eq!(diff.new_line_count, 2);
633 assert_eq!(diff.line_count_delta, 1);
634 assert!(diff.added.is_empty());
635 assert!(diff.removed.is_empty());
636 assert!(diff.modified.is_empty());
637 }
638
639 #[test]
640 fn range_is_stable_across_renders_and_worktree_mtime_changes() {
641 let _git_env = crate::test_env::hermetic_git_env_guard();
642 let (temp, base, tip) = committed_range("src/lib.rs", RUST_BASE, RUST_TIP);
643 let first = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
644 .expect("serialize first packet");
645 let second = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
646 .expect("serialize second packet");
647 assert_eq!(first, second);
648
649 let source = temp.path().join("src/lib.rs");
650 filetime::set_file_mtime(
651 &source,
652 FileTime::from_system_time(SystemTime::now() + Duration::from_secs(60)),
653 )
654 .expect("change worktree mtime");
655 let third = serde_json::to_vec(&symbol_diff_range(temp.path(), &base, &tip))
656 .expect("serialize third packet");
657 assert_eq!(first, third);
658 }
659
660 #[test]
661 fn range_uses_nul_delimited_unicode_paths_and_expands_renames() {
662 let _git_env = crate::test_env::hermetic_git_env_guard();
663 let temp = init_git_fixture();
664 let old_name = "src/before ü name.rs";
665 let new_name = "src/after ü name.rs";
666 write_file(temp.path(), old_name, "pub fn renamed() {}\n");
667 let base = commit_all(temp.path(), "base");
668 fs::rename(temp.path().join(old_name), temp.path().join(new_name)).expect("rename fixture");
669 let tip = commit_all(temp.path(), "rename");
670
671 let packet = symbol_diff_range(temp.path(), &base, &tip);
672 assert_eq!(packet.files.len(), 2);
673 assert_eq!(packet.files[0].path, new_name);
674 assert_eq!(packet.files[0].change, FileChangeKind::Added);
675 assert_eq!(packet.files[0].renamed_from.as_deref(), Some(old_name));
676 assert_eq!(entry_names(&packet.files[0].diff.added), ["renamed"]);
677 assert_eq!(packet.files[1].path, old_name);
678 assert_eq!(packet.files[1].change, FileChangeKind::Removed);
679 assert_eq!(packet.files[1].renamed_to.as_deref(), Some(new_name));
680 assert_eq!(entry_names(&packet.files[1].diff.removed), ["renamed"]);
681 }
682
683 #[test]
684 fn range_packet_disclaimer_is_byte_exact_and_bare_repositories_work() {
685 let _git_env = crate::test_env::hermetic_git_env_guard();
686 let (temp, base, tip) = committed_range("src/lib.rs", RUST_BASE, RUST_TIP);
687 let bare = tempfile::tempdir().expect("create bare parent");
688 let bare_repo = bare.path().join("range.git");
689 let mut command = Command::new("git");
690 let status = crate::test_env::apply_hermetic_git_env(command.current_dir(temp.path()))
691 .args(["clone", "--bare", "."])
692 .arg(&bare_repo)
693 .status()
694 .expect("clone bare fixture");
695 assert!(status.success(), "clone bare fixture failed");
696 let packet = symbol_diff_range(&bare_repo, &base, &tip);
697 let bytes = serde_json::to_vec(&packet).expect("serialize packet");
698
699 assert_eq!(packet.disclaimer, RANGE_SYMBOL_PACKET_DISCLAIMER);
700 assert!(bytes
701 .windows(RANGE_SYMBOL_PACKET_DISCLAIMER.len())
702 .any(|window| window == RANGE_SYMBOL_PACKET_DISCLAIMER.as_bytes()));
703 assert!(!String::from_utf8(bytes)
704 .expect("packet is UTF-8 JSON")
705 .contains(&temp.path().display().to_string()));
706 }
707
708 fn entry_names(entries: &[SymbolDiffEntry]) -> Vec<&str> {
709 entries.iter().map(|entry| entry.name.as_str()).collect()
710 }
711
712 fn scanning_byte_offset_at(source: &str, line: u32, column: u32) -> Option<usize> {
713 let bytes = source.as_bytes();
714 let mut line_start = 0;
715 for _ in 0..line {
716 let newline = bytes[line_start..].iter().position(|byte| *byte == b'\n')?;
717 line_start = line_start.checked_add(newline + 1)?;
718 }
719 let offset = line_start.checked_add(column as usize)?;
720 (offset <= bytes.len()).then_some(offset)
721 }
722
723 fn committed_range(
724 path: &str,
725 base_source: &str,
726 tip_source: &str,
727 ) -> (TempDir, String, String) {
728 let temp = init_git_fixture();
729 write_file(temp.path(), path, base_source);
730 let base = commit_all(temp.path(), "base");
731 write_file(temp.path(), path, tip_source);
732 let tip = commit_all(temp.path(), "tip");
733 (temp, base, tip)
734 }
735
736 fn init_git_fixture() -> TempDir {
737 let temp = tempfile::tempdir().expect("create git fixture");
738 run_git(temp.path(), ["init"].as_slice());
739 run_git(
740 temp.path(),
741 ["config", "user.email", "test@example.com"].as_slice(),
742 );
743 run_git(temp.path(), ["config", "user.name", "AFT Test"].as_slice());
744 temp
745 }
746
747 fn write_file(root: &Path, relative_path: &str, content: &str) {
748 let path = root.join(relative_path);
749 fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
750 fs::write(path, content).expect("write fixture");
751 }
752
753 fn commit_all(root: &Path, message: &str) -> String {
754 run_git(root, ["add", "."].as_slice());
755 run_git(root, ["commit", "-m", message].as_slice());
756 run_git(root, ["rev-parse", "HEAD"].as_slice())
757 }
758
759 fn run_git(root: &Path, args: &[&str]) -> String {
760 let mut command = Command::new("git");
761 let output = crate::test_env::apply_hermetic_git_env(command.current_dir(root))
762 .args(args)
763 .output()
764 .expect("run git");
765 assert!(
766 output.status.success(),
767 "git {:?} failed: {}",
768 args,
769 String::from_utf8_lossy(&output.stderr)
770 );
771 String::from_utf8(output.stdout)
772 .expect("git stdout is UTF-8")
773 .trim()
774 .to_string()
775 }
776}