michal/tit

Browse tree · Show commit · Download archive

Diff

dc89d0387aff953f3b3e5ad6

README.md

Mode 100644100644; object 511acedc90aa01c7c99308a6

@@ -419,3 +419,19 @@
 schema migration. Read the
 [pull-request review architectural decision record](docs/adr/0024-pull-request-review.md)
 for the anchor, permission, event, and outdated-state contracts.
+
+## Milestone 5.4 gate
+
+Run the pull-request merge gate:
+
+```text
+./scripts/check-m5-4
+```
+
+This command tests fast-forward and server-created merge commits, SHA-1 and
+SHA-256 repositories, rename and mode preservation, deterministic parents,
+attribution, conflict and stale-ref rejection, intent recovery, concurrent base
+updates, atomic events, the no-JavaScript Web form, and historical schema
+migration. Read the
+[pull-request merge architectural decision record](docs/adr/0025-pull-request-merge.md)
+for the merge, permission, intent, and recovery contracts.

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

Mode 100644; object 27c07cb2824c

@@ -1,0 +1,94 @@
+# 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.

scripts/check-m5-4

Mode 100755; object 305f497f83a6

@@ -1,0 +1,7 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test pull_requests
+cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript
+cargo test --locked --release --test sqlite migrates_each_committed_historical_fixture

src/feed.rs

Mode 100644100644; object 6083ae4415b4d6e892eb73c5

@@ -300,6 +300,7 @@
         "pull-request-line-commented" => pull_request_title(event, "commented on a line in"),
         "pull-request-approved" => pull_request_title(event, "approved"),
         "pull-request-changes-requested" => pull_request_title(event, "requested changes on"),
+        "pull-request-merged" => pull_request_title(event, "merged"),
         _ => "Repository event".to_owned(),
     }
 }

src/git/receive_pack.rs

Mode 100644100644; object df36e3ecf8ffefde28323e2a

@@ -442,10 +442,17 @@
         ("pending", true, false) | ("pending", true, true) => {
             store.abandon_git_intent(&intent.id)?;
         }
+        ("pending", false, false) if initial.len() == 1 => {
+            store.abandon_git_intent(&intent.id)?;
+        }
         ("promoted", false, true) => {
             store.complete_git_intent(&intent.id)?;
         }
         ("promoted", true, false) | ("promoted", true, true) => {
+            remove_promoted_pack(repository_path, intent.pack_name.as_deref())?;
+            store.abandon_git_intent(&intent.id)?;
+        }
+        ("promoted", false, false) if initial.len() == 1 => {
             remove_promoted_pack(repository_path, intent.pack_name.as_deref())?;
             store.abandon_git_intent(&intent.id)?;
         }

src/git/repository.rs

Mode 100644100644; object 4295f119092fadb8b5fff196

@@ -3,7 +3,7 @@
 use std::path::{Path, PathBuf};
 
 use gix::hash::{Kind, ObjectId};
-use gix::objs::{Data, Kind as ObjectKind, tree::EntryKind};
+use gix::objs::{Commit, Data, Kind as ObjectKind, tree::EntryKind};
 use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
 use gix::refs::{FullName, Target};
 use gix_pack::data::Version;
@@ -169,6 +169,16 @@
         expected: Option<ObjectId>,
         new: ObjectId,
     ) -> Result<(), GitRepositoryError> {
+        self.update_reference_with_log(name, expected, new, "pull request revision")
+    }
+
+    pub(crate) fn update_reference_with_log(
+        &self,
+        name: &str,
+        expected: Option<ObjectId>,
+        new: ObjectId,
+        message: &str,
+    ) -> Result<(), GitRepositoryError> {
         if new.kind() != self.object_format() {
             return Err(GitRepositoryError::WrongObjectFormat);
         }
@@ -185,7 +195,7 @@
                 log: LogChange {
                     mode: RefLog::AndReference,
                     force_create_reflog: false,
-                    message: "pull request revision".into(),
+                    message: message.into(),
                 },
             },
         };
@@ -193,6 +203,31 @@
             .edit_references_as([edit], None)
             .map_err(|error| GitRepositoryError::RefTransaction(error.to_string()))?;
         Ok(())
+    }
+
+    pub(crate) fn prepare_merge_commit(
+        &self,
+        base: ObjectId,
+        head: ObjectId,
+        actor: &str,
+        created_at: i64,
+        message: &str,
+    ) -> Result<ObjectId, GitRepositoryError> {
+        let repository = gix::open(self.repository.path())
+            .map_err(|error| GitRepositoryError::Merge(error.to_string()))?
+            .with_object_memory();
+        write_merge_commit(&repository, base, head, actor, created_at, message)
+    }
+
+    pub(crate) fn write_merge_commit(
+        &self,
+        base: ObjectId,
+        head: ObjectId,
+        actor: &str,
+        created_at: i64,
+        message: &str,
+    ) -> Result<ObjectId, GitRepositoryError> {
+        write_merge_commit(&self.repository, base, head, actor, created_at, message)
     }
 
     pub(crate) fn make_pack(
@@ -354,6 +389,60 @@
     }
 }
 
+fn write_merge_commit(
+    repository: &gix::Repository,
+    base: ObjectId,
+    head: ObjectId,
+    actor: &str,
+    created_at: i64,
+    message: &str,
+) -> Result<ObjectId, GitRepositoryError> {
+    if base.kind() != repository.object_hash() || head.kind() != repository.object_hash() {
+        return Err(GitRepositoryError::WrongObjectFormat);
+    }
+    let options = repository
+        .tree_merge_options()
+        .map_err(|error| GitRepositoryError::Merge(error.to_string()))?
+        .with_rewrites(Some(Default::default()))
+        .with_fail_on_conflict(Some(Default::default()));
+    let mut outcome = repository
+        .merge_commits(base, head, Default::default(), options.into())
+        .map_err(|error| GitRepositoryError::Merge(error.to_string()))?;
+    if outcome
+        .tree_merge
+        .has_unresolved_conflicts(Default::default())
+    {
+        return Err(GitRepositoryError::MergeConflict);
+    }
+    let tree = outcome
+        .tree_merge
+        .tree
+        .write()
+        .map_err(|error| GitRepositoryError::Merge(error.to_string()))?
+        .detach();
+    let signature = gix::actor::Signature {
+        name: actor.into(),
+        email: format!("{actor}@users.tit").into(),
+        time: gix::date::Time {
+            seconds: created_at,
+            offset: 0,
+        },
+    };
+    let commit = Commit {
+        message: message.into(),
+        tree,
+        author: signature.clone(),
+        committer: signature,
+        encoding: None,
+        parents: [base, head].into_iter().collect(),
+        extra_headers: Default::default(),
+    };
+    repository
+        .write_object(&commit)
+        .map(gix::Id::detach)
+        .map_err(|error| GitRepositoryError::Merge(error.to_string()))
+}
+
 #[derive(Debug, Error)]
 pub(crate) enum GitRepositoryError {
     #[error("cannot create Git repository {path}: {reason}")]
@@ -374,6 +463,10 @@
     BranchNotCommit,
     #[error("cannot update Git references: {0}")]
     RefTransaction(String),
+    #[error("cannot create a Git merge commit: {0}")]
+    Merge(String),
+    #[error("Git merge has conflicts")]
+    MergeConflict,
     #[error("cannot read Git object {id}: {reason}")]
     Object { id: ObjectId, reason: String },
     #[error("Git object does not exist: {0}")]

src/http/pull_requests.rs

Mode 100644100644; object 7caddf3b99d27258de976227

@@ -36,6 +36,10 @@
             "/{owner}/{repository}/pulls/{number}/reviews",
             post(create_review),
         )
