diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c964559..4535dc3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -392,11 +392,12 @@ func RunSessions(claudeDir string, all bool) error { return nil } -// RunMove moves a project's session directory to newPath, taking every -// session under it along. oldDir, if set, is used directly. Otherwise -// sessionID resolves to its project dir; if that's empty too, the session -// is resolved the same way other subcommands do (tmux window / live -// registry match). +// RunMove moves a session's project path to newPath. With --from, oldDir is +// used directly and the whole project directory (every session under it) is +// moved. Otherwise sessionID resolves to a specific session (defaulting to +// the current tmux window's session), and only that session's transcript +// (plus its subagent/scratchpad data) is moved, leaving sibling sessions +// under the old project path untouched. func RunMove(claudeDir, sessionID, oldDir, newPath string) error { newPath = strings.TrimSpace(newPath) if newPath == "" { @@ -415,31 +416,43 @@ func RunMove(claudeDir, sessionID, oldDir, newPath string) error { return fmt.Errorf("resolve old path: %w", err) } oldPath = abs - } else if sessionID != "" { - sess, ok := session.FindSessionByID(claudeDir, sessionID) + + if oldPath == newPath { + return fmt.Errorf("new path is the same as the current path: %s", oldPath) + } + if err := session.MoveProject(oldPath, newPath); err != nil { + return err + } + fmt.Fprintf(os.Stdout, "%s -> %s\n", oldPath, newPath) + return nil + } + + var sess session.Session + if sessionID != "" { + s, ok := session.FindSessionByID(claudeDir, sessionID) if !ok { return fmt.Errorf("session %s not found", sessionID) } - oldPath = sess.ProjectPath + sess = s } else { _, sessID, err := findSessionFile(claudeDir) if err != nil { return err } - sess, ok := session.FindSessionByID(claudeDir, sessID) + s, ok := session.FindSessionByID(claudeDir, sessID) if !ok { return fmt.Errorf("session %s not found", sessID) } - oldPath = sess.ProjectPath + sess = s } - if oldPath == newPath { - return fmt.Errorf("new path is the same as the current path: %s", oldPath) + if sess.ProjectPath == newPath { + return fmt.Errorf("new path is the same as the current path: %s", sess.ProjectPath) } - if err := session.MoveProject(oldPath, newPath); err != nil { + if err := session.MoveSession(sess.ProjectPath, newPath, sess.ID); err != nil { return err } - fmt.Fprintf(os.Stdout, "%s -> %s\n", oldPath, newPath) + fmt.Fprintf(os.Stdout, "%s -> %s\n", sess.ProjectPath, newPath) return nil } diff --git a/internal/session/scanner_path.go b/internal/session/scanner_path.go index 92604d4..027eaa3 100644 --- a/internal/session/scanner_path.go +++ b/internal/session/scanner_path.go @@ -49,6 +49,68 @@ func MoveProject(oldPath, newPath string) error { return nil } +// MoveSession moves a single session's transcript (and its subagent/scratchpad +// data) from the project directory for oldPath to the project directory for +// newPath, leaving every other session under oldPath untouched. Unlike +// MoveProject, this does not rename the whole project directory. +func MoveSession(oldPath, newPath, sessionID string) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("get home dir: %w", err) + } + + oldEncoded := EncodeProjectPath(oldPath) + newEncoded := EncodeProjectPath(newPath) + projectsDir := filepath.Join(home, ".claude", "projects") + oldDir := filepath.Join(projectsDir, oldEncoded) + newDir := filepath.Join(projectsDir, newEncoded) + + oldFile := filepath.Join(oldDir, sessionID+".jsonl") + if _, err := os.Stat(oldFile); os.IsNotExist(err) { + return fmt.Errorf("session file not found: %s", oldFile) + } + newFile := filepath.Join(newDir, sessionID+".jsonl") + if _, err := os.Stat(newFile); err == nil { + return fmt.Errorf("target already exists: %s", newFile) + } + + if err := os.MkdirAll(newDir, 0755); err != nil { + return fmt.Errorf("create target project dir: %w", err) + } + + if err := rewriteCwdInFile(oldFile, oldPath, newPath); err != nil { + return fmt.Errorf("rewrite cwd: %w", err) + } + if err := os.Rename(oldFile, newFile); err != nil { + return fmt.Errorf("rename session file: %w", err) + } + + oldSubDir := filepath.Join(oldDir, sessionID) + if info, err := os.Stat(oldSubDir); err == nil && info.IsDir() { + if err := rewriteCwdInDir(oldSubDir, oldPath, newPath); err != nil { + return fmt.Errorf("rewrite cwd in subagents: %w", err) + } + if err := os.Rename(oldSubDir, filepath.Join(newDir, sessionID)); err != nil { + return fmt.Errorf("move subagents dir: %w", err) + } + } + + oldScratchDir := filepath.Join(ScratchpadBase(), oldEncoded, sessionID) + if info, err := os.Stat(oldScratchDir); err == nil && info.IsDir() { + newScratchParent := filepath.Join(ScratchpadBase(), newEncoded) + if err := os.MkdirAll(newScratchParent, 0755); err != nil { + return fmt.Errorf("create target scratchpad dir: %w", err) + } + if err := os.Rename(oldScratchDir, filepath.Join(newScratchParent, sessionID)); err != nil { + return fmt.Errorf("move scratchpad dir: %w", err) + } + } + + decodedPathCache.Delete(oldEncoded) + + return nil +} + func rewriteCwdInDir(dir, oldCwd, newCwd string) error { return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { diff --git a/internal/session/scanner_path_test.go b/internal/session/scanner_path_test.go index 0105dae..51cdd64 100644 --- a/internal/session/scanner_path_test.go +++ b/internal/session/scanner_path_test.go @@ -1,6 +1,8 @@ package session import ( + "os" + "path/filepath" "testing" ) @@ -54,6 +56,65 @@ func TestDecodeDirName(t *testing.T) { } } +// TestMoveSessionOnlyMovesOneSession proves that MoveSession (used by +// `ccx move --session `) relocates only the named session's transcript, +// leaving a sibling session under the same old project path untouched — +// unlike MoveProject, which renames the whole project directory. +func TestMoveSessionOnlyMovesOneSession(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + oldPath := "/Users/test/project-a" + newPath := "/Users/test/project-b" + oldEncoded := EncodeProjectPath(oldPath) + newEncoded := EncodeProjectPath(newPath) + + oldDir := filepath.Join(home, ".claude", "projects", oldEncoded) + if err := os.MkdirAll(oldDir, 0755); err != nil { + t.Fatal(err) + } + + movingID := "session-moving" + stayingID := "session-staying" + movingContent := `{"cwd":"` + oldPath + `","other":"a"}` + "\n" + stayingContent := `{"cwd":"` + oldPath + `","other":"b"}` + "\n" + + if err := os.WriteFile(filepath.Join(oldDir, movingID+".jsonl"), []byte(movingContent), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldDir, stayingID+".jsonl"), []byte(stayingContent), 0644); err != nil { + t.Fatal(err) + } + + if err := MoveSession(oldPath, newPath, movingID); err != nil { + t.Fatalf("MoveSession: %v", err) + } + + newDir := filepath.Join(home, ".claude", "projects", newEncoded) + + if _, err := os.Stat(filepath.Join(oldDir, movingID+".jsonl")); !os.IsNotExist(err) { + t.Errorf("moving session's old file should be gone, stat err = %v", err) + } + movedData, err := os.ReadFile(filepath.Join(newDir, movingID+".jsonl")) + if err != nil { + t.Fatalf("moved session file not found: %v", err) + } + if want := `{"cwd":"` + newPath + `","other":"a"}` + "\n"; string(movedData) != want { + t.Errorf("moved session cwd not rewritten: got %q, want %q", movedData, want) + } + + stayingData, err := os.ReadFile(filepath.Join(oldDir, stayingID+".jsonl")) + if err != nil { + t.Fatalf("sibling session should remain in old project dir: %v", err) + } + if string(stayingData) != stayingContent { + t.Errorf("sibling session content should be untouched: got %q, want %q", stayingData, stayingContent) + } + if _, err := os.Stat(filepath.Join(newDir, stayingID+".jsonl")); !os.IsNotExist(err) { + t.Errorf("sibling session should NOT have been moved, stat err = %v", err) + } +} + func TestEncodeDecodeRoundtrip(t *testing.T) { // EncodeProjectPath replaces / and . with - // decodeDirName replaces - with / (simple decode)