1use std::path::{Path, PathBuf};
35use std::sync::Arc;
36
37use processkit::{JobRunner, ProcessRunner};
38use vcs_git::{Git, GitAt};
39use vcs_jj::{Jj, JjAt};
40
41mod dto;
42mod error;
43mod git_backend;
44mod jj_backend;
45
46pub use dto::{
47 BackendKind, ChangeKind, CreateOutcome, DiffStat, FileChange, OperationState, WorktreeInfo,
48};
49pub use error::{Error, Result};
50
51pub use vcs_git;
58pub use vcs_jj;
59
60#[derive(Debug, Clone, PartialEq, Eq)]
63#[non_exhaustive]
64pub struct Located {
65 pub kind: BackendKind,
67 pub root: PathBuf,
69}
70
71pub fn detect(start: &Path) -> Option<Located> {
80 let mut current = Some(start);
81 while let Some(dir) = current {
82 if dir.join(".jj").is_dir() {
83 return Some(Located {
84 kind: BackendKind::Jj,
85 root: dir.to_path_buf(),
86 });
87 }
88 if dir.join(".git").exists() {
89 return Some(Located {
90 kind: BackendKind::Git,
91 root: dir.to_path_buf(),
92 });
93 }
94 current = dir.parent();
95 }
96 None
97}
98
99enum Backend<R: ProcessRunner> {
102 Git(Arc<Git<R>>),
103 Jj(Arc<Jj<R>>),
104}
105
106impl<R: ProcessRunner> Backend<R> {
107 fn shared(&self) -> Self {
108 match self {
109 Backend::Git(g) => Backend::Git(Arc::clone(g)),
110 Backend::Jj(j) => Backend::Jj(Arc::clone(j)),
111 }
112 }
113}
114
115pub struct Repo<R: ProcessRunner = JobRunner> {
119 root: PathBuf,
120 cwd: PathBuf,
121 backend: Backend<R>,
122}
123
124impl Repo<JobRunner> {
125 pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
129 let dir = std::path::absolute(dir.as_ref())?;
133 let located = detect(&dir).ok_or_else(|| Error::NotARepository(dir.clone()))?;
134 let backend = match located.kind {
135 BackendKind::Git => Backend::Git(Arc::new(Git::new())),
136 BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
137 };
138 Ok(Repo {
139 root: located.root,
140 cwd: dir,
141 backend,
142 })
143 }
144}
145
146impl<R: ProcessRunner> Repo<R> {
147 pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self {
150 Repo {
151 root: root.into(),
152 cwd: cwd.into(),
153 backend: Backend::Git(Arc::new(client)),
154 }
155 }
156
157 pub fn from_jj(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>) -> Self {
159 Repo {
160 root: root.into(),
161 cwd: cwd.into(),
162 backend: Backend::Jj(Arc::new(client)),
163 }
164 }
165
166 pub fn kind(&self) -> BackendKind {
168 match &self.backend {
169 Backend::Git(_) => BackendKind::Git,
170 Backend::Jj(_) => BackendKind::Jj,
171 }
172 }
173
174 pub fn root(&self) -> &Path {
176 &self.root
177 }
178
179 pub fn cwd(&self) -> &Path {
181 &self.cwd
182 }
183
184 pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
186 Repo {
187 root: self.root.clone(),
188 cwd: dir.into(),
189 backend: self.backend.shared(),
190 }
191 }
192
193 pub fn git(&self) -> Option<&Git<R>> {
196 match &self.backend {
197 Backend::Git(g) => Some(g.as_ref()),
198 Backend::Jj(_) => None,
199 }
200 }
201
202 pub fn jj(&self) -> Option<&Jj<R>> {
204 match &self.backend {
205 Backend::Jj(j) => Some(j.as_ref()),
206 Backend::Git(_) => None,
207 }
208 }
209
210 pub fn git_at(&self) -> Option<GitAt<'_, R>> {
226 match &self.backend {
227 Backend::Git(g) => Some(g.at(&self.cwd)),
228 Backend::Jj(_) => None,
229 }
230 }
231
232 pub fn jj_at(&self) -> Option<JjAt<'_, R>> {
237 match &self.backend {
238 Backend::Jj(j) => Some(j.at(&self.cwd)),
239 Backend::Git(_) => None,
240 }
241 }
242
243 pub async fn current_branch(&self) -> Result<Option<String>> {
246 match &self.backend {
247 Backend::Git(g) => git_backend::current_branch(g, &self.cwd).await,
248 Backend::Jj(j) => jj_backend::current_branch(j, &self.cwd).await,
249 }
250 }
251
252 pub async fn trunk(&self) -> Result<Option<String>> {
256 let native = match &self.backend {
257 Backend::Git(g) => git_backend::trunk(g, &self.cwd).await?,
258 Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await?,
259 };
260 if native.is_some() {
261 return Ok(native);
262 }
263 for candidate in ["main", "master"] {
264 if self.branch_exists(candidate).await? {
265 return Ok(Some(candidate.to_string()));
266 }
267 }
268 Ok(None)
269 }
270
271 pub async fn local_branches(&self) -> Result<Vec<String>> {
273 match &self.backend {
274 Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
275 Backend::Jj(j) => jj_backend::local_branches(j, &self.cwd).await,
276 }
277 }
278
279 pub async fn branch_exists(&self, name: &str) -> Result<bool> {
281 match &self.backend {
282 Backend::Git(g) => git_backend::branch_exists(g, &self.cwd, name).await,
283 Backend::Jj(j) => jj_backend::branch_exists(j, &self.cwd, name).await,
284 }
285 }
286
287 pub async fn has_uncommitted_changes(&self) -> Result<bool> {
290 match &self.backend {
291 Backend::Git(g) => git_backend::has_uncommitted_changes(g, &self.cwd).await,
292 Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
293 }
294 }
295
296 pub async fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
299 match &self.backend {
300 Backend::Git(g) => git_backend::delete_branch(g, &self.cwd, name, force).await,
301 Backend::Jj(j) => jj_backend::delete_branch(j, &self.cwd, name).await,
302 }
303 }
304
305 pub async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
307 match &self.backend {
308 Backend::Git(g) => git_backend::rename_branch(g, &self.cwd, old, new).await,
309 Backend::Jj(j) => jj_backend::rename_branch(j, &self.cwd, old, new).await,
310 }
311 }
312
313 pub async fn changed_files(&self) -> Result<Vec<FileChange>> {
315 match &self.backend {
316 Backend::Git(g) => git_backend::changed_files(g, &self.cwd).await,
317 Backend::Jj(j) => jj_backend::changed_files(j, &self.cwd).await,
318 }
319 }
320
321 pub async fn diff_stat(&self) -> Result<DiffStat> {
329 match &self.backend {
330 Backend::Git(g) => git_backend::diff_stat(g, &self.cwd).await,
331 Backend::Jj(j) => jj_backend::diff_stat(j, &self.cwd).await,
332 }
333 }
334
335 pub async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
338 match &self.backend {
339 Backend::Git(g) => git_backend::commit_paths(g, &self.cwd, paths, message).await,
340 Backend::Jj(j) => jj_backend::commit_paths(j, &self.cwd, paths, message).await,
341 }
342 }
343
344 pub async fn fetch(&self) -> Result<()> {
346 match &self.backend {
347 Backend::Git(g) => git_backend::fetch(g, &self.cwd).await,
348 Backend::Jj(j) => jj_backend::fetch(j, &self.cwd).await,
349 }
350 }
351
352 pub async fn fetch_remote_branch(&self, branch: &str) -> Result<()> {
356 match &self.backend {
357 Backend::Git(g) => git_backend::fetch_remote_branch(g, &self.cwd, branch).await,
358 Backend::Jj(j) => jj_backend::fetch_remote_branch(j, &self.cwd, branch).await,
359 }
360 }
361
362 pub async fn checkout(&self, reference: &str) -> Result<()> {
364 match &self.backend {
365 Backend::Git(g) => git_backend::checkout(g, &self.cwd, reference).await,
366 Backend::Jj(j) => jj_backend::checkout(j, &self.cwd, reference).await,
367 }
368 }
369
370 pub async fn rebase(&self, onto: &str) -> Result<()> {
373 match &self.backend {
374 Backend::Git(g) => git_backend::rebase(g, &self.cwd, onto).await,
375 Backend::Jj(j) => jj_backend::rebase(j, &self.cwd, onto).await,
376 }
377 }
378
379 pub async fn in_progress_state(&self) -> Result<OperationState> {
386 match &self.backend {
387 Backend::Git(g) => git_backend::in_progress_state(g, &self.cwd).await,
388 Backend::Jj(j) => jj_backend::in_progress_state(j, &self.cwd).await,
389 }
390 }
391
392 pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
394 match &self.backend {
395 Backend::Git(g) => git_backend::list_worktrees(g, &self.cwd).await,
396 Backend::Jj(j) => jj_backend::list_worktrees(j, &self.cwd).await,
397 }
398 }
399
400 pub async fn create_worktree(
410 &self,
411 path: &Path,
412 branch: &str,
413 base: &str,
414 ) -> Result<CreateOutcome> {
415 match &self.backend {
416 Backend::Git(g) => git_backend::create_worktree(g, &self.cwd, path, branch, base).await,
417 Backend::Jj(j) => jj_backend::create_worktree(j, &self.cwd, path, branch, base).await,
418 }
419 }
420
421 pub async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()> {
424 match &self.backend {
425 Backend::Git(g) => git_backend::remove_worktree(g, &self.cwd, path, force).await,
426 Backend::Jj(j) => jj_backend::remove_worktree(j, &self.cwd, path, force).await,
427 }
428 }
429
430 pub fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()> {
437 match &self.backend {
438 Backend::Git(_) => {
439 vcs_git::blocking::worktree_remove(&self.cwd, path, true).map_err(Error::Io)
440 }
441 Backend::Jj(_) => {
442 match vcs_jj::blocking::workspace_name_for_path(&self.cwd, path) {
443 Some(name) => {
444 let _ = std::fs::remove_dir_all(path);
447 vcs_jj::blocking::workspace_forget(&self.cwd, &name).map_err(Error::Io)
448 }
449 None => Ok(()),
450 }
451 }
452 }
453 }
454}
455
456#[async_trait::async_trait]
466pub trait VcsRepo: Send + Sync {
467 fn kind(&self) -> BackendKind;
469 fn root(&self) -> &Path;
471 fn cwd(&self) -> &Path;
473
474 async fn current_branch(&self) -> Result<Option<String>>;
476 async fn trunk(&self) -> Result<Option<String>>;
478 async fn local_branches(&self) -> Result<Vec<String>>;
480 async fn branch_exists(&self, name: &str) -> Result<bool>;
482 async fn has_uncommitted_changes(&self) -> Result<bool>;
484 async fn delete_branch(&self, name: &str, force: bool) -> Result<()>;
486 async fn rename_branch(&self, old: &str, new: &str) -> Result<()>;
488 async fn changed_files(&self) -> Result<Vec<FileChange>>;
490 async fn diff_stat(&self) -> Result<DiffStat>;
492 async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()>;
494 async fn fetch(&self) -> Result<()>;
496 async fn fetch_remote_branch(&self, branch: &str) -> Result<()>;
498 async fn checkout(&self, reference: &str) -> Result<()>;
500 async fn rebase(&self, onto: &str) -> Result<()>;
502 async fn in_progress_state(&self) -> Result<OperationState>;
504 async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>>;
506 async fn create_worktree(&self, path: &Path, branch: &str, base: &str)
508 -> Result<CreateOutcome>;
509 async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()>;
511 fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()>;
513}
514
515#[async_trait::async_trait]
518impl<R: ProcessRunner> VcsRepo for Repo<R> {
519 fn kind(&self) -> BackendKind {
520 self.kind()
521 }
522 fn root(&self) -> &Path {
523 self.root()
524 }
525 fn cwd(&self) -> &Path {
526 self.cwd()
527 }
528 async fn current_branch(&self) -> Result<Option<String>> {
529 self.current_branch().await
530 }
531 async fn trunk(&self) -> Result<Option<String>> {
532 self.trunk().await
533 }
534 async fn local_branches(&self) -> Result<Vec<String>> {
535 self.local_branches().await
536 }
537 async fn branch_exists(&self, name: &str) -> Result<bool> {
538 self.branch_exists(name).await
539 }
540 async fn has_uncommitted_changes(&self) -> Result<bool> {
541 self.has_uncommitted_changes().await
542 }
543 async fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
544 self.delete_branch(name, force).await
545 }
546 async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
547 self.rename_branch(old, new).await
548 }
549 async fn changed_files(&self) -> Result<Vec<FileChange>> {
550 self.changed_files().await
551 }
552 async fn diff_stat(&self) -> Result<DiffStat> {
553 self.diff_stat().await
554 }
555 async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
556 self.commit_paths(paths, message).await
557 }
558 async fn fetch(&self) -> Result<()> {
559 self.fetch().await
560 }
561 async fn fetch_remote_branch(&self, branch: &str) -> Result<()> {
562 self.fetch_remote_branch(branch).await
563 }
564 async fn checkout(&self, reference: &str) -> Result<()> {
565 self.checkout(reference).await
566 }
567 async fn rebase(&self, onto: &str) -> Result<()> {
568 self.rebase(onto).await
569 }
570 async fn in_progress_state(&self) -> Result<OperationState> {
571 self.in_progress_state().await
572 }
573 async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
574 self.list_worktrees().await
575 }
576 async fn create_worktree(
577 &self,
578 path: &Path,
579 branch: &str,
580 base: &str,
581 ) -> Result<CreateOutcome> {
582 self.create_worktree(path, branch, base).await
583 }
584 async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()> {
585 self.remove_worktree(path, force).await
586 }
587 fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()> {
588 self.cleanup_worktree_blocking(path)
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use processkit::{Reply, ScriptedRunner};
596
597 struct TempDir(PathBuf);
601 impl TempDir {
602 fn new(tag: &str) -> Self {
603 use std::sync::atomic::{AtomicU64, Ordering};
606 static COUNTER: AtomicU64 = AtomicU64::new(0);
607 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
608 let dir =
609 std::env::temp_dir().join(format!("vcs-core-{tag}-{}-{n}", std::process::id()));
610 std::fs::create_dir_all(&dir).expect("create temp dir");
611 TempDir(dir)
612 }
613 fn path(&self) -> &Path {
614 &self.0
615 }
616 }
617 impl Drop for TempDir {
618 fn drop(&mut self) {
619 let _ = std::fs::remove_dir_all(&self.0);
620 }
621 }
622
623 #[test]
624 fn detect_finds_git_and_jj_and_prefers_jj() {
625 let tmp = TempDir::new("detect");
626 let root = tmp.path();
627
628 std::fs::create_dir_all(root.join(".git")).unwrap();
630 let located = detect(root).expect("git detected");
631 assert_eq!(located.kind, BackendKind::Git);
632 assert_eq!(located.root, root);
633
634 std::fs::create_dir_all(root.join(".jj")).unwrap();
636 assert_eq!(detect(root).unwrap().kind, BackendKind::Jj);
637 }
638
639 #[test]
640 fn detect_walks_up_to_ancestor() {
641 let tmp = TempDir::new("walkup");
642 let root = tmp.path();
643 std::fs::create_dir_all(root.join(".git")).unwrap();
644 let nested = root.join("a").join("b");
645 std::fs::create_dir_all(&nested).unwrap();
646 let located = detect(&nested).expect("found via ancestor walk");
647 assert_eq!(located.kind, BackendKind::Git);
648 assert_eq!(located.root, root);
649 }
650
651 #[test]
652 fn detect_returns_none_outside_repo() {
653 let tmp = TempDir::new("norepo");
654 assert!(detect(tmp.path()).is_none());
655 }
656
657 fn git_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
660 Repo::from_git("/repo", "/repo", Git::with_runner(runner))
661 }
662
663 fn jj_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
664 Repo::from_jj("/repo", "/repo", Jj::with_runner(runner))
665 }
666
667 #[tokio::test]
668 async fn kind_and_escape_hatches_reflect_backend() {
669 let repo = git_repo(ScriptedRunner::new());
670 assert_eq!(repo.kind(), BackendKind::Git);
671 assert!(repo.git().is_some());
672 assert!(repo.jj().is_none());
673 }
674
675 #[tokio::test]
678 async fn bound_views_reflect_backend_and_cwd() {
679 let git = git_repo(ScriptedRunner::new());
680 assert!(git.git_at().is_some());
681 assert!(git.jj_at().is_none());
682 assert_eq!(git.at("/repo/wt").cwd(), Path::new("/repo/wt"));
684
685 let jj = jj_repo(ScriptedRunner::new());
686 assert!(jj.jj_at().is_some());
687 assert!(jj.git_at().is_none());
688 }
689
690 #[tokio::test]
691 async fn current_branch_maps_detached_head_to_none() {
692 let named = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("main\n")));
693 assert_eq!(
694 named.current_branch().await.unwrap().as_deref(),
695 Some("main")
696 );
697 let detached = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("HEAD\n")));
698 assert!(detached.current_branch().await.unwrap().is_none());
699 }
700
701 #[tokio::test]
702 async fn changed_files_maps_git_status() {
703 let repo = git_repo(ScriptedRunner::new().on(
704 ["status"],
705 Reply::ok(" M a.rs\0?? b.rs\0R new.rs\0old.rs\0"),
706 ));
707 let changes = repo.changed_files().await.unwrap();
708 assert_eq!(changes.len(), 3);
709 assert_eq!(changes[0].kind, ChangeKind::Modified);
710 assert_eq!(changes[1].kind, ChangeKind::Added);
711 assert_eq!(changes[2].kind, ChangeKind::Renamed);
712 assert_eq!(changes[2].old_path.as_deref(), Some("old.rs"));
713 }
714
715 #[tokio::test]
716 async fn local_branches_maps_git_branch_output() {
717 let repo = git_repo(ScriptedRunner::new().on(["branch"], Reply::ok("* main\n feat\n")));
718 assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
719 }
720
721 #[tokio::test]
722 async fn branch_exists_reads_show_ref_exit() {
723 let yes = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
724 assert!(yes.branch_exists("main").await.unwrap());
725 let no = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
726 assert!(!no.branch_exists("nope").await.unwrap());
727 }
728
729 #[tokio::test]
730 async fn has_uncommitted_changes_reflects_status() {
731 let dirty = git_repo(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0")));
732 assert!(dirty.has_uncommitted_changes().await.unwrap());
733 let clean = git_repo(ScriptedRunner::new().on(["status"], Reply::ok("")));
734 assert!(!clean.has_uncommitted_changes().await.unwrap());
735 }
736
737 #[tokio::test]
738 async fn at_rebinds_cwd_and_shares_backend() {
739 let repo = git_repo(ScriptedRunner::new());
740 let moved = repo.at("/repo/sub");
741 assert_eq!(moved.cwd(), Path::new("/repo/sub"));
742 assert_eq!(moved.root(), Path::new("/repo"));
743 assert_eq!(moved.kind(), BackendKind::Git);
744 }
745
746 #[tokio::test]
749 async fn jj_kind_and_escape_hatches_reflect_backend() {
750 let repo = jj_repo(ScriptedRunner::new());
751 assert_eq!(repo.kind(), BackendKind::Jj);
752 assert!(repo.jj().is_some() && repo.git().is_none());
753 }
754
755 #[tokio::test]
756 async fn jj_current_branch_reads_bookmark() {
757 let repo = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("main\n")));
758 assert_eq!(
759 repo.current_branch().await.unwrap().as_deref(),
760 Some("main")
761 );
762 }
763
764 #[tokio::test]
765 async fn jj_local_branches_maps_bookmark_list() {
766 let repo = jj_repo(ScriptedRunner::new().on(
767 ["bookmark", "list"],
768 Reply::ok("main: chg cmt desc\nfeat: c2 m2 d2\n"),
769 ));
770 assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
771 }
772
773 #[tokio::test]
774 async fn jj_branch_exists_scans_bookmarks() {
775 let repo = jj_repo(
776 ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
777 );
778 assert!(repo.branch_exists("main").await.unwrap());
779 let repo2 = jj_repo(
780 ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
781 );
782 assert!(!repo2.branch_exists("missing").await.unwrap());
783 }
784
785 #[tokio::test]
786 async fn jj_has_uncommitted_changes_reads_empty_flag() {
787 let dirty = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\tfalse\twip\n")));
789 assert!(dirty.has_uncommitted_changes().await.unwrap());
790 let clean = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\ttrue\t\n")));
791 assert!(!clean.has_uncommitted_changes().await.unwrap());
792 }
793
794 #[tokio::test]
795 async fn jj_changed_files_maps_diff_summary() {
796 let repo = jj_repo(
797 ScriptedRunner::new().on(["diff"], Reply::ok("M src/a.rs\nA b.rs\nD gone.rs\n")),
798 );
799 let changes = repo.changed_files().await.unwrap();
800 assert_eq!(changes.len(), 3);
801 assert_eq!(changes[0].kind, ChangeKind::Modified);
802 assert_eq!(changes[1].kind, ChangeKind::Added);
803 assert_eq!(changes[2].kind, ChangeKind::Deleted);
804 assert!(changes.iter().all(|c| c.old_path.is_none()));
805 }
806
807 #[tokio::test]
808 async fn jj_rename_branch_builds_bookmark_rename() {
809 use processkit::RecordingRunner;
810 let rec = RecordingRunner::replying(Reply::ok(""));
811 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
812 repo.rename_branch("old", "new").await.unwrap();
813 assert_eq!(
814 rec.only_call().args_str(),
815 ["bookmark", "rename", "old", "new", "--color", "never"]
816 );
817 }
818
819 #[tokio::test]
822 async fn checkout_dispatches_per_backend() {
823 use processkit::RecordingRunner;
824 let grec = RecordingRunner::replying(Reply::ok(""));
825 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
826 .checkout("feat")
827 .await
828 .unwrap();
829 assert_eq!(grec.only_call().args_str(), ["checkout", "feat"]);
830
831 let jrec = RecordingRunner::replying(Reply::ok(""));
832 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
833 .checkout("feat")
834 .await
835 .unwrap();
836 assert_eq!(
837 jrec.only_call().args_str(),
838 ["edit", "feat", "--color", "never"]
839 );
840 }
841
842 #[tokio::test]
843 async fn fetch_remote_branch_dispatches_per_backend() {
844 use processkit::RecordingRunner;
845 let grec = RecordingRunner::replying(Reply::ok(""));
846 Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
847 .fetch_remote_branch("main")
848 .await
849 .unwrap();
850 assert!(
851 grec.only_call()
852 .args_str()
853 .starts_with(&["fetch".to_string()])
854 );
855
856 let jrec = RecordingRunner::replying(Reply::ok(""));
857 Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
858 .fetch_remote_branch("main")
859 .await
860 .unwrap();
861 let args = jrec.only_call().args_str();
862 assert_eq!(&args[..2], &["git", "fetch"]);
863 }
864
865 #[tokio::test]
867 async fn jj_in_progress_state_maps_conflict() {
868 let conflicted = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("1\n")));
869 assert_eq!(
870 conflicted.in_progress_state().await.unwrap(),
871 OperationState::Conflict
872 );
873 let clear = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("0\n")));
874 assert_eq!(
875 clear.in_progress_state().await.unwrap(),
876 OperationState::Clear
877 );
878 }
879
880 #[tokio::test]
883 async fn vcs_repo_trait_object_dispatches() {
884 let repo = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("main\n")));
885 let dynamic: &dyn VcsRepo = &repo;
886 assert_eq!(dynamic.kind(), BackendKind::Git);
887 assert_eq!(
888 dynamic.current_branch().await.unwrap().as_deref(),
889 Some("main")
890 );
891 }
892
893 #[tokio::test]
896 async fn trunk_falls_back_to_main() {
897 let repo = git_repo(
898 ScriptedRunner::new()
899 .on(["symbolic-ref"], Reply::fail(1, "")) .on(["show-ref"], Reply::ok("")), );
902 assert_eq!(repo.trunk().await.unwrap().as_deref(), Some("main"));
903 }
904
905 #[test]
906 fn error_classifiers_recognise_markers() {
907 let conflict = Error::Vcs(processkit::Error::Exit {
908 program: "git".into(),
909 code: 1,
910 stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
911 stderr: String::new(),
912 });
913 assert!(conflict.is_conflict());
914 assert!(!conflict.is_nothing_to_commit());
915 assert!(!Error::NotARepository("/x".into()).is_conflict());
917 }
918}