• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

valksor / go-mehrhof / 21649231814

03 Feb 2026 09:46PM UTC coverage: 40.273% (-0.7%) from 40.974%
21649231814

push

github

k0d3r1s
Updates static assets and license info

Adds updated CSS styles.
Updates open source license information.

405 of 1290 branches covered (31.4%)

Branch coverage included in aggregate %.

36225 of 89664 relevant lines covered (40.4%)

35.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

44.33
/internal/stack/rebase.go
1
package stack
2

3
import (
4
        "context"
5
        "errors"
6
        "fmt"
7
        "time"
8

9
        "github.com/valksor/go-mehrhof/internal/vcs"
10
)
11

12
// ErrRebaseConflict indicates a rebase failed due to merge conflicts.
13
var ErrRebaseConflict = errors.New("rebase conflict")
14

15
// Rebaser handles rebasing stacked tasks after parent branches merge.
16
type Rebaser struct {
17
        storage *Storage
18
        git     *vcs.Git
19
}
20

21
// NewRebaser creates a new Rebaser.
22
func NewRebaser(storage *Storage, git *vcs.Git) *Rebaser {
11✔
23
        return &Rebaser{
11✔
24
                storage: storage,
11✔
25
                git:     git,
11✔
26
        }
11✔
27
}
11✔
28

29
// RebaseResult contains the results of a rebase operation.
30
type RebaseResult struct {
31
        RebasedTasks   []RebaseTaskResult
32
        SkippedTasks   []SkippedTask
33
        FailedTask     *FailedRebase
34
        OriginalBranch string
35
}
36

37
// RebaseTaskResult represents a successful rebase of a single task.
38
type RebaseTaskResult struct {
39
        TaskID   string
40
        Branch   string
41
        OldBase  string
42
        NewBase  string
43
        Rebased  bool
44
        Duration time.Duration
45
}
46

47
// SkippedTask represents a task that was skipped during rebase.
48
type SkippedTask struct {
49
        TaskID string
50
        Branch string
51
        Reason string
52
}
53

54
// FailedRebase contains details about a failed rebase.
55
type FailedRebase struct {
56
        TaskID       string
57
        Branch       string
58
        OntoBase     string
59
        Error        error
60
        IsConflict   bool
61
        ConflictHint string
62
}
63

64
// RebaseAll rebases all tasks in the stack that need rebasing.
65
// Returns after first conflict - abort is automatic.
66
func (r *Rebaser) RebaseAll(ctx context.Context, stackID string) (*RebaseResult, error) {
2✔
67
        if err := r.storage.Load(); err != nil {
2✔
68
                return nil, fmt.Errorf("load stacks: %w", err)
×
69
        }
×
70

71
        s := r.storage.GetStack(stackID)
2✔
72
        if s == nil {
3✔
73
                return nil, fmt.Errorf("stack not found: %s", stackID)
1✔
74
        }
1✔
75

76
        // Get current branch to restore later
77
        originalBranch, err := r.git.CurrentBranch(ctx)
1✔
78
        if err != nil {
1✔
79
                return nil, fmt.Errorf("get current branch: %w", err)
×
80
        }
×
81

82
        result := &RebaseResult{
1✔
83
                RebasedTasks:   make([]RebaseTaskResult, 0),
1✔
84
                SkippedTasks:   make([]SkippedTask, 0),
1✔
85
                OriginalBranch: originalBranch,
1✔
86
        }
1✔
87

1✔
88
        // Get tasks in dependency order (parents first)
1✔
89
        tasksToRebase := r.getTasksInRebaseOrder(s)
1✔
90

1✔
91
        for _, task := range tasksToRebase {
1✔
92
                taskResult, err := r.rebaseTask(ctx, s, task)
×
93
                if err != nil {
×
94
                        // Check if it's a conflict
×
95
                        if errors.Is(err, ErrRebaseConflict) {
×
96
                                result.FailedTask = &FailedRebase{
×
97
                                        TaskID:       task.ID,
×
98
                                        Branch:       task.Branch,
×
99
                                        OntoBase:     r.getRebaseTarget(s, task),
×
100
                                        Error:        err,
×
101
                                        IsConflict:   true,
×
102
                                        ConflictHint: "Resolve conflicts manually or run 'mehr stack rebase --continue' after fixing",
×
103
                                }
×
104
                        } else {
×
105
                                result.FailedTask = &FailedRebase{
×
106
                                        TaskID:   task.ID,
×
107
                                        Branch:   task.Branch,
×
108
                                        OntoBase: r.getRebaseTarget(s, task),
×
109
                                        Error:    err,
×
110
                                }
×
111
                        }
×
112

113
                        // Try to restore original branch
114
                        _ = r.git.Checkout(ctx, originalBranch)
×
115

×
116
                        return result, err
×
117
                }
118

119
                if taskResult != nil {
×
120
                        result.RebasedTasks = append(result.RebasedTasks, *taskResult)
×
121
                }
×
122
        }
123

124
        // Restore original branch
125
        if err := r.git.Checkout(ctx, originalBranch); err != nil {
1✔
126
                return result, fmt.Errorf("restore original branch %s: %w", originalBranch, err)
×
127
        }
×
128

129
        // Save updated states
130
        if err := r.storage.Save(); err != nil {
1✔
131
                return result, fmt.Errorf("save stacks: %w", err)
×
132
        }
×
133

134
        return result, nil
1✔
135
}
136

