This repository has no description
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 = ℴ
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}