1use crate::{GitError, command, command::CatFileBatch, path};
4use clankerdiff_core::{
5 DiffDocument, DiffScope, DiffSide, FileDiff, FileStatus, Fingerprint, RepoPath,
6 RepositoryAction, SourceDocument, SourceResult, SourceUnavailable, UntrackedFile,
7 parse_porcelain_v1_z,
8};
9use std::{
10 collections::{HashMap, HashSet},
11 path::{Path, PathBuf},
12 sync::Arc,
13 time::Duration,
14};
15use tokio::time::sleep;
16
17const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
18const STATUS_ARGS: [&str; 5] = [
19 "--no-optional-locks",
20 "status",
21 "--porcelain=v1",
22 "-z",
23 "--untracked-files=all",
24];
25pub use clankerdiff_core::MAX_SOURCE_FILE_BYTES;
26pub const MAX_SOURCE_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024;
27const MAX_UNTRACKED_SNAPSHOT_BYTES: u64 = MAX_SOURCE_ARCHIVE_BYTES;
28
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct RepositorySnapshot {
33 pub scope: DiffScope,
34 pub document: Arc<DiffDocument>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38enum ContentLocation {
39 Absent,
40 Head(RepoPath),
41 Index(RepoPath),
42 Worktree(RepoPath),
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46enum ResolvedContentLocation {
47 Absent,
48 Blob(String),
49 Worktree(RepoPath),
50 Unavailable(SourceUnavailable),
51}
52
53enum BoundedWorktree {
54 Content(Vec<u8>),
55 TooLarge(u64),
56}
57
58#[derive(Debug, Clone, Copy)]
59enum BlobRecordKind {
60 Tree,
61 Index,
62}
63
64#[derive(Debug)]
65struct SnapshotInput {
66 has_head: bool,
67 diff: Vec<u8>,
68 status: Vec<u8>,
69 document: DiffDocument,
70}
71
72#[derive(Debug)]
73struct CapturedSources {
74 sides: Vec<[SourceResult; 2]>,
76 worktree_ids: HashMap<RepoPath, Fingerprint>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct GitRepository {
82 root: PathBuf,
83}
84
85impl GitRepository {
86 pub async fn apply(&self, action: RepositoryAction) -> Result<(), GitError> {
87 match action {
88 RepositoryAction::StagePaths(paths) => self.stage(&paths).await,
89 RepositoryAction::UnstagePaths(paths) => self.unstage(&paths).await,
90 RepositoryAction::StageAll => self.stage_all().await,
91 RepositoryAction::UnstageAll => self.unstage_all().await,
92 RepositoryAction::Commit { message } => self.commit(&message).await,
93 RepositoryAction::Discard { path, status } => self.discard(&path, status).await,
94 }
95 }
96
97 pub async fn discover(path: impl AsRef<Path>) -> Result<Self, GitError> {
98 let candidate = path.as_ref();
99 let output = match command::run(
100 candidate,
101 "discover repository",
102 ["rev-parse", "--show-toplevel"],
103 )
104 .await
105 {
106 Ok(output) => output,
107 Err(GitError::CommandFailed { .. }) => return Err(GitError::NotRepository),
108 Err(error) => return Err(error),
109 };
110 let root = parse_root(&output.stdout)?;
111 let root = tokio::fs::canonicalize(&root)
112 .await
113 .map_err(|source| GitError::Io {
114 path: root.clone(),
115 source,
116 })?;
117 Ok(Self { root })
118 }
119
120 #[must_use]
122 pub fn root(&self) -> &Path {
123 &self.root
124 }
125
126 pub async fn metadata_directories(&self) -> Result<Vec<PathBuf>, GitError> {
127 let mut directories = Vec::new();
128 for argument in ["--git-dir", "--git-common-dir"] {
129 let output = command::run(
130 &self.root,
131 "resolve Git metadata directory",
132 ["rev-parse", "--path-format=absolute", argument],
133 )
134 .await?;
135 let path = parse_root(&output.stdout)?;
136 let directory = tokio::fs::canonicalize(&path)
137 .await
138 .map_err(|source| GitError::Io { path, source })?;
139 if !directories.contains(&directory) {
140 directories.push(directory);
141 }
142 }
143 Ok(directories)
144 }
145
146 pub async fn snapshot(&self, scope: DiffScope) -> Result<DiffDocument, GitError> {
147 self.load_snapshot_input(scope, scope == DiffScope::Both)
148 .await
149 .map(|input| input.document)
150 }
151
152 pub async fn snapshot_with_sources(
153 &self,
154 scope: DiffScope,
155 ) -> Result<RepositorySnapshot, GitError> {
156 let mut retries = 0;
157 let mut delay = Duration::from_millis(250);
158 loop {
159 match self.capture_snapshot_with_sources(scope).await {
160 Err(GitError::UnstableSnapshot) if retries < 5 => {
161 retries += 1;
162 sleep(delay).await;
163 delay = delay.saturating_mul(2).min(Duration::from_secs(2));
164 }
165 result => return result,
166 }
167 }
168 }
169
170 async fn capture_snapshot_with_sources(
171 &self,
172 scope: DiffScope,
173 ) -> Result<RepositorySnapshot, GitError> {
174 for _ in 0..2 {
175 let initial = self.load_snapshot_input(scope, true).await?;
176 let initial_locations = self
177 .resolve_content_locations(&initial.document, scope, initial.has_head)
178 .await;
179 let captured = match self
180 .capture_sources(&initial.document, &initial_locations)
181 .await
182 {
183 Ok(captured) => captured,
184 Err(GitError::UnstableSnapshot) => continue,
185 Err(error) => return Err(error),
186 };
187 let final_input = self.load_snapshot_input(scope, true).await?;
188 let final_locations = self
189 .resolve_content_locations(&final_input.document, scope, final_input.has_head)
190 .await;
191 let metadata_stable = initial.has_head == final_input.has_head
192 && initial.diff == final_input.diff
193 && initial.status == final_input.status
194 && initial.document == final_input.document
195 && initial_locations == final_locations;
196 if metadata_stable && self.worktrees_match(&captured.worktree_ids).await {
197 let files = initial
198 .document
199 .files
200 .into_iter()
201 .zip(captured.sides)
202 .map(|(file, [old, new])| file.with_sources(old, new))
203 .collect();
204 return Ok(RepositorySnapshot {
205 scope,
206 document: Arc::new(DiffDocument {
207 repo_root: initial.document.repo_root,
208 files,
209 }),
210 });
211 }
212 }
213 Err(GitError::UnstableSnapshot)
214 }
215
216 async fn load_snapshot_input(
217 &self,
218 scope: DiffScope,
219 resolve_head: bool,
220 ) -> Result<SnapshotInput, GitError> {
221 let has_head = if resolve_head {
222 self.has_head().await?
223 } else {
224 true
225 };
226 let diff = command::run(&self.root, "load diff", Self::diff_args(scope, has_head))
227 .await?
228 .stdout;
229 let status = command::run(&self.root, "load status", STATUS_ARGS)
230 .await?
231 .stdout;
232 let untracked = if scope == DiffScope::Staged {
233 Vec::new()
234 } else {
235 self.read_untracked().await?
236 };
237 let repo_root = self
238 .root
239 .to_str()
240 .ok_or(GitError::UnsupportedRepositoryPath)?;
241 let document = DiffDocument::from_git_outputs_with_untracked(
242 repo_root, &diff, &status, scope, &untracked,
243 )?;
244 Ok(SnapshotInput {
245 has_head,
246 diff,
247 status,
248 document,
249 })
250 }
251
252 async fn resolve_content_locations(
254 &self,
255 document: &DiffDocument,
256 scope: DiffScope,
257 has_head: bool,
258 ) -> Vec<[ResolvedContentLocation; 2]> {
259 let head = if has_head {
260 self.resolve_head_blobs(document)
261 .await
262 .map_err(source_error)
263 } else {
264 Ok(HashMap::new())
265 };
266 let index = self.resolve_index_blobs().await.map_err(source_error);
267 document
268 .files
269 .iter()
270 .map(|file| {
271 [DiffSide::Old, DiffSide::New].map(|side| {
272 match content_location(scope, file, side, has_head) {
273 ContentLocation::Absent => ResolvedContentLocation::Absent,
274 ContentLocation::Worktree(path) => ResolvedContentLocation::Worktree(path),
275 ContentLocation::Head(path) => resolve_blob(&head, &path),
276 ContentLocation::Index(path) => resolve_blob(&index, &path),
277 }
278 })
279 })
280 .collect()
281 }
282
283 async fn capture_sources(
284 &self,
285 document: &DiffDocument,
286 locations: &[[ResolvedContentLocation; 2]],
287 ) -> Result<CapturedSources, GitError> {
288 let mut sides = Vec::with_capacity(document.files.len());
289 let mut worktree_ids = HashMap::new();
290 let mut loaded = 0_u64;
291 let mut blobs = if locations
292 .iter()
293 .flatten()
294 .any(|location| matches!(location, ResolvedContentLocation::Blob(_)))
295 {
296 Some(CatFileBatch::start(&self.root)?)
297 } else {
298 None
299 };
300 for (file, file_locations) in document.files.iter().zip(locations) {
301 let mut results = Vec::with_capacity(2);
302 for location in file_locations {
303 let (result, exact_id) = if file.binary {
304 (Err(SourceUnavailable::Binary), None)
305 } else {
306 self.capture_location(location, &mut loaded, blobs.as_mut())
307 .await
308 };
309 if let (ResolvedContentLocation::Worktree(path), Some(exact_id)) =
310 (location, exact_id)
311 {
312 worktree_ids.insert(path.clone(), exact_id);
313 }
314 results.push(result);
315 }
316 let [old, new] = <[SourceResult; 2]>::try_from(results)
317 .unwrap_or_else(|_| unreachable!("two sides per file"));
318 sides.push([old, new]);
319 }
320 Ok(CapturedSources {
321 sides,
322 worktree_ids,
323 })
324 }
325
326 async fn capture_location(
327 &self,
328 location: &ResolvedContentLocation,
329 loaded: &mut u64,
330 blobs: Option<&mut CatFileBatch>,
331 ) -> (SourceResult, Option<Fingerprint>) {
332 let bytes = match location {
333 ResolvedContentLocation::Absent => return (Err(SourceUnavailable::Absent), None),
334 ResolvedContentLocation::Unavailable(reason) => return (Err(reason.clone()), None),
335 ResolvedContentLocation::Blob(oid) => {
336 let Some(blobs) = blobs else {
337 return (
338 Err(SourceUnavailable::Error(
339 "source blob reader was not initialized".to_owned(),
340 )),
341 None,
342 );
343 };
344 match blobs.read_blob(oid, MAX_SOURCE_FILE_BYTES).await {
345 Ok(Ok(bytes)) => bytes,
346 Ok(Err(bytes)) => {
347 return (Err(SourceUnavailable::TooLarge { bytes }), None);
348 }
349 Err(error) => return (Err(source_error(error)), None),
350 }
351 }
352 ResolvedContentLocation::Worktree(path) => match self.read_bounded_worktree(path).await
353 {
354 Ok(bytes) => bytes,
355 Err(error) => return (Err(source_error(error)), None),
356 },
357 };
358 let exact_id = matches!(location, ResolvedContentLocation::Worktree(_))
359 .then(|| Fingerprint::of([bytes.as_slice()]));
360 let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
361 if size > MAX_SOURCE_FILE_BYTES {
362 return (Err(SourceUnavailable::TooLarge { bytes: size }), exact_id);
363 }
364 if bytes.contains(&0) {
365 return (Err(SourceUnavailable::Binary), exact_id);
366 }
367 let Ok(text) = String::from_utf8(bytes) else {
368 return (Err(SourceUnavailable::Binary), exact_id);
369 };
370 if loaded.saturating_add(size) > MAX_SOURCE_ARCHIVE_BYTES {
371 return (Err(SourceUnavailable::SnapshotBudgetExceeded), exact_id);
372 }
373 let source = match SourceDocument::new(&text) {
374 Ok(source) => Arc::new(source),
375 Err(reason) => return (Err(reason), exact_id),
376 };
377 *loaded = loaded.saturating_add(size);
378 (Ok(source), exact_id)
379 }
380
381 async fn worktrees_match(&self, expected: &HashMap<RepoPath, Fingerprint>) -> bool {
382 for (path, expected_id) in expected {
383 let Ok(bytes) = self.read_bounded_worktree(path).await else {
384 return false;
385 };
386 if Fingerprint::of([bytes.as_slice()]) != *expected_id {
387 return false;
388 }
389 }
390 true
391 }
392
393 async fn resolve_head_blobs(
394 &self,
395 document: &DiffDocument,
396 ) -> Result<HashMap<RepoPath, String>, GitError> {
397 let mut paths = document
398 .files
399 .iter()
400 .flat_map(|file| {
401 [
402 file.path.as_str(),
403 file.path_for_side(DiffSide::Old).as_str(),
404 ]
405 })
406 .map(str::to_owned)
407 .collect::<Vec<_>>();
408 paths.sort_unstable();
409 paths.dedup();
410 let mut args = vec![
411 "ls-tree".to_owned(),
412 "-r".to_owned(),
413 "-z".to_owned(),
414 "HEAD".to_owned(),
415 "--".to_owned(),
416 ];
417 args.extend(paths);
418 let output = command::run(&self.root, "resolve HEAD sources", args).await?;
419 Ok(parse_blob_records(&output.stdout, BlobRecordKind::Tree))
420 }
421
422 async fn resolve_index_blobs(&self) -> Result<HashMap<RepoPath, String>, GitError> {
423 let output = command::run(
424 &self.root,
425 "resolve index sources",
426 ["ls-files", "--stage", "-z"],
427 )
428 .await?;
429 Ok(parse_blob_records(&output.stdout, BlobRecordKind::Index))
430 }
431
432 async fn read_bounded_worktree(&self, path: &RepoPath) -> Result<Vec<u8>, GitError> {
433 match self.read_worktree_bytes(path).await? {
434 BoundedWorktree::Content(bytes) => Ok(bytes),
435 BoundedWorktree::TooLarge(bytes) => Err(GitError::SourceTooLarge { bytes }),
436 }
437 }
438
439 async fn read_worktree_bytes(&self, path: &RepoPath) -> Result<BoundedWorktree, GitError> {
440 let joined = path::lexical_path(&self.root, path)?;
441 let metadata = tokio::fs::symlink_metadata(&joined)
442 .await
443 .map_err(|source| GitError::Io {
444 path: joined.clone(),
445 source,
446 })?;
447 if metadata.file_type().is_symlink() {
448 let target = tokio::fs::read_link(&joined)
449 .await
450 .map_err(|source| GitError::Io {
451 path: joined,
452 source,
453 })?;
454 return target
455 .to_str()
456 .map(|target| BoundedWorktree::Content(target.as_bytes().to_vec()))
457 .ok_or(GitError::UnsupportedRepositoryPath);
458 }
459 let host_path = path::readable_path(&self.root, path).await?;
460 let size = tokio::fs::metadata(&host_path)
461 .await
462 .map_err(|source| GitError::Io {
463 path: host_path.clone(),
464 source,
465 })?
466 .len();
467 if size > MAX_SOURCE_FILE_BYTES {
468 return Ok(BoundedWorktree::TooLarge(size));
469 }
470 tokio::fs::read(&host_path)
471 .await
472 .map(BoundedWorktree::Content)
473 .map_err(|source| GitError::Io {
474 path: host_path,
475 source,
476 })
477 }
478
479 pub async fn ignored_paths(&self, relative: &[String]) -> Result<HashSet<String>, GitError> {
480 if relative.is_empty() {
481 return Ok(HashSet::new());
482 }
483 let mut stdin = Vec::new();
484 for path in relative {
485 stdin.extend_from_slice(path.as_bytes());
486 stdin.push(0);
487 }
488 let output = command::run_with_stdin(
489 &self.root,
490 "check ignored paths",
491 ["check-ignore", "-z", "--stdin"],
492 &stdin,
493 &[0, 1],
494 )
495 .await?;
496 Ok(output
497 .stdout
498 .split(|byte| *byte == 0)
499 .filter(|entry| !entry.is_empty())
500 .map(|entry| String::from_utf8_lossy(entry).into_owned())
501 .collect())
502 }
503
504 async fn stage(&self, paths: &[RepoPath]) -> Result<(), GitError> {
510 if paths.is_empty() {
511 return Ok(());
512 }
513 self.run_paths("stage paths", &["add"], paths).await
514 }
515
516 async fn unstage(&self, paths: &[RepoPath]) -> Result<(), GitError> {
523 if paths.is_empty() {
524 return Ok(());
525 }
526 if self.has_head().await? {
527 self.run_paths("unstage paths", &["reset", "--quiet", "HEAD"], paths)
528 .await
529 } else {
530 self.run_paths(
531 "unstage paths",
532 &["rm", "--cached", "-f", "--quiet", "--ignore-unmatch"],
533 paths,
534 )
535 .await
536 }
537 }
538
539 async fn stage_all(&self) -> Result<(), GitError> {
545 command::run(&self.root, "stage all", ["add", "-A", "--"])
546 .await
547 .map(drop)
548 }
549
550 async fn unstage_all(&self) -> Result<(), GitError> {
556 let args: &[&str] = if self.has_head().await? {
557 &["reset", "--quiet", "HEAD", "--"]
558 } else {
559 &[
560 "rm",
561 "--cached",
562 "-r",
563 "-f",
564 "--quiet",
565 "--ignore-unmatch",
566 "--",
567 ".",
568 ]
569 };
570 command::run(&self.root, "unstage all", args)
571 .await
572 .map(drop)
573 }
574
575 async fn commit(&self, message: &str) -> Result<(), GitError> {
582 if message.trim().is_empty() {
583 return Err(GitError::EmptyCommitMessage);
584 }
585 command::run(&self.root, "commit", ["commit", "-m", message])
586 .await
587 .map(drop)
588 }
589
590 async fn discard(&self, path: &RepoPath, status: FileStatus) -> Result<(), GitError> {
601 path::lexical_path(&self.root, path)?;
602 if status == FileStatus::Untracked {
603 return self
604 .run_paths(
605 "discard untracked path",
606 &["clean", "-f"],
607 std::slice::from_ref(path),
608 )
609 .await;
610 }
611 if !self.has_head().await? {
612 if status != FileStatus::Added {
613 return Err(GitError::CommandFailed {
614 operation: "discard path",
615 status: None,
616 stderr: "cannot restore a tracked path in a repository without HEAD".to_owned(),
617 });
618 }
619 self.run_paths(
620 "discard added path",
621 &["rm", "--cached", "-f", "--ignore-unmatch"],
622 std::slice::from_ref(path),
623 )
624 .await?;
625 return self
626 .run_paths(
627 "discard added path",
628 &["clean", "-f"],
629 std::slice::from_ref(path),
630 )
631 .await;
632 }
633 let mut restore_paths = vec![path.clone()];
634 if status == FileStatus::Renamed {
635 let output = command::run(&self.root, "resolve renamed path", STATUS_ARGS).await?;
636 if let Some(old_path) = parse_porcelain_v1_z(&output.stdout)?
637 .into_iter()
638 .find(|entry| entry.path == *path)
639 .and_then(|entry| entry.old_path)
640 {
641 restore_paths.push(old_path);
642 }
643 }
644 self.run_paths(
645 "discard path",
646 &["restore", "--source=HEAD", "--staged", "--worktree"],
647 &restore_paths,
648 )
649 .await
650 }
651
652 async fn read_untracked(&self) -> Result<Vec<UntrackedFile>, GitError> {
653 let output = command::run(
654 &self.root,
655 "list untracked files",
656 ["ls-files", "--others", "--exclude-standard", "-z", "--"],
657 )
658 .await?;
659 let mut files = Vec::new();
660 let mut loaded_bytes = 0_u64;
661 for raw_path in output
662 .stdout
663 .split(|byte| *byte == 0)
664 .filter(|path| !path.is_empty())
665 {
666 let text = std::str::from_utf8(raw_path)
667 .map_err(clankerdiff_core::DiffError::UnsupportedPathEncoding)?;
668 let path = RepoPath::new(text)?;
669 let captured = self.read_worktree_bytes(&path).await?;
670 let (contents, size) = match captured {
671 BoundedWorktree::Content(contents) => {
672 let size = u64::try_from(contents.len()).unwrap_or(u64::MAX);
673 if loaded_bytes.saturating_add(size) > MAX_UNTRACKED_SNAPSHOT_BYTES {
674 (Vec::new(), size)
675 } else {
676 loaded_bytes = loaded_bytes.saturating_add(size);
677 (contents, size)
678 }
679 }
680 BoundedWorktree::TooLarge(size) => (Vec::new(), size),
681 };
682 let omitted = contents.is_empty() && size != 0;
683 files.push(UntrackedFile {
684 path,
685 contents,
686 omitted_bytes: omitted.then_some(size),
687 });
688 }
689 Ok(files)
690 }
691
692 async fn has_head(&self) -> Result<bool, GitError> {
693 match command::run(
694 &self.root,
695 "resolve HEAD",
696 ["rev-parse", "--verify", "--quiet", "HEAD"],
697 )
698 .await
699 {
700 Ok(_) => Ok(true),
701 Err(GitError::CommandFailed {
702 status: Some(1), ..
703 }) => Ok(false),
704 Err(error) => Err(error),
705 }
706 }
707
708 fn diff_args(scope: DiffScope, has_head: bool) -> Vec<&'static str> {
709 let mut args = vec![
711 "-c",
712 "diff.autoRefreshIndex=false",
713 "diff",
714 "--no-ext-diff",
715 "--no-color",
716 "--find-renames",
717 "--find-copies",
718 "--find-copies-harder",
719 ];
720 match scope {
721 DiffScope::Unstaged => {}
722 DiffScope::Staged => args.push("--cached"),
723 DiffScope::Both => args.push(if has_head { "HEAD" } else { EMPTY_TREE }),
724 }
725 args.push("--");
726 args
727 }
728
729 async fn run_paths<'a>(
730 &self,
731 operation: &'static str,
732 prefix: &[&'a str],
733 paths: &'a [RepoPath],
734 ) -> Result<(), GitError> {
735 let mut args = prefix.to_vec();
736 args.push("--");
737 for path in paths {
738 path::lexical_path(&self.root, path)?;
739 args.push(path.as_str());
740 }
741 command::run(&self.root, operation, args).await.map(drop)
742 }
743}
744
745fn content_location(
746 scope: DiffScope,
747 file: &FileDiff,
748 side: DiffSide,
749 has_head: bool,
750) -> ContentLocation {
751 if side == DiffSide::Old && matches!(file.status, FileStatus::Added | FileStatus::Untracked) {
752 return ContentLocation::Absent;
753 }
754 if side == DiffSide::New && file.status == FileStatus::Deleted {
755 return ContentLocation::Absent;
756 }
757 let old_path = file.path_for_side(DiffSide::Old).clone();
758 match (scope, side) {
759 (DiffScope::Unstaged, DiffSide::Old) => ContentLocation::Index(old_path),
760 (DiffScope::Unstaged | DiffScope::Both, DiffSide::New) => {
761 ContentLocation::Worktree(file.path.clone())
762 }
763 (DiffScope::Staged | DiffScope::Both, DiffSide::Old) if has_head => {
764 ContentLocation::Head(old_path)
765 }
766 (DiffScope::Staged | DiffScope::Both, DiffSide::Old) => ContentLocation::Absent,
767 (DiffScope::Staged, DiffSide::New) => ContentLocation::Index(file.path.clone()),
768 }
769}
770
771fn resolve_blob(
772 blobs: &Result<HashMap<RepoPath, String>, SourceUnavailable>,
773 path: &RepoPath,
774) -> ResolvedContentLocation {
775 match blobs {
776 Ok(blobs) => blobs.get(path).cloned().map_or(
777 ResolvedContentLocation::Absent,
778 ResolvedContentLocation::Blob,
779 ),
780 Err(reason) => ResolvedContentLocation::Unavailable(reason.clone()),
781 }
782}
783
784fn parse_blob_records(output: &[u8], kind: BlobRecordKind) -> HashMap<RepoPath, String> {
785 output
786 .split(|byte| *byte == 0)
787 .filter_map(|record| {
788 let tab = record.iter().position(|byte| *byte == b'\t')?;
789 let header = std::str::from_utf8(&record[..tab]).ok()?;
790 let path = std::str::from_utf8(&record[tab.saturating_add(1)..]).ok()?;
791 let fields = header.split_ascii_whitespace().collect::<Vec<_>>();
792 let oid = match kind {
793 BlobRecordKind::Tree if fields.get(1) == Some(&"blob") => fields.get(2),
794 BlobRecordKind::Index if fields.get(2) == Some(&"0") => fields.get(1),
795 BlobRecordKind::Tree | BlobRecordKind::Index => None,
796 }?;
797 Some((RepoPath::new(path).ok()?, (*oid).to_owned()))
798 })
799 .collect()
800}
801
802fn source_error(error: GitError) -> SourceUnavailable {
803 match error {
804 GitError::SourceTooLarge { bytes } => SourceUnavailable::TooLarge { bytes },
805 GitError::UnstableSnapshot => SourceUnavailable::UnstableSnapshot,
806 other => SourceUnavailable::Error(other.to_string()),
807 }
808}
809
810fn parse_root(stdout: &[u8]) -> Result<PathBuf, GitError> {
811 let root = std::str::from_utf8(stdout)
812 .map_err(|_| GitError::UnsupportedRepositoryPath)?
813 .trim_end_matches(['\r', '\n']);
814 if root.is_empty() {
815 return Err(GitError::NotRepository);
816 }
817 Ok(PathBuf::from(root))
818}