Skip to content

Commit 54aa40a

Browse files
committed
cli(add,discover)(feat[no-merge]): Add duplicate merge opt-out
why: Some workflows want to inspect duplicate roots without auto-merging; add/discover always merged, risking unintended writes. what: - add a shared `--no-merge` flag that skips automatic merging while still logging warnings for duplicates - thread the optional merge behavior through add_repo and discover_repos using the duplicate-aware loader helpers - keep merge-on as the default so existing behavior stays intact
1 parent ddcd619 commit 54aa40a

File tree

3 files changed

+185
-45
lines changed

3 files changed

+185
-45
lines changed

src/vcspull/cli/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ def cli(_args: list[str] | None = None) -> None:
377377
workspace_root_override=args.workspace_root_path,
378378
yes=args.yes,
379379
dry_run=args.dry_run,
380+
merge_duplicates=args.merge_duplicates,
380381
)
381382
elif args.subparser_name == "fmt":
382383
format_config_file(

src/vcspull/cli/add.py

Lines changed: 84 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,20 @@ def create_add_subparser(parser: argparse.ArgumentParser) -> None:
8383
action="store_true",
8484
help="Preview changes without writing to config file",
8585
)
86+
parser.add_argument(
87+
"--no-merge",
88+
dest="merge_duplicates",
89+
action="store_false",
90+
help="Skip merging duplicate workspace roots before writing",
91+
)
8692
parser.add_argument(
8793
"-y",
8894
"--yes",
8995
dest="assume_yes",
9096
action="store_true",
9197
help="Automatically confirm interactive prompts",
9298
)
99+
parser.set_defaults(merge_duplicates=True)
93100

94101

95102
def _resolve_workspace_path(
@@ -174,6 +181,7 @@ def handle_add_command(args: argparse.Namespace) -> None:
174181
path=args.path,
175182
workspace_root_path=args.workspace_root_path,
176183
dry_run=args.dry_run,
184+
merge_duplicates=args.merge_duplicates,
177185
)
178186
return
179187

@@ -289,6 +297,7 @@ def handle_add_command(args: argparse.Namespace) -> None:
289297
path=str(repo_path),
290298
workspace_root_path=workspace_root_input,
291299
dry_run=args.dry_run,
300+
merge_duplicates=args.merge_duplicates,
292301
)
293302

294303

