diff --git a/src/core/graph.rs b/src/core/graph.rs index 09cd7e3..00635e7 100644 --- a/src/core/graph.rs +++ b/src/core/graph.rs @@ -156,21 +156,22 @@ fn display_path(repo_path: &str, cwd_prefix: &str) -> String { // ── Section building ──────────────────────────────────────────────────── -/// Group commits into sections: working changes, feature branches, loose -/// commits, and the upstream marker. Commits are assigned to a branch when -/// they follow a branch tip in topological order. -fn build_sections(info: RepoInfo) -> Vec
{ +/// Assign each in-range commit to the feature branch that owns it by walking +/// parent links from every branch tip. Commits absent from the returned map +/// are "loose": they sit on the integration line and belong to no feature +/// branch. +fn assign_commits_to_branches(info: &RepoInfo) -> HashMap { // Build a set of branch tip OIDs for quick lookup. let branch_tip_set: HashSet = info.branches.iter().map(|b| b.tip_oid).collect(); // Group branches by tip OID to handle co-located branches (multiple // branch names pointing to the same commit). - let mut tip_to_names: HashMap)>> = HashMap::new(); + let mut tip_to_names: HashMap> = HashMap::new(); for b in &info.branches { tip_to_names .entry(b.tip_oid) .or_default() - .push((b.name.clone(), b.remote.clone())); + .push(b.name.clone()); } // Build a parent lookup from the commit list so we can walk ancestry chains. @@ -188,7 +189,7 @@ fn build_sections(info: RepoInfo) -> Vec
{ if !seen_tips.insert(b.tip_oid) { continue; // Already processed commits for this tip } - let canonical_name = tip_to_names[&b.tip_oid][0].0.clone(); + let canonical_name = tip_to_names[&b.tip_oid][0].clone(); let mut current = Some(b.tip_oid); let mut is_tip = true; while let Some(oid) = current { @@ -205,6 +206,36 @@ fn build_sections(info: RepoInfo) -> Vec
{ } } + commit_to_branch +} + +/// OID of the commit shown at the top of the status graph: the first loose +/// commit (on the integration line, belonging to no feature branch) in +/// topological order. Returns None when every in-range commit belongs to a +/// feature branch, or there are no commits. +pub fn top_loose_commit(info: &RepoInfo) -> Option { + let commit_to_branch = assign_commits_to_branches(info); + info.commits + .iter() + .find(|c| !commit_to_branch.contains_key(&c.oid)) + .map(|c| c.oid) +} + +/// Group commits into sections: working changes, feature branches, loose +/// commits, and the upstream marker. +fn build_sections(info: RepoInfo) -> Vec
{ + let commit_to_branch = assign_commits_to_branches(&info); + + // Group branches by tip OID to handle co-located branches (multiple + // branch names pointing to the same commit). + let mut tip_to_names: HashMap)>> = HashMap::new(); + for b in &info.branches { + tip_to_names + .entry(b.tip_oid) + .or_default() + .push((b.name.clone(), b.remote.clone())); + } + // Map canonical name → all names for that branch group. // Reverse so the newest (alphabetically last) branch appears on top. let mut canonical_to_names: HashMap)>> = diff --git a/src/show.rs b/src/show.rs index 1345f81..7a117a2 100644 --- a/src/show.rs +++ b/src/show.rs @@ -2,15 +2,22 @@ use anyhow::Result; use crate::core::repo::{self, Target}; use crate::git; +use crate::status; /// Show the diff and metadata for a commit (like `git show`), using short IDs. /// -/// With no target, shows the last commit on the current branch (like `git show`). +/// With no target, shows the commit at the top of `loom status` — the tip of +/// the integration line, skipping merge commits and hidden branches. pub fn run(target: Option) -> Result<()> { let repo = repo::open_repo()?; let git_ref = match target { - None => "HEAD".to_string(), + // Fall back to HEAD outside an integration branch (e.g. plain repo) + // or when the integration line has no commits of its own. + None => match status::top_commit(&repo) { + Ok(Some(oid)) => oid.to_string(), + _ => "HEAD".to_string(), + }, Some(target) => { let resolved = repo::resolve_arg( &repo, diff --git a/src/status.rs b/src/status.rs index 46ef43e..59db714 100644 --- a/src/status.rs +++ b/src/status.rs @@ -47,6 +47,21 @@ pub fn run( Ok(()) } +/// OID of the commit shown at the top of `loom status`: the tip of the +/// integration line, skipping merge commits and hidden branches. Returns None +/// when the integration branch has no commits of its own above the merge-base. +pub fn top_commit(repo: &git2::Repository) -> Result> { + let mut info = repo::gather_repo_info(repo, false, 0)?; + + let pattern = + repo::hide_branch_pattern(repo).unwrap_or_else(|| repo::DEFAULT_HIDE_PATTERN.to_string()); + if !pattern.is_empty() { + hide_branches(&mut info, &pattern); + } + + Ok(graph::top_loose_commit(&info)) +} + /// Resolve a list of user-supplied IDs to a set of commit OIDs whose files /// should be shown. Supports git hashes and loom commit short IDs. /// Unknown IDs are silently skipped. diff --git a/src/status_test.rs b/src/status_test.rs index b5c4008..3d274c3 100644 --- a/src/status_test.rs +++ b/src/status_test.rs @@ -126,6 +126,47 @@ fn multiple_hidden_branches() { assert!(info.commits.is_empty()); } +// ── top_commit tests ──────────────────────────────────────────────────────── + +#[test] +fn top_commit_returns_tip_loose_commit() { + let test_repo = TestRepo::new_with_remote(); + + test_repo.commit_empty("C1"); + test_repo.commit_empty("C2"); + let c2 = test_repo.head_oid(); + + let top = super::top_commit(&test_repo.repo).unwrap(); + assert_eq!(top, Some(c2)); +} + +#[test] +fn top_commit_skips_merge_of_hidden_branch() { + // Reproduces the bug: HEAD is a merge of a hidden `local-` branch. The top + // of status is the integration commit below the merge, not the merge itself. + let test_repo = TestRepo::new_with_remote(); + + test_repo.commit_empty("Base"); + let base = test_repo.head_oid(); + + // A local-only branch that diverges from the base with its own commit. + test_repo.create_branch_at_commit("local-only", base); + test_repo.switch_branch("local-only"); + test_repo.commit_empty("L1"); + + // Meanwhile the integration line advances with a real commit. + test_repo.switch_branch("integration"); + test_repo.commit_empty("C1"); + let c1 = test_repo.head_oid(); + + // Merge local-only back into integration, leaving a merge commit at the tip. + test_repo.merge_no_ff("local-only"); + assert!(test_repo.head_commit().parent_count() > 1, "tip is a merge"); + + let top = super::top_commit(&test_repo.repo).unwrap(); + assert_eq!(top, Some(c1), "should skip the merge and pick C1"); +} + // ── resolve_commit_filter tests ───────────────────────────────────────────── #[test]