devlaunch 0.0.20__tar.gz → 0.0.22__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.20 → devlaunch-0.0.22}/PKG-INFO +65 -1
- {devlaunch-0.0.20 → devlaunch-0.0.22}/README.md +64 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/dl.py +95 -2
- devlaunch-0.0.22/devlaunch/workspace_state.py +150 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/pyproject.toml +1 -1
- {devlaunch-0.0.20 → devlaunch-0.0.22}/.gitignore +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/LICENSE +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/__init__.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/aid.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/completion.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/completion_loader.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/completions/__init__.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/completions/dl.bash +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/devpod_provider.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/devpod_ssh.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/gh_auth.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/tools.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/tty_session.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/workspace_id.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/__init__.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/branch_manager.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/config.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/locks.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/migration.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/models.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/repo_manager.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/storage.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/workspace_clone.py +0 -0
- {devlaunch-0.0.20 → devlaunch-0.0.22}/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.22
|
|
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,69 @@ 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) (pixi.lock, notes.md) 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. The changed paths are
|
|
437
|
+
named, not just counted, and that matters more than it looks: a devcontainer
|
|
438
|
+
that runs a package install in its `postCreateCommand` can leave a tracked
|
|
439
|
+
lockfile modified in *every* workspace it builds — this repo's own does — and as
|
|
440
|
+
a bare count that is indistinguishable from an hour of unsaved work. A cleanup
|
|
441
|
+
tool believing the count would then never clean anything. Named, it is
|
|
442
|
+
judgeable. A workspace `dl` did not
|
|
443
|
+
create reports `devlaunch: false` with no repo, branch or `unsaved`: there is no
|
|
444
|
+
clone of `dl`'s to protect, and it has no business inspecting your checkout.
|
|
445
|
+
|
|
446
|
+
**`dl <workspace> rm` refuses when the clone holds unsaved work**, so a caller
|
|
447
|
+
that forgets to read the field is still caught:
|
|
448
|
+
|
|
449
|
+
```
|
|
450
|
+
$ dl blooop/repo@feature rm
|
|
451
|
+
error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s).
|
|
452
|
+
Push or commit it, or run: dl blooop/repo@feature rm --force
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
That refusal is the only judgement `dl` makes here, and it is not about finished
|
|
456
|
+
work — it is `dl` declining to destroy the only copy of something. Say `--force`
|
|
457
|
+
if you mean it.
|
|
458
|
+
|
|
459
|
+
[`wf`](https://github.com/blooop/wayfinder) is the caller this was built for: it
|
|
460
|
+
names its branches after its tickets, so it knows which workspaces belong to
|
|
461
|
+
finished work and removes those.
|
|
462
|
+
|
|
399
463
|
## Examples
|
|
400
464
|
|
|
401
465
|
```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,69 @@ 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) (pixi.lock, notes.md) 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. The changed paths are
|
|
415
|
+
named, not just counted, and that matters more than it looks: a devcontainer
|
|
416
|
+
that runs a package install in its `postCreateCommand` can leave a tracked
|
|
417
|
+
lockfile modified in *every* workspace it builds — this repo's own does — and as
|
|
418
|
+
a bare count that is indistinguishable from an hour of unsaved work. A cleanup
|
|
419
|
+
tool believing the count would then never clean anything. Named, it is
|
|
420
|
+
judgeable. A workspace `dl` did not
|
|
421
|
+
create reports `devlaunch: false` with no repo, branch or `unsaved`: there is no
|
|
422
|
+
clone of `dl`'s to protect, and it has no business inspecting your checkout.
|
|
423
|
+
|
|
424
|
+
**`dl <workspace> rm` refuses when the clone holds unsaved work**, so a caller
|
|
425
|
+
that forgets to read the field is still caught:
|
|
426
|
+
|
|
427
|
+
```
|
|
428
|
+
$ dl blooop/repo@feature rm
|
|
429
|
+
error: devlaunch-repo-feature-xyz holds 1 unpushed commit(s).
|
|
430
|
+
Push or commit it, or run: dl blooop/repo@feature rm --force
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
That refusal is the only judgement `dl` makes here, and it is not about finished
|
|
434
|
+
work — it is `dl` declining to destroy the only copy of something. Say `--force`
|
|
435
|
+
if you mean it.
|
|
436
|
+
|
|
437
|
+
[`wf`](https://github.com/blooop/wayfinder) is the caller this was built for: it
|
|
438
|
+
names its branches after its tickets, so it knows which workspaces belong to
|
|
439
|
+
finished work and removes those.
|
|
440
|
+
|
|
377
441
|
## Examples
|
|
378
442
|
|
|
379
443
|
```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
|
|
@@ -1760,6 +1837,8 @@ def _run_cli(argv: Optional[List[str]] = None) -> int:
|
|
|
1760
1837
|
return 0
|
|
1761
1838
|
|
|
1762
1839
|
if args[0] == "--ls":
|
|
1840
|
+
if "--json" in args[1:]:
|
|
1841
|
+
return workspaces_as_json()
|
|
1763
1842
|
print_workspaces()
|
|
1764
1843
|
return 0
|
|
1765
1844
|
|
|
@@ -1946,6 +2025,20 @@ def _run_cli(argv: Optional[List[str]] = None) -> int:
|
|
|
1946
2025
|
return workspace_stop(workspace_id)
|
|
1947
2026
|
|
|
1948
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
|
|
1949
2042
|
return workspace_delete(workspace_id)
|
|
1950
2043
|
|
|
1951
2044
|
if subcommand == "code":
|
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
Only trailing newlines are trimmed, never leading whitespace. A full
|
|
61
|
+
``strip()`` here was wrong in a way that took real use to notice: the first
|
|
62
|
+
line of ``git status --porcelain`` for a *modified tracked* file begins with
|
|
63
|
+
a space (`` M pixi.lock``), so stripping ate the status column and
|
|
64
|
+
:func:`_name_a_few` then reported ``ixi.lock``. Untracked entries start
|
|
65
|
+
``??`` and were unharmed, which is exactly why the tests missed it.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
["git", *args], cwd=repo, capture_output=True, text=True, check=False, timeout=30
|
|
70
|
+
)
|
|
71
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
72
|
+
logger.debug(f"git {' '.join(args)} in {repo}: {e}")
|
|
73
|
+
return None
|
|
74
|
+
if result.returncode != 0:
|
|
75
|
+
logger.debug(f"git {' '.join(args)} in {repo}: {result.stderr.strip()}")
|
|
76
|
+
return None
|
|
77
|
+
return result.stdout.rstrip("\n")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_clone(clone: Path) -> CloneState:
|
|
81
|
+
"""Report what *clone* holds. The only function here that talks to git.
|
|
82
|
+
|
|
83
|
+
A directory that is not there, or is not a repository, holds nothing: there
|
|
84
|
+
is no work in it to lose. That is the truth about it rather than a special
|
|
85
|
+
case, and it is what lets a caller clear away a workspace whose clone was
|
|
86
|
+
already removed by hand.
|
|
87
|
+
"""
|
|
88
|
+
if not clone.is_dir():
|
|
89
|
+
return CloneState(branch=None, unsaved=None)
|
|
90
|
+
branch = _git(clone, "rev-parse", "--abbrev-ref", "HEAD")
|
|
91
|
+
return CloneState(branch=branch or None, unsaved=_unsaved(clone, branch))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _name_a_few(changed: List[str], limit: int = 3) -> str:
|
|
95
|
+
"""Name the first few changed paths from `git status --porcelain` lines.
|
|
96
|
+
|
|
97
|
+
A count alone is not enough to decide anything with. A devcontainer that
|
|
98
|
+
runs `pixi install` in its `postCreateCommand` leaves the tracked lockfile
|
|
99
|
+
modified in *every* workspace it builds, so "1 uncommitted change(s)" is the
|
|
100
|
+
permanent state of an otherwise untouched clone — and a person told only the
|
|
101
|
+
count has no way to tell that from an hour of unsaved work. Told the name,
|
|
102
|
+
they can.
|
|
103
|
+
|
|
104
|
+
The porcelain format is two status characters, a space, then the path, so
|
|
105
|
+
the path starts at offset 3; a rename reads `old -> new`, and the whole
|
|
106
|
+
field is kept rather than split, because both halves are the news.
|
|
107
|
+
"""
|
|
108
|
+
names = [line[3:].strip() for line in changed[:limit] if len(line) > 3]
|
|
109
|
+
if len(changed) > limit:
|
|
110
|
+
names.append("…")
|
|
111
|
+
return ", ".join(names)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _unsaved(clone: Path, branch: Optional[str]) -> Optional[str]:
|
|
115
|
+
"""What deleting *clone* would destroy, in words, or ``None`` if nothing.
|
|
116
|
+
|
|
117
|
+
Two kinds of loss, reported together because someone deciding whether to
|
|
118
|
+
force a delete wants both:
|
|
119
|
+
|
|
120
|
+
- a dirty tree, **untracked files included** — an agent's scratch notes are
|
|
121
|
+
not less lost for never having been added — with the first few paths
|
|
122
|
+
named, because a count alone cannot be judged (see :func:`_name_a_few`);
|
|
123
|
+
- commits no remote-tracking ref contains. ``--not --remotes`` asks about
|
|
124
|
+
*any* remote ref rather than this branch's upstream, so work that was
|
|
125
|
+
pushed under another name, or merged and fetched back, is correctly not
|
|
126
|
+
counted as lost.
|
|
127
|
+
"""
|
|
128
|
+
losses: List[str] = []
|
|
129
|
+
status = _git(clone, "status", "--porcelain")
|
|
130
|
+
if status:
|
|
131
|
+
changed = status.splitlines()
|
|
132
|
+
losses.append(f"{len(changed)} uncommitted change(s) ({_name_a_few(changed)})")
|
|
133
|
+
if branch:
|
|
134
|
+
# Argument order is load-bearing: `--not` flips the sense of every ref
|
|
135
|
+
# *after* it, so the branch has to be named before it. `log --not
|
|
136
|
+
# --remotes <branch>` excludes the branch as well and is silently always
|
|
137
|
+
# empty — which would report every clone as safe to delete.
|
|
138
|
+
unpushed = _git(clone, "log", "--oneline", branch, "--not", "--remotes")
|
|
139
|
+
if unpushed:
|
|
140
|
+
losses.append(f"{len(unpushed.splitlines())} unpushed commit(s)")
|
|
141
|
+
return " and ".join(losses) if losses else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def holds_unsaved_work(clone: Path) -> Optional[str]:
|
|
145
|
+
"""What would be lost by deleting *clone*, or ``None`` if nothing would be.
|
|
146
|
+
|
|
147
|
+
The guard `dl <ws> rm` consults. Thin on purpose: the interesting behaviour
|
|
148
|
+
is in :func:`read_clone`, and this is the name the guard reads by.
|
|
149
|
+
"""
|
|
150
|
+
return read_clone(clone).unsaved
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|