Skip to main content

keyhog_sources/git/
diff.rs

1//! Git diff source: scans only added/modified lines from `git diff`, ideal for
2//! CI/CD pre-commit hooks that should only flag new secrets.
3
4use keyhog_core::{Chunk, ChunkMetadata, Source, SourceError};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8/// Scans only the ADDED lines between two git refs.
9/// Uses `git diff` unified diff output and extracts lines starting with '+'.
10/// Useful for CI/CD pre-commit hooks and PR checks.
11///
12/// # Examples
13///
14/// ```rust
15/// use keyhog_core::Source;
16/// use keyhog_sources::GitDiffSource;
17/// use std::path::PathBuf;
18///
19/// let source = GitDiffSource::new(PathBuf::from("."), "main").with_head_ref("HEAD");
20/// assert_eq!(source.name(), "git-diff");
21/// ```
22pub struct GitDiffSource {
23    repo_path: PathBuf,
24    base_ref: String,
25    head_ref: Option<String>,
26}
27
28impl GitDiffSource {
29    /// Create a new diff source comparing `base_ref` to HEAD.
30    ///
31    /// # Examples
32    ///
33    /// ```rust
34    /// use keyhog_core::Source;
35    /// use keyhog_sources::GitDiffSource;
36    /// use std::path::PathBuf;
37    ///
38    /// let source = GitDiffSource::new(PathBuf::from("."), "origin/main");
39    /// assert_eq!(source.name(), "git-diff");
40    /// ```
41    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    /// Set a specific head ref to compare against (defaults to HEAD).
50    ///
51    /// # Examples
52    ///
53    /// ```rust
54    /// use keyhog_core::Source;
55    /// use keyhog_sources::GitDiffSource;
56    /// use std::path::PathBuf;
57    ///
58    /// let source = GitDiffSource::new(PathBuf::from("."), "main").with_head_ref("feature");
59    /// assert_eq!(source.name(), "git-diff");
60    /// ```
61    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
83/// Stream only ADDED lines from git diff output.
84fn 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    // Verify the refs exist first
95    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    // Run git diff to get unified diff output
105    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    // Get commit info for metadata
123    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    // New-file line BEFORE the current hunk's first added line (i.e. the
133    // hunk header's `+new_start - 1`). The scanner counts a match's line
134    // within the chunk text from 1; adding this base yields the absolute
135    // new-file line. With `-U0` a hunk's added lines are the contiguous
136    // run `new_start, new_start+1, …`, so one base per hunk is exact.
137    // Each hunk is emitted as its own chunk so its base applies cleanly;
138    // without this every diff finding reported line 1 (the chunk-local
139    // line of the concatenated added-line blob).
140    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                // New file: its first `@@` will set the base for its hunks.
192                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                // Start of a new hunk: flush the previous hunk as its own
234                // chunk (so its base line applies cleanly), then adopt this
235                // hunk's new-file start as the base for the lines that follow.
236                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                        // Mid-hunk flush of a single >10 MiB hunk: the lines
272                        // that follow continue the SAME hunk, so advance the
273                        // base by the lines we are emitting now to keep their
274                        // attribution correct after the buffer resets.
275                        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