Skip to content

Commit f7c60c3

Browse files
committed
feat(import[prune-untracked]) Add --prune-untracked flag to remove untagged entries
why: --sync/--prune only removes entries tagged with the current import source. Manually-added repos (no provenance metadata) are invisible to pruning. Users wanting a workspace to mirror a remote exactly need a way to clean out untagged entries. what: - Add _classify_untracked_prune_action() classifier with doctests - Add --prune-untracked CLI flag requiring --sync or --prune - Add second prune pass targeting entries without any import provenance - Preserve pinned entries and entries tagged from other import sources - Support dry-run, --yes, and interactive confirmation dialog - Wire through all 6 service handlers - Add 9 integration tests covering all scenarios - Add CHANGES entry
1 parent 150b7b8 commit f7c60c3

File tree

9 files changed

+669
-0
lines changed

9 files changed

+669
-0
lines changed

CHANGES

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ $ vcspull import gh myorg \
5353
Available on all six import providers. Pruning is config-only — cloned
5454
directories on disk are not deleted. See {ref}`cli-import` for details.
5555

56+
#### `vcspull import`: Add `--prune-untracked` flag (#520)
57+
58+
`--prune-untracked` expands `--sync` / `--prune` to also remove config
59+
entries in the target workspace that lack import provenance — repos added
60+
manually or before provenance tracking. Entries imported from other sources
61+
and pinned entries are preserved. A confirmation prompt lists exactly what
62+
would be removed.
63+
64+
```console
65+
$ vcspull import gh myorg \
66+
--workspace ~/code/ \
67+
--sync \
68+
--prune-untracked \
69+
--dry-run
70+
```
71+
5672
#### `options.pin`: Per-repo, per-operation mutation guard (#520)
5773

5874
Any repository entry can carry an `options.pin` block that prevents specific

