# HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1739561880 18000 # Fri Feb 14 14:38:00 2025 -0500 # Node ID 436c14f56d4f84e2e267d329a84a052903d76a95 # Parent d9fdf13ffc1d7c96a29924ab83598fb41703c346 # EXP-Topic annotate-wdir rust-config: add username parsing This adds Config::username which returns HGUSER, ui.username, or EMAIL in that order, similar to ui.username() in Python. I considered following the pattern of EDITOR, VISUAL, PAGER, etc. and using add_for_environment_variable, but it's not possible to get the same precendence as in Python that way (in particular HGUSER coming after the repo .hg/hgrc), at least not without significant changes. This will be used for 'rhg annotate -r wdir() -u' to annotate the username on lines that were changed in the working directory. diff --git a/rust/hg-core/src/config/mod.rs b/rust/hg-core/src/config/mod.rs --- a/rust/hg-core/src/config/mod.rs +++ b/rust/hg-core/src/config/mod.rs @@ -23,6 +23,7 @@ use self::layer::ConfigValue; use crate::errors::HgError; use crate::errors::{HgResultExt, IoResultExt}; +use crate::exit_codes; use crate::utils::files::get_bytes_from_os_str; use format_bytes::{write_bytes, DisplayBytes}; use std::collections::HashSet; @@ -841,6 +842,32 @@ _ => None, } } + + /// Returns the default username to be used in commits. Like ui.username() + /// in Python with acceptempty=False, but aborts rather than prompting for + /// input or falling back to the OS username and hostname. + pub fn username(&self) -> Result<Vec<u8>, HgError> { + if let Some(value) = env::var_os("HGUSER") { + if !value.is_empty() { + return Ok(value.into_encoded_bytes()); + } + } + if let Some(value) = self.get_str(b"ui", b"username")? { + if !value.is_empty() { + return Ok(value.as_bytes().to_vec()); + } + } + if let Some(value) = env::var_os("EMAIL") { + if !value.is_empty() { + return Ok(value.into_encoded_bytes()); + } + } + Err(HgError::abort( + "no username supplied", + exit_codes::ABORT, + Some("use 'hg config --edit' to set your username".to_string()), + )) + } } /// Corresponds to `usage.resources[.<dimension>]`. # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1739983578 18000 # Wed Feb 19 11:46:18 2025 -0500 # Node ID 47e9b5ac282effa8bec212277c57e85eef61f311 # Parent 436c14f56d4f84e2e267d329a84a052903d76a95 # EXP-Topic annotate-wdir rust-revlog: add RevisionOrWdir This type represents either a checked Revision or the wdir() pseudo-revision (revision 0x7fffffff, node ffffffffffffffffffffffffffffffffffffffff). You construct it with revision.into() or Revision::wdir(), and destructure it with rev.exclude_wdir() which returns Option<Revision>. I considered something like `enum RevisionOrWdir { Wdir, Revision(Revision) }`, but decided on `struct RevisionOrWdir(BaseRevision)` for a few reasons: - It's more ergonomic for the ways it actually gets used, in my opinion. - It also avoids the possibility of an invalid value Revision(0x7fffffff). - It remains 4 bytes rather than 8. - It maintains the ordering: wdir is greater than all other revisions. I'm planning to use this for 'rhg annotate -r wdir()'. diff --git a/rust/hg-core/src/revlog/mod.rs b/rust/hg-core/src/revlog/mod.rs --- a/rust/hg-core/src/revlog/mod.rs +++ b/rust/hg-core/src/revlog/mod.rs @@ -126,6 +126,39 @@ pub const WORKING_DIRECTORY_HEX: &str = "ffffffffffffffffffffffffffffffffffffffff"; +/// Either a checked revision or the working directory. +/// Note that [`Revision`] will never hold [`WORKING_DIRECTORY_REVISION`] +/// because that is not a valid revision in any revlog. +#[derive(Copy, Clone, Hash, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct RevisionOrWdir(BaseRevision); + +impl From<Revision> for RevisionOrWdir { + fn from(value: Revision) -> Self { + RevisionOrWdir(value.0) + } +} + +impl RevisionOrWdir { + /// Creates a [`RevisionOrWdir`] representing the working directory. + pub fn wdir() -> Self { + RevisionOrWdir(WORKING_DIRECTORY_REVISION.0) + } + + /// Returns the revision, or `None` if this is the working directory. + pub fn exclude_wdir(self) -> Option<Revision> { + if self.0 == WORKING_DIRECTORY_REVISION.0 { + None + } else { + Some(Revision(self.0)) + } + } + + /// Returns true if this is the working directory. + pub fn is_wdir(&self) -> bool { + *self == Self::wdir() + } +} + /// The simplest expression of what we need of Mercurial DAGs. pub trait Graph { /// Return the two parents of the given `Revision`. @@ -974,4 +1007,10 @@ } }; } + + #[test] + fn test_revision_or_wdir_ord() { + let highest: RevisionOrWdir = Revision(i32::MAX - 1).into(); + assert!(highest < RevisionOrWdir::wdir()); + } } # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1739983934 18000 # Wed Feb 19 11:52:14 2025 -0500 # Node ID deebcde0ab703f3489e9efbc09c87993808477d1 # Parent 47e9b5ac282effa8bec212277c57e85eef61f311 # EXP-Topic annotate-wdir rust-revset: support resolving wdir() This makes revset::resolve_single return RevisionOrWdir. Previously, it returned RevlogError::WDirUnsupported (leading to abort, not fallback) for 2147483647 and ffffffffffffffffffffffffffffffffffffffff. It did not recognize 'wdir()' itself, so that would lead to Python fallback. Now, it treats all 3 cases the same: it returns RevisionOrWdir::wdir() and lets the caller decide what to do. I changed rhg cat, files, and annotate to return HgError::unsupported in this case, since wdir is valid. I made `rhg status --change wdir()` behave the same as `rhg status`, conforming to the test in test-status.t. diff --git a/rust/hg-core/src/operations/cat.rs b/rust/hg-core/src/operations/cat.rs --- a/rust/hg-core/src/operations/cat.rs +++ b/rust/hg-core/src/operations/cat.rs @@ -83,7 +83,11 @@ revset: &str, mut files: Vec<&'a HgPath>, ) -> Result<CatOutput<'a>, RevlogError> { - let rev = crate::revset::resolve_single(revset, repo)?; + let Some(rev) = + crate::revset::resolve_single(revset, repo)?.exclude_wdir() + else { + return Err(HgError::unsupported("cat wdir not implemented").into()); + }; let manifest = repo.manifest_for_rev(rev.into())?; let mut results: Vec<(&'a HgPath, Vec<u8>)> = vec![]; let node = *repo.changelog()?.node_from_rev(rev); diff --git a/rust/hg-core/src/operations/list_tracked_files.rs b/rust/hg-core/src/operations/list_tracked_files.rs --- a/rust/hg-core/src/operations/list_tracked_files.rs +++ b/rust/hg-core/src/operations/list_tracked_files.rs @@ -22,8 +22,12 @@ revset: &str, narrow_matcher: Box<dyn Matcher + Sync>, ) -> Result<FilesForRev, RevlogError> { - let rev = crate::revset::resolve_single(revset, repo)?; - list_rev_tracked_files(repo, rev.into(), narrow_matcher) + match crate::revset::resolve_single(revset, repo)?.exclude_wdir() { + Some(rev) => list_rev_tracked_files(repo, rev.into(), narrow_matcher), + None => { + Err(HgError::unsupported("list wdir files not implemented").into()) + } + } } /// List files under Mercurial control at a given revision. diff --git a/rust/hg-core/src/revset.rs b/rust/hg-core/src/revset.rs --- a/rust/hg-core/src/revset.rs +++ b/rust/hg-core/src/revset.rs @@ -4,10 +4,10 @@ use crate::errors::HgError; use crate::repo::Repo; -use crate::revlog::NodePrefix; +use crate::revlog::{NodePrefix, RevisionOrWdir}; use crate::revlog::{Revision, NULL_REVISION, WORKING_DIRECTORY_HEX}; use crate::revlog::{Revlog, RevlogError}; -use crate::Node; +use crate::{Node, WORKING_DIRECTORY_REVISION}; /// Resolve a query string into a single revision. /// @@ -15,19 +15,20 @@ pub fn resolve_single( input: &str, repo: &Repo, -) -> Result<Revision, RevlogError> { +) -> Result<RevisionOrWdir, RevlogError> { let changelog = repo.changelog()?; match input { "." => { let p1 = repo.dirstate_parents()?.p1; - return changelog.revlog.rev_from_node(p1.into()); + return Ok(changelog.revlog.rev_from_node(p1.into())?.into()); } - "null" => return Ok(NULL_REVISION), + "null" => return Ok(NULL_REVISION.into()), + "wdir()" => return Ok(RevisionOrWdir::wdir()), _ => {} } - match resolve_rev_number_or_hex_prefix(input, &changelog.revlog) { + match resolve(input, &changelog.revlog) { Err(RevlogError::InvalidRevision(revision)) => { // TODO: support for the rest of the language here. let msg = format!("cannot parse revset '{}'", revision); @@ -41,31 +42,42 @@ /// the changelog, such as in `hg debugdata --manifest` CLI argument. /// /// * A non-negative decimal integer for a revision number, or -/// * An hexadecimal string, for the unique node ID that starts with this -/// prefix +/// * A hexadecimal string, for the unique node ID that starts with this prefix pub fn resolve_rev_number_or_hex_prefix( input: &str, revlog: &Revlog, ) -> Result<Revision, RevlogError> { + match resolve(input, revlog)?.exclude_wdir() { + Some(rev) => Ok(rev), + None => Err(RevlogError::WDirUnsupported), + } +} + +fn resolve( + input: &str, + revlog: &Revlog, +) -> Result<RevisionOrWdir, RevlogError> { // The Python equivalent of this is part of `revsymbol` in // `mercurial/scmutil.py` - if let Ok(integer) = input.parse::<i32>() { - if integer.to_string() == input - && integer >= 0 - && revlog.has_rev(integer.into()) - { - // This is fine because we've just checked that the revision is - // valid for the given revlog. - return Ok(Revision(integer)); + if integer.to_string() == input && integer >= 0 { + if integer == WORKING_DIRECTORY_REVISION.0 { + return Ok(RevisionOrWdir::wdir()); + } + if revlog.has_rev(integer.into()) { + // This is fine because we've just checked that the revision is + // valid for the given revlog. + return Ok(Revision(integer).into()); + } } } if let Ok(prefix) = NodePrefix::from_hex(input) { - if prefix.is_prefix_of(&Node::from_hex(WORKING_DIRECTORY_HEX).unwrap()) - { - return Err(RevlogError::WDirUnsupported); + let wdir_node = + Node::from_hex(WORKING_DIRECTORY_HEX).expect("wdir hex is valid"); + if prefix.is_prefix_of(&wdir_node) { + return Ok(RevisionOrWdir::wdir()); } - return revlog.rev_from_node(prefix); + return Ok(revlog.rev_from_node(prefix)?.into()); } Err(RevlogError::InvalidRevision(input.to_string())) } diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs --- a/rust/rhg/src/commands/annotate.rs +++ b/rust/rhg/src/commands/annotate.rs @@ -151,6 +151,11 @@ let rev = args.get_one::<String>("rev").expect("rev has a default"); let rev = hg::revset::resolve_single(rev, repo)?; + let Some(rev) = rev.exclude_wdir() else { + return Err(CommandError::unsupported( + "annotate wdir not implemented", + )); + }; let files = match args.get_many::<OsString>("files") { None => vec![], diff --git a/rust/rhg/src/commands/status.rs b/rust/rhg/src/commands/status.rs --- a/rust/rhg/src/commands/status.rs +++ b/rust/rhg/src/commands/status.rs @@ -25,7 +25,7 @@ use hg::repo::Repo; use hg::revlog::manifest::Manifest; use hg::revlog::options::{default_revlog_options, RevlogOpenOptions}; -use hg::revlog::RevlogType; +use hg::revlog::{RevisionOrWdir, RevlogError, RevlogType}; use hg::utils::debug::debug_wait_for_file; use hg::utils::files::{ get_bytes_from_os_str, get_bytes_from_os_string, get_path_from_bytes, @@ -171,12 +171,15 @@ let Some(revs) = revs else { return Ok(None); }; + let resolve = |input| match hg::revset::resolve_single(input, repo)? + .exclude_wdir() + { + Some(rev) => Ok(rev), + None => Err(RevlogError::WDirUnsupported), + }; match revs.as_slice() { [] => Ok(None), - [rev1, rev2] => Ok(Some(( - hg::revset::resolve_single(rev1, repo)?, - hg::revset::resolve_single(rev2, repo)?, - ))), + [rev1, rev2] => Ok(Some((resolve(rev1)?, resolve(rev2)?))), _ => Err(CommandError::unsupported("expected 0 or 2 --rev flags")), } } @@ -312,6 +315,8 @@ let change = change .map(|rev| hg::revset::resolve_single(rev, repo)) .transpose()?; + // Treat `rhg status --change wdir()` the same as `rhg status`. + let change = change.and_then(RevisionOrWdir::exclude_wdir); if verbose && has_unfinished_state(repo)? { return Err(CommandError::unsupported( # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1739906391 18000 # Tue Feb 18 14:19:51 2025 -0500 # Node ID 986de2c8cbd437661b2aa163783db3fa190e6360 # Parent deebcde0ab703f3489e9efbc09c87993808477d1 # EXP-Topic annotate-wdir rust-annotate: prefix abort messages with "abort:" This matches Python behavior and the other uses of HgError::abort. I noticed this difference in a test failure when working on 'wdir()' support. diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs --- a/rust/rhg/src/commands/annotate.rs +++ b/rust/rhg/src/commands/annotate.rs @@ -191,7 +191,7 @@ } if include.line_number && !(include.number || include.changeset) { return Err(CommandError::abort( - "at least one of -n/-c is required for -l", + "abort: at least one of -n/-c is required for -l", )); } @@ -234,7 +234,7 @@ AnnotateOutput::NotFound => { let short = changelog.node_from_rev(rev).short(); return Err(CommandError::abort(format!( - "{path}: no such file in rev {short:x}", + "abort: {path}: no such file in rev {short:x}", ))); } } # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1739907306 18000 # Tue Feb 18 14:35:06 2025 -0500 # Node ID d5f3ddfa4a63eadd2e968b3acd04b9c85564e830 # Parent 986de2c8cbd437661b2aa163783db3fa190e6360 # EXP-Topic annotate-wdir annotate: add test for file not found We already test hg annotate -r wdir() after removing a file with "rm" and "hg rm". This tests the case where the file was never in the repo to begin with. diff --git a/tests/test-annotate.t b/tests/test-annotate.t --- a/tests/test-annotate.t +++ b/tests/test-annotate.t @@ -706,6 +706,12 @@ abort: $ENOENT$: '$TESTTMP/repo/baz' (no-windows !) [255] +annotate file neither in repo nor working copy + + $ hg annotate -ncr "wdir()" never_existed + abort: never_existed: $ENOENT$ + [10] + $ hg revert --all --no-backup --quiet $ hg id -n 20 # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1741016709 18000 # Mon Mar 03 10:45:09 2025 -0500 # Node ID ca7fe70c7b1ccc2195c4215677cee74f322079d7 # Parent d5f3ddfa4a63eadd2e968b3acd04b9c85564e830 # EXP-Topic annotate-wdir annotate: use exit code 255 for missing files This makes hg annotate always use exit code 255 for missing files. Before, it used 10 (input error) if the file was never in the repo, and 255 (abort) if it is in the repo for some other revision. The motivation for this is to match the behavior of hg.py and rhg. This discrepancy was revealed by using "hg annotate" instead of "hg ann" in test-annotate.t, since rhg doesn't recognize "ann" and falls back. diff --git a/mercurial/commands.py b/mercurial/commands.py --- a/mercurial/commands.py +++ b/mercurial/commands.py @@ -571,7 +571,7 @@ ) def bad(x, y): - raise error.InputError(b"%s: %s" % (x, y)) + raise error.Abort(b"%s: %s" % (x, y)) m = scmutil.match(ctx, pats, opts, badfn=bad) diff --git a/tests/test-annotate.t b/tests/test-annotate.t --- a/tests/test-annotate.t +++ b/tests/test-annotate.t @@ -516,9 +516,9 @@ missing file - $ hg ann nosuchfile + $ hg annotate nosuchfile abort: nosuchfile: no such file in rev e9e6b4fa872f - [10] + [255] annotate file without '\n' on last line @@ -710,7 +710,7 @@ $ hg annotate -ncr "wdir()" never_existed abort: never_existed: $ENOENT$ - [10] + [255] $ hg revert --all --no-backup --quiet $ hg id -n diff --git a/tests/test-fastannotate-hg.t b/tests/test-fastannotate-hg.t --- a/tests/test-fastannotate-hg.t +++ b/tests/test-fastannotate-hg.t @@ -456,9 +456,9 @@ missing file - $ hg ann nosuchfile + $ hg annotate nosuchfile abort: nosuchfile: no such file in rev e9e6b4fa872f - [10] + [255] annotate file without '\n' on last line # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1742479164 14400 # Thu Mar 20 09:59:24 2025 -0400 # Node ID 4ae4f384a7b0f2ea522d0044831b586c3a38c223 # Parent ca7fe70c7b1ccc2195c4215677cee74f322079d7 # EXP-Topic annotate-wdir rust: refactor operations::annotate in preparation for wdir The main change is to add a RepoState type which bundles together a few things that are passed around a lot. The wdir() change adds dirstate parents to this. diff --git a/rust/hg-core/src/operations/annotate.rs b/rust/hg-core/src/operations/annotate.rs --- a/rust/hg-core/src/operations/annotate.rs +++ b/rust/hg-core/src/operations/annotate.rs @@ -1,5 +1,3 @@ -use std::borrow::Cow; - use crate::{ bdiff::{self, Lines}, errors::HgError, @@ -20,6 +18,7 @@ use itertools::Itertools as _; use rayon::prelude::*; use self_cell::self_cell; +use std::{borrow::Cow, cell::Ref}; /// Options for [`annotate`]. #[derive(Copy, Clone)] @@ -101,6 +100,25 @@ line_number: u32, } +/// [`Repo`] and related objects that often need to be passed together. +struct RepoState<'a> { + repo: &'a Repo, + changelog: Ref<'a, Changelog>, + manifestlog: Ref<'a, Manifestlog>, +} + +impl<'a> RepoState<'a> { + fn new(repo: &'a Repo, include_dirstate: bool) -> Result<Self, HgError> { + let changelog = repo.changelog()?; + let manifestlog = repo.manifestlog()?; + Ok(Self { + repo, + changelog, + manifestlog, + }) + } +} + /// Helper for keeping track of multiple filelogs. #[derive(Default)] struct FilelogSet { @@ -150,7 +168,7 @@ Ok(index) } - /// Opens a new filelog by path and returns the id for the given file node. + /// Opens a filelog by path and returns the id for the given file node. fn open_at_node( &mut self, repo: &Repo, @@ -163,9 +181,44 @@ Ok(FileId { index, revision }) } + /// Opens a filelog by path and returns the id for the given changelog + /// revision. Returns `None` if no filelog exists for that path. + fn open_at_changelog_rev( + &mut self, + state: &RepoState, + path: &HgPath, + changelog_revision: Revision, + ) -> Result<Option<FileId>, HgError> { + let changelog_data = + state.changelog.entry(changelog_revision)?.data()?; + let manifest = state + .manifestlog + .data_for_node(changelog_data.manifest_node()?.into())?; + let Some(entry) = manifest.find_by_path(path)? else { + return Ok(None); + }; + let node = entry.node_id()?; + Ok(Some(self.open_at_node(state.repo, path, node)?)) + } + + /// Opens and reads a file by path at a changelog revision, returning its + /// id and contents. Returns `None` if not found. + fn open_and_read( + &mut self, + state: &RepoState, + path: &HgPath, + revision: Revision, + ) -> Result<Option<(FileId, Vec<u8>)>, HgError> { + match self.open_at_changelog_rev(state, path, revision)? { + None => Ok(None), + Some(id) => Ok(Some((id, self.read(id)?))), + } + } + /// Reads the contents of a file by id. - fn read(&self, id: FileId) -> Result<FilelogRevisionData, HgError> { - self.get(id.index).filelog.entry(id.revision)?.data() + fn read(&self, id: FileId) -> Result<Vec<u8>, HgError> { + let filelog = &self.get(id.index).filelog; + filelog.entry(id.revision)?.data()?.into_file_data() } /// Returns the parents of a file. If `follow_copies` is true, it treats @@ -173,7 +226,7 @@ /// (since it has to read the file to extract the copy metadata). fn parents( &mut self, - repo: &Repo, + state: &RepoState, id: FileId, follow_copies: bool, ) -> Result<(Vec<FileId>, Option<Vec<u8>>), HgError> { @@ -195,7 +248,7 @@ // If copy or copyrev occurs without the other, ignore it. // This matches filerevisioncopied in storageutil.py. if let (Some(copy), Some(copyrev)) = (meta.copy, meta.copyrev) { - parents.push(self.open_at_node(repo, copy, copyrev)?); + parents.push(self.open_at_node(state.repo, copy, copyrev)?); } file_data = Some(data.into_file_data()?); } @@ -275,19 +328,13 @@ options: AnnotateOptions, ) -> Result<AnnotateOutput, HgError> { // Step 1: Load the base file and check if it's binary. - let changelog = repo.changelog()?; - let manifestlog = repo.manifestlog()?; + let state = RepoState::new(repo, changelog_revision.is_wdir())?; let mut fls = FilelogSet::default(); - let base_id = { - let changelog_data = changelog.entry(changelog_revision)?.data()?; - let manifest = manifestlog - .data_for_node(changelog_data.manifest_node()?.into())?; - let Some(entry) = manifest.find_by_path(path)? else { - return Ok(AnnotateOutput::NotFound); - }; - fls.open_at_node(repo, path, entry.node_id()?)? + let Some((base_id, base_file_data)) = + fls.open_and_read(&state, path, changelog_revision)? + else { + return Ok(AnnotateOutput::NotFound); }; - let base_file_data = fls.read(base_id)?.into_file_data()?; if !options.treat_binary_as_text && utils::files::is_binary(&base_file_data) { @@ -303,7 +350,7 @@ continue; } let (parents, file_data) = - fls.parents(repo, id, options.follow_copies)?; + fls.parents(&state, id, options.follow_copies)?; info.parents = Some(parents.clone()); if let Some(data) = file_data { info.file = AnnotatedFileState::Read(OwnedLines::split( @@ -337,7 +384,7 @@ |(&id, info)| -> Result<(), HgError> { if let AnnotatedFileState::None = info.file { info.file = AnnotatedFileState::Read(OwnedLines::split( - fls.read(id)?.into_file_data()?, + fls.read(id)?, options.whitespace, )?); } @@ -411,7 +458,7 @@ } // Use the same object for all ancestor checks, since it internally // builds a hash set of seen revisions. - let mut ancestors = ancestor_iter(&changelog, changelog_revision, None); + let mut ancestors = ancestor_iter(&state, changelog_revision, None); // Iterate in reverse topological order so that we visits nodes after their // children, that way we can propagate `descendant` correctly. for &id in topological_order.iter().rev() { @@ -422,8 +469,7 @@ ChangelogRevisionState::NotNeeded => descendant, ChangelogRevisionState::Needed => { let revision = adjust_link_revision( - &changelog, - &manifestlog, + &state, &fls, &mut ancestors, descendant, @@ -487,13 +533,13 @@ /// Creates an iterator over the ancestors of `base_revision` (inclusive), /// stopping at `stop_revision` if provided. Panics if `base_revision` is null. -fn ancestor_iter( - changelog: &Changelog, +fn ancestor_iter<'a>( + state: &'a RepoState<'a>, base_revision: Revision, stop_revision: Option<Revision>, -) -> AncestorsIterator<&Changelog> { +) -> AncestorsIterator<&'a Changelog> { AncestorsIterator::new( - changelog, + &*state.changelog, [base_revision], stop_revision.unwrap_or(NULL_REVISION), true, @@ -504,8 +550,7 @@ /// If the linkrev of `id` is in `ancestors`, returns it. Otherwise, finds and /// returns the first ancestor of `descendant` that introduced `id`. fn adjust_link_revision( - changelog: &Changelog, - manifestlog: &Manifestlog, + state: &RepoState<'_>, fls: &FilelogSet, ancestors: &mut AncestorsIterator<&Changelog>, descendant: Revision, @@ -514,19 +559,21 @@ let FilelogSetItem { filelog, path } = fls.get(id.index); let linkrev = filelog .revlog - .link_revision(id.revision, &changelog.revlog)?; + .link_revision(id.revision, &state.changelog.revlog)?; if ancestors.contains(linkrev).map_err(from_graph_error)? { return Ok(linkrev); } let file_node = *filelog.revlog.node_from_rev(id.revision); - for ancestor in ancestor_iter(changelog, descendant, Some(linkrev)) { + for ancestor in ancestor_iter(state, descendant, Some(linkrev)) { let ancestor = ancestor.map_err(from_graph_error)?; - let data = changelog.entry(ancestor)?.data()?; + let data = state.changelog.entry(ancestor)?.data()?; if data.files().contains(&path.as_ref()) { - let manifest_rev = manifestlog + let manifest_rev = state + .manifestlog .revlog .rev_from_node(data.manifest_node()?.into())?; - if let Some(entry) = manifestlog + if let Some(entry) = state + .manifestlog .inexact_data_delta_parents(manifest_rev)? .find_by_path(path)? { # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1739913669 18000 # Tue Feb 18 16:21:09 2025 -0500 # Node ID b7106a2741e5acdba2aef16263134e709b152028 # Parent 4ae4f384a7b0f2ea522d0044831b586c3a38c223 # EXP-Topic annotate-wdir rust: refactor commands::annotate in preparation for wdir The main change is to add a FormatterConfig type. diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs --- a/rust/rhg/src/commands/annotate.rs +++ b/rust/rhg/src/commands/annotate.rs @@ -1,15 +1,17 @@ use core::str; -use std::{collections::hash_map::Entry, ffi::OsString}; +use std::{cell::Ref, collections::hash_map::Entry, ffi::OsString}; +use chrono::{DateTime, FixedOffset}; use format_bytes::format_bytes; use hg::{ encoding::Encoder, operations::{ annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotation, }, + repo::Repo, revlog::changelog::Changelog, utils::strings::CleanWhitespace, - FastHashMap, Revision, + FastHashMap, Node, Revision, }; use crate::{error::CommandError, utils::path_utils::resolve_file_args}; @@ -204,11 +206,10 @@ let changelog = repo.changelog()?; let mut formatter = Formatter::new( - &changelog, + repo, invocation.ui.encoder(), - include, - verbosity, - ); + FormatterConfig { include, verbosity }, + )?; let mut stdout = invocation.ui.stdout_buffer(); for path in files { match annotate(repo, &path, rev, options)? { @@ -245,14 +246,17 @@ } struct Formatter<'a> { - changelog: &'a Changelog, + changelog: Ref<'a, Changelog>, encoder: &'a Encoder, - include: Include, - verbosity: Verbosity, + config: FormatterConfig, cache: FastHashMap<Revision, ChangesetData>, } -#[derive(Copy, Clone)] +struct FormatterConfig { + include: Include, + verbosity: Verbosity, +} + struct Include { user: bool, number: bool, @@ -274,7 +278,6 @@ } } -#[derive(Copy, Clone)] enum Verbosity { Quiet, Default, @@ -292,52 +295,60 @@ fn create( revision: Revision, changelog: &Changelog, - include: Include, - verbosity: Verbosity, - ) -> Result<ChangesetData, CommandError> { - let mut result = ChangesetData::default(); + config: &FormatterConfig, + ) -> Result<Self, CommandError> { + let include = &config.include; if !(include.user || include.changeset || include.date) { - return Ok(result); + return Ok(Self::default()); } let entry = changelog.entry(revision)?; let data = entry.data()?; - if include.user { - let user = match verbosity { - Verbosity::Verbose => data.user(), - _ => hg::utils::strings::short_user(data.user()), + let node = *entry.as_revlog_entry().node(); + Ok(Self::new(data.user(), node, data.timestamp()?, config)) + } + + fn new( + user: &[u8], + changeset: Node, + date: DateTime<FixedOffset>, + config: &FormatterConfig, + ) -> Self { + let mut result = ChangesetData::default(); + if config.include.user { + let user = match config.verbosity { + Verbosity::Verbose => user, + _ => hg::utils::strings::short_user(user), }; result.user = Some(user.to_vec()); } - if include.changeset { - let changeset = entry.as_revlog_entry().node().short(); - result.changeset = Some(format!("{:x}", changeset).into_bytes()); + if config.include.changeset { + result.changeset = + Some(format!("{:x}", changeset.short()).into_bytes()); } - if include.date { - let date = data.timestamp()?.format(match verbosity { + if config.include.date { + let date = date.format(match config.verbosity { Verbosity::Quiet => "%Y-%m-%d", _ => "%a %b %d %H:%M:%S %Y %z", }); result.date = Some(format!("{}", date).into_bytes()); } - Ok(result) + result } } impl<'a> Formatter<'a> { fn new( - changelog: &'a Changelog, + repo: &'a Repo, encoder: &'a Encoder, - include: Include, - verbosity: Verbosity, - ) -> Self { - let cache = FastHashMap::default(); - Self { + config: FormatterConfig, + ) -> Result<Self, CommandError> { + let changelog = repo.changelog()?; + Ok(Self { changelog, encoder, - include, - verbosity, - cache, - } + config, + cache: FastHashMap::default(), + }) } fn format( @@ -346,7 +357,7 @@ ) -> Result<Vec<Vec<u8>>, CommandError> { let mut lines: Vec<Vec<Vec<u8>>> = Vec::with_capacity(annotations.len()); - let num_fields = self.include.count(); + let num_fields = self.config.include.count(); let mut widths = vec![0usize; num_fields]; for annotation in annotations { let revision = annotation.revision; @@ -354,16 +365,15 @@ Entry::Occupied(occupied) => occupied.into_mut(), Entry::Vacant(vacant) => vacant.insert(ChangesetData::create( revision, - self.changelog, - self.include, - self.verbosity, + &self.changelog, + &self.config, )?), }; let mut fields = Vec::with_capacity(num_fields); if let Some(user) = &data.user { fields.push(user.clone()); } - if self.include.number { + if self.config.include.number { fields.push(format_bytes!(b"{}", revision)); } if let Some(changeset) = &data.changeset { @@ -372,10 +382,10 @@ if let Some(date) = &data.date { fields.push(date.clone()); } - if self.include.file { + if self.config.include.file { fields.push(annotation.path.into_vec()); } - if self.include.line_number { + if self.config.include.line_number { fields.push(format_bytes!(b"{}", annotation.line_number)); } for (field, width) in fields.iter().zip(widths.iter_mut()) { @@ -395,8 +405,8 @@ fields.iter().zip(widths.iter()).enumerate() { if i > 0 { - let colon = - self.include.line_number && i == num_fields - 1; + let colon = self.config.include.line_number + && i == num_fields - 1; bytes.push(if colon { b':' } else { b' ' }); } let padding = # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1742479171 14400 # Thu Mar 20 09:59:31 2025 -0400 # Node ID 170302f9b74fccab8c915c30b5823e39669e4de9 # Parent b7106a2741e5acdba2aef16263134e709b152028 # EXP-Topic annotate-wdir rust-annotate: support --rev wdir() This adds support for annotating a file in wdir(). Lines that are changed in the working directory get annotated with dirstate.p1 followed by "+", as in Python. I had to change some test-annotate.t output for an edge case. If file "foo" does not exist, `hg annotate -r wdir() foo` prints a slightly different error message depending on whether "foo" was ever previously tracked. I don't think this is useful or done purposefully, so it doesn't seem worth complicating rhg annotate to behave in the same way. diff --git a/rust/hg-core/src/operations/annotate.rs b/rust/hg-core/src/operations/annotate.rs --- a/rust/hg-core/src/operations/annotate.rs +++ b/rust/hg-core/src/operations/annotate.rs @@ -1,11 +1,11 @@ use crate::{ bdiff::{self, Lines}, + dirstate::{owning::OwningDirstateMap, DirstateError}, errors::HgError, repo::Repo, revlog::{ - changelog::Changelog, - filelog::{Filelog, FilelogRevisionData}, - manifest::Manifestlog, + changelog::Changelog, filelog::Filelog, manifest::Manifestlog, + RevisionOrWdir, }, utils::{ self, @@ -52,7 +52,7 @@ /// file's current path if it was copied or renamed in the past. pub path: HgPathBuf, /// The changelog revision that introduced the line. - pub revision: Revision, + pub revision: RevisionOrWdir, /// The one-based line number in the original file. pub line_number: u32, } @@ -105,21 +105,44 @@ repo: &'a Repo, changelog: Ref<'a, Changelog>, manifestlog: Ref<'a, Manifestlog>, + dirstate_parents: Option<[Revision; 2]>, + dirstate_map: Option<Ref<'a, OwningDirstateMap>>, } impl<'a> RepoState<'a> { fn new(repo: &'a Repo, include_dirstate: bool) -> Result<Self, HgError> { let changelog = repo.changelog()?; let manifestlog = repo.manifestlog()?; + let (dirstate_parents, dirstate_map) = if include_dirstate { + let crate::DirstateParents { p1, p2 } = repo.dirstate_parents()?; + let p1 = changelog.rev_from_node(p1.into())?; + let p2 = changelog.rev_from_node(p2.into())?; + let dirstate_map = + repo.dirstate_map().map_err(from_dirstate_error)?; + (Some([p1, p2]), Some(dirstate_map)) + } else { + (None, None) + }; Ok(Self { repo, changelog, manifestlog, + dirstate_parents, + dirstate_map, }) } + + fn dirstate_parents(&self) -> [Revision; 2] { + self.dirstate_parents.expect("should be set for wdir") + } + + fn dirstate_map(&'a self) -> &'a OwningDirstateMap { + self.dirstate_map.as_ref().expect("should be set for wdir") + } } /// Helper for keeping track of multiple filelogs. +/// Also abstracts over reading from filelogs and from the working directory. #[derive(Default)] struct FilelogSet { /// List of filelogs. The first one is for the root file being blamed. @@ -139,7 +162,15 @@ /// Identifies a file revision in a FilelogSet. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -struct FileId { +enum FileId { + /// The file in the working directory. + Wdir, + /// A revision of the file in a filelog. + Rev(RevFileId), +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +struct RevFileId { index: FilelogIndex, revision: Revision, } @@ -174,11 +205,11 @@ repo: &Repo, path: &HgPath, node: Node, - ) -> Result<FileId, HgError> { + ) -> Result<RevFileId, HgError> { let index = self.open(repo, path)?; let revision = self.get(index).filelog.revlog.rev_from_node(node.into())?; - Ok(FileId { index, revision }) + Ok(RevFileId { index, revision }) } /// Opens a filelog by path and returns the id for the given changelog @@ -188,7 +219,7 @@ state: &RepoState, path: &HgPath, changelog_revision: Revision, - ) -> Result<Option<FileId>, HgError> { + ) -> Result<Option<RevFileId>, HgError> { let changelog_data = state.changelog.entry(changelog_revision)?.data()?; let manifest = state @@ -201,22 +232,32 @@ Ok(Some(self.open_at_node(state.repo, path, node)?)) } - /// Opens and reads a file by path at a changelog revision, returning its - /// id and contents. Returns `None` if not found. + /// Opens and reads a file by path at a changelog revision (or working + /// directory), returning its id and contents. Returns `None` if not found. fn open_and_read( &mut self, state: &RepoState, path: &HgPath, - revision: Revision, + revision: RevisionOrWdir, ) -> Result<Option<(FileId, Vec<u8>)>, HgError> { - match self.open_at_changelog_rev(state, path, revision)? { - None => Ok(None), - Some(id) => Ok(Some((id, self.read(id)?))), + match revision.exclude_wdir() { + Some(revision) => { + match self.open_at_changelog_rev(state, path, revision)? { + None => Ok(None), + Some(id) => Ok(Some((FileId::Rev(id), self.read(id)?))), + } + } + None => { + let fs_path = utils::hg_path::hg_path_to_path_buf(path)?; + let maybe_data = + state.repo.working_directory_vfs().try_read(fs_path)?; + Ok(maybe_data.map(|data| (FileId::Wdir, data))) + } } } /// Reads the contents of a file by id. - fn read(&self, id: FileId) -> Result<Vec<u8>, HgError> { + fn read(&self, id: RevFileId) -> Result<Vec<u8>, HgError> { let filelog = &self.get(id.index).filelog; filelog.entry(id.revision)?.data()?.into_file_data() } @@ -227,19 +268,36 @@ fn parents( &mut self, state: &RepoState, + base_path: &HgPath, id: FileId, follow_copies: bool, ) -> Result<(Vec<FileId>, Option<Vec<u8>>), HgError> { + let mut parents = Vec::<FileId>::with_capacity(2); + let FileId::Rev(id) = id else { + // If a file in the working directory is copied/renamed, its parent + // is the copy source (just as it will be after committing). + let path = state + .dirstate_map() + .copy_map_get(base_path)? + .unwrap_or(base_path); + for rev in state.dirstate_parents() { + if let Some(id) = + self.open_at_changelog_rev(state, path, rev)? + { + parents.push(FileId::Rev(id)); + } + } + return Ok((parents, None)); + }; let filelog = &self.get(id.index).filelog; let revisions = filelog.parents(id.revision).map_err(from_graph_error)?; - let mut parents = Vec::with_capacity(2); let mut file_data = None; if revisions[0] != NULL_REVISION { - parents.push(FileId { + parents.push(FileId::Rev(RevFileId { index: id.index, revision: revisions[0], - }); + })); } else if follow_copies { // A null p1 indicates there might be copy metadata. // Check for it, and if present use it as the parent. @@ -248,15 +306,17 @@ // If copy or copyrev occurs without the other, ignore it. // This matches filerevisioncopied in storageutil.py. if let (Some(copy), Some(copyrev)) = (meta.copy, meta.copyrev) { - parents.push(self.open_at_node(state.repo, copy, copyrev)?); + parents.push(FileId::Rev( + self.open_at_node(state.repo, copy, copyrev)?, + )); } file_data = Some(data.into_file_data()?); } if revisions[1] != NULL_REVISION { - parents.push(FileId { + parents.push(FileId::Rev(RevFileId { index: id.index, revision: revisions[1], - }); + })); } Ok((parents, file_data)) } @@ -266,6 +326,8 @@ #[derive(Default)] struct FileInfo { /// Parents of this revision (via p1 and p2 or copy metadata). + /// These are always `FileId::Rev`, not `FileId::Wdir`, but we store + /// `FileId` because everything would have to convert to it anyways. parents: Option<Vec<FileId>>, /// Current state for annotating the file. file: AnnotatedFileState, @@ -275,7 +337,7 @@ revision: ChangelogRevisionState, /// The value of `revision` from a descendant. If the linkrev needs /// adjustment, we can start iterating the changelog here. - descendant: Option<Revision>, + descendant: Option<RevisionOrWdir>, } /// State enum for reading a file and annotating it. @@ -294,7 +356,7 @@ #[default] NotNeeded, Needed, - Done(Revision), + Done(RevisionOrWdir), } /// A collection of [`FileInfo`], forming a graph via [`FileInfo::parents`]. @@ -324,7 +386,7 @@ pub fn annotate( repo: &Repo, path: &HgPath, - changelog_revision: Revision, + changelog_revision: RevisionOrWdir, options: AnnotateOptions, ) -> Result<AnnotateOutput, HgError> { // Step 1: Load the base file and check if it's binary. @@ -350,7 +412,7 @@ continue; } let (parents, file_data) = - fls.parents(&state, id, options.follow_copies)?; + fls.parents(&state, path, id, options.follow_copies)?; info.parents = Some(parents.clone()); if let Some(data) = file_data { info.file = AnnotatedFileState::Read(OwnedLines::split( @@ -383,6 +445,9 @@ graph.0.par_iter_mut().try_for_each( |(&id, info)| -> Result<(), HgError> { if let AnnotatedFileState::None = info.file { + let FileId::Rev(id) = id else { + unreachable!("only the base file can be wdir"); + }; info.file = AnnotatedFileState::Read(OwnedLines::split( fls.read(id)?, options.whitespace, @@ -492,7 +557,10 @@ let mut changeset_annotations = Vec::with_capacity(annotations.len()); for Annotation { id, line_number } in annotations { changeset_annotations.push(ChangesetAnnotation { - path: fls.get(id.index).path.clone(), + path: match id { + FileId::Wdir => path.into(), + FileId::Rev(id) => fls.get(id.index).path.clone(), + }, revision: match graph[id].revision { ChangelogRevisionState::Done(revision) => revision, _ => unreachable!(), @@ -535,13 +603,18 @@ /// stopping at `stop_revision` if provided. Panics if `base_revision` is null. fn ancestor_iter<'a>( state: &'a RepoState<'a>, - base_revision: Revision, + base_revision: RevisionOrWdir, stop_revision: Option<Revision>, ) -> AncestorsIterator<&'a Changelog> { + let base_revisions: &[Revision] = match base_revision.exclude_wdir() { + Some(rev) => &[rev], + None => &state.dirstate_parents(), + }; + let stop_revision = stop_revision.unwrap_or(NULL_REVISION); AncestorsIterator::new( &*state.changelog, - [base_revision], - stop_revision.unwrap_or(NULL_REVISION), + base_revisions.iter().copied(), + stop_revision, true, ) .expect("base_revision should not be null") @@ -553,15 +626,18 @@ state: &RepoState<'_>, fls: &FilelogSet, ancestors: &mut AncestorsIterator<&Changelog>, - descendant: Revision, + descendant: RevisionOrWdir, id: FileId, -) -> Result<Revision, HgError> { +) -> Result<RevisionOrWdir, HgError> { + let FileId::Rev(id) = id else { + return Ok(RevisionOrWdir::wdir()); + }; let FilelogSetItem { filelog, path } = fls.get(id.index); let linkrev = filelog .revlog .link_revision(id.revision, &state.changelog.revlog)?; if ancestors.contains(linkrev).map_err(from_graph_error)? { - return Ok(linkrev); + return Ok(linkrev.into()); } let file_node = *filelog.revlog.node_from_rev(id.revision); for ancestor in ancestor_iter(state, descendant, Some(linkrev)) { @@ -578,17 +654,27 @@ .find_by_path(path)? { if entry.node_id()? == file_node { - return Ok(ancestor); + return Ok(ancestor.into()); } } } } // In theory this should be unreachable. But in case it happens, return the // linkrev. This matches _adjustlinkrev in context.py. - Ok(linkrev) + Ok(linkrev.into()) } /// Converts a [`GraphError`] to an [`HgError`]. fn from_graph_error(err: GraphError) -> HgError { HgError::corrupted(err.to_string()) } + +/// Converts a [`DirstateError`] to an [`HgError`]. +fn from_dirstate_error(err: DirstateError) -> HgError { + match err { + DirstateError::Map(err) => { + HgError::abort_simple(format!("dirstate error: {err}")) + } + DirstateError::Common(err) => err, + } +} diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs --- a/rust/rhg/src/commands/annotate.rs +++ b/rust/rhg/src/commands/annotate.rs @@ -1,16 +1,17 @@ use core::str; use std::{cell::Ref, collections::hash_map::Entry, ffi::OsString}; -use chrono::{DateTime, FixedOffset}; +use chrono::{DateTime, FixedOffset, Local}; use format_bytes::format_bytes; use hg::{ encoding::Encoder, + errors::IoResultExt as _, operations::{ annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotation, }, repo::Repo, - revlog::changelog::Changelog, - utils::strings::CleanWhitespace, + revlog::{changelog::Changelog, RevisionOrWdir}, + utils::{hg_path::HgPath, strings::CleanWhitespace}, FastHashMap, Node, Revision, }; @@ -153,11 +154,6 @@ let rev = args.get_one::<String>("rev").expect("rev has a default"); let rev = hg::revset::resolve_single(rev, repo)?; - let Some(rev) = rev.exclude_wdir() else { - return Err(CommandError::unsupported( - "annotate wdir not implemented", - )); - }; let files = match args.get_many::<OsString>("files") { None => vec![], @@ -204,17 +200,27 @@ (true, true) => unreachable!(), }; - let changelog = repo.changelog()?; + let wdir_config = if rev.is_wdir() { + let user = config.username()?; + Some(WdirConfig { user }) + } else { + None + }; + let mut formatter = Formatter::new( repo, invocation.ui.encoder(), - FormatterConfig { include, verbosity }, + FormatterConfig { + include, + verbosity, + wdir_config, + }, )?; let mut stdout = invocation.ui.stdout_buffer(); for path in files { match annotate(repo, &path, rev, options)? { AnnotateOutput::Text(text) => { - let annotations = formatter.format(text.annotations)?; + let annotations = formatter.format(&path, text.annotations)?; for (annotation, line) in annotations.iter().zip(&text.lines) { stdout.write_all(&format_bytes!( b"{}: {}", annotation, line @@ -233,10 +239,16 @@ ))?; } AnnotateOutput::NotFound => { - let short = changelog.node_from_rev(rev).short(); - return Err(CommandError::abort(format!( - "abort: {path}: no such file in rev {short:x}", - ))); + return Err(CommandError::abort(match rev.exclude_wdir() { + Some(rev) => { + let short = + repo.changelog()?.node_from_rev(rev).short(); + format!("abort: {path}: no such file in rev {short:x}",) + } + None => { + format!("abort: {path}: No such file or directory") + } + })); } } } @@ -246,15 +258,18 @@ } struct Formatter<'a> { + repo: &'a Repo, changelog: Ref<'a, Changelog>, + dirstate_p1: Revision, encoder: &'a Encoder, config: FormatterConfig, - cache: FastHashMap<Revision, ChangesetData>, + cache: FastHashMap<RevisionOrWdir, ChangesetData>, } struct FormatterConfig { include: Include, verbosity: Verbosity, + wdir_config: Option<WdirConfig>, } struct Include { @@ -284,6 +299,11 @@ Verbose, } +/// Information to use for lines that changed in the working directory. +struct WdirConfig { + user: Vec<u8>, +} + #[derive(Default)] struct ChangesetData { user: Option<Vec<u8>>, @@ -291,9 +311,18 @@ date: Option<Vec<u8>>, } +/// Whether the "+" sigil calculation is for --number or --changeset. +#[derive(PartialEq, Eq)] +enum SigilFor { + Number, + Changeset, +} + impl ChangesetData { fn create( - revision: Revision, + revision: RevisionOrWdir, + path: &HgPath, + repo: &Repo, changelog: &Changelog, config: &FormatterConfig, ) -> Result<Self, CommandError> { @@ -301,10 +330,25 @@ if !(include.user || include.changeset || include.date) { return Ok(Self::default()); } - let entry = changelog.entry(revision)?; - let data = entry.data()?; - let node = *entry.as_revlog_entry().node(); - Ok(Self::new(data.user(), node, data.timestamp()?, config)) + match revision.exclude_wdir() { + Some(revision) => { + let entry = changelog.entry(revision)?; + let data = entry.data()?; + let node = *entry.as_revlog_entry().node(); + Ok(Self::new(data.user(), node, data.timestamp()?, config)) + } + None => { + let p1 = repo.dirstate_parents()?.p1; + let fs_path = hg::utils::hg_path::hg_path_to_path_buf(path)?; + let meta = + repo.working_directory_vfs().symlink_metadata(&fs_path)?; + let mtime = meta.modified().when_reading_file(&fs_path)?; + let mtime = DateTime::<Local>::from(mtime).fixed_offset(); + let user = + &config.wdir_config.as_ref().expect("should be set").user; + Ok(Self::new(user, p1, mtime, config)) + } + } } fn new( @@ -343,8 +387,12 @@ config: FormatterConfig, ) -> Result<Self, CommandError> { let changelog = repo.changelog()?; + let dirstate_p1 = + changelog.rev_from_node(repo.dirstate_parents()?.p1.into())?; Ok(Self { + repo, changelog, + dirstate_p1, encoder, config, cache: FastHashMap::default(), @@ -353,18 +401,24 @@ fn format( &mut self, + path: &HgPath, annotations: Vec<ChangesetAnnotation>, ) -> Result<Vec<Vec<u8>>, CommandError> { let mut lines: Vec<Vec<Vec<u8>>> = Vec::with_capacity(annotations.len()); let num_fields = self.config.include.count(); let mut widths = vec![0usize; num_fields]; + // Clear the wdir cache entry, otherwise `rhg annotate --date f1 f2` + // would use f1's mtime for lines in f2 attributed to wdir. + self.cache.remove(&RevisionOrWdir::wdir()); for annotation in annotations { - let revision = annotation.revision; - let data = match self.cache.entry(revision) { + let rev = annotation.revision; + let data = match self.cache.entry(rev) { Entry::Occupied(occupied) => occupied.into_mut(), Entry::Vacant(vacant) => vacant.insert(ChangesetData::create( - revision, + rev, + path, + self.repo, &self.changelog, &self.config, )?), @@ -374,10 +428,13 @@ fields.push(user.clone()); } if self.config.include.number { - fields.push(format_bytes!(b"{}", revision)); + let number = rev.exclude_wdir().unwrap_or(self.dirstate_p1); + let sigil = fmt_sigil(&self.config, rev, SigilFor::Number); + fields.push(format_bytes!(b"{}{}", number, sigil)); } if let Some(changeset) = &data.changeset { - fields.push(changeset.clone()); + let sigil = fmt_sigil(&self.config, rev, SigilFor::Changeset); + fields.push(format_bytes!(b"{}{}", changeset, sigil)); } if let Some(date) = &data.date { fields.push(date.clone()); @@ -419,3 +476,23 @@ .collect()) } } + +fn fmt_sigil( + config: &FormatterConfig, + rev: RevisionOrWdir, + which: SigilFor, +) -> &'static [u8] { + // The "+" sigil is only used for '--rev wdir()'. + if config.wdir_config.is_none() { + return b""; + }; + // With --number --changeset, put it after the changeset. + if which == SigilFor::Number && config.include.changeset { + return b""; + } + if rev.is_wdir() { + b"+" + } else { + b" " + } +} diff --git a/tests/test-annotate.t b/tests/test-annotate.t --- a/tests/test-annotate.t +++ b/tests/test-annotate.t @@ -693,8 +693,9 @@ $ rm baz $ hg annotate -ncr "wdir()" baz - abort: $TESTTMP\repo/baz: $ENOENT$ (windows !) - abort: $ENOENT$: '$TESTTMP/repo/baz' (no-windows !) + abort: baz: $ENOENT$ (rhg !) + abort: $TESTTMP\repo/baz: $ENOENT$ (no-rhg windows !) + abort: $ENOENT$: '$TESTTMP/repo/baz' (no-rhg no-windows !) [255] annotate removed file @@ -702,8 +703,9 @@ $ hg rm baz $ hg annotate -ncr "wdir()" baz - abort: $TESTTMP\repo/baz: $ENOENT$ (windows !) - abort: $ENOENT$: '$TESTTMP/repo/baz' (no-windows !) + abort: baz: $ENOENT$ (rhg !) + abort: $TESTTMP\repo/baz: $ENOENT$ (no-rhg windows !) + abort: $ENOENT$: '$TESTTMP/repo/baz' (no-rhg no-windows !) [255] annotate file neither in repo nor working copy diff --git a/tests/test-rhg.t b/tests/test-rhg.t --- a/tests/test-rhg.t +++ b/tests/test-rhg.t @@ -202,6 +202,8 @@ test 0 1c9e69808da7 Thu Jan 01 00:00:00 1970 +0000 original:1: original content $ $NO_FALLBACK rhg blame -r . -ufdnclawbBZ --no-follow original test 0 1c9e69808da7 Thu Jan 01 00:00:00 1970 +0000 original:1: original content + $ $NO_FALLBACK rhg annotate -r 'wdir()' original + 0 : original content Fallback to Python $ $NO_FALLBACK rhg cat original --exclude="*.rs" # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1741288729 18000 # Thu Mar 06 14:18:49 2025 -0500 # Node ID 0d7f3a65e50312a9ad53f6cb95f05db4a97aa9d0 # Parent 170302f9b74fccab8c915c30b5823e39669e4de9 # EXP-Topic annotate-json annotate: add test for non-UTF8 with -Tjson This adds a test to test-annotate.t for annotating a non-UTF8 file with -Tjson. The current behavior is incorrect and outputs invalid JSON. I plan to fix the behavior in rhg annotate. diff --git a/tests/test-annotate.t b/tests/test-annotate.t --- a/tests/test-annotate.t +++ b/tests/test-annotate.t @@ -1060,6 +1060,19 @@ } ] +Test non-UTF8 (should use U+FFFD replacement character) +TODO: fix Python which instead emits invalid JSON + + $ "$PYTHON" -c 'open("latin1", "wb").write(b"\xc9")' + $ hg ci -qAm 'add latin1 file' + $ hg annotate -Tjson latin1 + [ + { + "lines": [{"line": "\xed\xb3\x89", "rev": 35}], (esc) (known-bad-output !) + "path": "latin1" + } + ] + Test annotate with whitespace options $ cd .. # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1741125338 18000 # Tue Mar 04 16:55:38 2025 -0500 # Node ID 2d9c24082adc1f62c6423b653cd8ecad7e2abb75 # Parent 0d7f3a65e50312a9ad53f6cb95f05db4a97aa9d0 # EXP-Topic annotate-json rhg: add dependency serde_json This will be used for rhg annotate -Tjson. diff --git a/rust/Cargo.lock b/rust/Cargo.lock --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -842,6 +842,12 @@ ] [[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] name = "jobserver" version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1359,6 +1365,7 @@ "logging_timer", "rayon", "regex", + "serde_json", "shellexpand", "which", "whoami", @@ -1387,6 +1394,12 @@ ] [[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1420,18 +1433,18 @@ [[package]] name = "serde" -version = "1.0.215" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.215" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" dependencies = [ "proc-macro2", "quote", @@ -1439,6 +1452,18 @@ ] [[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] name = "serde_spanned" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/rust/rhg/Cargo.toml b/rust/rhg/Cargo.toml --- a/rust/rhg/Cargo.toml +++ b/rust/rhg/Cargo.toml @@ -27,3 +27,4 @@ which = "4.3.0" rayon = "1.7.0" libc = "0.2.155" +serde_json = "1.0.140" # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1742324058 14400 # Tue Mar 18 14:54:18 2025 -0400 # Node ID 3f347e37603476a1c71941314b6681974a094e64 # Parent 2d9c24082adc1f62c6423b653cd8ecad7e2abb75 # EXP-Topic annotate-json rust-annotate: reorder in preparation for -Tjson refactor This moves around code in rust/rhg/src/commands/annotate.rs to make the refactor coming after this easier to review. I did it just by copying and pasting code, and adding a `print_output` function that contains the last half of `run`. diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs --- a/rust/rhg/src/commands/annotate.rs +++ b/rust/rhg/src/commands/annotate.rs @@ -216,54 +216,8 @@ wdir_config, }, )?; - let mut stdout = invocation.ui.stdout_buffer(); - for path in files { - match annotate(repo, &path, rev, options)? { - AnnotateOutput::Text(text) => { - let annotations = formatter.format(&path, text.annotations)?; - for (annotation, line) in annotations.iter().zip(&text.lines) { - stdout.write_all(&format_bytes!( - b"{}: {}", annotation, line - ))?; - } - if let Some(line) = text.lines.last() { - if !line.ends_with(b"\n") { - stdout.write_all(b"\n")?; - } - } - } - AnnotateOutput::Binary => { - stdout.write_all(&format_bytes!( - b"{}: binary file\n", - path.as_bytes() - ))?; - } - AnnotateOutput::NotFound => { - return Err(CommandError::abort(match rev.exclude_wdir() { - Some(rev) => { - let short = - repo.changelog()?.node_from_rev(rev).short(); - format!("abort: {path}: no such file in rev {short:x}",) - } - None => { - format!("abort: {path}: No such file or directory") - } - })); - } - } - } - stdout.flush()?; - Ok(()) -} - -struct Formatter<'a> { - repo: &'a Repo, - changelog: Ref<'a, Changelog>, - dirstate_p1: Revision, - encoder: &'a Encoder, - config: FormatterConfig, - cache: FastHashMap<RevisionOrWdir, ChangesetData>, + print_output(repo, invocation.ui, options, &mut formatter, rev, files) } struct FormatterConfig { @@ -318,66 +272,53 @@ Changeset, } -impl ChangesetData { - fn create( - revision: RevisionOrWdir, - path: &HgPath, - repo: &Repo, - changelog: &Changelog, - config: &FormatterConfig, - ) -> Result<Self, CommandError> { - let include = &config.include; - if !(include.user || include.changeset || include.date) { - return Ok(Self::default()); - } - match revision.exclude_wdir() { - Some(revision) => { - let entry = changelog.entry(revision)?; - let data = entry.data()?; - let node = *entry.as_revlog_entry().node(); - Ok(Self::new(data.user(), node, data.timestamp()?, config)) +fn print_output( + repo: &Repo, + ui: &crate::Ui, + options: AnnotateOptions, + formatter: &mut Formatter<'_>, + rev: RevisionOrWdir, + files: Vec<hg::utils::hg_path::HgPathBuf>, +) -> Result<(), CommandError> { + let mut stdout = ui.stdout_buffer(); + /* TODO move the below */ + for path in files { + match annotate(repo, &path, rev, options)? { + AnnotateOutput::Text(text) => { + let annotations = formatter.format(&path, text.annotations)?; + for (annotation, line) in annotations.iter().zip(&text.lines) { + stdout.write_all(&format_bytes!( + b"{}: {}", annotation, line + ))?; + } + if let Some(line) = text.lines.last() { + if !line.ends_with(b"\n") { + stdout.write_all(b"\n")?; + } + } } - None => { - let p1 = repo.dirstate_parents()?.p1; - let fs_path = hg::utils::hg_path::hg_path_to_path_buf(path)?; - let meta = - repo.working_directory_vfs().symlink_metadata(&fs_path)?; - let mtime = meta.modified().when_reading_file(&fs_path)?; - let mtime = DateTime::<Local>::from(mtime).fixed_offset(); - let user = - &config.wdir_config.as_ref().expect("should be set").user; - Ok(Self::new(user, p1, mtime, config)) + AnnotateOutput::Binary => { + stdout.write_all(&format_bytes!( + b"{}: binary file\n", + path.as_bytes() + ))?; + } + AnnotateOutput::NotFound => { + return Err(CommandError::abort(match rev.exclude_wdir() { + Some(rev) => { + let short = + repo.changelog()?.node_from_rev(rev).short(); + format!("abort: {path}: no such file in rev {short:x}",) + } + None => { + format!("abort: {path}: No such file or directory") + } + })); } } } - - fn new( - user: &[u8], - changeset: Node, - date: DateTime<FixedOffset>, - config: &FormatterConfig, - ) -> Self { - let mut result = ChangesetData::default(); - if config.include.user { - let user = match config.verbosity { - Verbosity::Verbose => user, - _ => hg::utils::strings::short_user(user), - }; - result.user = Some(user.to_vec()); - } - if config.include.changeset { - result.changeset = - Some(format!("{:x}", changeset.short()).into_bytes()); - } - if config.include.date { - let date = date.format(match config.verbosity { - Verbosity::Quiet => "%Y-%m-%d", - _ => "%a %b %d %H:%M:%S %Y %z", - }); - result.date = Some(format!("{}", date).into_bytes()); - } - result - } + stdout.flush()?; + Ok(()) } impl<'a> Formatter<'a> { @@ -496,3 +437,74 @@ b" " } } + +struct Formatter<'a> { + repo: &'a Repo, + changelog: Ref<'a, Changelog>, + dirstate_p1: Revision, + encoder: &'a Encoder, + config: FormatterConfig, + cache: FastHashMap<RevisionOrWdir, ChangesetData>, +} + +impl ChangesetData { + fn create( + revision: RevisionOrWdir, + path: &HgPath, + repo: &Repo, + changelog: &Changelog, + config: &FormatterConfig, + ) -> Result<Self, CommandError> { + let include = &config.include; + if !(include.user || include.changeset || include.date) { + return Ok(Self::default()); + } + match revision.exclude_wdir() { + Some(revision) => { + let entry = changelog.entry(revision)?; + let data = entry.data()?; + let node = *entry.as_revlog_entry().node(); + Ok(Self::new(data.user(), node, data.timestamp()?, config)) + } + None => { + let p1 = repo.dirstate_parents()?.p1; + let fs_path = hg::utils::hg_path::hg_path_to_path_buf(path)?; + let meta = + repo.working_directory_vfs().symlink_metadata(&fs_path)?; + let mtime = meta.modified().when_reading_file(&fs_path)?; + let mtime = DateTime::<Local>::from(mtime).fixed_offset(); + let user = + &config.wdir_config.as_ref().expect("should be set").user; + Ok(Self::new(user, p1, mtime, config)) + } + } + } + + fn new( + user: &[u8], + changeset: Node, + date: DateTime<FixedOffset>, + config: &FormatterConfig, + ) -> Self { + let mut result = ChangesetData::default(); + if config.include.user { + let user = match config.verbosity { + Verbosity::Verbose => user, + _ => hg::utils::strings::short_user(user), + }; + result.user = Some(user.to_vec()); + } + if config.include.changeset { + result.changeset = + Some(format!("{:x}", changeset.short()).into_bytes()); + } + if config.include.date { + let date = date.format(match config.verbosity { + Verbosity::Quiet => "%Y-%m-%d", + _ => "%a %b %d %H:%M:%S %Y %z", + }); + result.date = Some(format!("{}", date).into_bytes()); + } + result + } +} # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1741276265 18000 # Thu Mar 06 10:51:05 2025 -0500 # Node ID f84bb0c08ce6f8554b2d29f3ac08773e0e872e73 # Parent 3f347e37603476a1c71941314b6681974a094e64 # EXP-Topic annotate-json rust-annotate: refactor in preparation for -Tjson I had to restructure the annotation formatting to support multiple templates, so I split this into a separate change. diff --git a/rust/hg-core/src/operations/mod.rs b/rust/hg-core/src/operations/mod.rs --- a/rust/hg-core/src/operations/mod.rs +++ b/rust/hg-core/src/operations/mod.rs @@ -8,7 +8,8 @@ mod list_tracked_files; mod status_rev_rev; pub use annotate::{ - annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotation, + annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotatedFile, + ChangesetAnnotation, }; pub use cat::{cat, CatOutput}; pub use debugdata::debug_data; diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs --- a/rust/rhg/src/commands/annotate.rs +++ b/rust/rhg/src/commands/annotate.rs @@ -5,9 +5,9 @@ use format_bytes::format_bytes; use hg::{ encoding::Encoder, - errors::IoResultExt as _, + errors::{HgError, IoResultExt as _}, operations::{ - annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotation, + annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotatedFile, }, repo::Repo, revlog::{changelog::Changelog, RevisionOrWdir}, @@ -15,7 +15,10 @@ FastHashMap, Node, Revision, }; -use crate::{error::CommandError, utils::path_utils::resolve_file_args}; +use crate::{ + error::CommandError, ui::StdoutBuffer, + utils::path_utils::resolve_file_args, +}; pub const HELP_TEXT: &str = " show changeset information by line for each file @@ -207,20 +210,20 @@ None }; - let mut formatter = Formatter::new( - repo, - invocation.ui.encoder(), - FormatterConfig { - include, - verbosity, - wdir_config, - }, - )?; + let format_config = FormatConfig { + include, + verbosity, + wdir_config, + }; - print_output(repo, invocation.ui, options, &mut formatter, rev, files) + let file_results = files.iter().map(|path| -> FileResult { + (path.as_ref(), annotate(repo, path, rev, options)) + }); + + print_output(repo, invocation.ui, &format_config, rev, file_results) } -struct FormatterConfig { +struct FormatConfig { include: Include, verbosity: Verbosity, wdir_config: Option<WdirConfig>, @@ -258,6 +261,7 @@ user: Vec<u8>, } +/// Information that we can cache per changeset. #[derive(Default)] struct ChangesetData { user: Option<Vec<u8>>, @@ -272,30 +276,32 @@ Changeset, } -fn print_output( +type FileResult<'a> = (&'a HgPath, Result<AnnotateOutput, HgError>); + +fn print_output<'a>( repo: &Repo, ui: &crate::Ui, - options: AnnotateOptions, - formatter: &mut Formatter<'_>, + config: &FormatConfig, rev: RevisionOrWdir, - files: Vec<hg::utils::hg_path::HgPathBuf>, + file_results: impl Iterator<Item = FileResult<'a>>, ) -> Result<(), CommandError> { - let mut stdout = ui.stdout_buffer(); - /* TODO move the below */ - for path in files { - match annotate(repo, &path, rev, options)? { - AnnotateOutput::Text(text) => { - let annotations = formatter.format(&path, text.annotations)?; - for (annotation, line) in annotations.iter().zip(&text.lines) { - stdout.write_all(&format_bytes!( - b"{}: {}", annotation, line - ))?; - } - if let Some(line) = text.lines.last() { - if !line.ends_with(b"\n") { - stdout.write_all(b"\n")?; - } - } + let encoder = ui.encoder(); + let stdout = &mut ui.stdout_buffer(); + let dirstate_p1 = repo + .changelog()? + .rev_from_node(repo.dirstate_parents()?.p1.into())?; + let mut cache = Cache::new(repo)?; + for (path, output) in file_results { + match output? { + AnnotateOutput::Text(file) => { + print_lines_default( + file, + config, + stdout, + encoder, + dirstate_p1, + cache.for_path(path), + )?; } AnnotateOutput::Binary => { stdout.write_all(&format_bytes!( @@ -304,16 +310,7 @@ ))?; } AnnotateOutput::NotFound => { - return Err(CommandError::abort(match rev.exclude_wdir() { - Some(rev) => { - let short = - repo.changelog()?.node_from_rev(rev).short(); - format!("abort: {path}: no such file in rev {short:x}",) - } - None => { - format!("abort: {path}: No such file or directory") - } - })); + return handle_not_found(repo, rev, path) } } } @@ -321,105 +318,96 @@ Ok(()) } -impl<'a> Formatter<'a> { - fn new( - repo: &'a Repo, - encoder: &'a Encoder, - config: FormatterConfig, - ) -> Result<Self, CommandError> { - let changelog = repo.changelog()?; - let dirstate_p1 = - changelog.rev_from_node(repo.dirstate_parents()?.p1.into())?; - Ok(Self { - repo, - changelog, - dirstate_p1, - encoder, - config, - cache: FastHashMap::default(), - }) - } +type Stdout<'a> = + StdoutBuffer<'a, std::io::BufWriter<std::io::StdoutLock<'a>>>; - fn format( - &mut self, - path: &HgPath, - annotations: Vec<ChangesetAnnotation>, - ) -> Result<Vec<Vec<u8>>, CommandError> { - let mut lines: Vec<Vec<Vec<u8>>> = - Vec::with_capacity(annotations.len()); - let num_fields = self.config.include.count(); - let mut widths = vec![0usize; num_fields]; - // Clear the wdir cache entry, otherwise `rhg annotate --date f1 f2` - // would use f1's mtime for lines in f2 attributed to wdir. - self.cache.remove(&RevisionOrWdir::wdir()); - for annotation in annotations { - let rev = annotation.revision; - let data = match self.cache.entry(rev) { - Entry::Occupied(occupied) => occupied.into_mut(), - Entry::Vacant(vacant) => vacant.insert(ChangesetData::create( - rev, - path, - self.repo, - &self.changelog, - &self.config, - )?), - }; - let mut fields = Vec::with_capacity(num_fields); - if let Some(user) = &data.user { - fields.push(user.clone()); +fn print_lines_default( + file: ChangesetAnnotatedFile, + config: &FormatConfig, + stdout: &mut Stdout, + encoder: &Encoder, + dirstate_p1: Revision, + mut cache: CacheForPath, +) -> Result<(), CommandError> { + // Serialize the annotation fields (revision, user, etc.) for each line + // and keep track of their maximum lengths so that we can align them. + let mut field_lists: Vec<Vec<Vec<u8>>> = + Vec::with_capacity(file.annotations.len()); + let num_fields = config.include.count(); + let mut widths = vec![0usize; num_fields]; + for annotation in file.annotations { + let rev = annotation.revision; + let data = cache.get_data(rev, config)?; + let mut fields = Vec::with_capacity(num_fields); + if let Some(user) = &data.user { + fields.push(user.clone()); + } + if config.include.number { + let number = rev.exclude_wdir().unwrap_or(dirstate_p1); + let sigil = fmt_sigil(config, rev, SigilFor::Number); + fields.push(format_bytes!(b"{}{}", number, sigil)); + } + if let Some(changeset) = &data.changeset { + let sigil = fmt_sigil(config, rev, SigilFor::Changeset); + fields.push(format_bytes!(b"{}{}", changeset, sigil)); + } + if let Some(date) = &data.date { + fields.push(date.clone()); + } + if config.include.file { + fields.push(annotation.path.into_vec()); + } + if config.include.line_number { + fields.push(format_bytes!(b"{}", annotation.line_number)); + } + for (field, width) in fields.iter().zip(widths.iter_mut()) { + *width = std::cmp::max(*width, encoder.column_width_bytes(field)); + } + field_lists.push(fields); + } + // Print each line of the file prefixed by aligned annotations. + let total_width = widths.iter().sum::<usize>() + num_fields - 1; + for (fields, line) in field_lists.iter().zip(file.lines.iter()) { + let mut annotation = Vec::with_capacity(total_width); + for (i, (field, width)) in fields.iter().zip(widths.iter()).enumerate() + { + if i > 0 { + let colon = config.include.line_number && i == num_fields - 1; + annotation.push(if colon { b':' } else { b' ' }); } - if self.config.include.number { - let number = rev.exclude_wdir().unwrap_or(self.dirstate_p1); - let sigil = fmt_sigil(&self.config, rev, SigilFor::Number); - fields.push(format_bytes!(b"{}{}", number, sigil)); - } - if let Some(changeset) = &data.changeset { - let sigil = fmt_sigil(&self.config, rev, SigilFor::Changeset); - fields.push(format_bytes!(b"{}{}", changeset, sigil)); - } - if let Some(date) = &data.date { - fields.push(date.clone()); - } - if self.config.include.file { - fields.push(annotation.path.into_vec()); - } - if self.config.include.line_number { - fields.push(format_bytes!(b"{}", annotation.line_number)); - } - for (field, width) in fields.iter().zip(widths.iter_mut()) { - *width = std::cmp::max( - *width, - self.encoder.column_width_bytes(field), - ); - } - lines.push(fields); + let padding = width - encoder.column_width_bytes(field); + annotation.resize(annotation.len() + padding, b' '); + annotation.extend_from_slice(field); } - let total_width = widths.iter().sum::<usize>() + num_fields - 1; - Ok(lines - .iter() - .map(|fields| { - let mut bytes = Vec::with_capacity(total_width); - for (i, (field, width)) in - fields.iter().zip(widths.iter()).enumerate() - { - if i > 0 { - let colon = self.config.include.line_number - && i == num_fields - 1; - bytes.push(if colon { b':' } else { b' ' }); - } - let padding = - width - self.encoder.column_width_bytes(field); - bytes.resize(bytes.len() + padding, b' '); - bytes.extend_from_slice(field); - } - bytes - }) - .collect()) + stdout.write_all(&format_bytes!(b"{}: {}", annotation, line))?; } + if let Some(line) = file.lines.last() { + if !line.ends_with(b"\n") { + stdout.write_all(b"\n")?; + } + } + Ok(()) } +fn handle_not_found( + repo: &Repo, + rev: RevisionOrWdir, + path: &HgPath, +) -> Result<(), CommandError> { + Err(CommandError::abort(match rev.exclude_wdir() { + Some(rev) => { + let short = repo.changelog()?.node_from_rev(rev).short(); + format!("abort: {path}: no such file in rev {short:x}",) + } + None => { + format!("abort: {path}: No such file or directory") + } + })) +} + +/// Returns the sigil to put after the revision number or changeset. fn fmt_sigil( - config: &FormatterConfig, + config: &FormatConfig, rev: RevisionOrWdir, which: SigilFor, ) -> &'static [u8] { @@ -438,13 +426,58 @@ } } -struct Formatter<'a> { +/// A cache of [`ChangesetData`] for each changeset we've seen. +struct Cache<'a> { repo: &'a Repo, changelog: Ref<'a, Changelog>, - dirstate_p1: Revision, - encoder: &'a Encoder, - config: FormatterConfig, - cache: FastHashMap<RevisionOrWdir, ChangesetData>, + map: FastHashMap<RevisionOrWdir, ChangesetData>, +} + +impl<'a> Cache<'a> { + fn new(repo: &'a Repo) -> Result<Self, CommandError> { + Ok(Self { + repo, + changelog: repo.changelog()?, + map: Default::default(), + }) + } + + fn for_path(&mut self, path: &'a HgPath) -> CacheForPath<'_, 'a> { + CacheForPath { cache: self, path } + } +} + +/// [`Cache`] scoped to annotating a particular file. +struct CacheForPath<'a, 'b> { + cache: &'a mut Cache<'b>, + path: &'a HgPath, +} + +impl CacheForPath<'_, '_> { + fn get_data( + &mut self, + rev: RevisionOrWdir, + config: &FormatConfig, + ) -> Result<&ChangesetData, CommandError> { + Ok(match self.cache.map.entry(rev) { + Entry::Occupied(occupied) => occupied.into_mut(), + Entry::Vacant(vacant) => vacant.insert(ChangesetData::create( + rev, + self.path, + self.cache.repo, + &self.cache.changelog, + config, + )?), + }) + } +} + +impl Drop for CacheForPath<'_, '_> { + fn drop(&mut self) { + // Clear the wdir cache entry, otherwise `rhg annotate --date f1 f2` + // would use f1's mtime for lines in f2 attributed to wdir. + self.cache.map.remove(&RevisionOrWdir::wdir()); + } } impl ChangesetData { @@ -453,7 +486,7 @@ path: &HgPath, repo: &Repo, changelog: &Changelog, - config: &FormatterConfig, + config: &FormatConfig, ) -> Result<Self, CommandError> { let include = &config.include; if !(include.user || include.changeset || include.date) { @@ -464,7 +497,7 @@ let entry = changelog.entry(revision)?; let data = entry.data()?; let node = *entry.as_revlog_entry().node(); - Ok(Self::new(data.user(), node, data.timestamp()?, config)) + Self::new(data.user(), node, data.timestamp()?, config) } None => { let p1 = repo.dirstate_parents()?.p1; @@ -475,7 +508,7 @@ let mtime = DateTime::<Local>::from(mtime).fixed_offset(); let user = &config.wdir_config.as_ref().expect("should be set").user; - Ok(Self::new(user, p1, mtime, config)) + Self::new(user, p1, mtime, config) } } } @@ -484,27 +517,30 @@ user: &[u8], changeset: Node, date: DateTime<FixedOffset>, - config: &FormatterConfig, - ) -> Self { + config: &FormatConfig, + ) -> Result<Self, CommandError> { let mut result = ChangesetData::default(); if config.include.user { let user = match config.verbosity { - Verbosity::Verbose => user, - _ => hg::utils::strings::short_user(user), + Verbosity::Verbose => user.to_vec(), + _ => hg::utils::strings::short_user(user).to_vec(), }; result.user = Some(user.to_vec()); } if config.include.changeset { - result.changeset = - Some(format!("{:x}", changeset.short()).into_bytes()); + let hex = format!("{:x}", changeset.short()); + result.changeset = Some(hex.into_bytes()); } if config.include.date { - let date = date.format(match config.verbosity { - Verbosity::Quiet => "%Y-%m-%d", - _ => "%a %b %d %H:%M:%S %Y %z", - }); - result.date = Some(format!("{}", date).into_bytes()); + let date = format!( + "{}", + date.format(match config.verbosity { + Verbosity::Quiet => "%Y-%m-%d", + _ => "%a %b %d %H:%M:%S %Y %z", + }) + ); + result.date = Some(date.into_bytes()); } - result + Ok(result) } } # HG changeset patch # User Mitchell Kember <mkember@janestreet.com> # Date 1741037847 18000 # Mon Mar 03 16:37:27 2025 -0500 # Node ID 1b2961dee90628a17c42d3a0b173ec2d2a706eac # Parent f84bb0c08ce6f8554b2d29f3ac08773e0e872e73 # EXP-Topic annotate-json rust-annotate: support -Tjson This adds support for the json template in rhg annotate. All other -T/--template values continue to fallback to Python. I matched the format of the Python output so all existing tests pass. This was not that hard to do printing JSON manually. The only thing I use serde_json for is to escape strings. diff --git a/rust/rhg/src/commands/annotate.rs b/rust/rhg/src/commands/annotate.rs --- a/rust/rhg/src/commands/annotate.rs +++ b/rust/rhg/src/commands/annotate.rs @@ -12,7 +12,8 @@ repo::Repo, revlog::{changelog::Changelog, RevisionOrWdir}, utils::{hg_path::HgPath, strings::CleanWhitespace}, - FastHashMap, Node, Revision, + FastHashMap, Node, Revision, WORKING_DIRECTORY_HEX, + WORKING_DIRECTORY_REVISION, }; use crate::{ @@ -141,6 +142,12 @@ .long("ignore-space-at-eol") .action(clap::ArgAction::SetTrue), ) + .arg( + clap::Arg::new("template") + .help("display with template") + .short('T') + .long("template"), + ) .about(HELP_TEXT) } @@ -203,6 +210,14 @@ (true, true) => unreachable!(), }; + let template = match args.get_one::<String>("template") { + None => Template::Default, + Some(name) if name == "json" => Template::Json, + _ => { + return Err(CommandError::unsupported("only -Tjson is suppported")) + } + }; + let wdir_config = if rev.is_wdir() { let user = config.username()?; Some(WdirConfig { user }) @@ -211,6 +226,7 @@ }; let format_config = FormatConfig { + template, include, verbosity, wdir_config, @@ -224,11 +240,17 @@ } struct FormatConfig { + template: Template, include: Include, verbosity: Verbosity, wdir_config: Option<WdirConfig>, } +enum Template { + Default, + Json, +} + struct Include { user: bool, number: bool, @@ -262,6 +284,7 @@ } /// Information that we can cache per changeset. +/// For [`Template::Json`], the values are JSON encoded. #[derive(Default)] struct ChangesetData { user: Option<Vec<u8>>, @@ -291,27 +314,58 @@ .changelog()? .rev_from_node(repo.dirstate_parents()?.p1.into())?; let mut cache = Cache::new(repo)?; - for (path, output) in file_results { - match output? { - AnnotateOutput::Text(file) => { - print_lines_default( - file, - config, - stdout, - encoder, - dirstate_p1, - cache.for_path(path), - )?; + match config.template { + Template::Default => { + for (path, output) in file_results { + match output? { + AnnotateOutput::Text(file) => { + print_lines_default( + file, + config, + stdout, + encoder, + cache.for_path(path), + dirstate_p1, + )?; + } + AnnotateOutput::Binary => { + stdout.write_all(&format_bytes!( + b"{}: binary file\n", + path.as_bytes() + ))?; + } + AnnotateOutput::NotFound => { + return handle_not_found(repo, rev, path) + } + } } - AnnotateOutput::Binary => { - stdout.write_all(&format_bytes!( - b"{}: binary file\n", - path.as_bytes() - ))?; + } + Template::Json => { + stdout.write_all(b"[")?; + let mut file_sep: &[u8] = b"\n"; + for (path, output) in file_results { + stdout.write_all(file_sep)?; + file_sep = b",\n"; + stdout.write_all(b" {\n")?; + match output? { + AnnotateOutput::Text(file) => { + print_lines_json( + file, + config, + stdout, + cache.for_path(path), + )?; + } + AnnotateOutput::Binary => {} + AnnotateOutput::NotFound => { + return handle_not_found(repo, rev, path) + } + } + let path = json_string(path.as_bytes())?; + stdout + .write_all(&format_bytes!(b" \"path\": {}\n }", path))?; } - AnnotateOutput::NotFound => { - return handle_not_found(repo, rev, path) - } + stdout.write_all(b"\n]\n")?; } } stdout.flush()?; @@ -326,8 +380,8 @@ config: &FormatConfig, stdout: &mut Stdout, encoder: &Encoder, + mut cache: CacheForPath, dirstate_p1: Revision, - mut cache: CacheForPath, ) -> Result<(), CommandError> { // Serialize the annotation fields (revision, user, etc.) for each line // and keep track of their maximum lengths so that we can align them. @@ -389,6 +443,61 @@ Ok(()) } +fn print_lines_json( + file: ChangesetAnnotatedFile, + config: &FormatConfig, + stdout: &mut Stdout, + mut cache: CacheForPath, +) -> Result<(), CommandError> { + stdout.write_all(br#" "lines": ["#)?; + let mut line_sep: &[u8] = b""; + for (annotation, line) in file.annotations.iter().zip(file.lines.iter()) { + stdout.write_all(line_sep)?; + line_sep = b", "; + + let mut property_sep: &[u8] = b""; + let mut property = |key: &[u8], value: &[u8]| { + let res = + format_bytes!(br#"{}"{}": {}"#, property_sep, key, value); + property_sep = b", "; + res + }; + + stdout.write_all(b"{")?; + let rev = annotation.revision; + let data = cache.get_data(rev, config)?; + if let Some(date_json) = &data.date { + stdout.write_all(&property(b"date", date_json))?; + } + stdout.write_all(&property(b"line", &json_string(line)?))?; + if config.include.line_number { + let lineno = annotation.line_number.to_string(); + stdout.write_all(&property(b"lineno", lineno.as_bytes()))?; + } + if let Some(changeset_json) = &data.changeset { + stdout.write_all(&property(b"node", changeset_json))?; + } + if config.include.file { + let path = json_string(annotation.path.as_bytes())?; + stdout.write_all(&property(b"path", &path))?; + } + if config.include.number { + let number = match rev.exclude_wdir() { + Some(rev) => rev.0, + None => WORKING_DIRECTORY_REVISION.0, + }; + stdout + .write_all(&property(b"rev", number.to_string().as_bytes()))?; + } + if let Some(user_json) = &data.user { + stdout.write_all(&property(b"user", user_json))?; + } + stdout.write_all(b"}")?; + } + stdout.write_all(b"],\n")?; + Ok(()) +} + fn handle_not_found( repo: &Repo, rev: RevisionOrWdir, @@ -500,7 +609,11 @@ Self::new(data.user(), node, data.timestamp()?, config) } None => { - let p1 = repo.dirstate_parents()?.p1; + let node = match config.template { + Template::Default => repo.dirstate_parents()?.p1, + Template::Json => Node::from_hex(WORKING_DIRECTORY_HEX) + .expect("wdir hex should parse"), + }; let fs_path = hg::utils::hg_path::hg_path_to_path_buf(path)?; let meta = repo.working_directory_vfs().symlink_metadata(&fs_path)?; @@ -508,7 +621,7 @@ let mtime = DateTime::<Local>::from(mtime).fixed_offset(); let user = &config.wdir_config.as_ref().expect("should be set").user; - Self::new(user, p1, mtime, config) + Self::new(user, node, mtime, config) } } } @@ -521,26 +634,49 @@ ) -> Result<Self, CommandError> { let mut result = ChangesetData::default(); if config.include.user { - let user = match config.verbosity { - Verbosity::Verbose => user.to_vec(), - _ => hg::utils::strings::short_user(user).to_vec(), + let user = match config.template { + Template::Default => match config.verbosity { + Verbosity::Verbose => user.to_vec(), + _ => hg::utils::strings::short_user(user).to_vec(), + }, + Template::Json => json_string(user)?, }; result.user = Some(user.to_vec()); } if config.include.changeset { - let hex = format!("{:x}", changeset.short()); + let hex = match config.template { + Template::Default => format!("{:x}", changeset.short()), + Template::Json => format!("\"{:x}\"", changeset), + }; result.changeset = Some(hex.into_bytes()); } if config.include.date { - let date = format!( - "{}", - date.format(match config.verbosity { - Verbosity::Quiet => "%Y-%m-%d", - _ => "%a %b %d %H:%M:%S %Y %z", - }) - ); + let date = match config.template { + Template::Default => { + format!( + "{}", + date.format(match config.verbosity { + Verbosity::Quiet => "%Y-%m-%d", + _ => "%a %b %d %H:%M:%S %Y %z", + }) + ) + } + Template::Json => format!( + "[{}.0, {}]", + date.timestamp(), + date.offset().utc_minus_local(), + ), + }; result.date = Some(date.into_bytes()); } Ok(result) } } + +fn json_string(text: &[u8]) -> Result<Vec<u8>, CommandError> { + serde_json::to_vec(&String::from_utf8_lossy(text)).map_err(|err| { + CommandError::abort(format!( + "failed to serialize string to JSON: {err}" + )) + }) +} diff --git a/tests/test-annotate.t b/tests/test-annotate.t --- a/tests/test-annotate.t +++ b/tests/test-annotate.t @@ -1068,7 +1068,8 @@ $ hg annotate -Tjson latin1 [ { - "lines": [{"line": "\xed\xb3\x89", "rev": 35}], (esc) (known-bad-output !) + "lines": [{"line": "\xed\xb3\x89", "rev": 35}], (esc) (no-rhg known-bad-output !) + "lines": [{"line": "\xef\xbf\xbd", "rev": 35}], (esc) (rhg !) "path": "latin1" } ] diff --git a/tests/test-rhg.t b/tests/test-rhg.t --- a/tests/test-rhg.t +++ b/tests/test-rhg.t @@ -204,6 +204,13 @@ test 0 1c9e69808da7 Thu Jan 01 00:00:00 1970 +0000 original:1: original content $ $NO_FALLBACK rhg annotate -r 'wdir()' original 0 : original content + $ $NO_FALLBACK rhg annotate -Tjson original + [ + { + "lines": [{"line": "original content\n", "rev": 0}], + "path": "original" + } + ] Fallback to Python $ $NO_FALLBACK rhg cat original --exclude="*.rs"