Skip to content
This repository was archived by the owner on Mar 27, 2026. It is now read-only.

Commit 45896cd

Browse files
committed
compare HEAD with merge-base of HEAD and target branch
1 parent affc030 commit 45896cd

5 files changed

Lines changed: 256 additions & 51 deletions

File tree

README.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,28 @@ The compiled binary will be available at `target/release/repodiff.exe` (Windows)
4040

4141
## Usage
4242

43-
### Compare Latest Commit with Another Branch
43+
### Compare Branches
4444

45-
To compare the latest commit in the current branch with the latest common commit in another branch:
45+
To compare the latest commit on the current branch (`HEAD`) with the latest commit on another branch (`<target_branch>`):
4646

4747
```bash
48-
repodiff -b main -o output.txt
48+
# Default: Compare HEAD with <target_branch>'s HEAD
49+
repodiff -b <target_branch> -o output.txt
4950
```
5051

52+
This shows all changes on the current branch since it diverged from the target branch, *plus* any changes that occurred on the target branch after the divergence point.
53+
54+
To compare the latest commit on the current branch (`HEAD`) with the *common ancestor* (merge-base) commit between the current branch and the target branch:
55+
56+
```bash
57+
# Use --merge-base: Compare HEAD with the merge-base of HEAD and <target_branch>
58+
repodiff -b <target_branch> --merge-base -o output.txt
59+
# Short flag equivalent:
60+
repodiff -b <target_branch> -a -o output.txt
61+
```
62+
63+
This is often more useful for reviewing changes specific to the current branch, as it excludes changes made on the target branch after the branches diverged.
64+
5165
### Compare Two Specific Commits
5266

