From 4217fdaa9db92c5f2a3485b8e3d324f30d7309b7 Mon Sep 17 00:00:00 2001 From: Aleksey Imuzov Date: Wed, 8 Apr 2026 16:32:13 +0400 Subject: [PATCH 1/2] feat: draw a subset of the full graph by specifying refspecs Allow passing branch names or refspecs as positional arguments to scope the graph visualization to only the relevant subgraph. When multiple refspecs are given, the graph is bounded by their merge-base at the bottom. A single refspec with an upstream tracking branch auto-detects the fork point. Closes #55 --- src/graph.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++------ src/main.rs | 19 ++++++++++- 2 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/graph.rs b/src/graph.rs index 42bf0a7..7662964 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -45,6 +45,7 @@ impl GitGraph { settings: &Settings, start_point: Option, max_count: Option, + refspecs: Vec, ) -> Result { #![doc = include_str!("../docs/branch_assignment.md")] let mut stashes = HashSet::new(); @@ -62,17 +63,7 @@ impl GitGraph { walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) .map_err(|err| err.message().to_string())?; - // Use starting point if specified - if let Some(start) = start_point { - let object = repository - .revparse_single(&start) - .map_err(|err| format!("Failed to resolve start point '{}': {}", start, err))?; - walk.push(object.id()) - .map_err(|err| err.message().to_string())?; - } else { - walk.push_glob("*") - .map_err(|err| err.message().to_string())?; - } + configure_revwalk(&repository, &mut walk, start_point, &refspecs)?; if repository.is_shallow() { return Err("ERROR: git-graph does not support shallow clones due to a missing feature in the underlying libgit2 library.".to_string()); @@ -300,6 +291,87 @@ impl BranchVis { } } +/// For a single refspec, find a base branch to compare against +/// using the branch's upstream tracking ref. +fn find_base_oid(repository: &Repository, refspec: &str, tip_oid: Oid) -> Option { + if let Ok(branch) = repository.find_branch(refspec, BranchType::Local) { + if let Ok(upstream) = branch.upstream() { + if let Some(oid) = upstream.get().target() { + if oid != tip_oid { + return Some(oid); + } + } + } + } + + None +} + +fn hide_ancestors_of(repository: &Repository, walk: &mut git2::Revwalk, merge_base: Oid) { + if let Ok(commit) = repository.find_commit(merge_base) { + for parent in commit.parents() { + let _ = walk.hide(parent.id()); + } + } +} + +fn configure_revwalk( + repository: &Repository, + walk: &mut git2::Revwalk, + start_point: Option, + refspecs: &[String], +) -> Result<(), String> { + if !refspecs.is_empty() { + let mut resolved_oids = Vec::with_capacity(refspecs.len()); + for refspec in refspecs { + let object = repository + .revparse_single(refspec) + .map_err(|err| format!("Failed to resolve refspec '{}': {}", refspec, err))?; + let oid = object.id(); + walk.push(oid) + .map_err(|err| err.message().to_string())?; + resolved_oids.push(oid); + } + + if resolved_oids.len() == 1 { + // Single refspec: auto-detect base branch + if let Some(base_oid) = find_base_oid(repository, &refspecs[0], resolved_oids[0]) { + walk.push(base_oid) + .map_err(|err| err.message().to_string())?; + if let Ok(mb) = repository.merge_base(resolved_oids[0], base_oid) { + hide_ancestors_of(repository, walk, mb); + } + } + } else { + // Multiple refspecs: compute merge-base of all + let mut base = resolved_oids[0]; + let mut base_found = true; + for oid in &resolved_oids[1..] { + match repository.merge_base(base, *oid) { + Ok(mb) => base = mb, + Err(_) => { + base_found = false; + break; + } + } + } + if base_found { + hide_ancestors_of(repository, walk, base); + } + } + } else if let Some(start) = start_point { + let object = repository + .revparse_single(&start) + .map_err(|err| format!("Failed to resolve start point '{}': {}", start, err))?; + walk.push(object.id()) + .map_err(|err| err.message().to_string())?; + } else { + walk.push_glob("*") + .map_err(|err| err.message().to_string())?; + } + Ok(()) +} + /// Walks through the commits and adds each commit's Oid to the children of its parents. fn assign_children(commits: &mut [CommitInfo], indices: &HashMap) { for idx in 0..commits.len() { diff --git a/src/main.rs b/src/main.rs index d50fe11..6c0c498 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,6 +49,7 @@ fn from_args() -> Result<(), String> { ses.settings.as_ref().unwrap(), ses.svg, ses.commit_limit, + ses.refspecs, ) } @@ -126,6 +127,14 @@ fn match_args() -> ArgMatches { ) .required(false) .num_args(1), + ) + .arg( + Arg::new("refspecs") + .help( + "Branch names or refspecs to show.\n \ + Only the subgraph between the merge-base and the tips is displayed.", + ) + .num_args(1..), ); // Return match of declared arguments with what is present on command line @@ -190,6 +199,11 @@ fn configure_session(ses: &mut Session, matches: &ArgMatches) -> Result("refspecs") + .map(|vals| vals.cloned().collect()) + .unwrap_or_default(); + Ok(run_application) } @@ -204,6 +218,7 @@ struct Session { pub repository: Option, pub svg: bool, pub commit_limit: Option, + pub refspecs: Vec, } impl Session { @@ -219,6 +234,7 @@ impl Session { repository: None, svg: false, commit_limit: None, + refspecs: Vec::new(), } } } @@ -597,9 +613,10 @@ fn run( settings: &Settings, svg: bool, max_commits: Option, + refspecs: Vec, ) -> Result<(), String> { let now = Instant::now(); - let graph = GitGraph::new(repository, settings, None, max_commits)?; + let graph = GitGraph::new(repository, settings, None, max_commits, refspecs)?; let duration_graph = now.elapsed().as_micros(); From e98ca69aaaf0c3faef5371eb9d5ae32902dad485 Mon Sep 17 00:00:00 2001 From: Aleksey Imuzov Date: Wed, 8 Apr 2026 21:50:16 +0400 Subject: [PATCH 2/2] style: fix formatting --- src/graph.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/graph.rs b/src/graph.rs index 7662964..8e6ad82 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -328,8 +328,7 @@ fn configure_revwalk( .revparse_single(refspec) .map_err(|err| format!("Failed to resolve refspec '{}': {}", refspec, err))?; let oid = object.id(); - walk.push(oid) - .map_err(|err| err.message().to_string())?; + walk.push(oid).map_err(|err| err.message().to_string())?; resolved_oids.push(oid); }