Feat/update migrate (#910)

* * update migrate

* * rename handlers to sources

* * delete dead code

* * fix go test error
This commit is contained in:
lxowalle
2026-02-28 19:59:17 +08:00
committed by GitHub
parent 27e988c484
commit 8207c1c7e6
13 changed files with 3035 additions and 1472 deletions
+162
View File
@@ -0,0 +1,162 @@
package internal
import (
"fmt"
"io"
"os"
"path/filepath"
)
func ResolveTargetHome(override string) (string, error) {
if override != "" {
return ExpandHome(override), nil
}
if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" {
return ExpandHome(envHome), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolving home directory: %w", err)
}
return filepath.Join(home, ".picoclaw"), nil
}
func ExpandHome(path string) string {
if path == "" {
return path
}
if path[0] == '~' {
home, _ := os.UserHomeDir()
if len(path) > 1 && path[1] == '/' {
return home + path[1:]
}
return home
}
return path
}
func ResolveWorkspace(homeDir string) string {
return filepath.Join(homeDir, "workspace")
}
func PlanWorkspaceMigration(
srcWorkspace, dstWorkspace string,
migrateableFiles []string,
migrateableDirs []string,
force bool,
) ([]Action, error) {
var actions []Action
for _, filename := range migrateableFiles {
src := filepath.Join(srcWorkspace, filename)
dst := filepath.Join(dstWorkspace, filename)
action := planFileCopy(src, dst, force)
if action.Type != ActionSkip || action.Description != "" {
actions = append(actions, action)
}
}
for _, dirname := range migrateableDirs {
srcDir := filepath.Join(srcWorkspace, dirname)
if _, err := os.Stat(srcDir); os.IsNotExist(err) {
continue
}
dirActions, err := planDirCopy(srcDir, filepath.Join(dstWorkspace, dirname), force)
if err != nil {
return nil, err
}
actions = append(actions, dirActions...)
}
return actions, nil
}
func planFileCopy(src, dst string, force bool) Action {
if _, err := os.Stat(src); os.IsNotExist(err) {
return Action{
Type: ActionSkip,
Source: src,
Target: dst,
Description: "source file not found",
}
}
_, dstExists := os.Stat(dst)
if dstExists == nil && !force {
return Action{
Type: ActionBackup,
Source: src,
Target: dst,
Description: "destination exists, will backup and overwrite",
}
}
return Action{
Type: ActionCopy,
Source: src,
Target: dst,
Description: "copy file",
}
}
func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) {
var actions []Action
err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
dst := filepath.Join(dstDir, relPath)
if info.IsDir() {
actions = append(actions, Action{
Type: ActionCreateDir,
Target: dst,
Description: "create directory",
})
return nil
}
action := planFileCopy(path, dst, force)
actions = append(actions, action)
return nil
})
return actions, err
}
func RelPath(path, base string) string {
rel, err := filepath.Rel(base, path)
if err != nil {
return filepath.Base(path)
}
return rel
}
func CopyFile(src, dst string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
info, err := srcFile.Stat()
if err != nil {
return err
}
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
}
+195
View File
@@ -0,0 +1,195 @@
package internal
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExpandHome(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"", ""},
{"/absolute/path", "/absolute/path"},
{"relative/path", "relative/path"},
}
for _, tt := range tests {
result := ExpandHome(tt.input)
assert.Equal(t, tt.expected, result)
}
}
func TestExpandHomeWithTilde(t *testing.T) {
home, err := os.UserHomeDir()
require.NoError(t, err)
result := ExpandHome("~/path")
assert.Equal(t, home+"/path", result)
result = ExpandHome("~")
assert.Equal(t, home, result)
}
func TestResolveWorkspace(t *testing.T) {
result := ResolveWorkspace("/home/user/.picoclaw")
assert.Equal(t, "/home/user/.picoclaw/workspace", result)
}
func TestRelPath(t *testing.T) {
result := RelPath("/home/user/.picoclaw/workspace/file.txt", "/home/user/.picoclaw")
assert.Equal(t, "workspace/file.txt", result)
}
func TestRelPathError(t *testing.T) {
result := RelPath("relative/path", "/different/base")
assert.Equal(t, "path", result)
}
func TestResolveTargetHome(t *testing.T) {
home, err := os.UserHomeDir()
require.NoError(t, err)
result, err := ResolveTargetHome("")
require.NoError(t, err)
assert.Equal(t, filepath.Join(home, ".picoclaw"), result)
}
func TestResolveTargetHomeWithOverride(t *testing.T) {
result, err := ResolveTargetHome("/custom/path")
require.NoError(t, err)
assert.Equal(t, "/custom/path", result)
}
func TestCopyFile(t *testing.T) {
tmpDir := t.TempDir()
sourceFile := filepath.Join(tmpDir, "source.txt")
err := os.WriteFile(sourceFile, []byte("test content"), 0o644)
require.NoError(t, err)
dstFile := filepath.Join(tmpDir, "dest.txt")
err = CopyFile(sourceFile, dstFile)
require.NoError(t, err)
content, err := os.ReadFile(dstFile)
require.NoError(t, err)
assert.Equal(t, "test content", string(content))
}
func TestCopyFileSourceNotFound(t *testing.T) {
tmpDir := t.TempDir()
err := CopyFile(filepath.Join(tmpDir, "nonexistent.txt"), filepath.Join(tmpDir, "dest.txt"))
require.Error(t, err)
}
func TestPlanWorkspaceMigration(t *testing.T) {
tmpDir := t.TempDir()
srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
err := os.MkdirAll(srcWorkspace, 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("content"), 0o644)
require.NoError(t, err)
err = os.MkdirAll(filepath.Join(srcWorkspace, "subdir"), 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(srcWorkspace, "subdir", "file2.txt"), []byte("content"), 0o644)
require.NoError(t, err)
actions, err := PlanWorkspaceMigration(
srcWorkspace,
dstWorkspace,
[]string{"file1.txt"},
[]string{"subdir"},
false,
)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(actions), 1)
}
func TestPlanWorkspaceMigrationWithExistingDestination(t *testing.T) {
tmpDir := t.TempDir()
srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
err := os.MkdirAll(srcWorkspace, 0o755)
require.NoError(t, err)
err = os.MkdirAll(dstWorkspace, 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
require.NoError(t, err)
actions, err := PlanWorkspaceMigration(
srcWorkspace,
dstWorkspace,
[]string{"file1.txt"},
[]string{},
false,
)
require.NoError(t, err)
require.GreaterOrEqual(t, len(actions), 1)
assert.Equal(t, ActionBackup, actions[0].Type)
}
func TestPlanWorkspaceMigrationForce(t *testing.T) {
tmpDir := t.TempDir()
srcWorkspace := filepath.Join(tmpDir, "src", "workspace")
dstWorkspace := filepath.Join(tmpDir, "dst", "workspace")
err := os.MkdirAll(srcWorkspace, 0o755)
require.NoError(t, err)
err = os.MkdirAll(dstWorkspace, 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644)
require.NoError(t, err)
actions, err := PlanWorkspaceMigration(
srcWorkspace,
dstWorkspace,
[]string{"file1.txt"},
[]string{},
true,
)
require.NoError(t, err)
require.GreaterOrEqual(t, len(actions), 1)
assert.Equal(t, ActionCopy, actions[0].Type)
}
func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) {
tmpDir := t.TempDir()
actions, err := PlanWorkspaceMigration(
filepath.Join(tmpDir, "nonexistent"),
filepath.Join(tmpDir, "dst", "workspace"),
[]string{"file1.txt"},
[]string{},
false,
)
require.NoError(t, err)
require.Len(t, actions, 1)
assert.Equal(t, ActionSkip, actions[0].Type)
assert.Contains(t, actions[0].Description, "source file not found")
}
+52
View File
@@ -0,0 +1,52 @@
package internal
type Options struct {
DryRun bool
ConfigOnly bool
WorkspaceOnly bool
Force bool
Refresh bool
Source string
SourceHome string
TargetHome string
}
type Operation interface {
GetSourceName() string
GetSourceHome() (string, error)
GetSourceWorkspace() (string, error)
GetSourceConfigFile() (string, error)
ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error
GetMigrateableFiles() []string
GetMigrateableDirs() []string
}
type HandlerFactory func(opts Options) Operation
type ActionType int
const (
ActionCopy ActionType = iota
ActionSkip
ActionBackup
ActionConvertConfig
ActionCreateDir
ActionMergeConfig
)
type Action struct {
Type ActionType
Source string
Target string
Description string
}
type Result struct {
FilesCopied int
FilesSkipped int
BackupsCreated int
ConfigMigrated bool
DirsCreated int
Warnings []string
Errors []error
}