//! Keeps pull requests in step with their branches after a ref update. //! //! Called from `post_update::after_ref_update` for every `refs/heads/` update of a repo. //! Work runs inline (not spawned): each step is a few git calls and a few row writes, and inline //! execution keeps the results deterministic for tests and for the merge route. Per-pull errors //! are logged and skipped so one broken pull request does not block the others or the push. use std::collections::HashMap; use std::path::Path; use forge_core::reviews::{ReviewComment, Side}; use forge_core::{Mergeable, PullRequest, Repo}; use forge_git::pulls::{ MergeCheck, blob_at, check_merge, fetch_into_pull_ref, is_ancestor, merge_base, }; use serde_json::json; use similar::{ChangeTag, TextDiff}; use crate::internal::is_zero_oid; use crate::notify::{self, Activity}; use crate::state::AppState; use crate::webhooks::emit::{emit_item_action, lookup_sender}; /// Syncs every open pull request affected by `refname` (old -> new) in `repo`. /// /// - PRs whose head is `(repo, branch)`: the pull ref is updated, the head moves, a `head_updated` /// event is recorded and mergeability is refreshed. A deleted branch records `head_deleted` and /// keeps the last head. /// - PRs whose base is `(repo, branch)`: if the PR head is now contained in the new base tip and /// was not contained before the push, the PR is marked merged (a manual merge on the CLI). /// Otherwise mergeability is refreshed. pub async fn sync_pulls_for_ref( state: &AppState, repo: &Repo, actor_id: &str, refname: &str, old: &str, new: &str, ) -> forge_core::Result<()> { let Some(branch) = refname.strip_prefix("refs/heads/") else { return Ok(()); }; let heads = state.store.open_pulls_for_head(&repo.id, branch).await?; for (base_repo_id, number, issue_id) in heads { if let Err(e) = sync_head( state, repo, actor_id, &base_repo_id, number, &issue_id, branch, old, new, ) .await { tracing::error!(error = %e, repo_id = %repo.id, number, "pull sync: head update failed"); } } let bases = state.store.open_pulls_for_base(&repo.id, branch).await?; for (number, issue_id) in bases { if let Err(e) = sync_base(state, repo, actor_id, number, &issue_id, old, new).await { tracing::error!(error = %e, repo_id = %repo.id, number, "pull sync: base update failed"); } } Ok(()) } /// Handles one open PR whose head branch is `branch` in `head_repo`. #[allow(clippy::too_many_arguments)] async fn sync_head( state: &AppState, head_repo: &Repo, actor_id: &str, base_repo_id: &str, number: i64, issue_id: &str, branch: &str, old: &str, new: &str, ) -> forge_core::Result<()> { let actor = (!actor_id.is_empty()).then_some(actor_id); let Some(pr) = state.store.pull_by_number(base_repo_id, number).await? else { return Ok(()); }; if is_zero_oid(new) { state .store .add_event( issue_id, actor, "head_deleted", json!({"branch": branch, "last_head": pr.head_oid}), ) .await?; return Ok(()); } let Some(base_repo) = state.store.repo_by_id(base_repo_id).await? else { return Ok(()); }; let base_path = forge_git::repo::repo_path(&state.git.data_dir, &base_repo.id)?; let head_path = forge_git::repo::repo_path(&state.git.data_dir, &head_repo.id)?; fetch_into_pull_ref(&state.git, &base_path, &head_path, new, number).await?; state.store.update_pull_head(issue_id, new).await?; mark_outdated_threads(state, &base_path, issue_id, new).await; state .store .add_event( issue_id, actor, "head_updated", json!({"from": old, "to": new}), ) .await?; let sender = lookup_sender(state, actor_id).await; emit_item_action(state, &base_repo, &sender, true, number, "synchronize").await; // Reload so the refresh sees the new head. let Some(pr) = state.store.pull_by_number(base_repo_id, number).await? else { return Ok(()); }; notify::dispatch( state, &base_repo, &pr.issue, actor_id, Activity::Pushed, None, ) .await; crate::ci::on_pull_event(state, &base_repo, &pr, actor_id).await; refresh_mergeability(state, &base_repo, &pr).await } /// Largest file, in bytes, whose text is compared line by line when a moved head is checked. /// Bigger files, binary files and missing files fall back to outdated. const MAX_REMAP_BYTES: usize = 1024 * 1024; /// Handles submitted root comments after the head moved to `new_head`. /// /// A root is left alone while the blob at its `path` is the same at its `commit_oid` and at /// `new_head`. When the blob changed, a new-side root whose line kept its content is moved to the /// line's new number at `new_head` (replies follow it). Every other changed root is outdated: /// its line was deleted or modified, or the file vanished, grew past `MAX_REMAP_BYTES` or became /// binary. Blob lookups are cached per `(commit, path)`. Failures are logged and leave the /// comments as they were, so they never fail the push. async fn mark_outdated_threads(state: &AppState, base_path: &Path, issue_id: &str, new_head: &str) { let threads = match state.store.review_threads(issue_id, None).await { Ok(threads) => threads, Err(e) => { tracing::error!(error = %e, issue_id, "pull sync: loading review threads failed"); return; } }; let mut cache = HashMap::new(); let mut texts = HashMap::new(); let mut outdated = Vec::new(); let mut moved = Vec::new(); for thread in threads { let root = thread.root; if root.outdated { continue; } let old = cached_blob(state, base_path, &mut cache, &root.commit_oid, &root.path).await; let new = cached_blob(state, base_path, &mut cache, new_head, &root.path).await; let (old, new) = match (old, new) { (Ok(old), Ok(new)) => (old, new), (Err(e), _) | (_, Err(e)) => { tracing::error!(error = %e, comment_id = %root.id, "pull sync: blob lookup failed"); continue; } }; if old == new { continue; } let target = if root.side == Side::New && old.is_some() && new.is_some() { match remap_root_line(base_path, &mut texts, &root, new_head) { Ok(target) => target, Err(e) => { tracing::error!(error = %e, comment_id = %root.id, "pull sync: text lookup failed"); continue; } } } else { None }; match target { Some(line) => moved.push((root.id.clone(), line)), None => outdated.push(root.id.clone()), } } for (id, line) in &moved { if let Err(e) = state .store .relocate_review_comment(id, *line, new_head) .await { tracing::error!(error = %e, comment_id = %id, "pull sync: relocating comment failed"); } } if outdated.is_empty() { return; } if let Err(e) = state.store.mark_outdated(issue_id, &outdated).await { tracing::error!(error = %e, issue_id, "pull sync: marking comments outdated failed"); } } /// The line a changed root moves to at `new_head`, or None when it must be outdated. The root's /// line is looked up in the text at its own commit and at `new_head`; a missing or oversized text /// gives None. fn remap_root_line( base_path: &Path, texts: &mut HashMap<(String, String), Option>, root: &ReviewComment, new_head: &str, ) -> forge_core::Result> { let old = cached_text(base_path, texts, &root.commit_oid, &root.path)?; let new = cached_text(base_path, texts, new_head, &root.path)?; Ok(match (old, new) { (Some(old), Some(new)) => unchanged_line(&old, &new, root.line), _ => None, }) } /// The new line number of `old_line` (1-based) when its content is unchanged in `new`, or None /// when the line was deleted or modified. fn unchanged_line(old: &str, new: &str, old_line: u32) -> Option { let index = usize::try_from(old_line).ok()?.checked_sub(1)?; TextDiff::from_lines(old, new) .iter_all_changes() .find(|change| change.tag() == ChangeTag::Equal && change.old_index() == Some(index)) .and_then(|change| change.new_index()) .and_then(|index| u32::try_from(index + 1).ok()) } /// The text of the file at `path` in `commit`, looked up once per `(commit, path)`. fn cached_text( base_path: &Path, cache: &mut HashMap<(String, String), Option>, commit: &str, path: &str, ) -> forge_core::Result> { let key = (commit.to_owned(), path.to_owned()); if let Some(text) = cache.get(&key) { return Ok(text.clone()); } let text = forge_git::browse::read_text_blob(base_path, commit, path, MAX_REMAP_BYTES)?; cache.insert(key, text.clone()); Ok(text) } /// The blob at `path` in `commit`, looked up once per `(commit, path)`. async fn cached_blob( state: &AppState, base_path: &Path, cache: &mut HashMap<(String, String), Option>, commit: &str, path: &str, ) -> forge_core::Result> { let key = (commit.to_owned(), path.to_owned()); if let Some(blob) = cache.get(&key) { return Ok(blob.clone()); } let blob = blob_at(&state.git, base_path, commit, path).await?; cache.insert(key, blob.clone()); Ok(blob) } /// Handles one open PR whose base branch was just updated in `repo`. async fn sync_base( state: &AppState, repo: &Repo, actor_id: &str, number: i64, issue_id: &str, old: &str, new: &str, ) -> forge_core::Result<()> { if is_zero_oid(new) { // The base branch was deleted; there is nothing to compare against. return Ok(()); } let Some(pr) = state.store.pull_by_number(&repo.id, number).await? else { return Ok(()); }; let base_path = forge_git::repo::repo_path(&state.git.data_dir, &repo.id)?; if is_ancestor(&state.git, &base_path, &pr.head_oid, new).await? { let was_contained = !is_zero_oid(old) && is_ancestor(&state.git, &base_path, &pr.head_oid, old).await?; if !was_contained { // The push brought the head into base: a merge done outside the web UI. state.store.mark_merged(issue_id, actor_id, new).await?; let sender = lookup_sender(state, actor_id).await; emit_item_action(state, repo, &sender, true, number, "merged").await; notify::dispatch(state, repo, &pr.issue, actor_id, Activity::Merged, None).await; return Ok(()); } } refresh_mergeability(state, repo, &pr).await } /// Recomputes whether `pr` merges cleanly into the current tip of its base branch in `base_repo`, /// and stores the result (base tip, merge base, mergeability, conflicting files). /// /// A missing base branch leaves the stored result untouched. Git failures are returned; callers /// that must not fail a push log them. pub async fn refresh_mergeability( state: &AppState, base_repo: &Repo, pr: &PullRequest, ) -> forge_core::Result<()> { let base_path = forge_git::repo::repo_path(&state.git.data_dir, &base_repo.id)?; let Some(base_tip) = forge_git::browse::default_branch_commit(&base_path, &pr.base_branch)? else { return Ok(()); }; let (mergeable, files) = match check_merge(&state.git, &base_path, &base_tip, &pr.head_oid).await? { MergeCheck::Clean { .. } => (Mergeable::Clean, Vec::new()), MergeCheck::Conflict { files } => (Mergeable::Conflict, files), MergeCheck::UpToDate => (Mergeable::UpToDate, Vec::new()), }; let base_of_head = merge_base(&state.git, &base_path, &base_tip, &pr.head_oid).await?; state .store .set_mergeability( &pr.issue.id, &base_tip, base_of_head.as_deref(), mergeable, &files, ) .await?; Ok(()) } #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; use forge_core::{IssueState, Visibility}; use forge_git::repo::{init_bare, repo_path}; use tempfile::TempDir; use super::*; use crate::post_update::after_ref_update; use crate::test_support::test_state; 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") .env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_GLOBAL", "/dev/null") .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, commits it and returns the new HEAD. 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, &[ "-c", "commit.gpgsign=false", "commit", "-q", "-m", &format!("update {file}"), ], ); git(work, &["rev-parse", "HEAD"]) } struct Fixture { _tmp: TempDir, state: AppState, repo: Repo, owner_id: String, bare: PathBuf, work: PathBuf, } /// A private repo with a bare copy on disk and an empty work tree that pushes to it. async fn fixture() -> Fixture { let tmp = tempfile::tempdir().expect("tempdir"); let state = test_state(tmp.path()).await; let owner = state .store .create_user("alice", None, "unused", false) .await .expect("user"); let repo = state .store .create_repo(&owner.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"]); Fixture { _tmp: tmp, state, repo, owner_id: owner.id, bare, work, } } impl Fixture { fn bare_str(&self) -> &str { self.bare.to_str().expect("utf8 path") } fn push(&self, refspec: &str) { git(&self.work, &["push", self.bare_str(), refspec]); } /// Pushes `refspec` and runs the post-update work the hook would run. async fn push_and_sync(&self, branch: &str, old: &str, new: &str) { self.push(&format!("{new}:refs/heads/{branch}")); after_ref_update( &self.state, &self.repo, &self.owner_id, &format!("refs/heads/{branch}"), old, new, ) .await .expect("post-update"); } async fn pull(&self, number: i64) -> PullRequest { self.state .store .pull_by_number(&self.repo.id, number) .await .expect("lookup") .expect("pull exists") } } #[tokio::test] async fn pushing_to_head_branch_updates_the_pull() { let fx = fixture().await; let base = commit(&fx.work, "file.txt", "one"); fx.push("main"); git(&fx.work, &["checkout", "-b", "feature"]); let f1 = commit(&fx.work, "feature.txt", "f1"); fx.push("feature"); let pr = fx .state .store .create_pull( &fx.repo.id, &fx.owner_id, "Feature", "", "main", &fx.repo.id, "feature", &f1, &base, ) .await .expect("create pull"); let number = pr.issue.number; let f2 = commit(&fx.work, "feature.txt", "f2"); fx.push_and_sync("feature", &f1, &f2).await; let pr = fx.pull(number).await; assert_eq!(pr.head_oid, f2); assert_eq!(pr.mergeable, Mergeable::Clean); let pull_ref = git( &fx.bare, &["rev-parse", &format!("refs/pull/{number}/head")], ); assert_eq!(pull_ref, f2); let events = fx .state .store .list_events(&pr.issue.id) .await .expect("events"); let updated = events .iter() .find(|e| e.kind == "head_updated") .expect("head_updated event"); assert_eq!(updated.data["from"], f1); assert_eq!(updated.data["to"], f2); assert_eq!(updated.actor_id.as_deref(), Some(fx.owner_id.as_str())); } #[tokio::test] async fn deleting_head_branch_keeps_last_head() { let fx = fixture().await; let base = commit(&fx.work, "file.txt", "one"); fx.push("main"); git(&fx.work, &["checkout", "-b", "feature"]); let f1 = commit(&fx.work, "feature.txt", "f1"); fx.push("feature"); let pr = fx .state .store .create_pull( &fx.repo.id, &fx.owner_id, "Feature", "", "main", &fx.repo.id, "feature", &f1, &base, ) .await .expect("create pull"); let zero = "0".repeat(40); git(&fx.bare, &["update-ref", "-d", "refs/heads/feature"]); after_ref_update( &fx.state, &fx.repo, &fx.owner_id, "refs/heads/feature", &f1, &zero, ) .await .expect("post-update"); let pr = fx.pull(pr.issue.number).await; assert_eq!(pr.head_oid, f1); let events = fx .state .store .list_events(&pr.issue.id) .await .expect("events"); assert!(events.iter().any(|e| e.kind == "head_deleted")); } #[tokio::test] async fn pushing_conflicting_base_marks_pull_conflicting() { let fx = fixture().await; let base = commit(&fx.work, "file.txt", "one"); fx.push("main"); git(&fx.work, &["checkout", "-b", "feature"]); let f1 = commit(&fx.work, "file.txt", "feature"); fx.push("feature"); let pr = fx .state .store .create_pull( &fx.repo.id, &fx.owner_id, "Feature", "", "main", &fx.repo.id, "feature", &f1, &base, ) .await .expect("create pull"); assert_eq!(pr.mergeable, Mergeable::Unknown); git(&fx.work, &["checkout", "main"]); let m1 = commit(&fx.work, "file.txt", "main"); fx.push_and_sync("main", &base, &m1).await; let pr = fx.pull(pr.issue.number).await; assert_eq!(pr.mergeable, Mergeable::Conflict); assert_eq!(pr.conflict_files, vec!["file.txt".to_string()]); assert_eq!(pr.base_oid, m1); assert_eq!(pr.merge_base.as_deref(), Some(base.as_str())); } #[tokio::test] async fn pushing_a_changed_file_marks_its_comments_outdated() { use forge_core::reviews::Side; let fx = fixture().await; commit(&fx.work, "b.txt", "b0"); let base = commit(&fx.work, "a.txt", "a0"); fx.push("main"); git(&fx.work, &["checkout", "-b", "feature"]); let f1 = commit(&fx.work, "a.txt", "a1"); fx.push("feature"); let pr = fx .state .store .create_pull( &fx.repo.id, &fx.owner_id, "Feature", "", "main", &fx.repo.id, "feature", &f1, &base, ) .await .expect("create pull"); let store = &fx.state.store; store .add_review_comment( &pr.issue.id, &fx.owner_id, false, "a.txt", Side::New, 1, &f1, "", "on a", ) .await .expect("comment on a"); store .add_review_comment( &pr.issue.id, &fx.owner_id, false, "b.txt", Side::New, 1, &f1, "", "on b", ) .await .expect("comment on b"); let f2 = commit(&fx.work, "a.txt", "a2"); fx.push_and_sync("feature", &f1, &f2).await; let threads = store .review_threads(&pr.issue.id, None) .await .expect("threads"); let outdated_at = |path: &str| { threads .iter() .find(|t| t.root.path == path) .map(|t| t.root.outdated) }; assert_eq!(outdated_at("a.txt"), Some(true)); assert_eq!(outdated_at("b.txt"), Some(false)); } #[tokio::test] async fn base_push_containing_head_marks_pull_merged() { let fx = fixture().await; let base = commit(&fx.work, "file.txt", "one"); fx.push("main"); git(&fx.work, &["checkout", "-b", "feature"]); let f1 = commit(&fx.work, "feature.txt", "f1"); fx.push("feature"); let pr = fx .state .store .create_pull( &fx.repo.id, &fx.owner_id, "Feature", "", "main", &fx.repo.id, "feature", &f1, &base, ) .await .expect("create pull"); // A manual merge on the CLI: main gets a side commit, then the feature branch. git(&fx.work, &["checkout", "main"]); let side = commit(&fx.work, "other.txt", "other"); git( &fx.work, &["merge", "--no-ff", "feature", "-m", "merge feature"], ); let merge = git(&fx.work, &["rev-parse", "HEAD"]); assert_ne!(merge, side); fx.push_and_sync("main", &side, &merge).await; let pr = fx.pull(pr.issue.number).await; assert_eq!(pr.mergeable, Mergeable::Merged); assert_eq!(pr.merge_commit.as_deref(), Some(merge.as_str())); assert!(pr.merged_at.is_some()); assert_eq!(pr.issue.state, IssueState::Closed); let events = fx .state .store .list_events(&pr.issue.id) .await .expect("events"); assert!(events.iter().any(|e| e.kind == "merged")); } #[test] fn unchanged_line_follows_insertions_above() { let old = "a\nb\nc\n"; let new = "x\na\nb\nc\n"; assert_eq!(unchanged_line(old, new, 1), Some(2)); assert_eq!(unchanged_line(old, new, 2), Some(3)); assert_eq!(unchanged_line(old, new, 3), Some(4)); assert_eq!(unchanged_line(old, new, 0), None); assert_eq!(unchanged_line(old, new, 9), None); } #[test] fn unchanged_line_is_none_for_modified_or_deleted_lines() { assert_eq!(unchanged_line("a\nb\nc\n", "a\nB\nc\n", 2), None); assert_eq!(unchanged_line("a\nb\nc\n", "a\nB\nc\n", 3), Some(3)); assert_eq!(unchanged_line("a\nb\n", "a\n", 2), None); } #[tokio::test] async fn pushing_moved_lines_relocates_comments_and_changed_lines_go_outdated() { use forge_core::reviews::Side; let fx = fixture().await; let base = commit(&fx.work, "b.txt", "b0"); fx.push("main"); git(&fx.work, &["checkout", "-b", "feature"]); let f1 = commit(&fx.work, "a.txt", "a\nb\nc\n"); fx.push("feature"); let pr = fx .state .store .create_pull( &fx.repo.id, &fx.owner_id, "Feature", "", "main", &fx.repo.id, "feature", &f1, &base, ) .await .expect("create pull"); let store = &fx.state.store; for (line, body) in [(2, "on b"), (3, "on c")] { store .add_review_comment( &pr.issue.id, &fx.owner_id, false, "a.txt", Side::New, line, &f1, "", body, ) .await .expect("comment"); } // Lines are inserted above "b" and "c" is rewritten. let f2 = commit(&fx.work, "a.txt", "x\na\nb\nC\n"); fx.push_and_sync("feature", &f1, &f2).await; let threads = store .review_threads(&pr.issue.id, None) .await .expect("threads"); let on = |body: &str| { threads .iter() .map(|t| &t.root) .find(|r| r.body == body) .expect("root") .clone() }; let moved = on("on b"); assert!(!moved.outdated); assert_eq!(moved.line, 3); assert_eq!(moved.commit_oid, f2); assert_eq!(moved.diff_hunk, ""); assert!(on("on c").outdated); } #[tokio::test] async fn deleting_a_file_marks_its_comments_outdated() { use forge_core::reviews::Side; let fx = fixture().await; let base = commit(&fx.work, "b.txt", "b0"); fx.push("main"); git(&fx.work, &["checkout", "-b", "feature"]); let f1 = commit(&fx.work, "a.txt", "a\nb\n"); fx.push("feature"); let pr = fx .state .store .create_pull( &fx.repo.id, &fx.owner_id, "Feature", "", "main", &fx.repo.id, "feature", &f1, &base, ) .await .expect("create pull"); fx.state .store .add_review_comment( &pr.issue.id, &fx.owner_id, false, "a.txt", Side::New, 1, &f1, "", "on a", ) .await .expect("comment"); git(&fx.work, &["rm", "a.txt"]); git(&fx.work, &["commit", "-m", "remove a"]); let f2 = git(&fx.work, &["rev-parse", "HEAD"]); fx.push_and_sync("feature", &f1, &f2).await; let threads = fx .state .store .review_threads(&pr.issue.id, None) .await .expect("threads"); assert_eq!(threads.len(), 1); assert!(threads[0].root.outdated); assert_eq!(threads[0].root.commit_oid, f1); } }