Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:37:31.548Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

blame.rs

463 lines · 15.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.