devlaunch 0.0.19__tar.gz → 0.0.21__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.21}/PKG-INFO +59 -1
- {devlaunch-0.0.19 → devlaunch-0.0.21}/README.md +58 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/dl.py +101 -3
- devlaunch-0.0.21/devlaunch/workspace_state.py +121 -0
- devlaunch-0.0.21/devlaunch/worktree/locks.py +55 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/repo_manager.py +69 -15
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/storage.py +45 -20
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/workspace_clone.py +50 -18
- {devlaunch-0.0.19 → devlaunch-0.0.21}/pyproject.toml +1 -1
- {devlaunch-0.0.19 → devlaunch-0.0.21}/.gitignore +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/LICENSE +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/devpod_provider.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/tools.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.19 → devlaunch-0.0.21}/devlaunch/xdg.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.21
|
|
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
|
|
@@ -339,6 +339,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
|
|
|
339
339
|
| Command | Description |
|
|
340
340
|
|---------|-------------|
|
|
341
341
|
| `dl --ls` | List all workspaces |
|
|
342
|
+
| `dl --ls --json` | The same list as JSON, with each workspace's repo, branch, state and [unsaved work](#cleaning-up-workspaces) — for tools that decide what to clean up |
|
|
342
343
|
| `dl --install` | Install shell completions |
|
|
343
344
|
| `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
|
|
344
345
|
| `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
|
|
@@ -396,6 +397,63 @@ Erring this way is deliberate — a purge that skips one of your own workspaces
|
|
|
396
397
|
costs you a command, and the other kind of mistake costs you work you cannot get
|
|
397
398
|
back.
|
|
398
399
|
|
|
400
|
+
### Cleaning up workspaces
|
|
401
|
+
|
|
402
|
+
One workspace per branch means workspaces accumulate, and `--purge` is the wrong
|
|
403
|
+
tool for tidying: it is all-or-nothing and takes the caches with it.
|
|
404
|
+
|
|
405
|
+
**devlaunch does not decide which workspaces are finished.** Whether a piece of
|
|
406
|
+
work is over is a fact about a ticket, a review, or somebody's intent, and `dl`
|
|
407
|
+
knows about clones and containers. Inferring it from the branch — merged into
|
|
408
|
+
the default, or deleted from the remote — was tried and dropped: it reads like a
|
|
409
|
+
git fact but is a guess at intent, and it cannot tell a squash-merged branch
|
|
410
|
+
from an abandoned one. So `dl` supplies the two halves a tool that *does* know
|
|
411
|
+
needs, and that tool drives the cleanup:
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
dl --ls --json # what exists, and what each workspace holds
|
|
415
|
+
dl <workspace> rm # remove one
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
The JSON reports, per workspace: `id`, `devlaunch` (did `dl` create it),
|
|
419
|
+
`repo`, `branch` (what the workspace was made for), `checkedOut` (what its clone
|
|
420
|
+
is on now, which can differ), `path`, `state`, `lastUsed`, and — the field a
|
|
421
|
+
cleanup tool must not ignore — `unsaved`:
|
|
422
|
+
|
|
423
|
+
```json
|
|
424
|
+
{
|
|
425
|
+
"id": "devlaunch-wayfinder-devlaunch-80-ladepomi",
|
|
426
|
+
"devlaunch": true,
|
|
427
|
+
"repo": "blooop/devlaunch",
|
|
428
|
+
"branch": "wayfinder/devlaunch-80",
|
|
429
|
+
"state": "Stopped",
|
|
430
|
+
"unsaved": "2 uncommitted change(s) and 1 unpushed commit(s)"
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
`unsaved` is a description of what deleting would destroy, or `null` when the
|
|
435
|
+
clone holds nothing that does not also exist on a remote — uncommitted changes
|
|
436
|
+
(untracked files included) and commits no remote has. A workspace `dl` did not
|
|
437
|
+
create reports `devlaunch: false` with no repo, branch or `unsaved`: there is no
|
|
438
|
+
clone of `dl`'s to protect, and it has no business inspecting your checkout.
|
|
439
|
+
|
|
440
|
+
**`dl <workspace> rm` refuses when the clone holds unsaved work**, so a caller
|
|
441
|
+
that forgets to read the field is still caught:
|
|
442
|
+
|
|
443
|
+
```
|
|
444
|
+
$ dl blooop/repo@feature rm
|
|
445
|
+
error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s).
|
|
446
|
+
Push or commit it, or run: dl blooop/repo@feature rm --force
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
That refusal is the only judgement `dl` makes here, and it is not about finished
|
|
450
|
+
work — it is `dl` declining to destroy the only copy of something. Say `--force`
|
|
451
|
+
if you mean it.
|
|
452
|
+
|
|
453
|
+
[`wf`](https://github.com/blooop/wayfinder) is the caller this was built for: it
|
|
454
|
+
names its branches after its tickets, so it knows which workspaces belong to
|
|
455
|
+
finished work and removes those.
|
|
456
|
+
|
|
399
457
|
## Examples
|
|
400
458
|
|
|
401
459
|
```bash
|
|
@@ -317,6 +317,7 @@ existed — picks the tools up on its next `dl <workspace> restart`.
|
|
|
317
317
|
| Command | Description |
|
|
318
318
|
|---------|-------------|
|
|
319
319
|
| `dl --ls` | List all workspaces |
|
|
320
|
+
| `dl --ls --json` | The same list as JSON, with each workspace's repo, branch, state and [unsaved work](#cleaning-up-workspaces) — for tools that decide what to clean up |
|
|
320
321
|
| `dl --install` | Install shell completions |
|
|
321
322
|
| `dl --purge [-y]` | Remove all devlaunch data — [the workspaces devlaunch created](#what-purge-deletes), and its caches |
|
|
322
323
|
| `dl --prune-worktrees [days]` | Remove unused worktrees (default: 30 days) |
|
|
@@ -374,6 +375,63 @@ Erring this way is deliberate — a purge that skips one of your own workspaces
|
|
|
374
375
|
costs you a command, and the other kind of mistake costs you work you cannot get
|
|
375
376
|
back.
|
|
376
377
|
|
|
378
|
+
### Cleaning up workspaces
|
|
379
|
+
|
|
380
|
+
One workspace per branch means workspaces accumulate, and `--purge` is the wrong
|
|
381
|
+
tool for tidying: it is all-or-nothing and takes the caches with it.
|
|
382
|
+
|
|
383
|
+
**devlaunch does not decide which workspaces are finished.** Whether a piece of
|
|
384
|
+
work is over is a fact about a ticket, a review, or somebody's intent, and `dl`
|
|
385
|
+
knows about clones and containers. Inferring it from the branch — merged into
|
|
386
|
+
the default, or deleted from the remote — was tried and dropped: it reads like a
|
|
387
|
+
git fact but is a guess at intent, and it cannot tell a squash-merged branch
|
|
388
|
+
from an abandoned one. So `dl` supplies the two halves a tool that *does* know
|
|
389
|
+
needs, and that tool drives the cleanup:
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
dl --ls --json # what exists, and what each workspace holds
|
|
393
|
+
dl <workspace> rm # remove one
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The JSON reports, per workspace: `id`, `devlaunch` (did `dl` create it),
|
|
397
|
+
`repo`, `branch` (what the workspace was made for), `checkedOut` (what its clone
|
|
398
|
+
is on now, which can differ), `path`, `state`, `lastUsed`, and — the field a
|
|
399
|
+
cleanup tool must not ignore — `unsaved`:
|
|
400
|
+
|
|
401
|
+
```json
|
|
402
|
+
{
|
|
403
|
+
"id": "devlaunch-wayfinder-devlaunch-80-ladepomi",
|
|
404
|
+
"devlaunch": true,
|
|
405
|
+
"repo": "blooop/devlaunch",
|
|
406
|
+
"branch": "wayfinder/devlaunch-80",
|
|
407
|
+
"state": "Stopped",
|
|
408
|
+
"unsaved": "2 uncommitted change(s) and 1 unpushed commit(s)"
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
`unsaved` is a description of what deleting would destroy, or `null` when the
|
|
413
|
+
clone holds nothing that does not also exist on a remote — uncommitted changes
|
|
414
|
+
(untracked files included) and commits no remote has. A workspace `dl` did not
|
|
415
|
+
create reports `devlaunch: false` with no repo, branch or `unsaved`: there is no
|
|
416
|
+
clone of `dl`'s to protect, and it has no business inspecting your checkout.
|
|
417
|
+
|
|
418
|
+
**`dl <workspace> rm` refuses when the clone holds unsaved work**, so a caller
|
|
419
|
+
that forgets to read the field is still caught:
|
|
420
|
+
|
|
421
|
+
```
|
|
422
|
+
$ dl blooop/repo@feature rm
|
|
423
|
+
error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s).
|
|
424
|
+
Push or commit it, or run: dl blooop/repo@feature rm --force
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
That refusal is the only judgement `dl` makes here, and it is not about finished
|
|
428
|
+
work — it is `dl` declining to destroy the only copy of something. Say `--force`
|
|
429
|
+
if you mean it.
|
|
430
|
+
|
|
431
|
+
[`wf`](https://github.com/blooop/wayfinder) is the caller this was built for: it
|
|
432
|
+
names its branches after its tickets, so it knows which workspaces belong to
|
|
433
|
+
finished work and removes those.
|
|
434
|
+
|
|
377
435
|
## Examples
|
|
378
436
|
|
|
379
437
|
```bash
|
|
@@ -27,12 +27,13 @@ import re
|
|
|
27
27
|
import shlex
|
|
28
28
|
import time
|
|
29
29
|
from importlib.metadata import version as pkg_version, PackageNotFoundError, distribution
|
|
30
|
+
from pathlib import Path
|
|
30
31
|
from typing import Any, Dict, List, Mapping, NoReturn, Optional, Sequence, Tuple
|
|
31
32
|
from dataclasses import dataclass
|
|
32
33
|
from urllib.parse import urlparse
|
|
33
34
|
from urllib.request import url2pathname
|
|
34
35
|
|
|
35
|
-
from . import devpod_ssh, gh_auth, tools, tty_session
|
|
36
|
+
from . import devpod_ssh, gh_auth, tools, tty_session, workspace_state
|
|
36
37
|
from .completion import install_completions
|
|
37
38
|
from .workspace_id import TARGET_LENGTH, WorkspaceId, slug, source_workspace_id, validate_ref_name
|
|
38
39
|
from .worktree.config import get_worktree_config
|
|
@@ -431,6 +432,76 @@ def update_cache_background(force: bool = False) -> None:
|
|
|
431
432
|
pass
|
|
432
433
|
|
|
433
434
|
|
|
435
|
+
def _unsaved_work_in(workspace_id: str) -> Optional[str]:
|
|
436
|
+
"""What deleting *workspace_id* would destroy, or None if nothing would be.
|
|
437
|
+
|
|
438
|
+
Answers None for a workspace devlaunch has no record of, which is the honest
|
|
439
|
+
answer rather than a permissive one: those are workspaces opened from a path
|
|
440
|
+
or a URL that dl never cloned and does not manage, so it has no clone of its
|
|
441
|
+
own to protect and no business inspecting someone's checkout to find one.
|
|
442
|
+
"""
|
|
443
|
+
try:
|
|
444
|
+
record = _get_clone_manager().storage.get_worktree_by_workspace_id(workspace_id)
|
|
445
|
+
except (OSError, RuntimeError) as e:
|
|
446
|
+
logging.debug(f"could not read the workspace record for {workspace_id}: {e}")
|
|
447
|
+
return None
|
|
448
|
+
if record is None:
|
|
449
|
+
return None
|
|
450
|
+
return workspace_state.holds_unsaved_work(Path(record.local_path))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def workspaces_as_json() -> int:
|
|
454
|
+
"""Print the workspace list as JSON: what exists, and what each one holds.
|
|
455
|
+
|
|
456
|
+
The machine-readable half of cleanup. devlaunch does not decide which
|
|
457
|
+
workspaces are finished -- that is a fact about tickets, reviews and intent,
|
|
458
|
+
none of which it knows -- so it reports what it does know and lets the
|
|
459
|
+
caller that knows the rest decide. `wf` is one such caller: it named the
|
|
460
|
+
branches after its tickets, so matching a workspace to a ticket is its
|
|
461
|
+
business, not dl's.
|
|
462
|
+
|
|
463
|
+
Every field is something dl can answer for certain:
|
|
464
|
+
|
|
465
|
+
- `repo` and `branch` come from the record dl wrote when it made the clone;
|
|
466
|
+
a workspace dl did not make has neither, and says so with `devlaunch:
|
|
467
|
+
false` rather than a guess.
|
|
468
|
+
- `unsaved` is the field a caller must not ignore: a description of what
|
|
469
|
+
deleting would destroy, or null. `dl <ws> rm` refuses on it too, so a
|
|
470
|
+
caller that forgets is still caught -- but a caller that reads it can
|
|
471
|
+
leave the workspace alone instead of arguing with a refusal.
|
|
472
|
+
- `state` is devpod's, one `devpod status` per workspace, which is why this
|
|
473
|
+
is a command someone runs rather than something on the fast path.
|
|
474
|
+
"""
|
|
475
|
+
cache_dir = _get_cache_dir()
|
|
476
|
+
workspaces = list_workspaces()
|
|
477
|
+
clone_mgr = _get_clone_manager()
|
|
478
|
+
report: List[Dict[str, Any]] = []
|
|
479
|
+
for ws in workspaces:
|
|
480
|
+
mine = is_devlaunch_clone(ws, cache_dir)
|
|
481
|
+
record = clone_mgr.storage.get_worktree_by_workspace_id(ws.id) if mine else None
|
|
482
|
+
clone_path = Path(record.local_path) if record else None
|
|
483
|
+
state = workspace_state.read_clone(clone_path) if clone_path else None
|
|
484
|
+
report.append(
|
|
485
|
+
{
|
|
486
|
+
"id": ws.id,
|
|
487
|
+
"devlaunch": mine,
|
|
488
|
+
"repo": f"{record.owner}/{record.repo}" if record else None,
|
|
489
|
+
# The recorded branch is what the workspace was made for; the
|
|
490
|
+
# clone's current HEAD can differ (an agent checked something
|
|
491
|
+
# else out), so both are reported rather than one being made to
|
|
492
|
+
# stand for the other.
|
|
493
|
+
"branch": record.branch if record else None,
|
|
494
|
+
"checkedOut": state.branch if state else None,
|
|
495
|
+
"path": str(clone_path) if clone_path else None,
|
|
496
|
+
"state": get_workspace_state(ws.id),
|
|
497
|
+
"lastUsed": ws.last_used,
|
|
498
|
+
"unsaved": state.unsaved if state else None,
|
|
499
|
+
}
|
|
500
|
+
)
|
|
501
|
+
print(json.dumps(report, indent=2))
|
|
502
|
+
return 0
|
|
503
|
+
|
|
504
|
+
|
|
434
505
|
def purge_all_data() -> int:
|
|
435
506
|
"""Purge devlaunch's data: the workspaces it created, and its caches.
|
|
436
507
|
|
|
@@ -1609,7 +1680,9 @@ Workspace sources:
|
|
|
1609
1680
|
|
|
1610
1681
|
Workspace commands:
|
|
1611
1682
|
dl <user/repo> stop Stop the workspace
|
|
1612
|
-
dl <user/repo> rm, prune Delete the workspace
|
|
1683
|
+
dl <user/repo> rm, prune Delete the workspace. Refuses if its clone
|
|
1684
|
+
holds uncommitted or unpushed work; add
|
|
1685
|
+
--force to delete it anyway.
|
|
1613
1686
|
dl <user/repo> code Open in VS Code
|
|
1614
1687
|
dl <user/repo> restart Stop and start (no rebuild)
|
|
1615
1688
|
dl <user/repo> recreate Recreate container
|
|
@@ -1618,6 +1691,10 @@ Workspace commands:
|
|
|
1618
1691
|
|
|
1619
1692
|
Global commands:
|
|
1620
1693
|
dl --ls List all workspaces
|
|
1694
|
+
dl --ls --json List them as JSON, with each one's repo,
|
|
1695
|
+
branch, state, and what it holds that is
|
|
1696
|
+
not pushed anywhere ("unsaved"). For tools
|
|
1697
|
+
that decide which workspaces to clean up.
|
|
1621
1698
|
dl --install Install shell completions
|
|
1622
1699
|
dl --refresh Refresh completion cache
|
|
1623
1700
|
dl --purge [-y] Remove devlaunch's workspaces and caches
|
|
@@ -1659,7 +1736,12 @@ def _get_clone_manager() -> WorkspaceCloneManager:
|
|
|
1659
1736
|
if "clone_manager" not in _cache:
|
|
1660
1737
|
manager = WorkspaceCloneManager()
|
|
1661
1738
|
try:
|
|
1662
|
-
|
|
1739
|
+
# Under the metadata lock so two dl processes cannot migrate at
|
|
1740
|
+
# once: the renames are not idempotent mid-flight, and exclusive()
|
|
1741
|
+
# reloads first so the version check sees the other side's result.
|
|
1742
|
+
# migrate_cache calls save() directly, never a locked mutator.
|
|
1743
|
+
with manager.storage.exclusive():
|
|
1744
|
+
migrate_cache(manager.storage, pathlib.Path(manager.config.repos_dir))
|
|
1663
1745
|
except OSError as e:
|
|
1664
1746
|
# A failed migration must not take the command with it. The renames
|
|
1665
1747
|
# that did happen are still resumable: the version header is only
|
|
@@ -1755,6 +1837,8 @@ def _run_cli(argv: Optional[List[str]] = None) -> int:
|
|
|
1755
1837
|
return 0
|
|
1756
1838
|
|
|
1757
1839
|
if args[0] == "--ls":
|
|
1840
|
+
if "--json" in args[1:]:
|
|
1841
|
+
return workspaces_as_json()
|
|
1758
1842
|
print_workspaces()
|
|
1759
1843
|
return 0
|
|
1760
1844
|
|
|
@@ -1941,6 +2025,20 @@ def _run_cli(argv: Optional[List[str]] = None) -> int:
|
|
|
1941
2025
|
return workspace_stop(workspace_id)
|
|
1942
2026
|
|
|
1943
2027
|
if subcommand in ("rm", "prune"):
|
|
2028
|
+
# The one thing dl refuses on its own account. It is not a judgement
|
|
2029
|
+
# about whether the work is finished -- dl has no way to know that --
|
|
2030
|
+
# but about whether this clone is the only place the work exists.
|
|
2031
|
+
# Cleanup is expected to be driven by something that knows more than dl
|
|
2032
|
+
# does (a ticket tool, a script, a person), and this is what keeps a
|
|
2033
|
+
# confident caller from destroying an hour of somebody's afternoon.
|
|
2034
|
+
if "--force" not in args[2:]:
|
|
2035
|
+
unsaved = _unsaved_work_in(workspace_id)
|
|
2036
|
+
if unsaved:
|
|
2037
|
+
logging.error(
|
|
2038
|
+
f"{workspace_id} holds {unsaved}. Push or commit it, or run: "
|
|
2039
|
+
f"dl {raw_spec} rm --force"
|
|
2040
|
+
)
|
|
2041
|
+
return 1
|
|
1944
2042
|
return workspace_delete(workspace_id)
|
|
1945
2043
|
|
|
1946
2044
|
if subcommand == "code":
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""What a workspace holds — the facts a cleanup decision is made from elsewhere.
|
|
2
|
+
|
|
3
|
+
A workspace per branch means workspaces accumulate, and something has to remove
|
|
4
|
+
the finished ones. That something is **not devlaunch**: whether a piece of work
|
|
5
|
+
is finished is a fact about a ticket, a review or a person's intent, and dl
|
|
6
|
+
knows about none of those. It knows about clones and containers.
|
|
7
|
+
|
|
8
|
+
So the split is mechanism here, policy in the caller:
|
|
9
|
+
|
|
10
|
+
- ``dl --ls --json`` reports what exists and what each workspace holds, which is
|
|
11
|
+
what a caller needs to decide anything at all.
|
|
12
|
+
- ``dl <ws> rm`` deletes one, and refuses when the clone holds work that exists
|
|
13
|
+
nowhere else.
|
|
14
|
+
|
|
15
|
+
The refusal is the one judgement dl does make, and it is not a policy about
|
|
16
|
+
finished work: it is dl declining to destroy the only copy of something. A
|
|
17
|
+
caller that means it says ``--force``.
|
|
18
|
+
|
|
19
|
+
The alternative — dl inferring "finished" from the branch (merged into the
|
|
20
|
+
default, or deleted from the remote) — was built first and thrown away. It reads
|
|
21
|
+
as a git fact but it is a guess at intent: a squash-merged branch and an
|
|
22
|
+
abandoned one are indistinguishable, a branch merged upstream may still have
|
|
23
|
+
work to do, and a repo whose flow does not delete branches gets nothing. The
|
|
24
|
+
caller that knows the answer should say the answer.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import subprocess
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import List, Optional
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CloneState:
|
|
38
|
+
"""What one workspace clone holds, as far as git can tell.
|
|
39
|
+
|
|
40
|
+
``unsaved`` is the load-bearing field and is deliberately a description
|
|
41
|
+
rather than a flag: it is printed to a person who is deciding whether to
|
|
42
|
+
force a delete, and "3 uncommitted change(s) and 2 unpushed commit(s)" is
|
|
43
|
+
the thing that answers them. ``None`` means the clone holds nothing that
|
|
44
|
+
does not also exist on a remote.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
branch: Optional[str]
|
|
48
|
+
unsaved: Optional[str]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _git(repo: Path, *args: str) -> Optional[str]:
|
|
52
|
+
"""Run git in *repo*, returning stdout, or ``None`` if it refused.
|
|
53
|
+
|
|
54
|
+
A refusal is "cannot tell", never an answer: a clone that is broken, gone or
|
|
55
|
+
not a repository must not stop the other workspaces being reported, and must
|
|
56
|
+
never be reported as *safe to delete* on the strength of a failed command —
|
|
57
|
+
every caller here treats ``None`` as "no information", and the one place
|
|
58
|
+
that matters (:func:`holds_unsaved_work`) fails safe explicitly.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["git", *args], cwd=repo, capture_output=True, text=True, check=False, timeout=30
|
|
63
|
+
)
|
|
64
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
65
|
+
logger.debug(f"git {' '.join(args)} in {repo}: {e}")
|
|
66
|
+
return None
|
|
67
|
+
if result.returncode != 0:
|
|
68
|
+
logger.debug(f"git {' '.join(args)} in {repo}: {result.stderr.strip()}")
|
|
69
|
+
return None
|
|
70
|
+
return result.stdout.strip()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_clone(clone: Path) -> CloneState:
|
|
74
|
+
"""Report what *clone* holds. The only function here that talks to git.
|
|
75
|
+
|
|
76
|
+
A directory that is not there, or is not a repository, holds nothing: there
|
|
77
|
+
is no work in it to lose. That is the truth about it rather than a special
|
|
78
|
+
case, and it is what lets a caller clear away a workspace whose clone was
|
|
79
|
+
already removed by hand.
|
|
80
|
+
"""
|
|
81
|
+
if not clone.is_dir():
|
|
82
|
+
return CloneState(branch=None, unsaved=None)
|
|
83
|
+
branch = _git(clone, "rev-parse", "--abbrev-ref", "HEAD")
|
|
84
|
+
return CloneState(branch=branch or None, unsaved=_unsaved(clone, branch))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _unsaved(clone: Path, branch: Optional[str]) -> Optional[str]:
|
|
88
|
+
"""What deleting *clone* would destroy, in words, or ``None`` if nothing.
|
|
89
|
+
|
|
90
|
+
Two kinds of loss, reported together because someone deciding whether to
|
|
91
|
+
force a delete wants both:
|
|
92
|
+
|
|
93
|
+
- a dirty tree, **untracked files included** — an agent's scratch notes are
|
|
94
|
+
not less lost for never having been added;
|
|
95
|
+
- commits no remote-tracking ref contains. ``--not --remotes`` asks about
|
|
96
|
+
*any* remote ref rather than this branch's upstream, so work that was
|
|
97
|
+
pushed under another name, or merged and fetched back, is correctly not
|
|
98
|
+
counted as lost.
|
|
99
|
+
"""
|
|
100
|
+
losses: List[str] = []
|
|
101
|
+
status = _git(clone, "status", "--porcelain")
|
|
102
|
+
if status:
|
|
103
|
+
losses.append(f"{len(status.splitlines())} uncommitted change(s)")
|
|
104
|
+
if branch:
|
|
105
|
+
# Argument order is load-bearing: `--not` flips the sense of every ref
|
|
106
|
+
# *after* it, so the branch has to be named before it. `log --not
|
|
107
|
+
# --remotes <branch>` excludes the branch as well and is silently always
|
|
108
|
+
# empty — which would report every clone as safe to delete.
|
|
109
|
+
unpushed = _git(clone, "log", "--oneline", branch, "--not", "--remotes")
|
|
110
|
+
if unpushed:
|
|
111
|
+
losses.append(f"{len(unpushed.splitlines())} unpushed commit(s)")
|
|
112
|
+
return " and ".join(losses) if losses else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def holds_unsaved_work(clone: Path) -> Optional[str]:
|
|
116
|
+
"""What would be lost by deleting *clone*, or ``None`` if nothing would be.
|
|
117
|
+
|
|
118
|
+
The guard `dl <ws> rm` consults. Thin on purpose: the interesting behaviour
|
|
119
|
+
is in :func:`read_clone`, and this is the name the guard reads by.
|
|
120
|
+
"""
|
|
121
|
+
return read_clone(clone).unsaved
|
|
@@ -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
|