@@ -382,6 +382,99 @@ def _save_ordered_items(
382382 save_config_yaml_with_items (config_file_path , items )
383383
384384
385+ def _save_config_or_log_error (
386+ save_fn : t .Callable [[], None ],
387+ * ,
388+ config_file_path : pathlib .Path ,
389+ ) -> bool :
390+ """Execute a config-save callable with standardised error handling.
391+
392+ Wraps the save call in a try/except block, logging an exception message on
393+ failure and optionally printing the traceback when DEBUG logging is active.
394+ The success message is left to the caller, since each save site phrases it
395+ differently (e.g. "added" vs "label adjustments saved").
396+
397+ Parameters
398+ ----------
399+ save_fn : Callable[[], None]
400+ Zero-argument callable that performs the actual file write (e.g. a
401+ lambda wrapping ``save_config`` or ``_save_ordered_items``).
402+ config_file_path : pathlib.Path
403+ Path to the config file, used only for error-log messages.
404+
405+ Returns
406+ -------
407+ bool
408+ ``True`` if the save succeeded, ``False`` otherwise.
409+
410+ Examples
411+ --------
412+ >>> import pathlib
413+ >>> p = pathlib.Path("/tmp/x.yaml")
414+ >>> def _ok_save() -> None:
415+ ... pass
416+ >>> _save_config_or_log_error(_ok_save, config_file_path=p)
417+ True
418+
419+ >>> def _bad_save() -> None:
420+ ... raise RuntimeError("disk full")
421+ >>> _save_config_or_log_error(_bad_save, config_file_path=p)
422+ False
423+ """
424+ try :
425+ save_fn ()
426+ except Exception :
427+ log .exception (
428+ "Error saving config to %s" ,
429+ PrivatePath (config_file_path ),
430+ )
431+ if log .isEnabledFor (logging .DEBUG ):
432+ traceback .print_exc ()
433+ return False
434+ return True
435+
436+
437+ def _extract_current_url (existing_config : t .Any ) -> str :
438+ """Derive a display URL from an existing repository config entry.
439+
440+ Parameters
441+ ----------
442+ existing_config : Any
443+ The value stored in the workspace section for a given repo name.
444+ May be a plain URL string, a dict with ``repo`` / ``url`` keys,
445+ or any other object.
446+
447+ Returns
448+ -------
449+ str
450+ A human-readable URL string suitable for log messages.
451+
452+ Examples
453+ --------
454+ >>> _extract_current_url("git+https://github.com/user/repo.git")
455+ 'git+https://github.com/user/repo.git'
456+
457+ >>> _extract_current_url({"repo": "git+https://github.com/user/repo.git"})
458+ 'git+https://github.com/user/repo.git'
459+
460+ >>> _extract_current_url({"url": "https://example.com/repo.git"})
461+ 'https://example.com/repo.git'
462+
463+ >>> _extract_current_url({"repo": None, "url": None})
464+ 'unknown'
465+
466+ >>> _extract_current_url(42)
467+ '42'
468+ """
469+ if isinstance (existing_config , str ):
470+ return existing_config
471+ if isinstance (existing_config , dict ):
472+ repo_value = existing_config .get ("repo" )
473+ url_value = existing_config .get ("url" )
474+ return repo_value or url_value or "unknown"
475+ return str (existing_config )
476+
477+
385478def handle_add_command (args : argparse .Namespace ) -> None :
386479 """Entry point for the ``vcspull add`` CLI command."""
387480 repo_input = getattr (args , "repo_path" , None )
@@ -790,8 +883,10 @@ def _prepare_no_merge_items(
790883 f" ({ reason } )" if reason else "" ,
791884 )
792885 if (duplicate_merge_changes > 0 or config_was_relabelled ) and not dry_run :
793- try :
794- save_config (config_file_path , raw_config )
886+ if _save_config_or_log_error (
887+ lambda : save_config (config_file_path , raw_config ),
888+ config_file_path = config_file_path ,
889+ ):
795890 log .info (
796891 "%s✓%s Workspace label adjustments saved to %s%s%s." ,
797892 Fore .GREEN ,
@@ -800,13 +895,6 @@ def _prepare_no_merge_items(
800895 display_config_path ,
801896 Style .RESET_ALL ,
802897 )
803- except Exception :
804- log .exception (
805- "Error saving config to %s" ,
806- PrivatePath (config_file_path ),
807- )
808- if log .isEnabledFor (logging .DEBUG ):
809- traceback .print_exc ()
810898 elif (duplicate_merge_changes > 0 or config_was_relabelled ) and dry_run :
811899 log .info (
812900 "%s→%s Would save workspace label adjustments to %s%s%s." ,
@@ -818,14 +906,7 @@ def _prepare_no_merge_items(
818906 )
819907 return
820908 elif add_action == AddAction .SKIP_EXISTING :
821- if isinstance (existing_config , str ):
822- current_url = existing_config
823- elif isinstance (existing_config , dict ):
824- repo_value = existing_config .get ("repo" )
825- url_value = existing_config .get ("url" )
826- current_url = repo_value or url_value or "unknown"
827- else :
828- current_url = str (existing_config )
909+ current_url = _extract_current_url (existing_config )
829910
830911 log .warning (
831912 "Repository '%s' already exists under '%s'. Current URL: %s. "
@@ -836,8 +917,10 @@ def _prepare_no_merge_items(
836917 )
837918
838919 if (duplicate_merge_changes > 0 or config_was_relabelled ) and not dry_run :
839- try :
840- save_config (config_file_path , raw_config )
920+ if _save_config_or_log_error (
921+ lambda : save_config (config_file_path , raw_config ),
922+ config_file_path = config_file_path ,
923+ ):
841924 log .info (
842925 "%s✓%s Workspace label adjustments saved to %s%s%s." ,
843926 Fore .GREEN ,
@@ -846,13 +929,6 @@ def _prepare_no_merge_items(
846929 display_config_path ,
847930 Style .RESET_ALL ,
848931 )
849- except Exception :
850- log .exception (
851- "Error saving config to %s" ,
852- PrivatePath (config_file_path ),
853- )
854- if log .isEnabledFor (logging .DEBUG ):
855- traceback .print_exc ()
856932 elif (duplicate_merge_changes > 0 or config_was_relabelled ) and dry_run :
857933 log .info (
858934 "%s→%s Would save workspace label adjustments to %s%s%s." ,
@@ -886,8 +962,10 @@ def _prepare_no_merge_items(
886962 )
887963 return
888964
889- try :
890- save_config (config_file_path , raw_config )
965+ if _save_config_or_log_error (
966+ lambda : save_config (config_file_path , raw_config ),
967+ config_file_path = config_file_path ,
968+ ):
891969 log .info (
892970 "%s✓%s Successfully added %s'%s'%s (%s%s%s) to %s%s%s under '%s%s%s'." ,
893971 Fore .GREEN ,
@@ -905,13 +983,6 @@ def _prepare_no_merge_items(
905983 workspace_label ,
906984 Style .RESET_ALL ,
907985 )
908- except Exception :
909- log .exception (
910- "Error saving config to %s" ,
911- PrivatePath (config_file_path ),
912- )
913- if log .isEnabledFor (logging .DEBUG ):
914- traceback .print_exc ()
915986 return
916987
917988 ordered_items = _build_ordered_items (top_level_items , raw_config )
@@ -970,8 +1041,10 @@ def _prepare_no_merge_items(
9701041 Style .RESET_ALL ,
9711042 )
9721043 else :
973- try :
974- _save_ordered_items (config_file_path , ordered_items )
1044+ if _save_config_or_log_error (
1045+ lambda : _save_ordered_items (config_file_path , ordered_items ),
1046+ config_file_path = config_file_path ,
1047+ ):
9751048 log .info (
9761049 "%s✓%s Workspace label adjustments saved to %s%s%s." ,
9771050 Fore .GREEN ,
@@ -980,23 +1053,9 @@ def _prepare_no_merge_items(
9801053 display_config_path ,
9811054 Style .RESET_ALL ,
9821055 )
983- except Exception :
984- log .exception (
985- "Error saving config to %s" ,
986- PrivatePath (config_file_path ),
987- )
988- if log .isEnabledFor (logging .DEBUG ):
989- traceback .print_exc ()
9901056 return
9911057 elif no_merge_add_action == AddAction .SKIP_EXISTING :
992- if isinstance (existing_config , str ):
993- current_url = existing_config
994- elif isinstance (existing_config , dict ):
995- repo_value = existing_config .get ("repo" )
996- url_value = existing_config .get ("url" )
997- current_url = repo_value or url_value or "unknown"
998- else :
999- current_url = str (existing_config )
1058+ current_url = _extract_current_url (existing_config )
10001059
10011060 log .warning (
10021061 "Repository '%s' already exists under '%s'. Current URL: %s. "
@@ -1017,8 +1076,10 @@ def _prepare_no_merge_items(
10171076 Style .RESET_ALL ,
10181077 )
10191078 else :
1020- try :
1021- _save_ordered_items (config_file_path , ordered_items )
1079+ if _save_config_or_log_error (
1080+ lambda : _save_ordered_items (config_file_path , ordered_items ),
1081+ config_file_path = config_file_path ,
1082+ ):
10221083 log .info (
10231084 "%s✓%s Workspace label adjustments saved to %s%s%s." ,
10241085 Fore .GREEN ,
@@ -1027,13 +1088,6 @@ def _prepare_no_merge_items(
10271088 display_config_path ,
10281089 Style .RESET_ALL ,
10291090 )
1030- except Exception :
1031- log .exception (
1032- "Error saving config to %s" ,
1033- PrivatePath (config_file_path ),
1034- )
1035- if log .isEnabledFor (logging .DEBUG ):
1036- traceback .print_exc ()
10371091 return
10381092
10391093 target_section = ordered_items [target_index ]["section" ]
@@ -1067,8 +1121,10 @@ def _prepare_no_merge_items(
10671121 )
10681122 return
10691123
1070- try :
1071- _save_ordered_items (config_file_path , ordered_items )
1124+ if _save_config_or_log_error (
1125+ lambda : _save_ordered_items (config_file_path , ordered_items ),
1126+ config_file_path = config_file_path ,
1127+ ):
10721128 log .info (
10731129 "%s✓%s Successfully added %s'%s'%s (%s%s%s) to %s%s%s under '%s%s%s'." ,
10741130 Fore .GREEN ,
@@ -1086,10 +1142,3 @@ def _prepare_no_merge_items(
10861142 workspace_label ,
10871143 Style .RESET_ALL ,
10881144 )
1089- except Exception :
1090- log .exception (
1091- "Error saving config to %s" ,
1092- PrivatePath (config_file_path ),
1093- )
1094- if log .isEnabledFor (logging .DEBUG ):
1095- traceback .print_exc ()
0 commit comments