T14: git through its command line: status from porcelain v2 with the branch and HEAD's commit, in conflicted, staged, unstaged and untracked sections; stage and unstage by literal paths fed on stdin, a staged rename with its old path, before the first commit too; commit from stdin; the log as Ely's commits; a diff as git writes it, read into Ely's stretches with changed words marked, none for a binary file; Files' marks read the same records; tests on temp repositories
This commit is contained in:
3 files changed
+880
-205
No files matched your search
@@ -21,7 +21,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/sessions.rs`: a session and its row. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line.
|
||||
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/sessions.rs`: a session and its row. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, commit, log and diff.
|
||||
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
|
||||
@@ -62,4 +62,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 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: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
|
||||
+396
-204
@@ -1,101 +1,133 @@
|
||||
use std::{
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Output},
|
||||
process::{Command, Output, Stdio},
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, bail};
|
||||
use ely_gpui_component::lists::GitStatus;
|
||||
use anyhow::{Context as _, anyhow, bail, ensure};
|
||||
use ely_gpui_component::{
|
||||
git::{ChangeSection, Commit, DiffLine, LineKind, Stretch, mark_words},
|
||||
lists::GitStatus,
|
||||
};
|
||||
|
||||
/// Runs git in `folder` with English messages, taking no lock the user's own git could meet.
|
||||
fn git(folder: &Path, args: &[&str]) -> anyhow::Result<Output> {
|
||||
Command::new("git")
|
||||
/// 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());
|
||||
let mut child = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(folder)
|
||||
.arg("--no-optional-locks")
|
||||
.args(["--no-optional-locks", "--literal-pathspecs"])
|
||||
.args(args)
|
||||
.env("LC_ALL", "C")
|
||||
.output()
|
||||
.with_context(|| format!("git: `git {}` did not start", args.join(" ")))
|
||||
}
|
||||
|
||||
/// Each change at or under `folder`, by its full path; none outside a work tree.
|
||||
pub fn changes(folder: &Path) -> anyhow::Result<Option<Vec<(PathBuf, GitStatus)>>> {
|
||||
let place = git(
|
||||
folder,
|
||||
&["rev-parse", "--is-inside-work-tree", "--show-prefix"],
|
||||
)?;
|
||||
if !place.status.success() {
|
||||
let error = String::from_utf8_lossy(&place.stderr);
|
||||
if error.contains("not a git repository") {
|
||||
return Ok(None);
|
||||
}
|
||||
bail!(
|
||||
"git: rev-parse in {} failed: {}",
|
||||
folder.display(),
|
||||
error.trim()
|
||||
);
|
||||
}
|
||||
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 prefix = match answer {
|
||||
Some(("true", prefix)) => prefix,
|
||||
Some(("false", _)) => return Ok(None),
|
||||
_ => bail!("git: rev-parse in {} printed {place:?}", folder.display()),
|
||||
};
|
||||
let status = git(folder, &["status", "--porcelain=v1", "-z", "--", "."])?;
|
||||
if !status.status.success() {
|
||||
let error = String::from_utf8_lossy(&status.stderr);
|
||||
bail!(
|
||||
"git: status in {} failed: {}",
|
||||
folder.display(),
|
||||
error.trim()
|
||||
);
|
||||
}
|
||||
parse(&status.stdout)?
|
||||
.into_iter()
|
||||
.map(|(path, status)| {
|
||||
let Some(rest) = path.strip_prefix(prefix) else {
|
||||
bail!("git: {path} lies outside {prefix}");
|
||||
};
|
||||
let parts = rest.split('/').filter(|part| !part.is_empty());
|
||||
Ok((
|
||||
parts.fold(folder.to_path_buf(), |path, part| path.join(part)),
|
||||
status,
|
||||
))
|
||||
.stdin(if input.is_empty() {
|
||||
Stdio::null()
|
||||
} else {
|
||||
Stdio::piped()
|
||||
})
|
||||
.collect::<anyhow::Result<_>>()
|
||||
.map(Some)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("{} did not start", named()))?;
|
||||
let wrote = child
|
||||
.stdin
|
||||
.take()
|
||||
.map_or(Ok(()), |mut stdin| stdin.write_all(input));
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.with_context(|| format!("{} did not end", named()))?;
|
||||
if output.status.success() {
|
||||
wrote.with_context(|| format!("{} took no input", named()))?;
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Porcelain v1 records, each ended by NUL: `XY path`, a rename's or copy's old path after it.
|
||||
fn parse(out: &[u8]) -> anyhow::Result<Vec<(String, GitStatus)>> {
|
||||
/// What git said about a failure: its errors, else its output, else its exit status.
|
||||
fn said(output: &Output) -> String {
|
||||
[&output.stderr, &output.stdout]
|
||||
.into_iter()
|
||||
.map(|text| String::from_utf8_lossy(text).trim().to_string())
|
||||
.find(|text| !text.is_empty())
|
||||
.unwrap_or_else(|| output.status.to_string())
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
let (command, place) = (args.join(" "), folder.display());
|
||||
ensure!(
|
||||
output.status.success(),
|
||||
"git: `git {command}` failed in {place}: {}",
|
||||
said(&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.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Record {
|
||||
x: u8,
|
||||
y: u8,
|
||||
path: String,
|
||||
from: Option<String>,
|
||||
}
|
||||
|
||||
/// HEAD as status reads it: its branch, none when detached; its commit, none before the first.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Head {
|
||||
branch: Option<String>,
|
||||
commit: Option<String>,
|
||||
}
|
||||
|
||||
/// Porcelain v2 records, each ended by NUL: headers name HEAD; a rename'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());
|
||||
let mut changes = Vec::new();
|
||||
.filter(|field| !field.is_empty())
|
||||
.map(String::from_utf8_lossy);
|
||||
let (mut head, mut records) = (Head::default(), Vec::new());
|
||||
while let Some(field) = fields.next() {
|
||||
let record = || String::from_utf8_lossy(field);
|
||||
let (x, y, path) = match field {
|
||||
[x, y, b' ', path @ ..] if !path.is_empty() => (*x, *y, path),
|
||||
_ => bail!("git: status record {:?} is malformed", record()),
|
||||
let malformed = || anyhow!("git: status record {field:?} is malformed");
|
||||
let (kind, rest) = field.split_once(' ').ok_or_else(malformed)?;
|
||||
// Fields before the path, which may hold spaces.
|
||||
let count = match kind {
|
||||
"#" => {
|
||||
match rest.split_once(' ') {
|
||||
Some(("branch.oid", oid)) => {
|
||||
head.commit = (oid != "(initial)").then(|| oid.into())
|
||||
}
|
||||
Some(("branch.head", name)) => {
|
||||
head.branch = (name != "(detached)").then(|| name.into())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
"?" => 0,
|
||||
"1" => 7,
|
||||
"2" => 8,
|
||||
"u" => 9,
|
||||
_ => return Err(malformed()),
|
||||
};
|
||||
let Some(status) = kind(x, y) else {
|
||||
bail!("git: status record {:?} has an unknown code", record());
|
||||
let parts: Vec<&str> = rest.splitn(count + 1, ' ').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),
|
||||
_ => return Err(malformed()),
|
||||
};
|
||||
if [x, y].iter().any(|code| matches!(code, b'R' | b'C')) {
|
||||
fields
|
||||
.next()
|
||||
.with_context(|| format!("git: {:?} lacks its old path", record()))?;
|
||||
}
|
||||
changes.push((String::from_utf8_lossy(path).into_owned(), status));
|
||||
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()),
|
||||
_ => None,
|
||||
};
|
||||
let path = path.to_string();
|
||||
records.push(Record { x, y, path, from });
|
||||
}
|
||||
Ok(changes)
|
||||
Ok((head, records))
|
||||
}
|
||||
|
||||
/// One mark for a record's index and work tree codes; a copy is a new file.
|
||||
/// One mark for an index and a work tree code, `.` for none; a copy is a new file.
|
||||
fn kind(x: u8, y: u8) -> Option<GitStatus> {
|
||||
Some(match (x, y) {
|
||||
(b'?', b'?') => GitStatus::Untracked,
|
||||
@@ -108,130 +140,290 @@ fn kind(x: u8, y: u8) -> Option<GitStatus> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{env, fs};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A fresh folder under the temp dir.
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
let folder = env::temp_dir().join(format!("elyterminal-{name}-{}", std::process::id()));
|
||||
if folder.exists() {
|
||||
fs::remove_dir_all(&folder).expect("a stale test folder goes");
|
||||
}
|
||||
fs::create_dir_all(&folder).expect("a test folder");
|
||||
folder
|
||||
}
|
||||
|
||||
/// Runs git in `folder` as a fixed author, unsigned.
|
||||
fn run(folder: &Path, args: &[&str]) {
|
||||
let done = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(folder)
|
||||
.args(["-c", "user.name=Ely", "-c", "user.email=ely@example.com"])
|
||||
.args(["-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git runs");
|
||||
let error = String::from_utf8_lossy(&done.stderr);
|
||||
assert!(done.status.success(), "git {args:?}: {error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_read_as_marks_skipping_old_paths() {
|
||||
let out = b"RM new.rs\0old.rs\0 M src/a.rs\0?? notes/\0AA both.txt\0UU clash.rs\0\
|
||||
A added.rs\0 D gone.rs\0C copy.rs\0from.rs\0 T link\0MM twice.rs\0DU kept.rs\0";
|
||||
let marks = parse(out).expect("records");
|
||||
let expected = [
|
||||
("new.rs", GitStatus::Renamed),
|
||||
("src/a.rs", GitStatus::Modified),
|
||||
("notes/", GitStatus::Untracked),
|
||||
("both.txt", GitStatus::Conflicted),
|
||||
("clash.rs", GitStatus::Conflicted),
|
||||
("added.rs", GitStatus::Added),
|
||||
("gone.rs", GitStatus::Deleted),
|
||||
("copy.rs", GitStatus::Added),
|
||||
("link", GitStatus::Modified),
|
||||
("twice.rs", GitStatus::Modified),
|
||||
("kept.rs", GitStatus::Conflicted),
|
||||
]
|
||||
.map(|(path, status)| (path.to_string(), status));
|
||||
assert_eq!(marks, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_git_never_writes_are_errors() {
|
||||
for out in [
|
||||
&b"M\0"[..],
|
||||
b"MM \0",
|
||||
b"!! ignored\0",
|
||||
b"R new.rs\0",
|
||||
b" x\0",
|
||||
] {
|
||||
assert!(parse(out).is_err(), "{:?}", String::from_utf8_lossy(out));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_work_tree_reports_changes_under_the_folder() {
|
||||
let root = folder("git");
|
||||
run(&root, &["init", "-q"]);
|
||||
run(&root, &["config", "status.showUntrackedFiles", "normal"]);
|
||||
fs::create_dir_all(root.join("src/inner")).expect("folders");
|
||||
for (path, text) in [
|
||||
("kept.txt", "kept"),
|
||||
("gone.txt", "gone"),
|
||||
("src/main.rs", "fn main() {}"),
|
||||
("src/inner/deep.rs", "deep"),
|
||||
] {
|
||||
fs::write(root.join(path), text).expect("a file");
|
||||
}
|
||||
run(&root, &["add", "."]);
|
||||
run(&root, &["commit", "-qm", "first"]);
|
||||
fs::write(root.join("src/main.rs"), "fn main() { }").expect("an edit");
|
||||
fs::remove_file(root.join("gone.txt")).expect("a removal");
|
||||
fs::write(root.join("added.txt"), "new").expect("a new file");
|
||||
run(&root, &["add", "added.txt"]);
|
||||
fs::create_dir_all(root.join("notes/sub")).expect("an untracked folder");
|
||||
fs::write(root.join("notes/sub/todo.txt"), "todo").expect("an untracked file");
|
||||
|
||||
let mut all = changes(&root).expect("status").expect("a work tree");
|
||||
all.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let expected = [
|
||||
("added.txt", GitStatus::Added),
|
||||
("gone.txt", GitStatus::Deleted),
|
||||
("notes", GitStatus::Untracked),
|
||||
("src/main.rs", GitStatus::Modified),
|
||||
]
|
||||
.map(|(path, status)| (root.join(path), status));
|
||||
assert_eq!(all, expected);
|
||||
let src = root.join("src");
|
||||
let main = (src.join("main.rs"), GitStatus::Modified);
|
||||
assert_eq!(changes(&src).expect("status"), Some(vec![main]));
|
||||
let sub = root.join("notes").join("sub");
|
||||
let whole = (sub.clone(), GitStatus::Untracked);
|
||||
assert_eq!(
|
||||
changes(&sub).expect("status"),
|
||||
Some(vec![whole]),
|
||||
"a folder inside an untracked one is untracked whole"
|
||||
);
|
||||
assert_eq!(changes(&root.join(".git")).expect("rev-parse"), None);
|
||||
assert_eq!(changes(&folder("git-outside")).expect("rev-parse"), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_folder_named_with_a_newline_keeps_its_whole_prefix() {
|
||||
let root = folder("git-newline");
|
||||
run(&root, &["init", "-q"]);
|
||||
let inner = root.join("line\nbreak");
|
||||
fs::create_dir_all(&inner).expect("a folder");
|
||||
fs::write(inner.join("tracked.txt"), "first").expect("a file");
|
||||
run(&root, &["add", "."]);
|
||||
run(&root, &["commit", "-qm", "first"]);
|
||||
fs::write(inner.join("tracked.txt"), "second").expect("an edit");
|
||||
let edit = (inner.join("tracked.txt"), GitStatus::Modified);
|
||||
assert_eq!(changes(&inner).expect("status"), Some(vec![edit]));
|
||||
}
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// Each change at or under `folder`, by its full path; none outside a work tree.
|
||||
pub fn changes(folder: &Path) -> anyhow::Result<Option<Vec<(PathBuf, GitStatus)>>> {
|
||||
let place = ["rev-parse", "--is-inside-work-tree", "--show-prefix"];
|
||||
let place = git(folder, &place, &[])?;
|
||||
if !place.status.success() {
|
||||
let error = said(&place);
|
||||
if error.contains("not a git repository") {
|
||||
return Ok(None);
|
||||
}
|
||||
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 prefix = match answer {
|
||||
Some(("true", prefix)) => prefix,
|
||||
Some(("false", _)) => return Ok(None),
|
||||
_ => bail!("git: rev-parse in {} printed {place:?}", folder.display()),
|
||||
};
|
||||
let status = ["status", "--porcelain=v2", "-z", "--", "."];
|
||||
let (_, records) = parse(&run(folder, &status, &[])?)?;
|
||||
records
|
||||
.into_iter()
|
||||
.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 parts = rest.split('/').filter(|part| !part.is_empty());
|
||||
let path = parts.fold(folder.to_path_buf(), |path, part| path.join(part));
|
||||
Ok((path, status))
|
||||
})
|
||||
.collect::<anyhow::Result<_>>()
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
/// 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 status: GitStatus,
|
||||
}
|
||||
|
||||
/// A work tree as the Git panel shows it.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Repo {
|
||||
/// The work tree's top folder.
|
||||
pub top: PathBuf,
|
||||
/// The branch checked out; none when HEAD is detached.
|
||||
pub branch: Option<String>,
|
||||
/// HEAD's commit; none before the first.
|
||||
pub commit: Option<String>,
|
||||
pub conflicted: Vec<Change>,
|
||||
pub staged: Vec<Change>,
|
||||
pub unstaged: Vec<Change>,
|
||||
pub untracked: Vec<Change>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
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 status = ["status", "--porcelain=v2", "--branch", "-z"];
|
||||
let (head, records) = parse(&run(&top, &status, &[])?)?;
|
||||
let mut repo = Repo {
|
||||
top,
|
||||
branch: head.branch,
|
||||
commit: head.commit,
|
||||
conflicted: Vec::new(),
|
||||
staged: Vec::new(),
|
||||
unstaged: Vec::new(),
|
||||
untracked: Vec::new(),
|
||||
};
|
||||
for record in records {
|
||||
let change = |status, from| Change {
|
||||
path: record.path.clone(),
|
||||
from,
|
||||
status,
|
||||
};
|
||||
match mark(&record, record.x, record.y)? {
|
||||
GitStatus::Untracked => repo.untracked.push(change(GitStatus::Untracked, None)),
|
||||
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()));
|
||||
}
|
||||
if record.y != b'.' {
|
||||
let unstaged = mark(&record, b'.', record.y)?;
|
||||
repo.unstaged.push(change(unstaged, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(repo))
|
||||
}
|
||||
|
||||
/// Paths for git's `--pathspec-from-file`: each change's and a rename's old one, NUL-ended.
|
||||
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.push(0);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn names(changes: &[Change]) -> String {
|
||||
let names: Vec<&str> = changes.iter().map(|change| change.path.as_str()).collect();
|
||||
names.join(", ")
|
||||
}
|
||||
|
||||
/// Stages `changes`: new, changed and gone files alike.
|
||||
pub fn stage(top: &Path, changes: &[Change]) -> anyhow::Result<()> {
|
||||
let args = ["add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"];
|
||||
run(top, &args, &pathspecs(changes)?)?;
|
||||
log::info!("git: staged {} in {}", names(changes), top.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unstages `changes`, before the first commit too.
|
||||
pub fn unstage(top: &Path, changes: &[Change]) -> anyhow::Result<()> {
|
||||
let args = [
|
||||
"reset",
|
||||
"-q",
|
||||
"--pathspec-from-file=-",
|
||||
"--pathspec-file-nul",
|
||||
];
|
||||
run(top, &args, &pathspecs(changes)?)?;
|
||||
log::info!("git: unstaged {} in {}", names(changes), top.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commits what is staged with `message`.
|
||||
pub fn commit(top: &Path, message: &str) -> anyhow::Result<()> {
|
||||
run(top, &["commit", "-q", "-F", "-"], message.as_bytes())?;
|
||||
log::info!("git: committed in {}", top.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The newest `count` commits from HEAD, which must have one.
|
||||
pub fn log(top: &Path, count: usize) -> anyhow::Result<Vec<Commit>> {
|
||||
let count = format!("-n{count}");
|
||||
let format = "--format=%H%x00%P%x00%s%x00%an%x00%ar%x00%D";
|
||||
let args = ["log", &count, "-z", "--no-show-signature", format];
|
||||
let out = run(top, &args, &[])?;
|
||||
let out = String::from_utf8_lossy(&out);
|
||||
let fields: Vec<&str> = out.split_terminator('\0').collect();
|
||||
ensure!(
|
||||
fields.len().is_multiple_of(6),
|
||||
"git: log in {} printed {} fields, not six for each commit",
|
||||
top.display(),
|
||||
fields.len()
|
||||
);
|
||||
let listed = |text: &str, by: &str| -> Vec<_> {
|
||||
let parts = text.split(by).filter(|part| !part.is_empty());
|
||||
parts.map(|part| part.to_string().into()).collect()
|
||||
};
|
||||
let commits = fields.chunks(6).map(|commit| Commit {
|
||||
id: commit[0].to_string().into(),
|
||||
parents: listed(commit[1], " "),
|
||||
subject: commit[2].to_string().into(),
|
||||
author: commit[3].to_string().into(),
|
||||
when: commit[4].to_string().into(),
|
||||
refs: listed(commit[5], ", "),
|
||||
});
|
||||
Ok(commits.collect())
|
||||
}
|
||||
|
||||
/// `change`'s diff as `section` holds it, in Ely's stretches; none for a binary file.
|
||||
pub fn diff(
|
||||
top: &Path,
|
||||
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 output = git(top, &args, &[])?;
|
||||
// Without an index, differing files exit 1.
|
||||
let differ = section == ChangeSection::Untracked
|
||||
&& output.status.code() == Some(1)
|
||||
&& output.stderr.is_empty();
|
||||
ensure!(
|
||||
output.status.success() || differ,
|
||||
"git: `git {}` failed in {}: {}",
|
||||
args.join(" "),
|
||||
top.display(),
|
||||
said(&output)
|
||||
);
|
||||
hunks(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
/// A hunk header's first old and new line numbers.
|
||||
fn starts(header: &str) -> Option<(usize, usize)> {
|
||||
let mut ranges = header.strip_prefix("@@ -")?.split(' ');
|
||||
let first = |range: &str| range.split(',').next()?.parse().ok();
|
||||
let old = first(ranges.next()?)?;
|
||||
let new = first(ranges.next()?.strip_prefix('+')?)?;
|
||||
Some((old, new))
|
||||
}
|
||||
|
||||
/// Git's unified diff as Ely's stretches, changed words marked; none for a binary file.
|
||||
fn hunks(text: &str) -> anyhow::Result<Option<Vec<Stretch>>> {
|
||||
let (mut stretches, mut at, mut inside) = (Vec::new(), (0, 0), false);
|
||||
for line in text.lines() {
|
||||
if line.starts_with("diff ") {
|
||||
inside = false;
|
||||
} else if line.starts_with("@@") {
|
||||
at = starts(line).with_context(|| format!("git: hunk header {line:?}"))?;
|
||||
let header = line.to_string().into();
|
||||
stretches.push(Stretch::Hunk {
|
||||
header,
|
||||
lines: Vec::new(),
|
||||
});
|
||||
inside = true;
|
||||
} else if !inside {
|
||||
if line.starts_with("Binary files ") {
|
||||
return Ok(None);
|
||||
}
|
||||
} else if let Some(Stretch::Hunk { lines, .. }) = stretches.last_mut() {
|
||||
let (kind, text) = match line.as_bytes().first() {
|
||||
// `diff.suppressBlankEmpty` drops a blank line's space.
|
||||
None => (LineKind::Same, ""),
|
||||
Some(b' ') => (LineKind::Same, &line[1..]),
|
||||
Some(b'-') => (LineKind::Removed, &line[1..]),
|
||||
Some(b'+') => (LineKind::Added, &line[1..]),
|
||||
Some(b'\\') => continue,
|
||||
Some(_) => bail!("git: diff line {line:?} is malformed"),
|
||||
};
|
||||
let old = (kind != LineKind::Added).then_some(at.0);
|
||||
let new = (kind != LineKind::Removed).then_some(at.1);
|
||||
at = (
|
||||
at.0 + usize::from(old.is_some()),
|
||||
at.1 + usize::from(new.is_some()),
|
||||
);
|
||||
lines.push(DiffLine {
|
||||
kind,
|
||||
old,
|
||||
new,
|
||||
text: text.to_string().into(),
|
||||
words: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for stretch in &mut stretches {
|
||||
if let Stretch::Hunk { lines, .. } = stretch {
|
||||
mark_words(lines);
|
||||
}
|
||||
}
|
||||
Ok(Some(stretches))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,482 @@
|
||||
use std::{env, fs, ops::Range};
|
||||
|
||||
use gpui::SharedString;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A fresh folder under the temp dir.
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
let folder = env::temp_dir().join(format!("elyterminal-{name}-{}", std::process::id()));
|
||||
if folder.exists() {
|
||||
fs::remove_dir_all(&folder).expect("a stale test folder goes");
|
||||
}
|
||||
fs::create_dir_all(&folder).expect("a test folder");
|
||||
folder
|
||||
}
|
||||
|
||||
/// Runs git in `root` to set a test up.
|
||||
fn prepare(root: &Path, args: &[&str]) {
|
||||
let done = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git runs");
|
||||
let error = String::from_utf8_lossy(&done.stderr);
|
||||
assert!(done.status.success(), "git {args:?}: {error}");
|
||||
}
|
||||
|
||||
/// A new repository on `main` whose own config keeps the user's away.
|
||||
fn repository(name: &str) -> PathBuf {
|
||||
let root = folder(name);
|
||||
prepare(&root, &["init", "-q", "-b", "main"]);
|
||||
for [key, value] in [
|
||||
["user.name", "Ely"],
|
||||
["user.email", "ely@example.com"],
|
||||
["commit.gpgsign", "false"],
|
||||
["core.hooksPath", "no-hooks"],
|
||||
["core.autocrlf", "false"],
|
||||
["merge.conflictStyle", "merge"],
|
||||
["status.showUntrackedFiles", "normal"],
|
||||
] {
|
||||
prepare(&root, &["config", key, value]);
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// Writes `text` to `path` under `root`, making its folders.
|
||||
fn write(root: &Path, path: &str, text: &str) {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().expect("a parent")).expect("folders");
|
||||
fs::write(path, text).expect("a file");
|
||||
}
|
||||
|
||||
fn change(path: &str, status: GitStatus) -> Change {
|
||||
Change {
|
||||
path: path.into(),
|
||||
from: None,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
fn read(root: &Path) -> Repo {
|
||||
repo(root).expect("status").expect("a work tree")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_records_read_with_head() {
|
||||
let out = b"# branch.oid 1a2b\0# branch.head main\0# branch.upstream origin/main\0\
|
||||
# branch.ab +1 -0\0\
|
||||
1 .M N... 100644 100644 100644 aaa bbb src/a b.rs\0\
|
||||
2 RM N... 100644 100644 100644 aaa bbb R100 new name.rs\0old name.rs\0\
|
||||
u UU N... 100644 100644 100644 100644 aaa bbb ccc clash.rs\0? notes/\0";
|
||||
let (head, records) = parse(out).expect("records");
|
||||
let main = Head {
|
||||
branch: Some("main".into()),
|
||||
commit: Some("1a2b".into()),
|
||||
};
|
||||
assert_eq!(head, main);
|
||||
let record = |x, y, path: &str, from: Option<&str>| Record {
|
||||
x,
|
||||
y,
|
||||
path: path.into(),
|
||||
from: from.map(Into::into),
|
||||
};
|
||||
let expected = [
|
||||
record(b'.', b'M', "src/a b.rs", None),
|
||||
record(b'R', b'M', "new name.rs", Some("old name.rs")),
|
||||
record(b'U', b'U', "clash.rs", None),
|
||||
record(b'?', b'?', "notes/", None),
|
||||
];
|
||||
assert_eq!(records, expected);
|
||||
let unborn = b"# branch.oid (initial)\0# branch.head (detached)\0";
|
||||
let (head, _) = parse(unborn).expect("headers");
|
||||
assert_eq!(head, Head::default(), "no commit yet, no branch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_git_never_writes_are_errors() {
|
||||
for out in [
|
||||
&b"1 .M N... 100644\0"[..],
|
||||
b"x junk\0",
|
||||
b"2 R. N... 1 2 3 a b R100 new\0",
|
||||
b"1 M N... 1 2 3 a b p\0",
|
||||
b"?\0",
|
||||
b"1 .M N... 1 2 3 a b \0",
|
||||
] {
|
||||
assert!(parse(out).is_err(), "{:?}", String::from_utf8_lossy(out));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_codes_combine_into_one_mark() {
|
||||
use GitStatus::*;
|
||||
for (codes, status) in [
|
||||
(b"??", Untracked),
|
||||
(b"UU", Conflicted),
|
||||
(b"AA", Conflicted),
|
||||
(b"DU", Conflicted),
|
||||
(b".D", Deleted),
|
||||
(b"RM", Renamed),
|
||||
(b"AM", Added),
|
||||
(b"C.", Added),
|
||||
(b".T", Modified),
|
||||
(b"MM", Modified),
|
||||
] {
|
||||
let named = String::from_utf8_lossy(codes);
|
||||
assert_eq!(kind(codes[0], codes[1]), Some(status), "{named}");
|
||||
}
|
||||
assert_eq!(kind(b'.', b'.'), None);
|
||||
assert_eq!(kind(b'!', b'!'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_work_tree_reports_changes_under_the_folder() {
|
||||
let root = repository("git-changes");
|
||||
for (path, text) in [
|
||||
("kept.txt", "kept"),
|
||||
("gone.txt", "gone"),
|
||||
("src/main.rs", "fn main() {}"),
|
||||
("src/inner/deep.rs", "deep"),
|
||||
] {
|
||||
write(&root, path, text);
|
||||
}
|
||||
prepare(&root, &["add", "."]);
|
||||
prepare(&root, &["commit", "-qm", "first"]);
|
||||
write(&root, "src/main.rs", "fn main() { }");
|
||||
fs::remove_file(root.join("gone.txt")).expect("a removal");
|
||||
write(&root, "added.txt", "new");
|
||||
prepare(&root, &["add", "added.txt"]);
|
||||
write(&root, "notes/sub/todo.txt", "todo");
|
||||
|
||||
let mut all = changes(&root).expect("status").expect("a work tree");
|
||||
all.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let expected = [
|
||||
("added.txt", GitStatus::Added),
|
||||
("gone.txt", GitStatus::Deleted),
|
||||
("notes", GitStatus::Untracked),
|
||||
("src/main.rs", GitStatus::Modified),
|
||||
]
|
||||
.map(|(path, status)| (root.join(path), status));
|
||||
assert_eq!(all, expected);
|
||||
let src = root.join("src");
|
||||
let main = (src.join("main.rs"), GitStatus::Modified);
|
||||
assert_eq!(changes(&src).expect("status"), Some(vec![main]));
|
||||
let sub = root.join("notes").join("sub");
|
||||
let whole = (sub.clone(), GitStatus::Untracked);
|
||||
assert_eq!(
|
||||
changes(&sub).expect("status"),
|
||||
Some(vec![whole]),
|
||||
"a folder inside an untracked one is untracked whole"
|
||||
);
|
||||
assert_eq!(changes(&root.join(".git")).expect("rev-parse"), None);
|
||||
assert_eq!(changes(&folder("git-outside")).expect("rev-parse"), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_folder_named_with_a_newline_keeps_its_whole_prefix() {
|
||||
let root = repository("git-newline");
|
||||
write(&root, "line\nbreak/tracked.txt", "first");
|
||||
prepare(&root, &["add", "."]);
|
||||
prepare(&root, &["commit", "-qm", "first"]);
|
||||
write(&root, "line\nbreak/tracked.txt", "second");
|
||||
let inner = root.join("line\nbreak");
|
||||
let edit = (inner.join("tracked.txt"), GitStatus::Modified);
|
||||
assert_eq!(changes(&inner).expect("status"), Some(vec![edit]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repository_lists_its_branch_and_each_section() {
|
||||
let root = repository("git-repo");
|
||||
for path in ["kept.txt", "gone.txt", "old.txt", "src/main.rs"] {
|
||||
write(&root, path, "first\n");
|
||||
}
|
||||
prepare(&root, &["add", "."]);
|
||||
prepare(&root, &["commit", "-qm", "first"]);
|
||||
write(&root, "src/main.rs", "second\n");
|
||||
fs::remove_file(root.join("gone.txt")).expect("a removal");
|
||||
write(&root, "added.txt", "new\n");
|
||||
write(&root, "kept.txt", "staged\n");
|
||||
prepare(&root, &["add", "added.txt", "kept.txt"]);
|
||||
write(&root, "kept.txt", "staged, then changed\n");
|
||||
prepare(&root, &["mv", "old.txt", "new.txt"]);
|
||||
write(&root, "notes/todo.txt", "todo\n");
|
||||
|
||||
let state = read(&root.join("src"));
|
||||
let real = |path: &Path| fs::canonicalize(path).expect("a real path");
|
||||
assert_eq!(
|
||||
real(&state.top),
|
||||
real(&root),
|
||||
"the top, from a folder under it"
|
||||
);
|
||||
assert_eq!(state.branch.as_deref(), Some("main"));
|
||||
assert!(state.commit.is_some());
|
||||
let renamed = Change {
|
||||
path: "new.txt".into(),
|
||||
from: Some("old.txt".into()),
|
||||
status: GitStatus::Renamed,
|
||||
};
|
||||
let staged = [
|
||||
change("added.txt", GitStatus::Added),
|
||||
change("kept.txt", GitStatus::Modified),
|
||||
renamed,
|
||||
];
|
||||
assert_eq!(state.staged, staged);
|
||||
let unstaged = [
|
||||
change("gone.txt", GitStatus::Deleted),
|
||||
change("kept.txt", GitStatus::Modified),
|
||||
change("src/main.rs", GitStatus::Modified),
|
||||
];
|
||||
assert_eq!(state.unstaged, unstaged);
|
||||
assert_eq!(state.untracked, [change("notes/", GitStatus::Untracked)]);
|
||||
assert!(state.conflicted.is_empty());
|
||||
|
||||
prepare(&root, &["commit", "-qm", "second"]);
|
||||
prepare(&root, &["checkout", "-q", "--detach"]);
|
||||
assert_eq!(read(&root).branch, None, "a detached HEAD has no branch");
|
||||
assert_eq!(repo(&root.join(".git")).expect("rev-parse"), None);
|
||||
assert_eq!(repo(&folder("git-repo-outside")).expect("rev-parse"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_stage_and_unstage_by_their_literal_paths() {
|
||||
let root = repository("git-stage");
|
||||
write(&root, "star[1].txt", "a\n");
|
||||
write(&root, "star1.txt", "b\n");
|
||||
let star = change("star[1].txt", GitStatus::Untracked);
|
||||
stage(&root, &[star]).expect("a stage before the first commit");
|
||||
let state = read(&root);
|
||||
assert_eq!(state.commit, None);
|
||||
let added = change("star[1].txt", GitStatus::Added);
|
||||
assert_eq!(state.staged, [added], "the brackets match nothing else");
|
||||
assert_eq!(state.untracked, [change("star1.txt", GitStatus::Untracked)]);
|
||||
unstage(&root, &state.staged).expect("an unstage before the first commit");
|
||||
let state = read(&root);
|
||||
assert!(state.staged.is_empty(), "{:?}", state.staged);
|
||||
assert_eq!(state.untracked.len(), 2);
|
||||
|
||||
stage(&root, &state.untracked).expect("a stage");
|
||||
commit(&root, "first").expect("a commit");
|
||||
prepare(&root, &["mv", "star1.txt", "moved.txt"]);
|
||||
unstage(&root, &read(&root).staged).expect("an unstaged rename");
|
||||
let state = read(&root);
|
||||
assert!(
|
||||
state.staged.is_empty(),
|
||||
"both sides of the rename: {state:?}"
|
||||
);
|
||||
assert_eq!(state.unstaged, [change("star1.txt", GitStatus::Deleted)]);
|
||||
assert_eq!(state.untracked, [change("moved.txt", GitStatus::Untracked)]);
|
||||
assert!(
|
||||
stage(&root, &[]).is_err(),
|
||||
"no path would stage the whole tree"
|
||||
);
|
||||
assert!(unstage(&root, &[]).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_name_with_a_newline_stages_whole() {
|
||||
let root = repository("git-stage-newline");
|
||||
write(&root, "line\nfeed.txt", "a\n");
|
||||
let new = change("line\nfeed.txt", GitStatus::Untracked);
|
||||
stage(&root, &[new]).expect("a stage");
|
||||
let added = change("line\nfeed.txt", GitStatus::Added);
|
||||
assert_eq!(read(&root).staged, [added]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commit_takes_the_staged_files_and_heads_the_log() {
|
||||
let root = repository("git-commit");
|
||||
write(&root, "a.txt", "a\n");
|
||||
stage(&root, &[change("a.txt", GitStatus::Untracked)]).expect("a stage");
|
||||
commit(&root, "first subject\n\nIts body.").expect("a commit");
|
||||
write(&root, "b.txt", "b\n");
|
||||
stage(&root, &[change("b.txt", GitStatus::Untracked)]).expect("a stage");
|
||||
commit(&root, "second").expect("a commit");
|
||||
|
||||
let commits = log(&root, 10).expect("a log");
|
||||
let [newest, first] = commits.as_slice() else {
|
||||
panic!("two commits: {commits:?}");
|
||||
};
|
||||
assert_eq!(newest.subject.as_ref(), "second");
|
||||
assert_eq!(first.subject.as_ref(), "first subject");
|
||||
assert_eq!(newest.parents, std::slice::from_ref(&first.id));
|
||||
assert!(first.parents.is_empty());
|
||||
assert!(newest.id.chars().all(|c| c.is_ascii_hexdigit()) && !newest.id.is_empty());
|
||||
assert_eq!(newest.author.as_ref(), "Ely");
|
||||
assert!(newest.when.ends_with("ago"), "{}", newest.when);
|
||||
assert_eq!(newest.refs, [SharedString::from("HEAD -> main")]);
|
||||
assert!(first.refs.is_empty());
|
||||
assert_eq!(log(&root, 1).expect("a log").len(), 1);
|
||||
let error = commit(&root, "nothing").expect_err("nothing is staged");
|
||||
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");
|
||||
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");
|
||||
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 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);
|
||||
}
|
||||
|
||||
#[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