// 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" "os/exec" "path/filepath" "strings" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/git" "github.com/host-uk/core/pkg/i18n" coreio "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/log" "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 ", 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 log.E("dev.sync", "path traversal not allowed", nil) } // Convert to absolute path for io.Local absSource, err := filepath.Abs(source) if err != nil { return log.E("dev.sync", "failed to resolve source path", err) } // Validate source exists using io.Local.Stat sourceInfo, err := coreio.Local.Stat(absSource) if err != nil { return log.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 { // Ensure dir exists if err := coreio.Local.EnsureDir(filepath.Dir(destPath)); err != nil { cli.Print(" %s %s: copy failed: %s\n", errorStyle.Render("x"), repoName, err) failed++ continue } if err := coreio.Copy(coreio.Local, source, coreio.Local, 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, log.E("dev.sync", "failed to find registry", err) } registry, err := repos.LoadRegistry(registryPath) if err != nil { return nil, log.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 } // copyDir recursively copies a directory func copyDir(src, dst string) error { entries, err := coreio.Local.List(src) if err != nil { return err } if err := coreio.Local.EnsureDir(dst); 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 := coreio.Copy(coreio.Local, srcPath, coreio.Local, dstPath); err != nil { return err } } } return nil }