forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
3.5 KiB
1# Branch protection (contract between workers)
2
3The global rules from `docs/m1-interfaces.md` apply.
4
5## Migration `crates/forge-core/migrations/0003_branch_protection.sql`
6```sql
7CREATE 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)]
22pub 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)]
26pub 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.
31pub fn validate_pattern(p: &str) -> Result<()>;
32pub 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}.
39pub fn effective(rules: &[BranchRule], default_branch: &str, branch: &str) -> Protection;
40```
41
42## Store (forge-core)
43```rust
44pub async fn list_branch_rules(&self, repo_id: &str) -> Result<Vec<BranchRule>>; // ordered by pattern
45pub async fn upsert_branch_rule(&self, repo_id: &str, pattern: &str, allow_force_push: bool, allow_deletion: bool) -> Result<BranchRule>;
46pub 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)
50For 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.