1use std::path::{Path, PathBuf};
28use std::sync::Arc;
29
30use processkit::{JobRunner, ProcessRunner};
31use vcs_git::Git;
32use vcs_jj::Jj;
33
34mod dto;
35mod error;
36mod git_backend;
37mod jj_backend;
38
39pub use dto::{BackendKind, ChangeKind, CreateOutcome, DiffStat, FileChange, WorktreeInfo};
40pub use error::{Error, Result};
41
42pub use vcs_git;
49pub use vcs_jj;
50
51#[derive(Debug, Clone, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct Located {
56 pub kind: BackendKind,
58 pub root: PathBuf,
60}
61
62pub fn detect(start: &Path) -> Option<Located> {
71 let mut current = Some(start);
72 while let Some(dir) = current {
73 if dir.join(".jj").is_dir() {
74 return Some(Located {
75 kind: BackendKind::Jj,
76 root: dir.to_path_buf(),
77 });
78 }
79 if dir.join(".git").exists() {
80 return Some(Located {
81 kind: BackendKind::Git,
82 root: dir.to_path_buf(),
83 });
84 }
85 current = dir.parent();
86 }
87 None
88}
89
90enum Backend<R: ProcessRunner> {
93 Git(Arc<Git<R>>),
94 Jj(Arc<Jj<R>>),
95}
96
97impl<R: ProcessRunner> Backend<R> {
98 fn shared(&self) -> Self {
99 match self {
100 Backend::Git(g) => Backend::Git(Arc::clone(g)),
101 Backend::Jj(j) => Backend::Jj(Arc::clone(j)),
102 }
103 }
104}
105
106pub struct Repo<R: ProcessRunner = JobRunner> {
110 root: PathBuf,
111 cwd: PathBuf,
112 backend: Backend<R>,
113}
114
115impl Repo<JobRunner> {
116 pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
120 let dir = std::path::absolute(dir.as_ref())?;
124 let located = detect(&dir).ok_or_else(|| Error::NotARepository(dir.clone()))?;
125 let backend = match located.kind {
126 BackendKind::Git => Backend::Git(Arc::new(Git::new())),
127 BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
128 };
129 Ok(Repo {
130 root: located.root,
131 cwd: dir,
132 backend,
133 })
134 }
135}
136
137impl<R: ProcessRunner> Repo<R> {
138 pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self {
141 Repo {
142 root: root.into(),
143 cwd: cwd.into(),
144 backend: Backend::Git(Arc::new(client)),
145 }
146 }
147
148 pub fn from_jj(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>) -> Self {
150 Repo {
151 root: root.into(),
152 cwd: cwd.into(),
153 backend: Backend::Jj(Arc::new(client)),
154 }
155 }
156
157 pub fn kind(&self) -> BackendKind {
159 match &self.backend {
160 Backend::Git(_) => BackendKind::Git,
161 Backend::Jj(_) => BackendKind::Jj,
162 }
163 }
164
165 pub fn root(&self) -> &Path {
167 &self.root
168 }
169
170 pub fn cwd(&self) -> &Path {
172 &self.cwd
173 }
174
175 pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
177 Repo {
178 root: self.root.clone(),
179 cwd: dir.into(),
180 backend: self.backend.shared(),
181 }
182 }
183
184 pub fn git(&self) -> Option<&Git<R>> {
187 match &self.backend {
188 Backend::Git(g) => Some(g.as_ref()),
189 Backend::Jj(_) => None,
190 }
191 }
192
193 pub fn jj(&self) -> Option<&Jj<R>> {
195 match &self.backend {
196 Backend::Jj(j) => Some(j.as_ref()),
197 Backend::Git(_) => None,
198 }
199 }
200
201 pub async fn current_branch(&self) -> Result<Option<String>> {
204 match &self.backend {
205 Backend::Git(g) => git_backend::current_branch(g, &self.cwd).await,
206 Backend::Jj(j) => jj_backend::current_branch(j, &self.cwd).await,
207 }
208 }
209
210 pub async fn trunk(&self) -> Result<Option<String>> {
212 match &self.backend {
213 Backend::Git(g) => git_backend::trunk(g, &self.cwd).await,
214 Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await,
215 }
216 }
217
218 pub async fn local_branches(&self) -> Result<Vec<String>> {
220 match &self.backend {
221 Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
222 Backend::Jj(j) => jj_backend::local_branches(j, &self.cwd).await,
223 }
224 }
225
226 pub async fn branch_exists(&self, name: &str) -> Result<bool> {
228 match &self.backend {
229 Backend::Git(g) => git_backend::branch_exists(g, &self.cwd, name).await,
230 Backend::Jj(j) => jj_backend::branch_exists(j, &self.cwd, name).await,
231 }
232 }
233
234 pub async fn has_uncommitted_changes(&self) -> Result<bool> {
237 match &self.backend {
238 Backend::Git(g) => git_backend::has_uncommitted_changes(g, &self.cwd).await,
239 Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
240 }
241 }
242
243 pub async fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
246 match &self.backend {
247 Backend::Git(g) => git_backend::delete_branch(g, &self.cwd, name, force).await,
248 Backend::Jj(j) => jj_backend::delete_branch(j, &self.cwd, name).await,
249 }
250 }
251
252 pub async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
254 match &self.backend {
255 Backend::Git(g) => git_backend::rename_branch(g, &self.cwd, old, new).await,
256 Backend::Jj(j) => jj_backend::rename_branch(j, &self.cwd, old, new).await,
257 }
258 }
259
260 pub async fn changed_files(&self) -> Result<Vec<FileChange>> {
262 match &self.backend {
263 Backend::Git(g) => git_backend::changed_files(g, &self.cwd).await,
264 Backend::Jj(j) => jj_backend::changed_files(j, &self.cwd).await,
265 }
266 }
267
268 pub async fn diff_stat(&self) -> Result<DiffStat> {
270 match &self.backend {
271 Backend::Git(g) => git_backend::diff_stat(g, &self.cwd).await,
272 Backend::Jj(j) => jj_backend::diff_stat(j, &self.cwd).await,
273 }
274 }
275
276 pub async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
279 match &self.backend {
280 Backend::Git(g) => git_backend::commit_paths(g, &self.cwd, paths, message).await,
281 Backend::Jj(j) => jj_backend::commit_paths(j, &self.cwd, paths, message).await,
282 }
283 }
284
285 pub async fn fetch(&self) -> Result<()> {
287 match &self.backend {
288 Backend::Git(g) => git_backend::fetch(g, &self.cwd).await,
289 Backend::Jj(j) => jj_backend::fetch(j, &self.cwd).await,
290 }
291 }
292
293 pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
295 match &self.backend {
296 Backend::Git(g) => git_backend::list_worktrees(g, &self.cwd).await,
297 Backend::Jj(j) => jj_backend::list_worktrees(j, &self.cwd).await,
298 }
299 }
300
301 pub async fn create_worktree(
311 &self,
312 path: &Path,
313 branch: &str,
314 base: &str,
315 ) -> Result<CreateOutcome> {
316 match &self.backend {
317 Backend::Git(g) => git_backend::create_worktree(g, &self.cwd, path, branch, base).await,
318 Backend::Jj(j) => jj_backend::create_worktree(j, &self.cwd, path, branch, base).await,
319 }
320 }
321
322 pub async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()> {
325 match &self.backend {
326 Backend::Git(g) => git_backend::remove_worktree(g, &self.cwd, path, force).await,
327 Backend::Jj(j) => jj_backend::remove_worktree(j, &self.cwd, path, force).await,
328 }
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use processkit::{Reply, ScriptedRunner};
336
337 struct TempDir(PathBuf);
341 impl TempDir {
342 fn new(tag: &str) -> Self {
343 use std::sync::atomic::{AtomicU64, Ordering};
346 static COUNTER: AtomicU64 = AtomicU64::new(0);
347 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
348 let dir =
349 std::env::temp_dir().join(format!("vcs-core-{tag}-{}-{n}", std::process::id()));
350 std::fs::create_dir_all(&dir).expect("create temp dir");
351 TempDir(dir)
352 }
353 fn path(&self) -> &Path {
354 &self.0
355 }
356 }
357 impl Drop for TempDir {
358 fn drop(&mut self) {
359 let _ = std::fs::remove_dir_all(&self.0);
360 }
361 }
362
363 #[test]
364 fn detect_finds_git_and_jj_and_prefers_jj() {
365 let tmp = TempDir::new("detect");
366 let root = tmp.path();
367
368 std::fs::create_dir_all(root.join(".git")).unwrap();
370 let located = detect(root).expect("git detected");
371 assert_eq!(located.kind, BackendKind::Git);
372 assert_eq!(located.root, root);
373
374 std::fs::create_dir_all(root.join(".jj")).unwrap();
376 assert_eq!(detect(root).unwrap().kind, BackendKind::Jj);
377 }
378
379 #[test]
380 fn detect_walks_up_to_ancestor() {
381 let tmp = TempDir::new("walkup");
382 let root = tmp.path();
383 std::fs::create_dir_all(root.join(".git")).unwrap();
384 let nested = root.join("a").join("b");
385 std::fs::create_dir_all(&nested).unwrap();
386 let located = detect(&nested).expect("found via ancestor walk");
387 assert_eq!(located.kind, BackendKind::Git);
388 assert_eq!(located.root, root);
389 }
390
391 #[test]
392 fn detect_returns_none_outside_repo() {
393 let tmp = TempDir::new("norepo");
394 assert!(detect(tmp.path()).is_none());
395 }
396
397 fn git_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
400 Repo::from_git("/repo", "/repo", Git::with_runner(runner))
401 }
402
403 fn jj_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
404 Repo::from_jj("/repo", "/repo", Jj::with_runner(runner))
405 }
406
407 #[tokio::test]
408 async fn kind_and_escape_hatches_reflect_backend() {
409 let repo = git_repo(ScriptedRunner::new());
410 assert_eq!(repo.kind(), BackendKind::Git);
411 assert!(repo.git().is_some());
412 assert!(repo.jj().is_none());
413 }
414
415 #[tokio::test]
416 async fn current_branch_maps_detached_head_to_none() {
417 let named = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("main\n")));
418 assert_eq!(
419 named.current_branch().await.unwrap().as_deref(),
420 Some("main")
421 );
422 let detached = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("HEAD\n")));
423 assert!(detached.current_branch().await.unwrap().is_none());
424 }
425
426 #[tokio::test]
427 async fn changed_files_maps_git_status() {
428 let repo = git_repo(ScriptedRunner::new().on(
429 ["status"],
430 Reply::ok(" M a.rs\0?? b.rs\0R new.rs\0old.rs\0"),
431 ));
432 let changes = repo.changed_files().await.unwrap();
433 assert_eq!(changes.len(), 3);
434 assert_eq!(changes[0].kind, ChangeKind::Modified);
435 assert_eq!(changes[1].kind, ChangeKind::Added);
436 assert_eq!(changes[2].kind, ChangeKind::Renamed);
437 assert_eq!(changes[2].old_path.as_deref(), Some("old.rs"));
438 }
439
440 #[tokio::test]
441 async fn local_branches_maps_git_branch_output() {
442 let repo = git_repo(ScriptedRunner::new().on(["branch"], Reply::ok("* main\n feat\n")));
443 assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
444 }
445
446 #[tokio::test]
447 async fn branch_exists_reads_show_ref_exit() {
448 let yes = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
449 assert!(yes.branch_exists("main").await.unwrap());
450 let no = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
451 assert!(!no.branch_exists("nope").await.unwrap());
452 }
453
454 #[tokio::test]
455 async fn has_uncommitted_changes_reflects_status() {
456 let dirty = git_repo(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0")));
457 assert!(dirty.has_uncommitted_changes().await.unwrap());
458 let clean = git_repo(ScriptedRunner::new().on(["status"], Reply::ok("")));
459 assert!(!clean.has_uncommitted_changes().await.unwrap());
460 }
461
462 #[tokio::test]
463 async fn at_rebinds_cwd_and_shares_backend() {
464 let repo = git_repo(ScriptedRunner::new());
465 let moved = repo.at("/repo/sub");
466 assert_eq!(moved.cwd(), Path::new("/repo/sub"));
467 assert_eq!(moved.root(), Path::new("/repo"));
468 assert_eq!(moved.kind(), BackendKind::Git);
469 }
470
471 #[tokio::test]
474 async fn jj_kind_and_escape_hatches_reflect_backend() {
475 let repo = jj_repo(ScriptedRunner::new());
476 assert_eq!(repo.kind(), BackendKind::Jj);
477 assert!(repo.jj().is_some() && repo.git().is_none());
478 }
479
480 #[tokio::test]
481 async fn jj_current_branch_reads_bookmark() {
482 let repo = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("main\n")));
483 assert_eq!(
484 repo.current_branch().await.unwrap().as_deref(),
485 Some("main")
486 );
487 }
488
489 #[tokio::test]
490 async fn jj_local_branches_maps_bookmark_list() {
491 let repo = jj_repo(ScriptedRunner::new().on(
492 ["bookmark", "list"],
493 Reply::ok("main: chg cmt desc\nfeat: c2 m2 d2\n"),
494 ));
495 assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
496 }
497
498 #[tokio::test]
499 async fn jj_branch_exists_scans_bookmarks() {
500 let repo = jj_repo(
501 ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
502 );
503 assert!(repo.branch_exists("main").await.unwrap());
504 let repo2 = jj_repo(
505 ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
506 );
507 assert!(!repo2.branch_exists("missing").await.unwrap());
508 }
509
510 #[tokio::test]
511 async fn jj_has_uncommitted_changes_reads_empty_flag() {
512 let dirty = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\tfalse\twip\n")));
514 assert!(dirty.has_uncommitted_changes().await.unwrap());
515 let clean = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\ttrue\t\n")));
516 assert!(!clean.has_uncommitted_changes().await.unwrap());
517 }
518
519 #[tokio::test]
520 async fn jj_changed_files_maps_diff_summary() {
521 let repo = jj_repo(
522 ScriptedRunner::new().on(["diff"], Reply::ok("M src/a.rs\nA b.rs\nD gone.rs\n")),
523 );
524 let changes = repo.changed_files().await.unwrap();
525 assert_eq!(changes.len(), 3);
526 assert_eq!(changes[0].kind, ChangeKind::Modified);
527 assert_eq!(changes[1].kind, ChangeKind::Added);
528 assert_eq!(changes[2].kind, ChangeKind::Deleted);
529 assert!(changes.iter().all(|c| c.old_path.is_none()));
530 }
531
532 #[tokio::test]
533 async fn jj_rename_branch_builds_bookmark_rename() {
534 use processkit::RecordingRunner;
535 let rec = RecordingRunner::replying(Reply::ok(""));
536 let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
537 repo.rename_branch("old", "new").await.unwrap();
538 assert_eq!(
539 rec.only_call().args_str(),
540 ["bookmark", "rename", "old", "new"]
541 );
542 }
543}