1use 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 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 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 fn author_config_args(patch: &Patch) -> Result<Vec<String>> {
160 let (name, email) = split_author(&patch.author)?;
161 Ok(vec![
164 "--config".into(),
165 format!("user.name={name:?}"),
166 "--config".into(),
167 format!("user.email={email:?}"),
168 ])
169 }
170
171 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 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 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 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
265fn 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 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 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 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 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; }
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 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 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 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 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 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 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 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 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 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}