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