1#![allow(missing_docs)]
16
17use std::any::Any;
18use std::collections::BTreeMap;
19use std::collections::HashMap;
20use std::collections::HashSet;
21use std::fmt::Debug;
22use std::fs;
23use std::io;
24use std::io::ErrorKind;
25use std::io::Write as _;
26use std::path::Path;
27use std::path::PathBuf;
28use std::time::SystemTime;
29
30use itertools::Itertools as _;
31use prost::Message as _;
32use tempfile::NamedTempFile;
33use thiserror::Error;
34
35use crate::backend::BackendInitError;
36use crate::backend::CommitId;
37use crate::backend::MillisSinceEpoch;
38use crate::backend::Timestamp;
39use crate::content_hash::blake2b_hash;
40use crate::dag_walk;
41use crate::file_util::persist_content_addressed_temp_file;
42use crate::file_util::IoResultExt as _;
43use crate::file_util::PathError;
44use crate::merge::Merge;
45use crate::object_id::HexPrefix;
46use crate::object_id::ObjectId;
47use crate::object_id::PrefixResolution;
48use crate::op_store;
49use crate::op_store::OpStore;
50use crate::op_store::OpStoreError;
51use crate::op_store::OpStoreResult;
52use crate::op_store::Operation;
53use crate::op_store::OperationId;
54use crate::op_store::OperationMetadata;
55use crate::op_store::RefTarget;
56use crate::op_store::RemoteRef;
57use crate::op_store::RemoteRefState;
58use crate::op_store::RemoteView;
59use crate::op_store::RootOperationData;
60use crate::op_store::View;
61use crate::op_store::ViewId;
62use crate::ref_name::GitRefNameBuf;
63use crate::ref_name::RefNameBuf;
64use crate::ref_name::RemoteNameBuf;
65use crate::ref_name::WorkspaceName;
66use crate::ref_name::WorkspaceNameBuf;
67
68const OPERATION_ID_LENGTH: usize = 64;
70const VIEW_ID_LENGTH: usize = 64;
71
72#[derive(Debug, Error)]
74#[error("Failed to initialize simple operation store")]
75pub struct SimpleOpStoreInitError(#[from] pub PathError);
76
77impl From<SimpleOpStoreInitError> for BackendInitError {
78 fn from(err: SimpleOpStoreInitError) -> Self {
79 BackendInitError(err.into())
80 }
81}
82
83#[derive(Debug)]
84pub struct SimpleOpStore {
85 path: PathBuf,
86 root_data: RootOperationData,
87 root_operation_id: OperationId,
88 root_view_id: ViewId,
89}
90
91impl SimpleOpStore {
92 pub fn name() -> &'static str {
93 "simple_op_store"
94 }
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 SimpleOpStore {
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
136impl OpStore for SimpleOpStore {
137 fn as_any(&self) -> &dyn Any {
138 self
139 }
140
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 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::op_store::View::decode(&*buf)
160 .map_err(|err| to_read_error(err.into(), id))?;
161 Ok(view_from_proto(proto))
162 }
163
164 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 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::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 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 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 Ok(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 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).ok()
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).ok()
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| self.read_operation(id).map(|data| (id.clone(), data));
305 let reachable_ops: HashMap<OperationId, Operation> = dag_walk::dfs_ok(
306 head_ids.iter().map(read_op),
307 |(id, _)| id.clone(),
308 |(_, data)| data.parents.iter().map(read_op).collect_vec(),
309 )
310 .try_collect()?;
311 let reachable_views: HashSet<&ViewId> =
312 reachable_ops.values().map(|data| &data.view_id).collect();
313 tracing::info!(
314 reachable_op_count = reachable_ops.len(),
315 reachable_view_count = reachable_views.len(),
316 "collected reachable objects"
317 );
318
319 let prune_ops = || -> Result<(), PathError> {
320 let op_dir = self.operations_dir();
321 for entry in op_dir.read_dir().context(&op_dir)? {
322 let entry = entry.context(&op_dir)?;
323 let Some(id) = to_op_id(&entry) else {
324 tracing::trace!(?entry, "skipping invalid file name");
325 continue;
326 };
327 if reachable_ops.contains_key(&id) {
328 continue;
329 }
330 remove_file_if_not_new(&entry)?;
334 }
335 Ok(())
336 };
337 prune_ops().map_err(|err| OpStoreError::Other(err.into()))?;
338
339 let prune_views = || -> Result<(), PathError> {
340 let view_dir = self.views_dir();
341 for entry in view_dir.read_dir().context(&view_dir)? {
342 let entry = entry.context(&view_dir)?;
343 let Some(id) = to_view_id(&entry) else {
344 tracing::trace!(?entry, "skipping invalid file name");
345 continue;
346 };
347 if reachable_views.contains(&id) {
348 continue;
349 }
350 remove_file_if_not_new(&entry)?;
351 }
352 Ok(())
353 };
354 prune_views().map_err(|err| OpStoreError::Other(err.into()))?;
355
356 Ok(())
357 }
358}
359
360fn io_to_read_error(err: PathError, id: &impl ObjectId) -> OpStoreError {
361 if err.error.kind() == ErrorKind::NotFound {
362 OpStoreError::ObjectNotFound {
363 object_type: id.object_type(),
364 hash: id.hex(),
365 source: Box::new(err),
366 }
367 } else {
368 to_read_error(err.into(), id)
369 }
370}
371
372fn to_read_error(
373 source: Box<dyn std::error::Error + Send + Sync>,
374 id: &impl ObjectId,
375) -> OpStoreError {
376 OpStoreError::ReadObject {
377 object_type: id.object_type(),
378 hash: id.hex(),
379 source,
380 }
381}
382
383fn io_to_write_error(err: PathError, object_type: &'static str) -> OpStoreError {
384 OpStoreError::WriteObject {
385 object_type,
386 source: Box::new(err),
387 }
388}
389
390#[derive(Debug, Error)]
391enum PostDecodeError {
392 #[error("Invalid hash length (expected {expected} bytes, got {actual} bytes)")]
393 InvalidHashLength { expected: usize, actual: usize },
394}
395
396fn operation_id_from_proto(bytes: Vec<u8>) -> Result<OperationId, PostDecodeError> {
397 if bytes.len() != OPERATION_ID_LENGTH {
398 Err(PostDecodeError::InvalidHashLength {
399 expected: OPERATION_ID_LENGTH,
400 actual: bytes.len(),
401 })
402 } else {
403 Ok(OperationId::new(bytes))
404 }
405}
406
407fn view_id_from_proto(bytes: Vec<u8>) -> Result<ViewId, PostDecodeError> {
408 if bytes.len() != VIEW_ID_LENGTH {
409 Err(PostDecodeError::InvalidHashLength {
410 expected: VIEW_ID_LENGTH,
411 actual: bytes.len(),
412 })
413 } else {
414 Ok(ViewId::new(bytes))
415 }
416}
417
418fn timestamp_to_proto(timestamp: &Timestamp) -> crate::protos::op_store::Timestamp {
419 crate::protos::op_store::Timestamp {
420 millis_since_epoch: timestamp.timestamp.0,
421 tz_offset: timestamp.tz_offset,
422 }
423}
424
425fn timestamp_from_proto(proto: crate::protos::op_store::Timestamp) -> Timestamp {
426 Timestamp {
427 timestamp: MillisSinceEpoch(proto.millis_since_epoch),
428 tz_offset: proto.tz_offset,
429 }
430}
431
432fn operation_metadata_to_proto(
433 metadata: &OperationMetadata,
434) -> crate::protos::op_store::OperationMetadata {
435 crate::protos::op_store::OperationMetadata {
436 start_time: Some(timestamp_to_proto(&metadata.start_time)),
437 end_time: Some(timestamp_to_proto(&metadata.end_time)),
438 description: metadata.description.clone(),
439 hostname: metadata.hostname.clone(),
440 username: metadata.username.clone(),
441 is_snapshot: metadata.is_snapshot,
442 tags: metadata.tags.clone(),
443 }
444}
445
446fn operation_metadata_from_proto(
447 proto: crate::protos::op_store::OperationMetadata,
448) -> OperationMetadata {
449 let start_time = timestamp_from_proto(proto.start_time.unwrap_or_default());
450 let end_time = timestamp_from_proto(proto.end_time.unwrap_or_default());
451 OperationMetadata {
452 start_time,
453 end_time,
454 description: proto.description,
455 hostname: proto.hostname,
456 username: proto.username,
457 is_snapshot: proto.is_snapshot,
458 tags: proto.tags,
459 }
460}
461
462fn commit_predecessors_map_to_proto(
463 map: &BTreeMap<CommitId, Vec<CommitId>>,
464) -> Vec<crate::protos::op_store::CommitPredecessors> {
465 map.iter()
466 .map(
467 |(commit_id, predecessor_ids)| crate::protos::op_store::CommitPredecessors {
468 commit_id: commit_id.to_bytes(),
469 predecessor_ids: predecessor_ids.iter().map(|id| id.to_bytes()).collect(),
470 },
471 )
472 .collect()
473}
474
475fn commit_predecessors_map_from_proto(
476 proto: Vec<crate::protos::op_store::CommitPredecessors>,
477) -> BTreeMap<CommitId, Vec<CommitId>> {
478 proto
479 .into_iter()
480 .map(|entry| {
481 let commit_id = CommitId::new(entry.commit_id);
482 let predecessor_ids = entry
483 .predecessor_ids
484 .into_iter()
485 .map(CommitId::new)
486 .collect();
487 (commit_id, predecessor_ids)
488 })
489 .collect()
490}
491
492fn operation_to_proto(operation: &Operation) -> crate::protos::op_store::Operation {
493 let (commit_predecessors, stores_commit_predecessors) = match &operation.commit_predecessors {
494 Some(map) => (commit_predecessors_map_to_proto(map), true),
495 None => (vec![], false),
496 };
497 let mut proto = crate::protos::op_store::Operation {
498 view_id: operation.view_id.as_bytes().to_vec(),
499 parents: Default::default(),
500 metadata: Some(operation_metadata_to_proto(&operation.metadata)),
501 commit_predecessors,
502 stores_commit_predecessors,
503 };
504 for parent in &operation.parents {
505 proto.parents.push(parent.to_bytes());
506 }
507 proto
508}
509
510fn operation_from_proto(
511 proto: crate::protos::op_store::Operation,
512) -> Result<Operation, PostDecodeError> {
513 let parents = proto
514 .parents
515 .into_iter()
516 .map(operation_id_from_proto)
517 .try_collect()?;
518 let view_id = view_id_from_proto(proto.view_id)?;
519 let metadata = operation_metadata_from_proto(proto.metadata.unwrap_or_default());
520 let commit_predecessors = proto
521 .stores_commit_predecessors
522 .then(|| commit_predecessors_map_from_proto(proto.commit_predecessors));
523 Ok(Operation {
524 view_id,
525 parents,
526 metadata,
527 commit_predecessors,
528 })
529}
530
531fn view_to_proto(view: &View) -> crate::protos::op_store::View {
532 let mut proto = crate::protos::op_store::View {
533 ..Default::default()
534 };
535 for (name, commit_id) in &view.wc_commit_ids {
536 proto
537 .wc_commit_ids
538 .insert(name.into(), commit_id.to_bytes());
539 }
540 for head_id in &view.head_ids {
541 proto.head_ids.push(head_id.to_bytes());
542 }
543
544 proto.bookmarks = bookmark_views_to_proto_legacy(&view.local_bookmarks, &view.remote_views);
545
546 for (name, target) in &view.tags {
547 proto.tags.push(crate::protos::op_store::Tag {
548 name: name.into(),
549 target: ref_target_to_proto(target),
550 });
551 }
552
553 for (git_ref_name, target) in &view.git_refs {
554 proto.git_refs.push(crate::protos::op_store::GitRef {
555 name: git_ref_name.into(),
556 target: ref_target_to_proto(target),
557 ..Default::default()
558 });
559 }
560
561 proto.git_head = ref_target_to_proto(&view.git_head);
562
563 proto
564}
565
566fn view_from_proto(proto: crate::protos::op_store::View) -> View {
567 let mut view = View::empty();
569 #[expect(deprecated)]
572 if !proto.wc_commit_id.is_empty() {
573 view.wc_commit_ids.insert(
574 WorkspaceName::DEFAULT.to_owned(),
575 CommitId::new(proto.wc_commit_id),
576 );
577 }
578 for (name, commit_id) in proto.wc_commit_ids {
579 view.wc_commit_ids
580 .insert(WorkspaceNameBuf::from(name), CommitId::new(commit_id));
581 }
582 for head_id_bytes in proto.head_ids {
583 view.head_ids.insert(CommitId::new(head_id_bytes));
584 }
585
586 let (local_bookmarks, remote_views) = bookmark_views_from_proto_legacy(proto.bookmarks);
587 view.local_bookmarks = local_bookmarks;
588 view.remote_views = remote_views;
589
590 for tag_proto in proto.tags {
591 let name: RefNameBuf = tag_proto.name.into();
592 view.tags
593 .insert(name, ref_target_from_proto(tag_proto.target));
594 }
595
596 for git_ref in proto.git_refs {
597 let name: GitRefNameBuf = git_ref.name.into();
598 let target = if git_ref.target.is_some() {
599 ref_target_from_proto(git_ref.target)
600 } else {
601 RefTarget::normal(CommitId::new(git_ref.commit_id))
603 };
604 view.git_refs.insert(name, target);
605 }
606
607 #[expect(deprecated)]
608 if proto.git_head.is_some() {
609 view.git_head = ref_target_from_proto(proto.git_head);
610 } else if !proto.git_head_legacy.is_empty() {
611 view.git_head = RefTarget::normal(CommitId::new(proto.git_head_legacy));
612 }
613
614 view
615}
616
617fn bookmark_views_to_proto_legacy(
618 local_bookmarks: &BTreeMap<RefNameBuf, RefTarget>,
619 remote_views: &BTreeMap<RemoteNameBuf, RemoteView>,
620) -> Vec<crate::protos::op_store::Bookmark> {
621 op_store::merge_join_bookmark_views(local_bookmarks, remote_views)
622 .map(|(name, bookmark_target)| {
623 let local_target = ref_target_to_proto(bookmark_target.local_target);
624 let remote_bookmarks = bookmark_target
625 .remote_refs
626 .iter()
627 .map(
628 |&(remote_name, remote_ref)| crate::protos::op_store::RemoteBookmark {
629 remote_name: remote_name.into(),
630 target: ref_target_to_proto(&remote_ref.target),
631 state: remote_ref_state_to_proto(remote_ref.state),
632 },
633 )
634 .collect();
635 crate::protos::op_store::Bookmark {
636 name: name.into(),
637 local_target,
638 remote_bookmarks,
639 }
640 })
641 .collect()
642}
643
644fn bookmark_views_from_proto_legacy(
645 bookmarks_legacy: Vec<crate::protos::op_store::Bookmark>,
646) -> (
647 BTreeMap<RefNameBuf, RefTarget>,
648 BTreeMap<RemoteNameBuf, RemoteView>,
649) {
650 let mut local_bookmarks: BTreeMap<RefNameBuf, RefTarget> = BTreeMap::new();
651 let mut remote_views: BTreeMap<RemoteNameBuf, RemoteView> = BTreeMap::new();
652 for bookmark_proto in bookmarks_legacy {
653 let bookmark_name: RefNameBuf = bookmark_proto.name.into();
654 let local_target = ref_target_from_proto(bookmark_proto.local_target);
655 for remote_bookmark in bookmark_proto.remote_bookmarks {
656 let remote_name: RemoteNameBuf = remote_bookmark.remote_name.into();
657 let state = remote_ref_state_from_proto(remote_bookmark.state).unwrap_or_else(|| {
658 let is_git_tracking = crate::git::is_special_git_remote(&remote_name);
665 let default_state = if is_git_tracking || local_target.is_present() {
666 RemoteRefState::Tracked
667 } else {
668 RemoteRefState::New
669 };
670 tracing::trace!(
671 ?bookmark_name,
672 ?remote_name,
673 ?default_state,
674 "generated tracking state",
675 );
676 default_state
677 });
678 let remote_view = remote_views.entry(remote_name).or_default();
679 let remote_ref = RemoteRef {
680 target: ref_target_from_proto(remote_bookmark.target),
681 state,
682 };
683 remote_view
684 .bookmarks
685 .insert(bookmark_name.clone(), remote_ref);
686 }
687 if local_target.is_present() {
688 local_bookmarks.insert(bookmark_name, local_target);
689 }
690 }
691 (local_bookmarks, remote_views)
692}
693
694fn ref_target_to_proto(value: &RefTarget) -> Option<crate::protos::op_store::RefTarget> {
695 let term_to_proto = |term: &Option<CommitId>| crate::protos::op_store::ref_conflict::Term {
696 value: term.as_ref().map(|id| id.to_bytes()),
697 };
698 let merge = value.as_merge();
699 let conflict_proto = crate::protos::op_store::RefConflict {
700 removes: merge.removes().map(term_to_proto).collect(),
701 adds: merge.adds().map(term_to_proto).collect(),
702 };
703 let proto = crate::protos::op_store::RefTarget {
704 value: Some(crate::protos::op_store::ref_target::Value::Conflict(
705 conflict_proto,
706 )),
707 };
708 Some(proto)
709}
710
711#[expect(deprecated)]
712#[cfg(test)]
713fn ref_target_to_proto_legacy(value: &RefTarget) -> Option<crate::protos::op_store::RefTarget> {
714 if let Some(id) = value.as_normal() {
715 let proto = crate::protos::op_store::RefTarget {
716 value: Some(crate::protos::op_store::ref_target::Value::CommitId(
717 id.to_bytes(),
718 )),
719 };
720 Some(proto)
721 } else if value.has_conflict() {
722 let ref_conflict_proto = crate::protos::op_store::RefConflictLegacy {
723 removes: value.removed_ids().map(|id| id.to_bytes()).collect(),
724 adds: value.added_ids().map(|id| id.to_bytes()).collect(),
725 };
726 let proto = crate::protos::op_store::RefTarget {
727 value: Some(crate::protos::op_store::ref_target::Value::ConflictLegacy(
728 ref_conflict_proto,
729 )),
730 };
731 Some(proto)
732 } else {
733 assert!(value.is_absent());
734 None
735 }
736}
737
738fn ref_target_from_proto(maybe_proto: Option<crate::protos::op_store::RefTarget>) -> RefTarget {
739 let Some(proto) = maybe_proto else {
742 return RefTarget::absent();
744 };
745 match proto.value.unwrap() {
746 crate::protos::op_store::ref_target::Value::CommitId(id) => {
747 RefTarget::normal(CommitId::new(id))
749 }
750 #[expect(deprecated)]
751 crate::protos::op_store::ref_target::Value::ConflictLegacy(conflict) => {
752 let removes = conflict.removes.into_iter().map(CommitId::new);
754 let adds = conflict.adds.into_iter().map(CommitId::new);
755 RefTarget::from_legacy_form(removes, adds)
756 }
757 crate::protos::op_store::ref_target::Value::Conflict(conflict) => {
758 let term_from_proto =
759 |term: crate::protos::op_store::ref_conflict::Term| term.value.map(CommitId::new);
760 let removes = conflict.removes.into_iter().map(term_from_proto);
761 let adds = conflict.adds.into_iter().map(term_from_proto);
762 RefTarget::from_merge(Merge::from_removes_adds(removes, adds))
763 }
764 }
765}
766
767fn remote_ref_state_to_proto(state: RemoteRefState) -> Option<i32> {
768 let proto_state = match state {
769 RemoteRefState::New => crate::protos::op_store::RemoteRefState::New,
770 RemoteRefState::Tracked => crate::protos::op_store::RemoteRefState::Tracked,
771 };
772 Some(proto_state as i32)
773}
774
775fn remote_ref_state_from_proto(proto_value: Option<i32>) -> Option<RemoteRefState> {
776 let proto_state = proto_value?.try_into().ok()?;
777 let state = match proto_state {
778 crate::protos::op_store::RemoteRefState::New => RemoteRefState::New,
779 crate::protos::op_store::RemoteRefState::Tracked => RemoteRefState::Tracked,
780 };
781 Some(state)
782}
783
784#[cfg(test)]
785mod tests {
786 use insta::assert_snapshot;
787 use itertools::Itertools as _;
788 use maplit::btreemap;
789 use maplit::hashmap;
790 use maplit::hashset;
791
792 use super::*;
793 use crate::tests::new_temp_dir;
794
795 fn create_view() -> View {
796 let new_remote_ref = |target: &RefTarget| RemoteRef {
797 target: target.clone(),
798 state: RemoteRefState::New,
799 };
800 let tracked_remote_ref = |target: &RefTarget| RemoteRef {
801 target: target.clone(),
802 state: RemoteRefState::Tracked,
803 };
804 let head_id1 = CommitId::from_hex("aaa111");
805 let head_id2 = CommitId::from_hex("aaa222");
806 let bookmark_main_local_target = RefTarget::normal(CommitId::from_hex("ccc111"));
807 let bookmark_main_origin_target = RefTarget::normal(CommitId::from_hex("ccc222"));
808 let bookmark_deleted_origin_target = RefTarget::normal(CommitId::from_hex("ccc333"));
809 let tag_v1_target = RefTarget::normal(CommitId::from_hex("ddd111"));
810 let git_refs_main_target = RefTarget::normal(CommitId::from_hex("fff111"));
811 let git_refs_feature_target = RefTarget::from_legacy_form(
812 [CommitId::from_hex("fff111")],
813 [CommitId::from_hex("fff222"), CommitId::from_hex("fff333")],
814 );
815 let default_wc_commit_id = CommitId::from_hex("abc111");
816 let test_wc_commit_id = CommitId::from_hex("abc222");
817 View {
818 head_ids: hashset! {head_id1, head_id2},
819 local_bookmarks: btreemap! {
820 "main".into() => bookmark_main_local_target,
821 },
822 tags: btreemap! {
823 "v1.0".into() => tag_v1_target,
824 },
825 remote_views: btreemap! {
826 "origin".into() => RemoteView {
827 bookmarks: btreemap! {
828 "main".into() => tracked_remote_ref(&bookmark_main_origin_target),
829 "deleted".into() => new_remote_ref(&bookmark_deleted_origin_target),
830 },
831 },
832 },
833 git_refs: btreemap! {
834 "refs/heads/main".into() => git_refs_main_target,
835 "refs/heads/feature".into() => git_refs_feature_target,
836 },
837 git_head: RefTarget::normal(CommitId::from_hex("fff111")),
838 wc_commit_ids: btreemap! {
839 WorkspaceName::DEFAULT.to_owned() => default_wc_commit_id,
840 "test".into() => test_wc_commit_id,
841 },
842 }
843 }
844
845 fn create_operation() -> Operation {
846 let pad_id_bytes = |hex: &str, len: usize| {
847 let mut bytes = hex::decode(hex).unwrap();
848 bytes.resize(len, b'\0');
849 bytes
850 };
851 Operation {
852 view_id: ViewId::new(pad_id_bytes("aaa111", VIEW_ID_LENGTH)),
853 parents: vec![
854 OperationId::new(pad_id_bytes("bbb111", OPERATION_ID_LENGTH)),
855 OperationId::new(pad_id_bytes("bbb222", OPERATION_ID_LENGTH)),
856 ],
857 metadata: OperationMetadata {
858 start_time: Timestamp {
859 timestamp: MillisSinceEpoch(123456789),
860 tz_offset: 3600,
861 },
862 end_time: Timestamp {
863 timestamp: MillisSinceEpoch(123456800),
864 tz_offset: 3600,
865 },
866 description: "check out foo".to_string(),
867 hostname: "some.host.example.com".to_string(),
868 username: "someone".to_string(),
869 is_snapshot: false,
870 tags: hashmap! {
871 "key1".to_string() => "value1".to_string(),
872 "key2".to_string() => "value2".to_string(),
873 },
874 },
875 commit_predecessors: Some(btreemap! {
876 CommitId::from_hex("111111") => vec![],
877 CommitId::from_hex("222222") => vec![
878 CommitId::from_hex("333333"),
879 CommitId::from_hex("444444"),
880 ],
881 }),
882 }
883 }
884
885 #[test]
886 fn test_hash_view() {
887 assert_snapshot!(
889 ViewId::new(blake2b_hash(&create_view()).to_vec()).hex(),
890 @"f426676b3a2f7c6b9ec8677cb05ed249d0d244ab7e86a7c51117e2d8a4829db65e55970c761231e2107d303bf3d33a1f2afdd4ed2181f223e99753674b20a35e"
891 );
892 }
893
894 #[test]
895 fn test_hash_operation() {
896 assert_snapshot!(
898 OperationId::new(blake2b_hash(&create_operation()).to_vec()).hex(),
899 @"b544c80b5ededdd64d0f10468fa636a06b83c45d94dd9bdac95319f7fe11fee536506c5c110681dee6233e69db7647683e732939a3ec88e867250efd765fea18"
900 );
901 }
902
903 #[test]
904 fn test_read_write_view() {
905 let temp_dir = new_temp_dir();
906 let root_data = RootOperationData {
907 root_commit_id: CommitId::from_hex("000000"),
908 };
909 let store = SimpleOpStore::init(temp_dir.path(), root_data).unwrap();
910 let view = create_view();
911 let view_id = store.write_view(&view).unwrap();
912 let read_view = store.read_view(&view_id).unwrap();
913 assert_eq!(read_view, view);
914 }
915
916 #[test]
917 fn test_read_write_operation() {
918 let temp_dir = new_temp_dir();
919 let root_data = RootOperationData {
920 root_commit_id: CommitId::from_hex("000000"),
921 };
922 let store = SimpleOpStore::init(temp_dir.path(), root_data).unwrap();
923 let operation = create_operation();
924 let op_id = store.write_operation(&operation).unwrap();
925 let read_operation = store.read_operation(&op_id).unwrap();
926 assert_eq!(read_operation, operation);
927 }
928
929 #[test]
930 fn test_bookmark_views_legacy_roundtrip() {
931 let new_remote_ref = |target: &RefTarget| RemoteRef {
932 target: target.clone(),
933 state: RemoteRefState::New,
934 };
935 let tracked_remote_ref = |target: &RefTarget| RemoteRef {
936 target: target.clone(),
937 state: RemoteRefState::Tracked,
938 };
939 let local_bookmark1_target = RefTarget::normal(CommitId::from_hex("111111"));
940 let local_bookmark3_target = RefTarget::normal(CommitId::from_hex("222222"));
941 let git_bookmark1_target = RefTarget::normal(CommitId::from_hex("333333"));
942 let remote1_bookmark1_target = RefTarget::normal(CommitId::from_hex("444444"));
943 let remote2_bookmark2_target = RefTarget::normal(CommitId::from_hex("555555"));
944 let remote2_bookmark4_target = RefTarget::normal(CommitId::from_hex("666666"));
945 let local_bookmarks = btreemap! {
946 "bookmark1".into() => local_bookmark1_target.clone(),
947 "bookmark3".into() => local_bookmark3_target.clone(),
948 };
949 let remote_views = btreemap! {
950 "git".into() => RemoteView {
951 bookmarks: btreemap! {
952 "bookmark1".into() => tracked_remote_ref(&git_bookmark1_target),
953 },
954 },
955 "remote1".into() => RemoteView {
956 bookmarks: btreemap! {
957 "bookmark1".into() => tracked_remote_ref(&remote1_bookmark1_target),
958 },
959 },
960 "remote2".into() => RemoteView {
961 bookmarks: btreemap! {
962 "bookmark2".into() => new_remote_ref(&remote2_bookmark2_target),
964 "bookmark4".into() => tracked_remote_ref(&remote2_bookmark4_target),
965 },
966 },
967 };
968
969 let bookmarks_legacy = bookmark_views_to_proto_legacy(&local_bookmarks, &remote_views);
970 assert_eq!(
971 bookmarks_legacy
972 .iter()
973 .map(|proto| &proto.name)
974 .sorted()
975 .collect_vec(),
976 vec!["bookmark1", "bookmark2", "bookmark3", "bookmark4"],
977 );
978
979 let (local_bookmarks_reconstructed, remote_views_reconstructed) =
980 bookmark_views_from_proto_legacy(bookmarks_legacy);
981 assert_eq!(local_bookmarks_reconstructed, local_bookmarks);
982 assert_eq!(remote_views_reconstructed, remote_views);
983 }
984
985 #[test]
986 fn test_ref_target_change_delete_order_roundtrip() {
987 let target = RefTarget::from_merge(Merge::from_removes_adds(
988 vec![Some(CommitId::from_hex("111111"))],
989 vec![Some(CommitId::from_hex("222222")), None],
990 ));
991 let maybe_proto = ref_target_to_proto(&target);
992 assert_eq!(ref_target_from_proto(maybe_proto), target);
993
994 let target = RefTarget::from_merge(Merge::from_removes_adds(
996 vec![Some(CommitId::from_hex("111111"))],
997 vec![None, Some(CommitId::from_hex("222222"))],
998 ));
999 let maybe_proto = ref_target_to_proto(&target);
1000 assert_eq!(ref_target_from_proto(maybe_proto), target);
1001 }
1002
1003 #[test]
1004 fn test_ref_target_legacy_roundtrip() {
1005 let target = RefTarget::absent();
1006 let maybe_proto = ref_target_to_proto_legacy(&target);
1007 assert_eq!(ref_target_from_proto(maybe_proto), target);
1008
1009 let target = RefTarget::normal(CommitId::from_hex("111111"));
1010 let maybe_proto = ref_target_to_proto_legacy(&target);
1011 assert_eq!(ref_target_from_proto(maybe_proto), target);
1012
1013 let target = RefTarget::from_legacy_form(
1015 [CommitId::from_hex("111111"), CommitId::from_hex("222222")],
1016 [
1017 CommitId::from_hex("333333"),
1018 CommitId::from_hex("444444"),
1019 CommitId::from_hex("555555"),
1020 ],
1021 );
1022 let maybe_proto = ref_target_to_proto_legacy(&target);
1023 assert_eq!(ref_target_from_proto(maybe_proto), target);
1024
1025 let target = RefTarget::from_legacy_form(
1027 [CommitId::from_hex("111111")],
1028 [CommitId::from_hex("222222")],
1029 );
1030 let maybe_proto = ref_target_to_proto_legacy(&target);
1031 assert_eq!(ref_target_from_proto(maybe_proto), target);
1032 }
1033}