diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index bd4743a4..a66ae3de 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -31,11 +31,12 @@ assets = [ ] [features] -default = ["git", "jj"] +default = ["git", "hg", "jj"] unicode-lines = ["helix-core/unicode-lines", "helix-view/unicode-lines"] integration = ["helix-event/integration_test"] # VCS features git = ["helix-vcs/git"] +hg = ["helix-vcs/hg"] jj = ["helix-vcs/jj"] [[bin]] diff --git a/helix-vcs/Cargo.toml b/helix-vcs/Cargo.toml index 3ff74ebf..1d935f35 100644 --- a/helix-vcs/Cargo.toml +++ b/helix-vcs/Cargo.toml @@ -26,6 +26,7 @@ tempfile = { version = "3.13", optional = true } [features] git = ["gix"] +hg = ["memchr", "tempfile"] jj = ["memchr", "tempfile"] [dev-dependencies] diff --git a/helix-vcs/src/hg.rs b/helix-vcs/src/hg.rs new file mode 100644 index 00000000..cfe9171b --- /dev/null +++ b/helix-vcs/src/hg.rs @@ -0,0 +1,368 @@ +//! There is no published good rust implementation for accessing mercurial repos directly. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use arc_swap::ArcSwap; + +use crate::FileChange; + +pub(super) fn get_diff_base(repo: &Path, file: &Path) -> Result> { + let output = Command::new("chg") + // Ensure we're working in the expected repository. + .arg("--repository") + .arg(repo) + .args([ + "cat", + // Ensure no configuration option will interfere. + "--color=never", + "--pager=never", + // Restrict the diff to the current revision. + "--rev=.", + ]) + // Restrict the diff to the current file + .arg(file) + .stderr(std::process::Stdio::null()) + .output() + .context("failed to execute `chg cat` to get diff base")?; + + Ok(output.stdout) +} + +pub(crate) fn get_current_head_name(repo: &Path) -> Result>>> { + // Mercurial has named branches, bookmarks, and topics, the latter of which seems to be the + // recommended way to do feature branches. + let template = "{ \ + if(topic, \ + separate(' ', 's{topicidx}', '@', topic), \ + ifcontains(rev, revset('default'), \ + 'default', \ + short(node) \ + ) \ + ) \ + }\\n"; + + let out = Command::new("chg") + // Ensure we're working in the expected repository. + .arg("--repository") + .arg(repo) + .args([ + "log", + // Ensure no configuration option will interfere. + "--color=never", + "--pager=never", + // Use the current revision. + "--rev=.", + template, + ]) + .output()?; + + anyhow::ensure!(out.status.success(), "`chg log` executed but failed"); + + let output = String::from_utf8(out.stdout).context("`chg log` did not output valid UTF-8")?; + + if output.trim().is_empty() { + anyhow::bail!("repo is not initialized"); + } + + Ok(Arc::new(ArcSwap::from_pointee(output.into()))) +} + +// TODO: This does not work well with the base revision because we need to +// stitch together all changed files between base and working copy, but `chg +// status` doesn't provide range diff status and `chg diff` doesn't provide +// status like output AFAIK. + +pub(crate) fn for_each_changed_file( + repo: &Path, + callback: impl Fn(Result) -> bool, +) -> Result<()> { + // First we get conflict via another command because we have to, `chg diff` cannot list them and + // `chg status` does not support listing conflicted files. + let out = Command::new("chg") + // Ensure we're working in the expected repository. + .arg("--repository") + .arg(repo) + .args([ + "resolve", + "--list", + // Ensuring no configuration option will interfere. + "--color=never", + "--pager=never", + "--template", + "{path} //\n", + "set:unresolved()", + ]) + .output()?; + + anyhow::ensure!( + out.status.success(), + "`chg resolve --list` executed but failed" + ); + + let mut conflicts = HashSet::new(); + + for entry in split_double_slash_reverse(&out.stdout, true) { + if entry.is_empty() { + continue; + } + + // Record conflicted paths to avoid reporting them as modified too. + conflicts.insert(entry); + + let path = make_pathbuf(entry); + + if !callback(Ok(FileChange::Conflict { path })) { + return Ok(()); + } + } + + // The forward slash is the only character that is disallowed in both Unix and Windows paths, + // meaning `//` cannot ever appear in them on any platform. + // + // Lines will be of the following format (examples) + // + // ``` + // M // conflict.txt // \n + // A // file.rs // \n + // A // renamed.nix // after-rename.nix //\n + // R // testing.ts // \n + // ! // unremoved.rs // \n + // ? // untracked.rs // \n + // ``` + // + // Note we use `//\n` as the end delimiter to allow for files that contains `\n` in their name. + // + // First, print the status, it will determinate some of our parsing. + // One of "A" (added), "M" (modified), "R" (removed), "!" (removed but not tracked), or "?" + // (added but not tracked). + // + // Added paths are emitted before removed paths, by reading the lines in reverse we can record + // removed paths and recognize them in added states as rename sources. + let template = "{status} // {path} // {source} //\n"; + + let out = Command::new("chg") + // Ensure we're working in the expected repository. + .arg("--repository") + .arg(repo) + .args([ + "status", + // Ensuring no configuration option will interfere. + "--color=never", + "--pager=never", + // List per-file diff types, do not show diff itself. + "--template", + template, + ]) + .output()?; + + anyhow::ensure!(out.status.success(), "`chg status` executed but failed"); + + let mut removed = HashSet::new(); + + for entry in split_double_slash_reverse(&out.stdout, true) { + // Entries my return no status if a stateful operation like a merge is in progress. + if entry.is_empty() { + continue; + } + + let Some(change) = entry_to_immediate_change(entry, &mut removed) else { + continue; + }; + + if conflicts.contains(entry) { + continue; + } + + if !callback(Ok(change)) { + return Ok(()); + } + } + + // All removed paths that weren't recognized as renames are simply removed. + for path in removed { + if !callback(Ok(FileChange::Deleted { + path: make_pathbuf(path), + })) { + return Ok(()); + } + } + + Ok(()) +} + +pub(crate) fn open_repo(repo_path: &Path) -> Result<()> { + assert!( + repo_path.join(".hg").exists(), + "no .hg where one was expected: {}", + repo_path.display(), + ); + + let status = Command::new("chg") + .arg("root") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status()?; + + if status.success() { + Ok(()) + } else { + anyhow::bail!("not a valid mercurial repo") + } +} + +/// Associate a status to a `FileChange`. +/// +/// Gets something like `A // new.txt // source.txt` or `A // new.txt // ` as input. +/// +/// Removed paths are added to the removed buffer to be emitted in post. When copied path is +/// encountered the removed path may be removed again if it is set as the source and emitted as part +/// of a rename. +fn entry_to_immediate_change<'a>( + entry: &'a [u8], + removed: &mut HashSet<&'a [u8]>, +) -> Option { + let mut sections = split_double_slash_reverse(entry, false); + + // Note the order of the iterator is in reverse, so we yield the source first. + let source = sections.next()?; + let path = sections.next()?; + let status = sections.next()?; + + let change = match status { + // If the source was previously marked as removed, then it was renamed. + b"A" if removed.remove(source) => FileChange::Renamed { + from_path: make_pathbuf(source), + to_path: make_pathbuf(path), + }, + b"A" | b"?" => FileChange::Untracked { + path: make_pathbuf(path), + }, + b"M" => FileChange::Modified { + path: make_pathbuf(path), + }, + b"R" | b"!" => { + removed.insert(path); + return None; + } + _ => return None, + }; + + Some(change) +} + +#[cfg(any(unix, target_os = "wasi"))] +fn make_pathbuf(sl: &[u8]) -> PathBuf { + #[cfg(unix)] + use std::os::unix::ffi::OsStrExt; + #[cfg(target_os = "wasi")] + use std::os::wasi::ffi::OsStrExt; + + PathBuf::from(std::ffi::OsStr::from_bytes(sl)) +} + +// Imperfect fallback for platforms where we don't know about an always-correct method. +// In practice, non-UTF8 paths are vanishingly rare and should not be an issue for anyone running a +// Rust binary like Helix. +#[cfg(not(any(unix, target_os = "wasi")))] +fn make_pathbuf(sl: &[u8]) -> PathBuf { + let s = String::from_utf8_lossy(sl); + PathBuf::from(s.into_owned()) +} + +/// Split a byte slice on either ` // ` or ` //\n` depending on `with_newline`. +fn split_double_slash_reverse(slice: &[u8], with_newline: bool) -> impl Iterator { + let mut done = false; + let mut rest = slice; + let needle = if with_newline { " //\n" } else { " // " }.as_bytes(); + std::iter::from_fn(move || { + if done { + return None; + } + let result = match memchr::memmem::rfind(rest, needle) { + Some(pos) => { + // We use the non-panicking variants to avoid adding the panic machinery here when + // we know it won't ever panic in practice (unless there is a bug in memchr, which + // is unlikely given how much the crate is used). + let (before, after) = rest.split_at_checked(pos).unwrap_or_default(); + rest = before; + after.get(4..).unwrap_or_default() + } + None => { + done = true; + rest + } + }; + Some(result) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_double_slash_reverse_no_newline() { + let input = b"A // test.rs // test.rs //\n"; + let expected = [ + "test.rs //\n".as_bytes(), // Not trimmed since we're not splitting on newlines + "test.rs".as_bytes(), + "A".as_bytes(), + ]; + + let result = split_double_slash_reverse(input, false).collect::>(); + + assert_eq!(result, expected); + } + + #[test] + fn test_split_double_slash_reverse_with_newline() { + let input = "M // foo.rs // foo.rs //\nM // bar.rs // bar.rs //\n".as_bytes(); + + let expected = [ + // We expect an empty slice "after" the last split + &[], + "M // bar.rs // bar.rs".as_bytes(), + "M // foo.rs // foo.rs".as_bytes(), + ]; + + let result = split_double_slash_reverse(input, true).collect::>(); + + assert_eq!(result, expected); + } + + #[test] + fn test_entry_to_immediate_change() { + let mut removed = HashSet::new(); + + assert_eq!( + entry_to_immediate_change("R // qux.txt // ".as_bytes(), &mut removed), + None, + ); + assert_eq!( + entry_to_immediate_change("R // foo.txt // ".as_bytes(), &mut removed), + None, + ); + assert_eq!( + entry_to_immediate_change("A // bar.txt // foo.txt".as_bytes(), &mut removed).unwrap(), + FileChange::Renamed { + from_path: PathBuf::from("foo.txt"), + to_path: PathBuf::from("bar.txt"), + } + ); + assert_eq!( + entry_to_immediate_change("A // bar.txt // zir.txt".as_bytes(), &mut removed).unwrap(), + FileChange::Untracked { + path: PathBuf::from("bar.txt"), + } + ); + assert_eq!(removed, { + let mut m: HashSet<&[u8]> = HashSet::new(); + m.insert("qux.txt".as_bytes()); + m + }); + } +} diff --git a/helix-vcs/src/lib.rs b/helix-vcs/src/lib.rs index d0d266b7..35dbbf56 100644 --- a/helix-vcs/src/lib.rs +++ b/helix-vcs/src/lib.rs @@ -8,6 +8,8 @@ use std::{collections::HashMap, path::Path, sync::Arc}; #[cfg(feature = "git")] mod git; +#[cfg(feature = "hg")] +mod hg; #[cfg(feature = "jj")] mod jj; @@ -83,7 +85,10 @@ impl DiffProviderRegistry { } /// Creation and update methods -#[cfg_attr(not(any(feature = "git", feature = "jj")), allow(unused))] +#[cfg_attr( + not(any(feature = "git", feature = "hg", feature = "jj")), + allow(unused) +)] impl DiffProviderRegistry { /// Register a provider (if any is found) for the given path. pub fn add(&mut self, path: &Path, trust_full: bool) { @@ -104,6 +109,8 @@ impl DiffProviderRegistry { let result: Result<(Arc, PossibleDiffProvider)> = match provider { #[cfg(feature = "git")] PossibleDiffProvider::Git => self.add_file_git(repo_path, trust_full), + #[cfg(feature = "hg")] + PossibleDiffProvider::Hg => self.add_file_hg(repo_path), #[cfg(feature = "jj")] PossibleDiffProvider::JJ => self.add_file_jj(repo_path, trust_full), }; @@ -242,6 +249,25 @@ impl DiffProviderRegistry { } } + /// Add the mercurial repo to the known providers *if* it isn't already known. + #[cfg(feature = "hg")] + fn add_file_hg(&mut self, repo_path: &Path) -> Result<(Arc, PossibleDiffProvider)> { + // Don't build a mercurial repo object if there is already one for that path. + if let Some((key, DiffProvider::Hg(_))) = self.providers.get_key_value(repo_path) { + return Ok((Arc::clone(key), PossibleDiffProvider::Hg)); + } + + match hg::open_repo(repo_path) { + Ok(()) => { + let key = Arc::from(repo_path); + self.providers + .insert(Arc::clone(&key), DiffProvider::Hg(Arc::clone(&key))); + Ok((key, PossibleDiffProvider::Hg)) + } + Err(err) => Err(err), + } + } + /// Add the JJ repo to the known providers *if* it isn't already known. #[cfg(feature = "jj")] fn add_file_jj( @@ -290,13 +316,18 @@ pub enum DiffProvider { repo: Box, trust_full: bool, }, + #[cfg(feature = "hg")] + Hg(Arc), /// For [`jujutsu`](https://github.com/martinvonz/jj), we don't use the library but instead we /// call the binary because it can dynamically load backends, which the JJ library doesn't know about. #[cfg(feature = "jj")] JJ(Arc), } -#[cfg_attr(not(any(feature = "git", feature = "jj")), allow(unused))] +#[cfg_attr( + not(any(feature = "git", feature = "hg", feature = "jj")), + allow(unused) +)] impl DiffProvider { fn get_diff_base(&self, file: &Path) -> Result> { // We need the */ref else we're matching on a reference and Rust considers all references @@ -304,6 +335,8 @@ impl DiffProvider { match *self { #[cfg(feature = "git")] Self::Git { ref repo, .. } => git::get_diff_base(repo, file), + #[cfg(feature = "hg")] + Self::Hg(ref repo) => hg::get_diff_base(repo, file), #[cfg(feature = "jj")] Self::JJ(ref repo) => jj::get_diff_base(repo, file), } @@ -313,6 +346,8 @@ impl DiffProvider { match *self { #[cfg(feature = "git")] Self::Git { ref repo, .. } => git::get_current_head_name(repo), + #[cfg(feature = "hg")] + Self::Hg(ref repo) => hg::get_current_head_name(repo), #[cfg(feature = "jj")] Self::JJ(ref repo) => jj::get_current_head_name(repo), } @@ -322,6 +357,8 @@ impl DiffProvider { match *self { #[cfg(feature = "git")] Self::Git { ref repo, .. } => git::for_each_changed_file(repo, f), + #[cfg(feature = "hg")] + Self::Hg(ref repo) => hg::for_each_changed_file(repo, f), #[cfg(feature = "jj")] Self::JJ(ref repo) => jj::for_each_changed_file(repo, f), } @@ -333,6 +370,9 @@ pub enum PossibleDiffProvider { /// Possibly a git repo rooted at the stored path (i.e. `/.git` exists) #[cfg(feature = "git")] Git, + /// Possibly a mercurial repo rooted at the stored path (i.e. `/.hg` exists) + #[cfg(feature = "hg")] + Hg, /// Possibly a git repo rooted at the stored path (i.e. `/.jj` exists) #[cfg(feature = "jj")] JJ, @@ -349,6 +389,8 @@ fn get_possible_provider(path: &Path) -> Option<(&Path, PossibleDiffProvider)> { (".jj", PossibleDiffProvider::JJ), #[cfg(feature = "git")] (".git", PossibleDiffProvider::Git), + #[cfg(feature = "hg")] + (".hg", PossibleDiffProvider::Hg), ]; if !checks.is_empty() {