Skip to repository content463 lines · 15.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:37:04.751Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
blame.rs
1use crate::Oid;
2use crate::commit::{get_messages, get_tag_names};
3use crate::repository::{GitBinary, RepoPath};
4use anyhow::{Context as _, Result};
5use collections::{HashMap, HashSet};
6use futures::{AsyncWriteExt, TryFutureExt, try_join};
7use serde::{Deserialize, Serialize};
8use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
9use std::ops::Range;
10use text::{LineEnding, Rope};
11use time::OffsetDateTime;
12use time::UtcOffset;
13use time::macros::format_description;
14use util::command::Stdio;
15
16#[derive(Debug, Clone, Default)]
17pub struct Blame {
18 pub entries: Vec<BlameEntry>,
19 pub messages: HashMap<Oid, String>,
20 pub tag_names: HashMap<Oid, Vec<String>>,
21}
22
23impl Blame {
24 pub(crate) async fn for_path(
25 git: &GitBinary,
26 path: &RepoPath,
27 content: &Rope,
28 line_ending: LineEnding,
29 ) -> Result<Self> {
30 let mut entries = run_git_blame(git, path, content, line_ending).await?;
31
32 let mut unique_shas = HashSet::default();
33
34 for entry in entries.iter_mut() {
35 unique_shas.insert(entry.sha);
36 }
37
38 let shas = unique_shas.into_iter().collect::<Vec<_>>();
39 let (messages, tag_names) = try_join!(
40 get_messages(git, &shas)
41 .map_err(|error| error.context("failed to get commit messages")),
42 async {
43 match get_tag_names(git, &shas).await {
44 Ok(tag_names) => Ok(tag_names),
45 Err(error) => {
46 log::warn!("failed to get commit tag names: {error:#}");
47 Ok(HashMap::default())
48 }
49 }
50 },
51 )?;
52
53 entries.sort_unstable_by_key(|entry| entry.range.start);
54 Ok(Self {
55 entries,
56 messages,
57 tag_names,
58 })
59 }
60}
61
62const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD";
63const GIT_BLAME_NO_PATH: &str = "fatal: no such path";
64const BLAME_PARSE_YIELD_INTERVAL: usize = 512;
65
66async fn run_git_blame(
67 git: &GitBinary,
68 path: &RepoPath,
69 contents: &Rope,
70 line_ending: LineEnding,
71) -> Result<Vec<BlameEntry>> {
72 let mut child = {
73 let span = ztracing::debug_span!("spawning git-blame command", path = path.as_unix_str());
74 let _enter = span.enter();
75 git.build_command(&["blame", "--incremental", "--contents", "-", "--"])
76 .arg(path.as_unix_str())
77 .stdin(Stdio::piped())
78 .stdout(Stdio::piped())
79 .stderr(Stdio::piped())
80 .kill_on_drop(true)
81 .spawn()
82 .context("starting git blame process")?
83 };
84
85 let mut stdin = child
86 .stdin
87 .take()
88 .context("failed to get pipe to stdin of git blame command")?;
89 let stdout = child
90 .stdout
91 .take()
92 .context("failed to get stdout from git blame command")?;
93 let stderr = child
94 .stderr
95 .take()
96 .context("failed to get stderr from git blame command")?;
97
98 let write_stdin = async move {
99 for chunk in text::chunks_with_line_ending(contents, line_ending) {
100 stdin.write_all(chunk.as_bytes()).await?;
101 }
102 stdin.flush().await.map_err(Into::into)
103 };
104
105 let read_stdout = async move {
106 let mut parser = GitBlameParser::new();
107 let mut reader = BufReader::new(stdout);
108 let mut line_buffer = String::new();
109 let mut lines_read = 0;
110
111 loop {
112 line_buffer.clear();
113 let bytes_read = reader
114 .read_line(&mut line_buffer)
115 .await
116 .context("reading git blame stdout")?;
117 if bytes_read == 0 {
118 break;
119 }
120
121 let line = line_buffer.trim_end_matches(&['\r', '\n'][..]);
122 parser.push_line(line)?;
123 lines_read += 1;
124
125 if lines_read % BLAME_PARSE_YIELD_INTERVAL == 0 {
126 smol::future::yield_now().await;
127 }
128 }
129
130 Ok(parser.entries)
131 };
132
133 let read_stderr = async move {
134 let mut stderr_output = String::new();
135 BufReader::new(stderr)
136 .read_to_string(&mut stderr_output)
137 .await
138 .context("reading git blame stderr")?;
139 Result::<String>::Ok(stderr_output)
140 };
141
142 let wait_for_status = async {
143 child
144 .status()
145 .await
146 .context("waiting for git blame process")
147 };
148
149 let ((), entries, stderr, status) =
150 try_join!(write_stdin, read_stdout, read_stderr, wait_for_status)?;
151
152 if !status.success() {
153 let trimmed = stderr.trim();
154 if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
155 return Ok(Vec::new());
156 }
157 anyhow::bail!("git blame process failed: {stderr}");
158 }
159
160 Ok(entries)
161}
162
163#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
164pub struct BlameEntry {
165 pub sha: Oid,
166
167 pub range: Range<u32>,
168
169 pub original_line_number: u32,
170
171 pub author: Option<String>,
172 pub author_mail: Option<String>,
173 pub author_time: Option<i64>,
174 pub author_tz: Option<String>,
175
176 pub committer_name: Option<String>,
177 pub committer_email: Option<String>,
178 pub committer_time: Option<i64>,
179 pub committer_tz: Option<String>,
180
181 pub summary: Option<String>,
182
183 pub previous: Option<String>,
184 pub filename: String,
185}
186
187impl BlameEntry {
188 // Returns a BlameEntry by parsing the first line of a `git blame --incremental`
189 // entry. The line MUST have this format:
190 //
191 // <40-byte-hex-sha1> <sourceline> <resultline> <num-lines>
192 fn new_from_blame_line(line: &str) -> Result<BlameEntry> {
193 let mut parts = line.split_whitespace();
194
195 let sha = parts
196 .next()
197 .and_then(|line| line.parse::<Oid>().ok())
198 .context("parsing sha")?;
199
200 let original_line_number = parts
201 .next()
202 .and_then(|line| line.parse::<u32>().ok())
203 .context("parsing original line number")?;
204 let final_line_number = parts
205 .next()
206 .and_then(|line| line.parse::<u32>().ok())
207 .context("parsing final line number")?;
208
209 let line_count = parts
210 .next()
211 .and_then(|line| line.parse::<u32>().ok())
212 .context("parsing line count")?;
213
214 let start_line = final_line_number.saturating_sub(1);
215 let end_line = start_line + line_count;
216 let range = start_line..end_line;
217
218 Ok(Self {
219 sha,
220 range,
221 original_line_number,
222 author: None,
223 author_mail: None,
224 author_time: None,
225 author_tz: None,
226 committer_name: None,
227 committer_email: None,
228 committer_time: None,
229 committer_tz: None,
230 summary: None,
231 previous: None,
232 filename: String::new(),
233 })
234 }
235
236 pub fn author_offset_date_time(&self) -> Result<time::OffsetDateTime> {
237 if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) {
238 let format = format_description!("[offset_hour][offset_minute]");
239 let offset = UtcOffset::parse(author_tz, &format)?;
240 let date_time_utc = OffsetDateTime::from_unix_timestamp(author_time)?;
241
242 Ok(date_time_utc.to_offset(offset))
243 } else {
244 // Directly return current time in UTC if there's no committer time or timezone
245 Ok(time::OffsetDateTime::now_utc())
246 }
247 }
248}
249
250// GitBlameParser parses the output of `git blame --incremental`, which returns
251// all the blame-entries for a given path incrementally, as it finds them.
252//
253// Each entry *always* starts with:
254//
255// <40-byte-hex-sha1> <sourceline> <resultline> <num-lines>
256//
257// Each entry *always* ends with:
258//
259// filename <whitespace-quoted-filename-goes-here>
260//
261// Line numbers are 1-indexed.
262//
263// A `git blame --incremental` entry looks like this:
264//
265// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1
266// author Joe Schmoe
267// author-mail <joe.schmoe@example.com>
268// author-time 1709741400
269// author-tz +0100
270// committer Joe Schmoe
271// committer-mail <joe.schmoe@example.com>
272// committer-time 1709741400
273// committer-tz +0100
274// summary Joe's cool commit
275// previous 486c2409237a2c627230589e567024a96751d475 index.js
276// filename index.js
277//
278// If the entry has the same SHA as an entry that was already printed then no
279// signature information is printed:
280//
281// 6ad46b5257ba16d12c5ca9f0d4900320959df7f4 3 4 1
282// previous 486c2409237a2c627230589e567024a96751d475 index.js
283// filename index.js
284//
285// More about `--incremental` output: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-blame.html
286struct GitBlameParser {
287 entries: Vec<BlameEntry>,
288 index: HashMap<Oid, usize>,
289 current_entry: Option<BlameEntry>,
290}
291
292impl GitBlameParser {
293 fn new() -> Self {
294 Self {
295 entries: Vec::new(),
296 index: HashMap::default(),
297 current_entry: None,
298 }
299 }
300
301 fn push_line(&mut self, line: &str) -> Result<()> {
302 let mut done = false;
303
304 match &mut self.current_entry {
305 None => {
306 let mut new_entry = BlameEntry::new_from_blame_line(line)?;
307
308 if let Some(existing_entry) = self
309 .index
310 .get(&new_entry.sha)
311 .and_then(|slot| self.entries.get(*slot))
312 {
313 new_entry.author.clone_from(&existing_entry.author);
314 new_entry
315 .author_mail
316 .clone_from(&existing_entry.author_mail);
317 new_entry.author_time = existing_entry.author_time;
318 new_entry.author_tz.clone_from(&existing_entry.author_tz);
319 new_entry
320 .committer_name
321 .clone_from(&existing_entry.committer_name);
322 new_entry
323 .committer_email
324 .clone_from(&existing_entry.committer_email);
325 new_entry.committer_time = existing_entry.committer_time;
326 new_entry
327 .committer_tz
328 .clone_from(&existing_entry.committer_tz);
329 new_entry.summary.clone_from(&existing_entry.summary);
330 }
331
332 self.current_entry.replace(new_entry);
333 }
334 Some(entry) => {
335 let Some((key, value)) = line.split_once(' ') else {
336 return Ok(());
337 };
338 let is_committed = !entry.sha.is_zero();
339 match key {
340 "filename" => {
341 entry.filename = value.into();
342 done = true;
343 }
344 "previous" => entry.previous = Some(value.into()),
345
346 "summary" if is_committed => entry.summary = Some(value.into()),
347 "author" if is_committed => entry.author = Some(value.into()),
348 "author-mail" if is_committed => entry.author_mail = Some(value.into()),
349 "author-time" if is_committed => {
350 entry.author_time = Some(value.parse::<i64>()?)
351 }
352 "author-tz" if is_committed => entry.author_tz = Some(value.into()),
353
354 "committer" if is_committed => entry.committer_name = Some(value.into()),
355 "committer-mail" if is_committed => entry.committer_email = Some(value.into()),
356 "committer-time" if is_committed => {
357 entry.committer_time = Some(value.parse::<i64>()?)
358 }
359 "committer-tz" if is_committed => entry.committer_tz = Some(value.into()),
360 _ => {}
361 }
362 }
363 };
364
365 if done {
366 self.push_current_entry();
367 }
368
369 Ok(())
370 }
371
372 fn push_current_entry(&mut self) {
373 let Some(entry) = self.current_entry.take() else {
374 return;
375 };
376
377 self.index.insert(entry.sha, self.entries.len());
378
379 // We only want annotations that have a commit.
380 if !entry.sha.is_zero() {
381 self.entries.push(entry);
382 }
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use std::path::PathBuf;
389
390 use crate::blame::GitBlameParser;
391
392 use super::BlameEntry;
393
394 fn parse_git_blame(output: &str) -> anyhow::Result<Vec<BlameEntry>> {
395 let mut parser = GitBlameParser::new();
396
397 for line in output.lines() {
398 parser.push_line(line)?;
399 }
400
401 Ok(parser.entries)
402 }
403
404 fn read_test_data(filename: &str) -> String {
405 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
406 path.push("test_data");
407 path.push(filename);
408
409 std::fs::read_to_string(&path)
410 .unwrap_or_else(|_| panic!("Could not read test data at {:?}. Is it generated?", path))
411 }
412
413 fn assert_eq_golden(entries: &Vec<BlameEntry>, golden_filename: &str) {
414 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
415 path.push("test_data");
416 path.push("golden");
417 path.push(format!("{}.json", golden_filename));
418
419 let mut have_json =
420 serde_json::to_string_pretty(&entries).expect("could not serialize entries to JSON");
421 // We always want to save with a trailing newline.
422 have_json.push('\n');
423
424 let update = std::env::var("UPDATE_GOLDEN")
425 .map(|val| val.eq_ignore_ascii_case("true"))
426 .unwrap_or(false);
427
428 if update {
429 std::fs::create_dir_all(path.parent().unwrap())
430 .expect("could not create golden test data directory");
431 std::fs::write(&path, have_json).expect("could not write out golden data");
432 } else {
433 let want_json =
434 std::fs::read_to_string(&path).unwrap_or_else(|_| {
435 panic!("could not read golden test data file at {:?}. Did you run the test with UPDATE_GOLDEN=true before?", path);
436 }).replace("\r\n", "\n");
437
438 pretty_assertions::assert_eq!(have_json, want_json, "wrong blame entries");
439 }
440 }
441
442 #[test]
443 fn test_parse_git_blame_not_committed() {
444 let output = read_test_data("blame_incremental_not_committed");
445 let entries = parse_git_blame(&output).unwrap();
446 assert_eq_golden(&entries, "blame_incremental_not_committed");
447 }
448
449 #[test]
450 fn test_parse_git_blame_simple() {
451 let output = read_test_data("blame_incremental_simple");
452 let entries = parse_git_blame(&output).unwrap();
453 assert_eq_golden(&entries, "blame_incremental_simple");
454 }
455
456 #[test]
457 fn test_parse_git_blame_complex() {
458 let output = read_test_data("blame_incremental_complex");
459 let entries = parse_git_blame(&output).unwrap();
460 assert_eq_golden(&entries, "blame_incremental_complex");
461 }
462}
463