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 / repo.rs
54 kB 1637 lines
1use std::collections::{BTreeSet, HashMap, HashSet}; 2use std::path::{Path, PathBuf}; 3use std::sync::atomic::{AtomicU64, Ordering}; 4use std::sync::{Arc, Mutex, OnceLock}; 5 6use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog}; 7use gix::refs::{FullName, Target}; 8use knot_cache::{Cache, Moka, Weight}; 9use knot_types::{ 10 BranchName, KnotId, ObjectFormat, Oid, RefName, RefTransition, RepoDid, UnixSeconds, 11}; 12 13use crate::error::GitError; 14use crate::objects::{Haves, PackBudget, Walked, Wants}; 15 16const RESERVED_PREFIX: &str = "refs/cobs/"; 17const CHECKPOINT_PREFIX: &str = "refs/cob-checkpoints/"; 18const HIDDEN_PREFIX: &str = "refs/hidden/"; 19const REFLOG_COMMITTER_NAME: &str = "knot"; 20const REFLOG_COMMITTER_EMAIL: &str = "noreply@knot"; 21const HEADS_PREFIX: &str = "refs/heads/"; 22const TAGS_PREFIX: &str = "refs/tags/"; 23const MAX_SYMREF_DEPTH: usize = 5; 24const ADVERT_BYTES_PER_REF: u64 = 128; 25 26fn tuned(mut git: gix::Repository) -> gix::Repository { 27 git.object_cache_size_if_unset(knot_resource::object_cache_bytes()); 28 pin_reflog_identity(&mut git); 29 git 30} 31 32fn assembled(git: gix::Repository, path: PathBuf) -> Repo { 33 Repo { 34 git: tuned(git), 35 path, 36 commit_graph: OnceLock::new(), 37 } 38} 39 40fn pin_reflog_identity(git: &mut gix::Repository) { 41 use gix::config::tree::{Committer, Core}; 42 let mut config = git.config_snapshot_mut(); 43 let pinned = config.set_value(&Core::LOG_ALL_REF_UPDATES, "true").is_ok() 44 && config 45 .set_value(&Committer::NAME, REFLOG_COMMITTER_NAME) 46 .is_ok() 47 && config 48 .set_value(&Committer::EMAIL, REFLOG_COMMITTER_EMAIL) 49 .is_ok(); 50 if pinned { 51 let _ = config.commit(); 52 } 53} 54 55knot_types::scalar_newtype! { 56 struct RefEpoch(u64); 57 struct RefGeneration(u64); 58} 59 60struct RefState { 61 lock: Mutex<()>, 62 generation: AtomicU64, 63 epoch: RefEpoch, 64} 65 66type RefRegistry = Mutex<HashMap<PathBuf, Arc<RefState>>>; 67 68fn ref_registry() -> &'static RefRegistry { 69 static REGISTRY: OnceLock<RefRegistry> = OnceLock::new(); 70 REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) 71} 72 73fn next_epoch() -> RefEpoch { 74 static EPOCH: AtomicU64 = AtomicU64::new(0); 75 RefEpoch::new(EPOCH.fetch_add(1, Ordering::Relaxed)) 76} 77 78fn ref_state(git_dir: &Path) -> Arc<RefState> { 79 let mut states = ref_registry() 80 .lock() 81 .unwrap_or_else(|poisoned| poisoned.into_inner()); 82 Arc::clone(states.entry(git_dir.to_path_buf()).or_insert_with(|| { 83 Arc::new(RefState { 84 lock: Mutex::new(()), 85 generation: AtomicU64::new(0), 86 epoch: next_epoch(), 87 }) 88 })) 89} 90 91fn forget_ref_state(git_dir: &Path) { 92 ref_registry() 93 .lock() 94 .unwrap_or_else(|poisoned| poisoned.into_inner()) 95 .remove(git_dir); 96} 97 98type AdvertCache = Moka<(RefEpoch, RefGeneration), Arc<Vec<RefRecord>>>; 99 100fn advert_cache() -> &'static Arc<AdvertCache> { 101 static CACHE: OnceLock<Arc<AdvertCache>> = OnceLock::new(); 102 CACHE.get_or_init(|| { 103 let cache = Arc::new(Moka::by_weight( 104 Weight::new(knot_resource::advert_cache_bytes()), 105 |refs: &Arc<Vec<RefRecord>>| { 106 Weight::new( 107 (refs.len() as u64) 108 .max(1) 109 .saturating_mul(ADVERT_BYTES_PER_REF), 110 ) 111 }, 112 )); 113 knot_cache::register(&cache); 114 cache 115 }) 116} 117 118fn safe_component(part: &str) -> bool { 119 !matches!(part, "." | "..") && !part.contains(['/', '\\', '\0']) 120} 121 122pub fn repo_shard(did: &RepoDid) -> Result<PathBuf, GitError> { 123 shard_components(did.as_str()) 124} 125 126pub fn knot_shard(knot: &KnotId) -> Result<PathBuf, GitError> { 127 shard_components(knot.as_str()) 128} 129 130fn shard_components(did: &str) -> Result<PathBuf, GitError> { 131 let mut parts = did.splitn(3, ':'); 132 parts.next(); 133 let method = parts.next().unwrap_or("did"); 134 let msid = parts.next().unwrap_or_default(); 135 let split = msid 136 .char_indices() 137 .nth(2) 138 .map(|(index, _)| index) 139 .unwrap_or(msid.len()); 140 let (shard, remainder) = msid.split_at(split); 141 if [method, shard, remainder] 142 .iter() 143 .any(|part| !safe_component(part)) 144 { 145 return Err(GitError::UnsafeRepoDid(did.to_string())); 146 } 147 Ok(PathBuf::from(method).join(shard).join(remainder)) 148} 149 150#[derive(Debug, Clone)] 151pub struct Layout { 152 scan_path: PathBuf, 153 head: RefName, 154 reserved: Option<PathBuf>, 155 object_format: ObjectFormat, 156} 157 158fn default_head() -> RefName { 159 RefName::new(format!("{HEADS_PREFIX}main")).expect("refs/heads/main is valid ref name") 160} 161 162impl Layout { 163 pub fn new(scan_path: impl Into<PathBuf>) -> Self { 164 Self { 165 scan_path: scan_path.into(), 166 head: default_head(), 167 reserved: None, 168 object_format: ObjectFormat::default(), 169 } 170 } 171 172 pub fn with_default_branch(mut self, branch: BranchName) -> Self { 173 self.head = branch.head_ref(); 174 self 175 } 176 177 pub fn with_object_format(mut self, object_format: ObjectFormat) -> Self { 178 self.object_format = object_format; 179 self 180 } 181 182 pub fn reserving_meta(mut self, knot: &KnotId) -> Result<Self, GitError> { 183 let reserved = self.meta_path(knot)?; 184 self.reserved = Some(reserved); 185 Ok(self) 186 } 187 188 pub fn repo_path(&self, did: &RepoDid) -> Result<PathBuf, GitError> { 189 Ok(self.scan_path.join(shard_components(did.as_str())?)) 190 } 191 192 pub fn scratch_dir(&self) -> &Path { 193 &self.scan_path 194 } 195 196 pub fn meta_path(&self, knot: &KnotId) -> Result<PathBuf, GitError> { 197 Ok(self.scan_path.join(shard_components(knot.as_str())?)) 198 } 199 200 pub fn guarded_path(&self, did: &RepoDid) -> Result<PathBuf, GitError> { 201 let path = self.repo_path(did)?; 202 match &self.reserved { 203 Some(reserved) if *reserved == path => { 204 Err(GitError::ReservedDid(did.as_str().to_string())) 205 } 206 _ => Ok(path), 207 } 208 } 209 210 pub fn open(&self, did: &RepoDid) -> Result<Repo, GitError> { 211 Repo::open(self.guarded_path(did)?) 212 } 213 214 pub fn create(&self, did: &RepoDid) -> Result<Repo, GitError> { 215 self.init_repo(self.guarded_path(did)?) 216 } 217 218 pub fn remove(&self, did: &RepoDid) -> Result<(), GitError> { 219 let path = self.guarded_path(did)?; 220 if let Ok(repo) = Repo::open(&path) { 221 forget_ref_state(repo.git.git_dir()); 222 } 223 match std::fs::remove_dir_all(&path) { 224 Ok(()) => Ok(()), 225 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), 226 Err(error) => Err(GitError::Remove { 227 path, 228 message: error.to_string(), 229 }), 230 } 231 } 232 233 pub fn bootstrap_meta(&self, knot: &KnotId) -> Result<Repo, GitError> { 234 init_bare_idempotent(self.meta_path(knot)?) 235 } 236 237 fn init_repo(&self, path: PathBuf) -> Result<Repo, GitError> { 238 let repo = Repo::create_with_format(path, self.object_format)?; 239 repo.set_head(&self.head)?; 240 Ok(repo) 241 } 242} 243 244pub(crate) fn init_bare_with_format( 245 path: &Path, 246 format: ObjectFormat, 247) -> Result<gix::Repository, String> { 248 let object_hash = (format != ObjectFormat::SHA1).then(|| format.kind()); 249 gix::ThreadSafeRepository::init_opts( 250 path, 251 gix::create::Kind::Bare, 252 gix::create::Options { 253 object_hash, 254 ..Default::default() 255 }, 256 gix::open::Options::default(), 257 ) 258 .map(Into::into) 259 .map_err(|error| error.to_string()) 260} 261 262fn staging_path(parent: &Path) -> PathBuf { 263 static COUNTER: AtomicU64 = AtomicU64::new(0); 264 let nonce = COUNTER.fetch_add(1, Ordering::Relaxed); 265 parent.join(format!(".knot-staging.{}.{}", std::process::id(), nonce)) 266} 267 268fn init_bare_idempotent(path: PathBuf) -> Result<Repo, GitError> { 269 if let Ok(git) = gix::open(&path) { 270 return Ok(assembled(git, path)); 271 } 272 let parent = path.parent().ok_or_else(|| GitError::Create { 273 path: path.clone(), 274 message: "meta path has no parent directory".to_string(), 275 })?; 276 std::fs::create_dir_all(parent).map_err(|error| GitError::Create { 277 path: path.clone(), 278 message: error.to_string(), 279 })?; 280 let staging = staging_path(parent); 281 let _ = std::fs::remove_dir_all(&staging); 282 gix::init_bare(&staging).map_err(|error| GitError::Create { 283 path: staging.clone(), 284 message: error.to_string(), 285 })?; 286 match std::fs::rename(&staging, &path) { 287 Ok(()) => Repo::open(path), 288 Err(_) => { 289 let _ = std::fs::remove_dir_all(&staging); 290 Repo::open(path) 291 } 292 } 293} 294 295pub struct Repo { 296 git: gix::Repository, 297 path: PathBuf, 298 commit_graph: OnceLock<Option<gix::commitgraph::Graph>>, 299} 300 301#[derive(Debug, Clone, PartialEq, Eq)] 302pub struct RefRecord { 303 pub name: RefName, 304 pub target: Oid, 305} 306 307#[derive(Debug, Clone, PartialEq, Eq)] 308pub struct PackHash(String); 309 310impl PackHash { 311 pub fn new(value: impl Into<String>) -> Option<Self> { 312 let value = value.into(); 313 (matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())) 314 .then_some(Self(value)) 315 } 316 317 pub fn as_str(&self) -> &str { 318 &self.0 319 } 320} 321 322#[derive(Debug, Clone, PartialEq, Eq)] 323pub struct PackfileUrl(String); 324 325impl PackfileUrl { 326 pub fn new(value: impl Into<String>) -> Option<Self> { 327 let value = value.into(); 328 let authority = value 329 .strip_prefix("https://") 330 .or_else(|| value.strip_prefix("http://")) 331 .filter(|rest| !rest.is_empty() && !rest.starts_with('/')); 332 (authority.is_some() && !value.chars().any(|c| c.is_whitespace() || c.is_control())) 333 .then_some(Self(value)) 334 } 335 336 pub fn as_str(&self) -> &str { 337 &self.0 338 } 339} 340 341#[derive(Debug, Clone, PartialEq, Eq)] 342pub struct PackfileUri { 343 pub oid: Oid, 344 pub pack_hash: PackHash, 345 pub uri: PackfileUrl, 346} 347 348#[derive(Debug, Clone, PartialEq, Eq)] 349pub struct HeadRef { 350 pub name: RefName, 351 pub target: Oid, 352} 353 354#[derive(Debug, Clone, PartialEq, Eq)] 355pub struct ReflogUpdate { 356 pub name: RefName, 357 pub old: Option<Oid>, 358 pub new: Oid, 359 pub seconds: UnixSeconds, 360} 361 362#[derive(Debug, Clone, PartialEq, Eq)] 363pub enum RefUpdate { 364 Create { name: RefName, new: Oid }, 365 Update { name: RefName, old: Oid, new: Oid }, 366 Delete { name: RefName, old: Oid }, 367} 368 369impl RefUpdate { 370 pub fn name(&self) -> &RefName { 371 match self { 372 RefUpdate::Create { name, .. } 373 | RefUpdate::Update { name, .. } 374 | RefUpdate::Delete { name, .. } => name, 375 } 376 } 377 378 pub fn transition(&self) -> RefTransition { 379 match self { 380 RefUpdate::Create { new, .. } => RefTransition::Create { new: *new }, 381 RefUpdate::Update { old, new, .. } => RefTransition::Advance { 382 old: *old, 383 new: *new, 384 }, 385 RefUpdate::Delete { old, .. } => RefTransition::Delete { old: *old }, 386 } 387 } 388} 389 390pub fn is_reserved(name: &RefName) -> bool { 391 screens_reserved(name.as_str()) 392} 393 394pub fn screens_reserved(raw: &str) -> bool { 395 raw.starts_with(RESERVED_PREFIX) || raw.starts_with(CHECKPOINT_PREFIX) 396} 397 398fn is_hidden(name: &RefName) -> bool { 399 name.as_str().starts_with(HIDDEN_PREFIX) 400} 401 402pub fn is_branch(name: &RefName) -> bool { 403 name.as_str().starts_with(HEADS_PREFIX) 404} 405 406pub fn is_public_ref(name: &RefName) -> bool { 407 !is_reserved(name) && !is_hidden(name) 408} 409 410#[derive(Clone, Copy, Debug, PartialEq, Eq)] 411pub enum AdvertScope { 412 Upload, 413 Receive, 414} 415 416impl AdvertScope { 417 fn config_key(self) -> &'static str { 418 match self { 419 AdvertScope::Upload => "uploadpack.hideRefs", 420 AdvertScope::Receive => "receive.hideRefs", 421 } 422 } 423} 424 425fn ref_hidden_by(name: &RefName, patterns: &[String]) -> bool { 426 patterns.iter().any(|pattern| { 427 name.as_str() == pattern || name.as_str().starts_with(&format!("{pattern}/")) 428 }) 429} 430 431pub(crate) fn fsync_if_present(path: &Path) -> Result<(), GitError> { 432 knot_resource::fsync_path(path).map_err(|error| GitError::Fsync { 433 path: error.path, 434 message: error.source.to_string(), 435 }) 436} 437 438impl Repo { 439 pub fn open(path: impl Into<PathBuf>) -> Result<Repo, GitError> { 440 let path = path.into(); 441 let git = gix::open(&path).map_err(|error| GitError::Open { 442 path: path.clone(), 443 message: error.to_string(), 444 })?; 445 Ok(assembled(git, path)) 446 } 447 448 pub fn create(path: impl Into<PathBuf>) -> Result<Repo, GitError> { 449 Self::create_with_format(path, ObjectFormat::default()) 450 } 451 452 pub fn create_with_format( 453 path: impl Into<PathBuf>, 454 format: ObjectFormat, 455 ) -> Result<Repo, GitError> { 456 let path = path.into(); 457 if path.exists() { 458 return Err(GitError::AlreadyExists(path)); 459 } 460 if let Some(parent) = path.parent() { 461 std::fs::create_dir_all(parent).map_err(|error| GitError::Create { 462 path: path.clone(), 463 message: error.to_string(), 464 })?; 465 } 466 let git = init_bare_with_format(&path, format).map_err(|message| GitError::Create { 467 path: path.clone(), 468 message, 469 })?; 470 Ok(assembled(git, path)) 471 } 472 473 pub fn git(&self) -> &gix::Repository { 474 &self.git 475 } 476 477 pub fn object_format(&self) -> ObjectFormat { 478 ObjectFormat::from_kind(self.git.object_hash()) 479 } 480 481 pub fn path(&self) -> &Path { 482 &self.path 483 } 484 485 pub fn objects_dir(&self) -> PathBuf { 486 self.git.git_dir().join("objects") 487 } 488 489 pub fn references(&self) -> Result<Vec<RefRecord>, GitError> { 490 self.git 491 .references() 492 .map_err(|error| GitError::Backend(error.to_string()))? 493 .all() 494 .map_err(|error| GitError::Backend(error.to_string()))? 495 .filter_map(|reference| { 496 let reference = match reference { 497 Ok(reference) => reference, 498 Err(error) => return Some(Err(GitError::Backend(error.to_string()))), 499 }; 500 let raw = reference.name().as_bstr().to_string(); 501 let target = Oid::from(self.direct_target(&reference, MAX_SYMREF_DEPTH)?); 502 let name = RefName::new(raw).ok()?; 503 Some(Ok(RefRecord { name, target })) 504 }) 505 .collect() 506 } 507 508 pub fn reflog_updates_since(&self, since_seconds: UnixSeconds) -> Vec<ReflogUpdate> { 509 let Ok(references) = self.git.references() else { 510 return Vec::new(); 511 }; 512 let Ok(all) = references.all() else { 513 return Vec::new(); 514 }; 515 all.filter_map(Result::ok) 516 .filter(|reference| { 517 let name = reference.name().as_bstr().to_string(); 518 name.starts_with(HEADS_PREFIX) || name.starts_with(TAGS_PREFIX) 519 }) 520 .flat_map(|reference| self.ref_reflog_since(&reference, since_seconds)) 521 .collect() 522 } 523 524 fn ref_reflog_since( 525 &self, 526 reference: &gix::Reference<'_>, 527 since_seconds: UnixSeconds, 528 ) -> Vec<ReflogUpdate> { 529 let Ok(name) = RefName::new(reference.name().as_bstr().to_string()) else { 530 return Vec::new(); 531 }; 532 let mut platform = reference.log_iter(); 533 let Ok(Some(reverse)) = platform.rev() else { 534 return Vec::new(); 535 }; 536 reverse 537 .filter_map(Result::ok) 538 .take_while(|line| UnixSeconds::new(line.signature.time.seconds) >= since_seconds) 539 .filter_map(|line| { 540 (!line.new_oid.is_null()).then(|| ReflogUpdate { 541 name: name.clone(), 542 old: Some(line.previous_oid) 543 .filter(|previous| !previous.is_null()) 544 .map(Oid::from), 545 new: Oid::from(line.new_oid), 546 seconds: UnixSeconds::new(line.signature.time.seconds), 547 }) 548 }) 549 .collect() 550 } 551 552 pub fn find_ref(&self, name: &RefName) -> Result<Option<Oid>, GitError> { 553 match self 554 .git 555 .try_find_reference(name.as_str()) 556 .map_err(|error| GitError::Backend(error.to_string()))? 557 { 558 Some(reference) => Ok(self 559 .direct_target(&reference, MAX_SYMREF_DEPTH) 560 .map(Oid::from)), 561 None => Ok(None), 562 } 563 } 564 565 fn direct_target(&self, reference: &gix::Reference<'_>, depth: usize) -> Option<gix::ObjectId> { 566 match (depth, reference.follow()) { 567 (_, None) => reference.try_id().map(|id| id.detach()), 568 (0, Some(_)) => None, 569 (_, Some(Ok(next))) => self.direct_target(&next, depth - 1), 570 (_, Some(Err(_))) => None, 571 } 572 } 573 574 pub(crate) fn commit_graph(&self) -> Option<&gix::commitgraph::Graph> { 575 self.commit_graph 576 .get_or_init(|| self.git.commit_graph().ok()) 577 .as_ref() 578 } 579 580 pub fn with_ref_lock<R>(&self, body: impl FnOnce() -> R) -> R { 581 let state = ref_state(self.git.git_dir()); 582 let _guard = state 583 .lock 584 .lock() 585 .unwrap_or_else(|poisoned| poisoned.into_inner()); 586 body() 587 } 588 589 fn locked_value<R>(&self, body: impl FnOnce() -> R) -> R { 590 self.with_ref_lock(|| { 591 let outcome = body(); 592 let state = ref_state(self.git.git_dir()); 593 let previous = RefGeneration::new(state.generation.fetch_add(1, Ordering::SeqCst)); 594 advert_cache().invalidate(&(state.epoch, previous)); 595 outcome 596 }) 597 } 598 599 pub(crate) fn locked<R>( 600 &self, 601 body: impl FnOnce() -> Result<R, GitError>, 602 ) -> Result<R, GitError> { 603 self.locked_value(body) 604 } 605 606 pub fn with_ref_txn<R>(&self, body: impl FnOnce(&RefTxn<'_>) -> R) -> R { 607 self.locked_value(|| body(&RefTxn { repo: self })) 608 } 609 610 pub fn set_head(&self, target: &RefName) -> Result<(), GitError> { 611 self.locked(|| self.set_head_locked(target)) 612 } 613 614 pub fn set_head_sealed<R>( 615 &self, 616 target: &RefName, 617 seal: impl FnOnce() -> R, 618 ) -> Result<R, GitError> { 619 self.locked(|| { 620 self.set_head_locked(target)?; 621 Ok(seal()) 622 }) 623 } 624 625 fn set_head_locked(&self, target: &RefName) -> Result<(), GitError> { 626 let raw = target.as_str(); 627 let target_name = FullName::try_from(raw).map_err(|error| GitError::Reference { 628 name: raw.to_string(), 629 message: error.to_string(), 630 })?; 631 let edit = RefEdit { 632 change: Change::Update { 633 log: LogChange { 634 mode: RefLog::AndReference, 635 force_create_reflog: false, 636 message: "knot set HEAD".into(), 637 }, 638 expected: PreviousValue::Any, 639 new: Target::Symbolic(target_name), 640 }, 641 name: FullName::try_from("HEAD").map_err(|error| GitError::Reference { 642 name: "HEAD".to_string(), 643 message: error.to_string(), 644 })?, 645 deref: false, 646 }; 647 self.git 648 .edit_reference(edit) 649 .map_err(|error| GitError::Reference { 650 name: "HEAD".to_string(), 651 message: error.to_string(), 652 })?; 653 let git_dir = self.git.git_dir(); 654 fsync_if_present(&git_dir.join("HEAD"))?; 655 fsync_if_present(git_dir) 656 } 657 658 fn persist_refs<'a>( 659 &self, 660 mut names: impl Iterator<Item = &'a RefName>, 661 ) -> Result<(), GitError> { 662 let git_dir = self.git.git_dir(); 663 let mut dirs = BTreeSet::from([git_dir.to_path_buf()]); 664 names.try_for_each(|name| -> Result<(), GitError> { 665 let ref_path = git_dir.join(name.as_str()); 666 fsync_if_present(&ref_path)?; 667 std::iter::successors(ref_path.parent(), |path| path.parent()) 668 .take_while(|path| path.starts_with(git_dir)) 669 .for_each(|path| { 670 dirs.insert(path.to_path_buf()); 671 }); 672 Ok(()) 673 })?; 674 fsync_if_present(&git_dir.join("packed-refs"))?; 675 dirs.iter().try_for_each(|dir| fsync_if_present(dir)) 676 } 677 678 pub fn origin_url(&self) -> Option<String> { 679 self.git 680 .config_snapshot() 681 .string("remote.origin.url") 682 .map(|value| value.to_string()) 683 } 684 685 pub fn set_origin_url(&self, url: &str) -> Result<(), GitError> { 686 let path = self.git.git_dir().join("config"); 687 let report = |message: String| GitError::Config { 688 path: path.clone(), 689 message, 690 }; 691 let mut file = 692 gix::config::File::from_path_no_includes(path.clone(), gix::config::Source::Local) 693 .map_err(|error| report(error.to_string()))?; 694 file.set_raw_value_by( 695 "remote", 696 Some(gix::bstr::BStr::new("origin")), 697 "url", 698 gix::bstr::BStr::new(url), 699 ) 700 .map_err(|error| report(error.to_string()))?; 701 knot_resource::atomic_write(&path, knot_resource::FileMode::Inherited, |out| { 702 file.write_to(out) 703 .map_err(|error| report(error.to_string())) 704 })?; 705 fsync_if_present(self.git.git_dir()) 706 } 707 708 pub fn branches(&self) -> Result<Vec<RefRecord>, GitError> { 709 self.references().map(|records| { 710 records 711 .into_iter() 712 .filter(|record| record.name.as_str().starts_with(HEADS_PREFIX)) 713 .collect() 714 }) 715 } 716 717 pub fn tags(&self) -> Result<Vec<RefRecord>, GitError> { 718 self.references().map(|records| { 719 records 720 .into_iter() 721 .filter(|record| record.name.as_str().starts_with(TAGS_PREFIX)) 722 .collect() 723 }) 724 } 725 726 pub fn advertised_refs(&self) -> Result<Arc<Vec<RefRecord>>, GitError> { 727 let state = ref_state(self.git.git_dir()); 728 let key = ( 729 state.epoch, 730 RefGeneration::new(state.generation.load(Ordering::SeqCst)), 731 ); 732 advert_cache() 733 .get_or_try_insert_with(key, || self.public_refs().map(Arc::new)) 734 .map_err(|error: Arc<GitError>| GitError::Backend(error.to_string())) 735 } 736 737 fn public_refs(&self) -> Result<Vec<RefRecord>, GitError> { 738 self.references().map(|records| { 739 records 740 .into_iter() 741 .filter(|record| is_public_ref(&record.name)) 742 .collect() 743 }) 744 } 745 746 pub fn advertised_refs_for(&self, scope: AdvertScope) -> Result<Vec<RefRecord>, GitError> { 747 let patterns = self.hidden_ref_patterns(scope); 748 let base = self.advertised_refs()?; 749 if patterns.is_empty() { 750 return Ok(base.to_vec()); 751 } 752 Ok(base 753 .iter() 754 .filter(|record| !ref_hidden_by(&record.name, &patterns)) 755 .cloned() 756 .collect()) 757 } 758 759 pub fn blob_packfile_uris(&self) -> Vec<PackfileUri> { 760 let snapshot = self.git.config_snapshot(); 761 snapshot 762 .strings("uploadpack.blobPackfileUri") 763 .into_iter() 764 .flatten() 765 .filter_map(|value| { 766 let text = value.to_string(); 767 let mut parts = text.split_whitespace(); 768 let oid = Oid::from_hex(parts.next()?).ok()?; 769 let pack_hash = PackHash::new(parts.next()?)?; 770 let uri = PackfileUrl::new(parts.next()?)?; 771 Some(PackfileUri { 772 oid, 773 pack_hash, 774 uri, 775 }) 776 }) 777 .collect() 778 } 779 780 fn hidden_ref_patterns(&self, scope: AdvertScope) -> Vec<String> { 781 let snapshot = self.git.config_snapshot(); 782 ["transfer.hideRefs", scope.config_key()] 783 .into_iter() 784 .filter_map(|key| snapshot.strings(key)) 785 .flatten() 786 .map(|value| value.to_string()) 787 .collect() 788 } 789 790 pub fn head(&self) -> Option<HeadRef> { 791 let target = self.git.head_id().ok()?.detach(); 792 let raw = self.git.head_name().ok()??.as_bstr().to_string(); 793 let name = RefName::new(raw).ok()?; 794 Some(HeadRef { 795 name, 796 target: Oid::from(target), 797 }) 798 } 799 800 pub fn default_branch(&self) -> Option<RefName> { 801 let raw = self.git.head_name().ok()??.as_bstr().to_string(); 802 RefName::new(raw).ok() 803 } 804 805 pub fn contains(&self, oid: Oid) -> bool { 806 self.git.has_object(oid.object_id()) 807 } 808 809 pub fn is_shallow(&self) -> bool { 810 self.git.is_shallow() 811 } 812 813 pub(crate) fn shallow_grafts(&self) -> Result<HashSet<gix::ObjectId>, GitError> { 814 Ok(self 815 .git 816 .shallow_commits() 817 .map_err(|error| GitError::Decode(format!("shallow file: {error}")))? 818 .map(|commits| commits.iter().copied().collect()) 819 .unwrap_or_default()) 820 } 821 822 pub fn rev_walk(&self, wants: Wants, haves: Haves) -> Result<Vec<Oid>, GitError> { 823 let mut walked = Walked::new(PackBudget::unbounded()); 824 self.rev_walk_each(wants, haves, &mut walked) 825 } 826 827 pub(crate) fn rev_walk_each( 828 &self, 829 wants: Wants<'_>, 830 haves: Haves<'_>, 831 walked: &mut Walked, 832 ) -> Result<Vec<Oid>, GitError> { 833 let present: Vec<gix::ObjectId> = haves 834 .as_slice() 835 .iter() 836 .copied() 837 .filter(|oid| self.contains(*oid)) 838 .map(Oid::object_id) 839 .collect(); 840 let mut probe = *walked; 841 let collected = self 842 .git 843 .rev_walk(wants.as_slice().iter().copied().map(Oid::object_id)) 844 .with_hidden(present.iter().copied()) 845 .all() 846 .ok() 847 .and_then(|walk| { 848 walk.map(|info| { 849 probe.tick()?; 850 info.map(|info| Oid::from(info.id)) 851 .map_err(|error| GitError::RevWalk(error.to_string())) 852 }) 853 .collect::<Result<Vec<Oid>, _>>() 854 .ok() 855 }); 856 match collected { 857 Some(commits) => { 858 *walked = probe; 859 Ok(commits) 860 } 861 None => self.rev_walk_lenient(wants.as_slice(), &present, walked), 862 } 863 } 864 865 fn rev_walk_lenient( 866 &self, 867 wants: &[Oid], 868 hidden_tips: &[gix::ObjectId], 869 walked: &mut Walked, 870 ) -> Result<Vec<Oid>, GitError> { 871 let mut hidden: HashSet<gix::ObjectId> = HashSet::new(); 872 let mut stack = hidden_tips.to_vec(); 873 while let Some(oid) = stack.pop() { 874 if hidden.insert(oid) 875 && let Ok((_, parents)) = self.commit_tree_and_parents(Oid::from(oid)) 876 { 877 stack.extend(parents); 878 } 879 } 880 let mut visited: HashSet<gix::ObjectId> = HashSet::new(); 881 let mut commits = Vec::new(); 882 let mut stack: Vec<gix::ObjectId> = wants.iter().copied().map(Oid::object_id).collect(); 883 while let Some(oid) = stack.pop() { 884 if hidden.contains(&oid) || !visited.insert(oid) { 885 continue; 886 } 887 walked.tick()?; 888 let (_, parents) = self.commit_tree_and_parents(Oid::from(oid))?; 889 commits.push(Oid::from(oid)); 890 stack.extend(parents); 891 } 892 Ok(commits) 893 } 894 895 fn ref_edit(update: &RefUpdate, via_head: bool) -> Result<RefEdit, GitError> { 896 let edited = if via_head { 897 "HEAD" 898 } else { 899 update.name().as_str() 900 }; 901 let name = FullName::try_from(edited).map_err(|error| GitError::Reference { 902 name: edited.to_string(), 903 message: error.to_string(), 904 })?; 905 let log = LogChange { 906 mode: RefLog::AndReference, 907 force_create_reflog: true, 908 message: "knot ref update".into(), 909 }; 910 let change = match update { 911 RefUpdate::Create { new, .. } => Change::Update { 912 log, 913 expected: PreviousValue::MustNotExist, 914 new: Target::Object(new.object_id()), 915 }, 916 RefUpdate::Update { old, new, .. } => Change::Update { 917 log, 918 expected: PreviousValue::MustExistAndMatch(Target::Object(old.object_id())), 919 new: Target::Object(new.object_id()), 920 }, 921 RefUpdate::Delete { old, .. } => Change::Delete { 922 expected: PreviousValue::MustExistAndMatch(Target::Object(old.object_id())), 923 log: RefLog::AndReference, 924 }, 925 }; 926 Ok(RefEdit { 927 change, 928 name, 929 deref: via_head, 930 }) 931 } 932 933 fn updates_head_branch(&self, update: &RefUpdate) -> bool { 934 !matches!(update, RefUpdate::Delete { .. }) 935 && self 936 .default_branch() 937 .is_some_and(|head| head.as_str() == update.name().as_str()) 938 } 939 940 pub fn update_ref(&self, update: &RefUpdate) -> Result<(), GitError> { 941 self.locked(|| self.update_ref_locked(update)) 942 } 943 944 pub fn update_ref_sealed<R>( 945 &self, 946 update: &RefUpdate, 947 seal: impl FnOnce() -> R, 948 ) -> Result<R, GitError> { 949 self.locked(|| { 950 self.update_ref_locked(update)?; 951 Ok(seal()) 952 }) 953 } 954 955 fn update_ref_locked(&self, update: &RefUpdate) -> Result<(), GitError> { 956 let raw = update.name().as_str().to_string(); 957 let via_head = self.updates_head_branch(update); 958 self.git 959 .edit_reference(Self::ref_edit(update, via_head)?) 960 .map_err(|error| GitError::Reference { 961 name: raw, 962 message: error.to_string(), 963 })?; 964 self.persist_refs(std::iter::once(update.name())) 965 } 966 967 pub fn update_refs(&self, updates: &[RefUpdate]) -> Result<(), GitError> { 968 self.locked(|| self.update_refs_locked(updates)) 969 } 970 971 pub fn update_refs_sealed<R>( 972 &self, 973 updates: &[RefUpdate], 974 seal: impl FnOnce() -> R, 975 ) -> Result<R, GitError> { 976 self.locked(|| { 977 self.update_refs_locked(updates)?; 978 Ok(seal()) 979 }) 980 } 981 982 fn reject_df_conflicts(&self, updates: &[RefUpdate]) -> Result<(), GitError> { 983 let creates: Vec<&str> = updates 984 .iter() 985 .filter_map(|update| match update { 986 RefUpdate::Create { name, .. } => Some(name.as_str()), 987 _ => None, 988 }) 989 .collect(); 990 if creates.is_empty() { 991 return Ok(()); 992 } 993 let deletes: HashSet<&str> = updates 994 .iter() 995 .filter_map(|update| match update { 996 RefUpdate::Delete { name, .. } => Some(name.as_str()), 997 _ => None, 998 }) 999 .collect(); 1000 let names: Vec<String> = self 1001 .references()? 1002 .iter() 1003 .map(|record| record.name.as_str().to_string()) 1004 .filter(|name| !deletes.contains(name.as_str())) 1005 .chain(creates.iter().copied().map(str::to_string)) 1006 .collect(); 1007 let name_set: HashSet<&str> = names.iter().map(String::as_str).collect(); 1008 names 1009 .iter() 1010 .find_map(|name| { 1011 name.match_indices('/') 1012 .map(|(at, _)| &name[..at]) 1013 .find(|ancestor| name_set.contains(ancestor)) 1014 .map(|ancestor| (ancestor.to_string(), name.clone())) 1015 }) 1016 .map_or(Ok(()), |(directory, leaf)| { 1017 Err(GitError::AtomicRefs(format!( 1018 "d/f conflict: {directory} blocks {leaf}" 1019 ))) 1020 }) 1021 } 1022 1023 fn update_refs_locked(&self, updates: &[RefUpdate]) -> Result<(), GitError> { 1024 self.reject_df_conflicts(updates)?; 1025 let edits = updates 1026 .iter() 1027 .map(|update| { 1028 let via_head = self.updates_head_branch(update); 1029 Self::ref_edit(update, via_head) 1030 }) 1031 .collect::<Result<Vec<_>, _>>()?; 1032 self.git 1033 .edit_references(edits) 1034 .map_err(|error| GitError::AtomicRefs(error.to_string()))?; 1035 self.persist_refs(updates.iter().map(RefUpdate::name)) 1036 } 1037} 1038 1039pub struct RefTxn<'a> { 1040 repo: &'a Repo, 1041} 1042 1043impl RefTxn<'_> { 1044 pub fn update_ref(&self, update: &RefUpdate) -> Result<(), GitError> { 1045 self.repo.update_ref_locked(update) 1046 } 1047 1048 pub fn update_refs(&self, updates: &[RefUpdate]) -> Result<(), GitError> { 1049 self.repo.update_refs_locked(updates) 1050 } 1051} 1052 1053#[cfg(test)] 1054mod tests { 1055 use super::*; 1056 1057 const A: &str = "1111111111111111111111111111111111111111"; 1058 const B: &str = "2222222222222222222222222222222222222222"; 1059 1060 fn oid(hex: &str) -> Oid { 1061 Oid::from_hex(hex).unwrap() 1062 } 1063 1064 fn head_ref() -> RefName { 1065 RefName::new("refs/heads/main").unwrap() 1066 } 1067 1068 fn repo() -> (tempfile::TempDir, Layout, RepoDid) { 1069 let dir = tempfile::tempdir().unwrap(); 1070 let layout = Layout::new(dir.path()); 1071 let did = RepoDid::new("did:plc:squid").unwrap(); 1072 (dir, layout, did) 1073 } 1074 1075 #[test] 1076 fn layout_paths_shard_and_stay_within_scan() { 1077 let layout = Layout::new("/srv/git"); 1078 let cases: &[(&str, &str)] = &[ 1079 ("did:plc:squid", "plc/sq/uid"), 1080 ("did:web:oyster.cafe", "web/oy/ster.cafe"), 1081 ("did:web:nel.pet", "web/ne/l.pet"), 1082 ]; 1083 cases.iter().for_each(|&(raw, suffix)| { 1084 let path = layout.repo_path(&RepoDid::new(raw).unwrap()).unwrap(); 1085 assert!(path.ends_with(suffix), "{path:?} missing shard {suffix}"); 1086 assert!(path.starts_with("/srv/git"), "{path:?} escaped scan path"); 1087 }); 1088 } 1089 1090 #[test] 1091 fn dot_only_did_cannot_escape_scan_path() { 1092 let layout = Layout::new("/srv/git/scan"); 1093 ["did:plc:....", "did:web:....", "did:plc:...", "did:plc:.."] 1094 .into_iter() 1095 .map(|raw| RepoDid::new(raw).unwrap()) 1096 .for_each(|did| { 1097 assert!( 1098 matches!(layout.repo_path(&did), Err(GitError::UnsafeRepoDid(_))), 1099 "dot-only method-specific-id must be refused, never resolved to path" 1100 ); 1101 assert!(matches!(layout.open(&did), Err(GitError::UnsafeRepoDid(_)))); 1102 assert!(matches!( 1103 layout.create(&did), 1104 Err(GitError::UnsafeRepoDid(_)) 1105 )); 1106 }); 1107 1108 let real = RepoDid::new("did:web:oyster.cafe").unwrap(); 1109 assert!( 1110 layout.repo_path(&real).is_ok(), 1111 "legitimate did:web with dots in its domain must still resolve" 1112 ); 1113 } 1114 1115 #[test] 1116 fn meta_repo_path_is_sharded_and_never_collides() { 1117 let layout = Layout::new("/srv/git"); 1118 let knot = KnotId::new("did:web:oyster.cafe").unwrap(); 1119 let meta = layout.meta_path(&knot).unwrap(); 1120 assert!(meta.ends_with("web/oy/ster.cafe")); 1121 ["did:plc:squid", "did:web:nel.pet"] 1122 .into_iter() 1123 .map(|raw| RepoDid::new(raw).unwrap()) 1124 .for_each(|did| { 1125 assert_ne!(layout.repo_path(&did).unwrap(), meta); 1126 }); 1127 } 1128 1129 #[test] 1130 fn bootstrap_meta_creates_then_opens_idempotently() { 1131 let dir = tempfile::tempdir().unwrap(); 1132 let layout = Layout::new(dir.path()); 1133 let knot = KnotId::new("did:web:oyster.cafe").unwrap(); 1134 1135 let created = layout.bootstrap_meta(&knot).unwrap(); 1136 assert!(created.references().unwrap().is_empty()); 1137 assert_eq!(created.path(), layout.meta_path(&knot).unwrap()); 1138 1139 let reopened = layout.bootstrap_meta(&knot).unwrap(); 1140 assert_eq!(reopened.path(), layout.meta_path(&knot).unwrap()); 1141 } 1142 1143 #[test] 1144 fn concurrent_bootstrap_meta_converges_for_every_caller() { 1145 let dir = tempfile::tempdir().unwrap(); 1146 let layout = Layout::new(dir.path()); 1147 let knot = KnotId::new("did:web:oyster.cafe").unwrap(); 1148 let meta = layout.meta_path(&knot).unwrap(); 1149 1150 let paths = std::thread::scope(|scope| { 1151 let handles: Vec<_> = (0..8) 1152 .map(|_| { 1153 let layout = layout.clone(); 1154 let knot = knot.clone(); 1155 scope.spawn(move || { 1156 layout 1157 .bootstrap_meta(&knot) 1158 .map(|repo| repo.path().to_path_buf()) 1159 }) 1160 }) 1161 .collect(); 1162 handles 1163 .into_iter() 1164 .map(|handle| handle.join().unwrap()) 1165 .collect::<Vec<_>>() 1166 }); 1167 1168 assert!( 1169 paths 1170 .iter() 1171 .all(|outcome| matches!(outcome, Ok(path) if path == &meta)), 1172 "every racing bootstrap must converge on one meta repo, not fail: {paths:?}" 1173 ); 1174 let reopened = layout.bootstrap_meta(&knot).unwrap(); 1175 assert!(reopened.references().unwrap().is_empty()); 1176 } 1177 1178 #[test] 1179 fn reserving_meta_refuses_the_knot_did_for_open_and_create() { 1180 let dir = tempfile::tempdir().unwrap(); 1181 let knot = KnotId::new("did:web:oyster.cafe").unwrap(); 1182 let layout = Layout::new(dir.path()).reserving_meta(&knot).unwrap(); 1183 layout.bootstrap_meta(&knot).unwrap(); 1184 1185 let knot_as_repo = RepoDid::new("did:web:oyster.cafe").unwrap(); 1186 assert!(matches!( 1187 layout.open(&knot_as_repo), 1188 Err(GitError::ReservedDid(_)) 1189 )); 1190 assert!(matches!( 1191 layout.create(&knot_as_repo), 1192 Err(GitError::ReservedDid(_)) 1193 )); 1194 1195 let ordinary = RepoDid::new("did:plc:squid").unwrap(); 1196 assert!(layout.create(&ordinary).is_ok()); 1197 assert!(layout.open(&ordinary).is_ok()); 1198 } 1199 1200 #[test] 1201 fn creating_a_bare_repo_is_sha1_and_rejects_a_second_create() { 1202 let (_dir, layout, did) = repo(); 1203 1204 let repo = layout.create(&did).unwrap(); 1205 assert!(repo.references().unwrap().is_empty()); 1206 assert!(repo.head().is_none()); 1207 assert_eq!(repo.object_format(), ObjectFormat::SHA1); 1208 1209 assert!(layout.open(&did).unwrap().references().unwrap().is_empty()); 1210 assert!(matches!( 1211 layout.create(&did), 1212 Err(GitError::AlreadyExists(_)) 1213 )); 1214 } 1215 1216 #[test] 1217 fn with_ref_txn_holds_the_ref_lock_across_its_whole_body() { 1218 use std::sync::Mutex; 1219 use std::sync::mpsc::channel; 1220 1221 let (_dir, layout, did) = repo(); 1222 let repo = layout.create(&did).unwrap(); 1223 let contender_repo = layout.open(&did).unwrap(); 1224 1225 let order: Mutex<Vec<&str>> = Mutex::new(Vec::new()); 1226 let order_ref = &order; 1227 let (contending, observed) = channel(); 1228 1229 std::thread::scope(|scope| { 1230 repo.with_ref_txn(|_txn| { 1231 order_ref.lock().unwrap().push("txn-enter"); 1232 scope.spawn(move || { 1233 contending.send(()).unwrap(); 1234 contender_repo.with_ref_lock(|| order_ref.lock().unwrap().push("contender")); 1235 }); 1236 observed.recv().unwrap(); 1237 std::thread::yield_now(); 1238 order_ref.lock().unwrap().push("txn-exit"); 1239 }); 1240 }); 1241 1242 assert_eq!( 1243 *order.lock().unwrap(), 1244 ["txn-enter", "txn-exit", "contender"], 1245 "object migration runs inside the transaction body, so no other ref-lock holder can observe the half-applied push" 1246 ); 1247 } 1248 1249 #[test] 1250 fn create_with_sha256_object_format_round_trips() { 1251 let dir = tempfile::tempdir().unwrap(); 1252 let layout = Layout::new(dir.path()).with_object_format(ObjectFormat::SHA256); 1253 let did = RepoDid::new("did:plc:squid").unwrap(); 1254 1255 let repo = layout.create(&did).unwrap(); 1256 assert_eq!(repo.object_format(), ObjectFormat::SHA256); 1257 let oid = Oid::from(repo.git().write_blob(b"hello sha256\n").unwrap().detach()); 1258 assert_eq!( 1259 oid.to_hex().len(), 1260 64, 1261 "sha256 repo names objects with 32-byte digests" 1262 ); 1263 1264 let reopened = layout.open(&did).unwrap(); 1265 assert_eq!( 1266 reopened.object_format(), 1267 ObjectFormat::SHA256, 1268 "object format survives reopen, read from repo config" 1269 ); 1270 } 1271 1272 #[test] 1273 fn compare_and_swap_governs_every_ref_write() { 1274 let (_dir, layout, did) = repo(); 1275 let repo = layout.create(&did).unwrap(); 1276 let main = head_ref(); 1277 1278 repo.update_ref(&RefUpdate::Create { 1279 name: main.clone(), 1280 new: oid(A), 1281 }) 1282 .unwrap(); 1283 assert!( 1284 repo.update_ref(&RefUpdate::Create { 1285 name: main.clone(), 1286 new: oid(B), 1287 }) 1288 .is_err() 1289 ); 1290 assert!( 1291 repo.update_ref(&RefUpdate::Update { 1292 name: main.clone(), 1293 old: oid(B), 1294 new: oid(A), 1295 }) 1296 .is_err() 1297 ); 1298 repo.update_ref(&RefUpdate::Update { 1299 name: main.clone(), 1300 old: oid(A), 1301 new: oid(B), 1302 }) 1303 .unwrap(); 1304 let refs = repo.references().unwrap(); 1305 assert_eq!(refs.len(), 1); 1306 assert_eq!(refs[0].target, oid(B)); 1307 1308 drop(repo); 1309 let repo = layout.open(&did).unwrap(); 1310 assert_eq!( 1311 repo.find_ref(&main).unwrap(), 1312 Some(oid(B)), 1313 "update is visible after reopen" 1314 ); 1315 1316 repo.update_ref(&RefUpdate::Delete { 1317 name: main.clone(), 1318 old: oid(B), 1319 }) 1320 .unwrap(); 1321 assert!(repo.references().unwrap().is_empty()); 1322 1323 let x = RefName::new("refs/heads/x").unwrap(); 1324 let y = RefName::new("refs/heads/y").unwrap(); 1325 repo.update_refs(&[ 1326 RefUpdate::Create { 1327 name: x.clone(), 1328 new: oid(A), 1329 }, 1330 RefUpdate::Create { 1331 name: y.clone(), 1332 new: oid(A), 1333 }, 1334 ]) 1335 .unwrap(); 1336 let result = repo.update_refs(&[ 1337 RefUpdate::Update { 1338 name: x.clone(), 1339 old: oid(A), 1340 new: oid(B), 1341 }, 1342 RefUpdate::Update { 1343 name: y.clone(), 1344 old: oid(B), 1345 new: oid(A), 1346 }, 1347 ]); 1348 assert!( 1349 result.is_err(), 1350 "batch with one stale compare-and-swap must fail as a whole" 1351 ); 1352 assert_eq!( 1353 repo.find_ref(&x).unwrap(), 1354 Some(oid(A)), 1355 "valid edit in failed batch must roll back" 1356 ); 1357 assert_eq!(repo.find_ref(&y).unwrap(), Some(oid(A))); 1358 } 1359 1360 #[test] 1361 fn ref_writes_leave_a_recoverable_reflog_under_the_knot_identity() { 1362 let (_dir, layout, did) = repo(); 1363 let repo = layout.create(&did).unwrap(); 1364 1365 let committer = repo 1366 .git() 1367 .committer() 1368 .expect("committer is always pinned so reflog writes never depend on ambient config") 1369 .expect("pinned committer signature parses"); 1370 assert_eq!(committer.name.to_string(), REFLOG_COMMITTER_NAME); 1371 assert_eq!(committer.email.to_string(), REFLOG_COMMITTER_EMAIL); 1372 1373 repo.update_ref(&RefUpdate::Create { 1374 name: head_ref(), 1375 new: oid(A), 1376 }) 1377 .unwrap(); 1378 repo.update_ref(&RefUpdate::Update { 1379 name: head_ref(), 1380 old: oid(A), 1381 new: oid(B), 1382 }) 1383 .unwrap(); 1384 repo.update_ref(&RefUpdate::Create { 1385 name: RefName::new("refs/tags/v1").unwrap(), 1386 new: oid(A), 1387 }) 1388 .unwrap(); 1389 1390 let logs = repo.git().git_dir().join("logs"); 1391 let branch = std::fs::read_to_string(logs.join("refs/heads/main")) 1392 .expect("branch update must leave reflog so clobbering push is recoverable"); 1393 assert!( 1394 branch.contains(A) && branch.contains(B) && branch.contains(REFLOG_COMMITTER_NAME), 1395 "branch reflog records both tips under knot identity:\n{branch}" 1396 ); 1397 assert!( 1398 logs.join("refs/tags/v1").exists(), 1399 "force_create_reflog must log tags too, not just conventional refs/heads set" 1400 ); 1401 1402 let updates = repo.reflog_updates_since(UnixSeconds::new(0)); 1403 let head_new: Vec<Oid> = updates 1404 .iter() 1405 .filter(|update| update.name == head_ref()) 1406 .map(|update| update.new) 1407 .collect(); 1408 assert!( 1409 head_new.contains(&oid(A)) && head_new.contains(&oid(B)), 1410 "both branch tips are recovered from reflog: {head_new:?}" 1411 ); 1412 let create = updates 1413 .iter() 1414 .find(|update| update.name == head_ref() && update.new == oid(A)) 1415 .unwrap(); 1416 assert_eq!(create.old, None, "branch creation has no previous oid"); 1417 let update = updates 1418 .iter() 1419 .find(|update| update.name == head_ref() && update.new == oid(B)) 1420 .unwrap(); 1421 assert_eq!(update.old, Some(oid(A)), "branch update records prior oid"); 1422 assert!( 1423 updates 1424 .iter() 1425 .any(|update| update.name.as_str() == "refs/tags/v1" && update.new == oid(A)), 1426 "tag update is recovered too" 1427 ); 1428 assert!( 1429 repo.reflog_updates_since(UnixSeconds::new(i64::MAX)) 1430 .is_empty(), 1431 "horizon past every entry filters whole reflog out" 1432 ); 1433 } 1434 1435 #[test] 1436 fn advertisement_hides_reserved_refs_and_tracks_each_change() { 1437 let (_dir, layout, did) = repo(); 1438 let repo = layout.create(&did).unwrap(); 1439 let main = head_ref(); 1440 let feature = RefName::new("refs/heads/feature").unwrap(); 1441 1442 repo.update_ref(&RefUpdate::Create { 1443 name: main.clone(), 1444 new: oid(A), 1445 }) 1446 .unwrap(); 1447 let first = repo.advertised_refs().unwrap(); 1448 assert_eq!(first.len(), 1); 1449 assert_eq!(first[0].name.as_str(), "refs/heads/main"); 1450 assert_eq!( 1451 repo.advertised_refs().unwrap(), 1452 first, 1453 "repeated advertisement with no ref change serves same answer" 1454 ); 1455 1456 repo.update_ref(&RefUpdate::Create { 1457 name: RefName::new("refs/cobs/sh.tangled.repo.collaborator/limpet").unwrap(), 1458 new: oid(B), 1459 }) 1460 .unwrap(); 1461 let advertised = repo.advertised_refs().unwrap(); 1462 assert_eq!(advertised.len(), 1, "cob ref is hidden from advertisement"); 1463 assert_eq!(advertised[0].name.as_str(), "refs/heads/main"); 1464 assert_eq!(repo.references().unwrap().len(), 2); 1465 assert!(is_reserved(&RefName::new("refs/cobs/x/y").unwrap())); 1466 1467 repo.update_ref(&RefUpdate::Create { 1468 name: feature.clone(), 1469 new: oid(B), 1470 }) 1471 .unwrap(); 1472 assert_eq!( 1473 repo.advertised_refs().unwrap().len(), 1474 2, 1475 "ref created after advertisement invalidates cached answer" 1476 ); 1477 repo.update_ref(&RefUpdate::Delete { 1478 name: feature, 1479 old: oid(B), 1480 }) 1481 .unwrap(); 1482 assert_eq!( 1483 repo.advertised_refs().unwrap().len(), 1484 1, 1485 "delete after advertisement invalidates cached answer" 1486 ); 1487 1488 drop(repo); 1489 layout.remove(&did).unwrap(); 1490 let repo = layout.create(&did).unwrap(); 1491 repo.update_ref(&RefUpdate::Create { 1492 name: RefName::new("refs/heads/new").unwrap(), 1493 new: oid(B), 1494 }) 1495 .unwrap(); 1496 let advertised = repo.advertised_refs().unwrap(); 1497 assert_eq!( 1498 advertised.len(), 1499 1, 1500 "recreated repo advertises only its own ref" 1501 ); 1502 assert_eq!( 1503 advertised[0].name.as_str(), 1504 "refs/heads/new", 1505 "deleted repo's cached advertisement mustn't survive recreation at same path" 1506 ); 1507 } 1508 1509 #[test] 1510 fn advertisement_reflects_each_update_under_concurrent_readers() { 1511 let (_dir, layout, did) = repo(); 1512 let repo = layout.create(&did).unwrap(); 1513 let main = head_ref(); 1514 repo.update_ref(&RefUpdate::Create { 1515 name: main.clone(), 1516 new: oid(A), 1517 }) 1518 .unwrap(); 1519 drop(repo); 1520 1521 let stop = std::sync::atomic::AtomicBool::new(false); 1522 std::thread::scope(|scope| { 1523 (0..4).for_each(|_| { 1524 scope.spawn(|| { 1525 let reader = layout.open(&did).unwrap(); 1526 while !stop.load(Ordering::Relaxed) { 1527 let refs = reader.advertised_refs().unwrap(); 1528 assert_eq!(refs.len(), 1, "live branch is advertised exactly once"); 1529 assert!( 1530 refs[0].target == oid(A) || refs[0].target == oid(B), 1531 "reader must never observe value branch never held" 1532 ); 1533 } 1534 }); 1535 }); 1536 1537 let writer = layout.open(&did).unwrap(); 1538 (0..64).for_each(|round| { 1539 let (old, new) = if round % 2 == 0 { 1540 (oid(A), oid(B)) 1541 } else { 1542 (oid(B), oid(A)) 1543 }; 1544 writer 1545 .update_ref(&RefUpdate::Update { 1546 name: main.clone(), 1547 old, 1548 new, 1549 }) 1550 .unwrap(); 1551 assert_eq!( 1552 writer.advertised_refs().unwrap()[0].target, 1553 new, 1554 "advertisement taken after update reflects that update" 1555 ); 1556 }); 1557 stop.store(true, Ordering::Relaxed); 1558 }); 1559 } 1560 1561 #[test] 1562 fn create_honors_configured_default_branch() { 1563 let dir = tempfile::tempdir().unwrap(); 1564 let layout = Layout::new(dir.path()).with_default_branch(BranchName::new("trunk").unwrap()); 1565 let repo = layout 1566 .create(&RepoDid::new("did:plc:squid").unwrap()) 1567 .unwrap(); 1568 assert_eq!(repo.default_branch().unwrap().as_str(), "refs/heads/trunk"); 1569 } 1570 1571 #[test] 1572 fn concurrent_create_has_exactly_one_winner() { 1573 let (_dir, layout, did) = repo(); 1574 layout.create(&did).unwrap(); 1575 let main = head_ref(); 1576 1577 const C: &str = "3333333333333333333333333333333333333333"; 1578 const D: &str = "4444444444444444444444444444444444444444"; 1579 let winners = std::thread::scope(|scope| { 1580 let handles = [A, B, C, D].map(|hex| { 1581 let layout = layout.clone(); 1582 let did = did.clone(); 1583 let main = main.clone(); 1584 scope.spawn(move || { 1585 layout 1586 .open(&did) 1587 .unwrap() 1588 .update_ref(&RefUpdate::Create { 1589 name: main, 1590 new: oid(hex), 1591 }) 1592 .is_ok() 1593 }) 1594 }); 1595 handles 1596 .into_iter() 1597 .map(|handle| handle.join().unwrap()) 1598 .filter(|created| *created) 1599 .count() 1600 }); 1601 1602 assert_eq!(winners, 1, "concurrent creates of one ref mustn't both win"); 1603 assert_eq!(layout.open(&did).unwrap().references().unwrap().len(), 1); 1604 } 1605 1606 #[test] 1607 fn symref_cycle_does_not_overflow_references() { 1608 let (_dir, layout, did) = repo(); 1609 let repo = layout.create(&did).unwrap(); 1610 let symref = |name: &str, target: &str| RefEdit { 1611 change: Change::Update { 1612 log: LogChange { 1613 mode: RefLog::AndReference, 1614 force_create_reflog: false, 1615 message: "cycle".into(), 1616 }, 1617 expected: PreviousValue::Any, 1618 new: Target::Symbolic(FullName::try_from(target).unwrap()), 1619 }, 1620 name: FullName::try_from(name).unwrap(), 1621 deref: false, 1622 }; 1623 repo.git() 1624 .edit_reference(symref("refs/cycle/a", "refs/cycle/b")) 1625 .unwrap(); 1626 repo.git() 1627 .edit_reference(symref("refs/cycle/b", "refs/cycle/a")) 1628 .unwrap(); 1629 1630 let refs = repo.references().unwrap(); 1631 assert!( 1632 refs.iter() 1633 .all(|record| !record.name.as_str().starts_with("refs/cycle/")), 1634 "cyclic symref must be skipped, not resolved" 1635 ); 1636 } 1637}