1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3use std::path::{Path, PathBuf};
165use std::sync::Arc;
166
167use processkit::{JobRunner, ProcessRunner};
168use vcs_git::{Git, GitAt};
169use vcs_jj::{Jj, JjAt};
170
171mod dto;
172mod error;
173mod git_backend;
174mod jj_backend;
175
176pub use dto::{
177 BackendKind, ChangeKind, CreateOutcome, DiffStat, FileChange, MergeProbe, OperationState,
178 RepoSnapshot, WorktreeInfo,
179};
180pub use error::{Error, Result};
181
182pub use vcs_git;
189pub use vcs_jj;
190#[cfg(feature = "cancellation")]
194#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
195pub use processkit::CancellationToken;
196
197#[derive(Debug, Clone, PartialEq, Eq)]
200#[non_exhaustive]
201pub struct Located {
202 pub kind: BackendKind,
204 pub root: PathBuf,
206}
207
208pub fn detect(start: &Path) -> Option<Located> {
217 let mut current = Some(start);
218 while let Some(dir) = current {
219 if dir.join(".jj").is_dir() {
220 return Some(Located {
221 kind: BackendKind::Jj,
222 root: dir.to_path_buf(),
223 });
224 }
225 if dir.join(".git").exists() {
226 return Some(Located {
227 kind: BackendKind::Git,
228 root: dir.to_path_buf(),
229 });
230 }
231 current = dir.parent();
232 }
233 None
234}
235
236enum Backend<R: ProcessRunner> {
239 Git(Arc<Git<R>>),
240 Jj(Arc<Jj<R>>),
241}
242
243impl<R: ProcessRunner> Backend<R> {
244 fn shared(&self) -> Self {
245 match self {
246 Backend::Git(g) => Backend::Git(Arc::clone(g)),
247 Backend::Jj(j) => Backend::Jj(Arc::clone(j)),
248 }
249 }
250}
251
252pub struct Repo<R: ProcessRunner = JobRunner> {
256 root: PathBuf,
257 cwd: PathBuf,
258 backend: Backend<R>,
259}
260
261impl Repo<JobRunner> {
262 pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
266 let dir = std::path::absolute(dir.as_ref())?;
270 let located = detect(&dir).ok_or_else(|| Error::NotARepository(dir.clone()))?;
271 let backend = match located.kind {
272 BackendKind::Git => Backend::Git(Arc::new(Git::new())),
273 BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
274 };
275 Ok(Repo {
276 root: located.root,
277 cwd: dir,
278 backend,
279 })
280 }
281}
282
283impl<R: ProcessRunner> Repo<R> {
284 pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self {
287 Repo {
288 root: root.into(),
289 cwd: cwd.into(),
290 backend: Backend::Git(Arc::new(client)),
291 }
292 }
293
294 pub fn from_jj(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>) -> Self {
296 Repo {
297 root: root.into(),
298 cwd: cwd.into(),
299 backend: Backend::Jj(Arc::new(client)),
300 }
301 }
302
303 pub fn kind(&self) -> BackendKind {
305 match &self.backend {
306 Backend::Git(_) => BackendKind::Git,
307 Backend::Jj(_) => BackendKind::Jj,
308 }
309 }
310
311 pub fn root(&self) -> &Path {
313 &self.root
314 }
315
316 pub fn cwd(&self) -> &Path {
318 &self.cwd
319 }
320
321 pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
323 Repo {
324 root: self.root.clone(),
325 cwd: dir.into(),
326 backend: self.backend.shared(),
327 }
328 }
329
330 pub fn git(&self) -> Option<&Git<R>> {
333 match &self.backend {
334 Backend::Git(g) => Some(g.as_ref()),
335 Backend::Jj(_) => None,
336 }
337 }
338
339 pub fn jj(&self) -> Option<&Jj<R>> {
341 match &self.backend {
342 Backend::Jj(j) => Some(j.as_ref()),
343 Backend::Git(_) => None,
344 }
345 }
346
347 pub fn git_at(&self) -> Option<GitAt<'_, R>> {
363 match &self.backend {
364 Backend::Git(g) => Some(g.at(&self.cwd)),
365 Backend::Jj(_) => None,
366 }
367 }
368
369 pub fn jj_at(&self) -> Option<JjAt<'_, R>> {
374 match &self.backend {
375 Backend::Jj(j) => Some(j.at(&self.cwd)),
376 Backend::Git(_) => None,
377 }
378 }
379
380 pub async fn current_branch(&self) -> Result<Option<String>> {
383 match &self.backend {
384 Backend::Git(g) => git_backend::current_branch(g, &self.cwd).await,
385 Backend::Jj(j) => jj_backend::current_branch(j, &self.cwd).await,
386 }
387 }
388
389 pub async fn trunk(&self) -> Result<Option<String>> {
393 let native = match &self.backend {
394 Backend::Git(g) => git_backend::trunk(g, &self.cwd).await?,
395 Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await?,
396 };
397 if native.is_some() {
398 return Ok(native);
399 }
400 for candidate in ["main", "master"] {
401 if self.branch_exists(candidate).await? {
402 return Ok(Some(candidate.to_string()));
403 }
404 }
405 Ok(None)
406 }
407
408 pub async fn local_branches(&self) -> Result<Vec<String>> {
410 match &self.backend {
411 Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
412 Backend::Jj(j) => jj_backend::local_branches(j, &self.cwd).await,
413 }
414 }
415
416 pub async fn branch_exists(&self, name: &str) -> Result<bool> {
418 match &self.backend {
419 Backend::Git(g) => git_backend::branch_exists(g, &self.cwd, name).await,
420 Backend::Jj(j) => jj_backend::branch_exists(j, &self.cwd, name).await,
421 }
422 }
423
424 pub async fn has_uncommitted_changes(&self) -> Result<bool> {
427 match &self.backend {
428 Backend::Git(g) => git_backend::has_uncommitted_changes(g, &self.cwd).await,
429 Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
430 }
431 }
432
433 pub async fn has_tracked_changes(&self) -> Result<bool> {
440 match &self.backend {
441 Backend::Git(g) => git_backend::has_tracked_changes(g, &self.cwd).await,
442 Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
443 }
444 }
445
446 pub async fn conflicted_files(&self) -> Result<Vec<String>> {
450 match &self.backend {
451 Backend::Git(g) => git_backend::conflicted_files(g, &self.cwd).await,
452 Backend::Jj(j) => jj_backend::conflicted_files(j, &self.cwd).await,
453 }
454 }
455
456 pub async fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
459 match &self.backend {
460 Backend::Git(g) => git_backend::delete_branch(g, &self.cwd, name, force).await,
461 Backend::Jj(j) => jj_backend::delete_branch(j, &self.cwd, name).await,
462 }
463 }
464
465 pub async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
467 match &self.backend {
468 Backend::Git(g) => git_backend::rename_branch(g, &self.cwd, old, new).await,
469 Backend::Jj(j) => jj_backend::rename_branch(j, &self.cwd, old, new).await,
470 }
471 }
472
473 pub async fn changed_files(&self) -> Result<Vec<FileChange>> {
475 match &self.backend {
476 Backend::Git(g) => git_backend::changed_files(g, &self.cwd).await,
477 Backend::Jj(j) => jj_backend::changed_files(j, &self.cwd).await,
478 }
479 }
480
481 pub async fn diff_stat(&self) -> Result<DiffStat> {
491 match &self.backend {
492 Backend::Git(g) => git_backend::diff_stat(g, &self.cwd).await,
493 Backend::Jj(j) => jj_backend::diff_stat(j, &self.cwd).await,
494 }
495 }
496
497 pub async fn snapshot(&self) -> Result<RepoSnapshot> {
504 match &self.backend {
505 Backend::Git(g) => git_backend::snapshot(g, &self.cwd).await,
506 Backend::Jj(j) => jj_backend::snapshot(j, &self.cwd).await,
507 }
508 }
509
510 pub async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
516 if paths.is_empty() {
517 return Err(Error::Io(std::io::Error::new(
518 std::io::ErrorKind::InvalidInput,
519 "commit_paths requires at least one path: an empty set would error \
520 on git but commit the entire working copy on jj",
521 )));
522 }
523 match &self.backend {
524 Backend::Git(g) => git_backend::commit_paths(g, &self.cwd, paths, message).await,
525 Backend::Jj(j) => jj_backend::commit_paths(j, &self.cwd, paths, message).await,
526 }
527 }
528
529 pub async fn fetch(&self) -> Result<()> {
531 match &self.backend {
532 Backend::Git(g) => git_backend::fetch(g, &self.cwd).await,
533 Backend::Jj(j) => jj_backend::fetch(j, &self.cwd).await,
534 }
535 }
536
537 pub async fn fetch_from(&self, remote: &str) -> Result<()> {
541 match &self.backend {
542 Backend::Git(g) => git_backend::fetch_from(g, &self.cwd, remote).await,
543 Backend::Jj(j) => jj_backend::fetch_from(j, &self.cwd, remote).await,
544 }
545 }
546
547 pub async fn fetch_remote_branch(&self, branch: &str) -> Result<()> {
551 match &self.backend {
552 Backend::Git(g) => git_backend::fetch_remote_branch(g, &self.cwd, branch).await,
553 Backend::Jj(j) => jj_backend::fetch_remote_branch(j, &self.cwd, branch).await,
554 }
555 }
556
557 pub async fn push(&self, branch: &str) -> Result<()> {
568 match &self.backend {
569 Backend::Git(g) => git_backend::push(g, &self.cwd, branch).await,
570 Backend::Jj(j) => jj_backend::push(j, &self.cwd, branch).await,
571 }
572 }
573
574 pub async fn checkout(&self, reference: &str) -> Result<()> {
576 match &self.backend {
577 Backend::Git(g) => git_backend::checkout(g, &self.cwd, reference).await,
578 Backend::Jj(j) => jj_backend::checkout(j, &self.cwd, reference).await,
579 }
580 }
581
582 pub async fn rebase(&self, onto: &str) -> Result<()> {
585 match &self.backend {
586 Backend::Git(g) => git_backend::rebase(g, &self.cwd, onto).await,
587 Backend::Jj(j) => jj_backend::rebase(j, &self.cwd, onto).await,
588 }
589 }
590
591 pub async fn try_merge(&self, source: &str) -> Result<MergeProbe> {
607 match &self.backend {
608 Backend::Git(g) => git_backend::try_merge(g, &self.cwd, source).await,
609 Backend::Jj(j) => jj_backend::try_merge(j, &self.cwd, source).await,
610 }
611 }
612
613 pub async fn abort_in_progress(&self) -> Result<OperationState> {
619 match &self.backend {
620 Backend::Git(g) => git_backend::abort_in_progress(g, &self.cwd).await,
621 Backend::Jj(j) => jj_backend::abort_in_progress(j, &self.cwd).await,
622 }
623 }
624
625 pub async fn continue_in_progress(&self) -> Result<OperationState> {
635 match &self.backend {
636 Backend::Git(g) => git_backend::continue_in_progress(g, &self.cwd).await,
637 Backend::Jj(j) => jj_backend::continue_in_progress(j, &self.cwd).await,
638 }
639 }
640
641 pub async fn in_progress_state(&self) -> Result<OperationState> {
650 match &self.backend {
651 Backend::Git(g) => git_backend::in_progress_state(g, &self.cwd).await,
652 Backend::Jj(j) => jj_backend::in_progress_state(j, &self.cwd).await,
653 }
654 }
655
656 pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
658 match &self.backend {
659 Backend::Git(g) => git_backend::list_worktrees(g, &self.cwd).await,
660 Backend::Jj(j) => jj_backend::list_worktrees(j, &self.cwd).await,
661 }
662 }
663
664 pub async fn create_worktree(
674 &self,
675 path: &Path,
676 branch: &str,
677 base: &str,
678 ) -> Result<CreateOutcome> {
679 match &self.backend {
680 Backend::Git(g) => git_backend::create_worktree(g, &self.cwd, path, branch, base).await,
681 Backend::Jj(j) => jj_backend::create_worktree(j, &self.cwd, path, branch, base).await,
682 }
683 }
684
685 pub async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()> {
691 match &self.backend {
692 Backend::Git(g) => git_backend::remove_worktree(g, &self.cwd, path, force).await,
693 Backend::Jj(j) => jj_backend::remove_worktree(j, &self.cwd, path, force).await,
694 }
695 }
696
697 pub fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()> {
704 match &self.backend {
705 Backend::Git(_) => {
706 vcs_git::blocking::worktree_remove(&self.cwd, path, true).map_err(Error::Io)
707 }
708 Backend::Jj(_) => {
709 match vcs_jj::blocking::workspace_name_for_path(&self.cwd, path) {
710 Some(name) => {
711 let _ = std::fs::remove_dir_all(path);
714 vcs_jj::blocking::workspace_forget(&self.cwd, &name).map_err(Error::Io)
715 }
716 None => Ok(()),
717 }
718 }
719 }
720 }
721}
722
723macro_rules! facade_trait {
745 (
746 $(#[doc = $tdoc:expr])*
747 trait $Trait:ident for $Ty:ident;
748 sync {
749 $( #[doc = $sdoc:expr] fn $sn:ident( $($sa:ident: $sat:ty),* $(,)? ) -> $sr:ty; )*
750 }
751 async {
752 $( fn $an:ident( $($aa:ident: $aat:ty),* $(,)? ) -> $ar:ty; )*
753 }
754 ) => {
755 $(#[doc = $tdoc])*
756 #[async_trait::async_trait]
757 pub trait $Trait: Send + Sync {
758 $(
759 #[doc = $sdoc]
760 fn $sn(&self, $($sa: $sat),*) -> $sr;
761 )*
762 $(
763 #[doc = concat!("See [`", stringify!($Ty), "::", stringify!($an), "`].")]
764 async fn $an(&self, $($aa: $aat),*) -> $ar;
765 )*
766 }
767
768 #[async_trait::async_trait]
772 impl<R: ProcessRunner> $Trait for $Ty<R> {
773 $(
774 fn $sn(&self, $($sa: $sat),*) -> $sr {
775 self.$sn($($sa),*)
776 }
777 )*
778 $(
779 async fn $an(&self, $($aa: $aat),*) -> $ar {
780 self.$an($($aa),*).await
781 }
782 )*
783 }
784 };
785}
786
787facade_trait! {
788 trait VcsRepo for Repo;
798 sync {
799 #[doc = "Which backend drives this handle."]
800 fn kind() -> BackendKind;
801 #[doc = "The repository root detected at open time."]
802 fn root() -> &Path;
803 #[doc = "The directory operations run against."]
804 fn cwd() -> &Path;
805 #[doc = "See [`Repo::cleanup_worktree_blocking`]."]
806 fn cleanup_worktree_blocking(path: &Path) -> Result<()>;
807 }
808 async {
809 fn current_branch() -> Result<Option<String>>;
810 fn trunk() -> Result<Option<String>>;
811 fn local_branches() -> Result<Vec<String>>;
812 fn branch_exists(name: &str) -> Result<bool>;
813 fn has_uncommitted_changes() -> Result<bool>;
814 fn has_tracked_changes() -> Result<bool>;
815 fn conflicted_files() -> Result<Vec<String>>;
816 fn delete_branch(name: &str, force: bool) -> Result<()>;
817 fn rename_branch(old: &str, new: &str) -> Result<()>;
818 fn changed_files() -> Result<Vec<FileChange>>;
819 fn diff_stat() -> Result<DiffStat>;
820 fn snapshot() -> Result<RepoSnapshot>;
821 fn commit_paths(paths: &[String], message: &str) -> Result<()>;
822 fn fetch() -> Result<()>;
823 fn fetch_from(remote: &str) -> Result<()>;
824 fn fetch_remote_branch(branch: &str) -> Result<()>;
825 fn push(branch: &str) -> Result<()>;
826 fn checkout(reference: &str) -> Result<()>;
827 fn rebase(onto: &str) -> Result<()>;
828 fn try_merge(source: &str) -> Result<MergeProbe>;
829 fn abort_in_progress() -> Result<OperationState>;
830 fn continue_in_progress() -> Result<OperationState>;
831 fn in_progress_state() -> Result<OperationState>;
832 fn list_worktrees() -> Result<Vec<WorktreeInfo>>;
833 fn create_worktree(path: &Path, branch: &str, base: &str) -> Result<CreateOutcome>;
834 fn remove_worktree(path: &Path, force: bool) -> Result<()>;
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841 use processkit::{Reply, ScriptedRunner};
842
843 struct TempDir(PathBuf);
847 impl TempDir {
848 fn new(tag: &str) -> Self {
849 use std::sync::atomic::{AtomicU64, Ordering};
853 static COUNTER: AtomicU64 = AtomicU64::new(0);
854 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
855 let dir =
856 std::env::temp_dir().join(format!("vcs-core-{tag}-{}-{n}", std::process::id()));
857 std::fs::create_dir_all(&dir).expect("create temp dir");
858 TempDir(dir)
859 }
860 fn path(&self) -> &Path {
861 &self.0
862 }
863 }
864 impl Drop for TempDir {
865 fn drop(&mut self) {
866 let _ = std::fs::remove_dir_all(&self.0);
867 }
868 }
869
870 #[test]
871 fn detect_finds_git_and_jj_and_prefers_jj() {
872 let tmp = TempDir::new("detect");
873 let root = tmp.path();
874
875 std::fs::create_dir_all(root.join(".git")).unwrap();
877 let located = detect(root).expect("git detected");
878 assert_eq!(located.kind, BackendKind::Git);
879 assert_eq!(located.root, root);
880
881 std::fs::create_dir_all(root.join(".jj")).unwrap();
883 assert_eq!(detect(root).unwrap().kind, BackendKind::Jj);
884 }
885
886 #[test]
887 fn detect_walks_up_to_ancestor() {
888 let tmp = TempDir::new("walkup");
889 let root = tmp.path();
890 std::fs::create_dir_all(root.join(".git")).unwrap();
891 let nested = root.join("a").join("b");
892 std::fs::create_dir_all(&nested).unwrap();
893 let located = detect(&nested).expect("found via ancestor walk");
894 assert_eq!(located.kind, BackendKind::Git);
895 assert_eq!(located.root, root);
896 }
897
898 #[test]
899 fn detect_returns_none_outside_repo() {
900 let tmp = TempDir::new("norepo");
901 assert!(detect(tmp.path()).is_none());
902 }
903
904 fn git_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
907 Repo::from_git("/repo", "/repo", Git::with_runner(runner))
908 }
909
910 fn jj_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
911 Repo::from_jj("/repo", "/repo", Jj::with_runner(runner))
912 }
913
914 #[tokio::test]
918 async fn git_snapshot_combines_v2_status_and_op_state() {
919 let v2 = concat!(
920 "# branch.oid abc123\0",
921 "# branch.head main\0",
922 "# branch.upstream origin/main\0",
923 "# branch.ab +2 -0\0",
924 "1 .M N... 100644 100644 100644 1 2 a.rs\0",
925 "? new.txt\0",
926 );
927 let gitdir = TempDir::new("snap-git");
929 let repo = git_repo(
930 ScriptedRunner::new()
931 .on(["status", "--porcelain=v2"], Reply::ok(v2))
932 .on(
933 ["rev-parse", "--git-dir"],
934 Reply::ok(gitdir.path().to_str().unwrap()),
935 ),
936 );
937 let s = repo.snapshot().await.unwrap();
938 assert_eq!(s.branch.as_deref(), Some("main"));
939 assert_eq!(s.upstream.as_deref(), Some("origin/main"));
940 assert_eq!((s.ahead, s.behind), (Some(2), Some(0)));
941 assert!(s.dirty);
942 assert_eq!(s.change_count, 2, "1 tracked + 1 untracked");
943 assert!(!s.conflicted);
944 assert_eq!(s.operation, OperationState::Clear);
945 }
946
947 #[tokio::test]
950 async fn jj_snapshot_from_template_with_change_count() {
951 let repo = jj_repo(
952 ScriptedRunner::new()
953 .on(["log"], Reply::ok("deadbeef\tmain\t0\t1\n")) .on(["diff"], Reply::ok("M a.rs\nA b.rs\n")), );
956 let s = repo.snapshot().await.unwrap();
957 assert_eq!(s.head.as_deref(), Some("deadbeef"));
958 assert_eq!(s.branch.as_deref(), Some("main"));
959 assert!(s.dirty);
960 assert_eq!(s.change_count, 2);
961 assert!(s.conflicted);
962 assert_eq!(s.operation, OperationState::Conflict);
963 assert_eq!(s.upstream, None);
964 assert_eq!((s.ahead, s.behind), (None, None));
965 }
966
967 #[tokio::test]
970 async fn jj_snapshot_clean_skips_change_count() {
971 let repo = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("c0ffee\t\t1\t0\n")));
972 let s = repo.snapshot().await.unwrap();
973 assert_eq!(s.head.as_deref(), Some("c0ffee"));
974 assert_eq!(s.branch, None, "no bookmark");
975 assert!(!s.dirty);
976 assert_eq!(s.change_count, 0);
977 assert!(!s.conflicted);
978 assert_eq!(s.operation, OperationState::Clear);
979 }
980
981 #[tokio::test]
987 async fn jj_list_worktrees_batches_root_lookups() {
988 let repo = jj_repo(
989 ScriptedRunner::new()
990 .on(
991 ["workspace", "list"],
992 Reply::ok("default\tc0ffee\tmain\nws1\tdecaf0\t\n"),
993 )
994 .on(
995 ["workspace", "root", "--name", "default"],
996 Reply::ok("/repo\n"),
997 )
998 .on(
999 ["workspace", "root", "--name", "ws1"],
1000 Reply::ok("/repo/ws1\n"),
1001 ),
1002 );
1003 let worktrees = repo.list_worktrees().await.expect("list_worktrees");
1004 assert_eq!(worktrees.len(), 2);
1005 assert_eq!(worktrees[0].path, Path::new("/repo"));
1006 assert_eq!(worktrees[0].branch.as_deref(), Some("main"));
1007 assert_eq!(worktrees[1].path, Path::new("/repo/ws1"));
1008 assert_eq!(worktrees[1].branch, None);
1009 }
1010
1011 #[tokio::test]
1014 async fn jj_list_worktrees_skips_unresolvable_root() {
1015 let repo = jj_repo(
1016 ScriptedRunner::new()
1017 .on(
1018 ["workspace", "list"],
1019 Reply::ok("default\tc0ffee\tmain\ngone\tdecaf0\t\n"),
1020 )
1021 .on(
1022 ["workspace", "root", "--name", "default"],
1023 Reply::ok("/repo\n"),
1024 )
1025 .on(
1026 ["workspace", "root", "--name", "gone"],
1027 Reply::fail(1, "Error: No such workspace"),
1028 ),
1029 );
1030 let worktrees = repo.list_worktrees().await.expect("list_worktrees");
1031 assert_eq!(worktrees.len(), 1, "the unresolvable workspace is skipped");
1032 assert_eq!(worktrees[0].path, Path::new("/repo"));
1033 }
1034
1035 #[tokio::test]
1036 async fn kind_and_escape_hatches_reflect_backend() {
1037 let repo = git_repo(ScriptedRunner::new());
1038 assert_eq!(repo.kind(), BackendKind::Git);
1039 assert!(repo.git().is_some());
1040 assert!(repo.jj().is_none());
1041 }
1042
1043 #[tokio::test]
1046 async fn bound_views_reflect_backend_and_cwd() {
1047 let git = git_repo(ScriptedRunner::new());
1048 assert!(git.git_at().is_some());
1049 assert!(git.jj_at().is_none());
1050 assert_eq!(git.at("/repo/wt").cwd(), Path::new("/repo/wt"));
1052
1053 let jj = jj_repo(ScriptedRunner::new());
1054 assert!(jj.jj_at().is_some());
1055 assert!(jj.git_at().is_none());
1056 }
1057
1058 #[tokio::test]
1059 async fn current_branch_maps_detached_head_to_none() {
1060 let named = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("main\n")));
1061 assert_eq!(
1062 named.current_branch().await.unwrap().as_deref(),
1063 Some("main")
1064 );
1065 let detached = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("HEAD\n")));
1066 assert!(detached.current_branch().await.unwrap().is_none());
1067 }
1068
1069 #[tokio::test]
1070 async fn changed_files_maps_git_status() {
1071 let repo = git_repo(ScriptedRunner::new().on(
1072 ["status"],
1073 Reply::ok(" M a.rs\0?? b.rs\0R new.rs\0old.rs\0"),
1074 ));
1075 let changes = repo.changed_files().await.unwrap();
1076 assert_eq!(changes.len(), 3);
1077 assert_eq!(changes[0].kind, ChangeKind::Modified);
1078 assert_eq!(changes[1].kind, ChangeKind::Added);
1079 assert_eq!(changes[2].kind, ChangeKind::Renamed);
1080 assert_eq!(changes[2].old_path.as_deref(), Some("old.rs"));
1081 }
1082
1083 #[tokio::test]
1084 async fn local_branches_maps_git_branch_output() {
1085 let repo = git_repo(ScriptedRunner::new().on(["branch"], Reply::ok("* main\n feat\n")));
1086 assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
1087 }
1088
1089 #[tokio::test]
1090 async fn branch_exists_reads_show_ref_exit() {
1091 let yes = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
1092 assert!(yes.branch_exists("main").await.unwrap());
1093 let no = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
1094 assert!(!no.branch_exists("nope").await.unwrap());
1095 }
1096
1097 #[tokio::test]
1098 async fn has_uncommitted_changes_reflects_status() {
1099 let dirty = git_repo(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0")));
1100 assert!(dirty.has_uncommitted_changes().await.unwrap());
1101 let clean = git_repo(ScriptedRunner::new().on(["status"], Reply::ok("")));
1102 assert!(!clean.has_uncommitted_changes().await.unwrap());
1103 }
1104
1105 #[tokio::test]
1106 async fn at_rebinds_cwd_and_shares_backend() {
1107 let repo = git_repo(ScriptedRunner::new());
1108 let moved = repo.at("/repo/sub");
1109 assert_eq!(moved.cwd(), Path::new("/repo/sub"));
1110 assert_eq!(moved.root(), Path::new("/repo"));
1111 assert_eq!(moved.kind(), BackendKind::Git);
1112 }
1113
1114 #[tokio::test]
1117 async fn jj_kind_and_escape_hatches_reflect_backend() {
1118 let repo = jj_repo(ScriptedRunner::new());
1119 assert_eq!(repo.kind(), BackendKind::Jj);
1120 assert!(repo.jj().is_some() && repo.git().is_none());
1121 }
1122
1123 #[tokio::test]
1124 async fn jj_current_branch_reads_bookmark() {
1125 let repo = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("main\n")));
1126 assert_eq!(
1127 repo.current_branch().await.unwrap().as_deref(),
1128 Some("main")
1129 );
1130 }
1131
1132 #[tokio::test]
1133 async fn jj_local_branches_maps_bookmark_list() {
1134 let repo = jj_repo(ScriptedRunner::new().on(
1135 ["bookmark", "list"],
1136 Reply::ok("main: chg cmt desc\nfeat: c2 m2 d2\n"),
1137 ));
1138 assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
1139 }
1140
1141 #[tokio::test]
1142 async fn jj_branch_exists_scans_bookmarks() {
1143 let repo = jj_repo(
1144 ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
1145 );
1146 assert!(repo.branch_exists("main").await.unwrap());
1147 let repo2 = jj_repo(
1148 ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
1149 );
1150 assert!(!repo2.branch_exists("missing").await.unwrap());
1151 }
1152
1153 #[tokio::test]
1154 async fn jj_has_uncommitted_changes_reads_empty_flag() {
1155 let dirty = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\tfalse\twip\n")));
1157 assert!(dirty.has_uncommitted_changes().await.unwrap());
1158 let clean = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\ttrue\t\n")));
1159 assert!(!clean.has_uncommitted_changes().await.unwrap());
1160 }
1161
1162 #[tokio::test]
1163 async fn jj_changed_files_maps_diff_summary() {
1164 let repo = jj_repo(
1165 ScriptedRunner::new().on(["diff"], Reply::ok("M src/a.rs\nA b.rs\nD gone.rs\n")),
1166 );
1167 let changes = repo.changed_files().await.unwrap();
1168 assert_eq!(changes.len(), 3);
1169 assert_eq!(changes[0].kind, ChangeKind::Modified);
1170 assert_eq!(changes[1].kind, ChangeKind::Added);
1171 assert_eq!(changes[2].kind, ChangeKind::Deleted);
1172 assert!(changes.iter().all(|c| c.old_path.is_none()));
1173 }
1174
1175 #[tokio::test]
1178 async fn jj_changed_files_populates_rename_old_path() {
1179 let repo =
1180 jj_repo(ScriptedRunner::new().on(["diff"], Reply::ok("R src/{old.rs => new.rs}\n")));
1181 let changes = repo.changed_files().await.unwrap();
1182 assert_eq!(changes.len(), 1);
1183 assert_eq!(changes[0].kind, ChangeKind::Renamed);
1184 assert_eq!(changes[0].path, "src/new.rs");
1185 assert_eq!(changes[0].old_path.as_deref(), Some("src/old.rs"));
1186 }
1187
1188 #[tokio::test]
1193 async fn commit_paths_refuses_an_empty_path_set() {
1194 for repo in [
1195 git_repo(ScriptedRunner::new()),
1196 jj_repo(ScriptedRunner::new()),
1197 ] {
1198 let err = repo
1199 .commit_paths(&[], "msg")
1200 .await
1201 .expect_err("empty paths must be refused");
1202 assert!(
1203 err.to_string().contains("at least one path"),
1204 "unexpected error: {err}"
1205 );
1206 }
1207 }
1208
1209 #[tokio::test]
1210 async fn jj_rename_branch_builds_bookmark_rename() {
1211 use processkit::RecordingRunner;
1212 let rec = RecordingRunner::replying(Reply::ok(""));
1213 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
1214 repo.rename_branch("old", "new").await.unwrap();
1215 assert_eq!(
1216 rec.only_call().args_str(),
1217 ["bookmark", "rename", "old", "new", "--color", "never"]
1218 );
1219 }
1220
1221 #[tokio::test]
1224 async fn checkout_dispatches_per_backend() {
1225 use processkit::RecordingRunner;
1226 let grec = RecordingRunner::replying(Reply::ok(""));
1227 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1228 .checkout("feat")
1229 .await
1230 .unwrap();
1231 assert_eq!(grec.only_call().args_str(), ["checkout", "feat"]);
1232
1233 let jrec = RecordingRunner::replying(Reply::ok(""));
1234 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1235 .checkout("feat")
1236 .await
1237 .unwrap();
1238 assert_eq!(
1239 jrec.only_call().args_str(),
1240 ["edit", "feat", "--color", "never"]
1241 );
1242 }
1243
1244 #[tokio::test]
1245 async fn fetch_remote_branch_dispatches_per_backend() {
1246 use processkit::RecordingRunner;
1247 let grec = RecordingRunner::replying(Reply::ok(""));
1248 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1249 .fetch_remote_branch("main")
1250 .await
1251 .unwrap();
1252 assert!(
1253 grec.only_call()
1254 .args_str()
1255 .starts_with(&["fetch".to_string()])
1256 );
1257
1258 let jrec = RecordingRunner::replying(Reply::ok(""));
1259 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1260 .fetch_remote_branch("main")
1261 .await
1262 .unwrap();
1263 let args = jrec.only_call().args_str();
1264 assert_eq!(&args[..2], &["git", "fetch"]);
1265 }
1266
1267 #[tokio::test]
1270 async fn push_dispatches_per_backend() {
1271 use processkit::RecordingRunner;
1272 let grec = RecordingRunner::replying(Reply::ok(""));
1273 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1274 .push("feature")
1275 .await
1276 .unwrap();
1277 assert_eq!(
1278 grec.only_call().args_str(),
1279 ["push", "-u", "origin", "feature"]
1280 );
1281
1282 let jrec = RecordingRunner::replying(Reply::ok(""));
1283 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1284 .push("feature")
1285 .await
1286 .unwrap();
1287 let args = jrec.only_call().args_str();
1288 assert_eq!(&args[..4], &["git", "push", "-b", "feature"]);
1289 }
1290
1291 #[tokio::test]
1298 async fn push_flag_like_branch_follows_guard_convention() {
1299 use processkit::RecordingRunner;
1300 let grec = RecordingRunner::replying(Reply::ok(""));
1301 let err = Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1302 .push("--force")
1303 .await
1304 .unwrap_err();
1305 assert!(
1306 matches!(err, Error::Vcs(processkit::Error::Spawn { .. })),
1307 "got: {err:?}"
1308 );
1309 assert_eq!(grec.calls().len(), 0, "no process must have spawned");
1310
1311 let jrec = RecordingRunner::replying(Reply::ok(""));
1312 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1313 .push("--force")
1314 .await
1315 .expect("jj path spawns; the value rides -b verbatim");
1316 assert_eq!(
1317 &jrec.only_call().args_str()[..4],
1318 &["git", "push", "-b", "--force"],
1319 "the flag-like value must ride the -b flag-VALUE slot, not become argv"
1320 );
1321 }
1322
1323 #[tokio::test]
1324 async fn fetch_from_names_the_remote_on_both_backends() {
1325 use processkit::RecordingRunner;
1326 let grec = RecordingRunner::replying(Reply::ok(""));
1327 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1328 .fetch_from("upstream")
1329 .await
1330 .unwrap();
1331 assert_eq!(
1332 grec.only_call().args_str(),
1333 ["fetch", "--quiet", "upstream"]
1334 );
1335
1336 let jrec = RecordingRunner::replying(Reply::ok(""));
1337 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1338 .fetch_from("upstream")
1339 .await
1340 .unwrap();
1341 let args = jrec.only_call().args_str();
1342 assert_eq!(&args[..4], &["git", "fetch", "--remote", "upstream"]);
1343 }
1344
1345 #[tokio::test]
1347 async fn git_has_tracked_changes_ignores_untracked() {
1348 let dirty = git_repo(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0")));
1349 assert!(dirty.has_tracked_changes().await.unwrap());
1350 let clean = git_repo(ScriptedRunner::new().on(["status"], Reply::ok("")));
1353 assert!(!clean.has_tracked_changes().await.unwrap());
1354 }
1355
1356 #[tokio::test]
1358 async fn jj_has_tracked_changes_follows_working_copy() {
1359 let dirty = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\tfalse\twip\n")));
1360 assert!(dirty.has_tracked_changes().await.unwrap());
1361 }
1362
1363 #[tokio::test]
1364 async fn conflicted_files_dispatches_per_backend() {
1365 let git = git_repo(ScriptedRunner::new().on(["diff"], Reply::ok("a.rs\0b dir/c.rs\0")));
1366 assert_eq!(
1367 git.conflicted_files().await.unwrap(),
1368 ["a.rs", "b dir/c.rs"]
1369 );
1370
1371 let jj =
1372 jj_repo(ScriptedRunner::new().on(["resolve"], Reply::ok("a.rs 2-sided conflict\n")));
1373 assert_eq!(jj.conflicted_files().await.unwrap(), ["a.rs"]);
1374 let clean = jj_repo(ScriptedRunner::new().on(
1376 ["resolve"],
1377 Reply::fail(2, "Error: No conflicts found at this revision"),
1378 ));
1379 assert!(clean.conflicted_files().await.unwrap().is_empty());
1380 }
1381
1382 #[test]
1383 fn merge_probe_is_clean() {
1384 assert!(MergeProbe::Clean.is_clean());
1385 assert!(!MergeProbe::Conflicts(vec!["a.rs".into()]).is_clean());
1386 }
1387
1388 #[tokio::test]
1391 async fn git_try_merge_reports_clean_and_skips_needless_abort() {
1392 use processkit::RecordingRunner;
1393 let rec = RecordingRunner::new(
1394 ScriptedRunner::new()
1395 .on(["merge"], Reply::ok("Already up to date.\n"))
1396 .on(["rev-parse"], Reply::ok("/vcs-core-no-such-git-dir")),
1397 );
1398 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1399 assert_eq!(repo.try_merge("other").await.unwrap(), MergeProbe::Clean);
1400 assert!(
1401 rec.calls()
1402 .iter()
1403 .all(|c| !c.args_str().contains(&"--abort".to_string())),
1404 "no merge to abort"
1405 );
1406 }
1407
1408 #[tokio::test]
1411 async fn git_try_merge_collects_conflicts_then_aborts() {
1412 use processkit::RecordingRunner;
1413 let rec = RecordingRunner::new(
1414 ScriptedRunner::new()
1415 .on(["merge", "--abort"], Reply::ok(""))
1417 .on(
1418 ["merge"],
1419 Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
1420 )
1421 .on(["diff"], Reply::ok("a.rs\0")),
1422 );
1423 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1424 assert_eq!(
1425 repo.try_merge("other").await.unwrap(),
1426 MergeProbe::Conflicts(vec!["a.rs".to_string()])
1427 );
1428 let calls = rec.calls();
1429 let diff_pos = calls.iter().position(|c| c.args_str()[0] == "diff");
1430 let abort_pos = calls
1431 .iter()
1432 .position(|c| c.args_str().contains(&"--abort".to_string()));
1433 assert!(diff_pos.unwrap() < abort_pos.unwrap(), "{calls:?}");
1434 }
1435
1436 #[tokio::test]
1439 async fn git_try_merge_propagates_abort_failure() {
1440 let tmp = TempDir::new("probe-abort");
1441 std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
1442 let repo = git_repo(
1443 ScriptedRunner::new()
1444 .on(
1445 ["merge", "--abort"],
1446 Reply::fail(128, "fatal: cannot abort"),
1447 )
1448 .on(["merge"], Reply::ok(""))
1449 .on(["rev-parse"], Reply::ok(tmp.path().to_str().unwrap())),
1450 );
1451 assert!(repo.try_merge("other").await.is_err());
1452 }
1453
1454 #[tokio::test]
1456 async fn jj_try_merge_probes_and_restores() {
1457 use processkit::RecordingRunner;
1458 let rec = RecordingRunner::new(
1459 ScriptedRunner::new()
1460 .on(["op", "log"], Reply::ok("op42\n"))
1461 .on(["op", "restore"], Reply::ok(""))
1462 .on(["new"], Reply::ok(""))
1463 .on(["log"], Reply::ok("1\n")) .on(["resolve"], Reply::ok("a.rs 2-sided conflict\n")),
1465 );
1466 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
1467 assert_eq!(
1468 repo.try_merge("feature").await.unwrap(),
1469 MergeProbe::Conflicts(vec!["a.rs".to_string()])
1470 );
1471 let calls = rec.calls();
1472 assert_eq!(calls[0].args_str()[..2], ["op", "log"]);
1473 assert_eq!(calls[1].args_str()[0], "new");
1474 let last = calls.last().unwrap().args_str();
1475 assert_eq!(last[..3], ["op", "restore", "op42"]);
1476 }
1477
1478 #[tokio::test]
1479 async fn jj_try_merge_clean_and_restore_failure() {
1480 let clean = jj_repo(
1482 ScriptedRunner::new()
1483 .on(["op", "log"], Reply::ok("op42\n"))
1484 .on(["op", "restore"], Reply::ok(""))
1485 .on(["new"], Reply::ok(""))
1486 .on(["log"], Reply::ok("0\n")),
1487 );
1488 assert_eq!(clean.try_merge("feature").await.unwrap(), MergeProbe::Clean);
1489
1490 let broken = jj_repo(
1492 ScriptedRunner::new()
1493 .on(["op", "log"], Reply::ok("op42\n"))
1494 .on(["op", "restore"], Reply::fail(1, "op not found"))
1495 .on(["new"], Reply::ok(""))
1496 .on(["log"], Reply::ok("0\n")),
1497 );
1498 assert!(broken.try_merge("feature").await.is_err());
1499 }
1500
1501 #[tokio::test]
1504 async fn git_continue_blocked_by_conflicts_does_not_act() {
1505 use processkit::RecordingRunner;
1506 let rec = RecordingRunner::new(ScriptedRunner::new().on(["diff"], Reply::ok("a.rs\0")));
1507 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1508 assert_eq!(
1509 repo.continue_in_progress().await.unwrap(),
1510 OperationState::Conflict
1511 );
1512 assert!(
1513 rec.calls().iter().all(|c| c.args_str()[0] == "diff"),
1514 "only the conflict probe may run: {:?}",
1515 rec.calls()
1516 );
1517 }
1518
1519 #[tokio::test]
1525 async fn git_continue_maps_rebase_re_conflict() {
1526 use std::sync::Arc as StdArc;
1527 use std::sync::atomic::{AtomicBool, Ordering};
1528 let tmp = TempDir::new("rebase-restop");
1529 std::fs::create_dir_all(tmp.path().join("rebase-merge")).unwrap();
1530 let seen_first_diff = StdArc::new(AtomicBool::new(false));
1531 let flag = StdArc::clone(&seen_first_diff);
1532 let repo = git_repo(
1533 ScriptedRunner::new()
1534 .when(
1535 move |cmd| {
1536 cmd.arguments().first().and_then(|a| a.to_str()) == Some("diff")
1537 && flag.swap(true, Ordering::SeqCst)
1538 },
1539 Reply::ok("a.rs\0"),
1540 )
1541 .on(["diff"], Reply::ok(""))
1542 .on(["rev-parse"], Reply::ok(tmp.path().to_str().unwrap()))
1543 .on(
1544 ["rebase", "--continue"],
1545 Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
1546 ),
1547 );
1548 assert_eq!(
1549 repo.continue_in_progress().await.unwrap(),
1550 OperationState::Conflict
1551 );
1552 }
1553
1554 #[tokio::test]
1556 async fn git_abort_dispatches_on_merge_in_progress() {
1557 use processkit::RecordingRunner;
1558 let tmp = TempDir::new("abort");
1559 std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
1560 let rec = RecordingRunner::new(
1561 ScriptedRunner::new()
1562 .on(["rev-parse"], Reply::ok(tmp.path().to_str().unwrap()))
1563 .on(["merge", "--abort"], Reply::ok("")),
1564 );
1565 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1566 repo.abort_in_progress().await.unwrap();
1567 assert!(
1568 rec.calls()
1569 .iter()
1570 .any(|c| c.args_str() == ["merge", "--abort"]),
1571 "{:?}",
1572 rec.calls()
1573 );
1574 }
1575
1576 #[tokio::test]
1581 async fn git_in_progress_state_maps_merge_and_rebase() {
1582 let merging = TempDir::new("inprog-merge");
1583 std::fs::write(merging.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
1584 let merge_repo = Repo::from_git(
1585 "/repo",
1586 "/repo",
1587 Git::with_runner(
1588 ScriptedRunner::new()
1589 .on(["rev-parse"], Reply::ok(merging.path().to_str().unwrap())),
1590 ),
1591 );
1592 assert_eq!(
1593 merge_repo.in_progress_state().await.unwrap(),
1594 OperationState::Merge
1595 );
1596
1597 let rebasing = TempDir::new("inprog-rebase");
1598 std::fs::create_dir_all(rebasing.path().join("rebase-merge")).unwrap();
1599 let rebase_repo = Repo::from_git(
1600 "/repo",
1601 "/repo",
1602 Git::with_runner(
1603 ScriptedRunner::new()
1604 .on(["rev-parse"], Reply::ok(rebasing.path().to_str().unwrap())),
1605 ),
1606 );
1607 assert_eq!(
1608 rebase_repo.in_progress_state().await.unwrap(),
1609 OperationState::Rebase
1610 );
1611 }
1612
1613 #[tokio::test]
1617 async fn git_diff_stat_unborn_uses_empty_tree() {
1618 use processkit::RecordingRunner;
1619 let rec = RecordingRunner::new(
1620 ScriptedRunner::new()
1621 .on(["rev-parse"], Reply::fail(1, "")) .on(
1623 ["diff", "--shortstat"],
1624 Reply::ok(" 1 file changed, 2 insertions(+)\n"),
1625 ),
1626 );
1627 let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1628 let stat = repo.diff_stat().await.unwrap();
1629 assert_eq!(stat.insertions, 2);
1630 assert!(
1631 rec.calls()
1632 .iter()
1633 .any(|c| c.args_str() == ["diff", "--shortstat", vcs_git::EMPTY_TREE]),
1634 "diff_stat should target the empty tree on an unborn repo: {:?}",
1635 rec.calls()
1636 );
1637 }
1638
1639 #[tokio::test]
1641 async fn jj_abort_and_continue_are_reporting_noops() {
1642 let conflicted = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("1\n")));
1643 assert_eq!(
1644 conflicted.abort_in_progress().await.unwrap(),
1645 OperationState::Conflict
1646 );
1647 let clear = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("0\n")));
1648 assert_eq!(
1649 clear.continue_in_progress().await.unwrap(),
1650 OperationState::Clear
1651 );
1652 }
1653
1654 #[tokio::test]
1656 async fn jj_in_progress_state_maps_conflict() {
1657 let conflicted = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("1\n")));
1658 assert_eq!(
1659 conflicted.in_progress_state().await.unwrap(),
1660 OperationState::Conflict
1661 );
1662 let clear = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("0\n")));
1663 assert_eq!(
1664 clear.in_progress_state().await.unwrap(),
1665 OperationState::Clear
1666 );
1667 }
1668
1669 #[tokio::test]
1672 async fn vcs_repo_trait_object_dispatches() {
1673 let repo = git_repo(
1674 ScriptedRunner::new()
1675 .on(["rev-parse"], Reply::ok("main\n"))
1676 .on(["show-ref"], Reply::ok("")),
1677 );
1678 let dynamic: &dyn VcsRepo = &repo;
1679 assert_eq!(dynamic.kind(), BackendKind::Git);
1680 assert_eq!(
1681 dynamic.current_branch().await.unwrap().as_deref(),
1682 Some("main")
1683 );
1684 assert!(dynamic.branch_exists("main").await.unwrap());
1687 }
1688
1689 #[tokio::test]
1692 async fn trunk_falls_back_to_main() {
1693 let repo = git_repo(
1694 ScriptedRunner::new()
1695 .on(["symbolic-ref"], Reply::fail(1, "")) .on(["show-ref"], Reply::ok("")), );
1698 assert_eq!(repo.trunk().await.unwrap().as_deref(), Some("main"));
1699 }
1700
1701 #[test]
1702 fn error_classifiers_recognise_markers() {
1703 let conflict = Error::Vcs(processkit::Error::Exit {
1704 program: "git".into(),
1705 code: 1,
1706 stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
1707 stderr: String::new(),
1708 });
1709 assert!(conflict.is_merge_conflict());
1710 assert!(!conflict.is_nothing_to_commit());
1711 assert!(!Error::NotARepository("/x".into()).is_merge_conflict());
1713 }
1714}
1715
1716#[doc = include_str!("../docs/core.md")]
1718#[allow(rustdoc::broken_intra_doc_links)]
1719pub mod guide {
1720 #[doc = include_str!("../docs/cookbook.md")]
1721 #[allow(rustdoc::broken_intra_doc_links)]
1722 pub mod cookbook {}
1723 #[doc = include_str!("../docs/process-model.md")]
1724 #[allow(rustdoc::broken_intra_doc_links)]
1725 pub mod process_model {}
1726 #[doc = include_str!("../docs/positioning.md")]
1727 #[allow(rustdoc::broken_intra_doc_links)]
1728 pub mod positioning {}
1729 #[doc = include_str!("../docs/stability.md")]
1730 #[allow(rustdoc::broken_intra_doc_links)]
1731 pub mod stability {}
1732}