Tahinli/forge
public0 watching
★ 0
Branch protection (contract between workers)
The global rules from docs/m1-interfaces.md apply.
Migration crates/forge-core/migrations/0003_branch_protection.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
#[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)
pub async fn list_branch_rules(&self, repo_id: &str) -> Result<Vec<BranchRule>>; // ordered by pattern
pub async fn upsert_branch_rule(&self, repo_id: &str, pattern: &str, allow_force_push: bool, allow_deletion: bool) -> Result<BranchRule>;
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/<branch>, compute effective(rules, repo.default_branch, branch):
- deletion (new = zero oid) with
!allow_deletion→ reject:deleting protected branch '<b>' is not allowed - non-fast-forward (the existing
rev-listcheck) with!allow_force_push→ reject:force-push to protected branch '<b>' 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>(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.