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).map_err(|err| io_to_read_error(err, id))?;
156
157 let proto = crate::protos::op_store::View::decode(&*buf)
158 .map_err(|err| to_read_error(err.into(), id))?;
159 Ok(view_from_proto(proto))
160 }
161
162 fn write_view(&self, view: &View) -> OpStoreResult<ViewId> {
163 let dir = self.views_dir();
164 let temp_file =
165 NamedTempFile::new_in(&dir).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 .map_err(|err| io_to_write_error(err, "view"))?;
172
173 let id = ViewId::new(blake2b_hash(view).to_vec());
174
175 persist_content_addressed_temp_file(temp_file, dir.join(id.hex()))
176 .map_err(|err| io_to_write_error(err, "view"))?;
177 Ok(id)
178 }
179
180 fn read_operation(&self, id: &OperationId) -> OpStoreResult<Operation> {
181 if *id == self.root_operation_id {
182 return Ok(Operation::make_root(self.root_view_id.clone()));
183 }
184
185 let path = self.operations_dir().join(id.hex());
186 let buf = fs::read(path).map_err(|err| io_to_read_error(err, id))?;
187
188 let proto = crate::protos::op_store::Operation::decode(&*buf)
189 .map_err(|err| to_read_error(err.into(), id))?;
190 let mut operation =
191 operation_from_proto(proto).map_err(|err| to_read_error(err.into(), id))?;
192 if operation.parents.is_empty() {
193 operation.parents.push(self.root_operation_id.clone());
196 }
197 Ok(operation)
198 }
199
200 fn write_operation(&self, operation: &Operation) -> OpStoreResult<OperationId> {
201 assert!(!operation.parents.is_empty());
202 let dir = self.operations_dir();
203 let temp_file =
204 NamedTempFile::new_in(&dir).map_err(|err| io_to_write_error(err, "operation"))?;
205
206 let proto = operation_to_proto(operation);
207 temp_file
208 .as_file()
209 .write_all(&proto.encode_to_vec())
210 .map_err(|err| io_to_write_error(err, "operation"))?;
211
212 let id = OperationId::new(blake2b_hash(operation).to_vec());
213
214 persist_content_addressed_temp_file(temp_file, dir.join(id.hex()))
215 .map_err(|err| io_to_write_error(err, "operation"))?;
216 Ok(id)
217 }
218
219 fn resolve_operation_id_prefix(
220 &self,
221 prefix: &HexPrefix,
222 ) -> OpStoreResult<PrefixResolution<OperationId>> {
223 let op_dir = self.operations_dir();
224 let find = || -> io::Result<_> {
225 let matches_root = prefix.matches(&self.root_operation_id);
226 let hex_prefix = prefix.hex();
227 if hex_prefix.len() == OPERATION_ID_LENGTH * 2 {
228 if matches_root || op_dir.join(hex_prefix).try_exists()? {
230 let id = OperationId::from_bytes(prefix.as_full_bytes().unwrap());
231 return Ok(PrefixResolution::SingleMatch(id));
232 } else {
233 return Ok(PrefixResolution::NoMatch);
234 }
235 }
236
237 let mut matched = matches_root.then(|| self.root_operation_id.clone());
238 for entry in op_dir.read_dir()? {
239 let Ok(name) = entry?.file_name().into_string() else {
240 continue; };
242 if !name.starts_with(&hex_prefix) {
243 continue;
244 }
245 let Ok(id) = OperationId::try_from_hex(&name) else {
246 continue; };
248 if matched.is_some() {
249 return Ok(PrefixResolution::AmbiguousMatch);
250 }
251 matched = Some(id);
252 }
253 if let Some(id) = matched {
254 Ok(PrefixResolution::SingleMatch(id))
255 } else {
256 Ok(PrefixResolution::NoMatch)
257 }
258 };
259 find()
260 .context(&op_dir)
261 .map_err(|err| OpStoreError::Other(err.into()))
262 }
263
264 #[tracing::instrument(skip(self))]
265 fn gc(&self, head_ids: &[OperationId], keep_newer: SystemTime) -> OpStoreResult<()> {
266 let to_op_id = |entry: &fs::DirEntry| -> Option<OperationId> {
267 let name = entry.file_name().into_string().ok()?;
268 OperationId::try_from_hex(&name).ok()
269 };
270 let to_view_id = |entry: &fs::DirEntry| -> Option<ViewId> {
271 let name = entry.file_name().into_string().ok()?;
272 ViewId::try_from_hex(&name).ok()
273 };
274 let remove_file_if_not_new = |entry: &fs::DirEntry| -> Result<(), PathError> {
275 let path = entry.path();
276 let metadata = entry.metadata().context(&path)?;
279 let mtime = metadata.modified().expect("unsupported platform?");
280 if mtime > keep_newer {
281 tracing::trace!(?path, "not removing");
282 Ok(())
283 } else {
284 tracing::trace!(?path, "removing");
285 fs::remove_file(&path).context(&path)
286 }
287 };
288
289 let read_op = |id: &OperationId| self.read_operation(id).map(|data| (id.clone(), data));
293 let reachable_ops: HashMap<OperationId, Operation> = dag_walk::dfs_ok(
294 head_ids.iter().map(read_op),
295 |(id, _)| id.clone(),
296 |(_, data)| data.parents.iter().map(read_op).collect_vec(),
297 )
298 .try_collect()?;
299 let reachable_views: HashSet<&ViewId> =
300 reachable_ops.values().map(|data| &data.view_id).collect();
301 tracing::info!(
302 reachable_op_count = reachable_ops.len(),
303 reachable_view_count = reachable_views.len(),
304 "collected reachable objects"
305 );
306
307 let prune_ops = || -> Result<(), PathError> {
308 let op_dir = self.operations_dir();
309 for entry in op_dir.read_dir().context(&op_dir)? {
310 let entry = entry.context(&op_dir)?;
311 let Some(id) = to_op_id(&entry) else {
312 tracing::trace!(?entry, "skipping invalid file name");
313 continue;
314 };
315 if reachable_ops.contains_key(&id) {
316 continue;
317 }
318 remove_file_if_not_new(&entry)?;
322 }
323 Ok(())
324 };
325 prune_ops().map_err(|err| OpStoreError::Other(err.into()))?;
326
327 let prune_views = || -> Result<(), PathError> {
328 let view_dir = self.views_dir();
329 for entry in view_dir.read_dir().context(&view_dir)? {
330 let entry = entry.context(&view_dir)?;
331 let Some(id) = to_view_id(&entry) else {
332 tracing::trace!(?entry, "skipping invalid file name");
333 continue;
334 };
335 if reachable_views.contains(&id) {
336 continue;
337 }
338 remove_file_if_not_new(&entry)?;
339 }
340 Ok(())
341 };
342 prune_views().map_err(|err| OpStoreError::Other(err.into()))?;
343
344 Ok(())
345 }
346}
347
348fn io_to_read_error(err: std::io::Error, id: &impl ObjectId) -> OpStoreError {
349 if err.kind() == ErrorKind::NotFound {
350 OpStoreError::ObjectNotFound {
351 object_type: id.object_type(),
352 hash: id.hex(),
353 source: Box::new(err),
354 }
355 } else {
356 to_read_error(err.into(), id)
357 }
358}
359
360fn to_read_error(
361 source: Box<dyn std::error::Error + Send + Sync>,
362 id: &impl ObjectId,
363) -> OpStoreError {
364 OpStoreError::ReadObject {
365 object_type: id.object_type(),
366 hash: id.hex(),
367 source,
368 }
369}
370
371fn io_to_write_error(err: std::io::Error, object_type: &'static str) -> OpStoreError {
372 OpStoreError::WriteObject {
373 object_type,
374 source: Box::new(err),
375 }
376}
377
378#[derive(Debug, Error)]
379enum PostDecodeError {
380 #[error("Invalid hash length (expected {expected} bytes, got {actual} bytes)")]
381 InvalidHashLength { expected: usize, actual: usize },
382}
383
384fn operation_id_from_proto(bytes: Vec<u8>) -> Result<OperationId, PostDecodeError> {
385 if bytes.len() != OPERATION_ID_LENGTH {
386 Err(PostDecodeError::InvalidHashLength {
387 expected: OPERATION_ID_LENGTH,
388 actual: bytes.len(),
389 })
390 } else {
391 Ok(OperationId::new(bytes))
392 }
393}
394
395fn view_id_from_proto(bytes: Vec<u8>) -> Result<ViewId, PostDecodeError> {
396 if bytes.len() != VIEW_ID_LENGTH {
397 Err(PostDecodeError::InvalidHashLength {
398 expected: VIEW_ID_LENGTH,
399 actual: bytes.len(),
400 })
401 } else {
402 Ok(ViewId::new(bytes))
403 }
404}
405
406fn timestamp_to_proto(timestamp: &Timestamp) -> crate::protos::op_store::Timestamp {
407 crate::protos::op_store::Timestamp {
408 millis_since_epoch: timestamp.timestamp.0,
409 tz_offset: timestamp.tz_offset,
410 }
411}
412
413fn timestamp_from_proto(proto: crate::protos::op_store::Timestamp) -> Timestamp {
414 Timestamp {
415 timestamp: MillisSinceEpoch(proto.millis_since_epoch),
416 tz_offset: proto.tz_offset,
417 }
418}
419
420fn operation_metadata_to_proto(
421 metadata: &OperationMetadata,
422) -> crate::protos::op_store::OperationMetadata {
423 crate::protos::op_store::OperationMetadata {
424 start_time: Some(timestamp_to_proto(&metadata.start_time)),
425 end_time: Some(timestamp_to_proto(&metadata.end_time)),
426 description: metadata.description.clone(),
427 hostname: metadata.hostname.clone(),
428 username: metadata.username.clone(),
429 is_snapshot: metadata.is_snapshot,
430 tags: metadata.tags.clone(),
431 }
432}
433
434fn operation_metadata_from_proto(
435 proto: crate::protos::op_store::OperationMetadata,
436) -> OperationMetadata {
437 let start_time = timestamp_from_proto(proto.start_time.unwrap_or_default());
438 let end_time = timestamp_from_proto(proto.end_time.unwrap_or_default());
439 OperationMetadata {
440 start_time,
441 end_time,
442 description: proto.description,
443 hostname: proto.hostname,
444 username: proto.username,
445 is_snapshot: proto.is_snapshot,
446 tags: proto.tags,
447 }
448}
449
450fn operation_to_proto(operation: &Operation) -> crate::protos::op_store::Operation {
451 let mut proto = crate::protos::op_store::Operation {
452 view_id: operation.view_id.as_bytes().to_vec(),
453 metadata: Some(operation_metadata_to_proto(&operation.metadata)),
454 ..Default::default()
455 };
456 for parent in &operation.parents {
457 proto.parents.push(parent.to_bytes());
458 }
459 proto
460}
461
462fn operation_from_proto(
463 proto: crate::protos::op_store::Operation,
464) -> Result<Operation, PostDecodeError> {
465 let parents = proto
466 .parents
467 .into_iter()
468 .map(operation_id_from_proto)
469 .try_collect()?;
470 let view_id = view_id_from_proto(proto.view_id)?;
471 let metadata = operation_metadata_from_proto(proto.metadata.unwrap_or_default());
472 Ok(Operation {
473 view_id,
474 parents,
475 metadata,
476 })
477}
478
479fn view_to_proto(view: &View) -> crate::protos::op_store::View {
480 let mut proto = crate::protos::op_store::View {
481 ..Default::default()
482 };
483 for (name, commit_id) in &view.wc_commit_ids {
484 proto
485 .wc_commit_ids
486 .insert(name.into(), commit_id.to_bytes());
487 }
488 for head_id in &view.head_ids {
489 proto.head_ids.push(head_id.to_bytes());
490 }
491
492 proto.bookmarks = bookmark_views_to_proto_legacy(&view.local_bookmarks, &view.remote_views);
493
494 for (name, target) in &view.tags {
495 proto.tags.push(crate::protos::op_store::Tag {
496 name: name.into(),
497 target: ref_target_to_proto(target),
498 });
499 }
500
501 for (git_ref_name, target) in &view.git_refs {
502 proto.git_refs.push(crate::protos::op_store::GitRef {
503 name: git_ref_name.into(),
504 target: ref_target_to_proto(target),
505 ..Default::default()
506 });
507 }
508
509 proto.git_head = ref_target_to_proto(&view.git_head);
510
511 proto
512}
513
514fn view_from_proto(proto: crate::protos::op_store::View) -> View {
515 let mut view = View::empty();
517 #[expect(deprecated)]
520 if !proto.wc_commit_id.is_empty() {
521 view.wc_commit_ids.insert(
522 WorkspaceName::DEFAULT.to_owned(),
523 CommitId::new(proto.wc_commit_id),
524 );
525 }
526 for (name, commit_id) in proto.wc_commit_ids {
527 view.wc_commit_ids
528 .insert(WorkspaceNameBuf::from(name), CommitId::new(commit_id));
529 }
530 for head_id_bytes in proto.head_ids {
531 view.head_ids.insert(CommitId::new(head_id_bytes));
532 }
533
534 let (local_bookmarks, remote_views) = bookmark_views_from_proto_legacy(proto.bookmarks);
535 view.local_bookmarks = local_bookmarks;
536 view.remote_views = remote_views;
537
538 for tag_proto in proto.tags {
539 let name: RefNameBuf = tag_proto.name.into();
540 view.tags
541 .insert(name, ref_target_from_proto(tag_proto.target));
542 }
543
544 for git_ref in proto.git_refs {
545 let name: GitRefNameBuf = git_ref.name.into();
546 let target = if git_ref.target.is_some() {
547 ref_target_from_proto(git_ref.target)
548 } else {
549 RefTarget::normal(CommitId::new(git_ref.commit_id))
551 };
552 view.git_refs.insert(name, target);
553 }
554
555 #[expect(deprecated)]
556 if proto.git_head.is_some() {
557 view.git_head = ref_target_from_proto(proto.git_head);
558 } else if !proto.git_head_legacy.is_empty() {
559 view.git_head = RefTarget::normal(CommitId::new(proto.git_head_legacy));
560 }
561
562 view
563}
564
565fn bookmark_views_to_proto_legacy(
566 local_bookmarks: &BTreeMap<RefNameBuf, RefTarget>,
567 remote_views: &BTreeMap<RemoteNameBuf, RemoteView>,
568) -> Vec<crate::protos::op_store::Bookmark> {
569 op_store::merge_join_bookmark_views(local_bookmarks, remote_views)
570 .map(|(name, bookmark_target)| {
571 let local_target = ref_target_to_proto(bookmark_target.local_target);
572 let remote_bookmarks = bookmark_target
573 .remote_refs
574 .iter()
575 .map(
576 |&(remote_name, remote_ref)| crate::protos::op_store::RemoteBookmark {
577 remote_name: remote_name.into(),
578 target: ref_target_to_proto(&remote_ref.target),
579 state: remote_ref_state_to_proto(remote_ref.state),
580 },
581 )
582 .collect();
583 crate::protos::op_store::Bookmark {
584 name: name.into(),
585 local_target,
586 remote_bookmarks,
587 }
588 })
589 .collect()
590}
591
592fn bookmark_views_from_proto_legacy(
593 bookmarks_legacy: Vec<crate::protos::op_store::Bookmark>,
594) -> (
595 BTreeMap<RefNameBuf, RefTarget>,
596 BTreeMap<RemoteNameBuf, RemoteView>,
597) {
598 let mut local_bookmarks: BTreeMap<RefNameBuf, RefTarget> = BTreeMap::new();
599 let mut remote_views: BTreeMap<RemoteNameBuf, RemoteView> = BTreeMap::new();
600 for bookmark_proto in bookmarks_legacy {
601 let bookmark_name: RefNameBuf = bookmark_proto.name.into();
602 let local_target = ref_target_from_proto(bookmark_proto.local_target);
603 for remote_bookmark in bookmark_proto.remote_bookmarks {
604 let remote_name: RemoteNameBuf = remote_bookmark.remote_name.into();
605 let state = remote_ref_state_from_proto(remote_bookmark.state).unwrap_or_else(|| {
606 let is_git_tracking = crate::git::is_special_git_remote(&remote_name);
613 let default_state = if is_git_tracking || local_target.is_present() {
614 RemoteRefState::Tracked
615 } else {
616 RemoteRefState::New
617 };
618 tracing::trace!(
619 ?bookmark_name,
620 ?remote_name,
621 ?default_state,
622 "generated tracking state",
623 );
624 default_state
625 });
626 let remote_view = remote_views.entry(remote_name).or_default();
627 let remote_ref = RemoteRef {
628 target: ref_target_from_proto(remote_bookmark.target),
629 state,
630 };
631 remote_view
632 .bookmarks
633 .insert(bookmark_name.clone(), remote_ref);
634 }
635 if local_target.is_present() {
636 local_bookmarks.insert(bookmark_name, local_target);
637 }
638 }
639 (local_bookmarks, remote_views)
640}
641
642fn ref_target_to_proto(value: &RefTarget) -> Option<crate::protos::op_store::RefTarget> {
643 let term_to_proto = |term: &Option<CommitId>| crate::protos::op_store::ref_conflict::Term {
644 value: term.as_ref().map(|id| id.to_bytes()),
645 };
646 let merge = value.as_merge();
647 let conflict_proto = crate::protos::op_store::RefConflict {
648 removes: merge.removes().map(term_to_proto).collect(),
649 adds: merge.adds().map(term_to_proto).collect(),
650 };
651 let proto = crate::protos::op_store::RefTarget {
652 value: Some(crate::protos::op_store::ref_target::Value::Conflict(
653 conflict_proto,
654 )),
655 };
656 Some(proto)
657}
658
659#[expect(deprecated)]
660#[cfg(test)]
661fn ref_target_to_proto_legacy(value: &RefTarget) -> Option<crate::protos::op_store::RefTarget> {
662 if let Some(id) = value.as_normal() {
663 let proto = crate::protos::op_store::RefTarget {
664 value: Some(crate::protos::op_store::ref_target::Value::CommitId(
665 id.to_bytes(),
666 )),
667 };
668 Some(proto)
669 } else if value.has_conflict() {
670 let ref_conflict_proto = crate::protos::op_store::RefConflictLegacy {
671 removes: value.removed_ids().map(|id| id.to_bytes()).collect(),
672 adds: value.added_ids().map(|id| id.to_bytes()).collect(),
673 };
674 let proto = crate::protos::op_store::RefTarget {
675 value: Some(crate::protos::op_store::ref_target::Value::ConflictLegacy(
676 ref_conflict_proto,
677 )),
678 };
679 Some(proto)
680 } else {
681 assert!(value.is_absent());
682 None
683 }
684}
685
686fn ref_target_from_proto(maybe_proto: Option<crate::protos::op_store::RefTarget>) -> RefTarget {
687 let Some(proto) = maybe_proto else {
690 return RefTarget::absent();
692 };
693 match proto.value.unwrap() {
694 crate::protos::op_store::ref_target::Value::CommitId(id) => {
695 RefTarget::normal(CommitId::new(id))
697 }
698 #[expect(deprecated)]
699 crate::protos::op_store::ref_target::Value::ConflictLegacy(conflict) => {
700 let removes = conflict.removes.into_iter().map(CommitId::new);
702 let adds = conflict.adds.into_iter().map(CommitId::new);
703 RefTarget::from_legacy_form(removes, adds)
704 }
705 crate::protos::op_store::ref_target::Value::Conflict(conflict) => {
706 let term_from_proto =
707 |term: crate::protos::op_store::ref_conflict::Term| term.value.map(CommitId::new);
708 let removes = conflict.removes.into_iter().map(term_from_proto);
709 let adds = conflict.adds.into_iter().map(term_from_proto);
710 RefTarget::from_merge(Merge::from_removes_adds(removes, adds))
711 }
712 }
713}
714
715fn remote_ref_state_to_proto(state: RemoteRefState) -> Option<i32> {
716 let proto_state = match state {
717 RemoteRefState::New => crate::protos::op_store::RemoteRefState::New,
718 RemoteRefState::Tracked => crate::protos::op_store::RemoteRefState::Tracked,
719 };
720 Some(proto_state as i32)
721}
722
723fn remote_ref_state_from_proto(proto_value: Option<i32>) -> Option<RemoteRefState> {
724 let proto_state = proto_value?.try_into().ok()?;
725 let state = match proto_state {
726 crate::protos::op_store::RemoteRefState::New => RemoteRefState::New,
727 crate::protos::op_store::RemoteRefState::Tracked => RemoteRefState::Tracked,
728 };
729 Some(state)
730}
731
732#[cfg(test)]
733mod tests {
734 use insta::assert_snapshot;
735 use itertools::Itertools as _;
736 use maplit::btreemap;
737 use maplit::hashmap;
738 use maplit::hashset;
739
740 use super::*;
741 use crate::tests::new_temp_dir;
742
743 fn create_view() -> View {
744 let new_remote_ref = |target: &RefTarget| RemoteRef {
745 target: target.clone(),
746 state: RemoteRefState::New,
747 };
748 let tracked_remote_ref = |target: &RefTarget| RemoteRef {
749 target: target.clone(),
750 state: RemoteRefState::Tracked,
751 };
752 let head_id1 = CommitId::from_hex("aaa111");
753 let head_id2 = CommitId::from_hex("aaa222");
754 let bookmark_main_local_target = RefTarget::normal(CommitId::from_hex("ccc111"));
755 let bookmark_main_origin_target = RefTarget::normal(CommitId::from_hex("ccc222"));
756 let bookmark_deleted_origin_target = RefTarget::normal(CommitId::from_hex("ccc333"));
757 let tag_v1_target = RefTarget::normal(CommitId::from_hex("ddd111"));
758 let git_refs_main_target = RefTarget::normal(CommitId::from_hex("fff111"));
759 let git_refs_feature_target = RefTarget::from_legacy_form(
760 [CommitId::from_hex("fff111")],
761 [CommitId::from_hex("fff222"), CommitId::from_hex("fff333")],
762 );
763 let default_wc_commit_id = CommitId::from_hex("abc111");
764 let test_wc_commit_id = CommitId::from_hex("abc222");
765 View {
766 head_ids: hashset! {head_id1, head_id2},
767 local_bookmarks: btreemap! {
768 "main".into() => bookmark_main_local_target,
769 },
770 tags: btreemap! {
771 "v1.0".into() => tag_v1_target,
772 },
773 remote_views: btreemap! {
774 "origin".into() => RemoteView {
775 bookmarks: btreemap! {
776 "main".into() => tracked_remote_ref(&bookmark_main_origin_target),
777 "deleted".into() => new_remote_ref(&bookmark_deleted_origin_target),
778 },
779 },
780 },
781 git_refs: btreemap! {
782 "refs/heads/main".into() => git_refs_main_target,
783 "refs/heads/feature".into() => git_refs_feature_target,
784 },
785 git_head: RefTarget::normal(CommitId::from_hex("fff111")),
786 wc_commit_ids: btreemap! {
787 WorkspaceName::DEFAULT.to_owned() => default_wc_commit_id,
788 "test".into() => test_wc_commit_id,
789 },
790 }
791 }
792
793 fn create_operation() -> Operation {
794 let pad_id_bytes = |hex: &str, len: usize| {
795 let mut bytes = hex::decode(hex).unwrap();
796 bytes.resize(len, b'\0');
797 bytes
798 };
799 Operation {
800 view_id: ViewId::new(pad_id_bytes("aaa111", VIEW_ID_LENGTH)),
801 parents: vec![
802 OperationId::new(pad_id_bytes("bbb111", OPERATION_ID_LENGTH)),
803 OperationId::new(pad_id_bytes("bbb222", OPERATION_ID_LENGTH)),
804 ],
805 metadata: OperationMetadata {
806 start_time: Timestamp {
807 timestamp: MillisSinceEpoch(123456789),
808 tz_offset: 3600,
809 },
810 end_time: Timestamp {
811 timestamp: MillisSinceEpoch(123456800),
812 tz_offset: 3600,
813 },
814 description: "check out foo".to_string(),
815 hostname: "some.host.example.com".to_string(),
816 username: "someone".to_string(),
817 is_snapshot: false,
818 tags: hashmap! {
819 "key1".to_string() => "value1".to_string(),
820 "key2".to_string() => "value2".to_string(),
821 },
822 },
823 }
824 }
825
826 #[test]
827 fn test_hash_view() {
828 assert_snapshot!(
830 ViewId::new(blake2b_hash(&create_view()).to_vec()).hex(),
831 @"f426676b3a2f7c6b9ec8677cb05ed249d0d244ab7e86a7c51117e2d8a4829db65e55970c761231e2107d303bf3d33a1f2afdd4ed2181f223e99753674b20a35e"
832 );
833 }
834
835 #[test]
836 fn test_hash_operation() {
837 assert_snapshot!(
839 OperationId::new(blake2b_hash(&create_operation()).to_vec()).hex(),
840 @"a721c8bfe6d30b4279437722417743c2c5d9efe731942663e3e7d37320e0ab6b49a7c1452d101cc427ceb8927a4cab03d49dabe73c0677bb9edf5c8b2aa83585"
841 );
842 }
843
844 #[test]
845 fn test_read_write_view() {
846 let temp_dir = new_temp_dir();
847 let root_data = RootOperationData {
848 root_commit_id: CommitId::from_hex("000000"),
849 };
850 let store = SimpleOpStore::init(temp_dir.path(), root_data).unwrap();
851 let view = create_view();
852 let view_id = store.write_view(&view).unwrap();
853 let read_view = store.read_view(&view_id).unwrap();
854 assert_eq!(read_view, view);
855 }
856
857 #[test]
858 fn test_read_write_operation() {
859 let temp_dir = new_temp_dir();
860 let root_data = RootOperationData {
861 root_commit_id: CommitId::from_hex("000000"),
862 };
863 let store = SimpleOpStore::init(temp_dir.path(), root_data).unwrap();
864 let operation = create_operation();
865 let op_id = store.write_operation(&operation).unwrap();
866 let read_operation = store.read_operation(&op_id).unwrap();
867 assert_eq!(read_operation, operation);
868 }
869
870 #[test]
871 fn test_bookmark_views_legacy_roundtrip() {
872 let new_remote_ref = |target: &RefTarget| RemoteRef {
873 target: target.clone(),
874 state: RemoteRefState::New,
875 };
876 let tracked_remote_ref = |target: &RefTarget| RemoteRef {
877 target: target.clone(),
878 state: RemoteRefState::Tracked,
879 };
880 let local_bookmark1_target = RefTarget::normal(CommitId::from_hex("111111"));
881 let local_bookmark3_target = RefTarget::normal(CommitId::from_hex("222222"));
882 let git_bookmark1_target = RefTarget::normal(CommitId::from_hex("333333"));
883 let remote1_bookmark1_target = RefTarget::normal(CommitId::from_hex("444444"));
884 let remote2_bookmark2_target = RefTarget::normal(CommitId::from_hex("555555"));
885 let remote2_bookmark4_target = RefTarget::normal(CommitId::from_hex("666666"));
886 let local_bookmarks = btreemap! {
887 "bookmark1".into() => local_bookmark1_target.clone(),
888 "bookmark3".into() => local_bookmark3_target.clone(),
889 };
890 let remote_views = btreemap! {
891 "git".into() => RemoteView {
892 bookmarks: btreemap! {
893 "bookmark1".into() => tracked_remote_ref(&git_bookmark1_target),
894 },
895 },
896 "remote1".into() => RemoteView {
897 bookmarks: btreemap! {
898 "bookmark1".into() => tracked_remote_ref(&remote1_bookmark1_target),
899 },
900 },
901 "remote2".into() => RemoteView {
902 bookmarks: btreemap! {
903 "bookmark2".into() => new_remote_ref(&remote2_bookmark2_target),
905 "bookmark4".into() => tracked_remote_ref(&remote2_bookmark4_target),
906 },
907 },
908 };
909
910 let bookmarks_legacy = bookmark_views_to_proto_legacy(&local_bookmarks, &remote_views);
911 assert_eq!(
912 bookmarks_legacy
913 .iter()
914 .map(|proto| &proto.name)
915 .sorted()
916 .collect_vec(),
917 vec!["bookmark1", "bookmark2", "bookmark3", "bookmark4"],
918 );
919
920 let (local_bookmarks_reconstructed, remote_views_reconstructed) =
921 bookmark_views_from_proto_legacy(bookmarks_legacy);
922 assert_eq!(local_bookmarks_reconstructed, local_bookmarks);
923 assert_eq!(remote_views_reconstructed, remote_views);
924 }
925
926 #[test]
927 fn test_ref_target_change_delete_order_roundtrip() {
928 let target = RefTarget::from_merge(Merge::from_removes_adds(
929 vec![Some(CommitId::from_hex("111111"))],
930 vec![Some(CommitId::from_hex("222222")), None],
931 ));
932 let maybe_proto = ref_target_to_proto(&target);
933 assert_eq!(ref_target_from_proto(maybe_proto), target);
934
935 let target = RefTarget::from_merge(Merge::from_removes_adds(
937 vec![Some(CommitId::from_hex("111111"))],
938 vec![None, Some(CommitId::from_hex("222222"))],
939 ));
940 let maybe_proto = ref_target_to_proto(&target);
941 assert_eq!(ref_target_from_proto(maybe_proto), target);
942 }
943
944 #[test]
945 fn test_ref_target_legacy_roundtrip() {
946 let target = RefTarget::absent();
947 let maybe_proto = ref_target_to_proto_legacy(&target);
948 assert_eq!(ref_target_from_proto(maybe_proto), target);
949
950 let target = RefTarget::normal(CommitId::from_hex("111111"));
951 let maybe_proto = ref_target_to_proto_legacy(&target);
952 assert_eq!(ref_target_from_proto(maybe_proto), target);
953
954 let target = RefTarget::from_legacy_form(
956 [CommitId::from_hex("111111"), CommitId::from_hex("222222")],
957 [
958 CommitId::from_hex("333333"),
959 CommitId::from_hex("444444"),
960 CommitId::from_hex("555555"),
961 ],
962 );
963 let maybe_proto = ref_target_to_proto_legacy(&target);
964 assert_eq!(ref_target_from_proto(maybe_proto), target);
965
966 let target = RefTarget::from_legacy_form(
968 [CommitId::from_hex("111111")],
969 [CommitId::from_hex("222222")],
970 );
971 let maybe_proto = ref_target_to_proto_legacy(&target);
972 assert_eq!(ref_target_from_proto(maybe_proto), target);
973 }
974}