1use std::cell::{Cell, RefCell};
7use std::collections::HashMap;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use crate::diffalgo::{DiffAlgorithm, DiffSettings};
14use crate::git::GitVcs;
15use crate::model::{DiffModel, HunkId};
16use crate::vcs::{
17 BlameSpan, Bookmark, BranchInfo, ChangeInfo, ChangeOp, FileVersion, HeadInfo, JjView, LogEntry,
18 NetworkOp, PullOutcome, PullTarget, StatusModel, Vcs, VcsError, Worktree, WorktreeOp,
19};
20
21const NO_DESCRIPTION: &str = "(no description set)";
22const FIELD_SEPARATOR: char = '\x1f';
23const LIST_SEPARATOR: char = '\x1e';
24const COMMIT_ID_TEMPLATE: &str = r#"commit_id ++ "\n""#;
25const WORKSPACE_TEMPLATE: &str = r#"name ++ "\x1f" ++ root ++ "\x1f" ++ target.description().first_line() ++ "\x1f" ++ target.committer().timestamp().format("%s") ++ "\n""#;
26const NEAREST_BOOKMARKS: &str = "heads(::@ & (bookmarks() | remote_bookmarks()))";
28const HISTORY: &str = "::(@ | bookmarks() | remote_bookmarks() | tags())";
30
31pub struct JjVcs {
32 git: GitVcs,
33 root: PathBuf,
34 colocated: bool,
35 view: RefCell<JjView>,
36 base_known: Cell<bool>,
39}
40
41impl JjVcs {
42 pub fn open(root: &Path) -> Result<Self, VcsError> {
43 Self::open_with_settings(root, &DiffSettings::default())
44 }
45
46 pub fn open_with_settings(root: &Path, settings: &DiffSettings) -> Result<Self, VcsError> {
47 let colocated = root.join(".git").exists();
48 let git = if colocated {
49 GitVcs::open_with_settings(root, settings)?
50 } else {
51 GitVcs::open_jj_store(&store_git_dir(root)?, root, settings)?
52 };
53 Ok(Self {
54 git,
55 root: root.to_path_buf(),
56 colocated,
57 view: RefCell::default(),
58 base_known: Cell::new(false),
59 })
60 }
61
62 fn query(
65 &self,
66 revset: &str,
67 template: &str,
68 limit: Option<usize>,
69 ) -> Result<String, VcsError> {
70 let mut args = vec![
71 "log".to_owned(),
72 "--no-graph".to_owned(),
73 "--ignore-working-copy".to_owned(),
74 format!("--revisions={revset}"),
75 format!("--template={template}"),
76 ];
77 if let Some(limit) = limit {
78 args.push(format!("--limit={limit}"));
79 }
80 let args: Vec<&str> = args.iter().map(String::as_str).collect();
81 self.run(&args)
82 }
83
84 fn read_state(&self) -> Result<JjView, VcsError> {
87 let revset = if self.colocated {
88 format!("@ | {NEAREST_BOOKMARKS}")
89 } else {
90 "@ | bookmarks() | remote_bookmarks()".to_owned()
91 };
92 let marker = format!(
93 r#"if(self.current_working_copy(), "@", if(self.contained_in("{NEAREST_BOOKMARKS}"), "^", "-"))"#
94 );
95 let template = line_template(&[
96 &marker,
97 "commit_id",
98 "change_id.short(8)",
99 "description.first_line()",
100 &list_template("parents.map(|c| c.commit_id())"),
101 &list_template("local_bookmarks.map(|b| b.name())"),
102 &list_template(r#"remote_bookmarks.map(|b| b.name() ++ "@" ++ b.remote())"#),
103 r#"committer.timestamp().format("%s")"#,
104 ]);
105 let out = self.query(&revset, &template, None)?;
106 Ok(parse_state(&out, !self.colocated))
107 }
108
109 fn read_change_ids(&self, limit: usize) -> Result<HashMap<String, String>, VcsError> {
112 let template = line_template(&["commit_id", "change_id.short(8)"]);
113 let out = self.query(HISTORY, &template, Some(limit))?;
114 Ok(out
115 .lines()
116 .filter_map(|line| line.split_once(FIELD_SEPARATOR))
117 .map(|(oid, change)| (oid.to_owned(), change.to_owned()))
118 .collect())
119 }
120
121 fn ensure_base(&self) -> Result<(), VcsError> {
125 if self.colocated || self.base_known.get() {
126 return Ok(());
127 }
128 self.reread_state()
129 }
130
131 fn reread_state(&self) -> Result<(), VcsError> {
133 let state = self.read_state()?;
134 let change_ids = std::mem::take(&mut self.view.borrow_mut().change_ids);
135 self.adopt_jj_view(JjView {
136 change_ids,
137 ..state
138 });
139 Ok(())
140 }
141
142 fn decorated(&self, mut entries: Vec<LogEntry>) -> Vec<LogEntry> {
146 let view = self.view.borrow();
147 for entry in &mut entries {
148 entry.change_id = view.change_ids.get(&entry.oid).cloned();
149 if self.colocated {
150 entry.refs.retain(|name| !name.starts_with("jj/keep/"));
151 } else {
152 entry.refs = view
153 .bookmarks
154 .iter()
155 .filter(|bookmark| bookmark.oid == entry.oid)
156 .map(|bookmark| bookmark.name.clone())
157 .collect();
158 }
159 if entry.subject.is_empty() {
160 NO_DESCRIPTION.clone_into(&mut entry.subject);
161 }
162 }
163 entries
164 }
165
166 fn bookmark_oid(&self, name: &str) -> Option<String> {
168 self.view
169 .borrow()
170 .bookmarks
171 .iter()
172 .find(|bookmark| bookmark.name == name)
173 .map(|bookmark| bookmark.oid.clone())
174 }
175
176 fn resolve_revset(&self, revision: &str) -> Result<String, VcsError> {
178 let out = self.query(revision, COMMIT_ID_TEMPLATE, Some(2))?;
179 match out.lines().collect::<Vec<_>>().as_slice() {
180 [oid] if is_root(oid) => Err(VcsError::Rejected(format!(
181 "`{revision}` names jj's root commit, which git has no commit for; name another"
182 ))),
183 [oid] => Ok((*oid).to_owned()),
184 [] => Err(VcsError::Rejected(format!(
185 "`{revision}` matches no commit, name one that exists"
186 ))),
187 _ => Err(VcsError::Rejected(format!(
188 "`{revision}` matches several commits, name one"
189 ))),
190 }
191 }
192
193 fn run(&self, args: &[&str]) -> Result<String, VcsError> {
196 let output = Command::new("jj")
197 .current_dir(&self.root)
198 .args(args)
199 .env("JJ_EDITOR", "false")
202 .output()
203 .map_err(|err| match err.kind() {
204 io::ErrorKind::NotFound => VcsError::Rejected(
205 "install jj (jujutsu) on PATH to read and change this repo".into(),
206 ),
207 _ => VcsError::Io(err),
208 })?;
209 if output.status.success() {
210 return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
211 }
212 let stderr = String::from_utf8_lossy(&output.stderr);
213 let mut lines = stderr.lines().map(str::trim);
214 let message = lines
215 .clone()
216 .find(|line| line.starts_with("Error:"))
217 .or_else(|| lines.find(|line| !line.is_empty()))
218 .unwrap_or("run the jj command in a shell to see why it failed")
219 .to_owned();
220 Err(VcsError::Rejected(message))
221 }
222
223 fn head_oid(&self) -> Result<String, VcsError> {
226 if !self.colocated {
227 self.reread_state()?;
228 }
229 self.git.resolve("HEAD")
230 }
231
232 fn target(&self, rev: &str) -> Result<String, VcsError> {
236 if is_working_copy_revset(rev) {
237 return Ok(rev.to_owned());
238 }
239 let change = self.query("ed(rev), "change_id", Some(1))?;
240 if change.is_empty() {
241 return Err(VcsError::Rejected(format!(
242 "`{rev}` names no commit; refresh and pick another"
243 )));
244 }
245 Ok(format!("change_id({})", quoted(&change)))
246 }
247
248 fn refuse_over_snapshot(&self, verb: &str) -> Result<(), VcsError> {
252 self.run(&["util", "snapshot"])?;
253 let newest = self.run(&[
254 "op",
255 "log",
256 "--ignore-working-copy",
257 "--no-graph",
258 "--limit=1",
259 "--template=description",
260 ])?;
261 if newest.starts_with("snapshot working copy") {
262 return Err(VcsError::Rejected(format!(
263 "describe or commit your edits first; {verb} would drop them"
264 )));
265 }
266 Ok(())
267 }
268
269 fn squash(&self, rev: &str) -> Result<String, VcsError> {
272 let target = self.target(rev)?;
273 let source = self.query(&target, "description", Some(1))?;
274 let destination = self.query(&format!("({target})-"), "description", Some(1))?;
275 let revision = format!("--revision={target}");
276 if source.is_empty() || destination.is_empty() {
277 return self.run(&["squash", &revision]);
278 }
279 self.run(&[
280 "squash",
281 &revision,
282 &format!("--message={destination}\n\n{source}"),
283 ])
284 }
285
286 fn nearest_bookmark(&self) -> Result<NearestBookmark, VcsError> {
288 let template = line_template(&[
289 r#"if(self.current_working_copy(), "@", if(self.contained_in("@-"), "-", "^"))"#,
290 r#"if(immutable, "1", "0")"#,
291 &list_template("local_bookmarks.map(|b| b.name())"),
292 ]);
293 let out = self.query("@- | heads(::@ & bookmarks())", &template, None)?;
294 Ok(parse_nearest_bookmark(&out))
295 }
296
297 fn push_selection(&self) -> Result<String, VcsError> {
302 let nearest = self.nearest_bookmark()?;
303 match nearest.name {
304 Some(name) if nearest.on_working_line => {
305 return Ok(format!("--bookmark=exact:{}", quoted(&name)));
306 }
307 Some(name) if !nearest.immutable => {
308 return Err(VcsError::Rejected(format!(
309 "move {name} to @- first (jj bookmark move {name} --to @-), then push"
310 )));
311 }
312 _ => {}
313 }
314 if nearest.parent_immutable {
315 return Err(VcsError::Rejected(
316 "commit a change on top of @- first; there is nothing new to push".into(),
317 ));
318 }
319 Ok("--change=@-".into())
320 }
321}
322
323fn store_git_dir(root: &Path) -> Result<PathBuf, VcsError> {
326 if root.join(".jj/repo").is_file() {
328 return Err(VcsError::Rejected(
329 "open the repository's main jj workspace; secondary workspaces aren't supported yet"
330 .into(),
331 ));
332 }
333 let store = root.join(".jj/repo/store");
334 let target = fs::read_to_string(store.join("git_target"))?;
335 Ok(store.join(target.trim()))
336}
337
338fn line_template(fields: &[&str]) -> String {
340 let separator = format!(r#" ++ "\x{:02x}" ++ "#, u32::from(FIELD_SEPARATOR));
341 format!(r#"{} ++ "\n""#, fields.join(&separator))
342}
343
344fn list_template(list: &str) -> String {
345 format!(r#"{list}.join("\x{:02x}")"#, u32::from(LIST_SEPARATOR))
346}
347
348fn split_list(list: &str) -> impl Iterator<Item = &str> {
349 list.split(LIST_SEPARATOR).filter(|item| !item.is_empty())
350}
351
352fn real_remotes(list: &str) -> impl Iterator<Item = &str> {
354 split_list(list).filter(|name| !name.ends_with("@git"))
355}
356
357fn parse_state(out: &str, bookmarks: bool) -> JjView {
360 let mut view = JjView::default();
361 let mut nearest = None;
362 for line in out.lines() {
363 let fields: Vec<&str> = line.split(FIELD_SEPARATOR).collect();
364 let &[
365 marker,
366 oid,
367 change_id,
368 description,
369 parents,
370 local,
371 remote,
372 time,
373 ] = fields.as_slice()
374 else {
375 continue;
376 };
377 let bookmark = || split_list(local).chain(real_remotes(remote)).next();
378 if marker == "@" {
379 view.base = split_list(parents)
380 .next()
381 .filter(|id| !is_root(id))
382 .map(str::to_owned);
383 view.change = Some(ChangeInfo {
384 change_id: change_id.to_owned(),
385 commit_id: oid.to_owned(),
386 bookmark: bookmark().map(str::to_owned),
387 description: description.to_owned(),
388 });
389 } else if marker == "^" && nearest.is_none() {
390 nearest = bookmark();
391 }
392 if bookmarks {
393 let tip_unix = time.parse().unwrap_or_default();
394 let names = split_list(local)
395 .map(|name| (name, false))
396 .chain(real_remotes(remote).map(|name| (name, true)));
397 view.bookmarks.extend(names.map(|(name, remote)| Bookmark {
398 name: name.to_owned(),
399 oid: oid.to_owned(),
400 remote,
401 tip_unix,
402 }));
403 }
404 }
405 if let Some(change) = view.change.as_mut()
406 && change.bookmark.is_none()
407 {
408 change.bookmark = nearest.map(str::to_owned);
409 }
410 view
411}
412
413fn split_ancestry(revision: &str) -> (&str, &str) {
416 let kept = revision
417 .trim_end_matches(|c: char| matches!(c, '~' | '^') || c.is_ascii_digit())
418 .len();
419 revision
420 .get(kept..)
421 .and_then(|tail| tail.find(['~', '^']))
422 .map(|offset| kept + offset)
423 .filter(|&at| at > 0)
424 .and_then(|at| revision.split_at_checked(at))
425 .unwrap_or((revision, ""))
426}
427
428fn is_root(oid: &str) -> bool {
430 oid.bytes().all(|byte| byte == b'0')
431}
432
433fn is_working_copy_revset(revision: &str) -> bool {
436 revision == "@" || revision.starts_with("@-") || revision.starts_with("@+")
437}
438
439fn quoted(s: &str) -> String {
442 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
443}
444
445fn root_file(path: &str) -> String {
447 format!("root-file:{}", quoted(path))
448}
449
450struct NearestBookmark {
453 name: Option<String>,
454 on_working_line: bool,
456 immutable: bool,
457 parent_immutable: bool,
458}
459
460fn parse_nearest_bookmark(out: &str) -> NearestBookmark {
462 let mut nearest = NearestBookmark {
463 name: None,
464 on_working_line: false,
465 immutable: false,
466 parent_immutable: false,
467 };
468 for line in out.lines() {
469 let fields: Vec<&str> = line.split(FIELD_SEPARATOR).collect();
470 let &[marker, immutable, names] = fields.as_slice() else {
471 continue;
472 };
473 let immutable = immutable == "1";
474 if marker == "-" && immutable {
475 nearest.parent_immutable = true;
476 }
477 if nearest.name.is_none()
478 && let Some(name) = split_list(names).next()
479 {
480 nearest.name = Some(name.to_owned());
481 nearest.on_working_line = marker != "^";
482 nearest.immutable = immutable;
483 }
484 }
485 nearest
486}
487
488const EMPTY_MESSAGE: &str = "write a commit message first";
489const NO_STAGING: &str = "commit the whole working copy; jj has no staging area";
490const NO_STASH: &str = "start a new change from the change menu instead; jj has no stash";
491const NO_PULL_MODE: &str = "pull with p p; jj rebases @ onto the fetched bookmark";
492
493impl Vcs for JjVcs {
494 fn has_index(&self) -> bool {
495 false
496 }
497
498 fn native_git_checkout(&self) -> bool {
499 false
500 }
501
502 fn git_dir(&self) -> Result<PathBuf, VcsError> {
503 self.git.git_dir()
504 }
505
506 fn head(&self) -> Result<HeadInfo, VcsError> {
507 self.ensure_base()?;
508 let mut head = self.git.head()?;
509 if head.oid7.is_empty() {
510 head.branch = None;
511 }
512 Ok(head)
513 }
514
515 fn read_jj_view(&self, log_limit: usize) -> Result<Option<JjView>, VcsError> {
518 let view = JjView {
519 change_ids: self.read_change_ids(log_limit.saturating_mul(2))?,
520 ..self.read_state()?
521 };
522 self.adopt_jj_view(view.clone());
523 Ok(Some(view))
524 }
525
526 fn adopt_jj_view(&self, view: JjView) {
527 if !self.colocated {
528 self.base_known.set(true);
529 self.git.set_base(view.base.as_deref()).ok();
531 }
532 *self.view.borrow_mut() = view;
533 }
534
535 fn status(&self) -> Result<StatusModel, VcsError> {
538 Ok(StatusModel {
539 untracked: DiffModel::default(),
540 unstaged: DiffModel::default(),
541 staged: self.working_tree_diff()?,
542 })
543 }
544
545 fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
546 self.ensure_base()?;
547 self.git.working_tree_diff()
548 }
549
550 fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError> {
551 self.git.tree_to_workdir_diff(base_oid)
552 }
553
554 fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
555 self.git.commit_diff(oid)
556 }
557
558 fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
559 self.git.range_diff(oldest_oid, newest_oid)
560 }
561
562 fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
563 self.git.tree_diff(base_oid, newest_oid)
564 }
565
566 fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError> {
567 self.git.merge_base(a, b)
568 }
569
570 fn resolve(&self, revision: &str) -> Result<String, VcsError> {
578 if is_working_copy_revset(revision) {
579 return self.resolve_revset(revision);
580 }
581 self.ensure_base()?;
582 let (name, ancestry) = split_ancestry(revision);
583 if !self.colocated
584 && let Some(oid) = self.bookmark_oid(name)
585 {
586 return self.git.resolve(&format!("{oid}{ancestry}"));
587 }
588 let resolved = self.git.resolve(revision);
589 if resolved.is_ok() || name == "HEAD" {
591 return resolved;
592 }
593 if !ancestry.is_empty() {
594 return Err(VcsError::Rejected(format!(
595 "`{name}` names no bookmark, ref or commit; name one before `{ancestry}`"
596 )));
597 }
598 self.resolve_revset(revision)
599 }
600
601 fn log(&self, limit: usize, all_branches: bool) -> Result<Vec<LogEntry>, VcsError> {
602 self.ensure_base()?;
603 let entries = {
604 let view = self.view.borrow();
605 let tips: Vec<&str> = view
606 .bookmarks
607 .iter()
608 .map(|bookmark| bookmark.oid.as_str())
609 .filter(|oid| !is_root(oid))
610 .collect();
611 self.git.log_from(limit, all_branches, &tips)?
612 };
613 Ok(self.decorated(entries))
614 }
615
616 fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError> {
617 self.git.default_branch(remote)
618 }
619
620 fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError> {
621 self.ensure_base()?;
622 Ok(self.decorated(self.git.commits_between(base, head)?))
623 }
624
625 fn unpushed(&self, limit: usize) -> Result<Option<Vec<LogEntry>>, VcsError> {
626 self.ensure_base()?;
627 Ok(self
628 .git
629 .unpushed(limit)?
630 .map(|entries| self.decorated(entries)))
631 }
632
633 fn blame(&self, rel: &Path) -> Result<Vec<BlameSpan>, VcsError> {
634 self.ensure_base()?;
635 self.git.blame(rel)
636 }
637
638 fn blame_at(&self, rev: &str, rel: &Path) -> Result<Vec<BlameSpan>, VcsError> {
639 self.ensure_base()?;
640 self.git.blame_at(rev, rel)
641 }
642
643 fn file_history(&self, rel: &str, limit: usize) -> Result<Vec<FileVersion>, VcsError> {
644 self.ensure_base()?;
645 let versions = self.git.file_history(rel, limit)?;
646 let (paths, entries): (Vec<String>, Vec<LogEntry>) = versions
647 .into_iter()
648 .map(|version| (version.path, version.entry))
649 .unzip();
650 Ok(self
651 .decorated(entries)
652 .into_iter()
653 .zip(paths)
654 .map(|(entry, path)| FileVersion { entry, path })
655 .collect())
656 }
657
658 fn read_at(&self, rev: &str, path: &str) -> Result<Option<String>, VcsError> {
659 self.ensure_base()?;
660 self.git.read_at(rev, path)
661 }
662
663 fn read_blob(&self, oid: &str) -> Result<Option<Vec<u8>>, VcsError> {
664 self.git.read_blob(oid)
665 }
666
667 fn tracked_files(&self) -> Result<Vec<PathBuf>, VcsError> {
668 self.ensure_base()?;
669 self.git.tracked_files()
670 }
671
672 fn attr(&self, rel: &Path, name: &str) -> bool {
673 self.git.attr(rel, name)
674 }
675
676 fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
679 if self.colocated {
680 return self.git.branches();
681 }
682 let view = self.view.borrow();
683 let here = view.change.as_ref().and_then(|c| c.bookmark.as_deref());
684 Ok(view
685 .bookmarks
686 .iter()
687 .filter(|bookmark| !bookmark.remote)
688 .map(|bookmark| BranchInfo {
689 name: bookmark.name.clone(),
690 is_head: here == Some(bookmark.name.as_str()),
691 tip_unix: bookmark.tip_unix,
692 divergence: None,
693 })
694 .collect())
695 }
696
697 fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError> {
700 if self.colocated {
701 return self.git.divergence(branch);
702 }
703 let view = self.view.borrow();
704 let local = view
705 .bookmarks
706 .iter()
707 .find(|bookmark| !bookmark.remote && bookmark.name == branch);
708 let upstream = view.bookmarks.iter().find(|bookmark| {
709 bookmark.remote
710 && bookmark
711 .name
712 .rsplit_once('@')
713 .is_some_and(|(name, _)| name == branch)
714 });
715 let (Some(local), Some(upstream)) = (local, upstream) else {
716 return Ok(None);
717 };
718 self.git.ahead_behind(&local.oid, &upstream.oid).map(Some)
719 }
720
721 fn all_branches(&self) -> Result<Vec<String>, VcsError> {
722 if self.colocated {
723 return self.git.all_branches();
724 }
725 Ok(self
726 .view
727 .borrow()
728 .bookmarks
729 .iter()
730 .map(|bookmark| bookmark.name.clone())
731 .collect())
732 }
733
734 fn stage(&self, _rel: &Path) -> Result<(), VcsError> {
735 Err(VcsError::Rejected(NO_STAGING.into()))
736 }
737
738 fn stage_everything(&self) -> Result<(), VcsError> {
739 Err(VcsError::Rejected(NO_STAGING.into()))
740 }
741
742 fn unstage_everything(&self) -> Result<(), VcsError> {
743 Err(VcsError::Rejected(NO_STAGING.into()))
744 }
745
746 fn stage_hunk(&self, _rel: &Path, _hunk: &HunkId) -> Result<(), VcsError> {
747 Err(VcsError::Rejected(NO_STAGING.into()))
748 }
749
750 fn unstage(&self, _rel: &Path) -> Result<(), VcsError> {
751 Err(VcsError::Rejected(NO_STAGING.into()))
752 }
753
754 fn unstage_hunk(&self, _rel: &Path, _hunk: &HunkId) -> Result<(), VcsError> {
755 Err(VcsError::Rejected(NO_STAGING.into()))
756 }
757
758 fn discard(&self, rel: &Path) -> Result<(), VcsError> {
759 self.run(&["restore", "--", &root_file(&rel.to_string_lossy())])?;
760 Ok(())
761 }
762
763 fn commit_paths(&self, message: &str, paths: &[String]) -> Result<String, VcsError> {
764 if message.trim().is_empty() {
765 return Err(VcsError::Rejected(EMPTY_MESSAGE.into()));
766 }
767 let message = format!("--message={message}");
768 let filesets: Vec<String> = paths
770 .iter()
771 .map(|path| format!("root:{}", quoted(path)))
772 .collect();
773 let mut args = vec!["commit", message.as_str(), "--"];
774 args.extend(filesets.iter().map(String::as_str));
775 self.run(&args)?;
776 self.head_oid()
777 }
778
779 fn change(&self, op: &ChangeOp) -> Result<(), VcsError> {
780 match op {
781 ChangeOp::New(rev) => self.run(&["new", "--", &self.target(rev)?]),
782 ChangeOp::Edit(rev) => self.run(&["edit", "--", &self.target(rev)?]),
783 ChangeOp::Describe { rev, message } => self.run(&[
784 "describe",
785 &format!("--message={message}"),
786 "--",
787 &self.target(rev)?,
788 ]),
789 ChangeOp::Squash(rev) => self.squash(rev),
790 ChangeOp::Abandon(rev) => self.run(&["abandon", "--", &self.target(rev)?]),
791 ChangeOp::Undo => {
792 self.refuse_over_snapshot("undo")?;
793 self.run(&["undo"])
794 }
795 ChangeOp::Redo => {
796 self.refuse_over_snapshot("redo")?;
797 self.run(&["redo"])
798 }
799 }?;
800 Ok(())
801 }
802
803 fn pull_target(&self) -> Result<Option<PullTarget>, VcsError> {
805 let Some(bookmark) = self.nearest_bookmark()?.name else {
806 return Ok(None);
807 };
808 let tracked = self.run(&[
809 "bookmark",
810 "list",
811 "--ignore-working-copy",
812 "--tracked",
813 r#"--template=if(remote, remote ++ "\n")"#,
814 "--",
815 &format!("exact:{}", quoted(&bookmark)),
816 ])?;
817 let remotes: Vec<&str> = tracked.lines().filter(|r| *r != "git").collect();
818 let remote = remotes
819 .iter()
820 .find(|r| **r == "origin")
821 .or_else(|| remotes.first())
822 .map(|r| (*r).to_owned());
823 Ok(Some(PullTarget { bookmark, remote }))
824 }
825
826 fn rebase_onto_remote(&self, bookmark: &str, remote: &str) -> Result<PullOutcome, VcsError> {
827 let onto = format!("{bookmark}@{remote}");
828 let target = format!(
829 "remote_bookmarks(exact:{}, exact:{})",
830 quoted(bookmark),
831 quoted(remote)
832 );
833 if self.query(&target, COMMIT_ID_TEMPLATE, Some(1))?.is_empty() {
834 return Ok(PullOutcome::Gone { onto });
835 }
836 if !self
837 .query(&format!("({target}) & ::@"), COMMIT_ID_TEMPLATE, Some(1))?
838 .is_empty()
839 {
840 return Ok(PullOutcome::UpToDate { onto });
841 }
842 self.run(&["rebase", "--branch=@", &format!("--onto={target}")])?;
843 let conflicts = self
844 .query(
845 &format!("({target})..@ & conflicts()"),
846 COMMIT_ID_TEMPLATE,
847 None,
848 )?
849 .lines()
850 .count();
851 let bookmark_conflicted = !self
852 .run(&[
853 "bookmark",
854 "list",
855 "--ignore-working-copy",
856 "--conflicted",
857 "--",
858 &format!("exact:{}", quoted(bookmark)),
859 ])?
860 .is_empty();
861 Ok(PullOutcome::Rebased {
862 onto,
863 conflicts,
864 bookmark_conflicted,
865 })
866 }
867
868 fn commit_message(&self, oid: &str) -> Result<String, VcsError> {
869 self.git.commit_message(oid)
870 }
871
872 fn commit(&self, message: &str) -> Result<String, VcsError> {
875 if message.trim().is_empty() {
876 return Err(VcsError::Rejected(EMPTY_MESSAGE.into()));
877 }
878 self.run(&["commit", &format!("--message={message}")])?;
879 self.head_oid()
880 }
881
882 fn head_message(&self) -> Result<String, VcsError> {
883 self.ensure_base()?;
884 self.git.head_message()
885 }
886
887 fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
890 if let Some(message) = message
891 && message.trim().is_empty()
892 {
893 return Err(VcsError::Rejected(EMPTY_MESSAGE.into()));
894 }
895 match message {
896 None => self.run(&["squash", "--use-destination-message"])?,
897 Some(message) if use_index => self.run(&["squash", &format!("--message={message}")])?,
898 Some(message) => {
899 self.run(&["describe", "-r", "@-", &format!("--message={message}")])?
900 }
901 };
902 self.head_oid()
903 }
904
905 fn create_branch(&self, name: &str, _checkout: bool) -> Result<(), VcsError> {
907 self.run(&["bookmark", "create", "-r", "@", "--", name])?;
908 Ok(())
909 }
910
911 fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
913 self.run(&[
914 "bookmark",
915 "delete",
916 "--",
917 &format!("exact:{}", quoted(name)),
918 ])?;
919 Ok(())
920 }
921
922 fn checkout(&self, name: &str) -> Result<(), VcsError> {
925 self.run(&["new", "--", "ed(name)])?;
926 Ok(())
927 }
928
929 fn stash_push(&self, _message: Option<&str>) -> Result<(), VcsError> {
930 Err(VcsError::Rejected(NO_STASH.into()))
931 }
932
933 fn stash_pop(&self) -> Result<(), VcsError> {
934 Err(VcsError::Rejected(NO_STASH.into()))
935 }
936
937 fn network_argv(&self, op: NetworkOp) -> Result<Vec<String>, VcsError> {
941 let mut args = vec!["git".to_owned()];
942 match op {
943 NetworkOp::Fetch | NetworkOp::Pull => args.push("fetch".into()),
944 NetworkOp::FetchAll => args.extend(["fetch".into(), "--all-remotes".into()]),
945 NetworkOp::PullFrom { remote, .. } => {
946 args.extend(["fetch".into(), format!("--remote={remote}")]);
947 }
948 NetworkOp::Push | NetworkOp::PushSetUpstream { .. } => {
949 args.push("push".into());
950 if let NetworkOp::PushSetUpstream { remote } = op {
951 args.push(format!("--remote={remote}"));
952 }
953 args.push(self.push_selection()?);
954 }
955 NetworkOp::PullRebase | NetworkOp::PullMerge => {
956 return Err(VcsError::Rejected(NO_PULL_MODE.into()));
957 }
958 }
959 Ok(std::iter::once("jj".to_owned()).chain(args).collect())
960 }
961
962 fn worktrees(&self) -> Result<Vec<Worktree>, VcsError> {
963 let out = self.run(&[
964 "workspace",
965 "list",
966 "--ignore-working-copy",
967 &format!("--template={WORKSPACE_TEMPLATE}"),
968 ])?;
969 let here = fs::canonicalize(&self.root).ok();
970 Ok(out
971 .lines()
972 .filter_map(|line| {
973 let mut fields = line.split('\x1f');
974 let name = fields.next()?.to_owned();
975 let path = PathBuf::from(fields.next()?);
976 let subject = fields.next().unwrap_or_default().to_owned();
977 let time_unix = fields.next().and_then(|t| t.parse().ok()).unwrap_or(0);
978 Some(Worktree {
979 current: here.is_some() && fs::canonicalize(&path).ok() == here,
980 missing: !path.exists(),
981 main: name == "default",
982 label: name.clone(),
983 branch: None,
984 name,
985 path,
986 subject,
987 time_unix,
988 })
989 })
990 .collect())
991 }
992
993 fn worktree_argv(&self, op: &WorktreeOp) -> Result<Vec<String>, VcsError> {
994 let mut argv: Vec<String> = vec!["jj".into(), "workspace".into()];
995 match op {
996 WorktreeOp::Add {
997 path,
998 branch,
999 new_branch,
1000 } => {
1001 argv.extend(["add".into(), format!("--name={branch}")]);
1002 if !new_branch {
1003 argv.push(format!("--revision={}", quoted(branch)));
1004 }
1005 argv.push(path.display().to_string());
1006 }
1007 WorktreeOp::Remove { worktree, .. } => {
1009 argv.extend(["forget".into(), worktree.name.clone()]);
1010 }
1011 }
1012 Ok(argv)
1013 }
1014
1015 fn workdir(&self) -> Result<PathBuf, VcsError> {
1016 self.git.workdir()
1017 }
1018
1019 fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
1020 self.git.remote_url(name)
1021 }
1022
1023 fn remotes(&self) -> Result<Vec<String>, VcsError> {
1024 self.git.remotes()
1025 }
1026
1027 fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
1028 self.git.set_diff_algorithm(algorithm, indent_heuristic);
1029 }
1030}