Skip to main content

verbs/
source_heads.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Concurrent source heads on one Thread: the report clone, pull, status and
3//! `resolve --heads` share, head selection, and the pick or merge that
4//! resolves them.
5//!
6//! The default head a clone or pull materializes is chosen by
7//! [`repo::thread_replication::source_heads::default_source_head`]; this
8//! module never re-derives it.
9
10use std::collections::BTreeSet;
11
12use anyhow::{Result, anyhow};
13use objects::{
14    HeddleError, RecoveryDetails,
15    object::{Attribution, Blob, StateId, ThreadName},
16    store::ObjectStore,
17};
18use refs::Head;
19use repo::{CommitGraphIndex, Repository, thread_replication::source_heads::DefaultHeadRule};
20use schemars::JsonSchema;
21use serde::Serialize;
22
23use crate::{
24    merge::{
25        ConflictLabels, MergeAttemptPlan, MergePlan, MergeRelationKind, apply_merged_tree,
26        ensure_worktree_clean,
27    },
28    resolve::ClaimedProducerReport,
29    status::next_action::heddle_action,
30};
31
32/// Lists every head with its claimed producer and the pick / merge commands.
33pub const SOURCE_HEADS_ACTION: &str = "heddle resolve --heads";
34
35/// A Thread whose source has several concurrent heads. Every head is listed;
36/// choosing one to check out never discards the others.
37#[derive(Clone, Debug, Serialize, JsonSchema)]
38pub struct SourceHeadsReport {
39    pub thread: String,
40    /// Full State ID of the local Thread tip.
41    pub current: Option<String>,
42    /// Rule a clone or pull used to choose `current`. Absent outside a
43    /// transfer. One of `local_tip`, `local_lineage_greatest_state_id`,
44    /// `greatest_state_id`.
45    pub selected_by: Option<String>,
46    /// Every concurrent head, greatest State ID first.
47    pub heads: Vec<SourceHeadReport>,
48}
49
50#[derive(Clone, Debug, Serialize, JsonSchema)]
51pub struct SourceHeadReport {
52    pub state: String,
53    /// This head is the local Thread tip.
54    pub current: bool,
55    /// Attribution recorded in the head's State, not an authenticated actor.
56    pub producer: ClaimedProducerReport,
57    pub intent: Option<String>,
58    /// Resolve the Thread to exactly this head's tree.
59    pub pick_action: String,
60    /// Three-way merge this head into the local tip. Absent for the tip.
61    pub merge_action: Option<String>,
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
65#[serde(rename_all = "snake_case")]
66pub enum SourceHeadResolutionMode {
67    /// The Thread takes exactly the selected head's tree.
68    Pick,
69    /// The selected head is three-way merged into the local tip.
70    Merge,
71}
72
73/// Outcome of `heddle resolve --pick` / `--merge`.
74#[derive(Clone, Debug, Serialize, JsonSchema)]
75pub struct SourceHeadResolutionReport {
76    pub mode: SourceHeadResolutionMode,
77    pub thread: String,
78    pub selected: String,
79    /// The resolving capture. Absent while a merge waits on conflicts.
80    pub state: Option<String>,
81    /// The resolving capture's parents: every head it retires.
82    pub parents: Vec<String>,
83}
84
85impl SourceHeadsReport {
86    /// The blocker line status and ready show while the heads are unresolved.
87    pub fn blocker(&self) -> String {
88        format!(
89            "thread '{}' has {} unresolved alternative source heads; pick or merge one",
90            self.thread,
91            self.heads.len()
92        )
93    }
94}
95
96fn thread_error(error: repo::thread_replication::Error) -> HeddleError {
97    HeddleError::InvalidObject(error.to_string())
98}
99
100/// Report the concurrent heads of local Thread `thread`, or `None` when it
101/// has at most one head (or no native identity).
102pub fn source_heads_report(
103    repo: &Repository,
104    thread: &str,
105    selected_by: Option<DefaultHeadRule>,
106) -> objects::error::Result<Option<SourceHeadsReport>> {
107    let Some(heads) = repo.native_source_heads(thread).map_err(thread_error)? else {
108        return Ok(None);
109    };
110    if heads.len() < 2 {
111        return Ok(None);
112    }
113    let tip = repo.refs().get_thread(&ThreadName::new(thread))?;
114    let mut entries = Vec::with_capacity(heads.len());
115    for head in heads.iter().rev() {
116        let state = repo
117            .store()
118            .get_state(head)?
119            .ok_or(HeddleError::StateNotFound(*head))?;
120        let full = head.to_string_full();
121        let current = tip == Some(*head);
122        entries.push(SourceHeadReport {
123            pick_action: heddle_action(["resolve", "--pick", full.as_str()]),
124            merge_action: (!current).then(|| heddle_action(["resolve", "--merge", full.as_str()])),
125            state: full,
126            current,
127            producer: (&state.attribution).into(),
128            intent: state.intent.clone(),
129        });
130    }
131    Ok(Some(SourceHeadsReport {
132        thread: thread.to_string(),
133        current: tip.map(|tip| tip.to_string_full()),
134        selected_by: selected_by
135            .filter(|rule| *rule != DefaultHeadRule::Sole)
136            .map(|rule| rule.as_str().to_string()),
137        heads: entries,
138    }))
139}
140
141/// The rule a successful pull applied. A pull only succeeds when the chosen
142/// head is the local tip or fast-forwards it, so the facts determine the
143/// rule [`repo::thread_replication::source_heads::default_source_head`] used.
144pub fn pull_selection_rule(
145    repo: &Repository,
146    local_tip: Option<StateId>,
147    chosen: StateId,
148) -> objects::error::Result<DefaultHeadRule> {
149    let Some(tip) = local_tip else {
150        return Ok(DefaultHeadRule::GreatestStateId);
151    };
152    if tip == chosen {
153        return Ok(DefaultHeadRule::LocalTip);
154    }
155    let mut graph = CommitGraphIndex::new(repo);
156    Ok(
157        if graph
158            .is_ancestor(&tip, &chosen)
159            .map_err(|error| HeddleError::InvalidObject(error.to_string()))?
160        {
161            DefaultHeadRule::LocalLineage
162        } else {
163            DefaultHeadRule::GreatestStateId
164        },
165    )
166}
167
168/// Match `selector` (a full `hs-…` State ID, or a unique prefix with or
169/// without `hs-`) against the Thread's heads only.
170pub fn resolve_head_selector(
171    heads: &BTreeSet<StateId>,
172    thread: &str,
173    selector: &str,
174) -> std::result::Result<StateId, HeddleError> {
175    let wanted = selector.trim().to_ascii_lowercase();
176    let wanted = wanted.strip_prefix("hs-").unwrap_or(&wanted);
177    let matches = if wanted.is_empty() {
178        Vec::new()
179    } else {
180        heads
181            .iter()
182            .copied()
183            .filter(|head| {
184                let full = head.to_string_full();
185                full.strip_prefix("hs-")
186                    .unwrap_or(&full)
187                    .starts_with(wanted)
188            })
189            .collect::<Vec<_>>()
190    };
191    match matches.as_slice() {
192        [head] => Ok(*head),
193        [] => Err(HeddleError::recovery(
194            RecoveryDetails::safety_refusal(
195                "unknown_source_head",
196                format!("'{selector}' is not a source head of thread '{thread}'"),
197                "List the Thread's heads with `heddle resolve --heads`, then select one by its State ID.",
198                format!("no current source head of '{thread}' matches '{selector}'"),
199                "resolving to a State that is not a current head would discard every real alternative",
200                "repository state, refs and worktree files were left unchanged",
201            )
202            .with_recovery_commands(vec![SOURCE_HEADS_ACTION.to_string()]),
203        )),
204        _ => Err(HeddleError::recovery(
205            RecoveryDetails::safety_refusal(
206                "ambiguous_source_head",
207                format!(
208                    "'{selector}' matches {} source heads of thread '{thread}'",
209                    matches.len()
210                ),
211                "Use more of the State ID shown by `heddle resolve --heads`.",
212                format!("'{selector}' is a prefix of several heads"),
213                "picking one of several matches would be a guess",
214                "repository state, refs and worktree files were left unchanged",
215            )
216            .with_recovery_commands(vec![SOURCE_HEADS_ACTION.to_string()]),
217        )),
218    }
219}
220
221/// The Thread HEAD is attached to. Source heads belong to one Thread, so a
222/// detached HEAD has none to list or resolve.
223pub fn attached_thread(repo: &Repository) -> Result<String> {
224    match repo.head_ref()? {
225        Head::Attached { thread } => Ok(thread.to_string()),
226        Head::Detached { .. } => Err(anyhow!(HeddleError::recovery(
227            RecoveryDetails::safety_refusal(
228                "source_head_resolution_detached",
229                "Source heads belong to a checked-out Thread; HEAD is detached",
230                "Switch to the Thread with `heddle thread switch <name>`, then retry.",
231                "HEAD is detached, so no Thread's heads can be listed or resolved",
232                "a resolution capture must advance one Thread",
233                "repository state, refs and worktree files were left unchanged",
234            )
235            .with_recovery_commands(vec!["heddle status".to_string()])
236        ))),
237    }
238}
239
240/// The attached Thread and its heads, after refusing every state a pick or
241/// merge must not start from.
242pub struct SourceHeadSelection {
243    pub thread: String,
244    pub tip: StateId,
245    pub heads: BTreeSet<StateId>,
246    pub selected: StateId,
247}
248
249/// Preflight shared by pick and merge: an attached Thread with several heads,
250/// no merge in progress, a clean worktree, and a selector naming one head.
251pub fn select_source_head(repo: &Repository, selector: &str) -> Result<SourceHeadSelection> {
252    if repo.merge_state_manager().is_merge_in_progress() {
253        return Err(anyhow!(crate::merge::merge_already_in_progress_error()));
254    }
255    let thread = attached_thread(repo)?;
256    let heads = repo
257        .native_source_heads(&thread)
258        .map_err(thread_error)?
259        .unwrap_or_default();
260    if heads.len() < 2 {
261        return Err(anyhow!(HeddleError::recovery(
262            RecoveryDetails::safety_refusal(
263                "no_alternative_source_heads",
264                format!("Thread '{thread}' has no alternative source heads"),
265                "Inspect the Thread with `heddle status`.",
266                format!("thread '{thread}' has {} source head(s)", heads.len()),
267                "there is nothing to pick or merge",
268                "repository state, refs and worktree files were left unchanged",
269            )
270            .with_recovery_commands(vec!["heddle status".to_string()])
271        )));
272    }
273    let selected = resolve_head_selector(&heads, &thread, selector)?;
274    ensure_worktree_clean(repo, "resolve source heads")?;
275    let tip = repo
276        .refs()
277        .get_thread(&ThreadName::new(&thread))?
278        .ok_or(HeddleError::NotFound(format!("thread '{thread}' tip")))?;
279    Ok(SourceHeadSelection {
280        thread,
281        tip,
282        heads,
283        selected,
284    })
285}
286
287/// Resolve every head to `selection.selected`: one capture whose tree is that
288/// head's tree and whose parents name every head.
289pub fn pick_source_head(
290    repo: &Repository,
291    selection: &SourceHeadSelection,
292    attribution: Attribution,
293) -> Result<StateId> {
294    let state = repo
295        .pick_native_source_head(
296            &selection.thread,
297            selection.selected,
298            attribution,
299            format!("Pick source head {}", selection.selected.short()),
300        )
301        .map_err(thread_error)?;
302    Ok(state.id())
303}
304
305pub enum MergeSourceHeadOutcome {
306    /// A merge capture whose parents are the tip and the selected head.
307    Merged(StateId),
308    /// Conflicts are in the worktree and in merge state; `heddle resolve`
309    /// then `heddle continue` finish the merge.
310    Conflicted(Vec<String>),
311}
312
313/// Three-way merge `selection.selected` into the Thread tip.
314pub fn merge_source_head(
315    repo: &Repository,
316    selection: &SourceHeadSelection,
317    attribution: Attribution,
318) -> Result<MergeSourceHeadOutcome> {
319    let SourceHeadSelection {
320        thread,
321        tip,
322        selected,
323        ..
324    } = selection;
325    if tip == selected {
326        return Err(anyhow!(HeddleError::recovery(
327            RecoveryDetails::safety_refusal(
328                "source_head_already_current",
329                format!(
330                    "{} is already the tip of thread '{thread}'",
331                    selected.short()
332                ),
333                "Merge a different head, or pick this one with `heddle resolve --pick`.",
334                "the selected head is the local tip",
335                "merging a head into itself changes nothing",
336                "repository state, refs and worktree files were left unchanged",
337            )
338            .with_recovery_commands(vec![heddle_action([
339                "resolve",
340                "--pick",
341                selected.to_string_full().as_str(),
342            ])])
343        )));
344    }
345    // A hosted clone carries each head's content, not its ancestors'; clone
346    // and pull fetch the merge base when Weft publishes it.
347    let pick_instead = || {
348        vec![heddle_action([
349            "resolve",
350            "--pick",
351            selected.to_string_full().as_str(),
352        ])]
353    };
354    if let Some(base) = CommitGraphIndex::new(repo).find_merge_base(tip, selected)? {
355        let present = match repo.store().get_state(&base)? {
356            Some(state) => repo.store().get_tree(&state.tree)?.is_some(),
357            None => false,
358        };
359        if !present {
360            return Err(anyhow!(HeddleError::recovery(
361                RecoveryDetails::safety_refusal(
362                    "source_head_merge_base_unavailable",
363                    format!(
364                        "The merge base {} of the tip and {} is not available locally",
365                        base.short(),
366                        selected.short()
367                    ),
368                    "Pick one head with `heddle resolve --pick`, which needs no merge base, or pull again once the base is published.",
369                    "a three-way merge needs the common ancestor's content",
370                    "merging against a missing base would treat it as empty and could drop files",
371                    "repository state, refs and worktree files were left unchanged",
372                )
373                .with_recovery_commands(pick_instead())
374            )));
375        }
376    }
377    let current_label = format!("CURRENT ({thread})");
378    let incoming_label = format!("INCOMING ({})", selected.short());
379    let mut graph = CommitGraphIndex::new(repo);
380    let plan = MergePlan::for_merge_command(
381        repo,
382        &mut graph,
383        tip,
384        selected,
385        ConflictLabels {
386            current: &current_label,
387            incoming: &incoming_label,
388            strategy: MergeAttemptPlan::decide(false).strategy(),
389        },
390    )?;
391    let relation = plan.relation();
392    let base = relation.merge_base_id();
393    let result = match relation.kind() {
394        MergeRelationKind::CleanApply
395        | MergeRelationKind::Conflicted
396        | MergeRelationKind::AlreadyIntegrated => plan
397            .merge_result()
398            .ok_or_else(|| anyhow!("merge plan for a source head has no merge result"))?,
399        MergeRelationKind::AlreadyUpToDate | MergeRelationKind::FastForward => {
400            // Concurrent heads never contain one another; the local tip is
401            // behind every head only before a pull finishes. A pick names
402            // every head as a parent and so still resolves the Thread.
403            return Err(anyhow!(HeddleError::recovery(
404                RecoveryDetails::safety_refusal(
405                    "source_head_not_concurrent",
406                    format!(
407                        "{} and the tip of thread '{thread}' are not concurrent",
408                        selected.short()
409                    ),
410                    "Pick the head instead with `heddle resolve --pick`.",
411                    "one side already contains the other, so there is nothing to merge",
412                    "a merge capture here would not name every head",
413                    "repository state, refs and worktree files were left unchanged",
414                )
415                .with_recovery_commands(pick_instead())
416            )));
417        }
418    };
419    apply_merged_tree(repo, &result.tree)?;
420    if result.conflicts.is_empty() {
421        let state = repo.snapshot_merge_with_attribution(
422            selected,
423            Some(format!("Merge source head {}", selected.short())),
424            None,
425            attribution,
426            base,
427            false,
428        )?;
429        return Ok(MergeSourceHeadOutcome::Merged(state.id()));
430    }
431    let structured = plan
432        .structured_conflicts()
433        .map(|payload| -> Result<_> { Ok(repo.store().put_blob(&Blob::new(payload.encode()?))?) })
434        .transpose()?;
435    repo.merge_state_manager().start(
436        *tip,
437        *selected,
438        base,
439        result.conflicts.clone(),
440        structured,
441    )?;
442    Ok(MergeSourceHeadOutcome::Conflicted(result.conflicts.clone()))
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn kind(error: HeddleError) -> &'static str {
450        match error {
451            HeddleError::Recovery(details) => details.kind,
452            other => panic!("expected a typed refusal, got {other}"),
453        }
454    }
455
456    #[test]
457    fn head_selector_matches_one_head_by_unique_prefix_only() {
458        let low = StateId::from_bytes([0; 32]);
459        let mut near = [0; 32];
460        near[31] = 1;
461        let near = StateId::from_bytes(near);
462        let high = StateId::from_bytes([0xff; 32]);
463        let heads = BTreeSet::from([low, near, high]);
464        let full = high.to_string_full();
465        for selector in [
466            full.clone(),
467            full.to_ascii_uppercase(),
468            full.trim_start_matches("hs-").to_string(),
469            full[..10].to_string(),
470        ] {
471            assert_eq!(
472                resolve_head_selector(&heads, "main", &selector).expect("unique head"),
473                high
474            );
475        }
476        let shared = &low.to_string_full()[..20];
477        assert_eq!(
478            kind(resolve_head_selector(&heads, "main", shared).expect_err("two heads")),
479            "ambiguous_source_head"
480        );
481        for selector in ["", "hs-", "hs-yyyy", "not-a-head"] {
482            assert_eq!(
483                kind(resolve_head_selector(&heads, "main", selector).expect_err("no head")),
484                "unknown_source_head"
485            );
486        }
487    }
488}