Skip to content

Commit ceab336

Browse files
committed
feat: Add Jujutsu diff handling to Helix
1 parent 0929704 commit ceab336

File tree

5 files changed

+245
-1
lines changed

5 files changed

+245
-1
lines changed

helix-term/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ repository.workspace = true
1313
homepage.workspace = true
1414

1515
[features]
16-
default = ["git"]
16+
default = ["vcs"]
1717
unicode-lines = ["helix-core/unicode-lines", "helix-view/unicode-lines"]
1818
integration = ["helix-event/integration_test"]
19+
20+
# All VCSes available for diffs in Helix
21+
vcs = ["git", "jujutsu"]
1922
git = ["helix-vcs/git"]
23+
jujutsu = ["helix-vcs/jujutsu"]
2024

2125
[[bin]]
2226
name = "hx"

helix-vcs/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@ imara-diff = "0.1.7"
2424
anyhow = "1"
2525

2626
log = "0.4"
27+
# For `jujutsu`
28+
tempfile = { version = "3.10", optional = true }
2729

2830
[features]
2931
git = ["gix"]
32+
jujutsu = ["tempfile"]
33+
3034

3135
[dev-dependencies]
3236
tempfile = "3.11"

helix-vcs/src/jujutsu.rs

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
//! Jujutsu works with several backends and could add new ones in the future. Private builds of
2+
//! it could also have private backends. Those make it hard to use `jj-lib` since it won't have
3+
//! access to newer or private backends and fail to compute the diffs for them.
4+
//!
5+
//! Instead in case there *is* a diff to base ourselves on, we copy it to a tempfile or just use the
6+
//! current file if not.
7+
8+
use std::path::Path;
9+
use std::process::Command;
10+
use std::sync::Arc;
11+
12+
use anyhow::{Context, Ok, Result};
13+
use arc_swap::ArcSwap;
14+
15+
use crate::FileChange;
16+
17+
pub(super) fn get_diff_base(file: &Path) -> Result<Vec<u8>> {
18+
let jj_root_dir = find_jj_root(file)?;
19+
20+
// We extracted the `jj_root_dir` from the file itself, if stripping the prefix fails
21+
// something has gone very very wrong
22+
let file_rel_to_dot_jj = file
23+
.strip_prefix(jj_root_dir)
24+
.expect("failed to strip diff path from jj root dir");
25+
26+
let tmpfile = tempfile::NamedTempFile::with_prefix("helix-jj-diff-")
27+
.context("could not create tempfile to save jj diff base")?;
28+
let tmppath = tmpfile.path();
29+
30+
let copy_bin = if cfg!(windows) { "copy.exe" } else { "cp" };
31+
32+
let status = Command::new("jj")
33+
.arg("--repository")
34+
.arg(jj_root_dir)
35+
.args([
36+
"--ignore-working-copy",
37+
"diff",
38+
"--revision",
39+
"@",
40+
"--config-toml",
41+
])
42+
// Copy the temporary file provided by jujutsu to a temporary path of our own,
43+
// because the `$left` directory is deleted when `jj` finishes executing.
44+
.arg(format!(
45+
"ui.diff.tool = ['{exe}', '$left/{base}', '{target}']",
46+
exe = copy_bin,
47+
base = file_rel_to_dot_jj.display(),
48+
// Where to copy the jujutsu-provided file
49+
target = tmppath.display(),
50+
))
51+
// Restrict the diff to the current file
52+
.arg(file)
53+
.stdout(std::process::Stdio::null())
54+
.stderr(std::process::Stdio::null())
55+
.status()
56+
.context("failed to execute jj diff command")?;
57+
58+
let use_jj_path = status.success() && std::fs::metadata(tmppath).map_or(false, |m| m.len() > 0);
59+
// If the copy call inside `jj diff` succeeded, the tempfile is the one containing the base
60+
// else it's just the original file (so no diff). We check for size since `jj` can return
61+
// 0-sized files when there are no diffs to present for the file.
62+
let diff_base_path = if use_jj_path { tmppath } else { file };
63+
64+
// If the command succeeded, it means we either copied the jujutsu base or the current file,
65+
// so there should always be something to read and compare to.
66+
std::fs::read(diff_base_path).context("could not read jj diff base from the target")
67+
}
68+
69+
pub(super) fn get_current_head_name(file: &Path) -> Result<Arc<ArcSwap<Box<str>>>> {
70+
let jj_root_dir = find_jj_root(file)?;
71+
72+
// See <https://github.com/martinvonz/jj/blob/main/docs/templates.md>
73+
//
74+
// This will produce the following:
75+
//
76+
// - If there are no branches: `vyvqwlmsvnlkmqrvqktpuluvuknuxpmm`
77+
// - If there is a single branch: `vyvqwlmsvnlkmqrvqktpuluvuknuxpmm (master)`
78+
// - If there are 2+ branches: `vyvqwlmsvnlkmqrvqktpuluvuknuxpmm (master, jj-diffs)`
79+
//
80+
// Always using the long id makes it easy to share it with others, which would not be the
81+
// case for shorter ones: they could have a local change that renders it ambiguous.
82+
let template = r#"separate(" ", change_id, surround("(", ")", branches.join(", ")))"#;
83+
84+
let out = Command::new("jj")
85+
.arg("--repository")
86+
.arg(jj_root_dir)
87+
.args([
88+
"--ignore-working-copy",
89+
"log",
90+
"--color",
91+
"never",
92+
"--revisions",
93+
"@", // Only display the current revision
94+
"--no-graph",
95+
"--no-pager",
96+
"--template",
97+
template,
98+
])
99+
.output()?;
100+
101+
if !out.status.success() {
102+
anyhow::bail!("jj log command executed but failed");
103+
}
104+
105+
let out = String::from_utf8(out.stdout)?;
106+
107+
let rev = out
108+
.lines()
109+
.next()
110+
.context("should always find at least one line")?;
111+
112+
Ok(Arc::new(ArcSwap::from_pointee(rev.into())))
113+
}
114+
115+
pub(super) fn for_each_changed_file(
116+
cwd: &Path,
117+
callback: impl Fn(Result<FileChange>) -> bool,
118+
) -> Result<()> {
119+
let jj_root_dir = find_jj_root(cwd)?;
120+
121+
let out = Command::new("jj")
122+
.arg("--repository")
123+
.arg(jj_root_dir)
124+
.args([
125+
"--ignore-working-copy",
126+
"log",
127+
"--color",
128+
"never",
129+
"--revisions",
130+
"@", // Only display the current revision
131+
"--no-graph",
132+
"--no-pager",
133+
"--template",
134+
"",
135+
"--types",
136+
])
137+
.arg(cwd)
138+
.output()?;
139+
140+
if !out.status.success() {
141+
anyhow::bail!("jj log command executed but failed");
142+
}
143+
144+
let out = String::from_utf8(out.stdout)?;
145+
146+
for line in out.lines() {
147+
let mut split = line.splitn(2, ' ');
148+
149+
let Some(status) = split.next() else { continue; };
150+
let Some(path) = split.next() else { continue; };
151+
152+
let Some(change) = status_to_change(status, path) else { continue };
153+
154+
if !callback(Ok(change)) {
155+
break;
156+
}
157+
}
158+
159+
Ok(())
160+
}
161+
162+
/// Move up until we find the repository's root
163+
fn find_jj_root(file: &Path) -> Result<&Path> {
164+
file.ancestors()
165+
.find(|p| p.join(".jj").exists())
166+
.context("no .jj dir found in parents")
167+
}
168+
169+
/// Associate a status to a `FileChange`.
170+
fn status_to_change(status: &str, path: &str) -> Option<FileChange> {
171+
// Syntax: <https://github.com/martinvonz/jj/blob/320f50e00fcbd0d3ce27feb1e14b8e36d76b658f/cli/src/diff_util.rs#L68>
172+
Some(match status {
173+
"FF" | "LL" | "CF" | "CL" | "FL" | "LF" => FileChange::Modified { path: path.into() },
174+
"-F" | "-L" => FileChange::Untracked { path: path.into() },
175+
"F-" | "L-" => FileChange::Deleted { path: path.into() },
176+
"FC" | "LC" => FileChange::Conflict { path: path.into() },
177+
// We ignore gitsubmodules here since they not interesting in the context of
178+
// a file editor.
179+
_ => return None,
180+
})
181+
}
182+
183+
#[cfg(test)]
184+
mod tests {
185+
use std::path::PathBuf;
186+
187+
use super::*;
188+
189+
#[test]
190+
fn test_status_to_change() {
191+
let p = "helix-vcs/src/lib.rs";
192+
let pb = PathBuf::from(p);
193+
194+
for s in ["FF", "LL", "CF", "CL", "FL", "LF"] {
195+
assert_eq!(
196+
status_to_change(s, p).unwrap(),
197+
FileChange::Modified { path: pb.clone() }
198+
);
199+
}
200+
for s in ["-F", "-L"] {
201+
assert_eq!(
202+
status_to_change(s, p).unwrap(),
203+
FileChange::Untracked { path: pb.clone() }
204+
);
205+
}
206+
for s in ["F-", "L-"] {
207+
assert_eq!(
208+
status_to_change(s, p).unwrap(),
209+
FileChange::Deleted { path: pb.clone() }
210+
);
211+
}
212+
for s in ["FC", "LC"] {
213+
assert_eq!(
214+
status_to_change(s, p).unwrap(),
215+
FileChange::Conflict { path: pb.clone() }
216+
);
217+
}
218+
for s in ["GG", "LG", "ARO", "", " ", " "] {
219+
assert_eq!(status_to_change(s, p), None);
220+
}
221+
}
222+
}