+        .route(
+            "/{owner}/{repository}/pulls/{number}/merge",
+            post(merge_pull_request),
+        )
         .layer(DefaultBodyLimit::max(MAX_PULL_REQUEST_BYTES))
 }
 
@@ -162,10 +166,45 @@
                     can_review: detail.can_review
                         && !csrf.is_empty()
                         && result.revision.number == pull_request_revision(detail),
+                    can_merge: detail.can_merge
+                        && !csrf.is_empty()
+                        && result.revision.number == pull_request_revision(detail),
                 },
             )
         }
         Err(error) => read_error(error, &request_id.0),
+    }
+}
+
+async fn merge_pull_request(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<PullRequestPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "method"]) {
+        Ok(fields) => fields,
+        Err(()) => return bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let Some(service) = state.pull_requests.clone() else {
+        return internal(&request_id.0);
+    };
+    let owner = path.owner.clone();
+    let repository = path.repository.clone();
+    let number = path.number;
+    let result = job(state, move || {
+        service.merge(&owner, &repository, number, &actor, &fields[1])
+    })
+    .await;
+    match result {
+        Ok(_) => redirect(&path.owner, &path.repository, number),
+        Err(error) => mutation_error(error, &request_id.0),
     }
 }
 
@@ -377,11 +416,18 @@
         | PullRequestError::ReviewKind
         | PullRequestError::ReviewBody
         | PullRequestError::ReviewAnchor
+        | PullRequestError::MergeMethod
         | PullRequestError::Store(StoreError::PullRequestRevisionNotFound)
         | PullRequestError::Store(StoreError::PullRequestReviewAnchor)
         | PullRequestError::Git(crate::git::repository::GitRepositoryError::MissingReference(_)) => {
             bad_request(request_id)
         }
+        PullRequestError::StaleRevision | PullRequestError::Mergeability => render_error(
+            StatusCode::CONFLICT,
+            request_id,
+            "Pull-request conflict",
+            "The pull request cannot be merged in its current state.",
+        ),
         _ => internal(request_id),
     }
 }
@@ -490,6 +536,7 @@
     csrf: &'a str,
     can_revise: bool,
     can_review: bool,
+    can_merge: bool,
 }
 
 struct ReviewView<'a> {

src/pull_request.rs

Mode 100644100644; object e1fb41428065be33243e7d57

@@ -14,8 +14,9 @@
 };
 use crate::git::repository::{GitRepository, GitRepositoryError};
 use crate::store::{
-    NewPullRequestRefIntent, NewPullRequestReview, PullRequestDetail, PullRequestRecord,
-    PullRequestRefIntentRecord, PullRequestRevisionRecord, Store, StoreError,
+    GitOperationIntent, NewPullRequestMerge, NewPullRequestRefIntent, NewPullRequestReview,
+    PullRequestDetail, PullRequestRecord, PullRequestRefIntentRecord, PullRequestRevisionRecord,
+    Store, StoreError,
 };
 
 pub(crate) const MAX_TITLE_BYTES: usize = 200;
@@ -266,6 +267,9 @@
         self.recover_inner()?;
         let detail =
             Store::open(&self.database)?.pull_request(owner, repository, number, Some(actor))?;
+        if detail.pull_request.state != "open" {
+            return Err(StoreError::PullRequestState.into());
+        }
         let revision = detail
             .revisions
             .iter()
@@ -327,6 +331,128 @@
             .map_err(Into::into)
     }
 
+    pub(crate) fn merge(
+        &self,
+        owner: &str,
+        repository: &str,
+        number: i64,
+        actor: &str,
+        method: &str,
+    ) -> Result<PullRequestRecord, PullRequestError> {
+        validate_context(owner, repository, actor)?;
+        if number < 1 || !matches!(method, "fast-forward" | "merge-commit") {
+            return Err(PullRequestError::MergeMethod);
+        }
+        let _operation = self
+            .operations
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner);
+        self.recover_inner()?;
+        let detail =
+            Store::open(&self.database)?.pull_request(owner, repository, number, Some(actor))?;
+        if detail.pull_request.state != "open" {
+            return Err(StoreError::PullRequestState.into());
+        }
+        if !detail.can_merge {
+            return Err(StoreError::PullRequestDenied.into());
+        }
+        let revision = detail.revisions.last().ok_or(PullRequestError::Revision)?;
+        let path = self.repository_path(&detail.repository.id)?;
+        let git = GitRepository::open(&path)?;
+        let base = git.resolve_branch(&detail.pull_request.base_ref)?;
+        let head = git.resolve_branch(&detail.pull_request.head_ref)?;
+        if base.to_string() != revision.base_object_id
+            || head.to_string() != revision.head_object_id
+        {
+            return Err(PullRequestError::StaleRevision);
+        }
+        let reader = RepositoryReadService::open(&path, ReadLimits::default())?;
+        let comparison = reader.comparison(base, head, &ReadCancellation::default())?;
+        let intent_id = random_id()?;
+        let created_at = timestamp()?;
+        let merge_message = format!(
+            "Merge pull request #{number} from {}\n\n{}",
+            detail.pull_request.head_ref, detail.pull_request.title
+        );
+        let new_target = match (method, comparison.mergeability) {
+            ("fast-forward", crate::git::read::Mergeability::FastForward) => head,
+            ("merge-commit", crate::git::read::Mergeability::Clean) => {
+                git.prepare_merge_commit(base, head, actor, created_at, &merge_message)?
+            }
+            _ => return Err(PullRequestError::Mergeability),
+        };
+        let repository_text = path.to_str().ok_or(PullRequestError::RepositoryPath)?;
+        let quarantine = path.join("objects").join("tit-quarantine").join(&intent_id);
+        let quarantine_text = quarantine
+            .to_str()
+            .ok_or(PullRequestError::RepositoryPath)?;
+        let initial = serialize_ref(base, &detail.pull_request.base_ref);
+        let proposed = serialize_ref(new_target, &detail.pull_request.base_ref);
+        let base_text = base.to_string();
+        let head_text = head.to_string();
+        let new_target_text = new_target.to_string();
+        let mut store = Store::open(&self.database)?;
+        store.begin_pull_request_merge(
+            &NewPullRequestMerge {
+                owner,
+                repository,
+                number,
+                revision: revision.number,
+                actor,
+                method,
+                base_ref: &detail.pull_request.base_ref,
+                old_target: &base_text,
+                head_target: &head_text,
+                new_target: &new_target_text,
+                created_at,
+            },
+            &GitOperationIntent {
+                id: &intent_id,
+                repository_path: repository_text,
+                actor,
+                initial_refs: &initial,
+                proposed_refs: &proposed,
+                event_payload: &proposed,
+                quarantine_path: quarantine_text,
+                created_at,
+            },
+        )?;
+        crash_point("merge-intent");
+        if method == "merge-commit" {
+            match git.write_merge_commit(base, head, actor, created_at, &merge_message) {
+                Ok(written) if written == new_target => {}
+                Ok(_) => {
+                    store.abandon_git_intent(&intent_id)?;
+                    return Err(PullRequestError::NonDeterministicMerge);
+                }
+                Err(error) => {
+                    store.abandon_git_intent(&intent_id)?;
+                    return Err(error.into());
+                }
+            }
+        }
+        store.mark_git_objects_promoted(&intent_id, None)?;
+        crash_point("merge-objects");
+        if let Err(error) = git.update_reference_with_log(
+            &detail.pull_request.base_ref,
+            Some(base),
+            new_target,
+            "merge pull request",
+        ) {
+            match git.reference_target(&detail.pull_request.base_ref)? {
+                Some(current) if current == new_target => store.complete_git_intent(&intent_id)?,
+                _ => store.abandon_git_intent(&intent_id)?,
+            }
+            return Err(error.into());
+        }
+        crash_point("merge-ref");
+        store.complete_git_intent(&intent_id)?;
+        crash_point("merge-completed");
+        Ok(store
+            .pull_request(owner, repository, number, Some(actor))?
+            .pull_request)
+    }
+
     #[allow(
         dead_code,
         reason = "some integration tests compile the service without the Web list route"
@@ -358,6 +484,7 @@
             .operations
             .lock()
             .unwrap_or_else(std::sync::PoisonError::into_inner);
+        crate::git::receive_pack::recover_incomplete_pushes(&self.database)?;
         self.recover_inner()
     }
 
@@ -498,6 +625,10 @@
     format!("refs/pull/{number}/head")
 }
 
+fn serialize_ref(id: ObjectId, name: &str) -> Vec<u8> {
+    format!("{id} {name}\n").into_bytes()
+}
+
 fn parse_id(value: &str) -> Result<ObjectId, PullRequestError> {
     ObjectId::from_hex(value.as_bytes()).map_err(|_| PullRequestError::StoredObjectId)
 }
@@ -564,6 +695,14 @@
     ReviewBody,
     #[error("pull-request review line anchor is not valid")]
     ReviewAnchor,
+    #[error("pull-request merge method is not valid")]
+    MergeMethod,
+    #[error("pull-request revision is not the current branch state")]
+    StaleRevision,
+    #[error("pull request cannot use the requested merge method")]
+    Mergeability,
+    #[error("pull-request merge did not produce a deterministic commit")]
+    NonDeterministicMerge,
     #[error("pull-request refs have not changed")]
     Unchanged,
     #[error("stored pull-request object ID is not valid")]
@@ -576,6 +715,8 @@
     Io(#[from] std::io::Error),
     #[error(transparent)]
     Read(#[from] ReadError),
+    #[error(transparent)]
+    ReceivePack(#[from] crate::git::receive_pack::ReceivePackError),
     #[error("cannot create a random pull-request ID")]
     Random,
     #[error("the system clock is before the Unix epoch")]

src/store/event.rs

Mode 100644100644; object 98afed30bd531fc03dcb5320

@@ -28,6 +28,7 @@
     PullRequestLineCommented,
     PullRequestApproved,
     PullRequestChangesRequested,
+    PullRequestMerged,
 }
 
 impl EventKind {
@@ -57,7 +58,33 @@
             Self::PullRequestLineCommented => "pull-request-line-commented",
             Self::PullRequestApproved => "pull-request-approved",
             Self::PullRequestChangesRequested => "pull-request-changes-requested",
+            Self::PullRequestMerged => "pull-request-merged",
         }
+    }
+}
+
+pub(super) fn pull_request_merge(
+    pull_request_id: &str,
+    number: i64,
+    revision: i64,
+    method: &str,
+    base_ref: &str,
+    old_target: &str,
+    new_target: &str,
+) -> VersionedEvent {
+    VersionedEvent {
+        kind: EventKind::PullRequestMerged,
+        payload: json!({
+            "version": PAYLOAD_VERSION,
+            "pull_request_id": pull_request_id,
+            "number": number,
+            "revision": revision,
+            "method": method,
+            "base_ref": base_ref,
+            "old_target": old_target,
+            "new_target": new_target,
+        })
+        .to_string(),
     }
 }
 

src/store/migrations/017_pull_request_merges.sql

Mode 100644; object 02394689a226

