1use 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
32pub const SOURCE_HEADS_ACTION: &str = "heddle resolve --heads";
34
35#[derive(Clone, Debug, Serialize, JsonSchema)]
38pub struct SourceHeadsReport {
39 pub thread: String,
40 pub current: Option<String>,
42 pub selected_by: Option<String>,
46 pub heads: Vec<SourceHeadReport>,
48}
49
50#[derive(Clone, Debug, Serialize, JsonSchema)]
51pub struct SourceHeadReport {
52 pub state: String,
53 pub current: bool,
55 pub producer: ClaimedProducerReport,
57 pub intent: Option<String>,
58 pub pick_action: String,
60 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 Pick,
69 Merge,
71}
72
73#[derive(Clone, Debug, Serialize, JsonSchema)]
75pub struct SourceHeadResolutionReport {
76 pub mode: SourceHeadResolutionMode,
77 pub thread: String,
78 pub selected: String,
79 pub state: Option<String>,
81 pub parents: Vec<String>,
83}
84
85impl SourceHeadsReport {
86 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
100pub 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
141pub 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
168pub 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
221pub 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
240pub struct SourceHeadSelection {
243 pub thread: String,
244 pub tip: StateId,
245 pub heads: BTreeSet<StateId>,
246 pub selected: StateId,
247}
248
249pub 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
287pub 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 Merged(StateId),
308 Conflicted(Vec<String>),
311}
312
313pub 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 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: ¤t_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 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}