mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-12 00:45:22 +00:00
feat(apply): render+merge+write pipeline with state snapshots and .rej conflicts
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
// Package apply orchestrates adapter rendering, three-way merge, and writes.
|
||||
package apply
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/gitx"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/merge"
|
||||
)
|
||||
|
||||
// Options configures a Run call.
|
||||
type Options struct {
|
||||
Bundle *canonical.Bundle
|
||||
Repo *gitx.Repo
|
||||
Adapters []adapter.Adapter
|
||||
DryRun bool
|
||||
Force bool
|
||||
}
|
||||
|
||||
// Report summarises what Run did.
|
||||
type Report struct {
|
||||
Actions []Action
|
||||
Written int
|
||||
Skipped int
|
||||
Conflicts int
|
||||
}
|
||||
|
||||
// Action describes one file's outcome.
|
||||
type Action struct {
|
||||
Adapter string
|
||||
Dest string
|
||||
Kind string // "wrote" | "symlinked" | "skipped" | "conflict"
|
||||
Note string
|
||||
}
|
||||
|
||||
// Run renders all adapters and writes their FileSets to disk.
|
||||
// Conflicts are recorded as <dest>.rej files; processing continues past conflicts.
|
||||
func Run(opt Options) (*Report, error) {
|
||||
rep := &Report{}
|
||||
for _, ad := range opt.Adapters {
|
||||
files, err := ad.Render(opt.Bundle)
|
||||
if err != nil {
|
||||
return rep, fmt.Errorf("render %s: %w", ad.Name(), err)
|
||||
}
|
||||
var renderErr error
|
||||
files.ForEach(func(f adapter.File) {
|
||||
if renderErr != nil {
|
||||
return
|
||||
}
|
||||
renderErr = handle(opt, ad.Name(), f, rep)
|
||||
})
|
||||
if renderErr != nil {
|
||||
return rep, renderErr
|
||||
}
|
||||
}
|
||||
if !opt.DryRun && rep.Written > 0 && opt.Repo != nil {
|
||||
if err := opt.Repo.AddAll(); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
if err := opt.Repo.Commit(fmt.Sprintf("apply: %d files, %d conflicts", rep.Written, rep.Conflicts)); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
func handle(opt Options, adapterName string, f adapter.File, rep *Report) error {
|
||||
switch f.Kind {
|
||||
case adapter.RenderedFile:
|
||||
return handleRendered(opt, adapterName, f, rep)
|
||||
case adapter.SymlinkFile, adapter.SymlinkDir:
|
||||
return handleSymlink(opt, adapterName, f, rep)
|
||||
}
|
||||
return fmt.Errorf("unknown file kind for %s", f.Dest)
|
||||
}
|
||||
|
||||
func statePath(root, adapterName, dest string) string {
|
||||
return filepath.Join(root, "state", adapterName, dest)
|
||||
}
|
||||
|
||||
func handleRendered(opt Options, adapterName string, f adapter.File, rep *Report) error {
|
||||
sp := statePath(opt.Bundle.Root, adapterName, f.Dest)
|
||||
base, _ := os.ReadFile(sp)
|
||||
current, _ := os.ReadFile(f.Dest)
|
||||
|
||||
if string(current) == string(f.Content) {
|
||||
rep.Skipped++
|
||||
rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "skipped", Note: "already in sync"})
|
||||
return writeState(opt, sp, f.Content)
|
||||
}
|
||||
|
||||
if opt.Force || len(base) == 0 || string(current) == string(base) {
|
||||
if opt.DryRun {
|
||||
rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "wrote", Note: "would write"})
|
||||
return nil
|
||||
}
|
||||
if err := writeFile(f.Dest, f.Content, f.Mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeState(opt, sp, f.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
rep.Written++
|
||||
rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "wrote"})
|
||||
return nil
|
||||
}
|
||||
|
||||
res, err := merge.ThreeWay(merge.Inputs{Base: base, Ours: f.Content, Theirs: current})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Conflict {
|
||||
if opt.DryRun {
|
||||
rep.Conflicts++
|
||||
rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "conflict", Note: "would write .rej"})
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(f.Dest+".rej", res.Body, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
rep.Conflicts++
|
||||
rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "conflict", Note: "wrote .rej"})
|
||||
return nil
|
||||
}
|
||||
if opt.DryRun {
|
||||
rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "wrote", Note: "would merge"})
|
||||
return nil
|
||||
}
|
||||
if err := writeFile(f.Dest, res.Body, f.Mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeState(opt, sp, f.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
rep.Written++
|
||||
rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "wrote", Note: "merged"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleSymlink(opt Options, adapterName string, f adapter.File, rep *Report) error {
|
||||
existing, err := os.Readlink(f.Dest)
|
||||
if err == nil && existing == f.SymlinkTarget {
|
||||
rep.Skipped++
|
||||
rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "skipped", Note: "symlink already correct"})
|
||||
return nil
|
||||
}
|
||||
if opt.DryRun {
|
||||
rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "symlinked", Note: "would link"})
|
||||
return nil
|
||||
}
|
||||
if _, statErr := os.Lstat(f.Dest); statErr == nil {
|
||||
backup := filepath.Join(opt.Bundle.Root, "backups", adapterName, filepath.Base(f.Dest))
|
||||
if err := os.MkdirAll(filepath.Dir(backup), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if renameErr := os.Rename(f.Dest, backup); renameErr != nil && !errors.Is(renameErr, fs.ErrNotExist) {
|
||||
return renameErr
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(f.Dest), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(f.SymlinkTarget, f.Dest); err != nil {
|
||||
return err
|
||||
}
|
||||
rep.Written++
|
||||
rep.Actions = append(rep.Actions, Action{Adapter: adapterName, Dest: f.Dest, Kind: "symlinked"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeFile(dest string, body []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dest + ".tmp"
|
||||
if mode == 0 {
|
||||
mode = 0o644
|
||||
}
|
||||
if err := os.WriteFile(tmp, body, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dest)
|
||||
}
|
||||
|
||||
func writeState(opt Options, sp string, body []byte) error {
|
||||
if opt.DryRun {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(sp), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(sp, body, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/harness-sync/internal/adapter"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
||||
"github.com/lukaszraczylo/harness-sync/internal/gitx"
|
||||
)
|
||||
|
||||
type stubAdapter struct {
|
||||
name string
|
||||
files []adapter.File
|
||||
}
|
||||
|
||||
func (s *stubAdapter) Name() string { return s.name }
|
||||
func (s *stubAdapter) Detect() bool { return true }
|
||||
func (s *stubAdapter) Import(_ string) (*adapter.ImportResult, error) {
|
||||
return &adapter.ImportResult{}, nil
|
||||
}
|
||||
func (s *stubAdapter) Render(_ *canonical.Bundle) (*adapter.FileSet, error) {
|
||||
fs := adapter.NewFileSet()
|
||||
for _, f := range s.files {
|
||||
fs.Add(f)
|
||||
}
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
func TestApplyRenderedFileFreshWrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(t.TempDir(), "out.json")
|
||||
|
||||
require.NoError(t, initCanonical(root))
|
||||
|
||||
ad := &stubAdapter{
|
||||
name: "stub",
|
||||
files: []adapter.File{
|
||||
{Dest: target, Kind: adapter.RenderedFile, Content: []byte("{}\n")},
|
||||
},
|
||||
}
|
||||
rep, err := Run(Options{
|
||||
Bundle: &canonical.Bundle{Root: root},
|
||||
Adapters: []adapter.Adapter{ad},
|
||||
Repo: gitx.New(root),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, rep.Written)
|
||||
assert.Equal(t, 0, rep.Conflicts)
|
||||
|
||||
body, err := os.ReadFile(target)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "{}\n", string(body))
|
||||
|
||||
statePath := filepath.Join(root, "state", "stub", target)
|
||||
assert.FileExists(t, statePath)
|
||||
}
|
||||
|
||||
func TestApplyConflictWritesRej(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(t.TempDir(), "out.txt")
|
||||
|
||||
require.NoError(t, initCanonical(root))
|
||||
repo := gitx.New(root)
|
||||
|
||||
ad1 := &stubAdapter{
|
||||
name: "stub",
|
||||
files: []adapter.File{
|
||||
{Dest: target, Kind: adapter.RenderedFile, Content: []byte("base\n")},
|
||||
},
|
||||
}
|
||||
_, err := Run(Options{
|
||||
Bundle: &canonical.Bundle{Root: root},
|
||||
Adapters: []adapter.Adapter{ad1},
|
||||
Repo: repo,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, os.WriteFile(target, []byte("user-edit\n"), 0o600)) //nolint:gosec
|
||||
|
||||
ad2 := &stubAdapter{
|
||||
name: "stub",
|
||||
files: []adapter.File{
|
||||
{Dest: target, Kind: adapter.RenderedFile, Content: []byte("new\n")},
|
||||
},
|
||||
}
|
||||
rep, err := Run(Options{
|
||||
Bundle: &canonical.Bundle{Root: root},
|
||||
Adapters: []adapter.Adapter{ad2},
|
||||
Repo: repo,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, rep.Conflicts)
|
||||
assert.FileExists(t, target+".rej")
|
||||
}
|
||||
|
||||
func initCanonical(root string) error {
|
||||
if err := os.MkdirAll(filepath.Join(root, "state"), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
r := gitx.New(root)
|
||||
if err := r.Init(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.Configure("test", "[email protected]"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, ".gitkeep"), []byte{}, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.AddAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.Commit("init")
|
||||
}
|
||||
Reference in New Issue
Block a user