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, 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""#;
26/// The bookmarked commits nearest `@`, which name the line `@` sits on.
27const NEAREST_BOOKMARKS: &str = "heads(::@ & (bookmarks() | remote_bookmarks()))";
28/// The history whose change ids a view carries, matching what the log walks.
29const HISTORY: &str = "::(@ | bookmarks() | remote_bookmarks() | tags())";
30
31pub struct JjVcs {
32    git: GitVcs,
33    root: PathBuf,
34    colocated: bool,
35    view: RefCell<JjView>,
36    /// Whether this handle has learned `@-` from jj, which a repo with no
37    /// `.git` needs before its first read of HEAD.
38    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    /// `jj log` over `revset` with `template`, which a jj repo answers
63    /// without snapshotting the working copy.
64    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    /// `@`, its first parent and the nearest bookmark, plus every bookmark
85    /// when the git store has none of its own.
86    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    /// Short change id by commit id over the newest `limit` commits of
110    /// [`HISTORY`].
111    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    /// A repo with no `.git` learns `@-` from jj once per handle; a
122    /// refresh's view moves it after that. Without it every read of HEAD
123    /// would show an unborn repo, so a failing `jj` fails the read.
124    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    /// The state alone, keeping the change ids this handle already holds.
132    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    /// Short change ids, jj's bookmarks and jj's wording for an empty
143    /// description, and no `jj/keep/*` refs, which jj makes to keep commits
144    /// from git's gc.
145    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    /// The commit a jj bookmark (`name` or `name@remote`) sits on.
167    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    /// Exactly one commit `revision` names as a jj revset.
177    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    /// Run jj from the repo root, since jj resolves paths against the cwd. A
194    /// failure carries jj's `Error:` line.
195    fn run(&self, args: &[&str]) -> Result<String, VcsError> {
196        let output = Command::new("jj")
197            .current_dir(&self.root)
198            .args(args)
199            // we pass every message as an argument, so an editor jj opens
200            // anyway would freeze the UI thread; we make it fail at once
201            .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    /// Colocation exports jj's state to git synchronously, so HEAD already
224    /// names the commit a write made; a repo with no `.git` asks jj again.
225    fn head_oid(&self) -> Result<String, VcsError> {
226        if !self.colocated {
227            self.reread_state()?;
228        }
229        self.git.resolve("HEAD")
230    }
231
232    /// `rev` for a jj argument. A commit id goes as the change it belongs
233    /// to, since acting on a commit rewritten since would bring it back as a
234    /// divergent copy.
235    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(&quoted(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    /// jj snapshots edits before an undo or redo, and both would then act on
249    /// that snapshot and drop the edits, so we refuse while it is the newest
250    /// operation.
251    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    /// A bare squash opens an editor only when both sides are described, so
270    /// we join the two descriptions ourselves then.
271    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    /// Read fresh, since a bookmark made a moment ago must count.
287    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    /// What `jj git push` sends: the nearest local bookmark when it sits on
298    /// `@` or `@-`, else `@-` under a name jj makes from its change id. A
299    /// bookmark further back that is still mutable would push without the
300    /// newer work, so we ask for it to be moved.
301    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
323/// The git store a jj repo without `.git` keeps; `git_target` names it
324/// relative to `.jj/repo/store`, and it may sit outside the repo.
325fn store_git_dir(root: &Path) -> Result<PathBuf, VcsError> {
326    // a secondary workspace's `.jj/repo` is a file naming the main one
327    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
338/// `fields` joined into one line per commit.
339fn 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
352/// The `git` remote is jj's mirror of a colocated repo's local bookmarks.
353fn real_remotes(list: &str) -> impl Iterator<Item = &str> {
354    split_list(list).filter(|name| !name.ends_with("@git"))
355}
356
357/// [`JjVcs::read_state`]'s rows as a view with no change ids. `bookmarks`
358/// lists every bookmark the rows carry.
359fn 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
413/// `revision` split before a trailing git ancestry suffix: `main~2` is
414/// `("main", "~2")`, `v2` has none.
415fn 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
428/// jj's root commit, which the git store never holds.
429fn is_root(oid: &str) -> bool {
430    oid.bytes().all(|byte| byte == b'0')
431}
432
433/// `@` and the revsets built on it, which only jj reads right: git reads a
434/// bare `@` as HEAD.
435fn is_working_copy_revset(revision: &str) -> bool {
436    revision == "@" || revision.starts_with("@-") || revision.starts_with("@+")
437}
438
439/// `s` as a jj string literal, since git names like `fix(x)` and `u@v` parse
440/// as fileset or revset expressions when passed bare.
441fn quoted(s: &str) -> String {
442    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
443}
444
445/// `path` as a fileset naming exactly that repo-relative file.
446fn root_file(path: &str) -> String {
447    format!("root-file:{}", quoted(path))
448}
449
450/// The local bookmark nearest `@`, where it sits, and whether `@-` is
451/// already immutable.
452struct NearestBookmark {
453    name: Option<String>,
454    /// On `@` or `@-`, so a push sends what the reader is working on.
455    on_working_line: bool,
456    immutable: bool,
457    parent_immutable: bool,
458}
459
460/// [`JjVcs::nearest_bookmark`]'s rows: marker, immutable flag, local names.
461fn 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    /// Change ids cover twice `log_limit`, since jj and git order a busy
516    /// history differently.
517    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            // jj prints full hex ids, so the base always parses
530            self.git.set_base(view.base.as_deref()).ok();
531        }
532        *self.view.borrow_mut() = view;
533    }
534
535    /// One diff pass of `@-` vs the working copy. jj marks a new file
536    /// intent-to-add in the index, so git's three lists would count it twice.
537    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    /// Git syntax (`main~2`, refs, oids, `HEAD` as `@-`) resolves through
571    /// git, since a colocated repo's git refs are current where jj's import
572    /// of them may lag. With no `.git` the store's refs are stale or absent,
573    /// so jj's bookmarks name the commit there, and git applies the
574    /// ancestry suffix. What git cannot read (`trunk()`, change ids) is a jj
575    /// revset; a git suffix never goes to jj, which reads `~` as a set
576    /// difference.
577    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        // jj has no HEAD, so asking it would only cost a `jj` run
590        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    /// A repo with no `.git` lists jj's local bookmarks; the one `@` sits on
677    /// or under counts as checked out.
678    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    /// A repo with no `.git` measures a bookmark against its first remote
698    /// namesake.
699    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        // we pass `root:` so a selected folder commits its whole subtree
769        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    /// `origin` wins when the bookmark tracks several remotes.
804    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    /// Messages go as `--message=<text>`: clap reads a separate value that
873    /// starts with `-` as a flag.
874    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    /// `None` squashes with `-u`, since a bare squash opens an editor when
888    /// both sides are described. `Some` without `use_index` rewords `@-` alone.
889    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    /// Bookmarks `@`; `checkout` is moot since `@` is already checked out.
906    fn create_branch(&self, name: &str, _checkout: bool) -> Result<(), VcsError> {
907        self.run(&["bookmark", "create", "-r", "@", "--", name])?;
908        Ok(())
909    }
910
911    /// `exact:`, since jj reads a bare name as a glob.
912    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    /// `jj new <rev>` works on top of the branch and keeps the change left
923    /// behind; `jj edit` would rewrite the tip in place.
924    fn checkout(&self, name: &str) -> Result<(), VcsError> {
925        self.run(&["new", "--", &quoted(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    /// A push sends what [`JjVcs::push_selection`] picks, and jj tracks the
938    /// remote bookmark it makes. A pull is the fetch alone;
939    /// [`Vcs::rebase_onto_remote`] follows it.
940    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            // jj forgets a workspace and leaves its folder, so nothing is lost
1008            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}