1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3use std::future::Future;
124use std::path::{Path, PathBuf};
125use std::time::Duration;
126
127use processkit::ProcessRunner;
128pub use processkit::{Error, ProcessResult, Result};
131#[cfg(feature = "cancellation")]
134#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
135pub use processkit::CancellationToken;
136
137pub mod conflict;
138mod parse;
139pub use parse::{AnnotationLine, Bookmark, BookmarkRef, Change, ChangedPath, Operation, Workspace};
140pub use vcs_diff::{
144 ChangeKind, DiffLine, DiffStat, FileDiff, Hunk, Version as JjVersion, parse_diff,
145};
146pub use vcs_cli_support::is_transient_fetch_error;
149
150pub const BINARY: &str = "jj";
152
153#[derive(Debug, Clone)]
157#[non_exhaustive]
158pub enum DiffSpec {
159 WorkingTree,
161 Rev(String),
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168#[non_exhaustive]
169pub enum SparseMode {
170 Copy,
172 Full,
174 Empty,
176}
177
178impl SparseMode {
179 fn as_arg(self) -> &'static str {
181 match self {
182 SparseMode::Copy => "copy",
183 SparseMode::Full => "full",
184 SparseMode::Empty => "empty",
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct JjFileset(String);
195
196impl JjFileset {
197 pub fn path(path: impl AsRef<str>) -> Self {
203 let escaped = path.as_ref().replace('\\', "/").replace('"', "\\\"");
204 JjFileset(format!("file:\"{escaped}\""))
205 }
206
207 pub fn as_str(&self) -> &str {
209 &self.0
210 }
211}
212
213#[derive(Debug, Clone)]
217#[non_exhaustive]
218pub struct WorkspaceAdd {
219 pub name: String,
221 pub base: String,
223 pub path: PathBuf,
225 pub sparse_patterns: Option<SparseMode>,
228}
229
230impl WorkspaceAdd {
231 pub fn new(name: impl Into<String>, base: impl Into<String>, path: impl Into<PathBuf>) -> Self {
233 Self {
234 name: name.into(),
235 base: base.into(),
236 path: path.into(),
237 sparse_patterns: None,
238 }
239 }
240
241 pub fn sparse(mut self, mode: SparseMode) -> Self {
243 self.sparse_patterns = Some(mode);
244 self
245 }
246}
247
248#[derive(Debug, Clone)]
254#[non_exhaustive]
255pub struct SquashPaths {
256 pub from: String,
258 pub into: String,
260 pub filesets: Vec<JjFileset>,
262 pub use_destination_message: bool,
265}
266
267impl SquashPaths {
268 pub fn new(from: impl Into<String>, into: impl Into<String>) -> Self {
270 Self {
271 from: from.into(),
272 into: into.into(),
273 filesets: Vec::new(),
274 use_destination_message: false,
275 }
276 }
277
278 pub fn filesets(mut self, filesets: impl IntoIterator<Item = JjFileset>) -> Self {
280 self.filesets = filesets.into_iter().collect();
281 self
282 }
283
284 pub fn use_destination_message(mut self) -> Self {
287 self.use_destination_message = true;
288 self
289 }
290}
291
292fn first_bookmark(rendered: &str) -> Option<String> {
295 let rendered = rendered.trim();
296 (!rendered.is_empty()).then(|| rendered.split(',').next().unwrap_or(rendered).to_string())
297}
298
299fn reject_flag_like(what: &str, value: &str) -> Result<()> {
306 vcs_cli_support::reject_flag_like(BINARY, what, value)
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Hash)]
317pub struct RevsetExpr(String);
318
319impl RevsetExpr {
320 pub fn new(revset: impl Into<String>) -> Result<Self> {
322 let revset = revset.into();
323 reject_flag_like("revset", &revset)?;
324 Ok(RevsetExpr(revset))
325 }
326
327 pub fn as_str(&self) -> &str {
329 &self.0
330 }
331}
332
333impl std::fmt::Display for RevsetExpr {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 f.write_str(&self.0)
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343#[non_exhaustive]
344pub struct JjCapabilities {
345 pub version: JjVersion,
347}
348
349const MIN_SUPPORTED: JjVersion = JjVersion {
353 major: 0,
354 minor: 38,
355 patch: 0,
356};
357
358impl JjCapabilities {
359 pub fn is_supported(&self) -> bool {
361 self.version >= MIN_SUPPORTED
362 }
363
364 pub fn ensure_supported(&self) -> Result<()> {
367 if self.is_supported() {
368 return Ok(());
369 }
370 Err(Error::Spawn {
371 program: BINARY.to_string(),
372 source: std::io::Error::new(
373 std::io::ErrorKind::Unsupported,
374 format!(
375 "vcs-jj requires jj >= {MIN_SUPPORTED} (the validated floor), found {}",
376 self.version
377 ),
378 ),
379 })
380 }
381}
382
383#[cfg_attr(feature = "mock", mockall::automock)]
393#[async_trait::async_trait]
394pub trait JjApi: Send + Sync {
395 async fn run(&self, args: &[String]) -> Result<String>;
397 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
400 async fn version(&self) -> Result<String>;
402 async fn capabilities(&self) -> Result<JjCapabilities>;
406 async fn status(&self, dir: &Path) -> Result<Vec<ChangedPath>>;
409 async fn status_text(&self, dir: &Path) -> Result<String>;
412 async fn log(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>>;
414 async fn current_change(&self, dir: &Path) -> Result<Change>;
416 async fn describe(&self, dir: &Path, message: &str) -> Result<()>;
418 async fn describe_rev(&self, dir: &Path, revset: &str, message: &str) -> Result<()>;
420 async fn new_change(&self, dir: &Path, message: &str) -> Result<()>;
422 async fn bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>>;
424 async fn bookmarks_all(&self, dir: &Path) -> Result<Vec<BookmarkRef>>;
426 async fn reachable_bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>>;
430 async fn bookmark_track(&self, dir: &Path, name: &str, remote: &str) -> Result<()>;
432 async fn bookmark_set(&self, dir: &Path, name: &str, revision: &str) -> Result<()>;
434 async fn git_fetch(&self, dir: &Path) -> Result<()>;
437 async fn git_fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
440 async fn git_push(&self, dir: &Path, bookmark: Option<String>) -> Result<()>;
443
444 async fn root(&self, dir: &Path) -> Result<PathBuf>;
448 async fn current_bookmark(&self, dir: &Path) -> Result<Option<String>>;
452 async fn trunk(&self, dir: &Path) -> Result<Option<String>>;
454
455 async fn bookmark_create(&self, dir: &Path, name: &str, revision: &str) -> Result<()>;
459 async fn bookmark_rename(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
461 async fn bookmark_delete(&self, dir: &Path, name: &str) -> Result<()>;
463 async fn bookmark_move(
466 &self,
467 dir: &Path,
468 name: &str,
469 to: &str,
470 allow_backwards: bool,
471 ) -> Result<()>;
472
473 async fn diff_summary(&self, dir: &Path, from: &str, to: &str) -> Result<Vec<ChangedPath>>;
477 async fn diff_stat(&self, dir: &Path, revset: &str) -> Result<DiffStat>;
479 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
482 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
484 async fn commit_count(&self, dir: &Path, revset: &str) -> Result<usize>;
486 async fn is_conflicted(&self, dir: &Path, revset: &str) -> Result<bool>;
488 async fn has_workingcopy_conflict(&self, dir: &Path) -> Result<bool>;
490 async fn resolve_list(&self, dir: &Path, revset: &str) -> Result<Vec<String>>;
493 async fn template_query(
496 &self,
497 dir: &Path,
498 revset: &str,
499 template: &str,
500 limit: Option<usize>,
501 ) -> Result<String>;
502 async fn description(&self, dir: &Path, revset: &str) -> Result<String>;
508 async fn evolog(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>>;
512 async fn file_annotate(
515 &self,
516 dir: &Path,
517 path: &str,
518 revset: Option<String>,
519 ) -> Result<Vec<AnnotationLine>>;
520 async fn file_show(&self, dir: &Path, revset: &str, path: &str) -> Result<String>;
525
526 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
530 async fn rebase_branch(&self, dir: &Path, branch: &str, dest: &str) -> Result<()>;
532 async fn edit(&self, dir: &Path, revset: &str) -> Result<()>;
534 async fn squash_into(
538 &self,
539 dir: &Path,
540 into: &str,
541 use_destination_message: bool,
542 ) -> Result<()>;
543 async fn commit_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
546 async fn squash_paths(&self, dir: &Path, spec: SquashPaths) -> Result<()>;
549 async fn sparse_set(&self, dir: &Path, patterns: &[String]) -> Result<()>;
552 async fn new_merge(&self, dir: &Path, message: &str, parents: Vec<String>) -> Result<()>;
554 async fn abandon(&self, dir: &Path, revset: &str) -> Result<()>;
556 async fn git_fetch_branch(&self, dir: &Path, branch: &str) -> Result<()>;
559 async fn git_import(&self, dir: &Path) -> Result<()>;
561 async fn git_clone(&self, url: &str, dest: &Path, colocate: bool) -> Result<()>;
568 async fn absorb(&self, dir: &Path, from: Option<String>, filesets: &[JjFileset]) -> Result<()>;
572 async fn split_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
578 async fn duplicate(&self, dir: &Path, revset: &str) -> Result<()>;
580
581 async fn op_head(&self, dir: &Path) -> Result<String>;
586 async fn op_log(&self, dir: &Path, limit: usize) -> Result<Vec<Operation>>;
589 async fn op_restore(&self, dir: &Path, op_id: &str) -> Result<()>;
591 async fn op_undo(&self, dir: &Path) -> Result<()>;
593
594 async fn workspace_list(&self, dir: &Path) -> Result<Vec<Workspace>>;
598 async fn workspace_root(&self, dir: &Path, name: Option<String>) -> Result<PathBuf>;
600 async fn workspace_add(&self, dir: &Path, spec: WorkspaceAdd) -> Result<()>;
602 async fn workspace_forget(&self, dir: &Path, name: &str) -> Result<()>;
604}
605
606processkit::cli_client!(
607 pub struct Jj => BINARY
610);
611
612impl<R: ProcessRunner> Jj<R> {
613 fn cmd_in<I, S>(&self, dir: &Path, args: I) -> processkit::Command
620 where
621 I: IntoIterator<Item = S>,
622 S: AsRef<std::ffi::OsStr>,
623 {
624 self.core.command_in(dir, args).arg("--color").arg("never")
625 }
626}
627
628#[async_trait::async_trait]
629impl<R: ProcessRunner> JjApi for Jj<R> {
630 async fn run(&self, args: &[String]) -> Result<String> {
631 self.core.run(self.core.command(args)).await
632 }
633
634 async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
635 self.core.output(self.core.command(args)).await
636 }
637
638 async fn version(&self) -> Result<String> {
639 self.core.run(self.core.command(["--version"])).await
640 }
641
642 async fn capabilities(&self) -> Result<JjCapabilities> {
643 let raw = self.version().await?;
644 let version = parse::parse_jj_version(&raw).ok_or_else(|| Error::Parse {
645 program: BINARY.to_string(),
646 message: format!("unrecognisable `jj --version` output: {raw:?}"),
647 })?;
648 Ok(JjCapabilities { version })
649 }
650
651 async fn status(&self, dir: &Path) -> Result<Vec<ChangedPath>> {
652 self.core
655 .parse(
656 self.cmd_in(dir, ["diff", "-r", "@", "--summary"]),
657 parse::parse_diff_summary,
658 )
659 .await
660 }
661
662 async fn status_text(&self, dir: &Path) -> Result<String> {
663 self.core.run(self.cmd_in(dir, ["status"])).await
664 }
665
666 async fn log(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>> {
667 let n = format!("-n{max}");
668 self.core
669 .parse(
670 self.cmd_in(
671 dir,
672 [
673 "log",
674 "-r",
675 revset,
676 n.as_str(),
677 "--no-graph",
678 "-T",
679 parse::CHANGE_TEMPLATE,
680 ],
681 ),
682 parse::parse_changes,
683 )
684 .await
685 }
686
687 async fn current_change(&self, dir: &Path) -> Result<Change> {
688 let mut changes = self.log(dir, "@", 1).await?;
689 changes.pop().ok_or_else(|| Error::Parse {
690 program: BINARY.to_string(),
691 message: "no working-copy change found".to_string(),
692 })
693 }
694
695 async fn describe(&self, dir: &Path, message: &str) -> Result<()> {
696 self.core
697 .run_unit(self.cmd_in(dir, ["describe", "-m", message]))
698 .await
699 }
700
701 async fn describe_rev(&self, dir: &Path, revset: &str, message: &str) -> Result<()> {
702 self.core
703 .run_unit(self.cmd_in(dir, ["describe", "-r", revset, "-m", message]))
704 .await
705 }
706
707 async fn new_change(&self, dir: &Path, message: &str) -> Result<()> {
708 self.core
709 .run_unit(self.cmd_in(dir, ["new", "-m", message]))
710 .await
711 }
712
713 async fn bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>> {
714 self.core
715 .parse(
716 self.cmd_in(dir, ["bookmark", "list"]),
717 parse::parse_bookmarks,
718 )
719 .await
720 }
721
722 async fn bookmarks_all(&self, dir: &Path) -> Result<Vec<BookmarkRef>> {
723 self.core
724 .parse(
725 self.cmd_in(
726 dir,
727 ["bookmark", "list", "-a", "-T", parse::BOOKMARK_ALL_TEMPLATE],
728 ),
729 parse::parse_bookmarks_all,
730 )
731 .await
732 }
733
734 async fn reachable_bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>> {
735 self.core
736 .parse(
737 self.cmd_in(
738 dir,
739 [
740 "log",
741 "-r",
742 "heads(::@ & bookmarks())",
743 "--no-graph",
744 "-T",
745 parse::REACHABLE_BOOKMARKS_TEMPLATE,
746 ],
747 ),
748 parse::parse_reachable_bookmarks,
749 )
750 .await
751 }
752
753 async fn bookmark_track(&self, dir: &Path, name: &str, remote: &str) -> Result<()> {
754 reject_flag_like("bookmark name", name)?;
757 let target = format!("{name}@{remote}");
758 self.core
759 .run_unit(self.cmd_in(dir, ["bookmark", "track", target.as_str()]))
760 .await
761 }
762
763 async fn bookmark_set(&self, dir: &Path, name: &str, revision: &str) -> Result<()> {
764 reject_flag_like("bookmark name", name)?;
765 self.core
766 .run_unit(self.cmd_in(dir, ["bookmark", "set", name, "-r", revision]))
767 .await
768 }
769
770 async fn git_fetch(&self, dir: &Path) -> Result<()> {
771 let cmd = self.cmd_in(dir, ["git", "fetch"]).retry(
773 FETCH_ATTEMPTS,
774 FETCH_BACKOFF,
775 is_transient_fetch_error,
776 );
777 self.core.run_unit(cmd).await
778 }
779
780 async fn git_fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
781 let cmd = self
783 .cmd_in(dir, ["git", "fetch", "--remote", remote])
784 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
785 self.core.run_unit(cmd).await
786 }
787
788 async fn git_push(&self, dir: &Path, bookmark: Option<String>) -> Result<()> {
789 let mut args = vec!["git", "push"];
790 if let Some(name) = bookmark.as_deref() {
791 args.push("-b");
792 args.push(name);
793 }
794 self.core.run_unit(self.cmd_in(dir, args)).await
795 }
796
797 async fn root(&self, dir: &Path) -> Result<PathBuf> {
798 Ok(PathBuf::from(
799 self.core.run(self.cmd_in(dir, ["root"])).await?,
800 ))
801 }
802
803 async fn current_bookmark(&self, dir: &Path) -> Result<Option<String>> {
804 let out = self
805 .core
806 .run(self.cmd_in(
807 dir,
808 [
809 "log",
810 "-r",
811 "@",
812 "--no-graph",
813 "--limit",
814 "1",
815 "-T",
816 parse::BOOKMARKS_TEMPLATE,
817 ],
818 ))
819 .await?;
820 Ok(first_bookmark(&out))
821 }
822
823 async fn trunk(&self, dir: &Path) -> Result<Option<String>> {
824 let out = self
825 .core
826 .run(self.cmd_in(
827 dir,
828 [
829 "log",
830 "-r",
831 "trunk()",
832 "--no-graph",
833 "--limit",
834 "1",
835 "-T",
836 parse::BOOKMARKS_TEMPLATE,
837 ],
838 ))
839 .await?;
840 Ok(first_bookmark(&out))
841 }
842
843 async fn bookmark_create(&self, dir: &Path, name: &str, revision: &str) -> Result<()> {
844 reject_flag_like("bookmark name", name)?;
845 self.core
846 .run_unit(self.cmd_in(dir, ["bookmark", "create", name, "-r", revision]))
847 .await
848 }
849
850 async fn bookmark_rename(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
851 reject_flag_like("bookmark name", old)?;
852 reject_flag_like("bookmark name", new)?;
853 self.core
854 .run_unit(self.cmd_in(dir, ["bookmark", "rename", old, new]))
855 .await
856 }
857
858 async fn bookmark_delete(&self, dir: &Path, name: &str) -> Result<()> {
859 reject_flag_like("bookmark name", name)?;
860 self.core
861 .run_unit(self.cmd_in(dir, ["bookmark", "delete", name]))
862 .await
863 }
864
865 async fn bookmark_move(
866 &self,
867 dir: &Path,
868 name: &str,
869 to: &str,
870 allow_backwards: bool,
871 ) -> Result<()> {
872 reject_flag_like("bookmark name", name)?;
873 let mut args = vec!["bookmark", "move", name, "--to", to];
874 if allow_backwards {
875 args.push("--allow-backwards");
876 }
877 self.core.run_unit(self.cmd_in(dir, args)).await
878 }
879
880 async fn diff_summary(&self, dir: &Path, from: &str, to: &str) -> Result<Vec<ChangedPath>> {
881 let range = format!("({from})..({to})");
884 self.core
885 .parse(
886 self.cmd_in(dir, ["diff", "-r", range.as_str(), "--summary"]),
887 parse::parse_diff_summary,
888 )
889 .await
890 }
891
892 async fn diff_stat(&self, dir: &Path, revset: &str) -> Result<DiffStat> {
893 self.core
894 .parse(
895 self.cmd_in(dir, ["diff", "-r", revset, "--stat"]),
896 parse::parse_diff_stat,
897 )
898 .await
899 }
900
901 async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
902 let revset = match spec {
905 DiffSpec::WorkingTree => "@".to_string(),
906 DiffSpec::Rev(rev) => rev,
907 };
908 self.core
909 .run(self.cmd_in(dir, ["diff", "-r", revset.as_str(), "--git"]))
910 .await
911 }
912
913 async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
914 let text = self.diff_text(dir, spec).await?;
915 Ok(parse_diff(&text))
916 }
917
918 async fn commit_count(&self, dir: &Path, revset: &str) -> Result<usize> {
919 self.core
920 .parse(
921 self.cmd_in(
922 dir,
923 [
924 "log",
925 "-r",
926 revset,
927 "--no-graph",
928 "-T",
929 parse::COUNT_TEMPLATE,
930 ],
931 ),
932 |s| s.lines().filter(|line| !line.is_empty()).count(),
933 )
934 .await
935 }
936
937 async fn is_conflicted(&self, dir: &Path, revset: &str) -> Result<bool> {
938 let out = self
939 .core
940 .run(self.cmd_in(
941 dir,
942 [
943 "log",
944 "-r",
945 revset,
946 "--no-graph",
947 "--limit",
948 "1",
949 "-T",
950 parse::CONFLICT_TEMPLATE,
951 ],
952 ))
953 .await?;
954 Ok(out.trim() == "1")
955 }
956
957 async fn has_workingcopy_conflict(&self, dir: &Path) -> Result<bool> {
958 self.is_conflicted(dir, "@").await
961 }
962
963 async fn resolve_list(&self, dir: &Path, revset: &str) -> Result<Vec<String>> {
964 let res = self
965 .core
966 .output(self.cmd_in(dir, ["resolve", "--list", "-r", revset]))
967 .await?;
968 match res.code() {
969 Some(0) => Ok(parse::parse_resolve_list(res.stdout())),
970 _ if res.stderr().contains("No conflicts") => Ok(Vec::new()),
975 _ => {
976 res.ensure_success()?;
977 Ok(Vec::new()) }
979 }
980 }
981
982 async fn template_query(
983 &self,
984 dir: &Path,
985 revset: &str,
986 template: &str,
987 limit: Option<usize>,
988 ) -> Result<String> {
989 let mut args: Vec<String> = vec![
990 "log".into(),
991 "-r".into(),
992 revset.into(),
993 "--no-graph".into(),
994 ];
995 if let Some(n) = limit {
996 args.push("--limit".into());
997 args.push(n.to_string());
998 }
999 args.push("-T".into());
1000 args.push(template.into());
1001 self.core.run(self.cmd_in(dir, args)).await
1002 }
1003
1004 async fn description(&self, dir: &Path, revset: &str) -> Result<String> {
1005 self.template_query(dir, revset, "description", Some(1))
1006 .await
1007 }
1008
1009 async fn evolog(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>> {
1010 let limit = max.to_string();
1014 self.core
1015 .parse(
1016 self.cmd_in(
1017 dir,
1018 [
1019 "evolog",
1020 "-r",
1021 revset,
1022 "--no-graph",
1023 "--limit",
1024 limit.as_str(),
1025 "-T",
1026 parse::EVOLOG_TEMPLATE,
1027 ],
1028 ),
1029 parse::parse_changes,
1030 )
1031 .await
1032 }
1033
1034 async fn file_annotate(
1035 &self,
1036 dir: &Path,
1037 path: &str,
1038 revset: Option<String>,
1039 ) -> Result<Vec<AnnotationLine>> {
1040 let mut args = vec!["file", "annotate"];
1046 if let Some(revset) = revset.as_deref() {
1047 args.push("-r");
1048 args.push(revset);
1049 }
1050 args.extend([
1051 "-T",
1052 parse::ANNOTATE_TEMPLATE,
1053 "--color",
1054 "never",
1055 "--",
1056 path,
1057 ]);
1058 self.core
1059 .parse(self.core.command_in(dir, args), parse::parse_annotate)
1060 .await
1061 }
1062
1063 async fn file_show(&self, dir: &Path, revset: &str, path: &str) -> Result<String> {
1064 let fileset = JjFileset::path(path);
1069 self.core
1070 .run(self.cmd_in(dir, ["file", "show", "-r", revset, fileset.as_str()]))
1071 .await
1072 }
1073
1074 async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
1075 self.core
1076 .run_unit(self.cmd_in(dir, ["rebase", "-d", onto]))
1077 .await
1078 }
1079
1080 async fn rebase_branch(&self, dir: &Path, branch: &str, dest: &str) -> Result<()> {
1081 self.core
1082 .run_unit(self.cmd_in(dir, ["rebase", "-b", branch, "-d", dest]))
1083 .await
1084 }
1085
1086 async fn edit(&self, dir: &Path, revset: &str) -> Result<()> {
1087 reject_flag_like("revset", revset)?;
1088 self.core.run_unit(self.cmd_in(dir, ["edit", revset])).await
1089 }
1090
1091 async fn squash_into(
1092 &self,
1093 dir: &Path,
1094 into: &str,
1095 use_destination_message: bool,
1096 ) -> Result<()> {
1097 let mut command = self.cmd_in(dir, ["squash", "--into", into]);
1098 if use_destination_message {
1099 command = command.arg("--use-destination-message");
1100 }
1101 self.core.run_unit(command).await
1102 }
1103
1104 async fn commit_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()> {
1105 let mut args: Vec<String> = vec!["commit".into(), "-m".into(), message.into()];
1106 args.extend(filesets.iter().map(|f| f.as_str().to_string()));
1107 self.core.run_unit(self.cmd_in(dir, args)).await
1108 }
1109
1110 async fn squash_paths(&self, dir: &Path, spec: SquashPaths) -> Result<()> {
1111 let mut args: Vec<String> = vec![
1112 "squash".into(),
1113 "--from".into(),
1114 spec.from,
1115 "--into".into(),
1116 spec.into,
1117 ];
1118 if spec.use_destination_message {
1119 args.push("--use-destination-message".into());
1120 }
1121 args.extend(spec.filesets.iter().map(|f| f.as_str().to_string()));
1122 self.core.run_unit(self.cmd_in(dir, args)).await
1123 }
1124
1125 async fn sparse_set(&self, dir: &Path, patterns: &[String]) -> Result<()> {
1126 let mut args: Vec<String> = vec!["sparse".into(), "set".into(), "--clear".into()];
1129 for pattern in patterns {
1130 args.push("--add".into());
1131 args.push(pattern.clone());
1132 }
1133 self.core.run_unit(self.cmd_in(dir, args)).await
1134 }
1135
1136 async fn new_merge(&self, dir: &Path, message: &str, parents: Vec<String>) -> Result<()> {
1137 for parent in &parents {
1140 reject_flag_like("parent", parent)?;
1141 }
1142 let mut args: Vec<String> = vec!["new".into(), "-m".into(), message.into()];
1143 args.extend(parents);
1144 self.core.run_unit(self.cmd_in(dir, args)).await
1145 }
1146
1147 async fn abandon(&self, dir: &Path, revset: &str) -> Result<()> {
1148 reject_flag_like("revset", revset)?;
1149 self.core
1150 .run_unit(self.cmd_in(dir, ["abandon", revset]))
1151 .await
1152 }
1153
1154 async fn git_fetch_branch(&self, dir: &Path, branch: &str) -> Result<()> {
1155 let cmd = self
1156 .cmd_in(dir, ["git", "fetch", "--remote", "origin", "-b", branch])
1157 .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
1158 self.core.run_unit(cmd).await
1159 }
1160
1161 async fn git_import(&self, dir: &Path) -> Result<()> {
1162 self.core
1163 .run_unit(self.cmd_in(dir, ["git", "import"]))
1164 .await
1165 }
1166
1167 async fn git_clone(&self, url: &str, dest: &Path, colocate: bool) -> Result<()> {
1168 reject_flag_like("url", url)?;
1171 let command = self
1178 .core
1179 .command(["git", "clone", url])
1180 .arg(dest)
1181 .arg(if colocate {
1182 "--colocate"
1183 } else {
1184 "--no-colocate"
1185 });
1186 self.core
1187 .run_unit(command.arg("--color").arg("never"))
1188 .await
1189 }
1190
1191 async fn absorb(&self, dir: &Path, from: Option<String>, filesets: &[JjFileset]) -> Result<()> {
1192 let mut args: Vec<String> = vec!["absorb".into()];
1193 if let Some(from) = from.as_deref() {
1194 args.push("--from".into());
1195 args.push(from.into());
1196 }
1197 args.extend(filesets.iter().map(|f| f.as_str().to_string()));
1198 self.core.run_unit(self.cmd_in(dir, args)).await
1199 }
1200
1201 async fn split_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()> {
1202 if filesets.is_empty() {
1206 return Err(Error::Spawn {
1207 program: BINARY.to_string(),
1208 source: std::io::Error::new(
1209 std::io::ErrorKind::InvalidInput,
1210 "split_paths requires at least one fileset — an empty split \
1211 opens jj's interactive diff editor",
1212 ),
1213 });
1214 }
1215 let mut args: Vec<String> = vec!["split".into(), "-m".into(), message.into()];
1217 args.extend(filesets.iter().map(|f| f.as_str().to_string()));
1218 self.core.run_unit(self.cmd_in(dir, args)).await
1219 }
1220
1221 async fn duplicate(&self, dir: &Path, revset: &str) -> Result<()> {
1222 reject_flag_like("revset", revset)?;
1223 self.core
1224 .run_unit(self.cmd_in(dir, ["duplicate", revset]))
1225 .await
1226 }
1227
1228 async fn op_head(&self, dir: &Path) -> Result<String> {
1229 self.core
1230 .run(self.cmd_in(
1231 dir,
1232 [
1233 "op",
1234 "log",
1235 "--no-graph",
1236 "--limit",
1237 "1",
1238 "-T",
1239 "id.short()",
1240 ],
1241 ))
1242 .await
1243 }
1244
1245 async fn op_log(&self, dir: &Path, limit: usize) -> Result<Vec<Operation>> {
1246 let limit = limit.to_string();
1247 self.core
1248 .parse(
1249 self.cmd_in(
1250 dir,
1251 [
1252 "op",
1253 "log",
1254 "--no-graph",
1255 "--limit",
1256 limit.as_str(),
1257 "-T",
1258 parse::OP_TEMPLATE,
1259 ],
1260 ),
1261 parse::parse_operations,
1262 )
1263 .await
1264 }
1265
1266 async fn op_restore(&self, dir: &Path, op_id: &str) -> Result<()> {
1267 reject_flag_like("operation id", op_id)?;
1268 self.core
1269 .run_unit(self.cmd_in(dir, ["op", "restore", op_id]))
1270 .await
1271 }
1272
1273 async fn op_undo(&self, dir: &Path) -> Result<()> {
1274 self.core.run_unit(self.cmd_in(dir, ["op", "undo"])).await
1275 }
1276
1277 async fn workspace_list(&self, dir: &Path) -> Result<Vec<Workspace>> {
1278 self.core
1279 .parse(
1280 self.cmd_in(dir, ["workspace", "list", "-T", parse::WORKSPACE_TEMPLATE]),
1281 parse::parse_workspaces,
1282 )
1283 .await
1284 }
1285
1286 async fn workspace_root(&self, dir: &Path, name: Option<String>) -> Result<PathBuf> {
1287 let mut args: Vec<String> = vec!["workspace".into(), "root".into()];
1288 if let Some(n) = name.as_deref() {
1289 args.push("--name".into());
1290 args.push(n.to_string());
1291 }
1292 Ok(PathBuf::from(self.core.run(self.cmd_in(dir, args)).await?))
1293 }
1294
1295 async fn workspace_add(&self, dir: &Path, spec: WorkspaceAdd) -> Result<()> {
1296 let mut command = self
1300 .core
1301 .command_in(dir, ["workspace", "add", "--name"])
1302 .arg(&spec.name)
1303 .arg("-r")
1304 .arg(&spec.base);
1305 if let Some(mode) = spec.sparse_patterns {
1306 command = command.arg("--sparse-patterns").arg(mode.as_arg());
1307 }
1308 command = command.arg(&spec.path).arg("--color").arg("never");
1309 self.core.run_unit(command).await
1310 }
1311
1312 async fn workspace_forget(&self, dir: &Path, name: &str) -> Result<()> {
1313 reject_flag_like("workspace name", name)?;
1314 self.core
1315 .run_unit(self.cmd_in(dir, ["workspace", "forget", name]))
1316 .await
1317 }
1318}
1319
1320const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
1323const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
1324
1325const WORKSPACE_ROOTS_CONCURRENCY: usize = 8;
1329
1330impl<R: ProcessRunner> Jj<R> {
1331 pub async fn run_args(&self, args: &[&str]) -> Result<String> {
1336 self.core.run(self.core.command(args)).await
1337 }
1338
1339 pub async fn workspace_roots(&self, dir: &Path, names: &[String]) -> Vec<Result<PathBuf>> {
1349 let commands = names
1350 .iter()
1351 .map(|n| self.cmd_in(dir, ["workspace", "root", "--name", n.as_str()]));
1352 processkit::output_all(commands, WORKSPACE_ROOTS_CONCURRENCY, self.core.runner())
1353 .await
1354 .into_iter()
1355 .map(|r| {
1356 r.and_then(|pr| pr.ensure_success())
1357 .map(|pr| PathBuf::from(pr.stdout().trim_end()))
1360 })
1361 .collect()
1362 }
1363
1364 pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
1367 self.core.output(self.core.command(args)).await
1368 }
1369
1370 pub fn at<'a>(&'a self, dir: &'a Path) -> JjAt<'a, R> {
1375 JjAt { jj: self, dir }
1376 }
1377
1378 pub async fn transaction<'a, T, F, Fut>(&'a self, dir: &'a Path, f: F) -> Result<T>
1404 where
1405 F: FnOnce(JjAt<'a, R>) -> Fut,
1406 Fut: Future<Output = Result<T>> + 'a,
1407 {
1408 let pre = self.op_head(dir).await?;
1409 match f(self.at(dir)).await {
1410 Ok(value) => Ok(value),
1411 Err(err) => {
1412 let _ = self.op_restore(dir, &pre).await;
1415 Err(err)
1416 }
1417 }
1418 }
1419}
1420
1421pub struct JjAt<'a, R: ProcessRunner = processkit::JobRunner> {
1426 jj: &'a Jj<R>,
1427 dir: &'a Path,
1428}
1429
1430impl<R: ProcessRunner> Clone for JjAt<'_, R> {
1435 fn clone(&self) -> Self {
1436 *self
1437 }
1438}
1439impl<R: ProcessRunner> Copy for JjAt<'_, R> {}
1440
1441macro_rules! jj_at_forwarders {
1444 (
1445 bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
1446 dir { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
1447 ) => {
1448 impl<'a, R: ProcessRunner> JjAt<'a, R> {
1449 $(
1450 #[doc = concat!("Bound form of [`Jj`]'s `", stringify!($bn), "`.")]
1451 pub async fn $bn(&self, $($ba: $bt),*) -> $br {
1452 self.jj.$bn($($ba),*).await
1453 }
1454 )*
1455 $(
1456 #[doc = concat!("Bound form of [`Jj`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
1457 pub async fn $dn(&self, $($da: $dt),*) -> $dr {
1458 self.jj.$dn(self.dir, $($da),*).await
1459 }
1460 )*
1461 }
1462 };
1463}
1464
1465jj_at_forwarders! {
1466 bare {
1467 fn run(args: &[String]) -> Result<String>;
1468 fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
1469 fn run_args(args: &[&str]) -> Result<String>;
1470 fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
1471 fn version() -> Result<String>;
1472 fn capabilities() -> Result<JjCapabilities>;
1473 fn git_clone(url: &str, dest: &Path, colocate: bool) -> Result<()>;
1474 }
1475 dir {
1476 fn status() -> Result<Vec<ChangedPath>>;
1477 fn status_text() -> Result<String>;
1478 fn log(revset: &str, max: usize) -> Result<Vec<Change>>;
1479 fn current_change() -> Result<Change>;
1480 fn describe(message: &str) -> Result<()>;
1481 fn describe_rev(revset: &str, message: &str) -> Result<()>;
1482 fn new_change(message: &str) -> Result<()>;
1483 fn bookmarks() -> Result<Vec<Bookmark>>;
1484 fn bookmarks_all() -> Result<Vec<BookmarkRef>>;
1485 fn reachable_bookmarks() -> Result<Vec<Bookmark>>;
1486 fn bookmark_track(name: &str, remote: &str) -> Result<()>;
1487 fn bookmark_set(name: &str, revision: &str) -> Result<()>;
1488 fn git_fetch() -> Result<()>;
1489 fn git_fetch_from(remote: &str) -> Result<()>;
1490 fn git_push(bookmark: Option<String>) -> Result<()>;
1491 fn root() -> Result<PathBuf>;
1492 fn current_bookmark() -> Result<Option<String>>;
1493 fn trunk() -> Result<Option<String>>;
1494 fn bookmark_create(name: &str, revision: &str) -> Result<()>;
1495 fn bookmark_rename(old: &str, new: &str) -> Result<()>;
1496 fn bookmark_delete(name: &str) -> Result<()>;
1497 fn bookmark_move(name: &str, to: &str, allow_backwards: bool) -> Result<()>;
1498 fn diff_summary(from: &str, to: &str) -> Result<Vec<ChangedPath>>;
1499 fn diff_stat(revset: &str) -> Result<DiffStat>;
1500 fn diff_text(spec: DiffSpec) -> Result<String>;
1501 fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
1502 fn commit_count(revset: &str) -> Result<usize>;
1503 fn is_conflicted(revset: &str) -> Result<bool>;
1504 fn has_workingcopy_conflict() -> Result<bool>;
1505 fn resolve_list(revset: &str) -> Result<Vec<String>>;
1506 fn template_query(revset: &str, template: &str, limit: Option<usize>) -> Result<String>;
1507 fn description(revset: &str) -> Result<String>;
1508 fn evolog(revset: &str, max: usize) -> Result<Vec<Change>>;
1509 fn file_annotate(path: &str, revset: Option<String>) -> Result<Vec<AnnotationLine>>;
1510 fn file_show(revset: &str, path: &str) -> Result<String>;
1511 fn absorb(from: Option<String>, filesets: &[JjFileset]) -> Result<()>;
1512 fn split_paths(filesets: &[JjFileset], message: &str) -> Result<()>;
1513 fn duplicate(revset: &str) -> Result<()>;
1514 fn rebase(onto: &str) -> Result<()>;
1515 fn rebase_branch(branch: &str, dest: &str) -> Result<()>;
1516 fn edit(revset: &str) -> Result<()>;
1517 fn squash_into(into: &str, use_destination_message: bool) -> Result<()>;
1518 fn commit_paths(filesets: &[JjFileset], message: &str) -> Result<()>;
1519 fn squash_paths(spec: SquashPaths) -> Result<()>;
1520 fn sparse_set(patterns: &[String]) -> Result<()>;
1521 fn new_merge(message: &str, parents: Vec<String>) -> Result<()>;
1522 fn abandon(revset: &str) -> Result<()>;
1523 fn git_fetch_branch(branch: &str) -> Result<()>;
1524 fn git_import() -> Result<()>;
1525 fn op_head() -> Result<String>;
1526 fn op_log(limit: usize) -> Result<Vec<Operation>>;
1527 fn op_restore(op_id: &str) -> Result<()>;
1528 fn op_undo() -> Result<()>;
1529 fn workspace_list() -> Result<Vec<Workspace>>;
1530 fn workspace_root(name: Option<String>) -> Result<PathBuf>;
1531 fn workspace_add(spec: WorkspaceAdd) -> Result<()>;
1532 fn workspace_forget(name: &str) -> Result<()>;
1533 }
1534}
1535
1536impl<'a, R: ProcessRunner> JjAt<'a, R> {
1539 pub async fn transaction<T, F, Fut>(&self, f: F) -> Result<T>
1542 where
1543 F: FnOnce(JjAt<'a, R>) -> Fut,
1544 Fut: Future<Output = Result<T>> + 'a,
1545 {
1546 self.jj.transaction(self.dir, f).await
1547 }
1548}
1549
1550pub mod blocking {
1554 use std::path::{Path, PathBuf};
1555 use std::process::Command;
1556
1557 pub fn workspace_forget(dir: &Path, name: &str) -> std::io::Result<()> {
1559 let status = Command::new(super::BINARY)
1560 .current_dir(dir)
1561 .args(["workspace", "forget", name])
1562 .status()?;
1563 if status.success() {
1564 Ok(())
1565 } else {
1566 Err(std::io::Error::other(format!(
1567 "`jj workspace forget` exited with {status}"
1568 )))
1569 }
1570 }
1571
1572 pub fn workspace_name_for_path(dir: &Path, path: &Path) -> Option<String> {
1579 let target = normalize(path);
1580 let out = Command::new(super::BINARY)
1581 .current_dir(dir)
1582 .args(["workspace", "list", "-T", "name ++ \"\\n\""])
1583 .output()
1584 .ok()?;
1585 if !out.status.success() {
1586 return None;
1587 }
1588 for name in String::from_utf8_lossy(&out.stdout).lines() {
1589 let name = name.trim();
1590 if name.is_empty() {
1591 continue;
1592 }
1593 let root = Command::new(super::BINARY)
1594 .current_dir(dir)
1595 .args(["workspace", "root", "--name", name])
1596 .output();
1597 if let Ok(r) = root
1598 && r.status.success()
1599 {
1600 let p = PathBuf::from(String::from_utf8_lossy(&r.stdout).trim().to_string());
1601 if normalize(&p) == target || p == target || p == path {
1602 return Some(name.to_string());
1603 }
1604 }
1605 }
1606 None
1607 }
1608
1609 fn normalize(p: &Path) -> PathBuf {
1612 let canonical = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
1613 #[cfg(windows)]
1614 {
1615 let s = canonical.to_string_lossy();
1616 if let Some(rest) = s.strip_prefix(r"\\?\")
1617 && !rest.starts_with("UNC\\")
1618 {
1619 return PathBuf::from(rest.to_string());
1620 }
1621 }
1622 canonical
1623 }
1624}
1625
1626#[cfg(test)]
1627mod tests {
1628 use super::*;
1629 use processkit::{RecordingRunner, Reply, ScriptedRunner};
1630
1631 #[test]
1632 fn binary_name_is_jj() {
1633 assert_eq!(BINARY, "jj");
1634 }
1635
1636 #[allow(dead_code)]
1638 fn bound_view_is_copy_for_default_runner() {
1639 fn assert_copy<T: Copy>() {}
1640 assert_copy::<JjAt<'static, processkit::JobRunner>>();
1641 }
1642
1643 #[tokio::test]
1646 async fn bound_view_matches_dir_taking_calls() {
1647 let dir = Path::new("/repo");
1648 let rec = RecordingRunner::replying(Reply::ok(""));
1649 let jj = Jj::with_runner(&rec);
1650
1651 jj.bookmark_move(dir, "main", "@", true).await.unwrap();
1652 jj.at(dir).bookmark_move("main", "@", true).await.unwrap();
1653 jj.describe_rev(dir, "feat", "msg").await.unwrap();
1654 jj.at(dir).describe_rev("feat", "msg").await.unwrap();
1655 jj.description(dir, "@-").await.unwrap();
1656 jj.at(dir).description("@-").await.unwrap();
1657 jj.duplicate(dir, "@-").await.unwrap();
1659 jj.at(dir).duplicate("@-").await.unwrap();
1660
1661 let calls = rec.calls();
1662 assert_eq!(calls[0].args_str(), calls[1].args_str());
1663 assert_eq!(calls[2].args_str(), calls[3].args_str());
1664 assert_eq!(calls[4].args_str(), calls[5].args_str());
1665 assert_eq!(calls[6].args_str(), calls[7].args_str());
1666 assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
1667 }
1668
1669 #[tokio::test]
1670 async fn workspace_list_parses_template_rows() {
1671 let jj = Jj::with_runner(ScriptedRunner::new().on(
1672 ["workspace", "list"],
1673 Reply::ok("default\te2aa3420\tmain\nws1\t12345678\t\n"),
1674 ));
1675 let got = jj.workspace_list(Path::new(".")).await.expect("list");
1676 assert_eq!(got.len(), 2);
1677 assert_eq!(got[0].name, "default");
1678 assert_eq!(got[0].bookmarks, vec!["main".to_string()]);
1679 assert!(got[1].bookmarks.is_empty());
1680 }
1681
1682 #[tokio::test]
1687 async fn workspace_roots_batches_per_name_and_maps_errors() {
1688 let rec = RecordingRunner::new(
1689 ScriptedRunner::new()
1690 .on(
1691 ["workspace", "root", "--name", "default"],
1692 Reply::ok("/repo\n"),
1693 )
1694 .on(
1695 ["workspace", "root", "--name", "ws1"],
1696 Reply::ok("/repo/ws1\n"),
1697 )
1698 .on(
1699 ["workspace", "root", "--name", "gone"],
1700 Reply::fail(1, "Error: No such workspace"),
1701 ),
1702 );
1703 let jj = Jj::with_runner(&rec);
1704 let roots = jj
1705 .workspace_roots(
1706 Path::new("/repo"),
1707 &["default".into(), "gone".into(), "ws1".into()],
1708 )
1709 .await;
1710 assert_eq!(roots.len(), 3);
1712 assert_eq!(roots[0].as_deref().unwrap(), Path::new("/repo"));
1713 assert!(roots[1].is_err(), "a non-zero `workspace root` is Err");
1714 assert_eq!(roots[2].as_deref().unwrap(), Path::new("/repo/ws1"));
1715 let calls = rec.calls();
1717 assert_eq!(calls.len(), 3);
1718 assert!(
1719 calls
1720 .iter()
1721 .all(|c| c.args_str()[..2] == ["workspace", "root"])
1722 );
1723 }
1724
1725 #[tokio::test]
1727 async fn workspace_add_builds_name_base_path() {
1728 let rec = RecordingRunner::replying(Reply::ok(""));
1729 let jj = Jj::with_runner(&rec);
1730 jj.workspace_add(Path::new("/repo"), WorkspaceAdd::new("ws1", "main", "/wt"))
1731 .await
1732 .expect("workspace add");
1733 assert_eq!(
1734 rec.only_call().args_str(),
1735 [
1736 "workspace",
1737 "add",
1738 "--name",
1739 "ws1",
1740 "-r",
1741 "main",
1742 "/wt",
1743 "--color",
1744 "never"
1745 ]
1746 );
1747 }
1748
1749 #[tokio::test]
1751 async fn workspace_add_with_sparse_mode() {
1752 let rec = RecordingRunner::replying(Reply::ok(""));
1753 let jj = Jj::with_runner(&rec);
1754 jj.workspace_add(
1755 Path::new("/repo"),
1756 WorkspaceAdd::new("ws1", "main", "/wt").sparse(SparseMode::Empty),
1757 )
1758 .await
1759 .expect("workspace add");
1760 assert_eq!(
1761 rec.only_call().args_str(),
1762 [
1763 "workspace",
1764 "add",
1765 "--name",
1766 "ws1",
1767 "-r",
1768 "main",
1769 "--sparse-patterns",
1770 "empty",
1771 "/wt",
1772 "--color",
1773 "never"
1774 ]
1775 );
1776 }
1777
1778 #[test]
1779 fn fileset_quotes_metacharacters() {
1780 assert_eq!(
1781 JjFileset::path("src/a(b).rs").as_str(),
1782 "file:\"src/a(b).rs\""
1783 );
1784 assert_eq!(JjFileset::path("src\\a.rs").as_str(), "file:\"src/a.rs\"");
1787 assert_eq!(JjFileset::path("a\"b").as_str(), "file:\"a\\\"b\"");
1789 }
1790
1791 #[tokio::test]
1792 async fn commit_paths_builds_filesets() {
1793 let rec = RecordingRunner::replying(Reply::ok(""));
1794 let jj = Jj::with_runner(&rec);
1795 jj.commit_paths(
1796 Path::new("."),
1797 &[JjFileset::path("x|y.rs"), JjFileset::path("z.rs")],
1798 "msg",
1799 )
1800 .await
1801 .expect("commit_paths");
1802 assert_eq!(
1803 rec.only_call().args_str(),
1804 [
1805 "commit",
1806 "-m",
1807 "msg",
1808 "file:\"x|y.rs\"",
1809 "file:\"z.rs\"",
1810 "--color",
1811 "never"
1812 ]
1813 );
1814 }
1815
1816 #[tokio::test]
1817 async fn squash_paths_builds_from_into_filesets() {
1818 let rec = RecordingRunner::replying(Reply::ok(""));
1819 let jj = Jj::with_runner(&rec);
1820 jj.squash_paths(
1821 Path::new("."),
1822 SquashPaths::new("@", "feat").filesets([JjFileset::path("a.rs")]),
1823 )
1824 .await
1825 .expect("squash_paths");
1826 assert_eq!(
1827 rec.only_call().args_str(),
1828 [
1829 "squash",
1830 "--from",
1831 "@",
1832 "--into",
1833 "feat",
1834 "file:\"a.rs\"",
1835 "--color",
1836 "never"
1837 ]
1838 );
1839 }
1840
1841 #[tokio::test]
1842 async fn squash_paths_keeps_destination_message() {
1843 let rec = RecordingRunner::replying(Reply::ok(""));
1844 let jj = Jj::with_runner(&rec);
1845 jj.squash_paths(
1846 Path::new("."),
1847 SquashPaths::new("@", "feat")
1848 .filesets([JjFileset::path("a.rs")])
1849 .use_destination_message(),
1850 )
1851 .await
1852 .expect("squash_paths");
1853 assert_eq!(
1854 rec.only_call().args_str(),
1855 [
1856 "squash",
1857 "--from",
1858 "@",
1859 "--into",
1860 "feat",
1861 "--use-destination-message",
1862 "file:\"a.rs\"",
1863 "--color",
1864 "never"
1865 ]
1866 );
1867 }
1868
1869 #[tokio::test]
1870 async fn jj_new_revision_scoped_ops_build_args() {
1871 let rec = RecordingRunner::replying(Reply::ok(""));
1872 let jj = Jj::with_runner(&rec);
1873 jj.describe_rev(Path::new("."), "feat", "msg")
1874 .await
1875 .unwrap();
1876 assert_eq!(
1877 rec.only_call().args_str(),
1878 ["describe", "-r", "feat", "-m", "msg", "--color", "never"]
1879 );
1880
1881 let rec = RecordingRunner::replying(Reply::ok(""));
1882 let jj = Jj::with_runner(&rec);
1883 jj.rebase_branch(Path::new("."), "feat", "main")
1884 .await
1885 .unwrap();
1886 assert_eq!(
1887 rec.only_call().args_str(),
1888 ["rebase", "-b", "feat", "-d", "main", "--color", "never"]
1889 );
1890
1891 let rec = RecordingRunner::replying(Reply::ok(""));
1892 let jj = Jj::with_runner(&rec);
1893 jj.bookmark_track(Path::new("."), "feat", "origin")
1894 .await
1895 .unwrap();
1896 assert_eq!(
1897 rec.only_call().args_str(),
1898 ["bookmark", "track", "feat@origin", "--color", "never"]
1899 );
1900 }
1901
1902 #[tokio::test]
1903 async fn bookmarks_all_parses_local_and_remote() {
1904 let jj = Jj::with_runner(ScriptedRunner::new().on(
1905 ["bookmark", "list"],
1906 Reply::ok("main\t\t0\tabc123\nmain\torigin\t1\tabc123\n"),
1907 ));
1908 let refs = jj.bookmarks_all(Path::new(".")).await.unwrap();
1909 assert_eq!(refs.len(), 2);
1910 assert_eq!(refs[0].name, "main");
1911 assert!(refs[0].remote.is_none() && !refs[0].tracked);
1912 assert_eq!(refs[1].remote.as_deref(), Some("origin"));
1913 assert!(refs[1].tracked);
1914 }
1915
1916 #[tokio::test]
1917 async fn sparse_set_clears_then_adds() {
1918 let rec = RecordingRunner::replying(Reply::ok(""));
1919 let jj = Jj::with_runner(&rec);
1920 jj.sparse_set(Path::new("."), &["README.md".into(), "lib".into()])
1921 .await
1922 .expect("sparse_set");
1923 assert_eq!(
1924 rec.only_call().args_str(),
1925 [
1926 "sparse",
1927 "set",
1928 "--clear",
1929 "--add",
1930 "README.md",
1931 "--add",
1932 "lib",
1933 "--color",
1934 "never"
1935 ]
1936 );
1937 }
1938
1939 #[tokio::test]
1941 async fn status_parses_diff_summary() {
1942 let jj = Jj::with_runner(ScriptedRunner::new().on(
1943 ["diff", "-r", "@", "--summary"],
1944 Reply::ok("M a.rs\nA b.rs\n"),
1945 ));
1946 let entries = jj.status(Path::new(".")).await.expect("status");
1947 assert_eq!(entries.len(), 2);
1948 assert_eq!(entries[0].status, 'M');
1949 assert_eq!(entries[1].path, "b.rs");
1950 }
1951
1952 #[tokio::test]
1953 async fn status_text_is_raw_jj_status() {
1954 let jj = Jj::with_runner(
1955 ScriptedRunner::new().on(["status"], Reply::ok("Working copy changes:\n")),
1956 );
1957 assert!(
1958 jj.status_text(Path::new("."))
1959 .await
1960 .expect("status_text")
1961 .contains("Working copy changes")
1962 );
1963 }
1964
1965 #[tokio::test]
1966 async fn run_args_forwards_str_slices() {
1967 let jj = Jj::with_runner(ScriptedRunner::new().on(["root"], Reply::ok("/r\n")));
1968 assert_eq!(jj.run_args(&["root"]).await.unwrap(), "/r");
1969 }
1970
1971 #[tokio::test]
1972 async fn bookmark_move_appends_allow_backwards() {
1973 let rec = RecordingRunner::replying(Reply::ok(""));
1974 let jj = Jj::with_runner(&rec);
1975 jj.bookmark_move(Path::new("/r"), "main", "@", true)
1976 .await
1977 .unwrap();
1978 assert_eq!(
1979 rec.only_call().args_str(),
1980 [
1981 "bookmark",
1982 "move",
1983 "main",
1984 "--to",
1985 "@",
1986 "--allow-backwards",
1987 "--color",
1988 "never"
1989 ]
1990 );
1991 }
1992
1993 #[tokio::test]
1994 async fn new_merge_appends_parents() {
1995 let rec = RecordingRunner::replying(Reply::ok(""));
1996 let jj = Jj::with_runner(&rec);
1997 jj.new_merge(Path::new("/r"), "m", vec!["p1".into(), "p2".into()])
1998 .await
1999 .unwrap();
2000 assert_eq!(
2001 rec.only_call().args_str(),
2002 ["new", "-m", "m", "p1", "p2", "--color", "never"]
2003 );
2004 }
2005
2006 #[tokio::test]
2007 async fn is_conflicted_reads_template_flag() {
2008 let yes = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("1\n")));
2009 assert!(yes.is_conflicted(Path::new("."), "@").await.unwrap());
2010 let no = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("0\n")));
2011 assert!(!no.is_conflicted(Path::new("."), "@").await.unwrap());
2012 }
2013
2014 #[tokio::test]
2015 async fn commit_count_counts_template_lines() {
2016 let jj = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("a\nb\nc\n")));
2017 assert_eq!(jj.commit_count(Path::new("."), "::@").await.unwrap(), 3);
2018 }
2019
2020 #[tokio::test]
2021 async fn reachable_bookmarks_queries_heads_revset() {
2022 let rec = RecordingRunner::replying(Reply::ok("main\tabc123\n"));
2023 let jj = Jj::with_runner(&rec);
2024 let got = jj.reachable_bookmarks(Path::new(".")).await.unwrap();
2025 assert_eq!(got.len(), 1);
2026 assert_eq!(got[0].name, "main");
2027 let args = rec.only_call().args_str();
2028 assert_eq!(
2029 &args[..4],
2030 &["log", "-r", "heads(::@ & bookmarks())", "--no-graph"]
2031 );
2032 }
2033
2034 #[tokio::test]
2035 async fn resolve_list_distinguishes_no_conflicts_from_errors() {
2036 let none = Jj::with_runner(ScriptedRunner::new().on(
2038 ["resolve"],
2039 Reply::fail(2, "Error: No conflicts found at this revision"),
2040 ));
2041 assert!(
2042 none.resolve_list(Path::new("."), "@")
2043 .await
2044 .unwrap()
2045 .is_empty()
2046 );
2047 let bad = Jj::with_runner(ScriptedRunner::new().on(
2049 ["resolve"],
2050 Reply::fail(1, "Error: Revision `bogus` doesn't exist"),
2051 ));
2052 assert!(bad.resolve_list(Path::new("."), "bogus").await.is_err());
2053 let some = Jj::with_runner(
2055 ScriptedRunner::new().on(["resolve"], Reply::ok("a.rs 2-sided conflict\n")),
2056 );
2057 assert_eq!(
2058 some.resolve_list(Path::new("."), "@").await.unwrap(),
2059 ["a.rs"]
2060 );
2061 }
2062
2063 #[tokio::test]
2064 async fn current_bookmark_takes_first_or_none() {
2065 let some = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("main\n")));
2066 assert_eq!(
2067 some.current_bookmark(Path::new("."))
2068 .await
2069 .unwrap()
2070 .as_deref(),
2071 Some("main")
2072 );
2073 let none = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("\n")));
2074 assert!(
2075 none.current_bookmark(Path::new("."))
2076 .await
2077 .unwrap()
2078 .is_none()
2079 );
2080 }
2081
2082 #[tokio::test]
2084 async fn current_change_parses_scripted_output() {
2085 let jj = Jj::with_runner(
2086 ScriptedRunner::new().on(["log"], Reply::ok("kztuxlro\t38e00654\tfalse\thello jj\n")),
2087 );
2088 let change = jj
2089 .current_change(Path::new("."))
2090 .await
2091 .expect("current_change");
2092 assert_eq!(change.change_id, "kztuxlro");
2093 assert!(!change.empty);
2094 assert_eq!(change.description, "hello jj");
2095 }
2096
2097 #[tokio::test]
2101 async fn git_push_appends_bookmark_flag() {
2102 let jj = Jj::with_runner(
2103 ScriptedRunner::new().on(["git", "push", "-b", "feature"], Reply::ok("")),
2104 );
2105 jj.git_push(Path::new("."), Some("feature".to_string()))
2106 .await
2107 .expect("should build `git push -b feature`");
2108 }
2109
2110 #[tokio::test]
2112 async fn git_push_without_bookmark_is_bare() {
2113 let jj = Jj::with_runner(ScriptedRunner::new().on(["git", "push"], Reply::ok("")));
2114 jj.git_push(Path::new("."), None).await.expect("bare push");
2115 }
2116
2117 #[tokio::test]
2119 async fn git_fetch_retries_transient_failures() {
2120 let rec = RecordingRunner::replying(Reply::fail(1, "Error: Could not resolve host: x"));
2121 let jj = Jj::with_runner(&rec);
2122 assert!(jj.git_fetch(Path::new(".")).await.is_err());
2123 assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
2124 }
2125
2126 #[tokio::test]
2128 async fn git_fetch_from_builds_args_and_retries() {
2129 let rec = RecordingRunner::replying(Reply::ok(""));
2130 let jj = Jj::with_runner(&rec);
2131 jj.git_fetch_from(Path::new("."), "upstream")
2132 .await
2133 .expect("git_fetch_from");
2134 assert_eq!(
2135 rec.only_call().args_str(),
2136 ["git", "fetch", "--remote", "upstream", "--color", "never"]
2137 );
2138
2139 let failing = RecordingRunner::replying(Reply::fail(1, "Error: Connection timed out"));
2140 let jj = Jj::with_runner(&failing);
2141 assert!(jj.git_fetch_from(Path::new("."), "upstream").await.is_err());
2142 assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
2143 }
2144
2145 #[tokio::test]
2148 async fn transaction_restores_op_head_on_error() {
2149 let rec = RecordingRunner::new(
2150 ScriptedRunner::new()
2151 .on(["op", "log"], Reply::ok("abc123\n"))
2152 .on(["op", "restore"], Reply::ok(""))
2153 .on(["describe"], Reply::fail(1, "boom")),
2154 );
2155 let jj = Jj::with_runner(&rec);
2156 let res = jj
2157 .transaction(
2158 Path::new("/r"),
2159 |tx| async move { tx.describe("wip").await },
2160 )
2161 .await;
2162 let err = res.expect_err("closure error must surface");
2163 assert!(matches!(err, Error::Exit { .. }));
2164 let calls = rec.calls();
2165 assert_eq!(calls.len(), 3, "op head, mutation, restore: {calls:?}");
2166 assert_eq!(calls[0].args_str()[..2], ["op", "log"]);
2167 assert_eq!(calls[1].args_str()[0], "describe");
2168 assert_eq!(calls[2].args_str()[..3], ["op", "restore", "abc123"]);
2169 }
2170
2171 #[tokio::test]
2173 async fn transaction_keeps_changes_on_success() {
2174 let rec = RecordingRunner::new(
2175 ScriptedRunner::new()
2176 .on(["op", "log"], Reply::ok("abc123\n"))
2177 .on(["describe"], Reply::ok("")),
2178 );
2179 let jj = Jj::with_runner(&rec);
2180 jj.transaction(
2181 Path::new("/r"),
2182 |tx| async move { tx.describe("wip").await },
2183 )
2184 .await
2185 .expect("transaction");
2186 let calls = rec.calls();
2187 assert_eq!(calls.len(), 2);
2188 assert!(
2189 calls.iter().all(|c| c.args_str()[..2] != ["op", "restore"]),
2190 "no restore on success: {calls:?}"
2191 );
2192 }
2193
2194 #[tokio::test]
2196 async fn bound_view_forwards_transaction() {
2197 let dir = Path::new("/repo");
2198 let rec = RecordingRunner::new(
2199 ScriptedRunner::new()
2200 .on(["op", "log"], Reply::ok("op9\n"))
2201 .on(["new"], Reply::ok("")),
2202 );
2203 let jj = Jj::with_runner(&rec);
2204 jj.at(dir)
2205 .transaction(|tx| async move { tx.new_change("x").await })
2206 .await
2207 .expect("transaction");
2208 assert_eq!(rec.calls()[1].cwd.as_deref(), Some(dir.as_os_str()));
2209 }
2210
2211 #[tokio::test]
2214 async fn flag_like_positionals_are_rejected_before_spawning() {
2215 let rec = RecordingRunner::replying(Reply::ok(""));
2216 let jj = Jj::with_runner(&rec);
2217 let dir = Path::new("/r");
2218
2219 assert!(jj.bookmark_create(dir, "-evil", "@").await.is_err());
2220 assert!(jj.bookmark_rename(dir, "ok", "-bad").await.is_err());
2221 assert!(jj.bookmark_delete(dir, "--all").await.is_err());
2222 assert!(jj.bookmark_move(dir, "-evil", "@", false).await.is_err());
2223 assert!(jj.edit(dir, "-evil").await.is_err());
2224 assert!(jj.duplicate(dir, "-r").await.is_err());
2225 assert!(jj.abandon(dir, "-evil").await.is_err());
2226 assert!(
2228 jj.bookmark_track(dir, "--config=x", "origin")
2229 .await
2230 .is_err(),
2231 "name leads the {{name}}@{{remote}} token"
2232 );
2233 assert!(jj.bookmark_set(dir, "-evil", "@").await.is_err());
2234 assert!(jj.op_restore(dir, "--help").await.is_err());
2235 assert!(jj.workspace_forget(dir, "-evil").await.is_err());
2236 assert!(
2237 jj.new_merge(dir, "m", vec!["@".into(), "--ignore-working-copy".into()])
2238 .await
2239 .is_err(),
2240 "a flag-shaped parent is refused"
2241 );
2242 assert!(jj.git_clone("-evil", dir, false).await.is_err());
2243 assert!(jj.edit(dir, "").await.is_err(), "empty refused too");
2244 assert!(
2245 rec.calls().is_empty(),
2246 "nothing may spawn: {:?}",
2247 rec.calls()
2248 );
2249
2250 jj.edit(dir, "abc123").await.expect("edit");
2252 assert_eq!(
2253 rec.only_call().args_str(),
2254 ["edit", "abc123", "--color", "never"]
2255 );
2256 }
2257
2258 #[test]
2259 fn revset_expr_validates() {
2260 assert!(RevsetExpr::new("heads(::@ & bookmarks())").is_ok());
2261 assert_eq!(RevsetExpr::new("@-").unwrap().as_str(), "@-");
2262 assert!(RevsetExpr::new("-evil").is_err());
2263 assert!(RevsetExpr::new("").is_err());
2264 }
2265
2266 #[tokio::test]
2269 async fn capabilities_parse_and_gate_versions() {
2270 let jj = Jj::with_runner(ScriptedRunner::new().on(["--version"], Reply::ok("jj 0.38.0\n")));
2271 let caps = jj.capabilities().await.expect("capabilities");
2272 assert!(caps.is_supported());
2273 caps.ensure_supported().expect("supported");
2274
2275 let dev = Jj::with_runner(
2277 ScriptedRunner::new().on(["--version"], Reply::ok("jj 0.39.0-dev+abc123\n")),
2278 );
2279 assert!(dev.capabilities().await.unwrap().is_supported());
2280
2281 let old =
2282 Jj::with_runner(ScriptedRunner::new().on(["--version"], Reply::ok("jj 0.35.0\n")));
2283 let caps = old.capabilities().await.expect("capabilities");
2284 assert!(!caps.is_supported());
2285 let err = caps.ensure_supported().expect_err("unsupported");
2286 let Error::Spawn { source, .. } = &err else {
2288 panic!("expected Spawn, got {err:?}");
2289 };
2290 let message = source.to_string();
2291 assert!(message.contains("0.38.0"), "names the floor: {message}");
2292 assert!(
2293 message.contains("0.35.0"),
2294 "names the found version: {message}"
2295 );
2296
2297 let garbage = Jj::with_runner(ScriptedRunner::new().on(["--version"], Reply::ok("nope")));
2298 assert!(matches!(
2299 garbage.capabilities().await.unwrap_err(),
2300 Error::Parse { .. }
2301 ));
2302 }
2303
2304 #[tokio::test]
2307 async fn git_clone_builds_dirless_args() {
2308 let rec = RecordingRunner::replying(Reply::ok(""));
2309 let jj = Jj::with_runner(&rec);
2310 jj.git_clone("https://x/r.git", Path::new("/dest"), true)
2311 .await
2312 .expect("clone");
2313 let call = rec.only_call();
2314 assert_eq!(
2315 call.args_str(),
2316 [
2317 "git",
2318 "clone",
2319 "https://x/r.git",
2320 "/dest",
2321 "--colocate",
2322 "--color",
2323 "never"
2324 ]
2325 );
2326 assert_eq!(call.cwd, None, "clone runs without a working directory");
2327
2328 let plain = RecordingRunner::replying(Reply::ok(""));
2329 let jj = Jj::with_runner(&plain);
2330 jj.git_clone("u", Path::new("/d"), false).await.unwrap();
2331 let call = plain.only_call();
2332 assert!(call.has_flag("--no-colocate"), "explicit either way");
2333 assert!(!call.has_flag("--colocate"));
2334 }
2335
2336 #[tokio::test]
2337 async fn absorb_and_split_build_args() {
2338 let rec = RecordingRunner::replying(Reply::ok(""));
2339 let jj = Jj::with_runner(&rec);
2340 jj.absorb(Path::new("/r"), None, &[]).await.unwrap();
2341 jj.absorb(
2342 Path::new("/r"),
2343 Some("@-".into()),
2344 &[JjFileset::path("src/a.rs")],
2345 )
2346 .await
2347 .unwrap();
2348 jj.split_paths(Path::new("/r"), &[JjFileset::path("b.rs")], "split out b")
2349 .await
2350 .unwrap();
2351 jj.duplicate(Path::new("/r"), "@-").await.unwrap();
2352 let calls = rec.calls();
2353 assert_eq!(calls[0].args_str(), ["absorb", "--color", "never"]);
2354 assert_eq!(
2355 calls[1].args_str(),
2356 [
2357 "absorb",
2358 "--from",
2359 "@-",
2360 "file:\"src/a.rs\"",
2361 "--color",
2362 "never"
2363 ]
2364 );
2365 assert_eq!(
2366 calls[2].args_str(),
2367 [
2368 "split",
2369 "-m",
2370 "split out b",
2371 "file:\"b.rs\"",
2372 "--color",
2373 "never"
2374 ]
2375 );
2376 assert_eq!(calls[3].args_str(), ["duplicate", "@-", "--color", "never"]);
2377 }
2378
2379 #[tokio::test]
2382 async fn split_paths_refuses_empty_filesets_without_spawning() {
2383 let rec = RecordingRunner::replying(Reply::ok(""));
2384 let jj = Jj::with_runner(&rec);
2385 let err = jj
2386 .split_paths(Path::new("/r"), &[], "msg")
2387 .await
2388 .expect_err("empty filesets must be refused");
2389 assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
2390 assert!(rec.calls().is_empty(), "nothing may spawn");
2391 }
2392
2393 #[tokio::test]
2394 async fn op_log_parses_template_rows() {
2395 let rec = RecordingRunner::new(ScriptedRunner::new().on(
2396 ["op", "log"],
2397 Reply::ok("abc\tu@h\t2026-06-05T10:00:00+0200\tnew empty commit\n"),
2398 ));
2399 let jj = Jj::with_runner(&rec);
2400 let ops = jj.op_log(Path::new("."), 5).await.expect("op_log");
2401 assert_eq!(ops.len(), 1);
2402 assert_eq!(ops[0].id, "abc");
2403 assert_eq!(ops[0].description, "new empty commit");
2404 let args = rec.only_call().args_str();
2405 assert_eq!(&args[..5], &["op", "log", "--no-graph", "--limit", "5"]);
2406 }
2407
2408 #[tokio::test]
2411 async fn evolog_uses_commit_context_template() {
2412 let rec = RecordingRunner::new(
2413 ScriptedRunner::new().on(["evolog"], Reply::ok("kz\t38\tfalse\twip\n")),
2414 );
2415 let jj = Jj::with_runner(&rec);
2416 let rows = jj.evolog(Path::new("."), "@", 10).await.expect("evolog");
2417 assert_eq!(rows.len(), 1);
2418 assert_eq!(rows[0].description, "wip");
2419 let args = rec.only_call().args_str();
2420 assert_eq!(
2421 &args[..6],
2422 &["evolog", "-r", "@", "--no-graph", "--limit", "10"]
2423 );
2424 let template = &args[7];
2425 assert!(
2426 template.contains("commit.change_id()"),
2427 "commit-context form required, got {template}"
2428 );
2429 }
2430
2431 #[tokio::test]
2432 async fn file_annotate_and_show_build_args() {
2433 let rec = RecordingRunner::new(
2434 ScriptedRunner::new()
2435 .on(
2436 ["file", "annotate"],
2437 Reply::ok("kz\tline one\nkz\tline two"),
2438 )
2439 .on(["file", "show"], Reply::ok("content\n")),
2440 );
2441 let jj = Jj::with_runner(&rec);
2442 let lines = jj
2443 .file_annotate(Path::new("."), "src/a.rs", Some("@-".into()))
2444 .await
2445 .expect("annotate");
2446 assert_eq!(lines.len(), 2);
2447 assert_eq!(lines[0].change_id, "kz");
2448 assert_eq!(lines[1].line, 2);
2449 assert_eq!(
2450 jj.file_show(Path::new("."), "@-", "src/a.rs")
2451 .await
2452 .unwrap(),
2453 "content"
2454 );
2455 let calls = rec.calls();
2456 assert_eq!(
2459 calls[0].args_str(),
2460 [
2461 "file",
2462 "annotate",
2463 "-r",
2464 "@-",
2465 "-T",
2466 parse::ANNOTATE_TEMPLATE,
2467 "--color",
2468 "never",
2469 "--",
2470 "src/a.rs"
2471 ]
2472 );
2473 assert_eq!(
2477 calls[1].args_str(),
2478 [
2479 "file",
2480 "show",
2481 "-r",
2482 "@-",
2483 "file:\"src/a.rs\"",
2484 "--color",
2485 "never"
2486 ]
2487 );
2488 }
2489
2490 #[tokio::test]
2492 async fn description_builds_single_commit_template_query() {
2493 let rec = RecordingRunner::replying(Reply::ok("feat: parser\n\nbody\n"));
2494 let jj = Jj::with_runner(&rec);
2495 let text = jj
2496 .description(Path::new("."), "abc123")
2497 .await
2498 .expect("description");
2499 assert_eq!(text, "feat: parser\n\nbody");
2500 assert_eq!(
2501 rec.only_call().args_str(),
2502 [
2503 "log",
2504 "-r",
2505 "abc123",
2506 "--no-graph",
2507 "--limit",
2508 "1",
2509 "-T",
2510 "description",
2511 "--color",
2512 "never"
2513 ]
2514 );
2515 }
2516
2517 #[tokio::test]
2519 async fn diff_text_builds_working_copy_args() {
2520 let rec = RecordingRunner::replying(Reply::ok(""));
2521 let jj = Jj::with_runner(&rec);
2522 jj.diff_text(Path::new("."), DiffSpec::WorkingTree)
2523 .await
2524 .expect("diff_text");
2525 assert_eq!(
2526 rec.only_call().args_str(),
2527 ["diff", "-r", "@", "--git", "--color", "never"]
2528 );
2529 }
2530
2531 #[tokio::test]
2534 async fn commands_force_color_off() {
2535 let rec = RecordingRunner::replying(Reply::ok("x\n"));
2536 let jj = Jj::with_runner(&rec);
2537 jj.status_text(Path::new(".")).await.expect("status_text");
2538 let args = rec.only_call().args_str();
2539 let pos = args.iter().position(|a| a == "--color");
2540 assert_eq!(
2541 pos.map(|p| args.get(p + 1).map(String::as_str)),
2542 Some(Some("never"))
2543 );
2544 }
2545
2546 #[tokio::test]
2549 async fn diff_parses_scripted_output() {
2550 let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
2551 let jj = Jj::with_runner(ScriptedRunner::new().on(["diff"], Reply::ok(out)));
2552 let files = jj
2553 .diff(Path::new("."), DiffSpec::Rev("@-".into()))
2554 .await
2555 .expect("diff");
2556 assert_eq!(files.len(), 1);
2557 assert_eq!(files[0].path, "m");
2558 assert_eq!(files[0].change, ChangeKind::Modified);
2559 }
2560
2561 #[cfg(feature = "mock")]
2562 #[tokio::test]
2563 async fn consumer_mocks_the_interface() {
2564 let mut mock = MockJjApi::new();
2565 mock.expect_describe().returning(|_, _| Ok(()));
2566 assert!(mock.describe(Path::new("."), "msg").await.is_ok());
2567 }
2568}
2569
2570#[doc = include_str!("../docs/jj.md")]
2572#[allow(rustdoc::broken_intra_doc_links)]
2573pub mod guide {}