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