91 lines
3.2 KiB
Go
91 lines
3.2 KiB
Go
// Package hookutil holds small filesystem helpers shared by the agent hook
|
|
// installers (claude-code, codex, opencode). It centralizes the atomic-write
|
|
// primitive so every adapter writes hook config the same crash-safe way.
|
|
package hookutil
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// GitignoreSentinel marks a workspace .gitignore as AO-managed so
|
|
// EnsureWorkspaceGitignore can rewrite its own file idempotently while never
|
|
// touching a user- or repo-provided .gitignore at the same path.
|
|
const GitignoreSentinel = "# managed by agent-orchestrator: AO hook files stay out of git status"
|
|
|
|
// EnsureWorkspaceGitignore writes a self-ignoring .gitignore into dir covering
|
|
// the named AO-installed files. Hook files land in fresh session worktrees as
|
|
// untracked files, and `git worktree remove` (without --force) refuses on ANY
|
|
// untracked file — without this ignore, AO's own hook files would make every
|
|
// session workspace permanently undeletable. The patterns are anchored to dir
|
|
// and name only AO's files, so anything else an agent drops in the same
|
|
// directory still counts as dirt and keeps blocking teardown.
|
|
//
|
|
// A .gitignore at the same path that lacks the sentinel is left untouched and
|
|
// the install proceeds: the worktree then simply stays dirty and teardown
|
|
// preserves it, which is the safe degradation.
|
|
func EnsureWorkspaceGitignore(dir string, names ...string) error {
|
|
path := filepath.Join(dir, ".gitignore")
|
|
existing, err := os.ReadFile(path) //nolint:gosec // path built from caller-owned workspace dir
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
if err == nil && !strings.Contains(string(existing), GitignoreSentinel) {
|
|
return nil
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(GitignoreSentinel)
|
|
b.WriteString("\n/.gitignore\n")
|
|
for _, name := range names {
|
|
b.WriteString("/")
|
|
b.WriteString(filepath.ToSlash(name))
|
|
b.WriteString("\n")
|
|
}
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
return fmt.Errorf("create %s: %w", dir, err)
|
|
}
|
|
if err := AtomicWriteFile(path, []byte(b.String()), 0o600); err != nil {
|
|
return fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FileExists reports whether path names an existing regular file (not a
|
|
// directory). Adapters use it when probing well-known install locations for an
|
|
// agent binary.
|
|
func FileExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
// AtomicWriteFile writes data to path via a temp file in the same directory
|
|
// followed by a rename, so a crash or signal mid-write can't leave a truncated
|
|
// or empty file that the agent then fails to parse (silently disabling hooks).
|
|
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Chmod(perm); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmpName, path)
|
|
}
|