* chore(io): Migrate pkg/repos to Medium abstraction - Modified Registry and Repo structs in pkg/repos/registry.go to include io.Medium. - Updated LoadRegistry, FindRegistry, and ScanDirectory signatures to accept io.Medium. - Migrated all internal file operations in pkg/repos/registry.go to use the Medium interface instead of io.Local or os package. - Updated dozens of call sites across internal/cmd/ to pass io.Local to the updated repos functions. - Ensured consistent use of io.Medium for repo existence and git checks. * chore(io): Fix undefined io errors in repos migration - Fixed "undefined: io" compilation errors by using the correct 'coreio' alias in internal commands. - Corrected FindRegistry and LoadRegistry calls in cmd_file_sync.go, cmd_install.go, and cmd_search.go. - Verified fix with successful project-wide build. * chore(io): Final fixes for repos Medium migration - Fixed formatting issue in internal/cmd/setup/cmd_github.go by using 'coreio' alias for consistency. - Ensured all callers use the 'coreio' alias when referring to the io package. - Verified project-wide build completes successfully. * chore(io): Complete migration of pkg/repos to io.Medium - Migrated pkg/repos/registry.go to use io.Medium abstraction for all file operations. - Updated all callers in internal/cmd/ to pass io.Local, with proper alias handling. - Fixed formatting issues in cmd_github.go that caused previous CI failures. - Added unit tests in pkg/repos/registry_test.go using io.MockMedium. - Verified project-wide build and new unit tests pass. * chore(io): Address PR feedback for Medium migration - Made pkg/repos truly medium-agnostic by removing local filepath.Abs calls. - Restored Medium abstraction in pkg/cli/daemon.go (PIDFile and Daemon). - Restored context cancellation checks in pkg/container/linuxkit.go. - Updated pkg/cli/daemon_test.go to use MockMedium. - Documented FindRegistry's local filesystem dependencies. - Verified project-wide build and tests pass. * chore(io): Fix merge conflicts and address PR feedback - Resolved merge conflicts with latest dev branch. - Restored Medium abstraction in pkg/cli/daemon.go and context checks in pkg/container/linuxkit.go. - Refactored pkg/repos/registry.go to be truly medium-agnostic (removed filepath.Abs). - Updated pkg/cli/daemon_test.go to use MockMedium. - Verified all builds and tests pass locally. * chore(io): Complete pkg/repos Medium migration and PR feedback - Refactored pkg/repos to use io.Medium abstraction, removing local filesystem dependencies. - Updated all call sites in internal/cmd to pass io.Local/coreio.Local. - Restored Medium abstraction in pkg/cli/daemon.go and context checks in pkg/container/linuxkit.go. - Updated pkg/cli/daemon_test.go to use MockMedium for better test isolation. - Fixed merge conflicts and code formatting issues. - Verified project-wide build and tests pass. * fix(lint): handle error return values in registry tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
328 lines
8.8 KiB
Go
328 lines
8.8 KiB
Go
// 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 <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 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(coreio.Local)
|
|
if err != nil {
|
|
return nil, log.E("dev.sync", "failed to find registry", err)
|
|
}
|
|
|
|
registry, err := repos.LoadRegistry(coreio.Local, 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
|
|
}
|