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.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 = ℴ
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}