Skip to main content

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