mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-12 00:45:22 +00:00
feat(adapter): registry with detect filtering and duplicate guard
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"}) })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package render
|
||||
|
||||
import "github.com/pelletier/go-toml/v2"
|
||||
|
||||
func TOML(v any) ([]byte, error) {
|
||||
return toml.Marshal(v)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package render
|
||||
|
||||
import "gopkg.in/yaml.v3"
|
||||
|
||||
func YAML(v any) ([]byte, error) {
|
||||
return yaml.Marshal(v)
|
||||
}
|
||||
Reference in New Issue
Block a user