diff --git a/src/default_config.toml b/src/default_config.toml index 6088ab2bbc..93c33b57f3 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -180,6 +180,9 @@ fetch_menu.fetch_upstream = ["u"] root.log_menu = ["l"] log_menu.log_current = ["l"] +log_menu.log_local_branches = ["L"] +log_menu.log_all_branches = ["b"] +log_menu.log_all_refs = ["a"] log_menu.log_other = ["o"] log_menu.quit = ["q", "esc"] log_menu.-n = ["-n"] diff --git a/src/git/mod.rs b/src/git/mod.rs index 5607ee6410..d65188475b 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -25,6 +25,7 @@ mod parse; pub(crate) mod rebase_status; pub(crate) mod remote; pub(crate) mod status; +pub(crate) mod tree; pub(crate) fn rebase_status(repo: &Repository) -> Res> { let dir = repo.workdir().expect("No workdir"); diff --git a/src/git/tree.rs b/src/git/tree.rs new file mode 100644 index 0000000000..f18fe255d9 --- /dev/null +++ b/src/git/tree.rs @@ -0,0 +1,768 @@ +//! Logic and types for commit tree views (`git log --graph` layout). +//! +//! [`tree_roots`] walks the commits reachable from one or more root commits +//! and returns one [`TreeRow`] per output row. Each row carries the graph +//! prefix (e.g. `"* "` or `"|/"`) and, for commit rows, the commit's +//! details. The graph layout itself is computed by [`Graph::rows`], a port +//! of git's own `graph.c` state machine (sans colors, truncation and visual +//! roots). + +use crate::{Res, error::Error, item_data::Ref, items::short_age}; +use git2::{Oid, Repository}; +use regex::Regex; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub(crate) struct TreeRow { + /// Graph prefix for the row, e.g. `"* "` or `"|\\ "`. + pub(crate) graph: String, + /// Does this row correspond to a commit? + pub(crate) commit: Option, +} + +/// The fields of [`crate::item_data::ItemData::Commit`], so a tree row can be +/// turned into an `Item` without extra lookups. +#[derive(Debug, Clone)] +pub(crate) struct TreeCommit { + pub(crate) oid: String, + pub(crate) short_id: String, + pub(crate) associated_references: Vec, + pub(crate) summary: String, + pub(crate) author: String, + pub(crate) age: String, +} + +/// The oids of every local branch tip. +pub(crate) fn local_branch_roots(repo: &Repository) -> Vec { + branch_roots(repo, git2::BranchType::Local).collect() +} + +/// The oids of every branch tip (local and remote). +pub(crate) fn all_branch_roots(repo: &Repository) -> Vec { + branch_roots(repo, git2::BranchType::Local) + .chain(branch_roots(repo, git2::BranchType::Remote)) + .collect() +} + +/// The oids of every reference that points at a commit (branches, tags, +/// stashes, ...). +pub(crate) fn all_ref_roots(repo: &Repository) -> Vec { + let mut ids = Vec::new(); + if let Ok(references) = repo.references() { + for reference in references.flatten() { + if Ref::from_reference(&reference).is_none() { + continue; + } + if let Ok(commit) = reference.peel_to_commit() { + ids.push(commit.id()); + } + } + } + ids +} + +/// The oids of the tips of every branch of the given kind. +fn branch_roots<'repo>( + repo: &'repo Repository, + branch_type: git2::BranchType, +) -> impl Iterator + 'repo { + repo.branches(Some(branch_type)) + .into_iter() + .flatten() + .filter_map(|entry| match entry { + Ok((branch, _branch_type)) => branch.get().peel_to_commit().ok(), + Err(_) => None, + }) + .map(|commit| commit.id()) +} + +/// Collect the commits reachable from any of `roots` and lay them out as a +/// tree. An oid reachable from more than one root is included only once. +/// Commits whose message does not match `msg_regex` are dropped before the +/// `limit` is applied, like `git log --grep -n`. +pub(crate) fn tree_roots( + repo: &Repository, + limit: usize, + roots: &[Oid], + msg_regex: Option, +) -> Res> { + if roots.is_empty() { + return Ok(vec![]); + } + + let mut revwalk = repo.revwalk().map_err(Error::ReadLog)?; + // Walk in the same (date-ordered) sequence `git log` uses, so multiple + // roots (e.g. the local-branches view) come out in log order rather + // than in an order that depends on the roots' push order. The + // topological constraint keeps parents coming after their children + // even when commits share a committer date; without it the heap order + // of equal-dated commits can put a parent before its child, and the + // graph then draws that line as if it were a new branch. + revwalk + .set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL) + .map_err(Error::ReadLog)?; + for root in roots { + revwalk.push(*root).map_err(Error::ReadLog)?; + } + + // The limit is applied to the displayed (i.e. message-matching) commits, + // like `git log -n N --grep`: walk, filter, then take. + let oids: Vec = revwalk + .map(|oid_result| -> Res> { + let oid = oid_result.map_err(Error::ReadLog)?; + let commit = repo.find_commit(oid).map_err(Error::ReadLog)?; + let matches = msg_regex + .as_ref() + .is_none_or(|re| re.is_match(commit.message().unwrap_or_default())); + Ok(matches.then_some(oid)) + }) + .filter_map(|result| match result { + Ok(Some(oid)) => Some(Ok(oid)), + Ok(None) => None, + Err(err) => Some(Err(err)), + }) + .take(limit) + .collect::>>()?; + + let references = references(repo)?; + + // The commits, in display order, each indexed by oid for parent lookup. + let mut index_by_oid: HashMap = HashMap::with_capacity(oids.len()); + let mut nodes = Vec::with_capacity(oids.len()); + for (i, oid) in oids.iter().enumerate() { + index_by_oid.insert(*oid, i); + let commit = repo.find_commit(*oid).map_err(Error::ReadLog)?; + let short_id = commit.as_object().short_id().map_err(Error::ReadOid)?; + nodes.push(Node { + commit: TreeCommit { + oid: oid.to_string(), + short_id: String::from_utf8_lossy(&short_id).to_string(), + associated_references: references.get(oid).cloned().unwrap_or_default(), + summary: commit.summary().unwrap_or_default().to_string(), + author: commit.author().name().unwrap_or_default().to_string(), + age: short_age(commit.author().when()), + }, + parents: commit.parent_ids().collect(), + }); + } + + // The interesting parents of each commit: those that made it into the + // walk (e.g. ones cut off by the limit are not interesting, mirroring + // git's simplified history). + let parents: Vec> = nodes + .iter() + .map(|node| { + node.parents + .iter() + .filter_map(|parent| index_by_oid.get(parent).copied()) + .collect() + }) + .collect(); + + // Lay the rows out, then attach the commit data to the rows that carry + // a commit. + let rows = Graph::rows(parents) + .into_iter() + .map(|(graph, commit_idx)| TreeRow { + graph, + commit: commit_idx.map(|i| nodes[i].commit.clone()), + }) + .collect(); + + Ok(rows) +} + +#[derive(Debug)] +struct Node { + commit: TreeCommit, + parents: Vec, +} + +/// Every reference that points at a commit, grouped by that commit's oid. +fn references(repo: &Repository) -> Res>> { + let mut references: HashMap> = HashMap::new(); + for reference in repo.references().map_err(Error::ReadLog)?.flatten() { + if let (Ok(target), Some(ref_kind)) = + (reference.peel_to_commit(), Ref::from_reference(&reference)) + { + references.entry(target.id()).or_default().push(ref_kind); + } + } + Ok(references) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + Padding, + PreCommit, + Commit, + PostMerge, + Collapsing, +} + +const MERGE_CHARS: [char; 3] = ['/', '|', '\\']; + +/// The state machine that lays out graph lines the way `git log --graph` +/// does; drive it in one go with [`Graph::rows`]. +/// +/// A port of git's `graph.c` (the `columns` / `new_columns` / `mapping` +/// machinery). Column and mapping entries refer to the index of the +/// corresponding commit in the display-order list. +pub(crate) struct Graph { + /// Commit indices of the lines as they stood before the current commit. + columns: Vec, + /// Commit indices of the lines as they stand after the current commit. + new_columns: Vec, + /// Maps each display slot (2 slots per visual column) to the index of the + /// `new_columns` entry it eventually collapses onto; -1 for empty slots. + mapping: Vec, + mapping_size: usize, + old_mapping: Vec, + width: usize, + edges_added: i32, + prev_edges_added: i32, + merge_layout: i32, + /// The (interesting) parents of each commit, as indices into the + /// display-order list. + parents: Vec>, + /// Index of the commit currently being displayed. + commit: usize, + /// Position of the current commit within `columns`. + commit_index: usize, + prev_commit_index: usize, + state: State, + prev_state: State, + expansion_row: usize, +} + +impl Graph { + /// Lays out the graph rows for the given topology. `parents[i]` are the + /// indices (in display order) of commit `i`'s (interesting) parents. Each + /// result row is the row's graph prefix paired with the index of the commit + /// it carries, if any. + pub(crate) fn rows(parents: Vec>) -> Vec<(String, Option)> { + let num_commits = parents.len(); + let mut graph = Graph::new(parents); + let mut rows = Vec::new(); + for i in 0..num_commits { + graph.update(i); + let mut shown_commit = false; + loop { + let (prefix, is_commit_line) = graph.next_line(); + rows.push((prefix, is_commit_line.then_some(i))); + shown_commit |= is_commit_line; + + // The commit's output is complete once its commit line has been + // printed and the state machine settled back to padding. + if shown_commit && graph.state == State::Padding { + break; + } + } + } + rows + } + + fn new(parents: Vec>) -> Self { + Self { + columns: vec![], + new_columns: vec![], + mapping: vec![], + mapping_size: 0, + old_mapping: vec![], + width: 0, + edges_added: 0, + prev_edges_added: 0, + merge_layout: 0, + parents, + commit: 0, + commit_index: 0, + prev_commit_index: 0, + state: State::Padding, + prev_state: State::Padding, + expansion_row: 0, + } + } + + /// Number of (interesting) parents of the current commit. + fn num_parents(&self) -> usize { + self.parents[self.commit].len() + } + + /// Starts outputting the lines for the commit at `commit`. + fn update(&mut self, commit: usize) { + self.commit = commit; + + // Store the old commit_index in prev_commit_index; update_columns() + // will update commit_index for this commit. + self.prev_commit_index = self.commit_index; + + self.update_columns(); + + self.expansion_row = 0; + + // With the normal driver the previous commit always runs down to the + // padding state, so we land straight on the commit line (or on the + // pre-commit expansion rows of a wide merge). + if self.needs_pre_commit_line() { + self.state = State::PreCommit; + } else { + self.state = State::Commit; + } + } + + /// Outputs the next line. The second result is true only for the line + /// that carries the commit itself. + fn next_line(&mut self) -> (String, bool) { + let mut line = String::new(); + let is_commit_line = match self.state { + State::Padding => { + self.output_padding_line(&mut line); + false + } + State::PreCommit => { + self.output_pre_commit_line(&mut line); + false + } + State::Commit => { + self.output_commit_line(&mut line); + true + } + State::PostMerge => { + self.output_post_merge_line(&mut line); + false + } + State::Collapsing => { + self.output_collapsing_line(&mut line); + false + } + }; + + // Pad so that all lines for a commit have the same width and the + // fields printed to the right of the graph stay aligned. + if line.len() < self.width { + line.push_str(&" ".repeat(self.width - line.len())); + } + + (line, is_commit_line) + } + + fn update_columns(&mut self) { + // Swap columns with new_columns: columns now holds the state for the + // current commit, and new_columns becomes the storage for the state + // after this commit. + std::mem::swap(&mut self.columns, &mut self.new_columns); + let num_columns = self.columns.len(); + self.new_columns.clear(); + + // At most num_columns + num_parents columns for the next commit. + let max_new_columns = num_columns + self.num_parents(); + self.mapping = vec![-1; 2 * max_new_columns]; + self.mapping_size = 2 * max_new_columns; + self.width = 0; + self.prev_edges_added = self.edges_added; + self.edges_added = 0; + + // Populate new_columns and mapping. Some of the parents of this + // commit may already be in columns; in that case new_columns holds a + // single entry for each such commit, and mapping records where each + // line is supposed to end up after the collapsing is performed. + let mut seen_this = false; + for i in 0..=num_columns { + let col_commit = if i == num_columns { + if seen_this { + break; + } + self.commit + } else { + self.columns[i] + }; + + if col_commit == self.commit { + seen_this = true; + self.commit_index = i; + self.merge_layout = -1; + + for parent in self.parents[self.commit].clone() { + self.insert_into_new_columns(parent, i as i32); + } + + // The current commit always takes up at least 2 spaces. + if self.num_parents() == 0 { + self.width += 2; + } + } else { + self.insert_into_new_columns(col_commit, -1); + } + } + + // Shrink mapping to the minimum necessary. + while self.mapping_size > 1 && self.mapping[self.mapping_size - 1] < 0 { + self.mapping_size -= 1; + } + } + + /// Records a line in new_columns (adding it if absent) and extends + /// mapping with its target display slot. `idx` is the position of the + /// line in `columns`, or -1 for a line that is not in `columns`. + fn insert_into_new_columns(&mut self, commit: usize, idx: i32) { + let i = match self.new_columns.iter().position(|&c| c == commit) { + Some(i) => i, + None => { + self.new_columns.push(commit); + self.new_columns.len() - 1 + } + }; + + if self.num_parents() > 1 && idx >= 0 && self.merge_layout == -1 { + // The first parent of a merge: choose a layout based on whether + // the parent appears in a column to the left of the merge. + let dist = idx - i as i32; + let shift = if dist > 1 { 2 * dist - 3 } else { 1 }; + + self.merge_layout = if dist > 0 { 0 } else { 1 }; + self.edges_added = self.num_parents() as i32 + self.merge_layout - 2; + + let mapping_idx = (self.width as i32 + (self.merge_layout - 1) * shift) as usize; + self.width += 2 * self.merge_layout as usize; + self.mapping[mapping_idx] = i as i32; + } else if self.edges_added > 0 && i as i32 == self.mapping[self.width - 2] { + // Some columns have already been added by a merge, but this + // commit was found in the last existing column: make the two + // edges join immediately. + self.edges_added = -1; + self.mapping[self.width - 2] = i as i32; + } else { + self.mapping[self.width] = i as i32; + self.width += 2; + } + } + + fn needs_pre_commit_line(&self) -> bool { + self.num_parents() >= 3 && (self.commit_index as i32) < (self.columns.len() as i32 - 1) + } + + fn num_dashed_parents(&self) -> i32 { + self.num_parents() as i32 + self.merge_layout - 3 + } + + fn num_expansion_rows(&self) -> i32 { + self.num_dashed_parents() * 2 + } + + /// The mapping is up to date if each entry is at its target, or is 1 + /// greater than its target (in which case a '/' will be printed, so it + /// will look correct on the next row). + fn mapping_correct(&self) -> bool { + (0..self.mapping_size).all(|i| { + let target = self.mapping[i]; + target < 0 || target == i as i32 / 2 + }) + } + + fn update_state(&mut self, state: State) { + self.prev_state = self.state; + self.state = state; + } + + fn output_padding_line(&self, line: &mut String) { + // A padding row, that leaves all branch lines unchanged. + for _ in &self.new_columns { + line.push('|'); + line.push(' '); + } + } + + fn output_pre_commit_line(&mut self, line: &mut String) { + // A row that increases the space around a commit with multiple + // parents, to make room for it. Only called with 3 or more parents. + let mut seen_this = false; + for (i, &col_commit) in self.columns.iter().enumerate() { + if col_commit == self.commit { + seen_this = true; + line.push('|'); + line.push_str(&" ".repeat(self.expansion_row)); + } else if seen_this && self.expansion_row == 0 { + // If the previous commit was a merge and ended in the + // post-merge state, continue to print its branch lines as + // '\'; otherwise print them as '|'. + if self.prev_state == State::PostMerge && self.prev_commit_index < i { + line.push('\\'); + } else { + line.push('|'); + } + } else if seen_this && self.expansion_row > 0 { + line.push('\\'); + } else { + line.push('|'); + } + line.push(' '); + } + + self.expansion_row += 1; + if self.expansion_row >= self.num_expansion_rows() as usize { + self.update_state(State::Commit); + } + } + + fn output_commit_line(&mut self, line: &mut String) { + let num_columns = self.columns.len(); + let mut seen_this = false; + + // Iterate up to and including num_columns, since the current commit + // may not be in any of the existing columns (this happens when the + // commit has no children that have already been processed). + for i in 0..=num_columns { + let col_commit = if i == num_columns { + if seen_this { + break; + } + self.commit + } else { + self.columns[i] + }; + + if col_commit == self.commit { + seen_this = true; + line.push('*'); + + if self.num_parents() > 2 { + self.draw_octopus_merge(line); + } + } else if seen_this && self.edges_added > 1 { + line.push('\\'); + } else if seen_this && self.edges_added == 1 { + // A right-skewed 2-way merge, or a left-skewed 3-way merge. + // If the previous line was a post-merge line, the branch + // line coming into this commit may have been '\'; keep + // printing it as '\' so it looks nicer. + if self.prev_state == State::PostMerge + && self.prev_edges_added > 0 + && self.prev_commit_index < i + { + line.push('\\'); + } else { + line.push('|'); + } + } else if self.prev_state == State::Collapsing + && self.old_mapping.get(2 * i + 1).copied().unwrap_or(-1) == i as i32 + && self.mapping.get(2 * i).copied().unwrap_or(-1) < i as i32 + { + line.push('/'); + } else { + line.push('|'); + } + line.push(' '); + } + + if self.num_parents() > 1 { + self.update_state(State::PostMerge); + } else if self.mapping_correct() { + self.update_state(State::Padding); + } else { + self.update_state(State::Collapsing); + } + } + + /// Draws the horizontal dashes of an octopus merge. + fn draw_octopus_merge(&self, line: &mut String) { + let dashed_parents = self.num_dashed_parents(); + for i in 0..dashed_parents { + line.push('-'); + line.push(if i == dashed_parents - 1 { '.' } else { '-' }); + } + } + + fn output_post_merge_line(&mut self, line: &mut String) { + let num_columns = self.columns.len(); + let first_parent = self.parents[self.commit].first().copied(); + let mut seen_this = false; + let mut parent_col = None; + + for i in 0..=num_columns { + let col_commit = if i == num_columns { + if seen_this { + break; + } + self.commit + } else { + self.columns[i] + }; + + if col_commit == self.commit { + // Find the columns for the parent commits in new_columns and + // use those to format the edges. + seen_this = true; + let mut idx = self.merge_layout; + for (j, _) in self.parents[self.commit].iter().enumerate() { + line.push(MERGE_CHARS[idx as usize]); + + if idx == 2 { + if self.edges_added > 0 || j < self.num_parents() - 1 { + line.push(' '); + } + } else { + idx += 1; + } + } + if self.edges_added == 0 { + line.push(' '); + } + } else if seen_this { + if self.edges_added > 0 { + line.push('\\'); + } else { + line.push('|'); + } + line.push(' '); + } else { + line.push('|'); + if self.merge_layout != 0 || i as i32 != self.commit_index as i32 - 1 { + if parent_col.is_some() { + line.push('_'); + } else { + line.push(' '); + } + } + } + + if Some(col_commit) == first_parent { + parent_col = Some(i); + } + } + + if self.mapping_correct() { + self.update_state(State::Padding); + } else { + self.update_state(State::Collapsing); + } + } + + fn output_collapsing_line(&mut self, line: &mut String) { + // Swap the mapping and old_mapping arrays. + std::mem::swap(&mut self.mapping, &mut self.old_mapping); + + // Clear out the mapping array. + self.mapping = vec![-1; self.mapping_size]; + + let mut used_horizontal = false; + let mut horizontal_edge = -1; + let mut horizontal_edge_target = -1; + + for i in 0..self.mapping_size { + let target = self.old_mapping[i]; + if target < 0 { + continue; + } + + // update_columns() always inserts the leftmost column first, so + // each branch's target is either its current location or to the + // left of it; we never have to move branches to the right. + if target * 2 == i as i32 { + // This column is already in the correct place. + self.mapping[i] = target; + } else if self.mapping[i - 1] < 0 { + // Nothing is to the left: move to the left by one. + self.mapping[i - 1] = target; + + // If there isn't already an edge moving horizontally, select + // this one. The screen column of the first horizontal line + // is target*2+3. + if horizontal_edge == -1 { + horizontal_edge = i as i32; + horizontal_edge_target = target; + for j in (target * 2 + 3..i as i32 - 2).step_by(2) { + self.mapping[j as usize] = target; + } + } + } else if self.mapping[i - 1] == target { + // There is a branch line to our left already, and it is our + // target: combine with this line, since we share the same + // parent commit. + } else { + // There is a branch line to our left, but it isn't our + // target: cross over it. The space just to the left of this + // branch should always be empty. + self.mapping[i - 2] = target; + + if horizontal_edge == -1 { + horizontal_edge_target = target; + horizontal_edge = i as i32 - 1; + for j in (target * 2 + 3..i as i32 - 2).step_by(2) { + self.mapping[j as usize] = target; + } + } + } + } + + // Copy the current mapping array into old_mapping. + self.old_mapping = self.mapping.clone(); + + // The new mapping may be 1 smaller than the old mapping. + if self.mapping[self.mapping_size - 1] < 0 { + self.mapping_size -= 1; + } + + // Output a line based on the new mapping info. + for i in 0..self.mapping_size { + let target = self.mapping[i]; + + if target < 0 { + line.push(' '); + } else if target * 2 == i as i32 { + line.push('|'); + } else if target == horizontal_edge_target && i as i32 != horizontal_edge - 1 { + // Set the mappings for all but the first segment to -1 so + // that they won't continue into the next line. + if i as i32 != target * 2 + 3 { + self.mapping[i] = -1; + } + used_horizontal = true; + line.push('_'); + } else { + if used_horizontal && (i as i32) < horizontal_edge { + self.mapping[i] = -1; + } + line.push('/'); + } + } + + if self.mapping_correct() { + self.update_state(State::Padding); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Runs the graph driver over `parents` (indices into the commit list, + /// in display order) and returns the emitted lines with the commit line + /// marked by a 'C' prefix. + fn render(parents: Vec>) -> Vec { + Graph::rows(parents) + .into_iter() + .map(|(line, is_commit)| { + format!( + "{}{}", + if is_commit.is_some() { "C" } else { " " }, + line.trim_end() + ) + }) + .collect() + } + + #[test] + fn linear_history() { + // 0 <- 1 <- 2 + let lines = render(vec![vec![1], vec![2], vec![]]); + assert_eq!(lines, ["C*", "C*", "C*"]); + } + + #[test] + fn siblings() { + // 0, 1 are siblings on top of 2 (the shape from the issue example). + let lines = render(vec![vec![2], vec![2], vec![]]); + assert_eq!(lines, ["C*", "C| *", " |/", "C*"]); + } +} diff --git a/src/item_data.rs b/src/item_data.rs index 0c2e9ee687..7ee95866c8 100644 --- a/src/item_data.rs +++ b/src/item_data.rs @@ -13,6 +13,9 @@ pub(crate) enum ItemData { kind: Ref, }, Commit { + /// `git log --graph` prefix, e.g. `"* "`. Empty for logs without a + /// graph (e.g. the status screen's recent commits). + graph: String, oid: String, short_id: String, associated_references: Vec, @@ -148,22 +151,44 @@ pub(crate) enum Ref { Tag(String), Head(String), Remote(String), + /// Anything else (e.g. a stash). + Other(String), } impl Ref { + /// Classify a reference, or `None` for ones we don't display (remote + /// `HEAD` symrefs and prefetch refs). + pub(crate) fn from_reference(reference: &git2::Reference) -> Option { + let name = reference.shorthand()?.to_string(); + if name.ends_with("/HEAD") || name.starts_with("prefetch/remotes/") { + return None; + } + + Some(if reference.is_remote() { + Ref::Remote(name) + } else if reference.is_tag() { + Ref::Tag(name) + } else if reference.is_branch() { + Ref::Head(name) + } else { + Ref::Other(name) + }) + } + /// Convert to fully qualified refname (e.g., "refs/heads/main", "refs/tags/v1.0.0") pub(crate) fn to_full_refname(&self) -> String { match self { Ref::Head(name) => format!("refs/heads/{}", name), Ref::Tag(name) => format!("refs/tags/{}", name), Ref::Remote(name) => format!("refs/remotes/{}", name), + Ref::Other(name) => format!("refs/{}", name), } } /// Get the shorthand name without refs/ prefix pub(crate) fn shorthand(&self) -> &str { match self { - Ref::Head(name) | Ref::Tag(name) | Ref::Remote(name) => name, + Ref::Head(name) | Ref::Tag(name) | Ref::Remote(name) | Ref::Other(name) => name, } } } diff --git a/src/items.rs b/src/items.rs index 88077bdb7e..dc50398be4 100644 --- a/src/items.rs +++ b/src/items.rs @@ -145,7 +145,7 @@ pub(crate) fn stash_list(repo: &Repository, limit: usize) -> Res> { .collect::>()) } -fn short_age(time: git2::Time) -> String { +pub(crate) fn short_age(time: git2::Time) -> String { const MINUTE: i64 = 60; const HOUR: i64 = 60 * MINUTE; const DAY: i64 = 24 * HOUR; @@ -192,28 +192,12 @@ pub(crate) fn log( .references() .map_err(Error::ReadLog)? .filter_map(Result::ok) - .filter_map( - |reference| match (reference.peel_to_commit(), reference.shorthand()) { - (Ok(target), Some(name)) => { - if name.ends_with("/HEAD") || name.starts_with("prefetch/remotes/") { - return None; - } - - let name = name.to_owned(); - - let ref_kind = if reference.is_remote() { - Ref::Remote(name) - } else if reference.is_tag() { - Ref::Tag(name) - } else { - Ref::Head(name) - }; - - Some((target, ref_kind)) - } + .filter_map(|reference| { + match (reference.peel_to_commit(), Ref::from_reference(&reference)) { + (Ok(target), Some(ref_kind)) => Some((target, ref_kind)), _ => None, - }, - ) + } + }) .collect(); let items: Vec = revwalk @@ -237,6 +221,7 @@ pub(crate) fn log( .collect(); let data = ItemData::Commit { + graph: String::new(), oid: oid.to_string(), short_id, associated_references, diff --git a/src/lib.rs b/src/lib.rs index 9c582a05ee..985933eb38 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,7 +5,7 @@ mod cmd_log; pub mod config; pub mod error; mod file_watcher; -mod git; +pub mod git; pub mod gitu_diff; mod highlight; mod item_data; diff --git a/src/ops/log.rs b/src/ops/log.rs index 7e49d284f6..025f4acc07 100644 --- a/src/ops/log.rs +++ b/src/ops/log.rs @@ -3,6 +3,7 @@ use crate::{ Res, app::{App, PromptParams, State}, error::Error, + git::tree, item_data::{ItemData, Rev}, menu::arg::{Arg, any_regex, positive_number}, screen, @@ -12,12 +13,15 @@ use git2::Oid; use regex::Regex; use std::{rc::Rc, sync::Arc}; +/// Default for `-n`, like magit's default log buffer arguments. +const DEFAULT_LIMIT: u32 = 256; + pub(crate) fn init_args() -> Vec { vec![ Arg::new_arg( "-n", "Limit number of commits", - Some(|| 256), + Some(|| DEFAULT_LIMIT), positive_number, ), Arg::new_arg("--grep", "Search messages", None, any_regex), @@ -29,7 +33,7 @@ pub(crate) struct LogCurrent; impl OpTrait for LogCurrent { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app: &mut App, _term: &mut Term| { - goto_log_screen(app, None); + goto_log_screen(app, Vec::new()); Ok(()) })) } @@ -67,6 +71,51 @@ impl OpTrait for LogOther { } } +pub(crate) struct LogLocalBranches; +impl OpTrait for LogLocalBranches { + fn get_action(&self, _target: &ItemData) -> Option { + Some(Rc::new(|app: &mut App, _term: &mut Term| { + let roots = tree::local_branch_roots(&app.state.repo); + goto_log_screen(app, roots); + Ok(()) + })) + } + + fn display(&self, _state: &State) -> String { + "local branches".into() + } +} + +pub(crate) struct LogAllBranches; +impl OpTrait for LogAllBranches { + fn get_action(&self, _target: &ItemData) -> Option { + Some(Rc::new(|app: &mut App, _term: &mut Term| { + let roots = tree::all_branch_roots(&app.state.repo); + goto_log_screen(app, roots); + Ok(()) + })) + } + + fn display(&self, _state: &State) -> String { + "all branches".into() + } +} + +pub(crate) struct LogAllRefs; +impl OpTrait for LogAllRefs { + fn get_action(&self, _target: &ItemData) -> Option { + Some(Rc::new(|app: &mut App, _term: &mut Term| { + let roots = tree::all_ref_roots(&app.state.repo); + goto_log_screen(app, roots); + Ok(()) + })) + } + + fn display(&self, _state: &State) -> String { + "all refs".into() + } +} + fn log_other(app: &mut App, _term: &mut Term, result: &str) -> Res<()> { let oid_result = match app.state.repo.revparse_single(result) { Ok(rev) => Ok(rev.id()), @@ -75,11 +124,11 @@ fn log_other(app: &mut App, _term: &mut Term, result: &str) -> Res<()> { let oid = oid_result?; - goto_log_screen(app, Some(oid)); + goto_log_screen(app, vec![oid]); Ok(()) } -fn goto_log_screen(app: &mut App, rev: Option) { +fn goto_log_screen(app: &mut App, revs: Vec) { app.state.screens.drain(1..); let size = app.state.screens.last().unwrap().size; let limit = *app @@ -88,7 +137,7 @@ fn goto_log_screen(app: &mut App, rev: Option) { .as_ref() .and_then(|m| m.args.get("-n")) .and_then(|arg| arg.value_as::()) - .unwrap_or(&u32::MAX); + .unwrap_or(&DEFAULT_LIMIT); let msg_regex_menu = app .state @@ -104,7 +153,7 @@ fn goto_log_screen(app: &mut App, rev: Option) { Rc::clone(&app.state.repo), size, limit as usize, - rev, + revs, msg_regex, ) .expect("Couldn't create screen"), diff --git a/src/ops/mod.rs b/src/ops/mod.rs index 3fe8e652c1..a7254610a2 100644 --- a/src/ops/mod.rs +++ b/src/ops/mod.rs @@ -90,6 +90,9 @@ pub(crate) enum Op { CommitFixup, CommitInstantFixup, LogOther, + LogLocalBranches, + LogAllBranches, + LogAllRefs, RebaseAutosquash, RebaseInteractive, ResetSoft, @@ -184,6 +187,9 @@ impl Op { Op::FetchPushRemote => Box::new(fetch::FetchPushRemote), Op::FetchUpstream => Box::new(fetch::FetchUpstream), Op::LogCurrent => Box::new(log::LogCurrent), + Op::LogLocalBranches => Box::new(log::LogLocalBranches), + Op::LogAllBranches => Box::new(log::LogAllBranches), + Op::LogAllRefs => Box::new(log::LogAllRefs), Op::PullFromPushRemote => Box::new(pull::PullFromPushRemote), Op::PullFromUpstream => Box::new(pull::PullFromUpstream), Op::PullFromElsewhere => Box::new(pull::PullFromElsewhere), diff --git a/src/screen/log.rs b/src/screen/log.rs index 7e97634448..919ba88967 100644 --- a/src/screen/log.rs +++ b/src/screen/log.rs @@ -1,5 +1,11 @@ use super::Screen; -use crate::{Res, config::Config, items::log}; +use crate::{ + Res, + config::Config, + git::tree, + item_data::ItemData, + items::{self, Item}, +}; use git2::{Oid, Repository}; use regex::Regex; use std::{rc::Rc, sync::Arc}; @@ -9,12 +15,67 @@ pub(crate) fn create( repo: Rc, size: (u16, u16), limit: usize, - rev: Option, + revs: Vec, msg_regex: Option, ) -> Res { Screen::new( Arc::clone(&config), size, - Box::new(move || log(&repo, limit, rev, msg_regex.clone())), + Box::new(move || { + // Without explicit roots the tree is rooted at HEAD. + let roots = if revs.is_empty() { + match repo.head().and_then(|reference| reference.peel_to_commit()) { + Ok(commit) => vec![commit.id()], + Err(_) => vec![], + } + } else { + revs.clone() + }; + + let rows = tree::tree_roots(&repo, limit, &roots, msg_regex.clone())?; + + if rows.is_empty() { + // A message filter that matched nothing gets a hint; an + // empty repo (no HEAD) does not. + if msg_regex.is_some() { + Ok(vec![Item { + data: ItemData::Raw("No commits found".to_string()), + ..Default::default() + }]) + } else { + Ok(vec![]) + } + } else { + Ok(rows.into_iter().enumerate().map(row_to_item).collect()) + } + }), ) } + +/// Turns a tree row into a screen item. Commit rows are selectable; the +/// graph-only rows (merge branches, collapses) are unselectable padding. +fn row_to_item((i, row): (usize, tree::TreeRow)) -> Item { + match row.commit { + Some(commit) => Item { + id: items::hash(commit.oid.clone()), + depth: 1, + data: ItemData::Commit { + graph: row.graph, + oid: commit.oid, + short_id: commit.short_id, + associated_references: commit.associated_references, + summary: commit.summary, + author: commit.author, + age: commit.age, + }, + ..Default::default() + }, + None => Item { + id: items::hash(format!("graph-{i}-{}", row.graph)), + depth: 1, + unselectable: true, + data: ItemData::Raw(row.graph), + ..Default::default() + }, + } +} diff --git a/src/tests/log.rs b/src/tests/log.rs index a50204342a..7a4f7d69a1 100644 --- a/src/tests/log.rs +++ b/src/tests/log.rs @@ -77,6 +77,192 @@ fn log_other_invalid() { snapshot!(setup(setup_clone!()), "lo "); } +/// `git` with a fixed committer/author date, set per-process rather than +/// through the process-wide env that the shared helpers use, so parallel +/// tests can't clobber each other's dates. +fn git_at(ctx: &TestContext, date: &str, args: &[&str]) { + let output = std::process::Command::new("git") + .args(args) + .current_dir(&ctx.dir) + .env("GIT_AUTHOR_DATE", date) + .env("GIT_COMMITTER_DATE", date) + .output() + .unwrap_or_else(|e| panic!("failed to execute git {args:?}: {e}")); + assert!( + output.status.success(), + "failed to execute git {args:?}. Output: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Like the shared `commit()` helper, but with a fixed commit date (see +/// [`git_at`]). +fn commit_at(ctx: &TestContext, date: &str, file_name: &str, contents: &str, message: &str) { + fs::write(ctx.dir.join(file_name), contents).expect("error writing file"); + run(&ctx.dir, &["git", "add", file_name]); + git_at(ctx, date, &["commit", "-m", message]); +} + +#[test] +fn log_local_branches() { + let ctx = setup_clone!(); + // M (main) + // / \ + // C4 C3 (sub) + // | | + // C1 - C2 (feature) + // | + // C0 (origin/main) + // + // A branch (feature) off main, another branch (sub) off that, and sub + // merged back into main. Each commit gets a distinct committer date, + // so the tree walk is a plain date-ordered one, like `git log`. + commit_at( + &ctx, + "2024-03-01T10:00:00+00:00", + "one", + "1\n", + "first commit", + ); + run(&ctx.dir, &["git", "checkout", "-q", "-b", "feature"]); + commit_at( + &ctx, + "2024-03-02T10:00:00+00:00", + "two", + "2\n", + "start feature", + ); + run(&ctx.dir, &["git", "checkout", "-q", "-b", "sub"]); + commit_at( + &ctx, + "2024-03-03T10:00:00+00:00", + "three", + "3\n", + "add to feature", + ); + run(&ctx.dir, &["git", "checkout", "-q", "main"]); + commit_at( + &ctx, + "2024-03-04T10:00:00+00:00", + "four", + "4\n", + "more main work", + ); + git_at( + &ctx, + "2024-03-05T10:00:00+00:00", + &["merge", "-q", "--no-ff", "sub"], + ); + + snapshot!(ctx, "lL"); +} + +// C3 (side) +// | +// C2 (main) <- v1, and a stash (S) in log_all_refs +// | +// C1 +// | +// C0 (origin/main) +// +// The local branches diverge at C1 (side has C3, main has C2), and +// origin/main points at the initial commit. +fn diverging_branches(ctx: &TestContext) { + commit_at( + ctx, + "2024-03-01T10:00:00+00:00", + "one", + "1\n", + "first commit", + ); + run(&ctx.dir, &["git", "checkout", "-q", "-b", "side"]); + commit_at( + ctx, + "2024-03-03T10:00:00+00:00", + "three", + "3\n", + "third commit", + ); + run(&ctx.dir, &["git", "checkout", "-q", "main"]); + commit_at( + ctx, + "2024-03-02T10:00:00+00:00", + "two", + "2\n", + "second commit", + ); +} + +#[test] +fn log_local_branches_single_branch() { + let ctx = setup_clone!(); + // C4 (main) + // / \ + // C2 C3 (side) + // | | + // C1 ------- (side merged back with --no-ff) + // | + // C0 (origin/main) + // + // With only one local branch, the tree view has a single root; it must + // still show the graph (a plain log would hide the merged branch). + commit_at( + &ctx, + "2024-03-01T10:00:00+00:00", + "one", + "1\n", + "first commit", + ); + run(&ctx.dir, &["git", "checkout", "-q", "-b", "side"]); + commit_at( + &ctx, + "2024-03-02T10:00:00+00:00", + "two", + "2\n", + "second commit", + ); + run(&ctx.dir, &["git", "checkout", "-q", "main"]); + commit_at( + &ctx, + "2024-03-03T10:00:00+00:00", + "three", + "3\n", + "third commit", + ); + git_at( + &ctx, + "2024-03-04T10:00:00+00:00", + &["merge", "-q", "--no-ff", "side"], + ); + run(&ctx.dir, &["git", "branch", "-d", "side"]); + + snapshot!(ctx, "lL"); +} + +#[test] +fn log_all_branches() { + let ctx = setup_clone!(); + diverging_branches(&ctx); + + // Roots are every local and remote branch tip. + snapshot!(ctx, "lb"); +} + +#[test] +fn log_all_refs() { + let ctx = setup_clone!(); + diverging_branches(&ctx); + // A tag on the main tip, and a stash on top of side (a ref that is + // neither a branch, tag, nor remote branch). + run(&ctx.dir, &["git", "tag", "v1"]); + run(&ctx.dir, &["git", "checkout", "-q", "side"]); + fs::write(ctx.dir.join("initial-file"), "hello\nmodified\n").expect("error writing file"); + git_at(&ctx, "2024-03-04T10:00:00+00:00", &["stash", "-q"]); + + // Roots are every reference. + snapshot!(ctx, "la"); +} + #[test] fn log_empty_branch() { // Regression for #262: showing the log of a branch with no commits used to diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 9cef1630d6..7c0d9d6d97 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -35,6 +35,7 @@ mod reverse; mod search; mod stage; mod stash; +mod tree; mod unstage; pub use keys::*; diff --git a/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick.snap b/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick.snap index 35cb2c339a..549f18e9cb 100644 --- a/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick.snap +++ b/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick.snap @@ -2,8 +2,8 @@ source: src/tests/cherry_pick.rs expression: ctx.redact_buffer() --- -▌b9ef0d7 main other-branch add cherry-file Author Name __ | - b66a0bf origin/main add initial-file Author Name __ | +▌* b9ef0d7 main other-branch add cherry-file Author Name __ | + * b66a0bf origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | ────────────────────────────────────────────────────────────────────────────────| $ git cherry-pick other-branch | -styles_hash: e9f10e1b677b521d +styles_hash: c9a912e707a1c9cb diff --git a/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_no_commit.snap b/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_no_commit.snap index 413a8805b5..220a1ec0b0 100644 --- a/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_no_commit.snap +++ b/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_no_commit.snap @@ -2,7 +2,7 @@ source: src/tests/cherry_pick.rs expression: ctx.redact_buffer() --- -▌b66a0bf main origin/main add initial-file Author Name __ | +▌* b66a0bf main origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() $ git cherry-pick --no-commit Aother-branch | fatal: bad revision 'Aother-branch' | ! 'git cherry-pick--no-commitAother-branch' exited with code: 128 | -styles_hash: 3519610333b8eb28 +styles_hash: 9aee67d77a29909e diff --git a/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt.snap b/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt.snap index 83f09dd230..c0d104f3cd 100644 --- a/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt.snap +++ b/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt.snap @@ -2,7 +2,7 @@ source: src/tests/cherry_pick.rs expression: ctx.redact_buffer() --- - b66a0bf main origin/main add initial-file Author Name __ | + * b66a0bf main origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: fdfc7d3451e22d97 +styles_hash: cdf191580fdd8b34 diff --git a/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt_cancel.snap b/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt_cancel.snap index 3720917cac..430e8082ec 100644 --- a/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt_cancel.snap +++ b/src/tests/snapshots/gitu__tests__cherry_pick__cherry_pick_prompt_cancel.snap @@ -2,7 +2,7 @@ source: src/tests/cherry_pick.rs expression: ctx.redact_buffer() --- -▌b66a0bf main origin/main add initial-file Author Name __ | +▌* b66a0bf main origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: 74136530bbac8274 +styles_hash: 95e4eeecdc3ae74f diff --git a/src/tests/snapshots/gitu__tests__log.snap b/src/tests/snapshots/gitu__tests__log.snap index 38b2d430ec..2bd04582d5 100644 --- a/src/tests/snapshots/gitu__tests__log.snap +++ b/src/tests/snapshots/gitu__tests__log.snap @@ -2,9 +2,9 @@ source: src/tests/mod.rs expression: ctx.redact_buffer() --- -▌0c2c6c3 main a-tag add secondfile Author Name __ | - 223428c annotated add firstfile Author Name __ | - b66a0bf origin/main add initial-file Author Name __ | +▌* 0c2c6c3 main a-tag add secondfile Author Name __ | + * 223428c annotated add firstfile Author Name __ | + * b66a0bf origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: 4178da3e6ca7da32 +styles_hash: ce95d2071672732e diff --git a/src/tests/snapshots/gitu__tests__log__grep_prompt.snap b/src/tests/snapshots/gitu__tests__log__grep_prompt.snap index 8f2b3ef809..a7fca3db47 100644 --- a/src/tests/snapshots/gitu__tests__log__grep_prompt.snap +++ b/src/tests/snapshots/gitu__tests__log__grep_prompt.snap @@ -12,14 +12,14 @@ expression: ctx.redact_buffer() b66a0bf origin/main add initial-file Author Name __ | | | - | - | - | ────────────────────────────────────────────────────────────────────────────────| - Log Arguments | - l current -F Search messages (--grep) | - o other -n Limit number of commits (-n=256) | + Log Arguments | + l current -F Search messages (--grep) | + o other -n Limit number of commits (-n=256) | + L local branches | + b all branches | + a all refs | q/esc Quit/Close | ────────────────────────────────────────────────────────────────────────────────| ? Search messages: › █ | -styles_hash: 880fcd2eadaeef1f +styles_hash: 2f613fb5627c7f8c diff --git a/src/tests/snapshots/gitu__tests__log__grep_second.snap b/src/tests/snapshots/gitu__tests__log__grep_second.snap index 6d5eb2ac1b..22f5cc9836 100644 --- a/src/tests/snapshots/gitu__tests__log__grep_second.snap +++ b/src/tests/snapshots/gitu__tests__log__grep_second.snap @@ -2,7 +2,7 @@ source: src/tests/log.rs expression: ctx.redact_buffer() --- -▌6c08cf7 add second commit Author Name __ | +▌* 6c08cf7 add second commit Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: d7069ddaa611c83d +styles_hash: e223d27933984bd5 diff --git a/src/tests/snapshots/gitu__tests__log__grep_second_other.snap b/src/tests/snapshots/gitu__tests__log__grep_second_other.snap index 6d5eb2ac1b..22f5cc9836 100644 --- a/src/tests/snapshots/gitu__tests__log__grep_second_other.snap +++ b/src/tests/snapshots/gitu__tests__log__grep_second_other.snap @@ -2,7 +2,7 @@ source: src/tests/log.rs expression: ctx.redact_buffer() --- -▌6c08cf7 add second commit Author Name __ | +▌* 6c08cf7 add second commit Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: d7069ddaa611c83d +styles_hash: e223d27933984bd5 diff --git a/src/tests/snapshots/gitu__tests__log__grep_set_example.snap b/src/tests/snapshots/gitu__tests__log__grep_set_example.snap index 68efd0c61f..b05b562a5c 100644 --- a/src/tests/snapshots/gitu__tests__log__grep_set_example.snap +++ b/src/tests/snapshots/gitu__tests__log__grep_set_example.snap @@ -14,12 +14,12 @@ expression: ctx.redact_buffer() | | | - | - | - | ────────────────────────────────────────────────────────────────────────────────| - Log Arguments | - l current -F Search messages (--grep=example) | - o other -n Limit number of commits (-n=256) | + Log Arguments | + l current -F Search messages (--grep=example) | + o other -n Limit number of commits (-n=256) | + L local branches | + b all branches | + a all refs | q/esc Quit/Close | -styles_hash: e5cbb35512f17847 +styles_hash: 95e0fb86331a60b7 diff --git a/src/tests/snapshots/gitu__tests__log__limit_2_commits.snap b/src/tests/snapshots/gitu__tests__log__limit_2_commits.snap index 5d4318a2dd..0327cf4aba 100644 --- a/src/tests/snapshots/gitu__tests__log__limit_2_commits.snap +++ b/src/tests/snapshots/gitu__tests__log__limit_2_commits.snap @@ -2,8 +2,8 @@ source: src/tests/log.rs expression: ctx.redact_buffer() --- -▌8bb5532 main add first commit Author Name __ | - 6c08cf7 add second commit Author Name __ | +▌* 8bb5532 main add first commit Author Name __ | + * 6c08cf7 add second commit Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: f42d1be019af8018 +styles_hash: 4256947eac8c1a17 diff --git a/src/tests/snapshots/gitu__tests__log__limit_2_commits_other.snap b/src/tests/snapshots/gitu__tests__log__limit_2_commits_other.snap index 5d4318a2dd..0327cf4aba 100644 --- a/src/tests/snapshots/gitu__tests__log__limit_2_commits_other.snap +++ b/src/tests/snapshots/gitu__tests__log__limit_2_commits_other.snap @@ -2,8 +2,8 @@ source: src/tests/log.rs expression: ctx.redact_buffer() --- -▌8bb5532 main add first commit Author Name __ | - 6c08cf7 add second commit Author Name __ | +▌* 8bb5532 main add first commit Author Name __ | + * 6c08cf7 add second commit Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: f42d1be019af8018 +styles_hash: 4256947eac8c1a17 diff --git a/src/tests/snapshots/gitu__tests__log__limit_prompt.snap b/src/tests/snapshots/gitu__tests__log__limit_prompt.snap index 7126bca9ec..22b8062409 100644 --- a/src/tests/snapshots/gitu__tests__log__limit_prompt.snap +++ b/src/tests/snapshots/gitu__tests__log__limit_prompt.snap @@ -12,14 +12,14 @@ expression: ctx.redact_buffer() b66a0bf origin/main add initial-file Author Name __ | | | - | - | - | ────────────────────────────────────────────────────────────────────────────────| - Log Arguments | - l current -F Search messages (--grep) | - o other -n Limit number of commits (-n) | + Log Arguments | + l current -F Search messages (--grep) | + o other -n Limit number of commits (-n) | + L local branches | + b all branches | + a all refs | q/esc Quit/Close | ────────────────────────────────────────────────────────────────────────────────| ? Limit number of commits (default 256): › █ | -styles_hash: 15e73bd9294d10 +styles_hash: 39a543d959c0929d diff --git a/src/tests/snapshots/gitu__tests__log__limit_set_10.snap b/src/tests/snapshots/gitu__tests__log__limit_set_10.snap index 24a20d9db7..fb0becb9f8 100644 --- a/src/tests/snapshots/gitu__tests__log__limit_set_10.snap +++ b/src/tests/snapshots/gitu__tests__log__limit_set_10.snap @@ -14,12 +14,12 @@ expression: ctx.redact_buffer() | | | - | - | - | ────────────────────────────────────────────────────────────────────────────────| - Log Arguments | - l current -F Search messages (--grep) | - o other -n Limit number of commits (-n=10) | + Log Arguments | + l current -F Search messages (--grep) | + o other -n Limit number of commits (-n=10) | + L local branches | + b all branches | + a all refs | q/esc Quit/Close | -styles_hash: 931dfd744b566591 +styles_hash: aa49bbcd329a780e diff --git a/src/tests/snapshots/gitu__tests__log__log_all_branches.snap b/src/tests/snapshots/gitu__tests__log__log_all_branches.snap new file mode 100644 index 0000000000..bba282eeee --- /dev/null +++ b/src/tests/snapshots/gitu__tests__log__log_all_branches.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/log.rs +expression: ctx.redact_buffer() +--- +▌* 9295151 side third commit Author Name __ | + | * 16c9409 main second commit Author Name __ | + |/ | + * 98de714 first commit Author Name __ | + * b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: 6e850698f252fe96 diff --git a/src/tests/snapshots/gitu__tests__log__log_all_refs.snap b/src/tests/snapshots/gitu__tests__log__log_all_refs.snap new file mode 100644 index 0000000000..3c4ab130ab --- /dev/null +++ b/src/tests/snapshots/gitu__tests__log__log_all_refs.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/log.rs +expression: ctx.redact_buffer() +--- +▌* debe96e stash WIP on side: 9295151 third commit Author Name __ | + |\ | + | * 7f6dd15 index on side: 9295151 third commit Author Name __ | + |/ | + * 9295151 side third commit Author Name __ | + | * 16c9409 main v1 second commit Author Name __ | + |/ | + * 98de714 first commit Author Name __ | + * b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: 3a1b6f35d1a81966 diff --git a/src/tests/snapshots/gitu__tests__log__log_local_branches.snap b/src/tests/snapshots/gitu__tests__log__log_local_branches.snap new file mode 100644 index 0000000000..f30400274a --- /dev/null +++ b/src/tests/snapshots/gitu__tests__log__log_local_branches.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/log.rs +expression: ctx.redact_buffer() +--- +▌* 92d66bb main Merge branch 'sub' Author Name __ | + |\ | + * | a34b764 more main work Author Name __ | + | * 89dba4b sub add to feature Author Name __ | + | * 5b70804 feature start feature Author Name __ | + |/ | + * 98de714 first commit Author Name __ | + * b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: b9392395880a5114 diff --git a/src/tests/snapshots/gitu__tests__log__log_local_branches_single_branch.snap b/src/tests/snapshots/gitu__tests__log__log_local_branches_single_branch.snap new file mode 100644 index 0000000000..6496a82556 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__log__log_local_branches_single_branch.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/log.rs +expression: ctx.redact_buffer() +--- +▌* 378e3cc main Merge branch 'side' Author Name __ | + |\ | + * | 9295151 third commit Author Name __ | + | * 16c9409 second commit Author Name __ | + |/ | + * 98de714 first commit Author Name __ | + * b66a0bf origin/main add initial-file Author Name __ | + | + | + | + | + | + | + | + | + | + | + | + | + | +styles_hash: 571144f480974880 diff --git a/src/tests/snapshots/gitu__tests__log__log_other.snap b/src/tests/snapshots/gitu__tests__log__log_other.snap index 14ca5bf8dd..0b2f0949c8 100644 --- a/src/tests/snapshots/gitu__tests__log__log_other.snap +++ b/src/tests/snapshots/gitu__tests__log__log_other.snap @@ -2,9 +2,9 @@ source: src/tests/log.rs expression: ctx.redact_buffer() --- -▌6c08cf7 add second commit Author Name __ | - 79e63f1 add third commit Author Name __ | - b66a0bf origin/main add initial-file Author Name __ | +▌* 6c08cf7 add second commit Author Name __ | + * 79e63f1 add third commit Author Name __ | + * b66a0bf origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: a3e9d5b6c2c0bec8 +styles_hash: fa4089546f6cd3a2 diff --git a/src/tests/snapshots/gitu__tests__log__log_other_input.snap b/src/tests/snapshots/gitu__tests__log__log_other_input.snap index 14ca5bf8dd..0b2f0949c8 100644 --- a/src/tests/snapshots/gitu__tests__log__log_other_input.snap +++ b/src/tests/snapshots/gitu__tests__log__log_other_input.snap @@ -2,9 +2,9 @@ source: src/tests/log.rs expression: ctx.redact_buffer() --- -▌6c08cf7 add second commit Author Name __ | - 79e63f1 add third commit Author Name __ | - b66a0bf origin/main add initial-file Author Name __ | +▌* 6c08cf7 add second commit Author Name __ | + * 79e63f1 add third commit Author Name __ | + * b66a0bf origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | | | -styles_hash: a3e9d5b6c2c0bec8 +styles_hash: fa4089546f6cd3a2 diff --git a/src/tests/snapshots/gitu__tests__log__log_other_prompt.snap b/src/tests/snapshots/gitu__tests__log__log_other_prompt.snap index 6bb411c8bf..fbc21dd178 100644 --- a/src/tests/snapshots/gitu__tests__log__log_other_prompt.snap +++ b/src/tests/snapshots/gitu__tests__log__log_other_prompt.snap @@ -2,10 +2,10 @@ source: src/tests/log.rs expression: ctx.redact_buffer() --- - 8bb5532 main add first commit Author Name __ | -▌6c08cf7 add second commit Author Name __ | - 79e63f1 add third commit Author Name __ | - b66a0bf origin/main add initial-file Author Name __ | + * 8bb5532 main add first commit Author Name __ | +▌* 6c08cf7 add second commit Author Name __ | + * 79e63f1 add third commit Author Name __ | + * b66a0bf origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | ────────────────────────────────────────────────────────────────────────────────| ? Log rev (default 6c08cf78a4544ae4dda8e6161a61070867c60246): › █ | -styles_hash: 548691784d636073 +styles_hash: 2316bfac6128a37e diff --git a/src/tests/snapshots/gitu__tests__reset__reset_menu.snap b/src/tests/snapshots/gitu__tests__reset__reset_menu.snap index 63212c3d25..5957128c36 100644 --- a/src/tests/snapshots/gitu__tests__reset__reset_menu.snap +++ b/src/tests/snapshots/gitu__tests__reset__reset_menu.snap @@ -2,8 +2,8 @@ source: src/tests/reset.rs expression: ctx.redact_buffer() --- - ba1a85d main add unwanted-file Author Name __ | -▌b66a0bf origin/main add initial-file Author Name __ | + * ba1a85d main add unwanted-file Author Name __ | +▌* b66a0bf origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() m mixed | h hard | q/esc Quit/Close | -styles_hash: 631e5e77751e1cc6 +styles_hash: 8f9266bd8ec2b545 diff --git a/src/tests/snapshots/gitu__tests__reset__reset_soft_prompt.snap b/src/tests/snapshots/gitu__tests__reset__reset_soft_prompt.snap index 4b3b82a82d..a0bb06ab73 100644 --- a/src/tests/snapshots/gitu__tests__reset__reset_soft_prompt.snap +++ b/src/tests/snapshots/gitu__tests__reset__reset_soft_prompt.snap @@ -2,8 +2,8 @@ source: src/tests/reset.rs expression: ctx.redact_buffer() --- - ba1a85d main add unwanted-file Author Name __ | -▌b66a0bf origin/main add initial-file Author Name __ | + * ba1a85d main add unwanted-file Author Name __ | +▌* b66a0bf origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | ────────────────────────────────────────────────────────────────────────────────| ? Soft reset to (default origin/main): › q█ | -styles_hash: 4a9ab12ee0605e66 +styles_hash: e88667048e185e80 diff --git a/src/tests/snapshots/gitu__tests__revert_commit.snap b/src/tests/snapshots/gitu__tests__revert_commit.snap index 537125e943..f8baf40270 100644 --- a/src/tests/snapshots/gitu__tests__revert_commit.snap +++ b/src/tests/snapshots/gitu__tests__revert_commit.snap @@ -2,8 +2,8 @@ source: src/tests/mod.rs expression: ctx.redact_buffer() --- -▌6324471 main Revert "add initial-file" Author Name __ | - b66a0bf origin/main add initial-file Author Name __ | +▌* 6324471 main Revert "add initial-file" Author Name __ | + * b66a0bf origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | ────────────────────────────────────────────────────────────────────────────────| $ git revert --edit --no-edit main | -styles_hash: 5cb875281c9f055e +styles_hash: 8c4eaacf77074880 diff --git a/src/tests/snapshots/gitu__tests__revert_commit_prompt.snap b/src/tests/snapshots/gitu__tests__revert_commit_prompt.snap index afd9861e8c..e73cb8e1e2 100644 --- a/src/tests/snapshots/gitu__tests__revert_commit_prompt.snap +++ b/src/tests/snapshots/gitu__tests__revert_commit_prompt.snap @@ -2,7 +2,7 @@ source: src/tests/mod.rs expression: ctx.redact_buffer() --- -▌b66a0bf main origin/main add initial-file Author Name __ | +▌* b66a0bf main origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() | ────────────────────────────────────────────────────────────────────────────────| ? Revert commit (default main): › █ | -styles_hash: 876a2195717529a1 +styles_hash: ad25621345269c4e diff --git a/src/tests/snapshots/gitu__tests__revert_menu.snap b/src/tests/snapshots/gitu__tests__revert_menu.snap index c5c69c627c..c8b8068e93 100644 --- a/src/tests/snapshots/gitu__tests__revert_menu.snap +++ b/src/tests/snapshots/gitu__tests__revert_menu.snap @@ -2,7 +2,7 @@ source: src/tests/mod.rs expression: ctx.redact_buffer() --- -▌b66a0bf main origin/main add initial-file Author Name __ | +▌* b66a0bf main origin/main add initial-file Author Name __ | | | | @@ -22,4 +22,4 @@ expression: ctx.redact_buffer() c Continue -E Don't edit commit message (--no-edit) | V Revert commit(s) -s Add Signed-off-by lines (--signoff) | q/esc Quit/Close | -styles_hash: 89b4e55187671654 +styles_hash: 1b54d96a7f3536d1 diff --git a/src/tests/tree.rs b/src/tests/tree.rs new file mode 100644 index 0000000000..752f792a35 --- /dev/null +++ b/src/tests/tree.rs @@ -0,0 +1,700 @@ +//! Tests for the commit tree view (`git log --graph` layout). +//! +//! Topologies are described by a spec: `;`-separated lists of the parent +//! indices of each commit, in display order (commit 0 is the newest, e.g. +//! the current HEAD). Parents always have a higher index than their child. +//! e.g. `"1 2;3;4;4;"` is a merge of two diverging lines: +//! +//! ```text +//! 0 +//! / \ +//! 1 2 +//! / \ +//! 3 ----- 4 +//! ``` +//! +//! Verification strategy: +//! - The graph engine is checked against real `git log --graph`: it is fed +//! the exact commit order git displays and must reproduce git's output +//! byte for byte (graph prefix per line, and which line is the commit +//! line). +//! - `tree_roots` is checked separately: it walks in the same order as +//! `git log `, and its rows must carry the right graph prefix and +//! commit data. Note that `git log --graph` itself can +//! display commits in a different order (its graph lookahead shows the +//! second-parent line of a merge before the first-parent line continues), +//! so a byte-for-byte comparison of `tree_roots` against +//! `git log --graph` is only valid for topologies where both orders agree; +//! the fuzz test covers those end to end. + +use std::{ + collections::HashMap, + io::Write, + path::Path, + process::{Command, Stdio}, +}; + +use temp_dir::TempDir; + +use regex::Regex; + +use crate::git::tree; + +/// Commits of the spec, as parent-index lists. +fn spec_parents(spec: &str) -> Vec> { + spec.split(';') + .map(|s| { + s.split_whitespace() + .map(|p| p.parse().unwrap()) + .collect::>() + }) + .collect() +} + +/// Builds a repo from a spec using `git commit-tree` plumbing, so commits can +/// have arbitrary parents (including unrelated histories). Committer dates +/// increase with the spec index (commit 0 is newest), so the plain +/// (date-ordered) revision walk agrees with the spec order. Returns the +/// commits' oids by spec index (commit 0 is newest). +fn build_spec_repo(spec: &str) -> (TempDir, git2::Repository, Vec) { + let parents = spec_parents(spec); + let dir = TempDir::new().unwrap(); + let dir_path = dir.path(); + + git( + dir_path, + None, + &["git", "init", "-q", "--initial-branch=main"], + ); + git(dir_path, None, &["git", "config", "user.name", "CI"]); + git( + dir_path, + None, + &["git", "config", "user.email", "ci@example.com"], + ); + + let tree_oid = git(dir_path, None, &["git", "mktree"]); + let tree_oid = tree_oid.trim().to_string(); + + // Oids by spec index; commits are created bottom-up (parents first). + let mut oids: Vec> = vec![None; parents.len()]; + for i in (0..parents.len()).rev() { + let date = format!("2025-01-01T{:02}:00:00+00:00", 23 - i); + let mut args: Vec<&str> = vec!["git", "commit-tree", &tree_oid]; + for &p in &parents[i] { + args.push("-p"); + args.push(oids[p as usize].as_ref().unwrap()); + } + let oid = git_with_input( + dir_path, + Some(&date), + &args, + format!("commit {i}\n").as_bytes(), + ); + oids[i] = Some(oid.trim().to_string()); + } + git( + dir_path, + None, + &[ + "git", + "update-ref", + "refs/heads/main", + oids[0].as_ref().unwrap(), + ], + ); + + let repo = git2::Repository::open(dir_path).unwrap(); + (dir, repo, oids.into_iter().map(Option::unwrap).collect()) +} + +fn git(dir: &Path, date: Option<&str>, args: &[&str]) -> String { + git_with_input(dir, date, args, b"") +} + +fn git_with_input(dir: &Path, date: Option<&str>, args: &[&str], input: &[u8]) -> String { + let mut cmd = Command::new(args[0]); + cmd.args(&args[1..]).current_dir(dir); + // Isolate from the host git config and any env vars other tests set. + cmd.env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_AUTHOR_NAME", "CI") + .env("GIT_AUTHOR_EMAIL", "ci@example.com") + .env("GIT_COMMITTER_NAME", "CI") + .env("GIT_COMMITTER_EMAIL", "ci@example.com") + .env("LC_ALL", "C") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + if let Some(date) = date { + cmd.env("GIT_AUTHOR_DATE", date) + .env("GIT_COMMITTER_DATE", date); + } + let mut child = cmd + .spawn() + .unwrap_or_else(|e| panic!("failed to run {args:?}: {e}")); + child.stdin.as_mut().unwrap().write_all(input).unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "failed to run {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +/// Position of a full 40-char sha1 in a `--graph` line, if any. +fn find_hash(l: &str) -> Option { + l.find(|c: char| c.is_ascii_hexdigit()).filter(|&p| { + p + 40 <= l.len() + && l[p..p + 40].chars().all(|c| c.is_ascii_hexdigit()) + && (l.as_bytes().get(p + 40).is_none() || l.as_bytes()[p + 40] == b' ') + }) +} + +/// Parses `git log --graph --format=%H` into (prefix, Some(oid)) lines. +fn parse_graph(out: &str) -> Vec<(String, Option)> { + out.lines() + .map(|l| match find_hash(l) { + Some(p) if l[p..].trim_end().len() == 40 => ( + l[..p].trim_end().to_string(), + Some(l[p..p + 40].to_string()), + ), + _ => (l.trim_end().to_string(), None), + }) + .collect() +} + +/// The commit order of a plain (date-ordered) `git log` for the given revs, +/// as oids. Empty `revs` means HEAD. +fn git_log_order(dir: &Path, revs: &[&str]) -> Vec { + let mut args: Vec<&str> = vec!["git", "log", "--format=%H"]; + args.extend(revs); + git(dir, None, &args) + .lines() + .map(|s| s.to_string()) + .collect() +} + +/// The commit order git displays for a `--graph` log of the given revs, as +/// (oid, parents). Empty `revs` means HEAD. +fn git_graph_order(dir: &Path, revs: &[&str]) -> Vec<(String, Vec)> { + let mut args: Vec<&str> = vec!["git", "log", "--graph", "--format=%H %P"]; + args.extend(revs); + git(dir, None, &args) + .lines() + .filter_map(|l| { + let p = find_hash(l)?; + let oid = l[p..p + 40].to_string(); + let parents = l[p + 40..] + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + Some((oid, parents)) + }) + .collect() +} + +/// Runs the graph engine over the given (oid, parents) display order and +/// returns its lines as (prefix, Some(oid)). +fn engine_lines_for(commits: &[(String, Vec)]) -> Vec<(String, Option)> { + let index: HashMap = commits + .iter() + .enumerate() + .map(|(i, (o, _))| (o.clone(), i)) + .collect(); + + let parents: Vec> = commits + .iter() + .map(|(_, parents)| { + parents + .iter() + .filter_map(|p| index.get(p.as_str()).copied()) + .collect() + }) + .collect(); + + tree::Graph::rows(parents) + .into_iter() + .map(|(line, i)| (line.trim_end().to_string(), i.map(|i| commits[i].0.clone()))) + .collect() +} + +/// Check 1: the engine, fed the commit order git displays, reproduces +/// `git log --graph` byte for byte. +fn assert_engine_matches_git(dir: &Path, spec: &str) { + let theirs = parse_graph(&git(dir, None, &["git", "log", "--graph", "--format=%H"])); + let commits = git_graph_order(dir, &[]); + let mine = engine_lines_for(&commits); + assert_eq!( + mine, + theirs, + "engine output differs from `git log --graph` for spec {spec:?}\n--- engine ---\n{}\n--- git ---\n{}", + lines_str(&mine), + lines_str(&theirs), + ); +} + +/// Check 2: `tree_roots` walks in the same order as `git log ` and +/// attaches the right graph prefix and commit data to each row. +fn assert_rows(dir: &Path, revs: &[&str], spec: &str, rows: &[tree::TreeRow]) { + let plain = git_log_order(dir, revs); + let row_oids: Vec = rows + .iter() + .filter_map(|r| r.commit.as_ref().map(|c| c.oid.clone())) + .collect(); + + // The walk order (rows that carry a commit) must match plain `git log` + // in every case, including multiple roots. + assert_eq!(row_oids, plain, "walk order for spec {spec:?}"); + + // Expected rows: `git log --graph` itself when its commit order equals + // the app's walk order, otherwise the engine run over the walk order + // (the layout is covered by check 1; this checks tree_roots' plumbing). + let mut graph_args: Vec<&str> = vec!["git", "log", "--graph", "--format=%H"]; + graph_args.extend(revs); + let graph = parse_graph(&git(dir, None, &graph_args)); + let graph_oids: Vec = graph.iter().filter_map(|(_, o)| o.clone()).collect(); + let expected: Vec<(String, Option)> = if graph_oids == row_oids { + graph + } else { + let mut log_args: Vec<&str> = vec!["git", "log", "--format=%H %P"]; + log_args.extend(revs); + let log_out = git(dir, None, &log_args); + let by_oid: HashMap<&str, Vec> = log_out + .lines() + .map(|l| { + let (o, ps) = l.split_once(' ').unwrap(); + ( + o, + ps.split_whitespace().map(String::from).collect::>(), + ) + }) + .collect(); + // Rebuild the commit list in the app's walk order. + let commits: Vec<(String, Vec)> = row_oids + .iter() + .map(|o| (o.clone(), by_oid[o.as_str()].clone())) + .collect(); + engine_lines_for(&commits) + }; + assert_eq!(rows.len(), expected.len(), "row count for spec {spec:?}"); + for (i, (row, (want_prefix, want_oid))) in rows.iter().zip(expected.iter()).enumerate() { + assert_eq!( + row.graph.trim_end(), + want_prefix, + "row {i} graph for spec {spec:?}" + ); + assert_eq!( + row.commit.as_ref().map(|c| c.oid.clone()), + want_oid.clone(), + "row {i} commit for spec {spec:?}" + ); + } + + // Per-commit data. + let mut data_args: Vec<&str> = vec!["git", "log", "--format=%H%x00%s%x00%an"]; + data_args.extend(revs); + let expected_data: HashMap = git(dir, None, &data_args) + .lines() + .map(|l| { + let (h, rest) = l.split_once('\0').unwrap(); + let (s, a) = rest.split_once('\0').unwrap(); + (h.to_string(), (s.to_string(), a.to_string())) + }) + .collect(); + let refs: HashMap> = { + let out = git( + dir, + None, + &["git", "for-each-ref", "--format=%(objectname) %(refname)"], + ); + let mut m: HashMap> = HashMap::new(); + for l in out.lines() { + let (oid, name) = l.split_once(' ').unwrap(); + m.entry(oid.to_string()).or_default().push(name.to_string()); + } + m + }; + + for (i, row) in rows.iter().enumerate() { + let Some(commit) = row.commit.as_ref() else { + continue; + }; + let (summary, author) = &expected_data[&commit.oid]; + assert_eq!(&commit.summary, summary, "row {i} summary"); + assert_eq!(&commit.author, author, "row {i} author"); + assert!(!commit.age.is_empty(), "row {i} age"); + assert_eq!( + commit.short_id, + git(dir, None, &["git", "rev-parse", "--short", &commit.oid]).trim(), + "row {i} short_id" + ); + let want_refs: Vec = refs + .get(&commit.oid) + .map(|names| { + names + .iter() + .map(|n| { + if let Some(b) = n.strip_prefix("refs/heads/") { + format!("Head({b})") + } else if let Some(b) = n.strip_prefix("refs/tags/") { + format!("Tag({b})") + } else if let Some(b) = n.strip_prefix("refs/remotes/") { + format!("Remote({b})") + } else { + format!("?({n})") + } + }) + .collect() + }) + .unwrap_or_default(); + let got_refs: Vec = commit + .associated_references + .iter() + .map(|r| match r { + crate::item_data::Ref::Head(n) => format!("Head({n})"), + crate::item_data::Ref::Tag(n) => format!("Tag({n})"), + crate::item_data::Ref::Remote(n) => format!("Remote({n})"), + crate::item_data::Ref::Other(n) => format!("Other({n})"), + }) + .collect(); + let mut got_refs = got_refs; + let mut want_refs = want_refs; + got_refs.sort(); + want_refs.sort(); + assert_eq!(got_refs, want_refs, "row {i} refs for spec {spec:?}"); + } +} + +/// `tree_roots` drops commits whose message does not match `msg_regex`, +/// and the surviving graph simply ends where a filtered commit used to +/// continue it. +#[test] +fn tree_roots_message_filter() { + // 0 + // / \ + // 1 2 + // | | + // 3 4 + const SPEC: &str = "1 2;3;4;4;"; + let (_dir, repo, oids) = build_spec_repo(SPEC); + + // Only commits 1 and 4 survive; both used to descend into 3, so the + // tree comes out as two disconnected lines. + let re = Regex::new("commit [14]").unwrap(); + let head = git2::Oid::from_str(&oids[0]).unwrap(); + let rows = tree::tree_roots(&repo, usize::MAX, &[head], Some(re)).unwrap(); + let got: Vec<&str> = rows + .iter() + .filter_map(|row| row.commit.as_ref().map(|commit| commit.oid.as_str())) + .collect(); + assert_eq!(got, vec![oids[1].as_str(), oids[4].as_str()]); +} + +/// `limit` counts displayed commits, so it is applied after `msg_regex` +/// filters, like `git log -n N --grep`. +#[test] +fn tree_roots_limit_after_filter() { + const SPEC: &str = "1;2;3;4;"; + let (_dir, repo, oids) = build_spec_repo(SPEC); + let head = git2::Oid::from_str(&oids[0]).unwrap(); + + // Messages are "commit 0" through "commit 4"; keep only the odd ones + // and limit to two: both matching commits survive the limit. + let re = Regex::new("commit [13]").unwrap(); + let rows = tree::tree_roots(&repo, 2, &[head], Some(re)).unwrap(); + let got: Vec<&str> = rows + .iter() + .filter_map(|row| row.commit.as_ref().map(|commit| commit.oid.as_str())) + .collect(); + assert_eq!(got, vec![oids[1].as_str(), oids[3].as_str()]); +} + +/// The rows of `tree_roots` rooted at HEAD (the single-rev case the log +/// screen starts from). +fn assert_head_rows(dir: &Path, repo: &git2::Repository, spec: &str) { + let head = repo.head().unwrap().peel_to_commit().unwrap().id(); + let rows = tree::tree_roots(repo, usize::MAX, &[head], None).unwrap(); + assert_rows(dir, &["HEAD"], spec, &rows); +} + +fn lines_str(lines: &[(String, Option)]) -> String { + lines + .iter() + .map(|(p, o)| format!("{p}{o:?}")) + .collect::>() + .join("\n") +} + +/// Runs both checks against a spec-built repo. +fn assert_tree_matches_git(spec: &str) { + let (dir, repo, _oids) = build_spec_repo(spec); + assert_engine_matches_git(dir.path(), spec); + assert_head_rows(dir.path(), &repo, spec); +} + +/// A tree of all local branch tips (`local_branch_roots` + `tree_roots`), +/// the path behind the log screen's "local branches" op. +#[test] +fn tree_of_local_branches() { + // 0 (main) + // / \ + // 1 2 (right) + // / \ + // 3 (left) 4 + const SPEC: &str = "1 2;3;4;4;"; + let (dir, repo, _oids) = build_spec_repo(SPEC); + let dir = dir.path(); + let plain = git_log_order(dir, &[]); + git(dir, None, &["git", "branch", "left", &plain[3]]); + git(dir, None, &["git", "branch", "right", &plain[2]]); + + // Every local branch tip is a root, no more and no less. + let roots = tree::local_branch_roots(&repo); + let mut root_oids: Vec = roots.iter().map(|r| r.to_string()).collect(); + root_oids.sort(); + let mut want_roots = vec![plain[0].clone(), plain[2].clone(), plain[3].clone()]; + want_roots.sort(); + assert_eq!(root_oids, want_roots, "local branch roots"); + + // The tree of those roots matches `git log` over the same refs, in the + // same root order. + let name_by_oid: HashMap<&str, &str> = [ + (plain[0].as_str(), "main"), + (plain[2].as_str(), "right"), + (plain[3].as_str(), "left"), + ] + .into_iter() + .collect(); + let revs: Vec<&str> = roots + .iter() + .map(|r| name_by_oid[r.to_string().as_str()]) + .collect(); + let rows = tree::tree_roots(&repo, usize::MAX, &roots, None).unwrap(); + assert_rows(dir, &revs, SPEC, &rows); +} + +/// Fuzz: trees of up to 3 local branch tips over random topologies, checked +/// against `git log` over the same refs (and the graph layout against +/// `git log --graph`, as in check 1). +#[test] +fn random_branch_trees() { + let mut rng: u64 = 0x9e3779b97f4a7c15; + let mut next = move || { + rng = rng + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (rng >> 33) as usize + }; + + for _ in 0..20 { + let n = 4 + next() % 12; + // Linear chain (commit i has parent i+1), then random extra + // parents, which creates merges/octopuses. + let mut parents: Vec> = (0..n).map(|i| vec![(i + 1) as u32]).collect(); + parents[n - 1] = vec![]; + for _ in 0..(3 * n / 2) { + let i = next() % (n - 1); + let j = i + 1 + next() % (n - i - 1); + if !parents[i].contains(&(j as u32)) && parents[i].len() < 5 { + parents[i].push(j as u32); + parents[i].sort_unstable(); + } + } + let spec = parents + .iter() + .map(|ps| { + ps.iter() + .map(|p| p.to_string()) + .collect::>() + .join(" ") + }) + .collect::>() + .join(";"); + + // Pick 1-2 extra branch tips (main always points at commit 0). + let k = 1 + next() % 2; + let mut candidates: Vec = (1..n as u32).collect(); + for i in (1..candidates.len()).rev() { + let j = next() % (i + 1); + candidates.swap(i, j); + } + let tips: Vec = candidates.into_iter().take(k).collect(); + + let (dir, repo, _oids) = build_spec_repo(&spec); + let dir = dir.path(); + let plain = git_log_order(dir, &[]); + let mut name_by_oid: HashMap = + [(plain[0].clone(), "main".to_string())].into(); + for (j, &tip) in tips.iter().enumerate() { + let name = format!("t{j}"); + git(dir, None, &["git", "branch", &name, &plain[tip as usize]]); + name_by_oid.insert(plain[tip as usize].clone(), name); + } + + assert_engine_matches_git(dir, &spec); + + let roots = tree::local_branch_roots(&repo); + let revs: Vec = roots + .iter() + .map(|r| name_by_oid.get(&r.to_string()).unwrap().clone()) + .collect(); + let rev_refs: Vec<&str> = revs.iter().map(String::as_str).collect(); + let rows = tree::tree_roots(&repo, usize::MAX, &roots, None).unwrap(); + assert_rows(dir, &rev_refs, &spec, &rows); + } +} + +/// Byte-for-byte check against git for a collapse row that has nothing to +/// its left: that row is ` /`, not `|/` (in `|/` the `|` is a different, +/// surviving line). The shape needs a root commit shown mid-walk, its line +/// dying, while another line is still live to its right: +/// +/// ```text +/// D (main) +/// / \ +/// X Y - E (tip) +/// ``` +/// +/// Walk order D, X, E, Y: X (a root) is shown at the leftmost slot and its +/// line dies, and Y's line collapses left onto the now-empty slot. +#[test] +fn collapse_row_with_nothing_to_the_left_is_a_space() { + const SPEC: &str = "1 3;;3;"; + let (dir, repo, oids) = build_spec_repo(SPEC); + let dir = dir.path(); + // E (spec index 2) is a child of Y and unreachable from main, so it is + // not in `git log`; branch from the spec oid directly. + git(dir, None, &["git", "branch", "tip", &oids[2]]); + + let roots = tree::local_branch_roots(&repo); + let rows = tree::tree_roots(&repo, usize::MAX, &roots, None).unwrap(); + assert_rows(dir, &["main", "tip"], SPEC, &rows); + + assert!( + rows.iter() + .any(|r| r.commit.is_none() && r.graph.trim_end() == " /"), + "expected a ` /` collapse row (no line to its left), got: {}", + rows.iter() + .map(|r| format!("{:?}", r.graph.trim_end())) + .collect::>() + .join("\n"), + ); +} + +#[test] +fn linear_history() { + assert_tree_matches_git("1;2;"); +} + +#[test] +fn merge_of_diverging_lines() { + // 0 + // / \ + // 1 2 + // / \ + // 3 ----- 4 + assert_tree_matches_git("1 2;3;4;4;"); +} + +#[test] +fn merge_of_unrelated_roots() { + // 0 merges two commits with no common history. + assert_tree_matches_git("1 2;;;"); +} + +#[test] +fn octopus_merge_of_unrelated_roots() { + assert_tree_matches_git("1 2 3;;;;"); +} + +#[test] +fn nested_octopus() { + // 0 is a 4-way merge; its second parent is itself a 4-way merge. + assert_tree_matches_git("1 2 3 4;5;5;5;5;6 7 8 9;10;10;10;10;;"); +} + +#[test] +fn stacked_merges() { + // 0 merges 1 and 2, and 2 is itself a merge. + assert_tree_matches_git("1 2;3;3 4;5;5;"); +} + +#[test] +fn complex_history() { + // Long line with a side branch, a deep second side branch, and two + // merges at different depths (crossing `|` lines and a `_|_` collapse). + assert_tree_matches_git("1 5;3;3;4;5;6;7;9;9;10;11;12;13;"); +} + +/// Check 3: end-to-end byte-for-byte match of the HEAD-rooted tree against +/// `git log --graph`, over random topologies where the plain and graph +/// orders agree (a chain plus extra parent edges: the second parent of a +/// merge is always an ancestor of the first, so git's lookahead does not +/// reorder anything). +#[test] +fn random_topologies_match_git_graph_exactly() { + let mut rng: u64 = 0x9e3779b97f4a7c15; + let mut next = move || { + rng = rng + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (rng >> 33) as usize + }; + + for _ in 0..40 { + let n = 2 + next() % 14; + // Linear chain (commit i has parent i+1), then random extra + // parents, which creates merges/octopuses. Every extra parent is + // an ancestor of the first parent, so the plain and --graph commit + // orders agree and the comparison below is end to end. + let mut parents: Vec> = (0..n).map(|i| vec![(i + 1) as u32]).collect(); + parents[n - 1] = vec![]; + for _ in 0..(3 * n / 2) { + let i = next() % (n - 1); + let j = i + 1 + next() % (n - i - 1); + if !parents[i].contains(&(j as u32)) && parents[i].len() < 5 { + parents[i].push(j as u32); + parents[i].sort_unstable(); + } + } + let spec = parents + .iter() + .map(|ps| { + ps.iter() + .map(|p| p.to_string()) + .collect::>() + .join(" ") + }) + .collect::>() + .join(";"); + + let (dir, repo, _oids) = build_spec_repo(&spec); + + // For these topologies the plain and --graph orders agree, so the + // tree must match `git log --graph` byte for byte. + let theirs = parse_graph(&git( + dir.path(), + None, + &["git", "log", "--graph", "--format=%H"], + )); + let head = repo.head().unwrap().peel_to_commit().unwrap().id(); + let rows = tree::tree_roots(&repo, usize::MAX, &[head], None).unwrap(); + let mine: Vec<(String, Option)> = rows + .iter() + .map(|r| { + ( + r.graph.trim_end().to_string(), + r.commit.as_ref().map(|c| c.oid.clone()), + ) + }) + .collect(); + assert_eq!( + mine, + theirs, + "tree differs from `git log --graph` for spec {spec:?}\n--- tree ---\n{}\n--- git ---\n{}", + lines_str(&mine), + lines_str(&theirs), + ); + } +} diff --git a/src/ui/item.rs b/src/ui/item.rs index b97de88680..8857a6051c 100644 --- a/src/ui/item.rs +++ b/src/ui/item.rs @@ -63,6 +63,7 @@ pub(crate) fn layout_item<'a>( layout_reference(layout, kind, config, base); } ItemData::Commit { + graph, short_id, associated_references, summary, @@ -70,6 +71,10 @@ pub(crate) fn layout_item<'a>( age, .. } => { + if !graph.is_empty() { + layout_span(layout, (graph.as_str().into(), base)); + } + layout_span( layout, ( @@ -304,13 +309,16 @@ fn truncate(text: &str, width: usize) -> Cow<'_, str> { fn layout_reference<'a>(layout: &mut UiTree<'a>, reference: &'a Ref, config: &Config, base: Style) { let (name, style) = match reference { - Ref::Tag(tag) => (tag, &config.style.tag), - Ref::Head(branch) => (branch, &config.style.branch), - Ref::Remote(remote) => (remote, &config.style.remote), + Ref::Tag(tag) => (tag.as_str(), Some(&config.style.tag)), + Ref::Head(branch) => (branch.as_str(), Some(&config.style.branch)), + Ref::Remote(remote) => (remote.as_str(), Some(&config.style.remote)), + // Other refs (e.g. stashes) have no dedicated style. + Ref::Other(other) => (other.as_str(), None), }; - layout_span( - layout, - (name.as_str().into(), base.patch(Style::from(style))), - ); + if let Some(style) = style { + layout_span(layout, (name.into(), base.patch(Style::from(style)))); + } else { + layout_span(layout, (name.into(), base)); + } }