use gix for git status

This commit is contained in:
2026-06-03 23:16:40 +02:00
parent fe0a21bdb9
commit a56a2108ac
3 changed files with 228 additions and 107 deletions
+69
View File
@@ -0,0 +1,69 @@
# zterm
GPU-accelerated Wayland terminal emulator. Single Rust crate, edition 2024.
## Build & Run
```
cargo run # dev build, Wayland only
cargo build --release # release
cargo test # tests
```
## Features
| Feature | Effect |
|---------|--------|
| `webm` (default) | Enable WebM video via ffmpeg-next 8.x |
| `render_timing` | Enable per-frame parse/render timing instrumentation |
| `production` | `log/release_max_level_off` — zero logging overhead |
Disable webm: `--no-default-features --features render_timing`
## Architecture
- **Single process**: owns PTY, terminal state, rendering. No multi-process IPC.
- **Kitty-style shared parser** (`SharedParser` in `vt_parser.rs`): 1MB ring buffer. I/O thread writes directly into it; main thread parses in-place. Lock released during parsing so I/O continues.
- **Split tree** (`SplitNode` in `main.rs`): binary tree of panes. Layout computed via recursive `layout()` pass.
- **GPU rendering** (`renderer.rs`): wgpu + WGSL shaders. Instanced rendering with sprite atlas. Per-pane GPU buffers.
- **Kitty graphics protocol** (`graphics.rs`): PNG, GIF, WebM inline images.
## Key files
| File | Contents |
|------|----------|
| `src/main.rs` | `App` state machine, event loop, split tree, tab/pane management, keybindings |
| `src/terminal.rs` | Terminal grid, scrollback buffer, cursor, colors, cell attributes, `Handler` impl |
| `src/vt_parser.rs` | `SharedParser` (ring buffer + I/O), `Parser` (VT escape sequence parsing), `Handler` trait |
| `src/renderer.rs` | wgpu renderer, glyph atlas, font loading, instanced rendering pipeline |
| `src/graphics.rs` | Kitty graphics protocol parser (PNG/GIF/WebM) |
| `src/config.rs` | JSON config from `~/.config/zterm/config.json`, keybinding resolver |
| `src/pty.rs` | PTY master, fork/exec child shell, SIGWINCH resize |
| `src/pipeline.rs` | wgpu pipeline builder |
| `src/keyboard.rs` | Kitty keyboard protocol, key encoding |
| `zterm.terminfo` | Custom terminfo — install with `tic -x -o ~/.terminfo zterm.terminfo` |
| `src/bin/bench_process.rs` | Benchmark binary |
## Shaders
WGSL files: `src/glyph_shader.wgsl`, `src/image_shader.wgsl`, `src/quad_shader.wgsl`, `src/statusline_shader.wgsl`, `src/shader.wgsl` (edge glow).
## Config
`~/.config/zterm/config.json` — auto-created on first run. Fields: `font_family`, `font_size`, `tab_bar_position`, `background_opacity`, `scrollback_lines`, `inactive_pane_fade_ms`, `inactive_pane_dim`, `edge_glow_intensity`, `pass_keys_to_programs`, `keybindings`.
## Style
- `.rustfmt.toml`: `max_width = 80`
- `.gitignore` excludes `Cargo.lock` and `/benches` (unusual for Rust)
## Testing
Tests live inline in source files: `vt_parser.rs`, `simd_utf8.rs`, `graphics.rs`, `vt_test_osc.rs`. Run with `cargo test`.
## Setup
- **Wayland compositor** required (X11 not supported)
- **FFmpeg 5.x8.x** system libraries (for `webm` feature)
- **terminfo**: install `zterm.terminfo` so shells know the terminal type
- **Nerd Font** recommended for statusline icons (folder, branch, etc.)
+6 -1
View File
@@ -75,13 +75,18 @@ rustc-hash = "2"
# Base64 decoding for OSC statusline
base64 = "0.22"
# Pure-Rust git implementation (replaces git subprocess calls)
gix = { version = "0.84", features = ["max-performance", "parallel", "status", "blob-diff"] }
gix-diff = "0.64"
gix-status = "0.31"
# Image processing (Kitty graphics protocol)
image = { version = "0.25", default-features = false, features = ["png", "gif"] }
flate2 = "1"
# Video decoding for WebM support (video only, no audio)
# Requires system FFmpeg libraries (ffmpeg 5.x - 8.x supported)
ffmpeg-next = { version = "8.0", optional = true }
ffmpeg-next = { version = "8.1", optional = true }
[features]
default = ["webm"]
+153 -106
View File
@@ -983,128 +983,175 @@ struct GitStatus {
stash_count: usize,
}
/// Get git status for a directory.
/// Returns None if not in a git repository.
/// Visitor for counting working tree changes.
struct WorkingChangeVisitor<'a> {
count: &'a mut usize,
}
impl<'a> gix_status::index_as_worktree_with_renames::VisitEntry<'a> for WorkingChangeVisitor<'a> {
type ContentChange = <gix_status::index_as_worktree::traits::FastEq as gix_status::index_as_worktree::traits::CompareBlobs>::Output;
type SubmoduleStatus = gix::submodule::Status;
fn visit_entry(&mut self, entry: gix_status::index_as_worktree_with_renames::Entry<'a, Self::ContentChange, Self::SubmoduleStatus>) {
if let Some(summary) = entry.summary() {
match summary {
gix_status::index_as_worktree_with_renames::Summary::Added
| gix_status::index_as_worktree_with_renames::Summary::Modified
| gix_status::index_as_worktree_with_renames::Summary::Removed
| gix_status::index_as_worktree_with_renames::Summary::TypeChange
| gix_status::index_as_worktree_with_renames::Summary::Renamed
| gix_status::index_as_worktree_with_renames::Summary::Copied => {
*self.count += 1;
}
_ => {}
}
}
}
}
/// Get git status for a directory using gix (no subprocesses).
/// Returns None if not in a git repository or an error occurs.
fn get_git_status(cwd: &str) -> Option<GitStatus> {
use std::process::Command;
let repo = gix::discover(cwd).ok()?;
// Check if we're in a git repo and get the branch name
let head_output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(cwd)
.output()
.ok()?;
// Get branch name from HEAD
let head_name = repo.head_name().ok().flatten()?;
let head_name_bstr = head_name.as_bstr();
let head = head_name_bstr
.strip_prefix(b"refs/heads/")
.or(head_name_bstr.strip_prefix(b"refs/tags/"))
.or_else(|| head_name_bstr.strip_prefix(b"refs/"))
.map(|s| String::from_utf8_lossy(s).into_owned())
.unwrap_or_else(|| String::from_utf8_lossy(head_name_bstr).into_owned());
if !head_output.status.success() {
if head.is_empty() {
return None;
}
let head = String::from_utf8_lossy(&head_output.stdout)
.trim()
.to_string();
// Get ahead/behind against upstream
let mut ahead = 0usize;
let mut behind = 0usize;
if let Ok(head_id) = repo.head_id() {
if let Ok(head_ref) = repo.find_reference("HEAD") {
if let gix::refs::TargetRef::Symbolic(upstream_name) = head_ref.target() {
let upstream_full = format!("refs/remotes/origin/{}", upstream_name);
if let Ok(upstream_ref) = repo.find_reference(&upstream_full) {
let mut upstream_ref = upstream_ref;
if let Ok(upstream_id) = upstream_ref.peel_to_id() {
let head_id_detached = head_id.detach();
let upstream_id_detached = upstream_id.detach();
// Count ahead: commits reachable from head_id but not from upstream_id
let mut count = 0usize;
let mut seen = std::collections::HashSet::new();
let mut queue = vec![head_id_detached];
while let Some(current) = queue.pop() {
if seen.contains(&current) {
continue;
}
seen.insert(current);
if let Ok(commit) = repo.find_commit(current.clone()) {
for parent_oid in commit.parent_ids() {
let parent_oid_detached = parent_oid.detach();
if parent_oid_detached == upstream_id_detached {
break;
}
if !seen.contains(&parent_oid_detached) {
queue.push(parent_oid_detached);
}
}
}
count += 1;
}
ahead = count;
// Get ahead/behind status
let mut ahead = 0;
let mut behind = 0;
if let Ok(output) = Command::new("git")
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
.current_dir(cwd)
.output()
{
if output.status.success() {
let counts = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = counts.trim().split_whitespace().collect();
if parts.len() == 2 {
ahead = parts[0].parse().unwrap_or(0);
behind = parts[1].parse().unwrap_or(0);
}
}
}
// Get working directory and staging status using git status --porcelain
let mut working_modified = 0;
let mut working_added = 0;
let mut working_deleted = 0;
let mut staging_modified = 0;
let mut staging_added = 0;
let mut staging_deleted = 0;
if let Ok(output) = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(cwd)
.output()
{
if output.status.success() {
let status = String::from_utf8_lossy(&output.stdout);
for line in status.lines() {
if line.len() < 2 {
continue;
}
let chars: Vec<char> = line.chars().collect();
let staging_char = chars[0];
let working_char = chars[1];
// Staging status (first column)
match staging_char {
'M' => staging_modified += 1,
'A' => staging_added += 1,
'D' => staging_deleted += 1,
'R' => staging_modified += 1, // renamed
'C' => staging_added += 1, // copied
_ => {}
}
// Working directory status (second column)
match working_char {
'M' => working_modified += 1,
'D' => working_deleted += 1,
'?' => working_added += 1, // untracked
_ => {}
// Count behind: commits reachable from upstream_id but not from head_id
let mut count = 0usize;
let mut seen = std::collections::HashSet::new();
let mut queue = vec![upstream_id_detached];
while let Some(current) = queue.pop() {
if seen.contains(&current) {
continue;
}
seen.insert(current);
if let Ok(commit) = repo.find_commit(current.clone()) {
for parent_oid in commit.parent_ids() {
let parent_oid_detached = parent_oid.detach();
if parent_oid_detached == head_id_detached {
break;
}
if !seen.contains(&parent_oid_detached) {
queue.push(parent_oid_detached);
}
}
}
count += 1;
}
behind = count;
}
}
}
}
}
// Build status strings like oh-my-posh format
let working_changed = working_modified + working_added + working_deleted;
let mut working_parts = Vec::new();
if working_modified > 0 {
working_parts.push(format!("~{}", working_modified));
}
if working_added > 0 {
working_parts.push(format!("+{}", working_added));
}
if working_deleted > 0 {
working_parts.push(format!("-{}", working_deleted));
}
let working_string = working_parts.join(" ");
// Get stash count from refs
let stash_count = if repo.find_reference("refs/stash").is_ok() {
1
} else {
0
};
let staging_changed = staging_modified + staging_added + staging_deleted;
let mut staging_parts = Vec::new();
if staging_modified > 0 {
staging_parts.push(format!("~{}", staging_modified));
}
if staging_added > 0 {
staging_parts.push(format!("+{}", staging_added));
}
if staging_deleted > 0 {
staging_parts.push(format!("-{}", staging_deleted));
}
let staging_string = staging_parts.join(" ");
// Get staging status (HEAD tree vs index) and working status (index vs worktree)
let mut staging_changed = 0usize;
let mut working_changed = 0usize;
// Get stash count
let mut stash_count = 0;
if let Ok(output) = Command::new("git")
.args(["stash", "list"])
.current_dir(cwd)
.output()
{
if output.status.success() {
let stash = String::from_utf8_lossy(&output.stdout);
stash_count = stash.lines().count();
// Get HEAD tree ID for tree-index diff
if let Ok(head_tree_id) = repo.head_tree_id() {
// Count staging changes (HEAD tree vs index)
if let Ok(index) = repo.index() {
let _ = repo.tree_index_status(
&head_tree_id,
&index,
None::<&mut gix::Pathspec<'_>>,
gix::status::tree_index::TrackRenames::AsConfigured,
|change: gix_diff::index::ChangeRef<'_, '_>, _tree_idx: &gix::index::State, _worktree_idx: &gix::index::State| {
match change {
gix_diff::index::ChangeRef::Addition { .. } => staging_changed += 1,
gix_diff::index::ChangeRef::Deletion { .. } => staging_changed += 1,
gix_diff::index::ChangeRef::Modification { .. } => staging_changed += 1,
gix_diff::index::ChangeRef::Rewrite { .. } => staging_changed += 1,
}
{ let cf: std::ops::ControlFlow<()> = std::ops::ControlFlow::Continue(()); Ok::<_, Box<dyn std::error::Error + Send + Sync>>(cf) }
},
).ok();
}
}
// Count working changes (index vs worktree)
if let Ok(index) = repo.index() {
let _ = repo.index_worktree_status(
&index,
Vec::<&str>::new(),
&mut WorkingChangeVisitor { count: &mut working_changed },
gix_status::index_as_worktree::traits::FastEq,
gix::status::index_worktree::BuiltinSubmoduleStatus::new(repo.clone().into_sync(), gix::status::Submodule::AsConfigured { check_dirty: false }).ok()?,
&mut gix::progress::Discard,
&std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
Default::default(),
).ok();
}
// Build status strings matching original format
let working_string = if working_changed > 0 {
format!("{}", working_changed)
} else {
String::new()
};
let staging_string = if staging_changed > 0 {
format!("{}", staging_changed)
} else {
String::new()
};
Some(GitStatus {
head,
ahead,
@@ -3453,7 +3500,7 @@ fn setup_config_watcher(
fn main() {
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("info"),
env_logger::Env::default().default_filter_or("warn"),
)
.init();