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") + } +}