This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-git / src / reads.rs
21 kB 618 lines
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}