michal/tit

Browse tree · Show commit · Download archive

Blob: docs/adr/0025-pull-request-merge.md

Raw · Blame

# Architectural decision record 0025: Pull-request merge

Status: Accepted

Date: 2026-07-23

## Context

A pull-request merge changes a Git ref and SQLite metadata. These two stores
cannot use one transaction. A concurrent push can also move the base ref after
the server checks mergeability. A server-created merge commit must not need a
worktree, index, Git executable, or OpenSSH.

## Decision

Support `fast-forward` and `merge-commit` methods. The current pull-request
revision must match the current base and head refs. A fast-forward requires the
base commit to be an ancestor of the head commit. A merge commit requires a
clean three-way merge. Reject unrelated histories, conflicts, already-merged
heads, stale revisions, and a method that does not match the mergeability
state.

Only a repository owner or maintainer can merge in this milestone. Milestone
5.5 will move this rule into the common branch-policy service.

Create a merge commit without a worktree. Use `gix` rename detection and tree
merge code. Keep Git modes and byte paths. Use the base commit as the first
parent and the head commit as the second parent. Use the acting username for
the author and committer names. Use `<username>@users.tit` for both email
fields. Use UTC and the operation time. Use this message format:

```text
Merge pull request #<number> from <head-ref>

<title>
```

First, create the complete merge result in an in-memory object database. This
step gives the deterministic target ID and does not change the bare
repository. Then write the durable Git operation intent. Create the same merge
objects in the bare object database and require the same target ID. The new
objects stay unreachable until the ref transaction succeeds.

The merge intent extends the push intent. It stores the pull request, exact
revision, method, base ref, old target, and new target. The operation uses these
boundaries:

1. Write the intent and its pull-request data in one SQLite transaction.
2. Write the merge objects, if the method needs them.
3. Mark the objects as promoted.
4. Update the base ref only if it still has the expected old target.
5. Complete the Git intent, set the pull request to `merged`, and append the
   push, ref, pull-request, and audit events in one SQLite transaction.

Recovery uses the push intent state and the actual ref. Complete metadata when
the ref has the proposed target. Abandon the operation when its one ref still
has the initial target. A different target means that a concurrent ref update
won before this merge changed the ref, so recovery also abandons the merge.
Delete the pull-request extension for an abandoned intent so a later attempt
can start. Unreachable loose merge objects stay available for later object
maintenance.

## Failure and threat cases

Recheck repository access, the open state, the latest revision, and all stored
object IDs in the intent transaction. Use an expected-old ref update so a
concurrent base change cannot be overwritten. A unique pull-request merge
intent prevents two active merge operations for one pull request.

Do not run a custom merge command from repository content. The merge uses the
server-controlled bare repository configuration. Stop before the intent if the
bounded comparison fails. Abandon the intent if actual merge output is not the
prepared target.

## Evidence

The integration test fast-forwards SHA-1 and SHA-256 repositories. It checks
owner and reader permission, ref state, merged state, intent completion, and
event order. The worktree-free merge test checks SHA-1 and SHA-256, rename and
mode preservation, deterministic output, parent order, attribution, and the
absence of a bare index. Other tests reject conflicts and stale base refs.

The recovery test completes metadata after an interrupted ref update. It also
changes the base after object promotion and verifies that recovery keeps the
concurrent target, abandons the intent, and leaves the pull request open. The
Web integration test submits a fast-forward merge without JavaScript.

## Consequences

Each clean merge runs once in memory and once against the object database. This
cost gives the intent its exact proposed target before persistent object writes.
The base ref and pull-request state cannot disagree after recovery. A later
maintenance milestone must prune unreachable loose objects from abandoned
merge attempts.