Skip to content

Commit 092f01d

Browse files
committed
feat(migrate): add taskwing migrate command and auto-migration on upgrade
Existing users with local .taskwing/ directories get their knowledge automatically migrated to ~/.taskwing/projects/<slug>/ on first run after upgrading. The auto-migration runs silently during the version upgrade hook and only copies if the global store is empty. For manual control, `taskwing migrate` copies memory.db, ARCHITECTURE.md, config.yaml, policies, and version files. Idempotent by default, use --force to overwrite existing global data.
1 parent 288124b commit 092f01d

4 files changed

Lines changed: 248 additions & 1 deletion

File tree

cmd/doctor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ var doctorCmd = &cobra.Command{
2424
Long: `Validate your TaskWing installation and configuration.
2525
2626
Checks:
27-
• TaskWing initialization (.taskwing/ directory)
27+
• TaskWing initialization (global project store)
2828
• MCP server registration for AI tools
2929
• Hooks configuration for autonomous execution
3030
• Active plan and task status

cmd/migrate.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/josephgoksu/TaskWing/internal/config"
9+
"github.com/josephgoksu/TaskWing/internal/migration"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var migrateCmd = &cobra.Command{
14+
Use: "migrate",
15+
Short: "Migrate local .taskwing/ data to global store",
16+
Long: `Migrate project data from a local .taskwing/ directory to the global store at ~/.taskwing/projects/.
17+
18+
This is needed after upgrading to v1.22.6+ which centralizes all storage.
19+
After migration, you can safely delete the local .taskwing/ directory.
20+
21+
The command is idempotent and will not overwrite existing data in the global store
22+
unless --force is used.`,
23+
RunE: runMigrate,
24+
}
25+
26+
func init() {
27+
rootCmd.AddCommand(migrateCmd)
28+
migrateCmd.Flags().Bool("force", false, "Overwrite existing data in global store")
29+
}
30+
31+
func runMigrate(cmd *cobra.Command, args []string) error {
32+
force, _ := cmd.Flags().GetBool("force")
33+
34+
cwd, err := os.Getwd()
35+
if err != nil {
36+
return fmt.Errorf("get working directory: %w", err)
37+
}
38+
39+
result, err := migration.MigrateLocalToGlobal(cwd, force)
40+
if err != nil {
41+
return err
42+
}
43+
44+
switch {
45+
case result.AlreadyMigrated:
46+
fmt.Println("Nothing to migrate. Global store already has data for this project.")
47+
fmt.Printf(" Store: %s\n", result.StorePath)
48+
case result.NoLocalDir:
49+
fmt.Println("No local .taskwing/ directory found. Nothing to migrate.")
50+
default:
51+
fmt.Println("Migrated successfully.")
52+
fmt.Printf(" From: %s\n", filepath.Join(cwd, ".taskwing"))
53+
fmt.Printf(" To: %s\n", result.StorePath)
54+
for _, f := range result.FilesMigrated {
55+
fmt.Printf(" - %s\n", f)
56+
}
57+
fmt.Println()
58+
fmt.Println("You can now delete the local .taskwing/ directory:")
59+
fmt.Printf(" rm -rf %s\n", filepath.Join(cwd, ".taskwing"))
60+
}
61+
62+
// Also check if global store path is properly registered
63+
storePath, err := config.GetProjectStorePath(cwd)
64+
if err == nil && storePath != "" {
65+
fmt.Printf("\n Global store: %s\n", storePath)
66+
}
67+
68+
return nil
69+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package migration
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/josephgoksu/TaskWing/internal/config"
10+
)
11+
12+
// MigrateResult describes what happened during migration.
13+
type MigrateResult struct {
14+
StorePath string
15+
FilesMigrated []string
16+
NoLocalDir bool
17+
AlreadyMigrated bool
18+
}
19+
20+
// MigrateLocalToGlobal moves data from {projectDir}/.taskwing/ to the global store.
21+
// It copies memory.db, ARCHITECTURE.md, config.yaml, and version files.
22+
// Does not overwrite existing files in the global store unless force is true.
23+
func MigrateLocalToGlobal(projectDir string, force bool) (*MigrateResult, error) {
24+
localDir := filepath.Join(projectDir, ".taskwing")
25+
26+
// Check if local .taskwing/ exists
27+
if _, err := os.Stat(localDir); os.IsNotExist(err) {
28+
return &MigrateResult{NoLocalDir: true}, nil
29+
}
30+
31+
// Resolve global store path
32+
storePath, err := config.GetProjectStorePath(projectDir)
33+
if err != nil {
34+
return nil, fmt.Errorf("resolve global store: %w", err)
35+
}
36+
37+
// Files to migrate (source relative to .taskwing/, dest relative to store)
38+
migrations := []struct {
39+
src string // relative to localDir
40+
dst string // relative to storePath
41+
}{
42+
{"memory/memory.db", "memory.db"},
43+
{"memory/memory.db-wal", "memory.db-wal"},
44+
{"memory/memory.db-shm", "memory.db-shm"},
45+
{"memory/ARCHITECTURE.md", "ARCHITECTURE.md"},
46+
{"config.yaml", "config.yaml"},
47+
{"version", "version"},
48+
}
49+
50+
var migrated []string
51+
skippedExisting := false
52+
53+
for _, m := range migrations {
54+
srcPath := filepath.Join(localDir, m.src)
55+
dstPath := filepath.Join(storePath, m.dst)
56+
57+
// Skip if source doesn't exist
58+
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
59+
continue
60+
}
61+
62+
// Check if destination already exists
63+
if _, err := os.Stat(dstPath); err == nil {
64+
if !force {
65+
skippedExisting = true
66+
continue
67+
}
68+
}
69+
70+
if err := copyFile(srcPath, dstPath); err != nil {
71+
return nil, fmt.Errorf("copy %s: %w", m.src, err)
72+
}
73+
migrated = append(migrated, m.dst)
74+
}
75+
76+
// Also migrate policies directory if it exists
77+
localPolicies := filepath.Join(localDir, "policies")
78+
if info, err := os.Stat(localPolicies); err == nil && info.IsDir() {
79+
dstPolicies := filepath.Join(storePath, "policies")
80+
if err := copyDir(localPolicies, dstPolicies, force); err != nil {
81+
return nil, fmt.Errorf("copy policies: %w", err)
82+
}
83+
migrated = append(migrated, "policies/")
84+
}
85+
86+
if len(migrated) == 0 && skippedExisting {
87+
return &MigrateResult{StorePath: storePath, AlreadyMigrated: true}, nil
88+
}
89+
90+
return &MigrateResult{
91+
StorePath: storePath,
92+
FilesMigrated: migrated,
93+
}, nil
94+
}
95+
96+
// AutoMigrateIfNeeded silently migrates local .taskwing/ data on version upgrade.
97+
// Returns true if migration occurred.
98+
func AutoMigrateIfNeeded(projectDir string) bool {
99+
localDB := filepath.Join(projectDir, ".taskwing", "memory", "memory.db")
100+
if _, err := os.Stat(localDB); os.IsNotExist(err) {
101+
return false
102+
}
103+
104+
storePath, err := config.GetProjectStorePath(projectDir)
105+
if err != nil {
106+
return false
107+
}
108+
109+
globalDB := filepath.Join(storePath, "memory.db")
110+
if _, err := os.Stat(globalDB); err == nil {
111+
return false // Already has data, don't overwrite
112+
}
113+
114+
if err := copyFile(localDB, globalDB); err != nil {
115+
fmt.Fprintf(os.Stderr, "taskwing: auto-migration of local knowledge failed: %v\n", err)
116+
return false
117+
}
118+
119+
// Also copy ARCHITECTURE.md if present
120+
localArch := filepath.Join(projectDir, ".taskwing", "memory", "ARCHITECTURE.md")
121+
globalArch := filepath.Join(storePath, "ARCHITECTURE.md")
122+
if _, err := os.Stat(localArch); err == nil {
123+
_ = copyFile(localArch, globalArch)
124+
}
125+
126+
fmt.Fprintf(os.Stderr, "taskwing: migrated local knowledge to %s\n", storePath)
127+
return true
128+
}
129+
130+
func copyFile(src, dst string) error {
131+
if err := os.MkdirAll(filepath.Dir(dst), 0700); err != nil {
132+
return err
133+
}
134+
135+
in, err := os.Open(src)
136+
if err != nil {
137+
return err
138+
}
139+
defer in.Close()
140+
141+
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
142+
if err != nil {
143+
return err
144+
}
145+
defer out.Close()
146+
147+
if _, err := io.Copy(out, in); err != nil {
148+
return err
149+
}
150+
return out.Close()
151+
}
152+
153+
func copyDir(src, dst string, force bool) error {
154+
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
155+
if err != nil {
156+
return err
157+
}
158+
159+
rel, err := filepath.Rel(src, path)
160+
if err != nil {
161+
return err
162+
}
163+
dstPath := filepath.Join(dst, rel)
164+
165+
if info.IsDir() {
166+
return os.MkdirAll(dstPath, 0700)
167+
}
168+
169+
if _, err := os.Stat(dstPath); err == nil && !force {
170+
return nil // Skip existing
171+
}
172+
173+
return copyFile(path, dstPath)
174+
})
175+
}

internal/migration/upgrade.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ func CheckAndMigrate(projectDir, currentVersion string) (warnings []string, err
5353

5454
// --- Version mismatch: run migration ---
5555

56+
// 0. Auto-migrate local .taskwing/ to global store if needed
57+
AutoMigrateIfNeeded(projectDir)
58+
5659
// 1. Silent local migration: regenerate slash commands for managed AIs
5760
migrateLocalConfigs(projectDir)
5861

0 commit comments

Comments
 (0)