@@ -1,0 +1,121 @@
+CREATE TABLE pull_request_merge_intent (
+    intent_id TEXT PRIMARY KEY
+        REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    pull_request_id TEXT NOT NULL UNIQUE
+        REFERENCES pull_request (id) ON DELETE RESTRICT,
+    revision_id TEXT NOT NULL
+        REFERENCES pull_request_revision (id) ON DELETE RESTRICT,
+    revision_number INTEGER NOT NULL CHECK (revision_number >= 1),
+    method TEXT NOT NULL CHECK (method IN ('fast-forward', 'merge-commit')),
+    base_ref TEXT NOT NULL CHECK (length(CAST(base_ref AS BLOB)) BETWEEN 12 AND 1024),
+    old_target TEXT NOT NULL
+        CHECK (length(old_target) IN (40, 64) AND old_target = lower(old_target)
+               AND old_target NOT GLOB '*[^0-9a-f]*'),
+    new_target TEXT NOT NULL
+        CHECK (length(new_target) IN (40, 64) AND new_target = lower(new_target)
+               AND new_target NOT GLOB '*[^0-9a-f]*'),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+DROP INDEX repository_event_feed;
+DROP INDEX repository_event_issue_timeline;
+DROP INDEX repository_event_pull_request_timeline;
+ALTER TABLE repository_event RENAME TO repository_event_v16;
+
+CREATE TABLE repository_event (
+    id INTEGER PRIMARY KEY,
+    event_id TEXT NOT NULL UNIQUE
+        CHECK (
+            length(event_id) = 32
+            AND event_id = lower(event_id)
+            AND event_id NOT GLOB '*[^0-9a-f]*'
+        ),
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    sequence INTEGER NOT NULL CHECK (sequence >= 1),
+    source_intent_id TEXT
+        REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+    source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
+    issue_id TEXT REFERENCES issue (id) ON DELETE RESTRICT,
+    pull_request_id TEXT REFERENCES pull_request (id) ON DELETE RESTRICT,
+    kind TEXT NOT NULL
+        CHECK (kind IN (
+            'repository-created', 'repository-imported', 'push',
+            'ref-created', 'ref-updated', 'ref-deleted',
+            'tag-created', 'tag-updated', 'tag-deleted',
+            'issue-created', 'issue-edited', 'issue-commented',
+            'issue-closed', 'issue-reopened',
+            'issue-labeled', 'issue-unlabeled',
+            'issue-assigned', 'issue-unassigned',
+            'pull-request-created', 'pull-request-revised',
+            'pull-request-commented', 'pull-request-line-commented',
+            'pull-request-approved', 'pull-request-changes-requested',
+            'pull-request-merged'
+        )),
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+    ref_name BLOB,
+    old_target TEXT,
+    new_target TEXT,
+    payload_version INTEGER NOT NULL CHECK (payload_version = 1),
+    payload TEXT NOT NULL
+        CHECK (
+            length(payload) BETWEEN 1 AND 1048576
+            AND CASE WHEN json_valid(payload) THEN
+                json_type(payload) = 'object'
+                AND coalesce(json_extract(payload, '$.version') = payload_version, 0)
+            ELSE 0 END
+        ),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    UNIQUE (repository_id, sequence),
+    UNIQUE (source_intent_id, source_ordinal),
+    CHECK (
+        (source_intent_id IS NULL AND source_ordinal IS NULL)
+        OR (source_intent_id IS NOT NULL AND source_ordinal IS NOT NULL)
+    ),
+    CHECK (
+        (kind IN ('repository-created', 'repository-imported', 'push')
+            AND issue_id IS NULL AND pull_request_id IS NULL
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+        OR
+        ((kind LIKE 'ref-%' OR kind LIKE 'tag-%')
+            AND issue_id IS NULL AND pull_request_id IS NULL
+            AND ref_name IS NOT NULL
+            AND (old_target IS NOT NULL OR new_target IS NOT NULL))
+        OR
+        (kind LIKE 'issue-%'
+            AND issue_id IS NOT NULL AND pull_request_id IS NULL
+            AND source_intent_id IS NULL AND source_ordinal IS NULL
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+        OR
+        (kind LIKE 'pull-request-%'
+            AND issue_id IS NULL AND pull_request_id IS NOT NULL
+            AND (source_intent_id IS NULL OR kind = 'pull-request-merged')
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+    )
+) STRICT;
+
+INSERT INTO repository_event
+    (id, event_id, repository_id, sequence, source_intent_id, source_ordinal,
+     issue_id, pull_request_id, kind, actor, ref_name, old_target, new_target,
+     payload_version, payload, created_at)
+SELECT
+    id, event_id, repository_id, sequence, source_intent_id, source_ordinal,
+    issue_id, pull_request_id, kind, actor, ref_name, old_target, new_target,
+    payload_version, payload, created_at
+FROM repository_event_v16
+ORDER BY id;
+
+DROP TABLE repository_event_v16;
+
+CREATE INDEX repository_event_feed
+ON repository_event (repository_id, sequence DESC);
+
+CREATE INDEX repository_event_issue_timeline
+ON repository_event (issue_id, sequence)
+WHERE issue_id IS NOT NULL;
+
+CREATE INDEX repository_event_pull_request_timeline
+ON repository_event (pull_request_id, sequence)
+WHERE pull_request_id IS NOT NULL;

src/store/mod.rs

Mode 100644100644; object 988f9fcd3f7cf5e41ee39178

@@ -12,7 +12,7 @@
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
 const MAX_ACTIVE_FEED_TOKENS: i64 = 32;
-const SCHEMA_VERSION: i64 = 16;
+const SCHEMA_VERSION: i64 = 17;
 #[allow(
     dead_code,
     reason = "the integration test imports this module without the CLI operation"
@@ -22,7 +22,7 @@
     dead_code,
     reason = "M1A proves migrations before the M2 server calls them"
 )]
-const MIGRATIONS: [&str; 16] = [
+const MIGRATIONS: [&str; 17] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
@@ -39,6 +39,7 @@
     include_str!("migrations/014_feed_tokens.sql"),
     include_str!("migrations/015_pull_requests.sql"),
     include_str!("migrations/016_pull_request_reviews.sql"),
+    include_str!("migrations/017_pull_request_merges.sql"),
 ];
 
 #[allow(
@@ -355,6 +356,128 @@
         Ok(())
     }
 
+    pub(crate) fn begin_pull_request_merge(
+        &mut self,
+        merge: &NewPullRequestMerge<'_>,
+        intent: &GitOperationIntent<'_>,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let access = repository_issue_access(
+            &transaction,
+            merge.owner,
+            merge.repository,
+            Some(merge.actor),
+        )?;
+        if !access.can_read() {
+            return Err(StoreError::PullRequestHidden);
+        }
+        if !access.can_maintain() {
+            return Err(StoreError::PullRequestDenied);
+        }
+        if managed_repository_id(intent.repository_path) != Some(access.repository.id.as_str()) {
+            return Err(StoreError::Integrity(
+                "pull-request merge repository path does not match its repository".to_owned(),
+            ));
+        }
+        let current = transaction
+            .query_row(
+                "SELECT pull_request.id, pull_request.state, pull_request.base_ref,
+                        pull_request.base_object_id, pull_request.head_object_id,
+                        pull_request_revision.id, pull_request_revision.number
+                 FROM pull_request
+                 JOIN pull_request_revision
+                   ON pull_request_revision.pull_request_id = pull_request.id
+                 WHERE pull_request.repository_id = ?1
+                   AND pull_request.number = ?2
+                   AND pull_request_revision.number =
+                       (SELECT MAX(number) FROM pull_request_revision AS latest
+                        WHERE latest.pull_request_id = pull_request.id)",
+                rusqlite::params![access.repository.id, merge.number],
+                |row| {
+                    Ok((
+                        row.get::<_, String>(0)?,
+                        row.get::<_, String>(1)?,
+                        row.get::<_, String>(2)?,
+                        row.get::<_, String>(3)?,
+                        row.get::<_, String>(4)?,
+                        row.get::<_, String>(5)?,
+                        row.get::<_, i64>(6)?,
+                    ))
+                },
+            )
+            .optional()?
+            .ok_or_else(|| {
+                StoreError::PullRequestNotFound(
+                    merge.owner.to_owned(),
+                    merge.repository.to_owned(),
+                    merge.number,
+                )
+            })?;
+        if current.1 != "open" {
+            return Err(StoreError::PullRequestState);
+        }
+        if current.2 != merge.base_ref
+            || current.3 != merge.old_target
+            || current.4 != merge.head_target
+            || current.6 != merge.revision
+        {
+            return Err(StoreError::PullRequestRevisionNotFound);
+        }
+        let initial = parse_event_refs(intent.initial_refs)?;
+        let proposed = parse_event_refs(intent.proposed_refs)?;
+        if initial
+            != [(
+                merge.old_target.to_owned(),
+                merge.base_ref.as_bytes().to_vec(),
+            )]
+            || proposed
+                != [(
+                    merge.new_target.to_owned(),
+                    merge.base_ref.as_bytes().to_vec(),
+                )]
+        {
+            return Err(StoreError::EventPayload);
+        }
+        transaction.execute(
+            "INSERT INTO git_operation_intent
+             (id, repository_path, actor, initial_refs, proposed_refs, event_payload,
+              quarantine_path, state, created_at)
+             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8)",
+            rusqlite::params![
+                intent.id,
+                intent.repository_path,
+                intent.actor,
+                intent.initial_refs,
+                intent.proposed_refs,
+                intent.event_payload,
+                intent.quarantine_path,
+                intent.created_at,
+            ],
+        )?;
+        transaction.execute(
+            "INSERT INTO pull_request_merge_intent
+             (intent_id, repository_id, pull_request_id, revision_id, revision_number,
+              method, base_ref, old_target, new_target, created_at)
+             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
+            rusqlite::params![
+                intent.id,
+                access.repository.id,
+                current.0,
+                current.5,
+                merge.revision,
+                merge.method,
+                merge.base_ref,
+                merge.old_target,
+                merge.new_target,
+                merge.created_at,
+            ],
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
     pub(crate) fn mark_git_objects_promoted(
         &self,
         id: &str,
@@ -415,17 +538,27 @@
             &proposed_refs,
             created_at,
         )?;
+        complete_pull_request_merge(&transaction, id, &actor, created_at)?;
         transaction.commit()?;
         Ok(())
     }
 
