dotman-git 1.2.2__tar.gz → 1.2.3__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 (164) hide show
  1. {dotman_git-1.2.2 → dotman_git-1.2.3}/CHANGELOG.md +6 -2
  2. {dotman_git-1.2.2/dotman_git.egg-info → dotman_git-1.2.3}/PKG-INFO +7 -7
  3. {dotman_git-1.2.2 → dotman_git-1.2.3}/README.md +6 -6
  4. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/__init__.py +1 -1
  5. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/branch_ops.py +11 -2
  6. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/add_cmd.py +12 -1
  7. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/navigate_cmd.py +37 -3
  8. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/watch_cmd.py +14 -0
  9. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/interactive.py +42 -0
  10. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/save_deploy_ops.py +23 -3
  11. {dotman_git-1.2.2 → dotman_git-1.2.3/dotman_git.egg-info}/PKG-INFO +7 -7
  12. {dotman_git-1.2.2 → dotman_git-1.2.3}/pyproject.toml +1 -1
  13. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_add_cmd.py +65 -0
  14. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_branch_ops.py +1 -1
  15. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_save_deploy_ops.py +152 -0
  16. {dotman_git-1.2.2 → dotman_git-1.2.3}/CONTRIBUTING.md +0 -0
  17. {dotman_git-1.2.2 → dotman_git-1.2.3}/LICENSE +0 -0
  18. {dotman_git-1.2.2 → dotman_git-1.2.3}/MANIFEST.in +0 -0
  19. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/backups.py +0 -0
  20. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/__init__.py +0 -0
  21. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/audit_cmd.py +0 -0
  22. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/backup_cmd.py +0 -0
  23. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/branch_cmd.py +0 -0
  24. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/clean_cmd.py +0 -0
  25. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/common.py +0 -0
  26. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/completions_cmd.py +0 -0
  27. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/config_cmd.py +0 -0
  28. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/deploy_cmd.py +0 -0
  29. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/discover_cmd.py +0 -0
  30. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/doctor_cmd.py +0 -0
  31. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/edit_cmd.py +0 -0
  32. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/encrypt_cmd.py +0 -0
  33. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/export_cmd.py +0 -0
  34. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/import_cmd.py +0 -0
  35. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/init_cmd.py +0 -0
  36. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/interface.py +0 -0
  37. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/log_cmd.py +0 -0
  38. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/main.py +0 -0
  39. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/onboarding.py +0 -0
  40. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/profile_cmd.py +0 -0
  41. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/remote_cmd.py +0 -0
  42. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/restore_cmd.py +0 -0
  43. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/revert_cmd.py +0 -0
  44. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/rollback_cmd.py +0 -0
  45. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/show_cmd.py +0 -0
  46. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/status_cmd.py +0 -0
  47. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/switch_cmd.py +0 -0
  48. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/tag_cmd.py +0 -0
  49. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/template_cmd.py +0 -0
  50. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/tui_cmd.py +0 -0
  51. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/cli/verify_cmd.py +0 -0
  52. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/completions/_dot-man.zsh +0 -0
  53. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/completions/dot-man.bash +0 -0
  54. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/completions/dot-man.fish +0 -0
  55. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/completions/install.sh +0 -0
  56. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/config.py +0 -0
  57. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/config_detector.py +0 -0
  58. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/constants.py +0 -0
  59. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/core.py +0 -0
  60. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/dotman_config.py +0 -0
  61. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/encryption.py +0 -0
  62. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/exceptions.py +0 -0
  63. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/files.py +0 -0
  64. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/global_config.py +0 -0
  65. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/hooks.py +0 -0
  66. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/lock.py +0 -0
  67. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/merge.py +0 -0
  68. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/operations.py +0 -0
  69. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/py.typed +0 -0
  70. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/secrets.py +0 -0
  71. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/section.py +0 -0
  72. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/status_ops.py +0 -0
  73. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/tui_log.py +0 -0
  74. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/ui.py +0 -0
  75. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/utils.py +0 -0
  76. {dotman_git-1.2.2 → dotman_git-1.2.3}/dot_man/vault.py +0 -0
  77. {dotman_git-1.2.2 → dotman_git-1.2.3}/dotman_git.egg-info/SOURCES.txt +0 -0
  78. {dotman_git-1.2.2 → dotman_git-1.2.3}/dotman_git.egg-info/dependency_links.txt +0 -0
  79. {dotman_git-1.2.2 → dotman_git-1.2.3}/dotman_git.egg-info/entry_points.txt +0 -0
  80. {dotman_git-1.2.2 → dotman_git-1.2.3}/dotman_git.egg-info/requires.txt +0 -0
  81. {dotman_git-1.2.2 → dotman_git-1.2.3}/dotman_git.egg-info/top_level.txt +0 -0
  82. {dotman_git-1.2.2 → dotman_git-1.2.3}/setup.cfg +0 -0
  83. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_audit_cmd.py +0 -0
  84. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_backup_cmd.py +0 -0
  85. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_backups.py +0 -0
  86. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_branch_cmd.py +0 -0
  87. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_clean.py +0 -0
  88. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_cli_commands.py +0 -0
  89. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_cli_extra.py +0 -0
  90. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_cli_help.py +0 -0
  91. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_cli_revert.py +0 -0
  92. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_cli_v1.py +0 -0
  93. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_common_v1.py +0 -0
  94. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_completion.py +0 -0
  95. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_completions_cmd.py +0 -0
  96. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_comprehensive.py +0 -0
  97. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_config.py +0 -0
  98. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_config_cmd.py +0 -0
  99. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_config_cmd_v1.py +0 -0
  100. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_config_ops_v1.py +0 -0
  101. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_config_preservation.py +0 -0
  102. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_config_section.py +0 -0
  103. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_core.py +0 -0
  104. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_core_extended.py +0 -0
  105. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_core_v1.py +0 -0
  106. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_custom_secrets.py +0 -0
  107. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_deploy_cmd.py +0 -0
  108. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_discover_cmd.py +0 -0
  109. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_doctor_cmd.py +0 -0
  110. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_dotman_config.py +0 -0
  111. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_edit_cmd.py +0 -0
  112. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_encrypt_cmd.py +0 -0
  113. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_encryption.py +0 -0
  114. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_exceptions.py +0 -0
  115. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_export_cmd.py +0 -0
  116. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_files_atomic.py +0 -0
  117. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_files_comprehensive.py +0 -0
  118. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_files_extended.py +0 -0
  119. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_final_push.py +0 -0
  120. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_fixtures.py +0 -0
  121. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_git_manager.py +0 -0
  122. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_git_manager_extended.py +0 -0
  123. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_global_config.py +0 -0
  124. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_hooks.py +0 -0
  125. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_hooks_new.py +0 -0
  126. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_import_cmd.py +0 -0
  127. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_init_cmd.py +0 -0
  128. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_integration.py +0 -0
  129. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_interactive.py +0 -0
  130. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_lock.py +0 -0
  131. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_log_checkout_tag.py +0 -0
  132. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_log_cmd.py +0 -0
  133. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_log_cmd_v1.py +0 -0
  134. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_merge.py +0 -0
  135. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_more_integration.py +0 -0
  136. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_navigate_cmd.py +0 -0
  137. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_navigate_switch_v1.py +0 -0
  138. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_onboarding.py +0 -0
  139. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_operations.py +0 -0
  140. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_ops_v1.py +0 -0
  141. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_performance_logic.py +0 -0
  142. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_profile_cmd.py +0 -0
  143. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_profile_cmd_v1.py +0 -0
  144. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_profile_extra.py +0 -0
  145. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_remote_cmd.py +0 -0
  146. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_restore_cmd.py +0 -0
  147. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_rollback_cmd.py +0 -0
  148. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_secrets.py +0 -0
  149. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_section.py +0 -0
  150. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_shell_completions.py +0 -0
  151. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_show_cmd.py +0 -0
  152. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_status_ops.py +0 -0
  153. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_switch_cmd.py +0 -0
  154. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_switch_enhancements.py +0 -0
  155. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_symlink_deploy.py +0 -0
  156. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_tag_cmd.py +0 -0
  157. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_template_cmd.py +0 -0
  158. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_template_cmd_v1.py +0 -0
  159. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_template_extra.py +0 -0
  160. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_ui.py +0 -0
  161. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_utils.py +0 -0
  162. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_vault.py +0 -0
  163. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_verify_cmd.py +0 -0
  164. {dotman_git-1.2.2 → dotman_git-1.2.3}/tests/test_watch_cmd.py +0 -0
