feat(adapter): registry with detect filtering and duplicate guard

This commit is contained in:
2026-05-23 02:48:31 +01:00
parent 6ab8d149e1
commit 95962bdf9a
12 changed files with 389 additions and 0 deletions
+1
View File
@@ -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
+2
View File
@@ -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=
+35
View File
@@ -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"}) })
}
+60
View File
@@ -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()
+74
View File
@@ -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
}
+43
View File
@@ -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", "[email protected]"))
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", "[email protected]"))
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)
}
+72
View File
@@ -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
}
+42
View File
@@ -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))
}
+18
View File
@@ -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
}
+28
View File
@@ -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")
}
+7
View File
@@ -0,0 +1,7 @@
package render
import "github.com/pelletier/go-toml/v2"
func TOML(v any) ([]byte, error) {
return toml.Marshal(v)
}
+7
View File
@@ -0,0 +1,7 @@
package render
import "gopkg.in/yaml.v3"
func YAML(v any) ([]byte, error) {
return yaml.Marshal(v)
}