Files
ELYTerminal/src/git.rs
T

455 lines
16 KiB
Rust

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<A: AsRef<OsStr>>(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 and curl.
#[cfg(windows)]
pub(crate) 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<A: AsRef<OsStr>>(folder: &Path, args: &[A], input: &[u8]) -> anyhow::Result<Output> {
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<A: AsRef<OsStr>>(folder: &Path, args: &[A], output: &Output) {
let warned = String::from_utf8_lossy(&output.stderr);
if !warned.trim().is_empty() {
let (command, place) = (line(args), folder.display());
log::warn!("git: `git {command}` in {place} warned: {}", warned.trim());
}
}
/// Runs git and gives what it printed; a failure is an error carrying what git said.
fn run<A: AsRef<OsStr>>(folder: &Path, args: &[A], input: &[u8]) -> anyhow::Result<Vec<u8>> {
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<PathBuf> {
#[cfg(unix)]
let path = <OsStr as std::os::unix::ffi::OsStrExt>::from_bytes(bytes);
#[cfg(not(unix))]
let path = std::str::from_utf8(bytes).with_context(|| {
let shown = String::from_utf8_lossy(bytes);
format!("git: path {shown:?} is not UTF-8")
})?;
Ok(PathBuf::from(path))
}
/// A record of `git status --porcelain=v2 -z` in git's bytes: index and work tree codes, its path from the top, a rename's or copy's old path.
#[derive(Debug, PartialEq)]
struct Record {
x: u8,
y: u8,
path: Vec<u8>,
from: Option<Vec<u8>>,
}
/// 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 or copy's old path follows it.
fn parse(out: &[u8]) -> anyhow::Result<(Head, Vec<Record>)> {
let mut fields = out
.split(|&byte| byte == 0)
.filter(|field| !field.is_empty());
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<GitStatus> {
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<GitStatus> {
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<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());
}
// 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::<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: PathBuf,
pub from: Option<PathBuf>,
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 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<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_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<String> = 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<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())
}
#[cfg(test)]
mod tests;