Skip to main content

grit_lib/git_dir/
io_error.rs

1//! Map filesystem failures under the repository git directory to [`Error::GitDirPathIo`].
2
3use std::io;
4use std::path::{Path, PathBuf};
5
6use crate::error::{Error, Result};
7use crate::objects::odb::Odb;
8
9/// Git-directory filesystem operation that failed on a single path.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11#[non_exhaustive]
12pub enum GitDirPathOperation {
13    /// Writing a loose object or object store directory under `objects/`.
14    WriteObject,
15    /// Creating or updating a ref file or lock under `refs/`.
16    UpdateRef,
17    /// Appending to a reflog file under `logs/`.
18    AppendReflog,
19    /// Writing or replacing the index file.
20    WriteIndex,
21    /// Repack, prune, or other maintenance writing under `objects/pack/`.
22    GcPack,
23    /// Creating a directory under the git dir (refs, logs, objects, …).
24    CreateDir,
25}
26
27impl GitDirPathOperation {
28    fn phrase(self) -> &'static str {
29        match self {
30            Self::WriteObject => "add an object to the object database",
31            Self::UpdateRef => "update the ref store",
32            Self::AppendReflog => "append to the reflog",
33            Self::WriteIndex => "write the index",
34            Self::GcPack => "run repository maintenance",
35            Self::CreateDir => "create a directory in the repository",
36        }
37    }
38}
39
40/// True when `git_dir` is the hidden `.git` directory of a linked worktree.
41fn layout_uses_dot_git_prefix(git_dir: &Path) -> bool {
42    git_dir.file_name().is_some_and(|n| n == ".git")
43}
44
45/// Normalize a path suffix from [`Path::strip_prefix`] for stable `.git/…` display (forward slashes).
46fn normalize_repo_relative_suffix(rel: &str) -> String {
47    rel.trim_start_matches(['/', '\\']).replace('\\', "/")
48}
49
50/// Format a repository-relative suffix for display (worktree vs bare).
51fn format_layout_path(layout_git_dir: &Path, rel: &str) -> String {
52    if rel.is_empty() {
53        if layout_uses_dot_git_prefix(layout_git_dir) {
54            return ".git".to_owned();
55        }
56        return layout_git_dir
57            .file_name()
58            .map(|s| s.to_string_lossy().into_owned())
59            .unwrap_or_else(|| ".".into());
60    }
61    if layout_uses_dot_git_prefix(layout_git_dir) {
62        format!(".git/{rel}")
63    } else {
64        rel.to_owned()
65    }
66}
67
68/// Display path for ref-store I/O under `common_dir` and the checkout's `git_dir` (linked admin dir).
69#[must_use]
70pub fn display_ref_store_path(common_dir: &Path, checkout_git_dir: &Path, path: &Path) -> String {
71    let layout = if layout_uses_dot_git_prefix(common_dir) {
72        common_dir
73    } else {
74        checkout_git_dir
75    };
76    let rel = if let Ok(r) = path.strip_prefix(common_dir) {
77        normalize_repo_relative_suffix(&r.to_string_lossy())
78    } else if let Ok(tail) = path.strip_prefix(checkout_git_dir) {
79        let middle = checkout_git_dir
80            .strip_prefix(common_dir)
81            .map(|p| normalize_repo_relative_suffix(&p.to_string_lossy()))
82            .unwrap_or_default();
83        let tail = normalize_repo_relative_suffix(&tail.to_string_lossy());
84        if middle.is_empty() {
85            tail
86        } else {
87            format!("{middle}/{tail}")
88        }
89    } else {
90        return path.display().to_string();
91    };
92    format_layout_path(layout, &rel)
93}
94
95/// Repository-relative display path for a file under the git directory (e.g. `.git/objects`).
96#[must_use]
97pub fn display_git_dir_path(git_dir: &Path, path: &Path) -> String {
98    if let Ok(rel) = path.strip_prefix(git_dir) {
99        let trimmed = normalize_repo_relative_suffix(&rel.to_string_lossy());
100        return format_layout_path(git_dir, &trimmed);
101    }
102    if layout_uses_dot_git_prefix(git_dir) {
103        if let Some(worktree) = git_dir.parent() {
104            if let Ok(rel) = path.strip_prefix(worktree) {
105                return normalize_repo_relative_suffix(&rel.to_string_lossy());
106            }
107        }
108    }
109    path.display().to_string()
110}
111
112/// Resolve the git directory used for pack/maintenance I/O messages.
113#[must_use]
114pub fn git_dir_for_pack_io(odb: &Odb, pack_area_path: &Path) -> PathBuf {
115    if let Some(git_dir) = odb.config_git_dir() {
116        return git_dir.to_path_buf();
117    }
118    pack_area_path
119        .parent()
120        .and_then(|objects| objects.parent())
121        .map(Path::to_path_buf)
122        .unwrap_or_else(|| pack_area_path.to_path_buf())
123}
124
125/// Git directory inferred from an `objects/` directory path.
126#[must_use]
127pub fn git_dir_from_objects_dir(objects_dir: &Path) -> PathBuf {
128    objects_dir
129        .parent()
130        .map(Path::to_path_buf)
131        .unwrap_or_else(|| objects_dir.to_path_buf())
132}
133
134/// Git directory inferred from `objects/pack/` (or a file inside it).
135#[must_use]
136pub fn git_dir_from_pack_dir(pack_dir: &Path) -> PathBuf {
137    pack_dir
138        .parent()
139        .and_then(|objects| objects.parent())
140        .map(Path::to_path_buf)
141        .unwrap_or_else(|| pack_dir.to_path_buf())
142}
143
144/// Human-readable summary for [`Error::GitDirPathIo`].
145#[must_use]
146pub fn format_git_dir_io_message(
147    path: &str,
148    operation: GitDirPathOperation,
149    source: &io::Error,
150) -> String {
151    if source.kind() == io::ErrorKind::PermissionDenied {
152        format!(
153            "insufficient permission to {} ({path}): {source}",
154            operation.phrase()
155        )
156    } else {
157        format!("cannot {} ({path}): {source}", operation.phrase())
158    }
159}
160
161/// Wrap a raw I/O failure with git-dir path and operation context.
162#[must_use]
163pub fn map_git_dir_io(
164    git_dir: &Path,
165    path: &Path,
166    operation: GitDirPathOperation,
167    source: io::Error,
168) -> Error {
169    Error::GitDirPathIo {
170        path: display_git_dir_path(git_dir, path),
171        operation,
172        source,
173    }
174}
175
176/// Like [`map_git_dir_io`], for the files ref store (`common_dir` + checkout `git_dir`).
177#[must_use]
178pub fn map_ref_store_path_io(
179    common_dir: &Path,
180    checkout_git_dir: &Path,
181    path: &Path,
182    operation: GitDirPathOperation,
183    source: io::Error,
184) -> Error {
185    Error::GitDirPathIo {
186        path: display_ref_store_path(common_dir, checkout_git_dir, path),
187        operation,
188        source,
189    }
190}
191
192/// Attach git-dir context to [`std::io::Result`].
193pub fn io_with_git_path<T>(
194    git_dir: &Path,
195    path: &Path,
196    operation: GitDirPathOperation,
197    result: io::Result<T>,
198) -> Result<T> {
199    result.map_err(|source| map_git_dir_io(git_dir, path, operation, source))
200}
201
202/// Map a bare [`Error::Io`] from index persistence to [`Error::GitDirPathIo`].
203#[must_use]
204pub fn map_index_write_error(git_dir: &Path, index_path: &Path, err: Error) -> Error {
205    let display_path = if let Some(common) = crate::refs::common_dir(git_dir) {
206        display_ref_store_path(&common, git_dir, index_path)
207    } else {
208        display_git_dir_path(git_dir, index_path)
209    };
210    match err {
211        Error::Io(source) => Error::GitDirPathIo {
212            path: display_path,
213            operation: GitDirPathOperation::WriteIndex,
214            source,
215        },
216        other => other,
217    }
218}
219
220/// Attach ref-store git-dir context to [`std::io::Result`].
221pub fn io_with_ref_store_path<T>(
222    common_dir: &Path,
223    checkout_git_dir: &Path,
224    path: &Path,
225    operation: GitDirPathOperation,
226    result: io::Result<T>,
227) -> Result<T> {
228    result.map_err(|source| {
229        map_ref_store_path_io(common_dir, checkout_git_dir, path, operation, source)
230    })
231}
232
233/// Like [`map_git_dir_io`], inferring the git directory from an `objects/` root.
234#[must_use]
235pub fn map_objects_dir_io(
236    objects_dir: &Path,
237    path: &Path,
238    operation: GitDirPathOperation,
239    source: io::Error,
240) -> Error {
241    let git_dir = git_dir_from_objects_dir(objects_dir);
242    map_git_dir_io(&git_dir, path, operation, source)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::path::PathBuf;
249
250    #[test]
251    fn display_path_under_dot_git() {
252        let git = PathBuf::from("/tmp/pe/.git");
253        let path = git.join("objects/ab/cdef");
254        assert_eq!(display_git_dir_path(&git, &path), ".git/objects/ab/cdef");
255    }
256
257    #[test]
258    fn strip_prefix_suffix_normalizes_windows_separators() {
259        let git = PathBuf::from("/tmp/pe/.git");
260        let rel = normalize_repo_relative_suffix(r"\objects\ab\cdef");
261        assert_eq!(rel, "objects/ab/cdef");
262        assert_eq!(format_layout_path(&git, &rel), ".git/objects/ab/cdef");
263    }
264
265    #[cfg(windows)]
266    #[test]
267    fn display_path_under_dot_git_on_windows() {
268        let git = PathBuf::from(r"C:\Users\pe\repo\.git");
269        let path = PathBuf::from(r"C:\Users\pe\repo\.git\objects\ab\cdef");
270        let display = display_git_dir_path(&git, &path);
271        assert_eq!(display, ".git/objects/ab/cdef");
272        assert!(
273            !display.contains('\\'),
274            "git-dir display paths must use forward slashes: {display}"
275        );
276    }
277
278    #[test]
279    fn display_path_under_bare_repo() {
280        let git = PathBuf::from("/tmp/foo.git");
281        let path = git.join("objects/pack/tmp_repack_1");
282        assert_eq!(
283            display_git_dir_path(&git, &path),
284            "objects/pack/tmp_repack_1"
285        );
286    }
287
288    #[test]
289    fn storage_path_uses_dot_git_for_worktree_layout() {
290        let git = PathBuf::from("/tmp/wt/.git");
291        let common = PathBuf::from("/shared/commondir");
292        let path = common.join("refs/heads/main");
293        assert_eq!(
294            display_ref_store_path(&common, &git, &path),
295            ".git/refs/heads/main"
296        );
297    }
298
299    #[test]
300    fn ref_store_path_for_linked_worktree_head_lock() {
301        let common = PathBuf::from("/tmp/main/.git");
302        let admin = common.join("worktrees/linked");
303        let lock = admin.join("HEAD.lock");
304        assert_eq!(
305            display_ref_store_path(&common, &admin, &lock),
306            ".git/worktrees/linked/HEAD.lock"
307        );
308    }
309
310    #[test]
311    fn ref_store_path_for_bare_pack_staging() {
312        let bare = PathBuf::from("/tmp/repo.git");
313        let path = bare.join("objects/pack/tmp_repack_1");
314        assert_eq!(
315            display_ref_store_path(&bare, &bare, &path),
316            "objects/pack/tmp_repack_1"
317        );
318    }
319
320    #[test]
321    fn permission_message_names_git_path() {
322        let err = io::Error::from_raw_os_error(libc::EACCES as i32);
323        let msg = format_git_dir_io_message(".git/objects", GitDirPathOperation::WriteObject, &err);
324        assert!(msg.contains(".git/objects"));
325        assert!(msg.contains("insufficient permission"));
326    }
327}