use std::{ ffi::OsStr, io::Write, path::{Path, PathBuf}, process::{Command, Output, Stdio}, }; use anyhow::{Context as _, anyhow, bail, ensure}; use ely_gpui_component::{ git::{ChangeSection, Commit}, lists::GitStatus, }; mod diff; pub mod panel; pub use diff::diff; /// Git's arguments on one line, for messages. fn line>(args: &[A]) -> String { let args: Vec<_> = args .iter() .map(|arg| arg.as_ref().to_string_lossy()) .collect(); args.join(" ") } /// Keeps Windows from opening a console window for git. #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; /// 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: &[A], input: &[u8]) -> anyhow::Result { let named = || format!("git: `git {}` in {}", line(args), folder.display()); let mut command = Command::new("git"); #[cfg(windows)] std::os::windows::process::CommandExt::creation_flags(&mut command, CREATE_NO_WINDOW); let mut child = command .arg("-C") .arg(folder) .args(["--no-optional-locks", "--literal-pathspecs"]) .args(args) .env("LC_ALL", "C") .stdin(if input.is_empty() { Stdio::null() } else { Stdio::piped() }) .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) } /// 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()) } /// Logs what git warned of while it still did its work. fn warnings>(folder: &Path, args: &[A], output: &Output) { let warned = String::from_utf8_lossy(&output.stderr); if !warned.trim().is_empty() { let (command, place) = (line(args), folder.display()); log::warn!("git: `git {command}` in {place} warned: {}", warned.trim()); } } /// Runs git and gives what it printed; a failure is an error carrying what git said. fn run>(folder: &Path, args: &[A], input: &[u8]) -> anyhow::Result> { let output = git(folder, args, input)?; let (command, place) = (line(args), folder.display()); ensure!( output.status.success(), "git: `git {command}` failed in {place}: {}", said(&output) ); warnings(folder, args, &output); Ok(output.stdout) } /// A path from git's bytes: as they are on Unix; elsewhere UTF-8, as git writes them. fn path_of(bytes: &[u8]) -> anyhow::Result { #[cfg(unix)] let path = ::from_bytes(bytes); #[cfg(not(unix))] let path = std::str::from_utf8(bytes).with_context(|| { let shown = String::from_utf8_lossy(bytes); format!("git: path {shown:?} is not UTF-8") })?; Ok(PathBuf::from(path)) } /// A record of `git status --porcelain=v2 -z` in git's bytes: index and work tree codes, its path from the top, a rename's or copy's old path. #[derive(Debug, PartialEq)] struct Record { x: u8, y: u8, path: Vec, 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 or copy'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 head, mut records) = (Head::default(), Vec::new()); while let Some(field) = fields.next() { let malformed = || { let shown = String::from_utf8_lossy(field); anyhow!("git: status record {shown:?} is malformed") }; let mut words = field.splitn(2, |&byte| byte == b' '); let (Some(kind), Some(rest)) = (words.next(), words.next()) else { return Err(malformed()); }; // Fields before the path, which may hold spaces. let count = match kind { b"#" => { match String::from_utf8_lossy(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; } b"?" => 0, b"1" => 7, b"2" => 8, b"u" => 9, _ => return Err(malformed()), }; let parts: Vec<&[u8]> = rest.splitn(count + 1, |&byte| byte == b' ').collect(); let (codes, path) = match (kind, parts.as_slice()) { (b"?", [path]) => (b"??".as_slice(), *path), (_, [codes, .., path]) if parts.len() == count + 1 => (*codes, *path), _ => return Err(malformed()), }; let (&[x, y], false) = (codes, path.is_empty()) else { return Err(malformed()); }; let from = match kind { b"2" => Some(fields.next().ok_or_else(malformed)?.to_vec()), _ => None, }; let path = path.to_vec(); records.push(Record { x, y, path, from }); } Ok((head, records)) } /// 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, (b'U', _) | (_, b'U') | (b'A', b'A') | (b'D', b'D') => GitStatus::Conflicted, (b'D', _) | (_, b'D') => GitStatus::Deleted, (b'R', _) | (_, b'R') => GitStatus::Renamed, (b'A' | b'C', _) | (_, b'A' | b'C') => GitStatus::Added, (b'M' | b'T', _) | (_, b'M' | b'T') => GitStatus::Modified, _ => return None, }) } /// 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, path) = ( format!("{}{}", x as char, y as char), String::from_utf8_lossy(&record.path), ); kind(x, y).with_context(|| format!("git: {path} has codes {codes}")) } /// Each change at or under `folder`, by its full path; none outside a work tree. 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()); } // The prefix may hold newlines of its own. let answer = place.stdout.strip_suffix(b"\n").and_then(|out| { let at = out.iter().position(|&byte| byte == b'\n')?; Some((&out[..at], &out[at + 1..])) }); let prefix = match answer { Some((b"true", prefix)) => prefix, Some((b"false", _)) => return Ok(None), _ => { let printed = String::from_utf8_lossy(&place.stdout); bail!("git: rev-parse in {} printed {printed:?}", folder.display()) } }; let status = ["status", "--porcelain=v2", "-z", "--", "."]; let (_, records) = parse(&run(folder, &status, &[])?)?; records .into_iter() .map(|record| { let status = mark(&record, record.x, record.y)?; let Some(rest) = record.path.strip_prefix(prefix) else { let path = String::from_utf8_lossy(&record.path); let prefix = String::from_utf8_lossy(prefix); bail!("git: {path} lies outside {prefix}"); }; let mut path = folder.to_path_buf(); for part in rest.split(|&byte| byte == b'/') { if !part.is_empty() { path.push(path_of(part)?); } } Ok((path, status)) }) .collect::>() .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: PathBuf, 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 answer = git(folder, &["rev-parse", "--show-toplevel"], &[])?; if !answer.status.success() { let error = said(&answer); if error.contains("not a git repository") || error.contains("must be run in a work tree") { return Ok(None); } bail!("git: rev-parse in {} failed: {error}", folder.display()); } let top = answer.stdout.strip_suffix(b"\n").with_context(|| { let printed = String::from_utf8_lossy(&answer.stdout); format!("git: rev-parse in {} printed {printed:?}", folder.display()) })?; let top = path_of(top)?; let status = ["status", "--porcelain=v2", "--branch", "-z"]; let (head, records) = parse(&run(&top, &status, &[])?)?; let mut repo = Repo { top, branch: head.branch, commit: head.commit, conflicted: Vec::new(), staged: Vec::new(), unstaged: Vec::new(), untracked: Vec::new(), }; for record in records { let path = path_of(&record.path)?; let change = |status, from| Change { path: 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'.' { // A copy's source is no part of it. let from = match (record.x, &record.from) { (b'R', Some(from)) => Some(path_of(from)?), _ => None, }; repo.staged .push(change(mark(&record, record.x, b'.')?, from)); } if record.y != b'.' { repo.unstaged .push(change(mark(&record, b'.', record.y)?, None)); } } } } Ok(Some(repo)) } /// Paths for git's `--pathspec-from-file`: each change's and a rename's old one, NUL-ended, as git's bytes. fn pathspecs(changes: &[Change]) -> anyhow::Result> { // 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_os_str().as_encoded_bytes()); out.push(0); } } Ok(out) } /// The first few changes by name, for a log line, and how many more. fn names(changes: &[Change]) -> String { let mut names: Vec = changes .iter() .take(5) .map(|change| change.path.display().to_string()) .collect(); if changes.len() > names.len() { names.push(format!("{} more", changes.len() - names.len())); } 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, amend: bool) -> anyhow::Result<()> { let args: &[&str] = match amend { true => &["commit", "-q", "--amend", "-F", "-"], false => &["commit", "-q", "-F", "-"], }; run(top, args, message.as_bytes())?; let amended = if amend { ", amending HEAD" } else { "" }; log::info!("git: committed in {}{amended}", top.display()); Ok(()) } /// Discards `change` from the work tree: an edit goes back to the index; a new file, untracked or only intended to be added, goes. pub fn discard(top: &Path, change: &Change, section: ChangeSection) -> anyhow::Result<()> { let shown = change.path.display(); let one = || pathspecs(std::slice::from_ref(change)); match (section, change.status) { (ChangeSection::Untracked, _) => { let flags = ["clean", "-f", "-d", "--"].map(OsStr::new); let args: Vec<&OsStr> = flags.into_iter().chain([change.path.as_os_str()]).collect(); // Git passes over a repository of its own without a word. let removed = run(top, &args, &[])?; ensure!(!removed.is_empty(), "git: clean removed nothing at {shown}"); } (ChangeSection::Unstaged, GitStatus::Added | GitStatus::Renamed) => { let args = [ "rm", "-f", "-q", "--pathspec-from-file=-", "--pathspec-file-nul", ]; run(top, &args, &one()?)?; } (ChangeSection::Unstaged, _) => { let args = [ "restore", "--worktree", "--pathspec-from-file=-", "--pathspec-file-nul", ]; run(top, &args, &one()?)?; } _ => bail!("git: {shown} has nothing to discard in {section:?}"), } log::info!("git: discarded {shown} 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()) } #[cfg(test)] mod tests;