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.
- {devlaunch-0.0.10 → devlaunch-0.0.11}/PKG-INFO +38 -6
- {devlaunch-0.0.10 → devlaunch-0.0.11}/README.md +37 -4
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/dl.py +25 -2
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/__init__.py +3 -0
- devlaunch-0.0.11/devlaunch/worktree/migration.py +264 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/storage.py +20 -7
- {devlaunch-0.0.10 → devlaunch-0.0.11}/pyproject.toml +1 -2
- {devlaunch-0.0.10 → devlaunch-0.0.11}/.gitignore +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/LICENSE +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.10 → devlaunch-0.0.11}/devlaunch/worktree/repo_manager.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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.
|
|
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.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
|
|
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
|