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.
Files changed (29) hide show
  1. {devlaunch-0.0.20 → devlaunch-0.0.22}/PKG-INFO +65 -1
  2. {devlaunch-0.0.20 → devlaunch-0.0.22}/README.md +64 -0
  3. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/dl.py +95 -2
  4. devlaunch-0.0.22/devlaunch/workspace_state.py +150 -0
  5. {devlaunch-0.0.20 → devlaunch-0.0.22}/pyproject.toml +1 -1
  6. {devlaunch-0.0.20 → devlaunch-0.0.22}/.gitignore +0 -0
  7. {devlaunch-0.0.20 → devlaunch-0.0.22}/LICENSE +0 -0
  8. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/__init__.py +0 -0
  9. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/aid.py +0 -0
  10. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/completion.py +0 -0
  11. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/completion_loader.py +0 -0
  12. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/completions/__init__.py +0 -0
  13. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/completions/dl.bash +0 -0
  14. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/devpod_provider.py +0 -0
  15. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/devpod_ssh.py +0 -0
  16. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/gh_auth.py +0 -0
  17. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/tools.py +0 -0
  18. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/tty_session.py +0 -0
  19. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/workspace_id.py +0 -0
  20. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/__init__.py +0 -0
  21. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/branch_manager.py +0 -0
  22. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/config.py +0 -0
  23. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/locks.py +0 -0
  24. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/migration.py +0 -0
  25. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/models.py +0 -0
  26. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/repo_manager.py +0 -0
  27. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/storage.py +0 -0
  28. {devlaunch-0.0.20 → devlaunch-0.0.22}/devlaunch/worktree/workspace_clone.py +0 -0
  29. {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.20
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devlaunch"
3
- version = "0.0.20"
3
+ version = "0.0.22"
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