Skip to main content

review_queue/vcs/
jj.rs

1//! Jj backend. Shells out only to the `jj` CLI (never raw `git`) plus the standalone `patch(1)`
2//! tool for applying diffs - a secondary `jj workspace add`'d directory has no `.git` of its
3//! own (only the canonical repo's own default workspace is colocated with git), so `git apply`
4//! isn't available there. `jj commit` snapshots the working copy directly, so `patch(1)` editing
5//! files on disk is enough; no index of any kind needs updating.
6//!
7//! - `Checkout::Ref` with a `fork`: `jj git remote add <remote_name> <remote_url>` (skipped if
8//!   `jj git remote list` already has it - `add` isn't idempotent), then `jj git fetch -b
9//!   <branch> --remote <remote_name>`, giving the revset `<branch>@<remote_name>`.
10//! - `Checkout::Ref` with no `fork` (the PR's source fork was deleted): unsupported for now -
11//!   there's no remote/branch left to track and no raw-git fallback per the above. Callers
12//!   should suggest the git backend for that review instead.
13//! - `Checkout::Patches`: `base` must already be reachable (fetched as part of some branch's
14//!   history) - the jj backend has no by-SHA fetch to fall back on; `None` resolves to jj's own
15//!   `trunk()` revset alias.
16//! - `add_workspace`/`update_workspace`: `jj workspace add --name rq-<name> -r <revset> <ws>` /
17//!   `jj new <revset>` (run from inside `ws`), then each patch is applied with `patch -p1` and
18//!   `jj commit -m`. jj stamps a commit's author when it's *created* as an empty node, not when
19//!   `jj commit` later finalizes it - so authors are pre-stamped one step ahead via `--config
20//!   user.name=...`/`user.email=...` on whichever call creates the commit that will receive that
21//!   patch (the initial `workspace add`/`new` for the first patch, the previous patch's `commit`
22//!   for the rest). Verified with a local spike before relying on it.
23//! - Every `add_workspace`/`update_workspace` call pins its resulting head under a
24//!   `review-queue/<name>/<version>` bookmark (`jj bookmark create`/`set`). This isn't optional
25//!   bookkeeping the way it might seem: a spike showed that without it, a force-pushed PR branch
26//!   causes `jj git fetch` to abandon the old head on re-fetch (`git.abandon-unreachable-commits`
27//!   defaults to `true`), breaking interdiffing. The pin keeps it reachable regardless of what
28//!   the remote branch does next.
29//! - `is_dirty`: `@` isn't empty, or `@-` isn't the expected id (the stack tip) or one of its
30//!   ancestors.
31//! - `position`: `jj new <commit>` (never `edit`, which would rewrite the stack).
32//! - `remove_workspace`: `jj workspace forget`, `rm -rf`, and delete every
33//!   `review-queue/<name>/*` bookmark.
34
35use std::io::Write;
36use std::path::Path;
37use std::process::Command;
38
39use anyhow::{Context, Result, bail};
40
41use crate::source::{Checkout, Patch};
42use crate::vcs::Vcs;
43
44pub struct JjVcs;
45
46impl JjVcs {
47    fn run(&self, dir: &Path, args: &[&str]) -> Result<String> {
48        let output = Command::new("jj")
49            .current_dir(dir)
50            .args(args)
51            .output()
52            .with_context(|| format!("running `jj {}` in {}", args.join(" "), dir.display()))?;
53        if !output.status.success() {
54            bail!(
55                "`jj {}` in {} failed: {}",
56                args.join(" "),
57                dir.display(),
58                String::from_utf8_lossy(&output.stderr).trim(),
59            );
60        }
61        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
62    }
63
64    fn revision_exists(&self, repo: &Path, rev: &str) -> bool {
65        Command::new("jj")
66            .current_dir(repo)
67            .args(["log", "-r", rev, "--no-graph", "-T", ""])
68            .output()
69            .map(|o| o.status.success())
70            .unwrap_or(false)
71    }
72
73    fn remote_exists(&self, repo: &Path, name: &str) -> Result<bool> {
74        let out = self.run(repo, &["git", "remote", "list"])?;
75        Ok(out
76            .lines()
77            .any(|l| l.split_whitespace().next() == Some(name)))
78    }
79
80    fn ensure_remote(&self, repo: &Path, name: &str, url: &str) -> Result<()> {
81        if !self.remote_exists(repo, name)? {
82            self.run(repo, &["git", "remote", "add", name, url])
83                .with_context(|| format!("adding jj git remote `{name}`"))?;
84        }
85        Ok(())
86    }
87
88    /// Resolve a `Checkout` to a jj revset expression, fetching/registering remotes as needed.
89    /// Doesn't touch the working copy - callers combine this with `workspace add -r`/`jj new`.
90    fn resolve_revset(&self, repo: &Path, checkout: &Checkout) -> Result<String> {
91        match checkout {
92            Checkout::Ref {
93                fork: Some(fork), ..
94            } => {
95                self.ensure_remote(repo, &fork.remote_name, &fork.remote_url)?;
96                self.run(
97                    repo,
98                    &[
99                        "git",
100                        "fetch",
101                        "-b",
102                        &fork.branch,
103                        "--remote",
104                        &fork.remote_name,
105                    ],
106                )
107                .with_context(|| {
108                    format!("fetching `{}` from `{}`", fork.branch, fork.remote_name)
109                })?;
110                Ok(format!("{}@{}", fork.branch, fork.remote_name))
111            }
112            Checkout::Ref {
113                commit, fork: None, ..
114            } => bail!(
115                "can't check out `{commit}` with the jj backend: its source fork is gone, so there's \
116                 no remote/branch left to track and no raw-git fallback; use the git backend for this \
117                 review instead"
118            ),
119            Checkout::Patches {
120                base: Some(base), ..
121            } => {
122                if !self.revision_exists(repo, base) {
123                    bail!(
124                        "commit `{base}` isn't reachable in the canonical repo; the jj backend can only \
125                         check out commits already fetched via a tracked branch"
126                    );
127                }
128                Ok(base.clone())
129            }
130            Checkout::Patches { base: None, .. } => Ok("trunk()".to_string()),
131            Checkout::ExternalCommand { .. } => Ok("trunk()".to_string()),
132        }
133    }
134
135    /// Apply `diff` to the tree at `ws` with the standalone `patch(1)` tool.
136    fn apply_diff(&self, ws: &Path, diff: &str) -> Result<()> {
137        let mut file = tempfile::NamedTempFile::new().context("creating temp patch file")?;
138        file.write_all(diff.as_bytes())
139            .context("writing temp patch file")?;
140
141        let output = Command::new("patch")
142            .current_dir(ws)
143            .args(["-p1", "-i"])
144            .arg(file.path())
145            .output()
146            .context("running `patch`")?;
147        if !output.status.success() {
148            bail!(
149                "`patch -p1` in {} failed: {}",
150                ws.display(),
151                String::from_utf8_lossy(&output.stderr).trim()
152            );
153        }
154        Ok(())
155    }
156
157    /// `--config user.name=...`/`user.email=...` args that stamp `patch`'s author onto whatever
158    /// commit is created by the jj invocation they're attached to.
159    fn author_config_args(patch: &Patch) -> Result<Vec<String>> {
160        let (name, email) = split_author(&patch.author)?;
161        // Debug-formatting a &str produces a quoted, escaped literal, which is also valid TOML
162        // string syntax for the common case - good enough without a real TOML writer here.
163        Ok(vec![
164            "--config".into(),
165            format!("user.name={name:?}"),
166            "--config".into(),
167            format!("user.email={email:?}"),
168        ])
169    }
170
171    /// Apply `patches` bottom-to-top on top of the current (empty) `@` in `ws`, leaving a fresh
172    /// empty `@` on top. See the module docs for why authorship is pre-stamped one step ahead.
173    fn apply_patches(&self, ws: &Path, patches: &[Patch]) -> Result<()> {
174        for (i, patch) in patches.iter().enumerate() {
175            self.apply_diff(ws, &patch.diff)?;
176
177            let mut args = match patches.get(i + 1) {
178                Some(next) => Self::author_config_args(next)?,
179                None => Vec::new(),
180            };
181            args.push("commit".into());
182            args.push("-m".into());
183            args.push(patch.message.clone());
184            let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
185
186            self.run(ws, &arg_refs)
187                .with_context(|| format!("committing patch `{}`", patch.title))?;
188        }
189        Ok(())
190    }
191
192    fn patches_of(checkout: &Checkout) -> &[Patch] {
193        match checkout {
194            Checkout::Patches { patches, .. } => patches,
195            Checkout::Ref { .. } | Checkout::ExternalCommand { .. } => &[],
196        }
197    }
198
199    /// Apply whatever `checkout` needs beyond the initial `workspace add -r`/`jj new`: patches
200    /// for `Patches`, an external command for `ExternalCommand`, nothing for `Ref`.
201    fn apply_checkout(&self, ws: &Path, checkout: &Checkout) -> Result<()> {
202        match checkout {
203            Checkout::Ref { .. } => Ok(()),
204            Checkout::Patches { patches, .. } => self.apply_patches(ws, patches),
205            Checkout::ExternalCommand { program, args, env } => {
206                self.run_external(ws, program, args, env)
207            }
208        }
209    }
210
211    fn run_external(
212        &self,
213        ws: &Path,
214        program: &str,
215        args: &[String],
216        env: &[(String, String)],
217    ) -> Result<()> {
218        let output = Command::new(program)
219            .args(args)
220            .current_dir(ws)
221            .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
222            .output()
223            .with_context(|| format!("running `{program}`"))?;
224        if !output.status.success() {
225            bail!(
226                "`{program} {}` in {} failed: {}\n{}",
227                args.join(" "),
228                ws.display(),
229                crate::vcs::clean_output(&output.stderr),
230                crate::vcs::clean_output(&output.stdout),
231            );
232        }
233        Ok(())
234    }
235
236    fn head_id(&self, ws: &Path) -> Result<String> {
237        self.run(ws, &["log", "-r", "@-", "--no-graph", "-T", "commit_id"])
238    }
239
240    fn pin(&self, repo: &Path, name: &str, version: &str, head: &str) -> Result<()> {
241        // A stack's version can be a comma-joined list of its patches' versions, and jj parses
242        // bookmark names as revset symbols, so anything outside this set is a syntax error.
243        let version: String = version
244            .chars()
245            .map(|c| if c.is_ascii_alphanumeric() || "._-".contains(c) { c } else { '-' })
246            .collect();
247        let bookmark = format!("review-queue/{name}/{version}");
248        // `create` errors if the bookmark already exists (e.g. a retried sync); `set` handles
249        // that case instead, so this call is idempotent either way.
250        if self
251            .run(repo, &["bookmark", "create", "-r", head, &bookmark])
252            .is_err()
253        {
254            self.run(repo, &["bookmark", "set", "-r", head, &bookmark])
255                .with_context(|| format!("pinning `{bookmark}`"))?;
256        }
257        Ok(())
258    }
259
260    fn workspace_name(name: &str) -> String {
261        format!("rq-{}", name.replace('/', "-"))
262    }
263}
264
265/// Split `"Name <email>"` into its parts.
266fn split_author(author: &str) -> Result<(&str, &str)> {
267    let (name, rest) = author
268        .split_once('<')
269        .with_context(|| format!("author `{author}` isn't in `Name <email>` form"))?;
270    let email = rest
271        .strip_suffix('>')
272        .with_context(|| format!("author `{author}` isn't in `Name <email>` form"))?;
273    Ok((name.trim(), email.trim()))
274}
275
276impl Vcs for JjVcs {
277    fn ensure_commit(&self, repo: &Path, refspec_or_sha: &str) -> Result<()> {
278        if self.revision_exists(repo, refspec_or_sha) {
279            return Ok(());
280        }
281        // Best effort: there's no generic by-refspec fetch in jj, only tracked branches (see
282        // `resolve_revset`), so all we can do is refresh whatever's already configured.
283        self.run(repo, &["git", "fetch"])?;
284        if !self.revision_exists(repo, refspec_or_sha) {
285            bail!(
286                "`{refspec_or_sha}` not found in {} after fetching",
287                repo.display()
288            );
289        }
290        Ok(())
291    }
292
293    fn add_workspace(
294        &self,
295        repo: &Path,
296        ws: &Path,
297        checkout: &Checkout,
298        name: &str,
299        version: &str,
300    ) -> Result<String> {
301        // Refresh the default branch so `trunk()` (and revsets like `trunk()..@`) are current.
302        self.run(repo, &["git", "fetch"])
303            .with_context(|| format!("fetching default remote in {}", repo.display()))?;
304        let revset = self.resolve_revset(repo, checkout)?;
305        let patches = Self::patches_of(checkout);
306        let ws_str = ws.to_string_lossy().to_string();
307
308        let mut args = match patches.first() {
309            Some(first) => Self::author_config_args(first)?,
310            None => Vec::new(),
311        };
312        args.extend([
313            "workspace".into(),
314            "add".into(),
315            "--name".into(),
316            Self::workspace_name(name),
317            "-r".into(),
318            revset,
319            ws_str,
320        ]);
321        let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
322        self.run(repo, &arg_refs)
323            .with_context(|| format!("creating jj workspace at {}", ws.display()))?;
324
325        self.apply_checkout(ws, checkout)?;
326
327        let head = self.head_id(ws)?;
328        self.pin(repo, name, version, &head)?;
329        Ok(head)
330    }
331
332    fn update_workspace(
333        &self,
334        repo: &Path,
335        ws: &Path,
336        checkout: &Checkout,
337        name: &str,
338        version: &str,
339    ) -> Result<String> {
340        let revset = self.resolve_revset(repo, checkout)?;
341        let patches = Self::patches_of(checkout);
342
343        let mut args = match patches.first() {
344            Some(first) => Self::author_config_args(first)?,
345            None => Vec::new(),
346        };
347        args.push("new".into());
348        args.push(revset);
349        let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
350        self.run(ws, &arg_refs)
351            .with_context(|| format!("updating jj workspace at {}", ws.display()))?;
352
353        self.apply_checkout(ws, checkout)?;
354
355        let head = self.head_id(ws)?;
356        self.pin(repo, name, version, &head)?;
357        Ok(head)
358    }
359
360    fn is_dirty(&self, ws: &Path, expected_head: &str) -> Result<bool> {
361        let at_state = self.run(
362            ws,
363            &[
364                "log",
365                "-r",
366                "@",
367                "--no-graph",
368                "-T",
369                "if(empty, \"clean\", \"dirty\")",
370            ],
371        )?;
372        if at_state != "clean" {
373            return Ok(true);
374        }
375        let parent = self.run(ws, &["log", "-r", "@-", "--no-graph", "-T", "commit_id"])?;
376        if parent == expected_head {
377            return Ok(false);
378        }
379        // Positioned on an earlier patch of the stack is fine; anything else isn't.
380        let in_stack = self.run(
381            ws,
382            &[
383                "log",
384                "-r",
385                &format!("@- & ::{expected_head}"),
386                "--no-graph",
387                "-T",
388                "commit_id",
389            ],
390        )?;
391        Ok(in_stack.is_empty())
392    }
393
394    fn position(&self, ws: &Path, commit: &str) -> Result<()> {
395        // `new`, not `edit`: editing a mid-stack commit would rewrite it and everything above,
396        // moving the tip out from under `is_dirty`.
397        self.run(ws, &["new", commit])
398            .with_context(|| format!("moving to {commit} in {}", ws.display()))?;
399        Ok(())
400    }
401
402    fn commits(&self, ws: &Path, tip: &str, limit: usize) -> Result<Vec<(String, String)>> {
403        let raw = self.run(
404            ws,
405            &[
406                "log",
407                "-r",
408                &format!("::{tip}"),
409                "--limit",
410                &limit.to_string(),
411                "--no-graph",
412                "-T",
413                "commit_id ++ \"\\x1f\" ++ description ++ \"\\x1e\"",
414            ],
415        )?;
416        Ok(super::parse_commit_records(&raw))
417    }
418
419    fn remove_workspace(&self, repo: &Path, ws: &Path, name: &str) -> Result<()> {
420        let ws_name = Self::workspace_name(name);
421        self.run(repo, &["workspace", "forget", &ws_name])
422            .with_context(|| format!("forgetting jj workspace `{ws_name}`"))?;
423        if ws.exists() {
424            std::fs::remove_dir_all(ws).with_context(|| format!("removing {}", ws.display()))?;
425        }
426
427        let prefix = format!("review-queue/{name}/");
428        let listing = self.run(repo, &["bookmark", "list"])?;
429        for line in listing.lines() {
430            if line.starts_with(char::is_whitespace) {
431                continue; // indented lines are per-remote tracking info, not bookmark names
432            }
433            let Some((bookmark_name, _)) = line.split_once(':') else {
434                continue;
435            };
436            if bookmark_name.starts_with(&prefix) {
437                self.run(repo, &["bookmark", "delete", bookmark_name])?;
438            }
439        }
440        Ok(())
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use std::fs;
448    use tempfile::TempDir;
449
450    fn jj_available() -> bool {
451        Command::new("jj")
452            .arg("--version")
453            .output()
454            .map(|o| o.status.success())
455            .unwrap_or(false)
456    }
457
458    macro_rules! require_jj {
459        () => {
460            if !jj_available() {
461                eprintln!("skipping: `jj` not found on PATH");
462                return;
463            }
464        };
465    }
466
467    fn git(dir: &Path, args: &[&str]) {
468        let out = Command::new("git")
469            .current_dir(dir)
470            .args(args)
471            .output()
472            .unwrap();
473        assert!(
474            out.status.success(),
475            "git {args:?} failed: {}",
476            String::from_utf8_lossy(&out.stderr)
477        );
478    }
479
480    fn git_rev_parse(dir: &Path, rev: &str) -> String {
481        let out = Command::new("git")
482            .current_dir(dir)
483            .args(["rev-parse", rev])
484            .output()
485            .unwrap();
486        assert!(out.status.success());
487        String::from_utf8_lossy(&out.stdout).trim().to_string()
488    }
489
490    fn jj_out(dir: &Path, args: &[&str]) -> String {
491        let out = Command::new("jj")
492            .current_dir(dir)
493            .args(args)
494            .output()
495            .unwrap();
496        assert!(
497            out.status.success(),
498            "jj {args:?} failed: {}",
499            String::from_utf8_lossy(&out.stderr)
500        );
501        String::from_utf8_lossy(&out.stdout).trim().to_string()
502    }
503
504    fn commit_id(dir: &Path, revset: &str) -> String {
505        jj_out(dir, &["log", "-r", revset, "--no-graph", "-T", "commit_id"])
506    }
507
508    fn author_of(dir: &Path, revset: &str) -> String {
509        jj_out(
510            dir,
511            &[
512                "log",
513                "-r",
514                revset,
515                "--no-graph",
516                "-T",
517                "author.name() ++ \" <\" ++ author.email() ++ \">\"",
518            ],
519        )
520    }
521
522    /// Produce a unified diff for a single new file, applicable with `patch -p1` regardless of
523    /// what's currently checked out (a real Phabricator raw diff looks like this).
524    fn add_file_patch(title: &str, filename: &str, contents: &str, author: &str) -> Patch {
525        let diff = format!(
526            "diff --git a/{filename} b/{filename}\nnew file mode 100644\nindex 0000000..1111111\n--- /dev/null\n+++ b/{filename}\n@@ -0,0 +1,{n} @@\n{body}",
527            n = contents.lines().count(),
528            body = contents
529                .lines()
530                .map(|l| format!("+{l}\n"))
531                .collect::<String>(),
532        );
533        Patch {
534            title: title.into(),
535            author: author.into(),
536            message: title.into(),
537            diff,
538        }
539    }
540
541    /// A canonical jj repo cloned (colocated, the jj default) from a fresh upstream with one
542    /// commit, plus the upstream dir so tests can add branches/forks off it.
543    struct Fixture {
544        _tmp: TempDir,
545        upstream: std::path::PathBuf,
546        canon: std::path::PathBuf,
547        base: String,
548    }
549
550    fn fixture() -> Fixture {
551        let tmp = TempDir::new().unwrap();
552        let upstream = tmp.path().join("upstream");
553        fs::create_dir(&upstream).unwrap();
554        git(&upstream, &["init", "-q", "-b", "main"]);
555        git(&upstream, &["config", "user.name", "test"]);
556        git(&upstream, &["config", "user.email", "test@example.com"]);
557        fs::write(upstream.join("README.md"), "hello\n").unwrap();
558        git(&upstream, &["add", "README.md"]);
559        git(&upstream, &["commit", "-q", "-m", "base"]);
560
561        let canon = tmp.path().join("canon");
562        let out = Command::new("jj")
563            .current_dir(tmp.path())
564            .args([
565                "git",
566                "clone",
567                upstream.to_str().unwrap(),
568                canon.to_str().unwrap(),
569            ])
570            .output()
571            .unwrap();
572        assert!(
573            out.status.success(),
574            "jj git clone failed: {}",
575            String::from_utf8_lossy(&out.stderr)
576        );
577
578        let base = commit_id(&canon, "trunk()");
579        Fixture {
580            _tmp: tmp,
581            upstream,
582            canon,
583            base,
584        }
585    }
586
587    /// Clone `upstream` into a sibling "fork" dir, add `branch` with one commit, and return its
588    /// path and commit id - simulating a GitHub PR from a contributor's fork.
589    fn make_fork(
590        tmp: &Path,
591        upstream: &Path,
592        branch: &str,
593        filename: &str,
594        contents: &str,
595    ) -> (std::path::PathBuf, String) {
596        let fork = tmp.join("fork");
597        git(
598            tmp,
599            &[
600                "clone",
601                "-q",
602                upstream.to_str().unwrap(),
603                fork.to_str().unwrap(),
604            ],
605        );
606        git(&fork, &["checkout", "-q", "-b", branch]);
607        fs::write(fork.join(filename), contents).unwrap();
608        git(&fork, &["add", filename]);
609        git(&fork, &["commit", "-q", "-m", "pr change"]);
610        let sha = git_rev_parse(&fork, "HEAD");
611        (fork, sha)
612    }
613
614    /// Add a second branch (a second PR) to an existing fork clone.
615    fn add_branch_to_fork(fork: &Path, branch: &str, filename: &str, contents: &str) -> String {
616        git(fork, &["checkout", "-q", "main"]);
617        git(fork, &["checkout", "-q", "-b", branch]);
618        fs::write(fork.join(filename), contents).unwrap();
619        git(fork, &["add", filename]);
620        git(fork, &["commit", "-q", "-m", "second pr change"]);
621        git_rev_parse(fork, "HEAD")
622    }
623
624    #[test]
625    fn second_ref_from_same_fork_owner_reuses_the_jj_remote() {
626        require_jj!();
627        let f = fixture();
628        let (fork, sha1) = make_fork(
629            f._tmp.path(),
630            &f.upstream,
631            "feature-1",
632            "pr1.txt",
633            "pr one\n",
634        );
635        let sha2 = add_branch_to_fork(&fork, "feature-2", "pr2.txt", "pr two\n");
636
637        let fork_ref = |branch: &str| crate::source::ForkRef {
638            remote_name: "alice".into(),
639            remote_url: fork.to_string_lossy().to_string(),
640            branch: branch.into(),
641        };
642
643        let vcs = JjVcs;
644        let ws1 = f._tmp.path().join("ws1");
645        let checkout1 = Checkout::Ref {
646            refspec: "refs/pull/1/head".into(),
647            commit: sha1.clone(),
648            fork: Some(fork_ref("feature-1")),
649        };
650        let head1 = vcs
651            .add_workspace(&f.canon, &ws1, &checkout1, "github/1", "v1")
652            .unwrap();
653        assert_eq!(head1, sha1);
654
655        // `jj git remote add` errors on a duplicate name; this must not blow up the second time.
656        let ws2 = f._tmp.path().join("ws2");
657        let checkout2 = Checkout::Ref {
658            refspec: "refs/pull/2/head".into(),
659            commit: sha2.clone(),
660            fork: Some(fork_ref("feature-2")),
661        };
662        let head2 = vcs
663            .add_workspace(&f.canon, &ws2, &checkout2, "github/2", "v1")
664            .unwrap();
665        assert_eq!(head2, sha2);
666
667        assert_eq!(
668            vcs.run(&f.canon, &["git", "remote", "list"])
669                .unwrap()
670                .lines()
671                .count(),
672            2,
673            "should have origin + one alice remote, not two"
674        );
675    }
676
677    #[test]
678    fn add_workspace_from_ref_with_fork() {
679        require_jj!();
680        let f = fixture();
681        let (fork, sha) = make_fork(
682            f._tmp.path(),
683            &f.upstream,
684            "feature",
685            "pr.txt",
686            "pr change\n",
687        );
688
689        let vcs = JjVcs;
690        let ws = f._tmp.path().join("ws");
691        let checkout = Checkout::Ref {
692            refspec: "refs/pull/1/head".into(),
693            commit: sha.clone(),
694            fork: Some(crate::source::ForkRef {
695                remote_name: "alice".into(),
696                remote_url: fork.to_string_lossy().to_string(),
697                branch: "feature".into(),
698            }),
699        };
700        let head = vcs
701            .add_workspace(&f.canon, &ws, &checkout, "github/1", "v1")
702            .unwrap();
703
704        assert_eq!(head, sha);
705        assert!(ws.join("pr.txt").exists());
706        assert_eq!(commit_id(&f.canon, "review-queue/github/1/v1"), sha);
707    }
708
709    #[test]
710    fn add_workspace_from_ref_with_no_fork_is_an_error() {
711        require_jj!();
712        let f = fixture();
713        let vcs = JjVcs;
714        let ws = f._tmp.path().join("ws");
715        let checkout = Checkout::Ref {
716            refspec: "refs/pull/1/head".into(),
717            commit: "deadbeef".into(),
718            fork: None,
719        };
720
721        let err = vcs
722            .add_workspace(&f.canon, &ws, &checkout, "github/2", "v1")
723            .unwrap_err();
724        assert!(
725            err.to_string().contains("deadbeef"),
726            "error should name the commit: {err}"
727        );
728    }
729
730    #[test]
731    fn add_workspace_from_patch_stack_attributes_each_author() {
732        require_jj!();
733        let f = fixture();
734        let patch1 = add_file_patch("add a", "a.txt", "aaa\n", "Author One <one@example.com>");
735        let patch2 = add_file_patch("add b", "b.txt", "bbb\n", "Author Two <two@example.com>");
736        let checkout = Checkout::Patches {
737            base: Some(f.base.clone()),
738            patches: vec![patch1, patch2],
739        };
740
741        let vcs = JjVcs;
742        let ws = f._tmp.path().join("ws");
743        let head = vcs
744            .add_workspace(&f.canon, &ws, &checkout, "moz/D1", "1")
745            .unwrap();
746
747        assert!(ws.join("a.txt").exists());
748        assert!(ws.join("b.txt").exists());
749        assert_eq!(
750            commit_id(&ws, "@---"),
751            f.base,
752            "two patch commits should sit over base"
753        );
754        assert_eq!(author_of(&ws, "@--"), "Author One <one@example.com>");
755        assert_eq!(author_of(&ws, "@-"), "Author Two <two@example.com>");
756        assert_eq!(commit_id(&f.canon, "review-queue/moz/D1/1"), head);
757    }
758
759    #[test]
760    fn position_moves_within_a_stack_without_counting_as_dirty() {
761        require_jj!();
762        let f = fixture();
763        let checkout = Checkout::Patches {
764            base: Some(f.base.clone()),
765            patches: vec![
766                add_file_patch("add a", "a.txt", "aaa\n", "Author One <one@example.com>"),
767                add_file_patch("add b", "b.txt", "bbb\n", "Author Two <two@example.com>"),
768            ],
769        };
770        let vcs = JjVcs;
771        let ws = f._tmp.path().join("ws");
772        let tip = vcs
773            .add_workspace(&f.canon, &ws, &checkout, "moz/D1", "1")
774            .unwrap();
775
776        let commits = vcs.commits(&ws, &tip, 10).unwrap();
777        assert_eq!(commits[0].0, tip);
778        assert_eq!(commits[0].1.trim(), "add b");
779        assert_eq!(commits[1].1.trim(), "add a");
780
781        vcs.position(&ws, &commits[1].0).unwrap();
782        assert!(!ws.join("b.txt").exists());
783        assert!(!vcs.is_dirty(&ws, &tip).unwrap());
784
785        vcs.position(&ws, &tip).unwrap();
786        assert!(ws.join("b.txt").exists());
787        assert!(!vcs.is_dirty(&ws, &tip).unwrap());
788    }
789
790    #[test]
791    fn a_commit_outside_the_stack_is_dirty() {
792        require_jj!();
793        let f = fixture();
794        let checkout = Checkout::Patches {
795            base: Some(f.base.clone()),
796            patches: vec![add_file_patch(
797                "add a",
798                "a.txt",
799                "aaa\n",
800                "Author <a@example.com>",
801            )],
802        };
803        let vcs = JjVcs;
804        let ws = f._tmp.path().join("ws");
805        let tip = vcs
806            .add_workspace(&f.canon, &ws, &checkout, "moz/D1", "1")
807            .unwrap();
808
809        // Build on top of the base instead of the stack.
810        vcs.position(&ws, &f.base).unwrap();
811        fs::write(ws.join("mine.txt"), "x\n").unwrap();
812        assert!(vcs.is_dirty(&ws, &tip).unwrap(), "uncommitted edit");
813        jj_out(&ws, &["commit", "-m", "local"]);
814        assert!(vcs.is_dirty(&ws, &tip).unwrap(), "commit outside the stack");
815    }
816
817    #[test]
818    fn add_workspace_from_patch_stack_with_no_base_uses_trunk() {
819        require_jj!();
820        let f = fixture();
821        let checkout = Checkout::Patches {
822            base: None,
823            patches: vec![add_file_patch(
824                "add a",
825                "a.txt",
826                "aaa\n",
827                "Author <a@example.com>",
828            )],
829        };
830
831        let vcs = JjVcs;
832        let ws = f._tmp.path().join("ws");
833        vcs.add_workspace(&f.canon, &ws, &checkout, "moz/D2", "1")
834            .unwrap();
835
836        assert_eq!(commit_id(&ws, "@--"), f.base);
837    }
838
839    #[test]
840    fn add_workspace_from_patches_with_unreachable_base_is_an_error() {
841        require_jj!();
842        let f = fixture();
843        let checkout = Checkout::Patches {
844            // Not jj's root commit (all zeros) or any real commit - just an unreachable-looking id.
845            base: Some("1111111111111111111111111111111111111111".into()),
846            patches: vec![],
847        };
848        let vcs = JjVcs;
849        let ws = f._tmp.path().join("ws");
850
851        let err = vcs
852            .add_workspace(&f.canon, &ws, &checkout, "moz/D3", "1")
853            .unwrap_err();
854        assert!(
855            err.to_string().contains("isn't reachable"),
856            "unexpected error: {err}"
857        );
858    }
859
860    #[test]
861    fn apply_failure_leaves_workspace_for_inspection() {
862        require_jj!();
863        let f = fixture();
864        let bad = Patch {
865            title: "conflict".into(),
866            author: "Author <a@example.com>".into(),
867            message: "conflict".into(),
868            diff: "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1,1 +1,1 @@\n-this is not what's there\n+changed\n".into(),
869        };
870        let checkout = Checkout::Patches {
871            base: Some(f.base.clone()),
872            patches: vec![bad],
873        };
874        let vcs = JjVcs;
875        let ws = f._tmp.path().join("ws");
876        let result = vcs.add_workspace(&f.canon, &ws, &checkout, "moz/D4", "1");
877
878        assert!(result.is_err());
879        assert!(
880            ws.exists(),
881            "workspace should be left in place for inspection, not cleaned up"
882        );
883    }
884
885    #[test]
886    fn update_workspace_keeps_old_head_reachable() {
887        require_jj!();
888        let f = fixture();
889        let vcs = JjVcs;
890        let ws = f._tmp.path().join("ws");
891
892        let checkout_v1 = Checkout::Patches {
893            base: Some(f.base.clone()),
894            patches: vec![add_file_patch(
895                "add a",
896                "a.txt",
897                "aaa\n",
898                "Author <a@example.com>",
899            )],
900        };
901        let head_v1 = vcs
902            .add_workspace(&f.canon, &ws, &checkout_v1, "moz/D5", "1")
903            .unwrap();
904
905        let checkout_v2 = Checkout::Patches {
906            base: Some(f.base.clone()),
907            patches: vec![
908                add_file_patch("add a", "a.txt", "aaa\n", "Author <a@example.com>"),
909                add_file_patch("add b", "b.txt", "bbb\n", "Author <a@example.com>"),
910            ],
911        };
912        let head_v2 = vcs
913            .update_workspace(&f.canon, &ws, &checkout_v2, "moz/D5", "2")
914            .unwrap();
915
916        assert_ne!(head_v1, head_v2);
917        assert!(ws.join("b.txt").exists());
918        // The old head is still reachable via its pinned bookmark, even though the workspace
919        // moved on - this is the behavior the force-push spike showed doesn't happen for free.
920        assert_eq!(commit_id(&f.canon, "review-queue/moz/D5/1"), head_v1);
921        assert_eq!(commit_id(&f.canon, "review-queue/moz/D5/2"), head_v2);
922    }
923
924    #[test]
925    fn is_dirty_detects_local_changes_and_head_mismatch() {
926        require_jj!();
927        let f = fixture();
928        let vcs = JjVcs;
929        let ws = f._tmp.path().join("ws");
930        let checkout = Checkout::Patches {
931            base: Some(f.base.clone()),
932            patches: vec![],
933        };
934        let head = vcs
935            .add_workspace(&f.canon, &ws, &checkout, "moz/D6", "1")
936            .unwrap();
937
938        assert!(!vcs.is_dirty(&ws, &head).unwrap());
939
940        fs::write(ws.join("untracked.txt"), "oops\n").unwrap();
941        assert!(vcs.is_dirty(&ws, &head).unwrap());
942
943        fs::remove_file(ws.join("untracked.txt")).unwrap();
944        assert!(!vcs.is_dirty(&ws, &head).unwrap());
945        assert!(
946            vcs.is_dirty(&ws, "0000000000000000000000000000000000000000")
947                .unwrap()
948        );
949    }
950
951    #[test]
952    fn remove_workspace_cleans_up_workspace_and_bookmarks_without_touching_canon() {
953        require_jj!();
954        let f = fixture();
955        let vcs = JjVcs;
956        let ws = f._tmp.path().join("ws");
957        let checkout = Checkout::Patches {
958            base: Some(f.base.clone()),
959            patches: vec![],
960        };
961        vcs.add_workspace(&f.canon, &ws, &checkout, "moz/D7", "1")
962            .unwrap();
963
964        let trunk_before = commit_id(&f.canon, "trunk()");
965
966        vcs.remove_workspace(&f.canon, &ws, "moz/D7").unwrap();
967
968        assert!(!ws.exists());
969        let bookmarks = jj_out(&f.canon, &["bookmark", "list"]);
970        assert!(
971            !bookmarks.contains("review-queue/moz/D7"),
972            "expected pinned bookmarks to be deleted, found: {bookmarks}"
973        );
974        let workspaces = jj_out(&f.canon, &["workspace", "list"]);
975        assert!(
976            !workspaces.contains("rq-moz-D7"),
977            "expected workspace to be forgotten, found: {workspaces}"
978        );
979        assert_eq!(
980            commit_id(&f.canon, "trunk()"),
981            trunk_before,
982            "canonical repo must be untouched"
983        );
984    }
985
986    #[test]
987    fn add_workspace_with_external_command_runs_it_with_env_and_captures_head() {
988        require_jj!();
989        let f = fixture();
990        let vcs = JjVcs;
991        let ws = f._tmp.path().join("ws");
992        // A stand-in for `moz-phab patch`, which does exactly this on the jj path: modify the
993        // tree, `jj describe` to finalize the commit holding those changes, then `jj new` to
994        // leave a fresh empty `@` on top - the same "always ends empty" contract our own patch
995        // application already follows, which is what makes `head_id` (`@-`) correct afterward.
996        let checkout = Checkout::ExternalCommand {
997            program: "sh".into(),
998            args: vec![
999                "-c".into(),
1000                "echo \"$RQ_TEST_VAR\" > ext.txt && jj describe -m ext --quiet && jj new --quiet"
1001                    .into(),
1002            ],
1003            env: vec![("RQ_TEST_VAR".into(), "hello-from-env".into())],
1004        };
1005
1006        let head = vcs
1007            .add_workspace(&f.canon, &ws, &checkout, "moz/D8", "1")
1008            .unwrap();
1009
1010        assert_eq!(
1011            fs::read_to_string(ws.join("ext.txt")).unwrap().trim(),
1012            "hello-from-env"
1013        );
1014        assert_eq!(commit_id(&ws, "@-"), head);
1015        assert_eq!(commit_id(&f.canon, "review-queue/moz/D8/1"), head);
1016    }
1017
1018    #[test]
1019    fn external_command_failure_leaves_workspace_for_inspection() {
1020        require_jj!();
1021        let f = fixture();
1022        let vcs = JjVcs;
1023        let ws = f._tmp.path().join("ws");
1024        let checkout = Checkout::ExternalCommand {
1025            program: "sh".into(),
1026            args: vec!["-c".into(), "exit 7".into()],
1027            env: vec![],
1028        };
1029
1030        let err = vcs
1031            .add_workspace(&f.canon, &ws, &checkout, "moz/D9", "1")
1032            .unwrap_err();
1033
1034        assert!(
1035            ws.exists(),
1036            "workspace should be left in place for inspection, not cleaned up"
1037        );
1038        assert!(
1039            err.to_string().contains("failed"),
1040            "unexpected error: {err}"
1041        );
1042    }
1043}