Skip to content

Commit f597775

Browse files
committed
feat: vcs: support Jujutsu as a diff-provider
1 parent 8a78745 commit f597775

File tree

5 files changed

+341
-20
lines changed

5 files changed

+341
-20
lines changed

helix-term/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ assets = [
3131
]
3232

3333
[features]
34-
default = ["git"]
34+
default = ["git", "jj"]
3535
unicode-lines = ["helix-core/unicode-lines", "helix-view/unicode-lines"]
3636
integration = ["helix-event/integration_test"]
37+
# VCS features
3738
git = ["helix-vcs/git"]
39+
jj = ["helix-vcs/jj"]
3840

3941
[[bin]]
4042
name = "hx"

helix-vcs/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ gix = { version = "0.73.0", features = ["attributes", "parallel", "status"], def
2121
imara-diff = "0.2.0"
2222
anyhow = "1"
2323
log = "0.4"
24+
tempfile = { version = "3.13", optional = true }
2425

2526
[features]
2627
git = ["gix"]
28+
jj = ["tempfile"]
2729

2830
[dev-dependencies]
2931
tempfile.workspace = true

helix-vcs/src/jj.rs

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
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, Result};
13+
use arc_swap::ArcSwap;
14+
15+
use crate::FileChange;
16+
17+
pub(super) fn get_diff_base(repo: &Path, file: &Path) -> Result<Vec<u8>> {
18+
let file_relative_to_root = file
19+
.strip_prefix(repo)
20+
.context("failed to strip JJ repo root path from file")?;
21+
22+
let tmpfile = tempfile::NamedTempFile::with_prefix("helix-jj-diff-")
23+
.context("could not create tempfile to save jj diff base")?;
24+
let tmppath = tmpfile.path();
25+
26+
let copy_bin = if cfg!(windows) { "copy.exe" } else { "cp" };
27+
28+
let status = Command::new("jj")
29+
.arg("--repository")
30+
.arg(repo)
31+
.args([
32+
"--ignore-working-copy",
33+
"diff",
34+
"--revision",
35+
"@",
36+
"--config-toml",
37+
])
38+
// Copy the temporary file provided by jujutsu to a temporary path of our own,
39+
// because the `$left` directory is deleted when `jj` finishes executing.
40+
.arg(format!(
41+
"ui.diff.tool = ['{exe}', '$left/{base}', '{target}']",
42+
exe = copy_bin,
43+
base = file_relative_to_root.display(),
44+
// Where to copy the jujutsu-provided file
45+
target = tmppath.display(),
46+
))
47+
// Restrict the diff to the current file
48+
.arg(file)
49+
.stdout(std::process::Stdio::null())
50+
.stderr(std::process::Stdio::null())
51+
.status()
52+
.context("failed to execute jj diff command")?;
53+
54+
let use_jj_path = status.success() && std::fs::metadata(tmppath).map_or(false, |m| m.len() > 0);
55+
// If the copy call inside `jj diff` succeeded, the tempfile is the one containing the base
56+
// else it's just the original file (so no diff). We check for size since `jj` can return
57+
// 0-sized files when there are no diffs to present for the file.
58+
let diff_base_path = if use_jj_path { tmppath } else { file };
59+
60+
// If the command succeeded, it means we either copied the jujutsu base or the current file,
61+
// so there should always be something to read and compare to.
62+
std::fs::read(diff_base_path).context("could not read jj diff base from the target")
63+
}
64+
65+
pub(crate) fn get_current_head_name(repo: &Path) -> Result<Arc<ArcSwap<Box<str>>>> {
66+
// See <https://github.com/martinvonz/jj/blob/main/docs/templates.md>
67+
//
68+
// This will produce the following:
69+
//
70+
// - If there are no branches: `vyvqwlmsvnlkmqrvqktpuluvuknuxpmm`
71+
// - If there is a single branch: `vyvqwlmsvnlkmqrvqktpuluvuknuxpmm (master)`
72+
// - If there are 2+ branches: `vyvqwlmsvnlkmqrvqktpuluvuknuxpmm (master, jj-diffs)`
73+
//
74+
// Always using the long id makes it easy to share it with others, which would not be the
75+
// case for shorter ones: they could have a local change that renders it ambiguous.
76+
let template = r#"separate(" ", change_id, surround("(", ")", branches.join(", ")))"#;
77+
78+
let out = Command::new("jj")
79+
.arg("--repository")
80+
.arg(repo)
81+
.args([
82+
"--ignore-working-copy",
83+
"log",
84+
"--color",
85+
"never",
86+
"--revisions",
87+
"@", // Only display the current revision
88+
"--no-graph",
89+
"--no-pager",
90+
"--template",
91+
template,
92+
])
93+
.output()?;
94+
95+
if !out.status.success() {
96+
anyhow::bail!("jj log command executed but failed");
97+
}
98+
99+
let out = String::from_utf8(out.stdout)?;
100+
101+
let rev = out
102+
.lines()
103+
.next()
104+
// Contrary to git, if a JJ repo exists, it always has at least two revisions:
105+
// the root (zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz), which cannot be focused, and the current
106+
// one, which exists even for brand new repos.
107+
.context("should always find at least one line")?;
108+
109+
Ok(Arc::new(ArcSwap::from_pointee(rev.into())))
110+
}
111+
112+
pub(crate) fn for_each_changed_file(
113+
repo: &Path,
114+
callback: impl Fn(Result<FileChange>) -> bool,
115+
) -> Result<()> {
116+
let out = Command::new("jj")
117+
.arg("--repository")
118+
.arg(repo)
119+
.args([
120+
"--ignore-working-copy",
121+
"diff",
122+
"--color",
123+
"never",
124+
"--revision",
125+
"@", // Only display the current revision
126+
"--no-pager",
127+
"--types",
128+
])
129+
.output()?;
130+
131+
if !out.status.success() {
132+
anyhow::bail!("jj log command executed but failed");
133+
}
134+
135+
let out = String::from_utf8(out.stdout)?;
136+
137+
for line in out.lines() {
138+
let Some((status, path)) = line.split_once(' ') else {
139+
continue;
140+
};
141+
142+
let Some(change) = status_to_change(status, path) else {
143+
continue;
144+
};
145+
146+
if !callback(Ok(change)) {
147+
break;
148+
}
149+
}
150+
151+
Ok(())
152+
}
153+
154+
pub(crate) fn open_repo(repo_path: &Path) -> Result<()> {
155+
assert!(
156+
repo_path.join(".jj").exists(),
157+
"no .jj where one was expected: {}",
158+
repo_path.display(),
159+
);
160+
161+
let status = Command::new("jj")
162+
.args(["--ignore-working-copy", "root"])
163+
.stdout(std::process::Stdio::null())
164+
.stderr(std::process::Stdio::null())
165+
.status()?;
166+
167+
if status.success() {
168+
Ok(())
169+
} else {
170+
anyhow::bail!("not a valid JJ repo")
171+
}
172+
}
173+
174+
/// Associate a status to a `FileChange`.
175+
fn status_to_change(status: &str, path: &str) -> Option<FileChange> {
176+
if let rename @ Some(_) = find_rename(path) {
177+
return rename;
178+
}
179+
180+
// Syntax: <https://github.com/martinvonz/jj/blob/f9cfa5c9ce0eacd38e961c954e461e5e73067d22/cli/src/diff_util.rs#L97-L101>
181+
Some(match status {
182+
"FF" | "LL" | "CF" | "CL" | "FL" | "LF" => FileChange::Modified { path: path.into() },
183+
"-F" | "-L" => FileChange::Untracked { path: path.into() },
184+
"F-" | "L-" => FileChange::Deleted { path: path.into() },
185+
"FC" | "LC" => FileChange::Conflict { path: path.into() },
186+
// We ignore gitsubmodules here since they not interesting in the context of
187+
// a file editor.
188+
_ => return None,
189+
})
190+
}
191+
192+
fn find_rename(path: &str) -> Option<FileChange> {
193+
let (start, rest) = path.split_once('{')?;
194+
let (from, rest) = rest.split_once(" => ")?;
195+
let (to, end) = rest.split_once('}')?;
196+
197+
Some(FileChange::Renamed {
198+
from_path: format!("{start}{from}{end}").into(),
199+
to_path: format!("{start}{to}{end}").into(),
200+
})
201+
}
202+
203+
#[cfg(test)]
204+
mod tests {
205+
use std::path::PathBuf;
206+
207+
use super::*;
208+
209+
#[test]
210+
fn test_status_to_change() {
211+
let p = "helix-vcs/src/lib.rs";
212+
let pb = PathBuf::from(p);
213+
214+
for s in ["FF", "LL", "CF", "CL", "FL", "LF"] {
215+
assert_eq!(
216+
status_to_change(s, p).unwrap(),
217+
FileChange::Modified { path: pb.clone() }
218+
);
219+
}
220+
for s in ["-F", "-L"] {
221+
assert_eq!(
222+
status_to_change(s, p).unwrap(),
223+
FileChange::Untracked { path: pb.clone() }
224+
);
225+
}
226+
for s in ["F-", "L-"] {
227+
assert_eq!(
228+
status_to_change(s, p).unwrap(),
229+
FileChange::Deleted { path: pb.clone() }
230+
);
231+
}
232+
for s in ["FC", "LC"] {
233+
assert_eq!(
234+
status_to_change(s, p).unwrap(),
235+
FileChange::Conflict { path: pb.clone() }
236+
);
237+
}
238+
for s in ["GG", "LG", "ARO", "", " ", " "] {
239+
assert_eq!(status_to_change(s, p), None);
240+
}
241+
}
242+
243+
#[test]
244+
fn test_find_rename() {
245+
fn check(path: &str, expected: Option<(&str, &str)>) {
246+
let result = find_rename(path);
247+
248+
assert_eq!(
249+
result,
250+
expected.map(|(f, t)| FileChange::Renamed {
251+
from_path: f.into(),
252+
to_path: t.into()
253+
})
254+
)
255+
}
256+
257+
// No renames
258+
check("helix-term/Cargo.toml", None);
259+
check("helix-term/src/lib.rs", None);
260+
261+
// Rename of first element in path
262+
check(
263+
"{helix-term => helix-term2}/Cargo.toml",
264+
Some(("helix-term/Cargo.toml", "helix-term2/Cargo.toml")),
265+
);
266+
// Rename of final element in path
267+
check(
268+
"helix-term/{Cargo.toml => Cargo.toml2}",
269+
Some(("helix-term/Cargo.toml", "helix-term/Cargo.toml2")),
270+
);
271+
// Rename of a single dir in the middle
272+
check(
273+
"helix-term/{src => src2}/lib.rs",
274+
Some(("helix-term/src/lib.rs", "helix-term/src2/lib.rs")),
275+
);
276+
// Rename of two dirs in the middle
277+
check(
278+
"helix-term/{src/ui => src2/ui2}/text.rs",
279+
Some(("helix-term/src/ui/text.rs", "helix-term/src2/ui2/text.rs")),
280+
);
281+
}
282+
}

0 commit comments

Comments
 (0)