dotman-git 1.2.2__tar.gz → 1.3.0__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 (168) hide show
  1. {dotman_git-1.2.2 → dotman_git-1.3.0}/CHANGELOG.md +31 -2
  2. {dotman_git-1.2.2/dotman_git.egg-info → dotman_git-1.3.0}/PKG-INFO +7 -7
  3. {dotman_git-1.2.2 → dotman_git-1.3.0}/README.md +6 -6
  4. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/__init__.py +1 -1
  5. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/branch_ops.py +11 -2
  6. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/add_cmd.py +12 -1
  7. dotman_git-1.3.0/dot_man/cli/common.py +268 -0
  8. dotman_git-1.2.2/dot_man/cli/common.py → dotman_git-1.3.0/dot_man/cli/completions.py +17 -300
  9. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/doctor_cmd.py +4 -2
  10. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/navigate_cmd.py +135 -79
  11. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/remote_cmd.py +1 -1
  12. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/watch_cmd.py +14 -0
  13. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/global_config.py +49 -15
  14. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/interactive.py +42 -0
  15. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/save_deploy_ops.py +45 -3
  16. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/section.py +3 -0
  17. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/vault.py +43 -0
  18. {dotman_git-1.2.2 → dotman_git-1.3.0/dotman_git.egg-info}/PKG-INFO +7 -7
  19. {dotman_git-1.2.2 → dotman_git-1.3.0}/dotman_git.egg-info/SOURCES.txt +4 -0
  20. {dotman_git-1.2.2 → dotman_git-1.3.0}/pyproject.toml +1 -1
  21. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_add_cmd.py +65 -0
  22. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_branch_ops.py +1 -1
  23. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_completion.py +1 -1
  24. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_doctor_cmd.py +4 -2
  25. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_global_config.py +110 -0
  26. dotman_git-1.3.0/tests/test_navigate_helpers.py +197 -0
  27. dotman_git-1.3.0/tests/test_save_cmd.py +289 -0
  28. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_save_deploy_ops.py +152 -0
  29. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_shell_completions.py +2 -2
  30. dotman_git-1.3.0/tests/test_status_cmd.py +234 -0
  31. {dotman_git-1.2.2 → dotman_git-1.3.0}/CONTRIBUTING.md +0 -0
  32. {dotman_git-1.2.2 → dotman_git-1.3.0}/LICENSE +0 -0
  33. {dotman_git-1.2.2 → dotman_git-1.3.0}/MANIFEST.in +0 -0
  34. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/backups.py +0 -0
  35. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/__init__.py +0 -0
  36. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/audit_cmd.py +0 -0
  37. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/backup_cmd.py +0 -0
  38. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/branch_cmd.py +0 -0
  39. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/clean_cmd.py +0 -0
  40. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/completions_cmd.py +0 -0
  41. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/config_cmd.py +0 -0
  42. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/deploy_cmd.py +0 -0
  43. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/discover_cmd.py +0 -0
  44. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/edit_cmd.py +0 -0
  45. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/encrypt_cmd.py +0 -0
  46. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/export_cmd.py +0 -0
  47. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/import_cmd.py +0 -0
  48. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/init_cmd.py +0 -0
  49. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/interface.py +0 -0
  50. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/log_cmd.py +0 -0
  51. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/main.py +0 -0
  52. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/onboarding.py +0 -0
  53. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/profile_cmd.py +0 -0
  54. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/restore_cmd.py +0 -0
  55. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/revert_cmd.py +0 -0
  56. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/rollback_cmd.py +0 -0
  57. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/show_cmd.py +0 -0
  58. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/status_cmd.py +0 -0
  59. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/switch_cmd.py +0 -0
  60. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/tag_cmd.py +0 -0
  61. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/template_cmd.py +0 -0
  62. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/tui_cmd.py +0 -0
  63. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/cli/verify_cmd.py +0 -0
  64. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/completions/_dot-man.zsh +0 -0
  65. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/completions/dot-man.bash +0 -0
  66. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/completions/dot-man.fish +0 -0
  67. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/completions/install.sh +0 -0
  68. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/config.py +0 -0
  69. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/config_detector.py +0 -0
  70. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/constants.py +0 -0
  71. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/core.py +0 -0
  72. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/dotman_config.py +0 -0
  73. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/encryption.py +0 -0
  74. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/exceptions.py +0 -0
  75. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/files.py +0 -0
  76. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/hooks.py +0 -0
  77. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/lock.py +0 -0
  78. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/merge.py +0 -0
  79. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/operations.py +0 -0
  80. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/py.typed +0 -0
  81. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/secrets.py +0 -0
  82. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/status_ops.py +0 -0
  83. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/tui_log.py +0 -0
  84. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/ui.py +0 -0
  85. {dotman_git-1.2.2 → dotman_git-1.3.0}/dot_man/utils.py +0 -0
  86. {dotman_git-1.2.2 → dotman_git-1.3.0}/dotman_git.egg-info/dependency_links.txt +0 -0
  87. {dotman_git-1.2.2 → dotman_git-1.3.0}/dotman_git.egg-info/entry_points.txt +0 -0
  88. {dotman_git-1.2.2 → dotman_git-1.3.0}/dotman_git.egg-info/requires.txt +0 -0
  89. {dotman_git-1.2.2 → dotman_git-1.3.0}/dotman_git.egg-info/top_level.txt +0 -0
  90. {dotman_git-1.2.2 → dotman_git-1.3.0}/setup.cfg +0 -0
  91. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_audit_cmd.py +0 -0
  92. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_backup_cmd.py +0 -0
  93. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_backups.py +0 -0
  94. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_branch_cmd.py +0 -0
  95. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_clean.py +0 -0
  96. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_cli_commands.py +0 -0
  97. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_cli_extra.py +0 -0
  98. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_cli_help.py +0 -0
  99. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_cli_revert.py +0 -0
  100. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_cli_v1.py +0 -0
  101. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_common_v1.py +0 -0
  102. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_completions_cmd.py +0 -0
  103. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_comprehensive.py +0 -0
  104. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_config.py +0 -0
  105. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_config_cmd.py +0 -0
  106. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_config_cmd_v1.py +0 -0
  107. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_config_ops_v1.py +0 -0
  108. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_config_preservation.py +0 -0
  109. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_config_section.py +0 -0
  110. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_core.py +0 -0
  111. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_core_extended.py +0 -0
  112. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_core_v1.py +0 -0
  113. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_custom_secrets.py +0 -0
  114. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_deploy_cmd.py +0 -0
  115. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_discover_cmd.py +0 -0
  116. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_dotman_config.py +0 -0
  117. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_edit_cmd.py +0 -0
  118. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_encrypt_cmd.py +0 -0
  119. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_encryption.py +0 -0
  120. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_exceptions.py +0 -0
  121. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_export_cmd.py +0 -0
  122. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_files_atomic.py +0 -0
  123. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_files_comprehensive.py +0 -0
  124. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_files_extended.py +0 -0
  125. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_final_push.py +0 -0
  126. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_fixtures.py +0 -0
  127. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_git_manager.py +0 -0
  128. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_git_manager_extended.py +0 -0
  129. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_hooks.py +0 -0
  130. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_hooks_new.py +0 -0
  131. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_import_cmd.py +0 -0
  132. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_init_cmd.py +0 -0
  133. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_integration.py +0 -0
  134. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_interactive.py +0 -0
  135. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_lock.py +0 -0
  136. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_log_checkout_tag.py +0 -0
  137. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_log_cmd.py +0 -0
  138. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_log_cmd_v1.py +0 -0
  139. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_merge.py +0 -0
  140. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_more_integration.py +0 -0
  141. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_navigate_cmd.py +0 -0
  142. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_navigate_switch_v1.py +0 -0
  143. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_onboarding.py +0 -0
  144. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_operations.py +0 -0
  145. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_ops_v1.py +0 -0
  146. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_performance_logic.py +0 -0
  147. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_profile_cmd.py +0 -0
  148. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_profile_cmd_v1.py +0 -0
  149. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_profile_extra.py +0 -0
  150. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_remote_cmd.py +0 -0
  151. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_restore_cmd.py +0 -0
  152. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_rollback_cmd.py +0 -0
  153. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_secrets.py +0 -0
  154. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_section.py +0 -0
  155. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_show_cmd.py +0 -0
  156. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_status_ops.py +0 -0
  157. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_switch_cmd.py +0 -0
  158. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_switch_enhancements.py +0 -0
  159. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_symlink_deploy.py +0 -0
  160. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_tag_cmd.py +0 -0
  161. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_template_cmd.py +0 -0
  162. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_template_cmd_v1.py +0 -0
  163. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_template_extra.py +0 -0
  164. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_ui.py +0 -0
  165. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_utils.py +0 -0
  166. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_vault.py +0 -0
  167. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_verify_cmd.py +0 -0
  168. {dotman_git-1.2.2 → dotman_git-1.3.0}/tests/test_watch_cmd.py +0 -0
