fix: keep a path's bytes, so a filename that is not utf-8 survives
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use fs_err as fs;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
@@ -69,16 +70,21 @@ pub(super) fn resolve(path: &Path) -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
fn git_root(ctx: &Ctx) -> Result<PathBuf> {
|
||||
// bytes, since the checkout can live at a path that is not utf-8, and with
|
||||
// the cause kept: git not being installed and the directory not being a
|
||||
// repository are different problems with the same one-line answer otherwise
|
||||
let root = RevParse
|
||||
.capture(ctx)
|
||||
.map_err(|_| anyhow!("not inside a git repository"))?;
|
||||
.capture_bytes(ctx)
|
||||
.context("asking git for the repository root")?;
|
||||
|
||||
let root = root.trim().to_string();
|
||||
let root = root.strip_suffix(b"\n").unwrap_or(&root);
|
||||
if root.is_empty() {
|
||||
return Err(anyhow!("git reported an empty repository root"));
|
||||
}
|
||||
|
||||
Ok(fs::canonicalize(&root)?)
|
||||
Ok(fs::canonicalize(PathBuf::from(OsString::from_vec(
|
||||
root.to_vec(),
|
||||
)))?)
|
||||
}
|
||||
|
||||
fn repo_components(ctx: &Ctx, root: &Path) -> Result<PathBuf> {
|
||||
@@ -170,9 +176,12 @@ fn non_empty_var(name: &str) -> Option<OsString> {
|
||||
}
|
||||
|
||||
pub(super) fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result<bool> {
|
||||
// bytes: the listing echoes back the path asked about, which is not obliged
|
||||
// to be utf-8, and failing to read it would refuse the path for the wrong
|
||||
// reason rather than answering whether git tracks it
|
||||
let listed = LsFiles::tracked(&repo.root)
|
||||
.limited_to(&[rel])
|
||||
.capture(ctx)?;
|
||||
.capture_bytes(ctx)?;
|
||||
|
||||
Ok(!listed.is_empty())
|
||||
}
|
||||
|
||||
@@ -81,11 +81,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
|
||||
// a directory cannot be streamed, so it is the one shape that gets copied in
|
||||
if matches!(dump, Dump::Directory) {
|
||||
when_ready(
|
||||
ctx,
|
||||
&db,
|
||||
&Cp::into_container(&file.to_string_lossy(), &db.container, &remote),
|
||||
)?;
|
||||
when_ready(ctx, &db, &Cp::into_container(file, &db.container, &remote))?;
|
||||
}
|
||||
|
||||
let kind = match &dump {
|
||||
@@ -284,7 +280,7 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> {
|
||||
.in_container(&db.container)
|
||||
.run(ctx)?;
|
||||
|
||||
let copied = Cp::out_of_container(&db.container, &remote, &target.to_string_lossy()).run(ctx);
|
||||
let copied = Cp::out_of_container(&db.container, &remote, target).run(ctx);
|
||||
|
||||
let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user