src/vcspull/cli/import_cmd/_common.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,91 @@ def _classify_prune_action(
252252
return ImportAction.PRUNE
253253

254254

255+
def _classify_untracked_prune_action(
256+
*,
257+
existing_entry: dict[str, t.Any] | str | t.Any,
258+
) -> ImportAction:
259+
"""Classify whether an untracked config entry should be pruned.
260+
261+
"Untracked" means the entry has no import provenance from *any* source.
262+
This is the complement of :func:`_classify_prune_action`, which checks
263+
for a *specific* source tag.
264+
265+
Parameters
266+
----------
267+
existing_entry : dict | str | Any
268+
Current config entry for this repo name.
269+
270+
Returns
271+
-------
272+
ImportAction
273+
``PRUNE`` for entries without provenance (untracked),
274+
``SKIP_PINNED`` for pinned entries,
275+
``SKIP_EXISTING`` for entries that have provenance from any source.
276+
277+
Examples
278+
--------
279+
String entries have no metadata — they are untracked:
280+
281+
>>> _classify_untracked_prune_action(existing_entry="git+ssh://x")
282+
<ImportAction.PRUNE: 'prune'>
283+
284+
Dict entry without metadata is untracked:
285+
286+
>>> _classify_untracked_prune_action(
287+
... existing_entry={"repo": "git+ssh://x"},
288+
... )
289+
<ImportAction.PRUNE: 'prune'>
290+
291+
Dict entry with provenance from any source is tracked (kept):
292+
293+
>>> _classify_untracked_prune_action(
294+
... existing_entry={
295+
... "repo": "git+ssh://x",
296+
... "metadata": {"imported_from": "github:org"},
297+
... },
298+
... )
299+
<ImportAction.SKIP_EXISTING: 'skip_existing'>
300+
301+
>>> _classify_untracked_prune_action(
302+
... existing_entry={
303+
... "repo": "git+ssh://x",
304+
... "metadata": {"imported_from": "gitlab:other"},
305+
... },
306+
... )
307+
<ImportAction.SKIP_EXISTING: 'skip_existing'>
308+
309+
Pinned entry without provenance is protected:
310+
311+
>>> _classify_untracked_prune_action(
312+
... existing_entry={
313+
... "repo": "git+ssh://x",
314+
... "options": {"pin": True},
315+
... },
316+
... )
317+
<ImportAction.SKIP_PINNED: 'skip_pinned'>
318+
319+
Pinned entry with provenance is also protected:
320+
321+
>>> _classify_untracked_prune_action(
322+
... existing_entry={
323+
... "repo": "git+ssh://x",
324+
... "options": {"pin": True},
325+
... "metadata": {"imported_from": "github:org"},
326+
... },
327+
... )
328+
<ImportAction.SKIP_PINNED: 'skip_pinned'>
329+
"""
330+
if not isinstance(existing_entry, dict):
331+
return ImportAction.PRUNE
332+
if is_pinned_for_op(existing_entry, "import"):
333+
return ImportAction.SKIP_PINNED
334+
metadata = existing_entry.get("metadata")
335+
if isinstance(metadata, dict) and metadata.get("imported_from"):
336+
return ImportAction.SKIP_EXISTING
337+
return ImportAction.PRUNE
338+
339+
255340
# ---------------------------------------------------------------------------
256341
# Parent parser factories
257342
# ---------------------------------------------------------------------------
@@ -381,6 +466,17 @@ def _create_shared_parent() -> argparse.ArgumentParser:
381466
"Respects pinned entries."
382467
),
383468
)
469+
output_group.add_argument(
470+
"--prune-untracked",
471+
dest="prune_untracked",
472+
action="store_true",
473+
help=(
474+
"With --sync or --prune, also remove config entries in the "
475+
"target workspace that lack import provenance (e.g. manually "
476+
"added repos). Entries imported from other sources and pinned "
477+
"entries are preserved. Requires --sync or --prune."
478+
),
479+
)
384480
output_group.add_argument(
385481
"--color",
386482
choices=["auto", "always", "never"],
@@ -510,6 +606,7 @@ def _run_import(
510606
skip_groups: list[str] | None = None,
511607
sync: bool = False,
512608
prune: bool = False,
609+
prune_untracked: bool = False,
513610
import_source: str | None = None,
514611
) -> int:
515612
"""Run the import workflow for a single service.
@@ -585,6 +682,13 @@ def _run_import(
585682
formatter = OutputFormatter(output_mode)
586683
colors = Colors(get_color_mode(color))
587684

685+
if prune_untracked and not (sync or prune):
686+
log.error(
687+
"%s --prune-untracked requires --sync or --prune",
688+
colors.error("✗"),
689+
)
690+
return 1
691+
588692
# Build import options
589693
import_mode = ImportMode(mode)
590694
topic_list = (
@@ -1036,6 +1140,88 @@ def _run_import(
10361140
)
10371141
skip_pinned_count += 1
10381142

1143+
# Prune untracked entries (no import provenance from any source)
1144+
untracked_pruned: list[tuple[str, str]] = []
1145+
if prune_untracked and import_source:
1146+
target_workspaces = {ws for ws, _name in imported_workspace_repos}
1147+
for ws_label in target_workspaces:
1148+
ws_entries = raw_config.get(ws_label)
1149+
if not isinstance(ws_entries, dict):
1150+
continue
1151+
for repo_name in list(ws_entries):
1152+
if (ws_label, repo_name) in imported_workspace_repos:
1153+
continue
1154+
ut_action = _classify_untracked_prune_action(
1155+
existing_entry=ws_entries[repo_name],
1156+
)
1157+
if ut_action == ImportAction.PRUNE:
1158+
untracked_pruned.append((ws_label, repo_name))
1159+
elif ut_action == ImportAction.SKIP_PINNED:
1160+
reason = get_pin_reason(ws_entries[repo_name])
1161+
log.info(
1162+
"%s Skipping pruning pinned untracked repo: %s%s",
1163+
colors.warning("⊘"),
1164+
repo_name,
1165+
f" ({reason})" if reason else "",
1166+
)
1167+
skip_pinned_count += 1
1168+
1169+
if untracked_pruned:
1170+
if dry_run:
1171+
for _ws_label, repo_name in untracked_pruned:
1172+
log.info(
1173+
"[DRY-RUN] --prune-untracked: Would remove %s (untracked)",
1174+
repo_name,
1175+
)
1176+
pruned_count += len(untracked_pruned)
1177+
elif not yes:
1178+
# Confirmation prompt
1179+
by_ws: dict[str, list[str]] = {}
1180+
for ws_label, repo_name in untracked_pruned:
1181+
by_ws.setdefault(ws_label, []).append(repo_name)
1182+
for ws_label, names in by_ws.items():
1183+
log.warning(
1184+
"%s --prune-untracked: %d untracked %s in %s:",
1185+
colors.warning("⚠"),
1186+
len(names),
1187+
"entry" if len(names) == 1 else "entries",
1188+
ws_label,
1189+
)
1190+
for name in names:
1191+
log.warning(" %s", name)
1192+
log.warning(
1193+
" These entries have no import provenance.",
1194+
)
1195+
if not sys.stdin.isatty():
1196+
log.info(
1197+
"%s Non-interactive mode: use --yes to skip confirmation.",
1198+
colors.error("✗"),
1199+
)
1200+
else:
1201+
try:
1202+
confirm = input(" Remove? [y/N]: ").lower()
1203+
except EOFError:
1204+
confirm = ""
1205+
if confirm in {"y", "yes"}:
1206+
for ws_label, repo_name in untracked_pruned:
1207+
ws_e = raw_config.get(ws_label)
1208+
if isinstance(ws_e, dict) and repo_name in ws_e:
1209+
del ws_e[repo_name]
1210+
pruned_count += len(untracked_pruned)
1211+
else:
1212+
log.info(
1213+
"Kept %d untracked %s.",
1214+
len(untracked_pruned),
1215+
"entry" if len(untracked_pruned) == 1 else "entries",
1216+
)
1217+
else:
1218+
# --yes mode: proceed without confirmation
1219+
for ws_label, repo_name in untracked_pruned:
1220+
ws_e = raw_config.get(ws_label)
1221+
if isinstance(ws_e, dict) and repo_name in ws_e:
1222+
del ws_e[repo_name]
1223+
pruned_count += len(untracked_pruned)
1224+
10391225
if error_labels:
10401226
return 1
10411227

src/vcspull/cli/import_cmd/codeberg.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,5 +83,6 @@ def handle_codeberg(args: argparse.Namespace) -> int:
8383
use_https=getattr(args, "use_https", False),
8484
sync=getattr(args, "sync", False),
8585
prune=getattr(args, "prune", False),
86+
prune_untracked=getattr(args, "prune_untracked", False),
8687
import_source=f"codeberg:{args.target}",
8788
)

src/vcspull/cli/import_cmd/codecommit.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,6 @@ def handle_codecommit(args: argparse.Namespace) -> int:
103103
use_https=getattr(args, "use_https", False),
104104
sync=getattr(args, "sync", False),
105105
prune=getattr(args, "prune", False),
106+
prune_untracked=getattr(args, "prune_untracked", False),
106107
import_source=f"codecommit:{getattr(args, 'target', '') or '*'}",
107108
)

src/vcspull/cli/import_cmd/forgejo.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,6 @@ def handle_forgejo(args: argparse.Namespace) -> int:
9090
use_https=getattr(args, "use_https", False),
9191
sync=getattr(args, "sync", False),
9292
prune=getattr(args, "prune", False),
93+
prune_untracked=getattr(args, "prune_untracked", False),
9394
import_source=f"forgejo:{args.target}",
9495
)

src/vcspull/cli/import_cmd/gitea.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,6 @@ def handle_gitea(args: argparse.Namespace) -> int:
9090
use_https=getattr(args, "use_https", False),
9191
sync=getattr(args, "sync", False),
9292
prune=getattr(args, "prune", False),
93+
prune_untracked=getattr(args, "prune_untracked", False),
9394
import_source=f"gitea:{args.target}",
9495
)

src/vcspull/cli/import_cmd/github.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,5 +92,6 @@ def handle_github(args: argparse.Namespace) -> int:
9292
use_https=getattr(args, "use_https", False),
9393
sync=getattr(args, "sync", False),
9494
prune=getattr(args, "prune", False),
95+
prune_untracked=getattr(args, "prune_untracked", False),
9596
import_source=f"github:{args.target}",
9697
)

src/vcspull/cli/import_cmd/gitlab.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,5 +123,6 @@ def handle_gitlab(args: argparse.Namespace) -> int:
123123
skip_groups=getattr(args, "skip_groups", None),
124124
sync=getattr(args, "sync", False),
125125
prune=getattr(args, "prune", False),
126+
prune_untracked=getattr(args, "prune_untracked", False),
126127
import_source=f"gitlab:{args.target}",
127128
)

0 commit comments

Comments
 (0)