Skip to main content

anodizer_core/git/commits/
query.rs

1use super::*;
2use anyhow::Result;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone)]
6pub struct Commit {
7    pub hash: String,
8    pub short_hash: String,
9    pub message: String,
10    pub author_name: String,
11    pub author_email: String,
12    /// Full commit message body (everything after the subject line).
13    /// Contains trailers like `Co-Authored-By:`.
14    pub body: String,
15}
16
17/// Parse git log output (formatted as `%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e`)
18/// into a vec of [`Commit`]s.
19///
20/// Uses ASCII record separator (0x1e) between commits and unit separator (0x1f)
21/// between fields, so multi-line body text doesn't break parsing.
22///
23/// The single record decoder for this wire format: every changelog path
24/// (`parse_commit_output_with_files` here, and the changelog stage's git
25/// fetch) decodes through this function so the body / author fields can never
26/// drift between call sites.
27pub fn parse_commit_output(output: &str) -> Vec<Commit> {
28    if output.is_empty() {
29        return vec![];
30    }
31    output
32        .split('\x1e')
33        .filter(|record| !record.trim().is_empty())
34        .filter_map(|record| {
35            let fields: Vec<&str> = record.split('\x1f').collect();
36            if fields.len() >= 5 {
37                Some(Commit {
38                    hash: fields[0].trim().to_string(),
39                    short_hash: fields[1].to_string(),
40                    message: fields[2].to_string(),
41                    author_name: fields[3].to_string(),
42                    author_email: fields[4].to_string(),
43                    body: fields.get(5).unwrap_or(&"").trim().to_string(),
44                })
45            } else {
46                None
47            }
48        })
49        .collect()
50}
51
52pub(super) fn cwd_or_dot() -> PathBuf {
53    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
54}
55
56/// Get commits between two refs, optionally filtered to a path.
57pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
58    get_commits_between_in(&cwd_or_dot(), from, to, path_filter)
59}
60
61/// Path-taking sibling of [`get_commits_between`].
62pub fn get_commits_between_in(
63    cwd: &Path,
64    from: &str,
65    to: &str,
66    path_filter: Option<&str>,
67) -> Result<Vec<Commit>> {
68    get_commits_between_paths_in(
69        cwd,
70        from,
71        to,
72        &path_filter
73            .into_iter()
74            .map(String::from)
75            .collect::<Vec<_>>(),
76    )
77}
78
79/// Get commits between two refs, filtered to multiple paths (git log -- path1 path2 ...).
80pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
81    get_commits_between_paths_in(&cwd_or_dot(), from, to, paths)
82}
83
84/// Path-taking sibling of [`get_commits_between_paths`].
85pub fn get_commits_between_paths_in(
86    cwd: &Path,
87    from: &str,
88    to: &str,
89    paths: &[String],
90) -> Result<Vec<Commit>> {
91    let range = format!("{}..{}", from, to);
92    let mut args = vec![
93        "-c".to_string(),
94        "log.showSignature=false".to_string(),
95        "log".to_string(),
96        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
97        range,
98    ];
99    if !paths.is_empty() {
100        args.push("--".to_string());
101        for p in paths {
102            args.push(p.clone());
103        }
104    }
105    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
106    let output = git_output_in(cwd, &arg_refs)?;
107    Ok(parse_commit_output(&output))
108}
109
110/// Get all commits reachable from HEAD, optionally filtered to a path.
111/// Used for initial releases where there is no previous tag.
112pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
113    get_all_commits_in(&cwd_or_dot(), path_filter)
114}
115
116/// Path-taking sibling of [`get_all_commits`].
117pub fn get_all_commits_in(cwd: &Path, path_filter: Option<&str>) -> Result<Vec<Commit>> {
118    get_all_commits_paths_in(
119        cwd,
120        &path_filter
121            .into_iter()
122            .map(String::from)
123            .collect::<Vec<_>>(),
124    )
125}
126
127/// Get all commits reachable from HEAD, filtered to multiple paths.
128pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
129    get_all_commits_paths_in(&cwd_or_dot(), paths)
130}
131
132/// Path-taking sibling of [`get_all_commits_paths`].
133pub fn get_all_commits_paths_in(cwd: &Path, paths: &[String]) -> Result<Vec<Commit>> {
134    let mut args = vec![
135        "-c".to_string(),
136        "log.showSignature=false".to_string(),
137        "log".to_string(),
138        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
139        "HEAD".to_string(),
140    ];
141    if !paths.is_empty() {
142        args.push("--".to_string());
143        for p in paths {
144            args.push(p.clone());
145        }
146    }
147    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
148    let output = git_output_in(cwd, &arg_refs)?;
149    Ok(parse_commit_output(&output))
150}
151
152/// A commit paired with the workspace-relative paths it touched.
153///
154/// Produced by the `--name-only` fetch variants so the changelog renderers can
155/// apply a precise `changelog.paths` glob intersect over the git-pathspec
156/// scope (see [`crate::changelog_scope`]).
157#[derive(Debug, Clone)]
158pub struct CommitWithFiles {
159    /// The commit metadata.
160    pub commit: Commit,
161    /// Paths this commit touched, relative to the repo root.
162    pub files: Vec<String>,
163}
164
165/// Parse `git log --name-only` output (metadata formatted as
166/// `%H%x1f...%b%x1e`, followed by one touched-file path per line) into
167/// [`CommitWithFiles`].
168///
169/// git emits each commit as `<metadata>\x1e\n<file>\n<file>\n\n` (the touched
170/// files follow the `%x1e`-terminated metadata, then a blank line). Splitting
171/// on `\x1e` yields `[metadata_0, "\n<files_0>\n\n<metadata_1>", ...]`: the
172/// file block trailing each record up to the next metadata belongs to THAT
173/// record's commit.
174///
175/// The metadata record is multi-line because `%b` (the commit body) carries
176/// newlines, so the record runs from the first `\x1f`-bearing line through the
177/// end of the segment — NOT just the first matching line. Truncating to one
178/// line would drop body trailers (e.g. `Co-Authored-By:`) for every commit
179/// after the first, diverging from the full-body parse the changelog stage's
180/// `parse_git_log_records` performs.
181pub fn parse_commit_output_with_files(output: &str) -> Vec<CommitWithFiles> {
182    if output.is_empty() {
183        return vec![];
184    }
185    let segments: Vec<&str> = output.split('\x1e').collect();
186    let mut out: Vec<CommitWithFiles> = Vec::new();
187    // segments[i] for i>0 begins with the file block of commit i-1 followed by
188    // the metadata of commit i. The first segment is pure metadata (commit 0);
189    // the last segment is the file block of the final commit (no trailing
190    // metadata). Walk pairwise: metadata from this segment, files from the
191    // NEXT segment's leading lines (before its own metadata's first field).
192    for (idx, seg) in segments.iter().enumerate() {
193        // The metadata of commit `idx` is the part of `seg` AFTER the leading
194        // file block (file block present only for idx>0). For idx==0 the whole
195        // segment is metadata. For idx>0 the metadata record begins at the
196        // first `\x1f`-bearing line and continues to the segment end (a
197        // multi-line `%b` body keeps emitting newline-separated lines after the
198        // unit-separator fields), so the remainder is kept verbatim — joined
199        // from that line onward — rather than just the first matching line.
200        let metadata = if idx == 0 {
201            seg.trim_start_matches(['\n', '\r']).to_string()
202        } else {
203            let lines: Vec<&str> = seg.split('\n').collect();
204            match lines.iter().position(|line| line.contains('\x1f')) {
205                Some(start) => lines[start..].join("\n"),
206                None => String::new(),
207            }
208        };
209        if metadata.trim().is_empty() {
210            continue;
211        }
212        let commits = parse_commit_output(&metadata);
213        let Some(commit) = commits.into_iter().next() else {
214            continue;
215        };
216        // Files for THIS commit are the leading lines of the NEXT segment,
217        // before that segment's own metadata line.
218        let files = match segments.get(idx + 1) {
219            Some(next) => next
220                .split('\n')
221                .map(str::trim)
222                .take_while(|line| !line.contains('\x1f'))
223                .filter(|line| !line.is_empty())
224                .map(str::to_string)
225                .collect(),
226            None => Vec::new(),
227        };
228        out.push(CommitWithFiles { commit, files });
229    }
230    out
231}
232
233/// `--name-only` sibling of [`get_commits_between_paths_in`]: each commit is
234/// paired with the repo-relative paths it touched, for a precise
235/// `changelog.paths` glob intersect over the git-pathspec scope.
236pub fn get_commits_between_paths_with_files_in(
237    cwd: &Path,
238    from: &str,
239    to: &str,
240    paths: &[String],
241) -> Result<Vec<CommitWithFiles>> {
242    let range = format!("{}..{}", from, to);
243    let mut args = vec![
244        "-c".to_string(),
245        "log.showSignature=false".to_string(),
246        "log".to_string(),
247        "--name-only".to_string(),
248        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
249        range,
250    ];
251    if !paths.is_empty() {
252        args.push("--".to_string());
253        for p in paths {
254            args.push(p.clone());
255        }
256    }
257    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
258    let output = git_output_in(cwd, &arg_refs)?;
259    Ok(parse_commit_output_with_files(&output))
260}
261
262/// `--name-only` sibling of [`get_all_commits_paths_in`].
263pub fn get_all_commits_paths_with_files_in(
264    cwd: &Path,
265    paths: &[String],
266) -> Result<Vec<CommitWithFiles>> {
267    let mut args = vec![
268        "-c".to_string(),
269        "log.showSignature=false".to_string(),
270        "log".to_string(),
271        "--name-only".to_string(),
272        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
273        "HEAD".to_string(),
274    ];
275    if !paths.is_empty() {
276        args.push("--".to_string());
277        for p in paths {
278            args.push(p.clone());
279        }
280    }
281    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
282    let output = git_output_in(cwd, &arg_refs)?;
283    Ok(parse_commit_output_with_files(&output))
284}
285
286/// All commits reachable from an arbitrary `rev` (not just `HEAD`), filtered to
287/// `paths`. Used by the changelog stage to bound a no-lower-bound range at an
288/// explicit upper ref (`changelog ..<tag>`): the range is then every ancestor
289/// of `<tag>`, excluding commits made after it.
290pub fn get_commits_reachable_paths_in(
291    cwd: &Path,
292    rev: &str,
293    paths: &[String],
294) -> Result<Vec<Commit>> {
295    let mut args = vec![
296        "-c".to_string(),
297        "log.showSignature=false".to_string(),
298        "log".to_string(),
299        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
300        rev.to_string(),
301    ];
302    if !paths.is_empty() {
303        args.push("--".to_string());
304        for p in paths {
305            args.push(p.clone());
306        }
307    }
308    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
309    let output = git_output_in(cwd, &arg_refs)?;
310    Ok(parse_commit_output(&output))
311}
312
313/// `--name-only` sibling of [`get_commits_reachable_paths_in`].
314pub fn get_commits_reachable_paths_with_files_in(
315    cwd: &Path,
316    rev: &str,
317    paths: &[String],
318) -> Result<Vec<CommitWithFiles>> {
319    let mut args = vec![
320        "-c".to_string(),
321        "log.showSignature=false".to_string(),
322        "log".to_string(),
323        "--name-only".to_string(),
324        "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
325        rev.to_string(),
326    ];
327    if !paths.is_empty() {
328        args.push("--".to_string());
329        for p in paths {
330            args.push(p.clone());
331        }
332    }
333    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
334    let output = git_output_in(cwd, &arg_refs)?;
335    Ok(parse_commit_output_with_files(&output))
336}
337
338/// Get last N commit subjects.
339pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
340    get_last_commit_messages_in(&cwd_or_dot(), count)
341}
342
343/// Path-taking sibling of [`get_last_commit_messages`].
344pub fn get_last_commit_messages_in(cwd: &Path, count: usize) -> Result<Vec<String>> {
345    let output = git_output_in(
346        cwd,
347        &[
348            "-c",
349            "log.showSignature=false",
350            "log",
351            &format!("-{count}"),
352            "--pretty=format:%s",
353        ],
354    )?;
355    Ok(output.lines().map(str::to_string).collect())
356}
357
358/// Get commit subjects between two refs.
359pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
360    get_commit_messages_between_in(&cwd_or_dot(), from, to)
361}
362
363/// Path-taking sibling of [`get_commit_messages_between`].
364pub fn get_commit_messages_between_in(cwd: &Path, from: &str, to: &str) -> Result<Vec<String>> {
365    let output = git_output_in(
366        cwd,
367        &[
368            "-c",
369            "log.showSignature=false",
370            "log",
371            "--pretty=format:%s",
372            &format!("{from}..{to}"),
373        ],
374    )?;
375    Ok(output.lines().map(str::to_string).collect())
376}