···11+//! Jujutsu works with several backends and could add new ones in the future. Private builds of
22+//! it could also have private backends. Those make it hard to use `jj-lib` since it won't have
33+//! access to newer or private backends and fail to compute the diffs for them.
44+//!
55+//! Instead in case there *is* a diff to base ourselves on, we copy it to a tempfile or just use the
66+//! current file if not.
77+88+use std::path::{Path, PathBuf};
99+use std::process::Command;
1010+use std::sync::Arc;
1111+1212+use anyhow::{Context, Result};
1313+use arc_swap::ArcSwap;
1414+1515+use crate::FileChange;
1616+1717+pub(super) fn get_diff_base(repo: &Path, file: &Path) -> Result<Vec<u8>> {
1818+ let file_relative_to_root = file
1919+ .strip_prefix(repo)
2020+ .context("failed to strip JJ repo root path from file")?;
2121+2222+ let tmpfile = tempfile::NamedTempFile::with_prefix("helix-jj-diff-")
2323+ .context("could not create tempfile to save jj diff base")?;
2424+ let tmppath = tmpfile.path();
2525+2626+ let copy_bin = if cfg!(windows) { "copy.exe" } else { "cp" };
2727+2828+ let status = Command::new("jj")
2929+ // Ensure we're working in the expected repository.
3030+ .arg("--repository")
3131+ .arg(repo)
3232+ .args([
3333+ // Do not commit changes: this avoids Helix updating the JJ state every time this runs.
3434+ "--ignore-working-copy",
3535+ "diff",
3636+ // Ensuring no configuration option will interfere.
3737+ "--color",
3838+ "never",
3939+ "--no-pager",
4040+ // Work with current revision only.
4141+ "--revision",
4242+ "@",
4343+ // Pass custom diff configuration.
4444+ "--config",
4545+ ])
4646+ // Copy the temporary file provided by jujutsu to a temporary path of our own,
4747+ // because the `$left` directory is deleted when `jj` finishes executing.
4848+ .arg(format!(
4949+ "ui.diff-formatter=['{exe}', '$left/{base}', '{target}']",
5050+ exe = copy_bin,
5151+ base = file_relative_to_root.display(),
5252+ // Where to copy the jujutsu-provided file
5353+ target = tmppath.display(),
5454+ ))
5555+ // Restrict the diff to the current file
5656+ .arg(file)
5757+ .stdout(std::process::Stdio::null())
5858+ .stderr(std::process::Stdio::null())
5959+ .status()
6060+ .context("failed to execute `jj diff` to get diff base")?;
6161+6262+ let use_jj_path = status.success() && std::fs::metadata(tmppath).is_ok_and(|m| m.len() > 0);
6363+ // If the copy call inside `jj diff` succeeded, the tempfile is the one containing the base
6464+ // else it's just the original file (so no diff). We check for size since `jj` can return
6565+ // 0-sized files when there are no diffs to present for the file.
6666+ let diff_base_path = if use_jj_path { tmppath } else { file };
6767+6868+ // If the command succeeded, it means we either copied the jujutsu base or defaulted to the
6969+ // current file, so there should always be something to read and compare to.
7070+ std::fs::read(diff_base_path).context("could not read jj diff base from the target")
7171+}
7272+7373+pub(crate) fn get_current_head_name(repo: &Path) -> Result<Arc<ArcSwap<Box<str>>>> {
7474+ // See <https://docs.jj-vcs.dev/latest/templates/>
7575+ //
7676+ // This will produce the following:
7777+ //
7878+ // quvlrxss
7979+ // kmlpqmrv main-1
8080+ // sxrrsnun main-2 main-3*
8181+ // kzqnuykl
8282+ //
8383+ // There will be a `*` when a bookmark has been modified compared to its remote.
8484+ //
8585+ // We use a short ID with 8 characters because in practice the change ID is extremely unlikely
8686+ // to conflict since we only consider mutable commits (like most jj commands will do by default)
8787+ // and this leaves space for bookmarks to appear in the status bar even on narrower screens.
8888+ let template = r#"change_id.short(8) ++ " " ++ bookmarks ++ "\n""#;
8989+9090+ let out = Command::new("jj")
9191+ // Ensure we're working in the expected repository.
9292+ .arg("--repository")
9393+ .arg(repo)
9494+ .args([
9595+ // Do not commit changes: this avoids Helix updating the JJ state every time this runs.
9696+ "--ignore-working-copy",
9797+ "log",
9898+ // Ensuring no configuration option will interfere.
9999+ "--color",
100100+ "never",
101101+ "--no-graph",
102102+ "--no-pager",
103103+ // Includes from last immutable revision to current change.
104104+ "--revisions",
105105+ // <https://docs.jj-vcs.dev/latest/revsets/#built-in-aliases>
106106+ "mutable()-::@",
107107+ "--template",
108108+ template,
109109+ ])
110110+ .output()?;
111111+112112+ anyhow::ensure!(out.status.success(), "`jj log` executed but failed");
113113+114114+ let output = String::from_utf8(out.stdout).context("`jj log` did not output valid UTF-8")?;
115115+ let head_text = extract_head_name(&output)?;
116116+117117+ Ok(Arc::new(ArcSwap::from_pointee(head_text.into())))
118118+}
119119+120120+/// Helper function to make the extracting logic testable
121121+fn extract_head_name(output: &str) -> Result<String> {
122122+ let mut lines = output.lines();
123123+ let mut next = || lines.next().and_then(|line| line.split_once(' '));
124124+125125+ let (rev, exact_bookmarks) = next()
126126+ // Contrary to git, if a JJ repo exists, it always has at least two revisions:
127127+ // the root (zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz), which cannot be focused, and the current
128128+ // one, which exists even for brand new repos.
129129+ .context("should always find at least one line")?;
130130+131131+ let head_text = if !exact_bookmarks.is_empty() {
132132+ // Parentheses: bookmarks are exactly on current change.
133133+ format!("{rev} ({exact_bookmarks})")
134134+ } else {
135135+ let mutable_ancestor_bookmarks = std::iter::from_fn(next)
136136+ .map(|e| e.1)
137137+ .filter(|s| !s.is_empty())
138138+ .collect::<Vec<_>>();
139139+ if mutable_ancestor_bookmarks.is_empty() {
140140+ // Found no bookmarks amongst mutable ancestors.
141141+ rev.to_string()
142142+ } else {
143143+ // Angle brackets: bookmarks are on mutable ancestors.
144144+ format!("{rev} [{}]", mutable_ancestor_bookmarks.join(" ").trim())
145145+ }
146146+ };
147147+148148+ Ok(head_text)
149149+}
150150+151151+pub(crate) fn for_each_changed_file(
152152+ repo: &Path,
153153+ callback: impl Fn(Result<FileChange>) -> bool,
154154+) -> Result<()> {
155155+ // First we get conflict via another command because we have to, `jj diff` cannot list them and
156156+ // `jj status` does not support templating.
157157+ let out = Command::new("jj")
158158+ // Ensure we're working in the expected repository.
159159+ .arg("--repository")
160160+ .arg(repo)
161161+ .args([
162162+ // Do not commit changes: this avoids Helix updating the JJ state every time this runs.
163163+ "--ignore-working-copy",
164164+ "file",
165165+ "list",
166166+ // Ensuring no configuration option will interfere.
167167+ "--color",
168168+ "never",
169169+ "--no-pager",
170170+ // Work with current revision only.
171171+ "--revision",
172172+ "@",
173173+ // List per-file diff types, do not show diff itself.
174174+ "--template",
175175+ "if(conflict, path ++ \" //\n\")",
176176+ ])
177177+ .output()?;
178178+179179+ anyhow::ensure!(out.status.success(), "`jj file list` executed but failed");
180180+181181+ for entry in split_double_slash(&out.stdout, true) {
182182+ if entry.is_empty() {
183183+ continue;
184184+ }
185185+186186+ let path = make_pathbuf(entry);
187187+188188+ if !callback(Ok(FileChange::Conflict { path })) {
189189+ return Ok(());
190190+ }
191191+ }
192192+193193+ // The forward slash is the only character that is disallowed in both Unix and Windows paths,
194194+ // meaning `//` cannot ever appear in them on any platform.
195195+ //
196196+ // <https://docs.jj-vcs.dev/latest/templates/#treediffentry-type>
197197+ //
198198+ // Lines will be of the following format (examples)
199199+ //
200200+ // ```
201201+ // modified // conflict.txt // conflict // conflict.txt // file //\n
202202+ // added // added file.rs // // added file.rs // symlink //\n
203203+ // renamed // renamed.nix // file // after-rename.nix // file //\n
204204+ // removed // testing.ts // file // testing.ts // //\n
205205+ // ```
206206+ //
207207+ // Note we use `//\n` as the end delimiter to allow for files that contains `\n` in their name.
208208+ //
209209+ // For the file types, we will only concern ourselves with `file` and `symlink`, anything else
210210+ // will get dropped just like `git.rs` does.
211211+ let template = concat!(
212212+ // First, print the status, it will determinate some of our parsing.
213213+ // One of "modified", "added", "removed", "copied", or "renamed".
214214+ "status ++ ",
215215+ "\" // \" ++",
216216+ "source.path() ++ ",
217217+ "\" // \" ++ ",
218218+ // <https://docs.jj-vcs.dev/latest/templates/#treeentry-type>
219219+ "source.file_type() ++ ",
220220+ "\" // \" ++ ",
221221+ "target.path() ++ ",
222222+ "\" // \" ++ ",
223223+ // <https://docs.jj-vcs.dev/latest/templates/#treeentry-type>
224224+ "target.file_type() ++ ",
225225+ "\" //\\n\"",
226226+ );
227227+228228+ let out = Command::new("jj")
229229+ // Ensure we're working in the expected repository.
230230+ .arg("--repository")
231231+ .arg(repo)
232232+ .args([
233233+ // Do not commit changes: this avoids Helix updating the JJ state every time this runs.
234234+ "--ignore-working-copy",
235235+ "diff",
236236+ // Ensuring no configuration option will interfere.
237237+ "--color",
238238+ "never",
239239+ "--no-pager",
240240+ // Work with current revision only.
241241+ "--revision",
242242+ "@",
243243+ // List per-file diff types, do not show diff itself.
244244+ "--template",
245245+ template,
246246+ ])
247247+ .output()?;
248248+249249+ anyhow::ensure!(out.status.success(), "`jj diff` executed but failed");
250250+251251+ for entry in split_double_slash(&out.stdout, true) {
252252+ if entry.is_empty() {
253253+ continue;
254254+ }
255255+256256+ let Some(change) = entry_to_change(entry) else {
257257+ continue;
258258+ };
259259+260260+ if !callback(Ok(change)) {
261261+ return Ok(());
262262+ }
263263+ }
264264+265265+ Ok(())
266266+}
267267+268268+pub(crate) fn open_repo(repo_path: &Path) -> Result<()> {
269269+ assert!(
270270+ repo_path.join(".jj").exists(),
271271+ "no .jj where one was expected: {}",
272272+ repo_path.display(),
273273+ );
274274+275275+ let status = Command::new("jj")
276276+ .args(["--ignore-working-copy", "root"])
277277+ .stdout(std::process::Stdio::null())
278278+ .stderr(std::process::Stdio::null())
279279+ .status()?;
280280+281281+ if status.success() {
282282+ Ok(())
283283+ } else {
284284+ anyhow::bail!("not a valid JJ repo")
285285+ }
286286+}
287287+288288+/// Associate a status to a `FileChange`.
289289+///
290290+/// Gets something like `modified // conflict.txt // conflict // conflict.txt // file` as input.
291291+fn entry_to_change(entry: &[u8]) -> Option<FileChange> {
292292+ let mut sections = split_double_slash(entry, false);
293293+294294+ let kind = sections.next()?;
295295+296296+ let source_path = sections.next()?;
297297+ let source_file_type = sections.next()?;
298298+299299+ let target_path = sections.next()?;
300300+ let target_file_type = sections.next()?;
301301+302302+ // Never generated in practice but let's be thourough in case that changes.
303303+ // <https://github.com/jj-vcs/jj/issues/7264>
304304+ if target_file_type == b"conflict" {
305305+ return Some(FileChange::Conflict {
306306+ path: make_pathbuf(target_path),
307307+ });
308308+ }
309309+310310+ let file_types = [
311311+ // The empty file type is used when the file didn't exist before or doesn't exist now,
312312+ // e.g. when added or removed.
313313+ "".as_bytes(),
314314+ "conflict".as_bytes(),
315315+ "file".as_bytes(),
316316+ "symlink".as_bytes(),
317317+ ];
318318+ if !file_types.contains(&source_file_type) || !file_types.contains(&target_file_type) {
319319+ return None;
320320+ }
321321+322322+ let change = match kind {
323323+ b"added" | b"copied" => FileChange::Untracked {
324324+ path: make_pathbuf(target_path),
325325+ },
326326+ b"modified" => FileChange::Modified {
327327+ path: make_pathbuf(target_path),
328328+ },
329329+ b"removed" => FileChange::Deleted {
330330+ path: make_pathbuf(target_path),
331331+ },
332332+ b"renamed" => FileChange::Renamed {
333333+ from_path: make_pathbuf(source_path),
334334+ to_path: make_pathbuf(target_path),
335335+ },
336336+ _ => return None,
337337+ };
338338+339339+ Some(change)
340340+}
341341+342342+#[cfg(any(unix, target_os = "wasi"))]
343343+fn make_pathbuf(sl: &[u8]) -> PathBuf {
344344+ #[cfg(unix)]
345345+ use std::os::unix::ffi::OsStrExt;
346346+ #[cfg(target_os = "wasi")]
347347+ use std::os::wasi::ffi::OsStrExt;
348348+349349+ PathBuf::from(std::ffi::OsStr::from_bytes(sl))
350350+}
351351+352352+// Imperfect fallback for platforms where we don't know about an always-correct method.
353353+// In practice, non-UTF8 paths are vanishingly rare and should not be an issue for anyone running a
354354+// Rust binary like Helix.
355355+#[cfg(not(any(unix, target_os = "wasi")))]
356356+fn make_pathbuf(sl: &[u8]) -> PathBuf {
357357+ let s = String::from_utf8_lossy(sl);
358358+ PathBuf::from(s.into_owned())
359359+}
360360+361361+/// Split a byte slice on either ` // ` or ` //\n` depending on `with_newline`.
362362+fn split_double_slash(slice: &[u8], with_newline: bool) -> impl Iterator<Item = &[u8]> {
363363+ let mut done = false;
364364+ let mut rest = slice;
365365+ let needle = if with_newline { " //\n" } else { " // " }.as_bytes();
366366+ std::iter::from_fn(move || {
367367+ if done {
368368+ return None;
369369+ }
370370+ let result = match memchr::memmem::find(rest, needle) {
371371+ Some(pos) => {
372372+ // We use the non-panicking variants to avoid adding the panic machinery here when
373373+ // we know it won't ever panic in practice (unless there is a bug in memchr, which
374374+ // is unlikely given how much the crate is used).
375375+ let (before, after) = rest.split_at_checked(pos).unwrap_or_default();
376376+ rest = after.get(4..).unwrap_or_default();
377377+ before
378378+ }
379379+ None => {
380380+ done = true;
381381+ rest
382382+ }
383383+ };
384384+ Some(result)
385385+ })
386386+}
387387+388388+#[cfg(test)]
389389+mod tests {
390390+ use super::*;
391391+392392+ #[test]
393393+ fn test_split_double_slash_no_newline() {
394394+ let input = b"modified // test.rs // file // test.rs // file //\n";
395395+ let expected = [
396396+ "modified".as_bytes(),
397397+ "test.rs".as_bytes(),
398398+ "file".as_bytes(),
399399+ "test.rs".as_bytes(),
400400+ "file //\n".as_bytes(), // Not trimmed since we're not splitting on newlines
401401+ ];
402402+403403+ let result = split_double_slash(input, false).collect::<Vec<_>>();
404404+405405+ assert_eq!(result, expected);
406406+ }
407407+408408+ #[test]
409409+ fn test_split_double_slash_with_newline() {
410410+ let input = concat!(
411411+ "modified // test.rs // file // test.rs // file //\n",
412412+ "modified // test.rs // file // test.rs // file //\n",
413413+ )
414414+ .as_bytes();
415415+ let expected = [
416416+ "modified // test.rs // file // test.rs // file".as_bytes(),
417417+ "modified // test.rs // file // test.rs // file".as_bytes(),
418418+ // We expect an empty slice after the last split
419419+ &[],
420420+ ];
421421+422422+ let result = split_double_slash(input, true).collect::<Vec<_>>();
423423+424424+ assert_eq!(result, expected);
425425+ }
426426+427427+ #[test]
428428+ fn test_entry_to_change() {
429429+ let p = "helix-vcs/src/lib.rs";
430430+ let pb = PathBuf::from(p);
431431+432432+ let entry = |kind, (t1, t2)| {
433433+ entry_to_change(format!("{kind} // {p} // {t1} // {p} // {t2}").as_bytes())
434434+ };
435435+436436+ for types in [
437437+ ("conflict", "file"),
438438+ ("conflict", "symlink"),
439439+ ("file", "file"),
440440+ ("file", "symlink"),
441441+ ("symlink", "file"),
442442+ ("symlink", "symlink"),
443443+ ] {
444444+ assert_eq!(
445445+ entry("modified", types).unwrap(),
446446+ FileChange::Modified { path: pb.clone() }
447447+ );
448448+ }
449449+450450+ for types in [("", "file"), ("", "symlink")] {
451451+ assert_eq!(
452452+ entry("added", types).unwrap(),
453453+ FileChange::Untracked { path: pb.clone() }
454454+ );
455455+ }
456456+457457+ for types in [
458458+ ("file", "file"),
459459+ ("file", "symlink"),
460460+ ("symlink", "file"),
461461+ ("symlink", "symlink"),
462462+ ] {
463463+ assert_eq!(
464464+ entry("copied", types).unwrap(),
465465+ FileChange::Untracked { path: pb.clone() }
466466+ );
467467+ }
468468+469469+ for types in [("conflict", ""), ("file", ""), ("symlink", "")] {
470470+ assert_eq!(
471471+ entry("removed", types).unwrap(),
472472+ FileChange::Deleted { path: pb.clone() }
473473+ );
474474+ }
475475+476476+ for types in [
477477+ ("", "conflict"),
478478+ ("conflict", "conflict"),
479479+ ("file", "conflict"),
480480+ ("symlink", "conflict"),
481481+ ] {
482482+ assert_eq!(
483483+ entry("conflict", types).unwrap(),
484484+ FileChange::Conflict { path: pb.clone() }
485485+ );
486486+ }
487487+488488+ for invalid_kind in ["invalid", ""] {
489489+ assert_eq!(entry(invalid_kind, ("file", "file")), None);
490490+ }
491491+492492+ for invalid_types in [
493493+ ("tree", "file"),
494494+ ("submodule", "file"),
495495+ ("abcdef", "file"),
496496+ ("file", "tree"),
497497+ ("file", "submodule"),
498498+ ("file", "abcdef"),
499499+ ] {
500500+ assert_eq!(entry("modified", invalid_types), None);
501501+ }
502502+ }
503503+504504+ #[test]
505505+ fn test_extract_head_name() {
506506+ // No bookmarks.
507507+ let result = extract_head_name("abcdefgh \nijklmnop \n").unwrap();
508508+ assert_eq!(result, "abcdefgh");
509509+510510+ // Single exact bookmark.
511511+ let result = extract_head_name("abcdefgh bookmark*\nijklmnop other-bookmark*\n").unwrap();
512512+ assert_eq!(result, "abcdefgh (bookmark*)");
513513+514514+ // Multiple exact bookmarks.
515515+ let result = extract_head_name(concat!(
516516+ "abcdefgh bookmark bookmark-v2\n",
517517+ "ijklmnop other-ookmark\n",
518518+ ))
519519+ .unwrap();
520520+ assert_eq!(result, "abcdefgh (bookmark bookmark-v2)");
521521+522522+ // Single inexact bookmark.
523523+ let result = extract_head_name("abcdefgh \nijklmnop other-bookmark\n").unwrap();
524524+ assert_eq!(result, "abcdefgh [other-bookmark]");
525525+526526+ // Multiple inexact bookmarks.
527527+ let result = extract_head_name("abcdefgh \nijklmnop bookmark* bookmark-v2\n").unwrap();
528528+ assert_eq!(result, "abcdefgh [bookmark* bookmark-v2]");
529529+ }
530530+}
···11use std::path::{Path, PathBuf};
2233/// States for a file having been changed.
44+#[derive(Debug, PartialEq, Eq)]
45pub enum FileChange {
56 /// Not tracked by the VCS.
67 Untracked { path: PathBuf },