heddle-verbs 0.28.8

An AI-native version control system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
// SPDX-License-Identifier: Apache-2.0
//! Concurrent source heads on one Thread: the report clone, pull, status and
//! `resolve --heads` share, head selection, and the pick or merge that
//! resolves them.
//!
//! The default head a clone or pull materializes is chosen by
//! [`repo::thread_replication::source_heads::default_source_head`]; this
//! module never re-derives it.

use std::collections::BTreeSet;

use anyhow::{Result, anyhow};
use objects::{
    HeddleError, RecoveryDetails,
    object::{Attribution, Blob, StateId, ThreadName},
    store::ObjectStore,
};
use refs::Head;
use repo::{CommitGraphIndex, Repository, thread_replication::source_heads::DefaultHeadRule};
use schemars::JsonSchema;
use serde::Serialize;

use crate::{
    merge::{
        ConflictLabels, MergeAttemptPlan, MergePlan, MergeRelationKind, apply_merged_tree,
        ensure_worktree_clean,
    },
    resolve::ClaimedProducerReport,
    status::next_action::heddle_action,
};

/// Lists every head with its claimed producer and the pick / merge commands.
pub const SOURCE_HEADS_ACTION: &str = "heddle resolve --heads";

/// A Thread whose source has several concurrent heads. Every head is listed;
/// choosing one to check out never discards the others.
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct SourceHeadsReport {
    pub thread: String,
    /// Full State ID of the local Thread tip.
    pub current: Option<String>,
    /// Rule a clone or pull used to choose `current`. Absent outside a
    /// transfer. One of `local_tip`, `local_lineage_greatest_state_id`,
    /// `greatest_state_id`.
    pub selected_by: Option<String>,
    /// Every concurrent head, greatest State ID first.
    pub heads: Vec<SourceHeadReport>,
}

#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct SourceHeadReport {
    pub state: String,
    /// This head is the local Thread tip.
    pub current: bool,
    /// Attribution recorded in the head's State, not an authenticated actor.
    pub producer: ClaimedProducerReport,
    pub intent: Option<String>,
    /// Resolve the Thread to exactly this head's tree.
    pub pick_action: String,
    /// Three-way merge this head into the local tip. Absent for the tip.
    pub merge_action: Option<String>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SourceHeadResolutionMode {
    /// The Thread takes exactly the selected head's tree.
    Pick,
    /// The selected head is three-way merged into the local tip.
    Merge,
}

/// Outcome of `heddle resolve --pick` / `--merge`.
#[derive(Clone, Debug, Serialize, JsonSchema)]
pub struct SourceHeadResolutionReport {
    pub mode: SourceHeadResolutionMode,
    pub thread: String,
    pub selected: String,
    /// The resolving capture. Absent while a merge waits on conflicts.
    pub state: Option<String>,
    /// The resolving capture's parents: every head it retires.
    pub parents: Vec<String>,
}

impl SourceHeadsReport {
    /// The blocker line status and ready show while the heads are unresolved.
    pub fn blocker(&self) -> String {
        format!(
            "thread '{}' has {} unresolved alternative source heads; pick or merge one",
            self.thread,
            self.heads.len()
        )
    }
}

fn thread_error(error: repo::thread_replication::Error) -> HeddleError {
    HeddleError::InvalidObject(error.to_string())
}

/// Report the concurrent heads of local Thread `thread`, or `None` when it
/// has at most one head (or no native identity).
pub fn source_heads_report(
    repo: &Repository,
    thread: &str,
    selected_by: Option<DefaultHeadRule>,
) -> objects::error::Result<Option<SourceHeadsReport>> {
    let Some(heads) = repo.native_source_heads(thread).map_err(thread_error)? else {
        return Ok(None);
    };
    if heads.len() < 2 {
        return Ok(None);
    }
    let tip = repo.refs().get_thread(&ThreadName::new(thread))?;
    let mut entries = Vec::with_capacity(heads.len());
    for head in heads.iter().rev() {
        let state = repo
            .store()
            .get_state(head)?
            .ok_or(HeddleError::StateNotFound(*head))?;
        let full = head.to_string_full();
        let current = tip == Some(*head);
        entries.push(SourceHeadReport {
            pick_action: heddle_action(["resolve", "--pick", full.as_str()]),
            merge_action: (!current).then(|| heddle_action(["resolve", "--merge", full.as_str()])),
            state: full,
            current,
            producer: (&state.attribution).into(),
            intent: state.intent.clone(),
        });
    }
    Ok(Some(SourceHeadsReport {
        thread: thread.to_string(),
        current: tip.map(|tip| tip.to_string_full()),
        selected_by: selected_by
            .filter(|rule| *rule != DefaultHeadRule::Sole)
            .map(|rule| rule.as_str().to_string()),
        heads: entries,
    }))
}

