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)
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            // Repos created before we had the root operation will have an operation without
202            // parents.
203            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                // Fast path for full-length ID
241                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; // Skip invalid UTF-8
253                };
254                if !name.starts_with(&hex_prefix) {
255                    continue;
256                }
257                let Ok(id) = OperationId::try_from_hex(&name) else {
258                    continue; // Skip invalid hex
259                };
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            // Check timestamp, but there's still TOCTOU problem if an existing
289            // file is renewed.
290            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        // Reachable objects are resolved without considering the keep_newer
302        // parameter. We could collect ancestors of the "new" operations here,
303        // but more files can be added anyway after that.
304        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                // If the operation was added after collecting reachable_views,
331                // its view mtime would also be renewed. So there's no need to
332                // update the reachable_views set to preserve the view.
333                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    // TODO: validate commit id length?
568    let mut view = View::empty();
569    // For compatibility with old repos before we had support for multiple working
570    // copies
571    #[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            // Legacy format
602            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                // If local bookmark doesn't exist, we assume that the remote bookmark hasn't
659                // been merged because git.auto-local-bookmark was off. That's
660                // probably more common than deleted but yet-to-be-pushed local
661                // bookmark. Alternatively, we could read
662                // git.auto-local-bookmark setting here, but that wouldn't always work since the
663                // setting could be toggled after the bookmark got merged.
664                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    // TODO: Delete legacy format handling when we decide to drop support for views
740    // saved by jj <= 0.8.
741    let Some(proto) = maybe_proto else {
742        // Legacy absent id
743        return RefTarget::absent();
744    };
745    match proto.value.unwrap() {
746        crate::protos::op_store::ref_target::Value::CommitId(id) => {
747            // Legacy non-conflicting id
748            RefTarget::normal(CommitId::new(id))
749        }
750        #[expect(deprecated)]
751        crate::protos::op_store::ref_target::Value::ConflictLegacy(conflict) => {
752            // Legacy conflicting ids
753            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        // Test exact output so we detect regressions in compatibility
888        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        // Test exact output so we detect regressions in compatibility
897        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" is non-tracking. "bookmark4" is tracking, but locally deleted.
963                    "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        // If it were legacy format, order of None entry would be lost.
995        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        // N-way conflict
1014        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        // Change-delete conflict
1026        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}