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