/// The rule a successful pull applied. A pull only succeeds when the chosen
/// head is the local tip or fast-forwards it, so the facts determine the
/// rule [`repo::thread_replication::source_heads::default_source_head`] used.
pub fn pull_selection_rule(
    repo: &Repository,
    local_tip: Option<StateId>,
    chosen: StateId,
) -> objects::error::Result<DefaultHeadRule> {
    let Some(tip) = local_tip else {
        return Ok(DefaultHeadRule::GreatestStateId);
    };
    if tip == chosen {
        return Ok(DefaultHeadRule::LocalTip);
    }
    let mut graph = CommitGraphIndex::new(repo);
    Ok(
        if graph
            .is_ancestor(&tip, &chosen)
            .map_err(|error| HeddleError::InvalidObject(error.to_string()))?
        {
            DefaultHeadRule::LocalLineage
        } else {
            DefaultHeadRule::GreatestStateId
        },
    )
}

/// Match `selector` (a full `hs-…` State ID, or a unique prefix with or
/// without `hs-`) against the Thread's heads only.
pub fn resolve_head_selector(
    heads: &BTreeSet<StateId>,
    thread: &str,
    selector: &str,
) -> std::result::Result<StateId, HeddleError> {
    let wanted = selector.trim().to_ascii_lowercase();
    let wanted = wanted.strip_prefix("hs-").unwrap_or(&wanted);
    let matches = if wanted.is_empty() {
        Vec::new()
    } else {
        heads
            .iter()
            .copied()
            .filter(|head| {
                let full = head.to_string_full();
                full.strip_prefix("hs-")
                    .unwrap_or(&full)
                    .starts_with(wanted)
            })
            .collect::<Vec<_>>()
    };
    match matches.as_slice() {
        [head] => Ok(*head),
        [] => Err(HeddleError::recovery(
            RecoveryDetails::safety_refusal(
                "unknown_source_head",
                format!("'{selector}' is not a source head of thread '{thread}'"),
                "List the Thread's heads with `heddle resolve --heads`, then select one by its State ID.",
                format!("no current source head of '{thread}' matches '{selector}'"),
                "resolving to a State that is not a current head would discard every real alternative",
                "repository state, refs and worktree files were left unchanged",
            )
            .with_recovery_commands(vec![SOURCE_HEADS_ACTION.to_string()]),
        )),
        _ => Err(HeddleError::recovery(
            RecoveryDetails::safety_refusal(
                "ambiguous_source_head",
                format!(
                    "'{selector}' matches {} source heads of thread '{thread}'",
                    matches.len()
                ),
                "Use more of the State ID shown by `heddle resolve --heads`.",
                format!("'{selector}' is a prefix of several heads"),
                "picking one of several matches would be a guess",
                "repository state, refs and worktree files were left unchanged",
            )
            .with_recovery_commands(vec![SOURCE_HEADS_ACTION.to_string()]),
        )),
    }
}

/// The Thread HEAD is attached to. Source heads belong to one Thread, so a
/// detached HEAD has none to list or resolve.
pub fn attached_thread(repo: &Repository) -> Result<String> {
    match repo.head_ref()? {
        Head::Attached { thread } => Ok(thread.to_string()),
        Head::Detached { .. } => Err(anyhow!(HeddleError::recovery(
            RecoveryDetails::safety_refusal(
                "source_head_resolution_detached",
                "Source heads belong to a checked-out Thread; HEAD is detached",
                "Switch to the Thread with `heddle thread switch <name>`, then retry.",
                "HEAD is detached, so no Thread's heads can be listed or resolved",
                "a resolution capture must advance one Thread",
                "repository state, refs and worktree files were left unchanged",
            )
            .with_recovery_commands(vec!["heddle status".to_string()])
        ))),
    }
}

