1use crate::error::{Result, WtgError};
2use crate::github::ReleaseInfo;
3use git2::{Commit, Oid, Repository, Time};
4use regex::Regex;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, LazyLock, Mutex};
7
8#[derive(Clone)]
9pub struct GitRepo {
10 repo: Arc<Mutex<Repository>>,
11 path: PathBuf,
12}
13
14#[derive(Debug, Clone)]
15pub struct CommitInfo {
16 pub hash: String,
17 pub short_hash: String,
18 pub message: String,
19 pub message_lines: usize,
20 pub author_name: String,
21 pub author_email: String,
22 pub date: String,
23 pub timestamp: i64, }
25
26impl CommitInfo {
27 #[must_use]
29 pub fn date_rfc3339(&self) -> String {
30 use chrono::{DateTime, TimeZone, Utc};
31 let datetime: DateTime<Utc> = Utc.timestamp_opt(self.timestamp, 0).unwrap();
32 datetime.to_rfc3339()
33 }
34}
35
36#[derive(Debug, Clone)]
37pub struct FileInfo {
38 pub path: String,
39 pub last_commit: CommitInfo,
40 pub previous_authors: Vec<(String, String, String)>, }
42
43#[derive(Debug, Clone)]
44pub struct TagInfo {
45 pub name: String,
46 pub commit_hash: String,
47 pub is_semver: bool,
48 pub semver_info: Option<SemverInfo>,
49 pub is_release: bool, pub release_name: Option<String>, pub release_url: Option<String>, pub published_at: Option<String>, }
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct SemverInfo {
57 pub major: u32,
58 pub minor: u32,
59 pub patch: Option<u32>,
60 pub build: Option<u32>,
61 pub pre_release: Option<String>,
62 pub build_metadata: Option<String>,
63}
64
65impl GitRepo {
66 pub fn open() -> Result<Self> {
68 let repo = Repository::discover(".").map_err(|_| WtgError::NotInGitRepo)?;
69 let path = repo.path().to_path_buf();
70 Ok(Self {
71 repo: Arc::new(Mutex::new(repo)),
72 path,
73 })
74 }
75
76 pub fn from_path(path: &Path) -> Result<Self> {
78 let repo = Repository::open(path).map_err(|_| WtgError::NotInGitRepo)?;
79 let repo_path = repo.path().to_path_buf();
80 Ok(Self {
81 repo: Arc::new(Mutex::new(repo)),
82 path: repo_path,
83 })
84 }
85
86 #[must_use]
88 pub fn path(&self) -> &Path {
89 &self.path
90 }
91
92 fn with_repo<T>(&self, f: impl FnOnce(&Repository) -> T) -> T {
93 let repo = self.repo.lock().expect("git repository mutex poisoned");
94 f(&repo)
95 }
96
97 #[must_use]
99 pub fn find_commit(&self, hash_str: &str) -> Option<CommitInfo> {
100 self.with_repo(|repo| {
101 if let Ok(oid) = Oid::from_str(hash_str)
102 && let Ok(commit) = repo.find_commit(oid)
103 {
104 return Some(Self::commit_to_info(&commit));
105 }
106
107 if hash_str.len() >= 7
108 && let Ok(obj) = repo.revparse_single(hash_str)
109 && let Ok(commit) = obj.peel_to_commit()
110 {
111 return Some(Self::commit_to_info(&commit));
112 }
113
114 None
115 })
116 }
117
118 #[must_use]
120 pub fn find_file(&self, path: &str) -> Option<FileInfo> {
121 self.with_repo(|repo| {
122 let mut revwalk = repo.revwalk().ok()?;
123 revwalk.push_head().ok()?;
124
125 for oid in revwalk {
126 let oid = oid.ok()?;
127 let commit = repo.find_commit(oid).ok()?;
128
129 if commit_touches_file(&commit, path) {
130 let commit_info = Self::commit_to_info(&commit);
131 let previous_authors = Self::get_previous_authors(repo, path, &commit, 4);
132
133 return Some(FileInfo {
134 path: path.to_string(),
135 last_commit: commit_info,
136 previous_authors,
137 });
138 }
139 }
140
141 None
142 })
143 }
144
145 fn get_previous_authors(
147 repo: &Repository,
148 path: &str,
149 last_commit: &Commit,
150 limit: usize,
151 ) -> Vec<(String, String, String)> {
152 let mut authors = Vec::new();
153 let Ok(mut revwalk) = repo.revwalk() else {
154 return authors;
155 };
156
157 if revwalk.push_head().is_err() {
158 return authors;
159 }
160
161 let mut found_last = false;
162
163 for oid in revwalk {
164 if authors.len() >= limit {
165 break;
166 }
167
168 let Ok(oid) = oid else { continue };
169
170 let Ok(commit) = repo.find_commit(oid) else {
171 continue;
172 };
173
174 if commit.id() == last_commit.id() {
175 found_last = true;
176 continue;
177 }
178
179 if !found_last {
180 continue;
181 }
182
183 if commit_touches_file(&commit, path) {
184 authors.push((
185 commit.id().to_string()[..7].to_string(),
186 commit.author().name().unwrap_or("Unknown").to_string(),
187 commit.author().email().unwrap_or("").to_string(),
188 ));
189 }
190 }
191
192 authors
193 }
194
195 #[must_use]
197 pub fn get_tags(&self) -> Vec<TagInfo> {
198 self.get_tags_with_releases(&[])
199 }
200
201 #[must_use]
203 pub fn get_tags_with_releases(&self, github_releases: &[ReleaseInfo]) -> Vec<TagInfo> {
204 let release_map: std::collections::HashMap<String, &ReleaseInfo> = github_releases
205 .iter()
206 .map(|r| (r.tag_name.clone(), r))
207 .collect();
208
209 self.with_repo(|repo| {
210 let mut tags = Vec::new();
211
212 if let Ok(tag_names) = repo.tag_names(None) {
213 for tag_name in tag_names.iter().flatten() {
214 if let Ok(obj) = repo.revparse_single(tag_name)
215 && let Ok(commit) = obj.peel_to_commit()
216 {
217 let semver_info = parse_semver(tag_name);
218 let is_semver = semver_info.is_some();
219
220 let (is_release, release_name, release_url, published_at) = release_map
221 .get(tag_name)
222 .map_or((false, None, None, None), |release| {
223 (
224 true,
225 release.name.clone(),
226 Some(release.url.clone()),
227 release.published_at.clone(),
228 )
229 });
230
231 tags.push(TagInfo {
232 name: tag_name.to_string(),
233 commit_hash: commit.id().to_string(),
234 is_semver,
235 semver_info,
236 is_release,
237 release_name,
238 release_url,
239 published_at,
240 });
241 }
242 }
243 }
244
245 tags
246 })
247 }
248
249 #[must_use]
251 pub fn tags_containing_commit(&self, commit_hash: &str) -> Vec<TagInfo> {
252 let Ok(commit_oid) = Oid::from_str(commit_hash) else {
253 return Vec::new();
254 };
255
256 self.find_tags_containing_commit(commit_oid)
257 .unwrap_or_default()
258 }
259
260 #[must_use]
262 pub fn tag_from_release(&self, release: &ReleaseInfo) -> Option<TagInfo> {
263 self.with_repo(|repo| {
264 let obj = repo.revparse_single(&release.tag_name).ok()?;
265 let commit = obj.peel_to_commit().ok()?;
266 let semver_info = parse_semver(&release.tag_name);
267
268 Some(TagInfo {
269 name: release.tag_name.clone(),
270 commit_hash: commit.id().to_string(),
271 is_semver: semver_info.is_some(),
272 semver_info,
273 is_release: true,
274 release_name: release.name.clone(),
275 release_url: Some(release.url.clone()),
276 published_at: release.published_at.clone(),
277 })
278 })
279 }
280
281 #[must_use]
283 pub fn tag_contains_commit(&self, tag_commit_hash: &str, commit_hash: &str) -> bool {
284 let Ok(tag_oid) = Oid::from_str(tag_commit_hash) else {
285 return false;
286 };
287 let Ok(commit_oid) = Oid::from_str(commit_hash) else {
288 return false;
289 };
290
291 self.is_ancestor(commit_oid, tag_oid)
292 }
293
294 fn find_tags_containing_commit(&self, commit_oid: Oid) -> Option<Vec<TagInfo>> {
298 self.with_repo(|repo| {
299 let target_commit = repo.find_commit(commit_oid).ok()?;
300 let target_timestamp = target_commit.time().seconds();
301
302 let mut containing_tags = Vec::new();
303 let tag_names = repo.tag_names(None).ok()?;
304
305 for tag_name in tag_names.iter().flatten() {
306 if let Ok(obj) = repo.revparse_single(tag_name)
307 && let Ok(commit) = obj.peel_to_commit()
308 {
309 let tag_oid = commit.id();
310
311 if commit.time().seconds() < target_timestamp {
314 continue;
315 }
316
317 if tag_oid == commit_oid
319 || repo
320 .graph_descendant_of(tag_oid, commit_oid)
321 .unwrap_or(false)
322 {
323 let semver_info = parse_semver(tag_name);
324
325 containing_tags.push(TagInfo {
326 name: tag_name.to_string(),
327 commit_hash: tag_oid.to_string(),
328 is_semver: semver_info.is_some(),
329 semver_info,
330 is_release: false,
331 release_name: None,
332 release_url: None,
333 published_at: None,
334 });
335 }
336 }
337 }
338
339 if containing_tags.is_empty() {
340 None
341 } else {
342 Some(containing_tags)
343 }
344 })
345 }
346
347 pub(crate) fn get_commit_timestamp(&self, commit_hash: &str) -> i64 {
349 self.with_repo(|repo| {
350 Oid::from_str(commit_hash)
351 .and_then(|oid| repo.find_commit(oid))
352 .map(|c| c.time().seconds())
353 .unwrap_or(0)
354 })
355 }
356
357 fn is_ancestor(&self, ancestor: Oid, descendant: Oid) -> bool {
359 self.with_repo(|repo| {
360 repo.graph_descendant_of(descendant, ancestor)
361 .unwrap_or(false)
362 })
363 }
364
365 #[must_use]
367 pub fn github_remote(&self) -> Option<(String, String)> {
368 self.with_repo(|repo| {
369 for remote_name in ["origin", "upstream"] {
370 if let Ok(remote) = repo.find_remote(remote_name)
371 && let Some(url) = remote.url()
372 && let Some(github_info) = parse_github_url(url)
373 {
374 return Some(github_info);
375 }
376 }
377
378 if let Ok(remotes) = repo.remotes() {
379 for remote_name in remotes.iter().flatten() {
380 if let Ok(remote) = repo.find_remote(remote_name)
381 && let Some(url) = remote.url()
382 && let Some(github_info) = parse_github_url(url)
383 {
384 return Some(github_info);
385 }
386 }
387 }
388
389 None
390 })
391 }
392
393 fn commit_to_info(commit: &Commit) -> CommitInfo {
395 let message = commit.message().unwrap_or("").to_string();
396 let lines: Vec<&str> = message.lines().collect();
397 let message_lines = lines.len();
398 let time = commit.time();
399
400 CommitInfo {
401 hash: commit.id().to_string(),
402 short_hash: commit.id().to_string()[..7].to_string(),
403 message: (*lines.first().unwrap_or(&"")).to_string(),
404 message_lines,
405 author_name: commit.author().name().unwrap_or("Unknown").to_string(),
406 author_email: commit.author().email().unwrap_or("").to_string(),
407 date: format_git_time(&time),
408 timestamp: time.seconds(),
409 }
410 }
411}
412
413fn commit_touches_file(commit: &Commit, path: &str) -> bool {
415 let Ok(tree) = commit.tree() else {
416 return false;
417 };
418
419 let target_path = Path::new(path);
420 let current_entry = tree.get_path(target_path).ok();
421
422 if commit.parent_count() == 0 {
424 return current_entry.is_some();
425 }
426
427 for parent in commit.parents() {
428 let Ok(parent_tree) = parent.tree() else {
429 continue;
430 };
431
432 let previous_entry = parent_tree.get_path(target_path).ok();
433 if tree_entries_differ(current_entry.as_ref(), previous_entry.as_ref()) {
434 return true;
435 }
436 }
437
438 false
439}
440
441fn tree_entries_differ(
442 current: Option<&git2::TreeEntry<'_>>,
443 previous: Option<&git2::TreeEntry<'_>>,
444) -> bool {
445 match (current, previous) {
446 (None, None) => false,
447 (Some(_), None) | (None, Some(_)) => true,
448 (Some(current_entry), Some(previous_entry)) => {
449 current_entry.id() != previous_entry.id()
450 || current_entry.filemode() != previous_entry.filemode()
451 }
452 }
453}
454
455static SEMVER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
463 Regex::new(
464 r"^(?:[a-z]+-)?v?(\d+)\.(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:-([a-zA-Z0-9.-]+))|(?:([a-z]+)(\d+)))?(?:\+(.+))?$"
465 )
466 .expect("Invalid semver regex")
467});
468
469fn parse_semver(tag: &str) -> Option<SemverInfo> {
480 let caps = SEMVER_REGEX.captures(tag)?;
481
482 let major = caps.get(1)?.as_str().parse::<u32>().ok()?;
483 let minor = caps.get(2)?.as_str().parse::<u32>().ok()?;
484 let patch = caps.get(3).and_then(|m| m.as_str().parse::<u32>().ok());
485 let build = caps.get(4).and_then(|m| m.as_str().parse::<u32>().ok());
486
487 let pre_release = caps.get(5).map_or_else(
491 || {
492 caps.get(6).map(|py_pre| {
493 let py_num = caps
494 .get(7)
495 .map_or(String::new(), |m| m.as_str().to_string());
496 format!("{}{}", py_pre.as_str(), py_num)
497 })
498 },
499 |dash_pre| Some(dash_pre.as_str().to_string()),
500 );
501
502 let build_metadata = caps.get(8).map(|m| m.as_str().to_string());
503
504 Some(SemverInfo {
505 major,
506 minor,
507 patch,
508 build,
509 pre_release,
510 build_metadata,
511 })
512}
513
514#[cfg(test)]
516fn is_semver_tag(tag: &str) -> bool {
517 parse_semver(tag).is_some()
518}
519
520fn parse_github_url(url: &str) -> Option<(String, String)> {
522 if url.contains("github.com") {
527 let parts: Vec<&str> = if url.starts_with("git@") {
528 url.split(':').collect()
529 } else {
530 url.split("github.com/").collect()
531 };
532
533 if let Some(path) = parts.last() {
534 let path = path.trim_end_matches(".git");
535 let repo_parts: Vec<&str> = path.split('/').collect();
536 if repo_parts.len() >= 2 {
537 return Some((repo_parts[0].to_string(), repo_parts[1].to_string()));
538 }
539 }
540 }
541
542 None
543}
544
545fn format_git_time(time: &Time) -> String {
547 use chrono::{DateTime, TimeZone, Utc};
548
549 let datetime: DateTime<Utc> = Utc.timestamp_opt(time.seconds(), 0).unwrap();
550 datetime.format("%Y-%m-%d %H:%M:%S").to_string()
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use std::fs;
557 use tempfile::tempdir;
558
559 #[test]
560 fn test_parse_semver_2_part() {
561 let result = parse_semver("1.0");
562 assert!(result.is_some());
563 let semver = result.unwrap();
564 assert_eq!(semver.major, 1);
565 assert_eq!(semver.minor, 0);
566 assert_eq!(semver.patch, None);
567 assert_eq!(semver.build, None);
568 }
569
570 #[test]
571 fn test_parse_semver_2_part_with_v_prefix() {
572 let result = parse_semver("v2.1");
573 assert!(result.is_some());
574 let semver = result.unwrap();
575 assert_eq!(semver.major, 2);
576 assert_eq!(semver.minor, 1);
577 }
578
579 #[test]
580 fn test_parse_semver_3_part() {
581 let result = parse_semver("1.2.3");
582 assert!(result.is_some());
583 let semver = result.unwrap();
584 assert_eq!(semver.major, 1);
585 assert_eq!(semver.minor, 2);
586 assert_eq!(semver.patch, Some(3));
587 assert_eq!(semver.build, None);
588 }
589
590 #[test]
591 fn test_parse_semver_3_part_with_v_prefix() {
592 let result = parse_semver("v1.2.3");
593 assert!(result.is_some());
594 let semver = result.unwrap();
595 assert_eq!(semver.major, 1);
596 assert_eq!(semver.minor, 2);
597 assert_eq!(semver.patch, Some(3));
598 }
599
600 #[test]
601 fn test_parse_semver_4_part() {
602 let result = parse_semver("1.2.3.4");
603 assert!(result.is_some());
604 let semver = result.unwrap();
605 assert_eq!(semver.major, 1);
606 assert_eq!(semver.minor, 2);
607 assert_eq!(semver.patch, Some(3));
608 assert_eq!(semver.build, Some(4));
609 }
610
611 #[test]
612 fn test_parse_semver_with_pre_release() {
613 let result = parse_semver("1.0.0-alpha");
614 assert!(result.is_some());
615 let semver = result.unwrap();
616 assert_eq!(semver.major, 1);
617 assert_eq!(semver.minor, 0);
618 assert_eq!(semver.patch, Some(0));
619 assert_eq!(semver.pre_release, Some("alpha".to_string()));
620 }
621
622 #[test]
623 fn test_parse_semver_with_pre_release_numeric() {
624 let result = parse_semver("v2.0.0-rc.1");
625 assert!(result.is_some());
626 let semver = result.unwrap();
627 assert_eq!(semver.major, 2);
628 assert_eq!(semver.minor, 0);
629 assert_eq!(semver.patch, Some(0));
630 assert_eq!(semver.pre_release, Some("rc.1".to_string()));
631 }
632
633 #[test]
634 fn test_parse_semver_with_build_metadata() {
635 let result = parse_semver("1.0.0+build.123");
636 assert!(result.is_some());
637 let semver = result.unwrap();
638 assert_eq!(semver.major, 1);
639 assert_eq!(semver.minor, 0);
640 assert_eq!(semver.patch, Some(0));
641 assert_eq!(semver.build_metadata, Some("build.123".to_string()));
642 }
643
644 #[test]
645 fn test_parse_semver_with_pre_release_and_build() {
646 let result = parse_semver("v1.0.0-beta.2+20130313144700");
647 assert!(result.is_some());
648 let semver = result.unwrap();
649 assert_eq!(semver.major, 1);
650 assert_eq!(semver.minor, 0);
651 assert_eq!(semver.patch, Some(0));
652 assert_eq!(semver.pre_release, Some("beta.2".to_string()));
653 assert_eq!(semver.build_metadata, Some("20130313144700".to_string()));
654 }
655
656 #[test]
657 fn test_parse_semver_2_part_with_pre_release() {
658 let result = parse_semver("2.0-alpha");
659 assert!(result.is_some());
660 let semver = result.unwrap();
661 assert_eq!(semver.major, 2);
662 assert_eq!(semver.minor, 0);
663 assert_eq!(semver.patch, None);
664 assert_eq!(semver.pre_release, Some("alpha".to_string()));
665 }
666
667 #[test]
668 fn test_parse_semver_invalid_single_part() {
669 assert!(parse_semver("1").is_none());
670 }
671
672 #[test]
673 fn test_parse_semver_invalid_non_numeric() {
674 assert!(parse_semver("abc.def").is_none());
675 assert!(parse_semver("1.x.3").is_none());
676 }
677
678 #[test]
679 fn test_parse_semver_invalid_too_many_parts() {
680 assert!(parse_semver("1.2.3.4.5").is_none());
681 }
682
683 #[test]
684 fn test_is_semver_tag() {
685 assert!(is_semver_tag("1.0"));
687 assert!(is_semver_tag("v1.0"));
688 assert!(is_semver_tag("1.2.3"));
689 assert!(is_semver_tag("v1.2.3"));
690 assert!(is_semver_tag("1.2.3.4"));
691
692 assert!(is_semver_tag("1.0.0-alpha"));
694 assert!(is_semver_tag("v2.0.0-rc.1"));
695 assert!(is_semver_tag("1.2.3-beta.2"));
696
697 assert!(is_semver_tag("1.2.3a1"));
699 assert!(is_semver_tag("1.2.3b1"));
700 assert!(is_semver_tag("1.2.3rc1"));
701
702 assert!(is_semver_tag("1.0.0+build"));
704
705 assert!(is_semver_tag("py-v1.0.0"));
707 assert!(is_semver_tag("rust-v1.2.3-beta.1"));
708 assert!(is_semver_tag("python-1.2.3b1"));
709
710 assert!(!is_semver_tag("v1"));
712 assert!(!is_semver_tag("abc"));
713 assert!(!is_semver_tag("1.2.3.4.5"));
714 assert!(!is_semver_tag("server-v-1.0.0")); }
716
717 #[test]
718 fn test_parse_semver_with_custom_prefix() {
719 let result = parse_semver("py-v1.0.0-beta.1");
721 assert!(result.is_some());
722 let semver = result.unwrap();
723 assert_eq!(semver.major, 1);
724 assert_eq!(semver.minor, 0);
725 assert_eq!(semver.patch, Some(0));
726 assert_eq!(semver.pre_release, Some("beta.1".to_string()));
727
728 let result = parse_semver("rust-v1.0.0-beta.2");
730 assert!(result.is_some());
731 let semver = result.unwrap();
732 assert_eq!(semver.major, 1);
733 assert_eq!(semver.minor, 0);
734 assert_eq!(semver.patch, Some(0));
735 assert_eq!(semver.pre_release, Some("beta.2".to_string()));
736
737 let result = parse_semver("python-2.1.0");
739 assert!(result.is_some());
740 let semver = result.unwrap();
741 assert_eq!(semver.major, 2);
742 assert_eq!(semver.minor, 1);
743 assert_eq!(semver.patch, Some(0));
744 }
745
746 #[test]
747 fn test_parse_semver_python_style() {
748 let result = parse_semver("1.2.3a1");
750 assert!(result.is_some());
751 let semver = result.unwrap();
752 assert_eq!(semver.major, 1);
753 assert_eq!(semver.minor, 2);
754 assert_eq!(semver.patch, Some(3));
755 assert_eq!(semver.pre_release, Some("a1".to_string()));
756
757 let result = parse_semver("v1.2.3b2");
759 assert!(result.is_some());
760 let semver = result.unwrap();
761 assert_eq!(semver.major, 1);
762 assert_eq!(semver.minor, 2);
763 assert_eq!(semver.patch, Some(3));
764 assert_eq!(semver.pre_release, Some("b2".to_string()));
765
766 let result = parse_semver("2.0.0rc1");
768 assert!(result.is_some());
769 let semver = result.unwrap();
770 assert_eq!(semver.major, 2);
771 assert_eq!(semver.minor, 0);
772 assert_eq!(semver.patch, Some(0));
773 assert_eq!(semver.pre_release, Some("rc1".to_string()));
774
775 let result = parse_semver("py-v1.0.0b1");
777 assert!(result.is_some());
778 let semver = result.unwrap();
779 assert_eq!(semver.major, 1);
780 assert_eq!(semver.minor, 0);
781 assert_eq!(semver.patch, Some(0));
782 assert_eq!(semver.pre_release, Some("b1".to_string()));
783 }
784
785 #[test]
786 fn test_parse_semver_rejects_garbage() {
787 assert!(parse_semver("server-v-config").is_none());
789 assert!(parse_semver("whatever-v-something").is_none());
790
791 assert!(parse_semver("v1").is_none());
793 assert!(parse_semver("1").is_none());
794 assert!(parse_semver("1.2.3.4.5").is_none());
795 assert!(parse_semver("abc.def").is_none());
796 }
797
798 #[test]
799 fn file_history_tracks_content_and_metadata_changes() {
800 const ORIGINAL_PATH: &str = "config/policy.json";
801 const RENAMED_PATH: &str = "config/policy-renamed.json";
802 const EXECUTABLE_PATH: &str = "scripts/run.sh";
803 const DELETED_PATH: &str = "docs/legacy.md";
804 const DISTRACTION_PATH: &str = "README.md";
805
806 let temp = tempdir().expect("temp dir");
807 let repo = Repository::init(temp.path()).expect("git repo");
808
809 commit_file(&repo, DISTRACTION_PATH, "noise", "add distraction");
810 commit_file(&repo, ORIGINAL_PATH, "{\"version\":1}", "seed config");
811 commit_file(&repo, ORIGINAL_PATH, "{\"version\":2}", "config tweak");
812 let rename_commit = rename_file(&repo, ORIGINAL_PATH, RENAMED_PATH, "rename config");
813 let post_rename_commit = commit_file(
814 &repo,
815 RENAMED_PATH,
816 "{\"version\":3}",
817 "update renamed config",
818 );
819
820 commit_file(
821 &repo,
822 EXECUTABLE_PATH,
823 "#!/bin/sh\\nprintf hi\n",
824 "add runner",
825 );
826 let exec_mode_commit = change_file_mode(
827 &repo,
828 EXECUTABLE_PATH,
829 git2::FileMode::BlobExecutable,
830 "make runner executable",
831 );
832
833 commit_file(&repo, DELETED_PATH, "bye", "add temporary file");
834 let delete_commit = delete_file(&repo, DELETED_PATH, "remove temporary file");
835
836 let git_repo = GitRepo::from_path(temp.path()).expect("git repo wrapper");
837
838 let renamed_info = git_repo.find_file(RENAMED_PATH).expect("renamed file info");
839 assert_eq!(
840 renamed_info.last_commit.hash,
841 post_rename_commit.to_string()
842 );
843
844 let original_info = git_repo
845 .find_file(ORIGINAL_PATH)
846 .expect("original file info");
847 assert_eq!(original_info.last_commit.hash, rename_commit.to_string());
848
849 let exec_info = git_repo.find_file(EXECUTABLE_PATH).expect("exec file info");
850 assert_eq!(exec_info.last_commit.hash, exec_mode_commit.to_string());
851
852 let deleted_info = git_repo.find_file(DELETED_PATH).expect("deleted file info");
853 assert_eq!(deleted_info.last_commit.hash, delete_commit.to_string());
854 }
855
856 fn commit_file(repo: &Repository, path: &str, contents: &str, message: &str) -> git2::Oid {
857 let workdir = repo.workdir().expect("workdir");
858 let file_path = workdir.join(path);
859 if let Some(parent) = file_path.parent() {
860 fs::create_dir_all(parent).expect("create dir");
861 }
862 fs::write(&file_path, contents).expect("write file");
863
864 let mut index = repo.index().expect("index");
865 index.add_path(Path::new(path)).expect("add path");
866 write_tree_and_commit(repo, &mut index, message)
867 }
868
869 fn rename_file(repo: &Repository, from: &str, to: &str, message: &str) -> git2::Oid {
870 let workdir = repo.workdir().expect("workdir");
871 let from_path = workdir.join(from);
872 let to_path = workdir.join(to);
873 if let Some(parent) = to_path.parent() {
874 fs::create_dir_all(parent).expect("create dir");
875 }
876 fs::rename(&from_path, &to_path).expect("rename file");
877
878 let mut index = repo.index().expect("index");
879 index.remove_path(Path::new(from)).expect("remove old path");
880 index.add_path(Path::new(to)).expect("add new path");
881 write_tree_and_commit(repo, &mut index, message)
882 }
883
884 fn delete_file(repo: &Repository, path: &str, message: &str) -> git2::Oid {
885 let workdir = repo.workdir().expect("workdir");
886 let file_path = workdir.join(path);
887 if file_path.exists() {
888 fs::remove_file(&file_path).expect("remove file");
889 }
890
891 let mut index = repo.index().expect("index");
892 index.remove_path(Path::new(path)).expect("remove path");
893 write_tree_and_commit(repo, &mut index, message)
894 }
895
896 fn change_file_mode(
897 repo: &Repository,
898 path: &str,
899 mode: git2::FileMode,
900 message: &str,
901 ) -> git2::Oid {
902 let mut index = repo.index().expect("index");
903 index.add_path(Path::new(path)).expect("add path");
904 force_index_mode(&mut index, path, mode);
905 write_tree_and_commit(repo, &mut index, message)
906 }
907
908 fn force_index_mode(index: &mut git2::Index, path: &str, mode: git2::FileMode) {
909 if let Some(mut entry) = index.get_path(Path::new(path), 0) {
910 entry.mode = u32::try_from(i32::from(mode)).expect("valid file mode");
911 index.add(&entry).expect("re-add entry");
912 }
913 }
914
915 fn write_tree_and_commit(
916 repo: &Repository,
917 index: &mut git2::Index,
918 message: &str,
919 ) -> git2::Oid {
920 index.write().expect("write index");
921 let tree_oid = index.write_tree().expect("tree oid");
922 let tree = repo.find_tree(tree_oid).expect("tree");
923 let sig = test_signature();
924
925 let parents = repo
926 .head()
927 .ok()
928 .and_then(|head| head.target())
929 .and_then(|oid| repo.find_commit(oid).ok())
930 .into_iter()
931 .collect::<Vec<_>>();
932 let parent_refs = parents.iter().collect::<Vec<_>>();
933
934 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parent_refs)
935 .expect("commit")
936 }
937
938 fn test_signature() -> git2::Signature<'static> {
939 git2::Signature::now("Test User", "tester@example.com").expect("sig")
940 }
941}