Skip to main content

docgen_diff/
history.rs

1//! Git history walk + blob content (git2 layer).
2//!
3//! Given a repo and a doc path (relative to repo root), list every commit that
4//! touched it — newest-first — paired with the file content at that revision and
5//! at its first parent. Rename-aware via diff `find_similar`, first-commit-safe,
6//! no-history-safe.
7//!
8//! History selection mirrors `git log -- <path>`: the walk is over all
9//! reachable commits but a merge commit that is TREESAME (same blob at the
10//! tracked path) to one of its parents is simplified out, so a side-branch
11//! change is not duplicated under the merge subject. Rename following uses
12//! git2's `find_similar` with an explicit 50% similarity threshold (git's
13//! default); a rename that also rewrites the body below that threshold is seen
14//! as add+delete rather than a rename, so — like git without `--follow` — the
15//! pre-rename history is not stitched across in that case. Copy detection is
16//! off (parity with the original, which relied on `git log`'s default rename
17//! following).
18
19use std::path::Path;
20
21use git2::{Delta, DiffFindOptions, ErrorCode, Repository, Sort, Tree};
22
23use crate::error::DiffError;
24use crate::types::DocDiffFileStatus;
25
26/// Commit metadata captured for a revision.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CommitMeta {
29    pub hash: String,
30    pub short_hash: String,
31    pub parents: Vec<String>,
32    pub author: Option<String>,
33    /// RFC3339 string, parity with the TS ISO date.
34    pub date: Option<String>,
35    pub subject: String,
36}
37
38/// One revision of a doc: its commit metadata plus the file content at this
39/// revision (`new_text`) and at its first parent (`old_text`).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct RevisionContent {
42    pub meta: CommitMeta,
43    pub old_text: String,
44    pub new_text: String,
45    pub status: DocDiffFileStatus,
46    /// Path of the doc at this revision (the `new` side).
47    pub path: String,
48    /// Set when this revision renamed the doc (the `old` side path).
49    pub old_path: Option<String>,
50}
51
52/// Open the repo that contains `path` (walking up). Returns `Ok(None)` when
53/// `path` is not inside any git repo — the graceful "not a git repo" path.
54pub fn discover_repo(path: &Path) -> Result<Option<Repository>, DiffError> {
55    match Repository::discover(path) {
56        Ok(repo) => Ok(Some(repo)),
57        Err(e) if e.code() == ErrorCode::NotFound => Ok(None),
58        Err(e) => Err(e.into()),
59    }
60}
61
62/// All commits (newest-first, capped at `limit`) that touched `doc_rel_path`,
63/// each paired with the file content at that revision and at its first parent.
64pub fn doc_revisions(
65    repo: &Repository,
66    doc_rel_path: &str,
67    limit: usize,
68) -> Result<Vec<RevisionContent>, DiffError> {
69    let mut revwalk = repo.revwalk()?;
70    // Newest-first, parity with `git log` default for `recentDocCommits`.
71    revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
72    if revwalk.push_head().is_err() {
73        // Empty repo (no HEAD): no history.
74        return Ok(Vec::new());
75    }
76
77    // The path moves backward in time as we follow renames.
78    let mut current_path = doc_rel_path.to_string();
79    let mut out = Vec::new();
80
81    for oid in revwalk {
82        if out.len() >= limit {
83            break;
84        }
85        let oid = oid?;
86        let commit = repo.find_commit(oid)?;
87        let commit_tree = commit.tree()?;
88        let parent = commit.parents().next();
89        let parent_tree: Option<Tree> = match &parent {
90            Some(p) => Some(p.tree()?),
91            None => None,
92        };
93
94        // History simplification (parity with `git log -- <path>`): a merge
95        // commit whose blob at the tracked path is identical (TREESAME) to one
96        // of its parents brought no change of its own on this path — the real
97        // authoring commit lives on that parent's history and is walked
98        // separately. Emitting the merge too would duplicate that change.
99        // Skip it (but still follow the rest of the walk).
100        if commit.parent_count() > 1
101            && merge_is_treesame_to_a_parent(&commit, &commit_tree, &current_path)?
102        {
103            continue;
104        }
105
106        // Full tree diff with rename detection — we cannot pre-restrict the
107        // pathspec because a rename changes the path.
108        let mut diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
109        let mut find_opts = DiffFindOptions::new();
110        find_opts.renames(true);
111        // Pin git's default 50% similarity threshold so rename detection does
112        // not drift with git2's internal default (documented behavior above).
113        find_opts.rename_threshold(50);
114        diff.find_similar(Some(&mut find_opts))?;
115
116        // Find a delta touching the currently-tracked path (as new or old side).
117        let mut matched: Option<(DocDiffFileStatus, String, Option<String>)> = None;
118        for delta in diff.deltas() {
119            let new_path = delta.new_file().path().and_then(|p| p.to_str());
120            let old_path = delta.old_file().path().and_then(|p| p.to_str());
121
122            let touches = new_path == Some(current_path.as_str());
123            // For deletions the path lives on the old side.
124            let touches_deleted =
125                delta.status() == Delta::Deleted && old_path == Some(current_path.as_str());
126
127            if touches || touches_deleted {
128                let status = classify(delta.status());
129                let resolved_new = new_path.unwrap_or(current_path.as_str()).to_string();
130                let resolved_old = old_path.map(|s| s.to_string());
131                matched = Some((status, resolved_new, resolved_old));
132                break;
133            }
134        }
135
136        let (status, new_path, old_path) = match matched {
137            Some(m) => m,
138            // This commit did not touch the tracked path — skip (parity with
139            // the TS `entries.length === 0 -> continue`).
140            None => continue,
141        };
142
143        // Read content. `new_text` from this commit's tree; `old_text` from the
144        // parent tree (empty when parentless or Added).
145        let new_text = if status == DocDiffFileStatus::Deleted {
146            String::new()
147        } else {
148            blob_text(repo, &commit_tree, &new_path)
149        };
150        let old_text = match (&parent_tree, status) {
151            (_, DocDiffFileStatus::Added) => String::new(),
152            (Some(pt), _) => {
153                let read_path = old_path.as_deref().unwrap_or(new_path.as_str());
154                blob_text(repo, pt, read_path)
155            }
156            (None, _) => String::new(),
157        };
158
159        let rename_old_path = if status == DocDiffFileStatus::Renamed {
160            old_path.clone()
161        } else {
162            None
163        };
164
165        out.push(RevisionContent {
166            meta: commit_meta(&commit)?,
167            old_text,
168            new_text,
169            status,
170            path: new_path,
171            old_path: rename_old_path,
172        });
173
174        // Follow the rename backward: older commits know this doc by its old path.
175        if status == DocDiffFileStatus::Renamed {
176            if let Some(op) = old_path {
177                current_path = op;
178            }
179        }
180    }
181
182    Ok(out)
183}
184
185/// The content of a doc at git HEAD, for the editor's merge-against-HEAD view.
186/// `docs_dir` is the absolute docs directory; `doc_rel_path` is docs-relative
187/// (e.g. `"guide/intro.md"`). Returns `None` outside a repo, for an untracked
188/// file, or on any read error — the editor then shows no merge gutter.
189pub fn head_source(docs_dir: &Path, doc_rel_path: &str) -> Option<String> {
190    let repo = Repository::discover(docs_dir).ok()?;
191    let workdir = repo.workdir()?.to_path_buf();
192    // Resolve the path as git sees it (repo-relative), tolerating the macOS
193    // /var vs /private/var symlink the same way `git_rel_path` does.
194    let abs = docs_dir.join(doc_rel_path).canonicalize().ok()?;
195    let work = workdir.canonicalize().ok()?;
196    let rel = abs.strip_prefix(&work).ok()?;
197    let rel_str = rel.to_str()?;
198    let tree = repo.head().ok()?.peel_to_tree().ok()?;
199    let entry = tree.get_path(Path::new(rel_str)).ok()?;
200    let object = entry.to_object(&repo).ok()?;
201    let blob = object.as_blob()?;
202    Some(String::from_utf8_lossy(blob.content()).into_owned())
203}
204
205/// One changed doc file within a single commit (the `new` side, plus its
206/// `old` content from the first parent). The global analogue of the per-file
207/// [`RevisionContent`].
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct GlobalFileChange {
210    pub status: DocDiffFileStatus,
211    /// Repo-relative path of the doc at this revision (the `new` side).
212    pub path: String,
213    /// Set when this file was renamed (the `old` side path).
214    pub old_path: Option<String>,
215    pub old_text: String,
216    pub new_text: String,
217}
218
219/// One commit in the global doc timeline: its metadata plus every doc file it
220/// changed (under `docs_prefix`), diffed against its first parent.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct GlobalRevision {
223    pub meta: CommitMeta,
224    pub files: Vec<GlobalFileChange>,
225}
226
227/// True when `path` is `prefix` itself or lives beneath it (`prefix/...`).
228fn under_prefix(path: &str, prefix: &str) -> bool {
229    path == prefix || path.starts_with(&format!("{prefix}/"))
230}
231
232/// All commits (newest-first, capped at `limit` doc-touching commits) that
233/// changed any file under `docs_prefix` (repo-relative, e.g. `"docs"`), each
234/// paired with the per-file old/new content from its first parent. The global
235/// analogue of [`doc_revisions`] — mirrors `git log -- <docsPath>` plus the
236/// per-commit `git diff` in the original `git-diff.server.ts`.
237pub fn global_doc_revisions(
238    repo: &Repository,
239    docs_prefix: &str,
240    limit: usize,
241) -> Result<Vec<GlobalRevision>, DiffError> {
242    let mut revwalk = repo.revwalk()?;
243    revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
244    if revwalk.push_head().is_err() {
245        return Ok(Vec::new());
246    }
247
248    let mut out: Vec<GlobalRevision> = Vec::new();
249
250    for oid in revwalk {
251        if out.len() >= limit {
252            break;
253        }
254        let oid = oid?;
255        let commit = repo.find_commit(oid)?;
256        let commit_tree = commit.tree()?;
257        let parent = commit.parents().next();
258        let parent_tree: Option<Tree> = match &parent {
259            Some(p) => Some(p.tree()?),
260            None => None,
261        };
262
263        // Merge history simplification (parity with `git log -- <docsPath>`): a
264        // merge whose docs subtree is identical to a parent's brought no change
265        // of its own under the docs prefix — its authoring commits are walked
266        // separately. Skip it to avoid duplicating those changes.
267        if commit.parent_count() > 1
268            && merge_docs_treesame_to_a_parent(&commit, &commit_tree, docs_prefix)?
269        {
270            continue;
271        }
272
273        let mut diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), None)?;
274        let mut find_opts = DiffFindOptions::new();
275        find_opts.renames(true);
276        find_opts.rename_threshold(50);
277        diff.find_similar(Some(&mut find_opts))?;
278
279        let mut files: Vec<GlobalFileChange> = Vec::new();
280        for delta in diff.deltas() {
281            let new_path = delta.new_file().path().and_then(|p| p.to_str());
282            let old_path = delta.old_file().path().and_then(|p| p.to_str());
283            let status = classify(delta.status());
284
285            // Resolve the doc path on the side that exists, then filter to docs.
286            let touch_path = if status == DocDiffFileStatus::Deleted {
287                old_path
288            } else {
289                new_path
290            };
291            let Some(touch_path) = touch_path else {
292                continue;
293            };
294            if !under_prefix(touch_path, docs_prefix) {
295                continue;
296            }
297
298            let resolved_new = new_path.unwrap_or(touch_path).to_string();
299            let resolved_old = old_path.map(|s| s.to_string());
300
301            let new_text = if status == DocDiffFileStatus::Deleted {
302                String::new()
303            } else {
304                blob_text(repo, &commit_tree, &resolved_new)
305            };
306            let old_text = match (&parent_tree, status) {
307                (_, DocDiffFileStatus::Added) => String::new(),
308                (Some(pt), _) => {
309                    let read_path = resolved_old.as_deref().unwrap_or(resolved_new.as_str());
310                    blob_text(repo, pt, read_path)
311                }
312                (None, _) => String::new(),
313            };
314            let rename_old_path = if status == DocDiffFileStatus::Renamed {
315                resolved_old.clone()
316            } else {
317                None
318            };
319
320            files.push(GlobalFileChange {
321                status,
322                path: resolved_new,
323                old_path: rename_old_path,
324                old_text,
325                new_text,
326            });
327        }
328
329        if files.is_empty() {
330            // Commit touched no docs files — skip (parity with the TS
331            // `entries.length === 0 -> continue`).
332            continue;
333        }
334
335        // Stable, deterministic order by new-side path.
336        files.sort_by(|a, b| a.path.cmp(&b.path));
337
338        out.push(GlobalRevision {
339            meta: commit_meta(&commit)?,
340            files,
341        });
342    }
343
344    Ok(out)
345}
346
347/// True when the merge `commit`'s tree entry at `docs_prefix` is identical to
348/// that of at least one parent (TREESAME on the docs subtree).
349fn merge_docs_treesame_to_a_parent(
350    commit: &git2::Commit,
351    commit_tree: &Tree,
352    docs_prefix: &str,
353) -> Result<bool, DiffError> {
354    let merge_oid = blob_oid_at(commit_tree, docs_prefix);
355    for parent in commit.parents() {
356        let parent_tree = parent.tree()?;
357        if blob_oid_at(&parent_tree, docs_prefix) == merge_oid {
358            return Ok(true);
359        }
360    }
361    Ok(false)
362}
363
364/// True when the merge `commit`'s blob at `path` is identical to that path's
365/// blob in at least one parent (TREESAME). Mirrors git-log's merge history
366/// simplification: such a merge contributed no change of its own on `path`.
367fn merge_is_treesame_to_a_parent(
368    commit: &git2::Commit,
369    commit_tree: &Tree,
370    path: &str,
371) -> Result<bool, DiffError> {
372    let merge_oid = blob_oid_at(commit_tree, path);
373    for parent in commit.parents() {
374        let parent_tree = parent.tree()?;
375        if blob_oid_at(&parent_tree, path) == merge_oid {
376            return Ok(true);
377        }
378    }
379    Ok(false)
380}
381
382/// The blob oid at `path` within `tree`, or `None` when the path is absent or
383/// is not a blob. Two trees are TREESAME on `path` iff these match.
384fn blob_oid_at(tree: &Tree, path: &str) -> Option<git2::Oid> {
385    tree.get_path(Path::new(path)).ok().map(|e| e.id())
386}
387
388fn classify(delta: Delta) -> DocDiffFileStatus {
389    match delta {
390        Delta::Added => DocDiffFileStatus::Added,
391        Delta::Deleted => DocDiffFileStatus::Deleted,
392        Delta::Renamed => DocDiffFileStatus::Renamed,
393        // Modified, Copied, Typechange, etc. → Modified (parity default).
394        _ => DocDiffFileStatus::Modified,
395    }
396}
397
398/// Read a blob at `path` within `tree`, lossily decoding to UTF-8. Returns ""
399/// when the path is absent or is not a blob.
400fn blob_text(repo: &Repository, tree: &Tree, path: &str) -> String {
401    let entry = match tree.get_path(Path::new(path)) {
402        Ok(e) => e,
403        Err(_) => return String::new(),
404    };
405    let object = match entry.to_object(repo) {
406        Ok(o) => o,
407        Err(_) => return String::new(),
408    };
409    match object.as_blob() {
410        Some(blob) => String::from_utf8_lossy(blob.content()).into_owned(),
411        None => String::new(),
412    }
413}
414
415fn commit_meta(commit: &git2::Commit) -> Result<CommitMeta, DiffError> {
416    let hash = commit.id().to_string();
417    let short_hash = commit
418        .as_object()
419        .short_id()
420        .ok()
421        .and_then(|buf| buf.as_str().map(|s| s.to_string()))
422        .unwrap_or_else(|| hash.chars().take(7).collect());
423    let parents = commit.parent_ids().map(|oid| oid.to_string()).collect();
424    let author = commit.author().name().and_then(|n| {
425        if n.is_empty() {
426            None
427        } else {
428            Some(n.to_string())
429        }
430    });
431    let date = rfc3339(commit.time());
432    let subject = commit
433        .message()
434        .unwrap_or("")
435        .lines()
436        .next()
437        .unwrap_or("")
438        .to_string();
439    Ok(CommitMeta {
440        hash,
441        short_hash,
442        parents,
443        author,
444        date,
445        subject,
446    })
447}
448
449/// Convert a git2 commit time (epoch seconds + tz offset minutes) to an RFC3339
450/// string. Returns `None` if the time is out of range.
451fn rfc3339(time: git2::Time) -> Option<String> {
452    use chrono::{FixedOffset, TimeZone};
453    let offset = FixedOffset::east_opt(time.offset_minutes() * 60)?;
454    offset
455        .timestamp_opt(time.seconds(), 0)
456        .single()
457        .map(|dt| dt.to_rfc3339())
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::testutil::TempRepo;
464
465    #[test]
466    fn discover_repo_returns_none_outside_git() {
467        let dir = std::env::temp_dir().join(format!("docgen_nogit_{}", std::process::id()));
468        let _ = std::fs::remove_dir_all(&dir);
469        std::fs::create_dir_all(&dir).unwrap();
470        assert!(discover_repo(&dir).unwrap().is_none());
471        let _ = std::fs::remove_dir_all(&dir);
472    }
473
474    #[test]
475    fn discover_repo_finds_temp_repo() {
476        let r = TempRepo::init();
477        r.commit_file("docs/a.md", "x\n", "a");
478        assert!(discover_repo(&r.dir).unwrap().is_some());
479    }
480
481    #[test]
482    fn doc_revisions_lists_commits_newest_first_with_content() {
483        let r = TempRepo::init();
484        r.commit_file("docs/a.md", "# A\nfirst\n", "add a");
485        r.commit_file("docs/a.md", "# A\nsecond\n", "edit a");
486        r.commit_file("docs/other.md", "x\n", "unrelated");
487
488        let revs = doc_revisions(&r.repo, "docs/a.md", 50).unwrap();
489        assert_eq!(revs.len(), 2);
490        // Newest-first.
491        assert_eq!(revs[0].meta.subject, "edit a");
492        assert_eq!(revs[1].meta.subject, "add a");
493        // Content at each revision + its parent.
494        assert_eq!(revs[0].new_text, "# A\nsecond\n");
495        assert_eq!(revs[0].old_text, "# A\nfirst\n");
496        assert_eq!(revs[0].status, DocDiffFileStatus::Modified);
497        // First commit: parentless -> empty old_text, status Added.
498        assert_eq!(revs[1].old_text, "");
499        assert_eq!(revs[1].new_text, "# A\nfirst\n");
500        assert_eq!(revs[1].status, DocDiffFileStatus::Added);
501        // short_hash is a prefix of hash; parents recorded.
502        assert!(revs[0].meta.hash.starts_with(&revs[0].meta.short_hash));
503        assert_eq!(revs[1].meta.parents.len(), 0);
504        assert_eq!(revs[0].meta.parents.len(), 1);
505        // Metadata populated.
506        assert_eq!(revs[0].meta.author.as_deref(), Some("docgen test"));
507        assert!(revs[0].meta.date.is_some());
508    }
509
510    #[test]
511    fn doc_revisions_follows_a_rename() {
512        let r = TempRepo::init();
513        r.commit_file("docs/old.md", "# Doc\nbody line\nmore\n", "create");
514        r.rename_file("docs/old.md", "docs/new.md");
515        r.commit_all("rename");
516
517        let revs = doc_revisions(&r.repo, "docs/new.md", 50).unwrap();
518        assert!(revs
519            .iter()
520            .any(|rev| rev.status == DocDiffFileStatus::Renamed
521                && rev.old_path.as_deref() == Some("docs/old.md")));
522        // The create commit is reachable through the rename (old_path history).
523        assert!(revs.iter().any(|rev| rev.meta.subject == "create"));
524    }
525
526    #[test]
527    fn doc_revisions_rename_with_heavy_edit_is_add_delete_not_followed() {
528        // A rename that also rewrites the body below the 50% threshold is seen
529        // as Added(new) rather than Renamed; the pre-rename history is not
530        // stitched across (documented non-`--follow` behavior). This pins the
531        // explicit threshold so the classification cannot silently drift.
532        let r = TempRepo::init();
533        r.commit_file(
534            "docs/old.md",
535            "alpha beta gamma\ndelta epsilon zeta\neta theta iota\n",
536            "create old",
537        );
538        r.rename_file("docs/old.md", "docs/new.md");
539        // Replace the body entirely so similarity is far below 50%.
540        std::fs::write(
541            r.dir.join("docs/new.md"),
542            "completely different content here\nnothing in common at all\nbrand new words only\n",
543        )
544        .unwrap();
545        r.commit_all("rename and rewrite");
546
547        let revs = doc_revisions(&r.repo, "docs/new.md", 50).unwrap();
548        // Not classified as a rename, so old.md history is not followed.
549        assert_eq!(revs.len(), 1);
550        assert_eq!(revs[0].status, DocDiffFileStatus::Added);
551        assert_eq!(revs[0].old_path, None);
552        assert_eq!(revs[0].old_text, "");
553    }
554
555    #[test]
556    fn doc_revisions_empty_for_untouched_path() {
557        let r = TempRepo::init();
558        r.commit_file("docs/a.md", "x\n", "a");
559        assert!(doc_revisions(&r.repo, "docs/ghost.md", 50)
560            .unwrap()
561            .is_empty());
562    }
563
564    #[test]
565    fn doc_revisions_empty_for_initialized_repo_with_no_commits() {
566        // Initialized but no HEAD: exercises the push_head().is_err() branch.
567        let r = TempRepo::init();
568        assert!(doc_revisions(&r.repo, "docs/a.md", 50).unwrap().is_empty());
569    }
570
571    #[test]
572    fn doc_revisions_records_deletion() {
573        let r = TempRepo::init();
574        r.commit_file("docs/a.md", "# A\nbody\n", "add a");
575        r.delete_file("docs/a.md");
576        r.commit_all("remove a");
577
578        let revs = doc_revisions(&r.repo, "docs/a.md", 50).unwrap();
579        // Newest revision is the deletion.
580        assert_eq!(revs[0].meta.subject, "remove a");
581        assert_eq!(revs[0].status, DocDiffFileStatus::Deleted);
582        assert_eq!(revs[0].new_text, "");
583        assert_eq!(revs[0].old_text, "# A\nbody\n");
584    }
585
586    #[test]
587    fn doc_revisions_respects_limit() {
588        let r = TempRepo::init();
589        r.commit_file("docs/a.md", "1\n", "edit 1");
590        r.commit_file("docs/a.md", "2\n", "edit 2");
591        r.commit_file("docs/a.md", "3\n", "edit 3");
592
593        let revs = doc_revisions(&r.repo, "docs/a.md", 2).unwrap();
594        assert_eq!(revs.len(), 2);
595        assert_eq!(revs[0].meta.subject, "edit 3");
596        assert_eq!(revs[1].meta.subject, "edit 2");
597    }
598
599    #[test]
600    fn doc_revisions_skips_merge_commit_treesame_to_a_parent() {
601        // Reproduces `git log -- docs/a.md` history simplification: a merge that
602        // brings a side-branch change in (TREESAME to the side parent) must NOT
603        // appear as a duplicate revision alongside the real authoring commit.
604        let r = TempRepo::init();
605        r.commit_file("docs/a.md", "# A\nl1\n", "base");
606
607        r.checkout_new_branch("feature");
608        r.commit_file("docs/a.md", "# A\nl1\nfeatureline\n", "feature edit");
609
610        r.checkout_branch("master");
611        r.commit_file("docs/b.txt", "b\n", "unrelated b");
612        r.merge_no_ff("feature", "merge feature");
613
614        let revs = doc_revisions(&r.repo, "docs/a.md", 50).unwrap();
615        let subjects: Vec<&str> = revs.iter().map(|x| x.meta.subject.as_str()).collect();
616        // Only the real authoring commit + base — the merge is simplified out.
617        assert_eq!(subjects, vec!["feature edit", "base"]);
618    }
619}