@@ -7,11 +7,15 @@ 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.2.3] - 2026-06-11
11
+
12
+ ### Added
13
+
14
+ - **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
15
 
12
16
  ### Fixed
13
17
 
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`.)
18
+ - **Python 3.10 CI compatibility** — `dot_man/cli/__init__.py` no longer shadows module names with same-named function imports.
15
19
 
16
20
  ## [1.2.1] - 2026-06-10
17
21
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotman-git
3
- Version: 1.2.2
3
+ Version: 1.2.3
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 | 83% (1519 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 | 83% (1519 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.2.3"
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:
@@ -128,6 +128,37 @@ def _warn_symlinks(save_result: dict) -> None:
128
128
  )
129
129
 
130
130
 
131
+ def _prompt_for_symlinks(ops) -> set[Path]:
132
+ """Scan sections for symlinks and prompt user how to handle each.
133
+
134
+ Returns a set of symlinked paths the user chose to ignore.
135
+ """
136
+ from ..interactive import prompt_symlink_action
137
+
138
+ symlink_ignore: set[Path] = set()
139
+ ignore_all = False
140
+
141
+ for section_name in ops.get_sections():
142
+ section = ops.get_section(section_name)
143
+ for path in section.paths:
144
+ if not path.is_symlink():
145
+ continue
146
+ if ignore_all:
147
+ symlink_ignore.add(path)
148
+ continue
149
+ if path in symlink_ignore:
150
+ continue
151
+ action = prompt_symlink_action(path)
152
+ if action == "ignore":
153
+ symlink_ignore.add(path)
154
+ elif action == "all_ignore":
155
+ symlink_ignore.add(path)
156
+ ignore_all = True
157
+ # "follow" — no-op, proceed with default behavior
158
+
159
+ return symlink_ignore
160
+
161
+
131
162
  def run_branch_hooks(ops, hook_type: str) -> None:
132
163
  """Run on_activate or on_deactivate hooks from sections.
133
164
 
@@ -527,7 +558,8 @@ def _handle_commit_navigate(
527
558
  if save_mode == "save":
528
559
  ui.console.print(f"[bold]Saving current branch '{current_branch}'...[/bold]")
529
560
  secret_handler = get_secret_handler()
530
- save_result = ops.save_all(secret_handler)
561
+ symlink_ignore = _prompt_for_symlinks(ops)
562
+ save_result = ops.save_all(secret_handler, symlink_ignore=symlink_ignore)
531
563
  _warn_symlinks(save_result)
532
564
  saved_count = save_result["saved"]
533
565
  sections = get_changed_sections(ops)
@@ -592,7 +624,8 @@ def _handle_tag_navigate(
592
624
  if save_mode == "save":
593
625
  ui.console.print(f"[bold]Saving current branch '{current_branch}'...[/bold]")
594
626
  secret_handler = get_secret_handler()
595
- save_result = ops.save_all(secret_handler)
627
+ symlink_ignore = _prompt_for_symlinks(ops)
628
+ save_result = ops.save_all(secret_handler, symlink_ignore=symlink_ignore)
596
629
  _warn_symlinks(save_result)
597
630
  saved_count = save_result["saved"]
598
631
  sections = get_changed_sections(ops)
@@ -670,7 +703,8 @@ def _handle_branch_navigate(
670
703
 
671
704
  if save_mode == "save":
672
705
  secret_handler = get_secret_handler()
673
- save_result = ops.save_all(secret_handler)
706
+ symlink_ignore = _prompt_for_symlinks(ops)
707
+ save_result = ops.save_all(secret_handler, symlink_ignore=symlink_ignore)
674
708
  _warn_symlinks(save_result)
675
709
  saved_count = save_result["saved"]
676
710
  secrets = save_result["secrets"]
@@ -212,6 +212,20 @@ def watch(interval: float, commit: bool, message: str, dry_run: bool):
212
212
  warn("No paths to watch.")
213
213
  return
214
214
 
215
+ # Scan for symlinks
216
+ symlink_paths = [p for p in tracked_paths if p.is_symlink()]
217
+ if symlink_paths:
218
+ ui.console.print()
219
+ for sym_path in symlink_paths:
220
+ target = sym_path.resolve()
221
+ ui.console.print(
222
+ f" [yellow]⚠ {sym_path} is a symlink → {target}[/yellow]"
223
+ )
224
+ ui.console.print(
225
+ " [dim] Edits affect the symlink target, not the dot-man repo.[/dim]"
226
+ )
227
+ ui.console.print()
228
+
215
229
  backend = "watchdog" if _WATCHDOG_AVAILABLE else "polling"
216
230
  ui.console.print(
217
231
  f"[bold]👁 dot-man watch[/bold] [dim]({backend} backend)[/dim]"
@@ -5,9 +5,11 @@ __all__ = [
5
5
  "run_global_wizard",
6
6
  "run_templates_wizard",
7
7
  "edit_template",
8
+ "prompt_symlink_action",
8
9
  ]
9
10
 
10
11
  from pathlib import Path
12
+ from typing import cast
11
13
 
12
14
  import questionary
13
15
  from questionary import Style, ValidationError, Validator
@@ -458,3 +460,43 @@ def edit_template(config: DotManConfig, name: str):
458
460
  ).ask()
459
461
  if val:
460
462
  template["update_strategy"] = val
463
+
464
+
465
+ def prompt_symlink_action(path: Path) -> str:
466
+ """Ask the user how to handle a symlinked config path.
467
+
468
+ Args:
469
+ path: The symlinked path.
470
+
471
+ Returns:
472
+ "follow" to save the symlink target content,
473
+ "ignore" to skip this path,
474
+ "all_ignore" to skip this and all remaining symlinks.
475
+ """
476
+ target = path.resolve()
477
+ warn(
478
+ f"{path} is a symlink to {target}. "
479
+ "Changes to the repo may not affect the original file."
480
+ )
481
+ choice = questionary.select(
482
+ "How would you like to handle this symlink?",
483
+ choices=[
484
+ questionary.Choice(
485
+ title="Follow the link — save the target file's content into the repo",
486
+ value="follow",
487
+ ),
488
+ questionary.Choice(
489
+ title="Ignore — don't track this path",
490
+ value="ignore",
491
+ ),
492
+ questionary.Choice(
493
+ title="Ignore all — skip all symlinked paths in this operation",
494
+ value="all_ignore",
495
+ ),
496
+ ],
497
+ default="follow",
498
+ style=custom_style,
499
+ ).ask()
500
+ if choice is None:
501
+ return "follow"
502
+ return cast(str, choice)
@@ -216,10 +216,16 @@ class SaveDeployMixin:
216
216
  self,
217
217
  section: Section,
218
218
  secret_handler: Optional[Callable[[SecretMatch], str]] = None,
219
+ symlink_ignore: Optional[set[Path]] = None,
219
220
  ) -> tuple[int, list[SecretMatch], list[str], list[Path]]:
220
221
  """
221
222
  Save a section from local to repo.
222
223
 
224
+ Args:
225
+ section: Section to save.
226
+ secret_handler: Optional callback for secret handling.
227
+ symlink_ignore: Set of symlinked paths to skip (ignore).
228
+
223
229
  Returns:
224
230
  (files_saved, secrets_detected, errors, symlink_paths)
225
231
  """
@@ -258,13 +264,17 @@ class SaveDeployMixin:
258
264
  # Merge exclude patterns with ignored directories
259
265
  final_excludes = self._build_final_excludes(section)
260
266
 
267
+ symlink_ignore = symlink_ignore or set()
268
+
261
269
  for local_path in section.paths:
262
270
  if not local_path.exists():
263
271
  continue
264
272
 
265
- # Warn if path is a symlink (real content lives elsewhere)
273
+ # If path is a symlink, check if user chose to ignore it
266
274
  if local_path.is_symlink():
267
275
  symlink_paths.append(local_path)
276
+ if local_path in symlink_ignore:
277
+ continue
268
278
 
269
279
  repo_path = section.get_repo_path(local_path, REPO_DIR)
270
280
 
@@ -316,6 +326,12 @@ class SaveDeployMixin:
316
326
  for local_path in section.paths:
317
327
  repo_path = section.get_repo_path(local_path, REPO_DIR)
318
328
 
329
+ if local_path.is_symlink():
330
+ errors.append(
331
+ f"⚠ {local_path} is a symlink → {local_path.resolve()}. "
332
+ f"Deploy will overwrite it with a regular file."
333
+ )
334
+
319
335
  if section.update_strategy == "ignore" and local_path.exists():
320
336
  continue
321
337
 
@@ -431,7 +447,9 @@ class SaveDeployMixin:
431
447
  }
432
448
 
433
449
  def save_all(
434
- self, secret_handler: Optional[Callable[[SecretMatch], str]] = None
450
+ self,
451
+ secret_handler: Optional[Callable[[SecretMatch], str]] = None,
452
+ symlink_ignore: Optional[set[Path]] = None,
435
453
  ) -> dict:
436
454
  """
437
455
  Save all sections from local to repo.
@@ -447,7 +465,9 @@ class SaveDeployMixin:
447
465
 
448
466
  with self.vault.batch(), ThreadPoolExecutor() as executor:
449
467
  future_to_section = {
450
- executor.submit(self.save_section, section, secret_handler): section
468
+ executor.submit(
469
+ self.save_section, section, secret_handler, symlink_ignore
470
+ ): section
451
471
  for section in sections
452
472
  }
453
473
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotman-git
3
- Version: 1.2.2
3
+ Version: 1.2.3
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 | 83% (1519 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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dotman-git"
3
- version = "1.2.2"
3
+ version = "1.2.3"
4
4
  description = "Dotfile manager with git-powered branching"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -94,6 +94,71 @@ class TestAddWithInit:
94
94
 
95
95
  assert result.exit_code in [0, 1, 7]
96
96
 
97
+ def test_add_symlink_follows(self, clean_env):
98
+ """Add follows a symlink when prompt returns 'follow'."""
99
+ runner, dot_man_dir, repo_dir, global_toml, home = clean_env
100
+
101
+ # Create minimal global config
102
+ if not global_toml.parent.exists():
103
+ global_toml.parent.mkdir(parents=True)
104
+ global_toml.write_text("[defaults]\n")
105
+
106
+ from git import Repo
107
+
108
+ repo = Repo.init(repo_dir)
109
+ config_writer = repo.config_writer()
110
+ config_writer.set_value("user", "name", "Test")
111
+ config_writer.set_value("user", "email", "test@test.com")
112
+ config_writer.release()
113
+
114
+ (repo_dir / "test.txt").write_text("test")
115
+ repo.index.add(["test.txt"])
116
+ repo.index.commit("Initial")
117
+
118
+ target = home / "real_config.txt"
119
+ target.write_text("real content")
120
+
121
+ link = home / ".linked_config"
122
+ link.symlink_to(target)
123
+
124
+ with patch("dot_man.interactive.prompt_symlink_action", return_value="follow"):
125
+ result = runner.invoke(cli, ["add", str(link)])
126
+
127
+ assert result.exit_code in [0, 1, 7]
128
+
129
+ def test_add_symlink_ignore(self, clean_env):
130
+ """Add skips a symlink when prompt returns 'ignore'."""
131
+ runner, dot_man_dir, repo_dir, global_toml, home = clean_env
132
+
133
+ # Create minimal global config
134
+ if not global_toml.parent.exists():
135
+ global_toml.parent.mkdir(parents=True)
136
+ global_toml.write_text("[defaults]\n")
137
+
138
+ from git import Repo
139
+
140
+ repo = Repo.init(repo_dir)
141
+ config_writer = repo.config_writer()
142
+ config_writer.set_value("user", "name", "Test")
143
+ config_writer.set_value("user", "email", "test@test.com")
144
+ config_writer.release()
145
+
146
+ (repo_dir / "test.txt").write_text("test")
147
+ repo.index.add(["test.txt"])
148
+ repo.index.commit("Initial")
149
+
150
+ target = home / "real_config.txt"
151
+ target.write_text("real content")
152
+
153
+ link = home / ".linked_config"
154
+ link.symlink_to(target)
155
+
156
+ with patch("dot_man.interactive.prompt_symlink_action", return_value="ignore"):
157
+ result = runner.invoke(cli, ["add", str(link)])
158
+
159
+ assert result.exit_code == 0
160
+ assert "Skipped symlink" in result.output
161
+
97
162
  def test_add_directory(self, clean_env):
98
163
  """Add a directory to tracking."""
99
164
  runner, dot_man_dir, repo_dir, global_toml, home = clean_env
@@ -30,7 +30,7 @@ class FakeOps(BranchMixin):
30
30
  def current_branch(self) -> str:
31
31
  return self._current_branch
32
32
 
33
- def save_all(self, secret_handler=None):
33
+ def save_all(self, secret_handler=None, symlink_ignore=None):
34
34
  return self._save_all_result
35
35
 
36
36
  @property