1use std::path::Path;
20
21use git2::{Delta, DiffFindOptions, ErrorCode, Repository, Sort, Tree};
22
23use crate::error::DiffError;
24use crate::types::DocDiffFileStatus;
25
26#[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 pub date: Option<String>,
35 pub subject: String,
36}
37
38#[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 pub path: String,
48 pub old_path: Option<String>,
50}
51
52pub 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
62pub 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 revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
72 if revwalk.push_head().is_err() {
73 return Ok(Vec::new());
75 }
76
77 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 if commit.parent_count() > 1
101 && merge_is_treesame_to_a_parent(&commit, &commit_tree, ¤t_path)?
102 {
103 continue;
104 }
105
106 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 find_opts.rename_threshold(50);
114 diff.find_similar(Some(&mut find_opts))?;
115
116 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 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 None => continue,
141 };
142
143 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 if status == DocDiffFileStatus::Renamed {
176 if let Some(op) = old_path {
177 current_path = op;
178 }
179 }
180 }
181
182 Ok(out)
183}
184
185pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct GlobalFileChange {
210 pub status: DocDiffFileStatus,
211 pub path: String,
213 pub old_path: Option<String>,
215 pub old_text: String,
216 pub new_text: String,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct GlobalRevision {
223 pub meta: CommitMeta,
224 pub files: Vec<GlobalFileChange>,
225}
226
227fn under_prefix(path: &str, prefix: &str) -> bool {
229 path == prefix || path.starts_with(&format!("{prefix}/"))
230}
231
232pub 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 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 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 continue;
333 }
334
335 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
347fn 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
364fn 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
382fn 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 _ => DocDiffFileStatus::Modified,
395 }
396}
397
398fn 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
449fn 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 assert_eq!(revs[0].meta.subject, "edit a");
492 assert_eq!(revs[1].meta.subject, "add a");
493 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(subjects, vec!["feature edit", "base"]);
618 }
619}