-    pub(crate) fn abandon_git_intent(&self, id: &str) -> Result<(), StoreError> {
-        let changed = self.connection.execute(
+    pub(crate) fn abandon_git_intent(&mut self, id: &str) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        transaction.execute(
+            "DELETE FROM pull_request_merge_intent WHERE intent_id = ?1",
+            [id],
+        )?;
+        let changed = transaction.execute(
             "UPDATE git_operation_intent SET state = 'abandoned'
              WHERE id = ?1 AND state IN ('pending', 'promoted')",
             [id],
         )?;
-        require_one_intent(id, changed)
+        require_one_intent(id, changed)?;
+        transaction.commit()?;
+        Ok(())
     }
 
     #[allow(
@@ -1844,15 +1977,18 @@
                 })?
                 .collect::<Result<Vec<_>, _>>()?
         };
-        let can_revise = access.can_write_repository();
+        let is_open = pull_request.state == "open";
+        let can_revise = is_open && access.can_write_repository();
+        let can_merge = access.can_maintain() && pull_request.state == "open";
         Ok(PullRequestDetail {
-            can_review: access.active_actor_id.is_some() && access.can_read(),
+            can_review: is_open && access.active_actor_id.is_some() && access.can_read(),
             repository: access.repository,
             pull_request,
             revisions,
             reviews,
             timeline,
             can_revise,
+            can_merge,
         })
     }
 
@@ -3238,6 +3374,7 @@
     pub(crate) timeline: Vec<PullRequestTimelineRecord>,
     pub(crate) can_revise: bool,
     pub(crate) can_review: bool,
+    pub(crate) can_merge: bool,
 }
 
 pub(crate) struct NewPullRequestReview<'a> {
@@ -3395,6 +3532,20 @@
     pub(crate) proposed_refs: &'a [u8],
     pub(crate) event_payload: &'a [u8],
     pub(crate) quarantine_path: &'a str,
+    pub(crate) created_at: i64,
+}
+
+pub(crate) struct NewPullRequestMerge<'a> {
+    pub(crate) owner: &'a str,
+    pub(crate) repository: &'a str,
+    pub(crate) number: i64,
+    pub(crate) revision: i64,
+    pub(crate) actor: &'a str,
+    pub(crate) method: &'a str,
+    pub(crate) base_ref: &'a str,
+    pub(crate) old_target: &'a str,
+    pub(crate) head_target: &'a str,
+    pub(crate) new_target: &'a str,
     pub(crate) created_at: i64,
 }
 
@@ -4065,6 +4216,90 @@
         ],
     )?;
     Ok(())
