comparison rust/rhg/src/commands/annotate.rs @ 52767:6183949219b2

rhg: implement rhg annotate This initial implementation produces the same output as Python for all the files I've tried, and is usually 1.5-9x faster. The algorithm is mostly the same, but one key difference is that the Rust implementation only converts filelog revisions to changelog revisions if they will actually appear in the output. This does not support all the command line flags yet. In particular, --template, --include, --exclude, --skip, and whitespace-related flags will cause fallback to Python. Also, --rev 'wdir()' (often used by editor plugins) is not supported. There is also no pager.
author Mitchell Kember <mkember@janestreet.com>
date Fri, 24 Jan 2025 12:01:12 -0500
parents
children 874c64e041b5
comparison
equal deleted inserted replaced
52766:549b58b1ce72 52767:6183949219b2
1 use core::str;
2 use std::{collections::hash_map::Entry, ffi::OsString};
3
4 use format_bytes::format_bytes;
5 use hg::{
6 encoding::Encoder,
7 operations::{
8 annotate, AnnotateOptions, AnnotateOutput, ChangesetAnnotation,
9 },
10 revlog::changelog::Changelog,
11 FastHashMap, Revision,
12 };
13
14 use crate::{error::CommandError, utils::path_utils::resolve_file_args};
15
16 pub const HELP_TEXT: &str = "
17 show changeset information by line for each file
18 ";
19
20 pub fn args() -> clap::Command {
21 clap::command!("annotate")
22 .alias("blame")
23 .arg(
24 clap::Arg::new("files")
25 .help("files to annotate")
26 .required(true)
27 .num_args(1..)
28 .value_name("FILE")
29 .value_parser(clap::value_parser!(OsString)),
30 )
31 .arg(
32 clap::Arg::new("rev")
33 .help("annotate the specified revision")
34 .short('r')
35 .long("rev")
36 .value_name("REV")
37 .default_value("."),
38 )
39 .arg(
40 clap::Arg::new("no-follow")
41 .help("don't follow copies and renames")
42 .long("no-follow")
43 .action(clap::ArgAction::SetTrue),
44 )
45 .arg(
46 clap::Arg::new("text")
47 .help("treat all files as text")
48 .short('a')
49 .long("text")
50 .action(clap::ArgAction::SetTrue),
51 )
52 .arg(
53 clap::Arg::new("user")
54 .help("list the author (long with -v)")
55 .short('u')
56 .long("user")
57 .action(clap::ArgAction::SetTrue),
58 )
59 .arg(
60 clap::Arg::new("number")
61 .help("list the revision number (default)")
62 .short('n')
63 .long("number")
64 .action(clap::ArgAction::SetTrue),
65 )
66 .arg(
67 clap::Arg::new("changeset")
68 .help("list the changeset")
69 .short('c')
70 .long("changeset")
71 .action(clap::ArgAction::SetTrue),
72 )
73 .arg(
74 clap::Arg::new("date")
75 .help("list the date (short with -q)")
76 .short('d')
77 .long("date")
78 .action(clap::ArgAction::SetTrue),
79 )
80 .arg(
81 clap::Arg::new("file")
82 .help("list the filename")
83 .short('f')
84 .long("file")
85 .action(clap::ArgAction::SetTrue),
86 )
87 .arg(
88 clap::Arg::new("line-number")
89 .help("show the line number at the first appearance")
90 .short('l')
91 .long("line-number")
92 .action(clap::ArgAction::SetTrue),
93 )
94 .arg(
95 clap::Arg::new("quiet")
96 .help("show short date for -d")
97 .short('q')
98 .long("quiet")
99 .action(clap::ArgAction::SetTrue),
100 )
101 .arg(
102 clap::Arg::new("verbose")
103 .help("show full username for -u")
104 .short('v')
105 .long("verbose")
106 .action(clap::ArgAction::SetTrue)
107 .conflicts_with("quiet"),
108 )
109 .about(HELP_TEXT)
110 }
111
112 pub fn run(invocation: &crate::CliInvocation) -> Result<(), CommandError> {
113 let config = invocation.config;
114 if config.has_non_empty_section(b"annotate") {
115 return Err(CommandError::unsupported(
116 "rhg annotate does not support any [annotate] configs",
117 ));
118 }
119
120 let repo = invocation.repo?;
121 let args = invocation.subcommand_args;
122
123 let rev = args.get_one::<String>("rev").expect("rev has a default");
124 let rev = hg::revset::resolve_single(rev, repo)?;
125
126 let files = match args.get_many::<OsString>("files") {
127 None => vec![],
128 Some(files) => resolve_file_args(repo, files)?,
129 };
130
131 let options = AnnotateOptions {
132 treat_binary_as_text: args.get_flag("text"),
133 follow_copies: !args.get_flag("no-follow"),
134 };
135
136 let mut include = Include {
137 user: args.get_flag("user"),
138 number: args.get_flag("number"),
139 changeset: args.get_flag("changeset"),
140 date: args.get_flag("date"),
141 file: args.get_flag("file"),
142 line_number: args.get_flag("line-number"),
143 };
144 if !(include.user || include.file || include.date || include.changeset) {
145 include.number = true;
146 }
147 if include.line_number && !(include.number || include.changeset) {
148 return Err(CommandError::abort(
149 "at least one of -n/-c is required for -l",
150 ));
151 }
152
153 let verbosity = match (args.get_flag("quiet"), args.get_flag("verbose")) {
154 (false, false) => Verbosity::Default,
155 (true, false) => Verbosity::Quiet,
156 (false, true) => Verbosity::Verbose,
157 (true, true) => unreachable!(),
158 };
159
160 let changelog = repo.changelog()?;
161 let mut formatter = Formatter::new(
162 &changelog,
163 invocation.ui.encoder(),
164 include,
165 verbosity,
166 );
167 let mut stdout = invocation.ui.stdout_buffer();
168 for path in files {
169 match annotate(repo, &path, rev, options)? {
170 AnnotateOutput::Text(text) => {
171 let annotations = formatter.format(text.annotations)?;
172 for (annotation, line) in annotations.iter().zip(&text.lines) {
173 stdout.write_all(&format_bytes!(
174 b"{}: {}", annotation, line
175 ))?;
176 }
177 if let Some(line) = text.lines.last() {
178 if !line.ends_with(b"\n") {
179 stdout.write_all(b"\n")?;
180 }
181 }
182 }
183 AnnotateOutput::Binary => {
184 stdout.write_all(&format_bytes!(
185 b"{}: binary file\n",
186 path.as_bytes()
187 ))?;
188 }
189 AnnotateOutput::NotFound => {
190 let short = changelog.node_from_rev(rev).short();
191 return Err(CommandError::abort(format!(
192 "{path}: no such file in rev {short:x}",
193 )));
194 }
195 }
196 }
197 stdout.flush()?;
198
199 Ok(())
200 }
201
202 struct Formatter<'a> {
203 changelog: &'a Changelog,
204 encoder: &'a Encoder,
205 include: Include,
206 verbosity: Verbosity,
207 cache: FastHashMap<Revision, ChangesetData>,
208 }
209
210 #[derive(Copy, Clone)]
211 struct Include {
212 user: bool,
213 number: bool,
214 changeset: bool,
215 date: bool,
216 file: bool,
217 line_number: bool,
218 }
219
220 impl Include {
221 fn count(&self) -> usize {
222 // Rust guarantees false is 0 and true is 1.
223 self.user as usize
224 + self.number as usize
225 + self.changeset as usize
226 + self.date as usize
227 + self.file as usize
228 + self.line_number as usize
229 }
230 }
231
232 #[derive(Copy, Clone)]
233 enum Verbosity {
234 Quiet,
235 Default,
236 Verbose,
237 }
238
239 #[derive(Default)]
240 struct ChangesetData {
241 user: Option<Vec<u8>>,
242 changeset: Option<Vec<u8>>,
243 date: Option<Vec<u8>>,
244 }
245
246 impl ChangesetData {
247 fn create(
248 revision: Revision,
249 changelog: &Changelog,
250 include: Include,
251 verbosity: Verbosity,
252 ) -> Result<ChangesetData, CommandError> {
253 let mut result = ChangesetData::default();
254 if !(include.user || include.changeset || include.date) {
255 return Ok(result);
256 }
257 let entry = changelog.entry(revision)?;
258 let data = entry.data()?;
259 if include.user {
260 let user = match verbosity {
261 Verbosity::Verbose => data.user(),
262 _ => hg::utils::strings::short_user(data.user()),
263 };
264 result.user = Some(user.to_vec());
265 }
266 if include.changeset {
267 let changeset = entry.as_revlog_entry().node().short();
268 result.changeset = Some(format!("{:x}", changeset).into_bytes());
269 }
270 if include.date {
271 let date = data.timestamp()?.format(match verbosity {
272 Verbosity::Quiet => "%Y-%m-%d",
273 _ => "%a %b %d %H:%M:%S %Y %z",
274 });
275 result.date = Some(format!("{}", date).into_bytes());
276 }
277 Ok(result)
278 }
279 }
280
281 impl<'a> Formatter<'a> {
282 fn new(
283 changelog: &'a Changelog,
284 encoder: &'a Encoder,
285 include: Include,
286 verbosity: Verbosity,
287 ) -> Self {
288 let cache = FastHashMap::default();
289 Self {
290 changelog,
291 encoder,
292 include,
293 verbosity,
294 cache,
295 }
296 }
297
298 fn format(
299 &mut self,
300 annotations: Vec<ChangesetAnnotation>,
301 ) -> Result<Vec<Vec<u8>>, CommandError> {
302 let mut lines: Vec<Vec<Vec<u8>>> =
303 Vec::with_capacity(annotations.len());
304 let num_fields = self.include.count();
305 let mut widths = vec![0usize; num_fields];
306 for annotation in annotations {
307 let revision = annotation.revision;
308 let data = match self.cache.entry(revision) {
309 Entry::Occupied(occupied) => occupied.into_mut(),
310 Entry::Vacant(vacant) => vacant.insert(ChangesetData::create(
311 revision,
312 self.changelog,
313 self.include,
314 self.verbosity,
315 )?),
316 };
317 let mut fields = Vec::with_capacity(num_fields);
318 if let Some(user) = &data.user {
319 fields.push(user.clone());
320 }
321 if self.include.number {
322 fields.push(format_bytes!(b"{}", revision));
323 }
324 if let Some(changeset) = &data.changeset {
325 fields.push(changeset.clone());
326 }
327 if let Some(date) = &data.date {
328 fields.push(date.clone());
329 }
330 if self.include.file {
331 fields.push(annotation.path.into_vec());
332 }
333 if self.include.line_number {
334 fields.push(format_bytes!(b"{}", annotation.line_number));
335 }
336 for (field, width) in fields.iter().zip(widths.iter_mut()) {
337 *width = std::cmp::max(
338 *width,
339 self.encoder.column_width_bytes(field),
340 );
341 }
342 lines.push(fields);
343 }
344 let total_width = widths.iter().sum::<usize>() + num_fields - 1;
345 Ok(lines
346 .iter()
347 .map(|fields| {
348 let mut bytes = Vec::with_capacity(total_width);
349 for (i, (field, width)) in
350 fields.iter().zip(widths.iter()).enumerate()
351 {
352 if i > 0 {
353 let colon =
354 self.include.line_number && i == num_fields - 1;
355 bytes.push(if colon { b':' } else { b' ' });
356 }
357 let padding =
358 width - self.encoder.column_width_bytes(field);
359 bytes.resize(bytes.len() + padding, b' ');
360 bytes.extend_from_slice(field);
361 }
362 bytes
363 })
364 .collect())
365 }
366 }