A post-modern development environment.
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: vcs: support Jujutsu as a diff-provider

author
Alexis (Poliorcetics) Bourget
committer
Isaac Corbrey
date (Jul 3, 2026, 3:22 PM -0400) commit 6d140b48 parent c36c367f change-id oozxqurl
+590 -19
+1
Cargo.lock
··· 1698 1698 "helix-event", 1699 1699 "imara-diff", 1700 1700 "log", 1701 + "memchr", 1701 1702 "parking_lot", 1702 1703 "tempfile", 1703 1704 "tokio",
+3 -1
helix-term/Cargo.toml
··· 31 31 ] 32 32 33 33 [features] 34 - default = ["git"] 34 + default = ["git", "jj"] 35 35 unicode-lines = ["helix-core/unicode-lines", "helix-view/unicode-lines"] 36 36 integration = ["helix-event/integration_test"] 37 + # VCS features 37 38 git = ["helix-vcs/git"] 39 + jj = ["helix-vcs/jj"] 38 40 39 41 [[bin]] 40 42 name = "hx"
+3
helix-vcs/Cargo.toml
··· 21 21 imara-diff = "0.2.0" 22 22 anyhow = "1" 23 23 log = "0.4" 24 + memchr = { version = "2.7", optional = true } 25 + tempfile = { version = "3.13", optional = true } 24 26 25 27 [features] 26 28 git = ["gix"] 29 + jj = ["memchr", "tempfile"] 27 30 28 31 [dev-dependencies] 29 32 tempfile.workspace = true
+530
helix-vcs/src/jj.rs
··· 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, PathBuf}; 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 + // Ensure we're working in the expected repository. 30 + .arg("--repository") 31 + .arg(repo) 32 + .args([ 33 + // Do not commit changes: this avoids Helix updating the JJ state every time this runs. 34 + "--ignore-working-copy", 35 + "diff", 36 + // Ensuring no configuration option will interfere. 37 + "--color", 38 + "never", 39 + "--no-pager", 40 + // Work with current revision only. 41 + "--revision", 42 + "@", 43 + // Pass custom diff configuration. 44 + "--config", 45 + ]) 46 + // Copy the temporary file provided by jujutsu to a temporary path of our own, 47 + // because the `$left` directory is deleted when `jj` finishes executing. 48 + .arg(format!( 49 + "ui.diff-formatter=['{exe}', '$left/{base}', '{target}']", 50 + exe = copy_bin, 51 + base = file_relative_to_root.display(), 52 + // Where to copy the jujutsu-provided file 53 + target = tmppath.display(), 54 + )) 55 + // Restrict the diff to the current file 56 + .arg(file) 57 + .stdout(std::process::Stdio::null()) 58 + .stderr(std::process::Stdio::null()) 59 + .status() 60 + .context("failed to execute `jj diff` to get diff base")?; 61 + 62 + let use_jj_path = status.success() && std::fs::metadata(tmppath).is_ok_and(|m| m.len() > 0); 63 + // If the copy call inside `jj diff` succeeded, the tempfile is the one containing the base 64 + // else it's just the original file (so no diff). We check for size since `jj` can return 65 + // 0-sized files when there are no diffs to present for the file. 66 + let diff_base_path = if use_jj_path { tmppath } else { file }; 67 + 68 + // If the command succeeded, it means we either copied the jujutsu base or defaulted to the 69 + // current file, so there should always be something to read and compare to. 70 + std::fs::read(diff_base_path).context("could not read jj diff base from the target") 71 + } 72 + 73 + pub(crate) fn get_current_head_name(repo: &Path) -> Result<Arc<ArcSwap<Box<str>>>> { 74 + // See <https://docs.jj-vcs.dev/latest/templates/> 75 + // 76 + // This will produce the following: 77 + // 78 + // quvlrxss 79 + // kmlpqmrv main-1 80 + // sxrrsnun main-2 main-3* 81 + // kzqnuykl 82 + // 83 + // There will be a `*` when a bookmark has been modified compared to its remote. 84 + // 85 + // We use a short ID with 8 characters because in practice the change ID is extremely unlikely 86 + // to conflict since we only consider mutable commits (like most jj commands will do by default) 87 + // and this leaves space for bookmarks to appear in the status bar even on narrower screens. 88 + let template = r#"change_id.short(8) ++ " " ++ bookmarks ++ "\n""#; 89 + 90 + let out = Command::new("jj") 91 + // Ensure we're working in the expected repository. 92 + .arg("--repository") 93 + .arg(repo) 94 + .args([ 95 + // Do not commit changes: this avoids Helix updating the JJ state every time this runs. 96 + "--ignore-working-copy", 97 + "log", 98 + // Ensuring no configuration option will interfere. 99 + "--color", 100 + "never", 101 + "--no-graph", 102 + "--no-pager", 103 + // Includes from last immutable revision to current change. 104 + "--revisions", 105 + // <https://docs.jj-vcs.dev/latest/revsets/#built-in-aliases> 106 + "mutable()-::@", 107 + "--template", 108 + template, 109 + ]) 110 + .output()?; 111 + 112 + anyhow::ensure!(out.status.success(), "`jj log` executed but failed"); 113 + 114 + let output = String::from_utf8(out.stdout).context("`jj log` did not output valid UTF-8")?; 115 + let head_text = extract_head_name(&output)?; 116 + 117 + Ok(Arc::new(ArcSwap::from_pointee(head_text.into()))) 118 + } 119 + 120 + /// Helper function to make the extracting logic testable 121 + fn extract_head_name(output: &str) -> Result<String> { 122 + let mut lines = output.lines(); 123 + let mut next = || lines.next().and_then(|line| line.split_once(' ')); 124 + 125 + let (rev, exact_bookmarks) = next() 126 + // Contrary to git, if a JJ repo exists, it always has at least two revisions: 127 + // the root (zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz), which cannot be focused, and the current 128 + // one, which exists even for brand new repos. 129 + .context("should always find at least one line")?; 130 + 131 + let head_text = if !exact_bookmarks.is_empty() { 132 + // Parentheses: bookmarks are exactly on current change. 133 + format!("{rev} ({exact_bookmarks})") 134 + } else { 135 + let mutable_ancestor_bookmarks = std::iter::from_fn(next) 136 + .map(|e| e.1) 137 + .filter(|s| !s.is_empty()) 138 + .collect::<Vec<_>>(); 139 + if mutable_ancestor_bookmarks.is_empty() { 140 + // Found no bookmarks amongst mutable ancestors. 141 + rev.to_string() 142 + } else { 143 + // Angle brackets: bookmarks are on mutable ancestors. 144 + format!("{rev} [{}]", mutable_ancestor_bookmarks.join(" ").trim()) 145 + } 146 + }; 147 + 148 + Ok(head_text) 149 + } 150 + 151 + pub(crate) fn for_each_changed_file( 152 + repo: &Path, 153 + callback: impl Fn(Result<FileChange>) -> bool, 154 + ) -> Result<()> { 155 + // First we get conflict via another command because we have to, `jj diff` cannot list them and 156 + // `jj status` does not support templating. 157 + let out = Command::new("jj") 158 + // Ensure we're working in the expected repository. 159 + .arg("--repository") 160 + .arg(repo) 161 + .args([ 162 + // Do not commit changes: this avoids Helix updating the JJ state every time this runs. 163 + "--ignore-working-copy", 164 + "file", 165 + "list", 166 + // Ensuring no configuration option will interfere. 167 + "--color", 168 + "never", 169 + "--no-pager", 170 + // Work with current revision only. 171 + "--revision", 172 + "@", 173 + // List per-file diff types, do not show diff itself. 174 + "--template", 175 + "if(conflict, path ++ \" //\n\")", 176 + ]) 177 + .output()?; 178 + 179 + anyhow::ensure!(out.status.success(), "`jj file list` executed but failed"); 180 + 181 + for entry in split_double_slash(&out.stdout, true) { 182 + if entry.is_empty() { 183 + continue; 184 + } 185 + 186 + let path = make_pathbuf(entry); 187 + 188 + if !callback(Ok(FileChange::Conflict { path })) { 189 + return Ok(()); 190 + } 191 + } 192 + 193 + // The forward slash is the only character that is disallowed in both Unix and Windows paths, 194 + // meaning `//` cannot ever appear in them on any platform. 195 + // 196 + // <https://docs.jj-vcs.dev/latest/templates/#treediffentry-type> 197 + // 198 + // Lines will be of the following format (examples) 199 + // 200 + // ``` 201 + // modified // conflict.txt // conflict // conflict.txt // file //\n 202 + // added // added file.rs // // added file.rs // symlink //\n 203 + // renamed // renamed.nix // file // after-rename.nix // file //\n 204 + // removed // testing.ts // file // testing.ts // //\n 205 + // ``` 206 + // 207 + // Note we use `//\n` as the end delimiter to allow for files that contains `\n` in their name. 208 + // 209 + // For the file types, we will only concern ourselves with `file` and `symlink`, anything else 210 + // will get dropped just like `git.rs` does. 211 + let template = concat!( 212 + // First, print the status, it will determinate some of our parsing. 213 + // One of "modified", "added", "removed", "copied", or "renamed". 214 + "status ++ ", 215 + "\" // \" ++", 216 + "source.path() ++ ", 217 + "\" // \" ++ ", 218 + // <https://docs.jj-vcs.dev/latest/templates/#treeentry-type> 219 + "source.file_type() ++ ", 220 + "\" // \" ++ ", 221 + "target.path() ++ ", 222 + "\" // \" ++ ", 223 + // <https://docs.jj-vcs.dev/latest/templates/#treeentry-type> 224 + "target.file_type() ++ ", 225 + "\" //\\n\"", 226 + ); 227 + 228 + let out = Command::new("jj") 229 + // Ensure we're working in the expected repository. 230 + .arg("--repository") 231 + .arg(repo) 232 + .args([ 233 + // Do not commit changes: this avoids Helix updating the JJ state every time this runs. 234 + "--ignore-working-copy", 235 + "diff", 236 + // Ensuring no configuration option will interfere. 237 + "--color", 238 + "never", 239 + "--no-pager", 240 + // Work with current revision only. 241 + "--revision", 242 + "@", 243 + // List per-file diff types, do not show diff itself. 244 + "--template", 245 + template, 246 + ]) 247 + .output()?; 248 + 249 + anyhow::ensure!(out.status.success(), "`jj diff` executed but failed"); 250 + 251 + for entry in split_double_slash(&out.stdout, true) { 252 + if entry.is_empty() { 253 + continue; 254 + } 255 + 256 + let Some(change) = entry_to_change(entry) else { 257 + continue; 258 + }; 259 + 260 + if !callback(Ok(change)) { 261 + return Ok(()); 262 + } 263 + } 264 + 265 + Ok(()) 266 + } 267 + 268 + pub(crate) fn open_repo(repo_path: &Path) -> Result<()> { 269 + assert!( 270 + repo_path.join(".jj").exists(), 271 + "no .jj where one was expected: {}", 272 + repo_path.display(), 273 + ); 274 + 275 + let status = Command::new("jj") 276 + .args(["--ignore-working-copy", "root"]) 277 + .stdout(std::process::Stdio::null()) 278 + .stderr(std::process::Stdio::null()) 279 + .status()?; 280 + 281 + if status.success() { 282 + Ok(()) 283 + } else { 284 + anyhow::bail!("not a valid JJ repo") 285 + } 286 + } 287 + 288 + /// Associate a status to a `FileChange`. 289 + /// 290 + /// Gets something like `modified // conflict.txt // conflict // conflict.txt // file` as input. 291 + fn entry_to_change(entry: &[u8]) -> Option<FileChange> { 292 + let mut sections = split_double_slash(entry, false); 293 + 294 + let kind = sections.next()?; 295 + 296 + let source_path = sections.next()?; 297 + let source_file_type = sections.next()?; 298 + 299 + let target_path = sections.next()?; 300 + let target_file_type = sections.next()?; 301 + 302 + // Never generated in practice but let's be thourough in case that changes. 303 + // <https://github.com/jj-vcs/jj/issues/7264> 304 + if target_file_type == b"conflict" { 305 + return Some(FileChange::Conflict { 306 + path: make_pathbuf(target_path), 307 + }); 308 + } 309 + 310 + let file_types = [ 311 + // The empty file type is used when the file didn't exist before or doesn't exist now, 312 + // e.g. when added or removed. 313 + "".as_bytes(), 314 + "conflict".as_bytes(), 315 + "file".as_bytes(), 316 + "symlink".as_bytes(), 317 + ]; 318 + if !file_types.contains(&source_file_type) || !file_types.contains(&target_file_type) { 319 + return None; 320 + } 321 + 322 + let change = match kind { 323 + b"added" | b"copied" => FileChange::Untracked { 324 + path: make_pathbuf(target_path), 325 + }, 326 + b"modified" => FileChange::Modified { 327 + path: make_pathbuf(target_path), 328 + }, 329 + b"removed" => FileChange::Deleted { 330 + path: make_pathbuf(target_path), 331 + }, 332 + b"renamed" => FileChange::Renamed { 333 + from_path: make_pathbuf(source_path), 334 + to_path: make_pathbuf(target_path), 335 + }, 336 + _ => return None, 337 + }; 338 + 339 + Some(change) 340 + } 341 + 342 + #[cfg(any(unix, target_os = "wasi"))] 343 + fn make_pathbuf(sl: &[u8]) -> PathBuf { 344 + #[cfg(unix)] 345 + use std::os::unix::ffi::OsStrExt; 346 + #[cfg(target_os = "wasi")] 347 + use std::os::wasi::ffi::OsStrExt; 348 + 349 + PathBuf::from(std::ffi::OsStr::from_bytes(sl)) 350 + } 351 + 352 + // Imperfect fallback for platforms where we don't know about an always-correct method. 353 + // In practice, non-UTF8 paths are vanishingly rare and should not be an issue for anyone running a 354 + // Rust binary like Helix. 355 + #[cfg(not(any(unix, target_os = "wasi")))] 356 + fn make_pathbuf(sl: &[u8]) -> PathBuf { 357 + let s = String::from_utf8_lossy(sl); 358 + PathBuf::from(s.into_owned()) 359 + } 360 + 361 + /// Split a byte slice on either ` // ` or ` //\n` depending on `with_newline`. 362 + fn split_double_slash(slice: &[u8], with_newline: bool) -> impl Iterator<Item = &[u8]> { 363 + let mut done = false; 364 + let mut rest = slice; 365 + let needle = if with_newline { " //\n" } else { " // " }.as_bytes(); 366 + std::iter::from_fn(move || { 367 + if done { 368 + return None; 369 + } 370 + let result = match memchr::memmem::find(rest, needle) { 371 + Some(pos) => { 372 + // We use the non-panicking variants to avoid adding the panic machinery here when 373 + // we know it won't ever panic in practice (unless there is a bug in memchr, which 374 + // is unlikely given how much the crate is used). 375 + let (before, after) = rest.split_at_checked(pos).unwrap_or_default(); 376 + rest = after.get(4..).unwrap_or_default(); 377 + before 378 + } 379 + None => { 380 + done = true; 381 + rest 382 + } 383 + }; 384 + Some(result) 385 + }) 386 + } 387 + 388 + #[cfg(test)] 389 + mod tests { 390 + use super::*; 391 + 392 + #[test] 393 + fn test_split_double_slash_no_newline() { 394 + let input = b"modified // test.rs // file // test.rs // file //\n"; 395 + let expected = [ 396 + "modified".as_bytes(), 397 + "test.rs".as_bytes(), 398 + "file".as_bytes(), 399 + "test.rs".as_bytes(), 400 + "file //\n".as_bytes(), // Not trimmed since we're not splitting on newlines 401 + ]; 402 + 403 + let result = split_double_slash(input, false).collect::<Vec<_>>(); 404 + 405 + assert_eq!(result, expected); 406 + } 407 + 408 + #[test] 409 + fn test_split_double_slash_with_newline() { 410 + let input = concat!( 411 + "modified // test.rs // file // test.rs // file //\n", 412 + "modified // test.rs // file // test.rs // file //\n", 413 + ) 414 + .as_bytes(); 415 + let expected = [ 416 + "modified // test.rs // file // test.rs // file".as_bytes(), 417 + "modified // test.rs // file // test.rs // file".as_bytes(), 418 + // We expect an empty slice after the last split 419 + &[], 420 + ]; 421 + 422 + let result = split_double_slash(input, true).collect::<Vec<_>>(); 423 + 424 + assert_eq!(result, expected); 425 + } 426 + 427 + #[test] 428 + fn test_entry_to_change() { 429 + let p = "helix-vcs/src/lib.rs"; 430 + let pb = PathBuf::from(p); 431 + 432 + let entry = |kind, (t1, t2)| { 433 + entry_to_change(format!("{kind} // {p} // {t1} // {p} // {t2}").as_bytes()) 434 + }; 435 + 436 + for types in [ 437 + ("conflict", "file"), 438 + ("conflict", "symlink"), 439 + ("file", "file"), 440 + ("file", "symlink"), 441 + ("symlink", "file"), 442 + ("symlink", "symlink"), 443 + ] { 444 + assert_eq!( 445 + entry("modified", types).unwrap(), 446 + FileChange::Modified { path: pb.clone() } 447 + ); 448 + } 449 + 450 + for types in [("", "file"), ("", "symlink")] { 451 + assert_eq!( 452 + entry("added", types).unwrap(), 453 + FileChange::Untracked { path: pb.clone() } 454 + ); 455 + } 456 + 457 + for types in [ 458 + ("file", "file"), 459 + ("file", "symlink"), 460 + ("symlink", "file"), 461 + ("symlink", "symlink"), 462 + ] { 463 + assert_eq!( 464 + entry("copied", types).unwrap(), 465 + FileChange::Untracked { path: pb.clone() } 466 + ); 467 + } 468 + 469 + for types in [("conflict", ""), ("file", ""), ("symlink", "")] { 470 + assert_eq!( 471 + entry("removed", types).unwrap(), 472 + FileChange::Deleted { path: pb.clone() } 473 + ); 474 + } 475 + 476 + for types in [ 477 + ("", "conflict"), 478 + ("conflict", "conflict"), 479 + ("file", "conflict"), 480 + ("symlink", "conflict"), 481 + ] { 482 + assert_eq!( 483 + entry("conflict", types).unwrap(), 484 + FileChange::Conflict { path: pb.clone() } 485 + ); 486 + } 487 + 488 + for invalid_kind in ["invalid", ""] { 489 + assert_eq!(entry(invalid_kind, ("file", "file")), None); 490 + } 491 + 492 + for invalid_types in [ 493 + ("tree", "file"), 494 + ("submodule", "file"), 495 + ("abcdef", "file"), 496 + ("file", "tree"), 497 + ("file", "submodule"), 498 + ("file", "abcdef"), 499 + ] { 500 + assert_eq!(entry("modified", invalid_types), None); 501 + } 502 + } 503 + 504 + #[test] 505 + fn test_extract_head_name() { 506 + // No bookmarks. 507 + let result = extract_head_name("abcdefgh \nijklmnop \n").unwrap(); 508 + assert_eq!(result, "abcdefgh"); 509 + 510 + // Single exact bookmark. 511 + let result = extract_head_name("abcdefgh bookmark*\nijklmnop other-bookmark*\n").unwrap(); 512 + assert_eq!(result, "abcdefgh (bookmark*)"); 513 + 514 + // Multiple exact bookmarks. 515 + let result = extract_head_name(concat!( 516 + "abcdefgh bookmark bookmark-v2\n", 517 + "ijklmnop other-ookmark\n", 518 + )) 519 + .unwrap(); 520 + assert_eq!(result, "abcdefgh (bookmark bookmark-v2)"); 521 + 522 + // Single inexact bookmark. 523 + let result = extract_head_name("abcdefgh \nijklmnop other-bookmark\n").unwrap(); 524 + assert_eq!(result, "abcdefgh [other-bookmark]"); 525 + 526 + // Multiple inexact bookmarks. 527 + let result = extract_head_name("abcdefgh \nijklmnop bookmark* bookmark-v2\n").unwrap(); 528 + assert_eq!(result, "abcdefgh [bookmark* bookmark-v2]"); 529 + } 530 + }
+52 -18
helix-vcs/src/lib.rs
··· 8 8 9 9 #[cfg(feature = "git")] 10 10 mod git; 11 + #[cfg(feature = "jj")] 12 + mod jj; 11 13 12 14 mod diff; 13 15 ··· 81 83 } 82 84 83 85 /// Creation and update methods 84 - #[cfg_attr(not(feature = "git"), allow(unused))] 86 + #[cfg_attr(not(any(feature = "git", feature = "jj")), allow(unused))] 85 87 impl DiffProviderRegistry { 86 88 /// Register a provider (if any is found) for the given path. 87 89 /// ··· 102 104 return; 103 105 }; 104 106 105 - let result = match provider { 107 + let result: Result<(Arc<Path>, PossibleDiffProvider)> = match provider { 106 108 #[cfg(feature = "git")] 107 109 PossibleDiffProvider::Git => self.add_file_git(repo_path, trust_full), 110 + #[cfg(feature = "jj")] 111 + PossibleDiffProvider::JJ => self.add_file_jj(repo_path), 108 112 }; 109 113 110 114 match result { ··· 206 210 Err(err) => Err(err), 207 211 } 208 212 } 213 + 214 + /// Add the JJ repo to the known providers *if* it isn't already known. 215 + #[cfg(feature = "jj")] 216 + fn add_file_jj(&mut self, repo_path: &Path) -> Result<(Arc<Path>, PossibleDiffProvider)> { 217 + // Don't build a JJ repo object if there is already one for that path. 218 + if let Some((key, DiffProvider::JJ(_))) = self.providers.get_key_value(repo_path) { 219 + return Ok((Arc::clone(key), PossibleDiffProvider::JJ)); 220 + } 221 + 222 + match jj::open_repo(repo_path) { 223 + Ok(()) => { 224 + let key = Arc::from(repo_path); 225 + self.providers 226 + .insert(Arc::clone(&key), DiffProvider::JJ(Arc::clone(&key))); 227 + Ok((key, PossibleDiffProvider::JJ)) 228 + } 229 + Err(err) => Err(err), 230 + } 231 + } 209 232 } 210 233 211 234 /// A union type that includes all types that implement [DiffProvider]. We need this type to allow 212 235 /// cloning [DiffProviderRegistry] as `Clone` cannot be used in trait objects. 213 - /// 214 - /// `Copy` is simply to ensure the `clone()` call is the simplest it can be. 215 236 #[derive(Clone)] 216 237 pub enum DiffProvider { 217 238 #[cfg(feature = "git")] 218 239 Git(Box<gix::ThreadSafeRepository>), 240 + /// For [`jujutsu`](https://github.com/martinvonz/jj), we don't use the library but instead we 241 + /// call the binary because it can dynamically load backends, which the JJ library doesn't know about. 242 + #[cfg(feature = "jj")] 243 + JJ(Arc<Path>), 219 244 } 220 245 221 - #[cfg_attr(not(feature = "git"), allow(unused))] 246 + #[cfg_attr(not(any(feature = "git", feature = "jj")), allow(unused))] 222 247 impl DiffProvider { 223 248 fn get_diff_base(&self, file: &Path) -> Result<Vec<u8>> { 224 249 // We need the */ref else we're matching on a reference and Rust considers all references 225 - // inhabited. In our case 250 + // inhabited. 226 251 match *self { 227 252 #[cfg(feature = "git")] 228 253 Self::Git(ref repo) => git::get_diff_base(repo, file), 254 + #[cfg(feature = "jj")] 255 + Self::JJ(ref repo) => jj::get_diff_base(repo, file), 229 256 } 230 257 } 231 258 ··· 233 260 match *self { 234 261 #[cfg(feature = "git")] 235 262 Self::Git(ref repo) => git::get_current_head_name(repo), 263 + #[cfg(feature = "jj")] 264 + Self::JJ(ref repo) => jj::get_current_head_name(repo), 236 265 } 237 266 } 238 267 ··· 240 269 match *self { 241 270 #[cfg(feature = "git")] 242 271 Self::Git(ref repo) => git::for_each_changed_file(repo, f), 272 + #[cfg(feature = "jj")] 273 + Self::JJ(ref repo) => jj::for_each_changed_file(repo, f), 243 274 } 244 275 } 245 276 } ··· 249 280 /// Possibly a git repo rooted at the stored path (i.e. `<path>/.git` exists) 250 281 #[cfg(feature = "git")] 251 282 Git, 283 + /// Possibly a git repo rooted at the stored path (i.e. `<path>/.jj` exists) 284 + #[cfg(feature = "jj")] 285 + JJ, 252 286 } 253 287 254 288 /// Does *possible* diff provider auto detection. Returns the 'root' of the workspace ··· 256 290 /// We say possible because this function doesn't open the actual repository to check if that's 257 291 /// actually the case. 258 292 fn get_possible_provider(path: &Path) -> Option<(&Path, PossibleDiffProvider)> { 259 - if cfg!(feature = "git") { 260 - #[cfg_attr(not(feature = "git"), allow(unused))] 261 - fn check_path(path: &Path) -> Option<(&Path, PossibleDiffProvider)> { 262 - #[cfg(feature = "git")] 263 - if path.join(".git").try_exists().ok()? { 264 - return Some((path, PossibleDiffProvider::Git)); 265 - } 293 + // TODO(poliorcetics): make checking order configurable 294 + let checks: &[(&str, PossibleDiffProvider)] = &[ 295 + #[cfg(feature = "jj")] 296 + (".jj", PossibleDiffProvider::JJ), 297 + #[cfg(feature = "git")] 298 + (".git", PossibleDiffProvider::Git), 299 + ]; 266 300 267 - None 268 - } 269 - 301 + if !checks.is_empty() { 270 302 for parent in path.ancestors() { 271 - if let Some(path_and_provider) = check_path(parent) { 272 - return Some(path_and_provider); 303 + for &(repo_indic, pdp) in checks { 304 + if let Ok(true) = parent.join(repo_indic).try_exists() { 305 + return Some((parent, pdp)); 306 + } 273 307 } 274 308 } 275 309 }
+1
helix-vcs/src/status.rs
··· 1 1 use std::path::{Path, PathBuf}; 2 2 3 3 /// States for a file having been changed. 4 + #[derive(Debug, PartialEq, Eq)] 4 5 pub enum FileChange { 5 6 /// Not tracked by the VCS. 6 7 Untracked { path: PathBuf },