5367
To compare a specific commit with an earlier commit:
@@ -65,10 +79,11 @@ repodiff -c <commit_hash> -p -o output.txt
6579
```
6680

6781
Parameters:
68-
* `-b`, `--branch`: Branch to compare the current branch's latest commit against (finds the common ancestor).
69-
* `-c`, `--commit`: The newer commit hash to include in the comparison.
82+
* `-b`, `--branch <target_branch>`: Specify the target branch for comparison. By default, compares the current branch's `HEAD` with the `<target_branch>`'s `HEAD`. Use with `--merge-base` to compare against the common ancestor instead.
83+
* `-a`, `--merge-base`: When used with `-b`, compare the current branch's `HEAD` against the common ancestor (merge-base) commit of the current branch and the `<target_branch>`, instead of the `<target_branch>`'s `HEAD`.
84+
* `-c`, `--commit <commit_hash>`: The newer commit hash to include in the comparison.
7085
* `-p`, `--previous [PREVIOUS_COMMIT_HASH]`: Compare the commit specified by `-c` with a previous commit. If `PREVIOUS_COMMIT_HASH` is provided, compare against that specific hash. If omitted, compare against the parent of the commit specified by `-c`.
71-
* `-o`, `--output_file`: (Optional) Path to the output file. If not provided, the diff will be written to `repodiff_output.txt` in the current directory.
86+
* `-o`, `--output_file <path>`: (Optional) Path to the output file. If not provided, the diff will be written to `repodiff_output.txt` in the current directory.
7287
* `-v`, `--version`: Display the current version of RepoDiff
7388
* `-h`, `--help`: Print help information
7489

src/cli.rs

Lines changed: 60 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,27 @@ pub struct Args {
1717
#[arg(short = 'c', long = "commit")]
1818
pub commit: Option<String>,
1919

20-
/// Compare the latest commit on the current branch to the latest common commit with another branch
20+
/// Compare the current branch HEAD with the target branch HEAD.
21+
/// Use --merge-base to compare with the common ancestor instead.
2122
#[arg(short, long, conflicts_with_all = ["commit", "previous"])]
2223
pub branch: Option<String>,
2324

25+
/// Compare with the common ancestor (merge-base) of the current and target branch
26+
#[arg(short = 'a', long, requires = "branch")]
27+
pub merge_base: bool,
28+
2429
/// Compare the specified commit (--commit) with a previous commit.
2530
/// If a hash is provided, compare with that specific hash.
2631
/// If no hash is provided, compare with the parent of the commit specified by --commit.
2732
#[arg(short = 'p', long = "previous", value_name = "PREVIOUS_COMMIT_HASH", num_args = 0..=1, requires = "commit", conflicts_with = "branch")]
2833
pub previous: Option<Option<String>>,
2934
}
3035

36+
/// Abbreviate commit hash for cleaner output
37+
fn short_hash(hash: &str) -> &str {
38+
&hash[..12.min(hash.len())]
39+
}
40+
3141
/// Main entry point for the CLI
3242
pub fn run() -> Result<()> {
3343
let args = Args::parse();
@@ -36,55 +46,68 @@ pub fn run() -> Result<()> {
3646
let mut repodiff = RepoDiff::new("config.json")?;
3747
let git_ops = GitOperations::new();
3848

39-
// Determine the commit hashes based on provided arguments
40-
let (commit1, commit2) = if let Some(branch) = args.branch {
49+
// Validate arguments first
50+
if args.branch.is_none() && args.commit.is_none() {
51+
eprintln!("You must specify either a branch to compare (--branch [-a]) or a commit to compare (--commit -p).");
52+
process::exit(1);
53+
}
54+
if args.commit.is_some() && args.previous.is_none() {
55+
// This specific combination (--commit without --previous) is invalid
56+
eprintln!("Missing comparison target. Use --previous (-p) to compare with a parent or specific commit when using --commit, or use --branch (-b) to compare with another branch.");
57+
process::exit(1);
58+
}
59+
60+
// Determine the commit hashes based on validated arguments
61+
let (commit1, commit2): (String, String) = if let Some(branch) = args.branch {
4162
// Branch comparison logic
42-
let commit1 = git_ops.get_latest_common_commit_with_branch(&branch)?;
43-
let commit2 = git_ops.get_latest_commit()?;
44-
45-
println!(
46-
"Comparing latest common commit with branch '{}' ({}) and the latest commit on the current branch ({}).",
47-
branch,
48-
&commit1[..12.min(commit1.len())],
49-
&commit2[..12.min(commit2.len())]
50-
);
51-
(commit1, commit2)
63+
let head_commit = git_ops.get_latest_commit()?;
5264

53-
} else if let Some(commit_to_compare) = args.commit {
54-
// Commit comparison logic (using --commit and --previous)
55-
match args.previous {
56-
Some(Some(prev_commit_hash)) => {
65+
if args.merge_base {
66+
// Compare HEAD with merge-base
67+
let base_commit = git_ops.find_merge_base(&branch)?;
68+
println!(
69+
"Comparing merge-base with branch '{}' ({}) and current HEAD ({}).",
70+
branch,
71+
short_hash(&base_commit),
72+
short_hash(&head_commit)
73+
);
74+
(base_commit, head_commit)
75+
} else {
76+
// Compare HEAD with target branch HEAD
77+
let branch_head_commit = git_ops.get_branch_head(&branch)?;
78+
println!(
79+
"Comparing target branch '{}' HEAD ({}) and current HEAD ({}).",
80+
branch,
81+
short_hash(&branch_head_commit),
82+
short_hash(&head_commit)
83+
);
84+
(branch_head_commit, head_commit)
85+
}
86+
} else {
87+
// Commit comparison logic (--commit is guaranteed to be Some here,
88+
// and --previous is also guaranteed to be Some due to the check above)
89+
let commit_to_compare = args.commit.unwrap(); // Safe due to initial check
90+
match args.previous.unwrap() { // Safe due to initial check
91+
Some(prev_commit_hash) => {
5792
// -p <hash> provided: Compare commit_to_compare with prev_commit_hash
58-
let commit1 = prev_commit_hash;
59-
let commit2 = commit_to_compare;
6093
println!(
6194
"Comparing specified commit {} with previous commit {}.",
62-
&commit2[..12.min(commit2.len())],
63-
&commit1[..12.min(commit1.len())]
95+
short_hash(&commit_to_compare),
96+
short_hash(&prev_commit_hash)
6497
);
65-
(commit1, commit2)
98+
(prev_commit_hash, commit_to_compare)
6699
}
67-
Some(None) => {
100+
None => {
68101
// -p flag provided without value: Compare commit_to_compare with its parent
69-
let commit2 = commit_to_compare;
70-
let commit1 = git_ops.get_previous_commit(&commit2)?;
102+
let parent_commit = git_ops.get_previous_commit(&commit_to_compare)?;
71103
println!(
72104
"Comparing specified commit {} with its parent commit {}.",
73-
&commit2[..12.min(commit2.len())],
74-
&commit1[..12.min(commit1.len())]
105+
short_hash(&commit_to_compare),
106+
short_hash(&parent_commit)
75107
);
76-
(commit1, commit2)
77-
}
78-
None => {
79-
// Only -c provided, which is not enough for comparison.
80-
eprintln!("Missing comparison target. Use --previous (-p) to compare with a parent or specific commit when using --commit, or use --branch (-b) to compare with another branch.");
81-
process::exit(1);
108+
(parent_commit, commit_to_compare)
82109
}
83110
}
84-
} else {
85-
// Neither --branch nor --commit specified.
86-
eprintln!("You must specify either a branch to compare (--branch) or a commit to compare (--commit) along with a comparison target (--previous).");
87-
process::exit(1);
88111
};
89112

90113
// Set output file or default

src/utils/git_operations.rs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,25 +60,25 @@ impl GitOperations {
6060
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
6161
}
6262

63-
/// Get the latest common commit between the current branch and base branch
63+
/// Find the merge base commit between the current branch (HEAD) and a target branch
6464
///
6565
/// # Arguments
6666
///
67-
/// * `branch` - The name of the base branch to compare with
68-
pub fn get_latest_common_commit_with_branch(&self, branch: &str) -> Result<String> {
67+
/// * `branch` - The name of the target branch to find the common ancestor with
68+
pub fn find_merge_base(&self, branch: &str) -> Result<String> {
6969
let output = Command::new("git")
7070
.args(["merge-base", "HEAD", branch])
7171
.output()
7272
.map_err(|e| {
7373
RepoDiffError::GitError(format!(
74-
"Failed to get latest common commit with '{}': {}",
74+
"Failed to find merge base with branch '{}': {}",
7575
branch, e
7676
))
7777
})?;
7878

7979
if !output.status.success() {
8080
return Err(RepoDiffError::GitError(format!(
81-
"Failed to get latest common commit with '{}': {}",
81+
"Failed to find merge base with branch '{}': {}",
8282
branch,
8383
String::from_utf8_lossy(&output.stderr)
8484
)));
@@ -87,6 +87,28 @@ impl GitOperations {
8787
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
8888
}
8989

90+
/// Get the latest commit hash for a specific branch
91+
///
92+
/// # Arguments
93+
///
94+
/// * `branch_name` - The name of the branch
95+
pub fn get_branch_head(&self, branch_name: &str) -> Result<String> {
96+
let output = Command::new("git")
97+
.args(["rev-parse", branch_name])
98+
.output()
99+
.map_err(|e| RepoDiffError::GitError(format!("Failed to get HEAD for branch '{}': {}", branch_name, e)))?;
100+
101+
if !output.status.success() {
102+
return Err(RepoDiffError::GitError(format!(
103+
"Failed to get HEAD for branch '{}': {}",
104+
branch_name,
105+
String::from_utf8_lossy(&output.stderr)
106+
)));
107+
}
108+
109+
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
110+
}
111+
90112
/// Get the previous commit of a given commit hash
91113
///
92114
/// # Arguments

tests/cli_integration_test.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#[cfg(test)]
2+
mod tests {
3+
use std::fs;
4+
use std::path::PathBuf;
5+
use std::process::{Command, Stdio};
6+
use tempfile::{tempdir, TempDir};
7+
8+
struct TestRepo {
9+
dir: TempDir,
10+
main_head: String,
11+
feature_head: String,
12+
merge_base: String,
13+
}
14+
15+
/// Sets up a temporary Git repository with a main and feature branch.
16+
///
17+
/// Structure:
18+
/// M1 -> M2 (main)
19+
/// \-> F1 -> F2 (feature)
20+
///
21+
/// Returns commit hashes for main HEAD, feature HEAD, and the merge base.
22+
fn setup_git_repo() -> Result<TestRepo, Box<dyn std::error::Error>> {
23+
let dir = tempdir()?;
24+
let repo_path = dir.path();
25+
26+
// Helper function to run git commands
27+
let run_git = |args: &[&str]| -> Result<String, Box<dyn std::error::Error>> {
28+
let output = Command::new("git")
29+
.current_dir(repo_path)
30+
.args(args)
31+
.stdout(Stdio::piped())
32+
.stderr(Stdio::piped())
33+
.output()?;
34+
if !output.status.success() {
35+
return Err(format!(
36+
"Git command failed: {:?}\nStderr: {}",
37+
args,
38+
String::from_utf8_lossy(&output.stderr)
39+
)
40+
.into());
41+
}
42+
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
43+
};
44+
45+
// Helper function to create a commit
46+
let create_commit = |msg: &str, file_name: &str, content: &str| -> Result<String, Box<dyn std::error::Error>> {
47+
fs::write(repo_path.join(file_name), content)?;
48+
run_git(&["add", file_name])?;
49+
run_git(&["commit", "-m", msg])?;
50+
run_git(&["rev-parse", "HEAD"])
51+
};
52+
53+
// Initialize repo and make first commit (M1)
54+
run_git(&["init"])?;
55+
run_git(&["config", "user.email", "test@example.com"])?;
56+
run_git(&["config", "user.name", "Test User"])?;
57+
let commit_m1 = create_commit("Initial commit", "file1.txt", "Content 1")?;
58+
59+
// Ensure the primary branch is named 'main'
60+
let current_branch = run_git(&["branch", "--show-current"])?;
61+
if current_branch != "main" {
62+
run_git(&["branch", "-M", &current_branch, "main"])?;
63+
}
64+
65+
// Create feature branch from main (M1) and make commits (F1, F2)
66+
run_git(&["checkout", "-b", "feature"])?;
67+
create_commit("Feature commit 1", "file2.txt", "Feature content A")?;
68+
let commit_f2 = create_commit("Feature commit 2", "file1.txt", "Content 1 modified by feature")?;
69+
70+
// Switch back to main and make commit (M2)
71+
run_git(&["checkout", "main"])?;
72+
let commit_m2 = create_commit("Main commit 2", "file3.txt", "Main content B")?;
73+
74+
// Verify HEAD is now feature branch F2 for subsequent tests
75+
run_git(&["checkout", "feature"])?;
76+
77+
Ok(TestRepo {
78+
dir,
79+
main_head: commit_m2,
80+
feature_head: commit_f2,
81+
merge_base: commit_m1, // M1 is the merge base
82+
})
83+
}
84+
85+
fn run_repodiff(args: &[&str], cwd: &PathBuf) -> Result<String, Box<dyn std::error::Error>> {
86+
let repodiff_path = env!("CARGO_BIN_EXE_repodiff");
87+
let output = Command::new(repodiff_path)
88+
.current_dir(cwd)
89+
.args(args)
90+
.stdout(Stdio::piped())
91+
.stderr(Stdio::piped())
92+
.output()?;
93+
94+
if !output.status.success() {
95+
return Err(format!(
96+
"repodiff command failed: {:?}\nStderr: {}",
97+
args,
98+
String::from_utf8_lossy(&output.stderr)
99+
)
100+
.into());
101+
}
102+
Ok(String::from_utf8_lossy(&output.stdout).to_string())
103+
}
104+
105+
/// Abbreviate commit hash for cleaner output in assertions
106+
fn short_hash(hash: &str) -> &str {
107+
&hash[..12.min(hash.len())]
108+
}
109+
110+
#[test]
111+
fn test_compare_branch_head() -> Result<(), Box<dyn std::error::Error>> {
112+
let repo = setup_git_repo()?;
113+
114+
let output = run_repodiff(&["-b", "main"], &repo.dir.path().to_path_buf())?;
115+
116+
let expected_output = format!(
117+
"Comparing target branch 'main' HEAD ({}) and current HEAD ({})",
118+
short_hash(&repo.main_head),
119+
short_hash(&repo.feature_head)
120+
);
121+
122+
assert!(output.contains(&expected_output), "Output did not contain expected branch head comparison message.\nExpected: {}\nGot: {}", expected_output, output);
123+
assert!(repo.dir.path().join("repodiff_output.txt").exists(), "Output file was not created.");
124+
125+
Ok(())
126+
}
127+
128+
#[test]
129+
fn test_compare_merge_base() -> Result<(), Box<dyn std::error::Error>> {
130+
let repo = setup_git_repo()?;
131+
132+
let output = run_repodiff(&["-b", "main", "-a"], &repo.dir.path().to_path_buf())?;
133+
134+
let expected_output = format!(
135+
"Comparing merge-base with branch 'main' ({}) and current HEAD ({})",
136+
short_hash(&repo.merge_base),
137+
short_hash(&repo.feature_head)
138+
);
139+
140+
assert!(output.contains(&expected_output), "Output did not contain expected merge-base comparison message.\nExpected: {}\nGot: {}", expected_output, output);
141+
assert!(repo.dir.path().join("repodiff_output.txt").exists(), "Output file was not created.");
142+
143+
Ok(())
144+
}
145+
}

0 commit comments

Comments
 (0)