diff --git a/internal/apply/apply.go b/internal/apply/apply.go new file mode 100644 index 0000000..68bda55 --- /dev/null +++ b/internal/apply/apply.go @@ -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 .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) +} diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go new file mode 100644 index 0000000..4b93b01 --- /dev/null +++ b/internal/apply/apply_test.go @@ -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", "test@example.com"); 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") +}