From e2966686bd24be58ac0ed7d9347d727a5c51d491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 19:43:28 -0400 Subject: [PATCH] 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 --- AGENTS.md | 3 +- src/git.rs | 600 +++++++++++++++++++++++++++++++---------------- src/git/tests.rs | 482 +++++++++++++++++++++++++++++++++++++ 3 files changed, 880 insertions(+), 205 deletions(-) create mode 100644 src/git/tests.rs diff --git a/AGENTS.md b/AGENTS.md index dd760a3..4803d35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/src/git.rs b/src/git.rs index 82d3a40..da9f299 100644 --- a/src/git.rs +++ b/src/git.rs @@ -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 { - 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 { + 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>> { - 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::>() - .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> { +/// 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> { + 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, +} + +/// HEAD as status reads it: its branch, none when detached; its commit, none before the first. +#[derive(Debug, Default, PartialEq)] +struct Head { + branch: Option, + commit: Option, +} + +/// 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)> { 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 { Some(match (x, y) { (b'?', b'?') => GitStatus::Untracked, @@ -108,130 +140,290 @@ fn kind(x: u8, y: u8) -> Option { }) } -#[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 { + 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>> { + 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::>() + .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, + 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, + /// HEAD's commit; none before the first. + pub commit: Option, + pub conflicted: Vec, + pub staged: Vec, + pub unstaged: Vec, + pub untracked: Vec, +} + +/// The work tree holding `folder`, read whole; none outside one. +pub fn repo(folder: &Path) -> anyhow::Result> { + 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> { + // 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> { + 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>> { + 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>> { + 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; diff --git a/src/git/tests.rs b/src/git/tests.rs new file mode 100644 index 0000000..9e216dc --- /dev/null +++ b/src/git/tests.rs @@ -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, Option), text: &str) -> DiffLine { + lined(kind, at, text, Vec::new()) +} + +fn lined( + kind: LineKind, + (old, new): (Option, Option), + text: &str, + words: Vec>, +) -> DiffLine { + DiffLine { + kind, + old, + new, + text: text.to_string().into(), + words, + } +} + +fn hunk(header: &str, lines: Vec) -> 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" + ); +}