devlaunch 0.0.10__tar.gz → 0.0.11__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 (22) hide show
  1. {devlaunch-0.0.10 → devlaunch-0.0.11}/PKG-INFO +38 -6
  2. {devlaunch-0.0.10 → devlaunch-0.0.11}/README.md +37 -4
  3. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/dl.py +25 -2
  4. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/__init__.py +3 -0
  5. devlaunch-0.0.11/devlaunch/worktree/migration.py +264 -0
  6. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/storage.py +20 -7
  7. {devlaunch-0.0.10 → devlaunch-0.0.11}/pyproject.toml +1 -2
  8. {devlaunch-0.0.10 → devlaunch-0.0.11}/.gitignore +0 -0
  9. {devlaunch-0.0.10 → devlaunch-0.0.11}/LICENSE +0 -0
  10. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/__init__.py +0 -0
  11. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/aid.py +0 -0
  12. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/completion.py +0 -0
  13. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/completion_loader.py +0 -0
  14. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/completions/__init__.py +0 -0
  15. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/completions/dl.bash +0 -0
  16. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/gh_auth.py +0 -0
  17. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/workspace_id.py +0 -0
  18. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/branch_manager.py +0 -0
  19. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/config.py +0 -0
  20. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/models.py +0 -0
  21. {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/repo_manager.py +0 -0
  22. {devlaunch-0.0.10 → devlaunch-0.0.11}/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.11
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
 
@@ -37,6 +37,7 @@ from . import 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
 
@@ -1188,9 +1189,31 @@ _cache: dict[str, WorkspaceCloneManager] = {}
1188
1189
 
1189
1190
 
1190
1191
  def _get_clone_manager() -> WorkspaceCloneManager:
1191
- """Lazy factory for WorkspaceCloneManager."""
1192
+ """Lazy factory for WorkspaceCloneManager, migrating the cache on first use.
1193
+
1194
+ This is where the one-shot id-scheme migration runs, for three reasons. It is
1195
+ dl's single construction point for the object that owns every read of a
1196
+ workspace path, so nothing can reach a stale path before the rename. It is
1197
+ lazy, so the commands that touch no workspace -- `--help`, `--version`,
1198
+ `--ls`, the completion commands, `--purge`, and opening an existing workspace
1199
+ by name -- never reach it, which keeps #58's promise that help does no work.
1200
+ And the memo makes it at most once per process.
1201
+
1202
+ On an already-migrated cache this costs one integer comparison, because the
1203
+ trigger is the version header the storage load already parsed. Nothing here
1204
+ spawns devpod: the orphaned container ids come from metadata.
1205
+ """
1192
1206
  if "clone_manager" not in _cache:
1193
- _cache["clone_manager"] = WorkspaceCloneManager()
1207
+ manager = WorkspaceCloneManager()
1208
+ try:
1209
+ migrate_cache(manager.storage, pathlib.Path(manager.config.repos_dir))
1210
+ except OSError as e:
1211
+ # A failed migration must not take the command with it. The renames
1212
+ # that did happen are still resumable: the version header is only
1213
+ # written by the final save, so an unwritten file means the next run
1214
+ # migrates again and finds them already in place.
1215
+ logging.warning(f"Could not migrate the workspace cache: {e}")
1216
+ _cache["clone_manager"] = manager
1194
1217
  return _cache["clone_manager"]
1195
1218
 
1196
1219
 
@@ -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.11"
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