137
// RebaseTask rebases a single task.
138
func (r *Rebaser) RebaseTask(ctx context.Context, taskID string) (*RebaseResult, error) {
2✔
139
        if err := r.storage.Load(); err != nil {
2✔
140
                return nil, fmt.Errorf("load stacks: %w", err)
×
141
        }
×
142

143
        s := r.storage.GetStackByTask(taskID)
2✔
144
        if s == nil {
3✔
145
                return nil, fmt.Errorf("task not in any stack: %s", taskID)
1✔
146
        }
1✔
147

148
        task := s.GetTask(taskID)
1✔
149
        if task == nil {
1✔
150
                return nil, fmt.Errorf("task not found: %s", taskID)
×
151
        }
×
152

153
        if task.State != StateNeedsRebase {
2✔
154
                return nil, fmt.Errorf("task %s does not need rebasing (state: %s)", taskID, task.State)
1✔
155
        }
1✔
156

157
        // Get current branch to restore later
158
        originalBranch, err := r.git.CurrentBranch(ctx)
×
159
        if err != nil {
×
160
                return nil, fmt.Errorf("get current branch: %w", err)
×
161
        }
×
162

163
        result := &RebaseResult{
×
164
                RebasedTasks:   make([]RebaseTaskResult, 0),
×
165
                SkippedTasks:   make([]SkippedTask, 0),
×
166
                OriginalBranch: originalBranch,
×
167
        }
×
168

×
169
        taskResult, err := r.rebaseTask(ctx, s, *task)
×
170
        if err != nil {
×
171
                if errors.Is(err, ErrRebaseConflict) {
×
172
                        result.FailedTask = &FailedRebase{
×
173
                                TaskID:       task.ID,
×
174
                                Branch:       task.Branch,
×
175
                                OntoBase:     r.getRebaseTarget(s, *task),
×
176
                                Error:        err,
×
177
                                IsConflict:   true,
×
178
                                ConflictHint: "Resolve conflicts manually, then run 'mehr stack rebase --continue'",
×
179
                        }
×
180
                } else {
×
181
                        result.FailedTask = &FailedRebase{
×
182
                                TaskID:   task.ID,
×
183
                                Branch:   task.Branch,
×
184
                                OntoBase: r.getRebaseTarget(s, *task),
×
185
                                Error:    err,
×
186
                        }
×
187
                }
×
188
                // Try to restore original branch
189
                _ = r.git.Checkout(ctx, originalBranch)
×
190

×
191
                return result, err
×
192
        }
193

194
        if taskResult != nil {
×
195
                result.RebasedTasks = append(result.RebasedTasks, *taskResult)
×
196
        }
×
197

198
        // Restore original branch
199
        if err := r.git.Checkout(ctx, originalBranch); err != nil {
×
200
                return result, fmt.Errorf("restore original branch %s: %w", originalBranch, err)
×
201
        }
×
202

203
        // Save updated states
204
        if err := r.storage.Save(); err != nil {
×
205
                return result, fmt.Errorf("save stacks: %w", err)
×
206
        }
×
207

208
        return result, nil
×
209
}
210

