//! Deleting and restoring the head branch of a finished pull request: //! `POST /{owner}/{repo}/pulls/{number}/delete_branch` and `POST /{owner}/{repo}/pulls/{number}/restore_branch`. //! //! Both need a merged or closed pull request, a signed-in viewer with a valid CSRF token and Write //! on the head repo (403 otherwise). The head repo must still exist. Deleting needs the branch to //! sit at the pull request's head commit, not to be the default branch and to be deletable under //! branch protection. Restoring needs the branch to be missing and the head commit to still exist //! in the head repo. The ref moves with a compare-and-swap, so a branch that moved meanwhile is //! refused. A refusal sends the viewer back to the pull request with `?branch_error=`, and a //! success is recorded as a `head_ref_deleted` or `head_ref_restored` event on the pull request. use forge_core::protection; use forge_core::{Action, IssueState, PullRequest, User, authorize}; use forge_git::browse::{default_branch_commit, read_commit}; use forge_git::pulls::{delete_ref_cas, update_ref_cas}; use forge_git::repo::repo_path; use serde::Deserialize; use serde_json::json; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{forbidden, not_found, see_other}; use topcoat::router::request::uri; use topcoat::router::response::Response; use topcoat::router::{path_param, route}; use topcoat::view::{View, component, view}; use crate::auth::require_csrf; use crate::i18n::Lang; use crate::i18n::pulls::{self as g, K}; use crate::pages::issue_parts::parse_number; use crate::pages::repo_ctx::{RepoCtx, load_repo}; use crate::post_update::after_ref_update; use crate::state::AppState; use crate::views::repo_href; path_param!(owner); path_param!(repo); path_param!(number); const ZERO_OID: &str = "0000000000000000000000000000000000000000"; /// Refusal codes, sent as `?branch_error=`. The pull request page owns the wording; a code /// outside this set is never echoed back. pub const ERR_PROTECTED: &str = "protected"; pub const ERR_DEFAULT_BRANCH: &str = "default_branch"; pub const ERR_MOVED: &str = "moved"; pub const ERR_MISSING: &str = "missing"; pub const ERR_NOT_ALLOWED: &str = "not_allowed"; #[derive(Deserialize)] struct BranchForm { #[serde(default)] csrf: String, } #[derive(Clone, Copy)] enum Op { Delete, Restore, } /// The branch form the pull request page shows: which action it posts to and for which branch. pub struct BranchAction { pub restore: bool, pub branch: String, pub action: String, pub csrf: String, } #[route(POST "/{owner}/{repo}/pulls/{number}/delete_branch")] async fn delete_branch_post(cx: &Cx, Form(form): Form) -> Result { branch_post(cx, &form.csrf, Op::Delete).await } #[route(POST "/{owner}/{repo}/pulls/{number}/restore_branch")] async fn restore_branch_post(cx: &Cx, Form(form): Form) -> Result { branch_post(cx, &form.csrf, Op::Restore).await } async fn branch_post(cx: &Cx, csrf: &str, op: Op) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let (user, _) = require_csrf(cx, csrf).await?; let state = app_context::(cx); let Some(pr) = state.store.pull_by_number(&ctx.repo.id, number).await? else { return Err(not_found().into()); }; let pull_href = repo_href( &ctx.repo.owner_name, &ctx.repo.name, "pulls", &number.to_string(), ); if ctx.repo.archived { return Err(forbidden().into()); } let head = head_repo(state, &pr).await?; match &head { Some(head) => { let role = state.store.collaborator_role(&head.id, &user.id).await?; let org_role = state .store .org_repo_role(&head.owner_id, &user.id, &head.id) .await?; if head.archived || authorize(Some(&user), head, role, org_role, Action::Write).is_err() { return Err(forbidden().into()); } } None => { if authorize( Some(&user), &ctx.repo, ctx.role, ctx.org_role, Action::Write, ) .is_err() { return Err(forbidden().into()); } } } if !finished(&pr) { return back_to_pull(&pull_href, ERR_NOT_ALLOWED); } let Some(head) = head else { return back_to_pull(&pull_href, ERR_MISSING); }; let refusal = match op { Op::Delete => delete_branch(state, &head, &user, &pr).await?, Op::Restore => restore_branch(state, &head, &user, &pr).await?, }; match refusal { None => Err(see_other(pull_href).into()), Some(code) => back_to_pull(&pull_href, code), } } fn back_to_pull(pull_href: &str, code: &str) -> Result { Err(see_other(format!("{pull_href}?branch_error={code}")).into()) } /// A merged or closed pull request. Only these can have their head branch deleted or restored. fn finished(pr: &PullRequest) -> bool { pr.merged_at.is_some() || pr.issue.state != IssueState::Open } /// The head repo of `pr` when it still exists. async fn head_repo( state: &AppState, pr: &PullRequest, ) -> forge_core::Result> { match &pr.head_repo_id { Some(id) => state.store.repo_by_id(id).await, None => Ok(None), } } /// Deletes the head branch. `None` when it was deleted, `Some(code)` when it was refused. async fn delete_branch( state: &AppState, head: &forge_core::Repo, user: &User, pr: &PullRequest, ) -> forge_core::Result> { if let Some(code) = deletion_refusal(state, head, &pr.head_branch).await? { return Ok(Some(code)); } let path = repo_path(&state.git.data_dir, &head.id)?; let refname = format!("refs/heads/{}", pr.head_branch); match default_branch_commit(&path, &pr.head_branch)? { None => return Ok(Some(ERR_MISSING)), Some(tip) if tip != pr.head_oid => return Ok(Some(ERR_MOVED)), Some(_) => {} } match delete_ref_cas(&state.git, &path, &refname, &pr.head_oid).await { Ok(()) => {} Err(forge_git::error::Error::RefMoved(_)) => return Ok(Some(ERR_MOVED)), Err(e) => return Err(e.into()), } record_ref_change(state, head, user, &refname, &pr.head_oid, ZERO_OID).await; state .store .add_event( &pr.issue.id, Some(&user.id), "head_ref_deleted", json!({"branch": pr.head_branch}), ) .await?; Ok(None) } /// Recreates the head branch at the pull request's head commit. `None` when it was restored, /// `Some(code)` when it was refused. async fn restore_branch( state: &AppState, head: &forge_core::Repo, user: &User, pr: &PullRequest, ) -> forge_core::Result> { let path = repo_path(&state.git.data_dir, &head.id)?; let refname = format!("refs/heads/{}", pr.head_branch); if default_branch_commit(&path, &pr.head_branch)?.is_some() { return Ok(Some(ERR_MOVED)); } if read_commit(&path, &pr.head_oid)?.is_none() { return Ok(Some(ERR_MISSING)); } match update_ref_cas(&state.git, &path, &refname, &pr.head_oid, ZERO_OID).await { Ok(()) => {} Err(forge_git::error::Error::RefMoved(_)) => return Ok(Some(ERR_MOVED)), Err(e) => return Err(e.into()), } record_ref_change(state, head, user, &refname, ZERO_OID, &pr.head_oid).await; state .store .add_event( &pr.issue.id, Some(&user.id), "head_ref_restored", json!({"branch": pr.head_branch}), ) .await?; Ok(None) } /// The refusal that applies to deleting `branch` of `head`, if any: the default branch, or a /// branch that branch protection does not allow to be deleted. async fn deletion_refusal( state: &AppState, head: &forge_core::Repo, branch: &str, ) -> forge_core::Result> { if branch == head.default_branch { return Ok(Some(ERR_DEFAULT_BRANCH)); } let rules = state.store.list_branch_rules(&head.id).await?; if !protection::effective(&rules, &head.default_branch, branch).allow_deletion { return Ok(Some(ERR_PROTECTED)); } Ok(None) } /// Runs the post-update work for a head ref that moved from `old` to `new`. Failures are logged: /// the ref has already moved. async fn record_ref_change( state: &AppState, head: &forge_core::Repo, user: &User, refname: &str, old: &str, new: &str, ) { if let Err(e) = after_ref_update(state, head, &user.id, refname, old, new).await { tracing::error!(error = %e, repo_id = %head.id, ref_name = %refname, "branch: post-update failed"); } } /// The branch action the pull request page should show to the viewer, if any. Only finished /// pull requests get one, and only for viewers with Write on the head repo. pub(crate) async fn branch_action( state: &AppState, ctx: &RepoCtx, pr: &PullRequest, csrf: &str, ) -> forge_core::Result> { let Some((user, _)) = ctx.viewer.as_ref() else { return Ok(None); }; if ctx.repo.archived || !finished(pr) { return Ok(None); } let Some(head) = head_repo(state, pr).await? else { return Ok(None); }; let role = state.store.collaborator_role(&head.id, &user.id).await?; let org_role = state .store .org_repo_role(&head.owner_id, &user.id, &head.id) .await?; if head.archived || authorize(Some(user), &head, role, org_role, Action::Write).is_err() { return Ok(None); } let path = repo_path(&state.git.data_dir, &head.id)?; let href = repo_href( &ctx.repo.owner_name, &ctx.repo.name, "pulls", &pr.issue.number.to_string(), ); let (restore, action) = match default_branch_commit(&path, &pr.head_branch)? { Some(tip) if tip == pr.head_oid => { if deletion_refusal(state, &head, &pr.head_branch) .await? .is_some() { return Ok(None); } (false, "delete_branch") } Some(_) => return Ok(None), None => { if read_commit(&path, &pr.head_oid)?.is_none() { return Ok(None); } (true, "restore_branch") } }; Ok(Some(BranchAction { restore, branch: pr.head_branch.clone(), action: format!("{href}/{action}"), csrf: csrf.to_owned(), })) } /// The message for a `branch_error` code. Unknown codes have no message. pub(crate) fn branch_error_text(lang: Lang, code: &str) -> Option<&'static str> { Some(match code { ERR_PROTECTED => g::t(lang, K::BranchErrProtected), ERR_DEFAULT_BRANCH => g::t(lang, K::BranchErrDefault), ERR_MOVED => g::t(lang, K::BranchErrMoved), ERR_MISSING => g::t(lang, K::BranchErrMissing), ERR_NOT_ALLOWED => g::t(lang, K::BranchErrNotAllowed), _ => return None, }) } /// The message for the `branch_error` query value of the request, when it is a known code. pub(crate) fn branch_error_from(cx: &Cx, lang: Lang) -> Option { let request = uri(cx); let query = request.query()?; let (_, code) = form_urlencoded::parse(query.as_bytes()).find(|(key, _)| *key == "branch_error")?; branch_error_text(lang, &code).map(str::to_owned) } /// The branch box on a finished pull request: an error line and, when the viewer may act, the /// delete or restore form. #[component] pub(crate) async fn branch_box( lang: Lang, action: Option, error: Option, ) -> Result { Ok(view! {
if let Some(text) = error { } if let Some(a) = action { if a.restore {

(g::t(lang, K::BranchPrefix)) (a.branch.as_str()) (g::t(lang, K::BranchWasDeleted))

} else {

(g::t(lang, K::BranchPrefix)) (a.branch.as_str()) (g::t(lang, K::BranchStillHere))

} }
}) } #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; use forge_core::protection::RuleFlags; use forge_core::{Repo, Role, Store, Visibility}; use forge_git::repo::{init_bare, repo_path}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use super::*; use crate::auth::SESSION_COOKIE; use crate::test_support::{app_router, create_session, test_state}; const CSRF: &str = "branch-test-csrf"; fn git(dir: &Path, args: &[&str]) -> String { let out = std::process::Command::new("git") .arg("-C") .arg(dir) .args(args) .env("GIT_AUTHOR_NAME", "test") .env("GIT_AUTHOR_EMAIL", "test@example.com") .env("GIT_COMMITTER_NAME", "test") .env("GIT_COMMITTER_EMAIL", "test@example.com") .output() .expect("git runs"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() } /// Writes `contents` to `file` in the work tree and commits it. fn commit(work: &Path, file: &str, contents: &str) -> String { std::fs::write(work.join(file), contents).expect("write file"); git(work, &["add", file]); git(work, &["commit", "-m", contents]); git(work, &["rev-parse", "HEAD"]) } fn tip(dir: &Path, rev: &str) -> String { git(dir, &["rev-parse", rev]) } struct Fixture { _tmp: tempfile::TempDir, state: AppState, app: Router, repo: Repo, alice: User, bob: User, alice_cookie: String, bare: PathBuf, work: PathBuf, } /// A private repo `alice/proj` with `main` holding `file.txt`, a bare copy on disk and a work /// tree that pushes to it. async fn fixture() -> Fixture { let tmp = tempfile::tempdir().expect("tempdir"); let state = test_state(tmp.path()).await; let store: Store = state.store.clone(); let alice = store .create_user("alice", None, "unused", false) .await .expect("alice"); let bob = store .create_user("bob", None, "unused", false) .await .expect("bob"); let repo = store .create_repo(&alice.id, "proj", "", Visibility::Private, "main") .await .expect("repo"); let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); init_bare(&state.git, &bare, "main") .await .expect("init bare"); let work = tmp.path().join("work"); git(tmp.path(), &["init", "-b", "main", "work"]); commit(&work, "file.txt", "one"); git(&work, &["push", bare.to_str().expect("utf8"), "main"]); create_session(&store, &alice.id, "alice-token", CSRF).await; let alice_cookie = format!("__Host-{SESSION_COOKIE}=alice-token"); let app = app_router(state.clone()); Fixture { _tmp: tmp, state, app, repo, alice, bob, alice_cookie, bare, work, } } impl Fixture { fn bare_str(&self) -> &str { self.bare.to_str().expect("utf8 path") } fn branch_tip(&self, branch: &str) -> String { tip(&self.bare, &format!("refs/heads/{branch}")) } fn branch_exists(&self, branch: &str) -> bool { !git( &self.bare, &["for-each-ref", &format!("refs/heads/{branch}")], ) .is_empty() } async fn pull(&self, number: i64) -> PullRequest { self.state .store .pull_by_number(&self.repo.id, number) .await .expect("lookup") .expect("pull exists") } /// Commits `file` on a new branch cut from main, pushes it and opens a pull request from /// it. Returns the pull request number. async fn open_feature(&self, branch: &str, file: &str, contents: &str) -> i64 { git(&self.work, &["checkout", "-q", "main"]); git(&self.work, &["checkout", "-q", "-B", branch]); commit(&self.work, file, contents); git(&self.work, &["push", "-q", "-f", self.bare_str(), branch]); let base = self.branch_tip("main"); let head = self.branch_tip(branch); let pr = self .state .store .create_pull( &self.repo.id, &self.alice.id, &format!("Add {branch}"), "", "main", &self.repo.id, branch, &head, &base, ) .await .expect("create pull"); pr.issue.number } async fn send(&self, method: &str, path: &str, cookie: &str, body: String) -> Reply { let mut builder = Request::builder() .method(method) .uri(path) .header("cookie", cookie); if method == "POST" { builder = builder.header("content-type", "application/x-www-form-urlencoded"); } let res = self .app .handle(builder.body(Body::from(body)).expect("request")) .await; let status = res.status().as_u16(); let location = res .headers() .get("location") .and_then(|v| v.to_str().ok()) .map(str::to_owned); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); Reply { status, location, body: String::from_utf8_lossy(&bytes).into_owned(), } } async fn sign_in(&self, user: &User, token: &str) -> String { create_session(&self.state.store, &user.id, token, CSRF).await; format!("__Host-{SESSION_COOKIE}={token}") } async fn merge(&self, number: i64) -> Reply { self.send( "POST", &format!("/alice/proj/pulls/{number}/merge"), &self.alice_cookie, csrf_form(&[("style", "merge")]), ) .await } async fn branch_post(&self, number: i64, op: &str, cookie: &str) -> Reply { self.send( "POST", &format!("/alice/proj/pulls/{number}/{op}"), cookie, csrf_form(&[]), ) .await } } struct Reply { status: u16, location: Option, body: String, } fn csrf_form(pairs: &[(&str, &str)]) -> String { let mut form = form_urlencoded::Serializer::new(String::new()); form.append_pair("csrf", CSRF); for (key, value) in pairs { form.append_pair(key, value); } form.finish() } /// Asserts a refusal: a 303 back to the pull request with `branch_error=`. fn assert_refused(reply: &Reply, number: i64, code: &str) { assert_eq!(reply.status, 303, "body: {}", reply.body); assert_eq!( reply.location.as_deref(), Some(format!("/alice/proj/pulls/{number}?branch_error={code}").as_str()) ); } #[tokio::test] async fn merge_then_delete_removes_branch_and_records_event() { let fx = fixture().await; let number = fx.open_feature("feature", "f.txt", "feature").await; let merged = fx.merge(number).await; assert_eq!(merged.status, 303, "body: {}", merged.body); let deleted = fx .branch_post(number, "delete_branch", &fx.alice_cookie) .await; assert_eq!(deleted.status, 303, "body: {}", deleted.body); assert_eq!( deleted.location.as_deref(), Some(format!("/alice/proj/pulls/{number}").as_str()) ); assert!(!fx.branch_exists("feature")); let pr = fx.pull(number).await; let events = fx .state .store .list_events(&pr.issue.id) .await .expect("events"); let deletion = events .iter() .find(|e| e.kind == "head_ref_deleted") .expect("deletion event"); assert_eq!(deletion.data["branch"], "feature"); // The timeline names the branch. let page = fx .send( "GET", &format!("/alice/proj/pulls/{number}"), &fx.alice_cookie, String::new(), ) .await; assert!( page.body.contains("deleted the feature branch"), "{}", page.body ); assert!(page.body.contains("Restore branch"), "{}", page.body); let restored = fx .branch_post(number, "restore_branch", &fx.alice_cookie) .await; assert_eq!(restored.status, 303, "body: {}", restored.body); assert_eq!(fx.branch_tip("feature"), pr.head_oid); let events = fx .state .store .list_events(&pr.issue.id) .await .expect("events"); assert!(events.iter().any(|e| e.kind == "head_ref_restored")); } #[tokio::test] async fn protected_branch_is_refused() { let fx = fixture().await; let number = fx.open_feature("feature", "f.txt", "feature").await; fx.merge(number).await; fx.state .store .upsert_branch_rule(&fx.repo.id, "feature", RuleFlags::basic(false, false)) .await .expect("rule"); let reply = fx .branch_post(number, "delete_branch", &fx.alice_cookie) .await; assert_refused(&reply, number, "protected"); assert!(fx.branch_exists("feature")); } #[tokio::test] async fn branch_moved_after_merge_is_refused() { let fx = fixture().await; let number = fx.open_feature("feature", "f.txt", "feature").await; fx.merge(number).await; git(&fx.work, &["checkout", "-q", "feature"]); commit(&fx.work, "more.txt", "more"); git(&fx.work, &["push", "-q", fx.bare_str(), "feature"]); let moved_to = fx.branch_tip("feature"); let reply = fx .branch_post(number, "delete_branch", &fx.alice_cookie) .await; assert_refused(&reply, number, "moved"); assert_eq!(fx.branch_tip("feature"), moved_to); } #[tokio::test] async fn read_only_collaborator_is_forbidden_and_sees_no_button() { let fx = fixture().await; let number = fx.open_feature("feature", "f.txt", "feature").await; fx.merge(number).await; fx.state .store .set_collaborator(&fx.repo.id, &fx.bob.id, Role::Read) .await .expect("collaborator"); let bob_cookie = fx.sign_in(&fx.bob, "bob-token").await; let reply = fx.branch_post(number, "delete_branch", &bob_cookie).await; assert_eq!(reply.status, 403, "body: {}", reply.body); assert!(fx.branch_exists("feature")); let page = fx .send( "GET", &format!("/alice/proj/pulls/{number}"), &bob_cookie, String::new(), ) .await; assert!(!page.body.contains("Delete branch"), "{}", page.body); } #[tokio::test] async fn default_branch_is_refused() { let fx = fixture().await; // A finished pull request whose head is the default branch itself. let main = fx.branch_tip("main"); let pr = fx .state .store .create_pull( &fx.repo.id, &fx.alice.id, "Main", "", "dev", &fx.repo.id, "main", &main, &main, ) .await .expect("create pull"); fx.state .store .mark_merged(&pr.issue.id, &fx.alice.id, &main) .await .expect("mark merged"); let number = pr.issue.number; let reply = fx .branch_post(number, "delete_branch", &fx.alice_cookie) .await; assert_refused(&reply, number, "default_branch"); assert!(fx.branch_exists("main")); } #[test] fn unknown_branch_error_codes_have_no_message() { assert!(branch_error_text(Lang::En, "protected").is_some()); assert!(branch_error_text(Lang::En, "