Skip to content

Commit e0d3655

Browse files
committed
merge: code cleanup and add docs
1 parent 616f531 commit e0d3655

10 files changed

Lines changed: 1211 additions & 23 deletions

File tree

docs/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
| [stash.md](stash.md) | 暂存功能 - stash 命令详解,临时保存工作进度 |
3131
| [sparse-checkout.md](sparse-checkout.md) | 稀疏检出 - 按需检出指定目录 |
3232
| [pull-strategy.md](pull-strategy.md) | 拉取策略 - merge、rebase、fast-forward 策略详解 |
33+
| [merge.md](merge.md) | 三方合并 - merge 设计与实现、冲突检测、字符集处理 |
34+
| [merge-en.md](merge-en.md) | Three-Way Merge - design doc in English (for community sharing) |
3335

3436
### 高级特性
3537

docs/merge-en.md

Lines changed: 504 additions & 0 deletions
Large diffs are not rendered by default.

docs/merge.md

Lines changed: 497 additions & 0 deletions
Large diffs are not rendered by default.

pkg/zeta/merge_tree.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ func (r *Repository) resolveAncestorTree0(ctx context.Context, into, from *objec
144144
DetectRenames: true,
145145
Textconv: textconv,
146146
MergeDriver: mergeDriver,
147-
TextGetter: r.readMissingText,
147+
TextResolver: r.readMissingText,
148148
})
149149
if err != nil {
150150
return nil, err
@@ -223,7 +223,7 @@ func (r *Repository) mergeTree(ctx context.Context, into, from, base *object.Com
223223
DetectRenames: true,
224224
Textconv: textconv,
225225
MergeDriver: mergeDriver,
226-
TextGetter: r.readMissingText,
226+
TextResolver: r.readMissingText,
227227
})
228228
if err != nil {
229229
die_error("merge-tree: %v", err)

pkg/zeta/odb/merge.go

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ func (d *differences) nameConflicts() map[string]string {
284284
for j := i + 1; j < len(names); j++ {
285285
if strings.HasPrefix(names[j], prefix) {
286286
conflicts[names[i]] = names[j]
287+
break
287288
}
288289
}
289290
}
@@ -327,22 +328,15 @@ func (d *ODB) mergeDifferences(ctx context.Context, o, a, b *object.Tree) (*diff
327328
return ds, nil
328329
}
329330

330-
const (
331-
MERGE_VARIANT_NORMAL = 0
332-
MERGE_VARIANT_OURS = 1
333-
MERGE_VARIANT_THEIRS = 2
334-
)
335-
336331
type MergeOptions struct {
337332
Branch1 string
338333
Branch2 string
339334
DetectRenames bool
340335
RenameLimit int
341336
RenameScore int
342-
Variant int
343337
Textconv bool
344338
MergeDriver MergeDriver
345-
TextGetter TextGetter
339+
TextResolver TextResolver
346340
}
347341

348342
type MergeResult struct {
@@ -379,7 +373,7 @@ func (d *ODB) mergeEntry(ctx context.Context, ch *ChangeEntry, opts *MergeOption
379373
LabelB: ch.Path,
380374
Textconv: opts.Textconv,
381375
M: opts.MergeDriver,
382-
G: opts.TextGetter,
376+
G: opts.TextResolver,
383377
})
384378
if errors.Is(err, diferenco.ErrNonText) {
385379
result.Messages = append(result.Messages, tr.Sprintf("warning: Cannot merge binary files: %s (%s vs. %s)", ch.Path, opts.Branch1, opts.Branch2))
@@ -427,7 +421,7 @@ func (d *ODB) mergeEntry(ctx context.Context, ch *ChangeEntry, opts *MergeOption
427421
LabelB: ch.Path,
428422
Textconv: opts.Textconv,
429423
M: opts.MergeDriver,
430-
G: opts.TextGetter,
424+
G: opts.TextResolver,
431425
})
432426
if errors.Is(err, diferenco.ErrNonText) {
433427
result.Messages = append(result.Messages, tr.Sprintf("warning: Cannot merge binary files: %s (%s vs. %s)", ch.Path, opts.Branch1, opts.Branch2))
@@ -502,8 +496,8 @@ func (d *ODB) MergeTree(ctx context.Context, o, a, b *object.Tree, opts *MergeOp
502496
if opts.MergeDriver == nil {
503497
opts.MergeDriver = diferenco.DefaultMerge // fallback
504498
}
505-
if opts.TextGetter == nil {
506-
opts.TextGetter = d.unifiedText
499+
if opts.TextResolver == nil {
500+
opts.TextResolver = d.unifiedText
507501
}
508502
diffs, err := d.mergeDifferences(ctx, o, a, b)
509503
if err != nil {

pkg/zeta/odb/merge_driver.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ import (
1414
)
1515

1616
type MergeDriver func(ctx context.Context, o, a, b string, labelO, labelA, labelB string) (string, bool, error)
17-
type TextGetter func(ctx context.Context, oid plumbing.Hash, textconv bool) (string, string, error)
17+
type TextResolver func(ctx context.Context, oid plumbing.Hash, textconv bool) (string, string, error)
1818

1919
type mergeOptions struct {
2020
O, A, B plumbing.Hash
2121
LabelO, LabelA, LabelB string
2222
Textconv bool
2323
M MergeDriver
24-
G TextGetter
24+
G TextResolver
2525
}
2626

2727
type mergeTextResult struct {

pkg/zeta/odb/merge_review_test.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright ©️ Ant Group. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package odb
5+
6+
import (
7+
"testing"
8+
9+
"code.alipay.com/zeta/zeta/modules/plumbing"
10+
"code.alipay.com/zeta/zeta/modules/plumbing/filemode"
11+
"code.alipay.com/zeta/zeta/modules/zeta/object"
12+
)
13+
14+
// TestNameConflictsFirstMatch verifies that nameConflicts() records the first
15+
// matching path (sorted order) when a file name is a prefix of multiple paths.
16+
func TestNameConflictsFirstMatch(t *testing.T) {
17+
d := &differences{
18+
entries: map[string]*ChangeEntry{
19+
"a": {Path: "a", Our: &object.TreeEntry{Name: "a", Mode: filemode.Regular, Hash: plumbing.NewHash("1111111111111111111111111111111111111111111111111111111111111111")}},
20+
"a/b": {Path: "a/b", Their: &object.TreeEntry{Name: "b", Mode: filemode.Regular, Hash: plumbing.NewHash("2222222222222222222222222222222222222222222222222222222222222222")}},
21+
"a/c": {Path: "a/c", Their: &object.TreeEntry{Name: "c", Mode: filemode.Regular, Hash: plumbing.NewHash("3333333333333333333333333333333333333333333333333333333333333333")}},
22+
},
23+
renames: make(map[string]*RenameEntry),
24+
ours: map[string]bool{"a": true},
25+
theirs: map[string]bool{"a/b": true, "a/c": true},
26+
}
27+
28+
conflicts := d.nameConflicts()
29+
30+
if len(conflicts) != 1 {
31+
t.Fatalf("expected 1 conflict entry, got %d", len(conflicts))
32+
}
33+
34+
// After fix: records the first matching path in sorted order ("a/b" < "a/c").
35+
val, ok := conflicts["a"]
36+
if !ok {
37+
t.Fatal("expected conflict for key 'a'")
38+
}
39+
40+
if val != "a/b" {
41+
t.Errorf("expected conflict value 'a/b' (first sorted match), got %q", val)
42+
}
43+
}
44+
45+
// TestHasConflictCorrectness verifies that hasConflict() logic is correct.
46+
func TestHasConflictCorrectness(t *testing.T) {
47+
hash1 := plumbing.NewHash("1111111111111111111111111111111111111111111111111111111111111111")
48+
hash2 := plumbing.NewHash("2222222222222222222222222222222222222222222222222222222222222222")
49+
hash3 := plumbing.NewHash("3333333333333333333333333333333333333333333333333333333333333333")
50+
51+
tests := []struct {
52+
name string
53+
entry ChangeEntry
54+
expected bool
55+
}{
56+
{
57+
name: "ancestor==ours, theirs modified (no conflict - fast forward theirs)",
58+
entry: ChangeEntry{
59+
Path: "file.txt",
60+
Ancestor: &object.TreeEntry{Name: "file.txt", Hash: hash1, Mode: filemode.Regular},
61+
Our: &object.TreeEntry{Name: "file.txt", Hash: hash1, Mode: filemode.Regular},
62+
Their: &object.TreeEntry{Name: "file.txt", Hash: hash2, Mode: filemode.Regular},
63+
},
64+
expected: false,
65+
},
66+
{
67+
name: "ancestor==theirs, ours modified (no conflict - fast forward ours)",
68+
entry: ChangeEntry{
69+
Path: "file.txt",
70+
Ancestor: &object.TreeEntry{Name: "file.txt", Hash: hash1, Mode: filemode.Regular},
71+
Our: &object.TreeEntry{Name: "file.txt", Hash: hash2, Mode: filemode.Regular},
72+
Their: &object.TreeEntry{Name: "file.txt", Hash: hash1, Mode: filemode.Regular},
73+
},
74+
expected: false,
75+
},
76+
{
77+
name: "ours==theirs, both modified same way (no conflict)",
78+
entry: ChangeEntry{
79+
Path: "file.txt",
80+
Ancestor: &object.TreeEntry{Name: "file.txt", Hash: hash1, Mode: filemode.Regular},
81+
Our: &object.TreeEntry{Name: "file.txt", Hash: hash2, Mode: filemode.Regular},
82+
Their: &object.TreeEntry{Name: "file.txt", Hash: hash2, Mode: filemode.Regular},
83+
},
84+
expected: false,
85+
},
86+
{
87+
name: "all three different (conflict)",
88+
entry: ChangeEntry{
89+
Path: "file.txt",
90+
Ancestor: &object.TreeEntry{Name: "file.txt", Hash: hash1, Mode: filemode.Regular},
91+
Our: &object.TreeEntry{Name: "file.txt", Hash: hash2, Mode: filemode.Regular},
92+
Their: &object.TreeEntry{Name: "file.txt", Hash: hash3, Mode: filemode.Regular},
93+
},
94+
expected: true,
95+
},
96+
{
97+
name: "ours deleted (nil), theirs modified (conflict)",
98+
entry: ChangeEntry{
99+
Path: "file.txt",
100+
Ancestor: &object.TreeEntry{Name: "file.txt", Hash: hash1, Mode: filemode.Regular},
101+
Our: nil,
102+
Their: &object.TreeEntry{Name: "file.txt", Hash: hash2, Mode: filemode.Regular},
103+
},
104+
expected: true,
105+
},
106+
{
107+
name: "both deleted (nil) (no conflict - both agree)",
108+
entry: ChangeEntry{
109+
Path: "file.txt",
110+
Ancestor: &object.TreeEntry{Name: "file.txt", Hash: hash1, Mode: filemode.Regular},
111+
Our: nil,
112+
Their: nil,
113+
},
114+
expected: false,
115+
},
116+
}
117+
118+
for _, tt := range tests {
119+
t.Run(tt.name, func(t *testing.T) {
120+
got := tt.entry.hasConflict()
121+
if got != tt.expected {
122+
t.Errorf("hasConflict() = %v, want %v", got, tt.expected)
123+
}
124+
})
125+
}
126+
}
127+
128+
// TestNameConflictsBasic verifies nameConflicts() works for simple cases.
129+
func TestNameConflictsBasic(t *testing.T) {
130+
tests := []struct {
131+
name string
132+
entries map[string]*ChangeEntry
133+
expected map[string]string
134+
}{
135+
{
136+
name: "no conflicts",
137+
entries: map[string]*ChangeEntry{
138+
"a.txt": {Path: "a.txt"},
139+
"b.txt": {Path: "b.txt"},
140+
},
141+
expected: map[string]string{},
142+
},
143+
{
144+
name: "simple file/directory conflict",
145+
entries: map[string]*ChangeEntry{
146+
"a": {Path: "a"},
147+
"a/b": {Path: "a/b"},
148+
"c.txt": {Path: "c.txt"},
149+
},
150+
expected: map[string]string{"a": "a/b"},
151+
},
152+
{
153+
name: "multiple conflicts under same prefix (first match recorded)",
154+
entries: map[string]*ChangeEntry{
155+
"dir": {Path: "dir"},
156+
"dir/x": {Path: "dir/x"},
157+
"dir/y": {Path: "dir/y"},
158+
"dir/z": {Path: "dir/z"},
159+
"other": {Path: "other"},
160+
"other/w": {Path: "other/w"},
161+
},
162+
// After fix: records the first sorted match for each prefix
163+
expected: map[string]string{
164+
"dir": "dir/x", // first sorted match
165+
"other": "other/w", // only one match
166+
},
167+
},
168+
}
169+
170+
for _, tt := range tests {
171+
t.Run(tt.name, func(t *testing.T) {
172+
d := &differences{
173+
entries: tt.entries,
174+
renames: make(map[string]*RenameEntry),
175+
ours: make(map[string]bool),
176+
theirs: make(map[string]bool),
177+
}
178+
got := d.nameConflicts()
179+
if len(got) != len(tt.expected) {
180+
t.Errorf("nameConflicts() returned %d entries, want %d\n got: %v\n want: %v",
181+
len(got), len(tt.expected), got, tt.expected)
182+
return
183+
}
184+
for k, v := range tt.expected {
185+
if got[k] != v {
186+
t.Errorf("nameConflicts()[%q] = %q, want %q", k, got[k], v)
187+
}
188+
}
189+
})
190+
}
191+
}

pkg/zeta/worktree_rebase.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ func (w *Worktree) rebaseInternal(ctx context.Context, our, onto plumbing.Hash,
176176
DetectRenames: true,
177177
Textconv: textconv,
178178
MergeDriver: mergeDriver,
179-
TextGetter: w.readMissingText,
179+
TextResolver: w.readMissingText,
180180
})
181181
if err != nil {
182182
die_error("merge-tree: %v", err)
@@ -351,7 +351,7 @@ func (w *Worktree) rebaseWithUpstream(ctx context.Context, our, upstream, onto p
351351
DetectRenames: true,
352352
Textconv: textconv,
353353
MergeDriver: mergeDriver,
354-
TextGetter: w.readMissingText,
354+
TextResolver: w.readMissingText,
355355
})
356356
if err != nil {
357357
die_error("merge-tree: %v", err)
@@ -560,7 +560,7 @@ func (w *Worktree) rebaseContinue(ctx context.Context) error {
560560
DetectRenames: true,
561561
Textconv: false,
562562
MergeDriver: mergeDriver,
563-
TextGetter: w.readMissingText,
563+
TextResolver: w.readMissingText,
564564
})
565565
if err != nil {
566566
die_error("merge-tree: %v", err)

pkg/zeta/worktree_replay.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ func (w *Worktree) CherryPick(ctx context.Context, opts *CherryPickOptions) erro
131131
DetectRenames: true,
132132
Textconv: false,
133133
MergeDriver: w.resolveMergeDriver(),
134-
TextGetter: w.readMissingText,
134+
TextResolver: w.readMissingText,
135135
})
136136
if err != nil {
137137
die_error("merge-tree: %v", err)
@@ -334,7 +334,7 @@ func (w *Worktree) Revert(ctx context.Context, opts *RevertOptions) error {
334334
DetectRenames: true,
335335
Textconv: false,
336336
MergeDriver: w.resolveMergeDriver(),
337-
TextGetter: w.readMissingText,
337+
TextResolver: w.readMissingText,
338338
})
339339
if err != nil {
340340
die_error("merge-tree: %v", err)

pkg/zeta/worktree_stash.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ func (w *Worktree) cherryPickStash(ctx context.Context, stashIndex, stashWorktre
336336
DetectRenames: true,
337337
Textconv: false,
338338
MergeDriver: mergeDriver,
339-
TextGetter: w.readMissingText,
339+
TextResolver: w.readMissingText,
340340
})
341341
if err != nil {
342342
return nil, err
@@ -359,7 +359,7 @@ func (w *Worktree) cherryPickStash(ctx context.Context, stashIndex, stashWorktre
359359
DetectRenames: true,
360360
Textconv: false,
361361
MergeDriver: mergeDriver,
362-
TextGetter: w.readMissingText,
362+
TextResolver: w.readMissingText,
363363
})
364364
if err != nil {
365365
return nil, err

0 commit comments

Comments
 (0)