T15: the Git panel: HEAD's branch, or its commit while detached; Ely's changes list of conflicted, staged, unstaged and untracked files, each with open, stage, unstage, and discard, which asks first; stage all and unstage all; a commit with a summary, a description and amend; the last 50 commits; a pressed file's diff in Ely's DiffViewer over the window, unified or split; outside a repository it says so, and a failed git command shows git's words; it reads again at each prompt, on a folder change and after each action, and Files' marks with it; a section lists 500 files at most and the Git body is a cached view, so a long list does not slow the terminal
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 8m56s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 20:41:24 -04:00
1 parent e931fbd017
commit a8a30d3062
8 files changed
+976 -123

No files matched your search

+59 -108
View File
@@ -7,10 +7,15 @@ use std::{
use anyhow::{Context as _, anyhow, bail, ensure};
use ely_gpui_component::{
git::{ChangeSection, Commit, DiffLine, LineKind, Stretch, mark_words},
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
@@ -70,9 +75,9 @@ fn warnings<A: AsRef<OsStr>>(folder: &Path, args: &[A], output: &Output) {
}
/// 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>> {
fn run<A: AsRef<OsStr>>(folder: &Path, args: &[A], input: &[u8]) -> anyhow::Result<Vec<u8>> {
let output = git(folder, args, input)?;
let (command, place) = (args.join(" "), folder.display());
let (command, place) = (line(args), folder.display());
ensure!(
output.status.success(),
"git: `git {command}` failed in {place}: {}",
@@ -326,11 +331,16 @@ fn pathspecs(changes: &[Change]) -> anyhow::Result<Vec<u8>> {
Ok(out)
}
/// The first few changes by name, for a log line, and how many more.
fn names(changes: &[Change]) -> String {
let names: Vec<String> = changes
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(", ")
}
@@ -356,9 +366,51 @@ pub fn unstage(top: &Path, changes: &[Change]) -> anyhow::Result<()> {
}
/// 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());
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(())
}
@@ -391,106 +443,5 @@ pub fn log(top: &Path, count: usize) -> anyhow::Result<Vec<Commit>> {
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, shown) = (change.path.as_os_str(), change.path.display());
let flags: &[&str] = match section {
ChangeSection::Staged => &["--cached", "-M", "--"],
ChangeSection::Unstaged => &["--"],
// HEAD is our side: a file we deleted reads as new.
ChangeSection::Conflicted => &["HEAD", "--"],
ChangeSection::Untracked if path.as_encoded_bytes().ends_with(b"/") => {
bail!("git: {shown} is a folder")
}
ChangeSection::Untracked => &["--no-index", "--", "/dev/null"],
ChangeSection::Ignored => bail!("git: {shown} is ignored"),
};
let mut args: Vec<&OsStr> = ["diff", "--no-color", "--no-ext-diff", "--no-textconv"]
.into_iter()
.chain(flags.iter().copied())
.map(OsStr::new)
.collect();
args.push(path);
args.extend(change.from.as_deref().map(Path::as_os_str));
let output = git(top, &args, &[])?;
// Without an index git exits 1 for a difference and a failure alike; only a difference prints.
let differ = section == ChangeSection::Untracked
&& output.status.code() == Some(1)
&& !output.stdout.is_empty();
ensure!(
output.status.success() || differ,
"git: `git {}` failed in {}: {}",
line(&args),
top.display(),
said(&output)
);
warnings(top, &args, &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;