Skip to repository content372 lines · 12.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T08:03:23.052Z 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
before.rs
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