mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-16 01:07:50 +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
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
github.com/stretchr/testify v1.9.0
|
github.com/stretchr/testify v1.9.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package adapter
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,3 +24,37 @@ func TestKindString(t *testing.T) {
|
|||||||
assert.Equal(t, "rendered", RenderedFile.String())
|
assert.Equal(t, "rendered", RenderedFile.String())
|
||||||
assert.Equal(t, "symlink-file", SymlinkFile.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