jj_lib/
refs.rs

1// Copyright 2021 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 itertools::EitherOrBoth;
18
19use crate::backend::CommitId;
20use crate::index::Index;
21use crate::index::IndexResult;
22use crate::iter_util::fallible_position;
23use crate::merge::Merge;
24use crate::merge::SameChange;
25use crate::merge::trivial_merge;
26use crate::op_store::RefTarget;
27use crate::op_store::RemoteRef;
28
29/// Compares `refs1` and `refs2` targets, yields entry if they differ.
30///
31/// `refs1` and `refs2` must be sorted by `K`.
32pub fn diff_named_ref_targets<'a, 'b, K: Ord>(
33    refs1: impl IntoIterator<Item = (K, &'a RefTarget)>,
34    refs2: impl IntoIterator<Item = (K, &'b RefTarget)>,
35) -> impl Iterator<Item = (K, (&'a RefTarget, &'b RefTarget))> {
36    iter_named_pairs(
37        refs1,
38        refs2,
39        || RefTarget::absent_ref(),
40        || RefTarget::absent_ref(),
41    )
42    .filter(|(_, (target1, target2))| target1 != target2)
43}
44
45/// Compares remote `refs1` and `refs2` pairs, yields entry if they differ.
46///
47/// `refs1` and `refs2` must be sorted by `K`.
48pub fn diff_named_remote_refs<'a, 'b, K: Ord>(
49    refs1: impl IntoIterator<Item = (K, &'a RemoteRef)>,
50    refs2: impl IntoIterator<Item = (K, &'b RemoteRef)>,
51) -> impl Iterator<Item = (K, (&'a RemoteRef, &'b RemoteRef))> {
52    iter_named_pairs(
53        refs1,
54        refs2,
55        || RemoteRef::absent_ref(),
56        || RemoteRef::absent_ref(),
57    )
58    .filter(|(_, (ref1, ref2))| ref1 != ref2)
59}
60
61/// Iterates local `refs1` and remote `refs2` pairs by name.
62///
63/// `refs1` and `refs2` must be sorted by `K`.
64pub fn iter_named_local_remote_refs<'a, 'b, K: Ord>(
65    refs1: impl IntoIterator<Item = (K, &'a RefTarget)>,
66    refs2: impl IntoIterator<Item = (K, &'b RemoteRef)>,
67) -> impl Iterator<Item = (K, (&'a RefTarget, &'b RemoteRef))> {
68    iter_named_pairs(
69        refs1,
70        refs2,
71        || RefTarget::absent_ref(),
72        || RemoteRef::absent_ref(),
73    )
74}
75
76/// Compares `ids1` and `ids2` commit ids, yields entry if they differ.
77///
78/// `ids1` and `ids2` must be sorted by `K`.
79pub fn diff_named_commit_ids<'a, 'b, K: Ord>(
80    ids1: impl IntoIterator<Item = (K, &'a CommitId)>,
81    ids2: impl IntoIterator<Item = (K, &'b CommitId)>,
82) -> impl Iterator<Item = (K, (Option<&'a CommitId>, Option<&'b CommitId>))> {
83    iter_named_pairs(
84        ids1.into_iter().map(|(k, v)| (k, Some(v))),
85        ids2.into_iter().map(|(k, v)| (k, Some(v))),
86        || None,
87        || None,
88    )
89    .filter(|(_, (target1, target2))| target1 != target2)
90}
91
92fn iter_named_pairs<K: Ord, V1, V2>(
93    refs1: impl IntoIterator<Item = (K, V1)>,
94    refs2: impl IntoIterator<Item = (K, V2)>,
95    absent_ref1: impl Fn() -> V1,
96    absent_ref2: impl Fn() -> V2,
97) -> impl Iterator<Item = (K, (V1, V2))> {
98    itertools::merge_join_by(refs1, refs2, |(name1, _), (name2, _)| name1.cmp(name2)).map(
99        move |entry| match entry {
100            EitherOrBoth::Both((name, target1), (_, target2)) => (name, (target1, target2)),
101            EitherOrBoth::Left((name, target1)) => (name, (target1, absent_ref2())),
102            EitherOrBoth::Right((name, target2)) => (name, (absent_ref1(), target2)),
103        },
104    )
105}
106
107pub fn merge_ref_targets(
108    index: &dyn Index,
109    left: &RefTarget,
110    base: &RefTarget,
111    right: &RefTarget,
112) -> IndexResult<RefTarget> {
113    if let Some(&resolved) = trivial_merge(&[left, base, right], SameChange::Accept) {
114        return Ok(resolved.clone());
115    }
116
117    let mut merge = Merge::from_vec(vec![
118        left.as_merge().clone(),
119        base.as_merge().clone(),
120        right.as_merge().clone(),
121    ])
122    .flatten()
123    .simplify();
124    // Suppose left = [A - C + B], base = [B], right = [A], the merge result is
125    // [A - C + A], which can now be trivially resolved.
126    if let Some(resolved) = merge.resolve_trivial(SameChange::Accept) {
127        Ok(RefTarget::resolved(resolved.clone()))
128    } else {
129        merge_ref_targets_non_trivial(index, &mut merge)?;
130        // TODO: Maybe better to try resolve_trivial() again, but the result is
131        // unreliable since merge_ref_targets_non_trivial() is order dependent.
132        Ok(RefTarget::from_merge(merge))
133    }
134}
135
136pub fn merge_remote_refs(
137    index: &dyn Index,
138    left: &RemoteRef,
139    base: &RemoteRef,
140    right: &RemoteRef,
141) -> IndexResult<RemoteRef> {
142    // Just merge target and state fields separately. Strictly speaking, merging
143    // target-only change and state-only change shouldn't automatically mark the
144    // new target as tracking. However, many faulty merges will end up in local
145    // or remote target conflicts (since fast-forwardable move can be safely
146    // "tracked"), and the conflicts will require user intervention anyway. So
147    // there wouldn't be much reason to handle these merges precisely.
148    let target = merge_ref_targets(index, &left.target, &base.target, &right.target)?;
149    // Merged state shouldn't conflict atm since we only have two states, but if
150    // it does, keep the original state. The choice is arbitrary.
151    let state = *trivial_merge(&[left.state, base.state, right.state], SameChange::Accept)
152        .unwrap_or(&base.state);
153    Ok(RemoteRef { target, state })
154}
155
156fn merge_ref_targets_non_trivial(
157    index: &dyn Index,
158    conflict: &mut Merge<Option<CommitId>>,
159) -> IndexResult<()> {
160    while let Some((remove_index, add_index)) = find_pair_to_remove(index, conflict)? {
161        conflict.swap_remove(remove_index, add_index);
162    }
163    Ok(())
164}
165
166fn find_pair_to_remove(
167    index: &dyn Index,
168    conflict: &Merge<Option<CommitId>>,
169) -> IndexResult<Option<(usize, usize)>> {
170    // If a "remove" is an ancestor of two different "adds" and one of the
171    // "adds" is an ancestor of the other, then pick the descendant.
172    for (add_index1, add1) in conflict.adds().enumerate() {
173        for (add_index2, add2) in conflict.adds().enumerate().skip(add_index1 + 1) {
174            // TODO: Instead of relying on the list order, maybe ((add1, add2), remove)
175            // combination should be somehow weighted?
176            let (add_index, add_id) = match (add1, add2) {
177                (Some(id1), Some(id2)) if id1 == id2 => (add_index1, id1),
178                (Some(id1), Some(id2)) if index.is_ancestor(id1, id2)? => (add_index1, id1),
179                (Some(id1), Some(id2)) if index.is_ancestor(id2, id1)? => (add_index2, id2),
180                _ => continue,
181            };
182            if let Some(remove_index) =
183                fallible_position(conflict.removes(), |remove| match remove {
184                    Some(id) => index.is_ancestor(id, add_id),
185                    None => Ok(true), // Absent ref can be considered a root
186                })?
187            {
188                return Ok(Some((remove_index, add_index)));
189            }
190        }
191    }
192
193    Ok(None)
194}
195
196/// Pair of local and remote targets.
197#[derive(Clone, Copy, Debug, Eq, PartialEq)]
198pub struct LocalAndRemoteRef<'a> {
199    pub local_target: &'a RefTarget,
200    pub remote_ref: &'a RemoteRef,
201}
202
203#[derive(Debug, PartialEq, Eq, Clone, Hash)]
204pub struct BookmarkPushUpdate {
205    pub old_target: Option<CommitId>,
206    pub new_target: Option<CommitId>,
207}
208
209#[derive(Debug, PartialEq, Eq, Clone)]
210pub enum BookmarkPushAction {
211    Update(BookmarkPushUpdate),
212    AlreadyMatches,
213    LocalConflicted,
214    RemoteConflicted,
215    RemoteUntracked,
216}
217
218/// Figure out what changes (if any) need to be made to the remote when pushing
219/// this bookmark.
220pub fn classify_bookmark_push_action(targets: LocalAndRemoteRef) -> BookmarkPushAction {
221    let local_target = targets.local_target;
222    let remote_target = targets.remote_ref.tracked_target();
223    if local_target == remote_target {
224        BookmarkPushAction::AlreadyMatches
225    } else if local_target.has_conflict() {
226        BookmarkPushAction::LocalConflicted
227    } else if remote_target.has_conflict() {
228        BookmarkPushAction::RemoteConflicted
229    } else if targets.remote_ref.is_present() && !targets.remote_ref.is_tracked() {
230        BookmarkPushAction::RemoteUntracked
231    } else {
232        BookmarkPushAction::Update(BookmarkPushUpdate {
233            old_target: remote_target.as_normal().cloned(),
234            new_target: local_target.as_normal().cloned(),
235        })
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::op_store::RemoteRefState;
243
244    fn new_remote_ref(target: RefTarget) -> RemoteRef {
245        RemoteRef {
246            target,
247            state: RemoteRefState::New,
248        }
249    }
250
251    fn tracked_remote_ref(target: RefTarget) -> RemoteRef {
252        RemoteRef {
253            target,
254            state: RemoteRefState::Tracked,
255        }
256    }
257
258    #[test]
259    fn test_classify_bookmark_push_action_unchanged() {
260        let commit_id1 = CommitId::from_hex("11");
261        let targets = LocalAndRemoteRef {
262            local_target: &RefTarget::normal(commit_id1.clone()),
263            remote_ref: &tracked_remote_ref(RefTarget::normal(commit_id1)),
264        };
265        assert_eq!(
266            classify_bookmark_push_action(targets),
267            BookmarkPushAction::AlreadyMatches
268        );
269    }
270
271    #[test]
272    fn test_classify_bookmark_push_action_added() {
273        let commit_id1 = CommitId::from_hex("11");
274        let targets = LocalAndRemoteRef {
275            local_target: &RefTarget::normal(commit_id1.clone()),
276            remote_ref: RemoteRef::absent_ref(),
277        };
278        assert_eq!(
279            classify_bookmark_push_action(targets),
280            BookmarkPushAction::Update(BookmarkPushUpdate {
281                old_target: None,
282                new_target: Some(commit_id1),
283            })
284        );
285    }
286
287    #[test]
288    fn test_classify_bookmark_push_action_removed() {
289        let commit_id1 = CommitId::from_hex("11");
290        let targets = LocalAndRemoteRef {
291            local_target: RefTarget::absent_ref(),
292            remote_ref: &tracked_remote_ref(RefTarget::normal(commit_id1.clone())),
293        };
294        assert_eq!(
295            classify_bookmark_push_action(targets),
296            BookmarkPushAction::Update(BookmarkPushUpdate {
297                old_target: Some(commit_id1),
298                new_target: None,
299            })
300        );
301    }
302
303    #[test]
304    fn test_classify_bookmark_push_action_updated() {
305        let commit_id1 = CommitId::from_hex("11");
306        let commit_id2 = CommitId::from_hex("22");
307        let targets = LocalAndRemoteRef {
308            local_target: &RefTarget::normal(commit_id2.clone()),
309            remote_ref: &tracked_remote_ref(RefTarget::normal(commit_id1.clone())),
310        };
311        assert_eq!(
312            classify_bookmark_push_action(targets),
313            BookmarkPushAction::Update(BookmarkPushUpdate {
314                old_target: Some(commit_id1),
315                new_target: Some(commit_id2),
316            })
317        );
318    }
319
320    #[test]
321    fn test_classify_bookmark_push_action_removed_untracked() {
322        // This is not RemoteUntracked error since non-tracking remote bookmarks
323        // have no relation to local bookmarks, and there's nothing to push.
324        let commit_id1 = CommitId::from_hex("11");
325        let targets = LocalAndRemoteRef {
326            local_target: RefTarget::absent_ref(),
327            remote_ref: &new_remote_ref(RefTarget::normal(commit_id1.clone())),
328        };
329        assert_eq!(
330            classify_bookmark_push_action(targets),
331            BookmarkPushAction::AlreadyMatches
332        );
333    }
334
335    #[test]
336    fn test_classify_bookmark_push_action_updated_untracked() {
337        let commit_id1 = CommitId::from_hex("11");
338        let commit_id2 = CommitId::from_hex("22");
339        let targets = LocalAndRemoteRef {
340            local_target: &RefTarget::normal(commit_id2.clone()),
341            remote_ref: &new_remote_ref(RefTarget::normal(commit_id1.clone())),
342        };
343        assert_eq!(
344            classify_bookmark_push_action(targets),
345            BookmarkPushAction::RemoteUntracked
346        );
347    }
348
349    #[test]
350    fn test_classify_bookmark_push_action_local_conflicted() {
351        let commit_id1 = CommitId::from_hex("11");
352        let commit_id2 = CommitId::from_hex("22");
353        let targets = LocalAndRemoteRef {
354            local_target: &RefTarget::from_legacy_form([], [commit_id1.clone(), commit_id2]),
355            remote_ref: &tracked_remote_ref(RefTarget::normal(commit_id1)),
356        };
357        assert_eq!(
358            classify_bookmark_push_action(targets),
359            BookmarkPushAction::LocalConflicted
360        );
361    }
362
363    #[test]
364    fn test_classify_bookmark_push_action_remote_conflicted() {
365        let commit_id1 = CommitId::from_hex("11");
366        let commit_id2 = CommitId::from_hex("22");
367        let targets = LocalAndRemoteRef {
368            local_target: &RefTarget::normal(commit_id1.clone()),
369            remote_ref: &tracked_remote_ref(RefTarget::from_legacy_form(
370                [],
371                [commit_id1, commit_id2],
372            )),
373        };
374        assert_eq!(
375            classify_bookmark_push_action(targets),
376            BookmarkPushAction::RemoteConflicted
377        );
378    }
379}