1use std::{
2 borrow::Cow,
3 collections::HashSet,
4 path::{Path, PathBuf},
5};
6
7use git2::Sort;
8use ini::Ini;
9use time::OffsetDateTime;
10use tracing::{info, info_span};
11
12use crate::database::schema::{
13 commit::Commit,
14 repository::{Repository, RepositoryId},
15 tag::Tag,
16};
17
18pub fn run(scan_path: &Path, db: &sled::Db) {
19 let span = info_span!("index_update");
20 let _entered = span.enter();
21
22 info!("Starting index update");
23
24 update_repository_metadata(scan_path, db);
25 update_repository_reflog(scan_path, db);
26 update_repository_tags(scan_path, db);
27
28 info!("Flushing to disk");
29
30 db.flush().unwrap();
31
32 info!("Finished index update");
33}
34
35fn update_repository_metadata(scan_path: &Path, db: &sled::Db) {
36 let mut discovered = Vec::new();
37 discover_repositories(scan_path, &mut discovered);
38
39 for repository in discovered {
40 let relative = get_relative_path(scan_path, &repository);
41
42 let id = Repository::open(db, relative)
43 .unwrap()
44 .map_or_else(|| RepositoryId::new(db), |v| v.get().id);
45 let name = relative.file_name().unwrap().to_string_lossy();
46 let description = std::fs::read(repository.join("description")).unwrap_or_default();
47 let description = Some(String::from_utf8_lossy(&description)).filter(|v| !v.is_empty());
48
49 let repository_path = scan_path.join(relative);
50 let git_repository = git2::Repository::open(repository_path.clone()).unwrap();
51
52 Repository {
53 id,
54 name,
55 description,
56 owner: find_gitweb_owner(repository_path.as_path()),
57 last_modified: find_last_committed_time(&git_repository)
58 .unwrap_or(OffsetDateTime::UNIX_EPOCH),
59 }
60 .insert(db, relative);
61 }
62}
63
64fn find_last_committed_time(repo: &git2::Repository) -> Result<OffsetDateTime, git2::Error> {
65 let mut timestamp = OffsetDateTime::UNIX_EPOCH;
66
67 for reference in repo.references()? {
68 let Ok(commit) = reference?.peel_to_commit() else {
69 continue;
70 };
71
72 let committed_time = commit.committer().when().seconds();
73 let committed_time = OffsetDateTime::from_unix_timestamp(committed_time)
74 .unwrap_or(OffsetDateTime::UNIX_EPOCH);
75
76 if committed_time > timestamp {
77 timestamp = committed_time;
78 }
79 }
80
81 Ok(timestamp)
82}
83
84fn update_repository_reflog(scan_path: &Path, db: &sled::Db) {
85 for (relative_path, db_repository) in Repository::fetch_all(db).unwrap() {
86 let git_repository = git2::Repository::open(scan_path.join(&relative_path)).unwrap();
87
88 for reference in git_repository.references().unwrap() {
89 let reference = reference.unwrap();
90
91 let reference_name = String::from_utf8_lossy(reference.name_bytes());
92 if !reference_name.starts_with("refs/heads/") {
93 continue;
94 }
95
96 let span = info_span!(
97 "branch_index_update",
98 reference = reference_name.as_ref(),
99 repository = relative_path
100 );
101 let _entered = span.enter();
102
103 info!("Refreshing indexes");
104
105 let commit_tree = db_repository
106 .get()
107 .commit_tree(db, &reference_name)
108 .unwrap();
109
110 if let (Some(latest_indexed), Ok(latest_commit)) =
111 (commit_tree.fetch_latest_one(), reference.peel_to_commit())
112 {
113 if latest_commit.id().as_bytes() == &*latest_indexed.get().hash {
114 info!("No commits since last index");
115 continue;
116 }
117 }
118
119 // TODO: only scan revs from the last time we looked
120 let mut revwalk = git_repository.revwalk().unwrap();
121 revwalk.set_sorting(Sort::REVERSE).unwrap();
122 revwalk.push_ref(&reference_name).unwrap();
123
124 let mut i = 0;
125 for rev in revwalk {
126 let commit = git_repository.find_commit(rev.unwrap()).unwrap();
127 let author = commit.author();
128 let committer = commit.committer();
129
130 Commit::new(&commit, &author, &committer).insert(&commit_tree, i);
131 i += 1;
132 }
133
134 // a complete and utter hack to remove potentially dropped commits from our tree,
135 // we'll need to add `clear()` to sled's tx api to remove this
136 for to_remove in (i + 1)..(i + 100) {
137 commit_tree.remove(to_remove.to_be_bytes()).unwrap();
138 }
139 }
140 }
141}
142
143fn update_repository_tags(scan_path: &Path, db: &sled::Db) {
144 for (relative_path, db_repository) in Repository::fetch_all(db).unwrap() {
145 let git_repository = git2::Repository::open(scan_path.join(&relative_path)).unwrap();
146
147 let tag_tree = db_repository.get().tag_tree(db).unwrap();
148
149 let git_tags: HashSet<_> = git_repository
150 .references()
151 .unwrap()
152 .filter_map(Result::ok)
153 .filter(|v| v.name_bytes().starts_with(b"refs/tags/"))
154 .map(|v| String::from_utf8_lossy(v.name_bytes()).into_owned())
155 .collect();
156 let indexed_tags: HashSet<String> = tag_tree.list().into_iter().collect();
157
158 // insert any git tags that are missing from the index
159 for tag_name in git_tags.difference(&indexed_tags) {
160 let span = info_span!(
161 "tag_index_update",
162 reference = tag_name,
163 repository = relative_path
164 );
165 let _entered = span.enter();
166
167 let reference = git_repository.find_reference(tag_name).unwrap();
168
169 if let Ok(tag) = reference.peel_to_tag() {
170 info!("Inserting newly discovered tag to index");
171
172 Tag::new(tag.tagger().as_ref()).insert(&tag_tree, tag_name);
173 }
174 }
175
176 // remove any extra tags that the index has
177 // TODO: this also needs to check peel_to_tag
178 for tag_name in indexed_tags.difference(&git_tags) {
179 let span = info_span!(
180 "tag_index_update",
181 reference = tag_name,
182 repository = relative_path
183 );
184 let _entered = span.enter();
185
186 info!("Removing stale tag from index");
187
188 tag_tree.remove(tag_name);
189 }
190 }
191}
192
193fn get_relative_path<'a>(relative_to: &Path, full_path: &'a Path) -> &'a Path {
194 full_path.strip_prefix(relative_to).unwrap()
195}
196
197fn discover_repositories(current: &Path, discovered_repos: &mut Vec<PathBuf>) {
198 let dirs = std::fs::read_dir(current)
199 .unwrap()
200 .map(|v| v.unwrap().path())
201 .filter(|path| path.is_dir());
202
203 for dir in dirs {
204 if dir.join("packed-refs").is_file() {
205 // we've hit what looks like a bare git repo, lets take it
206 discovered_repos.push(dir);
207 } else {
208 // probably not a bare git repo, lets recurse deeper
209 discover_repositories(&dir, discovered_repos);
210 }
211 }
212}
213
214fn find_gitweb_owner(repository_path: &Path) -> Option<Cow<'_, str>> {
215 // Load the Git config file and attempt to extract the owner from the "gitweb" section.
216 // If the owner is not found, an empty string is returned.
217 Ini::load_from_file(repository_path.join("config"))
218 .ok()?
219 .section(Some("gitweb"))
220 .and_then(|section| section.get("owner"))
221 .map(String::from)
222 .map(Cow::Owned)
223}