* feat(devops): migrate filesystem operations to io.Local abstraction Migrate config.go: - os.ReadFile → io.Local.Read Migrate devops.go: - os.Stat → io.Local.IsFile Migrate images.go: - os.MkdirAll → io.Local.EnsureDir - os.Stat → io.Local.IsFile - os.ReadFile → io.Local.Read - os.WriteFile → io.Local.Write Migrate test.go: - os.ReadFile → io.Local.Read - os.Stat → io.Local.IsFile Migrate claude.go: - os.Stat → io.Local.IsDir Updated tests to reflect improved behavior: - Manifest.Save() now creates parent directories - hasFile() correctly returns false for directories Part of #101 (io.Medium migration tracking issue). Closes #107 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore(io): migrate remaining packages to io.Local abstraction Migrate filesystem operations to use the io.Local abstraction for improved security, testability, and consistency: - pkg/cache: Replace os.ReadFile, WriteFile, Remove, RemoveAll with io.Local equivalents. io.Local.Write creates parent dirs automatically. - pkg/agentic: Migrate config.go and context.go to use io.Local for reading config files and gathering file context. - pkg/repos: Use io.Local.Read, Exists, IsDir, List for registry operations and git repo detection. - pkg/release: Use io.Local for config loading, existence checks, and artifact discovery. - pkg/devops/sources: Use io.Local.EnsureDir for CDN download. All paths are converted to absolute using filepath.Abs() before calling io.Local methods to handle relative paths correctly. Closes #104, closes #106, closes #108, closes #111 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore(io): migrate pkg/cli and pkg/container to io.Local abstraction Continue io.Medium migration for the remaining packages: - pkg/cli/daemon.go: PIDFile Acquire/Release now use io.Local.Read, Delete, and Write for managing daemon PID files. - pkg/container/state.go: LoadState and SaveState use io.Local for JSON state persistence. EnsureLogsDir uses io.Local.EnsureDir. - pkg/container/templates.go: Template loading and directory scanning now use io.Local.IsFile, IsDir, Read, and List. - pkg/container/linuxkit.go: Image validation uses io.Local.IsFile, log file check uses io.Local.IsFile. Streaming log file creation (os.Create) remains unchanged as io.Local doesn't support streaming. Closes #105, closes #107 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address CodeRabbit feedback - use errors.E for context Add contextual error handling using errors.E helper as suggested: - config.go: Wrap LoadConfig read/parse errors - images.go: Wrap NewImageManager, loadManifest, and Manifest.Save errors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(io): add contextual error handling with E() helper Address CodeRabbit review feedback by wrapping raw errors with the errors.E() helper to provide service/action context for debugging: - pkg/cache: wrap cache.New, Get, Set, Delete, Clear errors - pkg/devops/test: wrap LoadTestConfig path/read/parse errors - pkg/cli/daemon: wrap PIDFile.Release path resolution error - pkg/container/state: wrap LoadState/SaveState errors - pkg/container/templates: wrap GetTemplate embedded/user read errors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore(io): migrate internal/cmd/dev to io.Local abstraction - Replace os.Stat with io.Local.Stat in cmd_file_sync.go - Update test file to use io.Local.EnsureDir and io.Local.Write - Add filepath.Abs for proper path resolution before io.Local calls Closes #114 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use log.E instead of errors.E in cmd_file_sync --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
328 lines
8.7 KiB
Go
328 lines
8.7 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()
|
|
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
|
|
}
|