From 13b99c7b5116d9c5b0571f68230c6a20d2f4780b Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 30 Jul 2026 14:17:25 +0200 Subject: [PATCH 001/282] feat: support lazy note lookup in gix-note Establish the minimal plumbing API needed to query notes efficiently for many objects, such as every commit visible in a history view. Resolve note blobs through Git notes progressive two-hex-digit fanout trees and use Git tree ordering for direct entry lookup. Load only trees along the requested object path and retain decoded trees in a caller-owned cache that can be reused across lookups and notes roots. Ignore entries that do not form valid notes instead of mistaking arbitrary tree contents for mappings. Use gix-error for contextual failures and add tests for fanout lookup, cache reuse, and malformed leaf entries. --- Cargo.lock | 7 ++ gix-note/Cargo.toml | 13 ++++ gix-note/src/lib.rs | 153 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index f743688b539..c7bf17d2313 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2155,6 +2155,13 @@ dependencies = [ [[package]] name = "gix-note" version = "0.0.0" +dependencies = [ + "gix-error", + "gix-hash", + "gix-object", + "gix-odb", + "gix-testtools", +] [[package]] name = "gix-object" diff --git a/gix-note/Cargo.toml b/gix-note/Cargo.toml index d3e95ae93cd..9a0155491c2 100644 --- a/gix-note/Cargo.toml +++ b/gix-note/Cargo.toml @@ -14,4 +14,17 @@ include = ["/src/**/*", "/LICENSE-*"] [lib] doctest = false +[features] +## Enable support for the SHA-1 hash by forwarding it to dependencies. +sha1 = ["gix-hash/sha1", "gix-object/sha1"] +## Enable support for the SHA-256 hash by forwarding it to dependencies. +sha256 = ["gix-hash/sha256", "gix-object/sha256"] + [dependencies] +gix-error = { version = "^0.2.5", path = "../gix-error" } +gix-hash = { version = "^0.26.0", path = "../gix-hash" } +gix-object = { version = "^0.63.0", path = "../gix-object" } + +[dev-dependencies] +gix-odb = { path = "../gix-odb" } +gix-testtools = { path = "../tests/tools" } diff --git a/gix-note/src/lib.rs b/gix-note/src/lib.rs index 45278f22481..f9216e7806a 100644 --- a/gix-note/src/lib.rs +++ b/gix-note/src/lib.rs @@ -1 +1,154 @@ +//! Read Git notes from notes trees. + #![forbid(unsafe_code)] +#![deny(missing_docs)] + +use std::{cmp::Ordering, collections::HashMap}; + +use gix_error::{ResultExt, message}; +use gix_hash::{ObjectId, oid}; +use gix_object::{ + Find, FindExt, Tree, + bstr::{BStr, ByteSlice}, +}; + +/// The error returned by note operations. +pub type Error = gix_error::Exn; + +/// Decoded trees retained across note lookups. +/// +/// The cache is independent of a particular notes root, allowing callers to +/// reuse it for all configured notes references. +#[derive(Default)] +pub struct Cache { + trees: HashMap, + buf: Vec, +} + +impl Cache { + fn tree(&mut self, id: ObjectId, objects: &impl Find) -> Result<&Tree, Error> { + if !self.trees.contains_key(&id) { + let tree = objects + .find_tree(&id, &mut self.buf) + .or_raise(|| message!("Could not load notes tree {id}"))? + .into_owned(); + self.trees.insert(id, tree); + } + Ok(self.trees.get(&id).expect("tree was inserted or already present")) + } +} + +/// Return the blob associated with `object` in the notes tree at `root`. +/// +/// Trees are loaded lazily along the progressive two-hex-digit fanout path and +/// retained in `cache`. Entries that do not conform to Git's notes layout are +/// ignored. +pub fn get(root: ObjectId, object: &oid, objects: &impl Find, cache: &mut Cache) -> Result, Error> { + let hex = object.to_hex().to_string(); + let mut remaining = hex.as_bytes().as_bstr(); + let mut tree_id = root; + + loop { + let tree = cache.tree(tree_id, objects)?; + if let Some(entry) = entry(tree, remaining, false).filter(|entry| entry.mode.is_blob()) { + return Ok(Some(entry.oid)); + } + let Some(component) = remaining.get(..2).filter(|_| remaining.len() > 2) else { + return Ok(None); + }; + let Some(subtree) = entry(tree, BStr::new(component), true).filter(|entry| entry.mode.is_tree()) else { + return Ok(None); + }; + tree_id = subtree.oid; + remaining = remaining[2..].as_bstr(); + } +} + +fn entry<'a>(tree: &'a Tree, name: &BStr, is_tree: bool) -> Option<&'a gix_object::tree::Entry> { + tree.entries + .binary_search_by(|candidate| cmp_entry_with_name(candidate, name, is_tree)) + .ok() + .map(|index| &tree.entries[index]) +} + +fn cmp_entry_with_name(entry: &gix_object::tree::Entry, name: &BStr, is_tree: bool) -> Ordering { + let common = entry.filename.len().min(name.len()); + entry.filename[..common].cmp(&name[..common]).then_with(|| { + let entry = entry + .filename + .get(common) + .or_else(|| entry.mode.is_tree().then_some(&b'/')); + let name = name.get(common).or_else(|| is_tree.then_some(&b'/')); + entry.cmp(&name) + }) +} + +#[cfg(test)] +mod tests { + use gix_hash::Kind; + use gix_object::{ + Write, + bstr::BString, + tree::{Entry, EntryKind}, + }; + + use super::*; + + #[test] + fn lazily_reads_fanout_trees_and_reuses_them() -> gix_testtools::Result { + let objects = gix_odb::memory::Proxy::new(gix_object::find::Never, Kind::Sha1); + let annotated = gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Blob, b"annotated")?; + let note = objects.write_buf(gix_object::Kind::Blob, b"note")?; + let hex = annotated.to_hex().to_string(); + let subtree = objects.write(&Tree { + entries: vec![Entry { + mode: EntryKind::Blob.into(), + filename: BString::from(&hex[2..]), + oid: note, + }], + })?; + let root = objects.write(&Tree { + entries: vec![Entry { + mode: EntryKind::Tree.into(), + filename: BString::from(&hex[..2]), + oid: subtree, + }], + })?; + + let mut cache = Cache::default(); + assert_eq!( + get(root, &annotated, &objects, &mut cache).map_err(gix_error::Exn::into_error)?, + Some(note), + "the note is found through its fanout path" + ); + assert_eq!(cache.trees.len(), 2, "only the root and matching subtree were loaded"); + assert_eq!( + get(root, &annotated, &objects, &mut cache).map_err(gix_error::Exn::into_error)?, + Some(note), + "the same lookup remains stable" + ); + assert_eq!(cache.trees.len(), 2, "repeated lookups reuse decoded trees"); + Ok(()) + } + + #[test] + fn ignores_entries_that_are_not_notes() -> gix_testtools::Result { + let objects = gix_odb::memory::Proxy::new(gix_object::find::Never, Kind::Sha1); + let annotated = gix_object::compute_hash(Kind::Sha1, gix_object::Kind::Blob, b"annotated")?; + let hex = annotated.to_hex().to_string(); + let root = objects.write(&Tree { + entries: vec![Entry { + mode: EntryKind::Tree.into(), + filename: BString::from(hex), + oid: ObjectId::empty_tree(Kind::Sha1), + }], + })?; + + assert_eq!( + get(root, &annotated, &objects, &mut Cache::default()).map_err(gix_error::Exn::into_error)?, + None, + "a tree at a note leaf is not a note" + ); + Ok(()) + } +} From bad5ec36666c9e8154d866f02b779b6fb0dc67c7 Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 30 Jul 2026 14:20:52 +0200 Subject: [PATCH 002/282] feat: support note mutation in gix-note Add plumbing operations to insert, replace, and remove note mappings while returning the rewritten root tree and the previous note id. Rebuild notes using the same dynamic fanout rule as Git so growing and shrinking collections remain compatible with existing notes trees. Preserve unrelated entries during rewriting, reject mixed object-hash kinds, report duplicate mappings, and leave the tree unchanged when removing a missing note. Keep object persistence delegated through Find and Write traits, allowing callers to decide how commits and refs are updated. Test fanout expansion and collapse, replacement and removal results, and preservation of non-note entries. --- crate-status.md | 2 +- gix-note/src/lib.rs | 264 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 261 insertions(+), 5 deletions(-) diff --git a/crate-status.md b/crate-status.md index 2e35e1b4340..69697a0b4be 100644 --- a/crate-status.md +++ b/crate-status.md @@ -619,7 +619,7 @@ Provide a native SSH transport and authentication backend so `gix` users can shi A mechanism to associate metadata with any object, and keep revisions of it using git itself. -* [ ] CRUD for git notes +* [x] CRUD for git notes ### gix-negotiate * **algorithms** diff --git a/gix-note/src/lib.rs b/gix-note/src/lib.rs index f9216e7806a..97df93c0f70 100644 --- a/gix-note/src/lib.rs +++ b/gix-note/src/lib.rs @@ -3,13 +3,17 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] -use std::{cmp::Ordering, collections::HashMap}; +use std::{ + cmp::Ordering, + collections::{BTreeMap, HashMap}, +}; -use gix_error::{ResultExt, message}; +use gix_error::{ErrorExt, ResultExt, message}; use gix_hash::{ObjectId, oid}; use gix_object::{ - Find, FindExt, Tree, - bstr::{BStr, ByteSlice}, + Find, FindExt, Tree, Write, + bstr::{BStr, BString, ByteSlice}, + tree::{Editor, EntryKind, EntryMode}, }; /// The error returned by note operations. @@ -38,6 +42,15 @@ impl Cache { } } +/// The result of changing one note mapping. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Edit { + /// The root tree containing the changed notes. + pub tree: ObjectId, + /// The note which was replaced or removed. + pub previous: Option, +} + /// Return the blob associated with `object` in the notes tree at `root`. /// /// Trees are loaded lazily along the progressive two-hex-digit fanout path and @@ -64,6 +77,192 @@ pub fn get(root: ObjectId, object: &oid, objects: &impl Find, cache: &mut Cache) } } +/// Add or replace the note for `object`, returning the new root tree and any +/// previous note. +/// +/// The notes tree is rewritten with the same progressive fanout heuristic as +/// Git while retaining entries that are not notes. +pub fn add( + root: ObjectId, + object: ObjectId, + note: ObjectId, + objects: &(impl Find + Write), + cache: &mut Cache, +) -> Result { + if object.kind() != root.kind() || note.kind() != root.kind() { + return Err(message("Notes, annotated objects, and their root tree must use the same hash kind").raise()); + } + edit(root, object, Some(note), objects, cache) +} + +/// Remove the note for `object`, returning the new root tree and removed note. +/// +/// If there is no such note, the root is returned unchanged. +pub fn remove( + root: ObjectId, + object: ObjectId, + objects: &(impl Find + Write), + cache: &mut Cache, +) -> Result { + if object.kind() != root.kind() { + return Err(message("The annotated object and notes root tree must use the same hash kind").raise()); + } + edit(root, object, None, objects, cache) +} + +fn edit( + root: ObjectId, + object: ObjectId, + note: Option, + objects: &(impl Find + Write), + cache: &mut Cache, +) -> Result { + let mut notes = BTreeMap::new(); + let mut non_notes = Vec::new(); + collect( + root, + BString::default(), + Vec::new(), + objects, + cache, + &mut notes, + &mut non_notes, + )?; + let previous = match note { + Some(note) => notes.insert(object, note), + None => notes.remove(&object), + }; + if note.is_none() && previous.is_none() { + return Ok(Edit { tree: root, previous }); + } + let tree = write(notes, non_notes, root.kind(), objects)?; + Ok(Edit { tree, previous }) +} + +#[derive(Clone)] +struct NonNote { + path: Vec, + mode: EntryMode, + oid: ObjectId, +} + +fn collect( + tree_id: ObjectId, + hex_prefix: BString, + path_prefix: Vec, + objects: &impl Find, + cache: &mut Cache, + notes: &mut BTreeMap, + non_notes: &mut Vec, +) -> Result<(), Error> { + let entries = cache.tree(tree_id, objects)?.entries.clone(); + let hex_len = tree_id.kind().len_in_hex(); + for entry in entries { + let mut path = path_prefix.clone(); + path.push(entry.filename.clone()); + if entry.mode.is_blob() && entry.filename.len() + hex_prefix.len() == hex_len { + let mut hex = hex_prefix.clone(); + hex.extend_from_slice(&entry.filename); + if let Ok(object) = ObjectId::from_hex(&hex) { + if notes.insert(object, entry.oid).is_some() { + return Err(message!("Multiple notes map to object {object}").raise()); + } + continue; + } + } + if entry.mode.is_tree() + && entry.filename.len() == 2 + && hex_prefix.len() + 2 < hex_len + && entry.filename.iter().all(u8::is_ascii_hexdigit) + { + let mut prefix = hex_prefix.clone(); + prefix.extend_from_slice(&entry.filename); + collect(entry.oid, prefix, path, objects, cache, notes, non_notes)?; + } else { + non_notes.push(NonNote { + path, + mode: entry.mode, + oid: entry.oid, + }); + } + } + Ok(()) +} + +fn write( + notes: BTreeMap, + non_notes: Vec, + hash: gix_hash::Kind, + objects: &(impl Find + Write), +) -> Result { + let mut editor = Editor::new(Tree { entries: Vec::new() }, objects, hash); + for entry in non_notes { + editor + .upsert(entry.path.iter(), entry.mode.kind(), entry.oid) + .or_raise(|| message("Could not restore a non-note tree entry"))?; + } + + let hexes: Vec<_> = notes.keys().map(|id| id.to_hex().to_string()).collect(); + let masks = fanout_masks(&hexes); + for ((_, note), hex) in notes.into_iter().zip(hexes) { + let fanout = fanout(&hex, &masks); + let path = note_path(&hex, fanout); + editor + .upsert(path.split_str("/"), EntryKind::Blob, note) + .or_raise(|| message("Could not add a note tree entry"))?; + } + editor + .write(|tree| { + objects + .write(tree) + .map_err(|err| message!("Could not write tree object: {err}").raise()) + }) + .or_raise(|| message("Could not write the notes tree")) +} + +fn fanout_masks(hexes: &[String]) -> HashMap { + let mut out = HashMap::new(); + for hex in hexes { + let bytes = hex.as_bytes(); + for offset in (0..bytes.len().saturating_sub(2)).step_by(2) { + let Some(nibble) = hex_nibble(bytes[offset]) else { + continue; + }; + *out.entry(BString::from(&bytes[..offset])).or_default() |= 1 << nibble; + } + } + out +} + +fn fanout(hex: &str, masks: &HashMap) -> usize { + let mut fanout = 0; + while fanout * 2 < hex.len().saturating_sub(2) + && masks.get(BStr::new(&hex.as_bytes()[..fanout * 2])) == Some(&u16::MAX) + { + fanout += 1; + } + fanout +} + +fn hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn note_path(hex: &str, fanout: usize) -> BString { + let mut out = BString::new(Vec::with_capacity(hex.len() + fanout)); + for component in hex.as_bytes()[..fanout * 2].chunks_exact(2) { + out.extend_from_slice(component); + out.push(b'/'); + } + out.extend_from_slice(&hex.as_bytes()[fanout * 2..]); + out +} + fn entry<'a>(tree: &'a Tree, name: &BStr, is_tree: bool) -> Option<&'a gix_object::tree::Entry> { tree.entries .binary_search_by(|candidate| cmp_entry_with_name(candidate, name, is_tree)) @@ -151,4 +350,61 @@ mod tests { ); Ok(()) } + + #[test] + fn mutations_rebalance_like_git_and_preserve_non_notes() -> gix_testtools::Result { + let objects = gix_odb::memory::Proxy::new(gix_object::find::Never, Kind::Sha1); + let unrelated = objects.write_buf(gix_object::Kind::Blob, b"keep")?; + let note = objects.write_buf(gix_object::Kind::Blob, b"note")?; + let mut root = objects.write(&Tree { + entries: vec![Entry { + mode: EntryKind::Blob.into(), + filename: "README".into(), + oid: unrelated, + }], + })?; + let mut annotated = Vec::new(); + let mut cache = Cache::default(); + for nibble in b"0123456789abcdef" { + let mut hex = vec![b'0'; Kind::Sha1.len_in_hex()]; + hex[0] = *nibble; + let object = ObjectId::from_hex(&hex)?; + annotated.push(object); + root = add(root, object, note, &objects, &mut cache) + .map_err(gix_error::Exn::into_error)? + .tree; + } + + let mut buf = Vec::new(); + let tree = objects.find_tree(&root, &mut buf)?; + assert_eq!( + tree.entries.iter().filter(|entry| entry.mode.is_tree()).count(), + 16, + "covering all first nibbles causes Git's first fanout level" + ); + assert!( + tree.entries + .iter() + .any(|entry| entry.filename == "README" && entry.oid == unrelated), + "non-note entries survive rebalancing" + ); + + let replacement = objects.write_buf(gix_object::Kind::Blob, b"replacement")?; + let outcome = add(root, annotated[0], replacement, &objects, &mut cache).map_err(gix_error::Exn::into_error)?; + assert_eq!( + outcome.previous, + Some(note), + "overwriting naturally returns the previous note" + ); + let outcome = remove(outcome.tree, annotated[0], &objects, &mut cache).map_err(gix_error::Exn::into_error)?; + assert_eq!(outcome.previous, Some(replacement), "removal returns the removed note"); + let mut buf = Vec::new(); + let tree = objects.find_tree(&outcome.tree, &mut buf)?; + assert_eq!( + tree.entries.iter().filter(|entry| entry.mode.is_blob()).count(), + 16, + "dropping one leading nibble collapses the remaining notes to the root beside README" + ); + Ok(()) + } } From a3c6528c8cef655e2756199a0661c132f306981b Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 30 Jul 2026 14:24:37 +0200 Subject: [PATCH 003/282] feat: expose repository notes Add the notes feature and Repository::notes as the porcelain layer over gix-note for repeated queries and mutations. Select the default notes ref from core.notesRef, including the GIT_NOTES_REF environment override represented in config::tree, and fall back to refs/notes/commits. Discover additional display refs from notes.displayRef or GIT_NOTES_DISPLAY_REF, expand glob patterns, preserve display order, and avoid duplicates. Retain lazily resolved notes roots and the shared decoded-tree cache so querying all visible commits does not repeatedly traverse the same trees. Return attached note blobs together with their source refs and allow callers to replace the displayed ref set. For mutations, accept conventional short notes-ref names, write note blobs and notes commits, and update refs with compare-and-swap expectations so concurrent changes are not silently overwritten. Keep cached roots coherent after edits and cover configured-ref lookup, replacement, and removal through the repository API. --- Cargo.lock | 1 + crate-status.md | 2 +- gix/Cargo.toml | 9 +- gix/src/config/cache/init.rs | 4 + gix/src/config/tree/sections/core.rs | 4 + gix/src/lib.rs | 4 + gix/src/note.rs | 272 +++++++++++++++++++++++++++ gix/src/repository/mod.rs | 2 + gix/src/repository/note.rs | 8 + gix/tests/gix/repository/mod.rs | 2 + gix/tests/gix/repository/note.rs | 46 +++++ 11 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 gix/src/note.rs create mode 100644 gix/src/repository/note.rs create mode 100644 gix/tests/gix/repository/note.rs diff --git a/Cargo.lock b/Cargo.lock index c7bf17d2313..8bc0c36cdae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1573,6 +1573,7 @@ dependencies = [ "gix-mailmap", "gix-merge", "gix-negotiate", + "gix-note", "gix-object", "gix-odb", "gix-pack", diff --git a/crate-status.md b/crate-status.md index 69697a0b4be..1a3c43102ae 100644 --- a/crate-status.md +++ b/crate-status.md @@ -98,7 +98,7 @@ The top-level crate that acts as hub to all functionality provided by the `gix-* * [x] use credential helper configuration and to obtain credentials with `gix_credentials::helper::Cascade` * **traverse** * [x] commit graphs - * [ ] make [git-notes](https://git-scm.com/docs/git-notes) accessible + * [x] make [git-notes](https://git-scm.com/docs/git-notes) accessible * [x] tree entries * **diffs/changes** * [x] tree with other tree diff --git a/gix/Cargo.toml b/gix/Cargo.toml index 53845b1639c..452b35d5987 100644 --- a/gix/Cargo.toml +++ b/gix/Cargo.toml @@ -68,7 +68,8 @@ extras = [ "status", "dirwalk", "blame", - "merge" + "merge", + "notes", ] ## Various progress-related features that improve the look of progress message units. @@ -89,6 +90,7 @@ comfort = [ sha1 = [ "gix-hash/sha1", "gix-pack/sha1", + "gix-note?/sha1", "gix-worktree-stream?/sha1", ] @@ -97,6 +99,7 @@ sha256 = [ "gix-hash/sha256", "gix-object/sha256", "gix-pack/sha256", + "gix-note?/sha256", "gix-worktree-stream?/sha256", ] @@ -144,6 +147,9 @@ attributes = [ ## Add support for mailmaps, as way of determining the final name of commmiters and authors. mailmap = ["dep:gix-mailmap", "revision"] +## Read and mutate Git notes. +notes = ["dep:gix-note"] + ## Make revspec parsing possible, as well describing revision. revision = ["gix-revision/describe", "gix-revision/merge_base", "index"] @@ -357,6 +363,7 @@ gix-traverse = { version = "^0.60.0", path = "../gix-traverse" } gix-diff = { version = "^0.66.0", path = "../gix-diff", default-features = false } gix-merge = { version = "^0.19.0", path = "../gix-merge", default-features = false, optional = true } gix-mailmap = { version = "^0.33.2", path = "../gix-mailmap", optional = true } +gix-note = { version = "^0.0.0", path = "../gix-note", optional = true } gix-features = { version = "^0.49.0", path = "../gix-features", features = [ "progress", "once_cell", diff --git a/gix/src/config/cache/init.rs b/gix/src/config/cache/init.rs index d28be822e33..7b6995c5939 100644 --- a/gix/src/config/cache/init.rs +++ b/gix/src/config/cache/init.rs @@ -401,6 +401,10 @@ fn apply_environment_overrides( let key = &Core::WORKTREE; (env(key), key.name) }, + { + let key = &Core::NOTES_REF; + (env(key), key.name) + }, { let key = &Core::EDITOR; (env(key), key.name) diff --git a/gix/src/config/tree/sections/core.rs b/gix/src/config/tree/sections/core.rs index 1b0bb5d1234..9962cbd871c 100644 --- a/gix/src/config/tree/sections/core.rs +++ b/gix/src/config/tree/sections/core.rs @@ -44,6 +44,9 @@ impl Core { keys::LockTimeout::new_lock_timeout("packedRefsTimeout", &config::Tree::CORE); /// The `core.multiPackIndex` key. pub const MULTIPACK_INDEX: keys::Boolean = keys::Boolean::new_boolean("multiPackIndex", &config::Tree::CORE); + /// The `core.notesRef` key. + pub const NOTES_REF: keys::Any = + keys::Any::new("notesRef", &config::Tree::CORE).with_environment_override("GIT_NOTES_REF"); /// The `core.logAllRefUpdates` key. pub const LOG_ALL_REF_UPDATES: LogAllRefUpdates = LogAllRefUpdates::new_with_validate("logAllRefUpdates", &config::Tree::CORE, validate::LogAllRefUpdates); @@ -125,6 +128,7 @@ impl Section for Core { &Self::FILES_REF_LOCK_TIMEOUT, &Self::PACKED_REFS_TIMEOUT, &Self::MULTIPACK_INDEX, + &Self::NOTES_REF, &Self::LOG_ALL_REF_UPDATES, &Self::PRECOMPOSE_UNICODE, &Self::REPOSITORY_FORMAT_VERSION, diff --git a/gix/src/lib.rs b/gix/src/lib.rs index fd3d54be4dd..7d1d1066cca 100644 --- a/gix/src/lib.rs +++ b/gix/src/lib.rs @@ -467,6 +467,10 @@ pub mod config; #[cfg(feature = "mailmap")] pub mod mailmap; +/// +#[cfg(feature = "notes")] +pub mod note; + /// pub mod worktree; diff --git a/gix/src/note.rs b/gix/src/note.rs new file mode 100644 index 00000000000..0d4d36030c7 --- /dev/null +++ b/gix/src/note.rs @@ -0,0 +1,272 @@ +//! Access Git notes. + +pub use gix_note::*; + +use gix_error::{ErrorExt, ResultExt, message}; + +use crate::{ + Blob, Repository, + bstr::{BStr, BString, ByteSlice, ByteVec}, + config::tree::Core, + refs::{FullName, transaction::PreviousValue}, +}; + +/// A note and the reference from which it originated. +pub struct Note<'a> { + /// The source notes reference. + pub reference: FullName, + /// The note blob. + pub blob: Blob<'a>, +} + +/// Cached access to one or more notes references. +pub struct Platform { + pub(crate) repo: Repository, + pub(crate) default_ref: Option, + pub(crate) refs: Vec, + pub(crate) roots: Vec>>, + pub(crate) cache: gix_note::Cache, +} + +impl Platform { + pub(crate) fn new(repo: Repository) -> Result { + let default_ref = match repo.config_snapshot().string(Core::NOTES_REF) { + Some(value) if value.is_empty() => None, + Some(value) => Some( + FullName::try_from(value) + .or_raise(|| message("core.notesRef must be a fully qualified reference name"))?, + ), + None => Some( + FullName::try_from("refs/notes/commits") + .expect("the standard notes reference is a valid full reference name"), + ), + }; + let mut refs = default_ref.iter().cloned().collect::>(); + let display_from_environment = repo + .open_options() + .permissions + .env + .git_prefix + .check_opt("GIT_NOTES_DISPLAY_REF") + .and_then(std::env::var_os); + let display_refs = match display_from_environment { + Some(value) => { + let value = gix_path::os_string_into_bstring(value) + .or_raise(|| message("GIT_NOTES_DISPLAY_REF is not representable as bytes"))?; + value + .split(|byte| *byte == b':') + .filter(|value| !value.is_empty()) + .map(BString::from) + .collect() + } + None => repo + .config_snapshot() + .plumbing() + .strings("notes.displayRef") + .unwrap_or_default(), + }; + for pattern in display_refs { + add_refs(&repo, pattern.as_bstr(), &mut refs)?; + } + let roots = vec![None; refs.len()]; + Ok(Platform { + repo, + default_ref, + refs, + roots, + cache: Default::default(), + }) + } + + /// Replace configured display references with `refs`. + pub fn with_refs(mut self, refs: impl IntoIterator>) -> Result { + let mut selected = Vec::new(); + for pattern in refs { + let pattern = pattern.into(); + add_refs(&self.repo, pattern.as_bstr(), &mut selected)?; + } + self.roots = vec![None; selected.len()]; + self.refs = selected; + Ok(self) + } + + /// Return the default notes reference selected by configuration. + pub fn default_ref(&self) -> Option<&gix_ref::FullNameRef> { + self.default_ref.as_ref().map(AsRef::as_ref) + } + + /// Return all notes associated with `object` in configured display order. + pub fn get(&mut self, object: impl Into) -> Result>, Error> { + let object = object.into(); + let mut found = Vec::new(); + for index in 0..self.refs.len() { + let Some(root) = self.root(index)? else { continue }; + if let Some(note) = gix_note::get(root, &object, &self.repo, &mut self.cache) + .or_raise(|| message!("Could not find notes for {object}"))? + { + found.push((self.refs[index].clone(), note)); + } + } + found + .into_iter() + .map(|(reference, id)| { + let blob = self + .repo + .find_blob(id) + .or_raise(|| message!("Could not load note {id} from {reference}"))?; + Ok(Note { reference, blob }) + }) + .collect() + } + + /// Add or replace a note in `notes_ref`, returning the previous note id. + pub fn add( + &mut self, + notes_ref: impl Into, + object: impl Into, + data: impl AsRef<[u8]>, + ) -> Result, Error> { + let notes_ref = expand_notes_ref(notes_ref.into())?; + let (root, parent) = self.edit_root(notes_ref.as_ref())?; + let object = object.into(); + let note = self + .repo + .write_blob(data) + .or_raise(|| message!("Could not write note for {object}"))? + .detach(); + let edit = gix_note::add(root, object, note, &self.repo, &mut self.cache) + .or_raise(|| message!("Could not add note for {object}"))?; + self.commit_edit(notes_ref, parent, edit, "Notes added by gitoxide")?; + Ok(edit.previous) + } + + /// Remove a note in `notes_ref`, returning the removed note id. + pub fn remove( + &mut self, + notes_ref: impl Into, + object: impl Into, + ) -> Result, Error> { + let notes_ref = expand_notes_ref(notes_ref.into())?; + let (root, parent) = self.edit_root(notes_ref.as_ref())?; + let object = object.into(); + let edit = gix_note::remove(root, object, &self.repo, &mut self.cache) + .or_raise(|| message!("Could not remove note for {object}"))?; + if edit.previous.is_some() { + self.commit_edit(notes_ref, parent, edit, "Notes removed by gitoxide")?; + } + Ok(edit.previous) + } + + fn root(&mut self, index: usize) -> Result, Error> { + if let Some(root) = self.roots[index] { + return Ok(root); + } + let name = self.refs[index].clone(); + let root = match self + .repo + .try_find_reference(name.as_ref()) + .or_raise(|| message!("Could not find notes reference {name}"))? + { + Some(mut reference) => Some( + reference + .peel_to_tree() + .or_raise(|| message!("Could not peel notes reference {name} to a tree"))? + .id, + ), + None => None, + }; + self.roots[index] = Some(root); + Ok(root) + } + + fn edit_root( + &self, + notes_ref: &gix_ref::FullNameRef, + ) -> Result<(gix_hash::ObjectId, Option), Error> { + match self + .repo + .try_find_reference(notes_ref) + .or_raise(|| message!("Could not find notes reference {notes_ref}"))? + { + Some(mut reference) => { + let parent = reference + .try_id() + .ok_or_else(|| message!("Notes reference {notes_ref} must be direct").raise())? + .detach(); + let root = reference + .peel_to_tree() + .or_raise(|| message!("Could not peel notes reference {notes_ref} to a tree"))? + .id; + Ok((root, Some(parent))) + } + None => Ok((gix_hash::ObjectId::empty_tree(self.repo.object_hash()), None)), + } + } + + fn commit_edit( + &mut self, + notes_ref: FullName, + parent: Option, + edit: gix_note::Edit, + message: &str, + ) -> Result<(), Error> { + let commit = self + .repo + .new_commit(message, edit.tree, parent) + .or_raise(|| message!("Could not create commit for {notes_ref}"))?; + let expected = parent.map_or(PreviousValue::MustNotExist, |id| { + PreviousValue::MustExistAndMatch(gix_ref::Target::Object(id)) + }); + self.repo + .reference(notes_ref.as_ref(), commit.id, expected, format!("notes: {message}")) + .or_raise(|| message!("Could not update notes reference {notes_ref}"))?; + for (index, reference) in self.refs.iter().enumerate() { + if reference == ¬es_ref { + self.roots[index] = Some(Some(edit.tree)); + } + } + Ok(()) + } +} + +fn add_refs(repo: &Repository, pattern: &BStr, out: &mut Vec) -> Result<(), Error> { + let parsed = + gix_glob::parse(pattern).ok_or_else(|| message("Notes display references must not be empty").raise())?; + if parsed.first_wildcard_pos.is_some() { + let platform = repo + .references() + .or_raise(|| message!("Could not iterate notes references matching {pattern}"))?; + let references = platform + .all() + .or_raise(|| message!("Could not iterate notes references matching {pattern}"))?; + for reference in references { + let reference = reference.map_err(|err| message!("Could not read reference: {err}").raise())?; + if parsed.matches(reference.name().as_bstr(), gix_glob::wildmatch::Mode::empty()) { + push_unique(out, reference.inner.name); + } + } + } else { + push_unique( + out, + FullName::try_from(pattern) + .or_raise(|| message!("Notes display reference {pattern} is not fully qualified"))?, + ); + } + Ok(()) +} + +fn push_unique(out: &mut Vec, reference: FullName) { + if !out.contains(&reference) { + out.push(reference); + } +} + +fn expand_notes_ref(mut name: BString) -> Result { + if name.starts_with_str("refs/notes/") { + } else if name.starts_with_str("notes/") { + name.insert_str(0, "refs/"); + } else { + name.insert_str(0, "refs/notes/"); + } + FullName::try_from(name).or_raise(|| message("The notes reference name is invalid")) +} diff --git a/gix/src/repository/mod.rs b/gix/src/repository/mod.rs index 1114a55291d..19f52b27aa7 100644 --- a/gix/src/repository/mod.rs +++ b/gix/src/repository/mod.rs @@ -49,6 +49,8 @@ mod mailmap; /// #[cfg(feature = "merge")] mod merge; +#[cfg(feature = "notes")] +mod note; mod object; #[cfg(feature = "attributes")] mod pathspec; diff --git a/gix/src/repository/note.rs b/gix/src/repository/note.rs new file mode 100644 index 00000000000..0e054ab5e9e --- /dev/null +++ b/gix/src/repository/note.rs @@ -0,0 +1,8 @@ +use crate::note; + +impl crate::Repository { + /// Return a platform for repeated Git notes queries and mutations. + pub fn notes(&self) -> Result { + note::Platform::new(self.clone()) + } +} diff --git a/gix/tests/gix/repository/mod.rs b/gix/tests/gix/repository/mod.rs index f8f7a3fee9f..cda2ac57fc6 100644 --- a/gix/tests/gix/repository/mod.rs +++ b/gix/tests/gix/repository/mod.rs @@ -16,6 +16,8 @@ mod filter; mod mailmap; #[cfg(feature = "merge")] mod merge; +#[cfg(feature = "notes")] +mod note; mod object; mod open; #[cfg(feature = "attributes")] diff --git a/gix/tests/gix/repository/note.rs b/gix/tests/gix/repository/note.rs new file mode 100644 index 00000000000..3089f7cb203 --- /dev/null +++ b/gix/tests/gix/repository/note.rs @@ -0,0 +1,46 @@ +use gix::config::tree::Key; + +#[test] +fn query_and_mutate_a_configured_notes_ref() -> crate::Result { + assert_eq!( + gix::config::tree::Core::NOTES_REF.environment_override(), + Some("GIT_NOTES_REF") + ); + + let (mut repo, _tmp) = crate::util::basic_rw_repo()?; + let mut config = repo.config_snapshot_mut(); + config.set_value(&gix::config::tree::Core::NOTES_REF, "refs/notes/review")?; + config.set_value(&gix::config::tree::User::NAME, "user")?; + config.set_value(&gix::config::tree::User::EMAIL, "user@example.com")?; + config.commit()?; + + let target = repo.write_blob(b"annotated")?.detach(); + let mut notes = repo.notes().map_err(gix::Exn::into_error)?; + assert_eq!( + notes.default_ref().map(ToString::to_string).as_deref(), + Some("refs/notes/review") + ); + assert!(notes.get(target).map_err(gix::Exn::into_error)?.is_empty()); + + assert_eq!( + notes.add("review", target, b"first").map_err(gix::Exn::into_error)?, + None + ); + let found = notes.get(target).map_err(gix::Exn::into_error)?; + assert_eq!(found.len(), 1); + assert_eq!(found[0].reference.to_string(), "refs/notes/review"); + assert_eq!(found[0].blob.data, b"first"); + drop(found); + + let previous = notes + .add("review", target, b"second") + .map_err(gix::Exn::into_error)? + .expect("the first note is replaced"); + assert_eq!(repo.find_blob(previous)?.data, b"first"); + assert_eq!( + notes.remove("review", target).map_err(gix::Exn::into_error)?, + Some(repo.write_blob(b"second")?.detach()) + ); + assert!(notes.get(target).map_err(gix::Exn::into_error)?.is_empty()); + Ok(()) +} From d17e434f2402e309bdb25e41556e204acde26ae0 Mon Sep 17 00:00:00 2001 From: Byron Date: Sun, 16 Aug 2026 19:56:17 +0200 Subject: [PATCH 004/282] fix: prefer longest remote prefixes for tracking branches Remote-tracking references can be ambiguous when configured remote names contain slashes. Prefer the longest matching remote namespace before reverse-mapping its fetch refspecs, while retaining the all-remotes fallback for custom destinations. --- gix/src/reference/remote.rs | 34 +++++++-------- gix/src/repository/config/branch.rs | 52 +++++++++++++++++------ gix/tests/gix/repository/config/remote.rs | 12 +++--- 3 files changed, 61 insertions(+), 37 deletions(-) diff --git a/gix/src/reference/remote.rs b/gix/src/reference/remote.rs index 35c658a92cc..2533a07fdbb 100644 --- a/gix/src/reference/remote.rs +++ b/gix/src/reference/remote.rs @@ -2,7 +2,7 @@ use gix_ref::{Category, FullName}; use crate::{ Reference, - bstr::ByteSlice, + bstr::{BStr, ByteSlice}, remote, repository::{branch_remote_ref_name, branch_remote_tracking_ref_name}, }; @@ -17,23 +17,10 @@ impl<'repo> Reference<'repo> { let (category, shortname) = self.name().category_and_short_name()?; match category { Category::RemoteBranch => { - if shortname.find_iter("/").take(2).count() == 1 { - let slash_pos = shortname.find_byte(b'/').expect("it was just found"); - shortname[..slash_pos] - .as_bstr() - .to_str() - .ok() - .map(|n| remote::Name::Symbol(n.into())) - } else { - let remotes = self.repo.remote_names(); - for slash_pos in shortname.rfind_iter("/") { - let candidate = shortname[..slash_pos].as_bstr(); - if remotes.contains(candidate) { - return candidate.to_str().ok().map(|n| remote::Name::Symbol(n.into())); - } - } - None - } + let remotes = self.repo.remote_names(); + remote_name_from_tracking_branch(shortname, &remotes) + .and_then(|name| name.to_str().ok()) + .map(|name| remote::Name::Symbol(name.into())) } Category::LocalBranch => self.repo.branch_remote_name(shortname, direction), _ => None, @@ -72,3 +59,14 @@ impl<'repo> Reference<'repo> { self.repo.branch_remote_tracking_ref_name(self.name(), direction) } } + +pub(crate) fn remote_name_from_tracking_branch<'a>(shortname: &'a BStr, remotes: &remote::Names) -> Option<&'a BStr> { + if shortname.find_iter("/").take(2).count() == 1 { + let slash_pos = shortname.find_byte(b'/').expect("it was just found"); + return Some(shortname[..slash_pos].as_bstr()); + } + shortname + .rfind_iter("/") + .map(|slash_pos| shortname[..slash_pos].as_bstr()) + .find(|candidate| remotes.contains(*candidate)) +} diff --git a/gix/src/repository/config/branch.rs b/gix/src/repository/config/branch.rs index 254f2c7b738..23ce9e23f26 100644 --- a/gix/src/repository/config/branch.rs +++ b/gix/src/repository/config/branch.rs @@ -141,7 +141,8 @@ impl crate::Repository { /// the side of the remote, also called upstream branch. /// /// Return `Ok(None)` if there is no remote with fetch-refspecs that would match `tracking_branch` on the right-hand side, - /// or `Err` if the matches were ambiguous. + /// or `Err` if the matches were ambiguous. If the tracking branch starts with a configured remote name, the longest + /// such prefix wins before its fetch refspecs are reverse-mapped. Otherwise all remotes are searched as a fallback. /// /// ### Limitations /// @@ -158,14 +159,17 @@ impl crate::Repository { } let null = self.object_hash().null(); - let item_to_search = gix_refspec::match_group::Item { - full_ref_name: tracking_branch.as_bstr(), - target: &null, - object: None, - }; + let remote_names = self.remote_names(); + let preferred = + crate::reference::remote::remote_name_from_tracking_branch(tracking_branch.shorten(), &remote_names) + .filter(|name| remote_names.contains(*name)) + .map(ToOwned::to_owned); let mut candidates = Vec::new(); let mut ambiguous_remotes = Vec::new(); - for remote_name in self.remote_names() { + for remote_name in preferred + .iter() + .chain(remote_names.iter().filter(|name| Some(*name) != preferred.as_ref())) + { let remote = self.find_remote(remote_name)?; let match_group = gix_refspec::MatchGroup::from_fetch_specs( remote @@ -173,7 +177,23 @@ impl crate::Repository { .iter() .map(|spec| spec.to_ref()), ); - let out = match_group.match_rhs(Some(item_to_search).into_iter()); + let out = match_group.match_rhs( + Some(gix_refspec::match_group::Item { + full_ref_name: tracking_branch.as_bstr(), + target: &null, + object: None, + }) + .into_iter(), + ); + if preferred.as_ref() == Some(remote_name) { + return match &out.mappings[..] { + [] => Ok(None), + [one] => Ok(Some((source_ref_to_full_name(one.lhs.clone())?, remote))), + [..] => Err(Error::AmbiguousRemotes { + remotes: remote.name.into_iter().collect(), + }), + }; + } match &out.mappings[..] { [] => {} [one] => candidates.push((remote.clone(), one.lhs.clone().into_owned())), @@ -183,12 +203,7 @@ impl crate::Repository { if candidates.len() == 1 { let (remote, candidate) = candidates.pop().expect("just checked for one entry"); - let upstream_branch = match candidate { - gix_refspec::match_group::SourceRef::FullName(name) => gix_ref::FullName::try_from(name.into_owned())?, - gix_refspec::match_group::SourceRef::ObjectId(_) => { - unreachable!("Such a reverse mapping isn't ever produced") - } - }; + let upstream_branch = source_ref_to_full_name(candidate)?; return Ok(Some((upstream_branch, remote))); } if ambiguous_remotes.len() + candidates.len() > 1 { @@ -262,6 +277,15 @@ impl crate::Repository { } } +fn source_ref_to_full_name(source: gix_refspec::match_group::SourceRef<'_>) -> Result { + match source { + gix_refspec::match_group::SourceRef::FullName(name) => gix_ref::FullName::try_from(name.into_owned()), + gix_refspec::match_group::SourceRef::ObjectId(_) => { + unreachable!("Such a reverse mapping isn't ever produced") + } + } +} + fn matching_remote<'a>( lhs: &FullNameRef, specs: impl IntoIterator, diff --git a/gix/tests/gix/repository/config/remote.rs b/gix/tests/gix/repository/config/remote.rs index f4e73859896..efca211a5ab 100644 --- a/gix/tests/gix/repository/config/remote.rs +++ b/gix/tests/gix/repository/config/remote.rs @@ -181,13 +181,15 @@ mod branch_remote { assert_eq!(remote.name().expect("named remote").as_bstr(), expected_remote_name); assert_eq!(upstream.as_bstr(), "refs/heads/main"); } - let err = repo + let (upstream, remote) = repo .upstream_branch_and_remote_for_tracking_branch("refs/remotes/with/two/slashes/main".try_into()?) - .unwrap_err(); + .expect("mapping succeeds") + .expect("the longest remote prefix maps uniquely"); + assert_eq!(upstream.as_bstr(), "refs/heads/main"); assert_eq!( - err.to_string(), - "Found ambiguous remotes without 1:1 mapping or more than one match: with/two, with/two/slashes", - "we aren't very specific report an error just like Git does in case of multi-remote ambiguity" + remote.name().expect("named remote").as_bstr(), + "with/two/slashes", + "the longest configured remote prefix wins" ); let (upstream, remote) = repo From d07ed80f94261753791f0d819f85ae4b4d5b4670 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 14:10:41 +0200 Subject: [PATCH 005/282] feat: verify visible commit signatures in tix Consolidates: - feat: verify visible commit signatures in tix - fix: color commit selections by signature status Keep test repositories isolated from user, system, and environment configuration through a shared test-only opening helper. --- gix-tix/Cargo.toml | 2 +- gix-tix/src/app.rs | 107 ++++++++++++++++++++++++++++++++- gix-tix/src/history.rs | 16 +++-- gix-tix/src/lib.rs | 68 ++++++++++++++++++++- gix-tix/src/ui.rs | 130 +++++++++++++++++++++++++++++++++++++---- 5 files changed, 303 insertions(+), 20 deletions(-) diff --git a/gix-tix/Cargo.toml b/gix-tix/Cargo.toml index a83a5c1e8ff..7386d02f175 100644 --- a/gix-tix/Cargo.toml +++ b/gix-tix/Cargo.toml @@ -25,7 +25,7 @@ sha256 = ["gix/sha256"] [dependencies] anyhow = "1.0.98" crossterm = { version = "0.29.0", features = ["osc52"] } -gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["mailmap", "parallel", "revision"] } +gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["mailmap", "parallel", "revision", "command"] } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } [dev-dependencies] diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 4e3ba4f1051..6d5f466ad82 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -19,6 +19,7 @@ pub(crate) struct Commit { pub attributions: Range, pub title: T, pub metadata_loaded: bool, + pub signature: SignatureState, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -27,6 +28,17 @@ pub(crate) struct Metadata { pub author: &'static Author, pub attributions: Range, pub title: T, + pub signature: SignatureState, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum SignatureState { + #[default] + Unsigned, + Unverified, + Verifying, + Verified, + Failed, } #[derive(Debug, Eq, Hash, PartialEq)] @@ -141,6 +153,7 @@ pub(crate) enum Action { ToggleHidden, ToggleAlign, ToggleCommit, + VerifySignatures, Cancel, Copy, CopyAuthor, @@ -148,12 +161,13 @@ pub(crate) enum Action { Quit, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum Effect { Cancel, CopyId(ObjectId), CopyAuthor(&'static Author), Reload(bool), + VerifySignatures(Vec), Quit, } @@ -188,6 +202,8 @@ pub(crate) struct App { horizontal_page: usize, horizontal_max: usize, follow_tail: bool, + pub(crate) signature_failures: usize, + signature_verification_running: bool, } impl App { @@ -222,6 +238,8 @@ impl App { horizontal_page: 1, horizontal_max: 0, follow_tail: false, + signature_failures: 0, + signature_verification_running: false, } } @@ -246,6 +264,7 @@ impl App { attributions: attribution_base + row.attributions.start..attribution_base + row.attributions.end, title: start..self.titles.len(), metadata_loaded: row.metadata_loaded, + signature: row.signature, }); } if was_empty { @@ -273,6 +292,7 @@ impl App { author, attributions, title, + signature, } = metadata; let title_start = self.titles.len(); self.titles.extend_from_slice(&title); @@ -283,6 +303,7 @@ impl App { row.attributions = attribution_start + attributions.start..attribution_start + attributions.end; row.title = title_start..self.titles.len(); row.metadata_loaded = true; + row.signature = signature; } pub(crate) fn title(&self, row: &CommitRow) -> &BStr { @@ -328,7 +349,11 @@ impl App { Action::PageDown => self.move_selection(self.viewport_rows.max(1), true), Action::First => self.select(0), Action::Last if !self.rows.is_empty() => { + let previous = self.selected; self.selected = Some(self.rows.len() - 1); + if self.selected != previous { + self.retry_failed_signatures(); + } self.follow_tail = self.state == State::Loading; self.ensure_visible(); } @@ -362,6 +387,22 @@ impl App { } Action::ToggleAlign => self.align_metadata = !self.align_metadata, Action::ToggleCommit => self.show_commit = !self.show_commit, + Action::VerifySignatures if !self.signature_verification_running => { + let start = self.offset.min(self.rows.len()); + let end = start.saturating_add(self.viewport_rows).min(self.rows.len()); + let ids: Vec<_> = self.rows[start..end] + .iter_mut() + .filter(|row| row.signature == SignatureState::Unverified) + .map(|row| { + row.signature = SignatureState::Verifying; + row.id + }) + .collect(); + if !ids.is_empty() { + self.signature_verification_running = true; + return vec![Effect::VerifySignatures(ids)]; + } + } Action::PreviewAuthorCopy(value) => self.preview_author_copy = value, Action::Cancel if self.state == State::Loading => { self.state = State::Cancelling; @@ -429,6 +470,7 @@ impl App { author: row.author, attributions: row.attributions.clone(), title: row.title.clone(), + signature: row.signature, }, ) }) @@ -444,6 +486,7 @@ impl App { row.attributions = metadata.attributions.clone(); row.title = metadata.title.clone(); row.metadata_loaded = true; + row.signature = metadata.signature; } } self.graph = Some(graph); @@ -468,6 +511,25 @@ impl App { self.show_hidden = show_hidden; self.horizontal_offset = 0; self.follow_tail = false; + self.signature_failures = 0; + self.signature_verification_running = false; + } + + pub(crate) fn finish_signature_verification(&mut self, results: Vec<(ObjectId, bool)>) { + let mut failed = 0; + for (id, valid) in results { + let Some(row) = self.rows.iter_mut().find(|row| row.id == id) else { + continue; + }; + row.signature = if valid { + SignatureState::Verified + } else { + failed += 1; + SignatureState::Failed + }; + } + self.signature_verification_running = false; + self.signature_failures = failed; } fn move_selection(&mut self, distance: usize, down: bool) { @@ -477,18 +539,34 @@ impl App { } else { selected.saturating_sub(distance) }); + if self.selected != Some(selected) { + self.retry_failed_signatures(); + } self.follow_tail = false; self.ensure_visible(); } fn select(&mut self, selected: usize) { if !self.rows.is_empty() { + let previous = self.selected; self.selected = Some(selected.min(self.rows.len() - 1)); + if self.selected != previous { + self.retry_failed_signatures(); + } self.follow_tail = false; self.ensure_visible(); } } + fn retry_failed_signatures(&mut self) { + for row in &mut self.rows { + if row.signature == SignatureState::Failed { + row.signature = SignatureState::Unverified; + } + } + self.signature_failures = 0; + } + pub(crate) fn ensure_visible(&mut self) { let Some(selected) = self.selected else { return }; let height = self.viewport_rows.max(1); @@ -831,6 +909,7 @@ mod tests { attributions: 0..0, title: format!("commit {n}").into(), metadata_loaded: true, + signature: SignatureState::Unsigned, } } @@ -958,6 +1037,7 @@ mod tests { author: row(1).author, attributions: 0..0, title: "loaded".into(), + signature: SignatureState::Unsigned, }, Vec::new(), ); @@ -968,6 +1048,31 @@ mod tests { assert_eq!(app.title(&app.rows[0]), "loaded"); } + #[test] + fn verifies_only_visible_unchecked_signatures() { + let mut app = App::new(2); + app.extend_commits(vec![row(1), row(2), row(3)]); + for row in &mut app.rows { + row.signature = SignatureState::Unverified; + } + app.offset = 1; + + assert_eq!( + app.update(Action::VerifySignatures), + vec![Effect::VerifySignatures(vec![id(2), id(3)])] + ); + assert_eq!(app.rows[0].signature, SignatureState::Unverified); + assert_eq!(app.rows[1].signature, SignatureState::Verifying); + app.finish_signature_verification(vec![(id(2), true), (id(3), false)]); + assert_eq!(app.rows[1].signature, SignatureState::Verified); + assert_eq!(app.rows[2].signature, SignatureState::Failed); + assert_eq!(app.signature_failures, 1); + + app.update(Action::MoveDown); + assert_eq!(app.rows[2].signature, SignatureState::Unverified); + assert_eq!(app.signature_failures, 0); + } + #[test] fn lane_reuses_a_parent_that_is_already_to_the_right() { let mut app = App::new(10); diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index 0b3d099dc9f..f683729aaa4 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -11,7 +11,7 @@ use gix::{ objs::commit::ref_iter::Token, }; -use crate::app::{Attribution, AttributionKind, Author, Commit, LoadedCommits, Metadata}; +use crate::app::{Attribution, AttributionKind, Author, Commit, LoadedCommits, Metadata, SignatureState}; pub(crate) type SharedAuthors = gix::features::threading::OwnShared>; static EMPTY_AUTHOR: std::sync::LazyLock = std::sync::LazyLock::new(|| Author { @@ -96,11 +96,13 @@ pub(crate) fn load( author, attributions: row_attributions, title, + signature, } = metadata.unwrap_or_else(|| Metadata { committer_time: Default::default(), author: &EMPTY_AUTHOR, attributions: 0..0, title: BString::default(), + signature: SignatureState::Unsigned, }); rows.push(Commit { id: info.id, @@ -110,6 +112,7 @@ pub(crate) fn load( attributions: row_attributions, title, metadata_loaded, + signature, }); if rows.len() == COMMIT_BATCH_SIZE && !emit(Event::Commits(LoadedCommits { @@ -148,6 +151,7 @@ fn decode_metadata<'a>( let mut author = None; let attribution_start = attributions.len(); let mut title = None; + let mut signature = SignatureState::Unsigned; for token in tokens { match token.context("could not decode commit")? { Token::Author { signature } => { @@ -183,6 +187,9 @@ fn decode_metadata<'a>( } } } + Token::ExtraHeader((name, _)) if name == "gpgsig" || name == "gpgsig-sha256" => { + signature = SignatureState::Unverified; + } _ => {} } } @@ -191,6 +198,7 @@ fn decode_metadata<'a>( author: author.context("commit has no author")?, attributions: attribution_start..attributions.len(), title: title.context("commit has no message")?, + signature, }) } @@ -357,7 +365,7 @@ mod tests { let mut events = Vec::new(); let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); - let repo = gix::open(path)?; + let repo = crate::open_test_repository(path)?; load( &repo, &revisions.iter().map(OsString::from).collect::>(), @@ -460,7 +468,7 @@ mod tests { .current_dir(fixture_path) .env("GIT_AUTHOR_DATE", "2000-01-05T00:00:00 +0000") .env("GIT_COMMITTER_DATE", "2000-01-05T00:00:00 +0000") - .args(["commit", "-q", "-m", "new"]) + .args(["-c", "commit.gpgSign=false", "commit", "-q", "-m", "new"]) .status()?; assert!(commit.success(), "a commit newer than the graph is created"); @@ -481,7 +489,7 @@ mod tests { .find(|row| !row.metadata_loaded) .expect("older graph commits defer metadata"); - let repo = gix::open(fixture_path)?; + let repo = crate::open_test_repository(fixture_path)?; let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); let (metadata, _) = load_metadata(&repo, deferred.id, &authors)?; diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 61454821e58..939a2b684eb 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -8,7 +8,7 @@ mod ui; use std::{ ffi::OsString, - path::Path, + path::{Path, PathBuf}, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -258,6 +258,7 @@ fn event_loop( let mut app = App::new(1); let mut lane_receiver = None; + let mut verification_receiver = None; let mut commit_message = None; let mut fill_repository = FillRepository { path: &repository_path, @@ -282,6 +283,19 @@ fn event_loop( let mut inline_terminal = None; let mut history_requires_alternate_screen = false; let result: Result> = (|| loop { + if let Some(result) = verification_receiver.as_ref().map(mpsc::Receiver::try_recv) { + match result { + Ok(results) => { + app.finish_signature_verification(results); + verification_receiver = None; + dirty = true; + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + anyhow::bail!("signature verification worker stopped unexpectedly") + } + } + } if let Some(result) = lane_receiver.as_ref().map(mpsc::Receiver::try_recv) { match result { Ok((rows, graph, lane_time)) => { @@ -358,7 +372,8 @@ fn event_loop( resize_inline, &mut inline_terminal, )?; - let streaming = matches!(app.state, State::Loading | State::Cancelling | State::Computing); + let streaming = matches!(app.state, State::Loading | State::Cancelling | State::Computing) + || verification_receiver.is_some(); if should_draw(dirty, streaming, last_draw.elapsed()) { draw( terminal, @@ -424,6 +439,9 @@ fn event_loop( gix::features::threading::OwnShared::clone(&authors), ); } + Effect::VerifySignatures(ids) => { + verification_receiver = Some(start_signature_verification(repository_path.clone(), ids)); + } Effect::Quit => { if app.inline { app.show_selection_tail = false; @@ -467,6 +485,42 @@ fn start_lane_worker(rows: Vec) -> mpsc::Receiver<(Vec, ap receiver } +type SignatureVerification = (gix::ObjectId, bool); + +fn start_signature_verification( + repository_path: PathBuf, + ids: Vec, +) -> mpsc::Receiver> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let results = match gix::open(repository_path) { + Ok(mut repository) => { + repository.object_cache_size(None); + ids.into_iter() + .map(|id| { + let result = repository + .find_commit(id) + .context("could not read signed commit") + .and_then(|commit| { + commit + .verify_signature() + .context("could not verify commit signature") + .and_then(|outcome| outcome.context("commit no longer has a signature")) + }); + match result { + Ok(outcome) if outcome.is_valid() => (id, true), + Ok(_) | Err(_) => (id, false), + } + }) + .collect() + } + Err(_) => ids.into_iter().map(|id| (id, false)).collect(), + }; + let _ = sender.send(results); + }); + receiver +} + fn start_history( repository: gix::ThreadSafeRepository, revisions: &[OsString], @@ -618,6 +672,7 @@ fn action(key: KeyEvent) -> Option { KeyCode::Char('t') => Some(Action::ToggleTrailers), KeyCode::Char('m') => Some(Action::ToggleMailmap), KeyCode::Char('r') => Some(Action::ToggleRefs), + KeyCode::Char('s') => Some(Action::VerifySignatures), KeyCode::Char('v') => Some(Action::ToggleHidden), KeyCode::Char('[') => Some(Action::ToggleAlign), KeyCode::Char(']' | 'o') => Some(Action::ToggleCommit), @@ -646,6 +701,11 @@ fn retains_fill_repository(kind: KeyEventKind, action: Option<&Action>) -> bool kind == KeyEventKind::Repeat && action.is_some_and(repeats_viewport) } +#[cfg(test)] +fn open_test_repository(path: impl AsRef) -> Result { + gix::open_opts(path.as_ref(), gix::open::Options::isolated()) +} + #[cfg(test)] mod tests { use super::*; @@ -788,6 +848,10 @@ mod tests { action(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)), Some(Action::ToggleHidden) ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)), + Some(Action::VerifySignatures) + ); assert_eq!( action(KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE)), Some(Action::ToggleAlign) diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index cc182e9d504..f0ad7c736a7 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -8,7 +8,7 @@ use ratatui::{ }; use crate::{ - app::{App, AttributionKind, CommitRow, CopyKind, NameMode, RefMode, State}, + app::{App, AttributionKind, CommitRow, CopyKind, NameMode, RefMode, SignatureState, State}, history::{DecorationKind, Decorations}, }; @@ -42,6 +42,9 @@ pub(crate) fn draw( let start = app.offset.min(app.rows.len()); let end = start.saturating_add(app.viewport_rows).min(app.rows.len()); let visible_rows = &app.rows[start..end]; + let has_verifiable_signatures = visible_rows + .iter() + .any(|row| matches!(row.signature, SignatureState::Unverified | SignatureState::Verifying)); let lanes = app.render_lanes(start..end); let content = Rect::new( body.x.saturating_add(2), @@ -134,8 +137,9 @@ pub(crate) fn draw( let y = body.y.saturating_add(index as u16); let selected = app.selected == Some(start + index); let metadata_width = metadata.width(); + let signature_color = signature_color(visible_rows[index].signature); let style = if selected { - Style::default().add_modifier(Modifier::REVERSED) + color(signature_color).add_modifier(Modifier::REVERSED) } else { Style::default() }; @@ -150,7 +154,14 @@ pub(crate) fn draw( Paragraph::new(lane).style(style).scroll((0, graph_offset as u16)), row_area, ); - color_graph(frame, row_area, lane, graph_offset, selected); + color_graph( + frame, + row_area, + lane, + graph_offset, + selected, + visible_rows[index].signature, + ); let aligned = Rect::new( content.x.saturating_add(align_width as u16), y, @@ -167,7 +178,14 @@ pub(crate) fn draw( Paragraph::new(Line::from(spans)).scroll((0, horizontal_offset as u16)), row_area, ); - color_graph(frame, row_area, lane, horizontal_offset, selected); + color_graph( + frame, + row_area, + lane, + horizontal_offset, + selected, + visible_rows[index].signature, + ); } if selected && app.show_selection_tail && body.width > 0 { let line_width = if align_metadata { @@ -238,6 +256,19 @@ pub(crate) fn draw( } else { " · y copy" })); + if app.signature_failures > 0 { + footer_spans.extend([ + Span::raw(format!(" · s {} ", app.signature_failures)), + Span::styled("●", color(Color::Red)), + ]); + } else if has_verifiable_signatures { + footer_spans.extend([ + Span::raw(" · s "), + Span::styled("●", color(Color::Rgb(255, 165, 0))), + Span::raw(" -> "), + Span::styled("●", color(Color::Green)), + ]); + } if app.state == State::Loading { footer_spans.push(Span::raw(" · Esc cancel")); } @@ -496,23 +527,38 @@ fn color(color: Color) -> Style { Style::default().fg(color) } -fn color_graph(frame: &mut Frame<'_>, area: Rect, graph: &str, offset: usize, selected: bool) { +fn color_graph( + frame: &mut Frame<'_>, + area: Rect, + graph: &str, + offset: usize, + selected: bool, + signature: SignatureState, +) { for (x, symbol) in graph.chars().skip(offset).take(area.width as usize).enumerate() { if symbol.is_whitespace() { continue; } - let mut style = if symbol == '●' { - Style::default().fg(Color::Blue) + let style = if selected { + color(signature_color(signature)).add_modifier(Modifier::REVERSED) + } else if symbol == '●' { + color(signature_color(signature)) } else { graph_style(offset.saturating_add(x) / 2) }; - if selected { - style = style.add_modifier(Modifier::REVERSED); - } frame.buffer_mut()[(area.x + x as u16, area.y)].set_style(style); } } +fn signature_color(signature: SignatureState) -> Color { + match signature { + SignatureState::Unsigned => Color::Blue, + SignatureState::Unverified | SignatureState::Verifying => Color::Rgb(255, 165, 0), + SignatureState::Verified => Color::Green, + SignatureState::Failed => Color::Red, + } +} + fn graph_style(column: usize) -> Style { const COLORS: [Color; 7] = [ Color::Magenta, @@ -573,6 +619,7 @@ mod tests { attributions: 0..7, title: "subject".into(), metadata_loaded: true, + signature: SignatureState::Unsigned, }], attributions: vec![ Attribution { @@ -678,6 +725,7 @@ mod tests { attributions: 0..0, title: "subject".into(), metadata_loaded: true, + signature: SignatureState::Unsigned, }]); complete(&mut app); let decorations = Decorations::from([( @@ -705,7 +753,9 @@ mod tests { for x in 0..11 { expected[(x, 0)].set_style(Style::default().add_modifier(Modifier::REVERSED)); } - expected[(2, 0)].set_style(Style::default().fg(Color::Blue).add_modifier(Modifier::REVERSED)); + for x in 0..4 { + expected[(x, 0)].set_style(Style::default().fg(Color::Blue).add_modifier(Modifier::REVERSED)); + } for x in 4..11 { expected[(x, 0)].set_style( Style::default() @@ -723,7 +773,7 @@ mod tests { expected[(x, 0)].set_style(Style::default().fg(Color::Green)); } expected[(selected_line.chars().count() as u16 + 1, 0)] - .set_style(Style::default().add_modifier(Modifier::REVERSED)); + .set_style(Style::default().fg(Color::Blue).add_modifier(Modifier::REVERSED)); let commit = footer_text[..footer_text.find("o commit").expect("the commit toggle is present")] .chars() .count(); @@ -872,6 +922,7 @@ mod tests { attributions: 0..0, title: format!("subject {n}").into(), metadata_loaded: true, + signature: SignatureState::Unsigned, }) .collect::>(), ); @@ -932,6 +983,57 @@ mod tests { Ok(()) } + #[test] + fn colors_commit_disks_by_signature_state() -> Result<(), Box> { + let states = [ + (SignatureState::Unsigned, Color::Blue), + (SignatureState::Unverified, Color::Rgb(255, 165, 0)), + (SignatureState::Verified, Color::Green), + (SignatureState::Failed, Color::Red), + ]; + let mut terminal = Terminal::new(TestBackend::new(2, states.len() as u16))?; + terminal.draw(|frame| { + for (y, (state, _)) in states.iter().enumerate() { + color_graph(frame, Rect::new(0, y as u16, 2, 1), "●─", 0, true, *state); + } + })?; + + for (y, (_, expected)) in states.iter().enumerate() { + for x in 0..2 { + let cell = &terminal.backend().buffer()[(x, y as u16)]; + assert_eq!(cell.fg, *expected); + assert!(cell.modifier.contains(Modifier::REVERSED)); + } + } + Ok(()) + } + + #[test] + fn shows_signature_action_only_while_actionable() -> Result<(), Box> { + let id = gix::ObjectId::Sha1([1; 20]); + let mut app = App::new(1); + app.extend_commits(vec![Commit { + id, + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + signature: SignatureState::Unverified, + }]); + complete(&mut app); + let mut terminal = Terminal::new(TestBackend::new(160, 2))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(rendered_line(&terminal, 1).contains("s ● -> ●")); + + app.finish_signature_verification(vec![(id, false)]); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(rendered_line(&terminal, 1).contains("s 1 ●")); + Ok(()) + } + #[test] fn advertises_cancel_only_while_loading() -> Result<(), Box> { let mut app = App::new(1); @@ -961,6 +1063,7 @@ mod tests { attributions: 0..0, title: "subject".into(), metadata_loaded: true, + signature: SignatureState::Unsigned, }]); let mut terminal = Terminal::new(TestBackend::new(120, 6))?; @@ -1118,6 +1221,7 @@ mod tests { attributions: 0..0, title: format!("subject {n}").into(), metadata_loaded: true, + signature: SignatureState::Unsigned, }) .collect::>(), ); @@ -1166,6 +1270,7 @@ mod tests { attributions: 0..0, title: "subject".into(), metadata_loaded: true, + signature: SignatureState::Unsigned, }; let decorations = Decorations::from([( id, @@ -1268,6 +1373,7 @@ mod tests { attributions: 0..0, title: format!("{} subject-tail", "a".repeat(50)).into(), metadata_loaded: true, + signature: SignatureState::Unsigned, }]); complete(&mut app); app.set_lane(0, &format!("{}{}", "A".repeat(40), "B".repeat(40))); From 2109507ed318413371d6d8bffa9022ddb5fa24e7 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 11:11:43 +0200 Subject: [PATCH 006/282] feat: present commit authorship in tix Improve attribution display while retaining access to complete actor identities. Treat every Assisted-by trailer value as an agent, toggle full actors and emails while hiding attribution comments, omit classified agent emails, italicize GitHub noreply actors, and group attribution keys whose displayed values are identical. Detect agent markers in commit messages and prefix generated commit titles with a bright-purple [A] marker shared with the Git notes marker. --- gix-tix/src/app.rs | 53 ++++--- gix-tix/src/history.rs | 20 +++ gix-tix/src/lib.rs | 7 +- gix-tix/src/ui.rs | 242 ++++++++++++++++++++++++------ gix-tix/tests/fixtures/history.sh | 4 +- 5 files changed, 252 insertions(+), 74 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 6d5f466ad82..f8145801cb5 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -19,6 +19,7 @@ pub(crate) struct Commit { pub attributions: Range, pub title: T, pub metadata_loaded: bool, + pub has_agent_marker: bool, pub signature: SignatureState, } @@ -28,6 +29,7 @@ pub(crate) struct Metadata { pub author: &'static Author, pub attributions: Range, pub title: T, + pub has_agent_marker: bool, pub signature: SignatureState, } @@ -53,6 +55,13 @@ impl Author { .iter() .any(|candidate| self.email.eq_ignore_ascii_case(candidate)) } + + pub fn is_github_noreply(&self) -> bool { + let suffix = b"@users.noreply.github.com"; + self.email + .get(self.email.len().saturating_sub(suffix.len())..) + .is_some_and(|email| email.eq_ignore_ascii_case(suffix)) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -63,15 +72,7 @@ pub(crate) struct Attribution { impl Attribution { pub fn is_agent(&self) -> bool { - self.author.is_bot() - || self.kind == AttributionKind::Assisted - && [b"opus".as_slice(), b"gpt".as_slice()].iter().any(|name| { - self.author - .name - .get(..name.len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case(name)) - && self.author.name.get(name.len()).is_none_or(u8::is_ascii_whitespace) - }) + self.author.is_bot() || self.kind == AttributionKind::Assisted } } @@ -147,6 +148,7 @@ pub(crate) enum Action { Last, ToggleDate, ToggleName, + ToggleEmail, ToggleTrailers, ToggleMailmap, ToggleRefs, @@ -186,6 +188,7 @@ pub(crate) struct App { pub lane_time: Option, pub show_committer_date: bool, pub name_mode: NameMode, + pub show_emails: bool, pub show_trailers: bool, pub use_mailmap: bool, pub ref_mode: RefMode, @@ -222,6 +225,7 @@ impl App { lane_time: None, show_committer_date: true, name_mode: NameMode::All, + show_emails: false, show_trailers: true, use_mailmap: true, ref_mode: RefMode::Default, @@ -264,6 +268,7 @@ impl App { attributions: attribution_base + row.attributions.start..attribution_base + row.attributions.end, title: start..self.titles.len(), metadata_loaded: row.metadata_loaded, + has_agent_marker: row.has_agent_marker, signature: row.signature, }); } @@ -292,6 +297,7 @@ impl App { author, attributions, title, + has_agent_marker, signature, } = metadata; let title_start = self.titles.len(); @@ -303,6 +309,7 @@ impl App { row.attributions = attribution_start + attributions.start..attribution_start + attributions.end; row.title = title_start..self.titles.len(); row.metadata_loaded = true; + row.has_agent_marker = has_agent_marker; row.signature = signature; } @@ -358,6 +365,7 @@ impl App { self.ensure_visible(); } Action::ToggleDate => self.show_committer_date = !self.show_committer_date, + Action::ToggleEmail => self.show_emails = !self.show_emails, Action::ToggleName => { let start = self.offset.min(self.rows.len()); let end = start.saturating_add(self.viewport_rows).min(self.rows.len()); @@ -470,6 +478,7 @@ impl App { author: row.author, attributions: row.attributions.clone(), title: row.title.clone(), + has_agent_marker: row.has_agent_marker, signature: row.signature, }, ) @@ -486,6 +495,7 @@ impl App { row.attributions = metadata.attributions.clone(); row.title = metadata.title.clone(); row.metadata_loaded = true; + row.has_agent_marker = metadata.has_agent_marker; row.signature = metadata.signature; } } @@ -909,6 +919,7 @@ mod tests { attributions: 0..0, title: format!("commit {n}").into(), metadata_loaded: true, + has_agent_marker: false, signature: SignatureState::Unsigned, } } @@ -920,34 +931,23 @@ mod tests { } #[test] - fn recognizes_named_agents_only_when_assisting() { - let opus = Box::leak(Box::new(Author { - name: b"Opus 4.7".as_bstr(), - email: b"".as_bstr(), - })); - let gpt = Box::leak(Box::new(Author { - name: b"GPT 5.6".as_bstr(), + fn recognizes_all_assistants_as_agents() { + let assistant = Box::leak(Box::new(Author { + name: b"Anything".as_bstr(), email: b"".as_bstr(), })); assert!( Attribution { kind: AttributionKind::Assisted, - author: opus, - } - .is_agent() - ); - assert!( - Attribution { - kind: AttributionKind::Assisted, - author: gpt, + author: assistant, } .is_agent() ); assert!( !Attribution { kind: AttributionKind::Reviewed, - author: opus, + author: assistant, } .is_agent() ); @@ -1037,6 +1037,7 @@ mod tests { author: row(1).author, attributions: 0..0, title: "loaded".into(), + has_agent_marker: false, signature: SignatureState::Unsigned, }, Vec::new(), @@ -1196,6 +1197,7 @@ mod tests { assert!(app.show_trailers, "trailer attribution is visible by default"); app.update(Action::ToggleDate); + app.update(Action::ToggleEmail); app.update(Action::ToggleName); app.update(Action::ToggleTrailers); app.update(Action::ToggleMailmap); @@ -1204,6 +1206,7 @@ mod tests { app.update(Action::ToggleCommit); assert!(!app.show_committer_date); + assert!(app.show_emails); assert_eq!(app.name_mode, NameMode::None); assert!(!app.show_trailers); assert!(!app.use_mailmap); diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index f683729aaa4..0e16dda33f2 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -96,12 +96,14 @@ pub(crate) fn load( author, attributions: row_attributions, title, + has_agent_marker, signature, } = metadata.unwrap_or_else(|| Metadata { committer_time: Default::default(), author: &EMPTY_AUTHOR, attributions: 0..0, title: BString::default(), + has_agent_marker: false, signature: SignatureState::Unsigned, }); rows.push(Commit { @@ -112,6 +114,7 @@ pub(crate) fn load( attributions: row_attributions, title, metadata_loaded, + has_agent_marker, signature, }); if rows.len() == COMMIT_BATCH_SIZE @@ -151,6 +154,7 @@ fn decode_metadata<'a>( let mut author = None; let attribution_start = attributions.len(); let mut title = None; + let mut has_agent_marker = false; let mut signature = SignatureState::Unsigned; for token in tokens { match token.context("could not decode commit")? { @@ -162,6 +166,7 @@ fn decode_metadata<'a>( committer_time = Some(signature.time().context("could not decode committer time")?); } Token::Message(message) => { + has_agent_marker = contains_agent_marker(message); let message = gix::objs::commit::MessageRef::from_bytes(message); title = Some(message.summary().into_owned()); if let Some(body) = message.body() { @@ -198,10 +203,17 @@ fn decode_metadata<'a>( author: author.context("commit has no author")?, attributions: attribution_start..attributions.len(), title: title.context("commit has no message")?, + has_agent_marker, signature, }) } +fn contains_agent_marker(message: &[u8]) -> bool { + [b"--- agent".as_slice(), b"".as_slice()] + .iter() + .any(|marker| message.windows(marker.len()).any(|window| window == *marker)) +} + pub(crate) fn count_up_to( repo: &gix::Repository, revisions: &[OsString], @@ -424,6 +436,7 @@ mod tests { topic.author.is_bot(), "well-known bot email addresses identify bot authors" ); + assert!(topic.has_agent_marker, "history loading recognizes the agent marker"); assert_eq!( attributions[topic.attributions.clone()] .iter() @@ -448,6 +461,13 @@ mod tests { Ok(()) } + #[test] + fn recognizes_supported_agent_markers() { + assert!(contains_agent_marker(b"subject\n\n--- agent\n")); + assert!(contains_agent_marker(b"subject\n\n\n")); + assert!(!contains_agent_marker(b"subject\n\nagent")); + } + #[test] fn decodes_commits_missing_from_a_stale_graph_and_defers_graph_commits() -> gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 939a2b684eb..7d68316906b 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -668,6 +668,7 @@ fn action(key: KeyEvent) -> Option { KeyCode::Home | KeyCode::Char('g') => Some(Action::First), KeyCode::End | KeyCode::Char('G') => Some(Action::Last), KeyCode::Char('d') => Some(Action::ToggleDate), + KeyCode::Char('e') => Some(Action::ToggleEmail), KeyCode::Char('n') => Some(Action::ToggleName), KeyCode::Char('t') => Some(Action::ToggleTrailers), KeyCode::Char('m') => Some(Action::ToggleMailmap), @@ -717,7 +718,7 @@ mod tests { let id = repository.rev_parse_single("topic")?.detach(); assert!( - load_commit_message(&repository, id)?.starts_with(b"topic\n\nCo-authored-by:"), + load_commit_message(&repository, id)?.starts_with(b"topic\n\n--- agent\n\nCo-authored-by:"), "on-demand loading retains the full commit message" ); Ok(()) @@ -828,6 +829,10 @@ mod tests { action(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), Some(Action::ToggleDate) ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), + Some(Action::ToggleEmail) + ); assert_eq!( action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), Some(Action::ToggleName) diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index f0ad7c736a7..2c16d4d40d5 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -12,6 +12,8 @@ use crate::{ history::{DecorationKind, Decorations}, }; +const NOTE_COLOR: Color = Color::LightMagenta; + pub(crate) fn draw( frame: &mut Frame<'_>, app: &mut App, @@ -91,6 +93,7 @@ pub(crate) fn draw( MetadataOptions { show_committer_date, show_author_name, + show_emails: app.show_emails, show_trailers, use_mailmap: app.use_mailmap && !preview_author_copy && copy_feedback != Some(CopyKind::Author), ref_mode, @@ -236,6 +239,7 @@ pub(crate) fn draw( ]); } footer_spans.extend([Span::raw(" · "), toggle("d date", app.show_committer_date)]); + footer_spans.extend([Span::raw(" · "), toggle("e emails", app.show_emails)]); let (name_label, names_visible) = match app.name_mode { NameMode::All => ("n names", true), NameMode::Author => ("n name", true), @@ -370,6 +374,7 @@ fn toggle(label: &'static str, enabled: bool) -> Span<'static> { struct MetadataOptions { show_committer_date: bool, show_author_name: bool, + show_emails: bool, show_trailers: bool, use_mailmap: bool, ref_mode: RefMode, @@ -390,6 +395,7 @@ fn metadata_line<'a>( let MetadataOptions { show_committer_date, show_author_name, + show_emails, show_trailers, use_mailmap, ref_mode, @@ -443,14 +449,17 @@ fn metadata_line<'a>( )); } if show_author_name { - let author = author_name(row.author, mailmap, use_mailmap).to_str_lossy(); - let author_style = if copy_feedback == Some(CopyKind::Author) { + let author = author_label(row.author, mailmap, use_mailmap, show_emails && !row.author.is_bot()); + let mut author_style = if copy_feedback == Some(CopyKind::Author) { Style::default() } else if preview_author_copy { color(Color::Magenta).add_modifier(Modifier::BOLD) } else { color(Color::Green) }; + if row.author.is_github_noreply() { + author_style = author_style.add_modifier(Modifier::ITALIC); + } spans.push(Span::styled( if row.author.is_bot() { format!("[{author}] ") @@ -460,55 +469,96 @@ fn metadata_line<'a>( author_style, )); if show_trailers { - for (kind, marker) in [ - (AttributionKind::CoAuthor, "Co: "), - (AttributionKind::Assisted, "As: "), - (AttributionKind::Reviewed, "Re: "), - (AttributionKind::Acked, "Ack: "), - (AttributionKind::Tested, "Te: "), - (AttributionKind::SignedOff, "So: "), + type Group = (&'static str, Vec<&'static str>, Vec<(String, Style)>); + let mut groups: Vec = Vec::new(); + for (kind, marker, grouped_marker) in [ + (AttributionKind::CoAuthor, "Co: ", "Co"), + (AttributionKind::Assisted, "As: ", "A"), + (AttributionKind::Reviewed, "Re: ", "Re"), + (AttributionKind::Acked, "Ack: ", "Ack"), + (AttributionKind::Tested, "Te: ", "Te"), + (AttributionKind::SignedOff, "So: ", "So"), ] { - let mut actors = attributions.iter().filter(|actor| actor.kind == kind).peekable(); - if actors.peek().is_none() { + let actors: Vec<_> = attributions + .iter() + .filter(|actor| actor.kind == kind) + .map(|actor| { + let name = if actor.author == row.author { + "*".to_owned() + } else { + let name = + author_label(actor.author, mailmap, use_mailmap, show_emails && !actor.is_agent()); + if actor.is_agent() { format!("[{name}]") } else { name } + }; + let style = if actor.author.is_github_noreply() { + color(Color::Green).add_modifier(Modifier::ITALIC) + } else { + color(Color::Green) + }; + (name, style) + }) + .collect(); + if actors.is_empty() { continue; } - spans.push(Span::styled(marker, color(Color::Green).add_modifier(Modifier::DIM))); - for (index, actor) in actors.enumerate() { + if let Some((_, markers, _)) = groups + .iter_mut() + .find(|(_, _, displayed_actors)| *displayed_actors == actors) + { + markers.push(grouped_marker); + } else { + groups.push((marker, vec![grouped_marker], actors)); + } + } + for (marker, markers, actors) in groups { + spans.push(Span::styled( + if markers.len() == 1 { + marker.to_owned() + } else { + format!("{}: ", markers.join(", ")) + }, + color(Color::Green).add_modifier(Modifier::DIM), + )); + for (index, (name, style)) in actors.into_iter().enumerate() { if index != 0 { spans.push(Span::raw(", ")); } - let name = if actor.author == row.author { - "*".to_owned() - } else { - let name = author_name(actor.author, mailmap, use_mailmap).to_str_lossy(); - if actor.is_agent() { - format!("[{name}]") - } else { - name.into_owned() - } - }; - spans.push(Span::styled(name, color(Color::Green))); + spans.push(Span::styled(name, style)); } spans.push(Span::raw(" ")); } } } - spans.push(Span::raw(title.to_str_lossy())); + if row.has_agent_marker { + spans.push(Span::styled("[A] ", color(NOTE_COLOR))); + } + if !show_emails { + spans.push(Span::raw(title.to_str_lossy())); + } Line::from(spans) } -fn author_name<'a>(author: &'a crate::app::Author, mailmap: &'a gix::mailmap::Snapshot, use_mailmap: bool) -> &'a BStr { - if use_mailmap { - mailmap - .try_resolve_ref(gix::actor::SignatureRef { +fn author_label( + author: &crate::app::Author, + mailmap: &gix::mailmap::Snapshot, + use_mailmap: bool, + show_email: bool, +) -> String { + let resolved = use_mailmap + .then(|| { + mailmap.try_resolve_ref(gix::actor::SignatureRef { name: author.name, email: author.email, time: "", }) - .and_then(|resolved| resolved.name) - .unwrap_or(author.name) + }) + .flatten(); + let name = resolved.as_ref().and_then(|actor| actor.name).unwrap_or(author.name); + if show_email { + let email = resolved.as_ref().and_then(|actor| actor.email).unwrap_or(author.email); + format!("{} <{}>", name.to_str_lossy(), email.to_str_lossy()) } else { - author.name + name.to_str_lossy().into_owned() } } @@ -616,18 +666,23 @@ mod tests { parent_ids: Default::default(), committer_time: gix::date::Time::default(), author: author(b"Codex", b"codex@openai.com"), - attributions: 0..7, + attributions: 0..8, title: "subject".into(), metadata_loaded: true, + has_agent_marker: false, signature: SignatureState::Unsigned, }], attributions: vec![ Attribution { kind: AttributionKind::CoAuthor, - author: author(b"Human", b"human@example.com"), + author: author(b"Claude", b"noreply@anthropic.com"), }, Attribution { kind: AttributionKind::CoAuthor, + author: author(b"Codex", b"codex@openai.com"), + }, + Attribution { + kind: AttributionKind::Assisted, author: author(b"Claude", b"noreply@anthropic.com"), }, Attribution { @@ -636,7 +691,7 @@ mod tests { }, Attribution { kind: AttributionKind::Reviewed, - author: author(b"Reviewer", b"reviewer@example.com"), + author: author(b"Human", b"human@example.com"), }, Attribution { kind: AttributionKind::Acked, @@ -661,10 +716,8 @@ mod tests { let row = rendered_row(&terminal); assert!( - row.contains( - "[Codex] Co: Mapped Human, [Claude] As: * Re: Reviewer Ack: Acknowledger Te: Tester So: Signer subject" - ), - "same-kind trailers share one marker, use mailmap, and collapse the primary author to an asterisk" + row.contains("[Codex] Co, A: [Claude], * Re: Mapped Human Ack: Acknowledger Te: Tester So: Signer subject"), + "attributions with identical displayed actors share their markers" ); let buffer = terminal.backend().buffer(); let style_at = |needle: &str| { @@ -672,8 +725,12 @@ mod tests { buffer[(x, 0)].fg }; assert_eq!(style_at("[Codex]"), Color::Green, "bot authors use the agent color"); - assert_eq!(style_at("Co:"), Color::Green, "attribution markers use the agent color"); - let marker_x = row.find("Co:").expect("rendered metadata contains a trailer marker") as u16; + assert_eq!( + style_at("Co, A:"), + Color::Green, + "grouped attribution markers use the agent color" + ); + let marker_x = row.find("Co, A:").expect("rendered metadata contains a trailer marker") as u16; assert!( buffer[(marker_x, 0)].modifier.contains(Modifier::DIM), "attribution markers are dimmed" @@ -695,21 +752,98 @@ mod tests { let row = rendered_row(&terminal); assert!(row.contains("Codex"), "the first n keeps the primary actor"); assert!( - !row.contains("Reviewer"), + !row.contains("Mapped Human"), "the first n hides trailer actors while trailers are enabled" ); app.update(Action::ToggleName); terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None))?; let row = rendered_row(&terminal); assert!(!row.contains("Codex"), "the second n hides the primary actor"); - assert!(!row.contains("Reviewer"), "the second n keeps trailer actors hidden"); + assert!( + !row.contains("Mapped Human"), + "the second n keeps trailer actors hidden" + ); app.update(Action::ToggleName); app.update(Action::ToggleMailmap); terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None))?; assert!( - rendered_row(&terminal).contains("Co: Human, [Claude]"), + rendered_row(&terminal).contains("Re: Human"), "m restores original trailer actor names" ); + + app.update(Action::ToggleEmail); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let row = rendered_row(&terminal); + assert!(row.contains("Human ")); + assert!(!row.contains("codex@openai.com")); + assert!(!row.contains("noreply@anthropic.com")); + Ok(()) + } + + #[test] + fn toggles_full_actor_and_comment() -> Result<(), Box> { + let mut app = App::new(1); + app.extend_commits(vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "unique comment".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }]); + app.selected = None; + let mut terminal = Terminal::new(TestBackend::new(100, 2))?; + + app.update(Action::ToggleEmail); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(rendered_row(&terminal).contains("author ")); + assert!(!rendered_row(&terminal).contains("unique comment")); + + app.update(Action::ToggleEmail); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!(!rendered_row(&terminal).contains("")); + assert!(rendered_row(&terminal).contains("unique comment")); + Ok(()) + } + + #[test] + fn italicizes_github_noreply_actors() -> Result<(), Box> { + let mut app = App::new(1); + app.extend_commits(LoadedCommits { + rows: vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"Author", b"1+author@users.noreply.github.com"), + attributions: 0..1, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }], + attributions: vec![Attribution { + kind: AttributionKind::Reviewed, + author: author(b"Reviewer", b"reviewer@USERS.NOREPLY.GITHUB.COM"), + }], + }); + app.selected = None; + app.update(Action::ToggleEmail); + let mut terminal = Terminal::new(TestBackend::new(160, 2))?; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + + let row = rendered_row(&terminal); + for actor in [ + "Author <1+author@users.noreply.github.com>", + "Reviewer ", + ] { + let start = row.find(actor).expect("the full actor is rendered") as u16; + for x in start..start + actor.len() as u16 { + assert!(terminal.backend().buffer()[(x, 0)].modifier.contains(Modifier::ITALIC)); + } + } Ok(()) } @@ -725,6 +859,7 @@ mod tests { attributions: 0..0, title: "subject".into(), metadata_loaded: true, + has_agent_marker: false, signature: SignatureState::Unsigned, }]); complete(&mut app); @@ -743,13 +878,13 @@ mod tests { )]); let mailmap = gix::mailmap::Snapshot::from_bytes(b"mapped author author \n"); - let mut terminal = Terminal::new(TestBackend::new(140, 2))?; + let mut terminal = Terminal::new(TestBackend::new(150, 2))?; terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; - let footer_text = "1 commits · ↑↓/jk move · h/l pan · [ align · o commit · d date · n names · m mailmap · t trailers · r refs · y copy · q quit"; + let footer_text = "1 commits · ↑↓/jk move · h/l pan · [ align · o commit · d date · e emails · n names · m mailmap · t trailers · r refs · y copy · q quit"; let selected_line = "> ● 0101010 (HEAD) 1970-01-01 mapped author subject"; - let mut expected = Buffer::with_lines([format!("{selected_line:<140}"), format!("{footer_text:<140}")]); + let mut expected = Buffer::with_lines([format!("{selected_line:<150}"), format!("{footer_text:<150}")]); for x in 0..11 { expected[(x, 0)].set_style(Style::default().add_modifier(Modifier::REVERSED)); } @@ -780,6 +915,12 @@ mod tests { for x in commit..commit + "o commit".len() { expected[(x as u16, 1)].set_style(Style::default().add_modifier(Modifier::DIM)); } + let email = footer_text[..footer_text.find("e emails").expect("the email toggle is present")] + .chars() + .count(); + for x in email..email + "e emails".len() { + expected[(x as u16, 1)].set_style(Style::default().add_modifier(Modifier::DIM)); + } terminal.backend().assert_buffer(&expected); app.inline = true; @@ -922,6 +1063,7 @@ mod tests { attributions: 0..0, title: format!("subject {n}").into(), metadata_loaded: true, + has_agent_marker: false, signature: SignatureState::Unsigned, }) .collect::>(), @@ -1020,6 +1162,7 @@ mod tests { attributions: 0..0, title: "subject".into(), metadata_loaded: true, + has_agent_marker: false, signature: SignatureState::Unverified, }]); complete(&mut app); @@ -1063,6 +1206,7 @@ mod tests { attributions: 0..0, title: "subject".into(), metadata_loaded: true, + has_agent_marker: false, signature: SignatureState::Unsigned, }]); let mut terminal = Terminal::new(TestBackend::new(120, 6))?; @@ -1221,6 +1365,7 @@ mod tests { attributions: 0..0, title: format!("subject {n}").into(), metadata_loaded: true, + has_agent_marker: false, signature: SignatureState::Unsigned, }) .collect::>(), @@ -1270,6 +1415,7 @@ mod tests { attributions: 0..0, title: "subject".into(), metadata_loaded: true, + has_agent_marker: false, signature: SignatureState::Unsigned, }; let decorations = Decorations::from([( @@ -1311,6 +1457,7 @@ mod tests { MetadataOptions { show_committer_date: true, show_author_name: true, + show_emails: false, show_trailers: true, use_mailmap: false, ref_mode: RefMode::All, @@ -1373,6 +1520,7 @@ mod tests { attributions: 0..0, title: format!("{} subject-tail", "a".repeat(50)).into(), metadata_loaded: true, + has_agent_marker: false, signature: SignatureState::Unsigned, }]); complete(&mut app); diff --git a/gix-tix/tests/fixtures/history.sh b/gix-tix/tests/fixtures/history.sh index e4122e01aaf..b053f6040c4 100755 --- a/gix-tix/tests/fixtures/history.sh +++ b/gix-tix/tests/fixtures/history.sh @@ -25,7 +25,9 @@ echo topic >topic git add topic GIT_AUTHOR_DATE="2000-01-04T00:00:00 +0000" GIT_COMMITTER_DATE="2000-01-04T00:00:00 +0000" \ git commit -q --author="Codex " -m topic \ - -m "Co-authored-by: Human Coauthor + -m "--- agent + +Co-authored-by: Human Coauthor Co-authored-by: Claude Assisted-by: Opus 4.7 rEvIeWeD-bY: Reviewer From 9d29d59903a93423cd3a2e0e2658a5f9a7ab29a7 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 15:40:36 +0200 Subject: [PATCH 007/282] feat: navigate merge ancestry with Shift in tix Consolidates: - fix: scope Shift handling to the active tix screen - feat: focus tix navigation on reachable commits - feat: isolate merged history in Shift navigation - feat: include merge fork points in Shift navigation - fix: defer Shift reachability until graph completion - feat: cycle junction parents during Shift navigation --- gix-tix/src/app.rs | 285 +++++++++++++++++++++++++++++++++++++++++++-- gix-tix/src/lib.rs | 98 ++++++++++++---- gix-tix/src/ui.rs | 111 ++++++++++++++++++ 3 files changed, 462 insertions(+), 32 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index f8145801cb5..8f0f02cac18 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, ops::Range, time::{Duration, Instant}, }; @@ -199,6 +199,9 @@ pub(crate) struct App { pub(crate) show_selection_tail: bool, pub inline: bool, pub preview_author_copy: bool, + reachability_anchor: Option, + junction_parent: Option, + reachable_rows: Option>, pub copy_feedback: Option, pub estimated_lane_width: usize, pub horizontal_offset: usize, @@ -236,6 +239,9 @@ impl App { show_selection_tail: true, inline: false, preview_author_copy: false, + reachability_anchor: None, + junction_parent: None, + reachable_rows: None, copy_feedback: None, estimated_lane_width: 0, horizontal_offset: 0, @@ -280,6 +286,9 @@ impl App { self.selected = Some(self.rows.len() - 1); self.ensure_visible(); } + if self.reachability_anchor.is_some() { + self.compute_reachable_rows(); + } } pub(crate) fn set_metadata( @@ -339,16 +348,20 @@ impl App { pub fn update(&mut self, action: Action) -> Vec { match action { Action::Cancelled if self.state == State::Cancelling => self.state = State::Cancelled, - Action::MoveUp => self.move_selection(1, false), - Action::MoveDown => self.move_selection(1, true), + Action::MoveUp => self.move_reachable(false), + Action::MoveDown => self.move_reachable(true), Action::ScrollLeft => { - self.horizontal_offset = self.horizontal_offset.saturating_sub(self.horizontal_page); + if !self.cycle_junction_parent(false) { + self.horizontal_offset = self.horizontal_offset.saturating_sub(self.horizontal_page); + } } Action::ScrollRight => { - self.horizontal_offset = self - .horizontal_offset - .saturating_add(self.horizontal_page) - .min(self.horizontal_max); + if !self.cycle_junction_parent(true) { + self.horizontal_offset = self + .horizontal_offset + .saturating_add(self.horizontal_page) + .min(self.horizontal_max); + } } Action::HalfPageUp => self.move_selection((self.viewport_rows / 2).max(1), false), Action::HalfPageDown => self.move_selection((self.viewport_rows / 2).max(1), true), @@ -411,7 +424,17 @@ impl App { return vec![Effect::VerifySignatures(ids)]; } } - Action::PreviewAuthorCopy(value) => self.preview_author_copy = value, + Action::PreviewAuthorCopy(value) => { + if value && !self.preview_author_copy { + self.reachability_anchor = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); + self.compute_reachable_rows(); + } else if !value { + self.reachability_anchor = None; + self.junction_parent = None; + self.reachable_rows = None; + } + self.preview_author_copy = value; + } Action::Cancel if self.state == State::Loading => { self.state = State::Cancelling; return vec![Effect::Cancel]; @@ -503,6 +526,9 @@ impl App { self.lane_time = Some(lane_time); self.selected = selected.and_then(|id| self.rows.iter().position(|row| row.id == id)); self.state = State::Complete; + if self.reachability_anchor.is_some() { + self.compute_reachable_rows(); + } self.ensure_visible(); } @@ -521,6 +547,10 @@ impl App { self.show_hidden = show_hidden; self.horizontal_offset = 0; self.follow_tail = false; + self.preview_author_copy = false; + self.reachability_anchor = None; + self.junction_parent = None; + self.reachable_rows = None; self.signature_failures = 0; self.signature_verification_running = false; } @@ -556,6 +586,104 @@ impl App { self.ensure_visible(); } + fn move_reachable(&mut self, down: bool) { + let (Some(selected), Some(reachable)) = (self.selected, self.reachable_rows.as_ref()) else { + self.move_selection(1, down); + return; + }; + let next = if down { + (selected + 1..self.rows.len()).find(|index| reachable.get(*index) == Some(&true)) + } else { + (0..selected).rev().find(|index| reachable.get(*index) == Some(&true)) + }; + if let Some(next) = next { + self.select(next); + } + } + + fn cycle_junction_parent(&mut self, forward: bool) -> bool { + if self.state != State::Complete { + return false; + } + let Some(parent_count) = self + .reachability_anchor + .and_then(|anchor| self.rows.iter().find(|row| row.id == anchor)) + .map(|row| row.parent_ids.len()) + .filter(|count| *count > 1) + else { + return false; + }; + let current = self.junction_parent.unwrap_or(1); + self.junction_parent = Some(if forward { + (current + 1) % parent_count + } else { + (current + parent_count - 1) % parent_count + }); + self.compute_reachable_rows(); + true + } + + fn compute_reachable_rows(&mut self) { + if self.state != State::Complete { + self.reachable_rows = None; + return; + } + let Some(anchor) = self.reachability_anchor else { + self.reachable_rows = None; + return; + }; + let Some(anchor_index) = self.rows.iter().position(|row| row.id == anchor) else { + self.reachable_rows = Some(vec![false; self.rows.len()]); + return; + }; + let parent_count = self.rows[anchor_index].parent_ids.len(); + let start = if parent_count > 1 { + let parent = self.junction_parent.get_or_insert(1); + if *parent >= parent_count { + *parent = 1; + } + self.rows[anchor_index] + .parent_ids + .get(*parent) + .copied() + .expect("the selected junction parent exists") + } else { + self.junction_parent = None; + anchor + }; + let mut pending = HashSet::from([start]); + let mut reachable: Vec<_> = self + .rows + .iter() + .map(|row| { + let reachable = pending.remove(&row.id); + if reachable { + pending.extend(row.parent_ids.iter().copied()); + } + reachable + }) + .collect(); + if start != anchor { + reachable[anchor_index] = true; + } + self.reachable_rows = Some(reachable); + } + + pub(crate) fn junction_parent(&self, index: usize) -> Option { + let row = self.rows.get(index)?; + if self.reachability_anchor == Some(row.id) { + self.junction_parent.map(|parent| parent + 1) + } else { + None + } + } + + pub(crate) fn is_row_reachable(&self, index: usize) -> bool { + self.reachable_rows + .as_ref() + .is_none_or(|reachable| reachable.get(index).copied().unwrap_or(false)) + } + fn select(&mut self, selected: usize) { if !self.rows.is_empty() { let previous = self.selected; @@ -1129,6 +1257,63 @@ mod tests { ); } + #[test] + fn shift_limits_jk_to_the_selected_commits_ancestors() { + let mut app = App::new(5); + app.extend_commits(vec![ + row_with_parents(5, &[3]), + row_with_parents(4, &[2]), + row_with_parents(3, &[1]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + app.selected = app.rows.iter().position(|row| row.id == id(5)); + + app.update(Action::PreviewAuthorCopy(true)); + let reachable: Vec<_> = app + .rows + .iter() + .enumerate() + .filter(|(index, _)| app.is_row_reachable(*index)) + .map(|(_, row)| row.id) + .collect(); + assert_eq!(reachable, [id(5), id(3), id(1)]); + + app.update(Action::MoveDown); + assert_eq!(app.rows[app.selected.expect("an ancestor is selected")].id, id(3)); + app.update(Action::MoveDown); + assert_eq!(app.rows[app.selected.expect("an ancestor is selected")].id, id(1)); + + app.update(Action::PreviewAuthorCopy(false)); + app.update(Action::MoveUp); + assert_eq!(app.rows[app.selected.expect("normal navigation is restored")].id, id(2)); + } + + #[test] + fn shift_defers_reachability_until_the_graph_is_complete() { + let mut app = App::new(4); + app.extend_commits(vec![row_with_parents(4, &[3, 2]), row_with_parents(3, &[1])]); + + app.update(Action::PreviewAuthorCopy(true)); + assert!( + app.reachable_rows.is_none(), + "pressing Shift while traversing does not compute reachability" + ); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + assert!( + app.reachable_rows.is_none(), + "later traversal batches do not recompute reachability" + ); + + complete(&mut app); + assert!( + app.reachable_rows.is_some(), + "graph completion computes reachability once" + ); + assert_eq!(app.junction_parent(0), Some(2)); + } + #[test] fn selection_follows_the_oldest_commit_until_the_user_moves() { let mut app = App::new(2); @@ -1267,9 +1452,11 @@ mod tests { ); complete(&mut app); assert_eq!(app.update(Action::ToggleHidden), vec![Effect::Reload(true)]); + drop(app.update(Action::PreviewAuthorCopy(true))); app.reload(true); assert!(app.rows.is_empty(), "reloading drops rows from the previous view"); assert!(app.show_hidden); + assert!(!app.preview_author_copy, "reloading clears transient Shift state"); assert_eq!(app.state, State::Loading); assert!( app.update(Action::ToggleHidden).is_empty(), @@ -1298,6 +1485,86 @@ mod tests { ); } + #[test] + fn shift_starts_with_a_merges_second_parent_rail() { + let mut app = App::new(7); + app.extend_commits(vec![ + row_with_parents(6, &[5, 4]), + row_with_parents(5, &[3]), + row_with_parents(4, &[2]), + row_with_parents(3, &[1]), + row_with_parents(2, &[1]), + row(1), + ]); + complete(&mut app); + app.selected = app.rows.iter().position(|row| row.id == id(6)); + + app.update(Action::PreviewAuthorCopy(true)); + let reachable: Vec<_> = app + .rows + .iter() + .enumerate() + .filter(|(index, _)| app.is_row_reachable(*index)) + .map(|(_, row)| row.id) + .collect(); + assert_eq!( + reachable, + [id(6), id(4), id(2), id(1)], + "the second parent and its complete ancestry are reachable" + ); + } + + #[test] + fn shift_cycles_junction_parents_without_panning() { + let mut app = App::new(8); + app.extend_commits(vec![ + row_with_parents(10, &[8, 9, 11]), + row_with_parents(8, &[6]), + row_with_parents(9, &[7, 6]), + row_with_parents(11, &[5]), + row_with_parents(7, &[1]), + row_with_parents(6, &[1]), + row_with_parents(5, &[1]), + row(1), + ]); + complete(&mut app); + app.selected = app.rows.iter().position(|row| row.id == id(10)); + app.set_horizontal_bounds(10, 25); + + app.update(Action::PreviewAuthorCopy(true)); + let reachable = |app: &App| { + app.rows + .iter() + .enumerate() + .filter(|(index, _)| app.is_row_reachable(*index)) + .map(|(_, row)| row.id) + .collect::>() + }; + assert_eq!(app.junction_parent(0), Some(2)); + assert_eq!( + reachable(&app), + HashSet::from([id(10), id(9), id(7), id(6), id(1)]), + "the selected rail traverses every parent of its next junction" + ); + + app.update(Action::ScrollRight); + assert_eq!(app.junction_parent(0), Some(3)); + assert_eq!(reachable(&app), HashSet::from([id(10), id(11), id(5), id(1)])); + app.update(Action::ScrollRight); + assert_eq!(app.junction_parent(0), Some(1)); + assert_eq!(reachable(&app), HashSet::from([id(10), id(8), id(6), id(1)])); + app.update(Action::ScrollLeft); + assert_eq!(app.junction_parent(0), Some(3)); + assert_eq!( + app.horizontal_offset, 0, + "junction selection suppresses horizontal panning" + ); + + app.update(Action::PreviewAuthorCopy(false)); + app.update(Action::ScrollRight); + assert_eq!(app.horizontal_offset, 10, "releasing Shift restores horizontal panning"); + } + #[test] fn completion_and_copy_effects_use_the_current_selection() { let mut app = App::new(10); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 7d68316906b..41d70494175 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -23,8 +23,9 @@ use crossterm::{ clipboard::CopyToClipboard, cursor, event::{ - self, Event as TerminalEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, - ModifierKeyCode, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + self, DisableFocusChange, EnableFocusChange, Event as TerminalEvent, KeyCode, KeyEvent, KeyEventKind, + KeyModifiers, KeyboardEnhancementFlags, ModifierKeyCode, PopKeyboardEnhancementFlags, + PushKeyboardEnhancementFlags, }, execute, style::Print, @@ -91,24 +92,20 @@ pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec, opti } .context("could not initialize terminal")?; let enhanced_keyboard = terminal::supports_keyboard_enhancement().unwrap_or(false); - let keyboard_setup = if enhanced_keyboard { - execute!( - terminal.backend_mut(), - PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_EVENT_TYPES - | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES - ) - ) - } else { - Ok(()) - }; + let keyboard_setup = enable_input(terminal.backend_mut(), enhanced_keyboard); let result = keyboard_setup .context("could not enable enhanced keyboard events") - .and_then(|()| event_loop(&mut terminal, repository, revisions, options, inline_height.is_some())); - let keyboard_restore = enhanced_keyboard - .then(|| execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags)) - .transpose(); + .and_then(|()| { + event_loop( + &mut terminal, + repository, + revisions, + options, + inline_height.is_some(), + enhanced_keyboard, + ) + }); + let keyboard_restore = disable_input(terminal.backend_mut(), enhanced_keyboard); let restore = restore_terminal(&mut terminal, inline_height.is_some()); let lane_time = result?; keyboard_restore.context("could not restore keyboard events")?; @@ -119,6 +116,28 @@ pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec, opti Ok(()) } +fn enable_input(backend: &mut CrosstermBackend, enhanced_keyboard: bool) -> std::io::Result<()> { + execute!(backend, EnableFocusChange)?; + if enhanced_keyboard { + execute!( + backend, + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES + ) + )?; + } + Ok(()) +} + +fn disable_input(backend: &mut CrosstermBackend, enhanced_keyboard: bool) -> std::io::Result<()> { + if enhanced_keyboard { + execute!(backend, PopKeyboardEnhancementFlags)?; + } + execute!(backend, DisableFocusChange) +} + fn half_height(terminal_height: u16) -> u16 { (terminal_height / 2).max(1) } @@ -164,18 +183,27 @@ fn restore_terminal(terminal: &mut ratatui::DefaultTerminal, inline: bool) -> Re Ok(()) } -fn enter_alternate_screen(terminal: &mut ratatui::DefaultTerminal) -> std::io::Result { +fn enter_alternate_screen( + terminal: &mut ratatui::DefaultTerminal, + enhanced_keyboard: bool, +) -> std::io::Result { + disable_input(terminal.backend_mut(), enhanced_keyboard)?; let alternate = ratatui::Terminal::new(CrosstermBackend::new(std::io::stdout()))?; execute!(terminal.backend_mut(), EnterAlternateScreen)?; - Ok(std::mem::replace(terminal, alternate)) + let inline = std::mem::replace(terminal, alternate); + enable_input(terminal.backend_mut(), enhanced_keyboard)?; + Ok(inline) } fn leave_alternate_screen( terminal: &mut ratatui::DefaultTerminal, inline: ratatui::DefaultTerminal, + enhanced_keyboard: bool, ) -> std::io::Result<()> { + disable_input(terminal.backend_mut(), enhanced_keyboard)?; drop(std::mem::replace(terminal, inline)); execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + enable_input(terminal.backend_mut(), enhanced_keyboard)?; terminal.hide_cursor() } @@ -201,6 +229,10 @@ fn resize_inline_screen(terminal: &mut ratatui::DefaultTerminal, height: u16) -> terminal.hide_cursor() } +#[expect( + clippy::too_many_arguments, + reason = "screen transitions need the complete terminal state" +)] fn sync_screen( terminal: &mut ratatui::DefaultTerminal, app: &mut App, @@ -209,6 +241,7 @@ fn sync_screen( history_requires_alternate_screen: bool, resize_inline: bool, inline_terminal: &mut Option, + enhanced_keyboard: bool, ) -> Result<()> { let needs_alternate_screen = app.show_commit || history_requires_alternate_screen; if !should_switch_screen(started_inline, needs_alternate_screen, inline_terminal.is_some()) { @@ -220,10 +253,11 @@ fn sync_screen( return Ok(()); } if needs_alternate_screen { - *inline_terminal = Some(enter_alternate_screen(terminal).context("could not enter the alternate screen")?); + *inline_terminal = + Some(enter_alternate_screen(terminal, enhanced_keyboard).context("could not enter the alternate screen")?); app.inline = false; } else if let Some(inline) = inline_terminal.take() { - leave_alternate_screen(terminal, inline).context("could not leave the alternate screen")?; + leave_alternate_screen(terminal, inline, enhanced_keyboard).context("could not leave the alternate screen")?; app.inline = true; let height = inline_height(screen, terminal::size()?.1, app.rows.len()) .expect("an inline history always has an inline height"); @@ -238,6 +272,7 @@ fn event_loop( revisions: Vec, options: Options, started_inline: bool, + enhanced_keyboard: bool, ) -> Result> { let Options { quit_on_finish, @@ -282,6 +317,7 @@ fn event_loop( let mut urgent = false; let mut inline_terminal = None; let mut history_requires_alternate_screen = false; + let mut focused = true; let result: Result> = (|| loop { if let Some(result) = verification_receiver.as_ref().map(mpsc::Receiver::try_recv) { match result { @@ -371,6 +407,7 @@ fn event_loop( history_requires_alternate_screen, resize_inline, &mut inline_terminal, + enhanced_keyboard, )?; let streaming = matches!(app.state, State::Loading | State::Cancelling | State::Computing) || verification_receiver.is_some(); @@ -397,6 +434,17 @@ fn event_loop( }; let key = match terminal_event { TerminalEvent::Key(key) => key, + TerminalEvent::FocusLost => { + focused = false; + drop(app.update(Action::PreviewAuthorCopy(false))); + dirty = true; + urgent = true; + continue; + } + TerminalEvent::FocusGained => { + focused = true; + continue; + } TerminalEvent::Resize(_, _) => { dirty = true; urgent = true; @@ -404,6 +452,9 @@ fn event_loop( } _ => continue, }; + if !focused { + continue; + } let action = action(key); fill_repository.retain = retains_fill_repository(key.kind, action.as_ref()); if !fill_repository.retain { @@ -467,10 +518,11 @@ fn event_loop( history_requires_alternate_screen, false, &mut inline_terminal, + enhanced_keyboard, )?; })(); let restore = inline_terminal - .map(|inline| leave_alternate_screen(terminal, inline)) + .map(|inline| leave_alternate_screen(terminal, inline, enhanced_keyboard)) .transpose(); let outcome = result?; restore.context("could not restore the inline terminal")?; diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 2c16d4d40d5..4cced3a465d 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -190,6 +190,32 @@ pub(crate) fn draw( visible_rows[index].signature, ); } + let lane_offset = if align_metadata { + graph_offset + } else { + horizontal_offset + }; + if let (Some(parent), Some(disk)) = ( + app.junction_parent(start + index), + lane.chars().position(|symbol| symbol == '●'), + ) && disk >= lane_offset + { + let number = parent.to_string(); + let x = disk + 1 - lane_offset; + if x < row_area.width as usize { + let width = number + .chars() + .count() + .min(lane.chars().count().saturating_sub(disk + 1)) + .min(row_area.width as usize - x); + if width > 0 { + frame.render_widget( + Paragraph::new(number).style(style), + Rect::new(row_area.x + x as u16, y, width as u16, 1), + ); + } + } + } if selected && app.show_selection_tail && body.width > 0 { let line_width = if align_metadata { align_width.saturating_add(metadata_width.saturating_sub(metadata_offset)) @@ -206,6 +232,11 @@ pub(crate) fn draw( .min(body.right().saturating_sub(1)); frame.buffer_mut()[(marker_x, y)].set_style(style); } + if !app.is_row_reachable(start + index) { + for x in body.x..body.right() { + frame.buffer_mut()[(x, y)].set_style(Style::default().add_modifier(Modifier::DIM)); + } + } } app.set_horizontal_bounds(content.width as usize, max_offset); if let (Some(area), Some(message)) = (commit_pane, commit_message) { @@ -1404,6 +1435,86 @@ mod tests { Ok(()) } + #[test] + fn dims_rows_outside_the_shift_reachability_set() -> Result<(), Box> { + let mut app = App::new(2); + app.extend_commits( + (1..=2) + .map(|n| Commit { + id: gix::ObjectId::Sha1([n; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: format!("subject {n}").into(), + metadata_loaded: true, + signature: SignatureState::Unsigned, + }) + .collect::>(), + ); + complete(&mut app); + app.update(Action::PreviewAuthorCopy(true)); + let mut terminal = Terminal::new(TestBackend::new(80, 3))?; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + + assert!( + !terminal.backend().buffer()[(10, 0)].modifier.contains(Modifier::DIM), + "the anchor row remains bright" + ); + assert!( + terminal.backend().buffer()[(10, 1)].modifier.contains(Modifier::DIM), + "an unreachable row is dimmed" + ); + Ok(()) + } + + #[test] + fn shows_the_selected_parent_beside_the_junction_disk() -> Result<(), Box> { + let commit = |n: u8, parents: &[u8]| Commit { + id: gix::ObjectId::Sha1([n; 20]), + parent_ids: parents + .iter() + .map(|parent| gix::ObjectId::Sha1([*parent; 20])) + .collect(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: format!("subject {n}").into(), + metadata_loaded: true, + signature: SignatureState::Unsigned, + }; + let mut app = App::new(4); + app.extend_commits(vec![ + commit(4, &[3, 2]), + commit(3, &[1]), + commit(2, &[1]), + commit(1, &[]), + ]); + complete(&mut app); + app.update(Action::PreviewAuthorCopy(true)); + let mut terminal = Terminal::new(TestBackend::new(80, 5))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let metadata_x = rendered_row(&terminal) + .find("0404040") + .expect("the junction metadata is visible"); + assert_eq!( + terminal.backend().buffer()[(3, 0)].symbol(), + "2", + "the initial parent number replaces the connector beside the disk" + ); + + app.update(Action::ScrollRight); + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!(terminal.backend().buffer()[(3, 0)].symbol(), "1"); + assert_eq!( + rendered_row(&terminal).find("0404040"), + Some(metadata_x), + "cycling parents does not shift metadata" + ); + Ok(()) + } + #[test] fn uses_the_tig_palette_without_coloring_the_selection() -> Result<(), Box> { let id = gix::ObjectId::Sha1([1; 20]); From 4219b1eefc2aaae0bba9994afacac1b37f6e4b58 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 18:51:49 +0200 Subject: [PATCH 008/282] feat: clarify filtered history in tix Retain the selected commit when toggling hidden history, falling back to the top only when the commit is no longer present. Hide references together with ancestry so filtered rows remain visually aligned. Retain excluded parents directly connected to visible commits as dimmed, terminal-colored boundary rows that show where graph lanes terminate without restoring full hidden ancestry. Keep these rows outside selection, paging, Shift navigation, signature verification, and selection restoration. --- gix-tix/src/app.rs | 145 +++++++++++++++++++++++++++++++++++++---- gix-tix/src/history.rs | 61 ++++++++++++++++- gix-tix/src/lib.rs | 8 ++- gix-tix/src/ui.rs | 62 +++++++++++++++++- 4 files changed, 259 insertions(+), 17 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 8f0f02cac18..5878930df15 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -176,6 +176,7 @@ pub(crate) enum Effect { #[derive(Debug)] pub(crate) struct App { pub rows: Vec, + hidden_rows: HashSet, titles: Vec, graph: Option, attributions: Vec, @@ -208,6 +209,7 @@ pub(crate) struct App { horizontal_page: usize, horizontal_max: usize, follow_tail: bool, + reload_selection: Option, pub(crate) signature_failures: usize, signature_verification_running: bool, } @@ -216,6 +218,7 @@ impl App { pub fn new(viewport_rows: usize) -> Self { App { rows: Vec::new(), + hidden_rows: HashSet::new(), titles: Vec::new(), graph: None, attributions: Vec::new(), @@ -248,11 +251,19 @@ impl App { horizontal_page: 1, horizontal_max: 0, follow_tail: false, + reload_selection: None, signature_failures: 0, signature_verification_running: false, } } + pub(crate) fn configure_hidden_filter(&mut self, present: bool) { + self.has_hidden_filter = present; + if present { + self.ref_mode = RefMode::None; + } + } + pub(crate) fn extend_commits(&mut self, commits: impl Into) { let LoadedCommits { rows, attributions } = commits.into(); if self.state != State::Loading || rows.is_empty() { @@ -280,10 +291,20 @@ impl App { } if was_empty { self.estimated_lane_width = estimate_lane_width(&self.rows[..self.viewport_rows.min(self.rows.len())]); - self.selected = Some(0); + self.selected = self.first_selectable(); self.ensure_visible(); } else if self.follow_tail { - self.selected = Some(self.rows.len() - 1); + self.selected = self.last_selectable(); + self.ensure_visible(); + } + if let Some(index) = self + .reload_selection + .and_then(|id| self.rows.iter().position(|row| row.id == id)) + { + if !self.is_row_hidden(index) { + self.selected = Some(index); + } + self.reload_selection = None; self.ensure_visible(); } if self.reachability_anchor.is_some() { @@ -291,6 +312,18 @@ impl App { } } + pub(crate) fn extend_hidden_commits(&mut self, commits: impl Into) { + let commits = commits.into(); + self.hidden_rows.extend(commits.rows.iter().map(|row| row.id)); + self.extend_commits(commits); + } + + pub(crate) fn is_row_hidden(&self, index: usize) -> bool { + self.rows + .get(index) + .is_some_and(|row| self.hidden_rows.contains(&row.id)) + } + pub(crate) fn set_metadata( &mut self, index: usize, @@ -367,10 +400,14 @@ impl App { Action::HalfPageDown => self.move_selection((self.viewport_rows / 2).max(1), true), Action::PageUp => self.move_selection(self.viewport_rows.max(1), false), Action::PageDown => self.move_selection(self.viewport_rows.max(1), true), - Action::First => self.select(0), - Action::Last if !self.rows.is_empty() => { + Action::First => { + if let Some(index) = self.first_selectable() { + self.select(index); + } + } + Action::Last if self.last_selectable().is_some() => { let previous = self.selected; - self.selected = Some(self.rows.len() - 1); + self.selected = self.last_selectable(); if self.selected != previous { self.retry_failed_signatures(); } @@ -413,7 +450,7 @@ impl App { let end = start.saturating_add(self.viewport_rows).min(self.rows.len()); let ids: Vec<_> = self.rows[start..end] .iter_mut() - .filter(|row| row.signature == SignatureState::Unverified) + .filter(|row| !self.hidden_rows.contains(&row.id) && row.signature == SignatureState::Unverified) .map(|row| { row.signature = SignatureState::Verifying; row.id @@ -473,6 +510,7 @@ impl App { State::Loading => { self.state = State::Computing; self.follow_tail = false; + self.reload_selection = None; Some(self.rows.clone()) } State::Cancelling => { @@ -533,7 +571,9 @@ impl App { } pub(crate) fn reload(&mut self, show_hidden: bool) { + self.reload_selection = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); self.rows = Vec::new(); + self.hidden_rows.clear(); self.titles = Vec::new(); self.graph = None; self.attributions = Vec::new(); @@ -574,11 +614,12 @@ impl App { fn move_selection(&mut self, distance: usize, down: bool) { let Some(selected) = self.selected else { return }; - self.selected = Some(if down { + let target = if down { selected.saturating_add(distance).min(self.rows.len() - 1) } else { selected.saturating_sub(distance) - }); + }; + self.selected = self.nearest_selectable(target, down); if self.selected != Some(selected) { self.retry_failed_signatures(); } @@ -592,9 +633,12 @@ impl App { return; }; let next = if down { - (selected + 1..self.rows.len()).find(|index| reachable.get(*index) == Some(&true)) + (selected + 1..self.rows.len()) + .find(|index| !self.is_row_hidden(*index) && reachable.get(*index) == Some(&true)) } else { - (0..selected).rev().find(|index| reachable.get(*index) == Some(&true)) + (0..selected) + .rev() + .find(|index| !self.is_row_hidden(*index) && reachable.get(*index) == Some(&true)) }; if let Some(next) = next { self.select(next); @@ -685,7 +729,7 @@ impl App { } fn select(&mut self, selected: usize) { - if !self.rows.is_empty() { + if !self.rows.is_empty() && !self.is_row_hidden(selected) { let previous = self.selected; self.selected = Some(selected.min(self.rows.len() - 1)); if self.selected != previous { @@ -696,6 +740,27 @@ impl App { } } + fn first_selectable(&self) -> Option { + (0..self.rows.len()).find(|index| !self.is_row_hidden(*index)) + } + + fn last_selectable(&self) -> Option { + (0..self.rows.len()).rev().find(|index| !self.is_row_hidden(*index)) + } + + fn nearest_selectable(&self, target: usize, down: bool) -> Option { + if down { + (target..self.rows.len()) + .find(|index| !self.is_row_hidden(*index)) + .or_else(|| (0..target).rev().find(|index| !self.is_row_hidden(*index))) + } else { + (0..=target) + .rev() + .find(|index| !self.is_row_hidden(*index)) + .or_else(|| (target + 1..self.rows.len()).find(|index| !self.is_row_hidden(*index))) + } + } + fn retry_failed_signatures(&mut self) { for row in &mut self.rows { if row.signature == SignatureState::Failed { @@ -1348,6 +1413,30 @@ mod tests { assert_eq!(app.offset, 0, "the newest commit is visible"); } + #[test] + fn hidden_boundary_rows_are_not_selectable_or_verifiable() { + let mut app = App::new(4); + app.extend_commits(vec![row(1), row(2), row(3)]); + app.update(Action::Last); + app.extend_hidden_commits(vec![row(4)]); + app.rows[3].signature = SignatureState::Unverified; + + assert_eq!( + app.selected, + Some(2), + "following the tail stops at the oldest visible commit" + ); + app.update(Action::MoveDown); + assert_eq!(app.selected, Some(2), "j cannot enter the hidden boundary"); + app.update(Action::First); + app.update(Action::PageDown); + assert_eq!(app.selected, Some(2), "paging skips the hidden boundary"); + assert!( + app.update(Action::VerifySignatures).is_empty(), + "hidden signatures are not actionable" + ); + } + #[test] fn half_pages_use_half_the_viewport() { let mut app = App::new(4); @@ -1444,7 +1533,12 @@ mod tests { "the key is inert without hidden revisions" ); - app.has_hidden_filter = true; + app.configure_hidden_filter(true); + assert_eq!( + app.ref_mode, + RefMode::None, + "hidden ancestry hides references by default" + ); app.extend_commits(vec![row(1)]); assert!( app.update(Action::ToggleHidden).is_empty(), @@ -1466,6 +1560,33 @@ mod tests { assert_eq!(app.update(Action::ToggleHidden), vec![Effect::Reload(false)]); } + #[test] + fn reload_retains_selection_or_falls_back_to_the_top() { + let mut app = App::new(3); + app.extend_commits(vec![row(1), row(2), row(3)]); + complete(&mut app); + app.update(Action::MoveDown); + let selected = app.rows[app.selected.expect("a row is selected")].id; + + app.reload(true); + app.extend_commits(vec![row(1), row(2), row(3)]); + complete(&mut app); + assert_eq!( + app.rows[app.selected.expect("the old row remains selected")].id, + selected + ); + + app.reload(false); + app.extend_commits(vec![row(3)]); + app.extend_hidden_commits(vec![row(2)]); + complete(&mut app); + assert_eq!( + app.selected, + Some(0), + "a selection which becomes a hidden boundary falls back to the top row" + ); + } + #[test] fn cancellation_preserves_rows_and_ignores_late_worker_events() { let mut app = App::new(10); diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index 0e16dda33f2..b052d6c0546 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -47,6 +47,7 @@ const COMMIT_BATCH_SIZE: usize = 1024; pub(crate) enum Event { Decorations(Decorations), Commits(LoadedCommits), + HiddenCommits(LoadedCommits), Complete, Cancelled, } @@ -77,6 +78,9 @@ pub(crate) fn load( .context("could not start revision walk")?; let mut rows = Vec::with_capacity(COMMIT_BATCH_SIZE); let mut attributions = Vec::with_capacity(COMMIT_BATCH_SIZE); + let mut visible = HashSet::new(); + let mut connected = Vec::new(); + let mut seen_parents = HashSet::new(); for info in walk { if cancelled.load(Ordering::Relaxed) { emit(Event::Cancelled); @@ -106,6 +110,13 @@ pub(crate) fn load( has_agent_marker: false, signature: SignatureState::Unsigned, }); + visible.insert(info.id); + connected.extend( + info.parent_ids + .iter() + .copied() + .filter(|parent| seen_parents.insert(*parent)), + ); rows.push(Commit { id: info.id, parent_ids: info.parent_ids, @@ -129,6 +140,42 @@ pub(crate) fn load( if !rows.is_empty() && !emit(Event::Commits(LoadedCommits { rows, attributions })) { return Ok(()); } + if !hidden_revisions.is_empty() { + connected.retain(|id| !visible.contains(id)); + let mut rows = Vec::with_capacity(connected.len()); + let mut attributions = Vec::new(); + let mut authors = gix::features::threading::lock(authors); + for id in connected { + if cancelled.load(Ordering::Relaxed) { + emit(Event::Cancelled); + return Ok(()); + } + let object = repo.find_commit(id).context("could not read connected hidden commit")?; + let parent_ids = object.parent_ids().map(gix::Id::detach).collect(); + let Metadata { + committer_time, + author, + attributions: row_attributions, + title, + has_agent_marker, + signature, + } = decode_metadata(object.iter(), &mut authors, &mut attributions)?; + rows.push(Commit { + id, + parent_ids, + committer_time, + author, + attributions: row_attributions, + title, + metadata_loaded: true, + has_agent_marker, + signature, + }); + } + if !rows.is_empty() && !emit(Event::HiddenCommits(LoadedCommits { rows, attributions })) { + return Ok(()); + } + } emit(Event::Complete); Ok(()) } @@ -542,7 +589,19 @@ mod tests { ); let expected = String::from_utf8(output.stdout)?.lines().map(str::to_owned).collect(); assert_eq!(actual, expected, "hidden tips use Git's exclusion semantics"); - let repo = gix::open(&fixture)?; + let repo = crate::open_test_repository(&fixture)?; + let connected: Vec<_> = events + .iter() + .flat_map(|event| match event { + Event::HiddenCommits(batch) => batch.rows.iter().map(|row| row.id).collect(), + _ => Vec::new(), + }) + .collect(); + assert_eq!( + connected, + [repo.rev_parse_single("topic^")?.detach()], + "only the excluded parent directly connected to visible history is retained" + ); let revisions = [OsString::from("topic")]; let hidden = [OsString::from("main")]; assert_eq!( diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 41d70494175..ee6f0a8951a 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -301,7 +301,7 @@ fn event_loop( retain: false, }; app.inline = started_inline; - app.has_hidden_filter = !hide.is_empty(); + app.configure_hidden_filter(!hide.is_empty()); let mut decorations = Decorations::new(); draw( terminal, @@ -388,6 +388,12 @@ fn event_loop( history_requires_alternate_screen = true; } } + Event::HiddenCommits(rows) => { + app.extend_hidden_commits(rows); + if history_needs_alternate_screen(screen, terminal::size()?.1, app.rows.len()) { + history_requires_alternate_screen = true; + } + } Event::Complete => { resize_inline = true; history_requires_alternate_screen = diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 4cced3a465d..be73243c8b6 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -44,9 +44,10 @@ pub(crate) fn draw( let start = app.offset.min(app.rows.len()); let end = start.saturating_add(app.viewport_rows).min(app.rows.len()); let visible_rows = &app.rows[start..end]; - let has_verifiable_signatures = visible_rows - .iter() - .any(|row| matches!(row.signature, SignatureState::Unverified | SignatureState::Verifying)); + let has_verifiable_signatures = visible_rows.iter().enumerate().any(|(index, row)| { + !app.is_row_hidden(start + index) + && matches!(row.signature, SignatureState::Unverified | SignatureState::Verifying) + }); let lanes = app.render_lanes(start..end); let content = Rect::new( body.x.saturating_add(2), @@ -237,6 +238,14 @@ pub(crate) fn draw( frame.buffer_mut()[(x, y)].set_style(Style::default().add_modifier(Modifier::DIM)); } } + if app.is_row_hidden(start + index) { + for x in body.x..body.right() { + frame.buffer_mut()[(x, y)] + .set_fg(Color::Reset) + .set_bg(Color::Reset) + .set_style(Style::default().add_modifier(Modifier::DIM)); + } + } } app.set_horizontal_bounds(content.width as usize, max_offset); if let (Some(area), Some(message)) = (commit_pane, commit_message) { @@ -1468,6 +1477,53 @@ mod tests { Ok(()) } + #[test] + fn renders_hidden_boundary_rows_without_colors() -> Result<(), Box> { + let commit = |n: u8| Commit { + id: gix::ObjectId::Sha1([n; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: format!("subject {n}").into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unverified, + }; + let mut app = App::new(2); + app.extend_commits(vec![commit(1)]); + app.extend_hidden_commits(vec![commit(2)]); + complete(&mut app); + app.set_lane(0, "● "); + app.set_lane(1, "● "); + let mut terminal = Terminal::new(TestBackend::new(80, 3))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + + let line = rendered_line(&terminal, 1); + assert!(line.contains("subject 2"), "the hidden commit keeps its normal content"); + let visible = rendered_line(&terminal, 0); + let visible_hash = visible.find("0101010").expect("the visible hash is present") as u16; + assert_ne!(terminal.backend().buffer()[(visible_hash, 0)].fg, Color::Reset); + let hash = line.find("0202020").expect("the hidden hash is visible") as u16; + assert!( + terminal.backend().buffer()[(hash, 1)].modifier.contains(Modifier::BOLD), + "non-color styling is retained" + ); + for x in 0..terminal.backend().buffer().area.width { + let cell = &terminal.backend().buffer()[(x, 1)]; + assert_eq!(cell.fg, Color::Reset, "the hidden row has no foreground colors"); + assert_eq!(cell.bg, Color::Reset, "the hidden row has no background colors"); + assert!(cell.modifier.contains(Modifier::DIM), "the hidden row is dimmed"); + } + assert_ne!( + terminal.backend().buffer()[(0, 1)].symbol(), + ">", + "the hidden row is not selected" + ); + Ok(()) + } + #[test] fn shows_the_selected_parent_beside_the_junction_disk() -> Result<(), Box> { let commit = |n: u8, parents: &[u8]| Commit { From 3e82fba25854ba6bc0ce0fd1de125ae52aef212f Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 15:19:04 +0200 Subject: [PATCH 009/282] feat: inspect tree changes in tix Add a default-open, focusable bottom panel for changed paths in the selected commit. Preserve diff order, summarize color-coded change kinds and line counts, support merge-parent cycling and path navigation, cap the panel at half the screen, and hide it during repeated history navigation. Distinguish inactive panels and history, expose focus feedback, and return with q/Escape. Compute line statistics in a temporary available-parallelism worker pool and use a short-lived cached repository for tree changes. Highlight compared parents, reuse computed line counts, and open selected file diffs through the built-in viewer or Git-compatible external diff and core.pager pipeline, preserving output from immediately closing pagers. Keep aligned metadata stable while panning and resizing panels. Avoid letting the default changes view force inline startup into the alternate screen, and delay empty loading frames to prevent scrollback residue and startup flashes. On exit, retain only the left selection marker in static frames, including when leaving an alternate screen, and let Ctrl-C terminate from any focus. --- gix-tix/Cargo.toml | 2 +- gix-tix/src/app.rs | 331 +++++++- gix-tix/src/lib.rs | 1172 +++++++++++++++++++++++++++-- gix-tix/src/ui.rs | 856 +++++++++++++++++++-- gix-tix/tests/fixtures/history.sh | 4 +- 5 files changed, 2241 insertions(+), 124 deletions(-) diff --git a/gix-tix/Cargo.toml b/gix-tix/Cargo.toml index 7386d02f175..595ca6c70d3 100644 --- a/gix-tix/Cargo.toml +++ b/gix-tix/Cargo.toml @@ -25,7 +25,7 @@ sha256 = ["gix/sha256"] [dependencies] anyhow = "1.0.98" crossterm = { version = "0.29.0", features = ["osc52"] } -gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["mailmap", "parallel", "revision", "command"] } +gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["blob-diff", "mailmap", "parallel", "revision", "command"] } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } [dev-dependencies] diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 5878930df15..d5f6bfebbbd 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -43,6 +43,59 @@ pub(crate) enum SignatureState { Failed, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ChangeKind { + Added, + Modified, + Deleted, + Renamed, + Copied, + TypeChanged, +} + +impl ChangeKind { + pub(crate) fn letter(self) -> char { + match self { + ChangeKind::Added => 'A', + ChangeKind::Modified => 'M', + ChangeKind::Deleted => 'D', + ChangeKind::Renamed => 'R', + ChangeKind::Copied => 'C', + ChangeKind::TypeChanged => 'T', + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PathChange { + pub kind: ChangeKind, + pub source: Option, + pub path: BString, + pub lines: Option<(u32, u32)>, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Changes { + pub parent: Option, + pub paths: Vec, + pub diffs: Vec, + pub lines_added: u64, + pub lines_removed: u64, +} + +impl Changes { + pub(crate) fn is_visible(&self) -> bool { + self.parent.is_some() || !self.paths.is_empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ComparedParent { + pub index: usize, + pub total: usize, + pub id: ObjectId, +} + #[derive(Debug, Eq, Hash, PartialEq)] pub(crate) struct Author { pub name: &'static BStr, @@ -155,11 +208,16 @@ pub(crate) enum Action { ToggleHidden, ToggleAlign, ToggleCommit, + ToggleChanges, + ToggleChangesFocus, + CycleChangesParent, + OpenDiff, VerifySignatures, Cancel, Copy, CopyAuthor, PreviewAuthorCopy(bool), + ForceQuit, Quit, } @@ -169,6 +227,7 @@ pub(crate) enum Effect { CopyId(ObjectId), CopyAuthor(&'static Author), Reload(bool), + OpenDiff(usize), VerifySignatures(Vec), Quit, } @@ -197,6 +256,18 @@ pub(crate) struct App { pub show_hidden: bool, pub align_metadata: bool, pub show_commit: bool, + pub show_changes: bool, + pub(crate) changes_suppressed: bool, + pub(crate) changes_focused: bool, + pub(crate) changes_selected: usize, + pub(crate) changes_offset: usize, + pub(crate) changes_horizontal_offset: usize, + pub(crate) changes_parent: usize, + pub(crate) diff_error: Option, + changes_page: usize, + changes_max: usize, + changes_horizontal_page: usize, + changes_horizontal_max: usize, pub(crate) show_selection_tail: bool, pub inline: bool, pub preview_author_copy: bool, @@ -204,6 +275,7 @@ pub(crate) struct App { junction_parent: Option, reachable_rows: Option>, pub copy_feedback: Option, + pub(crate) focus_feedback: Option<&'static str>, pub estimated_lane_width: usize, pub horizontal_offset: usize, horizontal_page: usize, @@ -239,6 +311,18 @@ impl App { show_hidden: false, align_metadata: true, show_commit: false, + show_changes: true, + changes_suppressed: false, + changes_focused: false, + changes_selected: 0, + changes_offset: 0, + changes_horizontal_offset: 0, + changes_parent: 0, + diff_error: None, + changes_page: 1, + changes_max: 0, + changes_horizontal_page: 1, + changes_horizontal_max: 0, show_selection_tail: true, inline: false, preview_author_copy: false, @@ -246,6 +330,7 @@ impl App { junction_parent: None, reachable_rows: None, copy_feedback: None, + focus_feedback: None, estimated_lane_width: 0, horizontal_offset: 0, horizontal_page: 1, @@ -381,30 +466,50 @@ impl App { pub fn update(&mut self, action: Action) -> Vec { match action { Action::Cancelled if self.state == State::Cancelling => self.state = State::Cancelled, + Action::MoveUp if self.changes_focused => self.move_changes(1, false), + Action::MoveDown if self.changes_focused => self.move_changes(1, true), Action::MoveUp => self.move_reachable(false), Action::MoveDown => self.move_reachable(true), Action::ScrollLeft => { - if !self.cycle_junction_parent(false) { + if self.changes_focused { + self.pan_changes(false); + } else if !self.cycle_junction_parent(false) { self.horizontal_offset = self.horizontal_offset.saturating_sub(self.horizontal_page); } } Action::ScrollRight => { - if !self.cycle_junction_parent(true) { + if self.changes_focused { + self.pan_changes(true); + } else if !self.cycle_junction_parent(true) { self.horizontal_offset = self .horizontal_offset .saturating_add(self.horizontal_page) .min(self.horizontal_max); } } + Action::HalfPageUp if self.changes_focused => self.move_changes((self.changes_page / 2).max(1), false), + Action::HalfPageDown if self.changes_focused => self.move_changes((self.changes_page / 2).max(1), true), + Action::PageUp if self.changes_focused => self.move_changes(self.changes_page, false), + Action::PageDown if self.changes_focused => self.move_changes(self.changes_page, true), Action::HalfPageUp => self.move_selection((self.viewport_rows / 2).max(1), false), Action::HalfPageDown => self.move_selection((self.viewport_rows / 2).max(1), true), Action::PageUp => self.move_selection(self.viewport_rows.max(1), false), Action::PageDown => self.move_selection(self.viewport_rows.max(1), true), + Action::First if self.changes_focused => { + self.changes_selected = 0; + self.diff_error = None; + self.ensure_changes_visible(); + } Action::First => { if let Some(index) = self.first_selectable() { self.select(index); } } + Action::Last if self.changes_focused => { + self.changes_selected = self.changes_max; + self.diff_error = None; + self.ensure_changes_visible(); + } Action::Last if self.last_selectable().is_some() => { let previous = self.selected; self.selected = self.last_selectable(); @@ -445,6 +550,32 @@ impl App { } Action::ToggleAlign => self.align_metadata = !self.align_metadata, Action::ToggleCommit => self.show_commit = !self.show_commit, + Action::ToggleChanges => { + self.focus_feedback = None; + self.show_changes = !self.show_changes; + if !self.show_changes { + self.changes_suppressed = false; + self.changes_focused = false; + self.reset_changes_view(); + } + } + Action::ToggleChangesFocus if self.show_changes => { + self.changes_focused = !self.changes_focused; + if self.changes_focused { + self.clear_preview_author_copy(); + } + self.focus_feedback = Some(if self.changes_focused { "changes" } else { "history" }); + } + Action::CycleChangesParent => { + if self.show_changes { + self.changes_parent = self.changes_parent.saturating_add(1); + self.diff_error = None; + } + } + Action::OpenDiff if self.changes_focused => { + self.diff_error = None; + return vec![Effect::OpenDiff(self.changes_selected)]; + } Action::VerifySignatures if !self.signature_verification_running => { let start = self.offset.min(self.rows.len()); let end = start.saturating_add(self.viewport_rows).min(self.rows.len()); @@ -461,14 +592,15 @@ impl App { return vec![Effect::VerifySignatures(ids)]; } } + Action::ForceQuit => return vec![Effect::Quit], + Action::Cancel | Action::Quit if self.changes_focused => self.focus_history(), + Action::PreviewAuthorCopy(_) if self.changes_focused => {} Action::PreviewAuthorCopy(value) => { if value && !self.preview_author_copy { self.reachability_anchor = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); self.compute_reachable_rows(); } else if !value { - self.reachability_anchor = None; - self.junction_parent = None; - self.reachable_rows = None; + self.clear_preview_author_copy(); } self.preview_author_copy = value; } @@ -585,12 +717,12 @@ impl App { self.lane_time = None; self.estimated_lane_width = 0; self.show_hidden = show_hidden; + self.changes_suppressed = false; self.horizontal_offset = 0; + self.focus_history(); + self.reset_changes_view(); self.follow_tail = false; - self.preview_author_copy = false; - self.reachability_anchor = None; - self.junction_parent = None; - self.reachable_rows = None; + self.clear_preview_author_copy(); self.signature_failures = 0; self.signature_verification_running = false; } @@ -612,6 +744,54 @@ impl App { self.signature_failures = failed; } + fn move_changes(&mut self, distance: usize, down: bool) { + self.diff_error = None; + self.changes_selected = if down { + self.changes_selected.saturating_add(distance).min(self.changes_max) + } else { + self.changes_selected.saturating_sub(distance) + }; + self.ensure_changes_visible(); + } + + fn clear_preview_author_copy(&mut self) { + self.preview_author_copy = false; + self.reachability_anchor = None; + self.junction_parent = None; + self.reachable_rows = None; + } + + pub(crate) fn focus_history(&mut self) { + self.changes_focused = false; + self.focus_feedback = None; + } + + pub(crate) fn changes_visible(&self) -> bool { + self.show_changes && !self.changes_suppressed + } + + fn ensure_changes_visible(&mut self) { + if self.changes_selected < self.changes_offset { + self.changes_offset = self.changes_selected; + } else if self.changes_selected >= self.changes_offset.saturating_add(self.changes_page) { + self.changes_offset = self.changes_selected + 1 - self.changes_page; + } + self.changes_offset = self + .changes_offset + .min(self.changes_max.saturating_add(1).saturating_sub(self.changes_page)); + } + + fn pan_changes(&mut self, right: bool) { + self.changes_horizontal_offset = if right { + self.changes_horizontal_offset + .saturating_add(self.changes_horizontal_page) + .min(self.changes_horizontal_max) + } else { + self.changes_horizontal_offset + .saturating_sub(self.changes_horizontal_page) + }; + } + fn move_selection(&mut self, distance: usize, down: bool) { let Some(selected) = self.selected else { return }; let target = if down { @@ -786,6 +966,34 @@ impl App { self.horizontal_offset = self.horizontal_offset.min(max); } + pub(crate) fn set_changes_bounds( + &mut self, + page: usize, + len: usize, + horizontal_page: usize, + horizontal_max: usize, + ) { + self.changes_page = page.max(1); + self.changes_max = len.saturating_sub(1); + if len == 0 { + self.changes_selected = 0; + self.changes_offset = 0; + } else { + self.changes_selected = self.changes_selected.min(self.changes_max); + self.ensure_changes_visible(); + } + self.changes_horizontal_page = horizontal_page.max(1); + self.changes_horizontal_max = horizontal_max; + self.changes_horizontal_offset = self.changes_horizontal_offset.min(horizontal_max); + } + + pub(crate) fn reset_changes_view(&mut self) { + self.diff_error = None; + self.changes_selected = 0; + self.changes_offset = 0; + self.changes_horizontal_offset = 0; + } + #[cfg(test)] pub(crate) fn set_lane(&mut self, index: usize, lane: &str) { self.test_lanes.resize(self.rows.len(), String::new()); @@ -1355,6 +1563,28 @@ mod tests { assert_eq!(app.rows[app.selected.expect("normal navigation is restored")].id, id(2)); } + #[test] + fn changes_focus_clears_and_ignores_shift() { + let mut app = App::new(2); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + + app.update(Action::PreviewAuthorCopy(true)); + assert!(app.preview_author_copy && app.reachable_rows.is_some()); + + app.update(Action::ToggleChangesFocus); + assert!( + app.changes_focused && !app.preview_author_copy && app.reachable_rows.is_none(), + "entering the changes pane clears transient history navigation" + ); + + app.update(Action::PreviewAuthorCopy(true)); + assert!( + !app.preview_author_copy && app.reachable_rows.is_none(), + "the inactive history pane ignores Shift" + ); + } + #[test] fn shift_defers_reachability_until_the_graph_is_complete() { let mut app = App::new(4); @@ -1465,10 +1695,57 @@ mod tests { assert_eq!(app.horizontal_offset, 0, "scrolling is disabled when content fits"); } + #[test] + fn focused_changes_redirect_navigation_to_the_path_viewport() { + let mut app = App::new(2); + app.extend_commits((1..=3).map(row).collect::>()); + app.set_changes_bounds(4, 10, 20, 45); + app.update(Action::ToggleChangesFocus); + assert!(app.changes_focused); + assert_eq!(app.focus_feedback.take(), Some("changes")); + app.update(Action::ToggleChangesFocus); + assert!(!app.changes_focused); + assert_eq!(app.focus_feedback.take(), Some("history")); + app.update(Action::ToggleChangesFocus); + + app.update(Action::MoveDown); + assert_eq!((app.changes_selected, app.changes_offset), (1, 0)); + assert_eq!(app.update(Action::OpenDiff), vec![Effect::OpenDiff(1)]); + assert_eq!( + app.selected, + Some(0), + "path selection leaves commit selection untouched" + ); + app.update(Action::PageDown); + assert_eq!((app.changes_selected, app.changes_offset), (5, 2)); + app.update(Action::HalfPageDown); + assert_eq!((app.changes_selected, app.changes_offset), (7, 4)); + app.update(Action::Last); + assert_eq!((app.changes_selected, app.changes_offset), (9, 6)); + app.update(Action::First); + assert_eq!((app.changes_selected, app.changes_offset), (0, 0)); + + app.update(Action::ScrollRight); + app.update(Action::ScrollRight); + app.update(Action::ScrollRight); + assert_eq!(app.changes_horizontal_offset, 45); + assert_eq!(app.horizontal_offset, 0, "path panning leaves the graph untouched"); + app.update(Action::ScrollLeft); + assert_eq!(app.changes_horizontal_offset, 25); + + app.update(Action::ToggleChanges); + assert!(!app.changes_focused, "closing the panel returns focus to history"); + assert!(app.update(Action::OpenDiff).is_empty()); + assert_eq!(app.changes_selected, 0); + assert_eq!(app.changes_offset, 0); + assert_eq!(app.changes_horizontal_offset, 0); + } + #[test] fn toggles_metadata_columns() { let mut app = App::new(1); assert!(app.show_trailers, "trailer attribution is visible by default"); + assert!(app.show_changes, "changed paths are visible by default"); app.update(Action::ToggleDate); app.update(Action::ToggleEmail); @@ -1478,6 +1755,8 @@ mod tests { app.update(Action::ToggleRefs); app.update(Action::ToggleAlign); app.update(Action::ToggleCommit); + app.update(Action::CycleChangesParent); + app.update(Action::ToggleChanges); assert!(!app.show_committer_date); assert!(app.show_emails); @@ -1491,6 +1770,8 @@ mod tests { assert_eq!(app.ref_mode, RefMode::Default); assert!(!app.align_metadata); assert!(app.show_commit); + assert!(!app.show_changes); + assert_eq!(app.changes_parent, 1); app.update(Action::ToggleAlign); assert!(app.align_metadata); } @@ -1567,8 +1848,15 @@ mod tests { complete(&mut app); app.update(Action::MoveDown); let selected = app.rows[app.selected.expect("a row is selected")].id; + app.set_changes_bounds(1, 3, 1, 2); + app.update(Action::ToggleChangesFocus); + app.update(Action::MoveDown); + app.update(Action::ScrollRight); app.reload(true); + assert!(!app.changes_focused, "reload returns focus to history"); + assert_eq!(app.changes_selected, 0); + assert_eq!((app.changes_offset, app.changes_horizontal_offset), (0, 0)); app.extend_commits(vec![row(1), row(2), row(3)]); complete(&mut app); assert_eq!( @@ -1606,6 +1894,31 @@ mod tests { ); } + #[test] + fn pane_exit_keys_return_to_history_but_control_c_quits() { + let mut app = App::new(1); + app.update(Action::ToggleChangesFocus); + + assert!(app.update(Action::Quit).is_empty()); + assert!(!app.changes_focused, "q returns focus to history"); + + app.update(Action::ToggleChangesFocus); + assert_eq!( + app.update(Action::ForceQuit), + vec![Effect::Quit], + "Ctrl-C quits even while changes have focus" + ); + assert!(app.update(Action::Cancel).is_empty()); + assert!(!app.changes_focused, "Escape returns focus to history"); + assert_eq!( + app.state, + State::Loading, + "Escape does not cancel while changes had focus" + ); + + assert_eq!(app.update(Action::Cancel), vec![Effect::Cancel]); + } + #[test] fn shift_starts_with_a_merges_second_parent_rail() { let mut app = App::new(7); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index ee6f0a8951a..74ce7a073d1 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -8,7 +8,9 @@ mod ui; use std::{ ffi::OsString, + io::{self, Write}, path::{Path, PathBuf}, + process::{Command, ExitStatus, Stdio}, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -18,7 +20,7 @@ use std::{ }; use anyhow::{Context, Result}; -use app::{Action, App, CommitRow, Effect, State}; +use app::{Action, App, ChangeKind, Changes, CommitRow, ComparedParent, Effect, PathChange, State}; use crossterm::{ clipboard::CopyToClipboard, cursor, @@ -28,16 +30,21 @@ use crossterm::{ PushKeyboardEnhancementFlags, }, execute, - style::Print, + style::{Print, ResetColor}, terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, }; -use gix::bstr::{BString, ByteSlice}; +use gix::{ + bstr::{BString, ByteSlice}, + prelude::TreeDiffChangeExt, +}; use history::{Authors, Decorations, Event, SharedAuthors}; -use ratatui::{TerminalOptions, Viewport, backend::CrosstermBackend}; +use ratatui::{TerminalOptions, Viewport, backend::CrosstermBackend, text::Line}; const EVENT_BATCH_SIZE: usize = 256; const OBJECT_CACHE_SIZE: usize = 4 * 1024 * 1024; const FRAME_INTERVAL: Duration = Duration::from_nanos(16_666_667); +const REPEAT_IDLE: Duration = Duration::from_millis(75); +const IMMEDIATE_PAGER_EXIT: Duration = Duration::from_millis(250); struct FillRepository<'a> { path: &'a Path, @@ -45,6 +52,165 @@ struct FillRepository<'a> { retain: bool, } +type LineCounts = Option<(u32, u32)>; +type LineDiffResult = (usize, gix::object::tree::diff::ChangeDetached, Result); + +struct LineDiffJob { + index: usize, + change: gix::object::tree::diff::ChangeDetached, +} + +struct LineDiffPool { + jobs: Option>, + results: mpsc::Receiver, + workers: Vec>, +} + +impl LineDiffPool { + fn new(repository_path: &Path, parallelism: usize) -> Result { + let repository = gix::open(repository_path) + .context("could not open repository for parallel line diffs")? + .into_sync(); + let mut worker_state = Vec::with_capacity(parallelism); + for _ in 0..parallelism { + let mut repository = repository.to_thread_local(); + repository.object_cache_size(OBJECT_CACHE_SIZE); + let resource_cache = repository + .diff_resource_cache_for_tree_diff() + .context("could not initialize parallel line diffs")?; + worker_state.push((repository, resource_cache)); + } + + let (jobs, job_receiver) = mpsc::channel::(); + let job_receiver = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(job_receiver)); + let (result_sender, results) = mpsc::channel(); + let workers = worker_state + .into_iter() + .map(|(repository, mut resource_cache)| { + let job_receiver = gix::features::threading::OwnShared::clone(&job_receiver); + let result_sender = result_sender.clone(); + std::thread::spawn(move || { + loop { + let Ok(job) = gix::features::threading::lock(&job_receiver).recv() else { + break; + }; + let result = job + .change + .attach(&repository, &repository) + .diff(&mut resource_cache) + .context("could not prepare line diff") + .and_then(|mut diff| { + diff.line_counts() + .context("could not count changed lines") + .map(|counts| counts.map(|counts| (counts.insertions, counts.removals))) + }); + resource_cache.clear_resource_cache_keep_allocation(); + if result_sender.send((job.index, job.change, result)).is_err() { + break; + } + } + }) + }) + .collect(); + Ok(LineDiffPool { + jobs: Some(jobs), + results, + workers, + }) + } + + fn line_counts( + &mut self, + changes: Vec, + ) -> Result> { + let len = changes.len(); + let jobs = self.jobs.as_ref().context("line diff pool is shutting down")?; + for (index, change) in changes.into_iter().enumerate() { + jobs.send(LineDiffJob { index, change }) + .context("line diff workers stopped unexpectedly")?; + } + + let mut out: Vec<_> = std::iter::repeat_with(|| None).take(len).collect(); + let mut first_error = None; + for _ in 0..len { + let (index, change, result) = self.results.recv().context("line diff workers stopped unexpectedly")?; + match result { + Ok(lines) => { + *out.get_mut(index) + .context("line diff worker returned an invalid result index")? = Some((change, lines)); + } + Err(err) if first_error.is_none() => first_error = Some(err), + Err(_) => {} + } + } + if let Some(err) = first_error { + return Err(err); + } + out.into_iter() + .map(|entry| entry.context("line diff worker omitted a result")) + .collect() + } +} + +impl Drop for LineDiffPool { + fn drop(&mut self) { + drop(self.jobs.take()); + for worker in self.workers.drain(..) { + drop(worker.join()); + } + } +} + +fn sync_line_diff_pool( + pool: &mut Option, + visible: bool, + repository_path: &Path, + parallelism: usize, +) -> Result<()> { + if visible && pool.is_none() { + *pool = Some(LineDiffPool::new(repository_path, parallelism.max(1))?); + } else if !visible { + *pool = None; + } + Ok(()) +} + +enum FileDiff { + External(gix::diff::blob::platform::prepare_diff_command::Command), + Pager { command: Command, diff: BuiltInDiff }, + BuiltIn(BuiltInDiff), +} + +pub(crate) struct BuiltInDiff { + title: BString, + lines: Vec, + max_width: usize, +} + +impl BuiltInDiff { + fn new(title: BString, lines: Vec) -> Self { + let max_width = lines + .iter() + .map(|line| Line::from(line.to_str_lossy()).width()) + .max() + .unwrap_or_default(); + BuiltInDiff { + title, + lines, + max_width, + } + } + + fn write_to(&self, mut out: impl Write) -> io::Result<()> { + for line in &self.lines { + out.write_all(line)?; + out.write_all(b"\n")?; + } + Ok(()) + } +} + /// Options for [`run()`]. #[derive(Clone, Debug, Default)] pub struct Options { @@ -211,10 +377,25 @@ fn should_switch_screen(started_inline: bool, needs_alternate_screen: bool, in_a started_inline && needs_alternate_screen != in_alternate_screen } +fn configure_initial_screen(app: &mut App, inline: bool) { + app.inline = inline; + if inline { + app.show_changes = false; + } +} + fn history_needs_alternate_screen(screen: Screen, terminal_height: u16, commits: usize) -> bool { screen == Screen::Auto && inline_height(screen, terminal_height, commits).is_none() } +fn needs_alternate_screen( + show_panel: bool, + history_requires_alternate_screen: bool, + current_inline_height: Option, +) -> bool { + show_panel || history_requires_alternate_screen || current_inline_height.is_none() +} + fn resize_inline_screen(terminal: &mut ratatui::DefaultTerminal, height: u16) -> std::io::Result<()> { if terminal.get_frame().area().height == height { return Ok(()); @@ -243,11 +424,14 @@ fn sync_screen( inline_terminal: &mut Option, enhanced_keyboard: bool, ) -> Result<()> { - let needs_alternate_screen = app.show_commit || history_requires_alternate_screen; + let inline_height = inline_height(screen, terminal::size()?.1, app.rows.len()); + let needs_alternate_screen = needs_alternate_screen( + app.show_commit || app.show_changes, + history_requires_alternate_screen, + inline_height, + ); if !should_switch_screen(started_inline, needs_alternate_screen, inline_terminal.is_some()) { - if started_inline && app.inline && resize_inline { - let height = inline_height(screen, terminal::size()?.1, app.rows.len()) - .expect("an inline history always has an inline height"); + if let (true, Some(height)) = (started_inline && app.inline && resize_inline, inline_height) { resize_inline_screen(terminal, height).context("could not resize the inline history")?; } return Ok(()); @@ -259,9 +443,9 @@ fn sync_screen( } else if let Some(inline) = inline_terminal.take() { leave_alternate_screen(terminal, inline, enhanced_keyboard).context("could not leave the alternate screen")?; app.inline = true; - let height = inline_height(screen, terminal::size()?.1, app.rows.len()) - .expect("an inline history always has an inline height"); - resize_inline_screen(terminal, height).context("could not resize the inline history")?; + if let Some(height) = inline_height { + resize_inline_screen(terminal, height).context("could not resize the inline history")?; + } } Ok(()) } @@ -295,13 +479,22 @@ fn event_loop( let mut lane_receiver = None; let mut verification_receiver = None; let mut commit_message = None; + let mut changes = None; + let line_diff_parallelism = std::thread::available_parallelism().map_or(1, Into::into); + let mut line_diff_pool = None; let mut fill_repository = FillRepository { path: &repository_path, retained: None, retain: false, }; - app.inline = started_inline; + configure_initial_screen(&mut app, started_inline); app.configure_hidden_filter(!hide.is_empty()); + sync_line_diff_pool( + &mut line_diff_pool, + app.show_changes, + &repository_path, + line_diff_parallelism, + )?; let mut decorations = Decorations::new(); draw( terminal, @@ -311,6 +504,8 @@ fn event_loop( &authors, &mut fill_repository, &mut commit_message, + &mut changes, + &mut line_diff_pool, )?; let mut last_draw = Instant::now(); let mut dirty = false; @@ -318,7 +513,19 @@ fn event_loop( let mut inline_terminal = None; let mut history_requires_alternate_screen = false; let mut focused = true; + let mut repeat_deadline: Option = None; let result: Result> = (|| loop { + if repeat_deadline.is_some_and(|deadline| Instant::now() >= deadline) { + repeat_deadline = None; + if app.changes_suppressed { + app.changes_suppressed = false; + dirty = true; + urgent = true; + } else { + fill_repository.retain = false; + fill_repository.retained = None; + } + } if let Some(result) = verification_receiver.as_ref().map(mpsc::Receiver::try_recv) { match result { Ok(results) => { @@ -357,10 +564,16 @@ fn event_loop( &authors, &mut fill_repository, &mut commit_message, + &mut changes, + &mut line_diff_pool, )?; last_draw = Instant::now(); dirty = false; urgent = false; + if repeat_deadline.is_none() { + fill_repository.retain = false; + fill_repository.retained = None; + } continue; } let mut events = 0; @@ -426,11 +639,14 @@ fn event_loop( &authors, &mut fill_repository, &mut commit_message, + &mut changes, + &mut line_diff_pool, )?; last_draw = Instant::now(); dirty = false; } - let terminal_event = match poll_timeout(streaming, events, dirty, last_draw.elapsed()) { + let repeat_timeout = repeat_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); + let terminal_event = match poll_timeout(streaming, events, dirty, last_draw.elapsed(), repeat_timeout) { Some(timeout) if event::poll(timeout)? => Some(event::read()?), Some(_) => None, None => Some(event::read()?), @@ -442,6 +658,8 @@ fn event_loop( TerminalEvent::Key(key) => key, TerminalEvent::FocusLost => { focused = false; + app.changes_suppressed = false; + repeat_deadline = None; drop(app.update(Action::PreviewAuthorCopy(false))); dirty = true; urgent = true; @@ -462,16 +680,40 @@ fn event_loop( continue; } let action = action(key); - fill_repository.retain = retains_fill_repository(key.kind, action.as_ref()); - if !fill_repository.retain { + let repeats_history = retains_fill_repository(key.kind, action.as_ref(), app.changes_focused); + if repeats_history { + fill_repository.retain = true; + repeat_deadline = Some(Instant::now() + REPEAT_IDLE); + } else if key.kind != KeyEventKind::Repeat { + fill_repository.retain = false; fill_repository.retained = None; } + if repeats_history && app.show_changes { + app.changes_suppressed = true; + } else if key.kind != KeyEventKind::Repeat && app.changes_suppressed { + app.changes_suppressed = false; + repeat_deadline = None; + dirty = true; + urgent = true; + } let Some(action) = action else { continue; }; + if action == Action::ToggleChangesFocus && !changes_focusable(changes.as_ref().map(|(_, _, changes)| changes)) { + continue; + } dirty = true; urgent = true; + let toggles_changes = action == Action::ToggleChanges; let effects = app.update(action); + if toggles_changes { + sync_line_diff_pool( + &mut line_diff_pool, + app.show_changes, + &repository_path, + line_diff_parallelism, + )?; + } for effect in effects { match effect { Effect::Cancel => cancelled.store(true, Ordering::Relaxed), @@ -496,24 +738,31 @@ fn event_loop( gix::features::threading::OwnShared::clone(&authors), ); } + Effect::OpenDiff(index) => { + let result = changes + .as_ref() + .and_then(|(_, _, changes)| changes.diffs.get(index).zip(changes.paths.get(index))) + .context("selected path no longer has diff resources") + .and_then(|(change, path)| prepare_file_diff(&repository_path, change, path)) + .and_then(|diff| match diff { + FileDiff::External(command) => { + run_external_diff(terminal, command, enhanced_keyboard).map(|()| false) + } + FileDiff::Pager { command, diff } => { + run_pager(terminal, command, &diff, enhanced_keyboard).map(|()| false) + } + FileDiff::BuiltIn(diff) => show_builtin_diff(terminal, &diff), + }); + match result { + Ok(true) => app.focus_history(), + Err(err) => app.diff_error = Some(format!("{err:#}")), + Ok(false) => {} + } + } Effect::VerifySignatures(ids) => { verification_receiver = Some(start_signature_verification(repository_path.clone(), ids)); } - Effect::Quit => { - if app.inline { - app.show_selection_tail = false; - draw( - terminal, - &mut app, - &decorations, - &mailmap, - &authors, - &mut fill_repository, - &mut commit_message, - )?; - } - return Ok(None); - } + Effect::Quit => return Ok(None), } } sync_screen( @@ -532,9 +781,34 @@ fn event_loop( .transpose(); let outcome = result?; restore.context("could not restore the inline terminal")?; + if outcome.is_none() && started_inline { + prepare_inline_exit(&mut app); + sync_line_diff_pool(&mut line_diff_pool, false, &repository_path, line_diff_parallelism)?; + draw( + terminal, + &mut app, + &decorations, + &mailmap, + &authors, + &mut fill_repository, + &mut commit_message, + &mut changes, + &mut line_diff_pool, + )?; + } Ok(outcome) } +fn prepare_inline_exit(app: &mut App) { + app.inline = true; + app.show_commit = false; + app.show_changes = false; + app.changes_suppressed = false; + app.changes_focused = false; + app.reset_changes_view(); + app.show_selection_tail = false; +} + fn start_lane_worker(rows: Vec) -> mpsc::Receiver<(Vec, app::Graph, Duration)> { let (sender, receiver) = mpsc::channel(); std::thread::spawn(move || { @@ -608,6 +882,7 @@ fn start_history( (cancelled, receiver) } +#[expect(clippy::too_many_arguments, reason = "drawing needs the complete view state")] fn draw( terminal: &mut ratatui::DefaultTerminal, app: &mut App, @@ -616,24 +891,50 @@ fn draw( authors: &SharedAuthors, fill_repository: &mut FillRepository<'_>, commit_message: &mut Option<(gix::ObjectId, BString)>, + changes: &mut Option<(gix::ObjectId, usize, Changes)>, + line_diff_pool: &mut Option, ) -> Result<()> { app.viewport_rows = terminal .get_frame() .area() .height .saturating_sub(1 + 2 * u16::from(app.inline)) as usize; + if !history_is_ready_to_draw(app.state, app.rows.len()) { + return Ok(()); + } app.ensure_visible(); let start = app.offset.min(app.rows.len()); let end = start.saturating_add(app.viewport_rows).min(app.rows.len()); - let selected = app - .show_commit + let changes_visible = app.changes_visible(); + let selected = (app.show_commit || changes_visible) .then(|| app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id)) .flatten(); - let message_to_load = selected.filter(|id| commit_message.as_ref().map(|(cached, _)| cached) != Some(id)); - if selected.is_none() { + let message_to_load = app + .show_commit + .then_some(selected) + .flatten() + .filter(|id| commit_message.as_ref().map(|(cached, _)| cached) != Some(id)); + if changes_visible && selected.is_some() && changes.as_ref().map(|(cached, _, _)| *cached) != selected { + app.changes_parent = 0; + } + let changes_to_load = changes_visible.then_some(selected).flatten().filter(|id| { + changes + .as_ref() + .is_none_or(|(cached, parent, _)| cached != id || *parent != app.changes_parent) + }); + if changes_to_load.is_some() { + app.reset_changes_view(); + } + if !app.show_commit || selected.is_none() { *commit_message = None; } - if app.rows[start..end].iter().any(|row| !row.metadata_loaded) || message_to_load.is_some() { + if !app.show_changes || app.selected.is_none() { + *changes = None; + } + if app.rows[start..end].iter().any(|row| !row.metadata_loaded) + || message_to_load.is_some() + || changes_to_load.is_some() + { let mut one_shot_repository = None; let repository = if fill_repository.retain { match &mut fill_repository.retained { @@ -654,9 +955,25 @@ fn draw( if let Some(id) = message_to_load { *commit_message = Some((id, load_commit_message(repository, id)?)); } + if let Some(id) = changes_to_load { + repository.object_cache_size(OBJECT_CACHE_SIZE); + let loaded = load_changes( + repository, + id, + app.changes_parent, + line_diff_pool + .as_mut() + .context("line diff pool is missing while the changes pane is visible")?, + ); + repository.object_cache_size(None); + let loaded = loaded?; + app.changes_parent = loaded.parent.map_or(0, |parent| parent.index); + *changes = Some((id, app.changes_parent, loaded)); + } } let message = commit_message.as_ref().map(|(_, message)| message.as_bstr()); - terminal.draw(|frame| ui::draw(frame, app, decorations, mailmap, message))?; + let changes = changes.as_ref().map(|(_, _, changes)| changes); + terminal.draw(|frame| ui::draw(frame, app, decorations, mailmap, message, changes))?; Ok(()) } @@ -666,11 +983,426 @@ fn open_fill_repository(repository_path: &Path) -> Result { Ok(repository) } +fn prepare_file_diff( + repository_path: &Path, + change: &gix::object::tree::diff::ChangeDetached, + path: &PathChange, +) -> Result { + let mut repository = gix::open(repository_path).context("could not open repository for file diff")?; + repository.object_cache_size(OBJECT_CACHE_SIZE); + prepare_file_diff_with_repository(&repository, change, path) +} + +fn prepare_file_diff_with_repository( + repository: &gix::Repository, + change: &gix::object::tree::diff::ChangeDetached, + path: &PathChange, +) -> Result { + let global_command = repository + .config_snapshot() + .trusted_program(gix::config::tree::Diff::EXTERNAL) + .map(gix::path::os_string_into_bstring) + .transpose() + .context("external diff command is not representable on this platform")?; + let mut resources = repository + .diff_resource_cache( + gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent, + Default::default(), + ) + .context("could not initialize file diff")?; + resources.options.skip_internal_diff_if_external_is_configured = true; + change + .attach(repository, repository) + .diff(&mut resources) + .context("could not prepare selected file")?; + let prepared = resources.prepare_diff().context("could not prepare selected diff")?; + match prepared.operation { + gix::diff::blob::platform::prepare_diff::Operation::ExternalCommand { command } => { + let command = command.to_owned(); + prepare_external_diff(repository, &resources, command) + } + gix::diff::blob::platform::prepare_diff::Operation::InternalDiff { algorithm } => { + if let Some(command) = global_command { + return prepare_external_diff(repository, &resources, command); + } + let input = prepared.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); + let rendered = gix::diff::blob::UnifiedDiff::new( + &diff, + &input, + gix::diff::blob::unified_diff::ConsumeBinaryHunk::new(BString::default(), "\n"), + gix::diff::blob::unified_diff::ContextSize::symmetrical(3), + ) + .consume() + .context("could not render selected diff")?; + prepare_pager(repository, built_in_diff(path, change, Some(rendered), false)) + } + gix::diff::blob::platform::prepare_diff::Operation::SourceOrDestinationIsBinary => { + prepare_pager(repository, built_in_diff(path, change, None, true)) + } + } +} + +fn prepare_pager(repository: &gix::Repository, diff: BuiltInDiff) -> Result { + let Some(program) = repository.config_snapshot().trusted_program("core.pager") else { + return Ok(FileDiff::BuiltIn(diff)); + }; + if program.is_empty() || program == "cat" { + return Ok(FileDiff::BuiltIn(diff)); + } + let command = gix::command::prepare(program) + .command_may_be_shell_script_disallow_manual_argument_splitting() + .with_context( + repository + .command_context() + .context("could not prepare pager environment")?, + ) + .env("GIT_PAGER_IN_USE", "true") + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .into(); + Ok(FileDiff::Pager { command, diff }) +} + +fn prepare_external_diff( + repository: &gix::Repository, + resources: &gix::diff::blob::Platform, + command: BString, +) -> Result { + Ok(FileDiff::External( + resources + .prepare_diff_command( + command, + repository + .command_context() + .context("could not prepare external diff environment")?, + 0, + 1, + ) + .context("could not prepare external diff command")?, + )) +} + +fn built_in_diff( + path: &PathChange, + change: &gix::object::tree::diff::ChangeDetached, + rendered: Option, + binary: bool, +) -> BuiltInDiff { + use gix::object::tree::diff::ChangeDetached; + + let (old_path, new_path, old_mode, new_mode) = match change { + ChangeDetached::Addition { entry_mode, .. } => (None, Some(path.path.as_bstr()), None, Some(*entry_mode)), + ChangeDetached::Deletion { entry_mode, .. } => (Some(path.path.as_bstr()), None, Some(*entry_mode), None), + ChangeDetached::Modification { + previous_entry_mode, + entry_mode, + .. + } => ( + Some(path.path.as_bstr()), + Some(path.path.as_bstr()), + Some(*previous_entry_mode), + Some(*entry_mode), + ), + ChangeDetached::Rewrite { + source_entry_mode, + entry_mode, + .. + } => ( + path.source.as_ref().map(|path| path.as_bstr()), + Some(path.path.as_bstr()), + Some(*source_entry_mode), + Some(*entry_mode), + ), + }; + let display_path = |path: Option<&gix::bstr::BStr>, prefix: &str| -> BString { + path.map_or_else( + || "/dev/null".into(), + |path| format!("{prefix}{}", path.to_str_lossy()).into(), + ) + }; + let mut lines = vec![ + format!("--- {}", display_path(old_path, "a/").to_str_lossy()).into(), + format!("+++ {}", display_path(new_path, "b/").to_str_lossy()).into(), + ]; + if old_mode != new_mode { + if let Some(mode) = old_mode { + lines.push(format!("old mode {}", mode.kind().as_octal_str()).into()); + } + if let Some(mode) = new_mode { + lines.push(format!("new mode {}", mode.kind().as_octal_str()).into()); + } + } + if binary { + lines.push("Binary files differ".into()); + } else if let Some(rendered) = rendered { + lines.extend(rendered.lines().map(BString::from)); + } + BuiltInDiff::new( + format!("{} {}", path.kind.letter(), path.path.to_str_lossy()).into(), + lines, + ) +} + +fn run_external_diff( + terminal: &mut ratatui::DefaultTerminal, + mut command: gix::diff::blob::platform::prepare_diff_command::Command, + enhanced_keyboard: bool, +) -> Result<()> { + with_suspended_terminal(terminal, enhanced_keyboard, || { + let status = command.status().context("could not launch external diff")?; + external_diff_status(status) + }) +} + +fn run_pager( + terminal: &mut ratatui::DefaultTerminal, + mut command: Command, + diff: &BuiltInDiff, + enhanced_keyboard: bool, +) -> Result<()> { + with_suspended_terminal(terminal, enhanced_keyboard, || { + let start = Instant::now(); + let mut child = command.spawn().context("could not launch diff pager")?; + let write_result = child.stdin.take().map_or_else( + || Err(io::Error::other("pager stdin was not piped")), + |mut stdin| diff.write_to(&mut stdin), + ); + let status = child.wait().context("could not wait for diff pager"); + pager_write_result(write_result)?; + pager_status(status?)?; + if pager_needs_acknowledgement(start.elapsed()) { + wait_for_keypress()?; + } + Ok(()) + }) +} + +fn wait_for_keypress() -> Result<()> { + terminal::enable_raw_mode().context("could not read pager acknowledgement")?; + loop { + if matches!( + event::read().context("could not read pager acknowledgement")?, + TerminalEvent::Key(KeyEvent { + kind: KeyEventKind::Press, + .. + }) + ) { + return Ok(()); + } + } +} + +fn with_suspended_terminal( + terminal: &mut ratatui::DefaultTerminal, + enhanced_keyboard: bool, + operation: impl FnOnce() -> Result, +) -> Result { + let suspend = disable_input(terminal.backend_mut(), enhanced_keyboard) + .and_then(|()| terminal.show_cursor()) + .and_then(|()| terminal::disable_raw_mode()) + .and_then(|()| { + execute!( + terminal.backend_mut(), + ResetColor, + cursor::MoveTo(0, 0), + Clear(ClearType::All) + ) + }); + if let Err(err) = suspend { + let _ = terminal::enable_raw_mode(); + let _ = enable_input(terminal.backend_mut(), enhanced_keyboard); + let _ = terminal.hide_cursor(); + return Err(err).context("could not suspend terminal for external program"); + } + + let result = operation(); + let restore = terminal::enable_raw_mode() + .and_then(|()| enable_input(terminal.backend_mut(), enhanced_keyboard)) + .and_then(|()| terminal.hide_cursor()) + .and_then(|()| terminal.clear()); + let value = result?; + restore.context("could not restore terminal after external program")?; + Ok(value) +} + +fn external_diff_status(status: ExitStatus) -> Result<()> { + if status.success() || status.code() == Some(1) { + Ok(()) + } else { + anyhow::bail!("external diff exited with {status}") + } +} + +fn pager_write_result(result: io::Result<()>) -> Result<()> { + match result { + Err(err) if err.kind() == io::ErrorKind::BrokenPipe => Ok(()), + result => result.context("could not write diff to pager"), + } +} + +fn pager_status(status: ExitStatus) -> Result<()> { + if status.success() { + Ok(()) + } else { + anyhow::bail!("diff pager exited with {status}") + } +} + +fn pager_needs_acknowledgement(elapsed: Duration) -> bool { + elapsed <= IMMEDIATE_PAGER_EXIT +} + +fn show_builtin_diff(terminal: &mut ratatui::DefaultTerminal, diff: &BuiltInDiff) -> Result { + let mut offset = 0usize; + let mut horizontal_offset = 0usize; + let mut focused = true; + loop { + let size = terminal.size().context("could not determine diff viewport")?; + let page = usize::from(size.height.saturating_sub(2)).max(1); + let max = diff.lines.len().saturating_sub(page); + let horizontal_page = usize::from(size.width).max(1); + let horizontal_max = diff.max_width.saturating_sub(horizontal_page); + offset = offset.min(max); + horizontal_offset = horizontal_offset.min(horizontal_max); + terminal + .draw(|frame| ui::draw_file_diff(frame, diff, offset, horizontal_offset)) + .context("could not draw file diff")?; + let event = event::read().context("could not read file diff input")?; + let key = match event { + TerminalEvent::FocusLost => { + focused = false; + continue; + } + TerminalEvent::FocusGained => { + focused = true; + continue; + } + TerminalEvent::Resize(_, _) => continue, + TerminalEvent::Key(key) if focused && key.kind != KeyEventKind::Release => key, + _ => continue, + }; + match action(key) { + Some(Action::OpenDiff) => return Ok(false), + Some(Action::ForceQuit | Action::Quit | Action::Cancel) => return Ok(true), + Some(Action::MoveUp) => offset = offset.saturating_sub(1), + Some(Action::MoveDown) => offset = offset.saturating_add(1).min(max), + Some(Action::PageUp) => offset = offset.saturating_sub(page), + Some(Action::PageDown) => offset = offset.saturating_add(page).min(max), + Some(Action::HalfPageUp) => offset = offset.saturating_sub((page / 2).max(1)), + Some(Action::HalfPageDown) => offset = offset.saturating_add((page / 2).max(1)).min(max), + Some(Action::First) => offset = 0, + Some(Action::Last) => offset = max, + Some(Action::ScrollLeft) => horizontal_offset = horizontal_offset.saturating_sub(horizontal_page), + Some(Action::ScrollRight) => { + horizontal_offset = horizontal_offset.saturating_add(horizontal_page).min(horizontal_max); + } + _ => {} + } + } +} + fn load_commit_message(repository: &gix::Repository, id: gix::ObjectId) -> Result { let commit = repository.find_commit(id).context("could not load commit message")?; Ok(commit.message_raw_sloppy().to_owned()) } +fn load_changes( + repository: &gix::Repository, + id: gix::ObjectId, + requested_parent: usize, + line_diff_pool: &mut LineDiffPool, +) -> Result { + let commit = repository.find_commit(id).context("could not load changed paths")?; + let parents: Vec<_> = commit.parent_ids().collect(); + let parent_index = requested_parent.checked_rem(parents.len()).unwrap_or_default(); + let parent = parents.get(parent_index).copied(); + let new_tree = commit.tree().context("could not load changed commit tree")?; + let old_tree = match parent { + Some(parent) => Some( + parent + .object() + .context("could not load parent commit")? + .try_into_commit() + .context("parent is not a commit")? + .tree() + .context("could not load parent commit tree")?, + ), + None => None, + }; + let changes = repository + .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) + .context("could not diff commit trees")?; + let mut out = Changes { + parent: (parents.len() > 1).then(|| ComparedParent { + index: parent_index, + total: parents.len(), + id: parent.expect("a merge has parents").detach(), + }), + ..Changes::default() + }; + let mut diffs = Vec::new(); + for change in changes { + use gix::object::tree::diff::ChangeDetached; + let (kind, source, path, is_tree) = match &change { + ChangeDetached::Addition { + entry_mode, location, .. + } => (ChangeKind::Added, None, location.clone(), entry_mode.is_tree()), + ChangeDetached::Deletion { + entry_mode, location, .. + } => (ChangeKind::Deleted, None, location.clone(), entry_mode.is_tree()), + ChangeDetached::Modification { + previous_entry_mode, + entry_mode, + location, + .. + } => ( + if previous_entry_mode.kind() == entry_mode.kind() { + ChangeKind::Modified + } else { + ChangeKind::TypeChanged + }, + None, + location.clone(), + previous_entry_mode.is_tree() && entry_mode.is_tree(), + ), + ChangeDetached::Rewrite { + source_location, + source_entry_mode, + entry_mode, + location, + copy, + .. + } => ( + if *copy { ChangeKind::Copied } else { ChangeKind::Renamed }, + Some(source_location.clone()), + location.clone(), + source_entry_mode.is_tree() || entry_mode.is_tree(), + ), + }; + if is_tree { + continue; + } + out.paths.push(PathChange { + kind, + source, + path, + lines: None, + }); + diffs.push(change); + } + for (path, (change, lines)) in out.paths.iter_mut().zip(line_diff_pool.line_counts(diffs)?) { + path.lines = lines; + if let Some((insertions, removals)) = lines { + out.lines_added += u64::from(insertions); + out.lines_removed += u64::from(removals); + } + out.diffs.push(change); + } + Ok(out) +} + fn actor_bytes(author: &app::Author) -> Vec { let mut out = Vec::with_capacity(author.name.len() + author.email.len() + 3); out.extend_from_slice(author.name); @@ -684,8 +1416,18 @@ fn should_draw(dirty: bool, streaming: bool, since_draw: Duration) -> bool { dirty && (!streaming || since_draw >= FRAME_INTERVAL) } -fn poll_timeout(streaming: bool, events: usize, dirty: bool, since_draw: Duration) -> Option { - streaming.then(|| { +fn history_is_ready_to_draw(state: State, commits: usize) -> bool { + commits != 0 || state != State::Loading +} + +fn poll_timeout( + streaming: bool, + events: usize, + dirty: bool, + since_draw: Duration, + wake_after: Option, +) -> Option { + let frame_timeout = streaming.then(|| { if events == EVENT_BATCH_SIZE { Duration::ZERO } else if dirty { @@ -693,7 +1435,12 @@ fn poll_timeout(streaming: bool, events: usize, dirty: bool, since_draw: Duratio } else { FRAME_INTERVAL } - }) + }); + match (frame_timeout, wake_after) { + (Some(frame), Some(wake_after)) => Some(frame.min(wake_after)), + (Some(frame), None) => Some(frame), + (None, wake_after) => wake_after, + } } fn action(key: KeyEvent) -> Option { @@ -709,7 +1456,11 @@ fn action(key: KeyEvent) -> Option { KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift) => { Some(Action::PreviewAuthorCopy(key.kind != KeyEventKind::Release)) } - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::Quit), + KeyCode::Tab => Some(Action::ToggleChangesFocus), + KeyCode::Enter => Some(Action::OpenDiff), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::ForceQuit), + KeyCode::Char('c') => Some(Action::ToggleChanges), + KeyCode::Char('p') => Some(Action::CycleChangesParent), KeyCode::Char('q') => Some(Action::Quit), KeyCode::Esc => Some(Action::Cancel), KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp), @@ -742,6 +1493,10 @@ fn action(key: KeyEvent) -> Option { } } +fn changes_focusable(changes: Option<&Changes>) -> bool { + changes.is_some_and(Changes::is_visible) +} + fn repeats_viewport(action: &Action) -> bool { matches!( action, @@ -756,8 +1511,8 @@ fn repeats_viewport(action: &Action) -> bool { ) } -fn retains_fill_repository(kind: KeyEventKind, action: Option<&Action>) -> bool { - kind == KeyEventKind::Repeat && action.is_some_and(repeats_viewport) +fn retains_fill_repository(kind: KeyEventKind, action: Option<&Action>, changes_focused: bool) -> bool { + !changes_focused && kind == KeyEventKind::Repeat && action.is_some_and(repeats_viewport) } #[cfg(test)] @@ -782,6 +1537,210 @@ mod tests { Ok(()) } + #[test] + fn loads_changes_against_each_merge_parent() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let repository = gix::open_opts(&fixture, gix::open::Options::isolated())?; + let mut line_diff_pool = None; + sync_line_diff_pool(&mut line_diff_pool, true, &fixture, 2)?; + assert_eq!( + line_diff_pool.as_ref().map(|pool| pool.workers.len()), + Some(2), + "showing changes creates the requested worker pool" + ); + sync_line_diff_pool(&mut line_diff_pool, false, &fixture, 2)?; + assert!(line_diff_pool.is_none(), "hiding changes destroys the worker pool"); + sync_line_diff_pool(&mut line_diff_pool, true, &fixture, 2)?; + let line_diff_pool = line_diff_pool + .as_mut() + .expect("showing changes recreates the worker pool"); + + let root = load_changes( + &repository, + repository.rev_parse_single("v1^{}")?.detach(), + 0, + line_diff_pool, + )?; + assert_eq!( + root.paths, + [PathChange { + kind: ChangeKind::Added, + source: None, + path: "root".into(), + lines: Some((1, 0)), + }], + "root commits are compared to the empty tree" + ); + assert_eq!((root.parent, root.lines_added, root.lines_removed), (None, 1, 0)); + assert_eq!(root.diffs.len(), 1, "the original change is retained for file diffs"); + match prepare_file_diff_with_repository(&repository, &root.diffs[0], &root.paths[0])? { + FileDiff::BuiltIn(diff) => { + assert_eq!(diff.title, "A root"); + assert!(diff.lines.iter().any(|line| line == "+root")); + } + FileDiff::External(_) => unreachable!("isolated repositories have no external diff"), + FileDiff::Pager { .. } => unreachable!("isolated repositories have no pager"), + } + + let external_repository = gix::open_opts( + &fixture, + gix::open::Options::isolated().config_overrides(["diff.external=test --flag"]), + )?; + match prepare_file_diff_with_repository(&external_repository, &root.diffs[0], &root.paths[0])? { + FileDiff::External(command) => assert!( + command + .get_args() + .any(|arg| arg.to_string_lossy().contains("test --flag")), + "the configured helper is prepared with shell semantics" + ), + FileDiff::BuiltIn(_) => unreachable!("configured external diffs take precedence"), + FileDiff::Pager { .. } => unreachable!("configured external diffs take precedence"), + } + + let pager_repository = gix::open_opts( + &fixture, + gix::open::Options::isolated().config_overrides(["core.pager=delta --dark"]), + )?; + match prepare_file_diff_with_repository(&pager_repository, &root.diffs[0], &root.paths[0])? { + FileDiff::Pager { command, diff } => { + assert!( + command + .get_args() + .any(|arg| arg.to_string_lossy().contains("delta --dark")), + "the configured pager is prepared with shell semantics" + ); + let mut patch = Vec::new(); + diff.write_to(&mut patch)?; + assert!(patch.starts_with(b"--- /dev/null\n+++ b/root\n")); + assert!(patch.ends_with(b"\n"), "pagers receive a complete final line"); + } + FileDiff::BuiltIn(_) | FileDiff::External(_) => { + unreachable!("configured pagers receive built-in diffs") + } + } + + for setting in ["core.pager=", "core.pager=cat"] { + let repository = gix::open_opts(&fixture, gix::open::Options::isolated().config_overrides([setting]))?; + assert!( + matches!( + prepare_file_diff_with_repository(&repository, &root.diffs[0], &root.paths[0])?, + FileDiff::BuiltIn(_) + ), + "disabled pagers retain the built-in viewer" + ); + } + + let topic = load_changes( + &repository, + repository.rev_parse_single("topic")?.detach(), + 0, + line_diff_pool, + )?; + assert_eq!( + topic.paths, + [ + PathChange { + kind: ChangeKind::Added, + source: None, + path: "topic".into(), + lines: Some((1, 0)), + }, + PathChange { + kind: ChangeKind::Added, + source: None, + path: "topic-extra".into(), + lines: Some((1, 0)), + } + ], + "parallel line diffs retain tree-diff order and status" + ); + assert_eq!((topic.lines_added, topic.lines_removed), (2, 0)); + + let merge = repository.rev_parse_single("main")?.detach(); + let first_parent = load_changes(&repository, merge, 0, line_diff_pool)?; + assert_eq!( + first_parent.parent, + Some(ComparedParent { + index: 0, + total: 2, + id: repository.rev_parse_single("main^1")?.detach(), + }) + ); + assert_eq!( + first_parent.paths, + [PathChange { + kind: ChangeKind::Added, + source: None, + path: "merged".into(), + lines: Some((1, 0)), + }], + "the default merge diff compares the result to its first parent" + ); + + let second_parent = load_changes(&repository, merge, 1, line_diff_pool)?; + assert_eq!( + second_parent.parent, + Some(ComparedParent { + index: 1, + total: 2, + id: repository.rev_parse_single("main^2")?.detach(), + }) + ); + assert_eq!( + second_parent.paths, + [PathChange { + kind: ChangeKind::Added, + source: None, + path: "main".into(), + lines: Some((1, 0)), + }], + "later parents can be selected independently" + ); + assert_eq!( + load_changes(&repository, merge, 2, line_diff_pool)?.parent, + first_parent.parent, + "parent selection wraps around" + ); + Ok(()) + } + + #[test] + fn streams_diff_bytes_and_accepts_early_pager_exit() -> gix_testtools::Result { + let diff = BuiltInDiff::new( + "M file".into(), + vec![BString::from("--- a/file"), BString::from(vec![b'+', 0xff])], + ); + let mut patch = Vec::new(); + + diff.write_to(&mut patch)?; + + assert_eq!(patch, b"--- a/file\n+\xff\n", "patch bytes reach the pager unchanged"); + pager_write_result(Err(io::Error::new(io::ErrorKind::BrokenPipe, "pager quit"))) + .expect("an early pager exit is normal"); + assert!( + pager_write_result(Err(io::Error::other("write failed"))).is_err(), + "other write failures remain visible" + ); + #[cfg(unix)] + assert!( + pager_status(std::os::unix::process::ExitStatusExt::from_raw(1 << 8)).is_err(), + "a failing pager remains visible" + ); + assert!( + pager_needs_acknowledgement(Duration::ZERO), + "an immediately closing pager leaves its output visible" + ); + assert!( + pager_needs_acknowledgement(Duration::from_millis(250)), + "the threshold is inclusive" + ); + assert!( + !pager_needs_acknowledgement(Duration::from_millis(251)), + "longer-running pagers restore tix immediately" + ); + Ok(()) + } + #[test] fn chooses_screen_from_terminal_and_history_height() { assert_eq!( @@ -823,6 +1782,18 @@ mod tests { #[test] fn switches_screens_for_inline_commit_panes_and_large_histories() { + let mut inline = App::new(1); + configure_initial_screen(&mut inline, true); + assert!(inline.inline); + assert!(!inline.show_changes, "inline startup hides the default changes view"); + let mut alternate = App::new(1); + configure_initial_screen(&mut alternate, false); + assert!(!alternate.inline); + assert!( + alternate.show_changes, + "alternate-screen startup keeps the default changes view" + ); + assert!( should_switch_screen(true, true, false), "opening the commit pane from inline mode enters the alternate screen" @@ -842,6 +1813,14 @@ mod tests { assert!(!history_needs_alternate_screen(Screen::Auto, 20, 7)); assert!(!history_needs_alternate_screen(Screen::Auto, 20, 8)); assert!(history_needs_alternate_screen(Screen::Auto, 20, 10)); + assert!( + needs_alternate_screen(false, false, None), + "current terminal geometry overrides a stale history-fit flag" + ); + assert!( + !needs_alternate_screen(false, false, Some(11)), + "a fitting current layout may return to inline mode" + ); assert!( !history_needs_alternate_screen(Screen::Half, 20, usize::MAX), "half-screen mode never switches because history grows" @@ -850,6 +1829,14 @@ mod tests { #[test] fn maps_navigation_and_control_c() { + assert_eq!( + action(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), + Some(Action::ToggleChangesFocus) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Some(Action::OpenDiff) + ); assert_eq!( action(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)), Some(Action::PageUp) @@ -927,6 +1914,14 @@ mod tests { action(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)), Some(Action::ToggleCommit) ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)), + Some(Action::CycleChangesParent) + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)), + Some(Action::ToggleChanges) + ); assert_eq!( action(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::SHIFT)), Some(Action::CopyAuthor) @@ -949,26 +1944,79 @@ mod tests { ); assert_eq!( action(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), - Some(Action::Quit) + Some(Action::ForceQuit) ); assert_eq!(action(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), None); } + #[test] + fn only_visible_changes_can_take_focus() { + assert!(!changes_focusable(None)); + assert!(!changes_focusable(Some(&Changes::default()))); + let changes = Changes { + paths: vec![PathChange { + kind: ChangeKind::Modified, + source: None, + path: "file".into(), + lines: None, + }], + ..Changes::default() + }; + assert!(changes_focusable(Some(&changes))); + } + #[test] fn retains_the_fill_repository_only_for_repeated_viewport_navigation() { - assert!(retains_fill_repository(KeyEventKind::Repeat, Some(&Action::MoveDown))); - assert!(!retains_fill_repository(KeyEventKind::Press, Some(&Action::MoveDown))); - assert!(!retains_fill_repository(KeyEventKind::Release, Some(&Action::MoveDown))); + assert!(retains_fill_repository( + KeyEventKind::Repeat, + Some(&Action::MoveDown), + false + )); + assert!(!retains_fill_repository( + KeyEventKind::Repeat, + Some(&Action::MoveDown), + true + )); + assert!(!retains_fill_repository( + KeyEventKind::Press, + Some(&Action::MoveDown), + false + )); + assert!(!retains_fill_repository( + KeyEventKind::Release, + Some(&Action::MoveDown), + false + )); assert!(!retains_fill_repository( KeyEventKind::Repeat, - Some(&Action::ScrollRight) + Some(&Action::ScrollRight), + false )); assert!(!retains_fill_repository( KeyEventKind::Repeat, - Some(&Action::ToggleDate) + Some(&Action::ToggleDate), + false )); } + #[test] + fn prepares_a_reduced_selection_after_leaving_the_alternate_screen() { + let mut app = App::new(1); + app.show_commit = true; + app.show_changes = true; + app.changes_focused = true; + + prepare_inline_exit(&mut app); + + assert!(app.inline, "the final frame is drawn into the restored inline screen"); + assert!( + !app.show_commit && !app.show_changes, + "alternate-screen panels are omitted from the final frame" + ); + assert!(!app.changes_focused, "the hidden panel no longer owns focus"); + assert!(!app.show_selection_tail, "only the left selection marker remains"); + } + #[test] fn copies_parsed_author_bytes_without_validation() { let author = app::Author { @@ -985,6 +2033,18 @@ mod tests { #[test] fn rendering_is_reactive_and_capped_while_streaming() { + assert!( + !history_is_ready_to_draw(State::Loading, 0), + "the initial empty frame remains outside terminal scrollback" + ); + assert!( + history_is_ready_to_draw(State::Loading, 1), + "the first commit makes loading history renderable" + ); + assert!( + history_is_ready_to_draw(State::Computing, 0), + "an empty completed traversal remains renderable" + ); assert!( !should_draw(false, false, Duration::MAX), "clean frames are never redrawn" @@ -1002,19 +2062,29 @@ mod tests { "streaming frames draw at the deadline" ); assert_eq!( - poll_timeout(false, 0, false, Duration::ZERO), + poll_timeout(false, 0, false, Duration::ZERO, None), None, "idle waits reactively for terminal input" ); assert_eq!( - poll_timeout(true, EVENT_BATCH_SIZE, true, Duration::ZERO), + poll_timeout(true, EVENT_BATCH_SIZE, true, Duration::ZERO, None), Some(Duration::ZERO), "saturated history batches keep processing" ); assert_eq!( - poll_timeout(true, 1, true, Duration::from_millis(10)), + poll_timeout(true, 1, true, Duration::from_millis(10), None), Some(FRAME_INTERVAL.saturating_sub(Duration::from_millis(10))), "dirty streaming frames wait only until their deadline" ); + assert_eq!( + poll_timeout(false, 0, false, Duration::ZERO, Some(REPEAT_IDLE)), + Some(REPEAT_IDLE), + "repeat-idle restoration wakes an otherwise idle event loop" + ); + assert_eq!( + poll_timeout(true, 1, true, Duration::from_millis(10), Some(REPEAT_IDLE)), + Some(FRAME_INTERVAL.saturating_sub(Duration::from_millis(10))), + "the earlier frame deadline takes precedence over repeat-idle restoration" + ); } } diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index be73243c8b6..803318bf0fe 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -4,22 +4,64 @@ use ratatui::{ layout::{Constraint, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, - widgets::{Clear, Paragraph, Wrap}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; use crate::{ - app::{App, AttributionKind, CommitRow, CopyKind, NameMode, RefMode, SignatureState, State}, + BuiltInDiff, + app::{App, AttributionKind, ChangeKind, Changes, CommitRow, CopyKind, NameMode, RefMode, SignatureState, State}, history::{DecorationKind, Decorations}, }; +const COMPARED_PARENT_COLOR: Color = Color::Cyan; const NOTE_COLOR: Color = Color::LightMagenta; +pub(crate) fn draw_file_diff(frame: &mut Frame<'_>, diff: &BuiltInDiff, offset: usize, horizontal_offset: usize) { + let [header, body, footer] = + Layout::vertical([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)]).areas(frame.area()); + frame.render_widget(Clear, frame.area()); + frame.render_widget( + Paragraph::new(diff.title.to_str_lossy()).style(Style::default().add_modifier(Modifier::BOLD)), + header, + ); + let lines = diff + .lines + .iter() + .map(|line| { + let style = if line.starts_with(b"@@") { + Style::default().fg(Color::Cyan) + } else if line.starts_with(b"+") { + Style::default().fg(Color::Green) + } else if line.starts_with(b"-") { + Style::default().fg(Color::Red) + } else if line.starts_with(b"Binary ") { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + Line::styled(line.to_str_lossy(), style) + }) + .collect::>(); + frame.render_widget( + Paragraph::new(Text::from(lines)).scroll(( + u16::try_from(offset).unwrap_or(u16::MAX), + u16::try_from(horizontal_offset).unwrap_or(u16::MAX), + )), + body, + ); + frame.render_widget( + Paragraph::new("↑↓/jk move · h/l pan · Enter/q/Esc back").style(Style::default().add_modifier(Modifier::DIM)), + footer, + ); +} + pub(crate) fn draw( frame: &mut Frame<'_>, app: &mut App, decorations: &Decorations, mailmap: &gix::mailmap::Snapshot, commit_message: Option<&BStr>, + changes: Option<&Changes>, ) { let [top_spacer, mut body, bottom_spacer, footer] = Layout::vertical([ Constraint::Length(u16::from(app.inline)), @@ -30,25 +72,51 @@ pub(crate) fn draw( .areas(frame.area()); frame.render_widget(Clear, top_spacer); frame.render_widget(Clear, bottom_spacer); - let commit_pane = app.show_commit.then(|| { - let width = 80.min(body.width / 2); - let [commits, message] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]).areas(body); + let full_body = body; + let compared_parent = if app.changes_visible() { + changes.and_then(|changes| changes.parent.map(|parent| parent.id)) + } else { + None + }; + let changes_pane = app.changes_visible().then(|| { + let desired_height = changes.filter(|changes| changes.is_visible()).map_or(0, |changes| { + u16::try_from(changes.paths.len()).unwrap_or(u16::MAX).saturating_add(3) + }); + let max_height = frame.area().height / 2; + let height = desired_height.min(max_height); + let [commits, changes] = Layout::vertical([Constraint::Min(0), Constraint::Length(height)]).areas(full_body); body = commits; - message.inner(Margin { - horizontal: 2, - vertical: 1, - }) + ( + changes, + changes.inner(Margin { + horizontal: 2, + vertical: 1, + }), + ) + }); + let commit_pane = app.show_commit.then(|| { + let width = 80.min(full_body.width / 2); + let [commits, message] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]).areas(full_body); + body.width = body.width.min(commits.width); + ( + message, + message.inner(Margin { + horizontal: 2, + vertical: 1, + }), + ) }); app.viewport_rows = body.height as usize; app.ensure_visible(); let start = app.offset.min(app.rows.len()); let end = start.saturating_add(app.viewport_rows).min(app.rows.len()); + let lane_end = start.saturating_add(full_body.height as usize).min(app.rows.len()); let visible_rows = &app.rows[start..end]; let has_verifiable_signatures = visible_rows.iter().enumerate().any(|(index, row)| { !app.is_row_hidden(start + index) && matches!(row.signature, SignatureState::Unverified | SignatureState::Verifying) }); - let lanes = app.render_lanes(start..end); + let lanes = app.render_lanes(start..lane_end); let content = Rect::new( body.x.saturating_add(2), body.y, @@ -98,7 +166,8 @@ pub(crate) fn draw( show_trailers, use_mailmap: app.use_mailmap && !preview_author_copy && copy_feedback != Some(CopyKind::Author), ref_mode, - selected: selected == Some(start + index), + selected: (selected == Some(start + index) && app.show_selection_tail) + || compared_parent == Some(row.id), preview_author_copy, copy_feedback: if selected == Some(start + index) { copy_feedback @@ -110,18 +179,8 @@ pub(crate) fn draw( }) .collect(); let graph_max_offset = max_lane_width.saturating_sub(align_width); - let metadata_max_offset = if align_metadata { - metadata - .iter() - .map(Line::width) - .max() - .unwrap_or_default() - .saturating_sub((content.width as usize).saturating_sub(align_width)) - } else { - 0 - }; let max_offset = if align_metadata { - graph_max_offset.saturating_add(metadata_max_offset) + graph_max_offset } else { lanes .iter() @@ -134,7 +193,6 @@ pub(crate) fn draw( .min(u16::MAX as usize); let horizontal_offset = app.horizontal_offset.min(max_offset); let graph_offset = horizontal_offset.min(graph_max_offset); - let metadata_offset = horizontal_offset.saturating_sub(graph_max_offset); for (index, metadata) in metadata.into_iter().enumerate() { let lane = lanes.lane(index); @@ -142,11 +200,16 @@ pub(crate) fn draw( let selected = app.selected == Some(start + index); let metadata_width = metadata.width(); let signature_color = signature_color(visible_rows[index].signature); - let style = if selected { - color(signature_color).add_modifier(Modifier::REVERSED) + let highlight = if selected && app.show_selection_tail { + Some(signature_color) + } else if compared_parent == Some(visible_rows[index].id) { + Some(COMPARED_PARENT_COLOR) } else { - Style::default() + None }; + let style = highlight.map_or_else(Style::default, |highlight| { + color(highlight).add_modifier(Modifier::REVERSED) + }); frame.render_widget( Paragraph::new(if selected { "> " } else { " " }).style(style), Rect::new(body.x, y, body.width.min(2), 1), @@ -163,7 +226,7 @@ pub(crate) fn draw( row_area, lane, graph_offset, - selected, + highlight, visible_rows[index].signature, ); let aligned = Rect::new( @@ -173,7 +236,7 @@ pub(crate) fn draw( 1, ); frame.render_widget(Clear, aligned); - frame.render_widget(Paragraph::new(metadata).scroll((0, metadata_offset as u16)), aligned); + frame.render_widget(Paragraph::new(metadata), aligned); } else { let mut spans = Vec::with_capacity(metadata.spans.len() + 1); spans.push(Span::styled(lane, style)); @@ -187,7 +250,7 @@ pub(crate) fn draw( row_area, lane, horizontal_offset, - selected, + highlight, visible_rows[index].signature, ); } @@ -219,7 +282,7 @@ pub(crate) fn draw( } if selected && app.show_selection_tail && body.width > 0 { let line_width = if align_metadata { - align_width.saturating_add(metadata_width.saturating_sub(metadata_offset)) + align_width.saturating_add(metadata_width) } else { lane.chars() .count() @@ -248,8 +311,51 @@ pub(crate) fn draw( } } app.set_horizontal_bounds(content.width as usize, max_offset); - if let (Some(area), Some(message)) = (commit_pane, commit_message) { - render_commit_message(frame, area, message); + if let Some((outer, area)) = changes_pane { + frame.render_widget(Clear, outer); + frame.render_widget(Block::new().borders(Borders::TOP), outer); + if let Some(changes) = changes.filter(|changes| changes.is_visible()) { + render_changes(frame, area, changes, app); + let status = Rect::new( + outer.x.saturating_add(2), + outer.bottom().saturating_sub(1), + outer.width.saturating_sub(4), + 1, + ); + let mut spans = Vec::new(); + if let Some(parent) = changes.parent { + spans.extend([ + Span::styled( + format!( + "vs parent {}/{} {}", + parent.index + 1, + parent.total, + parent.id.to_hex_with_len(7) + ), + color(COMPARED_PARENT_COLOR), + ), + Span::raw(" · p next parent · "), + ]); + } + if let Some(error) = &app.diff_error { + spans.push(Span::styled(format!("diff: {error}"), color(Color::Red))); + } else { + spans.push(Span::raw("↑↓/jk move · h/l pan · Enter diff")); + } + spans.push(Span::raw(" · c to hide")); + frame.render_widget(Paragraph::new(Line::from(spans)), status); + } + if !app.changes_focused { + frame + .buffer_mut() + .set_style(outer, Style::default().add_modifier(Modifier::DIM)); + } + } + if let Some((outer, area)) = commit_pane { + frame.render_widget(Clear, outer); + if let Some(message) = commit_message { + render_commit_message(frame, area, message); + } } let status = match app.state { @@ -263,8 +369,18 @@ pub(crate) fn draw( "{} commits{status} · ↑↓/jk move · h/l pan", app.rows.len() ))]; + if app.changes_visible() && changes.is_some_and(Changes::is_visible) { + footer_spans.push(match app.focus_feedback.take() { + Some(destination) => Span::raw(format!(" · Tab → {destination}")), + None => Span::raw(" · Tab switch"), + }); + } + if app.changes_focused { + footer_spans.push(Span::raw(" · q/Esc history")); + } footer_spans.extend([Span::raw(" · "), toggle("[ align", app.align_metadata)]); footer_spans.extend([Span::raw(" · "), toggle("o commit", app.show_commit)]); + footer_spans.extend([Span::raw(" · "), toggle("c changes", app.show_changes)]); if app.has_hidden_filter { footer_spans.extend([ Span::raw(" · "), @@ -313,11 +429,154 @@ pub(crate) fn draw( Span::styled("●", color(Color::Green)), ]); } - if app.state == State::Loading { - footer_spans.push(Span::raw(" · Esc cancel")); + if !app.changes_focused { + if app.state == State::Loading { + footer_spans.push(Span::raw(" · Esc cancel")); + } + footer_spans.push(Span::raw(" · q quit")); } - footer_spans.push(Span::raw(" · q quit")); frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer); + if app.changes_focused { + frame + .buffer_mut() + .set_style(body, Style::default().add_modifier(Modifier::DIM)); + } +} + +fn render_changes(frame: &mut Frame<'_>, area: Rect, changes: &Changes, app: &mut App) { + if !changes.is_visible() || area.height == 0 { + app.set_changes_bounds(0, 0, area.width as usize, 0); + return; + } + let mut summary = Vec::new(); + for kind in [ + ChangeKind::Added, + ChangeKind::Modified, + ChangeKind::Deleted, + ChangeKind::Renamed, + ChangeKind::Copied, + ChangeKind::TypeChanged, + ] { + let count = changes.paths.iter().filter(|change| change.kind == kind).count(); + if count == 0 { + continue; + } + if !summary.is_empty() { + summary.push(Span::raw(" ")); + } + summary.push(Span::styled( + format!("{} = {count}", kind.letter()), + color(change_color(kind)), + )); + } + if !summary.is_empty() { + summary.push(Span::raw(" · ")); + } + summary.extend([ + Span::raw(format!("{} files changed · ", changes.paths.len())), + Span::styled(format!("+{}", changes.lines_added), color(Color::Green)), + Span::raw(" "), + Span::styled(format!("-{}", changes.lines_removed), color(Color::Red)), + ]); + frame.render_widget( + Paragraph::new(Line::from(summary)), + Rect::new(area.x, area.y, area.width, 1), + ); + + let path_capacity = usize::from(area.height.saturating_sub(1)); + let overflow = changes.paths.len() > 1 && changes.paths.len() > path_capacity; + let visible_paths = if overflow { + path_capacity.saturating_sub(1) + } else { + path_capacity.min(changes.paths.len()) + }; + let lines: Vec<_> = changes + .paths + .iter() + .enumerate() + .map(|(index, change)| { + let selected = app.changes_focused && index == app.changes_selected; + let path_style = if selected { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let mut spans = vec![ + Span::styled(change.kind.letter().to_string(), color(change_color(change.kind))), + Span::raw(" "), + ]; + if let Some(source) = &change.source { + spans.extend([ + Span::styled(source.to_str_lossy(), path_style), + Span::styled(" -> ", path_style), + Span::styled(change.path.to_str_lossy(), path_style), + ]); + } else { + spans.push(Span::styled(change.path.to_str_lossy(), path_style)); + } + if selected && let Some((insertions, removals)) = change.lines { + spans.extend([ + Span::raw(" "), + Span::styled(format!("+{insertions}"), color(Color::Green)), + Span::raw(" "), + Span::styled(format!("-{removals}"), color(Color::Red)), + ]); + } + Line::from(spans) + }) + .collect(); + let horizontal_max = lines + .iter() + .map(Line::width) + .max() + .unwrap_or_default() + .saturating_sub(area.width as usize); + app.set_changes_bounds(visible_paths, changes.paths.len(), area.width as usize, horizontal_max); + let path_area = Rect::new( + area.x, + area.y.saturating_add(1), + area.width, + u16::try_from(visible_paths).unwrap_or(u16::MAX), + ); + frame.render_widget( + Paragraph::new(Text::from( + lines + .into_iter() + .skip(app.changes_offset) + .take(visible_paths) + .collect::>(), + )) + .scroll((0, u16::try_from(app.changes_horizontal_offset).unwrap_or(u16::MAX))), + path_area, + ); + let hidden = changes + .paths + .len() + .saturating_sub(app.changes_offset.saturating_add(visible_paths)); + if overflow && hidden > 0 { + frame.render_widget( + Paragraph::new(Line::styled( + format!("… {hidden} {} not shown", if hidden == 1 { "line" } else { "lines" }), + Style::default().add_modifier(Modifier::DIM), + )), + Rect::new( + area.x, + area.bottom().saturating_sub(1), + area.width, + u16::from(area.height > 0), + ), + ); + } +} + +fn change_color(kind: ChangeKind) -> Color { + match kind { + ChangeKind::Added => Color::Green, + ChangeKind::Modified => Color::Yellow, + ChangeKind::Deleted => Color::Red, + ChangeKind::Renamed | ChangeKind::Copied => Color::Cyan, + ChangeKind::TypeChanged => Color::Magenta, + } } fn render_commit_message(frame: &mut Frame<'_>, area: Rect, message: &BStr) { @@ -622,15 +881,15 @@ fn color_graph( area: Rect, graph: &str, offset: usize, - selected: bool, + highlight: Option, signature: SignatureState, ) { for (x, symbol) in graph.chars().skip(offset).take(area.width as usize).enumerate() { if symbol.is_whitespace() { continue; } - let style = if selected { - color(signature_color(signature)).add_modifier(Modifier::REVERSED) + let style = if let Some(highlight) = highlight { + color(highlight).add_modifier(Modifier::REVERSED) } else if symbol == '●' { color(signature_color(signature)) } else { @@ -686,7 +945,7 @@ mod tests { } fn draw(frame: &mut Frame<'_>, app: &mut App, decorations: &Decorations) { - super::draw(frame, app, decorations, &gix::mailmap::Snapshot::default(), None); + super::draw(frame, app, decorations, &gix::mailmap::Snapshot::default(), None, None); } fn complete(app: &mut App) { @@ -697,6 +956,33 @@ mod tests { app.finish_lane_computation(rows, lanes, lane_time); } + #[test] + fn renders_a_colored_file_diff_pager() -> Result<(), Box> { + let diff = BuiltInDiff::new( + "M file".into(), + ["--- a/file", "+++ b/file", "@@ -1 +1 @@", "-old", "+new"] + .into_iter() + .map(Into::into) + .collect(), + ); + let mut terminal = Terminal::new(TestBackend::new(40, 7))?; + + terminal.draw(|frame| draw_file_diff(frame, &diff, 0, 0))?; + + assert_eq!(rendered_line(&terminal, 0).trim(), "M file"); + for (y, color) in [ + (1, Color::Red), + (2, Color::Green), + (3, Color::Cyan), + (4, Color::Red), + (5, Color::Green), + ] { + assert_eq!(terminal.backend().buffer()[(0, y)].fg, color); + } + assert!(rendered_line(&terminal, 6).contains("Enter/q/Esc back")); + Ok(()) + } + #[test] fn renders_grouped_attributions_and_bot_names() -> Result<(), Box> { let mut app = App::new(1); @@ -752,7 +1038,7 @@ mod tests { let mailmap = gix::mailmap::Snapshot::from_bytes(b"Mapped Human Human \n"); - terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None, None))?; let row = rendered_row(&terminal); assert!( @@ -788,7 +1074,7 @@ mod tests { app.update(Action::ToggleTrailers); app.update(Action::ToggleName); - terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None, None))?; let row = rendered_row(&terminal); assert!(row.contains("Codex"), "the first n keeps the primary actor"); assert!( @@ -796,7 +1082,7 @@ mod tests { "the first n hides trailer actors while trailers are enabled" ); app.update(Action::ToggleName); - terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None, None))?; let row = rendered_row(&terminal); assert!(!row.contains("Codex"), "the second n hides the primary actor"); assert!( @@ -805,7 +1091,7 @@ mod tests { ); app.update(Action::ToggleName); app.update(Action::ToggleMailmap); - terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None, None))?; assert!( rendered_row(&terminal).contains("Re: Human"), "m restores original trailer actor names" @@ -918,13 +1204,13 @@ mod tests { )]); let mailmap = gix::mailmap::Snapshot::from_bytes(b"mapped author author \n"); - let mut terminal = Terminal::new(TestBackend::new(150, 2))?; + let mut terminal = Terminal::new(TestBackend::new(180, 2))?; - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; - let footer_text = "1 commits · ↑↓/jk move · h/l pan · [ align · o commit · d date · e emails · n names · m mailmap · t trailers · r refs · y copy · q quit"; + let footer_text = "1 commits · ↑↓/jk move · h/l pan · [ align · o commit · c changes · d date · e emails · n names · m mailmap · t trailers · r refs · y copy · q quit"; let selected_line = "> ● 0101010 (HEAD) 1970-01-01 mapped author subject"; - let mut expected = Buffer::with_lines([format!("{selected_line:<150}"), format!("{footer_text:<150}")]); + let mut expected = Buffer::with_lines([format!("{selected_line:<180}"), format!("{footer_text:<180}")]); for x in 0..11 { expected[(x, 0)].set_style(Style::default().add_modifier(Modifier::REVERSED)); } @@ -965,7 +1251,7 @@ mod tests { app.inline = true; let mut inline_terminal = Terminal::new(TestBackend::new(140, 4))?; - inline_terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + inline_terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!( rendered_line(&inline_terminal, 0).trim().is_empty(), "inline mode separates the commits from preceding content" @@ -993,7 +1279,7 @@ mod tests { ); app.update(Action::ToggleMailmap); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!( rendered_row(&terminal).contains(" author subject"), "m restores the original author name" @@ -1003,7 +1289,7 @@ mod tests { app.update(Action::ToggleDate); app.update(Action::ToggleName); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; let row = rendered_row(&terminal); assert!(!row.contains("1970-01-01"), "d hides the committer date"); assert!( @@ -1016,7 +1302,7 @@ mod tests { assert!(footer_is_dim(&terminal, "n name"), "disabled name is dimmed"); app.update(Action::ToggleName); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!( rendered_row(&terminal).contains("author"), "the second n restores the author name" @@ -1027,7 +1313,7 @@ mod tests { ); app.update(Action::ToggleRefs); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!(!rendered_row(&terminal).contains("HEAD"), "no refs hides regular refs"); assert!( !rendered_row(&terminal).contains("refs/patches"), @@ -1036,7 +1322,7 @@ mod tests { assert!(footer_is_dim(&terminal, "r no refs"), "no refs is dimmed"); app.update(Action::ToggleRefs); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!(rendered_row(&terminal).contains("HEAD"), "all refs shows regular refs"); assert!( rendered_row(&terminal).contains("refs/patches"), @@ -1045,7 +1331,7 @@ mod tests { assert!(!footer_is_dim(&terminal, "r all refs"), "all refs is not dimmed"); app.update(Action::ToggleRefs); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!(rendered_row(&terminal).contains("HEAD"), "refs shows regular refs"); assert!( !rendered_row(&terminal).contains("refs/patches"), @@ -1054,20 +1340,20 @@ mod tests { assert!(!footer_is_dim(&terminal, "r refs"), "refs is not dimmed"); app.has_hidden_filter = true; - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!( rendered_line(&terminal, 1).contains("v show hidden"), "the footer advertises the configured hidden-history toggle" ); app.show_hidden = true; - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!( rendered_line(&terminal, 1).contains("v hide hidden"), "the footer reflects the unfiltered view" ); app.update(Action::PreviewAuthorCopy(true)); - terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None))?; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; let row = rendered_row(&terminal); assert!( row.contains("author subject"), @@ -1176,7 +1462,14 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(2, states.len() as u16))?; terminal.draw(|frame| { for (y, (state, _)) in states.iter().enumerate() { - color_graph(frame, Rect::new(0, y as u16, 2, 1), "●─", 0, true, *state); + color_graph( + frame, + Rect::new(0, y as u16, 2, 1), + "●─", + 0, + Some(signature_color(*state)), + *state, + ); } })?; @@ -1262,6 +1555,7 @@ mod tests { &Decorations::new(), &gix::mailmap::Snapshot::default(), Some(b"subject\n\nbody".as_bstr()), + None, ); })?; assert_eq!( @@ -1296,6 +1590,7 @@ mod tests { &Decorations::new(), &gix::mailmap::Snapshot::default(), Some(b"subject".as_bstr()), + None, ); })?; assert_eq!( @@ -1306,6 +1601,422 @@ mod tests { Ok(()) } + #[test] + fn changing_the_changes_height_keeps_history_alignment_stable() -> Result<(), Box> { + let mut app = App::new(11); + app.extend_commits( + (1..=8) + .map(|n| Commit { + id: gix::ObjectId::Sha1([n; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: format!("subject {n}").into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }) + .collect::>(), + ); + complete(&mut app); + app.set_lane(6, "●──────── "); + let path = crate::app::PathChange { + kind: ChangeKind::Modified, + source: None, + path: "path".into(), + lines: None, + }; + let changes = |len| Changes { + paths: vec![path.clone(); len], + ..Changes::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 12))?; + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes(1)), + ); + })?; + let short = rendered_line(&terminal, 0) + .find("0101010") + .expect("metadata is visible with a short changes pane"); + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes(8)), + ); + })?; + assert_eq!( + rendered_line(&terminal, 0).find("0101010"), + Some(short), + "changes pane height does not move aligned history metadata" + ); + Ok(()) + } + + #[test] + fn shows_changed_paths_in_a_bottom_pane_below_the_summary() -> Result<(), Box> { + let mut app = App::new(6); + app.extend_commits(vec![ + Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: [gix::ObjectId::Sha1([2; 20]), gix::ObjectId::Sha1([3; 20])] + .into_iter() + .collect(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "merge".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }, + Commit { + id: gix::ObjectId::Sha1([2; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "parent".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }, + ]); + complete(&mut app); + let changes = Changes { + parent: None, + paths: vec![ + crate::app::PathChange { + kind: ChangeKind::Added, + source: None, + path: "added".into(), + lines: Some((10, 0)), + }, + crate::app::PathChange { + kind: ChangeKind::Modified, + source: None, + path: "modified".into(), + lines: Some((5, 2)), + }, + crate::app::PathChange { + kind: ChangeKind::Deleted, + source: None, + path: "deleted".into(), + lines: Some((0, 7)), + }, + crate::app::PathChange { + kind: ChangeKind::Renamed, + source: Some("old".into()), + path: "new".into(), + lines: Some((3, 3)), + }, + crate::app::PathChange { + kind: ChangeKind::Copied, + source: Some("source".into()), + path: "copy".into(), + lines: Some((0, 0)), + }, + crate::app::PathChange { + kind: ChangeKind::TypeChanged, + source: None, + path: format!("{}tail", "x".repeat(130)).into(), + lines: Some((24, 5)), + }, + ], + diffs: Vec::new(), + lines_added: 42, + lines_removed: 17, + }; + let mut terminal = Terminal::new(TestBackend::new(120, 16))?; + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + + assert_eq!( + terminal.backend().buffer()[(20, 7)].symbol(), + "─", + "the changes pane starts at the screen's halfway point" + ); + assert!( + terminal.backend().buffer()[(20, 7)].modifier.contains(Modifier::DIM), + "the inactive changes border is dimmed" + ); + assert!( + !terminal.backend().buffer()[(5, 0)].modifier.contains(Modifier::DIM) + && !terminal.backend().buffer()[(2, 15)].modifier.contains(Modifier::DIM), + "the focused history and its status use their normal intensity" + ); + let summary = rendered_line(&terminal, 8); + assert!( + summary.contains("A = 1 M = 1 D = 1 R = 1 C = 1 T = 1 · 6 files changed · +42 -17"), + "the pane starts with nonzero status and line aggregates" + ); + let added_x = summary.find("A = 1").expect("added aggregate is visible") as u16; + let deleted_x = summary.find("D = 1").expect("deleted aggregate is visible") as u16; + assert_eq!(terminal.backend().buffer()[(added_x, 8)].fg, Color::Green); + assert_eq!(terminal.backend().buffer()[(deleted_x, 8)].fg, Color::Red); + assert!( + terminal.backend().buffer()[(added_x, 8)] + .modifier + .contains(Modifier::DIM), + "the inactive summary is dimmed without losing its colors" + ); + assert!( + rendered_line(&terminal, 9).contains("A added"), + "changed paths follow the summary in diff order" + ); + let inactive_path = rendered_line(&terminal, 9); + let inactive_x = inactive_path.find("A added").expect("changed path is visible") as u16; + assert!( + terminal.backend().buffer()[(inactive_x, 9)] + .modifier + .contains(Modifier::DIM) + && terminal.backend().buffer()[(inactive_x + 2, 9)] + .modifier + .contains(Modifier::DIM), + "the inactive change kind and path are dimmed" + ); + assert!( + !rendered_line(&terminal, 9).contains("+10"), + "inactive panes do not display a path selection" + ); + assert!( + rendered_line(&terminal, 13).contains("… 2 lines not shown"), + "the capped pane reports paths that do not fit" + ); + assert!( + rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan"), + "the changes status advertises its navigation keys" + ); + assert!( + terminal.backend().buffer()[(2, 14)].modifier.contains(Modifier::DIM), + "the inactive changes status is dimmed" + ); + assert!(rendered_line(&terminal, 15).contains("Tab switch")); + + app.changes_suppressed = true; + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert!( + !rendered_line(&terminal, 8).contains("files changed"), + "repeated history navigation temporarily hides the changes pane" + ); + assert!( + app.show_changes && !footer_is_dim(&terminal, "c changes"), + "temporary suppression leaves the persistent changes setting enabled" + ); + app.changes_suppressed = false; + + app.update(Action::ToggleChangesFocus); + app.update(Action::MoveDown); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert!( + !terminal.backend().buffer()[(20, 7)].modifier.contains(Modifier::DIM), + "the focused changes border uses its normal style" + ); + assert!( + terminal.backend().buffer()[(5, 0)].modifier.contains(Modifier::DIM) + && !terminal.backend().buffer()[(2, 15)].modifier.contains(Modifier::DIM), + "the inactive history is dimmed without dimming the main status" + ); + assert!(rendered_line(&terminal, 15).contains("Tab → changes")); + assert!(rendered_line(&terminal, 15).contains("q/Esc history")); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert!( + rendered_line(&terminal, 15).contains("Tab switch"), + "focus feedback lasts for one redraw" + ); + assert!( + !terminal.backend().buffer()[(added_x, 8)] + .modifier + .contains(Modifier::DIM) + && !terminal.backend().buffer()[(2, 14)].modifier.contains(Modifier::DIM), + "the focused summary and status use their normal intensity" + ); + let selected = rendered_line(&terminal, 10); + assert!(selected.contains("M modified +5 -2")); + let path_x = selected.find("modified").expect("selected path is visible") as u16; + let kind_x = selected.find("M modified").expect("selected kind is visible") as u16; + let added_x = selected.find("+5").expect("selected additions are visible") as u16; + let removed_x = selected.find("-2").expect("selected removals are visible") as u16; + assert!( + !terminal.backend().buffer()[(kind_x, 10)] + .modifier + .contains(Modifier::DIM) + && !terminal.backend().buffer()[(path_x, 10)] + .modifier + .contains(Modifier::DIM), + "focused paths use their normal intensity" + ); + assert!( + terminal.backend().buffer()[(path_x, 10)] + .modifier + .contains(Modifier::REVERSED), + "the selected filepath is inverted" + ); + assert_eq!(terminal.backend().buffer()[(added_x, 10)].fg, Color::Green); + assert_eq!(terminal.backend().buffer()[(removed_x, 10)].fg, Color::Red); + assert!( + !terminal.backend().buffer()[(added_x, 10)] + .modifier + .contains(Modifier::REVERSED), + "the diff-line suffix keeps its normal background" + ); + assert!( + !rendered_line(&terminal, 9).contains("+10"), + "only the selected path displays its line counts" + ); + assert!(rendered_line(&terminal, 13).contains("… 2 lines not shown")); + assert!(rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan")); + + assert!( + rendered_line(&terminal, 14).contains("Enter diff · c to hide"), + "the visible changes pane advertises how to hide it" + ); + + app.update(Action::Last); + app.update(Action::ScrollRight); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert_eq!(app.changes_horizontal_offset, 20); + assert!( + rendered_line(&terminal, 12).contains("tail"), + "h/l pans long path rows while the summary remains fixed" + ); + assert!( + !rendered_line(&terminal, 13).contains("not shown"), + "the overflow indicator disappears at the end" + ); + + let mut short_terminal = Terminal::new(TestBackend::new(120, 8))?; + short_terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert!( + rendered_line(&short_terminal, 5).contains("… 1 line not shown"), + "the overflow count follows the selected final path when no path row fits" + ); + + let mut merge_changes = changes.clone(); + merge_changes.parent = Some(crate::app::ComparedParent { + index: 0, + total: 2, + id: gix::ObjectId::Sha1([2; 20]), + }); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&merge_changes), + ); + })?; + assert!( + rendered_line(&terminal, 8).starts_with(" A = 1"), + "parent context no longer crowds the aggregate summary" + ); + assert!( + rendered_line(&terminal, 14) + .contains("vs parent 1/2 0202020 · p next parent · ↑↓/jk move · h/l pan · Enter diff · c to hide"), + "merge diffs keep parent controls alongside navigation" + ); + let parent = rendered_line(&terminal, 1); + let disk_x = parent.find('●').expect("the parent disk is visible") as u16; + let hash_x = parent.find("0202020").expect("the parent hash is visible") as u16; + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(disk_x, 1)].fg, COMPARED_PARENT_COLOR); + assert!(buffer[(disk_x, 1)].modifier.contains(Modifier::REVERSED)); + assert!( + buffer[(hash_x, 1)].modifier.contains(Modifier::REVERSED), + "the compared parent's hash is inverted" + ); + assert!( + !rendered_line(&terminal, 15).contains("p next parent"), + "parent cycling is absent from the main status line" + ); + + app.update(Action::ToggleCommit); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(b"subject".as_bstr()), + Some(&changes), + ); + })?; + assert_eq!( + terminal.backend().buffer()[(62, 7)].symbol(), + " ", + "the right commit pane is rendered over the bottom changes pane" + ); + Ok(()) + } + #[test] fn aligns_commit_trailers_and_wraps_only_in_the_value_column() -> Result<(), Box> { let mut terminal = Terminal::new(TestBackend::new(40, 8))?; @@ -1427,6 +2138,7 @@ mod tests { buffer[(23, 1)].modifier.contains(Modifier::REVERSED), "a clipped selection marker uses the right border" ); + let hash_color = buffer[(5, 1)].fg; assert_eq!(app.selected, Some(2), "drawing preserves the global selection"); assert_eq!(app.offset, 1, "drawing preserves the global offset"); @@ -1434,9 +2146,22 @@ mod tests { terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; let buffer = terminal.backend().buffer(); assert!( - buffer[(0, 1)].modifier.contains(Modifier::REVERSED), - "the final frame keeps the left selection marker" + !buffer[(0, 1)].modifier.contains(Modifier::REVERSED | Modifier::DIM), + "the inactive marker has no selection modifiers" ); + assert!( + !buffer[(5, 1)].modifier.contains(Modifier::REVERSED | Modifier::DIM), + "the inactive hash has no selection modifiers" + ); + assert_eq!(buffer[(0, 1)].symbol(), ">", "the inactive row keeps its marker"); + assert_eq!(buffer[(0, 1)].fg, Color::Reset, "the marker uses normal text color"); + assert_eq!( + buffer[(0, 1)].bg, + Color::Reset, + "the marker has no selection background" + ); + assert_eq!(buffer[(5, 1)].fg, hash_color, "the hash returns to its normal color"); + assert_eq!(buffer[(5, 1)].bg, Color::Reset, "the hash has no selection background"); assert!( !buffer[(23, 1)].modifier.contains(Modifier::REVERSED), "the final frame hides the trailing selection marker" @@ -1457,6 +2182,7 @@ mod tests { attributions: 0..0, title: format!("subject {n}").into(), metadata_loaded: true, + has_agent_marker: false, signature: SignatureState::Unsigned, }) .collect::>(), @@ -1537,6 +2263,7 @@ mod tests { attributions: 0..0, title: format!("subject {n}").into(), metadata_loaded: true, + has_agent_marker: false, signature: SignatureState::Unsigned, }; let mut app = App::new(4); @@ -1742,9 +2469,14 @@ mod tests { app.update(Action::ScrollRight); terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!( + terminal.backend().buffer()[(4, 0)].symbol(), + "0", + "l leaves aligned metadata fixed when there is no graph left to pan" + ); assert!( - rendered_row(&terminal).contains("subject-tail"), - "l reveals clipped aligned metadata after graph panning is exhausted" + !rendered_row(&terminal).contains("subject-tail"), + "aligned metadata remains clipped instead of becoming horizontal-scroll content" ); Ok(()) } diff --git a/gix-tix/tests/fixtures/history.sh b/gix-tix/tests/fixtures/history.sh index b053f6040c4..a85ee743cbd 100755 --- a/gix-tix/tests/fixtures/history.sh +++ b/gix-tix/tests/fixtures/history.sh @@ -21,8 +21,10 @@ git switch -q main commit main "2000-01-03T00:00:00" git merge -q --no-edit merged git switch -q -c topic HEAD~1 +# Two files make parallel changed-line aggregation and diff ordering observable. echo topic >topic -git add topic +echo topic-extra >topic-extra +git add topic topic-extra GIT_AUTHOR_DATE="2000-01-04T00:00:00 +0000" GIT_COMMITTER_DATE="2000-01-04T00:00:00 +0000" \ git commit -q --author="Codex " -m topic \ -m "--- agent From 7d8a087e900c597619c9e8b74e70e15912860322 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 14:33:44 +0200 Subject: [PATCH 010/282] feat: enrich commit details in tix Show Git notes in history with a bright-purple [N] marker and render their contents before trailers in the commit view. Load notes lazily for visible commits through the repository notes platform and refresh them with the view. Make overflowing commit messages page-scrollable with PgUp/PgDn and Ctrl-b/ Ctrl-f, clamp offsets when content changes, and show pane-specific navigation status only while scrolling is possible and the pane is focused. Give pane status bars a distinct background without changing the main status line. --- gix-tix/Cargo.toml | 2 +- gix-tix/src/app.rs | 69 +++++++- gix-tix/src/lib.rs | 45 ++++- gix-tix/src/ui.rs | 410 ++++++++++++++++++++++++++++++++++++--------- 4 files changed, 440 insertions(+), 86 deletions(-) diff --git a/gix-tix/Cargo.toml b/gix-tix/Cargo.toml index 595ca6c70d3..99b7666b7e4 100644 --- a/gix-tix/Cargo.toml +++ b/gix-tix/Cargo.toml @@ -25,7 +25,7 @@ sha256 = ["gix/sha256"] [dependencies] anyhow = "1.0.98" crossterm = { version = "0.29.0", features = ["osc52"] } -gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["blob-diff", "mailmap", "parallel", "revision", "command"] } +gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["blob-diff", "mailmap", "notes", "parallel", "revision", "command"] } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } [dev-dependencies] diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index d5f6bfebbbd..8a0bfc7f247 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -237,6 +237,7 @@ pub(crate) struct App { pub rows: Vec, hidden_rows: HashSet, titles: Vec, + notes: HashMap>, graph: Option, attributions: Vec, #[cfg(test)] @@ -264,6 +265,9 @@ pub(crate) struct App { pub(crate) changes_horizontal_offset: usize, pub(crate) changes_parent: usize, pub(crate) diff_error: Option, + pub(crate) commit_offset: usize, + commit_page: usize, + commit_max: usize, changes_page: usize, changes_max: usize, changes_horizontal_page: usize, @@ -292,6 +296,7 @@ impl App { rows: Vec::new(), hidden_rows: HashSet::new(), titles: Vec::new(), + notes: HashMap::new(), graph: None, attributions: Vec::new(), #[cfg(test)] @@ -319,6 +324,9 @@ impl App { changes_horizontal_offset: 0, changes_parent: 0, diff_error: None, + commit_offset: 0, + commit_page: 1, + commit_max: 0, changes_page: 1, changes_max: 0, changes_horizontal_page: 1, @@ -445,6 +453,18 @@ impl App { self.titles[row.title.clone()].as_bstr() } + pub(crate) fn notes_loaded(&self, id: ObjectId) -> bool { + self.notes.contains_key(&id) + } + + pub(crate) fn set_notes(&mut self, id: ObjectId, notes: Vec) { + self.notes.insert(id, notes); + } + + pub(crate) fn notes(&self, id: ObjectId) -> &[BString] { + self.notes.get(&id).map(Vec::as_slice).unwrap_or_default() + } + pub(crate) fn render_lanes(&self, range: Range) -> RenderedLanes { #[cfg(test)] if !self.test_lanes.is_empty() { @@ -491,6 +511,12 @@ impl App { Action::HalfPageDown if self.changes_focused => self.move_changes((self.changes_page / 2).max(1), true), Action::PageUp if self.changes_focused => self.move_changes(self.changes_page, false), Action::PageDown if self.changes_focused => self.move_changes(self.changes_page, true), + Action::PageUp if self.show_commit && self.commit_max > 0 => { + self.commit_offset = self.commit_offset.saturating_sub(self.commit_page); + } + Action::PageDown if self.show_commit && self.commit_max > 0 => { + self.commit_offset = self.commit_offset.saturating_add(self.commit_page).min(self.commit_max); + } Action::HalfPageUp => self.move_selection((self.viewport_rows / 2).max(1), false), Action::HalfPageDown => self.move_selection((self.viewport_rows / 2).max(1), true), Action::PageUp => self.move_selection(self.viewport_rows.max(1), false), @@ -549,7 +575,10 @@ impl App { return vec![Effect::Reload(!self.show_hidden)]; } Action::ToggleAlign => self.align_metadata = !self.align_metadata, - Action::ToggleCommit => self.show_commit = !self.show_commit, + Action::ToggleCommit => { + self.show_commit = !self.show_commit; + self.reset_commit_view(); + } Action::ToggleChanges => { self.focus_feedback = None; self.show_changes = !self.show_changes; @@ -707,6 +736,7 @@ impl App { self.rows = Vec::new(); self.hidden_rows.clear(); self.titles = Vec::new(); + self.notes.clear(); self.graph = None; self.attributions = Vec::new(); #[cfg(test)] @@ -720,6 +750,7 @@ impl App { self.changes_suppressed = false; self.horizontal_offset = 0; self.focus_history(); + self.reset_commit_view(); self.reset_changes_view(); self.follow_tail = false; self.clear_preview_author_copy(); @@ -966,6 +997,17 @@ impl App { self.horizontal_offset = self.horizontal_offset.min(max); } + pub(crate) fn set_commit_bounds(&mut self, page: usize, max: usize) { + self.commit_page = page.max(1); + self.commit_max = max; + self.commit_offset = self.commit_offset.min(max); + } + + pub(crate) fn reset_commit_view(&mut self) { + self.commit_offset = 0; + self.commit_max = 0; + } + pub(crate) fn set_changes_bounds( &mut self, page: usize, @@ -1667,6 +1709,31 @@ mod tests { ); } + #[test] + fn full_pages_target_changes_then_commit_messages_then_history() { + let mut app = App::new(2); + app.extend_commits((1..=5).map(row).collect::>()); + app.show_commit = true; + app.set_commit_bounds(3, 7); + + app.update(Action::PageDown); + assert_eq!(app.commit_offset, 3); + assert_eq!(app.selected, Some(0), "commit paging leaves history selection alone"); + app.update(Action::PageDown); + assert_eq!(app.commit_offset, 6); + + app.changes_focused = true; + app.set_changes_bounds(2, 5, 1, 0); + app.update(Action::PageDown); + assert_eq!(app.changes_selected, 2, "focused changes retain paging priority"); + assert_eq!(app.commit_offset, 6); + + app.changes_focused = false; + app.set_commit_bounds(3, 0); + app.update(Action::PageDown); + assert_eq!(app.selected, Some(2), "history paging resumes when the commit fits"); + } + #[test] fn half_pages_use_half_the_viewport() { let mut app = App::new(4); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 74ce7a073d1..94525eae814 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -464,9 +464,13 @@ fn event_loop( screen, } = options; let repository_path = repository.git_dir().to_owned(); - let mailmap = gix::open(&repository_path) - .context("could not open repository for mailmap")? - .open_mailmap(); + let mut view_repository = gix::open(&repository_path).context("could not open repository for history view")?; + view_repository.object_cache_size(None); + let mailmap = view_repository.open_mailmap(); + let mut notes = view_repository + .notes() + .map_err(gix::Exn::into_error) + .context("could not open Git notes")?; let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); let (mut cancelled, mut receiver) = start_history( repository, @@ -503,6 +507,7 @@ fn event_loop( &mailmap, &authors, &mut fill_repository, + &mut notes, &mut commit_message, &mut changes, &mut line_diff_pool, @@ -563,6 +568,7 @@ fn event_loop( &mailmap, &authors, &mut fill_repository, + &mut notes, &mut commit_message, &mut changes, &mut line_diff_pool, @@ -638,6 +644,7 @@ fn event_loop( &mailmap, &authors, &mut fill_repository, + &mut notes, &mut commit_message, &mut changes, &mut line_diff_pool, @@ -728,6 +735,7 @@ fn event_loop( Effect::Reload(show_hidden) => { cancelled.store(true, Ordering::Relaxed); app.reload(show_hidden); + notes = open_notes(&repository_path)?; decorations.clear(); let hidden = if show_hidden { &[][..] } else { hide.as_slice() }; (cancelled, receiver) = start_history( @@ -791,6 +799,7 @@ fn event_loop( &mailmap, &authors, &mut fill_repository, + &mut notes, &mut commit_message, &mut changes, &mut line_diff_pool, @@ -890,6 +899,7 @@ fn draw( mailmap: &gix::mailmap::Snapshot, authors: &SharedAuthors, fill_repository: &mut FillRepository<'_>, + notes: &mut gix::note::Platform, commit_message: &mut Option<(gix::ObjectId, BString)>, changes: &mut Option<(gix::ObjectId, usize, Changes)>, line_diff_pool: &mut Option, @@ -905,6 +915,23 @@ fn draw( app.ensure_visible(); let start = app.offset.min(app.rows.len()); let end = start.saturating_add(app.viewport_rows).min(app.rows.len()); + for index in start..end { + let id = app.rows[index].id; + if app.notes_loaded(id) { + continue; + } + let loaded = notes + .get(id) + .map_err(gix::Exn::into_error) + .context("could not load visible commit notes")? + .into_iter() + .map(|note| { + let mut blob = note.blob; + BString::from(blob.take_data()) + }) + .collect(); + app.set_notes(id, loaded); + } let changes_visible = app.changes_visible(); let selected = (app.show_commit || changes_visible) .then(|| app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id)) @@ -914,6 +941,9 @@ fn draw( .then_some(selected) .flatten() .filter(|id| commit_message.as_ref().map(|(cached, _)| cached) != Some(id)); + if message_to_load.is_some() { + app.reset_commit_view(); + } if changes_visible && selected.is_some() && changes.as_ref().map(|(cached, _, _)| *cached) != selected { app.changes_parent = 0; } @@ -983,6 +1013,15 @@ fn open_fill_repository(repository_path: &Path) -> Result { Ok(repository) } +fn open_notes(repository_path: &Path) -> Result { + let mut repository = gix::open(repository_path).context("could not open repository for Git notes")?; + repository.object_cache_size(None); + repository + .notes() + .map_err(gix::Exn::into_error) + .context("could not open Git notes") +} + fn prepare_file_diff( repository_path: &Path, change: &gix::object::tree::diff::ChangeDetached, diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 803318bf0fe..f03e9452cc8 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -15,6 +15,7 @@ use crate::{ const COMPARED_PARENT_COLOR: Color = Color::Cyan; const NOTE_COLOR: Color = Color::LightMagenta; +const PANE_STATUS_BACKGROUND: Color = Color::DarkGray; pub(crate) fn draw_file_diff(frame: &mut Frame<'_>, diff: &BuiltInDiff, offset: usize, horizontal_offset: usize) { let [header, body, footer] = @@ -164,6 +165,7 @@ pub(crate) fn draw( show_author_name, show_emails: app.show_emails, show_trailers, + has_notes: !app.notes(row.id).is_empty(), use_mailmap: app.use_mailmap && !preview_author_copy && copy_feedback != Some(CopyKind::Author), ref_mode, selected: (selected == Some(start + index) && app.show_selection_tail) @@ -316,34 +318,39 @@ pub(crate) fn draw( frame.render_widget(Block::new().borders(Borders::TOP), outer); if let Some(changes) = changes.filter(|changes| changes.is_visible()) { render_changes(frame, area, changes, app); - let status = Rect::new( - outer.x.saturating_add(2), - outer.bottom().saturating_sub(1), - outer.width.saturating_sub(4), - 1, - ); - let mut spans = Vec::new(); - if let Some(parent) = changes.parent { - spans.extend([ - Span::styled( - format!( - "vs parent {}/{} {}", - parent.index + 1, - parent.total, - parent.id.to_hex_with_len(7) + if app.changes_focused { + let status = Rect::new( + outer.x.saturating_add(2), + outer.bottom().saturating_sub(1), + outer.width.saturating_sub(4), + 1, + ); + let mut spans = Vec::new(); + if let Some(parent) = changes.parent { + spans.extend([ + Span::styled( + format!( + "vs parent {}/{} {}", + parent.index + 1, + parent.total, + parent.id.to_hex_with_len(7) + ), + color(COMPARED_PARENT_COLOR), ), - color(COMPARED_PARENT_COLOR), - ), - Span::raw(" · p next parent · "), - ]); - } - if let Some(error) = &app.diff_error { - spans.push(Span::styled(format!("diff: {error}"), color(Color::Red))); - } else { - spans.push(Span::raw("↑↓/jk move · h/l pan · Enter diff")); + Span::raw(" · p next parent · "), + ]); + } + if let Some(error) = &app.diff_error { + spans.push(Span::styled(format!("diff: {error}"), color(Color::Red))); + } else { + spans.push(Span::raw("↑↓/jk move · h/l pan · Enter diff")); + } + spans.push(Span::raw(" · c to hide")); + frame.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(PANE_STATUS_BACKGROUND)), + status, + ); } - spans.push(Span::raw(" · c to hide")); - frame.render_widget(Paragraph::new(Line::from(spans)), status); } if !app.changes_focused { frame @@ -353,8 +360,28 @@ pub(crate) fn draw( } if let Some((outer, area)) = commit_pane { frame.render_widget(Clear, outer); - if let Some(message) = commit_message { - render_commit_message(frame, area, message); + let max_offset = if let Some(message) = commit_message { + let notes = app + .selected + .and_then(|index| app.rows.get(index)) + .map(|row| app.notes(row.id)) + .unwrap_or_default(); + render_commit_message(frame, area, message, notes, app.commit_offset) + } else { + 0 + }; + app.set_commit_bounds(area.height as usize, max_offset); + if max_offset > 0 { + frame.render_widget( + Paragraph::new("PgUp/C-b up page · PgDn/C-f down page · o to hide") + .style(Style::default().bg(PANE_STATUS_BACKGROUND)), + Rect::new( + outer.x.saturating_add(2), + outer.bottom().saturating_sub(1), + outer.width.saturating_sub(4), + 1, + ), + ); } } @@ -579,74 +606,128 @@ fn change_color(kind: ChangeKind) -> Color { } } -fn render_commit_message(frame: &mut Frame<'_>, area: Rect, message: &BStr) { +fn render_commit_message(frame: &mut Frame<'_>, area: Rect, message: &BStr, notes: &[BString], offset: usize) -> usize { let parsed = gix::objs::commit::MessageRef::from_bytes(message); - let Some(body) = parsed.body() else { - frame.render_widget( - Paragraph::new(commit_text(parsed.title, None)).wrap(Wrap { trim: false }), - area, - ); - return; - }; let mut body_message = BString::default(); let mut trailers = Vec::new(); - for block in body.message_blocks() { - body_message.extend_from_slice(block.message); - trailers.extend(block.trailers()); + if let Some(body) = parsed.body() { + for block in body.message_blocks() { + body_message.extend_from_slice(block.message); + trailers.extend(block.trailers()); + } } - if trailers.is_empty() || area.width < 3 { - frame.render_widget( - Paragraph::new(commit_text(parsed.title, parsed.body)).wrap(Wrap { trim: false }), + let body_message = body_message.trim_end().as_bstr(); + let body_message = (!body_message.is_empty()).then_some(body_message); + if trailers.is_empty() { + return render_scrolling_paragraph( + frame, area, + Paragraph::new(commit_text(parsed.title, parsed.body, notes)).wrap(Wrap { trim: false }), + offset, ); - return; } let key_width = trailers .iter() .map(|trailer| Line::raw(trailer.token.to_str_lossy()).width()) .max() .unwrap_or_default(); - if key_width > area.width.saturating_sub(3) as usize { - frame.render_widget( - Paragraph::new(commit_text(parsed.title, parsed.body)).wrap(Wrap { trim: false }), - area, - ); - return; + if area.width < 3 || key_width > area.width.saturating_sub(3) as usize { + if notes.is_empty() { + return render_scrolling_paragraph( + frame, + area, + Paragraph::new(commit_text(parsed.title, parsed.body, notes)).wrap(Wrap { trim: false }), + offset, + ); + } + let mut text = commit_text(parsed.title, body_message, notes); + text.lines.push(Line::default()); + for trailer in trailers { + text.lines.extend( + Text::raw(format!( + "{}: {}", + trailer.token.to_str_lossy(), + trailer.value.to_str_lossy() + )) + .lines, + ); + } + return render_scrolling_paragraph(frame, area, Paragraph::new(text).wrap(Wrap { trim: false }), offset); } let key_width = key_width as u16; - let body_message = body_message.trim_end().as_bstr(); - let text = commit_text(parsed.title, (!body_message.is_empty()).then_some(body_message)); + let text = commit_text(parsed.title, body_message, notes); let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); - let mut y = area - .y - .saturating_add(u16::try_from(paragraph.line_count(area.width)).unwrap_or(u16::MAX)) - .saturating_add(1); - frame.render_widget(paragraph, area); - + let body_height = paragraph.line_count(area.width); let value_x = area.x.saturating_add(key_width).saturating_add(2); let value_width = area.right().saturating_sub(value_x); - for trailer in trailers { - if y >= area.bottom() { + let trailers: Vec<_> = trailers + .into_iter() + .map(|trailer| { + let value = Paragraph::new(trailer.value.to_str_lossy()).wrap(Wrap { trim: false }); + let height = value.line_count(value_width).max(1); + (trailer, height) + }) + .collect(); + let total_height = body_height + .saturating_add(1) + .saturating_add(trailers.iter().map(|(_, height)| height).sum::()); + let max_offset = total_height.saturating_sub(area.height as usize).min(u16::MAX as usize); + let offset = offset.min(max_offset); + frame.render_widget(paragraph.scroll((u16::try_from(offset).unwrap_or(u16::MAX), 0)), area); + + let viewport_end = offset.saturating_add(area.height as usize); + let mut start = body_height.saturating_add(1); + for (trailer, height) in trailers { + let end = start.saturating_add(height); + if start >= viewport_end { break; } - let value = Paragraph::new(trailer.value.to_str_lossy()).wrap(Wrap { trim: false }); - let height = u16::try_from(value.line_count(value_width)) - .unwrap_or(u16::MAX) - .max(1) - .min(area.bottom().saturating_sub(y)); - frame.render_widget( - Paragraph::new(format!("{}:", trailer.token.to_str_lossy())) - .style(color(Color::Green)) - .right_aligned(), - Rect::new(area.x, y, key_width.saturating_add(1), 1), - ); - frame.render_widget(value, Rect::new(value_x, y, value_width, height)); - y = y.saturating_add(height); + if end > offset { + let skipped = offset.saturating_sub(start); + let y = area + .y + .saturating_add(u16::try_from(start.saturating_sub(offset)).unwrap_or_default()); + let visible_height = height + .saturating_sub(skipped) + .min(area.bottom().saturating_sub(y) as usize); + if skipped == 0 { + frame.render_widget( + Paragraph::new(format!("{}:", trailer.token.to_str_lossy())) + .style(color(Color::Green)) + .right_aligned(), + Rect::new(area.x, y, key_width.saturating_add(1), 1), + ); + } + let value = Paragraph::new(trailer.value.to_str_lossy()).wrap(Wrap { trim: false }); + frame.render_widget( + value.scroll((u16::try_from(skipped).unwrap_or(u16::MAX), 0)), + Rect::new( + value_x, + y, + value_width, + u16::try_from(visible_height).unwrap_or(u16::MAX), + ), + ); + } + start = end; } + max_offset } -fn commit_text<'a>(title: &'a BStr, body: Option<&'a BStr>) -> Text<'a> { +fn render_scrolling_paragraph(frame: &mut Frame<'_>, area: Rect, paragraph: Paragraph<'_>, offset: usize) -> usize { + let max_offset = paragraph + .line_count(area.width) + .saturating_sub(area.height as usize) + .min(u16::MAX as usize); + frame.render_widget( + paragraph.scroll((u16::try_from(offset.min(max_offset)).unwrap_or(u16::MAX), 0)), + area, + ); + max_offset +} + +fn commit_text<'a>(title: &'a BStr, body: Option<&'a BStr>, notes: &'a [BString]) -> Text<'a> { let mut text = Text::raw(title.to_str_lossy()); for line in &mut text.lines { line.style = Style::default().add_modifier(Modifier::BOLD); @@ -655,6 +736,18 @@ fn commit_text<'a>(title: &'a BStr, body: Option<&'a BStr>) -> Text<'a> { text.lines.push(Line::default()); text.lines.extend(Text::raw(body.to_str_lossy()).lines); } + for note in notes { + text.lines.push(Line::default()); + text.lines.push(Line::from(vec![ + Span::styled("Notes", color(NOTE_COLOR).add_modifier(Modifier::BOLD)), + Span::styled(":", color(NOTE_COLOR)), + ])); + let mut note = Text::raw(note.to_str_lossy()); + for line in &mut note.lines { + line.style = color(NOTE_COLOR); + } + text.lines.extend(note.lines); + } text } @@ -675,6 +768,7 @@ struct MetadataOptions { show_author_name: bool, show_emails: bool, show_trailers: bool, + has_notes: bool, use_mailmap: bool, ref_mode: RefMode, selected: bool, @@ -696,6 +790,7 @@ fn metadata_line<'a>( show_author_name, show_emails, show_trailers, + has_notes, use_mailmap, ref_mode, selected, @@ -831,6 +926,9 @@ fn metadata_line<'a>( if row.has_agent_marker { spans.push(Span::styled("[A] ", color(NOTE_COLOR))); } + if has_notes { + spans.push(Span::styled("[N] ", color(NOTE_COLOR))); + } if !show_emails { spans.push(Span::raw(title.to_str_lossy())); } @@ -1665,6 +1763,85 @@ mod tests { Ok(()) } + #[test] + fn pages_overflowing_commit_messages_and_hides_the_status_when_they_fit() -> Result<(), Box> + { + let mut app = App::new(4); + app.extend_commits(vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }]); + app.update(Action::ToggleCommit); + let message = b"subject\n\none\ntwo\nthree\nfour\nfive\nsix\n\nSigned-off-by: Alice".as_bstr(); + let mut terminal = Terminal::new(TestBackend::new(120, 7))?; + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(message), + None, + ); + })?; + assert!( + rendered_line(&terminal, 5).contains("PgUp/C-b up page · PgDn/C-f down page"), + "overflowing commit messages advertise both full-page key pairs" + ); + assert_eq!( + terminal.backend().buffer()[(62, 5)].bg, + PANE_STATUS_BACKGROUND, + "the commit status has the shared pane-status background" + ); + assert_eq!( + terminal.backend().buffer()[(0, 6)].bg, + Color::Reset, + "the main status keeps its original background" + ); + + app.update(Action::PageDown); + app.update(Action::PageDown); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(message), + None, + ); + })?; + assert!( + rendered_line(&terminal, 4).contains("Alice"), + "the last page reaches aligned trailers" + ); + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(b"subject".as_bstr()), + None, + ); + })?; + assert!( + !rendered_line(&terminal, 5).contains("PgUp"), + "the commit status disappears when all content fits" + ); + assert_eq!(app.commit_offset, 0, "shorter content clamps the old offset"); + Ok(()) + } + #[test] fn shows_changed_paths_in_a_bottom_pane_below_the_summary() -> Result<(), Box> { let mut app = App::new(6); @@ -1805,12 +1982,13 @@ mod tests { "the capped pane reports paths that do not fit" ); assert!( - rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan"), - "the changes status advertises its navigation keys" + !rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan"), + "the unfocused changes status is hidden" ); - assert!( - terminal.backend().buffer()[(2, 14)].modifier.contains(Modifier::DIM), - "the inactive changes status is dimmed" + assert_eq!( + terminal.backend().buffer()[(2, 15)].bg, + Color::Reset, + "the main status keeps its original background" ); assert!(rendered_line(&terminal, 15).contains("Tab switch")); @@ -1851,6 +2029,15 @@ mod tests { !terminal.backend().buffer()[(20, 7)].modifier.contains(Modifier::DIM), "the focused changes border uses its normal style" ); + assert!( + rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan"), + "the focused changes status advertises its navigation keys" + ); + assert_eq!( + terminal.backend().buffer()[(2, 14)].bg, + PANE_STATUS_BACKGROUND, + "the focused changes status uses the shared pane-status background" + ); assert!( terminal.backend().buffer()[(5, 0)].modifier.contains(Modifier::DIM) && !terminal.backend().buffer()[(2, 15)].modifier.contains(Modifier::DIM), @@ -2022,7 +2209,9 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(40, 8))?; let message = b"subject\n\nbody\n\nShort: one two three four five six seven\nCo-authored-by: Alice".as_bstr(); - terminal.draw(|frame| render_commit_message(frame, frame.area(), message))?; + terminal.draw(|frame| { + render_commit_message(frame, frame.area(), message, &[], 0); + })?; assert_eq!( rendered_line(&terminal, 4).find("one"), @@ -2063,7 +2252,7 @@ mod tests { let mut plain_terminal = Terminal::new(TestBackend::new(40, 4))?; plain_terminal.draw(|frame| { - render_commit_message(frame, frame.area(), b"plain subject\n\nplain body".as_bstr()); + render_commit_message(frame, frame.area(), b"plain subject\n\nplain body".as_bstr(), &[], 0); })?; assert!( plain_terminal.backend().buffer()[(0, 0)] @@ -2080,7 +2269,9 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(60, 8))?; let message = b"subject\n\nnot a trailer\nSigned-off-by: Alice\nanother note\nSigned-off-by: Bob".as_bstr(); - terminal.draw(|frame| render_commit_message(frame, frame.area(), message))?; + terminal.draw(|frame| { + render_commit_message(frame, frame.area(), message, &[], 0); + })?; assert!( rendered_line(&terminal, 2).contains("not a trailer") && rendered_line(&terminal, 3).contains("another note"), @@ -2103,6 +2294,62 @@ mod tests { Ok(()) } + #[test] + fn renders_note_markers_and_notes_before_trailers() -> Result<(), Box> { + let id = gix::ObjectId::Sha1([1; 20]); + let mut app = App::new(1); + app.extend_commits(vec![Commit { + id, + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: true, + signature: SignatureState::Unsigned, + }]); + app.set_notes(id, vec!["review note".into()]); + app.selected = None; + let mut history = Terminal::new(TestBackend::new(100, 2))?; + history.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let row = rendered_row(&history); + let agent_x = row.find("[A]").expect("the agent marker is visible") as u16; + let note_x = row.find("[N]").expect("the note marker is visible") as u16; + assert!( + row.contains("[A] [N] subject"), + "agent and note markers precede the title" + ); + assert_eq!(history.backend().buffer()[(agent_x, 0)].fg, Color::LightMagenta); + assert_eq!(history.backend().buffer()[(note_x, 0)].fg, Color::LightMagenta); + + let mut message = Terminal::new(TestBackend::new(40, 9))?; + message.draw(|frame| { + render_commit_message( + frame, + frame.area(), + b"subject\n\nbody\n\nSigned-off-by: Alice".as_bstr(), + &["review note".into()], + 0, + ); + })?; + assert_eq!(rendered_line(&message, 4).trim(), "Notes:"); + assert_eq!(rendered_line(&message, 5).trim(), "review note"); + assert!(rendered_line(&message, 7).contains("Alice"), "trailers follow notes"); + let notes_label = &message.backend().buffer()[(0, 4)]; + assert_eq!(notes_label.fg, NOTE_COLOR); + assert!( + notes_label.modifier.contains(Modifier::BOLD), + "only the Notes label is bold" + ); + assert!( + !message.backend().buffer()[(5, 4)].modifier.contains(Modifier::BOLD) + && !message.backend().buffer()[(0, 5)].modifier.contains(Modifier::BOLD), + "the colon and note body are not bold" + ); + Ok(()) + } + #[test] fn renders_only_the_visible_rows() -> Result<(), Box> { let mut app = App::new(2); @@ -2353,6 +2600,7 @@ mod tests { show_author_name: true, show_emails: false, show_trailers: true, + has_notes: false, use_mailmap: false, ref_mode: RefMode::All, selected: false, From 55baddbfb57d82bba310bc46f64027762ba5d5df Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 3 Aug 2026 15:35:19 +0200 Subject: [PATCH 011/282] feat: refresh tix history when input references move Watch the repository reference stores with native filesystem notifications and extract every direct or symbolic reference used by the view and hidden revspecs. Re-evaluate notifications lazily: traversal-tip changes refresh history, while unrelated reference changes are ignored and visible decoration changes update without retraversal. Defer tip refreshes until the active traversal and lane computation finish, and retain Shift-R only as a fallback when a watcher cannot be established. Keep an append-only cache of discovered commit rows and their complete parent topology. Incremental walks stop at cached commits, decode only newly encountered ODB commits, and derive the current visible and hidden-boundary projection in memory without pruning commits that leave the view. Recompute lanes off-thread and atomically replace the rendered graph so the old frame and selection remain stable until the refreshed graph is ready. Reopen isolated repositories with a small object cache for incremental traversal, preserve notes refresh behavior for manual view toggles, and re-evaluate inline versus alternate-screen sizing after the projected history changes. Treat refs that disappear between filesystem enumeration and reading as transient, while continuing to report malformed or inaccessible refs. Add coverage for symbolic reference discovery, missing-ref races, and cached fast-forward, rewind, and restoration. --- Cargo.lock | 77 +++++++++++++++++ gix-tix/Cargo.toml | 1 + gix-tix/src/app.rs | 191 ++++++++++++++++++++++++++++++++++++---- gix-tix/src/history.rs | 192 ++++++++++++++++++++++++++++++++++++++++- gix-tix/src/lib.rs | 186 ++++++++++++++++++++++++++++++++++----- gix-tix/src/ui.rs | 29 +++++-- 6 files changed, 628 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8bc0c36cdae..94051ba0a1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1340,6 +1340,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -2568,6 +2577,7 @@ dependencies = [ "crossterm", "gix", "gix-testtools", + "notify", "ratatui", ] @@ -3187,6 +3197,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags 2.13.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "insta" version = "1.48.0" @@ -3470,6 +3500,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -3717,6 +3767,33 @@ dependencies = [ "serde", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "ntapi" version = "0.4.3" diff --git a/gix-tix/Cargo.toml b/gix-tix/Cargo.toml index 99b7666b7e4..ca927441ebc 100644 --- a/gix-tix/Cargo.toml +++ b/gix-tix/Cargo.toml @@ -26,6 +26,7 @@ sha256 = ["gix/sha256"] anyhow = "1.0.98" crossterm = { version = "0.29.0", features = ["osc52"] } gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["blob-diff", "mailmap", "notes", "parallel", "revision", "command"] } +notify = "8.2.0" ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } [dev-dependencies] diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 8a0bfc7f247..894780743d8 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -205,6 +205,7 @@ pub(crate) enum Action { ToggleTrailers, ToggleMailmap, ToggleRefs, + Refresh, ToggleHidden, ToggleAlign, ToggleCommit, @@ -235,7 +236,10 @@ pub(crate) enum Effect { #[derive(Debug)] pub(crate) struct App { pub rows: Vec, + all_rows: HashMap, + all_order: Vec, hidden_rows: HashSet, + pending_hidden_rows: Option>, titles: Vec, notes: HashMap>, graph: Option, @@ -288,13 +292,17 @@ pub(crate) struct App { reload_selection: Option, pub(crate) signature_failures: usize, signature_verification_running: bool, + pub(crate) manual_refresh: bool, } impl App { pub fn new(viewport_rows: usize) -> Self { App { rows: Vec::new(), + all_rows: HashMap::new(), + all_order: Vec::new(), hidden_rows: HashSet::new(), + pending_hidden_rows: None, titles: Vec::new(), notes: HashMap::new(), graph: None, @@ -347,6 +355,7 @@ impl App { reload_selection: None, signature_failures: 0, signature_verification_running: false, + manual_refresh: false, } } @@ -358,29 +367,15 @@ impl App { } pub(crate) fn extend_commits(&mut self, commits: impl Into) { - let LoadedCommits { rows, attributions } = commits.into(); - if self.state != State::Loading || rows.is_empty() { + let commits = commits.into(); + if self.state != State::Loading || commits.rows.is_empty() { return; } + let rows = self.store_commits(commits); let was_empty = self.rows.is_empty(); - self.titles.reserve(rows.iter().map(|row| row.title.len()).sum()); - let attribution_base = self.attributions.len(); - self.attributions.extend(attributions); self.rows.reserve(rows.len()); for row in rows { - let start = self.titles.len(); - self.titles.extend_from_slice(&row.title); - self.rows.push(Commit { - id: row.id, - parent_ids: row.parent_ids, - committer_time: row.committer_time, - author: row.author, - attributions: attribution_base + row.attributions.start..attribution_base + row.attributions.end, - title: start..self.titles.len(), - metadata_loaded: row.metadata_loaded, - has_agent_marker: row.has_agent_marker, - signature: row.signature, - }); + self.rows.push(row); } if was_empty { self.estimated_lane_width = estimate_lane_width(&self.rows[..self.viewport_rows.min(self.rows.len())]); @@ -405,6 +400,34 @@ impl App { } } + fn store_commits(&mut self, commits: LoadedCommits) -> Vec { + let LoadedCommits { rows, attributions } = commits; + self.titles.reserve(rows.iter().map(|row| row.title.len()).sum()); + let attribution_base = self.attributions.len(); + self.attributions.extend(attributions); + rows.into_iter() + .map(|row| { + let start = self.titles.len(); + self.titles.extend_from_slice(&row.title); + let row = Commit { + id: row.id, + parent_ids: row.parent_ids, + committer_time: row.committer_time, + author: row.author, + attributions: attribution_base + row.attributions.start..attribution_base + row.attributions.end, + title: start..self.titles.len(), + metadata_loaded: row.metadata_loaded, + has_agent_marker: row.has_agent_marker, + signature: row.signature, + }; + if self.all_rows.insert(row.id, row.clone()).is_none() { + self.all_order.push(row.id); + } + row + }) + .collect() + } + pub(crate) fn extend_hidden_commits(&mut self, commits: impl Into) { let commits = commits.into(); self.hidden_rows.extend(commits.rows.iter().map(|row| row.id)); @@ -446,6 +469,7 @@ impl App { row.metadata_loaded = true; row.has_agent_marker = has_agent_marker; row.signature = signature; + self.all_rows.insert(row.id, row.clone()); } pub(crate) fn title(&self, row: &CommitRow) -> &BStr { @@ -569,6 +593,9 @@ impl App { RefMode::None => RefMode::All, }; } + Action::Refresh if self.manual_refresh && matches!(self.state, State::Complete | State::Cancelled) => { + return vec![Effect::Reload(self.show_hidden)]; + } Action::ToggleHidden if self.has_hidden_filter && matches!(self.state, State::Complete | State::Cancelled) => { @@ -683,6 +710,61 @@ impl App { } } + pub(crate) fn known_ids(&self) -> HashSet { + self.all_rows.keys().copied().collect() + } + + pub(crate) fn hidden_ids(&self) -> HashSet { + self.hidden_rows.clone() + } + + pub(crate) fn start_refresh( + &mut self, + commits: LoadedCommits, + view_tips: &[ObjectId], + hidden_tips: &[ObjectId], + ) -> Option> { + drop(self.store_commits(commits)); + + let visible = self.reachable_from(view_tips); + let hidden = self.reachable_from(hidden_tips); + let visible: HashSet<_> = visible.difference(&hidden).copied().collect(); + let boundary: HashSet<_> = if hidden_tips.is_empty() { + HashSet::new() + } else { + visible + .iter() + .filter_map(|id| self.all_rows.get(id)) + .flat_map(|row| row.parent_ids.iter().copied()) + .filter(|id| !visible.contains(id)) + .collect() + }; + let rows: Vec<_> = self + .all_order + .iter() + .filter(|id| visible.contains(*id) || boundary.contains(*id)) + .filter_map(|id| self.all_rows.get(id).cloned()) + .collect(); + self.pending_hidden_rows = Some(boundary); + self.state = State::Computing; + self.follow_tail = false; + Some(rows) + } + + fn reachable_from(&self, tips: &[ObjectId]) -> HashSet { + let mut reachable = HashSet::new(); + let mut pending = tips.to_vec(); + while let Some(id) = pending.pop() { + if !reachable.insert(id) { + continue; + } + if let Some(row) = self.all_rows.get(&id) { + pending.extend(row.parent_ids.iter().copied()); + } + } + reachable + } + pub(crate) fn finish_lane_computation(&mut self, rows: Vec, graph: Graph, lane_time: Duration) { if self.state != State::Computing { return; @@ -710,6 +792,9 @@ impl App { HashMap::new() }; self.rows = rows; + if let Some(hidden) = self.pending_hidden_rows.take() { + self.hidden_rows = hidden; + } for row in &mut self.rows { if let Some(metadata) = metadata.get(&row.id) { row.committer_time = metadata.committer_time; @@ -723,7 +808,9 @@ impl App { } self.graph = Some(graph); self.lane_time = Some(lane_time); - self.selected = selected.and_then(|id| self.rows.iter().position(|row| row.id == id)); + self.selected = selected + .and_then(|id| self.rows.iter().position(|row| row.id == id)) + .or_else(|| self.first_selectable()); self.state = State::Complete; if self.reachability_anchor.is_some() { self.compute_reachable_rows(); @@ -731,10 +818,14 @@ impl App { self.ensure_visible(); } + #[cfg(test)] pub(crate) fn reload(&mut self, show_hidden: bool) { self.reload_selection = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); self.rows = Vec::new(); + self.all_rows.clear(); + self.all_order.clear(); self.hidden_rows.clear(); + self.pending_hidden_rows = None; self.titles = Vec::new(); self.notes.clear(); self.graph = None; @@ -770,6 +861,7 @@ impl App { failed += 1; SignatureState::Failed }; + self.all_rows.insert(row.id, row.clone()); } self.signature_verification_running = false; self.signature_failures = failed; @@ -1439,6 +1531,45 @@ mod tests { ); } + #[test] + fn refresh_projects_from_an_append_only_commit_cache() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(3, &[2]), row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + + let rows = app + .start_refresh(vec![row_with_parents(4, &[3])].into(), &[id(4)], &[]) + .expect("a refresh computes lanes"); + assert_eq!( + app.rows.len(), + 3, + "the current frame stays intact while lanes are computed" + ); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert_eq!( + app.rows.iter().map(|row| row.id).collect::>(), + [id(4), id(3), id(2), id(1)] + ); + + let rows = app + .start_refresh(Vec::::new().into(), &[id(2)], &[]) + .expect("a rewind reprojects cached topology"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert_eq!(app.rows.iter().map(|row| row.id).collect::>(), [id(2), id(1)]); + + let rows = app + .start_refresh(Vec::::new().into(), &[id(4)], &[]) + .expect("a fast-forward to retained commits needs no new objects"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + assert_eq!( + app.rows.iter().map(|row| row.id).collect::>(), + [id(4), id(3), id(2), id(1)] + ); + } + #[test] fn lane_computation_keeps_provisional_rows_interactive() { let mut app = App::new(2); @@ -1908,6 +2039,28 @@ mod tests { assert_eq!(app.update(Action::ToggleHidden), vec![Effect::Reload(false)]); } + #[test] + fn refresh_reloads_only_finished_history() { + let mut app = App::new(1); + app.manual_refresh = true; + assert!( + app.update(Action::Refresh).is_empty(), + "a running walk cannot be replaced" + ); + + app.extend_commits(vec![row(1)]); + complete(&mut app); + assert_eq!(app.update(Action::Refresh), vec![Effect::Reload(false)]); + + app.show_hidden = true; + app.state = State::Cancelled; + assert_eq!( + app.update(Action::Refresh), + vec![Effect::Reload(true)], + "refresh preserves the hidden-history setting" + ); + } + #[test] fn reload_retains_selection_or_falls_back_to_the_top() { let mut app = App::new(3); diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index b052d6c0546..e33550a5793 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -36,6 +36,21 @@ pub(crate) enum DecorationKind { } pub(crate) type Decorations = HashMap>; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RefSnapshot { + pub view: HashMap, + pub hidden: HashMap, + pub view_tips: Vec, + pub hidden_tips: Vec, +} + +#[derive(Debug)] +pub(crate) struct Refresh { + pub refs: RefSnapshot, + pub decorations: Decorations, + pub commits: LoadedCommits, +} #[derive(Default)] pub(crate) struct Authors { strings: HashSet<&'static [u8]>, @@ -180,6 +195,126 @@ pub(crate) fn load( Ok(()) } +pub(crate) fn snapshot(repo: &gix::Repository, revisions: &[OsString], hidden: &[OsString]) -> Result { + Ok(RefSnapshot { + view: referenced_refs(repo, revisions)?, + hidden: referenced_refs(repo, hidden)?, + view_tips: resolve_tips(repo, revisions)?.unwrap_or_default(), + hidden_tips: resolve_revisions(repo, hidden, "hidden ")?, + }) +} + +pub(crate) fn refresh( + repo: &gix::Repository, + revisions: &[OsString], + hidden_revisions: &[OsString], + known: &HashSet, + expand: &HashSet, + authors: &SharedAuthors, +) -> Result { + let refs = snapshot(repo, revisions, hidden_revisions)?; + let mut tips = refs.view_tips.clone(); + tips.extend(refs.hidden_tips.iter().copied()); + tips.extend(expand.iter().copied()); + let mut rows = Vec::new(); + let mut attributions = Vec::new(); + if !tips.is_empty() { + let walk = repo + .rev_walk(tips) + .sorting(gix::revision::walk::Sorting::ByCommitTime(Default::default())) + .selected(|id| !known.contains(id) || expand.contains(id)) + .context("could not start incremental revision walk")?; + for info in walk { + let info = info.context("could not refresh revision history")?; + if known.contains(&info.id) { + continue; + } + let metadata = if info.generation.is_some() { + None + } else { + let object = info.object().context("could not read commit")?; + let mut authors = gix::features::threading::lock(authors); + Some(decode_metadata(object.iter(), &mut authors, &mut attributions)?) + }; + let metadata_loaded = metadata.is_some(); + let Metadata { + committer_time, + author, + attributions: row_attributions, + title, + has_agent_marker, + signature, + } = metadata.unwrap_or_else(|| Metadata { + committer_time: Default::default(), + author: &EMPTY_AUTHOR, + attributions: 0..0, + title: BString::default(), + has_agent_marker: false, + signature: SignatureState::Unsigned, + }); + rows.push(Commit { + id: info.id, + parent_ids: info.parent_ids, + committer_time, + author, + attributions: row_attributions, + title, + metadata_loaded, + has_agent_marker, + signature, + }); + } + } + Ok(Refresh { + refs, + decorations: decorations(repo)?, + commits: LoadedCommits { rows, attributions }, + }) +} + +fn referenced_refs(repo: &gix::Repository, revisions: &[OsString]) -> Result> { + let implicit_head = OsString::from("HEAD"); + let revisions = if revisions.is_empty() { + std::slice::from_ref(&implicit_head) + } else { + revisions + }; + let mut out = HashMap::new(); + for revision in revisions { + let revision = gix::path::os_str_into_bstr(revision) + .with_context(|| format!("revision {} is not valid UTF-8", revision.to_string_lossy()))?; + let spec = repo + .rev_parse(revision) + .with_context(|| format!("could not parse revision {revision}"))?; + for reference in [spec.first_reference(), spec.second_reference()].into_iter().flatten() { + insert_ref_chain(repo, reference.name.as_bstr(), &mut out)?; + } + } + Ok(out) +} + +fn insert_ref_chain(repo: &gix::Repository, name: &BStr, out: &mut HashMap) -> Result<()> { + let mut name = name.to_owned(); + loop { + if out.contains_key(&name) { + return Ok(()); + } + let reference = match repo.try_find_reference(name.as_bstr()) { + Ok(reference) => reference, + Err(err) if is_missing_ref(&err) => return Ok(()), + Err(err) => return Err(err).with_context(|| format!("could not read reference {name}")), + }; + let Some(reference) = reference else { + return Ok(()); + }; + let target = reference.target().into_owned(); + let next = target.try_name().map(|name| name.as_bstr().to_owned()); + out.insert(name, target); + let Some(next) = next else { return Ok(()) }; + name = next; + } +} + pub(crate) fn load_metadata( repo: &gix::Repository, id: ObjectId, @@ -354,7 +489,7 @@ impl Authors { } } -fn decorations(repo: &gix::Repository) -> Result { +pub(crate) fn decorations(repo: &gix::Repository) -> Result { let mut out = Decorations::new(); for reference in repo .references() @@ -362,7 +497,11 @@ fn decorations(repo: &gix::Repository) -> Result { .all() .context("could not iterate references")? { - let mut reference = reference.map_err(|err| anyhow::anyhow!("could not read reference: {err}"))?; + let mut reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => return Err(anyhow::anyhow!("could not read reference: {err}")), + }; let mut kind = decoration_kind(reference.name().as_bstr()); if kind == DecorationKind::Tag { let annotated = match reference.try_id() { @@ -397,6 +536,19 @@ fn decorations(repo: &gix::Repository) -> Result { Ok(out) } +fn is_missing_ref(mut err: &(dyn std::error::Error + 'static)) -> bool { + loop { + if err + .downcast_ref::() + .is_some_and(|err| err.kind() == std::io::ErrorKind::NotFound) + { + return true; + } + let Some(source) = err.source() else { return false }; + err = source; + } +} + fn decoration_kind(name: &[u8]) -> DecorationKind { if name.starts_with(b"refs/heads/") { DecorationKind::Local @@ -439,6 +591,22 @@ mod tests { Ok(events) } + #[test] + fn only_missing_ref_reads_are_ignored() { + let ref_error = |kind| gix::refs::file::iter::loose_then_packed::Error::ReadFileContents { + source: std::io::Error::from(kind), + path: "refs/heads/racing".into(), + }; + assert!( + is_missing_ref(&ref_error(std::io::ErrorKind::NotFound)), + "a ref removed after iteration began is transient" + ); + assert!( + !is_missing_ref(&ref_error(std::io::ErrorKind::PermissionDenied)), + "unrelated ref read errors remain actionable" + ); + } + #[test] fn walks_the_same_reachable_set_as_git_for_multiple_tips() -> gix_testtools::Result { let fixture = fixture()?; @@ -515,6 +683,26 @@ mod tests { assert!(!contains_agent_marker(b"subject\n\nagent")); } + #[test] + fn snapshots_references_and_symbolic_targets_from_revisions() -> gix_testtools::Result { + let fixture = fixture()?; + let repo = gix::open(fixture)?; + let implicit = snapshot(&repo, &[], &[])?; + assert!( + implicit.view.contains_key(b"HEAD".as_bstr()), + "an implicit revision watches HEAD" + ); + assert!( + implicit.view.contains_key(b"refs/heads/main".as_bstr()), + "the symbolic target of HEAD is watched as well" + ); + + let explicit = snapshot(&repo, &[OsString::from("main")], &[OsString::from("topic")])?; + assert!(explicit.view.contains_key(b"refs/heads/main".as_bstr())); + assert!(explicit.hidden.contains_key(b"refs/heads/topic".as_bstr())); + Ok(()) + } + #[test] fn decodes_commits_missing_from_a_stale_graph_and_defers_graph_commits() -> gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 94525eae814..715da9bdf81 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -38,6 +38,7 @@ use gix::{ prelude::TreeDiffChangeExt, }; use history::{Authors, Decorations, Event, SharedAuthors}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use ratatui::{TerminalOptions, Viewport, backend::CrosstermBackend, text::Line}; const EVENT_BATCH_SIZE: usize = 256; @@ -45,6 +46,7 @@ const OBJECT_CACHE_SIZE: usize = 4 * 1024 * 1024; const FRAME_INTERVAL: Duration = Duration::from_nanos(16_666_667); const REPEAT_IDLE: Duration = Duration::from_millis(75); const IMMEDIATE_PAGER_EXIT: Duration = Duration::from_millis(250); +const REF_EVENT_INTERVAL: Duration = Duration::from_millis(250); struct FillRepository<'a> { path: &'a Path, @@ -464,6 +466,7 @@ fn event_loop( screen, } = options; let repository_path = repository.git_dir().to_owned(); + let common_dir = repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()); let mut view_repository = gix::open(&repository_path).context("could not open repository for history view")?; view_repository.object_cache_size(None); let mailmap = view_repository.open_mailmap(); @@ -472,7 +475,9 @@ fn event_loop( .map_err(gix::Exn::into_error) .context("could not open Git notes")?; let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); - let (mut cancelled, mut receiver) = start_history( + let mut ref_snapshot = history::snapshot(&view_repository, &revisions, &hide)?; + let (mut ref_watcher, ref_events) = start_ref_watcher(&repository_path, &common_dir); + let (cancelled, receiver) = start_history( repository, &revisions, &hide, @@ -480,7 +485,11 @@ fn event_loop( ); let mut app = App::new(1); + app.manual_refresh = ref_watcher.is_none(); let mut lane_receiver = None; + let mut refresh_receiver: Option>> = None; + let mut refresh_pending = false; + let mut refresh_expand_hidden = false; let mut verification_receiver = None; let mut commit_message = None; let mut changes = None; @@ -517,9 +526,21 @@ fn event_loop( let mut urgent = false; let mut inline_terminal = None; let mut history_requires_alternate_screen = false; + let mut resize_inline_pending = false; + let mut history_finished = false; let mut focused = true; let mut repeat_deadline: Option = None; let result: Result> = (|| loop { + while let Ok(event) = ref_events.try_recv() { + match event { + Ok(event) if !matches!(event.kind, notify::EventKind::Access(_)) => refresh_pending = true, + Ok(_) => {} + Err(_) => { + ref_watcher = None; + app.manual_refresh = true; + } + } + } if repeat_deadline.is_some_and(|deadline| Instant::now() >= deadline) { repeat_deadline = None; if app.changes_suppressed { @@ -549,6 +570,9 @@ fn event_loop( Ok((rows, graph, lane_time)) => { app.finish_lane_computation(rows, graph, lane_time); lane_receiver = None; + history_requires_alternate_screen = + history_needs_alternate_screen(screen, terminal::size()?.1, app.rows.len()); + resize_inline_pending = true; dirty = true; if quit_on_finish { return Ok(app.lane_time); @@ -560,6 +584,63 @@ fn event_loop( } } } + if let Some(result) = refresh_receiver.as_ref().map(mpsc::Receiver::try_recv) { + match result { + Ok(result) => { + let result = result?; + decorations = result.decorations; + let hidden_tips = if app.show_hidden { + &[][..] + } else { + result.refs.hidden_tips.as_slice() + }; + if let Some(rows) = app.start_refresh(result.commits, &result.refs.view_tips, hidden_tips) { + lane_receiver = Some(start_lane_worker(rows)); + } + refresh_receiver = None; + dirty = true; + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => anyhow::bail!("history refresh worker stopped unexpectedly"), + } + } + if refresh_pending + && refresh_receiver.is_none() + && lane_receiver.is_none() + && matches!(app.state, State::Complete | State::Cancelled) + { + let repository = gix::open_opts(&repository_path, gix::open::Options::isolated()) + .context("could not inspect changed references")?; + let next = history::snapshot(&repository, &revisions, &hide)?; + let hidden_changed = next.hidden != ref_snapshot.hidden; + let tips_changed = next.view != ref_snapshot.view || hidden_changed; + ref_snapshot = next; + refresh_pending = false; + if tips_changed || refresh_expand_hidden { + let hidden = if app.show_hidden { Vec::new() } else { hide.clone() }; + let expand = if refresh_expand_hidden || hidden_changed { + app.hidden_ids() + } else { + Default::default() + }; + refresh_receiver = Some(start_history_refresh( + repository_path.clone(), + revisions.clone(), + hidden, + app.known_ids(), + expand, + gix::features::threading::OwnShared::clone(&authors), + )); + refresh_expand_hidden = false; + app.state = State::Loading; + } else { + let next = history::decorations(&repository)?; + if visible_decorations_changed(&decorations, &next, &app.rows) { + decorations = next; + dirty = true; + } + } + } if urgent { draw( terminal, @@ -583,16 +664,11 @@ fn event_loop( continue; } let mut events = 0; - let mut resize_inline = false; - while events < EVENT_BATCH_SIZE { + let mut resize_inline = std::mem::take(&mut resize_inline_pending); + while !history_finished && events < EVENT_BATCH_SIZE { let message = match receiver.try_recv() { Ok(message) => message, Err(mpsc::TryRecvError::Empty) => break, - Err(mpsc::TryRecvError::Disconnected) - if matches!(app.state, State::Computing | State::Complete | State::Cancelled) => - { - break; - } Err(mpsc::TryRecvError::Disconnected) => { anyhow::bail!("history worker stopped unexpectedly") } @@ -614,6 +690,7 @@ fn event_loop( } } Event::Complete => { + history_finished = true; resize_inline = true; history_requires_alternate_screen = history_needs_alternate_screen(screen, terminal::size()?.1, app.rows.len()); @@ -621,7 +698,10 @@ fn event_loop( lane_receiver = Some(start_lane_worker(rows)); } } - Event::Cancelled => drop(app.update(Action::Cancelled)), + Event::Cancelled => { + history_finished = true; + drop(app.update(Action::Cancelled)); + } } } sync_screen( @@ -653,7 +733,13 @@ fn event_loop( dirty = false; } let repeat_timeout = repeat_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); - let terminal_event = match poll_timeout(streaming, events, dirty, last_draw.elapsed(), repeat_timeout) { + let watcher_timeout = ref_watcher.as_ref().map(|_| REF_EVENT_INTERVAL); + let wake_after = match (repeat_timeout, watcher_timeout) { + (Some(repeat), Some(watcher)) => Some(repeat.min(watcher)), + (Some(timeout), None) | (None, Some(timeout)) => Some(timeout), + (None, None) => None, + }; + let terminal_event = match poll_timeout(streaming, events, dirty, last_draw.elapsed(), wake_after) { Some(timeout) if event::poll(timeout)? => Some(event::read()?), Some(_) => None, None => Some(event::read()?), @@ -733,18 +819,10 @@ fn event_loop( execute!(terminal.backend_mut(), CopyToClipboard::to_clipboard_from(actor))?; } Effect::Reload(show_hidden) => { - cancelled.store(true, Ordering::Relaxed); - app.reload(show_hidden); + app.show_hidden = show_hidden; notes = open_notes(&repository_path)?; - decorations.clear(); - let hidden = if show_hidden { &[][..] } else { hide.as_slice() }; - (cancelled, receiver) = start_history( - gix::ThreadSafeRepository::open_opts(&repository_path, gix::open::Options::isolated()) - .context("could not reopen repository for history reload")?, - &revisions, - hidden, - gix::features::threading::OwnShared::clone(&authors), - ); + refresh_pending = true; + refresh_expand_hidden = true; } Effect::OpenDiff(index) => { let result = changes @@ -891,6 +969,60 @@ fn start_history( (cancelled, receiver) } +fn start_history_refresh( + repository_path: PathBuf, + revisions: Vec, + hidden_revisions: Vec, + known: std::collections::HashSet, + expand: std::collections::HashSet, + authors: SharedAuthors, +) -> mpsc::Receiver> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let result = gix::open_opts(repository_path, gix::open::Options::isolated()) + .context("could not reopen repository for history refresh") + .and_then(|mut repository| { + repository.object_cache_size_if_unset(OBJECT_CACHE_SIZE); + history::refresh(&repository, &revisions, &hidden_revisions, &known, &expand, &authors) + }); + let _ = sender.send(result); + }); + receiver +} + +fn start_ref_watcher( + git_dir: &Path, + common_dir: &Path, +) -> ( + Option, + mpsc::Receiver>, +) { + let (sender, receiver) = mpsc::channel(); + let Ok(mut watcher) = notify::recommended_watcher(move |event| { + let _ = sender.send(event); + }) else { + return (None, receiver); + }; + let mut roots = vec![(common_dir.to_owned(), RecursiveMode::NonRecursive)]; + if git_dir != common_dir { + roots.push((git_dir.to_owned(), RecursiveMode::NonRecursive)); + } + for root in [common_dir.join("refs"), git_dir.join("refs")] { + if root.is_dir() && !roots.iter().any(|(path, _)| path == &root) { + roots.push((root, RecursiveMode::Recursive)); + } + } + if roots.into_iter().all(|(path, mode)| watcher.watch(&path, mode).is_ok()) { + (Some(watcher), receiver) + } else { + (None, receiver) + } +} + +fn visible_decorations_changed(old: &Decorations, new: &Decorations, rows: &[CommitRow]) -> bool { + rows.iter().any(|row| old.get(&row.id) != new.get(&row.id)) +} + #[expect(clippy::too_many_arguments, reason = "drawing needs the complete view state")] fn draw( terminal: &mut ratatui::DefaultTerminal, @@ -1520,6 +1652,8 @@ fn action(key: KeyEvent) -> Option { KeyCode::Char('n') => Some(Action::ToggleName), KeyCode::Char('t') => Some(Action::ToggleTrailers), KeyCode::Char('m') => Some(Action::ToggleMailmap), + KeyCode::Char('R') => Some(Action::Refresh), + KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Refresh), KeyCode::Char('r') => Some(Action::ToggleRefs), KeyCode::Char('s') => Some(Action::VerifySignatures), KeyCode::Char('v') => Some(Action::ToggleHidden), @@ -1933,6 +2067,16 @@ mod tests { action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), Some(Action::ToggleRefs) ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::SHIFT)), + Some(Action::Refresh), + "terminals which preserve lowercase shifted letters map Shift-R to refresh" + ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE)), + Some(Action::Refresh), + "terminals which encode Shift-R as an uppercase letter map it to refresh" + ); assert_eq!( action(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)), Some(Action::ToggleHidden) diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index f03e9452cc8..0267c0b4752 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -432,12 +432,20 @@ pub(crate) fn draw( for (label, enabled) in [("m mailmap", app.use_mailmap), ("t trailers", app.show_trailers)] { footer_spans.extend([Span::raw(" · "), toggle(label, enabled)]); } - let ref_label = match app.ref_mode { - RefMode::All => "r all refs", - RefMode::Default => "r refs", - RefMode::None => "r no refs", - }; - footer_spans.extend([Span::raw(" · "), toggle(ref_label, app.ref_mode != RefMode::None)]); + footer_spans.push(Span::raw(" · ")); + if app.preview_author_copy && app.manual_refresh { + footer_spans.push(toggle( + "R refresh", + matches!(app.state, State::Complete | State::Cancelled), + )); + } else { + let ref_label = match app.ref_mode { + RefMode::All => "r all refs", + RefMode::Default => "r refs", + RefMode::None => "r no refs", + }; + footer_spans.push(toggle(ref_label, app.ref_mode != RefMode::None)); + } footer_spans.push(Span::raw(if app.preview_author_copy { " · Y copy author" } else { @@ -1450,6 +1458,7 @@ mod tests { "the footer reflects the unfiltered view" ); + app.manual_refresh = true; app.update(Action::PreviewAuthorCopy(true)); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; let row = rendered_row(&terminal); @@ -1470,6 +1479,14 @@ mod tests { rendered_line(&terminal, 1).contains("Y copy author"), "the footer previews the shifted shortcut" ); + assert!( + rendered_line(&terminal, 1).contains("R refresh"), + "the footer previews the shifted refresh shortcut" + ); + assert!( + !rendered_line(&terminal, 1).contains("r refs"), + "the shifted refresh shortcut replaces the reference toggle" + ); Ok(()) } From 7d802a6f1b2dbc3fd1b9d44cb81ebc7ecbcdae05 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 4 Aug 2026 14:59:47 +0200 Subject: [PATCH 012/282] feat: show contextual information beside the tix selection Reserve space before the history selection tail for compact information about the selected commit. Reuse bright tree-change line counts, and for commits pointed to by refs show one deterministic upstream ahead/behind relation or a visible-ancestry count when hidden history exists. Resolve fetch tracking branches through gix, use commit-graph-aware counts, cache results by commit and upstream targets, invalidate them with reference or projected-history changes, and reuse the navigation repository and object cache during repeated movement. Keep blank margins around contextual information and the right-hand selection marker even when clipped. Clear the marker cell before applying inversion so row text is never inverted accidentally. --- gix-tix/src/app.rs | 50 +++++++++++ gix-tix/src/lib.rs | 211 +++++++++++++++++++++++++++++++++++++++++++-- gix-tix/src/ui.rs | 185 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 436 insertions(+), 10 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 894780743d8..4769b3006f6 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -96,6 +96,12 @@ pub(crate) struct ComparedParent { pub id: ObjectId, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SelectionRelation { + Tracking { ahead: usize, behind: usize }, + Visible(usize), +} + #[derive(Debug, Eq, Hash, PartialEq)] pub(crate) struct Author { pub name: &'static BStr, @@ -293,6 +299,7 @@ pub(crate) struct App { pub(crate) signature_failures: usize, signature_verification_running: bool, pub(crate) manual_refresh: bool, + pub(crate) selection_relation: Option, } impl App { @@ -356,6 +363,7 @@ impl App { signature_failures: 0, signature_verification_running: false, manual_refresh: false, + selection_relation: None, } } @@ -718,6 +726,26 @@ impl App { self.hidden_rows.clone() } + pub(crate) fn visible_ancestry_to_hidden(&self, tip: ObjectId) -> Option { + let mut pending = vec![tip]; + let mut seen = HashSet::new(); + let mut visible = 0; + let mut reached_hidden = false; + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + if self.hidden_rows.contains(&id) { + reached_hidden = true; + continue; + } + let Some(row) = self.all_rows.get(&id) else { continue }; + visible += 1; + pending.extend(row.parent_ids.iter().copied()); + } + reached_hidden.then_some(visible) + } + pub(crate) fn start_refresh( &mut self, commits: LoadedCommits, @@ -1570,6 +1598,28 @@ mod tests { ); } + #[test] + fn counts_distinct_visible_ancestry_only_when_it_reaches_hidden_history() { + let mut app = App::new(10); + app.extend_commits(vec![ + row_with_parents(4, &[3, 2]), + row_with_parents(3, &[1]), + row_with_parents(2, &[1]), + ]); + app.extend_hidden_commits(vec![row(1)]); + + assert_eq!(app.visible_ancestry_to_hidden(id(4)), Some(3)); + assert_eq!(app.visible_ancestry_to_hidden(id(3)), Some(1)); + assert_eq!(app.visible_ancestry_to_hidden(id(1)), Some(0)); + + app.hidden_rows.clear(); + assert_eq!( + app.visible_ancestry_to_hidden(id(4)), + None, + "without hidden history the fallback count has no useful base" + ); + } + #[test] fn lane_computation_keeps_provisional_rows_interactive() { let mut app = App::new(2); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 715da9bdf81..b45f1d92edc 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -20,7 +20,7 @@ use std::{ }; use anyhow::{Context, Result}; -use app::{Action, App, ChangeKind, Changes, CommitRow, ComparedParent, Effect, PathChange, State}; +use app::{Action, App, ChangeKind, Changes, CommitRow, ComparedParent, Effect, PathChange, SelectionRelation, State}; use crossterm::{ clipboard::CopyToClipboard, cursor, @@ -37,7 +37,7 @@ use gix::{ bstr::{BString, ByteSlice}, prelude::TreeDiffChangeExt, }; -use history::{Authors, Decorations, Event, SharedAuthors}; +use history::{Authors, DecorationKind, Decorations, Event, SharedAuthors}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use ratatui::{TerminalOptions, Viewport, backend::CrosstermBackend, text::Line}; @@ -54,6 +54,19 @@ struct FillRepository<'a> { retain: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct SelectionRef { + name: BString, + upstream: Option>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SelectionRelationCache { + id: gix::ObjectId, + refs: Vec, + relation: Option, +} + type LineCounts = Option<(u32, u32)>; type LineDiffResult = (usize, gix::object::tree::diff::ChangeDetached, Result); @@ -493,6 +506,7 @@ fn event_loop( let mut verification_receiver = None; let mut commit_message = None; let mut changes = None; + let mut selection_relation = None; let line_diff_parallelism = std::thread::available_parallelism().map_or(1, Into::into); let mut line_diff_pool = None; let mut fill_repository = FillRepository { @@ -519,6 +533,7 @@ fn event_loop( &mut notes, &mut commit_message, &mut changes, + &mut selection_relation, &mut line_diff_pool, )?; let mut last_draw = Instant::now(); @@ -569,6 +584,8 @@ fn event_loop( match result { Ok((rows, graph, lane_time)) => { app.finish_lane_computation(rows, graph, lane_time); + selection_relation = None; + app.selection_relation = None; lane_receiver = None; history_requires_alternate_screen = history_needs_alternate_screen(screen, terminal::size()?.1, app.rows.len()); @@ -589,6 +606,8 @@ fn event_loop( Ok(result) => { let result = result?; decorations = result.decorations; + selection_relation = None; + app.selection_relation = None; let hidden_tips = if app.show_hidden { &[][..] } else { @@ -635,7 +654,14 @@ fn event_loop( app.state = State::Loading; } else { let next = history::decorations(&repository)?; - if visible_decorations_changed(&decorations, &next, &app.rows) { + let relation_changed = selection_relation + .as_ref() + .is_some_and(|cached: &SelectionRelationCache| { + selection_refs(&repository, cached.id, &next) != cached.refs + }); + if visible_decorations_changed(&decorations, &next, &app.rows) || relation_changed { + selection_relation = None; + app.selection_relation = None; decorations = next; dirty = true; } @@ -652,6 +678,7 @@ fn event_loop( &mut notes, &mut commit_message, &mut changes, + &mut selection_relation, &mut line_diff_pool, )?; last_draw = Instant::now(); @@ -727,6 +754,7 @@ fn event_loop( &mut notes, &mut commit_message, &mut changes, + &mut selection_relation, &mut line_diff_pool, )?; last_draw = Instant::now(); @@ -880,6 +908,7 @@ fn event_loop( &mut notes, &mut commit_message, &mut changes, + &mut selection_relation, &mut line_diff_pool, )?; } @@ -1023,6 +1052,73 @@ fn visible_decorations_changed(old: &Decorations, new: &Decorations, rows: &[Com rows.iter().any(|row| old.get(&row.id) != new.get(&row.id)) } +fn selection_refs(repo: &gix::Repository, id: gix::ObjectId, decorations: &Decorations) -> Vec { + let mut refs: Vec<_> = decorations + .get(&id) + .into_iter() + .flatten() + .map(|decoration| { + let upstream = if decoration.kind == DecorationKind::Local { + let mut name = BString::from("refs/heads/"); + name.extend_from_slice(&decoration.name); + repo.try_find_reference(name.as_bstr()) + .ok() + .flatten() + .and_then(|reference| reference.remote_tracking_ref_name(gix::remote::Direction::Fetch)) + .map(|name| { + name.ok().and_then(|name| { + repo.try_find_reference(name.as_bstr()) + .ok() + .flatten() + .and_then(|mut reference| reference.peel_to_id().ok().map(gix::Id::detach)) + }) + }) + } else { + None + }; + SelectionRef { + name: decoration.name.clone(), + upstream, + } + }) + .collect(); + refs.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.upstream.cmp(&b.upstream))); + refs +} + +fn selection_relation( + repo: &gix::Repository, + id: gix::ObjectId, + refs: &[SelectionRef], + visible: Option, +) -> Option { + let has_upstream = refs.iter().any(|reference| reference.upstream.is_some()); + for upstream in refs.iter().filter_map(|reference| reference.upstream.flatten()) { + let Ok(ahead) = count_exclusive_commits(repo, id, upstream) else { + continue; + }; + let Ok(behind) = count_exclusive_commits(repo, upstream, id) else { + continue; + }; + return Some(SelectionRelation::Tracking { ahead, behind }); + } + (!has_upstream && !refs.is_empty()) + .then_some(visible) + .flatten() + .map(SelectionRelation::Visible) +} + +fn count_exclusive_commits(repo: &gix::Repository, tip: gix::ObjectId, hidden: gix::ObjectId) -> Result { + let mut walk = repo + .rev_walk([tip]) + .with_hidden([hidden]) + .all() + .context("could not compare branch with its upstream")?; + walk.try_fold(0usize, |count, info| { + info.context("could not traverse branch comparison").map(|_| count + 1) + }) +} + #[expect(clippy::too_many_arguments, reason = "drawing needs the complete view state")] fn draw( terminal: &mut ratatui::DefaultTerminal, @@ -1034,6 +1130,7 @@ fn draw( notes: &mut gix::note::Platform, commit_message: &mut Option<(gix::ObjectId, BString)>, changes: &mut Option<(gix::ObjectId, usize, Changes)>, + selection_cache: &mut Option, line_diff_pool: &mut Option, ) -> Result<()> { app.viewport_rows = terminal @@ -1065,9 +1162,16 @@ fn draw( app.set_notes(id, loaded); } let changes_visible = app.changes_visible(); - let selected = (app.show_commit || changes_visible) - .then(|| app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id)) - .flatten(); + let selected_id = app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id); + app.selection_relation = selection_cache + .as_ref() + .filter(|cached| Some(cached.id) == selected_id) + .and_then(|cached| cached.relation); + let relation_to_load = matches!(app.state, State::Complete | State::Cancelled) + .then_some(selected_id) + .flatten() + .filter(|id| selection_cache.as_ref().is_none_or(|cached| cached.id != *id)); + let selected = (app.show_commit || changes_visible).then_some(selected_id).flatten(); let message_to_load = app .show_commit .then_some(selected) @@ -1096,6 +1200,7 @@ fn draw( if app.rows[start..end].iter().any(|row| !row.metadata_loaded) || message_to_load.is_some() || changes_to_load.is_some() + || relation_to_load.is_some() { let mut one_shot_repository = None; let repository = if fill_repository.retain { @@ -1117,6 +1222,14 @@ fn draw( if let Some(id) = message_to_load { *commit_message = Some((id, load_commit_message(repository, id)?)); } + if let Some(id) = relation_to_load { + repository.object_cache_size(OBJECT_CACHE_SIZE); + let refs = selection_refs(repository, id, decorations); + let relation = selection_relation(repository, id, &refs, app.visible_ancestry_to_hidden(id)); + repository.object_cache_size(None); + *selection_cache = Some(SelectionRelationCache { id, refs, relation }); + app.selection_relation = relation; + } if let Some(id) = changes_to_load { repository.object_cache_size(OBJECT_CACHE_SIZE); let loaded = load_changes( @@ -1700,7 +1813,7 @@ mod tests { #[test] fn loads_commit_messages_from_an_existing_repository() -> gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; - let repository = gix::open(&fixture)?; + let repository = open_test_repository(&fixture)?; let id = repository.rev_parse_single("topic")?.detach(); assert!( @@ -1710,6 +1823,90 @@ mod tests { Ok(()) } + #[test] + fn selection_relation_prefers_tracking_counts_and_handles_missing_upstreams() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let mut repository = gix::open(&fixture)?; + repository.object_cache_size(OBJECT_CACHE_SIZE); + let topic = repository.rev_parse_single("topic")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let tracking = SelectionRef { + name: "topic".into(), + upstream: Some(Some(main)), + }; + assert_eq!( + selection_relation(&repository, topic, &[tracking.clone(), tracking], Some(99)), + Some(SelectionRelation::Tracking { ahead: 1, behind: 2 }), + "one upstream comparison wins over the visible-history fallback" + ); + assert_eq!( + selection_relation( + &repository, + topic, + &[SelectionRef { + name: "topic".into(), + upstream: Some(None), + }], + Some(1), + ), + None, + "a configured but missing tracking ref does not masquerade as an untracked branch" + ); + assert_eq!( + selection_relation( + &repository, + topic, + &[SelectionRef { + name: "tag: topic".into(), + upstream: None, + }], + Some(1), + ), + Some(SelectionRelation::Visible(1)) + ); + Ok(()) + } + + #[test] + fn selection_refs_resolve_the_configured_fetch_tracking_branch() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let path = fixture.path(); + for args in [ + ["config", "remote.origin.url", "https://example.com/repo"], + ["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"], + ["config", "branch.topic.remote", "origin"], + ["config", "branch.topic.merge", "refs/heads/main"], + ] { + let status = std::process::Command::new("git") + .current_dir(path) + .args(args) + .status()?; + assert!(status.success(), "git config prepares the tracking relationship"); + } + let repository = open_test_repository(path)?; + let topic = repository.rev_parse_single("topic")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let status = std::process::Command::new("git") + .current_dir(path) + .args(["update-ref", "refs/remotes/origin/main", &main.to_hex().to_string()]) + .status()?; + assert!(status.success(), "the configured tracking ref exists"); + let repository = gix::open(path)?; + let refs = selection_refs( + &repository, + topic, + &Decorations::from([( + topic, + vec![history::Decoration { + name: "topic".into(), + kind: DecorationKind::Local, + }], + )]), + ); + assert_eq!(refs[0].upstream, Some(Some(main))); + Ok(()) + } + #[test] fn loads_changes_against_each_merge_parent() -> gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 0267c0b4752..9273affa298 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -9,7 +9,10 @@ use ratatui::{ use crate::{ BuiltInDiff, - app::{App, AttributionKind, ChangeKind, Changes, CommitRow, CopyKind, NameMode, RefMode, SignatureState, State}, + app::{ + App, AttributionKind, ChangeKind, Changes, CommitRow, CopyKind, NameMode, RefMode, SelectionRelation, + SignatureState, State, + }, history::{DecorationKind, Decorations}, }; @@ -195,6 +198,15 @@ pub(crate) fn draw( .min(u16::MAX as usize); let horizontal_offset = app.horizontal_offset.min(max_offset); let graph_offset = horizontal_offset.min(graph_max_offset); + let selection_info = selection_info_line( + app.changes_visible() + .then_some(changes) + .flatten() + .filter(|changes| changes.is_visible()), + app.selection_relation, + ); + let selection_info_width = selection_info.width(); + let mut selection_info_area = None; for (index, metadata) in metadata.into_iter().enumerate() { let lane = lanes.lane(index); @@ -295,8 +307,25 @@ pub(crate) fn draw( .x .saturating_add(u16::try_from(line_width).unwrap_or(u16::MAX)) .saturating_add(1) + .saturating_add(u16::try_from(selection_info_width).unwrap_or(u16::MAX)) + .saturating_add(1) .min(body.right().saturating_sub(1)); - frame.buffer_mut()[(marker_x, y)].set_style(style); + if selection_info_width > 0 { + let width = u16::try_from(selection_info_width) + .unwrap_or(u16::MAX) + .min(marker_x.saturating_sub(content.x).saturating_sub(2)); + let area = Rect::new(marker_x.saturating_sub(width).saturating_sub(1), y, width, 1); + if width > 0 { + frame.buffer_mut()[(area.x - 1, y)].set_symbol(" "); + frame.render_widget(Paragraph::new(selection_info.clone()), area); + selection_info_area = Some(area); + } + } + let buffer = frame.buffer_mut(); + if marker_x > body.x { + buffer[(marker_x - 1, y)].set_symbol(" "); + } + buffer[(marker_x, y)].set_symbol(" ").set_style(style); } if !app.is_row_reachable(start + index) { for x in body.x..body.right() { @@ -475,7 +504,60 @@ pub(crate) fn draw( frame .buffer_mut() .set_style(body, Style::default().add_modifier(Modifier::DIM)); + if let Some(area) = selection_info_area { + frame.render_widget(Paragraph::new(selection_info), area); + } + } +} + +fn selection_info_line(changes: Option<&Changes>, relation: Option) -> Line<'static> { + let mut spans = Vec::new(); + if let Some(changes) = changes { + spans.extend([ + Span::styled(format!("+{}", changes.lines_added), selection_color(Color::Green)), + Span::raw(" "), + Span::styled(format!("-{}", changes.lines_removed), selection_color(Color::Red)), + ]); + } + match relation { + Some(SelectionRelation::Tracking { ahead, behind }) => { + if ahead > 0 { + push_selection_span( + &mut spans, + Span::styled(format!("⇡{ahead}"), selection_color(Color::Green)), + ); + } + if behind > 0 { + if ahead == 0 { + push_selection_span( + &mut spans, + Span::styled(format!("⇣{behind}"), selection_color(Color::Red)), + ); + } else { + spans.push(Span::styled(format!("⇣{behind}"), selection_color(Color::Red))); + } + } + } + Some(SelectionRelation::Visible(commits)) => { + push_selection_span( + &mut spans, + Span::styled(format!("⇡{commits}"), selection_color(Color::Green)), + ); + } + None => {} + } + Line::from(spans) +} + +fn selection_color(color: Color) -> Style { + Style::default().fg(color).remove_modifier(Modifier::DIM) +} + +fn push_selection_span(spans: &mut Vec>, span: Span<'static>) { + if !spans.is_empty() { + spans.push(Span::raw(" ")); } + spans.push(span); } fn render_changes(frame: &mut Frame<'_>, area: Rect, changes: &Changes, app: &mut App) { @@ -1062,6 +1144,103 @@ mod tests { app.finish_lane_computation(rows, lanes, lane_time); } + #[test] + fn renders_selection_info_beside_the_right_marker_without_dimming_it() -> Result<(), Box> { + let mut app = App::new(2); + app.extend_commits(vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "a subject which is deliberately too long".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }]); + complete(&mut app); + app.selection_relation = Some(SelectionRelation::Tracking { ahead: 1, behind: 2 }); + let changes = Changes { + paths: vec![crate::app::PathChange { + kind: ChangeKind::Modified, + source: None, + path: "file".into(), + lines: Some((3, 4)), + }], + lines_added: 3, + lines_removed: 4, + ..Changes::default() + }; + app.changes_focused = true; + let mut terminal = Terminal::new(TestBackend::new(38, 7))?; + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + let row = rendered_row(&terminal); + let info = "+3 -4 ⇡1⇣2"; + let info_byte = row.find(info).expect("selection info wins over the long subject"); + let info_x = row[..info_byte].chars().count() as u16; + let buffer = terminal.backend().buffer(); + assert_eq!( + buffer[(info_x - 1, 0)].symbol(), + " ", + "selection info has a left margin" + ); + assert_eq!(buffer[(info_x, 0)].fg, Color::Green); + assert_eq!(buffer[(info_x + 3, 0)].fg, Color::Red); + assert!(!buffer[(info_x, 0)].modifier.contains(Modifier::DIM)); + let spacer_x = info_x + info.chars().count() as u16; + assert_eq!(buffer[(spacer_x, 0)].symbol(), " ", "the marker has a left spacer"); + assert!( + buffer[(spacer_x + 1, 0)].modifier.contains(Modifier::REVERSED), + "the right selection block follows the spacer" + ); + assert_eq!( + buffer[(spacer_x + 1, 0)].symbol(), + " ", + "the right selection block never inverts text" + ); + + app.selection_relation = None; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(36, 0)].symbol(), " ", "a plain marker has a left spacer"); + assert_eq!(buffer[(37, 0)].symbol(), " ", "a plain marker never inverts text"); + assert!(buffer[(37, 0)].modifier.contains(Modifier::REVERSED)); + + app.show_selection_tail = false; + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert!(!rendered_row(&terminal).contains(info)); + + let text = |relation| { + selection_info_line(None, relation) + .spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::() + }; + assert_eq!(text(Some(SelectionRelation::Tracking { ahead: 0, behind: 2 })), "⇣2"); + assert_eq!(text(Some(SelectionRelation::Tracking { ahead: 0, behind: 0 })), ""); + Ok(()) + } + #[test] fn renders_a_colored_file_diff_pager() -> Result<(), Box> { let diff = BuiltInDiff::new( @@ -1339,7 +1518,7 @@ mod tests { for x in 30..44 { expected[(x, 0)].set_style(Style::default().fg(Color::Green)); } - expected[(selected_line.chars().count() as u16 + 1, 0)] + expected[(selected_line.chars().count() as u16 + 2, 0)] .set_style(Style::default().fg(Color::Blue).add_modifier(Modifier::REVERSED)); let commit = footer_text[..footer_text.find("o commit").expect("the commit toggle is present")] .chars() From 2315dec094a5f63220ebbef6bf66bf8bafb3a09d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 4 Aug 2026 15:24:28 +0200 Subject: [PATCH 013/282] feat: show tree and worktree changes together Show Tree and Worktree changes together by default and cycle c from Both to Tree to Hidden. Collect staged, unstaged, untracked, and conflicted paths with the cancellable status iterator, preserve Git-like ordering and colors, and reuse computed per-path line counts for summaries, selected rows, and the existing external or built-in diff pipeline. Watch the worktree and index only while needed, debounce updates for 75ms, and retain independent caches, selection, scrolling, and errors for both sources. Represent conflicts, submodules, unavailable diffs, and an enabled clean worktree without launching inappropriate pagers. Render both blocks over the full-height history, side by side when their condensed summaries fit and stacked otherwise. Join unequal borders, cap their height, keep history visible beside shorter blocks, and bound navigation above the top-most block so advancing scrolls history while the selected row stays fixed. Cycle focus in visual order and keep merge-parent controls on Tree. --- gix-tix/Cargo.toml | 2 +- gix-tix/src/app.rs | 423 +++++++++++++++------ gix-tix/src/lib.rs | 927 ++++++++++++++++++++++++++++++++++++++------ gix-tix/src/ui.rs | 930 +++++++++++++++++++++++++++++++++------------ 4 files changed, 1815 insertions(+), 467 deletions(-) diff --git a/gix-tix/Cargo.toml b/gix-tix/Cargo.toml index ca927441ebc..37ad53d3fcb 100644 --- a/gix-tix/Cargo.toml +++ b/gix-tix/Cargo.toml @@ -25,7 +25,7 @@ sha256 = ["gix/sha256"] [dependencies] anyhow = "1.0.98" crossterm = { version = "0.29.0", features = ["osc52"] } -gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["blob-diff", "mailmap", "notes", "parallel", "revision", "command"] } +gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["blob-diff", "mailmap", "notes", "parallel", "revision", "command", "status"] } notify = "8.2.0" ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 4769b3006f6..df55b9edd68 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -51,6 +51,7 @@ pub(crate) enum ChangeKind { Renamed, Copied, TypeChanged, + Unmerged, } impl ChangeKind { @@ -62,13 +63,70 @@ impl ChangeKind { ChangeKind::Renamed => 'R', ChangeKind::Copied => 'C', ChangeKind::TypeChanged => 'T', + ChangeKind::Unmerged => 'U', } } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum ChangesMode { + #[default] + Tree, + Both, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ChangePane { + Tree, + Worktree, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum ChangesLayout { + #[default] + SideBySide, + Stacked, +} + +#[derive(Debug)] +pub(crate) struct ChangesView { + pub selected: usize, + pub offset: usize, + pub horizontal_offset: usize, + pub error: Option, + page: usize, + max: usize, + horizontal_page: usize, + horizontal_max: usize, +} + +impl Default for ChangesView { + fn default() -> Self { + Self { + selected: 0, + offset: 0, + horizontal_offset: 0, + error: None, + page: 1, + max: 0, + horizontal_page: 1, + horizontal_max: 0, + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum ChangeGroup { + #[default] + Tree, + Staged, + Unstaged, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PathChange { pub kind: ChangeKind, + pub group: ChangeGroup, pub source: Option, pub path: BString, pub lines: Option<(u32, u32)>, @@ -78,7 +136,7 @@ pub(crate) struct PathChange { pub(crate) struct Changes { pub parent: Option, pub paths: Vec, - pub diffs: Vec, + pub diffs: Vec, pub lines_added: u64, pub lines_removed: u64, } @@ -234,7 +292,7 @@ pub(crate) enum Effect { CopyId(ObjectId), CopyAuthor(&'static Author), Reload(bool), - OpenDiff(usize), + OpenDiff(ChangePane, usize), VerifySignatures(Vec), Quit, } @@ -267,21 +325,18 @@ pub(crate) struct App { pub show_hidden: bool, pub align_metadata: bool, pub show_commit: bool, - pub show_changes: bool, + pub changes_mode: Option, pub(crate) changes_suppressed: bool, - pub(crate) changes_focused: bool, - pub(crate) changes_selected: usize, - pub(crate) changes_offset: usize, - pub(crate) changes_horizontal_offset: usize, + pub(crate) changes_focus: Option, + pub(crate) changes_layout: ChangesLayout, + pub(crate) tree_changes_visible: bool, + pub(crate) worktree_changes_visible: bool, + pub(crate) tree_changes: ChangesView, + pub(crate) worktree_changes: ChangesView, pub(crate) changes_parent: usize, - pub(crate) diff_error: Option, pub(crate) commit_offset: usize, commit_page: usize, commit_max: usize, - changes_page: usize, - changes_max: usize, - changes_horizontal_page: usize, - changes_horizontal_max: usize, pub(crate) show_selection_tail: bool, pub inline: bool, pub preview_author_copy: bool, @@ -331,21 +386,18 @@ impl App { show_hidden: false, align_metadata: true, show_commit: false, - show_changes: true, + changes_mode: Some(ChangesMode::Both), changes_suppressed: false, - changes_focused: false, - changes_selected: 0, - changes_offset: 0, - changes_horizontal_offset: 0, + changes_focus: None, + changes_layout: ChangesLayout::SideBySide, + tree_changes_visible: false, + worktree_changes_visible: false, + tree_changes: ChangesView::default(), + worktree_changes: ChangesView::default(), changes_parent: 0, - diff_error: None, commit_offset: 0, commit_page: 1, commit_max: 0, - changes_page: 1, - changes_max: 0, - changes_horizontal_page: 1, - changes_horizontal_max: 0, show_selection_tail: true, inline: false, preview_author_copy: false, @@ -518,19 +570,19 @@ impl App { pub fn update(&mut self, action: Action) -> Vec { match action { Action::Cancelled if self.state == State::Cancelling => self.state = State::Cancelled, - Action::MoveUp if self.changes_focused => self.move_changes(1, false), - Action::MoveDown if self.changes_focused => self.move_changes(1, true), + Action::MoveUp if self.changes_focus.is_some() => self.move_changes(1, false), + Action::MoveDown if self.changes_focus.is_some() => self.move_changes(1, true), Action::MoveUp => self.move_reachable(false), Action::MoveDown => self.move_reachable(true), Action::ScrollLeft => { - if self.changes_focused { + if self.changes_focus.is_some() { self.pan_changes(false); } else if !self.cycle_junction_parent(false) { self.horizontal_offset = self.horizontal_offset.saturating_sub(self.horizontal_page); } } Action::ScrollRight => { - if self.changes_focused { + if self.changes_focus.is_some() { self.pan_changes(true); } else if !self.cycle_junction_parent(true) { self.horizontal_offset = self @@ -539,10 +591,14 @@ impl App { .min(self.horizontal_max); } } - Action::HalfPageUp if self.changes_focused => self.move_changes((self.changes_page / 2).max(1), false), - Action::HalfPageDown if self.changes_focused => self.move_changes((self.changes_page / 2).max(1), true), - Action::PageUp if self.changes_focused => self.move_changes(self.changes_page, false), - Action::PageDown if self.changes_focused => self.move_changes(self.changes_page, true), + Action::HalfPageUp if self.changes_focus.is_some() => { + self.move_changes((self.focused_changes().page / 2).max(1), false); + } + Action::HalfPageDown if self.changes_focus.is_some() => { + self.move_changes((self.focused_changes().page / 2).max(1), true); + } + Action::PageUp if self.changes_focus.is_some() => self.move_changes(self.focused_changes().page, false), + Action::PageDown if self.changes_focus.is_some() => self.move_changes(self.focused_changes().page, true), Action::PageUp if self.show_commit && self.commit_max > 0 => { self.commit_offset = self.commit_offset.saturating_sub(self.commit_page); } @@ -553,9 +609,10 @@ impl App { Action::HalfPageDown => self.move_selection((self.viewport_rows / 2).max(1), true), Action::PageUp => self.move_selection(self.viewport_rows.max(1), false), Action::PageDown => self.move_selection(self.viewport_rows.max(1), true), - Action::First if self.changes_focused => { - self.changes_selected = 0; - self.diff_error = None; + Action::First if self.changes_focus.is_some() => { + let changes = self.focused_changes_mut(); + changes.selected = 0; + changes.error = None; self.ensure_changes_visible(); } Action::First => { @@ -563,9 +620,10 @@ impl App { self.select(index); } } - Action::Last if self.changes_focused => { - self.changes_selected = self.changes_max; - self.diff_error = None; + Action::Last if self.changes_focus.is_some() => { + let changes = self.focused_changes_mut(); + changes.selected = changes.max; + changes.error = None; self.ensure_changes_visible(); } Action::Last if self.last_selectable().is_some() => { @@ -616,29 +674,40 @@ impl App { } Action::ToggleChanges => { self.focus_feedback = None; - self.show_changes = !self.show_changes; - if !self.show_changes { + self.changes_mode = match self.changes_mode { + Some(ChangesMode::Both) => Some(ChangesMode::Tree), + Some(ChangesMode::Tree) => None, + None => Some(ChangesMode::Both), + }; + self.reset_changes_view(); + self.changes_parent = 0; + if self.changes_mode.is_none() { self.changes_suppressed = false; - self.changes_focused = false; - self.reset_changes_view(); + self.changes_focus = None; } } - Action::ToggleChangesFocus if self.show_changes => { - self.changes_focused = !self.changes_focused; - if self.changes_focused { + Action::ToggleChangesFocus if self.changes_mode.is_some() => { + self.cycle_changes_focus(); + if self.changes_focus.is_some() { self.clear_preview_author_copy(); } - self.focus_feedback = Some(if self.changes_focused { "changes" } else { "history" }); + self.focus_feedback = Some(match self.changes_focus { + Some(ChangePane::Tree) => "tree changes", + Some(ChangePane::Worktree) => "worktree changes", + None => "history", + }); } Action::CycleChangesParent => { - if self.show_changes { + if self.changes_focus == Some(ChangePane::Tree) { self.changes_parent = self.changes_parent.saturating_add(1); - self.diff_error = None; + self.tree_changes.error = None; } } - Action::OpenDiff if self.changes_focused => { - self.diff_error = None; - return vec![Effect::OpenDiff(self.changes_selected)]; + Action::OpenDiff if self.changes_focus.is_some() => { + let pane = self.changes_focus.expect("focus was checked"); + let changes = self.focused_changes_mut(); + changes.error = None; + return vec![Effect::OpenDiff(pane, changes.selected)]; } Action::VerifySignatures if !self.signature_verification_running => { let start = self.offset.min(self.rows.len()); @@ -657,8 +726,8 @@ impl App { } } Action::ForceQuit => return vec![Effect::Quit], - Action::Cancel | Action::Quit if self.changes_focused => self.focus_history(), - Action::PreviewAuthorCopy(_) if self.changes_focused => {} + Action::Cancel | Action::Quit if self.changes_focus.is_some() => self.focus_history(), + Action::PreviewAuthorCopy(_) if self.changes_focus.is_some() => {} Action::PreviewAuthorCopy(value) => { if value && !self.preview_author_copy { self.reachability_anchor = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); @@ -896,11 +965,12 @@ impl App { } fn move_changes(&mut self, distance: usize, down: bool) { - self.diff_error = None; - self.changes_selected = if down { - self.changes_selected.saturating_add(distance).min(self.changes_max) + let changes = self.focused_changes_mut(); + changes.error = None; + changes.selected = if down { + changes.selected.saturating_add(distance).min(changes.max) } else { - self.changes_selected.saturating_sub(distance) + changes.selected.saturating_sub(distance) }; self.ensure_changes_visible(); } @@ -913,33 +983,35 @@ impl App { } pub(crate) fn focus_history(&mut self) { - self.changes_focused = false; + self.changes_focus = None; self.focus_feedback = None; } pub(crate) fn changes_visible(&self) -> bool { - self.show_changes && !self.changes_suppressed + self.changes_mode.is_some() && !self.changes_suppressed } fn ensure_changes_visible(&mut self) { - if self.changes_selected < self.changes_offset { - self.changes_offset = self.changes_selected; - } else if self.changes_selected >= self.changes_offset.saturating_add(self.changes_page) { - self.changes_offset = self.changes_selected + 1 - self.changes_page; + let changes = self.focused_changes_mut(); + if changes.selected < changes.offset { + changes.offset = changes.selected; + } else if changes.selected >= changes.offset.saturating_add(changes.page) { + changes.offset = changes.selected + 1 - changes.page; } - self.changes_offset = self - .changes_offset - .min(self.changes_max.saturating_add(1).saturating_sub(self.changes_page)); + changes.offset = changes + .offset + .min(changes.max.saturating_add(1).saturating_sub(changes.page)); } fn pan_changes(&mut self, right: bool) { - self.changes_horizontal_offset = if right { - self.changes_horizontal_offset - .saturating_add(self.changes_horizontal_page) - .min(self.changes_horizontal_max) + let changes = self.focused_changes_mut(); + changes.horizontal_offset = if right { + changes + .horizontal_offset + .saturating_add(changes.horizontal_page) + .min(changes.horizontal_max) } else { - self.changes_horizontal_offset - .saturating_sub(self.changes_horizontal_page) + changes.horizontal_offset.saturating_sub(changes.horizontal_page) }; } @@ -1130,30 +1202,87 @@ impl App { pub(crate) fn set_changes_bounds( &mut self, + pane: ChangePane, page: usize, len: usize, horizontal_page: usize, horizontal_max: usize, ) { - self.changes_page = page.max(1); - self.changes_max = len.saturating_sub(1); + let changes = self.changes_mut(pane); + changes.page = page.max(1); + changes.max = len.saturating_sub(1); if len == 0 { - self.changes_selected = 0; - self.changes_offset = 0; + changes.selected = 0; + changes.offset = 0; } else { - self.changes_selected = self.changes_selected.min(self.changes_max); - self.ensure_changes_visible(); + changes.selected = changes.selected.min(changes.max); + if changes.selected < changes.offset { + changes.offset = changes.selected; + } else if changes.selected >= changes.offset.saturating_add(changes.page) { + changes.offset = changes.selected + 1 - changes.page; + } + changes.offset = changes + .offset + .min(changes.max.saturating_add(1).saturating_sub(changes.page)); } - self.changes_horizontal_page = horizontal_page.max(1); - self.changes_horizontal_max = horizontal_max; - self.changes_horizontal_offset = self.changes_horizontal_offset.min(horizontal_max); + changes.horizontal_page = horizontal_page.max(1); + changes.horizontal_max = horizontal_max; + changes.horizontal_offset = changes.horizontal_offset.min(horizontal_max); } pub(crate) fn reset_changes_view(&mut self) { - self.diff_error = None; - self.changes_selected = 0; - self.changes_offset = 0; - self.changes_horizontal_offset = 0; + self.tree_changes = ChangesView::default(); + self.worktree_changes = ChangesView::default(); + } + + pub(crate) fn changes(&self, pane: ChangePane) -> &ChangesView { + match pane { + ChangePane::Tree => &self.tree_changes, + ChangePane::Worktree => &self.worktree_changes, + } + } + + pub(crate) fn changes_mut(&mut self, pane: ChangePane) -> &mut ChangesView { + match pane { + ChangePane::Tree => &mut self.tree_changes, + ChangePane::Worktree => &mut self.worktree_changes, + } + } + + fn focused_changes(&self) -> &ChangesView { + self.changes(self.changes_focus.expect("changes are focused")) + } + + fn focused_changes_mut(&mut self) -> &mut ChangesView { + self.changes_mut(self.changes_focus.expect("changes are focused")) + } + + fn cycle_changes_focus(&mut self) { + let (first, second) = match self.changes_layout { + ChangesLayout::SideBySide => (ChangePane::Tree, ChangePane::Worktree), + ChangesLayout::Stacked => (ChangePane::Worktree, ChangePane::Tree), + }; + let visible = |pane| match pane { + ChangePane::Tree => self.tree_changes_visible, + ChangePane::Worktree => self.worktree_changes_visible, + }; + self.changes_focus = match self.changes_focus { + None if visible(first) => Some(first), + None if visible(second) => Some(second), + Some(current) if current == first && visible(second) => Some(second), + Some(_) | None => None, + }; + } + + pub(crate) fn set_changes_layout(&mut self, layout: ChangesLayout, tree_visible: bool, worktree_visible: bool) { + self.changes_layout = layout; + self.tree_changes_visible = tree_visible; + self.worktree_changes_visible = worktree_visible; + if self.changes_focus == Some(ChangePane::Tree) && !tree_visible { + self.changes_focus = worktree_visible.then_some(ChangePane::Worktree); + } else if self.changes_focus == Some(ChangePane::Worktree) && !worktree_visible { + self.changes_focus = tree_visible.then_some(ChangePane::Tree); + } } #[cfg(test)] @@ -1532,6 +1661,10 @@ mod tests { app.finish_lane_computation(rows, graph, lane_time); } + fn show_tree_changes(app: &mut App) { + app.set_changes_layout(ChangesLayout::SideBySide, true, false); + } + #[test] fn completion_orders_and_draws_merge_lanes() { let mut app = App::new(10); @@ -1795,9 +1928,10 @@ mod tests { app.update(Action::PreviewAuthorCopy(true)); assert!(app.preview_author_copy && app.reachable_rows.is_some()); + show_tree_changes(&mut app); app.update(Action::ToggleChangesFocus); assert!( - app.changes_focused && !app.preview_author_copy && app.reachable_rows.is_none(), + app.changes_focus == Some(ChangePane::Tree) && !app.preview_author_copy && app.reachable_rows.is_none(), "entering the changes pane clears transient history navigation" ); @@ -1903,13 +2037,13 @@ mod tests { app.update(Action::PageDown); assert_eq!(app.commit_offset, 6); - app.changes_focused = true; - app.set_changes_bounds(2, 5, 1, 0); + app.changes_focus = Some(ChangePane::Tree); + app.set_changes_bounds(ChangePane::Tree, 2, 5, 1, 0); app.update(Action::PageDown); - assert_eq!(app.changes_selected, 2, "focused changes retain paging priority"); + assert_eq!(app.tree_changes.selected, 2, "focused changes retain paging priority"); assert_eq!(app.commit_offset, 6); - app.changes_focused = false; + app.changes_focus = None; app.set_commit_bounds(3, 0); app.update(Action::PageDown); assert_eq!(app.selected, Some(2), "history paging resumes when the commit fits"); @@ -1947,53 +2081,62 @@ mod tests { fn focused_changes_redirect_navigation_to_the_path_viewport() { let mut app = App::new(2); app.extend_commits((1..=3).map(row).collect::>()); - app.set_changes_bounds(4, 10, 20, 45); + app.set_changes_bounds(ChangePane::Tree, 4, 10, 20, 45); + show_tree_changes(&mut app); app.update(Action::ToggleChangesFocus); - assert!(app.changes_focused); - assert_eq!(app.focus_feedback.take(), Some("changes")); + assert_eq!(app.changes_focus, Some(ChangePane::Tree)); + assert_eq!(app.focus_feedback.take(), Some("tree changes")); app.update(Action::ToggleChangesFocus); - assert!(!app.changes_focused); + assert_eq!(app.changes_focus, None); assert_eq!(app.focus_feedback.take(), Some("history")); app.update(Action::ToggleChangesFocus); app.update(Action::MoveDown); - assert_eq!((app.changes_selected, app.changes_offset), (1, 0)); - assert_eq!(app.update(Action::OpenDiff), vec![Effect::OpenDiff(1)]); + assert_eq!((app.tree_changes.selected, app.tree_changes.offset), (1, 0)); + assert_eq!( + app.update(Action::OpenDiff), + vec![Effect::OpenDiff(ChangePane::Tree, 1)] + ); assert_eq!( app.selected, Some(0), "path selection leaves commit selection untouched" ); app.update(Action::PageDown); - assert_eq!((app.changes_selected, app.changes_offset), (5, 2)); + assert_eq!((app.tree_changes.selected, app.tree_changes.offset), (5, 2)); app.update(Action::HalfPageDown); - assert_eq!((app.changes_selected, app.changes_offset), (7, 4)); + assert_eq!((app.tree_changes.selected, app.tree_changes.offset), (7, 4)); app.update(Action::Last); - assert_eq!((app.changes_selected, app.changes_offset), (9, 6)); + assert_eq!((app.tree_changes.selected, app.tree_changes.offset), (9, 6)); app.update(Action::First); - assert_eq!((app.changes_selected, app.changes_offset), (0, 0)); + assert_eq!((app.tree_changes.selected, app.tree_changes.offset), (0, 0)); app.update(Action::ScrollRight); app.update(Action::ScrollRight); app.update(Action::ScrollRight); - assert_eq!(app.changes_horizontal_offset, 45); + assert_eq!(app.tree_changes.horizontal_offset, 45); assert_eq!(app.horizontal_offset, 0, "path panning leaves the graph untouched"); app.update(Action::ScrollLeft); - assert_eq!(app.changes_horizontal_offset, 25); + assert_eq!(app.tree_changes.horizontal_offset, 25); app.update(Action::ToggleChanges); - assert!(!app.changes_focused, "closing the panel returns focus to history"); + app.update(Action::ToggleChanges); + assert_eq!(app.changes_focus, None, "closing the panel returns focus to history"); assert!(app.update(Action::OpenDiff).is_empty()); - assert_eq!(app.changes_selected, 0); - assert_eq!(app.changes_offset, 0); - assert_eq!(app.changes_horizontal_offset, 0); + assert_eq!(app.tree_changes.selected, 0); + assert_eq!(app.tree_changes.offset, 0); + assert_eq!(app.tree_changes.horizontal_offset, 0); } #[test] fn toggles_metadata_columns() { let mut app = App::new(1); assert!(app.show_trailers, "trailer attribution is visible by default"); - assert!(app.show_changes, "changed paths are visible by default"); + assert_eq!( + app.changes_mode, + Some(ChangesMode::Both), + "tree and worktree changes are visible by default" + ); app.update(Action::ToggleDate); app.update(Action::ToggleEmail); @@ -2018,12 +2161,64 @@ mod tests { assert_eq!(app.ref_mode, RefMode::Default); assert!(!app.align_metadata); assert!(app.show_commit); - assert!(!app.show_changes); - assert_eq!(app.changes_parent, 1); + assert_eq!(app.changes_mode, Some(ChangesMode::Tree)); + assert_eq!(app.changes_parent, 0); app.update(Action::ToggleAlign); assert!(app.align_metadata); } + #[test] + fn cycles_both_tree_and_hidden_changes() { + let mut app = App::new(1); + assert_eq!(app.changes_mode, Some(ChangesMode::Both)); + + app.update(Action::ToggleChanges); + assert_eq!(app.changes_mode, Some(ChangesMode::Tree)); + + app.changes_focus = Some(ChangePane::Tree); + app.update(Action::ToggleChanges); + assert_eq!(app.changes_mode, None); + assert_eq!(app.changes_focus, None, "hiding changes returns focus to history"); + + app.update(Action::ToggleChanges); + assert_eq!(app.changes_mode, Some(ChangesMode::Both)); + } + + #[test] + fn cycles_changes_focus_in_visual_order_and_keeps_navigation_independent() { + let mut app = App::new(1); + app.changes_mode = Some(ChangesMode::Both); + app.set_changes_bounds(ChangePane::Tree, 2, 4, 10, 20); + app.set_changes_bounds(ChangePane::Worktree, 2, 4, 10, 20); + app.set_changes_layout(ChangesLayout::SideBySide, true, true); + + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, Some(ChangePane::Tree)); + app.update(Action::MoveDown); + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, Some(ChangePane::Worktree)); + app.update(Action::MoveDown); + assert_eq!(app.tree_changes.selected, 1); + assert_eq!(app.worktree_changes.selected, 1); + assert_eq!( + app.update(Action::OpenDiff), + vec![Effect::OpenDiff(ChangePane::Worktree, 1)] + ); + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, None); + + app.set_changes_layout(ChangesLayout::Stacked, true, true); + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, Some(ChangePane::Worktree)); + app.update(Action::ToggleChangesFocus); + assert_eq!(app.changes_focus, Some(ChangePane::Tree)); + + app.set_changes_layout(ChangesLayout::Stacked, false, true); + assert_eq!(app.changes_focus, Some(ChangePane::Worktree)); + app.set_changes_layout(ChangesLayout::Stacked, false, false); + assert_eq!(app.changes_focus, None); + } + #[test] fn cycles_author_names_without_inert_states() { let mut app = App::new(1); @@ -2118,15 +2313,16 @@ mod tests { complete(&mut app); app.update(Action::MoveDown); let selected = app.rows[app.selected.expect("a row is selected")].id; - app.set_changes_bounds(1, 3, 1, 2); + app.set_changes_bounds(ChangePane::Tree, 1, 3, 1, 2); + show_tree_changes(&mut app); app.update(Action::ToggleChangesFocus); app.update(Action::MoveDown); app.update(Action::ScrollRight); app.reload(true); - assert!(!app.changes_focused, "reload returns focus to history"); - assert_eq!(app.changes_selected, 0); - assert_eq!((app.changes_offset, app.changes_horizontal_offset), (0, 0)); + assert_eq!(app.changes_focus, None, "reload returns focus to history"); + assert_eq!(app.tree_changes.selected, 0); + assert_eq!((app.tree_changes.offset, app.tree_changes.horizontal_offset), (0, 0)); app.extend_commits(vec![row(1), row(2), row(3)]); complete(&mut app); assert_eq!( @@ -2167,10 +2363,11 @@ mod tests { #[test] fn pane_exit_keys_return_to_history_but_control_c_quits() { let mut app = App::new(1); + show_tree_changes(&mut app); app.update(Action::ToggleChangesFocus); assert!(app.update(Action::Quit).is_empty()); - assert!(!app.changes_focused, "q returns focus to history"); + assert_eq!(app.changes_focus, None, "q returns focus to history"); app.update(Action::ToggleChangesFocus); assert_eq!( @@ -2179,7 +2376,7 @@ mod tests { "Ctrl-C quits even while changes have focus" ); assert!(app.update(Action::Cancel).is_empty()); - assert!(!app.changes_focused, "Escape returns focus to history"); + assert_eq!(app.changes_focus, None, "Escape returns focus to history"); assert_eq!( app.state, State::Loading, diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index b45f1d92edc..108cfb7ec77 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -20,7 +20,10 @@ use std::{ }; use anyhow::{Context, Result}; -use app::{Action, App, ChangeKind, Changes, CommitRow, ComparedParent, Effect, PathChange, SelectionRelation, State}; +use app::{ + Action, App, ChangeGroup, ChangeKind, ChangePane, Changes, ChangesMode, CommitRow, ComparedParent, Effect, + PathChange, SelectionRelation, State, +}; use crossterm::{ clipboard::CopyToClipboard, cursor, @@ -45,6 +48,7 @@ const EVENT_BATCH_SIZE: usize = 256; const OBJECT_CACHE_SIZE: usize = 4 * 1024 * 1024; const FRAME_INTERVAL: Duration = Duration::from_nanos(16_666_667); const REPEAT_IDLE: Duration = Duration::from_millis(75); +const WORKTREE_EVENT_IDLE: Duration = Duration::from_millis(75); const IMMEDIATE_PAGER_EXIT: Duration = Duration::from_millis(250); const REF_EVENT_INTERVAL: Duration = Duration::from_millis(250); @@ -54,6 +58,34 @@ struct FillRepository<'a> { retain: bool, } +struct WorktreeWatcher { + _watcher: RecommendedWatcher, + events: mpsc::Receiver>, + workdir: PathBuf, + dot_git: PathBuf, + git_dir: PathBuf, + index: PathBuf, +} + +impl WorktreeWatcher { + fn event_is_relevant(&self, event: ¬ify::Event) -> bool { + worktree_event_is_relevant(event, &self.workdir, &self.dot_git, &self.git_dir, &self.index) + } +} + +fn worktree_event_is_relevant( + event: ¬ify::Event, + workdir: &Path, + dot_git: &Path, + git_dir: &Path, + index: &Path, +) -> bool { + !matches!(event.kind, notify::EventKind::Access(_)) + && event.paths.iter().any(|path| { + path == index || (path.starts_with(workdir) && !path.starts_with(dot_git) && !path.starts_with(git_dir)) + }) +} + #[derive(Clone, Debug, Eq, PartialEq)] struct SelectionRef { name: BString, @@ -68,11 +100,29 @@ struct SelectionRelationCache { } type LineCounts = Option<(u32, u32)>; -type LineDiffResult = (usize, gix::object::tree::diff::ChangeDetached, Result); + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct DiffResource { + id: gix::ObjectId, + mode: gix::objs::tree::EntryMode, + path: BString, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum FileChange { + Tree(gix::object::tree::diff::ChangeDetached), + Worktree { + old: Option, + new: Option, + }, + Unavailable(&'static str), +} + +type LineDiffResult = (usize, FileChange, Result); struct LineDiffJob { index: usize, - change: gix::object::tree::diff::ChangeDetached, + change: FileChange, } struct LineDiffPool { @@ -81,6 +131,80 @@ struct LineDiffPool { workers: Vec>, } +fn worktree_diff_cache( + repository: &gix::Repository, + mode: gix::diff::blob::pipeline::Mode, +) -> Result> { + let Some(workdir) = repository.workdir() else { + return Ok(None); + }; + repository + .diff_resource_cache( + mode, + gix::diff::blob::pipeline::WorktreeRoots { + old_root: None, + new_root: Some(workdir.to_owned()), + }, + ) + .map(Some) + .context("could not initialize worktree diff resources") +} + +fn set_worktree_resources( + repository: &gix::Repository, + cache: &mut gix::diff::blob::Platform, + old: Option<&DiffResource>, + new: Option<&DiffResource>, +) -> Result<()> { + let fallback = old.or(new).context("a file diff needs at least one resource")?; + let old_resource = old.unwrap_or(fallback); + cache + .set_resource( + old.map_or_else(|| repository.object_hash().null(), |resource| resource.id), + old_resource.mode.kind(), + old_resource.path.as_bstr(), + gix::diff::blob::ResourceKind::OldOrSource, + repository, + ) + .context("could not prepare old worktree diff resource")?; + let new_resource = new.unwrap_or(fallback); + cache + .set_resource( + new.map_or_else(|| repository.object_hash().null(), |resource| resource.id), + new_resource.mode.kind(), + new_resource.path.as_bstr(), + gix::diff::blob::ResourceKind::NewOrDestination, + repository, + ) + .context("could not prepare new worktree diff resource")?; + Ok(()) +} + +fn line_counts_for_change( + repository: &gix::Repository, + change: &FileChange, + tree_cache: &mut gix::diff::blob::Platform, + worktree_cache: Option<&mut gix::diff::blob::Platform>, +) -> Result { + let counts = match change { + FileChange::Tree(change) => change + .attach(repository, repository) + .diff(tree_cache) + .context("could not prepare line diff")? + .line_counts() + .context("could not count changed lines")?, + FileChange::Worktree { old, new } => { + let cache = worktree_cache.context("a working tree is required to count changed lines")?; + set_worktree_resources(repository, cache, old.as_ref(), new.as_ref())?; + gix::object::blob::diff::Platform { resource_cache: cache } + .line_counts() + .context("could not count worktree changed lines")? + } + FileChange::Unavailable(_) => None, + }; + Ok(counts.map(|counts| (counts.insertions, counts.removals))) +} + impl LineDiffPool { fn new(repository_path: &Path, parallelism: usize) -> Result { let repository = gix::open(repository_path) @@ -90,10 +214,12 @@ impl LineDiffPool { for _ in 0..parallelism { let mut repository = repository.to_thread_local(); repository.object_cache_size(OBJECT_CACHE_SIZE); - let resource_cache = repository + let tree_cache = repository .diff_resource_cache_for_tree_diff() .context("could not initialize parallel line diffs")?; - worker_state.push((repository, resource_cache)); + let worktree_cache = worktree_diff_cache(&repository, gix::diff::blob::pipeline::Mode::ToGit) + .context("could not initialize parallel worktree line diffs")?; + worker_state.push((repository, tree_cache, worktree_cache)); } let (jobs, job_receiver) = mpsc::channel::(); @@ -102,7 +228,7 @@ impl LineDiffPool { let (result_sender, results) = mpsc::channel(); let workers = worker_state .into_iter() - .map(|(repository, mut resource_cache)| { + .map(|(repository, mut tree_cache, mut worktree_cache)| { let job_receiver = gix::features::threading::OwnShared::clone(&job_receiver); let result_sender = result_sender.clone(); std::thread::spawn(move || { @@ -110,17 +236,12 @@ impl LineDiffPool { let Ok(job) = gix::features::threading::lock(&job_receiver).recv() else { break; }; - let result = job - .change - .attach(&repository, &repository) - .diff(&mut resource_cache) - .context("could not prepare line diff") - .and_then(|mut diff| { - diff.line_counts() - .context("could not count changed lines") - .map(|counts| counts.map(|counts| (counts.insertions, counts.removals))) - }); - resource_cache.clear_resource_cache_keep_allocation(); + let result = + line_counts_for_change(&repository, &job.change, &mut tree_cache, worktree_cache.as_mut()); + tree_cache.clear_resource_cache_keep_allocation(); + if let Some(cache) = worktree_cache.as_mut() { + cache.clear_resource_cache_keep_allocation(); + } if result_sender.send((job.index, job.change, result)).is_err() { break; } @@ -135,10 +256,7 @@ impl LineDiffPool { }) } - fn line_counts( - &mut self, - changes: Vec, - ) -> Result> { + fn line_counts(&mut self, changes: Vec) -> Result> { let len = changes.len(); let jobs = self.jobs.as_ref().context("line diff pool is shutting down")?; for (index, change) in changes.into_iter().enumerate() { @@ -395,7 +513,7 @@ fn should_switch_screen(started_inline: bool, needs_alternate_screen: bool, in_a fn configure_initial_screen(app: &mut App, inline: bool) { app.inline = inline; if inline { - app.show_changes = false; + app.changes_mode = None; } } @@ -441,7 +559,7 @@ fn sync_screen( ) -> Result<()> { let inline_height = inline_height(screen, terminal::size()?.1, app.rows.len()); let needs_alternate_screen = needs_alternate_screen( - app.show_commit || app.show_changes, + app.show_commit || app.changes_mode.is_some(), history_requires_alternate_screen, inline_height, ); @@ -505,7 +623,10 @@ fn event_loop( let mut refresh_expand_hidden = false; let mut verification_receiver = None; let mut commit_message = None; - let mut changes = None; + let mut tree_changes = None; + let mut worktree_changes = None; + let mut worktree_watcher: Option = None; + let mut worktree_refresh_deadline: Option = None; let mut selection_relation = None; let line_diff_parallelism = std::thread::available_parallelism().map_or(1, Into::into); let mut line_diff_pool = None; @@ -518,7 +639,7 @@ fn event_loop( app.configure_hidden_filter(!hide.is_empty()); sync_line_diff_pool( &mut line_diff_pool, - app.show_changes, + app.changes_mode.is_some(), &repository_path, line_diff_parallelism, )?; @@ -532,7 +653,8 @@ fn event_loop( &mut fill_repository, &mut notes, &mut commit_message, - &mut changes, + &mut tree_changes, + &mut worktree_changes, &mut selection_relation, &mut line_diff_pool, )?; @@ -546,6 +668,30 @@ fn event_loop( let mut focused = true; let mut repeat_deadline: Option = None; let result: Result> = (|| loop { + if let Some(watcher) = worktree_watcher.as_mut() { + while let Ok(event) = watcher.events.try_recv() { + match event { + Ok(event) if watcher.event_is_relevant(&event) => { + worktree_refresh_deadline = Some(Instant::now() + WORKTREE_EVENT_IDLE); + } + Ok(_) => {} + Err(err) => { + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + worktree_watcher = None; + worktree_refresh_deadline = None; + dirty = true; + urgent = true; + break; + } + } + } + } + if worktree_refresh_deadline.is_some_and(|deadline| Instant::now() >= deadline) { + worktree_refresh_deadline = None; + invalidate_worktree_changes(&mut worktree_changes); + dirty = true; + urgent = true; + } while let Ok(event) = ref_events.try_recv() { match event { Ok(event) if !matches!(event.kind, notify::EventKind::Access(_)) => refresh_pending = true, @@ -677,7 +823,8 @@ fn event_loop( &mut fill_repository, &mut notes, &mut commit_message, - &mut changes, + &mut tree_changes, + &mut worktree_changes, &mut selection_relation, &mut line_diff_pool, )?; @@ -753,7 +900,8 @@ fn event_loop( &mut fill_repository, &mut notes, &mut commit_message, - &mut changes, + &mut tree_changes, + &mut worktree_changes, &mut selection_relation, &mut line_diff_pool, )?; @@ -762,11 +910,13 @@ fn event_loop( } let repeat_timeout = repeat_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); let watcher_timeout = ref_watcher.as_ref().map(|_| REF_EVENT_INTERVAL); - let wake_after = match (repeat_timeout, watcher_timeout) { - (Some(repeat), Some(watcher)) => Some(repeat.min(watcher)), - (Some(timeout), None) | (None, Some(timeout)) => Some(timeout), - (None, None) => None, - }; + let worktree_timeout = worktree_refresh_deadline + .map(|deadline| deadline.saturating_duration_since(Instant::now())) + .or_else(|| worktree_watcher.as_ref().map(|_| REF_EVENT_INTERVAL)); + let wake_after = [repeat_timeout, watcher_timeout, worktree_timeout] + .into_iter() + .flatten() + .min(); let terminal_event = match poll_timeout(streaming, events, dirty, last_draw.elapsed(), wake_after) { Some(timeout) if event::poll(timeout)? => Some(event::read()?), Some(_) => None, @@ -801,7 +951,7 @@ fn event_loop( continue; } let action = action(key); - let repeats_history = retains_fill_repository(key.kind, action.as_ref(), app.changes_focused); + let repeats_history = retains_fill_repository(key.kind, action.as_ref(), app.changes_focus.is_some()); if repeats_history { fill_repository.retain = true; repeat_deadline = Some(Instant::now() + REPEAT_IDLE); @@ -809,7 +959,7 @@ fn event_loop( fill_repository.retain = false; fill_repository.retained = None; } - if repeats_history && app.show_changes { + if repeats_history && app.changes_mode.is_some() { app.changes_suppressed = true; } else if key.kind != KeyEventKind::Repeat && app.changes_suppressed { app.changes_suppressed = false; @@ -820,20 +970,32 @@ fn event_loop( let Some(action) = action else { continue; }; - if action == Action::ToggleChangesFocus && !changes_focusable(changes.as_ref().map(|(_, _, changes)| changes)) { - continue; - } dirty = true; urgent = true; + let previous_changes_mode = app.changes_mode; let toggles_changes = action == Action::ToggleChanges; + let refreshes_worktree = action == Action::Refresh && app.changes_mode == Some(ChangesMode::Both); let effects = app.update(action); + if refreshes_worktree { + invalidate_worktree_changes(&mut worktree_changes); + } if toggles_changes { sync_line_diff_pool( &mut line_diff_pool, - app.show_changes, + app.changes_mode.is_some(), &repository_path, line_diff_parallelism, )?; + if app.changes_mode == Some(ChangesMode::Both) { + invalidate_worktree_changes(&mut worktree_changes); + worktree_watcher = start_worktree_watcher(&view_repository); + if worktree_watcher.is_none() { + app.worktree_changes.error = Some("worktree changes won't update automatically".into()); + } + } else if previous_changes_mode == Some(ChangesMode::Both) { + worktree_watcher = None; + worktree_refresh_deadline = None; + } } for effect in effects { match effect { @@ -852,10 +1014,13 @@ fn event_loop( refresh_pending = true; refresh_expand_hidden = true; } - Effect::OpenDiff(index) => { + Effect::OpenDiff(pane, index) => { + let changes = match pane { + ChangePane::Tree => tree_changes.as_ref().map(|(_, _, changes)| changes), + ChangePane::Worktree => worktree_changes.as_ref().map(|(_, changes)| changes), + }; let result = changes - .as_ref() - .and_then(|(_, _, changes)| changes.diffs.get(index).zip(changes.paths.get(index))) + .and_then(|changes| changes.diffs.get(index).zip(changes.paths.get(index))) .context("selected path no longer has diff resources") .and_then(|(change, path)| prepare_file_diff(&repository_path, change, path)) .and_then(|diff| match diff { @@ -869,7 +1034,7 @@ fn event_loop( }); match result { Ok(true) => app.focus_history(), - Err(err) => app.diff_error = Some(format!("{err:#}")), + Err(err) => app.changes_mut(pane).error = Some(format!("{err:#}")), Ok(false) => {} } } @@ -907,7 +1072,8 @@ fn event_loop( &mut fill_repository, &mut notes, &mut commit_message, - &mut changes, + &mut tree_changes, + &mut worktree_changes, &mut selection_relation, &mut line_diff_pool, )?; @@ -918,9 +1084,9 @@ fn event_loop( fn prepare_inline_exit(app: &mut App) { app.inline = true; app.show_commit = false; - app.show_changes = false; + app.changes_mode = None; app.changes_suppressed = false; - app.changes_focused = false; + app.changes_focus = None; app.reset_changes_view(); app.show_selection_tail = false; } @@ -1048,6 +1214,37 @@ fn start_ref_watcher( } } +fn start_worktree_watcher(repository: &gix::Repository) -> Option { + let workdir = repository.workdir()?.to_owned(); + let index = repository.index_path(); + let git_dir = repository.git_dir().to_owned(); + let dot_git = workdir.join(gix::discover::DOT_GIT_DIR); + let (sender, events) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event| { + let _ = sender.send(event); + }) + .ok()?; + watcher.watch(&workdir, RecursiveMode::Recursive).ok()?; + let index_parent = index.parent()?; + if !index_parent.starts_with(&workdir) { + watcher.watch(index_parent, RecursiveMode::NonRecursive).ok()?; + } + Some(WorktreeWatcher { + _watcher: watcher, + events, + workdir, + dot_git, + git_dir, + index, + }) +} + +fn invalidate_worktree_changes(changes: &mut Option<(usize, Changes)>) { + if let Some((marker, _)) = changes { + *marker = usize::MAX; + } +} + fn visible_decorations_changed(old: &Decorations, new: &Decorations, rows: &[CommitRow]) -> bool { rows.iter().any(|row| old.get(&row.id) != new.get(&row.id)) } @@ -1129,11 +1326,12 @@ fn draw( fill_repository: &mut FillRepository<'_>, notes: &mut gix::note::Platform, commit_message: &mut Option<(gix::ObjectId, BString)>, - changes: &mut Option<(gix::ObjectId, usize, Changes)>, + tree_changes: &mut Option<(gix::ObjectId, usize, Changes)>, + worktree_changes: &mut Option<(usize, Changes)>, selection_cache: &mut Option, line_diff_pool: &mut Option, ) -> Result<()> { - app.viewport_rows = terminal + let render_rows = terminal .get_frame() .area() .height @@ -1141,9 +1339,10 @@ fn draw( if !history_is_ready_to_draw(app.state, app.rows.len()) { return Ok(()); } + app.viewport_rows = app.viewport_rows.min(render_rows.max(1)); app.ensure_visible(); let start = app.offset.min(app.rows.len()); - let end = start.saturating_add(app.viewport_rows).min(app.rows.len()); + let end = start.saturating_add(render_rows).min(app.rows.len()); for index in start..end { let id = app.rows[index].id; if app.notes_loaded(id) { @@ -1171,7 +1370,9 @@ fn draw( .then_some(selected_id) .flatten() .filter(|id| selection_cache.as_ref().is_none_or(|cached| cached.id != *id)); - let selected = (app.show_commit || changes_visible).then_some(selected_id).flatten(); + let selected = (app.show_commit || app.changes_mode.is_some()) + .then_some(selected_id) + .flatten(); let message_to_load = app .show_commit .then_some(selected) @@ -1180,26 +1381,36 @@ fn draw( if message_to_load.is_some() { app.reset_commit_view(); } - if changes_visible && selected.is_some() && changes.as_ref().map(|(cached, _, _)| *cached) != selected { + if changes_visible && selected.is_some() && tree_changes.as_ref().map(|(cached, _, _)| *cached) != selected { app.changes_parent = 0; } - let changes_to_load = changes_visible.then_some(selected).flatten().filter(|id| { - changes + let tree_changes_to_load = (changes_visible && app.changes_mode.is_some()) + .then_some(selected) + .flatten() + .filter(|id| { + tree_changes + .as_ref() + .is_none_or(|(cached, parent, _)| *cached != *id || *parent != app.changes_parent) + }); + let worktree_changes_to_load = changes_visible + && app.changes_mode == Some(ChangesMode::Both) + && worktree_changes .as_ref() - .is_none_or(|(cached, parent, _)| cached != id || *parent != app.changes_parent) - }); - if changes_to_load.is_some() { + .is_none_or(|(marker, _)| *marker == usize::MAX); + if tree_changes_to_load.is_some() || worktree_changes_to_load { app.reset_changes_view(); } if !app.show_commit || selected.is_none() { *commit_message = None; } - if !app.show_changes || app.selected.is_none() { - *changes = None; + if app.changes_mode.is_none() { + *tree_changes = None; + *worktree_changes = None; } if app.rows[start..end].iter().any(|row| !row.metadata_loaded) || message_to_load.is_some() - || changes_to_load.is_some() + || tree_changes_to_load.is_some() + || worktree_changes_to_load || relation_to_load.is_some() { let mut one_shot_repository = None; @@ -1230,7 +1441,7 @@ fn draw( *selection_cache = Some(SelectionRelationCache { id, refs, relation }); app.selection_relation = relation; } - if let Some(id) = changes_to_load { + if let Some(id) = tree_changes_to_load { repository.object_cache_size(OBJECT_CACHE_SIZE); let loaded = load_changes( repository, @@ -1243,12 +1454,47 @@ fn draw( repository.object_cache_size(None); let loaded = loaded?; app.changes_parent = loaded.parent.map_or(0, |parent| parent.index); - *changes = Some((id, app.changes_parent, loaded)); + *tree_changes = Some((id, app.changes_parent, loaded)); + } + if worktree_changes_to_load { + repository.object_cache_size(OBJECT_CACHE_SIZE); + let loaded = load_worktree_changes( + repository, + line_diff_pool + .as_mut() + .context("line diff pool is missing while the changes pane is visible")?, + ); + repository.object_cache_size(None); + match loaded { + Ok(loaded) => { + app.worktree_changes.error = None; + *worktree_changes = Some((0, loaded)); + } + Err(err) => { + app.worktree_changes.error = Some(format!("status: {err:#}")); + if let Some((marker, _)) = worktree_changes.as_mut() { + *marker = 0; + } else { + *worktree_changes = Some((0, Changes::default())); + } + } + } } } let message = commit_message.as_ref().map(|(_, message)| message.as_bstr()); - let changes = changes.as_ref().map(|(_, _, changes)| changes); - terminal.draw(|frame| ui::draw(frame, app, decorations, mailmap, message, changes))?; + let tree_changes = tree_changes.as_ref().map(|(_, _, changes)| changes); + let worktree_changes = worktree_changes.as_ref().map(|(_, changes)| changes); + terminal.draw(|frame| { + ui::draw_with_worktree( + frame, + app, + decorations, + mailmap, + message, + tree_changes, + worktree_changes, + ); + })?; Ok(()) } @@ -1267,11 +1513,7 @@ fn open_notes(repository_path: &Path) -> Result { .context("could not open Git notes") } -fn prepare_file_diff( - repository_path: &Path, - change: &gix::object::tree::diff::ChangeDetached, - path: &PathChange, -) -> Result { +fn prepare_file_diff(repository_path: &Path, change: &FileChange, path: &PathChange) -> Result { let mut repository = gix::open(repository_path).context("could not open repository for file diff")?; repository.object_cache_size(OBJECT_CACHE_SIZE); prepare_file_diff_with_repository(&repository, change, path) @@ -1279,26 +1521,45 @@ fn prepare_file_diff( fn prepare_file_diff_with_repository( repository: &gix::Repository, - change: &gix::object::tree::diff::ChangeDetached, + change: &FileChange, path: &PathChange, ) -> Result { + if let FileChange::Unavailable(message) = change { + anyhow::bail!("{message}"); + } let global_command = repository .config_snapshot() .trusted_program(gix::config::tree::Diff::EXTERNAL) .map(gix::path::os_string_into_bstring) .transpose() .context("external diff command is not representable on this platform")?; - let mut resources = repository - .diff_resource_cache( + let mut resources = match change { + FileChange::Tree(_) => repository + .diff_resource_cache( + gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent, + Default::default(), + ) + .context("could not initialize file diff")?, + FileChange::Worktree { .. } => worktree_diff_cache( + repository, gix::diff::blob::pipeline::Mode::ToGitUnlessBinaryToTextIsPresent, - Default::default(), - ) - .context("could not initialize file diff")?; + )? + .context("a working tree is required to show this diff")?, + FileChange::Unavailable(_) => unreachable!("handled above"), + }; resources.options.skip_internal_diff_if_external_is_configured = true; - change - .attach(repository, repository) - .diff(&mut resources) - .context("could not prepare selected file")?; + match change { + FileChange::Tree(change) => { + change + .attach(repository, repository) + .diff(&mut resources) + .context("could not prepare selected file")?; + } + FileChange::Worktree { old, new } => { + set_worktree_resources(repository, &mut resources, old.as_ref(), new.as_ref())?; + } + FileChange::Unavailable(_) => unreachable!("handled above"), + } let prepared = resources.prepare_diff().context("could not prepare selected diff")?; match prepared.operation { gix::diff::blob::platform::prepare_diff::Operation::ExternalCommand { command } => { @@ -1368,37 +1629,41 @@ fn prepare_external_diff( )) } -fn built_in_diff( - path: &PathChange, - change: &gix::object::tree::diff::ChangeDetached, - rendered: Option, - binary: bool, -) -> BuiltInDiff { - use gix::object::tree::diff::ChangeDetached; - +fn built_in_diff(path: &PathChange, change: &FileChange, rendered: Option, binary: bool) -> BuiltInDiff { let (old_path, new_path, old_mode, new_mode) = match change { - ChangeDetached::Addition { entry_mode, .. } => (None, Some(path.path.as_bstr()), None, Some(*entry_mode)), - ChangeDetached::Deletion { entry_mode, .. } => (Some(path.path.as_bstr()), None, Some(*entry_mode), None), - ChangeDetached::Modification { + FileChange::Tree(gix::object::tree::diff::ChangeDetached::Addition { entry_mode, .. }) => { + (None, Some(path.path.as_bstr()), None, Some(*entry_mode)) + } + FileChange::Tree(gix::object::tree::diff::ChangeDetached::Deletion { entry_mode, .. }) => { + (Some(path.path.as_bstr()), None, Some(*entry_mode), None) + } + FileChange::Tree(gix::object::tree::diff::ChangeDetached::Modification { previous_entry_mode, entry_mode, .. - } => ( + }) => ( Some(path.path.as_bstr()), Some(path.path.as_bstr()), Some(*previous_entry_mode), Some(*entry_mode), ), - ChangeDetached::Rewrite { + FileChange::Tree(gix::object::tree::diff::ChangeDetached::Rewrite { source_entry_mode, entry_mode, .. - } => ( + }) => ( path.source.as_ref().map(|path| path.as_bstr()), Some(path.path.as_bstr()), Some(*source_entry_mode), Some(*entry_mode), ), + FileChange::Worktree { old, new } => ( + old.as_ref().map(|resource| resource.path.as_bstr()), + new.as_ref().map(|resource| resource.path.as_bstr()), + old.as_ref().map(|resource| resource.mode), + new.as_ref().map(|resource| resource.mode), + ), + FileChange::Unavailable(_) => unreachable!("unavailable diffs aren't rendered"), }; let display_path = |path: Option<&gix::bstr::BStr>, prefix: &str| -> BString { path.map_or_else( @@ -1670,11 +1935,12 @@ fn load_changes( } out.paths.push(PathChange { kind, + group: ChangeGroup::Tree, source, path, lines: None, }); - diffs.push(change); + diffs.push(FileChange::Tree(change)); } for (path, (change, lines)) in out.paths.iter_mut().zip(line_diff_pool.line_counts(diffs)?) { path.lines = lines; @@ -1687,6 +1953,323 @@ fn load_changes( Ok(out) } +fn entry_mode(mode: gix::index::entry::Mode) -> Result { + mode.to_tree_entry_mode() + .context("status entry cannot be represented in a tree") +} + +fn staged_change(change: gix::diff::index::Change) -> Result<(PathChange, FileChange)> { + use gix::diff::index::Change; + use gix::object::tree::diff::ChangeDetached; + + let (kind, source, path, diff) = match change { + Change::Addition { + location, + entry_mode: mode, + id, + .. + } => { + let entry_mode = entry_mode(mode)?; + let path = location.into_owned(); + let diff = ChangeDetached::Addition { + location: path.clone(), + entry_mode, + relation: None, + id: id.into_owned(), + }; + (ChangeKind::Added, None, path, diff) + } + Change::Deletion { + location, + entry_mode: mode, + id, + .. + } => { + let entry_mode = entry_mode(mode)?; + let path = location.into_owned(); + let diff = ChangeDetached::Deletion { + location: path.clone(), + entry_mode, + relation: None, + id: id.into_owned(), + }; + (ChangeKind::Deleted, None, path, diff) + } + Change::Modification { + location, + previous_entry_mode, + previous_id, + entry_mode: mode, + id, + .. + } => { + let previous_entry_mode = entry_mode(previous_entry_mode)?; + let current_entry_mode = entry_mode(mode)?; + let path = location.into_owned(); + let kind = if previous_entry_mode.kind() == current_entry_mode.kind() { + ChangeKind::Modified + } else { + ChangeKind::TypeChanged + }; + let diff = ChangeDetached::Modification { + location: path.clone(), + previous_entry_mode, + previous_id: previous_id.into_owned(), + entry_mode: current_entry_mode, + id: id.into_owned(), + }; + (kind, None, path, diff) + } + Change::Rewrite { + source_location, + source_entry_mode, + source_id, + location, + entry_mode: mode, + id, + copy, + .. + } => { + let source_entry_mode = entry_mode(source_entry_mode)?; + let current_entry_mode = entry_mode(mode)?; + let source = source_location.into_owned(); + let path = location.into_owned(); + let diff = ChangeDetached::Rewrite { + source_location: source.clone(), + source_entry_mode, + source_relation: None, + source_id: source_id.into_owned(), + diff: None, + entry_mode: current_entry_mode, + id: id.into_owned(), + location: path.clone(), + relation: None, + copy, + }; + ( + if copy { ChangeKind::Copied } else { ChangeKind::Renamed }, + Some(source), + path, + diff, + ) + } + }; + let unavailable = matches!(diff, ChangeDetached::Addition { entry_mode, .. } if entry_mode.is_commit()) + || matches!(diff, ChangeDetached::Deletion { entry_mode, .. } if entry_mode.is_commit()) + || matches!(diff, ChangeDetached::Modification { previous_entry_mode, entry_mode, .. } if previous_entry_mode.is_commit() || entry_mode.is_commit()) + || matches!(diff, ChangeDetached::Rewrite { source_entry_mode, entry_mode, .. } if source_entry_mode.is_commit() || entry_mode.is_commit()); + Ok(( + PathChange { + kind, + group: ChangeGroup::Staged, + source, + path, + lines: None, + }, + if unavailable { + FileChange::Unavailable("submodule changes don't have a file diff") + } else { + FileChange::Tree(diff) + }, + )) +} + +fn worktree_resource(entry: &gix::index::Entry, path: &gix::bstr::BStr) -> Result { + Ok(DiffResource { + id: entry.id, + mode: entry_mode(entry.mode)?, + path: path.to_owned(), + }) +} + +fn unstaged_change( + item: gix::status::index_worktree::Item, + object_hash: gix::hash::Kind, +) -> Result> { + use gix::status::index_worktree::Item; + use gix::status::plumbing::index_as_worktree::{Change, EntryStatus}; + + let (kind, source, path, diff) = match item { + Item::Modification { + entry, + rela_path, + status, + .. + } => { + let old = worktree_resource(&entry, rela_path.as_bstr())?; + match status { + EntryStatus::Conflict { .. } => ( + ChangeKind::Unmerged, + None, + rela_path, + FileChange::Unavailable("an unmerged path has no single file diff"), + ), + EntryStatus::IntentToAdd => ( + ChangeKind::Added, + None, + rela_path.clone(), + FileChange::Worktree { + old: None, + new: Some(DiffResource { + id: entry.id.kind().null(), + mode: old.mode, + path: rela_path, + }), + }, + ), + EntryStatus::NeedsUpdate(_) => return Ok(None), + EntryStatus::Change(Change::Removed) => ( + ChangeKind::Deleted, + None, + rela_path, + FileChange::Worktree { + old: Some(old), + new: None, + }, + ), + EntryStatus::Change(Change::Type { worktree_mode }) => { + let new_mode = entry_mode(worktree_mode)?; + ( + ChangeKind::TypeChanged, + None, + rela_path.clone(), + FileChange::Worktree { + old: Some(old), + new: Some(DiffResource { + id: entry.id.kind().null(), + mode: new_mode, + path: rela_path, + }), + }, + ) + } + EntryStatus::Change(Change::Modification { + executable_bit_changed, .. + }) => { + let mode = if executable_bit_changed { + if old.mode.is_executable() { + gix::objs::tree::EntryKind::Blob + } else { + gix::objs::tree::EntryKind::BlobExecutable + } + .into() + } else { + old.mode + }; + ( + ChangeKind::Modified, + None, + rela_path.clone(), + FileChange::Worktree { + old: Some(old), + new: Some(DiffResource { + id: entry.id.kind().null(), + mode, + path: rela_path, + }), + }, + ) + } + EntryStatus::Change(Change::SubmoduleModification(_)) => ( + ChangeKind::Modified, + None, + rela_path, + FileChange::Unavailable("submodule changes don't have a file diff"), + ), + } + } + Item::DirectoryContents { entry, .. } => { + let mode = match entry.disk_kind { + Some(gix::dir::entry::Kind::File) => gix::objs::tree::EntryKind::Blob.into(), + Some(gix::dir::entry::Kind::Symlink) => gix::objs::tree::EntryKind::Link.into(), + _ => return Ok(None), + }; + let path = entry.rela_path; + ( + ChangeKind::Added, + None, + path.clone(), + FileChange::Worktree { + old: None, + new: Some(DiffResource { + id: object_hash.null(), + mode, + path, + }), + }, + ) + } + Item::Rewrite { + source, + dirwalk_entry, + copy, + .. + } => { + let source = source.rela_path().to_owned(); + let path = dirwalk_entry.rela_path; + ( + if copy { ChangeKind::Copied } else { ChangeKind::Renamed }, + Some(source), + path, + FileChange::Unavailable("unstaged rewrite diffs aren't available"), + ) + } + }; + Ok(Some(( + PathChange { + kind, + group: ChangeGroup::Unstaged, + source, + path, + lines: None, + }, + diff, + ))) +} + +fn load_worktree_changes(repository: &gix::Repository, line_diff_pool: &mut LineDiffPool) -> Result { + let mut status = repository + .status(gix::progress::Discard) + .context("could not initialize worktree status")? + .untracked_files(gix::status::UntrackedFiles::Files) + .index_worktree_options_mut(|options| { + options.sorting = Some(gix::status::plumbing::index_as_worktree_with_renames::Sorting::ByPathCaseSensitive); + }) + .into_iter(Vec::::new()) + .context("could not start worktree status")?; + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + for item in status.by_ref() { + match item.context("could not obtain worktree status")? { + gix::status::Item::TreeIndex(change) => staged.push(staged_change(change)?), + gix::status::Item::IndexWorktree(item) => { + if let Some(change) = unstaged_change(item, repository.object_hash())? { + unstaged.push(change); + } + } + } + } + drop(status); + staged.sort_by(|(a, _), (b, _)| a.path.cmp(&b.path)); + unstaged.sort_by(|(a, _), (b, _)| a.path.cmp(&b.path)); + staged.extend(unstaged); + + let (paths, diffs): (Vec<_>, Vec<_>) = staged.into_iter().unzip(); + let mut out = Changes { + paths, + ..Changes::default() + }; + for (path, (change, lines)) in out.paths.iter_mut().zip(line_diff_pool.line_counts(diffs)?) { + path.lines = lines; + if let Some((insertions, removals)) = lines { + out.lines_added += u64::from(insertions); + out.lines_removed += u64::from(removals); + } + out.diffs.push(change); + } + Ok(out) +} + fn actor_bytes(author: &app::Author) -> Vec { let mut out = Vec::with_capacity(author.name.len() + author.email.len() + 3); out.extend_from_slice(author.name); @@ -1779,10 +2362,6 @@ fn action(key: KeyEvent) -> Option { } } -fn changes_focusable(changes: Option<&Changes>) -> bool { - changes.is_some_and(Changes::is_visible) -} - fn repeats_viewport(action: &Action) -> bool { matches!( action, @@ -1935,6 +2514,7 @@ mod tests { root.paths, [PathChange { kind: ChangeKind::Added, + group: ChangeGroup::Tree, source: None, path: "root".into(), lines: Some((1, 0)), @@ -2011,12 +2591,14 @@ mod tests { [ PathChange { kind: ChangeKind::Added, + group: ChangeGroup::Tree, source: None, path: "topic".into(), lines: Some((1, 0)), }, PathChange { kind: ChangeKind::Added, + group: ChangeGroup::Tree, source: None, path: "topic-extra".into(), lines: Some((1, 0)), @@ -2040,6 +2622,7 @@ mod tests { first_parent.paths, [PathChange { kind: ChangeKind::Added, + group: ChangeGroup::Tree, source: None, path: "merged".into(), lines: Some((1, 0)), @@ -2060,6 +2643,7 @@ mod tests { second_parent.paths, [PathChange { kind: ChangeKind::Added, + group: ChangeGroup::Tree, source: None, path: "main".into(), lines: Some((1, 0)), @@ -2074,6 +2658,80 @@ mod tests { Ok(()) } + #[test] + fn loads_staged_and_unstaged_worktree_changes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let path = fixture.path(); + let git = |args: &[&str]| -> std::io::Result { + std::process::Command::new("git") + .current_dir(path) + .args(["-c", "commit.gpgsign=false"]) + .args(args) + .status() + }; + + assert!(git(&["switch", "-q", "-c", "conflict-other"])?.success()); + std::fs::write(path.join("root"), "other\n")?; + assert!(git(&["commit", "-qam", "other"])?.success()); + assert!(git(&["switch", "-q", "main"])?.success()); + std::fs::write(path.join("root"), "ours\n")?; + assert!(git(&["commit", "-qam", "ours"])?.success()); + assert!( + !git(&["merge", "--no-edit", "conflict-other"])?.success(), + "the fixture deliberately leaves an unresolved path" + ); + + std::fs::write(path.join("staged"), "staged\n")?; + std::fs::write(path.join("both"), "index\n")?; + assert!(git(&["add", "staged", "both"])?.success()); + std::fs::write(path.join("both"), "index\nworktree\n")?; + std::fs::write(path.join("untracked"), "untracked\n")?; + std::fs::write(path.join(".git/info/exclude"), "ignored\n")?; + std::fs::write(path.join("ignored"), "ignored\n")?; + + let repository = gix::open(path)?; + let mut line_diff_pool = LineDiffPool::new(path, 2)?; + let changes = load_worktree_changes(&repository, &mut line_diff_pool)?; + let rows: Vec<_> = changes + .paths + .iter() + .map(|change| (change.group, change.kind, change.path.to_string())) + .collect(); + assert_eq!( + rows, + [ + (ChangeGroup::Staged, ChangeKind::Added, "both".into()), + (ChangeGroup::Staged, ChangeKind::Added, "staged".into()), + (ChangeGroup::Unstaged, ChangeKind::Added, ".mailmap".into()), + (ChangeGroup::Unstaged, ChangeKind::Modified, "both".into()), + (ChangeGroup::Unstaged, ChangeKind::Unmerged, "root".into()), + (ChangeGroup::Unstaged, ChangeKind::Added, "untracked".into()), + ], + "status is partitioned, path-sorted, includes conflicts and untracked files, and excludes ignored files" + ); + assert!(changes.lines_added > 0, "available file diffs contribute line counts"); + for (path, diff) in changes.paths.iter().zip(&changes.diffs) { + if path.kind != ChangeKind::Unmerged { + prepare_file_diff_with_repository(&repository, diff, path) + .with_context(|| format!("{} should produce a staged or worktree diff", path.path))?; + } + } + let conflict = changes + .paths + .iter() + .position(|change| change.kind == ChangeKind::Unmerged) + .expect("the conflict is visible"); + assert!( + prepare_file_diff_with_repository(&repository, &changes.diffs[conflict], &changes.paths[conflict]) + .err() + .expect("conflicts cannot produce a single file diff") + .to_string() + .contains("no single file diff"), + "opening an unresolved path produces actionable feedback" + ); + Ok(()) + } + #[test] fn streams_diff_bytes_and_accepts_early_pager_exit() -> gix_testtools::Result { let diff = BuiltInDiff::new( @@ -2155,13 +2813,16 @@ mod tests { let mut inline = App::new(1); configure_initial_screen(&mut inline, true); assert!(inline.inline); - assert!(!inline.show_changes, "inline startup hides the default changes view"); + assert_eq!( + inline.changes_mode, None, + "inline startup hides the default changes view" + ); let mut alternate = App::new(1); configure_initial_screen(&mut alternate, false); assert!(!alternate.inline); assert!( - alternate.show_changes, - "alternate-screen startup keeps the default changes view" + alternate.changes_mode == Some(ChangesMode::Both), + "alternate-screen startup keeps the default tree and worktree changes view" ); assert!( @@ -2329,22 +2990,6 @@ mod tests { assert_eq!(action(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), None); } - #[test] - fn only_visible_changes_can_take_focus() { - assert!(!changes_focusable(None)); - assert!(!changes_focusable(Some(&Changes::default()))); - let changes = Changes { - paths: vec![PathChange { - kind: ChangeKind::Modified, - source: None, - path: "file".into(), - lines: None, - }], - ..Changes::default() - }; - assert!(changes_focusable(Some(&changes))); - } - #[test] fn retains_the_fill_repository_only_for_repeated_viewport_navigation() { assert!(retains_fill_repository( @@ -2383,17 +3028,17 @@ mod tests { fn prepares_a_reduced_selection_after_leaving_the_alternate_screen() { let mut app = App::new(1); app.show_commit = true; - app.show_changes = true; - app.changes_focused = true; + app.changes_mode = Some(ChangesMode::Tree); + app.changes_focus = Some(ChangePane::Tree); prepare_inline_exit(&mut app); assert!(app.inline, "the final frame is drawn into the restored inline screen"); assert!( - !app.show_commit && !app.show_changes, + !app.show_commit && app.changes_mode.is_none(), "alternate-screen panels are omitted from the final frame" ); - assert!(!app.changes_focused, "the hidden panel no longer owns focus"); + assert_eq!(app.changes_focus, None, "the hidden panel no longer owns focus"); assert!(!app.show_selection_tail, "only the left selection marker remains"); } @@ -2467,4 +3112,46 @@ mod tests { "the earlier frame deadline takes precedence over repeat-idle restoration" ); } + + #[test] + fn filters_worktree_watch_events_and_invalidates_cached_status() { + use notify::event::{AccessKind, ModifyKind}; + + let workdir = Path::new("/repo"); + let dot_git = workdir.join(".git"); + let git_dir = dot_git.clone(); + let index = git_dir.join("index"); + let modified = + |path: &Path| notify::Event::new(notify::EventKind::Modify(ModifyKind::Any)).add_path(path.to_owned()); + assert!(worktree_event_is_relevant( + &modified(&workdir.join("src/lib.rs")), + workdir, + &dot_git, + &git_dir, + &index + )); + assert!(worktree_event_is_relevant( + &modified(&index), + workdir, + &dot_git, + &git_dir, + &index + )); + assert!(!worktree_event_is_relevant( + &modified(&git_dir.join("HEAD")), + workdir, + &dot_git, + &git_dir, + &index + )); + let access = + notify::Event::new(notify::EventKind::Access(AccessKind::Any)).add_path(workdir.join("src/lib.rs")); + assert!(!worktree_event_is_relevant( + &access, workdir, &dot_git, &git_dir, &index + )); + + let mut changes = Some((0, Changes::default())); + invalidate_worktree_changes(&mut changes); + assert_eq!(changes.as_ref().map(|(marker, _)| *marker), Some(usize::MAX)); + } } diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 9273affa298..08618812700 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -10,8 +10,8 @@ use ratatui::{ use crate::{ BuiltInDiff, app::{ - App, AttributionKind, ChangeKind, Changes, CommitRow, CopyKind, NameMode, RefMode, SelectionRelation, - SignatureState, State, + App, AttributionKind, ChangeGroup, ChangeKind, ChangePane, Changes, ChangesLayout, ChangesMode, CommitRow, + CopyKind, NameMode, RefMode, SelectionRelation, SignatureState, State, }, history::{DecorationKind, Decorations}, }; @@ -20,6 +20,113 @@ const COMPARED_PARENT_COLOR: Color = Color::Cyan; const NOTE_COLOR: Color = Color::LightMagenta; const PANE_STATUS_BACKGROUND: Color = Color::DarkGray; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ChangesPaneArea { + pane: ChangePane, + outer: Rect, +} + +fn changes_pane_areas( + area: Rect, + max_height: u16, + tree: Option<(u16, usize)>, + worktree: Option<(u16, usize)>, +) -> (ChangesLayout, Vec, u16) { + match (tree, worktree) { + (None, None) => (ChangesLayout::SideBySide, Vec::new(), 0), + (Some((height, _)), None) => { + let height = height.min(max_height); + ( + ChangesLayout::SideBySide, + vec![ChangesPaneArea { + pane: ChangePane::Tree, + outer: Rect::new(area.x, area.bottom().saturating_sub(height), area.width, height), + }], + height, + ) + } + (None, Some((height, _))) => { + let height = height.min(max_height); + ( + ChangesLayout::SideBySide, + vec![ChangesPaneArea { + pane: ChangePane::Worktree, + outer: Rect::new(area.x, area.bottom().saturating_sub(height), area.width, height), + }], + height, + ) + } + (Some((tree_height, tree_title)), Some((worktree_height, worktree_title))) => { + let tree_width = area.width / 2; + let worktree_width = area.width.saturating_sub(tree_width); + if tree_title <= usize::from(tree_width) && worktree_title <= usize::from(worktree_width) { + let tree_height = tree_height.min(max_height); + let worktree_height = worktree_height.min(max_height); + let height = tree_height.max(worktree_height); + ( + ChangesLayout::SideBySide, + vec![ + ChangesPaneArea { + pane: ChangePane::Tree, + outer: Rect::new( + area.x, + area.bottom().saturating_sub(tree_height), + tree_width, + tree_height, + ), + }, + ChangesPaneArea { + pane: ChangePane::Worktree, + outer: Rect::new( + area.x.saturating_add(tree_width), + area.bottom().saturating_sub(worktree_height), + worktree_width, + worktree_height, + ), + }, + ], + height, + ) + } else { + let total = tree_height.saturating_add(worktree_height); + let (worktree_height, tree_height) = if total <= max_height { + (worktree_height, tree_height) + } else { + let half = max_height / 2; + if worktree_height <= half { + (worktree_height, max_height.saturating_sub(worktree_height)) + } else if tree_height <= half { + (max_height.saturating_sub(tree_height), tree_height) + } else { + (half.saturating_add(max_height % 2), half) + } + }; + let height = worktree_height.saturating_add(tree_height); + let tree_y = area.bottom().saturating_sub(tree_height); + ( + ChangesLayout::Stacked, + vec![ + ChangesPaneArea { + pane: ChangePane::Worktree, + outer: Rect::new( + area.x, + tree_y.saturating_sub(worktree_height), + area.width, + worktree_height, + ), + }, + ChangesPaneArea { + pane: ChangePane::Tree, + outer: Rect::new(area.x, tree_y, area.width, tree_height), + }, + ], + height, + ) + } + } + } +} + pub(crate) fn draw_file_diff(frame: &mut Frame<'_>, diff: &BuiltInDiff, offset: usize, horizontal_offset: usize) { let [header, body, footer] = Layout::vertical([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)]).areas(frame.area()); @@ -59,13 +166,26 @@ pub(crate) fn draw_file_diff(frame: &mut Frame<'_>, diff: &BuiltInDiff, offset: ); } +#[cfg(test)] pub(crate) fn draw( frame: &mut Frame<'_>, app: &mut App, decorations: &Decorations, mailmap: &gix::mailmap::Snapshot, commit_message: Option<&BStr>, - changes: Option<&Changes>, + tree_changes: Option<&Changes>, +) { + draw_with_worktree(frame, app, decorations, mailmap, commit_message, tree_changes, None); +} + +pub(crate) fn draw_with_worktree( + frame: &mut Frame<'_>, + app: &mut App, + decorations: &Decorations, + mailmap: &gix::mailmap::Snapshot, + commit_message: Option<&BStr>, + tree_changes: Option<&Changes>, + worktree_changes: Option<&Changes>, ) { let [top_spacer, mut body, bottom_spacer, footer] = Layout::vertical([ Constraint::Length(u16::from(app.inline)), @@ -78,26 +198,44 @@ pub(crate) fn draw( frame.render_widget(Clear, bottom_spacer); let full_body = body; let compared_parent = if app.changes_visible() { - changes.and_then(|changes| changes.parent.map(|parent| parent.id)) + tree_changes.and_then(|changes| changes.parent.map(|parent| parent.id)) } else { None }; - let changes_pane = app.changes_visible().then(|| { - let desired_height = changes.filter(|changes| changes.is_visible()).map_or(0, |changes| { - u16::try_from(changes.paths.len()).unwrap_or(u16::MAX).saturating_add(3) - }); - let max_height = frame.area().height / 2; - let height = desired_height.min(max_height); - let [commits, changes] = Layout::vertical([Constraint::Min(0), Constraint::Length(height)]).areas(full_body); - body = commits; - ( - changes, - changes.inner(Margin { - horizontal: 2, - vertical: 1, - }), - ) - }); + let tree_visible = app.changes_visible() && tree_changes.is_some_and(Changes::is_visible); + let worktree_visible = + app.changes_visible() && app.changes_mode == Some(ChangesMode::Both) && worktree_changes.is_some(); + let tree_summary = tree_changes.map(|changes| changes_summary(ChangePane::Tree, app, changes)); + let worktree_summary = worktree_changes.map(|changes| changes_summary(ChangePane::Worktree, app, changes)); + let pane_height = |changes: &Changes| u16::try_from(changes.paths.len()).unwrap_or(u16::MAX).saturating_add(2); + let (changes_layout, changes_panes, _) = changes_pane_areas( + full_body, + frame.area().height / 2, + tree_visible.then(|| { + ( + pane_height(tree_changes.expect("visible tree changes exist")), + tree_summary.as_ref().map_or(0, Line::width), + ) + }), + worktree_visible.then(|| { + ( + pane_height(worktree_changes.expect("visible worktree changes exist")), + worktree_summary.as_ref().map_or(0, Line::width), + ) + }), + ); + if app.changes_visible() { + app.set_changes_layout( + changes_layout, + changes_panes + .iter() + .any(|pane| pane.pane == ChangePane::Tree && pane.outer.height > 0), + changes_panes + .iter() + .any(|pane| pane.pane == ChangePane::Worktree && pane.outer.height > 0) + && worktree_changes.is_some_and(Changes::is_visible), + ); + } let commit_pane = app.show_commit.then(|| { let width = 80.min(full_body.width / 2); let [commits, message] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]).areas(full_body); @@ -110,17 +248,21 @@ pub(crate) fn draw( }), ) }); - app.viewport_rows = body.height as usize; + app.viewport_rows = changes_panes + .iter() + .map(|pane| pane.outer.y.saturating_sub(body.y)) + .min() + .unwrap_or(body.height) + .max(1) as usize; app.ensure_visible(); let start = app.offset.min(app.rows.len()); - let end = start.saturating_add(app.viewport_rows).min(app.rows.len()); - let lane_end = start.saturating_add(full_body.height as usize).min(app.rows.len()); - let visible_rows = &app.rows[start..end]; + let render_end = start.saturating_add(body.height as usize).min(app.rows.len()); + let visible_rows = &app.rows[start..render_end]; let has_verifiable_signatures = visible_rows.iter().enumerate().any(|(index, row)| { !app.is_row_hidden(start + index) && matches!(row.signature, SignatureState::Unverified | SignatureState::Verifying) }); - let lanes = app.render_lanes(start..lane_end); + let lanes = app.render_lanes(start..render_end); let content = Rect::new( body.x.saturating_add(2), body.y, @@ -200,7 +342,7 @@ pub(crate) fn draw( let graph_offset = horizontal_offset.min(graph_max_offset); let selection_info = selection_info_line( app.changes_visible() - .then_some(changes) + .then_some(tree_changes) .flatten() .filter(|changes| changes.is_visible()), app.selection_relation, @@ -342,51 +484,79 @@ pub(crate) fn draw( } } app.set_horizontal_bounds(content.width as usize, max_offset); - if let Some((outer, area)) = changes_pane { + if app.changes_focus.is_some() { + frame + .buffer_mut() + .set_style(body, Style::default().add_modifier(Modifier::DIM)); + if let Some(area) = selection_info_area { + frame.render_widget(Paragraph::new(selection_info), area); + } + } + for pane_area in &changes_panes { + let outer = pane_area.outer; + let pane = pane_area.pane; + let changes = match pane { + ChangePane::Tree => tree_changes.expect("visible tree changes exist"), + ChangePane::Worktree => worktree_changes.expect("visible worktree changes exist"), + }; + let summary = match pane { + ChangePane::Tree => tree_summary.clone().expect("visible tree summary exists"), + ChangePane::Worktree => worktree_summary.clone().expect("visible worktree summary exists"), + }; + let area = outer.inner(Margin { + horizontal: 2, + vertical: 1, + }); frame.render_widget(Clear, outer); - frame.render_widget(Block::new().borders(Borders::TOP), outer); - if let Some(changes) = changes.filter(|changes| changes.is_visible()) { - render_changes(frame, area, changes, app); - if app.changes_focused { - let status = Rect::new( - outer.x.saturating_add(2), - outer.bottom().saturating_sub(1), - outer.width.saturating_sub(4), - 1, - ); - let mut spans = Vec::new(); - if let Some(parent) = changes.parent { - spans.extend([ - Span::styled( - format!( - "vs parent {}/{} {}", - parent.index + 1, - parent.total, - parent.id.to_hex_with_len(7) - ), - color(COMPARED_PARENT_COLOR), + frame.render_widget(Block::new().borders(Borders::TOP).title(summary), outer); + render_changes(frame, area, changes, pane, app); + if app.changes_focus == Some(pane) { + let status = Rect::new( + outer.x.saturating_add(2), + outer.bottom().saturating_sub(1), + outer.width.saturating_sub(4), + 1, + ); + let mut spans = Vec::new(); + if pane == ChangePane::Tree + && let Some(parent) = changes.parent + { + spans.extend([ + Span::styled( + format!( + "vs parent {}/{} {}", + parent.index + 1, + parent.total, + parent.id.to_hex_with_len(7) ), - Span::raw(" · p next parent · "), - ]); - } - if let Some(error) = &app.diff_error { - spans.push(Span::styled(format!("diff: {error}"), color(Color::Red))); - } else { - spans.push(Span::raw("↑↓/jk move · h/l pan · Enter diff")); - } - spans.push(Span::raw(" · c to hide")); - frame.render_widget( - Paragraph::new(Line::from(spans)).style(Style::default().bg(PANE_STATUS_BACKGROUND)), - status, - ); + color(COMPARED_PARENT_COLOR), + ), + Span::raw(" · p next parent · "), + ]); } - } - if !app.changes_focused { + if let Some(error) = &app.changes(pane).error { + spans.push(Span::styled(format!("diff: {error}"), color(Color::Red))); + } else { + spans.push(Span::raw("↑↓/jk move · h/l pan · Enter diff")); + } + spans.push(Span::raw(match app.changes_mode { + Some(ChangesMode::Both) => " · c tree", + Some(ChangesMode::Tree) => " · c to hide", + None => "", + })); + frame.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(PANE_STATUS_BACKGROUND)), + status, + ); + } else { frame .buffer_mut() .set_style(outer, Style::default().add_modifier(Modifier::DIM)); } } + if changes_layout == ChangesLayout::SideBySide { + render_changes_divider(frame, &changes_panes, app); + } if let Some((outer, area)) = commit_pane { frame.render_widget(Clear, outer); let max_offset = if let Some(message) = commit_message { @@ -425,18 +595,18 @@ pub(crate) fn draw( "{} commits{status} · ↑↓/jk move · h/l pan", app.rows.len() ))]; - if app.changes_visible() && changes.is_some_and(Changes::is_visible) { + if app.tree_changes_visible || app.worktree_changes_visible { footer_spans.push(match app.focus_feedback.take() { Some(destination) => Span::raw(format!(" · Tab → {destination}")), None => Span::raw(" · Tab switch"), }); } - if app.changes_focused { + if app.changes_focus.is_some() { footer_spans.push(Span::raw(" · q/Esc history")); } footer_spans.extend([Span::raw(" · "), toggle("[ align", app.align_metadata)]); footer_spans.extend([Span::raw(" · "), toggle("o commit", app.show_commit)]); - footer_spans.extend([Span::raw(" · "), toggle("c changes", app.show_changes)]); + footer_spans.extend([Span::raw(" · "), toggle("c changes", app.changes_mode.is_some())]); if app.has_hidden_filter { footer_spans.extend([ Span::raw(" · "), @@ -493,20 +663,41 @@ pub(crate) fn draw( Span::styled("●", color(Color::Green)), ]); } - if !app.changes_focused { + if app.changes_focus.is_none() { if app.state == State::Loading { footer_spans.push(Span::raw(" · Esc cancel")); } footer_spans.push(Span::raw(" · q quit")); } frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer); - if app.changes_focused { - frame - .buffer_mut() - .set_style(body, Style::default().add_modifier(Modifier::DIM)); - if let Some(area) = selection_info_area { - frame.render_widget(Paragraph::new(selection_info), area); - } +} + +fn render_changes_divider(frame: &mut Frame<'_>, panes: &[ChangesPaneArea], app: &App) { + let Some(tree) = panes.iter().find(|pane| pane.pane == ChangePane::Tree) else { + return; + }; + let Some(worktree) = panes.iter().find(|pane| pane.pane == ChangePane::Worktree) else { + return; + }; + let x = worktree.outer.x; + let top = tree.outer.y.min(worktree.outer.y); + let bottom = tree.outer.bottom().max(worktree.outer.bottom()); + let style = if app.changes_focus.is_none() { + Style::default().add_modifier(Modifier::DIM) + } else { + Style::default() + }; + for y in top..bottom { + let symbol = if tree.outer.y == worktree.outer.y && y == tree.outer.y { + "┬" + } else if y == tree.outer.y { + if tree.outer.y < worktree.outer.y { "┐" } else { "┤" } + } else if y == worktree.outer.y { + if worktree.outer.y < tree.outer.y { "┌" } else { "├" } + } else { + "│" + }; + frame.buffer_mut()[(x, y)].set_symbol(symbol).set_style(style); } } @@ -560,47 +751,14 @@ fn push_selection_span(spans: &mut Vec>, span: Span<'static>) { spans.push(span); } -fn render_changes(frame: &mut Frame<'_>, area: Rect, changes: &Changes, app: &mut App) { - if !changes.is_visible() || area.height == 0 { - app.set_changes_bounds(0, 0, area.width as usize, 0); +fn render_changes(frame: &mut Frame<'_>, area: Rect, changes: &Changes, pane: ChangePane, app: &mut App) { + if area.height == 0 { + app.set_changes_bounds(pane, 0, 0, area.width as usize, 0); return; } - let mut summary = Vec::new(); - for kind in [ - ChangeKind::Added, - ChangeKind::Modified, - ChangeKind::Deleted, - ChangeKind::Renamed, - ChangeKind::Copied, - ChangeKind::TypeChanged, - ] { - let count = changes.paths.iter().filter(|change| change.kind == kind).count(); - if count == 0 { - continue; - } - if !summary.is_empty() { - summary.push(Span::raw(" ")); - } - summary.push(Span::styled( - format!("{} = {count}", kind.letter()), - color(change_color(kind)), - )); - } - if !summary.is_empty() { - summary.push(Span::raw(" · ")); - } - summary.extend([ - Span::raw(format!("{} files changed · ", changes.paths.len())), - Span::styled(format!("+{}", changes.lines_added), color(Color::Green)), - Span::raw(" "), - Span::styled(format!("-{}", changes.lines_removed), color(Color::Red)), - ]); - frame.render_widget( - Paragraph::new(Line::from(summary)), - Rect::new(area.x, area.y, area.width, 1), - ); - - let path_capacity = usize::from(area.height.saturating_sub(1)); + let focused = app.changes_focus == Some(pane); + let selected_index = app.changes(pane).selected.min(changes.paths.len().saturating_sub(1)); + let path_capacity = usize::from(area.height); let overflow = changes.paths.len() > 1 && changes.paths.len() > path_capacity; let visible_paths = if overflow { path_capacity.saturating_sub(1) @@ -612,14 +770,14 @@ fn render_changes(frame: &mut Frame<'_>, area: Rect, changes: &Changes, app: &mu .iter() .enumerate() .map(|(index, change)| { - let selected = app.changes_focused && index == app.changes_selected; + let selected = focused && index == selected_index; let path_style = if selected { Style::default().add_modifier(Modifier::REVERSED) } else { Style::default() }; let mut spans = vec![ - Span::styled(change.kind.letter().to_string(), color(change_color(change.kind))), + Span::styled(change.kind.letter().to_string(), color(path_change_color(change))), Span::raw(" "), ]; if let Some(source) = &change.source { @@ -648,28 +806,29 @@ fn render_changes(frame: &mut Frame<'_>, area: Rect, changes: &Changes, app: &mu .max() .unwrap_or_default() .saturating_sub(area.width as usize); - app.set_changes_bounds(visible_paths, changes.paths.len(), area.width as usize, horizontal_max); + app.set_changes_bounds( + pane, + visible_paths, + changes.paths.len(), + area.width as usize, + horizontal_max, + ); + let offset = app.changes(pane).offset; + let horizontal_offset = app.changes(pane).horizontal_offset; let path_area = Rect::new( area.x, - area.y.saturating_add(1), + area.y, area.width, u16::try_from(visible_paths).unwrap_or(u16::MAX), ); frame.render_widget( Paragraph::new(Text::from( - lines - .into_iter() - .skip(app.changes_offset) - .take(visible_paths) - .collect::>(), + lines.into_iter().skip(offset).take(visible_paths).collect::>(), )) - .scroll((0, u16::try_from(app.changes_horizontal_offset).unwrap_or(u16::MAX))), + .scroll((0, u16::try_from(horizontal_offset).unwrap_or(u16::MAX))), path_area, ); - let hidden = changes - .paths - .len() - .saturating_sub(app.changes_offset.saturating_add(visible_paths)); + let hidden = changes.paths.len().saturating_sub(offset.saturating_add(visible_paths)); if overflow && hidden > 0 { frame.render_widget( Paragraph::new(Line::styled( @@ -693,9 +852,98 @@ fn change_color(kind: ChangeKind) -> Color { ChangeKind::Deleted => Color::Red, ChangeKind::Renamed | ChangeKind::Copied => Color::Cyan, ChangeKind::TypeChanged => Color::Magenta, + ChangeKind::Unmerged => Color::Red, } } +fn path_change_color(change: &crate::app::PathChange) -> Color { + match change.group { + ChangeGroup::Tree => change_color(change.kind), + ChangeGroup::Staged => Color::Green, + ChangeGroup::Unstaged => Color::Red, + } +} + +fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'static> { + let mut spans = match pane { + ChangePane::Tree => { + let id = app + .selected + .and_then(|index| app.rows.get(index)) + .map_or_else(|| "-------".into(), |row| row.id.to_hex_with_len(7).to_string()); + vec![Span::raw(format!("─ Tree {id} ── "))] + } + ChangePane::Worktree => vec![Span::raw("─ Worktree ── ")], + }; + if pane == ChangePane::Worktree && changes.paths.is_empty() { + spans.extend([ + Span::styled("+0", color(Color::Green)), + Span::raw(" "), + Span::styled("-0", color(Color::Red)), + Span::raw(" "), + ]); + return Line::from(spans); + } + let counts: Vec<_> = match pane { + ChangePane::Tree => { + let mut counts = Vec::new(); + for kind in [ + ChangeKind::Added, + ChangeKind::Modified, + ChangeKind::Deleted, + ChangeKind::Renamed, + ChangeKind::Copied, + ChangeKind::TypeChanged, + ] { + let count = changes.paths.iter().filter(|change| change.kind == kind).count(); + if count == 0 { + continue; + } + counts.push((kind.letter().to_string(), count, change_color(kind))); + } + counts + } + ChangePane::Worktree => { + let staged = changes + .paths + .iter() + .filter(|change| change.group == ChangeGroup::Staged) + .count(); + let unstaged = changes.paths.len().saturating_sub(staged); + [ + ("S".to_owned(), staged, Color::Green), + ("U".to_owned(), unstaged, Color::Red), + ] + .into_iter() + .filter(|(_, count, _)| *count > 0) + .collect() + } + }; + let has_counts = !counts.is_empty(); + let show_total = counts.len() != 1 || counts[0].1 != changes.paths.len(); + for (index, (label, count, count_color)) in counts.into_iter().enumerate() { + if index > 0 { + spans.push(Span::raw(" + ")); + } + spans.push(Span::styled(format!("{label} {count}"), color(count_color))); + } + if show_total { + spans.push(Span::raw(format!( + "{}= {}", + if has_counts { " " } else { "" }, + changes.paths.len() + ))); + } + spans.extend([ + Span::raw(" · "), + Span::styled(format!("+{}", changes.lines_added), color(Color::Green)), + Span::raw(" "), + Span::styled(format!("-{}", changes.lines_removed), color(Color::Red)), + Span::raw(" "), + ]); + Line::from(spans) +} + fn render_commit_message(frame: &mut Frame<'_>, area: Rect, message: &BStr, notes: &[BString], offset: usize) -> usize { let parsed = gix::objs::commit::MessageRef::from_bytes(message); let mut body_message = BString::default(); @@ -1163,6 +1411,7 @@ mod tests { let changes = Changes { paths: vec![crate::app::PathChange { kind: ChangeKind::Modified, + group: ChangeGroup::Tree, source: None, path: "file".into(), lines: Some((3, 4)), @@ -1171,7 +1420,7 @@ mod tests { lines_removed: 4, ..Changes::default() }; - app.changes_focused = true; + app.changes_focus = Some(ChangePane::Tree); let mut terminal = Terminal::new(TestBackend::new(38, 7))?; terminal.draw(|frame| { @@ -1895,70 +2144,6 @@ mod tests { Ok(()) } - #[test] - fn changing_the_changes_height_keeps_history_alignment_stable() -> Result<(), Box> { - let mut app = App::new(11); - app.extend_commits( - (1..=8) - .map(|n| Commit { - id: gix::ObjectId::Sha1([n; 20]), - parent_ids: Default::default(), - committer_time: gix::date::Time::default(), - author: author(b"author", b"author@example.com"), - attributions: 0..0, - title: format!("subject {n}").into(), - metadata_loaded: true, - has_agent_marker: false, - signature: SignatureState::Unsigned, - }) - .collect::>(), - ); - complete(&mut app); - app.set_lane(6, "●──────── "); - let path = crate::app::PathChange { - kind: ChangeKind::Modified, - source: None, - path: "path".into(), - lines: None, - }; - let changes = |len| Changes { - paths: vec![path.clone(); len], - ..Changes::default() - }; - let mut terminal = Terminal::new(TestBackend::new(80, 12))?; - - terminal.draw(|frame| { - super::draw( - frame, - &mut app, - &Decorations::new(), - &gix::mailmap::Snapshot::default(), - None, - Some(&changes(1)), - ); - })?; - let short = rendered_line(&terminal, 0) - .find("0101010") - .expect("metadata is visible with a short changes pane"); - - terminal.draw(|frame| { - super::draw( - frame, - &mut app, - &Decorations::new(), - &gix::mailmap::Snapshot::default(), - None, - Some(&changes(8)), - ); - })?; - assert_eq!( - rendered_line(&terminal, 0).find("0101010"), - Some(short), - "changes pane height does not move aligned history metadata" - ); - Ok(()) - } - #[test] fn pages_overflowing_commit_messages_and_hides_the_status_when_they_fit() -> Result<(), Box> { @@ -2038,6 +2223,87 @@ mod tests { Ok(()) } + #[test] + fn changing_the_changes_height_keeps_history_alignment_stable() -> Result<(), Box> { + let mut app = App::new(11); + app.extend_commits( + (1..=10) + .map(|n| Commit { + id: gix::ObjectId::Sha1([n; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: format!("subject {n}").into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }) + .collect::>(), + ); + complete(&mut app); + app.selected = Some(7); + app.ensure_visible(); + let selection = app.selected; + app.set_lane(6, "●──────── "); + let path = crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: "path".into(), + lines: None, + }; + let changes = |len| Changes { + paths: vec![path.clone(); len], + ..Changes::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 12))?; + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes(1)), + ); + })?; + let short = rendered_line(&terminal, 0) + .find("0101010") + .expect("metadata is visible with a short changes pane"); + assert_eq!((app.selected, app.offset), (selection, 0)); + + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes(8)), + ); + })?; + assert_eq!( + rendered_line(&terminal, 0).find("0404040"), + Some(short), + "changes pane height does not move aligned history metadata" + ); + assert_eq!( + (app.selected, app.offset), + (selection, 3), + "the selected commit stays immediately above the taller changes pane" + ); + + app.update(Action::MoveDown); + assert_eq!( + (app.selected, app.offset), + (Some(8), 4), + "moving down advances the commit and scrolls history at the pane boundary" + ); + Ok(()) + } + #[test] fn shows_changed_paths_in_a_bottom_pane_below_the_summary() -> Result<(), Box> { let mut app = App::new(6); @@ -2073,36 +2339,42 @@ mod tests { paths: vec![ crate::app::PathChange { kind: ChangeKind::Added, + group: ChangeGroup::Tree, source: None, path: "added".into(), lines: Some((10, 0)), }, crate::app::PathChange { kind: ChangeKind::Modified, + group: ChangeGroup::Tree, source: None, path: "modified".into(), lines: Some((5, 2)), }, crate::app::PathChange { kind: ChangeKind::Deleted, + group: ChangeGroup::Tree, source: None, path: "deleted".into(), lines: Some((0, 7)), }, crate::app::PathChange { kind: ChangeKind::Renamed, + group: ChangeGroup::Tree, source: Some("old".into()), path: "new".into(), lines: Some((3, 3)), }, crate::app::PathChange { kind: ChangeKind::Copied, + group: ChangeGroup::Tree, source: Some("source".into()), path: "copy".into(), lines: Some((0, 0)), }, crate::app::PathChange { kind: ChangeKind::TypeChanged, + group: ChangeGroup::Tree, source: None, path: format!("{}tail", "x".repeat(130)).into(), lines: Some((24, 5)), @@ -2126,12 +2398,12 @@ mod tests { })?; assert_eq!( - terminal.backend().buffer()[(20, 7)].symbol(), + terminal.backend().buffer()[(119, 7)].symbol(), "─", "the changes pane starts at the screen's halfway point" ); assert!( - terminal.backend().buffer()[(20, 7)].modifier.contains(Modifier::DIM), + terminal.backend().buffer()[(119, 7)].modifier.contains(Modifier::DIM), "the inactive changes border is dimmed" ); assert!( @@ -2139,43 +2411,53 @@ mod tests { && !terminal.backend().buffer()[(2, 15)].modifier.contains(Modifier::DIM), "the focused history and its status use their normal intensity" ); - let summary = rendered_line(&terminal, 8); + let summary = rendered_line(&terminal, 7); + assert_eq!( + terminal.backend().buffer()[(0, 7)].symbol(), + "─", + "the tree title border reaches the left edge" + ); assert!( - summary.contains("A = 1 M = 1 D = 1 R = 1 C = 1 T = 1 · 6 files changed · +42 -17"), - "the pane starts with nonzero status and line aggregates" + summary.contains("Tree 0101010 ── A 1 + M 1 + D 1 + R 1 + C 1 + T 1 = 6 · +42 -17"), + "the top border contains the tree identity and aggregates" ); - let added_x = summary.find("A = 1").expect("added aggregate is visible") as u16; - let deleted_x = summary.find("D = 1").expect("deleted aggregate is visible") as u16; - assert_eq!(terminal.backend().buffer()[(added_x, 8)].fg, Color::Green); - assert_eq!(terminal.backend().buffer()[(deleted_x, 8)].fg, Color::Red); + let position = |needle| { + summary[..summary.find(needle).expect("aggregate is visible")] + .chars() + .count() as u16 + }; + let added_x = position("A 1"); + let deleted_x = position("D 1"); + assert_eq!(terminal.backend().buffer()[(added_x, 7)].fg, Color::Green); + assert_eq!(terminal.backend().buffer()[(deleted_x, 7)].fg, Color::Red); assert!( - terminal.backend().buffer()[(added_x, 8)] + terminal.backend().buffer()[(added_x, 7)] .modifier .contains(Modifier::DIM), "the inactive summary is dimmed without losing its colors" ); assert!( - rendered_line(&terminal, 9).contains("A added"), - "changed paths follow the summary in diff order" + rendered_line(&terminal, 8).contains("A added"), + "changed paths follow the summary border in diff order" ); - let inactive_path = rendered_line(&terminal, 9); + let inactive_path = rendered_line(&terminal, 8); let inactive_x = inactive_path.find("A added").expect("changed path is visible") as u16; assert!( - terminal.backend().buffer()[(inactive_x, 9)] + terminal.backend().buffer()[(inactive_x, 8)] .modifier .contains(Modifier::DIM) - && terminal.backend().buffer()[(inactive_x + 2, 9)] + && terminal.backend().buffer()[(inactive_x + 2, 8)] .modifier .contains(Modifier::DIM), "the inactive change kind and path are dimmed" ); assert!( - !rendered_line(&terminal, 9).contains("+10"), + !rendered_line(&terminal, 8).contains("+10"), "inactive panes do not display a path selection" ); assert!( - rendered_line(&terminal, 13).contains("… 2 lines not shown"), - "the capped pane reports paths that do not fit" + rendered_line(&terminal, 13).contains("T "), + "reclaiming the summary row lets all paths fit" ); assert!( !rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan"), @@ -2200,11 +2482,11 @@ mod tests { ); })?; assert!( - !rendered_line(&terminal, 8).contains("files changed"), + !rendered_line(&terminal, 7).contains("files changed"), "repeated history navigation temporarily hides the changes pane" ); assert!( - app.show_changes && !footer_is_dim(&terminal, "c changes"), + app.changes_mode.is_some() && !footer_is_dim(&terminal, "c changes"), "temporary suppression leaves the persistent changes setting enabled" ); app.changes_suppressed = false; @@ -2222,7 +2504,7 @@ mod tests { ); })?; assert!( - !terminal.backend().buffer()[(20, 7)].modifier.contains(Modifier::DIM), + !terminal.backend().buffer()[(119, 7)].modifier.contains(Modifier::DIM), "the focused changes border uses its normal style" ); assert!( @@ -2239,7 +2521,7 @@ mod tests { && !terminal.backend().buffer()[(2, 15)].modifier.contains(Modifier::DIM), "the inactive history is dimmed without dimming the main status" ); - assert!(rendered_line(&terminal, 15).contains("Tab → changes")); + assert!(rendered_line(&terminal, 15).contains("Tab → tree changes")); assert!(rendered_line(&terminal, 15).contains("q/Esc history")); terminal.draw(|frame| { super::draw( @@ -2256,51 +2538,51 @@ mod tests { "focus feedback lasts for one redraw" ); assert!( - !terminal.backend().buffer()[(added_x, 8)] + !terminal.backend().buffer()[(added_x, 7)] .modifier .contains(Modifier::DIM) && !terminal.backend().buffer()[(2, 14)].modifier.contains(Modifier::DIM), "the focused summary and status use their normal intensity" ); - let selected = rendered_line(&terminal, 10); + let selected = rendered_line(&terminal, 9); assert!(selected.contains("M modified +5 -2")); let path_x = selected.find("modified").expect("selected path is visible") as u16; let kind_x = selected.find("M modified").expect("selected kind is visible") as u16; let added_x = selected.find("+5").expect("selected additions are visible") as u16; let removed_x = selected.find("-2").expect("selected removals are visible") as u16; assert!( - !terminal.backend().buffer()[(kind_x, 10)] + !terminal.backend().buffer()[(kind_x, 9)] .modifier .contains(Modifier::DIM) - && !terminal.backend().buffer()[(path_x, 10)] + && !terminal.backend().buffer()[(path_x, 9)] .modifier .contains(Modifier::DIM), "focused paths use their normal intensity" ); assert!( - terminal.backend().buffer()[(path_x, 10)] + terminal.backend().buffer()[(path_x, 9)] .modifier .contains(Modifier::REVERSED), "the selected filepath is inverted" ); - assert_eq!(terminal.backend().buffer()[(added_x, 10)].fg, Color::Green); - assert_eq!(terminal.backend().buffer()[(removed_x, 10)].fg, Color::Red); + assert_eq!(terminal.backend().buffer()[(added_x, 9)].fg, Color::Green); + assert_eq!(terminal.backend().buffer()[(removed_x, 9)].fg, Color::Red); assert!( - !terminal.backend().buffer()[(added_x, 10)] + !terminal.backend().buffer()[(added_x, 9)] .modifier .contains(Modifier::REVERSED), "the diff-line suffix keeps its normal background" ); assert!( - !rendered_line(&terminal, 9).contains("+10"), + !rendered_line(&terminal, 8).contains("+10"), "only the selected path displays its line counts" ); - assert!(rendered_line(&terminal, 13).contains("… 2 lines not shown")); + assert!(rendered_line(&terminal, 13).contains("T ")); assert!(rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan")); assert!( - rendered_line(&terminal, 14).contains("Enter diff · c to hide"), - "the visible changes pane advertises how to hide it" + rendered_line(&terminal, 14).contains("Enter diff · c tree"), + "the changes pane advertises the next cycle mode" ); app.update(Action::Last); @@ -2315,9 +2597,9 @@ mod tests { Some(&changes), ); })?; - assert_eq!(app.changes_horizontal_offset, 20); + assert_eq!(app.tree_changes.horizontal_offset, 20); assert!( - rendered_line(&terminal, 12).contains("tail"), + rendered_line(&terminal, 13).contains("tail"), "h/l pans long path rows while the summary remains fixed" ); assert!( @@ -2337,8 +2619,8 @@ mod tests { ); })?; assert!( - rendered_line(&short_terminal, 5).contains("… 1 line not shown"), - "the overflow count follows the selected final path when no path row fits" + !rendered_line(&short_terminal, 5).contains("not shown"), + "the overflow count disappears once the selected final path is visible" ); let mut merge_changes = changes.clone(); @@ -2358,12 +2640,12 @@ mod tests { ); })?; assert!( - rendered_line(&terminal, 8).starts_with(" A = 1"), - "parent context no longer crowds the aggregate summary" + rendered_line(&terminal, 7).contains("Tree 0101010 ── A 1"), + "parent context no longer crowds the aggregate border" ); assert!( rendered_line(&terminal, 14) - .contains("vs parent 1/2 0202020 · p next parent · ↑↓/jk move · h/l pan · Enter diff · c to hide"), + .contains("vs parent 1/2 0202020 · p next parent · ↑↓/jk move · h/l pan · Enter diff · c tree"), "merge diffs keep parent controls alongside navigation" ); let parent = rendered_line(&terminal, 1); @@ -2400,6 +2682,188 @@ mod tests { Ok(()) } + #[test] + fn summarizes_staged_and_unstaged_changes_in_the_top_border() -> Result<(), Box> { + let mut app = App::new(1); + app.changes_mode = Some(ChangesMode::Both); + let changes = Changes { + paths: vec![ + crate::app::PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Staged, + source: None, + path: "same".into(), + lines: Some((1, 0)), + }, + crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Unstaged, + source: None, + path: "same".into(), + lines: Some((2, 1)), + }, + ], + lines_added: 3, + lines_removed: 1, + ..Changes::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 8))?; + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&changes), + ); + })?; + + let (header_y, header) = (0..8) + .map(|y| (y, rendered_line(&terminal, y))) + .find(|(_, line)| line.contains("Worktree")) + .expect("the worktree border is visible"); + assert!( + header.contains("Worktree ── S 1 + U 1 = 2 · +3 -1"), + "the border distinguishes staged and unstaged rows: {header:?}" + ); + let staged_y = header_y + 1; + let unstaged_y = header_y + 2; + let staged_x = rendered_line(&terminal, staged_y).find('A').expect("staged letter") as u16; + let unstaged_x = rendered_line(&terminal, unstaged_y).find('M').expect("unstaged letter") as u16; + assert_eq!(terminal.backend().buffer()[(staged_x, staged_y)].fg, Color::Green); + assert_eq!(terminal.backend().buffer()[(unstaged_x, unstaged_y)].fg, Color::Red); + + let modified = Changes { + paths: (0..12) + .map(|index| crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: format!("file-{index}").into(), + lines: Some((0, 0)), + }) + .collect(), + ..Changes::default() + }; + let summary = changes_summary(ChangePane::Tree, &app, &modified) + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(summary.contains("M 12 · +0 -0")); + assert!(!summary.contains("= 12"), "a single term already expresses the total"); + + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&Changes::default()), + ); + })?; + assert!( + (0..8).any(|y| rendered_line(&terminal, y).contains("Worktree ── +0 -0")), + "an enabled clean worktree remains visible as an empty block" + ); + assert!( + !(0..8).any(|y| rendered_line(&terminal, y).contains("= 0")), + "a clean worktree has no empty aggregate" + ); + assert!(!app.worktree_changes_visible, "an empty block is not focusable"); + Ok(()) + } + + #[test] + fn lays_out_tree_and_worktree_changes_by_available_width() -> Result<(), Box> { + let (layout, panes, height) = changes_pane_areas(Rect::new(0, 0, 120, 20), 10, Some((5, 30)), Some((3, 25))); + assert_eq!(layout, ChangesLayout::SideBySide); + assert_eq!(height, 5); + assert_eq!(panes[0].outer, Rect::new(0, 15, 60, 5)); + assert_eq!(panes[1].outer, Rect::new(60, 17, 60, 3)); + + let (layout, panes, height) = changes_pane_areas(Rect::new(0, 0, 60, 20), 10, Some((8, 31)), Some((3, 31))); + assert_eq!(layout, ChangesLayout::Stacked); + assert_eq!(height, 10); + assert_eq!( + panes[0], + ChangesPaneArea { + pane: ChangePane::Worktree, + outer: Rect::new(0, 10, 60, 3), + } + ); + assert_eq!( + panes[1], + ChangesPaneArea { + pane: ChangePane::Tree, + outer: Rect::new(0, 13, 60, 7), + } + ); + + let (_, panes, height) = changes_pane_areas(Rect::new(0, 0, 120, 20), 10, None, Some((3, 25))); + assert_eq!(height, 3); + assert_eq!(panes[0].outer, Rect::new(0, 17, 120, 3)); + + let mut app = App::new(1); + app.changes_mode = Some(ChangesMode::Both); + let path = |group, path: &'static str| Changes { + paths: vec![crate::app::PathChange { + kind: ChangeKind::Modified, + group, + source: None, + path: path.into(), + lines: Some((1, 1)), + }], + lines_added: 1, + lines_removed: 1, + ..Changes::default() + }; + let mut tree = path(ChangeGroup::Tree, "tree-file"); + tree.paths.push(crate::app::PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "tree-file-2".into(), + lines: Some((0, 0)), + }); + let worktree = path(ChangeGroup::Staged, "worktree-file"); + let mut terminal = Terminal::new(TestBackend::new(120, 10))?; + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&tree), + Some(&worktree), + ); + })?; + let halves = |row: String| { + let left = row.chars().take(60).collect::(); + let right = row.chars().skip(60).collect::(); + (left, right) + }; + let (left, _) = halves(rendered_line(&terminal, 5)); + assert!(left.contains("Tree")); + let (left, right) = halves(rendered_line(&terminal, 6)); + assert!(left.contains("tree-file")); + assert!(right.contains("Worktree")); + let (left, right) = halves(rendered_line(&terminal, 7)); + assert!(left.contains("tree-file-2")); + assert!(right.contains("worktree-file")); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(60, 5)].symbol(), "┐"); + assert_eq!(buffer[(60, 6)].symbol(), "├"); + assert_eq!(buffer[(60, 7)].symbol(), "│"); + assert_eq!(buffer[(60, 8)].symbol(), "│"); + Ok(()) + } + #[test] fn aligns_commit_trailers_and_wraps_only_in_the_value_column() -> Result<(), Box> { let mut terminal = Terminal::new(TestBackend::new(40, 8))?; From 5cd3bcaa85fc7fb058a1b5871e9bcb65cf1d80cc Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 5 Aug 2026 14:49:35 +0200 Subject: [PATCH 014/282] feat: navigate tix history with mouse scrolling Capture terminal mouse input while tix is active and restore it across screen transitions and exit. Map vertical wheel and trackpad events to history movement and horizontal events to the existing pan actions. Treat vertical scrolling over the history like repeated keyboard navigation: hide the changes blocks, retain the temporary fill repository, and restore both after the existing 75 ms idle window. Keep scrolling within a focused changes block visible, and ignore clicks, drags, and pointer movement. Apply every scroll event faithfully while rate-limiting redraws to the existing frame interval. This lets fast wheel bursts drain from the terminal queue without rendering and refilling the selected view after every event. --- gix-tix/src/lib.rs | 71 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 14 deletions(-) diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 108cfb7ec77..6dbb25eb733 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -28,9 +28,9 @@ use crossterm::{ clipboard::CopyToClipboard, cursor, event::{ - self, DisableFocusChange, EnableFocusChange, Event as TerminalEvent, KeyCode, KeyEvent, KeyEventKind, - KeyModifiers, KeyboardEnhancementFlags, ModifierKeyCode, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, + self, DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, Event as TerminalEvent, + KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, ModifierKeyCode, MouseEventKind, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, style::{Print, ResetColor}, @@ -416,7 +416,7 @@ pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec, opti } fn enable_input(backend: &mut CrosstermBackend, enhanced_keyboard: bool) -> std::io::Result<()> { - execute!(backend, EnableFocusChange)?; + execute!(backend, EnableFocusChange, EnableMouseCapture)?; if enhanced_keyboard { execute!( backend, @@ -434,7 +434,7 @@ fn disable_input(backend: &mut CrosstermBackend, enhanced_keybo if enhanced_keyboard { execute!(backend, PopKeyboardEnhancementFlags)?; } - execute!(backend, DisableFocusChange) + execute!(backend, DisableMouseCapture, DisableFocusChange) } fn half_height(terminal_height: u16) -> u16 { @@ -889,7 +889,8 @@ fn event_loop( enhanced_keyboard, )?; let streaming = matches!(app.state, State::Loading | State::Cancelling | State::Computing) - || verification_receiver.is_some(); + || verification_receiver.is_some() + || repeat_deadline.is_some(); if should_draw(dirty, streaming, last_draw.elapsed()) { draw( terminal, @@ -925,8 +926,19 @@ fn event_loop( let Some(terminal_event) = terminal_event else { continue; }; - let key = match terminal_event { - TerminalEvent::Key(key) => key, + let (action, repeats_history, is_repeat, throttles_draw) = match terminal_event { + TerminalEvent::Key(key) => { + let action = action(key); + let repeats_history = retains_fill_repository(key.kind, action.as_ref(), app.changes_focus.is_some()); + (action, repeats_history, key.kind == KeyEventKind::Repeat, false) + } + TerminalEvent::Mouse(mouse) => { + let Some(action) = mouse_scroll_action(mouse.kind) else { + continue; + }; + let repeats_history = app.changes_focus.is_none() && repeats_viewport(&action); + (Some(action), repeats_history, true, true) + } TerminalEvent::FocusLost => { focused = false; app.changes_suppressed = false; @@ -950,18 +962,18 @@ fn event_loop( if !focused { continue; } - let action = action(key); - let repeats_history = retains_fill_repository(key.kind, action.as_ref(), app.changes_focus.is_some()); + if repeats_history || throttles_draw { + repeat_deadline = Some(Instant::now() + REPEAT_IDLE); + } if repeats_history { fill_repository.retain = true; - repeat_deadline = Some(Instant::now() + REPEAT_IDLE); - } else if key.kind != KeyEventKind::Repeat { + } else if !is_repeat { fill_repository.retain = false; fill_repository.retained = None; } if repeats_history && app.changes_mode.is_some() { app.changes_suppressed = true; - } else if key.kind != KeyEventKind::Repeat && app.changes_suppressed { + } else if !is_repeat && app.changes_suppressed { app.changes_suppressed = false; repeat_deadline = None; dirty = true; @@ -971,7 +983,7 @@ fn event_loop( continue; }; dirty = true; - urgent = true; + urgent |= !throttles_draw; let previous_changes_mode = app.changes_mode; let toggles_changes = action == Action::ToggleChanges; let refreshes_worktree = action == Action::Refresh && app.changes_mode == Some(ChangesMode::Both); @@ -2380,6 +2392,16 @@ fn retains_fill_repository(kind: KeyEventKind, action: Option<&Action>, changes_ !changes_focused && kind == KeyEventKind::Repeat && action.is_some_and(repeats_viewport) } +fn mouse_scroll_action(kind: MouseEventKind) -> Option { + match kind { + MouseEventKind::ScrollUp => Some(Action::MoveUp), + MouseEventKind::ScrollDown => Some(Action::MoveDown), + MouseEventKind::ScrollLeft => Some(Action::ScrollLeft), + MouseEventKind::ScrollRight => Some(Action::ScrollRight), + _ => None, + } +} + #[cfg(test)] fn open_test_repository(path: impl AsRef) -> Result { gix::open_opts(path.as_ref(), gix::open::Options::isolated()) @@ -3024,6 +3046,27 @@ mod tests { )); } + #[test] + fn maps_continuous_mouse_scrolling_to_navigation() { + assert_eq!(mouse_scroll_action(MouseEventKind::ScrollUp), Some(Action::MoveUp)); + assert_eq!(mouse_scroll_action(MouseEventKind::ScrollDown), Some(Action::MoveDown)); + assert_eq!( + mouse_scroll_action(MouseEventKind::ScrollLeft), + Some(Action::ScrollLeft) + ); + assert_eq!( + mouse_scroll_action(MouseEventKind::ScrollRight), + Some(Action::ScrollRight) + ); + assert_eq!(mouse_scroll_action(MouseEventKind::Moved), None); + assert!(repeats_viewport( + &mouse_scroll_action(MouseEventKind::ScrollDown).expect("vertical scrolling has an action") + )); + assert!(!repeats_viewport( + &mouse_scroll_action(MouseEventKind::ScrollRight).expect("horizontal scrolling has an action") + )); + } + #[test] fn prepares_a_reduced_selection_after_leaving_the_alternate_screen() { let mut app = App::new(1); From efac1bb074a4652967bd80ef252b9fb9db6b9077 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 5 Aug 2026 15:15:49 +0200 Subject: [PATCH 015/282] feat: show the selected history row in tix Keep the live commit count in the history status bar while traversal, cancellation, and lane computation are active. Once the completed graph is displayed, replace it with a reverse row number for the current selection. Number displayed rows from the bottom so the oldest row is #1 and the top-most row is the total number of commits. Retain the commit count for empty histories where no row can be selected. --- gix-tix/src/ui.rs | 49 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 08618812700..26b8d3793b1 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -592,8 +592,8 @@ pub(crate) fn draw_with_worktree( State::Cancelled => " · cancelled", }; let mut footer_spans = vec![Span::raw(format!( - "{} commits{status} · ↑↓/jk move · h/l pan", - app.rows.len() + "{}{status} · ↑↓/jk move · h/l pan", + history_position(app) ))]; if app.tree_changes_visible || app.worktree_changes_visible { footer_spans.push(match app.focus_feedback.take() { @@ -672,6 +672,13 @@ pub(crate) fn draw_with_worktree( frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer); } +fn history_position(app: &App) -> String { + match (app.state, app.selected) { + (State::Complete, Some(selected)) => format!("#{}", app.rows.len().saturating_sub(selected)), + _ => format!("{} commits", app.rows.len()), + } +} + fn render_changes_divider(frame: &mut Frame<'_>, panes: &[ChangesPaneArea], app: &App) { let Some(tree) = panes.iter().find(|pane| pane.pane == ChangePane::Tree) else { return; @@ -1392,6 +1399,40 @@ mod tests { app.finish_lane_computation(rows, lanes, lane_time); } + #[test] + fn counts_commits_until_the_graph_is_complete_then_tracks_the_selected_row() { + let mut app = App::new(3); + app.extend_commits( + (1..=3) + .map(|byte| Commit { + id: gix::ObjectId::Sha1([byte; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }) + .collect::>(), + ); + assert_eq!(history_position(&app), "3 commits"); + + let rows = app + .start_lane_computation() + .expect("a loading app starts lane computation"); + assert_eq!(history_position(&app), "3 commits"); + let (rows, lanes, lane_time) = crate::app::compute_lanes(rows); + app.finish_lane_computation(rows, lanes, lane_time); + + assert_eq!(history_position(&app), "#3"); + app.update(Action::MoveDown); + assert_eq!(history_position(&app), "#2"); + app.update(Action::MoveDown); + assert_eq!(history_position(&app), "#1"); + } + #[test] fn renders_selection_info_beside_the_right_marker_without_dimming_it() -> Result<(), Box> { let mut app = App::new(2); @@ -1742,7 +1783,7 @@ mod tests { terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; - let footer_text = "1 commits · ↑↓/jk move · h/l pan · [ align · o commit · c changes · d date · e emails · n names · m mailmap · t trailers · r refs · y copy · q quit"; + let footer_text = "#1 · ↑↓/jk move · h/l pan · [ align · o commit · c changes · d date · e emails · n names · m mailmap · t trailers · r refs · y copy · q quit"; let selected_line = "> ● 0101010 (HEAD) 1970-01-01 mapped author subject"; let mut expected = Buffer::with_lines([format!("{selected_line:<180}"), format!("{footer_text:<180}")]); for x in 0..11 { @@ -1794,7 +1835,7 @@ mod tests { rendered_line(&inline_terminal, 2).trim().is_empty(), "inline mode separates the commits from the status line" ); - assert!(rendered_line(&inline_terminal, 3).starts_with("1 commits")); + assert!(rendered_line(&inline_terminal, 3).starts_with("#1")); app.inline = false; let row = terminal.backend().buffer(); From 12ab5e9dc5c7863c48791ff8c840e04364cd4592 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 14:04:25 +0200 Subject: [PATCH 016/282] fix: keep tix open after its worktree is removed A deleted linked worktree invalidates the per-worktree Git directory watched by tix, even on systems where the process current directory still resolves. Normalize the common repository path lexically at startup so it no longer traverses the removable .git/worktrees/ directory. Recover through the common repository both when a running view observes removal and when the worktree is already gone between repository discovery and the initial history metadata open. In the startup case, replace the stale traversal repository before any frame is retained, so the view starts directly from the common repository without attempting an animation from unavailable state. Move into the common directory and reopen it with core.bare enabled, then restart reference watching there so history remains live. Drop worktree-derived state during runtime recovery and keep the changes view limited to tree changes. Temporary repositories and diff workers retain the bare mode, and the line-diff pool avoids creating worktree resources even when gix still retains an inferred main-worktree path internally. Show successful recovery in the main status line until the next user action. If changing into or opening the common repository fails, restore the terminal and return the contextual error instead of silently quitting. --- gix-tix/src/app.rs | 35 +++++- gix-tix/src/lib.rs | 282 +++++++++++++++++++++++++++++++++++++++------ gix-tix/src/ui.rs | 12 ++ 3 files changed, 291 insertions(+), 38 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index df55b9edd68..c59c6950206 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -326,6 +326,7 @@ pub(crate) struct App { pub align_metadata: bool, pub show_commit: bool, pub changes_mode: Option, + worktree_changes_available: bool, pub(crate) changes_suppressed: bool, pub(crate) changes_focus: Option, pub(crate) changes_layout: ChangesLayout, @@ -345,6 +346,7 @@ pub(crate) struct App { reachable_rows: Option>, pub copy_feedback: Option, pub(crate) focus_feedback: Option<&'static str>, + pub(crate) notice: Option, pub estimated_lane_width: usize, pub horizontal_offset: usize, horizontal_page: usize, @@ -387,6 +389,7 @@ impl App { align_metadata: true, show_commit: false, changes_mode: Some(ChangesMode::Both), + worktree_changes_available: true, changes_suppressed: false, changes_focus: None, changes_layout: ChangesLayout::SideBySide, @@ -406,6 +409,7 @@ impl App { reachable_rows: None, copy_feedback: None, focus_feedback: None, + notice: None, estimated_lane_width: 0, horizontal_offset: 0, horizontal_page: 1, @@ -568,6 +572,7 @@ impl App { } pub fn update(&mut self, action: Action) -> Vec { + self.notice = None; match action { Action::Cancelled if self.state == State::Cancelling => self.state = State::Cancelled, Action::MoveUp if self.changes_focus.is_some() => self.move_changes(1, false), @@ -677,7 +682,8 @@ impl App { self.changes_mode = match self.changes_mode { Some(ChangesMode::Both) => Some(ChangesMode::Tree), Some(ChangesMode::Tree) => None, - None => Some(ChangesMode::Both), + None if self.worktree_changes_available => Some(ChangesMode::Both), + None => Some(ChangesMode::Tree), }; self.reset_changes_view(); self.changes_parent = 0; @@ -987,6 +993,18 @@ impl App { self.focus_feedback = None; } + pub(crate) fn set_worktree_changes_available(&mut self, available: bool) { + self.worktree_changes_available = available; + if !available { + if self.changes_mode == Some(ChangesMode::Both) { + self.changes_mode = Some(ChangesMode::Tree); + } + if self.changes_focus == Some(ChangePane::Worktree) { + self.focus_history(); + } + } + } + pub(crate) fn changes_visible(&self) -> bool { self.changes_mode.is_some() && !self.changes_suppressed } @@ -2184,6 +2202,21 @@ mod tests { assert_eq!(app.changes_mode, Some(ChangesMode::Both)); } + #[test] + fn bare_repositories_cycle_only_tree_and_hidden_changes() { + let mut app = App::new(1); + app.changes_focus = Some(ChangePane::Worktree); + + app.set_worktree_changes_available(false); + assert_eq!(app.changes_mode, Some(ChangesMode::Tree)); + assert_eq!(app.changes_focus, None, "a hidden worktree pane cannot retain focus"); + + app.update(Action::ToggleChanges); + assert_eq!(app.changes_mode, None); + app.update(Action::ToggleChanges); + assert_eq!(app.changes_mode, Some(ChangesMode::Tree)); + } + #[test] fn cycles_changes_focus_in_visual_order_and_keeps_navigation_independent() { let mut app = App::new(1); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 6dbb25eb733..659a0f647ec 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -52,8 +52,9 @@ const WORKTREE_EVENT_IDLE: Duration = Duration::from_millis(75); const IMMEDIATE_PAGER_EXIT: Duration = Duration::from_millis(250); const REF_EVENT_INTERVAL: Duration = Duration::from_millis(250); -struct FillRepository<'a> { - path: &'a Path, +struct FillRepository { + path: PathBuf, + bare: bool, retained: Option, retain: bool, } @@ -206,8 +207,8 @@ fn line_counts_for_change( } impl LineDiffPool { - fn new(repository_path: &Path, parallelism: usize) -> Result { - let repository = gix::open(repository_path) + fn new(repository_path: &Path, bare: bool, parallelism: usize) -> Result { + let repository = open_repository(repository_path, bare, false) .context("could not open repository for parallel line diffs")? .into_sync(); let mut worker_state = Vec::with_capacity(parallelism); @@ -217,8 +218,11 @@ impl LineDiffPool { let tree_cache = repository .diff_resource_cache_for_tree_diff() .context("could not initialize parallel line diffs")?; - let worktree_cache = worktree_diff_cache(&repository, gix::diff::blob::pipeline::Mode::ToGit) - .context("could not initialize parallel worktree line diffs")?; + let worktree_cache = if bare { + None + } else { + worktree_diff_cache(&repository, gix::diff::blob::pipeline::Mode::ToGit)? + }; worker_state.push((repository, tree_cache, worktree_cache)); } @@ -299,10 +303,11 @@ fn sync_line_diff_pool( pool: &mut Option, visible: bool, repository_path: &Path, + bare: bool, parallelism: usize, ) -> Result<()> { if visible && pool.is_none() { - *pool = Some(LineDiffPool::new(repository_path, parallelism.max(1))?); + *pool = Some(LineDiffPool::new(repository_path, bare, parallelism.max(1))?); } else if !visible { *pool = None; } @@ -585,7 +590,7 @@ fn sync_screen( fn event_loop( terminal: &mut ratatui::DefaultTerminal, - repository: gix::ThreadSafeRepository, + mut repository: gix::ThreadSafeRepository, revisions: Vec, options: Options, started_inline: bool, @@ -596,18 +601,25 @@ fn event_loop( hide, screen, } = options; - let repository_path = repository.git_dir().to_owned(); - let common_dir = repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()); - let mut view_repository = gix::open(&repository_path).context("could not open repository for history view")?; + let mut repository_path = repository.git_dir().to_owned(); + let common_dir = normalize_common_dir(repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()))?; + let (mut view_repository, recovered_at_startup) = open_history_repository(&mut repository_path, &common_dir)?; + let mut repository_is_bare = view_repository.workdir().is_none(); + if recovered_at_startup { + repository = view_repository.into_sync(); + repository_is_bare = true; + view_repository = open_repository(&repository_path, true, false) + .context("could not reopen common repository for history metadata")?; + } view_repository.object_cache_size(None); - let mailmap = view_repository.open_mailmap(); + let mut mailmap = view_repository.open_mailmap(); let mut notes = view_repository .notes() .map_err(gix::Exn::into_error) .context("could not open Git notes")?; let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); let mut ref_snapshot = history::snapshot(&view_repository, &revisions, &hide)?; - let (mut ref_watcher, ref_events) = start_ref_watcher(&repository_path, &common_dir); + let (mut ref_watcher, mut ref_events) = start_ref_watcher(&repository_path, &common_dir); let (cancelled, receiver) = start_history( repository, &revisions, @@ -616,6 +628,9 @@ fn event_loop( ); let mut app = App::new(1); + if recovered_at_startup { + app.notice = Some("worktree removed; using the common repository without worktree changes".into()); + } app.manual_refresh = ref_watcher.is_none(); let mut lane_receiver = None; let mut refresh_receiver: Option>> = None; @@ -631,16 +646,19 @@ fn event_loop( let line_diff_parallelism = std::thread::available_parallelism().map_or(1, Into::into); let mut line_diff_pool = None; let mut fill_repository = FillRepository { - path: &repository_path, + path: repository_path.clone(), + bare: repository_is_bare, retained: None, retain: false, }; configure_initial_screen(&mut app, started_inline); + app.set_worktree_changes_available(!repository_is_bare); app.configure_hidden_filter(!hide.is_empty()); sync_line_diff_pool( &mut line_diff_pool, app.changes_mode.is_some(), &repository_path, + repository_is_bare, line_diff_parallelism, )?; let mut decorations = Decorations::new(); @@ -774,8 +792,46 @@ fn event_loop( && lane_receiver.is_none() && matches!(app.state, State::Complete | State::Cancelled) { - let repository = gix::open_opts(&repository_path, gix::open::Options::isolated()) - .context("could not inspect changed references")?; + let repository = match open_repository(&repository_path, repository_is_bare, true) { + Ok(repository) => repository, + Err(_err) if worktree_repository_is_gone(&repository_path) => { + let mut recovered = recover_common_repository(&common_dir) + .context("could not recover after the worktree repository disappeared")?; + recovered.object_cache_size(None); + repository_path.clone_from(&common_dir); + repository_is_bare = true; + mailmap = recovered.open_mailmap(); + notes = recovered + .notes() + .map_err(gix::Exn::into_error) + .context("could not reopen Git notes after worktree removal")?; + view_repository = recovered; + fill_repository.path.clone_from(&repository_path); + fill_repository.bare = true; + fill_repository.retain = false; + fill_repository.retained = None; + app.set_worktree_changes_available(false); + worktree_watcher = None; + worktree_refresh_deadline = None; + worktree_changes = None; + line_diff_pool = None; + sync_line_diff_pool( + &mut line_diff_pool, + app.changes_mode.is_some(), + &repository_path, + true, + line_diff_parallelism, + )?; + let (watcher, events) = start_ref_watcher(&repository_path, &repository_path); + ref_watcher = watcher; + ref_events = events; + app.manual_refresh = ref_watcher.is_none(); + app.notice = Some("worktree removed; using the common repository without worktree changes".into()); + open_repository(&repository_path, true, true) + .context("could not inspect common repository references")? + } + Err(err) => return Err(err).context("could not inspect changed references"), + }; let next = history::snapshot(&repository, &revisions, &hide)?; let hidden_changed = next.hidden != ref_snapshot.hidden; let tips_changed = next.view != ref_snapshot.view || hidden_changed; @@ -790,6 +846,7 @@ fn event_loop( }; refresh_receiver = Some(start_history_refresh( repository_path.clone(), + repository_is_bare, revisions.clone(), hidden, app.known_ids(), @@ -996,6 +1053,7 @@ fn event_loop( &mut line_diff_pool, app.changes_mode.is_some(), &repository_path, + repository_is_bare, line_diff_parallelism, )?; if app.changes_mode == Some(ChangesMode::Both) { @@ -1022,7 +1080,7 @@ fn event_loop( } Effect::Reload(show_hidden) => { app.show_hidden = show_hidden; - notes = open_notes(&repository_path)?; + notes = open_notes(&repository_path, repository_is_bare)?; refresh_pending = true; refresh_expand_hidden = true; } @@ -1034,7 +1092,9 @@ fn event_loop( let result = changes .and_then(|changes| changes.diffs.get(index).zip(changes.paths.get(index))) .context("selected path no longer has diff resources") - .and_then(|(change, path)| prepare_file_diff(&repository_path, change, path)) + .and_then(|(change, path)| { + prepare_file_diff(&repository_path, repository_is_bare, change, path) + }) .and_then(|diff| match diff { FileDiff::External(command) => { run_external_diff(terminal, command, enhanced_keyboard).map(|()| false) @@ -1051,7 +1111,11 @@ fn event_loop( } } Effect::VerifySignatures(ids) => { - verification_receiver = Some(start_signature_verification(repository_path.clone(), ids)); + verification_receiver = Some(start_signature_verification( + repository_path.clone(), + repository_is_bare, + ids, + )); } Effect::Quit => return Ok(None), } @@ -1070,11 +1134,17 @@ fn event_loop( let restore = inline_terminal .map(|inline| leave_alternate_screen(terminal, inline, enhanced_keyboard)) .transpose(); - let outcome = result?; restore.context("could not restore the inline terminal")?; + let outcome = result?; if outcome.is_none() && started_inline { prepare_inline_exit(&mut app); - sync_line_diff_pool(&mut line_diff_pool, false, &repository_path, line_diff_parallelism)?; + sync_line_diff_pool( + &mut line_diff_pool, + false, + &repository_path, + repository_is_bare, + line_diff_parallelism, + )?; draw( terminal, &mut app, @@ -1115,11 +1185,12 @@ type SignatureVerification = (gix::ObjectId, bool); fn start_signature_verification( repository_path: PathBuf, + bare: bool, ids: Vec, ) -> mpsc::Receiver> { let (sender, receiver) = mpsc::channel(); std::thread::spawn(move || { - let results = match gix::open(repository_path) { + let results = match open_repository(&repository_path, bare, false) { Ok(mut repository) => { repository.object_cache_size(None); ids.into_iter() @@ -1178,6 +1249,7 @@ fn start_history( fn start_history_refresh( repository_path: PathBuf, + bare: bool, revisions: Vec, hidden_revisions: Vec, known: std::collections::HashSet, @@ -1186,7 +1258,7 @@ fn start_history_refresh( ) -> mpsc::Receiver> { let (sender, receiver) = mpsc::channel(); std::thread::spawn(move || { - let result = gix::open_opts(repository_path, gix::open::Options::isolated()) + let result = open_repository(&repository_path, bare, true) .context("could not reopen repository for history refresh") .and_then(|mut repository| { repository.object_cache_size_if_unset(OBJECT_CACHE_SIZE); @@ -1335,7 +1407,7 @@ fn draw( decorations: &Decorations, mailmap: &gix::mailmap::Snapshot, authors: &SharedAuthors, - fill_repository: &mut FillRepository<'_>, + fill_repository: &mut FillRepository, notes: &mut gix::note::Platform, commit_message: &mut Option<(gix::ObjectId, BString)>, tree_changes: &mut Option<(gix::ObjectId, usize, Changes)>, @@ -1429,10 +1501,10 @@ fn draw( let repository = if fill_repository.retain { match &mut fill_repository.retained { Some(repository) => repository, - slot @ None => slot.insert(open_fill_repository(fill_repository.path)?), + slot @ None => slot.insert(open_fill_repository(&fill_repository.path, fill_repository.bare)?), } } else { - one_shot_repository.insert(open_fill_repository(fill_repository.path)?) + one_shot_repository.insert(open_fill_repository(&fill_repository.path, fill_repository.bare)?) }; for index in start..end { if app.rows[index].metadata_loaded { @@ -1510,14 +1582,66 @@ fn draw( Ok(()) } -fn open_fill_repository(repository_path: &Path) -> Result { - let mut repository = gix::open(repository_path).context("could not open repository for history view")?; +fn open_repository(repository_path: &Path, bare: bool, isolated: bool) -> Result { + let options = if isolated { + gix::open::Options::isolated() + } else { + gix::open::Options::default() + } + .open_path_as_is(bare); + let options = if bare { + options.cli_overrides(["core.bare=true"]) + } else { + options + }; + Ok(gix::open_opts(repository_path, options)?) +} + +fn open_history_repository(repository_path: &mut PathBuf, common_dir: &Path) -> Result<(gix::Repository, bool)> { + match gix::open(&*repository_path) { + Ok(repository) => Ok((repository, false)), + Err(_err) if worktree_repository_is_gone(repository_path) => { + let repository = recover_common_repository(common_dir) + .context("could not recover before history traversal after the worktree repository disappeared")?; + common_dir.clone_into(repository_path); + Ok((repository, true)) + } + Err(err) => Err(err).context("could not open repository for history view"), + } +} + +fn recover_common_repository(common_dir: &Path) -> Result { + std::env::set_current_dir(common_dir).with_context(|| { + format!( + "could not change directory to common repository at {}", + common_dir.display() + ) + })?; + open_repository(common_dir, true, false) + .with_context(|| format!("could not open common repository at {} as bare", common_dir.display())) +} + +fn normalize_common_dir(common_dir: PathBuf) -> Result { + let current_dir = std::env::current_dir().context("could not obtain current directory")?; + gix::path::normalize(common_dir.into(), ¤t_dir) + .map(Into::into) + .context("common repository path could not be normalized") +} + +fn worktree_repository_is_gone(repository_path: &Path) -> bool { + !repository_path.is_dir() || std::env::current_dir().is_err() +} + +fn open_fill_repository(repository_path: &Path, bare: bool) -> Result { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository for history view")?; repository.object_cache_size(None); Ok(repository) } -fn open_notes(repository_path: &Path) -> Result { - let mut repository = gix::open(repository_path).context("could not open repository for Git notes")?; +fn open_notes(repository_path: &Path, bare: bool) -> Result { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository for Git notes")?; repository.object_cache_size(None); repository .notes() @@ -1525,8 +1649,9 @@ fn open_notes(repository_path: &Path) -> Result { .context("could not open Git notes") } -fn prepare_file_diff(repository_path: &Path, change: &FileChange, path: &PathChange) -> Result { - let mut repository = gix::open(repository_path).context("could not open repository for file diff")?; +fn prepare_file_diff(repository_path: &Path, bare: bool, change: &FileChange, path: &PathChange) -> Result { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository for file diff")?; repository.object_cache_size(OBJECT_CACHE_SIZE); prepare_file_diff_with_repository(&repository, change, path) } @@ -2513,15 +2638,15 @@ mod tests { let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; let repository = gix::open_opts(&fixture, gix::open::Options::isolated())?; let mut line_diff_pool = None; - sync_line_diff_pool(&mut line_diff_pool, true, &fixture, 2)?; + sync_line_diff_pool(&mut line_diff_pool, true, &fixture, false, 2)?; assert_eq!( line_diff_pool.as_ref().map(|pool| pool.workers.len()), Some(2), "showing changes creates the requested worker pool" ); - sync_line_diff_pool(&mut line_diff_pool, false, &fixture, 2)?; + sync_line_diff_pool(&mut line_diff_pool, false, &fixture, false, 2)?; assert!(line_diff_pool.is_none(), "hiding changes destroys the worker pool"); - sync_line_diff_pool(&mut line_diff_pool, true, &fixture, 2)?; + sync_line_diff_pool(&mut line_diff_pool, true, &fixture, false, 2)?; let line_diff_pool = line_diff_pool .as_mut() .expect("showing changes recreates the worker pool"); @@ -2680,6 +2805,89 @@ mod tests { Ok(()) } + #[test] + fn configures_a_common_repository_as_bare_for_tree_changes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let git_dir = open_test_repository(&fixture)?.git_dir().to_owned(); + let repository = open_repository(&git_dir, true, false)?; + + assert_eq!( + repository.config_snapshot().boolean("core.bare"), + Some(true), + "repository configuration suppresses worktree operations" + ); + assert!( + LineDiffPool::new(&git_dir, true, 1).is_ok(), + "tree changes remain available without a worktree" + ); + Ok(()) + } + + #[test] + fn detects_a_removed_per_worktree_repository_even_if_the_current_directory_resolves() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + assert!( + std::env::current_dir().is_ok(), + "the process directory remains available" + ); + let missing = fixture.join("missing-worktree-git-dir"); + assert!(worktree_repository_is_gone(&missing)); + let Err(err) = recover_common_repository(&missing) else { + panic!("a missing common repository cannot be recovered") + }; + assert!( + format!("{err:#}").contains("could not change directory to common repository"), + "recovery failures retain actionable context" + ); + Ok(()) + } + + #[test] + fn normalizes_a_common_directory_through_a_missing_per_worktree_directory() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let git_dir = open_test_repository(&fixture)?.git_dir().to_owned(); + let indirect = git_dir.join("worktrees/missing/../.."); + assert!( + !git_dir.join("worktrees/missing").exists(), + "the intermediate path is absent" + ); + assert_eq!(normalize_common_dir(indirect)?, git_dir); + Ok(()) + } + + #[test] + fn opens_the_common_repository_when_the_initial_worktree_is_already_gone() -> gix_testtools::Result { + const COMMON_DIR: &str = "GIX_TIX_TEST_REMOVED_WORKTREE_COMMON_DIR"; + if let Some(git_dir) = std::env::var_os(COMMON_DIR).map(PathBuf::from) { + let mut stale_git_dir = git_dir.join("worktrees/deleted"); + let (repository, recovered) = open_history_repository(&mut stale_git_dir, &git_dir)?; + + assert!( + recovered, + "a missing per-worktree repository uses the common repository" + ); + assert_eq!(stale_git_dir, git_dir, "future opens use the surviving repository"); + assert_eq!( + repository.config_snapshot().boolean("core.bare"), + Some(true), + "recovery configures the common repository as bare" + ); + return Ok(()); + } + + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let git_dir = open_test_repository(&fixture)?.git_dir().canonicalize()?; + let status = Command::new(std::env::current_exe()?) + .env(COMMON_DIR, git_dir) + .args([ + "--exact", + "tests::opens_the_common_repository_when_the_initial_worktree_is_already_gone", + ]) + .status()?; + assert!(status.success(), "the isolated recovery process completes successfully"); + Ok(()) + } + #[test] fn loads_staged_and_unstaged_worktree_changes() -> gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; @@ -2711,8 +2919,8 @@ mod tests { std::fs::write(path.join(".git/info/exclude"), "ignored\n")?; std::fs::write(path.join("ignored"), "ignored\n")?; - let repository = gix::open(path)?; - let mut line_diff_pool = LineDiffPool::new(path, 2)?; + let repository = open_test_repository(path)?; + let mut line_diff_pool = LineDiffPool::new(path, false, 2)?; let changes = load_worktree_changes(&repository, &mut line_diff_pool)?; let rows: Vec<_> = changes .paths diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 26b8d3793b1..f7ae0822533 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -669,6 +669,9 @@ pub(crate) fn draw_with_worktree( } footer_spans.push(Span::raw(" · q quit")); } + if let Some(notice) = &app.notice { + footer_spans = vec![Span::raw(notice)]; + } frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer); } @@ -1853,7 +1856,16 @@ mod tests { "completed work cannot be cancelled" ); + app.notice = Some("worktree removed; using common repository".into()); + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert_eq!( + rendered_line(&terminal, 1).trim(), + "worktree removed; using common repository", + "recovery information replaces the status until the next action" + ); + app.update(Action::ToggleMailmap); + assert!(app.notice.is_none(), "the next action restores the normal status"); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!( rendered_row(&terminal).contains(" author subject"), From fc26cee6dd82c32aedcd081d9904f2359ad033b0 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 6 Aug 2026 07:00:32 +0200 Subject: [PATCH 017/282] feat: coordinate tix overlay pane layout Lay out the commit message and change blocks within a shared overlay region so they no longer paint over each other. Reserve the commit message width first and let tree and worktree changes adapt within the remaining space. Delineate the commit message with a left border and move its title onto the first pane row while retaining its padding and scrolling status. --- gix-tix/src/ui.rs | 90 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index f7ae0822533..fc4b4a6f88d 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -207,9 +207,20 @@ pub(crate) fn draw_with_worktree( app.changes_visible() && app.changes_mode == Some(ChangesMode::Both) && worktree_changes.is_some(); let tree_summary = tree_changes.map(|changes| changes_summary(ChangePane::Tree, app, changes)); let worktree_summary = worktree_changes.map(|changes| changes_summary(ChangePane::Worktree, app, changes)); + let commit_pane = app.show_commit.then(|| { + let width = 80.min(full_body.width / 2); + let [commits, message] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]).areas(full_body); + body.width = body.width.min(commits.width); + let mut content = message.inner(Margin { + horizontal: 2, + vertical: 0, + }); + content.height = content.height.saturating_sub(1); + (message, content) + }); let pane_height = |changes: &Changes| u16::try_from(changes.paths.len()).unwrap_or(u16::MAX).saturating_add(2); let (changes_layout, changes_panes, _) = changes_pane_areas( - full_body, + body, frame.area().height / 2, tree_visible.then(|| { ( @@ -236,18 +247,6 @@ pub(crate) fn draw_with_worktree( && worktree_changes.is_some_and(Changes::is_visible), ); } - let commit_pane = app.show_commit.then(|| { - let width = 80.min(full_body.width / 2); - let [commits, message] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]).areas(full_body); - body.width = body.width.min(commits.width); - ( - message, - message.inner(Margin { - horizontal: 2, - vertical: 1, - }), - ) - }); app.viewport_rows = changes_panes .iter() .map(|pane| pane.outer.y.saturating_sub(body.y)) @@ -559,6 +558,7 @@ pub(crate) fn draw_with_worktree( } if let Some((outer, area)) = commit_pane { frame.render_widget(Clear, outer); + frame.render_widget(Block::new().borders(Borders::LEFT), outer); let max_offset = if let Some(message) = commit_message { let notes = app .selected @@ -2155,14 +2155,19 @@ mod tests { ); })?; assert_eq!( - terminal.backend().buffer()[(62, 1)].symbol(), + terminal.backend().buffer()[(62, 0)].symbol(), "s", - "the pane is capped at half width with two columns of horizontal margin" + "the title starts on the first pane row after two columns of horizontal margin" ); assert_eq!( - terminal.backend().buffer()[(62, 3)].symbol(), + terminal.backend().buffer()[(60, 0)].symbol(), + "│", + "the pane has a left border" + ); + assert_eq!( + terminal.backend().buffer()[(62, 2)].symbol(), "b", - "vertical margin leaves the full commit body intact" + "the commit body remains separated from its title" ); assert!( !footer_is_dim(&terminal, "o commit"), @@ -2190,7 +2195,7 @@ mod tests { ); })?; assert_eq!( - wide_terminal.backend().buffer()[(122, 1)].symbol(), + wide_terminal.backend().buffer()[(122, 0)].symbol(), "s", "the pane remains eighty columns wide on a wide screen" ); @@ -2717,21 +2722,62 @@ mod tests { ); app.update(Action::ToggleCommit); + let worktree_changes = Changes::default(); terminal.draw(|frame| { - super::draw( + super::draw_with_worktree( frame, &mut app, &Decorations::new(), &gix::mailmap::Snapshot::default(), Some(b"subject".as_bstr()), Some(&changes), + Some(&worktree_changes), ); })?; assert_eq!( - terminal.backend().buffer()[(62, 7)].symbol(), - " ", - "the right commit pane is rendered over the bottom changes pane" + app.changes_layout, + ChangesLayout::Stacked, + "both change blocks adapt to the width left by the commit pane" + ); + assert!( + rendered_line(&terminal, 7) + .chars() + .take(60) + .collect::() + .contains("Worktree") + ); + assert!( + rendered_line(&terminal, 9) + .chars() + .take(60) + .collect::() + .contains("Tree") + ); + assert_eq!(terminal.backend().buffer()[(60, 7)].symbol(), "│"); + assert_eq!( + app.viewport_rows, 7, + "history remains bounded above the highest overlay" + ); + assert!(rendered_line(&terminal, 0).starts_with('>')); + + let mut wide_terminal = Terminal::new(TestBackend::new(240, 16))?; + wide_terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + Some(b"subject".as_bstr()), + Some(&changes), + Some(&worktree_changes), + ); + })?; + assert_eq!( + app.changes_layout, + ChangesLayout::SideBySide, + "sufficient remaining width still permits side-by-side changes" ); + assert_eq!(wide_terminal.backend().buffer()[(160, 7)].symbol(), "│"); Ok(()) } From c8d9e61ab1e9b13bd32d5fe767ed09b25802026d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 6 Aug 2026 15:32:34 +0200 Subject: [PATCH 018/282] fix: select the newest tix row after watched refs change Filesystem-driven reference updates can insert or replace traversal tips, so a preserved selection may leave the refreshed view positioned in stale context. Track the refresh origin through asynchronous traversal and lane computation, then select the first selectable row only for watched-reference refreshes. Manual and visibility reloads continue preserving their selection. --- gix-tix/src/app.rs | 39 +++++++++++++++++++++++++++++++++++---- gix-tix/src/lib.rs | 19 +++++++++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index c59c6950206..a6b0de357ba 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -353,6 +353,7 @@ pub(crate) struct App { horizontal_max: usize, follow_tail: bool, reload_selection: Option, + select_top_after_refresh: bool, pub(crate) signature_failures: usize, signature_verification_running: bool, pub(crate) manual_refresh: bool, @@ -416,6 +417,7 @@ impl App { horizontal_max: 0, follow_tail: false, reload_selection: None, + select_top_after_refresh: false, signature_failures: 0, signature_verification_running: false, manual_refresh: false, @@ -826,6 +828,7 @@ impl App { commits: LoadedCommits, view_tips: &[ObjectId], hidden_tips: &[ObjectId], + select_top: bool, ) -> Option> { drop(self.store_commits(commits)); @@ -849,6 +852,7 @@ impl App { .filter_map(|id| self.all_rows.get(id).cloned()) .collect(); self.pending_hidden_rows = Some(boundary); + self.select_top_after_refresh = select_top; self.state = State::Computing; self.follow_tail = false; Some(rows) @@ -872,7 +876,9 @@ impl App { if self.state != State::Computing { return; } - let selected = self.selected.map(|index| self.rows[index].id); + let selected = (!std::mem::take(&mut self.select_top_after_refresh)) + .then(|| self.selected.map(|index| self.rows[index].id)) + .flatten(); let metadata: HashMap<_, _> = if rows.iter().any(|row| !row.metadata_loaded) { self.rows .iter() @@ -924,6 +930,7 @@ impl App { #[cfg(test)] pub(crate) fn reload(&mut self, show_hidden: bool) { self.reload_selection = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id); + self.select_top_after_refresh = false; self.rows = Vec::new(); self.all_rows.clear(); self.all_order.clear(); @@ -1717,7 +1724,7 @@ mod tests { complete(&mut app); let rows = app - .start_refresh(vec![row_with_parents(4, &[3])].into(), &[id(4)], &[]) + .start_refresh(vec![row_with_parents(4, &[3])].into(), &[id(4)], &[], false) .expect("a refresh computes lanes"); assert_eq!( app.rows.len(), @@ -1730,16 +1737,21 @@ mod tests { app.rows.iter().map(|row| row.id).collect::>(), [id(4), id(3), id(2), id(1)] ); + assert_eq!( + app.selected.map(|index| app.rows[index].id), + Some(id(3)), + "ordinary refreshes preserve the selected commit" + ); let rows = app - .start_refresh(Vec::::new().into(), &[id(2)], &[]) + .start_refresh(Vec::::new().into(), &[id(2)], &[], false) .expect("a rewind reprojects cached topology"); let (rows, graph, time) = compute_lanes(rows); app.finish_lane_computation(rows, graph, time); assert_eq!(app.rows.iter().map(|row| row.id).collect::>(), [id(2), id(1)]); let rows = app - .start_refresh(Vec::::new().into(), &[id(4)], &[]) + .start_refresh(Vec::::new().into(), &[id(4)], &[], false) .expect("a fast-forward to retained commits needs no new objects"); let (rows, graph, time) = compute_lanes(rows); app.finish_lane_computation(rows, graph, time); @@ -1749,6 +1761,25 @@ mod tests { ); } + #[test] + fn filesystem_refresh_selects_the_first_selectable_row() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(3, &[2]), row_with_parents(2, &[1]), row(1)]); + complete(&mut app); + app.update(Action::MoveDown); + app.update(Action::MoveDown); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(1))); + + let rows = app + .start_refresh(vec![row_with_parents(4, &[3])].into(), &[id(4)], &[], true) + .expect("a filesystem refresh computes lanes"); + let (rows, graph, time) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, time); + + assert_eq!(app.selected, app.first_selectable()); + assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(4))); + } + #[test] fn counts_distinct_visible_ancestry_only_when_it_reaches_hidden_history() { let mut app = App::new(10); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 659a0f647ec..c332c6bcc46 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -635,6 +635,8 @@ fn event_loop( let mut lane_receiver = None; let mut refresh_receiver: Option>> = None; let mut refresh_pending = false; + let mut refresh_from_filesystem = false; + let mut refresh_select_top = false; let mut refresh_expand_hidden = false; let mut verification_receiver = None; let mut commit_message = None; @@ -712,7 +714,13 @@ fn event_loop( } while let Ok(event) = ref_events.try_recv() { match event { - Ok(event) if !matches!(event.kind, notify::EventKind::Access(_)) => refresh_pending = true, + Ok(event) if !matches!(event.kind, notify::EventKind::Access(_)) => { + refresh_pending = true; + refresh_from_filesystem = true; + if refresh_receiver.is_some() { + refresh_select_top = true; + } + } Ok(_) => {} Err(_) => { ref_watcher = None; @@ -777,7 +785,12 @@ fn event_loop( } else { result.refs.hidden_tips.as_slice() }; - if let Some(rows) = app.start_refresh(result.commits, &result.refs.view_tips, hidden_tips) { + if let Some(rows) = app.start_refresh( + result.commits, + &result.refs.view_tips, + hidden_tips, + std::mem::take(&mut refresh_select_top), + ) { lane_receiver = Some(start_lane_worker(rows)); } refresh_receiver = None; @@ -835,6 +848,7 @@ fn event_loop( let next = history::snapshot(&repository, &revisions, &hide)?; let hidden_changed = next.hidden != ref_snapshot.hidden; let tips_changed = next.view != ref_snapshot.view || hidden_changed; + let select_top = std::mem::take(&mut refresh_from_filesystem); ref_snapshot = next; refresh_pending = false; if tips_changed || refresh_expand_hidden { @@ -853,6 +867,7 @@ fn event_loop( expand, gix::features::threading::OwnShared::clone(&authors), )); + refresh_select_top = select_top; refresh_expand_hidden = false; app.state = State::Loading; } else { From b50f1dd20d2491da3a1defd9623fe4a430eadfb0 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 6 Aug 2026 15:44:48 +0200 Subject: [PATCH 019/282] feat: group tix history display shortcuts Collapse history presentation controls behind a `v view` prefix to keep the main status concise. Expand date, actor, mailmap, trailer, reference, and hidden history controls on demand while leaving alignment and overlay panes direct. Keep the display group open for consecutive presentation changes and collapse it after navigation or any other recognized command. --- gix-tix/src/app.rs | 45 ++++++++++++++++++++++++++ gix-tix/src/lib.rs | 73 +++++++++++++++++++++++------------------- gix-tix/src/ui.rs | 80 ++++++++++++++++++++++------------------------ 3 files changed, 124 insertions(+), 74 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index a6b0de357ba..3a966524de8 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -271,6 +271,7 @@ pub(crate) enum Action { ToggleRefs, Refresh, ToggleHidden, + ToggleHistoryDisplay, ToggleAlign, ToggleCommit, ToggleChanges, @@ -347,6 +348,7 @@ pub(crate) struct App { pub copy_feedback: Option, pub(crate) focus_feedback: Option<&'static str>, pub(crate) notice: Option, + pub(crate) history_display_expanded: bool, pub estimated_lane_width: usize, pub horizontal_offset: usize, horizontal_page: usize, @@ -411,6 +413,7 @@ impl App { copy_feedback: None, focus_feedback: None, notice: None, + history_display_expanded: false, estimated_lane_width: 0, horizontal_offset: 0, horizontal_page: 1, @@ -575,6 +578,19 @@ impl App { pub fn update(&mut self, action: Action) -> Vec { self.notice = None; + if !matches!( + &action, + Action::ToggleHistoryDisplay + | Action::ToggleDate + | Action::ToggleEmail + | Action::ToggleName + | Action::ToggleTrailers + | Action::ToggleMailmap + | Action::ToggleRefs + | Action::ToggleHidden + ) { + self.history_display_expanded = false; + } match action { Action::Cancelled if self.state == State::Cancelling => self.state = State::Cancelled, Action::MoveUp if self.changes_focus.is_some() => self.move_changes(1, false), @@ -659,6 +675,7 @@ impl App { } Action::ToggleTrailers => self.show_trailers = !self.show_trailers, Action::ToggleMailmap => self.use_mailmap = !self.use_mailmap, + Action::ToggleHistoryDisplay => self.history_display_expanded = !self.history_display_expanded, Action::ToggleRefs => { self.ref_mode = match self.ref_mode { RefMode::All => RefMode::Default, @@ -2216,6 +2233,34 @@ mod tests { assert!(app.align_metadata); } + #[test] + fn history_display_group_stays_open_only_for_grouped_actions() { + let mut app = App::new(1); + + app.update(Action::ToggleHistoryDisplay); + assert!(app.history_display_expanded); + app.update(Action::ToggleDate); + app.update(Action::ToggleEmail); + assert!( + app.history_display_expanded, + "grouped display changes keep the group open" + ); + + app.update(Action::MoveDown); + assert!(!app.history_display_expanded, "navigation collapses the group"); + + app.update(Action::ToggleHistoryDisplay); + app.update(Action::ToggleAlign); + assert!( + !app.history_display_expanded, + "direct display commands also collapse the group" + ); + + app.update(Action::ToggleHistoryDisplay); + app.update(Action::ToggleHistoryDisplay); + assert!(!app.history_display_expanded, "the prefix key toggles the group"); + } + #[test] fn cycles_both_tree_and_hidden_changes() { let mut app = App::new(1); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index c332c6bcc46..ed112f78129 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -1000,7 +1000,7 @@ fn event_loop( }; let (action, repeats_history, is_repeat, throttles_draw) = match terminal_event { TerminalEvent::Key(key) => { - let action = action(key); + let action = action_with_history_display(key, app.history_display_expanded); let repeats_history = retains_fill_repository(key.kind, action.as_ref(), app.changes_focus.is_some()); (action, repeats_history, key.kind == KeyEventKind::Repeat, false) } @@ -2463,6 +2463,10 @@ fn poll_timeout( } fn action(key: KeyEvent) -> Option { + action_with_history_display(key, false) +} + +fn action_with_history_display(key: KeyEvent, history_display_expanded: bool) -> Option { if key.kind == KeyEventKind::Release && !matches!( key.code, @@ -2484,6 +2488,7 @@ fn action(key: KeyEvent) -> Option { KeyCode::Esc => Some(Action::Cancel), KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp), KeyCode::Down | KeyCode::Char('j') => Some(Action::MoveDown), + KeyCode::Char('h') if history_display_expanded => Some(Action::ToggleHidden), KeyCode::Char('h') => Some(Action::ScrollLeft), KeyCode::Char('l') => Some(Action::ScrollRight), KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Action::PageUp), @@ -2495,16 +2500,16 @@ fn action(key: KeyEvent) -> Option { KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Last), KeyCode::Home | KeyCode::Char('g') => Some(Action::First), KeyCode::End | KeyCode::Char('G') => Some(Action::Last), - KeyCode::Char('d') => Some(Action::ToggleDate), - KeyCode::Char('e') => Some(Action::ToggleEmail), - KeyCode::Char('n') => Some(Action::ToggleName), - KeyCode::Char('t') => Some(Action::ToggleTrailers), - KeyCode::Char('m') => Some(Action::ToggleMailmap), + KeyCode::Char('d') if history_display_expanded => Some(Action::ToggleDate), + KeyCode::Char('e') if history_display_expanded => Some(Action::ToggleEmail), + KeyCode::Char('n') if history_display_expanded => Some(Action::ToggleName), + KeyCode::Char('t') if history_display_expanded => Some(Action::ToggleTrailers), + KeyCode::Char('m') if history_display_expanded => Some(Action::ToggleMailmap), KeyCode::Char('R') => Some(Action::Refresh), KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Refresh), - KeyCode::Char('r') => Some(Action::ToggleRefs), + KeyCode::Char('r') if history_display_expanded => Some(Action::ToggleRefs), KeyCode::Char('s') => Some(Action::VerifySignatures), - KeyCode::Char('v') => Some(Action::ToggleHidden), + KeyCode::Char('v') => Some(Action::ToggleHistoryDisplay), KeyCode::Char('[') => Some(Action::ToggleAlign), KeyCode::Char(']' | 'o') => Some(Action::ToggleCommit), KeyCode::Char('Y') => Some(Action::CopyAuthor), @@ -3146,30 +3151,12 @@ mod tests { Some(Action::Last), "terminals that report shifted letters in lowercase still map Shift-G to the first commit" ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), - Some(Action::ToggleDate) - ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), - Some(Action::ToggleEmail) - ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), - Some(Action::ToggleName) - ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)), - Some(Action::ToggleTrailers) - ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)), - Some(Action::ToggleMailmap) - ); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), - Some(Action::ToggleRefs) - ); + assert_eq!(action(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), None); + assert_eq!(action(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), None); + assert_eq!(action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), None); + assert_eq!(action(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)), None); + assert_eq!(action(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)), None); + assert_eq!(action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), None); assert_eq!( action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::SHIFT)), Some(Action::Refresh), @@ -3182,7 +3169,27 @@ mod tests { ); assert_eq!( action(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)), - Some(Action::ToggleHidden) + Some(Action::ToggleHistoryDisplay) + ); + for (key, expected) in [ + ('d', Action::ToggleDate), + ('e', Action::ToggleEmail), + ('n', Action::ToggleName), + ('t', Action::ToggleTrailers), + ('m', Action::ToggleMailmap), + ('r', Action::ToggleRefs), + ('h', Action::ToggleHidden), + ] { + assert_eq!( + action_with_history_display(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE), true), + Some(expected), + "{key} is available after the view prefix" + ); + } + assert_eq!( + action_with_history_display(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), true), + Some(Action::ToggleHistoryDisplay), + "v closes the view shortcut group" ); assert_eq!( action(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)), diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index fc4b4a6f88d..025b1fefde4 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -607,43 +607,45 @@ pub(crate) fn draw_with_worktree( footer_spans.extend([Span::raw(" · "), toggle("[ align", app.align_metadata)]); footer_spans.extend([Span::raw(" · "), toggle("o commit", app.show_commit)]); footer_spans.extend([Span::raw(" · "), toggle("c changes", app.changes_mode.is_some())]); - if app.has_hidden_filter { - footer_spans.extend([ - Span::raw(" · "), - toggle( - if app.show_hidden { - "v hide hidden" - } else { - "v show hidden" - }, - app.show_hidden, - ), - ]); - } - footer_spans.extend([Span::raw(" · "), toggle("d date", app.show_committer_date)]); - footer_spans.extend([Span::raw(" · "), toggle("e emails", app.show_emails)]); - let (name_label, names_visible) = match app.name_mode { - NameMode::All => ("n names", true), - NameMode::Author => ("n name", true), - NameMode::None => ("n name", false), - }; - footer_spans.extend([Span::raw(" · "), toggle(name_label, names_visible)]); - for (label, enabled) in [("m mailmap", app.use_mailmap), ("t trailers", app.show_trailers)] { - footer_spans.extend([Span::raw(" · "), toggle(label, enabled)]); - } - footer_spans.push(Span::raw(" · ")); - if app.preview_author_copy && app.manual_refresh { - footer_spans.push(toggle( - "R refresh", - matches!(app.state, State::Complete | State::Cancelled), - )); - } else { + if app.history_display_expanded { + footer_spans.extend([Span::raw(" · "), toggle("d date", app.show_committer_date)]); + footer_spans.extend([Span::raw(" · "), toggle("e emails", app.show_emails)]); + let (name_label, names_visible) = match app.name_mode { + NameMode::All => ("n names", true), + NameMode::Author => ("n name", true), + NameMode::None => ("n name", false), + }; + footer_spans.extend([Span::raw(" · "), toggle(name_label, names_visible)]); + for (label, enabled) in [("m mailmap", app.use_mailmap), ("t trailers", app.show_trailers)] { + footer_spans.extend([Span::raw(" · "), toggle(label, enabled)]); + } let ref_label = match app.ref_mode { RefMode::All => "r all refs", RefMode::Default => "r refs", RefMode::None => "r no refs", }; - footer_spans.push(toggle(ref_label, app.ref_mode != RefMode::None)); + footer_spans.extend([Span::raw(" · "), toggle(ref_label, app.ref_mode != RefMode::None)]); + if app.has_hidden_filter { + footer_spans.extend([ + Span::raw(" · "), + toggle( + if app.show_hidden { + "h hide hidden" + } else { + "h show hidden" + }, + app.show_hidden, + ), + ]); + } + } else { + footer_spans.push(Span::raw(" · v view")); + } + if app.preview_author_copy && app.manual_refresh { + footer_spans.extend([ + Span::raw(" · "), + toggle("R refresh", matches!(app.state, State::Complete | State::Cancelled)), + ]); } footer_spans.push(Span::raw(if app.preview_author_copy { " · Y copy author" @@ -1612,6 +1614,7 @@ mod tests { ], }); app.selected = None; + app.history_display_expanded = true; let mut terminal = Terminal::new(TestBackend::new(160, 2))?; let mailmap = @@ -1786,7 +1789,7 @@ mod tests { terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; - let footer_text = "#1 · ↑↓/jk move · h/l pan · [ align · o commit · c changes · d date · e emails · n names · m mailmap · t trailers · r refs · y copy · q quit"; + let footer_text = "#1 · ↑↓/jk move · h/l pan · [ align · o commit · c changes · v view · y copy · q quit"; let selected_line = "> ● 0101010 (HEAD) 1970-01-01 mapped author subject"; let mut expected = Buffer::with_lines([format!("{selected_line:<180}"), format!("{footer_text:<180}")]); for x in 0..11 { @@ -1819,12 +1822,6 @@ mod tests { for x in commit..commit + "o commit".len() { expected[(x as u16, 1)].set_style(Style::default().add_modifier(Modifier::DIM)); } - let email = footer_text[..footer_text.find("e emails").expect("the email toggle is present")] - .chars() - .count(); - for x in email..email + "e emails".len() { - expected[(x as u16, 1)].set_style(Style::default().add_modifier(Modifier::DIM)); - } terminal.backend().assert_buffer(&expected); app.inline = true; @@ -1864,6 +1861,7 @@ mod tests { "recovery information replaces the status until the next action" ); + app.history_display_expanded = true; app.update(Action::ToggleMailmap); assert!(app.notice.is_none(), "the next action restores the normal status"); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; @@ -1929,13 +1927,13 @@ mod tests { app.has_hidden_filter = true; terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!( - rendered_line(&terminal, 1).contains("v show hidden"), + rendered_line(&terminal, 1).contains("h show hidden"), "the footer advertises the configured hidden-history toggle" ); app.show_hidden = true; terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!( - rendered_line(&terminal, 1).contains("v hide hidden"), + rendered_line(&terminal, 1).contains("h hide hidden"), "the footer reflects the unfiltered view" ); From 9e09a2bf45a2b9995786ebdbe80cb9e1f01a0bc3 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 6 Aug 2026 18:36:48 +0200 Subject: [PATCH 020/282] change: brighten red UI elements and omit zero diff counts Use the terminal bright-red color for deletions, unstaged changes, failed signatures, errors, behind counts, and graph rails so red remains legible against dark backgrounds. Render insertion and removal counts only when non-zero in history selection information, changes summaries, and selected changed paths. Keep clean worktree blocks visible by their title. --- gix-tix/src/ui.rs | 141 ++++++++++++++++++++++++++++++---------------- 1 file changed, 93 insertions(+), 48 deletions(-) diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 025b1fefde4..183ecc6a029 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -144,7 +144,7 @@ pub(crate) fn draw_file_diff(frame: &mut Frame<'_>, diff: &BuiltInDiff, offset: } else if line.starts_with(b"+") { Style::default().fg(Color::Green) } else if line.starts_with(b"-") { - Style::default().fg(Color::Red) + Style::default().fg(Color::LightRed) } else if line.starts_with(b"Binary ") { Style::default().fg(Color::Yellow) } else { @@ -534,7 +534,7 @@ pub(crate) fn draw_with_worktree( ]); } if let Some(error) = &app.changes(pane).error { - spans.push(Span::styled(format!("diff: {error}"), color(Color::Red))); + spans.push(Span::styled(format!("diff: {error}"), color(Color::LightRed))); } else { spans.push(Span::raw("↑↓/jk move · h/l pan · Enter diff")); } @@ -655,7 +655,7 @@ pub(crate) fn draw_with_worktree( if app.signature_failures > 0 { footer_spans.extend([ Span::raw(format!(" · s {} ", app.signature_failures)), - Span::styled("●", color(Color::Red)), + Span::styled("●", color(Color::LightRed)), ]); } else if has_verifiable_signatures { footer_spans.extend([ @@ -716,11 +716,18 @@ fn render_changes_divider(frame: &mut Frame<'_>, panes: &[ChangesPaneArea], app: fn selection_info_line(changes: Option<&Changes>, relation: Option) -> Line<'static> { let mut spans = Vec::new(); if let Some(changes) = changes { - spans.extend([ - Span::styled(format!("+{}", changes.lines_added), selection_color(Color::Green)), - Span::raw(" "), - Span::styled(format!("-{}", changes.lines_removed), selection_color(Color::Red)), - ]); + if changes.lines_added > 0 { + push_selection_span( + &mut spans, + Span::styled(format!("+{}", changes.lines_added), selection_color(Color::Green)), + ); + } + if changes.lines_removed > 0 { + push_selection_span( + &mut spans, + Span::styled(format!("-{}", changes.lines_removed), selection_color(Color::LightRed)), + ); + } } match relation { Some(SelectionRelation::Tracking { ahead, behind }) => { @@ -734,10 +741,10 @@ fn selection_info_line(changes: Option<&Changes>, relation: Option, area: Rect, changes: &Changes, pane: Ch spans.push(Span::styled(change.path.to_str_lossy(), path_style)); } if selected && let Some((insertions, removals)) = change.lines { - spans.extend([ - Span::raw(" "), - Span::styled(format!("+{insertions}"), color(Color::Green)), - Span::raw(" "), - Span::styled(format!("-{removals}"), color(Color::Red)), - ]); + if insertions > 0 { + spans.extend([ + Span::raw(" "), + Span::styled(format!("+{insertions}"), color(Color::Green)), + ]); + } + if removals > 0 { + spans.extend([ + Span::raw(" "), + Span::styled(format!("-{removals}"), color(Color::LightRed)), + ]); + } } Line::from(spans) }) @@ -861,10 +874,10 @@ fn change_color(kind: ChangeKind) -> Color { match kind { ChangeKind::Added => Color::Green, ChangeKind::Modified => Color::Yellow, - ChangeKind::Deleted => Color::Red, + ChangeKind::Deleted => Color::LightRed, ChangeKind::Renamed | ChangeKind::Copied => Color::Cyan, ChangeKind::TypeChanged => Color::Magenta, - ChangeKind::Unmerged => Color::Red, + ChangeKind::Unmerged => Color::LightRed, } } @@ -872,7 +885,7 @@ fn path_change_color(change: &crate::app::PathChange) -> Color { match change.group { ChangeGroup::Tree => change_color(change.kind), ChangeGroup::Staged => Color::Green, - ChangeGroup::Unstaged => Color::Red, + ChangeGroup::Unstaged => Color::LightRed, } } @@ -887,15 +900,6 @@ fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'stat } ChangePane::Worktree => vec![Span::raw("─ Worktree ── ")], }; - if pane == ChangePane::Worktree && changes.paths.is_empty() { - spans.extend([ - Span::styled("+0", color(Color::Green)), - Span::raw(" "), - Span::styled("-0", color(Color::Red)), - Span::raw(" "), - ]); - return Line::from(spans); - } let counts: Vec<_> = match pane { ChangePane::Tree => { let mut counts = Vec::new(); @@ -924,7 +928,7 @@ fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'stat let unstaged = changes.paths.len().saturating_sub(staged); [ ("S".to_owned(), staged, Color::Green), - ("U".to_owned(), unstaged, Color::Red), + ("U".to_owned(), unstaged, Color::LightRed), ] .into_iter() .filter(|(_, count, _)| *count > 0) @@ -932,7 +936,7 @@ fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'stat } }; let has_counts = !counts.is_empty(); - let show_total = counts.len() != 1 || counts[0].1 != changes.paths.len(); + let show_total = has_counts && (counts.len() != 1 || counts[0].1 != changes.paths.len()); for (index, (label, count, count_color)) in counts.into_iter().enumerate() { if index > 0 { spans.push(Span::raw(" + ")); @@ -946,13 +950,22 @@ fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'stat changes.paths.len() ))); } - spans.extend([ - Span::raw(" · "), - Span::styled(format!("+{}", changes.lines_added), color(Color::Green)), - Span::raw(" "), - Span::styled(format!("-{}", changes.lines_removed), color(Color::Red)), - Span::raw(" "), - ]); + if changes.lines_added > 0 || changes.lines_removed > 0 { + spans.push(Span::raw(" · ")); + if changes.lines_added > 0 { + spans.push(Span::styled(format!("+{}", changes.lines_added), color(Color::Green))); + } + if changes.lines_removed > 0 { + if changes.lines_added > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + format!("-{}", changes.lines_removed), + color(Color::LightRed), + )); + } + spans.push(Span::raw(" ")); + } Line::from(spans) } @@ -1352,7 +1365,7 @@ fn signature_color(signature: SignatureState) -> Color { SignatureState::Unsigned => Color::Blue, SignatureState::Unverified | SignatureState::Verifying => Color::Rgb(255, 165, 0), SignatureState::Verified => Color::Green, - SignatureState::Failed => Color::Red, + SignatureState::Failed => Color::LightRed, } } @@ -1364,7 +1377,7 @@ fn graph_style(column: usize) -> Style { Color::Green, Color::Reset, Color::White, - Color::Red, + Color::LightRed, ]; let index = column % 14; let style = Style::default().fg(COLORS[index % COLORS.len()]); @@ -1490,7 +1503,7 @@ mod tests { "selection info has a left margin" ); assert_eq!(buffer[(info_x, 0)].fg, Color::Green); - assert_eq!(buffer[(info_x + 3, 0)].fg, Color::Red); + assert_eq!(buffer[(info_x + 3, 0)].fg, Color::LightRed); assert!(!buffer[(info_x, 0)].modifier.contains(Modifier::DIM)); let spacer_x = info_x + info.chars().count() as u16; assert_eq!(buffer[(spacer_x, 0)].symbol(), " ", "the marker has a left spacer"); @@ -1533,6 +1546,10 @@ mod tests { }; assert_eq!(text(Some(SelectionRelation::Tracking { ahead: 0, behind: 2 })), "⇣2"); assert_eq!(text(Some(SelectionRelation::Tracking { ahead: 0, behind: 0 })), ""); + assert!( + selection_info_line(Some(&Changes::default()), None).spans.is_empty(), + "selection information hides empty diff counts" + ); Ok(()) } @@ -1551,10 +1568,10 @@ mod tests { assert_eq!(rendered_line(&terminal, 0).trim(), "M file"); for (y, color) in [ - (1, Color::Red), + (1, Color::LightRed), (2, Color::Green), (3, Color::Cyan), - (4, Color::Red), + (4, Color::LightRed), (5, Color::Green), ] { assert_eq!(terminal.backend().buffer()[(0, y)].fg, color); @@ -2051,7 +2068,7 @@ mod tests { (SignatureState::Unsigned, Color::Blue), (SignatureState::Unverified, Color::Rgb(255, 165, 0)), (SignatureState::Verified, Color::Green), - (SignatureState::Failed, Color::Red), + (SignatureState::Failed, Color::LightRed), ]; let mut terminal = Terminal::new(TestBackend::new(2, states.len() as u16))?; terminal.draw(|frame| { @@ -2485,7 +2502,7 @@ mod tests { let added_x = position("A 1"); let deleted_x = position("D 1"); assert_eq!(terminal.backend().buffer()[(added_x, 7)].fg, Color::Green); - assert_eq!(terminal.backend().buffer()[(deleted_x, 7)].fg, Color::Red); + assert_eq!(terminal.backend().buffer()[(deleted_x, 7)].fg, Color::LightRed); assert!( terminal.backend().buffer()[(added_x, 7)] .modifier @@ -2622,7 +2639,7 @@ mod tests { "the selected filepath is inverted" ); assert_eq!(terminal.backend().buffer()[(added_x, 9)].fg, Color::Green); - assert_eq!(terminal.backend().buffer()[(removed_x, 9)].fg, Color::Red); + assert_eq!(terminal.backend().buffer()[(removed_x, 9)].fg, Color::LightRed); assert!( !terminal.backend().buffer()[(added_x, 9)] .modifier @@ -2641,6 +2658,23 @@ mod tests { "the changes pane advertises the next cycle mode" ); + app.update(Action::MoveUp); + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &Decorations::new(), + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + assert!(rendered_line(&terminal, 8).contains("A added +10")); + assert!( + !rendered_line(&terminal, 8).contains("-0"), + "selected paths hide empty counts" + ); + app.update(Action::Last); app.update(Action::ScrollRight); terminal.draw(|frame| { @@ -2830,7 +2864,10 @@ mod tests { let staged_x = rendered_line(&terminal, staged_y).find('A').expect("staged letter") as u16; let unstaged_x = rendered_line(&terminal, unstaged_y).find('M').expect("unstaged letter") as u16; assert_eq!(terminal.backend().buffer()[(staged_x, staged_y)].fg, Color::Green); - assert_eq!(terminal.backend().buffer()[(unstaged_x, unstaged_y)].fg, Color::Red); + assert_eq!( + terminal.backend().buffer()[(unstaged_x, unstaged_y)].fg, + Color::LightRed + ); let modified = Changes { paths: (0..12) @@ -2849,7 +2886,11 @@ mod tests { .iter() .map(|span| span.content.as_ref()) .collect::(); - assert!(summary.contains("M 12 · +0 -0")); + assert!(summary.contains("M 12")); + assert!( + !summary.contains("+0") && !summary.contains("-0"), + "empty diff counts are hidden" + ); assert!(!summary.contains("= 12"), "a single term already expresses the total"); terminal.draw(|frame| { @@ -2864,9 +2905,13 @@ mod tests { ); })?; assert!( - (0..8).any(|y| rendered_line(&terminal, y).contains("Worktree ── +0 -0")), + (0..8).any(|y| rendered_line(&terminal, y).contains("Worktree ──")), "an enabled clean worktree remains visible as an empty block" ); + assert!( + !(0..8).any(|y| rendered_line(&terminal, y).contains("+0") || rendered_line(&terminal, y).contains("-0")), + "a clean worktree omits empty diff counts" + ); assert!( !(0..8).any(|y| rendered_line(&terminal, y).contains("= 0")), "a clean worktree has no empty aggregate" From cda3fafe20ea7a9d65ede3b5133c7386200838a9 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 6 Aug 2026 20:03:57 +0200 Subject: [PATCH 021/282] feat: copy selected changed paths in tix Make the existing y shortcut copy the selected path when either changes block has focus. Preserve raw Git path bytes and retain commit-id copying when history has focus. --- gix-tix/src/app.rs | 7 ++++++ gix-tix/src/lib.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++++ gix-tix/src/ui.rs | 12 +++++++--- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 3a966524de8..7ab678cc110 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -281,6 +281,7 @@ pub(crate) enum Action { VerifySignatures, Cancel, Copy, + CopyPath(BString), CopyAuthor, PreviewAuthorCopy(bool), ForceQuit, @@ -291,6 +292,7 @@ pub(crate) enum Action { pub(crate) enum Effect { Cancel, CopyId(ObjectId), + CopyPath(BString), CopyAuthor(&'static Author), Reload(bool), OpenDiff(ChangePane, usize), @@ -772,6 +774,7 @@ impl App { return vec![Effect::CopyId(id)]; } } + Action::CopyPath(path) => return vec![Effect::CopyPath(path)], Action::CopyAuthor => { if let Some(author) = self .selected @@ -2589,6 +2592,10 @@ mod tests { app.extend_commits(vec![row(7)]); assert_eq!(app.update(Action::Copy), vec![Effect::CopyId(row(7).id)]); + assert_eq!( + app.update(Action::CopyPath("dir/file".into())), + vec![Effect::CopyPath("dir/file".into())] + ); assert_eq!(app.update(Action::CopyAuthor), vec![Effect::CopyAuthor(row(7).author)]); complete(&mut app); assert_eq!(app.state, State::Complete); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index ed112f78129..490daad31f0 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -1054,6 +1054,12 @@ fn event_loop( let Some(action) = action else { continue; }; + let action = copy_selected_path_action( + action, + &app, + tree_changes.as_ref().map(|(_, _, changes)| changes), + worktree_changes.as_ref().map(|(_, changes)| changes), + ); dirty = true; urgent |= !throttles_draw; let previous_changes_mode = app.changes_mode; @@ -1089,6 +1095,7 @@ fn event_loop( terminal.backend_mut(), CopyToClipboard::to_clipboard_from(id.to_hex().to_string()) )?, + Effect::CopyPath(path) => execute!(terminal.backend_mut(), CopyToClipboard::to_clipboard_from(path))?, Effect::CopyAuthor(author) => { let actor = actor_bytes(author); execute!(terminal.backend_mut(), CopyToClipboard::to_clipboard_from(actor))?; @@ -2519,6 +2526,25 @@ fn action_with_history_display(key: KeyEvent, history_display_expanded: bool) -> } } +fn copy_selected_path_action( + action: Action, + app: &App, + tree_changes: Option<&Changes>, + worktree_changes: Option<&Changes>, +) -> Action { + if action != Action::Copy { + return action; + } + let (pane, changes) = match app.changes_focus { + Some(pane @ ChangePane::Tree) => (pane, tree_changes), + Some(pane @ ChangePane::Worktree) => (pane, worktree_changes), + None => return action, + }; + changes + .and_then(|changes| changes.paths.get(app.changes(pane).selected)) + .map_or(action, |change| Action::CopyPath(change.path.clone())) +} + fn repeats_viewport(action: &Action) -> bool { matches!( action, @@ -2556,6 +2582,38 @@ fn open_test_repository(path: impl AsRef) -> Result gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 183ecc6a029..70a454f4e8b 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -538,6 +538,7 @@ pub(crate) fn draw_with_worktree( } else { spans.push(Span::raw("↑↓/jk move · h/l pan · Enter diff")); } + spans.push(Span::raw(" · y copy")); spans.push(Span::raw(match app.changes_mode { Some(ChangesMode::Both) => " · c tree", Some(ChangesMode::Tree) => " · c to hide", @@ -2654,9 +2655,13 @@ mod tests { assert!(rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan")); assert!( - rendered_line(&terminal, 14).contains("Enter diff · c tree"), + rendered_line(&terminal, 14).contains("Enter diff · y copy · c tree"), "the changes pane advertises the next cycle mode" ); + assert!( + rendered_line(&terminal, 14).contains("y copy"), + "the changes pane advertises path copying" + ); app.update(Action::MoveUp); terminal.draw(|frame| { @@ -2734,8 +2739,9 @@ mod tests { "parent context no longer crowds the aggregate border" ); assert!( - rendered_line(&terminal, 14) - .contains("vs parent 1/2 0202020 · p next parent · ↑↓/jk move · h/l pan · Enter diff · c tree"), + rendered_line(&terminal, 14).contains( + "vs parent 1/2 0202020 · p next parent · ↑↓/jk move · h/l pan · Enter diff · y copy · c tree" + ), "merge diffs keep parent controls alongside navigation" ); let parent = rendered_line(&terminal, 1); From c86aacd30357419f631b357ff9dd1f6aee23ab2d Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 7 Aug 2026 12:52:24 +0200 Subject: [PATCH 022/282] change: widen the default tix commit panel Reserve eighty content columns for commit messages on sufficiently wide terminals, in addition to the panel border and horizontal margins. This prevents conventionally wrapped commit text from orphaning its final word. --- gix-tix/src/ui.rs | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 70a454f4e8b..8cb547b0bbc 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -17,6 +17,7 @@ use crate::{ }; const COMPARED_PARENT_COLOR: Color = Color::Cyan; +const COMMIT_PANE_WIDTH: u16 = 84; const NOTE_COLOR: Color = Color::LightMagenta; const PANE_STATUS_BACKGROUND: Color = Color::DarkGray; @@ -208,7 +209,7 @@ pub(crate) fn draw_with_worktree( let tree_summary = tree_changes.map(|changes| changes_summary(ChangePane::Tree, app, changes)); let worktree_summary = worktree_changes.map(|changes| changes_summary(ChangePane::Worktree, app, changes)); let commit_pane = app.show_commit.then(|| { - let width = 80.min(full_body.width / 2); + let width = COMMIT_PANE_WIDTH.min(full_body.width / 2); let [commits, message] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]).areas(full_body); body.width = body.width.min(commits.width); let mut content = message.inner(Margin { @@ -2200,20 +2201,37 @@ mod tests { app.update(Action::ToggleCommit); let mut wide_terminal = Terminal::new(TestBackend::new(200, 6))?; + let conventional_line = format!("{} word", "x".repeat(75)); wide_terminal.draw(|frame| { super::draw( frame, &mut app, &Decorations::new(), &gix::mailmap::Snapshot::default(), - Some(b"subject".as_bstr()), + Some(conventional_line.as_bytes().as_bstr()), None, ); })?; assert_eq!( - wide_terminal.backend().buffer()[(122, 0)].symbol(), - "s", - "the pane remains eighty columns wide on a wide screen" + wide_terminal.backend().buffer()[(118, 0)].symbol(), + "x", + "the pane reserves eighty content columns on a wide screen" + ); + assert!( + rendered_line(&wide_terminal, 0) + .chars() + .skip(118) + .take(80) + .collect::() + .ends_with(" word") + && rendered_line(&wide_terminal, 1) + .chars() + .skip(118) + .take(80) + .collect::() + .trim() + .is_empty(), + "an eighty-column message line does not wrap its final word" ); Ok(()) } @@ -2815,7 +2833,7 @@ mod tests { ChangesLayout::SideBySide, "sufficient remaining width still permits side-by-side changes" ); - assert_eq!(wide_terminal.backend().buffer()[(160, 7)].symbol(), "│"); + assert_eq!(wide_terminal.backend().buffer()[(156, 7)].symbol(), "│"); Ok(()) } From 2780da79da54a9de0fef63257d63601f8143ba96 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 8 Aug 2026 16:24:14 +0200 Subject: [PATCH 023/282] feat: persist tix diagnostics to OS log storage Write daily, non-ANSI tracing logs to the platform-standard application log directory so watcher and refresh failures can be diagnosed after the terminal UI exits. Keep seven days of logs and install the subscriber only for the calling thread so embedding tix cannot replace an application-wide tracing subscriber. Logging is best-effort: initialization failures are reported before terminal setup and do not prevent tix from starting. --- Cargo.lock | 81 ++++++++++++++++++++++++++++ gix-tix/Cargo.toml | 4 ++ gix-tix/src/lib.rs | 13 +++++ gix-tix/src/logging.rs | 119 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 gix-tix/src/logging.rs diff --git a/Cargo.lock b/Cargo.lock index 94051ba0a1e..366b40f9ee1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1078,6 +1078,27 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -2575,10 +2596,14 @@ version = "0.1.0" dependencies = [ "anyhow", "crossterm", + "directories", "gix", "gix-testtools", "notify", "ratatui", + "tracing", + "tracing-appender", + "tracing-subscriber", ] [[package]] @@ -3553,6 +3578,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + [[package]] name = "libsqlite3-sys" version = "0.38.1" @@ -3936,6 +3970,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "page_size" version = "0.6.0" @@ -4429,6 +4469,17 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.4" @@ -5040,6 +5091,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -5240,6 +5297,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -5248,6 +5306,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5437,6 +5505,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/gix-tix/Cargo.toml b/gix-tix/Cargo.toml index 37ad53d3fcb..6b4af3ca78b 100644 --- a/gix-tix/Cargo.toml +++ b/gix-tix/Cargo.toml @@ -25,9 +25,13 @@ sha256 = ["gix/sha256"] [dependencies] anyhow = "1.0.98" crossterm = { version = "0.29.0", features = ["osc52"] } +directories = "6.0.0" gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["blob-diff", "mailmap", "notes", "parallel", "revision", "command", "status"] } notify = "8.2.0" ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } +tracing = "0.1.37" +tracing-appender = "0.2.4" +tracing-subscriber = "0.3.17" [dev-dependencies] gix-testtools = { path = "../tests/tools" } diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 490daad31f0..8003f909670 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -4,6 +4,7 @@ mod app; mod history; +mod logging; mod ui; use std::{ @@ -374,6 +375,18 @@ pub enum Screen { /// Run the interactive commit graph for `repository`. pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec, options: Options) -> Result<()> { + let _log_guard = match logging::init() { + Ok(guard) => Some(guard), + Err(err) => { + eprintln!("warning: could not initialize tix diagnostics: {err:#}"); + None + } + }; + tracing::info!( + revision_count = revisions.len(), + hidden_revision_count = options.hide.len(), + "starting tix" + ); let terminal_height = match options.screen { Screen::Always => 0, Screen::Auto | Screen::Half => terminal::size().context("could not determine terminal size")?.1, diff --git a/gix-tix/src/logging.rs b/gix-tix/src/logging.rs new file mode 100644 index 00000000000..f5dba90f934 --- /dev/null +++ b/gix-tix/src/logging.rs @@ -0,0 +1,119 @@ +use std::{ + fs, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; + +use anyhow::{Context, Result}; +use tracing_subscriber::{filter::Targets, prelude::*}; + +const FILE_PREFIX: &str = "tix.log"; +const RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +pub(crate) fn init() -> Result { + let directory = log_directory().context("could not determine the platform log directory")?; + fs::create_dir_all(&directory) + .with_context(|| format!("could not create log directory at {}", directory.display()))?; + let cleanup_errors = prune(&directory, SystemTime::now()); + let appender = tracing_appender::rolling::RollingFileAppender::builder() + .rotation(tracing_appender::rolling::Rotation::DAILY) + .filename_prefix(FILE_PREFIX) + .build(&directory) + .context("could not open the daily diagnostic log")?; + let subscriber = tracing_subscriber::registry().with( + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_target(false) + .with_writer(appender) + .with_filter( + Targets::new() + .with_default(tracing::Level::WARN) + .with_target("gix_tix", tracing::Level::DEBUG), + ), + ); + let guard = tracing::subscriber::set_default(subscriber); + tracing::info!(path = %directory.display(), "initialized diagnostics"); + for error in cleanup_errors { + tracing::warn!(%error, "could not prune an old diagnostic log"); + } + Ok(guard) +} + +#[cfg(target_os = "macos")] +fn log_directory() -> Option { + directories::BaseDirs::new().map(|dirs| dirs.home_dir().join("Library/Logs/org.GitoxideLabs.tix")) +} + +#[cfg(target_os = "linux")] +fn log_directory() -> Option { + directories::ProjectDirs::from("org", "GitoxideLabs", "tix").and_then(|dirs| dirs.state_dir().map(Path::to_owned)) +} + +#[cfg(target_os = "windows")] +fn log_directory() -> Option { + directories::BaseDirs::new().map(|dirs| dirs.data_local_dir().join("GitoxideLabs/tix/logs")) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn log_directory() -> Option { + directories::ProjectDirs::from("org", "GitoxideLabs", "tix").map(|dirs| dirs.data_local_dir().join("logs")) +} + +fn prune(directory: &Path, now: SystemTime) -> Vec { + let mut errors = Vec::new(); + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(err) => { + errors.push(err.to_string()); + return errors; + } + }; + for entry in entries { + let result = (|| -> std::io::Result<()> { + let entry = entry?; + let name = entry.file_name(); + if !name.to_string_lossy().starts_with(&format!("{FILE_PREFIX}.")) { + return Ok(()); + } + let age = now.duration_since(entry.metadata()?.modified()?).unwrap_or_default(); + if age > RETENTION { + fs::remove_file(entry.path())?; + } + Ok(()) + })(); + if let Err(err) = result { + errors.push(err.to_string()); + } + } + errors +} + +#[cfg(test)] +mod tests { + use std::{fs::File, time::UNIX_EPOCH}; + + use super::*; + + #[test] + fn prunes_only_expired_daily_logs() -> gix_testtools::Result { + let directory = std::env::temp_dir().join(format!( + "gix-tix-log-prune-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() + )); + fs::create_dir(&directory)?; + let old = directory.join("tix.log.older"); + let recent = directory.join("tix.log.recent"); + let unrelated = directory.join("other.log.old"); + File::create(&old)?.set_modified(UNIX_EPOCH)?; + File::create(&recent)?; + File::create(&unrelated)?.set_modified(UNIX_EPOCH)?; + + assert!(prune(&directory, SystemTime::now()).is_empty()); + assert!(!old.exists(), "expired tix logs are removed"); + assert!(recent.exists(), "recent tix logs are retained"); + assert!(unrelated.exists(), "unrelated files are retained"); + fs::remove_dir_all(directory)?; + Ok(()) + } +} From 330919f5cb73835e4b0a103adeb23c592278e1f3 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 8 Aug 2026 16:26:05 +0200 Subject: [PATCH 024/282] fix: reliably refresh tix after repository changes Start worktree observation whenever the default combined changes view is active, including at startup, so edits made before cycling the panel are not missed. Treat ref events as potential worktree-status changes as well, which covers checked-out branch movement changing both HEAD and the index/worktree comparison. Bound notification batches and use a fixed 75ms coalescing window so event storms cannot starve terminal input. Honor backend rescan requests, retain the native event-driven design, and retry failed ref or worktree watchers every five seconds while they are needed. Add diagnostic tracing around watcher roots, event decisions, cache invalidation, snapshot comparisons, refresh workers, retries, and worktree-removal recovery. --- gix-tix/src/lib.rs | 345 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 282 insertions(+), 63 deletions(-) diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 8003f909670..9bc3ba1a8d6 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -52,6 +52,7 @@ const REPEAT_IDLE: Duration = Duration::from_millis(75); const WORKTREE_EVENT_IDLE: Duration = Duration::from_millis(75); const IMMEDIATE_PAGER_EXIT: Duration = Duration::from_millis(250); const REF_EVENT_INTERVAL: Duration = Duration::from_millis(250); +const WATCH_RETRY_INTERVAL: Duration = Duration::from_secs(5); struct FillRepository { path: PathBuf, @@ -69,6 +70,11 @@ struct WorktreeWatcher { index: PathBuf, } +struct RefWatcher { + _watcher: RecommendedWatcher, + events: mpsc::Receiver>, +} + impl WorktreeWatcher { fn event_is_relevant(&self, event: ¬ify::Event) -> bool { worktree_event_is_relevant(event, &self.workdir, &self.dot_git, &self.git_dir, &self.index) @@ -82,10 +88,37 @@ fn worktree_event_is_relevant( git_dir: &Path, index: &Path, ) -> bool { - !matches!(event.kind, notify::EventKind::Access(_)) - && event.paths.iter().any(|path| { - path == index || (path.starts_with(workdir) && !path.starts_with(dot_git) && !path.starts_with(git_dir)) - }) + event.need_rescan() + || (!matches!(event.kind, notify::EventKind::Access(_)) + && event.paths.iter().any(|path| { + path == index || (path.starts_with(workdir) && !path.starts_with(dot_git) && !path.starts_with(git_dir)) + })) +} + +fn notification_is_actionable(event: ¬ify::Event) -> bool { + event.need_rescan() || !matches!(event.kind, notify::EventKind::Access(_)) +} + +fn worktree_watcher_needed(repository_is_bare: bool, mode: Option) -> bool { + !repository_is_bare && mode == Some(ChangesMode::Both) +} + +fn schedule_once(deadline: &mut Option, now: Instant, delay: Duration) -> bool { + if deadline.is_some() { + false + } else { + *deadline = Some(now + delay); + true + } +} + +fn take_due(deadline: &mut Option, now: Instant) -> bool { + if deadline.is_some_and(|deadline| now >= deadline) { + *deadline = None; + true + } else { + false + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -632,7 +665,15 @@ fn event_loop( .context("could not open Git notes")?; let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); let mut ref_snapshot = history::snapshot(&view_repository, &revisions, &hide)?; - let (mut ref_watcher, mut ref_events) = start_ref_watcher(&repository_path, &common_dir); + let mut watcher_retry_deadline = None; + let mut ref_watcher = match start_ref_watcher(&repository_path, &common_dir) { + Ok(watcher) => Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "reference watcher startup failed"); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + None + } + }; let (cancelled, receiver) = start_history( repository, &revisions, @@ -676,6 +717,16 @@ fn event_loop( repository_is_bare, line_diff_parallelism, )?; + if worktree_watcher_needed(repository_is_bare, app.changes_mode) { + match start_worktree_watcher(&view_repository) { + Ok(watcher) => worktree_watcher = Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "worktree watcher startup failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + } + } let mut decorations = Decorations::new(); draw( terminal, @@ -701,45 +752,129 @@ fn event_loop( let mut focused = true; let mut repeat_deadline: Option = None; let result: Result> = (|| loop { + let mut worktree_watch_error = None; if let Some(watcher) = worktree_watcher.as_mut() { - while let Ok(event) = watcher.events.try_recv() { - match event { - Ok(event) if watcher.event_is_relevant(&event) => { - worktree_refresh_deadline = Some(Instant::now() + WORKTREE_EVENT_IDLE); + let mut received = 0; + let mut relevant = 0; + let mut rescans = 0; + while received < EVENT_BATCH_SIZE { + match watcher.events.try_recv() { + Ok(Ok(event)) => { + received += 1; + rescans += usize::from(event.need_rescan()); + if watcher.event_is_relevant(&event) { + relevant += 1; + schedule_once(&mut worktree_refresh_deadline, Instant::now(), WORKTREE_EVENT_IDLE); + } } - Ok(_) => {} - Err(err) => { - app.worktree_changes.error = Some(format!("worktree watch: {err}")); - worktree_watcher = None; - worktree_refresh_deadline = None; - dirty = true; - urgent = true; + Ok(Err(err)) => { + worktree_watch_error = Some(err); break; } + Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => break, } } + if received > 0 { + tracing::debug!(received, relevant, rescans, "processed worktree event batch"); + } } - if worktree_refresh_deadline.is_some_and(|deadline| Instant::now() >= deadline) { + if let Some(err) = worktree_watch_error { + tracing::warn!(error = %err, "worktree watcher failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + worktree_watcher = None; worktree_refresh_deadline = None; - invalidate_worktree_changes(&mut worktree_changes); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); dirty = true; urgent = true; } - while let Ok(event) = ref_events.try_recv() { - match event { - Ok(event) if !matches!(event.kind, notify::EventKind::Access(_)) => { - refresh_pending = true; - refresh_from_filesystem = true; - if refresh_receiver.is_some() { - refresh_select_top = true; + if take_due(&mut worktree_refresh_deadline, Instant::now()) { + let invalidated = invalidate_worktree_changes(&mut worktree_changes); + tracing::debug!(invalidated, "worktree event deadline elapsed"); + dirty = true; + urgent = true; + } + let mut ref_watch_error = None; + if let Some(watcher) = ref_watcher.as_mut() { + let mut received = 0; + let mut actionable = 0; + let mut rescans = 0; + while received < EVENT_BATCH_SIZE { + match watcher.events.try_recv() { + Ok(Ok(event)) => { + received += 1; + rescans += usize::from(event.need_rescan()); + if notification_is_actionable(&event) { + actionable += 1; + refresh_pending = true; + refresh_from_filesystem = true; + if refresh_receiver.is_some() { + refresh_select_top = true; + } + if invalidate_worktree_changes(&mut worktree_changes) { + dirty = true; + urgent = true; + } + } + } + Ok(Err(err)) => { + ref_watch_error = Some(err); + break; + } + Err(mpsc::TryRecvError::Empty | mpsc::TryRecvError::Disconnected) => break, + } + } + if received > 0 { + tracing::debug!(received, actionable, rescans, "processed reference event batch"); + } + } + if let Some(err) = ref_watch_error { + tracing::warn!(error = %err, "reference watcher failed"); + ref_watcher = None; + app.manual_refresh = true; + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + if take_due(&mut watcher_retry_deadline, Instant::now()) { + let mut retry = false; + if ref_watcher.is_none() { + match start_ref_watcher(&repository_path, &common_dir) { + Ok(watcher) => { + tracing::info!("reference watcher recovered"); + ref_watcher = Some(watcher); + app.manual_refresh = false; + } + Err(err) => { + tracing::warn!(error = %err, "reference watcher retry failed"); + retry = true; } } - Ok(_) => {} - Err(_) => { - ref_watcher = None; - app.manual_refresh = true; + } + if worktree_watcher_needed(repository_is_bare, app.changes_mode) && worktree_watcher.is_none() { + match start_worktree_watcher(&view_repository) { + Ok(watcher) => { + tracing::info!("worktree watcher recovered"); + worktree_watcher = Some(watcher); + if app + .worktree_changes + .error + .as_deref() + .is_some_and(|message| message.starts_with("worktree watch:")) + { + app.worktree_changes.error = None; + } + invalidate_worktree_changes(&mut worktree_changes); + dirty = true; + urgent = true; + } + Err(err) => { + tracing::warn!(error = %err, "worktree watcher retry failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + retry = true; + } } } + if retry { + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } } if repeat_deadline.is_some_and(|deadline| Instant::now() >= deadline) { repeat_deadline = None; @@ -790,6 +925,7 @@ fn event_loop( match result { Ok(result) => { let result = result?; + tracing::info!(commit_count = result.commits.rows.len(), "history refresh completed"); decorations = result.decorations; selection_relation = None; app.selection_relation = None; @@ -848,9 +984,15 @@ fn event_loop( true, line_diff_parallelism, )?; - let (watcher, events) = start_ref_watcher(&repository_path, &repository_path); - ref_watcher = watcher; - ref_events = events; + tracing::warn!(common_dir = %repository_path.display(), "worktree disappeared; recovered with common repository"); + ref_watcher = match start_ref_watcher(&repository_path, &repository_path) { + Ok(watcher) => Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "reference watcher recovery failed"); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + None + } + }; app.manual_refresh = ref_watcher.is_none(); app.notice = Some("worktree removed; using the common repository without worktree changes".into()); open_repository(&repository_path, true, true) @@ -861,6 +1003,7 @@ fn event_loop( let next = history::snapshot(&repository, &revisions, &hide)?; let hidden_changed = next.hidden != ref_snapshot.hidden; let tips_changed = next.view != ref_snapshot.view || hidden_changed; + tracing::debug!(tips_changed, hidden_changed, "compared reference snapshot"); let select_top = std::mem::take(&mut refresh_from_filesystem); ref_snapshot = next; refresh_pending = false; @@ -883,6 +1026,7 @@ fn event_loop( refresh_select_top = select_top; refresh_expand_hidden = false; app.state = State::Loading; + tracing::info!(select_top, "started history refresh"); } else { let next = history::decorations(&repository)?; let relation_changed = selection_relation @@ -895,6 +1039,7 @@ fn event_loop( app.selection_relation = None; decorations = next; dirty = true; + tracing::debug!(relation_changed, "updated history decorations"); } } } @@ -999,7 +1144,8 @@ fn event_loop( let worktree_timeout = worktree_refresh_deadline .map(|deadline| deadline.saturating_duration_since(Instant::now())) .or_else(|| worktree_watcher.as_ref().map(|_| REF_EVENT_INTERVAL)); - let wake_after = [repeat_timeout, watcher_timeout, worktree_timeout] + let retry_timeout = watcher_retry_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); + let wake_after = [repeat_timeout, watcher_timeout, worktree_timeout, retry_timeout] .into_iter() .flatten() .min(); @@ -1092,9 +1238,23 @@ fn event_loop( )?; if app.changes_mode == Some(ChangesMode::Both) { invalidate_worktree_changes(&mut worktree_changes); - worktree_watcher = start_worktree_watcher(&view_repository); - if worktree_watcher.is_none() { - app.worktree_changes.error = Some("worktree changes won't update automatically".into()); + match start_worktree_watcher(&view_repository) { + Ok(watcher) => { + worktree_watcher = Some(watcher); + if app + .worktree_changes + .error + .as_deref() + .is_some_and(|message| message.starts_with("worktree watch:")) + { + app.worktree_changes.error = None; + } + } + Err(err) => { + tracing::warn!(error = %err, "worktree watcher startup failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } } } else if previous_changes_mode == Some(ChangesMode::Both) { worktree_watcher = None; @@ -1304,19 +1464,12 @@ fn start_history_refresh( receiver } -fn start_ref_watcher( - git_dir: &Path, - common_dir: &Path, -) -> ( - Option, - mpsc::Receiver>, -) { - let (sender, receiver) = mpsc::channel(); - let Ok(mut watcher) = notify::recommended_watcher(move |event| { +fn start_ref_watcher(git_dir: &Path, common_dir: &Path) -> Result { + let (sender, events) = mpsc::channel(); + let mut watcher = notify::recommended_watcher(move |event| { let _ = sender.send(event); - }) else { - return (None, receiver); - }; + }) + .context("could not initialize reference watcher")?; let mut roots = vec![(common_dir.to_owned(), RecursiveMode::NonRecursive)]; if git_dir != common_dir { roots.push((git_dir.to_owned(), RecursiveMode::NonRecursive)); @@ -1326,15 +1479,23 @@ fn start_ref_watcher( roots.push((root, RecursiveMode::Recursive)); } } - if roots.into_iter().all(|(path, mode)| watcher.watch(&path, mode).is_ok()) { - (Some(watcher), receiver) - } else { - (None, receiver) + for (path, mode) in &roots { + watcher + .watch(path, *mode) + .with_context(|| format!("could not watch references at {}", path.display()))?; } + tracing::info!(?roots, "watching references"); + Ok(RefWatcher { + _watcher: watcher, + events, + }) } -fn start_worktree_watcher(repository: &gix::Repository) -> Option { - let workdir = repository.workdir()?.to_owned(); +fn start_worktree_watcher(repository: &gix::Repository) -> Result { + let workdir = repository + .workdir() + .context("cannot watch a bare repository")? + .to_owned(); let index = repository.index_path(); let git_dir = repository.git_dir().to_owned(); let dot_git = workdir.join(gix::discover::DOT_GIT_DIR); @@ -1342,13 +1503,18 @@ fn start_worktree_watcher(repository: &gix::Repository) -> Option Option) { +fn invalidate_worktree_changes(changes: &mut Option<(usize, Changes)>) -> bool { if let Some((marker, _)) = changes { + if *marker == usize::MAX { + return false; + } *marker = usize::MAX; + return true; } + false } fn visible_decorations_changed(old: &Decorations, new: &Decorations, rows: &[CommitRow]) -> bool { @@ -1576,6 +1747,7 @@ fn draw( *tree_changes = Some((id, app.changes_parent, loaded)); } if worktree_changes_to_load { + let started = Instant::now(); repository.object_cache_size(OBJECT_CACHE_SIZE); let loaded = load_worktree_changes( repository, @@ -1586,10 +1758,23 @@ fn draw( repository.object_cache_size(None); match loaded { Ok(loaded) => { - app.worktree_changes.error = None; + tracing::debug!( + path_count = loaded.paths.len(), + elapsed_ms = started.elapsed().as_millis(), + "loaded worktree changes" + ); + if !app + .worktree_changes + .error + .as_deref() + .is_some_and(|message| message.starts_with("worktree watch:")) + { + app.worktree_changes.error = None; + } *worktree_changes = Some((0, loaded)); } Err(err) => { + tracing::warn!(error = %err, "could not load worktree changes"); app.worktree_changes.error = Some(format!("status: {err:#}")); if let Some((marker, _)) = worktree_changes.as_mut() { *marker = 0; @@ -3459,7 +3644,7 @@ mod tests { #[test] fn filters_worktree_watch_events_and_invalidates_cached_status() { - use notify::event::{AccessKind, ModifyKind}; + use notify::event::{AccessKind, Flag, ModifyKind}; let workdir = Path::new("/repo"); let dot_git = workdir.join(".git"); @@ -3493,9 +3678,43 @@ mod tests { assert!(!worktree_event_is_relevant( &access, workdir, &dot_git, &git_dir, &index )); + assert!(!notification_is_actionable(&access)); + let rescan = notify::Event::new(notify::EventKind::Other).set_flag(Flag::Rescan); + assert!(worktree_event_is_relevant(&rescan, workdir, &dot_git, &git_dir, &index)); + assert!(notification_is_actionable(&rescan)); let mut changes = Some((0, Changes::default())); - invalidate_worktree_changes(&mut changes); + assert!(invalidate_worktree_changes(&mut changes)); assert_eq!(changes.as_ref().map(|(marker, _)| *marker), Some(usize::MAX)); + assert!(!invalidate_worktree_changes(&mut changes)); + } + + #[test] + fn starts_worktree_watching_for_the_combined_view() { + assert!(worktree_watcher_needed(false, Some(ChangesMode::Both))); + assert!(!worktree_watcher_needed(false, Some(ChangesMode::Tree))); + assert!(!worktree_watcher_needed(false, None)); + assert!(!worktree_watcher_needed(true, Some(ChangesMode::Both))); + } + + #[test] + fn event_deadlines_coalesce_without_extending_and_can_be_retried() { + let now = Instant::now(); + let mut deadline = None; + assert!(schedule_once(&mut deadline, now, WORKTREE_EVENT_IDLE)); + let first = deadline; + assert!(!schedule_once( + &mut deadline, + now + Duration::from_millis(50), + WORKTREE_EVENT_IDLE + )); + assert_eq!(deadline, first, "later events do not extend the debounce window"); + assert!(!take_due(&mut deadline, now + Duration::from_millis(74))); + assert!(take_due(&mut deadline, now + WORKTREE_EVENT_IDLE)); + assert_eq!(deadline, None); + + assert!(schedule_once(&mut deadline, now, WATCH_RETRY_INTERVAL)); + assert!(!take_due(&mut deadline, now + Duration::from_secs(4))); + assert!(take_due(&mut deadline, now + WATCH_RETRY_INTERVAL)); } } From 228854b90e1c52fe12b2840cdb468ce58c778461 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 8 Aug 2026 17:17:58 +0200 Subject: [PATCH 025/282] fix: retain changed-path selection across refreshes Preserve the selected path and its relative viewport position when filesystem notifications reload tree or worktree changes. Fall back to the previous numeric position, clamped by layout, only when the selected path disappeared. --- gix-tix/src/lib.rs | 63 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 9bc3ba1a8d6..8e329818d22 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -1606,6 +1606,25 @@ fn count_exclusive_commits(repo: &gix::Repository, tip: gix::ObjectId, hidden: g }) } +fn remembered_change_selection(view: &app::ChangesView, changes: Option<&Changes>) -> Option<(BString, usize)> { + changes.and_then(|changes| { + changes + .paths + .get(view.selected) + .map(|change| (change.path.clone(), view.selected.saturating_sub(view.offset))) + }) +} + +fn restore_change_selection(view: &mut app::ChangesView, changes: &Changes, remembered: Option<(BString, usize)>) { + let Some((path, viewport_row)) = remembered else { + return; + }; + if let Some(selected) = changes.paths.iter().position(|change| change.path == path) { + view.selected = selected; + view.offset = selected.saturating_sub(viewport_row); + } +} + #[expect(clippy::too_many_arguments, reason = "drawing needs the complete view state")] fn draw( terminal: &mut ratatui::DefaultTerminal, @@ -1687,9 +1706,17 @@ fn draw( && worktree_changes .as_ref() .is_none_or(|(marker, _)| *marker == usize::MAX); - if tree_changes_to_load.is_some() || worktree_changes_to_load { - app.reset_changes_view(); - } + let tree_selection = tree_changes_to_load.and_then(|_| { + remembered_change_selection(&app.tree_changes, tree_changes.as_ref().map(|(_, _, changes)| changes)) + }); + let worktree_selection = worktree_changes_to_load + .then(|| { + remembered_change_selection( + &app.worktree_changes, + worktree_changes.as_ref().map(|(_, changes)| changes), + ) + }) + .flatten(); if !app.show_commit || selected.is_none() { *commit_message = None; } @@ -1744,6 +1771,7 @@ fn draw( repository.object_cache_size(None); let loaded = loaded?; app.changes_parent = loaded.parent.map_or(0, |parent| parent.index); + restore_change_selection(&mut app.tree_changes, &loaded, tree_selection); *tree_changes = Some((id, app.changes_parent, loaded)); } if worktree_changes_to_load { @@ -1771,6 +1799,7 @@ fn draw( { app.worktree_changes.error = None; } + restore_change_selection(&mut app.worktree_changes, &loaded, worktree_selection); *worktree_changes = Some((0, loaded)); } Err(err) => { @@ -3697,6 +3726,34 @@ mod tests { assert!(!worktree_watcher_needed(true, Some(ChangesMode::Both))); } + #[test] + fn restores_changed_path_selection_after_reordering() { + let path = |path: &str| PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Unstaged, + source: None, + path: path.into(), + lines: None, + }; + let previous = Changes { + paths: ["a", "b", "selected"].into_iter().map(path).collect(), + ..Changes::default() + }; + let mut view = app::ChangesView::default(); + view.selected = 2; + view.offset = 1; + let remembered = remembered_change_selection(&view, Some(&previous)); + let refreshed = Changes { + paths: ["x", "y", "z", "selected"].into_iter().map(path).collect(), + ..Changes::default() + }; + + restore_change_selection(&mut view, &refreshed, remembered); + + assert_eq!(view.selected, 3, "the same path remains selected"); + assert_eq!(view.offset, 2, "the path retains its relative viewport row"); + } + #[test] fn event_deadlines_coalesce_without_extending_and_can_be_retried() { let now = Instant::now(); From 38c3dd73b967fd076b564959bf206630c1c17341 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 8 Aug 2026 17:21:32 +0200 Subject: [PATCH 026/282] fix: release idle tix repository handles Remove the long-lived view repository and notes platform from the event loop because both retained object database pack handles while tix was idle. Open repositories only for bounded view population and watcher setup, then retain detached mailmap, note, and reference data. Document repository lifetime as a local gix-tix invariant so future panes and platforms do not accidentally reintroduce persistent repository ownership. --- gix-tix/AGENTS.md | 8 ++ gix-tix/src/lib.rs | 250 +++++++++++++++++++++++++-------------------- 2 files changed, 147 insertions(+), 111 deletions(-) create mode 100644 gix-tix/AGENTS.md diff --git a/gix-tix/AGENTS.md b/gix-tix/AGENTS.md new file mode 100644 index 00000000000..b3c339703dd --- /dev/null +++ b/gix-tix/AGENTS.md @@ -0,0 +1,8 @@ +# gix-tix invariants + +## Repository lifetime + +- Do not retain a `gix::Repository`, or a platform/object that owns one, in application or event-loop state while tix is idle. +- Open a fresh, non-isolated repository for bounded view population so configuration such as mailmap and diff filters is honored, then retain only detached display data. +- The fill repository may be reused only while continuous navigation is active and must be dropped when its idle timer expires. +- Filesystem watchers retain paths and native watcher handles, never repositories. diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 8e329818d22..b9eecec27db 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -153,19 +153,33 @@ pub(crate) enum FileChange { Unavailable(&'static str), } -type LineDiffResult = (usize, FileChange, Result); - struct LineDiffJob { index: usize, change: FileChange, } +enum LineDiffMessage { + Job(LineDiffJob), + FinishBatch, +} + +enum LineDiffResult { + Change(usize, FileChange, Result), + BatchFinished, +} + struct LineDiffPool { - jobs: Option>, + jobs: Vec>, results: mpsc::Receiver, workers: Vec>, } +type LineDiffState = ( + gix::Repository, + gix::diff::blob::Platform, + Option, +); + fn worktree_diff_cache( repository: &gix::Repository, mode: gix::diff::blob::pipeline::Mode, @@ -240,79 +254,106 @@ fn line_counts_for_change( Ok(counts.map(|counts| (counts.insertions, counts.removals))) } +fn open_line_diff_state(repository_path: &Path, bare: bool) -> Result { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository for parallel line diffs")?; + repository.object_cache_size(OBJECT_CACHE_SIZE); + let tree_cache = repository + .diff_resource_cache_for_tree_diff() + .context("could not initialize parallel line diffs")?; + let worktree_cache = if bare { + None + } else { + worktree_diff_cache(&repository, gix::diff::blob::pipeline::Mode::ToGit)? + }; + Ok((repository, tree_cache, worktree_cache)) +} + impl LineDiffPool { fn new(repository_path: &Path, bare: bool, parallelism: usize) -> Result { - let repository = open_repository(repository_path, bare, false) - .context("could not open repository for parallel line diffs")? - .into_sync(); - let mut worker_state = Vec::with_capacity(parallelism); - for _ in 0..parallelism { - let mut repository = repository.to_thread_local(); - repository.object_cache_size(OBJECT_CACHE_SIZE); - let tree_cache = repository - .diff_resource_cache_for_tree_diff() - .context("could not initialize parallel line diffs")?; - let worktree_cache = if bare { - None - } else { - worktree_diff_cache(&repository, gix::diff::blob::pipeline::Mode::ToGit)? - }; - worker_state.push((repository, tree_cache, worktree_cache)); - } - - let (jobs, job_receiver) = mpsc::channel::(); - let job_receiver = - gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(job_receiver)); + drop(open_line_diff_state(repository_path, bare)?); let (result_sender, results) = mpsc::channel(); - let workers = worker_state - .into_iter() - .map(|(repository, mut tree_cache, mut worktree_cache)| { - let job_receiver = gix::features::threading::OwnShared::clone(&job_receiver); + let mut jobs = Vec::with_capacity(parallelism); + let workers = (0..parallelism) + .map(|_| { + let (job_sender, job_receiver) = mpsc::channel(); + jobs.push(job_sender); let result_sender = result_sender.clone(); + let repository_path = repository_path.to_owned(); std::thread::spawn(move || { - loop { - let Ok(job) = gix::features::threading::lock(&job_receiver).recv() else { - break; - }; - let result = - line_counts_for_change(&repository, &job.change, &mut tree_cache, worktree_cache.as_mut()); - tree_cache.clear_resource_cache_keep_allocation(); - if let Some(cache) = worktree_cache.as_mut() { - cache.clear_resource_cache_keep_allocation(); - } - if result_sender.send((job.index, job.change, result)).is_err() { - break; + let mut state: Option = None; + while let Ok(message) = job_receiver.recv() { + match message { + LineDiffMessage::Job(job) => { + let result = (|| { + if state.is_none() { + state = Some(open_line_diff_state(&repository_path, bare)?); + } + let (repository, tree_cache, worktree_cache) = + state.as_mut().expect("line diff state was just initialized"); + let result = line_counts_for_change( + repository, + &job.change, + tree_cache, + worktree_cache.as_mut(), + ); + tree_cache.clear_resource_cache_keep_allocation(); + if let Some(cache) = worktree_cache.as_mut() { + cache.clear_resource_cache_keep_allocation(); + } + result + })(); + if result_sender + .send(LineDiffResult::Change(job.index, job.change, result)) + .is_err() + { + break; + } + } + LineDiffMessage::FinishBatch => { + state = None; + if result_sender.send(LineDiffResult::BatchFinished).is_err() { + break; + } + } } } }) }) .collect(); - Ok(LineDiffPool { - jobs: Some(jobs), - results, - workers, - }) + Ok(LineDiffPool { jobs, results, workers }) } fn line_counts(&mut self, changes: Vec) -> Result> { let len = changes.len(); - let jobs = self.jobs.as_ref().context("line diff pool is shutting down")?; + let worker_count = self.jobs.len(); for (index, change) in changes.into_iter().enumerate() { - jobs.send(LineDiffJob { index, change }) + self.jobs[index % worker_count] + .send(LineDiffMessage::Job(LineDiffJob { index, change })) + .context("line diff workers stopped unexpectedly")?; + } + for jobs in &self.jobs { + jobs.send(LineDiffMessage::FinishBatch) .context("line diff workers stopped unexpectedly")?; } let mut out: Vec<_> = std::iter::repeat_with(|| None).take(len).collect(); let mut first_error = None; - for _ in 0..len { - let (index, change, result) = self.results.recv().context("line diff workers stopped unexpectedly")?; - match result { - Ok(lines) => { - *out.get_mut(index) - .context("line diff worker returned an invalid result index")? = Some((change, lines)); + let mut completed = 0; + let mut finished = 0; + while completed < len || finished < worker_count { + match self.results.recv().context("line diff workers stopped unexpectedly")? { + LineDiffResult::Change(index, change, Ok(lines)) => { + *out.get_mut(index).expect("jobs preserve their original result index") = Some((change, lines)); + completed += 1; + } + LineDiffResult::Change(_, _, Err(err)) => { + if first_error.is_none() { + first_error = Some(err); + } + completed += 1; } - Err(err) if first_error.is_none() => first_error = Some(err), - Err(_) => {} + LineDiffResult::BatchFinished => finished += 1, } } if let Some(err) = first_error { @@ -326,7 +367,7 @@ impl LineDiffPool { impl Drop for LineDiffPool { fn drop(&mut self) { - drop(self.jobs.take()); + self.jobs.clear(); for worker in self.workers.drain(..) { drop(worker.join()); } @@ -650,21 +691,20 @@ fn event_loop( let mut repository_path = repository.git_dir().to_owned(); let common_dir = normalize_common_dir(repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()))?; let (mut view_repository, recovered_at_startup) = open_history_repository(&mut repository_path, &common_dir)?; - let mut repository_is_bare = view_repository.workdir().is_none(); + view_repository.object_cache_size(None); + let (mut repository_is_bare, mut mailmap, mut ref_snapshot) = { + let bare = view_repository.workdir().is_none(); + let mailmap = view_repository.open_mailmap(); + let refs = history::snapshot(&view_repository, &revisions, &hide)?; + (bare, mailmap, refs) + }; if recovered_at_startup { repository = view_repository.into_sync(); repository_is_bare = true; - view_repository = open_repository(&repository_path, true, false) - .context("could not reopen common repository for history metadata")?; + } else { + drop(view_repository); } - view_repository.object_cache_size(None); - let mut mailmap = view_repository.open_mailmap(); - let mut notes = view_repository - .notes() - .map_err(gix::Exn::into_error) - .context("could not open Git notes")?; let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); - let mut ref_snapshot = history::snapshot(&view_repository, &revisions, &hide)?; let mut watcher_retry_deadline = None; let mut ref_watcher = match start_ref_watcher(&repository_path, &common_dir) { Ok(watcher) => Some(watcher), @@ -718,7 +758,7 @@ fn event_loop( line_diff_parallelism, )?; if worktree_watcher_needed(repository_is_bare, app.changes_mode) { - match start_worktree_watcher(&view_repository) { + match start_worktree_watcher(&repository_path, repository_is_bare) { Ok(watcher) => worktree_watcher = Some(watcher), Err(err) => { tracing::warn!(error = %err, "worktree watcher startup failed"); @@ -735,7 +775,6 @@ fn event_loop( &mailmap, &authors, &mut fill_repository, - &mut notes, &mut commit_message, &mut tree_changes, &mut worktree_changes, @@ -849,7 +888,7 @@ fn event_loop( } } if worktree_watcher_needed(repository_is_bare, app.changes_mode) && worktree_watcher.is_none() { - match start_worktree_watcher(&view_repository) { + match start_worktree_watcher(&repository_path, repository_is_bare) { Ok(watcher) => { tracing::info!("worktree watcher recovered"); worktree_watcher = Some(watcher); @@ -963,11 +1002,6 @@ fn event_loop( repository_path.clone_from(&common_dir); repository_is_bare = true; mailmap = recovered.open_mailmap(); - notes = recovered - .notes() - .map_err(gix::Exn::into_error) - .context("could not reopen Git notes after worktree removal")?; - view_repository = recovered; fill_repository.path.clone_from(&repository_path); fill_repository.bare = true; fill_repository.retain = false; @@ -995,8 +1029,7 @@ fn event_loop( }; app.manual_refresh = ref_watcher.is_none(); app.notice = Some("worktree removed; using the common repository without worktree changes".into()); - open_repository(&repository_path, true, true) - .context("could not inspect common repository references")? + recovered } Err(err) => return Err(err).context("could not inspect changed references"), }; @@ -1051,7 +1084,6 @@ fn event_loop( &mailmap, &authors, &mut fill_repository, - &mut notes, &mut commit_message, &mut tree_changes, &mut worktree_changes, @@ -1129,7 +1161,6 @@ fn event_loop( &mailmap, &authors, &mut fill_repository, - &mut notes, &mut commit_message, &mut tree_changes, &mut worktree_changes, @@ -1238,7 +1269,7 @@ fn event_loop( )?; if app.changes_mode == Some(ChangesMode::Both) { invalidate_worktree_changes(&mut worktree_changes); - match start_worktree_watcher(&view_repository) { + match start_worktree_watcher(&repository_path, repository_is_bare) { Ok(watcher) => { worktree_watcher = Some(watcher); if app @@ -1275,7 +1306,6 @@ fn event_loop( } Effect::Reload(show_hidden) => { app.show_hidden = show_hidden; - notes = open_notes(&repository_path, repository_is_bare)?; refresh_pending = true; refresh_expand_hidden = true; } @@ -1347,7 +1377,6 @@ fn event_loop( &mailmap, &authors, &mut fill_repository, - &mut notes, &mut commit_message, &mut tree_changes, &mut worktree_changes, @@ -1491,7 +1520,9 @@ fn start_ref_watcher(git_dir: &Path, common_dir: &Path) -> Result { }) } -fn start_worktree_watcher(repository: &gix::Repository) -> Result { +fn start_worktree_watcher(repository_path: &Path, bare: bool) -> Result { + let repository = open_repository(repository_path, bare, false) + .context("could not open repository for worktree watcher setup")?; let workdir = repository .workdir() .context("cannot watch a bare repository")? @@ -1633,7 +1664,6 @@ fn draw( mailmap: &gix::mailmap::Snapshot, authors: &SharedAuthors, fill_repository: &mut FillRepository, - notes: &mut gix::note::Platform, commit_message: &mut Option<(gix::ObjectId, BString)>, tree_changes: &mut Option<(gix::ObjectId, usize, Changes)>, worktree_changes: &mut Option<(usize, Changes)>, @@ -1652,23 +1682,11 @@ fn draw( app.ensure_visible(); let start = app.offset.min(app.rows.len()); let end = start.saturating_add(render_rows).min(app.rows.len()); - for index in start..end { - let id = app.rows[index].id; - if app.notes_loaded(id) { - continue; - } - let loaded = notes - .get(id) - .map_err(gix::Exn::into_error) - .context("could not load visible commit notes")? - .into_iter() - .map(|note| { - let mut blob = note.blob; - BString::from(blob.take_data()) - }) - .collect(); - app.set_notes(id, loaded); - } + let notes_to_load: Vec<_> = app.rows[start..end] + .iter() + .map(|row| row.id) + .filter(|id| !app.notes_loaded(*id)) + .collect(); let changes_visible = app.changes_visible(); let selected_id = app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id); app.selection_relation = selection_cache @@ -1724,7 +1742,8 @@ fn draw( *tree_changes = None; *worktree_changes = None; } - if app.rows[start..end].iter().any(|row| !row.metadata_loaded) + if !notes_to_load.is_empty() + || app.rows[start..end].iter().any(|row| !row.metadata_loaded) || message_to_load.is_some() || tree_changes_to_load.is_some() || worktree_changes_to_load @@ -1739,6 +1758,25 @@ fn draw( } else { one_shot_repository.insert(open_fill_repository(&fill_repository.path, fill_repository.bare)?) }; + if !notes_to_load.is_empty() { + let mut notes = repository + .notes() + .map_err(gix::Exn::into_error) + .context("could not open Git notes")?; + for id in notes_to_load { + let loaded = notes + .get(id) + .map_err(gix::Exn::into_error) + .context("could not load visible commit notes")? + .into_iter() + .map(|note| { + let mut blob = note.blob; + BString::from(blob.take_data()) + }) + .collect(); + app.set_notes(id, loaded); + } + } for index in start..end { if app.rows[index].metadata_loaded { continue; @@ -1888,16 +1926,6 @@ fn open_fill_repository(repository_path: &Path, bare: bool) -> Result Result { - let mut repository = - open_repository(repository_path, bare, false).context("could not open repository for Git notes")?; - repository.object_cache_size(None); - repository - .notes() - .map_err(gix::Exn::into_error) - .context("could not open Git notes") -} - fn prepare_file_diff(repository_path: &Path, bare: bool, change: &FileChange, path: &PathChange) -> Result { let mut repository = open_repository(repository_path, bare, false).context("could not open repository for file diff")?; From 23ed897285aaf457cc5c601e3d5003626abaaf00 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 05:38:25 +0200 Subject: [PATCH 027/282] Document agent authorship for tix changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require commits created or materially rewritten by an AI agent to use that agent’s own author identity instead of silently inheriting the repository owner’s identity. Preserve existing authorship and permit a different identity only when the user explicitly requests it for the particular commit. This makes commit provenance reviewable while keeping explicit authorship decisions under the user’s control. --- gix-tix/AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gix-tix/AGENTS.md b/gix-tix/AGENTS.md index b3c339703dd..9ba5c27f400 100644 --- a/gix-tix/AGENTS.md +++ b/gix-tix/AGENTS.md @@ -1,5 +1,12 @@ # gix-tix invariants +## Commit authorship + +- A commit created or materially rewritten by an AI agent must use that agent's own name and email as its author. Do not silently inherit the repository owner's configured identity. +- Preserve the author of an existing commit when the agent is not responsible for its contents. +- Use another author's identity only when the user explicitly requests it for that particular commit. +- Keep this provenance in commit metadata so reviewers can distinguish agent-authored changes without relying on commit-message trailers. + ## Repository lifetime - Do not retain a `gix::Repository`, or a platform/object that owns one, in application or event-loop state while tix is idle. From ad7bf64387aed49d4030ed0421fae0fd89f3fc11 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 8 Aug 2026 17:42:43 +0200 Subject: [PATCH 028/282] fix: keep tix ancestry comparisons in its cached graph Replace the main history rev-walk with a traversal that retains detached parent, generation, and commit-time data while streaming visible rows. Reverse-index local branches and, when their visible target is encountered, resolve configured upstreams and schedule both sides of each tracking relationship as internal-only traversal tips. Computing both sides through hidden frontiers ensures ahead/behind painting always sees complete ancestry. Compute ahead/behind and hidden-history counts with a bounded two-color paint over this in-memory graph instead of opening a repository and traversing object history whenever the selection changes. Cache completed relationships and preserve shallow, hidden-boundary, commit-graph, and deferred-metadata behavior. Move the graph through filesystem refresh workers and stop new walks at any complete cached ancestry, including upstream-only history. Give explicit hidden-history expansion its own traversal state so topology-only cached commits become display rows until already-materialized ancestry is reached, without adding commits reachable only from a hidden tip. This lets ref changes append to the existing graph without retraversing known commits, makes show-hidden restore the original view, and still drops every repository and commit-graph handle when its worker finishes. --- gix-tix/src/app.rs | 46 -- gix-tix/src/history.rs | 1061 +++++++++++++++++++++++++++++++++++----- gix-tix/src/lib.rs | 246 ++++------ 3 files changed, 1031 insertions(+), 322 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 7ab678cc110..22f1b2b824f 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -815,34 +815,10 @@ impl App { } } - pub(crate) fn known_ids(&self) -> HashSet { - self.all_rows.keys().copied().collect() - } - pub(crate) fn hidden_ids(&self) -> HashSet { self.hidden_rows.clone() } - pub(crate) fn visible_ancestry_to_hidden(&self, tip: ObjectId) -> Option { - let mut pending = vec![tip]; - let mut seen = HashSet::new(); - let mut visible = 0; - let mut reached_hidden = false; - while let Some(id) = pending.pop() { - if !seen.insert(id) { - continue; - } - if self.hidden_rows.contains(&id) { - reached_hidden = true; - continue; - } - let Some(row) = self.all_rows.get(&id) else { continue }; - visible += 1; - pending.extend(row.parent_ids.iter().copied()); - } - reached_hidden.then_some(visible) - } - pub(crate) fn start_refresh( &mut self, commits: LoadedCommits, @@ -1800,28 +1776,6 @@ mod tests { assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(4))); } - #[test] - fn counts_distinct_visible_ancestry_only_when_it_reaches_hidden_history() { - let mut app = App::new(10); - app.extend_commits(vec![ - row_with_parents(4, &[3, 2]), - row_with_parents(3, &[1]), - row_with_parents(2, &[1]), - ]); - app.extend_hidden_commits(vec![row(1)]); - - assert_eq!(app.visible_ancestry_to_hidden(id(4)), Some(3)); - assert_eq!(app.visible_ancestry_to_hidden(id(3)), Some(1)); - assert_eq!(app.visible_ancestry_to_hidden(id(1)), Some(0)); - - app.hidden_rows.clear(); - assert_eq!( - app.visible_ancestry_to_hidden(id(4)), - None, - "without hidden history the fallback count has no useful base" - ); - } - #[test] fn lane_computation_keeps_provisional_rows_interactive() { let mut app = App::new(2); diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index e33550a5793..c8d888a9c5c 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -1,4 +1,5 @@ use std::{ + cmp::Ordering as CmpOrdering, collections::{HashMap, HashSet}, ffi::OsString, sync::atomic::{AtomicBool, Ordering}, @@ -37,6 +38,448 @@ pub(crate) enum DecorationKind { pub(crate) type Decorations = HashMap>; +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SelectionRef { + pub name: BString, + pub upstream: Option>, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Node { + complete: bool, + stored: bool, + flags: u8, + expanded: u8, + emitted: bool, +} + +#[derive(Debug, Default)] +pub(crate) struct HistoryGraph { + commits: gix::revwalk::graph::IdMap>, + tracking: HashMap>, + relations: HashMap<(ObjectId, ObjectId), (usize, usize)>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct GenThenTime { + generation: gix::revwalk::graph::Generation, + time: gix::date::SecondsSinceUnixEpoch, +} + +impl From<&gix::revwalk::graph::Commit> for GenThenTime { + fn from(commit: &gix::revwalk::graph::Commit) -> Self { + GenThenTime { + generation: commit + .generation + .unwrap_or(gix::commitgraph::GENERATION_NUMBER_INFINITY), + time: commit.commit_time, + } + } +} + +impl Ord for GenThenTime { + fn cmp(&self, other: &Self) -> CmpOrdering { + self.generation.cmp(&other.generation).then(self.time.cmp(&other.time)) + } +} + +impl PartialOrd for GenThenTime { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl HistoryGraph { + fn ensure_commit( + &mut self, + repo: &gix::Repository, + cache: Option<&gix::commitgraph::Graph>, + shallow: &HashSet, + id: ObjectId, + buf: &mut Vec, + ) -> Result<()> { + if self.commits.contains_key(&id) { + return Ok(()); + } + let commit = gix::traverse::commit::find(cache, &repo.objects, &id, buf) + .context("could not load commit for cached history traversal")?; + let (mut parents, commit_time, generation) = match commit { + gix::traverse::commit::Either::CommitRefIter(iter) => { + let mut parents = gix::traverse::commit::ParentIds::new(); + let mut commit_time = 0; + for token in iter { + match token.context("could not decode cached history commit")? { + Token::Tree { .. } => {} + Token::Parent { id } => parents.push(id), + Token::Committer { signature } => { + commit_time = signature.seconds(); + break; + } + _ => {} + } + } + (parents, commit_time, None) + } + gix::traverse::commit::Either::CachedCommit(commit) => { + let cache = cache.expect("cached commits originate from the provided commit-graph"); + let mut parents = gix::traverse::commit::ParentIds::new(); + for parent in commit.iter_parents() { + let parent = + parent.map_err(|err| anyhow::anyhow!("could not decode commit-graph parent: {err}"))?; + parents.push(cache.id_at(parent).to_owned()); + } + ( + parents, + commit.committer_timestamp() as gix::date::SecondsSinceUnixEpoch, + Some(commit.generation()), + ) + } + }; + if shallow.contains(&id) { + parents.clear(); + } + self.commits.insert( + id, + gix::revwalk::graph::Commit { + parents, + commit_time, + generation, + data: Node::default(), + }, + ); + Ok(()) + } + + #[expect(clippy::too_many_arguments)] + fn schedule_cached( + &mut self, + repo: &gix::Repository, + cache: Option<&gix::commitgraph::Graph>, + shallow: &HashSet, + states: &mut HashMap, + queue: &mut gix::revwalk::PriorityQueue, + buf: &mut Vec, + id: ObjectId, + flags: u8, + ) -> Result<()> { + self.ensure_commit(repo, cache, shallow, id, buf)?; + let state = states.entry(id).or_default(); + if state.flags & flags != flags { + state.flags |= flags; + queue.insert(self.commits[&id].commit_time, id); + } + Ok(()) + } + + pub(crate) fn selection_refs(&self, id: ObjectId, decorations: &Decorations) -> Vec { + let tracked = self.tracking.get(&id); + let mut refs: Vec<_> = decorations + .get(&id) + .into_iter() + .flatten() + .map(|decoration| { + let upstream = if decoration.kind == DecorationKind::Local { + tracked + .into_iter() + .flatten() + .find(|reference| reference.name == decoration.name) + .and_then(|reference| reference.upstream) + } else { + None + }; + SelectionRef { + name: decoration.name.clone(), + upstream, + } + }) + .collect(); + refs.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.upstream.cmp(&b.upstream))); + refs + } + + pub(crate) fn selection_relation( + &mut self, + id: ObjectId, + refs: &[SelectionRef], + hidden: &[ObjectId], + ) -> Option { + let has_upstream = refs.iter().any(|reference| reference.upstream.is_some()); + for upstream in refs.iter().filter_map(|reference| reference.upstream.flatten()) { + let relation = if let Some(relation) = self.relations.get(&(id, upstream)).copied() { + Some(relation) + } else { + let relation = self.paint(id, std::slice::from_ref(&upstream))?; + self.relations.insert((id, upstream), relation); + Some(relation) + }; + if let Some((ahead, behind)) = relation { + return Some(crate::app::SelectionRelation::Tracking { ahead, behind }); + } + } + if has_upstream || refs.is_empty() || hidden.is_empty() { + return None; + } + self.paint(id, hidden) + .map(|(visible, _)| crate::app::SelectionRelation::Visible(visible)) + } + + fn paint(&self, first: ObjectId, others: &[ObjectId]) -> Option<(usize, usize)> { + if !self.commits.contains_key(&first) || others.iter().any(|id| !self.commits.contains_key(id)) { + return None; + } + let mut flags = HashMap::::new(); + let mut queue = gix::revwalk::PriorityQueue::::new(); + let mut queued = HashSet::new(); + let mut pending = 0usize; + for (id, flag) in std::iter::once((first, VISIBLE)).chain(others.iter().copied().map(|id| (id, HIDDEN))) { + *flags.entry(id).or_default() |= flag; + if queued.insert(id) { + queue.insert(GenThenTime::from(&self.commits[&id]), id); + pending += 1; + } + } + while pending != 0 { + let Some((_priority, id)) = queue.pop() else { break }; + queued.remove(&id); + let mut propagated = flags[&id]; + if propagated & STALE == 0 { + pending -= 1; + } + if propagated & (VISIBLE | HIDDEN) == VISIBLE | HIDDEN { + propagated |= STALE; + *flags.get_mut(&id).expect("queued commits have flags") = propagated; + } + for &parent in &self.commits[&id].parents { + let Some(commit) = self.commits.get(&parent) else { + continue; + }; + let parent_flags = flags.entry(parent).or_default(); + let previous = *parent_flags; + if previous & propagated != propagated { + *parent_flags = previous | propagated; + if queued.contains(&parent) { + if previous & STALE == 0 && *parent_flags & STALE != 0 { + pending -= 1; + } + } else { + queued.insert(parent); + if *parent_flags & STALE == 0 { + pending += 1; + } + queue.insert(GenThenTime::from(commit), parent); + } + } + } + } + let mut ahead = 0; + let mut behind = 0; + for flags in flags.into_values() { + match flags & (VISIBLE | HIDDEN) { + VISIBLE => ahead += 1, + HIDDEN => behind += 1, + _ => {} + } + } + Some((ahead, behind)) + } + + pub(crate) fn refresh( + &mut self, + repo: &gix::Repository, + revisions: &[OsString], + hidden_revisions: &[OsString], + expand: &HashSet, + authors: &SharedAuthors, + ) -> Result { + let refs = snapshot(repo, revisions, hidden_revisions)?; + let shallow: HashSet<_> = repo + .shallow_commits() + .context("could not read shallow commits")? + .into_iter() + .flat_map(|commits| commits.iter().copied().collect::>()) + .collect(); + let cache = repo + .commit_graph_if_enabled() + .context("could not open commit-graph for history refresh")?; + let local_refs = local_refs_by_target(repo)?; + let mut tracking = HashMap::new(); + let mut states = HashMap::::new(); + let mut queue = gix::revwalk::PriorityQueue::new(); + let mut buf = Vec::new(); + for id in refs.view_tips.iter().chain(&refs.hidden_tips).copied() { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + id, + VISIBLE, + )?; + } + for &id in expand { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + id, + EXPAND, + )?; + } + for (&id, names) in &local_refs { + if self.commits.get(&id).is_none_or(|commit| !commit.data.stored) { + continue; + } + let tracked = resolve_tracking(repo, names)?; + if tracked.iter().any(|reference| reference.upstream.flatten().is_some()) { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + id, + INTERNAL, + )?; + } + for upstream in tracked.iter().filter_map(|reference| reference.upstream.flatten()) { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + upstream, + INTERNAL, + )?; + } + tracking.insert(id, tracked); + } + + let mut rows = Vec::new(); + let mut attributions = Vec::new(); + while let Some((_time, id)) = queue.pop() { + let Some(state) = states.get_mut(&id) else { continue }; + let delta = state.flags & !state.expanded; + if delta == 0 { + continue; + } + state.expanded |= delta; + let commit = &self.commits[&id]; + let was_stored = commit.data.stored; + let stop = commit.data.complete && (delta & EXPAND == 0 || was_stored && !expand.contains(&id)); + let should_store = delta & (VISIBLE | EXPAND) != 0 && !was_stored; + let parent_ids = commit.parents.clone(); + let generation = commit.generation; + if should_store { + if let Some(names) = local_refs.get(&id) { + let tracked = resolve_tracking(repo, names)?; + if tracked.iter().any(|reference| reference.upstream.flatten().is_some()) { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + id, + INTERNAL, + )?; + } + for upstream in tracked.iter().filter_map(|reference| reference.upstream.flatten()) { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + upstream, + INTERNAL, + )?; + } + tracking.insert(id, tracked); + } + let metadata = if generation.is_some() { + None + } else { + let object = repo.find_commit(id).context("could not read refreshed commit")?; + let mut authors = gix::features::threading::lock(authors); + Some(decode_metadata(object.iter(), &mut authors, &mut attributions)?) + }; + let metadata_loaded = metadata.is_some(); + let Metadata { + committer_time, + author, + attributions: row_attributions, + title, + has_agent_marker, + signature, + } = metadata.unwrap_or_else(|| Metadata { + committer_time: Default::default(), + author: &EMPTY_AUTHOR, + attributions: 0..0, + title: BString::default(), + has_agent_marker: false, + signature: SignatureState::Unsigned, + }); + rows.push(Commit { + id, + parent_ids: parent_ids.clone(), + committer_time, + author, + attributions: row_attributions, + title, + metadata_loaded, + has_agent_marker, + signature, + }); + self.commits + .get_mut(&id) + .expect("loaded commit remains present") + .data + .stored = true; + } + if stop { + continue; + } + for parent in parent_ids { + self.schedule_cached( + repo, + cache.as_ref(), + &shallow, + &mut states, + &mut queue, + &mut buf, + parent, + delta & (VISIBLE | INTERNAL | EXPAND), + )?; + } + } + for (id, state) in states { + if state.expanded & (VISIBLE | INTERNAL | EXPAND) != 0 { + self.commits + .get_mut(&id) + .expect("walked commits remain cached") + .data + .complete = true; + } + } + self.tracking = tracking; + Ok(Refresh { + refs, + decorations: decorations(repo)?, + commits: LoadedCommits { rows, attributions }, + }) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct RefSnapshot { pub view: HashMap, @@ -57,13 +500,165 @@ pub(crate) struct Authors { authors: HashMap<(&'static BStr, &'static BStr), &'static Author>, } const COMMIT_BATCH_SIZE: usize = 1024; +const VISIBLE: u8 = 1 << 0; +const INTERNAL: u8 = 1 << 1; +const HIDDEN: u8 = 1 << 2; +const STALE: u8 = 1 << 3; +const EXPAND: u8 = 1 << 4; + +#[derive(Default)] +struct WalkState { + flags: u8, + expanded: u8, +} + +fn schedule( + graph: &mut gix::revwalk::Graph<'_, '_, gix::revwalk::graph::Commit>, + queue: &mut gix::revwalk::PriorityQueue, + shallow: &HashSet, + id: ObjectId, + flags: u8, +) -> Result<()> { + let Some(commit) = graph + .get_or_insert_full_commit(id, |commit| { + if shallow.contains(&id) { + commit.parents.clear(); + } + }) + .context("could not load commit for history traversal")? + else { + return Ok(()); + }; + if commit.data.flags & flags != flags { + commit.data.flags |= flags; + queue.insert(commit.commit_time, id); + } + Ok(()) +} + +fn hidden_frontier( + graph: &mut gix::revwalk::Graph<'_, '_, gix::revwalk::graph::Commit>, + visible_tips: &[ObjectId], + hidden_tips: &[ObjectId], + shallow: &HashSet, +) -> Result> { + if hidden_tips.is_empty() { + return Ok(HashSet::new()); + } + let mut flags = HashMap::::new(); + let mut queue = gix::revwalk::PriorityQueue::::new(); + for (tips, flag) in [(visible_tips, VISIBLE), (hidden_tips, HIDDEN)] { + for &id in tips { + let Some(commit) = graph + .get_or_insert_full_commit(id, |commit| { + if shallow.contains(&id) { + commit.parents.clear(); + } + }) + .context("could not load commit while preparing hidden history")? + else { + continue; + }; + *flags.entry(id).or_default() |= flag; + queue.insert(GenThenTime::from(&*commit), id); + } + } + while queue + .iter_unordered() + .any(|id| flags.get(id).is_some_and(|flags| flags & STALE == 0)) + { + let Some((_priority, id)) = queue.pop() else { break }; + let mut propagated = flags[&id]; + if propagated & (VISIBLE | HIDDEN) == VISIBLE | HIDDEN { + propagated |= STALE; + *flags.get_mut(&id).expect("queued commits have flags") = propagated; + } + let parents = graph.get(&id).expect("queued commits are loaded").parents.clone(); + for parent in parents { + let Some(commit) = graph + .get_or_insert_full_commit(parent, |commit| { + if shallow.contains(&parent) { + commit.parents.clear(); + } + }) + .context("could not load hidden commit parent")? + else { + continue; + }; + let parent_flags = flags.entry(parent).or_default(); + if *parent_flags & propagated != propagated { + *parent_flags |= propagated; + queue.insert(GenThenTime::from(&*commit), parent); + } + } + } + Ok(flags + .into_iter() + .filter_map(|(id, flags)| (flags & (VISIBLE | HIDDEN) == VISIBLE | HIDDEN).then_some(id)) + .collect()) +} + +fn local_refs_by_target(repo: &gix::Repository) -> Result>> { + let mut out = HashMap::>::new(); + let platform = repo.references().context("could not open references")?; + let refs = platform + .local_branches() + .context("could not iterate local branches")? + .peeled() + .context("could not prepare local branches for peeling")?; + for reference in refs { + let reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => return Err(anyhow::anyhow!("could not read local branch: {err}")), + }; + out.entry(reference.id().detach()) + .or_default() + .push(reference.name().as_bstr().to_owned()); + } + Ok(out) +} + +fn resolve_tracking(repo: &gix::Repository, names: &[BString]) -> Result> { + let mut out = Vec::with_capacity(names.len()); + for full_name in names { + let Some(reference) = repo + .try_find_reference(full_name.as_bstr()) + .with_context(|| format!("could not read local branch {full_name}"))? + else { + continue; + }; + let upstream = reference + .remote_tracking_ref_name(gix::remote::Direction::Fetch) + .map(|name| { + let name = name.context("could not resolve remote-tracking branch name")?; + Ok::<_, anyhow::Error>( + repo.try_find_reference(name.as_bstr()) + .with_context(|| format!("could not read remote-tracking branch {name}"))? + .and_then(|mut reference| reference.peel_to_id().ok().map(gix::Id::detach)), + ) + }) + .transpose()?; + out.push(SelectionRef { + name: full_name + .strip_prefix(b"refs/heads/") + .unwrap_or(full_name.as_slice()) + .into(), + upstream, + }); + } + out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.upstream.cmp(&b.upstream))); + out.dedup(); + Ok(out) +} #[derive(Debug)] pub(crate) enum Event { Decorations(Decorations), Commits(LoadedCommits), HiddenCommits(LoadedCommits), - Complete, + VisibleComplete, + Complete(HistoryGraph), Cancelled, } @@ -77,7 +672,8 @@ pub(crate) fn load( ) -> Result<()> { let Some(tips) = resolve_tips(repo, revisions)? else { emit(Event::Decorations(decorations(repo)?)); - emit(Event::Complete); + emit(Event::VisibleComplete); + emit(Event::Complete(HistoryGraph::default())); return Ok(()); }; let hidden_tips = resolve_revisions(repo, hidden_revisions, "hidden ")?; @@ -85,78 +681,122 @@ pub(crate) fn load( if !emit(Event::Decorations(decorations(repo)?)) { return Ok(()); } - let walk = repo - .rev_walk(tips) - .with_hidden(hidden_tips) - .sorting(gix::revision::walk::Sorting::ByCommitTime(Default::default())) - .all() - .context("could not start revision walk")?; + let shallow: HashSet<_> = repo + .shallow_commits() + .context("could not read shallow commits")? + .into_iter() + .flat_map(|commits| commits.iter().copied().collect::>()) + .collect(); + let commit_graph = repo + .commit_graph_if_enabled() + .context("could not open commit-graph for history traversal")?; + let mut graph = repo.revision_graph::>(commit_graph.as_ref()); + let hidden = hidden_frontier(&mut graph, &tips, &hidden_tips, &shallow)?; + let local_refs = local_refs_by_target(repo)?; + let mut tracking = HashMap::new(); + let mut queue = gix::revwalk::PriorityQueue::new(); + for &tip in &tips { + schedule(&mut graph, &mut queue, &shallow, tip, VISIBLE)?; + } let mut rows = Vec::with_capacity(COMMIT_BATCH_SIZE); let mut attributions = Vec::with_capacity(COMMIT_BATCH_SIZE); - let mut visible = HashSet::new(); let mut connected = Vec::new(); - let mut seen_parents = HashSet::new(); - for info in walk { + let mut connected_seen = HashSet::new(); + while let Some((_time, id)) = queue.pop() { if cancelled.load(Ordering::Relaxed) { emit(Event::Cancelled); return Ok(()); } - let info = info.context("could not traverse revision history")?; - let metadata = if info.generation.is_some() { + let commit = graph.get_mut(&id).expect("queued commits are loaded"); + let delta = commit.data.flags & !commit.data.expanded; + if delta == 0 { + continue; + } + commit.data.expanded |= delta; + commit.data.complete = true; + let should_emit = delta & VISIBLE != 0 && !commit.data.emitted && !hidden.contains(&id); + commit.data.emitted |= should_emit; + commit.data.stored |= should_emit; + let parent_ids = commit.parents.clone(); + let generation = commit.generation; + if should_emit && let Some(names) = local_refs.get(&id) { + let refs = resolve_tracking(repo, names)?; + if refs.iter().any(|reference| reference.upstream.flatten().is_some()) { + schedule(&mut graph, &mut queue, &shallow, id, INTERNAL)?; + } + for upstream in refs.iter().filter_map(|reference| reference.upstream.flatten()) { + schedule(&mut graph, &mut queue, &shallow, upstream, INTERNAL)?; + } + tracking.insert(id, refs); + } + let metadata = if !should_emit || generation.is_some() { None } else { - let object = info.object().context("could not read commit")?; + let object = repo.find_commit(id).context("could not read commit")?; let mut authors = gix::features::threading::lock(authors); Some(decode_metadata(object.iter(), &mut authors, &mut attributions)?) }; - let metadata_loaded = metadata.is_some(); - let Metadata { - committer_time, - author, - attributions: row_attributions, - title, - has_agent_marker, - signature, - } = metadata.unwrap_or_else(|| Metadata { - committer_time: Default::default(), - author: &EMPTY_AUTHOR, - attributions: 0..0, - title: BString::default(), - has_agent_marker: false, - signature: SignatureState::Unsigned, - }); - visible.insert(info.id); - connected.extend( - info.parent_ids - .iter() - .copied() - .filter(|parent| seen_parents.insert(*parent)), - ); - rows.push(Commit { - id: info.id, - parent_ids: info.parent_ids, - committer_time, - author, - attributions: row_attributions, - title, - metadata_loaded, - has_agent_marker, - signature, - }); - if rows.len() == COMMIT_BATCH_SIZE - && !emit(Event::Commits(LoadedCommits { - rows: std::mem::replace(&mut rows, Vec::with_capacity(COMMIT_BATCH_SIZE)), - attributions: std::mem::replace(&mut attributions, Vec::with_capacity(COMMIT_BATCH_SIZE)), - })) - { - return Ok(()); + if should_emit { + let metadata_loaded = metadata.is_some(); + let Metadata { + committer_time, + author, + attributions: row_attributions, + title, + has_agent_marker, + signature, + } = metadata.unwrap_or_else(|| Metadata { + committer_time: Default::default(), + author: &EMPTY_AUTHOR, + attributions: 0..0, + title: BString::default(), + has_agent_marker: false, + signature: SignatureState::Unsigned, + }); + if !hidden_revisions.is_empty() { + connected.extend(parent_ids.iter().copied().filter(|id| connected_seen.insert(*id))); + } + rows.push(Commit { + id, + parent_ids: parent_ids.clone(), + committer_time, + author, + attributions: row_attributions, + title, + metadata_loaded, + has_agent_marker, + signature, + }); + if rows.len() == COMMIT_BATCH_SIZE + && !emit(Event::Commits(LoadedCommits { + rows: std::mem::replace(&mut rows, Vec::with_capacity(COMMIT_BATCH_SIZE)), + attributions: std::mem::replace(&mut attributions, Vec::with_capacity(COMMIT_BATCH_SIZE)), + })) + { + return Ok(()); + } + } + let propagated = if hidden.contains(&id) { + delta & INTERNAL + } else { + delta & (VISIBLE | INTERNAL) + }; + for parent in parent_ids { + let parent_flags = if hidden.contains(&parent) { + propagated & !VISIBLE + } else { + propagated + }; + if parent_flags != 0 { + schedule(&mut graph, &mut queue, &shallow, parent, parent_flags)?; + } } } if !rows.is_empty() && !emit(Event::Commits(LoadedCommits { rows, attributions })) { return Ok(()); } if !hidden_revisions.is_empty() { - connected.retain(|id| !visible.contains(id)); + connected.retain(|id| graph.get(id).is_none_or(|commit| !commit.data.emitted)); let mut rows = Vec::with_capacity(connected.len()); let mut attributions = Vec::new(); let mut authors = gix::features::threading::lock(authors); @@ -186,12 +826,27 @@ pub(crate) fn load( has_agent_marker, signature, }); + if let Some(commit) = graph + .get_or_insert_full_commit(id, |commit| { + if shallow.contains(&id) { + commit.parents.clear(); + } + }) + .context("could not retain connected hidden commit")? + { + commit.data.stored = true; + } } if !rows.is_empty() && !emit(Event::HiddenCommits(LoadedCommits { rows, attributions })) { return Ok(()); } } - emit(Event::Complete); + emit(Event::VisibleComplete); + emit(Event::Complete(HistoryGraph { + commits: graph.detach(), + tracking, + relations: HashMap::new(), + })); Ok(()) } @@ -204,74 +859,6 @@ pub(crate) fn snapshot(repo: &gix::Repository, revisions: &[OsString], hidden: & }) } -pub(crate) fn refresh( - repo: &gix::Repository, - revisions: &[OsString], - hidden_revisions: &[OsString], - known: &HashSet, - expand: &HashSet, - authors: &SharedAuthors, -) -> Result { - let refs = snapshot(repo, revisions, hidden_revisions)?; - let mut tips = refs.view_tips.clone(); - tips.extend(refs.hidden_tips.iter().copied()); - tips.extend(expand.iter().copied()); - let mut rows = Vec::new(); - let mut attributions = Vec::new(); - if !tips.is_empty() { - let walk = repo - .rev_walk(tips) - .sorting(gix::revision::walk::Sorting::ByCommitTime(Default::default())) - .selected(|id| !known.contains(id) || expand.contains(id)) - .context("could not start incremental revision walk")?; - for info in walk { - let info = info.context("could not refresh revision history")?; - if known.contains(&info.id) { - continue; - } - let metadata = if info.generation.is_some() { - None - } else { - let object = info.object().context("could not read commit")?; - let mut authors = gix::features::threading::lock(authors); - Some(decode_metadata(object.iter(), &mut authors, &mut attributions)?) - }; - let metadata_loaded = metadata.is_some(); - let Metadata { - committer_time, - author, - attributions: row_attributions, - title, - has_agent_marker, - signature, - } = metadata.unwrap_or_else(|| Metadata { - committer_time: Default::default(), - author: &EMPTY_AUTHOR, - attributions: 0..0, - title: BString::default(), - has_agent_marker: false, - signature: SignatureState::Unsigned, - }); - rows.push(Commit { - id: info.id, - parent_ids: info.parent_ids, - committer_time, - author, - attributions: row_attributions, - title, - metadata_loaded, - has_agent_marker, - signature, - }); - } - } - Ok(Refresh { - refs, - decorations: decorations(repo)?, - commits: LoadedCommits { rows, attributions }, - }) -} - fn referenced_refs(repo: &gix::Repository, revisions: &[OsString]) -> Result> { let implicit_head = OsString::from("HEAD"); let revisions = if revisions.is_empty() { @@ -572,6 +1159,12 @@ mod tests { gix_testtools::scripted_fixture_read_only_needs_archive("history.sh") } + fn id(n: u8) -> ObjectId { + let mut bytes = [0; 20]; + bytes[19] = n; + ObjectId::Sha1(bytes) + } + fn loaded(path: &std::path::Path, revisions: &[&str], hidden_revisions: &[&str]) -> Result> { let mut events = Vec::new(); let authors = @@ -607,6 +1200,36 @@ mod tests { ); } + #[test] + fn paints_criss_cross_relations_from_cached_parents() { + let mut graph = HistoryGraph::default(); + for (n, parents, generation) in [ + (1, vec![], 1), + (2, vec![1], 2), + (3, vec![1], 2), + (4, vec![2, 3], 3), + (5, vec![3, 2], 3), + (6, vec![4], 4), + (7, vec![5], 4), + ] { + graph.commits.insert( + id(n), + gix::revwalk::graph::Commit { + parents: parents.into_iter().map(id).collect(), + commit_time: generation.into(), + generation: Some(generation), + data: Node::default(), + }, + ); + } + + assert_eq!( + graph.paint(id(6), &[id(7)]), + Some((2, 2)), + "both merge tips stop at the shared criss-cross ancestry" + ); + } + #[test] fn walks_the_same_reachable_set_as_git_for_multiple_tips() -> gix_testtools::Result { let fixture = fixture()?; @@ -629,7 +1252,7 @@ mod tests { ); let expected = String::from_utf8(output.stdout)?.lines().map(str::to_owned).collect(); assert_eq!(actual, expected, "all commits reachable from either tip are shown once"); - assert!(matches!(events.last(), Some(Event::Complete)), "the walk completes"); + assert!(matches!(events.last(), Some(Event::Complete(_))), "the walk completes"); let (topic, attributions) = events .iter() .filter_map(|event| match event { @@ -755,6 +1378,190 @@ mod tests { Ok(()) } + #[test] + fn refresh_stops_at_the_persistent_graph() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let events = loaded(fixture.path(), &["main"], &[])?; + let mut graph = events + .into_iter() + .find_map(|event| match event { + Event::Complete(graph) => Some(graph), + _ => None, + }) + .expect("history loading returns the persistent graph"); + + std::fs::write(fixture.path().join("new"), "new\n")?; + for args in [ + &["add", "new"][..], + &["-c", "commit.gpgSign=false", "commit", "-q", "-m", "new"], + ] { + let status = Command::new("git").current_dir(fixture.path()).args(args).status()?; + assert!(status.success(), "git prepares one new commit"); + } + let repo = crate::open_test_repository(fixture.path())?; + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let first = graph.refresh(&repo, &["main".into()], &[], &HashSet::new(), &authors)?; + assert_eq!(first.commits.rows.len(), 1, "only the new descendant is loaded"); + let second = graph.refresh(&repo, &["main".into()], &[], &HashSet::new(), &authors)?; + assert!( + second.commits.rows.is_empty(), + "an unchanged tip stops immediately at complete cached ancestry" + ); + Ok(()) + } + + #[test] + fn refresh_stops_at_cached_tracking_ancestry() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let main = crate::open_test_repository(fixture.path())? + .rev_parse_single("main")? + .detach(); + for args in [ + &["config", "remote.origin.url", "https://example.com/repo"][..], + &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"][..], + &["config", "branch.topic.remote", "origin"][..], + &["config", "branch.topic.merge", "refs/heads/main"][..], + &["update-ref", "refs/remotes/origin/main", &main.to_hex().to_string()][..], + ] { + let status = Command::new("git").current_dir(fixture.path()).args(args).status()?; + assert!(status.success(), "git configures a tracking branch"); + } + let events = loaded(fixture.path(), &["topic"], &[])?; + let mut graph = events + .into_iter() + .find_map(|event| match event { + Event::Complete(graph) => Some(graph), + _ => None, + }) + .expect("history loading returns the persistent graph"); + let repo = gix::open(fixture.path())?; + let cached = graph.commits.get_mut(&main).expect("the tracking tip was scheduled"); + assert!(cached.data.complete && !cached.data.stored); + cached.parents.push(id(255)); + + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let refresh = graph.refresh(&repo, &["topic".into()], &[], &HashSet::new(), &authors)?; + assert!( + refresh.commits.rows.is_empty(), + "an unchanged tracking tip stops before revisiting its cached parents" + ); + Ok(()) + } + + #[test] + fn hidden_history_keeps_tracking_relations_complete_and_can_be_expanded() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let path = fixture.path(); + let git = |args: &[&str]| -> gix_testtools::Result { + let output = Command::new("git").current_dir(path).args(args).output()?; + assert!( + output.status.success(), + "git {args:?} prepares the hidden tracking fixture: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) + }; + let commit = |name: &str| -> gix_testtools::Result { + std::fs::write(path.join(name), format!("{name}\n"))?; + git(&["add", name])?; + git(&["commit", "-q", "-m", name]) + }; + + git(&["config", "commit.gpgsign", "false"])?; + git(&["switch", "-q", "-c", "relation-base", "main"])?; + commit("base-0")?; + let base = crate::open_test_repository(path)?.rev_parse_single("HEAD")?.detach(); + for name in ["base-1", "base-2", "base-3"] { + commit(name)?; + } + git(&["switch", "-q", "-c", "hidden"])?; + commit("hidden-only")?; + let hidden_only = crate::open_test_repository(path)?.rev_parse_single("HEAD")?.detach(); + git(&["switch", "-q", "-c", "local", "relation-base"])?; + commit("local-only")?; + let local = crate::open_test_repository(path)?.rev_parse_single("HEAD")?.detach(); + git(&["switch", "-q", "--detach", &base.to_hex().to_string()])?; + commit("upstream-only")?; + let upstream = crate::open_test_repository(path)?.rev_parse_single("HEAD")?.detach(); + git(&["switch", "-q", "local"])?; + for args in [ + &["config", "remote.origin.url", "https://example.com/repo"][..], + &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"][..], + &["config", "branch.local.remote", "origin"][..], + &["config", "branch.local.merge", "refs/heads/local"][..], + &[ + "update-ref", + "refs/remotes/origin/local", + &upstream.to_hex().to_string(), + ][..], + ] { + git(args)?; + } + + let mut decorations = Decorations::new(); + let mut visible = HashSet::new(); + let mut boundary = HashSet::new(); + let mut graph = None; + for event in loaded(path, &["local"], &["hidden"])? { + match event { + Event::Decorations(value) => decorations = value, + Event::Commits(batch) => visible.extend(batch.rows.into_iter().map(|row| row.id)), + Event::HiddenCommits(batch) => { + boundary.extend(batch.rows.into_iter().map(|row| row.id)); + visible.extend(boundary.iter().copied()); + } + Event::Complete(value) => graph = Some(value), + Event::VisibleComplete | Event::Cancelled => {} + } + } + let mut graph = graph.expect("history loading returns the persistent graph"); + let refs = graph.selection_refs(local, &decorations); + let counts = Command::new("git") + .current_dir(path) + .args([ + "rev-list", + "--left-right", + "--count", + "local...refs/remotes/origin/local", + ]) + .output()?; + assert!(counts.status.success(), "git computes the expected tracking relation"); + let expected: Vec<_> = String::from_utf8(counts.stdout)? + .split_whitespace() + .map(str::parse::) + .collect::>()?; + assert_eq!( + graph.selection_relation(local, &refs, &[]), + Some(crate::app::SelectionRelation::Tracking { + ahead: expected[0], + behind: expected[1], + }), + "hidden tips do not truncate either side of the tracking relation" + ); + + let repo = crate::open_test_repository(path)?; + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let refresh = graph.refresh(&repo, &["local".into()], &[], &boundary, &authors)?; + visible.extend(refresh.commits.rows.into_iter().map(|row| row.id)); + let expected: HashSet<_> = repo + .rev_walk([local]) + .all()? + .map(|info| info.map(|info| info.id)) + .collect::>()?; + assert_eq!( + visible, expected, + "showing hidden materializes the original view ancestry" + ); + assert!( + !visible.contains(&hidden_only), + "showing hidden does not add commits reachable only from a hidden tip" + ); + Ok(()) + } + #[test] fn hides_tips_and_every_commit_reachable_from_them() -> gix_testtools::Result { let fixture = fixture()?; @@ -803,7 +1610,7 @@ mod tests { "the screen-size probe uses the same hidden history" ); assert!( - matches!(events.last(), Some(Event::Complete)), + matches!(events.last(), Some(Event::Complete(_))), "the filtered walk completes" ); Ok(()) diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index b9eecec27db..235697ef9d9 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -41,7 +41,7 @@ use gix::{ bstr::{BString, ByteSlice}, prelude::TreeDiffChangeExt, }; -use history::{Authors, DecorationKind, Decorations, Event, SharedAuthors}; +use history::{Authors, Decorations, Event, HistoryGraph, SelectionRef, SharedAuthors}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use ratatui::{TerminalOptions, Viewport, backend::CrosstermBackend, text::Line}; @@ -121,12 +121,6 @@ fn take_due(deadline: &mut Option, now: Instant) -> bool { } } -#[derive(Clone, Debug, Eq, PartialEq)] -struct SelectionRef { - name: BString, - upstream: Option>, -} - #[derive(Clone, Debug, Eq, PartialEq)] struct SelectionRelationCache { id: gix::ObjectId, @@ -727,7 +721,7 @@ fn event_loop( } app.manual_refresh = ref_watcher.is_none(); let mut lane_receiver = None; - let mut refresh_receiver: Option>> = None; + let mut refresh_receiver: Option)>> = None; let mut refresh_pending = false; let mut refresh_from_filesystem = false; let mut refresh_select_top = false; @@ -739,6 +733,7 @@ fn event_loop( let mut worktree_watcher: Option = None; let mut worktree_refresh_deadline: Option = None; let mut selection_relation = None; + let mut history_graph = None; let line_diff_parallelism = std::thread::available_parallelism().map_or(1, Into::into); let mut line_diff_pool = None; let mut fill_repository = FillRepository { @@ -778,6 +773,7 @@ fn event_loop( &mut commit_message, &mut tree_changes, &mut worktree_changes, + &mut history_graph, &mut selection_relation, &mut line_diff_pool, )?; @@ -962,7 +958,8 @@ fn event_loop( } if let Some(result) = refresh_receiver.as_ref().map(mpsc::Receiver::try_recv) { match result { - Ok(result) => { + Ok((graph, result)) => { + history_graph = Some(graph); let result = result?; tracing::info!(commit_count = result.commits.rows.len(), "history refresh completed"); decorations = result.decorations; @@ -991,6 +988,7 @@ fn event_loop( if refresh_pending && refresh_receiver.is_none() && lane_receiver.is_none() + && history_graph.is_some() && matches!(app.state, State::Complete | State::Cancelled) { let repository = match open_repository(&repository_path, repository_is_bare, true) { @@ -1040,41 +1038,27 @@ fn event_loop( let select_top = std::mem::take(&mut refresh_from_filesystem); ref_snapshot = next; refresh_pending = false; - if tips_changed || refresh_expand_hidden { - let hidden = if app.show_hidden { Vec::new() } else { hide.clone() }; - let expand = if refresh_expand_hidden || hidden_changed { - app.hidden_ids() - } else { - Default::default() - }; - refresh_receiver = Some(start_history_refresh( - repository_path.clone(), - repository_is_bare, - revisions.clone(), - hidden, - app.known_ids(), - expand, - gix::features::threading::OwnShared::clone(&authors), - )); - refresh_select_top = select_top; - refresh_expand_hidden = false; - app.state = State::Loading; - tracing::info!(select_top, "started history refresh"); + let hidden = if app.show_hidden { Vec::new() } else { hide.clone() }; + let expand = if refresh_expand_hidden || hidden_changed { + app.hidden_ids() } else { - let next = history::decorations(&repository)?; - let relation_changed = selection_relation - .as_ref() - .is_some_and(|cached: &SelectionRelationCache| { - selection_refs(&repository, cached.id, &next) != cached.refs - }); - if visible_decorations_changed(&decorations, &next, &app.rows) || relation_changed { - selection_relation = None; - app.selection_relation = None; - decorations = next; - dirty = true; - tracing::debug!(relation_changed, "updated history decorations"); - } - } + Default::default() + }; + refresh_receiver = Some(start_history_refresh( + repository_path.clone(), + repository_is_bare, + revisions.clone(), + hidden, + expand, + gix::features::threading::OwnShared::clone(&authors), + history_graph + .take() + .expect("refresh starts only with a cached history graph"), + )); + refresh_select_top = select_top; + refresh_expand_hidden = false; + app.state = State::Loading; + tracing::info!(select_top, "started history refresh"); } if urgent { draw( @@ -1087,6 +1071,7 @@ fn event_loop( &mut commit_message, &mut tree_changes, &mut worktree_changes, + &mut history_graph, &mut selection_relation, &mut line_diff_pool, )?; @@ -1125,8 +1110,7 @@ fn event_loop( history_requires_alternate_screen = true; } } - Event::Complete => { - history_finished = true; + Event::VisibleComplete => { resize_inline = true; history_requires_alternate_screen = history_needs_alternate_screen(screen, terminal::size()?.1, app.rows.len()); @@ -1134,6 +1118,12 @@ fn event_loop( lane_receiver = Some(start_lane_worker(rows)); } } + Event::Complete(graph) => { + history_finished = true; + history_graph = Some(graph); + selection_relation = None; + app.selection_relation = None; + } Event::Cancelled => { history_finished = true; drop(app.update(Action::Cancelled)); @@ -1164,6 +1154,7 @@ fn event_loop( &mut commit_message, &mut tree_changes, &mut worktree_changes, + &mut history_graph, &mut selection_relation, &mut line_diff_pool, )?; @@ -1380,6 +1371,7 @@ fn event_loop( &mut commit_message, &mut tree_changes, &mut worktree_changes, + &mut history_graph, &mut selection_relation, &mut line_diff_pool, )?; @@ -1476,19 +1468,19 @@ fn start_history_refresh( bare: bool, revisions: Vec, hidden_revisions: Vec, - known: std::collections::HashSet, expand: std::collections::HashSet, authors: SharedAuthors, -) -> mpsc::Receiver> { + mut graph: HistoryGraph, +) -> mpsc::Receiver<(HistoryGraph, Result)> { let (sender, receiver) = mpsc::channel(); std::thread::spawn(move || { let result = open_repository(&repository_path, bare, true) .context("could not reopen repository for history refresh") .and_then(|mut repository| { repository.object_cache_size_if_unset(OBJECT_CACHE_SIZE); - history::refresh(&repository, &revisions, &hidden_revisions, &known, &expand, &authors) + graph.refresh(&repository, &revisions, &hidden_revisions, &expand, &authors) }); - let _ = sender.send(result); + let _ = sender.send((graph, result)); }); receiver } @@ -1566,77 +1558,6 @@ fn invalidate_worktree_changes(changes: &mut Option<(usize, Changes)>) -> bool { false } -fn visible_decorations_changed(old: &Decorations, new: &Decorations, rows: &[CommitRow]) -> bool { - rows.iter().any(|row| old.get(&row.id) != new.get(&row.id)) -} - -fn selection_refs(repo: &gix::Repository, id: gix::ObjectId, decorations: &Decorations) -> Vec { - let mut refs: Vec<_> = decorations - .get(&id) - .into_iter() - .flatten() - .map(|decoration| { - let upstream = if decoration.kind == DecorationKind::Local { - let mut name = BString::from("refs/heads/"); - name.extend_from_slice(&decoration.name); - repo.try_find_reference(name.as_bstr()) - .ok() - .flatten() - .and_then(|reference| reference.remote_tracking_ref_name(gix::remote::Direction::Fetch)) - .map(|name| { - name.ok().and_then(|name| { - repo.try_find_reference(name.as_bstr()) - .ok() - .flatten() - .and_then(|mut reference| reference.peel_to_id().ok().map(gix::Id::detach)) - }) - }) - } else { - None - }; - SelectionRef { - name: decoration.name.clone(), - upstream, - } - }) - .collect(); - refs.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.upstream.cmp(&b.upstream))); - refs -} - -fn selection_relation( - repo: &gix::Repository, - id: gix::ObjectId, - refs: &[SelectionRef], - visible: Option, -) -> Option { - let has_upstream = refs.iter().any(|reference| reference.upstream.is_some()); - for upstream in refs.iter().filter_map(|reference| reference.upstream.flatten()) { - let Ok(ahead) = count_exclusive_commits(repo, id, upstream) else { - continue; - }; - let Ok(behind) = count_exclusive_commits(repo, upstream, id) else { - continue; - }; - return Some(SelectionRelation::Tracking { ahead, behind }); - } - (!has_upstream && !refs.is_empty()) - .then_some(visible) - .flatten() - .map(SelectionRelation::Visible) -} - -fn count_exclusive_commits(repo: &gix::Repository, tip: gix::ObjectId, hidden: gix::ObjectId) -> Result { - let mut walk = repo - .rev_walk([tip]) - .with_hidden([hidden]) - .all() - .context("could not compare branch with its upstream")?; - walk.try_fold(0usize, |count, info| { - info.context("could not traverse branch comparison").map(|_| count + 1) - }) -} - fn remembered_change_selection(view: &app::ChangesView, changes: Option<&Changes>) -> Option<(BString, usize)> { changes.and_then(|changes| { changes @@ -1667,6 +1588,7 @@ fn draw( commit_message: &mut Option<(gix::ObjectId, BString)>, tree_changes: &mut Option<(gix::ObjectId, usize, Changes)>, worktree_changes: &mut Option<(usize, Changes)>, + history_graph: &mut Option, selection_cache: &mut Option, line_diff_pool: &mut Option, ) -> Result<()> { @@ -1742,12 +1664,20 @@ fn draw( *tree_changes = None; *worktree_changes = None; } + if let Some(id) = relation_to_load + && let Some(graph) = history_graph + { + let refs = graph.selection_refs(id, decorations); + let hidden: Vec<_> = app.hidden_ids().into_iter().collect(); + let relation = graph.selection_relation(id, &refs, &hidden); + *selection_cache = Some(SelectionRelationCache { id, refs, relation }); + app.selection_relation = relation; + } if !notes_to_load.is_empty() || app.rows[start..end].iter().any(|row| !row.metadata_loaded) || message_to_load.is_some() || tree_changes_to_load.is_some() || worktree_changes_to_load - || relation_to_load.is_some() { let mut one_shot_repository = None; let repository = if fill_repository.retain { @@ -1788,14 +1718,6 @@ fn draw( if let Some(id) = message_to_load { *commit_message = Some((id, load_commit_message(repository, id)?)); } - if let Some(id) = relation_to_load { - repository.object_cache_size(OBJECT_CACHE_SIZE); - let refs = selection_refs(repository, id, decorations); - let relation = selection_relation(repository, id, &refs, app.visible_ancestry_to_hidden(id)); - repository.object_cache_size(None); - *selection_cache = Some(SelectionRelationCache { id, refs, relation }); - app.selection_relation = relation; - } if let Some(id) = tree_changes_to_load { repository.object_cache_size(OBJECT_CACHE_SIZE); let loaded = load_changes( @@ -2885,41 +2807,55 @@ mod tests { #[test] fn selection_relation_prefers_tracking_counts_and_handles_missing_upstreams() -> gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; - let mut repository = gix::open(&fixture)?; - repository.object_cache_size(OBJECT_CACHE_SIZE); + let repository = open_test_repository(&fixture)?; let topic = repository.rev_parse_single("topic")?.detach(); let main = repository.rev_parse_single("main")?.detach(); + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let mut graph = None; + history::load( + &repository, + &[OsString::from("topic"), OsString::from("main")], + &[], + &authors, + &AtomicBool::new(false), + |event| { + if let Event::Complete(value) = event { + graph = Some(value); + } + true + }, + )?; + let mut graph = graph.expect("history traversal returns its graph"); let tracking = SelectionRef { name: "topic".into(), upstream: Some(Some(main)), }; assert_eq!( - selection_relation(&repository, topic, &[tracking.clone(), tracking], Some(99)), + graph.selection_relation(topic, &[tracking.clone(), tracking], &[]), Some(SelectionRelation::Tracking { ahead: 1, behind: 2 }), "one upstream comparison wins over the visible-history fallback" ); assert_eq!( - selection_relation( - &repository, + graph.selection_relation( topic, &[SelectionRef { name: "topic".into(), upstream: Some(None), }], - Some(1), + &[], ), None, "a configured but missing tracking ref does not masquerade as an untracked branch" ); assert_eq!( - selection_relation( - &repository, + graph.selection_relation( topic, &[SelectionRef { name: "tag: topic".into(), upstream: None, }], - Some(1), + &[main], ), Some(SelectionRelation::Visible(1)) ); @@ -2950,19 +2886,31 @@ mod tests { .args(["update-ref", "refs/remotes/origin/main", &main.to_hex().to_string()]) .status()?; assert!(status.success(), "the configured tracking ref exists"); - let repository = gix::open(path)?; - let refs = selection_refs( + let repository = open_test_repository(path)?; + let authors = + gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); + let mut graph = None; + history::load( &repository, - topic, - &Decorations::from([( - topic, - vec![history::Decoration { - name: "topic".into(), - kind: DecorationKind::Local, - }], - )]), - ); + &[OsString::from("topic")], + &[], + &authors, + &AtomicBool::new(false), + |event| { + if let Event::Complete(value) = event { + graph = Some(value); + } + true + }, + )?; + let mut graph = graph.expect("history traversal returns its graph"); + let refs = graph.selection_refs(topic, &history::decorations(&repository)?); assert_eq!(refs[0].upstream, Some(Some(main))); + assert_eq!( + graph.selection_relation(topic, &refs, &[]), + Some(SelectionRelation::Tracking { ahead: 1, behind: 2 }), + "the dynamically scheduled upstream has enough cached ancestry for comparison" + ); Ok(()) } From 0a22730990ec8329d1d6bb6d22e950246f46e0af Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 8 Aug 2026 20:35:09 +0200 Subject: [PATCH 029/282] fix: keep mouse navigation responsive in large tix histories Avoid scanning every history row when no failed signature state needs resetting, and coalesce queued vertical mouse-scroll events into a single bounded selection move. This keeps terminal momentum from monopolizing the event loop on million-commit histories while preserving the total requested movement. --- gix-tix/src/app.rs | 32 +++++++++++++++++++++----- gix-tix/src/lib.rs | 56 +++++++++++++++++++++++++++++++++------------- 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 22f1b2b824f..c7066e6bba9 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -255,6 +255,8 @@ pub(crate) enum Action { Cancelled, MoveUp, MoveDown, + MoveUpBy(usize), + MoveDownBy(usize), ScrollLeft, ScrollRight, HalfPageUp, @@ -597,8 +599,12 @@ impl App { Action::Cancelled if self.state == State::Cancelling => self.state = State::Cancelled, Action::MoveUp if self.changes_focus.is_some() => self.move_changes(1, false), Action::MoveDown if self.changes_focus.is_some() => self.move_changes(1, true), - Action::MoveUp => self.move_reachable(false), - Action::MoveDown => self.move_reachable(true), + Action::MoveUpBy(distance) if self.changes_focus.is_some() => self.move_changes(distance, false), + Action::MoveDownBy(distance) if self.changes_focus.is_some() => self.move_changes(distance, true), + Action::MoveUp => self.move_reachable(1, false), + Action::MoveDown => self.move_reachable(1, true), + Action::MoveUpBy(distance) => self.move_reachable(distance, false), + Action::MoveDownBy(distance) => self.move_reachable(distance, true), Action::ScrollLeft => { if self.changes_focus.is_some() { self.pan_changes(false); @@ -1051,18 +1057,21 @@ impl App { self.ensure_visible(); } - fn move_reachable(&mut self, down: bool) { + fn move_reachable(&mut self, distance: usize, down: bool) { let (Some(selected), Some(reachable)) = (self.selected, self.reachable_rows.as_ref()) else { - self.move_selection(1, down); + self.move_selection(distance, down); return; }; + let distance = distance.max(1); let next = if down { (selected + 1..self.rows.len()) - .find(|index| !self.is_row_hidden(*index) && reachable.get(*index) == Some(&true)) + .filter(|index| !self.is_row_hidden(*index) && reachable.get(*index) == Some(&true)) + .nth(distance - 1) } else { (0..selected) .rev() - .find(|index| !self.is_row_hidden(*index) && reachable.get(*index) == Some(&true)) + .filter(|index| !self.is_row_hidden(*index) && reachable.get(*index) == Some(&true)) + .nth(distance - 1) }; if let Some(next) = next { self.select(next); @@ -1186,6 +1195,9 @@ impl App { } fn retry_failed_signatures(&mut self) { + if self.signature_failures == 0 { + return; + } for row in &mut self.rows { if row.signature == SignatureState::Failed { row.signature = SignatureState::Unverified; @@ -2021,6 +2033,14 @@ mod tests { app.update(Action::First); assert_eq!(app.selected, Some(0), "First selects the newest commit"); assert_eq!(app.offset, 0, "the newest commit is visible"); + app.update(Action::MoveDownBy(3)); + assert_eq!( + app.selected, + Some(3), + "batched mouse navigation moves once by its full distance" + ); + app.update(Action::MoveUpBy(2)); + assert_eq!(app.selected, Some(1)); } #[test] diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 235697ef9d9..27e908e903c 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -786,6 +786,7 @@ fn event_loop( let mut history_finished = false; let mut focused = true; let mut repeat_deadline: Option = None; + let mut pending_terminal_event = None; let result: Result> = (|| loop { let mut worktree_watch_error = None; if let Some(watcher) = worktree_watcher.as_mut() { @@ -1171,10 +1172,13 @@ fn event_loop( .into_iter() .flatten() .min(); - let terminal_event = match poll_timeout(streaming, events, dirty, last_draw.elapsed(), wake_after) { - Some(timeout) if event::poll(timeout)? => Some(event::read()?), - Some(_) => None, - None => Some(event::read()?), + let terminal_event = match pending_terminal_event.take() { + Some(event) => Some(event), + None => match poll_timeout(streaming, events, dirty, last_draw.elapsed(), wake_after) { + Some(timeout) if event::poll(timeout)? => Some(event::read()?), + Some(_) => None, + None => Some(event::read()?), + }, }; let Some(terminal_event) = terminal_event else { continue; @@ -1186,7 +1190,21 @@ fn event_loop( (action, repeats_history, key.kind == KeyEventKind::Repeat, false) } TerminalEvent::Mouse(mouse) => { - let Some(action) = mouse_scroll_action(mouse.kind) else { + let kind = mouse.kind; + let mut distance = 1; + if matches!(kind, MouseEventKind::ScrollUp | MouseEventKind::ScrollDown) { + while distance < EVENT_BATCH_SIZE && event::poll(Duration::ZERO)? { + let next = event::read()?; + match next { + TerminalEvent::Mouse(next) if next.kind == kind => distance += 1, + next => { + pending_terminal_event = Some(next); + break; + } + } + } + } + let Some(action) = mouse_scroll_action(kind, distance) else { continue; }; let repeats_history = app.changes_focus.is_none() && repeats_viewport(&action); @@ -2727,6 +2745,8 @@ fn repeats_viewport(action: &Action) -> bool { action, Action::MoveUp | Action::MoveDown + | Action::MoveUpBy(_) + | Action::MoveDownBy(_) | Action::HalfPageUp | Action::HalfPageDown | Action::PageUp @@ -2740,10 +2760,10 @@ fn retains_fill_repository(kind: KeyEventKind, action: Option<&Action>, changes_ !changes_focused && kind == KeyEventKind::Repeat && action.is_some_and(repeats_viewport) } -fn mouse_scroll_action(kind: MouseEventKind) -> Option { +fn mouse_scroll_action(kind: MouseEventKind, distance: usize) -> Option { match kind { - MouseEventKind::ScrollUp => Some(Action::MoveUp), - MouseEventKind::ScrollDown => Some(Action::MoveDown), + MouseEventKind::ScrollUp => Some(Action::MoveUpBy(distance.max(1))), + MouseEventKind::ScrollDown => Some(Action::MoveDownBy(distance.max(1))), MouseEventKind::ScrollLeft => Some(Action::ScrollLeft), MouseEventKind::ScrollRight => Some(Action::ScrollRight), _ => None, @@ -3539,22 +3559,28 @@ mod tests { #[test] fn maps_continuous_mouse_scrolling_to_navigation() { - assert_eq!(mouse_scroll_action(MouseEventKind::ScrollUp), Some(Action::MoveUp)); - assert_eq!(mouse_scroll_action(MouseEventKind::ScrollDown), Some(Action::MoveDown)); assert_eq!( - mouse_scroll_action(MouseEventKind::ScrollLeft), + mouse_scroll_action(MouseEventKind::ScrollUp, 4), + Some(Action::MoveUpBy(4)) + ); + assert_eq!( + mouse_scroll_action(MouseEventKind::ScrollDown, 3), + Some(Action::MoveDownBy(3)) + ); + assert_eq!( + mouse_scroll_action(MouseEventKind::ScrollLeft, 1), Some(Action::ScrollLeft) ); assert_eq!( - mouse_scroll_action(MouseEventKind::ScrollRight), + mouse_scroll_action(MouseEventKind::ScrollRight, 1), Some(Action::ScrollRight) ); - assert_eq!(mouse_scroll_action(MouseEventKind::Moved), None); + assert_eq!(mouse_scroll_action(MouseEventKind::Moved, 1), None); assert!(repeats_viewport( - &mouse_scroll_action(MouseEventKind::ScrollDown).expect("vertical scrolling has an action") + &mouse_scroll_action(MouseEventKind::ScrollDown, 2).expect("vertical scrolling has an action") )); assert!(!repeats_viewport( - &mouse_scroll_action(MouseEventKind::ScrollRight).expect("horizontal scrolling has an action") + &mouse_scroll_action(MouseEventKind::ScrollRight, 1).expect("horizontal scrolling has an action") )); } From 2f46b67c3e5a1a8af306fdec01d27ffb7bb8f17f Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 8 Aug 2026 21:00:48 +0200 Subject: [PATCH 030/282] perf: compact tix history storage Store persistent ancestry once as index-addressed commits with flat u32 parent edges instead of repeating object IDs in cached parent lists. Use compact vector walk state for refresh and ahead/behind calculations, and share immutable display rows between the active view, append-only cache, and lane worker. Keep lane-local parent pruning out of the append-only cache so later view projections retain off-screen ancestry needed for expansion. This preserves fast incremental refreshes and navigation while reducing peak memory on the 1.35-million-commit Linux history from 1.45 GB to 796 MB, with startup changing from 3.35s to 3.49s. --- gix-tix/src/app.rs | 92 ++++++-- gix-tix/src/history.rs | 476 ++++++++++++++++++++++++++--------------- gix-tix/src/lib.rs | 6 +- 3 files changed, 370 insertions(+), 204 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index c7066e6bba9..7839496d70f 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -1,6 +1,7 @@ use std::{ collections::{HashMap, HashSet}, ops::Range, + sync::Arc, time::{Duration, Instant}, }; @@ -205,6 +206,7 @@ pub(crate) enum AttributionKind { pub(crate) type LoadedCommit = Commit; pub(crate) type CommitRow = Commit>; +pub(crate) type SharedCommitRow = Arc; #[derive(Debug)] pub(crate) struct LoadedCommits { @@ -304,8 +306,8 @@ pub(crate) enum Effect { #[derive(Debug)] pub(crate) struct App { - pub rows: Vec, - all_rows: HashMap, + pub rows: Vec, + all_rows: HashMap, all_order: Vec, hidden_rows: HashSet, pending_hidden_rows: Option>, @@ -473,7 +475,7 @@ impl App { } } - fn store_commits(&mut self, commits: LoadedCommits) -> Vec { + fn store_commits(&mut self, commits: LoadedCommits) -> Vec { let LoadedCommits { rows, attributions } = commits; self.titles.reserve(rows.iter().map(|row| row.title.len()).sum()); let attribution_base = self.attributions.len(); @@ -493,7 +495,8 @@ impl App { has_agent_marker: row.has_agent_marker, signature: row.signature, }; - if self.all_rows.insert(row.id, row.clone()).is_none() { + let row = Arc::new(row); + if self.all_rows.insert(row.id, Arc::clone(&row)).is_none() { self.all_order.push(row.id); } row @@ -523,6 +526,7 @@ impl App { if row.metadata_loaded { return; } + let row = Arc::make_mut(row); let Metadata { committer_time, author, @@ -542,7 +546,7 @@ impl App { row.metadata_loaded = true; row.has_agent_marker = has_agent_marker; row.signature = signature; - self.all_rows.insert(row.id, row.clone()); + self.all_rows.insert(row.id, Arc::clone(&self.rows[index])); } pub(crate) fn title(&self, row: &CommitRow) -> &BStr { @@ -745,14 +749,18 @@ impl App { Action::VerifySignatures if !self.signature_verification_running => { let start = self.offset.min(self.rows.len()); let end = start.saturating_add(self.viewport_rows).min(self.rows.len()); - let ids: Vec<_> = self.rows[start..end] + let changed: Vec<_> = self.rows[start..end] .iter_mut() .filter(|row| !self.hidden_rows.contains(&row.id) && row.signature == SignatureState::Unverified) .map(|row| { - row.signature = SignatureState::Verifying; - row.id + Arc::make_mut(row).signature = SignatureState::Verifying; + (row.id, Arc::clone(row)) }) .collect(); + for (id, row) in &changed { + self.all_rows.insert(*id, Arc::clone(row)); + } + let ids: Vec<_> = changed.into_iter().map(|(id, _)| id).collect(); if !ids.is_empty() { self.signature_verification_running = true; return vec![Effect::VerifySignatures(ids)]; @@ -804,7 +812,7 @@ impl App { Vec::new() } - pub(crate) fn start_lane_computation(&mut self) -> Option> { + pub(crate) fn start_lane_computation(&mut self) -> Option> { match self.state { State::Loading => { self.state = State::Computing; @@ -831,7 +839,7 @@ impl App { view_tips: &[ObjectId], hidden_tips: &[ObjectId], select_top: bool, - ) -> Option> { + ) -> Option> { drop(self.store_commits(commits)); let visible = self.reachable_from(view_tips); @@ -851,7 +859,7 @@ impl App { .all_order .iter() .filter(|id| visible.contains(*id) || boundary.contains(*id)) - .filter_map(|id| self.all_rows.get(id).cloned()) + .filter_map(|id| self.all_rows.get(id).map(Arc::clone)) .collect(); self.pending_hidden_rows = Some(boundary); self.select_top_after_refresh = select_top; @@ -874,7 +882,7 @@ impl App { reachable } - pub(crate) fn finish_lane_computation(&mut self, rows: Vec, graph: Graph, lane_time: Duration) { + pub(crate) fn finish_lane_computation(&mut self, rows: Vec, graph: Graph, lane_time: Duration) { if self.state != State::Computing { return; } @@ -908,6 +916,7 @@ impl App { } for row in &mut self.rows { if let Some(metadata) = metadata.get(&row.id) { + let row = Arc::make_mut(row); row.committer_time = metadata.committer_time; row.author = metadata.author; row.attributions = metadata.attributions.clone(); @@ -964,16 +973,17 @@ impl App { pub(crate) fn finish_signature_verification(&mut self, results: Vec<(ObjectId, bool)>) { let mut failed = 0; for (id, valid) in results { - let Some(row) = self.rows.iter_mut().find(|row| row.id == id) else { + let Some(index) = self.rows.iter().position(|row| row.id == id) else { continue; }; + let row = Arc::make_mut(&mut self.rows[index]); row.signature = if valid { SignatureState::Verified } else { failed += 1; SignatureState::Failed }; - self.all_rows.insert(row.id, row.clone()); + self.all_rows.insert(id, Arc::clone(&self.rows[index])); } self.signature_verification_running = false; self.signature_failures = failed; @@ -1198,11 +1208,16 @@ impl App { if self.signature_failures == 0 { return; } + let mut changed = Vec::new(); for row in &mut self.rows { if row.signature == SignatureState::Failed { - row.signature = SignatureState::Unverified; + Arc::make_mut(row).signature = SignatureState::Unverified; + changed.push((row.id, Arc::clone(row))); } } + for (id, row) in changed { + self.all_rows.insert(id, row); + } self.signature_failures = 0; } @@ -1325,11 +1340,13 @@ impl App { } } -fn estimate_lane_width(rows: &[CommitRow]) -> usize { +fn estimate_lane_width(rows: &[SharedCommitRow]) -> usize { let mut rows = rows.to_vec(); let known: HashMap<_, _> = rows.iter().enumerate().map(|(index, row)| (row.id, index)).collect(); for row in &mut rows { - row.parent_ids.retain(|id| known.contains_key(id)); + if row.parent_ids.iter().any(|id| !known.contains_key(id)) { + Arc::make_mut(row).parent_ids.retain(|id| known.contains_key(id)); + } } let graph = Graph::new(&rows); graph @@ -1340,10 +1357,12 @@ fn estimate_lane_width(rows: &[CommitRow]) -> usize { .unwrap_or_default() } -pub(crate) fn compute_lanes(mut rows: Vec) -> (Vec, Graph, Duration) { +pub(crate) fn compute_lanes(mut rows: Vec) -> (Vec, Graph, Duration) { let positions: HashMap<_, _> = rows.iter().enumerate().map(|(index, row)| (row.id, index)).collect(); for row in &mut rows { - row.parent_ids.retain(|id| positions.contains_key(id)); + if row.parent_ids.iter().any(|id| !positions.contains_key(id)) { + Arc::make_mut(row).parent_ids.retain(|id| positions.contains_key(id)); + } } let mut children = vec![0usize; rows.len()]; for row in rows.iter() { @@ -1397,7 +1416,7 @@ pub(crate) struct Graph { } impl Graph { - fn new(rows: &[CommitRow]) -> Self { + fn new(rows: &[SharedCommitRow]) -> Self { let mut state = LaneState::default(); let mut graph = Graph { offsets: Vec::with_capacity(rows.len().div_ceil(CHECKPOINT_INTERVAL) + 1), @@ -1414,7 +1433,7 @@ impl Graph { graph } - fn render(&self, rows: &[CommitRow], range: Range) -> RenderedLanes { + fn render(&self, rows: &[SharedCommitRow], range: Range) -> RenderedLanes { let start = range.start.min(rows.len()); let end = range.end.min(rows.len()); if start >= end { @@ -1767,6 +1786,33 @@ mod tests { app.rows.iter().map(|row| row.id).collect::>(), [id(4), id(3), id(2), id(1)] ); + assert!( + app.rows + .iter() + .all(|row| Arc::ptr_eq(row, app.all_rows.get(&row.id).expect("visible rows remain cached"))), + "the active projection shares its immutable rows with the append-only cache" + ); + } + + #[test] + fn lane_computation_keeps_cached_parents_outside_the_current_view() { + let mut app = App::new(3); + app.extend_commits(vec![row_with_parents(2, &[1])]); + app.extend_hidden_commits(vec![row_with_parents(1, &[0])]); + let rows = app + .start_lane_computation() + .expect("loading rows starts lane computation"); + let (rows, graph, elapsed) = compute_lanes(rows); + app.finish_lane_computation(rows, graph, elapsed); + + let rows = app + .start_refresh(vec![row(0)].into(), &[id(2)], &[], false) + .expect("refresh projects the extended ancestry"); + assert_eq!( + rows.iter().map(|row| row.id).collect::>(), + [id(2), id(1), id(0)], + "lane pruning does not disconnect cached ancestry needed by a later expansion" + ); } #[test] @@ -1846,7 +1892,7 @@ mod tests { let mut app = App::new(2); app.extend_commits(vec![row(1), row(2), row(3)]); for row in &mut app.rows { - row.signature = SignatureState::Unverified; + Arc::make_mut(row).signature = SignatureState::Unverified; } app.offset = 1; @@ -2049,7 +2095,7 @@ mod tests { app.extend_commits(vec![row(1), row(2), row(3)]); app.update(Action::Last); app.extend_hidden_commits(vec![row(4)]); - app.rows[3].signature = SignatureState::Unverified; + Arc::make_mut(&mut app.rows[3]).signature = SignatureState::Unverified; assert_eq!( app.selected, diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index c8d888a9c5c..9a8109652f6 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -44,10 +44,40 @@ pub(crate) struct SelectionRef { pub upstream: Option>, } +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(crate) struct CommitIndex(u32); + +impl CommitIndex { + fn new(index: usize) -> Result { + Ok(CommitIndex( + index + .try_into() + .context("tix cannot index more than u32::MAX commits")?, + )) + } + + pub(crate) fn as_usize(self) -> usize { + self.0 as usize + } +} + +#[derive(Clone, Debug)] +struct GraphCommit { + id: ObjectId, + parents: std::ops::Range, + commit_time: gix::date::SecondsSinceUnixEpoch, + generation: u32, + state: u8, +} + +impl GraphCommit { + fn generation(&self) -> Option { + (self.generation != 0).then_some(self.generation) + } +} + #[derive(Clone, Copy, Debug, Default)] struct Node { - complete: bool, - stored: bool, flags: u8, expanded: u8, emitted: bool, @@ -55,9 +85,12 @@ struct Node { #[derive(Debug, Default)] pub(crate) struct HistoryGraph { - commits: gix::revwalk::graph::IdMap>, - tracking: HashMap>, - relations: HashMap<(ObjectId, ObjectId), (usize, usize)>, + commits: Vec, + parents: Vec, + by_id: HashMap, + stored_order: Vec, + tracking: HashMap>, + relations: HashMap<(CommitIndex, CommitIndex), (usize, usize)>, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -66,11 +99,11 @@ struct GenThenTime { time: gix::date::SecondsSinceUnixEpoch, } -impl From<&gix::revwalk::graph::Commit> for GenThenTime { - fn from(commit: &gix::revwalk::graph::Commit) -> Self { +impl From<&GraphCommit> for GenThenTime { + fn from(commit: &GraphCommit) -> Self { GenThenTime { generation: commit - .generation + .generation() .unwrap_or(gix::commitgraph::GENERATION_NUMBER_INFINITY), time: commit.commit_time, } @@ -90,6 +123,39 @@ impl PartialOrd for GenThenTime { } impl HistoryGraph { + fn intern(&mut self, id: ObjectId) -> Result { + if let Some(index) = self.by_id.get(&id) { + return Ok(*index); + } + let index = CommitIndex::new(self.commits.len())?; + self.commits.push(GraphCommit { + id, + parents: 0..0, + commit_time: 0, + generation: 0, + state: 0, + }); + self.by_id.insert(id, index); + Ok(index) + } + + fn index(&self, id: ObjectId) -> Option { + self.by_id.get(&id).copied() + } + + pub(crate) fn id(&self, index: CommitIndex) -> ObjectId { + self.commits[index.as_usize()].id + } + + fn parents(&self, index: CommitIndex) -> &[CommitIndex] { + let range = self.commits[index.as_usize()].parents.clone(); + &self.parents[range.start as usize..range.end as usize] + } + + fn parent_ids(&self, index: CommitIndex) -> gix::traverse::commit::ParentIds { + self.parents(index).iter().map(|parent| self.id(*parent)).collect() + } + fn ensure_commit( &mut self, repo: &gix::Repository, @@ -97,9 +163,10 @@ impl HistoryGraph { shallow: &HashSet, id: ObjectId, buf: &mut Vec, - ) -> Result<()> { - if self.commits.contains_key(&id) { - return Ok(()); + ) -> Result { + let index = self.intern(id)?; + if self.commits[index.as_usize()].state & NODE_LOADED != 0 { + return Ok(index); } let commit = gix::traverse::commit::find(cache, &repo.objects, &id, buf) .context("could not load commit for cached history traversal")?; @@ -138,16 +205,27 @@ impl HistoryGraph { if shallow.contains(&id) { parents.clear(); } - self.commits.insert( - id, - gix::revwalk::graph::Commit { - parents, - commit_time, - generation, - data: Node::default(), - }, - ); - Ok(()) + let parents: Vec<_> = parents + .into_iter() + .map(|parent| self.intern(parent)) + .collect::>()?; + let start: u32 = self + .parents + .len() + .try_into() + .context("tix cannot index more than u32::MAX parent edges")?; + self.parents.extend(parents); + let end: u32 = self + .parents + .len() + .try_into() + .context("tix cannot index more than u32::MAX parent edges")?; + let node = &mut self.commits[index.as_usize()]; + node.parents = start..end; + node.commit_time = commit_time; + node.generation = generation.unwrap_or_default(); + node.state |= NODE_LOADED; + Ok(index) } #[expect(clippy::too_many_arguments)] @@ -156,23 +234,24 @@ impl HistoryGraph { repo: &gix::Repository, cache: Option<&gix::commitgraph::Graph>, shallow: &HashSet, - states: &mut HashMap, - queue: &mut gix::revwalk::PriorityQueue, + states: &mut Vec, + queue: &mut gix::revwalk::PriorityQueue, buf: &mut Vec, id: ObjectId, flags: u8, ) -> Result<()> { - self.ensure_commit(repo, cache, shallow, id, buf)?; - let state = states.entry(id).or_default(); + let index = self.ensure_commit(repo, cache, shallow, id, buf)?; + states.resize(self.commits.len(), WalkState::default()); + let state = &mut states[index.as_usize()]; if state.flags & flags != flags { state.flags |= flags; - queue.insert(self.commits[&id].commit_time, id); + queue.insert(self.commits[index.as_usize()].commit_time, index); } Ok(()) } pub(crate) fn selection_refs(&self, id: ObjectId, decorations: &Decorations) -> Vec { - let tracked = self.tracking.get(&id); + let tracked = self.index(id).and_then(|index| self.tracking.get(&index)); let mut refs: Vec<_> = decorations .get(&id) .into_iter() @@ -205,11 +284,12 @@ impl HistoryGraph { ) -> Option { let has_upstream = refs.iter().any(|reference| reference.upstream.is_some()); for upstream in refs.iter().filter_map(|reference| reference.upstream.flatten()) { - let relation = if let Some(relation) = self.relations.get(&(id, upstream)).copied() { + let pair = self.index(id).zip(self.index(upstream))?; + let relation = if let Some(relation) = self.relations.get(&pair).copied() { Some(relation) } else { let relation = self.paint(id, std::slice::from_ref(&upstream))?; - self.relations.insert((id, upstream), relation); + self.relations.insert(pair, relation); Some(relation) }; if let Some((ahead, behind)) = relation { @@ -224,56 +304,53 @@ impl HistoryGraph { } fn paint(&self, first: ObjectId, others: &[ObjectId]) -> Option<(usize, usize)> { - if !self.commits.contains_key(&first) || others.iter().any(|id| !self.commits.contains_key(id)) { - return None; - } - let mut flags = HashMap::::new(); - let mut queue = gix::revwalk::PriorityQueue::::new(); - let mut queued = HashSet::new(); + let first = self.index(first)?; + let others: Vec<_> = others.iter().map(|id| self.index(*id)).collect::>()?; + let mut flags = vec![0u8; self.commits.len()]; + let mut queue = gix::revwalk::PriorityQueue::::new(); + let mut queued = vec![false; self.commits.len()]; let mut pending = 0usize; - for (id, flag) in std::iter::once((first, VISIBLE)).chain(others.iter().copied().map(|id| (id, HIDDEN))) { - *flags.entry(id).or_default() |= flag; - if queued.insert(id) { - queue.insert(GenThenTime::from(&self.commits[&id]), id); + for (index, flag) in std::iter::once((first, VISIBLE)).chain(others.into_iter().map(|index| (index, HIDDEN))) { + flags[index.as_usize()] |= flag; + if !queued[index.as_usize()] { + queued[index.as_usize()] = true; + queue.insert(GenThenTime::from(&self.commits[index.as_usize()]), index); pending += 1; } } while pending != 0 { - let Some((_priority, id)) = queue.pop() else { break }; - queued.remove(&id); - let mut propagated = flags[&id]; + let Some((_priority, index)) = queue.pop() else { break }; + queued[index.as_usize()] = false; + let mut propagated = flags[index.as_usize()]; if propagated & STALE == 0 { pending -= 1; } if propagated & (VISIBLE | HIDDEN) == VISIBLE | HIDDEN { propagated |= STALE; - *flags.get_mut(&id).expect("queued commits have flags") = propagated; + flags[index.as_usize()] = propagated; } - for &parent in &self.commits[&id].parents { - let Some(commit) = self.commits.get(&parent) else { - continue; - }; - let parent_flags = flags.entry(parent).or_default(); + for &parent in self.parents(index) { + let parent_flags = &mut flags[parent.as_usize()]; let previous = *parent_flags; if previous & propagated != propagated { *parent_flags = previous | propagated; - if queued.contains(&parent) { + if queued[parent.as_usize()] { if previous & STALE == 0 && *parent_flags & STALE != 0 { pending -= 1; } } else { - queued.insert(parent); + queued[parent.as_usize()] = true; if *parent_flags & STALE == 0 { pending += 1; } - queue.insert(GenThenTime::from(commit), parent); + queue.insert(GenThenTime::from(&self.commits[parent.as_usize()]), parent); } } } } let mut ahead = 0; let mut behind = 0; - for flags in flags.into_values() { + for flags in flags { match flags & (VISIBLE | HIDDEN) { VISIBLE => ahead += 1, HIDDEN => behind += 1, @@ -303,7 +380,7 @@ impl HistoryGraph { .context("could not open commit-graph for history refresh")?; let local_refs = local_refs_by_target(repo)?; let mut tracking = HashMap::new(); - let mut states = HashMap::::new(); + let mut states = vec![WalkState::default(); self.commits.len()]; let mut queue = gix::revwalk::PriorityQueue::new(); let mut buf = Vec::new(); for id in refs.view_tips.iter().chain(&refs.hidden_tips).copied() { @@ -331,7 +408,8 @@ impl HistoryGraph { )?; } for (&id, names) in &local_refs { - if self.commits.get(&id).is_none_or(|commit| !commit.data.stored) { + let Some(index) = self.index(id) else { continue }; + if self.commits[index.as_usize()].state & NODE_STORED == 0 { continue; } let tracked = resolve_tracking(repo, names)?; @@ -359,24 +437,27 @@ impl HistoryGraph { INTERNAL, )?; } - tracking.insert(id, tracked); + tracking.insert(index, tracked); } let mut rows = Vec::new(); let mut attributions = Vec::new(); - while let Some((_time, id)) = queue.pop() { - let Some(state) = states.get_mut(&id) else { continue }; + while let Some((_time, index)) = queue.pop() { + let state = &mut states[index.as_usize()]; let delta = state.flags & !state.expanded; if delta == 0 { continue; } state.expanded |= delta; - let commit = &self.commits[&id]; - let was_stored = commit.data.stored; - let stop = commit.data.complete && (delta & EXPAND == 0 || was_stored && !expand.contains(&id)); + let id = self.id(index); + let commit = &self.commits[index.as_usize()]; + let was_stored = commit.state & NODE_STORED != 0; + let stop = + commit.state & NODE_COMPLETE != 0 && (delta & EXPAND == 0 || was_stored && !expand.contains(&id)); let should_store = delta & (VISIBLE | EXPAND) != 0 && !was_stored; - let parent_ids = commit.parents.clone(); - let generation = commit.generation; + let parent_indices = self.parents(index).to_vec(); + let parent_ids = self.parent_ids(index); + let generation = commit.generation(); if should_store { if let Some(names) = local_refs.get(&id) { let tracked = resolve_tracking(repo, names)?; @@ -404,7 +485,7 @@ impl HistoryGraph { INTERNAL, )?; } - tracking.insert(id, tracked); + tracking.insert(index, tracked); } let metadata = if generation.is_some() { None @@ -440,16 +521,13 @@ impl HistoryGraph { has_agent_marker, signature, }); - self.commits - .get_mut(&id) - .expect("loaded commit remains present") - .data - .stored = true; + self.commits[index.as_usize()].state |= NODE_STORED; + self.stored_order.push(index); } if stop { continue; } - for parent in parent_ids { + for parent in parent_indices { self.schedule_cached( repo, cache.as_ref(), @@ -457,18 +535,14 @@ impl HistoryGraph { &mut states, &mut queue, &mut buf, - parent, + self.id(parent), delta & (VISIBLE | INTERNAL | EXPAND), )?; } } - for (id, state) in states { + for (index, state) in states.into_iter().enumerate() { if state.expanded & (VISIBLE | INTERNAL | EXPAND) != 0 { - self.commits - .get_mut(&id) - .expect("walked commits remain cached") - .data - .complete = true; + self.commits[index].state |= NODE_COMPLETE; } } self.tracking = tracking; @@ -505,39 +579,42 @@ const INTERNAL: u8 = 1 << 1; const HIDDEN: u8 = 1 << 2; const STALE: u8 = 1 << 3; const EXPAND: u8 = 1 << 4; +const NODE_LOADED: u8 = 1 << 0; +const NODE_COMPLETE: u8 = 1 << 1; +const NODE_STORED: u8 = 1 << 2; -#[derive(Default)] +#[derive(Clone, Copy, Default)] struct WalkState { flags: u8, expanded: u8, } +#[expect(clippy::too_many_arguments)] fn schedule( - graph: &mut gix::revwalk::Graph<'_, '_, gix::revwalk::graph::Commit>, - queue: &mut gix::revwalk::PriorityQueue, + graph: &mut HistoryGraph, + repo: &gix::Repository, + cache: Option<&gix::commitgraph::Graph>, + states: &mut Vec, + queue: &mut gix::revwalk::PriorityQueue, shallow: &HashSet, + buf: &mut Vec, id: ObjectId, flags: u8, ) -> Result<()> { - let Some(commit) = graph - .get_or_insert_full_commit(id, |commit| { - if shallow.contains(&id) { - commit.parents.clear(); - } - }) - .context("could not load commit for history traversal")? - else { - return Ok(()); - }; - if commit.data.flags & flags != flags { - commit.data.flags |= flags; - queue.insert(commit.commit_time, id); + let index = graph.ensure_commit(repo, cache, shallow, id, buf)?; + states.resize(graph.commits.len(), Node::default()); + let state = &mut states[index.as_usize()]; + if state.flags & flags != flags { + state.flags |= flags; + queue.insert(graph.commits[index.as_usize()].commit_time, index); } Ok(()) } fn hidden_frontier( - graph: &mut gix::revwalk::Graph<'_, '_, gix::revwalk::graph::Commit>, + graph: &mut HistoryGraph, + repo: &gix::Repository, + cache: Option<&gix::commitgraph::Graph>, visible_tips: &[ObjectId], hidden_tips: &[ObjectId], shallow: &HashSet, @@ -545,56 +622,41 @@ fn hidden_frontier( if hidden_tips.is_empty() { return Ok(HashSet::new()); } - let mut flags = HashMap::::new(); - let mut queue = gix::revwalk::PriorityQueue::::new(); + let mut flags = Vec::::new(); + let mut queue = gix::revwalk::PriorityQueue::::new(); + let mut buf = Vec::new(); for (tips, flag) in [(visible_tips, VISIBLE), (hidden_tips, HIDDEN)] { for &id in tips { - let Some(commit) = graph - .get_or_insert_full_commit(id, |commit| { - if shallow.contains(&id) { - commit.parents.clear(); - } - }) - .context("could not load commit while preparing hidden history")? - else { - continue; - }; - *flags.entry(id).or_default() |= flag; - queue.insert(GenThenTime::from(&*commit), id); + let index = graph.ensure_commit(repo, cache, shallow, id, &mut buf)?; + flags.resize(graph.commits.len(), 0); + flags[index.as_usize()] |= flag; + queue.insert(GenThenTime::from(&graph.commits[index.as_usize()]), index); } } - while queue - .iter_unordered() - .any(|id| flags.get(id).is_some_and(|flags| flags & STALE == 0)) - { - let Some((_priority, id)) = queue.pop() else { break }; - let mut propagated = flags[&id]; + while queue.iter_unordered().any(|index| flags[index.as_usize()] & STALE == 0) { + let Some((_priority, index)) = queue.pop() else { break }; + let mut propagated = flags[index.as_usize()]; if propagated & (VISIBLE | HIDDEN) == VISIBLE | HIDDEN { propagated |= STALE; - *flags.get_mut(&id).expect("queued commits have flags") = propagated; + flags[index.as_usize()] = propagated; } - let parents = graph.get(&id).expect("queued commits are loaded").parents.clone(); + let parents = graph.parents(index).to_vec(); for parent in parents { - let Some(commit) = graph - .get_or_insert_full_commit(parent, |commit| { - if shallow.contains(&parent) { - commit.parents.clear(); - } - }) - .context("could not load hidden commit parent")? - else { - continue; - }; - let parent_flags = flags.entry(parent).or_default(); + let parent_id = graph.id(parent); + let parent = graph.ensure_commit(repo, cache, shallow, parent_id, &mut buf)?; + flags.resize(graph.commits.len(), 0); + let parent_flags = &mut flags[parent.as_usize()]; if *parent_flags & propagated != propagated { *parent_flags |= propagated; - queue.insert(GenThenTime::from(&*commit), parent); + queue.insert(GenThenTime::from(&graph.commits[parent.as_usize()]), parent); } } } Ok(flags .into_iter() - .filter_map(|(id, flags)| (flags & (VISIBLE | HIDDEN) == VISIBLE | HIDDEN).then_some(id)) + .enumerate() + .filter(|(_, flags)| flags & (VISIBLE | HIDDEN) == VISIBLE | HIDDEN) + .map(|(index, _)| graph.id(CommitIndex(index as u32))) .collect()) } @@ -690,44 +752,80 @@ pub(crate) fn load( let commit_graph = repo .commit_graph_if_enabled() .context("could not open commit-graph for history traversal")?; - let mut graph = repo.revision_graph::>(commit_graph.as_ref()); - let hidden = hidden_frontier(&mut graph, &tips, &hidden_tips, &shallow)?; + let mut graph = HistoryGraph::default(); + let hidden = hidden_frontier(&mut graph, repo, commit_graph.as_ref(), &tips, &hidden_tips, &shallow)?; let local_refs = local_refs_by_target(repo)?; let mut tracking = HashMap::new(); + let mut states = vec![Node::default(); graph.commits.len()]; let mut queue = gix::revwalk::PriorityQueue::new(); + let mut buf = Vec::new(); for &tip in &tips { - schedule(&mut graph, &mut queue, &shallow, tip, VISIBLE)?; + schedule( + &mut graph, + repo, + commit_graph.as_ref(), + &mut states, + &mut queue, + &shallow, + &mut buf, + tip, + VISIBLE, + )?; } let mut rows = Vec::with_capacity(COMMIT_BATCH_SIZE); let mut attributions = Vec::with_capacity(COMMIT_BATCH_SIZE); let mut connected = Vec::new(); let mut connected_seen = HashSet::new(); - while let Some((_time, id)) = queue.pop() { + while let Some((_time, index)) = queue.pop() { if cancelled.load(Ordering::Relaxed) { emit(Event::Cancelled); return Ok(()); } - let commit = graph.get_mut(&id).expect("queued commits are loaded"); - let delta = commit.data.flags & !commit.data.expanded; - if delta == 0 { - continue; - } - commit.data.expanded |= delta; - commit.data.complete = true; - let should_emit = delta & VISIBLE != 0 && !commit.data.emitted && !hidden.contains(&id); - commit.data.emitted |= should_emit; - commit.data.stored |= should_emit; - let parent_ids = commit.parents.clone(); - let generation = commit.generation; + let id = graph.id(index); + let (delta, should_emit) = { + let state = &mut states[index.as_usize()]; + let delta = state.flags & !state.expanded; + if delta == 0 { + continue; + } + state.expanded |= delta; + let should_emit = delta & VISIBLE != 0 && !state.emitted && !hidden.contains(&id); + state.emitted |= should_emit; + (delta, should_emit) + }; + graph.commits[index.as_usize()].state |= NODE_COMPLETE; + let parent_indices = graph.parents(index).to_vec(); + let parent_ids = graph.parent_ids(index); + let generation = graph.commits[index.as_usize()].generation(); if should_emit && let Some(names) = local_refs.get(&id) { let refs = resolve_tracking(repo, names)?; if refs.iter().any(|reference| reference.upstream.flatten().is_some()) { - schedule(&mut graph, &mut queue, &shallow, id, INTERNAL)?; + schedule( + &mut graph, + repo, + commit_graph.as_ref(), + &mut states, + &mut queue, + &shallow, + &mut buf, + id, + INTERNAL, + )?; } for upstream in refs.iter().filter_map(|reference| reference.upstream.flatten()) { - schedule(&mut graph, &mut queue, &shallow, upstream, INTERNAL)?; + schedule( + &mut graph, + repo, + commit_graph.as_ref(), + &mut states, + &mut queue, + &shallow, + &mut buf, + upstream, + INTERNAL, + )?; } - tracking.insert(id, refs); + tracking.insert(index, refs); } let metadata = if !should_emit || generation.is_some() { None @@ -767,6 +865,8 @@ pub(crate) fn load( has_agent_marker, signature, }); + graph.commits[index.as_usize()].state |= NODE_STORED; + graph.stored_order.push(index); if rows.len() == COMMIT_BATCH_SIZE && !emit(Event::Commits(LoadedCommits { rows: std::mem::replace(&mut rows, Vec::with_capacity(COMMIT_BATCH_SIZE)), @@ -781,14 +881,25 @@ pub(crate) fn load( } else { delta & (VISIBLE | INTERNAL) }; - for parent in parent_ids { - let parent_flags = if hidden.contains(&parent) { + for parent in parent_indices { + let parent_id = graph.id(parent); + let parent_flags = if hidden.contains(&parent_id) { propagated & !VISIBLE } else { propagated }; if parent_flags != 0 { - schedule(&mut graph, &mut queue, &shallow, parent, parent_flags)?; + schedule( + &mut graph, + repo, + commit_graph.as_ref(), + &mut states, + &mut queue, + &shallow, + &mut buf, + parent_id, + parent_flags, + )?; } } } @@ -796,7 +907,7 @@ pub(crate) fn load( return Ok(()); } if !hidden_revisions.is_empty() { - connected.retain(|id| graph.get(id).is_none_or(|commit| !commit.data.emitted)); + connected.retain(|id| graph.index(*id).is_none_or(|index| !states[index.as_usize()].emitted)); let mut rows = Vec::with_capacity(connected.len()); let mut attributions = Vec::new(); let mut authors = gix::features::threading::lock(authors); @@ -826,15 +937,10 @@ pub(crate) fn load( has_agent_marker, signature, }); - if let Some(commit) = graph - .get_or_insert_full_commit(id, |commit| { - if shallow.contains(&id) { - commit.parents.clear(); - } - }) - .context("could not retain connected hidden commit")? - { - commit.data.stored = true; + let index = graph.ensure_commit(repo, commit_graph.as_ref(), &shallow, id, &mut buf)?; + if graph.commits[index.as_usize()].state & NODE_STORED == 0 { + graph.commits[index.as_usize()].state |= NODE_STORED; + graph.stored_order.push(index); } } if !rows.is_empty() && !emit(Event::HiddenCommits(LoadedCommits { rows, attributions })) { @@ -842,11 +948,8 @@ pub(crate) fn load( } } emit(Event::VisibleComplete); - emit(Event::Complete(HistoryGraph { - commits: graph.detach(), - tracking, - relations: HashMap::new(), - })); + graph.tracking = tracking; + emit(Event::Complete(graph)); Ok(()) } @@ -1165,6 +1268,24 @@ mod tests { ObjectId::Sha1(bytes) } + fn insert_commit(graph: &mut HistoryGraph, n: u8, parents: &[u8], generation: u32) { + let index = graph.intern(id(n)).expect("the small test graph fits in u32"); + let parents: Vec<_> = parents + .iter() + .map(|parent| graph.intern(id(*parent)).expect("the small test graph fits in u32")) + .collect(); + let start = graph.parents.len() as u32; + graph.parents.extend(parents); + let end = graph.parents.len() as u32; + graph.commits[index.as_usize()] = GraphCommit { + id: id(n), + parents: start..end, + commit_time: generation.into(), + generation, + state: NODE_LOADED, + }; + } + fn loaded(path: &std::path::Path, revisions: &[&str], hidden_revisions: &[&str]) -> Result> { let mut events = Vec::new(); let authors = @@ -1212,15 +1333,7 @@ mod tests { (6, vec![4], 4), (7, vec![5], 4), ] { - graph.commits.insert( - id(n), - gix::revwalk::graph::Commit { - parents: parents.into_iter().map(id).collect(), - commit_time: generation.into(), - generation: Some(generation), - data: Node::default(), - }, - ); + insert_commit(&mut graph, n, &parents, generation); } assert_eq!( @@ -1435,10 +1548,17 @@ mod tests { _ => None, }) .expect("history loading returns the persistent graph"); - let repo = gix::open(fixture.path())?; - let cached = graph.commits.get_mut(&main).expect("the tracking tip was scheduled"); - assert!(cached.data.complete && !cached.data.stored); - cached.parents.push(id(255)); + let repo = crate::open_test_repository(fixture.path())?; + let index = graph.index(main).expect("the tracking tip was scheduled"); + let fake_parent = graph.intern(id(255)).expect("the small test graph fits in u32"); + let mut parents = graph.parents(index).to_vec(); + parents.push(fake_parent); + let start = graph.parents.len() as u32; + graph.parents.extend(parents); + let end = graph.parents.len() as u32; + let cached = &mut graph.commits[index.as_usize()]; + assert!(cached.state & NODE_COMPLETE != 0 && cached.state & NODE_STORED == 0); + cached.parents = start..end; let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 27e908e903c..3a89ed54a37 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -22,8 +22,8 @@ use std::{ use anyhow::{Context, Result}; use app::{ - Action, App, ChangeGroup, ChangeKind, ChangePane, Changes, ChangesMode, CommitRow, ComparedParent, Effect, - PathChange, SelectionRelation, State, + Action, App, ChangeGroup, ChangeKind, ChangePane, Changes, ChangesMode, ComparedParent, Effect, PathChange, + SelectionRelation, SharedCommitRow, State, }; use crossterm::{ clipboard::CopyToClipboard, @@ -1407,7 +1407,7 @@ fn prepare_inline_exit(app: &mut App) { app.show_selection_tail = false; } -fn start_lane_worker(rows: Vec) -> mpsc::Receiver<(Vec, app::Graph, Duration)> { +fn start_lane_worker(rows: Vec) -> mpsc::Receiver<(Vec, app::Graph, Duration)> { let (sender, receiver) = mpsc::channel(); std::thread::spawn(move || { let _ = sender.send(app::compute_lanes(rows)); From 29ee7daeee1992d61e3272821b3996f63913fbef Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 8 Aug 2026 21:24:00 +0200 Subject: [PATCH 031/282] perf: cache recently viewed tree changes Keep a bounded MRU of immutable commit and merge-parent diff results while the changes view is open. Reuse changed paths, detached diff resources, and computed line counts when revisiting history without retaining unbounded data. Worktree changes remain separately cached and invalidated by filesystem notifications. --- gix-tix/src/lib.rs | 124 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 111 insertions(+), 13 deletions(-) diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 3a89ed54a37..7c5804bf111 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -8,6 +8,7 @@ mod logging; mod ui; use std::{ + collections::VecDeque, ffi::OsString, io::{self, Write}, path::{Path, PathBuf}, @@ -128,6 +129,42 @@ struct SelectionRelationCache { relation: Option, } +const TREE_CHANGES_CACHE_SIZE: usize = 8; +type TreeChangesEntry = (gix::ObjectId, usize, Changes); + +#[derive(Default)] +struct TreeChangesCache(VecDeque); + +impl TreeChangesCache { + fn as_ref(&self) -> Option<&TreeChangesEntry> { + self.0.front() + } + + fn activate(&mut self, id: gix::ObjectId, parent: usize) -> bool { + let Some(position) = self + .0 + .iter() + .position(|(cached_id, cached_parent, _)| *cached_id == id && *cached_parent == parent) + else { + return false; + }; + if position != 0 { + let entry = self.0.remove(position).expect("the cached position exists"); + self.0.push_front(entry); + } + true + } + + fn insert(&mut self, entry: TreeChangesEntry) { + self.0.push_front(entry); + self.0.truncate(TREE_CHANGES_CACHE_SIZE); + } + + fn clear(&mut self) { + self.0.clear(); + } +} + type LineCounts = Option<(u32, u32)>; #[derive(Clone, Debug, PartialEq)] @@ -728,7 +765,7 @@ fn event_loop( let mut refresh_expand_hidden = false; let mut verification_receiver = None; let mut commit_message = None; - let mut tree_changes = None; + let mut tree_changes = TreeChangesCache::default(); let mut worktree_changes = None; let mut worktree_watcher: Option = None; let mut worktree_refresh_deadline: Option = None; @@ -1604,7 +1641,7 @@ fn draw( authors: &SharedAuthors, fill_repository: &mut FillRepository, commit_message: &mut Option<(gix::ObjectId, BString)>, - tree_changes: &mut Option<(gix::ObjectId, usize, Changes)>, + tree_changes: &mut TreeChangesCache, worktree_changes: &mut Option<(usize, Changes)>, history_graph: &mut Option, selection_cache: &mut Option, @@ -1651,22 +1688,32 @@ fn draw( if changes_visible && selected.is_some() && tree_changes.as_ref().map(|(cached, _, _)| *cached) != selected { app.changes_parent = 0; } - let tree_changes_to_load = (changes_visible && app.changes_mode.is_some()) + let desired_tree_changes = (changes_visible && app.changes_mode.is_some()) .then_some(selected) .flatten() - .filter(|id| { - tree_changes - .as_ref() - .is_none_or(|(cached, parent, _)| *cached != *id || *parent != app.changes_parent) - }); + .map(|id| (id, app.changes_parent)); + let tree_changes_changed = desired_tree_changes.is_some_and(|(id, parent)| { + tree_changes + .as_ref() + .is_none_or(|(cached, cached_parent, _)| *cached != id || *cached_parent != parent) + }); + let tree_selection = tree_changes_changed + .then(|| remembered_change_selection(&app.tree_changes, tree_changes.as_ref().map(|(_, _, changes)| changes))) + .flatten(); + let tree_changes_to_load = desired_tree_changes + .filter(|(id, parent)| !tree_changes.activate(*id, *parent)) + .map(|(id, _)| id); + if tree_changes_changed + && tree_changes_to_load.is_none() + && let Some(changes) = tree_changes.as_ref().map(|(_, _, changes)| changes) + { + restore_change_selection(&mut app.tree_changes, changes, tree_selection.clone()); + } let worktree_changes_to_load = changes_visible && app.changes_mode == Some(ChangesMode::Both) && worktree_changes .as_ref() .is_none_or(|(marker, _)| *marker == usize::MAX); - let tree_selection = tree_changes_to_load.and_then(|_| { - remembered_change_selection(&app.tree_changes, tree_changes.as_ref().map(|(_, _, changes)| changes)) - }); let worktree_selection = worktree_changes_to_load .then(|| { remembered_change_selection( @@ -1679,7 +1726,7 @@ fn draw( *commit_message = None; } if app.changes_mode.is_none() { - *tree_changes = None; + tree_changes.clear(); *worktree_changes = None; } if let Some(id) = relation_to_load @@ -1750,7 +1797,7 @@ fn draw( let loaded = loaded?; app.changes_parent = loaded.parent.map_or(0, |parent| parent.index); restore_change_selection(&mut app.tree_changes, &loaded, tree_selection); - *tree_changes = Some((id, app.changes_parent, loaded)); + tree_changes.insert((id, app.changes_parent, loaded)); } if worktree_changes_to_load { let started = Instant::now(); @@ -2779,6 +2826,57 @@ fn open_test_repository(path: impl AsRef) -> Result Date: Sun, 9 Aug 2026 04:34:16 +0200 Subject: [PATCH 032/282] fix: retain history selection for worktree-only changes Decide whether a filesystem-triggered history refresh selects the top row only after comparing the watched traversal refs. Index and worktree notifications may still invalidate status data, but no longer move the history selection when the view and hidden refs are unchanged. Also avoid marking an already-running refresh for top selection before the pending notification has been classified. --- gix-tix/src/lib.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 7c5804bf111..5c319357a9c 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -880,9 +880,6 @@ fn event_loop( actionable += 1; refresh_pending = true; refresh_from_filesystem = true; - if refresh_receiver.is_some() { - refresh_select_top = true; - } if invalidate_worktree_changes(&mut worktree_changes) { dirty = true; urgent = true; @@ -1073,7 +1070,7 @@ fn event_loop( let hidden_changed = next.hidden != ref_snapshot.hidden; let tips_changed = next.view != ref_snapshot.view || hidden_changed; tracing::debug!(tips_changed, hidden_changed, "compared reference snapshot"); - let select_top = std::mem::take(&mut refresh_from_filesystem); + let select_top = std::mem::take(&mut refresh_from_filesystem) && tips_changed; ref_snapshot = next; refresh_pending = false; let hidden = if app.show_hidden { Vec::new() } else { hide.clone() }; From a123763865d0aa4f579406aee1e466632b194587 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 04:52:50 +0200 Subject: [PATCH 033/282] fix: delay background refresh status Keep the completed history footer stable for the first 500 ms of filesystem-triggered and manual refreshes. Reveal loading or computing state only when the combined traversal and lane work remains active beyond that threshold. Initial history loading keeps its immediate progress behavior, and the existing event-loop deadline mechanism provides the delayed redraw without polling. --- gix-tix/src/app.rs | 2 ++ gix-tix/src/lib.rs | 41 +++++++++++++++++++++++++++++---- gix-tix/src/ui.rs | 57 ++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 7839496d70f..667fb1c3266 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -320,6 +320,7 @@ pub(crate) struct App { pub selected: Option, pub offset: usize, pub state: State, + pub(crate) deferred_history_state: Option, pub viewport_rows: usize, pub lane_time: Option, pub show_committer_date: bool, @@ -385,6 +386,7 @@ impl App { selected: None, offset: 0, state: State::Loading, + deferred_history_state: None, viewport_rows, lane_time: None, show_committer_date: true, diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 5c319357a9c..21911d21c6a 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -49,6 +49,7 @@ use ratatui::{TerminalOptions, Viewport, backend::CrosstermBackend, text::Line}; const EVENT_BATCH_SIZE: usize = 256; const OBJECT_CACHE_SIZE: usize = 4 * 1024 * 1024; const FRAME_INTERVAL: Duration = Duration::from_nanos(16_666_667); +const HISTORY_STATUS_DELAY: Duration = Duration::from_millis(500); const REPEAT_IDLE: Duration = Duration::from_millis(75); const WORKTREE_EVENT_IDLE: Duration = Duration::from_millis(75); const IMMEDIATE_PAGER_EXIT: Duration = Duration::from_millis(250); @@ -823,6 +824,7 @@ fn event_loop( let mut history_finished = false; let mut focused = true; let mut repeat_deadline: Option = None; + let mut history_status_deadline: Option = None; let mut pending_terminal_event = None; let result: Result> = (|| loop { let mut worktree_watch_error = None; @@ -946,6 +948,11 @@ fn event_loop( schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); } } + if take_due(&mut history_status_deadline, Instant::now()) { + app.deferred_history_state = None; + dirty = true; + urgent = true; + } if repeat_deadline.is_some_and(|deadline| Instant::now() >= deadline) { repeat_deadline = None; if app.changes_suppressed { @@ -974,6 +981,8 @@ fn event_loop( match result { Ok((rows, graph, lane_time)) => { app.finish_lane_computation(rows, graph, lane_time); + history_status_deadline = None; + app.deferred_history_state = None; selection_relation = None; app.selection_relation = None; lane_receiver = None; @@ -1026,6 +1035,7 @@ fn event_loop( && history_graph.is_some() && matches!(app.state, State::Complete | State::Cancelled) { + let refresh_started = Instant::now(); let repository = match open_repository(&repository_path, repository_is_bare, true) { Ok(repository) => repository, Err(_err) if worktree_repository_is_gone(&repository_path) => { @@ -1092,6 +1102,8 @@ fn event_loop( )); refresh_select_top = select_top; refresh_expand_hidden = false; + app.deferred_history_state = Some(app.state); + history_status_deadline = Some(refresh_started + HISTORY_STATUS_DELAY); app.state = State::Loading; tracing::info!(select_top, "started history refresh"); } @@ -1202,10 +1214,18 @@ fn event_loop( .map(|deadline| deadline.saturating_duration_since(Instant::now())) .or_else(|| worktree_watcher.as_ref().map(|_| REF_EVENT_INTERVAL)); let retry_timeout = watcher_retry_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); - let wake_after = [repeat_timeout, watcher_timeout, worktree_timeout, retry_timeout] - .into_iter() - .flatten() - .min(); + let history_status_timeout = + history_status_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); + let wake_after = [ + repeat_timeout, + watcher_timeout, + worktree_timeout, + retry_timeout, + history_status_timeout, + ] + .into_iter() + .flatten() + .min(); let terminal_event = match pending_terminal_event.take() { Some(event) => Some(event), None => match poll_timeout(streaming, events, dirty, last_draw.elapsed(), wake_after) { @@ -3870,5 +3890,18 @@ mod tests { assert!(schedule_once(&mut deadline, now, WATCH_RETRY_INTERVAL)); assert!(!take_due(&mut deadline, now + Duration::from_secs(4))); assert!(take_due(&mut deadline, now + WATCH_RETRY_INTERVAL)); + + assert!( + schedule_once(&mut deadline, now, HISTORY_STATUS_DELAY), + "background progress gets its own deadline" + ); + assert!( + !take_due(&mut deadline, now + Duration::from_millis(499)), + "the completed footer remains visible before 500 ms" + ); + assert!( + take_due(&mut deadline, now + HISTORY_STATUS_DELAY), + "background progress becomes visible at 500 ms" + ); } } diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 8cb547b0bbc..61d2f532a65 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -586,7 +586,8 @@ pub(crate) fn draw_with_worktree( } } - let status = match app.state { + let history_state = app.deferred_history_state.unwrap_or(app.state); + let status = match history_state { State::Loading => "", State::Cancelling => " · cancelling", State::Computing => " · computing", @@ -646,7 +647,7 @@ pub(crate) fn draw_with_worktree( if app.preview_author_copy && app.manual_refresh { footer_spans.extend([ Span::raw(" · "), - toggle("R refresh", matches!(app.state, State::Complete | State::Cancelled)), + toggle("R refresh", matches!(history_state, State::Complete | State::Cancelled)), ]); } footer_spans.push(Span::raw(if app.preview_author_copy { @@ -668,7 +669,7 @@ pub(crate) fn draw_with_worktree( ]); } if app.changes_focus.is_none() { - if app.state == State::Loading { + if history_state == State::Loading { footer_spans.push(Span::raw(" · Esc cancel")); } footer_spans.push(Span::raw(" · q quit")); @@ -680,7 +681,7 @@ pub(crate) fn draw_with_worktree( } fn history_position(app: &App) -> String { - match (app.state, app.selected) { + match (app.deferred_history_state.unwrap_or(app.state), app.selected) { (State::Complete, Some(selected)) => format!("#{}", app.rows.len().saturating_sub(selected)), _ => format!("{} commits", app.rows.len()), } @@ -1453,6 +1454,54 @@ mod tests { assert_eq!(history_position(&app), "#1"); } + #[test] + fn keeps_the_completed_footer_while_background_progress_is_deferred() -> Result<(), Box> { + let mut app = App::new(1); + app.extend_commits(vec![Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }]); + complete(&mut app); + let mut terminal = Terminal::new(TestBackend::new(160, 2))?; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let completed = rendered_line(&terminal, 1); + + app.deferred_history_state = Some(State::Complete); + app.state = State::Computing; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!( + rendered_line(&terminal, 1), + completed, + "short lane computation preserves the completed footer" + ); + + app.deferred_history_state = None; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + let computing = rendered_line(&terminal, 1); + assert!( + computing.contains("1 commits · computing"), + "expired deferral reveals computation progress" + ); + assert_ne!(computing, completed, "visible progress changes the footer"); + + app.deferred_history_state = Some(State::Complete); + app.state = State::Loading; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert_eq!( + rendered_line(&terminal, 1), + completed, + "short traversal setup also preserves the completed footer" + ); + Ok(()) + } + #[test] fn renders_selection_info_beside_the_right_marker_without_dimming_it() -> Result<(), Box> { let mut app = App::new(2); From 205a53ed137abc664dc249402c586988af67f0a8 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 12:34:26 +0200 Subject: [PATCH 034/282] change!: always run tix in the alternate screen Make the alternate screen the only terminal lifecycle for tix. Remove the public Screen modes and the standalone --screen option, then initialize and restore the terminal directly through ratatui for every session. Delete the preliminary screen-size revision walk, inline height prediction, half-screen sizing, runtime transitions between terminal surfaces, inline resize handling, and the reduced static exit frame. Short histories now start exactly like long histories, retain the default tree and worktree changes view, and use the first terminal row instead of reserving inline spacers. Remove the now-unused bounded history counting helper and all tests dedicated to inline geometry and screen switching. Keep the existing alternate-screen rendering, external pager suspension, input setup, refresh animation, and terminal restoration paths unchanged. --- gix-tix/src/app.rs | 2 - gix-tix/src/history.rs | 36 ---- gix-tix/src/lib.rs | 407 ++--------------------------------------- gix-tix/src/main.rs | 47 +---- gix-tix/src/ui.rs | 24 +-- 5 files changed, 16 insertions(+), 500 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 667fb1c3266..ab4792b1b41 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -347,7 +347,6 @@ pub(crate) struct App { commit_page: usize, commit_max: usize, pub(crate) show_selection_tail: bool, - pub inline: bool, pub preview_author_copy: bool, reachability_anchor: Option, junction_parent: Option, @@ -413,7 +412,6 @@ impl App { commit_page: 1, commit_max: 0, show_selection_tail: true, - inline: false, preview_author_copy: false, reachability_anchor: None, junction_parent: None, diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index 9a8109652f6..3e743f8bb58 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -1086,30 +1086,6 @@ fn contains_agent_marker(message: &[u8]) -> bool { .any(|marker| message.windows(marker.len()).any(|window| window == *marker)) } -pub(crate) fn count_up_to( - repo: &gix::Repository, - revisions: &[OsString], - hidden_revisions: &[OsString], - limit: usize, -) -> Result { - let Some(tips) = resolve_tips(repo, revisions)? else { - return Ok(0); - }; - let hidden_tips = resolve_revisions(repo, hidden_revisions, "hidden ")?; - let walk = repo - .rev_walk(tips) - .with_hidden(hidden_tips) - .sorting(gix::revision::walk::Sorting::ByCommitTime(Default::default())) - .all() - .context("could not start revision walk")?; - let mut count = 0; - for info in walk.take(limit) { - info.context("could not traverse revision history")?; - count += 1; - } - Ok(count) -} - fn resolve_tips(repo: &gix::Repository, revisions: &[OsString]) -> Result>> { if revisions.is_empty() { repo.head() @@ -1717,18 +1693,6 @@ mod tests { [repo.rev_parse_single("topic^")?.detach()], "only the excluded parent directly connected to visible history is retained" ); - let revisions = [OsString::from("topic")]; - let hidden = [OsString::from("main")]; - assert_eq!( - count_up_to(&repo, &revisions, &hidden, 1)?, - actual.len().min(1), - "the screen-size probe stops at its limit" - ); - assert_eq!( - count_up_to(&repo, &revisions, &hidden, usize::MAX)?, - actual.len(), - "the screen-size probe uses the same hidden history" - ); assert!( matches!(events.last(), Some(Event::Complete(_))), "the filtered walk completes" diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 21911d21c6a..6bc3e3229a9 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -35,8 +35,8 @@ use crossterm::{ PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, - style::{Print, ResetColor}, - terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, + style::ResetColor, + terminal::{self, Clear, ClearType}, }; use gix::{ bstr::{BString, ByteSlice}, @@ -44,7 +44,7 @@ use gix::{ }; use history::{Authors, Decorations, Event, HistoryGraph, SelectionRef, SharedAuthors}; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use ratatui::{TerminalOptions, Viewport, backend::CrosstermBackend, text::Line}; +use ratatui::{backend::CrosstermBackend, text::Line}; const EVENT_BATCH_SIZE: usize = 256; const OBJECT_CACHE_SIZE: usize = 4 * 1024 * 1024; @@ -463,20 +463,6 @@ pub struct Options { pub quit_on_finish: bool, /// Revisions whose reachable commits should initially be hidden. pub hide: Vec, - /// How much of the terminal to use. - pub screen: Screen, -} - -/// How `gix-tix` occupies the terminal. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum Screen { - /// Use the main screen for short histories, otherwise the alternate screen. - #[default] - Auto, - /// Always use the alternate screen. - Always, - /// Use half of the main screen. - Half, } /// Run the interactive commit graph for `repository`. @@ -493,43 +479,14 @@ pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec, opti hidden_revision_count = options.hide.len(), "starting tix" ); - let terminal_height = match options.screen { - Screen::Always => 0, - Screen::Auto | Screen::Half => terminal::size().context("could not determine terminal size")?.1, - }; - let visible_commits = match options.screen { - Screen::Auto | Screen::Half => history::count_up_to( - &repository.to_thread_local(), - &revisions, - &options.hide, - half_height(terminal_height) as usize, - )?, - Screen::Always => 0, - }; - let inline_height = inline_height(options.screen, terminal_height, visible_commits); - let mut terminal = match inline_height { - Some(height) => ratatui::try_init_with_options(TerminalOptions { - viewport: Viewport::Inline(height), - }), - None => ratatui::try_init(), - } - .context("could not initialize terminal")?; + let mut terminal = ratatui::try_init().context("could not initialize terminal")?; let enhanced_keyboard = terminal::supports_keyboard_enhancement().unwrap_or(false); let keyboard_setup = enable_input(terminal.backend_mut(), enhanced_keyboard); let result = keyboard_setup .context("could not enable enhanced keyboard events") - .and_then(|()| { - event_loop( - &mut terminal, - repository, - revisions, - options, - inline_height.is_some(), - enhanced_keyboard, - ) - }); + .and_then(|()| event_loop(&mut terminal, repository, revisions, options, enhanced_keyboard)); let keyboard_restore = disable_input(terminal.backend_mut(), enhanced_keyboard); - let restore = restore_terminal(&mut terminal, inline_height.is_some()); + let restore = ratatui::try_restore().context("could not restore terminal"); let lane_time = result?; keyboard_restore.context("could not restore keyboard events")?; restore?; @@ -561,165 +518,14 @@ fn disable_input(backend: &mut CrosstermBackend, enhanced_keybo execute!(backend, DisableMouseCapture, DisableFocusChange) } -fn half_height(terminal_height: u16) -> u16 { - (terminal_height / 2).max(1) -} - -fn inline_height(screen: Screen, terminal_height: u16, visible_commits: usize) -> Option { - let half = half_height(terminal_height); - let compact = u16::try_from(visible_commits).unwrap_or(u16::MAX).saturating_add(3); - match screen { - Screen::Always => None, - Screen::Half => Some(compact.min(half)), - Screen::Auto if visible_commits < half as usize => Some(compact), - Screen::Auto => None, - } -} - -fn restore_terminal(terminal: &mut ratatui::DefaultTerminal, inline: bool) -> Result<()> { - if !inline { - return ratatui::try_restore().context("could not restore terminal"); - } - - let cursor = (|| { - let area = terminal.get_frame().area(); - let terminal_height = terminal.size()?.height; - execute!( - terminal.backend_mut(), - cursor::MoveTo(0, area.bottom().saturating_sub(1)), - Clear(ClearType::CurrentLine) - )?; - if area.bottom() < terminal_height { - execute!(terminal.backend_mut(), cursor::MoveTo(0, area.bottom())) - } else { - execute!( - terminal.backend_mut(), - cursor::MoveTo(0, terminal_height.saturating_sub(1)), - Print("\r\n") - ) - } - .and_then(|()| terminal.show_cursor()) - })(); - let raw_mode = terminal::disable_raw_mode(); - cursor.context("could not restore terminal cursor")?; - raw_mode.context("could not disable terminal raw mode")?; - Ok(()) -} - -fn enter_alternate_screen( - terminal: &mut ratatui::DefaultTerminal, - enhanced_keyboard: bool, -) -> std::io::Result { - disable_input(terminal.backend_mut(), enhanced_keyboard)?; - let alternate = ratatui::Terminal::new(CrosstermBackend::new(std::io::stdout()))?; - execute!(terminal.backend_mut(), EnterAlternateScreen)?; - let inline = std::mem::replace(terminal, alternate); - enable_input(terminal.backend_mut(), enhanced_keyboard)?; - Ok(inline) -} - -fn leave_alternate_screen( - terminal: &mut ratatui::DefaultTerminal, - inline: ratatui::DefaultTerminal, - enhanced_keyboard: bool, -) -> std::io::Result<()> { - disable_input(terminal.backend_mut(), enhanced_keyboard)?; - drop(std::mem::replace(terminal, inline)); - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - enable_input(terminal.backend_mut(), enhanced_keyboard)?; - terminal.hide_cursor() -} - -fn should_switch_screen(started_inline: bool, needs_alternate_screen: bool, in_alternate_screen: bool) -> bool { - started_inline && needs_alternate_screen != in_alternate_screen -} - -fn configure_initial_screen(app: &mut App, inline: bool) { - app.inline = inline; - if inline { - app.changes_mode = None; - } -} - -fn history_needs_alternate_screen(screen: Screen, terminal_height: u16, commits: usize) -> bool { - screen == Screen::Auto && inline_height(screen, terminal_height, commits).is_none() -} - -fn needs_alternate_screen( - show_panel: bool, - history_requires_alternate_screen: bool, - current_inline_height: Option, -) -> bool { - show_panel || history_requires_alternate_screen || current_inline_height.is_none() -} - -fn resize_inline_screen(terminal: &mut ratatui::DefaultTerminal, height: u16) -> std::io::Result<()> { - if terminal.get_frame().area().height == height { - return Ok(()); - } - let resized = ratatui::Terminal::with_options( - CrosstermBackend::new(std::io::stdout()), - TerminalOptions { - viewport: Viewport::Inline(height), - }, - )?; - drop(std::mem::replace(terminal, resized)); - terminal.hide_cursor() -} - -#[expect( - clippy::too_many_arguments, - reason = "screen transitions need the complete terminal state" -)] -fn sync_screen( - terminal: &mut ratatui::DefaultTerminal, - app: &mut App, - screen: Screen, - started_inline: bool, - history_requires_alternate_screen: bool, - resize_inline: bool, - inline_terminal: &mut Option, - enhanced_keyboard: bool, -) -> Result<()> { - let inline_height = inline_height(screen, terminal::size()?.1, app.rows.len()); - let needs_alternate_screen = needs_alternate_screen( - app.show_commit || app.changes_mode.is_some(), - history_requires_alternate_screen, - inline_height, - ); - if !should_switch_screen(started_inline, needs_alternate_screen, inline_terminal.is_some()) { - if let (true, Some(height)) = (started_inline && app.inline && resize_inline, inline_height) { - resize_inline_screen(terminal, height).context("could not resize the inline history")?; - } - return Ok(()); - } - if needs_alternate_screen { - *inline_terminal = - Some(enter_alternate_screen(terminal, enhanced_keyboard).context("could not enter the alternate screen")?); - app.inline = false; - } else if let Some(inline) = inline_terminal.take() { - leave_alternate_screen(terminal, inline, enhanced_keyboard).context("could not leave the alternate screen")?; - app.inline = true; - if let Some(height) = inline_height { - resize_inline_screen(terminal, height).context("could not resize the inline history")?; - } - } - Ok(()) -} - fn event_loop( terminal: &mut ratatui::DefaultTerminal, mut repository: gix::ThreadSafeRepository, revisions: Vec, options: Options, - started_inline: bool, enhanced_keyboard: bool, ) -> Result> { - let Options { - quit_on_finish, - hide, - screen, - } = options; + let Options { quit_on_finish, hide } = options; let mut repository_path = repository.git_dir().to_owned(); let common_dir = normalize_common_dir(repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()))?; let (mut view_repository, recovered_at_startup) = open_history_repository(&mut repository_path, &common_dir)?; @@ -780,7 +586,6 @@ fn event_loop( retained: None, retain: false, }; - configure_initial_screen(&mut app, started_inline); app.set_worktree_changes_available(!repository_is_bare); app.configure_hidden_filter(!hide.is_empty()); sync_line_diff_pool( @@ -818,9 +623,6 @@ fn event_loop( let mut last_draw = Instant::now(); let mut dirty = false; let mut urgent = false; - let mut inline_terminal = None; - let mut history_requires_alternate_screen = false; - let mut resize_inline_pending = false; let mut history_finished = false; let mut focused = true; let mut repeat_deadline: Option = None; @@ -986,9 +788,6 @@ fn event_loop( selection_relation = None; app.selection_relation = None; lane_receiver = None; - history_requires_alternate_screen = - history_needs_alternate_screen(screen, terminal::size()?.1, app.rows.len()); - resize_inline_pending = true; dirty = true; if quit_on_finish { return Ok(app.lane_time); @@ -1132,7 +931,6 @@ fn event_loop( continue; } let mut events = 0; - let mut resize_inline = std::mem::take(&mut resize_inline_pending); while !history_finished && events < EVENT_BATCH_SIZE { let message = match receiver.try_recv() { Ok(message) => message, @@ -1145,22 +943,9 @@ fn event_loop( dirty = true; match message? { Event::Decorations(value) => decorations = value, - Event::Commits(rows) => { - app.extend_commits(rows); - if history_needs_alternate_screen(screen, terminal::size()?.1, app.rows.len()) { - history_requires_alternate_screen = true; - } - } - Event::HiddenCommits(rows) => { - app.extend_hidden_commits(rows); - if history_needs_alternate_screen(screen, terminal::size()?.1, app.rows.len()) { - history_requires_alternate_screen = true; - } - } + Event::Commits(rows) => app.extend_commits(rows), + Event::HiddenCommits(rows) => app.extend_hidden_commits(rows), Event::VisibleComplete => { - resize_inline = true; - history_requires_alternate_screen = - history_needs_alternate_screen(screen, terminal::size()?.1, app.rows.len()); if let Some(rows) = app.start_lane_computation() { lane_receiver = Some(start_lane_worker(rows)); } @@ -1177,16 +962,6 @@ fn event_loop( } } } - sync_screen( - terminal, - &mut app, - screen, - started_inline, - history_requires_alternate_screen, - resize_inline, - &mut inline_terminal, - enhanced_keyboard, - )?; let streaming = matches!(app.state, State::Loading | State::Cancelling | State::Computing) || verification_receiver.is_some() || repeat_deadline.is_some(); @@ -1408,57 +1183,8 @@ fn event_loop( Effect::Quit => return Ok(None), } } - sync_screen( - terminal, - &mut app, - screen, - started_inline, - history_requires_alternate_screen, - false, - &mut inline_terminal, - enhanced_keyboard, - )?; })(); - let restore = inline_terminal - .map(|inline| leave_alternate_screen(terminal, inline, enhanced_keyboard)) - .transpose(); - restore.context("could not restore the inline terminal")?; - let outcome = result?; - if outcome.is_none() && started_inline { - prepare_inline_exit(&mut app); - sync_line_diff_pool( - &mut line_diff_pool, - false, - &repository_path, - repository_is_bare, - line_diff_parallelism, - )?; - draw( - terminal, - &mut app, - &decorations, - &mailmap, - &authors, - &mut fill_repository, - &mut commit_message, - &mut tree_changes, - &mut worktree_changes, - &mut history_graph, - &mut selection_relation, - &mut line_diff_pool, - )?; - } - Ok(outcome) -} - -fn prepare_inline_exit(app: &mut App) { - app.inline = true; - app.show_commit = false; - app.changes_mode = None; - app.changes_suppressed = false; - app.changes_focus = None; - app.reset_changes_view(); - app.show_selection_tail = false; + result } fn start_lane_worker(rows: Vec) -> mpsc::Receiver<(Vec, app::Graph, Duration)> { @@ -1664,11 +1390,7 @@ fn draw( selection_cache: &mut Option, line_diff_pool: &mut Option, ) -> Result<()> { - let render_rows = terminal - .get_frame() - .area() - .height - .saturating_sub(1 + 2 * u16::from(app.inline)) as usize; + let render_rows = terminal.get_frame().area().height.saturating_sub(1) as usize; if !history_is_ready_to_draw(app.state, app.rows.len()) { return Ok(()); } @@ -3415,95 +3137,6 @@ mod tests { Ok(()) } - #[test] - fn chooses_screen_from_terminal_and_history_height() { - assert_eq!( - inline_height(Screen::Auto, 20, 7), - Some(10), - "short histories occupy only their rows, spacers, and footer" - ); - assert_eq!( - inline_height(Screen::Auto, 20, 8), - Some(11), - "spacers do not force an otherwise short history into the alternate screen" - ); - assert_eq!( - inline_height(Screen::Auto, 20, 10), - None, - "the auto cutoff remains half the terminal height" - ); - assert_eq!( - inline_height(Screen::Half, 21, 3), - Some(6), - "half mode shrinks to the rows, spacers, and footer needed by short histories" - ); - assert_eq!( - inline_height(Screen::Half, 21, 10), - Some(10), - "half mode is capped at half the terminal, rounded down" - ); - assert_eq!( - inline_height(Screen::Half, 21, 0), - Some(3), - "an empty history only needs its spacers and footer" - ); - assert_eq!( - inline_height(Screen::Always, 20, 0), - None, - "always mode uses the alternate screen" - ); - } - - #[test] - fn switches_screens_for_inline_commit_panes_and_large_histories() { - let mut inline = App::new(1); - configure_initial_screen(&mut inline, true); - assert!(inline.inline); - assert_eq!( - inline.changes_mode, None, - "inline startup hides the default changes view" - ); - let mut alternate = App::new(1); - configure_initial_screen(&mut alternate, false); - assert!(!alternate.inline); - assert!( - alternate.changes_mode == Some(ChangesMode::Both), - "alternate-screen startup keeps the default tree and worktree changes view" - ); - - assert!( - should_switch_screen(true, true, false), - "opening the commit pane from inline mode enters the alternate screen" - ); - assert!( - should_switch_screen(true, false, true), - "closing the commit pane returns to inline mode" - ); - assert!( - !should_switch_screen(false, true, true), - "a session that started in the alternate screen stays there" - ); - assert!( - !should_switch_screen(true, true, true), - "an already-active alternate screen is not re-entered" - ); - assert!(!history_needs_alternate_screen(Screen::Auto, 20, 7)); - assert!(!history_needs_alternate_screen(Screen::Auto, 20, 8)); - assert!(history_needs_alternate_screen(Screen::Auto, 20, 10)); - assert!( - needs_alternate_screen(false, false, None), - "current terminal geometry overrides a stale history-fit flag" - ); - assert!( - !needs_alternate_screen(false, false, Some(11)), - "a fitting current layout may return to inline mode" - ); - assert!( - !history_needs_alternate_screen(Screen::Half, 20, usize::MAX), - "half-screen mode never switches because history grows" - ); - } - #[test] fn maps_navigation_and_control_c() { assert_eq!( @@ -3699,24 +3332,6 @@ mod tests { )); } - #[test] - fn prepares_a_reduced_selection_after_leaving_the_alternate_screen() { - let mut app = App::new(1); - app.show_commit = true; - app.changes_mode = Some(ChangesMode::Tree); - app.changes_focus = Some(ChangePane::Tree); - - prepare_inline_exit(&mut app); - - assert!(app.inline, "the final frame is drawn into the restored inline screen"); - assert!( - !app.show_commit && app.changes_mode.is_none(), - "alternate-screen panels are omitted from the final frame" - ); - assert_eq!(app.changes_focus, None, "the hidden panel no longer owns focus"); - assert!(!app.show_selection_tail, "only the left selection marker remains"); - } - #[test] fn copies_parsed_author_bytes_without_validation() { let author = app::Author { diff --git a/gix-tix/src/main.rs b/gix-tix/src/main.rs index 4c544e70c53..ef02db214a0 100644 --- a/gix-tix/src/main.rs +++ b/gix-tix/src/main.rs @@ -8,7 +8,7 @@ fn main() -> Result<()> { let (revisions, options, help) = arguments(gix::env::args_os().skip(1))?; if help { println!( - "Usage: tix [--quit-on-finish] [--screen MODE] [-h|--hide REVSPEC] [REVISION]...\n\nBrowse commits reachable from HEAD or the given revisions.\n\nOptions:\n -h, --hide REVSPEC Hide this revision and all commits reachable from it\n --screen MODE Use auto, always, or half screen mode [default: auto]\n --help Print help" + "Usage: tix [--quit-on-finish] [-h|--hide REVSPEC] [REVISION]...\n\nBrowse commits reachable from HEAD or the given revisions.\n\nOptions:\n -h, --hide REVSPEC Hide this revision and all commits reachable from it\n --help Print help" ); return Ok(()); } @@ -29,21 +29,6 @@ fn arguments(mut args: impl Iterator) -> Result<(Vec, break; } else if arg == "--quit-on-finish" { options.quit_on_finish = true; - } else if arg == "--screen" { - let value = args.next().context("--screen requires one of: auto, always, half")?; - if value == "--help" { - help = true; - break; - } - options.screen = match value { - value if value == "auto" => gix_tix::Screen::Auto, - value if value == "always" => gix_tix::Screen::Always, - value if value == "half" => gix_tix::Screen::Half, - value => anyhow::bail!( - "invalid --screen value {}; expected auto, always, or half", - value.display() - ), - }; } else if arg == "-h" || arg == "--hide" { let revision = args.next().context("-h/--hide requires a revision to hide")?; if revision == "--help" { @@ -70,23 +55,12 @@ mod tests { #[test] fn separates_options_from_revisions() -> Result<()> { let (revisions, options, help) = arguments( - [ - "--quit-on-finish", - "--screen", - "half", - "-h", - "main", - "--hide", - "tag", - "topic", - "--help", - ] - .into_iter() - .map(OsString::from), + ["--quit-on-finish", "-h", "main", "--hide", "tag", "topic", "--help"] + .into_iter() + .map(OsString::from), )?; assert!(options.quit_on_finish); - assert_eq!(options.screen, gix_tix::Screen::Half); assert_eq!(options.hide, ["main", "tag"], "both hide options are retained"); assert_eq!(revisions, ["topic"], "only positional revisions remain"); assert!(help, "--help remains available without claiming -h"); @@ -100,19 +74,6 @@ mod tests { "--help wins regardless of its position" ); } - assert_eq!( - arguments(std::iter::empty())?.1.screen, - gix_tix::Screen::Auto, - "auto is the default" - ); - assert!( - arguments(["--screen"].into_iter().map(OsString::from)).is_err(), - "a missing screen mode is rejected" - ); - assert!( - arguments(["--screen", "other"].into_iter().map(OsString::from)).is_err(), - "an unknown screen mode is rejected" - ); Ok(()) } } diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 61d2f532a65..4120b58a3a2 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -188,15 +188,7 @@ pub(crate) fn draw_with_worktree( tree_changes: Option<&Changes>, worktree_changes: Option<&Changes>, ) { - let [top_spacer, mut body, bottom_spacer, footer] = Layout::vertical([ - Constraint::Length(u16::from(app.inline)), - Constraint::Min(0), - Constraint::Length(u16::from(app.inline)), - Constraint::Length(1), - ]) - .areas(frame.area()); - frame.render_widget(Clear, top_spacer); - frame.render_widget(Clear, bottom_spacer); + let [mut body, footer] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area()); let full_body = body; let compared_parent = if app.changes_visible() { tree_changes.and_then(|changes| changes.parent.map(|parent| parent.id)) @@ -1892,20 +1884,6 @@ mod tests { } terminal.backend().assert_buffer(&expected); - app.inline = true; - let mut inline_terminal = Terminal::new(TestBackend::new(140, 4))?; - inline_terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; - assert!( - rendered_line(&inline_terminal, 0).trim().is_empty(), - "inline mode separates the commits from preceding content" - ); - assert!( - rendered_line(&inline_terminal, 2).trim().is_empty(), - "inline mode separates the commits from the status line" - ); - assert!(rendered_line(&inline_terminal, 3).starts_with("#1")); - app.inline = false; - let row = terminal.backend().buffer(); assert!( row[(10, 0)].modifier.contains(Modifier::REVERSED), From 56682804ea2cbac0c45e9c05f61258997cb76964 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 12:33:38 +0200 Subject: [PATCH 035/282] change!: remove gix tix screen selection Remove the plumbing CLI screen-mode option now that the interactive history always owns the alternate screen. The automatic and half-screen modes depended on inline rendering behavior that is being retired from gix-tix. Stop accepting --screen for gix tix and its aliases, and remove the mode validation and translation into gix-tix. Construct the reduced gix-tix options directly from the remaining quit and hidden-revision arguments. Keep a command-line regression assertion so the removed option cannot silently return as an ignored argument. --- src/plumbing/main.rs | 36 +++++++++--------------------------- src/plumbing/options/mod.rs | 3 --- 2 files changed, 9 insertions(+), 30 deletions(-) diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index e672cef82ce..10010dc3db6 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -161,26 +161,13 @@ pub fn main() -> Result<()> { Subcommands::Tix { help: _, quit_on_finish, - screen, hide, revisions, - } => { - let screen = match screen.as_str() { - "auto" => gix_tix::Screen::Auto, - "always" => gix_tix::Screen::Always, - "half" => gix_tix::Screen::Half, - value => anyhow::bail!("invalid screen mode: {value}"), - }; - gix_tix::run( - repository(Mode::Lenient)?.into_sync(), - revisions, - gix_tix::Options { - quit_on_finish, - hide, - screen, - }, - ) - } + } => gix_tix::run( + repository(Mode::Lenient)?.into_sync(), + revisions, + gix_tix::Options { quit_on_finish, hide }, + ), Subcommands::Env => prepare_and_run( "env", trace, @@ -1880,17 +1867,12 @@ mod tests { }; assert_eq!(hide, ["main", "tag"], "short and long hide options append"); assert_eq!(revisions, ["topic"], "positional revisions remain visible tips"); - let args = Args::try_parse_from(["gix", "tix", "--screen", "half"]).expect("the half-screen mode parses"); - let Subcommands::Tix { screen, .. } = args.cmd else { - panic!("tix arguments route to tix") - }; - assert_eq!(screen, "half", "the requested screen mode is retained"); assert_eq!( - Args::try_parse_from(["gix", "tix", "--screen", "other"]) - .expect_err("unknown screen modes are rejected") + Args::try_parse_from(["gix", "tix", "--screen", "half"]) + .expect_err("screen selection is no longer supported") .kind(), - clap::error::ErrorKind::InvalidValue, - "screen mode validation happens at the command line" + clap::error::ErrorKind::UnknownArgument, + "alternate-screen operation has no command-line mode" ); assert_eq!( Args::try_parse_from(["gix", "tix", "--help"]) diff --git a/src/plumbing/options/mod.rs b/src/plumbing/options/mod.rs index c253ff11b27..7bfe8608d64 100644 --- a/src/plumbing/options/mod.rs +++ b/src/plumbing/options/mod.rs @@ -178,9 +178,6 @@ pub enum Subcommands { /// Exit once all commits and graph lanes have been computed. #[clap(long)] quit_on_finish: bool, - /// Choose automatic, full alternate-screen, or compact half-screen display. - #[clap(long, value_name = "MODE", value_parser = ["auto", "always", "half"], default_value = "auto")] - screen: String, /// Hide this revision and every commit reachable from it. #[clap(short = 'h', long, value_name = "REVSPEC")] hide: Vec, From 41336a1e50277c82158b1a7c9b451157cd047aad Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 12:57:03 +0200 Subject: [PATCH 036/282] fix: refresh tix after reference transactions Wait for a short quiet period after filesystem notifications before reading traversal tips, so multi-step ref updates are observed after their final rename instead of at the temporary lock-file event. This makes newly created or pushed commits appear automatically. Keep Shift+R available as an unconditional explicit refresh, and cover loose-ref notification delivery with a real watcher test. --- gix-tix/src/app.rs | 3 +-- gix-tix/src/lib.rs | 60 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index ab4792b1b41..44a454e8856 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -695,7 +695,7 @@ impl App { RefMode::None => RefMode::All, }; } - Action::Refresh if self.manual_refresh && matches!(self.state, State::Complete | State::Cancelled) => { + Action::Refresh if matches!(self.state, State::Complete | State::Cancelled) => { return vec![Effect::Reload(self.show_hidden)]; } Action::ToggleHidden @@ -2419,7 +2419,6 @@ mod tests { #[test] fn refresh_reloads_only_finished_history() { let mut app = App::new(1); - app.manual_refresh = true; assert!( app.update(Action::Refresh).is_empty(), "a running walk cannot be replaced" diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 6bc3e3229a9..48876915bba 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -52,6 +52,7 @@ const FRAME_INTERVAL: Duration = Duration::from_nanos(16_666_667); const HISTORY_STATUS_DELAY: Duration = Duration::from_millis(500); const REPEAT_IDLE: Duration = Duration::from_millis(75); const WORKTREE_EVENT_IDLE: Duration = Duration::from_millis(75); +const REF_EVENT_IDLE: Duration = Duration::from_millis(100); const IMMEDIATE_PAGER_EXIT: Duration = Duration::from_millis(250); const REF_EVENT_INTERVAL: Duration = Duration::from_millis(250); const WATCH_RETRY_INTERVAL: Duration = Duration::from_secs(5); @@ -568,6 +569,7 @@ fn event_loop( let mut refresh_receiver: Option)>> = None; let mut refresh_pending = false; let mut refresh_from_filesystem = false; + let mut ref_refresh_deadline: Option = None; let mut refresh_select_top = false; let mut refresh_expand_hidden = false; let mut verification_receiver = None; @@ -682,12 +684,7 @@ fn event_loop( rescans += usize::from(event.need_rescan()); if notification_is_actionable(&event) { actionable += 1; - refresh_pending = true; - refresh_from_filesystem = true; - if invalidate_worktree_changes(&mut worktree_changes) { - dirty = true; - urgent = true; - } + ref_refresh_deadline = Some(Instant::now() + REF_EVENT_IDLE); } } Ok(Err(err)) => { @@ -704,9 +701,18 @@ fn event_loop( if let Some(err) = ref_watch_error { tracing::warn!(error = %err, "reference watcher failed"); ref_watcher = None; + ref_refresh_deadline = None; app.manual_refresh = true; schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); } + if take_due(&mut ref_refresh_deadline, Instant::now()) { + refresh_pending = true; + refresh_from_filesystem = true; + if invalidate_worktree_changes(&mut worktree_changes) { + dirty = true; + urgent = true; + } + } if take_due(&mut watcher_retry_deadline, Instant::now()) { let mut retry = false; if ref_watcher.is_none() { @@ -985,6 +991,8 @@ fn event_loop( } let repeat_timeout = repeat_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); let watcher_timeout = ref_watcher.as_ref().map(|_| REF_EVENT_INTERVAL); + let ref_refresh_timeout = + ref_refresh_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); let worktree_timeout = worktree_refresh_deadline .map(|deadline| deadline.saturating_duration_since(Instant::now())) .or_else(|| worktree_watcher.as_ref().map(|_| REF_EVENT_INTERVAL)); @@ -994,6 +1002,7 @@ fn event_loop( let wake_after = [ repeat_timeout, watcher_timeout, + ref_refresh_timeout, worktree_timeout, retry_timeout, history_status_timeout, @@ -2565,6 +2574,37 @@ fn open_test_repository(path: impl AsRef) -> Result gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = open_test_repository(fixture.path())?; + let watcher = start_ref_watcher(repository.git_dir(), repository.common_dir())?; + let topic = repository.rev_parse_single("topic")?.detach(); + let status = Command::new("git") + .current_dir(fixture.path()) + .args(["update-ref", "refs/heads/watched", &topic.to_hex().to_string()]) + .status()?; + assert!(status.success(), "git updates a loose reference"); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut paths = Vec::new(); + while Instant::now() < deadline { + let event = watcher + .events + .recv_timeout(deadline.saturating_duration_since(Instant::now()))??; + assert!(notification_is_actionable(&event), "the reference update is actionable"); + paths.extend(event.paths); + if paths.iter().any(|path| path.ends_with("refs/heads/watched")) { + break; + } + } + assert!( + paths.iter().any(|path| path.ends_with("refs/heads/watched")), + "the final loose reference is reported after its lock file: {paths:?}" + ); + Ok(()) + } + #[test] fn caches_recent_tree_changes_by_commit_and_parent() { let id = |value| { @@ -3518,5 +3558,13 @@ mod tests { take_due(&mut deadline, now + HISTORY_STATUS_DELAY), "background progress becomes visible at 500 ms" ); + + let last_event = now + Duration::from_millis(75); + deadline = Some(last_event + REF_EVENT_IDLE); + assert!( + !take_due(&mut deadline, now + REF_EVENT_IDLE), + "reference inspection waits for the final transaction event" + ); + assert!(take_due(&mut deadline, last_event + REF_EVENT_IDLE)); } } From 499da05857f5238d810b3a5a8c25875eb09471ab Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 12:02:08 +0200 Subject: [PATCH 037/282] feat: emphasize tix filesystem history changes Present completed filesystem refreshes immediately instead of interpolating between terminal buffers. Briefly bold only new or replaced visible history rows, then settle to the ordinary target after 180ms. Match rows by commit ID first and tree ID second so amended messages and authors retain their visual identity while a new object ID is emphasized. Keep duplicate-tree matches in display order, and load tree IDs only for changed visible commit sequences through a fresh cacheless repository. Leave removals and unrelated branch replacements immediate because neither has a useful on-screen anchor. Make show-hidden, hide-hidden, manual refresh, worktree-only updates, navigation, and pane changes immediate as well. Do not block refreshes, redraws, or input while emphasis is active. Any interaction clears the temporary styling, and a subsequent filesystem refresh replaces it from the last logical target. Snapshot every distinct frame for reword and new-top-commit changes with insta. Omit unchanged hold ticks and document the cargo-insta review workflow. --- Cargo.lock | 1 + gix-tix/AGENTS.md | 5 + gix-tix/Cargo.toml | 1 + gix-tix/src/animation.rs | 311 ++++++++++++++++++ gix-tix/src/lib.rs | 237 ++++++++++++- ...sts__filesystem_new_top_commit_frames.snap | 16 + ...tion__tests__filesystem_reword_frames.snap | 14 + gix-tix/src/ui.rs | 23 +- 8 files changed, 601 insertions(+), 7 deletions(-) create mode 100644 gix-tix/src/animation.rs create mode 100644 gix-tix/src/snapshots/gix_tix__animation__tests__filesystem_new_top_commit_frames.snap create mode 100644 gix-tix/src/snapshots/gix_tix__animation__tests__filesystem_reword_frames.snap diff --git a/Cargo.lock b/Cargo.lock index 366b40f9ee1..96f3b55fad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2599,6 +2599,7 @@ dependencies = [ "directories", "gix", "gix-testtools", + "insta", "notify", "ratatui", "tracing", diff --git a/gix-tix/AGENTS.md b/gix-tix/AGENTS.md index 9ba5c27f400..9e05df11802 100644 --- a/gix-tix/AGENTS.md +++ b/gix-tix/AGENTS.md @@ -13,3 +13,8 @@ - Open a fresh, non-isolated repository for bounded view population so configuration such as mailmap and diff filters is honored, then retain only detached display data. - The fill repository may be reused only while continuous navigation is active and must be dropped when its idle timer expires. - Filesystem watchers retain paths and native watcher handles, never repositories. + +## Filesystem emphasis snapshots + +- Transition tests use `insta` snapshots containing every distinct frame; unchanged hold frames are intentionally omitted. +- Run them with `cargo insta test -p gix-tix -F sha1`; review updates with `cargo insta review`. Never edit `.snap` files manually. diff --git a/gix-tix/Cargo.toml b/gix-tix/Cargo.toml index 6b4af3ca78b..ccce2b50334 100644 --- a/gix-tix/Cargo.toml +++ b/gix-tix/Cargo.toml @@ -35,6 +35,7 @@ tracing-subscriber = "0.3.17" [dev-dependencies] gix-testtools = { path = "../tests/tools" } +insta = "1.46.3" [package.metadata.docs.rs] features = ["sha1"] diff --git a/gix-tix/src/animation.rs b/gix-tix/src/animation.rs new file mode 100644 index 00000000000..568d81539bc --- /dev/null +++ b/gix-tix/src/animation.rs @@ -0,0 +1,311 @@ +use std::{collections::HashMap, time::Duration}; + +use gix::ObjectId; +use ratatui::{buffer::Buffer, layout::Rect, style::Modifier}; + +use crate::ui::FrameLayout; + +const EMPHASIS_DURATION: Duration = Duration::from_millis(180); + +#[derive(Clone, Debug)] +pub(crate) struct Row { + pub id: ObjectId, + pub tree: Option, + pub y: u16, +} + +#[derive(Clone, Debug)] +pub(crate) struct Snapshot { + pub buffer: Buffer, + pub layout: FrameLayout, + pub rows: Vec, +} + +impl Snapshot { + pub(crate) fn new(buffer: Buffer, layout: FrameLayout) -> Self { + let rows = layout + .rows + .iter() + .map(|(id, y)| Row { + id: *id, + tree: None, + y: *y, + }) + .collect(); + Snapshot { buffer, layout, rows } + } + + pub(crate) fn set_trees(&mut self, trees: &HashMap) { + for row in &mut self.rows { + row.tree = trees.get(&row.id).copied(); + } + } +} + +#[derive(Debug)] +pub(crate) struct Emphasis { + target: Snapshot, + displayed: Buffer, + remaining: Duration, +} + +impl Emphasis { + pub(crate) fn new(source: Snapshot, target: Snapshot) -> Option { + if source.buffer.area != target.buffer.area || source.buffer == target.buffer { + return None; + } + let matches = row_matches(&source.rows, &target.rows); + if matches.is_empty() { + return None; + } + let mut displayed = target.buffer.clone(); + for (target_index, target_row) in target.rows.iter().enumerate() { + let changed = matches + .iter() + .find(|(_, candidate)| *candidate == target_index) + .is_none_or(|(source_index, _)| source.rows[*source_index].id != target_row.id); + if changed && !covered_by_overlay(target_row.y, &target.layout.overlays) { + modify_row(&mut displayed, target.layout.history, target_row.y, Modifier::BOLD); + } + } + (displayed != target.buffer).then_some(Emphasis { + target, + displayed, + remaining: EMPHASIS_DURATION, + }) + } + + pub(crate) fn target(&self) -> &Snapshot { + &self.target + } + + pub(crate) fn displayed(&self) -> &Buffer { + &self.displayed + } + + pub(crate) fn timeout(&self) -> Duration { + self.remaining + } + + pub(crate) fn advance(&mut self, elapsed: Duration) -> Option<&Buffer> { + self.remaining = self.remaining.saturating_sub(elapsed); + if !self.is_complete() { + return None; + } + self.displayed.clone_from(&self.target.buffer); + Some(&self.displayed) + } + + pub(crate) fn is_complete(&self) -> bool { + self.remaining == Duration::ZERO + } +} + +fn row_matches(source: &[Row], target: &[Row]) -> Vec<(usize, usize)> { + let width = target.len() + 1; + let mut scores = vec![0u16; (source.len() + 1) * width]; + for source_index in (0..source.len()).rev() { + for target_index in (0..target.len()).rev() { + let same = row_score(&source[source_index], &target[target_index]); + scores[source_index * width + target_index] = if same > 0 { + (same + scores[(source_index + 1) * width + target_index + 1]) + .max(scores[(source_index + 1) * width + target_index]) + .max(scores[source_index * width + target_index + 1]) + } else { + scores[(source_index + 1) * width + target_index].max(scores[source_index * width + target_index + 1]) + }; + } + } + let mut out = Vec::new(); + let (mut source_index, mut target_index) = (0, 0); + while source_index < source.len() && target_index < target.len() { + let same = row_score(&source[source_index], &target[target_index]); + if same > 0 + && scores[source_index * width + target_index] + == same + scores[(source_index + 1) * width + target_index + 1] + { + out.push((source_index, target_index)); + source_index += 1; + target_index += 1; + } else if scores[(source_index + 1) * width + target_index] >= scores[source_index * width + target_index + 1] { + source_index += 1; + } else { + target_index += 1; + } + } + out +} + +fn row_score(source: &Row, target: &Row) -> u16 { + if source.id == target.id { + 2 + } else if source.tree.is_some() && source.tree == target.tree { + 1 + } else { + 0 + } +} + +fn covered_by_overlay(y: u16, overlays: &[Rect]) -> bool { + overlays.iter().any(|area| y >= area.y && y < area.bottom()) +} + +fn modify_row(buffer: &mut Buffer, area: Rect, y: u16, modifier: Modifier) { + if y < area.y || y >= area.bottom() { + return; + } + for x in area.x.max(buffer.area.x)..area.right().min(buffer.area.right()) { + buffer[(x, y)].modifier.insert(modifier); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(n: u8) -> ObjectId { + ObjectId::from_bytes_or_panic(&[n; 20]) + } + + fn snapshot(ids: &[u8], trees: &[u8]) -> Snapshot { + history_snapshot( + &ids.iter() + .zip(trees) + .map(|(id, tree)| (*id, *tree, id.to_string())) + .collect::>(), + ) + } + + fn history_snapshot(rows: &[(u8, u8, String)]) -> Snapshot { + let area = Rect::new(0, 0, 24, 4); + let mut buffer = Buffer::empty(area); + let rows = rows + .iter() + .enumerate() + .map(|(index, (id_value, tree, text))| { + for (x, symbol) in text.chars().take(area.width.into()).enumerate() { + buffer[(x as u16, index as u16)].set_char(symbol); + } + Row { + id: id(*id_value), + tree: Some(id(*tree)), + y: index as u16, + } + }) + .collect(); + Snapshot { + buffer, + layout: FrameLayout { + history: area, + ..FrameLayout::default() + }, + rows, + } + } + + fn distinct_frames(mut emphasis: Emphasis) -> String { + let first = emphasis.displayed().clone(); + assert!( + emphasis + .advance(EMPHASIS_DURATION.saturating_sub(Duration::from_nanos(1))) + .is_none(), + "hold ticks do not produce duplicate frames" + ); + let final_frame = emphasis + .advance(Duration::from_nanos(1)) + .expect("the emphasis settles after its hold") + .clone(); + [first, final_frame] + .iter() + .enumerate() + .map(|(index, frame)| format!("--- frame {index} ---\n{}", display_frame(frame))) + .collect::>() + .join("\n") + } + + fn display_frame(buffer: &Buffer) -> String { + let mut out = String::new(); + for y in buffer.area.y..buffer.area.bottom() { + let cells = (buffer.area.x..buffer.area.right()) + .map(|x| &buffer[(x, y)]) + .collect::>(); + let text = cells.iter().map(|cell| cell.symbol()).collect::(); + out.push_str(text.trim_end()); + out.push('\n'); + let modifiers = cells + .iter() + .map(|cell| { + if cell.modifier.contains(Modifier::BOLD) { + 'b' + } else { + ' ' + } + }) + .collect::(); + if !modifiers.trim().is_empty() { + out.push_str("style: "); + out.push_str(modifiers.trim_end()); + out.push('\n'); + } + } + out + } + + #[test] + fn rewritten_rows_match_by_tree_even_if_the_author_changed_with_the_id() { + let source = snapshot(&[1, 2], &[10, 20]); + let target = snapshot(&[3, 2], &[10, 20]); + assert_eq!(row_matches(&source.rows, &target.rows), [(0, 0), (1, 1)]); + } + + #[test] + fn duplicate_trees_are_matched_in_relative_order() { + let source = snapshot(&[1, 2, 3], &[10, 10, 20]); + let target = snapshot(&[4, 5, 3], &[10, 10, 20]); + assert_eq!(row_matches(&source.rows, &target.rows), [(0, 0), (1, 1), (2, 2)]); + } + + #[test] + fn removals_and_unrelated_replacements_are_immediate() { + let source = snapshot(&[1, 2], &[10, 20]); + let removal = snapshot(&[2], &[20]); + assert!( + Emphasis::new(source, removal).is_none(), + "a removal has no new row to emphasize" + ); + + let source = snapshot(&[1], &[10]); + let unrelated = snapshot(&[2], &[20]); + assert!( + Emphasis::new(source, unrelated).is_none(), + "an unrelated history has no visual anchor" + ); + } + + #[test] + fn filesystem_reword_frames() { + let source = history_snapshot(&[(1, 10, "111 old title".into()), (2, 20, "222 unchanged".into())]); + let target = history_snapshot(&[(3, 10, "333 new title".into()), (2, 20, "222 unchanged".into())]); + insta::assert_snapshot!(distinct_frames( + Emphasis::new(source, target).expect("a reword is emphasized") + )); + } + + #[test] + fn filesystem_new_top_commit_frames() { + let source = history_snapshot(&[ + (1, 10, "111 first".into()), + (2, 20, "222 second".into()), + (3, 30, "333 third".into()), + ]); + let target = history_snapshot(&[ + (4, 40, "444 new commit".into()), + (1, 10, "111 first".into()), + (2, 20, "222 second".into()), + (3, 30, "333 third".into()), + ]); + insta::assert_snapshot!(distinct_frames( + Emphasis::new(source, target).expect("a new top commit is emphasized") + )); + } +} diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 48876915bba..6706fe77b13 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -2,13 +2,14 @@ #![forbid(unsafe_code)] +mod animation; mod app; mod history; mod logging; mod ui; use std::{ - collections::VecDeque, + collections::{HashMap, VecDeque}, ffi::OsString, io::{self, Write}, path::{Path, PathBuf}, @@ -131,6 +132,123 @@ struct SelectionRelationCache { relation: Option, } +#[derive(Default)] +struct MotionState { + shown: Option, + pending: Option, + active: Option, + last_tick: Option, +} + +impl MotionState { + fn capture(&mut self) { + if self.pending.is_none() { + let source = self + .active + .as_ref() + .map(|emphasis| emphasis.target().clone()) + .or_else(|| self.shown.clone()); + if let Some(source) = source { + self.pending = Some(source); + } + } + } + + fn has_pending(&self) -> bool { + self.pending.is_some() + } + + fn transition_ids(&self, target: &animation::Snapshot) -> Vec { + let Some(source) = &self.pending else { + return Vec::new(); + }; + if source + .rows + .iter() + .map(|row| row.id) + .eq(target.rows.iter().map(|row| row.id)) + { + return Vec::new(); + } + source.rows.iter().chain(&target.rows).map(|row| row.id).collect() + } + + fn begin( + &mut self, + target: animation::Snapshot, + trees: &HashMap, + now: Instant, + ) -> Option { + let Some(mut source) = self.pending.take() else { + self.shown = Some(target); + return None; + }; + if source.buffer.area != target.buffer.area || source.buffer == target.buffer { + self.shown = Some(target); + self.last_tick = None; + return None; + } + source.set_trees(trees); + let mut target = target; + target.set_trees(trees); + let Some(emphasis) = animation::Emphasis::new(source, target.clone()) else { + self.shown = Some(target); + self.active = None; + self.last_tick = None; + return None; + }; + let displayed = emphasis.displayed().clone(); + self.active = Some(emphasis); + self.last_tick = Some(now); + Some(displayed) + } + + fn timeout(&self, now: Instant) -> Option { + self.active.as_ref().map(|emphasis| { + let since_tick = self + .last_tick + .map_or(Duration::ZERO, |last_tick| now.saturating_duration_since(last_tick)); + emphasis.timeout().saturating_sub(since_tick) + }) + } + + fn advance(&mut self, now: Instant) -> Option { + let elapsed = self + .last_tick + .replace(now) + .map_or(Duration::ZERO, |previous| now.saturating_duration_since(previous)); + let emphasis = self.active.as_mut()?; + let frame = emphasis.advance(elapsed).cloned(); + if emphasis.is_complete() { + self.shown = Some(emphasis.target().clone()); + self.active = None; + self.last_tick = None; + } + frame + } + + fn finish(&mut self) -> Option { + let emphasis = self.active.take()?; + let target = emphasis.target().clone(); + let buffer = target.buffer.clone(); + self.shown = Some(target); + self.last_tick = None; + Some(buffer) + } + + fn show(&mut self, target: animation::Snapshot) -> ratatui::buffer::Buffer { + self.active = None; + self.last_tick = None; + let buffer = target.buffer.clone(); + self.shown = Some(target); + buffer + } + + fn cancel_pending(&mut self) { + self.pending = None; + } +} + const TREE_CHANGES_CACHE_SIZE: usize = 8; type TreeChangesEntry = (gix::ObjectId, usize, Changes); @@ -608,6 +726,7 @@ fn event_loop( } } let mut decorations = Decorations::new(); + let mut motion = MotionState::default(); draw( terminal, &mut app, @@ -621,6 +740,7 @@ fn event_loop( &mut history_graph, &mut selection_relation, &mut line_diff_pool, + &mut motion, )?; let mut last_draw = Instant::now(); let mut dirty = false; @@ -884,8 +1004,14 @@ fn event_loop( let next = history::snapshot(&repository, &revisions, &hide)?; let hidden_changed = next.hidden != ref_snapshot.hidden; let tips_changed = next.view != ref_snapshot.view || hidden_changed; + let from_filesystem = std::mem::take(&mut refresh_from_filesystem); + if tips_changed && from_filesystem { + motion.capture(); + } else { + motion.cancel_pending(); + } tracing::debug!(tips_changed, hidden_changed, "compared reference snapshot"); - let select_top = std::mem::take(&mut refresh_from_filesystem) && tips_changed; + let select_top = from_filesystem && tips_changed; ref_snapshot = next; refresh_pending = false; let hidden = if app.show_hidden { Vec::new() } else { hide.clone() }; @@ -912,6 +1038,13 @@ fn event_loop( app.state = State::Loading; tracing::info!(select_top, "started history refresh"); } + let now = Instant::now(); + if motion.timeout(now) == Some(Duration::ZERO) + && let Some(frame) = motion.advance(now) + { + present_buffer(terminal, &frame)?; + last_draw = now; + } if urgent { draw( terminal, @@ -926,6 +1059,7 @@ fn event_loop( &mut history_graph, &mut selection_relation, &mut line_diff_pool, + &mut motion, )?; last_draw = Instant::now(); dirty = false; @@ -985,6 +1119,7 @@ fn event_loop( &mut history_graph, &mut selection_relation, &mut line_diff_pool, + &mut motion, )?; last_draw = Instant::now(); dirty = false; @@ -999,6 +1134,7 @@ fn event_loop( let retry_timeout = watcher_retry_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); let history_status_timeout = history_status_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); + let animation_timeout = motion.timeout(Instant::now()); let wake_after = [ repeat_timeout, watcher_timeout, @@ -1006,6 +1142,7 @@ fn event_loop( worktree_timeout, retry_timeout, history_status_timeout, + animation_timeout, ] .into_iter() .flatten() @@ -1049,6 +1186,10 @@ fn event_loop( (Some(action), repeats_history, true, true) } TerminalEvent::FocusLost => { + if let Some(frame) = motion.finish() { + present_buffer(terminal, &frame)?; + } + motion.cancel_pending(); focused = false; app.changes_suppressed = false; repeat_deadline = None; @@ -1062,12 +1203,23 @@ fn event_loop( continue; } TerminalEvent::Resize(_, _) => { + if let Some(frame) = motion.finish() { + present_buffer(terminal, &frame)?; + } + motion.cancel_pending(); dirty = true; urgent = true; continue; } _ => continue, }; + if action.as_ref().is_some_and(|action| action != &Action::ForceQuit) { + if let Some(frame) = motion.finish() { + present_buffer(terminal, &frame)?; + last_draw = Instant::now(); + } + motion.cancel_pending(); + } if !focused { continue; } @@ -1398,6 +1550,7 @@ fn draw( history_graph: &mut Option, selection_cache: &mut Option, line_diff_pool: &mut Option, + motion: &mut MotionState, ) -> Result<()> { let render_rows = terminal.get_frame().area().height.saturating_sub(1) as usize; if !history_is_ready_to_draw(app.state, app.rows.len()) { @@ -1590,17 +1743,89 @@ fn draw( let message = commit_message.as_ref().map(|(_, message)| message.as_bstr()); let tree_changes = tree_changes.as_ref().map(|(_, _, changes)| changes); let worktree_changes = worktree_changes.as_ref().map(|(_, changes)| changes); - terminal.draw(|frame| { + terminal + .autoresize() + .context("could not resize the terminal before drawing")?; + let layout = { + let mut frame = terminal.get_frame(); ui::draw_with_worktree( - frame, + &mut frame, app, decorations, mailmap, message, tree_changes, worktree_changes, - ); - })?; + ) + }; + let target = animation::Snapshot::new(terminal.current_buffer_mut().clone(), layout); + let ready = matches!(app.state, State::Complete | State::Cancelled); + let presented = if motion.has_pending() && ready { + let ids = motion.transition_ids(&target); + let trees = load_transition_trees(&fill_repository.path, fill_repository.bare, &ids); + motion + .begin(target.clone(), &trees, Instant::now()) + .unwrap_or_else(|| target.buffer.clone()) + } else { + motion.show(target) + }; + terminal.current_buffer_mut().clone_from(&presented); + terminal + .apply_buffer_with_cursor(None) + .context("could not draw terminal frame")?; + Ok(()) +} + +fn load_transition_trees( + repository_path: &Path, + bare: bool, + ids: &[gix::ObjectId], +) -> HashMap { + if ids.is_empty() { + return HashMap::new(); + } + let loaded = (|| -> Result<_> { + let mut repository = open_repository(repository_path, bare, true)?; + repository.object_cache_size(None); + let cache = repository + .commit_graph_if_enabled() + .context("could not open commit-graph for transition matching")?; + let mut buf = Vec::new(); + let mut trees = HashMap::with_capacity(ids.len()); + for id in ids { + let commit = match gix::traverse::commit::find(cache.as_ref(), &repository.objects, id, &mut buf) { + Ok(commit) => commit, + Err(err) => { + tracing::debug!(%id, error = %err, "could not load transition commit"); + continue; + } + }; + match commit.tree_id() { + Ok(tree) => { + trees.insert(*id, tree); + } + Err(err) => tracing::debug!(%id, error = %err, "could not decode transition commit tree"), + } + } + Ok(trees) + })(); + match loaded { + Ok(trees) => trees, + Err(err) => { + tracing::warn!(error = %err, "transition tree matching unavailable"); + HashMap::new() + } + } +} + +fn present_buffer(terminal: &mut ratatui::DefaultTerminal, buffer: &ratatui::buffer::Buffer) -> Result<()> { + if terminal.get_frame().area() != buffer.area { + return Ok(()); + } + terminal.current_buffer_mut().clone_from(buffer); + terminal + .apply_buffer_with_cursor(None) + .context("could not draw animation frame")?; Ok(()) } diff --git a/gix-tix/src/snapshots/gix_tix__animation__tests__filesystem_new_top_commit_frames.snap b/gix-tix/src/snapshots/gix_tix__animation__tests__filesystem_new_top_commit_frames.snap new file mode 100644 index 00000000000..0d05fc9416b --- /dev/null +++ b/gix-tix/src/snapshots/gix_tix__animation__tests__filesystem_new_top_commit_frames.snap @@ -0,0 +1,16 @@ +--- +source: gix-tix/src/animation.rs +expression: "distinct_frames(Emphasis::new(source,\ntarget).expect(\"a new top commit is emphasized\"))" +--- +--- frame 0 --- +444 new commit +style: bbbbbbbbbbbbbbbbbbbbbbbb +111 first +222 second +333 third + +--- frame 1 --- +444 new commit +111 first +222 second +333 third diff --git a/gix-tix/src/snapshots/gix_tix__animation__tests__filesystem_reword_frames.snap b/gix-tix/src/snapshots/gix_tix__animation__tests__filesystem_reword_frames.snap new file mode 100644 index 00000000000..8fe780ebdce --- /dev/null +++ b/gix-tix/src/snapshots/gix_tix__animation__tests__filesystem_reword_frames.snap @@ -0,0 +1,14 @@ +--- +source: gix-tix/src/animation.rs +expression: "distinct_frames(Emphasis::new(source,\ntarget).expect(\"a reword is emphasized\"))" +--- +--- frame 0 --- +333 new title +style: bbbbbbbbbbbbbbbbbbbbbbbb +222 unchanged + + + +--- frame 1 --- +333 new title +222 unchanged diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 4120b58a3a2..fa9ae0bb544 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -27,6 +27,13 @@ struct ChangesPaneArea { outer: Rect, } +#[derive(Clone, Debug, Default)] +pub(crate) struct FrameLayout { + pub history: Rect, + pub overlays: Vec, + pub rows: Vec<(gix::ObjectId, u16)>, +} + fn changes_pane_areas( area: Rect, max_height: u16, @@ -187,7 +194,7 @@ pub(crate) fn draw_with_worktree( commit_message: Option<&BStr>, tree_changes: Option<&Changes>, worktree_changes: Option<&Changes>, -) { +) -> FrameLayout { let [mut body, footer] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area()); let full_body = body; let compared_parent = if app.changes_visible() { @@ -342,6 +349,11 @@ pub(crate) fn draw_with_worktree( let selection_info_width = selection_info.width(); let mut selection_info_area = None; + let rows = visible_rows + .iter() + .enumerate() + .map(|(index, row)| (row.id, body.y.saturating_add(index as u16))) + .collect(); for (index, metadata) in metadata.into_iter().enumerate() { let lane = lanes.lane(index); let y = body.y.saturating_add(index as u16); @@ -670,6 +682,15 @@ pub(crate) fn draw_with_worktree( footer_spans = vec![Span::raw(notice)]; } frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer); + FrameLayout { + history: body, + overlays: changes_panes + .iter() + .map(|pane| pane.outer) + .chain(commit_pane.map(|(outer, _)| outer)) + .collect(), + rows, + } } fn history_position(app: &App) -> String { From fa377d4bcf36395a9ca05d2f54bbe6bd0ea42b3c Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 06:57:33 +0200 Subject: [PATCH 038/282] feat: correlate tix filesystem responses in diagnostics Assign a monotonically increasing response ID when the first actionable reference or relevant worktree notification arrives. Coalesce subsequent events under that ID until the existing debounce expires, and record batch counts, event-kind counts, rescans, and semantic triggers for HEAD, index, packed refs, loose refs, Git metadata, and worktree paths. Include up to sixteen deduplicated trigger paths and report how many additional unique paths were omitted. Recognize transaction lock files as the corresponding HEAD, index, or packed-refs trigger so the initiating Git operation remains clear. Carry response IDs through worktree invalidation, reference comparison, history refresh, lane computation, delayed status display, and history emphasis. Attribute every filesystem-caused presentation to all accumulated reasons, allowing multiple responses and phases to identify a single coalesced frame. Finish each response with its elapsed time, presentation count, and outcome, including superseded or interrupted emphasis and watcher failures. Keep keyboard and mouse redraws out of the trace except when they terminate an active filesystem emphasis. Preserve existing refresh and redraw behavior so diagnostics expose the current cause-and-effect chain without changing it. Cover trigger classification, batch coalescing, bounded path collection, rescans, new response IDs after debounce, and overlapping causes in one frame. --- gix-tix/src/lib.rs | 96 +++++++-- gix-tix/src/logging.rs | 441 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 523 insertions(+), 14 deletions(-) diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 6706fe77b13..c166d7c9bb4 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -727,6 +727,7 @@ fn event_loop( } let mut decorations = Decorations::new(); let mut motion = MotionState::default(); + let mut filesystem_responses = logging::FilesystemResponses::default(); draw( terminal, &mut app, @@ -741,6 +742,7 @@ fn event_loop( &mut selection_relation, &mut line_diff_pool, &mut motion, + &mut filesystem_responses, )?; let mut last_draw = Instant::now(); let mut dirty = false; @@ -763,6 +765,7 @@ fn event_loop( rescans += usize::from(event.need_rescan()); if watcher.event_is_relevant(&event) { relevant += 1; + filesystem_responses.observe_worktree(&event, &watcher.workdir, &watcher.index); schedule_once(&mut worktree_refresh_deadline, Instant::now(), WORKTREE_EVENT_IDLE); } } @@ -774,11 +777,15 @@ fn event_loop( } } if received > 0 { + if relevant > 0 { + filesystem_responses.note_worktree_batch(); + } tracing::debug!(received, relevant, rescans, "processed worktree event batch"); } } if let Some(err) = worktree_watch_error { tracing::warn!(error = %err, "worktree watcher failed"); + filesystem_responses.fail_pending_worktree(); app.worktree_changes.error = Some(format!("worktree watch: {err}")); worktree_watcher = None; worktree_refresh_deadline = None; @@ -788,6 +795,7 @@ fn event_loop( } if take_due(&mut worktree_refresh_deadline, Instant::now()) { let invalidated = invalidate_worktree_changes(&mut worktree_changes); + filesystem_responses.worktree_due(invalidated); tracing::debug!(invalidated, "worktree event deadline elapsed"); dirty = true; urgent = true; @@ -804,6 +812,7 @@ fn event_loop( rescans += usize::from(event.need_rescan()); if notification_is_actionable(&event) { actionable += 1; + filesystem_responses.observe_references(&event, &repository_path, &common_dir); ref_refresh_deadline = Some(Instant::now() + REF_EVENT_IDLE); } } @@ -815,20 +824,28 @@ fn event_loop( } } if received > 0 { + if actionable > 0 { + filesystem_responses.note_reference_batch(); + } tracing::debug!(received, actionable, rescans, "processed reference event batch"); } } if let Some(err) = ref_watch_error { tracing::warn!(error = %err, "reference watcher failed"); + filesystem_responses.fail_pending_references(); ref_watcher = None; ref_refresh_deadline = None; app.manual_refresh = true; schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); } if take_due(&mut ref_refresh_deadline, Instant::now()) { + let response_ids = filesystem_responses.references_due(); refresh_pending = true; refresh_from_filesystem = true; - if invalidate_worktree_changes(&mut worktree_changes) { + let invalidated = invalidate_worktree_changes(&mut worktree_changes); + filesystem_responses.phase(&response_ids, "reference-worktree-cache-invalidation"); + if invalidated { + filesystem_responses.queue_frame(&response_ids, "reference-worktree-cache-invalidation"); dirty = true; urgent = true; } @@ -878,6 +895,8 @@ fn event_loop( } if take_due(&mut history_status_deadline, Instant::now()) { app.deferred_history_state = None; + let response_ids = filesystem_responses.active_reference_ids().to_vec(); + filesystem_responses.queue_frame(&response_ids, "delayed-history-status"); dirty = true; urgent = true; } @@ -909,6 +928,10 @@ fn event_loop( match result { Ok((rows, graph, lane_time)) => { app.finish_lane_computation(rows, graph, lane_time); + let response_ids = filesystem_responses.active_reference_ids().to_vec(); + filesystem_responses.phase(&response_ids, "lane-computation-completed"); + filesystem_responses.queue_frame(&response_ids, "lane-computation-completed"); + filesystem_responses.finish_after_frame(&response_ids, "completed"); history_status_deadline = None; app.deferred_history_state = None; selection_relation = None; @@ -931,6 +954,9 @@ fn event_loop( history_graph = Some(graph); let result = result?; tracing::info!(commit_count = result.commits.rows.len(), "history refresh completed"); + let response_ids = filesystem_responses.active_reference_ids().to_vec(); + filesystem_responses.phase(&response_ids, "history-refresh-completed"); + filesystem_responses.queue_frame(&response_ids, "history-refresh-completed"); decorations = result.decorations; selection_relation = None; app.selection_relation = None; @@ -961,6 +987,7 @@ fn event_loop( && matches!(app.state, State::Complete | State::Cancelled) { let refresh_started = Instant::now(); + let response_ids = filesystem_responses.begin_reference_refresh(); let repository = match open_repository(&repository_path, repository_is_bare, true) { Ok(repository) => repository, Err(_err) if worktree_repository_is_gone(&repository_path) => { @@ -977,6 +1004,7 @@ fn event_loop( app.set_worktree_changes_available(false); worktree_watcher = None; worktree_refresh_deadline = None; + filesystem_responses.cancel_pending_worktree("worktree-unavailable"); worktree_changes = None; line_diff_pool = None; sync_line_diff_pool( @@ -1010,7 +1038,12 @@ fn event_loop( } else { motion.cancel_pending(); } - tracing::debug!(tips_changed, hidden_changed, "compared reference snapshot"); + tracing::debug!( + ?response_ids, + tips_changed, + hidden_changed, + "compared reference snapshot" + ); let select_top = from_filesystem && tips_changed; ref_snapshot = next; refresh_pending = false; @@ -1036,13 +1069,19 @@ fn event_loop( app.deferred_history_state = Some(app.state); history_status_deadline = Some(refresh_started + HISTORY_STATUS_DELAY); app.state = State::Loading; - tracing::info!(select_top, "started history refresh"); + filesystem_responses.phase(&response_ids, "history-refresh-started"); + tracing::info!(?response_ids, select_top, "started history refresh"); } let now = Instant::now(); if motion.timeout(now) == Some(Duration::ZERO) && let Some(frame) = motion.advance(now) { - present_buffer(terminal, &frame)?; + if present_buffer(terminal, &frame)? { + filesystem_responses.emphasis_finished("history-emphasis-settled", "completed"); + filesystem_responses.frame_presented(); + } else { + filesystem_responses.emphasis_aborted("terminal-area-changed"); + } last_draw = now; } if urgent { @@ -1060,6 +1099,7 @@ fn event_loop( &mut selection_relation, &mut line_diff_pool, &mut motion, + &mut filesystem_responses, )?; last_draw = Instant::now(); dirty = false; @@ -1120,6 +1160,7 @@ fn event_loop( &mut selection_relation, &mut line_diff_pool, &mut motion, + &mut filesystem_responses, )?; last_draw = Instant::now(); dirty = false; @@ -1187,7 +1228,12 @@ fn event_loop( } TerminalEvent::FocusLost => { if let Some(frame) = motion.finish() { - present_buffer(terminal, &frame)?; + if present_buffer(terminal, &frame)? { + filesystem_responses.emphasis_finished("emphasis-interrupted-by-focus", "interrupted-by-focus"); + filesystem_responses.frame_presented(); + } else { + filesystem_responses.emphasis_aborted("terminal-area-changed"); + } } motion.cancel_pending(); focused = false; @@ -1204,7 +1250,13 @@ fn event_loop( } TerminalEvent::Resize(_, _) => { if let Some(frame) = motion.finish() { - present_buffer(terminal, &frame)?; + if present_buffer(terminal, &frame)? { + filesystem_responses + .emphasis_finished("emphasis-interrupted-by-resize", "interrupted-by-resize"); + filesystem_responses.frame_presented(); + } else { + filesystem_responses.emphasis_aborted("terminal-area-changed"); + } } motion.cancel_pending(); dirty = true; @@ -1215,7 +1267,12 @@ fn event_loop( }; if action.as_ref().is_some_and(|action| action != &Action::ForceQuit) { if let Some(frame) = motion.finish() { - present_buffer(terminal, &frame)?; + if present_buffer(terminal, &frame)? { + filesystem_responses.emphasis_finished("emphasis-interrupted-by-input", "interrupted-by-input"); + filesystem_responses.frame_presented(); + } else { + filesystem_responses.emphasis_aborted("terminal-area-changed"); + } last_draw = Instant::now(); } motion.cancel_pending(); @@ -1289,6 +1346,7 @@ fn event_loop( } else if previous_changes_mode == Some(ChangesMode::Both) { worktree_watcher = None; worktree_refresh_deadline = None; + filesystem_responses.cancel_pending_worktree("watcher-disabled"); } } for effect in effects { @@ -1551,6 +1609,7 @@ fn draw( selection_cache: &mut Option, line_diff_pool: &mut Option, motion: &mut MotionState, + filesystem_responses: &mut logging::FilesystemResponses, ) -> Result<()> { let render_rows = terminal.get_frame().area().height.saturating_sub(1) as usize; if !history_is_ready_to_draw(app.state, app.rows.len()) { @@ -1763,16 +1822,25 @@ fn draw( let presented = if motion.has_pending() && ready { let ids = motion.transition_ids(&target); let trees = load_transition_trees(&fill_repository.path, fill_repository.bare, &ids); - motion - .begin(target.clone(), &trees, Instant::now()) - .unwrap_or_else(|| target.buffer.clone()) + let started = motion.begin(target.clone(), &trees, Instant::now()); + if started.is_some() { + filesystem_responses.emphasis_started(); + } + started.unwrap_or_else(|| target.buffer.clone()) } else { - motion.show(target) + filesystem_responses.emphasis_finished("history-emphasis-superseded", "superseded"); + let frame = motion.show(target); + if ready { + let response_ids = filesystem_responses.active_reference_ids().to_vec(); + filesystem_responses.finish_after_frame(&response_ids, "completed"); + } + frame }; terminal.current_buffer_mut().clone_from(&presented); terminal .apply_buffer_with_cursor(None) .context("could not draw terminal frame")?; + filesystem_responses.frame_presented(); Ok(()) } @@ -1818,15 +1886,15 @@ fn load_transition_trees( } } -fn present_buffer(terminal: &mut ratatui::DefaultTerminal, buffer: &ratatui::buffer::Buffer) -> Result<()> { +fn present_buffer(terminal: &mut ratatui::DefaultTerminal, buffer: &ratatui::buffer::Buffer) -> Result { if terminal.get_frame().area() != buffer.area { - return Ok(()); + return Ok(false); } terminal.current_buffer_mut().clone_from(buffer); terminal .apply_buffer_with_cursor(None) .context("could not draw animation frame")?; - Ok(()) + Ok(true) } fn open_repository(repository_path: &Path, bare: bool, isolated: bool) -> Result { diff --git a/gix-tix/src/logging.rs b/gix-tix/src/logging.rs index f5dba90f934..afae4c5c11c 100644 --- a/gix-tix/src/logging.rs +++ b/gix-tix/src/logging.rs @@ -1,4 +1,5 @@ use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, fs, path::{Path, PathBuf}, time::{Duration, SystemTime}, @@ -9,6 +10,338 @@ use tracing_subscriber::{filter::Targets, prelude::*}; const FILE_PREFIX: &str = "tix.log"; const RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); +const MAX_TRIGGER_PATHS: usize = 16; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum Trigger { + Head, + Index, + PackedRefs, + Refs, + Rescan, + Worktree, + GitMetadata, +} + +#[derive(Clone, Copy, Debug)] +enum WatcherKind { + References, + Worktree, +} + +struct Response { + id: u64, + watcher: WatcherKind, + started: std::time::Instant, + batches: usize, + events: usize, + rescans: usize, + kinds: BTreeMap<&'static str, usize>, + triggers: BTreeSet, + seen_paths: HashSet, + paths: Vec, + omitted_paths: usize, + presentations: usize, +} + +impl Response { + fn new(id: u64, watcher: WatcherKind) -> Self { + Response { + id, + watcher, + started: std::time::Instant::now(), + batches: 0, + events: 0, + rescans: 0, + kinds: BTreeMap::new(), + triggers: BTreeSet::new(), + seen_paths: HashSet::new(), + paths: Vec::new(), + omitted_paths: 0, + presentations: 0, + } + } + + fn observe(&mut self, event: ¬ify::Event, classify: impl Fn(&Path) -> Trigger) { + self.events += 1; + self.rescans += usize::from(event.need_rescan()); + *self.kinds.entry(event_kind(&event.kind)).or_default() += 1; + if event.need_rescan() { + self.triggers.insert(Trigger::Rescan); + } + for path in &event.paths { + self.triggers.insert(classify(path)); + if !self.seen_paths.insert(path.clone()) { + continue; + } + if self.paths.len() < MAX_TRIGGER_PATHS { + self.paths.push(path.clone()); + } else { + self.omitted_paths += 1; + } + } + } + + fn log_trigger(&self) { + tracing::debug!( + response_id = self.id, + watcher = ?self.watcher, + batches = self.batches, + events = self.events, + rescans = self.rescans, + event_kinds = ?self.kinds, + triggers = ?self.triggers, + paths = ?self.paths, + omitted_paths = self.omitted_paths, + "filesystem UI response triggered" + ); + } +} + +#[derive(Default)] +pub(crate) struct FilesystemResponses { + next_id: u64, + responses: HashMap, + pending_worktree: Option, + pending_references: Option, + queued_references: Vec, + active_references: Vec, + emphasis: Vec, + frame_causes: Vec<(u64, &'static str)>, + finish_after_frame: Vec<(u64, &'static str)>, +} + +impl FilesystemResponses { + pub(crate) fn observe_worktree(&mut self, event: ¬ify::Event, workdir: &Path, index: &Path) -> u64 { + let id = self.ensure_pending(WatcherKind::Worktree); + self.responses + .get_mut(&id) + .expect("a pending response is registered") + .observe(event, |path| { + if path == index { + Trigger::Index + } else if path.starts_with(workdir) { + Trigger::Worktree + } else { + Trigger::GitMetadata + } + }); + id + } + + pub(crate) fn observe_references(&mut self, event: ¬ify::Event, git_dir: &Path, common_dir: &Path) -> u64 { + let id = self.ensure_pending(WatcherKind::References); + self.responses + .get_mut(&id) + .expect("a pending response is registered") + .observe(event, |path| classify_reference_path(path, git_dir, common_dir)); + id + } + + pub(crate) fn note_worktree_batch(&mut self) { + self.note_batch(self.pending_worktree); + } + + pub(crate) fn note_reference_batch(&mut self) { + self.note_batch(self.pending_references); + } + + pub(crate) fn worktree_due(&mut self, invalidated: bool) -> Vec { + let Some(id) = self.pending_worktree.take() else { + return Vec::new(); + }; + self.log_trigger(id); + tracing::debug!(response_id = id, invalidated, action = "worktree-cache-invalidation"); + self.queue_frame(&[id], "worktree-cache-invalidation"); + self.finish_after_frame(&[id], "completed"); + vec![id] + } + + pub(crate) fn references_due(&mut self) -> Vec { + let Some(id) = self.pending_references.take() else { + return Vec::new(); + }; + self.log_trigger(id); + self.queued_references.push(id); + tracing::debug!(response_id = id, action = "reference-refresh-queued"); + vec![id] + } + + pub(crate) fn begin_reference_refresh(&mut self) -> Vec { + self.active_references = std::mem::take(&mut self.queued_references); + self.active_references.clone() + } + + pub(crate) fn active_reference_ids(&self) -> &[u64] { + &self.active_references + } + + pub(crate) fn phase(&self, ids: &[u64], action: &'static str) { + if !ids.is_empty() { + tracing::debug!(response_ids = ?ids, action); + } + } + + pub(crate) fn queue_frame(&mut self, ids: &[u64], reason: &'static str) { + for id in ids { + if !self.frame_causes.contains(&(*id, reason)) { + self.frame_causes.push((*id, reason)); + } + } + } + + pub(crate) fn finish_after_frame(&mut self, ids: &[u64], outcome: &'static str) { + for id in ids { + if !self.finish_after_frame.iter().any(|(candidate, _)| candidate == id) { + self.finish_after_frame.push((*id, outcome)); + } + } + } + + pub(crate) fn keep_after_frame(&mut self, ids: &[u64]) { + self.finish_after_frame.retain(|(id, _)| !ids.contains(id)); + } + + pub(crate) fn emphasis_started(&mut self) { + let superseded = std::mem::take(&mut self.emphasis); + self.finish(&superseded, "superseded"); + let ids = self.active_references.clone(); + self.keep_after_frame(&ids); + self.queue_frame(&ids, "history-emphasis-started"); + self.emphasis = ids; + } + + pub(crate) fn emphasis_finished(&mut self, reason: &'static str, outcome: &'static str) { + let ids = std::mem::take(&mut self.emphasis); + self.queue_frame(&ids, reason); + self.finish_after_frame(&ids, outcome); + } + + pub(crate) fn emphasis_aborted(&mut self, outcome: &'static str) { + let ids = std::mem::take(&mut self.emphasis); + self.finish(&ids, outcome); + } + + pub(crate) fn fail_pending_worktree(&mut self) { + self.cancel_pending_worktree("watcher-failure"); + } + + pub(crate) fn cancel_pending_worktree(&mut self, outcome: &'static str) { + if let Some(id) = self.pending_worktree.take() { + self.log_trigger(id); + self.finish(&[id], outcome); + } + } + + pub(crate) fn fail_pending_references(&mut self) { + if let Some(id) = self.pending_references.take() { + self.log_trigger(id); + self.finish(&[id], "watcher-failure"); + } + } + + pub(crate) fn frame_presented(&mut self) { + if self.frame_causes.is_empty() { + return; + } + let causes = std::mem::take(&mut self.frame_causes); + let mut ids = causes.iter().map(|(id, _)| *id).collect::>(); + ids.sort_unstable(); + ids.dedup(); + for id in &ids { + if let Some(response) = self.responses.get_mut(id) { + response.presentations += 1; + } + } + tracing::debug!(response_ids = ?ids, ?causes, "filesystem-triggered UI frame presented"); + + let finishing = std::mem::take(&mut self.finish_after_frame); + for (id, outcome) in finishing { + self.finish(&[id], outcome); + } + } + + pub(crate) fn finish(&mut self, ids: &[u64], outcome: &'static str) { + for id in ids { + let Some(response) = self.responses.remove(id) else { + continue; + }; + tracing::debug!( + response_id = response.id, + watcher = ?response.watcher, + outcome, + presentations = response.presentations, + elapsed_ms = response.started.elapsed().as_millis(), + "filesystem UI response finished" + ); + } + self.active_references.retain(|id| !ids.contains(id)); + self.emphasis.retain(|id| !ids.contains(id)); + self.queued_references.retain(|id| !ids.contains(id)); + self.frame_causes.retain(|(id, _)| !ids.contains(id)); + self.finish_after_frame.retain(|(id, _)| !ids.contains(id)); + } + + fn ensure_pending(&mut self, watcher: WatcherKind) -> u64 { + let slot = match watcher { + WatcherKind::References => &mut self.pending_references, + WatcherKind::Worktree => &mut self.pending_worktree, + }; + if let Some(id) = *slot { + return id; + } + self.next_id += 1; + let id = self.next_id; + self.responses.insert(id, Response::new(id, watcher)); + *slot = Some(id); + id + } + + fn log_trigger(&self, id: u64) { + if let Some(response) = self.responses.get(&id) { + response.log_trigger(); + } + } + + fn note_batch(&mut self, id: Option) { + if let Some(response) = id.and_then(|id| self.responses.get_mut(&id)) { + response.batches += 1; + } + } +} + +fn event_kind(kind: ¬ify::EventKind) -> &'static str { + match kind { + notify::EventKind::Access(_) => "access", + notify::EventKind::Create(_) => "create", + notify::EventKind::Modify(_) => "modify", + notify::EventKind::Remove(_) => "remove", + notify::EventKind::Other => "other", + notify::EventKind::Any => "any", + } +} + +fn classify_reference_path(path: &Path, git_dir: &Path, common_dir: &Path) -> Trigger { + let head = git_dir.join("HEAD"); + let common_head = common_dir.join("HEAD"); + let index = git_dir.join("index"); + let packed_refs = common_dir.join("packed-refs"); + if path == head + || path == head.with_extension("lock") + || path == common_head + || path == common_head.with_extension("lock") + { + Trigger::Head + } else if path == index || path == index.with_extension("lock") { + Trigger::Index + } else if path == packed_refs || path == packed_refs.with_extension("lock") { + Trigger::PackedRefs + } else if path.starts_with(git_dir.join("refs")) || path.starts_with(common_dir.join("refs")) { + Trigger::Refs + } else { + Trigger::GitMetadata + } +} pub(crate) fn init() -> Result { let directory = log_directory().context("could not determine the platform log directory")?; @@ -92,8 +425,116 @@ fn prune(directory: &Path, now: SystemTime) -> Vec { mod tests { use std::{fs::File, time::UNIX_EPOCH}; + use notify::event::{Flag, ModifyKind}; + use super::*; + fn modified(path: impl Into) -> notify::Event { + notify::Event::new(notify::EventKind::Modify(ModifyKind::Any)).add_path(path.into()) + } + + #[test] + fn classifies_reference_triggers() { + let common = Path::new("/repo/.git"); + let linked = common.join("worktrees/topic"); + assert_eq!( + classify_reference_path(&linked.join("HEAD"), &linked, common), + Trigger::Head + ); + assert_eq!( + classify_reference_path(&linked.join("HEAD.lock"), &linked, common), + Trigger::Head, + "transaction lock files retain their semantic trigger" + ); + assert_eq!( + classify_reference_path(&linked.join("index"), &linked, common), + Trigger::Index + ); + assert_eq!( + classify_reference_path(&common.join("packed-refs"), &linked, common), + Trigger::PackedRefs + ); + assert_eq!( + classify_reference_path(&common.join("refs/heads/main"), &linked, common), + Trigger::Refs + ); + assert_eq!( + classify_reference_path(&common.join("config"), &linked, common), + Trigger::GitMetadata + ); + } + + #[test] + fn coalesces_batches_and_bounds_deduplicated_trigger_paths() { + let common = Path::new("/repo/.git"); + let mut responses = FilesystemResponses::default(); + let first = responses.observe_references(&modified(common.join("HEAD")), common, common); + responses.note_reference_batch(); + let second = responses.observe_references(&modified(common.join("refs/heads/main")), common, common); + let rescan = notify::Event::new(notify::EventKind::Other).set_flag(Flag::Rescan); + let third = responses.observe_references(&rescan, common, common); + responses.note_reference_batch(); + assert_eq!(first, second, "events before the deadline share one response"); + assert_eq!(first, third, "rescans join the pending response"); + let response = responses + .responses + .get(&first) + .expect("the response is retained until acted upon"); + assert_eq!(response.events, 3); + assert_eq!(response.batches, 2); + assert_eq!(response.kinds, [("modify", 2), ("other", 1)].into_iter().collect()); + assert_eq!( + response.triggers, + [Trigger::Head, Trigger::Refs, Trigger::Rescan].into_iter().collect() + ); + + assert_eq!(responses.references_due(), [first]); + let next = responses.observe_references(&modified(common.join("HEAD")), common, common); + assert_ne!(next, first, "activity after the deadline starts another response"); + + let mut many = notify::Event::new(notify::EventKind::Modify(ModifyKind::Any)); + for index in 0..MAX_TRIGGER_PATHS + 4 { + many = many.add_path(common.join(format!("refs/heads/{index}"))); + } + many = many.add_path(common.join(format!("refs/heads/{}", MAX_TRIGGER_PATHS + 3))); + responses.observe_references(&many, common, common); + let response = responses.responses.get(&next).expect("the new response is pending"); + assert_eq!(response.paths.len(), MAX_TRIGGER_PATHS); + assert_eq!( + response.omitted_paths, 5, + "only unique paths beyond the cap are counted" + ); + } + + #[test] + fn attributes_overlapping_responses_to_one_presented_frame() { + let common = Path::new("/repo/.git"); + let workdir = Path::new("/repo"); + let mut responses = FilesystemResponses::default(); + let worktree = responses.observe_worktree(&modified(workdir.join("file")), workdir, &common.join("index")); + responses.worktree_due(true); + + let references = responses.observe_references(&modified(common.join("HEAD")), common, common); + responses.references_due(); + assert_eq!(responses.begin_reference_refresh(), [references]); + responses.queue_frame(&[references], "lane-computation-completed"); + responses.finish_after_frame(&[references], "completed"); + + assert_eq!( + responses.frame_causes, + [ + (worktree, "worktree-cache-invalidation"), + (references, "lane-computation-completed") + ], + "one frame retains both filesystem causes" + ); + responses.frame_presented(); + assert!( + responses.responses.is_empty(), + "both responses finish after the shared frame" + ); + } + #[test] fn prunes_only_expired_daily_logs() -> gix_testtools::Result { let directory = std::env::temp_dir().join(format!( From 1190125b3b672fa7f576d6bb2bf2498b3a1bcb49 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 07:34:34 +0200 Subject: [PATCH 039/282] fix: avoid watching ignored tix worktree directories Use gix directory walking to register non-recursive watches only for directories that Git status would traverse. This prevents ignored build output such as target/ from repeatedly invalidating and recomputing an unchanged worktree status. Refresh the directory watch set when directory topology, ignore rules, or the index changes, while retaining only paths and native watcher handles between refreshes. --- gix-tix/src/lib.rs | 209 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 199 insertions(+), 10 deletions(-) diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index c166d7c9bb4..b535d1b6cf2 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -9,7 +9,7 @@ mod logging; mod ui; use std::{ - collections::{HashMap, VecDeque}, + collections::{HashMap, HashSet, VecDeque}, ffi::OsString, io::{self, Write}, path::{Path, PathBuf}, @@ -68,6 +68,7 @@ struct FillRepository { struct WorktreeWatcher { _watcher: RecommendedWatcher, events: mpsc::Receiver>, + directories: HashSet, workdir: PathBuf, dot_git: PathBuf, git_dir: PathBuf, @@ -83,6 +84,45 @@ impl WorktreeWatcher { fn event_is_relevant(&self, event: ¬ify::Event) -> bool { worktree_event_is_relevant(event, &self.workdir, &self.dot_git, &self.git_dir, &self.index) } + + fn watch_set_may_change(&self, event: ¬ify::Event) -> bool { + worktree_watch_set_may_change(event, &self.index, &self.directories) + } +} + +#[derive(Default)] +struct WorktreeDirectories { + root: PathBuf, + paths: HashSet, +} + +impl gix::dir::walk::Delegate for WorktreeDirectories { + fn emit( + &mut self, + _entry: gix::dir::EntryRef<'_>, + _collapsed_directory_status: Option, + ) -> gix::dir::walk::Action { + std::ops::ControlFlow::Continue(()) + } + + fn can_recurse( + &mut self, + entry: gix::dir::EntryRef<'_>, + for_deletion: Option, + worktree_root_is_repository: bool, + ) -> bool { + let recurse = entry.status.can_recurse( + entry.disk_kind, + entry.pathspec_match, + for_deletion, + worktree_root_is_repository, + ); + if recurse { + self.paths + .insert(self.root.join(gix::path::from_bstr(entry.rela_path.as_ref()))); + } + recurse + } } fn worktree_event_is_relevant( @@ -99,6 +139,31 @@ fn worktree_event_is_relevant( })) } +fn worktree_watch_set_may_change(event: ¬ify::Event, index: &Path, directories: &HashSet) -> bool { + if event.need_rescan() + || event + .paths + .iter() + .any(|path| path == index || path.file_name().is_some_and(|name| name == ".gitignore")) + { + return true; + } + match event.kind { + notify::EventKind::Create(notify::event::CreateKind::Folder) + | notify::EventKind::Remove(notify::event::RemoveKind::Folder) + | notify::EventKind::Modify(notify::event::ModifyKind::Name(_)) => true, + notify::EventKind::Create(notify::event::CreateKind::Any | notify::event::CreateKind::Other) + | notify::EventKind::Any => event + .paths + .iter() + .any(|path| path.is_dir() || directories.contains(path)), + notify::EventKind::Remove(notify::event::RemoveKind::Any | notify::event::RemoveKind::Other) => { + event.paths.iter().any(|path| directories.contains(path)) + } + _ => false, + } +} + fn notification_is_actionable(event: ¬ify::Event) -> bool { event.need_rescan() || !matches!(event.kind, notify::EventKind::Access(_)) } @@ -696,6 +761,7 @@ fn event_loop( let mut worktree_changes = None; let mut worktree_watcher: Option = None; let mut worktree_refresh_deadline: Option = None; + let mut worktree_watch_set_changed = false; let mut selection_relation = None; let mut history_graph = None; let line_diff_parallelism = std::thread::available_parallelism().map_or(1, Into::into); @@ -765,6 +831,7 @@ fn event_loop( rescans += usize::from(event.need_rescan()); if watcher.event_is_relevant(&event) { relevant += 1; + worktree_watch_set_changed |= watcher.watch_set_may_change(&event); filesystem_responses.observe_worktree(&event, &watcher.workdir, &watcher.index); schedule_once(&mut worktree_refresh_deadline, Instant::now(), WORKTREE_EVENT_IDLE); } @@ -789,11 +856,23 @@ fn event_loop( app.worktree_changes.error = Some(format!("worktree watch: {err}")); worktree_watcher = None; worktree_refresh_deadline = None; + worktree_watch_set_changed = false; schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); dirty = true; urgent = true; } if take_due(&mut worktree_refresh_deadline, Instant::now()) { + if std::mem::take(&mut worktree_watch_set_changed) { + match start_worktree_watcher(&repository_path, repository_is_bare) { + Ok(watcher) => worktree_watcher = Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "worktree watcher rebuild failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + worktree_watcher = None; + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + } + } let invalidated = invalidate_worktree_changes(&mut worktree_changes); filesystem_responses.worktree_due(invalidated); tracing::debug!(invalidated, "worktree event deadline elapsed"); @@ -1004,6 +1083,7 @@ fn event_loop( app.set_worktree_changes_available(false); worktree_watcher = None; worktree_refresh_deadline = None; + worktree_watch_set_changed = false; filesystem_responses.cancel_pending_worktree("worktree-unavailable"); worktree_changes = None; line_diff_pool = None; @@ -1314,6 +1394,16 @@ fn event_loop( let effects = app.update(action); if refreshes_worktree { invalidate_worktree_changes(&mut worktree_changes); + worktree_watch_set_changed = false; + match start_worktree_watcher(&repository_path, repository_is_bare) { + Ok(watcher) => worktree_watcher = Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "worktree watcher refresh failed"); + app.worktree_changes.error = Some(format!("worktree watch: {err}")); + worktree_watcher = None; + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + } } if toggles_changes { sync_line_diff_pool( @@ -1325,6 +1415,7 @@ fn event_loop( )?; if app.changes_mode == Some(ChangesMode::Both) { invalidate_worktree_changes(&mut worktree_changes); + worktree_watch_set_changed = false; match start_worktree_watcher(&repository_path, repository_is_bare) { Ok(watcher) => { worktree_watcher = Some(watcher); @@ -1346,6 +1437,7 @@ fn event_loop( } else if previous_changes_mode == Some(ChangesMode::Both) { worktree_watcher = None; worktree_refresh_deadline = None; + worktree_watch_set_changed = false; filesystem_responses.cancel_pending_worktree("watcher-disabled"); } } @@ -1530,6 +1622,7 @@ fn start_ref_watcher(git_dir: &Path, common_dir: &Path) -> Result { } fn start_worktree_watcher(repository_path: &Path, bare: bool) -> Result { + let started = Instant::now(); let repository = open_repository(repository_path, bare, false) .context("could not open repository for worktree watcher setup")?; let workdir = repository @@ -1539,24 +1632,43 @@ fn start_worktree_watcher(repository_path: &Path, bare: bool) -> Result Result Result> { + let root = repository + .workdir() + .context("cannot walk a bare repository")? + .to_owned(); + let index = repository + .index_or_empty() + .context("could not open index for worktree watcher")?; + let options = repository + .dirwalk_options() + .context("could not configure worktree directory walk")?; + let mut directories = WorktreeDirectories { + root: root.clone(), + paths: HashSet::from([root]), + }; + repository + .dirwalk(&index, None::<&str>, &AtomicBool::default(), options, &mut directories) + .context("could not enumerate worktree directories")?; + Ok(directories.paths) +} + fn invalidate_worktree_changes(changes: &mut Option<(usize, Changes)>) -> bool { if let Some((marker, _)) = changes { if *marker == usize::MAX { @@ -3738,7 +3871,7 @@ mod tests { #[test] fn filters_worktree_watch_events_and_invalidates_cached_status() { - use notify::event::{AccessKind, Flag, ModifyKind}; + use notify::event::{AccessKind, CreateKind, Flag, ModifyKind, RemoveKind}; let workdir = Path::new("/repo"); let dot_git = workdir.join(".git"); @@ -3777,12 +3910,68 @@ mod tests { assert!(worktree_event_is_relevant(&rescan, workdir, &dot_git, &git_dir, &index)); assert!(notification_is_actionable(&rescan)); + let directories = HashSet::from([workdir.join("src")]); + assert!(!worktree_watch_set_may_change( + &modified(&workdir.join("src/lib.rs")), + &index, + &directories + )); + assert!(worktree_watch_set_may_change(&modified(&index), &index, &directories)); + assert!(worktree_watch_set_may_change( + &modified(&workdir.join(".gitignore")), + &index, + &directories + )); + let create_directory = + notify::Event::new(notify::EventKind::Create(CreateKind::Folder)).add_path(workdir.join("new")); + assert!(worktree_watch_set_may_change(&create_directory, &index, &directories)); + let remove_directory = + notify::Event::new(notify::EventKind::Remove(RemoveKind::Folder)).add_path(workdir.join("src")); + assert!(worktree_watch_set_may_change(&remove_directory, &index, &directories)); + assert!(worktree_watch_set_may_change(&rescan, &index, &directories)); + let mut changes = Some((0, Changes::default())); assert!(invalidate_worktree_changes(&mut changes)); assert_eq!(changes.as_ref().map(|(marker, _)| *marker), Some(usize::MAX)); assert!(!invalidate_worktree_changes(&mut changes)); } + #[test] + fn worktree_watch_directories_follow_git_ignores() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let root = fixture.path(); + std::fs::create_dir_all(root.join("visible/nested"))?; + std::fs::create_dir_all(root.join("visible/ignored/nested"))?; + std::fs::create_dir_all(root.join("target/nested"))?; + std::fs::write(root.join(".gitignore"), "target/\nvisible/ignored/\n")?; + + let repository = open_test_repository(root)?; + let directories = worktree_watch_directories(&repository)?; + let root = repository.workdir().expect("the fixture has a worktree"); + assert!(directories.contains(root), "the worktree root is always watched"); + assert!( + directories.contains(&root.join("visible")), + "visible directories are watched" + ); + assert!( + directories.contains(&root.join("visible/nested")), + "visible descendants are watched" + ); + assert!( + !directories.contains(&root.join("target")), + "ignored directories aren't watched" + ); + assert!( + !directories.contains(&root.join("target/nested")), + "ignored descendants aren't traversed" + ); + assert!( + !directories.contains(&root.join("visible/ignored")), + "nested ignore rules are honored" + ); + Ok(()) + } + #[test] fn starts_worktree_watching_for_the_combined_view() { assert!(worktree_watcher_needed(false, Some(ChangesMode::Both))); From 1de283e9158be2944239996e804b21b6a3fbb1cb Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 13:16:28 +0200 Subject: [PATCH 040/282] fix: ignore incomplete Git lock notifications in tix Ignore filesystem batches that only create, write, or remove Git lock files. Keep completed rename notifications actionable even when a backend reports only the lock path, and keep events naming the actual index, HEAD, or reference target, so atomic updates still refresh reliably without read-only Git commands causing needless history and status work. Label an empty worktree changes block as clean in green so its otherwise empty status remains explicit. --- gix-tix/src/lib.rs | 33 +++++++++++++++++++++++++++------ gix-tix/src/ui.rs | 15 +++++++++++---- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index b535d1b6cf2..ff996d684db 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -165,7 +165,18 @@ fn worktree_watch_set_may_change(event: ¬ify::Event, index: &Path, directorie } fn notification_is_actionable(event: ¬ify::Event) -> bool { - event.need_rescan() || !matches!(event.kind, notify::EventKind::Access(_)) + event.need_rescan() + || (!matches!(event.kind, notify::EventKind::Access(_)) + && (event.paths.is_empty() + || matches!( + event.kind, + notify::EventKind::Modify(notify::event::ModifyKind::Name(_)) + ) + || event.paths.iter().any(|path| { + !path + .file_name() + .is_some_and(|name| name.as_encoded_bytes().ends_with(b".lock")) + }))) } fn worktree_watcher_needed(repository_is_bare: bool, mode: Option) -> bool { @@ -3014,19 +3025,22 @@ mod tests { let deadline = Instant::now() + Duration::from_secs(5); let mut paths = Vec::new(); + let watched = repository.git_dir().join("refs/heads/watched"); while Instant::now() < deadline { let event = watcher .events .recv_timeout(deadline.saturating_duration_since(Instant::now()))??; - assert!(notification_is_actionable(&event), "the reference update is actionable"); + if !notification_is_actionable(&event) { + continue; + } paths.extend(event.paths); - if paths.iter().any(|path| path.ends_with("refs/heads/watched")) { + if watched.is_file() { break; } } assert!( - paths.iter().any(|path| path.ends_with("refs/heads/watched")), - "the final loose reference is reported after its lock file: {paths:?}" + watched.is_file(), + "the completed loose-reference transaction is actionable: {paths:?}" ); Ok(()) } @@ -3871,7 +3885,7 @@ mod tests { #[test] fn filters_worktree_watch_events_and_invalidates_cached_status() { - use notify::event::{AccessKind, CreateKind, Flag, ModifyKind, RemoveKind}; + use notify::event::{AccessKind, CreateKind, Flag, ModifyKind, RemoveKind, RenameMode}; let workdir = Path::new("/repo"); let dot_git = workdir.join(".git"); @@ -3906,6 +3920,13 @@ mod tests { &access, workdir, &dot_git, &git_dir, &index )); assert!(!notification_is_actionable(&access)); + let lock_only = modified(&git_dir.join("index.lock")); + assert!(!notification_is_actionable(&lock_only)); + let completed_lock_rename = notify::Event::new(notify::EventKind::Modify(ModifyKind::Name(RenameMode::Any))) + .add_path(git_dir.join("index.lock")); + assert!(notification_is_actionable(&completed_lock_rename)); + let completed_lock_update = lock_only.add_path(index.clone()); + assert!(notification_is_actionable(&completed_lock_update)); let rescan = notify::Event::new(notify::EventKind::Other).set_flag(Flag::Rescan); assert!(worktree_event_is_relevant(&rescan, workdir, &dot_git, &git_dir, &index)); assert!(notification_is_actionable(&rescan)); diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index fa9ae0bb544..784cd59bb3b 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -914,6 +914,11 @@ fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'stat .map_or_else(|| "-------".into(), |row| row.id.to_hex_with_len(7).to_string()); vec![Span::raw(format!("─ Tree {id} ── "))] } + ChangePane::Worktree if changes.paths.is_empty() => vec![ + Span::raw("─ Worktree "), + Span::styled("clean", color(Color::Green)), + Span::raw(" ── "), + ], ChangePane::Worktree => vec![Span::raw("─ Worktree ── ")], }; let counts: Vec<_> = match pane { @@ -2976,10 +2981,12 @@ mod tests { Some(&Changes::default()), ); })?; - assert!( - (0..8).any(|y| rendered_line(&terminal, y).contains("Worktree ──")), - "an enabled clean worktree remains visible as an empty block" - ); + let (clean_y, clean_header) = (0..8) + .map(|y| (y, rendered_line(&terminal, y))) + .find(|(_, line)| line.contains("Worktree clean")) + .expect("an enabled clean worktree remains visible as an empty block"); + let clean_x = clean_header.find("clean").expect("clean label") as u16; + assert_eq!(terminal.backend().buffer()[(clean_x, clean_y)].fg, Color::Green); assert!( !(0..8).any(|y| rendered_line(&terminal, y).contains("+0") || rendered_line(&terminal, y).contains("-0")), "a clean worktree omits empty diff counts" From 40433c8d7cc150948344b69471c89e6556f64fe0 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 13:59:29 +0200 Subject: [PATCH 041/282] feat: open whole-commit diffs from tix history Make Enter on a history row display the selected commit against its active comparison parent. Reuse the existing file-diff preparation so attributes, binary handling, external diff commands, configured pagers, and the built-in viewer behave consistently with changed-path diffs. Prefix whole-commit output with the selected row identity and a Git-style diffstat in tree order. Show each changed path with its line total and a scaled additions/deletions graph, followed by the comparison parent, per-kind file totals, and aggregate line counts. Mirror the history mailmap and email display, and derive all line counts from the diff already being prepared. Show the summary and internal patch before per-path external diff drivers. Allow Enter to continue into those drivers while q or Escape returns directly to history. --- gix-tix/src/app.rs | 8 +- gix-tix/src/lib.rs | 336 +++++++++++++++++++++++++++++++++++++++------ gix-tix/src/ui.rs | 270 +++++++++++++++++++++++++++++++----- 3 files changed, 535 insertions(+), 79 deletions(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 44a454e8856..1beb1baa1bb 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -300,6 +300,7 @@ pub(crate) enum Effect { CopyAuthor(&'static Author), Reload(bool), OpenDiff(ChangePane, usize), + OpenCommitDiff(ObjectId), VerifySignatures(Vec), Quit, } @@ -746,6 +747,11 @@ impl App { changes.error = None; return vec![Effect::OpenDiff(pane, changes.selected)]; } + Action::OpenDiff => { + if let Some(id) = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id) { + return vec![Effect::OpenCommitDiff(id)]; + } + } Action::VerifySignatures if !self.signature_verification_running => { let start = self.offset.min(self.rows.len()); let end = start.saturating_add(self.viewport_rows).min(self.rows.len()); @@ -2211,7 +2217,7 @@ mod tests { app.update(Action::ToggleChanges); app.update(Action::ToggleChanges); assert_eq!(app.changes_focus, None, "closing the panel returns focus to history"); - assert!(app.update(Action::OpenDiff).is_empty()); + assert_eq!(app.update(Action::OpenDiff), vec![Effect::OpenCommitDiff(id(1))]); assert_eq!(app.tree_changes.selected, 0); assert_eq!(app.tree_changes.offset, 0); assert_eq!(app.tree_changes.horizontal_offset, 0); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index ff996d684db..46d32889513 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -622,8 +622,19 @@ enum FileDiff { BuiltIn(BuiltInDiff), } +enum PreparedFileDiff { + External(gix::diff::blob::platform::prepare_diff_command::Command, LineCounts), + BuiltIn(BuiltInDiff, LineCounts), +} + +struct CommitDiff { + external: Vec, + internal: FileDiff, +} + pub(crate) struct BuiltInDiff { title: BString, + summary: Option>>, lines: Vec, max_width: usize, } @@ -637,12 +648,36 @@ impl BuiltInDiff { .unwrap_or_default(); BuiltInDiff { title, + summary: None, lines, max_width, } } + fn with_summary(mut self, summary: Vec>) -> Self { + self.max_width = self + .max_width + .max(summary.iter().map(Line::width).max().unwrap_or_default()); + self.summary = Some(summary); + self + } + + fn display_line_count(&self) -> usize { + self.lines.len() + self.summary.as_ref().map_or(0, |summary| summary.len() + 1) + } + fn write_to(&self, mut out: impl Write) -> io::Result<()> { + if let Some(summary) = &self.summary { + out.write_all(&self.title)?; + out.write_all(b"\n")?; + for line in summary { + for span in &line.spans { + out.write_all(span.content.as_bytes())?; + } + out.write_all(b"\n")?; + } + out.write_all(b"\n")?; + } for line in &self.lines { out.write_all(line)?; out.write_all(b"\n")?; @@ -1480,21 +1515,33 @@ fn event_loop( .and_then(|(change, path)| { prepare_file_diff(&repository_path, repository_is_bare, change, path) }) - .and_then(|diff| match diff { - FileDiff::External(command) => { - run_external_diff(terminal, command, enhanced_keyboard).map(|()| false) - } - FileDiff::Pager { command, diff } => { - run_pager(terminal, command, &diff, enhanced_keyboard).map(|()| false) - } - FileDiff::BuiltIn(diff) => show_builtin_diff(terminal, &diff), - }); + .and_then(|diff| show_file_diff(terminal, diff, enhanced_keyboard)); match result { Ok(true) => app.focus_history(), Err(err) => app.changes_mut(pane).error = Some(format!("{err:#}")), Ok(false) => {} } } + Effect::OpenCommitDiff(id) => { + let title = app + .rows + .iter() + .find(|row| row.id == id) + .map(|row| { + ui::commit_diff_title(row, app.title(row), &mailmap, app.use_mailmap, app.show_emails) + }) + .context("selected commit is no longer available")?; + let cached = tree_changes.as_ref().filter(|(cached_id, _, _)| *cached_id == id); + let parent = cached.map_or(0, |(_, parent, _)| *parent); + let cached = cached.map(|(_, _, changes)| changes); + let result = prepare_commit_diff(&repository_path, repository_is_bare, id, parent, cached, title) + .and_then(|diff| show_commit_diff(terminal, diff, enhanced_keyboard)); + match result { + Ok(true) => app.focus_history(), + Err(err) => app.notice = Some(format!("diff: {err:#}")), + Ok(false) => {} + } + } Effect::VerifySignatures(ids) => { verification_receiver = Some(start_signature_verification( repository_path.clone(), @@ -2105,11 +2152,78 @@ fn prepare_file_diff(repository_path: &Path, bare: bool, change: &FileChange, pa prepare_file_diff_with_repository(&repository, change, path) } +fn prepare_commit_diff( + repository_path: &Path, + bare: bool, + id: gix::ObjectId, + requested_parent: usize, + cached: Option<&Changes>, + title: BString, +) -> Result { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository for commit diff")?; + repository.object_cache_size(OBJECT_CACHE_SIZE); + prepare_commit_diff_with_repository(&repository, id, requested_parent, cached, title) +} + +fn prepare_commit_diff_with_repository( + repository: &gix::Repository, + id: gix::ObjectId, + requested_parent: usize, + cached: Option<&Changes>, + title: BString, +) -> Result { + let loaded = cached + .is_none() + .then(|| load_changes_without_lines(repository, id, requested_parent)) + .transpose()?; + let changes = cached + .or(loaded.as_ref()) + .context("commit diff changes were neither cached nor loaded")?; + let mut external = Vec::new(); + let mut lines = Vec::new(); + let mut lines_added = 0u64; + let mut lines_removed = 0u64; + let mut line_counts = Vec::with_capacity(changes.paths.len()); + for (change, path) in changes.diffs.iter().zip(&changes.paths) { + let counts = match prepare_file_diff_content(repository, change, path, true)? { + PreparedFileDiff::External(command, counts) => { + external.push(command); + counts + } + PreparedFileDiff::BuiltIn(diff, counts) => { + lines.extend(diff.lines); + counts + } + }; + if let Some((added, removed)) = counts { + lines_added += u64::from(added); + lines_removed += u64::from(removed); + } + line_counts.push(counts); + } + let summary = ui::commit_diff_summary(changes, &line_counts, lines_added, lines_removed); + let internal = prepare_pager(repository, BuiltInDiff::new(title, lines).with_summary(summary))?; + Ok(CommitDiff { external, internal }) +} + fn prepare_file_diff_with_repository( repository: &gix::Repository, change: &FileChange, path: &PathChange, ) -> Result { + match prepare_file_diff_content(repository, change, path, false)? { + PreparedFileDiff::External(command, _) => Ok(FileDiff::External(command)), + PreparedFileDiff::BuiltIn(diff, _) => prepare_pager(repository, diff), + } +} + +fn prepare_file_diff_content( + repository: &gix::Repository, + change: &FileChange, + path: &PathChange, + count_lines: bool, +) -> Result { if let FileChange::Unavailable(message) = change { anyhow::bail!("{message}"); } @@ -2149,15 +2263,33 @@ fn prepare_file_diff_with_repository( let prepared = resources.prepare_diff().context("could not prepare selected diff")?; match prepared.operation { gix::diff::blob::platform::prepare_diff::Operation::ExternalCommand { command } => { + let counts = count_lines + .then(|| { + let input = prepared.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics( + repository.diff_algorithm().context("could not obtain diff algorithm")?, + &input, + ); + Ok::<_, anyhow::Error>((diff.count_additions(), diff.count_removals())) + }) + .transpose()?; let command = command.to_owned(); prepare_external_diff(repository, &resources, command) + .map(|command| PreparedFileDiff::External(command, counts)) } gix::diff::blob::platform::prepare_diff::Operation::InternalDiff { algorithm } => { if let Some(command) = global_command { - return prepare_external_diff(repository, &resources, command); + let counts = count_lines.then(|| { + let input = prepared.interned_input(); + let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); + (diff.count_additions(), diff.count_removals()) + }); + return prepare_external_diff(repository, &resources, command) + .map(|command| PreparedFileDiff::External(command, counts)); } let input = prepared.interned_input(); let diff = gix::diff::blob::diff_with_slider_heuristics(algorithm, &input); + let counts = Some((diff.count_additions(), diff.count_removals())); let rendered = gix::diff::blob::UnifiedDiff::new( &diff, &input, @@ -2166,10 +2298,13 @@ fn prepare_file_diff_with_repository( ) .consume() .context("could not render selected diff")?; - prepare_pager(repository, built_in_diff(path, change, Some(rendered), false)) + Ok(PreparedFileDiff::BuiltIn( + built_in_diff(path, change, Some(rendered), false), + counts, + )) } gix::diff::blob::platform::prepare_diff::Operation::SourceOrDestinationIsBinary => { - prepare_pager(repository, built_in_diff(path, change, None, true)) + Ok(PreparedFileDiff::BuiltIn(built_in_diff(path, change, None, true), None)) } } } @@ -2200,19 +2335,17 @@ fn prepare_external_diff( repository: &gix::Repository, resources: &gix::diff::blob::Platform, command: BString, -) -> Result { - Ok(FileDiff::External( - resources - .prepare_diff_command( - command, - repository - .command_context() - .context("could not prepare external diff environment")?, - 0, - 1, - ) - .context("could not prepare external diff command")?, - )) +) -> Result { + resources + .prepare_diff_command( + command, + repository + .command_context() + .context("could not prepare external diff environment")?, + 0, + 1, + ) + .context("could not prepare external diff command") } fn built_in_diff(path: &PathChange, change: &FileChange, rendered: Option, binary: bool) -> BuiltInDiff { @@ -2280,6 +2413,28 @@ fn built_in_diff(path: &PathChange, change: &FileChange, rendered: Option Result { + match diff { + FileDiff::External(command) => run_external_diff(terminal, command, enhanced_keyboard).map(|()| false), + FileDiff::Pager { command, diff } => run_pager(terminal, command, &diff, enhanced_keyboard).map(|()| false), + FileDiff::BuiltIn(diff) => show_builtin_diff(terminal, &diff), + } +} + +fn show_commit_diff( + terminal: &mut ratatui::DefaultTerminal, + diff: CommitDiff, + enhanced_keyboard: bool, +) -> Result { + if show_file_diff(terminal, diff.internal, enhanced_keyboard)? { + return Ok(true); + } + for command in diff.external { + run_external_diff(terminal, command, enhanced_keyboard)?; + } + Ok(false) +} + fn run_external_diff( terminal: &mut ratatui::DefaultTerminal, mut command: gix::diff::blob::platform::prepare_diff_command::Command, @@ -2396,7 +2551,7 @@ fn show_builtin_diff(terminal: &mut ratatui::DefaultTerminal, diff: &BuiltInDiff loop { let size = terminal.size().context("could not determine diff viewport")?; let page = usize::from(size.height.saturating_sub(2)).max(1); - let max = diff.lines.len().saturating_sub(page); + let max = diff.display_line_count().saturating_sub(page); let horizontal_page = usize::from(size.width).max(1); let horizontal_max = diff.max_width.saturating_sub(horizontal_page); offset = offset.min(max); @@ -2448,6 +2603,24 @@ fn load_changes( id: gix::ObjectId, requested_parent: usize, line_diff_pool: &mut LineDiffPool, +) -> Result { + let mut out = load_changes_without_lines(repository, id, requested_parent)?; + let diffs = std::mem::take(&mut out.diffs); + for (path, (change, lines)) in out.paths.iter_mut().zip(line_diff_pool.line_counts(diffs)?) { + path.lines = lines; + if let Some((insertions, removals)) = lines { + out.lines_added += u64::from(insertions); + out.lines_removed += u64::from(removals); + } + out.diffs.push(change); + } + Ok(out) +} + +fn load_changes_without_lines( + repository: &gix::Repository, + id: gix::ObjectId, + requested_parent: usize, ) -> Result { let commit = repository.find_commit(id).context("could not load changed paths")?; let parents: Vec<_> = commit.parent_ids().collect(); @@ -2477,7 +2650,6 @@ fn load_changes( }), ..Changes::default() }; - let mut diffs = Vec::new(); for change in changes { use gix::object::tree::diff::ChangeDetached; let (kind, source, path, is_tree) = match &change { @@ -2526,15 +2698,7 @@ fn load_changes( path, lines: None, }); - diffs.push(FileChange::Tree(change)); - } - for (path, (change, lines)) in out.paths.iter_mut().zip(line_diff_pool.line_counts(diffs)?) { - path.lines = lines; - if let Some((insertions, removals)) = lines { - out.lines_added += u64::from(insertions); - out.lines_removed += u64::from(removals); - } - out.diffs.push(change); + out.diffs.push(FileChange::Tree(change)); } Ok(out) } @@ -3345,12 +3509,8 @@ mod tests { ); } - let topic = load_changes( - &repository, - repository.rev_parse_single("topic")?.detach(), - 0, - line_diff_pool, - )?; + let topic_id = repository.rev_parse_single("topic")?.detach(); + let topic = load_changes(&repository, topic_id, 0, line_diff_pool)?; assert_eq!( topic.paths, [ @@ -3372,6 +3532,85 @@ mod tests { "parallel line diffs retain tree-diff order and status" ); assert_eq!((topic.lines_added, topic.lines_removed), (2, 0)); + let title: BString = format!("{} author topic", topic_id.to_hex_with_len(7)).into(); + let commit_diff = prepare_commit_diff_with_repository(&repository, topic_id, 0, None, title.clone())?; + assert!(commit_diff.external.is_empty()); + let FileDiff::BuiltIn(diff) = commit_diff.internal else { + unreachable!("an isolated repository uses the built-in commit viewer") + }; + assert_eq!(diff.title, title); + let summary = diff + .summary + .as_ref() + .expect("whole-commit diffs have a summary") + .last() + .expect("the aggregate follows path statistics") + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!( + summary.contains("A 2 · +2"), + "the existing diff pass supplies aggregate counts" + ); + let topic_position = diff + .lines + .iter() + .position(|line| line == "+++ b/topic") + .expect("the first path is present"); + let extra_position = diff + .lines + .iter() + .position(|line| line == "+++ b/topic-extra") + .expect("the second path is present"); + assert!( + topic_position < extra_position, + "whole-commit patches retain tree-diff order" + ); + let empty = + prepare_commit_diff_with_repository(&repository, topic_id, 0, Some(&Changes::default()), title.clone())?; + let FileDiff::BuiltIn(empty) = empty.internal else { + unreachable!("empty commits retain the built-in viewer") + }; + assert!(empty.lines.is_empty(), "an empty commit opens an empty patch"); + assert!( + empty + .summary + .expect("empty commits have a summary") + .iter() + .flat_map(|line| &line.spans) + .any(|span| span.content.contains("No changes")), + "empty commits explain the absent patch" + ); + + let pager_diff = + prepare_commit_diff_with_repository(&pager_repository, topic_id, 0, Some(&topic), title.clone())?; + assert!(pager_diff.external.is_empty()); + let FileDiff::Pager { diff, .. } = pager_diff.internal else { + unreachable!("one configured pager receives the aggregate commit patch") + }; + let mut streamed = Vec::new(); + diff.write_to(&mut streamed)?; + assert!( + streamed.starts_with( + format!("{title}\n topic | 1 +\n topic-extra | 1 +\nroot · A 2 · +2 \n\n").as_bytes() + ), + "the pager receives path statistics and the aggregate before the patch" + ); + let external_diff = + prepare_commit_diff_with_repository(&external_repository, topic_id, 0, Some(&topic), title.clone())?; + assert_eq!( + external_diff.external.len(), + 2, + "external diff commands remain per-path" + ); + let FileDiff::BuiltIn(summary) = external_diff.internal else { + unreachable!("an all-external commit still shows its summary") + }; + assert!( + summary.lines.is_empty(), + "external patches aren't duplicated internally" + ); let merge = repository.rev_parse_single("main")?.detach(); let first_parent = load_changes(&repository, merge, 0, line_diff_pool)?; @@ -3415,6 +3654,19 @@ mod tests { }], "later parents can be selected independently" ); + let second_parent_diff = + prepare_commit_diff_with_repository(&repository, merge, 1, Some(&second_parent), "merge title".into())?; + let FileDiff::BuiltIn(diff) = second_parent_diff.internal else { + unreachable!("an isolated repository uses the built-in commit viewer") + }; + assert!( + diff.summary + .expect("merge diff has a summary") + .iter() + .flat_map(|line| &line.spans) + .any(|span| span.content.contains("vs parent 2/2")), + "the commit viewer identifies the selected merge parent" + ); assert_eq!( load_changes(&repository, merge, 2, line_diff_pool)?.parent, first_parent.parent, diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 784cd59bb3b..6e365fe248d 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -143,7 +143,7 @@ pub(crate) fn draw_file_diff(frame: &mut Frame<'_>, diff: &BuiltInDiff, offset: Paragraph::new(diff.title.to_str_lossy()).style(Style::default().add_modifier(Modifier::BOLD)), header, ); - let lines = diff + let mut lines = diff .lines .iter() .map(|line| { @@ -161,6 +161,9 @@ pub(crate) fn draw_file_diff(frame: &mut Frame<'_>, diff: &BuiltInDiff, offset: Line::styled(line.to_str_lossy(), style) }) .collect::>(); + if let Some(summary) = &diff.summary { + lines.splice(0..0, summary.iter().cloned().chain(std::iter::once(Line::default()))); + } frame.render_widget( Paragraph::new(Text::from(lines)).scroll(( u16::try_from(offset).unwrap_or(u16::MAX), @@ -602,6 +605,9 @@ pub(crate) fn draw_with_worktree( "{}{status} · ↑↓/jk move · h/l pan", history_position(app) ))]; + if app.changes_focus.is_none() { + footer_spans.push(Span::raw(" · Enter diff")); + } if app.tree_changes_visible || app.worktree_changes_visible { footer_spans.push(match app.focus_feedback.take() { Some(destination) => Span::raw(format!(" · Tab → {destination}")), @@ -905,6 +911,111 @@ fn path_change_color(change: &crate::app::PathChange) -> Color { } } +pub(crate) fn commit_diff_title( + row: &CommitRow, + title: &BStr, + mailmap: &gix::mailmap::Snapshot, + use_mailmap: bool, + show_emails: bool, +) -> BString { + let author = author_label(row.author, mailmap, use_mailmap, show_emails && !row.author.is_bot()); + let author = if row.author.is_bot() { + format!("[{author}]") + } else { + author + }; + let mut out: BString = format!("{} {author} ", row.id.to_hex_with_len(7)).into(); + out.extend_from_slice(title); + out +} + +pub(crate) fn commit_diff_summary( + changes: &Changes, + line_counts: &[Option<(u32, u32)>], + lines_added: u64, + lines_removed: u64, +) -> Vec> { + let paths = changes + .paths + .iter() + .map(|change| match &change.source { + Some(source) => format!("{} -> {}", source.to_str_lossy(), change.path.to_str_lossy()), + None => change.path.to_str_lossy().into_owned(), + }) + .collect::>(); + let path_width = paths + .iter() + .map(|path| Line::from(path.as_str()).width()) + .max() + .unwrap_or_default(); + let count_width = line_counts + .iter() + .map(|counts| { + counts.map_or(3, |(added, removed)| { + (u64::from(added) + u64::from(removed)).to_string().len() + }) + }) + .max() + .unwrap_or_default(); + let max_changes = line_counts + .iter() + .flatten() + .map(|(added, removed)| u64::from(*added) + u64::from(*removed)) + .max() + .unwrap_or_default(); + let graph_width = max_changes.min(40); + let mut lines = paths + .into_iter() + .zip(line_counts) + .map(|(path, counts)| { + let padding = " ".repeat(path_width.saturating_sub(Line::from(path.as_str()).width())); + let mut spans = vec![Span::raw(format!(" {path}{padding} | "))]; + match counts { + Some((added, removed)) => { + let total = u64::from(*added) + u64::from(*removed); + spans.push(Span::raw(format!("{total:>count_width$} "))); + let scaled = |count: u32| { + (u64::from(count) * graph_width / max_changes.max(1)).max(u64::from(count > 0)) as usize + }; + spans.push(Span::styled("+".repeat(scaled(*added)), color(Color::Green))); + spans.push(Span::styled("-".repeat(scaled(*removed)), color(Color::LightRed))); + } + None => spans.push(Span::raw(format!("{:>count_width$}", "Bin"))), + } + Line::from(spans) + }) + .collect::>(); + let mut spans = match changes.parent { + Some(parent) if parent.total > 1 => vec![Span::styled( + format!( + "vs parent {}/{} {} · ", + parent.index + 1, + parent.total, + parent.id.to_hex_with_len(7) + ), + color(COMPARED_PARENT_COLOR), + )], + Some(parent) => vec![Span::styled( + format!("vs parent {} · ", parent.id.to_hex_with_len(7)), + color(COMPARED_PARENT_COLOR), + )], + None => vec![Span::styled("root · ", color(COMPARED_PARENT_COLOR))], + }; + if changes.paths.is_empty() { + spans.push(Span::styled("No changes", Style::default().add_modifier(Modifier::DIM))); + } else { + append_change_aggregate( + &mut spans, + tree_change_counts(changes), + changes.paths.len(), + lines_added, + lines_removed, + ); + } + lines.push(Line::from(spans)); + lines +} + fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'static> { let mut spans = match pane { ChangePane::Tree => { @@ -922,24 +1033,7 @@ fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'stat ChangePane::Worktree => vec![Span::raw("─ Worktree ── ")], }; let counts: Vec<_> = match pane { - ChangePane::Tree => { - let mut counts = Vec::new(); - for kind in [ - ChangeKind::Added, - ChangeKind::Modified, - ChangeKind::Deleted, - ChangeKind::Renamed, - ChangeKind::Copied, - ChangeKind::TypeChanged, - ] { - let count = changes.paths.iter().filter(|change| change.kind == kind).count(); - if count == 0 { - continue; - } - counts.push((kind.letter().to_string(), count, change_color(kind))); - } - counts - } + ChangePane::Tree => tree_change_counts(changes), ChangePane::Worktree => { let staged = changes .paths @@ -956,8 +1050,42 @@ fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'stat .collect() } }; + append_change_aggregate( + &mut spans, + counts, + changes.paths.len(), + changes.lines_added, + changes.lines_removed, + ); + Line::from(spans) +} + +fn tree_change_counts(changes: &Changes) -> Vec<(String, usize, Color)> { + [ + ChangeKind::Added, + ChangeKind::Modified, + ChangeKind::Deleted, + ChangeKind::Renamed, + ChangeKind::Copied, + ChangeKind::TypeChanged, + ] + .into_iter() + .filter_map(|kind| { + let count = changes.paths.iter().filter(|change| change.kind == kind).count(); + (count > 0).then(|| (kind.letter().to_string(), count, change_color(kind))) + }) + .collect() +} + +fn append_change_aggregate( + spans: &mut Vec>, + counts: Vec<(String, usize, Color)>, + total: usize, + lines_added: u64, + lines_removed: u64, +) { let has_counts = !counts.is_empty(); - let show_total = has_counts && (counts.len() != 1 || counts[0].1 != changes.paths.len()); + let show_total = has_counts && (counts.len() != 1 || counts[0].1 != total); for (index, (label, count, count_color)) in counts.into_iter().enumerate() { if index > 0 { spans.push(Span::raw(" + ")); @@ -965,29 +1093,21 @@ fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'stat spans.push(Span::styled(format!("{label} {count}"), color(count_color))); } if show_total { - spans.push(Span::raw(format!( - "{}= {}", - if has_counts { " " } else { "" }, - changes.paths.len() - ))); + spans.push(Span::raw(format!("{}= {}", if has_counts { " " } else { "" }, total))); } - if changes.lines_added > 0 || changes.lines_removed > 0 { + if lines_added > 0 || lines_removed > 0 { spans.push(Span::raw(" · ")); - if changes.lines_added > 0 { - spans.push(Span::styled(format!("+{}", changes.lines_added), color(Color::Green))); + if lines_added > 0 { + spans.push(Span::styled(format!("+{lines_added}"), color(Color::Green))); } - if changes.lines_removed > 0 { - if changes.lines_added > 0 { + if lines_removed > 0 { + if lines_added > 0 { spans.push(Span::raw(" ")); } - spans.push(Span::styled( - format!("-{}", changes.lines_removed), - color(Color::LightRed), - )); + spans.push(Span::styled(format!("-{lines_removed}"), color(Color::LightRed))); } spans.push(Span::raw(" ")); } - Line::from(spans) } fn render_commit_message(frame: &mut Frame<'_>, area: Rect, message: &BStr, notes: &[BString], offset: usize) -> usize { @@ -1649,6 +1769,83 @@ mod tests { Ok(()) } + #[test] + fn renders_and_streams_compact_commit_diff_summaries() -> Result<(), Box> { + let row = Commit { + id: gix::ObjectId::Sha1([1; 20]), + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: 0..0, + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }; + let mailmap = + gix::mailmap::Snapshot::from_bytes(b"mapped author author \n"); + let title = commit_diff_title(&row, b"subject".as_bstr(), &mailmap, true, false); + assert_eq!(title, "0101010 mapped author subject"); + assert_eq!( + commit_diff_title(&row, b"subject".as_bstr(), &mailmap, true, true), + "0101010 mapped author subject" + ); + let changes = Changes { + paths: vec![ + crate::app::PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "new".into(), + lines: None, + }, + crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: "old".into(), + lines: None, + }, + ], + ..Changes::default() + }; + let diff = BuiltInDiff::new( + title.clone(), + ["--- a/old", "+++ b/old"].into_iter().map(Into::into).collect(), + ) + .with_summary(commit_diff_summary(&changes, &[Some((2, 0)), Some((1, 1))], 3, 1)); + let mut terminal = Terminal::new(TestBackend::new(64, 9))?; + + terminal.draw(|frame| draw_file_diff(frame, &diff, 0, 0))?; + + assert_eq!(rendered_line(&terminal, 0).trim(), title); + assert_eq!(rendered_line(&terminal, 1).trim(), "new | 2 ++"); + assert_eq!(rendered_line(&terminal, 2).trim(), "old | 2 +-"); + let summary = "root · A 1 + M 1 = 2 · +3 -1"; + assert_eq!(rendered_line(&terminal, 3).trim(), summary); + let buffer = terminal.backend().buffer(); + let summary_x = |needle| { + summary[..summary.find(needle).expect("summary term is present")] + .chars() + .count() as u16 + }; + assert_eq!(buffer[(0, 3)].fg, COMPARED_PARENT_COLOR); + assert_eq!(buffer[(summary_x("A 1"), 3)].fg, Color::Green); + assert_eq!(buffer[(summary_x("-1"), 3)].fg, Color::LightRed); + assert_eq!(buffer[(9, 1)].fg, Color::Green); + assert_eq!(buffer[(10, 2)].fg, Color::LightRed); + assert_eq!(rendered_line(&terminal, 4).trim(), ""); + assert_eq!(rendered_line(&terminal, 5).trim(), "--- a/old"); + + let mut streamed = Vec::new(); + diff.write_to(&mut streamed)?; + assert_eq!( + streamed, + b"0101010 mapped author subject\n new | 2 ++\n old | 2 +-\nroot \xc2\xb7 A 1 + M 1 = 2 \xc2\xb7 +3 -1 \n\n--- a/old\n+++ b/old\n" + ); + Ok(()) + } + #[test] fn renders_grouped_attributions_and_bot_names() -> Result<(), Box> { let mut app = App::new(1); @@ -1875,7 +2072,8 @@ mod tests { terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; - let footer_text = "#1 · ↑↓/jk move · h/l pan · [ align · o commit · c changes · v view · y copy · q quit"; + let footer_text = + "#1 · ↑↓/jk move · h/l pan · Enter diff · [ align · o commit · c changes · v view · y copy · q quit"; let selected_line = "> ● 0101010 (HEAD) 1970-01-01 mapped author subject"; let mut expected = Buffer::with_lines([format!("{selected_line:<180}"), format!("{footer_text:<180}")]); for x in 0..11 { From a428db4bfa54b72eefebd88977d97eff343a2e9b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 14:16:36 +0200 Subject: [PATCH 042/282] feat: highlight unseen filesystem redraws in tix Replace the main status separators with prominent orange commit-style discs when a filesystem-attributed frame is presented while the terminal is unfocused. Keep the indication across later redraws and restore the normal separators immediately when terminal focus returns. --- gix-tix/src/app.rs | 2 ++ gix-tix/src/lib.rs | 27 ++++++++++++++++++++++++- gix-tix/src/logging.rs | 7 +++++++ gix-tix/src/ui.rs | 45 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 1beb1baa1bb..838a6ec2b53 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -355,6 +355,7 @@ pub(crate) struct App { pub copy_feedback: Option, pub(crate) focus_feedback: Option<&'static str>, pub(crate) notice: Option, + pub(crate) unseen_filesystem_redraw: bool, pub(crate) history_display_expanded: bool, pub estimated_lane_width: usize, pub horizontal_offset: usize, @@ -420,6 +421,7 @@ impl App { copy_feedback: None, focus_feedback: None, notice: None, + unseen_filesystem_redraw: false, history_display_expanded: false, estimated_lane_width: 0, horizontal_offset: 0, diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 46d32889513..35e13f86c44 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -179,6 +179,10 @@ fn notification_is_actionable(event: ¬ify::Event) -> bool { }))) } +fn unseen_filesystem_redraw(current: bool, focused: bool, filesystem_frame: bool) -> bool { + !focused && (current || filesystem_frame) +} + fn worktree_watcher_needed(repository_is_bare: bool, mode: Option) -> bool { !repository_is_bare && mode == Some(ChangesMode::Both) } @@ -840,6 +844,7 @@ fn event_loop( let mut decorations = Decorations::new(); let mut motion = MotionState::default(); let mut filesystem_responses = logging::FilesystemResponses::default(); + let mut focused = true; draw( terminal, &mut app, @@ -854,13 +859,13 @@ fn event_loop( &mut selection_relation, &mut line_diff_pool, &mut motion, + focused, &mut filesystem_responses, )?; let mut last_draw = Instant::now(); let mut dirty = false; let mut urgent = false; let mut history_finished = false; - let mut focused = true; let mut repeat_deadline: Option = None; let mut history_status_deadline: Option = None; let mut pending_terminal_event = None; @@ -1225,6 +1230,7 @@ fn event_loop( &mut selection_relation, &mut line_diff_pool, &mut motion, + focused, &mut filesystem_responses, )?; last_draw = Instant::now(); @@ -1286,6 +1292,7 @@ fn event_loop( &mut selection_relation, &mut line_diff_pool, &mut motion, + focused, &mut filesystem_responses, )?; last_draw = Instant::now(); @@ -1372,6 +1379,10 @@ fn event_loop( } TerminalEvent::FocusGained => { focused = true; + if app.unseen_filesystem_redraw { + dirty = true; + urgent = true; + } continue; } TerminalEvent::Resize(_, _) => { @@ -1800,12 +1811,18 @@ fn draw( selection_cache: &mut Option, line_diff_pool: &mut Option, motion: &mut MotionState, + focused: bool, filesystem_responses: &mut logging::FilesystemResponses, ) -> Result<()> { let render_rows = terminal.get_frame().area().height.saturating_sub(1) as usize; if !history_is_ready_to_draw(app.state, app.rows.len()) { return Ok(()); } + app.unseen_filesystem_redraw = unseen_filesystem_redraw( + app.unseen_filesystem_redraw, + focused, + filesystem_responses.has_queued_frame(), + ); app.viewport_rows = app.viewport_rows.min(render_rows.max(1)); app.ensure_visible(); let start = app.offset.min(app.rows.len()); @@ -3175,6 +3192,14 @@ fn open_test_repository(path: impl AsRef) -> Result gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; diff --git a/gix-tix/src/logging.rs b/gix-tix/src/logging.rs index afae4c5c11c..a591bf44d24 100644 --- a/gix-tix/src/logging.rs +++ b/gix-tix/src/logging.rs @@ -198,6 +198,10 @@ impl FilesystemResponses { } } + pub(crate) fn has_queued_frame(&self) -> bool { + !self.frame_causes.is_empty() + } + pub(crate) fn keep_after_frame(&mut self, ids: &[u64]) { self.finish_after_frame.retain(|(id, _)| !ids.contains(id)); } @@ -511,8 +515,10 @@ mod tests { let common = Path::new("/repo/.git"); let workdir = Path::new("/repo"); let mut responses = FilesystemResponses::default(); + assert!(!responses.has_queued_frame()); let worktree = responses.observe_worktree(&modified(workdir.join("file")), workdir, &common.join("index")); responses.worktree_due(true); + assert!(responses.has_queued_frame()); let references = responses.observe_references(&modified(common.join("HEAD")), common, common); responses.references_due(); @@ -529,6 +535,7 @@ mod tests { "one frame retains both filesystem causes" ); responses.frame_presented(); + assert!(!responses.has_queued_frame()); assert!( responses.responses.is_empty(), "both responses finish after the shared frame" diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 6e365fe248d..0b8e34c8b97 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -18,6 +18,7 @@ use crate::{ const COMPARED_PARENT_COLOR: Color = Color::Cyan; const COMMIT_PANE_WIDTH: u16 = 84; +const FILESYSTEM_NOTIFICATION_COLOR: Color = Color::Rgb(255, 165, 0); const NOTE_COLOR: Color = Color::LightMagenta; const PANE_STATUS_BACKGROUND: Color = Color::DarkGray; @@ -687,6 +688,9 @@ pub(crate) fn draw_with_worktree( if let Some(notice) = &app.notice { footer_spans = vec![Span::raw(notice)]; } + if app.unseen_filesystem_redraw { + footer_spans = notification_discs(footer_spans); + } frame.render_widget(Paragraph::new(Line::from(footer_spans)), footer); FrameLayout { history: body, @@ -1266,6 +1270,22 @@ fn toggle(label: &'static str, enabled: bool) -> Span<'static> { ) } +fn notification_discs(spans: Vec>) -> Vec> { + let mut out = Vec::with_capacity(spans.len()); + for span in spans { + let style = span.style; + for (index, text) in span.content.split('·').enumerate() { + if index > 0 { + out.push(Span::styled("●", style.fg(FILESYSTEM_NOTIFICATION_COLOR))); + } + if !text.is_empty() { + out.push(Span::styled(text.to_owned(), style)); + } + } + } + out +} + #[derive(Clone, Copy)] struct MetadataOptions { show_committer_date: bool, @@ -2123,6 +2143,31 @@ mod tests { "completed work cannot be cancelled" ); + app.unseen_filesystem_redraw = true; + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + for (x, _) in footer_text + .chars() + .enumerate() + .filter(|(_, character)| *character == '·') + { + assert_eq!( + terminal.backend().buffer()[(x as u16, 1)].symbol(), + "●", + "status separators become prominent notification discs" + ); + assert_eq!( + terminal.backend().buffer()[(x as u16, 1)].fg, + FILESYSTEM_NOTIFICATION_COLOR, + "every status separator marks an unseen filesystem redraw" + ); + } + assert_eq!( + terminal.backend().buffer()[(0, 1)].fg, + Color::Reset, + "notification coloring does not affect status text" + ); + app.unseen_filesystem_redraw = false; + app.notice = Some("worktree removed; using common repository".into()); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert_eq!( From 20ad3639cc0ce9359c0c710a377f29aec1aa8dd4 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 11 Aug 2026 16:51:33 +0200 Subject: [PATCH 043/282] feat: shade the tix commit panel Replace the commit message panel border with a subtle background derived from the terminal theme. Query the terminal background once at startup, shift it by one sixteenth toward the opposite luminance extreme, and retain the default background when detection is unavailable. --- Cargo.lock | 33 ++++++++++++++++++++++++++++++ gix-tix/Cargo.toml | 1 + gix-tix/src/app.rs | 2 ++ gix-tix/src/lib.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++- gix-tix/src/ui.rs | 43 +++++++++++++++++++++++++++++++++----- 5 files changed, 124 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96f3b55fad9..75e9dc3f559 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2602,6 +2602,7 @@ dependencies = [ "insta", "notify", "ratatui", + "terminal-colorsaurus", "tracing", "tracing-appender", "tracing-subscriber", @@ -5220,6 +5221,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal-colorsaurus" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a46bb5364467da040298c573c8a95dbf9a512efc039630409a03126e3703e90" +dependencies = [ + "cfg-if", + "libc", + "memchr", + "mio", + "terminal-trx", + "windows-sys 0.61.2", + "xterm-color", +] + +[[package]] +name = "terminal-trx" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3f27d9a8a177e57545481faec87acb45c6e854ed1e5a3658ad186c106f38ed" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -6320,6 +6347,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xterm-color" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7008a9d8ba97a7e47d9b2df63fcdb8dade303010c5a7cd5bf2469d4da6eba673" + [[package]] name = "xz2" version = "0.1.7" diff --git a/gix-tix/Cargo.toml b/gix-tix/Cargo.toml index ccce2b50334..9f4572fed21 100644 --- a/gix-tix/Cargo.toml +++ b/gix-tix/Cargo.toml @@ -29,6 +29,7 @@ directories = "6.0.0" gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["blob-diff", "mailmap", "notes", "parallel", "revision", "command", "status"] } notify = "8.2.0" ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } +terminal-colorsaurus = "1.0.3" tracing = "0.1.37" tracing-appender = "0.2.4" tracing-subscriber = "0.3.17" diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 838a6ec2b53..67183355f08 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -345,6 +345,7 @@ pub(crate) struct App { pub(crate) worktree_changes: ChangesView, pub(crate) changes_parent: usize, pub(crate) commit_offset: usize, + pub(crate) commit_pane_background: Option<(u8, u8, u8)>, commit_page: usize, commit_max: usize, pub(crate) show_selection_tail: bool, @@ -411,6 +412,7 @@ impl App { worktree_changes: ChangesView::default(), changes_parent: 0, commit_offset: 0, + commit_pane_background: None, commit_page: 1, commit_max: 0, show_selection_tail: true, diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 35e13f86c44..5a48d9d1fa7 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -57,6 +57,7 @@ const REF_EVENT_IDLE: Duration = Duration::from_millis(100); const IMMEDIATE_PAGER_EXIT: Duration = Duration::from_millis(250); const REF_EVENT_INTERVAL: Duration = Duration::from_millis(250); const WATCH_RETRY_INTERVAL: Duration = Duration::from_secs(5); +const THEME_QUERY_TIMEOUT: Duration = Duration::from_millis(100); struct FillRepository { path: PathBuf, @@ -699,6 +700,34 @@ pub struct Options { pub hide: Vec, } +fn detect_commit_pane_background() -> Option<(u8, u8, u8)> { + let mut options = terminal_colorsaurus::QueryOptions::default(); + options.timeout = THEME_QUERY_TIMEOUT; + match terminal_colorsaurus::background_color(options) { + Ok(background) => { + let color = background.scale_to_8bit(); + let shaded = shade_terminal_background(color, background.perceived_lightness() <= 0.5); + tracing::debug!(?color, ?shaded, "detected terminal background"); + Some(shaded) + } + Err(err) => { + tracing::debug!(error = %err, "terminal background detection unavailable"); + None + } + } +} + +fn shade_terminal_background((red, green, blue): (u8, u8, u8), dark: bool) -> (u8, u8, u8) { + let shade = |channel: u8| { + if dark { + channel + (u8::MAX - channel) / 16 + } else { + channel - channel / 16 + } + }; + (shade(red), shade(green), shade(blue)) +} + /// Run the interactive commit graph for `repository`. pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec, options: Options) -> Result<()> { let _log_guard = match logging::init() { @@ -713,12 +742,22 @@ pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec, opti hidden_revision_count = options.hide.len(), "starting tix" ); + let commit_pane_background = detect_commit_pane_background(); let mut terminal = ratatui::try_init().context("could not initialize terminal")?; let enhanced_keyboard = terminal::supports_keyboard_enhancement().unwrap_or(false); let keyboard_setup = enable_input(terminal.backend_mut(), enhanced_keyboard); let result = keyboard_setup .context("could not enable enhanced keyboard events") - .and_then(|()| event_loop(&mut terminal, repository, revisions, options, enhanced_keyboard)); + .and_then(|()| { + event_loop( + &mut terminal, + repository, + revisions, + options, + enhanced_keyboard, + commit_pane_background, + ) + }); let keyboard_restore = disable_input(terminal.backend_mut(), enhanced_keyboard); let restore = ratatui::try_restore().context("could not restore terminal"); let lane_time = result?; @@ -758,6 +797,7 @@ fn event_loop( revisions: Vec, options: Options, enhanced_keyboard: bool, + commit_pane_background: Option<(u8, u8, u8)>, ) -> Result> { let Options { quit_on_finish, hide } = options; let mut repository_path = repository.git_dir().to_owned(); @@ -794,6 +834,7 @@ fn event_loop( ); let mut app = App::new(1); + app.commit_pane_background = commit_pane_background; if recovered_at_startup { app.notice = Some("worktree removed; using the common repository without worktree changes".into()); } @@ -3192,6 +3233,14 @@ fn open_test_repository(path: impl AsRef) -> Result Result<(), Box> { let mut app = App::new(3); + app.commit_pane_background = Some((15, 16, 17)); app.extend_commits(vec![Commit { id: gix::ObjectId::Sha1([1; 20]), parent_ids: Default::default(), @@ -2474,8 +2479,23 @@ mod tests { ); assert_eq!( terminal.backend().buffer()[(60, 0)].symbol(), - "│", - "the pane has a left border" + " ", + "the pane starts with padding instead of a border" + ); + assert_eq!( + terminal.backend().buffer()[(60, 0)].bg, + Color::Rgb(15, 16, 17), + "the commit pane has the derived terminal-background shade" + ); + assert_eq!( + terminal.backend().buffer()[(62, 0)].bg, + Color::Rgb(15, 16, 17), + "the shade extends behind the commit message" + ); + assert_eq!( + terminal.backend().buffer()[(59, 0)].bg, + Color::Reset, + "the history background is unchanged" ); assert_eq!( terminal.backend().buffer()[(62, 2)].symbol(), @@ -2494,6 +2514,11 @@ mod tests { " ", "closing the pane removes the commit body" ); + assert_eq!( + terminal.backend().buffer()[(62, 3)].bg, + Color::Reset, + "closing the pane removes its background shade" + ); app.update(Action::ToggleCommit); let mut wide_terminal = Terminal::new(TestBackend::new(200, 6))?; @@ -2536,6 +2561,7 @@ mod tests { fn pages_overflowing_commit_messages_and_hides_the_status_when_they_fit() -> Result<(), Box> { let mut app = App::new(4); + app.commit_pane_background = Some((15, 16, 17)); app.extend_commits(vec![Commit { id: gix::ObjectId::Sha1([1; 20]), parent_ids: Default::default(), @@ -2695,6 +2721,7 @@ mod tests { #[test] fn shows_changed_paths_in_a_bottom_pane_below_the_summary() -> Result<(), Box> { let mut app = App::new(6); + app.commit_pane_background = Some((15, 16, 17)); app.extend_commits(vec![ Commit { id: gix::ObjectId::Sha1([1; 20]), @@ -3105,7 +3132,12 @@ mod tests { .collect::() .contains("Tree") ); - assert_eq!(terminal.backend().buffer()[(60, 7)].symbol(), "│"); + assert_eq!(terminal.backend().buffer()[(60, 7)].symbol(), " "); + assert_eq!( + terminal.backend().buffer()[(60, 7)].bg, + Color::Rgb(15, 16, 17), + "the shaded commit pane separates the overlays without a border" + ); assert_eq!( app.viewport_rows, 7, "history remains bounded above the highest overlay" @@ -3129,7 +3161,8 @@ mod tests { ChangesLayout::SideBySide, "sufficient remaining width still permits side-by-side changes" ); - assert_eq!(wide_terminal.backend().buffer()[(156, 7)].symbol(), "│"); + assert_eq!(wide_terminal.backend().buffer()[(156, 7)].symbol(), " "); + assert_eq!(wide_terminal.backend().buffer()[(156, 7)].bg, Color::Rgb(15, 16, 17)); Ok(()) } From 6833803afe1a4e7d76cddd3dfa46c14b23ef03eb Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 05:12:38 +0200 Subject: [PATCH 044/282] feat: reword the newest commit in tix Allow r to edit the top-most selectable commit using the Git-selected editor and a structured author, committer, date, comment-prefix, and message document. Offer missing GPT 5.6 Assisted-by and Co-authored-by trailers as semicolon-prefixed comments that users may opt into by removing the prefix, without repeating trailer keys already present regardless of their values. Apply Git-compatible comment and whitespace cleanup after editing, with a configurable non-empty line prefix that defaults to a semicolon and only matches at column zero. Recreate the commit with configured signing when enabled, then atomically retarget direct references that still point at the old commit while excluding tags and remote-tracking references. A detached HEAD is updated as well. Keep repositories short-lived around editor invocation so tix retains no object database handles while idle. --- gix-tix/src/app.rs | 34 ++++ gix-tix/src/lib.rs | 77 +++++++- gix-tix/src/reword.rs | 439 ++++++++++++++++++++++++++++++++++++++++++ gix-tix/src/ui.rs | 6 +- 4 files changed, 552 insertions(+), 4 deletions(-) create mode 100644 gix-tix/src/reword.rs diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 67183355f08..a46a03b5bc8 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -282,6 +282,7 @@ pub(crate) enum Action { ToggleChangesFocus, CycleChangesParent, OpenDiff, + Reword, VerifySignatures, Cancel, Copy, @@ -301,6 +302,7 @@ pub(crate) enum Effect { Reload(bool), OpenDiff(ChangePane, usize), OpenCommitDiff(ObjectId), + Reword(ObjectId), VerifySignatures(Vec), Quit, } @@ -756,6 +758,11 @@ impl App { return vec![Effect::OpenCommitDiff(id)]; } } + Action::Reword if self.can_reword() => { + return vec![Effect::Reword( + self.rows[self.selected.expect("reword requires a selection")].id, + )]; + } Action::VerifySignatures if !self.signature_verification_running => { let start = self.offset.min(self.rows.len()); let end = start.saturating_add(self.viewport_rows).min(self.rows.len()); @@ -1197,6 +1204,17 @@ impl App { (0..self.rows.len()).find(|index| !self.is_row_hidden(*index)) } + pub(crate) fn can_reword(&self) -> bool { + self.state == State::Complete && self.reword_shortcut_visible() + } + + pub(crate) fn reword_shortcut_visible(&self) -> bool { + self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self.selected.is_some() + && self.selected == self.first_selectable() + } + fn last_selectable(&self) -> Option { (0..self.rows.len()).rev().find(|index| !self.is_row_hidden(*index)) } @@ -1844,6 +1862,22 @@ mod tests { assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(4))); } + #[test] + fn only_the_newest_completed_history_row_can_be_reworded() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + assert!(!app.can_reword(), "loading history cannot be reworded"); + complete(&mut app); + assert_eq!(app.update(Action::Reword), vec![Effect::Reword(id(2))]); + + app.update(Action::MoveDown); + assert!( + !app.can_reword(), + "a commit with a visible descendant cannot be reworded" + ); + assert!(app.update(Action::Reword).is_empty()); + } + #[test] fn lane_computation_keeps_provisional_rows_interactive() { let mut app = App::new(2); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 5a48d9d1fa7..f541a2514ce 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -6,6 +6,7 @@ mod animation; mod app; mod history; mod logging; +mod reword; mod ui; use std::{ @@ -845,6 +846,7 @@ fn event_loop( let mut refresh_from_filesystem = false; let mut ref_refresh_deadline: Option = None; let mut refresh_select_top = false; + let mut refresh_select_top_requested = false; let mut refresh_expand_hidden = false; let mut verification_receiver = None; let mut commit_message = None; @@ -1216,7 +1218,7 @@ fn event_loop( hidden_changed, "compared reference snapshot" ); - let select_top = from_filesystem && tips_changed; + let select_top = std::mem::take(&mut refresh_select_top_requested) || from_filesystem && tips_changed; ref_snapshot = next; refresh_pending = false; let hidden = if app.show_hidden { Vec::new() } else { hide.clone() }; @@ -1594,6 +1596,21 @@ fn event_loop( Ok(false) => {} } } + Effect::Reword(id) => { + match reword_commit(terminal, &repository_path, repository_is_bare, id, enhanced_keyboard) { + Ok(Some(new_id)) => { + app.notice = Some(format!( + "reworded {} as {}", + id.to_hex_with_len(7), + new_id.to_hex_with_len(7) + )); + refresh_select_top_requested = true; + refresh_pending = true; + } + Ok(None) => {} + Err(err) => app.notice = Some(format!("reword: {err:#}")), + } + } Effect::VerifySignatures(ids) => { verification_receiver = Some(start_signature_verification( repository_path.clone(), @@ -2493,6 +2510,58 @@ fn show_commit_diff( Ok(false) } +fn reword_commit( + terminal: &mut ratatui::DefaultTerminal, + repository_path: &Path, + bare: bool, + id: gix::ObjectId, + enhanced_keyboard: bool, +) -> Result> { + let (editor, document) = { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository before editing commit")?; + repository.object_cache_size(None); + reword::document(&repository, id)? + }; + let mut tempfile = gix::tempfile::new( + std::env::temp_dir(), + gix::tempfile::ContainingDirectory::Exists, + gix::tempfile::AutoRemove::Tempfile, + ) + .context("could not create commit message file")? + .take() + .context("commit message file disappeared")?; + tempfile + .write_all(&document) + .context("could not write commit message file")?; + tempfile.flush().context("could not flush commit message file")?; + + if editor != ":" { + with_suspended_terminal(terminal, enhanced_keyboard, || { + let status = Command::from( + gix::command::prepare(&editor) + .arg(tempfile.path()) + .command_may_be_shell_script_allow_manual_argument_splitting(), + ) + .status() + .with_context(|| format!("could not launch Git editor {}", editor.to_string_lossy()))?; + if !status.success() { + anyhow::bail!("Git editor {} exited with {status}", editor.to_string_lossy()); + } + Ok(()) + })?; + } + let edited = std::fs::read(tempfile.path()).context("could not read edited commit message")?; + if edited == document { + return Ok(None); + } + + let mut repository = + open_repository(repository_path, bare, false).context("could not reopen repository after editing commit")?; + repository.object_cache_size(None); + reword::apply(&repository, id, &edited) +} + fn run_external_diff( terminal: &mut ratatui::DefaultTerminal, mut command: gix::diff::blob::platform::prepare_diff_command::Command, @@ -3164,6 +3233,7 @@ fn action_with_history_display(key: KeyEvent, history_display_expanded: bool) -> KeyCode::Char('R') => Some(Action::Refresh), KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Refresh), KeyCode::Char('r') if history_display_expanded => Some(Action::ToggleRefs), + KeyCode::Char('r') => Some(Action::Reword), KeyCode::Char('s') => Some(Action::VerifySignatures), KeyCode::Char('v') => Some(Action::ToggleHistoryDisplay), KeyCode::Char('[') => Some(Action::ToggleAlign), @@ -3991,7 +4061,10 @@ mod tests { assert_eq!(action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), None); assert_eq!(action(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)), None); assert_eq!(action(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)), None); - assert_eq!(action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), None); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), + Some(Action::Reword) + ); assert_eq!( action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::SHIFT)), Some(Action::Refresh), diff --git a/gix-tix/src/reword.rs b/gix-tix/src/reword.rs new file mode 100644 index 00000000000..809d1df86de --- /dev/null +++ b/gix-tix/src/reword.rs @@ -0,0 +1,439 @@ +use anyhow::{Context, Result}; +use gix::{ + bstr::{BString, ByteSlice}, + refs::{ + Category, Target, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + }, +}; + +const AUTHOR: &[u8] = b"Author: "; +const AUTHOR_DATE: &[u8] = b"AuthorDate: "; +const COMMITTER: &[u8] = b"Committer: "; +const COMMITTER_DATE: &[u8] = b"CommitterDate: "; +const COMMENT_CHAR: &[u8] = b"CommentChar: "; +const DEFAULT_COMMENT_CHAR: &[u8] = b";"; +const ASSISTED_BY: &[u8] = b"Assisted-by: GPT 5.6"; +const CO_AUTHORED_BY: &[u8] = b"Co-authored-by: GPT 5.6 "; + +struct Edit<'a> { + author: &'a [u8], + author_time: gix::date::Time, + committer: &'a [u8], + committer_time: gix::date::Time, + message: BString, +} + +pub(crate) fn document(repo: &gix::Repository, id: gix::ObjectId) -> Result<(std::ffi::OsString, Vec)> { + let editor = repo.editor().context("no Git editor is available")?; + let mut commit = repo + .find_commit(id) + .context("could not find commit to reword")? + .decode() + .context("could not decode commit to reword")? + .into_owned() + .context("could not own commit to reword")?; + commit.committer.time = gix::date::Time::now_local_or_utc(); + + let mut out = Vec::new(); + write_actor(&mut out, AUTHOR, &commit.author); + write_date(&mut out, AUTHOR_DATE, commit.author.time)?; + write_actor(&mut out, COMMITTER, &commit.committer); + write_date(&mut out, COMMITTER_DATE, commit.committer.time)?; + out.extend_from_slice(COMMENT_CHAR); + out.extend_from_slice(DEFAULT_COMMENT_CHAR); + out.push(b'\n'); + out.push(b'\n'); + out.extend_from_slice(&commit.message); + if !out.ends_with(b"\n") { + out.push(b'\n'); + } + let suggestions = missing_agent_trailers(&commit.message); + if suggestions.iter().any(Option::is_some) { + if !out.ends_with(b"\n\n") { + out.push(b'\n'); + } + for trailer in suggestions.into_iter().flatten() { + out.extend_from_slice(DEFAULT_COMMENT_CHAR); + out.extend_from_slice(trailer); + out.push(b'\n'); + } + } + Ok((editor, out)) +} + +fn missing_agent_trailers(message: &[u8]) -> [Option<&'static [u8]>; 2] { + let mut has_assisted_by = false; + let mut has_co_authored_by = false; + if let Some(body) = gix::objs::commit::MessageRef::from_bytes(message).body() { + for trailer in body.trailers() { + has_assisted_by |= trailer.is_assisted_by(); + has_co_authored_by |= trailer.is_co_authored_by(); + } + } + [ + (!has_assisted_by).then_some(ASSISTED_BY), + (!has_co_authored_by).then_some(CO_AUTHORED_BY), + ] +} + +pub(crate) fn apply(repo: &gix::Repository, old_id: gix::ObjectId, edited: &[u8]) -> Result> { + let edit = parse(edited)?; + if edit.message.is_empty() { + anyhow::bail!("the edited commit message is empty"); + } + + let refs = matching_references(repo, old_id)?; + if refs.is_empty() { + anyhow::bail!("no mutable reference points to the commit anymore"); + } + + let mut commit = repo + .find_commit(old_id) + .context("could not find commit after editing")? + .decode() + .context("could not decode commit after editing")? + .into_owned() + .context("could not own commit after editing")?; + commit.author = actor(edit.author, edit.author_time, "author")?; + commit.committer = actor(edit.committer, edit.committer_time, "committer")?; + commit.message = edit.message; + commit.extra_headers.retain(|(name, _)| { + name.as_slice() != gix::objs::commit::SIGNATURE_FIELD_NAME.as_bytes() + && name.as_slice() != gix::objs::commit::SIGNATURE_FIELD_NAME_SHA256.as_bytes() + }); + if let Some(options) = repo + .commit_signing_options_if_enabled() + .context("could not resolve commit signing configuration")? + { + commit = commit.sign(options).context("could not sign reworded commit")?; + } + let new_id = repo + .write_object(&commit) + .context("could not write reworded commit")? + .detach(); + if new_id == old_id { + return Ok(None); + } + + let log_message = gix::reference::log::message("commit", commit.message.as_bstr(), commit.parents.len()); + let edits = refs.into_iter().map(|name| RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: log_message.clone(), + }, + expected: PreviousValue::MustExistAndMatch(Target::Object(old_id)), + new: Target::Object(new_id), + }, + name, + deref: false, + }); + let mut time_buf = gix::date::parse::TimeBuf::default(); + repo.edit_references_as(edits, Some(commit.committer.to_ref(&mut time_buf))) + .context("could not update references to the reworded commit")?; + Ok(Some(new_id)) +} + +fn write_actor(out: &mut Vec, label: &[u8], actor: &gix::actor::Signature) { + out.extend_from_slice(label); + out.extend_from_slice(&actor.name); + out.extend_from_slice(b" <"); + out.extend_from_slice(&actor.email); + out.extend_from_slice(b">\n"); +} + +fn write_date(out: &mut Vec, label: &[u8], time: gix::date::Time) -> Result<()> { + out.extend_from_slice(label); + out.extend_from_slice( + time.format(gix::date::time::format::ISO8601) + .context("could not format commit date")? + .as_bytes(), + ); + out.push(b'\n'); + Ok(()) +} + +fn parse(input: &[u8]) -> Result> { + let mut parts = input.splitn(7, |byte| *byte == b'\n'); + let author = header(parts.next(), AUTHOR)?; + let author_time = date(header(parts.next(), AUTHOR_DATE)?, "author")?; + let committer = header(parts.next(), COMMITTER)?; + let committer_time = date(header(parts.next(), COMMITTER_DATE)?, "committer")?; + let comment_char = header(parts.next(), COMMENT_CHAR)?; + if comment_char.contains(&b'\r') { + anyhow::bail!("CommentChar must not contain a line ending"); + } + if parts.next().map(trim_cr) != Some(&[][..]) { + anyhow::bail!("expected an empty line after the commit headers"); + } + let message = cleanup_message(parts.next().context("the commit message is missing")?, comment_char); + Ok(Edit { + author, + author_time, + committer, + committer_time, + message, + }) +} + +fn cleanup_message(input: &[u8], comment_char: &[u8]) -> BString { + let mut out = Vec::new(); + let mut empty_lines = 0; + for line in input.lines_with_terminator() { + let line = trim_cr(line.strip_suffix(b"\n").unwrap_or(line)); + if line.starts_with(comment_char) { + continue; + } + let line = &line[..line + .iter() + .rposition(|byte| !byte.is_ascii_whitespace()) + .map_or(0, |pos| pos + 1)]; + if line.is_empty() { + empty_lines += 1; + continue; + } + if !out.is_empty() && empty_lines > 0 { + out.push(b'\n'); + } + empty_lines = 0; + out.extend_from_slice(line); + out.push(b'\n'); + } + out.into() +} + +fn header<'a>(line: Option<&'a [u8]>, prefix: &[u8]) -> Result<&'a [u8]> { + trim_cr(line.context("a commit header is missing")?) + .strip_prefix(prefix) + .filter(|value| !value.is_empty()) + .with_context(|| format!("expected a non-empty {} header", prefix[..prefix.len() - 2].as_bstr())) +} + +fn trim_cr(line: &[u8]) -> &[u8] { + line.strip_suffix(b"\r").unwrap_or(line) +} + +fn date(value: &[u8], field: &str) -> Result { + let value = std::str::from_utf8(value).with_context(|| format!("{field} date is not UTF-8"))?; + gix::date::parse(value, None) + .map_err(|err| anyhow::Error::new(err.into_error())) + .with_context(|| format!("could not parse {field} date")) +} + +fn actor(value: &[u8], time: gix::date::Time, field: &str) -> Result { + let parsed = gix::actor::SignatureRef::from_bytes(value) + .with_context(|| format!("could not parse {field} identity"))? + .trim(); + if parsed.name.is_empty() || parsed.email.is_empty() || !parsed.time.is_empty() { + anyhow::bail!("{field} must be written as Name "); + } + Ok(gix::actor::Signature { + name: parsed.name.into(), + email: parsed.email.into(), + time, + }) +} + +fn matching_references(repo: &gix::Repository, id: gix::ObjectId) -> Result> { + let mut out = Vec::new(); + for reference in repo.references()?.all()? { + let reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => anyhow::bail!("could not inspect references pointing to commit: {err}"), + }; + if !matches!( + reference.name().category(), + Some(Category::Tag | Category::RemoteBranch) + ) && reference.try_id().is_some_and(|target| target.as_ref() == id) + { + out.push(reference.name().to_owned()); + } + } + if let Some(head) = repo.try_find_reference("HEAD")? + && head.try_id().is_some_and(|target| target.as_ref() == id) + { + out.push(head.name().to_owned()); + } + Ok(out) +} + +fn is_missing_ref(mut err: &(dyn std::error::Error + 'static)) -> bool { + loop { + if err + .downcast_ref::() + .is_some_and(|err| err.kind() == std::io::ErrorKind::NotFound) + { + return true; + } + let Some(source) = err.source() else { return false }; + err = source; + } +} + +#[cfg(test)] +mod tests { + use std::process::Command; + + use super::*; + + #[test] + fn parses_the_edit_document() -> gix_testtools::Result { + let input = b"Author: A U Thor \n\ + AuthorDate: 2026-08-12 10:20:30 +0200\n\ + Committer: C O Mitter \n\ + CommitterDate: 2026-08-12 11:20:30 +0200\n\ + CommentChar: ;\n\ + \n\ + title\n\nbody\n"; + let edit = parse(input)?; + assert_eq!( + edit.author, b"A U Thor ", + "the author identity is preserved" + ); + assert_eq!(edit.author_time.offset, 7200, "the author timezone is parsed"); + assert_eq!( + edit.committer, b"C O Mitter ", + "the committer identity is preserved" + ); + assert_eq!(edit.committer_time.offset, 7200, "the committer timezone is parsed"); + assert_eq!( + edit.message, b"title\n\nbody\n", + "the message is preserved byte-for-byte" + ); + Ok(()) + } + + #[test] + fn document_does_not_repeat_existing_agent_trailers() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_read_only("history.sh")?; + let repository = gix::open_opts( + fixture, + gix::open::Options::isolated().config_overrides(["core.editor=:".to_owned()]), + )?; + let topic = repository.find_reference("refs/heads/topic")?.id().detach(); + let (editor, document) = document(&repository, topic)?; + assert_eq!(editor, ":", "the configured editor is returned"); + assert!( + document + .windows(b"CommentChar: ;\n\n".len()) + .any(|line| line == b"CommentChar: ;\n\n"), + "the template declares its default comment prefix" + ); + assert!( + !document + .windows(b";Assisted-by:".len()) + .any(|line| line == b";Assisted-by:") + && !document + .windows(b";Co-authored-by:".len()) + .any(|line| line == b";Co-authored-by:"), + "existing trailer keys suppress model-specific suggestions regardless of their values" + ); + Ok(()) + } + + #[test] + fn offers_only_missing_agent_trailer_keys() { + assert_eq!( + missing_agent_trailers(b"title\n\nASSISTED-BY: another agent\n"), + [None, Some(CO_AUTHORED_BY)], + "trailer keys are matched case-insensitively and independently of their values" + ); + assert_eq!( + missing_agent_trailers(b"title\n\nco-AUTHORED-by: Someone \n"), + [Some(ASSISTED_BY), None], + "either missing trailer remains available for opt-in" + ); + } + + #[test] + fn cleanup_honors_git_style_comment_prefixes_and_opted_in_trailers() -> gix_testtools::Result { + let input = b"Author: A \n\ + AuthorDate: 2026-08-12 10:20:30 +0200\n\ + Committer: C \n\ + CommitterDate: 2026-08-12 11:20:30 +0200\n\ + CommentChar: //\n\ + \n\ + \nsubject \n\n\ninline // stays \n // indented stays\n//removed\n\nAssisted-by: GPT 5.6\n//Co-authored-by: GPT 5.6 \n"; + assert_eq!( + parse(input)?.message, + b"subject\n\ninline // stays\n // indented stays\n\nAssisted-by: GPT 5.6\n".as_bstr(), + "only column-zero comments are removed and Git whitespace cleanup is applied" + ); + + let empty_comment = input.replacen(b"CommentChar: //", b"CommentChar: ", 1); + assert!(parse(&empty_comment).is_err(), "the comment prefix cannot be empty"); + Ok(()) + } + + #[test] + fn rewrites_direct_refs_except_tags_and_remotes_and_signs_when_enabled() -> gix_testtools::Result { + if !gix_testtools::signature::program_available("ssh-keygen") { + return Ok(()); + } + let (_key_home, key) = gix_testtools::signature::ssh_private_key()?; + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let old_id = gix::open(fixture.path())?.head_id()?.detach(); + let git = |args: &[&str]| -> std::io::Result { + Command::new("git").arg("-C").arg(fixture.path()).args(args).status() + }; + for name in ["refs/patches/reword", "refs/tags/keep", "refs/remotes/origin/keep"] { + assert!( + git(&["update-ref", name, &old_id.to_string()])?.success(), + "the test reference is created" + ); + } + + let repository = gix::open_opts( + fixture.path(), + gix::open::Options::isolated().config_overrides([ + "commit.gpgSign=true".to_owned(), + "gpg.format=ssh".to_owned(), + format!("user.signingKey={}", key.display()), + format!( + "gpg.ssh.allowedSignersFile={}", + gix_testtools::signature::fixture("ssh-allowed-signers").display() + ), + ]), + )?; + let edited = b"Author: New Author \n\ + AuthorDate: 2026-08-12 10:20:30 +0200\n\ + Committer: New Committer \n\ + CommitterDate: 2026-08-12 11:20:30 +0200\n\ + CommentChar: ;\n\ + \n\ + rewritten title\n\nrewritten body\n\nAssisted-by: GPT 5.6\n;Co-authored-by: GPT 5.6 \n"; + let new_id = apply(&repository, old_id, edited)?.expect("the edited commit differs"); + let commit = repository.find_commit(new_id)?; + let decoded = commit.decode()?; + assert_eq!( + decoded.message, + b"rewritten title\n\nrewritten body\n\nAssisted-by: GPT 5.6\n".as_bstr() + ); + assert_eq!(decoded.author()?.name, b"New Author".as_bstr()); + assert!( + commit + .verify_signature()? + .expect("configured signing adds a signature") + .is_valid(), + "the rewritten commit has a valid configured signature" + ); + for name in ["refs/heads/main", "refs/patches/reword"] { + assert_eq!( + repository.find_reference(name)?.id().detach(), + new_id, + "{name} follows the rewrite" + ); + } + for name in ["refs/tags/keep", "refs/remotes/origin/keep"] { + assert_eq!( + repository.find_reference(name)?.id().detach(), + old_id, + "{name} is not rewritten" + ); + } + Ok(()) + } +} diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index f5a16461add..f63d55f6563 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -612,6 +612,9 @@ pub(crate) fn draw_with_worktree( ))]; if app.changes_focus.is_none() { footer_spans.push(Span::raw(" · Enter diff")); + if app.reword_shortcut_visible() { + footer_spans.push(Span::raw(" · r reword")); + } } if app.tree_changes_visible || app.worktree_changes_visible { footer_spans.push(match app.focus_feedback.take() { @@ -2096,8 +2099,7 @@ mod tests { terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; - let footer_text = - "#1 · ↑↓/jk move · h/l pan · Enter diff · [ align · o commit · c changes · v view · y copy · q quit"; + let footer_text = "#1 · ↑↓/jk move · h/l pan · Enter diff · r reword · [ align · o commit · c changes · v view · y copy · q quit"; let selected_line = "> ● 0101010 (HEAD) 1970-01-01 mapped author subject"; let mut expected = Buffer::with_lines([format!("{selected_line:<180}"), format!("{footer_text:<180}")]); for x in 0..11 { From 24e6995136357059a5d2d6de5e790f5074b44c76 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 11:13:13 +0200 Subject: [PATCH 045/282] feat: mark HEAD and dirty worktrees in tix Replace the HEAD commit disk with @ while retaining signature-state and selection coloring, so its position remains visible independently of reference label settings. When displayed worktree changes are non-empty, place D in the left marker column on HEAD. Keep > on a separately selected commit and restore the normal marker when the worktree pane is not rendered. --- gix-tix/src/ui.rs | 157 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 143 insertions(+), 14 deletions(-) diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index f63d55f6563..d0aba8b5934 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -239,6 +239,10 @@ pub(crate) fn draw_with_worktree( ) }), ); + let worktree_dirty = worktree_changes.is_some_and(|changes| !changes.paths.is_empty()) + && changes_panes + .iter() + .any(|pane| pane.pane == ChangePane::Worktree && pane.outer.height > 0); if app.changes_visible() { app.set_changes_layout( changes_layout, @@ -362,6 +366,11 @@ pub(crate) fn draw_with_worktree( let lane = lanes.lane(index); let y = body.y.saturating_add(index as u16); let selected = app.selected == Some(start + index); + let head = decorations.get(&visible_rows[index].id).is_some_and(|decorations| { + decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::Head) + }); let metadata_width = metadata.width(); let signature_color = signature_color(visible_rows[index].signature); let highlight = if selected && app.show_selection_tail { @@ -375,7 +384,14 @@ pub(crate) fn draw_with_worktree( color(highlight).add_modifier(Modifier::REVERSED) }); frame.render_widget( - Paragraph::new(if selected { "> " } else { " " }).style(style), + Paragraph::new(if head && worktree_dirty { + "D " + } else if selected { + "> " + } else { + " " + }) + .style(if selected { style } else { Style::default() }), Rect::new(body.x, y, body.width.min(2), 1), ); @@ -392,6 +408,7 @@ pub(crate) fn draw_with_worktree( graph_offset, highlight, visible_rows[index].signature, + head, ); let aligned = Rect::new( content.x.saturating_add(align_width as u16), @@ -416,6 +433,7 @@ pub(crate) fn draw_with_worktree( horizontal_offset, highlight, visible_rows[index].signature, + head, ); } let lane_offset = if align_metadata { @@ -1512,6 +1530,7 @@ fn color_graph( offset: usize, highlight: Option, signature: SignatureState, + head: bool, ) { for (x, symbol) in graph.chars().skip(offset).take(area.width as usize).enumerate() { if symbol.is_whitespace() { @@ -1524,7 +1543,11 @@ fn color_graph( } else { graph_style(offset.saturating_add(x) / 2) }; - frame.buffer_mut()[(area.x + x as u16, area.y)].set_style(style); + let cell = &mut frame.buffer_mut()[(area.x + x as u16, area.y)]; + if head && symbol == '●' { + cell.set_symbol("@"); + } + cell.set_style(style); } } @@ -2100,7 +2123,7 @@ mod tests { terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; let footer_text = "#1 · ↑↓/jk move · h/l pan · Enter diff · r reword · [ align · o commit · c changes · v view · y copy · q quit"; - let selected_line = "> ● 0101010 (HEAD) 1970-01-01 mapped author subject"; + let selected_line = "> @ 0101010 (HEAD) 1970-01-01 mapped author subject"; let mut expected = Buffer::with_lines([format!("{selected_line:<180}"), format!("{footer_text:<180}")]); for x in 0..11 { expected[(x, 0)].set_style(Style::default().add_modifier(Modifier::REVERSED)); @@ -2221,6 +2244,10 @@ mod tests { app.update(Action::ToggleRefs); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!(!rendered_row(&terminal).contains("HEAD"), "no refs hides regular refs"); + assert!( + rendered_row(&terminal).starts_with("> @"), + "HEAD keeps its graph marker" + ); assert!( !rendered_row(&terminal).contains("refs/patches"), "no refs hides special refs" @@ -2367,29 +2394,35 @@ mod tests { } #[test] - fn colors_commit_disks_by_signature_state() -> Result<(), Box> { + fn colors_commit_markers_by_signature_state() -> Result<(), Box> { let states = [ (SignatureState::Unsigned, Color::Blue), (SignatureState::Unverified, Color::Rgb(255, 165, 0)), (SignatureState::Verified, Color::Green), (SignatureState::Failed, Color::LightRed), ]; - let mut terminal = Terminal::new(TestBackend::new(2, states.len() as u16))?; + let mut terminal = Terminal::new(TestBackend::new(4, states.len() as u16))?; terminal.draw(|frame| { for (y, (state, _)) in states.iter().enumerate() { - color_graph( - frame, - Rect::new(0, y as u16, 2, 1), - "●─", - 0, - Some(signature_color(*state)), - *state, - ); + for (x, head) in [(0, false), (2, true)] { + frame.render_widget(Paragraph::new("●─"), Rect::new(x, y as u16, 2, 1)); + color_graph( + frame, + Rect::new(x, y as u16, 2, 1), + "●─", + 0, + Some(signature_color(*state)), + *state, + head, + ); + } } })?; for (y, (_, expected)) in states.iter().enumerate() { - for x in 0..2 { + assert_eq!(terminal.backend().buffer()[(0, y as u16)].symbol(), "●"); + assert_eq!(terminal.backend().buffer()[(2, y as u16)].symbol(), "@"); + for x in 0..4 { let cell = &terminal.backend().buffer()[(x, y as u16)]; assert_eq!(cell.fg, *expected); assert!(cell.modifier.contains(Modifier::REVERSED)); @@ -2398,6 +2431,102 @@ mod tests { Ok(()) } + #[test] + fn marks_dirty_head_independently_of_history_selection() -> Result<(), Box> { + let head = gix::ObjectId::Sha1([1; 20]); + let mut app = App::new(5); + app.extend_commits( + [head, gix::ObjectId::Sha1([2; 20])] + .into_iter() + .map(|id| Commit { + id, + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }) + .collect::>(), + ); + complete(&mut app); + let decorations = Decorations::from([( + head, + vec![Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }], + )]); + let dirty = Changes { + paths: vec![crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Unstaged, + source: None, + path: "dirty".into(), + lines: None, + }], + ..Changes::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 8))?; + + app.selected = Some(1); + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&dirty), + ); + })?; + assert!(rendered_line(&terminal, 0).starts_with("D @")); + assert!(rendered_line(&terminal, 1).starts_with("> ●")); + assert_eq!(terminal.backend().buffer()[(0, 0)].modifier, Modifier::empty()); + assert!( + terminal.backend().buffer()[(0, 1)] + .modifier + .contains(Modifier::REVERSED) + ); + + app.selected = Some(0); + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&dirty), + ); + })?; + assert!(rendered_line(&terminal, 0).starts_with("D @")); + assert!( + terminal.backend().buffer()[(0, 0)] + .modifier + .contains(Modifier::REVERSED) + ); + + app.changes_mode = Some(ChangesMode::Tree); + terminal.draw(|frame| { + super::draw_with_worktree( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + None, + Some(&dirty), + ); + })?; + assert!(rendered_line(&terminal, 0).starts_with("> @")); + Ok(()) + } + #[test] fn shows_signature_action_only_while_actionable() -> Result<(), Box> { let id = gix::ObjectId::Sha1([1; 20]); From 2d917f7da6049cf41c00254c5b5e5f535f184989 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 11:20:04 +0200 Subject: [PATCH 046/282] Document the gix-tix behavioral contract Capture the final behavior assembled by the tix patch stack: history traversal and projection, visual states and attribution, keyboard and Shift navigation, overlay layout, tree and worktree changes, diff presentation, signature verification, rewording, filesystem refresh and recovery, diagnostics, and bounded-resource requirements. Make the local agent instructions require user-visible, lifecycle, performance, and resource-ownership changes to update the specification and its regression coverage in the same semantic patch. --- gix-tix/AGENTS.md | 6 + gix-tix/spec.md | 331 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 gix-tix/spec.md diff --git a/gix-tix/AGENTS.md b/gix-tix/AGENTS.md index 9e05df11802..ea79bb28845 100644 --- a/gix-tix/AGENTS.md +++ b/gix-tix/AGENTS.md @@ -1,5 +1,11 @@ # gix-tix invariants +## Behavioral specification + +- Keep `spec.md` synchronized with every user-visible, lifecycle, performance, + or resource-ownership change to tix. Update the specification and its + regression coverage in the same semantic patch as the implementation. + ## Commit authorship - A commit created or materially rewritten by an AI agent must use that agent's own name and email as its author. Do not silently inherit the repository owner's configured identity. diff --git a/gix-tix/spec.md b/gix-tix/spec.md new file mode 100644 index 00000000000..e734095b376 --- /dev/null +++ b/gix-tix/spec.md @@ -0,0 +1,331 @@ +# gix-tix specification + +This document describes the intended behavior of `tix` on this branch. It is the +behavioral contract for future changes; implementation details belong here only +when they preserve responsiveness, bounded memory, Git compatibility, or resource +lifetime. + +## Purpose and invocation + +`tix` is a minimal, `tig`-inspired commit-history browser optimized for large +repositories. It must remain useful on histories as large as the Linux kernel +without trading responsiveness for metadata that is not visible. + +- `tix [REVISION]...` shows commits reachable from the supplied revisions, or + from `HEAD` when none are supplied. +- `-h/--hide REVSPEC` excludes the revision and its reachable ancestry. The + option may be repeated. +- `--quit-on-finish` exits after traversal and lane computation, for measurement + and non-interactive use. +- Revisions must resolve and peel to commits. Invalid or non-commit revisions are + errors. +- The UI always owns the alternate screen. Raw mode, focus reporting, mouse + capture, and enhanced keyboard reporting are restored on every exit path. +- `Ctrl-C` exits immediately from any normal tix focus. `q` quits from history; + `q` or `Escape` in a focused changes block returns focus to history. + +## History model + +### Traversal and projection + +- Traversal streams commits before graph-lane computation finishes. The footer + reports the number received while loading and switches to the selected row + number after completion. Rows are numbered from the bottom, so the oldest row + is `#1` and the newest row is `#N`. +- Commit topology, commit time, and generation are loaded through the same + commit-graph-or-ODB lookup model as `gix-traverse`. A small object cache avoids + repeated ODB decoding during a walk. +- Metadata already decoded from ODB is retained. Metadata omitted because a + commit came from the commit-graph is populated lazily for visible rows. +- The persistent graph is append-only and index-addressed, with one compact copy + of each commit and flat parent edges. View refreshes project rows from this + cache and stop walking when complete cached ancestry is reached. +- Local branch targets are reverse-indexed. Configured upstream targets are added + as internal traversal tips so ahead/behind calculations have complete ancestry + without a second repository walk. +- Shallow boundaries are honored. Parent topology needed by future projections, + hidden expansion, and ahead/behind calculations must not be pruned with the + currently visible lane graph. + +### Hidden history + +- Hidden ancestry is removed from the selectable view by default. Direct parents + that connect visible history to hidden history remain as boundary rows. +- Boundary rows retain graph styling but use terminal-default colors, are dimmed, + and cannot be selected, paged to, copied, signature-verified, restored as a + selection, or entered by Shift navigation. +- When hidden revisions are configured, references are hidden by default so + metadata remains aligned. +- `v`, then `h`, toggles the full hidden projection. Toggling preserves the + selected commit when it still exists and otherwise selects the newest + selectable row. + +### Row content and visual states + +- A row contains graph lanes, a seven-character object ID, optional references, + committer date, author and attribution information, markers, and title. +- The commit marker is blue when unsigned, orange when signed but unverified or + being verified, green when verified, and bright red when verification fails. +- The current `HEAD` commit uses `@` instead of the normal commit disc and keeps + the same signature and selection coloring. It remains visible when textual + reference labels are hidden. +- The selected row uses `>` at the left. If the displayed worktree block is dirty, + `D` is shown at the `HEAD` row instead; a separately selected row retains `>`. +- Selection inversion covers the left marker, graph, commit marker, and hash. + Its graph background is derived from the commit-marker color. The selected + row's right-hand tail and contextual information always have blank margins and + never invert an adjacent character. +- A compared merge parent is cyan, including its commit marker, and its hash is + inverted. +- Rows outside active Shift reachability are dimmed. When a changes block has + focus, history is dimmed but its contextual selection information and main + status line remain prominent. + +### Metadata and attribution + +- Mailmap resolution is enabled by default and is obtained from a non-isolated + repository. +- Recognized attribution trailers are `Co-authored-by`, `Assisted-by`, + `Reviewed-by`, `Acked-by`, `Tested-by`, and `Signed-off-by`. +- Every displayed `Assisted-by` value is classified as an agent. Agent names are + bracketed and agent emails are never displayed. +- Attribution keys with identical displayed actor lists are grouped, for example + `Co, A: [GPT 5.6]`. +- Actors whose email ends in `@users.noreply.github.com` are italicized. +- Full-actor mode shows author emails and attribution actors but hides the commit + title. Classified agent emails remain hidden. +- A commit message containing `--- agent` or `` receives a bright + purple `[A]` before its title. +- A commit with notes in the configured notes ref receives a matching `[N]`. + Notes are loaded lazily for visible commits. + +### Selection context + +- When tree changes are displayed, non-zero insertion and deletion counts for + the selected commit appear immediately before the right selection tail. +- When a selected commit is pointed to by local refs, display at most one + deterministic relationship. Prefer a configured-upstream relation as + `⇡ahead⇣behind`; otherwise, when hidden ancestry exists, show the visible-only + count as `⇡N`. +- Relationship walks use the in-memory graph, stop once no further distinction + can be made, and cache completed results. They must never reopen a repository + merely because selection moved. + +## Interaction + +### Navigation and display controls + +| Key | Behavior | +| --- | --- | +| `j`/Down, `k`/Up | Move one selectable row or changed path. | +| Mouse/trackpad vertical scroll | Move history by the coalesced scroll distance; move paths when a changes block is focused. | +| `h`/`l` | Pan history or the focused changes block horizontally. | +| `Ctrl-u`/`Ctrl-d` | Move half a page. | +| `Ctrl-b`/`Ctrl-f`, `PageUp`/`PageDown` | Move a page; scroll an overflowing commit message when applicable. | +| `g`/Home, `G`/End | Select the newest/top or oldest/bottom selectable item. | +| `[` | Toggle graph/metadata alignment. | +| `v` | Toggle the history-display key group. Pressing `v` again closes it. | +| `v d` | Toggle committer dates. | +| `v e` | Toggle full actors/emails and titles. | +| `v n` | Cycle all attribution, author only, and no names, skipping inert states. | +| `v t` | Toggle attribution trailers. | +| `v m` | Toggle mailmap resolution. | +| `v r` | Cycle all, normal, and no reference labels. | +| `v h` | Show or hide configured hidden ancestry. | +| `Shift-R` | Explicitly refresh the revision view and visible worktree status. | +| `y` | Copy the selected commit ID, or the selected raw path when a changes block is focused. | +| `Shift-y`/`Y` | Copy the selected author as `Name `. | +| `s` | Verify signed, unverified commits currently visible on screen. | + +The display group remains open for consecutive display changes and closes on +navigation or another recognized command. `[` and overlay controls remain direct +shortcuts. + +### Held Shift ancestry mode + +- On terminals that report modifier press and release events, pressing Shift in + focused history anchors navigation at the selected commit. Releasing Shift + restores ordinary navigation. +- `j` and `k` then visit only commits reachable through the selected rail; other + rows are dimmed. +- A non-merge anchor follows all of its ancestry. A merge anchor initially uses + its second parent, excluding first-parent ancestry except shared fork points. +- `h` and `l` cycle the merge anchor's parents instead of panning. The chosen + parent is numbered beside the junction marker. Later merges on the chosen rail + traverse all their parents normally. +- Reachability is computed only after traversal and lane computation complete. + Shift is ignored while another changes block has focus or while the terminal + itself is unfocused. + +## Overlay views + +Overlay views paint over history without changing metadata alignment. Selection +is bounded above the top-most changes block: moving down at that boundary scrolls +history so the selected row stays visible. The commit view reserves right-side +space first; changes blocks adapt within the remaining history width. + +### Commit message + +- `o` or `]` toggles the commit view on the right. It uses at most half the + terminal and reserves 80 content columns when space permits. +- The panel has a minimally shaded background derived from the detected terminal + background, with the default background as fallback. +- The title begins on the first content row and is bold. Body text follows, then + each note with a bold purple `Notes` prefix, then aligned trailers. +- Overflow is page-scrollable and gets a distinct pane status line only when + scrolling is possible. + +### Tree and worktree changes + +- Changes start enabled as `Tree + Worktree`. `c` cycles `Tree + Worktree` → + `Tree` → hidden. Bare repositories omit the worktree mode. +- Each block has a top border carrying its compact summary. Tree summaries show + the selected short hash; worktree summaries distinguish staged and unstaged + counts. Kind totals, total files when non-redundant, and non-zero line totals + are color-coded. An empty enabled worktree says `Worktree clean` in green. +- Tree paths preserve tree-diff order. Worktree paths show staged entries first in + green and unstaged/untracked/conflicted entries second in bright red, sorted by + raw path within each group. +- Path kinds are `A`, `M`, `D`, `R`, `C`, `T`, and `U`. The selected path is + subtly inverted and appends its already-computed non-zero line counts. +- Blocks are side by side when both condensed titles fit, otherwise Worktree is + stacked above Tree. A shared vertical divider joins side-by-side blocks. Blocks + size to content but together use no more than half the terminal. +- If paths overflow, the final row reports the remaining line count and updates + while scrolling. A single path is never replaced by overflow text. +- `Tab` cycles focus in visual order through visible changes blocks and history. + Inactive blocks, including paths and borders, are dimmed. Only the focused + block shows its distinct status line. +- `p` cycles the comparison parent while Tree has focus. Merge commits are + compared to one parent at a time; root commits compare against an empty tree. +- Repeated history keys and vertical mouse bursts temporarily hide changes + overlays. They return after 75 ms of navigation idle, with the same path + selection and viewport where possible. +- Tree diff results, detached diff resources, and line counts use a bounded MRU + while changes remain enabled. Worktree results are cached separately and + invalidated by relevant filesystem events. +- Per-file line information is computed once in an `available_parallelism` + worker pool that exists only while changes are enabled. + +### Diffs + +- `Enter` in history opens the whole selected commit against the active parent. + `Enter` in a focused changes block opens only its selected path. +- A whole-commit diff starts with commit identity and a Git-style per-path + diffstat in diff order, followed by parent/root, kind totals, and aggregate line + totals. It then shows the internal patch and invokes any per-path external diff + drivers. +- Diff preparation honors Git attributes, text conversion, binary detection, + external diff commands, and the configured `core.pager` pipeline. +- Binary, submodule, conflicted, and otherwise unavailable file diffs do not + launch an inappropriate pager; the changes status line reports the reason. +- The built-in viewer takes over the alternate screen and supports the same + vertical and horizontal navigation keys. `Enter` advances from a whole-commit + internal diff to external drivers; `q` or `Escape` returns to tix. +- External programs run with the terminal suspended and restored afterward. + Broken-pipe writes are accepted. If a pager exits within 250 ms, its already + displayed output is retained until a keypress so short output remains readable. + +## Signature verification and rewording + +### Signatures + +- Presence of `gpgsig` or `gpgsig-sha256` marks a commit as signed but + unverified; history loading does not validate signatures eagerly. +- The `s` hint appears only while the viewport has work to verify and disappears + after success. Verification uses Git-compatible repository configuration. +- Failures show their count with a bright-red marker. Moving the history + selection resets failed visible states to unverified so verification can be + retried. + +### Reword + +- `r` is available only after history completion and only on the newest + selectable row, where tix assumes the commit has no displayed descendants. +- The configured Git editor receives a document containing `Author`, + `AuthorDate`, `Committer`, `CommitterDate`, `CommentChar`, and the complete + message. Author identity and time are retained; committer time defaults to now. +- `CommentChar` is a non-empty single-line byte prefix, defaults to `;`, and is + recognized only at column zero. Parsing removes those lines and applies + Git-style whitespace cleanup. +- Missing `Assisted-by: GPT 5.6` and + `Co-authored-by: GPT 5.6 ` trailers are offered as commented + opt-ins. A case-insensitive existing trailer key suppresses its suggestion, + regardless of value. +- An unchanged editor document is a no-op. Otherwise tix recreates the commit, + signs it when commit-signing configuration is enabled, and atomically retargets + mutable local refs that pointed directly at the old commit. Tags and + remote-tracking refs remain unchanged; a detached `HEAD` is retargeted. +- Editor, signing, parsing, writing, or reference-update failures are shown in + the main status line and do not leave a repository retained by the UI. + +## Refresh, focus, and diagnostics + +- Native reference watchers observe `HEAD`, loose and packed refs, and the direct + or symbolic refs used by view and hide revspecs. Missing refs during an atomic + update are transient; malformed or inaccessible refs remain errors. +- Ref changes that affect view or hidden tips trigger an incremental history + refresh. Decoration-only changes avoid traversal. Filesystem-driven traversal + changes select the newest selectable row; manual refresh and display toggles + preserve selection when possible. +- The worktree watcher exists only while the combined worktree block is enabled. + It observes the index and ignore-aware directories that Git status would walk, + using non-recursive registrations so ignored build trees do not generate work. +- Access-only and incomplete `.lock` activity are ignored. Completed atomic + renames, index/HEAD updates, relevant worktree paths, and backend rescan requests + invalidate the appropriate cache. +- Worktree updates retain the history selection and restore changed-path + selection by raw path and relative viewport position. They never select the + newest commit merely because status changed. +- Event batches are bounded and coalesced. Worktree status waits 75 ms of quiet; + reference transactions wait for their final update. Watchers retry after + failure while still needed. +- Refresh status remains hidden for 500 ms so quick background work does not + flicker the footer. +- A filesystem history refresh is presented immediately. New or replaced visible + rows are bold for 180 ms, matched first by commit ID and then by tree ID so + rewords retain visual identity. Removals, unrelated replacements, manual + actions, hidden toggles, and worktree-only updates are immediate without + emphasis. Input, focus changes, and resizing end emphasis immediately. +- While the terminal is unfocused, filesystem-attributed redraws replace footer + separators with persistent orange discs. Focus restores normal separators. +- Filesystem responses receive correlated IDs in daily tracing logs, including + semantic trigger, coalesced paths, phases, presentation count, elapsed time, + and outcome. Logs use the platform application-log directory, retain seven + days, and are best-effort. +- If a linked worktree disappears, tix lexically normalizes and enters the common + repository, reopens it as bare, drops worktree state, keeps tree/history views + live, and reports recovery in the status line. If recovery fails, terminal state + is restored and the contextual error is returned. + +## Resource and responsiveness invariants + +- No `gix::Repository`, commit-graph, object platform, notes platform, or other + repository-owning value may remain in idle application/event-loop state. +- View population opens a fresh non-isolated repository so mailmap, notes, diff + drivers, pagers, signing, and other Git configuration are current. It starts + without an object cache; bounded diff operations may enable one temporarily and + disable it again before any navigation reuse. Detached display data is retained. +- One fill repository may be shared by commit, tree, worktree, and metadata loads + during continuous key-repeat or mouse navigation. It is dropped after the + 75 ms idle boundary. +- Traversal and incremental refresh workers may use a bounded object cache and + must drop their repository when finished. Lane, verification, and line-diff + workers exist only for active work and do not form persistent pools. +- Redraw is reactive and capped at approximately 60 frames per second while + streaming. Mouse events are drained and coalesced in bounded batches so input + storms cannot starve the main loop. +- Main status remains readable regardless of pane focus. Errors are surfaced in + the nearest relevant status line; diagnostics never replace user-visible + errors. + +## Regression coverage + +- Unit tests cover navigation, projections, pane layout, status summaries, + selection restoration, watcher classification, cached graph walks, diff + preparation, signatures, rewording, and terminal rendering. +- Filesystem row emphasis uses `insta` snapshots containing every distinct frame; + unchanged hold frames are omitted. Run `cargo insta test -p gix-tix -F sha1` + and review with `cargo insta review`; never edit snapshots manually. +- Behavior changes to this specification require corresponding tests and an + update to this document in the same semantic patch. From 1b53cfff47e6e1807041de5c3fd9ec3cdc36948d Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 11:23:15 +0200 Subject: [PATCH 047/282] feat: use a Markdown buffer for tix rewording Give the temporary commit-edit document an .md suffix so editors can select Markdown syntax highlighting while retaining automatic cleanup and the existing Git-selected editor flow. Record the filename contract in the tix specification. --- gix-tix/spec.md | 3 ++- gix-tix/src/lib.rs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/gix-tix/spec.md b/gix-tix/spec.md index e734095b376..7e4b199c6b8 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -244,7 +244,8 @@ space first; changes blocks adapt within the remaining history width. selectable row, where tix assumes the commit has no displayed descendants. - The configured Git editor receives a document containing `Author`, `AuthorDate`, `Committer`, `CommitterDate`, `CommentChar`, and the complete - message. Author identity and time are retained; committer time defaults to now. + message in a temporary `.md` file for syntax highlighting. Author identity and + time are retained; committer time defaults to now. - `CommentChar` is a non-empty single-line byte prefix, defaults to `;`, and is recognized only at column zero. Parsing removes those lines and applies Git-style whitespace cleanup. diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index f541a2514ce..0ed87c54105 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -2523,8 +2523,8 @@ fn reword_commit( repository.object_cache_size(None); reword::document(&repository, id)? }; - let mut tempfile = gix::tempfile::new( - std::env::temp_dir(), + let mut tempfile = gix::tempfile::writable_at( + std::env::temp_dir().join(format!("tix-reword-{}.md", std::process::id())), gix::tempfile::ContainingDirectory::Exists, gix::tempfile::AutoRemove::Tempfile, ) From acfef7e642f296e8497c790a1fc1ec175eace50a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 13:58:44 +0200 Subject: [PATCH 048/282] feat: add time-travel checkouts to tix Let t detach HEAD at the selected commit through git checkout while preserving descendants that would otherwise disappear behind namespaced refs/tix/pins references. Discover applicable pins for detached HEAD even with explicit revisions, follow symbolic branch targets as they advance, and return through a selected pin by restoring its branch or detached commit before removing it. Use the cached history graph to decide whether the old tip needs protection and discard provisional pins when existing view tips already retain it. Keep malformed, dangling, and non-commit pins out of history, surface checkout failures without forcing local changes, refresh history and worktree state after success, and document the lifecycle and UI contract. --- gix-tix/spec.md | 23 +++ gix-tix/src/app.rs | 25 +++ gix-tix/src/history.rs | 175 ++++++++++++++++-- gix-tix/src/lib.rs | 29 ++- gix-tix/src/time_travel.rs | 363 +++++++++++++++++++++++++++++++++++++ gix-tix/src/ui.rs | 69 +++++++ 6 files changed, 671 insertions(+), 13 deletions(-) create mode 100644 gix-tix/src/time_travel.rs diff --git a/gix-tix/spec.md b/gix-tix/spec.md index 7e4b199c6b8..acbc8ef57ce 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -136,11 +136,34 @@ without trading responsiveness for metadata that is not visible. | `y` | Copy the selected commit ID, or the selected raw path when a changes block is focused. | | `Shift-y`/`Y` | Copy the selected author as `Name `. | | `s` | Verify signed, unverified commits currently visible on screen. | +| `t` | Time-travel to the selected commit, or return through its tix pin. | The display group remains open for consecutive display changes and closes on navigation or another recognized command. `[` and overlay controls remain direct shortcuts. +### Time-travel + +- On a completed, focused history in a worktree repository, `t` on a non-`HEAD` + row runs `git checkout --detach ` without forcing local changes. +- If the selected commit is known to be an ancestor of the previous `HEAD`, tix + retains descendants that would otherwise leave the view with a + `refs/tix/pins/` ref. Pins use at least four alphanumeric characters; + generated pins start with eight hexadecimal characters from the saved commit. +- A pin is symbolic when the previous `HEAD` named a local branch, so later branch + advances move the pinned tip. An already detached `HEAD` receives a direct pin. +- While `HEAD` is detached, applicable pins augment implicit and explicit revision + tips when their ancestry contains `HEAD`. Unrelated, dangling, malformed, and + non-commit pins do not enter the view or its decorations. Normal hidden-revision + exclusions still apply. +- Applicable pins are shown as blue `pin:` decorations. `t` on a pinned + tip checks out its underlying branch, or its direct commit in detached mode, + then removes that one pin. Multiple matching pins prefer symbolic targets and + then lexical ref-name order. +- Checkout failures retain the original `HEAD` and remove any newly created + provisional pin. Successful travel preserves the selected row, refreshes + history directly, and invalidates worktree status. + ### Held Shift ancestry mode - On terminals that report modifier press and release events, pressing Shift in diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index a46a03b5bc8..2db09a7a43b 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -283,6 +283,7 @@ pub(crate) enum Action { CycleChangesParent, OpenDiff, Reword, + TimeTravel, VerifySignatures, Cancel, Copy, @@ -303,6 +304,7 @@ pub(crate) enum Effect { OpenDiff(ChangePane, usize), OpenCommitDiff(ObjectId), Reword(ObjectId), + TimeTravel(ObjectId), VerifySignatures(Vec), Quit, } @@ -763,6 +765,11 @@ impl App { self.rows[self.selected.expect("reword requires a selection")].id, )]; } + Action::TimeTravel if self.time_travel_shortcut_visible() => { + return vec![Effect::TimeTravel( + self.rows[self.selected.expect("time-travel requires a selection")].id, + )]; + } Action::VerifySignatures if !self.signature_verification_running => { let start = self.offset.min(self.rows.len()); let end = start.saturating_add(self.viewport_rows).min(self.rows.len()); @@ -1215,6 +1222,13 @@ impl App { && self.selected == self.first_selectable() } + pub(crate) fn time_travel_shortcut_visible(&self) -> bool { + self.worktree_changes_available + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self.selected.is_some() + } + fn last_selectable(&self) -> Option { (0..self.rows.len()).rev().find(|index| !self.is_row_hidden(*index)) } @@ -1878,6 +1892,17 @@ mod tests { assert!(app.update(Action::Reword).is_empty()); } + #[test] + fn time_travel_requires_completed_history_and_a_worktree() { + let mut app = App::new(10); + app.extend_commits(vec![row(1)]); + assert!(app.update(Action::TimeTravel).is_empty()); + complete(&mut app); + assert_eq!(app.update(Action::TimeTravel), vec![Effect::TimeTravel(id(1))]); + app.set_worktree_changes_available(false); + assert!(app.update(Action::TimeTravel).is_empty()); + } + #[test] fn lane_computation_keeps_provisional_rows_interactive() { let mut app = App::new(2); diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index 3e743f8bb58..2e494e750a3 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -29,6 +29,7 @@ pub(crate) struct Decoration { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum DecorationKind { Head, + Pin, Local, Remote, Tag, @@ -38,6 +39,15 @@ pub(crate) enum DecorationKind { pub(crate) type Decorations = HashMap>; +pub(crate) const PIN_PREFIX: &[u8] = b"refs/tix/pins/"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Pin { + pub name: gix::refs::FullName, + pub target: gix::refs::Target, + pub id: ObjectId, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct SelectionRef { pub name: BString, @@ -152,6 +162,24 @@ impl HistoryGraph { &self.parents[range.start as usize..range.end as usize] } + pub(crate) fn is_ancestor(&self, ancestor: ObjectId, descendant: ObjectId) -> bool { + let (Some(ancestor), Some(descendant)) = (self.index(ancestor), self.index(descendant)) else { + return false; + }; + let mut seen = vec![false; self.commits.len()]; + let mut pending = vec![descendant]; + while let Some(index) = pending.pop() { + if index == ancestor { + return true; + } + if std::mem::replace(&mut seen[index.as_usize()], true) { + continue; + } + pending.extend_from_slice(self.parents(index)); + } + false + } + fn parent_ids(&self, index: CommitIndex) -> gix::traverse::commit::ParentIds { self.parents(index).iter().map(|parent| self.id(*parent)).collect() } @@ -546,9 +574,10 @@ impl HistoryGraph { } } self.tracking = tracking; + let decorations = decorations(repo, &refs.pins)?; Ok(Refresh { refs, - decorations: decorations(repo)?, + decorations, commits: LoadedCommits { rows, attributions }, }) } @@ -560,6 +589,7 @@ pub(crate) struct RefSnapshot { pub hidden: HashMap, pub view_tips: Vec, pub hidden_tips: Vec, + pub pins: Vec, } #[derive(Debug)] @@ -732,15 +762,17 @@ pub(crate) fn load( cancelled: &AtomicBool, mut emit: impl FnMut(Event) -> bool, ) -> Result<()> { - let Some(tips) = resolve_tips(repo, revisions)? else { - emit(Event::Decorations(decorations(repo)?)); + let refs = snapshot(repo, revisions, hidden_revisions)?; + let tips = refs.view_tips; + if tips.is_empty() { + emit(Event::Decorations(decorations(repo, &refs.pins)?)); emit(Event::VisibleComplete); emit(Event::Complete(HistoryGraph::default())); return Ok(()); - }; - let hidden_tips = resolve_revisions(repo, hidden_revisions, "hidden ")?; + } + let hidden_tips = refs.hidden_tips; - if !emit(Event::Decorations(decorations(repo)?)) { + if !emit(Event::Decorations(decorations(repo, &refs.pins)?)) { return Ok(()); } let shallow: HashSet<_> = repo @@ -954,14 +986,101 @@ pub(crate) fn load( } pub(crate) fn snapshot(repo: &gix::Repository, revisions: &[OsString], hidden: &[OsString]) -> Result { + snapshot_ignoring_pin(repo, revisions, hidden, None) +} + +pub(crate) fn snapshot_ignoring_pin( + repo: &gix::Repository, + revisions: &[OsString], + hidden: &[OsString], + ignored_pin: Option<&BStr>, +) -> Result { + let pins = applicable_pins(repo)? + .into_iter() + .filter(|pin| ignored_pin != Some(pin.name.as_bstr())) + .collect::>(); + let mut view = referenced_refs(repo, revisions)?; + for pin in &pins { + insert_ref_chain(repo, pin.name.as_bstr(), &mut view)?; + } + let mut view_tips = resolve_tips(repo, revisions)?.unwrap_or_default(); + view_tips.extend(pins.iter().map(|pin| pin.id)); + let mut seen = HashSet::new(); + view_tips.retain(|id| seen.insert(*id)); Ok(RefSnapshot { - view: referenced_refs(repo, revisions)?, + view, hidden: referenced_refs(repo, hidden)?, - view_tips: resolve_tips(repo, revisions)?.unwrap_or_default(), + view_tips, hidden_tips: resolve_revisions(repo, hidden, "hidden ")?, + pins, }) } +pub(crate) fn all_pins(repo: &gix::Repository) -> Result> { + let mut out = Vec::new(); + let references = repo.references().context("could not open references")?; + for reference in references + .prefixed(PIN_PREFIX.as_bstr()) + .context("could not iterate tix pins")? + { + let mut reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => return Err(anyhow::anyhow!("could not read tix pin: {err}")), + }; + let suffix = reference.name().as_bstr().strip_prefix(PIN_PREFIX).unwrap_or_default(); + if suffix.len() < 4 || !suffix.iter().all(u8::is_ascii_alphanumeric) { + tracing::warn!(name = %reference.name(), "ignoring malformed tix pin"); + continue; + } + let name = reference.name().to_owned(); + let target = reference.target().into_owned(); + let id = match reference.peel_to_id() { + Ok(id) => id.detach(), + Err(err) => { + tracing::warn!(name = %name, error = %err, "ignoring unresolved tix pin"); + continue; + } + }; + match repo.find_header(id) { + Ok(header) if header.kind() == gix::object::Kind::Commit => {} + Ok(_) => { + tracing::warn!(name = %name, "ignoring tix pin that does not resolve to a commit"); + continue; + } + Err(err) => { + tracing::warn!(name = %name, error = %err, "ignoring unreadable tix pin target"); + continue; + } + } + out.push(Pin { name, target, id }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(out) +} + +pub(crate) fn applicable_pins(repo: &gix::Repository) -> Result> { + let head = repo.head().context("could not read HEAD while resolving tix pins")?; + if !head.is_detached() { + return Ok(Vec::new()); + } + let Some(head) = head.id().map(gix::Id::detach) else { + return Ok(Vec::new()); + }; + all_pins(repo)? + .into_iter() + .filter_map(|pin| match repo.merge_base(head, pin.id) { + Ok(base) => Some(Ok((base.as_ref() == head).then_some(pin))), + Err(gix::repository::merge_base::Error::NotFound { .. }) => None, + Err(err) => Some(Err(err).context("could not determine tix pin reachability")), + }) + .filter_map(|result| match result { + Ok(pin) => pin.map(Ok), + Err(err) => Some(Err(err)), + }) + .collect() +} + fn referenced_refs(repo: &gix::Repository, revisions: &[OsString]) -> Result> { let implicit_head = OsString::from("HEAD"); let revisions = if revisions.is_empty() { @@ -1155,8 +1274,9 @@ impl Authors { } } -pub(crate) fn decorations(repo: &gix::Repository) -> Result { +pub(crate) fn decorations(repo: &gix::Repository, pins: &[Pin]) -> Result { let mut out = Decorations::new(); + let pins: HashSet<_> = pins.iter().map(|pin| pin.name.as_bstr()).collect(); for reference in repo .references() .context("could not open references")? @@ -1168,6 +1288,10 @@ pub(crate) fn decorations(repo: &gix::Repository) -> Result { Err(err) if is_missing_ref(&*err) => continue, Err(err) => return Err(anyhow::anyhow!("could not read reference: {err}")), }; + let pin_suffix = reference.name().as_bstr().strip_prefix(PIN_PREFIX).map(BString::from); + if pin_suffix.is_some() && !pins.contains(reference.name().as_bstr()) { + continue; + } let mut kind = decoration_kind(reference.name().as_bstr()); if kind == DecorationKind::Tag { let annotated = match reference.try_id() { @@ -1182,7 +1306,10 @@ pub(crate) fn decorations(repo: &gix::Repository) -> Result { continue; }; let id = id.detach(); - let mut name = reference.name().shorten().to_owned(); + let mut name = pin_suffix.map_or_else( + || reference.name().shorten().to_owned(), + |suffix| format!("pin:{}", suffix.to_str_lossy()).into(), + ); if matches!(kind, DecorationKind::Tag | DecorationKind::AnnotatedTag) { name.insert_str(0, "tag: "); } @@ -1216,7 +1343,9 @@ fn is_missing_ref(mut err: &(dyn std::error::Error + 'static)) -> bool { } fn decoration_kind(name: &[u8]) -> DecorationKind { - if name.starts_with(b"refs/heads/") { + if name.starts_with(PIN_PREFIX) { + DecorationKind::Pin + } else if name.starts_with(b"refs/heads/") { DecorationKind::Local } else if name.starts_with(b"refs/tags/") { DecorationKind::Tag @@ -1739,6 +1868,7 @@ mod tests { #[test] fn classifies_reference_kinds() { + assert_eq!(decoration_kind(b"refs/tix/pins/abcd"), DecorationKind::Pin); assert_eq!(decoration_kind(b"refs/heads/main"), DecorationKind::Local); assert_eq!(decoration_kind(b"refs/tags/v1"), DecorationKind::Tag); assert_eq!(decoration_kind(b"refs/remotes/origin/main"), DecorationKind::Remote); @@ -1746,6 +1876,29 @@ mod tests { assert_eq!(decoration_kind(b"refs/stash"), DecorationKind::Special); } + #[test] + fn ignores_malformed_and_non_commit_pins() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repo = crate::open_test_repository(fixture.path())?; + let head = repo.head_id()?.detach(); + let blob = repo.write_blob(b"not a commit")?.detach(); + repo.reference( + "refs/tix/pins/a", + head, + gix::refs::transaction::PreviousValue::MustNotExist, + "test malformed pin", + )?; + repo.reference( + "refs/tix/pins/abcd", + blob, + gix::refs::transaction::PreviousValue::MustNotExist, + "test non-commit pin", + )?; + + assert!(all_pins(&repo)?.is_empty(), "invalid pins never enter history"); + Ok(()) + } + #[test] fn interns_raw_author_identities() { let mut authors = Authors::default(); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 0ed87c54105..40619db8acf 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -7,6 +7,7 @@ mod app; mod history; mod logging; mod reword; +mod time_travel; mod ui; use std::{ @@ -1611,6 +1612,26 @@ fn event_loop( Err(err) => app.notice = Some(format!("reword: {err:#}")), } } + Effect::TimeTravel(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + let result = history_graph + .as_ref() + .context("time-travel requires a completed history graph") + .and_then(|graph| { + time_travel::perform(&repository_path, repository_is_bare, id, graph, &revisions) + }); + match result { + Ok(Some(notice)) => { + tracing::info!(selected = %id, %notice, "completed time-travel action"); + app.notice = Some(notice); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Ok(None) => {} + Err(err) => app.notice = Some(format!("time-travel: {err:#}")), + } + } Effect::VerifySignatures(ids) => { verification_receiver = Some(start_signature_verification( repository_path.clone(), @@ -3234,6 +3255,7 @@ fn action_with_history_display(key: KeyEvent, history_display_expanded: bool) -> KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Refresh), KeyCode::Char('r') if history_display_expanded => Some(Action::ToggleRefs), KeyCode::Char('r') => Some(Action::Reword), + KeyCode::Char('t') => Some(Action::TimeTravel), KeyCode::Char('s') => Some(Action::VerifySignatures), KeyCode::Char('v') => Some(Action::ToggleHistoryDisplay), KeyCode::Char('[') => Some(Action::ToggleAlign), @@ -3549,7 +3571,7 @@ mod tests { }, )?; let mut graph = graph.expect("history traversal returns its graph"); - let refs = graph.selection_refs(topic, &history::decorations(&repository)?); + let refs = graph.selection_refs(topic, &history::decorations(&repository, &[])?); assert_eq!(refs[0].upstream, Some(Some(main))); assert_eq!( graph.selection_relation(topic, &refs, &[]), @@ -4059,12 +4081,15 @@ mod tests { assert_eq!(action(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), None); assert_eq!(action(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), None); assert_eq!(action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), None); - assert_eq!(action(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)), None); assert_eq!(action(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)), None); assert_eq!( action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), Some(Action::Reword) ); + assert_eq!( + action(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)), + Some(Action::TimeTravel) + ); assert_eq!( action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::SHIFT)), Some(Action::Refresh), diff --git a/gix-tix/src/time_travel.rs b/gix-tix/src/time_travel.rs new file mode 100644 index 00000000000..2fe5f2efc8c --- /dev/null +++ b/gix-tix/src/time_travel.rs @@ -0,0 +1,363 @@ +use std::{ffi::OsString, path::Path, process::Command}; + +use anyhow::{Context, Result}; +use gix::{ + ObjectId, + bstr::ByteSlice, + refs::{ + Target, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + }, +}; + +use crate::{history, open_repository}; + +pub(crate) fn perform( + repository_path: &Path, + bare: bool, + selected: ObjectId, + graph: &history::HistoryGraph, + revisions: &[OsString], +) -> Result> { + let repository = + open_repository(repository_path, bare, false).context("could not open repository for time-travel")?; + let workdir = repository + .workdir() + .context("time-travel requires a worktree")? + .to_owned(); + let head = repository.head().context("could not read HEAD before time-travel")?; + let Some(head_id) = head.id().map(gix::Id::detach) else { + anyhow::bail!("cannot time-travel from an unborn HEAD"); + }; + if selected == head_id { + return Ok(None); + } + if let Some(pin) = selected_pin(&repository, selected)? { + drop(repository); + checkout_pin(&workdir, &pin)?; + let repository = open_repository(repository_path, bare, false) + .context("could not reopen repository after returning from time-travel")?; + return Ok(Some(match delete_pin(&repository, &pin) { + Ok(()) => format!("returned from {}", pin_label(&pin)), + Err(err) => format!("returned from {}; pin remains: {err:#}", pin_label(&pin)), + })); + } + let saved_target = head + .referent_name() + .map(|name| Target::Symbolic(name.to_owned())) + .unwrap_or(Target::Object(head_id)); + let provisional = graph + .is_ancestor(selected, head_id) + .then(|| create_or_reuse_pin(&repository, saved_target, head_id)) + .transpose()?; + drop(repository); + + if let Err(checkout) = checkout_detached(&workdir, selected) { + if let Some((pin, true)) = &provisional { + let cleanup = open_repository(repository_path, bare, false) + .context("could not reopen repository to remove a provisional pin") + .and_then(|repository| delete_pin(&repository, pin)); + if let Err(cleanup) = cleanup { + return Err(checkout.context(format!( + "checkout failed and {} could not be removed: {cleanup:#}", + pin_label(pin) + ))); + } + } + return Err(checkout); + } + + let mut notice = format!("time-travelled to {}", selected.to_hex_with_len(7)); + if let Some((pin, true)) = provisional { + let repository = + open_repository(repository_path, bare, false).context("could not reopen repository after time-travel")?; + let snapshot = history::snapshot_ignoring_pin(&repository, revisions, &[], Some(pin.name.as_bstr()))?; + if snapshot + .view_tips + .iter() + .copied() + .any(|tip| contains(&repository, head_id, tip)) + { + if let Err(err) = delete_pin(&repository, &pin) { + notice = format!("{notice}; redundant {} remains: {err:#}", pin_label(&pin)); + } + } else { + notice = format!("{notice}; saved {}", pin_label(&pin)); + } + } + Ok(Some(notice)) +} + +fn selected_pin(repository: &gix::Repository, selected: ObjectId) -> Result> { + let mut pins: Vec<_> = history::applicable_pins(repository)? + .into_iter() + .filter(|pin| pin.id == selected) + .collect(); + pins.sort_by(|a, b| { + a.target + .try_name() + .is_none() + .cmp(&b.target.try_name().is_none()) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(pins.into_iter().next()) +} + +fn create_or_reuse_pin(repository: &gix::Repository, target: Target, id: ObjectId) -> Result<(history::Pin, bool)> { + let pins = history::all_pins(repository)?; + if let Some(pin) = pins.iter().find(|pin| pin.target == target) { + return Ok((pin.clone(), false)); + } + let hex = id.to_hex().to_string(); + let mut suffix_len = 8.min(hex.len()); + let mut number = 2; + let name = loop { + let suffix = if suffix_len <= hex.len() { + hex[..suffix_len].to_owned() + } else { + let suffix = format!("{hex}{number}"); + number += 1; + suffix + }; + let name: gix::refs::FullName = format!("{}{}", String::from_utf8_lossy(history::PIN_PREFIX), suffix) + .try_into() + .context("generated an invalid tix pin name")?; + if repository + .try_find_reference(name.as_ref()) + .context("could not check for a colliding tix pin")? + .is_none() + { + break name; + } + if suffix_len < hex.len() { + suffix_len += 1; + } else { + suffix_len = hex.len() + 1; + } + }; + repository + .edit_references_as( + [RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: "tix time-travel".into(), + }, + expected: PreviousValue::MustNotExist, + new: target.clone(), + }, + name: name.clone(), + deref: false, + }], + None, + ) + .context("could not create tix pin")?; + Ok((history::Pin { name, target, id }, true)) +} + +fn delete_pin(repository: &gix::Repository, pin: &history::Pin) -> Result<()> { + repository + .edit_references_as( + [RefEdit { + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(pin.target.clone()), + log: RefLog::AndReference, + }, + name: pin.name.clone(), + deref: false, + }], + None, + ) + .context("could not remove tix pin")?; + Ok(()) +} + +fn checkout_pin(workdir: &Path, pin: &history::Pin) -> Result<()> { + match pin.target.try_name() { + Some(name) => { + let branch = name + .as_bstr() + .strip_prefix(b"refs/heads/") + .context("a symbolic tix pin does not point to a local branch")?; + checkout( + workdir, + [ + OsString::from("--no-guess"), + gix::path::from_bstr(branch.as_bstr()).into_owned().into_os_string(), + ], + ) + } + None => checkout_detached(workdir, pin.id), + } +} + +fn checkout_detached(workdir: &Path, id: ObjectId) -> Result<()> { + checkout( + workdir, + [OsString::from("--detach"), OsString::from(id.to_hex().to_string())], + ) +} + +fn checkout(workdir: &Path, args: impl IntoIterator) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(workdir) + .arg("checkout") + .args(args) + .output() + .context("could not launch git checkout")?; + if output.status.success() { + return Ok(()); + } + let stderr = output.stderr.trim().to_str_lossy(); + if stderr.is_empty() { + anyhow::bail!("git checkout failed with {}", output.status) + } + anyhow::bail!("git checkout failed with {}: {}", output.status, stderr) +} + +fn contains(repository: &gix::Repository, ancestor: ObjectId, descendant: ObjectId) -> bool { + ancestor == descendant + || repository + .merge_base(ancestor, descendant) + .is_ok_and(|base| base.as_ref() == ancestor) +} + +fn pin_label(pin: &history::Pin) -> String { + format!( + "pin:{}", + pin.name + .as_bstr() + .strip_prefix(history::PIN_PREFIX) + .unwrap_or(pin.name.as_bstr()) + .to_str_lossy() + ) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicBool; + + use super::*; + + fn loaded_graph(repository: &gix::Repository, revisions: &[OsString]) -> Result { + let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new( + history::Authors::default(), + )); + let mut graph = None; + history::load(repository, revisions, &[], &authors, &AtomicBool::new(false), |event| { + if let history::Event::Complete(value) = event { + graph = Some(value); + } + true + })?; + graph.context("history traversal did not produce a graph") + } + + #[test] + fn travels_with_symbolic_and_direct_pins_and_returns() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = gix::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let root = repository.rev_parse_single("main~2")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let topic = repository.rev_parse_single("topic")?.detach(); + let graph = loaded_graph(&repository, &[])?; + assert!(graph.is_ancestor(root, main), "the selected root is known ancestry"); + assert!(history::all_pins(&repository)?.is_empty()); + assert_eq!( + open_repository(&repository_path, false, false)?.head_id()?.detach(), + main + ); + assert!(!contains(&repository, main, root)); + drop(repository); + + let notice = perform(&repository_path, false, root, &graph, &[])?.context("time-travel changed HEAD")?; + assert!(notice.contains("saved pin:"), "{notice}"); + let repository = gix::open(fixture.path())?; + assert!(repository.head()?.is_detached(), "travel detaches HEAD"); + assert_eq!(repository.head_id()?.detach(), root); + let pins = history::all_pins(&repository)?; + assert_eq!(pins.len(), 1, "the lost branch tip gets one pin"); + assert_eq!( + pins[0].target.try_name().map(gix::refs::FullNameRef::as_bstr), + Some(b"refs/heads/main".as_bstr()) + ); + assert_eq!(pins[0].id, main); + assert!(history::snapshot(&repository, &[], &[])?.view_tips.contains(&main)); + + repository + .find_reference("refs/heads/main")? + .set_target_id(topic, "advance pinned branch")?; + let advanced = history::snapshot(&repository, &[], &[])?; + assert!(advanced.view_tips.contains(&topic), "a symbolic pin follows its branch"); + drop(repository); + + perform(&repository_path, false, topic, &graph, &[])?; + let repository = gix::open(fixture.path())?; + assert_eq!( + repository.head_name()?.map(|name| name.as_bstr().to_owned()), + Some(b"refs/heads/main".into()), + "returning through a symbolic pin reattaches HEAD" + ); + assert!(history::all_pins(&repository)?.is_empty(), "the used pin is removed"); + + let detach = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "--detach", &main.to_hex().to_string()]) + .status()?; + assert!(detach.success()); + let graph = loaded_graph(&gix::open(fixture.path())?, &[])?; + perform(&repository_path, false, root, &graph, &[])?; + let pin = history::all_pins(&gix::open(fixture.path())?)? + .pop() + .context("direct pin is present")?; + assert_eq!(pin.target.try_id().map(ToOwned::to_owned), Some(main)); + perform(&repository_path, false, main, &graph, &[])?; + let repository = gix::open(fixture.path())?; + assert!( + repository.head()?.is_detached(), + "a direct pin returns to detached HEAD" + ); + assert_eq!(repository.head_id()?.detach(), main); + assert!(history::all_pins(&repository)?.is_empty()); + Ok(()) + } + + #[test] + fn explicit_tips_avoid_redundant_pins_and_failed_checkouts_clean_up() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + let repository = gix::open(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let root = repository.rev_parse_single("main~2")?.detach(); + let main = repository.rev_parse_single("main")?.detach(); + let revisions = [OsString::from("main")]; + let graph = loaded_graph(&repository, &revisions)?; + drop(repository); + + perform(&repository_path, false, root, &graph, &revisions)?; + assert!( + history::all_pins(&gix::open(fixture.path())?)?.is_empty(), + "an explicit tip already retains the former HEAD" + ); + + let checkout = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "--no-guess", "main"]) + .status()?; + assert!(checkout.success()); + std::fs::write(fixture.path().join("main"), "dirty\n")?; + let err = perform(&repository_path, false, root, &graph, &[]).expect_err("Git rejects a conflicting checkout"); + assert!(format!("{err:#}").contains("git checkout failed")); + let repository = gix::open(fixture.path())?; + assert_eq!(repository.head_id()?.detach(), main, "failed checkout retains HEAD"); + assert!( + history::all_pins(&repository)?.is_empty(), + "the provisional pin is removed" + ); + Ok(()) + } +} diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index d0aba8b5934..3ba9eeb12a6 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -630,6 +630,30 @@ pub(crate) fn draw_with_worktree( ))]; if app.changes_focus.is_none() { footer_spans.push(Span::raw(" · Enter diff")); + if app.time_travel_shortcut_visible() + && decorations + .values() + .flatten() + .any(|decoration| decoration.kind == DecorationKind::Head) + && let Some(selected) = app.selected.and_then(|index| app.rows.get(index)) + { + let selected_refs = decorations.get(&selected.id).map(Vec::as_slice).unwrap_or_default(); + if !selected_refs + .iter() + .any(|decoration| decoration.kind == DecorationKind::Head) + { + footer_spans.push(Span::raw( + if selected_refs + .iter() + .any(|decoration| decoration.kind == DecorationKind::Pin) + { + " · t return" + } else { + " · t travel" + }, + )); + } + } if app.reword_shortcut_visible() { footer_spans.push(Span::raw(" · r reword")); } @@ -1511,6 +1535,7 @@ fn author_label( fn decoration_style(kind: DecorationKind) -> Style { match kind { DecorationKind::Head => Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + DecorationKind::Pin => Style::default().fg(Color::Blue), DecorationKind::Local => Style::default().fg(Color::Cyan), DecorationKind::Remote => Style::default().fg(Color::Yellow), DecorationKind::Tag => Style::default().fg(Color::Magenta), @@ -2317,6 +2342,50 @@ mod tests { Ok(()) } + #[test] + fn advertises_travel_and_return_for_non_head_rows() -> Result<(), Box> { + let selected = gix::ObjectId::Sha1([1; 20]); + let head = gix::ObjectId::Sha1([2; 20]); + let mut app = App::new(2); + app.extend_commits(vec![Commit { + id: selected, + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }]); + complete(&mut app); + let mut decorations = Decorations::from([ + ( + selected, + vec![Decoration { + name: "pin:01010101".into(), + kind: DecorationKind::Pin, + }], + ), + ( + head, + vec![Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }], + ), + ]); + let mut terminal = Terminal::new(TestBackend::new(140, 2))?; + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!(rendered_row(&terminal).contains("pin:01010101")); + assert!(rendered_line(&terminal, 1).contains("t return")); + + decorations.remove(&selected); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!(rendered_line(&terminal, 1).contains("t travel")); + Ok(()) + } + #[test] fn removes_the_copied_fields_color_from_only_the_selected_row_for_one_frame() -> Result<(), Box> { From d0f11cd835cdb007380aa18d7f891b135a7612f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 14:27:23 +0200 Subject: [PATCH 049/282] feat: show other worktree checkouts in tix Decorate commits checked out by other main or linked worktrees with light-blue name@ labels, replacing the ordinary branch label. Keep the checkout from which tix was opened represented by the graph @ marker and its ordinary local reference, and use directory basenames for other detached worktrees. Keep other-worktree labels visible on the selected row when references are hidden. Add -w/--worktrees to include every successfully resolved worktree HEAD, including the current checkout, alongside implicit or explicit traversal tips without weakening hidden-revision exclusions. Discover worktrees from their private Git metadata so stale checkout directories remain useful, while malformed, unborn, or inaccessible entries are logged and skipped. Watch linked HEAD and worktree membership changes so decorations and optional tips stay current, while ignoring unrelated linked indexes, logs, and metadata. Document the behavior and cover current, attached, detached, stale, malformed, hidden-reference, CLI, and watcher cases. --- gix-tix/spec.md | 21 ++- gix-tix/src/history.rs | 305 ++++++++++++++++++++++++++++++++++--- gix-tix/src/lib.rs | 153 +++++++++++++++++-- gix-tix/src/logging.rs | 16 ++ gix-tix/src/main.rs | 31 +++- gix-tix/src/time_travel.rs | 45 ++++-- gix-tix/src/ui.rs | 117 +++++++++++--- 7 files changed, 614 insertions(+), 74 deletions(-) diff --git a/gix-tix/spec.md b/gix-tix/spec.md index acbc8ef57ce..6125d0f8538 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -15,6 +15,9 @@ without trading responsiveness for metadata that is not visible. from `HEAD` when none are supplied. - `-h/--hide REVSPEC` excludes the revision and its reachable ancestry. The option may be repeated. +- `-w/--worktrees` adds every successfully resolved main and linked worktree + `HEAD` to the visible traversal tips, in addition to implicit or explicit + revisions. Hidden revisions still exclude matching ancestry. - `--quit-on-finish` exits after traversal and lane computation, for measurement and non-interactive use. - Revisions must resolve and peel to commits. Invalid or non-commit revisions are @@ -68,7 +71,15 @@ without trading responsiveness for metadata that is not visible. being verified, green when verified, and bright red when verification fails. - The current `HEAD` commit uses `@` instead of the normal commit disc and keeps the same signature and selection coloring. It remains visible when textual - reference labels are hidden. + reference labels are hidden, and textual `HEAD` is never rendered alongside it. +- Local branches checked out in other worktrees are displayed as `short-name@` + in light blue instead of their plain branch decoration. Other detached + worktrees use their checkout directory basename. The current worktree is + represented by the commit's `@` marker and ordinary local reference; identical + labels are deduplicated. +- When reference labels are hidden, worktree labels are visible only on the + selected row. Stale, malformed, unborn, and otherwise unreadable worktree + entries are skipped without failing history loading. - The selected row uses `>` at the left. If the displayed worktree block is dirty, `D` is shown at the `HEAD` row instead; a separately selected row retains `>`. - Selection inversion covers the left marker, graph, commit marker, and hash. @@ -285,9 +296,11 @@ space first; changes blocks adapt within the remaining history width. ## Refresh, focus, and diagnostics -- Native reference watchers observe `HEAD`, loose and packed refs, and the direct - or symbolic refs used by view and hide revspecs. Missing refs during an atomic - update are transient; malformed or inaccessible refs remain errors. +- Native reference watchers observe `HEAD`, loose and packed refs, linked-worktree + HEAD and membership changes, and the direct or symbolic refs used by view and + hide revspecs. Linked indexes, logs, locks, and unrelated metadata do not + trigger history refreshes. Missing refs during an atomic update are transient; + malformed or inaccessible ordinary refs remain errors. - Ref changes that affect view or hidden tips trigger an incremental history refresh. Decoration-only changes avoid traversal. Filesystem-driven traversal changes select the newest selectable row; manual refresh and display toggles diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index 2e494e750a3..477a618df13 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -30,6 +30,8 @@ pub(crate) struct Decoration { pub(crate) enum DecorationKind { Head, Pin, + WorktreeBranch, + WorktreeDetached, Local, Remote, Tag, @@ -48,6 +50,14 @@ pub(crate) struct Pin { pub id: ObjectId, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WorktreeCheckout { + pub id: ObjectId, + pub name: BString, + pub reference: Option, + pub is_current: bool, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct SelectionRef { pub name: BString, @@ -285,7 +295,7 @@ impl HistoryGraph { .into_iter() .flatten() .map(|decoration| { - let upstream = if decoration.kind == DecorationKind::Local { + let upstream = if matches!(decoration.kind, DecorationKind::Local | DecorationKind::WorktreeBranch) { tracked .into_iter() .flatten() @@ -393,10 +403,11 @@ impl HistoryGraph { repo: &gix::Repository, revisions: &[OsString], hidden_revisions: &[OsString], + include_worktrees: bool, expand: &HashSet, authors: &SharedAuthors, ) -> Result { - let refs = snapshot(repo, revisions, hidden_revisions)?; + let refs = snapshot(repo, revisions, hidden_revisions, include_worktrees)?; let shallow: HashSet<_> = repo .shallow_commits() .context("could not read shallow commits")? @@ -574,7 +585,7 @@ impl HistoryGraph { } } self.tracking = tracking; - let decorations = decorations(repo, &refs.pins)?; + let decorations = decorations(repo, &refs.pins, &refs.worktrees)?; Ok(Refresh { refs, decorations, @@ -590,6 +601,7 @@ pub(crate) struct RefSnapshot { pub view_tips: Vec, pub hidden_tips: Vec, pub pins: Vec, + pub worktrees: Vec, } #[derive(Debug)] @@ -758,21 +770,22 @@ pub(crate) fn load( repo: &gix::Repository, revisions: &[OsString], hidden_revisions: &[OsString], + include_worktrees: bool, authors: &SharedAuthors, cancelled: &AtomicBool, mut emit: impl FnMut(Event) -> bool, ) -> Result<()> { - let refs = snapshot(repo, revisions, hidden_revisions)?; + let refs = snapshot(repo, revisions, hidden_revisions, include_worktrees)?; let tips = refs.view_tips; if tips.is_empty() { - emit(Event::Decorations(decorations(repo, &refs.pins)?)); + emit(Event::Decorations(decorations(repo, &refs.pins, &refs.worktrees)?)); emit(Event::VisibleComplete); emit(Event::Complete(HistoryGraph::default())); return Ok(()); } let hidden_tips = refs.hidden_tips; - if !emit(Event::Decorations(decorations(repo, &refs.pins)?)) { + if !emit(Event::Decorations(decorations(repo, &refs.pins, &refs.worktrees)?)) { return Ok(()); } let shallow: HashSet<_> = repo @@ -985,26 +998,36 @@ pub(crate) fn load( Ok(()) } -pub(crate) fn snapshot(repo: &gix::Repository, revisions: &[OsString], hidden: &[OsString]) -> Result { - snapshot_ignoring_pin(repo, revisions, hidden, None) +pub(crate) fn snapshot( + repo: &gix::Repository, + revisions: &[OsString], + hidden: &[OsString], + include_worktrees: bool, +) -> Result { + snapshot_ignoring_pin(repo, revisions, hidden, include_worktrees, None) } pub(crate) fn snapshot_ignoring_pin( repo: &gix::Repository, revisions: &[OsString], hidden: &[OsString], + include_worktrees: bool, ignored_pin: Option<&BStr>, ) -> Result { let pins = applicable_pins(repo)? .into_iter() .filter(|pin| ignored_pin != Some(pin.name.as_bstr())) .collect::>(); + let worktrees = worktree_checkouts(repo); let mut view = referenced_refs(repo, revisions)?; for pin in &pins { insert_ref_chain(repo, pin.name.as_bstr(), &mut view)?; } let mut view_tips = resolve_tips(repo, revisions)?.unwrap_or_default(); view_tips.extend(pins.iter().map(|pin| pin.id)); + if include_worktrees { + view_tips.extend(worktrees.iter().map(|worktree| worktree.id)); + } let mut seen = HashSet::new(); view_tips.retain(|id| seen.insert(*id)); Ok(RefSnapshot { @@ -1013,9 +1036,98 @@ pub(crate) fn snapshot_ignoring_pin( view_tips, hidden_tips: resolve_revisions(repo, hidden, "hidden ")?, pins, + worktrees, }) } +pub(crate) fn worktree_checkouts(repo: &gix::Repository) -> Vec { + let mut out = Vec::new(); + let current_worktree = repo.worktree().map(|worktree| worktree.id().map(ToOwned::to_owned)); + match repo.main_repo() { + Ok(main) if !main.is_bare() => { + let name = main.workdir().and_then(worktree_basename); + add_worktree_checkout(&main, name, b"main".as_bstr(), current_worktree == Some(None), &mut out); + } + Ok(_) => {} + Err(err) => tracing::warn!(error = %err, "ignoring inaccessible main worktree"), + } + match repo.worktrees() { + Ok(worktrees) => { + for proxy in worktrees { + let worktree = proxy.id().to_owned(); + let name = proxy.base().ok().as_deref().and_then(worktree_basename); + let is_current = current_worktree + .as_ref() + .and_then(Option::as_ref) + .is_some_and(|current| current == &worktree); + match proxy.into_repo_with_possibly_inaccessible_worktree() { + Ok(repository) => { + add_worktree_checkout(&repository, name, worktree.as_bstr(), is_current, &mut out); + } + Err(err) => { + tracing::warn!(worktree = %worktree, error = %err, "ignoring inaccessible linked worktree"); + } + } + } + } + Err(err) => tracing::warn!(error = %err, "ignoring unreadable linked worktree list"), + } + out.sort_by(|a, b| { + a.id.cmp(&b.id) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.reference.cmp(&b.reference)) + }); + out.dedup(); + out +} + +fn add_worktree_checkout( + repo: &gix::Repository, + detached_name: Option, + worktree: &BStr, + is_current: bool, + out: &mut Vec, +) { + let mut head = match repo.head() { + Ok(head) => head, + Err(err) => { + tracing::warn!(%worktree, error = %err, "ignoring worktree with unreadable HEAD"); + return; + } + }; + let reference = head.referent_name().map(ToOwned::to_owned); + let id = match head.try_peel_to_id() { + Ok(Some(id)) => id.detach(), + Ok(None) => return, + Err(err) => { + tracing::warn!(%worktree, error = %err, "ignoring worktree with unresolved HEAD"); + return; + } + }; + let name = match &reference { + Some(reference) => reference.shorten().to_owned(), + None => match detached_name { + Some(name) => name, + None => { + tracing::warn!(%worktree, "ignoring detached worktree without a directory basename"); + return; + } + }, + }; + out.push(WorktreeCheckout { + id, + name, + reference, + is_current, + }); +} + +fn worktree_basename(path: &std::path::Path) -> Option { + path.file_name() + .and_then(|name| gix::path::os_str_into_bstr(name).ok()) + .map(ToOwned::to_owned) +} + pub(crate) fn all_pins(repo: &gix::Repository) -> Result> { let mut out = Vec::new(); let references = repo.references().context("could not open references")?; @@ -1274,7 +1386,7 @@ impl Authors { } } -pub(crate) fn decorations(repo: &gix::Repository, pins: &[Pin]) -> Result { +pub(crate) fn decorations(repo: &gix::Repository, pins: &[Pin], worktrees: &[WorktreeCheckout]) -> Result { let mut out = Decorations::new(); let pins: HashSet<_> = pins.iter().map(|pin| pin.name.as_bstr()).collect(); for reference in repo @@ -1288,11 +1400,12 @@ pub(crate) fn decorations(repo: &gix::Repository, pins: &[Pin]) -> Result continue, Err(err) => return Err(anyhow::anyhow!("could not read reference: {err}")), }; - let pin_suffix = reference.name().as_bstr().strip_prefix(PIN_PREFIX).map(BString::from); - if pin_suffix.is_some() && !pins.contains(reference.name().as_bstr()) { + let full_name = reference.name().to_owned(); + let pin_suffix = full_name.as_bstr().strip_prefix(PIN_PREFIX).map(BString::from); + if pin_suffix.is_some() && !pins.contains(full_name.as_bstr()) { continue; } - let mut kind = decoration_kind(reference.name().as_bstr()); + let mut kind = decoration_kind(full_name.as_bstr()); if kind == DecorationKind::Tag { let annotated = match reference.try_id() { Some(id) => id.header().context("could not inspect tag")?.kind() == gix::objs::Kind::Tag, @@ -1306,8 +1419,13 @@ pub(crate) fn decorations(repo: &gix::Repository, pins: &[Pin]) -> Result Result>(), &hidden_revisions.iter().map(OsString::from).collect::>(), + false, &authors, &AtomicBool::new(false), |event| { @@ -1527,8 +1663,8 @@ mod tests { #[test] fn snapshots_references_and_symbolic_targets_from_revisions() -> gix_testtools::Result { let fixture = fixture()?; - let repo = gix::open(fixture)?; - let implicit = snapshot(&repo, &[], &[])?; + let repo = crate::open_test_repository(fixture)?; + let implicit = snapshot(&repo, &[], &[], false)?; assert!( implicit.view.contains_key(b"HEAD".as_bstr()), "an implicit revision watches HEAD" @@ -1538,12 +1674,137 @@ mod tests { "the symbolic target of HEAD is watched as well" ); - let explicit = snapshot(&repo, &[OsString::from("main")], &[OsString::from("topic")])?; + let explicit = snapshot(&repo, &[OsString::from("main")], &[OsString::from("topic")], false)?; assert!(explicit.view.contains_key(b"refs/heads/main".as_bstr())); assert!(explicit.hidden.contains_key(b"refs/heads/topic".as_bstr())); Ok(()) } + #[test] + fn discovers_worktree_decorations_and_optionally_adds_their_tips() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; + for args in [ + ["worktree", "add", "-q", "topic-wt", "topic"].as_slice(), + ["worktree", "add", "-q", "--detach", "detached-wt", "main~2"].as_slice(), + ["worktree", "add", "-q", "--detach", "broken-wt", "main~2"].as_slice(), + ] { + let status = Command::new("git").current_dir(fixture.path()).args(args).status()?; + assert!(status.success(), "git creates the worktree fixture"); + } + std::fs::remove_dir_all(fixture.path().join("detached-wt"))?; + std::fs::write( + fixture.path().join(".git/worktrees/broken-wt/HEAD"), + "not a ref or object id\n", + )?; + + let repo = crate::open_test_repository(fixture.path())?; + let main = repo.rev_parse_single("main")?.detach(); + let topic = repo.rev_parse_single("topic")?.detach(); + let root = repo.rev_parse_single("main~2")?.detach(); + let worktrees = worktree_checkouts(&repo); + assert!(worktrees.iter().any(|worktree| { + worktree.id == main + && worktree.name == "main" + && worktree.is_current + && worktree + .reference + .as_ref() + .is_some_and(|name| name.as_bstr() == b"refs/heads/main") + })); + assert!(worktrees.iter().any(|worktree| { + worktree.id == topic + && worktree.name == "topic" + && !worktree.is_current + && worktree + .reference + .as_ref() + .is_some_and(|name| name.as_bstr() == b"refs/heads/topic") + })); + assert!(worktrees.iter().any(|worktree| { + worktree.id == root + && worktree.name == "detached-wt" + && worktree.reference.is_none() + && !worktree.is_current + })); + assert_eq!(worktrees.len(), 3, "the malformed worktree is ignored"); + + let main_repo_decorations = decorations(&repo, &[], &worktrees)?; + let main_decorations = main_repo_decorations.get(&main).expect("main is decorated"); + assert!( + main_decorations + .iter() + .any(|decoration| { decoration.kind == DecorationKind::Local && decoration.name == "main" }) + ); + assert!( + !main_decorations + .iter() + .any(|decoration| { decoration.kind == DecorationKind::WorktreeBranch && decoration.name == "main" }) + ); + assert!(main_repo_decorations.get(&topic).is_some_and(|decorations| { + decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::WorktreeBranch && decoration.name == "topic") + })); + assert!(main_repo_decorations.get(&root).is_some_and(|decorations| { + decorations.iter().any(|decoration| { + decoration.kind == DecorationKind::WorktreeDetached && decoration.name == "detached-wt" + }) + })); + + let explicit = [OsString::from("main")]; + let without = snapshot(&repo, &explicit, &[], false)?; + assert_eq!(without.view_tips, [main], "explicit revisions are unchanged by default"); + let with = snapshot(&repo, &explicit, &[], true)?; + assert!(with.view_tips.contains(&main)); + assert!(with.view_tips.contains(&topic)); + assert!(with.view_tips.contains(&root)); + + let linked_path = fixture.path().join("topic-wt"); + let linked_repo = crate::open_test_repository(&linked_path)?; + let linked_worktrees = worktree_checkouts(&linked_repo); + assert!( + linked_worktrees + .iter() + .any(|worktree| worktree.id == topic && worktree.is_current) + ); + let linked_decorations = decorations(&linked_repo, &[], &linked_worktrees)?; + assert!(linked_decorations.get(&topic).is_some_and(|decorations| { + decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::Local && decoration.name == "topic") + && !decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::WorktreeBranch && decoration.name == "topic") + })); + assert!(linked_decorations.get(&main).is_some_and(|decorations| { + decorations + .iter() + .any(|decoration| decoration.kind == DecorationKind::WorktreeBranch && decoration.name == "main") + })); + + let status = Command::new("git") + .current_dir(&linked_path) + .args(["checkout", "-q", "--detach", "main~1"]) + .status()?; + assert!(status.success(), "git detaches the current linked worktree"); + let detached_repo = crate::open_test_repository(&linked_path)?; + let detached_worktrees = worktree_checkouts(&detached_repo); + let current = detached_worktrees + .iter() + .find(|worktree| worktree.is_current) + .expect("the current detached worktree is discovered"); + assert!(current.reference.is_none()); + let detached_decorations = decorations(&detached_repo, &[], &detached_worktrees)?; + assert!( + !detached_decorations + .get(¤t.id) + .is_some_and(|decorations| decorations.iter().any(|decoration| { + decoration.kind == DecorationKind::WorktreeDetached && decoration.name == current.name + })) + ); + Ok(()) + } + #[test] fn decodes_commits_missing_from_a_stale_graph_and_defers_graph_commits() -> gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; @@ -1619,9 +1880,9 @@ mod tests { let repo = crate::open_test_repository(fixture.path())?; let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); - let first = graph.refresh(&repo, &["main".into()], &[], &HashSet::new(), &authors)?; + let first = graph.refresh(&repo, &["main".into()], &[], false, &HashSet::new(), &authors)?; assert_eq!(first.commits.rows.len(), 1, "only the new descendant is loaded"); - let second = graph.refresh(&repo, &["main".into()], &[], &HashSet::new(), &authors)?; + let second = graph.refresh(&repo, &["main".into()], &[], false, &HashSet::new(), &authors)?; assert!( second.commits.rows.is_empty(), "an unchanged tip stops immediately at complete cached ancestry" @@ -1667,7 +1928,7 @@ mod tests { let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); - let refresh = graph.refresh(&repo, &["topic".into()], &[], &HashSet::new(), &authors)?; + let refresh = graph.refresh(&repo, &["topic".into()], &[], false, &HashSet::new(), &authors)?; assert!( refresh.commits.rows.is_empty(), "an unchanged tracking tip stops before revisiting its cached parents" @@ -1769,7 +2030,7 @@ mod tests { let repo = crate::open_test_repository(path)?; let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); - let refresh = graph.refresh(&repo, &["local".into()], &[], &boundary, &authors)?; + let refresh = graph.refresh(&repo, &["local".into()], &[], false, &boundary, &authors)?; visible.extend(refresh.commits.rows.into_iter().map(|row| row.id)); let expected: HashSet<_> = repo .rev_walk([local]) @@ -1854,8 +2115,8 @@ mod tests { let mut cancelled = Vec::new(); let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); - let repo = gix::open(&fixture)?; - load(&repo, &[], &[], &authors, &AtomicBool::new(true), |event| { + let repo = crate::open_test_repository(&fixture)?; + load(&repo, &[], &[], false, &authors, &AtomicBool::new(true), |event| { cancelled.push(event); true })?; diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 40619db8acf..edec1a808ea 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -81,6 +81,8 @@ struct WorktreeWatcher { struct RefWatcher { _watcher: RecommendedWatcher, events: mpsc::Receiver>, + git_dir: PathBuf, + worktrees_dir: PathBuf, } impl WorktreeWatcher { @@ -93,6 +95,16 @@ impl WorktreeWatcher { } } +impl RefWatcher { + fn event_is_relevant(&self, event: ¬ify::Event) -> bool { + reference_event_is_relevant(event, &self.git_dir, &self.worktrees_dir) + } + + fn watch_set_may_change(&self, event: ¬ify::Event) -> bool { + reference_watch_set_may_change(event, &self.worktrees_dir) + } +} + #[derive(Default)] struct WorktreeDirectories { root: PathBuf, @@ -182,6 +194,36 @@ fn notification_is_actionable(event: ¬ify::Event) -> bool { }))) } +fn reference_event_is_relevant(event: ¬ify::Event, git_dir: &Path, worktrees_dir: &Path) -> bool { + notification_is_actionable(event) + && (event.need_rescan() + || event.paths.is_empty() + || event.paths.iter().any(|path| { + if let Ok(relative) = path.strip_prefix(git_dir) + && (relative.components().count() <= 1 || relative.starts_with("refs")) + { + return true; + } + let Ok(relative) = path.strip_prefix(worktrees_dir) else { + return true; + }; + let mut components = relative.components(); + let Some(_) = components.next() else { return true }; + match components.next() { + None => true, + Some(name) => matches!(name.as_os_str().as_encoded_bytes(), b"HEAD" | b"gitdir"), + } + })) +} + +fn reference_watch_set_may_change(event: ¬ify::Event, worktrees_dir: &Path) -> bool { + event.need_rescan() + || event.paths.iter().any(|path| { + path.strip_prefix(worktrees_dir) + .is_ok_and(|relative| relative.components().count() <= 1) + }) +} + fn unseen_filesystem_redraw(current: bool, focused: bool, filesystem_frame: bool) -> bool { !focused && (current || filesystem_frame) } @@ -700,6 +742,8 @@ pub struct Options { pub quit_on_finish: bool, /// Revisions whose reachable commits should initially be hidden. pub hide: Vec, + /// Add every successfully resolved worktree HEAD as a visible traversal tip. + pub worktrees: bool, } fn detect_commit_pane_background() -> Option<(u8, u8, u8)> { @@ -742,6 +786,7 @@ pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec, opti tracing::info!( revision_count = revisions.len(), hidden_revision_count = options.hide.len(), + include_worktrees = options.worktrees, "starting tix" ); let commit_pane_background = detect_commit_pane_background(); @@ -801,7 +846,11 @@ fn event_loop( enhanced_keyboard: bool, commit_pane_background: Option<(u8, u8, u8)>, ) -> Result> { - let Options { quit_on_finish, hide } = options; + let Options { + quit_on_finish, + hide, + worktrees, + } = options; let mut repository_path = repository.git_dir().to_owned(); let common_dir = normalize_common_dir(repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()))?; let (mut view_repository, recovered_at_startup) = open_history_repository(&mut repository_path, &common_dir)?; @@ -809,7 +858,7 @@ fn event_loop( let (mut repository_is_bare, mut mailmap, mut ref_snapshot) = { let bare = view_repository.workdir().is_none(); let mailmap = view_repository.open_mailmap(); - let refs = history::snapshot(&view_repository, &revisions, &hide)?; + let refs = history::snapshot(&view_repository, &revisions, &hide, worktrees)?; (bare, mailmap, refs) }; if recovered_at_startup { @@ -828,10 +877,12 @@ fn event_loop( None } }; + let mut ref_watch_set_changed = false; let (cancelled, receiver) = start_history( repository, &revisions, &hide, + worktrees, gix::features::threading::OwnShared::clone(&authors), ); @@ -984,8 +1035,9 @@ fn event_loop( Ok(Ok(event)) => { received += 1; rescans += usize::from(event.need_rescan()); - if notification_is_actionable(&event) { + if watcher.event_is_relevant(&event) { actionable += 1; + ref_watch_set_changed |= watcher.watch_set_may_change(&event); filesystem_responses.observe_references(&event, &repository_path, &common_dir); ref_refresh_deadline = Some(Instant::now() + REF_EVENT_IDLE); } @@ -1009,10 +1061,25 @@ fn event_loop( filesystem_responses.fail_pending_references(); ref_watcher = None; ref_refresh_deadline = None; + ref_watch_set_changed = false; app.manual_refresh = true; schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); } if take_due(&mut ref_refresh_deadline, Instant::now()) { + if std::mem::take(&mut ref_watch_set_changed) { + match start_ref_watcher(&repository_path, &common_dir) { + Ok(watcher) => { + ref_watcher = Some(watcher); + app.manual_refresh = false; + } + Err(err) => { + tracing::warn!(error = %err, "reference watcher rebuild failed"); + ref_watcher = None; + app.manual_refresh = true; + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + } + } + } let response_ids = filesystem_responses.references_due(); refresh_pending = true; refresh_from_filesystem = true; @@ -1198,15 +1265,17 @@ fn event_loop( None } }; + ref_watch_set_changed = false; app.manual_refresh = ref_watcher.is_none(); app.notice = Some("worktree removed; using the common repository without worktree changes".into()); recovered } Err(err) => return Err(err).context("could not inspect changed references"), }; - let next = history::snapshot(&repository, &revisions, &hide)?; + let next = history::snapshot(&repository, &revisions, &hide, worktrees)?; let hidden_changed = next.hidden != ref_snapshot.hidden; - let tips_changed = next.view != ref_snapshot.view || hidden_changed; + let worktree_tips_changed = worktrees && next.worktrees != ref_snapshot.worktrees; + let tips_changed = next.view != ref_snapshot.view || hidden_changed || worktree_tips_changed; let from_filesystem = std::mem::take(&mut refresh_from_filesystem); if tips_changed && from_filesystem { motion.capture(); @@ -1233,6 +1302,7 @@ fn event_loop( repository_is_bare, revisions.clone(), hidden, + worktrees, expand, gix::features::threading::OwnShared::clone(&authors), history_graph @@ -1619,7 +1689,7 @@ fn event_loop( .as_ref() .context("time-travel requires a completed history graph") .and_then(|graph| { - time_travel::perform(&repository_path, repository_is_bare, id, graph, &revisions) + time_travel::perform(&repository_path, repository_is_bare, id, graph, &revisions, worktrees) }); match result { Ok(Some(notice)) => { @@ -1695,6 +1765,7 @@ fn start_history( repository: gix::ThreadSafeRepository, revisions: &[OsString], hidden_revisions: &[OsString], + include_worktrees: bool, authors: SharedAuthors, ) -> (Arc, mpsc::Receiver>) { let cancelled = Arc::new(AtomicBool::new(false)); @@ -1709,6 +1780,7 @@ fn start_history( &repository, &revisions, &hidden_revisions, + include_worktrees, &authors, &worker_cancelled, |event| sender.send(Ok(event)).is_ok(), @@ -1720,11 +1792,16 @@ fn start_history( (cancelled, receiver) } +#[expect( + clippy::too_many_arguments, + reason = "the worker owns each independent refresh input" +)] fn start_history_refresh( repository_path: PathBuf, bare: bool, revisions: Vec, hidden_revisions: Vec, + include_worktrees: bool, expand: std::collections::HashSet, authors: SharedAuthors, mut graph: HistoryGraph, @@ -1735,7 +1812,14 @@ fn start_history_refresh( .context("could not reopen repository for history refresh") .and_then(|mut repository| { repository.object_cache_size_if_unset(OBJECT_CACHE_SIZE); - graph.refresh(&repository, &revisions, &hidden_revisions, &expand, &authors) + graph.refresh( + &repository, + &revisions, + &hidden_revisions, + include_worktrees, + &expand, + &authors, + ) }); let _ = sender.send((graph, result)); }); @@ -1748,15 +1832,23 @@ fn start_ref_watcher(git_dir: &Path, common_dir: &Path) -> Result { let _ = sender.send(event); }) .context("could not initialize reference watcher")?; + let worktrees_dir = common_dir.join("worktrees"); + let linked_git_dir_is_covered = worktrees_dir.is_dir() && git_dir.starts_with(&worktrees_dir); let mut roots = vec![(common_dir.to_owned(), RecursiveMode::NonRecursive)]; - if git_dir != common_dir { + if git_dir != common_dir && !linked_git_dir_is_covered { roots.push((git_dir.to_owned(), RecursiveMode::NonRecursive)); } for root in [common_dir.join("refs"), git_dir.join("refs")] { - if root.is_dir() && !roots.iter().any(|(path, _)| path == &root) { + if root.is_dir() + && !(linked_git_dir_is_covered && root.starts_with(&worktrees_dir)) + && !roots.iter().any(|(path, _)| path == &root) + { roots.push((root, RecursiveMode::Recursive)); } } + if worktrees_dir.is_dir() { + roots.push((worktrees_dir.clone(), RecursiveMode::Recursive)); + } for (path, mode) in &roots { watcher .watch(path, *mode) @@ -1766,6 +1858,8 @@ fn start_ref_watcher(git_dir: &Path, common_dir: &Path) -> Result { Ok(RefWatcher { _watcher: watcher, events, + git_dir: git_dir.to_owned(), + worktrees_dir, }) } @@ -3484,6 +3578,7 @@ mod tests { &repository, &[OsString::from("topic"), OsString::from("main")], &[], + false, &authors, &AtomicBool::new(false), |event| { @@ -3561,6 +3656,7 @@ mod tests { &repository, &[OsString::from("topic")], &[], + false, &authors, &AtomicBool::new(false), |event| { @@ -3571,7 +3667,7 @@ mod tests { }, )?; let mut graph = graph.expect("history traversal returns its graph"); - let refs = graph.selection_refs(topic, &history::decorations(&repository, &[])?); + let refs = graph.selection_refs(topic, &history::decorations(&repository, &[], &[])?); assert_eq!(refs[0].upstream, Some(Some(main))); assert_eq!( graph.selection_relation(topic, &refs, &[]), @@ -4355,6 +4451,43 @@ mod tests { assert!(worktree_event_is_relevant(&rescan, workdir, &dot_git, &git_dir, &index)); assert!(notification_is_actionable(&rescan)); + let worktrees = git_dir.join("worktrees"); + let linked = worktrees.join("linked"); + assert!(reference_event_is_relevant( + &modified(&linked.join("HEAD")), + &git_dir, + &worktrees + )); + assert!(reference_event_is_relevant( + &modified(&linked.join("gitdir")), + &git_dir, + &worktrees + )); + assert!(!reference_event_is_relevant( + &modified(&linked.join("index")), + &git_dir, + &worktrees + )); + assert!(!reference_event_is_relevant( + &modified(&linked.join("logs/HEAD")), + &git_dir, + &worktrees + )); + let current_linked = worktrees.join("current"); + assert!(reference_event_is_relevant( + &modified(¤t_linked.join("index")), + ¤t_linked, + &worktrees + )); + assert!(reference_watch_set_may_change( + &modified(&worktrees.join("new-linked")), + &worktrees + )); + assert!(!reference_watch_set_may_change( + &modified(&linked.join("HEAD")), + &worktrees + )); + let directories = HashSet::from([workdir.join("src")]); assert!(!worktree_watch_set_may_change( &modified(&workdir.join("src/lib.rs")), diff --git a/gix-tix/src/logging.rs b/gix-tix/src/logging.rs index a591bf44d24..a4df04558c5 100644 --- a/gix-tix/src/logging.rs +++ b/gix-tix/src/logging.rs @@ -328,12 +328,23 @@ fn event_kind(kind: ¬ify::EventKind) -> &'static str { fn classify_reference_path(path: &Path, git_dir: &Path, common_dir: &Path) -> Trigger { let head = git_dir.join("HEAD"); let common_head = common_dir.join("HEAD"); + let linked_head = path + .strip_prefix(common_dir.join("worktrees")) + .ok() + .is_some_and(|relative| { + let mut components = relative.components(); + let name = components + .nth(1) + .map(|component| component.as_os_str().as_encoded_bytes()); + matches!(name, Some(b"HEAD" | b"HEAD.lock")) && components.next().is_none() + }); let index = git_dir.join("index"); let packed_refs = common_dir.join("packed-refs"); if path == head || path == head.with_extension("lock") || path == common_head || path == common_head.with_extension("lock") + || linked_head { Trigger::Head } else if path == index || path == index.with_extension("lock") { @@ -450,6 +461,11 @@ mod tests { Trigger::Head, "transaction lock files retain their semantic trigger" ); + assert_eq!( + classify_reference_path(&common.join("worktrees/other/HEAD"), &linked, common), + Trigger::Head, + "other linked worktree HEADs retain their semantic trigger" + ); assert_eq!( classify_reference_path(&linked.join("index"), &linked, common), Trigger::Index diff --git a/gix-tix/src/main.rs b/gix-tix/src/main.rs index ef02db214a0..40132e32788 100644 --- a/gix-tix/src/main.rs +++ b/gix-tix/src/main.rs @@ -8,7 +8,7 @@ fn main() -> Result<()> { let (revisions, options, help) = arguments(gix::env::args_os().skip(1))?; if help { println!( - "Usage: tix [--quit-on-finish] [-h|--hide REVSPEC] [REVISION]...\n\nBrowse commits reachable from HEAD or the given revisions.\n\nOptions:\n -h, --hide REVSPEC Hide this revision and all commits reachable from it\n --help Print help" + "Usage: tix [--quit-on-finish] [-w|--worktrees] [-h|--hide REVSPEC] [REVISION]...\n\nBrowse commits reachable from HEAD or the given revisions.\n\nOptions:\n -h, --hide REVSPEC Hide this revision and all commits reachable from it\n -w, --worktrees Add all worktree HEADs as visible tips\n --help Print help" ); return Ok(()); } @@ -29,13 +29,20 @@ fn arguments(mut args: impl Iterator) -> Result<(Vec, break; } else if arg == "--quit-on-finish" { options.quit_on_finish = true; + } else if arg == "-w" || arg == "--worktrees" { + options.worktrees = true; } else if arg == "-h" || arg == "--hide" { let revision = args.next().context("-h/--hide requires a revision to hide")?; if revision == "--help" { help = true; break; } - if revision == "-h" || revision == "--hide" || revision == "--quit-on-finish" { + if revision == "-h" + || revision == "--hide" + || revision == "-w" + || revision == "--worktrees" + || revision == "--quit-on-finish" + { anyhow::bail!("-h/--hide requires a revision to hide"); } options.hide.push(revision); @@ -55,12 +62,22 @@ mod tests { #[test] fn separates_options_from_revisions() -> Result<()> { let (revisions, options, help) = arguments( - ["--quit-on-finish", "-h", "main", "--hide", "tag", "topic", "--help"] - .into_iter() - .map(OsString::from), + [ + "--quit-on-finish", + "-w", + "-h", + "main", + "--hide", + "tag", + "topic", + "--help", + ] + .into_iter() + .map(OsString::from), )?; assert!(options.quit_on_finish); + assert!(options.worktrees); assert_eq!(options.hide, ["main", "tag"], "both hide options are retained"); assert_eq!(revisions, ["topic"], "only positional revisions remain"); assert!(help, "--help remains available without claiming -h"); @@ -74,6 +91,10 @@ mod tests { "--help wins regardless of its position" ); } + assert!( + arguments(["--hide", "--worktrees"].into_iter().map(OsString::from)).is_err(), + "worktree options cannot be consumed as hidden revisions" + ); Ok(()) } } diff --git a/gix-tix/src/time_travel.rs b/gix-tix/src/time_travel.rs index 2fe5f2efc8c..7fd8e1b1e88 100644 --- a/gix-tix/src/time_travel.rs +++ b/gix-tix/src/time_travel.rs @@ -18,6 +18,7 @@ pub(crate) fn perform( selected: ObjectId, graph: &history::HistoryGraph, revisions: &[OsString], + include_worktrees: bool, ) -> Result> { let repository = open_repository(repository_path, bare, false).context("could not open repository for time-travel")?; @@ -71,7 +72,8 @@ pub(crate) fn perform( if let Some((pin, true)) = provisional { let repository = open_repository(repository_path, bare, false).context("could not reopen repository after time-travel")?; - let snapshot = history::snapshot_ignoring_pin(&repository, revisions, &[], Some(pin.name.as_bstr()))?; + let snapshot = + history::snapshot_ignoring_pin(&repository, revisions, &[], include_worktrees, Some(pin.name.as_bstr()))?; if snapshot .view_tips .iter() @@ -246,12 +248,20 @@ mod tests { history::Authors::default(), )); let mut graph = None; - history::load(repository, revisions, &[], &authors, &AtomicBool::new(false), |event| { - if let history::Event::Complete(value) = event { - graph = Some(value); - } - true - })?; + history::load( + repository, + revisions, + &[], + false, + &authors, + &AtomicBool::new(false), + |event| { + if let history::Event::Complete(value) = event { + graph = Some(value); + } + true + }, + )?; graph.context("history traversal did not produce a graph") } @@ -273,7 +283,7 @@ mod tests { assert!(!contains(&repository, main, root)); drop(repository); - let notice = perform(&repository_path, false, root, &graph, &[])?.context("time-travel changed HEAD")?; + let notice = perform(&repository_path, false, root, &graph, &[], false)?.context("time-travel changed HEAD")?; assert!(notice.contains("saved pin:"), "{notice}"); let repository = gix::open(fixture.path())?; assert!(repository.head()?.is_detached(), "travel detaches HEAD"); @@ -285,16 +295,20 @@ mod tests { Some(b"refs/heads/main".as_bstr()) ); assert_eq!(pins[0].id, main); - assert!(history::snapshot(&repository, &[], &[])?.view_tips.contains(&main)); + assert!( + history::snapshot(&repository, &[], &[], false)? + .view_tips + .contains(&main) + ); repository .find_reference("refs/heads/main")? .set_target_id(topic, "advance pinned branch")?; - let advanced = history::snapshot(&repository, &[], &[])?; + let advanced = history::snapshot(&repository, &[], &[], false)?; assert!(advanced.view_tips.contains(&topic), "a symbolic pin follows its branch"); drop(repository); - perform(&repository_path, false, topic, &graph, &[])?; + perform(&repository_path, false, topic, &graph, &[], false)?; let repository = gix::open(fixture.path())?; assert_eq!( repository.head_name()?.map(|name| name.as_bstr().to_owned()), @@ -310,12 +324,12 @@ mod tests { .status()?; assert!(detach.success()); let graph = loaded_graph(&gix::open(fixture.path())?, &[])?; - perform(&repository_path, false, root, &graph, &[])?; + perform(&repository_path, false, root, &graph, &[], false)?; let pin = history::all_pins(&gix::open(fixture.path())?)? .pop() .context("direct pin is present")?; assert_eq!(pin.target.try_id().map(ToOwned::to_owned), Some(main)); - perform(&repository_path, false, main, &graph, &[])?; + perform(&repository_path, false, main, &graph, &[], false)?; let repository = gix::open(fixture.path())?; assert!( repository.head()?.is_detached(), @@ -337,7 +351,7 @@ mod tests { let graph = loaded_graph(&repository, &revisions)?; drop(repository); - perform(&repository_path, false, root, &graph, &revisions)?; + perform(&repository_path, false, root, &graph, &revisions, false)?; assert!( history::all_pins(&gix::open(fixture.path())?)?.is_empty(), "an explicit tip already retains the former HEAD" @@ -350,7 +364,8 @@ mod tests { .status()?; assert!(checkout.success()); std::fs::write(fixture.path().join("main"), "dirty\n")?; - let err = perform(&repository_path, false, root, &graph, &[]).expect_err("Git rejects a conflicting checkout"); + let err = + perform(&repository_path, false, root, &graph, &[], false).expect_err("Git rejects a conflicting checkout"); assert!(format!("{err:#}").contains("git checkout failed")); let repository = gix::open(fixture.path())?; assert_eq!(repository.head_id()?.detach(), main, "failed checkout retains HEAD"); diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 3ba9eeb12a6..64563c17724 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -1384,14 +1384,20 @@ fn metadata_line<'a>( id_style }, )]; - let mut labels = decorations - .get(&row.id) - .into_iter() - .flatten() + let row_decorations = decorations.get(&row.id).map(Vec::as_slice).unwrap_or_default(); + let mut labels = row_decorations + .iter() .filter(|decoration| match ref_mode { + _ if decoration.kind == DecorationKind::Head => false, RefMode::All => true, RefMode::Default => decoration.kind != DecorationKind::Special, - RefMode::None => false, + RefMode::None => { + selected + && matches!( + decoration.kind, + DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached + ) + } }) .peekable(); if labels.peek().is_some() { @@ -1400,8 +1406,16 @@ fn metadata_line<'a>( if index != 0 { spans.push(Span::raw(", ")); } + let name = decoration.name.to_str_lossy(); spans.push(Span::styled( - decoration.name.to_str_lossy(), + if matches!( + decoration.kind, + DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached + ) { + format!("{name}@").into() + } else { + name + }, decoration_style(decoration.kind), )); } @@ -1536,6 +1550,7 @@ fn decoration_style(kind: DecorationKind) -> Style { match kind { DecorationKind::Head => Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), DecorationKind::Pin => Style::default().fg(Color::Blue), + DecorationKind::WorktreeBranch | DecorationKind::WorktreeDetached => Style::default().fg(Color::LightBlue), DecorationKind::Local => Style::default().fg(Color::Cyan), DecorationKind::Remote => Style::default().fg(Color::Yellow), DecorationKind::Tag => Style::default().fg(Color::Magenta), @@ -2148,7 +2163,7 @@ mod tests { terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; let footer_text = "#1 · ↑↓/jk move · h/l pan · Enter diff · r reword · [ align · o commit · c changes · v view · y copy · q quit"; - let selected_line = "> @ 0101010 (HEAD) 1970-01-01 mapped author subject"; + let selected_line = "> @ 0101010 1970-01-01 mapped author subject"; let mut expected = Buffer::with_lines([format!("{selected_line:<180}"), format!("{footer_text:<180}")]); for x in 0..11 { expected[(x, 0)].set_style(Style::default().add_modifier(Modifier::REVERSED)); @@ -2163,13 +2178,10 @@ mod tests { .add_modifier(Modifier::REVERSED | Modifier::BOLD), ); } - for x in 13..17 { - expected[(x, 0)].set_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); - } - for x in 19..30 { + for x in 12..23 { expected[(x, 0)].set_style(Style::default().fg(Color::Blue)); } - for x in 30..44 { + for x in 23..37 { expected[(x, 0)].set_style(Style::default().fg(Color::Green)); } expected[(selected_line.chars().count() as u16 + 2, 0)] @@ -2191,7 +2203,10 @@ mod tests { !row[(11, 0)].modifier.contains(Modifier::REVERSED), "selection ends immediately after the hash" ); - assert_eq!(row[(13, 0)].fg, Color::Cyan, "reference colors remain visible"); + assert!( + !rendered_row(&terminal).contains("HEAD"), + "the graph marker makes textual HEAD redundant" + ); assert!( !rendered_line(&terminal, 1).contains("Esc cancel"), "completed work cannot be cancelled" @@ -2281,7 +2296,10 @@ mod tests { app.update(Action::ToggleRefs); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; - assert!(rendered_row(&terminal).contains("HEAD"), "all refs shows regular refs"); + assert!( + !rendered_row(&terminal).contains("HEAD"), + "all refs still omits redundant HEAD" + ); assert!( rendered_row(&terminal).contains("refs/patches"), "all refs shows special refs" @@ -2290,7 +2308,10 @@ mod tests { app.update(Action::ToggleRefs); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; - assert!(rendered_row(&terminal).contains("HEAD"), "refs shows regular refs"); + assert!( + !rendered_row(&terminal).contains("HEAD"), + "refs still omits redundant HEAD" + ); assert!( !rendered_row(&terminal).contains("refs/patches"), "refs hides special refs" @@ -2386,6 +2407,66 @@ mod tests { Ok(()) } + #[test] + fn renders_worktree_labels_and_keeps_them_selected_when_refs_are_hidden() -> Result<(), Box> + { + let checked_out = gix::ObjectId::Sha1([1; 20]); + let other = gix::ObjectId::Sha1([2; 20]); + let commit = |id| Commit { + id, + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }; + let mut app = App::new(2); + app.extend_commits(vec![commit(checked_out), commit(other)]); + complete(&mut app); + let decorations = Decorations::from([( + checked_out, + vec![ + Decoration { + name: "main".into(), + kind: DecorationKind::WorktreeBranch, + }, + Decoration { + name: "detached".into(), + kind: DecorationKind::WorktreeDetached, + }, + Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }, + ], + )]); + let mut terminal = Terminal::new(TestBackend::new(140, 3))?; + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let row = rendered_row(&terminal); + assert!(row.contains("main@")); + assert!(row.contains("detached@")); + assert!(!row.contains("HEAD"), "a worktree label replaces textual HEAD"); + let x = row.find("main@").expect("the worktree label is visible") as u16; + assert_eq!(terminal.backend().buffer()[(x, 0)].fg, Color::LightBlue); + + app.update(Action::ToggleRefs); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!( + rendered_row(&terminal).contains("main@"), + "the selected row retains worktrees" + ); + app.update(Action::MoveDown); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!( + !rendered_row(&terminal).contains("main@"), + "hidden refs omit worktrees from unselected rows" + ); + Ok(()) + } + #[test] fn removes_the_copied_fields_color_from_only_the_selected_row_for_one_frame() -> Result<(), Box> { @@ -3978,9 +4059,9 @@ mod tests { ); assert_eq!(style("1970-01-01 "), Style::default().fg(Color::Blue)); assert_eq!(style("author "), Style::default().fg(Color::Green)); - assert_eq!( - style("HEAD"), - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + assert!( + line.spans.iter().all(|span| span.content != "HEAD"), + "the graph marker makes textual HEAD redundant" ); assert_eq!(style("main"), Style::default().fg(Color::Cyan)); assert_eq!(style("origin/main"), Style::default().fg(Color::Yellow)); From 08be5aedb9bc1ee09a96cda9b7c8b0ade3143fae Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 13:38:44 +0200 Subject: [PATCH 050/282] Adapt plumbing tix launch to extensible options Construct gix_tix::Options with an explicit update over its defaults. This keeps the embedding plumbing command on the established behavior when tix adds optional traversal inputs such as linked worktree tips, and prevents future additive options from breaking workspace builds. --- src/plumbing/main.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index 10010dc3db6..8640da5da3b 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -166,7 +166,11 @@ pub fn main() -> Result<()> { } => gix_tix::run( repository(Mode::Lenient)?.into_sync(), revisions, - gix_tix::Options { quit_on_finish, hide }, + gix_tix::Options { + quit_on_finish, + hide, + ..Default::default() + }, ), Subcommands::Env => prepare_and_run( "env", From 83777f32181f6dc22056b8d37489a0d8adfb7ea2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 14:38:44 +0200 Subject: [PATCH 051/282] feat: group tix editing shortcuts Put commit rewording and time-travel checkouts behind an e prefix, matching the existing view shortcut group's toggle and dismissal behavior. Keep the overlapping e, r, and t display commands unchanged while the view group is active, and remove the former direct mutation shortcuts. Advertise the edit group in the history footer, expand it to only the actions available for the current selection, and document and test the key-routing and group-state contract. --- gix-tix/spec.md | 12 +++++++++++- gix-tix/src/app.rs | 34 ++++++++++++++++++++++++++++++++++ gix-tix/src/lib.rs | 42 ++++++++++++++++++++++++++---------------- gix-tix/src/ui.rs | 32 +++++++++++++++++++++++++------- 4 files changed, 96 insertions(+), 24 deletions(-) diff --git a/gix-tix/spec.md b/gix-tix/spec.md index 6125d0f8538..592e8450fcd 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -274,7 +274,7 @@ space first; changes blocks adapt within the remaining history width. ### Reword -- `r` is available only after history completion and only on the newest +- `e`, then `r`, is available only after history completion and only on the newest selectable row, where tix assumes the commit has no displayed descendants. - The configured Git editor receives a document containing `Author`, `AuthorDate`, `Committer`, `CommitterDate`, `CommentChar`, and the complete @@ -294,6 +294,16 @@ space first; changes blocks adapt within the remaining history width. - Editor, signing, parsing, writing, or reference-update failures are shown in the main status line and do not leave a repository retained by the UI. +### Editing shortcuts + +- `e` toggles the edit shortcut group. `e r` rewords the newest commit and + `e t` enters or returns from time travel when that action is available. +- Edit shortcuts keep the group open. Navigation or another recognized command + closes it, matching the `v` display shortcut group. Plain `r` and `t` do not + mutate the repository. +- While the `v` group is open, `e`, `r`, and `t` retain their display meanings + for emails, references, and trailers. + ## Refresh, focus, and diagnostics - Native reference watchers observe `HEAD`, loose and packed refs, linked-worktree diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 2db09a7a43b..ab34b9ff635 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -276,6 +276,7 @@ pub(crate) enum Action { Refresh, ToggleHidden, ToggleHistoryDisplay, + ToggleEdit, ToggleAlign, ToggleCommit, ToggleChanges, @@ -362,6 +363,7 @@ pub(crate) struct App { pub(crate) notice: Option, pub(crate) unseen_filesystem_redraw: bool, pub(crate) history_display_expanded: bool, + pub(crate) edit_expanded: bool, pub estimated_lane_width: usize, pub horizontal_offset: usize, horizontal_page: usize, @@ -429,6 +431,7 @@ impl App { notice: None, unseen_filesystem_redraw: false, history_display_expanded: false, + edit_expanded: false, estimated_lane_width: 0, horizontal_offset: 0, horizontal_page: 1, @@ -608,6 +611,9 @@ impl App { ) { self.history_display_expanded = false; } + if !matches!(&action, Action::ToggleEdit | Action::Reword | Action::TimeTravel) { + self.edit_expanded = false; + } match action { Action::Cancelled if self.state == State::Cancelling => self.state = State::Cancelled, Action::MoveUp if self.changes_focus.is_some() => self.move_changes(1, false), @@ -697,6 +703,7 @@ impl App { Action::ToggleTrailers => self.show_trailers = !self.show_trailers, Action::ToggleMailmap => self.use_mailmap = !self.use_mailmap, Action::ToggleHistoryDisplay => self.history_display_expanded = !self.history_display_expanded, + Action::ToggleEdit => self.edit_expanded = !self.edit_expanded, Action::ToggleRefs => { self.ref_mode = match self.ref_mode { RefMode::All => RefMode::Default, @@ -2353,6 +2360,33 @@ mod tests { assert!(!app.history_display_expanded, "the prefix key toggles the group"); } + #[test] + fn edit_group_stays_open_only_for_grouped_actions() { + let mut app = App::new(1); + + app.update(Action::ToggleEdit); + assert!(app.edit_expanded); + app.update(Action::Reword); + assert!(app.edit_expanded, "grouped edit commands keep the group open"); + + app.update(Action::MoveDown); + assert!(!app.edit_expanded, "navigation collapses the group"); + + app.update(Action::ToggleEdit); + app.update(Action::ToggleHistoryDisplay); + assert!(!app.edit_expanded, "opening the view group closes the edit group"); + assert!(app.history_display_expanded); + + app.update(Action::ToggleEdit); + assert!(app.edit_expanded); + assert!( + !app.history_display_expanded, + "opening the edit group closes the view group" + ); + app.update(Action::ToggleEdit); + assert!(!app.edit_expanded, "the prefix key toggles the group"); + } + #[test] fn cycles_both_tree_and_hidden_changes() { let mut app = App::new(1); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index edec1a808ea..6a702acfb36 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -1448,7 +1448,7 @@ fn event_loop( }; let (action, repeats_history, is_repeat, throttles_draw) = match terminal_event { TerminalEvent::Key(key) => { - let action = action_with_history_display(key, app.history_display_expanded); + let action = action_with_shortcut_groups(key, app.history_display_expanded, app.edit_expanded); let repeats_history = retains_fill_repository(key.kind, action.as_ref(), app.changes_focus.is_some()); (action, repeats_history, key.kind == KeyEventKind::Repeat, false) } @@ -3303,10 +3303,10 @@ fn poll_timeout( } fn action(key: KeyEvent) -> Option { - action_with_history_display(key, false) + action_with_shortcut_groups(key, false, false) } -fn action_with_history_display(key: KeyEvent, history_display_expanded: bool) -> Option { +fn action_with_shortcut_groups(key: KeyEvent, history_display_expanded: bool, edit_expanded: bool) -> Option { if key.kind == KeyEventKind::Release && !matches!( key.code, @@ -3348,10 +3348,11 @@ fn action_with_history_display(key: KeyEvent, history_display_expanded: bool) -> KeyCode::Char('R') => Some(Action::Refresh), KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Refresh), KeyCode::Char('r') if history_display_expanded => Some(Action::ToggleRefs), - KeyCode::Char('r') => Some(Action::Reword), - KeyCode::Char('t') => Some(Action::TimeTravel), + KeyCode::Char('r') if edit_expanded => Some(Action::Reword), + KeyCode::Char('t') if edit_expanded => Some(Action::TimeTravel), KeyCode::Char('s') => Some(Action::VerifySignatures), KeyCode::Char('v') => Some(Action::ToggleHistoryDisplay), + KeyCode::Char('e') => Some(Action::ToggleEdit), KeyCode::Char('[') => Some(Action::ToggleAlign), KeyCode::Char(']' | 'o') => Some(Action::ToggleCommit), KeyCode::Char('Y') => Some(Action::CopyAuthor), @@ -4175,17 +4176,14 @@ mod tests { "terminals that report shifted letters in lowercase still map Shift-G to the first commit" ); assert_eq!(action(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), None); - assert_eq!(action(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), None); - assert_eq!(action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), None); - assert_eq!(action(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)), None); - assert_eq!( - action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), - Some(Action::Reword) - ); assert_eq!( - action(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)), - Some(Action::TimeTravel) + action(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), + Some(Action::ToggleEdit) ); + assert_eq!(action(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)), None); + assert_eq!(action(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)), None); + assert_eq!(action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), None); + assert_eq!(action(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)), None); assert_eq!( action(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::SHIFT)), Some(Action::Refresh), @@ -4210,16 +4208,28 @@ mod tests { ('h', Action::ToggleHidden), ] { assert_eq!( - action_with_history_display(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE), true), + action_with_shortcut_groups(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE), true, false), Some(expected), "{key} is available after the view prefix" ); } assert_eq!( - action_with_history_display(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), true), + action_with_shortcut_groups(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE), true, false), Some(Action::ToggleHistoryDisplay), "v closes the view shortcut group" ); + for (key, expected) in [('r', Action::Reword), ('t', Action::TimeTravel)] { + assert_eq!( + action_with_shortcut_groups(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE), false, true), + Some(expected), + "{key} is available after the edit prefix" + ); + } + assert_eq!( + action_with_shortcut_groups(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), false, true), + Some(Action::ToggleEdit), + "e closes the edit shortcut group" + ); assert_eq!( action(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)), Some(Action::VerifySignatures) diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 64563c17724..7abae7f7158 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -630,7 +630,7 @@ pub(crate) fn draw_with_worktree( ))]; if app.changes_focus.is_none() { footer_spans.push(Span::raw(" · Enter diff")); - if app.time_travel_shortcut_visible() + let time_travel = if app.time_travel_shortcut_visible() && decorations .values() .flatten() @@ -642,7 +642,7 @@ pub(crate) fn draw_with_worktree( .iter() .any(|decoration| decoration.kind == DecorationKind::Head) { - footer_spans.push(Span::raw( + Some( if selected_refs .iter() .any(|decoration| decoration.kind == DecorationKind::Pin) @@ -651,11 +651,22 @@ pub(crate) fn draw_with_worktree( } else { " · t travel" }, - )); + ) + } else { + None } - } - if app.reword_shortcut_visible() { - footer_spans.push(Span::raw(" · r reword")); + } else { + None + }; + if app.edit_expanded { + if let Some(label) = time_travel { + footer_spans.push(Span::raw(label)); + } + if app.reword_shortcut_visible() { + footer_spans.push(Span::raw(" · r reword")); + } + } else if !app.history_display_expanded { + footer_spans.push(Span::raw(" · e edit")); } } if app.tree_changes_visible || app.worktree_changes_visible { @@ -1994,6 +2005,11 @@ mod tests { gix::mailmap::Snapshot::from_bytes(b"Mapped Human Human \n"); terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None, None))?; + assert!( + !rendered_line(&terminal, 1).contains("e edit"), + "the view group keeps e reserved for toggling emails" + ); + let row = rendered_row(&terminal); assert!( row.contains("[Codex] Co, A: [Claude], * Re: Mapped Human Ack: Acknowledger Te: Tester So: Signer subject"), @@ -2162,7 +2178,7 @@ mod tests { terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; - let footer_text = "#1 · ↑↓/jk move · h/l pan · Enter diff · r reword · [ align · o commit · c changes · v view · y copy · q quit"; + let footer_text = "#1 · ↑↓/jk move · h/l pan · Enter diff · e edit · [ align · o commit · c changes · v view · y copy · q quit"; let selected_line = "> @ 0101010 1970-01-01 mapped author subject"; let mut expected = Buffer::with_lines([format!("{selected_line:<180}"), format!("{footer_text:<180}")]); for x in 0..11 { @@ -2397,9 +2413,11 @@ mod tests { ), ]); let mut terminal = Terminal::new(TestBackend::new(140, 2))?; + app.edit_expanded = true; terminal.draw(|frame| draw(frame, &mut app, &decorations))?; assert!(rendered_row(&terminal).contains("pin:01010101")); assert!(rendered_line(&terminal, 1).contains("t return")); + assert!(!rendered_line(&terminal, 1).contains("e edit")); decorations.remove(&selected); terminal.draw(|frame| draw(frame, &mut app, &decorations))?; From ec09425986c2bff3f367664a32dcee10a198c3d3 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 16:17:09 +0200 Subject: [PATCH 052/282] feat: focus and flag non-tip HEAD in tix Select the current worktree HEAD as soon as its history row becomes available during startup, while allowing user navigation to cancel the pending jump and retaining the normal fallback when HEAD is outside the view. Warn when the current HEAD has visible descendants by underlining its unselected history row and bolding the @ marker without changing signature, selection, or other row colors. --- gix-tix/spec.md | 4 ++ gix-tix/src/app.rs | 87 +++++++++++++++++++++++++++++++++++ gix-tix/src/lib.rs | 20 +++++++- gix-tix/src/ui.rs | 112 ++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 216 insertions(+), 7 deletions(-) diff --git a/gix-tix/spec.md b/gix-tix/spec.md index 592e8450fcd..c727a7c7131 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -72,6 +72,10 @@ without trading responsiveness for metadata that is not visible. - The current `HEAD` commit uses `@` instead of the normal commit disc and keeps the same signature and selection coloring. It remains visible when textual reference labels are hidden, and textual `HEAD` is never rendered alongside it. +- At startup, the current worktree's `@` row becomes selected as soon as it is + loaded, unless the user navigates first. If it has visible descendants, its + unselected non-whitespace content is underlined and `@` is bold; a selected + row keeps only bold `@`. - Local branches checked out in other worktrees are displayed as `short-name@` in light blue instead of their plain branch decoration. Other detached worktrees use their checkout directory basename. The current worktree is diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index ab34b9ff635..22a25aec857 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -370,6 +370,9 @@ pub(crate) struct App { horizontal_max: usize, follow_tail: bool, reload_selection: Option, + pending_initial_selection: Option, + worktree_head: Option, + worktree_head_has_descendants: bool, select_top_after_refresh: bool, pub(crate) signature_failures: usize, signature_verification_running: bool, @@ -438,6 +441,9 @@ impl App { horizontal_max: 0, follow_tail: false, reload_selection: None, + pending_initial_selection: None, + worktree_head: None, + worktree_head_has_descendants: false, select_top_after_refresh: false, signature_failures: 0, signature_verification_running: false, @@ -453,6 +459,16 @@ impl App { } } + pub(crate) fn set_worktree_head(&mut self, head: Option, select_on_load: bool) { + self.worktree_head = head; + self.pending_initial_selection = select_on_load.then_some(head).flatten(); + self.update_worktree_head_descendants(); + } + + pub(crate) fn worktree_head_has_descendants(&self, id: ObjectId) -> bool { + self.worktree_head == Some(id) && self.worktree_head_has_descendants + } + pub(crate) fn extend_commits(&mut self, commits: impl Into) { let commits = commits.into(); if self.state != State::Loading || commits.rows.is_empty() { @@ -482,6 +498,16 @@ impl App { self.reload_selection = None; self.ensure_visible(); } + if let Some(index) = self + .pending_initial_selection + .and_then(|id| self.rows.iter().position(|row| row.id == id)) + { + if !self.is_row_hidden(index) { + self.selected = Some(index); + } + self.pending_initial_selection = None; + self.ensure_visible(); + } if self.reachability_anchor.is_some() { self.compute_reachable_rows(); } @@ -489,6 +515,11 @@ impl App { fn store_commits(&mut self, commits: LoadedCommits) -> Vec { let LoadedCommits { rows, attributions } = commits; + if !self.worktree_head_has_descendants + && let Some(head) = self.worktree_head + { + self.worktree_head_has_descendants = rows.iter().any(|row| row.parent_ids.contains(&head)); + } self.titles.reserve(rows.iter().map(|row| row.title.len()).sum()); let attribution_base = self.attributions.len(); self.attributions.extend(attributions); @@ -677,6 +708,7 @@ impl App { self.ensure_changes_visible(); } Action::Last if self.last_selectable().is_some() => { + self.pending_initial_selection = None; let previous = self.selected; self.selected = self.last_selectable(); if self.selected != previous { @@ -849,6 +881,7 @@ impl App { self.state = State::Computing; self.follow_tail = false; self.reload_selection = None; + self.pending_initial_selection = None; Some(self.rows.clone()) } State::Cancelling => { @@ -959,6 +992,7 @@ impl App { } self.graph = Some(graph); self.lane_time = Some(lane_time); + self.update_worktree_head_descendants(); self.selected = selected .and_then(|id| self.rows.iter().position(|row| row.id == id)) .or_else(|| self.first_selectable()); @@ -996,6 +1030,8 @@ impl App { self.reset_commit_view(); self.reset_changes_view(); self.follow_tail = false; + self.pending_initial_selection = None; + self.update_worktree_head_descendants(); self.clear_preview_author_copy(); self.signature_failures = 0; self.signature_verification_running = false; @@ -1084,6 +1120,7 @@ impl App { } fn move_selection(&mut self, distance: usize, down: bool) { + self.pending_initial_selection = None; let Some(selected) = self.selected else { return }; let target = if down { selected.saturating_add(distance).min(self.rows.len() - 1) @@ -1099,6 +1136,7 @@ impl App { } fn move_reachable(&mut self, distance: usize, down: bool) { + self.pending_initial_selection = None; let (Some(selected), Some(reachable)) = (self.selected, self.reachable_rows.as_ref()) else { self.move_selection(distance, down); return; @@ -1203,6 +1241,7 @@ impl App { } fn select(&mut self, selected: usize) { + self.pending_initial_selection = None; if !self.rows.is_empty() && !self.is_row_hidden(selected) { let previous = self.selected; self.selected = Some(selected.min(self.rows.len() - 1)); @@ -1218,6 +1257,14 @@ impl App { (0..self.rows.len()).find(|index| !self.is_row_hidden(*index)) } + fn update_worktree_head_descendants(&mut self) { + self.worktree_head_has_descendants = self.worktree_head.is_some_and(|head| { + self.rows + .iter() + .any(|row| row.id != head && row.parent_ids.contains(&head)) + }); + } + pub(crate) fn can_reword(&self) -> bool { self.state == State::Complete && self.reword_shortcut_visible() } @@ -2141,6 +2188,46 @@ mod tests { assert_eq!(app.selected, Some(2), "manual navigation stops following the tail"); } + #[test] + fn startup_selection_follows_the_worktree_head_until_the_user_moves() { + let mut app = App::new(2); + app.set_worktree_head(Some(id(2)), true); + app.extend_commits(vec![row_with_parents(3, &[2])]); + assert_eq!(app.selected, Some(0), "the newest row is selected provisionally"); + assert!( + app.worktree_head_has_descendants(id(2)), + "a streamed child marks HEAD as having visible descendants" + ); + + app.extend_commits(vec![row(2)]); + assert_eq!(app.selected, Some(1), "selection moves to HEAD when its row arrives"); + complete(&mut app); + assert_eq!(app.selected, Some(1), "lane computation retains the HEAD selection"); + + let mut moved = App::new(2); + moved.set_worktree_head(Some(id(2)), true); + moved.extend_commits(vec![row_with_parents(3, &[2])]); + moved.update(Action::MoveDown); + moved.extend_commits(vec![row(2)]); + assert_eq!(moved.selected, Some(0), "navigation cancels the pending jump to HEAD"); + } + + #[test] + fn startup_head_selection_falls_back_when_head_is_unavailable() { + let mut absent = App::new(2); + absent.set_worktree_head(Some(id(9)), true); + absent.extend_commits(vec![row(3), row(2)]); + complete(&mut absent); + assert_eq!(absent.selected, Some(0), "an absent HEAD retains the newest selection"); + + let mut hidden = App::new(2); + hidden.set_worktree_head(Some(id(2)), true); + hidden.extend_commits(vec![row(3)]); + hidden.extend_hidden_commits(vec![row(2)]); + complete(&mut hidden); + assert_eq!(hidden.selected, Some(0), "a hidden HEAD cannot become selected"); + } + #[test] fn navigation_is_clamped_and_uses_the_viewport_for_pages() { let mut app = App::new(2); diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 6a702acfb36..eedee93773c 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -1198,6 +1198,12 @@ fn event_loop( let response_ids = filesystem_responses.active_reference_ids().to_vec(); filesystem_responses.phase(&response_ids, "history-refresh-completed"); filesystem_responses.queue_frame(&response_ids, "history-refresh-completed"); + app.set_worktree_head( + (!repository_is_bare) + .then(|| decoration_head(&result.decorations)) + .flatten(), + false, + ); decorations = result.decorations; selection_relation = None; app.selection_relation = None; @@ -1368,7 +1374,10 @@ fn event_loop( events += 1; dirty = true; match message? { - Event::Decorations(value) => decorations = value, + Event::Decorations(value) => { + app.set_worktree_head((!repository_is_bare).then(|| decoration_head(&value)).flatten(), true); + decorations = value; + } Event::Commits(rows) => app.extend_commits(rows), Event::HiddenCommits(rows) => app.extend_hidden_commits(rows), Event::VisibleComplete => { @@ -1959,6 +1968,15 @@ fn remembered_change_selection(view: &app::ChangesView, changes: Option<&Changes }) } +fn decoration_head(decorations: &Decorations) -> Option { + decorations.iter().find_map(|(id, decorations)| { + decorations + .iter() + .any(|decoration| decoration.kind == history::DecorationKind::Head) + .then_some(*id) + }) +} + fn restore_change_selection(view: &mut app::ChangesView, changes: &Changes, remembered: Option<(BString, usize)>) { let Some((path, viewport_row)) = remembered else { return; diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 7abae7f7158..1be49c3c737 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -371,6 +371,9 @@ pub(crate) fn draw_with_worktree( .iter() .any(|decoration| decoration.kind == DecorationKind::Head) }); + let head_has_descendants = app.worktree_head_has_descendants(visible_rows[index].id); + let underline = head_has_descendants && !selected; + let head_state = head.then_some(head_has_descendants); let metadata_width = metadata.width(); let signature_color = signature_color(visible_rows[index].signature); let highlight = if selected && app.show_selection_tail { @@ -408,7 +411,7 @@ pub(crate) fn draw_with_worktree( graph_offset, highlight, visible_rows[index].signature, - head, + head_state, ); let aligned = Rect::new( content.x.saturating_add(align_width as u16), @@ -433,7 +436,7 @@ pub(crate) fn draw_with_worktree( horizontal_offset, highlight, visible_rows[index].signature, - head, + head_state, ); } let lane_offset = if align_metadata { @@ -495,6 +498,14 @@ pub(crate) fn draw_with_worktree( } buffer[(marker_x, y)].set_symbol(" ").set_style(style); } + if underline { + for x in body.x..body.right() { + let cell = &mut frame.buffer_mut()[(x, y)]; + if !cell.symbol().chars().all(char::is_whitespace) { + cell.modifier.insert(Modifier::UNDERLINED); + } + } + } if !app.is_row_reachable(start + index) { for x in body.x..body.right() { frame.buffer_mut()[(x, y)].set_style(Style::default().add_modifier(Modifier::DIM)); @@ -1581,21 +1592,24 @@ fn color_graph( offset: usize, highlight: Option, signature: SignatureState, - head: bool, + head: Option, ) { for (x, symbol) in graph.chars().skip(offset).take(area.width as usize).enumerate() { if symbol.is_whitespace() { continue; } - let style = if let Some(highlight) = highlight { + let mut style = if let Some(highlight) = highlight { color(highlight).add_modifier(Modifier::REVERSED) } else if symbol == '●' { color(signature_color(signature)) } else { graph_style(offset.saturating_add(x) / 2) }; + if head == Some(true) && symbol == '●' { + style = style.add_modifier(Modifier::BOLD); + } let cell = &mut frame.buffer_mut()[(area.x + x as u16, area.y)]; - if head && symbol == '●' { + if head.is_some() && symbol == '●' { cell.set_symbol("@"); } cell.set_style(style); @@ -2581,7 +2595,7 @@ mod tests { 0, Some(signature_color(*state)), *state, - head, + head.then_some(false), ); } } @@ -2599,6 +2613,92 @@ mod tests { Ok(()) } + #[test] + fn underlines_an_unselected_non_tip_head_and_bolds_its_marker() -> Result<(), Box> { + let head = gix::ObjectId::Sha1([1; 20]); + let child = gix::ObjectId::Sha1([2; 20]); + let commit = |id: gix::ObjectId, parent: Option| Commit { + id, + parent_ids: parent.into_iter().collect(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }; + let mut app = App::new(2); + app.set_worktree_head(Some(head), false); + app.extend_commits(vec![commit(child, Some(head)), commit(head, None)]); + complete(&mut app); + app.selected = Some(0); + let decorations = Decorations::from([( + head, + vec![Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }], + )]); + let mut terminal = Terminal::new(TestBackend::new(80, 3))?; + + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + let head_row = 1; + for x in [2, 5, 20] { + assert!( + terminal.backend().buffer()[(x, head_row)] + .modifier + .contains(Modifier::UNDERLINED), + "each non-whitespace part of the unselected HEAD line is underlined" + ); + } + for x in [0, 1, 3, 11, 79] { + assert!( + !terminal.backend().buffer()[(x, head_row)] + .modifier + .contains(Modifier::UNDERLINED), + "whitespace on the HEAD line is not underlined" + ); + } + assert!( + terminal.backend().buffer()[(2, head_row)] + .modifier + .contains(Modifier::BOLD), + "the non-tip @ is bold" + ); + + app.selected = Some(1); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!( + !terminal.backend().buffer()[(20, head_row)] + .modifier + .contains(Modifier::UNDERLINED), + "selection removes the warning underline" + ); + assert!( + terminal.backend().buffer()[(2, head_row)] + .modifier + .contains(Modifier::BOLD), + "the selected non-tip @ remains bold" + ); + + app.set_worktree_head(Some(child), false); + let decorations = Decorations::from([( + child, + vec![Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }], + )]); + app.selected = Some(1); + terminal.draw(|frame| draw(frame, &mut app, &decorations))?; + assert!( + !terminal.backend().buffer()[(2, 0)].modifier.contains(Modifier::BOLD), + "a tip @ keeps its normal weight" + ); + Ok(()) + } + #[test] fn marks_dirty_head_independently_of_history_selection() -> Result<(), Box> { let head = gix::ObjectId::Sha1([1; 20]); From a7f4336b7125eb9cdb6d40910894ee309f4671cb Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 20:15:42 +0200 Subject: [PATCH 053/282] Move tix editing into a dedicated module --- gix-tix/src/edit/mod.rs | 45 +++++++++++++++++++++ gix-tix/src/{ => edit}/reword.rs | 2 +- gix-tix/src/{ => edit}/time_travel.rs | 18 ++++----- gix-tix/src/lib.rs | 56 ++++++++++----------------- 4 files changed, 75 insertions(+), 46 deletions(-) create mode 100644 gix-tix/src/edit/mod.rs rename gix-tix/src/{ => edit}/reword.rs (99%) rename gix-tix/src/{ => edit}/time_travel.rs (95%) diff --git a/gix-tix/src/edit/mod.rs b/gix-tix/src/edit/mod.rs new file mode 100644 index 00000000000..35c0f139810 --- /dev/null +++ b/gix-tix/src/edit/mod.rs @@ -0,0 +1,45 @@ +use std::{ffi::OsStr, io::Write, process::Command}; + +use anyhow::{Context, Result}; + +pub(crate) mod reword; +pub(crate) mod time_travel; + +pub(crate) fn edit_document( + terminal: &mut ratatui::DefaultTerminal, + editor: &OsStr, + document: &[u8], + filename: &str, + enhanced_keyboard: bool, +) -> Result>> { + let mut tempfile = gix::tempfile::writable_at( + std::env::temp_dir().join(filename), + gix::tempfile::ContainingDirectory::Exists, + gix::tempfile::AutoRemove::Tempfile, + ) + .context("could not create commit message file")? + .take() + .context("commit message file disappeared")?; + tempfile + .write_all(document) + .context("could not write commit message file")?; + tempfile.flush().context("could not flush commit message file")?; + + if editor != ":" { + crate::with_suspended_terminal(terminal, enhanced_keyboard, || { + let status = Command::from( + gix::command::prepare(editor) + .arg(tempfile.path()) + .command_may_be_shell_script_allow_manual_argument_splitting(), + ) + .status() + .with_context(|| format!("could not launch Git editor {}", editor.to_string_lossy()))?; + if !status.success() { + anyhow::bail!("Git editor {} exited with {status}", editor.to_string_lossy()); + } + Ok(()) + })?; + } + let edited = std::fs::read(tempfile.path()).context("could not read edited commit message")?; + Ok((edited != document).then_some(edited)) +} diff --git a/gix-tix/src/reword.rs b/gix-tix/src/edit/reword.rs similarity index 99% rename from gix-tix/src/reword.rs rename to gix-tix/src/edit/reword.rs index 809d1df86de..a48d00754b1 100644 --- a/gix-tix/src/reword.rs +++ b/gix-tix/src/edit/reword.rs @@ -375,7 +375,7 @@ mod tests { } let (_key_home, key) = gix_testtools::signature::ssh_private_key()?; let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; - let old_id = gix::open(fixture.path())?.head_id()?.detach(); + let old_id = crate::open_test_repository(fixture.path())?.head_id()?.detach(); let git = |args: &[&str]| -> std::io::Result { Command::new("git").arg("-C").arg(fixture.path()).args(args).status() }; diff --git a/gix-tix/src/time_travel.rs b/gix-tix/src/edit/time_travel.rs similarity index 95% rename from gix-tix/src/time_travel.rs rename to gix-tix/src/edit/time_travel.rs index 7fd8e1b1e88..a88945c33f8 100644 --- a/gix-tix/src/time_travel.rs +++ b/gix-tix/src/edit/time_travel.rs @@ -268,7 +268,7 @@ mod tests { #[test] fn travels_with_symbolic_and_direct_pins_and_returns() -> gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; - let repository = gix::open(fixture.path())?; + let repository = crate::open_test_repository(fixture.path())?; let repository_path = repository.git_dir().to_owned(); let root = repository.rev_parse_single("main~2")?.detach(); let main = repository.rev_parse_single("main")?.detach(); @@ -285,7 +285,7 @@ mod tests { let notice = perform(&repository_path, false, root, &graph, &[], false)?.context("time-travel changed HEAD")?; assert!(notice.contains("saved pin:"), "{notice}"); - let repository = gix::open(fixture.path())?; + let repository = crate::open_test_repository(fixture.path())?; assert!(repository.head()?.is_detached(), "travel detaches HEAD"); assert_eq!(repository.head_id()?.detach(), root); let pins = history::all_pins(&repository)?; @@ -309,7 +309,7 @@ mod tests { drop(repository); perform(&repository_path, false, topic, &graph, &[], false)?; - let repository = gix::open(fixture.path())?; + let repository = crate::open_test_repository(fixture.path())?; assert_eq!( repository.head_name()?.map(|name| name.as_bstr().to_owned()), Some(b"refs/heads/main".into()), @@ -323,14 +323,14 @@ mod tests { .args(["checkout", "--detach", &main.to_hex().to_string()]) .status()?; assert!(detach.success()); - let graph = loaded_graph(&gix::open(fixture.path())?, &[])?; + let graph = loaded_graph(&crate::open_test_repository(fixture.path())?, &[])?; perform(&repository_path, false, root, &graph, &[], false)?; - let pin = history::all_pins(&gix::open(fixture.path())?)? + let pin = history::all_pins(&crate::open_test_repository(fixture.path())?)? .pop() .context("direct pin is present")?; assert_eq!(pin.target.try_id().map(ToOwned::to_owned), Some(main)); perform(&repository_path, false, main, &graph, &[], false)?; - let repository = gix::open(fixture.path())?; + let repository = crate::open_test_repository(fixture.path())?; assert!( repository.head()?.is_detached(), "a direct pin returns to detached HEAD" @@ -343,7 +343,7 @@ mod tests { #[test] fn explicit_tips_avoid_redundant_pins_and_failed_checkouts_clean_up() -> gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_writable("history.sh")?; - let repository = gix::open(fixture.path())?; + let repository = crate::open_test_repository(fixture.path())?; let repository_path = repository.git_dir().to_owned(); let root = repository.rev_parse_single("main~2")?.detach(); let main = repository.rev_parse_single("main")?.detach(); @@ -353,7 +353,7 @@ mod tests { perform(&repository_path, false, root, &graph, &revisions, false)?; assert!( - history::all_pins(&gix::open(fixture.path())?)?.is_empty(), + history::all_pins(&crate::open_test_repository(fixture.path())?)?.is_empty(), "an explicit tip already retains the former HEAD" ); @@ -367,7 +367,7 @@ mod tests { let err = perform(&repository_path, false, root, &graph, &[], false).expect_err("Git rejects a conflicting checkout"); assert!(format!("{err:#}").contains("git checkout failed")); - let repository = gix::open(fixture.path())?; + let repository = crate::open_test_repository(fixture.path())?; assert_eq!(repository.head_id()?.detach(), main, "failed checkout retains HEAD"); assert!( history::all_pins(&repository)?.is_empty(), diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index eedee93773c..2fcf66d6840 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -4,10 +4,9 @@ mod animation; mod app; +mod edit; mod history; mod logging; -mod reword; -mod time_travel; mod ui; use std::{ @@ -1698,7 +1697,14 @@ fn event_loop( .as_ref() .context("time-travel requires a completed history graph") .and_then(|graph| { - time_travel::perform(&repository_path, repository_is_bare, id, graph, &revisions, worktrees) + edit::time_travel::perform( + &repository_path, + repository_is_bare, + id, + graph, + &revisions, + worktrees, + ) }); match result { Ok(Some(notice)) => { @@ -2654,45 +2660,23 @@ fn reword_commit( let mut repository = open_repository(repository_path, bare, false).context("could not open repository before editing commit")?; repository.object_cache_size(None); - reword::document(&repository, id)? + edit::reword::document(&repository, id)? }; - let mut tempfile = gix::tempfile::writable_at( - std::env::temp_dir().join(format!("tix-reword-{}.md", std::process::id())), - gix::tempfile::ContainingDirectory::Exists, - gix::tempfile::AutoRemove::Tempfile, - ) - .context("could not create commit message file")? - .take() - .context("commit message file disappeared")?; - tempfile - .write_all(&document) - .context("could not write commit message file")?; - tempfile.flush().context("could not flush commit message file")?; - - if editor != ":" { - with_suspended_terminal(terminal, enhanced_keyboard, || { - let status = Command::from( - gix::command::prepare(&editor) - .arg(tempfile.path()) - .command_may_be_shell_script_allow_manual_argument_splitting(), - ) - .status() - .with_context(|| format!("could not launch Git editor {}", editor.to_string_lossy()))?; - if !status.success() { - anyhow::bail!("Git editor {} exited with {status}", editor.to_string_lossy()); - } - Ok(()) - })?; - } - let edited = std::fs::read(tempfile.path()).context("could not read edited commit message")?; - if edited == document { + let Some(edited) = edit::edit_document( + terminal, + &editor, + &document, + &format!("tix-reword-{}.md", std::process::id()), + enhanced_keyboard, + )? + else { return Ok(None); - } + }; let mut repository = open_repository(repository_path, bare, false).context("could not reopen repository after editing commit")?; repository.object_cache_size(None); - reword::apply(&repository, id, &edited) + edit::reword::apply(&repository, id, &edited) } fn run_external_diff( From 1e87f6e049d1f5ba82b88e5c507bf4008b91e5d1 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 20:19:23 +0200 Subject: [PATCH 054/282] feat: create commits from tix Restrict rewording and commit creation to commits without descendants in the completed in-memory history graph. Offer `e n` for a live worktree, including unborn repositories, without retaining a repository in the UI. Resolve identities, signing configuration, every mutable direct ref, linked-worktree safety, index conflicts, filters, the candidate tree, and its per-path diffstat before opening the editor. Keep provisional objects in object memory so cancellation and preflight failures leave refs, index, object storage, and worktree untouched. Let a changed index win over unstaged files; otherwise snapshot worktree changes only when HEAD is based on the selected parent, falling back to the parent or empty tree. Present a Markdown what/why template with optional attribution trailers and a commented Git-style diffstat. After editing, revalidate all destinations, sign when configured, persist prepared objects, and atomically advance every mutable direct ref pointing at the parent, including local branches, custom refs, direct pins, and detached HEAD. Preserve attached HEAD and unrelated worktrees, exclude tags and remote-tracking refs, reject branches checked out elsewhere, and align the current checkout without running hooks. Add scenario coverage for staged precedence, worktree snapshots, unborn roots, multi-ref updates, and repository-state isolation. --- gix-tix/spec.md | 36 +- gix-tix/src/app.rs | 80 ++- gix-tix/src/edit/create.rs | 565 ++++++++++++++++++ gix-tix/src/edit/mod.rs | 2 + gix-tix/src/edit/refs.rs | 174 ++++++ gix-tix/src/edit/reword.rs | 119 ++-- gix-tix/src/edit/time_travel.rs | 4 +- gix-tix/src/history.rs | 4 + gix-tix/src/lib.rs | 118 +++- gix-tix/src/ui.rs | 3 + gix-tix/tests/fixtures/create_commit.sh | 14 + .../generated-archives/create_commit.tar | Bin 0 -> 64000 bytes 12 files changed, 1013 insertions(+), 106 deletions(-) create mode 100644 gix-tix/src/edit/create.rs create mode 100644 gix-tix/src/edit/refs.rs create mode 100644 gix-tix/tests/fixtures/create_commit.sh create mode 100644 gix-tix/tests/fixtures/generated-archives/create_commit.tar diff --git a/gix-tix/spec.md b/gix-tix/spec.md index c727a7c7131..d6afc335658 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -264,7 +264,7 @@ space first; changes blocks adapt within the remaining history width. Broken-pipe writes are accepted. If a pager exits within 250 ms, its already displayed output is retained until a keypress so short output remains readable. -## Signature verification and rewording +## Signature verification and editing ### Signatures @@ -278,8 +278,8 @@ space first; changes blocks adapt within the remaining history width. ### Reword -- `e`, then `r`, is available only after history completion and only on the newest - selectable row, where tix assumes the commit has no displayed descendants. +- `e`, then `r`, is available only after history completion and only when the + selected commit has no descendants in tix's complete cached graph. - The configured Git editor receives a document containing `Author`, `AuthorDate`, `Committer`, `CommitterDate`, `CommentChar`, and the complete message in a temporary `.md` file for syntax highlighting. Author identity and @@ -298,10 +298,36 @@ space first; changes blocks adapt within the remaining history width. - Editor, signing, parsing, writing, or reference-update failures are shown in the main status line and do not leave a repository retained by the UI. +### New commits + +- `e`, then `n`, creates a child of the selected commit, or a root commit for an + unborn `HEAD`. It is available only with a live worktree, after history + completion, and when the selected parent has no known descendants. +- Before launching the editor, tix resolves identities, signing configuration, + every mutable direct ref, linked-worktree safety, index conflicts, filters, + candidate tree, per-path diffstat, and a provisional commit entirely through an + in-memory object database. Cancellation and preflight failure write no object, + reference, index, or worktree state. +- A changed index supplies the complete commit tree and wins over unstaged + changes. Otherwise, when the worktree `HEAD` is the selected parent, worktree + changes are filtered into a tree. With no applicable changes—or when the + selected parent is not the worktree base—the parent tree is reused; an unborn + repository starts from the empty tree. +- The Markdown editor buffer contains editable identities and dates, a `what` + title, a `why` body, optional attribution trailers, and a commented Git-style + per-path diffstat. Commit hooks are not run. +- After editing, tix revalidates the destination, applies configured signing, + persists the already-prepared objects, and atomically advances every mutable + direct ref pointing at the parent. This includes local branches, custom refs, + direct tix pins, and a detached `HEAD`, while excluding tags and remote-tracking + refs. An attached `HEAD` remains attached; an unrelated worktree `HEAD` is left + untouched. A ref changed by another process or a branch checked out in another + worktree aborts safely. + ### Editing shortcuts -- `e` toggles the edit shortcut group. `e r` rewords the newest commit and - `e t` enters or returns from time travel when that action is available. +- `e` toggles the edit shortcut group. `e r` rewords, `e n` creates a commit, + and `e t` enters or returns from time travel when each action is available. - Edit shortcuts keep the group open. Navigation or another recognized command closes it, matching the `v` display shortcut group. Plain `r` and `t` do not mutate the repository. diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 22a25aec857..59fe42b4069 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -284,6 +284,7 @@ pub(crate) enum Action { CycleChangesParent, OpenDiff, Reword, + NewCommit, TimeTravel, VerifySignatures, Cancel, @@ -305,6 +306,7 @@ pub(crate) enum Effect { OpenDiff(ChangePane, usize), OpenCommitDiff(ObjectId), Reword(ObjectId), + NewCommit(Option), TimeTravel(ObjectId), VerifySignatures(Vec), Quit, @@ -373,6 +375,8 @@ pub(crate) struct App { pending_initial_selection: Option, worktree_head: Option, worktree_head_has_descendants: bool, + worktree_head_unborn: bool, + known_descendants: HashSet, select_top_after_refresh: bool, pub(crate) signature_failures: usize, signature_verification_running: bool, @@ -444,6 +448,8 @@ impl App { pending_initial_selection: None, worktree_head: None, worktree_head_has_descendants: false, + worktree_head_unborn: false, + known_descendants: HashSet::new(), select_top_after_refresh: false, signature_failures: 0, signature_verification_running: false, @@ -465,6 +471,15 @@ impl App { self.update_worktree_head_descendants(); } + pub(crate) fn set_worktree_head_unborn(&mut self, unborn: bool) { + self.worktree_head_unborn = unborn; + } + + pub(crate) fn set_known_descendants(&mut self, ids: HashSet) { + self.known_descendants = ids; + self.update_worktree_head_descendants(); + } + pub(crate) fn worktree_head_has_descendants(&self, id: ObjectId) -> bool { self.worktree_head == Some(id) && self.worktree_head_has_descendants } @@ -642,7 +657,10 @@ impl App { ) { self.history_display_expanded = false; } - if !matches!(&action, Action::ToggleEdit | Action::Reword | Action::TimeTravel) { + if !matches!( + &action, + Action::ToggleEdit | Action::Reword | Action::NewCommit | Action::TimeTravel + ) { self.edit_expanded = false; } match action { @@ -804,6 +822,11 @@ impl App { self.rows[self.selected.expect("reword requires a selection")].id, )]; } + Action::NewCommit if self.can_create_commit() => { + return vec![Effect::NewCommit( + self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id), + )]; + } Action::TimeTravel if self.time_travel_shortcut_visible() => { return vec![Effect::TimeTravel( self.rows[self.selected.expect("time-travel requires a selection")].id, @@ -1258,11 +1281,11 @@ impl App { } fn update_worktree_head_descendants(&mut self) { - self.worktree_head_has_descendants = self.worktree_head.is_some_and(|head| { - self.rows - .iter() - .any(|row| row.id != head && row.parent_ids.contains(&head)) - }); + self.worktree_head_has_descendants = self.worktree_head.is_some_and(|head| self.has_known_descendant(head)); + } + + fn has_known_descendant(&self, id: ObjectId) -> bool { + self.known_descendants.contains(&id) || self.rows.iter().any(|row| row.parent_ids.contains(&id)) } pub(crate) fn can_reword(&self) -> bool { @@ -1272,8 +1295,21 @@ impl App { pub(crate) fn reword_shortcut_visible(&self) -> bool { self.changes_focus.is_none() && self.deferred_history_state.unwrap_or(self.state) == State::Complete - && self.selected.is_some() - && self.selected == self.first_selectable() + && self + .selected + .and_then(|index| self.rows.get(index)) + .is_some_and(|row| !self.has_known_descendant(row.id)) + } + + pub(crate) fn can_create_commit(&self) -> bool { + self.state == State::Complete + && self.worktree_changes_available + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && match self.selected.and_then(|index| self.rows.get(index)) { + Some(row) => !self.has_known_descendant(row.id), + None => self.worktree_head_unborn, + } } pub(crate) fn time_travel_shortcut_visible(&self) -> bool { @@ -1931,7 +1967,7 @@ mod tests { } #[test] - fn only_the_newest_completed_history_row_can_be_reworded() { + fn only_a_completed_history_row_without_descendants_can_be_reworded() { let mut app = App::new(10); app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); assert!(!app.can_reword(), "loading history cannot be reworded"); @@ -1946,6 +1982,31 @@ mod tests { assert!(app.update(Action::Reword).is_empty()); } + #[test] + fn editing_requires_no_known_descendants_and_new_commits_support_unborn_head() { + let mut app = App::new(10); + app.extend_commits(vec![row(2)]); + complete(&mut app); + app.set_known_descendants(HashSet::from([id(2)])); + assert!( + !app.can_reword(), + "a descendant outside the visible projection still prevents rewording" + ); + assert!( + !app.can_create_commit(), + "a descendant outside the visible projection still prevents a child" + ); + + let mut unborn = App::new(10); + unborn.set_worktree_head_unborn(true); + complete(&mut unborn); + assert!( + unborn.can_create_commit(), + "an unborn worktree can create its root commit" + ); + assert_eq!(unborn.update(Action::NewCommit), vec![Effect::NewCommit(None)]); + } + #[test] fn time_travel_requires_completed_history_and_a_worktree() { let mut app = App::new(10); @@ -2454,6 +2515,7 @@ mod tests { app.update(Action::ToggleEdit); assert!(app.edit_expanded); app.update(Action::Reword); + app.update(Action::NewCommit); assert!(app.edit_expanded, "grouped edit commands keep the group open"); app.update(Action::MoveDown); diff --git a/gix-tix/src/edit/create.rs b/gix-tix/src/edit/create.rs new file mode 100644 index 00000000000..ae92bc0a9ac --- /dev/null +++ b/gix-tix/src/edit/create.rs @@ -0,0 +1,565 @@ +use std::{ffi::OsString, path::Path}; + +use anyhow::{Context, Result}; +use gix::{ObjectId, bstr::ByteSlice, objs::Write}; + +use crate::{ + ChangeGroup, ChangeKind, ComparedParent, add_line_counts, load_tree_changes_without_lines, + load_worktree_changes_without_lines, ui, +}; + +use super::{refs::MutableRefs, reword, time_travel}; + +pub(crate) struct Prepared { + pub editor: OsString, + pub document: Vec, + parent: Option, + tree: ObjectId, + references: MutableRefs, + checkout: Checkout, + objects: gix::odb::memory::Storage, +} + +enum Checkout { + None, + Branch(gix::refs::FullName), + Detached, +} + +pub(crate) fn prepare(mut repo: gix::Repository, parent: Option) -> Result { + repo.workdir().context("creating a commit requires a worktree")?; + let head = repo.head().context("could not read HEAD before creating a commit")?; + let head_id = head.id().map(gix::Id::detach); + if parent.is_none() && !head.is_unborn() { + anyhow::bail!("an unborn history is required to create a root commit"); + } + if let Some(parent) = parent { + repo.find_commit(parent) + .context("could not find the selected parent commit")?; + } + let (references, checkout) = match parent { + Some(parent) => { + let references = MutableRefs::pointing_to(&repo, parent)?; + if references.is_empty() { + anyhow::bail!("no mutable reference points to the selected parent"); + } + references.ensure_not_checked_out_elsewhere(&repo)?; + let checkout = if head_id == Some(parent) { + match head.referent_name() { + Some(name) => Checkout::Branch(name.to_owned()), + None => Checkout::Detached, + } + } else { + Checkout::None + }; + (references, checkout) + } + None => { + let name = head + .referent_name() + .context("an unborn HEAD must point to a branch")? + .to_owned(); + (MutableRefs::unborn(&repo)?, Checkout::Branch(name)) + } + }; + let editor = repo.editor().context("no Git editor is available")?; + let author = repo + .author() + .context("no Git author is configured")? + .context("could not resolve the Git author")? + .to_owned() + .context("could not own the Git author")?; + let committer = repo + .committer() + .context("no Git committer is configured")? + .context("could not resolve the Git committer")? + .to_owned() + .context("could not own the Git committer")?; + repo.commit_signing_options_if_enabled() + .context("could not resolve commit signing configuration")?; + + repo = repo.with_object_memory(); + let baseline = match parent { + Some(id) => repo + .find_commit(id) + .context("could not find the parent commit")? + .tree() + .context("could not load the parent tree")?, + None => repo.empty_tree(), + }; + let index = repo.index_or_empty().context("could not load the index")?; + if index + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted) + { + anyhow::bail!("cannot create a commit with unresolved index conflicts"); + } + let mut index_editor = repo.empty_tree().edit().context("could not prepare the index tree")?; + for entry in index.entries() { + let mode = entry + .mode + .to_tree_entry_mode() + .context("an index entry has an invalid mode")?; + index_editor + .upsert(entry.path(&index), mode.kind(), entry.id) + .context("could not add an index entry to the candidate tree")?; + } + let index_tree = index_editor + .write() + .context("could not build the candidate index tree")? + .detach(); + let based_on_parent = head_id == parent; + let tree = if based_on_parent && index_tree != baseline.id { + index_tree + } else if based_on_parent { + worktree_tree(&repo, &baseline)? + } else { + baseline.id + }; + + let new_tree = repo.find_tree(tree).context("could not load the candidate tree")?; + let mut changes = load_tree_changes_without_lines( + &repo, + parent.map(|_| &baseline), + &new_tree, + parent.map(|id| ComparedParent { index: 0, total: 1, id }), + )?; + let line_counts = add_line_counts(&repo, &mut changes)?; + let mut document = Vec::new(); + reword::write_headers(&mut document, &author, &committer)?; + document.extend_from_slice(b"\nwhat\n\nwhy\n"); + for trailer in reword::missing_agent_trailers(b"what\n\nwhy\n").into_iter().flatten() { + document.extend_from_slice(b"\n;"); + document.extend_from_slice(trailer); + } + document.extend_from_slice(b"\n\n; Changes to be committed:\n"); + for line in ui::commit_diff_summary(&changes, &line_counts, changes.lines_added, changes.lines_removed) { + document.extend_from_slice(b"; "); + for span in line.spans { + document.extend_from_slice(span.content.as_bytes()); + } + document.push(b'\n'); + } + drop(new_tree); + drop(baseline); + drop(index); + + let provisional = repo + .new_commit("what\n\nwhy\n", tree, parent) + .context("could not prepare the commit object")? + .id; + let mut objects = repo + .objects + .take_object_memory() + .context("candidate object memory was unavailable")?; + objects.remove(&provisional); + Ok(Prepared { + editor, + document, + parent, + tree, + references, + checkout, + objects, + }) +} + +fn worktree_tree(repo: &gix::Repository, baseline: &gix::Tree<'_>) -> Result { + let changes = load_worktree_changes_without_lines(repo)?; + if changes.paths.is_empty() { + return Ok(baseline.id); + } + let (mut pipeline, index) = repo + .filter_pipeline(None) + .context("could not initialize worktree filters")?; + let mut editor = baseline.edit().context("could not edit the parent tree")?; + for change in changes + .paths + .iter() + .filter(|change| change.group == ChangeGroup::Unstaged) + { + if let Some(source) = &change.source { + editor + .remove(source) + .context("could not remove a renamed source path")?; + } + if change.kind == ChangeKind::Deleted { + editor + .remove(&change.path) + .context("could not remove a deleted worktree path")?; + continue; + } + match pipeline + .worktree_file_to_object(change.path.as_bstr(), &index) + .with_context(|| format!("could not prepare {}", change.path.to_str_lossy()))? + { + Some((id, kind, _)) => { + editor + .upsert(&change.path, kind, id) + .context("could not add a worktree path to the candidate tree")?; + } + None => { + editor + .remove(&change.path) + .context("could not remove an unavailable worktree path")?; + } + } + } + Ok(editor.write().context("could not build the worktree tree")?.detach()) +} + +pub(crate) fn apply(mut repo: gix::Repository, mut prepared: Prepared, edited: &[u8]) -> Result { + let edit = reword::parse(edited)?; + if edit.message.is_empty() { + anyhow::bail!("the edited commit message is empty"); + } + prepared.references.validate(&repo)?; + let references = match prepared.parent { + Some(parent) => MutableRefs::pointing_to(&repo, parent)?, + None => MutableRefs::unborn(&repo)?, + }; + if references.is_empty() { + anyhow::bail!("no mutable reference points to the selected parent anymore"); + } + references.ensure_not_checked_out_elsewhere(&repo)?; + let signing = repo + .commit_signing_options_if_enabled() + .context("could not resolve commit signing configuration")?; + repo.objects.set_object_memory(std::mem::take(&mut prepared.objects)); + let mut commit = gix::objs::Commit { + message: edit.message, + tree: prepared.tree, + author: reword::actor(edit.author, edit.author_time, "author")?, + committer: reword::actor(edit.committer, edit.committer_time, "committer")?, + encoding: None, + parents: prepared.parent.into_iter().collect(), + extra_headers: Vec::new(), + }; + if let Some(options) = signing { + commit = commit.sign(options).context("could not sign the new commit")?; + } + let new_id = repo + .write_object(&commit) + .context("could not prepare the final commit")? + .detach(); + let objects = repo + .objects + .take_object_memory() + .context("candidate object memory was unavailable")?; + for (id, (kind, data)) in objects.iter() { + repo.write_buf_with_known_id(*kind, data, *id) + .map_err(|err| anyhow::anyhow!("could not persist a prepared commit object: {err}"))?; + } + let log_message = gix::reference::log::message("commit", commit.message.as_bstr(), commit.parents.len()); + let mut time_buf = gix::date::parse::TimeBuf::default(); + references.update( + &repo, + new_id, + log_message.as_bstr(), + Some(commit.committer.to_ref(&mut time_buf)), + )?; + let checkout = match &prepared.checkout { + Checkout::None => Ok(()), + Checkout::Branch(name) => { + let workdir = repo.workdir().context("creating a commit requires a worktree")?; + let branch = name + .as_bstr() + .strip_prefix(b"refs/heads/") + .context("the destination isn't a local branch")?; + time_travel::checkout( + workdir, + [ + OsString::from("--no-guess"), + gix::path::from_bstr(branch.as_bstr()).into_owned().into_os_string(), + ], + ) + .and_then(|()| reset_index(workdir, new_id)) + } + Checkout::Detached => { + let workdir = repo.workdir().context("creating a commit requires a worktree")?; + time_travel::checkout_detached(workdir, new_id) + } + }; + if let Err(err) = checkout { + let rollback = references.rollback(&repo, new_id); + return match rollback { + Ok(()) => Err(err), + Err(rollback) => Err(err.context(format!("the destination could not be rolled back: {rollback:#}"))), + }; + } + Ok(new_id) +} + +fn reset_index(workdir: &Path, id: ObjectId) -> Result<()> { + let output = std::process::Command::new("git") + .arg("-C") + .arg(workdir) + .args(["reset", "--mixed", "--quiet"]) + .arg(id.to_string()) + .output() + .context("could not update the index after checkout")?; + if output.status.success() { + Ok(()) + } else { + anyhow::bail!( + "git reset failed with {}: {}", + output.status, + output.stderr.to_str_lossy().trim() + ) + } +} + +#[cfg(test)] +mod tests { + use std::process::Command; + + use super::*; + + fn open(path: &Path) -> gix_testtools::Result { + Ok(gix::open_opts( + path, + gix::open::Options::isolated().config_overrides([ + "core.editor=:".to_owned(), + "user.name=author".to_owned(), + "user.email=author@example.com".to_owned(), + ]), + )?) + } + + fn object_count(path: &Path) -> gix_testtools::Result> { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(["count-objects", "-v"]) + .output()?; + if !output.status.success() { + return Err(format!("git count-objects failed: {}", output.stderr.to_str_lossy()).into()); + } + Ok(output.stdout) + } + + #[test] + fn preparation_is_unobservable_and_staged_changes_win() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let parent = open(fixture.path())?.head_id()?.detach(); + for name in ["refs/patches/create", "refs/tags/keep", "refs/remotes/origin/keep"] { + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["update-ref", name, &parent.to_string()]) + .status()? + .success(), + "the test reference is created" + ); + } + let before = gix_testtools::repository::snapshot(fixture.path())?; + let objects_before = object_count(fixture.path())?; + let prepared = prepare(open(fixture.path())?, Some(parent))?; + assert!( + prepared + .document + .windows(b"tracked".len()) + .any(|window| window == b"tracked"), + "the editor buffer includes a commented per-file diffstat: {}", + prepared.document.as_bstr() + ); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "preparing the tree and commit leaves the complete repository state unchanged" + ); + assert_eq!( + object_count(fixture.path())?, + objects_before, + "preparation writes no loose objects" + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["update-ref", "refs/patches/late", &parent.to_string()]) + .status()? + .success(), + "a ref may appear while the editor is open" + ); + + let edited = prepared.document.replacen(b"what\n\nwhy", b"title\n\nbody", 1); + let new_id = apply(open(fixture.path())?, prepared, &edited)?; + let after = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!( + after.head, + gix_testtools::repository::Head::Symbolic { + name: b"refs/heads/main".into(), + id: new_id, + }, + "the checked-out branch advances to the new commit" + ); + let repository = open(fixture.path())?; + let commit = repository.find_commit(new_id)?; + assert_eq!(commit.parent_ids().next().map(gix::Id::detach), Some(parent)); + assert_eq!(commit.message_raw()?, b"title\n\nbody\n".as_bstr()); + assert_eq!( + Some(commit.tree_id()?.detach()), + before.index_tree, + "a changed index supplies the commit tree even when the worktree differs" + ); + assert_eq!(after.index_tree, before.index_tree, "the committed index stays intact"); + assert_eq!( + after.worktree, before.worktree, + "unstaged and untracked files stay intact" + ); + assert_eq!( + after.commits.len(), + before.commits.len() + 1, + "exactly one reachable commit is added" + ); + for name in ["refs/heads/main", "refs/patches/create", "refs/patches/late"] { + assert_eq!( + repository.find_reference(name)?.id().detach(), + new_id, + "{name} advances to the new commit" + ); + } + for name in ["refs/tags/keep", "refs/remotes/origin/keep"] { + assert_eq!( + repository.find_reference(name)?.id().detach(), + parent, + "{name} is not edited" + ); + } + Ok(()) + } + + #[test] + fn worktree_changes_supply_the_tree_when_the_index_is_unchanged() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["reset", "-q", "HEAD"]) + .status()? + .success() + ); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let parent = open(fixture.path())?.head_id()?.detach(); + let prepared = prepare(open(fixture.path())?, Some(parent))?; + let edited = prepared.document.replacen(b"what\n\nwhy", b"worktree\n\nstate", 1); + let new_id = apply(open(fixture.path())?, prepared, &edited)?; + let after = gix_testtools::repository::snapshot(fixture.path())?; + let repository = open(fixture.path())?; + let commit = repository.find_commit(new_id)?; + assert_ne!( + Some(commit.tree_id()?.detach()), + before.index_tree, + "worktree changes produce a new tree" + ); + assert_eq!( + after.index_tree, + Some(commit.tree_id()?.detach()), + "checking out the commit updates the index to its worktree-derived tree" + ); + assert_eq!( + after.worktree, before.worktree, + "the committed worktree bytes remain exactly as prepared" + ); + Ok(()) + } + + #[test] + fn creates_an_empty_root_commit_for_an_unborn_head() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["init", "-q", "-b", "main"]) + .status()? + .success() + ); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let prepared = prepare(open(fixture.path())?, None)?; + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "root-commit preflight is unobservable" + ); + let edited = prepared.document.replacen(b"what\n\nwhy", b"root\n\nreason", 1); + let new_id = apply(open(fixture.path())?, prepared, &edited)?; + let repository = open(fixture.path())?; + let commit = repository.find_commit(new_id)?; + assert!(commit.parent_ids().next().is_none(), "the root has no parent"); + assert_eq!( + commit.tree_id()?.detach(), + ObjectId::empty_tree(repository.object_hash()), + "no index or worktree changes reuse the empty tree" + ); + assert_eq!( + repository.head_name()?.map(|name| name.as_bstr().to_owned()), + Some(b"refs/heads/main".into()), + "the unborn branch is created and remains checked out" + ); + Ok(()) + } + + #[test] + fn an_unrelated_worktree_head_is_not_checked_out_or_moved() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let parent = open(fixture.path())?.head_id()?.detach(); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["checkout", "-q", "--orphan", "other"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["rm", "-rf", "-q", "."]) + .status()? + .success() + ); + std::fs::write(fixture.path().join("other"), b"other\n")?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["add", "other"]) + .status()? + .success() + ); + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["-c", "commit.gpgSign=false", "commit", "-q", "-m", "other"]) + .status()? + .success() + ); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let prepared = prepare(open(fixture.path())?, Some(parent))?; + let edited = prepared.document.replacen(b"what\n\nwhy", b"child\n\nreason", 1); + let new_id = apply(open(fixture.path())?, prepared, &edited)?; + let after = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!( + after.head, before.head, + "the unrelated checked-out branch does not move" + ); + assert_eq!(after.index, before.index, "the unrelated index does not change"); + assert_eq!( + after.worktree, before.worktree, + "the unrelated worktree does not change" + ); + assert_eq!( + open(fixture.path())?.find_reference("refs/heads/main")?.id().detach(), + new_id, + "the selected parent branch advances independently" + ); + Ok(()) + } +} diff --git a/gix-tix/src/edit/mod.rs b/gix-tix/src/edit/mod.rs index 35c0f139810..216b294e7d9 100644 --- a/gix-tix/src/edit/mod.rs +++ b/gix-tix/src/edit/mod.rs @@ -2,6 +2,8 @@ use std::{ffi::OsStr, io::Write, process::Command}; use anyhow::{Context, Result}; +pub(crate) mod create; +pub(crate) mod refs; pub(crate) mod reword; pub(crate) mod time_travel; diff --git a/gix-tix/src/edit/refs.rs b/gix-tix/src/edit/refs.rs new file mode 100644 index 00000000000..6a3e7c616e9 --- /dev/null +++ b/gix-tix/src/edit/refs.rs @@ -0,0 +1,174 @@ +use anyhow::{Context, Result}; +use gix::bstr::{BStr, ByteSlice}; +use gix::refs::{ + Category, Target, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, +}; + +use crate::history; + +#[derive(Clone)] +pub(super) struct MutableRefs { + names: Vec, + old: Option, +} + +impl MutableRefs { + pub(super) fn pointing_to(repo: &gix::Repository, id: gix::ObjectId) -> Result { + let mut names = Vec::new(); + for reference in repo.references()?.all()? { + let reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => anyhow::bail!("could not inspect references pointing to commit: {err}"), + }; + if !matches!( + reference.name().category(), + Some(Category::Tag | Category::RemoteBranch) + ) && reference.try_id().is_some_and(|target| target.as_ref() == id) + { + names.push(reference.name().to_owned()); + } + } + if let Some(head) = repo.try_find_reference("HEAD")? + && head.try_id().is_some_and(|target| target.as_ref() == id) + { + names.push(head.name().to_owned()); + } + Ok(Self { names, old: Some(id) }) + } + + pub(super) fn unborn(repo: &gix::Repository) -> Result { + let head = repo.head().context("could not read unborn HEAD")?; + if !head.is_unborn() { + anyhow::bail!("an unborn HEAD is required"); + } + let name = head + .referent_name() + .context("an unborn HEAD must point to a branch")? + .to_owned(); + Ok(Self { + names: vec![name], + old: None, + }) + } + + pub(super) fn is_empty(&self) -> bool { + self.names.is_empty() + } + + pub(super) fn contains(&self, name: &gix::refs::FullNameRef) -> bool { + self.names.iter().any(|candidate| candidate.as_ref() == name) + } + + pub(super) fn validate(&self, repo: &gix::Repository) -> Result<()> { + for name in &self.names { + let actual = repo + .try_find_reference(name)? + .and_then(|reference| reference.try_id().map(gix::Id::detach)); + if actual != self.old { + anyhow::bail!("a reference changed while editing"); + } + } + Ok(()) + } + + pub(super) fn ensure_not_checked_out_elsewhere(&self, repo: &gix::Repository) -> Result<()> { + if history::worktree_checkouts(repo).iter().any(|checkout| { + !checkout.is_current + && checkout + .reference + .as_ref() + .is_some_and(|name| self.contains(name.as_ref())) + }) { + anyhow::bail!("an affected branch is checked out in another worktree"); + } + Ok(()) + } + + pub(super) fn update( + &self, + repo: &gix::Repository, + new: gix::ObjectId, + message: &BStr, + committer: Option>, + ) -> Result<()> { + repo.edit_references_as( + self.names.iter().cloned().map(|name| RefEdit { + change: Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: message.to_owned(), + }, + expected: self.old.map_or(PreviousValue::MustNotExist, |old| { + PreviousValue::MustExistAndMatch(Target::Object(old)) + }), + new: Target::Object(new), + }, + name, + deref: false, + }), + committer, + ) + .context("could not update references")?; + Ok(()) + } + + pub(super) fn delete( + &self, + repo: &gix::Repository, + _message: &BStr, + committer: Option>, + ) -> Result<()> { + let old = self.old.context("cannot delete an unborn reference")?; + repo.edit_references_as( + self.names.iter().cloned().map(|name| RefEdit { + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(Target::Object(old)), + log: RefLog::AndReference, + }, + name, + deref: false, + }), + committer, + ) + .context("could not delete references")?; + Ok(()) + } + + pub(super) fn rollback(&self, repo: &gix::Repository, current: gix::ObjectId) -> Result<()> { + let current_refs = Self { + names: self.names.clone(), + old: Some(current), + }; + let committer = repo.committer().transpose()?; + match self.old { + Some(old) => current_refs.update(repo, old, b"tix edit rollback".as_bstr(), committer), + None => current_refs.delete(repo, b"tix edit rollback".as_bstr(), committer), + } + } + + pub(super) fn rollback_deleted(&self, repo: &gix::Repository) -> Result<()> { + let old = self.old.context("an unborn reference was not deleted")?; + let deleted = Self { + names: self.names.clone(), + old: None, + }; + let committer = repo.committer().transpose()?; + deleted.update(repo, old, b"tix edit rollback".as_bstr(), committer) + } +} + +fn is_missing_ref(mut err: &(dyn std::error::Error + 'static)) -> bool { + loop { + if err + .downcast_ref::() + .is_some_and(|err| err.kind() == std::io::ErrorKind::NotFound) + { + return true; + } + let Some(source) = err.source() else { return false }; + err = source; + } +} diff --git a/gix-tix/src/edit/reword.rs b/gix-tix/src/edit/reword.rs index a48d00754b1..a5fb0c98e77 100644 --- a/gix-tix/src/edit/reword.rs +++ b/gix-tix/src/edit/reword.rs @@ -1,27 +1,23 @@ use anyhow::{Context, Result}; -use gix::{ - bstr::{BString, ByteSlice}, - refs::{ - Category, Target, - transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, - }, -}; +use gix::bstr::{BString, ByteSlice}; + +use super::refs::MutableRefs; const AUTHOR: &[u8] = b"Author: "; const AUTHOR_DATE: &[u8] = b"AuthorDate: "; const COMMITTER: &[u8] = b"Committer: "; const COMMITTER_DATE: &[u8] = b"CommitterDate: "; const COMMENT_CHAR: &[u8] = b"CommentChar: "; -const DEFAULT_COMMENT_CHAR: &[u8] = b";"; -const ASSISTED_BY: &[u8] = b"Assisted-by: GPT 5.6"; -const CO_AUTHORED_BY: &[u8] = b"Co-authored-by: GPT 5.6 "; +pub(super) const DEFAULT_COMMENT_CHAR: &[u8] = b";"; +pub(super) const ASSISTED_BY: &[u8] = b"Assisted-by: GPT 5.6"; +pub(super) const CO_AUTHORED_BY: &[u8] = b"Co-authored-by: GPT 5.6 "; -struct Edit<'a> { - author: &'a [u8], - author_time: gix::date::Time, - committer: &'a [u8], - committer_time: gix::date::Time, - message: BString, +pub(super) struct Edit<'a> { + pub author: &'a [u8], + pub author_time: gix::date::Time, + pub committer: &'a [u8], + pub committer_time: gix::date::Time, + pub message: BString, } pub(crate) fn document(repo: &gix::Repository, id: gix::ObjectId) -> Result<(std::ffi::OsString, Vec)> { @@ -36,13 +32,7 @@ pub(crate) fn document(repo: &gix::Repository, id: gix::ObjectId) -> Result<(std commit.committer.time = gix::date::Time::now_local_or_utc(); let mut out = Vec::new(); - write_actor(&mut out, AUTHOR, &commit.author); - write_date(&mut out, AUTHOR_DATE, commit.author.time)?; - write_actor(&mut out, COMMITTER, &commit.committer); - write_date(&mut out, COMMITTER_DATE, commit.committer.time)?; - out.extend_from_slice(COMMENT_CHAR); - out.extend_from_slice(DEFAULT_COMMENT_CHAR); - out.push(b'\n'); + write_headers(&mut out, &commit.author, &commit.committer)?; out.push(b'\n'); out.extend_from_slice(&commit.message); if !out.ends_with(b"\n") { @@ -62,7 +52,7 @@ pub(crate) fn document(repo: &gix::Repository, id: gix::ObjectId) -> Result<(std Ok((editor, out)) } -fn missing_agent_trailers(message: &[u8]) -> [Option<&'static [u8]>; 2] { +pub(super) fn missing_agent_trailers(message: &[u8]) -> [Option<&'static [u8]>; 2] { let mut has_assisted_by = false; let mut has_co_authored_by = false; if let Some(body) = gix::objs::commit::MessageRef::from_bytes(message).body() { @@ -83,10 +73,11 @@ pub(crate) fn apply(repo: &gix::Repository, old_id: gix::ObjectId, edited: &[u8] anyhow::bail!("the edited commit message is empty"); } - let refs = matching_references(repo, old_id)?; + let refs = MutableRefs::pointing_to(repo, old_id)?; if refs.is_empty() { anyhow::bail!("no mutable reference points to the commit anymore"); } + refs.ensure_not_checked_out_elsewhere(repo)?; let mut commit = repo .find_commit(old_id) @@ -117,25 +108,32 @@ pub(crate) fn apply(repo: &gix::Repository, old_id: gix::ObjectId, edited: &[u8] } let log_message = gix::reference::log::message("commit", commit.message.as_bstr(), commit.parents.len()); - let edits = refs.into_iter().map(|name| RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: log_message.clone(), - }, - expected: PreviousValue::MustExistAndMatch(Target::Object(old_id)), - new: Target::Object(new_id), - }, - name, - deref: false, - }); let mut time_buf = gix::date::parse::TimeBuf::default(); - repo.edit_references_as(edits, Some(commit.committer.to_ref(&mut time_buf))) - .context("could not update references to the reworded commit")?; + refs.update( + repo, + new_id, + log_message.as_ref(), + Some(commit.committer.to_ref(&mut time_buf)), + ) + .context("could not update references to the reworded commit")?; Ok(Some(new_id)) } +pub(super) fn write_headers( + out: &mut Vec, + author: &gix::actor::Signature, + committer: &gix::actor::Signature, +) -> Result<()> { + write_actor(out, AUTHOR, author); + write_date(out, AUTHOR_DATE, author.time)?; + write_actor(out, COMMITTER, committer); + write_date(out, COMMITTER_DATE, committer.time)?; + out.extend_from_slice(COMMENT_CHAR); + out.extend_from_slice(DEFAULT_COMMENT_CHAR); + out.push(b'\n'); + Ok(()) +} + fn write_actor(out: &mut Vec, label: &[u8], actor: &gix::actor::Signature) { out.extend_from_slice(label); out.extend_from_slice(&actor.name); @@ -155,7 +153,7 @@ fn write_date(out: &mut Vec, label: &[u8], time: gix::date::Time) -> Result< Ok(()) } -fn parse(input: &[u8]) -> Result> { +pub(super) fn parse(input: &[u8]) -> Result> { let mut parts = input.splitn(7, |byte| *byte == b'\n'); let author = header(parts.next(), AUTHOR)?; let author_time = date(header(parts.next(), AUTHOR_DATE)?, "author")?; @@ -222,7 +220,7 @@ fn date(value: &[u8], field: &str) -> Result { .with_context(|| format!("could not parse {field} date")) } -fn actor(value: &[u8], time: gix::date::Time, field: &str) -> Result { +pub(super) fn actor(value: &[u8], time: gix::date::Time, field: &str) -> Result { let parsed = gix::actor::SignatureRef::from_bytes(value) .with_context(|| format!("could not parse {field} identity"))? .trim(); @@ -236,43 +234,6 @@ fn actor(value: &[u8], time: gix::date::Time, field: &str) -> Result Result> { - let mut out = Vec::new(); - for reference in repo.references()?.all()? { - let reference = match reference { - Ok(reference) => reference, - Err(err) if is_missing_ref(&*err) => continue, - Err(err) => anyhow::bail!("could not inspect references pointing to commit: {err}"), - }; - if !matches!( - reference.name().category(), - Some(Category::Tag | Category::RemoteBranch) - ) && reference.try_id().is_some_and(|target| target.as_ref() == id) - { - out.push(reference.name().to_owned()); - } - } - if let Some(head) = repo.try_find_reference("HEAD")? - && head.try_id().is_some_and(|target| target.as_ref() == id) - { - out.push(head.name().to_owned()); - } - Ok(out) -} - -fn is_missing_ref(mut err: &(dyn std::error::Error + 'static)) -> bool { - loop { - if err - .downcast_ref::() - .is_some_and(|err| err.kind() == std::io::ErrorKind::NotFound) - { - return true; - } - let Some(source) = err.source() else { return false }; - err = source; - } -} - #[cfg(test)] mod tests { use std::process::Command; diff --git a/gix-tix/src/edit/time_travel.rs b/gix-tix/src/edit/time_travel.rs index a88945c33f8..4ee0e5ee4d1 100644 --- a/gix-tix/src/edit/time_travel.rs +++ b/gix-tix/src/edit/time_travel.rs @@ -194,14 +194,14 @@ fn checkout_pin(workdir: &Path, pin: &history::Pin) -> Result<()> { } } -fn checkout_detached(workdir: &Path, id: ObjectId) -> Result<()> { +pub(super) fn checkout_detached(workdir: &Path, id: ObjectId) -> Result<()> { checkout( workdir, [OsString::from("--detach"), OsString::from(id.to_hex().to_string())], ) } -fn checkout(workdir: &Path, args: impl IntoIterator) -> Result<()> { +pub(super) fn checkout(workdir: &Path, args: impl IntoIterator) -> Result<()> { let output = Command::new("git") .arg("-C") .arg(workdir) diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index 477a618df13..2aeb3440df0 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -190,6 +190,10 @@ impl HistoryGraph { false } + pub(crate) fn commits_with_descendants(&self) -> HashSet { + self.parents.iter().map(|parent| self.id(*parent)).collect() + } + fn parent_ids(&self, index: CommitIndex) -> gix::traverse::commit::ParentIds { self.parents(index).iter().map(|parent| self.id(*parent)).collect() } diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 2fcf66d6840..f3fea1a50c7 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -854,11 +854,12 @@ fn event_loop( let common_dir = normalize_common_dir(repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()))?; let (mut view_repository, recovered_at_startup) = open_history_repository(&mut repository_path, &common_dir)?; view_repository.object_cache_size(None); - let (mut repository_is_bare, mut mailmap, mut ref_snapshot) = { + let (mut repository_is_bare, mut mailmap, mut ref_snapshot, mut worktree_head_unborn) = { let bare = view_repository.workdir().is_none(); let mailmap = view_repository.open_mailmap(); let refs = history::snapshot(&view_repository, &revisions, &hide, worktrees)?; - (bare, mailmap, refs) + let unborn = !bare && view_repository.head()?.is_unborn(); + (bare, mailmap, refs, unborn) }; if recovered_at_startup { repository = view_repository.into_sync(); @@ -886,6 +887,7 @@ fn event_loop( ); let mut app = App::new(1); + app.set_worktree_head_unborn(worktree_head_unborn); app.commit_pane_background = commit_pane_background; if recovered_at_startup { app.notice = Some("worktree removed; using the common repository without worktree changes".into()); @@ -1191,6 +1193,7 @@ fn event_loop( if let Some(result) = refresh_receiver.as_ref().map(mpsc::Receiver::try_recv) { match result { Ok((graph, result)) => { + app.set_known_descendants(graph.commits_with_descendants()); history_graph = Some(graph); let result = result?; tracing::info!(commit_count = result.commits.rows.len(), "history refresh completed"); @@ -1203,6 +1206,11 @@ fn event_loop( .flatten(), false, ); + worktree_head_unborn = !repository_is_bare + && open_repository(&repository_path, false, false) + .and_then(|repo| Ok(repo.head()?.is_unborn())) + .unwrap_or(false); + app.set_worktree_head_unborn(worktree_head_unborn); decorations = result.decorations; selection_relation = None; app.selection_relation = None; @@ -1386,6 +1394,7 @@ fn event_loop( } Event::Complete(graph) => { history_finished = true; + app.set_known_descendants(graph.commits_with_descendants()); history_graph = Some(graph); selection_relation = None; app.selection_relation = None; @@ -1690,6 +1699,23 @@ fn event_loop( Err(err) => app.notice = Some(format!("reword: {err:#}")), } } + Effect::NewCommit(parent) => { + match create_commit( + terminal, + &repository_path, + repository_is_bare, + parent, + enhanced_keyboard, + ) { + Ok(Some(new_id)) => { + app.notice = Some(format!("created {}", new_id.to_hex_with_len(7))); + refresh_select_top_requested = true; + refresh_pending = true; + } + Ok(None) => {} + Err(err) => app.notice = Some(format!("new commit: {err:#}")), + } + } Effect::TimeTravel(id) => { fill_repository.retain = false; fill_repository.retained = None; @@ -2679,6 +2705,33 @@ fn reword_commit( edit::reword::apply(&repository, id, &edited) } +fn create_commit( + terminal: &mut ratatui::DefaultTerminal, + repository_path: &Path, + bare: bool, + parent: Option, + enhanced_keyboard: bool, +) -> Result> { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository before creating commit")?; + repository.object_cache_size(None); + let prepared = edit::create::prepare(repository, parent)?; + let Some(edited) = edit::edit_document( + terminal, + &prepared.editor, + &prepared.document, + &format!("tix-commit-{}.md", std::process::id()), + enhanced_keyboard, + )? + else { + return Ok(None); + }; + let mut repository = + open_repository(repository_path, bare, false).context("could not reopen repository after editing commit")?; + repository.object_cache_size(None); + edit::create::apply(repository, prepared, &edited).map(Some) +} + fn run_external_diff( terminal: &mut ratatui::DefaultTerminal, mut command: gix::diff::blob::platform::prepare_diff_command::Command, @@ -2883,15 +2936,29 @@ fn load_changes_without_lines( ), None => None, }; - let changes = repository - .diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) - .context("could not diff commit trees")?; - let mut out = Changes { - parent: (parents.len() > 1).then(|| ComparedParent { + load_tree_changes_without_lines( + repository, + old_tree.as_ref(), + &new_tree, + (parents.len() > 1).then(|| ComparedParent { index: parent_index, total: parents.len(), id: parent.expect("a merge has parents").detach(), }), + ) +} + +fn load_tree_changes_without_lines( + repository: &gix::Repository, + old_tree: Option<&gix::Tree<'_>>, + new_tree: &gix::Tree<'_>, + parent: Option, +) -> Result { + let changes = repository + .diff_tree_to_tree(old_tree, Some(new_tree), None) + .context("could not diff commit trees")?; + let mut out = Changes { + parent, ..Changes::default() }; for change in changes { @@ -2947,6 +3014,24 @@ fn load_changes_without_lines( Ok(out) } +fn add_line_counts(repository: &gix::Repository, changes: &mut Changes) -> Result> { + let mut cache = repository + .diff_resource_cache_for_tree_diff() + .context("could not initialize commit diff summary")?; + let mut counts = Vec::with_capacity(changes.diffs.len()); + for (path, change) in changes.paths.iter_mut().zip(&changes.diffs) { + let lines = line_counts_for_change(repository, change, &mut cache, None)?; + path.lines = lines; + if let Some((added, removed)) = lines { + changes.lines_added += u64::from(added); + changes.lines_removed += u64::from(removed); + } + counts.push(lines); + cache.clear_resource_cache_keep_allocation(); + } + Ok(counts) +} + fn entry_mode(mode: gix::index::entry::Mode) -> Result { mode.to_tree_entry_mode() .context("status entry cannot be represented in a tree") @@ -3221,7 +3306,7 @@ fn unstaged_change( ))) } -fn load_worktree_changes(repository: &gix::Repository, line_diff_pool: &mut LineDiffPool) -> Result { +fn load_worktree_changes_without_lines(repository: &gix::Repository) -> Result { let mut status = repository .status(gix::progress::Discard) .context("could not initialize worktree status")? @@ -3249,10 +3334,16 @@ fn load_worktree_changes(repository: &gix::Repository, line_diff_pool: &mut Line staged.extend(unstaged); let (paths, diffs): (Vec<_>, Vec<_>) = staged.into_iter().unzip(); - let mut out = Changes { + Ok(Changes { paths, + diffs, ..Changes::default() - }; + }) +} + +fn load_worktree_changes(repository: &gix::Repository, line_diff_pool: &mut LineDiffPool) -> Result { + let mut out = load_worktree_changes_without_lines(repository)?; + let diffs = std::mem::take(&mut out.diffs); for (path, (change, lines)) in out.paths.iter_mut().zip(line_diff_pool.line_counts(diffs)?) { path.lines = lines; if let Some((insertions, removals)) = lines { @@ -3351,6 +3442,7 @@ fn action_with_shortcut_groups(key: KeyEvent, history_display_expanded: bool, ed KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Action::Refresh), KeyCode::Char('r') if history_display_expanded => Some(Action::ToggleRefs), KeyCode::Char('r') if edit_expanded => Some(Action::Reword), + KeyCode::Char('n') if edit_expanded => Some(Action::NewCommit), KeyCode::Char('t') if edit_expanded => Some(Action::TimeTravel), KeyCode::Char('s') => Some(Action::VerifySignatures), KeyCode::Char('v') => Some(Action::ToggleHistoryDisplay), @@ -4220,7 +4312,11 @@ mod tests { Some(Action::ToggleHistoryDisplay), "v closes the view shortcut group" ); - for (key, expected) in [('r', Action::Reword), ('t', Action::TimeTravel)] { + for (key, expected) in [ + ('r', Action::Reword), + ('n', Action::NewCommit), + ('t', Action::TimeTravel), + ] { assert_eq!( action_with_shortcut_groups(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE), false, true), Some(expected), diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 1be49c3c737..7b6b793b71c 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -676,6 +676,9 @@ pub(crate) fn draw_with_worktree( if app.reword_shortcut_visible() { footer_spans.push(Span::raw(" · r reword")); } + if app.can_create_commit() { + footer_spans.push(Span::raw(" · n new")); + } } else if !app.history_display_expanded { footer_spans.push(Span::raw(" · e edit")); } diff --git a/gix-tix/tests/fixtures/create_commit.sh b/gix-tix/tests/fixtures/create_commit.sh new file mode 100644 index 00000000000..320bcd3fc33 --- /dev/null +++ b/gix-tix/tests/fixtures/create_commit.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +# A staged version plus a different unstaged version makes the staged-wins rule observable. +git init -q -b main . +git config user.name author +git config user.email author@example.com +printf 'base\n' >tracked +git add tracked +GIT_AUTHOR_DATE='2000-01-01T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-01T00:00:00 +0000' git commit -q -m base +printf 'staged\n' >tracked +git add tracked +printf 'unstaged\n' >tracked +printf 'untracked\n' >untracked diff --git a/gix-tix/tests/fixtures/generated-archives/create_commit.tar b/gix-tix/tests/fixtures/generated-archives/create_commit.tar new file mode 100644 index 0000000000000000000000000000000000000000..0059d8d99c2310f70e778c4e259f8d70e1893957 GIT binary patch literal 64000 zcmeHwYiwLecAjP(WWi2=Y#xTOe~g+|XGu!zC*RbNGa5>>!&!|eOH{Omq~YA|zD@S1 z*|*iVTNFp5MHWdA`=Ab=AD0X9!0K#)ZY7)XG`b{xQf19%bS;j9A#2@oI; z+y9d9JE!XQ?b}T@NwMXbohD|~-S<|VsygS?sdG-9I+e>`8v9=Xf6I%Dm-val^xo zVre4($4wZ(C;juA+aG?owX=Kk#@5b<_r7`It&Wucg~g=&FBOaP6ZwDPlo$k9_CkLm z`v>vzWqBg=zuv6+&&N07QS-mJw3M9x#R8s9^uO`Z#6iBXb$1irzk>hY_~f(i=ghO8 zth&!PE?xTCPi)+Idg;=4{}x{VUzGV%`2VY4|IJ_h3xEE5b1(nhgJ0kOvF`8uv8liQ zFTZ=~t369%=kCp$DEEgiWs9be`s%Oz$#=fH^n+jfi?@GxwJzD=ZH9|4Pfn<%#}x(S#TRZ*|G{=e;sM zR!g9D=+bfk(3b?+3w?@rZuG(0SxP9EOwi z|26PekN){qH{8#5f^22quRIC5om}W0v>N`acVYzn7w6}fllK4O;xhJ&UcKqj5WeL5 zKbPOkm+Q@ZxSvX0HrJcRe=bBf6bUA*wCk;o=>(?Rs(KyYct)7RuV%p?rqlL)6EyJ@ zPGm#&$)zp>%+7v2Q~>y_H=hOAU^L|;mddvMieG=`XIt2IoGH$l!+pPLP`mAWl}^Z} zPzo>z&3MhKZ8=1Nn&0-D6@Mn21yI!CvsMt+J3;$sPRa_f&b}|T0i@He`vTK;pRdYo zuUXkQoqexk(7j5x4XB;QksF!e$k;Fn1BC5wuIe|BHnr@#3ZW7-YxTWu+w0VUgJ)j5 z?v)!pVTL|&hRWLP7Pi=+TPtqfJu+3l=5-sLITLo#5l@GTY8YWR1g7dWwrviB_LF*Z zPqAz}gn`3T2grezY5NBO$kM}u+4n;99*xv%7GI{;4h{rjG*$qxbd=Fnf`fy4=bF-@ z9CV1CliD{2UZ=7TAS`Bq8U-@PXGg|s`*!faW1!`wbkjcss!3c7v{P^OhN5)I^)nCZ zmH~-a!PnrK-)`5dzCf~A1c0)??>(yr-S#yw-Jy>{+OYs7%YGv`1b*t2WCtKL*a4lk zm3vhIhKMr^2Ivs$t)SlQm|D=rz?#qeW*v3SK>)PYD?)L{>b`QiRL)6t!Ijyo?9JL@i;{>Uj(*inn5%BsoxIR z@lIEGUD-&6DU)c%O|fd=hkz0WbDVGo5PYa^2X!Rng}V&KoTUG3E>*4jW@gsBNKN^b z{lIK|qh!*VD{1^>cZ#W(?BRBspxMa-?+L^O7_o^7ABJ9=6U=Mw>3mGW*9jM}T|m)p z5EE@d$-WQ%m6sq~SZ{+HI9#wOg$H4cK&{S0;USg=6GrUHi4{j^44F#d_ka#76ot8! z+#F&^raCh1Zd2qRBv>^oA~D-F*#qC z4%5qTQ$*>w*rAxrD*K&7-xAj~nj?qppqx%>Oc@U|vh8PscD8}RkxUh?N?tPrhAEYS zZb+I&!_B)*(7j3)0vX`qUXWgdg7Nii2EVzet^c51%f9_Yh9kde3?wE*Ip5UTq zQ5hAAqZ`Xhz@_9U=$bOm90V+uqF zR}x4 zHM}5{r`poV1&s2xNmEFq&6>IS;hmkI-sQ&0?!EV~7kA&k`E8;|CZpGp0hp|>GNrVUulmpO z&2FP%J5AW@1eNj|*v_4j-h{;-&Et?HHAd`73f-^e=X_g`p(kdR% zTpfzN3>(Co(Kys#qkarVtdNvi>5MLa={VfYpxda51hGn8oN>vil(xEI5n}056PM8|5Hq_*Ce8 zmoBQgaYFfR)cOiOEhOp&&H#Ia0E-PIo-jsPW;6BQuP~t#|Iim z^hC;W5fzLNc7u%wi1TzpOq!$7o$%N?wE82eCu#=)2VM=7Hx&qOs$03($fZMIfD^~A zn%!xK@AN>hk!ndjN}Lx3wCbhz{;9g6<~Dr=GGdTqpih8UM>H*YD^lf;V-4sGR2#^b zzPJZa4LoZ$VUN=;X+Y6T**UTKNzExyd z4!bRyM7BX`jP_A~Ws@nGw=k>?^OlYWUvw}Vv#zAtlvb0o!?2i0n{1%t(%QY>3S?lI z-4FrJYbUfi*r=leJv^zT;8<79O8q~;d*`U-uTzG^CwEMI%qj$(?#eKy0uiq;n@Y0k zC=ki=eZ9b39#vmP9f>GgJCYWssvVV{C4*We9VS6DpdBy1MOwD4c0l!%j{ugw9%#DL z#i!Ff)`g-Pl~2#6DhOJ{%UH!U?XmRzCKrclOd6-Jn9ocaRV>S2xpKwa(e~nfFtU_D zS5`p{!xL*kKU+*)yO#1puj1y~CcWf_w5M&2`H)fefYJZ;-fn{}Xt5Mz@-yR|D^ zs!dxfA^yU$bYyCD+;G$~un2cEaLzR-PzCo>co#|xN&d>5jul9sf)BKr?b8|XFx(H? zom~rTJ@dkTyL`C^<0XoT083|*(=Tn_#3(!Mt{)rZRE)kmu%?r(&Y4VKs~}4#Mue5V zDy;FWw}x8s$06Sb+f7rg;aUi(9wIuqg{e0i!7AxYQY6cCJ%N!ootELn+{E;qorY;o?)?x> zOAaEo0O+eeN;*v9~!=5_bKt;#@Sno~TMJeDBqpb#C9JM3xmME|&56CSC*NnE$5*rz@S= z7w!R~9(2O_uhQcDK>Ytgaei|C=QWW3(kb`)trGf3ph(5U95iA)gT<=AU?m%~#jS5hL~E{6 z3c+i`t=7TqP%M@rMxDpN_H{5y0ee?;TL~EUtrpNKrv!$Y0-FMEj!-@MnIcR+bDM6f z{!Mtu*4|z(8{$Gx_6egJz>vReG9^~MfAEjQ|cR?ltd8_u3sAW}tck zLL{nprQ2?(YvomqvXpL*QaROqQ5rk;8%An5Umj}vyLg(y)+lYBj*pG`bVe}7=P{!$ zFM1J{S%NjI-O=D^Z09n|O%)0gb6#I^+WG6gIQ= zh@W16sC8&lfMB%_FtjM)zcF!i7hb?kn-Hom3bu6IvLZXn+)|Z>{7aTR0hxn zd@G2!Lj;8r`4FAlZ$PMngh64Gy(PMbQI!@ZGUYblkZ=OT;Ubhrk)EqE<` zD~QtSJnQ*1Acatpkq+t+&J&mxp2c1^<$Lxy#83nxO}_|ZKKdVKUKFpUGw-Bdrq;%0 zWhLhSf#2S9eZ4R{VC4K?7@YqI3ZBIOz6Jr%)Bj(6{$q|Mi~vkd@RQy6h1);UQlZBu z1OV!uUIYvk0HT{BvIRk+^v4es2GSp^N}NX|#OKElq0pP#b_*;o`@Jy%z^R_X=Aiu9 zDSvF)G0uC(TJiZGgq^H9iY{#GOEaV9e`$Fj|Hs18Wd1u6jrH@i^B-#!EMZs&Fo>Bj zEVfZ1n;(eLmiTN;<7)SytU+)s?DHVlPtF9Ke&y}4A!nG(5$?GtXJYkjLl;53U@aKi zqBvHJf4+dn=f9HJYAV@-aPNY|z=-)@S}rXl_5Y>9^5p#I>)@2L&VM>uRF#eUTZXLs zTF5T_tR-<<)@7!VTXZJU!-uyJ8y2cn=jW^o4w42o1?$H`0G#JdtDvr}gpKwP5y}-m zre5G#tJ_8hjvURQpP~w3(i=vvwgdq<2ctDk7*l+lcah=OJvj82!ME0i9du@iH!$%i z`1;OKH7kuS5P05lUb5iLLA zVH4}zXzq^SCr4&QG*E92} zRK6Rwv z4d&?U&0L0kkw^}NuFZYc@o}~V*7e*hMvlMX9Rww&^*xGcPt%W@X_GS_bH%m{5(SR; zbIQl4IYmVsz0K1A&Ilu4y=vCiO<`E)4R#{Yn=7;W#YN-X+kV$N191pXV$XDFJ2&s{ zZr!+fdk0Y(5>?bR(_#LjM>DxA8;@plS2FoN23RwXiuo2oYA#ENf}Br**3-f18XhcY z^8i7;nFU%ZKEB$gz%klS4@0#?1*t{AD7-#=bDDtB$X6#JH-=Ln>P^MaE;;q;)nOO{ zIJh9svTpRR|L%C^1uguxBO}&-q<$T+{}&dQCieeX=y=|;r(OT$JfA4}x-Dgb7DIk1 zVr3C>qSYRXV^{(fBJL&VhK-g6&tuJp;BVU#jMFSwhDEVXLBeR$w$EU|UR6Mej`^ka zWjYR%0vP=|&!X$NovSUTmrhmg56~dLbN}A^yW4kmwzhBYZf@Vdz2i=y)Yl8CDc#_a z8tF_OA*c=R*&I`EiaR?~{xh79OXHuezy4^TD1e}h70pse!|Ei!Ll7jPAvWGaqCTQP zUAh-tpuL8FWL^k}%U9N4Mqz9WkwLT>vC9pS-o}N>)RR5c_>W%fQLgDW8aT0UQc z4uy#MC*;2UZaG&E@{Z^7$O(amqJu#j4_iL$fNl%sUk8#4JpmlNFlO_JtDygC$oY#fDhJ|AFk+9o40`Io6&b53}FJd@87c6Ikg+^Y1eNbMUntvJOZ4lIV1ww zo_@d~U_TcL4il3m{q?cBrw)$HOvX|I@z})tu8thXr-AudM6bVd{q8sajIh@IrZoe_ zy;$|j-Mu}fzADA{c5d9fdl!pt!8o8pV8R*^Xf|3OxK`ef!{=1wZGX7_N1X#n$sR~` zpa+ecjDr!&-sq@$0~TnGyQW=7rgLHirKV^)rQZW9py!6GI1S7Y_9UkitZ(TaGA95v z8BB#;l5Z zU+o9?wr{Vkefu63{){Wd69VozP%{N6FKIzngTb12?hLPa=MHMVMDq;~N8R^#-PDyX z;@GxHJSSxKJV4{F?5<#vH5i(K2-n`&6`o(6$po`Pg(;nOh?fbGHwBNg8!sw|M|tvc z&b;$Ne#%UH`I6vXG8F}T(O1j%VJ0IL>s8d0I>L0j7z<-rhO=DMXA6-<;e>!R;h<{! zb0)o!#y`URZhTCT17}GNA8GaqGr8V`yh5z4)L83wYAZk^UMN4~F__u*?om^FC;WjT zcnqU2Nzrt@e`rp|kEB%e$VZnlLH{{%@q-Cre|F;gR^i=4E1n@2zj z#04fpw~YWD$KKfuh~w-Ak(=G9!iQD$XE0;5@1R3+jKYO(j4gC+T}WG+d30s-{aZJ7 z1;O_F*6len?XY9ApV0ReWa%R49Kmfc#aYC}1mukp^FYYpaq#R-D-A{rO_^{@6M$&P z{O154%F?AtFAn`_Zo-6;8HkZl^r;|=LoK$}kf$`)3YG&|V+LidL|gJGL1aXbd+Bq< z_Gu0f2xJEvwn*~;PY?pWu~5~+IinrWKt%E6NegMvx5=Cm=n;rb9Re>2gLX-?as)>r z5*LZ|!=V)9B7h96)+KpE6Ewmyi{&L=m}ap?9Vv+%5S>eW%sK1E+-w|)1GG8V zsUF?{%tu}n>uw+F@#g`><31YTz_E1Ves-WJ?AUfv^ z4tgP*mH!l}FVZTs2207rLnK{t;d>+vO!d96O+S755X_@E1}6T78Sh%9cjLW?q9GVE62Uf#g z6VEwkl8Qwrfrz=V3(@Ihkcd~;oUk+5XjCi6@#Zxs*JaHEpE?MqY*gLby=rFtJMaoW zdVzK1+#-IMMqvd#GzjUaTlnK8cR zN~MAM%r!T;=!(-Wnq*q<*oF5Lx@zCzi~cIAh_utLzo>KqqLYlKWt}p8D%C)slWk6z zq7g>4|5A3e;L*a7LYEXW`2E1nLm03y{=CMacf2NLEt(lg>f1J>+k^(xL4$U=-2Q~0z`uNRF_ z^!k%XjhYFe@=CN;+6*-iX43ieEH&`{^;IlRneLG>AzY(2RMLWhHb+6=)J>?KSab*E zV*e0EXnQi3B5t*$r!}Tpc*6R#k}G5FeHw)5gBYzJZBtxMAB0EYwE65ald%sfM|(KH zGHpdmtRhDJpx&G>t*w32?`$4cEwTDad8zo#MK zE0#I!{-0*=&;^3oV&l<2NQVIT9pZH8_WXQC|Hs0OCBy{FF_Z3ag-gT(xu6xh!ksO4 zu{U~7p&Ua!;mP<|NcY3VvyF6Nyi@S8SeprdJP?b zXu8NVKsq=ZAwZotK4C3Vm~GPZl5%SL5{OD9+Cuu5I?{i~lXK!^FLqgxhSwrz4T?Pi z>1;`;^vDq0LKZL0t}dB7dvH`}^d$;{n$DQOD0_-#?2+@s@nke2@!$_=X0PAd+}aZH zrp`l2SI$tjD2F+uBGZ!OG;tN`cwwc^_y8i{SUw9+zT4WP9K_L0l~1*c5kOW ziyXo94-*P8!2n8lmu&~Io+G{m0lF>jbpT9_HmvoT!exu#N!e$VuE7+PU>Ic)GPKL+ zo9z$(3*fbd<6KI>nF|!IUuQPAjN|{$xNCN6bNBkKTkD$~dbWzBS0?+N$Bz&c_?bDZ zm~3TMHUMl|Sma}e&-2mE?e|hQ>BLzx*E@_~1i_fMm>BG59dRHd=11Z;naNv&Ou=?= z-W(vLvzOeCAZQq=0BiXH(#UiB0(k{Me8;iy9jA6Q8G>LQWx!m(xL18v4|^^l&Nt;b zxUo@ZOB-UU7EP_d2}=@GB^Nw@v3peqgOgMhF>%1JrErocHYK?k8ET$O$8|D~Q$&r6 z_u=7c5e^&y^9H~Pu#bRNWo$7>;pSJDDVYM%vEXm+U&egzFIxv z3de`@IF5iUmFxg!1|lHOrG`FFM4j-am026&4@l%C$_xmGXLS!6jR*@drXW)!YU0f2 zzT<~J(S$u*KxvIuBAw4F1R`doS>6{TyPT>P@D7Bt#=;EF3@C&gOfS=~+^HdcUs>Ut zxoout!O{#dM0t*w9vX@aT%t1==hOpgu9UX!Vv=qP8;!nqV3TRc@&hNQC|?c4X7OGf z&_YjX@Nx|*6K^=HKkhx&R{XC|V+76PXZ6NxkX9 z!2~duKu7b9xL{j!xR)r$>V-~;#7d<@74=GLMPh#3XmiIN}Kre=5p17qc&p~)l6J!H3;q83k&a@3riyo2rBM356O z?(|^G63V7J#6I=|Y%cm>(Cj=kU3NK5=d=2IsZgG-&8be2- zjA~JKw&tkuAmg{?^me)^he@kkjx~8rvT*mlptqY^{Mw7L21tsUX>XjCdd zzU-HT#Pomqf7^Z;2SzVQ?|0hh$@)Ju{e^-1KNc2n|NF%M>o7RJ&z}BIMQ1Z;9YIgp z?;uis)|3i`r8)k)Vm|0L>w(!s+BqBxK@LEa-GMTc?(qx`5;If)OF1tTh#&a17lDwm z($1SE4h{fe?#54ovR>avg)y$a)AuT#23}wYs0^CsX>jI=;a6%y)T#RKz_ZFddgnTH z*5r;<^CAVDKdi2Ch~ofISeX5S1_j;Sn$nSFH0vF7d-hDHQPs>qy-{zjoMDs4e9sE* z;zX?otwNCs1fG=s5Y|GyVjF`V9I47S6*jsgDQZM*o^JZjJJYUbVgq5>64coaBI~9- zVJ*H&c1>>IJd~7Nh+#s!H7$*(<1XWNt6OGJC@=x#jbTmDY3;LhwwfZfb!4i_VlA>u z#bv55CA<2_aMpDx*UZ|y+CtYKefKfNdw6tE4q*2{KGo!Mr;o9^T9;PAT!Vc=pBW7s z^cI?Qy9@7M)M*?Y(Yvdt$jD7s_MDxxAm{8vm(UKp{dS&5?QmRe3%d>5 z5l!@%OMCft3OL`bH+S{tx=@Q-B$|`aMn|tBIe`SUex${sE`1#7yK+%UA1tRDhyo@3 z)ecg`4DZY}AXV%rs|3_Lx#)gM%OrYDi7-2GsYz#nM_4|9ieO2(_A@6u5*s_?uwp|e zgTF0_Xc0t3C#`j5kp1Mm4wzr3QZ7Yrz%DE778A&caPg6&Pv(W4LN9q@9iHTmW7SK? zRA<6`>X zOc~f(BEV&OC|n+bl_^&|8^K&gQk|}}q1`1H%ATY^Vo2Em=8PfG%^8d{+Az`F3`}{T zVQ)Gw5d6q9OAiZ-#FlV4Y}iA>;pP#`uS{{G+lcT9*c%Ia^GHbUB>EZxW-XYRKW)ju zt}{~g0J<*hiK(glM>FmckgKys;y$Nlkd}CT10m?F{Sux4?ETE-A2sthXeHe%K9*Os zw;AV*Ap8TIUvVH4Fd3qnQNU9V6>_E|;~NUQ^w^%$k$|)}MGS?=Dc?i=3u!RB0W7`L zfV3$MUSn{Bj%vytR6+i zF3||GKO@#t&Xd`X4W%2~o4XRlF8f4wQC8O_?pfNyUKzIilnGRVy7f3Z>*tn4t1v_| zAIobKJ~V7VF#@ywq=sM=9O?*hFB%q9l}6m$xnj0?L=ZcG@s)!rh`o6&ZM%->d;3k~ z808g{%vmnOl8CE7;jN+53=S5kO^|20H4B*zmSA8fH)jC7bEEe&xij)MBt#H@c@yn1&T(;=AXim|}1^$5g4DK?a5J=ks zmT@%yIW;&LbaZw59JXxRRi1oO6~Q%YS9$J1rbq#3?*dg9L8TY40e5|a1C`iuJXIG8 zt~qC5J6@*Tbz*tzTseT0_i!Zy^x@P4p4=6Go?~mh9ZdB}cMUD~JHg(({vBr^>A+8L zI3=c_Q4h>Ahoo?gb{Hi5X-uF!q6|Il=~NgnO$eEOA#$`LX27wJEYnu2#qnC59ypN3 zYyYqk+0;Z9RluhjBp3-!KC6-~P{}fqkDg7(d0+Q~9RZ!|n`{H#vdXq|;!D3hY^%y~ zX1A3rTgw>QyUeh}S+5kaUGm|PBu~i@1OU6@5Zd+;>5I%m*w{$T;NI5cagS&L7CLKs{K@)REd(_i?r1p@Bjhy#(yIi-=d5NG~2ki)?s!j>A z50NgXo|Wt}m4(jWi^L9q%qFSsMiIaGSj0_q>Pzn*mz^3+f2%9vWEhD_;lT-K8EOWL zJt7}mSO=cVZP}bWI5#eL5l2Voq`tj@+;swkxaITWFNvR*T_7}Rlca$6r+73tfX8^` z|8|y2jXZmbKleHCou&BF17!YRHrH9EzXA%eOdQCNGJJ$TSF!?V(f{UMeaZKF$hlXr zAMB%nf({@*%hJV7DdS37fbn9(K;{MOs6P^cu1vHRnr~*YFM(=mQ*T#*IY9Su!y#&B)ku_RL3$O|C7+l(0M@CDe}C_t6Dh zP*K5QYOT&CAT~hgEiBF`EFE6FGo``KC}MLt0fwH^5aKoyCn2NjoDeu(6sDZ|H$LCn z=K*re|Eo621;_q#j3@hl3&o|vVE(U#N&es0!XEwp-|LYUX=60xl1@qj*@BY=QrRRP z|4&EgNpZgvqAv5dMixjQoK0cEy<-3nPbOzQ7h&6mOH&z(-aj^K1R!h@%H(|k@wgEP zOuZlm0zssC;s3gmL^(F|nXW{(*cAV(iOSI4oTy z>2O0L2UXTFLd5yNafKi*qhW4J`g8TPek1aKxvj@o?q9%ibq}cc;r0b)l|gT)X3|$Z zn0N8|{hjx>&z*D;yPqceG{jz|OR0#2l<-;V4yr!e*nruNy-PH|LwWAf&LIXA914T8 z3(p6B_(PI=GVVfs(CPu3v3JGv;dRZ>VZer=*w6p%H(VAL=>i%g#1p&*khUkAPQr~jCE17SP^@QZHt9P}?OFBB*Azi2`Xfj5l)c?Pu(DSO^Wr2k3!UkSE8l7KH2 z3#CG7o+7k3zqGVC(f`hi@H5mSZBFxNR+oH#-Yer{wNxvtc;!ODE0)V7TWPtx;;+nC zy`|O4inX5^&i9=Z~ikl>fB&XtVklCasC$CY!jng4-gnHrP#cVzqr#Y58nQ(P#_PyBzcfo0M|7R)6tpU%vpXPe6Q5Fwi&uSC_`ptMkbJrOE!^ zSZI+1nxKFFbelX$C@23OCI7M5C*}Wg2|3{=^8aMKUc}qyZ@m1OUrK$Y_(yN7|K8R1 zPh`Gdf0Fu@-}(ptWbyhh-Tk{i{=5G>JM(Fn`=7ikH=g~Azw~DdKlsbP z@LzxU{2RaVH-C6%@7=%lvw!EE-}%}9R$2M0zyCk}$`AhYpZo29`*V$d^dExuzy7D+ z``Y(@ukrOi{<|yX_f~)LpD+CWZ~m{}_bbbQkl`+sa|Hrfs za1Qwo|L-LJ>y?Rn3IKidFRq@V#@UOUgZ{;(`Gtx7cXm{txkiHi`RZz=va-0c;8jYM z1rasE8@0rY}Ci8Y9^_2 zg&!hhtBsDx;nH>Ebvye(8@VWXa|Fuh@Ax@_Nt8;Dis%2F|BnH_%K$_&;-7yU49QI#nu{4SQ z83QQ>Kn~D94_0V)aC6{5(Q`i-E&m7nzYFt=llcF0Bm4{%7fP$erDa(CxOJoc{0tQ* zMSmnPFgX9Q9%QZde?cQ0IsaknCC+~@EH6*|KNp1l6TvWrqqFr&xy3@RI8g*oMA+oz P1OpQcOfc{xf`R`Jpvc72 literal 0 HcmV?d00001 From d624e719fde43d4295f9859fd57caec3293ef423 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 05:39:37 +0200 Subject: [PATCH 055/282] feat: forget top commits from tix Offer `e d d` only for a completed, selected non-merge commit with no known descendants. Make the first `d` arm an explicit status-line confirmation, and cancel it on navigation, refresh, cancellation, selection changes, or another command. Reuse the shared all-mutable-ref transaction used by reword and create. Retarget local branches, custom refs, direct pins, and detached HEAD to the sole parent; delete matching refs for a root while excluding tags and remote-tracking refs. Preserve attached HEAD, reject affected branches checked out in another worktree, support ref-only operation without a worktree, and leave an attached root branch unborn. When the selected commit is current worktree HEAD, use a temporary-index `git read-tree` preflight and a two-tree update to discard only the tracked delta introduced by the commit. Reject overlapping tracked, staged, index, or untracked conflicts while preserving unrelated untracked files; roll references back if checkout application fails. Refresh cached history and retain the parent selection. Add full-state scenarios for ordinary tips, conflict isolation, root-to-unborn behavior, bare repositories, detached-root rejection, multi-ref retargeting, and immutable tags/remotes. Update the tix specification and shortcut coverage. --- gix-tix/spec.md | 26 +- gix-tix/src/app.rs | 66 +++- gix-tix/src/edit/forget.rs | 322 ++++++++++++++++++ gix-tix/src/edit/mod.rs | 1 + gix-tix/src/lib.rs | 24 ++ gix-tix/src/ui.rs | 7 + gix-tix/tests/fixtures/forget_commit.sh | 18 + .../generated-archives/forget_commit.tar | Bin 0 -> 76288 bytes 8 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 gix-tix/src/edit/forget.rs create mode 100644 gix-tix/tests/fixtures/forget_commit.sh create mode 100644 gix-tix/tests/fixtures/generated-archives/forget_commit.tar diff --git a/gix-tix/spec.md b/gix-tix/spec.md index d6afc335658..6249c8e8ca2 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -324,15 +324,35 @@ space first; changes blocks adapt within the remaining history width. untouched. A ref changed by another process or a branch checked out in another worktree aborts safely. +### Forget commits + +- `e`, then `d`, is available after history completion for a selected non-merge + commit with no descendants in the complete cached graph. The first `d` arms a + `d again forget` confirmation; the second performs it. Navigation, refresh, + cancellation, selection changes, and other commands disarm confirmation. +- Forgetting does not require a worktree. Every mutable direct ref pointing at + the commit is atomically retargeted to its parent, or deleted for a root. + Tags and remote-tracking refs remain unchanged. A branch checked out in another + worktree aborts before mutation. +- When the selected commit is the current worktree `HEAD`, Git preflights and + applies a two-tree index/worktree transition which discards only that commit's + tracked delta. Conflicting staged, tracked, or untracked state refuses the + operation; unrelated untracked content survives. When `HEAD` is unrelated, only + refs move and the worktree is untouched. +- Forgetting an attached root deletes the branch and leaves symbolic `HEAD` + unborn. A selected detached root is rejected because it cannot produce a valid + unborn `HEAD`. Success refreshes history and selects the parent when present. + ### Editing shortcuts - `e` toggles the edit shortcut group. `e r` rewords, `e n` creates a commit, - and `e t` enters or returns from time travel when each action is available. + `e d d` confirms forgetting a top commit, and `e t` enters or returns from time + travel when each action is available. - Edit shortcuts keep the group open. Navigation or another recognized command closes it, matching the `v` display shortcut group. Plain `r` and `t` do not mutate the repository. -- While the `v` group is open, `e`, `r`, and `t` retain their display meanings - for emails, references, and trailers. +- While the `v` group is open, `d`, `e`, `r`, and `t` retain their display + meanings for dates, emails, references, and trailers. ## Refresh, focus, and diagnostics diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 59fe42b4069..ae19edf7f16 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -285,6 +285,7 @@ pub(crate) enum Action { OpenDiff, Reword, NewCommit, + Forget, TimeTravel, VerifySignatures, Cancel, @@ -307,6 +308,7 @@ pub(crate) enum Effect { OpenCommitDiff(ObjectId), Reword(ObjectId), NewCommit(Option), + Forget(ObjectId), TimeTravel(ObjectId), VerifySignatures(Vec), Quit, @@ -366,6 +368,7 @@ pub(crate) struct App { pub(crate) unseen_filesystem_redraw: bool, pub(crate) history_display_expanded: bool, pub(crate) edit_expanded: bool, + forget_confirmation: Option, pub estimated_lane_width: usize, pub horizontal_offset: usize, horizontal_page: usize, @@ -439,6 +442,7 @@ impl App { unseen_filesystem_redraw: false, history_display_expanded: false, edit_expanded: false, + forget_confirmation: None, estimated_lane_width: 0, horizontal_offset: 0, horizontal_page: 1, @@ -644,6 +648,9 @@ impl App { pub fn update(&mut self, action: Action) -> Vec { self.notice = None; + if !matches!(&action, Action::Forget) { + self.forget_confirmation = None; + } if !matches!( &action, Action::ToggleHistoryDisplay @@ -659,7 +666,7 @@ impl App { } if !matches!( &action, - Action::ToggleEdit | Action::Reword | Action::NewCommit | Action::TimeTravel + Action::ToggleEdit | Action::Reword | Action::NewCommit | Action::Forget | Action::TimeTravel ) { self.edit_expanded = false; } @@ -827,6 +834,14 @@ impl App { self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id), )]; } + Action::Forget if self.can_forget() => { + let id = self.rows[self.selected.expect("forget requires a selection")].id; + if self.forget_confirmation == Some(id) { + self.forget_confirmation = None; + return vec![Effect::Forget(id)]; + } + self.forget_confirmation = Some(id); + } Action::TimeTravel if self.time_travel_shortcut_visible() => { return vec![Effect::TimeTravel( self.rows[self.selected.expect("time-travel requires a selection")].id, @@ -927,6 +942,7 @@ impl App { hidden_tips: &[ObjectId], select_top: bool, ) -> Option> { + self.forget_confirmation = None; drop(self.store_commits(commits)); let visible = self.reachable_from(view_tips); @@ -1312,6 +1328,28 @@ impl App { } } + pub(crate) fn can_forget(&self) -> bool { + self.state == State::Complete + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self + .selected + .and_then(|index| self.rows.get(index)) + .is_some_and(|row| row.parent_ids.len() <= 1 && !self.has_known_descendant(row.id)) + } + + pub(crate) fn forget_confirmation_visible(&self) -> bool { + self.selected + .and_then(|index| self.rows.get(index)) + .is_some_and(|row| self.forget_confirmation == Some(row.id)) + } + + pub(crate) fn select_commit(&mut self, id: ObjectId) { + if let Some(index) = self.rows.iter().position(|row| row.id == id) { + self.select(index); + } + } + pub(crate) fn time_travel_shortcut_visible(&self) -> bool { self.worktree_changes_available && self.changes_focus.is_none() @@ -1982,6 +2020,31 @@ mod tests { assert!(app.update(Action::Reword).is_empty()); } + #[test] + fn forgetting_a_non_merge_tip_requires_a_second_d_and_navigation_cancels_it() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + assert!(!app.can_forget(), "loading history cannot forget commits"); + complete(&mut app); + assert!(app.can_forget()); + assert!( + app.update(Action::Forget).is_empty(), + "the first d only arms confirmation" + ); + assert!(app.forget_confirmation_visible()); + app.update(Action::MoveDown); + assert!(!app.forget_confirmation_visible(), "navigation cancels confirmation"); + assert!(!app.can_forget(), "a commit with a descendant cannot be forgotten"); + app.update(Action::MoveUp); + assert!(app.update(Action::Forget).is_empty()); + assert_eq!(app.update(Action::Forget), vec![Effect::Forget(id(2))]); + + let mut merge = App::new(10); + merge.extend_commits(vec![row_with_parents(3, &[2, 1]), row(2), row(1)]); + complete(&mut merge); + assert!(!merge.can_forget(), "merge commits are not forgettable"); + } + #[test] fn editing_requires_no_known_descendants_and_new_commits_support_unborn_head() { let mut app = App::new(10); @@ -2516,6 +2579,7 @@ mod tests { assert!(app.edit_expanded); app.update(Action::Reword); app.update(Action::NewCommit); + app.update(Action::Forget); assert!(app.edit_expanded, "grouped edit commands keep the group open"); app.update(Action::MoveDown); diff --git a/gix-tix/src/edit/forget.rs b/gix-tix/src/edit/forget.rs new file mode 100644 index 00000000000..5f0536a92c6 --- /dev/null +++ b/gix-tix/src/edit/forget.rs @@ -0,0 +1,322 @@ +use std::{io::Write, path::Path, process::Command}; + +use anyhow::{Context, Result}; +use gix::{ObjectId, bstr::ByteSlice}; + +use super::refs::MutableRefs; + +pub(crate) fn perform(repo: &gix::Repository, id: ObjectId) -> Result> { + let commit = repo.find_commit(id).context("could not find the commit to forget")?; + let parents: Vec<_> = commit.parent_ids().map(gix::Id::detach).collect(); + if parents.len() > 1 { + anyhow::bail!("merge commits cannot be forgotten"); + } + let parent = parents.first().copied(); + let old_tree = commit + .tree_id() + .context("could not read the forgotten commit tree")? + .detach(); + let refs = MutableRefs::pointing_to(repo, id)?; + if refs.is_empty() { + anyhow::bail!("no mutable reference points to the commit anymore"); + } + refs.ensure_not_checked_out_elsewhere(repo)?; + let head = repo.head().context("could not inspect HEAD before forgetting")?; + let head_is_selected = head.id().is_some_and(|head| head.as_ref() == id); + if head_is_selected && parent.is_none() && head.referent_name().is_none() { + anyhow::bail!("a detached root commit cannot leave an unborn HEAD"); + } + let transition = match (repo.workdir(), head_is_selected) { + (Some(workdir), true) => { + let new_tree = match parent { + Some(parent) => repo + .find_commit(parent) + .context("could not find the parent commit")? + .tree_id() + .context("could not read the parent tree")? + .detach(), + None => repo + .write_object(&gix::objs::Tree { entries: Vec::new() }) + .context("could not prepare the empty root tree")? + .detach(), + }; + preflight_tree_transition(repo, workdir, old_tree, new_tree)?; + Some((workdir.to_owned(), new_tree)) + } + _ => None, + }; + drop(head); + drop(commit); + + let committer = repo.committer().transpose()?; + match parent { + Some(parent) => refs.update(repo, parent, b"forget commit".as_bstr(), committer)?, + None => refs.delete(repo, b"forget root commit".as_bstr(), committer)?, + } + if let Some((workdir, new_tree)) = transition + && let Err(err) = apply_tree_transition(&workdir, old_tree, new_tree) + { + let rollback = match parent { + Some(parent) => refs.rollback(repo, parent), + None => refs.rollback_deleted(repo), + }; + return match rollback { + Ok(()) => Err(err), + Err(rollback) => Err(err.context(format!("references could not be rolled back: {rollback:#}"))), + }; + } + Ok(parent) +} + +fn preflight_tree_transition(repo: &gix::Repository, workdir: &Path, old: ObjectId, new: ObjectId) -> Result<()> { + let mut index = gix::tempfile::writable_at( + std::env::temp_dir().join(format!( + "tix-forget-index-{}-{old}-{:?}", + std::process::id(), + std::thread::current().id() + )), + gix::tempfile::ContainingDirectory::Exists, + gix::tempfile::AutoRemove::Tempfile, + ) + .context("could not create a temporary index for forget preflight")?; + index + .write_all(&std::fs::read(repo.index_path()).context("could not read the index before forgetting")?) + .context("could not copy the index for forget preflight")?; + index.flush().context("could not flush the forget preflight index")?; + let index = index.take().context("the forget preflight index disappeared")?; + let refresh = Command::new("git") + .arg("-C") + .arg(workdir) + .env("GIT_INDEX_FILE", index.path()) + .args(["update-index", "-q", "--refresh"]) + .output() + .context("could not refresh the index before forgetting")?; + if !refresh.status.success() { + anyhow::bail!("{}", refresh.stderr.to_str_lossy().trim()); + } + run_read_tree(workdir, Some(index.path()), true, old, new) + .context("local changes conflict with forgetting this commit") +} + +fn apply_tree_transition(workdir: &Path, old: ObjectId, new: ObjectId) -> Result<()> { + let refresh = Command::new("git") + .arg("-C") + .arg(workdir) + .args(["update-index", "-q", "--refresh"]) + .output() + .context("could not refresh the index before applying forget")?; + if !refresh.status.success() { + anyhow::bail!("{}", refresh.stderr.to_str_lossy().trim()); + } + run_read_tree(workdir, None, false, old, new).context("could not update the index and worktree") +} + +fn run_read_tree(workdir: &Path, index: Option<&Path>, dry_run: bool, old: ObjectId, new: ObjectId) -> Result<()> { + let mut command = Command::new("git"); + command.arg("-C").arg(workdir).arg("read-tree"); + if let Some(index) = index { + command.env("GIT_INDEX_FILE", index); + } + if dry_run { + command.arg("-n"); + } + let output = command + .args(["-m", "-u"]) + .arg(old.to_string()) + .arg(new.to_string()) + .output() + .context("could not run git read-tree")?; + if output.status.success() { + Ok(()) + } else { + anyhow::bail!("{}", output.stderr.to_str_lossy().trim()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn open(path: &Path) -> gix_testtools::Result { + Ok(gix::open_opts( + path, + gix::open::Options::isolated().config_overrides([ + "user.name=author".to_owned(), + "user.email=author@example.com".to_owned(), + ]), + )?) + } + + #[test] + fn forgets_a_tip_atomically_and_preserves_untracked_files() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("forget_commit.sh")?; + let repository = open(fixture.path())?; + let top = repository.head_id()?.detach(); + let parent = repository + .find_commit(top)? + .parent_ids() + .next() + .expect("top has a parent") + .detach(); + + assert_eq!(perform(&repository, top)?, Some(parent)); + let state = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!( + state.head, + gix_testtools::repository::Head::Symbolic { + name: b"refs/heads/main".into(), + id: parent, + }, + "the attached branch is retargeted to the parent" + ); + assert_eq!( + std::fs::read(fixture.path().join("tracked"))?, + b"base\n", + "the selected commit's tracked change is discarded" + ); + assert!( + !fixture.path().join("added").exists(), + "the selected commit's added file is removed" + ); + assert_eq!( + std::fs::read(fixture.path().join("untracked"))?, + b"untracked\n", + "unrelated untracked files survive" + ); + assert_eq!( + state.index_tree, + Some(repository.find_commit(parent)?.tree_id()?.detach()), + "the index matches the parent tree" + ); + for name in ["refs/heads/main", "refs/patches/forget"] { + assert_eq!( + repository.find_reference(name)?.id().detach(), + parent, + "{name} follows the forget" + ); + } + for name in ["refs/tags/keep", "refs/remotes/origin/keep"] { + assert_eq!( + repository.find_reference(name)?.id().detach(), + top, + "{name} remains immutable" + ); + } + Ok(()) + } + + #[test] + fn refuses_conflicting_local_changes_without_mutating_repository_state() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("forget_commit.sh")?; + std::fs::write(fixture.path().join("tracked"), b"local\n")?; + let before = gix_testtools::repository::snapshot(fixture.path())?; + let repository = open(fixture.path())?; + let top = repository.head_id()?.detach(); + assert!( + perform(&repository, top).is_err(), + "overlapping local changes are rejected" + ); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "a failed preflight leaves refs, index, commits, and worktree unchanged" + ); + Ok(()) + } + + #[test] + fn forgetting_the_checked_out_root_leaves_an_unborn_branch() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + let git = |args: &[&str]| -> std::io::Result { + Command::new("git").arg("-C").arg(fixture.path()).args(args).status() + }; + assert!(git(&["init", "-q", "-b", "main"])?.success()); + assert!(git(&["config", "user.name", "author"])?.success()); + assert!(git(&["config", "user.email", "author@example.com"])?.success()); + std::fs::write(fixture.path().join("tracked"), b"root\n")?; + assert!(git(&["add", "tracked"])?.success()); + assert!(git(&["-c", "commit.gpgSign=false", "commit", "-q", "-m", "root"])?.success()); + std::fs::write(fixture.path().join("untracked"), b"keep\n")?; + let repository = open(fixture.path())?; + let root = repository.head_id()?.detach(); + + assert_eq!(perform(&repository, root)?, None); + let state = gix_testtools::repository::snapshot(fixture.path())?; + assert_eq!( + state.head, + gix_testtools::repository::Head::Unborn(b"refs/heads/main".into()), + "deleting the root branch leaves symbolic HEAD unborn" + ); + assert!(state.index.is_empty(), "the index becomes empty"); + assert!( + !fixture.path().join("tracked").exists(), + "tracked root content is removed" + ); + assert_eq!(std::fs::read(fixture.path().join("untracked"))?, b"keep\n"); + Ok(()) + } + + #[test] + fn forgetting_without_a_worktree_only_retargets_references() -> gix_testtools::Result { + let source = gix_testtools::scripted_fixture_read_only("forget_commit.sh")?; + let fixture = gix_testtools::tempfile::tempdir()?; + assert!( + Command::new("git") + .args(["clone", "-q", "--bare"]) + .arg(source) + .arg(fixture.path()) + .status()? + .success() + ); + let repository = open(fixture.path())?; + assert!(repository.is_bare(), "the scenario has no worktree"); + let top = repository.head_id()?.detach(); + let parent = repository + .find_commit(top)? + .parent_ids() + .next() + .expect("top has a parent") + .detach(); + assert_eq!(perform(&repository, top)?, Some(parent)); + assert_eq!( + repository.head_id()?.detach(), + parent, + "HEAD's branch moves without checkout" + ); + Ok(()) + } + + #[test] + fn refuses_to_forget_a_checked_out_detached_root() -> gix_testtools::Result { + let fixture = gix_testtools::tempfile::tempdir()?; + let git = |args: &[&str]| -> std::io::Result { + Command::new("git").arg("-C").arg(fixture.path()).args(args).status() + }; + assert!(git(&["init", "-q", "-b", "main"])?.success()); + assert!(git(&["config", "user.name", "author"])?.success()); + assert!(git(&["config", "user.email", "author@example.com"])?.success()); + assert!( + git(&[ + "-c", + "commit.gpgSign=false", + "commit", + "--allow-empty", + "-q", + "-m", + "root" + ])? + .success() + ); + assert!(git(&["checkout", "-q", "--detach"])?.success()); + assert!(git(&["branch", "-D", "main"])?.success()); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let repository = open(fixture.path())?; + let root = repository.head_id()?.detach(); + assert!( + perform(&repository, root).is_err(), + "detached HEAD cannot become unborn" + ); + assert_eq!(gix_testtools::repository::snapshot(fixture.path())?, before); + Ok(()) + } +} diff --git a/gix-tix/src/edit/mod.rs b/gix-tix/src/edit/mod.rs index 216b294e7d9..04671e43e49 100644 --- a/gix-tix/src/edit/mod.rs +++ b/gix-tix/src/edit/mod.rs @@ -3,6 +3,7 @@ use std::{ffi::OsStr, io::Write, process::Command}; use anyhow::{Context, Result}; pub(crate) mod create; +pub(crate) mod forget; pub(crate) mod refs; pub(crate) mod reword; pub(crate) mod time_travel; diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index f3fea1a50c7..bd881e7664b 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -1716,6 +1716,21 @@ fn event_loop( Err(err) => app.notice = Some(format!("new commit: {err:#}")), } } + Effect::Forget(id) => { + fill_repository.retain = false; + fill_repository.retained = None; + match forget_commit(&repository_path, repository_is_bare, id) { + Ok(parent) => { + app.notice = Some(format!("forgot {}", id.to_hex_with_len(7))); + if let Some(parent) = parent { + app.select_commit(parent); + } + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Err(err) => app.notice = Some(format!("forget: {err:#}")), + } + } Effect::TimeTravel(id) => { fill_repository.retain = false; fill_repository.retained = None; @@ -2732,6 +2747,13 @@ fn create_commit( edit::create::apply(repository, prepared, &edited).map(Some) } +fn forget_commit(repository_path: &Path, bare: bool, id: gix::ObjectId) -> Result> { + let mut repository = + open_repository(repository_path, bare, false).context("could not open repository before forgetting commit")?; + repository.object_cache_size(None); + edit::forget::perform(&repository, id) +} + fn run_external_diff( terminal: &mut ratatui::DefaultTerminal, mut command: gix::diff::blob::platform::prepare_diff_command::Command, @@ -3443,6 +3465,7 @@ fn action_with_shortcut_groups(key: KeyEvent, history_display_expanded: bool, ed KeyCode::Char('r') if history_display_expanded => Some(Action::ToggleRefs), KeyCode::Char('r') if edit_expanded => Some(Action::Reword), KeyCode::Char('n') if edit_expanded => Some(Action::NewCommit), + KeyCode::Char('d') if edit_expanded => Some(Action::Forget), KeyCode::Char('t') if edit_expanded => Some(Action::TimeTravel), KeyCode::Char('s') => Some(Action::VerifySignatures), KeyCode::Char('v') => Some(Action::ToggleHistoryDisplay), @@ -4315,6 +4338,7 @@ mod tests { for (key, expected) in [ ('r', Action::Reword), ('n', Action::NewCommit), + ('d', Action::Forget), ('t', Action::TimeTravel), ] { assert_eq!( diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 7b6b793b71c..279a7eefde1 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -679,6 +679,13 @@ pub(crate) fn draw_with_worktree( if app.can_create_commit() { footer_spans.push(Span::raw(" · n new")); } + if app.can_forget() { + footer_spans.push(Span::raw(if app.forget_confirmation_visible() { + " · d again forget" + } else { + " · d forget" + })); + } } else if !app.history_display_expanded { footer_spans.push(Span::raw(" · e edit")); } diff --git a/gix-tix/tests/fixtures/forget_commit.sh b/gix-tix/tests/fixtures/forget_commit.sh new file mode 100644 index 00000000000..9964e4396bd --- /dev/null +++ b/gix-tix/tests/fixtures/forget_commit.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +# The top commit changes and adds tracked files; an unrelated untracked file must survive forgetting it. +git init -q -b main . +git config user.name author +git config user.email author@example.com +printf 'base\n' >tracked +git add tracked +GIT_AUTHOR_DATE='2000-01-01T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-01T00:00:00 +0000' git commit -q -m base +printf 'top\n' >tracked +printf 'added\n' >added +git add tracked added +GIT_AUTHOR_DATE='2000-01-02T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-02T00:00:00 +0000' git commit -q -m top +git update-ref refs/patches/forget HEAD +git update-ref refs/tags/keep HEAD +git update-ref refs/remotes/origin/keep HEAD +printf 'untracked\n' >untracked diff --git a/gix-tix/tests/fixtures/generated-archives/forget_commit.tar b/gix-tix/tests/fixtures/generated-archives/forget_commit.tar new file mode 100644 index 0000000000000000000000000000000000000000..79d27c24ed211d6cc2267ca07577256f809a9a15 GIT binary patch literal 76288 zcmeHw4UAjab)I$|#~s>koWO>&No&8QCY~AX@c(aSw4=3lB&}vwE6LI*+C)FIkL2SF zR~$YrAF0t;t8J1NsM9ueV8boqv_X)x4br+lLDM2>5dWYp5Ic?08rarxn;>=LHV%vi zwQH|qq)5N-ockUh$@w8i!?CiBXraN~H^9RI&}!PHTTXeTjS)5Z zY_^x~{%^d^9_glkDw#{Ck0eLhD+B!XPJg!%ISe$9+juZB5MQgdM%^>c|6%2SA(tM@ z|4|bL@Jau~(uHTAU0zu|eQJ5-*~?EIc&nuUkn%sBN{!|JffHgIfUU9o-v%qMpGPwP ztM#&TXLMuUYyM}_sb2jL3SeQZ|Ba3$0pwH5mzMD3VgCQZz2je<`IAeZ^Z!0{cM0_I$CBtYnim zewOA^V<+LRT=X6LvNQi%_Sz^Ge_&=G@;{Z&j@SRgV5)BVAEeap^nM@mKb| z!jXL&%m0y~_B#S_Xa27{c6l@lz<%U^p)lV6KMV%yp?_k-K3Ln2W7v=UFQmr)-@~B) zSpFXdmLJ?UI`h9_w@T{|j{h&49Q%I`fo=NepQyOaHRnKMLiQp5Gx;>)#SUToulM-B zvHU*_CVOz(komvvy0=E7`pdxgqyJ;Wa4i21fo(eUPc+)zdaUKfpaqm}x$Rcmvo{(w z=l(mf2mN!IOrbaaBbO_T<^TO-es2gzA59di^@O(`i5#^~)-C6b5Z$6ku)I>U+GtrV z*J?M)cFVDBOPIqc$G{&}tLZqFTgOub5n5!Qc;qO+tgKf(1%SWR`fV2h=2mfwrD9E| zz&9dud)3S#G6{qRcOU|@61E8qGXARe@ zw%q2{tdtdCt#wCg14ye`bp)pAK3^4^cD=N2wbt#Hh3=KwO+ancw)~M<92px%VSq6G zjhCJJ){>TeQX!PwdZoJ7ZrZIXaB$mhR_$WVAxzI9&QMvKU9NjA_*N7*Pi|Rdr((Bj zty#-!qa(Hs71c1pwg*gAD{R}^bep%T^)#_RRM;Gvv?S!MXWd6YQ1Ga7skNqx1D+wb*&8- zXswon;y$Z8%IStr6ujcX+N=VVo9k6!H}tyN@abld#?C7IpH?2@rmkY zP+L-7xXZ$rd+9$DkCdy9H9ce9jZ8SDb=Nxap0pL6JQl@Ib|)3N#~xm25;QxxVc&wd z03+5h;XTi8a)R0QHJy*W@O8quY!^_p8^lCYP_piTf8`|z=T)2F1`Zc2O5s6Rd!Sb5 zq3{q(g9!t6<-`gjG=xm0@EM@P2t{FTB{zrYk*T(CcR2C5uBpbWfBpZ8RRE)Mn0RtdHa$nLy;3==Q zHeGEMyt+aDVu{|qR7rS{0Zd%AYv?rF!14|ff$>nm190>NW92Vj1JIu7JK-JWR?rY! z4Ozz+0Yf3H=9cVQKw~qEE*U1nEK@F$?J&KZCPkEviyaEdtfJG}bPRF*M&snL6_nFS zjVWVeMmC+8+l zR9GKU%0qNi>y*+Y2|;j@t+w3NveT%!TbLr1c3ntDS{B>Y8srIrt2)bGyri6U9^qDs zVSD;l`%hUc+<_F`!#n5S7TXxV^H9)p-@l5j7St$`e+ULLzD{Sf`)8 zxbh>b40v6=eD-8&_3Y{Ah$5Mcc1s3e#T=C>=@W^vb30LQ*J`HIguO^m5vS%kkqP-d zInCN=aXsc1Uw{;`Vn$_)#k>~CWIZ0hnb`(uh9UvdN;c43-WGe4Y!Gku#^H7t3|OI8 zYDFh?`HP0(F1hVmStN*2>cWgmPNj4gv~o;)E5NVw5+VCf@p)iYUk9;!*nb6_`|Y*= zG7>M=0WrF7uk-k?j{Qf=ZD#|pi1q!J)vnuhEZ$IGpiCiNNY^K56%I8ut^rF>FYppe zL6qVryzWJf(%M{WizdE+x0O1T5O3X1#av8l`GN_oW+Zf_y4X}L-=LgD6{#UlDAqaS zqsqBaT~?RGE<;yh^1&BzZ^h9ucQZ5qys*^TD7tRVp+e`tJb+G#MF#6u?@;}RxZ zP+)w8)k3#?OEhj-9UG4d!WJyK`cwP3{zZ+~sKUm2V|kmc4d^CXs1S8l!#GUD=vU zT90E`C#=VHJouu6IWgmxRGZRha%LC?6HzPX>bSIa=eGjs8)n;s*0!)qtNo4obfAYP z(knO?RkKq65AfdFYB-COA>qj#5+5@PL8rSi%s?Q*6=ov6thyJ7-tt|&z+B#|zKl8$ zQKohvEe5I`l%64j8YLYjLEWVtFTO=uwvDDs^^}hQmcAZnx>Knar#h?)MKvfNor#oe zn8wk`P{km z;}OTROa5G2qLRwqxpr2|u= zJ01anQwkh!OAFOEj%r?4@g%V8Pt)>ZSFb_lQl4g$p|XbZ|`? z;#()Iz#6sEC*G67sCBIh6JP~+pYp3rVdOr6;XR*6pbie#CtyD;*SuVvUFH!*!@gD~yxz2An@dIu3&0Q6NC zC4EgIOq~$FX^mkf+4lW4hZ1{aiX0IpQG-4QGpSQyn<4hs3$v_N7=ElhQ15Hd$8=_$ ztZzXYVSx1KK-&^mFI=T?sCS+}!Lq8!k=3v0799nNNfx>IErW&dCHDs?j#ZjWx)55VzF z)c6S06|F`9G;}QOkVHIiZgwtbu~kAJ2^6W2n1e=)XRufm7_4N=TISX_B%(1_DTUy* z;Z|$^b|@B05u?7x!1T30N&$OUbXy4+_O0U5DyIa7ngW{wZVpd9`I9M_eAaoot-3ei zAzM3py=;gJLD3Pg*C$V zy=sAX#Cfr8*VHApT<5lrZiyMvVT`J!%U&Mi5P)~>ts;rN7jqu&4DJ%r8NvN!^o$6C zBfnm@n{XK5bF)>d;RWK%lK~St}o0FzUfd!1CQVhbT=7DvZ=~6 zwnVXwC71=~JH6)~BD+*`;h^GP^O|(8*`i@46pdb8z8JNpxl_QNLN8y#Zjy&xZMav6 zMD?z;n>BT<+^&f-yc18Fdj~ zB|d7M1^VIrRGkHSzyd5>+4~8B=)mrR;RAtjb{=~rs_gElN1KwHF}l%RJ0^JAFd0PJgR#2?7t<+;;NKC^Sw++`@r$I7|=^ltUD@LQ4mk53U zD#D5@dMO=Q62r{NEug|3^CO|L*%gyC?uU zAF(9_amx^ZfQ58-2pcO-49Ci9zHrtxX$2BnVjdvnxuYxdNThXdOp|UhR6Vav>0(nE zKpXI_AmR@CgD>(SI=SC~PzMQv!X|q|mIdveX>{N%B`U+6kzi{n5~t@PhTU|y2HOpI zEgd6>qUt>B_%tAeP?C`j>Jd&5m=>PFUN+@>><+|G03u4i2xC6FA5Pv)Jru5wNWQ5Zx4!EeH~&KYm+bApJ3_#C}9Vcz$dn6gqR;Y=I?Yzc(ZRIMpNA zc$SZu@;jCt;k@@*D?I;QuN6~A(Sc2UX=bnapDyHj=YKXop8r0HM*4Tq`H!^)EsZ=aYr;`TxVjSCKv1~vujhe7~+&zn|3T~i4g z?H(eOOHN3=z_UiX30WmabLgiiLzr}i(Wxy#0KS9K7$=M=-pRYj@aqm7`pe*3Yr+m1 zTH^IhJPN+9b5!+GtqlaAJ)XHT=Mg-fmVvLb+Jr>`7o+%8EQXfR zNBD>2w+Dx(Y)tvQ2#5atA_+B(k#N#M-bTTnR z2Ut%pBvQ--b9D7)c9MOONDhQW&c4)gaJB{3_3R8rj?eZT1SO{QJ&I^gqu1+ED{ei{ z727aK5IEk=DbJ(k1Qm7kHbDb;QW*KM$E?LgE4f|gE$l?1H^*l5iyw`1`NEUN8Hhu8 z5__gYTRDAcb@|lk^DBtbkf@@%HRUC4UZ0L1J8^v`erz()#Q+P|^;DvPkeZ_sq9Er} zp!KwHx`qb}nmj;It;c|tl7p}2DR7MTquZfcqJq>SU=&^#zBx_6XymKikQ>4&5cQ~H zXqBA$*kjva2;ks?9A@37`SZ`vWw{FJ8HPcJ;!=mE{ZPSC=kaIltnc zLa8n$BNMv8BQ>IvRfM3{xM#CNy$SB@OgOi3J}!!X+RozjzM=qvGDb8@Aq}gO01rWs zgoc=S4~hB+0(I$L^aJfR{A2R2fH-<=@m>(d#t<1qn-RO*5b11Oh#bWZ354i$)q2cI z&EUOkV+<)v_RE9gdfz?_gyet4+i>eVO&{Cj`EJC1AJ8(eyZle3Qu)64k9;~gw*L-; zDFx@ROp8Sf!+LaB9JzHi#iI`&;W{k42?X<^|)!>Ol_(?Z!fV1(+^8V;E{)>Xqy zC_cU3YBjutM4|#63K8?S$bIYWV!Y}me4a}nCj=gf4hC)9YdEk2+6|b0El4i(1aP>c z%(X&-Oc|db2Ru8*i|i|5U1-=i@{H&Oi-)Os9(X!PfncR)1Vqh-WfDZk-4cyVgC%Hx zz&K@206B)v2GvwC`%7hpWFQy=oe2XVSgvJQ`lMUB=E*r)hO%@zjf1h!Bx!Dht3$!Z z#*ruI^8p5@ZGyA2#yDxZ2mv*Qbl2fW_Ryo74rmv%8GQ%B5GHW`$~l9bf!%N~ww>ly zAPEr0Bfy!OLn5Hf=?5GFcH)8HutL(LyFOO;$i|j6J!vR`cx+;RRY#8F)4==;qSxPh z^3qd3EUa~jT%y8*=!Z zioEF$*Z-h%ASu}csSfm@ah-86V%b|ds?LA~n$KNRen+O_Vg*GeXgWpDfECbli>o*d zOb_-XrxdJj=^ipC05y}C3acdNs^kz@w2*{Nexu|n4kAy_U?409lLQBn7p;`}{etJ# zR~xt1a6Ew;2728@=uZ{C+NUpHIKQy)++{5MlYS|l5b&P^H4}hxj}~;J?YK}6()4r zAzsEq-V{8Jow!>%1@bT_wEtgJ*J{yFS=@(K1@$a#cCNfrH(M&D#pTC zmhD+C=(B;yqHsb$ns88+^;s)=B8q>6`Tg-RK@OZHar}w0SD4AwI^-2%ZKcLSyH%M3 z8u3E;8IQrtrgzut+B@M76v1PNWfWmYeL$E27p|-BuaNmHs@1XLFcLHI}j)AG8-OL+Jpf#B~XXHpenI7D13M2jr zl6b1SN-$$4_Lwem(&h;s0Z9@Um<;VE0(5-#j-7xwj-4QKV<*b+VU?X}%oy!E=+HYx z;X;3m4Rmc$NSmI%er)ONxl^lxVDo+X{H!(QW5VDxtN8Jf$fL zupG!5(=Tf!+LA{J0waRlOP?#YPvd|9gMVbeAf)MbHg{tb!TG{~(L=;b+v=9Y- z>&z*E9)Z}@A@GtgXqVJWTW};Iagj(r97;hh0?5E}RgyQO2fdKZ%6So~FQO{6`b){gLnK}A!gpO7nCN<8ntt)c zYhWJ5F*vEO>g)}44Tq&DiIim)t;ZjaPMkh}ik+Ug7L}(IPu&v^PwsYsV}mpceIhJa z*P>*u=#5!<973tQa$5j#EsB*%N*VbVy>SDj@8KWxU{|3^<_RW~bdnU>TdqpcIOn^O zs~z#F7{IW51g4gd`MiAI;0dKvsG7(4t&3|Q=C~pnk(oGdJ?@Kx6I}Sv!cL1tFfOpZ z!s?7`$Hl@gy2H5q*Zo5ifpgVp*bCx0=S)(u2qh3P7iJ+EOa_T~^_vrRCL8V53Ua*J zHOh5a^T4M*gaI2>_jae6wfHi;!q@L&9oe^t-%g`2f*u-#bktw_h4P3Rf$&;{mLl(< zV#pIvTc*BIyG~e15ml2~oVYe$(dCT_m90V0G;MS1uMwIpVu};z+3+`tmu+!6r zG69@2Y@WNAHrhrkh9-NNF}&tVrM~&hHMe)s6{lS=$+X^%3-18BYS-e6{wk`7v_aQj zRN4j6y^N-1ePwze)j*(=ZFZTW0Y@BI!mP-h~V9YbYqi&6PAYpzZJtGb@ zV9mZ=E#q8)EW}tbg-^TmdeI0)uiuT-sM#Y_?u*t+n{5q*>1ZN4Lk+xpeHDvSrh8ya z2-m0$)oZ~(o1-A`)lI0LSakd3V)qdC(Dr051>9;#PisuI@E+^WNUlj^@6#YeAH-4k--oYvB2*$}I%#LEcaNnF34 zxSpU~xu@q4?+OOgwnRO=m$EFkRe1Ne( z?8ENKmV8GozUa_?}JeA{>lux|EJkIbb(;D*l_d@(jmZohd3R&Js-^I|4_KG zgqUDCX3`z5a6R!re$a|p;f9M{?2MjMXqy0Gjab4voT!rge7l?)(6R*7b!s305k55# zdN|yvfzbTtHlo5gpnV^oGxwPPnN+_2{?9COf{*9FkHOLX9d!Ofciia!u<{H;qA>rv z4*`ooT16%ubFgQ$2LP%SbO561AkP5l;7o)7b>i@ZHArE$Nvo5TQ`46~R3gz9(!W%Z zHZ+`^6DNDI%ZfC-2005*>=8(3N#=O5COaAz48l7 z%BM?^t#AOs^bnhx(pJxB#5Jrt#Y_`qnx>2Ny$m?x)*H{~Y79t=;eDuN-{z^=ZTfy3 zW#w2ffB@ke>dw?-%n0&R0nbIN6RjS%t}=iUxq2}czQSH4zaTCJzW_(PMPkOw=~0bV zfrC;@dkBArk+hCYwi0vS(E>v+O^hf)1j|4h^zv&k&;{i_Lb|f2SU3a%gYSG-24E9i zgX6OZuxA7e(#ZlklJ_(bVu(|?^cuh#g9p=@#g+;3N)ZUhkMW6Gj%^J(g}GT}7pt`@ z*~qmnyESB|_pDSB!LY%0Z>u?j9KrMt6ACfG07`h5O$V@^BfbRzx()7i08E88tnrz` zWsBfR*=Lll!4#BW7-bPMw4c#8)*b%m!fOl1xs-r27bsjj$!u?F4%2pUEzz#5K=H1gcOKwbe5-{)BPjw36Y z3_&oDGGH!X+%Dg)dL5S#=bQ2z+}NnItPL?$i>6lKge3{ek_(={*uAn3gOgMhF>$~z zL~xQQG$pwi>1m!zpX+2EM~E6f-iL>)ML2K-%o_kFz&Zk2m9fPjg_~bpreq34$AZ7P ze;M+Di>_7jJ8lV5(2Q`PU($W7Q6?P8i@i_vrRI&qD(+~j(E;aOdBI<-Ut<0DZe?THH zQD#6e+^*WtXhc|uF$I|-Q4>R-`wk!aL=)z40i`us33NWA5QvzSW_e$X>~gAFz&j9z zjfLr-8Bhp0m`@zOusk=CaWm1WVJz5al^ydT1yzaEZ=foKttHxl-Et7n8Ib z*l2X@4UFpe3$^{C(zM+BYv zgVYQlRH4v<`N2dfqKt82VqC8*;&duVRY4@Ti&d}obIk+2ZdgieEzGof=f@Q67D}gsUB>_K`v>7fxN^!e4UMWwYEr9Tdt)AjC%>{VNhU1gzyG zN*&Q`)E4}1u9oPPUqzlqDwA+C!^g^XRuj@dFJp5HK?lT)0`COL4@^_jyn%tSa?sG^ z5#}DU+e}c4r$;$x&P?9^c1|P62^jbFV8as1raHtf_5*A#`e4vl2AVFr9HsMF{k>Eu z2kSVsRR$Jld#tBJkwHjjyRhYCOpJZFs%YC{$XyM(1}1;Ts6Wo4BsTl^?TEco9z+m1ELMi>44d+2^xbogMlwtqH8ahFd@naEM0QO z+HbQv0WqdAZXf|2O}YU2d-cMlXXM(D2yR>nV+RH%c}dbC;Z~LCO>r_vW)h31m2fQ) zq!~OEJ)%M0XnLpMO_z+40vMfXK&-yKn04FtD;HKyp7VPVvSMN}8RlN3q6Li@9wd9} zVj8jmw%ud!sD#ilWu1O*c}4g<7?lc;*ZUq{hx}?lH1sVp0wUVr2LGPPA2oS{CCcJx?Qij))Lar;aCWA0HW*) zl%Z&cXK;|1p#oUSd7(h~z^}Oogp3vSy=mg$01)PGe8Da1^^H^*!|FSIui|Oo1%`mi zplO~4XPy{-r8Y#Js{0N+quir+eusul?npH+P{8@a=o*_i4giIP*)M2N(A}*m9a%=R z-a)r#&vY7dloiZ1R}zS;4du|kl;1P4fv{`|>P!cbb<>=%7GEX1Cbw^HN=h!oFd^QWmPXKVKjU_zU1U%w zFahO_*I7ZQHP05=YJ}9*k*O++wa6?LN2$J~&FUk=S=6OmGi&o|3tfNo-SZUh-quFZ zh1~=BRFTV_p2zBHTv|!%IP4Sp%xKu4x6q`UZFv8JPUGl^-d#mSMsB(?=j@~fIcF!j zgm&PGClWkrhvRbmBRPq7Y(m_Cj2sQ+NIp+zo;ucW5?mtxRc)uG7tpSoQ9!o95`;>L z`$C&M4-YG^WnFlNx`7f5zn`G{?i3GKl8F=~EJa@GhrW7vF9<`{OhFdc{z)^=kU}D< z4$s3K&nrj`>zZPLrw|GU9ECjW!fwNML=zq6(q29h0nS&e^;P}3DAeK>iRNT9(b0=Y zP9OoTH?>&MrRM{ES1u~)g5|3QqCiQ1wSyEf+jr(TkScbRQ39&1cyK?ZVG_NjM3^18 z)TFb(BP<_4MX;n?`xzG=35}g`SfQbl!Jm*svpH=ej2GhGW48n_!Uo zod>)dq^ERLM}!7aHw+3H70>^fCPCQ}UqW4?5oCS_tf!nOvmYBur!Fk5N))^76PZO> zU6;6LX$^a2*!EK09yw>4E!v+*1Fq^k32u8u74iERDVL?@C z#Lb;!)&(9B#13G1<)8{;ZeEL;t|R*1d=oiFdBr4ihRd)d;wn&hYv?qCg9U06C*$dWp>?RzfU{)+?h%SOhQcqfTVFE4a?Ok%(k$9olwFLmi7`_(R@0?@?LOuKk z>O&pnNmn2txTaXv1TzB0Psck@85tB|9zScJzaQW8iu z!d+{JFGD7A*`m)yvx-(O@Eg=;aF+>%K-3g4jHCI_slmyhgR9$Tv1QvV^W>AN2!69> zmFFI0gcN}GE>Lw5RC)m$aM#y3P>CJKQ+1)>nsWxW<7LFZPAp-ZD;tpVHm-z#J{-Bq zle^;2^VwQ&2UC60zlN6konUWX|Bf?|bl~@JI3=c_Q4h>Ahoo?gb{Hi55lo;pq6|Il z=u{XmO$eEO9&)rIX2543S*DFvi{rIAJ#Zk6*ZyH8GO39Ss(?>5NH7wdd{!k}pps=I zA3d86^S)XZE*}WosEjdy*NJIO~-nrb`YylH@5F zf&gGwe1tX~MEWB05H>a<(>S@hGgbwmz1mYSHw5qscv#vZE_DJEvW2^Js3vVla6uh& zMtjuJeWdm_6B{}2&33tNpYswye;qUpm=qqIaE76#vDhQ>!4K=ebGa>>lLzO<xY%|e z6M}Wr-xGm;nP7>GL?S0=F$At(OA-LlEkr;&i1L$?nvPl3D%CsY93`eCeE??Ss|IUi zlGJ%p>fnBGF^w^0mNmg$|Ex20{Y9HyG18dh;!m&(?+=gw9>{8cC6#uTq#a5C)0NH= z0ak-`Ls3lCf{C?#A)!VOLTW_z8=1rlRVxfU0!G*>&T3+#3O6W3o-_qJ4X<#4-oT+| z+(?v~ArsTm42(VBp1G;my^Ui~FSz^)r8KWPt?2nG`16 zI|cypWOCMX5jJi3X(~g}`#VOB0E9_G*?V6=IBo<2Q!j{tKoDu(b6WtQXxk9pP=o>( zvItMrMh^14KxfJ^!Z>`Wm{`yw|3JP5F?MAu+%8=u>2OLS2UXTFLd5rh;|f7sM#J2c z^ylhn{aWDv^0yvCxqku6ue(9T54SHcs|#OfL!f zoeHtX6a_#K#*7*3ddwJy?VldF$O#`Hf}GHeTNd-@}?pV89W))@ZbKIos#r_*El50C1hYwSw@1b4fJ2H97E^xsYY zPxqhy$>nG-^g$ZIgV*``PudLfv9fu)l26T7oJ`8jW4}lQw>q z=2FIg!s85&t9t`q-)rpjIG&ua=Ck=iA%(a?>oGPxHXTO5)`NoVng0?3H)!2K%aO=K z>VN4%HZ_+2gCh7o%56vg1P`^`2h858r2lU6Kby<++JBjRVI2S6i^oy?CT#}!So3+u z$=F5wnNL@eb9OPAv{S`mGMi7O3&lBSE>pJi^QF1LGAzPqoGDO)IsJx%Z%s$Yk2wpY%^ScjS)A9m?I$W72;&`Jc)6`Tx_IasIda zLHWRS^|Hg77vokRuQ=2rP55H?A;xoKi+yZ@4fu1&7;l1O!#BDeosyV3+Wttjflc?L z))_=UBXNkP^}+=>9wV+1hOng3Ae|f%8nMRw7`?lzvm)`I<{`bdz=kD9z zzH;;0Z~VoLw|?c!JO9(!SDuUiyO00EPyVepKK_o+eD*KB?*sq%pZrYetxv67`{U-j z7k=Rb&piIU8*hC2EkFI1%$;mLIq~ZsN>(m?ITgS4U*0|I{@j;-<_ot!yma%wEfribS9Zh74w<-OfpxjlnV3Z;TqaY z{$p|I)&CG6FpmEi4$J#1ednRaW}bfbN;2;xv&m#8^}gwur_X)%{ImA2J@HT9|Ao(e zX6k+4cYNjF|5Uv4FKX}q=sO}u=0E7jq3 zQmB+Nl|r^s!0oxox%6DJk|`wZ`9iLkN~N8-N`b^M{>A_Chj-rd%FqAd#kD8@-iLna zy?^wf|5=***>8U3?|$JA{_2;0=fkyM`EPFXxBk_~-uAIqYma>IH|C0G=0E;#vfupO zufFo<*FOBkrT_5eXFis5_S4yModd@}*Zdz*9Oypuf5iWd&;K7dv9|%(MSt9Fd25?8 z_xosH^vC_*qwz-ejOKNI{HUY6LT;9FPZM&|I6b}n6dsh5|VcT_3eM>?9;R7zx42tN8a^@ zk9HLp|9DMfplkl;hji=>h?#w?|EYX7IbQz<#P9tS>!E+5l1dlLbHyxkJrwihWOlAR zSIVSvd1r2}lBtx6^OZtwNH@`5@*k-n`t1J{7MZd9zaPpDR@aaJ_?OR{_z&3!w14ee z|L=qMKK<(5H+}R6PQ3GDv%fd}f4}&HfBm~wPA)+ z_3TG}_D4VTk6yX=@R66#z3ZEUwK^{KpkP4ezux39vM%j4|I>xO{hw5NeExf6^aumK zhC5fB8sL_mfdcxyG`6h z#+$=YMt_I%2#rrEh3UB8@9jD+Q7z#*pZx+Dpx$2dKbh*g|1&j?{}}+W2T&xS|9Cnb z05-q#Kp?gJNasE=vgiCy=6m)3WGXj~|2Pb868ewl{$Zf{fox-S6_@z9cW^Pp>ISaK zU0oePFFF5*JpY-_rN`%gMnH-_kbU$|fEDVkYHO>n=)NE9DgSZSt0(>=%bYM{|IfZr zK5)e}&ioW|2)8W6V36GzxZb$nYXbxQ^B?O$%vk>iG{imUKYTsC^S^-j@A3RUAOh?H hhFAFczMgbEmyD;z%HS^O8owH2V2pt=23|85`2SDbXC43m literal 0 HcmV?d00001 From 57fcbeabd30d2f39e3f5805c37e377135cc1b678 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 06:26:34 +0200 Subject: [PATCH 056/282] feat: show net lines in tix diffstats Keep Git-compatible per-file churn totals and scaled insertion/deletion bars, then append a right-aligned signed net line count computed as additions minus deletions. Color positive deltas green, negative deltas bright red, and zero neutrally; retain binary rows as Bin without inventing line information. Use the shared summary renderer so whole-commit built-in views, pager input, external-diff summaries, and commented new-commit editor diffstats agree. Preserve parent and aggregate insertion/deletion summaries. Cover positive, negative, zero, differently sized bars, binary files, streamed pager bytes, and the commit-editor template. Update the tix behavioral specification. --- gix-tix/spec.md | 9 ++-- gix-tix/src/edit/create.rs | 6 +-- gix-tix/src/lib.rs | 2 +- gix-tix/src/ui.rs | 96 +++++++++++++++++++++++++++++++------- 4 files changed, 88 insertions(+), 25 deletions(-) diff --git a/gix-tix/spec.md b/gix-tix/spec.md index 6249c8e8ca2..bf123359fdd 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -250,9 +250,10 @@ space first; changes blocks adapt within the remaining history width. - `Enter` in history opens the whole selected commit against the active parent. `Enter` in a focused changes block opens only its selected path. - A whole-commit diff starts with commit identity and a Git-style per-path - diffstat in diff order, followed by parent/root, kind totals, and aggregate line - totals. It then shows the internal patch and invokes any per-path external diff - drivers. + diffstat in diff order. Each textual path retains Git's churn count and bar, + followed by an aligned signed net `additions - deletions` count. Parent/root, + kind totals, and aggregate line totals follow before the internal patch and any + per-path external diff drivers. - Diff preparation honors Git attributes, text conversion, binary detection, external diff commands, and the configured `core.pager` pipeline. - Binary, submodule, conflicted, and otherwise unavailable file diffs do not @@ -315,7 +316,7 @@ space first; changes blocks adapt within the remaining history width. repository starts from the empty tree. - The Markdown editor buffer contains editable identities and dates, a `what` title, a `why` body, optional attribution trailers, and a commented Git-style - per-path diffstat. Commit hooks are not run. + per-path diffstat with signed net line counts. Commit hooks are not run. - After editing, tix revalidates the destination, applies configured signing, persists the already-prepared objects, and atomically advances every mutable direct ref pointing at the parent. This includes local branches, custom refs, diff --git a/gix-tix/src/edit/create.rs b/gix-tix/src/edit/create.rs index ae92bc0a9ac..0003abe288a 100644 --- a/gix-tix/src/edit/create.rs +++ b/gix-tix/src/edit/create.rs @@ -360,9 +360,9 @@ mod tests { assert!( prepared .document - .windows(b"tracked".len()) - .any(|window| window == b"tracked"), - "the editor buffer includes a commented per-file diffstat: {}", + .windows(b"tracked | 2 +- 0".len()) + .any(|window| window == b"tracked | 2 +- 0"), + "the editor buffer includes a commented per-file diffstat with net lines: {}", prepared.document.as_bstr() ); assert_eq!( diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index bd881e7664b..a1b492a4f0e 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -3973,7 +3973,7 @@ mod tests { diff.write_to(&mut streamed)?; assert!( streamed.starts_with( - format!("{title}\n topic | 1 +\n topic-extra | 1 +\nroot · A 2 · +2 \n\n").as_bytes() + format!("{title}\n topic | 1 + +1\n topic-extra | 1 + +1\nroot · A 2 · +2 \n\n").as_bytes() ), "the pager receives path statistics and the aggregate before the patch" ); diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 279a7eefde1..1d37922467c 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -1048,7 +1048,21 @@ pub(crate) fn commit_diff_summary( .map(|(added, removed)| u64::from(*added) + u64::from(*removed)) .max() .unwrap_or_default(); - let graph_width = max_changes.min(40); + let graph_width = max_changes.min(40) as usize; + let delta = |added: u32, removed: u32| i64::from(added) - i64::from(removed); + let format_delta = |delta: i64| { + if delta > 0 { + format!("+{delta}") + } else { + delta.to_string() + } + }; + let delta_width = line_counts + .iter() + .flatten() + .map(|(added, removed)| format_delta(delta(*added, *removed)).len()) + .max() + .unwrap_or_default(); let mut lines = paths .into_iter() .zip(line_counts) @@ -1060,10 +1074,25 @@ pub(crate) fn commit_diff_summary( let total = u64::from(*added) + u64::from(*removed); spans.push(Span::raw(format!("{total:>count_width$} "))); let scaled = |count: u32| { - (u64::from(count) * graph_width / max_changes.max(1)).max(u64::from(count > 0)) as usize + (u64::from(count) * graph_width as u64 / max_changes.max(1)).max(u64::from(count > 0)) as usize }; - spans.push(Span::styled("+".repeat(scaled(*added)), color(Color::Green))); - spans.push(Span::styled("-".repeat(scaled(*removed)), color(Color::LightRed))); + let added_width = scaled(*added); + let removed_width = scaled(*removed); + spans.push(Span::styled("+".repeat(added_width), color(Color::Green))); + spans.push(Span::styled("-".repeat(removed_width), color(Color::LightRed))); + let delta = delta(*added, *removed); + let delta = format_delta(delta); + spans.push(Span::raw(" ".repeat( + graph_width.saturating_sub(added_width + removed_width) + 1 + delta_width - delta.len(), + ))); + spans.push(Span::styled( + delta, + color(match added.cmp(removed) { + std::cmp::Ordering::Greater => Color::Green, + std::cmp::Ordering::Less => Color::LightRed, + std::cmp::Ordering::Equal => Color::Reset, + }), + )); } None => spans.push(Span::raw(format!("{:>count_width$}", "Bin"))), } @@ -1931,6 +1960,20 @@ mod tests { path: "old".into(), lines: None, }, + crate::app::PathChange { + kind: ChangeKind::Deleted, + group: ChangeGroup::Tree, + source: None, + path: "gone".into(), + lines: None, + }, + crate::app::PathChange { + kind: ChangeKind::Modified, + group: ChangeGroup::Tree, + source: None, + path: "image.bin".into(), + lines: None, + }, ], ..Changes::default() }; @@ -1938,35 +1981,54 @@ mod tests { title.clone(), ["--- a/old", "+++ b/old"].into_iter().map(Into::into).collect(), ) - .with_summary(commit_diff_summary(&changes, &[Some((2, 0)), Some((1, 1))], 3, 1)); + .with_summary(commit_diff_summary( + &changes, + &[Some((2, 0)), Some((1, 1)), Some((0, 3)), None], + 3, + 4, + )); let mut terminal = Terminal::new(TestBackend::new(64, 9))?; terminal.draw(|frame| draw_file_diff(frame, &diff, 0, 0))?; assert_eq!(rendered_line(&terminal, 0).trim(), title); - assert_eq!(rendered_line(&terminal, 1).trim(), "new | 2 ++"); - assert_eq!(rendered_line(&terminal, 2).trim(), "old | 2 +-"); - let summary = "root · A 1 + M 1 = 2 · +3 -1"; - assert_eq!(rendered_line(&terminal, 3).trim(), summary); + assert_eq!(rendered_line(&terminal, 1).trim(), "new | 2 ++ +2"); + assert_eq!(rendered_line(&terminal, 2).trim(), "old | 2 +- 0"); + assert_eq!(rendered_line(&terminal, 3).trim(), "gone | 3 --- -3"); + assert_eq!(rendered_line(&terminal, 4).trim(), "image.bin | Bin"); + let summary = "root · A 1 + M 2 + D 1 = 4 · +3 -4"; + assert_eq!(rendered_line(&terminal, 5).trim(), summary); let buffer = terminal.backend().buffer(); let summary_x = |needle| { summary[..summary.find(needle).expect("summary term is present")] .chars() .count() as u16 }; - assert_eq!(buffer[(0, 3)].fg, COMPARED_PARENT_COLOR); - assert_eq!(buffer[(summary_x("A 1"), 3)].fg, Color::Green); - assert_eq!(buffer[(summary_x("-1"), 3)].fg, Color::LightRed); - assert_eq!(buffer[(9, 1)].fg, Color::Green); - assert_eq!(buffer[(10, 2)].fg, Color::LightRed); - assert_eq!(rendered_line(&terminal, 4).trim(), ""); - assert_eq!(rendered_line(&terminal, 5).trim(), "--- a/old"); + assert_eq!(buffer[(0, 5)].fg, COMPARED_PARENT_COLOR); + assert_eq!(buffer[(summary_x("A 1"), 5)].fg, Color::Green); + assert_eq!(buffer[(summary_x("-4"), 5)].fg, Color::LightRed); + let delta_x = rendered_line(&terminal, 1).find("+2").expect("positive delta") as u16; + let zero_x = rendered_line(&terminal, 2).rfind('0').expect("zero delta") as u16; + assert_eq!( + delta_x + 2, + zero_x + 1, + "deltas are right-aligned despite different sign widths" + ); + assert_eq!( + delta_x, + rendered_line(&terminal, 3).find("-3").expect("negative delta") as u16 + ); + assert_eq!(buffer[(delta_x, 1)].fg, Color::Green); + assert_eq!(buffer[(zero_x, 2)].fg, Color::Reset); + assert_eq!(buffer[(delta_x, 3)].fg, Color::LightRed); + assert_eq!(rendered_line(&terminal, 6).trim(), ""); + assert_eq!(rendered_line(&terminal, 7).trim(), "--- a/old"); let mut streamed = Vec::new(); diff.write_to(&mut streamed)?; assert_eq!( streamed, - b"0101010 mapped author subject\n new | 2 ++\n old | 2 +-\nroot \xc2\xb7 A 1 + M 1 = 2 \xc2\xb7 +3 -1 \n\n--- a/old\n+++ b/old\n" + b"0101010 mapped author subject\n new | 2 ++ +2\n old | 2 +- 0\n gone | 3 --- -3\n image.bin | Bin\nroot \xc2\xb7 A 1 + M 2 + D 1 = 4 \xc2\xb7 +3 -4 \n\n--- a/old\n+++ b/old\n" ); Ok(()) } From fca490473216570f0610f7283933011d29fa00ed Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 07:03:48 +0200 Subject: [PATCH 057/282] fix: recover tix before processing filesystem events Treat the event-loop head as the repository-lifecycle boundary because the linked worktree and process working directory may disappear while terminal or filesystem input is pending. Recover to the lexically normalized common repository before watcher handling, cache invalidation, or redraw can open a short-lived fill repository. Switch the surviving common repository to bare mode, discard worktree-only watchers, caches and diff workers, rebuild reference watching, and schedule history refresh from the surviving graph. If a repository vanishes in the narrow interval before reference inspection, return to the boundary instead of aborting. Document the lifecycle invariant for future event-loop work and extend the isolated recovery regression to cover disappearance during event processing. --- gix-tix/AGENTS.md | 4 ++ gix-tix/spec.md | 10 +++-- gix-tix/src/lib.rs | 109 +++++++++++++++++++++++++++++---------------- 3 files changed, 80 insertions(+), 43 deletions(-) diff --git a/gix-tix/AGENTS.md b/gix-tix/AGENTS.md index ea79bb28845..c6f6856879b 100644 --- a/gix-tix/AGENTS.md +++ b/gix-tix/AGENTS.md @@ -15,6 +15,10 @@ ## Repository lifetime +- Treat the event-loop head as a repository-lifecycle boundary. After any wait, + the original worktree directory and process CWD may no longer exist; recover + to the normalized common repository before processing watchers, loading view + data, or drawing. - Do not retain a `gix::Repository`, or a platform/object that owns one, in application or event-loop state while tix is idle. - Open a fresh, non-isolated repository for bounded view population so configuration such as mailmap and diff filters is honored, then retain only detached display data. - The fill repository may be reused only while continuous navigation is active and must be dropped when its idle timer expires. diff --git a/gix-tix/spec.md b/gix-tix/spec.md index bf123359fdd..3cdb17bd307 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -391,10 +391,12 @@ space first; changes blocks adapt within the remaining history width. semantic trigger, coalesced paths, phases, presentation count, elapsed time, and outcome. Logs use the platform application-log directory, retain seven days, and are best-effort. -- If a linked worktree disappears, tix lexically normalizes and enters the common - repository, reopens it as bare, drops worktree state, keeps tree/history views - live, and reports recovery in the status line. If recovery fails, terminal state - is restored and the contextual error is returned. +- After every event-loop wait, tix assumes that the original worktree and process + working directory may have disappeared. Before processing filesystem events or + redrawing, it lexically normalizes and enters the common repository, reopens it + as bare, drops worktree state, keeps tree/history views live, and reports recovery + in the status line. If recovery fails, terminal state is restored and the + contextual error is returned. ## Resource and responsiveness invariants diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index a1b492a4f0e..e8c6f258021 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -966,6 +966,48 @@ fn event_loop( let mut history_status_deadline: Option = None; let mut pending_terminal_event = None; let result: Result> = (|| loop { + if let Some(mut recovered) = + recover_event_loop_repository(&mut repository_path, &common_dir, &mut repository_is_bare)? + { + recovered.object_cache_size(None); + mailmap = recovered.open_mailmap(); + fill_repository.path.clone_from(&repository_path); + fill_repository.bare = true; + fill_repository.retain = false; + fill_repository.retained = None; + app.set_worktree_changes_available(false); + worktree_watcher = None; + worktree_refresh_deadline = None; + worktree_watch_set_changed = false; + filesystem_responses.cancel_pending_worktree("worktree-unavailable"); + worktree_changes = None; + line_diff_pool = None; + sync_line_diff_pool( + &mut line_diff_pool, + app.changes_mode.is_some(), + &repository_path, + true, + line_diff_parallelism, + )?; + tracing::warn!(common_dir = %repository_path.display(), "worktree disappeared; recovered with common repository"); + ref_watcher = match start_ref_watcher(&repository_path, &repository_path) { + Ok(watcher) => Some(watcher), + Err(err) => { + tracing::warn!(error = %err, "reference watcher recovery failed"); + schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); + None + } + }; + ref_watch_set_changed = false; + app.manual_refresh = ref_watcher.is_none(); + app.notice = Some("worktree removed; using the common repository without worktree changes".into()); + if history_graph.is_some() { + refresh_pending = true; + refresh_from_filesystem = true; + } + dirty = true; + urgent = true; + } let mut worktree_watch_error = None; if let Some(watcher) = worktree_watcher.as_mut() { let mut received = 0; @@ -1244,45 +1286,7 @@ fn event_loop( let response_ids = filesystem_responses.begin_reference_refresh(); let repository = match open_repository(&repository_path, repository_is_bare, true) { Ok(repository) => repository, - Err(_err) if worktree_repository_is_gone(&repository_path) => { - let mut recovered = recover_common_repository(&common_dir) - .context("could not recover after the worktree repository disappeared")?; - recovered.object_cache_size(None); - repository_path.clone_from(&common_dir); - repository_is_bare = true; - mailmap = recovered.open_mailmap(); - fill_repository.path.clone_from(&repository_path); - fill_repository.bare = true; - fill_repository.retain = false; - fill_repository.retained = None; - app.set_worktree_changes_available(false); - worktree_watcher = None; - worktree_refresh_deadline = None; - worktree_watch_set_changed = false; - filesystem_responses.cancel_pending_worktree("worktree-unavailable"); - worktree_changes = None; - line_diff_pool = None; - sync_line_diff_pool( - &mut line_diff_pool, - app.changes_mode.is_some(), - &repository_path, - true, - line_diff_parallelism, - )?; - tracing::warn!(common_dir = %repository_path.display(), "worktree disappeared; recovered with common repository"); - ref_watcher = match start_ref_watcher(&repository_path, &repository_path) { - Ok(watcher) => Some(watcher), - Err(err) => { - tracing::warn!(error = %err, "reference watcher recovery failed"); - schedule_once(&mut watcher_retry_deadline, Instant::now(), WATCH_RETRY_INTERVAL); - None - } - }; - ref_watch_set_changed = false; - app.manual_refresh = ref_watcher.is_none(); - app.notice = Some("worktree removed; using the common repository without worktree changes".into()); - recovered - } + Err(_err) if worktree_repository_is_gone(&repository_path) => continue, Err(err) => return Err(err).context("could not inspect changed references"), }; let next = history::snapshot(&repository, &revisions, &hide, worktrees)?; @@ -2382,6 +2386,21 @@ fn recover_common_repository(common_dir: &Path) -> Result { .with_context(|| format!("could not open common repository at {} as bare", common_dir.display())) } +fn recover_event_loop_repository( + repository_path: &mut PathBuf, + common_dir: &Path, + bare: &mut bool, +) -> Result> { + if *bare || !worktree_repository_is_gone(repository_path) { + return Ok(None); + } + let repository = + recover_common_repository(common_dir).context("could not recover after the worktree repository disappeared")?; + common_dir.clone_into(repository_path); + *bare = true; + Ok(Some(repository)) +} + fn normalize_common_dir(common_dir: PathBuf) -> Result { let current_dir = std::env::current_dir().context("could not obtain current directory")?; gix::path::normalize(common_dir.into(), ¤t_dir) @@ -4122,6 +4141,18 @@ mod tests { Some(true), "recovery configures the common repository as bare" ); + + let mut stale_git_dir = git_dir.join("worktrees/deleted-during-event-loop"); + let mut bare = false; + assert!( + recover_event_loop_repository(&mut stale_git_dir, &git_dir, &mut bare)?.is_some(), + "the event-loop boundary recovers before its next action" + ); + assert_eq!( + stale_git_dir, git_dir, + "future event-loop opens use the common repository" + ); + assert!(bare, "future event-loop opens treat the common repository as bare"); return Ok(()); } From dcd79d6b47807a98b729a3b33e6273a7e343d478 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 10:09:01 +0200 Subject: [PATCH 058/282] feat: clarify active tix shortcut prefixes Make the main footer show when the display or edit prefix is active instead of replacing the prefix label with an unbounded run of shortcuts. Enclose the applicable commands in a bold `v active (...)` or `e active (...)` group so direct shortcuts remain visibly outside it. Keep display-toggle state dimming, omit edit operations that cannot act on the current selection, and show `no actions` when an edit prefix has no applicable command. Document the footer contract and cover grouping, emphasis, contextual edit actions, hidden-history availability, and the empty state. --- gix-tix/spec.md | 7 +++- gix-tix/src/ui.rs | 88 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/gix-tix/spec.md b/gix-tix/spec.md index 3cdb17bd307..085227352de 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -155,7 +155,9 @@ without trading responsiveness for metadata that is not visible. The display group remains open for consecutive display changes and closes on navigation or another recognized command. `[` and overlay controls remain direct -shortcuts. +shortcuts. While active, the footer renders a bold `v active (` marker, every +applicable display option, and a closing `)` so direct shortcuts remain visibly +outside the prefix. ### Time-travel @@ -352,6 +354,9 @@ space first; changes blocks adapt within the remaining history width. - Edit shortcuts keep the group open. Navigation or another recognized command closes it, matching the `v` display shortcut group. Plain `r` and `t` do not mutate the repository. +- While active, the footer renders a bold `e active (` marker and only the edit + actions available for the current selection, followed by `)`. An empty group + says `no actions`. - While the `v` group is open, `d`, `e`, `r`, and `t` retain their display meanings for dates, emails, references, and trailers. diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 1d37922467c..28132fcada8 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -658,9 +658,9 @@ pub(crate) fn draw_with_worktree( .iter() .any(|decoration| decoration.kind == DecorationKind::Pin) { - " · t return" + "t return" } else { - " · t travel" + "t travel" }, ) } else { @@ -670,22 +670,39 @@ pub(crate) fn draw_with_worktree( None }; if app.edit_expanded { - if let Some(label) = time_travel { - footer_spans.push(Span::raw(label)); - } + let mut options = Vec::new(); if app.reword_shortcut_visible() { - footer_spans.push(Span::raw(" · r reword")); + options.push("r reword"); } if app.can_create_commit() { - footer_spans.push(Span::raw(" · n new")); + options.push("n new"); } if app.can_forget() { - footer_spans.push(Span::raw(if app.forget_confirmation_visible() { - " · d again forget" + options.push(if app.forget_confirmation_visible() { + "d again forget" } else { - " · d forget" - })); + "d forget" + }); + } + if let Some(label) = time_travel { + options.push(label); + } + footer_spans.push(Span::raw(" · ")); + footer_spans.push(Span::styled( + "e active (", + Style::default().add_modifier(Modifier::BOLD), + )); + if options.is_empty() { + footer_spans.push(Span::raw("no actions")); + } else { + for (index, option) in options.into_iter().enumerate() { + if index > 0 { + footer_spans.push(Span::raw(" · ")); + } + footer_spans.push(Span::raw(option)); + } } + footer_spans.push(Span::raw(")")); } else if !app.history_display_expanded { footer_spans.push(Span::raw(" · e edit")); } @@ -703,7 +720,12 @@ pub(crate) fn draw_with_worktree( footer_spans.extend([Span::raw(" · "), toggle("o commit", app.show_commit)]); footer_spans.extend([Span::raw(" · "), toggle("c changes", app.changes_mode.is_some())]); if app.history_display_expanded { - footer_spans.extend([Span::raw(" · "), toggle("d date", app.show_committer_date)]); + footer_spans.push(Span::raw(" · ")); + footer_spans.push(Span::styled( + "v active (", + Style::default().add_modifier(Modifier::BOLD), + )); + footer_spans.push(toggle("d date", app.show_committer_date)); footer_spans.extend([Span::raw(" · "), toggle("e emails", app.show_emails)]); let (name_label, names_visible) = match app.name_mode { NameMode::All => ("n names", true), @@ -733,6 +755,7 @@ pub(crate) fn draw_with_worktree( ), ]); } + footer_spans.push(Span::raw(")")); } else { footer_spans.push(Span::raw(" · v view")); } @@ -2502,7 +2525,10 @@ mod tests { app.edit_expanded = true; terminal.draw(|frame| draw(frame, &mut app, &decorations))?; assert!(rendered_row(&terminal).contains("pin:01010101")); - assert!(rendered_line(&terminal, 1).contains("t return")); + assert!( + rendered_line(&terminal, 1).contains("e active (r reword · n new · d forget · t return)"), + "the active edit prefix contains exactly its actionable commands" + ); assert!(!rendered_line(&terminal, 1).contains("e edit")); decorations.remove(&selected); @@ -2511,6 +2537,42 @@ mod tests { Ok(()) } + #[test] + fn visually_groups_active_prefix_options() -> Result<(), Box> { + let mut app = App::new(1); + app.changes_mode = None; + app.configure_hidden_filter(true); + app.history_display_expanded = true; + let mut terminal = Terminal::new(TestBackend::new(180, 2))?; + + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + + let footer = rendered_line(&terminal, 1); + let view = "v active (d date · e emails · n names · m mailmap · t trailers · r no refs · h show hidden)"; + assert!( + footer.contains(view), + "the view prefix encloses all of its applicable options" + ); + let active = footer[..footer.find("v active").expect("the active view prefix is visible")] + .chars() + .count() as u16; + assert!( + terminal.backend().buffer()[(active, 1)] + .modifier + .contains(Modifier::BOLD), + "the active prefix label is emphasized" + ); + + app.history_display_expanded = false; + app.edit_expanded = true; + terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; + assert!( + rendered_line(&terminal, 1).contains("e active (no actions)"), + "an active prefix remains explicit when the current context offers no actions" + ); + Ok(()) + } + #[test] fn renders_worktree_labels_and_keeps_them_selected_when_refs_are_hidden() -> Result<(), Box> { From 23c2801c0b7fee47f8d989e351f746e1d213a705 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 10:15:07 +0200 Subject: [PATCH 059/282] feat: standardize transient tix messages Make App own the single path for global user feedback instead of allowing event-loop and rendering code to write its footer notice storage directly. Global command results and worktree-disappearance recovery now use `leave_message()`, while pane-specific errors stay with their panes and the next recognized action clears the message centrally. Explain an unchanged new-commit editor with `no commit created: no input was provided` instead of silently returning to history. Preserve empty edited messages as errors because those represent invalid supplied input. Document the transient-message lifetime and cover footer replacement, the no-input text, and clearing on the next action. --- gix-tix/spec.md | 5 +++++ gix-tix/src/app.rs | 14 +++++++++++--- gix-tix/src/lib.rs | 24 ++++++++++++------------ gix-tix/src/ui.rs | 16 ++++++++++++---- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/gix-tix/spec.md b/gix-tix/spec.md index 085227352de..659fc22932b 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -423,6 +423,11 @@ space first; changes blocks adapt within the remaining history width. - Main status remains readable regardless of pane focus. Errors are surfaced in the nearest relevant status line; diagnostics never replace user-visible errors. +- Global command and recovery feedback uses one transient message channel. A + message replaces the main status line until the next recognized user action; + pane-specific errors remain in their pane status line. +- Closing the new-commit editor without changing its prepared buffer leaves the + repository untouched and reports `no commit created: no input was provided`. ## Regression coverage diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index ae19edf7f16..c7036ccf936 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -364,7 +364,7 @@ pub(crate) struct App { reachable_rows: Option>, pub copy_feedback: Option, pub(crate) focus_feedback: Option<&'static str>, - pub(crate) notice: Option, + message: Option, pub(crate) unseen_filesystem_redraw: bool, pub(crate) history_display_expanded: bool, pub(crate) edit_expanded: bool, @@ -438,7 +438,7 @@ impl App { reachable_rows: None, copy_feedback: None, focus_feedback: None, - notice: None, + message: None, unseen_filesystem_redraw: false, history_display_expanded: false, edit_expanded: false, @@ -462,6 +462,14 @@ impl App { } } + pub(crate) fn leave_message(&mut self, message: impl Into) { + self.message = Some(message.into()); + } + + pub(crate) fn message(&self) -> Option<&str> { + self.message.as_deref() + } + pub(crate) fn configure_hidden_filter(&mut self, present: bool) { self.has_hidden_filter = present; if present { @@ -647,7 +655,7 @@ impl App { } pub fn update(&mut self, action: Action) -> Vec { - self.notice = None; + self.message = None; if !matches!(&action, Action::Forget) { self.forget_confirmation = None; } diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index e8c6f258021..b50b73039d9 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -890,7 +890,7 @@ fn event_loop( app.set_worktree_head_unborn(worktree_head_unborn); app.commit_pane_background = commit_pane_background; if recovered_at_startup { - app.notice = Some("worktree removed; using the common repository without worktree changes".into()); + app.leave_message("worktree removed; using the common repository without worktree changes"); } app.manual_refresh = ref_watcher.is_none(); let mut lane_receiver = None; @@ -1000,7 +1000,7 @@ fn event_loop( }; ref_watch_set_changed = false; app.manual_refresh = ref_watcher.is_none(); - app.notice = Some("worktree removed; using the common repository without worktree changes".into()); + app.leave_message("worktree removed; using the common repository without worktree changes"); if history_graph.is_some() { refresh_pending = true; refresh_from_filesystem = true; @@ -1684,14 +1684,14 @@ fn event_loop( .and_then(|diff| show_commit_diff(terminal, diff, enhanced_keyboard)); match result { Ok(true) => app.focus_history(), - Err(err) => app.notice = Some(format!("diff: {err:#}")), + Err(err) => app.leave_message(format!("diff: {err:#}")), Ok(false) => {} } } Effect::Reword(id) => { match reword_commit(terminal, &repository_path, repository_is_bare, id, enhanced_keyboard) { Ok(Some(new_id)) => { - app.notice = Some(format!( + app.leave_message(format!( "reworded {} as {}", id.to_hex_with_len(7), new_id.to_hex_with_len(7) @@ -1700,7 +1700,7 @@ fn event_loop( refresh_pending = true; } Ok(None) => {} - Err(err) => app.notice = Some(format!("reword: {err:#}")), + Err(err) => app.leave_message(format!("reword: {err:#}")), } } Effect::NewCommit(parent) => { @@ -1712,12 +1712,12 @@ fn event_loop( enhanced_keyboard, ) { Ok(Some(new_id)) => { - app.notice = Some(format!("created {}", new_id.to_hex_with_len(7))); + app.leave_message(format!("created {}", new_id.to_hex_with_len(7))); refresh_select_top_requested = true; refresh_pending = true; } - Ok(None) => {} - Err(err) => app.notice = Some(format!("new commit: {err:#}")), + Ok(None) => app.leave_message("no commit created: no input was provided"), + Err(err) => app.leave_message(format!("new commit: {err:#}")), } } Effect::Forget(id) => { @@ -1725,14 +1725,14 @@ fn event_loop( fill_repository.retained = None; match forget_commit(&repository_path, repository_is_bare, id) { Ok(parent) => { - app.notice = Some(format!("forgot {}", id.to_hex_with_len(7))); + app.leave_message(format!("forgot {}", id.to_hex_with_len(7))); if let Some(parent) = parent { app.select_commit(parent); } invalidate_worktree_changes(&mut worktree_changes); refresh_pending = true; } - Err(err) => app.notice = Some(format!("forget: {err:#}")), + Err(err) => app.leave_message(format!("forget: {err:#}")), } } Effect::TimeTravel(id) => { @@ -1754,12 +1754,12 @@ fn event_loop( match result { Ok(Some(notice)) => { tracing::info!(selected = %id, %notice, "completed time-travel action"); - app.notice = Some(notice); + app.leave_message(notice); invalidate_worktree_changes(&mut worktree_changes); refresh_pending = true; } Ok(None) => {} - Err(err) => app.notice = Some(format!("time-travel: {err:#}")), + Err(err) => app.leave_message(format!("time-travel: {err:#}")), } } Effect::VerifySignatures(ids) => { diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 28132fcada8..ed40927ac89 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -789,8 +789,8 @@ pub(crate) fn draw_with_worktree( } footer_spans.push(Span::raw(" · q quit")); } - if let Some(notice) = &app.notice { - footer_spans = vec![Span::raw(notice)]; + if let Some(message) = app.message() { + footer_spans = vec![Span::raw(message)]; } if app.unseen_filesystem_redraw { footer_spans = notification_discs(footer_spans); @@ -2362,7 +2362,7 @@ mod tests { ); app.unseen_filesystem_redraw = false; - app.notice = Some("worktree removed; using common repository".into()); + app.leave_message("worktree removed; using common repository"); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert_eq!( rendered_line(&terminal, 1).trim(), @@ -2372,13 +2372,21 @@ mod tests { app.history_display_expanded = true; app.update(Action::ToggleMailmap); - assert!(app.notice.is_none(), "the next action restores the normal status"); + assert!(app.message().is_none(), "the next action restores the normal status"); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!( rendered_row(&terminal).contains(" author subject"), "m restores the original author name" ); assert!(footer_is_dim(&terminal, "m mailmap"), "disabled mailmap is dimmed"); + + app.leave_message("no commit created: no input was provided"); + terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; + assert_eq!( + rendered_line(&terminal, 1).trim(), + "no commit created: no input was provided", + "an unchanged new-commit editor explains why nothing happened" + ); app.update(Action::ToggleMailmap); app.update(Action::ToggleDate); From 60d8bb430c1a60a6806bc005705f27815a9c43a3 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 10:18:38 +0200 Subject: [PATCH 060/282] feat: prioritize tix prefixes in history status Place the view prefix immediately after the history position and the edit prefix immediately after it, before navigation and other direct shortcuts. Keep active prefix groups in the same priority position so their available commands remain visible on narrow terminals. Do not advertise the edit prefix while the view prefix is active because `e` then toggles email display. Preserve all key handling and contextual availability semantics. Update the behavioral specification and footer regressions for inactive, active, deferred-progress, and narrow-history layouts. --- gix-tix/spec.md | 3 ++- gix-tix/src/ui.rs | 63 ++++++++++++++++++++++++++--------------------- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/gix-tix/spec.md b/gix-tix/spec.md index 659fc22932b..77b473a361d 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -157,7 +157,8 @@ The display group remains open for consecutive display changes and closes on navigation or another recognized command. `[` and overlay controls remain direct shortcuts. While active, the footer renders a bold `v active (` marker, every applicable display option, and a closing `)` so direct shortcuts remain visibly -outside the prefix. +outside the prefix. The history status starts with the history position, then the +`v` prefix and the `e` prefix when it is addressable, before navigation shortcuts. ### Time-travel diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index ed40927ac89..e054264fbdb 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -635,10 +635,8 @@ pub(crate) fn draw_with_worktree( State::Complete => "", State::Cancelled => " · cancelled", }; - let mut footer_spans = vec![Span::raw(format!( - "{}{status} · ↑↓/jk move · h/l pan", - history_position(app) - ))]; + let mut footer_spans = vec![Span::raw(format!("{status} · ↑↓/jk move · h/l pan"))]; + let mut edit_prefix_spans = Vec::new(); if app.changes_focus.is_none() { footer_spans.push(Span::raw(" · Enter diff")); let time_travel = if app.time_travel_shortcut_visible() @@ -687,24 +685,24 @@ pub(crate) fn draw_with_worktree( if let Some(label) = time_travel { options.push(label); } - footer_spans.push(Span::raw(" · ")); - footer_spans.push(Span::styled( + edit_prefix_spans.push(Span::raw(" · ")); + edit_prefix_spans.push(Span::styled( "e active (", Style::default().add_modifier(Modifier::BOLD), )); if options.is_empty() { - footer_spans.push(Span::raw("no actions")); + edit_prefix_spans.push(Span::raw("no actions")); } else { for (index, option) in options.into_iter().enumerate() { if index > 0 { - footer_spans.push(Span::raw(" · ")); + edit_prefix_spans.push(Span::raw(" · ")); } - footer_spans.push(Span::raw(option)); + edit_prefix_spans.push(Span::raw(option)); } } - footer_spans.push(Span::raw(")")); + edit_prefix_spans.push(Span::raw(")")); } else if !app.history_display_expanded { - footer_spans.push(Span::raw(" · e edit")); + edit_prefix_spans.push(Span::raw(" · e edit")); } } if app.tree_changes_visible || app.worktree_changes_visible { @@ -719,31 +717,32 @@ pub(crate) fn draw_with_worktree( footer_spans.extend([Span::raw(" · "), toggle("[ align", app.align_metadata)]); footer_spans.extend([Span::raw(" · "), toggle("o commit", app.show_commit)]); footer_spans.extend([Span::raw(" · "), toggle("c changes", app.changes_mode.is_some())]); + let mut view_prefix_spans = Vec::new(); if app.history_display_expanded { - footer_spans.push(Span::raw(" · ")); - footer_spans.push(Span::styled( + view_prefix_spans.push(Span::raw(" · ")); + view_prefix_spans.push(Span::styled( "v active (", Style::default().add_modifier(Modifier::BOLD), )); - footer_spans.push(toggle("d date", app.show_committer_date)); - footer_spans.extend([Span::raw(" · "), toggle("e emails", app.show_emails)]); + view_prefix_spans.push(toggle("d date", app.show_committer_date)); + view_prefix_spans.extend([Span::raw(" · "), toggle("e emails", app.show_emails)]); let (name_label, names_visible) = match app.name_mode { NameMode::All => ("n names", true), NameMode::Author => ("n name", true), NameMode::None => ("n name", false), }; - footer_spans.extend([Span::raw(" · "), toggle(name_label, names_visible)]); + view_prefix_spans.extend([Span::raw(" · "), toggle(name_label, names_visible)]); for (label, enabled) in [("m mailmap", app.use_mailmap), ("t trailers", app.show_trailers)] { - footer_spans.extend([Span::raw(" · "), toggle(label, enabled)]); + view_prefix_spans.extend([Span::raw(" · "), toggle(label, enabled)]); } let ref_label = match app.ref_mode { RefMode::All => "r all refs", RefMode::Default => "r refs", RefMode::None => "r no refs", }; - footer_spans.extend([Span::raw(" · "), toggle(ref_label, app.ref_mode != RefMode::None)]); + view_prefix_spans.extend([Span::raw(" · "), toggle(ref_label, app.ref_mode != RefMode::None)]); if app.has_hidden_filter { - footer_spans.extend([ + view_prefix_spans.extend([ Span::raw(" · "), toggle( if app.show_hidden { @@ -755,10 +754,15 @@ pub(crate) fn draw_with_worktree( ), ]); } - footer_spans.push(Span::raw(")")); + view_prefix_spans.push(Span::raw(")")); } else { - footer_spans.push(Span::raw(" · v view")); + view_prefix_spans.push(Span::raw(" · v view")); } + let mut ordered = vec![Span::raw(history_position(app))]; + ordered.append(&mut view_prefix_spans); + ordered.append(&mut edit_prefix_spans); + ordered.append(&mut footer_spans); + footer_spans = ordered; if app.preview_author_copy && app.manual_refresh { footer_spans.extend([ Span::raw(" · "), @@ -1801,7 +1805,7 @@ mod tests { terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; let computing = rendered_line(&terminal, 1); assert!( - computing.contains("1 commits · computing"), + computing.contains("1 commits · v view · e edit · computing"), "expired deferral reveals computation progress" ); assert_ne!(computing, completed, "visible progress changes the footer"); @@ -2287,7 +2291,7 @@ mod tests { terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; - let footer_text = "#1 · ↑↓/jk move · h/l pan · Enter diff · e edit · [ align · o commit · c changes · v view · y copy · q quit"; + let footer_text = "#1 · v view · e edit · ↑↓/jk move · h/l pan · Enter diff · [ align · o commit · c changes · y copy · q quit"; let selected_line = "> @ 0101010 1970-01-01 mapped author subject"; let mut expected = Buffer::with_lines([format!("{selected_line:<180}"), format!("{footer_text:<180}")]); for x in 0..11 { @@ -2558,8 +2562,8 @@ mod tests { let footer = rendered_line(&terminal, 1); let view = "v active (d date · e emails · n names · m mailmap · t trailers · r no refs · h show hidden)"; assert!( - footer.contains(view), - "the view prefix encloses all of its applicable options" + footer.starts_with(&format!("0 commits · {view} · ↑↓/jk move")), + "the active view prefix follows the history position" ); let active = footer[..footer.find("v active").expect("the active view prefix is visible")] .chars() @@ -2575,8 +2579,8 @@ mod tests { app.edit_expanded = true; terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; assert!( - rendered_line(&terminal, 1).contains("e active (no actions)"), - "an active prefix remains explicit when the current context offers no actions" + rendered_line(&terminal, 1).starts_with("0 commits · v view · e active (no actions) · ↑↓/jk move"), + "the edit prefix follows the view prefix even when no action is available" ); Ok(()) } @@ -4390,7 +4394,10 @@ mod tests { !rendered_row(&terminal).contains("0101010"), "[ restores natural post-graph placement" ); - assert!(footer_is_dim(&terminal, "[ align"), "disabled alignment is dimmed"); + assert!( + !app.align_metadata, + "alignment is disabled even when its later footer hint is clipped" + ); app.update(Action::ScrollRight); terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; From 03b8f8f41355b5e3f7ac453de3a6941d5d06951a Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 10:26:48 +0200 Subject: [PATCH 061/282] feat: streamline tix status shortcuts Replace redundant prefix-key-plus-verb labels with compact action labels whose shortcut characters are underlined. Apply the convention consistently to the history footer, expanded view and edit groups, changes blocks, and commit-message status, while keeping navigation chords and other keys explicit when they cannot be embedded naturally. Order the history footer as position, display and edit groups, top-level viewport actions, and finally navigation. Name the commit-message viewport toggle open message or close message so it cannot be confused with creating a commit in the edit group. Show active prefix choices directly in parentheses, making the former active suffix unnecessary. Render the Enter key consistently as throughout history, changes, and diff status bars. Update the behavioral specification and terminal-buffer assertions to cover the labels, styling, ordering, and disabled state. --- gix-tix/spec.md | 14 ++- gix-tix/src/ui.rs | 277 +++++++++++++++++++++++++++++----------------- 2 files changed, 185 insertions(+), 106 deletions(-) diff --git a/gix-tix/spec.md b/gix-tix/spec.md index 77b473a361d..ad99cffce97 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -155,10 +155,14 @@ without trading responsiveness for metadata that is not visible. The display group remains open for consecutive display changes and closes on navigation or another recognized command. `[` and overlay controls remain direct -shortcuts. While active, the footer renders a bold `v active (` marker, every -applicable display option, and a closing `)` so direct shortcuts remain visibly +shortcuts. The footer underlines the `v` in `view`; while active, `view (` contains +every applicable display option and a closing `)` so direct shortcuts remain visibly outside the prefix. The history status starts with the history position, then the -`v` prefix and the `e` prefix when it is addressable, before navigation shortcuts. +`v` prefix and the `e` prefix when it is addressable. Remaining history-level +actions follow before movement, panning, and diff navigation shortcuts. +All status lines embed and underline a shortcut character in its action label when +possible; keys that cannot be expressed naturally in the label remain explicit. +The Enter key is written as `` throughout. ### Time-travel @@ -209,6 +213,8 @@ space first; changes blocks adapt within the remaining history width. - `o` or `]` toggles the commit view on the right. It uses at most half the terminal and reserves 80 content columns when space permits. +- Its history-status action says `open message` or `close message`, avoiding + confusion with the edit group's commit-creation action. - The panel has a minimally shaded background derived from the detected terminal background, with the default background as fallback. - The title begins on the first content row and is bold. Body text follows, then @@ -355,7 +361,7 @@ space first; changes blocks adapt within the remaining history width. - Edit shortcuts keep the group open. Navigation or another recognized command closes it, matching the `v` display shortcut group. Plain `r` and `t` do not mutate the repository. -- While active, the footer renders a bold `e active (` marker and only the edit +- The footer underlines the `e` in `edit`; while active, `edit (` contains only the actions available for the current selection, followed by `)`. An empty group says `no actions`. - While the `v` group is open, `d`, `e`, `r`, and `t` retain their display diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index e054264fbdb..d6f831e8e85 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -173,7 +173,7 @@ pub(crate) fn draw_file_diff(frame: &mut Frame<'_>, diff: &BuiltInDiff, offset: body, ); frame.render_widget( - Paragraph::new("↑↓/jk move · h/l pan · Enter/q/Esc back").style(Style::default().add_modifier(Modifier::DIM)), + Paragraph::new("↑↓/jk move · h/l pan · /q/Esc back").style(Style::default().add_modifier(Modifier::DIM)), footer, ); } @@ -568,20 +568,26 @@ pub(crate) fn draw_with_worktree( ), color(COMPARED_PARENT_COLOR), ), - Span::raw(" · p next parent · "), + Span::raw(" · "), ]); + spans.extend(shortcut("next parent", 'p', true)); + spans.push(Span::raw(" · ")); } if let Some(error) = &app.changes(pane).error { spans.push(Span::styled(format!("diff: {error}"), color(Color::LightRed))); } else { - spans.push(Span::raw("↑↓/jk move · h/l pan · Enter diff")); + spans.push(Span::raw("↑↓/jk move · h/l pan · diff")); + } + spans.push(Span::raw(" · ")); + spans.extend(shortcut("copy", 'y', true)); + if let Some(label) = match app.changes_mode { + Some(ChangesMode::Both) => Some("cycle tree"), + Some(ChangesMode::Tree) => Some("close"), + None => None, + } { + spans.push(Span::raw(" · ")); + spans.extend(shortcut(label, 'c', true)); } - spans.push(Span::raw(" · y copy")); - spans.push(Span::raw(match app.changes_mode { - Some(ChangesMode::Both) => " · c tree", - Some(ChangesMode::Tree) => " · c to hide", - None => "", - })); frame.render_widget( Paragraph::new(Line::from(spans)).style(Style::default().bg(PANE_STATUS_BACKGROUND)), status, @@ -615,8 +621,13 @@ pub(crate) fn draw_with_worktree( app.set_commit_bounds(area.height as usize, max_offset); if max_offset > 0 { frame.render_widget( - Paragraph::new("PgUp/C-b up page · PgDn/C-f down page · o to hide") - .style(Style::default().bg(PANE_STATUS_BACKGROUND)), + Paragraph::new(Line::from(vec![ + Span::raw("PgUp/C-b up page · PgDn/C-f down page · "), + Span::raw("cl"), + Span::styled("o", Style::default().add_modifier(Modifier::UNDERLINED)), + Span::raw("se"), + ])) + .style(Style::default().bg(PANE_STATUS_BACKGROUND)), Rect::new( outer.x.saturating_add(2), outer.bottom().saturating_sub(1), @@ -635,10 +646,9 @@ pub(crate) fn draw_with_worktree( State::Complete => "", State::Cancelled => " · cancelled", }; - let mut footer_spans = vec![Span::raw(format!("{status} · ↑↓/jk move · h/l pan"))]; + let mut footer_spans = vec![Span::raw(status)]; let mut edit_prefix_spans = Vec::new(); if app.changes_focus.is_none() { - footer_spans.push(Span::raw(" · Enter diff")); let time_travel = if app.time_travel_shortcut_visible() && decorations .values() @@ -656,9 +666,9 @@ pub(crate) fn draw_with_worktree( .iter() .any(|decoration| decoration.kind == DecorationKind::Pin) { - "t return" + ("return", 't') } else { - "t travel" + ("travel", 't') }, ) } else { @@ -670,39 +680,39 @@ pub(crate) fn draw_with_worktree( if app.edit_expanded { let mut options = Vec::new(); if app.reword_shortcut_visible() { - options.push("r reword"); + options.push(("reword", 'r')); } if app.can_create_commit() { - options.push("n new"); + options.push(("new", 'n')); } if app.can_forget() { options.push(if app.forget_confirmation_visible() { - "d again forget" + ("d again forget", 'd') } else { - "d forget" + ("d forget", 'd') }); } if let Some(label) = time_travel { options.push(label); } edit_prefix_spans.push(Span::raw(" · ")); - edit_prefix_spans.push(Span::styled( - "e active (", - Style::default().add_modifier(Modifier::BOLD), - )); + edit_prefix_spans.push(Span::styled("e", Style::default().add_modifier(Modifier::UNDERLINED))); + edit_prefix_spans.push(Span::raw("dit (")); if options.is_empty() { edit_prefix_spans.push(Span::raw("no actions")); } else { - for (index, option) in options.into_iter().enumerate() { + for (index, (label, key)) in options.into_iter().enumerate() { if index > 0 { edit_prefix_spans.push(Span::raw(" · ")); } - edit_prefix_spans.push(Span::raw(option)); + edit_prefix_spans.extend(shortcut(label, key, true)); } } edit_prefix_spans.push(Span::raw(")")); } else if !app.history_display_expanded { - edit_prefix_spans.push(Span::raw(" · e edit")); + edit_prefix_spans.push(Span::raw(" · ")); + edit_prefix_spans.push(Span::styled("e", Style::default().add_modifier(Modifier::UNDERLINED))); + edit_prefix_spans.push(Span::raw("dit")); } } if app.tree_changes_visible || app.worktree_changes_visible { @@ -715,48 +725,57 @@ pub(crate) fn draw_with_worktree( footer_spans.push(Span::raw(" · q/Esc history")); } footer_spans.extend([Span::raw(" · "), toggle("[ align", app.align_metadata)]); - footer_spans.extend([Span::raw(" · "), toggle("o commit", app.show_commit)]); - footer_spans.extend([Span::raw(" · "), toggle("c changes", app.changes_mode.is_some())]); + footer_spans.push(Span::raw(" · ")); + footer_spans.extend(shortcut( + if app.show_commit { + "close message" + } else { + "open message" + }, + 'o', + app.show_commit, + )); + footer_spans.push(Span::raw(" · ")); + footer_spans.extend(shortcut("cycle changes", 'c', app.changes_mode.is_some())); let mut view_prefix_spans = Vec::new(); if app.history_display_expanded { view_prefix_spans.push(Span::raw(" · ")); - view_prefix_spans.push(Span::styled( - "v active (", - Style::default().add_modifier(Modifier::BOLD), - )); - view_prefix_spans.push(toggle("d date", app.show_committer_date)); - view_prefix_spans.extend([Span::raw(" · "), toggle("e emails", app.show_emails)]); + view_prefix_spans.push(Span::styled("v", Style::default().add_modifier(Modifier::UNDERLINED))); + view_prefix_spans.push(Span::raw("iew (")); + view_prefix_spans.extend(shortcut("date", 'd', app.show_committer_date)); + view_prefix_spans.push(Span::raw(" · ")); + view_prefix_spans.extend(shortcut("emails", 'e', app.show_emails)); let (name_label, names_visible) = match app.name_mode { - NameMode::All => ("n names", true), - NameMode::Author => ("n name", true), - NameMode::None => ("n name", false), + NameMode::All => ("names", true), + NameMode::Author => ("name", true), + NameMode::None => ("name", false), }; - view_prefix_spans.extend([Span::raw(" · "), toggle(name_label, names_visible)]); - for (label, enabled) in [("m mailmap", app.use_mailmap), ("t trailers", app.show_trailers)] { - view_prefix_spans.extend([Span::raw(" · "), toggle(label, enabled)]); + view_prefix_spans.push(Span::raw(" · ")); + view_prefix_spans.extend(shortcut(name_label, 'n', names_visible)); + for (label, key, enabled) in [("mailmap", 'm', app.use_mailmap), ("trailers", 't', app.show_trailers)] { + view_prefix_spans.push(Span::raw(" · ")); + view_prefix_spans.extend(shortcut(label, key, enabled)); } let ref_label = match app.ref_mode { - RefMode::All => "r all refs", - RefMode::Default => "r refs", - RefMode::None => "r no refs", + RefMode::All => "all refs", + RefMode::Default => "refs", + RefMode::None => "no refs", }; - view_prefix_spans.extend([Span::raw(" · "), toggle(ref_label, app.ref_mode != RefMode::None)]); + view_prefix_spans.push(Span::raw(" · ")); + view_prefix_spans.extend(shortcut(ref_label, 'r', app.ref_mode != RefMode::None)); if app.has_hidden_filter { - view_prefix_spans.extend([ - Span::raw(" · "), - toggle( - if app.show_hidden { - "h hide hidden" - } else { - "h show hidden" - }, - app.show_hidden, - ), - ]); + view_prefix_spans.push(Span::raw(" · ")); + view_prefix_spans.extend(shortcut( + if app.show_hidden { "hide hidden" } else { "show hidden" }, + 'h', + app.show_hidden, + )); } view_prefix_spans.push(Span::raw(")")); } else { - view_prefix_spans.push(Span::raw(" · v view")); + view_prefix_spans.push(Span::raw(" · ")); + view_prefix_spans.push(Span::styled("v", Style::default().add_modifier(Modifier::UNDERLINED))); + view_prefix_spans.push(Span::raw("iew")); } let mut ordered = vec![Span::raw(history_position(app))]; ordered.append(&mut view_prefix_spans); @@ -764,16 +783,19 @@ pub(crate) fn draw_with_worktree( ordered.append(&mut footer_spans); footer_spans = ordered; if app.preview_author_copy && app.manual_refresh { - footer_spans.extend([ - Span::raw(" · "), - toggle("R refresh", matches!(history_state, State::Complete | State::Cancelled)), - ]); + footer_spans.push(Span::raw(" · ")); + footer_spans.extend(shortcut( + "Refresh", + 'R', + matches!(history_state, State::Complete | State::Cancelled), + )); } - footer_spans.push(Span::raw(if app.preview_author_copy { - " · Y copy author" + footer_spans.push(Span::raw(" · ")); + footer_spans.extend(if app.preview_author_copy { + shortcut("copY author", 'Y', true) } else { - " · y copy" - })); + shortcut("copy", 'y', true) + }); if app.signature_failures > 0 { footer_spans.extend([ Span::raw(format!(" · s {} ", app.signature_failures)), @@ -791,7 +813,12 @@ pub(crate) fn draw_with_worktree( if history_state == State::Loading { footer_spans.push(Span::raw(" · Esc cancel")); } - footer_spans.push(Span::raw(" · q quit")); + footer_spans.push(Span::raw(" · ")); + footer_spans.extend(shortcut("quit", 'q', true)); + } + footer_spans.push(Span::raw(" · ↑↓/jk move · h/l pan")); + if app.changes_focus.is_none() { + footer_spans.push(Span::raw(" · diff")); } if let Some(message) = app.message() { footer_spans = vec![Span::raw(message)]; @@ -1407,6 +1434,21 @@ fn toggle(label: &'static str, enabled: bool) -> Span<'static> { ) } +fn shortcut(label: &'static str, key: char, enabled: bool) -> Vec> { + let key_start = label.find(key).expect("shortcut key is present in its label"); + let key_end = key_start + key.len_utf8(); + let style = if enabled { + Style::default() + } else { + Style::default().add_modifier(Modifier::DIM) + }; + vec![ + Span::styled(&label[..key_start], style), + Span::styled(&label[key_start..key_end], style.add_modifier(Modifier::UNDERLINED)), + Span::styled(&label[key_end..], style), + ] +} + fn notification_discs(spans: Vec>) -> Vec> { let mut out = Vec::with_capacity(spans.len()); for span in spans { @@ -1739,6 +1781,14 @@ mod tests { app.finish_lane_computation(rows, lanes, lane_time); } + #[test] + fn embeds_status_shortcuts_in_their_labels() { + let spans = shortcut("copy", 'y', false); + assert_eq!(Line::from(spans.clone()).to_string(), "copy"); + assert!(spans[1].style.add_modifier.contains(Modifier::UNDERLINED)); + assert!(spans[1].style.add_modifier.contains(Modifier::DIM)); + } + #[test] fn counts_commits_until_the_graph_is_complete_then_tracks_the_selected_row() { let mut app = App::new(3); @@ -1805,7 +1855,7 @@ mod tests { terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; let computing = rendered_line(&terminal, 1); assert!( - computing.contains("1 commits · v view · e edit · computing"), + computing.contains("1 commits · view · edit · computing"), "expired deferral reveals computation progress" ); assert_ne!(computing, completed, "visible progress changes the footer"); @@ -1932,7 +1982,7 @@ mod tests { .map(Into::into) .collect(), ); - let mut terminal = Terminal::new(TestBackend::new(40, 7))?; + let mut terminal = Terminal::new(TestBackend::new(48, 7))?; terminal.draw(|frame| draw_file_diff(frame, &diff, 0, 0))?; @@ -1946,7 +1996,7 @@ mod tests { ] { assert_eq!(terminal.backend().buffer()[(0, y)].fg, color); } - assert!(rendered_line(&terminal, 6).contains("Enter/q/Esc back")); + assert!(rendered_line(&terminal, 6).contains("/q/Esc back")); Ok(()) } @@ -2119,7 +2169,7 @@ mod tests { terminal.draw(|frame| super::draw(frame, &mut app, &Decorations::new(), &mailmap, None, None))?; assert!( - !rendered_line(&terminal, 1).contains("e edit"), + !rendered_line(&terminal, 1).contains(" · edit ·"), "the view group keeps e reserved for toggling emails" ); @@ -2147,7 +2197,7 @@ mod tests { assert_eq!(style_at("Human"), Color::Green, "human trailer actors are green"); assert_eq!(style_at("[Claude]"), Color::Green, "bot co-authors use agent styling"); assert!( - rendered_line(&terminal, 1).contains("t trailers"), + rendered_line(&terminal, 1).contains("trailers"), "the footer advertises the trailer toggle" ); @@ -2291,7 +2341,7 @@ mod tests { terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; - let footer_text = "#1 · v view · e edit · ↑↓/jk move · h/l pan · Enter diff · [ align · o commit · c changes · y copy · q quit"; + let footer_text = "#1 · view · edit · [ align · open message · cycle changes · copy · quit · ↑↓/jk move · h/l pan · diff"; let selected_line = "> @ 0101010 1970-01-01 mapped author subject"; let mut expected = Buffer::with_lines([format!("{selected_line:<180}"), format!("{footer_text:<180}")]); for x in 0..11 { @@ -2315,12 +2365,30 @@ mod tests { } expected[(selected_line.chars().count() as u16 + 2, 0)] .set_style(Style::default().fg(Color::Blue).add_modifier(Modifier::REVERSED)); - let commit = footer_text[..footer_text.find("o commit").expect("the commit toggle is present")] + let message = footer_text[..footer_text.find("open message").expect("the message toggle is present")] .chars() .count(); - for x in commit..commit + "o commit".len() { + for x in message..message + "open message".len() { expected[(x as u16, 1)].set_style(Style::default().add_modifier(Modifier::DIM)); } + for (label, key) in [ + ("view", 'v'), + ("edit", 'e'), + ("open message", 'o'), + ("cycle changes", 'c'), + ("copy", 'y'), + ("quit", 'q'), + ] { + let label_start = footer_text[..footer_text.find(label).expect("shortcut label is present")] + .chars() + .count(); + let key_offset = label[..label.find(key).expect("shortcut key is in its label")] + .chars() + .count(); + expected[((label_start + key_offset) as u16, 1)] + .modifier + .insert(Modifier::UNDERLINED); + } terminal.backend().assert_buffer(&expected); let row = terminal.backend().buffer(); @@ -2382,7 +2450,7 @@ mod tests { rendered_row(&terminal).contains(" author subject"), "m restores the original author name" ); - assert!(footer_is_dim(&terminal, "m mailmap"), "disabled mailmap is dimmed"); + assert!(footer_is_dim(&terminal, "mailmap"), "disabled mailmap is dimmed"); app.leave_message("no commit created: no input was provided"); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; @@ -2404,8 +2472,8 @@ mod tests { ); assert!(!row.contains("refs/patches"), "special refs are hidden until requested"); assert!(row.contains("subject"), "the commit subject remains visible"); - assert!(footer_is_dim(&terminal, "d date"), "disabled date is dimmed"); - assert!(footer_is_dim(&terminal, "n name"), "disabled name is dimmed"); + assert!(footer_is_dim(&terminal, "date"), "disabled date is dimmed"); + assert!(footer_is_dim(&terminal, "name"), "disabled name is dimmed"); app.update(Action::ToggleName); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; @@ -2414,7 +2482,7 @@ mod tests { "the second n restores the author name" ); assert!( - !footer_is_dim(&terminal, "n name"), + !footer_is_dim(&terminal, "name"), "the restored name mode is not dimmed" ); @@ -2429,7 +2497,7 @@ mod tests { !rendered_row(&terminal).contains("refs/patches"), "no refs hides special refs" ); - assert!(footer_is_dim(&terminal, "r no refs"), "no refs is dimmed"); + assert!(footer_is_dim(&terminal, "no refs"), "no refs is dimmed"); app.update(Action::ToggleRefs); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; @@ -2441,7 +2509,7 @@ mod tests { rendered_row(&terminal).contains("refs/patches"), "all refs shows special refs" ); - assert!(!footer_is_dim(&terminal, "r all refs"), "all refs is not dimmed"); + assert!(!footer_is_dim(&terminal, "all refs"), "all refs is not dimmed"); app.update(Action::ToggleRefs); terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; @@ -2453,18 +2521,18 @@ mod tests { !rendered_row(&terminal).contains("refs/patches"), "refs hides special refs" ); - assert!(!footer_is_dim(&terminal, "r refs"), "refs is not dimmed"); + assert!(!footer_is_dim(&terminal, "refs"), "refs is not dimmed"); app.has_hidden_filter = true; terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!( - rendered_line(&terminal, 1).contains("h show hidden"), + rendered_line(&terminal, 1).contains("show hidden"), "the footer advertises the configured hidden-history toggle" ); app.show_hidden = true; terminal.draw(|frame| super::draw(frame, &mut app, &decorations, &mailmap, None, None))?; assert!( - rendered_line(&terminal, 1).contains("h hide hidden"), + rendered_line(&terminal, 1).contains("hide hidden"), "the footer reflects the unfiltered view" ); @@ -2486,15 +2554,15 @@ mod tests { "the author takes the copy color" ); assert!( - rendered_line(&terminal, 1).contains("Y copy author"), + rendered_line(&terminal, 1).contains("copY author"), "the footer previews the shifted shortcut" ); assert!( - rendered_line(&terminal, 1).contains("R refresh"), + rendered_line(&terminal, 1).contains("Refresh"), "the footer previews the shifted refresh shortcut" ); assert!( - !rendered_line(&terminal, 1).contains("r refs"), + !rendered_line(&terminal, 1).contains(" · refs"), "the shifted refresh shortcut replaces the reference toggle" ); Ok(()) @@ -2538,14 +2606,14 @@ mod tests { terminal.draw(|frame| draw(frame, &mut app, &decorations))?; assert!(rendered_row(&terminal).contains("pin:01010101")); assert!( - rendered_line(&terminal, 1).contains("e active (r reword · n new · d forget · t return)"), + rendered_line(&terminal, 1).contains("edit (reword · new · d forget · return)"), "the active edit prefix contains exactly its actionable commands" ); - assert!(!rendered_line(&terminal, 1).contains("e edit")); + assert!(!rendered_line(&terminal, 1).contains(" · edit ·")); decorations.remove(&selected); terminal.draw(|frame| draw(frame, &mut app, &decorations))?; - assert!(rendered_line(&terminal, 1).contains("t travel")); + assert!(rendered_line(&terminal, 1).contains("travel")); Ok(()) } @@ -2560,26 +2628,26 @@ mod tests { terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; let footer = rendered_line(&terminal, 1); - let view = "v active (d date · e emails · n names · m mailmap · t trailers · r no refs · h show hidden)"; + let view = "view (date · emails · names · mailmap · trailers · no refs · show hidden)"; assert!( - footer.starts_with(&format!("0 commits · {view} · ↑↓/jk move")), + footer.starts_with(&format!("0 commits · {view} · [ align")), "the active view prefix follows the history position" ); - let active = footer[..footer.find("v active").expect("the active view prefix is visible")] + let active = footer[..footer.find("view (").expect("the active view prefix is visible")] .chars() .count() as u16; assert!( terminal.backend().buffer()[(active, 1)] .modifier - .contains(Modifier::BOLD), - "the active prefix label is emphasized" + .contains(Modifier::UNDERLINED), + "the prefix key is underlined in its verb" ); app.history_display_expanded = false; app.edit_expanded = true; terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; assert!( - rendered_line(&terminal, 1).starts_with("0 commits · v view · e active (no actions) · ↑↓/jk move"), + rendered_line(&terminal, 1).starts_with("0 commits · view · edit (no actions) · [ align"), "the edit prefix follows the view prefix even when no action is available" ); Ok(()) @@ -3004,7 +3072,10 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(120, 6))?; terminal.draw(|frame| draw(frame, &mut app, &Decorations::new()))?; - assert!(footer_is_dim(&terminal, "o commit"), "the closed commit pane is dimmed"); + assert!( + footer_is_dim(&terminal, "open message"), + "the closed commit pane is dimmed" + ); app.update(Action::ToggleCommit); terminal.draw(|frame| { @@ -3048,7 +3119,7 @@ mod tests { "the commit body remains separated from its title" ); assert!( - !footer_is_dim(&terminal, "o commit"), + !footer_is_dim(&terminal, "close message"), "the open commit pane is not dimmed" ); @@ -3446,7 +3517,7 @@ mod tests { "repeated history navigation temporarily hides the changes pane" ); assert!( - app.changes_mode.is_some() && !footer_is_dim(&terminal, "c changes"), + app.changes_mode.is_some() && !footer_is_dim(&terminal, "cycle changes"), "temporary suppression leaves the persistent changes setting enabled" ); app.changes_suppressed = false; @@ -3541,11 +3612,11 @@ mod tests { assert!(rendered_line(&terminal, 14).contains("↑↓/jk move · h/l pan")); assert!( - rendered_line(&terminal, 14).contains("Enter diff · y copy · c tree"), + rendered_line(&terminal, 14).contains(" diff · copy · cycle tree"), "the changes pane advertises the next cycle mode" ); assert!( - rendered_line(&terminal, 14).contains("y copy"), + rendered_line(&terminal, 14).contains("copy"), "the changes pane advertises path copying" ); @@ -3626,7 +3697,7 @@ mod tests { ); assert!( rendered_line(&terminal, 14).contains( - "vs parent 1/2 0202020 · p next parent · ↑↓/jk move · h/l pan · Enter diff · y copy · c tree" + "vs parent 1/2 0202020 · next parent · ↑↓/jk move · h/l pan · diff · copy · cycle tree" ), "merge diffs keep parent controls alongside navigation" ); @@ -3641,7 +3712,7 @@ mod tests { "the compared parent's hash is inverted" ); assert!( - !rendered_line(&terminal, 15).contains("p next parent"), + !rendered_line(&terminal, 15).contains("next parent"), "parent cycling is absent from the main status line" ); @@ -4448,7 +4519,9 @@ mod tests { fn footer_is_dim(terminal: &Terminal, label: &str) -> bool { let y = terminal.backend().buffer().area.height - 1; let footer = rendered_line(terminal, y); - let x = footer[..footer.find(label).expect("toggle is visible")].chars().count() as u16; + let x = footer[..footer.rfind(label).expect("toggle is visible")] + .chars() + .count() as u16; terminal.backend().buffer()[(x, y)].modifier.contains(Modifier::DIM) } } From 0bdd80df04e8656170c4531126df1eebd3145cb1 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 13:10:33 +0200 Subject: [PATCH 062/282] feat: rebase linear descendants for tix history edits Allow reword, commit insertion, and forgetting from arbitrary points in a linear history instead of limiting edits to the newest commit. Introduce one transactional edit::rebase primitive that prepares all rewritten commits and cherry-picked trees in object memory, preserves forks, rejects descendant merges, and aborts the complete operation on conflicts. Mutable local references across the rewritten set move in one compare-and-swap transaction; tags and remote-tracking refs remain immutable. Model tree handling explicitly with LeaveAsIs, LeaveAsIsAndMark, and CherryPick. The marker form writes tix-rebase: pending, and Repeat resumes from a marked base, cherry-picks the marked range, and clears the markers. Model signatures with RedoIfNeeded and InvalidateExisting, respecting repository signing configuration while applying one current committer identity and timestamp to automatically rebased descendants. Preflight every affected accessible worktree through Git before refs move. Apply checkout transitions only after all preparation succeeds, and roll back already-updated worktrees and refs if a later checkout fails. Commit insertion preserves worktree bytes while updating affected indexes to the new committed tree. Stale unrelated linked worktrees are ignored. Route reword, create, and forget through this primitive and remove the former parallel MutableRefs implementation. Permit edits with linear descendants while retaining merge safety, and update the behavioral specification. Add scenario fixtures and snapshots for middle-stack rewrites, removal with tree transplanting, deferred rebase replay, and conflict atomicity. --- gix-tix/Cargo.toml | 2 +- gix-tix/spec.md | 57 +- gix-tix/src/app.rs | 30 +- gix-tix/src/edit/create.rs | 168 +--- gix-tix/src/edit/forget.rs | 104 +-- gix-tix/src/edit/mod.rs | 44 +- gix-tix/src/edit/rebase.rs | 825 ++++++++++++++++++ gix-tix/src/edit/refs.rs | 174 ---- gix-tix/src/edit/reword.rs | 52 +- ..._rebase__tests__reworded-middle-stack.snap | 56 ++ gix-tix/src/history.rs | 62 ++ gix-tix/src/lib.rs | 59 +- .../generated-archives/rebase_conflict.tar | Bin 0 -> 70656 bytes .../generated-archives/rebase_edit.tar | Bin 0 -> 75264 bytes gix-tix/tests/fixtures/rebase_conflict.sh | 15 + gix-tix/tests/fixtures/rebase_edit.sh | 22 + 16 files changed, 1221 insertions(+), 449 deletions(-) create mode 100644 gix-tix/src/edit/rebase.rs delete mode 100644 gix-tix/src/edit/refs.rs create mode 100644 gix-tix/src/edit/snapshots/gix_tix__edit__rebase__tests__reworded-middle-stack.snap create mode 100644 gix-tix/tests/fixtures/generated-archives/rebase_conflict.tar create mode 100644 gix-tix/tests/fixtures/generated-archives/rebase_edit.tar create mode 100644 gix-tix/tests/fixtures/rebase_conflict.sh create mode 100644 gix-tix/tests/fixtures/rebase_edit.sh diff --git a/gix-tix/Cargo.toml b/gix-tix/Cargo.toml index 9f4572fed21..ea9e0e23481 100644 --- a/gix-tix/Cargo.toml +++ b/gix-tix/Cargo.toml @@ -26,7 +26,7 @@ sha256 = ["gix/sha256"] anyhow = "1.0.98" crossterm = { version = "0.29.0", features = ["osc52"] } directories = "6.0.0" -gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["blob-diff", "mailmap", "notes", "parallel", "revision", "command", "status"] } +gix = { version = "^0.86.0", path = "../gix", default-features = false, features = ["blob-diff", "mailmap", "merge", "notes", "parallel", "revision", "command", "sha1", "status"] } notify = "8.2.0" ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } terminal-colorsaurus = "1.0.3" diff --git a/gix-tix/spec.md b/gix-tix/spec.md index ad99cffce97..937248ecbd0 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -288,8 +288,8 @@ space first; changes blocks adapt within the remaining history width. ### Reword -- `e`, then `r`, is available only after history completion and only when the - selected commit has no descendants in tix's complete cached graph. +- `e`, then `r`, is available after history completion when no known descendant + of the selected commit is a merge commit. - The configured Git editor receives a document containing `Author`, `AuthorDate`, `Committer`, `CommitterDate`, `CommentChar`, and the complete message in a temporary `.md` file for syntax highlighting. Author identity and @@ -302,9 +302,9 @@ space first; changes blocks adapt within the remaining history width. opt-ins. A case-insensitive existing trailer key suppresses its suggestion, regardless of value. - An unchanged editor document is a no-op. Otherwise tix recreates the commit, - signs it when commit-signing configuration is enabled, and atomically retargets - mutable local refs that pointed directly at the old commit. Tags and - remote-tracking refs remain unchanged; a detached `HEAD` is retargeted. + signs it when commit-signing configuration is enabled, and rewrites every + linear descendant with unchanged trees and corrected parentage. Mutable refs + follow every rewritten commit; tags and remote-tracking refs remain unchanged. - Editor, signing, parsing, writing, or reference-update failures are shown in the main status line and do not leave a repository retained by the UI. @@ -312,9 +312,9 @@ space first; changes blocks adapt within the remaining history width. - `e`, then `n`, creates a child of the selected commit, or a root commit for an unborn `HEAD`. It is available only with a live worktree, after history - completion, and when the selected parent has no known descendants. + completion, and when the selected parent has no known merge descendant. - Before launching the editor, tix resolves identities, signing configuration, - every mutable direct ref, linked-worktree safety, index conflicts, filters, + index conflicts, filters, candidate tree, per-path diffstat, and a provisional commit entirely through an in-memory object database. Cancellation and preflight failure write no object, reference, index, or worktree state. @@ -327,23 +327,22 @@ space first; changes blocks adapt within the remaining history width. title, a `why` body, optional attribution trailers, and a commented Git-style per-path diffstat with signed net line counts. Commit hooks are not run. - After editing, tix revalidates the destination, applies configured signing, - persists the already-prepared objects, and atomically advances every mutable - direct ref pointing at the parent. This includes local branches, custom refs, - direct tix pins, and a detached `HEAD`, while excluding tags and remote-tracking - refs. An attached `HEAD` remains attached; an unrelated worktree `HEAD` is left - untouched. A ref changed by another process or a branch checked out in another - worktree aborts safely. + rebases linear descendants, persists the prepared objects, and atomically + advances mutable refs throughout the rewritten stack. This includes local + branches, custom refs, direct tix pins, and a detached `HEAD`, while excluding + tags and remote-tracking refs. Checked-out affected worktrees are preflighted; + inaccessible or conflicting affected worktrees abort safely. ### Forget commits - `e`, then `d`, is available after history completion for a selected non-merge - commit with no descendants in the complete cached graph. The first `d` arms a + commit with no known merge descendant. The first `d` arms a `d again forget` confirmation; the second performs it. Navigation, refresh, cancellation, selection changes, and other commands disarm confirmation. -- Forgetting does not require a worktree. Every mutable direct ref pointing at - the commit is atomically retargeted to its parent, or deleted for a root. - Tags and remote-tracking refs remain unchanged. A branch checked out in another - worktree aborts before mutation. +- Forgetting does not require a worktree. Linear descendants are cherry-picked + onto the selected commit's parent, and mutable refs throughout the rewritten + stack move atomically. Empty commits only need parent rewriting. Tags and + remote-tracking refs remain unchanged. - When the selected commit is the current worktree `HEAD`, Git preflights and applies a two-tree index/worktree transition which discards only that commit's tracked delta. Conflicting staged, tracked, or untracked state refuses the @@ -353,6 +352,28 @@ space first; changes blocks adapt within the remaining history width. unborn. A selected detached root is rejected because it cannot produce a valid unborn `HEAD`. Success refreshes history and selects the parent when present. +### Transactional rebases + +- Reword, new-commit, and forget edits share one in-memory rebase primitive. + Forks are preserved, descendant merges are rejected, and all commit/tree + preparation—including cherry-pick conflict detection—finishes before objects + become reachable through refs. +- `Tree::LeaveAsIs` rewrites parentage without changing trees; + `LeaveAsIsAndMark` additionally writes `tix-rebase: pending`; and `CherryPick` + transplants each tree delta, aborting the entire edit on unresolved conflicts. + A marked operation can be repeated from its marked base and clears all markers + after a successful cherry-pick. +- `Signature::RedoIfNeeded` signs every rewritten commit when signing is + configured and otherwise removes stale signature headers. + `InvalidateExisting` empties existing signature values when signing is + configured, or removes them when it is not. Automatically rebased descendants + retain their author and receive one configured current committer identity and + timestamp for the operation. +- All mutable local refs pointing anywhere into the rewritten set are changed in + one compare-and-swap transaction. A checkout failure rolls back already-applied + worktree transitions and the ref transaction; newly written unreachable objects + may remain for normal Git garbage collection. + ### Editing shortcuts - `e` toggles the edit shortcut group. `e r` rewords, `e n` creates a commit, diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index c7036ccf936..6b005d5aaf0 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -380,6 +380,7 @@ pub(crate) struct App { worktree_head_has_descendants: bool, worktree_head_unborn: bool, known_descendants: HashSet, + known_merge_descendants: HashSet, select_top_after_refresh: bool, pub(crate) signature_failures: usize, signature_verification_running: bool, @@ -454,6 +455,7 @@ impl App { worktree_head_has_descendants: false, worktree_head_unborn: false, known_descendants: HashSet::new(), + known_merge_descendants: HashSet::new(), select_top_after_refresh: false, signature_failures: 0, signature_verification_running: false, @@ -492,6 +494,10 @@ impl App { self.update_worktree_head_descendants(); } + pub(crate) fn set_known_merge_descendants(&mut self, ids: HashSet) { + self.known_merge_descendants = ids; + } + pub(crate) fn worktree_head_has_descendants(&self, id: ObjectId) -> bool { self.worktree_head == Some(id) && self.worktree_head_has_descendants } @@ -1322,7 +1328,7 @@ impl App { && self .selected .and_then(|index| self.rows.get(index)) - .is_some_and(|row| !self.has_known_descendant(row.id)) + .is_some_and(|row| !self.known_merge_descendants.contains(&row.id)) } pub(crate) fn can_create_commit(&self) -> bool { @@ -1331,7 +1337,7 @@ impl App { && self.changes_focus.is_none() && self.deferred_history_state.unwrap_or(self.state) == State::Complete && match self.selected.and_then(|index| self.rows.get(index)) { - Some(row) => !self.has_known_descendant(row.id), + Some(row) => !self.known_merge_descendants.contains(&row.id), None => self.worktree_head_unborn, } } @@ -1343,7 +1349,7 @@ impl App { && self .selected .and_then(|index| self.rows.get(index)) - .is_some_and(|row| row.parent_ids.len() <= 1 && !self.has_known_descendant(row.id)) + .is_some_and(|row| row.parent_ids.len() <= 1 && !self.known_merge_descendants.contains(&row.id)) } pub(crate) fn forget_confirmation_visible(&self) -> bool { @@ -2013,7 +2019,7 @@ mod tests { } #[test] - fn only_a_completed_history_row_without_descendants_can_be_reworded() { + fn completed_non_merge_stacks_can_be_reworded_from_any_row() { let mut app = App::new(10); app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); assert!(!app.can_reword(), "loading history cannot be reworded"); @@ -2021,11 +2027,8 @@ mod tests { assert_eq!(app.update(Action::Reword), vec![Effect::Reword(id(2))]); app.update(Action::MoveDown); - assert!( - !app.can_reword(), - "a commit with a visible descendant cannot be reworded" - ); - assert!(app.update(Action::Reword).is_empty()); + assert!(app.can_reword(), "linear descendants can be rebased after rewording"); + assert_eq!(app.update(Action::Reword), vec![Effect::Reword(id(1))]); } #[test] @@ -2042,7 +2045,7 @@ mod tests { assert!(app.forget_confirmation_visible()); app.update(Action::MoveDown); assert!(!app.forget_confirmation_visible(), "navigation cancels confirmation"); - assert!(!app.can_forget(), "a commit with a descendant cannot be forgotten"); + assert!(app.can_forget(), "a commit with linear descendants can be forgotten"); app.update(Action::MoveUp); assert!(app.update(Action::Forget).is_empty()); assert_eq!(app.update(Action::Forget), vec![Effect::Forget(id(2))]); @@ -2054,18 +2057,19 @@ mod tests { } #[test] - fn editing_requires_no_known_descendants_and_new_commits_support_unborn_head() { + fn editing_rejects_merge_descendants_and_new_commits_support_unborn_head() { let mut app = App::new(10); app.extend_commits(vec![row(2)]); complete(&mut app); app.set_known_descendants(HashSet::from([id(2)])); + app.set_known_merge_descendants(HashSet::from([id(2)])); assert!( !app.can_reword(), - "a descendant outside the visible projection still prevents rewording" + "a merge descendant outside the visible projection prevents rewording" ); assert!( !app.can_create_commit(), - "a descendant outside the visible projection still prevents a child" + "a merge descendant outside the visible projection prevents a child" ); let mut unborn = App::new(10); diff --git a/gix-tix/src/edit/create.rs b/gix-tix/src/edit/create.rs index 0003abe288a..2c45abf09a9 100644 --- a/gix-tix/src/edit/create.rs +++ b/gix-tix/src/edit/create.rs @@ -1,31 +1,23 @@ -use std::{ffi::OsString, path::Path}; +use std::ffi::OsString; use anyhow::{Context, Result}; -use gix::{ObjectId, bstr::ByteSlice, objs::Write}; +use gix::{ObjectId, bstr::ByteSlice}; use crate::{ ChangeGroup, ChangeKind, ComparedParent, add_line_counts, load_tree_changes_without_lines, load_worktree_changes_without_lines, ui, }; -use super::{refs::MutableRefs, reword, time_travel}; +use super::{rebase, reword}; pub(crate) struct Prepared { pub editor: OsString, pub document: Vec, parent: Option, tree: ObjectId, - references: MutableRefs, - checkout: Checkout, objects: gix::odb::memory::Storage, } -enum Checkout { - None, - Branch(gix::refs::FullName), - Detached, -} - pub(crate) fn prepare(mut repo: gix::Repository, parent: Option) -> Result { repo.workdir().context("creating a commit requires a worktree")?; let head = repo.head().context("could not read HEAD before creating a commit")?; @@ -37,31 +29,9 @@ pub(crate) fn prepare(mut repo: gix::Repository, parent: Option) -> Re repo.find_commit(parent) .context("could not find the selected parent commit")?; } - let (references, checkout) = match parent { - Some(parent) => { - let references = MutableRefs::pointing_to(&repo, parent)?; - if references.is_empty() { - anyhow::bail!("no mutable reference points to the selected parent"); - } - references.ensure_not_checked_out_elsewhere(&repo)?; - let checkout = if head_id == Some(parent) { - match head.referent_name() { - Some(name) => Checkout::Branch(name.to_owned()), - None => Checkout::Detached, - } - } else { - Checkout::None - }; - (references, checkout) - } - None => { - let name = head - .referent_name() - .context("an unborn HEAD must point to a branch")? - .to_owned(); - (MutableRefs::unborn(&repo)?, Checkout::Branch(name)) - } - }; + if parent.is_none() { + head.referent_name().context("an unborn HEAD must point to a branch")?; + } let editor = repo.editor().context("no Git editor is available")?; let author = repo .author() @@ -159,8 +129,6 @@ pub(crate) fn prepare(mut repo: gix::Repository, parent: Option) -> Re document, parent, tree, - references, - checkout, objects, }) } @@ -209,25 +177,18 @@ fn worktree_tree(repo: &gix::Repository, baseline: &gix::Tree<'_>) -> Result Result { +pub(crate) fn apply( + mut repo: gix::Repository, + graph: &crate::history::HistoryGraph, + mut prepared: Prepared, + edited: &[u8], +) -> Result { let edit = reword::parse(edited)?; if edit.message.is_empty() { anyhow::bail!("the edited commit message is empty"); } - prepared.references.validate(&repo)?; - let references = match prepared.parent { - Some(parent) => MutableRefs::pointing_to(&repo, parent)?, - None => MutableRefs::unborn(&repo)?, - }; - if references.is_empty() { - anyhow::bail!("no mutable reference points to the selected parent anymore"); - } - references.ensure_not_checked_out_elsewhere(&repo)?; - let signing = repo - .commit_signing_options_if_enabled() - .context("could not resolve commit signing configuration")?; repo.objects.set_object_memory(std::mem::take(&mut prepared.objects)); - let mut commit = gix::objs::Commit { + let commit = gix::objs::Commit { message: edit.message, tree: prepared.tree, author: reword::actor(edit.author, edit.author_time, "author")?, @@ -236,83 +197,32 @@ pub(crate) fn apply(mut repo: gix::Repository, mut prepared: Prepared, edited: & parents: prepared.parent.into_iter().collect(), extra_headers: Vec::new(), }; - if let Some(options) = signing { - commit = commit.sign(options).context("could not sign the new commit")?; - } - let new_id = repo - .write_object(&commit) - .context("could not prepare the final commit")? - .detach(); - let objects = repo - .objects - .take_object_memory() - .context("candidate object memory was unavailable")?; - for (id, (kind, data)) in objects.iter() { - repo.write_buf_with_known_id(*kind, data, *id) - .map_err(|err| anyhow::anyhow!("could not persist a prepared commit object: {err}"))?; - } - let log_message = gix::reference::log::message("commit", commit.message.as_bstr(), commit.parents.len()); - let mut time_buf = gix::date::parse::TimeBuf::default(); - references.update( - &repo, - new_id, - log_message.as_bstr(), - Some(commit.committer.to_ref(&mut time_buf)), - )?; - let checkout = match &prepared.checkout { - Checkout::None => Ok(()), - Checkout::Branch(name) => { - let workdir = repo.workdir().context("creating a commit requires a worktree")?; - let branch = name - .as_bstr() - .strip_prefix(b"refs/heads/") - .context("the destination isn't a local branch")?; - time_travel::checkout( - workdir, - [ - OsString::from("--no-guess"), - gix::path::from_bstr(branch.as_bstr()).into_owned().into_os_string(), - ], - ) - .and_then(|()| reset_index(workdir, new_id)) - } - Checkout::Detached => { - let workdir = repo.workdir().context("creating a commit requires a worktree")?; - time_travel::checkout_detached(workdir, new_id) - } + let base_tree = match prepared.parent { + Some(parent) => repo.find_commit(parent)?.tree_id()?.detach(), + None => repo.empty_tree().id, }; - if let Err(err) = checkout { - let rollback = references.rollback(&repo, new_id); - return match rollback { - Ok(()) => Err(err), - Err(rollback) => Err(err.context(format!("the destination could not be rolled back: {rollback:#}"))), - }; - } - Ok(new_id) -} - -fn reset_index(workdir: &Path, id: ObjectId) -> Result<()> { - let output = std::process::Command::new("git") - .arg("-C") - .arg(workdir) - .args(["reset", "--mixed", "--quiet"]) - .arg(id.to_string()) - .output() - .context("could not update the index after checkout")?; - if output.status.success() { - Ok(()) + let tree_mode = if base_tree == commit.tree { + rebase::Tree::LeaveAsIs } else { - anyhow::bail!( - "git reset failed with {}: {}", - output.status, - output.stderr.to_str_lossy().trim() - ) - } + rebase::Tree::CherryPick + }; + rebase::perform( + repo, + graph, + rebase::Edit::Insert { + anchor: prepared.parent, + commit, + }, + rebase::Signature::RedoIfNeeded, + tree_mode, + )? + .selected + .context("inserting a commit did not produce a selection") } #[cfg(test)] mod tests { - use std::process::Command; + use std::{path::Path, process::Command}; use super::*; @@ -386,7 +296,8 @@ mod tests { ); let edited = prepared.document.replacen(b"what\n\nwhy", b"title\n\nbody", 1); - let new_id = apply(open(fixture.path())?, prepared, &edited)?; + let graph = super::super::loaded_graph(&open(fixture.path())?)?; + let new_id = apply(open(fixture.path())?, &graph, prepared, &edited)?; let after = gix_testtools::repository::snapshot(fixture.path())?; assert_eq!( after.head, @@ -447,7 +358,8 @@ mod tests { let parent = open(fixture.path())?.head_id()?.detach(); let prepared = prepare(open(fixture.path())?, Some(parent))?; let edited = prepared.document.replacen(b"what\n\nwhy", b"worktree\n\nstate", 1); - let new_id = apply(open(fixture.path())?, prepared, &edited)?; + let graph = super::super::loaded_graph(&open(fixture.path())?)?; + let new_id = apply(open(fixture.path())?, &graph, prepared, &edited)?; let after = gix_testtools::repository::snapshot(fixture.path())?; let repository = open(fixture.path())?; let commit = repository.find_commit(new_id)?; @@ -487,7 +399,8 @@ mod tests { "root-commit preflight is unobservable" ); let edited = prepared.document.replacen(b"what\n\nwhy", b"root\n\nreason", 1); - let new_id = apply(open(fixture.path())?, prepared, &edited)?; + let graph = super::super::loaded_graph(&open(fixture.path())?)?; + let new_id = apply(open(fixture.path())?, &graph, prepared, &edited)?; let repository = open(fixture.path())?; let commit = repository.find_commit(new_id)?; assert!(commit.parent_ids().next().is_none(), "the root has no parent"); @@ -544,7 +457,8 @@ mod tests { let before = gix_testtools::repository::snapshot(fixture.path())?; let prepared = prepare(open(fixture.path())?, Some(parent))?; let edited = prepared.document.replacen(b"what\n\nwhy", b"child\n\nreason", 1); - let new_id = apply(open(fixture.path())?, prepared, &edited)?; + let graph = super::super::loaded_graph(&open(fixture.path())?)?; + let new_id = apply(open(fixture.path())?, &graph, prepared, &edited)?; let after = gix_testtools::repository::snapshot(fixture.path())?; assert_eq!( after.head, before.head, diff --git a/gix-tix/src/edit/forget.rs b/gix-tix/src/edit/forget.rs index 5f0536a92c6..6286e576790 100644 --- a/gix-tix/src/edit/forget.rs +++ b/gix-tix/src/edit/forget.rs @@ -3,72 +3,41 @@ use std::{io::Write, path::Path, process::Command}; use anyhow::{Context, Result}; use gix::{ObjectId, bstr::ByteSlice}; -use super::refs::MutableRefs; +use super::rebase; -pub(crate) fn perform(repo: &gix::Repository, id: ObjectId) -> Result> { +pub(crate) fn perform( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + id: ObjectId, +) -> Result> { let commit = repo.find_commit(id).context("could not find the commit to forget")?; - let parents: Vec<_> = commit.parent_ids().map(gix::Id::detach).collect(); - if parents.len() > 1 { - anyhow::bail!("merge commits cannot be forgotten"); - } - let parent = parents.first().copied(); - let old_tree = commit - .tree_id() - .context("could not read the forgotten commit tree")? - .detach(); - let refs = MutableRefs::pointing_to(repo, id)?; - if refs.is_empty() { - anyhow::bail!("no mutable reference points to the commit anymore"); - } - refs.ensure_not_checked_out_elsewhere(repo)?; - let head = repo.head().context("could not inspect HEAD before forgetting")?; - let head_is_selected = head.id().is_some_and(|head| head.as_ref() == id); - if head_is_selected && parent.is_none() && head.referent_name().is_none() { - anyhow::bail!("a detached root commit cannot leave an unborn HEAD"); - } - let transition = match (repo.workdir(), head_is_selected) { - (Some(workdir), true) => { - let new_tree = match parent { - Some(parent) => repo - .find_commit(parent) - .context("could not find the parent commit")? - .tree_id() - .context("could not read the parent tree")? - .detach(), - None => repo - .write_object(&gix::objs::Tree { entries: Vec::new() }) - .context("could not prepare the empty root tree")? - .detach(), - }; - preflight_tree_transition(repo, workdir, old_tree, new_tree)?; - Some((workdir.to_owned(), new_tree)) - } - _ => None, + let parent = commit.parent_ids().next().map(gix::Id::detach); + let tree = commit.tree_id()?.detach(); + let parent_tree = match parent { + Some(parent) => repo.find_commit(parent)?.tree_id()?.detach(), + None => repo.empty_tree().id, }; - drop(head); drop(commit); - - let committer = repo.committer().transpose()?; - match parent { - Some(parent) => refs.update(repo, parent, b"forget commit".as_bstr(), committer)?, - None => refs.delete(repo, b"forget root commit".as_bstr(), committer)?, - } - if let Some((workdir, new_tree)) = transition - && let Err(err) = apply_tree_transition(&workdir, old_tree, new_tree) - { - let rollback = match parent { - Some(parent) => refs.rollback(repo, parent), - None => refs.rollback_deleted(repo), - }; - return match rollback { - Ok(()) => Err(err), - Err(rollback) => Err(err.context(format!("references could not be rolled back: {rollback:#}"))), - }; - } - Ok(parent) + Ok(rebase::perform( + repo, + graph, + rebase::Edit::Remove { target: id }, + rebase::Signature::RedoIfNeeded, + if tree == parent_tree { + rebase::Tree::LeaveAsIs + } else { + rebase::Tree::CherryPick + }, + )? + .selected) } -fn preflight_tree_transition(repo: &gix::Repository, workdir: &Path, old: ObjectId, new: ObjectId) -> Result<()> { +pub(super) fn preflight_tree_transition( + repo: &gix::Repository, + workdir: &Path, + old: ObjectId, + new: ObjectId, +) -> Result<()> { let mut index = gix::tempfile::writable_at( std::env::temp_dir().join(format!( "tix-forget-index-{}-{old}-{:?}", @@ -98,7 +67,7 @@ fn preflight_tree_transition(repo: &gix::Repository, workdir: &Path, old: Object .context("local changes conflict with forgetting this commit") } -fn apply_tree_transition(workdir: &Path, old: ObjectId, new: ObjectId) -> Result<()> { +pub(super) fn apply_tree_transition(workdir: &Path, old: ObjectId, new: ObjectId) -> Result<()> { let refresh = Command::new("git") .arg("-C") .arg(workdir) @@ -159,7 +128,8 @@ mod tests { .expect("top has a parent") .detach(); - assert_eq!(perform(&repository, top)?, Some(parent)); + let graph = super::super::loaded_graph(&repository)?; + assert_eq!(perform(repository.clone(), &graph, top)?, Some(parent)); let state = gix_testtools::repository::snapshot(fixture.path())?; assert_eq!( state.head, @@ -213,7 +183,7 @@ mod tests { let repository = open(fixture.path())?; let top = repository.head_id()?.detach(); assert!( - perform(&repository, top).is_err(), + perform(repository.clone(), &super::super::loaded_graph(&repository)?, top).is_err(), "overlapping local changes are rejected" ); assert_eq!( @@ -240,7 +210,8 @@ mod tests { let repository = open(fixture.path())?; let root = repository.head_id()?.detach(); - assert_eq!(perform(&repository, root)?, None); + let graph = super::super::loaded_graph(&repository)?; + assert_eq!(perform(repository.clone(), &graph, root)?, None); let state = gix_testtools::repository::snapshot(fixture.path())?; assert_eq!( state.head, @@ -277,7 +248,8 @@ mod tests { .next() .expect("top has a parent") .detach(); - assert_eq!(perform(&repository, top)?, Some(parent)); + let graph = super::super::loaded_graph(&repository)?; + assert_eq!(perform(repository.clone(), &graph, top)?, Some(parent)); assert_eq!( repository.head_id()?.detach(), parent, @@ -313,7 +285,7 @@ mod tests { let repository = open(fixture.path())?; let root = repository.head_id()?.detach(); assert!( - perform(&repository, root).is_err(), + perform(repository.clone(), &super::super::loaded_graph(&repository)?, root).is_err(), "detached HEAD cannot become unborn" ); assert_eq!(gix_testtools::repository::snapshot(fixture.path())?, before); diff --git a/gix-tix/src/edit/mod.rs b/gix-tix/src/edit/mod.rs index 04671e43e49..626952ce025 100644 --- a/gix-tix/src/edit/mod.rs +++ b/gix-tix/src/edit/mod.rs @@ -2,9 +2,51 @@ use std::{ffi::OsStr, io::Write, process::Command}; use anyhow::{Context, Result}; +#[cfg(test)] +pub(super) fn loaded_graph(repo: &gix::Repository) -> Result { + use std::sync::atomic::AtomicBool; + + if repo.head_id().is_err() { + return Ok(crate::history::HistoryGraph::default()); + } + let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new( + crate::history::Authors::default(), + )); + let mut revisions = Vec::new(); + for reference in repo.references()?.all()? { + let reference = reference.map_err(|err| anyhow::anyhow!("could not read test reference: {err}"))?; + if reference.name().as_bstr() != b"HEAD" && reference.try_id().is_some() { + revisions.push( + gix::path::from_bstr(reference.name().as_bstr()) + .into_owned() + .into_os_string(), + ); + } + } + if repo.head().is_ok_and(|head| head.referent_name().is_none()) { + revisions.push("HEAD".into()); + } + let mut graph = None; + crate::history::load( + repo, + &revisions, + &[], + false, + &authors, + &AtomicBool::new(false), + |event| { + if let crate::history::Event::Complete(value) = event { + graph = Some(value); + } + true + }, + )?; + graph.context("history traversal did not produce a graph") +} + pub(crate) mod create; pub(crate) mod forget; -pub(crate) mod refs; +pub(crate) mod rebase; pub(crate) mod reword; pub(crate) mod time_travel; diff --git a/gix-tix/src/edit/rebase.rs b/gix-tix/src/edit/rebase.rs new file mode 100644 index 00000000000..7af41c2795d --- /dev/null +++ b/gix-tix/src/edit/rebase.rs @@ -0,0 +1,825 @@ +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, + process::Command, +}; + +use anyhow::{Context, Result}; +use gix::{ + ObjectId, + bstr::{BStr, BString}, + objs::Write, + refs::{ + Category, Target, + transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, + }, +}; + +use crate::history::HistoryGraph; + +const MARKER: &[u8] = b"tix-rebase"; +const PENDING: &[u8] = b"pending"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Signature { + #[allow(dead_code, reason = "available to edits which defer a rebase")] + InvalidateExisting, + RedoIfNeeded, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Tree { + LeaveAsIs, + LeaveAsIsAndMark, + CherryPick, +} + +pub(crate) enum Edit { + Replace { + target: ObjectId, + commit: gix::objs::Commit, + }, + Insert { + anchor: Option, + commit: gix::objs::Commit, + }, + Remove { + target: ObjectId, + }, + #[allow(dead_code, reason = "available to resume a marked deferred rebase")] + Repeat { + base: ObjectId, + }, +} + +pub(crate) struct Outcome { + pub selected: Option, +} + +pub(crate) fn perform( + mut repo: gix::Repository, + graph: &HistoryGraph, + edit: Edit, + signature: Signature, + mut tree_mode: Tree, +) -> Result { + let (root, replacement, inserted, removed, repeat) = match edit { + Edit::Replace { target, commit } => (Some(target), Some(commit), false, false, false), + Edit::Insert { anchor, commit } => (anchor, Some(commit), true, false, false), + Edit::Remove { target } => (Some(target), None, false, true, false), + Edit::Repeat { base } => (Some(base), None, false, false, true), + }; + if repeat { + tree_mode = Tree::CherryPick; + } + + let affected = match root { + Some(root) => graph + .descendants_in_parent_order(root) + .context("the edited commit is not in the loaded history")?, + None => Vec::new(), + }; + validate(&repo, graph, &affected, removed, repeat, tree_mode)?; + + let signing = repo + .commit_signing_options_if_enabled() + .context("could not resolve commit signing configuration")?; + let committer = repo + .committer() + .context("no Git committer is configured")? + .context("could not resolve the Git committer")? + .to_owned() + .context("could not own the Git committer")?; + repo = repo.with_object_memory(); + + let mut rewritten = HashMap::>::new(); + let mut selected = None; + if inserted { + let mut commit = replacement.clone().context("an inserted commit is required")?; + commit.parents = root.into_iter().collect(); + marker(&mut commit, tree_mode == Tree::LeaveAsIsAndMark); + let id = write_commit(&repo, commit, signature, signing.clone())?; + selected = Some(id); + if let Some(root) = root { + rewritten.insert(root, Some(id)); + } else { + rewritten.insert(id, Some(id)); + } + } else if removed { + let root = root.context("a removed commit is required")?; + let parent = graph + .parents_of(root) + .context("the removed commit is not in the loaded history")? + .first() + .copied(); + rewritten.insert(root, parent); + selected = parent; + } + + let mut pending = affected; + if inserted || removed { + pending.retain(|id| Some(*id) != root); + } + for old_id in pending { + let old_parents = graph.parents_of(old_id).context("an affected commit is incomplete")?; + let mut commit = if Some(old_id) == root { + match replacement.clone() { + Some(commit) => commit, + None => repo + .find_commit(old_id) + .context("could not find commit to rewrite")? + .decode() + .context("could not decode commit to rewrite")? + .into_owned() + .context("could not own commit to rewrite")?, + } + } else { + repo.find_commit(old_id) + .context("could not find descendant commit")? + .decode() + .context("could not decode descendant commit")? + .into_owned() + .context("could not own descendant commit")? + }; + let new_parents: Vec<_> = old_parents + .iter() + .filter_map(|parent| rewritten.get(parent).copied().unwrap_or(Some(*parent))) + .collect(); + if Some(old_id) != root || repeat { + commit.committer = committer.clone(); + } + commit.tree = rewritten_tree(&repo, &commit, &old_parents, &new_parents, tree_mode)?; + commit.parents = new_parents.into_iter().collect(); + marker(&mut commit, tree_mode == Tree::LeaveAsIsAndMark); + let new_id = write_commit(&repo, commit, signature, signing.clone())?; + rewritten.insert(old_id, Some(new_id)); + if Some(old_id) == root { + selected = Some(new_id); + } + } + + let objects = repo + .objects + .take_object_memory() + .context("candidate object memory was unavailable")?; + for (id, (kind, data)) in objects.iter() { + repo.write_buf_with_known_id(*kind, data, *id) + .map_err(|err| anyhow::anyhow!("could not persist a prepared rebase object: {err}"))?; + } + + let transitions = worktree_transitions(&repo, &rewritten, inserted)?; + let index_resets = inserted.then(|| inserted_index_resets(&repo, &rewritten)).transpose()?; + for transition in &transitions { + super::forget::preflight_tree_transition( + &transition.repo, + &transition.workdir, + transition.old, + transition.new, + )?; + } + let rollback_refs = update_refs(&repo, &rewritten, root.is_none(), selected, &committer)?; + for (transitioned, transition) in transitions.iter().enumerate() { + if let Err(err) = super::forget::apply_tree_transition(&transition.workdir, transition.old, transition.new) { + return rollback(&repo, &committer, &transitions[..transitioned], &rollback_refs, err); + } + } + let index_resets = index_resets.unwrap_or_default(); + for (index, reset) in index_resets.iter().enumerate() { + if let Err(mut err) = reset_index(&reset.workdir, reset.new) { + for applied in index_resets[..=index].iter().rev() { + if let Err(restore) = std::fs::write(&applied.index, &applied.before) { + err = err.context(format!("index rollback failed: {restore}")); + } + } + return rollback(&repo, &committer, &transitions, &rollback_refs, err); + } + } + Ok(Outcome { selected }) +} + +fn rollback( + repo: &gix::Repository, + committer: &gix::actor::Signature, + transitions: &[Transition], + refs: &[RefEdit], + cause: anyhow::Error, +) -> Result { + let mut failures = Vec::new(); + for transition in transitions.iter().rev() { + if let Err(err) = super::forget::apply_tree_transition(&transition.workdir, transition.new, transition.old) { + failures.push(format!("worktree rollback failed: {err:#}")); + } + } + let mut time = gix::date::parse::TimeBuf::default(); + if let Err(err) = repo.edit_references_as(refs.iter().cloned(), Some(committer.to_ref(&mut time))) { + failures.push(format!("reference rollback failed: {err}")); + } + if failures.is_empty() { + Err(cause) + } else { + Err(cause.context(failures.join("; "))) + } +} + +fn inserted_index_resets( + repo: &gix::Repository, + rewritten: &HashMap>, +) -> Result> { + let mut repos = vec![ + repo.main_repo() + .context("could not open the main worktree repository")?, + ]; + for proxy in repo.worktrees().context("could not enumerate linked worktrees")? { + if let Ok(worktree_repo) = proxy.into_repo_with_possibly_inaccessible_worktree() { + repos.push(worktree_repo); + } + } + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for worktree_repo in repos { + if !seen.insert(worktree_repo.git_dir().to_owned()) { + continue; + } + let Some(old) = worktree_repo + .head() + .ok() + .and_then(|head| head.id().map(gix::Id::detach)) + else { + continue; + }; + let Some(Some(new)) = rewritten.get(&old).copied() else { + continue; + }; + if let Some(workdir) = worktree_repo.workdir().filter(|path| path.is_dir()) { + let index = worktree_repo.index_path(); + out.push(IndexReset { + workdir: workdir.to_owned(), + index: index.to_owned(), + before: std::fs::read(index).context("could not preserve an affected index")?, + new, + }); + } + } + Ok(out) +} + +struct IndexReset { + workdir: PathBuf, + index: PathBuf, + before: Vec, + new: ObjectId, +} + +fn reset_index(workdir: &std::path::Path, id: ObjectId) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(workdir) + .args(["reset", "--mixed", "--quiet"]) + .arg(id.to_string()) + .output() + .context("could not update the index after inserting a commit")?; + if output.status.success() { + Ok(()) + } else { + anyhow::bail!("git reset failed: {}", String::from_utf8_lossy(&output.stderr).trim()) + } +} + +fn validate( + repo: &gix::Repository, + graph: &HistoryGraph, + affected: &[ObjectId], + removed: bool, + repeat: bool, + tree: Tree, +) -> Result<()> { + for (position, id) in affected.iter().enumerate() { + let parents = graph.parents_of(*id).context("an affected commit is incomplete")?; + if parents.len() > 1 && (position > 0 || removed || tree == Tree::CherryPick) { + anyhow::bail!("descendant merge commits cannot be rebased"); + } + if repeat { + let commit = repo.find_commit(*id)?.decode()?.into_owned()?; + if !has_marker(&commit) { + anyhow::bail!("all repeated rebase commits must carry the pending marker"); + } + } + } + if repeat + && let Some(base) = affected.first() + && let Some(parent) = graph.parents_of(*base).and_then(|parents| parents.first().copied()) + && has_marker(&repo.find_commit(parent)?.decode()?.into_owned()?) + { + anyhow::bail!("the parent of a repeated rebase must not carry the pending marker"); + } + Ok(()) +} + +fn rewritten_tree( + repo: &gix::Repository, + commit: &gix::objs::Commit, + old_parents: &[ObjectId], + new_parents: &[ObjectId], + mode: Tree, +) -> Result { + if mode != Tree::CherryPick || old_parents == new_parents { + return Ok(commit.tree); + } + let old_base = parent_tree(repo, old_parents.first().copied())?; + let new_base = parent_tree(repo, new_parents.first().copied())?; + if commit.tree == old_base { + return Ok(new_base); + } + if old_base == new_base { + return Ok(commit.tree); + } + let labels = gix::merge::blob::builtin_driver::text::Labels { + ancestor: Some(BStr::new(b"parent")), + current: Some(BStr::new(b"rebased parent")), + other: Some(BStr::new(b"commit")), + }; + let mut outcome = repo + .merge_trees(old_base, new_base, commit.tree, labels, repo.tree_merge_options()?) + .context("could not cherry-pick a descendant tree")?; + if outcome.has_unresolved_conflicts(gix::merge::tree::TreatAsUnresolved::git()) { + anyhow::bail!("rebasing would cause a merge conflict"); + } + Ok(outcome + .tree + .write() + .context("could not prepare a rebased tree")? + .detach()) +} + +fn parent_tree(repo: &gix::Repository, parent: Option) -> Result { + match parent { + Some(parent) => Ok(repo.find_commit(parent)?.tree_id()?.detach()), + None => Ok(repo.empty_tree().id), + } +} + +fn marker(commit: &mut gix::objs::Commit, add: bool) { + commit.extra_headers.retain(|(name, _)| name.as_slice() != MARKER); + if add { + commit.extra_headers.push((MARKER.into(), PENDING.into())); + } +} + +fn has_marker(commit: &gix::objs::Commit) -> bool { + commit + .extra_headers + .iter() + .any(|(name, value)| name.as_slice() == MARKER && value.as_slice() == PENDING) +} + +fn write_commit( + repo: &gix::Repository, + mut commit: gix::objs::Commit, + signature: Signature, + signing: Option, +) -> Result { + let had_signature = commit.extra_headers.iter().any(|(name, _)| is_signature(name)); + commit.extra_headers.retain(|(name, _)| !is_signature(name)); + commit = match (signature, signing) { + (Signature::RedoIfNeeded, Some(options)) => commit.sign(options).context("could not sign rebased commit")?, + (Signature::InvalidateExisting, Some(_)) if had_signature => { + let field = gix::objs::commit::signature_field_name(commit.tree.kind()); + commit.extra_headers.push((field.into(), BString::default())); + commit + } + _ => commit, + }; + Ok(repo + .write_object(&commit) + .context("could not prepare rebased commit")? + .detach()) +} + +fn is_signature(name: &BString) -> bool { + name.as_slice() == gix::objs::commit::SIGNATURE_FIELD_NAME.as_bytes() + || name.as_slice() == gix::objs::commit::SIGNATURE_FIELD_NAME_SHA256.as_bytes() +} + +struct Transition { + repo: gix::Repository, + workdir: PathBuf, + old: ObjectId, + new: ObjectId, +} + +fn worktree_transitions( + repo: &gix::Repository, + rewritten: &HashMap>, + inserted: bool, +) -> Result> { + if inserted { + return Ok(Vec::new()); + } + let mut repos = vec![ + repo.main_repo() + .context("could not open the main worktree repository")?, + ]; + for proxy in repo.worktrees().context("could not enumerate linked worktrees")? { + if let Ok(worktree_repo) = proxy.into_repo_with_possibly_inaccessible_worktree() { + repos.push(worktree_repo); + } + } + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for worktree_repo in repos { + if !seen.insert(worktree_repo.git_dir().to_owned()) { + continue; + } + let Some(old) = worktree_repo + .head() + .ok() + .and_then(|head| head.id().map(gix::Id::detach)) + else { + continue; + }; + let Some(new) = rewritten.get(&old).copied() else { + continue; + }; + if worktree_repo.workdir().is_none() && worktree_repo.is_bare() { + continue; + } + let old_tree = worktree_repo.find_commit(old)?.tree_id()?.detach(); + let new_tree = match new { + Some(new) => repo.find_commit(new)?.tree_id()?.detach(), + None if worktree_repo.head()?.referent_name().is_some() => repo.empty_tree().id, + None => anyhow::bail!("a detached checked-out root commit cannot be removed"), + }; + if old_tree == new_tree { + continue; + } + let workdir = worktree_repo + .workdir() + .filter(|path| path.is_dir()) + .context("an affected worktree is inaccessible")? + .to_owned(); + out.push(Transition { + repo: worktree_repo, + workdir, + old: old_tree, + new: new_tree, + }); + } + Ok(out) +} + +fn update_refs( + repo: &gix::Repository, + rewritten: &HashMap>, + unborn: bool, + inserted: Option, + committer: &gix::actor::Signature, +) -> Result> { + let mut edits = Vec::new(); + let mut rollback = Vec::new(); + for reference in repo.references()?.all()? { + let reference = match reference { + Ok(reference) => reference, + Err(err) if is_missing_ref(&*err) => continue, + Err(err) => anyhow::bail!("could not inspect a reference before rebasing: {err}"), + }; + if matches!( + reference.name().category(), + Some(Category::Tag | Category::RemoteBranch) + ) { + continue; + } + let Some(old) = reference.try_id().map(gix::Id::detach) else { + continue; + }; + let Some(new) = rewritten.get(&old) else { continue }; + let name = reference.name().to_owned(); + edits.push(ref_edit(name.clone(), old, *new)); + rollback.push(reverse_ref_edit(name, old, *new)); + } + if let Some(head) = repo.try_find_reference("HEAD")? + && let Some(old) = head.try_id().map(gix::Id::detach) + && let Some(new) = rewritten.get(&old) + { + let name = head.name().to_owned(); + edits.push(ref_edit(name.clone(), old, *new)); + rollback.push(reverse_ref_edit(name, old, *new)); + } + if unborn { + let name = repo + .head()? + .referent_name() + .context("an unborn HEAD must point to a branch")? + .to_owned(); + let new = inserted.context("an unborn insertion must create a commit")?; + edits.push(RefEdit { + name: name.clone(), + deref: false, + change: Change::Update { + log: log_change(), + expected: PreviousValue::MustNotExist, + new: Target::Object(new), + }, + }); + rollback.push(RefEdit { + name, + deref: false, + change: Change::Delete { + expected: PreviousValue::MustExistAndMatch(Target::Object(new)), + log: RefLog::AndReference, + }, + }); + } + if edits.is_empty() { + anyhow::bail!("no mutable reference points to an affected commit"); + } + let mut time = gix::date::parse::TimeBuf::default(); + repo.edit_references_as(edits, Some(committer.to_ref(&mut time))) + .context("could not update references after rebasing")?; + Ok(rollback) +} + +fn is_missing_ref(mut err: &(dyn std::error::Error + 'static)) -> bool { + loop { + if err + .downcast_ref::() + .is_some_and(|err| err.kind() == std::io::ErrorKind::NotFound) + { + return true; + } + let Some(source) = err.source() else { return false }; + err = source; + } +} + +fn reverse_ref_edit(name: gix::refs::FullName, old: ObjectId, new: Option) -> RefEdit { + RefEdit { + name, + deref: false, + change: match new { + Some(new) => Change::Update { + log: log_change(), + expected: PreviousValue::MustExistAndMatch(Target::Object(new)), + new: Target::Object(old), + }, + None => Change::Update { + log: log_change(), + expected: PreviousValue::MustNotExist, + new: Target::Object(old), + }, + }, + } +} + +fn ref_edit(name: gix::refs::FullName, old: ObjectId, new: Option) -> RefEdit { + RefEdit { + name, + deref: false, + change: match new { + Some(new) => Change::Update { + log: log_change(), + expected: PreviousValue::MustExistAndMatch(Target::Object(old)), + new: Target::Object(new), + }, + None => Change::Delete { + expected: PreviousValue::MustExistAndMatch(Target::Object(old)), + log: RefLog::AndReference, + }, + }, + } +} + +fn log_change() -> LogChange { + LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: BString::from("tix rebase"), + } +} + +#[cfg(test)] +mod tests { + use std::{path::Path, process::Command}; + + use gix::bstr::ByteSlice; + + use super::*; + + fn open(path: &Path) -> gix_testtools::Result { + Ok(gix::open_opts( + path, + gix::open::Options::isolated().config_overrides([ + "user.name=rebasing committer".to_owned(), + "user.email=rebasing@example.com".to_owned(), + "gitoxide.commit.committerDate=2001-01-01T00:00:00 +0000".to_owned(), + "commit.gpgSign=false".to_owned(), + ]), + )?) + } + + #[test] + fn rewords_a_middle_commit_and_reparents_all_linear_descendants() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let old_tip = repo.head_id()?.detach(); + let old_tip_tree = repo.find_commit(old_tip)?.tree_id()?.detach(); + let mut commit = repo.find_commit(middle)?.decode()?.into_owned()?; + commit.message = "rewritten middle".into(); + + let outcome = perform( + repo.clone(), + &graph, + Edit::Replace { target: middle, commit }, + Signature::RedoIfNeeded, + Tree::LeaveAsIs, + )?; + let new_middle = outcome.selected.expect("replacement selects the rewritten commit"); + let new_tip = repo.head_id()?.detach(); + assert_ne!(new_tip, old_tip, "the descendant is rewritten"); + assert_eq!( + repo.find_commit(new_tip)?.parent_ids().next().map(gix::Id::detach), + Some(new_middle), + "the descendant follows the replacement" + ); + assert_eq!( + repo.find_commit(new_tip)?.tree_id()?.detach(), + old_tip_tree, + "a reword preserves descendant trees" + ); + insta::assert_snapshot!( + "reworded-middle-stack", + gix_testtools::repository::snapshot(fixture.path())?.to_string() + ); + Ok(()) + } + + #[test] + fn removes_a_middle_commit_by_cherry_picking_its_descendant() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let base = repo.rev_parse_single("HEAD~2")?.detach(); + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let old_tip_tree = repo.find_commit(repo.head_id()?)?.tree_id()?.detach(); + + let outcome = perform( + repo.clone(), + &graph, + Edit::Remove { target: middle }, + Signature::RedoIfNeeded, + Tree::CherryPick, + )?; + assert_eq!(outcome.selected, Some(base), "removal selects its parent"); + let tip = repo.head_id()?.detach(); + assert_eq!( + repo.find_commit(tip)?.parent_ids().next().map(gix::Id::detach), + Some(base), + "the descendant is transplanted onto the removed commit's parent" + ); + assert_ne!( + repo.find_commit(tip)?.tree_id()?.detach(), + old_tip_tree, + "the removed commit's tree contribution is absent" + ); + Ok(()) + } + + #[test] + fn a_marked_rebase_can_be_repeated_and_clears_its_markers() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let commit = repo.find_commit(middle)?.decode()?.into_owned()?; + let marked = perform( + repo.clone(), + &graph, + Edit::Replace { target: middle, commit }, + Signature::InvalidateExisting, + Tree::LeaveAsIsAndMark, + )? + .selected + .expect("marking rewrites the selected commit"); + + let graph = super::super::loaded_graph(&repo)?; + perform( + repo.clone(), + &graph, + Edit::Repeat { base: marked }, + Signature::RedoIfNeeded, + Tree::CherryPick, + )?; + let mut id = Some(repo.head_id()?.detach()); + while let Some(current) = id { + let commit = repo.find_commit(current)?.decode()?.into_owned()?; + assert!(!has_marker(&commit), "repeating clears every pending marker"); + id = commit.parents.first().copied(); + } + Ok(()) + } + + #[test] + fn rewrites_every_fork_without_flattening_it() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let tree = repo.find_commit(middle)?.tree_id()?.detach(); + let tree_hex = tree.to_string(); + let middle_hex = middle.to_string(); + let side = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args([ + "-c", + "commit.gpgSign=false", + "commit-tree", + &tree_hex, + "-p", + &middle_hex, + "-m", + "side", + ]) + .env("GIT_AUTHOR_DATE", "2000-01-04T00:00:00 +0000") + .env("GIT_COMMITTER_DATE", "2000-01-04T00:00:00 +0000") + .output()?; + assert!(side.status.success(), "the side commit fixture is created"); + let side = ObjectId::from_hex(side.stdout.trim())?; + assert!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["update-ref", "refs/heads/side", &side.to_string()]) + .status()? + .success(), + "the side tip is made visible to history loading" + ); + let graph = super::super::loaded_graph(&repo)?; + let mut commit = repo.find_commit(middle)?.decode()?.into_owned()?; + commit.message = "fork point".into(); + let rewritten_middle = perform( + repo.clone(), + &graph, + Edit::Replace { target: middle, commit }, + Signature::RedoIfNeeded, + Tree::LeaveAsIs, + )? + .selected + .expect("the fork point is rewritten"); + + let main = repo.head_id()?.detach(); + let side = repo.find_reference("refs/heads/side")?.id().detach(); + assert_ne!(main, side, "the two descendant lines remain distinct"); + for (name, tip) in [("main", main), ("side", side)] { + assert_eq!( + repo.find_commit(tip)?.parent_ids().next().map(gix::Id::detach), + Some(rewritten_middle), + "the {name} fork follows the rewritten fork point" + ); + } + Ok(()) + } + + #[test] + fn a_conflicting_cherry_pick_leaves_repository_state_unmodified() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let middle = repo.rev_parse_single("HEAD~1")?.detach(); + let before = gix_testtools::repository::snapshot(fixture.path())?; + let objects_before = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["count-objects", "-v"]) + .output()? + .stdout; + + assert!( + perform( + repo, + &graph, + Edit::Remove { target: middle }, + Signature::RedoIfNeeded, + Tree::CherryPick, + ) + .is_err(), + "an unresolved cherry-pick aborts the complete rebase" + ); + assert_eq!( + gix_testtools::repository::snapshot(fixture.path())?, + before, + "refs, index, and worktree remain unchanged" + ); + assert_eq!( + Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["count-objects", "-v"]) + .output()? + .stdout, + objects_before, + "failed in-memory rebases write no objects" + ); + Ok(()) + } +} diff --git a/gix-tix/src/edit/refs.rs b/gix-tix/src/edit/refs.rs deleted file mode 100644 index 6a3e7c616e9..00000000000 --- a/gix-tix/src/edit/refs.rs +++ /dev/null @@ -1,174 +0,0 @@ -use anyhow::{Context, Result}; -use gix::bstr::{BStr, ByteSlice}; -use gix::refs::{ - Category, Target, - transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}, -}; - -use crate::history; - -#[derive(Clone)] -pub(super) struct MutableRefs { - names: Vec, - old: Option, -} - -impl MutableRefs { - pub(super) fn pointing_to(repo: &gix::Repository, id: gix::ObjectId) -> Result { - let mut names = Vec::new(); - for reference in repo.references()?.all()? { - let reference = match reference { - Ok(reference) => reference, - Err(err) if is_missing_ref(&*err) => continue, - Err(err) => anyhow::bail!("could not inspect references pointing to commit: {err}"), - }; - if !matches!( - reference.name().category(), - Some(Category::Tag | Category::RemoteBranch) - ) && reference.try_id().is_some_and(|target| target.as_ref() == id) - { - names.push(reference.name().to_owned()); - } - } - if let Some(head) = repo.try_find_reference("HEAD")? - && head.try_id().is_some_and(|target| target.as_ref() == id) - { - names.push(head.name().to_owned()); - } - Ok(Self { names, old: Some(id) }) - } - - pub(super) fn unborn(repo: &gix::Repository) -> Result { - let head = repo.head().context("could not read unborn HEAD")?; - if !head.is_unborn() { - anyhow::bail!("an unborn HEAD is required"); - } - let name = head - .referent_name() - .context("an unborn HEAD must point to a branch")? - .to_owned(); - Ok(Self { - names: vec![name], - old: None, - }) - } - - pub(super) fn is_empty(&self) -> bool { - self.names.is_empty() - } - - pub(super) fn contains(&self, name: &gix::refs::FullNameRef) -> bool { - self.names.iter().any(|candidate| candidate.as_ref() == name) - } - - pub(super) fn validate(&self, repo: &gix::Repository) -> Result<()> { - for name in &self.names { - let actual = repo - .try_find_reference(name)? - .and_then(|reference| reference.try_id().map(gix::Id::detach)); - if actual != self.old { - anyhow::bail!("a reference changed while editing"); - } - } - Ok(()) - } - - pub(super) fn ensure_not_checked_out_elsewhere(&self, repo: &gix::Repository) -> Result<()> { - if history::worktree_checkouts(repo).iter().any(|checkout| { - !checkout.is_current - && checkout - .reference - .as_ref() - .is_some_and(|name| self.contains(name.as_ref())) - }) { - anyhow::bail!("an affected branch is checked out in another worktree"); - } - Ok(()) - } - - pub(super) fn update( - &self, - repo: &gix::Repository, - new: gix::ObjectId, - message: &BStr, - committer: Option>, - ) -> Result<()> { - repo.edit_references_as( - self.names.iter().cloned().map(|name| RefEdit { - change: Change::Update { - log: LogChange { - mode: RefLog::AndReference, - force_create_reflog: false, - message: message.to_owned(), - }, - expected: self.old.map_or(PreviousValue::MustNotExist, |old| { - PreviousValue::MustExistAndMatch(Target::Object(old)) - }), - new: Target::Object(new), - }, - name, - deref: false, - }), - committer, - ) - .context("could not update references")?; - Ok(()) - } - - pub(super) fn delete( - &self, - repo: &gix::Repository, - _message: &BStr, - committer: Option>, - ) -> Result<()> { - let old = self.old.context("cannot delete an unborn reference")?; - repo.edit_references_as( - self.names.iter().cloned().map(|name| RefEdit { - change: Change::Delete { - expected: PreviousValue::MustExistAndMatch(Target::Object(old)), - log: RefLog::AndReference, - }, - name, - deref: false, - }), - committer, - ) - .context("could not delete references")?; - Ok(()) - } - - pub(super) fn rollback(&self, repo: &gix::Repository, current: gix::ObjectId) -> Result<()> { - let current_refs = Self { - names: self.names.clone(), - old: Some(current), - }; - let committer = repo.committer().transpose()?; - match self.old { - Some(old) => current_refs.update(repo, old, b"tix edit rollback".as_bstr(), committer), - None => current_refs.delete(repo, b"tix edit rollback".as_bstr(), committer), - } - } - - pub(super) fn rollback_deleted(&self, repo: &gix::Repository) -> Result<()> { - let old = self.old.context("an unborn reference was not deleted")?; - let deleted = Self { - names: self.names.clone(), - old: None, - }; - let committer = repo.committer().transpose()?; - deleted.update(repo, old, b"tix edit rollback".as_bstr(), committer) - } -} - -fn is_missing_ref(mut err: &(dyn std::error::Error + 'static)) -> bool { - loop { - if err - .downcast_ref::() - .is_some_and(|err| err.kind() == std::io::ErrorKind::NotFound) - { - return true; - } - let Some(source) = err.source() else { return false }; - err = source; - } -} diff --git a/gix-tix/src/edit/reword.rs b/gix-tix/src/edit/reword.rs index a5fb0c98e77..738cf96b7a7 100644 --- a/gix-tix/src/edit/reword.rs +++ b/gix-tix/src/edit/reword.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result}; use gix::bstr::{BString, ByteSlice}; -use super::refs::MutableRefs; +use super::rebase; const AUTHOR: &[u8] = b"Author: "; const AUTHOR_DATE: &[u8] = b"AuthorDate: "; @@ -67,18 +67,17 @@ pub(super) fn missing_agent_trailers(message: &[u8]) -> [Option<&'static [u8]>; ] } -pub(crate) fn apply(repo: &gix::Repository, old_id: gix::ObjectId, edited: &[u8]) -> Result> { +pub(crate) fn apply( + repo: gix::Repository, + graph: &crate::history::HistoryGraph, + old_id: gix::ObjectId, + edited: &[u8], +) -> Result> { let edit = parse(edited)?; if edit.message.is_empty() { anyhow::bail!("the edited commit message is empty"); } - let refs = MutableRefs::pointing_to(repo, old_id)?; - if refs.is_empty() { - anyhow::bail!("no mutable reference points to the commit anymore"); - } - refs.ensure_not_checked_out_elsewhere(repo)?; - let mut commit = repo .find_commit(old_id) .context("could not find commit after editing")? @@ -89,34 +88,14 @@ pub(crate) fn apply(repo: &gix::Repository, old_id: gix::ObjectId, edited: &[u8] commit.author = actor(edit.author, edit.author_time, "author")?; commit.committer = actor(edit.committer, edit.committer_time, "committer")?; commit.message = edit.message; - commit.extra_headers.retain(|(name, _)| { - name.as_slice() != gix::objs::commit::SIGNATURE_FIELD_NAME.as_bytes() - && name.as_slice() != gix::objs::commit::SIGNATURE_FIELD_NAME_SHA256.as_bytes() - }); - if let Some(options) = repo - .commit_signing_options_if_enabled() - .context("could not resolve commit signing configuration")? - { - commit = commit.sign(options).context("could not sign reworded commit")?; - } - let new_id = repo - .write_object(&commit) - .context("could not write reworded commit")? - .detach(); - if new_id == old_id { - return Ok(None); - } - - let log_message = gix::reference::log::message("commit", commit.message.as_bstr(), commit.parents.len()); - let mut time_buf = gix::date::parse::TimeBuf::default(); - refs.update( + let outcome = rebase::perform( repo, - new_id, - log_message.as_ref(), - Some(commit.committer.to_ref(&mut time_buf)), - ) - .context("could not update references to the reworded commit")?; - Ok(Some(new_id)) + graph, + rebase::Edit::Replace { target: old_id, commit }, + rebase::Signature::RedoIfNeeded, + rebase::Tree::LeaveAsIs, + )?; + Ok(outcome.selected.filter(|new_id| *new_id != old_id)) } pub(super) fn write_headers( @@ -366,7 +345,8 @@ mod tests { CommentChar: ;\n\ \n\ rewritten title\n\nrewritten body\n\nAssisted-by: GPT 5.6\n;Co-authored-by: GPT 5.6 \n"; - let new_id = apply(&repository, old_id, edited)?.expect("the edited commit differs"); + let graph = super::super::loaded_graph(&repository)?; + let new_id = apply(repository.clone(), &graph, old_id, edited)?.expect("the edited commit differs"); let commit = repository.find_commit(new_id)?; let decoded = commit.decode()?; assert_eq!( diff --git a/gix-tix/src/edit/snapshots/gix_tix__edit__rebase__tests__reworded-middle-stack.snap b/gix-tix/src/edit/snapshots/gix_tix__edit__rebase__tests__reworded-middle-stack.snap new file mode 100644 index 00000000000..33e89295f05 --- /dev/null +++ b/gix-tix/src/edit/snapshots/gix_tix__edit__rebase__tests__reworded-middle-stack.snap @@ -0,0 +1,56 @@ +--- +source: gix-tix/src/edit/rebase.rs +expression: "gix_testtools::repository::snapshot(fixture.path())?.to_string()" +--- +HEAD refs/heads/main -> C2 + +[refs] +refs/heads/main = C2 +refs/patches/middle = C1 +refs/patches/tip = C2 + +[commits] +C0 + tree T2 + author author 946684800 +0000 + committer committer 946684800 +0000 + + base + +C1 + tree T0 + parent C0 + author author 946771200 +0000 + committer committer 946771200 +0000 + + rewritten middle + +C2 + tree T1 + parent C1 + author author 946857600 +0000 + committer rebasing committer 978307200 +0000 + + tip + +[index] +tree = T1 +100644 B1 stage=0 "base" +100644 B2 stage=0 "middle" +100644 B0 stage=0 "tip" + +[worktree] +100644 file "base" = "base\n" +100644 file "middle" = "middle\n" +100644 file "tip" = "tip\n" + +[objects] +C1 = 347d88644e1829f6255a5d6dc8721a29fa8352f4 +C0 = 5295d5ce934d18b6a1f48c3b7d836d53b55304e5 +T0 = 559dc660acc84805fedbcb75588371240372c173 +T1 = 569961a34e2a909d8c7a2940ed4ad365504e64ed +T2 = 8552142164d268c95f60dabb74465a2f4ffb51b7 +B0 = b218ebde2fc5b78f14aa04eeb42a74b17e2e367d +B1 = df967b96a579e45a18b8251732d16804b2e56a55 +C2 = e2722ab1d7aeafc5fb8cddb4a8e504cfa7855b85 +B2 = ea1a2ae6cdab2b53dee8943ca555c4312a0b798a diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index 2aeb3440df0..6ae03b4394e 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -194,6 +194,68 @@ impl HistoryGraph { self.parents.iter().map(|parent| self.id(*parent)).collect() } + pub(crate) fn parents_of(&self, id: ObjectId) -> Option> { + let index = self.index(id)?; + Some(self.parents(index).iter().map(|parent| self.id(*parent)).collect()) + } + + pub(crate) fn commits_with_merge_descendants(&self) -> HashSet { + let mut pending: Vec<_> = self + .commits + .iter() + .filter(|commit| commit.parents.len() > 1) + .flat_map(|commit| { + let range = commit.parents.clone(); + self.parents[range.start as usize..range.end as usize].iter().copied() + }) + .collect(); + let mut ancestors = HashSet::new(); + while let Some(index) = pending.pop() { + if ancestors.insert(index) { + pending.extend_from_slice(self.parents(index)); + } + } + ancestors.into_iter().map(|index| self.id(index)).collect() + } + + pub(crate) fn descendants_in_parent_order(&self, root: ObjectId) -> Option> { + let root = self.index(root)?; + let mut included = HashSet::from([root]); + loop { + let mut changed = false; + for index in 0..self.commits.len() { + let index = CommitIndex::new(index).expect("an existing graph index fits into u32"); + if included.contains(&index) || !self.parents(index).iter().any(|parent| included.contains(parent)) { + continue; + } + included.insert(index); + changed = true; + } + if !changed { + break; + } + } + let mut out = Vec::with_capacity(included.len()); + while out.len() < included.len() { + let before = out.len(); + for index in &included { + if out.contains(index) + || self + .parents(*index) + .iter() + .any(|parent| included.contains(parent) && !out.contains(parent)) + { + continue; + } + out.push(*index); + } + if out.len() == before { + return None; + } + } + Some(out.into_iter().map(|index| self.id(index)).collect()) + } + fn parent_ids(&self, index: CommitIndex) -> gix::traverse::commit::ParentIds { self.parents(index).iter().map(|parent| self.id(*parent)).collect() } diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index b50b73039d9..dd95f7502a4 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -1236,6 +1236,7 @@ fn event_loop( match result { Ok((graph, result)) => { app.set_known_descendants(graph.commits_with_descendants()); + app.set_known_merge_descendants(graph.commits_with_merge_descendants()); history_graph = Some(graph); let result = result?; tracing::info!(commit_count = result.commits.rows.len(), "history refresh completed"); @@ -1399,6 +1400,7 @@ fn event_loop( Event::Complete(graph) => { history_finished = true; app.set_known_descendants(graph.commits_with_descendants()); + app.set_known_merge_descendants(graph.commits_with_merge_descendants()); history_graph = Some(graph); selection_relation = None; app.selection_relation = None; @@ -1689,7 +1691,20 @@ fn event_loop( } } Effect::Reword(id) => { - match reword_commit(terminal, &repository_path, repository_is_bare, id, enhanced_keyboard) { + let result = history_graph + .as_ref() + .context("reword requires a completed history graph") + .and_then(|graph| { + reword_commit( + terminal, + &repository_path, + repository_is_bare, + graph, + id, + enhanced_keyboard, + ) + }); + match result { Ok(Some(new_id)) => { app.leave_message(format!( "reworded {} as {}", @@ -1704,13 +1719,20 @@ fn event_loop( } } Effect::NewCommit(parent) => { - match create_commit( - terminal, - &repository_path, - repository_is_bare, - parent, - enhanced_keyboard, - ) { + let result = history_graph + .as_ref() + .context("creating a commit requires a completed history graph") + .and_then(|graph| { + create_commit( + terminal, + &repository_path, + repository_is_bare, + graph, + parent, + enhanced_keyboard, + ) + }); + match result { Ok(Some(new_id)) => { app.leave_message(format!("created {}", new_id.to_hex_with_len(7))); refresh_select_top_requested = true; @@ -1723,7 +1745,11 @@ fn event_loop( Effect::Forget(id) => { fill_repository.retain = false; fill_repository.retained = None; - match forget_commit(&repository_path, repository_is_bare, id) { + let result = history_graph + .as_ref() + .context("forget requires a completed history graph") + .and_then(|graph| forget_commit(&repository_path, repository_is_bare, graph, id)); + match result { Ok(parent) => { app.leave_message(format!("forgot {}", id.to_hex_with_len(7))); if let Some(parent) = parent { @@ -2713,6 +2739,7 @@ fn reword_commit( terminal: &mut ratatui::DefaultTerminal, repository_path: &Path, bare: bool, + graph: &HistoryGraph, id: gix::ObjectId, enhanced_keyboard: bool, ) -> Result> { @@ -2736,13 +2763,14 @@ fn reword_commit( let mut repository = open_repository(repository_path, bare, false).context("could not reopen repository after editing commit")?; repository.object_cache_size(None); - edit::reword::apply(&repository, id, &edited) + edit::reword::apply(repository, graph, id, &edited) } fn create_commit( terminal: &mut ratatui::DefaultTerminal, repository_path: &Path, bare: bool, + graph: &HistoryGraph, parent: Option, enhanced_keyboard: bool, ) -> Result> { @@ -2763,14 +2791,19 @@ fn create_commit( let mut repository = open_repository(repository_path, bare, false).context("could not reopen repository after editing commit")?; repository.object_cache_size(None); - edit::create::apply(repository, prepared, &edited).map(Some) + edit::create::apply(repository, graph, prepared, &edited).map(Some) } -fn forget_commit(repository_path: &Path, bare: bool, id: gix::ObjectId) -> Result> { +fn forget_commit( + repository_path: &Path, + bare: bool, + graph: &HistoryGraph, + id: gix::ObjectId, +) -> Result> { let mut repository = open_repository(repository_path, bare, false).context("could not open repository before forgetting commit")?; repository.object_cache_size(None); - edit::forget::perform(&repository, id) + edit::forget::perform(repository, graph, id) } fn run_external_diff( diff --git a/gix-tix/tests/fixtures/generated-archives/rebase_conflict.tar b/gix-tix/tests/fixtures/generated-archives/rebase_conflict.tar new file mode 100644 index 0000000000000000000000000000000000000000..9a97d11f4a0c7c67b4390c173315197017ee57bf GIT binary patch literal 70656 zcmeHw4Uk+{b*2ms#WV|e2`nU)pXb)#nUSV{r+-Exk8F7)+hfa;kw(ZwBadHqzn*EU zr(e5Yw?>03Z&DAK&t~`WFFxIW zuySh78=}j1H3T+;;WVBuv;Q(b~^w0 zY3u@5dVYOyZJwrq`ok`F0~=Q!1@2!;^_q- z<3;%Yrbn;7_phw)-#_pFy=mXRTVDJ5J3q2--%q>?ub)GiSL6Si-~YO=elhX!55Dcy z@4fl!|LH*UeNV)5SNGjqu2dbo#b@dC@#FaO1N%}|GUC1J@*m}`g|ANU`xhHePaOEI zAN|SqZT#T3KbLy#7v6gF|9;npUh&djKR);D#edk8nxlViGzNtK8&269V#DPBo#}t6 zWF9hMr2h|bQVi%|{u51mbx1_8i}_FGGWpT`4}bbq|0n+^)*QPu90J(Q{O9u{`~L<^ zfA{<+*6m7duiNGPZ)yK!d-T6_K9wKoe|tR5&eRp-N=6^jHC7c2GCb~{uA|uAk#F3Y4_6bY7BY*gw^tLa*;ddY4&mTd`fIHefq!)i7h$8u|U3MaBf@`*?G1I*G| z#Zv(ITd7@gFFGZwu*p)fhEsGZmz-GLE?%5WO<5ajPR&B?hGQ3-9-Bfbz#ufsu9Zy7 z9txD5hEpp#lin17q7I+cU9Zw~8=KQoR)96v9H|W;%|^u$n5O%DRcP3?;+oZ5vzr#W zS8O!^wOQTtM`m$kY#40$ zWo>q`<~5;PD{k)Ev`S9dZdIGpme)c@Y#l19VT3IYn5vZ7wzc6lE>>!*ie=Lw3>=<1 zKz6lE!&!GREp0qlYqp2pqmfG4;L9pE+;xE%j1>UPJj!T`?)rMAd06M7;5Lb!t=hNN z?PhTeKv>KGwG_xSpKV%p!!d&g9$hUjrEAUxP|eB3KOIqCv`_D-O!1GS6o;d6`*osts>-xURUb= zycwji^GZyZ{#CSy+13#YyOri53Tb{kMfO~5T1A^I3VspTwpDX$u{S#nmmP1mgw~ae zWSGK4lRk@;T*m_x&z&a2twZpkx*61_lo#rMxe5YkRaLE z#RkdP1EFHHMG6=Iktz2jEd-v@T64qIRza)lq%W4}?n{Lek7)oASL`Y}%{H*SV~Rj{ zsNexOdV;a?m#+b6&-9(pj#4Y;5L69WchPa`W`?Y)TePde9GhWu$S@gZiE@!-2ljFr z6j3@Zb|@sX3QlvwF_Y^z8YhJ{g*Q<;|G#eC#nR z7OSk*IICW(0D0TO%;Fh@TEl^o0!-B+W0XbFvvp36s3k(LR@nwkmZFhzB?O){730Y_Eg5x3sIlR;R>9680(8rBq7qpa-D@jY zomZe7QDXt2++js2B%;=wb^L(`m)^9(jgyr#_a05H+Z!eI!wG zE+uNMYSna_u;&RX;#55+GA6&rCs`XUuEpHKqmUw2%&2U!nAgNKS+@sprurbw%t(N= zq75{c`eJXK4dTsU9QMOtKnmSbD>|;rUo;GN!EIGbB0-E&7baX%Dy6%im1Ekj74U*B z7qb5ppL=HYwNX02{>vlotK0sY5r1JD#PGh2>_21w(Q>=C0a(ELe$i^x>>3tts4q~a zuwSI>6SNA4ni|)D1*jKziKQS)@e^M6qDE`tq2yUud@zBVnLm+@H z$F7>)c8BllfgmH*lG-z|TNKc$m)<+u>WZ4%^cBd6F(o~H0>p|_($V{>{Iy5}Is?@P z(xoHr0aOFcS_`npX_r)?XhsZ=%!?%f9)V?m@9Ag+Y@*+1qEw7o@r(_F!gw;MKAg8= zZ-xnS4`TZe=Ml9o944ZmK#2(#JtlNXt3%kyczXhEl8dk34d`;P`2332B!K&h<+OY& z%ChXW>NJT=gVGr7qt43Kc+$EZ!#ZN!uH(TM9n6s_zogofMw2tcFqnv1F;~Z>wcEcH zNY5}^9<;W(En4ku)Sm}>cp}|`V_r2Y_5T3x&CR+qPZ<(s?vVJHQV7cKN-*035w0*5 z=_b`dAiB$U^a8v*sJ@Ik5K*RfAT734J19Lx0yRoHOoEzAJ6?Q?v~24Qm+C1W0W5tz z&~ztKk4>~m7m8|7J~|aC+Axiy_&o27Bwm>hr#kr?#4SpC`@*_)A;fcrN{6k+0Y)Lpy z#mUCyN|B03An1664hMtH4qd%RN5?>#4Qt|jGIq$0l`l+K6Um7wKZbXn#E>KoOzT*I^fCBAYq1WU z0S~=3x6xcNz~;xVm~Z>{w_#jGF%e+V@ox5uTDM}9%|^=!4RS0rze}*DyIY;M#yeWY zw0L4f80kw}U4&qJ4K>prhI|%eH$k!DsmO^2eb((&C`-Qd z1S}P7V`C6)VVb?7PdBN8*?2wz7 zzO(HR?bf~TLuuWE2rU5ms$(X7O(HxyA$rpqJ7Kc^{Z);t8{hD-6lHHD=ySkH?FxN{ z*jq15vRYy1W9@=^U4uTLoOQIe326k*r9TJSmbiN1DuqM6{rnD=RZWhhepa{WXkg4i z{#~rO8<1&OpXmHFz7hx}@LQYdxQ)aTX8kbYTr|D5sOnny?yl4-+`fs3EK81DEW`6n z+yKZS|4$uGS30xz+yg{CXp8vYbap1!?f=Q7W@bkF|2JU%x4Hk1-6*W1aE|Csm0ghi zf7z?l&0M%WI$b|diuopLd<5!>RwDo!I+k`wBH6swoy%!#mC#25MJgobpb_I4ELH^u zE7`IZx%CZ+Xv|egA$V=L)!Mrqip5gIsP8c_eeI1>z}^+zRsx27E4#GHDS@G;z@~tk z!&6WGcnT(;b&_tY&P{m8*7ja68{$Gxa0sL1!jRu@ji*`l?CBF!4!16g(ikV9us*v* zgzH`khH0!E!5>~>jj(;MTHqaV9&6cEb%`z3xb34`VrJ?vM%B_~FOP8uz`OQVk;LAM zIS+RRcM0i?;QlgtMg(EjY9+ftH&`7#Gy8pP#^3O`U3Ubr-oU=s?{1}p?`Gchh55lZ z?OD;lBX|Sd4F-~Is4|T$QEX!gW`X&3@41J_E>&GPsJPd>D&1=|X_(23M$aui7_}z3 zQ^1}=FJHxOl80SwxL1fo^{%uURduaguTd7!?NKVHx-Uv&$9%&`4du&2ZGQz%BiI_H z&C_(SF&`ZljPZHMsEhb2@qX)Gpda2()mfki%)!Eyy`K<>HtY@?LI;x3H z(+cxvK;VVWys?>SNmQZzTd|r&&cwvlt%-+Gf$jt8Jgxr#D=609R_Zb_B&OkRw*}W* zyFn6+=^iF=T8u`+mk53UD#D5@dM>>F-J=_It^YRtci5vX?xhwmVEs?#vdQlCKRc5e z#sA%a<$qs${oi{3XA1>D`4L+}5Vs5g2uMhGhp@5Y#Bi*j>I-LGla?T{CFTKAo;$k2 zMpA4@;`!tNBaK_5CD70|KPDM zBLK_^dNLcoaQg>ZD)jg+0f4&O7Xf_*fas=(Y(bDH{qcQ;f%M0y61x!zVgBeN6xzIP zw!jjy-y0GDWc3I(p5Jh56t0nlW`0?HTJ!GlTd)ozHf!|C!u~|NSW% z>fi11Kh`Q(!mtit5EmKG-`*&Z%@4$AOMEuixYSxNXb@Z-`#cEtlQRL^UwPYPNDdPo z;hu|9CRX1DbP>c0mffK(ibKWt^9y*G|8P>ni2ZCr4WG_WaHKNJGs zd)~AP>Y7T}X!j7ITy#R}1)kMg4TRvx(H#0IN)RUPVYF*Y5PqiRnezx9Ps_kpS!uwcfQwQ5N|&S*5gOfZ zJ&3qA3|s>A!hJRPLX1Kbfyvdnk3iFN0QF$qzz8^Fkg)3a6M0f));#KZAH zbDD_9QEdNsVu}v1u3ku_m-bpEspyn7Ab@Vnt19)5r`QSloe%?y<>%4`XNc85wlz#D} zan78&%QypZ2v1_qbZAS*Pp>Q)wxBAa}BEbguvY@ytD1_)SL!tp7;;*JJ-@vbmA{zms{~ zZQ1Rv|8kyBlziQmvfMgDeko#Q5%NW=ITXjR1S~|{OVAA)Ee)E-nh(L>Feex%Sg-(# zVxEG8(WVWD!GP_mfD#<@i|Wg07$yZU`n8)y*Qa)_wqP%1RqhYaAb;@enR{1GJ-D=Z z>g3A8sk0}S{8K2E`DA2FH+ZB*bi9HP)GGIEu2FA{J3C{}C7h3o;-8i?zuZ$4Kv2er zW+|j$brRqq2$Ikc6Yn8WA3>ll-HU#py@r2`Ul9=d56oW;!q^xhgJ?5imm4DOjSG>Z z*dc)sovc^~t<)6W%QnUiWyx-7a8&O*hk=m%FMI25ji>2j8{7aw?Drlm16#}gR4SG0 ziT}u@lOy}@MoJR9A6%H5J5_gT>BF>8u6eDI?nL5@vLrf?>xz=pfy}3l93>^v)^A|~dYpp`O;wF5WOCTo%9*PbIZQQFP5fOYp zF#np6T<8hla7USIg#?*0K0yw6wv89rSHikbw{hed(F+z2Q}aCVWRL>EN>2%hstwB| zh>p7`8kq)5(EfmN%A5dl44nubF>U)X?Ge2 zW1&gX+z3~Pf{%?OPtNB93{KkwXJ?IZ(r^(1Y7FU)!;kEtM>ieNE@m_O4um00;N;m8 z20Pn!!#&n=8k>P6Kp2kzXKD_KfHtQea0u9m2ZF;2Nt4d{SluJ*o7UvGnF+*W6Z0!N zavYxq=BE(7e&^BC_quk-Kf#O~)Ifd5hs?NSD#b=g|9Y1{1ZJ<9;cg5b&P^HDiEsl@@f# z?XCIXgZ*nh_#kRtMe|h~N8Q&}eAbmN;@Gy;^_-B|a~F;KWmg1~tijL>M7XxcuJD}F zcH~MC@hBIsPFr_gk)Oh8SFaM>Ri>g~FFID_WodnfdPB6tk3j3Vr)4+tD^>g*C44qzEb1_*%r_G6MQ zX$;o*G)Z7tp6F|B3Nxx`^GC2EVEnlQ?(0zZ9aMhoav-ePF%WgMlX#;Ev?i=`w;ahQ z(Sw>zVZ=W{5>Iqi2{>kK0K3RZn>%;}BuQL=8Cnel==kIvI|6YWJ3{2fj+EfTDmjzj z80|aep?i!%h5i`p=-RwYZF+L~z{0&Jj;#oS&G*HV)7FHK9V_-IeP3>jE`sJJ+y-Oh zA|@svZKRn8LI#h6XSW(@Fkom(go}D=P&?*a26#`FE=_u|;Y@H7#*^rGMn=)6f-DZ@ z&{{*9(i8<)4rGn#m9-LW$)f~;5kcyu&lTIJaX=uDC2ZIt%>z6^2>8ZARq>`R?SKX% ziYHH6h+=+g%qf8$VX~=1;3Z+uE~yna;YdW{B9VSLl!9CYkb$L&ByXr;jP_~dbK1f|FJ!ZF9z*Jjs0yv#Qo?wMr0ZVzmZgEQ zju)os#~wQm;!zxfl6tC6UqIJzSc;NJNoL-<{r2eC@sr2c>9O-sc}nrr$#8gbr2`xr zq*>@s!kl$JO5%!Mn3l&Ol*%i&2@vO_Sec}hk$=$(7f|{t{y`766sjbifSII|oT0&T zRf@*R??$e+#iwEb!|oE8T0;1F@ua~MN~usakMUa<*Fem1MKmHaaoD=u7Y9eU@S%lk zEf&GJK>7-+J+5mm7JkudjLU!BJ2VkE=ZuCuC!TY1l8Qwrfrz;<3(@vukcd~mIU#3~ z(V$k44P z-WLn*XC$!G(}%(UP6;;86|jxA5sRV8Zek3txl*ZzpSk9CFS_Ei3z$snU31~xhOXMN z_@cjxDk5#W>n|#8f#_~R)3UxYy)D&1pp$I2V9@|0+J7lKSnz1!K%wgvGWdPY@F5IX z7=Lzk!`@_8KhaRy<{;BjLH6mcoyu&F{z0ylQdHj3ER8Y=m^ttTf&mFNH=|a$ibIf~ zFs|36KgcQ65CB036s+vJu_Saa6lVH7wXJb9jM<{~}wf`4q1AEW^WoOchTg5{V=ceuiJ#RK_4D`th;S?pqa^kktv0m2%wgm*YmCHeWbI5nVU z38>@LKma0qY9RD*XQu{2{LgJfg|kQdK0av<;QyIauIK)TOa?i@NBO^f3=Z$_cKIK= z$`<4>hg4)*lDH^Ht6N&VW11jeS~ymPqAC0(9%# z>j0QCZCK+oh07Mfld{h!U4tno!7xf9BxpaQZ>%%?&xO|(j&msiXD(1Uf0WtW#(nyK zoV#Wx7FLd)I5EG#p~p%{dS%6)xUh_%z{jnPq7^Gn$p(N)3yXa0@OdsDKXrHHIGs3i z*3l;87cpVXTTBdgl8!i#5%VMQo6O{`L8c%(IB(Vw(%DXKM-ViORDjhT7ir|VeSy3J zn0%jN;X95jX)*-CJj#H$fN{HYsp7R=LgY84Ik>S=XHgqssuoSHK!)WMlq45Cf3bTd z9|oCJ6)|zZ&qZ*OD8!Q7jPx|mrB8Lj#}T5&kN4r>Y7q_`0rLjH39yEMRwZmPNa5yJ zmnn$?(XpU!?q7y{@1%NeZx}viA3En;lB31^xeq26DT0T$l#L+o0s|ra582^M4iD5&s$tlWL6|q^oR|j*Ur!;uE1{W#nfpP07bMgY5nKB7` z!c8;0g`?cUO$f}t*_`qNh`6I@CJ~#+gvgoHn=X8q0Olvq(R?F*uq`^=PLyNxLSKo* zN~J>;_0lyv(j9};3?NjY(1Q8FL@A<-abaRyuPowpDo9m9B)5xIul94z1QQhZ3H7ul z7n(E(ox`{*h#%794A&}t8jZJWP_0LK^zAgRcA(nF8Cp7Z>=YONqO&HO<^JiQP(}io zeDu-3B7sA|TFyzSA)1Zag5S-RBE9k}$kRw=5^iSrSh>z>LK^60Y;G>-fS6IBogn#v zX=;);FfdjQ8k#%;?;*R*7`1qMl!N9B^Y*rL96?UNxUUBrl29_$A$E`-U~|z2fyQQ_ z>9WgFI-k|wONDZK9jCU+zyfWL^>ipQ23!@YqNVibX98sCF|BS9Rx29uw`F8c8YjxL^%GejDP zejJIM5&dP9UuBza)65qmO7!R-w}7ri^;2Svfbp&cMeN(HVSsZKj4v|)YyBd%a5Uar zufqichGq6=*|B+>5G+}~H1j6E+5uRcACP}W}n{M3nN2m{oHaOV<+^h*2gEoVK&sm~t z&zUeG$_Ol7a>m+kvoirP*cdmEU>;4n0Qr0F)am=>+K>otTnS?b1}14q(jnnimFUCb zWRT1x7EdeTS|Ug@cqqC=gS^r7PQjZF86^cUI@5qyeS?U#@B5`wOGi)my$D$`v6u{V z&{WZaMhp*EoT-!X&e`l@xTD6L6 zEg>&Dw|Z;=#r$U5w&@u=3H)0_&pOF2+NkB&U6r2H_Zua@l~>Ga{K0nq~t;j6XLCD zX#^ejGj7*g1qOuz6Hwk5)&!l_Jey~$5ze-bOjTK|MP{kkPxU2jRv#J8ye{RMS({f| z==!7Y9-(;mHrER->>kLcvRv-;2v%3)(n?x~VV}@vM#BcZg(lr-!TT3<8b?R;?kXxW za?_PLXD2PlIXlrMv;%kCk>F7~9GBxC$w_q0Cd3WM$k9-a zcyK?ZArif&M2H=@)TFb(BP<_4MX;n?`xzG+35}g`SfQbl!0(Vmv&2^!EXfn|D z127y=09DYSU*@~3wP?^uoxw*W_((W9JabBDL7LKjQ|C(%#FT-oB?4U7L*eoetgu{a zXA|aUBvp2$4eeiYq2$RK=o(V2hd4tB^mzv33^q(OHv>~1XxN+b1%e-WX6byAk=POr zhYfp3INUs9=#?o>bQ=*q0dr$Pn~!A5eThDYfLQ}(^7Upq*mXv#u0z*_JuxaR$Hu_M6JRSifR)8I7*H|VIw%wZLC zu4^1`I2L@c2?nX(e!#myx=KfNL}(y&!Jv>)@%*1@5|k|QCDbJvLFQ*bdP;f1{n$`C zc4}coqS$4h$Slh0y2L$8tJo{Uwx1G#N>G124$k`dOQKO20-2BHwFVy=HlP@R*|=Cn zFbWQJc(@l03#v*ZZtfhgPVtB!b^yaG2UQSr^IFt&9ntsZo5(TBD<+vURE8xHSAoJ? zL#G)WEKr*u&va`RG94trz)o(|RyU!CT&vwc?iQiwp`%pI!Y%k+m}b*x4&{A!p$JP# zO8URZ_u^y;t$8Spr#MZ>Ua-DnH{c)zv0_O>bP+s~deX836KFwi?~>Dw#0$l)EdVga z@U_5x=Oi-_>ft}kKGad3bOjQEYl>w}Fe706w5-9%qQlOhi@fqyoO!;Oh2i zY}qzSJo%(5g5RuJ<+%qL;S4}~7pS@j>U;qkaMxEkP>CJKQ+1)>nsWxW<7LFZPAp-Z zE9;Q*Hm-z#J{&p6le^;2^T}Fo2UC60zlN6kogi;s|Bf?|bl`VUI3=c_Q4h>Ahon%A zb{JFmCNR({QHGwjbt(*)CWNq`ha9bl8Su$RmT9Ba;&`pH2M(n1+CQvBCN+^k74WGB z2}XjG&#Gh#RFaJ3qi54$+SmPHe*%5iH^~OHWt45PSK!5xe%shqmE+9*RNT@fe4IGGe4WH>`mlUVE#`QV3j;JMtE zP3FP5ak+~)I65cw%?;#!ComBw9A5k-@$<3^geFar6wv+{j|RK&7!Um4aH-Ttu&4O= ze|x^O6kpna%>N74QI_effI=)22GXYtAK~MdECO2azjc?sfnBGF^$18i<;oBm+MSjf6-=Fj5OxB_!H#9`vW9^2eR5< zNu`|yX@?TPbfvvSfYe~!P!v)iMK*fDyKevzpkb!VL17pv(XC78;a&5Ur2}2W7LhXn(2VKAg6-6AT*6LgW zLIZ@}!s3j=(&2?WlkV+|A~qfuVCX3gA#O8~2^n4I3xR8k!icZ_4bS)Xe1IJC|Ei6$ z$Fct$JsN0{CPqUp>7*o(EjU>ql}y6%|8#_I757Ua z>SzAe$N~w3Gbv2CcMJgH$t2fv5jJi3X(~g}``3&b0SJ?XvirV(aNGz4rd|*OfgsYn z>$U(u(Y7JHp$G*oWD%aKjqK%lfi}xA!Z>`Wm{`yw|3JP5F?M7t?3b>R^KeWe2UXTF zLd5rh;|f7sM#J2c^ylhn{c7O<^0yvCxqku6ue(mg54SHcs|8DggLQ427bq7_S9XSHC9ebB(eu?tjPdkSgP*5lg&K5i$_~G

GJtjGe^00L$#U@r;zod}V~1O-44#te>iJ!Xu<_D>hw(a-M=3<&?@<}`1J-6h*^asMM< zck_QTpUUma4zW)GXk?~F)?h36z1uGw1wMgr+bx_MTrZe^L{gQ9LJlmB%lPf@zt z26s~L1up+NH{1DG#bjYNm9n$5W$ zn=h3zGbwwvFk5iaNjsaivHzJ(=5obSDhX8jo{j<7>f0skIP8Ja-R6G=N9}}hwqKT$ zyhHv^WoFVN{@*@|cU_Wmvz^a~|G$U%pO>lant}#bBz^5r|4-(VBmI9cX0N}5$bZg% z!nrJWOkF?79tfQOt>phquE+jQ&y4cF^x$zA5A?F5su!cHk5?RNNHe||dWbP@e7=WF z;DA4YjIky-CVZpI{t4Mrr|yTNws5-dx9&#t6Alh*S`Q?^_ZV@7ZHNWk4Qihu)g5b| zkJY=hc-%?O#+uGB`UARB&9f$vd0boIh7rP~I&(Id48-f$8QyOlh@W@u3;goL9%xL% zn1$~gOqtl>seUrBLHwUg_uT)Q%w$LU-;JOOc>U%W1LCRcM@;__liUc*KR?7ZC7a#rmujJ}e&pz`T@4V&7<;R~|e(8tr zJ@L%`S3UA8zy7gDKKp_H^^4^{c-=f1T3`}UXK@`;NV|L$Mi{XhQuS3dRG_b&bXOWwQo{@*TH#cw?I zxmSPmO!PIk{N`<+F8sScdE2cYxbHpL&EI;-zkT+$hZ--r%Q<%8>FCpUKJ_8%D~G;* z@t0Ok{OA|Ib$tE&x8DBwOTX~cPyP5Se&Ow_zkAO!fAx>+^4xO8NZEOd(asW>ck98K+Uxv!!%8dnnxjV&IQ~^1t`~4}@>cjP(D3 zn7#f2mv1^ab>9PLlet1NlT6N}-f-_9e`?YBy*qyU&42OPPfonyRfm^8{!n=a0j z?R364o68=WDHjgqvv$Ew=4NNhDLdtK!Wi^xH}XF<(*Fmcbx+>+m;aYv|L*6WdFIit zed$W#H=dq;`F!C$AHVR0@7wpvAAH3#d(!F%$nymQ9sEC}IM7|lf5iWd{QozCMmpv{ zKQojjcQOCi{~y_ZLz#>&pk4EyNTu_$@bu-=$;^yXNEea(+0N%nv*~==wu`CqY^K1a zc}JgJtp7P&05r1yc7)~K74E_1`ZqrM|Gs?ScOLqB{?K3l*q`!;i!|3OC1OpxXUn&o!54%|ZVPlN!zoAS< z7tpTxPaMkS3x{%cHh;*;WbG6(7o@YP{0z1PbF;}zA?;-Ga)*xjLGnMD?uq})OR{Gur6R-ssUg784dg*vJ8BdL}ziol8(W?;#Mi>}j J;CluG{~sj5DP8~o literal 0 HcmV?d00001 diff --git a/gix-tix/tests/fixtures/generated-archives/rebase_edit.tar b/gix-tix/tests/fixtures/generated-archives/rebase_edit.tar new file mode 100644 index 0000000000000000000000000000000000000000..1dc9d0d9bcc072d44fa8e1122dcfa0ce0ccfa104 GIT binary patch literal 75264 zcmeHw4Uk+}b>1wy7^WdEVlY2ROy0I;cV?vN|G&{_N4wgQmL1SaLK-26q@8`;{d%TX zJ>9+ix;2{BY8~)TQ3*e`3p)$;ngt_3=G-2iV4fV}NG!)}SB$)?lY>& z+ss(!_$T6Eg|Yas5hMK3_&cE+LFZ8$Pe=+xSIW&{BgXl^Tlt^MBuDap*aHLj9RJwD z$@}hGTv|SMbaCmvGcOrDXx<7xQ+<_E96`%w6nJ1=^FZyFnW&I|s_ zZ+&BI?A7lW8@uWIDDyo0|Mai^#?{|==f>Cn$qPSx+Y6GPU%lnAZ#;N*?3T7A!TPs- z;_-vtUxoA5U+RMMjQ{-)_W$W;-*e#1SHAhCJKlNrKPUD-YvZ4d{nT2ySgeRm!unIM zdFAciUxo8;zTO4rmd9TBXJ3hZ{6lYi-n(!4?(gnze&Eq)_S)FZkZeGfpQY2sj^WRX z#!^;1^ooCJzx~AiU-_-qJv@EacV7BO&Z!f{?;QWV)>{v}=0W?rPk+bDj^^L_h04D7 zJzr}66E{ByaH|8I!H?t|_c|Ja&cuKF(r^o8TUt^Jqj(f^XUL~f-24T%2R zfr+Q{nW8g0n@FUyg+elUuuz;WILSgf>l6#=l#?hFGnsTIIWiKrqvDhIU3>o5?Pg)s z8O92*2l<~&rQ@UZ{|2yC&-lmMcGIAm--G;5B~l~*-=OHf9h{N;-;O#z`R{t?f7|~u zsNU~o{%3O7FCG*E+rfE?@BgN_ zBmHkXCVKL|lli||t6ds~;%CUR2mKEYz>)obBe<$P{;_(?U5zwr5m*6*OSM)r>e_2{ z^~7HvCVj>~lS<{f_kS`Y`G0*h-)ZfAx5o12YRp{?h4xuTs+My_2Hm1au-rnUTyI*< zn$@Zo?WSYdmN17?jDSC^X2Wr;S`|+bL|~D9qM>~Nv$R@v6#)L0tCwq+oT8QAV5vyM zDLCcJPNZ%ZE=?t-t@Tx>YN2++u?tO?O`#NE5SnFIi>7541xilCsTQ0mcN#!ZhtKLY zx7@5XHfE%(0Bf!~QX4>;jj|&!4f=eQZ`jqss?}Vzn-JfgtZaBav)D5>jKTn6 z@QoIo>c)bWy;~s^YSmJCrPZ*TWzgWV-6-4nibI&LLzSIxf*p=0V*&AdxEgTD$Bt8s(xRkPH<8An&Z&m&>(Q z;}Do`-9aHmtcH?#r&3!7eae(%YZz#-1CTbA+f@OEgtHh7+9cKMwQ{v-m1+%ita{n0 zmQmMQtAVWLf{eJw>W*@{;S&X~w6NC8Am#dMS=bF&m+RiR8KMz+WhM;&3R=WyYma5R z73V?ABdn3O1)G=8M3#t!k|rdDv;xhO2%4Vrej;&#s(UL4pQ|sUv(3$YF${Ft-jjyXcasHmpXgD)J8!tQZlI7-?CN zHS6AEM~;F-iLmqtR4tM*YnHRtst|d~%#wqNgdV6YA|yyQqSzoCyI@rGwm<;`ATs2h zq=mp!UTdz`v{mrx8u^PQx^XFU;4utf;<8-<(rg3EJBA31hYBu$0~7R>zdQ{B0>R~m3Nx!ju~99 z(I`1=3FUNBW6Ic=kqswOYeXvO9EYjERmrZppfIH}$PGzTsd)2l0pngIi*aI0lPZhb zfKNC<+HY`IxmE;{u}Z39!J)T+vmaV+z#as~1v7F_nC z1?8+06x~7;f{_Jxy$oC-@tiA8q18kyzU&T&c;yK$dWMvKp&+@jywtdqY}8uTx((q4 zTGT;t;Szii0=ZbeO*cC0N-ZJ-(F!DMmR+==3E(jWA_S{lD1f6e_!X?ZPILub;u3Ec z`OFFzidHllCO@@YK4>@`5rBf1dDX>80<&9%*K}@1bk0W}vLcc4N|mGPw(^j-EsQLl zL8vtxC@G*+H8f6H6h2$$;D}lx{A!hK@MJL@DwPA^$y1RMUJ%MdtumJ8&07e9K4+F* zpVG3dwy=)dD;N|b$h0M+?g%$FYONJa=KurNN^_zTSr)ffm$5o8Lph?x0!F#h3R6gg ztvT!1eW#XQwah@3kh!=i^(NRL-t?#8 zW)$>Up<8N&Cv^D>2jMQ%T9u+m5Tn!u8JC<&=`LhtpLVJSKV{1W>_4UFz^uMDNⅅ zIh_0Hw*TOk8u|a)6dc~Sw*5!TZEFKCkM;eM)vDT6EZ$IGpiCiNNY^K56%I8ut^o^B zFYppeL73tvxbB6G(%M*Qi6%aWx1}nT5O>v1L~5AU@&yxE%}D4ob=`n_+2PZ%*J4Nlir^;`|8qgW2HjpnJaSxyxc-C5gJx;r% z0!1@q=E%HQ5||^f4DdY}4q=+;_f(jQF)N<2VNe)P2Gxi2R^(xrATPn$4#fF}t&4|9 zC@4^3!iA3tU()K3>14b;zBb9l*Fyuk94tPsqBRBJo?9sGSOl@CU?5MV1dYTMslysN`)f(-1@h#G_tv6~^Px%O7>Fa@}JDGTBvdy|sRQ>Yd z=}-ZOR`D`WF-&_be74HPp%{?H;r-SlRv1+b%kSU6-#Vr31#z%sDS@u6f*OV=)`Cv9 z7(a9<a$0A3*t8wC=0O%h3`_z=YgxEdn>JQL@P%P% z-_&S&-BHWH5Zs%AGhTxNRcJqscdo<`$M(-?UqSS7_&}?X4xIrH-PKy7xom*VPh2(M z_U&uKxQ1dPz`_&V(=Tk@ie5GwEho^)@xb^l!J6)Fb;g?LXcfcaiV0DU4!U^nqQ~1}iKZ3B?r{wcm_PSBu(WQ!U2Iw`T-X7i zgKNSJzIE90tx+p^`1S;Pt!q_q0G2@aNw3NzmQI?C8+72gO*cSgdMLx3>-W)PyRi!z z1b}Q#1e&^T7mpj|!5=4YVQSAtuu6EMTO`YL-G-4DJT1cxxQXdI+X>TdkNqZ`*4>G~ z0-&!tM$*$Hf}<1QH?6UoNwzt@s&RGG8y=Q|?2Y(1`!lIsVUsTQ)(f(%R@i-CyP#fI zr}yj3I#S($G{VfKKL^^DxO(9#g+sml{7#ltO^&R7R=4PAV9Y`OU8>gBA=9uv(fMh7 zB@jxOZ|zCPZ6ua3>xU8NqUp6oRoB9IPq|v=_Dx7+S$yPT8J=-|10VC7Iu z2Z(ymHs`;RnN+sh|C3InQlt2<8!-ND{|~g-t=&J^jlwz#=ZNl9*?HOjm%U2e%!S*d z)Aa*zd=oZ40(C{J5daMxOFJZyY+mcm-s{(_SY*~xk`i4X_ z<|?HSyf)lw?cEN=Vku(O^B5Rhd!rPvcSX0AfFW+B8m)3lV5ljuDd6UC)ssJwfXQc_ zpxdf*6CSd)z1PczxDezW!YI~Y$nUczlB|06^l>VO+m}UY^pj9npJcGtG_pupo!{ZL!5yW}}`(D4-%0+xPw7ym_jdK!ETa^ zU2V8mNJRCnv>Fw4tz55B7SioeDyOt2u_-cQw8pa;ys!j-+B0Ejm14j3K~7-uK2SE33JHt`VogE6LZ=@A!j z$FZ5ULi)7vq1K^I0fNywz|f+ElUut1!p__bU>mz=v=chrJ~SQG#HMM5@iQRsLPy@% z%(Nsb(EhDR)#A*=z}BtF`%!`J13{kFzfTnuYi}!cnHUn&a8IoT*ITwhMd814VufaQOzz5Z{% z|Few(pz{%1LJ+qM0q|K!cZaaC;zV$)wBiY8U6Ym|u_fjKQl2}yGLJ-BcgHm8CPUeE zTa+#~l>xK?-wGn`5JBOIe27l&Hz3qO!l1CpUYBJ-(KC(icuR@OaJwhiS_(z!xrksl z9j?Jv9bQYv2%@k$&)Pl>NFkJDq=R~dV+5v!r?Hn!`5w6fG30{?(=Wo9kIsh^R}%-q z6L*ELh31B4Wp&N}HK(!S;W{u0(0BghtY26DzYKzcNBaK_5CDUm|CnQ4MgS%!_{nVi z!tL*CsnFxQ1OV#pTm)5t!37zjQ_mDr0&2+og9ghG36n=P=I?Dqx) z0H=Bg8_)7FQ+~^`L!9>>YX#?j&22{1Q8cirFU|Cu|H)iW{?BxFH2=LJ8tUJj&VQ^` zu!Lb9z#uL#p1-|OBAXwG(U$mZOygo}Ew4dvb?oyX*iX&`?0n^Ivms}g%n|OnC}(2z zZ9o@6ykMy|v_)~K6n|m?56*ubVxy@<*4&i=iGe=zKbcGRj61z*QGs%oLq0s+t-&s>@F2rf^{z*kvrz@mVQQT$4mr4$Jo-e;XcTpKzr z0eV4P4ZRSf5Jq5frS1{XU=E-ztQ+`2%I$_B-&;-k`Y?tP+sDvqcw9KH2v(5eCy|Dp zb^w`48GkGvdKqfoB_NdcpZ%An`;m(G89N8dw++^@L+^wHioPbZiWt$*GHcy36-09zVt-Jm%4vlV?va z;6XCPQ1AJPR45c{xeYmohrs-b6)8va_9b|<5N!Y>7f#Psl?NQA)?)WZ`;BQV8bz^v z6R~MJz`C%INHG)4(Sgm(1aXl_4unO{JkoS%W}m z6D41_rL0<=A-@!{vIu#i)f|drSOOLz?j`7kjg|(_W6g))Z%l`_RPJ@Cr>Rco;1mVxc%eDUm=1$g~YVr^*l&u3ss6MUWkf~#x zGt7kIQ>)Ep-JOfYO3)ELqohaZWdOE(?JE@m@u2f+{~aN_K7L!BMF;T~!^ zjSXKCAdE+VGc|`qK%3JKI0WoOeZgS`q)BIetnQ(;4Qpz`j0EDbN%>{%IgU?*^3#Z3 zzw5~9m%Kt)>ulASf#O~)I{DVhijKZ2#b=g|9y@&+i*DTVfNla4)`&ne(fXjZ@`fBf zry_6g;rj0*han|D0R9NP4E=vx9d27Yd-zd3^gUD0U=m^WfBtbHTc`IRlKj_w~ z%k@huIG#Wa16Vf@`csCl_N8Y|o|v0^a`8GT_a z%jPWS<7^u^>A}sWFyfye zi6=X&_%mj_&vcQKHh1y}NSw65WN0-IpyRQ3aFIb4JftLRK&#whL>hweTK z7kYiH1GRY>+T_%Q{R{UVKe{XgHs2Rd%vh5ib*#vP^nKMLbP+T+;5HcNEMj5;@q3c$OvbZOFyb!U>BFs?+uGct-k6=ZQJ1=bq!l%^=a zav*C=udJ15OCBZgjR$c=MI!xhCXtfuqHr)WsL0%N>4Fj1{s{ynmr4JT$WIC7CSH%h}SrJmf zWl3Cum@S+=lv0o?s5jM1&KU~_y^zhyc?hX5!YZ_SOUcATBwhEycR?B$?|5OFe(0g| zU>>D0IH{-V%tfGv!%~z)$};oT9e0GskDWM5q{q*P)PH7SFP<=TLMauh<}rTj;_8bzu82lt#tvC`c;etN7e2JG)neiI3#_lO z+Wp#cvG9s+(J$|HZ`VZNoHH8soOsSTlT<812}I0=S%`KfgG9V~%?Ue`jrz5M9B+1o za$VLu@Tmu3hmERxyIswie-vKf3s{-NbrcoF{4-G;P_11nFc|?ssc+Epgk#|rr z&e1ewhOff(jI5 zdiqc%fK!Cca~0D@+lWNaWH&Pg*IcR8GoQKUb}zc(wDTvK*4uL7-GQ#!vG@XCB^8Ob z)AbjXwjp#kqiI=Bnck6VAkfJ++e}fPB8tD1?Jsz=u&>Z{3mN=AXXYUcSQvkHW!>Ij zRzJ~D+U6kBQbG3VuARzkm;OPnl~PpR(ku-#379$X1%?3&H8;Xmse(h0;4rS&yW^b0-$bhNPV_kK==h?o(UdxE6f84^Bd_I zai9Tf_T_RB=L%#Y#)>I?+M(BrMkspyc0-MtT|(u$##+Z_Qv+ct91BlV1Mgg4#p0Cd z?i&-rHEKh3TQJb(Cc>ybyS3G<+^v4R6ElSZ@zDx%lt%GFeIZtf*cb78$`2CJjQqX^)M zUAPdt5TjhVrsoi^3I)`*L_NG14uPFO<}W}CFyNjWur2}C6l zZ6WL!82;*8tWSJebZ5woH&$ia^+Z^iR}sY-`Xd%*`r0 zU#^tNMm6h9t%B_Iu9b)*7}nqJZ8oNnBbfeSLLns>Knd@%K>+JH;#&})TjyQ}z?5ji z8lNd#wg{e-eMUhIrl17FD2tGxy^Ow*&hWn)ytZ(hO9?o0LBjbX%;q-X@&6;-H9Nkr zeB}7?`2}`8Qbf`#EAr^Y3kV8)#9A*{k;1fW0GPC}$j1(!=Y?Y@?+G2F6KBpk(q#N1 z28?-&Nx@Fm5eG72ek6XAnY=Z~6l@3Q%^E^F+sW++f`*X_u)0%28hLJCAg=%h-{V;L zjzdeD3_&Q5GGH!H+%8@&yKR>c=bQ2z+}NnIs0}eyi>6lKgyj$vB^Nw@5xt@ZgOgMh zF>%1pg>aH6FeSMe>1v)!kLzR}he#SP-iL>)ML4hr%o_kFz$yY-m9fPjg_~bpreq34 z$AZ7Pe;M$-lk2&?VdgP$=p1uNju!F8-XC0~2p-;2HiEnd3Iy~&WQQ+0Jg6HHZ!RYH zfb_sV`d>O7?>+y4`$$Ln-yX(uw^iEuAEd(8;XEEkK$c2&0BZ^&AjYMJK2JoQ@TQe% z6XFj@2hd$ASIb1+#jaGb}&nN^UW~Eu)7bClz zsuu7Lgx$u%^v(<@gzQW^)34mAA%0(3;XHHMXbnQ8DN=~?95FpK6dAZgXE4sG*QmKt z+Ikn0wCdPsbnG=R@f-DLI5|c6svtIt_v&CQ^pplK*We;WJuqq=VNPCvGeag}Pq=A@ zw{VnNxCw#zH=EO501y|N4levngN6=6k0Gpm?%Y*F)mDu>y<^EP6eqdgyeRy>eXJZ8GnGH9;2St9Y6VjX=;i$ zFfdjQ8k#)9+(UMoacc4ODErNs$=ln`F$6h*;+`ICSVGxUyV${gfXzi83>ry6(Cf-(1#J=4g24;?e@ni;ItzDuPj>em7b=ZMa z=3jg=Hgwat`Xw6X^b04Fpef&fc!mo^7PB(+K>=#TnSPK1}1q)(jnnimGJ%IWRT1x z7EdeTS|Ug@cqqC=gS^r7PX3z?86^eKJJWzzef^kq)Avg!myR6wun1T&k%)A&-%!zl zMhp*f9$_}uT63XrGzB_RR*pZ?#5lgEM40qOmoF1o$` z4^2PabN@#=ofz%^c=Q|Q-?siwMQ5Q_-+-R9+C-%Mw3UpJ~n<{}U>R@n2ViGu?`n7i@8T3)Yjq{0|f-|2f5 zPXjM71XKo1^E5c~#PBP%A?j3}ci}nb<&BwuE#BL1f)DC#=O+Nz~-_&2>r1g%~EpThr3;5%)4~ z*IRi8g@O`L-Wb*doz^^?XR9HOw)RX_S*%57sn|#LC23Y4>CU_^<(gTWS6k@%qwgM| zcy~9}@-^5!kWVGK-01PDa77 z`IaD5O57LP;CXmhc}?r&%cvXZfZ_L@bl)B2;Yu=*f`p~WOZ|YWi}yk>WX%*}aqXWl z{R}81lIrjT-0{4E)Ud88=6edEaKKT>!!AS{wj-KoGnZm{X9zT3E?1ZJ=e&#-w@5T6 zqlu25M{)uQXuV&H`6xZ$>$`GMNe3)XH4p_#@KppUVm3$S5Qr*vlu-i8&8UArrC}1i zrbL(>wA7@tpd&0FKt-^mT>BXn9trfFaae(_lfmzlM6?K^qLbFRGRS^%UI)ytQz;sv zH(;3+mJ?mX@o90tH$D?r%@n%E6YKCKKM<&%+@dqD1VKz0*jggMWqK%F9)guAm)PBac^OG{ zy3&UBF1b+l*zl?Ee+L8WUNkT$NtYYcACUX7c>D&}0*1m3VO z_+S$ZQosFxcY}154r`ClK;ohyA*15?Khq>ATjEOqB^p8Ir_XxIc{2O4p>*`*!m>oM z%RZ4=l+|^KdzMzPSB7mrWdfC;-g@kx_4AfQqcHd~AIobMJ~V7VF#@x3sf1t@9O`g! zFB%q9l}6m$*>9cX5kc$#23HQMAm-+^ut6Qs_vV|(G0H0@nKN95B@tJF!dpY985}H7 zn;_40YZfvcEWyA|Zq!ybpoeVLt}k~BQ1pN(RkL6V9ttyT8qJ}+=PndsNl8in7w5e= zSpsVwisLCx6A}y7ckBinq+nJoX^1X@M^aB(c2EK>=e!VajvXE%G%N&xzHHt8X@Ku;V zDg*HAv7CoP1U# zTcDC%@F4ygIULxqNgQf>kRi^~mheVfC&pPZfm1Ug47hO95 zGMl8j*NfoA$0Bb0Q(t=jxa`zm`WsylC&M_H6ds&#hM}gg*dy}63+uphxhrACaH;^Y18`OZ>& zX#+C<&s#@Wrn3SHu}l!iCS~{tAFpHq(ER_cyY(gC>mlcM!A`IjdJ1{~`8O{pE=U=# zqyZQ#wi(EnP#yLA63{E-FOi{8=*SGZ!1ZfE0w6kt2*wVg{D`EcV^+0N`HDG5i781R zfSLHJ!5Wz)b?%lrxF1|hV@#PvO>oyc>r7p5(IzTJ8na*g33lQA0TRFiS?#T)($0dk zLkVC|X)h6AHCQ(k#Z)brSlbg4YV;taMr6N{NxV?C#K0p^gstMNCN`>YgF@(TQ?T9e z5*O$-9BRgmM2TrKF)dBs*z@d}`<0qpTec`+ctT339kJ>F1zb>3z+q~w&LtqwLFg?k z&L}MHUJ#jNFEUElXjFg!QyN0tX5u7dbe$&zwibmUPyHLF_wC0IJK+CS8)d+;|Lo!R z{@-{a8}H5kl^*5)y&=re>Hj_AYmp{KLoVs0B# z34}8#Ot^Op0OHBytmh(Z+VIj;2BP=3j2Zz5lZ3MSzJOrd2n42H5CefA(!A@o06@{U zA-tgo`7UG;o~n%u^1MKM$}z$?c&M0E&?EmKz6LRNWGmb(T_wlis6-B`tYd_T=L5$T zg1C%^xhd(-)zkVF-~Z)pJqB|B0+v^Ijfx*`Utm@lU_&*NzUtn*i$~5b-FtG+Nf)vE zX+<7#u~+G(R765b_$+k?Ri7O`46_}3muP;8^4v>1hZsi%g#1nh*kh6czz<`_jIFuM7>DhjF1VwgPYw#m{Kw5{?hw)CwBP3bN51Z!|M6TR zJC+$DPCjU4rbgCa%m2OCFYEfZAF&h6t55y+$HXHvKgIYH!yVpk?|Lyd@Y_2E&Ybr-ky4MDGQ|~D* z|2Z~0`B<6c!Avnza1N%@#l&nrYbQ$S*+MFxE6%2}#Y`%XE8F5}Cu8hqhO^(H<}JS7 zW$d&lo*cFgrnA}E^lUtC9bnVuLQkA%)N`@ZbFfuiC$!@hQ`xkAFrO*r({?(Zun*4K zxn$BQrISS`o6b7PbiU}s6Up4pP|M{K2zl#3Z4UcLMO?QU+Hp(qH1Kg|XA#_(EfkW; zgN5R3!ATZ?PO*?qIf+6slSyZiJ40zUlY@_}3nlD03_$5_&wmC-?PlWayetQKm-9c7 zP9;b4f9E9Lb4iZPPCldg{}Vj_d70XtIjDa{^4Bi)|9CDw(*OHY_WDbR{O9<`oGWt2 z)b)exfxz+KPX4E|J@$VxHOl|eL&sq}(94c0ZiKErUU8%$&G=&YA;P%v`5rcb1O7NN z#+u-m;EgW(CS^~Zx*v|(!s))xx(CruI5@0nJ&*w3WyBS>Ar^EusC|Z1cdU6nR`1f{ zaVI$&Ymi~|2h_=`YfT~ZxVFFzBZNtH=4>z-h}W<)ywBPnJzujg^2?(G(3plX3O_!W zGO)u_{iI|4=6^idbN_1`y75T=y8&zguiqSFKwNeGi0MC?BsYNYPmm^L{?{bS)R4Tt zed9kU9=iSiiF7i>x>^uOU?(?!&$xc{$f{9~D9BArZR z)5T= z+;m|2rT3kUXY;s)GoDJk{N9(&oNzvT=X)Ri(trEZur)<*unK6D+#Yft}A75eM{ zzy3K{Bb+O@z4QCu{`Q04`P$XkyZ(6Q@6G3b`{NhC`1G+ao%%=1gFtBn;|5ZobN(lX z((>Nq|0w=%D1*@jv}^ohI}7sa0@CMaU-_TwIscK(AtrDX|JR4G19*MqrfaYNSmjRA!yyLx#|M6M>{09fhxnmzY{jFF0pTCGqJ?uvRJP|+s?sr}|G<*2F51;v^ zyT1N&4}apr5C3%eU7xx9>o1%v?W7y#}g5Z6zEj`=^NIG{bq zf5d-`&VOA0==O)uG5(7KKGV;re6ENN8k3TH$M75-|}}4Jo*>m&t3TQ zxBRQs|FAOu!{>bBmlJ=p`pf_QnV8NKCuYC(Kc4Kp|0#={ z@FV+w`>`9uTW|l*VcZ9H|6cUso8LSLxFZ-(4hnS4|NIlj|C@`C?Ef3VL0#h?OUybr zEt@PAGWpzWDUr5q9O`xQ>7<=Y=My<6>7=r`;_fi(FaJ~B`+vDK&VP;M|L*9#m(rg6 zx2HZi^R=gq{p_`yU$z%8qdHGg3Uti>%oEA~H#+}$BUq|y{9^|XW)pTQ?Ii7k@q@@& zkh7Bq({ZPmwu>p`BSwF0%*21ZKfQ9j z@gHYj{%f!LgZD4~$@k~~_U&K(zsFwjfscOq-=2N!11CQB@1H;RcQaF8xiS42wX!=3 zbj*MF+=ubmi~lE!nBZNy8TN+y$p6>&|Had>ou#eW9jW{2|H(Y;E7e7+{?$cktdiVSG)RR%Ay-+~pzux4sm+CvJ({KK#dgp&4iT$6E z{l6pfURR;>xO2(57#eS<^2JRkxQPKbNV&W^e%^}nHZ@!qh6{!8OKub&&+~Tnd0bsw zL7uj8q>n{@bR-dI)p?(r>lZ7pQ~_NsTl8w;eoy4>K-ypjS+#jdkyrRUQg$?eh$I)D zx9k=!yl>zxLgXT>pp5zvuiF^pD zL4HD0V0jsrc-5}pVwmMMT$8;#j1yd(|GUiplFTGW`Co1Xp{d={{!lB-}#^H uiT_CFa-;aa0g-=OFucOk^YtX7nRqlY%Ko@5!A37f6c|xpM1h|W3jBW&X7`Z* literal 0 HcmV?d00001 diff --git a/gix-tix/tests/fixtures/rebase_conflict.sh b/gix-tix/tests/fixtures/rebase_conflict.sh new file mode 100644 index 00000000000..61477a9b04f --- /dev/null +++ b/gix-tix/tests/fixtures/rebase_conflict.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +# Removing the middle commit asks the tip's same-line edit to apply to an incompatible base. +git init -q -b main . +git config user.name author +git config user.email author@example.com +git config commit.gpgSign false +printf 'base\n' >file +git add file +GIT_AUTHOR_DATE='2000-01-01T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-01T00:00:00 +0000' git commit -q -m base +printf 'middle\n' >file +git commit -qam middle +printf 'tip\n' >file +git commit -qam tip diff --git a/gix-tix/tests/fixtures/rebase_edit.sh b/gix-tix/tests/fixtures/rebase_edit.sh new file mode 100644 index 00000000000..a815dbdf838 --- /dev/null +++ b/gix-tix/tests/fixtures/rebase_edit.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +# A three-commit linear stack makes parent-only rewrites and tree-transplanting observable. +git init -q -b main . +git config user.name author +git config user.email author@example.com +git config commit.gpgSign false + +printf 'base\n' >base +git add base +GIT_AUTHOR_DATE='2000-01-01T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-01T00:00:00 +0000' git commit -q -m base + +printf 'middle\n' >middle +git add middle +GIT_AUTHOR_DATE='2000-01-02T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-02T00:00:00 +0000' git commit -q -m middle +git update-ref refs/patches/middle HEAD + +printf 'tip\n' >tip +git add tip +GIT_AUTHOR_DATE='2000-01-03T00:00:00 +0000' GIT_COMMITTER_DATE='2000-01-03T00:00:00 +0000' git commit -q -m tip +git update-ref refs/patches/tip HEAD From 94bf93a9611c64c77c39aa2296f79bbb58a40211 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 14:30:48 +0200 Subject: [PATCH 063/282] feat: trace tix edit durations Instrument reword, create, forget, rebase, editor, and time-travel operations at both the user-action and internal phase boundaries. Exclude repositories, graphs, terminals, and message buffers from span fields while retaining commit and mode context. Emit span close events through the existing diagnostic logger so each completed phase records tracing-subscriber busy and idle timing. --- gix-tix/src/edit/create.rs | 2 ++ gix-tix/src/edit/forget.rs | 1 + gix-tix/src/edit/mod.rs | 1 + gix-tix/src/edit/rebase.rs | 1 + gix-tix/src/edit/reword.rs | 2 ++ gix-tix/src/edit/time_travel.rs | 1 + gix-tix/src/lib.rs | 3 +++ gix-tix/src/logging.rs | 1 + 8 files changed, 12 insertions(+) diff --git a/gix-tix/src/edit/create.rs b/gix-tix/src/edit/create.rs index 2c45abf09a9..c6a78bef600 100644 --- a/gix-tix/src/edit/create.rs +++ b/gix-tix/src/edit/create.rs @@ -18,6 +18,7 @@ pub(crate) struct Prepared { objects: gix::odb::memory::Storage, } +#[tracing::instrument(skip_all, fields(parent = ?parent))] pub(crate) fn prepare(mut repo: gix::Repository, parent: Option) -> Result { repo.workdir().context("creating a commit requires a worktree")?; let head = repo.head().context("could not read HEAD before creating a commit")?; @@ -177,6 +178,7 @@ fn worktree_tree(repo: &gix::Repository, baseline: &gix::Tree<'_>) -> Result, } +#[tracing::instrument(skip_all, fields(signature = ?signature, tree = ?tree_mode))] pub(crate) fn perform( mut repo: gix::Repository, graph: &HistoryGraph, diff --git a/gix-tix/src/edit/reword.rs b/gix-tix/src/edit/reword.rs index 738cf96b7a7..6bf9f589987 100644 --- a/gix-tix/src/edit/reword.rs +++ b/gix-tix/src/edit/reword.rs @@ -20,6 +20,7 @@ pub(super) struct Edit<'a> { pub message: BString, } +#[tracing::instrument(skip_all, fields(commit_id = %id))] pub(crate) fn document(repo: &gix::Repository, id: gix::ObjectId) -> Result<(std::ffi::OsString, Vec)> { let editor = repo.editor().context("no Git editor is available")?; let mut commit = repo @@ -67,6 +68,7 @@ pub(super) fn missing_agent_trailers(message: &[u8]) -> [Option<&'static [u8]>; ] } +#[tracing::instrument(skip_all, fields(commit_id = %old_id))] pub(crate) fn apply( repo: gix::Repository, graph: &crate::history::HistoryGraph, diff --git a/gix-tix/src/edit/time_travel.rs b/gix-tix/src/edit/time_travel.rs index 4ee0e5ee4d1..4b1f8cf988a 100644 --- a/gix-tix/src/edit/time_travel.rs +++ b/gix-tix/src/edit/time_travel.rs @@ -12,6 +12,7 @@ use gix::{ use crate::{history, open_repository}; +#[tracing::instrument(skip_all, fields(commit_id = %selected))] pub(crate) fn perform( repository_path: &Path, bare: bool, diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index dd95f7502a4..0d8c2f42964 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -2735,6 +2735,7 @@ fn show_commit_diff( Ok(false) } +#[tracing::instrument(skip_all, fields(commit_id = %id))] fn reword_commit( terminal: &mut ratatui::DefaultTerminal, repository_path: &Path, @@ -2766,6 +2767,7 @@ fn reword_commit( edit::reword::apply(repository, graph, id, &edited) } +#[tracing::instrument(skip_all, fields(parent = ?parent))] fn create_commit( terminal: &mut ratatui::DefaultTerminal, repository_path: &Path, @@ -2794,6 +2796,7 @@ fn create_commit( edit::create::apply(repository, graph, prepared, &edited).map(Some) } +#[tracing::instrument(skip_all, fields(commit_id = %id))] fn forget_commit( repository_path: &Path, bare: bool, diff --git a/gix-tix/src/logging.rs b/gix-tix/src/logging.rs index a4df04558c5..f25350b1fa9 100644 --- a/gix-tix/src/logging.rs +++ b/gix-tix/src/logging.rs @@ -372,6 +372,7 @@ pub(crate) fn init() -> Result { tracing_subscriber::fmt::layer() .with_ansi(false) .with_target(false) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .with_writer(appender) .with_filter( Targets::new() From 3ea168fc45a7c675b8ddcd35e12bcfb3acdb6fa9 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 15:05:15 +0200 Subject: [PATCH 064/282] feat: amend and spill commits with lazy rebases Add edit actions and CLI commands for amending HEAD from the index or worktree and spilling its tree delta back into the worktree. Preserve worktree files, reset the affected index, and atomically retarget mutable references through the shared rebase transaction. Mark cheaply reparented commits as pending with their original parent so time travel can cherry-pick the complete deferred region correctly. Show pending commits in cyan, invalidate stale signatures, and redo configured signatures when the deferred rebase is completed. --- gix-tix/spec.md | 20 +++- gix-tix/src/app.rs | 64 +++++++++++- gix-tix/src/edit/create.rs | 34 ++++--- gix-tix/src/edit/head.rs | 166 ++++++++++++++++++++++++++++++++ gix-tix/src/edit/mod.rs | 2 +- gix-tix/src/edit/rebase.rs | 86 ++++++++++++++--- gix-tix/src/edit/time_travel.rs | 91 +++++++++++++++-- gix-tix/src/history.rs | 7 +- gix-tix/src/lib.rs | 66 +++++++++++++ gix-tix/src/main.rs | 33 ++++++- gix-tix/src/ui.rs | 17 ++++ 11 files changed, 544 insertions(+), 42 deletions(-) create mode 100644 gix-tix/src/edit/head.rs diff --git a/gix-tix/spec.md b/gix-tix/spec.md index 937248ecbd0..d6a53eab4ab 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -333,6 +333,22 @@ space first; changes blocks adapt within the remaining history width. tags and remote-tracking refs. Checked-out affected worktrees are preflighted; inaccessible or conflicting affected worktrees abort safely. +### Amend and spill + +- `e a` amends the current worktree's `@` commit with the changed index, or + worktree changes when the index already matches `HEAD`. `e s` spills that + commit's tree delta into the worktree by replacing its tree with its first + parent's tree, or the empty tree for a root commit. Clean operations are + unavailable and report a no-op through `tix edit amend|spill`. +- Both operations leave worktree files untouched, reset the affected worktree's + index to the rewritten commit, and cheaply rewrite linear descendants with + their trees unchanged. Rewritten commits carry `tix-rebase: pending`, invalidate + existing signatures, retain the original parent needed for later replay, and + use a bright-cyan commit marker. +- Time travel from either endpoint of a pending region completes the entire + marked rebase with cherry-picking and configured signing before checkout. A + conflict aborts before refs or worktrees change. + ### Forget commits - `e`, then `d`, is available after history completion for a selected non-merge @@ -377,8 +393,8 @@ space first; changes blocks adapt within the remaining history width. ### Editing shortcuts - `e` toggles the edit shortcut group. `e r` rewords, `e n` creates a commit, - `e d d` confirms forgetting a top commit, and `e t` enters or returns from time - travel when each action is available. + `e a` amends `@`, `e s` spills `@`, `e d d` confirms forgetting a top commit, + and `e t` enters or returns from time travel when each action is available. - Edit shortcuts keep the group open. Navigation or another recognized command closes it, matching the `v` display shortcut group. Plain `r` and `t` do not mutate the repository. diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 6b005d5aaf0..c9e08d50ace 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -42,6 +42,7 @@ pub(crate) enum SignatureState { Verifying, Verified, Failed, + PendingRebase, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -285,6 +286,8 @@ pub(crate) enum Action { OpenDiff, Reword, NewCommit, + Amend, + Spill, Forget, TimeTravel, VerifySignatures, @@ -308,6 +311,8 @@ pub(crate) enum Effect { OpenCommitDiff(ObjectId), Reword(ObjectId), NewCommit(Option), + Amend(ObjectId), + Spill(ObjectId), Forget(ObjectId), TimeTravel(ObjectId), VerifySignatures(Vec), @@ -379,6 +384,8 @@ pub(crate) struct App { worktree_head: Option, worktree_head_has_descendants: bool, worktree_head_unborn: bool, + amend_available: bool, + spill_available: bool, known_descendants: HashSet, known_merge_descendants: HashSet, select_top_after_refresh: bool, @@ -454,6 +461,8 @@ impl App { worktree_head: None, worktree_head_has_descendants: false, worktree_head_unborn: false, + amend_available: false, + spill_available: false, known_descendants: HashSet::new(), known_merge_descendants: HashSet::new(), select_top_after_refresh: false, @@ -680,7 +689,13 @@ impl App { } if !matches!( &action, - Action::ToggleEdit | Action::Reword | Action::NewCommit | Action::Forget | Action::TimeTravel + Action::ToggleEdit + | Action::Reword + | Action::NewCommit + | Action::Amend + | Action::Spill + | Action::Forget + | Action::TimeTravel ) { self.edit_expanded = false; } @@ -848,6 +863,16 @@ impl App { self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id), )]; } + Action::Amend if self.can_amend() => { + return vec![Effect::Amend( + self.rows[self.selected.expect("amend requires a selection")].id, + )]; + } + Action::Spill if self.can_spill() => { + return vec![Effect::Spill( + self.rows[self.selected.expect("spill requires a selection")].id, + )]; + } Action::Forget if self.can_forget() => { let id = self.rows[self.selected.expect("forget requires a selection")].id; if self.forget_confirmation == Some(id) { @@ -1352,6 +1377,29 @@ impl App { .is_some_and(|row| row.parent_ids.len() <= 1 && !self.known_merge_descendants.contains(&row.id)) } + fn can_edit_head(&self) -> bool { + self.state == State::Complete + && self.worktree_changes_available + && self.changes_focus.is_none() + && self.deferred_history_state.unwrap_or(self.state) == State::Complete + && self.selected.and_then(|index| self.rows.get(index)).is_some_and(|row| { + Some(row.id) == self.worktree_head && !self.known_merge_descendants.contains(&row.id) + }) + } + + pub(crate) fn can_amend(&self) -> bool { + self.can_edit_head() && self.amend_available + } + + pub(crate) fn can_spill(&self) -> bool { + self.can_edit_head() && self.spill_available + } + + pub(crate) fn set_head_edit_availability(&mut self, amend: bool, spill: bool) { + self.amend_available = amend; + self.spill_available = spill; + } + pub(crate) fn forget_confirmation_visible(&self) -> bool { self.selected .and_then(|index| self.rows.get(index)) @@ -2031,6 +2079,20 @@ mod tests { assert_eq!(app.update(Action::Reword), vec![Effect::Reword(id(1))]); } + #[test] + fn amend_and_spill_are_limited_to_the_current_worktree_head() { + let mut app = App::new(10); + app.extend_commits(vec![row_with_parents(2, &[1]), row(1)]); + app.set_worktree_head(Some(id(2)), false); + app.set_head_edit_availability(true, true); + complete(&mut app); + assert_eq!(app.update(Action::Amend), vec![Effect::Amend(id(2))]); + assert_eq!(app.update(Action::Spill), vec![Effect::Spill(id(2))]); + app.update(Action::MoveDown); + assert!(!app.can_amend()); + assert!(app.update(Action::Amend).is_empty()); + } + #[test] fn forgetting_a_non_merge_tip_requires_a_second_d_and_navigation_cancels_it() { let mut app = App::new(10); diff --git a/gix-tix/src/edit/create.rs b/gix-tix/src/edit/create.rs index c6a78bef600..5c3753ec678 100644 --- a/gix-tix/src/edit/create.rs +++ b/gix-tix/src/edit/create.rs @@ -66,20 +66,7 @@ pub(crate) fn prepare(mut repo: gix::Repository, parent: Option) -> Re { anyhow::bail!("cannot create a commit with unresolved index conflicts"); } - let mut index_editor = repo.empty_tree().edit().context("could not prepare the index tree")?; - for entry in index.entries() { - let mode = entry - .mode - .to_tree_entry_mode() - .context("an index entry has an invalid mode")?; - index_editor - .upsert(entry.path(&index), mode.kind(), entry.id) - .context("could not add an index entry to the candidate tree")?; - } - let index_tree = index_editor - .write() - .context("could not build the candidate index tree")? - .detach(); + let index_tree = index_tree(&repo, &index)?; let based_on_parent = head_id == parent; let tree = if based_on_parent && index_tree != baseline.id { index_tree @@ -134,7 +121,24 @@ pub(crate) fn prepare(mut repo: gix::Repository, parent: Option) -> Re }) } -fn worktree_tree(repo: &gix::Repository, baseline: &gix::Tree<'_>) -> Result { +pub(super) fn index_tree(repo: &gix::Repository, index: &gix::index::File) -> Result { + let mut editor = repo.empty_tree().edit().context("could not prepare the index tree")?; + for entry in index.entries() { + let mode = entry + .mode + .to_tree_entry_mode() + .context("an index entry has an invalid mode")?; + editor + .upsert(entry.path(index), mode.kind(), entry.id) + .context("could not add an index entry to the candidate tree")?; + } + Ok(editor + .write() + .context("could not build the candidate index tree")? + .detach()) +} + +pub(super) fn worktree_tree(repo: &gix::Repository, baseline: &gix::Tree<'_>) -> Result { let changes = load_worktree_changes_without_lines(repo)?; if changes.paths.is_empty() { return Ok(baseline.id); diff --git a/gix-tix/src/edit/head.rs b/gix-tix/src/edit/head.rs new file mode 100644 index 00000000000..ad1cd79257a --- /dev/null +++ b/gix-tix/src/edit/head.rs @@ -0,0 +1,166 @@ +use anyhow::{Context, Result}; +use gix::ObjectId; + +use super::{create, rebase}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Kind { + Amend, + Spill, +} + +#[tracing::instrument(skip_all, fields(?kind))] +pub fn perform( + mut repo: gix::Repository, + graph: &crate::history::HistoryGraph, + kind: Kind, +) -> Result> { + let head = repo + .head_id() + .context("editing requires an existing HEAD commit")? + .detach(); + let mut commit = repo + .find_commit(head) + .context("could not find HEAD commit")? + .decode() + .context("could not decode HEAD commit")? + .into_owned() + .context("could not own HEAD commit")?; + repo.workdir().context("editing HEAD requires a worktree")?; + repo.commit_signing_options_if_enabled() + .context("could not resolve commit signing configuration")?; + repo = repo.with_object_memory(); + let old_tree = commit.tree; + let parent_tree = match commit.parents.first().copied() { + Some(parent) => repo.find_commit(parent)?.tree_id()?.detach(), + None => repo.empty_tree().id, + }; + let tree = match kind { + Kind::Spill => parent_tree, + Kind::Amend => { + let index = repo.index_or_empty().context("could not load the index")?; + if index + .entries() + .iter() + .any(|entry| entry.stage() != gix::index::entry::Stage::Unconflicted) + { + anyhow::bail!("cannot amend with unresolved index conflicts"); + } + let index_tree = create::index_tree(&repo, &index)?; + drop(index); + if index_tree != old_tree { + index_tree + } else { + let baseline = repo.find_tree(old_tree)?; + create::worktree_tree(&repo, &baseline)? + } + } + }; + if tree == old_tree { + return Ok(None); + } + commit.tree = tree; + Ok(rebase::perform( + repo, + graph, + rebase::Edit::Replace { target: head, commit }, + rebase::Signature::InvalidateExisting, + rebase::Tree::LeaveAsIsAndMark, + )? + .selected) +} + +#[cfg(test)] +mod tests { + use std::{path::Path, process::Command}; + + use gix::bstr::ByteSlice; + + use super::*; + + fn open(path: &Path) -> gix_testtools::Result { + Ok(gix::open_opts( + path, + gix::open::Options::isolated().config_overrides([ + "user.name=editor".to_owned(), + "user.email=editor@example.com".to_owned(), + "commit.gpgSign=false".to_owned(), + ]), + )?) + } + + fn git(path: &Path, args: &[&str]) -> gix_testtools::Result> { + let output = Command::new("git").arg("-C").arg(path).args(args).output()?; + if !output.status.success() { + return Err(format!("git {} failed: {}", args.join(" "), output.stderr.to_str_lossy()).into()); + } + Ok(output.stdout) + } + + #[test] + fn amend_prefers_the_index_and_leaves_worktree_files_alone() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let repo = open(fixture.path())?; + let old = repo.head_id()?.detach(); + let graph = super::super::loaded_graph(&repo)?; + let new = perform(repo, &graph, Kind::Amend)?.expect("staged changes amend HEAD"); + assert_ne!(new, old); + assert_eq!(std::fs::read(fixture.path().join("tracked"))?, b"unstaged\n"); + assert_eq!(git(fixture.path(), &["show", "HEAD:tracked"])?, b"staged\n"); + assert!( + git(fixture.path(), &["diff", "--cached", "--name-only"])?.is_empty(), + "the index follows the amended commit" + ); + let commit = open(fixture.path())?.find_commit(new)?.decode()?.into_owned()?; + assert!(super::super::rebase::has_marker(&commit), "lazy descendants are marked"); + Ok(()) + } + + #[test] + fn spill_moves_the_tip_tree_change_to_the_worktree() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repo = open(fixture.path())?; + let old = repo.head_id()?.detach(); + let parent_tree = repo + .find_commit(old)? + .parent_ids() + .next() + .expect("tip has parent") + .object()? + .peel_to_tree()? + .id; + let graph = super::super::loaded_graph(&repo)?; + let new = perform(repo, &graph, Kind::Spill)?.expect("the tip introduces changes"); + let repo = open(fixture.path())?; + assert_eq!(repo.find_commit(new)?.tree_id()?.detach(), parent_tree); + assert_eq!( + std::fs::read(fixture.path().join("tip"))?, + b"tip\n", + "worktree content survives" + ); + assert!( + git(fixture.path(), &["diff", "--cached", "--name-only"])?.is_empty(), + "the index follows the spilled commit" + ); + assert_eq!(git(fixture.path(), &["status", "--short"])?, b"?? tip\n"); + let graph = super::super::loaded_graph(&repo)?; + assert_eq!(perform(repo, &graph, Kind::Spill)?, None, "an empty spill is a no-op"); + Ok(()) + } + + #[test] + fn spilling_a_root_uses_the_empty_tree() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let new = perform(repo, &graph, Kind::Spill)?.expect("the root has a non-empty tree"); + let repo = open(fixture.path())?; + assert_eq!(repo.find_commit(new)?.tree_id()?.detach(), repo.empty_tree().id); + assert!( + git(fixture.path(), &["diff", "--cached", "--name-only"])?.is_empty(), + "the root spill resets the index to empty" + ); + assert_eq!(std::fs::read(fixture.path().join("tracked"))?, b"unstaged\n"); + Ok(()) + } +} diff --git a/gix-tix/src/edit/mod.rs b/gix-tix/src/edit/mod.rs index e20a1e5864a..d6d23f01c36 100644 --- a/gix-tix/src/edit/mod.rs +++ b/gix-tix/src/edit/mod.rs @@ -2,7 +2,6 @@ use std::{ffi::OsStr, io::Write, process::Command}; use anyhow::{Context, Result}; -#[cfg(test)] pub(super) fn loaded_graph(repo: &gix::Repository) -> Result { use std::sync::atomic::AtomicBool; @@ -46,6 +45,7 @@ pub(super) fn loaded_graph(repo: &gix::Repository) -> Result, + rewritten: HashMap>, +} + +impl Outcome { + pub(crate) fn map(&self, id: ObjectId) -> Option { + self.rewritten.get(&id).copied().unwrap_or(Some(id)) + } } #[tracing::instrument(skip_all, fields(signature = ?signature, tree = ?tree_mode))] @@ -98,7 +104,7 @@ pub(crate) fn perform( if inserted { let mut commit = replacement.clone().context("an inserted commit is required")?; commit.parents = root.into_iter().collect(); - marker(&mut commit, tree_mode == Tree::LeaveAsIsAndMark); + marker(&mut commit, tree_mode == Tree::LeaveAsIsAndMark, root); let id = write_commit(&repo, commit, signature, signing.clone())?; selected = Some(id); if let Some(root) = root { @@ -149,9 +155,25 @@ pub(crate) fn perform( if Some(old_id) != root || repeat { commit.committer = committer.clone(); } - commit.tree = rewritten_tree(&repo, &commit, &old_parents, &new_parents, tree_mode)?; + let original_parent = repeat.then(|| marked_parent(&commit)).transpose()?.flatten(); + let original_parents = original_parent.into_iter().collect::>(); + commit.tree = rewritten_tree( + &repo, + &commit, + if original_parents.is_empty() { + &old_parents + } else { + &original_parents + }, + &new_parents, + tree_mode, + )?; commit.parents = new_parents.into_iter().collect(); - marker(&mut commit, tree_mode == Tree::LeaveAsIsAndMark); + marker( + &mut commit, + tree_mode == Tree::LeaveAsIsAndMark, + old_parents.first().copied(), + ); let new_id = write_commit(&repo, commit, signature, signing.clone())?; rewritten.insert(old_id, Some(new_id)); if Some(old_id) == root { @@ -168,8 +190,15 @@ pub(crate) fn perform( .map_err(|err| anyhow::anyhow!("could not persist a prepared rebase object: {err}"))?; } - let transitions = worktree_transitions(&repo, &rewritten, inserted)?; - let index_resets = inserted.then(|| inserted_index_resets(&repo, &rewritten)).transpose()?; + let transitions = worktree_transitions(&repo, &rewritten, inserted || tree_mode == Tree::LeaveAsIsAndMark)?; + let index_reset_from = if inserted || tree_mode == Tree::LeaveAsIsAndMark { + root + } else { + None + }; + let index_resets = index_reset_from + .map(|old| index_resets(&repo, &rewritten, old)) + .transpose()?; for transition in &transitions { super::forget::preflight_tree_transition( &transition.repo, @@ -195,7 +224,7 @@ pub(crate) fn perform( return rollback(&repo, &committer, &transitions, &rollback_refs, err); } } - Ok(Outcome { selected }) + Ok(Outcome { selected, rewritten }) } fn rollback( @@ -222,9 +251,10 @@ fn rollback( } } -fn inserted_index_resets( +fn index_resets( repo: &gix::Repository, rewritten: &HashMap>, + reset_from: ObjectId, ) -> Result> { let mut repos = vec![ repo.main_repo() @@ -248,6 +278,9 @@ fn inserted_index_resets( else { continue; }; + if old != reset_from { + continue; + } let Some(Some(new)) = rewritten.get(&old).copied() else { continue; }; @@ -359,14 +392,30 @@ fn parent_tree(repo: &gix::Repository, parent: Option) -> Result) { + commit + .extra_headers + .retain(|(name, _)| name.as_slice() != MARKER && name.as_slice() != ORIGINAL_PARENT); if add { commit.extra_headers.push((MARKER.into(), PENDING.into())); + if let Some(parent) = original_parent { + commit + .extra_headers + .push((ORIGINAL_PARENT.into(), parent.to_hex().to_string().into())); + } } } -fn has_marker(commit: &gix::objs::Commit) -> bool { +fn marked_parent(commit: &gix::objs::Commit) -> Result> { + commit + .extra_headers + .iter() + .find(|(name, _)| name.as_slice() == ORIGINAL_PARENT) + .map(|(_, value)| ObjectId::from_hex(value).context("pending rebase has an invalid original parent")) + .transpose() +} + +pub(super) fn has_marker(commit: &gix::objs::Commit) -> bool { commit .extra_headers .iter() @@ -692,7 +741,8 @@ mod tests { let repo = open(fixture.path())?; let graph = super::super::loaded_graph(&repo)?; let middle = repo.rev_parse_single("HEAD~1")?.detach(); - let commit = repo.find_commit(middle)?.decode()?.into_owned()?; + let mut commit = repo.find_commit(middle)?.decode()?.into_owned()?; + commit.tree = repo.find_commit(repo.rev_parse_single("HEAD~2")?)?.tree_id()?.detach(); let marked = perform( repo.clone(), &graph, @@ -717,6 +767,16 @@ mod tests { assert!(!has_marker(&commit), "repeating clears every pending marker"); id = commit.parents.first().copied(); } + let files = Command::new("git") + .arg("-C") + .arg(fixture.path()) + .args(["ls-tree", "-r", "--name-only", "HEAD"]) + .output()?; + assert!(files.status.success()); + assert_eq!( + files.stdout, b"base\ntip\n", + "repeat cherry-picks the descendant against its recorded original parent" + ); Ok(()) } diff --git a/gix-tix/src/edit/time_travel.rs b/gix-tix/src/edit/time_travel.rs index 4b1f8cf988a..cf01542466f 100644 --- a/gix-tix/src/edit/time_travel.rs +++ b/gix-tix/src/edit/time_travel.rs @@ -16,21 +16,43 @@ use crate::{history, open_repository}; pub(crate) fn perform( repository_path: &Path, bare: bool, - selected: ObjectId, + mut selected: ObjectId, graph: &history::HistoryGraph, revisions: &[OsString], include_worktrees: bool, ) -> Result> { - let repository = + let mut repository = open_repository(repository_path, bare, false).context("could not open repository for time-travel")?; let workdir = repository .workdir() .context("time-travel requires a worktree")? .to_owned(); let head = repository.head().context("could not read HEAD before time-travel")?; - let Some(head_id) = head.id().map(gix::Id::detach) else { + let Some(mut head_id) = head.id().map(gix::Id::detach) else { anyhow::bail!("cannot time-travel from an unborn HEAD"); }; + let head_referent = head.referent_name().map(ToOwned::to_owned); + drop(head); + let mut completed_graph = None; + while let Some(base) = pending_base(&repository, head_id, selected)? { + let outcome = super::rebase::perform( + repository, + completed_graph.as_ref().unwrap_or(graph), + super::rebase::Edit::Repeat { base }, + super::rebase::Signature::RedoIfNeeded, + super::rebase::Tree::CherryPick, + )?; + selected = outcome + .map(selected) + .context("the time-travel destination disappeared while completing its rebase")?; + head_id = outcome + .map(head_id) + .context("HEAD disappeared while completing its rebase")?; + repository = open_repository(repository_path, bare, false) + .context("could not reopen repository after completing a pending rebase")?; + completed_graph = Some(super::loaded_graph(&repository)?); + } + let graph = completed_graph.as_ref().unwrap_or(graph); if selected == head_id { return Ok(None); } @@ -44,10 +66,7 @@ pub(crate) fn perform( Err(err) => format!("returned from {}; pin remains: {err:#}", pin_label(&pin)), })); } - let saved_target = head - .referent_name() - .map(|name| Target::Symbolic(name.to_owned())) - .unwrap_or(Target::Object(head_id)); + let saved_target = head_referent.map(Target::Symbolic).unwrap_or(Target::Object(head_id)); let provisional = graph .is_ancestor(selected, head_id) .then(|| create_or_reuse_pin(&repository, saved_target, head_id)) @@ -91,6 +110,32 @@ pub(crate) fn perform( Ok(Some(notice)) } +fn pending_base(repository: &gix::Repository, head: ObjectId, selected: ObjectId) -> Result> { + for endpoint in [head, selected] { + let mut current = endpoint; + let mut base = None; + loop { + let commit = repository + .find_commit(current) + .context("could not inspect a time-travel endpoint for a pending rebase")? + .decode()? + .into_owned()?; + if !super::rebase::has_marker(&commit) { + break; + } + base = Some(current); + let Some(parent) = commit.parents.first().copied() else { + break; + }; + current = parent; + } + if base.is_some() { + return Ok(base); + } + } + Ok(None) +} + fn selected_pin(repository: &gix::Repository, selected: ObjectId) -> Result> { let mut pins: Vec<_> = history::applicable_pins(repository)? .into_iter() @@ -376,4 +421,36 @@ mod tests { ); Ok(()) } + + #[test] + fn travelling_completes_the_whole_pending_rebase() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + let repository = crate::open_test_repository(fixture.path())?; + let repository_path = repository.git_dir().to_owned(); + let middle = repository.rev_parse_single("HEAD~1")?.detach(); + let root = repository.rev_parse_single("HEAD~2")?.detach(); + let graph = loaded_graph(&repository, &[])?; + let commit = repository.find_commit(middle)?.decode()?.into_owned()?; + super::super::rebase::perform( + repository.clone(), + &graph, + super::super::rebase::Edit::Replace { target: middle, commit }, + super::super::rebase::Signature::InvalidateExisting, + super::super::rebase::Tree::LeaveAsIsAndMark, + )?; + let graph = loaded_graph(&repository, &[])?; + perform(&repository_path, false, root, &graph, &[], false)?; + + let repository = crate::open_test_repository(fixture.path())?; + let mut current = Some(repository.find_reference("refs/heads/main")?.id().detach()); + while let Some(id) = current { + let commit = repository.find_commit(id)?.decode()?.into_owned()?; + assert!( + !super::super::rebase::has_marker(&commit), + "time travel clears the complete pending region" + ); + current = commit.parents.first().copied(); + } + Ok(()) + } } diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index 6ae03b4394e..5eb238f51b5 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -1361,7 +1361,12 @@ fn decode_metadata<'a>( } } } - Token::ExtraHeader((name, _)) if name == "gpgsig" || name == "gpgsig-sha256" => { + Token::ExtraHeader((name, value)) if name == "tix-rebase" && value.as_ref() == b"pending" => { + signature = SignatureState::PendingRebase; + } + Token::ExtraHeader((name, _)) + if (name == "gpgsig" || name == "gpgsig-sha256") && signature != SignatureState::PendingRebase => + { signature = SignatureState::Unverified; } _ => {} diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 0d8c2f42964..79f055d604d 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -745,6 +745,30 @@ pub struct Options { pub worktrees: bool, } +/// An edit applied to the commit checked out by the current worktree. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HeadEdit { + /// Add staged changes, or worktree changes when nothing is staged, to `HEAD`. + Amend, + /// Move the changes introduced by `HEAD` into the worktree. + Spill, +} + +/// Apply `edit` to the current worktree's `HEAD` without starting the terminal UI. +pub fn edit_head(repository: gix::ThreadSafeRepository, edit: HeadEdit) -> Result> { + let _log_guard = logging::init().context("could not initialize tix diagnostics")?; + let repository = repository.to_thread_local(); + let graph = edit::loaded_graph(&repository)?; + edit::head::perform( + repository, + &graph, + match edit { + HeadEdit::Amend => edit::head::Kind::Amend, + HeadEdit::Spill => edit::head::Kind::Spill, + }, + ) +} + fn detect_commit_pane_background() -> Option<(u8, u8, u8)> { let mut options = terminal_colorsaurus::QueryOptions::default(); options.timeout = THEME_QUERY_TIMEOUT; @@ -1742,6 +1766,44 @@ fn event_loop( Err(err) => app.leave_message(format!("new commit: {err:#}")), } } + edit @ (Effect::Amend(id) | Effect::Spill(id)) => { + fill_repository.retain = false; + fill_repository.retained = None; + let kind = if matches!(edit, Effect::Amend(_)) { + edit::head::Kind::Amend + } else { + edit::head::Kind::Spill + }; + let verb = if kind == edit::head::Kind::Amend { + "amend" + } else { + "spill" + }; + let result = history_graph + .as_ref() + .context("editing HEAD requires a completed history graph") + .and_then(|graph| { + edit::head::perform( + open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository for HEAD edit")?, + graph, + kind, + ) + }); + match result { + Ok(Some(new_id)) => { + app.leave_message(format!( + "{verb}ed {} as {}", + id.to_hex_with_len(7), + new_id.to_hex_with_len(7) + )); + invalidate_worktree_changes(&mut worktree_changes); + refresh_pending = true; + } + Ok(None) => app.leave_message(format!("nothing to {verb}")), + Err(err) => app.leave_message(format!("{verb}: {err:#}")), + } + } Effect::Forget(id) => { fill_repository.retain = false; fill_repository.retained = None; @@ -3520,6 +3582,8 @@ fn action_with_shortcut_groups(key: KeyEvent, history_display_expanded: bool, ed KeyCode::Char('r') if history_display_expanded => Some(Action::ToggleRefs), KeyCode::Char('r') if edit_expanded => Some(Action::Reword), KeyCode::Char('n') if edit_expanded => Some(Action::NewCommit), + KeyCode::Char('a') if edit_expanded => Some(Action::Amend), + KeyCode::Char('s') if edit_expanded => Some(Action::Spill), KeyCode::Char('d') if edit_expanded => Some(Action::Forget), KeyCode::Char('t') if edit_expanded => Some(Action::TimeTravel), KeyCode::Char('s') => Some(Action::VerifySignatures), @@ -4405,6 +4469,8 @@ mod tests { for (key, expected) in [ ('r', Action::Reword), ('n', Action::NewCommit), + ('a', Action::Amend), + ('s', Action::Spill), ('d', Action::Forget), ('t', Action::TimeTravel), ] { diff --git a/gix-tix/src/main.rs b/gix-tix/src/main.rs index 40132e32788..942d159e7d7 100644 --- a/gix-tix/src/main.rs +++ b/gix-tix/src/main.rs @@ -5,10 +5,25 @@ use std::ffi::OsString; use anyhow::{Context, Result}; fn main() -> Result<()> { - let (revisions, options, help) = arguments(gix::env::args_os().skip(1))?; + let mut args = gix::env::args_os().skip(1).peekable(); + let edit = if args.peek().is_some_and(|arg| arg == "edit") { + args.next(); + let edit = match args.next().as_deref() { + Some(value) if value == "amend" => gix_tix::HeadEdit::Amend, + Some(value) if value == "spill" => gix_tix::HeadEdit::Spill, + _ => anyhow::bail!("usage: tix edit amend|spill"), + }; + if args.next().is_some() { + anyhow::bail!("usage: tix edit amend|spill"); + } + Some(edit) + } else { + None + }; + let (revisions, options, help) = arguments(args)?; if help { println!( - "Usage: tix [--quit-on-finish] [-w|--worktrees] [-h|--hide REVSPEC] [REVISION]...\n\nBrowse commits reachable from HEAD or the given revisions.\n\nOptions:\n -h, --hide REVSPEC Hide this revision and all commits reachable from it\n -w, --worktrees Add all worktree HEADs as visible tips\n --help Print help" + "Usage: tix [--quit-on-finish] [-w|--worktrees] [-h|--hide REVSPEC] [REVISION]...\n tix edit amend|spill\n\nBrowse commits reachable from HEAD or edit its checked-out commit.\n\nOptions:\n -h, --hide REVSPEC Hide this revision and all commits reachable from it\n -w, --worktrees Add all worktree HEADs as visible tips\n --help Print help" ); return Ok(()); } @@ -16,6 +31,20 @@ fn main() -> Result<()> { let current_dir = std::env::current_dir().context("could not determine current directory")?; let repository = gix::ThreadSafeRepository::discover_with_environment_overrides(current_dir) .context("could not discover repository")?; + if let Some(edit) = edit { + match gix_tix::edit_head(repository, edit)? { + Some(id) => println!("{}", id.to_hex_with_len(7)), + None => println!( + "nothing to {}", + if edit == gix_tix::HeadEdit::Amend { + "amend" + } else { + "spill" + } + ), + } + return Ok(()); + } gix_tix::run(repository, revisions, options) } diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index d6f831e8e85..144b2676ec0 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -243,6 +243,15 @@ pub(crate) fn draw_with_worktree( && changes_panes .iter() .any(|pane| pane.pane == ChangePane::Worktree && pane.outer.height > 0); + let selected_is_head = app.selected.and_then(|index| app.rows.get(index)).is_some_and(|row| { + decorations + .get(&row.id) + .is_some_and(|refs| refs.iter().any(|r| r.kind == DecorationKind::Head)) + }); + app.set_head_edit_availability( + selected_is_head && worktree_changes.is_some_and(|changes| !changes.paths.is_empty()), + selected_is_head && tree_changes.is_some_and(|changes| !changes.paths.is_empty()), + ); if app.changes_visible() { app.set_changes_layout( changes_layout, @@ -685,6 +694,12 @@ pub(crate) fn draw_with_worktree( if app.can_create_commit() { options.push(("new", 'n')); } + if app.can_amend() { + options.push(("amend", 'a')); + } + if app.can_spill() { + options.push(("spill", 's')); + } if app.can_forget() { options.push(if app.forget_confirmation_visible() { ("d again forget", 'd') @@ -1730,6 +1745,7 @@ fn signature_color(signature: SignatureState) -> Color { SignatureState::Unverified | SignatureState::Verifying => Color::Rgb(255, 165, 0), SignatureState::Verified => Color::Green, SignatureState::Failed => Color::LightRed, + SignatureState::PendingRebase => Color::LightCyan, } } @@ -2796,6 +2812,7 @@ mod tests { (SignatureState::Unverified, Color::Rgb(255, 165, 0)), (SignatureState::Verified, Color::Green), (SignatureState::Failed, Color::LightRed), + (SignatureState::PendingRebase, Color::LightCyan), ]; let mut terminal = Terminal::new(TestBackend::new(4, states.len() as u16))?; terminal.draw(|frame| { From 5556f0d6693b56452097a6401feeb6937bf6e277 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 15:20:28 +0200 Subject: [PATCH 065/282] feat: spill selected tree paths Keep the history edit prefix available while the tree-changes block has focus and scope spill to its selected path. Restore that path from the currently displayed parent while retaining every other tree change, then reuse the existing lazy rebase transaction to preserve worktree files, reset the index, invalidate signatures, and update mutable refs. Leave the command-line edit interface unchanged so tix edit spill continues to spill the complete commit. --- gix-tix/spec.md | 3 ++ gix-tix/src/app.rs | 15 ++++-- gix-tix/src/edit/head.rs | 112 ++++++++++++++++++++++++++++++++++++--- gix-tix/src/lib.rs | 31 ++++++++--- gix-tix/src/ui.rs | 68 ++++++++++++++++++++++-- 5 files changed, 209 insertions(+), 20 deletions(-) diff --git a/gix-tix/spec.md b/gix-tix/spec.md index d6a53eab4ab..4c360bb2528 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -340,6 +340,9 @@ space first; changes blocks adapt within the remaining history width. commit's tree delta into the worktree by replacing its tree with its first parent's tree, or the empty tree for a root commit. Clean operations are unavailable and report a no-op through `tix edit amend|spill`. +- With a path selected in the focused tree-changes block, the main `e` prefix + offers `spill` and `e s` spills only that path against the displayed parent. + The CLI intentionally supports only whole-commit spilling. - Both operations leave worktree files untouched, reset the affected worktree's index to the rewritten commit, and cheaply rewrite linear descendants with their trees unchanged. Rewritten commits carry `tix-rebase: pending`, invalidate diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index c9e08d50ace..39cb8297deb 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -1380,7 +1380,6 @@ impl App { fn can_edit_head(&self) -> bool { self.state == State::Complete && self.worktree_changes_available - && self.changes_focus.is_none() && self.deferred_history_state.unwrap_or(self.state) == State::Complete && self.selected.and_then(|index| self.rows.get(index)).is_some_and(|row| { Some(row.id) == self.worktree_head && !self.known_merge_descendants.contains(&row.id) @@ -1388,11 +1387,11 @@ impl App { } pub(crate) fn can_amend(&self) -> bool { - self.can_edit_head() && self.amend_available + self.can_edit_head() && self.changes_focus.is_none() && self.amend_available } pub(crate) fn can_spill(&self) -> bool { - self.can_edit_head() && self.spill_available + self.can_edit_head() && matches!(self.changes_focus, None | Some(ChangePane::Tree)) && self.spill_available } pub(crate) fn set_head_edit_availability(&mut self, amend: bool, spill: bool) { @@ -2088,6 +2087,16 @@ mod tests { complete(&mut app); assert_eq!(app.update(Action::Amend), vec![Effect::Amend(id(2))]); assert_eq!(app.update(Action::Spill), vec![Effect::Spill(id(2))]); + app.changes_focus = Some(ChangePane::Tree); + assert!(!app.can_amend(), "a tree path cannot be amended"); + assert_eq!( + app.update(Action::Spill), + vec![Effect::Spill(id(2))], + "tree focus scopes spill to its selected path" + ); + app.changes_focus = Some(ChangePane::Worktree); + assert!(!app.can_spill(), "worktree paths cannot be spilled from a commit"); + app.changes_focus = None; app.update(Action::MoveDown); assert!(!app.can_amend()); assert!(app.update(Action::Amend).is_empty()); diff --git a/gix-tix/src/edit/head.rs b/gix-tix/src/edit/head.rs index ad1cd79257a..b40cfadcfe1 100644 --- a/gix-tix/src/edit/head.rs +++ b/gix-tix/src/edit/head.rs @@ -1,7 +1,8 @@ use anyhow::{Context, Result}; -use gix::ObjectId; +use gix::{ObjectId, bstr::BStr}; use super::{create, rebase}; +use crate::{ChangeKind, PathChange}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Kind { @@ -14,6 +15,7 @@ pub fn perform( mut repo: gix::Repository, graph: &crate::history::HistoryGraph, kind: Kind, + selected_path: Option<(&PathChange, Option)>, ) -> Result> { let head = repo .head_id() @@ -36,7 +38,12 @@ pub fn perform( None => repo.empty_tree().id, }; let tree = match kind { - Kind::Spill => parent_tree, + Kind::Spill => match selected_path { + Some((path, selected_parent)) => { + spill_path_tree(&repo, old_tree, selected_parent.unwrap_or(parent_tree), path)? + } + None => parent_tree, + }, Kind::Amend => { let index = repo.index_or_empty().context("could not load the index")?; if index @@ -70,6 +77,63 @@ pub fn perform( .selected) } +fn spill_path_tree( + repo: &gix::Repository, + commit_tree: ObjectId, + parent_tree: ObjectId, + change: &PathChange, +) -> Result { + let parent = repo.find_tree(parent_tree).context("could not load the parent tree")?; + let mut editor = repo + .find_tree(commit_tree) + .context("could not load the commit tree")? + .edit() + .context("could not edit the commit tree")?; + match change.kind { + ChangeKind::Added => { + editor.remove(&change.path).context("could not spill the added path")?; + } + ChangeKind::Deleted | ChangeKind::Modified | ChangeKind::TypeChanged => { + restore_path(&parent, &mut editor, &change.path)?; + } + ChangeKind::Renamed | ChangeKind::Copied => { + editor + .remove(&change.path) + .context("could not spill the rewritten destination")?; + if change.kind == ChangeKind::Renamed { + restore_path( + &parent, + &mut editor, + change.source.as_ref().context("a rename has no source path")?, + )?; + } + } + ChangeKind::Unmerged => anyhow::bail!("cannot spill an unmerged path"), + } + Ok(editor + .write() + .context("could not build the partially spilled tree")? + .detach()) +} + +fn restore_path( + parent: &gix::Tree<'_>, + editor: &mut gix::object::tree::Editor<'_>, + path: &gix::bstr::BString, +) -> Result<()> { + let entry = parent + .lookup_entry( + path.split(|byte| *byte == b'/') + .map(|component| BStr::new(component).to_owned()), + ) + .context("could not look up the path in the parent tree")? + .context("the path is absent from the parent tree")?; + editor + .upsert(path, entry.mode().kind(), entry.object_id()) + .context("could not restore the path from the parent tree")?; + Ok(()) +} + #[cfg(test)] mod tests { use std::{path::Path, process::Command}; @@ -103,7 +167,7 @@ mod tests { let repo = open(fixture.path())?; let old = repo.head_id()?.detach(); let graph = super::super::loaded_graph(&repo)?; - let new = perform(repo, &graph, Kind::Amend)?.expect("staged changes amend HEAD"); + let new = perform(repo, &graph, Kind::Amend, None)?.expect("staged changes amend HEAD"); assert_ne!(new, old); assert_eq!(std::fs::read(fixture.path().join("tracked"))?, b"unstaged\n"); assert_eq!(git(fixture.path(), &["show", "HEAD:tracked"])?, b"staged\n"); @@ -130,7 +194,7 @@ mod tests { .peel_to_tree()? .id; let graph = super::super::loaded_graph(&repo)?; - let new = perform(repo, &graph, Kind::Spill)?.expect("the tip introduces changes"); + let new = perform(repo, &graph, Kind::Spill, None)?.expect("the tip introduces changes"); let repo = open(fixture.path())?; assert_eq!(repo.find_commit(new)?.tree_id()?.detach(), parent_tree); assert_eq!( @@ -144,7 +208,11 @@ mod tests { ); assert_eq!(git(fixture.path(), &["status", "--short"])?, b"?? tip\n"); let graph = super::super::loaded_graph(&repo)?; - assert_eq!(perform(repo, &graph, Kind::Spill)?, None, "an empty spill is a no-op"); + assert_eq!( + perform(repo, &graph, Kind::Spill, None)?, + None, + "an empty spill is a no-op" + ); Ok(()) } @@ -153,7 +221,7 @@ mod tests { let fixture = gix_testtools::scripted_fixture_writable("create_commit.sh")?; let repo = open(fixture.path())?; let graph = super::super::loaded_graph(&repo)?; - let new = perform(repo, &graph, Kind::Spill)?.expect("the root has a non-empty tree"); + let new = perform(repo, &graph, Kind::Spill, None)?.expect("the root has a non-empty tree"); let repo = open(fixture.path())?; assert_eq!(repo.find_commit(new)?.tree_id()?.detach(), repo.empty_tree().id); assert!( @@ -163,4 +231,36 @@ mod tests { assert_eq!(std::fs::read(fixture.path().join("tracked"))?, b"unstaged\n"); Ok(()) } + + #[test] + fn spilling_one_path_keeps_the_other_commit_changes() -> gix_testtools::Result { + let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; + std::fs::write(fixture.path().join("other"), "other\n")?; + git(fixture.path(), &["add", "other"])?; + git(fixture.path(), &["commit", "--amend", "--no-edit"])?; + let repo = open(fixture.path())?; + let graph = super::super::loaded_graph(&repo)?; + let selected = PathChange { + kind: ChangeKind::Added, + group: crate::ChangeGroup::Tree, + source: None, + path: "tip".into(), + lines: None, + }; + let new = + perform(repo, &graph, Kind::Spill, Some((&selected, None)))?.expect("the selected path can be spilled"); + let repo = open(fixture.path())?; + let tree = repo.find_commit(new)?.tree()?; + assert!( + tree.lookup_entry(["other"])?.is_some(), + "the unselected addition remains committed" + ); + assert!( + tree.lookup_entry(["tip"])?.is_none(), + "the selected addition is spilled" + ); + assert_eq!(std::fs::read(fixture.path().join("tip"))?, b"tip\n"); + assert_eq!(git(fixture.path(), &["status", "--short"])?, b"?? tip\n"); + Ok(()) + } } diff --git a/gix-tix/src/lib.rs b/gix-tix/src/lib.rs index 79f055d604d..03c83f7da9f 100644 --- a/gix-tix/src/lib.rs +++ b/gix-tix/src/lib.rs @@ -766,6 +766,7 @@ pub fn edit_head(repository: gix::ThreadSafeRepository, edit: HeadEdit) -> Resul HeadEdit::Amend => edit::head::Kind::Amend, HeadEdit::Spill => edit::head::Kind::Spill, }, + None, ) } @@ -1779,16 +1780,34 @@ fn event_loop( } else { "spill" }; + let path = (kind == edit::head::Kind::Spill && app.changes_focus == Some(ChangePane::Tree)) + .then(|| { + tree_changes + .as_ref() + .filter(|(cached_id, _, _)| *cached_id == id) + .and_then(|(_, _, changes)| { + changes + .paths + .get(app.tree_changes.selected) + .cloned() + .map(|path| (path, changes.parent.map(|parent| parent.id))) + }) + .context("selected tree path is no longer available") + }) + .transpose(); let result = history_graph .as_ref() .context("editing HEAD requires a completed history graph") .and_then(|graph| { - edit::head::perform( - open_repository(&repository_path, repository_is_bare, false) - .context("could not open repository for HEAD edit")?, - graph, - kind, - ) + path.and_then(|path| { + edit::head::perform( + open_repository(&repository_path, repository_is_bare, false) + .context("could not open repository for HEAD edit")?, + graph, + kind, + path.as_ref().map(|(path, parent)| (path, *parent)), + ) + }) }); match result { Ok(Some(new_id)) => { diff --git a/gix-tix/src/ui.rs b/gix-tix/src/ui.rs index 144b2676ec0..a1ae02852a3 100644 --- a/gix-tix/src/ui.rs +++ b/gix-tix/src/ui.rs @@ -657,7 +657,7 @@ pub(crate) fn draw_with_worktree( }; let mut footer_spans = vec![Span::raw(status)]; let mut edit_prefix_spans = Vec::new(); - if app.changes_focus.is_none() { + if app.changes_focus != Some(ChangePane::Worktree) { let time_travel = if app.time_travel_shortcut_visible() && decorations .values() @@ -688,10 +688,10 @@ pub(crate) fn draw_with_worktree( }; if app.edit_expanded { let mut options = Vec::new(); - if app.reword_shortcut_visible() { + if app.changes_focus.is_none() && app.reword_shortcut_visible() { options.push(("reword", 'r')); } - if app.can_create_commit() { + if app.changes_focus.is_none() && app.can_create_commit() { options.push(("new", 'n')); } if app.can_amend() { @@ -700,14 +700,16 @@ pub(crate) fn draw_with_worktree( if app.can_spill() { options.push(("spill", 's')); } - if app.can_forget() { + if app.changes_focus.is_none() && app.can_forget() { options.push(if app.forget_confirmation_visible() { ("d again forget", 'd') } else { ("d forget", 'd') }); } - if let Some(label) = time_travel { + if app.changes_focus.is_none() + && let Some(label) = time_travel + { options.push(label); } edit_prefix_spans.push(Span::raw(" · ")); @@ -2669,6 +2671,62 @@ mod tests { Ok(()) } + #[test] + fn focused_tree_path_offers_only_spill_in_the_main_edit_prefix() -> Result<(), Box> { + let id = gix::ObjectId::Sha1([1; 20]); + let mut app = App::new(4); + app.extend_commits(vec![Commit { + id, + parent_ids: Default::default(), + committer_time: gix::date::Time::default(), + author: author(b"author", b"author@example.com"), + attributions: 0..0, + title: "subject".into(), + metadata_loaded: true, + has_agent_marker: false, + signature: SignatureState::Unsigned, + }]); + app.set_worktree_head(Some(id), false); + complete(&mut app); + app.changes_focus = Some(ChangePane::Tree); + app.edit_expanded = true; + let changes = Changes { + paths: vec![crate::app::PathChange { + kind: ChangeKind::Added, + group: ChangeGroup::Tree, + source: None, + path: "file".into(), + lines: None, + }], + ..Changes::default() + }; + let decorations = Decorations::from([( + id, + vec![Decoration { + name: "HEAD".into(), + kind: DecorationKind::Head, + }], + )]); + let mut terminal = Terminal::new(TestBackend::new(120, 8))?; + terminal.draw(|frame| { + super::draw( + frame, + &mut app, + &decorations, + &gix::mailmap::Snapshot::default(), + None, + Some(&changes), + ); + })?; + let footer = rendered_line(&terminal, 7); + assert!( + footer.contains("edit (spill)"), + "tree focus keeps the scoped edit visible: {footer}" + ); + assert!(!footer.contains("amend"), "tree paths cannot be amended"); + Ok(()) + } + #[test] fn renders_worktree_labels_and_keeps_them_selected_when_refs_are_hidden() -> Result<(), Box> { From 5ac278a71e882472ad4f12de038905e2af8444d6 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 15:29:00 +0200 Subject: [PATCH 066/282] feat: expose hidden branch divergence in tix Use named hidden local refs and the persistent history graph to find their best common bases with the visible view. Render the view-relative missing count as a right-aligned behind marker with stable margins, retain normal reference display, and tolerate unavailable hidden revisions when another requested hidden revision resolves. --- gix-tix/spec.md | 14 +++-- gix-tix/src/app.rs | 18 +++++-- gix-tix/src/history.rs | 115 ++++++++++++++++++++++++++++++++++++++++- gix-tix/src/lib.rs | 28 +++++++++- gix-tix/src/ui.rs | 71 +++++++++++++++++++++---- 5 files changed, 225 insertions(+), 21 deletions(-) diff --git a/gix-tix/spec.md b/gix-tix/spec.md index 4c360bb2528..3f14ebea243 100644 --- a/gix-tix/spec.md +++ b/gix-tix/spec.md @@ -20,8 +20,9 @@ without trading responsiveness for metadata that is not visible. revisions. Hidden revisions still exclude matching ancestry. - `--quit-on-finish` exits after traversal and lane computation, for measurement and non-interactive use. -- Revisions must resolve and peel to commits. Invalid or non-commit revisions are - errors. +- Revisions must resolve and peel to commits. Invalid or non-commit visible + revisions are errors. An unavailable hidden revision emits a warning and is + ignored when another hidden revision resolves; if none resolve, startup fails. - The UI always owns the alternate screen. Raw mode, focus reporting, mouse capture, and enhanced keyboard reporting are restored on every exit path. - `Ctrl-C` exits immediately from any normal tix focus. `q` quits from history; @@ -57,11 +58,16 @@ without trading responsiveness for metadata that is not visible. - Boundary rows retain graph styling but use terminal-default colors, are dimmed, and cannot be selected, paged to, copied, signature-verified, restored as a selection, or entered by Shift navigation. -- When hidden revisions are configured, references are hidden by default so - metadata remains aligned. +- Hidden revisions do not change the default reference display mode. - `v`, then `h`, toggles the full hidden projection. Toggling preserves the selected commit when it still exists and otherwise selects the newest selectable row. +- When a hidden revspec names a local branch, its best common base with the + visible tips permanently shows `⇣N` after the commit title when that branch has + `N` commits not reachable from the view. The terminal edge pushes the marker + left over a clipped title when necessary. A blank margin remains on each side. + The cached history graph supplies the base and count; unrelated refs and + zero-count relations add no marker. ### Row content and visual states diff --git a/gix-tix/src/app.rs b/gix-tix/src/app.rs index 39cb8297deb..bd74b4368e1 100644 --- a/gix-tix/src/app.rs +++ b/gix-tix/src/app.rs @@ -393,6 +393,7 @@ pub(crate) struct App { signature_verification_running: bool, pub(crate) manual_refresh: bool, pub(crate) selection_relation: Option, + hidden_branch_behind: HashMap, } impl App { @@ -470,6 +471,7 @@ impl App { signature_verification_running: false, manual_refresh: false, selection_relation: None, + hidden_branch_behind: HashMap::new(), } } @@ -483,9 +485,6 @@ impl App { pub(crate) fn configure_hidden_filter(&mut self, present: bool) { self.has_hidden_filter = present; - if present { - self.ref_mode = RefMode::None; - } } pub(crate) fn set_worktree_head(&mut self, head: Option, select_on_load: bool) { @@ -974,6 +973,14 @@ impl App { self.hidden_rows.clone() } + pub(crate) fn set_hidden_branch_behind(&mut self, markers: HashMap) { + self.hidden_branch_behind = markers; + } + + pub(crate) fn hidden_branch_behind(&self, id: ObjectId) -> Option { + self.hidden_branch_behind.get(&id).copied() + } + pub(crate) fn start_refresh( &mut self, commits: LoadedCommits, @@ -1089,6 +1096,7 @@ impl App { self.all_rows.clear(); self.all_order.clear(); self.hidden_rows.clear(); + self.hidden_branch_behind.clear(); self.pending_hidden_rows = None; self.titles = Vec::new(); self.notes.clear(); @@ -2791,8 +2799,8 @@ mod tests { app.configure_hidden_filter(true); assert_eq!( app.ref_mode, - RefMode::None, - "hidden ancestry hides references by default" + RefMode::Default, + "hidden ancestry keeps the normal reference display" ); app.extend_commits(vec![row(1)]); assert!( diff --git a/gix-tix/src/history.rs b/gix-tix/src/history.rs index 5eb238f51b5..8e015885305 100644 --- a/gix-tix/src/history.rs +++ b/gix-tix/src/history.rs @@ -407,13 +407,51 @@ impl HistoryGraph { .map(|(visible, _)| crate::app::SelectionRelation::Visible(visible)) } + pub(crate) fn hidden_branch_behind( + &self, + view_tips: &[ObjectId], + hidden_tips: impl IntoIterator, + ) -> HashMap { + let hidden_tips: HashSet<_> = hidden_tips.into_iter().collect(); + let mut out: HashMap = HashMap::new(); + for tip in hidden_tips { + let Some((ahead, _, bases)) = self.paint_with_bases(tip, view_tips) else { + continue; + }; + if ahead == 0 { + continue; + } + for base in bases { + out.entry(base) + .and_modify(|previous| *previous = (*previous).max(ahead)) + .or_insert(ahead); + } + } + out + } + fn paint(&self, first: ObjectId, others: &[ObjectId]) -> Option<(usize, usize)> { + self.paint_inner(first, others, false) + .map(|(ahead, behind, _)| (ahead, behind)) + } + + fn paint_with_bases(&self, first: ObjectId, others: &[ObjectId]) -> Option<(usize, usize, Vec)> { + self.paint_inner(first, others, true) + } + + fn paint_inner( + &self, + first: ObjectId, + others: &[ObjectId], + collect_bases: bool, + ) -> Option<(usize, usize, Vec)> { let first = self.index(first)?; let others: Vec<_> = others.iter().map(|id| self.index(*id)).collect::>()?; let mut flags = vec![0u8; self.commits.len()]; let mut queue = gix::revwalk::PriorityQueue::::new(); let mut queued = vec![false; self.commits.len()]; let mut pending = 0usize; + let mut bases = Vec::new(); for (index, flag) in std::iter::once((first, VISIBLE)).chain(others.into_iter().map(|index| (index, HIDDEN))) { flags[index.as_usize()] |= flag; if !queued[index.as_usize()] { @@ -430,6 +468,9 @@ impl HistoryGraph { pending -= 1; } if propagated & (VISIBLE | HIDDEN) == VISIBLE | HIDDEN { + if collect_bases && propagated & STALE == 0 { + bases.push(self.id(index)); + } propagated |= STALE; flags[index.as_usize()] = propagated; } @@ -461,7 +502,15 @@ impl HistoryGraph { _ => {} } } - Some((ahead, behind)) + if collect_bases && bases.len() > 1 { + let candidates = bases.clone(); + bases.retain(|candidate| { + !candidates + .iter() + .any(|other| candidate != other && self.is_ancestor(*candidate, *other)) + }); + } + Some((ahead, behind, bases)) } pub(crate) fn refresh( @@ -1400,6 +1449,34 @@ fn resolve_tips(repo: &gix::Repository, revisions: &[OsString]) -> Result