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.
Files changed (23) hide show
  1. {devlaunch-0.0.10 → devlaunch-0.0.12}/PKG-INFO +38 -6
  2. {devlaunch-0.0.10 → devlaunch-0.0.12}/README.md +37 -4
  3. devlaunch-0.0.12/devlaunch/devpod_ssh.py +143 -0
  4. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/dl.py +72 -5
  5. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/__init__.py +3 -0
  6. devlaunch-0.0.12/devlaunch/worktree/migration.py +264 -0
  7. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/storage.py +20 -7
  8. {devlaunch-0.0.10 → devlaunch-0.0.12}/pyproject.toml +1 -2
  9. {devlaunch-0.0.10 → devlaunch-0.0.12}/.gitignore +0 -0
  10. {devlaunch-0.0.10 → devlaunch-0.0.12}/LICENSE +0 -0
  11. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/__init__.py +0 -0
  12. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/aid.py +0 -0
  13. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/completion.py +0 -0
  14. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/completion_loader.py +0 -0
  15. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/completions/__init__.py +0 -0
  16. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/completions/dl.bash +0 -0
  17. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/gh_auth.py +0 -0
  18. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/workspace_id.py +0 -0
  19. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/branch_manager.py +0 -0
  20. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/config.py +0 -0
  21. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/models.py +0 -0
  22. {devlaunch-0.0.10 → devlaunch-0.0.12}/devlaunch/worktree/repo_manager.py +0 -0
  23. {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.10
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
- > **Upgrading:** this id format is new. Existing workspaces and clone directories were
166
- > named by the previous scheme and will get new ids, so `dl user/repo@branch` creates a
167
- > fresh workspace and leaves the old container behind. Remove stale ones with
168
- > `dl <old-id> rm`, which still finds and deletes the old clone directory.
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
- > **Upgrading:** this id format is new. Existing workspaces and clone directories were
143
- > named by the previous scheme and will get new ids, so `dl user/repo@branch` creates a
144
- > fresh workspace and leaves the old container behind. Remove stale ones with
145
- > `dl <old-id> rm`, which still finds and deletes the old clone directory.
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
- result = run_devpod(args, env=env)
1052
- return result.returncode
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
- _cache["clone_manager"] = WorkspaceCloneManager()
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. A file without a "version" key
16
- # predates versioning and is treated as version 1.
17
- SCHEMA_VERSION = 1
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: same shape as v1.
182
- return SCHEMA_VERSION, False
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 {SCHEMA_VERSION}"
211
+ f"({raw!r}); reading it as schema version {LEGACY_SCHEMA_VERSION}"
199
212
  )
200
- return SCHEMA_VERSION, True
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.10"
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