devlaunch 0.0.7__tar.gz → 0.0.8__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.
@@ -179,20 +179,8 @@ test_suite_analysis/metadata.json
179
179
  # Claude Code local settings (personal, not shared)
180
180
  .claude/settings.local.json
181
181
 
182
- # Ralph autonomous agent state files
183
- .call_count
184
- .circuit_breaker_history
185
- .circuit_breaker_state
186
- .exit_signals
187
- .last_reset
188
- .ralph_session
189
- .ralph_session_history
190
- .response_analysis
191
182
  .claude_session_id
192
- progress.json
193
- status.json
194
183
  logs/
195
- .ralph/
196
184
 
197
185
  # uv is not this project's package manager (pixi.lock is authoritative)
198
186
  uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlaunch
3
- Version: 0.0.7
3
+ Version: 0.0.8
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
@@ -33,7 +33,7 @@ A streamlined CLI for [devpod](https://devpod.sh) with intuitive autocomplete an
33
33
  [![GitHub pull-requests merged](https://badgen.net/github/merged-prs/blooop/devlaunch)](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged)
34
34
  [![GitHub release](https://img.shields.io/github/release/blooop/devlaunch.svg)](https://GitHub.com/blooop/devlaunch/releases/)
35
35
  [![PyPI](https://img.shields.io/pypi/v/devlaunch)](https://pypi.org/project/devlaunch/)
36
- [![Conda](https://img.shields.io/badge/conda-v0.0.7-brightgreen?logo=anaconda)](https://prefix.dev/channels/blooop/packages/devlaunch)
36
+ [![Conda](https://img.shields.io/badge/conda-v0.0.8-brightgreen?logo=anaconda)](https://prefix.dev/channels/blooop/packages/devlaunch)
37
37
  [![License](https://img.shields.io/github/license/blooop/devlaunch)](https://opensource.org/license/mit/)
38
38
  [![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/)
39
39
  [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh)
@@ -55,6 +55,8 @@ pip install devlaunch
55
55
  ```
56
56
 
57
57
  Note: When using pip, you must install [devpod](https://devpod.sh/docs/getting-started/install) separately.
58
+ If `devpod` is not on `PATH`, every command that needs it prints a single install hint on stderr and exits `127`
59
+ (the shell's "command not found" code). `dl --help` and `dl --version` keep working without it.
58
60
 
59
61
  ### Shell Completions
60
62
 
@@ -105,6 +107,50 @@ Projects with demanding devcontainers — several variants, compose sidecars, or
105
107
  host-side `initializeCommand` that has to tell branch workspaces apart — are
106
108
  covered in [docs/devcontainer-projects.md](docs/devcontainer-projects.md).
107
109
 
110
+ ## GitHub Authentication
111
+
112
+ Every workspace `dl` opens inherits the host's GitHub login, so `gh` is already
113
+ authenticated inside the container and the devcontainer.json does not have to
114
+ arrange anything for it. devpod forwards the ssh agent and git credentials on its
115
+ own, but nothing else carries `gh`.
116
+
117
+ devlaunch takes the token from `GH_TOKEN`, `GITHUB_TOKEN`, or `gh auth token`,
118
+ whichever answers first, and hands it to the container as `GH_TOKEN`. That reaches
119
+ any image and any container user, unlike a bind-mount of `~/.config/gh`, and it
120
+ works whether the host keeps its token in `hosts.yml` or in a keyring. The token
121
+ is passed to devpod through a private file and through devpod's own environment,
122
+ never on a command line, so it does not appear in `ps`. The container still needs
123
+ `gh` installed for the login to be of any use. Check a workspace with:
124
+
125
+ ```bash
126
+ dl <workspace> -- gh auth status
127
+ ```
128
+
129
+ ### Who gets the token
130
+
131
+ Everything running in the container does — including a `postCreateCommand` from a
132
+ repo you did not write. `dl someone/repo` builds and runs that project's
133
+ devcontainer with your GitHub token in its environment, and a `gh auth login` token
134
+ usually carries `repo`, `workflow`, `gist` and `read:org` scopes. devpod already
135
+ forwards the ssh agent to every workspace, so this is not a new trust boundary, but
136
+ it is a wider one. Skip it for a repo you have not read:
137
+
138
+ ```bash
139
+ DEVLAUNCH_NO_GH_TOKEN=1 dl someone/repo
140
+ ```
141
+
142
+ | Variable | Description |
143
+ |----------|-------------|
144
+ | `DEVLAUNCH_NO_GH_TOKEN=1` | Do not forward the host's GitHub login into workspaces |
145
+
146
+ ### When the token changes
147
+
148
+ `dl` refreshes the token on every start, so rotating it on the host is enough for
149
+ any workspace that gets started or restarted afterwards. Attaching to a workspace
150
+ that is *already running* skips that step, and the token it was given at startup
151
+ stays in place — including one it was given before you set
152
+ `DEVLAUNCH_NO_GH_TOKEN`. Run `dl <workspace> restart` to replace it.
153
+
108
154
  ## Global Commands
109
155
 
110
156
  | Command | Description |
@@ -163,13 +209,6 @@ Use `--warm` to prepare a workspace without attaching a shell:
163
209
  dl --warm owner/repo@branch # Creates container in background
164
210
  ```
165
211
 
166
- ### Backend Selection
167
-
168
- ```bash
169
- dl --backend devpod owner/repo # Force legacy DevPod backend
170
- DEVLAUNCH_BACKEND=devpod dl owner/repo # Use environment variable
171
- ```
172
-
173
212
  ## Shell Completion
174
213
 
175
214
  After running `dl --install`, you get intelligent tab completion:
@@ -10,7 +10,7 @@ A streamlined CLI for [devpod](https://devpod.sh) with intuitive autocomplete an
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
12
  [![PyPI](https://img.shields.io/pypi/v/devlaunch)](https://pypi.org/project/devlaunch/)
13
- [![Conda](https://img.shields.io/badge/conda-v0.0.7-brightgreen?logo=anaconda)](https://prefix.dev/channels/blooop/packages/devlaunch)
13
+ [![Conda](https://img.shields.io/badge/conda-v0.0.8-brightgreen?logo=anaconda)](https://prefix.dev/channels/blooop/packages/devlaunch)
14
14
  [![License](https://img.shields.io/github/license/blooop/devlaunch)](https://opensource.org/license/mit/)
15
15
  [![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/)
16
16
  [![Pixi Badge](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json)](https://pixi.sh)
@@ -32,6 +32,8 @@ pip install devlaunch
32
32
  ```
33
33
 
34
34
  Note: When using pip, you must install [devpod](https://devpod.sh/docs/getting-started/install) separately.
35
+ If `devpod` is not on `PATH`, every command that needs it prints a single install hint on stderr and exits `127`
36
+ (the shell's "command not found" code). `dl --help` and `dl --version` keep working without it.
35
37
 
36
38
  ### Shell Completions
37
39
 
@@ -82,6 +84,50 @@ Projects with demanding devcontainers — several variants, compose sidecars, or
82
84
  host-side `initializeCommand` that has to tell branch workspaces apart — are
83
85
  covered in [docs/devcontainer-projects.md](docs/devcontainer-projects.md).
84
86
 
87
+ ## GitHub Authentication
88
+
89
+ Every workspace `dl` opens inherits the host's GitHub login, so `gh` is already
90
+ authenticated inside the container and the devcontainer.json does not have to
91
+ arrange anything for it. devpod forwards the ssh agent and git credentials on its
92
+ own, but nothing else carries `gh`.
93
+
94
+ devlaunch takes the token from `GH_TOKEN`, `GITHUB_TOKEN`, or `gh auth token`,
95
+ whichever answers first, and hands it to the container as `GH_TOKEN`. That reaches
96
+ any image and any container user, unlike a bind-mount of `~/.config/gh`, and it
97
+ works whether the host keeps its token in `hosts.yml` or in a keyring. The token
98
+ is passed to devpod through a private file and through devpod's own environment,
99
+ never on a command line, so it does not appear in `ps`. The container still needs
100
+ `gh` installed for the login to be of any use. Check a workspace with:
101
+
102
+ ```bash
103
+ dl <workspace> -- gh auth status
104
+ ```
105
+
106
+ ### Who gets the token
107
+
108
+ Everything running in the container does — including a `postCreateCommand` from a
109
+ repo you did not write. `dl someone/repo` builds and runs that project's
110
+ devcontainer with your GitHub token in its environment, and a `gh auth login` token
111
+ usually carries `repo`, `workflow`, `gist` and `read:org` scopes. devpod already
112
+ forwards the ssh agent to every workspace, so this is not a new trust boundary, but
113
+ it is a wider one. Skip it for a repo you have not read:
114
+
115
+ ```bash
116
+ DEVLAUNCH_NO_GH_TOKEN=1 dl someone/repo
117
+ ```
118
+
119
+ | Variable | Description |
120
+ |----------|-------------|
121
+ | `DEVLAUNCH_NO_GH_TOKEN=1` | Do not forward the host's GitHub login into workspaces |
122
+
123
+ ### When the token changes
124
+
125
+ `dl` refreshes the token on every start, so rotating it on the host is enough for
126
+ any workspace that gets started or restarted afterwards. Attaching to a workspace
127
+ that is *already running* skips that step, and the token it was given at startup
128
+ stays in place — including one it was given before you set
129
+ `DEVLAUNCH_NO_GH_TOKEN`. Run `dl <workspace> restart` to replace it.
130
+
85
131
  ## Global Commands
86
132
 
87
133
  | Command | Description |
@@ -140,13 +186,6 @@ Use `--warm` to prepare a workspace without attaching a shell:
140
186
  dl --warm owner/repo@branch # Creates container in background
141
187
  ```
142
188
 
143
- ### Backend Selection
144
-
145
- ```bash
146
- dl --backend devpod owner/repo # Force legacy DevPod backend
147
- DEVLAUNCH_BACKEND=devpod dl owner/repo # Use environment variable
148
- ```
149
-
150
189
  ## Shell Completion
151
190
 
152
191
  After running `dl --install`, you get intelligent tab completion:
@@ -29,11 +29,38 @@ from importlib.metadata import version as pkg_version, PackageNotFoundError
29
29
  from typing import List, Optional, Dict, Any
30
30
  from dataclasses import dataclass
31
31
 
32
+ from . import gh_auth
32
33
  from .completion import install_completions
33
34
  from .worktree.config import get_worktree_config
34
35
  from .worktree.workspace_clone import WorkspaceCloneManager
35
36
 
36
37
 
38
+ class DevpodNotInstalled(Exception):
39
+ """The devpod binary dl shells out to is not on PATH.
40
+
41
+ Deliberately not an OSError (FileNotFoundError is one) and not a
42
+ RuntimeError: dl catches both broadly in a dozen places so that a flaky
43
+ command degrades to an empty list or a "failed to prepare workspace"
44
+ message. A missing binary reported through one of those handlers is
45
+ reported wrongly, so it travels as a type nothing between run_devpod and
46
+ main() catches, and main() is the only place that handles it.
47
+ """
48
+
49
+
50
+ # One line, so a completion helper that trips over it cannot spew into the
51
+ # user's shell. It names both install routes because devpod ships with the
52
+ # pixi/conda package and does not ship with the pip one (see README).
53
+ DEVPOD_MISSING_MESSAGE = (
54
+ "devpod not found on PATH: dl cannot manage workspaces without it. "
55
+ "Install devpod from https://devpod.sh/docs/getting-started/install "
56
+ "(pixi/conda installs of devlaunch include it; pip installs do not)."
57
+ )
58
+
59
+ # The shell's own "command not found" code, which says more than a bare 1 and
60
+ # cannot be confused with a devpod command that ran and failed.
61
+ DEVPOD_MISSING_EXIT_CODE = 127
62
+
63
+
37
64
  def get_version() -> str:
38
65
  """Get the package version."""
39
66
  try:
@@ -560,28 +587,6 @@ def get_git_remote_url(path: str) -> Optional[str]:
560
587
  return None
561
588
 
562
589
 
563
- def get_git_branches(path: str) -> List[str]:
564
- """Get list of branches from a git repository."""
565
- try:
566
- result = subprocess.run(
567
- ["git", "-C", path, "branch", "-r"],
568
- capture_output=True,
569
- text=True,
570
- check=False,
571
- )
572
- if result.returncode == 0:
573
- branches = []
574
- for line in result.stdout.strip().split("\n"):
575
- line = line.strip()
576
- if line and "origin/" in line and "HEAD" not in line:
577
- branch = line.replace("origin/", "")
578
- branches.append(branch)
579
- return branches
580
- except (OSError, subprocess.SubprocessError):
581
- pass
582
- return []
583
-
584
-
585
590
  def _git_ls_remote(owner_repo: str, *args: str) -> Optional[str]:
586
591
  """Run git ls-remote and return stdout, or None on error.
587
592
 
@@ -610,15 +615,6 @@ def remote_branch_exists(owner_repo: str, branch: str) -> bool:
610
615
  return bool(output)
611
616
 
612
617
 
613
- def get_remote_head_sha(owner_repo: str) -> Optional[str]:
614
- """Get the SHA of the default branch (HEAD) of a remote repository."""
615
- output = _git_ls_remote(owner_repo, "HEAD")
616
- if not output:
617
- return None
618
- # Output format: "<sha>\tHEAD"
619
- return output.strip().split()[0]
620
-
621
-
622
618
  def discover_repos_from_workspaces(workspaces: List[Workspace]) -> Dict[str, List[str]]:
623
619
  """Discover owner/repo from workspace git remotes.
624
620
 
@@ -692,20 +688,34 @@ def get_known_repos() -> List[str]:
692
688
  return result
693
689
 
694
690
 
695
- def run_devpod(args: List[str], capture: bool = False) -> subprocess.CompletedProcess:
691
+ def run_devpod(
692
+ args: List[str], capture: bool = False, env: Optional[Dict[str, str]] = None
693
+ ) -> subprocess.CompletedProcess:
696
694
  """Run a devpod command.
697
695
 
696
+ env replaces devpod's whole environment when given, so a caller that wants
697
+ to add one variable must build it from os.environ. It exists so a secret can
698
+ be handed to devpod without putting it in argv, where ps would expose it.
699
+
698
700
  Security note: Using list form of subprocess.run (not shell=True) prevents
699
701
  command injection. Each list element is passed as a separate argument to
700
702
  the executable, so special characters are not interpreted by a shell.
703
+
704
+ This is dl's only devpod spawn, so it is also the only place that can tell
705
+ "devpod is not installed" from "devpod ran and failed". The former is
706
+ raised as DevpodNotInstalled rather than folded into a returncode: callers
707
+ branch on returncode and would carry on as though devpod had answered.
701
708
  """
702
709
  cmd = ["devpod"] + args
703
710
  logging.debug("Running: %s", " ".join(cmd))
704
- if capture:
711
+ try:
712
+ if capture:
713
+ # nosec B603 - using list form, not shell=True; no command injection risk
714
+ return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
705
715
  # nosec B603 - using list form, not shell=True; no command injection risk
706
- return subprocess.run(cmd, capture_output=True, text=True, check=False)
707
- # nosec B603 - using list form, not shell=True; no command injection risk
708
- return subprocess.run(cmd, check=False)
716
+ return subprocess.run(cmd, check=False, env=env)
717
+ except FileNotFoundError as e:
718
+ raise DevpodNotInstalled(DEVPOD_MISSING_MESSAGE) from e
709
719
 
710
720
 
711
721
  def list_workspaces() -> List[Workspace]:
@@ -835,7 +845,11 @@ def workspace_up(
835
845
  args.extend(["--dotfiles", ctx["DOTFILES_URL"]])
836
846
  if ctx.get("DOTFILES_SCRIPT"):
837
847
  args.extend(["--dotfiles-script", ctx["DOTFILES_SCRIPT"]])
838
- return run_devpod(args)
848
+ # Give every workspace the host's gh login, whatever its devcontainer.json
849
+ # does or doesn't set up for itself.
850
+ with gh_auth.up_args() as token_args:
851
+ args.extend(token_args)
852
+ return run_devpod(args)
839
853
 
840
854
 
841
855
  def workspace_ssh(
@@ -860,8 +874,14 @@ def workspace_ssh(
860
874
  if command:
861
875
  args.extend(["--command", command])
862
876
 
877
+ # Attaching to a running workspace skips workspace_up, so the gh login has
878
+ # to be offered here too. Only the variable name lands in args; the token
879
+ # travels in devpod's environment.
880
+ token_args, env = gh_auth.ssh_args_and_env()
881
+ args.extend(token_args)
882
+
863
883
  logging.info(f"SSH command: devpod {' '.join(args)}")
864
- result = run_devpod(args)
884
+ result = run_devpod(args, env=env)
865
885
  return result.returncode
866
886
 
867
887
 
@@ -904,12 +924,6 @@ def workspace_delete(workspace: str) -> int:
904
924
  return result.returncode
905
925
 
906
926
 
907
- def workspace_status(workspace: str) -> int:
908
- """Get status of a workspace."""
909
- result = run_devpod(["status", workspace])
910
- return result.returncode
911
-
912
-
913
927
  def get_workspace_state(workspace_id: str) -> Optional[str]:
914
928
  """Get workspace state from devpod (e.g., 'Running', 'Stopped')."""
915
929
  result = run_devpod(["status", workspace_id, "--output", "json"], capture=True)
@@ -938,6 +952,10 @@ Options:
938
952
  name means .devcontainer/<name>/devcontainer.json.
939
953
  Stored with the workspace, so pass it once.
940
954
 
955
+ Environment:
956
+ DEVLAUNCH_NO_GH_TOKEN=1 Do not forward the host's gh login into
957
+ workspaces (forwarded as GH_TOKEN by default)
958
+
941
959
  Workspace sources:
942
960
  dl myproject Existing workspace by name
943
961
  dl user/repo Create from GitHub repo
@@ -986,7 +1004,21 @@ def _get_clone_manager() -> WorkspaceCloneManager:
986
1004
 
987
1005
 
988
1006
  def main() -> int:
989
- """Main entry point for dl CLI."""
1007
+ """Main entry point for dl CLI.
1008
+
1009
+ Thin wrapper so there is exactly one handler for a missing devpod, however
1010
+ deep in the command it was noticed. The message goes to stderr because
1011
+ stdout is parsed by the completion machinery (--repos, --completion-data).
1012
+ """
1013
+ try:
1014
+ return _run_cli()
1015
+ except DevpodNotInstalled as e:
1016
+ print(e, file=sys.stderr)
1017
+ return DEVPOD_MISSING_EXIT_CODE
1018
+
1019
+
1020
+ def _run_cli() -> int:
1021
+ """Dispatch a dl command line. See main() for the error handling around it."""
990
1022
  try:
991
1023
  args, devcontainer = extract_devcontainer_flag(sys.argv[1:])
992
1024
  except ValueError as e:
@@ -0,0 +1,171 @@
1
+ """Carry the host's GitHub CLI credentials into every workspace devlaunch opens.
2
+
3
+ devpod forwards the ssh agent and a git credential helper, but nothing carries
4
+ `gh` authentication, so `gh` starts out logged out in every container — including
5
+ the one devlaunch itself is developed in. A devcontainer.json can bind-mount
6
+ ~/.config/gh, but that only helps the projects that opted in, the mount target
7
+ has to name the container user's home directory, and it hands over nothing at
8
+ all when the host keeps its token in a keyring instead of hosts.yml.
9
+
10
+ A token in the environment needs no cooperation from the image, the
11
+ devcontainer.json, or the container user, so it works for whatever devlaunch is
12
+ asked to launch. `gh` reads GH_TOKEN ahead of its own config, and `gh auth
13
+ token` sources the token whether the host stores it in a file or a keyring.
14
+
15
+ The token reaches devpod out of band — through a private file for `devpod up`
16
+ and through devpod's own environment for `devpod ssh` — so it never sits in a
17
+ command line that `ps` shows to every other user on the host.
18
+ """
19
+
20
+ import contextlib
21
+ import functools
22
+ import logging
23
+ import os
24
+ import re
25
+ import shutil
26
+ import subprocess
27
+ import tempfile
28
+ from typing import Dict, Iterator, List, Optional, Tuple
29
+
30
+ # The variable set inside the container. gh consults it before its config file.
31
+ TOKEN_VAR = "GH_TOKEN"
32
+
33
+ # Host variables to reuse before paying for a `gh` subprocess. Honouring
34
+ # GH_TOKEN also means a devlaunch running inside a devlaunch workspace passes
35
+ # its own forwarded token further down.
36
+ HOST_TOKEN_VARS = ("GH_TOKEN", "GITHUB_TOKEN")
37
+
38
+ # Set this to opt a machine out of forwarding entirely.
39
+ DISABLE_VAR = "DEVLAUNCH_NO_GH_TOKEN"
40
+
41
+ _FALSEY = ("", "0", "false", "no")
42
+
43
+ # Every GitHub token form is a flat ASCII string; anything else came from a
44
+ # broken gh install or a wrapper script that printed a message on stdout.
45
+ _TOKEN_PATTERN = re.compile(r"\A[A-Za-z0-9_.\-]+\Z")
46
+
47
+ # gh may have to unlock a keyring, so don't let it stall a workspace forever.
48
+ _GH_TIMEOUT_SECONDS = 10
49
+
50
+
51
+ def forwarding_disabled() -> bool:
52
+ """Whether the user opted this machine out of gh token forwarding."""
53
+ return os.environ.get(DISABLE_VAR, "").strip().lower() not in _FALSEY
54
+
55
+
56
+ def _is_token(value: str) -> bool:
57
+ return bool(value) and bool(_TOKEN_PATTERN.match(value))
58
+
59
+
60
+ def _token_from_gh_cli() -> Optional[str]:
61
+ """Ask the gh CLI for the host's token, or None if it has none to give."""
62
+ if not shutil.which("gh"):
63
+ return None
64
+ try:
65
+ # nosec B603 B607 - list form, not shell=True; no command injection risk
66
+ result = subprocess.run(
67
+ ["gh", "auth", "token"],
68
+ capture_output=True,
69
+ text=True,
70
+ check=False,
71
+ # gh must not eat stdin that belongs to the command `dl` was asked
72
+ # to run, and must not leave the terminal in a state of its own.
73
+ stdin=subprocess.DEVNULL,
74
+ timeout=_GH_TIMEOUT_SECONDS,
75
+ )
76
+ except (OSError, subprocess.SubprocessError) as e:
77
+ logging.debug("Could not read a GitHub token from gh: %s", e)
78
+ return None
79
+ if result.returncode != 0:
80
+ logging.debug("gh auth token exited %s; no GitHub auth to forward", result.returncode)
81
+ return None
82
+ # Never log the value itself, only whether it was usable.
83
+ if not _is_token(result.stdout.strip()):
84
+ logging.debug("gh auth token printed something that is not a token; ignoring it")
85
+ return None
86
+ return result.stdout.strip()
87
+
88
+
89
+ @functools.lru_cache(maxsize=1)
90
+ def resolve_token() -> Optional[str]:
91
+ """The host's GitHub token, or None if there is nothing to forward.
92
+
93
+ Cached for the life of the process: a single `dl` run can hand the token to
94
+ both `devpod up` and `devpod ssh`, and asking gh twice can mean unlocking a
95
+ keyring twice.
96
+ """
97
+ if forwarding_disabled():
98
+ return None
99
+ for var in HOST_TOKEN_VARS:
100
+ token = os.environ.get(var, "").strip()
101
+ if _is_token(token):
102
+ return token
103
+ return _token_from_gh_cli()
104
+
105
+
106
+ def _stage_token_file(token: str) -> Optional[str]:
107
+ """Write the token to a file only this user can read, or None if that failed.
108
+
109
+ Forwarding a credential is a convenience, so a temp dir that is full or
110
+ read-only has to cost the workspace its gh login, not its launch.
111
+ """
112
+ try:
113
+ fd, path = tempfile.mkstemp(prefix="devlaunch-gh-", suffix=".env")
114
+ except OSError as e:
115
+ logging.debug("Could not create a file to pass the GitHub token to devpod: %s", e)
116
+ return None
117
+ try:
118
+ with os.fdopen(fd, "w") as handle:
119
+ handle.write(f"{TOKEN_VAR}={token}\n")
120
+ except OSError as e:
121
+ logging.debug("Could not write the GitHub token for devpod: %s", e)
122
+ with contextlib.suppress(OSError):
123
+ os.unlink(path)
124
+ return None
125
+ return path
126
+
127
+
128
+ @contextlib.contextmanager
129
+ def up_args() -> Iterator[List[str]]:
130
+ """Yield `devpod up` flags that put the host's token in the workspace env.
131
+
132
+ The token goes in a private file rather than on the command line because
133
+ `devpod up` can run for minutes while an image builds, and its argv is
134
+ readable by every user on the host for that whole time. --workspace-env-file
135
+ is a devpod flag of its own, so it adds to whatever the user has configured
136
+ through --workspace-env instead of displacing it.
137
+
138
+ devpod re-applies the workspace env on every `up`, so a token that has since
139
+ changed on the host reaches even a container that is already running. It
140
+ only reaches one this way, though: see ssh_args_and_env.
141
+ """
142
+ token = resolve_token()
143
+ path = _stage_token_file(token) if token else None
144
+ if not path:
145
+ yield []
146
+ return
147
+ try:
148
+ yield ["--workspace-env-file", path]
149
+ finally:
150
+ with contextlib.suppress(OSError):
151
+ os.unlink(path)
152
+
153
+
154
+ def ssh_args_and_env() -> Tuple[List[str], Optional[Dict[str, str]]]:
155
+ """Return `devpod ssh` flags plus the environment devpod must be run with.
156
+
157
+ This covers attaching to a workspace that is already running, which skips
158
+ `devpod up` and its workspace env entirely. --send-env only names the
159
+ variable — devpod reads the value from its own environment — so the token
160
+ stays out of argv here too.
161
+
162
+ devpod lets a workspace env value win over --send-env, so this tops up
163
+ workspaces devlaunch never created rather than overriding a token that
164
+ `devpod up` just delivered. The flip side is that it cannot refresh one
165
+ either: a running workspace whose token has been revoked since it started
166
+ needs `dl <ws> restart` to pick up the new one.
167
+ """
168
+ token = resolve_token()
169
+ if not token:
170
+ return [], None
171
+ return ["--send-env", TOKEN_VAR], {**os.environ, TOKEN_VAR: token}
@@ -186,52 +186,3 @@ class BranchManager:
186
186
  except subprocess.CalledProcessError as e:
187
187
  logger.debug(f"Failed to push branch: {e.stderr}")
188
188
  raise RuntimeError(f"Failed to push branch to remote: {e.stderr}") from e
189
-
190
- def create_remote_branch_via_ssh(
191
- self, owner: str, repo: str, branch: str, ssh_key_path: Optional[str] = None
192
- ) -> bool:
193
- """Create a remote branch on GitHub via SSH (legacy method from dl.py)."""
194
- logger.info(f"Creating remote branch {branch} for {owner}/{repo} via SSH")
195
-
196
- ssh_command = ["ssh"]
197
- if ssh_key_path:
198
- ssh_command.extend(["-i", ssh_key_path])
199
-
200
- ssh_command.extend(["git@github.com", "create", f"{owner}/{repo}", branch])
201
-
202
- try:
203
- result = subprocess.run(
204
- ssh_command, capture_output=True, text=True, timeout=10, check=False
205
- )
206
-
207
- if result.returncode == 0:
208
- logger.info(f"Successfully created remote branch {branch}")
209
- return True
210
- # Check if branch already exists
211
- if "branch already exists" in result.stderr.lower():
212
- logger.info(f"Branch {branch} already exists on remote")
213
- return True
214
- logger.warning(f"Failed to create remote branch: {result.stderr}")
215
- return False
216
-
217
- except subprocess.TimeoutExpired:
218
- logger.warning("SSH command timed out")
219
- return False
220
- except Exception as e:
221
- logger.warning(f"Error creating remote branch via SSH: {e}")
222
- return False
223
-
224
- def checkout_branch(self, repo_path: Path, branch: str) -> None:
225
- """Checkout a branch in a repository or worktree."""
226
- try:
227
- result = subprocess.run(
228
- ["git", "checkout", branch],
229
- cwd=repo_path,
230
- capture_output=True,
231
- text=True,
232
- check=True,
233
- )
234
- logger.debug(f"Checkout output: {result.stdout}")
235
- except subprocess.CalledProcessError as e:
236
- logger.debug(f"Failed to checkout branch: {e.stderr}")
237
- raise RuntimeError(f"Failed to checkout branch: {e.stderr}") from e
@@ -6,7 +6,6 @@ from pathlib import Path
6
6
  from typing import Dict, Optional, Union
7
7
 
8
8
  import tomli
9
- import tomli_w
10
9
 
11
10
 
12
11
  def _get_cache_base() -> Path:
@@ -101,15 +100,6 @@ def load_config() -> Dict:
101
100
  return tomli.load(f)
102
101
 
103
102
 
104
- def save_config(config: Dict) -> None:
105
- """Save configuration to file."""
106
- config_path = get_config_path()
107
- config_path.parent.mkdir(parents=True, exist_ok=True)
108
-
109
- with open(config_path, "wb") as f:
110
- tomli_w.dump(config, f)
111
-
112
-
113
103
  def get_worktree_config() -> WorktreeConfig:
114
104
  """Get worktree configuration, loading from file if exists."""
115
105
  config_data = load_config()
@@ -1,9 +1,31 @@
1
1
  """Data models for worktree backend."""
2
2
 
3
- from dataclasses import asdict, dataclass, field
3
+ from dataclasses import asdict, dataclass, field, fields
4
4
  from datetime import datetime
5
5
  from pathlib import Path
6
- from typing import Dict, List, Optional
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ def unknown_fields(model: Any, data: Dict) -> List[str]:
10
+ """Return the keys in ``data`` that ``model`` declares no field for.
11
+
12
+ A newer devlaunch writing an extra field is the expected source. Callers use
13
+ this to report what a rewrite would drop; ``from_dict`` ignores those keys.
14
+ """
15
+ known = {f.name for f in fields(model)}
16
+ return sorted(key for key in data if key not in known)
17
+
18
+
19
+ def _drop_unknown(model: Any, data: Dict) -> Dict:
20
+ """Copy ``data`` keeping only the fields ``model`` declares.
21
+
22
+ Ignoring an unrecognized field keeps the entry loadable. Rebuilding it with
23
+ ``cls(**data)`` instead would raise TypeError, and since one stored field is
24
+ shaped like every other, a single field added by a newer build would make
25
+ every entry unreadable at once.
26
+ """
27
+ known = {f.name for f in fields(model)}
28
+ return {key: value for key, value in data.items() if key in known}
7
29
 
8
30
 
9
31
  @dataclass
@@ -27,8 +49,8 @@ class BaseRepository:
27
49
 
28
50
  @classmethod
29
51
  def from_dict(cls, data: Dict) -> "BaseRepository":
30
- """Create from dictionary."""
31
- data = data.copy()
52
+ """Create from dictionary, ignoring fields this build does not declare."""
53
+ data = _drop_unknown(cls, data)
32
54
  data["local_path"] = Path(data["local_path"])
33
55
  if data.get("last_fetched"):
34
56
  data["last_fetched"] = datetime.fromisoformat(data["last_fetched"])
@@ -58,8 +80,8 @@ class WorktreeInfo:
58
80
 
59
81
  @classmethod
60
82
  def from_dict(cls, data: Dict) -> "WorktreeInfo":
61
- """Create from dictionary."""
62
- data = data.copy()
83
+ """Create from dictionary, ignoring fields this build does not declare."""
84
+ data = _drop_unknown(cls, data)
63
85
  data["local_path"] = Path(data["local_path"])
64
86
  data["created_at"] = datetime.fromisoformat(data["created_at"])
65
87
  data["last_used"] = datetime.fromisoformat(data["last_used"])
@@ -0,0 +1,348 @@
1
+ """Storage utilities for worktree metadata."""
2
+
3
+ import contextlib
4
+ import json
5
+ import os
6
+ import shutil
7
+ import stat
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Tuple
12
+
13
+ from .models import BaseRepository, WorktreeInfo, unknown_fields
14
+
15
+ # Version of the on-disk metadata.json format. A file without a "version" key
16
+ # predates versioning and is treated as version 1.
17
+ SCHEMA_VERSION = 1
18
+
19
+ # Top-level keys this build writes, and therefore the only ones a rewrite keeps.
20
+ _KNOWN_SECTIONS = frozenset({"version", "repositories", "worktrees"})
21
+
22
+ # Errors raised when a single stored entry cannot be rebuilt into a model:
23
+ # KeyError for a missing field, TypeError for an unknown/bad-typed field
24
+ # (from_dict does cls(**data)), ValueError for an unparsable timestamp.
25
+ _ENTRY_ERRORS = (KeyError, TypeError, ValueError)
26
+
27
+
28
+ def _get_default_metadata_path() -> Path:
29
+ """Get the default metadata path, honoring XDG_CACHE_HOME."""
30
+ xdg_cache = os.environ.get("XDG_CACHE_HOME")
31
+ if xdg_cache:
32
+ return Path(xdg_cache) / "devlaunch" / "metadata.json"
33
+ return Path.home() / ".cache" / "devlaunch" / "metadata.json"
34
+
35
+
36
+ def _warn(message: str) -> None:
37
+ """Emit a single warning line on stderr (stdout is parsed by completions)."""
38
+ print(f"dl: {message}", file=sys.stderr)
39
+
40
+
41
+ def _file_mode(path: Path) -> Optional[int]:
42
+ """Return the permission bits of ``path``, or None if it does not exist."""
43
+ try:
44
+ return stat.S_IMODE(path.stat().st_mode)
45
+ except OSError:
46
+ return None
47
+
48
+
49
+ def _resolve_link(path: Path) -> Path:
50
+ """Return the real file behind ``path``, following it if it is a symlink.
51
+
52
+ Only the final component is resolved. Writing atomically means renaming a
53
+ temp file over the target, which would replace a symlink with a regular
54
+ file; anyone who points metadata.json at a synced directory would silently
55
+ lose the link and every later write. Resolving once up front keeps every
56
+ file operation (write, quarantine, backup) on the real file.
57
+ """
58
+ if path.is_symlink():
59
+ return Path(os.path.realpath(path))
60
+ return path
61
+
62
+
63
+ class MetadataStorage:
64
+ """Handles persistent storage of worktree metadata."""
65
+
66
+ def __init__(self, metadata_path: Optional[Path] = None):
67
+ """Initialize metadata storage."""
68
+ if metadata_path is None:
69
+ metadata_path = _get_default_metadata_path()
70
+ self.metadata_path = metadata_path
71
+ self.metadata_path.parent.mkdir(parents=True, exist_ok=True)
72
+ # Every file operation targets the real file, not a symlink pointing at it.
73
+ self._file_path = _resolve_link(self.metadata_path)
74
+ self._load()
75
+
76
+ def _quarantine(self, reason: str) -> None:
77
+ """Move an unusable metadata file aside so the data stays inspectable.
78
+
79
+ A single quarantine slot is used, overwritten on repeat corruption.
80
+ """
81
+ corrupt_path = self._file_path.with_name(self._file_path.name + ".corrupt")
82
+ try:
83
+ self._file_path.replace(corrupt_path)
84
+ except OSError as exc:
85
+ _warn(
86
+ f"{reason}; could not move it aside to {corrupt_path} ({exc}); "
87
+ "starting with empty metadata"
88
+ )
89
+ else:
90
+ _warn(f"{reason}; moved it to {corrupt_path} and started with empty metadata")
91
+
92
+ def _read_file(self) -> Optional[Dict[str, Any]]:
93
+ """Read and sanity-check the metadata file, quarantining it if unusable."""
94
+ if not self._file_path.exists():
95
+ return None
96
+ try:
97
+ with open(self._file_path, "r", encoding="utf-8") as f:
98
+ data = json.load(f)
99
+ # ValueError covers both json.JSONDecodeError (a ValueError subclass) and
100
+ # the UnicodeDecodeError that non-UTF-8 bytes raise from inside json.load.
101
+ except (OSError, ValueError) as exc:
102
+ self._quarantine(f"could not read metadata file {self._file_path} ({exc})")
103
+ return None
104
+ if not isinstance(data, dict):
105
+ self._quarantine(
106
+ f"metadata file {self._file_path} is not a JSON object "
107
+ f"(found {type(data).__name__})"
108
+ )
109
+ return None
110
+ return data
111
+
112
+ def _backup(self) -> None:
113
+ """Copy the on-disk file aside before a lossy rewrite can overwrite it.
114
+
115
+ This runs at load time, while the original bytes are still on disk: the
116
+ next mutation rewrites the file from what was loaded, so anything _load
117
+ could not round-trip is gone by then. A single backup slot is used,
118
+ overwritten on repeat, kept separate from the quarantine slot so the two
119
+ recovery cases cannot clobber each other.
120
+ """
121
+ backup_path = self._file_path.with_name(self._file_path.name + ".bak")
122
+ reason = (
123
+ f"rewriting {self._file_path} in this build's format will drop "
124
+ "information it currently holds"
125
+ )
126
+ try:
127
+ shutil.copy2(self._file_path, backup_path)
128
+ except OSError as exc:
129
+ _warn(f"{reason}; could not preserve the original at {backup_path} ({exc})")
130
+ else:
131
+ _warn(f"{reason}; preserved the original at {backup_path}")
132
+
133
+ def _load_section(
134
+ self, data: Dict[str, Any], section: str, model: Any
135
+ ) -> Tuple[Dict[str, Any], bool]:
136
+ """Rebuild one section, skipping (not discarding) individually broken entries.
137
+
138
+ Returns the loaded entries and whether anything stored was left behind --
139
+ an entry that could not be rebuilt at all, or one carrying a field this
140
+ build does not declare and so would drop on the next write.
141
+ """
142
+ entries = data.get(section, {})
143
+ if not isinstance(entries, dict):
144
+ _warn(
145
+ f'ignoring the "{section}" section of {self._file_path}: '
146
+ f"expected an object, found {type(entries).__name__}"
147
+ )
148
+ return {}, True
149
+
150
+ loaded: Dict[str, Any] = {}
151
+ lossy = False
152
+ for key, entry in entries.items():
153
+ if not isinstance(entry, dict):
154
+ lossy = True
155
+ _warn(
156
+ f"skipping malformed {section} entry {key!r} in {self._file_path}: "
157
+ f"expected an object, found {type(entry).__name__}"
158
+ )
159
+ continue
160
+ try:
161
+ loaded[key] = model.from_dict(entry)
162
+ except _ENTRY_ERRORS as exc:
163
+ lossy = True
164
+ _warn(f"skipping malformed {section} entry {key!r} in {self._file_path}: {exc!r}")
165
+ continue
166
+ # The entry loaded, but a field only a newer build knows about is not
167
+ # carried into the rebuilt model and disappears on the next write.
168
+ extra = unknown_fields(model, entry)
169
+ if extra:
170
+ lossy = True
171
+ _warn(
172
+ f"{section} entry {key!r} in {self._file_path} has field(s) this build "
173
+ f"does not understand ({', '.join(extra)}); they are dropped when it "
174
+ "is rewritten"
175
+ )
176
+ return loaded, lossy
177
+
178
+ def _load_version(self, data: Dict[str, Any]) -> Tuple[int, bool]:
179
+ """Interpret the version header, returning the version and whether it is lossy."""
180
+ if "version" not in data:
181
+ # An absent version means a legacy pre-versioning file: same shape as v1.
182
+ return SCHEMA_VERSION, False
183
+
184
+ raw = data["version"]
185
+ # JSON has a single number type, so tools freely normalize 1 to 1.0; an
186
+ # integral number is that version. bool is an int subclass in Python, but
187
+ # a true/false header is nonsense rather than version 1.
188
+ if isinstance(raw, int) and not isinstance(raw, bool):
189
+ version = raw
190
+ elif isinstance(raw, float) and raw.is_integer():
191
+ version = int(raw)
192
+ else:
193
+ # The entries do not depend on the header, so never discard them over
194
+ # it: warn, read the file as legacy v1, and preserve the original
195
+ # because the rewritten header will not match what is there now.
196
+ _warn(
197
+ f'metadata file {self._file_path} has an invalid "version" header '
198
+ f"({raw!r}); reading it as schema version {SCHEMA_VERSION}"
199
+ )
200
+ return SCHEMA_VERSION, True
201
+
202
+ if version > SCHEMA_VERSION:
203
+ _warn(
204
+ f"{self._file_path} was written by a newer devlaunch (schema version "
205
+ f"{version}, this build understands {SCHEMA_VERSION}); its entries are "
206
+ f"loaded as-is, and the next change rewrites the whole file as schema "
207
+ f"version {SCHEMA_VERSION}"
208
+ )
209
+ return version, True
210
+
211
+ # A version below SCHEMA_VERSION is an older shape, upgraded on the next
212
+ # write; the value is exposed unchanged so a migration can branch on it.
213
+ return version, False
214
+
215
+ def _load(self) -> None:
216
+ """Load metadata from disk, never raising on damaged input."""
217
+ self.repositories: Dict[str, BaseRepository] = {}
218
+ self.worktrees: Dict[str, WorktreeInfo] = {}
219
+ self.schema_version: int = SCHEMA_VERSION
220
+
221
+ data = self._read_file()
222
+ if data is None:
223
+ return
224
+
225
+ self.schema_version, version_is_lossy = self._load_version(data)
226
+ self.repositories, repos_skipped = self._load_section(data, "repositories", BaseRepository)
227
+ self.worktrees, worktrees_skipped = self._load_section(data, "worktrees", WorktreeInfo)
228
+
229
+ unknown = sorted(set(data) - _KNOWN_SECTIONS)
230
+ if unknown:
231
+ _warn(
232
+ f"{self._file_path} has top-level key(s) this build does not understand "
233
+ f"({', '.join(unknown)}); they are dropped when it is rewritten"
234
+ )
235
+
236
+ if version_is_lossy or repos_skipped or worktrees_skipped or unknown:
237
+ self._backup()
238
+
239
+ def save(self) -> None:
240
+ """Save metadata to disk atomically.
241
+
242
+ Writes to a fresh temp file, fsyncs it, then renames it over the real
243
+ path, so an interrupted write can never leave a truncated metadata.json
244
+ behind. Write failures are deliberately not swallowed: silently losing
245
+ workspace metadata is worse than an error.
246
+ """
247
+ data = {
248
+ "version": SCHEMA_VERSION,
249
+ "repositories": {key: repo.to_dict() for key, repo in self.repositories.items()},
250
+ "worktrees": {key: worktree.to_dict() for key, worktree in self.worktrees.items()},
251
+ }
252
+
253
+ # mkstemp gives a name no other writer can be holding, in the same
254
+ # directory so the rename stays atomic, created 0600 so the contents are
255
+ # never briefly world-readable.
256
+ fd, temp_name = tempfile.mkstemp(
257
+ dir=self._file_path.parent, prefix=f"{self._file_path.name}.", suffix=".tmp"
258
+ )
259
+ temp_path = Path(temp_name)
260
+ try:
261
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
262
+ json.dump(data, f, indent=2)
263
+ f.flush()
264
+ os.fsync(f.fileno())
265
+ # Renaming a fresh file would otherwise reset the mode to the umask
266
+ # default, silently widening a metadata.json the user locked down.
267
+ mode = _file_mode(self._file_path)
268
+ if mode is not None:
269
+ os.chmod(temp_path, mode)
270
+ temp_path.replace(self._file_path)
271
+ finally:
272
+ # No-op after a successful rename; cleans up a failed write.
273
+ with contextlib.suppress(OSError):
274
+ temp_path.unlink(missing_ok=True)
275
+
276
+ def add_repository(self, repo: BaseRepository) -> None:
277
+ """Add or update a repository."""
278
+ key = f"{repo.owner}/{repo.repo}"
279
+ self.repositories[key] = repo
280
+ self.save()
281
+
282
+ def get_repository(self, owner: str, repo: str) -> Optional[BaseRepository]:
283
+ """Get a repository by owner and name."""
284
+ key = f"{owner}/{repo}"
285
+ return self.repositories.get(key)
286
+
287
+ def list_repositories(self) -> List[BaseRepository]:
288
+ """List all repositories."""
289
+ return list(self.repositories.values())
290
+
291
+ def remove_repository(self, owner: str, repo: str) -> None:
292
+ """Remove a repository."""
293
+ key = f"{owner}/{repo}"
294
+ if key in self.repositories:
295
+ del self.repositories[key]
296
+ self.save()
297
+
298
+ def add_worktree(self, worktree: WorktreeInfo) -> None:
299
+ """Add or update a worktree."""
300
+ key = f"{worktree.owner}/{worktree.repo}/{worktree.branch}"
301
+ self.worktrees[key] = worktree
302
+
303
+ # Update repository's worktree list in memory, then write once.
304
+ repo = self.get_repository(worktree.owner, worktree.repo)
305
+ if repo and worktree.branch not in repo.worktrees:
306
+ repo.worktrees.append(worktree.branch)
307
+ self.repositories[f"{worktree.owner}/{worktree.repo}"] = repo
308
+
309
+ self.save()
310
+
311
+ def get_worktree(self, owner: str, repo: str, branch: str) -> Optional[WorktreeInfo]:
312
+ """Get a worktree by repository and branch."""
313
+ key = f"{owner}/{repo}/{branch}"
314
+ return self.worktrees.get(key)
315
+
316
+ def list_worktrees(
317
+ self, owner: Optional[str] = None, repo: Optional[str] = None
318
+ ) -> List[WorktreeInfo]:
319
+ """List worktrees, optionally filtered by repository."""
320
+ worktrees = list(self.worktrees.values())
321
+
322
+ if owner and repo:
323
+ worktrees = [w for w in worktrees if w.owner == owner and w.repo == repo]
324
+ elif owner:
325
+ worktrees = [w for w in worktrees if w.owner == owner]
326
+
327
+ return worktrees
328
+
329
+ def get_worktree_by_workspace_id(self, workspace_id: str) -> Optional[WorktreeInfo]:
330
+ """Look up a worktree by its DevPod workspace ID."""
331
+ for worktree in self.worktrees.values():
332
+ if worktree.workspace_id == workspace_id:
333
+ return worktree
334
+ return None
335
+
336
+ def remove_worktree(self, owner: str, repo: str, branch: str) -> None:
337
+ """Remove a worktree."""
338
+ key = f"{owner}/{repo}/{branch}"
339
+ if key in self.worktrees:
340
+ del self.worktrees[key]
341
+
342
+ # Update repository's worktree list in memory, then write once.
343
+ repo_obj = self.get_repository(owner, repo)
344
+ if repo_obj and branch in repo_obj.worktrees:
345
+ repo_obj.worktrees.remove(branch)
346
+ self.repositories[f"{owner}/{repo}"] = repo_obj
347
+
348
+ self.save()
@@ -214,8 +214,6 @@ class WorkspaceCloneManager:
214
214
  bare_repo_path = self.repo_manager.get_bare_path(owner, repo)
215
215
 
216
216
  ws_path = self.get_workspace_path(owner, repo, branch)
217
- is_new_workspace = False
218
-
219
217
  is_new_workspace = False
220
218
  if not self.workspace_exists(owner, repo, branch):
221
219
  is_new_workspace = True
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devlaunch"
3
- version = "0.0.7"
3
+ version = "0.0.8"
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"
@@ -1,131 +0,0 @@
1
- """Storage utilities for worktree metadata."""
2
-
3
- import json
4
- import os
5
- from pathlib import Path
6
- from typing import Dict, List, Optional
7
-
8
- from .models import BaseRepository, WorktreeInfo
9
-
10
-
11
- def _get_default_metadata_path() -> Path:
12
- """Get the default metadata path, honoring XDG_CACHE_HOME."""
13
- xdg_cache = os.environ.get("XDG_CACHE_HOME")
14
- if xdg_cache:
15
- return Path(xdg_cache) / "devlaunch" / "metadata.json"
16
- return Path.home() / ".cache" / "devlaunch" / "metadata.json"
17
-
18
-
19
- class MetadataStorage:
20
- """Handles persistent storage of worktree metadata."""
21
-
22
- def __init__(self, metadata_path: Optional[Path] = None):
23
- """Initialize metadata storage."""
24
- if metadata_path is None:
25
- metadata_path = _get_default_metadata_path()
26
- self.metadata_path = metadata_path
27
- self.metadata_path.parent.mkdir(parents=True, exist_ok=True)
28
- self._load()
29
-
30
- def _load(self) -> None:
31
- """Load metadata from disk."""
32
- if self.metadata_path.exists():
33
- with open(self.metadata_path, "r", encoding="utf-8") as f:
34
- data = json.load(f)
35
- else:
36
- data = {"repositories": {}, "worktrees": {}}
37
-
38
- self.repositories: Dict[str, BaseRepository] = {}
39
- self.worktrees: Dict[str, WorktreeInfo] = {}
40
-
41
- # Load repositories
42
- for key, repo_data in data.get("repositories", {}).items():
43
- self.repositories[key] = BaseRepository.from_dict(repo_data)
44
-
45
- # Load worktrees
46
- for key, worktree_data in data.get("worktrees", {}).items():
47
- self.worktrees[key] = WorktreeInfo.from_dict(worktree_data)
48
-
49
- def save(self) -> None:
50
- """Save metadata to disk."""
51
- data = {
52
- "repositories": {key: repo.to_dict() for key, repo in self.repositories.items()},
53
- "worktrees": {key: worktree.to_dict() for key, worktree in self.worktrees.items()},
54
- }
55
-
56
- with open(self.metadata_path, "w", encoding="utf-8") as f:
57
- json.dump(data, f, indent=2)
58
-
59
- def add_repository(self, repo: BaseRepository) -> None:
60
- """Add or update a repository."""
61
- key = f"{repo.owner}/{repo.repo}"
62
- self.repositories[key] = repo
63
- self.save()
64
-
65
- def get_repository(self, owner: str, repo: str) -> Optional[BaseRepository]:
66
- """Get a repository by owner and name."""
67
- key = f"{owner}/{repo}"
68
- return self.repositories.get(key)
69
-
70
- def list_repositories(self) -> List[BaseRepository]:
71
- """List all repositories."""
72
- return list(self.repositories.values())
73
-
74
- def remove_repository(self, owner: str, repo: str) -> None:
75
- """Remove a repository."""
76
- key = f"{owner}/{repo}"
77
- if key in self.repositories:
78
- del self.repositories[key]
79
- self.save()
80
-
81
- def add_worktree(self, worktree: WorktreeInfo) -> None:
82
- """Add or update a worktree."""
83
- key = f"{worktree.owner}/{worktree.repo}/{worktree.branch}"
84
- self.worktrees[key] = worktree
85
-
86
- # Update repository's worktree list
87
- repo = self.get_repository(worktree.owner, worktree.repo)
88
- if repo and worktree.branch not in repo.worktrees:
89
- repo.worktrees.append(worktree.branch)
90
- self.add_repository(repo)
91
-
92
- self.save()
93
-
94
- def get_worktree(self, owner: str, repo: str, branch: str) -> Optional[WorktreeInfo]:
95
- """Get a worktree by repository and branch."""
96
- key = f"{owner}/{repo}/{branch}"
97
- return self.worktrees.get(key)
98
-
99
- def list_worktrees(
100
- self, owner: Optional[str] = None, repo: Optional[str] = None
101
- ) -> List[WorktreeInfo]:
102
- """List worktrees, optionally filtered by repository."""
103
- worktrees = list(self.worktrees.values())
104
-
105
- if owner and repo:
106
- worktrees = [w for w in worktrees if w.owner == owner and w.repo == repo]
107
- elif owner:
108
- worktrees = [w for w in worktrees if w.owner == owner]
109
-
110
- return worktrees
111
-
112
- def get_worktree_by_workspace_id(self, workspace_id: str) -> Optional[WorktreeInfo]:
113
- """Look up a worktree by its DevPod workspace ID."""
114
- for worktree in self.worktrees.values():
115
- if worktree.workspace_id == workspace_id:
116
- return worktree
117
- return None
118
-
119
- def remove_worktree(self, owner: str, repo: str, branch: str) -> None:
120
- """Remove a worktree."""
121
- key = f"{owner}/{repo}/{branch}"
122
- if key in self.worktrees:
123
- del self.worktrees[key]
124
-
125
- # Update repository's worktree list
126
- repo_obj = self.get_repository(owner, repo)
127
- if repo_obj and branch in repo_obj.worktrees:
128
- repo_obj.worktrees.remove(branch)
129
- self.add_repository(repo_obj)
130
-
131
- self.save()
File without changes