status: add status.compareBranches config for multiple branch comparisons

Add a new configuration variable status.compareBranches that allows users to specify a space-separated list of branch comparisons in git status output. Supported values: - @{upstream} for the current branch's upstream tracking branch - @{push} for the current branch's push destination Any other value is ignored and a warning is shown. When not configured, the default behavior is equivalent to setting `status.compareBranches = @{upstream}`, preserving backward compatibility. The advice messages shown are context-aware: - "git pull" advice is shown only when comparing against @{upstream} - "git push" advice is shown only when comparing against @{push} - Divergence advice is shown for upstream branch comparisons This is useful for triangular workflows where the upstream tracking branch differs from the push destination, allowing users to see their status relative to both branches at once. Example configuration: [status] compareBranches = @{upstream} @{push} Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Harald Nordgren committed Feb 26, 2026 at 10:33 UTC 3ea95ac9c5a8ac60702b157153498453afe7ab3b
3 files changed +447 -28
Documentation/config/status.adoc
+19
@@ -17,6 +17,25 @@ status.aheadBehind::
17 `--no-ahead-behind` by default in linkgit:git-status[1] for
18 non-porcelain status formats. Defaults to true.
19
20 +status.compareBranches::
21 + A space-separated list of branch comparison specifiers to use in
22 + linkgit:git-status[1]. Currently, only `@{upstream}` and `@{push}`
23 + are supported. They are interpreted as `branch@{upstream}` and
24 + `branch@{push}` for the current branch.
25 ++
26 +If not set, the default behavior is equivalent to `@{upstream}`, which
27 +compares against the configured upstream tracking branch.
28 ++
29 +Example:
30 ++
31 +----
32 +[status]
33 + compareBranches = @{upstream} @{push}
34 +----
35 ++
36 +This would show comparisons against both the configured upstream and push
37 +tracking branches for the current branch.
38 +
39 status.displayCommentPrefix::
40 If set to true, linkgit:git-status[1] will insert a comment
41 prefix before each output line (starting with
remote.c
+118 -28
@@ -29,6 +29,12 @@
29
30 enum map_direction { FROM_SRC, FROM_DST };
31
32 +enum {
33 + ENABLE_ADVICE_PULL = (1 << 0),
34 + ENABLE_ADVICE_PUSH = (1 << 1),
35 + ENABLE_ADVICE_DIVERGENCE = (1 << 2),
36 +};
37 +
38 struct counted_string {
39 size_t len;
40 const char *s;
@@ -2241,13 +2247,40 @@ int stat_tracking_info(struct branch *branch, int *num_ours, int *num_theirs,
2247 return stat_branch_pair(branch->refname, base, num_ours, num_theirs, abf);
2248 }
2249
2250 +static char *resolve_compare_branch(struct branch *branch, const char *name)
2251 +{
2252 + const char *resolved = NULL;
2253 +
2254 + if (!branch || !name)
2255 + return NULL;
2256 +
2257 + if (!strcasecmp(name, "@{upstream}")) {
2258 + resolved = branch_get_upstream(branch, NULL);
2259 + } else if (!strcasecmp(name, "@{push}")) {
2260 + resolved = branch_get_push(branch, NULL);
2261 + } else {
2262 + warning(_("ignoring value '%s' for status.compareBranches, "
2263 + "only @{upstream} and @{push} are supported"),
2264 + name);
2265 + return NULL;
2266 + }
2267 +
2268 + if (resolved)
2269 + return xstrdup(resolved);
2270 + return NULL;
2271 +}
2272 +
2273 static void format_branch_comparison(struct strbuf *sb,
2274 bool up_to_date,
2275 int ours, int theirs,
2276 const char *branch_name,
2277 enum ahead_behind_flags abf,
2249 - bool show_divergence_advice)
2278 + unsigned flags)
2279 {
2280 + bool use_push_advice = (flags & ENABLE_ADVICE_PUSH);
2281 + bool use_pull_advice = (flags & ENABLE_ADVICE_PULL);
2282 + bool use_divergence_advice = (flags & ENABLE_ADVICE_DIVERGENCE);
2283 +
2284 if (up_to_date) {
2285 strbuf_addf(sb,
2286 _("Your branch is up to date with '%s'.\n"),
@@ -2256,7 +2289,7 @@ static void format_branch_comparison(struct strbuf *sb,
2289 strbuf_addf(sb,
2290 _("Your branch and '%s' refer to different commits.\n"),
2291 branch_name);
2259 - if (advice_enabled(ADVICE_STATUS_HINTS))
2292 + if (use_push_advice && advice_enabled(ADVICE_STATUS_HINTS))
2293 strbuf_addf(sb, _(" (use \"%s\" for details)\n"),
2294 "git status --ahead-behind");
2295 } else if (!theirs) {
@@ -2265,7 +2298,7 @@ static void format_branch_comparison(struct strbuf *sb,
2298 "Your branch is ahead of '%s' by %d commits.\n",
2299 ours),
2300 branch_name, ours);
2268 - if (advice_enabled(ADVICE_STATUS_HINTS))
2301 + if (use_push_advice && advice_enabled(ADVICE_STATUS_HINTS))
2302 strbuf_addstr(sb,
2303 _(" (use \"git push\" to publish your local commits)\n"));
2304 } else if (!ours) {
@@ -2276,7 +2309,7 @@ static void format_branch_comparison(struct strbuf *sb,
2309 "and can be fast-forwarded.\n",
2310 theirs),
2311 branch_name, theirs);
2279 - if (advice_enabled(ADVICE_STATUS_HINTS))
2312 + if (use_pull_advice && advice_enabled(ADVICE_STATUS_HINTS))
2313 strbuf_addstr(sb,
2314 _(" (use \"git pull\" to update your local branch)\n"));
2315 } else {
@@ -2289,8 +2322,7 @@ static void format_branch_comparison(struct strbuf *sb,
2322 "respectively.\n",
2323 ours + theirs),
2324 branch_name, ours, theirs);
2292 - if (show_divergence_advice &&
2293 - advice_enabled(ADVICE_STATUS_HINTS))
2325 + if (use_divergence_advice && advice_enabled(ADVICE_STATUS_HINTS))
2326 strbuf_addstr(sb,
2327 _(" (use \"git pull\" if you want to integrate the remote branch with yours)\n"));
2328 }
@@ -2303,34 +2335,92 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb,
2335 enum ahead_behind_flags abf,
2336 int show_divergence_advice)
2337 {
2306 - int ours, theirs, cmp_fetch;
2307 - const char *full_base;
2308 - char *base;
2309 - int upstream_is_gone = 0;
2338 + char *compare_branches = NULL;
2339 + struct string_list branches = STRING_LIST_INIT_DUP;
2340 + struct strset processed_refs = STRSET_INIT;
2341 + int reported = 0;
2342 + size_t i;
2343 + const char *upstream_ref;
2344 + const char *push_ref;
2345
2311 - cmp_fetch = stat_tracking_info(branch, &ours, &theirs, &full_base, 0, abf);
2312 - if (cmp_fetch < 0) {
2313 - if (!full_base)
2314 - return 0;
2315 - upstream_is_gone = 1;
2346 + repo_config_get_string(the_repository, "status.comparebranches",
2347 + &compare_branches);
2348 +
2349 + if (compare_branches) {
2350 + string_list_split(&branches, compare_branches, " ", -1);
2351 + string_list_remove_empty_items(&branches, 0);
2352 + } else {
2353 + string_list_append(&branches, "@{upstream}");
2354 }
2355
2318 - base = refs_shorten_unambiguous_ref(get_main_ref_store(the_repository),
2319 - full_base, 0);
2356 + upstream_ref = branch_get_upstream(branch, NULL);
2357 + push_ref = branch_get_push(branch, NULL);
2358
2321 - if (upstream_is_gone) {
2322 - strbuf_addf(sb,
2323 - _("Your branch is based on '%s', but the upstream is gone.\n"),
2324 - base);
2325 - if (advice_enabled(ADVICE_STATUS_HINTS))
2326 - strbuf_addstr(sb,
2327 - _(" (use \"git branch --unset-upstream\" to fixup)\n"));
2328 - } else {
2329 - format_branch_comparison(sb, !cmp_fetch, ours, theirs, base, abf, show_divergence_advice);
2359 + for (i = 0; i < branches.nr; i++) {
2360 + char *full_ref;
2361 + char *short_ref;
2362 + int ours, theirs, cmp;
2363 + int is_upstream, is_push;
2364 + unsigned flags = 0;
2365 +
2366 + full_ref = resolve_compare_branch(branch,
2367 + branches.items[i].string);
2368 + if (!full_ref)
2369 + continue;
2370 +
2371 + if (!strset_add(&processed_refs, full_ref)) {
2372 + free(full_ref);
2373 + continue;
2374 + }
2375 +
2376 + short_ref = refs_shorten_unambiguous_ref(
2377 + get_main_ref_store(the_repository), full_ref, 0);
2378 +
2379 + is_upstream = upstream_ref && !strcmp(full_ref, upstream_ref);
2380 + is_push = push_ref && !strcmp(full_ref, push_ref);
2381 +
2382 + if (is_upstream && (!push_ref || !strcmp(upstream_ref, push_ref)))
2383 + is_push = 1;
2384 +
2385 + cmp = stat_branch_pair(branch->refname, full_ref,
2386 + &ours, &theirs, abf);
2387 +
2388 + if (cmp < 0) {
2389 + if (is_upstream) {
2390 + strbuf_addf(sb,
2391 + _("Your branch is based on '%s', but the upstream is gone.\n"),
2392 + short_ref);
2393 + if (advice_enabled(ADVICE_STATUS_HINTS))
2394 + strbuf_addstr(sb,
2395 + _(" (use \"git branch --unset-upstream\" to fixup)\n"));
2396 + reported = 1;
2397 + }
2398 + free(full_ref);
2399 + free(short_ref);
2400 + continue;
2401 + }
2402 +
2403 + if (reported)
2404 + strbuf_addstr(sb, "\n");
2405 +
2406 + if (is_upstream)
2407 + flags |= ENABLE_ADVICE_PULL;
2408 + if (is_push)
2409 + flags |= ENABLE_ADVICE_PUSH;
2410 + if (show_divergence_advice && is_upstream)
2411 + flags |= ENABLE_ADVICE_DIVERGENCE;
2412 + format_branch_comparison(sb, !cmp, ours, theirs, short_ref,
2413 + abf, flags);
2414 + reported = 1;
2415 +
2416 + free(full_ref);
2417 + free(short_ref);
2418 }
2419
2332 - free(base);
2333 - return 1;
2420 + string_list_clear(&branches, 0);
2421 + strset_clear(&processed_refs);
2422 + free(compare_branches);
2423 + return reported;
2424 }
2425
2426 static int one_local_ref(const struct reference *ref, void *cb_data)
t/t6040-tracking-info.sh
+310
@@ -292,4 +292,314 @@ test_expect_success '--set-upstream-to @{-1}' '
292 test_cmp expect actual
293 '
294
295 +test_expect_success 'status tracking origin/main shows only main' '
296 + (
297 + cd test &&
298 + git checkout b4 &&
299 + git status >../actual
300 + ) &&
301 + cat >expect <<-EOF &&
302 + On branch b4
303 + Your branch is ahead of ${SQ}origin/main${SQ} by 2 commits.
304 + (use "git push" to publish your local commits)
305 +
306 + nothing to commit, working tree clean
307 + EOF
308 + test_cmp expect actual
309 +'
310 +
311 +test_expect_success 'status --no-ahead-behind tracking origin/main shows only main' '
312 + (
313 + cd test &&
314 + git checkout b4 &&
315 + git status --no-ahead-behind >../actual
316 + ) &&
317 + cat >expect <<-EOF &&
318 + On branch b4
319 + Your branch and ${SQ}origin/main${SQ} refer to different commits.
320 + (use "git status --ahead-behind" for details)
321 +
322 + nothing to commit, working tree clean
323 + EOF
324 + test_cmp expect actual
325 +'
326 +
327 +test_expect_success 'status.compareBranches from upstream has no duplicates' '
328 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
329 + git -C test checkout main &&
330 + git -C test status >actual &&
331 + cat >expect <<-EOF &&
332 + On branch main
333 + Your branch is up to date with ${SQ}origin/main${SQ}.
334 +
335 + nothing to commit, working tree clean
336 + EOF
337 + test_cmp expect actual
338 +'
339 +
340 +test_expect_success 'status.compareBranches shows ahead of both upstream and push branch' '
341 + test_config -C test push.default current &&
342 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
343 + git -C test checkout -b feature2 origin/main &&
344 + git -C test push origin HEAD &&
345 + (cd test && advance work) &&
346 + git -C test status >actual &&
347 + cat >expect <<-EOF &&
348 + On branch feature2
349 + Your branch is ahead of ${SQ}origin/main${SQ} by 1 commit.
350 +
351 + Your branch is ahead of ${SQ}origin/feature2${SQ} by 1 commit.
352 + (use "git push" to publish your local commits)
353 +
354 + nothing to commit, working tree clean
355 + EOF
356 + test_cmp expect actual
357 +'
358 +
359 +test_expect_success 'checkout with status.compareBranches shows both branches' '
360 + test_config -C test push.default current &&
361 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
362 + git -C test checkout feature2 >actual &&
363 + cat >expect <<-EOF &&
364 + Your branch is ahead of ${SQ}origin/main${SQ} by 1 commit.
365 +
366 + Your branch is ahead of ${SQ}origin/feature2${SQ} by 1 commit.
367 + (use "git push" to publish your local commits)
368 + EOF
369 + test_cmp expect actual
370 +'
371 +
372 +test_expect_success 'setup for ahead of tracked but diverged from main' '
373 + (
374 + cd test &&
375 + git checkout -b feature4 origin/main &&
376 + advance work1 &&
377 + git checkout origin/main &&
378 + advance work2 &&
379 + git push origin HEAD:main &&
380 + git checkout feature4 &&
381 + advance work3
382 + )
383 +'
384 +
385 +test_expect_success 'status.compareBranches shows diverged and ahead' '
386 + test_config -C test push.default current &&
387 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
388 + git -C test checkout feature4 &&
389 + git -C test branch --set-upstream-to origin/main &&
390 + git -C test push origin HEAD &&
391 + (cd test && advance work) &&
392 + git -C test status >actual &&
393 + cat >expect <<-EOF &&
394 + On branch feature4
395 + Your branch and ${SQ}origin/main${SQ} have diverged,
396 + and have 3 and 1 different commits each, respectively.
397 + (use "git pull" if you want to integrate the remote branch with yours)
398 +
399 + Your branch is ahead of ${SQ}origin/feature4${SQ} by 1 commit.
400 + (use "git push" to publish your local commits)
401 +
402 + nothing to commit, working tree clean
403 + EOF
404 + test_cmp expect actual
405 +'
406 +
407 +test_expect_success 'status --no-ahead-behind with status.compareBranches' '
408 + test_config -C test push.default current &&
409 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
410 + git -C test checkout feature4 &&
411 + git -C test status --no-ahead-behind >actual &&
412 + cat >expect <<-EOF &&
413 + On branch feature4
414 + Your branch and ${SQ}origin/main${SQ} refer to different commits.
415 +
416 + Your branch and ${SQ}origin/feature4${SQ} refer to different commits.
417 + (use "git status --ahead-behind" for details)
418 +
419 + nothing to commit, working tree clean
420 + EOF
421 + test_cmp expect actual
422 +'
423 +
424 +test_expect_success 'setup upstream remote' '
425 + (
426 + cd test &&
427 + git remote add upstream ../. &&
428 + git fetch upstream
429 + )
430 +'
431 +
432 +test_expect_success 'status.compareBranches with upstream and origin remotes' '
433 + test_config -C test push.default current &&
434 + test_config -C test remote.pushDefault origin &&
435 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
436 + git -C test checkout -b feature5 upstream/main &&
437 + git -C test push origin &&
438 + (cd test && advance work) &&
439 + git -C test status >actual &&
440 + cat >expect <<-EOF &&
441 + On branch feature5
442 + Your branch is ahead of ${SQ}upstream/main${SQ} by 1 commit.
443 +
444 + Your branch is ahead of ${SQ}origin/feature5${SQ} by 1 commit.
445 + (use "git push" to publish your local commits)
446 +
447 + nothing to commit, working tree clean
448 + EOF
449 + test_cmp expect actual
450 +'
451 +
452 +test_expect_success 'status.compareBranches supports ordered upstream/push entries' '
453 + test_config -C test push.default current &&
454 + test_config -C test remote.pushDefault origin &&
455 + test_config -C test status.compareBranches "@{push} @{upstream}" &&
456 + git -C test checkout -b feature6 upstream/main &&
457 + git -C test push origin &&
458 + (cd test && advance work) &&
459 + git -C test status >actual &&
460 + cat >expect <<-EOF &&
461 + On branch feature6
462 + Your branch is ahead of ${SQ}origin/feature6${SQ} by 1 commit.
463 + (use "git push" to publish your local commits)
464 +
465 + Your branch is ahead of ${SQ}upstream/main${SQ} by 1 commit.
466 +
467 + nothing to commit, working tree clean
468 + EOF
469 + test_cmp expect actual
470 +'
471 +
472 +test_expect_success 'status.compareBranches with diverged push branch' '
473 + test_config -C test push.default current &&
474 + test_config -C test remote.pushDefault origin &&
475 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
476 + git -C test checkout -b feature7 upstream/main &&
477 + (cd test && advance work71) &&
478 + git -C test push origin &&
479 + git -C test reset --hard upstream/main &&
480 + (cd test && advance work72) &&
481 + git -C test status >actual &&
482 + cat >expect <<-EOF &&
483 + On branch feature7
484 + Your branch is ahead of ${SQ}upstream/main${SQ} by 1 commit.
485 +
486 + Your branch and ${SQ}origin/feature7${SQ} have diverged,
487 + and have 1 and 1 different commits each, respectively.
488 +
489 + nothing to commit, working tree clean
490 + EOF
491 + test_cmp expect actual
492 +'
493 +
494 +test_expect_success 'status.compareBranches shows up to date branches' '
495 + test_config -C test push.default current &&
496 + test_config -C test remote.pushDefault origin &&
497 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
498 + git -C test checkout -b feature8 upstream/main &&
499 + git -C test push origin &&
500 + git -C test status >actual &&
501 + cat >expect <<-EOF &&
502 + On branch feature8
503 + Your branch is up to date with ${SQ}upstream/main${SQ}.
504 +
505 + Your branch is up to date with ${SQ}origin/feature8${SQ}.
506 +
507 + nothing to commit, working tree clean
508 + EOF
509 + test_cmp expect actual
510 +'
511 +
512 +test_expect_success 'status --no-ahead-behind with status.compareBranches up to date' '
513 + test_config -C test push.default current &&
514 + test_config -C test remote.pushDefault origin &&
515 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
516 + git -C test checkout feature8 >actual &&
517 + git -C test push origin &&
518 + git -C test status --no-ahead-behind >actual &&
519 + cat >expect <<-EOF &&
520 + On branch feature8
521 + Your branch is up to date with ${SQ}upstream/main${SQ}.
522 +
523 + Your branch is up to date with ${SQ}origin/feature8${SQ}.
524 +
525 + nothing to commit, working tree clean
526 + EOF
527 + test_cmp expect actual
528 +'
529 +
530 +test_expect_success 'checkout with status.compareBranches shows up to date' '
531 + test_config -C test push.default current &&
532 + test_config -C test remote.pushDefault origin &&
533 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
534 + git -C test checkout feature8 >actual &&
535 + cat >expect <<-EOF &&
536 + Your branch is up to date with ${SQ}upstream/main${SQ}.
537 +
538 + Your branch is up to date with ${SQ}origin/feature8${SQ}.
539 + EOF
540 + test_cmp expect actual
541 +'
542 +
543 +test_expect_success 'status.compareBranches with upstream behind and push up to date' '
544 + test_config -C test push.default current &&
545 + test_config -C test remote.pushDefault origin &&
546 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
547 + git -C test checkout -b ahead upstream/main &&
548 + (cd test && advance work) &&
549 + git -C test push upstream HEAD &&
550 + git -C test checkout -b feature9 upstream/main &&
551 + git -C test push origin &&
552 + git -C test branch --set-upstream-to upstream/ahead &&
553 + git -C test status >actual &&
554 + cat >expect <<-EOF &&
555 + On branch feature9
556 + Your branch is behind ${SQ}upstream/ahead${SQ} by 1 commit, and can be fast-forwarded.
557 + (use "git pull" to update your local branch)
558 +
559 + Your branch is up to date with ${SQ}origin/feature9${SQ}.
560 +
561 + nothing to commit, working tree clean
562 + EOF
563 + test_cmp expect actual
564 +'
565 +
566 +test_expect_success 'status.compareBranches with remapped push refspec' '
567 + test_config -C test remote.origin.push refs/heads/feature10:refs/heads/remapped &&
568 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
569 + git -C test checkout -b feature10 origin/main &&
570 + git -C test push &&
571 + (cd test && advance work) &&
572 + git -C test status >actual &&
573 + cat >expect <<-EOF &&
574 + On branch feature10
575 + Your branch is ahead of ${SQ}origin/main${SQ} by 1 commit.
576 +
577 + Your branch is ahead of ${SQ}origin/remapped${SQ} by 1 commit.
578 + (use "git push" to publish your local commits)
579 +
580 + nothing to commit, working tree clean
581 + EOF
582 + test_cmp expect actual
583 +'
584 +
585 +test_expect_success 'status.compareBranches with remapped push and upstream remote' '
586 + test_config -C test remote.pushDefault origin &&
587 + test_config -C test remote.origin.push refs/heads/feature11:refs/heads/remapped &&
588 + test_config -C test status.compareBranches "@{upstream} @{push}" &&
589 + git -C test checkout -b feature11 upstream/main &&
590 + git -C test push origin &&
591 + (cd test && advance work) &&
592 + git -C test status >actual &&
593 + cat >expect <<-EOF &&
594 + On branch feature11
595 + Your branch is ahead of ${SQ}upstream/main${SQ} by 1 commit.
596 +
597 + Your branch is ahead of ${SQ}origin/remapped${SQ} by 1 commit.
598 + (use "git push" to publish your local commits)
599 +
600 + nothing to commit, working tree clean
601 + EOF
602 + test_cmp expect actual
603 +'
604 +
605 test_done