1#![expect(missing_docs)]
16
17use std::collections::BTreeMap;
18use std::collections::HashMap;
19use std::collections::HashSet;
20use std::fmt::Debug;
21use std::fs;
22use std::io;
23use std::io::ErrorKind;
24use std::io::Write as _;
25use std::path::Path;
26use std::path::PathBuf;
27use std::time::SystemTime;
28
29use async_trait::async_trait;
30use itertools::Itertools as _;
31use pollster::FutureExt as _;
32use prost::Message as _;
33use smallvec::SmallVec;
34use tempfile::NamedTempFile;
35use thiserror::Error;
36
37use crate::backend::BackendInitError;
38use crate::backend::CommitId;
39use crate::backend::MillisSinceEpoch;
40use crate::backend::Timestamp;
41use crate::content_hash::blake2b_hash;
42use crate::dag_walk_async;
43use crate::file_util::IoResultExt as _;
44use crate::file_util::PathError;
45use crate::file_util::persist_content_addressed_temp_file;
46use crate::merge::Merge;
47use crate::object_id::HexPrefix;
48use crate::object_id::ObjectId;
49use crate::object_id::PrefixResolution;
50use crate::op_store;
51use crate::op_store::OpStore;
52use crate::op_store::OpStoreError;
53use crate::op_store::OpStoreResult;
54use crate::op_store::Operation;
55use crate::op_store::OperationId;
56use crate::op_store::OperationMetadata;
57use crate::op_store::RefTarget;
58use crate::op_store::RemoteRef;
59use crate::op_store::RemoteRefState;
60use crate::op_store::RemoteView;
61use crate::op_store::RootOperationData;
62use crate::op_store::TimestampRange;
63use crate::op_store::View;
64use crate::op_store::ViewId;
65use crate::ref_name::GitRefNameBuf;
66use crate::ref_name::RefNameBuf;
67use crate::ref_name::RemoteNameBuf;
68use crate::ref_name::WorkspaceName;
69use crate::ref_name::WorkspaceNameBuf;
70
71const OPERATION_ID_LENGTH: usize = 64;
73const VIEW_ID_LENGTH: usize = 64;
74
75#[derive(Debug, Error)]
77#[error("Failed to initialize simple operation store")]
78pub struct SimpleOpStoreInitError(#[from] pub PathError);
79
80impl From<SimpleOpStoreInitError> for BackendInitError {
81 fn from(err: SimpleOpStoreInitError) -> Self {
82 Self(err.into())
83 }
84}
85
86#[derive(Debug)]
87pub struct SimpleOpStore {
88 path: PathBuf,
89 root_data: RootOperationData,
90 root_operation_id: OperationId,
91 root_view_id: ViewId,
92}
93
94impl SimpleOpStore {
95 pub fn name() -> &'static str {
96 "simple_op_store"
97 }
98
99 pub fn init(
101 store_path: &Path,
102 root_data: RootOperationData,
103 ) -> Result<Self, SimpleOpStoreInitError> {
104 let store = Self::new(store_path, root_data);
105 store.init_base_dirs()?;
106 Ok(store)
107 }
108
109 pub fn load(store_path: &Path, root_data: RootOperationData) -> Self {
111 Self::new(store_path, root_data)
112 }
113
114 fn new(store_path: &Path, root_data: RootOperationData) -> Self {
115 Self {
116 path: store_path.to_path_buf(),
117 root_data,
118 root_operation_id: OperationId::from_bytes(&[0; OPERATION_ID_LENGTH]),
119 root_view_id: ViewId::from_bytes(&[0; VIEW_ID_LENGTH]),
120 }
121 }
122
123 fn init_base_dirs(&self) -> Result<(), PathError> {
124 for dir in [self.views_dir(), self.operations_dir()] {
125 fs::create_dir(&dir).context(&dir)?;
126 }
127 Ok(())
128 }
129
130 fn views_dir(&self) -> PathBuf {
131 self.path.join("views")
132 }
133
134 fn operations_dir(&self) -> PathBuf {
135 self.path.join("operations")
136 }
137}
138
139#[async_trait]
140impl OpStore for SimpleOpStore {
141 fn name(&self) -> &str {
142 Self::name()
143 }
144
145 fn root_operation_id(&self) -> &OperationId {
146 &self.root_operation_id
147 }
148
149 async fn read_view(&self, id: &ViewId) -> OpStoreResult<View> {
150 if *id == self.root_view_id {
151 return Ok(View::make_root(self.root_data.root_commit_id.clone()));
152 }
153
154 let path = self.views_dir().join(id.hex());
155 let buf = fs::read(&path)
156 .context(&path)
157 .map_err(|err| io_to_read_error(err, id))?;
158
159 let proto = crate::protos::simple_op_store::View::decode(&*buf)
160 .map_err(|err| to_read_error(err.into(), id))?;
161 view_from_proto(proto).map_err(|err| to_read_error(err.into(), id))
162 }
163
164 async fn write_view(&self, view: &View) -> OpStoreResult<ViewId> {
165 let dir = self.views_dir();
166 let temp_file = NamedTempFile::new_in(&dir)
167 .context(&dir)
168 .map_err(|err| io_to_write_error(err, "view"))?;
169
170 let proto = view_to_proto(view);
171 temp_file
172 .as_file()
173 .write_all(&proto.encode_to_vec())
174 .context(temp_file.path())
175 .map_err(|err| io_to_write_error(err, "view"))?;
176
177 let id = ViewId::new(blake2b_hash(view).to_vec());
178
179 let new_path = dir.join(id.hex());
180 persist_content_addressed_temp_file(temp_file, &new_path)
181 .context(&new_path)
182 .map_err(|err| io_to_write_error(err, "view"))?;
183 Ok(id)
184 }
185
186 async fn read_operation(&self, id: &OperationId) -> OpStoreResult<Operation> {
187 if *id == self.root_operation_id {
188 return Ok(Operation::make_root(self.root_view_id.clone()));
189 }
190
191 let path = self.operations_dir().join(id.hex());
192 let buf = fs::read(&path)
193 .context(&path)
194 .map_err(|err| io_to_read_error(err, id))?;
195
196 let proto = crate::protos::simple_op_store::Operation::decode(&*buf)
197 .map_err(|err| to_read_error(err.into(), id))?;
198 let mut operation =
199 operation_from_proto(proto).map_err(|err| to_read_error(err.into(), id))?;
200 if operation.parents.is_empty() {
201 operation.parents.push(self.root_operation_id.clone());
204 }
205 Ok(operation)
206 }
207
208 async fn write_operation(&self, operation: &Operation) -> OpStoreResult<OperationId> {
209 assert!(!operation.parents.is_empty());
210 let dir = self.operations_dir();
211 let temp_file = NamedTempFile::new_in(&dir)
212 .context(&dir)
213 .map_err(|err| io_to_write_error(err, "operation"))?;
214
215 let proto = operation_to_proto(operation);
216 temp_file
217 .as_file()
218 .write_all(&proto.encode_to_vec())
219 .context(temp_file.path())
220 .map_err(|err| io_to_write_error(err, "operation"))?;
221
222 let id = OperationId::new(blake2b_hash(operation).to_vec());
223
224 let new_path = dir.join(id.hex());
225 persist_content_addressed_temp_file(temp_file, &new_path)
226 .context(&new_path)
227 .map_err(|err| io_to_write_error(err, "operation"))?;
228 Ok(id)
229 }
230
231 async fn resolve_operation_id_prefix(
232 &self,
233 prefix: &HexPrefix,
234 ) -> OpStoreResult<PrefixResolution<OperationId>> {
235 let op_dir = self.operations_dir();
236 let find = || -> io::Result<_> {
237 let matches_root = prefix.matches(&self.root_operation_id);
238 let hex_prefix = prefix.hex();
239 if hex_prefix.len() == OPERATION_ID_LENGTH * 2 {
240 if matches_root || op_dir.join(hex_prefix).try_exists()? {
242 let id = OperationId::from_bytes(prefix.as_full_bytes().unwrap());
243 return Ok(PrefixResolution::SingleMatch(id));
244 } else {
245 return Ok(PrefixResolution::NoMatch);
246 }
247 }
248
249 let mut matched = matches_root.then(|| self.root_operation_id.clone());
250 for entry in op_dir.read_dir()? {
251 let Ok(name) = entry?.file_name().into_string() else {
252 continue; };
254 if !name.starts_with(&hex_prefix) {
255 continue;
256 }
257 let Some(id) = OperationId::try_from_hex(&name) else {
258 continue; };
260 if matched.is_some() {
261 return Ok(PrefixResolution::AmbiguousMatch);
262 }
263 matched = Some(id);
264 }
265 if let Some(id) = matched {
266 Ok(PrefixResolution::SingleMatch(id))
267 } else {
268 Ok(PrefixResolution::NoMatch)
269 }
270 };
271 find()
272 .context(&op_dir)
273 .map_err(|err| OpStoreError::Other(err.into()))
274 }
275
276 #[tracing::instrument(skip(self))]
277 async fn gc(&self, head_ids: &[OperationId], keep_newer: SystemTime) -> OpStoreResult<()> {
278 let to_op_id = |entry: &fs::DirEntry| -> Option<OperationId> {
279 let name = entry.file_name().into_string().ok()?;
280 OperationId::try_from_hex(name)
281 };
282 let to_view_id = |entry: &fs::DirEntry| -> Option<ViewId> {
283 let name = entry.file_name().into_string().ok()?;
284 ViewId::try_from_hex(name)
285 };
286 let remove_file_if_not_new = |entry: &fs::DirEntry| -> Result<(), PathError> {
287 let path = entry.path();
288 let metadata = entry.metadata().context(&path)?;
291 let mtime = metadata.modified().expect("unsupported platform?");
292 if mtime > keep_newer {
293 tracing::trace!(?path, "not removing");
294 Ok(())
295 } else {
296 tracing::trace!(?path, "removing");
297 fs::remove_file(&path).context(&path)
298 }
299 };
300
301 let read_op = |id: &OperationId| {
305 self.read_operation(id)
306 .block_on()
307 .map(|data| (id.clone(), data))
308 };
309 let reachable_ops: HashMap<OperationId, Operation> = dag_walk_async::dfs(
310 head_ids.iter().map(read_op),
311 |(id, _)| id.clone(),
312 |(_, data)| data.parents.iter().map(read_op).collect_vec(),
313 )
314 .try_collect()?;
315 let reachable_views: HashSet<&ViewId> =
316 reachable_ops.values().map(|data| &data.view_id).collect();
317 tracing::info!(
318 reachable_op_count = reachable_ops.len(),
319 reachable_view_count = reachable_views.len(),
320 "collected reachable objects"
321 );
322
323 let prune_ops = || -> Result<(), PathError> {
324 let op_dir = self.operations_dir();
325 for entry in op_dir.read_dir().context(&op_dir)? {
326 let entry = entry.context(&op_dir)?;
327 let Some(id) = to_op_id(&entry) else {
328 tracing::trace!(?entry, "skipping invalid file name");
329 continue;
330 };
331 if reachable_ops.contains_key(&id) {
332 continue;
333 }
334 remove_file_if_not_new(&entry)?;
338 }
339 Ok(())
340 };
341 prune_ops().map_err(|err| OpStoreError::Other(err.into()))?;
342
343 let prune_views = || -> Result<(), PathError> {
344 let view_dir = self.views_dir();
345 for entry in view_dir.read_dir().context(&view_dir)? {
346 let entry = entry.context(&view_dir)?;
347 let Some(id) = to_view_id(&entry) else {
348 tracing::trace!(?entry, "skipping invalid file name");
349 continue;
350 };
351 if reachable_views.contains(&id) {
352 continue;
353 }
354 remove_file_if_not_new(&entry)?;
355 }
356 Ok(())
357 };
358 prune_views().map_err(|err| OpStoreError::Other(err.into()))?;
359
360 Ok(())
361 }
362}
363
364fn io_to_read_error(err: PathError, id: &impl ObjectId) -> OpStoreError {
365 if err.source.kind() == ErrorKind::NotFound {
366 OpStoreError::ObjectNotFound {
367 object_type: id.object_type(),
368 hash: id.hex(),
369 source: Box::new(err),
370 }
371 } else {
372 to_read_error(err.into(), id)
373 }
374}
375
376fn to_read_error(
377 source: Box<dyn std::error::Error + Send + Sync>,
378 id: &impl ObjectId,
379) -> OpStoreError {
380 OpStoreError::ReadObject {
381 object_type: id.object_type(),
382 hash: id.hex(),
383 source,
384 }
385}
386
387fn io_to_write_error(err: PathError, object_type: &'static str) -> OpStoreError {
388 OpStoreError::WriteObject {
389 object_type,
390 source: Box::new(err),
391 }
392}
393
394#[derive(Debug, Error)]
395enum PostDecodeError {
396 #[error("Invalid hash length (expected {expected} bytes, got {actual} bytes)")]
397 InvalidHashLength { expected: usize, actual: usize },
398 #[error("Invalid remote ref state value {0}")]
399 InvalidRemoteRefStateValue(i32),
400 #[error("Invalid number of ref target terms {0}")]
401 EvenNumberOfRefTargetTerms(usize),
402}
403
404fn operation_id_from_proto(bytes: Vec<u8>) -> Result<OperationId, PostDecodeError> {
405 if bytes.len() != OPERATION_ID_LENGTH {
406 Err(PostDecodeError::InvalidHashLength {
407 expected: OPERATION_ID_LENGTH,
408 actual: bytes.len(),
409 })
410 } else {
411 Ok(OperationId::new(bytes))
412 }
413}
414
415fn view_id_from_proto(bytes: Vec<u8>) -> Result<ViewId, PostDecodeError> {
416 if bytes.len() != VIEW_ID_LENGTH {
417 Err(PostDecodeError::InvalidHashLength {
418 expected: VIEW_ID_LENGTH,
419 actual: bytes.len(),
420 })
421 } else {
422 Ok(ViewId::new(bytes))
423 }
424}
425
426fn timestamp_to_proto(timestamp: &Timestamp) -> crate::protos::simple_op_store::Timestamp {
427 crate::protos::simple_op_store::Timestamp {
428 millis_since_epoch: timestamp.timestamp.0,
429 tz_offset: timestamp.tz_offset,
430 }
431}
432
433fn timestamp_from_proto(proto: crate::protos::simple_op_store::Timestamp) -> Timestamp {
434 Timestamp {
435 timestamp: MillisSinceEpoch(proto.millis_since_epoch),
436 tz_offset: proto.tz_offset,
437 }
438}
439
440fn operation_metadata_to_proto(
441 metadata: &OperationMetadata,
442) -> crate::protos::simple_op_store::OperationMetadata {
443 crate::protos::simple_op_store::OperationMetadata {
444 start_time: Some(timestamp_to_proto(&metadata.time.start)),
445 end_time: Some(timestamp_to_proto(&metadata.time.end)),
446 description: metadata.description.clone(),
447 hostname: metadata.hostname.clone(),
448 username: metadata.username.clone(),
449 is_snapshot: metadata.is_snapshot,
450 workspace_name: metadata.workspace_name.clone().map(Into::into),
451 attributes: metadata
452 .attributes
453 .iter()
454 .map(|(k, v)| (k.clone(), v.clone()))
455 .collect(),
456 }
457}
458
459fn operation_metadata_from_proto(
460 proto: crate::protos::simple_op_store::OperationMetadata,
461) -> OperationMetadata {
462 let time = TimestampRange {
463 start: timestamp_from_proto(proto.start_time.unwrap_or_default()),
464 end: timestamp_from_proto(proto.end_time.unwrap_or_default()),
465 };
466 let workspace_name = proto.workspace_name.map(Into::into);
467 OperationMetadata {
468 time,
469 description: proto.description,
470 hostname: proto.hostname,
471 username: proto.username,
472 is_snapshot: proto.is_snapshot,
473 workspace_name,
474 attributes: proto.attributes.into_iter().collect(),
475 }
476}
477
478fn commit_predecessors_map_to_proto(
479 map: &BTreeMap<CommitId, Vec<CommitId>>,
480) -> Vec<crate::protos::simple_op_store::CommitPredecessors> {
481 map.iter()
482 .map(
483 |(commit_id, predecessor_ids)| crate::protos::simple_op_store::CommitPredecessors {
484 commit_id: commit_id.to_bytes(),
485 predecessor_ids: predecessor_ids.iter().map(|id| id.to_bytes()).collect(),
486 },
487 )
488 .collect()
489}
490
491fn commit_predecessors_map_from_proto(
492 proto: Vec<crate::protos::simple_op_store::CommitPredecessors>,
493) -> BTreeMap<CommitId, Vec<CommitId>> {
494 proto
495 .into_iter()
496 .map(|entry| {
497 let commit_id = CommitId::new(entry.commit_id);
498 let predecessor_ids = entry
499 .predecessor_ids
500 .into_iter()
501 .map(CommitId::new)
502 .collect();
503 (commit_id, predecessor_ids)
504 })
505 .collect()
506}
507
508fn operation_to_proto(operation: &Operation) -> crate::protos::simple_op_store::Operation {
509 let (commit_predecessors, stores_commit_predecessors) = match &operation.commit_predecessors {
510 Some(map) => (commit_predecessors_map_to_proto(map), true),
511 None => (vec![], false),
512 };
513 let parents = operation.parents.iter().map(|id| id.to_bytes()).collect();
514 crate::protos::simple_op_store::Operation {
515 view_id: operation.view_id.as_bytes().to_vec(),
516 parents,
517 metadata: Some(operation_metadata_to_proto(&operation.metadata)),
518 commit_predecessors,
519 stores_commit_predecessors,
520 }
521}
522
523fn operation_from_proto(
524 proto: crate::protos::simple_op_store::Operation,
525) -> Result<Operation, PostDecodeError> {
526 let parents = proto
527 .parents
528 .into_iter()
529 .map(operation_id_from_proto)
530 .try_collect()?;
531 let view_id = view_id_from_proto(proto.view_id)?;
532 let metadata = operation_metadata_from_proto(proto.metadata.unwrap_or_default());
533 let commit_predecessors = proto
534 .stores_commit_predecessors
535 .then(|| commit_predecessors_map_from_proto(proto.commit_predecessors));
536 Ok(Operation {
537 view_id,
538 parents,
539 metadata,
540 commit_predecessors,
541 })
542}
543
544fn view_to_proto(view: &View) -> crate::protos::simple_op_store::View {
545 let wc_commit_ids = view
546 .wc_commit_ids
547 .iter()
548 .map(|(name, id)| (name.into(), id.to_bytes()))
549 .collect();
550 let head_ids = view.head_ids.iter().map(|id| id.to_bytes()).collect();
551
552 let bookmarks = bookmark_views_to_proto_legacy(&view.local_bookmarks, &view.remote_views);
553
554 let local_tags = view
555 .local_tags
556 .iter()
557 .map(|(name, target)| crate::protos::simple_op_store::Tag {
558 name: name.into(),
559 target: ref_target_to_proto(target),
560 })
561 .collect();
562
563 let remote_views = remote_views_to_proto(&view.remote_views);
564
565 let git_refs = view
566 .git_refs
567 .iter()
568 .map(|(name, target)| {
569 #[expect(deprecated)]
570 crate::protos::simple_op_store::GitRef {
571 name: name.into(),
572 commit_id: Default::default(),
573 target: ref_target_to_proto(target),
574 }
575 })
576 .collect();
577
578 let git_heads = view
579 .git_heads
580 .iter()
581 .map(|(name, target)| crate::protos::simple_op_store::GitHead {
582 name: name.as_str().to_owned(),
583 target: ref_target_to_proto(target),
584 })
585 .collect();
586
587 #[expect(deprecated)]
588 crate::protos::simple_op_store::View {
589 head_ids,
590 wc_commit_id: Default::default(),
591 wc_commit_ids,
592 bookmarks,
593 local_tags,
594 remote_views,
595 git_refs,
596 git_head_legacy: Default::default(),
597 git_head: view
599 .git_heads
600 .get(WorkspaceName::DEFAULT)
601 .and_then(ref_target_to_proto),
602 has_git_refs_migrated_to_remote_tags: true,
604 git_heads,
605 }
606}
607
608fn view_from_proto(proto: crate::protos::simple_op_store::View) -> Result<View, PostDecodeError> {
609 let mut wc_commit_ids = BTreeMap::new();
613 #[expect(deprecated)]
614 if !proto.wc_commit_id.is_empty() {
615 wc_commit_ids.insert(
616 WorkspaceName::DEFAULT.to_owned(),
617 CommitId::new(proto.wc_commit_id),
618 );
619 }
620 for (name, commit_id) in proto.wc_commit_ids {
621 wc_commit_ids.insert(WorkspaceNameBuf::from(name), CommitId::new(commit_id));
622 }
623 let head_ids = proto.head_ids.into_iter().map(CommitId::new).collect();
624
625 let (local_bookmarks, mut remote_views) = bookmark_views_from_proto_legacy(proto.bookmarks)?;
626
627 let local_tags = proto
628 .local_tags
629 .into_iter()
630 .map(|tag_proto| {
631 let name: RefNameBuf = tag_proto.name.into();
632 (name, ref_target_from_proto(tag_proto.target))
633 })
634 .collect();
635
636 let git_refs: BTreeMap<_, _> = proto
637 .git_refs
638 .into_iter()
639 .map(|git_ref| {
640 let name: GitRefNameBuf = git_ref.name.into();
641 let target = if git_ref.target.is_some() {
642 ref_target_from_proto(git_ref.target)
643 } else {
644 #[expect(deprecated)]
646 RefTarget::normal(CommitId::new(git_ref.commit_id))
647 };
648 (name, target)
649 })
650 .collect();
651
652 if !proto.remote_views.is_empty() {
654 remote_views = remote_views_from_proto(proto.remote_views)?;
655 }
656
657 #[cfg(feature = "git")]
658 if !proto.has_git_refs_migrated_to_remote_tags {
659 tracing::info!("migrating Git-tracking tags");
660 let git_tags: BTreeMap<_, _> = git_refs
661 .iter()
662 .filter_map(|(full_name, target)| {
663 let name = full_name.as_str().strip_prefix("refs/tags/")?;
664 assert!(!name.is_empty());
665 let name: RefNameBuf = name.into();
666 let remote_ref = RemoteRef {
667 target: target.clone(),
668 state: RemoteRefState::Tracked,
669 };
670 Some((name, remote_ref))
671 })
672 .collect();
673 if !git_tags.is_empty() {
674 let git_view = remote_views
675 .entry(crate::git::REMOTE_NAME_FOR_LOCAL_GIT_REPO.to_owned())
676 .or_default();
677 assert!(git_view.tags.is_empty());
678 git_view.tags = git_tags;
679 }
680 }
681
682 let mut git_heads: BTreeMap<WorkspaceNameBuf, RefTarget> = proto
683 .git_heads
684 .into_iter()
685 .map(|entry| {
686 (
687 WorkspaceNameBuf::from(entry.name),
688 ref_target_from_proto(entry.target),
689 )
690 })
691 .collect();
692 #[expect(deprecated)]
693 if git_heads.is_empty() {
694 let git_head = if proto.git_head.is_some() {
695 ref_target_from_proto(proto.git_head)
696 } else if !proto.git_head_legacy.is_empty() {
697 RefTarget::normal(CommitId::new(proto.git_head_legacy))
698 } else {
699 RefTarget::absent()
700 };
701 if git_head.is_present() {
702 git_heads.insert(WorkspaceName::DEFAULT.to_owned(), git_head);
703 }
704 }
705
706 Ok(View {
707 head_ids,
708 local_bookmarks,
709 local_tags,
710 remote_views,
711 git_refs,
712 git_heads,
713 wc_commit_ids,
714 })
715}
716
717fn bookmark_views_to_proto_legacy(
718 local_bookmarks: &BTreeMap<RefNameBuf, RefTarget>,
719 remote_views: &BTreeMap<RemoteNameBuf, RemoteView>,
720) -> Vec<crate::protos::simple_op_store::Bookmark> {
721 op_store::merge_join_ref_views(local_bookmarks, remote_views, |view| &view.bookmarks)
722 .map(|(name, bookmark_target)| {
723 let local_target = ref_target_to_proto(bookmark_target.local_target);
724 let remote_bookmarks = bookmark_target
726 .remote_refs
727 .iter()
728 .map(
729 |&(remote_name, remote_ref)| crate::protos::simple_op_store::RemoteBookmark {
730 remote_name: remote_name.into(),
731 target: ref_target_to_proto(&remote_ref.target),
732 state: Some(remote_ref_state_to_proto(remote_ref.state)),
733 },
734 )
735 .collect();
736 #[expect(deprecated)]
737 crate::protos::simple_op_store::Bookmark {
738 name: name.into(),
739 local_target,
740 remote_bookmarks,
741 }
742 })
743 .collect()
744}
745
746type BookmarkViews = (
747 BTreeMap<RefNameBuf, RefTarget>,
748 BTreeMap<RemoteNameBuf, RemoteView>,
749);
750
751fn bookmark_views_from_proto_legacy(
752 bookmarks_legacy: Vec<crate::protos::simple_op_store::Bookmark>,
753) -> Result<BookmarkViews, PostDecodeError> {
754 let mut local_bookmarks: BTreeMap<RefNameBuf, RefTarget> = BTreeMap::new();
755 let mut remote_views: BTreeMap<RemoteNameBuf, RemoteView> = BTreeMap::new();
756 for bookmark_proto in bookmarks_legacy {
757 let bookmark_name: RefNameBuf = bookmark_proto.name.into();
758 let local_target = ref_target_from_proto(bookmark_proto.local_target);
759 #[expect(deprecated)]
760 let remote_bookmarks = bookmark_proto.remote_bookmarks;
761 for remote_bookmark in remote_bookmarks {
762 let remote_name: RemoteNameBuf = remote_bookmark.remote_name.into();
763 let state = match remote_bookmark.state {
764 Some(n) => remote_ref_state_from_proto(n)?,
765 None => RemoteRefState::New,
769 };
770 let remote_view = remote_views.entry(remote_name).or_default();
771 let remote_ref = RemoteRef {
772 target: ref_target_from_proto(remote_bookmark.target),
773 state,
774 };
775 remote_view
776 .bookmarks
777 .insert(bookmark_name.clone(), remote_ref);
778 }
779 if local_target.is_present() {
780 local_bookmarks.insert(bookmark_name, local_target);
781 }
782 }
783 Ok((local_bookmarks, remote_views))
784}
785
786fn remote_views_to_proto(
787 remote_views: &BTreeMap<RemoteNameBuf, RemoteView>,
788) -> Vec<crate::protos::simple_op_store::RemoteView> {
789 remote_views
790 .iter()
791 .map(|(name, view)| crate::protos::simple_op_store::RemoteView {
792 name: name.into(),
793 bookmarks: remote_refs_to_proto(&view.bookmarks),
794 tags: remote_refs_to_proto(&view.tags),
795 })
796 .collect()
797}
798
799fn remote_views_from_proto(
800 remote_views_proto: Vec<crate::protos::simple_op_store::RemoteView>,
801) -> Result<BTreeMap<RemoteNameBuf, RemoteView>, PostDecodeError> {
802 remote_views_proto
803 .into_iter()
804 .map(|proto| {
805 let name: RemoteNameBuf = proto.name.into();
806 let view = RemoteView {
807 bookmarks: remote_refs_from_proto(proto.bookmarks)?,
808 tags: remote_refs_from_proto(proto.tags)?,
809 };
810 Ok((name, view))
811 })
812 .collect()
813}
814
815fn remote_refs_to_proto(
816 remote_refs: &BTreeMap<RefNameBuf, RemoteRef>,
817) -> Vec<crate::protos::simple_op_store::RemoteRef> {
818 remote_refs
819 .iter()
820 .map(
821 |(name, remote_ref)| crate::protos::simple_op_store::RemoteRef {
822 name: name.into(),
823 target_terms: ref_target_to_terms_proto(&remote_ref.target),
824 state: remote_ref_state_to_proto(remote_ref.state),
825 },
826 )
827 .collect()
828}
829
830fn remote_refs_from_proto(
831 remote_refs_proto: Vec<crate::protos::simple_op_store::RemoteRef>,
832) -> Result<BTreeMap<RefNameBuf, RemoteRef>, PostDecodeError> {
833 remote_refs_proto
834 .into_iter()
835 .map(|proto| {
836 let name: RefNameBuf = proto.name.into();
837 let remote_ref = RemoteRef {
838 target: ref_target_from_terms_proto(proto.target_terms)?,
839 state: remote_ref_state_from_proto(proto.state)?,
840 };
841 Ok((name, remote_ref))
842 })
843 .collect()
844}
845
846fn ref_target_to_terms_proto(
847 value: &RefTarget,
848) -> Vec<crate::protos::simple_op_store::RefTargetTerm> {
849 value
850 .as_merge()
851 .iter()
852 .map(|term| term.as_ref().map(|id| id.to_bytes()))
853 .map(|value| crate::protos::simple_op_store::RefTargetTerm { value })
854 .collect()
855}
856
857fn ref_target_from_terms_proto(
858 proto: Vec<crate::protos::simple_op_store::RefTargetTerm>,
859) -> Result<RefTarget, PostDecodeError> {
860 let terms: SmallVec<[_; 1]> = proto
861 .into_iter()
862 .map(|crate::protos::simple_op_store::RefTargetTerm { value }| value.map(CommitId::new))
863 .collect();
864 if terms.len().is_multiple_of(2) {
865 Err(PostDecodeError::EvenNumberOfRefTargetTerms(terms.len()))
866 } else {
867 Ok(RefTarget::from_merge(Merge::from_vec(terms)))
868 }
869}
870
871fn ref_target_to_proto(value: &RefTarget) -> Option<crate::protos::simple_op_store::RefTarget> {
872 let term_to_proto =
873 |term: &Option<CommitId>| crate::protos::simple_op_store::ref_conflict::Term {
874 value: term.as_ref().map(|id| id.to_bytes()),
875 };
876 let merge = value.as_merge();
877 let conflict_proto = crate::protos::simple_op_store::RefConflict {
878 removes: merge.removes().map(term_to_proto).collect(),
879 adds: merge.adds().map(term_to_proto).collect(),
880 };
881 let proto = crate::protos::simple_op_store::RefTarget {
882 value: Some(crate::protos::simple_op_store::ref_target::Value::Conflict(
883 conflict_proto,
884 )),
885 };
886 Some(proto)
887}
888
889#[expect(deprecated)]
890#[cfg(test)]
891fn ref_target_to_proto_legacy(
892 value: &RefTarget,
893) -> Option<crate::protos::simple_op_store::RefTarget> {
894 if let Some(id) = value.as_normal() {
895 let proto = crate::protos::simple_op_store::RefTarget {
896 value: Some(crate::protos::simple_op_store::ref_target::Value::CommitId(
897 id.to_bytes(),
898 )),
899 };
900 Some(proto)
901 } else if value.has_conflict() {
902 let ref_conflict_proto = crate::protos::simple_op_store::RefConflictLegacy {
903 removes: value.removed_ids().map(|id| id.to_bytes()).collect(),
904 adds: value.added_ids().map(|id| id.to_bytes()).collect(),
905 };
906 let proto = crate::protos::simple_op_store::RefTarget {
907 value: Some(
908 crate::protos::simple_op_store::ref_target::Value::ConflictLegacy(
909 ref_conflict_proto,
910 ),
911 ),
912 };
913 Some(proto)
914 } else {
915 assert!(value.is_absent());
916 None
917 }
918}
919
920fn ref_target_from_proto(
921 maybe_proto: Option<crate::protos::simple_op_store::RefTarget>,
922) -> RefTarget {
923 let Some(proto) = maybe_proto else {
926 return RefTarget::absent();
928 };
929 match proto.value.unwrap() {
930 #[expect(deprecated)]
931 crate::protos::simple_op_store::ref_target::Value::CommitId(id) => {
932 RefTarget::normal(CommitId::new(id))
934 }
935 #[expect(deprecated)]
936 crate::protos::simple_op_store::ref_target::Value::ConflictLegacy(conflict) => {
937 let removes = conflict.removes.into_iter().map(CommitId::new);
939 let adds = conflict.adds.into_iter().map(CommitId::new);
940 RefTarget::from_legacy_form(removes, adds)
941 }
942 crate::protos::simple_op_store::ref_target::Value::Conflict(conflict) => {
943 let term_from_proto = |term: crate::protos::simple_op_store::ref_conflict::Term| {
944 term.value.map(CommitId::new)
945 };
946 let removes = conflict.removes.into_iter().map(term_from_proto);
947 let adds = conflict.adds.into_iter().map(term_from_proto);
948 RefTarget::from_merge(Merge::from_removes_adds(removes, adds))
949 }
950 }
951}
952
953fn remote_ref_state_to_proto(state: RemoteRefState) -> i32 {
954 let proto_state = match state {
955 RemoteRefState::New => crate::protos::simple_op_store::RemoteRefState::New,
956 RemoteRefState::Tracked => crate::protos::simple_op_store::RemoteRefState::Tracked,
957 };
958 proto_state as i32
959}
960
961fn remote_ref_state_from_proto(proto_value: i32) -> Result<RemoteRefState, PostDecodeError> {
962 let proto_state = proto_value
963 .try_into()
964 .map_err(|prost::UnknownEnumValue(n)| PostDecodeError::InvalidRemoteRefStateValue(n))?;
965 let state = match proto_state {
966 crate::protos::simple_op_store::RemoteRefState::New => RemoteRefState::New,
967 crate::protos::simple_op_store::RemoteRefState::Tracked => RemoteRefState::Tracked,
968 };
969 Ok(state)
970}
971
972#[cfg(test)]
973mod tests {
974 use insta::assert_snapshot;
975 use maplit::btreemap;
976 use maplit::hashset;
977
978 use super::*;
979 use crate::hex_util;
980 use crate::tests::TestResult;
981 use crate::tests::new_temp_dir;
982
983 fn create_view() -> View {
984 let new_remote_ref = |target: &RefTarget| RemoteRef {
985 target: target.clone(),
986 state: RemoteRefState::New,
987 };
988 let tracked_remote_ref = |target: &RefTarget| RemoteRef {
989 target: target.clone(),
990 state: RemoteRefState::Tracked,
991 };
992 let head_id1 = CommitId::from_hex("aaa111");
993 let head_id2 = CommitId::from_hex("aaa222");
994 let bookmark_main_local_target = RefTarget::normal(CommitId::from_hex("ccc111"));
995 let bookmark_main_origin_target = RefTarget::normal(CommitId::from_hex("ccc222"));
996 let bookmark_deleted_origin_target = RefTarget::normal(CommitId::from_hex("ccc333"));
997 let tag_v1_local_target = RefTarget::normal(CommitId::from_hex("ddd111"));
998 let tag_v1_origin_target = RefTarget::normal(CommitId::from_hex("ddd222"));
999 let tag_deleted_origin_target = RefTarget::normal(CommitId::from_hex("ddd333"));
1000 let git_refs_main_target = RefTarget::normal(CommitId::from_hex("fff111"));
1001 let git_refs_feature_target = RefTarget::from_legacy_form(
1002 [CommitId::from_hex("fff111")],
1003 [CommitId::from_hex("fff222"), CommitId::from_hex("fff333")],
1004 );
1005 let default_wc_commit_id = CommitId::from_hex("abc111");
1006 let test_wc_commit_id = CommitId::from_hex("abc222");
1007 View {
1008 head_ids: hashset! {head_id1, head_id2},
1009 local_bookmarks: btreemap! {
1010 "main".into() => bookmark_main_local_target,
1011 },
1012 local_tags: btreemap! {
1013 "v1.0".into() => tag_v1_local_target,
1014 },
1015 remote_views: btreemap! {
1016 "origin".into() => RemoteView {
1017 bookmarks: btreemap! {
1018 "main".into() => tracked_remote_ref(&bookmark_main_origin_target),
1019 "deleted".into() => new_remote_ref(&bookmark_deleted_origin_target),
1020 },
1021 tags: btreemap! {
1022 "v1.0".into() => tracked_remote_ref(&tag_v1_origin_target),
1023 "deleted".into() => new_remote_ref(&tag_deleted_origin_target),
1024 },
1025 },
1026 },
1027 git_refs: btreemap! {
1028 "refs/heads/main".into() => git_refs_main_target,
1029 "refs/heads/feature".into() => git_refs_feature_target,
1030 },
1031 git_heads: btreemap! {
1032 WorkspaceName::DEFAULT.to_owned() => RefTarget::normal(CommitId::from_hex("fff111")),
1033 },
1034 wc_commit_ids: btreemap! {
1035 WorkspaceName::DEFAULT.to_owned() => default_wc_commit_id,
1036 "test".into() => test_wc_commit_id,
1037 },
1038 }
1039 }
1040
1041 fn create_operation() -> Operation {
1042 let pad_id_bytes = |hex: &str, len: usize| {
1043 let mut bytes = hex_util::decode_hex(hex).unwrap();
1044 bytes.resize(len, b'\0');
1045 bytes
1046 };
1047 Operation {
1048 view_id: ViewId::new(pad_id_bytes("aaa111", VIEW_ID_LENGTH)),
1049 parents: vec![
1050 OperationId::new(pad_id_bytes("bbb111", OPERATION_ID_LENGTH)),
1051 OperationId::new(pad_id_bytes("bbb222", OPERATION_ID_LENGTH)),
1052 ],
1053 metadata: OperationMetadata {
1054 time: TimestampRange {
1055 start: Timestamp {
1056 timestamp: MillisSinceEpoch(123456789),
1057 tz_offset: 3600,
1058 },
1059 end: Timestamp {
1060 timestamp: MillisSinceEpoch(123456800),
1061 tz_offset: 3600,
1062 },
1063 },
1064 description: "check out foo".to_string(),
1065 hostname: "some.host.example.com".to_string(),
1066 username: "someone".to_string(),
1067 is_snapshot: false,
1068 workspace_name: Some(WorkspaceNameBuf::from("test")),
1069 attributes: btreemap! {
1070 "key1".to_string() => "value1".to_string(),
1071 "key2".to_string() => "value2".to_string(),
1072 },
1073 },
1074 commit_predecessors: Some(btreemap! {
1075 CommitId::from_hex("111111") => vec![],
1076 CommitId::from_hex("222222") => vec![
1077 CommitId::from_hex("333333"),
1078 CommitId::from_hex("444444"),
1079 ],
1080 }),
1081 }
1082 }
1083
1084 #[test]
1085 fn test_hash_view() {
1086 assert_snapshot!(
1088 ViewId::new(blake2b_hash(&create_view()).to_vec()).hex(),
1089 @"b37a61a743f394241cd44e9016cc6f9b68321d7ae0e21e432d9124d08a1b5f98f08f6a1d04534ea0652ea4be74e9c4fb2075bdf00343165b5d380aa196790a14"
1090 );
1091 }
1092
1093 #[test]
1094 fn test_hash_operation() {
1095 assert_snapshot!(
1097 OperationId::new(blake2b_hash(&create_operation()).to_vec()).hex(),
1098 @"f5963c593a63bb852061a86ad919c12c6ba1940eeef30a832524c39ccea6a9f768aa2aa53becec34d379eb291ec6726837c4113857849cb9dcc62dbe0a517176"
1099 );
1100 }
1101
1102 #[test]
1103 fn test_read_write_view() -> TestResult {
1104 let temp_dir = new_temp_dir();
1105 let root_data = RootOperationData {
1106 root_commit_id: CommitId::from_hex("000000"),
1107 };
1108 let store = SimpleOpStore::init(temp_dir.path(), root_data)?;
1109 let view = create_view();
1110 let view_id = store.write_view(&view).block_on()?;
1111 let read_view = store.read_view(&view_id).block_on()?;
1112 assert_eq!(read_view, view);
1113 Ok(())
1114 }
1115
1116 #[test]
1117 fn test_read_write_operation() -> TestResult {
1118 let temp_dir = new_temp_dir();
1119 let root_data = RootOperationData {
1120 root_commit_id: CommitId::from_hex("000000"),
1121 };
1122 let store = SimpleOpStore::init(temp_dir.path(), root_data)?;
1123 let operation = create_operation();
1124 let op_id = store.write_operation(&operation).block_on()?;
1125 let read_operation = store.read_operation(&op_id).block_on()?;
1126 assert_eq!(read_operation, operation);
1127 Ok(())
1128 }
1129
1130 #[test]
1131 fn test_remote_views_legacy_roundtrip() {
1132 let mut view = create_view();
1133 assert!(!view.remote_views.is_empty());
1134 for remote_view in view.remote_views.values_mut() {
1135 remote_view.tags.clear();
1137 }
1138 let mut proto = view_to_proto(&view);
1139 proto.remote_views.clear(); let view_reconstructed = view_from_proto(proto).unwrap();
1141 assert_eq!(view.remote_views, view_reconstructed.remote_views);
1142 }
1143
1144 #[test]
1145 fn test_remote_views_new_roundtrip() {
1146 let view = create_view();
1147 assert!(!view.remote_views.is_empty());
1148 let mut proto = view_to_proto(&view);
1149 for bookmark in &mut proto.bookmarks {
1150 #[expect(deprecated)]
1151 bookmark.remote_bookmarks.clear(); }
1153 let view_reconstructed = view_from_proto(proto).unwrap();
1154 assert_eq!(view.remote_views, view_reconstructed.remote_views);
1155 }
1156
1157 #[test]
1158 fn test_migrate_git_refs_to_remote_tags() {
1159 let tracked_remote_ref = |target: &RefTarget| RemoteRef {
1160 target: target.clone(),
1161 state: RemoteRefState::Tracked,
1162 };
1163 let git_ref_to_proto = |name: &str, ref_target| crate::protos::simple_op_store::GitRef {
1164 name: name.to_owned(),
1165 #[expect(deprecated)]
1166 commit_id: Default::default(),
1167 target: ref_target_to_proto(ref_target),
1168 };
1169 let v1_target = RefTarget::normal(CommitId::from_hex("111111"));
1170 let main_target = RefTarget::normal(CommitId::from_hex("222222"));
1171 let orig_remote_views = btreemap! {
1172 "git".into() => RemoteView {
1173 bookmarks: btreemap! {
1174 "main".into() => tracked_remote_ref(&main_target),
1175 },
1176 tags: btreemap! {},
1177 },
1178 };
1179 let proto = crate::protos::simple_op_store::View {
1180 remote_views: remote_views_to_proto(&orig_remote_views),
1181 git_refs: vec![
1182 git_ref_to_proto("refs/tags/v1.0", &v1_target),
1183 git_ref_to_proto("refs/heads/main", &main_target),
1184 ],
1185 has_git_refs_migrated_to_remote_tags: false,
1186 ..Default::default()
1187 };
1188
1189 let view = view_from_proto(proto).unwrap();
1190 if cfg!(feature = "git") {
1191 assert_eq!(
1192 view.remote_views,
1193 btreemap! {
1194 "git".into() => RemoteView {
1195 bookmarks: btreemap! {
1196 "main".into() => tracked_remote_ref(&main_target),
1197 },
1198 tags: btreemap! {
1199 "v1.0".into() => tracked_remote_ref(&v1_target),
1200 },
1201 },
1202 }
1203 );
1204 } else {
1205 assert_eq!(view.remote_views, orig_remote_views);
1206 }
1207
1208 let mut proto = view_to_proto(&view);
1210 assert!(proto.has_git_refs_migrated_to_remote_tags);
1211 for view_proto in &mut proto.remote_views {
1212 view_proto.tags.clear();
1213 }
1214 let view = view_from_proto(proto).unwrap();
1215 assert_eq!(view.remote_views, orig_remote_views);
1216 }
1217
1218 #[test]
1219 fn test_bookmark_views_legacy_roundtrip() {
1220 let new_remote_ref = |target: &RefTarget| RemoteRef {
1221 target: target.clone(),
1222 state: RemoteRefState::New,
1223 };
1224 let tracked_remote_ref = |target: &RefTarget| RemoteRef {
1225 target: target.clone(),
1226 state: RemoteRefState::Tracked,
1227 };
1228 let local_bookmark1_target = RefTarget::normal(CommitId::from_hex("111111"));
1229 let local_bookmark3_target = RefTarget::normal(CommitId::from_hex("222222"));
1230 let git_bookmark1_target = RefTarget::normal(CommitId::from_hex("333333"));
1231 let remote1_bookmark1_target = RefTarget::normal(CommitId::from_hex("444444"));
1232 let remote2_bookmark2_target = RefTarget::normal(CommitId::from_hex("555555"));
1233 let remote2_bookmark4_target = RefTarget::normal(CommitId::from_hex("666666"));
1234 let local_bookmarks = btreemap! {
1235 "bookmark1".into() => local_bookmark1_target.clone(),
1236 "bookmark3".into() => local_bookmark3_target.clone(),
1237 };
1238 let remote_views = btreemap! {
1239 "git".into() => RemoteView {
1240 bookmarks: btreemap! {
1241 "bookmark1".into() => tracked_remote_ref(&git_bookmark1_target),
1242 },
1243 tags: btreemap! {},
1244 },
1245 "remote1".into() => RemoteView {
1246 bookmarks: btreemap! {
1247 "bookmark1".into() => tracked_remote_ref(&remote1_bookmark1_target),
1248 },
1249 tags: btreemap! {},
1250 },
1251 "remote2".into() => RemoteView {
1252 bookmarks: btreemap! {
1253 "bookmark2".into() => new_remote_ref(&remote2_bookmark2_target),
1255 "bookmark4".into() => tracked_remote_ref(&remote2_bookmark4_target),
1256 },
1257 tags: btreemap! {},
1258 },
1259 };
1260
1261 let bookmarks_legacy = bookmark_views_to_proto_legacy(&local_bookmarks, &remote_views);
1262 assert_eq!(
1263 bookmarks_legacy
1264 .iter()
1265 .map(|proto| &proto.name)
1266 .sorted()
1267 .collect_vec(),
1268 vec!["bookmark1", "bookmark2", "bookmark3", "bookmark4"],
1269 );
1270
1271 let (local_bookmarks_reconstructed, remote_views_reconstructed) =
1272 bookmark_views_from_proto_legacy(bookmarks_legacy).unwrap();
1273 assert_eq!(local_bookmarks_reconstructed, local_bookmarks);
1274 assert_eq!(remote_views_reconstructed, remote_views);
1275 }
1276
1277 #[test]
1278 fn test_ref_target_change_delete_order_roundtrip() {
1279 let target = RefTarget::from_merge(Merge::from_removes_adds(
1280 vec![Some(CommitId::from_hex("111111"))],
1281 vec![Some(CommitId::from_hex("222222")), None],
1282 ));
1283 let maybe_proto = ref_target_to_proto(&target);
1284 assert_eq!(ref_target_from_proto(maybe_proto), target);
1285
1286 let target = RefTarget::from_merge(Merge::from_removes_adds(
1288 vec![Some(CommitId::from_hex("111111"))],
1289 vec![None, Some(CommitId::from_hex("222222"))],
1290 ));
1291 let maybe_proto = ref_target_to_proto(&target);
1292 assert_eq!(ref_target_from_proto(maybe_proto), target);
1293 }
1294
1295 #[test]
1296 fn test_ref_target_legacy_roundtrip() {
1297 let target = RefTarget::absent();
1298 let maybe_proto = ref_target_to_proto_legacy(&target);
1299 assert_eq!(ref_target_from_proto(maybe_proto), target);
1300
1301 let target = RefTarget::normal(CommitId::from_hex("111111"));
1302 let maybe_proto = ref_target_to_proto_legacy(&target);
1303 assert_eq!(ref_target_from_proto(maybe_proto), target);
1304
1305 let target = RefTarget::from_legacy_form(
1307 [CommitId::from_hex("111111"), CommitId::from_hex("222222")],
1308 [
1309 CommitId::from_hex("333333"),
1310 CommitId::from_hex("444444"),
1311 CommitId::from_hex("555555"),
1312 ],
1313 );
1314 let maybe_proto = ref_target_to_proto_legacy(&target);
1315 assert_eq!(ref_target_from_proto(maybe_proto), target);
1316
1317 let target = RefTarget::from_legacy_form(
1319 [CommitId::from_hex("111111")],
1320 [CommitId::from_hex("222222")],
1321 );
1322 let maybe_proto = ref_target_to_proto_legacy(&target);
1323 assert_eq!(ref_target_from_proto(maybe_proto), target);
1324 }
1325}