michal/tit
Browse tree · Show commit · Download archive
Diff
dc89d0387aff → 953f3b3e5ad6
README.md
Mode 100644 → 100644; object 511acedc90aa → 01c7c99308a6
@@ -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 100644 → 100644; object 6083ae4415b4 → d6e892eb73c5
@@ -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 100644 → 100644; object df36e3ecf8ff → efde28323e2a
@@ -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 100644 → 100644; object 4295f119092f → adb8b5fff196
@@ -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 100644 → 100644; object 7caddf3b99d2 → 7258de976227
@@ -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 100644 → 100644; object e1fb41428065 → be33243e7d57
@@ -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 100644 → 100644; object 98afed30bd53 → 1fc03dcb5320
@@ -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 100644 → 100644; object 988f9fcd3f7c → f5e41ee39178
@@ -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 100644 → 100644; object 039e4b5a5ac6 → a4c51e881f4b
@@ -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 100644 → 100644; object 32e32f6467b4 → ec0bf742f72d
@@ -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 100644 → 100644; object d146e2787ded → e188a92e22ae
@@ -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 100644 → 100644; object 0a643fa75935 → b5db6a6494ad
@@ -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 100644 → 100644; object 9a654b0702c3 → dafdbb1e75ca
@@ -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);