This repository has no description
1use std::collections::{HashMap, HashSet};
2use std::ops::ControlFlow;
3use std::time::Instant;
4
5use gix::bstr::ByteSlice;
6use knot_types::{BranchName, Oid, RepoPath, TagName, UnixSeconds};
7
8use crate::error::{GitError, backend};
9use crate::objects::{Commit, CommitRange, EntryKind, Identity, identity, map_kind};
10use crate::repo::Repo;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SizedEntry {
14 pub name: String,
15 pub oid: Oid,
16 pub kind: EntryKind,
17 pub size: u64,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct PathEntry {
22 pub oid: Oid,
23 pub kind: EntryKind,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct LastCommit {
28 pub id: Oid,
29 pub subject: String,
30 pub time: UnixSeconds,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum BranchTip {
35 Commit(Box<Commit>),
36 Opaque {
37 id: Oid,
38 message: String,
39 created_at: UnixSeconds,
40 },
41}
42
43impl BranchTip {
44 pub fn created_at(&self) -> UnixSeconds {
45 match self {
46 BranchTip::Commit(commit) => commit.committer.time,
47 BranchTip::Opaque { created_at, .. } => *created_at,
48 }
49 }
50
51 pub fn id(&self) -> Oid {
52 match self {
53 BranchTip::Commit(commit) => commit.id,
54 BranchTip::Opaque { id, .. } => *id,
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct BranchInfo {
61 pub name: BranchName,
62 pub tip: BranchTip,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct AnnotatedTag {
67 pub tagger: Option<Identity>,
68 pub pgp_signature: Option<String>,
69 pub target: Oid,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct TagInfo {
74 pub name: TagName,
75 pub id: Oid,
76 pub created_at: UnixSeconds,
77 pub message: String,
78 pub annotated: Option<AnnotatedTag>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Submodule {
83 pub name: String,
84 pub path: RepoPath,
85 pub url: String,
86 pub branch: Option<BranchName>,
87}
88
89knot_types::scalar_newtype! {
90 pub struct LogSkip(usize);
91 pub struct LogLimit(usize);
92}
93
94impl Repo {
95 pub fn resolve_revision(&self, spec: &str) -> Option<Oid> {
96 if spec.is_empty() || spec.contains('\0') {
97 return None;
98 }
99 self.git()
100 .rev_parse_single(spec.as_bytes())
101 .ok()
102 .map(|id| Oid::from(id.detach()))
103 }
104
105 pub fn peel_to_commit(&self, oid: Oid) -> Result<Oid, GitError> {
106 let peeled = self
107 .git()
108 .find_object(oid.object_id())
109 .map_err(backend)?
110 .peel_tags_to_end()
111 .map_err(backend)?;
112 match peeled.kind {
113 gix::object::Kind::Commit => Ok(Oid::from(peeled.id)),
114 _ => Err(GitError::ObjectType {
115 oid,
116 expected: "commit",
117 }),
118 }
119 }
120
121 fn walk_from(
122 &self,
123 start: Oid,
124 hidden: Option<Oid>,
125 ) -> Result<impl Iterator<Item = Result<Oid, GitError>> + '_, GitError> {
126 let hidden = hidden.filter(|oid| self.contains(*oid)).map(Oid::object_id);
127 Ok(self
128 .git()
129 .rev_walk(Some(start.object_id()))
130 .sorting(gix::revision::walk::Sorting::ByCommitTime(
131 gix::traverse::commit::simple::CommitTimeOrder::NewestFirst,
132 ))
133 .with_hidden(hidden)
134 .all()
135 .map_err(|error| GitError::RevWalk(error.to_string()))?
136 .map(|info| {
137 info.map(|info| Oid::from(info.id))
138 .map_err(|error| GitError::RevWalk(error.to_string()))
139 }))
140 }
141
142 pub fn commits_between(
143 &self,
144 range: CommitRange,
145 limit: LogLimit,
146 ) -> Result<Vec<Oid>, GitError> {
147 self.walk_from(range.head, Some(range.base))?
148 .take(limit.get())
149 .collect()
150 }
151
152 pub fn log_window(
153 &self,
154 start: Oid,
155 skip: LogSkip,
156 limit: LogLimit,
157 ) -> Result<(Vec<Commit>, usize), GitError> {
158 self.walk_from(start, None)?.enumerate().try_fold(
159 (Vec::new(), 0usize),
160 |(mut window, _), (index, oid)| {
161 let oid = oid?;
162 if index >= skip.get() && window.len() < limit.get() {
163 window.push(self.find_commit(oid)?);
164 }
165 Ok((window, index + 1))
166 },
167 )
168 }
169
170 pub fn merge_base(&self, one: Oid, two: Oid) -> Result<Option<Oid>, GitError> {
171 use gix::repository::merge_base::Error;
172 match self.git().merge_base(one.object_id(), two.object_id()) {
173 Ok(id) => Ok(Some(Oid::from(id.detach()))),
174 Err(Error::NotFound { .. }) => Ok(None),
175 Err(error) => Err(backend(error)),
176 }
177 }
178
179 // For security purposes.
180 // Without this, anyone who knows an oid can read objects from
181 // a deleted branch/ unreferenced push.
182 //
183 // COBs and forky staging refs aren't counted ofc.
184 //
185 // Oh btw to that end `advertised_refs()` *isn't* what upload-pack "advertises":
186 // upload-pack uses
187 // `advertised_refs_for(AdvertScope::Upload)` which also omits
188 // refs matching `transfer.hideRefs`/`uploadpack.hideRefs`.
189 pub fn reachable_from_public(&self, target: Oid) -> Result<bool, GitError> {
190 let tips: Vec<Oid> = self
191 .advertised_refs()?
192 .iter()
193 // skip any broken refs
194 .filter_map(|record| self.peel_to_commit(record.target).ok())
195 .collect();
196 if tips.contains(&target) {
197 return Ok(true);
198 }
199 tips.iter().try_fold(false, |found, tip| {
200 Ok(found
201 || self
202 // Traverse graph, but shouldn't be too hard on CPU
203 // because `commit_graph_if_enabled` isn't directly
204 // on the object db.
205 .merge_base(target, *tip)?
206 .is_some_and(|base| base == target))
207 })
208 }
209
210 pub fn branch_list(&self) -> Result<Vec<BranchInfo>, GitError> {
211 self.branches()?
212 .into_iter()
213 .filter_map(|record| record.name.branch_name().map(|name| (name, record.target)))
214 .map(|(name, target)| self.branch_tip(target).map(|tip| BranchInfo { name, tip }))
215 .collect()
216 }
217
218 fn branch_tip(&self, target: Oid) -> Result<BranchTip, GitError> {
219 let object = self
220 .git()
221 .find_object(target.object_id())
222 .map_err(backend)?;
223 match object.kind {
224 gix::object::Kind::Commit => self
225 .find_commit(target)
226 .map(|commit| BranchTip::Commit(Box::new(commit))),
227 gix::object::Kind::Tag => {
228 let tag = object.try_into_tag().map_err(backend)?;
229 let decoded = tag.decode().map_err(backend)?;
230 let created_at = decoded
231 .tagger()
232 .map_err(|error| GitError::Decode(error.to_string()))?
233 .map(identity)
234 .transpose()?
235 .map(|tagger| tagger.time)
236 .unwrap_or(UnixSeconds::new(0));
237 Ok(BranchTip::Opaque {
238 id: target,
239 message: decoded.message.to_string(),
240 created_at,
241 })
242 }
243 _ => Ok(BranchTip::Opaque {
244 id: target,
245 message: String::new(),
246 created_at: UnixSeconds::new(0),
247 }),
248 }
249 }
250
251 pub fn tag_list(&self) -> Result<Vec<TagInfo>, GitError> {
252 self.tags()?
253 .into_iter()
254 .filter_map(|record| record.name.tag_name().map(|name| (name, record.target)))
255 .map(|(name, target)| self.tag_info(name, target))
256 .collect()
257 }
258
259 fn tag_info(&self, name: TagName, target: Oid) -> Result<TagInfo, GitError> {
260 let object = self
261 .git()
262 .find_object(target.object_id())
263 .map_err(backend)?;
264 match object.kind {
265 gix::object::Kind::Tag => {
266 let tag = object.try_into_tag().map_err(backend)?;
267 let decoded = tag.decode().map_err(backend)?;
268 let tagger = decoded
269 .tagger()
270 .map_err(|error| GitError::Decode(error.to_string()))?
271 .map(identity)
272 .transpose()?;
273 let created_at = tagger
274 .as_ref()
275 .map(|tagger| tagger.time)
276 .unwrap_or(UnixSeconds::new(0));
277 Ok(TagInfo {
278 name,
279 id: target,
280 created_at,
281 message: decoded.message.to_string(),
282 annotated: Some(AnnotatedTag {
283 tagger,
284 pgp_signature: decoded.pgp_signature.map(|signature| signature.to_string()),
285 target: Oid::from(decoded.target()),
286 }),
287 })
288 }
289 gix::object::Kind::Commit => {
290 let commit = self.find_commit(target)?;
291 Ok(TagInfo {
292 name,
293 id: target,
294 created_at: commit.committer.time,
295 message: commit.message,
296 annotated: None,
297 })
298 }
299 _ => Ok(TagInfo {
300 name,
301 id: target,
302 created_at: UnixSeconds::new(0),
303 message: String::new(),
304 annotated: None,
305 }),
306 }
307 }
308
309 fn dir_tree_id(
310 &self,
311 commit: Oid,
312 dir: Option<&RepoPath>,
313 ) -> Result<Option<gix::ObjectId>, GitError> {
314 let root = self.commit_tree(commit)?;
315 let Some(dir) = dir else {
316 return Ok(Some(root));
317 };
318 let tree = self.git().find_tree(root).map_err(backend)?;
319 match tree.lookup_entry_by_path(dir.as_str()).map_err(backend)? {
320 Some(entry) if entry.mode().is_tree() => Ok(Some(entry.object_id())),
321 _ => Ok(None),
322 }
323 }
324
325 pub(crate) fn root_tree(&self, commit: Oid) -> Result<gix::Tree<'_>, GitError> {
326 let root = self.commit_tree(commit)?;
327 self.git().find_tree(root).map_err(backend)
328 }
329
330 pub fn entry_at(&self, commit: Oid, path: &RepoPath) -> Result<Option<PathEntry>, GitError> {
331 let tree = self.root_tree(commit)?;
332 Ok(tree
333 .lookup_entry_by_path(path.as_str())
334 .map_err(backend)?
335 .map(|entry| PathEntry {
336 oid: Oid::from(entry.object_id()),
337 kind: map_kind(entry.mode().kind()),
338 }))
339 }
340
341 pub fn tree_entries_at(
342 &self,
343 commit: Oid,
344 path: Option<&RepoPath>,
345 ) -> Result<Option<Vec<SizedEntry>>, GitError> {
346 let Some(path) = path else {
347 let tree = self.root_tree(commit)?;
348 return self.sized_entries(&tree).map(Some);
349 };
350 match self.entry_at(commit, path)? {
351 None => Ok(None),
352 Some(entry) if entry.kind == EntryKind::Tree => self.tree_entries(entry.oid).map(Some),
353 Some(entry) if entry.kind == EntryKind::Commit => Ok(None),
354 Some(_) => Ok(Some(Vec::new())),
355 }
356 }
357
358 pub fn tree_entries(&self, tree: Oid) -> Result<Vec<SizedEntry>, GitError> {
359 let tree = self.git().find_tree(tree.object_id()).map_err(backend)?;
360 self.sized_entries(&tree)
361 }
362
363 fn sized_entries(&self, tree: &gix::Tree<'_>) -> Result<Vec<SizedEntry>, GitError> {
364 let decoded = tree
365 .decode()
366 .map_err(|error| GitError::Decode(error.to_string()))?;
367 decoded
368 .entries
369 .iter()
370 .map(|entry| {
371 let oid = Oid::from(entry.oid.to_owned());
372 let kind = map_kind(entry.mode.kind());
373 let size = match kind {
374 EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link => self
375 .git()
376 .try_find_header(oid.object_id())
377 .map_err(|error| GitError::Corrupt {
378 oid,
379 message: error.to_string(),
380 })?
381 .map(|header| header.size())
382 .unwrap_or(0),
383 EntryKind::Tree | EntryKind::Commit => 0,
384 };
385 Ok(SizedEntry {
386 name: entry.filename.to_string(),
387 oid,
388 kind,
389 size,
390 })
391 })
392 .collect()
393 }
394
395 fn entry_oids_of_tree(&self, tree: gix::ObjectId) -> Result<HashMap<String, Oid>, GitError> {
396 let tree = self.git().find_tree(tree).map_err(backend)?;
397 let decoded = tree
398 .decode()
399 .map_err(|error| GitError::Decode(error.to_string()))?;
400 Ok(decoded
401 .entries
402 .iter()
403 .map(|entry| (entry.filename.to_string(), Oid::from(entry.oid.to_owned())))
404 .collect())
405 }
406
407 pub fn last_commits(
408 &self,
409 start: Oid,
410 dir: Option<&RepoPath>,
411 names: &[String],
412 deadline: Option<Instant>,
413 ) -> Result<HashMap<String, LastCommit>, GitError> {
414 let mut pending: HashSet<&str> = names.iter().map(String::as_str).collect();
415 let mut attributed = HashMap::new();
416 let mut dir_trees: HashMap<Oid, Option<gix::ObjectId>> = HashMap::new();
417 let mut dir_tree_of = |commit: Oid| -> Result<Option<gix::ObjectId>, GitError> {
418 match dir_trees.get(&commit) {
419 Some(known) => Ok(*known),
420 None => {
421 let id = self.dir_tree_id(commit, dir)?;
422 dir_trees.insert(commit, id);
423 Ok(id)
424 }
425 }
426 };
427
428 let mut step = |oid: Result<Oid, GitError>| -> Result<bool, GitError> {
429 if pending.is_empty() || deadline.is_some_and(|deadline| Instant::now() >= deadline) {
430 return Ok(false);
431 }
432 let oid = oid?;
433 let commit = self.find_commit(oid)?;
434 if commit.parents.len() > 1 {
435 return Ok(true);
436 }
437 let here_tree = dir_tree_of(oid)?;
438 let parent_tree = commit
439 .parents
440 .first()
441 .copied()
442 .map(&mut dir_tree_of)
443 .transpose()?
444 .flatten();
445 if here_tree == parent_tree || here_tree.is_none() {
446 return Ok(true);
447 }
448 let here = self.entry_oids_of_tree(here_tree.expect("checked above"))?;
449 let parent = parent_tree
450 .map(|tree| self.entry_oids_of_tree(tree))
451 .transpose()?
452 .unwrap_or_default();
453 let changed: Vec<String> = pending
454 .iter()
455 .filter(|name| here.contains_key(**name) && here.get(**name) != parent.get(**name))
456 .map(|name| name.to_string())
457 .collect();
458 changed.iter().for_each(|name| {
459 pending.remove(name.as_str());
460 });
461 changed.into_iter().for_each(|name| {
462 attributed.insert(
463 name,
464 LastCommit {
465 id: oid,
466 subject: subject_line(&commit.message),
467 time: commit.author.time,
468 },
469 );
470 });
471 Ok(true)
472 };
473
474 let flow =
475 self.walk_from(start, None)?
476 .try_for_each(|oid| -> ControlFlow<Option<GitError>> {
477 match step(oid) {
478 Ok(true) => ControlFlow::Continue(()),
479 Ok(false) => ControlFlow::Break(None),
480 Err(error) => ControlFlow::Break(Some(error)),
481 }
482 });
483 match flow {
484 ControlFlow::Break(Some(error)) => Err(error),
485 _ => Ok(attributed),
486 }
487 }
488
489 pub fn submodules(&self, commit: Oid) -> Result<Vec<Submodule>, GitError> {
490 let gitmodules = RepoPath::new(".gitmodules").expect("literal path is well-formed");
491 let Some(entry) = self.entry_at(commit, &gitmodules)? else {
492 return Ok(Vec::new());
493 };
494 if !entry.kind.is_file() {
495 return Ok(Vec::new());
496 }
497 let raw = self.read_blob(entry.oid)?;
498 Ok(parse_gitmodules(raw.as_bstr().to_str_lossy().as_ref()))
499 }
500}
501
502fn subject_line(message: &str) -> String {
503 message.lines().next().unwrap_or_default().to_string()
504}
505
506fn strip_config_comment(line: &str) -> String {
507 let flow = line.chars().try_fold(
508 (String::new(), false, false),
509 |(mut out, quoted, escaped), ch| match (escaped, quoted, ch) {
510 (false, false, '#' | ';') => ControlFlow::Break(out),
511 (false, _, '"') => {
512 out.push(ch);
513 ControlFlow::Continue((out, !quoted, false))
514 }
515 (false, _, '\\') => {
516 out.push(ch);
517 ControlFlow::Continue((out, quoted, true))
518 }
519 _ => {
520 out.push(ch);
521 ControlFlow::Continue((out, quoted, false))
522 }
523 },
524 );
525 match flow {
526 ControlFlow::Continue((out, _, _)) | ControlFlow::Break(out) => out,
527 }
528}
529
530fn unquote_config_value(raw: &str) -> String {
531 raw.trim()
532 .chars()
533 .fold((String::new(), false), |(mut out, escaped), ch| {
534 match (escaped, ch) {
535 (true, 'n') => {
536 out.push('\n');
537 (out, false)
538 }
539 (true, 't') => {
540 out.push('\t');
541 (out, false)
542 }
543 (true, 'b') => {
544 out.push('\u{0008}');
545 (out, false)
546 }
547 (true, other) => {
548 out.push(other);
549 (out, false)
550 }
551 (false, '\\') => (out, true),
552 (false, '"') => (out, false),
553 (false, other) => {
554 out.push(other);
555 (out, false)
556 }
557 }
558 })
559 .0
560}
561
562fn parse_gitmodules(content: &str) -> Vec<Submodule> {
563 struct Partial {
564 name: String,
565 path: Option<String>,
566 url: Option<String>,
567 branch: Option<String>,
568 }
569 let finish = |partial: Partial| -> Option<Submodule> {
570 Some(Submodule {
571 name: partial.name,
572 path: RepoPath::new(partial.path?).ok()?,
573 url: partial.url?,
574 branch: partial
575 .branch
576 .and_then(|branch| BranchName::new(branch).ok()),
577 })
578 };
579 let (mut sections, last) = content.lines().map(strip_config_comment).fold(
580 (Vec::new(), None::<Partial>),
581 |(mut done, current), line| {
582 let line = line.trim();
583 if let Some(rest) = line.strip_prefix("[submodule \"")
584 && let Some(name) = rest.strip_suffix("\"]")
585 {
586 done.extend(current.and_then(&finish));
587 return (
588 done,
589 Some(Partial {
590 name: name.to_string(),
591 path: None,
592 url: None,
593 branch: None,
594 }),
595 );
596 }
597 if line.starts_with('[') {
598 done.extend(current.and_then(&finish));
599 return (done, None);
600 }
601 let current = current.map(|mut partial| {
602 if let Some((key, value)) = line.split_once('=') {
603 let value = unquote_config_value(value);
604 match key.trim() {
605 "path" => partial.path = Some(value),
606 "url" => partial.url = Some(value),
607 "branch" => partial.branch = Some(value),
608 _ => {}
609 }
610 }
611 partial
612 });
613 (done, current)
614 },
615 );
616 sections.extend(last.and_then(&finish));
617 sections
618}