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

+2 -2
View File
@@ -61,6 +61,6 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound. A full reset, `ESC c`, empties alacritty's title and sends only `CursorBlinkingChange`, in the parser's order, synchronized updates too; on that event the view replays the title through `set_options`, which sends it.
- 2026-10-14: the title bar is transparent, as Ghostty's: the content runs under it, Ely's column headers drag the window, and the terminal's header shows the program's title. macOS draws its traffic lights at Ely's origin; elsewhere Ely draws the controls, so windows ask for client decorations, which only Linux reads, and Ely's `ResizeBorder` gives the edges. Cmd-B and Cmd-Alt-B (Ctrl-Shift-B and Ctrl-Shift-Alt-B off macOS) toggle the sessions and right columns, kept in the settings.
- 2026-10-14: session keys are Ghostty's: Cmd-T, Cmd-W, Cmd-1 to Cmd-8, Cmd-9 for the last, Ctrl-Tab and Ctrl-Shift-Tab; off macOS Ctrl-Shift-T, Ctrl-Shift-W and Alt with the digits. A digit past the last session goes on to the program. A new session runs the Shell setting in home or in the shown session's folder, as Start in says. A session whose program ends closes its row; the last row closes the window, and with no window left the app quits. A bell in a hidden session marks its row until it is shown. A hidden terminal hears no mouse release, so hiding it ends its mouse gesture as releases would: the selection drag ends, and the program hears each held button come up. A row's title is the program's, or the app's name while it names none.
- 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v1 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A program without shell integration gets a line in the right column until it reports a folder. A control in a column header keeps its presses: Ely's header zooms the window on any double press.
- 2026-10-14: git runs in English with `--literal-pathspecs` and without optional locks. Status reads porcelain v2. Paths to stage and unstage go in on stdin, NUL-ended (`--pathspec-from-file`), so any name and any count pass; an empty list is refused, since git would take it as the whole tree. Unstaging is `git reset`, which works before the first commit, and a staged rename unstages with its old path. A diff is git's own, without external tools, a conflict against our side, read into Ely's stretches; a binary file has none, and an untracked folder none either. The log needs a commit at HEAD: git refuses it before the first.
- 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v2 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A name not in UTF-8 keeps its bytes: its label shows it lossily, and its tree key adds the bytes after a NUL, which no path holds, so no two names share a key. A program without shell integration gets a line in the right column until it reports a folder. A control in a column header keeps its presses: Ely's header zooms the window on any double press.
- 2026-10-14: git runs in English with `--literal-pathspecs` and without optional locks. Paths stay git's bytes until shown, so a name not in UTF-8 still names its own file; off Unix git writes UTF-8. Status reads porcelain v2. Paths to stage and unstage go in on stdin, NUL-ended (`--pathspec-from-file`), so any name and any count pass; an empty list is refused, since git would take it as the whole tree. Unstaging is `git reset`, which works before the first commit, and a staged rename unstages with its old path; a copy is a new file, its source no part of it. A diff is git's own, without external tools or text converters, a conflict against HEAD, our side, so a file we deleted reads as new; it is read into Ely's stretches; a binary file has none, and an untracked folder none either. Without an index git exits 1 for a difference and a failure alike; only a difference prints. What git warns of while it works goes to the log. The log needs a commit at HEAD: git refuses it before the first.
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
+42 -12
View File
@@ -1,6 +1,7 @@
use std::{
borrow::Cow,
collections::{HashMap, HashSet},
ffi::OsString,
fs, io,
path::{Path, PathBuf},
};
@@ -17,7 +18,7 @@ use crate::{git, sessions};
/// A folder's entry: its name, and whether it is a folder, links followed.
#[derive(Debug, PartialEq)]
struct Entry {
name: String,
name: OsString,
folder: bool,
}
@@ -33,14 +34,16 @@ fn list(folder: &Path) -> Listing {
let kind = entry.file_type()?;
Ok(Entry {
folder: kind.is_dir() || (kind.is_symlink() && entry.path().is_dir()),
name: entry.file_name().to_string_lossy().into_owned(),
name: entry.file_name(),
})
})
.collect()
};
let mut entries = read().map_err(|error| error.to_string())?;
entries
.sort_by_cached_key(|entry| (!entry.folder, entry.name.to_lowercase(), entry.name.clone()));
entries.sort_by_cached_key(|entry| {
let name = entry.name.to_string_lossy().to_lowercase();
(!entry.folder, name, entry.name.clone())
});
Ok(entries)
}
@@ -92,8 +95,16 @@ fn badge(node: TreeNode, mark: Option<GitStatus>, dirty: bool) -> TreeNode {
node.note(note).tone(tone)
}
/// `path`'s tree key, one to a path: a path not in UTF-8 adds its bytes after a NUL, which no path holds.
fn key(path: &Path) -> SharedString {
path.to_string_lossy().into_owned().into()
match path.to_str() {
Some(text) => text.to_string().into(),
None => {
let bytes = path.as_os_str().as_encoded_bytes();
let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
format!("{}\0{hex}", path.to_string_lossy()).into()
}
}
}
/// The Files panel: the shown session's folder as a tree, Git's marks on names.
@@ -207,6 +218,19 @@ impl Files {
}
}
/// The path a tree key stands for: the root, or an entry read.
fn path(&self, of: &str) -> Option<PathBuf> {
let entries = self.listings.iter().flat_map(|(folder, listing)| {
listing
.iter()
.flatten()
.map(|entry| folder.join(&entry.name))
});
std::iter::once(self.root.clone())
.chain(entries)
.find(|path| key(path).as_ref() == of)
}
/// Folder `path`'s node, its entries once read; `untracked` when an untracked folder holds it.
fn folder(&self, path: &Path, label: String, untracked: bool) -> TreeNode {
let own = |path: &Path| self.marks.get(mark_key(path).as_ref()).copied();
@@ -217,7 +241,8 @@ impl Files {
None => node.pending(),
Some(Err(_)) => return node.children([]).note("unreadable").tone(Tone::Danger),
Some(Ok(entries)) => node.children(entries.iter().map(|entry| {
let (path, label) = (path.join(&entry.name), sessions::one_line(&entry.name));
let path = path.join(&entry.name);
let label = sessions::one_line(&entry.name.to_string_lossy());
if entry.folder {
return self.folder(&path, label, inside);
}
@@ -242,19 +267,24 @@ impl Render for Files {
}
_ => sessions::shown(&self.root, home.as_deref()),
};
let files = cx.entity();
let (load, open) = (cx.entity(), cx.entity());
Tree::new(
("files", self.generation),
[self.folder(&self.root, label, false)],
)
.open([key(&self.root)])
.on_load(move |key, _, cx| {
let folder = PathBuf::from(key.as_ref());
files.update(cx, |files, cx| files.load(folder, cx))
load.update(cx, |files, cx| match files.path(key) {
Some(folder) => files.load(folder, cx),
None => log::warn!("files: {key} to read is gone"),
})
})
.on_activate(|key, _, cx| {
log::info!("files: opening {key}");
cx.open_with_system(Path::new(key.as_ref()))
.on_activate(move |key, _, cx| match open.read(cx).path(key) {
Some(path) => {
log::info!("files: opening {}", path.display());
cx.open_with_system(&path)
}
None => log::warn!("files: {key} to open is gone"),
})
.size_full()
}
+30 -1
View File
@@ -49,7 +49,7 @@ fn folders_come_first_then_files_by_name_ignoring_case() {
let listed: Vec<_> = list(&root).expect("a listing");
let listed: Vec<_> = listed
.iter()
.map(|entry| (entry.name.as_str(), entry.folder))
.map(|entry| (entry.name.to_str().expect("a UTF-8 name"), entry.folder))
.collect();
assert_eq!(listed, expected);
assert!(list(&root.join("missing")).is_err());
@@ -161,6 +161,35 @@ fn names_with_newlines_label_on_one_line_and_keep_their_paths() {
assert_eq!(files.folder(&root, "r".into(), false), expected);
}
#[cfg(unix)]
#[test]
fn names_that_show_alike_keep_their_own_keys_and_paths() {
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
let root = PathBuf::from("/r");
let raw = OsStr::from_bytes(b"bad\xff.txt");
let (text, bytes) = (root.join("bad\u{FFFD}.txt"), root.join(raw));
let mut files = Files::new(root.clone());
let entry = |name: &OsStr| Entry {
name: name.into(),
folder: false,
};
let entries = vec![entry(OsStr::new("bad\u{FFFD}.txt")), entry(raw)];
files.listings.insert(root.clone(), Ok(entries));
assert_ne!(key(&text), key(&bytes), "two paths, two keys");
let expected = TreeNode::new(key(&root), "r")
.icon(IconName::Folder)
.children([
TreeNode::new(key(&text), "bad\u{FFFD}.txt").icon(IconName::File),
TreeNode::new(key(&bytes), "bad\u{FFFD}.txt").icon(IconName::File),
]);
assert_eq!(files.folder(&root, "r".into(), false), expected);
for path in [&root, &text, &bytes] {
let found = files.path(&key(path));
assert_eq!(found.as_ref(), Some(path), "{}", path.display());
}
assert_eq!(files.path("/r/gone"), None);
}
/// Runs git in `root` as a fixed author, unsigned.
#[cfg(target_os = "macos")]
fn run(root: &Path, args: &[&str]) {
+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))
}
+51 -157
View File
@@ -1,4 +1,4 @@
use std::{env, fs, ops::Range};
use std::{env, fs};
use gpui::SharedString;
@@ -313,170 +313,64 @@ fn a_commit_takes_the_staged_files_and_heads_the_log() {
assert!(format!("{error:#}").contains("nothing"), "{error:#}");
}
fn line(kind: LineKind, at: (Option<usize>, Option<usize>), text: &str) -> DiffLine {
lined(kind, at, text, Vec::new())
}
fn lined(
kind: LineKind,
(old, new): (Option<usize>, Option<usize>),
text: &str,
words: Vec<Range<usize>>,
) -> DiffLine {
DiffLine {
kind,
old,
new,
text: text.to_string().into(),
words,
}
}
fn hunk(header: &str, lines: Vec<DiffLine>) -> Stretch {
Stretch::Hunk {
header: header.to_string().into(),
lines,
}
}
#[test]
fn diffs_read_as_ely_stretches() {
use LineKind::*;
let root = repository("git-diff");
write(&root, "a.txt", "one\nthe old line\nthree\n");
write(&root, "old.txt", "kept\n");
fs::write(root.join("bin.dat"), b"x\0y").expect("a binary file");
fn a_copy_keeps_no_source_to_unstage_or_diff() {
let root = repository("git-copy");
prepare(&root, &["config", "status.renames", "copies"]);
prepare(&root, &["config", "diff.renames", "copies"]);
let text: String = (1..=50).map(|n| format!("line {n}\n")).collect();
write(&root, "source.txt", &text);
prepare(&root, &["add", "."]);
prepare(&root, &["commit", "-qm", "first"]);
write(&root, "a.txt", "one\nthe new line\nthree\n");
prepare(&root, &["add", "a.txt"]);
write(&root, "a.txt", "one\nthe new line\nthree\nfour");
fs::write(root.join("bin.dat"), b"x\0z").expect("a binary edit");
prepare(&root, &["mv", "old.txt", "new.txt"]);
write(&root, "fresh.txt", "fresh\nlines\n");
write(&root, "notes/todo.txt", "todo\n");
let a = change("a.txt", GitStatus::Modified);
let staged = diff(&root, &a, ChangeSection::Staged).expect("a diff");
let words = |kind, at, text| lined(kind, at, text, vec![Range { start: 4, end: 7 }]);
let expected = hunk(
"@@ -1,3 +1,3 @@",
vec![
line(Same, (Some(1), Some(1)), "one"),
words(Removed, (Some(2), None), "the old line"),
words(Added, (None, Some(2)), "the new line"),
line(Same, (Some(3), Some(3)), "three"),
],
);
assert_eq!(staged, Some(vec![expected]));
let unstaged = diff(&root, &a, ChangeSection::Unstaged).expect("a diff");
let expected = hunk(
"@@ -1,3 +1,4 @@",
vec![
line(Same, (Some(1), Some(1)), "one"),
line(Same, (Some(2), Some(2)), "the new line"),
line(Same, (Some(3), Some(3)), "three"),
line(Added, (None, Some(4)), "four"),
],
);
assert_eq!(unstaged, Some(vec![expected]));
let fresh = change("fresh.txt", GitStatus::Untracked);
let untracked = diff(&root, &fresh, ChangeSection::Untracked).expect("a diff");
let expected = hunk(
"@@ -0,0 +1,2 @@",
vec![
line(Added, (None, Some(1)), "fresh"),
line(Added, (None, Some(2)), "lines"),
],
);
assert_eq!(untracked, Some(vec![expected]));
let binary = change("bin.dat", GitStatus::Modified);
let binary = diff(&root, &binary, ChangeSection::Unstaged).expect("a diff");
assert_eq!(binary, None, "a binary file has no lines");
let moved = read(&root)
.staged
.into_iter()
.find(|change| change.from.is_some());
let moved = moved.expect("a staged rename");
let renamed = diff(&root, &moved, ChangeSection::Staged).expect("a diff");
assert_eq!(renamed, Some(Vec::new()), "a pure rename changes no line");
let notes = change("notes/", GitStatus::Untracked);
assert!(diff(&root, &notes, ChangeSection::Untracked).is_err());
}
#[test]
fn a_conflict_diffs_against_our_side() {
use LineKind::*;
let root = repository("git-conflict");
write(&root, "c.txt", "base\n");
write(&root, "copy.txt", &text);
write(&root, "source.txt", &format!("{text}more\n"));
prepare(&root, &["add", "."]);
prepare(&root, &["commit", "-qm", "base"]);
prepare(&root, &["checkout", "-qb", "other"]);
write(&root, "c.txt", "theirs\n");
prepare(&root, &["commit", "-qam", "theirs"]);
prepare(&root, &["checkout", "-q", "main"]);
write(&root, "c.txt", "ours\n");
prepare(&root, &["commit", "-qam", "ours"]);
let merged = Command::new("git")
.arg("-C")
.arg(&root)
.args(["merge", "-q", "other"])
.output()
.expect("git runs");
assert!(!merged.status.success(), "the merge stops");
let state = read(&root);
assert_eq!(state.conflicted, [change("c.txt", GitStatus::Conflicted)]);
let ours = diff(&root, &state.conflicted[0], ChangeSection::Conflicted).expect("a diff");
let Some([Stretch::Hunk { lines, .. }]) = ours.as_deref() else {
panic!("one hunk: {ours:?}");
let source = change("source.txt", GitStatus::Modified);
let copy = change("copy.txt", GitStatus::Added);
assert_eq!(read(&root).staged, [copy.clone(), source.clone()]);
let stretches = diff(&root, &copy, ChangeSection::Staged).expect("a diff");
let Some([Stretch::Hunk { lines, .. }]) = stretches.as_deref() else {
panic!("one hunk: {stretches:?}");
};
let shown: Vec<_> = lines
.iter()
.map(|line| (line.kind, line.text.as_ref()))
.collect();
let expected = [
(Added, "<<<<<<< HEAD"),
(Same, "ours"),
(Added, "======="),
(Added, "theirs"),
(Added, ">>>>>>> other"),
];
assert_eq!(shown, expected);
let added = lines.iter().all(|line| line.kind == LineKind::Added);
assert!(
added && lines.len() == 50,
"the copy's lines alone: {lines:?}"
);
unstage(&root, &[copy]).expect("an unstaged copy");
assert_eq!(read(&root).staged, [source], "the source stays staged");
}
#[cfg(unix)]
#[test]
fn hunks_number_lines_skip_markers_and_stop_at_the_next_file() {
use LineKind::*;
let text = "* Unmerged path a\ndiff --git a/a b/a\n--- a/a\n+++ b/a\n\
@@ -3 +3,2 @@ fn main\n-x\r\n+y\n+z\n\\ No newline at end of file\n\
diff --git a/b b/b\nnew file mode 100644\n--- /dev/null\n+++ b/b\n@@ -0,0 +1 @@\n+b\n";
let first = hunk(
"@@ -3 +3,2 @@ fn main",
vec![
line(Removed, (Some(3), None), "x"),
line(Added, (None, Some(3)), "y"),
line(Added, (None, Some(4)), "z"),
],
);
let second = hunk("@@ -0,0 +1 @@", vec![line(Added, (None, Some(1)), "b")]);
assert_eq!(hunks(text).expect("hunks"), Some(vec![first, second]));
let blank = hunk(
"@@ -1,3 +1,3 @@",
vec![
line(Same, (Some(1), Some(1)), "a"),
line(Same, (Some(2), Some(2)), ""),
line(Removed, (Some(3), None), "b"),
line(Added, (None, Some(3)), "c"),
],
);
let suppressed = "@@ -1,3 +1,3 @@\n a\n\n-b\n+c\n";
assert_eq!(hunks(suppressed).expect("hunks"), Some(vec![blank]));
let binary = "diff --git a/x b/x\nBinary files a/x and b/x differ\n";
assert_eq!(hunks(binary).expect("a diff"), None);
assert!(hunks("@@ -a +b @@\n").is_err(), "a header without numbers");
assert!(
hunks("@@ -1 +1 @@\n?x\n").is_err(),
"a line git never writes"
fn names_that_show_alike_stay_apart() {
let root = repository("git-bytes");
write(&root, "bad\u{FFFD}.txt", "text\n");
prepare(&root, &["add", "."]);
let blob = run(&root, &["hash-object", "-w", "--stdin"], b"text\n").expect("a blob");
// A name not in UTF-8, which macOS refuses on disk, goes straight into the index.
let mut entry = b"100644 ".to_vec();
entry.extend(blob.trim_ascii());
entry.extend(b"\tbad\xff.txt\0");
run(&root, &["update-index", "-z", "--index-info"], &entry).expect("an index entry");
let raw = <OsStr as std::os::unix::ffi::OsStrExt>::from_bytes(b"bad\xff.txt");
let staged = read(&root).staged;
let shown: Vec<_> = staged
.iter()
.map(|change| change.path.to_string_lossy())
.collect();
assert_eq!(
shown,
["bad\u{FFFD}.txt", "bad\u{FFFD}.txt"],
"two names show alike"
);
let bytes = staged.iter().find(|change| change.path == raw);
let bytes = bytes.expect("the name in bytes").clone();
unstage(&root, &[bytes]).expect("an unstage");
let left = [change("bad\u{FFFD}.txt", GitStatus::Added)];
assert_eq!(read(&root).staged, left, "the other name stays staged");
}
mod diffs;
+229
View File
@@ -0,0 +1,229 @@
use std::ops::Range;
use super::*;
fn line(kind: LineKind, at: (Option<usize>, Option<usize>), text: &str) -> DiffLine {
lined(kind, at, text, Vec::new())
}
fn lined(
kind: LineKind,
(old, new): (Option<usize>, Option<usize>),
text: &str,
words: Vec<Range<usize>>,
) -> DiffLine {
DiffLine {
kind,
old,
new,
text: text.to_string().into(),
words,
}
}
fn hunk(header: &str, lines: Vec<DiffLine>) -> Stretch {
Stretch::Hunk {
header: header.to_string().into(),
lines,
}
}
#[test]
fn diffs_read_as_ely_stretches() {
use LineKind::*;
let root = repository("git-diff");
write(&root, "a.txt", "one\nthe old line\nthree\n");
write(&root, "old.txt", "kept\n");
fs::write(root.join("bin.dat"), b"x\0y").expect("a binary file");
prepare(&root, &["add", "."]);
prepare(&root, &["commit", "-qm", "first"]);
write(&root, "a.txt", "one\nthe new line\nthree\n");
prepare(&root, &["add", "a.txt"]);
write(&root, "a.txt", "one\nthe new line\nthree\nfour");
fs::write(root.join("bin.dat"), b"x\0z").expect("a binary edit");
prepare(&root, &["mv", "old.txt", "new.txt"]);
write(&root, "fresh.txt", "fresh\nlines\n");
write(&root, "notes/todo.txt", "todo\n");
let a = change("a.txt", GitStatus::Modified);
let staged = diff(&root, &a, ChangeSection::Staged).expect("a diff");
let words = |kind, at, text| lined(kind, at, text, vec![Range { start: 4, end: 7 }]);
let expected = hunk(
"@@ -1,3 +1,3 @@",
vec![
line(Same, (Some(1), Some(1)), "one"),
words(Removed, (Some(2), None), "the old line"),
words(Added, (None, Some(2)), "the new line"),
line(Same, (Some(3), Some(3)), "three"),
],
);
assert_eq!(staged, Some(vec![expected]));
let unstaged = diff(&root, &a, ChangeSection::Unstaged).expect("a diff");
let expected = hunk(
"@@ -1,3 +1,4 @@",
vec![
line(Same, (Some(1), Some(1)), "one"),
line(Same, (Some(2), Some(2)), "the new line"),
line(Same, (Some(3), Some(3)), "three"),
line(Added, (None, Some(4)), "four"),
],
);
assert_eq!(unstaged, Some(vec![expected]));
let fresh = change("fresh.txt", GitStatus::Untracked);
let untracked = diff(&root, &fresh, ChangeSection::Untracked).expect("a diff");
let expected = hunk(
"@@ -0,0 +1,2 @@",
vec![
line(Added, (None, Some(1)), "fresh"),
line(Added, (None, Some(2)), "lines"),
],
);
assert_eq!(untracked, Some(vec![expected]));
let binary = change("bin.dat", GitStatus::Modified);
let binary = diff(&root, &binary, ChangeSection::Unstaged).expect("a diff");
assert_eq!(binary, None, "a binary file has no lines");
let moved = read(&root)
.staged
.into_iter()
.find(|change| change.from.is_some());
let moved = moved.expect("a staged rename");
let renamed = diff(&root, &moved, ChangeSection::Staged).expect("a diff");
assert_eq!(renamed, Some(Vec::new()), "a pure rename changes no line");
let notes = change("notes/", GitStatus::Untracked);
assert!(diff(&root, &notes, ChangeSection::Untracked).is_err());
}
/// A repository whose merge of `other`, which made c.txt `theirs`, stops on what `ours` did to it.
fn conflict(name: &str, ours: impl FnOnce(&Path)) -> PathBuf {
let root = repository(name);
write(&root, "c.txt", "base\n");
prepare(&root, &["add", "."]);
prepare(&root, &["commit", "-qm", "base"]);
prepare(&root, &["checkout", "-qb", "other"]);
write(&root, "c.txt", "theirs\n");
prepare(&root, &["commit", "-qam", "theirs"]);
prepare(&root, &["checkout", "-q", "main"]);
ours(&root);
prepare(&root, &["commit", "-qam", "ours"]);
let merged = Command::new("git")
.arg("-C")
.arg(&root)
.args(["merge", "-q", "other"])
.output()
.expect("git runs");
assert!(!merged.status.success(), "the merge stops");
root
}
#[test]
fn a_conflict_diffs_against_head_our_side() {
use LineKind::*;
let root = conflict("git-conflict", |root| write(root, "c.txt", "ours\n"));
let state = read(&root);
assert_eq!(state.conflicted, [change("c.txt", GitStatus::Conflicted)]);
let ours = diff(&root, &state.conflicted[0], ChangeSection::Conflicted).expect("a diff");
let Some([Stretch::Hunk { lines, .. }]) = ours.as_deref() else {
panic!("one hunk: {ours:?}");
};
let shown: Vec<_> = lines
.iter()
.map(|line| (line.kind, line.text.as_ref()))
.collect();
let expected = [
(Added, "<<<<<<< HEAD"),
(Same, "ours"),
(Added, "======="),
(Added, "theirs"),
(Added, ">>>>>>> other"),
];
assert_eq!(shown, expected);
let root = conflict("git-conflict-deleted", |root| {
prepare(root, &["rm", "-q", "c.txt"])
});
let state = read(&root);
assert_eq!(state.conflicted, [change("c.txt", GitStatus::Conflicted)]);
let theirs = diff(&root, &state.conflicted[0], ChangeSection::Conflicted).expect("a diff");
let expected = hunk(
"@@ -0,0 +1 @@",
vec![line(Added, (None, Some(1)), "theirs")],
);
assert_eq!(
theirs,
Some(vec![expected]),
"a file we deleted reads as new"
);
}
#[test]
fn a_diff_runs_no_text_converter() {
use LineKind::*;
let root = repository("git-textconv");
write(&root, "a.txt", "old\n");
write(&root, ".gitattributes", "a.txt diff=review\n");
prepare(&root, &["add", "."]);
prepare(&root, &["commit", "-qm", "first"]);
// Git dies when a converter fails, so this one would fail the diff it ran in.
prepare(&root, &["config", "diff.review.textconv", "false"]);
write(&root, "a.txt", "new\n");
let a = change("a.txt", GitStatus::Modified);
let expected = hunk(
"@@ -1 +1 @@",
vec![
line(Removed, (Some(1), None), "old"),
line(Added, (None, Some(1)), "new"),
],
);
let unstaged = diff(&root, &a, ChangeSection::Unstaged).expect("a diff");
assert_eq!(unstaged, Some(vec![expected]));
}
#[test]
fn an_untracked_diff_outlasts_a_warning_not_a_missing_file() {
use LineKind::*;
let root = repository("git-crlf");
prepare(&root, &["config", "core.autocrlf", "true"]);
write(&root, "fresh.txt", "fresh\n");
let fresh = change("fresh.txt", GitStatus::Untracked);
let untracked = diff(&root, &fresh, ChangeSection::Untracked).expect("a diff git warned of");
let expected = hunk("@@ -0,0 +1 @@", vec![line(Added, (None, Some(1)), "fresh")]);
assert_eq!(untracked, Some(vec![expected]));
let gone = change("gone.txt", GitStatus::Untracked);
assert!(diff(&root, &gone, ChangeSection::Untracked).is_err());
}
#[test]
fn hunks_number_lines_skip_markers_and_stop_at_the_next_file() {
use LineKind::*;
let text = "* Unmerged path a\ndiff --git a/a b/a\n--- a/a\n+++ b/a\n\
@@ -3 +3,2 @@ fn main\n-x\r\n+y\n+z\n\\ No newline at end of file\n\
diff --git a/b b/b\nnew file mode 100644\n--- /dev/null\n+++ b/b\n@@ -0,0 +1 @@\n+b\n";
let first = hunk(
"@@ -3 +3,2 @@ fn main",
vec![
line(Removed, (Some(3), None), "x"),
line(Added, (None, Some(3)), "y"),
line(Added, (None, Some(4)), "z"),
],
);
let second = hunk("@@ -0,0 +1 @@", vec![line(Added, (None, Some(1)), "b")]);
assert_eq!(hunks(text).expect("hunks"), Some(vec![first, second]));
let blank = hunk(
"@@ -1,3 +1,3 @@",
vec![
line(Same, (Some(1), Some(1)), "a"),
line(Same, (Some(2), Some(2)), ""),
line(Removed, (Some(3), None), "b"),
line(Added, (None, Some(3)), "c"),
],
);
let suppressed = "@@ -1,3 +1,3 @@\n a\n\n-b\n+c\n";
assert_eq!(hunks(suppressed).expect("hunks"), Some(vec![blank]));
let binary = "diff --git a/x b/x\nBinary files a/x and b/x differ\n";
assert_eq!(hunks(binary).expect("a diff"), None);
assert!(hunks("@@ -a +b @@\n").is_err(), "a header without numbers");
assert!(
hunks("@@ -1 +1 @@\n?x\n").is_err(),
"a line git never writes"
);
}