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.
- {devlaunch-0.0.19 → devlaunch-0.0.20}/PKG-INFO +1 -1
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/dl.py +6 -1
- devlaunch-0.0.20/devlaunch/worktree/locks.py +55 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/repo_manager.py +69 -15
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/storage.py +45 -20
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/workspace_clone.py +50 -18
- {devlaunch-0.0.19 → devlaunch-0.0.20}/pyproject.toml +1 -1
- {devlaunch-0.0.19 → devlaunch-0.0.20}/.gitignore +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/LICENSE +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/README.md +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/devpod_provider.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/tools.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.20}/devlaunch/xdg.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
292
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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.
|
|
336
|
+
with self.exclusive():
|
|
337
|
+
self.worktrees[key] = worktree
|
|
314
338
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
375
|
+
with self.exclusive():
|
|
376
|
+
if key in self.worktrees:
|
|
377
|
+
del self.worktrees[key]
|
|
353
378
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|