feat(dev): add safe git operations for AI agents (#71)

* feat(dev): add safe git operations for AI agents

Adds agent-safe commands to prevent common git mistakes:

- `core dev sync <file> --to="pattern"`: Sync files across repos
  - Auto-pulls before copying (safe sync)
  - Optional commit with --message
  - Optional push with --push
  - Dry-run mode with --dry-run

- `core dev apply --command="..."`: Run commands across repos
  - Execute shell commands in each repo
  - Execute scripts with --script
  - Optional commit/push after changes
  - Continue on error with --continue
  - Filter repos with --repos

Safety features:
- Never force push
- Auto-pull before push on rejection
- Report failures without stopping other repos
- Dry-run support for previewing changes

Closes #53

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(dev): address CodeRabbit review feedback

- Use errors.E() for consistent error handling in cmd_apply.go and cmd_file_sync.go
- Add path traversal validation to reject ".." in source paths
- Execute scripts directly to honor shebangs (not via sh)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-01 06:11:24 +00:00 committed by GitHub
parent b482189ce6
commit 92264f29f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 687 additions and 1 deletions

289
pkg/dev/cmd_apply.go Normal file
View file

@ -0,0 +1,289 @@
// cmd_apply.go implements safe command/script execution across repos for AI agents.
//
// Usage:
// core dev apply --command="sed -i 's/old/new/g' README.md"
// core dev apply --script="./scripts/update-version.sh"
// core dev apply --command="..." --commit --message="chore: update"
package dev
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/errors"
"github.com/host-uk/core/pkg/git"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/repos"
)
// Apply command flags
var (
applyCommand string
applyScript string
applyRepos string
applyCommit bool
applyMessage string
applyCoAuthor string
applyDryRun bool
applyPush bool
applyContinue bool // Continue on error
)
// addApplyCommand adds the 'apply' command to dev.
func addApplyCommand(parent *cli.Command) {
applyCmd := &cli.Command{
Use: "apply",
Short: i18n.T("cmd.dev.apply.short"),
Long: i18n.T("cmd.dev.apply.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runApply()
},
}
applyCmd.Flags().StringVar(&applyCommand, "command", "", i18n.T("cmd.dev.apply.flag.command"))
applyCmd.Flags().StringVar(&applyScript, "script", "", i18n.T("cmd.dev.apply.flag.script"))
applyCmd.Flags().StringVar(&applyRepos, "repos", "", i18n.T("cmd.dev.apply.flag.repos"))
applyCmd.Flags().BoolVar(&applyCommit, "commit", false, i18n.T("cmd.dev.apply.flag.commit"))
applyCmd.Flags().StringVarP(&applyMessage, "message", "m", "", i18n.T("cmd.dev.apply.flag.message"))
applyCmd.Flags().StringVar(&applyCoAuthor, "co-author", "", i18n.T("cmd.dev.apply.flag.co_author"))
applyCmd.Flags().BoolVar(&applyDryRun, "dry-run", false, i18n.T("cmd.dev.apply.flag.dry_run"))
applyCmd.Flags().BoolVar(&applyPush, "push", false, i18n.T("cmd.dev.apply.flag.push"))
applyCmd.Flags().BoolVar(&applyContinue, "continue", false, i18n.T("cmd.dev.apply.flag.continue"))
parent.AddCommand(applyCmd)
}
func runApply() error {
ctx := context.Background()
// Validate inputs
if applyCommand == "" && applyScript == "" {
return errors.E("dev.apply", i18n.T("cmd.dev.apply.error.no_command"), nil)
}
if applyCommand != "" && applyScript != "" {
return errors.E("dev.apply", i18n.T("cmd.dev.apply.error.both_command_script"), nil)
}
if applyCommit && applyMessage == "" {
return errors.E("dev.apply", i18n.T("cmd.dev.apply.error.commit_needs_message"), nil)
}
// Validate script exists
if applyScript != "" {
if _, err := os.Stat(applyScript); err != nil {
return errors.E("dev.apply", "script not found: "+applyScript, err)
}
}
// Get target repos
targetRepos, err := getApplyTargetRepos()
if err != nil {
return err
}
if len(targetRepos) == 0 {
return errors.E("dev.apply", i18n.T("cmd.dev.apply.error.no_repos"), nil)
}
// Show plan
action := applyCommand
if applyScript != "" {
action = applyScript
}
cli.Print("%s: %s\n", dimStyle.Render(i18n.T("cmd.dev.apply.action")), action)
cli.Print("%s: %d repos\n", dimStyle.Render(i18n.T("cmd.dev.apply.targets")), len(targetRepos))
if applyDryRun {
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.apply.dry_run_mode")))
}
cli.Blank()
var succeeded, skipped, failed int
for _, repo := range targetRepos {
repoName := filepath.Base(repo.Path)
if applyDryRun {
cli.Print(" %s %s\n", dimStyle.Render("[dry-run]"), repoName)
succeeded++
continue
}
// Step 1: Run command or script
var cmdErr error
if applyCommand != "" {
cmdErr = runCommandInRepo(ctx, repo.Path, applyCommand)
} else {
cmdErr = runScriptInRepo(ctx, repo.Path, applyScript)
}
if cmdErr != nil {
cli.Print(" %s %s: %s\n", errorStyle.Render("x"), repoName, cmdErr)
failed++
if !applyContinue {
return cli.Err("%s", i18n.T("cmd.dev.apply.error.command_failed"))
}
continue
}
// Step 2: Check if anything changed
statuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repo.Path},
Names: map[string]string{repo.Path: repoName},
})
if len(statuses) == 0 || !statuses[0].IsDirty() {
cli.Print(" %s %s: %s\n", dimStyle.Render("-"), repoName, i18n.T("cmd.dev.apply.no_changes"))
skipped++
continue
}
// Step 3: Commit if requested
if applyCommit {
commitMsg := applyMessage
if applyCoAuthor != "" {
commitMsg += "\n\nCo-Authored-By: " + applyCoAuthor
}
// Stage all changes
if _, err := gitCommandQuiet(ctx, repo.Path, "add", "-A"); err != nil {
cli.Print(" %s %s: stage failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
if !applyContinue {
return err
}
continue
}
// Commit
if _, err := gitCommandQuiet(ctx, repo.Path, "commit", "-m", commitMsg); err != nil {
cli.Print(" %s %s: commit failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
if !applyContinue {
return err
}
continue
}
// Step 4: Push if requested
if applyPush {
if err := safePush(ctx, repo.Path); err != nil {
cli.Print(" %s %s: push failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
if !applyContinue {
return err
}
continue
}
}
}
cli.Print(" %s %s\n", successStyle.Render("v"), repoName)
succeeded++
}
// Summary
cli.Blank()
cli.Print("%s: ", i18n.T("cmd.dev.apply.summary"))
if succeeded > 0 {
cli.Print("%s", successStyle.Render(i18n.T("common.count.succeeded", map[string]interface{}{"Count": succeeded})))
}
if skipped > 0 {
if succeeded > 0 {
cli.Print(", ")
}
cli.Print("%s", dimStyle.Render(i18n.T("common.count.skipped", map[string]interface{}{"Count": skipped})))
}
if failed > 0 {
if succeeded > 0 || skipped > 0 {
cli.Print(", ")
}
cli.Print("%s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
// getApplyTargetRepos gets repos to apply command to
func getApplyTargetRepos() ([]*repos.Repo, error) {
// Load registry
registryPath, err := repos.FindRegistry()
if err != nil {
return nil, errors.E("dev.apply", "failed to find registry", err)
}
registry, err := repos.LoadRegistry(registryPath)
if err != nil {
return nil, errors.E("dev.apply", "failed to load registry", err)
}
// If --repos specified, filter to those
if applyRepos != "" {
repoNames := strings.Split(applyRepos, ",")
nameSet := make(map[string]bool)
for _, name := range repoNames {
nameSet[strings.TrimSpace(name)] = true
}
var matched []*repos.Repo
for _, repo := range registry.Repos {
if nameSet[repo.Name] {
matched = append(matched, repo)
}
}
return matched, nil
}
// Return all repos as slice
var all []*repos.Repo
for _, repo := range registry.Repos {
all = append(all, repo)
}
return all, nil
}
// runCommandInRepo runs a shell command in a repo directory
func runCommandInRepo(ctx context.Context, repoPath, command string) error {
// Use shell to execute command
var cmd *exec.Cmd
if isWindows() {
cmd = exec.CommandContext(ctx, "cmd", "/C", command)
} else {
cmd = exec.CommandContext(ctx, "sh", "-c", command)
}
cmd.Dir = repoPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// runScriptInRepo runs a script in a repo directory
func runScriptInRepo(ctx context.Context, repoPath, scriptPath string) error {
// Get absolute path to script
absScript, err := filepath.Abs(scriptPath)
if err != nil {
return err
}
var cmd *exec.Cmd
if isWindows() {
cmd = exec.CommandContext(ctx, "cmd", "/C", absScript)
} else {
// Execute script directly to honor shebang
cmd = exec.CommandContext(ctx, absScript)
}
cmd.Dir = repoPath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// isWindows returns true if running on Windows
func isWindows() bool {
return os.PathSeparator == '\\'
}

View file

@ -75,6 +75,10 @@ func AddDevCommands(root *cli.Command) {
addPushCommand(devCmd)
addPullCommand(devCmd)
// Safe git operations for AI agents
addFileSyncCommand(devCmd)
addApplyCommand(devCmd)
// GitHub integration
addIssuesCommand(devCmd)
addReviewsCommand(devCmd)

350
pkg/dev/cmd_file_sync.go Normal file
View file

@ -0,0 +1,350 @@
// cmd_file_sync.go implements safe file synchronization across repos for AI agents.
//
// Usage:
// core dev sync workflow.yml --to="packages/core-*"
// core dev sync .github/workflows/ --to="packages/core-*" --message="feat: add CI"
// core dev sync config.yaml --to="packages/core-*" --dry-run
package dev
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/errors"
"github.com/host-uk/core/pkg/git"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/repos"
)
// File sync command flags
var (
fileSyncTo string
fileSyncMessage string
fileSyncCoAuthor string
fileSyncDryRun bool
fileSyncPush bool
)
// addFileSyncCommand adds the 'sync' command to dev for file syncing.
func addFileSyncCommand(parent *cli.Command) {
syncCmd := &cli.Command{
Use: "sync <file-or-dir>",
Short: i18n.T("cmd.dev.file_sync.short"),
Long: i18n.T("cmd.dev.file_sync.long"),
Args: cli.MinimumNArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
return runFileSync(args[0])
},
}
syncCmd.Flags().StringVar(&fileSyncTo, "to", "", i18n.T("cmd.dev.file_sync.flag.to"))
syncCmd.Flags().StringVarP(&fileSyncMessage, "message", "m", "", i18n.T("cmd.dev.file_sync.flag.message"))
syncCmd.Flags().StringVar(&fileSyncCoAuthor, "co-author", "", i18n.T("cmd.dev.file_sync.flag.co_author"))
syncCmd.Flags().BoolVar(&fileSyncDryRun, "dry-run", false, i18n.T("cmd.dev.file_sync.flag.dry_run"))
syncCmd.Flags().BoolVar(&fileSyncPush, "push", false, i18n.T("cmd.dev.file_sync.flag.push"))
_ = syncCmd.MarkFlagRequired("to")
parent.AddCommand(syncCmd)
}
func runFileSync(source string) error {
ctx := context.Background()
// Security: Reject path traversal attempts
if strings.Contains(source, "..") {
return errors.E("dev.sync", "path traversal not allowed", nil)
}
// Validate source exists
sourceInfo, err := os.Stat(source)
if err != nil {
return errors.E("dev.sync", i18n.T("cmd.dev.file_sync.error.source_not_found", map[string]interface{}{"Path": source}), err)
}
// Find target repos
targetRepos, err := resolveTargetRepos(fileSyncTo)
if err != nil {
return err
}
if len(targetRepos) == 0 {
return cli.Err("%s", i18n.T("cmd.dev.file_sync.error.no_targets"))
}
// Show plan
cli.Print("%s: %s\n", dimStyle.Render(i18n.T("cmd.dev.file_sync.source")), source)
cli.Print("%s: %d repos\n", dimStyle.Render(i18n.T("cmd.dev.file_sync.targets")), len(targetRepos))
if fileSyncDryRun {
cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.dev.file_sync.dry_run_mode")))
}
cli.Blank()
var succeeded, skipped, failed int
for _, repo := range targetRepos {
repoName := filepath.Base(repo.Path)
if fileSyncDryRun {
cli.Print(" %s %s\n", dimStyle.Render("[dry-run]"), repoName)
succeeded++
continue
}
// Step 1: Pull latest (safe sync)
if err := safePull(ctx, repo.Path); err != nil {
cli.Print(" %s %s: pull failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
// Step 2: Copy file(s)
destPath := filepath.Join(repo.Path, source)
if sourceInfo.IsDir() {
if err := copyDir(source, destPath); err != nil {
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
} else {
if err := copyFile(source, destPath); err != nil {
cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
}
// Step 3: Check if anything changed
statuses := git.Status(ctx, git.StatusOptions{
Paths: []string{repo.Path},
Names: map[string]string{repo.Path: repoName},
})
if len(statuses) == 0 || !statuses[0].IsDirty() {
cli.Print(" %s %s: %s\n", dimStyle.Render("-"), repoName, i18n.T("cmd.dev.file_sync.no_changes"))
skipped++
continue
}
// Step 4: Commit if message provided
if fileSyncMessage != "" {
commitMsg := fileSyncMessage
if fileSyncCoAuthor != "" {
commitMsg += "\n\nCo-Authored-By: " + fileSyncCoAuthor
}
if err := gitAddCommit(ctx, repo.Path, source, commitMsg); err != nil {
cli.Print(" %s %s: commit failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
// Step 5: Push if requested
if fileSyncPush {
if err := safePush(ctx, repo.Path); err != nil {
cli.Print(" %s %s: push failed: %s\n", errorStyle.Render("x"), repoName, err)
failed++
continue
}
}
}
cli.Print(" %s %s\n", successStyle.Render("v"), repoName)
succeeded++
}
// Summary
cli.Blank()
cli.Print("%s: ", i18n.T("cmd.dev.file_sync.summary"))
if succeeded > 0 {
cli.Print("%s", successStyle.Render(i18n.T("common.count.succeeded", map[string]interface{}{"Count": succeeded})))
}
if skipped > 0 {
if succeeded > 0 {
cli.Print(", ")
}
cli.Print("%s", dimStyle.Render(i18n.T("common.count.skipped", map[string]interface{}{"Count": skipped})))
}
if failed > 0 {
if succeeded > 0 || skipped > 0 {
cli.Print(", ")
}
cli.Print("%s", errorStyle.Render(i18n.T("common.count.failed", map[string]interface{}{"Count": failed})))
}
cli.Blank()
return nil
}
// resolveTargetRepos resolves the --to pattern to actual repos
func resolveTargetRepos(pattern string) ([]*repos.Repo, error) {
// Load registry
registryPath, err := repos.FindRegistry()
if err != nil {
return nil, errors.E("dev.sync", "failed to find registry", err)
}
registry, err := repos.LoadRegistry(registryPath)
if err != nil {
return nil, errors.E("dev.sync", "failed to load registry", err)
}
// Match pattern against repo names
var matched []*repos.Repo
for _, repo := range registry.Repos {
if matchGlob(repo.Name, pattern) || matchGlob(repo.Path, pattern) {
matched = append(matched, repo)
}
}
return matched, nil
}
// matchGlob performs simple glob matching with * wildcards
func matchGlob(s, pattern string) bool {
// Handle exact match
if s == pattern {
return true
}
// Handle * at end
if strings.HasSuffix(pattern, "*") {
prefix := strings.TrimSuffix(pattern, "*")
return strings.HasPrefix(s, prefix)
}
// Handle * at start
if strings.HasPrefix(pattern, "*") {
suffix := strings.TrimPrefix(pattern, "*")
return strings.HasSuffix(s, suffix)
}
// Handle * in middle
if strings.Contains(pattern, "*") {
parts := strings.SplitN(pattern, "*", 2)
return strings.HasPrefix(s, parts[0]) && strings.HasSuffix(s, parts[1])
}
return false
}
// safePull pulls with rebase, handling errors gracefully
func safePull(ctx context.Context, path string) error {
// Check if we have upstream
_, err := gitCommandQuiet(ctx, path, "rev-parse", "--abbrev-ref", "@{u}")
if err != nil {
// No upstream set, skip pull
return nil
}
return git.Pull(ctx, path)
}
// safePush pushes with automatic pull-rebase on rejection
func safePush(ctx context.Context, path string) error {
err := git.Push(ctx, path)
if err == nil {
return nil
}
// If non-fast-forward, try pull and push again
if git.IsNonFastForward(err) {
if pullErr := git.Pull(ctx, path); pullErr != nil {
return pullErr
}
return git.Push(ctx, path)
}
return err
}
// gitAddCommit stages and commits a file/directory
func gitAddCommit(ctx context.Context, repoPath, filePath, message string) error {
// Stage the file(s)
if _, err := gitCommandQuiet(ctx, repoPath, "add", filePath); err != nil {
return err
}
// Commit
_, err := gitCommandQuiet(ctx, repoPath, "commit", "-m", message)
return err
}
// gitCommandQuiet runs a git command without output
func gitCommandQuiet(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
return "", cli.Err("%s", strings.TrimSpace(string(output)))
}
return string(output), nil
}
// copyFile copies a single file
func copyFile(src, dst string) error {
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
srcInfo, err := srcFile.Stat()
if err != nil {
return err
}
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode())
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
// copyDir recursively copies a directory
func copyDir(src, dst string) error {
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
if err := copyFile(srcPath, dstPath); err != nil {
return err
}
}
}
return nil
}

View file

@ -185,7 +185,45 @@
"sync.short": "Synchronizes public service APIs with internal implementations",
"vm.short": "Dev environment commands",
"vm.not_installed": "dev environment not installed (run 'core dev install' first)",
"vm.not_running": "Dev environment is not running"
"vm.not_running": "Dev environment is not running",
"file_sync.short": "Sync files across repos (agent-safe)",
"file_sync.long": "Safely sync files or directories across multiple repositories with automatic pull/commit/push. Designed for AI agents to avoid common git pitfalls.",
"file_sync.flag.to": "Target repos pattern (e.g., packages/core-*)",
"file_sync.flag.message": "Commit message for the sync",
"file_sync.flag.co_author": "Co-author for commit (e.g., 'Name <email>')",
"file_sync.flag.dry_run": "Show what would be done without making changes",
"file_sync.flag.push": "Push after committing",
"file_sync.source": "Source",
"file_sync.targets": "Targets",
"file_sync.summary": "Summary",
"file_sync.no_changes": "no changes",
"file_sync.dry_run_mode": "(dry run)",
"file_sync.error.source_not_found": "Source not found: {{.Path}}",
"file_sync.error.no_targets": "No target repos matched the pattern",
"file_sync.error.no_registry": "No repos.yaml found",
"apply.short": "Run command or script across repos (agent-safe)",
"apply.long": "Run a command or script across multiple repositories with optional commit and push. Designed for AI agents to safely apply changes at scale.",
"apply.flag.command": "Shell command to run in each repo",
"apply.flag.script": "Script file to run in each repo",
"apply.flag.repos": "Comma-separated list of repo names (default: all)",
"apply.flag.commit": "Commit changes after running",
"apply.flag.message": "Commit message (required with --commit)",
"apply.flag.co_author": "Co-author for commit",
"apply.flag.dry_run": "Show what would be done without making changes",
"apply.flag.push": "Push after committing",
"apply.flag.continue": "Continue on error instead of stopping",
"apply.action": "Action",
"apply.targets": "Targets",
"apply.summary": "Summary",
"apply.no_changes": "no changes",
"apply.dry_run_mode": "(dry run)",
"apply.error.no_command": "Either --command or --script is required",
"apply.error.both_command_script": "Cannot use both --command and --script",
"apply.error.commit_needs_message": "--commit requires --message",
"apply.error.script_not_found": "Script not found: {{.Path}}",
"apply.error.no_repos": "No repos found",
"apply.error.no_registry": "No repos.yaml found",
"apply.error.command_failed": "Command failed (use --continue to skip failures)"
},
"docs": {
"short": "Documentation management",
@ -425,6 +463,11 @@
},
"hint": {
"fix_deps": "Update dependencies to fix vulnerabilities"
},
"count": {
"succeeded": "{{.Count}} succeeded",
"failed": "{{.Count}} failed",
"skipped": "{{.Count}} skipped"
}
},
"error": {