Tahinli/forge
public0 watching
★ 0
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-optionswhere git supports it. - No shell, no unwrap in non-test code. Clippy
-D warningsand fmt must pass. - No inline styles and no JS.
- Every page goes through
load_repoandauthorize, and times useviewer_tz. - Never put cargo target dirs or big files in /tmp, and tests use
tempfile::TempDir.
Concepts
- A PR is an
issuesrow withkind = '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 filterkind = 'issue'; PR pages filterkind = 'pull'.#Nlinks go to/issues/N, and the issue page redirects (303) to/pulls/Nwhen 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/<N>/headpointing 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 localgit fetchfrom the fork's path.
Forks
reposgainsfork_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,-2suffix 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 <src_path> <new_path>via GitCommand.--localhardlinks objects, which is cheap and safe because objects are immutable. Copy only branches and tags (refs/heads/*,refs/tags/*), notrefs/pull/*. Then apply the managed config the same wayinit_baredoes (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_ofset to NULL in the delete transaction.
Schema (migration 0007_pulls.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)
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<Id>,
pub head_branch: String, pub head_oid: String, pub base_oid: String, pub merge_base: Option<String>,
pub mergeable: Mergeable, pub conflict_files: Vec<String>, pub merged_at: Option<String>,
pub merged_by: Option<Id>, pub merge_commit: Option<String> }
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<PullRequest>; // allocates a number like create_issue (shared counter), kind='pull'
pub async fn pull_by_number(&self, repo_id: &str, number: i64) -> Result<Option<PullRequest>>;
pub async fn list_pulls(&self, repo_id: &str, f: &IssueFilter) -> Result<(Vec<IssueListItem>, 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<Vec<(Id /*base repo*/, i64 /*number*/, Id /*issue id*/)>>;
pub async fn open_pulls_for_base(&self, repo_id: &str, base_branch: &str) -> Result<Vec<(i64, Id)>>;
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<Option<i64>>; // prevents duplicates
// forks
pub async fn create_fork(&self, source: &Repo, owner_id: &str, name: &str) -> Result<Repo>; // sets fork_of
pub async fn list_forks(&self, repo_id: &str) -> Result<Vec<Repo>>;
add_event must accept the new kinds: merged, head_updated (data {"from","to"}), base_changed, and head_deleted.
forge-git (src/pulls.rs)
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 <oid>`; fork: `git fetch --no-tags <head_repo_path> <oid>` 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<Option<String>>;
pub enum MergeCheck { Clean { tree: String }, Conflict { files: Vec<String> }, UpToDate }
pub async fn check_merge(env, repo, base_oid: &str, head_oid: &str) -> Result<MergeCheck>;
// `git merge-tree --write-tree --name-only --no-messages <base> <head>`; 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<String>;
// `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 <ref> <new> <old>`, an atomic compare-and-swap
pub async fn range_diff_files(env, repo, base_oid: &str, head_oid: &str, limits: DiffLimits) -> Result<CommitDiff>; // 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<Vec<CommitInfo>>; // `log <head> ^<base>`, 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<title>"). - 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 ininternal.rsinto a reusablefn 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, ahead_updatedevent, then recompute mergeability in the background. If the branch was deleted: ahead_deletedevent, 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_mergedwith the merge commit unknown. Use the base tip, and record themergedevent.
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 isbranchorowner:branchfor forks; alsoGET /{o}/{r}/comparewith 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}/pullscreates the PR (CSRF; a duplicate open PR for the same head and base redirects to the existing one).
- Shows the commits, the diff stat and the file diffs (reuse the commit page's diff rendering: extract it into a shared component in
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.rsinto 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_updatedevent). - 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.
#Non the issue page redirects to the PR.