Skip to main content

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}