Skip to main content

pristine/
git.rs

1//! What git knows about a directory: where its work tree is, and whether anything under it is
2//! tracked.
3//!
4//! ## Why the index, and why by asking git for it
5//!
6//! Tier two's safety property is "contains no tracked file at any depth", which is exactly the
7//! guarantee `git clean` enforces and the only reason the tier can be on by default. The index
8//! is the authority for it, and getting the index wrong means deleting somebody's source — so
9//! this module asks `git ls-files` rather than parsing `.git/index` itself. Split indexes,
10//! version-4 path compression, sparse directory entries and linked work trees are all shapes a
11//! hand-rolled reader gets wrong quietly, and quietly is the one failure mode a cleaner cannot
12//! afford. One subprocess per work tree buys the exact answer.
13//!
14//! If git cannot be run, or the repository will not answer, tier two goes inert for that work
15//! tree and says so. It never falls back to a guess.
16
17use std::ffi::OsStr;
18use std::path::{Component, Path, PathBuf};
19use std::process::{Command, Stdio};
20use std::{fmt, io, str};
21
22use unicode_normalization::UnicodeNormalization;
23
24/// Environment variables that redirect git at a repository other than the one it was pointed
25/// at, cleared before every invocation.
26///
27/// This is not hygiene, it is the safety property again. Anything running inside a git hook,
28/// a `filter-branch` or a rebase has `GIT_DIR` and `GIT_INDEX_FILE` set, and they win over
29/// `-C`: `GIT_INDEX_FILE=<another repo's index> git -C here ls-files` lists the *other*
30/// repository's files and reports nothing about this one. Every path here would then look
31/// untracked, and looking untracked is what makes a directory deletable. It fails silently and
32/// in the dangerous direction, which is the combination that has to be designed out.
33const AMBIENT_GIT_ENV: [&str; 8] = [
34    "GIT_DIR",
35    "GIT_INDEX_FILE",
36    "GIT_WORK_TREE",
37    "GIT_COMMON_DIR",
38    "GIT_OBJECT_DIRECTORY",
39    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
40    "GIT_CEILING_DIRECTORIES",
41    "GIT_NAMESPACE",
42];
43
44/// A `git` invocation pointed at `dir`, with everything ambient that could change its answer
45/// shut out.
46///
47/// Two families of variable, and they are dangerous for different reasons.
48///
49/// [`AMBIENT_GIT_ENV`] redirects git at another *repository*, which is the failure described
50/// above it.
51///
52/// The locale redirects git's *prose*, and repo mode reads git's prose because `git clean` has
53/// no `-z` and no porcelain format — it prints `Would remove <path>`, and that sentence is
54/// translated. Measured, not assumed: under `LANGUAGE=de` the same command prints
55/// `Würde … löschen`, and a parser looking for `Would remove` finds nothing at all. So a repo
56/// full of build output would report as already clean. `LC_ALL=C` wins over `LANGUAGE`
57/// (measured), and `LANGUAGE` is cleared as well because it is the one variable that otherwise
58/// wins over `LANG`.
59pub(crate) fn git(dir: &Path) -> Command {
60    let mut command = Command::new("git");
61    command.arg("-C").arg(dir).stdin(Stdio::null());
62    for variable in AMBIENT_GIT_ENV {
63        command.env_remove(variable);
64    }
65    command.env("LC_ALL", "C").env("LANGUAGE", "");
66    command
67}
68
69/// Whether `dir` is the root of a git work tree.
70///
71/// A `.git` that is a *file* rather than a directory is a linked work tree or a submodule, and
72/// is just as much a work tree root as the ordinary case.
73#[must_use]
74pub fn is_work_tree_root(dir: &Path) -> bool {
75    dir.join(".git").symlink_metadata().is_ok()
76}
77
78/// What the `.git` at a work tree root actually is.
79///
80/// [`is_work_tree_root`] deliberately collapses all three, which is right for the safety model's
81/// question — "is there a checkout here" — and wrong for the only question where the difference
82/// decides whether a directory is disposable.
83///
84/// **A linked work tree is the one kind that holds no history of its own.** Its commits go to the
85/// repository's object store and its branch is an ordinary ref there, so deleting the directory
86/// costs the checked-out files and nothing else — verified rather than assumed: a commit made in
87/// a linked work tree is still readable through its branch after the directory is removed
88/// outright. A [`Repository`](Self::Repository) *is* the object store, and a
89/// [`Submodule`](Self::Submodule) is a checkout the superproject's index points at, which is a
90/// different promise from "somewhere to do some work".
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Checkout {
93    /// `.git` is a directory: the repository itself, holding every object and ref under it.
94    Repository,
95    /// `.git` is a file pointing into another repository's `worktrees/`, which is where the
96    /// objects and the branch actually live.
97    Linked,
98    /// `.git` is a file pointing into a superproject's `modules/`.
99    Submodule,
100}
101
102/// Which kind of checkout is rooted at `dir`, or `None` if there is not one.
103///
104/// **Asked of git rather than read out of the `.git` file**, for this module's founding reason:
105/// the file's `gitdir:` line has to be resolved relative to the work tree, can be absolute or
106/// relative, and points somewhere whose *shape* is what distinguishes a linked work tree from a
107/// submodule. A hand-rolled reader gets that wrong quietly, and quietly is the failure mode a
108/// cleaner cannot afford — here it would mean reading a submodule as disposable.
109///
110/// The discriminator is git's own: `--git-dir` and `--git-common-dir` are equal for a repository
111/// and for a submodule, and a linked work tree is the only case where the first sits at
112/// `<common>/worktrees/<name>`. Measured against all three, rather than inferred from the
113/// documentation.
114///
115/// Fails toward [`Repository`](Checkout::Repository) — never toward `Linked` — whenever git
116/// cannot be run or will not answer. Everything downstream reads `Linked` as permission.
117#[must_use]
118pub fn checkout_at(dir: &Path) -> Option<Checkout> {
119    if !is_work_tree_root(dir) {
120        return None;
121    }
122    let Ok(output) = git(dir)
123        .args(["rev-parse", "--path-format=absolute", "--git-dir"])
124        .arg("--git-common-dir")
125        .output()
126    else {
127        return Some(Checkout::Repository);
128    };
129    if !output.status.success() {
130        return Some(Checkout::Repository);
131    }
132    let text = String::from_utf8_lossy(&output.stdout);
133    let mut lines = text.lines();
134    let (Some(git_dir), Some(common)) = (lines.next(), lines.next()) else {
135        return Some(Checkout::Repository);
136    };
137    let git_dir = Path::new(git_dir.trim());
138    // `<common>/worktrees/<name>`, checked a component at a time rather than by string prefix, so
139    // a repository that happens to live under a directory called `worktrees` cannot match.
140    let linked = git_dir
141        .parent()
142        .is_some_and(|holder| holder.file_name() == Some(OsStr::new("worktrees")))
143        && git_dir.parent().and_then(Path::parent) == Some(Path::new(common.trim()));
144    Some(if linked {
145        Checkout::Linked
146    } else if git_dir
147        .components()
148        .any(|part| part.as_os_str() == OsStr::new("modules"))
149    {
150        Checkout::Submodule
151    } else {
152        Checkout::Repository
153    })
154}
155
156/// Whether the work tree at `dir` holds work that exists nowhere else.
157///
158/// `git status --porcelain` is empty exactly when there is nothing uncommitted and nothing
159/// untracked — and it says nothing about *ignored* files, which is what makes this usable here at
160/// all: a work tree carrying 4 GiB of `node_modules` reads clean, because that is precisely the
161/// content this program exists to regenerate rather than preserve.
162///
163/// Errs toward "not clean" on every failure. The answer is the gate on an irreversible removal.
164#[must_use]
165pub fn is_clean(dir: &Path) -> bool {
166    git(dir)
167        .args(["status", "--porcelain"])
168        .output()
169        .is_ok_and(|output| output.status.success() && output.stdout.is_empty())
170}
171
172/// Whether `HEAD` names a branch rather than sitting detached.
173///
174/// **The one way removing a linked work tree can lose a commit.** A commit made on a detached
175/// `HEAD` is reachable only through that work tree's own `HEAD`, so once the directory is gone
176/// and the administrative files are pruned nothing refers to it and `gc` will collect it —
177/// measured, not reasoned about: `git fsck --unreachable` lists it immediately after. A commit on
178/// a branch is reachable through an ordinary ref in the repository and survives.
179///
180/// Errs toward "detached" on every failure, which is the answer that keeps the directory.
181#[must_use]
182pub fn head_on_branch(dir: &Path) -> bool {
183    git(dir)
184        .args(["symbolic-ref", "--quiet", "HEAD"])
185        .output()
186        .is_ok_and(|output| output.status.success())
187}
188
189/// The nearest ancestor of `from`, `from` itself included, that is a git work tree root.
190///
191/// This is git's own rule, which is what makes a checkout inside another checkout behave: the
192/// inner repository is the authority for everything under it, and the outer one has no say.
193#[must_use]
194pub fn discover(from: &Path) -> Option<PathBuf> {
195    let mut cursor = Some(from);
196    while let Some(dir) = cursor {
197        if is_work_tree_root(dir) {
198            return Some(dir.to_path_buf());
199        }
200        cursor = dir.parent();
201    }
202    None
203}
204
205/// One git work tree, with the set of paths its index tracks.
206#[derive(Debug)]
207pub struct WorkTree {
208    root: PathBuf,
209    /// Every tracked path, relative to `root`, as raw bytes and sorted. Sorted so a "is
210    /// anything under this directory tracked" question is a binary search rather than a scan
211    /// of an index that can hold hundreds of thousands of entries.
212    tracked: Vec<Box<[u8]>>,
213}
214
215impl WorkTree {
216    /// Reads the index of the work tree rooted at `root`.
217    ///
218    /// # Errors
219    ///
220    /// If git cannot be run at all, or refuses to list the index.
221    pub fn open(root: &Path) -> Result<Self, GitError> {
222        let mut command = git(root);
223        // `--full-name` pins the paths to the work tree root rather than to a working
224        // directory, and `-z` gives them raw: git quotes and escapes any other way, and a
225        // path this module misreads is a path it wrongly believes is untracked.
226        command.args(["ls-files", "-z", "--full-name"]);
227
228        let output = command
229            .output()
230            .map_err(|err| GitError::Run(root.to_path_buf(), err))?;
231        if !output.status.success() {
232            return Err(GitError::Refused(
233                root.to_path_buf(),
234                String::from_utf8_lossy(&output.stderr).trim().to_owned(),
235            ));
236        }
237
238        // Sorted here rather than trusted to arrive sorted. The index is sorted and `ls-files`
239        // walks it in order, but the binary search below is a safety property and it should
240        // not rest on an implementation detail of another program.
241        let mut tracked: Vec<Box<[u8]>> = output
242            .stdout
243            .split(|byte| *byte == 0)
244            .filter(|path| !path.is_empty())
245            // Composed here as well as on the query side, so the two agree. See `comparable`.
246            .map(|path| Box::from(comparable(path.to_vec())))
247            .collect();
248        tracked.sort_unstable();
249        tracked.dedup();
250
251        Ok(Self {
252            root: root.to_path_buf(),
253            tracked,
254        })
255    }
256
257    /// The work tree's root directory.
258    #[must_use]
259    pub fn root(&self) -> &Path {
260        &self.root
261    }
262
263    /// How many paths the index tracks.
264    #[must_use]
265    pub fn tracked(&self) -> usize {
266        self.tracked.len()
267    }
268
269    /// Whether anything tracked lives at or below `dir`.
270    ///
271    /// Answers `true` for a path this work tree cannot express, because every caller is asking
272    /// in order to decide whether deleting `dir` is safe, and "I could not tell" has to read as
273    /// "do not touch it".
274    #[must_use]
275    pub fn holds_tracked_path(&self, dir: &Path) -> bool {
276        let Ok(relative) = dir.strip_prefix(&self.root) else {
277            return true;
278        };
279        let Some(mut prefix) = as_index_path(relative) else {
280            return true;
281        };
282        if prefix.is_empty() {
283            // The work tree root itself: anything at all is under it.
284            return !self.tracked.is_empty();
285        }
286        // A gitlink — a submodule — is a tracked entry for the directory itself rather than for
287        // anything beneath it, so the exact path has to be checked as well as the prefix.
288        if self.tracked.binary_search(&prefix.clone().into()).is_ok() {
289            return true;
290        }
291        prefix.push(b'/');
292        let at = self
293            .tracked
294            .partition_point(|path| path.as_ref() < prefix.as_slice());
295        self.tracked
296            .get(at)
297            .is_some_and(|path| path.starts_with(&prefix))
298    }
299}
300
301/// A work-tree-relative path in the form git's index uses: `/`-separated, raw bytes.
302///
303/// Returns `None` for anything that is not a plain sequence of normal components, which cannot
304/// be compared against index entries and which callers treat as "assume it is tracked".
305fn as_index_path(relative: &Path) -> Option<Vec<u8>> {
306    let mut out = Vec::new();
307    for component in relative.components() {
308        let Component::Normal(segment) = component else {
309            return None;
310        };
311        if !out.is_empty() {
312            out.push(b'/');
313        }
314        out.extend_from_slice(segment.as_encoded_bytes());
315    }
316    Some(comparable(out))
317}
318
319/// The form both sides of the tracked-path comparison have to be in.
320///
321/// The two sides disagree about Unicode normalization, and on macOS they disagree *by default*.
322/// `readdir` on APFS hands back the bytes a name was created with, which for anything touched by
323/// an HFS-era tool is decomposed; git sets `core.precomposeunicode` on macOS, so `git add`
324/// composes the name before storing it. A directory called `café` is then `cafe\xcc\x81` on disk
325/// and `caf\xc3\xa9` in the index — measured, not assumed — and a raw byte comparison misses.
326///
327/// That miss is the dangerous direction: a directory that *does* hold a tracked file looks
328/// untracked, and looking untracked is what makes it eligible for deletion. It is reachable
329/// without anyone doing anything exotic, since only one component of the path has to be
330/// non-ASCII: `docs/café/build` matched by an ordinary `build/` ignore rule is enough.
331///
332/// So both sides are composed before they are compared. Where two names on the same filesystem
333/// differ only by normalization — possible on Linux, which normalizes nothing — this conflates
334/// them, and conflating errs toward "tracked", which is the side to err on. Anything that is not
335/// UTF-8 cannot be normalized and is compared as it stands, which is correct: nothing converts
336/// it on either side either.
337fn comparable(path: Vec<u8>) -> Vec<u8> {
338    if path.is_ascii() {
339        return path;
340    }
341    match str::from_utf8(&path) {
342        Ok(text) => text.nfc().collect::<String>().into_bytes(),
343        Err(_) => path,
344    }
345}
346
347/// Why a work tree could not be consulted.
348#[derive(Debug)]
349#[non_exhaustive]
350pub enum GitError {
351    /// git could not be run — most often because it is not installed.
352    Run(PathBuf, io::Error),
353    /// git ran and refused, carrying whatever it said about why.
354    Refused(PathBuf, String),
355}
356
357impl fmt::Display for GitError {
358    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359        match self {
360            Self::Run(root, err) => write!(
361                f,
362                "could not run git in {}, so nothing there can be judged safe to remove: {err}",
363                root.display()
364            ),
365            Self::Refused(root, message) => write!(
366                f,
367                "git would not list the index of {}, so nothing there can be judged safe to \
368                 remove: {message}",
369                root.display()
370            ),
371        }
372    }
373}
374
375impl std::error::Error for GitError {
376    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
377        match self {
378            Self::Run(_, err) => Some(err),
379            Self::Refused(..) => None,
380        }
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::{
387        Checkout, WorkTree, as_index_path, checkout_at, comparable, git, head_on_branch, is_clean,
388    };
389    use std::path::{Path, PathBuf};
390
391    /// A repository with one commit, made with git rather than by writing `.git` by hand — the
392    /// shapes this module distinguishes are git's, and a fixture that spelled them itself would
393    /// be asserting that the fixture agrees with the code.
394    fn repo(at: &Path) {
395        std::fs::create_dir_all(at).unwrap();
396        run(at, &["init", "--quiet", "."]);
397        run(at, &["config", "user.email", "test@example.com"]);
398        run(at, &["config", "user.name", "test"]);
399        std::fs::write(at.join("tracked.txt"), "content").unwrap();
400        run(at, &["add", "."]);
401        run(at, &["commit", "--quiet", "-m", "first"]);
402    }
403
404    /// `git worktree add --quiet`, which every fixture below needs and which does not fit on
405    /// one line spelled out at each call.
406    fn worktree(main: &Path, args: &[&str]) {
407        let mut all = vec!["worktree", "add", "--quiet"];
408        all.extend_from_slice(args);
409        run(main, &all);
410    }
411
412    fn run(at: &Path, args: &[&str]) {
413        let output = git(at).args(args).output().unwrap();
414        assert!(
415            output.status.success(),
416            "git {args:?} in {}: {}",
417            at.display(),
418            String::from_utf8_lossy(&output.stderr)
419        );
420    }
421
422    #[test]
423    fn a_linked_work_tree_is_told_apart_from_the_repository_and_from_a_submodule() {
424        // The distinction the whole feature rests on, and none of the three can be told apart by
425        // whether `.git` is a file: a submodule has one too.
426        let tmp = tempfile::TempDir::new().unwrap();
427        let base = std::fs::canonicalize(tmp.path()).unwrap();
428        let main = base.join("main");
429        let inner = base.join("inner");
430        repo(&main);
431        repo(&inner);
432        worktree(&main, &["../linked", "-b", "feature"]);
433        run(
434            &main,
435            &[
436                "-c",
437                "protocol.file.allow=always",
438                "submodule",
439                "--quiet",
440                "add",
441                inner.to_str().unwrap(),
442                "vendored",
443            ],
444        );
445        run(&main, &["commit", "--quiet", "-m", "vendored"]);
446
447        assert_eq!(checkout_at(&main), Some(Checkout::Repository));
448        assert_eq!(checkout_at(&base.join("linked")), Some(Checkout::Linked));
449        assert_eq!(
450            checkout_at(&main.join("vendored")),
451            Some(Checkout::Submodule),
452            "a submodule was read as a disposable work tree"
453        );
454        // Not a checkout at all, which is every other directory on the disk.
455        assert_eq!(checkout_at(&base), None);
456    }
457
458    #[test]
459    fn a_work_tree_is_clean_despite_ignored_build_output_and_dirty_with_anything_else() {
460        // The property that makes this usable: the directories pristine exists to reclaim are
461        // exactly the ones that must not count as work.
462        let tmp = tempfile::TempDir::new().unwrap();
463        let base = std::fs::canonicalize(tmp.path()).unwrap();
464        let main = base.join("main");
465        repo(&main);
466        std::fs::write(main.join(".gitignore"), "node_modules/\n").unwrap();
467        run(&main, &["add", ".gitignore"]);
468        run(&main, &["commit", "--quiet", "-m", "ignore"]);
469
470        std::fs::create_dir_all(main.join("node_modules/dep")).unwrap();
471        std::fs::write(main.join("node_modules/dep/index.js"), "x").unwrap();
472        assert!(
473            is_clean(&main),
474            "4 GiB of node_modules must not read as work that exists nowhere else"
475        );
476
477        // An untracked file that nothing ignores is work, and so is an edit to a tracked one.
478        std::fs::write(main.join("notes.md"), "only copy").unwrap();
479        assert!(!is_clean(&main));
480        std::fs::remove_file(main.join("notes.md")).unwrap();
481        assert!(is_clean(&main));
482        std::fs::write(main.join("tracked.txt"), "edited").unwrap();
483        assert!(!is_clean(&main));
484    }
485
486    #[test]
487    fn a_detached_head_is_refused_because_its_commits_are_reachable_from_nothing_else() {
488        // Measured in #656's follow-up: a commit made on a detached HEAD in a linked work tree is
489        // listed by `git fsck --unreachable` the moment the directory is removed and pruned. On a
490        // branch it survives, because the branch is an ordinary ref in the repository.
491        let tmp = tempfile::TempDir::new().unwrap();
492        let base = std::fs::canonicalize(tmp.path()).unwrap();
493        let main = base.join("main");
494        repo(&main);
495        worktree(&main, &["../onbranch", "-b", "feature"]);
496        worktree(&main, &["--detach", "../loose"]);
497
498        assert!(head_on_branch(&base.join("onbranch")));
499        assert!(!head_on_branch(&base.join("loose")));
500    }
501
502    #[test]
503    fn every_answer_that_decides_a_deletion_fails_toward_keeping_the_directory() {
504        // A directory git will not speak for at all. `is_clean` and `head_on_branch` gate an
505        // irreversible removal, so silence has to read as "do not touch it" — the same discipline
506        // the tier-two fallback keeps when a work tree will not answer.
507        let tmp = tempfile::TempDir::new().unwrap();
508        let base = std::fs::canonicalize(tmp.path()).unwrap();
509        assert!(!is_clean(&base), "a directory git disowns read as clean");
510        assert!(!head_on_branch(&base));
511        // And a checkout it cannot classify is the kind nothing is allowed to remove.
512        assert_eq!(checkout_at(&base), None);
513    }
514
515    /// Mirrors what [`WorkTree::open`] does to `git ls-files` output, so a fixture and a real
516    /// index are the same shape.
517    fn work_tree(root: &str, tracked: &[&str]) -> WorkTree {
518        let mut tracked: Vec<Box<[u8]>> = tracked
519            .iter()
520            .map(|path| Box::from(comparable(path.as_bytes().to_vec())))
521            .collect();
522        tracked.sort_unstable();
523        WorkTree {
524            root: PathBuf::from(root),
525            tracked,
526        }
527    }
528
529    #[test]
530    fn a_tracked_file_at_any_depth_bars_the_directory_above_it() {
531        let tree = work_tree("/repo", &["out/deep/deeper/keep.txt", "src/main.rs"]);
532        assert!(tree.holds_tracked_path(Path::new("/repo/out")));
533        assert!(tree.holds_tracked_path(Path::new("/repo/out/deep")));
534        assert!(!tree.holds_tracked_path(Path::new("/repo/out/other")));
535    }
536
537    #[test]
538    fn a_name_that_merely_starts_the_same_is_not_a_match() {
539        // `-`, `.` and `/` are 0x2d, 0x2e and 0x2f, so these three sort either side of the
540        // `out/` prefix and a sloppy comparison picks up the wrong ones.
541        let tree = work_tree("/repo", &["out-takes/a.txt", "out.txt", "outer/b.txt"]);
542        assert!(!tree.holds_tracked_path(Path::new("/repo/out")));
543        assert!(tree.holds_tracked_path(Path::new("/repo/out-takes")));
544        assert!(tree.holds_tracked_path(Path::new("/repo/outer")));
545    }
546
547    #[test]
548    fn a_gitlink_bars_the_directory_it_names() {
549        // A submodule is one index entry for the directory itself, with nothing under it.
550        let tree = work_tree("/repo", &["vendor/sub"]);
551        assert!(tree.holds_tracked_path(Path::new("/repo/vendor/sub")));
552        assert!(tree.holds_tracked_path(Path::new("/repo/vendor")));
553    }
554
555    #[test]
556    fn a_path_outside_the_work_tree_is_assumed_tracked() {
557        let tree = work_tree("/repo", &["src/main.rs"]);
558        assert!(tree.holds_tracked_path(Path::new("/elsewhere/out")));
559    }
560
561    #[test]
562    fn an_empty_index_holds_nothing() {
563        let tree = work_tree("/repo", &[]);
564        assert!(!tree.holds_tracked_path(Path::new("/repo")));
565        assert!(!tree.holds_tracked_path(Path::new("/repo/out")));
566    }
567
568    #[test]
569    fn a_decomposed_path_matches_the_composed_one_git_stored() {
570        // `café` as git records it on macOS against `café` as `readdir` hands it back. Without
571        // composing both sides these are different byte strings, the search misses, and a
572        // directory holding a tracked file is reported as free to delete.
573        let tree = work_tree("/repo", &["caf\u{e9}/build/keep.txt"]);
574        assert!(tree.holds_tracked_path(Path::new("/repo/cafe\u{301}/build")));
575        assert!(tree.holds_tracked_path(Path::new("/repo/caf\u{e9}/build")));
576        assert!(!tree.holds_tracked_path(Path::new("/repo/cafe\u{301}/other")));
577    }
578
579    #[test]
580    fn index_paths_are_slash_separated_and_reject_anything_exotic() {
581        assert_eq!(
582            as_index_path(Path::new("a/b/c")).unwrap(),
583            b"a/b/c".to_vec()
584        );
585        assert_eq!(as_index_path(Path::new("")).unwrap(), Vec::<u8>::new());
586        assert!(as_index_path(Path::new("../a")).is_none());
587    }
588}