Skip to content

Commit db78866

Browse files
henrikjeclaude
andcommitted
feat(push): allow --force to override behind-remote skip
When a repo is strictly behind the remote (toPush=0, toPull>0), arb push now respects --force instead of unconditionally skipping. The push uses --force-with-lease to overwrite remote-only commits, matching user expectations and the command's documented behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 05473e6 commit db78866

5 files changed

Lines changed: 138 additions & 2 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Allow --force to override "behind remote" skip
2+
3+
Date: 2026-03-26
4+
5+
## Context
6+
7+
When a repo is strictly behind the remote (`toPush=0, toPull>0`), `arb push` skips it with "behind origin (pull first?)". This skip is unconditional — `arb push --force` has no effect because the `--force` flag only gates the "diverged" case (`will-force-push` outcome) via `applyForcePushPolicy()`, not the "behind remote" case which is classified as a hard skip in `assessPushRepo()`.
8+
9+
The command's own help text says "Use --force when the remote has genuinely new commits that you want to overwrite", which users naturally expect to cover the behind-remote case. The real-world scenario: a collaborator force-pushed to your branch, or commits were added to the remote that you want to discard — you want to restore the remote to your local state.
10+
11+
## Options
12+
13+
### Keep the hard skip, require `arb pull` first
14+
Leave the behind-remote skip unconditional. Users must pull (incorporating the remote commits) before they can push.
15+
- **Pros:** Prevents accidental loss of remote-only commits.
16+
- **Cons:** Makes `--force` misleading — it promises to override but doesn't. Forces users to pull commits they intend to discard. No workaround within `arb push`.
17+
18+
### Allow `--force` to override the behind-remote skip
19+
When `--force` is passed and the repo is behind remote, classify it as `will-force-push` with `ahead: 0` instead of skipping. The push uses `--force-with-lease` (same as other force pushes).
20+
- **Pros:** Matches user expectations and the command's documented behavior. Consistent with how `--force` works for diverged branches. `--force-with-lease` still protects against concurrent pushes.
21+
- **Cons:** The `ahead: 0` case is unusual — requires special plan display text ("force push (overwrite N on origin)") and result text ("force pushed (overwrote remote)") to avoid confusing "0 commits" messages.
22+
23+
## Decision
24+
25+
Allow `--force` to override the behind-remote skip. Handle it in `assessPushRepo()` at the skip point, matching the existing pattern used by `--include-wrong-branch` and `--include-merged`.
26+
27+
## Reasoning
28+
29+
The fix follows the established pattern: options that override skips are checked at the skip point in `assessPushRepo()`. The alternative — converting skips back to pushable assessments in `applyForcePushPolicy()` — conflates two responsibilities (gating vs. ungating) and would require reconstructing assessment data that the skip discards.
30+
31+
The `--force-with-lease` safety net applies here just as it does for diverged pushes. If someone else pushes between fetch and push, the lease check rejects it.
32+
33+
## Consequences
34+
35+
- `arb push --force` now works for repos that are behind remote, overwriting remote-only commits.
36+
- The plan display shows "force push (overwrite N on origin)" for this case, clearly communicating the consequence.
37+
- Without `--force`, the behavior is unchanged — behind-remote repos are still skipped with "pull first?".
38+
- The `--force-with-lease` protection applies, preventing accidental overwrites if the remote changes between fetch and push.