helix-vcs/src/lib.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ use std::{
88
#[cfg(feature = "git")]
99
mod git;
1010

11+
#[cfg(feature = "jujutsu")]
12+
mod jujutsu;
13+
1114
mod diff;
1215

1316
pub use diff::{DiffHandle, Hunk};
@@ -75,6 +78,8 @@ impl Default for DiffProviderRegistry {
7578
let providers = vec![
7679
#[cfg(feature = "git")]
7780
DiffProvider::Git,
81+
#[cfg(feature = "jujutsu")]
82+
DiffProvider::Jujutsu,
7883
];
7984
DiffProviderRegistry { providers }
8085
}
@@ -88,6 +93,8 @@ impl Default for DiffProviderRegistry {
8893
pub enum DiffProvider {
8994
#[cfg(feature = "git")]
9095
Git,
96+
#[cfg(feature = "jujutsu")]
97+
Jujutsu,
9198
None,
9299
}
93100

@@ -96,6 +103,8 @@ impl DiffProvider {
96103
match self {
97104
#[cfg(feature = "git")]
98105
Self::Git => git::get_diff_base(file),
106+
#[cfg(feature = "jujutsu")]
107+
Self::Jujutsu => jujutsu::get_diff_base(file),
99108
Self::None => bail!("No diff support compiled in"),
100109
}
101110
}
@@ -104,6 +113,8 @@ impl DiffProvider {
104113
match self {
105114
#[cfg(feature = "git")]
106115
Self::Git => git::get_current_head_name(file),
116+
#[cfg(feature = "jujutsu")]
117+
Self::Jujutsu => jujutsu::get_current_head_name(file),
107118
Self::None => bail!("No diff support compiled in"),
108119
}
109120
}
@@ -116,6 +127,8 @@ impl DiffProvider {
116127
match self {
117128
#[cfg(feature = "git")]
118129
Self::Git => git::for_each_changed_file(cwd, f),
130+
#[cfg(feature = "jujutsu")]
131+
Self::Jujutsu => jujutsu::for_each_changed_file(cwd, f),
119132
Self::None => bail!("No diff support compiled in"),
120133
}
121134
}

helix-vcs/src/status.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::path::{Path, PathBuf};
22

3+
#[derive(Debug, PartialEq)]
34
pub enum FileChange {
45
Untracked {
56
path: PathBuf,

0 commit comments

Comments
 (0)