fix: keep a path's bytes, so a filename that is not utf-8 survives

This commit is contained in:
2026-09-09 12:01:59 +00:00
parent b4b6d5918f
commit e146b0460a
8 changed files with 172 additions and 72 deletions

View File

@@ -1,4 +1,6 @@
use fs_err::{read_dir, read_link};
use std::ffi::OsString;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
@@ -6,7 +8,7 @@ use anyhow::{Result, anyhow};
use super::store::{Repo, resolve, symlink_metadata_opt};
use crate::cmd::{Cmd, LsFiles};
use crate::ctx::Ctx;
use crate::output::{line, text};
use crate::output::{line, text, write_bytes};
// whether anything is outside the store, which main turns into an exit code
pub fn check(
@@ -23,13 +25,10 @@ pub fn check(
// git lists untracked and ignored separately
for (mark, ignored) in [(UNTRACKED, false), (IGNORED, true)] {
for entry in list_others(ctx, &repo, ignored, &pathspecs)? {
let rel = PathBuf::from(entry.trim_end_matches('/'));
// --directory collapses a wholly untracked dir into `dir/`
if entry.ends_with('/') {
exposed.extend(walk(&repo, &rel, mark)?.1);
} else {
exposed.extend(classify(&repo, &rel, mark)?);
match entry.strip_suffix(b"/") {
Some(dir) => exposed.extend(walk(&repo, &path_from(dir), mark)?.1),
None => exposed.extend(classify(&repo, &path_from(&entry), mark)?),
}
}
}
@@ -46,19 +45,36 @@ pub fn check(
}
fn print_porcelain(exposed: &[Exposed], null: bool) {
let end = if null { '\0' } else { '\n' };
// a filename can hold an arrow but not a NUL, as `git status -z` also assumes
let between = if null { "\0" } else { " -> " };
// -z is the format for scripts that must survive any filename, so its
// records are written as the bytes a path actually is. a filename can hold
// an arrow but not a NUL, as `git status -z` also assumes
if null {
for item in exposed {
let mut record = item.code().into_bytes();
record.push(b' ');
record.extend_from_slice(item.name.as_os_str().as_bytes());
if let Some(dest) = &item.dest {
record.push(0);
record.extend_from_slice(dest.as_os_str().as_bytes());
}
record.push(0);
write_bytes(&record);
}
return;
}
for item in exposed {
match &item.dest {
Some(dest) => text!(
"{} {}{between}{}{end}",
"{} {} -> {}\n",
item.code(),
item.name,
item.name.display(),
dest.display()
),
None => text!("{} {}{end}", item.code(), item.name),
None => text!("{} {}\n", item.code(), item.name.display()),
}
}
}
@@ -98,8 +114,8 @@ fn print_listing(repo: &Repo, exposed: &[Exposed]) {
line!("\n{heading}\n{hint}");
for item in items {
match &item.dest {
Some(dest) => line!("\t{} -> {}", item.name, dest.display()),
None => line!("\t{}", item.name),
Some(dest) => line!("\t{} -> {}", item.name.display(), dest.display()),
None => line!("\t{}", item.name.display()),
}
}
}
@@ -126,12 +142,12 @@ const ELSEWHERE: char = '>';
struct Exposed {
mark: char,
name: String,
name: PathBuf,
dest: Option<PathBuf>,
}
impl Exposed {
fn content(mark: char, name: String) -> Self {
fn content(mark: char, name: PathBuf) -> Self {
Self {
mark,
name,
@@ -151,7 +167,7 @@ impl Exposed {
fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
let src = repo.root.join(rel);
let name = rel.display().to_string();
let name = rel.to_path_buf();
let Some(meta) = symlink_metadata_opt(&src)? else {
return Ok(None);
@@ -196,8 +212,10 @@ 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() {
let name = format!("{}/", rel.display());
return Ok((0, vec![Exposed::content(mark, name)]));
let mut name = rel.as_os_str().to_owned();
name.push("/");
return Ok((0, vec![Exposed::content(mark, PathBuf::from(name))]));
}
Ok((handled, exposed))
@@ -230,16 +248,25 @@ fn list_others(
repo: &Repo,
ignored: bool,
pathspecs: &[PathBuf],
) -> Result<Vec<String>> {
) -> Result<Vec<Vec<u8>>> {
let mut listing = LsFiles::untracked(&repo.root).limited_to(pathspecs);
if ignored {
listing = listing.ignored();
}
Ok(listing
.capture(ctx)?
.split('\0')
.filter(|p| !p.is_empty())
.map(String::from)
.collect())
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
fn split_nul(out: &[u8]) -> Vec<Vec<u8>> {
out.split(|byte| *byte == 0)
.filter(|record| !record.is_empty())
.map(<[u8]>::to_vec)
.collect()
}
fn path_from(bytes: &[u8]) -> PathBuf {
PathBuf::from(OsString::from_vec(bytes.to_vec()))
}