/// The attached Thread and its heads, after refusing every state a pick or
/// merge must not start from.
pub struct SourceHeadSelection {
    pub thread: String,
    pub tip: StateId,
    pub heads: BTreeSet<StateId>,
    pub selected: StateId,
}

/// Preflight shared by pick and merge: an attached Thread with several heads,
/// no merge in progress, a clean worktree, and a selector naming one head.
pub fn select_source_head(repo: &Repository, selector: &str) -> Result<SourceHeadSelection> {
    if repo.merge_state_manager().is_merge_in_progress() {
        return Err(anyhow!(crate::merge::merge_already_in_progress_error()));
    }
    let thread = attached_thread(repo)?;
    let heads = repo
        .native_source_heads(&thread)
        .map_err(thread_error)?
        .unwrap_or_default();
    if heads.len() < 2 {
        return Err(anyhow!(HeddleError::recovery(
            RecoveryDetails::safety_refusal(
                "no_alternative_source_heads",
                format!("Thread '{thread}' has no alternative source heads"),
                "Inspect the Thread with `heddle status`.",
                format!("thread '{thread}' has {} source head(s)", heads.len()),
                "there is nothing to pick or merge",
                "repository state, refs and worktree files were left unchanged",
            )
            .with_recovery_commands(vec!["heddle status".to_string()])
        )));
    }
    let selected = resolve_head_selector(&heads, &thread, selector)?;
    ensure_worktree_clean(repo, "resolve source heads")?;
    let tip = repo
        .refs()
        .get_thread(&ThreadName::new(&thread))?
        .ok_or(HeddleError::NotFound(format!("thread '{thread}' tip")))?;
    Ok(SourceHeadSelection {
        thread,
        tip,
        heads,
        selected,
    })
}

/// Resolve every head to `selection.selected`: one capture whose tree is that
/// head's tree and whose parents name every head.
pub fn pick_source_head(
    repo: &Repository,
    selection: &SourceHeadSelection,
    attribution: Attribution,
) -> Result<StateId> {
    let state = repo
        .pick_native_source_head(
            &selection.thread,
            selection.selected,
            attribution,
            format!("Pick source head {}", selection.selected.short()),
        )
        .map_err(thread_error)?;
    Ok(state.id())
}

pub enum MergeSourceHeadOutcome {
    /// A merge capture whose parents are the tip and the selected head.
    Merged(StateId),
    /// Conflicts are in the worktree and in merge state; `heddle resolve`
    /// then `heddle continue` finish the merge.
    Conflicted(Vec<String>),
}

