Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 27 additions & 14 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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
}

Expand Down
62 changes: 62 additions & 0 deletions internal/session/scanner_path.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
61 changes: 61 additions & 0 deletions internal/session/scanner_path_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package session

import (
"os"
"path/filepath"
"testing"
)

Expand Down Expand Up @@ -54,6 +56,65 @@ func TestDecodeDirName(t *testing.T) {
}
}

// TestMoveSessionOnlyMovesOneSession proves that MoveSession (used by
// `ccx move --session <id>`) 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)
Expand Down
Loading