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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlaunch
3
- Version: 0.0.2
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
  [![GitHub issues](https://img.shields.io/github/issues/blooop/devlaunch.svg)](https://GitHub.com/blooop/devlaunch/issues/)
31
31
  [![GitHub pull-requests merged](https://badgen.net/github/merged-prs/blooop/devlaunch)](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged)
32
32
  [![GitHub release](https://img.shields.io/github/release/blooop/devlaunch.svg)](https://GitHub.com/blooop/devlaunch/releases/)
33
+ [![PyPI](https://img.shields.io/pypi/v/devlaunch)](https://pypi.org/project/devlaunch/)
33
34
  [![License](https://img.shields.io/github/license/blooop/devlaunch)](https://opensource.org/license/mit/)
34
35
  [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://www.python.org/downloads/)
35
36
  [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh)
@@ -54,51 +55,53 @@ source ~/.bashrc # or restart your terminal
54
55
  ## Usage
55
56
 
56
57
  ```bash
57
- dl # Interactive workspace selector (fzf)
58
- dl <workspace> # Start workspace and attach shell
59
- dl <workspace> <command> # Run command in workspace
60
- dl owner/repo # Create workspace from GitHub repo
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
- ## Commands
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 interactively with fzf
83
- dl
84
-
85
- # Open an existing workspace
86
- dl myproject
87
-
88
- # Create workspace from GitHub repository
89
- dl loft-sh/devpod
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 command flags (`--ls`, `--stop`, etc.)
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
  [![GitHub issues](https://img.shields.io/github/issues/blooop/devlaunch.svg)](https://GitHub.com/blooop/devlaunch/issues/)
10
10
  [![GitHub pull-requests merged](https://badgen.net/github/merged-prs/blooop/devlaunch)](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged)
11
11
  [![GitHub release](https://img.shields.io/github/release/blooop/devlaunch.svg)](https://GitHub.com/blooop/devlaunch/releases/)
12
+ [![PyPI](https://img.shields.io/pypi/v/devlaunch)](https://pypi.org/project/devlaunch/)
12
13
  [![License](https://img.shields.io/github/license/blooop/devlaunch)](https://opensource.org/license/mit/)
13
14
  [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://www.python.org/downloads/)
14
15
  [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh)
@@ -33,51 +34,53 @@ source ~/.bashrc # or restart your terminal
33
34
  ## Usage
34
35
 
35
36
  ```bash
36
- dl # Interactive workspace selector (fzf)
37
- dl <workspace> # Start workspace and attach shell
38
- dl <workspace> <command> # Run command in workspace
39
- dl owner/repo # Create workspace from GitHub repo
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
- ## Commands
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 interactively with fzf
62
- dl
63
-
64
- # Open an existing workspace
65
- dl myproject
66
-
67
- # Create workspace from GitHub repository
68
- dl loft-sh/devpod
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 command flags (`--ls`, `--stop`, etc.)
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
- complete -F _dl_completion dl
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 that devpod will use for a given spec.
246
+ """Derive the workspace ID for a given spec.
185
247
 
186
- Devpod uses:
187
- - For git repos: the repo name (e.g., owner/repo -> repo)
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
- # Strip @branch suffix if present
192
- base_spec = spec.split("@")[0]
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, extract repo name
268
+ # For git URLs or owner/repo
199
269
  if is_git_spec(base_spec):
200
- # Handle various formats: github.com/owner/repo, owner/repo, https://...
201
- parts = base_spec.rstrip("/").split("/")
202
- repo_name = parts[-1]
203
- # Remove .git suffix if present
204
- if repo_name.endswith(".git"):
205
- repo_name = repo_name[:-4]
206
- return repo_name
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, ide: Optional[str] = None, recreate: bool = False, reset: bool = False
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
- if raw_spec in existing_ids:
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devlaunch"
3
- version = "0.0.2"
3
+ version = "0.0.4"
4
4
  authors = [{ name = "Austin Gregg-Smith", email = "blooop@gmail.com" }]
5
5
  description = "DevLaunch - A streamlined CLI for devpod workspaces"
6
6
  readme = "README.md"
File without changes
File without changes