@@ -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
0 commit comments