Skip to content

Commit ed2f3ce

Browse files
exzshaooz-agent
andcommitted
[WAR-7378] Fix: Use fork point for PR diffs and commit message retrieval
This change updates the Git dialogs to compare against the correct parent branch instead of always using the main branch. Specifically: - Replaces detect_main_branch with detect_parent_branch in several locations to determine the base for diffs and commit messages. - Introduces detect_fork_point to determine the point where the current branch diverged from its parent. - Uses the fork point when no parent branch is specified, ensuring accurate diffs even when the parent branch is not explicitly set. Fixes WAR-7378 Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent 0ca787c commit ed2f3ce

4 files changed

Lines changed: 186 additions & 153 deletions

File tree

app/src/code_review/git_dialog/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ impl GitDialog {
553553
) -> Self {
554554
let (confirm_button, cancel_button, close_button) =
555555
Self::build_dialog_buttons(pr::confirm_label_for(), Some(pr::confirm_icon_for()), ctx);
556-
let state = pr::new_state(&repo_path, parent_branch_name.as_deref(), ctx);
556+
let state = pr::new_state(&repo_path, ctx);
557557
Self {
558558
repo_path,
559559
branch_name,

app/src/code_review/git_dialog/pr.rs

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,10 @@ pub(super) fn is_ready_to_confirm(_state: &PrState) -> bool {
6060
true
6161
}
6262

63-
pub(super) fn new_state(
64-
repo_path: &std::path::Path,
65-
parent_branch: Option<&str>,
66-
ctx: &mut ViewContext<GitDialog>,
67-
) -> PrState {
63+
pub(super) fn new_state(repo_path: &std::path::Path, ctx: &mut ViewContext<GitDialog>) -> PrState {
6864
let diff_repo_path = repo_path.to_path_buf();
69-
let parent_branch = parent_branch.map(|s| s.to_string());
7065
ctx.spawn(
71-
async move { get_branch_diff_entries(&diff_repo_path, parent_branch.as_deref()).await },
66+
async move { get_branch_diff_entries(&diff_repo_path, None).await },
7267
|me, result, ctx| {
7368
if let GitDialogMode::CreatePr(state) = &mut me.mode {
7469
match result {
@@ -172,8 +167,8 @@ pub(super) async fn create_pr_with_ai_content(
172167
code_review_ai: &dyn AIClient,
173168
path_env: Option<&str>,
174169
) -> anyhow::Result<PrInfo> {
175-
let diff = get_diff_for_pr(repo_path, parent_branch).await?;
176-
let commit_messages = get_branch_commit_messages(repo_path, parent_branch)
170+
let diff = get_diff_for_pr(repo_path, None).await?;
171+
let commit_messages = get_branch_commit_messages(repo_path, None)
177172
.await
178173
.unwrap_or_default();
179174

app/src/util/git.rs

Lines changed: 114 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -193,15 +193,60 @@ pub async fn detect_main_branch(repo_path: &Path) -> Result<String> {
193193
run_git_command(repo_path, &["branch", "--show-current"]).await
194194
}
195195

196-
/// Returns the closest-ancestor branch of `HEAD`, falling back to main.
196+
/// Returns the SHA where `HEAD` forked from any other ref. Use
197+
/// `<fork>..HEAD` for "commits unique to this branch". For branch-name
198+
/// lookup, see [`detect_parent_branch`].
199+
#[cfg(not(feature = "local_fs"))]
200+
pub async fn detect_fork_point(_repo_path: &Path) -> Result<Option<String>> {
201+
Err(anyhow!("Not supported without local_fs"))
202+
}
203+
204+
/// See the no-`local_fs` stub above for documentation.
205+
#[cfg(feature = "local_fs")]
206+
pub async fn detect_fork_point(repo_path: &Path) -> Result<Option<String>> {
207+
// Exclude both `<current>` and `origin/<current>` so the branch isn't
208+
// subtracted from itself. `--exclude` applies to the next `--branches`
209+
// / `--remotes` and matches the short ref name.
210+
let current = detect_current_branch(repo_path).await.ok();
211+
let branch_exclude = current.as_deref().map(|c| format!("--exclude={c}"));
212+
let remote_exclude = current.as_deref().map(|c| format!("--exclude=origin/{c}"));
213+
214+
let mut args: Vec<&str> = vec!["rev-list", "HEAD", "--not"];
215+
args.extend(branch_exclude.as_deref());
216+
args.push("--branches");
217+
args.extend(remote_exclude.as_deref());
218+
args.push("--remotes");
219+
220+
let unique = match run_git_command(repo_path, &args).await {
221+
Ok(out) => out,
222+
Err(e) => {
223+
log::debug!("detect_fork_point: rev-list failed: {e}");
224+
return Ok(None);
225+
}
226+
};
227+
228+
// Last non-empty line = oldest unique commit; its parent = fork point.
229+
// No unique commits means HEAD is fully shared, so fork = HEAD.
230+
let target = match unique.lines().filter(|l| !l.trim().is_empty()).next_back() {
231+
Some(sha) => format!("{}^", sha.trim()),
232+
None => "HEAD".to_string(),
233+
};
234+
Ok(run_git_command(repo_path, &["rev-parse", &target])
235+
.await
236+
.ok()
237+
.map(|s| s.trim().to_string()))
238+
}
239+
240+
/// Closest-ancestor *branch name* of `HEAD`, falling back to main. Used
241+
/// for `gh pr create --base` and UI labels; for diff/log ranges prefer
242+
/// [`detect_fork_point`] (returns a stable SHA).
197243
#[cfg(not(feature = "local_fs"))]
198244
pub async fn detect_parent_branch(_repo_path: &Path) -> Result<String> {
199245
Err(anyhow!("Not supported without local_fs"))
200246
}
201247

202-
/// Returns the closest-ancestor branch of `HEAD`, falling back to main.
203-
/// Ties prefer main, then local over `origin/*`, then alphabetical.
204-
/// Callers with already-known values should prefer [`detect_parent_branch_with_context`].
248+
/// See [`detect_parent_branch_with_context`] for the algorithm; this
249+
/// variant fetches `current`/`upstream`/`main` itself.
205250
#[cfg(feature = "local_fs")]
206251
pub async fn detect_parent_branch(repo_path: &Path) -> Result<String> {
207252
let (current, upstream, main) = futures::join!(
@@ -232,95 +277,65 @@ pub async fn detect_parent_branch_with_context(
232277
Err(anyhow!("Not supported without local_fs"))
233278
}
234279

235-
/// Like [`detect_parent_branch`], but reuses already-known `current`, `upstream`,
236-
/// and `main` to avoid redundant subprocess spawns.
280+
/// Picks the branch whose tip still contains the fork-point commit.
281+
/// `--contains` (unlike `--merged HEAD`) keeps refs that have advanced
282+
/// past the fork point. Ties prefer main, local over `origin/*`, then alpha.
237283
#[cfg(feature = "local_fs")]
238284
pub async fn detect_parent_branch_with_context(
239285
repo_path: &Path,
240286
current: Option<&str>,
241287
upstream: Option<&str>,
242288
main: Result<String>,
243289
) -> Result<String> {
244-
use std::collections::HashMap;
290+
let Some(fork) = detect_fork_point(repo_path).await.ok().flatten() else {
291+
// No fork point — fall back to detected main.
292+
return main;
293+
};
245294

246-
let (refs_output, log_output) = futures::join!(
247-
async {
248-
run_git_command(
249-
repo_path,
250-
&[
251-
"for-each-ref",
252-
"--merged",
253-
"HEAD",
254-
"--format=%(objectname) %(refname:short)",
255-
"refs/heads",
256-
"refs/remotes",
257-
],
258-
)
259-
.await
260-
.inspect_err(|e| log::debug!("detect_parent_branch: for-each-ref failed: {e}"))
261-
.unwrap_or_default()
262-
},
263-
async {
264-
run_git_command(
265-
repo_path,
266-
&["log", "HEAD", "--format=%H", "--max-count=1000"],
267-
)
268-
.await
269-
.inspect_err(|e| log::debug!("detect_parent_branch: log HEAD failed: {e}"))
270-
.unwrap_or_default()
271-
},
272-
);
295+
let refs_output = run_git_command(
296+
repo_path,
297+
&[
298+
"for-each-ref",
299+
"--contains",
300+
&fork,
301+
"--format=%(refname:short)",
302+
"refs/heads",
303+
"refs/remotes",
304+
],
305+
)
306+
.await
307+
.inspect_err(|e| log::debug!("detect_parent_branch: for-each-ref --contains failed: {e}"))
308+
.unwrap_or_default();
273309

274-
// Position in HEAD's history = distance from HEAD.
275-
let positions: HashMap<&str, usize> = log_output
310+
// Skip bare `origin`, `*/HEAD` aliases, and self / upstream.
311+
let candidates: Vec<String> = refs_output
276312
.lines()
277-
.enumerate()
278-
.map(|(i, sha)| (sha, i))
313+
.map(|l| l.trim().to_string())
314+
.filter(|name| !name.is_empty())
315+
.filter(|name| name.contains('/') || name != "origin")
316+
.filter(|name| !name.ends_with("/HEAD"))
317+
.filter(|name| current != Some(name.as_str()))
318+
.filter(|name| upstream != Some(name.as_str()))
279319
.collect();
280320

281-
let mut candidates: Vec<(usize, String)> = Vec::new();
282-
for line in refs_output.lines() {
283-
let Some((sha, name)) = line.trim().split_once(' ') else {
284-
continue;
285-
};
286-
let name = name.trim();
287-
if name.is_empty() || sha.is_empty() {
288-
continue;
289-
}
290-
// Some git versions emit a bare "origin" for `refs/remotes/origin/HEAD`.
291-
if !name.contains('/') && name == "origin" {
292-
continue;
293-
}
294-
if current == Some(name) {
295-
continue;
296-
}
297-
if upstream == Some(name) {
298-
continue;
299-
}
300-
let Some(&count) = positions.get(sha) else {
301-
continue;
302-
};
303-
candidates.push((count, name.to_string()));
304-
}
305-
321+
// Tiebreakers: prefer detected main, then local over `origin/*`, then alpha.
306322
let main_name = main.as_ref().ok().map(String::as_str);
307323
let best = candidates.into_iter().min_by(|a, b| {
308-
a.0.cmp(&b.0)
309-
.then_with(|| {
310-
let a_is_main = main_name == Some(a.1.as_str());
311-
let b_is_main = main_name == Some(b.1.as_str());
312-
b_is_main.cmp(&a_is_main)
313-
})
314-
.then_with(|| b.1.contains('/').cmp(&a.1.contains('/')))
315-
.then_with(|| a.1.cmp(&b.1))
324+
let a_is_main = main_name == Some(a.as_str());
325+
let b_is_main = main_name == Some(b.as_str());
326+
b_is_main
327+
.cmp(&a_is_main)
328+
.then_with(|| b.contains('/').cmp(&a.contains('/')))
329+
.then_with(|| a.cmp(b))
316330
});
317331

318-
if let Some((count, branch)) = best {
332+
if let Some(branch) = best {
319333
let is_local = !branch.contains('/');
320-
log::debug!("detect_parent_branch: picked {branch} (count={count}, local={is_local})");
334+
log::debug!("detect_parent_branch: picked {branch} (fork={fork}, local={is_local})");
321335
return Ok(branch);
322336
}
323337

338+
// No ref reaches the fork point; fall back to detected main.
324339
main
325340
}
326341

@@ -466,7 +481,7 @@ pub async fn get_file_change_entries(
466481
Err(anyhow!("Not supported on wasm"))
467482
}
468483

469-
/// Unpushed commits: `@{u}..HEAD`, or `<detected_parent>..HEAD` if no upstream.
484+
/// Unpushed commits: `@{u}..HEAD`, or `<fork_point>..HEAD` if no upstream.
470485
#[cfg(feature = "local_fs")]
471486
pub async fn get_unpushed_commits(repo_path: &Path) -> Result<Vec<Commit>> {
472487
let output = match run_git_command(
@@ -479,21 +494,14 @@ pub async fn get_unpushed_commits(repo_path: &Path) -> Result<Vec<Commit>> {
479494
Err(e) => {
480495
let msg = e.to_string();
481496
if msg.contains("no upstream configured") || msg.contains("unknown revision") {
482-
let current_branch =
483-
run_git_command(repo_path, &["symbolic-ref", "--short", "HEAD"])
484-
.await
485-
.ok()
486-
.map(|s| s.trim().to_string());
487-
488-
let parent_branch = detect_parent_branch(repo_path).await.ok();
489-
490-
// No meaningful base when current == parent (or detection failed);
491-
// list all commits reachable from HEAD.
492-
let range = match (&current_branch, &parent_branch) {
493-
(Some(current), Some(parent)) if current != parent => {
494-
format!("{parent}..HEAD")
495-
}
496-
_ => "HEAD".to_string(),
497+
// No upstream — fall back to the fork-point commit so we show
498+
// exactly the commits unique to this branch, regardless of
499+
// where the parent branch's tip currently sits.
500+
let fork_point = detect_fork_point(repo_path).await.ok().flatten();
501+
502+
let range = match fork_point {
503+
Some(sha) => format!("{sha}..HEAD"),
504+
None => "HEAD".to_string(),
497505
};
498506

499507
run_git_command(
@@ -754,16 +762,19 @@ pub async fn run_commit(
754762
Err(anyhow!("Not supported on wasm"))
755763
}
756764

757-
/// Per-file stats for what would land in a PR: detected parent vs
758-
/// remote branch (or HEAD when unpushed).
765+
/// Per-file stats for what would land in a PR: base (caller-supplied or
766+
/// fork-point SHA) vs `origin/<current>` (or HEAD when unpushed).
767+
/// `parent_branch` accepts a branch name or SHA; `None` uses the fork point.
759768
#[cfg(feature = "local_fs")]
760769
pub async fn get_branch_diff_entries(
761770
repo_path: &Path,
762771
parent_branch: Option<&str>,
763772
) -> Result<Vec<FileChangeEntry>> {
764773
let base = match parent_branch {
765774
Some(b) => b.to_string(),
766-
None => detect_parent_branch(repo_path).await?,
775+
None => detect_fork_point(repo_path)
776+
.await?
777+
.ok_or_else(|| anyhow!("Could not detect fork point for current branch"))?,
767778
};
768779
let current = detect_current_branch(repo_path).await?;
769780
let remote_ref = format!("origin/{current}");
@@ -907,13 +918,16 @@ pub async fn get_pr_for_branch(
907918
Err(anyhow!("Not supported on wasm"))
908919
}
909920

910-
/// PR-ready diff (detected parent vs remote branch / HEAD), truncated
911-
/// for AI token limits. Used for PR title/body generation.
921+
/// PR-ready diff (base vs `origin/<current>` or HEAD), truncated for AI
922+
/// token limits. `parent_branch` accepts a branch name or SHA; `None`
923+
/// uses the fork point.
912924
#[cfg(feature = "local_fs")]
913925
pub async fn get_diff_for_pr(repo_path: &Path, parent_branch: Option<&str>) -> Result<String> {
914926
let base = match parent_branch {
915927
Some(b) => b.to_string(),
916-
None => detect_parent_branch(repo_path).await?,
928+
None => detect_fork_point(repo_path)
929+
.await?
930+
.ok_or_else(|| anyhow!("Could not detect fork point for current branch"))?,
917931
};
918932
let current = detect_current_branch(repo_path).await?;
919933
let remote_ref = format!("origin/{current}");
@@ -943,15 +957,18 @@ pub async fn get_diff_for_pr(_repo_path: &Path, _parent_branch: Option<&str>) ->
943957
Err(anyhow!("Not supported on wasm"))
944958
}
945959

946-
/// Returns commit messages on the current branch not on the detected parent.
960+
/// Commit subject lines on the current branch since the base.
961+
/// `parent_branch` accepts a branch name or SHA; `None` uses the fork point.
947962
#[cfg(feature = "local_fs")]
948963
pub async fn get_branch_commit_messages(
949964
repo_path: &Path,
950965
parent_branch: Option<&str>,
951966
) -> Result<Vec<String>> {
952967
let base = match parent_branch {
953968
Some(b) => b.to_string(),
954-
None => detect_parent_branch(repo_path).await?,
969+
None => detect_fork_point(repo_path)
970+
.await?
971+
.ok_or_else(|| anyhow!("Could not detect fork point for current branch"))?,
955972
};
956973
let range = format!("{base}..HEAD");
957974
let output = run_git_command(repo_path, &["log", &range, "--format=%s"]).await?;

0 commit comments

Comments
 (0)