Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T08:03:31.252Z 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

before.rs

372 lines · 12.7 KB · rust
1use crate::commit::get_messages;
2use crate::{GitRemote, Oid};
3use anyhow::{Context as _, Result, anyhow};
4use collections::{HashMap, HashSet};
5use futures::AsyncWriteExt;
6use gpui::SharedString;
7use serde::{Deserialize, Serialize};
8use std::process::Stdio;
9use std::{ops::Range, path::Path};
10use text::Rope;
11use time::OffsetDateTime;
12use time::UtcOffset;
13use time::macros::format_description;
14
15pub use git2 as libgit;
16
17#[derive(Debug, Clone, Default)]
18pub struct Blame {
19    pub entries: Vec<BlameEntry>,
20    pub messages: HashMap<Oid, String>,
21    pub remote_url: Option<String>,
22}
23
24#[derive(Clone, Debug, Default)]
25pub struct ParsedCommitMessage {
26    pub message: SharedString,
27    pub permalink: Option<url::Url>,
28    pub pull_request: Option<crate::hosting_provider::PullRequest>,
29    pub remote: Option<GitRemote>,
30}
31
32impl Blame {
33    pub async fn for_path(
34        git_binary: &Path,
35        working_directory: &Path,
36        path: &Path,
37        content: &Rope,
38        remote_url: Option<String>,
39    ) -> Result<Self> {
40        let output = run_git_blame(git_binary, working_directory, path, content).await?;
41        let mut entries = parse_git_blame(&output)?;
42        entries.sort_unstable_by(|a, b| a.range.start.cmp(&b.range.start));
43
44        let mut unique_shas = HashSet::default();
45
46        for entry in entries.iter_mut() {
47            unique_shas.insert(entry.sha);
48        }
49
50        let shas = unique_shas.into_iter().collect::<Vec<_>>();
51        let messages = get_messages(working_directory, &shas)
52            .await
53            .context("failed to get commit messages")?;
54
55        Ok(Self {
56            entries,
57            messages,
58            remote_url,
59        })
60    }
61}
62
63const GIT_BLAME_NO_COMMIT_ERROR: &str = "fatal: no such ref: HEAD";
64const GIT_BLAME_NO_PATH: &str = "fatal: no such path";
65
66async fn run_git_blame(
67    git_binary: &Path,
68    working_directory: &Path,
69    path: &Path,
70    contents: &Rope,
71) -> Result<String> {
72    let mut child = util::command::new_smol_command(git_binary)
73        .current_dir(working_directory)
74        .arg("blame")
75        .arg("--incremental")
76        .arg("--contents")
77        .arg("-")
78        .arg(path.as_os_str())
79        .stdin(Stdio::piped())
80        .stdout(Stdio::piped())
81        .stderr(Stdio::piped())
82        .spawn()
83        .context("starting git blame process")?;
84
85    let stdin = child
86        .stdin
87        .as_mut()
88        .context("failed to get pipe to stdin of git blame command")?;
89
90    for chunk in contents.chunks() {
91        stdin.write_all(chunk.as_bytes()).await?;
92    }
93    stdin.flush().await?;
94
95    let output = child.output().await.context("reading git blame output")?;
96
97    if !output.status.success() {
98        let stderr = String::from_utf8_lossy(&output.stderr);
99        let trimmed = stderr.trim();
100        if trimmed == GIT_BLAME_NO_COMMIT_ERROR || trimmed.contains(GIT_BLAME_NO_PATH) {
101            return Ok(String::new());
102        }
103        anyhow::bail!("git blame process failed: {stderr}");
104    }
105
106    Ok(String::from_utf8(output.stdout)?)
107}
108
109#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
110pub struct BlameEntry {
111    pub sha: Oid,
112
113    pub range: Range<u32>,
114
115    pub original_line_number: u32,
116
117    pub author: Option<String>,
118    pub author_mail: Option<String>,
119    pub author_time: Option<i64>,
120    pub author_tz: Option<String>,
121
122    pub committer_name: Option<String>,
123    pub committer_email: Option<String>,
124    pub committer_time: Option<i64>,
125    pub committer_tz: Option<String>,
126
127    pub summary: Option<String>,
128
129    pub previous: Option<String>,
130    pub filename: String,
131}
132
133impl BlameEntry {
134    // Returns a BlameEntry by parsing the first line of a `git blame --incremental`
135    // entry. The line MUST have this format:
136    //
137    //     <40-byte-hex-sha1> <sourceline> <resultline> <num-lines>
138    fn new_from_blame_line(line: &str) -> Result<BlameEntry> {
139        let mut parts = line.split_whitespace();
140
141        let sha = parts
142            .next()
143            .and_then(|line| line.parse::<Oid>().ok())
144            .with_context(|| format!("parsing sha from {line}"))?;
145
146        let original_line_number = parts
147            .next()
148            .and_then(|line| line.parse::<u32>().ok())
149            .with_context(|| format!("parsing original line number from {line}"))?;
150        let final_line_number = parts
151            .next()
152            .and_then(|line| line.parse::<u32>().ok())
153            .with_context(|| format!("parsing final line number from {line}"))?;
154
155        let line_count = parts
156            .next()
157            .and_then(|line| line.parse::<u32>().ok())
158            .with_context(|| format!("parsing line count from {line}"))?;
159
160        let start_line = final_line_number.saturating_sub(1);
161        let end_line = start_line + line_count;
162        let range = start_line..end_line;
163
164        Ok(Self {
165            sha,
166            range,
167            original_line_number,
168            ..Default::default()
169        })
170    }
171
172    pub fn author_offset_date_time(&self) -> Result<time::OffsetDateTime> {
173        if let (Some(author_time), Some(author_tz)) = (self.author_time, &self.author_tz) {
174            let format = format_description!("[offset_hour][offset_minute]");
175            let offset = UtcOffset::parse(author_tz, &format)?;
176            let date_time_utc = OffsetDateTime::from_unix_timestamp(author_time)?;
177
178            Ok(date_time_utc.to_offset(offset))
179        } else {
180            // Directly return current time in UTC if there's no committer time or timezone
181            Ok(time::OffsetDateTime::now_utc())
182        }
183    }
184}
185
186// parse_git_blame parses the output of `git blame --incremental`, which returns
187// all the blame-entries for a given path incrementally, as it finds them.
188//
189// Each entry *always* starts with:
190//
191//     <40-byte-hex-sha1> <sourceline> <resultline> <num-lines>
192//
193// Each entry *always* ends with:
194//
195//     filename <whitespace-quoted-filename-goes-here>
196//
197// Line numbers are 1-indexed.
198//
199// A `git blame --incremental` entry looks like this:
200//
201//    6ad46b5257ba16d12c5ca9f0d4900320959df7f4 2 2 1
202//    author Joe Schmoe
203//    author-mail <joe.schmoe@example.com>
204//    author-time 1709741400
205//    author-tz +0100
206//    committer Joe Schmoe
207//    committer-mail <joe.schmoe@example.com>
208//    committer-time 1709741400
209//    committer-tz +0100
210//    summary Joe's cool commit
211//    previous 486c2409237a2c627230589e567024a96751d475 index.js
212//    filename index.js
213//
214// If the entry has the same SHA as an entry that was already printed then no
215// signature information is printed:
216//
217//    6ad46b5257ba16d12c5ca9f0d4900320959df7f4 3 4 1
218//    previous 486c2409237a2c627230589e567024a96751d475 index.js
219//    filename index.js
220//
221// More about `--incremental` output: https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-blame.html
222fn parse_git_blame(output: &str) -> Result<Vec<BlameEntry>> {
223    let mut entries: Vec<BlameEntry> = Vec::new();
224    let mut index: HashMap<Oid, usize> = HashMap::default();
225
226    let mut current_entry: Option<BlameEntry> = None;
227
228    for line in output.lines() {
229        let mut done = false;
230
231        match &mut current_entry {
232            None => {
233                let mut new_entry = BlameEntry::new_from_blame_line(line)?;
234
235                if let Some(existing_entry) = index
236                    .get(&new_entry.sha)
237                    .and_then(|slot| entries.get(*slot))
238                {
239                    new_entry.author.clone_from(&existing_entry.author);
240                    new_entry
241                        .author_mail
242                        .clone_from(&existing_entry.author_mail);
243                    new_entry.author_time = existing_entry.author_time;
244                    new_entry.author_tz.clone_from(&existing_entry.author_tz);
245                    new_entry
246                        .committer_name
247                        .clone_from(&existing_entry.committer_name);
248                    new_entry
249                        .committer_email
250                        .clone_from(&existing_entry.committer_email);
251                    new_entry.committer_time = existing_entry.committer_time;
252                    new_entry
253                        .committer_tz
254                        .clone_from(&existing_entry.committer_tz);
255                    new_entry.summary.clone_from(&existing_entry.summary);
256                }
257
258                current_entry.replace(new_entry);
259            }
260            Some(entry) => {
261                let Some((key, value)) = line.split_once(' ') else {
262                    continue;
263                };
264                let is_committed = !entry.sha.is_zero();
265                match key {
266                    "filename" => {
267                        entry.filename = value.into();
268                        done = true;
269                    }
270                    "previous" => entry.previous = Some(value.into()),
271
272                    "summary" if is_committed => entry.summary = Some(value.into()),
273                    "author" if is_committed => entry.author = Some(value.into()),
274                    "author-mail" if is_committed => entry.author_mail = Some(value.into()),
275                    "author-time" if is_committed => {
276                        entry.author_time = Some(value.parse::<i64>()?)
277                    }
278                    "author-tz" if is_committed => entry.author_tz = Some(value.into()),
279
280                    "committer" if is_committed => entry.committer_name = Some(value.into()),
281                    "committer-mail" if is_committed => entry.committer_email = Some(value.into()),
282                    "committer-time" if is_committed => {
283                        entry.committer_time = Some(value.parse::<i64>()?)
284                    }
285                    "committer-tz" if is_committed => entry.committer_tz = Some(value.into()),
286                    _ => {}
287                }
288            }
289        };
290
291        if done {
292            if let Some(entry) = current_entry.take() {
293                index.insert(entry.sha, entries.len());
294
295                // We only want annotations that have a commit.
296                if !entry.sha.is_zero() {
297                    entries.push(entry);
298                }
299            }
300        }
301    }
302
303    Ok(entries)
304}
305
306#[cfg(test)]
307mod tests {
308    use std::path::PathBuf;
309
310    use super::BlameEntry;
311    use super::parse_git_blame;
312
313    fn read_test_data(filename: &str) -> String {
314        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
315        path.push("test_data");
316        path.push(filename);
317
318        std::fs::read_to_string(&path)
319            .unwrap_or_else(|_| panic!("Could not read test data at {:?}. Is it generated?", path))
320    }
321
322    fn assert_eq_golden(entries: &Vec<BlameEntry>, golden_filename: &str) {
323        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
324        path.push("test_data");
325        path.push("golden");
326        path.push(format!("{}.json", golden_filename));
327
328        let mut have_json =
329            serde_json::to_string_pretty(&entries).expect("could not serialize entries to JSON");
330        // We always want to save with a trailing newline.
331        have_json.push('\n');
332
333        let update = std::env::var("UPDATE_GOLDEN")
334            .map(|val| val.eq_ignore_ascii_case("true"))
335            .unwrap_or(false);
336
337        if update {
338            std::fs::create_dir_all(path.parent().unwrap())
339                .expect("could not create golden test data directory");
340            std::fs::write(&path, have_json).expect("could not write out golden data");
341        } else {
342            let want_json =
343                std::fs::read_to_string(&path).unwrap_or_else(|_| {
344                    panic!("could not read golden test data file at {:?}. Did you run the test with UPDATE_GOLDEN=true before?", path);
345                }).replace("\r\n", "\n");
346
347            pretty_assertions::assert_eq!(have_json, want_json, "wrong blame entries");
348        }
349    }
350
351    #[test]
352    fn test_parse_git_blame_not_committed() {
353        let output = read_test_data("blame_incremental_not_committed");
354        let entries = parse_git_blame(&output).unwrap();
355        assert_eq_golden(&entries, "blame_incremental_not_committed");
356    }
357
358    #[test]
359    fn test_parse_git_blame_simple() {
360        let output = read_test_data("blame_incremental_simple");
361        let entries = parse_git_blame(&output).unwrap();
362        assert_eq_golden(&entries, "blame_incremental_simple");
363    }
364
365    #[test]
366    fn test_parse_git_blame_complex() {
367        let output = read_test_data("blame_incremental_complex");
368        let entries = parse_git_blame(&output).unwrap();
369        assert_eq_golden(&entries, "blame_incremental_complex");
370    }
371}
372
Served at tenant.openagents/omega Member data and write actions are omitted.