211
// rebaseTask performs the actual rebase for a single task.
212
// Returns nil result (without error) if task doesn't need rebasing.
213
func (r *Rebaser) rebaseTask(ctx context.Context, s *Stack, task StackedTask) (*RebaseTaskResult, error) {
×
214
        // Skip tasks that don't need rebasing
×
215
        if task.State != StateNeedsRebase {
×
216
                return nil, nil //nolint:nilnil // Intentional: nil result means task was skipped (not an error)
×
217
        }
×
218

219
        // Verify branch exists
220
        if !r.git.BranchExists(ctx, task.Branch) {
×
221
                return nil, fmt.Errorf("branch %s does not exist", task.Branch)
×
222
        }
×
223

224
        // Determine rebase target
225
        target := r.getRebaseTarget(s, task)
×
226
        if target == "" {
×
227
                return nil, fmt.Errorf("cannot determine rebase target for task %s", task.ID)
×
228
        }
×
229

230
        // Switch to task branch
231
        if err := r.git.Checkout(ctx, task.Branch); err != nil {
×
232
                return nil, fmt.Errorf("switch to branch %s: %w", task.Branch, err)
×
233
        }
×
234

235
        start := time.Now()
×
236

×
237
        // Get old base for reporting
×
238
        oldBase := task.BaseBranch
×
239
        if oldBase == "" && task.DependsOn != "" {
×
240
                if parentTask := s.GetTask(task.DependsOn); parentTask != nil {
×
241
                        oldBase = parentTask.Branch
×
242
                }
×
243
        }
244

245
        // Perform rebase
246
        if err := r.git.RebaseBranch(ctx, target); err != nil {
×
247
                // Abort the rebase to leave clean state
×
248
                _ = r.git.AbortRebase(ctx)
×
249

×
250
                return nil, fmt.Errorf("%w: rebasing %s onto %s: %w", ErrRebaseConflict, task.Branch, target, err)
×
251
        }
×
252

253
        duration := time.Since(start)
×
254

×
255
        // Update task state
×
256
        taskPtr := s.GetTask(task.ID)
×
257
        if taskPtr != nil {
×
258
                taskPtr.State = StateActive
×
259
                taskPtr.UpdatedAt = time.Now()
×
260
                // Update base branch to reflect new base
×
261
                taskPtr.BaseBranch = target
×
262
        }
×
263

264
        return &RebaseTaskResult{
×
265
                TaskID:   task.ID,
×
266
                Branch:   task.Branch,
×
267
                OldBase:  oldBase,
×
268
                NewBase:  target,
×
269
                Rebased:  true,
×
270
                Duration: duration,
×
271
        }, nil
×
272
}
273

