Skip to main content

chant/
merge_errors.rs

1//! Actionable error messages for merge operations.
2//!
3//! Provides structured error messages with context, diagnosis,
4//! and concrete next steps to help users recover from merge failures.
5
6use std::fmt;
7
8/// Merge error kind - the type of merge failure
9#[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/// Context information for a merge error
28#[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/// Structured merge error with kind and context
44#[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
548// Legacy functions for backward compatibility
549pub 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/// Conflict type classification for merge operations.
611#[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
630/// Detailed merge conflict error with file list and recovery steps.
631pub 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
697/// Classify merge conflict type from git output.
698pub 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
740/// Parse conflicting files from git status --porcelain output.
741pub 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}