From 95962bdf9a8da36c1cc78091fff92c71fb1adb3a Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 23 May 2026 02:48:31 +0100 Subject: [PATCH] feat(adapter): registry with detect filtering and duplicate guard --- go.mod | 1 + go.sum | 2 + internal/adapter/adapter_test.go | 35 +++++++++++++++ internal/adapter/registry.go | 60 ++++++++++++++++++++++++++ internal/gitx/repo.go | 74 ++++++++++++++++++++++++++++++++ internal/gitx/repo_test.go | 43 +++++++++++++++++++ internal/merge/merge.go | 72 +++++++++++++++++++++++++++++++ internal/merge/merge_test.go | 42 ++++++++++++++++++ internal/render/json.go | 18 ++++++++ internal/render/render_test.go | 28 ++++++++++++ internal/render/toml.go | 7 +++ internal/render/yaml.go | 7 +++ 12 files changed, 389 insertions(+) create mode 100644 internal/adapter/registry.go create mode 100644 internal/gitx/repo.go create mode 100644 internal/gitx/repo_test.go create mode 100644 internal/merge/merge.go create mode 100644 internal/merge/merge_test.go create mode 100644 internal/render/json.go create mode 100644 internal/render/render_test.go create mode 100644 internal/render/toml.go create mode 100644 internal/render/yaml.go diff --git a/go.mod b/go.mod index d354bb0..f684973 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/lukaszraczylo/harness-sync go 1.26.3 require ( + github.com/pelletier/go-toml/v2 v2.2.3 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 4353b61..8f2502e 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/adapter/adapter_test.go b/internal/adapter/adapter_test.go index 2f0d96e..feb1d53 100644 --- a/internal/adapter/adapter_test.go +++ b/internal/adapter/adapter_test.go @@ -3,6 +3,7 @@ package adapter import ( "testing" + "github.com/lukaszraczylo/harness-sync/internal/canonical" "github.com/stretchr/testify/assert" ) @@ -23,3 +24,37 @@ func TestKindString(t *testing.T) { assert.Equal(t, "rendered", RenderedFile.String()) assert.Equal(t, "symlink-file", SymlinkFile.String()) } + +type fakeAdapter struct { + name string + detect bool +} + +func (f *fakeAdapter) Name() string { return f.name } +func (f *fakeAdapter) Detect() bool { return f.detect } +func (f *fakeAdapter) Render(_ *canonical.Bundle) (*FileSet, error) { return NewFileSet(), nil } +func (f *fakeAdapter) Import(_ string) (*ImportResult, error) { return &ImportResult{}, nil } + +func TestRegistry(t *testing.T) { + r := NewRegistry() + a := &fakeAdapter{name: "a", detect: true} + b := &fakeAdapter{name: "b", detect: false} + r.Register(a) + r.Register(b) + + assert.Equal(t, []string{"a", "b"}, r.Names()) + assert.Equal(t, []string{"a"}, r.DetectedNames()) + + got, ok := r.Get("a") + assert.True(t, ok) + assert.Equal(t, "a", got.Name()) + + _, ok = r.Get("missing") + assert.False(t, ok) +} + +func TestRegistryDuplicatePanics(t *testing.T) { + r := NewRegistry() + r.Register(&fakeAdapter{name: "x"}) + assert.Panics(t, func() { r.Register(&fakeAdapter{name: "x"}) }) +} diff --git a/internal/adapter/registry.go b/internal/adapter/registry.go new file mode 100644 index 0000000..29e5b27 --- /dev/null +++ b/internal/adapter/registry.go @@ -0,0 +1,60 @@ +package adapter + +import "fmt" + +// Registry holds adapters in registration order. +type Registry struct { + byName map[string]Adapter + order []string +} + +// NewRegistry returns an empty Registry. +func NewRegistry() *Registry { + return &Registry{byName: map[string]Adapter{}} +} + +// Register adds a to the registry. Panics on duplicate name. +func (r *Registry) Register(a Adapter) { + if _, exists := r.byName[a.Name()]; exists { + panic(fmt.Sprintf("adapter %q already registered", a.Name())) + } + r.byName[a.Name()] = a + r.order = append(r.order, a.Name()) +} + +// Names returns adapter names in registration order. +func (r *Registry) Names() []string { + out := make([]string, len(r.order)) + copy(out, r.order) + return out +} + +// DetectedNames returns names of adapters whose Detect() returns true. +func (r *Registry) DetectedNames() []string { + var out []string + for _, n := range r.order { + if r.byName[n].Detect() { + out = append(out, n) + } + } + return out +} + +// Get returns the adapter for name and whether it was found. +func (r *Registry) Get(name string) (Adapter, bool) { + a, ok := r.byName[name] + return a, ok +} + +// All returns adapters in registration order. +func (r *Registry) All() []Adapter { + out := make([]Adapter, 0, len(r.order)) + for _, n := range r.order { + out = append(out, r.byName[n]) + } + return out +} + +// Default is the process-global registry used by main(). Tests should +// construct their own Registry via NewRegistry to avoid global state. +var Default = NewRegistry() diff --git a/internal/gitx/repo.go b/internal/gitx/repo.go new file mode 100644 index 0000000..d784a67 --- /dev/null +++ b/internal/gitx/repo.go @@ -0,0 +1,74 @@ +// Package gitx wraps git CLI invocations the canonical repo needs. +package gitx + +import ( + "bytes" + "fmt" + "os/exec" +) + +type Repo struct { + Dir string +} + +func New(dir string) *Repo { return &Repo{Dir: dir} } + +func (r *Repo) run(args ...string) ([]byte, error) { + cmd := exec.Command("git", args...) + cmd.Dir = r.Dir + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("git %v: %w: %s", args, err, stderr.String()) + } + return stdout.Bytes(), nil +} + +func (r *Repo) Init() error { + _, err := r.run("init", "-q", "-b", "main") + return err +} + +func (r *Repo) Configure(name, email string) error { + if _, err := r.run("config", "user.name", name); err != nil { + return err + } + _, err := r.run("config", "user.email", email) + return err +} + +func (r *Repo) AddAll() error { + _, err := r.run("add", "-A") + return err +} + +func (r *Repo) Add(paths ...string) error { + args := append([]string{"add", "--"}, paths...) + _, err := r.run(args...) + return err +} + +func (r *Repo) Commit(msg string) error { + _, err := r.run("commit", "-q", "-m", msg) + return err +} + +func (r *Repo) HeadCommit() (string, error) { + b, err := r.run("rev-parse", "HEAD") + return string(bytes.TrimSpace(b)), err +} + +func (r *Repo) ShowFileAtHead(path string) ([]byte, error) { + return r.run("show", "HEAD:"+path) +} + +func (r *Repo) IsRepo() bool { + _, err := r.run("rev-parse", "--git-dir") + return err == nil +} + +func (r *Repo) Revert(n int) error { + _, err := r.run("revert", "--no-edit", fmt.Sprintf("HEAD~%d..HEAD", n-1)) + return err +} diff --git a/internal/gitx/repo_test.go b/internal/gitx/repo_test.go new file mode 100644 index 0000000..3947c54 --- /dev/null +++ b/internal/gitx/repo_test.go @@ -0,0 +1,43 @@ +package gitx + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitAddCommit(t *testing.T) { + dir := t.TempDir() + r := New(dir) + + require.NoError(t, r.Init()) + require.NoError(t, r.Configure("nobody", "nobody@example.com")) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hi"), 0o600)) + require.NoError(t, r.AddAll()) + require.NoError(t, r.Commit("initial")) + + head, err := r.HeadCommit() + require.NoError(t, err) + assert.NotEmpty(t, head) +} + +func TestShowFileAtHead(t *testing.T) { + dir := t.TempDir() + r := New(dir) + require.NoError(t, r.Init()) + require.NoError(t, r.Configure("nobody", "nobody@example.com")) + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("v1"), 0o600)) + require.NoError(t, r.AddAll()) + require.NoError(t, r.Commit("v1")) + + body, err := r.ShowFileAtHead("a.txt") + require.NoError(t, err) + assert.Equal(t, "v1", string(body)) + + _, err = r.ShowFileAtHead("nope.txt") + assert.Error(t, err) +} diff --git a/internal/merge/merge.go b/internal/merge/merge.go new file mode 100644 index 0000000..07ac088 --- /dev/null +++ b/internal/merge/merge.go @@ -0,0 +1,72 @@ +// Package merge wraps git merge-file for three-way file merges. +package merge + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// Inputs: +// +// Base = last-rendered snapshot (state/...) +// Ours = new rendered output (what we'd write now) +// Theirs = current target file contents (what's on disk at the destination) +type Inputs struct { + Base []byte + Ours []byte + Theirs []byte +} + +type Result struct { + Body []byte + Conflict bool +} + +// ThreeWay runs `git merge-file -p ours base theirs` and returns the +// merged body. When Conflict is true, Body contains conflict markers. +func ThreeWay(in Inputs) (*Result, error) { + tmp, err := os.MkdirTemp("", "harness-sync-merge-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmp) + + oursPath := filepath.Join(tmp, "ours") + basePath := filepath.Join(tmp, "base") + theirsPath := filepath.Join(tmp, "theirs") + + if err := os.WriteFile(oursPath, in.Ours, 0o644); err != nil { + return nil, err + } + if err := os.WriteFile(basePath, in.Base, 0o644); err != nil { + return nil, err + } + if err := os.WriteFile(theirsPath, in.Theirs, 0o644); err != nil { + return nil, err + } + + cmd := exec.Command( + "git", "merge-file", + "-L", "harness-sync (new)", + "-L", "harness-sync (base)", + "-L", "harness-sync (current)", + "-p", + oursPath, basePath, theirsPath, + ) + out, runErr := cmd.Output() + + res := &Result{Body: out} + if runErr != nil { + if ee, ok := runErr.(*exec.ExitError); ok { + // git merge-file exits with conflict-count on conflict; body still valid + if ee.ExitCode() > 0 { + res.Conflict = true + return res, nil + } + } + return nil, fmt.Errorf("git merge-file: %w", runErr) + } + return res, nil +} diff --git a/internal/merge/merge_test.go b/internal/merge/merge_test.go new file mode 100644 index 0000000..21fbee8 --- /dev/null +++ b/internal/merge/merge_test.go @@ -0,0 +1,42 @@ +package merge + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCleanFastForward(t *testing.T) { + res, err := ThreeWay(Inputs{ + Base: []byte("a\nb\nc\n"), + Ours: []byte("a\nb\nc\nd\n"), + Theirs: []byte("a\nb\nc\n"), + }) + require.NoError(t, err) + assert.False(t, res.Conflict) + assert.Equal(t, "a\nb\nc\nd\n", string(res.Body)) +} + +func TestConflict(t *testing.T) { + res, err := ThreeWay(Inputs{ + Base: []byte("a\n"), + Ours: []byte("a\nours\n"), + Theirs: []byte("a\ntheirs\n"), + }) + require.NoError(t, err) + assert.True(t, res.Conflict) + assert.Contains(t, string(res.Body), "<<<<<<<") + assert.Contains(t, string(res.Body), ">>>>>>>") +} + +func TestNoOpWhenAllEqual(t *testing.T) { + res, err := ThreeWay(Inputs{ + Base: []byte("a\n"), + Ours: []byte("a\n"), + Theirs: []byte("a\n"), + }) + require.NoError(t, err) + assert.False(t, res.Conflict) + assert.Equal(t, "a\n", string(res.Body)) +} diff --git a/internal/render/json.go b/internal/render/json.go new file mode 100644 index 0000000..01b5fb8 --- /dev/null +++ b/internal/render/json.go @@ -0,0 +1,18 @@ +// Package render holds deterministic format marshallers. +package render + +import ( + "bytes" + "encoding/json" +) + +func JSON(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..8f2bc15 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,28 @@ +package render + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSONIndent(t *testing.T) { + b, err := JSON(map[string]any{"a": 1, "b": "x"}) + require.NoError(t, err) + s := string(b) + assert.Contains(t, s, "\"a\": 1") + assert.True(t, s[len(s)-1] == '\n', "trailing newline required") +} + +func TestYAML(t *testing.T) { + b, err := YAML(map[string]any{"a": 1}) + require.NoError(t, err) + assert.Contains(t, string(b), "a: 1") +} + +func TestTOML(t *testing.T) { + b, err := TOML(map[string]any{"a": 1}) + require.NoError(t, err) + assert.Contains(t, string(b), "a = 1") +} diff --git a/internal/render/toml.go b/internal/render/toml.go new file mode 100644 index 0000000..11f5028 --- /dev/null +++ b/internal/render/toml.go @@ -0,0 +1,7 @@ +package render + +import "github.com/pelletier/go-toml/v2" + +func TOML(v any) ([]byte, error) { + return toml.Marshal(v) +} diff --git a/internal/render/yaml.go b/internal/render/yaml.go new file mode 100644 index 0000000..58b545e --- /dev/null +++ b/internal/render/yaml.go @@ -0,0 +1,7 @@ +package render + +import "gopkg.in/yaml.v3" + +func YAML(v any) ([]byte, error) { + return yaml.Marshal(v) +}