# Branch protection (contract between workers) The global rules from `docs/m1-interfaces.md` apply. ## Migration `crates/forge-core/migrations/0003_branch_protection.sql` ```sql CREATE TABLE branch_protections ( id TEXT PRIMARY KEY, -- ULID repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, pattern TEXT NOT NULL, -- "main", "release/*", "**" allow_force_push INTEGER NOT NULL DEFAULT 0, allow_deletion INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE (repo_id, pattern) ); ``` `Store::delete_repo` must delete these rows too, in the same transaction. ## forge-core `src/protection.rs` ```rust #[derive(Debug, Clone, PartialEq)] pub struct BranchRule { pub id: String, pub repo_id: String, pub pattern: String, pub allow_force_push: bool, pub allow_deletion: bool, pub created_at: String } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Protection { pub allow_force_push: bool, pub allow_deletion: bool } /// Pattern syntax: a literal branch name, `*` matches any run of chars except `/`, `**` matches anything /// including `/`. Max 200 bytes, it must be a valid branch name otherwise (no `..`, no leading `-`, no /// control or space chars, no `~^:?[\`). The pattern must not include the `refs/heads/` prefix. pub fn validate_pattern(p: &str) -> Result<()>; pub fn pattern_matches(pattern: &str, branch: &str) -> bool; /// Rules for `branch` (short name, without refs/heads/): /// 1. If any rule matches: the most specific one wins. A literal (no `*`) beats a glob; among globs the /// longest pattern wins, and ties go to the earliest created_at. /// 2. Otherwise, if `branch == default_branch`: built-in protection {force_push: false, deletion: false}. /// 3. Otherwise: unprotected {force_push: true, deletion: true}. pub fn effective(rules: &[BranchRule], default_branch: &str, branch: &str) -> Protection; ``` ## Store (forge-core) ```rust pub async fn list_branch_rules(&self, repo_id: &str) -> Result>; // ordered by pattern pub async fn upsert_branch_rule(&self, repo_id: &str, pattern: &str, allow_force_push: bool, allow_deletion: bool) -> Result; pub async fn delete_branch_rule(&self, repo_id: &str, rule_id: &str) -> Result<()>; // scoped by repo_id ``` ## Enforcement (forge-web `internal.rs` pre-receive) For each updated `refs/heads/`, compute `effective(rules, repo.default_branch, branch)`: - deletion (new = zero oid) with `!allow_deletion` → reject: `deleting protected branch '' is not allowed` - non-fast-forward (the existing `rev-list` check) with `!allow_force_push` → reject: `force-push to protected branch '' is not allowed` - Pushes to the archived repo and the ref-name checks stay as they are. Tags are not covered by these rules. ## UI (forge-web `pages/repo_settings.rs`) - On `/{owner}/{repo}/settings`, a "Branch protection" section above the Danger zone: - A table of rules: pattern, force push allowed (yes/no), deletion allowed (yes/no), and a Remove button (POST with CSRF). - If no rule matches the default branch, show a muted row: "`` (default branch): protected by default; add a rule for it to change this." - 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. - 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.