Skip to main content

diffler_core/
jj.rs

1//! jj backend for the [`Vcs`] trait: a colocated jj/git repo (`.jj` beside
2//! `.git`, as `jj git init --colocate` makes). Reads delegate to [`GitVcs`],
3//! since a colocated repo keeps git's HEAD on jj's `@-` and the working
4//! copy on disk matches `@`, so diff, log, blame, and tree reads already
5//! answer correctly through git2. Writes shell out to the `jj` CLI: jj owns
6//! the operation log and working-copy snapshot, and duplicating that through
7//! git2 would fight the real source of truth.
8
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, BranchInfo, HeadInfo, LogEntry, NetworkOp, StatusModel, Vcs, VcsError,
18};
19
20pub struct JjVcs {
21    git: GitVcs,
22    root: PathBuf,
23}
24
25impl JjVcs {
26    /// Open at [`DiffSettings::default`].
27    pub fn open(root: &Path) -> Result<Self, VcsError> {
28        Self::open_with_settings(root, &DiffSettings::default())
29    }
30
31    pub fn open_with_settings(root: &Path, settings: &DiffSettings) -> Result<Self, VcsError> {
32        Ok(Self {
33            git: GitVcs::open_with_settings(root, settings)?,
34            root: root.to_path_buf(),
35        })
36    }
37
38    /// Run a jj subcommand with the repo root as its working directory (jj
39    /// resolves paths against the process cwd, not `-R`) and return trimmed
40    /// stdout. A non-zero exit becomes a [`VcsError::Rejected`] carrying jj's
41    /// `Error: ...` summary line.
42    fn run(&self, args: &[&str]) -> Result<String, VcsError> {
43        let output = Command::new("jj")
44            .current_dir(&self.root)
45            .args(args)
46            // we pass every message as an argument, so an editor jj opens
47            // anyway would freeze the UI thread; we make it fail at once
48            .env("JJ_EDITOR", "false")
49            .output()
50            .map_err(|err| match err.kind() {
51                io::ErrorKind::NotFound => {
52                    VcsError::Rejected("install jj (jujutsu) on PATH to change this repo".into())
53                }
54                _ => VcsError::Io(err),
55            })?;
56        if output.status.success() {
57            return Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned());
58        }
59        let stderr = String::from_utf8_lossy(&output.stderr);
60        let mut lines = stderr.lines().map(str::trim);
61        let message = lines
62            .clone()
63            .find(|line| line.starts_with("Error:"))
64            .or_else(|| lines.find(|line| !line.is_empty()))
65            .unwrap_or("jj command failed")
66            .to_owned();
67        Err(VcsError::Rejected(message))
68    }
69
70    /// The oid a write just landed on: colocation exports jj's state to git
71    /// synchronously, so HEAD (which sits on `@-`) already reflects it.
72    fn head_oid(&self) -> Result<String, VcsError> {
73        self.git.resolve("HEAD")
74    }
75}
76
77/// `s` as a jj string literal, so a path or bookmark name reaches jj's
78/// fileset and revset parsers as one symbol: `fix(x)` and `u@v` are valid
79/// git names that parse as expressions when passed bare.
80fn quoted(s: &str) -> String {
81    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
82}
83
84const NO_STAGING: &str = "commit the whole working copy; jj has no staging area";
85const NO_STASH: &str = "run jj new in a shell to set this change aside; jj has no stash";
86const NO_PUSH_PULL: &str = "run jj git push or jj git fetch in a shell to sync a jj repo";
87
88impl Vcs for JjVcs {
89    fn has_index(&self) -> bool {
90        false
91    }
92
93    fn native_git_checkout(&self) -> bool {
94        false
95    }
96
97    fn git_dir(&self) -> Result<PathBuf, VcsError> {
98        self.git.git_dir()
99    }
100
101    fn head(&self) -> Result<HeadInfo, VcsError> {
102        self.git.head()
103    }
104
105    /// Built from `working_tree_diff` (`@-` vs the whole working copy in one
106    /// diff pass): jj's snapshot marks a new file intent-to-add in the
107    /// colocated index, so merging git's own untracked/unstaged/staged lists
108    /// would double-count it, git2 reporting it once as added (tree vs
109    /// index) and again as modified (index vs workdir).
110    fn status(&self) -> Result<StatusModel, VcsError> {
111        Ok(StatusModel {
112            untracked: DiffModel::default(),
113            unstaged: DiffModel::default(),
114            staged: self.git.working_tree_diff()?,
115        })
116    }
117
118    fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
119        self.git.working_tree_diff()
120    }
121
122    fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError> {
123        self.git.tree_to_workdir_diff(base_oid)
124    }
125
126    fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
127        self.git.commit_diff(oid)
128    }
129
130    fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
131        self.git.range_diff(oldest_oid, newest_oid)
132    }
133
134    fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
135        self.git.tree_diff(base_oid, newest_oid)
136    }
137
138    fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError> {
139        self.git.merge_base(a, b)
140    }
141
142    fn resolve(&self, revision: &str) -> Result<String, VcsError> {
143        self.git.resolve(revision)
144    }
145
146    fn log(&self, limit: usize) -> Result<Vec<LogEntry>, VcsError> {
147        self.git.log(limit)
148    }
149
150    fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError> {
151        self.git.default_branch(remote)
152    }
153
154    fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError> {
155        self.git.commits_between(base, head)
156    }
157
158    fn unpushed(&self, limit: usize) -> Result<Option<Vec<LogEntry>>, VcsError> {
159        self.git.unpushed(limit)
160    }
161
162    fn blame(&self, rel: &Path) -> Result<Vec<BlameSpan>, VcsError> {
163        self.git.blame(rel)
164    }
165
166    fn read_at(&self, rev: &str, path: &str) -> Result<Option<String>, VcsError> {
167        self.git.read_at(rev, path)
168    }
169
170    fn tracked_files(&self) -> Result<Vec<PathBuf>, VcsError> {
171        self.git.tracked_files()
172    }
173
174    fn attr(&self, rel: &Path, name: &str) -> bool {
175        self.git.attr(rel, name)
176    }
177
178    fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
179        self.git.branches()
180    }
181
182    fn divergence(&self, branch: &str) -> Result<Option<(usize, usize)>, VcsError> {
183        self.git.divergence(branch)
184    }
185
186    fn all_branches(&self) -> Result<Vec<String>, VcsError> {
187        self.git.all_branches()
188    }
189
190    fn stage(&self, _rel: &Path) -> Result<(), VcsError> {
191        Err(VcsError::Rejected(NO_STAGING.into()))
192    }
193
194    fn stage_everything(&self) -> Result<(), VcsError> {
195        Err(VcsError::Rejected(NO_STAGING.into()))
196    }
197
198    fn unstage_everything(&self) -> Result<(), VcsError> {
199        Err(VcsError::Rejected(NO_STAGING.into()))
200    }
201
202    fn stage_hunk(&self, _rel: &Path, _hunk: &HunkId) -> Result<(), VcsError> {
203        Err(VcsError::Rejected(NO_STAGING.into()))
204    }
205
206    fn unstage(&self, _rel: &Path) -> Result<(), VcsError> {
207        Err(VcsError::Rejected(NO_STAGING.into()))
208    }
209
210    fn unstage_hunk(&self, _rel: &Path, _hunk: &HunkId) -> Result<(), VcsError> {
211        Err(VcsError::Rejected(NO_STAGING.into()))
212    }
213
214    fn discard(&self, rel: &Path) -> Result<(), VcsError> {
215        let fileset = format!("root-file:{}", quoted(&rel.to_string_lossy()));
216        self.run(&["restore", "--", &fileset])?;
217        Ok(())
218    }
219
220    /// Messages go as `--message=<text>`: clap reads a separate value that
221    /// starts with `-` as a flag.
222    fn commit(&self, message: &str) -> Result<String, VcsError> {
223        if message.trim().is_empty() {
224            return Err(VcsError::Rejected("empty commit message".into()));
225        }
226        self.run(&["commit", &format!("--message={message}")])?;
227        self.head_oid()
228    }
229
230    fn head_message(&self) -> Result<String, VcsError> {
231        self.git.head_message()
232    }
233
234    /// `message: None` folds the working copy into its parent, keeping the
235    /// parent's description (`jj squash -u`: a bare squash opens an editor
236    /// when both sides are described); `Some` with `use_index` folds and
237    /// sets the new description in the same squash; `Some` without
238    /// `use_index` rewords the parent alone (`jj describe`), leaving the
239    /// working copy untouched.
240    fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
241        if let Some(message) = message
242            && message.trim().is_empty()
243        {
244            return Err(VcsError::Rejected("empty commit message".into()));
245        }
246        match message {
247            None => self.run(&["squash", "--use-destination-message"])?,
248            Some(message) if use_index => self.run(&["squash", &format!("--message={message}")])?,
249            Some(message) => {
250                self.run(&["describe", "-r", "@-", &format!("--message={message}")])?
251            }
252        };
253        self.head_oid()
254    }
255
256    /// Labels the current change (`@`) with a bookmark; `checkout` is a
257    /// no-op since `@` is already where the reader is standing.
258    fn create_branch(&self, name: &str, _checkout: bool) -> Result<(), VcsError> {
259        self.run(&["bookmark", "create", "-r", "@", "--", name])?;
260        Ok(())
261    }
262
263    /// `exact:`, since jj reads a bare name as a glob.
264    fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
265        self.run(&[
266            "bookmark",
267            "delete",
268            "--",
269            &format!("exact:{}", quoted(name)),
270        ])?;
271        Ok(())
272    }
273
274    /// `jj new <rev>` keeps working on top of the branch; `jj edit` edits
275    /// its tip commit in place. `jj new` never discards the change being
276    /// left behind, only detaches it.
277    fn checkout(&self, name: &str) -> Result<(), VcsError> {
278        self.run(&["new", "--", &quoted(name)])?;
279        Ok(())
280    }
281
282    fn stash_push(&self, _message: Option<&str>) -> Result<(), VcsError> {
283        Err(VcsError::Rejected(NO_STASH.into()))
284    }
285
286    fn stash_pop(&self) -> Result<(), VcsError> {
287        Err(VcsError::Rejected(NO_STASH.into()))
288    }
289
290    fn network_argv(&self, op: NetworkOp) -> Result<Vec<String>, VcsError> {
291        let args: &[&str] = match op {
292            NetworkOp::Fetch => &["git", "fetch"],
293            NetworkOp::FetchAll => &["git", "fetch", "--all-remotes"],
294            NetworkOp::Push
295            | NetworkOp::PushSetUpstream { .. }
296            | NetworkOp::Pull
297            | NetworkOp::PullFrom { .. }
298            | NetworkOp::PullRebase
299            | NetworkOp::PullMerge => return Err(VcsError::Rejected(NO_PUSH_PULL.into())),
300        };
301        Ok(std::iter::once("jj")
302            .chain(args.iter().copied())
303            .map(str::to_owned)
304            .collect())
305    }
306
307    fn workdir(&self) -> Result<PathBuf, VcsError> {
308        self.git.workdir()
309    }
310
311    fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
312        self.git.remote_url(name)
313    }
314
315    fn remotes(&self) -> Result<Vec<String>, VcsError> {
316        self.git.remotes()
317    }
318
319    fn set_diff_algorithm(&self, algorithm: DiffAlgorithm, indent_heuristic: bool) {
320        self.git.set_diff_algorithm(algorithm, indent_heuristic);
321    }
322}