devlaunch 0.0.10__tar.gz → 0.0.12__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.10 → devlaunch-0.0.12}/PKG-INFO +38 -6
- {devlaunch-0.0.10 → devlaunch-0.0.12}/README.md +37 -4
- devlaunch-0.0.12/devlaunch/devpod_ssh.py +143 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/dl.py +72 -5
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/__init__.py +3 -0
- devlaunch-0.0.12/devlaunch/worktree/migration.py +264 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/storage.py +20 -7
- {devlaunch-0.0.10 → devlaunch-0.0.12}/pyproject.toml +1 -2
- {devlaunch-0.0.10 → devlaunch-0.0.12}/.gitignore +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/LICENSE +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/workspace_clone.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlaunch
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.12
|
|
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
|
|
@@ -8,7 +8,6 @@ Author-email: Austin Gregg-Smith <blooop@gmail.com>
|
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Requires-Dist: iterfzf>=1.0.0
|
|
11
|
-
Requires-Dist: tomli-w>=1.0.0
|
|
12
11
|
Requires-Dist: tomli>=2.0.0
|
|
13
12
|
Provides-Extra: test
|
|
14
13
|
Requires-Dist: coverage<=7.14.1,>=7.5.4; extra == 'test'
|
|
@@ -162,10 +161,43 @@ to leave room for tools that add their own prefixes.
|
|
|
162
161
|
Branch names must be safe as both git refs and directory names — a name with a space or
|
|
163
162
|
a leading dash is rejected rather than quietly rewritten.
|
|
164
163
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
164
|
+
### Upgrading from an older devlaunch
|
|
165
|
+
|
|
166
|
+
This id format is new, and the directories and containers on your machine were named by
|
|
167
|
+
the previous scheme. The first `dl user/repo…` command after upgrading migrates the cache
|
|
168
|
+
once and prints what it did. `dl --help`, `dl --version`, `dl --ls` and opening an existing
|
|
169
|
+
workspace by name do not trigger it.
|
|
170
|
+
|
|
171
|
+
**Your clone directories are renamed.** What was
|
|
172
|
+
`~/.cache/devlaunch/repos/blooop/devlaunch/main` becomes
|
|
173
|
+
`~/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo`. A workspace is a git
|
|
174
|
+
clone whose `origin` points at the `.bare` cache next to it, and `.bare` does not move, so
|
|
175
|
+
this is a plain rename: branches, history and **uncommitted changes all survive** — only
|
|
176
|
+
the folder name changes. `metadata.json` is updated in the same pass, so nothing is left
|
|
177
|
+
pointing at the old name.
|
|
178
|
+
|
|
179
|
+
**Your existing devpod containers keep their old ids and are orphaned.** The next
|
|
180
|
+
`dl user/repo@branch` builds a fresh container under the new id. dl does not delete
|
|
181
|
+
containers for you — deleting by id is how a running sidecar got destroyed the last time
|
|
182
|
+
something tried ([kinisi_ros#9766](https://github.com/kinisi-robotics/kinisi_ros/pull/9766)) —
|
|
183
|
+
so it prints a one-line notice with the count and writes the old ids to
|
|
184
|
+
`~/.cache/devlaunch/orphaned-workspaces.txt`. Remove them when you are ready:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
xargs -r -n1 devpod delete < ~/.cache/devlaunch/orphaned-workspaces.txt
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**A clone directory with no metadata record is left alone.** Nothing records which branch
|
|
191
|
+
it was cloned for, and the old directory name cannot be turned back into one — `feature/auth`
|
|
192
|
+
and `feature-auth` both became `feature-auth` — so a guessed name would be worse than no
|
|
193
|
+
rename. Those directories stay exactly where they are and are listed in
|
|
194
|
+
`~/.cache/devlaunch/unmigrated-clones.txt`.
|
|
195
|
+
|
|
196
|
+
Running dl again changes nothing: the migration is keyed on the `version` field in
|
|
197
|
+
`metadata.json`, not on directory names, so a branch that happens to look like a new-scheme
|
|
198
|
+
id is never mistaken for one. If a migration is interrupted, the next run finishes it — the
|
|
199
|
+
version is written last, in the same atomic save as the new paths, so it never claims more
|
|
200
|
+
than the filesystem has actually done.
|
|
169
201
|
|
|
170
202
|
## Workspace Commands
|
|
171
203
|
|
|
@@ -139,10 +139,43 @@ to leave room for tools that add their own prefixes.
|
|
|
139
139
|
Branch names must be safe as both git refs and directory names — a name with a space or
|
|
140
140
|
a leading dash is rejected rather than quietly rewritten.
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
### Upgrading from an older devlaunch
|
|
143
|
+
|
|
144
|
+
This id format is new, and the directories and containers on your machine were named by
|
|
145
|
+
the previous scheme. The first `dl user/repo…` command after upgrading migrates the cache
|
|
146
|
+
once and prints what it did. `dl --help`, `dl --version`, `dl --ls` and opening an existing
|
|
147
|
+
workspace by name do not trigger it.
|
|
148
|
+
|
|
149
|
+
**Your clone directories are renamed.** What was
|
|
150
|
+
`~/.cache/devlaunch/repos/blooop/devlaunch/main` becomes
|
|
151
|
+
`~/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo`. A workspace is a git
|
|
152
|
+
clone whose `origin` points at the `.bare` cache next to it, and `.bare` does not move, so
|
|
153
|
+
this is a plain rename: branches, history and **uncommitted changes all survive** — only
|
|
154
|
+
the folder name changes. `metadata.json` is updated in the same pass, so nothing is left
|
|
155
|
+
pointing at the old name.
|
|
156
|
+
|
|
157
|
+
**Your existing devpod containers keep their old ids and are orphaned.** The next
|
|
158
|
+
`dl user/repo@branch` builds a fresh container under the new id. dl does not delete
|
|
159
|
+
containers for you — deleting by id is how a running sidecar got destroyed the last time
|
|
160
|
+
something tried ([kinisi_ros#9766](https://github.com/kinisi-robotics/kinisi_ros/pull/9766)) —
|
|
161
|
+
so it prints a one-line notice with the count and writes the old ids to
|
|
162
|
+
`~/.cache/devlaunch/orphaned-workspaces.txt`. Remove them when you are ready:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
xargs -r -n1 devpod delete < ~/.cache/devlaunch/orphaned-workspaces.txt
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**A clone directory with no metadata record is left alone.** Nothing records which branch
|
|
169
|
+
it was cloned for, and the old directory name cannot be turned back into one — `feature/auth`
|
|
170
|
+
and `feature-auth` both became `feature-auth` — so a guessed name would be worse than no
|
|
171
|
+
rename. Those directories stay exactly where they are and are listed in
|
|
172
|
+
`~/.cache/devlaunch/unmigrated-clones.txt`.
|
|
173
|
+
|
|
174
|
+
Running dl again changes nothing: the migration is keyed on the `version` field in
|
|
175
|
+
`metadata.json`, not on directory names, so a branch that happens to look like a new-scheme
|
|
176
|
+
id is never mistaken for one. If a migration is interrupted, the next run finishes it — the
|
|
177
|
+
version is written last, in the same atomic save as the new paths, so it never claims more
|
|
178
|
+
than the filesystem has actually done.
|
|
146
179
|
|
|
147
180
|
## Workspace Commands
|
|
148
181
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""How a `devpod ssh` session ended, recovered from what devpod reports.
|
|
2
|
+
|
|
3
|
+
devpod means to pass a remote process's exit status through. Its top-level error
|
|
4
|
+
handler does:
|
|
5
|
+
|
|
6
|
+
if sshExitErr, ok := err.(*ssh.ExitError); ok {
|
|
7
|
+
os.Exit(sshExitErr.ExitStatus())
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
But by the time the error reaches there it has been wrapped three times —
|
|
11
|
+
`ssh session: %w` in cmd/machine/ssh.go, then "run in container", then "tunnel to
|
|
12
|
+
container" — and a bare type assertion does not see through `%w`. So every
|
|
13
|
+
nonzero remote exit misses that branch and lands on devpod's generic failure
|
|
14
|
+
path instead, which prints
|
|
15
|
+
|
|
16
|
+
error Try using the --debug flag to see a more verbose output root.go:106
|
|
17
|
+
fatal tunnel to container: run in container: ssh session: Process exited with status 130
|
|
18
|
+
|
|
19
|
+
and exits 1.
|
|
20
|
+
|
|
21
|
+
Nothing has gone wrong in that example. A login shell exits with the status of
|
|
22
|
+
its last command, so a single Ctrl-C before typing `exit` is enough to make a
|
|
23
|
+
perfectly ordinary session end 130. The session ran and it ended; devpod just has
|
|
24
|
+
no way left to say so.
|
|
25
|
+
|
|
26
|
+
Both of those lines are Error/Fatal level, which loft-sh/log sends to stderr
|
|
27
|
+
(Info-level progress goes to stdout, so reading stderr does not hold back the
|
|
28
|
+
"waiting for workspace" chatter). That makes the status recoverable: read
|
|
29
|
+
devpod's stderr, take the status out of the message it buried it in, and hold
|
|
30
|
+
back the two lines that only exist because devpod could not report it properly.
|
|
31
|
+
|
|
32
|
+
The distinction the rest of devlaunch needs is which process the resulting number
|
|
33
|
+
came from, so it is a type rather than a bare int — see SshOutcome.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import Iterable, NoReturn, Optional, TextIO
|
|
39
|
+
|
|
40
|
+
# devpod prints this immediately before the fatal it belongs to, so it has to be
|
|
41
|
+
# held for one line to see which fatal that is.
|
|
42
|
+
DEBUG_HINT = "Try using the --debug flag to see a more verbose output"
|
|
43
|
+
|
|
44
|
+
# The status golang.org/x/crypto/ssh formatted into an *ssh.ExitError:
|
|
45
|
+
# "Process exited with status 130", optionally " from signal SIGINT" and
|
|
46
|
+
# ". Reason was: ...". Anchored on devpod's "fatal" tag as well so a remote
|
|
47
|
+
# program printing the same sentence on its own stderr (which reaches us only
|
|
48
|
+
# when there is no pty) cannot be mistaken for devpod's report.
|
|
49
|
+
#
|
|
50
|
+
# No \b before "fatal": devpod colours the tag, and the escape it emits ends in
|
|
51
|
+
# "m", so there is no word boundary in front of it.
|
|
52
|
+
REMOTE_EXIT_RE = re.compile(r"fatal\b.*\bssh session: Process exited with status (\d+)")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class RemoteExit:
|
|
57
|
+
"""devpod ran the remote program, and it exited with `status`.
|
|
58
|
+
|
|
59
|
+
Not a devlaunch failure, whatever `status` is: the shell or command the user
|
|
60
|
+
asked for ran to completion. `status` belongs to that program.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
status: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class DevpodFailed:
|
|
68
|
+
"""devpod never ran the remote program, or lost it partway.
|
|
69
|
+
|
|
70
|
+
`exit_code` is devpod's own. devpod has already written its diagnostics to
|
|
71
|
+
stderr by the time this is constructed, so it carries no message of its own —
|
|
72
|
+
there is nothing devlaunch knows that the user has not already been told.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
exit_code: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
SshOutcome = RemoteExit | DevpodFailed
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def assert_never(value: NoReturn) -> NoReturn:
|
|
82
|
+
"""Fail loudly on an SshOutcome arm nobody handled.
|
|
83
|
+
|
|
84
|
+
A runtime backstop, not a compile-time one: `ty`, the checker this project
|
|
85
|
+
runs in CI, does not currently reject a `match` that drops an arm. It is
|
|
86
|
+
still worth having, because a `match` with no fallthrough returns None, and
|
|
87
|
+
`main` returning None makes `dl` exit 0 — a new outcome would otherwise go
|
|
88
|
+
out as success.
|
|
89
|
+
|
|
90
|
+
Stands in for typing.assert_never, which needs 3.11; this project is 3.10+.
|
|
91
|
+
"""
|
|
92
|
+
raise AssertionError(f"unhandled outcome: {value!r}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def filter_devpod_stderr(lines: Iterable[str], out: TextIO) -> Optional[int]:
|
|
96
|
+
"""Forward devpod's stderr, holding back its report of a remote exit status.
|
|
97
|
+
|
|
98
|
+
Returns that status if devpod reported one. Everything else is passed through
|
|
99
|
+
verbatim and unbuffered, so a genuine devpod failure still reads exactly as
|
|
100
|
+
it does today — including the --debug hint, which is released ahead of the
|
|
101
|
+
fatal it precedes rather than after it.
|
|
102
|
+
"""
|
|
103
|
+
remote_status: Optional[int] = None
|
|
104
|
+
held_hint: Optional[str] = None
|
|
105
|
+
|
|
106
|
+
for line in lines:
|
|
107
|
+
match = REMOTE_EXIT_RE.search(line)
|
|
108
|
+
if match:
|
|
109
|
+
remote_status = int(match.group(1))
|
|
110
|
+
# The hint introduced this fatal, so it goes with it.
|
|
111
|
+
held_hint = None
|
|
112
|
+
continue
|
|
113
|
+
if DEBUG_HINT in line:
|
|
114
|
+
held_hint = line
|
|
115
|
+
continue
|
|
116
|
+
if held_hint is not None:
|
|
117
|
+
out.write(held_hint)
|
|
118
|
+
held_hint = None
|
|
119
|
+
out.write(line)
|
|
120
|
+
out.flush()
|
|
121
|
+
|
|
122
|
+
if held_hint is not None:
|
|
123
|
+
out.write(held_hint)
|
|
124
|
+
out.flush()
|
|
125
|
+
|
|
126
|
+
return remote_status
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def interpret(devpod_exit_code: int, remote_status: Optional[int]) -> SshOutcome:
|
|
130
|
+
"""Decide what a finished `devpod ssh` actually reported.
|
|
131
|
+
|
|
132
|
+
A recovered remote status wins over devpod's own exit code, because devpod
|
|
133
|
+
reports 1 alongside it regardless of what the remote program returned.
|
|
134
|
+
"""
|
|
135
|
+
if remote_status is not None:
|
|
136
|
+
return RemoteExit(remote_status)
|
|
137
|
+
if devpod_exit_code == 0:
|
|
138
|
+
return RemoteExit(0)
|
|
139
|
+
# No status to recover. Either devpod really did fail, or a future devpod
|
|
140
|
+
# unwraps the error properly and exits with the remote status itself — in
|
|
141
|
+
# which case this is still the right number to pass on, and devpod stayed
|
|
142
|
+
# quiet, so nothing spurious is printed either way.
|
|
143
|
+
return DevpodFailed(devpod_exit_code)
|
|
@@ -33,10 +33,11 @@ from dataclasses import dataclass
|
|
|
33
33
|
from urllib.parse import urlparse
|
|
34
34
|
from urllib.request import url2pathname
|
|
35
35
|
|
|
36
|
-
from . import gh_auth
|
|
36
|
+
from . import devpod_ssh, gh_auth
|
|
37
37
|
from .completion import install_completions
|
|
38
38
|
from .workspace_id import TARGET_LENGTH, WorkspaceId, slug, source_workspace_id, validate_ref_name
|
|
39
39
|
from .worktree.config import get_worktree_config
|
|
40
|
+
from .worktree.migration import migrate_cache
|
|
40
41
|
from .worktree.workspace_clone import WorkspaceCloneManager
|
|
41
42
|
|
|
42
43
|
|
|
@@ -831,6 +832,38 @@ def run_devpod(
|
|
|
831
832
|
raise DevpodNotInstalled(DEVPOD_MISSING_MESSAGE) from e
|
|
832
833
|
|
|
833
834
|
|
|
835
|
+
def run_devpod_session(
|
|
836
|
+
args: List[str], env: Optional[Dict[str, str]] = None
|
|
837
|
+
) -> devpod_ssh.SshOutcome:
|
|
838
|
+
"""Run a devpod command that hands its stdin/stdout to a terminal session.
|
|
839
|
+
|
|
840
|
+
stdin and stdout are inherited untouched — devpod puts the real terminal into
|
|
841
|
+
raw mode through them, and requests a pty on that basis. Only stderr is read,
|
|
842
|
+
which under a pty carries devpod's own warnings and errors and nothing else,
|
|
843
|
+
so that devpod's report of how the session ended can be interpreted rather
|
|
844
|
+
than dumped on the user. See devpod_ssh for why that is necessary.
|
|
845
|
+
"""
|
|
846
|
+
cmd = ["devpod"] + args
|
|
847
|
+
logging.debug("Running: %s", " ".join(cmd))
|
|
848
|
+
# nosec B603 - using list form, not shell=True; no command injection risk
|
|
849
|
+
with subprocess.Popen(
|
|
850
|
+
cmd,
|
|
851
|
+
stderr=subprocess.PIPE,
|
|
852
|
+
text=True,
|
|
853
|
+
encoding="utf-8",
|
|
854
|
+
errors="replace",
|
|
855
|
+
env=env,
|
|
856
|
+
) as proc:
|
|
857
|
+
# proc.stderr is a pipe because PIPE was asked for, but Popen's type
|
|
858
|
+
# cannot express that, so the narrowing happens here rather than by
|
|
859
|
+
# widening filter_devpod_stderr to a None it would have no answer for.
|
|
860
|
+
pipe = proc.stderr
|
|
861
|
+
remote_status = (
|
|
862
|
+
devpod_ssh.filter_devpod_stderr(pipe, sys.stderr) if pipe is not None else None
|
|
863
|
+
)
|
|
864
|
+
return devpod_ssh.interpret(proc.returncode, remote_status)
|
|
865
|
+
|
|
866
|
+
|
|
834
867
|
# The memoized `devpod list` snapshot. A dict rather than a module-level
|
|
835
868
|
# Optional so the accessors below need no `global`, and so "nothing read yet"
|
|
836
869
|
# (no key at all) stays distinguishable from "devpod has no workspaces" (an
|
|
@@ -1048,8 +1081,20 @@ def workspace_ssh(
|
|
|
1048
1081
|
args.extend(token_args)
|
|
1049
1082
|
|
|
1050
1083
|
logging.info(f"SSH command: devpod {' '.join(args)}")
|
|
1051
|
-
|
|
1052
|
-
|
|
1084
|
+
outcome = run_devpod_session(args, env=env)
|
|
1085
|
+
|
|
1086
|
+
# The two arms carry the same kind of number from different processes, which
|
|
1087
|
+
# is exactly the confusion this used to make: `dl` reported devpod's exit
|
|
1088
|
+
# code (always 1) for a session that had ended perfectly normally with, say,
|
|
1089
|
+
# 130. Whichever arm this is, the status returned is the session's.
|
|
1090
|
+
match outcome:
|
|
1091
|
+
case devpod_ssh.RemoteExit(status=status):
|
|
1092
|
+
return status
|
|
1093
|
+
case devpod_ssh.DevpodFailed(exit_code=exit_code):
|
|
1094
|
+
logging.debug("devpod ssh failed with exit code %s", exit_code)
|
|
1095
|
+
return exit_code
|
|
1096
|
+
case _ as unhandled:
|
|
1097
|
+
devpod_ssh.assert_never(unhandled)
|
|
1053
1098
|
|
|
1054
1099
|
|
|
1055
1100
|
def attach_workspace(workspace_id: str, shell_command: Optional[str] = None) -> int:
|
|
@@ -1188,9 +1233,31 @@ _cache: dict[str, WorkspaceCloneManager] = {}
|
|
|
1188
1233
|
|
|
1189
1234
|
|
|
1190
1235
|
def _get_clone_manager() -> WorkspaceCloneManager:
|
|
1191
|
-
"""Lazy factory for WorkspaceCloneManager.
|
|
1236
|
+
"""Lazy factory for WorkspaceCloneManager, migrating the cache on first use.
|
|
1237
|
+
|
|
1238
|
+
This is where the one-shot id-scheme migration runs, for three reasons. It is
|
|
1239
|
+
dl's single construction point for the object that owns every read of a
|
|
1240
|
+
workspace path, so nothing can reach a stale path before the rename. It is
|
|
1241
|
+
lazy, so the commands that touch no workspace -- `--help`, `--version`,
|
|
1242
|
+
`--ls`, the completion commands, `--purge`, and opening an existing workspace
|
|
1243
|
+
by name -- never reach it, which keeps #58's promise that help does no work.
|
|
1244
|
+
And the memo makes it at most once per process.
|
|
1245
|
+
|
|
1246
|
+
On an already-migrated cache this costs one integer comparison, because the
|
|
1247
|
+
trigger is the version header the storage load already parsed. Nothing here
|
|
1248
|
+
spawns devpod: the orphaned container ids come from metadata.
|
|
1249
|
+
"""
|
|
1192
1250
|
if "clone_manager" not in _cache:
|
|
1193
|
-
|
|
1251
|
+
manager = WorkspaceCloneManager()
|
|
1252
|
+
try:
|
|
1253
|
+
migrate_cache(manager.storage, pathlib.Path(manager.config.repos_dir))
|
|
1254
|
+
except OSError as e:
|
|
1255
|
+
# A failed migration must not take the command with it. The renames
|
|
1256
|
+
# that did happen are still resumable: the version header is only
|
|
1257
|
+
# written by the final save, so an unwritten file means the next run
|
|
1258
|
+
# migrates again and finds them already in place.
|
|
1259
|
+
logging.warning(f"Could not migrate the workspace cache: {e}")
|
|
1260
|
+
_cache["clone_manager"] = manager
|
|
1194
1261
|
return _cache["clone_manager"]
|
|
1195
1262
|
|
|
1196
1263
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from .branch_manager import BranchManager
|
|
4
4
|
from .config import WorktreeConfig, get_worktree_config
|
|
5
|
+
from .migration import MigrationReport, migrate_cache
|
|
5
6
|
from .models import BaseRepository, WorktreeInfo
|
|
6
7
|
from .repo_manager import RepositoryManager
|
|
7
8
|
from .storage import MetadataStorage
|
|
@@ -14,6 +15,8 @@ __all__ = [
|
|
|
14
15
|
"get_worktree_config",
|
|
15
16
|
"BranchManager",
|
|
16
17
|
"MetadataStorage",
|
|
18
|
+
"MigrationReport",
|
|
19
|
+
"migrate_cache",
|
|
17
20
|
"RepositoryManager",
|
|
18
21
|
"WorkspaceCloneManager",
|
|
19
22
|
]
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Bring a cache written by an older devlaunch onto the current id scheme.
|
|
2
|
+
|
|
3
|
+
Before blooop/devlaunch#64 a clone directory's leaf was the flattened branch name
|
|
4
|
+
(``<cache>/repos/blooop/devlaunch/main``) and the devpod workspace id was a second,
|
|
5
|
+
separately derived string. Now :class:`~devlaunch.workspace_id.WorkspaceId` derives
|
|
6
|
+
one id that names both (``devlaunch-main-zovomobo``). Every clone directory written
|
|
7
|
+
by an older build therefore sits under a name nothing looks for any more.
|
|
8
|
+
|
|
9
|
+
**Renaming is the right answer, not orphaning.** A workspace is a ``git clone``
|
|
10
|
+
whose ``origin`` points at the ``.bare`` path, and ``.bare`` does not move, so a
|
|
11
|
+
plain ``rename`` is lossless: the clone keeps working and **uncommitted work
|
|
12
|
+
survives**. That work is the one thing in the cache that is not cheaply
|
|
13
|
+
recreatable, which is what decides the strategy (see #55).
|
|
14
|
+
|
|
15
|
+
**The trigger is the version header, not the directory name.** ``metadata.json``
|
|
16
|
+
carries a ``version`` (#56), so this migration runs exactly when
|
|
17
|
+
``schema_version < SCHEMA_VERSION`` and then writes the new version. Sniffing the
|
|
18
|
+
leaf for "a dash plus consonant-vowel pairs" was considered and rejected: a branch
|
|
19
|
+
literally named ``foo-bexoza`` false-positives, and the header makes the trigger
|
|
20
|
+
deterministic and idempotent by construction.
|
|
21
|
+
|
|
22
|
+
**Write ordering.** All renames happen first; then a single
|
|
23
|
+
:meth:`MetadataStorage.save` writes the new paths *and* the new version header in
|
|
24
|
+
one atomic replace. Nothing writes the header early, so "header says 2" always
|
|
25
|
+
means "every path in this file is current". A crash anywhere in the renames leaves
|
|
26
|
+
the header at 1, so the next run migrates again and finds each already-renamed
|
|
27
|
+
directory as "destination present, source gone" -- which it treats as a resumed
|
|
28
|
+
rename and simply catches metadata up to. The reverse ordering has no safe
|
|
29
|
+
resume: saving first would bump the header to 2 while directories were still
|
|
30
|
+
under their old names, and the next run would skip them for good.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import List, Optional, Tuple
|
|
38
|
+
|
|
39
|
+
from ..workspace_id import WorkspaceId
|
|
40
|
+
from .storage import SCHEMA_VERSION, MetadataStorage
|
|
41
|
+
|
|
42
|
+
#: The bare reference repo shares the parent of the clone directories and is never
|
|
43
|
+
#: one of them. It is skipped by name because it is the layout's one fixed leaf.
|
|
44
|
+
BARE_DIR_NAME = ".bare"
|
|
45
|
+
|
|
46
|
+
#: Old devpod workspace ids, one per line, for the cleanup command in the notice.
|
|
47
|
+
ORPHAN_LIST_NAME = "orphaned-workspaces.txt"
|
|
48
|
+
|
|
49
|
+
#: Clone directories the migration deliberately did not rename, one path per line.
|
|
50
|
+
UNMIGRATED_LIST_NAME = "unmigrated-clones.txt"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _notice(message: str) -> None:
|
|
54
|
+
"""Emit one line on stderr (stdout is parsed by the completion machinery)."""
|
|
55
|
+
print(f"dl: {message}", file=sys.stderr)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class MigrationReport:
|
|
60
|
+
"""What one migration run did, for the caller and for the notices."""
|
|
61
|
+
|
|
62
|
+
#: ``(source, destination)`` for each directory actually renamed.
|
|
63
|
+
renamed: List[Tuple[Path, Path]] = field(default_factory=list)
|
|
64
|
+
#: ``(source, destination, error)`` for each rename the filesystem refused.
|
|
65
|
+
failed: List[Tuple[Path, Path, OSError]] = field(default_factory=list)
|
|
66
|
+
#: Recorded paths that no longer exist, so there was nothing to rename.
|
|
67
|
+
missing: List[Path] = field(default_factory=list)
|
|
68
|
+
#: Directories left under their old name because no record names their ref.
|
|
69
|
+
unmigrated: List[Path] = field(default_factory=list)
|
|
70
|
+
#: ``(directory, branch)`` for records holding a ref no id can be derived from.
|
|
71
|
+
unusable: List[Tuple[Path, str]] = field(default_factory=list)
|
|
72
|
+
#: ``(source, destination)`` where the derived name is another record's clone.
|
|
73
|
+
blocked: List[Tuple[Path, Path]] = field(default_factory=list)
|
|
74
|
+
#: Old devpod workspace ids, now orphaned because the id derivation changed.
|
|
75
|
+
orphaned_ids: List[str] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _clone_dirs(repos_dir: Path) -> List[Path]:
|
|
79
|
+
"""Every workspace clone directory under ``repos_dir/<owner>/<repo>/``.
|
|
80
|
+
|
|
81
|
+
The layout is exactly three levels deep, so this is a bounded walk rather than
|
|
82
|
+
an ``rglob``: descending into the clones themselves would traverse every
|
|
83
|
+
checked-out working tree in the cache.
|
|
84
|
+
"""
|
|
85
|
+
found: List[Path] = []
|
|
86
|
+
if not repos_dir.is_dir():
|
|
87
|
+
return found
|
|
88
|
+
try:
|
|
89
|
+
owner_dirs = sorted(p for p in repos_dir.iterdir() if p.is_dir())
|
|
90
|
+
for owner_dir in owner_dirs:
|
|
91
|
+
for repo_dir in sorted(p for p in owner_dir.iterdir() if p.is_dir()):
|
|
92
|
+
found.extend(
|
|
93
|
+
sorted(p for p in repo_dir.iterdir() if p.is_dir() and p.name != BARE_DIR_NAME)
|
|
94
|
+
)
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
_notice(f"could not scan {repos_dir} for old workspace clones ({exc})")
|
|
97
|
+
return found
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _rename(src: Path, dest: Path, report: MigrationReport) -> bool:
|
|
101
|
+
"""Move *src* to *dest*, recording the outcome. False if it did not happen.
|
|
102
|
+
|
|
103
|
+
``os.rename`` and not ``shutil.move``: a rename either happens or does not,
|
|
104
|
+
while a copying fallback could leave a half-written duplicate of a clone that
|
|
105
|
+
holds uncommitted work. A cross-filesystem cache is rare enough to report and
|
|
106
|
+
leave to the user.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
os.rename(src, dest)
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
report.failed.append((src, dest, exc))
|
|
113
|
+
return False
|
|
114
|
+
report.renamed.append((src, dest))
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _migrate_record(record, repos_dir: Path, claimed, report: MigrationReport) -> None:
|
|
119
|
+
"""Put one record's directory under its derived name and update the record.
|
|
120
|
+
|
|
121
|
+
``local_path`` as stored is the source, never a recomputed old path: the record
|
|
122
|
+
is the truth about where the clone is now, which is the same principle that made
|
|
123
|
+
removal work for old-scheme workspaces (#64).
|
|
124
|
+
|
|
125
|
+
``claimed`` is every path some record pointed at before this run started.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
workspace = WorkspaceId(record.owner, record.repo, record.branch)
|
|
129
|
+
except ValueError:
|
|
130
|
+
# The old derivation coerced unsafe refs instead of rejecting them, so a
|
|
131
|
+
# stored branch is not necessarily a legal ref. No id can be derived, so
|
|
132
|
+
# there is no name to rename to; leave the record and the directory as
|
|
133
|
+
# they are and say so.
|
|
134
|
+
report.unusable.append((Path(record.local_path), record.branch))
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
src = Path(record.local_path)
|
|
138
|
+
dest = repos_dir / record.owner / record.repo / workspace.value
|
|
139
|
+
|
|
140
|
+
if dest != src and dest in claimed:
|
|
141
|
+
# The derived name is a directory some *other* record owns. Only possible
|
|
142
|
+
# when a branch was literally named after another branch's derived id --
|
|
143
|
+
# #55's `foo-bexoza` case, now needing an exact hash match. Rename nothing
|
|
144
|
+
# and, unlike every other outcome, do not repoint the record either:
|
|
145
|
+
# adopting a clone another record owns is how one workspace's `rm` deletes
|
|
146
|
+
# another's work, which is the class of bug #9766 was.
|
|
147
|
+
report.blocked.append((src, dest))
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if dest.exists():
|
|
151
|
+
# Either an interrupted earlier run already renamed this clone, or a
|
|
152
|
+
# newer-scheme clone was created alongside the old one. Rename nothing.
|
|
153
|
+
# The record follows the canonically named directory, so that a later
|
|
154
|
+
# `dl ... rm` deletes the clone devpod is actually using; a leftover src is
|
|
155
|
+
# reported below, because it becomes a directory no record points at.
|
|
156
|
+
pass
|
|
157
|
+
elif src.exists():
|
|
158
|
+
if not _rename(src, dest, report):
|
|
159
|
+
return
|
|
160
|
+
else:
|
|
161
|
+
# Already stale before this run: the record outlived its directory. Not a
|
|
162
|
+
# failure -- repointing it at the derived path is what a fresh clone would
|
|
163
|
+
# use, and `workspace_exists` reads the filesystem, so nothing is misled.
|
|
164
|
+
report.missing.append(src)
|
|
165
|
+
|
|
166
|
+
old_id = record.workspace_id
|
|
167
|
+
if old_id != workspace.value:
|
|
168
|
+
report.orphaned_ids.append(old_id)
|
|
169
|
+
record.local_path = dest
|
|
170
|
+
# The record carries the derived id, because `remove_workspace_by_id` looks up
|
|
171
|
+
# records by exactly the id dl derives from the spec. `devpod_workspace_id` is
|
|
172
|
+
# left alone: #55 flagged holding two ids in one record as a modelling defect,
|
|
173
|
+
# and giving that field a second meaning ("the orphaned old container") would
|
|
174
|
+
# make the defect worse. The orphaned ids go in the notice instead.
|
|
175
|
+
record.workspace_id = workspace.value
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _announce(report: MigrationReport, cache_dir: Path) -> None:
|
|
179
|
+
"""Tell the user what changed, in one line per kind of outcome."""
|
|
180
|
+
if report.renamed:
|
|
181
|
+
src, dest = report.renamed[0]
|
|
182
|
+
_notice(
|
|
183
|
+
f"migrated {len(report.renamed)} workspace clone director"
|
|
184
|
+
f"{'y' if len(report.renamed) == 1 else 'ies'} to the new id scheme "
|
|
185
|
+
f"(e.g. {src.name} -> {dest.name})"
|
|
186
|
+
)
|
|
187
|
+
for src, dest, exc in report.failed:
|
|
188
|
+
_notice(f"could not rename {src} to {dest} ({exc}); it was left where it is")
|
|
189
|
+
if report.missing:
|
|
190
|
+
_notice(
|
|
191
|
+
f"{len(report.missing)} metadata record(s) pointed at a clone directory that is "
|
|
192
|
+
"no longer there; they now point at their new-scheme path"
|
|
193
|
+
)
|
|
194
|
+
for path, branch in report.unusable:
|
|
195
|
+
_notice(
|
|
196
|
+
f"left {path} as it is: its recorded branch {branch!r} is not a usable git ref, "
|
|
197
|
+
"so no id can be derived for it"
|
|
198
|
+
)
|
|
199
|
+
for src, dest in report.blocked:
|
|
200
|
+
_notice(
|
|
201
|
+
f"left {src} as it is: its new name {dest.name} is already another workspace's "
|
|
202
|
+
"clone directory; move or delete one of them by hand"
|
|
203
|
+
)
|
|
204
|
+
if report.unmigrated:
|
|
205
|
+
listing = _write_lines(
|
|
206
|
+
cache_dir / UNMIGRATED_LIST_NAME, [str(p) for p in report.unmigrated]
|
|
207
|
+
)
|
|
208
|
+
_notice(
|
|
209
|
+
f"{len(report.unmigrated)} clone director"
|
|
210
|
+
f"{'y' if len(report.unmigrated) == 1 else 'ies'} could not be renamed (no metadata "
|
|
211
|
+
f"record, so the branch they were cloned for is unknown) and were left as they are"
|
|
212
|
+
+ (f"; listed in {listing}" if listing else "")
|
|
213
|
+
)
|
|
214
|
+
if report.orphaned_ids:
|
|
215
|
+
listing = _write_lines(cache_dir / ORPHAN_LIST_NAME, sorted(report.orphaned_ids))
|
|
216
|
+
cleanup = (
|
|
217
|
+
f"xargs -r -n1 devpod delete < {listing}"
|
|
218
|
+
if listing
|
|
219
|
+
else "devpod delete <old-id>, one per workspace"
|
|
220
|
+
)
|
|
221
|
+
_notice(
|
|
222
|
+
f"{len(report.orphaned_ids)} devpod container(s) still carry the old workspace ids "
|
|
223
|
+
f"and are now orphaned; dl does not delete containers for you -- remove them with: "
|
|
224
|
+
f"{cleanup}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _write_lines(path: Path, lines: List[str]) -> Optional[Path]:
|
|
229
|
+
"""Write one line per entry, returning the path, or None if it could not be."""
|
|
230
|
+
try:
|
|
231
|
+
path.write_text("".join(f"{line}\n" for line in lines), encoding="utf-8")
|
|
232
|
+
except OSError as exc:
|
|
233
|
+
_notice(f"could not write {path} ({exc})")
|
|
234
|
+
return None
|
|
235
|
+
return path
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def migrate_cache(storage: MetadataStorage, repos_dir: Path) -> Optional[MigrationReport]:
|
|
239
|
+
"""Migrate *storage* and the clone directories under *repos_dir*, once.
|
|
240
|
+
|
|
241
|
+
Returns None when the cache is already current, which is the common case and
|
|
242
|
+
costs a single integer comparison -- no filesystem scan and no devpod call.
|
|
243
|
+
"""
|
|
244
|
+
if storage.schema_version >= SCHEMA_VERSION:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
report = MigrationReport()
|
|
248
|
+
# Snapshotted before any record is touched: it has to describe the layout the
|
|
249
|
+
# run started from, not one the run is halfway through rewriting.
|
|
250
|
+
claimed = {Path(record.local_path) for record in storage.worktrees.values()}
|
|
251
|
+
for record in storage.worktrees.values():
|
|
252
|
+
_migrate_record(record, repos_dir, claimed, report)
|
|
253
|
+
|
|
254
|
+
# Anything still under an old-scheme name that no record claims. Computed after
|
|
255
|
+
# the renames so it picks up both never-recorded directories and the leftover
|
|
256
|
+
# side of a collision, and excludes everything just moved into place.
|
|
257
|
+
recorded = {Path(record.local_path) for record in storage.worktrees.values()}
|
|
258
|
+
report.unmigrated = [path for path in _clone_dirs(repos_dir) if path not in recorded]
|
|
259
|
+
|
|
260
|
+
# One atomic write, last: it carries the new paths and the new version header
|
|
261
|
+
# together, so the header can never claim more than the filesystem has done.
|
|
262
|
+
storage.save()
|
|
263
|
+
_announce(report, storage.metadata_path.parent)
|
|
264
|
+
return report
|
|
@@ -12,9 +12,22 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
12
12
|
|
|
13
13
|
from .models import BaseRepository, WorktreeInfo, unknown_fields
|
|
14
14
|
|
|
15
|
-
# Version of the on-disk metadata.json format.
|
|
16
|
-
#
|
|
17
|
-
|
|
15
|
+
# Version of the on-disk metadata.json format.
|
|
16
|
+
#
|
|
17
|
+
# 1: the original shape. Clone-directory leaves are flattened branch names and
|
|
18
|
+
# workspace ids are derived separately from them.
|
|
19
|
+
# 2: leaves and workspace ids are both WorkspaceId.value (#64). Reached from 1 by
|
|
20
|
+
# devlaunch.worktree.migration, which renames the directories on disk and then
|
|
21
|
+
# writes the new paths and this header in one atomic save.
|
|
22
|
+
SCHEMA_VERSION = 2
|
|
23
|
+
|
|
24
|
+
# What a file whose header cannot be read is assumed to be. A file without a
|
|
25
|
+
# "version" key predates versioning, so it is the original shape, not the current
|
|
26
|
+
# one -- reading it as current would skip the migration it needs. The same applies
|
|
27
|
+
# to a header that is present but nonsense: the conservative reading is the oldest
|
|
28
|
+
# shape, because a migration that runs against an already-migrated cache is a
|
|
29
|
+
# no-op while one that never runs leaves directories nothing looks for.
|
|
30
|
+
LEGACY_SCHEMA_VERSION = 1
|
|
18
31
|
|
|
19
32
|
# Top-level keys this build writes, and therefore the only ones a rewrite keeps.
|
|
20
33
|
_KNOWN_SECTIONS = frozenset({"version", "repositories", "worktrees"})
|
|
@@ -178,8 +191,8 @@ class MetadataStorage:
|
|
|
178
191
|
def _load_version(self, data: Dict[str, Any]) -> Tuple[int, bool]:
|
|
179
192
|
"""Interpret the version header, returning the version and whether it is lossy."""
|
|
180
193
|
if "version" not in data:
|
|
181
|
-
# An absent version means a legacy pre-versioning file:
|
|
182
|
-
return
|
|
194
|
+
# An absent version means a legacy pre-versioning file: the v1 shape.
|
|
195
|
+
return LEGACY_SCHEMA_VERSION, False
|
|
183
196
|
|
|
184
197
|
raw = data["version"]
|
|
185
198
|
# JSON has a single number type, so tools freely normalize 1 to 1.0; an
|
|
@@ -195,9 +208,9 @@ class MetadataStorage:
|
|
|
195
208
|
# because the rewritten header will not match what is there now.
|
|
196
209
|
_warn(
|
|
197
210
|
f'metadata file {self._file_path} has an invalid "version" header '
|
|
198
|
-
f"({raw!r}); reading it as schema version {
|
|
211
|
+
f"({raw!r}); reading it as schema version {LEGACY_SCHEMA_VERSION}"
|
|
199
212
|
)
|
|
200
|
-
return
|
|
213
|
+
return LEGACY_SCHEMA_VERSION, True
|
|
201
214
|
|
|
202
215
|
if version > SCHEMA_VERSION:
|
|
203
216
|
_warn(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "devlaunch"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.12"
|
|
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"
|
|
@@ -9,7 +9,6 @@ license = "MIT"
|
|
|
9
9
|
dependencies = [
|
|
10
10
|
"iterfzf>=1.0.0",
|
|
11
11
|
"tomli>=2.0.0",
|
|
12
|
-
"tomli_w>=1.0.0",
|
|
13
12
|
]
|
|
14
13
|
|
|
15
14
|
[project.urls]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|