@@ -7,11 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [1.2.2] - 2026-06-10
10
+ ## [1.3.0] - 2026-06-27
11
+
12
+ ### Added
13
+
14
+ - **Template substitution in deployed files** — `{{VAR}}` placeholders in config files (e.g. `{{HOSTNAME}}`, `{{USER}}`, `{{OS}}`) are now rendered automatically during deploy. Enabled by default via `render_templates` on each section.
15
+ - **Conditional template syntax** — `{{ if OS == "darwin" }}...{{ endif }}` and `{{ if VAR != "value" }}...{{ endif }}` blocks in config files. Conditionals are evaluated before variable substitution. Uses stdlib regex, no new dependencies.
16
+ - **Vault key rotation** — `dot-man vault rotate-key` re-encrypts all secrets with a new Fernet key. Old key backed up to `.key.bak`. `dot-man vault status` shows vault stats.
17
+ - **Expanded `--dry-run` on navigate** — now shows all 4 phases: sections that would save, branch status, files that would deploy + hooks, config update.
18
+ - **62 new tests** — `test_save_cmd.py` (23), `test_status_cmd.py` (18), `test_navigate_helpers.py` (21), plus 14 template/conditional tests.
19
+ - **Shell completions split** — extracted completion functions into `completions.py` (310 lines), `common.py` reduced from 642 to 260 lines.
20
+ - **Priority improvement plan** — documented 20-item prioritized improvement roadmap in `docs/PRIORITY_PLAN.md`.
21
+
22
+ ### Fixed
23
+
24
+ - **`doctor_cmd.py:117`** — `.exists()` called on string instead of `Path`. Added `from pathlib import Path` and wrapped `DOT_MAN_TOML` in `Path()`.
25
+ - **`remote_cmd.py:225`** — `Path` passed where API expects `str`. Added `str()` conversion.
26
+ - **Stale coverage claims** — README and TODO updated from 83% to 81% (actual), test count from 1519 to 1526.
27
+ - **`test_doctor_cmd.py`** — fixed test to properly mock `DOT_MAN_DIR` instead of relying on bug-induced crash.
28
+ - **Import patch targets** — `test_completion.py` and `test_shell_completions.py` updated to patch `dot_man.cli.completions._git_runner`.
29
+
30
+ ### Refactored
31
+
32
+ - **Deduplicate hook execution in navigate** — extracted `_run_shell_hooks()` helper, replacing 3 identical ~25-line hook blocks.
33
+ - **Split `common.py`** — completion functions moved to `completions.py`, backward-compatible re-exports preserved.
34
+
35
+ ## [1.2.3] - 2026-06-11
36
+
37
+ ### Added
38
+
39
+ - **Interactive symlink handling** — `dot-man add`, `navigate`, and `watch` now detect symlinked config paths and prompt the user with three choices: follow the link (save target content), ignore (skip this path), or ignore all. Deploying also warns when overwriting a symlinked local path.
11
40
 
