Tahinli/forge
public0 watching
★ 0
| 1 | # Branch protection (contract between workers) |
| 2 | |
| 3 | The global rules from `docs/m1-interfaces.md` apply. |
| 4 | |
| 5 | ## Migration `crates/forge-core/migrations/0003_branch_protection.sql` |
| 6 | ```sql |
| 7 | CREATE TABLE branch_protections ( |
| 8 | id TEXT PRIMARY KEY, -- ULID |
| 9 | repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, |
| 10 | pattern TEXT NOT NULL, -- "main", "release/*", "**" |
| 11 | allow_force_push INTEGER NOT NULL DEFAULT 0, |
| 12 | allow_deletion INTEGER NOT NULL DEFAULT 0, |
| 13 | created_at TEXT NOT NULL, |
| 14 | UNIQUE (repo_id, pattern) |
| 15 | ); |
| 16 | ``` |
| 17 | `Store::delete_repo` must delete these rows too, in the same transaction. |
| 18 | |
| 19 | ## forge-core `src/protection.rs` |
| 20 | ```rust |
| 21 | #[derive(Debug, Clone, PartialEq)] |
| 22 | pub struct BranchRule { pub id: String, pub repo_id: String, pub pattern: String, |
| 23 | pub allow_force_push: bool, pub allow_deletion: bool, pub created_at: String } |
| 24 | |
| 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 26 | pub struct Protection { pub allow_force_push: bool, pub allow_deletion: bool } |
| 27 | |
| 28 | /// Pattern syntax: a literal branch name, `*` matches any run of chars except `/`, `**` matches anything |
| 29 | /// including `/`. Max 200 bytes, it must be a valid branch name otherwise (no `..`, no leading `-`, no |
| 30 | /// control or space chars, no `~^:?[\`). The pattern must not include the `refs/heads/` prefix. |
| 31 | pub fn validate_pattern(p: &str) -> Result<()>; |
| 32 | pub fn pattern_matches(pattern: &str, branch: &str) -> bool; |
| 33 | |
| 34 | /// Rules for `branch` (short name, without refs/heads/): |
| 35 | /// 1. If any rule matches: the most specific one wins. A literal (no `*`) beats a glob; among globs the |
| 36 | /// longest pattern wins, and ties go to the earliest created_at. |
| 37 | /// 2. Otherwise, if `branch == default_branch`: built-in protection {force_push: false, deletion: false}. |
| 38 | /// 3. Otherwise: unprotected {force_push: true, deletion: true}. |
| 39 | pub fn effective(rules: &[BranchRule], default_branch: &str, branch: &str) -> Protection; |
| 40 | ``` |
| 41 | |
| 42 | ## Store (forge-core) |
| 43 | ```rust |
| 44 | pub async fn list_branch_rules(&self, repo_id: &str) -> Result<Vec<BranchRule>>; // ordered by pattern |
| 45 | pub async fn upsert_branch_rule(&self, repo_id: &str, pattern: &str, allow_force_push: bool, allow_deletion: bool) -> Result<BranchRule>; |
| 46 | pub async fn delete_branch_rule(&self, repo_id: &str, rule_id: &str) -> Result<()>; // scoped by repo_id |
| 47 | ``` |
| 48 | |
| 49 | ## Enforcement (forge-web `internal.rs` pre-receive) |
| 50 | For each updated `refs/heads/<branch>`, compute `effective(rules, repo.default_branch, branch)`: |
| 51 | - deletion (new = zero oid) with `!allow_deletion` → reject: `deleting protected branch '<b>' is not allowed` |
| 52 | - non-fast-forward (the existing `rev-list` check) with `!allow_force_push` → reject: `force-push to protected branch '<b>' is not allowed` |
| 53 | - Pushes to the archived repo and the ref-name checks stay as they are. Tags are not covered by these rules. |
| 54 | |
| 55 | ## UI (forge-web `pages/repo_settings.rs`) |
| 56 | - On `/{owner}/{repo}/settings`, a "Branch protection" section above the Danger zone: |
| 57 | - A table of rules: pattern, force push allowed (yes/no), deletion allowed (yes/no), and a Remove button (POST with CSRF). |
| 58 | - If no rule matches the default branch, show a muted row: "`<default>` (default branch): protected by default; add a rule for it to change this." |
| 59 | - An add/update form: pattern (prefilled with the default branch), checkboxes "Allow force pushes" and "Allow deletion", and a Save button. Saving an existing pattern updates it. |
| 60 | - Who can manage rules: `Action::Admin` (the repo owner, instance admins, and collaborators with the Admin role). Others get 404 or 403, like the existing settings. |