Skip to main content

clankerdiff_git/
repository.rs

1//! Concrete native Git repository service.
2
3use 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/// A complete review document captured for one scope, with bounded immutable
30/// complete-file versions attached to every file.
31#[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    /// Old and new results for each file, in document order.
75    sides: Vec<[SourceResult; 2]>,
76    worktree_ids: HashMap<RepoPath, Fingerprint>,
77}
78
79/// A discovered Git worktree and its native operations.
80#[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    /// Returns the canonical worktree root.
121    #[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    /// Resolves where each file's old and new versions live, in document order.
253    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    /// Stages selected paths. An empty slice is a no-op.
505    ///
506    /// # Errors
507    ///
508    /// Returns an error when a path is invalid or Git cannot stage it.
509    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    /// Unstages selected paths while preserving their worktree contents.
517    /// An empty slice is a no-op.
518    ///
519    /// # Errors
520    ///
521    /// Returns an error when a path is invalid or Git cannot update the index.
522    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    /// Stages all tracked, untracked, and deleted paths.
540    ///
541    /// # Errors
542    ///
543    /// Returns an error when Git cannot update the index.
544    async fn stage_all(&self) -> Result<(), GitError> {
545        command::run(&self.root, "stage all", ["add", "-A", "--"])
546            .await
547            .map(drop)
548    }
549
550    /// Unstages the complete index while preserving worktree contents.
551    ///
552    /// # Errors
553    ///
554    /// Returns an error when Git cannot update the index.
555    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    /// Commits the current index with the supplied non-empty message.
576    ///
577    /// # Errors
578    ///
579    /// Returns [`GitError::EmptyCommitMessage`] for a blank message, or an
580    /// execution error when Git cannot create the commit.
581    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    /// Discards all staged and unstaged changes for one path.
591    ///
592    /// Untracked paths are removed with `git clean`; tracked paths are restored
593    /// from `HEAD`. In an unborn repository an added path is removed from the
594    /// index and worktree.
595    ///
596    /// # Errors
597    ///
598    /// Returns an error when the path is invalid, status metadata cannot be
599    /// parsed, or Git cannot restore or remove the path.
600    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        // Diff has its own stat-cache refresh setting, independent of status.
710        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}