spawnpoint 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spawnpoint-0.1.0/.gitignore +8 -0
- spawnpoint-0.1.0/LICENSE +21 -0
- spawnpoint-0.1.0/PKG-INFO +122 -0
- spawnpoint-0.1.0/README.md +99 -0
- spawnpoint-0.1.0/clean.sh +3 -0
- spawnpoint-0.1.0/cleanup.py +323 -0
- spawnpoint-0.1.0/excalidraw.log +12 -0
- spawnpoint-0.1.0/install.sh +88 -0
- spawnpoint-0.1.0/main.py +348 -0
- spawnpoint-0.1.0/plans/feat-open-source-spawnpoint-cli.md +404 -0
- spawnpoint-0.1.0/pyproject.toml +39 -0
- spawnpoint-0.1.0/requirements.txt +3 -0
- spawnpoint-0.1.0/run.sh +3 -0
- spawnpoint-0.1.0/src/spawnpoint/__init__.py +3 -0
- spawnpoint-0.1.0/src/spawnpoint/cleanup.py +283 -0
- spawnpoint-0.1.0/src/spawnpoint/cli.py +200 -0
- spawnpoint-0.1.0/src/spawnpoint/config.py +121 -0
- spawnpoint-0.1.0/src/spawnpoint/create.py +211 -0
- spawnpoint-0.1.0/src/spawnpoint/utils.py +169 -0
spawnpoint-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mihir Gupta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spawnpoint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spawn multi-repo worktree workspaces for feature development
|
|
5
|
+
Project-URL: Homepage, https://github.com/mihirgupta0900/spawnpoint
|
|
6
|
+
Project-URL: Repository, https://github.com/mihirgupta0900/spawnpoint
|
|
7
|
+
Project-URL: Issues, https://github.com/mihirgupta0900/spawnpoint/issues
|
|
8
|
+
Author-email: Mihir Gupta <mihirgupta0900@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: developer-tools,git,monorepo,workspace,worktree
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: inquirerpy>=0.3
|
|
20
|
+
Requires-Dist: rich>=13
|
|
21
|
+
Requires-Dist: typer>=0.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Spawnpoint
|
|
25
|
+
|
|
26
|
+
Spawn multi-repo worktree workspaces for feature development.
|
|
27
|
+
|
|
28
|
+
Working on a feature that spans multiple repos? Spawnpoint creates a dedicated folder with git worktrees from each repo on the same branch, installs dependencies, and copies over config files — so you can start coding (or start a Claude session) immediately.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
curl -fsSL https://raw.githubusercontent.com/mihirgupta0900/spawnpoint/main/install.sh | sh
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or with pipx:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
pipx install spawnpoint
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or with pip:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
pip install spawnpoint
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
spawnpoint create # select repos, name a branch, spawn worktrees
|
|
52
|
+
spawnpoint cleanup # select and remove worktree workspaces
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
On first run, Spawnpoint will ask you to configure your scan directories and workspace location.
|
|
56
|
+
|
|
57
|
+
## How It Works
|
|
58
|
+
|
|
59
|
+
1. **Select repos** — Spawnpoint scans your code directories and presents a fuzzy-searchable list of git repos
|
|
60
|
+
2. **Name a branch** — Enter a branch name for your feature
|
|
61
|
+
3. **Spawn** — For each repo, Spawnpoint:
|
|
62
|
+
- Creates a git worktree (or new branch if needed)
|
|
63
|
+
- Initializes submodules
|
|
64
|
+
- Copies `.env` files, `CLAUDE.md`, and other config files from the original repo
|
|
65
|
+
- Installs dependencies (detects npm/pnpm/yarn/bun, pip/uv/poetry, bundler, go modules)
|
|
66
|
+
|
|
67
|
+
All worktrees land in a single folder (`~/.spawnpoint/workspaces/<branch-name>/`) so you can open the whole workspace in your editor or start an AI coding session.
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
| Command | Description |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `spawnpoint create` | Spawn worktree workspaces |
|
|
74
|
+
| `spawnpoint cleanup` | Remove worktree workspaces |
|
|
75
|
+
| `spawnpoint init` | Run interactive setup |
|
|
76
|
+
| `spawnpoint config` | View current config |
|
|
77
|
+
| `spawnpoint config --edit` | Edit config in $EDITOR |
|
|
78
|
+
| `spawnpoint config --reset` | Reset to defaults |
|
|
79
|
+
| `spawnpoint update` | Update to latest version |
|
|
80
|
+
| `spawnpoint --version` | Show version |
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
Config lives at `~/.spawnpoint/config.toml`:
|
|
85
|
+
|
|
86
|
+
```toml
|
|
87
|
+
# Directories to scan for git repos
|
|
88
|
+
scan_dirs = ['~/code', '~/projects']
|
|
89
|
+
|
|
90
|
+
# Where workspaces are created
|
|
91
|
+
worktree_dir = '~/.spawnpoint/workspaces'
|
|
92
|
+
|
|
93
|
+
# How deep to scan for repos (1-4)
|
|
94
|
+
scan_depth = 2
|
|
95
|
+
|
|
96
|
+
# Files/dirs to copy into new worktrees
|
|
97
|
+
copy_patterns_globs = ['.env*']
|
|
98
|
+
copy_patterns_files = ['AGENT.md', 'CLAUDE.md', 'GEMINI.md']
|
|
99
|
+
copy_patterns_dirs = ['.vscode', 'docs']
|
|
100
|
+
|
|
101
|
+
# Branch priority for base branch selection
|
|
102
|
+
branch_priority = ['development', 'staging', 'main', 'master']
|
|
103
|
+
|
|
104
|
+
# Auto-install dependencies after worktree creation
|
|
105
|
+
auto_install_deps = true
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Requirements
|
|
109
|
+
|
|
110
|
+
- Python 3.10+
|
|
111
|
+
- git
|
|
112
|
+
|
|
113
|
+
## Uninstall
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
pipx uninstall spawnpoint
|
|
117
|
+
rm -rf ~/.spawnpoint
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Spawnpoint
|
|
2
|
+
|
|
3
|
+
Spawn multi-repo worktree workspaces for feature development.
|
|
4
|
+
|
|
5
|
+
Working on a feature that spans multiple repos? Spawnpoint creates a dedicated folder with git worktrees from each repo on the same branch, installs dependencies, and copies over config files — so you can start coding (or start a Claude session) immediately.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
curl -fsSL https://raw.githubusercontent.com/mihirgupta0900/spawnpoint/main/install.sh | sh
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or with pipx:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
pipx install spawnpoint
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or with pip:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
pip install spawnpoint
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
spawnpoint create # select repos, name a branch, spawn worktrees
|
|
29
|
+
spawnpoint cleanup # select and remove worktree workspaces
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
On first run, Spawnpoint will ask you to configure your scan directories and workspace location.
|
|
33
|
+
|
|
34
|
+
## How It Works
|
|
35
|
+
|
|
36
|
+
1. **Select repos** — Spawnpoint scans your code directories and presents a fuzzy-searchable list of git repos
|
|
37
|
+
2. **Name a branch** — Enter a branch name for your feature
|
|
38
|
+
3. **Spawn** — For each repo, Spawnpoint:
|
|
39
|
+
- Creates a git worktree (or new branch if needed)
|
|
40
|
+
- Initializes submodules
|
|
41
|
+
- Copies `.env` files, `CLAUDE.md`, and other config files from the original repo
|
|
42
|
+
- Installs dependencies (detects npm/pnpm/yarn/bun, pip/uv/poetry, bundler, go modules)
|
|
43
|
+
|
|
44
|
+
All worktrees land in a single folder (`~/.spawnpoint/workspaces/<branch-name>/`) so you can open the whole workspace in your editor or start an AI coding session.
|
|
45
|
+
|
|
46
|
+
## Commands
|
|
47
|
+
|
|
48
|
+
| Command | Description |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `spawnpoint create` | Spawn worktree workspaces |
|
|
51
|
+
| `spawnpoint cleanup` | Remove worktree workspaces |
|
|
52
|
+
| `spawnpoint init` | Run interactive setup |
|
|
53
|
+
| `spawnpoint config` | View current config |
|
|
54
|
+
| `spawnpoint config --edit` | Edit config in $EDITOR |
|
|
55
|
+
| `spawnpoint config --reset` | Reset to defaults |
|
|
56
|
+
| `spawnpoint update` | Update to latest version |
|
|
57
|
+
| `spawnpoint --version` | Show version |
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
Config lives at `~/.spawnpoint/config.toml`:
|
|
62
|
+
|
|
63
|
+
```toml
|
|
64
|
+
# Directories to scan for git repos
|
|
65
|
+
scan_dirs = ['~/code', '~/projects']
|
|
66
|
+
|
|
67
|
+
# Where workspaces are created
|
|
68
|
+
worktree_dir = '~/.spawnpoint/workspaces'
|
|
69
|
+
|
|
70
|
+
# How deep to scan for repos (1-4)
|
|
71
|
+
scan_depth = 2
|
|
72
|
+
|
|
73
|
+
# Files/dirs to copy into new worktrees
|
|
74
|
+
copy_patterns_globs = ['.env*']
|
|
75
|
+
copy_patterns_files = ['AGENT.md', 'CLAUDE.md', 'GEMINI.md']
|
|
76
|
+
copy_patterns_dirs = ['.vscode', 'docs']
|
|
77
|
+
|
|
78
|
+
# Branch priority for base branch selection
|
|
79
|
+
branch_priority = ['development', 'staging', 'main', 'master']
|
|
80
|
+
|
|
81
|
+
# Auto-install dependencies after worktree creation
|
|
82
|
+
auto_install_deps = true
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Requirements
|
|
86
|
+
|
|
87
|
+
- Python 3.10+
|
|
88
|
+
- git
|
|
89
|
+
|
|
90
|
+
## Uninstall
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
pipx uninstall spawnpoint
|
|
94
|
+
rm -rf ~/.spawnpoint
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from InquirerPy import inquirer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import track
|
|
12
|
+
|
|
13
|
+
app = typer.Typer()
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
HOME = Path.home()
|
|
17
|
+
WORK_DIRS = [
|
|
18
|
+
HOME / "code" / "work" / "worktrees",
|
|
19
|
+
HOME / "code" / "work",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class WorktreeInfo:
|
|
25
|
+
worktree_path: Path
|
|
26
|
+
parent_repo_path: Optional[Path]
|
|
27
|
+
branch_name: str
|
|
28
|
+
is_dirty: bool
|
|
29
|
+
last_modified: datetime
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class BranchFolder:
|
|
34
|
+
path: Path
|
|
35
|
+
name: str
|
|
36
|
+
worktrees: List[WorktreeInfo] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def oldest_modified(self) -> datetime:
|
|
40
|
+
if not self.worktrees:
|
|
41
|
+
return datetime.fromtimestamp(self.path.stat().st_mtime, tz=timezone.utc)
|
|
42
|
+
return min(w.last_modified for w in self.worktrees)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def any_dirty(self) -> bool:
|
|
46
|
+
return any(w.is_dirty for w in self.worktrees)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_git_file(path: Path) -> Optional[Path]:
|
|
50
|
+
"""Parse a .git file to extract the gitdir path and resolve the parent repo."""
|
|
51
|
+
git_path = path / ".git"
|
|
52
|
+
if not git_path.is_file():
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
content = git_path.read_text().strip()
|
|
56
|
+
if not content.startswith("gitdir:"):
|
|
57
|
+
return None
|
|
58
|
+
gitdir = Path(content.split("gitdir:", 1)[1].strip())
|
|
59
|
+
if not gitdir.is_absolute():
|
|
60
|
+
gitdir = (path / gitdir).resolve()
|
|
61
|
+
# gitdir is like /path/to/repo/.git/worktrees/<name>
|
|
62
|
+
# Walk up 3 levels: <name> -> worktrees -> .git -> repo
|
|
63
|
+
parent_repo = gitdir.parent.parent.parent
|
|
64
|
+
if (parent_repo / ".git").exists():
|
|
65
|
+
return parent_repo
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_branch_name(path: Path) -> str:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
74
|
+
cwd=path, capture_output=True, text=True,
|
|
75
|
+
)
|
|
76
|
+
return result.stdout.strip() if result.returncode == 0 else "unknown"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_dirty(path: Path) -> bool:
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
["git", "status", "--porcelain"],
|
|
82
|
+
cwd=path, capture_output=True, text=True,
|
|
83
|
+
)
|
|
84
|
+
return bool(result.stdout.strip()) if result.returncode == 0 else False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_mtime(path: Path) -> datetime:
|
|
88
|
+
return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scan_worktree(path: Path) -> Optional[WorktreeInfo]:
|
|
92
|
+
"""Scan a single directory that has a .git file (worktree)."""
|
|
93
|
+
parent_repo = parse_git_file(path)
|
|
94
|
+
if parent_repo is None:
|
|
95
|
+
# .git file broken or not a worktree
|
|
96
|
+
return None
|
|
97
|
+
return WorktreeInfo(
|
|
98
|
+
worktree_path=path,
|
|
99
|
+
parent_repo_path=parent_repo,
|
|
100
|
+
branch_name=get_branch_name(path),
|
|
101
|
+
is_dirty=is_dirty(path),
|
|
102
|
+
last_modified=get_mtime(path),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def scan_single_dir(work_dir: Path) -> List[BranchFolder]:
|
|
107
|
+
"""Scan a single work directory for branch folders containing worktrees."""
|
|
108
|
+
if not work_dir.exists():
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
folders: List[BranchFolder] = []
|
|
112
|
+
seen_paths: set[Path] = set()
|
|
113
|
+
|
|
114
|
+
for entry in sorted(work_dir.iterdir()):
|
|
115
|
+
if not entry.is_dir():
|
|
116
|
+
continue
|
|
117
|
+
resolved = entry.resolve()
|
|
118
|
+
if resolved in seen_paths:
|
|
119
|
+
continue
|
|
120
|
+
seen_paths.add(resolved)
|
|
121
|
+
|
|
122
|
+
bf = BranchFolder(path=entry, name=entry.name)
|
|
123
|
+
|
|
124
|
+
git_file = entry / ".git"
|
|
125
|
+
if git_file.is_file():
|
|
126
|
+
# Single-repo worktree
|
|
127
|
+
wt = scan_worktree(entry)
|
|
128
|
+
if wt:
|
|
129
|
+
bf.worktrees.append(wt)
|
|
130
|
+
else:
|
|
131
|
+
# Multi-repo: check subdirectories
|
|
132
|
+
for sub in sorted(entry.iterdir()):
|
|
133
|
+
if sub.is_dir() and (sub / ".git").is_file():
|
|
134
|
+
wt = scan_worktree(sub)
|
|
135
|
+
if wt:
|
|
136
|
+
bf.worktrees.append(wt)
|
|
137
|
+
|
|
138
|
+
if bf.worktrees:
|
|
139
|
+
folders.append(bf)
|
|
140
|
+
|
|
141
|
+
return folders
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def scan_branch_folders() -> List[BranchFolder]:
|
|
145
|
+
"""Scan all WORK_DIRS for branch folders containing worktrees."""
|
|
146
|
+
all_folders: List[BranchFolder] = []
|
|
147
|
+
seen_paths: set[Path] = set()
|
|
148
|
+
|
|
149
|
+
for work_dir in WORK_DIRS:
|
|
150
|
+
for bf in scan_single_dir(work_dir):
|
|
151
|
+
resolved = bf.path.resolve()
|
|
152
|
+
if resolved in seen_paths:
|
|
153
|
+
continue
|
|
154
|
+
seen_paths.add(resolved)
|
|
155
|
+
all_folders.append(bf)
|
|
156
|
+
|
|
157
|
+
return all_folders
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def format_age(dt: datetime) -> str:
|
|
161
|
+
delta = datetime.now(tz=timezone.utc) - dt
|
|
162
|
+
days = delta.days
|
|
163
|
+
if days == 0:
|
|
164
|
+
hours = delta.seconds // 3600
|
|
165
|
+
return f"{hours}h ago" if hours > 0 else "just now"
|
|
166
|
+
if days < 30:
|
|
167
|
+
return f"{days}d ago"
|
|
168
|
+
return f"{days // 30}mo ago"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def format_choice(bf: BranchFolder) -> str:
|
|
172
|
+
repo_count = len(bf.worktrees)
|
|
173
|
+
dirty_str = "dirty" if bf.any_dirty else "clean"
|
|
174
|
+
age = format_age(bf.oldest_modified)
|
|
175
|
+
repo_label = "repo" if repo_count == 1 else "repos"
|
|
176
|
+
parent_dir = bf.path.parent.name
|
|
177
|
+
return f"{bf.name} ({parent_dir}/, {repo_count} {repo_label}, {dirty_str}, {age})"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def remove_worktree(wt: WorktreeInfo, delete_branch: bool):
|
|
181
|
+
"""Remove a single worktree and optionally its branch."""
|
|
182
|
+
parent = wt.parent_repo_path
|
|
183
|
+
|
|
184
|
+
if parent is None or not parent.exists():
|
|
185
|
+
console.print(f" [yellow]Parent repo gone, removing directory: {wt.worktree_path}[/yellow]")
|
|
186
|
+
shutil.rmtree(wt.worktree_path, ignore_errors=True)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
# git worktree remove
|
|
190
|
+
cmd = ["git", "worktree", "remove", str(wt.worktree_path)]
|
|
191
|
+
if wt.is_dirty:
|
|
192
|
+
cmd.append("--force")
|
|
193
|
+
console.print(f" [yellow]Force-removing dirty worktree:[/yellow] {wt.worktree_path.name}")
|
|
194
|
+
else:
|
|
195
|
+
console.print(f" Removing worktree: {wt.worktree_path.name}")
|
|
196
|
+
|
|
197
|
+
result = subprocess.run(cmd, cwd=parent, capture_output=True, text=True)
|
|
198
|
+
if result.returncode != 0:
|
|
199
|
+
err = result.stderr.strip()
|
|
200
|
+
console.print(f" [red]worktree remove failed: {err}[/red]")
|
|
201
|
+
# Fallback: remove directory
|
|
202
|
+
if wt.worktree_path.exists():
|
|
203
|
+
console.print(f" [yellow]Falling back to rmtree[/yellow]")
|
|
204
|
+
shutil.rmtree(wt.worktree_path, ignore_errors=True)
|
|
205
|
+
|
|
206
|
+
# Delete branch if requested
|
|
207
|
+
if delete_branch and wt.branch_name not in ("unknown", "HEAD"):
|
|
208
|
+
result = subprocess.run(
|
|
209
|
+
["git", "branch", "-d", wt.branch_name],
|
|
210
|
+
cwd=parent, capture_output=True, text=True,
|
|
211
|
+
)
|
|
212
|
+
if result.returncode != 0:
|
|
213
|
+
err = result.stderr.strip()
|
|
214
|
+
if "not fully merged" in err:
|
|
215
|
+
console.print(f" [yellow]Branch '{wt.branch_name}' not merged, force-deleting[/yellow]")
|
|
216
|
+
subprocess.run(
|
|
217
|
+
["git", "branch", "-D", wt.branch_name],
|
|
218
|
+
cwd=parent, capture_output=True, text=True,
|
|
219
|
+
)
|
|
220
|
+
elif "not found" not in err:
|
|
221
|
+
console.print(f" [dim]Branch delete skipped: {err}[/dim]")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.command()
|
|
225
|
+
def main():
|
|
226
|
+
"""Remove worktree workspaces created by the setup script."""
|
|
227
|
+
existing_dirs = [d for d in WORK_DIRS if d.exists()]
|
|
228
|
+
if not existing_dirs:
|
|
229
|
+
console.print(f"[yellow]No worktree directories found[/yellow]")
|
|
230
|
+
raise typer.Exit()
|
|
231
|
+
|
|
232
|
+
dirs_str = ", ".join(str(d) for d in existing_dirs)
|
|
233
|
+
console.print(f"[bold blue]Scanning for worktree workspaces...[/bold blue]")
|
|
234
|
+
console.print(f"[dim]Directories: {dirs_str}[/dim]")
|
|
235
|
+
folders = scan_branch_folders()
|
|
236
|
+
|
|
237
|
+
if not folders:
|
|
238
|
+
console.print("[yellow]No worktree workspaces found.[/yellow]")
|
|
239
|
+
raise typer.Exit()
|
|
240
|
+
|
|
241
|
+
# Sort oldest-first
|
|
242
|
+
folders.sort(key=lambda bf: bf.oldest_modified)
|
|
243
|
+
|
|
244
|
+
# Build lookup
|
|
245
|
+
folder_map = {format_choice(bf): bf for bf in folders}
|
|
246
|
+
|
|
247
|
+
selected_labels = inquirer.fuzzy(
|
|
248
|
+
message="Select workspaces to remove (Type to search):",
|
|
249
|
+
choices=list(folder_map.keys()),
|
|
250
|
+
multiselect=True,
|
|
251
|
+
).execute()
|
|
252
|
+
|
|
253
|
+
if not selected_labels:
|
|
254
|
+
console.print("No workspaces selected. Exiting.")
|
|
255
|
+
raise typer.Exit()
|
|
256
|
+
|
|
257
|
+
selected = [folder_map[label] for label in selected_labels]
|
|
258
|
+
|
|
259
|
+
# Branch deletion preference
|
|
260
|
+
branch_pref = inquirer.select(
|
|
261
|
+
message="Delete branches from parent repos?",
|
|
262
|
+
choices=["Delete all branches", "Keep branches", "Ask per branch"],
|
|
263
|
+
).execute()
|
|
264
|
+
|
|
265
|
+
# Show plan
|
|
266
|
+
console.print(f"\n[bold]Plan:[/bold]")
|
|
267
|
+
for bf in selected:
|
|
268
|
+
console.print(f"\n [bold]{bf.name}/[/bold]")
|
|
269
|
+
for wt in bf.worktrees:
|
|
270
|
+
dirty_tag = " [yellow](dirty — will force)[/yellow]" if wt.is_dirty else ""
|
|
271
|
+
parent_label = wt.parent_repo_path.name if wt.parent_repo_path else "unknown"
|
|
272
|
+
console.print(f" {parent_label}: git worktree remove{dirty_tag}")
|
|
273
|
+
if branch_pref == "Delete all branches":
|
|
274
|
+
console.print(f" {parent_label}: git branch -d {wt.branch_name}")
|
|
275
|
+
elif branch_pref == "Ask per branch":
|
|
276
|
+
console.print(f" {parent_label}: [dim]will ask about branch '{wt.branch_name}'[/dim]")
|
|
277
|
+
|
|
278
|
+
console.print("")
|
|
279
|
+
if not inquirer.confirm(message="Proceed with cleanup?").execute():
|
|
280
|
+
console.print("Aborted.")
|
|
281
|
+
raise typer.Exit()
|
|
282
|
+
|
|
283
|
+
# Execute
|
|
284
|
+
parent_repos_seen: set[Path] = set()
|
|
285
|
+
|
|
286
|
+
for bf in track(selected, description="Cleaning up..."):
|
|
287
|
+
# Per-branch "ask" decisions
|
|
288
|
+
branch_decisions: dict[str, bool] = {}
|
|
289
|
+
|
|
290
|
+
for wt in bf.worktrees:
|
|
291
|
+
delete_branch = False
|
|
292
|
+
if branch_pref == "Delete all branches":
|
|
293
|
+
delete_branch = True
|
|
294
|
+
elif branch_pref == "Ask per branch":
|
|
295
|
+
key = f"{wt.parent_repo_path}:{wt.branch_name}"
|
|
296
|
+
if key not in branch_decisions:
|
|
297
|
+
branch_decisions[key] = inquirer.confirm(
|
|
298
|
+
message=f"Delete branch '{wt.branch_name}' from {wt.parent_repo_path.name}?",
|
|
299
|
+
default=True,
|
|
300
|
+
).execute()
|
|
301
|
+
delete_branch = branch_decisions[key]
|
|
302
|
+
|
|
303
|
+
remove_worktree(wt, delete_branch)
|
|
304
|
+
|
|
305
|
+
if wt.parent_repo_path and wt.parent_repo_path.exists():
|
|
306
|
+
parent_repos_seen.add(wt.parent_repo_path)
|
|
307
|
+
|
|
308
|
+
# Remove any leftover non-worktree dirs and the branch folder itself
|
|
309
|
+
if bf.path.exists():
|
|
310
|
+
shutil.rmtree(bf.path, ignore_errors=True)
|
|
311
|
+
|
|
312
|
+
# Prune all affected parent repos
|
|
313
|
+
for repo in parent_repos_seen:
|
|
314
|
+
subprocess.run(
|
|
315
|
+
["git", "worktree", "prune"],
|
|
316
|
+
cwd=repo, capture_output=True,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
console.print("\n[bold green]Done![/bold green]")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
app()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
2026-02-26 00:01:20.082 [info] Starting Excalidraw MCP server...
|
|
2
|
+
2026-02-26 00:01:20.085 [debug] Connecting to stdio transport...
|
|
3
|
+
2026-02-26 00:01:20.086 [info] Excalidraw MCP server running on stdio
|
|
4
|
+
2026-02-26 00:01:20.250 [info] Listing available tools
|
|
5
|
+
2026-02-27 23:50:12.254 [info] Starting Excalidraw MCP server...
|
|
6
|
+
2026-02-27 23:50:12.255 [debug] Connecting to stdio transport...
|
|
7
|
+
2026-02-27 23:50:12.256 [info] Excalidraw MCP server running on stdio
|
|
8
|
+
2026-02-27 23:50:12.569 [info] Listing available tools
|
|
9
|
+
2026-02-28 00:06:17.621 [info] Starting Excalidraw MCP server...
|
|
10
|
+
2026-02-28 00:06:17.623 [debug] Connecting to stdio transport...
|
|
11
|
+
2026-02-28 00:06:17.623 [info] Excalidraw MCP server running on stdio
|
|
12
|
+
2026-02-28 00:06:17.754 [info] Listing available tools
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Spawnpoint installer
|
|
5
|
+
# Usage: curl -fsSL https://raw.githubusercontent.com/mihirgupta0900/spawnpoint/main/install.sh | sh
|
|
6
|
+
|
|
7
|
+
log() { printf "\033[0;32m=>\033[0m %s\n" "$1" >&2; }
|
|
8
|
+
warn() { printf "\033[1;33mwarning:\033[0m %s\n" "$1" >&2; }
|
|
9
|
+
err() { printf "\033[0;31merror:\033[0m %s\n" "$1" >&2; exit 1; }
|
|
10
|
+
|
|
11
|
+
PYTHON=""
|
|
12
|
+
INSTALLER=""
|
|
13
|
+
|
|
14
|
+
check_python() {
|
|
15
|
+
for cmd in python3 python; do
|
|
16
|
+
if command -v "$cmd" >/dev/null 2>&1; then
|
|
17
|
+
ver=$("$cmd" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
|
|
18
|
+
major=$(echo "$ver" | cut -d. -f1)
|
|
19
|
+
minor=$(echo "$ver" | cut -d. -f2)
|
|
20
|
+
if [ "$major" -ge 3 ] && [ "$minor" -ge 10 ]; then
|
|
21
|
+
PYTHON="$cmd"
|
|
22
|
+
log "Found $cmd ($ver)"
|
|
23
|
+
return 0
|
|
24
|
+
fi
|
|
25
|
+
fi
|
|
26
|
+
done
|
|
27
|
+
err "Python 3.10+ is required. Install from https://python.org"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ensure_pipx() {
|
|
31
|
+
if command -v pipx >/dev/null 2>&1; then
|
|
32
|
+
INSTALLER="pipx"
|
|
33
|
+
return 0
|
|
34
|
+
fi
|
|
35
|
+
log "pipx not found. Installing pipx..."
|
|
36
|
+
"$PYTHON" -m pip install --user pipx 2>/dev/null && {
|
|
37
|
+
# Ensure pipx is on PATH
|
|
38
|
+
"$PYTHON" -m pipx ensurepath 2>/dev/null || true
|
|
39
|
+
INSTALLER="pipx"
|
|
40
|
+
return 0
|
|
41
|
+
}
|
|
42
|
+
warn "Could not install pipx. Falling back to pip."
|
|
43
|
+
INSTALLER="pip"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
install_spawnpoint() {
|
|
47
|
+
check_python
|
|
48
|
+
ensure_pipx
|
|
49
|
+
|
|
50
|
+
if [ "$INSTALLER" = "pipx" ]; then
|
|
51
|
+
log "Installing spawnpoint via pipx..."
|
|
52
|
+
if command -v pipx >/dev/null 2>&1; then
|
|
53
|
+
pipx install spawnpoint
|
|
54
|
+
else
|
|
55
|
+
"$PYTHON" -m pipx install spawnpoint
|
|
56
|
+
fi
|
|
57
|
+
else
|
|
58
|
+
log "Installing spawnpoint via pip..."
|
|
59
|
+
"$PYTHON" -m pip install --user spawnpoint
|
|
60
|
+
fi
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
verify() {
|
|
64
|
+
echo ""
|
|
65
|
+
if command -v spawnpoint >/dev/null 2>&1; then
|
|
66
|
+
log "spawnpoint $(spawnpoint --version 2>/dev/null || echo '') installed successfully!"
|
|
67
|
+
else
|
|
68
|
+
warn "spawnpoint installed but not found in PATH."
|
|
69
|
+
warn "You may need to add ~/.local/bin to your PATH:"
|
|
70
|
+
echo ""
|
|
71
|
+
case "$SHELL" in
|
|
72
|
+
*/zsh) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;;
|
|
73
|
+
*/bash) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;;
|
|
74
|
+
*/fish) echo " fish_add_path ~/.local/bin" ;;
|
|
75
|
+
*) echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" ;;
|
|
76
|
+
esac
|
|
77
|
+
echo ""
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
echo " Next steps:"
|
|
81
|
+
echo " spawnpoint create # spawn worktree workspaces"
|
|
82
|
+
echo " spawnpoint cleanup # remove worktree workspaces"
|
|
83
|
+
echo " spawnpoint --help # see all commands"
|
|
84
|
+
echo ""
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
install_spawnpoint
|
|
88
|
+
verify
|