devlaunch 0.0.19__tar.gz → 0.0.20__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 (28) hide show
  1. {devlaunch-0.0.19 → devlaunch-0.0.20}/PKG-INFO +1 -1
  2. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/dl.py +6 -1
  3. devlaunch-0.0.20/devlaunch/worktree/locks.py +55 -0
  4. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/repo_manager.py +69 -15
  5. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/storage.py +45 -20
  6. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/workspace_clone.py +50 -18
  7. {devlaunch-0.0.19 → devlaunch-0.0.20}/pyproject.toml +1 -1
  8. {devlaunch-0.0.19 → devlaunch-0.0.20}/.gitignore +0 -0
  9. {devlaunch-0.0.19 → devlaunch-0.0.20}/LICENSE +0 -0
  10. {devlaunch-0.0.19 → devlaunch-0.0.20}/README.md +0 -0
  11. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/__init__.py +0 -0
  12. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/aid.py +0 -0
  13. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/completion.py +0 -0
  14. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/completion_loader.py +0 -0
  15. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/completions/__init__.py +0 -0
  16. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/completions/dl.bash +0 -0
  17. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/devpod_provider.py +0 -0
  18. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/devpod_ssh.py +0 -0
  19. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/gh_auth.py +0 -0
  20. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/tools.py +0 -0
  21. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/tty_session.py +0 -0
  22. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/workspace_id.py +0 -0
  23. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/__init__.py +0 -0
  24. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/branch_manager.py +0 -0
  25. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/config.py +0 -0
  26. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/migration.py +0 -0
  27. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/models.py +0 -0
  28. {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/xdg.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlaunch
3
- Version: 0.0.19
3
+ Version: 0.0.20
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
@@ -1659,7 +1659,12 @@ def _get_clone_manager() -> WorkspaceCloneManager:
1659
1659
  if "clone_manager" not in _cache:
1660
1660
  manager = WorkspaceCloneManager()
1661
1661
  try:
1662
- migrate_cache(manager.storage, pathlib.Path(manager.config.repos_dir))
1662
+ # Under the metadata lock so two dl processes cannot migrate at
1663
+ # once: the renames are not idempotent mid-flight, and exclusive()
1664
+ # reloads first so the version check sees the other side's result.
1665
+ # migrate_cache calls save() directly, never a locked mutator.
1666
+ with manager.storage.exclusive():
1667
+ migrate_cache(manager.storage, pathlib.Path(manager.config.repos_dir))
1663
1668
  except OSError as e:
1664
1669
  # A failed migration must not take the command with it. The renames
1665
1670
  # that did happen are still resumable: the version header is only
@@ -0,0 +1,55 @@
1
+ """Inter-process locks for the shared cache.
2
+
3
+ Several dl processes can run at once — two agents launched on their own
4
+ branches, a completion refresh in the background — and they share one bare-clone
5
+ cache and one metadata.json. These locks are what keeps simultaneous runs from
6
+ racing each other over that state: without them, two first launches of a repo
7
+ both ran ``git clone --bare`` into the same path (and the loser's cleanup
8
+ deleted the winner's half-written clone), and metadata writers rewrote the file
9
+ from stale in-memory copies, dropping each other's records.
10
+
11
+ ``flock`` rather than a pid file: the kernel releases it when the process dies,
12
+ however it dies, so a crashed dl never leaves the cache wedged.
13
+
14
+ Two deliberate limits, both load-bearing:
15
+
16
+ - **Not reentrant.** Acquiring a path twice in one process deadlocks (the second
17
+ open file description blocks on the first). Call sites are structured so no
18
+ lock is ever taken while the same lock is held — see the acquisition comments
19
+ at each site.
20
+ - **The lock file is never deleted.** Unlinking an flock'd file is the classic
21
+ self-defeating move: a process that opened the old inode still "holds" a lock
22
+ nobody else can see, while new arrivals lock a fresh file and walk straight
23
+ past it. A few empty ``.lock`` files in the cache are the price of the
24
+ guarantee; ``dl --purge`` sweeps them away with everything else.
25
+ """
26
+
27
+ import contextlib
28
+ import fcntl
29
+ import os
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Iterator, Optional
33
+
34
+
35
+ @contextlib.contextmanager
36
+ def hold_lock(lock_path: Path, waiting_note: Optional[str] = None) -> Iterator[None]:
37
+ """Hold an exclusive inter-process lock on *lock_path* for the block.
38
+
39
+ Blocks until the lock is free. When another process already holds it and
40
+ *waiting_note* is given, one line is printed to stderr first, so a dl run
41
+ that sits waiting on a sibling's long clone says why it is sitting.
42
+ """
43
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
44
+ fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
45
+ try:
46
+ try:
47
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
48
+ except BlockingIOError:
49
+ if waiting_note:
50
+ print(f"dl: waiting for {waiting_note}", file=sys.stderr)
51
+ fcntl.flock(fd, fcntl.LOCK_EX)
52
+ yield
53
+ finally:
54
+ # Closing the descriptor releases the lock; nothing is unlinked.
55
+ os.close(fd)
@@ -7,6 +7,7 @@ from datetime import datetime
7
7
  from pathlib import Path
8
8
  from typing import Optional, TYPE_CHECKING
9
9
 
10
+ from .locks import hold_lock
10
11
  from .models import BaseRepository
11
12
  from .storage import MetadataStorage
12
13
 
@@ -41,6 +42,15 @@ class RepositoryManager:
41
42
  """Get the bare git directory for a repository."""
42
43
  return self.get_repo_path(owner, repo) / ".bare"
43
44
 
45
+ def lock_path(self, owner: str, repo: str) -> Path:
46
+ """The lock every process takes before mutating repos/<owner>/<repo>.
47
+
48
+ A file, not a directory, inside the repo dir: every walker of the cache
49
+ filters on ``is_dir()``, so it is invisible to discovery, migration and
50
+ completion scans.
51
+ """
52
+ return self.get_repo_path(owner, repo) / ".lock"
53
+
44
54
  def clone_repo(self, owner: str, repo: str, remote_url: str) -> BaseRepository:
45
55
  """Clone a new base repository as bare (no working directory).
46
56
 
@@ -58,7 +68,21 @@ class RepositoryManager:
58
68
  existing_repo = self.get_repo(owner, repo)
59
69
  if existing_repo:
60
70
  return existing_repo
61
- # Repository path exists but metadata doesn't - continue to create metadata
71
+ if (bare_path / "HEAD").exists():
72
+ # The bare clone is already on disk but this process has no
73
+ # record of it -- another process just made it (this process's
74
+ # metadata was loaded before that one saved), or an earlier run
75
+ # died between clone and save. Either way the clone on disk is
76
+ # the authority and the record is derived state: rebuild the
77
+ # record. Cloning over it instead is not an option -- git
78
+ # refuses the non-empty destination, and the failure cleanup
79
+ # below would then delete a cache another launch is using.
80
+ return self._register_existing_bare(owner, repo, remote_url, bare_path)
81
+ # No HEAD: a dead run's partial clone. Holding the repo lock (every
82
+ # caller comes through ensure_repo) means no live process owns it,
83
+ # so clear it and clone fresh.
84
+ logger.warning(f"Removing partial clone at {bare_path}")
85
+ shutil.rmtree(bare_path)
62
86
 
63
87
  # Create parent directory
64
88
  bare_path.parent.mkdir(parents=True, exist_ok=True)
@@ -97,11 +121,28 @@ class RepositoryManager:
97
121
 
98
122
  except subprocess.CalledProcessError as e:
99
123
  logger.debug(f"Failed to clone repository: {e.stderr}")
100
- # Clean up partial clone
124
+ # Clean up the partial clone. Safe to delete: the exists-cases were
125
+ # all handled above, so this directory is one this call created.
101
126
  if bare_path.exists():
102
127
  shutil.rmtree(bare_path)
103
128
  raise RuntimeError(f"Failed to clone repository: {e.stderr}") from e
104
129
 
130
+ def _register_existing_bare(
131
+ self, owner: str, repo: str, remote_url: str, bare_path: Path
132
+ ) -> BaseRepository:
133
+ """Rebuild the metadata record for a bare clone already on disk."""
134
+ base_repo = BaseRepository(
135
+ owner=owner,
136
+ repo=repo,
137
+ remote_url=remote_url,
138
+ local_path=bare_path,
139
+ default_branch=self._get_default_branch(bare_path),
140
+ last_fetched=datetime.now(),
141
+ worktrees=[],
142
+ )
143
+ self.storage.add_repository(base_repo)
144
+ return base_repo
145
+
105
146
  def fetch_repo(self, owner: str, repo: str) -> None:
106
147
  """Fetch latest changes from remote."""
107
148
  bare_path = self.get_bare_path(owner, repo)
@@ -166,20 +207,33 @@ class RepositoryManager:
166
207
  """Ensure repo exists locally, clone if needed.
167
208
 
168
209
  Uses lazy fetch: only fetches if fetch_interval has elapsed since last fetch.
169
- """
170
- if self.repo_exists(owner, repo):
171
- existing_repo = self.get_repo(owner, repo)
172
- if existing_repo:
173
- # Only fetch if interval has elapsed (lazy fetch)
174
- if auto_fetch and self._should_fetch(existing_repo):
175
- try:
176
- self.fetch_repo(owner, repo)
177
- except Exception as e:
178
- logger.warning(f"Failed to fetch updates: {e}")
179
- return existing_repo
180
- # Metadata doesn't exist but repo exists - fall through to clone (which will add metadata)
181
210
 
182
- return self.clone_repo(owner, repo, remote_url)
211
+ The whole exists-check-then-clone sequence runs under the repo lock:
212
+ without it, two processes launching the same repo at once both saw no
213
+ clone and both ran ``git clone --bare`` into the same path — and the
214
+ loser's cleanup in clone_repo deleted the winner's half-written cache.
215
+ Serialized, the loser just waits and then reuses the winner's clone.
216
+ clone_repo and fetch_repo rely on this lock rather than taking it
217
+ themselves (hold_lock is not reentrant).
218
+ """
219
+ with hold_lock(
220
+ self.lock_path(owner, repo),
221
+ waiting_note=f"another dl run preparing {owner}/{repo}",
222
+ ):
223
+ if self.repo_exists(owner, repo):
224
+ existing_repo = self.get_repo(owner, repo)
225
+ if existing_repo:
226
+ # Only fetch if interval has elapsed (lazy fetch)
227
+ if auto_fetch and self._should_fetch(existing_repo):
228
+ try:
229
+ self.fetch_repo(owner, repo)
230
+ except Exception as e:
231
+ logger.warning(f"Failed to fetch updates: {e}")
232
+ return existing_repo
233
+ # Metadata doesn't exist but repo exists - fall through to clone
234
+ # (which will add metadata)
235
+
236
+ return self.clone_repo(owner, repo, remote_url)
183
237
 
184
238
  def repo_exists(self, owner: str, repo: str) -> bool:
185
239
  """Check if repository exists locally."""
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple
12
12
 
13
13
  from devlaunch.xdg import devlaunch_cache
14
14
 
15
+ from .locks import hold_lock
15
16
  from .models import BaseRepository, WorktreeInfo, unknown_fields
16
17
 
17
18
  # Version of the on-disk metadata.json format.
@@ -83,8 +84,28 @@ class MetadataStorage:
83
84
  self.metadata_path.parent.mkdir(parents=True, exist_ok=True)
84
85
  # Every file operation targets the real file, not a symlink pointing at it.
85
86
  self._file_path = _resolve_link(self.metadata_path)
87
+ # A sidecar rather than the file itself: save() replaces metadata.json
88
+ # by rename, and a lock taken on a replaced inode guards nothing.
89
+ self._lock_path = self._file_path.with_name(self._file_path.name + ".lock")
86
90
  self._load()
87
91
 
92
+ @contextlib.contextmanager
93
+ def exclusive(self):
94
+ """Hold the metadata lock and reload before the block runs.
95
+
96
+ Every mutation goes through this: the in-memory copy was loaded whenever
97
+ this process started, and other dl processes may have written since.
98
+ Rewriting the file from that stale copy silently drops their records —
99
+ reloading under the lock is what makes read-modify-write safe. The
100
+ caller applies its change and calls save() before the block ends.
101
+
102
+ Not reentrant (see locks.py), so mutators must never be called from
103
+ inside an exclusive() block — they take this lock themselves.
104
+ """
105
+ with hold_lock(self._lock_path, waiting_note="another dl run updating the workspace list"):
106
+ self._load()
107
+ yield
108
+
88
109
  def _quarantine(self, reason: str) -> None:
89
110
  """Move an unusable metadata file aside so the data stays inspectable.
90
111
 
@@ -288,8 +309,9 @@ class MetadataStorage:
288
309
  def add_repository(self, repo: BaseRepository) -> None:
289
310
  """Add or update a repository."""
290
311
  key = f"{repo.owner}/{repo.repo}"
291
- self.repositories[key] = repo
292
- self.save()
312
+ with self.exclusive():
313
+ self.repositories[key] = repo
314
+ self.save()
293
315
 
294
316
  def get_repository(self, owner: str, repo: str) -> Optional[BaseRepository]:
295
317
  """Get a repository by owner and name."""
@@ -303,22 +325,24 @@ class MetadataStorage:
303
325
  def remove_repository(self, owner: str, repo: str) -> None:
304
326
  """Remove a repository."""
305
327
  key = f"{owner}/{repo}"
306
- if key in self.repositories:
307
- del self.repositories[key]
308
- self.save()
328
+ with self.exclusive():
329
+ if key in self.repositories:
330
+ del self.repositories[key]
331
+ self.save()
309
332
 
310
333
  def add_worktree(self, worktree: WorktreeInfo) -> None:
311
334
  """Add or update a worktree."""
312
335
  key = f"{worktree.owner}/{worktree.repo}/{worktree.branch}"
313
- self.worktrees[key] = worktree
336
+ with self.exclusive():
337
+ self.worktrees[key] = worktree
314
338
 
315
- # Update repository's worktree list in memory, then write once.
316
- repo = self.get_repository(worktree.owner, worktree.repo)
317
- if repo and worktree.branch not in repo.worktrees:
318
- repo.worktrees.append(worktree.branch)
319
- self.repositories[f"{worktree.owner}/{worktree.repo}"] = repo
339
+ # Update repository's worktree list in memory, then write once.
340
+ repo = self.get_repository(worktree.owner, worktree.repo)
341
+ if repo and worktree.branch not in repo.worktrees:
342
+ repo.worktrees.append(worktree.branch)
343
+ self.repositories[f"{worktree.owner}/{worktree.repo}"] = repo
320
344
 
321
- self.save()
345
+ self.save()
322
346
 
323
347
  def get_worktree(self, owner: str, repo: str, branch: str) -> Optional[WorktreeInfo]:
324
348
  """Get a worktree by repository and branch."""
@@ -348,13 +372,14 @@ class MetadataStorage:
348
372
  def remove_worktree(self, owner: str, repo: str, branch: str) -> None:
349
373
  """Remove a worktree."""
350
374
  key = f"{owner}/{repo}/{branch}"
351
- if key in self.worktrees:
352
- del self.worktrees[key]
375
+ with self.exclusive():
376
+ if key in self.worktrees:
377
+ del self.worktrees[key]
353
378
 
354
- # Update repository's worktree list in memory, then write once.
355
- repo_obj = self.get_repository(owner, repo)
356
- if repo_obj and branch in repo_obj.worktrees:
357
- repo_obj.worktrees.remove(branch)
358
- self.repositories[f"{owner}/{repo}"] = repo_obj
379
+ # Update repository's worktree list in memory, then write once.
380
+ repo_obj = self.get_repository(owner, repo)
381
+ if repo_obj and branch in repo_obj.worktrees:
382
+ repo_obj.worktrees.remove(branch)
383
+ self.repositories[f"{owner}/{repo}"] = repo_obj
359
384
 
360
- self.save()
385
+ self.save()
@@ -25,6 +25,7 @@ from typing import Optional
25
25
  from ..workspace_id import WorkspaceId, validate_ref_name
26
26
  from .branch_manager import BranchManager
27
27
  from .config import WorktreeConfig, get_worktree_config
28
+ from .locks import hold_lock
28
29
  from .models import WorktreeInfo
29
30
  from .repo_manager import RepositoryManager
30
31
  from .storage import MetadataStorage
@@ -173,27 +174,36 @@ class WorkspaceCloneManager:
173
174
 
174
175
  Fetches latest refs, then uses BranchManager to create the branch
175
176
  locally if needed. Does not push to the remote.
177
+
178
+ Runs under the repo lock: the fetch and the branch creation both write
179
+ refs in the shared bare repo, and two processes doing so at once trip
180
+ over git's own ref locks. (hold_lock is not reentrant; no callee here
181
+ takes the repo lock.)
176
182
  """
177
183
  bare_path = self.repo_manager.get_bare_path(owner, repo)
178
- # Lazy-fetch: only hits the network when the fetch interval has elapsed
179
- try:
180
- self.repo_manager.lazy_fetch(owner, repo)
181
- except (RuntimeError, ValueError, OSError) as e:
182
- logger.warning(f"Failed to fetch before branch ensure: {e}")
184
+ with hold_lock(
185
+ self.repo_manager.lock_path(owner, repo),
186
+ waiting_note=f"another dl run preparing {owner}/{repo}",
187
+ ):
188
+ # Lazy-fetch: only hits the network when the fetch interval has elapsed
189
+ try:
190
+ self.repo_manager.lazy_fetch(owner, repo)
191
+ except (RuntimeError, ValueError, OSError) as e:
192
+ logger.warning(f"Failed to fetch before branch ensure: {e}")
183
193
 
184
- try:
185
- default_branch = self.repo_manager.get_default_branch(owner, repo)
186
- except (RuntimeError, subprocess.CalledProcessError, OSError) as e:
187
- logger.warning(f"Failed to resolve default branch: {e}")
188
- default_branch = None
189
-
190
- self.branch_manager.ensure_branch_exists(
191
- bare_path,
192
- branch,
193
- create_remote=False,
194
- start_point=default_branch or "HEAD",
195
- use_local_refs=True,
196
- )
194
+ try:
195
+ default_branch = self.repo_manager.get_default_branch(owner, repo)
196
+ except (RuntimeError, subprocess.CalledProcessError, OSError) as e:
197
+ logger.warning(f"Failed to resolve default branch: {e}")
198
+ default_branch = None
199
+
200
+ self.branch_manager.ensure_branch_exists(
201
+ bare_path,
202
+ branch,
203
+ create_remote=False,
204
+ start_point=default_branch or "HEAD",
205
+ use_local_refs=True,
206
+ )
197
207
 
198
208
  def ensure_workspace(
199
209
  self,
@@ -223,6 +233,28 @@ class WorkspaceCloneManager:
223
233
  bare_repo_path = self.repo_manager.get_bare_path(owner, repo)
224
234
 
225
235
  ws_path = self.get_workspace_path(owner, repo, branch)
236
+
237
+ # Steps 2-6 mutate the workspace clone, so they run under the repo
238
+ # lock: fire the same workspace twice at once and, unserialized, each
239
+ # process saw no clone, both cloned into the same path, and the loser's
240
+ # cleanup deleted the winner's. The lock is taken only after
241
+ # ensure_repo (which takes the same lock) has returned -- hold_lock is
242
+ # not reentrant.
243
+ with hold_lock(
244
+ self.repo_manager.lock_path(owner, repo),
245
+ waiting_note=f"another dl run preparing {owner}/{repo}",
246
+ ):
247
+ return self._prepare_workspace(workspace, bare_repo_path, ws_path, remote_url)
248
+
249
+ def _prepare_workspace(
250
+ self,
251
+ workspace: WorkspaceId,
252
+ bare_repo_path: Path,
253
+ ws_path: Path,
254
+ remote_url: str,
255
+ ) -> Path:
256
+ """Steps 2-6 of ensure_workspace; the caller holds the repo lock."""
257
+ owner, repo, branch = workspace.owner, workspace.repo, workspace.ref
226
258
  is_new_workspace = False
227
259
  if not self.workspace_exists(owner, repo, branch):
228
260
  is_new_workspace = True
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devlaunch"
3
- version = "0.0.19"
3
+ version = "0.0.20"
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"
File without changes
File without changes
File without changes
File without changes
File without changes