1use std::fmt;
7
8#[derive(Debug, Clone, PartialEq)]
10pub enum MergeErrorKind {
11 FastForwardConflict,
12 MergeConflict,
13 BranchNotFound,
14 MainBranchNotFound,
15 SpecStatusNotMergeable,
16 NoBranchForSpec,
17 WorktreeAlreadyExists,
18 NoCommitsFound,
19 DriverMembersIncomplete,
20 MemberMergeFailed,
21 GenericMergeFailed,
22 RebaseConflict,
23 MergeStopped,
24 RebaseStopped,
25}
26
27#[derive(Debug, Clone, Default)]
29pub struct MergeContext {
30 pub spec_id: String,
31 pub spec_branch: Option<String>,
32 pub main_branch: Option<String>,
33 pub status: Option<String>,
34 pub stderr: Option<String>,
35 pub conflicting_files: Vec<String>,
36 pub incomplete_members: Vec<String>,
37 pub member_id: Option<String>,
38 pub error_message: Option<String>,
39 pub worktree_path: Option<String>,
40 pub driver_id: Option<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct MergeError {
46 pub kind: MergeErrorKind,
47 pub context: MergeContext,
48}
49
50impl MergeError {
51 pub fn new(kind: MergeErrorKind, context: MergeContext) -> Self {
52 Self { kind, context }
53 }
54
55 pub fn fast_forward_conflict(
56 spec_id: &str,
57 spec_branch: &str,
58 main_branch: &str,
59 stderr: &str,
60 ) -> Self {
61 Self::new(
62 MergeErrorKind::FastForwardConflict,
63 MergeContext {
64 spec_id: spec_id.to_string(),
65 spec_branch: Some(spec_branch.to_string()),
66 main_branch: Some(main_branch.to_string()),
67 stderr: Some(stderr.to_string()),
68 ..Default::default()
69 },
70 )
71 }
72
73 pub fn merge_conflict(spec_id: &str, spec_branch: &str, main_branch: &str) -> Self {
74 Self::new(
75 MergeErrorKind::MergeConflict,
76 MergeContext {
77 spec_id: spec_id.to_string(),
78 spec_branch: Some(spec_branch.to_string()),
79 main_branch: Some(main_branch.to_string()),
80 ..Default::default()
81 },
82 )
83 }
84
85 pub fn branch_not_found(spec_id: &str, spec_branch: &str) -> Self {
86 Self::new(
87 MergeErrorKind::BranchNotFound,
88 MergeContext {
89 spec_id: spec_id.to_string(),
90 spec_branch: Some(spec_branch.to_string()),
91 ..Default::default()
92 },
93 )
94 }
95
96 pub fn main_branch_not_found(main_branch: &str) -> Self {
97 Self::new(
98 MergeErrorKind::MainBranchNotFound,
99 MergeContext {
100 main_branch: Some(main_branch.to_string()),
101 ..Default::default()
102 },
103 )
104 }
105
106 pub fn spec_status_not_mergeable(spec_id: &str, status: &str) -> Self {
107 Self::new(
108 MergeErrorKind::SpecStatusNotMergeable,
109 MergeContext {
110 spec_id: spec_id.to_string(),
111 status: Some(status.to_string()),
112 ..Default::default()
113 },
114 )
115 }
116
117 pub fn no_branch_for_spec(spec_id: &str) -> Self {
118 Self::new(
119 MergeErrorKind::NoBranchForSpec,
120 MergeContext {
121 spec_id: spec_id.to_string(),
122 ..Default::default()
123 },
124 )
125 }
126
127 pub fn worktree_already_exists(spec_id: &str, worktree_path: &str, branch: &str) -> Self {
128 Self::new(
129 MergeErrorKind::WorktreeAlreadyExists,
130 MergeContext {
131 spec_id: spec_id.to_string(),
132 spec_branch: Some(branch.to_string()),
133 worktree_path: Some(worktree_path.to_string()),
134 ..Default::default()
135 },
136 )
137 }
138
139 pub fn no_commits_found(spec_id: &str, branch: &str) -> Self {
140 Self::new(
141 MergeErrorKind::NoCommitsFound,
142 MergeContext {
143 spec_id: spec_id.to_string(),
144 spec_branch: Some(branch.to_string()),
145 ..Default::default()
146 },
147 )
148 }
149
150 pub fn driver_members_incomplete(driver_id: &str, incomplete: &[String]) -> Self {
151 Self::new(
152 MergeErrorKind::DriverMembersIncomplete,
153 MergeContext {
154 driver_id: Some(driver_id.to_string()),
155 incomplete_members: incomplete.to_vec(),
156 ..Default::default()
157 },
158 )
159 }
160
161 pub fn member_merge_failed(driver_id: &str, member_id: &str, error: &str) -> Self {
162 Self::new(
163 MergeErrorKind::MemberMergeFailed,
164 MergeContext {
165 driver_id: Some(driver_id.to_string()),
166 member_id: Some(member_id.to_string()),
167 error_message: Some(error.to_string()),
168 ..Default::default()
169 },
170 )
171 }
172
173 pub fn generic_merge_failed(
174 spec_id: &str,
175 branch: &str,
176 main_branch: &str,
177 error: &str,
178 ) -> Self {
179 Self::new(
180 MergeErrorKind::GenericMergeFailed,
181 MergeContext {
182 spec_id: spec_id.to_string(),
183 spec_branch: Some(branch.to_string()),
184 main_branch: Some(main_branch.to_string()),
185 error_message: Some(error.to_string()),
186 ..Default::default()
187 },
188 )
189 }
190
191 pub fn rebase_conflict(spec_id: &str, branch: &str, conflicting_files: &[String]) -> Self {
192 Self::new(
193 MergeErrorKind::RebaseConflict,
194 MergeContext {
195 spec_id: spec_id.to_string(),
196 spec_branch: Some(branch.to_string()),
197 conflicting_files: conflicting_files.to_vec(),
198 ..Default::default()
199 },
200 )
201 }
202
203 pub fn merge_stopped(spec_id: &str) -> Self {
204 Self::new(
205 MergeErrorKind::MergeStopped,
206 MergeContext {
207 spec_id: spec_id.to_string(),
208 ..Default::default()
209 },
210 )
211 }
212
213 pub fn rebase_stopped(spec_id: &str) -> Self {
214 Self::new(
215 MergeErrorKind::RebaseStopped,
216 MergeContext {
217 spec_id: spec_id.to_string(),
218 ..Default::default()
219 },
220 )
221 }
222}
223
224impl fmt::Display for MergeError {
225 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226 use MergeErrorKind::*;
227
228 match &self.kind {
229 FastForwardConflict => {
230 let spec_id = &self.context.spec_id;
231 let spec_branch = self.context.spec_branch.as_deref().unwrap_or("");
232 let main_branch = self.context.main_branch.as_deref().unwrap_or("");
233 let stderr = self.context.stderr.as_deref().unwrap_or("").trim();
234
235 write!(
236 f,
237 "Error: Cannot fast-forward merge for spec {}\n\n\
238 Context:\n\
239 \x20 - Branch: {}\n\
240 \x20 - Target: {}\n\
241 \x20 - Branches have diverged from common ancestor\n\
242 \x20 - Git output: {}\n\n\
243 Next Steps:\n\
244 \x20 1. Use no-fast-forward merge: chant merge {} --no-ff\n\
245 \x20 2. Or rebase onto {}: chant merge {} --rebase\n\
246 \x20 3. Or merge manually: git merge --no-ff {}\n\
247 \x20 4. Debug divergence: git log {} --oneline -5\n\n\
248 Tip: Use 'chant merge --help' for all available options",
249 spec_id,
250 spec_branch,
251 main_branch,
252 stderr,
253 spec_id,
254 main_branch,
255 spec_id,
256 spec_branch,
257 spec_branch
258 )
259 }
260 MergeConflict => {
261 let spec_id = &self.context.spec_id;
262 let spec_branch = self.context.spec_branch.as_deref().unwrap_or("");
263 let main_branch = self.context.main_branch.as_deref().unwrap_or("");
264
265 write!(
266 f,
267 "Error: Merge conflicts detected for spec {}\n\n\
268 Context:\n\
269 \x20 - Branch: {}\n\
270 \x20 - Target: {}\n\
271 \x20 - Conflicting changes exist between branches\n\n\
272 Diagnosis:\n\
273 \x20 - The spec branch and {} have conflicting changes\n\
274 \x20 - Merge was aborted to preserve both branches\n\n\
275 Next Steps:\n\
276 \x20 1. Auto-resolve conflicts: chant merge {} --rebase --auto\n\
277 \x20 2. Rebase first, then merge: chant merge {} --rebase\n\
278 \x20 3. Manual merge: git merge --no-ff {}\n\
279 \x20 4. Inspect conflicts: git diff {} {}\n\
280 \x20 5. View branch history: git log {} --oneline -5\n\n\
281 Documentation: See 'chant merge --help' for more options",
282 spec_id,
283 spec_branch,
284 main_branch,
285 main_branch,
286 spec_id,
287 spec_id,
288 spec_branch,
289 main_branch,
290 spec_branch,
291 spec_branch
292 )
293 }
294 BranchNotFound => {
295 let spec_id = &self.context.spec_id;
296 let spec_branch = self.context.spec_branch.as_deref().unwrap_or("");
297
298 write!(
299 f,
300 "Error: Spec branch '{}' not found for spec {}\n\n\
301 Context:\n\
302 \x20 - Expected branch: {}\n\
303 \x20 - The branch may have been deleted or never created\n\n\
304 Diagnosis:\n\
305 \x20 - Check if the spec was worked in branch mode\n\
306 \x20 - The branch may have been cleaned up after a previous merge\n\n\
307 Next Steps:\n\
308 \x20 1. List all chant branches: git branch --list 'chant/*'\n\
309 \x20 2. Check worktree status: git worktree list\n\
310 \x20 3. If branch existed, check reflog: git reflog --all\n\
311 \x20 4. If work was lost, re-execute: chant work {}\n\n\
312 Documentation: See 'chant merge --help' for more options",
313 spec_branch, spec_id, spec_branch, spec_id
314 )
315 }
316 MainBranchNotFound => {
317 let main_branch = self.context.main_branch.as_deref().unwrap_or("");
318
319 write!(
320 f,
321 "Error: Main branch '{}' does not exist\n\n\
322 Context:\n\
323 \x20 - Expected main branch: {}\n\
324 \x20 - This is typically 'main' or 'master'\n\n\
325 Diagnosis:\n\
326 \x20 - The repository may use a different default branch name\n\n\
327 Next Steps:\n\
328 \x20 1. Check available branches: git branch -a\n\
329 \x20 2. Check remote default: git remote show origin | grep 'HEAD branch'\n\
330 \x20 3. If using a different name, configure it in .chant/config.md\n\n\
331 Documentation: See 'chant merge --help' for more options",
332 main_branch, main_branch
333 )
334 }
335 SpecStatusNotMergeable => {
336 let spec_id = &self.context.spec_id;
337 let status = self.context.status.as_deref().unwrap_or("");
338
339 write!(
340 f,
341 "Error: Cannot merge spec {} (status: {})\n\n\
342 Context:\n\
343 \x20 - Spec: {}\n\
344 \x20 - Current status: {}\n\
345 \x20 - Only completed specs can be merged\n\n\
346 Next Steps:\n\
347 \x20 1. Check spec details: chant show {}\n\
348 \x20 2. If work is done, finalize first: chant finalize {}\n\
349 \x20 3. If needs attention, resolve issues and retry\n\n\
350 Documentation: See 'chant merge --help' for more options",
351 spec_id, status, spec_id, status, spec_id, spec_id
352 )
353 }
354 NoBranchForSpec => {
355 let spec_id = &self.context.spec_id;
356
357 write!(
358 f,
359 "Error: No branch found for spec {}\n\n\
360 Context:\n\
361 \x20 - Spec: {}\n\
362 \x20 - The spec is completed but has no associated branch\n\n\
363 Diagnosis:\n\
364 \x20 - The spec may have been worked in direct mode (no separate branch)\n\
365 \x20 - The branch may have been deleted after a previous merge\n\n\
366 Next Steps:\n\
367 \x20 1. Check for existing branches: git branch --list 'chant/*{}*'\n\
368 \x20 2. Check if already merged: git log --oneline --grep='chant({})'\n\
369 \x20 3. If not merged and branch lost, re-execute: chant work {}\n\n\
370 Documentation: See 'chant merge --help' for more options",
371 spec_id, spec_id, spec_id, spec_id, spec_id
372 )
373 }
374 WorktreeAlreadyExists => {
375 let spec_id = &self.context.spec_id;
376 let worktree_path = self.context.worktree_path.as_deref().unwrap_or("");
377 let branch = self.context.spec_branch.as_deref().unwrap_or("");
378
379 write!(
380 f,
381 "Error: Worktree already exists for spec {}\n\n\
382 Context:\n\
383 \x20 - Worktree path: {}\n\
384 \x20 - Branch: {}\n\
385 \x20 - A worktree at this path is already in use\n\n\
386 Diagnosis:\n\
387 \x20 - A previous execution may not have cleaned up properly\n\
388 \x20 - The worktree may still be in use by another process\n\n\
389 Next Steps:\n\
390 \x20 1. Remove manually: git worktree remove {} --force\n\
391 \x20 2. List all worktrees: git worktree list\n\
392 \x20 3. Then retry: chant work {}",
393 spec_id, worktree_path, branch, worktree_path, spec_id
394 )
395 }
396 NoCommitsFound => {
397 let spec_id = &self.context.spec_id;
398 let branch = self.context.spec_branch.as_deref().unwrap_or("");
399
400 write!(
401 f,
402 "Error: No commits found matching pattern 'chant({}):'\n\n\
403 Context:\n\
404 \x20 - Branch: {}\n\
405 \x20 - Expected pattern: 'chant({}): <description>'\n\n\
406 Diagnosis:\n\
407 \x20 - The agent may have forgotten to commit with the correct pattern\n\
408 \x20 - Commit messages must include 'chant({}):' prefix\n\n\
409 Next Steps:\n\
410 \x20 1. Check commits on branch: git log {} --oneline\n\
411 \x20 2. If commits exist but wrong pattern, amend or merge manually\n\
412 \x20 3. If no work was done, the branch may be empty\n\
413 \x20 4. Use --allow-no-commits as fallback (special cases only)\n\n\
414 Debugging: Report this if commits look correct - may be a pattern matching bug\n\n\
415 Documentation: See 'chant merge --help' for more options",
416 spec_id, branch, spec_id, spec_id, branch
417 )
418 }
419 DriverMembersIncomplete => {
420 let driver_id = self.context.driver_id.as_deref().unwrap_or("");
421 let incomplete = &self.context.incomplete_members;
422
423 write!(
424 f,
425 "Error: Cannot merge driver spec {} - members are incomplete\n\n\
426 Context:\n\
427 \x20 - Driver spec: {}\n\
428 \x20 - All member specs must be completed before merging the driver\n\n\
429 Incomplete members:\n\
430 \x20 - {}\n\n\
431 Next Steps:\n\
432 \x20 1. Check each incomplete member: chant show <member-id>\n\
433 \x20 2. Complete or cancel pending members\n\
434 \x20 3. Retry driver merge: chant merge {}\n\n\
435 Documentation: See 'chant merge --help' for more options",
436 driver_id,
437 driver_id,
438 incomplete.join("\n - "),
439 driver_id
440 )
441 }
442 MemberMergeFailed => {
443 let driver_id = self.context.driver_id.as_deref().unwrap_or("");
444 let member_id = self.context.member_id.as_deref().unwrap_or("");
445 let error = self.context.error_message.as_deref().unwrap_or("");
446
447 write!(
448 f,
449 "Error: Member spec merge failed, driver merge not attempted\n\n\
450 Context:\n\
451 \x20 - Driver spec: {}\n\
452 \x20 - Failed member: {}\n\
453 \x20 - Error: {}\n\n\
454 Next Steps:\n\
455 \x20 1. Resolve the member merge issue first\n\
456 \x20 2. Merge the member manually: chant merge {}\n\
457 \x20 3. Then retry the driver merge: chant merge {}\n\
458 \x20 4. Or use rebase: chant merge {} --rebase\n\n\
459 Documentation: See 'chant merge --help' for more options",
460 driver_id, member_id, error, member_id, driver_id, member_id
461 )
462 }
463 GenericMergeFailed => {
464 let spec_id = &self.context.spec_id;
465 let branch = self.context.spec_branch.as_deref().unwrap_or("");
466 let main_branch = self.context.main_branch.as_deref().unwrap_or("");
467 let error = self.context.error_message.as_deref().unwrap_or("").trim();
468
469 write!(
470 f,
471 "Error: Merge failed for spec {}\n\n\
472 Context:\n\
473 \x20 - Branch: {}\n\
474 \x20 - Target: {}\n\
475 \x20 - Error: {}\n\n\
476 Next Steps:\n\
477 \x20 1. Try with rebase: chant merge {} --rebase\n\
478 \x20 2. Or auto-resolve: chant merge {} --rebase --auto\n\
479 \x20 3. Manual merge: git merge --no-ff {}\n\
480 \x20 4. Debug: git log {} --online -5\n\n\
481 Documentation: See 'chant merge --help' for more options",
482 spec_id, branch, main_branch, error, spec_id, spec_id, branch, branch
483 )
484 }
485 RebaseConflict => {
486 let spec_id = &self.context.spec_id;
487 let branch = self.context.spec_branch.as_deref().unwrap_or("");
488 let conflicting_files = &self.context.conflicting_files;
489
490 write!(
491 f,
492 "Error: Rebase conflict for spec {}\n\n\
493 Context:\n\
494 \x20 - Branch: {}\n\
495 \x20 - Conflicting files:\n\
496 \x20 - {}\n\n\
497 Next Steps:\n\
498 \x20 1. Auto-resolve: chant merge {} --rebase --auto\n\
499 \x20 2. Resolve manually, then: git rebase --continue\n\
500 \x20 3. Abort rebase: git rebase --abort\n\
501 \x20 4. Try direct merge instead: git merge --no-ff {}\n\n\
502 Documentation: See 'chant merge --help' for more options",
503 spec_id,
504 branch,
505 conflicting_files.join("\n - "),
506 spec_id,
507 branch
508 )
509 }
510 MergeStopped => {
511 let spec_id = &self.context.spec_id;
512
513 write!(
514 f,
515 "Error: Merge stopped at spec {}\n\n\
516 Context:\n\
517 \x20 - Processing halted due to merge failure\n\
518 \x20 - Remaining specs were not processed\n\n\
519 Next Steps:\n\
520 \x20 1. Resolve the issue with spec {}: chant show {}\n\
521 \x20 2. Retry with continue-on-error: chant merge --all --continue-on-error\n\
522 \x20 3. Or merge specs individually: chant merge {}\n\n\
523 Documentation: See 'chant merge --help' for more options",
524 spec_id, spec_id, spec_id, spec_id
525 )
526 }
527 RebaseStopped => {
528 let spec_id = &self.context.spec_id;
529
530 write!(
531 f,
532 "Error: Merge stopped at spec {} due to rebase conflict\n\n\
533 Context:\n\
534 \x20 - Rebase encountered conflicts\n\
535 \x20 - Remaining specs were not processed\n\n\
536 Next Steps:\n\
537 \x20 1. Auto-resolve conflicts: chant merge {} --rebase --auto\n\
538 \x20 2. Use continue-on-error: chant merge --all --rebase --continue-on-error\n\
539 \x20 3. Resolve manually and retry\n\n\
540 Documentation: See 'chant merge --help' for more options",
541 spec_id, spec_id
542 )
543 }
544 }
545 }
546}
547
548pub fn fast_forward_conflict(
550 spec_id: &str,
551 spec_branch: &str,
552 main_branch: &str,
553 stderr: &str,
554) -> String {
555 MergeError::fast_forward_conflict(spec_id, spec_branch, main_branch, stderr).to_string()
556}
557
558pub fn merge_conflict(spec_id: &str, spec_branch: &str, main_branch: &str) -> String {
559 MergeError::merge_conflict(spec_id, spec_branch, main_branch).to_string()
560}
561
562pub fn branch_not_found(spec_id: &str, spec_branch: &str) -> String {
563 MergeError::branch_not_found(spec_id, spec_branch).to_string()
564}
565
566pub fn main_branch_not_found(main_branch: &str) -> String {
567 MergeError::main_branch_not_found(main_branch).to_string()
568}
569
570pub fn spec_status_not_mergeable(spec_id: &str, status: &str) -> String {
571 MergeError::spec_status_not_mergeable(spec_id, status).to_string()
572}
573
574pub fn no_branch_for_spec(spec_id: &str) -> String {
575 MergeError::no_branch_for_spec(spec_id).to_string()
576}
577
578pub fn worktree_already_exists(spec_id: &str, worktree_path: &str, branch: &str) -> String {
579 MergeError::worktree_already_exists(spec_id, worktree_path, branch).to_string()
580}
581
582pub fn no_commits_found(spec_id: &str, branch: &str) -> String {
583 MergeError::no_commits_found(spec_id, branch).to_string()
584}
585
586pub fn driver_members_incomplete(driver_id: &str, incomplete: &[String]) -> String {
587 MergeError::driver_members_incomplete(driver_id, incomplete).to_string()
588}
589
590pub fn member_merge_failed(driver_id: &str, member_id: &str, error: &str) -> String {
591 MergeError::member_merge_failed(driver_id, member_id, error).to_string()
592}
593
594pub fn generic_merge_failed(spec_id: &str, branch: &str, main_branch: &str, error: &str) -> String {
595 MergeError::generic_merge_failed(spec_id, branch, main_branch, error).to_string()
596}
597
598pub fn rebase_conflict(spec_id: &str, branch: &str, conflicting_files: &[String]) -> String {
599 MergeError::rebase_conflict(spec_id, branch, conflicting_files).to_string()
600}
601
602pub fn merge_stopped(spec_id: &str) -> String {
603 MergeError::merge_stopped(spec_id).to_string()
604}
605
606pub fn rebase_stopped(spec_id: &str) -> String {
607 MergeError::rebase_stopped(spec_id).to_string()
608}
609
610#[derive(Debug, Clone, PartialEq)]
612pub enum ConflictType {
613 FastForward,
614 Content,
615 Tree,
616 Unknown,
617}
618
619impl fmt::Display for ConflictType {
620 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
621 match self {
622 ConflictType::FastForward => write!(f, "fast-forward"),
623 ConflictType::Content => write!(f, "content"),
624 ConflictType::Tree => write!(f, "tree"),
625 ConflictType::Unknown => write!(f, "unknown"),
626 }
627 }
628}
629
630pub fn merge_conflict_detailed(
632 spec_id: &str,
633 spec_branch: &str,
634 main_branch: &str,
635 conflict_type: ConflictType,
636 conflicting_files: &[String],
637) -> String {
638 let conflict_type_str = match conflict_type {
639 ConflictType::FastForward => "Cannot fast-forward",
640 ConflictType::Content => "Content conflicts detected",
641 ConflictType::Tree => "Tree conflicts detected",
642 ConflictType::Unknown => "Merge conflicts detected",
643 };
644
645 let files_section = if conflicting_files.is_empty() {
646 " (unable to determine conflicting files)".to_string()
647 } else {
648 conflicting_files
649 .iter()
650 .map(|f| format!(" - {}", f))
651 .collect::<Vec<_>>()
652 .join("\n")
653 };
654
655 let recovery_steps = match conflict_type {
656 ConflictType::FastForward => format!(
657 "Next steps:\n\
658 \x20 1. Use no-fast-forward merge: chant merge {} --no-ff\n\
659 \x20 2. Or rebase onto {}: chant merge {} --rebase\n\
660 \x20 3. Or merge manually: git merge --no-ff {}",
661 spec_id, main_branch, spec_id, spec_branch
662 ),
663 ConflictType::Content | ConflictType::Tree | ConflictType::Unknown => format!(
664 "Next steps:\n\
665 \x20 1. Resolve conflicts manually, then: git merge --continue\n\
666 \x20 2. Or try automatic rebase: chant merge {} --rebase --auto\n\
667 \x20 3. Or abort: git merge --abort\n\n\
668 Example (resolve manually):\n\
669 \x20 $ git status # see conflicting files\n\
670 \x20 $ vim src/main.rs # edit to resolve\n\
671 \x20 $ git add src/main.rs # stage resolved file\n\
672 \x20 $ git merge --continue # complete merge",
673 spec_id
674 ),
675 };
676
677 format!(
678 "Error: {} for spec {}\n\n\
679 Context:\n\
680 \x20 - Branch: {}\n\
681 \x20 - Target: {}\n\
682 \x20 - Conflict type: {}\n\n\
683 Files with conflicts:\n\
684 {}\n\n\
685 {}\n\n\
686 Documentation: See 'chant merge --help' for more options",
687 conflict_type_str,
688 spec_id,
689 spec_branch,
690 main_branch,
691 conflict_type,
692 files_section,
693 recovery_steps
694 )
695}
696
697pub fn classify_conflict_type(stderr: &str, status_output: Option<&str>) -> ConflictType {
699 let stderr_lower = stderr.to_lowercase();
700
701 if stderr_lower.contains("not possible to fast-forward")
702 || stderr_lower.contains("cannot fast-forward")
703 || stderr_lower.contains("refusing to merge unrelated histories")
704 {
705 return ConflictType::FastForward;
706 }
707
708 if stderr_lower.contains("conflict (rename/delete)")
709 || stderr_lower.contains("conflict (modify/delete)")
710 || stderr_lower.contains("deleted in")
711 || stderr_lower.contains("renamed in")
712 || stderr_lower.contains("conflict (add/add)")
713 {
714 return ConflictType::Tree;
715 }
716
717 if let Some(status) = status_output {
718 if status.lines().any(|line| {
719 let prefix = line.get(..2).unwrap_or("");
720 matches!(prefix, "DD" | "AU" | "UD" | "UA" | "DU")
721 }) {
722 return ConflictType::Tree;
723 }
724
725 if status.lines().any(|line| {
726 let prefix = line.get(..2).unwrap_or("");
727 matches!(prefix, "UU" | "AA")
728 }) {
729 return ConflictType::Content;
730 }
731 }
732
733 if stderr_lower.contains("conflict") || stderr_lower.contains("merge conflict") {
734 return ConflictType::Content;
735 }
736
737 ConflictType::Unknown
738}
739
740pub fn parse_conflicting_files(status_output: &str) -> Vec<String> {
742 let mut files = Vec::new();
743
744 for line in status_output.lines() {
745 if line.len() >= 3 {
746 let status = &line[0..2];
747 if status.contains('U') || status == "AA" || status == "DD" {
748 let file = line[3..].trim();
749 files.push(file.to_string());
750 }
751 }
752 }
753
754 files
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760
761 #[test]
762 fn test_fast_forward_conflict_contains_spec_id() {
763 let msg = fast_forward_conflict(
764 "001-abc",
765 "chant/001-abc",
766 "main",
767 "fatal: cannot fast-forward",
768 );
769 assert!(msg.contains("001-abc"));
770 assert!(msg.contains("chant/001-abc"));
771 assert!(msg.contains("main"));
772 assert!(msg.contains("Next Steps"));
773 assert!(msg.contains("chant merge 001-abc --no-ff"));
774 assert!(msg.contains("chant merge 001-abc --rebase"));
775 }
776
777 #[test]
778 fn test_merge_conflict_contains_recovery_steps() {
779 let msg = merge_conflict("001-abc", "chant/001-abc", "main");
780 assert!(msg.contains("Merge conflicts detected"));
781 assert!(msg.contains("chant merge 001-abc --rebase --auto"));
782 assert!(msg.contains("git merge --no-ff chant/001-abc"));
783 assert!(msg.contains("Documentation"));
784 }
785
786 #[test]
787 fn test_conflict_type_display() {
788 assert_eq!(format!("{}", ConflictType::FastForward), "fast-forward");
789 assert_eq!(format!("{}", ConflictType::Content), "content");
790 assert_eq!(format!("{}", ConflictType::Tree), "tree");
791 assert_eq!(format!("{}", ConflictType::Unknown), "unknown");
792 }
793
794 #[test]
795 fn test_classify_conflict_type_fast_forward() {
796 let stderr = "fatal: Not possible to fast-forward, aborting.";
797 assert_eq!(
798 classify_conflict_type(stderr, None),
799 ConflictType::FastForward
800 );
801 }
802
803 #[test]
804 fn test_parse_conflicting_files() {
805 let status = "UU src/main.rs\nUU src/lib.rs\nM src/other.rs\n";
806 let files = parse_conflicting_files(status);
807 assert_eq!(files.len(), 2);
808 assert!(files.contains(&"src/main.rs".to_string()));
809 assert!(files.contains(&"src/lib.rs".to_string()));
810 }
811}