Skip to main content

diffler_core/
jj.rs

1//! jj backend. Reads delegate to [`GitVcs`]: in a colocated repo git's HEAD
2//! sits on `@-` and the worktree matches `@`; otherwise we open jj's own git
3//! store and point its reads of HEAD at `@-`. Writes shell out to `jj`,
4//! which owns the operation log and working-copy snapshot.
5
6use 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, HeadInfo, JjView, LogEntry, NetworkOp,
18    PullOutcome, PullTarget, StatusModel, Vcs, VcsError,
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""#;
25/// The bookmarked commits nearest `@`, which name the line `@` sits on.
26const NEAREST_BOOKMARKS: &str = "heads(::@ & (bookmarks() | remote_bookmarks()))";
27/// The history whose change ids a view carries, matching what the log walks.
28const HISTORY: &str = "::(@ | bookmarks() | remote_bookmarks() | tags())";
29
30pub struct JjVcs {
31    git: GitVcs,
32    root: PathBuf,
33    colocated: bool,
34    view: RefCell<JjView>,
35    /// Whether this handle has learned `@-` from jj, which a repo with no
36    /// `.git` needs before its first read of HEAD.
37    base_known: Cell<bool>,
38}
39
40impl JjVcs {
41    pub fn open(root: &Path) -> Result<Self, VcsError> {
42        Self::open_with_settings(root, &DiffSettings::default())
43    }
44
45    pub fn open_with_settings(root: &Path, settings: &DiffSettings) -> Result<Self, VcsError> {
46        let colocated = root.join(".git").exists();
47        let git = if colocated {
48            GitVcs::open_with_settings(root, settings)?
49        } else {
50            GitVcs::open_jj_store(&store_git_dir(root)?, root, settings)?
51        };
52        Ok(Self {
53            git,
54            root: root.to_path_buf(),
55            colocated,
56            view: RefCell::default(),
57            base_known: Cell::new(false),
58        })
59    }
60
61    /// `jj log` over `revset` with `template`, which a jj repo answers
62    /// without snapshotting the working copy.
63    fn query(
64        &self,
65        revset: &str,
66        template: &str,
67        limit: Option<usize>,
68    ) -> Result<String, VcsError> {
69        let mut args = vec![
70            "log".to_owned(),
71            "--no-graph".to_owned(),
72            "--ignore-working-copy".to_owned(),
73            format!("--revisions={revset}"),
74            format!("--template={template}"),
75        ];
76        if let Some(limit) = limit {
77            args.push(format!("--limit={limit}"));
78        }
79        let args: Vec<&str> = args.iter().map(String::as_str).collect();
80        self.run(&args)
81    }
82
83    /// `@`, its first parent and the nearest bookmark, plus every bookmark
84    /// when the git store has none of its own.
85    fn read_state(&self) -> Result<JjView, VcsError> {
86        let revset = if self.colocated {
87            format!("@ | {NEAREST_BOOKMARKS}")
88        } else {
89            "@ | bookmarks() | remote_bookmarks()".to_owned()
90        };
91        let marker = format!(
92            r#"if(self.current_working_copy(), "@", if(self.contained_in("{NEAREST_BOOKMARKS}"), "^", "-"))"#
93        );
94        let template = line_template(&[
95            &marker,
96            "commit_id",
97            "change_id.short(8)",
98            "description.first_line()",
99            &list_template("parents.map(|c| c.commit_id())"),
100            &list_template("local_bookmarks.map(|b| b.name())"),
101            &list_template(r#"remote_bookmarks.map(|b| b.name() ++ "@" ++ b.remote())"#),
102            r#"committer.timestamp().format("%s")"#,
103        ]);
104        let out = self.query(&revset, &template, None)?;
105        Ok(parse_state(&out, !self.colocated))
106    }
107
108    /// Short change id by commit id over the newest `limit` commits of
109    /// [`HISTORY`].
110    fn read_change_ids(&self, limit: usize) -> Result<HashMap<String, String>, VcsError> {
111        let template = line_template(&["commit_id", "change_id.short(8)"]);
112        let out = self.query(HISTORY, &template, Some(limit))?;
113        Ok(out
114            .lines()
115            .filter_map(|line| line.split_once(FIELD_SEPARATOR))
116            .map(|(oid, change)| (oid.to_owned(), change.to_owned()))
117            .collect())
118    }
119
120    /// A repo with no `.git` learns `@-` from jj once per handle; a
121    /// refresh's view moves it after that. Without it every read of HEAD
122    /// would show an unborn repo, so a failing `jj` fails the read.
123    fn ensure_base(&self) -> Result<(), VcsError> {
124        if self.colocated || self.base_known.get() {
125            return Ok(());
126        }
127        self.reread_state()
128    }
129
130    /// The state alone, keeping the change ids this handle already holds.
131    fn reread_state(&self) -> Result<(), VcsError> {
132        let state = self.read_state()?;
133        let change_ids = std::mem::take(&mut self.view.borrow_mut().change_ids);
134        self.adopt_jj_view(JjView {
135            change_ids,
136            ..state
137        });
138        Ok(())
139    }
140
141    /// Short change ids, jj's bookmarks and jj's wording for an empty
142    /// description, and no `jj/keep/*` refs, which jj makes to keep commits
143    /// from git's gc.
144    fn decorated(&self, mut entries: Vec<LogEntry>) -> Vec<LogEntry> {
145        let view = self.view.borrow();
146        for entry in &mut entries {
147            entry.change_id = view.change_ids.get(&entry.oid).cloned();
148            if self.colocated {
149                entry.refs.retain(|name| !name.starts_with("jj/keep/"));
150            } else {
151                entry.refs = view
152                    .bookmarks
153                    .iter()
154                    .filter(|bookmark| bookmark.oid == entry.oid)
155                    .map(|bookmark| bookmark.name.clone())
156                    .collect();
157            }
158            if entry.subject.is_empty() {
159                NO_DESCRIPTION.clone_into(&mut entry.subject);
160            }
161        }
162        entries
163    }
164
165    /// The commit a jj bookmark (`name` or `name@remote`) sits on.
166    fn bookmark_oid(&self, name: &str) -> Option<String> {
167        self.view
168            .borrow()
169            .bookmarks
170            .iter()
171            .find(|bookmark| bookmark.name == name)
172            .map(|bookmark| bookmark.oid.clone())
173    }
174
175    /// Exactly one commit `revision` names as a jj revset.
176    fn resolve_revset(&self, revision: &str) -> Result<String, VcsError> {
177        let out = self.query(revision, COMMIT_ID_TEMPLATE, Some(2))?;
178        match out.lines().collect::<Vec<_>>().as_slice() {
179            [oid] if is_root(oid) => Err(VcsError::Rejected(format!(
180                "`{revision}` names jj's root commit, which git has no commit for; name another"
181            ))),
182            [oid] => Ok((*oid).to_owned()),
183            [] => Err(VcsError::Rejected(format!(
184                "`{revision}` matches no commit, name one that exists"
185            ))),
186            _ => Err(VcsError::Rejected(format!(
187                "`{revision}` matches several commits, name one"
188            ))),
189        }
190    }
191
192    /// Run jj from the repo root, since jj resolves paths against the cwd. A
193    /// failure carries jj's `Error:` line.
194    fn run(&self, args: &[&str]) -> Result<String, VcsError> {
195        let output = Command::new("jj")
196            .current_dir(&self.root)
197            .args(args)
198            // we pass every message as an argument, so an editor jj opens
199            // anyway would freeze the UI thread; we make it fail at once
200            .env("JJ_EDITOR", "false")
201            .output()
202            .map_err(|err| match err.kind() {
203                io::ErrorKind::NotFound => VcsError::Rejected(
204                    "install jj (jujutsu) on PATH to read and change this repo".into(),
205                ),
206                _ => VcsError::Io(err),
207            })?;
208        if output.status.success() {
209            return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
210        }
211        let stderr = String::from_utf8_lossy(&output.stderr);
212        let mut lines = stderr.lines().map(str::trim);
213        let message = lines
214            .clone()
215            .find(|line| line.starts_with("Error:"))
216            .or_else(|| lines.find(|line| !line.is_empty()))
217            .unwrap_or("run the jj command in a shell to see why it failed")
218            .to_owned();
219        Err(VcsError::Rejected(message))
220    }
221
222    /// Colocation exports jj's state to git synchronously, so HEAD already
223    /// names the commit a write made; a repo with no `.git` asks jj again.
224    fn head_oid(&self) -> Result<String, VcsError> {
225        if !self.colocated {
226            self.reread_state()?;
227        }
228        self.git.resolve("HEAD")
229    }
230
231    /// `rev` for a jj argument. A commit id goes as the change it belongs
232    /// to, since acting on a commit rewritten since would bring it back as a
233    /// divergent copy.
234    fn target(&self, rev: &str) -> Result<String, VcsError> {
235        if is_working_copy_revset(rev) {
236            return Ok(rev.to_owned());
237        }
238        let change = self.query(&quoted(rev), "change_id", Some(1))?;
239        if change.is_empty() {
240            return Err(VcsError::Rejected(format!(
241                "`{rev}` names no commit; refresh and pick another"
242            )));
243        }
244        Ok(format!("change_id({})", quoted(&change)))
245    }
246
247    /// jj snapshots edits before an undo or redo, and both would then act on
248    /// that snapshot and drop the edits, so we refuse while it is the newest
249    /// operation.
250    fn refuse_over_snapshot(&self, verb: &str) -> Result<(), VcsError> {
251        self.run(&["util", "snapshot"])?;
252        let newest = self.run(&[
253            "op",
254            "log",
255            "--ignore-working-copy",
256            "--no-graph",
257            "--limit=1",
258            "--template=description",
259        ])?;
260        if newest.starts_with("snapshot working copy") {
261            return Err(VcsError::Rejected(format!(
262                "describe or commit your edits first; {verb} would drop them"
263            )));
264        }
265        Ok(())
266    }
267
268    /// A bare squash opens an editor only when both sides are described, so
269    /// we join the two descriptions ourselves then.
270    fn squash(&self, rev: &str) -> Result<String, VcsError> {
271        let target = self.target(rev)?;
272        let source = self.query(&target, "description", Some(1))?;
273        let destination = self.query(&format!("({target})-"), "description", Some(1))?;
274        let revision = format!("--revision={target}");
275        if source.is_empty() || destination.is_empty() {
276            return self.run(&["squash", &revision]);
277        }
278        self.run(&[
279            "squash",
280            &revision,
281            &format!("--message={destination}\n\n{source}"),
282        ])
283    }
284
285    /// Read fresh, since a bookmark made a moment ago must count.
286    fn nearest_bookmark(&self) -> Result<NearestBookmark, VcsError> {
287        let template = line_template(&[
288            r#"if(self.current_working_copy(), "@", if(self.contained_in("@-"), "-", "^"))"#,
289            r#"if(immutable, "1", "0")"#,
290            &list_template("local_bookmarks.map(|b| b.name())"),
291        ]);
292        let out = self.query("@- | heads(::@ & bookmarks())", &template, None)?;
293        Ok(parse_nearest_bookmark(&out))
294    }
295
296    /// What `jj git push` sends: the nearest local bookmark when it sits on
297    /// `@` or `@-`, else `@-` under a name jj makes from its change id. A
298    /// bookmark further back that is still mutable would push without the
299    /// newer work, so we ask for it to be moved.
300    fn push_selection(&self) -> Result<String, VcsError> {
301        let nearest = self.nearest_bookmark()?;
302        match nearest.name {
303            Some(name) if nearest.on_working_line => {
304                return Ok(format!("--bookmark=exact:{}", quoted(&name)));
305            }
306            Some(name) if !nearest.immutable => {
307                return Err(VcsError::Rejected(format!(
308                    "move {name} to @- first (jj bookmark move {name} --to @-), then push"
309                )));
310            }
311            _ => {}
312        }
313        if nearest.parent_immutable {
314            return Err(VcsError::Rejected(
315                "commit a change on top of @- first; there is nothing new to push".into(),
316            ));
317        }
318        Ok("--change=@-".into())
319    }
320}
321
322/// The git store a jj repo without `.git` keeps; `git_target` names it
323/// relative to `.jj/repo/store`, and it may sit outside the repo.
324fn store_git_dir(root: &Path) -> Result<PathBuf, VcsError> {
325    // a secondary workspace's `.jj/repo` is a file naming the main one
326    if root.join(".jj/repo").is_file() {
327        return Err(VcsError::Rejected(
328            "open the repository's main jj workspace; secondary workspaces aren't supported yet"
329                .into(),
330        ));
331    }
332    let store = root.join(".jj/repo/store");
333    let target = fs::read_to_string(store.join("git_target"))?;
334    Ok(store.join(target.trim()))
335}
336
337/// `fields` joined into one line per commit.
338fn line_template(fields: &[&str]) -> String {
339    let separator = format!(r#" ++ "\x{:02x}" ++ "#, u32::from(FIELD_SEPARATOR));
340    format!(r#"{} ++ "\n""#, fields.join(&separator))
341}
342
343fn list_template(list: &str) -> String {
344    format!(r#"{list}.join("\x{:02x}")"#, u32::from(LIST_SEPARATOR))
345}
346
347fn split_list(list: &str) -> impl Iterator<Item = &str> {
348    list.split(LIST_SEPARATOR).filter(|item| !item.is_empty())
349}
350
351/// The `git` remote is jj's mirror of a colocated repo's local bookmarks.
352fn real_remotes(list: &str) -> impl Iterator<Item = &str> {
353    split_list(list).filter(|name| !name.ends_with("@git"))
354}
355
356/// [`JjVcs::read_state`]'s rows as a view with no change ids. `bookmarks`
357/// lists every bookmark the rows carry.
358fn parse_state(out: &str, bookmarks: bool) -> JjView {
359    let mut view = JjView::default();
360    let mut nearest = None;
361    for line in out.lines() {
362        let fields: Vec<&str> = line.split(FIELD_SEPARATOR).collect();
363        let &[
364            marker,
365            oid,
366            change_id,
367            description,
368            parents,
369            local,
370            remote,
371            time,
372        ] = fields.as_slice()
373        else {
374            continue;
375        };
376        let bookmark = || split_list(local).chain(real_remotes(remote)).next();
377        if marker == "@" {
378            view.base = split_list(parents)
379                .next()
380                .filter(|id| !is_root(id))
381                .map(str::to_owned);
382            view.change = Some(ChangeInfo {
383                change_id: change_id.to_owned(),
384                commit_id: oid.to_owned(),
385                bookmark: bookmark().map(str::to_owned),
386                description: description.to_owned(),
387            });
388        } else if marker == "^" && nearest.is_none() {
389            nearest = bookmark();
390        }
391        if bookmarks {
392            let tip_unix = time.parse().unwrap_or_default();
393            let names = split_list(local)
394                .map(|name| (name, false))
395                .chain(real_remotes(remote).map(|name| (name, true)));
396            view.bookmarks.extend(names.map(|(name, remote)| Bookmark {
397                name: name.to_owned(),
398                oid: oid.to_owned(),
399                remote,
400                tip_unix,
401            }));
402        }
403    }
404    if let Some(change) = view.change.as_mut()
405        && change.bookmark.is_none()
406    {
407        change.bookmark = nearest.map(str::to_owned);
408    }
409    view
410}
411
412/// `revision` split before a trailing git ancestry suffix: `main~2` is
413/// `("main", "~2")`, `v2` has none.
414fn split_ancestry(revision: &str) -> (&str, &str) {
415    let kept = revision
416        .trim_end_matches(|c: char| matches!(c, '~' | '^') || c.is_ascii_digit())
417        .len();
418    revision
419        .get(kept..)
420        .and_then(|tail| tail.find(['~', '^']))
421        .map(|offset| kept + offset)
422        .filter(|&at| at > 0)
423        .and_then(|at| revision.split_at_checked(at))
424        .unwrap_or((revision, ""))
425}
426
427/// jj's root commit, which the git store never holds.
428fn is_root(oid: &str) -> bool {
429    oid.bytes().all(|byte| byte == b'0')
430}
431
432/// `@` and the revsets built on it, which only jj reads right: git reads a
433/// bare `@` as HEAD.
434fn is_working_copy_revset(revision: &str) -> bool {
435    revision == "@" || revision.starts_with("@-") || revision.starts_with("@+")
436}
437
438/// `s` as a jj string literal, since git names like `fix(x)` and `u@v` parse
439/// as fileset or revset expressions when passed bare.
440fn quoted(s: &str) -> String {
441    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
442}
443
444/// `path` as a fileset naming exactly that repo-relative file.
445fn root_file(path: &str) -> String {
446    format!("root-file:{}", quoted(path))
447}
448
449/// The local bookmark nearest `@`, where it sits, and whether `@-` is
450/// already immutable.
451struct NearestBookmark {
452    name: Option<String>,
453    /// On `@` or `@-`, so a push sends what the reader is working on.
454    on_working_line: bool,
455    immutable: bool,
456    parent_immutable: bool,
457}
458
459/// [`JjVcs::nearest_bookmark`]'s rows: marker, immutable flag, local names.
460fn parse_nearest_bookmark(out: &str) -> NearestBookmark {
461    let mut nearest = NearestBookmark {
462        name: None,
463        on_working_line: false,
464        immutable: false,
465        parent_immutable: false,
466    };
467    for line in out.lines() {
468        let fields: Vec<&str> = line.split(FIELD_SEPARATOR).collect();
469        let &[marker, immutable, names] = fields.as_slice() else {
470            continue;
471        };
472        let immutable = immutable == "1";
473        if marker == "-" && immutable {
474            nearest.parent_immutable = true;
475        }
476        if nearest.name.is_none()
477            && let Some(name) = split_list(names).next()
478        {
479            nearest.name = Some(name.to_owned());
480            nearest.on_working_line = marker != "^";
481            nearest.immutable = immutable;
482        }
483    }
484    nearest
485}
486
487const EMPTY_MESSAGE: &str = "write a commit message first";
488const NO_STAGING: &str = "commit the whole working copy; jj has no staging area";
489const NO_STASH: &str = "start a new change from the change menu instead; jj has no stash";
490const NO_PULL_MODE: &str = "pull with p p; jj rebases @ onto the fetched bookmark";
491
492impl Vcs for JjVcs {
493    fn has_index(&self) -> bool {
494        false
495    }
496
497    fn native_git_checkout(&self) -> bool {
498        false
499    }
500
501    fn git_dir(&self) -> Result<PathBuf, VcsError> {
502        self.git.git_dir()
503    }
504
505    fn head(&self) -> Result<HeadInfo, VcsError> {
506        self.ensure_base()?;
507        let mut head = self.git.head()?;
508        if head.oid7.is_empty() {
509            head.branch = None;
510        }
511        Ok(head)
512    }
513
514    /// Change ids cover twice `log_limit`, since jj and git order a busy
515    /// history differently.
516    fn read_jj_view(&self, log_limit: usize) -> Result<Option<JjView>, VcsError> {
517        let view = JjView {
518            change_ids: self.read_change_ids(log_limit.saturating_mul(2))?,
519            ..self.read_state()?
520        };
521        self.adopt_jj_view(view.clone());
522        Ok(Some(view))
523    }
524
525    fn adopt_jj_view(&self, view: JjView) {
526        if !self.colocated {
527            self.base_known.set(true);
528            // jj prints full hex ids, so the base always parses
529            self.git.set_base(view.base.as_deref()).ok();
530        }
531        *self.view.borrow_mut() = view;
532    }
533
534    /// One diff pass of `@-` vs the working copy. jj marks a new file
535    /// intent-to-add in the index, so git's three lists would count it twice.
536    fn status(&self) -> Result<StatusModel, VcsError> {
537        Ok(StatusModel {
538            untracked: DiffModel::default(),
539            unstaged: DiffModel::default(),
540            staged: self.working_tree_diff()?,
541        })
542    }
543
544    fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
545        self.ensure_base()?;
546        self.git.working_tree_diff()
547    }
548
549    fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError> {
550        self.git.tree_to_workdir_diff(base_oid)
551    }
552
553    fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
554        self.git.commit_diff(oid)
555    }
556
557    fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
558        self.git.range_diff(oldest_oid, newest_oid)
559    }
560
561    fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
562        self.git.tree_diff(base_oid, newest_oid)
563    }
564
565    fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError> {
566        self.git.merge_base(a, b)
567    }
568
569    /// Git syntax (`main~2`, refs, oids, `HEAD` as `@-`) resolves through
570    /// git, since a colocated repo's git refs are current where jj's import
571    /// of them may lag. With no `.git` the store's refs are stale or absent,
572    /// so jj's bookmarks name the commit there, and git applies the
573    /// ancestry suffix. What git cannot read (`trunk()`, change ids) is a jj
574    /// revset; a git suffix never goes to jj, which reads `~` as a set
575    /// difference.
576    fn resolve(&self, revision: &str) -> Result<String, VcsError> {
577        if is_working_copy_revset(revision) {
578            return self.resolve_revset(revision);
579        }
580        self.ensure_base()?;
581        let (name, ancestry) = split_ancestry(revision);
582        if !self.colocated
583            && let Some(oid) = self.bookmark_oid(name)
584        {
585            return self.git.resolve(&format!("{oid}{ancestry}"));
586        }
587        let resolved = self.git.resolve(revision);
588        // jj has no HEAD, so asking it would only cost a `jj` run
589        if resolved.is_ok() || name == "HEAD" {
590            return resolved;
591        }
592        if !ancestry.is_empty() {
593            return Err(VcsError::Rejected(format!(
594                "`{name}` names no bookmark, ref or commit; name one before `{ancestry}`"
595            )));
596        }
597        self.resolve_revset(revision)
598    }
599
600    fn log(&self, limit: usize, all_branches: bool) -> Result<Vec<LogEntry>, VcsError> {
601        self.ensure_base()?;
602        let entries = {
603            let view = self.view.borrow();
604            let tips: Vec<&str> = view
605                .bookmarks
606                .iter()
607                .map(|bookmark| bookmark.oid.as_str())
608                .filter(|oid| !is_root(oid))
609                .collect();
610            self.git.log_from(limit, all_branches, &tips)?
611        };
612        Ok(self.decorated(entries))
613    }
614
615    fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError> {
616        self.git.default_branch(remote)
617    }
618
619    fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError> {
620        self.ensure_base()?;
621        Ok(self.decorated(self.git.commits_between(base, head)?))
622    }
623
624    fn unpushed(&self, limit: usize) -> Result<Option<Vec<LogEntry>>, VcsError> {
625        self.ensure_base()?;
626        Ok(self
627            .git
628            .unpushed(limit)?
629            .map(|entries| self.decorated(entries)))
630    }
631
632    fn blame(&self, rel: &Path) -> Result<Vec<BlameSpan>, VcsError> {
633        self.ensure_base()?;
634        self.git.blame(rel)
635    }
636
637    fn read_at(&self, rev: &str, path: &str) -> Result<Option<String>, VcsError> {
638        self.ensure_base()?;
639        self.git.read_at(rev, path)
640    }
641
642    fn read_blob(&self, oid: &str) -> Result<Option<Vec<u8>>, VcsError> {
643        self.git.read_blob(oid)
644    }
645
646    fn tracked_files(&self) -> Result<Vec<PathBuf>, VcsError> {
647        self.ensure_base()?;
648        self.git.tracked_files()
649    }
650
651    fn attr(&self, rel: &Path, name: &str) -> bool {
652        self.git.attr(rel, name)
653    }
654
655    /// A repo with no `.git` lists jj's local bookmarks; the one `@` sits on
656    /// or under counts as checked out.
657    fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
658        if self.colocated {
659            return self.git.branches();
660        }
661        let view = self.view.borrow();
662        let here = view.change.as_ref().and_then(|c| c.bookmark.as_deref());
663        Ok(view
664            .bookmarks
665            .iter()
666            .filter(|bookmark| !bookmark.remote)
667            .map(|bookmark| BranchInfo {
668                name: bookmark.name.clone(),
669                is_head: here == Some(bookmark.name.as_str()),
670                tip_unix: bookmark.tip_unix,
671                divergence: None,
672            })
673            .collect())
674    }
675
676    /// A repo with no `.git` measures a bookmark against its first remote
677    /// namesake.
678    fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError> {
679        if self.colocated {
680            return self.git.divergence(branch);
681        }
682        let view = self.view.borrow();
683        let local = view
684            .bookmarks
685            .iter()
686            .find(|bookmark| !bookmark.remote && bookmark.name == branch);
687        let upstream = view.bookmarks.iter().find(|bookmark| {
688            bookmark.remote
689                && bookmark
690                    .name
691                    .rsplit_once('@')
692                    .is_some_and(|(name, _)| name == branch)
693        });
694        let (Some(local), Some(upstream)) = (local, upstream) else {
695            return Ok(None);
696        };
697        self.git.ahead_behind(&local.oid, &upstream.oid).map(Some)
698    }
699
700    fn all_branches(&self) -> Result<Vec<String>, VcsError> {
701        if self.colocated {
702            return self.git.all_branches();
703        }
704        Ok(self
705            .view
706            .borrow()
707            .bookmarks
708            .iter()
709            .map(|bookmark| bookmark.name.clone())
710            .collect())
711    }
712
713    fn stage(&self, _rel: &Path) -> Result<(), VcsError> {
714        Err(VcsError::Rejected(NO_STAGING.into()))
715    }
716
717    fn stage_everything(&self) -> Result<(), VcsError> {
718        Err(VcsError::Rejected(NO_STAGING.into()))
719    }
720
721    fn unstage_everything(&self) -> Result<(), VcsError> {
722        Err(VcsError::Rejected(NO_STAGING.into()))
723    }
724
725    fn stage_hunk(&self, _rel: &Path, _hunk: &HunkId) -> Result<(), VcsError> {
726        Err(VcsError::Rejected(NO_STAGING.into()))
727    }
728
729    fn unstage(&self, _rel: &Path) -> Result<(), VcsError> {
730        Err(VcsError::Rejected(NO_STAGING.into()))
731    }
732
733    fn unstage_hunk(&self, _rel: &Path, _hunk: &HunkId) -> Result<(), VcsError> {
734        Err(VcsError::Rejected(NO_STAGING.into()))
735    }
736
737    fn discard(&self, rel: &Path) -> Result<(), VcsError> {
738        self.run(&["restore", "--", &root_file(&rel.to_string_lossy())])?;
739        Ok(())
740    }
741
742    fn commit_paths(&self, message: &str, paths: &[String]) -> Result<String, VcsError> {
743        if message.trim().is_empty() {
744            return Err(VcsError::Rejected(EMPTY_MESSAGE.into()));
745        }
746        let message = format!("--message={message}");
747        // we pass `root:` so a selected folder commits its whole subtree
748        let filesets: Vec<String> = paths
749            .iter()
750            .map(|path| format!("root:{}", quoted(path)))
751            .collect();
752        let mut args = vec!["commit", message.as_str(), "--"];
753        args.extend(filesets.iter().map(String::as_str));
754        self.run(&args)?;
755        self.head_oid()
756    }
757
758    fn change(&self, op: &ChangeOp) -> Result<(), VcsError> {
759        match op {
760            ChangeOp::New(rev) => self.run(&["new", "--", &self.target(rev)?]),
761            ChangeOp::Edit(rev) => self.run(&["edit", "--", &self.target(rev)?]),
762            ChangeOp::Describe { rev, message } => self.run(&[
763                "describe",
764                &format!("--message={message}"),
765                "--",
766                &self.target(rev)?,
767            ]),
768            ChangeOp::Squash(rev) => self.squash(rev),
769            ChangeOp::Abandon(rev) => self.run(&["abandon", "--", &self.target(rev)?]),
770            ChangeOp::Undo => {
771                self.refuse_over_snapshot("undo")?;
772                self.run(&["undo"])
773            }
774            ChangeOp::Redo => {
775                self.refuse_over_snapshot("redo")?;
776                self.run(&["redo"])
777            }
778        }?;
779        Ok(())
780    }
781
782    /// `origin` wins when the bookmark tracks several remotes.
783    fn pull_target(&self) -> Result<Option<PullTarget>, VcsError> {
784        let Some(bookmark) = self.nearest_bookmark()?.name else {
785            return Ok(None);
786        };
787        let tracked = self.run(&[
788            "bookmark",
789            "list",
790            "--ignore-working-copy",
791            "--tracked",
792            r#"--template=if(remote, remote ++ "\n")"#,
793            "--",
794            &format!("exact:{}", quoted(&bookmark)),
795        ])?;
796        let remotes: Vec<&str> = tracked.lines().filter(|r| *r != "git").collect();
797        let remote = remotes
798            .iter()
799            .find(|r| **r == "origin")
800            .or_else(|| remotes.first())
801            .map(|r| (*r).to_owned());
802        Ok(Some(PullTarget { bookmark, remote }))
803    }
804
805    fn rebase_onto_remote(&self, bookmark: &str, remote: &str) -> Result<PullOutcome, VcsError> {
806        let onto = format!("{bookmark}@{remote}");
807        let target = format!(
808            "remote_bookmarks(exact:{}, exact:{})",
809            quoted(bookmark),
810            quoted(remote)
811        );
812        if self.query(&target, COMMIT_ID_TEMPLATE, Some(1))?.is_empty() {
813            return Ok(PullOutcome::Gone { onto });
814        }
815        if !self
816            .query(&format!("({target}) & ::@"), COMMIT_ID_TEMPLATE, Some(1))?
817            .is_empty()
818        {
819            return Ok(PullOutcome::UpToDate { onto });
820        }
821        self.run(&["rebase", "--branch=@", &format!("--onto={target}")])?;
822        let conflicts = self
823            .query(
824                &format!("({target})..@ & conflicts()"),
825                COMMIT_ID_TEMPLATE,
826                None,
827            )?
828            .lines()
829            .count();
830        let bookmark_conflicted = !self
831            .run(&[
832                "bookmark",
833                "list",
834                "--ignore-working-copy",
835                "--conflicted",
836                "--",
837                &format!("exact:{}", quoted(bookmark)),
838            ])?
839            .is_empty();
840        Ok(PullOutcome::Rebased {
841            onto,
842            conflicts,
843            bookmark_conflicted,
844        })
845    }
846
847    fn commit_message(&self, oid: &str) -> Result<String, VcsError> {
848        self.git.commit_message(oid)
849    }
850
851    /// Messages go as `--message=<text>`: clap reads a separate value that
852    /// starts with `-` as a flag.
853    fn commit(&self, message: &str) -> Result<String, VcsError> {
854        if message.trim().is_empty() {
855            return Err(VcsError::Rejected(EMPTY_MESSAGE.into()));
856        }
857        self.run(&["commit", &format!("--message={message}")])?;
858        self.head_oid()
859    }
860
861    fn head_message(&self) -> Result<String, VcsError> {
862        self.ensure_base()?;
863        self.git.head_message()
864    }
865
866    /// `None` squashes with `-u`, since a bare squash opens an editor when
867    /// both sides are described. `Some` without `use_index` rewords `@-` alone.
868    fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
869        if let Some(message) = message
870            && message.trim().is_empty()
871        {
872            return Err(VcsError::Rejected(EMPTY_MESSAGE.into()));
873        }
874        match message {
875            None => self.run(&["squash", "--use-destination-message"])?,
876            Some(message) if use_index => self.run(&["squash", &format!("--message={message}")])?,
877            Some(message) => {
878                self.run(&["describe", "-r", "@-", &format!("--message={message}")])?
879            }
880        };
881        self.head_oid()
882    }
883
884    /// Bookmarks `@`; `checkout` is moot since `@` is already checked out.
885    fn create_branch(&self, name: &str, _checkout: bool) -> Result<(), VcsError> {
886        self.run(&["bookmark", "create", "-r", "@", "--", name])?;
887        Ok(())
888    }
889
890    /// `exact:`, since jj reads a bare name as a glob.
891    fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
892        self.run(&[
893            "bookmark",
894            "delete",
895            "--",
896            &format!("exact:{}", quoted(name)),
897        ])?;
898        Ok(())
899    }
900
901    /// `jj new <rev>` works on top of the branch and keeps the change left
902    /// behind; `jj edit` would rewrite the tip in place.
903    fn checkout(&self, name: &str) -> Result<(), VcsError> {
904        self.run(&["new", "--", &quoted(name)])?;
905        Ok(())
906    }
907
908    fn stash_push(&self, _message: Option<&str>) -> Result<(), VcsError> {
909        Err(VcsError::Rejected(NO_STASH.into()))
910    }
911
912    fn stash_pop(&self) -> Result<(), VcsError> {
913        Err(VcsError::Rejected(NO_STASH.into()))
914    }
915
916    /// A push sends what [`JjVcs::push_selection`] picks, and jj tracks the
917    /// remote bookmark it makes. A pull is the fetch alone;
918    /// [`Vcs::rebase_onto_remote`] follows it.
919    fn network_argv(&self, op: NetworkOp) -> Result<Vec<String>, VcsError> {
920        let mut args = vec!["git".to_owned()];
921        match op {
922            NetworkOp::Fetch | NetworkOp::Pull => args.push("fetch".into()),
923            NetworkOp::FetchAll => args.extend(["fetch".into(), "--all-remotes".into()]),
924            NetworkOp::PullFrom { remote, .. } => {
925                args.extend(["fetch".into(), format!("--remote={remote}")]);
926            }
927            NetworkOp::Push | NetworkOp::PushSetUpstream { .. } => {
928                args.push("push".into());
929                if let NetworkOp::PushSetUpstream { remote } = op {
930                    args.push(format!("--remote={remote}"));
931                }
932                args.push(self.push_selection()?);
933            }
934            NetworkOp::PullRebase | NetworkOp::PullMerge => {
935                return Err(VcsError::Rejected(NO_PULL_MODE.into()));
936            }
937        }
938        Ok(std::iter::once("jj".to_owned()).chain(args).collect())
939    }
940
941    fn workdir(&self) -> Result<PathBuf, VcsError> {
942        self.git.workdir()
943    }
944
945    fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
946        self.git.remote_url(name)
947    }
948
949    fn remotes(&self) -> Result<Vec<String>, VcsError> {
950        self.git.remotes()
951    }
952
953    fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
954        self.git.set_diff_algorithm(algorithm, indent_heuristic);
955    }
956}