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
This commit is contained in:
6 files changed
+486
-237
No files matched your search
+42
-12
@@ -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
@@ -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
@@ -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
@@ -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, ¬es, 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, ©, 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;
|
||||
@@ -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, ¬es, 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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user