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/// User-visible path for a file under `storage_root` (often `commondir`) in a repo laid out as `layout_git_dir`.
69#[must_use]
70#[allow(dead_code)]
71pub fn display_storage_path(layout_git_dir: &Path, storage_root: &Path, path: &Path) -> String {
72    if let Ok(rel) = path.strip_prefix(storage_root) {
73        let trimmed = normalize_repo_relative_suffix(&rel.to_string_lossy());
74        return format_layout_path(layout_git_dir, &trimmed);
75    }
76    display_git_dir_path(layout_git_dir, path)
77}
78
79/// Display path for ref-store I/O under `common_dir` and the checkout's `git_dir` (linked admin dir).
80#[must_use]
81pub fn display_ref_store_path(common_dir: &Path, checkout_git_dir: &Path, path: &Path) -> String {
82    let layout = if layout_uses_dot_git_prefix(common_dir) {
83        common_dir
84    } else {
85        checkout_git_dir
86    };
87    let rel = if let Ok(r) = path.strip_prefix(common_dir) {
88        normalize_repo_relative_suffix(&r.to_string_lossy())
89    } else if let Ok(tail) = path.strip_prefix(checkout_git_dir) {
90        let middle = checkout_git_dir
91            .strip_prefix(common_dir)
92            .map(|p| normalize_repo_relative_suffix(&p.to_string_lossy()))
93            .unwrap_or_default();
94        let tail = normalize_repo_relative_suffix(&tail.to_string_lossy());
95        if middle.is_empty() {
96            tail
97        } else {
98            format!("{middle}/{tail}")
99        }
100    } else {
101        return path.display().to_string();
102    };
103    format_layout_path(layout, &rel)
104}
105
106/// Repository-relative display path for a file under the git directory (e.g. `.git/objects`).
107#[must_use]
108pub fn display_git_dir_path(git_dir: &Path, path: &Path) -> String {
109    if let Ok(rel) = path.strip_prefix(git_dir) {
110        let trimmed = normalize_repo_relative_suffix(&rel.to_string_lossy());
111        return format_layout_path(git_dir, &trimmed);
112    }
113    if layout_uses_dot_git_prefix(git_dir) {
114        if let Some(worktree) = git_dir.parent() {
115            if let Ok(rel) = path.strip_prefix(worktree) {
116                return normalize_repo_relative_suffix(&rel.to_string_lossy());
117            }
118        }
119    }
120    path.display().to_string()
121}
122
123/// Resolve the git directory used for pack/maintenance I/O messages.
124#[must_use]
125pub fn git_dir_for_pack_io(odb: &Odb, pack_area_path: &Path) -> PathBuf {
126    if let Some(git_dir) = odb.config_git_dir() {
127        return git_dir.to_path_buf();
128    }
129    pack_area_path
130        .parent()
131        .and_then(|objects| objects.parent())
132        .map(Path::to_path_buf)
133        .unwrap_or_else(|| pack_area_path.to_path_buf())
134}
135
136/// Git directory inferred from an `objects/` directory path.
137#[must_use]
138pub fn git_dir_from_objects_dir(objects_dir: &Path) -> PathBuf {
139    objects_dir
140        .parent()
141        .map(Path::to_path_buf)
142        .unwrap_or_else(|| objects_dir.to_path_buf())
143}
144
145/// Git directory inferred from `objects/pack/` (or a file inside it).
146#[must_use]
147pub fn git_dir_from_pack_dir(pack_dir: &Path) -> PathBuf {
148    pack_dir
149        .parent()
150        .and_then(|objects| objects.parent())
151        .map(Path::to_path_buf)
152        .unwrap_or_else(|| pack_dir.to_path_buf())
153}
154
155/// Human-readable summary for [`Error::GitDirPathIo`].
156#[must_use]
157pub fn format_git_dir_io_message(
158    path: &str,
159    operation: GitDirPathOperation,
160    source: &io::Error,
161) -> String {
162    if source.kind() == io::ErrorKind::PermissionDenied {
163        format!(
164            "insufficient permission to {} ({path}): {source}",
165            operation.phrase()
166        )
167    } else {
168        format!("cannot {} ({path}): {source}", operation.phrase())
169    }
170}
171
172/// Wrap a raw I/O failure with git-dir path and operation context.
173#[must_use]
174pub fn map_git_dir_io(
175    git_dir: &Path,
176    path: &Path,
177    operation: GitDirPathOperation,
178    source: io::Error,
179) -> Error {
180    Error::GitDirPathIo {
181        path: display_git_dir_path(git_dir, path),
182        operation,
183        source,
184    }
185}
186
187/// Like [`map_git_dir_io`], for paths under a separate storage root (linked worktrees).
188#[must_use]
189#[allow(dead_code)]
190pub fn map_storage_git_dir_io(
191    layout_git_dir: &Path,
192    storage_root: &Path,
193    path: &Path,
194    operation: GitDirPathOperation,
195    source: io::Error,
196) -> Error {
197    Error::GitDirPathIo {
198        path: display_storage_path(layout_git_dir, storage_root, path),
199        operation,
200        source,
201    }
202}
203
204/// Like [`map_git_dir_io`], for the files ref store (`common_dir` + checkout `git_dir`).
205#[must_use]
206pub fn map_ref_store_path_io(
207    common_dir: &Path,
208    checkout_git_dir: &Path,
209    path: &Path,
210    operation: GitDirPathOperation,
211    source: io::Error,
212) -> Error {
213    Error::GitDirPathIo {
214        path: display_ref_store_path(common_dir, checkout_git_dir, path),
215        operation,
216        source,
217    }
218}
219
220/// Attach git-dir context to [`std::io::Result`].
221pub fn io_with_git_path<T>(
222    git_dir: &Path,
223    path: &Path,
224    operation: GitDirPathOperation,
225    result: io::Result<T>,
226) -> Result<T> {
227    result.map_err(|source| map_git_dir_io(git_dir, path, operation, source))
228}
229
230/// Map a bare [`Error::Io`] from index persistence to [`Error::GitDirPathIo`].
231#[must_use]
232pub fn map_index_write_error(git_dir: &Path, index_path: &Path, err: Error) -> Error {
233    let display_path = if let Some(common) = crate::refs::common_dir(git_dir) {
234        display_ref_store_path(&common, git_dir, index_path)
235    } else {
236        display_git_dir_path(git_dir, index_path)
237    };
238    match err {
239        Error::Io(source) => Error::GitDirPathIo {
240            path: display_path,
241            operation: GitDirPathOperation::WriteIndex,
242            source,
243        },
244        other => other,
245    }
246}
247
248/// Attach storage-layout git-dir context to [`std::io::Result`].
249#[allow(dead_code)]
250pub fn io_with_storage_git_path<T>(
251    layout_git_dir: &Path,
252    storage_root: &Path,
253    path: &Path,
254    operation: GitDirPathOperation,
255    result: io::Result<T>,
256) -> Result<T> {
257    result.map_err(|source| {
258        map_storage_git_dir_io(layout_git_dir, storage_root, path, operation, source)
259    })
260}
261
262/// Attach ref-store git-dir context to [`std::io::Result`].
263pub fn io_with_ref_store_path<T>(
264    common_dir: &Path,
265    checkout_git_dir: &Path,
266    path: &Path,
267    operation: GitDirPathOperation,
268    result: io::Result<T>,
269) -> Result<T> {
270    result.map_err(|source| {
271        map_ref_store_path_io(common_dir, checkout_git_dir, path, operation, source)
272    })
273}
274
275/// Like [`map_git_dir_io`], inferring the git directory from an `objects/` root.
276#[must_use]
277pub fn map_objects_dir_io(
278    objects_dir: &Path,
279    path: &Path,
280    operation: GitDirPathOperation,
281    source: io::Error,
282) -> Error {
283    let git_dir = git_dir_from_objects_dir(objects_dir);
284    map_git_dir_io(&git_dir, path, operation, source)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use std::path::PathBuf;
291
292    #[test]
293    fn display_path_under_dot_git() {
294        let git = PathBuf::from("/tmp/pe/.git");
295        let path = git.join("objects/ab/cdef");
296        assert_eq!(display_git_dir_path(&git, &path), ".git/objects/ab/cdef");
297    }
298
299    #[test]
300    fn strip_prefix_suffix_normalizes_windows_separators() {
301        let git = PathBuf::from("/tmp/pe/.git");
302        let rel = normalize_repo_relative_suffix(r"\objects\ab\cdef");
303        assert_eq!(rel, "objects/ab/cdef");
304        assert_eq!(format_layout_path(&git, &rel), ".git/objects/ab/cdef");
305    }
306
307    #[cfg(windows)]
308    #[test]
309    fn display_path_under_dot_git_on_windows() {
310        let git = PathBuf::from(r"C:\Users\pe\repo\.git");
311        let path = PathBuf::from(r"C:\Users\pe\repo\.git\objects\ab\cdef");
312        let display = display_git_dir_path(&git, &path);
313        assert_eq!(display, ".git/objects/ab/cdef");
314        assert!(
315            !display.contains('\\'),
316            "git-dir display paths must use forward slashes: {display}"
317        );
318    }
319
320    #[test]
321    fn display_path_under_bare_repo() {
322        let git = PathBuf::from("/tmp/foo.git");
323        let path = git.join("objects/pack/tmp_repack_1");
324        assert_eq!(
325            display_git_dir_path(&git, &path),
326            "objects/pack/tmp_repack_1"
327        );
328    }
329
330    #[test]
331    fn storage_path_uses_dot_git_for_worktree_layout() {
332        let git = PathBuf::from("/tmp/wt/.git");
333        let common = PathBuf::from("/shared/commondir");
334        let path = common.join("refs/heads/main");
335        assert_eq!(
336            display_storage_path(&git, &common, &path),
337            ".git/refs/heads/main"
338        );
339    }
340
341    #[test]
342    fn ref_store_path_for_linked_worktree_head_lock() {
343        let common = PathBuf::from("/tmp/main/.git");
344        let admin = common.join("worktrees/linked");
345        let lock = admin.join("HEAD.lock");
346        assert_eq!(
347            display_ref_store_path(&common, &admin, &lock),
348            ".git/worktrees/linked/HEAD.lock"
349        );
350    }
351
352    #[test]
353    fn ref_store_path_for_bare_pack_staging() {
354        let bare = PathBuf::from("/tmp/repo.git");
355        let path = bare.join("objects/pack/tmp_repack_1");
356        assert_eq!(
357            display_ref_store_path(&bare, &bare, &path),
358            "objects/pack/tmp_repack_1"
359        );
360    }
361
362    #[test]
363    fn permission_message_names_git_path() {
364        let err = io::Error::from_raw_os_error(libc::EACCES as i32);
365        let msg = format_git_dir_io_message(".git/objects", GitDirPathOperation::WriteObject, &err);
366        assert!(msg.contains(".git/objects"));
367        assert!(msg.contains("insufficient permission"));
368    }
369}