+}
+
+fn complete_pull_request_merge(
+    transaction: &rusqlite::Transaction<'_>,
+    intent_id: &str,
+    actor: &str,
+    completed_at: i64,
+) -> Result<(), StoreError> {
+    let merge = transaction
+        .query_row(
+            "SELECT merge.repository_id, merge.pull_request_id, pull_request.number,
+                    merge.revision_id, merge.revision_number, merge.method,
+                    merge.base_ref, merge.old_target, merge.new_target,
+                    pull_request.state, pull_request.base_ref,
+                    pull_request.base_object_id, pull_request.head_object_id,
+                    revision.base_object_id, revision.head_object_id,
+                    (SELECT MAX(number) FROM pull_request_revision AS latest
+                     WHERE latest.pull_request_id = pull_request.id)
+             FROM pull_request_merge_intent AS merge
+             JOIN pull_request ON pull_request.id = merge.pull_request_id
+             JOIN pull_request_revision AS revision ON revision.id = merge.revision_id
+             WHERE merge.intent_id = ?1",
+            [intent_id],
+            |row| {
+                Ok((
+                    row.get::<_, String>(0)?,
+                    row.get::<_, String>(1)?,
+                    row.get::<_, i64>(2)?,
+                    row.get::<_, String>(3)?,
+                    row.get::<_, i64>(4)?,
+                    row.get::<_, String>(5)?,
+                    row.get::<_, String>(6)?,
+                    row.get::<_, String>(7)?,
+                    row.get::<_, String>(8)?,
+                    row.get::<_, String>(9)?,
+                    row.get::<_, String>(10)?,
+                    row.get::<_, String>(11)?,
+                    row.get::<_, String>(12)?,
+                    row.get::<_, String>(13)?,
+                    row.get::<_, String>(14)?,
+                    row.get::<_, i64>(15)?,
+                ))
+            },
+        )
+        .optional()?;
+    let Some(merge) = merge else {
+        return Ok(());
+    };
+    if merge.9 != "open"
+        || merge.10 != merge.6
+        || merge.11 != merge.7
+        || merge.12 != merge.14
+        || merge.13 != merge.7
+        || merge.15 != merge.4
+    {
+        return Err(StoreError::PullRequestState);
+    }
+    let changed = transaction.execute(
+        "UPDATE pull_request SET state = 'merged', updated_at = ?2
+         WHERE id = ?1 AND state = 'open'",
+        rusqlite::params![merge.1, completed_at],
+    )?;
+    if changed != 1 {
+        return Err(StoreError::PullRequestState);
+    }
+    let event = event::pull_request_merge(
+        &merge.1, merge.2, merge.4, &merge.5, &merge.6, &merge.7, &merge.8,
+    );
+    insert_domain_event(
+        transaction,
+        &NewDomainEvent {
+            repository_id: &merge.0,
+            source_intent_id: Some(intent_id),
+            source_ordinal: Some(2),
+            issue_id: None,
+            pull_request_id: Some(&merge.1),
+            event: &event,
+            actor,
+            ref_name: None,
+            old_target: None,
+            new_target: None,
+            created_at: completed_at,
+        },
+    )
 }
 
 fn end_sessions(

templates/pull_request.html

Mode 100644100644; object 039e4b5a5ac6a4c51e881f4b

@@ -138,6 +138,28 @@
 {% endfor %}
   </section>
 
+{% if can_merge %}
+{% if comparison.mergeability == "fast-forward" %}
+  <section aria-labelledby="merge-heading">
+    <h2 id="merge-heading">Merge this pull request</h2>
+    <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/merge">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+      <input type="hidden" name="method" value="fast-forward">
+      <button type="submit">Fast-forward {{ pull_request.base_ref }}</button>
+    </form>
+  </section>
+{% else if comparison.mergeability == "clean merge" %}
+  <section aria-labelledby="merge-heading">
+    <h2 id="merge-heading">Merge this pull request</h2>
+    <form method="post" action="/{{ owner }}/{{ repository }}/pulls/{{ pull_request.number }}/merge">
+      <input type="hidden" name="csrf" value="{{ csrf }}">
+      <input type="hidden" name="method" value="merge-commit">
+      <button type="submit">Create a merge commit on {{ pull_request.base_ref }}</button>
+    </form>
+  </section>
+{% endif %}
+{% endif %}
+
 {% if can_review %}
   <section aria-labelledby="review-heading">
     <h2 id="review-heading">Review this revision</h2>

tests/cli.rs

Mode 100644100644; object 32e32f6467b4ec0bf742f72d

@@ -25,7 +25,8 @@
     include_str!("../src/store/migrations/014_feed_tokens.sql"),
     include_str!("../src/store/migrations/015_pull_requests.sql"),
     include_str!("../src/store/migrations/016_pull_request_reviews.sql"),
-    "PRAGMA user_version = 16;\n",
+    include_str!("../src/store/migrations/017_pull_request_merges.sql"),
+    "PRAGMA user_version = 17;\n",
 );
 
 #[test]

tests/public_routes.rs

Mode 100644100644; object d146e2787dede188a92e22ae

@@ -911,6 +911,29 @@
     assert_eq!(revised_again.status, 303);
     let outdated = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
     assert!(outdated.text().contains("<strong>Outdated</strong>"));
+    let merge_page = request(
+        server.address(),
+        "GET",
+        "/alice/example/pulls/1",
+        &[("Cookie", cookie.as_str())],
+        &[],
+    );
+    assert!(merge_page.text().contains("Fast-forward refs/heads/main"));
+    let merge = request(
+        server.address(),
+        "POST",
+        "/alice/example/pulls/1/merge",
+        &headers,
+        form(&[("csrf", csrf.as_str()), ("method", "fast-forward")]).as_bytes(),
+    );
+    assert_eq!(merge.status, 303);
+    let merged = request(server.address(), "GET", "/alice/example/pulls/1", &[], &[]);
+    assert!(merged.text().contains("merged · opened by alice"));
+    assert!(merged.text().contains("pull-request-merged"));
+    assert_eq!(
+        rev_parse(&bare, "refs/heads/main"),
+        rev_parse(&bare, "refs/heads/feature")
+    );
     let search_page = request(server.address(), "GET", "/search", &[], &[]);
     assert_eq!(search_page.status, 200);
     assert!(

tests/pull_requests.rs

Mode 100644100644; object 0a643fa75935b5db6a6494ad

@@ -26,6 +26,7 @@
 
 use std::fs;
 use std::os::unix::ffi::OsStringExt;
+use std::os::unix::fs::PermissionsExt;
 use std::path::{Path, PathBuf};
 use std::process::Command;
 use std::sync::Arc;
@@ -36,7 +37,7 @@
 use gix::hash::ObjectId;
 use pull_request::{PullRequestError, PullRequestService};
 use rusqlite::params;
-use store::{NewPullRequestRefIntent, Store, StoreError};
+use store::{GitOperationIntent, NewPullRequestMerge, NewPullRequestRefIntent, Store, StoreError};
 use tempfile::TempDir;
 
 #[test]
@@ -247,6 +248,301 @@
             ]
         );
     }