274
// getTasksInRebaseOrder returns tasks that need rebasing in dependency order.
275
// Parents are returned before children to ensure valid rebase targets.
276
func (r *Rebaser) getTasksInRebaseOrder(s *Stack) []StackedTask {
6✔
277
        needsRebase := s.GetTasksNeedingRebase()
6✔
278
        if len(needsRebase) == 0 {
8✔
279
                return nil
2✔
280
        }
2✔
281

282
        // Build map for quick lookup
283
        taskMap := make(map[string]StackedTask)
4✔
284
        for _, t := range needsRebase {
9✔
285
                taskMap[t.ID] = t
5✔
286
        }
5✔
287

288
        // Simple topological sort
289
        var ordered []StackedTask
4✔
290
        visited := make(map[string]bool)
4✔
291

4✔
292
        var visit func(taskID string)
4✔
293
        visit = func(taskID string) {
10✔
294
                if visited[taskID] {
7✔
295
                        return
1✔
296
                }
1✔
297
                visited[taskID] = true
5✔
298

5✔
299
                task, ok := taskMap[taskID]
5✔
300
                if !ok {
5✔
301
                        return
×
302
                }
×
303

304
                // Visit parent first if it also needs rebasing
305
                if task.DependsOn != "" {
7✔
306
                        if _, parentNeedsRebase := taskMap[task.DependsOn]; parentNeedsRebase {
3✔
307
                                visit(task.DependsOn)
1✔
308
                        }
1✔
309
                }
310

311
                ordered = append(ordered, task)
5✔
312
        }
313

314
        for taskID := range taskMap {
9✔
315
                visit(taskID)
5✔
316
        }
5✔
317

318
        return ordered
4✔
319
}
320

321
// getRebaseTarget determines what branch a task should be rebased onto.
322
func (r *Rebaser) getRebaseTarget(s *Stack, task StackedTask) string {
5✔
323
        // Get the target branch from root task's base branch
5✔
324
        targetBranch := r.getStackTargetBranch(s)
5✔
325

5✔
326
        // If task depends on another task, rebase onto that task's new base
5✔
327
        if task.DependsOn != "" {
7✔
328
                parentTask := s.GetTask(task.DependsOn)
2✔
329
                if parentTask != nil {
4✔
330
                        // If parent is merged, rebase onto the target branch (e.g., main)
2✔
331
                        if parentTask.State == StateMerged {
3✔
332
                                return targetBranch
1✔
333
                        }
1✔
334
                        // Otherwise rebase onto parent's branch
335
                        return parentTask.Branch
1✔
336
                }
337
        }
338

339
        // Root task rebases onto target branch
340
        return targetBranch
3✔
341
}
342

343
// getStackTargetBranch returns the target branch for the stack.
344
// This is the base branch of the root task (e.g., "main", "master").
345
func (r *Rebaser) getStackTargetBranch(s *Stack) string {
5✔
346
        // Find root task and get its base branch
5✔
347
        rootTask := s.GetTask(s.RootTask)
5✔
348
        if rootTask != nil && rootTask.BaseBranch != "" {
10✔
349
                return rootTask.BaseBranch
5✔
350
        }
5✔
351

352
        // Fallback: find first task with a base branch
353
        for _, task := range s.Tasks {
×
354
                if task.BaseBranch != "" {
×
355
                        return task.BaseBranch
×
356
                }
×
357
        }
358

359
        // Default fallback
360
        return "main"
×
361
}
362

363
// RebasePreview contains the result of previewing a rebase operation.
364
// Preview checks for conflicts without actually executing the rebase.
365
type RebasePreview struct {
366
        Tasks             []TaskPreview // All tasks in rebase order with conflict status
367
        HasConflicts      bool          // True if any task would have conflicts
368
        SafeCount         int           // Number of tasks that can be safely rebased
369
        ConflictCount     int           // Number of tasks with conflicts
370
        Unavailable       bool          // True if conflict detection is unavailable (Git too old)
371
        UnavailableReason string        // Reason why conflict detection is unavailable
372
}
373

374
// TaskPreview contains conflict preview information for a single task.
375
type TaskPreview struct {
376
        TaskID           string   // Task identifier
377
        Branch           string   // Branch name
378
        OntoBase         string   // Target branch for rebase
379
        WouldConflict    bool     // True if rebase would result in conflicts
380
        ConflictingFiles []string // Files that would have conflicts
381
        Unavailable      bool     // True if conflict check unavailable for this task
382
}
383

