use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::path::{Component, Path, PathBuf};
use gix::bstr::BStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PorcelainCode {
pub index: char,
pub worktree: char,
}
impl PorcelainCode {
pub const BLANK: Self = Self {
index: ' ',
worktree: ' ',
};
pub const CLEAN: Self = Self {
index: '○',
worktree: ' ',
};
pub const UNTRACKED: Self = Self {
index: '?',
worktree: '?',
};
pub const IGNORED: Self = Self {
index: '·',
worktree: '·',
};
pub const MODIFIED_WORKTREE: Self = Self {
index: ' ',
worktree: '●',
};
pub const DELETED_WORKTREE: Self = Self {
index: ' ',
worktree: '▽',
};
pub const TYPE_CHANGE_WORKTREE: Self = Self {
index: ' ',
worktree: '≈',
};
pub const RENAMED: Self = Self {
index: '→',
worktree: ' ',
};
pub const COPIED: Self = Self {
index: '⇉',
worktree: ' ',
};
pub const RENAMED_WORKTREE: Self = Self {
index: ' ',
worktree: '→',
};
pub const COPIED_WORKTREE: Self = Self {
index: ' ',
worktree: '⇉',
};
pub const UNMERGED: Self = Self {
index: '✘',
worktree: '✘',
};
pub const DIRTY_SUBTREE: Self = Self {
index: '⋯',
worktree: ' ',
};
#[must_use]
pub const fn with_index(self, idx: char) -> Self {
Self {
index: idx,
worktree: self.worktree,
}
}
#[must_use]
pub const fn glyph(self) -> char {
if self.worktree == ' ' {
self.index
} else {
self.worktree
}
}
}
pub struct Snapshot {
pub root: PathBuf,
pub statuses: HashMap<PathBuf, PorcelainCode>,
dirty_ancestors: HashSet<PathBuf>,
tracked_prefixes: HashSet<PathBuf>,
submodule_roots: HashSet<PathBuf>,
repo: gix::Repository,
excludes: RefCell<gix::worktree::Stack>,
}
impl std::fmt::Debug for Snapshot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Snapshot")
.field("root", &self.root)
.field("statuses", &self.statuses)
.field("dirty_ancestors", &self.dirty_ancestors)
.field("tracked_prefixes_len", &self.tracked_prefixes.len())
.field("submodule_roots", &self.submodule_roots)
.finish_non_exhaustive()
}
}
impl Snapshot {
#[must_use]
pub fn lookup(&self, path: &Path) -> PorcelainCode {
let Some(rel) = self.relativize(path) else {
return PorcelainCode::BLANK;
};
self.lookup_rel(&rel, || {
std::fs::symlink_metadata(path).ok().map(|m| m.is_dir())
})
}
fn lookup_rel<F: FnOnce() -> Option<bool>>(
&self,
rel: &Path,
kind_resolver: F,
) -> PorcelainCode {
if rel.starts_with(".git") {
return PorcelainCode::BLANK;
}
let tracked_prefix = self.tracked_prefixes.contains(rel);
if let Some(code) = self.statuses.get(rel).copied()
&& !(tracked_prefix && is_collapsed_dir(code))
{
return code;
}
if !tracked_prefix {
for ancestor in iter_ancestors(rel) {
if let Some(code) = self.statuses.get(ancestor).copied()
&& (code == PorcelainCode::UNTRACKED || code == PorcelainCode::IGNORED)
{
return code;
}
if self.submodule_roots.contains(ancestor) {
return PorcelainCode::CLEAN;
}
}
}
if tracked_prefix {
return PorcelainCode::CLEAN;
}
if self.path_is_excluded(rel, kind_resolver()) {
PorcelainCode::IGNORED
} else {
PorcelainCode::UNTRACKED
}
}
fn path_is_excluded(&self, rel: &Path, is_directory: Option<bool>) -> bool {
is_directory.map_or_else(
|| self.check_excluded(rel, true) || self.check_excluded(rel, false),
|d| self.check_excluded(rel, d),
)
}
fn check_excluded(&self, rel: &Path, is_directory: bool) -> bool {
let mode = if is_directory {
gix::index::entry::Mode::DIR
} else {
gix::index::entry::Mode::FILE
};
self.excludes
.borrow_mut()
.at_path(rel, Some(mode), &self.repo.objects)
.is_ok_and(|p| p.is_excluded())
}
#[must_use]
pub fn is_ignored(&self, path: &Path) -> bool {
self.lookup(path) == PorcelainCode::IGNORED
}
#[must_use]
pub fn is_ignored_with_kind(&self, path: &Path, is_directory: bool) -> bool {
self.relativize(path).is_some_and(|rel| {
self.lookup_rel(&rel, || Some(is_directory)) == PorcelainCode::IGNORED
})
}
#[must_use]
pub fn has_dirty_descendants(&self, path: &Path) -> bool {
let Some(rel) = self.relativize(path) else {
return false;
};
self.dirty_ancestors.contains(&rel)
}
#[must_use]
pub fn display_code_for(&self, path: &Path, is_directory: bool) -> PorcelainCode {
let Some(rel) = self.relativize(path) else {
return PorcelainCode::BLANK;
};
let direct = self.lookup_rel(&rel, || Some(is_directory));
if direct == PorcelainCode::CLEAN && is_directory && self.dirty_ancestors.contains(&rel) {
PorcelainCode::DIRTY_SUBTREE
} else if direct == PorcelainCode::UNTRACKED
&& is_directory
&& !subtree_contains_file(&self.root.join(&rel))
{
PorcelainCode::BLANK
} else {
direct
}
}
fn relativize(&self, path: &Path) -> Option<PathBuf> {
let abs = std::path::absolute(path).ok();
let candidate: &Path = abs.as_deref().unwrap_or(path);
if let Ok(rel) = candidate.strip_prefix(&self.root) {
let has_dotdot = rel.components().any(|c| matches!(c, Component::ParentDir));
if !has_dotdot {
return Some(rel.to_path_buf());
}
}
let resolved = match (path.parent(), path.file_name()) {
(Some(parent), Some(name)) => {
let to_canon: &Path = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
std::fs::canonicalize(to_canon).ok()?.join(name)
}
_ => std::fs::canonicalize(path).ok()?,
};
resolved
.strip_prefix(&self.root)
.ok()
.map(Path::to_path_buf)
}
}
#[derive(Debug, Default)]
pub struct SnapshotCache {
by_scope: HashMap<PathBuf, Option<PathBuf>>,
by_workdir: HashMap<PathBuf, Option<Snapshot>>,
}
impl SnapshotCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn for_target(&mut self, target: &Path) -> Option<&Snapshot> {
let scope = normalize_existing(&scope_dir(target))?;
if !self.by_scope.contains_key(&scope) {
self.populate_scope(&scope);
}
let workdir = self.by_scope.get(&scope)?.clone()?;
self.by_workdir.get(&workdir)?.as_ref()
}
fn populate_scope(&mut self, scope: &Path) {
let resolved = gix::discover(scope).ok().and_then(|repo| {
repo.workdir()
.and_then(normalize_existing)
.map(|wd| (repo, wd))
});
let Some((repo, workdir)) = resolved else {
self.by_scope.insert(scope.to_path_buf(), None);
return;
};
self.by_scope
.insert(scope.to_path_buf(), Some(workdir.clone()));
if self.by_workdir.contains_key(&workdir) {
return;
}
let snap = build_snapshot(repo, workdir.clone());
self.by_workdir.insert(workdir, snap);
}
}
fn build_snapshot(repo: gix::Repository, workdir: PathBuf) -> Option<Snapshot> {
let statuses = collect_statuses(&repo).unwrap_or_default();
assemble_snapshot(repo, workdir, statuses)
}
fn scope_dir(target: &Path) -> PathBuf {
if target.is_dir() {
return target.to_path_buf();
}
let parent = target.parent().filter(|p| !p.as_os_str().is_empty());
parent.map_or_else(|| PathBuf::from("."), Path::to_path_buf)
}
fn normalize_existing(path: &Path) -> Option<PathBuf> {
std::fs::canonicalize(path)
.ok()
.or_else(|| std::path::absolute(path).ok())
}
fn rela_to_pathbuf(b: &BStr) -> PathBuf {
PathBuf::from(OsStr::from_bytes(b.as_ref()))
}
#[must_use]
pub fn discover(start: &Path) -> Option<Snapshot> {
let repo = gix::discover(start).ok()?;
let workdir = normalize_existing(repo.workdir()?)?;
let statuses = collect_statuses(&repo).unwrap_or_default();
assemble_snapshot(repo, workdir, statuses)
}
fn assemble_snapshot(
repo: gix::Repository,
workdir: PathBuf,
statuses: HashMap<PathBuf, PorcelainCode>,
) -> Option<Snapshot> {
let index = repo.index_or_empty().ok()?;
let excludes = repo
.excludes(
&index,
None,
gix::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped,
)
.ok()?
.detach();
let dirty_ancestors = compute_dirty_ancestors(&statuses, &workdir);
let (tracked_prefixes, submodule_roots) = collect_index_prefixes(&index);
Some(Snapshot {
root: workdir,
statuses,
dirty_ancestors,
tracked_prefixes,
submodule_roots,
repo,
excludes: RefCell::new(excludes),
})
}
fn collect_index_prefixes(index: &gix::index::File) -> (HashSet<PathBuf>, HashSet<PathBuf>) {
let mut prefixes: HashSet<PathBuf> = HashSet::new();
let mut submodules: HashSet<PathBuf> = HashSet::new();
prefixes.insert(PathBuf::new());
for entry in index.entries() {
let path = rela_to_pathbuf(entry.path(index));
if entry.mode.contains(gix::index::entry::Mode::COMMIT) {
submodules.insert(path.clone());
}
for ancestor in path.ancestors() {
if !prefixes.insert(ancestor.to_path_buf()) {
break;
}
}
}
(prefixes, submodules)
}
fn compute_dirty_ancestors(
statuses: &HashMap<PathBuf, PorcelainCode>,
workdir: &Path,
) -> HashSet<PathBuf> {
let mut out = HashSet::new();
for (path, code) in statuses {
if *code == PorcelainCode::CLEAN || *code == PorcelainCode::IGNORED {
continue;
}
if *code == PorcelainCode::UNTRACKED && is_empty_dir(&workdir.join(path)) {
continue;
}
for ancestor in iter_ancestors(path) {
out.insert(ancestor.to_path_buf());
}
}
out
}
fn is_collapsed_dir(code: PorcelainCode) -> bool {
code == PorcelainCode::UNTRACKED || code == PorcelainCode::IGNORED
}
fn iter_ancestors(rel: &Path) -> impl Iterator<Item = &Path> {
rel.ancestors().skip(1)
}
fn subtree_contains_file(dir: &Path) -> bool {
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let Ok(read) = std::fs::read_dir(¤t) else {
return true;
};
for entry in read {
match entry.and_then(|e| e.file_type().map(|ft| (ft, e))) {
Ok((ft, e)) if ft.is_dir() => stack.push(e.path()),
_ => return true,
}
}
}
false
}
fn is_empty_dir(abs: &Path) -> bool {
abs.symlink_metadata().is_ok_and(|m| m.is_dir()) && !subtree_contains_file(abs)
}
fn collect_statuses(
repo: &gix::Repository,
) -> Result<HashMap<PathBuf, PorcelainCode>, Box<dyn std::error::Error>> {
let mut out: HashMap<PathBuf, PorcelainCode> = HashMap::new();
let platform = repo
.status(gix::progress::Discard)?
.untracked_files(gix::status::UntrackedFiles::Collapsed)
.index_worktree_rewrites(gix::diff::Rewrites::default())
.dirwalk_options(|opts| {
opts.emit_ignored(Some(gix::dir::walk::EmissionMode::CollapseDirectory))
.emit_collapsed(Some(
gix::dir::walk::CollapsedEntriesEmissionMode::OnStatusMismatch,
))
});
let iter = platform.into_iter(Vec::new())?;
for item in iter {
let item = item?;
match item {
gix::status::Item::IndexWorktree(iw) => {
handle_index_worktree(&iw, &mut out);
}
gix::status::Item::TreeIndex(change) => {
handle_tree_index(&change, &mut out);
}
}
}
Ok(out)
}
fn handle_index_worktree(
item: &gix::status::index_worktree::Item,
out: &mut HashMap<PathBuf, PorcelainCode>,
) {
use gix::status::plumbing::index_as_worktree::{Change as IwChange, EntryStatus};
match item {
gix::status::index_worktree::Item::Modification {
rela_path, status, ..
} => {
let path = rela_to_pathbuf(rela_path.as_ref());
let code = match status {
EntryStatus::Change(IwChange::Removed) => PorcelainCode::DELETED_WORKTREE,
EntryStatus::Change(IwChange::Type { .. }) => PorcelainCode::TYPE_CHANGE_WORKTREE,
EntryStatus::Conflict { .. } => PorcelainCode::UNMERGED,
_ => PorcelainCode::MODIFIED_WORKTREE,
};
let prev = out.get(&path).copied();
out.insert(path, merge(prev, code));
}
gix::status::index_worktree::Item::DirectoryContents { entry, .. } => {
let path = rela_to_pathbuf(entry.rela_path.as_ref());
let code = match entry.status {
gix::dir::entry::Status::Ignored(_) => PorcelainCode::IGNORED,
_ => PorcelainCode::UNTRACKED,
};
out.insert(path, code);
}
gix::status::index_worktree::Item::Rewrite {
source,
dirwalk_entry,
copy,
..
} => {
let path = rela_to_pathbuf(dirwalk_entry.rela_path.as_ref());
let code = rewrite_code(*copy);
let prev = out.get(&path).copied();
out.insert(path, merge(prev, code));
if !*copy
&& let gix::status::index_worktree::RewriteSource::RewriteFromIndex {
source_rela_path,
..
} = source
{
let source_path = rela_to_pathbuf(source_rela_path.as_ref());
let prev = out.get(&source_path).copied();
out.insert(source_path, merge(prev, PorcelainCode::DELETED_WORKTREE));
}
}
}
}
fn handle_tree_index(change: &gix::diff::index::Change, out: &mut HashMap<PathBuf, PorcelainCode>) {
let (rel, idx_char) = match change {
gix::diff::index::Change::Addition { location, .. } => (location, '+'),
gix::diff::index::Change::Deletion { location, .. } => (location, '▽'),
gix::diff::index::Change::Modification { location, .. } => (location, '●'),
gix::diff::index::Change::Rewrite { location, .. } => (location, '→'),
};
let path = rela_to_pathbuf(rel);
let existing = out.get(&path).copied().unwrap_or(PorcelainCode::BLANK);
out.insert(path, existing.with_index(idx_char));
}
const fn rewrite_code(copy: bool) -> PorcelainCode {
if copy {
PorcelainCode::COPIED_WORKTREE
} else {
PorcelainCode::RENAMED_WORKTREE
}
}
fn merge(prev: Option<PorcelainCode>, next: PorcelainCode) -> PorcelainCode {
prev.map_or(next, |p| PorcelainCode {
index: if p.index == ' ' { next.index } else { p.index },
worktree: if next.worktree == ' ' {
p.worktree
} else {
next.worktree
},
})
}
#[cfg(test)]
#[expect(
clippy::significant_drop_tightening,
reason = "TestRepo intentionally holds GIT_LOCK and TempDir for the entire test scope so parallel git subprocesses can't share index state via environment"
)]
mod tests {
use super::{PorcelainCode, Snapshot, SnapshotCache, discover, merge};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, MutexGuard};
use tempfile::TempDir;
static GIT_LOCK: Mutex<()> = Mutex::new(());
fn run_git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("HOME", dir)
.status()
.unwrap();
assert!(status.success(), "git {args:?} failed");
}
struct TestRepo {
_lock: MutexGuard<'static, ()>,
dir: TempDir,
}
impl TestRepo {
fn new() -> Self {
let lock = GIT_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = tempfile::tempdir().unwrap();
run_git(dir.path(), &["init", "-q", "-b", "main"]);
run_git(dir.path(), &["config", "user.email", "t@example.invalid"]);
run_git(dir.path(), &["config", "user.name", "t"]);
Self { _lock: lock, dir }
}
fn root(&self) -> &Path {
self.dir.path()
}
fn write(&self, rel: &str, content: &[u8]) -> &Self {
let p = self.dir.path().join(rel);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, content).unwrap();
self
}
fn create_dir(&self, rel: &str) -> &Self {
std::fs::create_dir_all(self.dir.path().join(rel)).unwrap();
self
}
fn remove_file(&self, rel: &str) -> &Self {
std::fs::remove_file(self.dir.path().join(rel)).unwrap();
self
}
fn rename(&self, from: &str, to: &str) -> &Self {
std::fs::rename(self.dir.path().join(from), self.dir.path().join(to)).unwrap();
self
}
fn symlink(&self, target: impl AsRef<Path>, rel: &str) -> &Self {
std::os::unix::fs::symlink(target, self.dir.path().join(rel)).unwrap();
self
}
fn git(&self, args: &[&str]) -> &Self {
run_git(self.dir.path(), args);
self
}
fn commit(&self, add_args: &[&str], msg: &str) -> &Self {
let mut add: Vec<&str> = vec!["add"];
add.extend_from_slice(add_args);
self.git(&add).git(&["commit", "-q", "-m", msg])
}
fn snapshot(&self) -> Snapshot {
discover(self.dir.path()).expect("repo present")
}
}
fn status_at(snap: &Snapshot, rel: &str) -> PorcelainCode {
snap.lookup(&snap.root.join(rel))
}
#[test]
fn dir_with_deleted_tracked_file_and_untracked_content_is_dirty_subtree() {
let r = TestRepo::new();
r.write("d0/f1", b"base\n").commit(&["d0/f1"], "base");
r.remove_file("d0/f1");
r.write("d0/d1/f0", b"x\n"); let snap = r.snapshot();
assert_eq!(
snap.display_code_for(&snap.root.join("d0"), true),
PorcelainCode::DIRTY_SUBTREE,
"a directory that still tracks a (deleted) file is a dirty subtree, not untracked"
);
assert_eq!(status_at(&snap, "d0/d1/f0"), PorcelainCode::UNTRACKED);
}
#[test]
fn nested_tracked_prefix_under_collapsed_untracked_is_dirty_subtree() {
let r = TestRepo::new();
r.write("d0/d1/f1", b"base\n").commit(&["d0/d1/f1"], "base");
r.remove_file("d0/d1/f1");
r.write("d0/u", b"x\n"); let snap = r.snapshot();
assert_eq!(
snap.display_code_for(&snap.root.join("d0"), true),
PorcelainCode::DIRTY_SUBTREE
);
assert_eq!(
snap.display_code_for(&snap.root.join("d0/d1"), true),
PorcelainCode::DIRTY_SUBTREE
);
}
#[test]
fn rewrite_code_distinguishes_copy_and_rename() {
use super::rewrite_code;
assert_eq!(rewrite_code(true), PorcelainCode::COPIED_WORKTREE);
assert_eq!(rewrite_code(false), PorcelainCode::RENAMED_WORKTREE);
}
#[test]
fn merge_returns_next_when_no_prior() {
let m = merge(None, PorcelainCode::UNTRACKED);
assert_eq!(m, PorcelainCode::UNTRACKED);
}
#[test]
fn merge_keeps_prior_index_and_takes_next_worktree() {
let prev = PorcelainCode {
index: '+',
worktree: ' ',
};
let m = merge(Some(prev), PorcelainCode::MODIFIED_WORKTREE);
assert_eq!(m.index, '+');
assert_eq!(m.worktree, '●');
}
#[test]
fn merge_keeps_prior_worktree_when_next_is_blank() {
let prev = PorcelainCode {
index: ' ',
worktree: '▽',
};
let next = PorcelainCode {
index: '●',
worktree: ' ',
};
let m = merge(Some(prev), next);
assert_eq!(m.index, '●');
assert_eq!(m.worktree, '▽');
}
#[test]
fn lookup_returns_untracked_for_unknown_path_inside_root() {
let r = TestRepo::new();
assert_eq!(
r.snapshot().lookup(&r.root().join("ghost")),
PorcelainCode::UNTRACKED,
);
}
#[test]
fn lookup_returns_blank_for_path_outside_root() {
let r = TestRepo::new();
let sibling = tempfile::tempdir().unwrap();
assert_eq!(
r.snapshot().lookup(&sibling.path().join("elsewhere")),
PorcelainCode::BLANK,
);
}
#[test]
fn lookup_uses_status_walk_entry_for_known_path() {
let r = TestRepo::new();
r.write("a", b"one\n").commit(&["a"], "init");
r.write("a", b"two\n");
assert_eq!(
status_at(&r.snapshot(), "a"),
PorcelainCode::MODIFIED_WORKTREE,
);
}
#[test]
fn is_ignored_only_true_for_ignored_code() {
let r = TestRepo::new();
r.write(".gitignore", b"ig\n")
.write("ig", b"x")
.write("un", b"x");
let snap = r.snapshot();
assert!(snap.is_ignored(&r.root().join("ig")));
assert!(!snap.is_ignored(&r.root().join("un")));
assert!(!snap.is_ignored(&r.root().join("missing")));
}
#[test]
fn discover_returns_none_outside_repo() {
let dir = tempfile::tempdir().unwrap();
assert!(discover(dir.path()).is_none());
}
#[test]
fn discover_returns_some_inside_repo() {
let dir = tempfile::tempdir().unwrap();
let _repo = gix::init(dir.path()).unwrap();
let snap = discover(dir.path()).unwrap();
let expected = std::fs::canonicalize(dir.path()).unwrap();
let actual = std::fs::canonicalize(&snap.root).unwrap();
assert_eq!(actual, expected);
}
#[test]
fn discover_reports_untracked_file() {
let r = TestRepo::new();
r.write("new", b"x");
assert_eq!(status_at(&r.snapshot(), "new"), PorcelainCode::UNTRACKED);
}
#[test]
fn discover_reports_ignored_file() {
let r = TestRepo::new();
r.write(".gitignore", b"hidden\n").write("hidden", b"x");
assert_eq!(status_at(&r.snapshot(), "hidden"), PorcelainCode::IGNORED);
}
#[test]
fn discover_reports_modified_worktree() {
let r = TestRepo::new();
r.write("a", b"hello\n").commit(&["a"], "x");
r.write("a", b"different\n");
assert_eq!(
status_at(&r.snapshot(), "a"),
PorcelainCode::MODIFIED_WORKTREE,
);
}
#[test]
fn discover_reports_deleted_worktree() {
let r = TestRepo::new();
r.write("b", b"x").commit(&["b"], "x").remove_file("b");
assert_eq!(
status_at(&r.snapshot(), "b"),
PorcelainCode::DELETED_WORKTREE,
);
}
#[test]
fn discover_reports_staged_addition() {
let r = TestRepo::new();
r.write("seed", b"x").commit(&["seed"], "seed");
r.write("staged", b"hi").git(&["add", "staged"]);
assert_eq!(status_at(&r.snapshot(), "staged").index, '+');
}
#[test]
fn discover_reports_staged_modification() {
let r = TestRepo::new();
r.write("m", b"one\n").commit(&["m"], "m");
r.write("m", b"two\n").git(&["add", "m"]);
assert_eq!(status_at(&r.snapshot(), "m").index, '●');
}
#[test]
fn discover_reports_staged_deletion() {
let r = TestRepo::new();
r.write("d", b"x")
.commit(&["d"], "d")
.git(&["rm", "-q", "d"]);
assert_eq!(status_at(&r.snapshot(), "d").index, '▽');
}
#[test]
fn discover_reports_rename_in_worktree() {
let r = TestRepo::new();
let body = "line\n".repeat(40);
r.write("from", body.as_bytes()).commit(&["from"], "from");
r.rename("from", "to");
assert_eq!(
status_at(&r.snapshot(), "to"),
PorcelainCode::RENAMED_WORKTREE,
);
}
#[test]
fn discover_reports_staged_rename() {
let r = TestRepo::new();
let body = "line\n".repeat(40);
r.write("from", body.as_bytes()).commit(&["from"], "from");
r.git(&["mv", "from", "to"]);
assert_eq!(status_at(&r.snapshot(), "to").index, '→');
}
#[test]
fn discover_reports_unmerged_conflict() {
let r = TestRepo::new();
r.write("c", b"base\n").commit(&["c"], "base");
r.git(&["checkout", "-q", "-b", "other"]);
r.write("c", b"other\n")
.git(&["commit", "-q", "-am", "other"]);
r.git(&["checkout", "-q", "main"]);
r.write("c", b"main\n")
.git(&["commit", "-q", "-am", "main"]);
let _ = Command::new("git")
.arg("-C")
.arg(r.root())
.args(["merge", "--no-edit", "-q", "other"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("HOME", r.root())
.status()
.unwrap();
assert_eq!(status_at(&r.snapshot(), "c"), PorcelainCode::UNMERGED);
}
#[test]
fn discover_reports_type_change() {
let r = TestRepo::new();
r.write("t", b"file").commit(&["t"], "t").remove_file("t");
r.symlink("anything", "t");
assert_eq!(
status_at(&r.snapshot(), "t"),
PorcelainCode::TYPE_CHANGE_WORKTREE,
);
}
#[test]
fn porcelain_codes_are_distinct() {
let codes = [
PorcelainCode::CLEAN,
PorcelainCode::UNTRACKED,
PorcelainCode::IGNORED,
PorcelainCode::MODIFIED_WORKTREE,
PorcelainCode::DELETED_WORKTREE,
PorcelainCode::TYPE_CHANGE_WORKTREE,
PorcelainCode::RENAMED,
PorcelainCode::COPIED,
PorcelainCode::RENAMED_WORKTREE,
PorcelainCode::COPIED_WORKTREE,
PorcelainCode::UNMERGED,
PorcelainCode::DIRTY_SUBTREE,
PorcelainCode::BLANK,
];
for (i, a) in codes.iter().enumerate() {
for b in &codes[i + 1..] {
assert_ne!(a, b);
}
}
let with = PorcelainCode::MODIFIED_WORKTREE.with_index('+');
assert_eq!(with.index, '+');
assert_eq!(with.worktree, '●');
}
#[test]
fn lookup_inherits_untracked_from_collapsed_directory() {
let r = TestRepo::new();
r.write("dir/file", b"x").write("dir/deeper/file", b"x");
let snap = r.snapshot();
assert_eq!(
snap.lookup(&r.root().join("dir/file")),
PorcelainCode::UNTRACKED,
);
assert_eq!(
snap.lookup(&r.root().join("dir/deeper/file")),
PorcelainCode::UNTRACKED,
);
assert_eq!(
snap.lookup(&r.root().join("other")),
PorcelainCode::UNTRACKED,
);
}
#[test]
fn lookup_inherits_ignored_from_ancestor() {
let r = TestRepo::new();
r.write(".gitignore", b"ig/\n").write("ig/inside", b"x");
let snap = r.snapshot();
assert_eq!(
snap.lookup(&r.root().join("ig/inside")),
PorcelainCode::IGNORED,
);
}
#[test]
fn lookup_marks_dir_ignored_when_internal_gitignore_ignores_all() {
let r = TestRepo::new();
r.write("seed", b"x").commit(&["."], "init");
r.write(".venv/.gitignore", b"*\n").write(".venv/lib", b"x");
let snap = r.snapshot();
assert_eq!(
snap.display_code_for(&r.root().join(".venv"), true),
PorcelainCode::IGNORED,
);
assert_eq!(
snap.lookup(&r.root().join(".venv/lib")),
PorcelainCode::IGNORED,
);
}
#[test]
fn lookup_marks_ignored_subdir_inside_untracked_dir() {
let r = TestRepo::new();
r.write("seed", b"x").commit(&["."], "init");
r.write("newdir/note", b"x") .write("newdir/cache/.gitignore", b"*\n")
.write("newdir/cache/data", b"x");
let snap = r.snapshot();
assert_eq!(
snap.display_code_for(&r.root().join("newdir/cache"), true),
PorcelainCode::IGNORED,
);
assert_eq!(
snap.lookup(&r.root().join("newdir/cache/data")),
PorcelainCode::IGNORED,
);
assert_eq!(
snap.lookup(&r.root().join("newdir/note")),
PorcelainCode::UNTRACKED,
);
assert_eq!(
snap.display_code_for(&r.root().join("newdir"), true),
PorcelainCode::UNTRACKED,
);
}
#[test]
fn display_ignored_for_subdir_with_only_root_ignored_files() {
let r = TestRepo::new();
r.write(".gitignore", b"*.log\n")
.write("seed", b"x")
.commit(&["."], "init");
r.write("a/x.log", b"1\n").write("a/y.log", b"2\n");
let snap = r.snapshot();
assert_eq!(
snap.display_code_for(&r.root().join("a"), true),
PorcelainCode::IGNORED,
);
assert_eq!(
snap.lookup(&r.root().join("a/x.log")),
PorcelainCode::IGNORED,
);
}
#[test]
fn display_untracked_for_subdir_mixing_ignored_and_untracked() {
let r = TestRepo::new();
r.write(".gitignore", b"*.log\n")
.write("seed", b"x")
.commit(&["."], "init");
r.write("a/x.log", b"1\n").write("a/y.txt", b"2\n");
let snap = r.snapshot();
assert_eq!(
snap.display_code_for(&r.root().join("a"), true),
PorcelainCode::UNTRACKED,
);
assert_eq!(
snap.lookup(&r.root().join("a/x.log")),
PorcelainCode::IGNORED,
);
assert_eq!(
snap.lookup(&r.root().join("a/y.txt")),
PorcelainCode::UNTRACKED,
);
}
#[test]
fn display_clean_for_tracked_subdir_holding_an_ignored_file() {
let r = TestRepo::new();
r.write(".gitignore", b"*.log\n")
.write("a/t", b"tracked\n")
.commit(&["."], "init");
r.write("a/x.log", b"1\n");
assert_eq!(
r.snapshot().display_code_for(&r.root().join("a"), true),
PorcelainCode::CLEAN,
);
r.write("a/t", b"changed\n");
assert_eq!(
r.snapshot().display_code_for(&r.root().join("a"), true),
PorcelainCode::DIRTY_SUBTREE,
);
}
#[test]
fn display_blank_for_empty_untracked_dir() {
let r = TestRepo::new();
r.write("seed", b"x").commit(&["."], "init");
r.create_dir("empty");
assert_eq!(
r.snapshot().display_code_for(&r.root().join("empty"), true),
PorcelainCode::BLANK,
);
}
#[test]
fn display_blank_for_deep_empty_dir_chain() {
let r = TestRepo::new();
r.write("seed", b"x").commit(&["."], "init");
r.create_dir("x/y/z");
let snap = r.snapshot();
for rel in ["x", "x/y", "x/y/z"] {
assert_eq!(
snap.display_code_for(&r.root().join(rel), true),
PorcelainCode::BLANK,
"{rel} should render blank",
);
}
}
#[test]
fn display_untracked_for_dir_with_deep_file() {
let r = TestRepo::new();
r.write("seed", b"x").commit(&["."], "init");
r.write("x/y/z/f", b"data");
assert_eq!(
r.snapshot().display_code_for(&r.root().join("x"), true),
PorcelainCode::UNTRACKED,
);
}
#[test]
fn display_blank_for_empty_subdir_in_untracked_dir() {
let r = TestRepo::new();
r.write("seed", b"x").commit(&["."], "init");
r.write("newdir/note", b"x").create_dir("newdir/emptysub");
let snap = r.snapshot();
assert_eq!(
snap.display_code_for(&r.root().join("newdir"), true),
PorcelainCode::UNTRACKED,
);
assert_eq!(
snap.display_code_for(&r.root().join("newdir/emptysub"), true),
PorcelainCode::BLANK,
);
}
#[test]
fn display_ignored_for_empty_explicitly_ignored_dir() {
let r = TestRepo::new();
r.write(".gitignore", b"build/\n").commit(&["."], "init");
r.create_dir("build");
assert_eq!(
r.snapshot().display_code_for(&r.root().join("build"), true),
PorcelainCode::IGNORED,
);
}
#[test]
fn subtree_contains_file_treats_unreadable_as_content() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("f");
std::fs::write(&file, b"x").unwrap();
assert!(super::subtree_contains_file(&file));
assert!(super::subtree_contains_file(&tmp.path().join("missing")));
}
#[test]
fn is_ignored_with_kind_tracks_classification() {
let r = TestRepo::new();
r.write(".gitignore", b"build/\n")
.write("seed", b"x")
.commit(&["."], "init");
r.create_dir("build").create_dir("plain");
let snap = r.snapshot();
assert!(snap.is_ignored_with_kind(&r.root().join("build"), true));
assert!(!snap.is_ignored_with_kind(&r.root().join("plain"), true));
let outside = tempfile::tempdir().unwrap();
assert!(!snap.is_ignored_with_kind(&outside.path().join("x"), true));
}
#[test]
fn lookup_does_not_inherit_modified_from_ancestor() {
let r = TestRepo::new();
r.write("mod", b"orig\n").commit(&["mod"], "init");
r.write("mod", b"changed\n");
let snap = r.snapshot();
assert_ne!(
snap.lookup(&r.root().join("mod/inside")),
PorcelainCode::MODIFIED_WORKTREE,
);
}
#[test]
fn lookup_resolves_dotdot_via_canonicalize_fallback() {
let r = TestRepo::new();
r.write("file", b"x").create_dir("sub");
let canonical_root = std::fs::canonicalize(r.root()).unwrap();
let weird = canonical_root.join("sub").join("..").join("file");
assert_eq!(r.snapshot().lookup(&weird), PorcelainCode::UNTRACKED);
}
#[test]
fn lookup_returns_blank_when_canonicalize_lands_outside_root() {
let r = TestRepo::new();
let snap = r.snapshot();
let sibling = tempfile::tempdir().unwrap();
std::fs::write(sibling.path().join("file"), b"x").unwrap();
assert_eq!(
snap.lookup(&sibling.path().join("file")),
PorcelainCode::BLANK,
);
}
#[test]
fn lookup_returns_blank_when_canonicalize_fails() {
let r = TestRepo::new();
assert_eq!(
r.snapshot()
.lookup(&r.root().join("missing/../also-missing")),
PorcelainCode::BLANK,
);
}
#[test]
fn lookup_preserves_leaf_symlink_via_parent_canonicalisation() {
let r = TestRepo::new();
r.write("entry", b"originally a file")
.commit(&["entry"], "init");
r.remove_file("entry").symlink("/dev/null", "entry");
let canonical = std::fs::canonicalize(r.root()).unwrap();
let link_parent = tempfile::tempdir().unwrap();
let link_dir = link_parent.path().join("via_link");
std::os::unix::fs::symlink(&canonical, &link_dir).unwrap();
let snap = r.snapshot();
assert_eq!(
snap.lookup(&link_dir.join("entry")),
PorcelainCode::TYPE_CHANGE_WORKTREE,
);
}
#[test]
fn lookup_handles_single_component_relative_path() {
let r = TestRepo::new();
assert_eq!(r.snapshot().lookup(Path::new("solo")), PorcelainCode::BLANK);
}
#[test]
fn lookup_handles_path_with_no_file_name() {
let r = TestRepo::new();
assert_eq!(r.snapshot().lookup(Path::new("..")), PorcelainCode::BLANK);
}
#[test]
fn lookup_returns_blank_for_path_in_unrelated_tempdir() {
let r = TestRepo::new();
let snap = r.snapshot();
let sibling = tempfile::tempdir().unwrap();
assert_eq!(
snap.lookup(&sibling.path().join("file")),
PorcelainCode::BLANK,
);
}
#[test]
fn snapshot_cache_reuses_entry_for_same_target() {
let r = TestRepo::new();
let mut cache = SnapshotCache::new();
let first_ptr: *const Snapshot = cache.for_target(r.root()).unwrap();
let second_ptr: *const Snapshot = cache.for_target(r.root()).unwrap();
assert!(std::ptr::eq(first_ptr, second_ptr));
}
#[test]
fn snapshot_cache_shares_snapshot_across_subdirs_in_same_repo() {
let r = TestRepo::new();
r.write("a/tracked", b"x")
.write("b/tracked", b"x")
.commit(&["."], "init");
r.write("a/only_a", b"x").write("b/only_b", b"x");
let mut cache = SnapshotCache::new();
let first_ptr: *const Snapshot = cache.for_target(&r.root().join("a")).unwrap();
let second_ptr: *const Snapshot = cache.for_target(&r.root().join("b")).unwrap();
assert!(std::ptr::eq(first_ptr, second_ptr));
let statuses: Vec<_> = cache
.for_target(&r.root().join("a"))
.unwrap()
.statuses
.keys()
.cloned()
.collect();
assert!(statuses.iter().any(|p| p.ends_with("only_a")));
assert!(statuses.iter().any(|p| p.ends_with("only_b")));
}
#[test]
fn snapshot_cache_returns_none_outside_repo() {
let dir = tempfile::tempdir().unwrap();
let mut cache = SnapshotCache::new();
assert!(cache.for_target(dir.path()).is_none());
}
#[test]
fn snapshot_cache_returns_none_when_build_fails() {
let r = TestRepo::new();
let mut bytes = Vec::new();
bytes.extend_from_slice(b"DIRC");
bytes.extend_from_slice(&0x99_u32.to_be_bytes());
bytes.extend_from_slice(&0_u32.to_be_bytes());
bytes.extend_from_slice(&[0u8; 20]);
std::fs::write(r.root().join(".git/index"), &bytes).unwrap();
let mut cache = SnapshotCache::new();
assert!(cache.for_target(r.root()).is_none());
assert!(cache.for_target(r.root()).is_none());
}
#[test]
fn snapshot_cache_caches_negative_results() {
let dir = tempfile::tempdir().unwrap();
let canon = std::fs::canonicalize(dir.path()).unwrap();
let mut cache = SnapshotCache::new();
assert!(cache.for_target(dir.path()).is_none());
assert!(cache.for_target(dir.path()).is_none());
assert!(cache.by_scope.contains_key(&canon));
assert!(cache.by_scope[&canon].is_none());
}
#[test]
fn scope_dir_for_bare_filename_returns_current_dir() {
use super::scope_dir;
assert_eq!(scope_dir(Path::new("ghost.txt")), PathBuf::from("."));
}
#[test]
fn normalize_existing_falls_back_to_absolute_for_missing_path() {
use super::normalize_existing;
let result = normalize_existing(Path::new("/tmp/freshl-definitely-missing-12345"));
assert_eq!(
result,
Some(PathBuf::from("/tmp/freshl-definitely-missing-12345"))
);
}
#[test]
fn rename_in_worktree_status_uses_worktree_column() {
assert_eq!(PorcelainCode::RENAMED_WORKTREE.index, ' ');
assert_eq!(PorcelainCode::RENAMED_WORKTREE.worktree, '→');
assert_eq!(PorcelainCode::COPIED_WORKTREE.index, ' ');
assert_eq!(PorcelainCode::COPIED_WORKTREE.worktree, '⇉');
}
#[test]
fn dirty_ancestors_includes_parents_of_modified_file() {
let r = TestRepo::new();
r.write("a/b/c/file", b"orig\n")
.write("sibling/other", b"x")
.commit(&["."], "init");
r.write("a/b/c/file", b"modified\n");
let snap = r.snapshot();
assert!(snap.has_dirty_descendants(&r.root().join("a")));
assert!(snap.has_dirty_descendants(&r.root().join("a/b")));
assert!(snap.has_dirty_descendants(&r.root().join("a/b/c")));
assert!(!snap.has_dirty_descendants(&r.root().join("sibling")));
}
#[test]
fn dirty_ancestors_excludes_ignored_descendants() {
let r = TestRepo::new();
r.write("dir/tracked", b"x")
.write(".gitignore", b"dir/hidden\n")
.commit(&["."], "init");
r.write("dir/hidden", b"x");
let snap = r.snapshot();
assert!(!snap.has_dirty_descendants(&r.root().join("dir")));
}
#[test]
fn dirty_ancestors_includes_untracked_in_tracked_dir() {
let r = TestRepo::new();
r.write("dir/tracked", b"x").commit(&["."], "init");
r.write("dir/new", b"x");
let snap = r.snapshot();
assert!(snap.has_dirty_descendants(&r.root().join("dir")));
}
#[test]
fn dirty_ancestors_does_not_flag_clean_repo() {
let r = TestRepo::new();
r.write("file", b"x").commit(&["."], "init");
let snap = r.snapshot();
assert!(!snap.has_dirty_descendants(r.root()));
assert!(!snap.has_dirty_descendants(&r.root().join("file")));
}
#[test]
fn has_dirty_descendants_false_outside_root() {
let r = TestRepo::new();
let snap = r.snapshot();
let sibling = tempfile::tempdir().unwrap();
assert!(!snap.has_dirty_descendants(&sibling.path().join("dir")));
}
#[test]
fn display_code_for_returns_blank_outside_root() {
let r = TestRepo::new();
let snap = r.snapshot();
let sibling = tempfile::tempdir().unwrap();
assert_eq!(
snap.display_code_for(&sibling.path().join("dir"), true),
PorcelainCode::BLANK,
);
}
#[test]
fn has_dirty_descendants_true_for_root_with_dirty_subtree() {
let r = TestRepo::new();
r.write("a", b"x").commit(&["."], "init");
r.write("a", b"changed");
let snap = r.snapshot();
assert!(snap.has_dirty_descendants(r.root()));
assert_eq!(
snap.display_code_for(r.root(), true),
PorcelainCode::DIRTY_SUBTREE,
);
}
#[test]
fn dirty_ancestors_skips_empty_untracked_dir() {
let r = TestRepo::new();
r.write("seed", b"x").commit(&["."], "init");
r.create_dir("x/y/z");
let snap = r.snapshot();
assert!(!snap.has_dirty_descendants(r.root()));
assert_eq!(snap.display_code_for(r.root(), true), PorcelainCode::CLEAN);
}
#[test]
fn dirty_ancestors_includes_untracked_dir_with_content() {
let r = TestRepo::new();
r.write("seed", b"x").commit(&["."], "init");
r.write("bar/f", b"x");
let snap = r.snapshot();
assert!(snap.has_dirty_descendants(r.root()));
assert_eq!(
snap.display_code_for(r.root(), true),
PorcelainCode::DIRTY_SUBTREE,
);
}
#[test]
fn dirty_ancestors_counts_untracked_symlink_as_content() {
let r = TestRepo::new();
r.write("seed", b"x").commit(&["."], "init");
r.create_dir("target").symlink("target", "link");
let snap = r.snapshot();
assert!(snap.has_dirty_descendants(r.root()));
}
#[test]
fn lookup_marks_ignored_dir_when_scope_is_subdir() {
let r = TestRepo::new();
r.write(".gitignore", b"node_modules/\n")
.write("backend/tracked", b"x")
.commit(&["."], "init");
r.write("backend/node_modules/anything", b"x");
let mut cache = SnapshotCache::new();
let snap = cache
.for_target(&r.root().join("backend"))
.expect("repo present");
assert_eq!(
snap.display_code_for(&r.root().join("backend/node_modules"), true),
PorcelainCode::IGNORED,
);
}
#[test]
fn lookup_marks_untracked_dir_when_scope_is_subdir() {
let r = TestRepo::new();
r.write("backend/tracked", b"x").commit(&["."], "init");
r.write("backend/brand_new/anything", b"x");
let mut cache = SnapshotCache::new();
let snap = cache
.for_target(&r.root().join("backend"))
.expect("repo present");
assert_eq!(
snap.display_code_for(&r.root().join("backend/brand_new"), true),
PorcelainCode::UNTRACKED,
);
}
#[test]
fn lookup_marks_clean_only_when_path_in_index() {
let r = TestRepo::new();
r.write("backend/tracked", b"x").commit(&["."], "init");
let mut cache = SnapshotCache::new();
let snap = cache
.for_target(&r.root().join("backend"))
.expect("repo present");
assert_eq!(
snap.lookup(&r.root().join("backend/tracked")),
PorcelainCode::CLEAN,
);
assert_ne!(
snap.lookup(&r.root().join("backend/never_existed")),
PorcelainCode::CLEAN,
);
}
#[test]
fn lookup_marks_brand_new_file_untracked_not_clean() {
let r = TestRepo::new();
r.write("seed", b"x").commit(&["."], "init");
r.write("brand_new", b"x");
assert_eq!(
status_at(&r.snapshot(), "brand_new"),
PorcelainCode::UNTRACKED,
);
}
#[test]
fn lookup_consults_exclude_stack_for_unwalked_paths() {
let r = TestRepo::new();
r.write(".gitignore", b"*.log\n")
.write("anchor", b"x")
.commit(&["."], "init");
let snap = r.snapshot();
assert_eq!(
snap.lookup(&r.root().join("missing.log")),
PorcelainCode::IGNORED,
);
r.write("created_after_snapshot.log", b"x");
assert_eq!(
snap.lookup(&r.root().join("created_after_snapshot.log")),
PorcelainCode::IGNORED,
);
}
#[test]
fn snapshot_debug_emits_non_empty_repr() {
let r = TestRepo::new();
let snap = r.snapshot();
let repr = format!("{snap:?}");
assert!(repr.contains("Snapshot"));
assert!(repr.contains("tracked_prefixes_len"));
}
#[test]
fn worktree_rename_marks_source_deleted_not_clean() {
let r = TestRepo::new();
let body = "line\n".repeat(40);
r.write("from", body.as_bytes()).commit(&["from"], "from");
r.rename("from", "to");
assert_eq!(
status_at(&r.snapshot(), "from"),
PorcelainCode::DELETED_WORKTREE,
);
}
#[test]
fn submodule_contents_resolve_to_clean() {
let r = TestRepo::new();
let inner = tempfile::tempdir().unwrap();
run_git(inner.path(), &["init", "-q", "-b", "main"]);
run_git(inner.path(), &["config", "user.email", "t@example.invalid"]);
run_git(inner.path(), &["config", "user.name", "t"]);
std::fs::write(inner.path().join("inside"), b"x").unwrap();
run_git(inner.path(), &["add", "inside"]);
run_git(inner.path(), &["commit", "-q", "-m", "inner"]);
let inner_url = format!("file://{}", inner.path().display());
let status = Command::new("git")
.arg("-C")
.arg(r.root())
.args(["-c", "protocol.file.allow=always"])
.args(["submodule", "add", "-q", &inner_url, "submod"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("HOME", r.root())
.status()
.unwrap();
assert!(status.success(), "git submodule add failed");
r.commit(&["."], "add submod");
let snap = r.snapshot();
assert_eq!(
snap.lookup(&r.root().join("submod/inside")),
PorcelainCode::CLEAN,
);
}
#[test]
fn symlink_to_directory_matching_dir_rule_is_not_ignored() {
let r = TestRepo::new();
r.write(".gitignore", b"vendor/\n")
.commit(&[".gitignore"], "ig");
r.symlink("/tmp", "vendor");
let snap = r.snapshot();
assert_ne!(
snap.lookup(&r.root().join("vendor")),
PorcelainCode::IGNORED,
);
}
#[test]
fn lookup_returns_untracked_when_exclude_stack_at_path_errors() {
let r = TestRepo::new();
r.write("parent", b"i'm a file");
let snap = r.snapshot();
assert_eq!(
snap.lookup(&r.root().join("parent/child")),
PorcelainCode::UNTRACKED,
);
}
#[test]
fn dot_git_dir_looks_up_as_blank() {
let r = TestRepo::new();
assert_eq!(
r.snapshot().lookup(&r.root().join(".git")),
PorcelainCode::BLANK,
);
}
#[test]
fn dot_git_descendants_look_up_as_blank() {
let r = TestRepo::new();
let snap = r.snapshot();
assert_eq!(
snap.lookup(&r.root().join(".git/HEAD")),
PorcelainCode::BLANK,
);
assert_eq!(
snap.lookup(&r.root().join(".git/config")),
PorcelainCode::BLANK,
);
}
#[test]
fn display_code_for_dot_git_is_blank() {
let r = TestRepo::new();
assert_eq!(
r.snapshot().display_code_for(&r.root().join(".git"), true),
PorcelainCode::BLANK,
);
}
#[test]
fn display_code_for_consults_exclude_stack_on_missing_path() {
let r = TestRepo::new();
r.write(".gitignore", b"*.log\n")
.write("anchor", b"x")
.commit(&["."], "init");
assert_eq!(
r.snapshot()
.display_code_for(&r.root().join("missing.log"), false),
PorcelainCode::IGNORED,
);
}
#[test]
fn submodule_dot_git_linkfile_resolves_to_clean() {
let r = TestRepo::new();
let inner = tempfile::tempdir().unwrap();
run_git(inner.path(), &["init", "-q", "-b", "main"]);
run_git(inner.path(), &["config", "user.email", "t@example.invalid"]);
run_git(inner.path(), &["config", "user.name", "t"]);
std::fs::write(inner.path().join("inside"), b"x").unwrap();
run_git(inner.path(), &["add", "inside"]);
run_git(inner.path(), &["commit", "-q", "-m", "inner"]);
let inner_url = format!("file://{}", inner.path().display());
let status = Command::new("git")
.arg("-C")
.arg(r.root())
.args(["-c", "protocol.file.allow=always"])
.args(["submodule", "add", "-q", &inner_url, "submod"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("HOME", r.root())
.status()
.unwrap();
assert!(status.success(), "git submodule add failed");
r.commit(&["."], "add submod");
assert_eq!(
r.snapshot().lookup(&r.root().join("submod/.git")),
PorcelainCode::CLEAN,
);
}
#[test]
fn dot_git_only_special_at_real_gitdir() {
let r = TestRepo::new();
r.write("subdir/.git", b"not a real linkfile\n");
let snap = r.snapshot();
assert_eq!(snap.lookup(&r.root().join(".git")), PorcelainCode::BLANK);
assert_eq!(
snap.lookup(&r.root().join("subdir/.git")),
PorcelainCode::UNTRACKED,
);
}
#[test]
fn discover_returns_none_for_corrupt_index() {
let r = TestRepo::new();
let mut bytes = Vec::new();
bytes.extend_from_slice(b"DIRC"); bytes.extend_from_slice(&0x99_u32.to_be_bytes()); bytes.extend_from_slice(&0_u32.to_be_bytes()); bytes.extend_from_slice(&[0u8; 20]);
std::fs::write(r.root().join(".git/index"), &bytes).unwrap();
assert!(discover(r.root()).is_none());
}
}