keyhog_sources/git/
diff.rs1use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8pub struct GitDiffSource {
23 repo_path: PathBuf,
24 base_ref: String,
25 head_ref: Option<String>,
26}
27
28impl GitDiffSource {
29 pub fn new(repo_path: PathBuf, base_ref: impl Into<String>) -> Self {
42 Self {
43 repo_path,
44 base_ref: base_ref.into(),
45 head_ref: None,
46 }
47 }
48
49 pub fn with_head_ref(mut self, head_ref: impl Into<String>) -> Self {
62 self.head_ref = Some(head_ref.into());
63 self
64 }
65}
66
67impl Source for GitDiffSource {
68 fn name(&self) -> &str {
69 "git-diff"
70 }
71
72 fn chunks(&self) -> Box<dyn Iterator<Item = Result<Chunk, SourceError>> + '_> {
73 match stream_added_lines(&self.repo_path, &self.base_ref, self.head_ref.as_deref()) {
74 Ok(iter) => Box::new(iter),
75 Err(e) => Box::new(std::iter::once(Err(e))),
76 }
77 }
78 fn as_any(&self) -> &dyn std::any::Any {
79 self
80 }
81}
82
83fn stream_added_lines(
85 repo_path: &Path,
86 base_ref: &str,
87 head_ref: Option<&str>,
88) -> Result<impl Iterator<Item = Result<Chunk, SourceError>>, SourceError> {
89 let base_ref = super::validate_ref_name(base_ref)?;
90 let head_ref = head_ref.map(super::validate_ref_name).transpose()?;
91 let repo_root = super::canonical_repo_root(repo_path)?;
92 let repo_arg = super::validate_repo_path(&repo_root)?;
93
94 super::verify_ref(&repo_arg, &base_ref)?;
96 let base_commit = super::get_commit_hash(&repo_arg, &base_ref)?;
97 let head_commit = if let Some(head_ref) = head_ref.as_deref() {
98 super::verify_ref(&repo_arg, head_ref)?;
99 Some(super::get_commit_hash(&repo_arg, head_ref)?)
100 } else {
101 None
102 };
103
104 let mut command = Command::new(super::git_bin()?);
106 command.args(["-C", &repo_arg, "diff", "-U0", "--end-of-options"]);
107 command.arg(&base_commit);
108 if let Some(head_commit) = head_commit.as_deref() {
109 command.arg(head_commit);
110 }
111
112 command.stdout(std::process::Stdio::piped());
113 command.stderr(std::process::Stdio::piped());
114
115 let mut child = command.spawn().map_err(SourceError::Io)?;
116 let stdout = child
117 .stdout
118 .take()
119 .ok_or_else(|| SourceError::Io(std::io::Error::other("missing stdout")))?;
120 let mut reader = std::io::BufReader::new(stdout);
121
122 let metadata_commit = head_commit.unwrap_or_else(|| base_commit.clone());
124 let author = super::get_commit_author(&repo_arg, &metadata_commit)?;
125 let date = super::get_commit_date(&repo_arg, &metadata_commit)?;
126
127 let mut current_path: Option<String> = None;
128 let mut current_content = String::new();
129 let mut in_hunk = false;
130 let mut done = false;
131 let mut line_buf: Vec<u8> = Vec::new();
132 let mut current_base_line: usize = 0;
141
142 Ok(std::iter::from_fn(move || {
143 if done {
144 return None;
145 }
146
147 loop {
148 let line = match super::read_capped_line(
149 &mut reader,
150 &mut line_buf,
151 super::MAX_GIT_LINE_BYTES,
152 ) {
153 Ok(n) if n > 0 => {
154 let l = String::from_utf8_lossy(&line_buf);
155 l.trim_end_matches('\n').trim_end_matches('\r').to_string()
156 }
157 Err(e) => {
158 done = true;
159 return Some(Err(SourceError::Io(e)));
160 }
161 Ok(_) => {
162 done = true;
163 if let Some(ref path) = current_path {
164 if !current_content.trim().is_empty() {
165 return Some(Ok(Chunk {
166 data: current_content.trim().to_string().into(),
167 metadata: ChunkMetadata {
168 base_offset: 0,
169 base_line: current_base_line,
170 source_type: "git-diff".into(),
171 path: Some(path.clone()),
172 commit: Some(metadata_commit.clone()),
173 author: Some(author.clone()),
174 date: Some(date.clone()),
175 mtime_ns: None,
176 size_bytes: None,
177 },
178 }));
179 }
180 }
181 return None;
182 }
183 };
184
185 if line.starts_with("diff --git ") {
186 let prev_path = current_path.take();
187 let prev_content = std::mem::take(&mut current_content);
188 let prev_base_line = current_base_line;
189
190 in_hunk = false;
191 current_base_line = 0;
193
194 if let Some(path) = prev_path {
195 if !prev_content.trim().is_empty() {
196 return Some(Ok(Chunk {
197 data: prev_content.trim().to_string().into(),
198 metadata: ChunkMetadata {
199 base_offset: 0,
200 base_line: prev_base_line,
201 source_type: "git-diff".into(),
202 path: Some(path),
203 commit: Some(metadata_commit.clone()),
204 author: Some(author.clone()),
205 date: Some(date.clone()),
206 mtime_ns: None,
207 size_bytes: None,
208 },
209 }));
210 }
211 }
212 continue;
213 }
214
215 if line.starts_with("deleted file mode") {
216 current_path = None;
217 continue;
218 }
219
220 if line.starts_with("new file mode")
221 || line.starts_with("index ")
222 || line.starts_with("--- ")
223 {
224 continue;
225 }
226
227 if let Some(path_part) = line.strip_prefix("+++ b/") {
228 current_path = Some(path_part.trim().to_string());
229 continue;
230 }
231
232 if line.starts_with("@@") && line.contains("@@") {
233 let new_start = super::parse_hunk_new_start(&line).unwrap_or(1);
237 let prev_content = std::mem::take(&mut current_content);
238 let prev_base_line = current_base_line;
239 current_base_line = new_start.saturating_sub(1);
240 in_hunk = true;
241 if let Some(ref path) = current_path {
242 if !prev_content.trim().is_empty() {
243 return Some(Ok(Chunk {
244 data: prev_content.trim().to_string().into(),
245 metadata: ChunkMetadata {
246 base_offset: 0,
247 base_line: prev_base_line,
248 source_type: "git-diff".into(),
249 path: Some(path.clone()),
250 commit: Some(metadata_commit.clone()),
251 author: Some(author.clone()),
252 date: Some(date.clone()),
253 mtime_ns: None,
254 size_bytes: None,
255 },
256 }));
257 }
258 }
259 continue;
260 }
261
262 if in_hunk && line.starts_with('+') && !line.starts_with("+++") {
263 current_content.push_str(&line[1..]);
264 current_content.push('\n');
265 }
266
267 if current_content.len() > 10 * 1024 * 1024 {
268 if let Some(ref path) = current_path {
269 if !current_content.trim().is_empty() {
270 let flush_base_line = current_base_line;
271 current_base_line = current_base_line
276 .saturating_add(memchr::memchr_iter(b'\n', current_content.as_bytes()).count());
277 let chunk_content = current_content.trim().to_string();
278 current_content = String::new();
279 return Some(Ok(Chunk {
280 data: chunk_content.into(),
281 metadata: ChunkMetadata {
282 base_offset: 0,
283 base_line: flush_base_line,
284 source_type: "git-diff".into(),
285 path: Some(path.clone()),
286 commit: Some(metadata_commit.clone()),
287 author: Some(author.clone()),
288 date: Some(date.clone()),
289 mtime_ns: None,
290 size_bytes: None,
291 },
292 }));
293 }
294 }
295 }
296 }
297 }))
298}
299