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:
parent
b482189ce6
commit
92264f29f4
4 changed files with 687 additions and 1 deletions
289
pkg/dev/cmd_apply.go
Normal file
289
pkg/dev/cmd_apply.go
Normal 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 == '\\'
|
||||
}
|
||||
|
|
@ -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
350
pkg/dev/cmd_file_sync.go
Normal 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
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue