gwm/review.rs
1//! `gwm review <PR#>` (issue #308) — materialise an existing GitHub PR
2//! into an isolated worktree.
3//!
4//! Every other worktree⇄GitHub path in gwm is **outbound** (it assumes
5//! *you* authored a `<type>/#<issue>-<desc>` branch). This module covers
6//! the **inbound** case: check out a teammate's PR — including one opened
7//! from a fork — into a clean worktree to review / test / fix it.
8//!
9//! The fetch leans on GitHub's universal `refs/pull/<N>/head` ref, which
10//! the **base** repo (`origin`) exposes for *every* PR regardless of
11//! which fork the head lives on. That sidesteps resolving the
12//! contributor's fork URL (and its credentials) entirely, and works for
13//! open, draft, closed, and merged PRs alike. The mutating `git fetch`
14//! shells out to the user's `git` for the same credential reason
15//! [`crate::sync`] does; everything else (branch existence, worktree
16//! attach, PR link) goes through libgit2.
17
18use crate::bootstrap::{self, BootstrapCtx, BootstrapReport};
19use crate::config::Config;
20use crate::error::{GwmError, Result};
21use crate::lifecycle::{self, HookContext, HookPhase, HookSkips};
22use crate::naming::kebab;
23use crate::{github, launcher, worktree};
24use git2::Repository;
25use std::path::{Path, PathBuf};
26
27/// Derive a ref-/filesystem-safe slug from a PR head ref name. Only the
28/// last path segment is kept (`alice/feat/spike-x` → `spike-x`) and then
29/// kebab-cased, so a noisy contributor branch collapses to a short tail.
30pub fn head_slug(head_ref: &str) -> String {
31 let last = head_ref.rsplit('/').next().unwrap_or(head_ref);
32 kebab(last)
33}
34
35/// Join `pr-<N>`, the kebab-cased author, and the kebab-cased slug with
36/// `-`, skipping any empty segment so a missing author / slug never
37/// produces a `--` run. The shared tail behind both the branch name and
38/// the worktree directory name.
39fn review_tail(number: u64, author: &str, slug: &str) -> String {
40 let mut tail = format!("pr-{number}");
41 let author = kebab(author);
42 if !author.is_empty() {
43 tail.push('-');
44 tail.push_str(&author);
45 }
46 let slug = kebab(slug);
47 if !slug.is_empty() {
48 tail.push('-');
49 tail.push_str(&slug);
50 }
51 tail
52}
53
54/// Local review branch name: `review/pr-<N>-<author>-<slug>`. Deliberately
55/// *not* the `<type>/#<issue>-<desc>` shape — the branch isn't ours, and
56/// the `review/` prefix keeps it out of the issue-number auto-link path.
57pub fn review_branch_name(number: u64, author: &str, slug: &str) -> String {
58 format!("review/{}", review_tail(number, author, slug))
59}
60
61/// Worktree directory name: `review-pr-<N>-<author>-<slug>`. Mirrors the
62/// branch tail with `-` joins so `gwm path review-pr-<N>` fuzzy-resolves it.
63pub fn review_dirname(number: u64, author: &str, slug: &str) -> String {
64 format!("review-{}", review_tail(number, author, slug))
65}
66
67/// Derive a worktree directory name from an explicit `--name` branch
68/// override: slashes (illegal in a path segment) collapse to dashes so a
69/// `review/pr-9-x` override still lands in a flat `review-pr-9-x` dir.
70pub fn dirname_from_branch(branch: &str) -> String {
71 branch.replace('/', "-")
72}
73
74/// Fetch the change's head commit into `branch` via origin's head ref.
75///
76/// `head_ref` is the forge-specific LHS — `pull/<N>/head` on GitHub,
77/// `merge-requests/<iid>/head` on GitLab (issue #419) — supplied by
78/// [`crate::forge::Forge::pr_head_refspec`] rather than hardcoded here.
79/// The RHS is written as an explicit `refs/heads/<branch>` so git never
80/// has to guess where a bare name lands. Logged so the call surfaces in
81/// the Command Logs modal.
82pub fn fetch_pr_head_ref(workdir: &Path, head_ref: &str, branch: &str) -> Result<()> {
83 let refspec = format!("{head_ref}:refs/heads/{branch}");
84 worktree::run_git_logged(workdir, &["fetch", "origin", &refspec])?;
85 Ok(())
86}
87
88/// Everything `gwm review` needs to know once the PR metadata is resolved.
89#[derive(Debug, Clone)]
90pub struct ReviewSpec<'a> {
91 /// PR / MR number — feeds the PR link and the branch / directory names.
92 pub number: u64,
93 /// Forge-specific head ref to fetch (`pull/<N>/head`,
94 /// `merge-requests/<iid>/head`). Issue #419: carried on the spec so
95 /// `materialize` never has to know which forge it is serving.
96 pub head_ref: &'a str,
97 /// Local review branch to create (`review/pr-<N>-<author>-<slug>`).
98 pub branch: &'a str,
99 /// Worktree directory name (`review-pr-<N>-<author>-<slug>`).
100 pub dirname: &'a str,
101 /// Absolute worktree path on disk.
102 pub target: &'a Path,
103 /// The diff base recorded as `branch.<name>.gwm-base` so the launcher's
104 /// `{base}`/`{diff}` placeholders compare against the PR's merge target.
105 /// Callers should pass a reliably resolvable ref — a remote-tracking
106 /// `origin/<base>` rather than a bare local `<base>` that may be stale or
107 /// absent. `None` falls back to the parent ref `worktree::add` records.
108 pub base_ref: Option<&'a str>,
109}
110
111/// The behavioural seam: fetch the PR head, attach a worktree to it, link
112/// the PR, and record the diff base. Does **not** bootstrap or run
113/// lifecycle hooks — the CLI layer wraps those around this call so the
114/// ordering matches `gwm create`.
115///
116/// Pre-flights both the local branch and the target directory *before*
117/// the fetch, so the common "re-run after a half-finished attempt" path
118/// fails cleanly instead of tripping over its own orphaned branch. As a
119/// belt-and-suspenders, a fetched branch is deleted again if the worktree
120/// attach fails downstream.
121pub fn materialize(repo: &Repository, workdir: &Path, spec: &ReviewSpec) -> Result<PathBuf> {
122 if repo.find_branch(spec.branch, git2::BranchType::Local).is_ok() {
123 return Err(GwmError::Other(format!(
124 "review branch '{}' already exists; remove the existing review worktree first (e.g. `gwm remove {} --delete-branch`)",
125 spec.branch, spec.dirname
126 )));
127 }
128 if spec.target.exists() {
129 return Err(GwmError::WorktreeExists(
130 spec.dirname.into(),
131 spec.target.display().to_string(),
132 ));
133 }
134
135 fetch_pr_head_ref(workdir, spec.head_ref, spec.branch)?;
136
137 // The fetch just minted `branch`; reuse it rather than branching from
138 // HEAD. If the attach fails, drop the branch so a retry isn't blocked.
139 let created = match worktree::add(repo, spec.dirname, spec.target, spec.branch, true) {
140 Ok(path) => path,
141 Err(e) => {
142 if let Ok(mut b) = repo.find_branch(spec.branch, git2::BranchType::Local) {
143 let _ = b.delete();
144 }
145 return Err(e);
146 }
147 };
148
149 // Explicit link — a `review/…` branch matches neither the issue-number
150 // pattern nor `gh pr list --head <branch>` (that keys on the PR's head
151 // ref, not our local name), so auto-detection can't wire this up.
152 github::link_pr(repo, spec.branch, spec.number)?;
153
154 // Point the diff base at the PR's real base ref when we know it, so the
155 // `R` review launcher diffs against the right merge target.
156 if let Some(base) = spec.base_ref {
157 let _ = launcher::write_gwm_base(repo, spec.branch, base);
158 }
159
160 Ok(created)
161}
162
163/// The ordered reports produced when `gwm review` runs setup against a
164/// materialised worktree, mirroring `gwm create`'s bootstrap sequence.
165#[derive(Debug)]
166pub struct ReviewSetupReports {
167 pub pre_bootstrap: BootstrapReport,
168 pub bootstrap: BootstrapReport,
169 pub post_bootstrap: BootstrapReport,
170 pub post_create: BootstrapReport,
171}
172
173/// Run the post-materialise bootstrap + lifecycle-hook sequence — the steps
174/// `gwm review` shares with `gwm create` — **only when `bootstrap` is true**.
175///
176/// This gate is a security boundary, not a convenience toggle. Unlike
177/// `gwm create` (which sets up a branch off *your* HEAD), `gwm review`
178/// materialises a worktree full of a contributor's PR code, possibly from an
179/// untrusted fork. The bootstrap commands and the `pre_bootstrap` /
180/// `post_bootstrap` / `post_create` hooks all run with their cwd inside that
181/// worktree, so `npm install` (preinstall scripts), `composer install`,
182/// `direnv allow` (the PR's `.envrc`), a `Makefile` target, etc. would
183/// execute attacker-controlled code. The repo's own `.gwm.toml` being trusted
184/// (the TOFU ledger) does not cover the PR-controlled files those commands
185/// evaluate — so review is **safe-by-default**, and this whole sequence is
186/// opt-in via `gwm review --bootstrap`. Returns `None` (nothing executed)
187/// when `bootstrap` is false.
188///
189/// `ctx` must already point its cwd at the materialised worktree (build it
190/// with `HookContext::with_cwd`).
191pub fn run_post_setup(
192 config: &Config,
193 ctx: &HookContext,
194 main_repo: &Path,
195 worktree: &Path,
196 skips: &HookSkips,
197 bootstrap: bool,
198) -> Result<Option<ReviewSetupReports>> {
199 if !bootstrap {
200 return Ok(None);
201 }
202
203 let pre_bootstrap = lifecycle::run_phase(config, HookPhase::PreBootstrap, ctx, skips, false)?;
204 let bctx = BootstrapCtx {
205 main_repo,
206 worktree,
207 config,
208 };
209 let bootstrap = bootstrap::run_core(&bctx)?;
210 let post_bootstrap = lifecycle::run_phase(config, HookPhase::PostBootstrap, ctx, skips, false)?;
211 // `true`: legacy `[[bootstrap.command]]` is folded into post_create when no
212 // explicit hook block exists — matches `cmd_create`'s bootstrapped path.
213 let post_create = lifecycle::run_phase(config, HookPhase::PostCreate, ctx, skips, true)?;
214
215 Ok(Some(ReviewSetupReports {
216 pre_bootstrap,
217 bootstrap,
218 post_bootstrap,
219 post_create,
220 }))
221}