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#[derive(Debug, Snafu)]
28#[non_exhaustive]
29pub enum GitCliBackendError {
30 #[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 source: io::Error,
42 program: OsString,
44 args: Vec<OsString>,
46 },
47 #[snafu(display("git command returned non-zero exit status: {status}"))]
49 GitExitStatus {
50 status: ExitStatus,
52 },
53 #[snafu(display("failed to convert git command output to UTF-8"))]
55 InvalidUtf8 {
56 source: Utf8Error,
58 },
59 #[snafu(display("invalid status entry in git status output: {entry:?}"))]
61 InvalidGitStatus {
62 entry: Vec<u8>,
64 },
65 #[snafu(display("invalid rev-parse output: {output:?}"))]
67 InvalidRevParse {
68 output: Vec<u8>,
70 },
71 #[snafu(display("path has no parent directory: {}", git_dir.display()))]
73 NoGitDirParent {
74 git_dir: PathBuf,
76 },
77 #[snafu(display("no listed worktree matched git dir: {}", git_dir.display()))]
79 NoWorktreeForGitDir {
80 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 [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 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
377fn 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 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 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}