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 PR's head commit into `branch` via origin's
75/// `refs/pull/<N>/head` ref. The RHS is written as an explicit
76/// `refs/heads/<branch>` so git never has to guess where a bare name
77/// lands. Logged so the call surfaces in the Command Logs modal.
78pub fn fetch_pr_head_ref(workdir: &Path, number: u64, branch: &str) -> Result<()> {
79  let refspec = format!("pull/{number}/head:refs/heads/{branch}");
80  worktree::run_git_logged(workdir, &["fetch", "origin", &refspec])?;
81  Ok(())
82}
83
84/// Everything `gwm review` needs to know once the PR metadata is resolved.
85#[derive(Debug, Clone)]
86pub struct ReviewSpec<'a> {
87  /// PR number — feeds the `refs/pull/<N>/head` fetch and the PR link.
88  pub number: u64,
89  /// Local review branch to create (`review/pr-<N>-<author>-<slug>`).
90  pub branch: &'a str,
91  /// Worktree directory name (`review-pr-<N>-<author>-<slug>`).
92  pub dirname: &'a str,
93  /// Absolute worktree path on disk.
94  pub target: &'a Path,
95  /// The diff base recorded as `branch.<name>.gwm-base` so the launcher's
96  /// `{base}`/`{diff}` placeholders compare against the PR's merge target.
97  /// Callers should pass a reliably resolvable ref — a remote-tracking
98  /// `origin/<base>` rather than a bare local `<base>` that may be stale or
99  /// absent. `None` falls back to the parent ref `worktree::add` records.
100  pub base_ref: Option<&'a str>,
101}
102
103/// The behavioural seam: fetch the PR head, attach a worktree to it, link
104/// the PR, and record the diff base. Does **not** bootstrap or run
105/// lifecycle hooks — the CLI layer wraps those around this call so the
106/// ordering matches `gwm create`.
107///
108/// Pre-flights both the local branch and the target directory *before*
109/// the fetch, so the common "re-run after a half-finished attempt" path
110/// fails cleanly instead of tripping over its own orphaned branch. As a
111/// belt-and-suspenders, a fetched branch is deleted again if the worktree
112/// attach fails downstream.
113pub fn materialize(repo: &Repository, workdir: &Path, spec: &ReviewSpec) -> Result<PathBuf> {
114  if repo.find_branch(spec.branch, git2::BranchType::Local).is_ok() {
115    return Err(GwmError::Other(format!(
116      "review branch '{}' already exists; remove the existing review worktree first (e.g. `gwm remove {} --delete-branch`)",
117      spec.branch, spec.dirname
118    )));
119  }
120  if spec.target.exists() {
121    return Err(GwmError::WorktreeExists(
122      spec.dirname.into(),
123      spec.target.display().to_string(),
124    ));
125  }
126
127  fetch_pr_head_ref(workdir, spec.number, spec.branch)?;
128
129  // The fetch just minted `branch`; reuse it rather than branching from
130  // HEAD. If the attach fails, drop the branch so a retry isn't blocked.
131  let created = match worktree::add(repo, spec.dirname, spec.target, spec.branch, true) {
132    Ok(path) => path,
133    Err(e) => {
134      if let Ok(mut b) = repo.find_branch(spec.branch, git2::BranchType::Local) {
135        let _ = b.delete();
136      }
137      return Err(e);
138    }
139  };
140
141  // Explicit link — a `review/…` branch matches neither the issue-number
142  // pattern nor `gh pr list --head <branch>` (that keys on the PR's head
143  // ref, not our local name), so auto-detection can't wire this up.
144  github::link_pr(repo, spec.branch, spec.number)?;
145
146  // Point the diff base at the PR's real base ref when we know it, so the
147  // `R` review launcher diffs against the right merge target.
148  if let Some(base) = spec.base_ref {
149    let _ = launcher::write_gwm_base(repo, spec.branch, base);
150  }
151
152  Ok(created)
153}
154
155/// The ordered reports produced when `gwm review` runs setup against a
156/// materialised worktree, mirroring `gwm create`'s bootstrap sequence.
157#[derive(Debug)]
158pub struct ReviewSetupReports {
159  pub pre_bootstrap: BootstrapReport,
160  pub bootstrap: BootstrapReport,
161  pub post_bootstrap: BootstrapReport,
162  pub post_create: BootstrapReport,
163}
164
165/// Run the post-materialise bootstrap + lifecycle-hook sequence — the steps
166/// `gwm review` shares with `gwm create` — **only when `bootstrap` is true**.
167///
168/// This gate is a security boundary, not a convenience toggle. Unlike
169/// `gwm create` (which sets up a branch off *your* HEAD), `gwm review`
170/// materialises a worktree full of a contributor's PR code, possibly from an
171/// untrusted fork. The bootstrap commands and the `pre_bootstrap` /
172/// `post_bootstrap` / `post_create` hooks all run with their cwd inside that
173/// worktree, so `npm install` (preinstall scripts), `composer install`,
174/// `direnv allow` (the PR's `.envrc`), a `Makefile` target, etc. would
175/// execute attacker-controlled code. The repo's own `.gwm.toml` being trusted
176/// (the TOFU ledger) does not cover the PR-controlled files those commands
177/// evaluate — so review is **safe-by-default**, and this whole sequence is
178/// opt-in via `gwm review --bootstrap`. Returns `None` (nothing executed)
179/// when `bootstrap` is false.
180///
181/// `ctx` must already point its cwd at the materialised worktree (build it
182/// with `HookContext::with_cwd`).
183pub fn run_post_setup(
184  config: &Config,
185  ctx: &HookContext,
186  main_repo: &Path,
187  worktree: &Path,
188  skips: &HookSkips,
189  bootstrap: bool,
190) -> Result<Option<ReviewSetupReports>> {
191  if !bootstrap {
192    return Ok(None);
193  }
194
195  let pre_bootstrap = lifecycle::run_phase(config, HookPhase::PreBootstrap, ctx, skips, false)?;
196  let bctx = BootstrapCtx {
197    main_repo,
198    worktree,
199    config,
200  };
201  let bootstrap = bootstrap::run_core(&bctx)?;
202  let post_bootstrap = lifecycle::run_phase(config, HookPhase::PostBootstrap, ctx, skips, false)?;
203  // `true`: legacy `[[bootstrap.command]]` is folded into post_create when no
204  // explicit hook block exists — matches `cmd_create`'s bootstrapped path.
205  let post_create = lifecycle::run_phase(config, HookPhase::PostCreate, ctx, skips, true)?;
206
207  Ok(Some(ReviewSetupReports {
208    pre_bootstrap,
209    bootstrap,
210    post_bootstrap,
211    post_create,
212  }))
213}