Skip to content

Commit ce6b5a1

Browse files
Fix: Improve merge commit information retrieval
The `get_merge_commit_info` function was previously only checking the latest commit on a branch for merge information. This could lead to incomplete or misleading reports if earlier merge commits existed on the branch. This commit modifies `get_merge_commit_info` to iterate through all commits on the specified branch. It now correctly identifies all merge commits that merged from the given parent branch and extracts their respective commit messages and statistics. Additionally, a new test case (`test_get_merge_commit_info_multiple_merges`) has been added to `tests/merge.rs`. This test verifies that the updated function correctly retrieves information for multiple merge commits on a single branch, ensuring more robust and accurate reporting of merge activities.
1 parent f6a6d36 commit ce6b5a1

File tree

2 files changed

+178
-69
lines changed

2 files changed

+178
-69
lines changed

src/main.rs

Lines changed: 78 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1725,9 +1725,9 @@ impl GitChain {
17251725
parent_branch: &str,
17261726
branch_name: &str,
17271727
) -> Result<Vec<MergeCommitInfo>, Error> {
1728-
// Get the latest commit on the branch
1728+
// 1. Fetch all commit hashes for the given `branch_name`.
17291729
let mut command = Command::new("git");
1730-
command.args(["log", "--oneline", "-1", branch_name]);
1730+
command.args(["log", "--format=%H", branch_name]); // Get only commit hashes
17311731
let output = match command.output() {
17321732
Ok(output) => output,
17331733
Err(_) => return Ok(vec![]), // Return empty vec on error
@@ -1737,92 +1737,101 @@ impl GitChain {
17371737
return Ok(vec![]);
17381738
}
17391739

1740-
let latest_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
1741-
if latest_commit.is_empty() {
1742-
return Ok(vec![]);
1743-
}
1740+
let commit_hashes_str = String::from_utf8_lossy(&output.stdout);
1741+
let commit_hashes: Vec<&str> = commit_hashes_str.trim().lines().collect();
17441742

1745-
// Check if it's a merge commit by looking for parent commits
1746-
let commit_hash = latest_commit.split_whitespace().next().unwrap_or("");
1747-
if commit_hash.is_empty() {
1743+
if commit_hashes.is_empty() {
17481744
return Ok(vec![]);
17491745
}
17501746

1751-
// Get commit information
1752-
let mut command = Command::new("git");
1753-
command.args(["show", "--stat", commit_hash]);
1754-
let output = match command.output() {
1755-
Ok(output) => output,
1756-
Err(_) => return Ok(vec![]),
1757-
};
1747+
let mut merge_commits_info = Vec::new();
17581748

1759-
if !output.status.success() {
1760-
return Ok(vec![]);
1761-
}
1749+
// 2. Iterate through each commit hash.
1750+
for commit_hash in commit_hashes {
1751+
if commit_hash.is_empty() {
1752+
continue;
1753+
}
1754+
1755+
// 3. For each commit, use `git show --stat <commit_hash>` to get its information.
1756+
let mut show_command = Command::new("git");
1757+
show_command.args(["show", "--stat", commit_hash]);
1758+
let show_output = match show_command.output() {
1759+
Ok(output) => output,
1760+
Err(_) => continue, // Skip this commit on error
1761+
};
1762+
1763+
if !show_output.status.success() {
1764+
continue; // Skip this commit on error
1765+
}
17621766

1763-
let commit_info = String::from_utf8_lossy(&output.stdout).to_string();
1767+
let commit_info_str = String::from_utf8_lossy(&show_output.stdout).to_string();
1768+
let commit_lines: Vec<&str> = commit_info_str.lines().collect();
17641769

1765-
// Check if it's a merge commit, which typically contains "Merge" in the commit message
1766-
if commit_info.contains(&format!("Merge branch '{}'", parent_branch))
1767-
|| commit_info.contains("Merge branch")
1768-
{
1769-
// Extract commit message (first line after commit hash)
1770-
let commit_lines: Vec<&str> = commit_info.lines().collect();
1771-
let message = commit_lines
1770+
// 4. If the commit is a merge commit (contains "Merge branch" in its message AND refers to `parent_branch`),
1771+
// extract its message and stats.
1772+
let merge_line_prefix = format!("Merge branch '{}'", parent_branch);
1773+
let is_merge_from_parent = commit_lines
17721774
.iter()
1773-
.position(|line| line.trim().starts_with("Merge branch"))
1774-
.map(|idx| commit_lines[idx].trim().to_string());
1775+
.any(|line| line.trim().starts_with(&merge_line_prefix));
17751776

1776-
// Extract stats
1777-
let stats_line = commit_lines
1777+
// Also check for generic "Merge branch" if parent_branch is empty (e.g. for initial merge to main)
1778+
// or if the specific format isn't exact.
1779+
let is_generic_merge = commit_lines
17781780
.iter()
1779-
.find(|line| line.contains("files changed") || line.contains("file changed"));
1781+
.any(|line| line.trim().starts_with("Merge branch"));
17801782

1781-
let stats = stats_line.map(|line| {
1782-
let mut files_changed = 0;
1783-
let mut insertions = 0;
1784-
let mut deletions = 0;
1783+
// A merge commit must have a line starting with "Merge: "
1784+
let is_actual_merge_commit = commit_lines.iter().any(|line| line.starts_with("Merge: "));
17851785

1786-
if let Some(files_idx) = line.find("file changed") {
1787-
if let Some(files_num) = line[..files_idx].split_whitespace().last() {
1788-
files_changed = files_num.parse().unwrap_or(0);
1789-
}
1790-
} else if let Some(files_idx) = line.find("files changed") {
1791-
if let Some(files_num) = line[..files_idx].split_whitespace().last() {
1792-
files_changed = files_num.parse().unwrap_or(0);
1793-
}
1794-
}
1786+
if is_actual_merge_commit && (is_merge_from_parent || (parent_branch.is_empty() && is_generic_merge)) {
1787+
// Extract commit message (the line starting with "Merge branch ...")
1788+
let message = commit_lines
1789+
.iter()
1790+
.find(|line| line.trim().starts_with("Merge branch"))
1791+
.map(|line| line.trim().to_string());
17951792

1796-
if let Some(ins_idx) = line.find("insertion") {
1797-
if let Some(ins_end) = line[..ins_idx].rfind(' ') {
1798-
if let Some(ins_start) = line[..ins_end].rfind(' ') {
1799-
let ins_str = &line[ins_start + 1..ins_end];
1800-
insertions = ins_str.parse().unwrap_or(0);
1793+
// Extract stats (preserve existing logic, with slight refinement for parsing)
1794+
let stats_line = commit_lines
1795+
.iter()
1796+
.find(|line| line.contains("files changed") || line.contains("file changed"));
1797+
1798+
let stats = stats_line.map(|line| {
1799+
let mut files_changed = 0;
1800+
let mut insertions = 0;
1801+
let mut deletions = 0;
1802+
1803+
// Example line: "1 file changed, 5 insertions(+), 2 deletions(-)"
1804+
// More robust parsing:
1805+
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
1806+
for part in parts {
1807+
if part.contains("file changed") || part.contains("files changed") {
1808+
if let Some(num_str) = part.split_whitespace().next() {
1809+
files_changed = num_str.parse().unwrap_or(0);
1810+
}
1811+
} else if part.contains("insertion") {
1812+
if let Some(num_str) = part.split_whitespace().next() {
1813+
insertions = num_str.parse().unwrap_or(0);
1814+
}
1815+
} else if part.contains("deletion") {
1816+
if let Some(num_str) = part.split_whitespace().next() {
1817+
deletions = num_str.parse().unwrap_or(0);
1818+
}
18011819
}
18021820
}
1803-
}
1804-
1805-
if let Some(del_idx) = line.find("deletion") {
1806-
if let Some(del_end) = line[..del_idx].rfind(' ') {
1807-
if let Some(del_start) = line[..del_end].rfind(' ') {
1808-
let del_str = &line[del_start + 1..del_end];
1809-
deletions = del_str.parse().unwrap_or(0);
1810-
}
1821+
MergeStats {
1822+
files_changed,
1823+
insertions,
1824+
deletions,
18111825
}
1812-
}
1826+
});
18131827

1814-
MergeStats {
1815-
files_changed,
1816-
insertions,
1817-
deletions,
1818-
}
1819-
});
1820-
1821-
return Ok(vec![MergeCommitInfo { message, stats }]);
1828+
merge_commits_info.push(MergeCommitInfo { message, stats });
1829+
}
18221830
}
18231831

1824-
// It's not a merge commit
1825-
Ok(vec![])
1832+
// 5. Collect all `MergeCommitInfo` structs for the identified merge commits.
1833+
// 6. Return `Ok(Vec<MergeCommitInfo>)`
1834+
Ok(merge_commits_info)
18261835
}
18271836

18281837
fn report_merge_results(

tests/merge.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,106 @@ fn merge_subcommand_simple() {
296296
teardown_git_repo(repo_name);
297297
}
298298

299+
#[test]
300+
fn test_get_merge_commit_info_multiple_merges() {
301+
let repo_name = "test_get_merge_commit_info_multiple_merges";
302+
let repo = setup_git_repo(repo_name);
303+
let path_to_repo = generate_path_to_repo(repo_name);
304+
let original_cwd = std::env::current_dir().unwrap(); // Save CWD
305+
std::env::set_current_dir(&path_to_repo).unwrap(); // Change CWD to repo path
306+
307+
// 1. Set up a Git repository with a `main` branch and a feature branch (e.g., `feat/test-merge-info`).
308+
create_new_file(&path_to_repo, "initial.txt", "Initial content on main");
309+
first_commit_all(&repo, "Initial commit on main");
310+
311+
create_branch(&repo, "feat/test-merge-info");
312+
checkout_branch(&repo, "feat/test-merge-info");
313+
create_new_file(&path_to_repo, "feature_initial.txt", "Initial content on feature branch");
314+
commit_all(&repo, "Initial commit on feature branch");
315+
316+
// 2. Create a few commits on the `main` branch.
317+
checkout_branch(&repo, "main");
318+
create_new_file(&path_to_repo, "main_commit1.txt", "Main commit 1");
319+
commit_all(&repo, "Main C1");
320+
create_new_file(&path_to_repo, "main_commit2.txt", "Main commit 2");
321+
commit_all(&repo, "Main C2");
322+
323+
// 3. Create a few commits on the `feat/test-merge-info` branch.
324+
checkout_branch(&repo, "feat/test-merge-info");
325+
create_new_file(&path_to_repo, "feat_commit1.txt", "Feature commit 1");
326+
commit_all(&repo, "Feat C1");
327+
create_new_file(&path_to_repo, "feat_commit2.txt", "Feature commit 2");
328+
commit_all(&repo, "Feat C2");
329+
330+
// 4. Merge `main` into `feat/test-merge-info` (this will be the first merge commit).
331+
// Add some file changes in this merge.
332+
// The stats will reflect files from main brought into feat/test-merge-info.
333+
// For this merge, main_commit1.txt and main_commit2.txt are new to feat/test-merge-info.
334+
let merge_msg1 = "Merge main into feat/test-merge-info (1st)";
335+
run_git_command(&path_to_repo, vec!["merge", "main", "--no-ff", "-m", merge_msg1]);
336+
337+
// 5. Create more commits on `main`.
338+
checkout_branch(&repo, "main");
339+
create_new_file(&path_to_repo, "main_commit3.txt", "Main commit 3");
340+
commit_all(&repo, "Main C3");
341+
create_new_file(&path_to_repo, "main_commit4.txt", "Main commit 4");
342+
commit_all(&repo, "Main C4");
343+
344+
// 6. Create more commits on `feat/test-merge-info`.
345+
checkout_branch(&repo, "feat/test-merge-info");
346+
create_new_file(&path_to_repo, "feat_commit3.txt", "Feature commit 3");
347+
commit_all(&repo, "Feat C3");
348+
349+
// 7. Merge `main` into `feat/test-merge-info` again (this will be the second merge commit).
350+
// Add different file changes in this merge.
351+
// For this merge, main_commit3.txt and main_commit4.txt are new to feat/test-merge-info.
352+
let merge_msg2 = "Merge main into feat/test-merge-info (2nd)";
353+
run_git_command(&path_to_repo, vec!["merge", "main", "--no-ff", "-m", merge_msg2]);
354+
355+
// 8. Call `get_merge_commit_info` with `parent_branch` as "main" and `branch_name` as "feat/test-merge-info".
356+
// Need to instantiate GitChain for the current repo.
357+
// The GitChain::init() discovers the repo from the current directory.
358+
let git_chain_path = Path::new("."); // Relative to current_dir which is path_to_repo
359+
let git_chain = git_chain::GitChain::init(git_chain_path).expect("Failed to init GitChain");
360+
361+
let merge_infos_result = git_chain.get_merge_commit_info("main", "feat/test-merge-info");
362+
363+
// 9. Assert that the function returns `Ok` with a vector containing two `MergeCommitInfo` structs.
364+
assert!(merge_infos_result.is_ok(), "get_merge_commit_info should return Ok");
365+
let merge_infos = merge_infos_result.unwrap();
366+
assert_eq!(merge_infos.len(), 2, "Should find 2 merge commits from main");
367+
368+
// 10. Assert that the extracted information (message, stats) for both merge commits is correct.
369+
// Merge commits are typically listed in reverse chronological order by `git log`.
370+
// The function as implemented iterates `git log` and then `git show` for each,
371+
// so the order in `merge_infos` should be reverse chronological.
372+
373+
// Second merge (most recent)
374+
let info2 = &merge_infos[0];
375+
assert_eq!(info2.message, Some(merge_msg2.to_string()));
376+
assert!(info2.stats.is_some(), "Stats should be present for the 2nd merge");
377+
if let Some(stats) = &info2.stats {
378+
// main_commit3.txt, main_commit4.txt are introduced.
379+
// Exact insertions/deletions depend on line count, but files_changed should be 2.
380+
assert_eq!(stats.files_changed, 2, "2nd merge: files_changed should be 2");
381+
assert!(stats.insertions > 0, "2nd merge: insertions should be > 0");
382+
// Deletions could be 0 if only new files are added.
383+
}
384+
385+
// First merge
386+
let info1 = &merge_infos[1];
387+
assert_eq!(info1.message, Some(merge_msg1.to_string()));
388+
assert!(info1.stats.is_some(), "Stats should be present for the 1st merge");
389+
if let Some(stats) = &info1.stats {
390+
// main_commit1.txt, main_commit2.txt are introduced.
391+
assert_eq!(stats.files_changed, 2, "1st merge: files_changed should be 2");
392+
assert!(stats.insertions > 0, "1st merge: insertions should be > 0");
393+
}
394+
395+
std::env::set_current_dir(original_cwd).unwrap(); // Restore CWD
396+
teardown_git_repo(repo_name);
397+
}
398+
299399
#[test]
300400
fn merge_subcommand_with_ahead_behind() {
301401
// Test that merge command works with branches that are ahead and behind

0 commit comments

Comments
 (0)