jj_lib/
simple_op_store.rs

1// Copyright 2020 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#![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
68// BLAKE2b-512 hash length in bytes
69const OPERATION_ID_LENGTH: usize = 64;
70const VIEW_ID_LENGTH: usize = 64;
71
72/// Error that may occur during [`SimpleOpStore`] initialization.
73#[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    /// Creates an empty OpStore. Returns error if it already exists.
97    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    /// Load an existing OpStore
107    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            // Repos created before we had the root operation will have an operation without
194            // parents.
195            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                // Fast path for full-length ID
229                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; // Skip invalid UTF-8
241                };
242                if !name.starts_with(&hex_prefix) {
243                    continue;
244                }
245                let Ok(id) = OperationId::try_from_hex(&name) else {
246                    continue; // Skip invalid hex
247                };
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            // Check timestamp, but there's still TOCTOU problem if an existing
277            // file is renewed.
278            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        // Reachable objects are resolved without considering the keep_newer
290        // parameter. We could collect ancestors of the "new" operations here,
291        // but more files can be added anyway after that.
292        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                // If the operation was added after collecting reachable_views,
319                // its view mtime would also be renewed. So there's no need to
320                // update the reachable_views set to preserve the view.
321                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    // TODO: validate commit id length?
516    let mut view = View::empty();
517    // For compatibility with old repos before we had support for multiple working
518    // copies
519    #[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            // Legacy format
550            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                // If local bookmark doesn't exist, we assume that the remote bookmark hasn't
607                // been merged because git.auto-local-bookmark was off. That's
608                // probably more common than deleted but yet-to-be-pushed local
609                // bookmark. Alternatively, we could read
610                // git.auto-local-bookmark setting here, but that wouldn't always work since the
611                // setting could be toggled after the bookmark got merged.
612                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    // TODO: Delete legacy format handling when we decide to drop support for views
688    // saved by jj <= 0.8.
689    let Some(proto) = maybe_proto else {
690        // Legacy absent id
691        return RefTarget::absent();
692    };
693    match proto.value.unwrap() {
694        crate::protos::op_store::ref_target::Value::CommitId(id) => {
695            // Legacy non-conflicting id
696            RefTarget::normal(CommitId::new(id))
697        }
698        #[expect(deprecated)]
699        crate::protos::op_store::ref_target::Value::ConflictLegacy(conflict) => {
700            // Legacy conflicting ids
701            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        // Test exact output so we detect regressions in compatibility
829        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        // Test exact output so we detect regressions in compatibility
838        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" is non-tracking. "bookmark4" is tracking, but locally deleted.
904                    "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        // If it were legacy format, order of None entry would be lost.
936        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        // N-way conflict
955        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        // Change-delete conflict
967        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}