Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions src/core/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Section> {
/// 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<git2::Oid, String> {
// Build a set of branch tip OIDs for quick lookup.
let branch_tip_set: HashSet<git2::Oid> = 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<git2::Oid, Vec<(String, Option<RemoteStatus>)>> = HashMap::new();
let mut tip_to_names: HashMap<git2::Oid, Vec<String>> = 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.
Expand All @@ -188,7 +189,7 @@ fn build_sections(info: RepoInfo) -> Vec<Section> {
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 {
Expand All @@ -205,6 +206,36 @@ fn build_sections(info: RepoInfo) -> Vec<Section> {
}
}

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<git2::Oid> {
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<Section> {
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<git2::Oid, Vec<(String, Option<RemoteStatus>)>> = 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<String, Vec<(String, Option<RemoteStatus>)>> =
Expand Down
11 changes: 9 additions & 2 deletions src/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> 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,
Expand Down
15 changes: 15 additions & 0 deletions src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<git2::Oid>> {
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.
Expand Down
41 changes: 41 additions & 0 deletions src/status_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading