samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
206 lines· 12.3 KB ·Markdown
1# Pull requests
2
3A pull request proposes merging one branch (the **head**) into another (the **base**) of the same repository. Openhub has no forks, so both branches always live in one bare repository. This page covers how pull requests are created, tracked as branches move, displayed, merged and closed.
4
5**Source files:**
6- `src/web/pulls.rs`: handlers.
7- `src/sync.rs`: tracking.
8- `src/git.rs`: `mergeability`, `merge`, `commits_between`, `range_diff`, `set_pull_ref`.
9- `src/db.rs`: pull request and event queries.
10- `templates/repo/{compare,pulls,pull}.html`: pages.
11
12## Lifecycle
13
14```
15 create (compare page)
16 │
17 ▼
18 ┌──────────────▶ open ───────────────┐
19 │ │ │ merged in the web interface, or the
20 │ reopen │ close │ head reached the base through a push
21 │ ▼ ▼
22 └────────────── closed merged (final)
23```
24
25| State | Range shown | Can change to |
26| --- | --- | --- |
27| `open` | Live: merge base of the current tips → head tip | `closed` (author or writer), `merged` (writer merges, or detected) |
28| `closed` | Frozen at close: `base_sha..head_sha` | `open` (author or writer, if both branches still exist) |
29| `merged` | Frozen at merge: `base_sha..head_sha` | Nothing; this state is final |
30
31### Key columns
32
33| Column | Meaning |
34| --- | --- |
35| `head_sha` | Last known tip of the head branch, mirrored in `refs/pull/<n>/head` |
36| `base_sha` | The merge base of base and head. Despite the name, it is not the base branch's tip |
37| `merge_commit_sha` | For web merges, the new tip of the base branch: the merge commit, the squash commit or the last rebased commit |
38
39## Comparing and creating
40
41**The compare page** is `GET /{owner}/{repo}/compare/{base}...{head}` (`pulls::compare`). `/compare?base=…&head=…` redirects there, and `/compare/{head}` uses the default branch as the base. It shows, in order:
42
431. A notice instead of results when no head is chosen, a branch doesn't exist, base and head are the same, the histories are unrelated, or the head has nothing new.
442. **Commits:** `git::commits_between(base_tip, head_tip)`, the commits on the head that aren't on the base, oldest first.
453. **Mergeability**, from `git::mergeability`.
464. **Diff:** `git::range_diff(merge_base, head_tip)`. This is a three-dot diff: only the head's changes since it diverged, not changes made on the base since then.
475. **A form** whose title defaults to the single commit's summary when there is exactly one commit, or else the branch name made readable (`feature/add-login` becomes `Feature/add login`).
486. **A link** to the existing open pull request for this base and head, instead of the form, when there is one.
49
50**Creating** (`POST /{owner}/{repo}/pulls`, `pulls::create`) requires:
51- a signed-in user with read access;
52- a non-empty title, cut to 256 characters;
53- both branches to exist;
54- base and head to differ;
55- the head not to be already contained in the base;
56- the two histories to share a merge base.
57
58If an open pull request already exists for the same base and head, the request redirects to it instead.
59
60On success, Openhub:
611. Assigns the next number for this repository: `MAX(number) + 1`, inside a transaction.
622. Stores `head_sha` (the head tip) and `base_sha` (the merge base).
633. Writes `refs/pull/<n>/head`.
64
65## Tracking open pull requests
66
67Pushes can move either branch at any time, so `sync::refresh_pulls(state, repo_id, actor)` reconciles every open pull request in a repository. It runs:
68
69| Trigger | `actor` recorded for detected merges |
70| --- | --- |
71| After every successful SSH push (`sync::after_push`) | The pusher |
72| After a web merge (`pulls::merge`), to update the repository's other pull requests | The user who merged |
73| On every view of an open pull request's conversation, commits or files tab (`pulls::load_pull`) | None, so the timeline reads "This pull request was merged" |
74
75For each open pull request:
76
771. **The head branch no longer exists:** nothing changes. The page says the head branch no longer exists and still shows the commits from the stored head.
782. **The head tip moved:** `refs/pull/<n>/head` is moved to the new tip.
793. **The head tip is now an ancestor of, or equal to, the base tip:** the pull request is marked `merged`. This catches merges done on the command line and fast-forward pushes. In the database:
80 - `merged_by` is set to `actor`.
81 - `merge_commit_sha` is set to NULL.
82 - `base_sha` keeps the stored merge base (or is computed if missing).
83 - `head_sha` is set to the head tip.
84 - A `merged` event is added.
854. **Otherwise:** if the head tip or the merge base changed, `head_sha` and `base_sha` are updated.
86
87The git work runs in one `spawn_blocking` call, and the database writes follow it.
88
89## Which commits a pull request shows
90
91`pulls::pull_range` decides the commit range for every pull request tab:
92
93- **Open:** `(merge_base(base tip, head tip), head tip)`, computed live. If the head branch is gone, it falls back to the stored `head_sha`, or `refs/pull/<n>/head`. If the base branch is gone, it falls back to the stored `base_sha`.
94- **Closed or merged:** `(base_sha, head_sha)`, exactly as frozen. This is why `base_sha` stores the merge base rather than the base tip: after a merge, the base tip contains the head, so a live range would be empty.
95
96From that range, `pulls::pull_data` derives:
97- the commits (`commits_between`);
98- the file, addition and deletion counts (`diff_stats`);
99- the rendered diff (`range_diff`, on the files tab only);
100- for open pull requests with both branches present, the mergeability.
101
102## Mergeability
103
104`git::mergeability(repo, base_tip, head_tip)` is computed on every view of an open pull request. It isn't cached.
105
106| Result | Meaning | Page shows |
107| --- | --- | --- |
108| `UpToDate` | The head is already contained in the base | (Normally detected as merged first) |
109| `Conflicts` | An in-memory three-way merge (`merge_commits`) has conflicts | Conflict notice with command-line instructions |
110| `Clean` | The merge succeeds | Merge controls for users with write access |
111
112## Merging in the web interface
113
114`POST /{owner}/{repo}/pull/{n}/merge` with `method=merge|squash|rebase` (`pulls::merge` → `git::merge`). It requires `write`.
115
1161. Re-reads both branch tips and re-checks mergeability. If there are conflicts, or nothing to merge, it shows an error message and changes nothing.
1172. Builds the result in memory. The base branch isn't touched yet.
118
119 | Method | Result | Author | Committer | Message |
120 | --- | --- | --- | --- | --- |
121 | `merge` | One commit with parents `[base tip, head tip]`, tree from the three-way merge | Merging user | Merging user | `Merge pull request #N from <head>`, a blank line, then the title |
122 | `squash` | One commit with parent `[base tip]`, tree from the three-way merge | Author of the head tip commit | Merging user | `<title> (#N)`, plus a `* <summary>` line per commit when there's more than one |
123 | `rebase` | Each non-merge commit in `base..head` cherry-picked in order onto the base tip | Original author | Merging user | Original message |
124
125 About rebasing:
126 - Merge commits in the range are skipped, as `git rebase` does.
127 - A commit that becomes empty is dropped.
128 - A conflict in any commit fails the whole merge with `Commit abc1234 cannot be rebased cleanly`.
129
130 The merging user's identity is their display name (or username) and account email, with the current time. Commits aren't signed. No hooks run.
1313. Moves the base branch with `repo.reference_matching(refs/heads/<base>, new_tip, force, expected = base tip from step 1)`. This is a compare-and-swap: if anyone pushed to the base since step 1, the update fails with "The base branch changed while merging; please try again". The objects already written stay unreferenced and are harmless.
1324. Points `refs/pull/<n>/head` at the head tip that was merged.
1335. Records the merge in the database:
134 - `mark_merged` sets the state, `merged_by`, `merge_commit_sha = new_tip`, `head_sha` and `base_sha = merge base`.
135 - A `merged` event is added.
136 - `pushed_at` is updated.
137 - `refresh_pulls` runs for the rest of the repository.
138
139The head branch is kept. After a merge or close, the page offers **Delete branch** when all of these hold:
140- the user has write access;
141- the branch still exists;
142- it isn't the default branch;
143- no other open pull request uses it as its head.
144
145Deleting it records a `branch_deleted` event.
146
147## Closing, reopening and editing
148
149- **Close:** use the **Close pull request** button beside the comment box. Any text in the box is posted as a comment first. Alternatively, `POST …/state` with `action=close`.
150 - Allowed for the author, or anyone with write access.
151 - Freezes `head_sha` and `base_sha` to the current `pull_range` and records a `closed` event.
152- **Reopen** (`POST …/state` with `action=reopen`):
153 - Allowed for the author, or anyone with write access.
154 - Requires both branches to exist, and no other open pull request for the same base and head.
155 - Recomputes `head_sha` and the merge base, moves `refs/pull/<n>/head` and records a `reopened` event.
156 - Merged pull requests can't be reopened.
157- **Edit** (`POST …/edit`): the author, or anyone with write access, can change the title and description.
158
159## Conversation
160
161The description (`pull_requests.body`) and comments (`pr_events` rows with `kind = 'comment'`) are Markdown, rendered with `markdown::render` without a link base. Relative links there aren't rewritten.
162
163Any signed-in user with read access can comment. Comments can't be edited or deleted.
164
165| Event `kind` | Created by | Shown as |
166| --- | --- | --- |
167| `comment` | `pulls::comment` | Comment box |
168| `closed` | `pulls::close_pull` | "*user* closed this" |
169| `reopened` | `pulls::set_state` | "*user* reopened this" |
170| `merged` | `pulls::merge`, `sync::refresh_pulls` | "*user* merged commit abc1234 into main", or "This pull request was merged" when there's no author |
171| `branch_deleted` | `repo::delete_branch` (when called with `pr`) | "*user* deleted the *branch* branch" |
172
173## Where pull requests appear
174
175- **Repository → Pull requests:** a list with open and closed tabs. The closed tab includes merged pull requests. The repository tab bar shows the open count.
176- **Dashboard:** up to 20 open pull requests that the user authored, or that are in repositories they own or collaborate on.
177- **Branches page:** a link from each branch to its open pull request.
178- **Repository home:** a banner offering **Compare & pull request** when a non-default branch got a commit within the last hour, has no open pull request, and the viewer has write access.
179- **Push output:** the "Create a pull request" or "View pull request" hints (see [Git transport](git-transport.md#after-a-push)).
180
181## Checking out a pull request locally
182
183```sh
184git fetch origin pull/<n>/head:pr-<n>
185git checkout pr-<n>
186```
187
188The sidebar of every pull request shows these commands.
189
190## Edge cases
191
192| Situation | Behaviour |
193| --- | --- |
194| Head branch force-pushed | The next refresh records the new head. `refs/pull/<n>/head` moves, so the old commits become unreferenced unless another ref holds them |
195| Head branch deleted while open | The pull request stays open and shows its last known commits. It can't be merged until the branch is pushed again |
196| Head branch renamed | Seen as a deletion. The pull request doesn't follow the new name |
197| Base branch deleted | Can't be merged. The page says the base branch no longer exists |
198| Head merged into base by a plain `git push` | Detected after that push and marked merged, with the pusher recorded |
199| Base and head merged locally, but the merge result never pushed | Nothing changes until the base branch on the server contains the head |
200| Two pull requests share a head branch with different bases | Allowed. Each is tracked independently |
201
202## When you change this
203
204- **New pull request state or event kind:** update the state table and event table above, the `-- …` comments in the migration (as a new migration if the schema changes), `templates/repo/pull.html` and the smoke test.
205- **New merge method:** add it to `git::MergeMethod`, implement it in `git::merge` (keeping the compare-and-swap ref update) and add an option to the merge form.
206- **Anything that writes `head_sha`** must also call `git::set_pull_ref`.