ADR 0006 — `finish` always refuses to run from the default branch

  • Status: Accepted
  • Amended: 2026-07-17 by ADR 0031 — commit joins finish in refusing the default branch (per-call --allow-default-branch override), closing the branch-commit-finish loop at both ends.
  • Date: 2026-06-24
  • Tracking: legacy tracker 1621 (formalizes a rule locked on legacy tracker 1601).

Context

madtea finish completes a feature branch: it commits, pushes, opens a PR, merges it, and returns to the default branch. Running it from the default branch is a category error — there is no feature branch to merge, and the only coherent interpretations (open a PR from the default onto itself; push the default with no review) are exactly the unguarded paths finish exists to avoid.

So finish refuses to run from the default branch. There is a single, narrow exception: a brand-new empty repository has no history and no real default branch yet, so being “on the default branch” there is really the very first push. In that one case finish treats the operation as the initial push (commit

  • push directly, no PR) rather than refusing.

This is a deliberate, now-explicitly-LOCKED decision (Decided 2026-06-24, legacy tracker 1601: “we should ALWAYS refuse default branch, whatever that happens to be”). “Whatever that happens to be” matters: the rule keys off the resolved default branch, not a hardcoded main/master. A repo whose default was renamed, or one that uses a non-standard default, is refused on its default — because finish resolves the default authoritatively from the forge API (legacy tracker 1472) before the check, not from a stale local guess.

Two gaps prompted this ADR:

  • The rule was only described in docs/architecture/finish-workflow.md (Stage 1), not recorded as a numbered ADR — so the ADR-of-record (docs/architecture/technical-choices.md) did not front-door it, and an investigation during the legacy tracker 1601 reconciliation nearly re-flagged the refusal as a bug.
  • Its “guard,” TestRunFinishBlocksDefaultBranchWithHistory, was vacuous: it asserted a hand-written string literal contained a substring. It never called the real finish code and never set up a default-branch state — it would have passed even if the branch == defaultBranch guard were deleted.

A locked decision with neither an ADR nor a real CI guard is one refactor away from silent regression. This ADR records the rule; legacy tracker 1621 also replaces the vacuous test with one that actually exercises the guard.

Decision

madtea finish always refuses to run from the repository’s default branch — except on an empty repository, where it treats the operation as the initial push.

The default branch is resolved authoritatively (forge API first, local guess only as an offline fallback), so the rule holds for whatever the default happens to be — including a renamed or non-standard default.

The decision lives in newFinishRun (internal/service/workflow/finish.go):

if branch == defaultBranch {
    if isEmptyRepo() {
        // empty repo → initial push, no PR
        return runInitialFinish(...)
    }
    return fmt.Errorf("cannot finish from %s branch - switch to a feature branch first", branch)
}

The single exception

StateBehaviour
On the default branch, non-empty repoRefuse — “cannot finish from <default> branch”.
On the default branch, empty repoTreat as the initial push (commit + push directly, no PR).

The empty-repo carve-out is the only exception. It is not a softening of the rule — there is no feature branch to refuse in favour of yet, so the operation is unambiguously a first push.

A real guard

The rule is locked by a behavioral test that drives the actual decision via newFinishRun’s var-bound seams (branchExistsFn / remoteBranchExistsFn, and the default-branch resolver seam) so no live repo is needed. The test asserts:

  • on the default branch of a non-empty repo, finish returns the refusal error — and would FAIL if the branch == defaultBranch guard were removed or weakened; and
  • on the empty-repo default-branch case, finish takes the initial-push path instead of refusing.

Consequences

  • The rule is front-doored in docs/architecture/technical-choices.md and the ADR index, so a future investigation finds the decision instead of re-deriving (or re-flagging) it.
  • The replacement test in internal/service/workflow is a structural guard: delete the guard, the test goes red. The vacuous string-literal test is gone.
  • This ADR formalizes an existing, already-decided rule — it does not fork or change behaviour, so there is no prior ADR to reconcile against.

Alternatives considered

  • Refuse only on main/master (hardcoded names). Rejected: it misses a renamed or non-standard default — exactly the case legacy tracker 1472 made finish resolve authoritatively. The rule keys off the resolved default, whatever it is.
  • Allow finish from the default with a warning. Rejected: the only thing finish could do from the default is the unguarded path it exists to prevent. A refusal with a clear “switch to a feature branch” message is the correct, non-surprising behaviour.
  • No empty-repo exception (always refuse). Rejected: a brand-new repo’s first commit legitimately sits on the default branch; refusing it would force a pointless throwaway feature branch for the very first push.

Canonical source: docs/adr/0006-finish-refuses-default-branch.md in the madtea repo.