+}
+
+#[test]
+fn fast_forwards_pull_requests_with_one_durable_merge_event_for_both_hashes() {
+    for (index, object_format) in ["sha1", "sha256"].into_iter().enumerate() {
+        let fixture = Fixture::new(object_format, index + 30);
+        let service = PullRequestService::new(&fixture.database, &fixture.repositories);
+        let opened = service
+            .open(
+                "alice",
+                "project",
+                "alice",
+                "Fast-forward the feature",
+                "Move the base ref to the reviewed head.",
+                "refs/heads/main",
+                "refs/heads/feature",
+            )
+            .expect("open a fast-forward pull request");
+        assert!(matches!(
+            service.merge("alice", "project", 1, "bob", "fast-forward"),
+            Err(PullRequestError::Store(StoreError::PullRequestDenied))
+        ));
+
+        let merged = service
+            .merge("alice", "project", 1, "alice", "fast-forward")
+            .expect("fast-forward the pull request");
+        assert_eq!(merged.state, "merged");
+        assert_eq!(
+            rev_parse(&fixture.bare, "refs/heads/main"),
+            opened.head_object_id
+        );
+        assert!(matches!(
+            service.merge("alice", "project", 1, "alice", "fast-forward"),
+            Err(PullRequestError::Store(StoreError::PullRequestState))
+        ));
+
+        let store = Store::open(&fixture.database).expect("open the merge store");
+        let intent_state: String = store
+            .connection()
+            .query_row(
+                "SELECT git_operation_intent.state
+                 FROM pull_request_merge_intent
+                 JOIN git_operation_intent
+                   ON git_operation_intent.id = pull_request_merge_intent.intent_id",
+                [],
+                |row| row.get(0),
+            )
+            .expect("read the merge intent");
+        assert_eq!(intent_state, "completed");
+        let events: Vec<String> = store
+            .connection()
+            .prepare(
+                "SELECT kind FROM repository_event
+                 WHERE sequence > 1 ORDER BY sequence",
+            )
+            .expect("prepare merge events")
+            .query_map([], |row| row.get(0))
+            .expect("query merge events")
+            .collect::<Result<_, _>>()
+            .expect("read merge events");
+        assert_eq!(events, ["push", "ref-updated", "pull-request-merged",]);
+    }
+}
+
+#[test]
+fn rejects_a_merge_when_the_base_moved_after_the_revision() {
+    let fixture = Fixture::new("sha1", 40);
+    let service = PullRequestService::new(&fixture.database, &fixture.repositories);
+    service
+        .open(
+            "alice",
+            "project",
+            "alice",
+            "Stale base",
+            "Do not merge a stale comparison.",
+            "refs/heads/main",
+            "refs/heads/feature",
+        )
+        .expect("open a pull request");
+    fixture.commit_on("main", "base.txt", "new base\n", "move the base");
+    assert!(matches!(
+        service.merge("alice", "project", 1, "alice", "fast-forward"),
+        Err(PullRequestError::StaleRevision)
+    ));
+    let count: i64 = Store::open(&fixture.database)
+        .expect("open the stale merge store")
+        .connection()
+        .query_row(
+            "SELECT COUNT(*) FROM pull_request_merge_intent",
+            [],
+            |row| row.get(0),
+        )
+        .expect("count merge intents");
+    assert_eq!(count, 0);
+}
+
+#[test]
+fn creates_worktree_free_merge_commits_with_deterministic_parents_for_both_hashes() {
+    for (index, object_format) in ["sha1", "sha256"].into_iter().enumerate() {
+        let fixture = Fixture::new(object_format, index + 50);
+        fixture.commit_on("main", "base.txt", "base side\n", "advance base");
+        run(
+            &fixture.worktree,
+            Command::new("git").args(["switch", "-q", "feature"]),
+        );
+        run(
+            &fixture.worktree,
+            Command::new("git").args(["mv", "feature.txt", "renamed-feature.txt"]),
+        );
+        let renamed = fixture.worktree.join("renamed-feature.txt");
+        fs::set_permissions(&renamed, fs::Permissions::from_mode(0o755))
+            .expect("set the executable mode");
+        git_commit(&fixture.worktree, "rename the feature");
+        run(
+            &fixture.worktree,
+            Command::new("git")
+                .args(["push", "-q"])
+                .arg(&fixture.bare)
+                .arg("feature"),
+        );
+
+        let git = GitRepository::open(&fixture.bare).expect("open the merge repository");
+        let base = git
+            .resolve_branch("refs/heads/main")
+            .expect("resolve the merge base");
+        let head = git
+            .resolve_branch("refs/heads/feature")
+            .expect("resolve the merge head");
+        let object_state = git_object_state(&fixture.bare);
+        let first = git
+            .prepare_merge_commit(base, head, "alice", 1234, "deterministic merge")
+            .expect("prepare a merge commit");
+        let second = git
+            .prepare_merge_commit(base, head, "alice", 1234, "deterministic merge")
+            .expect("prepare the same merge commit");
+        assert_eq!(first, second);
+        assert_eq!(git_object_state(&fixture.bare), object_state);
+        assert!(!fixture.bare.join("index").exists());
+
+        let service = PullRequestService::new(&fixture.database, &fixture.repositories);
+        service
+            .open(
+                "alice",
+                "project",
+                "alice",
+                "Merge the rename",
+                "Keep the rename and executable mode.",
+                "refs/heads/main",
+                "refs/heads/feature",
+            )
+            .expect("open a divergent pull request");
+        service
+            .merge("alice", "project", 1, "alice", "merge-commit")
+            .expect("create a merge commit");
+        let merged = rev_parse(&fixture.bare, "refs/heads/main");
+        assert_ne!(merged, base.to_string());
+        assert_ne!(merged, head.to_string());
+        let description = git_output(
+            &fixture.bare,
+            &["show", "-s", "--format=%an|%ae|%cn|%ce|%P|%s", &merged],
+        );
+        let expected = format!(
+            "alice|alice@users.tit|alice|alice@users.tit|{base} {head}|Merge pull request #1 from refs/heads/feature"
+        );
+        assert_eq!(description.trim(), expected);
+        let tree = git_output(&fixture.bare, &["ls-tree", "-r", &merged]);
+        assert!(tree.contains("100755 blob"));
+        assert!(tree.contains("\trenamed-feature.txt"));
+        assert!(tree.contains("\tbase.txt"));
+        assert!(!fixture.bare.join("index").exists());
+    }
+}
+
+#[test]
+fn rejects_a_conflicting_server_merge_without_moving_the_base() {
+    let fixture = Fixture::new("sha1", 60);
+    fixture.commit_on("main", "README.md", "main content\n", "change main");
+    fixture.commit_on(
+        "feature",
+        "README.md",
+        "feature content\n",
+        "change feature",
+    );
+    let base = rev_parse(&fixture.bare, "refs/heads/main");
+    let service = PullRequestService::new(&fixture.database, &fixture.repositories);
+    service
+        .open(
+            "alice",
+            "project",
+            "alice",
+            "Conflicting merge",
+            "Do not create a conflict commit.",
+            "refs/heads/main",
+            "refs/heads/feature",
+        )
+        .expect("open a conflicting pull request");
+    assert!(matches!(
+        service.merge("alice", "project", 1, "alice", "merge-commit"),
+        Err(PullRequestError::Mergeability)
+    ));
+    assert_eq!(rev_parse(&fixture.bare, "refs/heads/main"), base);
+    let count: i64 = Store::open(&fixture.database)
+        .expect("open the conflict store")
+        .connection()
+        .query_row(
+            "SELECT COUNT(*) FROM pull_request_merge_intent",
+            [],
+            |row| row.get(0),
+        )
+        .expect("count conflict intents");
+    assert_eq!(count, 0);
+}
+
+#[test]
+fn recovers_a_completed_merge_and_abandons_a_concurrent_base_change() {
+    let fixture = Fixture::new("sha1", 70);
+    let service = PullRequestService::new(&fixture.database, &fixture.repositories);
+    service
+        .open(
+            "alice",
+            "project",
+            "alice",
+            "Recover this merge",
+            "Complete metadata after the ref update.",
+            "refs/heads/main",
+            "refs/heads/feature",
+        )
+        .expect("open a recoverable pull request");
+    begin_test_merge_intent(&fixture, 1, "71000000000000000000000000000000");
+    let git = GitRepository::open(&fixture.bare).expect("open the recovery repository");
+    let base = git
+        .resolve_branch("refs/heads/main")
+        .expect("resolve the recovery base");
+    let head = git
+        .resolve_branch("refs/heads/feature")
+        .expect("resolve the recovery head");
+    git.update_reference_with_log("refs/heads/main", Some(base), head, "interrupted merge")
+        .expect("apply the interrupted merge ref");
+    service.recover().expect("recover merge metadata");
+    assert_eq!(
+        service
+            .get("alice", "project", 1, Some("alice"))
+            .expect("read the recovered merge")
+            .pull_request
+            .state,
+        "merged"
+    );
+
+    let concurrent = Fixture::new("sha1", 71);
+    let service = PullRequestService::new(&concurrent.database, &concurrent.repositories);
+    service
+        .open(
+            "alice",
+            "project",
+            "alice",
+            "Race the base",
+            "A concurrent base update wins before this ref moves.",
+            "refs/heads/main",
+            "refs/heads/feature",
+        )
+        .expect("open a concurrent pull request");
+    begin_test_merge_intent(&concurrent, 1, "72000000000000000000000000000000");
+    concurrent.commit_on("main", "raced.txt", "concurrent\n", "race the merge");
+    let raced_target = rev_parse(&concurrent.bare, "refs/heads/main");
+    service
+        .recover()
+        .expect("abandon the merge that did not update its ref");
+    assert_eq!(rev_parse(&concurrent.bare, "refs/heads/main"), raced_target);
+    assert_eq!(
+        service
+            .get("alice", "project", 1, Some("alice"))
+            .expect("read the unmerged pull request")
+            .pull_request
+            .state,
+        "open"
+    );
+    let store = Store::open(&concurrent.database).expect("open the raced merge store");
+    let state: String = store
+        .connection()
+        .query_row(
+            "SELECT state FROM git_operation_intent WHERE id = ?1",
+            ["72000000000000000000000000000000"],
+            |row| row.get(0),
+        )
+        .expect("read the raced intent state");
+    assert_eq!(state, "abandoned");
+    let merge_count: i64 = store
+        .connection()
+        .query_row(
+            "SELECT COUNT(*) FROM pull_request_merge_intent",
+            [],
+            |row| row.get(0),
+        )
+        .expect("count active merge metadata");
+    assert_eq!(merge_count, 0);
 }
 
 #[test]