384
// PreviewRebase checks all tasks in the stack for potential conflicts without executing.
385
// Returns a preview showing which tasks can be safely rebased and which have conflicts.
386
func (r *Rebaser) PreviewRebase(ctx context.Context, stackID string) (*RebasePreview, error) {
3✔
387
        if err := r.storage.Load(); err != nil {
3✔
388
                return nil, fmt.Errorf("load stacks: %w", err)
×
389
        }
×
390

391
        s := r.storage.GetStack(stackID)
3✔
392
        if s == nil {
3✔
393
                return nil, fmt.Errorf("stack not found: %s", stackID)
×
394
        }
×
395

396
        preview := &RebasePreview{
3✔
397
                Tasks: make([]TaskPreview, 0),
3✔
398
        }
3✔
399

3✔
400
        // Get tasks in dependency order (parents first)
3✔
401
        tasksToRebase := r.getTasksInRebaseOrder(s)
3✔
402
        if len(tasksToRebase) == 0 {
4✔
403
                return preview, nil
1✔
404
        }
1✔
405

406
        for _, task := range tasksToRebase {
4✔
407
                taskPreview, err := r.previewTask(ctx, s, task)
2✔
408
                if err != nil {
2✔
409
                        return nil, fmt.Errorf("preview task %s: %w", task.ID, err)
×
410
                }
×
411

412
                preview.Tasks = append(preview.Tasks, *taskPreview)
2✔
413

2✔
414
                if taskPreview.Unavailable {
2✔
415
                        preview.Unavailable = true
×
416
                        if preview.UnavailableReason == "" {
×
417
                                preview.UnavailableReason = "conflict detection unavailable for some tasks"
×
418
                        }
×
419
                } else if taskPreview.WouldConflict {
3✔
420
                        preview.HasConflicts = true
1✔
421
                        preview.ConflictCount++
1✔
422
                } else {
2✔
423
                        preview.SafeCount++
1✔
424
                }
1✔
425
        }
426

427
        return preview, nil
2✔
428
}
429

430
// PreviewTask checks a single task for potential conflicts.
431
func (r *Rebaser) PreviewTask(ctx context.Context, taskID string) (*TaskPreview, error) {
×
432
        if err := r.storage.Load(); err != nil {
×
433
                return nil, fmt.Errorf("load stacks: %w", err)
×
434
        }
×
435

436
        s := r.storage.GetStackByTask(taskID)
×
437
        if s == nil {
×
438
                return nil, fmt.Errorf("task not in any stack: %s", taskID)
×
439
        }
×
440

441
        task := s.GetTask(taskID)
×
442
        if task == nil {
×
443
                return nil, fmt.Errorf("task not found: %s", taskID)
×
444
        }
×
445

446
        return r.previewTask(ctx, s, *task)
×
447
}
448

449
// previewTask checks a single task for potential rebase conflicts.
450
func (r *Rebaser) previewTask(ctx context.Context, s *Stack, task StackedTask) (*TaskPreview, error) {
2✔
451
        // Determine rebase target
2✔
452
        target := r.getRebaseTarget(s, task)
2✔
453
        if target == "" {
2✔
454
                return nil, fmt.Errorf("cannot determine rebase target for task %s", task.ID)
×
455
        }
×
456

457
        preview := &TaskPreview{
2✔
458
                TaskID:   task.ID,
2✔
459
                Branch:   task.Branch,
2✔
460
                OntoBase: target,
2✔
461
        }
2✔
462

2✔
463
        // Verify branch exists
2✔
464
        if !r.git.BranchExists(ctx, task.Branch) {
2✔
465
                return nil, fmt.Errorf("branch %s does not exist", task.Branch)
×
466
        }
×
467

468
        // Check for conflicts using merge-tree (doesn't modify working directory)
469
        conflictInfo, err := r.git.CheckRebaseConflicts(ctx, task.Branch, target)
2✔
470
        if err != nil {
2✔
471
                return nil, fmt.Errorf("check conflicts: %w", err)
×
472
        }
×
473

474
        if conflictInfo.Unavailable {
2✔
475
                preview.Unavailable = true
×
476

×
477
                return preview, nil
×
478
        }
×
479

480
        preview.WouldConflict = conflictInfo.HasConflicts
2✔
481
        preview.ConflictingFiles = conflictInfo.ConflictingFiles
2✔
482

2✔
483
        return preview, nil
2✔
484
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc