devlaunch 0.0.2__tar.gz → 0.0.4__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.
- {devlaunch-0.0.2 → devlaunch-0.0.4}/PKG-INFO +39 -36
- {devlaunch-0.0.2 → devlaunch-0.0.4}/README.md +38 -35
- {devlaunch-0.0.2 → devlaunch-0.0.4}/devlaunch/completions/dl.bash +15 -5
- {devlaunch-0.0.2 → devlaunch-0.0.4}/devlaunch/dl.py +251 -20
- {devlaunch-0.0.2 → devlaunch-0.0.4}/pyproject.toml +1 -1
- {devlaunch-0.0.2 → devlaunch-0.0.4}/.gitignore +0 -0
- {devlaunch-0.0.2 → devlaunch-0.0.4}/LICENSE +0 -0
- {devlaunch-0.0.2 → devlaunch-0.0.4}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.2 → devlaunch-0.0.4}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.2 → devlaunch-0.0.4}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.2 → devlaunch-0.0.4}/devlaunch/completions/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlaunch
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: DevLaunch - A streamlined CLI for devpod workspaces
|
|
5
5
|
Project-URL: Source, https://github.com/blooop/devlaunch
|
|
6
6
|
Project-URL: Home, https://github.com/blooop/devlaunch
|
|
@@ -30,6 +30,7 @@ A streamlined CLI for [devpod](https://devpod.sh) with intuitive autocomplete an
|
|
|
30
30
|
[](https://GitHub.com/blooop/devlaunch/issues/)
|
|
31
31
|
[](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged)
|
|
32
32
|
[](https://GitHub.com/blooop/devlaunch/releases/)
|
|
33
|
+
[](https://pypi.org/project/devlaunch/)
|
|
33
34
|
[](https://opensource.org/license/mit/)
|
|
34
35
|
[](https://www.python.org/downloads/)
|
|
35
36
|
[](https://pixi.sh)
|
|
@@ -54,51 +55,53 @@ source ~/.bashrc # or restart your terminal
|
|
|
54
55
|
## Usage
|
|
55
56
|
|
|
56
57
|
```bash
|
|
57
|
-
dl
|
|
58
|
-
dl <
|
|
59
|
-
dl <
|
|
60
|
-
dl
|
|
61
|
-
dl owner/repo@branch # Create workspace from specific branch
|
|
62
|
-
dl ./path # Create workspace from local path
|
|
58
|
+
dl # Interactive workspace selector (fzf)
|
|
59
|
+
dl <user/repo> # Start workspace and attach shell
|
|
60
|
+
dl <user/repo> <cmd> # Run workspace command (stop, code, etc.)
|
|
61
|
+
dl <user/repo> -- <command> # Run shell command in workspace
|
|
63
62
|
```
|
|
64
63
|
|
|
65
|
-
##
|
|
64
|
+
## Workspace Sources
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
dl myproject # Existing workspace by name
|
|
68
|
+
dl user/repo # Create from GitHub repo
|
|
69
|
+
dl user/repo@branch # Create from specific branch
|
|
70
|
+
dl ./path # Create from local path
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Workspace Commands
|
|
74
|
+
|
|
75
|
+
| Command | Description |
|
|
76
|
+
|---------|-------------|
|
|
77
|
+
| `dl <user/repo> stop` | Stop the workspace |
|
|
78
|
+
| `dl <user/repo> rm, prune` | Delete the workspace |
|
|
79
|
+
| `dl <user/repo> code` | Open in VS Code |
|
|
80
|
+
| `dl <user/repo> restart` | Stop and start (no rebuild) |
|
|
81
|
+
| `dl <user/repo> recreate` | Recreate container |
|
|
82
|
+
| `dl <user/repo> reset` | Clean slate (remove all, recreate) |
|
|
83
|
+
| `dl <user/repo> -- <command>` | Run shell command in workspace |
|
|
84
|
+
|
|
85
|
+
## Global Commands
|
|
66
86
|
|
|
67
87
|
| Command | Description |
|
|
68
88
|
|---------|-------------|
|
|
69
89
|
| `dl --ls` | List all workspaces |
|
|
70
|
-
| `dl --stop <workspace>` | Stop a workspace |
|
|
71
|
-
| `dl --rm <workspace>` | Delete a workspace |
|
|
72
|
-
| `dl --code <workspace>` | Open workspace in VS Code |
|
|
73
|
-
| `dl --status <workspace>` | Show workspace status |
|
|
74
|
-
| `dl --recreate <workspace>` | Recreate workspace container |
|
|
75
|
-
| `dl --reset <workspace>` | Reset workspace (clean slate) |
|
|
76
90
|
| `dl --install` | Install shell completions |
|
|
77
|
-
| `dl --help` | Show help |
|
|
91
|
+
| `dl --help, -h` | Show this help |
|
|
92
|
+
| `dl --version` | Show version |
|
|
78
93
|
|
|
79
94
|
## Examples
|
|
80
95
|
|
|
81
96
|
```bash
|
|
82
|
-
# Select workspace
|
|
83
|
-
dl
|
|
84
|
-
|
|
85
|
-
#
|
|
86
|
-
dl
|
|
87
|
-
|
|
88
|
-
#
|
|
89
|
-
dl
|
|
90
|
-
|
|
91
|
-
# Create workspace from specific branch
|
|
92
|
-
dl blooop/devlaunch@main
|
|
93
|
-
|
|
94
|
-
# Create workspace from local folder
|
|
95
|
-
dl ./my-project
|
|
96
|
-
|
|
97
|
-
# Open workspace in VS Code
|
|
98
|
-
dl --code myproject
|
|
99
|
-
|
|
100
|
-
# Run a command in workspace
|
|
101
|
-
dl myproject 'make test'
|
|
97
|
+
dl # Select workspace with fzf
|
|
98
|
+
dl devpod # Open existing workspace
|
|
99
|
+
dl loft-sh/devpod # Create from GitHub
|
|
100
|
+
dl blooop/devlaunch@main # Create from specific branch
|
|
101
|
+
dl ./my-project # Create from local folder
|
|
102
|
+
dl blooop/devlaunch code # Open in VS Code
|
|
103
|
+
dl blooop/devlaunch -- make test # Run command in workspace
|
|
104
|
+
dl blooop/devlaunch stop # Stop workspace
|
|
102
105
|
```
|
|
103
106
|
|
|
104
107
|
## Features
|
|
@@ -116,7 +119,7 @@ After running `dl --install`, you get intelligent tab completion:
|
|
|
116
119
|
- Workspace names from your devpod list
|
|
117
120
|
- Known GitHub owners and repositories from your workspaces
|
|
118
121
|
- File/directory paths when starting with `./`, `/`, or `~`
|
|
119
|
-
- All
|
|
122
|
+
- All global flags (`--ls`, `--install`, etc.) and workspace commands
|
|
120
123
|
|
|
121
124
|
## Development
|
|
122
125
|
|
|
@@ -9,6 +9,7 @@ A streamlined CLI for [devpod](https://devpod.sh) with intuitive autocomplete an
|
|
|
9
9
|
[](https://GitHub.com/blooop/devlaunch/issues/)
|
|
10
10
|
[](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged)
|
|
11
11
|
[](https://GitHub.com/blooop/devlaunch/releases/)
|
|
12
|
+
[](https://pypi.org/project/devlaunch/)
|
|
12
13
|
[](https://opensource.org/license/mit/)
|
|
13
14
|
[](https://www.python.org/downloads/)
|
|
14
15
|
[](https://pixi.sh)
|
|
@@ -33,51 +34,53 @@ source ~/.bashrc # or restart your terminal
|
|
|
33
34
|
## Usage
|
|
34
35
|
|
|
35
36
|
```bash
|
|
36
|
-
dl
|
|
37
|
-
dl <
|
|
38
|
-
dl <
|
|
39
|
-
dl
|
|
40
|
-
dl owner/repo@branch # Create workspace from specific branch
|
|
41
|
-
dl ./path # Create workspace from local path
|
|
37
|
+
dl # Interactive workspace selector (fzf)
|
|
38
|
+
dl <user/repo> # Start workspace and attach shell
|
|
39
|
+
dl <user/repo> <cmd> # Run workspace command (stop, code, etc.)
|
|
40
|
+
dl <user/repo> -- <command> # Run shell command in workspace
|
|
42
41
|
```
|
|
43
42
|
|
|
44
|
-
##
|
|
43
|
+
## Workspace Sources
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
dl myproject # Existing workspace by name
|
|
47
|
+
dl user/repo # Create from GitHub repo
|
|
48
|
+
dl user/repo@branch # Create from specific branch
|
|
49
|
+
dl ./path # Create from local path
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Workspace Commands
|
|
53
|
+
|
|
54
|
+
| Command | Description |
|
|
55
|
+
|---------|-------------|
|
|
56
|
+
| `dl <user/repo> stop` | Stop the workspace |
|
|
57
|
+
| `dl <user/repo> rm, prune` | Delete the workspace |
|
|
58
|
+
| `dl <user/repo> code` | Open in VS Code |
|
|
59
|
+
| `dl <user/repo> restart` | Stop and start (no rebuild) |
|
|
60
|
+
| `dl <user/repo> recreate` | Recreate container |
|
|
61
|
+
| `dl <user/repo> reset` | Clean slate (remove all, recreate) |
|
|
62
|
+
| `dl <user/repo> -- <command>` | Run shell command in workspace |
|
|
63
|
+
|
|
64
|
+
## Global Commands
|
|
45
65
|
|
|
46
66
|
| Command | Description |
|
|
47
67
|
|---------|-------------|
|
|
48
68
|
| `dl --ls` | List all workspaces |
|
|
49
|
-
| `dl --stop <workspace>` | Stop a workspace |
|
|
50
|
-
| `dl --rm <workspace>` | Delete a workspace |
|
|
51
|
-
| `dl --code <workspace>` | Open workspace in VS Code |
|
|
52
|
-
| `dl --status <workspace>` | Show workspace status |
|
|
53
|
-
| `dl --recreate <workspace>` | Recreate workspace container |
|
|
54
|
-
| `dl --reset <workspace>` | Reset workspace (clean slate) |
|
|
55
69
|
| `dl --install` | Install shell completions |
|
|
56
|
-
| `dl --help` | Show help |
|
|
70
|
+
| `dl --help, -h` | Show this help |
|
|
71
|
+
| `dl --version` | Show version |
|
|
57
72
|
|
|
58
73
|
## Examples
|
|
59
74
|
|
|
60
75
|
```bash
|
|
61
|
-
# Select workspace
|
|
62
|
-
dl
|
|
63
|
-
|
|
64
|
-
#
|
|
65
|
-
dl
|
|
66
|
-
|
|
67
|
-
#
|
|
68
|
-
dl
|
|
69
|
-
|
|
70
|
-
# Create workspace from specific branch
|
|
71
|
-
dl blooop/devlaunch@main
|
|
72
|
-
|
|
73
|
-
# Create workspace from local folder
|
|
74
|
-
dl ./my-project
|
|
75
|
-
|
|
76
|
-
# Open workspace in VS Code
|
|
77
|
-
dl --code myproject
|
|
78
|
-
|
|
79
|
-
# Run a command in workspace
|
|
80
|
-
dl myproject 'make test'
|
|
76
|
+
dl # Select workspace with fzf
|
|
77
|
+
dl devpod # Open existing workspace
|
|
78
|
+
dl loft-sh/devpod # Create from GitHub
|
|
79
|
+
dl blooop/devlaunch@main # Create from specific branch
|
|
80
|
+
dl ./my-project # Create from local folder
|
|
81
|
+
dl blooop/devlaunch code # Open in VS Code
|
|
82
|
+
dl blooop/devlaunch -- make test # Run command in workspace
|
|
83
|
+
dl blooop/devlaunch stop # Stop workspace
|
|
81
84
|
```
|
|
82
85
|
|
|
83
86
|
## Features
|
|
@@ -95,7 +98,7 @@ After running `dl --install`, you get intelligent tab completion:
|
|
|
95
98
|
- Workspace names from your devpod list
|
|
96
99
|
- Known GitHub owners and repositories from your workspaces
|
|
97
100
|
- File/directory paths when starting with `./`, `/`, or `~`
|
|
98
|
-
- All
|
|
101
|
+
- All global flags (`--ls`, `--install`, etc.) and workspace commands
|
|
99
102
|
|
|
100
103
|
## Development
|
|
101
104
|
|
|
@@ -19,6 +19,7 @@ _dl_completion() {
|
|
|
19
19
|
local DL_WORKSPACES=""
|
|
20
20
|
local DL_REPOS=""
|
|
21
21
|
local DL_OWNERS=""
|
|
22
|
+
local DL_BRANCHES=""
|
|
22
23
|
|
|
23
24
|
# Source the bash cache file (fast, no jq needed)
|
|
24
25
|
if [[ -f "$cache_file" ]]; then
|
|
@@ -33,18 +34,25 @@ _dl_completion() {
|
|
|
33
34
|
return 0
|
|
34
35
|
fi
|
|
35
36
|
|
|
36
|
-
# Don't add space after completion to allow @branch suffix
|
|
37
|
-
compopt -o nospace
|
|
38
|
-
|
|
39
37
|
# If typing a path, complete files/directories
|
|
40
38
|
if [[ "$cur" == ./* || "$cur" == /* || "$cur" == ~/* ]]; then
|
|
41
|
-
compopt +o nospace
|
|
42
39
|
COMPREPLY=( $(compgen -d -- ${cur}) )
|
|
43
40
|
return 0
|
|
44
41
|
fi
|
|
45
42
|
|
|
43
|
+
# Check if completing branch (contains @)
|
|
44
|
+
if [[ "$cur" == *@* ]]; then
|
|
45
|
+
# Use cached branches (format: owner/repo@branch)
|
|
46
|
+
if [[ -n "$DL_BRANCHES" ]]; then
|
|
47
|
+
COMPREPLY=( $(compgen -W "${DL_BRANCHES}" -- ${cur}) )
|
|
48
|
+
fi
|
|
49
|
+
return 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
46
52
|
# Check if completing owner/repo format (contains /)
|
|
47
53
|
if [[ "$cur" == */* ]]; then
|
|
54
|
+
# Don't add space - allow @branch suffix
|
|
55
|
+
compopt -o nospace
|
|
48
56
|
# Complete from known repos
|
|
49
57
|
if [[ -n "$DL_REPOS" ]]; then
|
|
50
58
|
COMPREPLY=( $(compgen -W "${DL_REPOS}" -- ${cur}) )
|
|
@@ -53,6 +61,7 @@ _dl_completion() {
|
|
|
53
61
|
fi
|
|
54
62
|
|
|
55
63
|
# Default: complete workspace names and offer owner/ completion
|
|
64
|
+
compopt -o nospace # For owner/ completions
|
|
56
65
|
local completions="$DL_WORKSPACES"
|
|
57
66
|
|
|
58
67
|
# Add owners with trailing slash
|
|
@@ -82,5 +91,6 @@ _dl_completion() {
|
|
|
82
91
|
return 0
|
|
83
92
|
}
|
|
84
93
|
|
|
85
|
-
|
|
94
|
+
# Use -o default for better completion behavior
|
|
95
|
+
complete -o default -F _dl_completion dl
|
|
86
96
|
# end dl completion
|
|
@@ -92,11 +92,13 @@ def write_bash_completion_cache(data: Dict[str, Any]) -> None:
|
|
|
92
92
|
workspaces = " ".join(data.get("workspaces", []))
|
|
93
93
|
repos = " ".join(data.get("repos", []))
|
|
94
94
|
owners = " ".join(data.get("owners", []))
|
|
95
|
+
branches = " ".join(data.get("branches", []))
|
|
95
96
|
lines = [
|
|
96
97
|
"# Auto-generated by dl - do not edit",
|
|
97
98
|
f'DL_WORKSPACES="{workspaces}"',
|
|
98
99
|
f'DL_REPOS="{repos}"',
|
|
99
100
|
f'DL_OWNERS="{owners}"',
|
|
101
|
+
f'DL_BRANCHES="{branches}"',
|
|
100
102
|
]
|
|
101
103
|
with open(BASH_CACHE_FILE, "w", encoding="utf-8") as f:
|
|
102
104
|
f.write("\n".join(lines) + "\n")
|
|
@@ -104,6 +106,29 @@ def write_bash_completion_cache(data: Dict[str, Any]) -> None:
|
|
|
104
106
|
pass
|
|
105
107
|
|
|
106
108
|
|
|
109
|
+
def get_remote_branches(owner_repo: str) -> List[str]:
|
|
110
|
+
"""Get list of branches from a remote GitHub repository."""
|
|
111
|
+
url = f"git@github.com:{owner_repo}.git"
|
|
112
|
+
try:
|
|
113
|
+
result = subprocess.run(
|
|
114
|
+
["git", "ls-remote", "--heads", url],
|
|
115
|
+
capture_output=True,
|
|
116
|
+
text=True,
|
|
117
|
+
check=False,
|
|
118
|
+
timeout=5, # Don't hang on slow connections
|
|
119
|
+
)
|
|
120
|
+
if result.returncode == 0:
|
|
121
|
+
branches = []
|
|
122
|
+
for line in result.stdout.strip().split("\n"):
|
|
123
|
+
if line and "refs/heads/" in line:
|
|
124
|
+
branch = line.split("refs/heads/")[-1]
|
|
125
|
+
branches.append(branch)
|
|
126
|
+
return branches
|
|
127
|
+
except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
|
|
128
|
+
pass
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
|
|
107
132
|
def update_completion_cache() -> Dict[str, Any]:
|
|
108
133
|
"""Update the completion cache with current data."""
|
|
109
134
|
workspaces = list_workspaces()
|
|
@@ -119,10 +144,18 @@ def update_completion_cache() -> Dict[str, Any]:
|
|
|
119
144
|
# Extract unique owners
|
|
120
145
|
owners = sorted(repos.keys())
|
|
121
146
|
|
|
147
|
+
# Fetch branches for all known repos (as owner/repo@branch strings)
|
|
148
|
+
all_branches = []
|
|
149
|
+
for owner_repo in known_repos:
|
|
150
|
+
branches = get_remote_branches(owner_repo)
|
|
151
|
+
for branch in branches:
|
|
152
|
+
all_branches.append(f"{owner_repo}@{branch}")
|
|
153
|
+
|
|
122
154
|
data = {
|
|
123
155
|
"workspaces": workspace_ids,
|
|
124
156
|
"repos": known_repos,
|
|
125
157
|
"owners": owners,
|
|
158
|
+
"branches": all_branches,
|
|
126
159
|
}
|
|
127
160
|
write_completion_cache(data)
|
|
128
161
|
write_bash_completion_cache(data)
|
|
@@ -148,6 +181,27 @@ def update_cache_background() -> None:
|
|
|
148
181
|
OWNER_REPO_PATTERN = re.compile(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(@[a-zA-Z0-9_./%-]+)?$")
|
|
149
182
|
|
|
150
183
|
|
|
184
|
+
def parse_owner_repo_branch(spec: str) -> Optional[tuple]:
|
|
185
|
+
"""Parse owner/repo[@branch] spec into (owner_repo, branch) tuple.
|
|
186
|
+
|
|
187
|
+
Returns None if spec doesn't match owner/repo format.
|
|
188
|
+
Branch may be None if not specified.
|
|
189
|
+
"""
|
|
190
|
+
# Skip paths and full URLs
|
|
191
|
+
if is_path_spec(spec) or "://" in spec:
|
|
192
|
+
return None
|
|
193
|
+
if spec.startswith("github.com/") or spec.startswith("gitlab.com/"):
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
if not OWNER_REPO_PATTERN.match(spec):
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
if "@" in spec:
|
|
200
|
+
owner_repo, branch = spec.split("@", 1)
|
|
201
|
+
return (owner_repo, branch)
|
|
202
|
+
return (spec, None)
|
|
203
|
+
|
|
204
|
+
|
|
151
205
|
def is_path_spec(spec: str) -> bool:
|
|
152
206
|
"""Check if spec looks like a filesystem path."""
|
|
153
207
|
return spec.startswith("./") or spec.startswith("/") or spec.startswith("~")
|
|
@@ -180,30 +234,59 @@ def expand_workspace_spec(spec: str) -> str:
|
|
|
180
234
|
return spec
|
|
181
235
|
|
|
182
236
|
|
|
237
|
+
def sanitize_workspace_id(name: str) -> str:
|
|
238
|
+
"""Sanitize a name to match devpod's workspace ID format.
|
|
239
|
+
|
|
240
|
+
Devpod converts names to lowercase and replaces / with -.
|
|
241
|
+
"""
|
|
242
|
+
return name.lower().replace("/", "-")
|
|
243
|
+
|
|
244
|
+
|
|
183
245
|
def spec_to_workspace_id(spec: str) -> str:
|
|
184
|
-
"""Derive the workspace ID
|
|
246
|
+
"""Derive the workspace ID for a given spec.
|
|
185
247
|
|
|
186
|
-
|
|
187
|
-
|
|
248
|
+
When a branch is specified, we use the branch name as the workspace ID
|
|
249
|
+
to allow multiple branches of the same repo to be open simultaneously.
|
|
250
|
+
This requires passing --id to devpod.
|
|
251
|
+
|
|
252
|
+
- For git repos with branch: sanitized branch name
|
|
253
|
+
- For git repos without branch: sanitized full URL
|
|
188
254
|
- For paths: the directory name (e.g., ./my-project -> my-project)
|
|
189
255
|
- For existing IDs: the ID as-is
|
|
190
256
|
"""
|
|
191
|
-
#
|
|
192
|
-
|
|
257
|
+
# Check for @branch suffix
|
|
258
|
+
branch = None
|
|
259
|
+
if "@" in spec:
|
|
260
|
+
base_spec, branch = spec.split("@", 1)
|
|
261
|
+
else:
|
|
262
|
+
base_spec = spec
|
|
193
263
|
|
|
194
264
|
# For paths, use the directory name
|
|
195
265
|
if is_path_spec(base_spec):
|
|
196
266
|
return pathlib.Path(base_spec).expanduser().resolve().name
|
|
197
267
|
|
|
198
|
-
# For git URLs or owner/repo
|
|
268
|
+
# For git URLs or owner/repo
|
|
199
269
|
if is_git_spec(base_spec):
|
|
200
|
-
#
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
270
|
+
# If branch specified, use branch name as workspace ID
|
|
271
|
+
# This allows multiple branches of same repo open simultaneously
|
|
272
|
+
if branch:
|
|
273
|
+
return sanitize_workspace_id(branch)
|
|
274
|
+
|
|
275
|
+
# Otherwise derive from full source URL
|
|
276
|
+
full_source = expand_workspace_spec(base_spec)
|
|
277
|
+
# Strip protocol prefix if present
|
|
278
|
+
if "://" in full_source:
|
|
279
|
+
full_source = full_source.split("://", 1)[1]
|
|
280
|
+
# Strip .git suffix if present
|
|
281
|
+
if full_source.endswith(".git"):
|
|
282
|
+
full_source = full_source[:-4]
|
|
283
|
+
# Devpod sanitizes: lowercase, replace . and / with -, remove _
|
|
284
|
+
workspace_id = full_source.lower()
|
|
285
|
+
workspace_id = workspace_id.replace(".", "-").replace("/", "-")
|
|
286
|
+
workspace_id = workspace_id.replace("_", "")
|
|
287
|
+
# Remove trailing - if any
|
|
288
|
+
workspace_id = workspace_id.rstrip("-")
|
|
289
|
+
return workspace_id
|
|
207
290
|
|
|
208
291
|
# Otherwise assume it's already a workspace ID
|
|
209
292
|
return spec
|
|
@@ -317,6 +400,138 @@ def get_git_branches(path: str) -> List[str]:
|
|
|
317
400
|
return []
|
|
318
401
|
|
|
319
402
|
|
|
403
|
+
def _git_ls_remote(owner_repo: str, *args: str) -> Optional[str]:
|
|
404
|
+
"""Run git ls-remote and return stdout, or None on error.
|
|
405
|
+
|
|
406
|
+
Uses SSH URL for consistency with other git operations.
|
|
407
|
+
Includes timeout to prevent hanging on slow/unreachable remotes.
|
|
408
|
+
"""
|
|
409
|
+
url = f"git@github.com:{owner_repo}.git"
|
|
410
|
+
try:
|
|
411
|
+
result = subprocess.run(
|
|
412
|
+
["git", "ls-remote", url, *args],
|
|
413
|
+
capture_output=True,
|
|
414
|
+
text=True,
|
|
415
|
+
check=False,
|
|
416
|
+
timeout=5,
|
|
417
|
+
)
|
|
418
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
419
|
+
return result.stdout
|
|
420
|
+
except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
|
|
421
|
+
pass
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def remote_branch_exists(owner_repo: str, branch: str) -> bool:
|
|
426
|
+
"""Check if a branch exists on a remote GitHub repository."""
|
|
427
|
+
output = _git_ls_remote(owner_repo, "--heads", branch)
|
|
428
|
+
return bool(output)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def get_remote_head_sha(owner_repo: str) -> Optional[str]:
|
|
432
|
+
"""Get the SHA of the default branch (HEAD) of a remote repository."""
|
|
433
|
+
output = _git_ls_remote(owner_repo, "HEAD")
|
|
434
|
+
if not output:
|
|
435
|
+
return None
|
|
436
|
+
# Output format: "<sha>\tHEAD"
|
|
437
|
+
return output.strip().split()[0]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _get_git_work_dir() -> pathlib.Path:
|
|
441
|
+
"""Get a persistent git working directory in the cache."""
|
|
442
|
+
git_dir = CACHE_DIR / "git"
|
|
443
|
+
git_dir.mkdir(parents=True, exist_ok=True)
|
|
444
|
+
return git_dir
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def create_remote_branch(owner_repo: str, branch: str) -> bool:
|
|
448
|
+
"""Create a new branch on a remote GitHub repository.
|
|
449
|
+
|
|
450
|
+
Creates the branch pointing to the current HEAD (default branch).
|
|
451
|
+
Requires push access to the repository and configured SSH keys.
|
|
452
|
+
"""
|
|
453
|
+
# Use SSH URL - requires configured SSH keys
|
|
454
|
+
ssh_url = f"git@github.com:{owner_repo}.git"
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
# Use persistent cache directory for git operations
|
|
458
|
+
git_dir = _get_git_work_dir()
|
|
459
|
+
|
|
460
|
+
# Initialize git repo if not already done
|
|
461
|
+
git_init_marker = git_dir / ".git"
|
|
462
|
+
if not git_init_marker.exists():
|
|
463
|
+
init_result = subprocess.run(
|
|
464
|
+
["git", "init"],
|
|
465
|
+
cwd=git_dir,
|
|
466
|
+
capture_output=True,
|
|
467
|
+
text=True,
|
|
468
|
+
check=False,
|
|
469
|
+
)
|
|
470
|
+
if init_result.returncode != 0:
|
|
471
|
+
logging.error(f"Failed to init git repo: {init_result.stderr.strip()}")
|
|
472
|
+
return False
|
|
473
|
+
|
|
474
|
+
# Fetch the default branch HEAD from remote via SSH
|
|
475
|
+
fetch_result = subprocess.run(
|
|
476
|
+
["git", "fetch", "--depth=1", ssh_url, "HEAD"],
|
|
477
|
+
cwd=git_dir,
|
|
478
|
+
capture_output=True,
|
|
479
|
+
text=True,
|
|
480
|
+
check=False,
|
|
481
|
+
timeout=30,
|
|
482
|
+
)
|
|
483
|
+
if fetch_result.returncode != 0:
|
|
484
|
+
stderr = fetch_result.stderr.strip()
|
|
485
|
+
if "permission denied" in stderr.lower() or "host key verification" in stderr.lower():
|
|
486
|
+
logging.error(
|
|
487
|
+
f"SSH authentication failed for {owner_repo}.\n"
|
|
488
|
+
f"Please create the branch manually on GitHub, or configure SSH keys."
|
|
489
|
+
)
|
|
490
|
+
else:
|
|
491
|
+
logging.error(f"Failed to fetch from {owner_repo}: {stderr}")
|
|
492
|
+
return False
|
|
493
|
+
|
|
494
|
+
# Push FETCH_HEAD to create the new branch
|
|
495
|
+
result = subprocess.run(
|
|
496
|
+
["git", "push", ssh_url, f"FETCH_HEAD:refs/heads/{branch}"],
|
|
497
|
+
cwd=git_dir,
|
|
498
|
+
capture_output=True,
|
|
499
|
+
text=True,
|
|
500
|
+
check=False,
|
|
501
|
+
)
|
|
502
|
+
if result.returncode == 0:
|
|
503
|
+
logging.info(f"Created branch '{branch}' on {owner_repo}")
|
|
504
|
+
return True
|
|
505
|
+
|
|
506
|
+
stderr = result.stderr.strip()
|
|
507
|
+
if "permission denied" in stderr.lower() or "host key verification" in stderr.lower():
|
|
508
|
+
logging.error(
|
|
509
|
+
f"SSH authentication failed for {owner_repo}.\n"
|
|
510
|
+
f"Please create the branch manually on GitHub, or configure SSH keys."
|
|
511
|
+
)
|
|
512
|
+
else:
|
|
513
|
+
logging.error(f"Failed to create branch: {stderr}")
|
|
514
|
+
return False
|
|
515
|
+
except subprocess.TimeoutExpired:
|
|
516
|
+
logging.error(f"Timeout connecting to {owner_repo}")
|
|
517
|
+
return False
|
|
518
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
519
|
+
logging.error(f"Failed to create branch: {e}")
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def ensure_remote_branch(owner_repo: str, branch: str) -> bool:
|
|
524
|
+
"""Ensure a branch exists on the remote, creating it if necessary.
|
|
525
|
+
|
|
526
|
+
Returns True if branch exists or was created successfully.
|
|
527
|
+
"""
|
|
528
|
+
if remote_branch_exists(owner_repo, branch):
|
|
529
|
+
return True
|
|
530
|
+
|
|
531
|
+
logging.info(f"Branch '{branch}' does not exist, creating it...")
|
|
532
|
+
return create_remote_branch(owner_repo, branch)
|
|
533
|
+
|
|
534
|
+
|
|
320
535
|
def discover_repos_from_workspaces(workspaces: List[Workspace]) -> Dict[str, List[str]]:
|
|
321
536
|
"""Discover owner/repo from workspace git remotes.
|
|
322
537
|
|
|
@@ -450,10 +665,16 @@ def fuzzy_select_workspace() -> Optional[str]:
|
|
|
450
665
|
|
|
451
666
|
|
|
452
667
|
def workspace_up(
|
|
453
|
-
workspace: str,
|
|
668
|
+
workspace: str,
|
|
669
|
+
ide: Optional[str] = None,
|
|
670
|
+
recreate: bool = False,
|
|
671
|
+
reset: bool = False,
|
|
672
|
+
workspace_id: Optional[str] = None,
|
|
454
673
|
):
|
|
455
674
|
"""Start or create a workspace."""
|
|
456
675
|
args = ["up", workspace]
|
|
676
|
+
if workspace_id:
|
|
677
|
+
args.extend(["--id", workspace_id])
|
|
457
678
|
if ide:
|
|
458
679
|
args.extend(["--ide", ide])
|
|
459
680
|
if recreate:
|
|
@@ -607,12 +828,22 @@ def main() -> int:
|
|
|
607
828
|
return 1
|
|
608
829
|
|
|
609
830
|
# Resolve workspace spec and ID
|
|
610
|
-
|
|
831
|
+
is_existing = raw_spec in existing_ids
|
|
832
|
+
if is_existing:
|
|
611
833
|
workspace_spec = raw_spec
|
|
612
834
|
workspace_id = raw_spec
|
|
835
|
+
custom_id = None # Don't pass --id for existing workspaces
|
|
613
836
|
else:
|
|
614
837
|
workspace_spec = expand_workspace_spec(raw_spec)
|
|
615
838
|
workspace_id = spec_to_workspace_id(raw_spec)
|
|
839
|
+
custom_id = workspace_id # Pass --id to create with our desired ID
|
|
840
|
+
|
|
841
|
+
# For new git repos with a branch, ensure the branch exists (create if needed)
|
|
842
|
+
parsed = parse_owner_repo_branch(raw_spec)
|
|
843
|
+
if parsed and parsed[1]: # Has owner/repo and branch
|
|
844
|
+
owner_repo, branch = parsed
|
|
845
|
+
if not ensure_remote_branch(owner_repo, branch):
|
|
846
|
+
return 1
|
|
616
847
|
|
|
617
848
|
# Handle workspace subcommands
|
|
618
849
|
if subcommand == "stop":
|
|
@@ -622,11 +853,11 @@ def main() -> int:
|
|
|
622
853
|
return workspace_delete(workspace_id)
|
|
623
854
|
|
|
624
855
|
if subcommand == "code":
|
|
625
|
-
result = workspace_up(workspace_spec, ide="vscode")
|
|
856
|
+
result = workspace_up(workspace_spec, ide="vscode", workspace_id=custom_id)
|
|
626
857
|
return result.returncode
|
|
627
858
|
|
|
628
859
|
if subcommand == "recreate":
|
|
629
|
-
result = workspace_up(workspace_spec, recreate=True)
|
|
860
|
+
result = workspace_up(workspace_spec, recreate=True, workspace_id=custom_id)
|
|
630
861
|
if result.returncode != 0:
|
|
631
862
|
return result.returncode
|
|
632
863
|
return workspace_ssh(workspace_id)
|
|
@@ -636,14 +867,14 @@ def main() -> int:
|
|
|
636
867
|
stop_ret = workspace_stop(workspace_id)
|
|
637
868
|
if stop_ret != 0:
|
|
638
869
|
return stop_ret
|
|
639
|
-
result = workspace_up(workspace_spec)
|
|
870
|
+
result = workspace_up(workspace_spec, workspace_id=custom_id)
|
|
640
871
|
if result.returncode != 0:
|
|
641
872
|
return result.returncode
|
|
642
873
|
return workspace_ssh(workspace_id)
|
|
643
874
|
|
|
644
875
|
if subcommand == "reset":
|
|
645
876
|
# Clean slate - remove everything and recreate
|
|
646
|
-
result = workspace_up(workspace_spec, reset=True)
|
|
877
|
+
result = workspace_up(workspace_spec, reset=True, workspace_id=custom_id)
|
|
647
878
|
if result.returncode != 0:
|
|
648
879
|
return result.returncode
|
|
649
880
|
return workspace_ssh(workspace_id)
|
|
@@ -660,7 +891,7 @@ def main() -> int:
|
|
|
660
891
|
return 1
|
|
661
892
|
|
|
662
893
|
# Default: start workspace and attach shell
|
|
663
|
-
result = workspace_up(workspace_spec)
|
|
894
|
+
result = workspace_up(workspace_spec, workspace_id=custom_id)
|
|
664
895
|
if result.returncode != 0:
|
|
665
896
|
return result.returncode
|
|
666
897
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|