12
41
  ### Fixed
13
42
 
14
- - **Python 3.10 CI compatibility** — `dot_man/cli/__init__.py` no longer shadows module names with same-named function imports (`from .discover_cmd import discover_cmd` → `from . import discover_cmd`). On Python 3.10, `unittest.mock.patch` resolves dotted paths via attribute lookup, so the shadowing caused `AttributeError` when test patches tried to access module-level names like `ConfigDetector` on the Click command function instead of the module. (Affected `test_discover_cmd.py`, `test_encrypt_cmd.py`, `test_import_cmd.py`.)
43
+ - **Python 3.10 CI compatibility** — `dot_man/cli/__init__.py` no longer shadows module names with same-named function imports.
15
44
 
16
45
  ## [1.2.1] - 2026-06-10
17
46
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotman-git
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: Dotfile manager with git-powered branching
5
5
  Author-email: Bishoy Ehab <beshoyehabhhmm@gmail.com>
6
6
  License: MIT
@@ -69,7 +69,7 @@ Dynamic: license-file
69
69
  [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
70
70
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
71
71
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
72
- [![Coverage](https://img.shields.io/badge/coverage-60%25-green)](https://github.com/BeshoyEhab/dot-man)
72
+ [![Coverage](https://img.shields.io/badge/coverage-83%25-green)](https://github.com/BeshoyEhab/dot-man)
73
73
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/BeshoyEhab/dot-man/pulls)
74
74
 
75
75
  ---
@@ -480,10 +480,10 @@ System variables (`{{HOSTNAME}}`, `{{USER}}`, `{{SHELL}}`, etc.) are auto-popula
480
480
 
481
481
  | Metric | Value |
482
482
  |--------|-------|
483
- | Version | `1.1.1` |
484
- | Python | `3.9+` |
483
+ | Version | `1.2.3` |
484
+ | Python | `3.10 · 3.11 · 3.12 · 3.13` |
485
485
  | Platforms | Linux, macOS |
486
- | Test Coverage | 59% (869 tests) |
486
+ | Test Coverage | 81% (1526 tests) |
487
487
  | Commands | 30+ |
488
488
  | PyPI | [`dotman-git`](https://pypi.org/project/dotman-git/) |
489
489
 
@@ -512,9 +512,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) and [DEVELOPMENT.md](DEVELOPMENT.md) for
512
512
 
513
513
  ## Roadmap
514
514
 
515
- - [ ] 80%+ test coverage
515
+ - [x] 80%+ test coverage
516
516
  - [ ] Full documentation site (mkdocs)
517
- - [ ] Symlink mode
517
+ - [x] Symlink mode
518
518
  - [x] `dot-man watch` — auto-sync on file change
519
519
  - [x] Deploy rollback (transaction-style)
520
520
  - [ ] Plugin system
@@ -12,7 +12,7 @@
12
12
  [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
13
13
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
14
14
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
15
- [![Coverage](https://img.shields.io/badge/coverage-60%25-green)](https://github.com/BeshoyEhab/dot-man)
15
+ [![Coverage](https://img.shields.io/badge/coverage-83%25-green)](https://github.com/BeshoyEhab/dot-man)
16
16
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/BeshoyEhab/dot-man/pulls)
17
17
 
18
18
  ---
@@ -423,10 +423,10 @@ System variables (`{{HOSTNAME}}`, `{{USER}}`, `{{SHELL}}`, etc.) are auto-popula
423
423
 
424
424
  | Metric | Value |
425
425
  |--------|-------|
426
- | Version | `1.1.1` |
427
- | Python | `3.9+` |
426
+ | Version | `1.2.3` |
427
+ | Python | `3.10 · 3.11 · 3.12 · 3.13` |
428
428
  | Platforms | Linux, macOS |
429
- | Test Coverage | 59% (869 tests) |
429
+ | Test Coverage | 81% (1526 tests) |
430
430
  | Commands | 30+ |
431
431
  | PyPI | [`dotman-git`](https://pypi.org/project/dotman-git/) |
432
432
 
@@ -455,9 +455,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) and [DEVELOPMENT.md](DEVELOPMENT.md) for
455
455
 
456
456
  ## Roadmap
457
457
 
458
- - [ ] 80%+ test coverage
458
+ - [x] 80%+ test coverage
459
459
  - [ ] Full documentation site (mkdocs)
460
- - [ ] Symlink mode
460
+ - [x] Symlink mode
461
461
  - [x] `dot-man watch` — auto-sync on file change
462
462
  - [x] Deploy rollback (transaction-style)
463
463
  - [ ] Plugin system
@@ -1,4 +1,4 @@
1
1
  """dot-man: Dotfile manager with git-powered branching."""
2
2
 
3
- __version__ = "1.2.2"
3
+ __version__ = "1.3.0"
4
4
  __author__ = "Bishoy Ehab"
@@ -54,7 +54,9 @@ class BranchMixin:
54
54
 
55
55
  @abstractmethod
56
56
  def save_all(
57
- self, secret_handler: Optional[Callable[[SecretMatch], str]] = None
57
+ self,
58
+ secret_handler: Optional[Callable[[SecretMatch], str]] = None,
59
+ symlink_ignore: Optional[set[Path]] = None,
58
60
  ) -> dict[str, Any]: ...
59
61
 
60
62
  @property
@@ -93,10 +95,17 @@ class BranchMixin:
93
95
  target_branch: str,
94
96
  dry_run: bool = False,
95
97
  secret_handler: Optional[Callable[[SecretMatch], str]] = None,
98
+ symlink_ignore: Optional[set[Path]] = None,
96
99
  ) -> dict:
97
100
  """
98
101
  Switch to a different branch.
99
102
 
103
+ Args:
104
+ target_branch: Branch to switch to.
105
+ dry_run: If True, don't make changes.
106
+ secret_handler: Optional callback for secret handling.
107
+ symlink_ignore: Set of symlinked paths to skip when saving.
108
+
100
109
  Returns dict with:
101
110
  - saved_count: files saved from current branch
102
111
  - deployed_count: files deployed from target branch
@@ -127,7 +136,7 @@ class BranchMixin:
127
136
  with lock_context:
128
137
  # Phase 1: Save current branch
129
138
  if not dry_run:
130
- save_result = self.save_all(secret_handler)
139
+ save_result = self.save_all(secret_handler, symlink_ignore)
131
140
  result["saved_count"] = save_result["saved"]
132
141
  result["secrets_redacted"] = len(save_result["secrets"])
133
142
  result["errors"].extend(save_result["errors"])
@@ -70,7 +70,18 @@ def add(
70
70
  dot-man add ~/.config/hypr --inherits linux-gui --post-deploy "hyprctl reload"
71
71
  """
72
72
  try:
73
- local_path = Path(path).expanduser().resolve()
73
+ raw_path = Path(path).expanduser()
74
+
75
+ # Warn if path is a symlink
76
+ if raw_path.is_symlink():
77
+ from ..interactive import prompt_symlink_action
78
+
79
+ action = prompt_symlink_action(raw_path)
80
+ if action in ("ignore", "all_ignore"):
81
+ warn(f"Skipped symlink: {raw_path}")
82
+ return
83
+
84
+ local_path = raw_path.resolve()
74
85
 
75
86
  # Auto-generate section name if not provided
76
87
  if not section:
@@ -0,0 +1,268 @@
1
+ """Common utilities for dot-man CLI commands."""
2
+
3
+ import logging
4
+ import re
5
+ from functools import wraps
6
+ from typing import Callable
7
+
8
+ import click
9
+
10
+ from .. import ui
11
+ from ..constants import DOT_MAN_DIR, REPO_DIR
12
+ from ..core import GitManager
13
+ from ..secrets import PermanentRedactGuard, SecretGuard, SecretMatch
14
+ from .completions import ( # noqa: F401 — re-exports for backward compat
15
+ _clear_all_caches,
16
+ _clear_completion_cache,
17
+ _set_git_runner,
18
+ complete_branches,
19
+ complete_commits,
20
+ complete_config_keys,
21
+ complete_profiles,
22
+ complete_sections,
23
+ complete_switch_args,
24
+ complete_tags,
25
+ complete_template_keys,
26
+ )
27
+
28
+
29
+ def error(message: str, exit_code: int = 1) -> None:
30
+ """Print error message and exit."""
31
+ ui.error(message, exit_code)
32
+
33
+
34
+ def success(message: str) -> None:
35
+ """Print success message."""
36
+ ui.success(message)
37
+
38
+
39
+ def warn(message: str) -> None:
40
+ """Print warning message."""
41
+ ui.warn(message)
42
+
43
+
44
+ def handle_exception(exc: BaseException, context: str = "Operation") -> None:
45
+ """Handle exceptions with user-friendly diagnostics."""
46
+ from ..exceptions import DotManError, ErrorDiagnostic
47
+
48
+ if isinstance(exc, DotManError):
49
+ error(str(exc), exc.exit_code)
50
+ return
51
+
52
+ if isinstance(exc, KeyboardInterrupt):
53
+ ui.console.print()
54
+ warn("Operation cancelled by user")
55
+ raise SystemExit(130)
56
+
57
+ diagnostic = ErrorDiagnostic.from_exception(exc) # type: ignore[arg-type]
58
+ ui.console.print()
59
+ ui.console.print(f"[red bold]{diagnostic.title}[/red bold]")
60
+ ui.console.print(f"[red]{diagnostic.details}[/red]")
61
+ ui.console.print()
62
+ ui.console.print(f"[dim]💡 {diagnostic.suggestion}[/dim]")
63
+ raise SystemExit(1)
64
+
65
+
66
+ class AliasedCommand(click.Command):
67
+ """Custom Command class that supports aliases."""
68
+
69
+ def __init__(self, *args, aliases=None, **kwargs):
70
+ super().__init__(*args, **kwargs)
71
+ self._aliases = aliases or []
72
+
73
+ @property
74
+ def aliases(self):
75
+ return self._aliases
76
+
77
+
78
+ def command(name=None, cls=None, aliases=None, **kwargs):
79
+ """Decorator to create a command with optional aliases."""
80
+ if cls is None:
81
+ cls = AliasedCommand
82
+
83
+ def decorator(f):
84
+ return click.command(name=name, cls=cls, aliases=aliases, **kwargs)(f)
85
+
86
+ return decorator
87
+
88
+
89
+ class DotManGroup(click.Group):
90
+ """Custom Click Group to provide suggestions for typos."""
91
+
92
+ def add_command(self, command, name=None):
93
+ name = name or command.name
94
+ super().add_command(command, name)
95
+ if hasattr(command, "aliases") and command.aliases:
96
+ for alias in command.aliases:
97
+ super().add_command(command, alias)
98
+
99
+ def get_command(self, ctx, cmd_name):
100
+ rv = click.Group.get_command(self, ctx, cmd_name)
101
+ if rv is not None:
102
+ return rv
103
+
104
+ matches = [cmd for cmd in self.list_commands(ctx)]
105
+ suggestion = ui.suggest_command(cmd_name, matches)
106
+
107
+ ui.error(f"Unknown command '{cmd_name}'", exit_code=0)
108
+ if suggestion:
109
+ ui.warn(f"Did you mean '{suggestion}'?")
110
+
111
+ ctx.exit(2)
112
+
113
+
114
+ def require_init(func):
115
+ """Decorator to require initialization before running command."""
116
+
117
+ @wraps(func)
118
+ def wrapper(*args, **kwargs):
119
+ if not DOT_MAN_DIR.exists():
120
+ ui.console.print()
121
+ ui.print_banner("🎯 Welcome to dot-man!")
122
+ ui.console.print()
123
+ ui.console.print("[bold]The Dotfile Manager for Professionals[/bold]")
124
+ ui.console.print()
125
+ ui.console.print("[bold cyan]Coming from another tool?[/bold cyan]")
126
+ ui.console.print(
127
+ " [cyan]dot-man import chezmoi[/cyan] - Import from chezmoi"
128
+ )
129
+ ui.console.print(
130
+ " [cyan]dot-man import yadm[/cyan] - Import from yadm"
131
+ )
132
+ ui.console.print(
133
+ " [cyan]dot-man import stow[/cyan] - Import from GNU Stow"
134
+ )
135
+ ui.console.print(
136
+ " [cyan]dot-man import all[/cyan] - Auto-detect and import"
137
+ )
138
+ ui.console.print()
139
+ ui.console.print("[bold cyan]Get started:[/bold cyan]")
140
+ ui.console.print(
141
+ " [cyan]dot-man init[/cyan] - Initialize your dotfiles repository"
142
+ )
143
+ ui.console.print(
144
+ " [cyan]dot-man init --help[/cyan] - See all init options"
145
+ )
146
+ ui.console.print()
147
+ ui.console.print("[bold cyan]Quick overview:[/bold cyan]")
148
+ ui.console.print(
149
+ " [cyan]dot-man add <path>[/cyan] - Add files to track"
150
+ )
151
+ ui.console.print(
152
+ " [cyan]dot-man status[/cyan] - View tracked files"
153
+ )
154
+ ui.console.print(
155
+ " [cyan]dot-man navigate <branch>[/cyan] - Switch between configurations"
156
+ )
157
+ ui.console.print(
158
+ " [cyan]dot-man --help[/cyan] - See all commands"
159
+ )
160
+ ui.console.print()
161
+ ui.console.print("[dim]💡 Run 'dot-man init' to get started![/dim]")
162
+ ui.console.print()
163
+ raise SystemExit(1)
164
+
165
+ if not REPO_DIR.exists() or not (REPO_DIR / ".git").exists():
166
+ error("Repository not initialized. Run 'dot-man init' first.", exit_code=1)
167
+
168
+ return func(*args, **kwargs)
169
+
170
+ return wrapper
171
+
172
+
173
+ def parse_branch_arg(arg: str) -> dict:
174
+ """Parse branch argument with @tag or commit SHA support."""
175
+ match = re.match(r"^(.+)@(.+)$", arg)
176
+ if match:
177
+ base = match.group(1)
178
+ target = match.group(2)
179
+
180
+ if not base:
181
+ base = "HEAD"
182
+
183
+ if re.match(r"^[a-f0-9]{7,40}$", target):
184
+ return {"type": "commit", "base": base, "target": target}
185
+
186
+ return {"type": "tag", "base": base, "target": target}
187
+
188
+ if re.match(r"^[a-f0-9]{7,40}$", arg):
189
+ return {"type": "commit", "base": "HEAD", "target": arg}
190
+
191
+ try:
192
+ git = GitManager()
193
+ if arg in git.list_tags():
194
+ return {"type": "tag", "base": "HEAD", "target": arg}
195
+ except Exception as e:
196
+ logging.debug(f"Could not check tags: {e}")
197
+
198
+ return {"type": "branch", "base": arg, "target": arg}
199
+
200
+
201
+ class BranchParamType(click.ParamType):
202
+ """Parameter type that accepts branch, branch@tag, or commit SHA."""
203
+
204
+ name = "branch"
205
+
206
+ def convert(self, value, param, ctx):
207
+ if not value:
208
+ return None
209
+ return parse_branch_arg(value)
210
+
211
+
212
+ BRANCH = BranchParamType()
213
+
214
+
215
+ def get_secret_handler() -> Callable[[SecretMatch], str]:
216
+ """Get a secret handler that prompts the user for action."""
217
+ guard = SecretGuard()
218
+ permanent_guard = PermanentRedactGuard()
219
+
220
+ def handle_secret(match: SecretMatch) -> str:
221
+ if permanent_guard.should_redact(
222
+ match.file, match.line_content, match.pattern_name
223
+ ):
224
+ return "REDACT"
225
+
226
+ if guard.is_allowed(match.file, match.line_content, match.pattern_name):
227
+ return "IGNORE"
228
+
229
+ ui.console.print()
230
+ ui.warn("Potential secret detected!")
231
+ ui.console.print(f"File: [cyan]{match.file}[/cyan]")
232
+ ui.console.print(f"Line {match.line_number}: {match.line_content[:80]}...")
233
+ ui.console.print(
234
+ f"Pattern: {match.pattern_name} (severity: {match.severity.value})"
235
+ )
236
+ ui.console.print()
237
+
238
+ ui.console.print("Choose how to handle this secret:")
239
+ ui.console.print(" 1. [dim]Ignore (skip it this time)[/dim]")
240
+ ui.console.print(
241
+ " 2. [yellow]Protect (replace with ***REDACTED*** this time)[/yellow]"
242
+ )
243
+ ui.console.print(
244
+ " 3. [blue]Add to skip list (skip this line every time)[/blue]"
245
+ )
246
+ ui.console.print(" 4. [red]Protect forever (always replace in repo)[/red]")
247
+ ui.console.print()
248
+
249
+ choices = ["1", "2", "3", "4"]
250
+ choice = ui.ask("Enter choice", choices=choices, default="2")
251
+
252
+ if choice == "1":
253
+ return "IGNORE"
254
+ elif choice == "2":
255
+ return "REDACT"
256
+ elif choice == "3":
257
+ guard.add_allowed(match.file, match.line_content, match.pattern_name)
258
+ ui.info("Added to skip list.")
259
+ return "IGNORE"
260
+ elif choice == "4":
261
+ permanent_guard.add_permanent_redact(
262
+ match.file, match.line_content, match.pattern_name
263
+ )
264
+ ui.warn("Will always redact this secret.")
265
+ return "REDACT"
266
+ return "REDACT"
267
+
268
+ return handle_secret