T14: paths stay git's bytes until shown, so a name not in UTF-8 names its own file in status, stage, unstage and diff, and Files keys and opens each name by its bytes too; a copy is a new file whose source no unstage or diff touches; diffs run no text converter; an untracked diff git prints despite a warning stands, the warning logged, while a missing file still fails; a conflict diffs against HEAD, so a file we deleted reads as new; AGENTS.md says Files reads porcelain v2
CI / platforms (windows-latest) (push) Failing after 10s
CI / macos (push) Failing after 7m48s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 20:03:12 -04:00
1 parent e2966686bd
commit 911500c64e
6 files changed
+486 -237

No files matched your search

+132 -65
View File
@@ -1,4 +1,5 @@
use std::{
ffi::OsStr,
io::Write,
path::{Path, PathBuf},
process::{Command, Output, Stdio},
@@ -10,9 +11,18 @@ use ely_gpui_component::{
lists::GitStatus,
};
/// Git's arguments on one line, for messages.
fn line<A: AsRef<OsStr>>(args: &[A]) -> String {
let args: Vec<_> = args
.iter()
.map(|arg| arg.as_ref().to_string_lossy())
.collect();
args.join(" ")
}
/// Runs git in `folder`: English messages, literal paths, no lock the user's own git could meet; `input` feeds stdin.
fn git(folder: &Path, args: &[&str], input: &[u8]) -> anyhow::Result<Output> {
let named = || format!("git: `git {}` in {}", args.join(" "), folder.display());
fn git<A: AsRef<OsStr>>(folder: &Path, args: &[A], input: &[u8]) -> anyhow::Result<Output> {
let named = || format!("git: `git {}` in {}", line(args), folder.display());
let mut child = Command::new("git")
.arg("-C")
.arg(folder)
@@ -50,6 +60,15 @@ fn said(output: &Output) -> String {
.unwrap_or_else(|| output.status.to_string())
}
/// Logs what git warned of while it still did its work.
fn warnings<A: AsRef<OsStr>>(folder: &Path, args: &[A], output: &Output) {
let warned = String::from_utf8_lossy(&output.stderr);
if !warned.trim().is_empty() {
let (command, place) = (line(args), folder.display());
log::warn!("git: `git {command}` in {place} warned: {}", warned.trim());
}
}
/// Runs git and gives what it printed; a failure is an error carrying what git said.
fn run(folder: &Path, args: &[&str], input: &[u8]) -> anyhow::Result<Vec<u8>> {
let output = git(folder, args, input)?;
@@ -59,16 +78,29 @@ fn run(folder: &Path, args: &[&str], input: &[u8]) -> anyhow::Result<Vec<u8>> {
"git: `git {command}` failed in {place}: {}",
said(&output)
);
warnings(folder, args, &output);
Ok(output.stdout)
}
/// A record of `git status --porcelain=v2 -z`: index and work tree codes, its path from the top, a rename's old path.
/// A path from git's bytes: as they are on Unix; elsewhere UTF-8, as git writes them.
fn path_of(bytes: &[u8]) -> anyhow::Result<PathBuf> {
#[cfg(unix)]
let path = <OsStr as std::os::unix::ffi::OsStrExt>::from_bytes(bytes);
#[cfg(not(unix))]
let path = std::str::from_utf8(bytes).with_context(|| {
let shown = String::from_utf8_lossy(bytes);
format!("git: path {shown:?} is not UTF-8")
})?;
Ok(PathBuf::from(path))
}
/// A record of `git status --porcelain=v2 -z` in git's bytes: index and work tree codes, its path from the top, a rename's or copy's old path.
#[derive(Debug, PartialEq)]
struct Record {
x: u8,
y: u8,
path: String,
from: Option<String>,
path: Vec<u8>,
from: Option<Vec<u8>>,
}
/// HEAD as status reads it: its branch, none when detached; its commit, none before the first.
@@ -78,20 +110,25 @@ struct Head {
commit: Option<String>,
}
/// Porcelain v2 records, each ended by NUL: headers name HEAD; a rename's old path follows it.
/// Porcelain v2 records, each ended by NUL: headers name HEAD; a rename's or copy's old path follows it.
fn parse(out: &[u8]) -> anyhow::Result<(Head, Vec<Record>)> {
let mut fields = out
.split(|&byte| byte == 0)
.filter(|field| !field.is_empty())
.map(String::from_utf8_lossy);
.filter(|field| !field.is_empty());
let (mut head, mut records) = (Head::default(), Vec::new());
while let Some(field) = fields.next() {
let malformed = || anyhow!("git: status record {field:?} is malformed");
let (kind, rest) = field.split_once(' ').ok_or_else(malformed)?;
let malformed = || {
let shown = String::from_utf8_lossy(field);
anyhow!("git: status record {shown:?} is malformed")
};
let mut words = field.splitn(2, |&byte| byte == b' ');
let (Some(kind), Some(rest)) = (words.next(), words.next()) else {
return Err(malformed());
};
// Fields before the path, which may hold spaces.
let count = match kind {
"#" => {
match rest.split_once(' ') {
b"#" => {
match String::from_utf8_lossy(rest).split_once(' ') {
Some(("branch.oid", oid)) => {
head.commit = (oid != "(initial)").then(|| oid.into())
}
@@ -102,26 +139,26 @@ fn parse(out: &[u8]) -> anyhow::Result<(Head, Vec<Record>)> {
}
continue;
}
"?" => 0,
"1" => 7,
"2" => 8,
"u" => 9,
b"?" => 0,
b"1" => 7,
b"2" => 8,
b"u" => 9,
_ => return Err(malformed()),
};
let parts: Vec<&str> = rest.splitn(count + 1, ' ').collect();
let parts: Vec<&[u8]> = rest.splitn(count + 1, |&byte| byte == b' ').collect();
let (codes, path) = match (kind, parts.as_slice()) {
("?", [path]) => (b"??".as_slice(), *path),
(_, [codes, .., path]) if parts.len() == count + 1 => (codes.as_bytes(), *path),
(b"?", [path]) => (b"??".as_slice(), *path),
(_, [codes, .., path]) if parts.len() == count + 1 => (*codes, *path),
_ => return Err(malformed()),
};
let (&[x, y], false) = (codes, path.is_empty()) else {
return Err(malformed());
};
let from = match kind {
"2" => Some(fields.next().ok_or_else(malformed)?.into_owned()),
b"2" => Some(fields.next().ok_or_else(malformed)?.to_vec()),
_ => None,
};
let path = path.to_string();
let path = path.to_vec();
records.push(Record { x, y, path, from });
}
Ok((head, records))
@@ -142,8 +179,11 @@ fn kind(x: u8, y: u8) -> Option<GitStatus> {
/// A record's mark from `x` and `y`; codes git never writes are an error.
fn mark(record: &Record, x: u8, y: u8) -> anyhow::Result<GitStatus> {
let codes = format!("{}{}", x as char, y as char);
kind(x, y).with_context(|| format!("git: {} has codes {codes}", record.path))
let (codes, path) = (
format!("{}{}", x as char, y as char),
String::from_utf8_lossy(&record.path),
);
kind(x, y).with_context(|| format!("git: {path} has codes {codes}"))
}
/// Each change at or under `folder`, by its full path; none outside a work tree.
@@ -157,15 +197,18 @@ pub fn changes(folder: &Path) -> anyhow::Result<Option<Vec<(PathBuf, GitStatus)>
}
bail!("git: rev-parse in {} failed: {error}", folder.display());
}
let place = String::from_utf8_lossy(&place.stdout);
// The prefix may hold newlines of its own.
let answer = place
.strip_suffix('\n')
.and_then(|place| place.split_once('\n'));
let answer = place.stdout.strip_suffix(b"\n").and_then(|out| {
let at = out.iter().position(|&byte| byte == b'\n')?;
Some((&out[..at], &out[at + 1..]))
});
let prefix = match answer {
Some(("true", prefix)) => prefix,
Some(("false", _)) => return Ok(None),
_ => bail!("git: rev-parse in {} printed {place:?}", folder.display()),
Some((b"true", prefix)) => prefix,
Some((b"false", _)) => return Ok(None),
_ => {
let printed = String::from_utf8_lossy(&place.stdout);
bail!("git: rev-parse in {} printed {printed:?}", folder.display())
}
};
let status = ["status", "--porcelain=v2", "-z", "--", "."];
let (_, records) = parse(&run(folder, &status, &[])?)?;
@@ -174,10 +217,16 @@ pub fn changes(folder: &Path) -> anyhow::Result<Option<Vec<(PathBuf, GitStatus)>
.map(|record| {
let status = mark(&record, record.x, record.y)?;
let Some(rest) = record.path.strip_prefix(prefix) else {
bail!("git: {} lies outside {prefix}", record.path);
let path = String::from_utf8_lossy(&record.path);
let prefix = String::from_utf8_lossy(prefix);
bail!("git: {path} lies outside {prefix}");
};
let parts = rest.split('/').filter(|part| !part.is_empty());
let path = parts.fold(folder.to_path_buf(), |path, part| path.join(part));
let mut path = folder.to_path_buf();
for part in rest.split(|&byte| byte == b'/') {
if !part.is_empty() {
path.push(path_of(part)?);
}
}
Ok((path, status))
})
.collect::<anyhow::Result<_>>()
@@ -187,8 +236,8 @@ pub fn changes(folder: &Path) -> anyhow::Result<Option<Vec<(PathBuf, GitStatus)>
/// A changed file, by its path from the work tree's top; a staged rename keeps its old path.
#[derive(Clone, Debug, PartialEq)]
pub struct Change {
pub path: String,
pub from: Option<String>,
pub path: PathBuf,
pub from: Option<PathBuf>,
pub status: GitStatus,
}
@@ -209,19 +258,19 @@ pub struct Repo {
/// The work tree holding `folder`, read whole; none outside one.
pub fn repo(folder: &Path) -> anyhow::Result<Option<Repo>> {
let top = git(folder, &["rev-parse", "--show-toplevel"], &[])?;
if !top.status.success() {
let error = said(&top);
let answer = git(folder, &["rev-parse", "--show-toplevel"], &[])?;
if !answer.status.success() {
let error = said(&answer);
if error.contains("not a git repository") || error.contains("must be run in a work tree") {
return Ok(None);
}
bail!("git: rev-parse in {} failed: {error}", folder.display());
}
let top = String::from_utf8_lossy(&top.stdout);
let top = top
.strip_suffix('\n')
.with_context(|| format!("git: rev-parse in {} printed {top:?}", folder.display()))?;
let top = PathBuf::from(top);
let top = answer.stdout.strip_suffix(b"\n").with_context(|| {
let printed = String::from_utf8_lossy(&answer.stdout);
format!("git: rev-parse in {} printed {printed:?}", folder.display())
})?;
let top = path_of(top)?;
let status = ["status", "--porcelain=v2", "--branch", "-z"];
let (head, records) = parse(&run(&top, &status, &[])?)?;
let mut repo = Repo {
@@ -234,8 +283,9 @@ pub fn repo(folder: &Path) -> anyhow::Result<Option<Repo>> {
untracked: Vec::new(),
};
for record in records {
let path = path_of(&record.path)?;
let change = |status, from| Change {
path: record.path.clone(),
path: path.clone(),
from,
status,
};
@@ -244,12 +294,17 @@ pub fn repo(folder: &Path) -> anyhow::Result<Option<Repo>> {
GitStatus::Conflicted => repo.conflicted.push(change(GitStatus::Conflicted, None)),
_ => {
if record.x != b'.' {
let staged = mark(&record, record.x, b'.')?;
repo.staged.push(change(staged, record.from.clone()));
// A copy's source is no part of it.
let from = match (record.x, &record.from) {
(b'R', Some(from)) => Some(path_of(from)?),
_ => None,
};
repo.staged
.push(change(mark(&record, record.x, b'.')?, from));
}
if record.y != b'.' {
let unstaged = mark(&record, b'.', record.y)?;
repo.unstaged.push(change(unstaged, None));
repo.unstaged
.push(change(mark(&record, b'.', record.y)?, None));
}
}
}
@@ -257,14 +312,14 @@ pub fn repo(folder: &Path) -> anyhow::Result<Option<Repo>> {
Ok(Some(repo))
}
/// Paths for git's `--pathspec-from-file`: each change's and a rename's old one, NUL-ended.
/// Paths for git's `--pathspec-from-file`: each change's and a rename's old one, NUL-ended, as git's bytes.
fn pathspecs(changes: &[Change]) -> anyhow::Result<Vec<u8>> {
// No path would take the whole tree.
ensure!(!changes.is_empty(), "git: no file named");
let mut out = Vec::new();
for change in changes {
for path in std::iter::once(&change.path).chain(&change.from) {
out.extend_from_slice(path.as_bytes());
out.extend_from_slice(path.as_os_str().as_encoded_bytes());
out.push(0);
}
}
@@ -272,7 +327,10 @@ fn pathspecs(changes: &[Change]) -> anyhow::Result<Vec<u8>> {
}
fn names(changes: &[Change]) -> String {
let names: Vec<&str> = changes.iter().map(|change| change.path.as_str()).collect();
let names: Vec<String> = changes
.iter()
.map(|change| change.path.display().to_string())
.collect();
names.join(", ")
}
@@ -339,29 +397,38 @@ pub fn diff(
change: &Change,
section: ChangeSection,
) -> anyhow::Result<Option<Vec<Stretch>>> {
let path = change.path.as_str();
let mut args = vec!["diff", "--no-color", "--no-ext-diff"];
match section {
ChangeSection::Staged => args.extend(["--cached", "-M", "--", path]),
ChangeSection::Unstaged => args.extend(["--", path]),
ChangeSection::Conflicted => args.extend(["--ours", "--", path]),
ChangeSection::Untracked if path.ends_with('/') => bail!("git: {path} is a folder"),
ChangeSection::Untracked => args.extend(["--no-index", "--", "/dev/null", path]),
ChangeSection::Ignored => bail!("git: {path} is ignored"),
}
args.extend(change.from.as_deref());
let (path, shown) = (change.path.as_os_str(), change.path.display());
let flags: &[&str] = match section {
ChangeSection::Staged => &["--cached", "-M", "--"],
ChangeSection::Unstaged => &["--"],
// HEAD is our side: a file we deleted reads as new.
ChangeSection::Conflicted => &["HEAD", "--"],
ChangeSection::Untracked if path.as_encoded_bytes().ends_with(b"/") => {
bail!("git: {shown} is a folder")
}
ChangeSection::Untracked => &["--no-index", "--", "/dev/null"],
ChangeSection::Ignored => bail!("git: {shown} is ignored"),
};
let mut args: Vec<&OsStr> = ["diff", "--no-color", "--no-ext-diff", "--no-textconv"]
.into_iter()
.chain(flags.iter().copied())
.map(OsStr::new)
.collect();
args.push(path);
args.extend(change.from.as_deref().map(Path::as_os_str));
let output = git(top, &args, &[])?;
// Without an index, differing files exit 1.
// Without an index git exits 1 for a difference and a failure alike; only a difference prints.
let differ = section == ChangeSection::Untracked
&& output.status.code() == Some(1)
&& output.stderr.is_empty();
&& !output.stdout.is_empty();
ensure!(
output.status.success() || differ,
"git: `git {}` failed in {}: {}",
args.join(" "),
line(&args),
top.display(),
said(&output)
);
warnings(top, &args, &output);
hunks(&String::from_utf8_lossy(&output.stdout))
}