# Milestone 4a: forks and pull requests (contract between workers) Reviews with line comments come in M4b, not here. The global rules from `docs/m1-interfaces.md` and `docs/m3-interfaces.md` apply: - The git CLI only gets **full hex OIDs** and validated ref names, placed after `--end-of-options` where git supports it. - No shell, no unwrap in non-test code. Clippy `-D warnings` and fmt must pass. - No inline styles and no JS. - Every page goes through `load_repo` and `authorize`, and times use `viewer_tz`. - **Never put cargo target dirs or big files in /tmp**, and tests use `tempfile::TempDir`. ## Concepts - A PR is an `issues` row with `kind = 'pull'`, so it shares the per-repo numbering with issues. That gives it comments, labels, assignees, milestones, events and edit history for free. Issue pages already filter `kind = 'issue'`; PR pages filter `kind = 'pull'`. `#N` links go to `/issues/N`, and the issue page redirects (303) to `/pulls/N` when N is a PR. - The **head** is `(head_repo_id, head_branch)`, either the same repo or a fork of the base. The **base** is `(base repo = issue.repo_id, base_branch)`. - The base repo keeps `refs/pull//head` pointing at the head commit. It's updated whenever the head branch moves, so the base repo always has the objects. For a fork, that means a local `git fetch` from the fork's path. ## Forks - `repos` gains `fork_of TEXT NULL REFERENCES repos(id)` (migration 0007). - Creating a fork: `POST /{o}/{r}/fork` (signed in, Read on the source, CSRF). The new repo is named like the source (a `-1`, `-2` suffix on conflict), owned by the viewer. Its visibility matches the source; a private source forks to private. - **On disk:** `git clone --bare --local --no-checkout ` via GitCommand. `--local` hardlinks objects, which is cheap and safe because objects are immutable. Copy only branches and tags (`refs/heads/*`, `refs/tags/*`), not `refs/pull/*`. Then apply the managed config the same way `init_bare` does (hooks path and so on). Use alternates? **No**: deleting or trashing the parent would break the fork. - The fork page header shows "forked from owner/name", linked to the source if the viewer can see it. - Deleting a parent repo leaves forks working, with `fork_of` set to NULL in the delete transaction. ## Schema (migration `0007_pulls.sql`) ```sql ALTER TABLE repos ADD COLUMN fork_of TEXT REFERENCES repos(id); CREATE TABLE pull_requests ( issue_id TEXT PRIMARY KEY REFERENCES issues(id) ON DELETE CASCADE, base_branch TEXT NOT NULL, head_repo_id TEXT REFERENCES repos(id), -- NULL if the head repo was deleted head_branch TEXT NOT NULL, head_oid TEXT NOT NULL, -- last known head commit (also refs/pull/N/head) base_oid TEXT NOT NULL, -- base branch tip when mergeability was last computed merge_base TEXT, mergeable TEXT NOT NULL DEFAULT 'unknown', -- 'unknown' | 'clean' | 'conflict' | 'merged' | 'up_to_date' conflict_files TEXT NOT NULL DEFAULT '[]', -- JSON array of paths merged_at TEXT, merged_by TEXT REFERENCES users(id), merge_commit TEXT, allow_maintainer_edits INTEGER NOT NULL DEFAULT 1); CREATE INDEX pull_requests_head ON pull_requests(head_repo_id, head_branch); ``` `Store::delete_repo` (base) must delete pull_requests rows of the repo's issues. Deleting a **head** repo sets `head_repo_id = NULL`; such a PR can't be merged any more, but its stored refs and diff still show. ## forge-core (`src/pulls.rs` model plus `store/pulls.rs`, an `impl Store` block) ```rust pub enum Mergeable { Unknown, Clean, Conflict, Merged, UpToDate } // as_str/parse pub struct PullRequest { pub issue: Issue, pub base_branch: String, pub head_repo_id: Option, pub head_branch: String, pub head_oid: String, pub base_oid: String, pub merge_base: Option, pub mergeable: Mergeable, pub conflict_files: Vec, pub merged_at: Option, pub merged_by: Option, pub merge_commit: Option } pub enum MergeStyle { Merge, Squash, FastForward } // "merge" | "squash" | "ff" // store/pulls.rs pub async fn create_pull(&self, repo_id: &str, author_id: &str, title: &str, body: &str, base_branch: &str, head_repo_id: &str, head_branch: &str, head_oid: &str, base_oid: &str) -> Result; // allocates a number like create_issue (shared counter), kind='pull' pub async fn pull_by_number(&self, repo_id: &str, number: i64) -> Result>; pub async fn list_pulls(&self, repo_id: &str, f: &IssueFilter) -> Result<(Vec, u64)>; // same filters as issues; state 'open'|'closed' (merged PRs are closed) pub async fn pull_counts(&self, repo_id: &str) -> Result<(u64, u64)>; pub async fn open_pulls_for_head(&self, head_repo_id: &str, head_branch: &str) -> Result>; pub async fn open_pulls_for_base(&self, repo_id: &str, base_branch: &str) -> Result>; pub async fn update_pull_head(&self, issue_id: &str, head_oid: &str) -> Result<()>; // resets mergeable to 'unknown' pub async fn set_mergeability(&self, issue_id: &str, base_oid: &str, merge_base: Option<&str>, m: Mergeable, conflicts: &[String]) -> Result<()>; pub async fn mark_merged(&self, issue_id: &str, actor_id: &str, merge_commit: &str) -> Result<()>; // closes the issue (state_reason completed) + 'merged' event {"commit"} pub async fn find_open_pull(&self, base_repo: &str, base_branch: &str, head_repo: &str, head_branch: &str) -> Result>; // prevents duplicates // forks pub async fn create_fork(&self, source: &Repo, owner_id: &str, name: &str) -> Result; // sets fork_of pub async fn list_forks(&self, repo_id: &str) -> Result>; ``` `add_event` must accept the new kinds: `merged`, `head_updated` (data `{"from","to"}`), `base_changed`, and `head_deleted`. ## forge-git (`src/pulls.rs`) ```rust pub async fn fetch_into_pull_ref(env: &GitEnv, base_repo: &Path, head_repo: &Path, head_oid: &str, number: i64) -> Result<()>; // same repo: `update-ref refs/pull/N/head `; fork: `git fetch --no-tags ` then update-ref (uploadpack.allowAnySHA1InWant isn't needed for a local path fetch of a full oid; if it is, fetch the branch ref and verify the oid) pub async fn merge_base(env, repo, a: &str, b: &str) -> Result>; pub enum MergeCheck { Clean { tree: String }, Conflict { files: Vec }, UpToDate } pub async fn check_merge(env, repo, base_oid: &str, head_oid: &str) -> Result; // `git merge-tree --write-tree --name-only --no-messages `; exit 1 = conflict (parse the file list); UpToDate if head is an ancestor of base pub async fn commit_tree(env, repo, tree: &str, parents: &[&str], message: &str, author: &Signature, committer: &Signature) -> Result; // `git commit-tree`, with GIT_AUTHOR_*/GIT_COMMITTER_* env set from the signatures; the message goes in via stdin (never argv) pub async fn update_ref_cas(env, repo, refname: &str, new: &str, old: &str) -> Result<()>; // `update-ref `, an atomic compare-and-swap pub async fn range_diff_files(env, repo, base_oid: &str, head_oid: &str, limits: DiffLimits) -> Result; // three-dot: merge-base..head, same parsing as commit_diff pub async fn range_log(env, repo, base_oid: &str, head_oid: &str, max: usize) -> Result>; // `log ^`, oldest first pub struct Signature { pub name: String, pub email: String, pub time_unix: i64 } ``` **Merging (forge-web, `pulls_merge.rs`):** load the PR; require Write on the base repo, not archived, the PR open, and the head present. Recompute `check_merge` against the CURRENT base tip. - **Merge:** `commit_tree(tree, [base, head], "Merge pull request #N from owner/branch\n\n")`. - **Squash:** `commit_tree(tree, [base], "<title> (#N)\n\n<body or commit list>")`, with the PR author as author. - **FastForward:** only if base is an ancestor of head; new = head. - Then `update_ref_cas(refs/heads/<base>, new, base)`. On a CAS failure, show "base moved, try again". - **Branch protection:** a merge is never a force push. It runs as Write, so allowed. - After the merge, call `mark_merged`, then run the same post-push side effects the hook would: closing keywords from the merged commits, the maintenance trigger, and recomputing mergeability of other PRs on that base. Factor the post-receive logic in `internal.rs` into a reusable `fn after_ref_update(state, repo, actor, refname, old, new)`, then call it from both places. ## Post-receive integration (forge-web `internal.rs`) For every updated `refs/heads/<b>` of repo R (both paths, via `after_ref_update`): - For each open PR whose head is (R, b): `fetch_into_pull_ref`, `update_pull_head`, a `head_updated` event, then recompute mergeability in the background. If the branch was deleted: a `head_deleted` event, and keep the last head. - For each open PR whose base is (R, b): recompute mergeability in the background. - If a push to the base branch makes a PR's head an ancestor of base (someone merged manually on the CLI), call `mark_merged` with the merge commit unknown. Use the base tip, and record the `merged` event. ## Web routes - `GET /{o}/{r}/pulls`: list, same filters as issues; state open/closed, where closed also shows merged with a purple badge. `pulls_list.rs`. - `GET /{o}/{r}/compare/{base}...{head}`, where head is `branch` or `owner:branch` for forks; also `GET /{o}/{r}/compare` with a base/head picker form (plain GET form). `compare.rs`. - Shows the commits, the diff stat and the file diffs (reuse the commit page's diff rendering: extract it into a shared component in `views/diff.rs`). - Shows an "Open pull request" form (title prefilled from the first commit summary, body) if the viewer can Read the base and there are commits. - `POST /{o}/{r}/pulls` creates the PR (CSRF; a duplicate open PR for the same head and base redirects to the existing one). - `GET /{o}/{r}/pulls/{n}` (Conversation tab), `/pulls/{n}/commits`, `/pulls/{n}/files`. `pull_view.rs`. - Header: title, state badge (Open / Merged / Closed), "author wants to merge N commits into base from head". - The Conversation tab reuses the issue timeline, sidebar and comment form. Factor the shared parts of `issue_view.rs` into reusable components; the issue page must keep working and its tests must keep passing. - **Merge box:** mergeability status ("This branch has conflicts that must be resolved", listing the files; "Ready to merge"; "Checking…" when unknown), a style radio (merge, squash, ff when possible) and a Merge button for Write holders. `POST /{o}/{r}/pulls/{n}/merge`. - Close and reopen work like issues. - Repo tabs gain **Pull requests (N)** after Issues. - Repo pages for a forkable repo show a **Fork** button in the header (`POST /fork`). The fork header shows "forked from". - A banner on the repo home after a recent push to a non-default branch ("feature/x had recent pushes — Compare & pull request"): optional, skip if complex. ## Tests (each worker covers its part; the e2e test file `tests/pulls_e2e.rs` uses real git) - Same-repo PR: push `feature`, open the PR via compare, see its commits and files, merge-commit merge, then main contains the feature commit, the PR is merged, and a "Fixes #1" in the PR commits closes issue 1. - Fork PR: bob forks, pushes to his fork, opens a PR to alice's repo. Alice merges with squash, giving a single new commit on main with bob as author. - A conflict is detected and lists the file; the Merge button is disabled and the POST is rejected. - Pushing to the head branch updates the PR (a new commit shows, a `head_updated` event). - A base that moved concurrently gives the CAS failure message (simulate by updating the base between check and update; a unit test in forge-git is fine). - FF-only when possible; otherwise the FF option is hidden or rejected. - Permissions: a Read user can open a PR from their fork but can't merge (403). Private base: anonymous gets 404. - `#N` on the issue page redirects to the PR.