docs/sync.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ arb pull --merge # pull with merge commit
2929
arb push
3030
```
3131

32-
After a rebase, amend, or squash, `arb push` detects that all remote commits are outdated (already reflected in your local history) and pushes automatically with `--force-with-lease` — no `--force` flag needed. Use `--force` only when the remote has genuinely new commits from someone else.
32+
After a rebase, amend, or squash, `arb push` detects that all remote commits are outdated (already reflected in your local history) and pushes automatically with `--force-with-lease` — no `--force` flag needed. Use `--force` when the remote has genuinely new commits that you want to overwrite — including when your local branch is strictly behind the remote and you want to roll it back to your local state.
3333

3434
When a collaborator force-pushes a rebased branch and you have no unique local commits to preserve, `arb pull --merge` shows a **safe reset** action in the plan and resets to the rewritten remote tip instead of attempting a three-way merge.
3535

src/commands/push.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,27 @@ describe("assessPushRepo", () => {
394394
expect(a.skipFlag).toBe("behind-remote");
395395
});
396396

397+
test("will-force-push when behind remote with --force", () => {
398+
const a = assessPushRepo(
399+
makeRepo({
400+
share: {
401+
remote: "origin",
402+
ref: "origin/feature",
403+
refMode: "configured",
404+
toPush: 0,
405+
toPull: 3,
406+
},
407+
}),
408+
DIR,
409+
"feature",
410+
SHA,
411+
{ force: true },
412+
);
413+
expect(a.outcome).toBe("will-force-push");
414+
expect(a.ahead).toBe(0);
415+
expect(a.behind).toBe(3);
416+
});
417+
397418
test("will-force-push when diverged", () => {
398419
const a = assessPushRepo(
399420
makeRepo({
@@ -880,6 +901,16 @@ describe("formatPushPlan", () => {
880901
expect(plan).not.toContain("new");
881902
});
882903

904+
test("shows overwrite text for force push with ahead=0", () => {
905+
const plan = formatPushPlan(
906+
[makeAssessment({ outcome: "will-force-push", ahead: 0, behind: 3 })],
907+
makeRemotesMap(["repo-a", {}]),
908+
);
909+
expect(plan).toContain("force push");
910+
expect(plan).toContain("overwrite 3 on origin");
911+
expect(plan).not.toContain("0 commit");
912+
});
913+
883914
test("shows force with behind count when no base info and not rebased", () => {
884915
const plan = formatPushPlan(
885916
[makeAssessment({ outcome: "will-force-push", ahead: 3, behind: 2, rebased: 0 })],
@@ -1278,6 +1309,14 @@ describe("applyForcePushPolicy", () => {
12781309
const nextAssessments = applyForcePushPolicy(assessments, false);
12791310
expect(nextAssessments[0]?.skipReason).toContain("3 rebased");
12801311
});
1312+
1313+
test("passes through will-force-push with ahead=0 when force allowed", () => {
1314+
const assessments = [makeAssessment({ ahead: 0, behind: 3 })];
1315+
const nextAssessments = applyForcePushPolicy(assessments, true);
1316+
expect(nextAssessments[0]?.outcome).toBe("will-force-push");
1317+
expect(nextAssessments[0]?.ahead).toBe(0);
1318+
expect(nextAssessments[0]?.behind).toBe(3);
1319+
});
12811320
});
12821321

12831322
describe("pushActionCell", () => {
@@ -1374,6 +1413,16 @@ describe("pushActionCell", () => {
13741413
expect(result.plain).toContain("4 behind base");
13751414
});
13761415

1416+
test("will-force-push with ahead=0 shows overwrite text", () => {
1417+
const result = pushActionCell(
1418+
makeAssessment({ outcome: "will-force-push", ahead: 0, behind: 3 }),
1419+
makeRemotesMap(["repo-a", {}]),
1420+
);
1421+
expect(result.plain).toContain("force push");
1422+
expect(result.plain).toContain("overwrite 3 on origin");
1423+
expect(result.plain).not.toContain("0 commit");
1424+
});
1425+
13771426
test("will-force-push without baseAhead shows generic force text", () => {
13781427
const result = pushActionCell(
13791428
makeAssessment({ outcome: "will-force-push", ahead: 2, behind: 3 }),

src/commands/push.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ export async function runPush(
158158
: ["push", "-u", a.shareRemote, a.branch];
159159
const pushResult = await gitNetwork(a.repoDir, pushTimeout, pushArgs);
160160
if (pushResult.exitCode === 0) {
161-
inlineResult(a.repo, `pushed ${plural(a.ahead, "commit")}`);
161+
const resultMsg = a.ahead === 0 ? "force pushed (overwrote remote)" : `pushed ${plural(a.ahead, "commit")}`;
162+
inlineResult(a.repo, resultMsg);
162163
pushOk++;
163164
} else {
164165
inlineResult(a.repo, red("failed"));
@@ -360,6 +361,11 @@ export function pushActionCell(a: PushAssessment, remotesMap: Map<string, RepoRe
360361
result = behindBaseSuffix(result);
361362
if (a.headSha) result = suffix(result, ` (HEAD ${a.headSha})`, "muted");
362363
}
364+
} else if (a.ahead === 0) {
365+
// will-force-push with ahead=0 — overwriting remote (behind-remote + force)
366+
result = cell(`force push (overwrite ${a.behind} on ${a.shareRemote})`);
367+
result = behindBaseSuffix(result);
368+
if (a.headSha) result = suffix(result, ` (HEAD ${a.headSha})`, "muted");
363369
} else if (a.baseAhead > 0 || a.rebased > 0) {
364370
// will-force-push with base info
365371
const fromBase = Math.max(0, a.ahead - a.baseAhead);
@@ -578,6 +584,9 @@ export function assessPushRepo(
578584
}
579585

580586
if (toPush === 0 && toPull > 0) {
587+
if (options?.force) {
588+
return { ...base, outcome: "will-force-push", ahead: 0, behind: toPull };
589+
}
581590
return {
582591
...base,
583592
outcome: "skip",

test/integration/sync.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,46 @@ describe("push [repos...] and --force", () => {
840840
expect(result.output).toContain("uncommitted changes");
841841
expect(result.output).not.toContain("reset");
842842
}));
843+
844+
test("arb push --force overwrites remote when strictly behind", () =>
845+
withEnv(async (env) => {
846+
await arb(env, ["create", "my-feature", "repo-a"]);
847+
const repoA = join(env.projectDir, "my-feature/repo-a");
848+
849+
// Make 1 commit and push
850+
await write(join(repoA, "a.txt"), "a");
851+
await git(repoA, ["add", "a.txt"]);
852+
await git(repoA, ["commit", "-m", "commit a"]);
853+
await git(repoA, ["push", "-u", "origin", "my-feature"]);
854+
855+
// Simulate someone else pushing a new commit to remote
856+
const bare = join(env.originDir, "repo-a.git");
857+
const tmp = join(env.testDir, "tmp-behind");
858+
await git(env.testDir, ["clone", bare, tmp]);
859+
await git(tmp, ["checkout", "my-feature"]);
860+
await write(join(tmp, "extra.txt"), "extra");
861+
await git(tmp, ["add", "extra.txt"]);
862+
await git(tmp, ["commit", "-m", "extra commit"]);
863+
await git(tmp, ["push", "origin", "my-feature"]);
864+
await rm(tmp, { recursive: true });
865+
866+
await fetchAllRepos(env);
867+
868+
// Without --force, should skip as behind remote
869+
const skipResult = await arb(env, ["push", "--yes"], {
870+
cwd: join(env.projectDir, "my-feature"),
871+
});
872+
expect(skipResult.output).toContain("behind origin");
873+
expect(skipResult.output).toContain("pull first");
874+
875+
// With --force, should overwrite remote
876+
const forceResult = await arb(env, ["push", "--force", "--yes"], {
877+
cwd: join(env.projectDir, "my-feature"),
878+
});
879+
expect(forceResult.exitCode).toBe(0);
880+
expect(forceResult.output).toContain("overwrite");
881+
expect(forceResult.output).toContain("Pushed 1 repo");
882+
}));
843883
});
844884

845885
// ── pull [repos...] ─────────────────────────────────────────────

0 commit comments

Comments
 (0)