Skip to main content

diffler_core/
jj.rs

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