diff --git a/.gitignore b/.gitignore
index f2737d7..af3c966 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
/coverage.out
/goreleaser
/.tmp/
+/.tools/
/.gocache/
/.gomodcache/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6de74f8..6144fd8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.1.0] - 2026-09-23
+
+### Added
+
+- `scan` hides stale noise by default (`.__rebind_orphan_*` workspaces, `empty-window`, `tmp-*` / numeric session agent dirs, and 0-transcript agent dirs). Pass `scan --all` to show everything.
+- `scan --path
` filters the workspace/agent tables to one project (includes `.__rebind_orphan_*` siblings).
+- `prune` previews (default) or deletes (`--yes`) safe leftovers: orphaned `workspaceStorage` dirs and empty ephemeral agent project dirs. Creates a backup first. Optional `--empty-projects` also includes 0-transcript path-like agent dirs.
+- Reject workspace-storage hashes pasted into `--from` / `--to` / `scan --path` (and the guided menu path prompts). Use the project folder path plus `--target-id` instead.
+- Guided migrate/repair picker labels SPLIT-BRAIN rows with header counts and a recommended keep id.
+
## [1.0.1] - 2026-09-11
### Fixed
@@ -33,4 +43,6 @@ First public release.
- Machine-move documentation (`docs/machine-move.md`) with backup/restore and prefix vs exact guidance
- Contributing guide, code of conduct, security policy, changelog, and GitHub issue/PR templates
+[Unreleased]: https://github.com/Interittus13/cursor-rebind/compare/v1.0.1...HEAD
+[1.0.1]: https://github.com/Interittus13/cursor-rebind/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/Interittus13/cursor-rebind/releases/tag/v1.0.0
diff --git a/README.md b/README.md
index 56ae35e..6333b1f 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,10 @@ Download the Windows archive from [Releases](https://github.com/Interittus13/cur
cursor-rebind
cursor-rebind scan
+cursor-rebind scan --all
+cursor-rebind scan --path /path/to/project
+cursor-rebind prune # dry-run safe leftovers
+cursor-rebind prune --yes # quit Cursor first; backs up then deletes
cursor-rebind doctor /path/to/project
# Preview a rebind
@@ -161,13 +165,14 @@ cursor-rebind migrate --from /home/olduser --to /home/newuser --prefix --yes
### Notes
-- Quit Cursor completely before `migrate` / `repair` (reload is not enough).
+- Quit Cursor completely before `migrate` / `repair` / `prune --yes` (reload is not enough).
- Prefer `--target-id` when multiple `workspaceStorage` entries exist for the same folder.
+- `--from` / `--to` must be **folder paths**, not the 32-character workspace ids from `scan` (those go in `--target-id`). Pasting an id as a path is rejected with a hint.
- **Never delete the empty shell** Cursor minted for `--to` and consolidate onto the older data leftover — Cursor remints that shell and IDE/Agents stay empty. Migrate/repair attach chats **onto** the emptiest/newest shell, then orphan siblings.
- Exact `migrate` / `repair` run a post-apply **health check** (single live workspace id + named chats on that id). Failure exits non-zero with a `repair --to` hint; use `verify` / `doctor` to detect `SPLIT-BRAIN`.
- `migrate` strategy (`create` / `replace-empty` / `merge`) chooses plan messaging and which chat becomes the primary tab. Apply steps are the same; **merge does not combine two threads into one**.
-- Tool backups from migrate/repair live under `~/.cursor-rebind/backups/` and can be listed with `cursor-rebind restore --list`.
-- Use `--cleanup` only after you are happy with the migrate; default is to keep path-orphaned storage as a safety net.
+- Tool backups from migrate/repair/prune live under `~/.cursor-rebind/backups/` and can be listed with `cursor-rebind restore --list`.
+- Use `--cleanup` only after you are happy with the migrate; default is to keep path-orphaned storage as a safety net. Later, `prune` (or migrate `--cleanup`) can remove those orphans.
## How it works
diff --git a/docs/machine-move.md b/docs/machine-move.md
index ff240be..11ea4a5 100644
--- a/docs/machine-move.md
+++ b/docs/machine-move.md
@@ -154,7 +154,12 @@ After a rename or machine move, Cursor may leave **two** `workspaceStorage//
```bash
cursor-rebind doctor /path/to/project # look for SPLIT-BRAIN
+cursor-rebind scan --path /path/to/project
# quit Cursor fully
cursor-rebind repair --to /path/to/project --yes
+# optional: pick keep id explicitly from scan
+# cursor-rebind repair --to /path/to/project --target-id --yes
cursor-rebind verify /path/to/project
```
+
+`--from` / `--to` are **folder paths**. The 32-character `ID` column from `scan` belongs in `--target-id` only. After migrate/repair, `cursor-rebind prune` can remove `.__rebind_orphan_*` workspaceStorage leftovers (dry-run by default).
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index fa0cb82..01a6aa2 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -39,6 +39,8 @@ func Execute() error {
return nil
case "scan":
return runScan(args[1:])
+ case "prune":
+ return runPrune(args[1:])
case "doctor":
return runDoctor(args[1:])
case "map", "preview":
@@ -63,7 +65,8 @@ Run with no arguments in a terminal for a guided menu.
Usage:
cursor-rebind
- cursor-rebind scan [--json]
+ cursor-rebind scan [--all] [--path ] [--json]
+ cursor-rebind prune [--empty-projects] [--json] [--yes]
cursor-rebind doctor [path] [--json]
cursor-rebind map --from --to [--prefix] [--json]
cursor-rebind migrate --from --to [--prefix] [--target-id ] [--cleanup] [--dry-run|--yes]
@@ -75,7 +78,8 @@ Usage:
Commands:
(no args) Guided menu (interactive terminal only)
- scan Inventory workspaces and chat identity
+ scan Inventory workspaces and chat identity (hides stale noise by default)
+ prune Preview/delete orphaned workspaceStorage + empty tmp/empty-window agent dirs
doctor Diagnose missing chats for a project path
map Build a rebind plan (alias: preview)
migrate Apply a rebind plan (quit Cursor first)
@@ -86,17 +90,38 @@ Commands:
Notes:
--cleanup After exact migrate/repair, delete orphaned old workspaceStorage
dirs (not your project folder). Refused with --prefix.
+ scan Default view hides .__rebind_orphan_*, empty-window, tmp-*, and
+ 0-transcript agent dirs. Use scan --all to show everything.
+ scan --path limits the table to one project.
+ prune Dry-run by default. Quit Cursor, then prune --yes to delete
+ (backs up under ~/.cursor-rebind/backups/).
+ --from/--to must be folder paths, not workspace ids (use --target-id for ids).
`, Version)
}
func runScan(args []string) error {
asJSON := false
- for _, a := range args {
+ showAll := false
+ pathFilter := ""
+ for i := 0; i < len(args); i++ {
+ a := args[i]
switch a {
case "--json":
asJSON = true
+ case "--all":
+ showAll = true
+ case "--path":
+ if i+1 >= len(args) {
+ return fmt.Errorf("scan: --path requires a directory")
+ }
+ i++
+ pathFilter = args[i]
case "-h", "--help":
- fmt.Println("Usage: cursor-rebind scan [--json]")
+ fmt.Println("Usage: cursor-rebind scan [--all] [--path ] [--json]")
+ fmt.Println()
+ fmt.Println("By default hides stale noise (rebind orphans, empty-window, tmp-*,")
+ fmt.Println("0-transcript agent dirs). Pass --all to list everything.")
+ fmt.Println("--path limits the table to one project folder (and its orphan siblings).")
return nil
default:
return fmt.Errorf("scan: unknown flag %q", a)
@@ -112,23 +137,41 @@ func runScan(args []string) error {
return err
}
+ workspaces, projects, hiddenWS, hiddenProj := discover.FilterInventory(inv, !showAll)
+ if pathFilter != "" {
+ if paths.LooksLikeWorkspaceID(pathFilter) {
+ return paths.ErrWorkspaceIDAsPath("--path", pathFilter)
+ }
+ pathFilter = absPath(expandHome(pathFilter))
+ workspaces, projects = discover.FilterByPath(workspaces, projects, pathFilter)
+ hiddenWS, hiddenProj = 0, 0 // counts are for pre-path filter; avoid confusing footer
+ }
+
if asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
// Avoid dumping every header entry in default JSON (can be large).
type scanOut struct {
- Roots paths.Roots `json:"roots"`
- Workspaces []discover.Workspace `json:"workspaces"`
- Projects []discover.AgentProject `json:"projects"`
- Headers discover.HeaderIndex `json:"headers"`
- ScannedAt interface{} `json:"scannedAt"`
+ Roots paths.Roots `json:"roots"`
+ Workspaces []discover.Workspace `json:"workspaces"`
+ Projects []discover.AgentProject `json:"projects"`
+ Headers discover.HeaderIndex `json:"headers"`
+ ScannedAt interface{} `json:"scannedAt"`
+ HiddenWorkspaces int `json:"hiddenWorkspaces,omitempty"`
+ HiddenProjects int `json:"hiddenProjects,omitempty"`
+ ShowingAll bool `json:"showingAll"`
+ PathFilter string `json:"pathFilter,omitempty"`
}
return enc.Encode(scanOut{
- Roots: inv.Roots,
- Workspaces: inv.Workspaces,
- Projects: inv.Projects,
- Headers: inv.Headers,
- ScannedAt: inv.ScannedAt,
+ Roots: inv.Roots,
+ Workspaces: workspaces,
+ Projects: projects,
+ Headers: inv.Headers,
+ ScannedAt: inv.ScannedAt,
+ HiddenWorkspaces: hiddenWS,
+ HiddenProjects: hiddenProj,
+ ShowingAll: showAll,
+ PathFilter: pathFilter,
})
}
@@ -137,9 +180,20 @@ func runScan(args []string) error {
fmt.Printf("User data: %s\n", roots.UserDataDir)
fmt.Printf("Global DB: %s\n", roots.GlobalDB)
fmt.Printf("Projects: %s\n", roots.ProjectsDir)
- fmt.Printf("Workspaces: %d\n", len(inv.Workspaces))
- fmt.Printf("Agent dirs: %d\n", len(inv.Projects))
- if inv.Headers.Loaded {
+ if pathFilter != "" {
+ fmt.Printf("Filter: %s\n", pathFilter)
+ }
+ fmt.Printf("Workspaces: %d", len(workspaces))
+ if pathFilter == "" && !showAll && len(inv.Workspaces) != len(workspaces) {
+ fmt.Printf(" (of %d)", len(inv.Workspaces))
+ }
+ fmt.Println()
+ fmt.Printf("Agent dirs: %d", len(projects))
+ if pathFilter == "" && !showAll && len(inv.Projects) != len(projects) {
+ fmt.Printf(" (of %d)", len(inv.Projects))
+ }
+ fmt.Println()
+ if pathFilter == "" && inv.Headers.Loaded {
fmt.Printf("Headers: %d chats", inv.Headers.Total)
if inv.Headers.MissingPath > 0 {
fmt.Printf(" (%d without path)", inv.Headers.MissingPath)
@@ -151,14 +205,14 @@ func runScan(args []string) error {
fmt.Printf(" %-40s %d\n", k, n)
}
}
- } else if inv.Headers.Error != "" {
+ } else if pathFilter == "" && inv.Headers.Error != "" {
fmt.Printf("Headers: error: %s\n", inv.Headers.Error)
}
fmt.Println()
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "ID\tEXISTS\tHEADERS\tSCHEMA\tPATH")
- for _, ws := range inv.Workspaces {
+ for _, ws := range workspaces {
ex := "no"
if ws.PathExists {
ex = "yes"
@@ -179,18 +233,101 @@ func runScan(args []string) error {
_ = w.Flush()
fmt.Println()
fmt.Println("Tip: pass ID to migrate/repair with --target-id when several rows share the same PATH.")
+ if pathFilter == "" && !showAll && (hiddenWS > 0 || hiddenProj > 0) {
+ fmt.Printf("Hidden: %d workspace(s), %d agent dir(s) — scan --all to show; prune to delete safe leftovers.\n", hiddenWS, hiddenProj)
+ }
+ if pathFilter != "" && len(workspaces) == 0 {
+ fmt.Println("No workspaces matched this path. Try scan --all --path or check the folder exists.")
+ }
fmt.Println()
fmt.Println("Agent projects:")
w = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "TRANSCRIPTS\tNAME")
- for _, p := range inv.Projects {
+ for _, p := range projects {
fmt.Fprintf(w, "%d\t%s\n", p.TranscriptCount, p.Name)
}
_ = w.Flush()
return nil
}
+func runPrune(args []string) error {
+ asJSON := false
+ yes := false
+ includeEmpty := false
+ for _, a := range args {
+ switch a {
+ case "--json":
+ asJSON = true
+ case "--yes":
+ yes = true
+ case "--empty-projects":
+ includeEmpty = true
+ case "-h", "--help":
+ fmt.Println("Usage: cursor-rebind prune [--empty-projects] [--json] [--yes]")
+ fmt.Println()
+ fmt.Println("Dry-run by default. Lists safe leftovers:")
+ fmt.Println(" • workspaceStorage dirs marked .__rebind_orphan_*")
+ fmt.Println(" • empty-window workspaceStorage (if present)")
+ fmt.Println(" • tmp-*/empty-window/numeric agent dirs with 0 transcripts")
+ fmt.Println()
+ fmt.Println("--empty-projects also lists 0-transcript path-like agent dirs (still opt-in).")
+ fmt.Println("Quit Cursor, then pass --yes to delete (creates a backup first).")
+ return nil
+ default:
+ return fmt.Errorf("prune: unknown flag %q", a)
+ }
+ }
+
+ roots, err := paths.Discover()
+ if err != nil {
+ return err
+ }
+ inv, err := discover.Scan(roots)
+ if err != nil {
+ return err
+ }
+
+ dryRun := !yes
+ res, err := rebind.PruneStale(inv, includeEmpty, dryRun)
+ if err != nil {
+ return err
+ }
+
+ if asJSON {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(res)
+ }
+
+ fmt.Println("cursor-rebind prune")
+ fmt.Println("===================")
+ if len(res.Candidates) == 0 {
+ fmt.Println("Nothing to prune.")
+ return nil
+ }
+ w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(w, "KIND\tSAFE\tLABEL\tPATH")
+ for _, c := range res.Candidates {
+ safe := "no"
+ if c.Safe {
+ safe = "yes"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", c.Kind, safe, c.Label, c.Path)
+ }
+ _ = w.Flush()
+ fmt.Println()
+ if dryRun {
+ fmt.Printf("%d candidate(s). Dry-run only — quit Cursor, then: cursor-rebind prune --yes\n", len(res.Candidates))
+ return nil
+ }
+ fmt.Printf("Removed %d path(s).\n", len(res.Removed))
+ if res.BackupID != "" {
+ fmt.Printf("Backup: %s (cursor-rebind restore %s)\n", res.BackupID, res.BackupID)
+ }
+ return nil
+}
+
func runDoctor(args []string) error {
asJSON := false
var pathArg string
@@ -325,6 +462,14 @@ func absPath(p string) string {
return abs
}
+// requireFolderPath rejects workspace-storage hashes pasted into --from/--to/--path.
+func requireFolderPath(flag, value string) error {
+ if paths.LooksLikeWorkspaceID(value) {
+ return paths.ErrWorkspaceIDAsPath(flag, value)
+ }
+ return nil
+}
+
func runPlan(cmd string, args []string) error {
f, _, err := parsePathFlags(args)
if err != nil {
@@ -337,6 +482,12 @@ func runPlan(cmd string, args []string) error {
if f.from == "" || f.to == "" {
return fmt.Errorf("%s requires --from and --to", cmd)
}
+ if err := requireFolderPath("--from", f.from); err != nil {
+ return err
+ }
+ if err := requireFolderPath("--to", f.to); err != nil {
+ return err
+ }
f.from, f.to = absPath(f.from), absPath(f.to)
roots, err := paths.Discover()
@@ -377,6 +528,12 @@ func runMigrate(args []string) error {
if f.from == "" || f.to == "" {
return fmt.Errorf("migrate requires --from and --to")
}
+ if err := requireFolderPath("--from", f.from); err != nil {
+ return err
+ }
+ if err := requireFolderPath("--to", f.to); err != nil {
+ return err
+ }
if f.dryRun && f.yes {
return fmt.Errorf("use either --dry-run or --yes, not both")
}
@@ -436,6 +593,14 @@ func runRepair(args []string) error {
if f.to == "" {
return fmt.Errorf("repair requires --to")
}
+ if err := requireFolderPath("--to", f.to); err != nil {
+ return err
+ }
+ if f.from != "" {
+ if err := requireFolderPath("--from", f.from); err != nil {
+ return err
+ }
+ }
if !f.yes {
return fmt.Errorf("repair requires --yes (quit Cursor first)")
}
diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go
index 6a44e38..edb88b1 100644
--- a/internal/cli/interactive.go
+++ b/internal/cli/interactive.go
@@ -32,9 +32,10 @@ func runInteractiveMenu() error {
fmt.Println(" 1) Migrate a renamed or moved project")
fmt.Println(" 2) Repair Agents/IDE after a partial migrate")
fmt.Println(" 3) Scan workspaces")
- fmt.Println(" 4) Machine-move backup tips")
- fmt.Println(" 5) Quit")
- fmt.Print("Choice [1-5]: ")
+ fmt.Println(" 4) Prune stale leftovers (dry-run)")
+ fmt.Println(" 5) Machine-move backup tips")
+ fmt.Println(" 6) Quit")
+ fmt.Print("Choice [1-6]: ")
choice, err := readLine(in)
if err != nil {
return err
@@ -53,12 +54,16 @@ func runInteractiveMenu() error {
fmt.Fprintf(os.Stderr, "scan: %v\n", err)
}
case "4":
+ if err := runPrune(nil); err != nil {
+ fmt.Fprintf(os.Stderr, "prune: %v\n", err)
+ }
+ case "5":
printMachineMoveTips()
- case "5", "q", "quit", "":
+ case "6", "q", "quit", "":
fmt.Println("Bye.")
return nil
default:
- fmt.Println("Please enter a number from 1 to 5.")
+ fmt.Println("Please enter a number from 1 to 6.")
}
fmt.Println()
}
@@ -203,14 +208,28 @@ func promptTargetID(in *bufio.Reader, inv *discover.Inventory, to string) (strin
to = filepath.Clean(to)
var matches []discover.Workspace
for _, w := range inv.Workspaces {
- if filepath.Clean(w.FolderPath) == to {
+ fp := filepath.Clean(w.FolderPath)
+ if fp == to || strings.HasPrefix(fp, to+".__rebind_orphan_") {
+ // Prefer live rows in the picker; still show orphans for awareness.
matches = append(matches, w)
}
}
- if len(matches) <= 1 {
- if len(matches) == 1 {
- fmt.Printf("Using workspace id %s\n", matches[0].ID)
- return matches[0].ID, nil
+ // Live (non-orphan) first for picking the keep id.
+ var live []discover.Workspace
+ for _, w := range matches {
+ if !discover.IsOrphanFolderPath(w.FolderPath) && !discover.IsOrphanFolderPath(w.FolderURI) {
+ live = append(live, w)
+ }
+ }
+ pickFrom := live
+ if len(pickFrom) == 0 {
+ pickFrom = matches
+ }
+
+ if len(pickFrom) <= 1 {
+ if len(pickFrom) == 1 {
+ fmt.Printf("Using workspace id %s\n", pickFrom[0].ID)
+ return pickFrom[0].ID, nil
}
raw, err := promptOptional(in, "Target workspace id (optional; leave blank to auto-pick)")
if err != nil {
@@ -218,24 +237,36 @@ func promptTargetID(in *bufio.Reader, inv *discover.Inventory, to string) (strin
}
return strings.TrimSpace(raw), nil
}
- fmt.Printf("Multiple workspace entries point at %s:\n", to)
- for i, w := range matches {
- hint := ""
- if w.HeaderChats > 0 {
- hint = fmt.Sprintf(" (%d header chats)", w.HeaderChats)
- } else if !w.PathExists {
- hint = " (path missing)"
+
+ recommended := ""
+ if h := rebind.AssessPathHealth(inv, to, ""); h != nil && h.KeepID != "" {
+ recommended = h.KeepID
+ }
+
+ fmt.Printf("SPLIT-BRAIN: %d workspace entries for %s\n", len(pickFrom), to)
+ fmt.Println("Pick the shell Cursor opens (usually fewer named chats / the newer empty id).")
+ for i, w := range pickFrom {
+ ex := "no"
+ if w.PathExists {
+ ex = "yes"
+ }
+ mark := ""
+ if w.ID == recommended {
+ mark = " ← recommended keep"
}
- fmt.Printf(" %d) %s%s\n", i+1, w.ID, hint)
+ fmt.Printf(" %d) %s headers=%d exists=%s%s\n", i+1, w.ID, w.HeaderChats, ex, mark)
}
- fmt.Print("Pick a number (or paste a workspace id): ")
+ fmt.Print("Pick a number (or paste a workspace id; blank = recommended): ")
line, err := readLine(in)
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
- if n, err := strconv.Atoi(line); err == nil && n >= 1 && n <= len(matches) {
- return matches[n-1].ID, nil
+ if line == "" && recommended != "" {
+ return recommended, nil
+ }
+ if n, err := strconv.Atoi(line); err == nil && n >= 1 && n <= len(pickFrom) {
+ return pickFrom[n-1].ID, nil
}
return line, nil
}
@@ -252,6 +283,11 @@ func promptPath(in *bufio.Reader, label string) (string, error) {
fmt.Println("Path is required.")
continue
}
+ if paths.LooksLikeWorkspaceID(line) {
+ fmt.Println(paths.ErrWorkspaceIDAsPath("path", line))
+ fmt.Println("Enter the project folder path instead.")
+ continue
+ }
return absPath(expandHome(line)), nil
}
}
diff --git a/internal/discover/discover.go b/internal/discover/discover.go
index 2e1c1fa..abb38d9 100644
--- a/internal/discover/discover.go
+++ b/internal/discover/discover.go
@@ -256,10 +256,7 @@ func scanProjects(dir string) ([]AgentProject, error) {
func inferPathFromProjectName(name string) string {
// Skip ephemeral / numeric session folders.
- if name == "empty-window" || strings.HasPrefix(name, "tmp-") {
- return ""
- }
- if len(name) > 0 && name[0] >= '0' && name[0] <= '9' && !strings.Contains(name, "-") {
+ if IsEphemeralProjectName(name) {
return ""
}
// Reverse sanitization is lossy (can't recover which '-' were separators),
diff --git a/internal/discover/filter.go b/internal/discover/filter.go
new file mode 100644
index 0000000..cb79f12
--- /dev/null
+++ b/internal/discover/filter.go
@@ -0,0 +1,50 @@
+package discover
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+// FilterByPath keeps workspaces/projects related to absPath (exact folder match,
+// orphan siblings for that folder, and agent dirs whose inferred path matches).
+func FilterByPath(workspaces []Workspace, projects []AgentProject, absPath string) ([]Workspace, []AgentProject) {
+ want := filepath.Clean(absPath)
+ if want == "" || want == "." {
+ return workspaces, projects
+ }
+ var wsOut []Workspace
+ for _, w := range workspaces {
+ fp := filepath.Clean(w.FolderPath)
+ if fp == want || strings.HasPrefix(fp, want+".__rebind_orphan_") {
+ wsOut = append(wsOut, w)
+ }
+ }
+ var projOut []AgentProject
+ for _, p := range projects {
+ inf := filepath.Clean(p.InferredPath)
+ if inf == want || strings.HasPrefix(inf, want+string(filepath.Separator)) {
+ projOut = append(projOut, p)
+ continue
+ }
+ if projectNameLikelyPath(p.Name, want) {
+ projOut = append(projOut, p)
+ }
+ }
+ return wsOut, projOut
+}
+
+func projectNameLikelyPath(name, absPath string) bool {
+ if name == "" || absPath == "" {
+ return false
+ }
+ parts := strings.Split(strings.Trim(filepath.ToSlash(absPath), "/"), "/")
+ if len(parts) < 2 {
+ return false
+ }
+ slug := strings.Join(parts, "-")
+ if name == slug {
+ return true
+ }
+ last := parts[len(parts)-1]
+ return strings.HasSuffix(name, "-"+last) && strings.Contains(name, parts[0]+"-")
+}
diff --git a/internal/discover/stale.go b/internal/discover/stale.go
new file mode 100644
index 0000000..01e1b2d
--- /dev/null
+++ b/internal/discover/stale.go
@@ -0,0 +1,174 @@
+package discover
+
+import (
+ "path/filepath"
+ "strings"
+ "unicode"
+)
+
+// StaleKind explains why a scan row is considered noise for default display
+// and/or a safe prune candidate.
+type StaleKind string
+
+const (
+ StaleNone StaleKind = ""
+ StaleOrphanWorkspace StaleKind = "orphan-workspace"
+ StaleEmptyWindow StaleKind = "empty-window"
+ StaleEphemeralProject StaleKind = "ephemeral-project"
+ StaleEmptyAgentProject StaleKind = "empty-agent-project"
+)
+
+// IsOrphanFolderPath reports cursor-rebind's retired-workspace marker.
+func IsOrphanFolderPath(fp string) bool {
+ return strings.Contains(fp, ".__rebind_orphan_")
+}
+
+// IsEphemeralProjectName matches Cursor session / empty-window project slugs.
+func IsEphemeralProjectName(name string) bool {
+ if name == "" {
+ return false
+ }
+ if name == "empty-window" || strings.HasPrefix(name, "tmp-") {
+ return true
+ }
+ return isNumericSessionSlug(name)
+}
+
+func isNumericSessionSlug(name string) bool {
+ if name == "" || strings.Contains(name, "-") {
+ return false
+ }
+ for _, r := range name {
+ if !unicode.IsDigit(r) {
+ return false
+ }
+ }
+ return len(name) >= 10 // Cursor uses long epoch-ish ids
+}
+
+// ClassifyWorkspace returns why a workspace row is noise, if any.
+func ClassifyWorkspace(w Workspace) StaleKind {
+ if w.ID == "empty-window" {
+ return StaleEmptyWindow
+ }
+ if IsOrphanFolderPath(w.FolderPath) || IsOrphanFolderPath(w.FolderURI) {
+ return StaleOrphanWorkspace
+ }
+ return StaleNone
+}
+
+// ClassifyProject returns why an agent project row is noise, if any.
+// Ephemeral names are always stale. Non-ephemeral dirs with zero transcripts
+// are hidden from default scan but only pruned when also stub-empty on disk
+// (handled by prune, not classification alone).
+func ClassifyProject(p AgentProject) StaleKind {
+ if IsEphemeralProjectName(p.Name) {
+ return StaleEphemeralProject
+ }
+ if p.TranscriptCount == 0 {
+ return StaleEmptyAgentProject
+ }
+ return StaleNone
+}
+
+// FilterInventory returns a copy with stale rows removed when hideStale is true.
+// Counts of hidden workspaces/projects are returned for the scan footer.
+func FilterInventory(inv *Inventory, hideStale bool) (workspaces []Workspace, projects []AgentProject, hiddenWS, hiddenProj int) {
+ if inv == nil {
+ return nil, nil, 0, 0
+ }
+ if !hideStale {
+ return append([]Workspace(nil), inv.Workspaces...), append([]AgentProject(nil), inv.Projects...), 0, 0
+ }
+ for _, w := range inv.Workspaces {
+ if ClassifyWorkspace(w) != StaleNone {
+ hiddenWS++
+ continue
+ }
+ workspaces = append(workspaces, w)
+ }
+ for _, p := range inv.Projects {
+ if ClassifyProject(p) != StaleNone {
+ hiddenProj++
+ continue
+ }
+ projects = append(projects, p)
+ }
+ return workspaces, projects, hiddenWS, hiddenProj
+}
+
+// PruneCandidate is one path the prune command may delete.
+type PruneCandidate struct {
+ Kind StaleKind `json:"kind"`
+ Label string `json:"label"`
+ Path string `json:"path"`
+ Detail string `json:"detail,omitempty"`
+ Safe bool `json:"safe"` // true = default prune set
+}
+
+// CollectPruneCandidates lists safe default prune targets from an inventory:
+// - workspaceStorage dirs marked .__rebind_orphan_*
+// - ephemeral agent project dirs (tmp-*, empty-window, numeric) with 0 transcripts
+//
+// Empty non-ephemeral agent dirs and live missing-path workspaces are listed
+// only when includeEmptyProjects is true (still never deletes headers/chats).
+func CollectPruneCandidates(inv *Inventory, includeEmptyProjects bool) []PruneCandidate {
+ if inv == nil {
+ return nil
+ }
+ var out []PruneCandidate
+ wsRoot := inv.Roots.WorkspaceStorage
+ for _, w := range inv.Workspaces {
+ kind := ClassifyWorkspace(w)
+ switch kind {
+ case StaleOrphanWorkspace:
+ dir := filepath.Join(wsRoot, w.ID)
+ out = append(out, PruneCandidate{
+ Kind: kind,
+ Label: w.ID,
+ Path: dir,
+ Detail: w.FolderPath,
+ Safe: true,
+ })
+ case StaleEmptyWindow:
+ // empty-window is a synthetic id; only remove if a real dir exists.
+ dir := filepath.Join(wsRoot, w.ID)
+ out = append(out, PruneCandidate{
+ Kind: kind,
+ Label: w.ID,
+ Path: dir,
+ Detail: "empty-window workspaceStorage",
+ Safe: true,
+ })
+ }
+ }
+ for _, p := range inv.Projects {
+ kind := ClassifyProject(p)
+ switch kind {
+ case StaleEphemeralProject:
+ if p.TranscriptCount > 0 {
+ // Keep sessions that still have transcript files.
+ continue
+ }
+ out = append(out, PruneCandidate{
+ Kind: kind,
+ Label: p.Name,
+ Path: p.Dir,
+ Detail: "0 transcripts",
+ Safe: true,
+ })
+ case StaleEmptyAgentProject:
+ if !includeEmptyProjects {
+ continue
+ }
+ out = append(out, PruneCandidate{
+ Kind: kind,
+ Label: p.Name,
+ Path: p.Dir,
+ Detail: "0 transcripts (path-like slug)",
+ Safe: false,
+ })
+ }
+ }
+ return out
+}
diff --git a/internal/discover/stale_test.go b/internal/discover/stale_test.go
new file mode 100644
index 0000000..e7d2d94
--- /dev/null
+++ b/internal/discover/stale_test.go
@@ -0,0 +1,120 @@
+package discover
+
+import (
+ "testing"
+
+ "github.com/Interittus13/cursor-rebind/internal/paths"
+)
+
+func TestClassifyWorkspace(t *testing.T) {
+ cases := []struct {
+ w Workspace
+ want StaleKind
+ }{
+ {Workspace{ID: "abc", FolderPath: "/home/u/proj"}, StaleNone},
+ {Workspace{ID: "empty-window"}, StaleEmptyWindow},
+ {Workspace{ID: "dead", FolderPath: "/home/u/proj.__rebind_orphan_deadbeef"}, StaleOrphanWorkspace},
+ {Workspace{ID: "dead", FolderURI: "file:///x.__rebind_orphan_abcd"}, StaleOrphanWorkspace},
+ }
+ for _, tc := range cases {
+ if got := ClassifyWorkspace(tc.w); got != tc.want {
+ t.Fatalf("ClassifyWorkspace(%+v)=%q want %q", tc.w, got, tc.want)
+ }
+ }
+}
+
+func TestClassifyProject(t *testing.T) {
+ cases := []struct {
+ p AgentProject
+ want StaleKind
+ }{
+ {AgentProject{Name: "home-u-Documents-proj", TranscriptCount: 3}, StaleNone},
+ {AgentProject{Name: "tmp-abc-def", TranscriptCount: 0}, StaleEphemeralProject},
+ {AgentProject{Name: "empty-window", TranscriptCount: 0}, StaleEphemeralProject},
+ {AgentProject{Name: "1784008642728", TranscriptCount: 0}, StaleEphemeralProject},
+ {AgentProject{Name: "home-u-Documents-proj", TranscriptCount: 0}, StaleEmptyAgentProject},
+ }
+ for _, tc := range cases {
+ if got := ClassifyProject(tc.p); got != tc.want {
+ t.Fatalf("ClassifyProject(%+v)=%q want %q", tc.p, got, tc.want)
+ }
+ }
+}
+
+func TestFilterInventory(t *testing.T) {
+ inv := &Inventory{
+ Workspaces: []Workspace{
+ {ID: "keep", FolderPath: "/a", HeaderChats: 2},
+ {ID: "empty-window"},
+ {ID: "orph", FolderPath: "/a.__rebind_orphan_orph"},
+ },
+ Projects: []AgentProject{
+ {Name: "home-keep", TranscriptCount: 2},
+ {Name: "tmp-x", TranscriptCount: 0},
+ {Name: "home-empty", TranscriptCount: 0},
+ },
+ }
+ ws, proj, hw, hp := FilterInventory(inv, true)
+ if len(ws) != 1 || ws[0].ID != "keep" {
+ t.Fatalf("workspaces=%v", ws)
+ }
+ if len(proj) != 1 || proj[0].Name != "home-keep" {
+ t.Fatalf("projects=%v", proj)
+ }
+ if hw != 2 || hp != 2 {
+ t.Fatalf("hidden ws=%d proj=%d", hw, hp)
+ }
+ wsAll, projAll, _, _ := FilterInventory(inv, false)
+ if len(wsAll) != 3 || len(projAll) != 3 {
+ t.Fatalf("unfiltered sizes %d %d", len(wsAll), len(projAll))
+ }
+}
+
+func TestFilterByPath(t *testing.T) {
+ want := "/home/u/proj"
+ ws := []Workspace{
+ {ID: "a", FolderPath: want},
+ {ID: "b", FolderPath: want + ".__rebind_orphan_bbbbbbbb"},
+ {ID: "c", FolderPath: "/home/u/other"},
+ }
+ proj := []AgentProject{
+ {Name: "home-u-proj", InferredPath: want, TranscriptCount: 1},
+ {Name: "home-u-other", InferredPath: "/home/u/other", TranscriptCount: 1},
+ }
+ wsOut, projOut := FilterByPath(ws, proj, want)
+ if len(wsOut) != 2 {
+ t.Fatalf("workspaces=%d", len(wsOut))
+ }
+ if len(projOut) != 1 || projOut[0].Name != "home-u-proj" {
+ t.Fatalf("projects=%v", projOut)
+ }
+}
+
+func TestCollectPruneCandidates(t *testing.T) {
+ inv := &Inventory{
+ Roots: paths.Roots{WorkspaceStorage: "/ws", ProjectsDir: "/proj"},
+ Workspaces: []Workspace{
+ {ID: "live", FolderPath: "/home/u/a"},
+ {ID: "orph1", FolderPath: "/home/u/a.__rebind_orphan_orph1"},
+ {ID: "empty-window"},
+ },
+ Projects: []AgentProject{
+ {Name: "tmp-dead", Dir: "/proj/tmp-dead", TranscriptCount: 0},
+ {Name: "tmp-alive", Dir: "/proj/tmp-alive", TranscriptCount: 2},
+ {Name: "home-u-old", Dir: "/proj/home-u-old", TranscriptCount: 0},
+ },
+ }
+ cands := CollectPruneCandidates(inv, false)
+ if len(cands) != 3 { // orph1, empty-window, tmp-dead
+ t.Fatalf("safe cands=%d %#v", len(cands), cands)
+ }
+ for _, c := range cands {
+ if !c.Safe {
+ t.Fatalf("unexpected unsafe %#v", c)
+ }
+ }
+ cands = CollectPruneCandidates(inv, true)
+ if len(cands) != 4 {
+ t.Fatalf("with empty projects cands=%d", len(cands))
+ }
+}
diff --git a/internal/paths/workspace_id.go b/internal/paths/workspace_id.go
new file mode 100644
index 0000000..7fde701
--- /dev/null
+++ b/internal/paths/workspace_id.go
@@ -0,0 +1,43 @@
+package paths
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+)
+
+// LooksLikeWorkspaceID reports whether s is (or ends with) a Cursor
+// workspaceStorage hash: 32 lowercase hex chars. Users sometimes paste that
+// into --from/--to instead of a project folder path.
+func LooksLikeWorkspaceID(s string) bool {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return false
+ }
+ // Raw id or basename after accidental abs-path expansion.
+ base := filepath.Base(filepath.Clean(s))
+ return isWorkspaceHash(base)
+}
+
+func isWorkspaceHash(s string) bool {
+ if len(s) != 32 {
+ return false
+ }
+ for _, r := range s {
+ if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
+ return false
+ }
+ }
+ return true
+}
+
+// ErrWorkspaceIDAsPath is returned when --from/--to looks like a workspace id.
+func ErrWorkspaceIDAsPath(flag, value string) error {
+ id := filepath.Base(filepath.Clean(strings.TrimSpace(value)))
+ return fmt.Errorf(
+ "%s %q looks like a workspace id, not a folder path\n"+
+ " Use the project directory for --from/--to, and pass the id with --target-id:\n"+
+ " cursor-rebind repair --to /path/to/project --target-id %s --yes",
+ flag, value, id,
+ )
+}
diff --git a/internal/paths/workspace_id_test.go b/internal/paths/workspace_id_test.go
new file mode 100644
index 0000000..9b48ed4
--- /dev/null
+++ b/internal/paths/workspace_id_test.go
@@ -0,0 +1,25 @@
+package paths
+
+import "testing"
+
+func TestLooksLikeWorkspaceID(t *testing.T) {
+ cases := []struct {
+ in string
+ want bool
+ }{
+ {"44847637afdcf3dc29f818063cd7cd8c", true},
+ {"44847637AFDCF3DC29F818063CD7CD8C", true},
+ {"/home/ulap92/44847637afdcf3dc29f818063cd7cd8c", true},
+ {"/home/ulap92/Documents/Arpit/Projects/PledgeBall", false},
+ {"1f6b9b2ad3ed61f81bd55495f041a05e", true},
+ {"short", false},
+ {"44847637afdcf3dc29f818063cd7cd8c0", false}, // 33
+ {"gg847637afdcf3dc29f818063cd7cd8c", false},
+ {"", false},
+ }
+ for _, tc := range cases {
+ if got := LooksLikeWorkspaceID(tc.in); got != tc.want {
+ t.Fatalf("LooksLikeWorkspaceID(%q)=%v want %v", tc.in, got, tc.want)
+ }
+ }
+}
diff --git a/internal/rebind/prune.go b/internal/rebind/prune.go
new file mode 100644
index 0000000..08ee532
--- /dev/null
+++ b/internal/rebind/prune.go
@@ -0,0 +1,68 @@
+package rebind
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/Interittus13/cursor-rebind/internal/backup"
+ "github.com/Interittus13/cursor-rebind/internal/discover"
+ "github.com/Interittus13/cursor-rebind/internal/guard"
+)
+
+// PruneResult summarizes a prune dry-run or apply.
+type PruneResult struct {
+ Candidates []discover.PruneCandidate
+ Removed []string
+ BackupID string
+ DryRun bool
+}
+
+// PruneStale deletes safe stale Cursor storage leftovers (orphaned workspaceStorage
+// dirs and empty ephemeral ~/.cursor/projects slugs). Always backs up trees when
+// applying. Does not touch global chat headers or project source folders.
+func PruneStale(inv *discover.Inventory, includeEmptyProjects, dryRun bool) (*PruneResult, error) {
+ if inv == nil {
+ return nil, fmt.Errorf("nil inventory")
+ }
+ cands := discover.CollectPruneCandidates(inv, includeEmptyProjects)
+ res := &PruneResult{Candidates: cands, DryRun: dryRun}
+ if dryRun || len(cands) == 0 {
+ return res, nil
+ }
+ if err := guard.EnsureCursorClosed(); err != nil {
+ return nil, err
+ }
+
+ id, absDir, man, err := backup.Create("prune stale Cursor storage")
+ if err != nil {
+ return nil, err
+ }
+ res.BackupID = id
+
+ for _, c := range cands {
+ if _, err := os.Stat(c.Path); err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ return res, err
+ }
+ var logical string
+ if c.Kind == discover.StaleOrphanWorkspace || c.Kind == discover.StaleEmptyWindow {
+ logical = filepath.Join("prune", "workspaceStorage", c.Label)
+ } else {
+ logical = filepath.Join("prune", "projects", c.Label)
+ }
+ if err := backup.CopyTree(absDir, man, logical, c.Path); err != nil {
+ return res, fmt.Errorf("backup %s: %w", c.Path, err)
+ }
+ if err := os.RemoveAll(c.Path); err != nil {
+ return res, fmt.Errorf("remove %s: %w", c.Path, err)
+ }
+ res.Removed = append(res.Removed, c.Path)
+ }
+ if err := backup.WriteManifest(absDir, man); err != nil {
+ return res, err
+ }
+ return res, nil
+}
diff --git a/internal/rebind/prune_test.go b/internal/rebind/prune_test.go
new file mode 100644
index 0000000..3c4fe34
--- /dev/null
+++ b/internal/rebind/prune_test.go
@@ -0,0 +1,98 @@
+package rebind
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/Interittus13/cursor-rebind/internal/discover"
+ "github.com/Interittus13/cursor-rebind/internal/paths"
+)
+
+func TestPruneStaleDryRunAndApply(t *testing.T) {
+ root := t.TempDir()
+ wsRoot := filepath.Join(root, "workspaceStorage")
+ projRoot := filepath.Join(root, "projects")
+ if err := os.MkdirAll(wsRoot, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(projRoot, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ orphID := "abcdef0123456789abcdef0123456789"
+ orphDir := filepath.Join(wsRoot, orphID)
+ if err := os.MkdirAll(orphDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ meta, _ := json.Marshal(map[string]string{
+ "folder": "file:///home/u/proj.__rebind_orphan_abcdef01",
+ })
+ if err := os.WriteFile(filepath.Join(orphDir, "workspace.json"), meta, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(orphDir, "marker.txt"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ tmpName := "tmp-dead-session"
+ tmpDir := filepath.Join(projRoot, tmpName)
+ if err := os.MkdirAll(tmpDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ keepID := "11111111111111111111111111111111"
+ keepDir := filepath.Join(wsRoot, keepID)
+ if err := os.MkdirAll(keepDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ keepMeta, _ := json.Marshal(map[string]string{"folder": "file:///home/u/proj"})
+ if err := os.WriteFile(filepath.Join(keepDir, "workspace.json"), keepMeta, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ inv := &discover.Inventory{
+ Roots: paths.Roots{WorkspaceStorage: wsRoot, ProjectsDir: projRoot},
+ Workspaces: []discover.Workspace{
+ {ID: orphID, FolderPath: "/home/u/proj.__rebind_orphan_abcdef01"},
+ {ID: keepID, FolderPath: "/home/u/proj", PathExists: true},
+ },
+ Projects: []discover.AgentProject{
+ {Name: tmpName, Dir: tmpDir, TranscriptCount: 0},
+ },
+ }
+
+ dry, err := PruneStale(inv, false, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(dry.Candidates) != 2 {
+ t.Fatalf("candidates=%d", len(dry.Candidates))
+ }
+ if _, err := os.Stat(orphDir); err != nil {
+ t.Fatal("dry-run must not delete")
+ }
+
+ // Apply without Cursor guard: ensure no Cursor detection falsely blocks in CI.
+ // If Cursor is running locally this may fail — skip apply then.
+ res, err := PruneStale(inv, false, false)
+ if err != nil {
+ t.Skipf("prune apply skipped (Cursor running or backup issue): %v", err)
+ }
+ if len(res.Removed) != 2 {
+ t.Fatalf("removed=%v", res.Removed)
+ }
+ if _, err := os.Stat(orphDir); !os.IsNotExist(err) {
+ t.Fatal("orphan workspace should be gone")
+ }
+ if _, err := os.Stat(tmpDir); !os.IsNotExist(err) {
+ t.Fatal("tmp project should be gone")
+ }
+ if _, err := os.Stat(keepDir); err != nil {
+ t.Fatal("live workspace must remain")
+ }
+ if res.BackupID == "" {
+ t.Fatal("expected backup id")
+ }
+}