mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-12 00:45:22 +00:00
Three robustness fixes for the apply pipeline:
1. Silent read errors on state/target files treated 'file absent' as
'file empty' and fast-forward-overwrote user edits. Add
common.ReadExistingFile that returns (nil,nil) only on
os.IsNotExist; other I/O errors propagate. Use it from apply.Run
and from every adapter's Render (claudecode, crush, kilo,
opencode, goose, zed).
2. apply.Run required callers to pre-construct *gitx.Repo; passing
nil silently skipped the commit. Run now auto-opens a repo from
Bundle.Root, gracefully no-ops when the root is not a git repo,
and uses a new gitx.HasStagedChanges() check to skip the no-op
'nothing to commit' that triggered the original silent-skip.
3. Each of 7 adapter packages re-implemented the same 25-line
Option/WithHome/New constructor. Hoist into adapter.Base +
adapter.BaseOption + NewBase. Each adapter embeds *Base and
reduces to a 9-line constructor. Public New(WithHome(h)) API
preserved; a.home becomes a.Home.
Tests:
- apply: 5 new (propagates-read-error, auto-opens-repo,
no-repo-skips-commit, skips-no-op-commit, commit-message-counts)
- gitx: 2 new (HasStagedChanges empty/after-add)
- adapter/common: 3 new (ReadExistingFile missing/content/perm)
- adapter: 4 new (NewBase default/WithHome/order/typed)
- existing adapter tests unchanged — verify a.Home still honoured.
236 lines
6.9 KiB
Go
236 lines
6.9 KiB
Go
// 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/adapter/common"
|
|
"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.
|
|
//
|
|
// When Options.Repo is nil the canonical repo is auto-opened from Bundle.Root.
|
|
// If that directory is not a git repository the commit step is silently
|
|
// skipped — useful for one-off renders (CI smoke tests, dry-runs) where
|
|
// initialising a repo would be more surprising than useful.
|
|
//
|
|
// IMPORTANT: harness-sync intentionally does NOT resolve ${VAR} references in
|
|
// canonical configs. Every supported harness performs its own env-var
|
|
// expansion at MCP launch / config-load time, so resolving here would only
|
|
// bake real secret values into the rendered target files AND the state/
|
|
// snapshots tracked by git — which is a security regression. Leave
|
|
// placeholders alone; the downstream harness resolves them at use time.
|
|
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 {
|
|
return rep, nil
|
|
}
|
|
repo := opt.Repo
|
|
if repo == nil {
|
|
repo = gitx.New(opt.Bundle.Root)
|
|
}
|
|
if !repo.IsRepo() {
|
|
return rep, nil
|
|
}
|
|
if err := repo.AddAll(); err != nil {
|
|
return rep, err
|
|
}
|
|
dirty, err := repo.HasStagedChanges()
|
|
if err != nil {
|
|
return rep, err
|
|
}
|
|
if !dirty {
|
|
// Nothing changed inside the canonical repo (writes were outside it,
|
|
// e.g. symlinks to temp dirs in tests). Skip the no-op commit.
|
|
return rep, nil
|
|
}
|
|
if err := 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, readErr := common.ReadExistingFile(sp)
|
|
if readErr != nil {
|
|
return fmt.Errorf("read state %s: %w", sp, readErr)
|
|
}
|
|
current, readErr := common.ReadExistingFile(f.Dest)
|
|
if readErr != nil {
|
|
return fmt.Errorf("read target %s: %w", f.Dest, readErr)
|
|
}
|
|
|
|
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 || f.NoMerge || 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)
|
|
}
|