fix(client): three-way merge when a file changed on both sides

This commit is contained in:
hh committed 2026-10-02 23:31:16 +00:00
1 parent e89cf7864a
commit 30cd772f08
3 files changed
+123 -2

No files matched your search

+55 -2
View File
@@ -1,6 +1,7 @@
package client
import (
"bytes"
"cmp"
"context"
"encoding/json"
@@ -9,10 +10,12 @@ import (
"maps"
"net/http"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"syscall"
"unicode/utf8"
"lodge/internal/gitrepo"
"lodge/internal/protocol"
@@ -176,7 +179,9 @@ func (c *Client) scan(repo *gitrepo.Repo, rules Rules) diskFiles {
return found
}
func reconcile(space string, local, base, remote protocol.Manifest, relevant func(string) bool) (first, second []protocol.Change, pull, next protocol.Manifest) {
type mergeFunc func(local, base, remote protocol.File) (protocol.File, bool)
func reconcile(space string, local, base, remote protocol.Manifest, relevant func(string) bool, merge mergeFunc) (first, second []protocol.Change, pull, next protocol.Manifest) {
pull, next = protocol.Manifest{}, maps.Clone(base)
if next == nil {
next = protocol.Manifest{}
@@ -192,6 +197,14 @@ func reconcile(space string, local, base, remote protocol.Manifest, relevant fun
continue
}
l, b, r := local[path], base[path], remote[path]
if l.Hash != b.Hash && r.Hash != b.Hash && l.Hash != r.Hash {
if merged, ok := merge(l, b, r); ok {
if merged.Hash != l.Hash {
pull[path] = merged
}
l, b = merged, r
}
}
mine := protocol.Change{Space: space, Path: path, Base: r.Hash, Hash: l.Hash}
settled := r
switch {
@@ -215,6 +228,43 @@ func reconcile(space string, local, base, remote protocol.Manifest, relevant fun
return first, second, pull, next
}
func (c *Client) merge(ctx context.Context, local, base, remote protocol.File) (protocol.File, bool) {
names := []string{"local", "base", "remote"}
versions := make([][]byte, len(names))
for i, file := range []protocol.File{local, base, remote} {
if file.Hash == "" {
return protocol.File{}, false
}
data, err := c.Blob(ctx, file.Hash)
if err != nil || !isText(data) {
return protocol.File{}, false
}
versions[i] = data
}
dir, err := os.MkdirTemp(c.DataDir, "merge-")
if err != nil {
return protocol.File{}, false
}
defer os.RemoveAll(dir)
for i, name := range names {
if err := os.WriteFile(filepath.Join(dir, name), versions[i], 0o600); err != nil {
return protocol.File{}, false
}
}
cmd := exec.CommandContext(ctx, "git", append([]string{"merge-file", "-p"}, names...)...)
cmd.Dir = dir
merged, err := cmd.Output()
if err != nil || len(merged) > protocol.MaxBlobSize {
return protocol.File{}, false
}
hash, err := c.cacheBlob(merged)
return protocol.File{Hash: hash, Time: max(local.Time, remote.Time)}, err == nil
}
func isText(data []byte) bool {
return len(data) <= protocol.MaxBlobSize && utf8.Valid(data) && bytes.IndexByte(data, 0) == -1
}
func (c *Client) pass(ctx context.Context, repo *gitrepo.Repo, st *worktreeState, online bool, res *Result) (bool, error) {
rules := LoadRules(repo.Root)
if err := writeExclude(repo, rules); err != nil {
@@ -261,6 +311,9 @@ func (c *Client) pass(ctx context.Context, repo *gitrepo.Repo, st *worktreeState
var first, second []protocol.Change
pulls, bases := map[string]protocol.Manifest{}, map[string]protocol.Manifest{}
spaces := map[string]bool{}
merge := func(l, b, r protocol.File) (protocol.File, bool) {
return c.merge(ctx, l, b, r)
}
for _, group := range []map[string]protocol.Manifest{locals, st.Base, remote.Spaces} {
for space := range group {
spaces[space] = true
@@ -283,7 +336,7 @@ func (c *Client) pass(ctx context.Context, repo *gitrepo.Repo, st *worktreeState
return protocol.SafePath(path) && rules.covers(path, space == protocol.SharedSpace) && !found.tracked[path]
}
}
mine, theirs, pull, next := reconcile(space, local, st.Base[space], remote.Spaces[space], relevant)
mine, theirs, pull, next := reconcile(space, local, st.Base[space], remote.Spaces[space], relevant, merge)
first, second, pulls[space], bases[space] = append(first, mine...), append(second, theirs...), pull, next
}