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