/// Three-way merge `selection.selected` into the Thread tip.
pub fn merge_source_head(
    repo: &Repository,
    selection: &SourceHeadSelection,
    attribution: Attribution,
) -> Result<MergeSourceHeadOutcome> {
    let SourceHeadSelection {
        thread,
        tip,
        selected,
        ..
    } = selection;
    if tip == selected {
        return Err(anyhow!(HeddleError::recovery(
            RecoveryDetails::safety_refusal(
                "source_head_already_current",
                format!(
                    "{} is already the tip of thread '{thread}'",
                    selected.short()
                ),
                "Merge a different head, or pick this one with `heddle resolve --pick`.",
                "the selected head is the local tip",
                "merging a head into itself changes nothing",
                "repository state, refs and worktree files were left unchanged",
            )
            .with_recovery_commands(vec![heddle_action([
                "resolve",
                "--pick",
                selected.to_string_full().as_str(),
            ])])
        )));
    }
    // A hosted clone carries each head's content, not its ancestors'; clone
    // and pull fetch the merge base when Weft publishes it.
    let pick_instead = || {
        vec![heddle_action([
            "resolve",
            "--pick",
            selected.to_string_full().as_str(),
        ])]
    };
    if let Some(base) = CommitGraphIndex::new(repo).find_merge_base(tip, selected)? {
        let present = match repo.store().get_state(&base)? {
            Some(state) => repo.store().get_tree(&state.tree)?.is_some(),
            None => false,
        };
        if !present {
            return Err(anyhow!(HeddleError::recovery(
                RecoveryDetails::safety_refusal(
                    "source_head_merge_base_unavailable",
                    format!(
                        "The merge base {} of the tip and {} is not available locally",
                        base.short(),
                        selected.short()
                    ),
                    "Pick one head with `heddle resolve --pick`, which needs no merge base, or pull again once the base is published.",
                    "a three-way merge needs the common ancestor's content",
                    "merging against a missing base would treat it as empty and could drop files",
                    "repository state, refs and worktree files were left unchanged",
                )
                .with_recovery_commands(pick_instead())
            )));
        }
    }
    let current_label = format!("CURRENT ({thread})");
    let incoming_label = format!("INCOMING ({})", selected.short());
    let mut graph = CommitGraphIndex::new(repo);
    let plan = MergePlan::for_merge_command(
        repo,
        &mut graph,
        tip,
        selected,
        ConflictLabels {
            current: &current_label,
            incoming: &incoming_label,
            strategy: MergeAttemptPlan::decide(false).strategy(),
        },
    )?;
    let relation = plan.relation();
    let base = relation.merge_base_id();
    let result = match relation.kind() {
        MergeRelationKind::CleanApply
        | MergeRelationKind::Conflicted
        | MergeRelationKind::AlreadyIntegrated => plan
            .merge_result()
            .ok_or_else(|| anyhow!("merge plan for a source head has no merge result"))?,
        MergeRelationKind::AlreadyUpToDate | MergeRelationKind::FastForward => {
            // Concurrent heads never contain one another; the local tip is
            // behind every head only before a pull finishes. A pick names
            // every head as a parent and so still resolves the Thread.
            return Err(anyhow!(HeddleError::recovery(
                RecoveryDetails::safety_refusal(
                    "source_head_not_concurrent",
                    format!(
                        "{} and the tip of thread '{thread}' are not concurrent",
                        selected.short()
                    ),
                    "Pick the head instead with `heddle resolve --pick`.",
                    "one side already contains the other, so there is nothing to merge",
                    "a merge capture here would not name every head",
                    "repository state, refs and worktree files were left unchanged",
                )
                .with_recovery_commands(pick_instead())
            )));
        }
    };
    apply_merged_tree(repo, &result.tree)?;
    if result.conflicts.is_empty() {
        let state = repo.snapshot_merge_with_attribution(
            selected,
            Some(format!("Merge source head {}", selected.short())),
            None,
            attribution,
            base,
            false,
        )?;
        return Ok(MergeSourceHeadOutcome::Merged(state.id()));
    }
    let structured = plan
        .structured_conflicts()
        .map(|payload| -> Result<_> { Ok(repo.store().put_blob(&Blob::new(payload.encode()?))?) })
        .transpose()?;
    repo.merge_state_manager().start(
        *tip,
        *selected,
        base,
        result.conflicts.clone(),
        structured,
    )?;
    Ok(MergeSourceHeadOutcome::Conflicted(result.conflicts.clone()))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn kind(error: HeddleError) -> &'static str {
        match error {
            HeddleError::Recovery(details) => details.kind,
            other => panic!("expected a typed refusal, got {other}"),
        }
    }

    #[test]
    fn head_selector_matches_one_head_by_unique_prefix_only() {
        let low = StateId::from_bytes([0; 32]);
        let mut near = [0; 32];
        near[31] = 1;
        let near = StateId::from_bytes(near);
        let high = StateId::from_bytes([0xff; 32]);
        let heads = BTreeSet::from([low, near, high]);
        let full = high.to_string_full();
        for selector in [
            full.clone(),
            full.to_ascii_uppercase(),
            full.trim_start_matches("hs-").to_string(),
            full[..10].to_string(),
        ] {
            assert_eq!(
                resolve_head_selector(&heads, "main", &selector).expect("unique head"),
                high
            );
        }
        let shared = &low.to_string_full()[..20];
        assert_eq!(
            kind(resolve_head_selector(&heads, "main", shared).expect_err("two heads")),
            "ambiguous_source_head"
        );
        for selector in ["", "hs-", "hs-yyyy", "not-a-head"] {
            assert_eq!(
                kind(resolve_head_selector(&heads, "main", selector).expect_err("no head")),
                "unknown_source_head"
            );
        }
    }
}