Skip to main content

vcs_modify_guard/vcs/
git_cli.rs

1use std::{
2    borrow::Cow,
3    ffi::{OsStr, OsString},
4    fmt::Write as _,
5    io,
6    path::{Path, PathBuf},
7    process::{Command, ExitStatus, Output},
8    str::Utf8Error,
9};
10
11use snafu::{OptionExt as _, ResultExt as _, Snafu, ensure};
12
13use super::VcsRepository;
14use crate::{
15    ModifyGuardError, error,
16    repository::{FileChange, RepositoryChanges},
17    util::{self, WorktreeRelativePath},
18    vcs::VcsBackend,
19};
20
21pub(super) const BACKEND: GitCliBackend = GitCliBackend;
22
23#[derive(Debug)]
24pub(super) struct GitCliBackend;
25
26/// Errors returned by `git-cli` backend operations.
27#[derive(Debug, Snafu)]
28#[non_exhaustive]
29pub enum GitCliBackendError {
30    /// Executing a `git` command failed.
31    #[snafu(display(
32        "failed to execute git command: {}{}",
33        program.display(),
34        args.iter().fold(String::new(), |mut output, arg| {
35            let _ = write!(&mut output, " {}", arg.display());
36            output
37        })
38    ))]
39    GitCommand {
40        /// The underlying error from executing the `git` command.
41        source: io::Error,
42        /// The `git` program that was executed.
43        program: OsString,
44        /// The arguments passed to the `git` command.
45        args: Vec<OsString>,
46    },
47    /// A `git` command returned a non-zero exit status.
48    #[snafu(display("git command returned non-zero exit status: {status}"))]
49    GitExitStatus {
50        /// The exit status returned by the `git` command.
51        status: ExitStatus,
52    },
53    /// Converting the output of a `git` command to UTF-8 failed.
54    #[snafu(display("failed to convert git command output to UTF-8"))]
55    InvalidUtf8 {
56        /// The underlying error from converting the `git` command output to UTF-8.
57        source: Utf8Error,
58    },
59    /// An invalid status entry was encountered in the output of `git status`.
60    #[snafu(display("invalid status entry in git status output: {entry:?}"))]
61    InvalidGitStatus {
62        /// The invalid status entry from the `git status` output.
63        entry: Vec<u8>,
64    },
65    /// An invalid rev-parse output was encountered.
66    #[snafu(display("invalid rev-parse output: {output:?}"))]
67    InvalidRevParse {
68        /// The invalid output from the `git rev-parse` command.
69        output: Vec<u8>,
70    },
71    /// A path was expected to have a parent directory, but it did not.
72    #[snafu(display("path has no parent directory: {}", git_dir.display()))]
73    NoGitDirParent {
74        /// The path that was expected to have a parent directory.
75        git_dir: PathBuf,
76    },
77    /// No worktree listed by Git matched the repository administrative directory.
78    #[snafu(display("no listed worktree matched git dir: {}", git_dir.display()))]
79    NoWorktreeForGitDir {
80        /// The repository administrative directory that could not be mapped back to a worktree.
81        git_dir: PathBuf,
82    },
83}
84
85impl From<GitCliBackendError> for ModifyGuardError {
86    #[inline]
87    fn from(source: GitCliBackendError) -> Self {
88        Self::Backend {
89            source: source.into(),
90        }
91    }
92}
93
94impl VcsBackend for GitCliBackend {
95    fn discover(
96        &self,
97        mut path: &Path,
98    ) -> Result<Option<Box<dyn VcsRepository>>, ModifyGuardError> {
99        util::ensure_path_exists(path)?;
100        #[expect(
101            clippy::unwrap_used,
102            reason = "path is guaranteed to have a parent because it exists and is a file"
103        )]
104        if path.is_file() {
105            path = path.parent().unwrap();
106        }
107
108        let Some(is_bare) = repo_is_bare(path)? else {
109            return Ok(None);
110        };
111        ensure!(!is_bare, error::RepositoryWithoutWorktreeSnafu { path });
112
113        let worktree = if repo_is_inside_git_dir(path)? {
114            let git_dir = repo_absolute_git_dir(path)?;
115            repo_worktree_from_git_dir(&git_dir)?
116        } else {
117            repo_toplevel(path)?
118        };
119
120        Ok(Some(Box::new(GitCliRepository { worktree })))
121    }
122
123    fn open(&self, path: &Path) -> Result<Option<Box<dyn VcsRepository>>, ModifyGuardError> {
124        util::ensure_path_is_directory(path)?;
125
126        let Some(is_bare) = repo_is_bare(path)? else {
127            return Ok(None);
128        };
129        if is_bare {
130            let git_dir = repo_absolute_git_dir(path)?;
131            if is_same_path(&git_dir, path) {
132                return Err(error::RepositoryWithoutWorktreeSnafu { path }.build());
133            }
134            return Ok(None);
135        }
136
137        if repo_is_inside_git_dir(path)? {
138            let git_dir = repo_absolute_git_dir(path)?;
139            if is_same_path(&git_dir, path) {
140                let worktree = repo_worktree_from_git_dir(&git_dir)?;
141                return Ok(Some(Box::new(GitCliRepository { worktree })));
142            }
143            return Ok(None);
144        }
145
146        let prefix = run_git(["rev-parse", "--show-prefix"], path)?;
147        let prefix = parse_stdout_as_path(&prefix)?;
148        if !prefix.as_os_str().is_empty() {
149            return Ok(None);
150        }
151
152        let worktree = repo_toplevel(path)?;
153        Ok(Some(Box::new(GitCliRepository { worktree })))
154    }
155}
156
157#[derive(Debug)]
158struct GitCliRepository {
159    worktree: PathBuf,
160}
161
162impl VcsRepository for GitCliRepository {
163    fn worktree(&self) -> &Path {
164        &self.worktree
165    }
166
167    fn repository_changes(&self) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
168        let file_changes = self.collect_changes(None)?;
169        Ok(RepositoryChanges::new(file_changes))
170    }
171
172    fn path_changes(&self, wt_path: &Path) -> Result<Option<RepositoryChanges>, ModifyGuardError> {
173        let wt_path = WorktreeRelativePath::from_wt_path(&self.worktree, wt_path)?;
174        let file_changes = self.collect_changes(Some(&wt_path))?;
175        Ok(RepositoryChanges::new(file_changes))
176    }
177
178    fn file_change(&self, wt_path: &Path) -> Result<Option<FileChange>, ModifyGuardError> {
179        let wt_path = WorktreeRelativePath::from_wt_path(&self.worktree, wt_path)?;
180        match &wt_path {
181            WorktreeRelativePath::Existing(wt_path) => {
182                let fs_path = self.worktree.join(wt_path);
183                util::ensure_path_is_file(&fs_path)?;
184            }
185            WorktreeRelativePath::Missing(_) => {}
186        }
187        let file_changes = self.collect_changes(Some(&wt_path))?;
188
189        match file_changes.as_slice() {
190            [] => Ok(None),
191            // `git status <pathspec>` treats a missing directory path as a prefix match
192            // and may return changes below it. `file_change()` needs file semantics like
193            // `git2::Repository::status_file`, so accept only an exact path match.
194            [change] if change.wt_path() == wt_path.as_path() => Ok(Some(change.clone())),
195            [..] => Err(error::AmbiguousFilePathSnafu { wt_path }.build()),
196        }
197    }
198}
199
200impl GitCliRepository {
201    fn collect_changes(
202        &self,
203        wt_path: Option<&WorktreeRelativePath>,
204    ) -> Result<Vec<FileChange>, ModifyGuardError> {
205        let pathspec = wt_path
206            .as_ref()
207            .filter(|wt_path| !wt_path.is_empty())
208            .map(|wt_path| literal_pathspec(wt_path.as_path()));
209        let args = [
210            "status",
211            "--porcelain=v1",
212            "-z",
213            "--no-renames",
214            "--no-ignored",
215            "--untracked-files=all",
216        ]
217        .into_iter()
218        .map(|s| Cow::Borrowed(OsStr::new(s)))
219        .chain(pathspec.map(Cow::Owned));
220        let statuses = run_git(args, &self.worktree)?;
221        let statuses = parse_stdout_as_bytes(&statuses);
222        let statuses = parse_git_status(statuses)?;
223        let statuses = statuses
224            .into_iter()
225            .filter_map(StatusEntry::build)
226            .peekable()
227            .collect::<Vec<_>>();
228        if statuses.is_empty()
229            && let Some(WorktreeRelativePath::Missing(wt_path)) = &wt_path
230        {
231            return Err(error::PathNotFoundSnafu { path: wt_path }.build());
232        }
233        Ok(statuses)
234    }
235}
236
237const REPO_CONTEXT_ENV_VARS: &[&str] = &[
238    "GIT_DIR",
239    "GIT_WORK_TREE",
240    "GIT_INDEX_FILE",
241    "GIT_COMMON_DIR",
242];
243
244fn literal_pathspec(path: &Path) -> OsString {
245    let mut pattern = OsString::from(":(top,literal)");
246    pattern.push(path.as_os_str());
247    pattern
248}
249
250fn git_command(current_dir: &Path) -> Command {
251    let mut cmd = Command::new("git");
252    // Make this backend interpret the caller's path the same way as the
253    // libgit2 backend. These vars can redirect Git to a different repository,
254    // worktree, common dir, or index than the path being queried.
255    for env_var in REPO_CONTEXT_ENV_VARS {
256        cmd.env_remove(env_var);
257    }
258    cmd.current_dir(current_dir);
259    cmd
260}
261
262fn run_git_without_status_check<I, S>(
263    args: I,
264    current_dir: &Path,
265) -> Result<Output, GitCliBackendError>
266where
267    I: IntoIterator<Item = S>,
268    S: AsRef<OsStr>,
269{
270    let mut cmd = git_command(current_dir);
271    cmd.args(args);
272    let output = cmd.output().with_context(|_| GitCommandSnafu {
273        program: cmd.get_program(),
274        args: cmd.get_args().map(ToOwned::to_owned).collect::<Vec<_>>(),
275    })?;
276    Ok(output)
277}
278
279fn run_git<I, S>(args: I, current_dir: &Path) -> Result<Output, GitCliBackendError>
280where
281    I: IntoIterator<Item = S>,
282    S: AsRef<OsStr>,
283{
284    let output = run_git_without_status_check(args, current_dir)?;
285    ensure!(
286        output.status.success(),
287        GitExitStatusSnafu {
288            status: output.status
289        }
290    );
291    Ok(output)
292}
293
294fn trim_trailing_newline(bytes: &[u8]) -> &[u8] {
295    bytes.strip_suffix(b"\n").unwrap_or(bytes)
296}
297
298fn parse_stdout_as_bytes(output: &Output) -> &[u8] {
299    trim_trailing_newline(&output.stdout)
300}
301
302fn parse_stdout_as_os_str(output: &Output) -> Result<&OsStr, GitCliBackendError> {
303    let s = parse_stdout_as_bytes(output);
304    util::bytes_to_os_str(s).context(InvalidUtf8Snafu)
305}
306
307fn parse_stdout_as_path(output: &Output) -> Result<&Path, GitCliBackendError> {
308    let path = parse_stdout_as_os_str(output)?;
309    Ok(Path::new(path))
310}
311
312fn parse_stdout_as_bool(output: &Output) -> Result<bool, GitCliBackendError> {
313    let s = parse_stdout_as_bytes(output);
314    match s {
315        b"true" => Ok(true),
316        b"false" => Ok(false),
317        bytes => Err(InvalidRevParseSnafu { output: bytes }.build()),
318    }
319}
320
321fn repo_is_bare(path: &Path) -> Result<Option<bool>, GitCliBackendError> {
322    let output = run_git_without_status_check(["rev-parse", "--is-bare-repository"], path)?;
323    if !output.status.success() {
324        return Ok(None);
325    }
326    let is_bare = parse_stdout_as_bool(&output)?;
327    Ok(Some(is_bare))
328}
329
330fn repo_is_inside_git_dir(path: &Path) -> Result<bool, GitCliBackendError> {
331    let output = run_git(["rev-parse", "--is-inside-git-dir"], path)?;
332    parse_stdout_as_bool(&output)
333}
334
335fn repo_absolute_git_dir(path: &Path) -> Result<PathBuf, GitCliBackendError> {
336    let output = run_git(["rev-parse", "--absolute-git-dir"], path)?;
337    Ok(parse_stdout_as_path(&output)?.to_path_buf())
338}
339
340fn repo_toplevel(path: &Path) -> Result<PathBuf, GitCliBackendError> {
341    let output = run_git(["rev-parse", "--show-toplevel"], path)?;
342    Ok(parse_stdout_as_path(&output)?.to_path_buf())
343}
344
345fn run_git_for_git_dir<I, S>(git_dir: &Path, args: I) -> Result<Output, GitCliBackendError>
346where
347    I: IntoIterator<Item = S>,
348    S: AsRef<OsStr>,
349{
350    let query_dir = git_dir.parent().context(NoGitDirParentSnafu {
351        git_dir: git_dir.to_path_buf(),
352    })?;
353    let mut git_dir_arg = OsString::from("--git-dir=");
354    git_dir_arg.push(git_dir.as_os_str());
355    let args = std::iter::once(Cow::Owned(git_dir_arg)).chain(
356        args.into_iter()
357            .map(|arg| Cow::Owned(arg.as_ref().to_os_string())),
358    );
359    run_git(args, query_dir)
360}
361
362fn repo_common_git_dir(git_dir: &Path) -> Result<PathBuf, GitCliBackendError> {
363    let output = run_git_for_git_dir(
364        git_dir,
365        [OsStr::new("rev-parse"), OsStr::new("--git-common-dir")],
366    )?;
367    let common_dir = parse_stdout_as_path(&output)?;
368    if common_dir.is_absolute() {
369        return Ok(common_dir.to_path_buf());
370    }
371    let query_dir = git_dir.parent().context(NoGitDirParentSnafu {
372        git_dir: git_dir.to_path_buf(),
373    })?;
374    Ok(query_dir.join(common_dir))
375}
376
377// Parse the documented stable porcelain format rather than reading
378// `$GIT_DIR/worktrees/*/gitdir` directly. `git-worktree(1)` says `--porcelain`
379// output "will remain stable across Git versions and regardless of user
380// configuration", and "The first attribute of a worktree is always `worktree'".
381// <https://git-scm.com/docs/git-worktree>
382fn parse_worktree_list_porcelain(output: &[u8]) -> Result<Vec<PathBuf>, GitCliBackendError> {
383    let mut worktrees = vec![];
384    for field in output.split(|&byte| byte == b'\0') {
385        let Some(path) = field.strip_prefix(b"worktree ") else {
386            continue;
387        };
388        let path = util::bytes_to_os_str(path).context(InvalidUtf8Snafu)?;
389        worktrees.push(PathBuf::from(path));
390    }
391    Ok(worktrees)
392}
393
394fn repo_worktree_from_git_dir(git_dir: &Path) -> Result<PathBuf, GitCliBackendError> {
395    let common_git_dir = repo_common_git_dir(git_dir)?;
396    let worktrees = run_git_for_git_dir(
397        &common_git_dir,
398        [
399            OsStr::new("worktree"),
400            OsStr::new("list"),
401            OsStr::new("--porcelain"),
402            OsStr::new("-z"),
403        ],
404    )?;
405    let worktrees = parse_worktree_list_porcelain(parse_stdout_as_bytes(&worktrees))?;
406    // Reverse-map the administrative git dir back to the owning worktree by
407    // asking Git for every listed worktree's absolute git dir and comparing the
408    // resolved paths. This keeps the implementation CLI-based for linked
409    // worktrees instead of depending on `.git` file layout details.
410    for worktree in worktrees {
411        let candidate_git_dir = repo_absolute_git_dir(&worktree)?;
412        if is_same_path(&candidate_git_dir, git_dir) {
413            return Ok(worktree);
414        }
415    }
416    NoWorktreeForGitDirSnafu {
417        git_dir: git_dir.to_path_buf(),
418    }
419    .fail()
420}
421
422fn is_same_path(path1: &Path, path2: &Path) -> bool {
423    if path1.components() == path2.components() {
424        return true;
425    }
426    match (path1.canonicalize(), path2.canonicalize()) {
427        (Ok(canon1), Ok(canon2)) => canon1 == canon2,
428        _ => false,
429    }
430}
431
432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
433enum ChangeKind {
434    Unmodified,
435    Modified,
436    TypeChanged,
437    Added,
438    Deleted,
439    Renamed,
440    Copied,
441    UpdatedButUnmerged,
442    Untracked,
443}
444
445impl ChangeKind {
446    fn from_byte(c: u8) -> Option<Self> {
447        match c {
448            b' ' => Some(Self::Unmodified),
449            b'M' => Some(Self::Modified),
450            b'T' => Some(Self::TypeChanged),
451            b'A' => Some(Self::Added),
452            b'D' => Some(Self::Deleted),
453            b'R' => Some(Self::Renamed),
454            b'C' => Some(Self::Copied),
455            b'U' => Some(Self::UpdatedButUnmerged),
456            b'?' => Some(Self::Untracked),
457            _ => None,
458        }
459    }
460}
461
462#[derive(Debug, Clone, PartialEq, Eq)]
463struct StatusEntry<'a> {
464    index: ChangeKind,
465    worktree: ChangeKind,
466    wt_path: &'a OsStr,
467}
468
469fn parse_git_status(status: &[u8]) -> Result<Vec<StatusEntry<'_>>, GitCliBackendError> {
470    let mut changes = vec![];
471    for entry in status.split(|c| *c == b'\0') {
472        if entry.is_empty() {
473            continue;
474        }
475        let mut cs = entry.iter();
476        let index = cs
477            .next()
478            .copied()
479            .and_then(ChangeKind::from_byte)
480            .context(InvalidGitStatusSnafu { entry })?;
481        let worktree = cs
482            .next()
483            .copied()
484            .and_then(ChangeKind::from_byte)
485            .context(InvalidGitStatusSnafu { entry })?;
486        let space = cs
487            .next()
488            .copied()
489            .context(InvalidGitStatusSnafu { entry })?;
490        ensure!(space == b' ', InvalidGitStatusSnafu { entry });
491        let Some(wt_path) = util::bytes_to_os_str(cs.as_slice()).ok() else {
492            // Match the libgit2 backend's aggregate queries by skipping status
493            // entries whose paths cannot be represented on this platform.
494            continue;
495        };
496        let entry = StatusEntry {
497            index,
498            worktree,
499            wt_path,
500        };
501        changes.push(entry);
502    }
503    Ok(changes)
504}
505
506impl StatusEntry<'_> {
507    fn build(self) -> Option<FileChange> {
508        let StatusEntry {
509            index,
510            worktree,
511            wt_path,
512        } = self;
513        let (dirty, staged) = if index == ChangeKind::Untracked || worktree == ChangeKind::Untracked
514        {
515            (true, false)
516        } else {
517            (
518                worktree != ChangeKind::Unmodified,
519                index != ChangeKind::Unmodified,
520            )
521        };
522        (dirty || staged).then(|| FileChange {
523            wt_path: PathBuf::from(wt_path),
524            dirty,
525            staged,
526        })
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn parse_git_status_returns_file_changes() {
536        use ChangeKind::*;
537
538        let status = b"   clean.txt\0M  staged.txt\0";
539        let changes = parse_git_status(status).unwrap();
540        assert_eq!(
541            changes,
542            [
543                StatusEntry {
544                    index: Unmodified,
545                    worktree: Unmodified,
546                    wt_path: OsStr::new("clean.txt")
547                },
548                StatusEntry {
549                    index: Modified,
550                    worktree: Unmodified,
551                    wt_path: OsStr::new("staged.txt")
552                },
553            ]
554        );
555    }
556}