@@ -745,6 +1041,60 @@
     run(worktree, &mut command);
 }
 
+fn begin_test_merge_intent(fixture: &Fixture, number: i64, intent_id: &str) {
+    let service = PullRequestService::new(&fixture.database, &fixture.repositories);
+    let detail = service
+        .get("alice", "project", number, Some("alice"))
+        .expect("read a merge intent pull request");
+    let revision = detail.revisions.last().expect("find the merge revision");
+    let git = GitRepository::open(&fixture.bare).expect("open the merge intent repository");
+    let base = git
+        .resolve_branch("refs/heads/main")
+        .expect("resolve the merge intent base");
+    let head = git
+        .resolve_branch("refs/heads/feature")
+        .expect("resolve the merge intent head");
+    let initial = format!("{base} refs/heads/main\n").into_bytes();
+    let proposed = format!("{head} refs/heads/main\n").into_bytes();
+    let created_at = revision.created_at + 1;
+    let quarantine = fixture
+        .bare
+        .join("objects")
+        .join("tit-quarantine")
+        .join(intent_id);
+    let mut store = Store::open(&fixture.database).expect("open the merge intent store");
+    store
+        .begin_pull_request_merge(
+            &NewPullRequestMerge {
+                owner: "alice",
+                repository: "project",
+                number,
+                revision: revision.number,
+                actor: "alice",
+                method: "fast-forward",
+                base_ref: "refs/heads/main",
+                old_target: &base.to_string(),
+                head_target: &head.to_string(),
+                new_target: &head.to_string(),
+                created_at,
+            },
+            &GitOperationIntent {
+                id: intent_id,
+                repository_path: fixture.bare.to_str().expect("a UTF-8 repository path"),
+                actor: "alice",
+                initial_refs: &initial,
+                proposed_refs: &proposed,
+                event_payload: &proposed,
+                quarantine_path: quarantine.to_str().expect("a UTF-8 quarantine path"),
+                created_at,
+            },
+        )
+        .expect("begin a test merge intent");
+    store
+        .mark_git_objects_promoted(intent_id, None)
+        .expect("mark test merge objects promoted");
+}
+
 fn rev_parse(repository: &Path, revision: &str) -> String {
     let output = Command::new("git")
         .args(["rev-parse", revision])
@@ -756,6 +1106,20 @@
         .expect("read a fixture object ID")
         .trim()
         .to_owned()
+}
+
+fn git_output(repository: &Path, arguments: &[&str]) -> String {
+    let output = Command::new("git")
+        .args(arguments)
+        .current_dir(repository)
+        .output()
+        .expect("run a Git inspection command");
+    assert!(
+        output.status.success(),
+        "Git inspection failed: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    String::from_utf8(output.stdout).expect("read Git inspection output")
 }
 
 fn git_object_state(repository: &Path) -> String {

tests/sqlite.rs

Mode 100644100644; object 9a654b0702c3dafdbb1e75ca

@@ -91,6 +91,19 @@
     include_str!("../src/store/migrations/015_pull_requests.sql"),
     "PRAGMA user_version = 15;\n",
 );
+const V16_FIXTURE: &str = concat!(
+    include_str!("fixtures/sqlite/v7.sql"),
+    include_str!("../src/store/migrations/008_web_sessions.sql"),
+    include_str!("../src/store/migrations/009_repository_authorization.sql"),
+    include_str!("../src/store/migrations/010_audit_history.sql"),
+    include_str!("../src/store/migrations/011_domain_events.sql"),
+    include_str!("../src/store/migrations/012_issues.sql"),
+    include_str!("../src/store/migrations/013_watches.sql"),
+    include_str!("../src/store/migrations/014_feed_tokens.sql"),
+    include_str!("../src/store/migrations/015_pull_requests.sql"),
+    include_str!("../src/store/migrations/016_pull_request_reviews.sql"),
+    "PRAGMA user_version = 16;\n",
+);
 
 fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
     directory.path().join(name)
@@ -208,7 +221,7 @@
     let directory = TempDir::new().expect("create a temporary directory");
     let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
 
-    assert_eq!(store.schema_version().expect("read the schema version"), 16);
+    assert_eq!(store.schema_version().expect("read the schema version"), 17);
     assert_eq!(
         store
             .connection()
@@ -1572,13 +1585,14 @@
         (V13_FIXTURE, 13),
         (V14_FIXTURE, 14),
         (V15_FIXTURE, 15),
+        (V16_FIXTURE, 16),
     ] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "tit.sqlite3");
         create_fixture(&path, fixture);
 
         let store = Store::open(&path).expect("migrate the fixture");
-        assert_eq!(store.schema_version().expect("read the schema version"), 16);
+        assert_eq!(store.schema_version().expect("read the schema version"), 17);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -1651,7 +1665,7 @@
 
 #[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 16)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 17)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);