Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions decisions/0097-force-push-behind-remote.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Allow --force to override "behind remote" skip

Date: 2026-03-26

## Context

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()`.

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.

## Options

### Keep the hard skip, require `arb pull` first
Leave the behind-remote skip unconditional. Users must pull (incorporating the remote commits) before they can push.
- **Pros:** Prevents accidental loss of remote-only commits.
- **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`.

### Allow `--force` to override the behind-remote skip
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).
- **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.
- **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.

## Decision

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`.

## Reasoning

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.

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.

## Consequences

- `arb push --force` now works for repos that are behind remote, overwriting remote-only commits.
- The plan display shows "force push (overwrite N on origin)" for this case, clearly communicating the consequence.
- Without `--force`, the behavior is unchanged — behind-remote repos are still skipped with "pull first?".
- The `--force-with-lease` protection applies, preventing accidental overwrites if the remote changes between fetch and push.
2 changes: 1 addition & 1 deletion docs/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ arb pull --merge # pull with merge commit
arb push
```

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.
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.

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.

Expand Down
49 changes: 49 additions & 0 deletions src/commands/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,27 @@ describe("assessPushRepo", () => {
expect(a.skipFlag).toBe("behind-remote");
});

test("will-force-push when behind remote with --force", () => {
const a = assessPushRepo(
makeRepo({
share: {
remote: "origin",
ref: "origin/feature",
refMode: "configured",
toPush: 0,
toPull: 3,
},
}),
DIR,
"feature",
SHA,
{ force: true },
);
expect(a.outcome).toBe("will-force-push");
expect(a.ahead).toBe(0);
expect(a.behind).toBe(3);
});

test("will-force-push when diverged", () => {
const a = assessPushRepo(
makeRepo({
Expand Down Expand Up @@ -880,6 +901,16 @@ describe("formatPushPlan", () => {
expect(plan).not.toContain("new");
});

test("shows overwrite text for force push with ahead=0", () => {
const plan = formatPushPlan(
[makeAssessment({ outcome: "will-force-push", ahead: 0, behind: 3 })],
makeRemotesMap(["repo-a", {}]),
);
expect(plan).toContain("force push");
expect(plan).toContain("overwrite 3 on origin");
expect(plan).not.toContain("0 commit");
});

test("shows force with behind count when no base info and not rebased", () => {
const plan = formatPushPlan(
[makeAssessment({ outcome: "will-force-push", ahead: 3, behind: 2, rebased: 0 })],
Expand Down Expand Up @@ -1278,6 +1309,14 @@ describe("applyForcePushPolicy", () => {
const nextAssessments = applyForcePushPolicy(assessments, false);
expect(nextAssessments[0]?.skipReason).toContain("3 rebased");
});

test("passes through will-force-push with ahead=0 when force allowed", () => {
const assessments = [makeAssessment({ ahead: 0, behind: 3 })];
const nextAssessments = applyForcePushPolicy(assessments, true);
expect(nextAssessments[0]?.outcome).toBe("will-force-push");
expect(nextAssessments[0]?.ahead).toBe(0);
expect(nextAssessments[0]?.behind).toBe(3);
});
});

describe("pushActionCell", () => {
Expand Down Expand Up @@ -1374,6 +1413,16 @@ describe("pushActionCell", () => {
expect(result.plain).toContain("4 behind base");
});

test("will-force-push with ahead=0 shows overwrite text", () => {
const result = pushActionCell(
makeAssessment({ outcome: "will-force-push", ahead: 0, behind: 3 }),
makeRemotesMap(["repo-a", {}]),
);
expect(result.plain).toContain("force push");
expect(result.plain).toContain("overwrite 3 on origin");
expect(result.plain).not.toContain("0 commit");
});

test("will-force-push without baseAhead shows generic force text", () => {
const result = pushActionCell(
makeAssessment({ outcome: "will-force-push", ahead: 2, behind: 3 }),
Expand Down
11 changes: 10 additions & 1 deletion src/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ export async function runPush(
: ["push", "-u", a.shareRemote, a.branch];
const pushResult = await gitNetwork(a.repoDir, pushTimeout, pushArgs);
if (pushResult.exitCode === 0) {
inlineResult(a.repo, `pushed ${plural(a.ahead, "commit")}`);
const resultMsg = a.ahead === 0 ? "force pushed (overwrote remote)" : `pushed ${plural(a.ahead, "commit")}`;
inlineResult(a.repo, resultMsg);
pushOk++;
} else {
inlineResult(a.repo, red("failed"));
Expand Down Expand Up @@ -360,6 +361,11 @@ export function pushActionCell(a: PushAssessment, remotesMap: Map<string, RepoRe
result = behindBaseSuffix(result);
if (a.headSha) result = suffix(result, ` (HEAD ${a.headSha})`, "muted");
}
} else if (a.ahead === 0) {
// will-force-push with ahead=0 — overwriting remote (behind-remote + force)
result = cell(`force push (overwrite ${a.behind} on ${a.shareRemote})`);
result = behindBaseSuffix(result);
if (a.headSha) result = suffix(result, ` (HEAD ${a.headSha})`, "muted");
} else if (a.baseAhead > 0 || a.rebased > 0) {
// will-force-push with base info
const fromBase = Math.max(0, a.ahead - a.baseAhead);
Expand Down Expand Up @@ -578,6 +584,9 @@ export function assessPushRepo(
}

if (toPush === 0 && toPull > 0) {
if (options?.force) {
return { ...base, outcome: "will-force-push", ahead: 0, behind: toPull };
}
return {
...base,
outcome: "skip",
Expand Down
40 changes: 40 additions & 0 deletions test/integration/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,46 @@ describe("push [repos...] and --force", () => {
expect(result.output).toContain("uncommitted changes");
expect(result.output).not.toContain("reset");
}));

test("arb push --force overwrites remote when strictly behind", () =>
withEnv(async (env) => {
await arb(env, ["create", "my-feature", "repo-a"]);
const repoA = join(env.projectDir, "my-feature/repo-a");

// Make 1 commit and push
await write(join(repoA, "a.txt"), "a");
await git(repoA, ["add", "a.txt"]);
await git(repoA, ["commit", "-m", "commit a"]);
await git(repoA, ["push", "-u", "origin", "my-feature"]);

// Simulate someone else pushing a new commit to remote
const bare = join(env.originDir, "repo-a.git");
const tmp = join(env.testDir, "tmp-behind");
await git(env.testDir, ["clone", bare, tmp]);
await git(tmp, ["checkout", "my-feature"]);
await write(join(tmp, "extra.txt"), "extra");
await git(tmp, ["add", "extra.txt"]);
await git(tmp, ["commit", "-m", "extra commit"]);
await git(tmp, ["push", "origin", "my-feature"]);
await rm(tmp, { recursive: true });

await fetchAllRepos(env);

// Without --force, should skip as behind remote
const skipResult = await arb(env, ["push", "--yes"], {
cwd: join(env.projectDir, "my-feature"),
});
expect(skipResult.output).toContain("behind origin");
expect(skipResult.output).toContain("pull first");

// With --force, should overwrite remote
const forceResult = await arb(env, ["push", "--force", "--yes"], {
cwd: join(env.projectDir, "my-feature"),
});
expect(forceResult.exitCode).toBe(0);
expect(forceResult.output).toContain("overwrite");
expect(forceResult.output).toContain("Pushed 1 repo");
}));
});

// ── pull [repos...] ─────────────────────────────────────────────
Expand Down
Loading