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.
- {devlaunch-0.0.7 → devlaunch-0.0.8}/.gitignore +0 -12
- {devlaunch-0.0.7 → devlaunch-0.0.8}/PKG-INFO +48 -9
- {devlaunch-0.0.7 → devlaunch-0.0.8}/README.md +47 -8
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/dl.py +77 -45
- devlaunch-0.0.8/devlaunch/gh_auth.py +171 -0
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/worktree/branch_manager.py +0 -49
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/worktree/config.py +0 -10
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/worktree/models.py +28 -6
- devlaunch-0.0.8/devlaunch/worktree/storage.py +348 -0
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/worktree/workspace_clone.py +0 -2
- {devlaunch-0.0.7 → devlaunch-0.0.8}/pyproject.toml +1 -1
- devlaunch-0.0.7/devlaunch/worktree/storage.py +0 -131
- {devlaunch-0.0.7 → devlaunch-0.0.8}/LICENSE +0 -0
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.7 → devlaunch-0.0.8}/devlaunch/worktree/repo_manager.py +0 -0
|
@@ -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.
|
|
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
|
[](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged)
|
|
34
34
|
[](https://GitHub.com/blooop/devlaunch/releases/)
|
|
35
35
|
[](https://pypi.org/project/devlaunch/)
|
|
36
|
-
[](https://prefix.dev/channels/blooop/packages/devlaunch)
|
|
37
37
|
[](https://opensource.org/license/mit/)
|
|
38
38
|
[](https://www.python.org/downloads/)
|
|
39
39
|
[](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
|
[](https://github.com/blooop/devlaunch/pulls?q=is%3Amerged)
|
|
11
11
|
[](https://GitHub.com/blooop/devlaunch/releases/)
|
|
12
12
|
[](https://pypi.org/project/devlaunch/)
|
|
13
|
-
[](https://prefix.dev/channels/blooop/packages/devlaunch)
|
|
14
14
|
[](https://opensource.org/license/mit/)
|
|
15
15
|
[](https://www.python.org/downloads/)
|
|
16
16
|
[](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(
|
|
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
|
-
|
|
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,
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|