@@ -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" ) ) ]
198244pub 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" ) ]
206251pub 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" ) ]
238284pub 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" ) ]
471486pub 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" ) ]
760769pub 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" ) ]
913925pub 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" ) ]
948963pub 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