fix: make the link store hold what it says it holds

This commit is contained in:
2026-09-09 12:03:16 +00:00
parent e146b0460a
commit 6b2d86ff5a
6 changed files with 413 additions and 49 deletions

View File

@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use super::store::{Repo, resolve, symlink_metadata_opt};
use super::store::{Leads, Repo, leads, resolve, symlink_metadata_opt};
use crate::cmd::{Cmd, LsFiles};
use crate::ctx::Ctx;
use crate::output::{line, text, write_bytes};
@@ -33,6 +33,11 @@ pub fn check(
}
}
// and says nothing about tracked paths, where a symlink out still counts
for entry in list_tracked(ctx, &repo, &pathspecs)? {
exposed.extend(classify_tracked(&repo, &path_from(&entry))?);
}
exposed.sort_by(|a, b| a.name.cmp(&b.name));
if porcelain || null {
@@ -139,6 +144,8 @@ impl Section {
const UNTRACKED: char = '?';
const IGNORED: char = '!';
const ELSEWHERE: char = '>';
// no git equivalent: a tracked path, which only ever appears as a symlink out
const TRACKED: char = 'T';
struct Exposed {
mark: char,
@@ -178,7 +185,16 @@ fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
let dest = read_link(&src)?;
if dest == repo.store.join(rel) {
return Ok(None);
// it names the store, but what the store holds there can be a symlink of
// its own leading straight back out, which is not being held at all
return Ok(match leads(&src, &repo.store) {
Leads::Inside | Leads::Dangling => None,
Leads::Outside(end) => Some(Exposed {
mark,
name,
dest: Some(end),
}),
});
}
Ok(Some(Exposed {
@@ -188,6 +204,29 @@ fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
}))
}
// git tracks symlinks, so one can lead out of the repository without ever
// showing up in an untracked or ignored listing; `add` cannot externalize it
// either, so all check can do is say it is there
fn classify_tracked(repo: &Repo, rel: &Path) -> Result<Option<Exposed>> {
let src = repo.root.join(rel);
let Some(meta) = symlink_metadata_opt(&src)? else {
return Ok(None);
};
if !meta.is_symlink() {
return Ok(None);
}
Ok(match leads(&src, &repo.root) {
Leads::Inside | Leads::Dangling => None,
Leads::Outside(end) => Some(Exposed {
mark: TRACKED,
name: rel.to_path_buf(),
dest: Some(end),
}),
})
}
fn walk(repo: &Repo, rel: &Path, mark: char) -> Result<(usize, Vec<Exposed>)> {
let dir = repo.root.join(rel);
let mut handled = 0;
@@ -210,8 +249,12 @@ fn walk(repo: &Repo, rel: &Path, mark: char) -> Result<(usize, Vec<Exposed>)> {
}
}
// nothing below is in the store, so collapse to one line
if handled == 0 && !exposed.is_empty() {
// nothing below is in the store, so collapse to one line -- unless some of
// it leads out of the repository, which is a different thing to report and
// carries a destination the one line would drop
let all_content = exposed.iter().all(|item| item.dest.is_none());
if handled == 0 && !exposed.is_empty() && all_content {
let mut name = rel.as_os_str().to_owned();
name.push("/");
@@ -257,6 +300,12 @@ fn list_others(
Ok(split_nul(&listing.capture_bytes(ctx)?))
}
fn list_tracked(ctx: &Ctx, repo: &Repo, pathspecs: &[PathBuf]) -> Result<Vec<Vec<u8>>> {
let listing = LsFiles::tracked(&repo.root).limited_to(pathspecs);
Ok(split_nul(&listing.capture_bytes(ctx)?))
}
// every listing check reads is asked for with -z, and kept as the bytes git
// wrote: a path is not obliged to be utf-8, and one that is not used to fail
// the whole listing rather than the one entry