Skip to content

Commit f3bfa22

Browse files
authored
[feat] Add conflict-check and merge-plan commands (#51)
* [chore] Add .worktrees/ to .gitignore * [feat] Add DiffNameOnly and MergeTree git operations Add thin wrapper functions for git diff --name-only (three-dot) and git merge-tree --write-tree to support conflict detection between branches. * [feat] Add conflict detection and merge planning package Add internal/conflict/ with pure functions for overlap detection (DetectOverlaps) and greedy merge ordering (PlanMergeOrder), plus parallel git wrappers (CollectDiffs, DryMergeAll) with semaphore-bounded concurrency. * [feat] Add conflict-check and merge-plan commands Add two new commands: - conflict-check: detects file overlaps between worktree branches with optional --dry-merge flag for git merge-tree simulation - merge-plan: recommends optimal merge order to minimize conflicts Includes unit tests, command-level tests, and E2E tests. Closes #41 * [test] Add unit tests for CollectDiffs and DryMergeAll Cover the parallel git wrapper functions with mock-based tests to bring internal/conflict/ coverage from 53% to 99.1% and overall project coverage above the 95% CI gate.
1 parent d119064 commit f3bfa22

15 files changed

Lines changed: 1774 additions & 10 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,7 @@ local.properties
4343
*local.properties
4444
*local.yml
4545
*local.xml
46-
*local.json
46+
*local.json
47+
48+
# Worktrees
49+
.worktrees/

cmd/conflict_check.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/lugassawan/rimba/internal/config"
8+
"github.com/lugassawan/rimba/internal/conflict"
9+
"github.com/lugassawan/rimba/internal/hint"
10+
"github.com/lugassawan/rimba/internal/operations"
11+
"github.com/lugassawan/rimba/internal/resolver"
12+
"github.com/lugassawan/rimba/internal/spinner"
13+
"github.com/lugassawan/rimba/internal/termcolor"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
const (
18+
flagDryMerge = "dry-merge"
19+
20+
hintDryMerge = "Simulate merges with git merge-tree (git 2.38+)"
21+
)
22+
23+
func init() {
24+
conflictCheckCmd.Flags().Bool(flagDryMerge, false, "simulate merges with git merge-tree (git 2.38+)")
25+
rootCmd.AddCommand(conflictCheckCmd)
26+
}
27+
28+
var conflictCheckCmd = &cobra.Command{
29+
Use: "conflict-check",
30+
Short: "Detect file overlaps between worktree branches",
31+
Long: "Scans all active worktrees and reports files modified in multiple branches, indicating potential merge conflicts.",
32+
RunE: func(cmd *cobra.Command, _ []string) error {
33+
cfg := config.FromContext(cmd.Context())
34+
35+
r := newRunner()
36+
37+
worktrees, err := listWorktreeInfos(r)
38+
if err != nil {
39+
return err
40+
}
41+
42+
prefixes := resolver.AllPrefixes()
43+
allTasks := operations.CollectTasks(worktrees, prefixes)
44+
eligible := operations.FilterEligible(worktrees, prefixes, cfg.DefaultSource, allTasks, true)
45+
46+
if len(eligible) == 0 {
47+
fmt.Fprintln(cmd.OutOrStdout(), "No active worktree branches found.")
48+
return nil
49+
}
50+
51+
hint.New(cmd, hintPainter(cmd)).
52+
Add(flagDryMerge, hintDryMerge).
53+
Show()
54+
55+
s := spinner.New(spinnerOpts(cmd))
56+
defer s.Stop()
57+
s.Start("Collecting file changes...")
58+
59+
diffs, err := conflict.CollectDiffs(r, cfg.DefaultSource, eligible)
60+
if err != nil {
61+
return err
62+
}
63+
64+
result := conflict.DetectOverlaps(diffs)
65+
66+
dryMerge, _ := cmd.Flags().GetBool(flagDryMerge)
67+
var dryResults []conflict.DryMergeResult
68+
if dryMerge {
69+
s.Update("Running dry merges...")
70+
dryResults, err = conflict.DryMergeAll(r, eligible)
71+
if err != nil {
72+
return err
73+
}
74+
}
75+
76+
s.Stop()
77+
78+
noColor, _ := cmd.Flags().GetBool(flagNoColor)
79+
p := termcolor.NewPainter(noColor)
80+
81+
if len(result.Overlaps) == 0 && !hasConflicts(dryResults) {
82+
fmt.Fprintln(cmd.OutOrStdout(), "No file overlaps found between active worktree branches.")
83+
return nil
84+
}
85+
86+
if len(result.Overlaps) > 0 {
87+
renderOverlapTable(cmd, p, result, prefixes)
88+
}
89+
90+
if dryMerge {
91+
renderDryMergeResults(cmd, p, dryResults, prefixes)
92+
}
93+
94+
fmt.Fprintf(cmd.OutOrStdout(), "\n%d file overlap(s) found across %d branches.\n",
95+
len(result.Overlaps), result.TotalBranches)
96+
97+
return nil
98+
},
99+
}
100+
101+
func renderOverlapTable(cmd *cobra.Command, p *termcolor.Painter, result *conflict.CheckResult, prefixes []string) {
102+
tbl := termcolor.NewTable(2)
103+
tbl.AddRow(
104+
p.Paint("FILE", termcolor.Bold),
105+
p.Paint("BRANCHES", termcolor.Bold),
106+
p.Paint("SEVERITY", termcolor.Bold),
107+
)
108+
109+
for _, o := range result.Overlaps {
110+
branchLabels := make([]string, len(o.Branches))
111+
for i, b := range o.Branches {
112+
task, prefix := resolver.TaskFromBranch(b, prefixes)
113+
if prefix != "" {
114+
branchLabels[i] = task
115+
} else {
116+
branchLabels[i] = b
117+
}
118+
}
119+
120+
sevLabel := conflict.SeverityLabel(o)
121+
var sevColor termcolor.Color
122+
if o.Severity == conflict.SeverityHigh {
123+
sevColor = termcolor.Red
124+
} else {
125+
sevColor = termcolor.Yellow
126+
}
127+
128+
tbl.AddRow(
129+
o.File,
130+
strings.Join(branchLabels, ", "),
131+
p.Paint(sevLabel, sevColor),
132+
)
133+
}
134+
135+
tbl.Render(cmd.OutOrStdout())
136+
}
137+
138+
func renderDryMergeResults(cmd *cobra.Command, p *termcolor.Painter, results []conflict.DryMergeResult, prefixes []string) {
139+
var conflicting []conflict.DryMergeResult
140+
for _, r := range results {
141+
if r.HasConflicts {
142+
conflicting = append(conflicting, r)
143+
}
144+
}
145+
146+
if len(conflicting) == 0 {
147+
return
148+
}
149+
150+
fmt.Fprintln(cmd.OutOrStdout())
151+
fmt.Fprintln(cmd.OutOrStdout(), p.Paint("Dry merge conflicts:", termcolor.Bold))
152+
153+
tbl := termcolor.NewTable(2)
154+
tbl.AddRow(
155+
p.Paint("BRANCH 1", termcolor.Bold),
156+
p.Paint("BRANCH 2", termcolor.Bold),
157+
p.Paint("CONFLICT FILES", termcolor.Bold),
158+
)
159+
160+
for _, r := range conflicting {
161+
task1, _ := resolver.TaskFromBranch(r.Branch1, prefixes)
162+
task2, _ := resolver.TaskFromBranch(r.Branch2, prefixes)
163+
files := p.Paint(strings.Join(r.ConflictFiles, ", "), termcolor.Red)
164+
tbl.AddRow(task1, task2, files)
165+
}
166+
167+
tbl.Render(cmd.OutOrStdout())
168+
}
169+
170+
func hasConflicts(results []conflict.DryMergeResult) bool {
171+
for _, r := range results {
172+
if r.HasConflicts {
173+
return true
174+
}
175+
}
176+
return false
177+
}

0 commit comments

Comments
 (0)