@@ -299,6 +308,8 @@ def add_repo(
299308
path: str | None,
300309
workspace_root_path: str | None,
301310
dry_run: bool,
311+
*,
312+
merge_duplicates: bool = True,
302313
) -> None:
303314
"""Add a repository to the vcspull configuration.
304315
@@ -365,41 +376,84 @@ def add_repo(
365376
config_file_path,
366377
)
367378

368-
(
369-
raw_config,
370-
duplicate_merge_conflicts,
371-
duplicate_merge_changes,
372-
duplicate_merge_details,
373-
) = merge_duplicate_workspace_roots(raw_config, duplicate_root_occurrences)
374-
375-
for message in duplicate_merge_conflicts:
376-
log.warning(message)
379+
duplicate_merge_conflicts: list[str] = []
380+
duplicate_merge_changes = 0
381+
duplicate_merge_details: list[tuple[str, int]] = []
382+
383+
if merge_duplicates:
384+
(
385+
raw_config,
386+
duplicate_merge_conflicts,
387+
duplicate_merge_changes,
388+
duplicate_merge_details,
389+
) = merge_duplicate_workspace_roots(raw_config, duplicate_root_occurrences)
390+
for message in duplicate_merge_conflicts:
391+
log.warning(message)
392+
393+
if duplicate_merge_changes and duplicate_merge_details:
394+
for label, occurrence_count in duplicate_merge_details:
395+
log.info(
396+
"%s•%s Merged %s%d%s duplicate entr%s for workspace root %s%s%s",
397+
Fore.BLUE,
398+
Style.RESET_ALL,
399+
Fore.YELLOW,
400+
occurrence_count,
401+
Style.RESET_ALL,
402+
"y" if occurrence_count == 1 else "ies",
403+
Fore.MAGENTA,
404+
label,
405+
Style.RESET_ALL,
406+
)
407+
else:
408+
if duplicate_root_occurrences:
409+
duplicate_merge_details = [
410+
(label, len(values))
411+
for label, values in duplicate_root_occurrences.items()
412+
]
413+
for label, occurrence_count in duplicate_merge_details:
414+
log.warning(
415+
"%s•%s Duplicate workspace root %s%s%s appears %s%d%s time%s; "
416+
"skipping merge because --no-merge was provided.",
417+
Fore.BLUE,
418+
Style.RESET_ALL,
419+
Fore.MAGENTA,
420+
label,
421+
Style.RESET_ALL,
422+
Fore.YELLOW,
423+
occurrence_count,
424+
Style.RESET_ALL,
425+
"" if occurrence_count == 1 else "s",
426+
)
377427

378-
if duplicate_merge_changes and duplicate_merge_details:
379-
for label, occurrence_count in duplicate_merge_details:
380-
log.info(
381-
"%s•%s Merged %s%d%s duplicate entr%s for workspace root %s%s%s",
382-
Fore.BLUE,
383-
Style.RESET_ALL,
384-
Fore.YELLOW,
385-
occurrence_count,
386-
Style.RESET_ALL,
387-
"y" if occurrence_count == 1 else "ies",
388-
Fore.MAGENTA,
389-
label,
390-
Style.RESET_ALL,
391-
)
428+
duplicate_merge_conflicts = []
392429

393430
cwd = pathlib.Path.cwd()
394431
home = pathlib.Path.home()
395432

396-
normalization_result = normalize_workspace_roots(
397-
raw_config,
398-
cwd=cwd,
399-
home=home,
400-
)
401-
raw_config, workspace_map, merge_conflicts, merge_changes = normalization_result
402-
config_was_normalized = (merge_changes + duplicate_merge_changes) > 0
433+
if merge_duplicates:
434+
(
435+
raw_config,
436+
workspace_map,
437+
merge_conflicts,
438+
merge_changes,
439+
) = normalize_workspace_roots(
440+
raw_config,
441+
cwd=cwd,
442+
home=home,
443+
)
444+
config_was_normalized = (merge_changes + duplicate_merge_changes) > 0
445+
else:
446+
(
447+
_normalized_preview,
448+
workspace_map,
449+
merge_conflicts,
450+
_merge_changes,
451+
) = normalize_workspace_roots(
452+
raw_config,
453+
cwd=cwd,
454+
home=home,
455+
)
456+
config_was_normalized = False
403457

404458
for message in merge_conflicts:
405459
log.warning(message)

src/vcspull/cli/discover.py

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111

1212
from colorama import Fore, Style
1313

14-
from vcspull._internal.config_reader import ConfigReader
14+
from vcspull._internal.config_reader import DuplicateAwareConfigReader
1515
from vcspull.config import (
1616
canonicalize_workspace_path,
1717
expand_dir,
1818
find_home_config_files,
19+
merge_duplicate_workspace_roots,
1920
normalize_workspace_roots,
2021
save_config_yaml,
2122
workspace_root_label,
@@ -104,6 +105,13 @@ def create_discover_subparser(parser: argparse.ArgumentParser) -> None:
104105
action="store_true",
105106
help="Preview changes without writing to config file",
106107
)
108+
parser.add_argument(
109+
"--no-merge",
110+
dest="merge_duplicates",
111+
action="store_false",
112+
help="Skip merging duplicate workspace roots before writing",
113+
)
114+
parser.set_defaults(merge_duplicates=True)
107115

108116

109117
def _resolve_workspace_path(
@@ -142,6 +150,8 @@ def discover_repos(
142150
workspace_root_override: str | None,
143151
yes: bool,
144152
dry_run: bool,
153+
*,
154+
merge_duplicates: bool = True,
145155
) -> None:
146156
"""Scan filesystem for git repositories and add to vcspull config.
147157
@@ -186,27 +196,36 @@ def discover_repos(
186196
else:
187197
config_file_path = home_configs[0]
188198

189-
raw_config: dict[str, t.Any] = {}
199+
raw_config: dict[str, t.Any]
200+
duplicate_root_occurrences: dict[str, list[t.Any]]
190201
if config_file_path.exists() and config_file_path.is_file():
191202
try:
192-
loaded_config = ConfigReader._from_file(config_file_path)
203+
(
204+
raw_config,
205+
duplicate_root_occurrences,
206+
) = DuplicateAwareConfigReader.load_with_duplicates(config_file_path)
207+
except TypeError:
208+
log.exception(
209+
"Config file %s is not a valid YAML dictionary.",
210+
config_file_path,
211+
)
212+
return
193213
except Exception:
194214
log.exception("Error loading YAML from %s. Aborting.", config_file_path)
195215
if log.isEnabledFor(logging.DEBUG):
196216
traceback.print_exc()
197217
return
198-
199-
if loaded_config is None:
218+
if raw_config is None:
200219
raw_config = {}
201-
elif isinstance(loaded_config, dict):
202-
raw_config = loaded_config
203-
else:
220+
elif not isinstance(raw_config, dict):
204221
log.error(
205222
"Config file %s is not a valid YAML dictionary.",
206223
config_file_path,
207224
)
208225
return
209226
else:
227+
raw_config = {}
228+
duplicate_root_occurrences = {}
210229
log.info(
211230
"%si%s Config file %s%s%s not found. A new one will be created.",
212231
Fore.CYAN,
@@ -216,15 +235,79 @@ def discover_repos(
216235
Style.RESET_ALL,
217236
)
218237

238+
duplicate_merge_conflicts: list[str] = []
239+
duplicate_merge_changes = 0
240+
duplicate_merge_details: list[tuple[str, int]] = []
241+
242+
if merge_duplicates:
243+
(
244+
raw_config,
245+
duplicate_merge_conflicts,
246+
duplicate_merge_changes,
247+
duplicate_merge_details,
248+
) = merge_duplicate_workspace_roots(raw_config, duplicate_root_occurrences)
249+
for message in duplicate_merge_conflicts:
250+
log.warning(message)
251+
if duplicate_merge_changes and duplicate_merge_details:
252+
for label, occurrence_count in duplicate_merge_details:
253+
log.info(
254+
"%s•%s Merged %s%d%s duplicate entr%s for workspace root %s%s%s",
255+
Fore.BLUE,
256+
Style.RESET_ALL,
257+
Fore.YELLOW,
258+
occurrence_count,
259+
Style.RESET_ALL,
260+
"y" if occurrence_count == 1 else "ies",
261+
Fore.MAGENTA,
262+
label,
263+
Style.RESET_ALL,
264+
)
265+
else:
266+
if duplicate_root_occurrences:
267+
duplicate_merge_details = [
268+
(label, len(values))
269+
for label, values in duplicate_root_occurrences.items()
270+
]
271+
for label, occurrence_count in duplicate_merge_details:
272+
log.warning(
273+
"%s•%s Duplicate workspace root %s%s%s appears %s%d%s time%s; "
274+
"skipping merge because --no-merge was provided.",
275+
Fore.BLUE,
276+
Style.RESET_ALL,
277+
Fore.MAGENTA,
278+
label,
279+
Style.RESET_ALL,
280+
Fore.YELLOW,
281+
occurrence_count,
282+
Style.RESET_ALL,
283+
"" if occurrence_count == 1 else "s",
284+
)
285+
219286
cwd = pathlib.Path.cwd()
220287
home = pathlib.Path.home()
221288

222-
normalization_result = normalize_workspace_roots(
223-
raw_config,
224-
cwd=cwd,
225-
home=home,
226-
)
227-
raw_config, workspace_map, merge_conflicts, merge_changes = normalization_result
289+
if merge_duplicates:
290+
(
291+
raw_config,
292+
workspace_map,
293+
merge_conflicts,
294+
merge_changes,
295+
) = normalize_workspace_roots(
296+
raw_config,
297+
cwd=cwd,
298+
home=home,
299+
)
300+
else:
301+
(
302+
_,
303+
workspace_map,
304+
merge_conflicts,
305+
merge_changes,
306+
) = normalize_workspace_roots(
307+
raw_config,
308+
cwd=cwd,
309+
home=home,
310+
)
228311

229312
for message in merge_conflicts:
230313
log.warning(message)
@@ -350,7 +433,9 @@ def discover_repos(
350433
Style.RESET_ALL,
351434
)
352435

353-
changes_made = merge_changes > 0
436+
changes_made = merge_duplicates and (
437+
merge_changes > 0 or duplicate_merge_changes > 0
438+
)
354439

355440
if not repos_to_add:
356441
if existing_repos:

0 commit comments

Comments
 (0)