dotman-git 1.0.0__py3-none-any.whl

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 (69) hide show
  1. dot_man/__init__.py +4 -0
  2. dot_man/backups.py +211 -0
  3. dot_man/branch_ops.py +347 -0
  4. dot_man/cli/__init__.py +113 -0
  5. dot_man/cli/add_cmd.py +167 -0
  6. dot_man/cli/audit_cmd.py +141 -0
  7. dot_man/cli/backup_cmd.py +105 -0
  8. dot_man/cli/branch_cmd.py +103 -0
  9. dot_man/cli/clean_cmd.py +97 -0
  10. dot_man/cli/common.py +548 -0
  11. dot_man/cli/completions_cmd.py +127 -0
  12. dot_man/cli/config_cmd.py +979 -0
  13. dot_man/cli/deploy_cmd.py +169 -0
  14. dot_man/cli/discover_cmd.py +105 -0
  15. dot_man/cli/doctor_cmd.py +229 -0
  16. dot_man/cli/edit_cmd.py +177 -0
  17. dot_man/cli/encrypt_cmd.py +205 -0
  18. dot_man/cli/export_cmd.py +146 -0
  19. dot_man/cli/import_cmd.py +315 -0
  20. dot_man/cli/init_cmd.py +532 -0
  21. dot_man/cli/interface.py +56 -0
  22. dot_man/cli/log_cmd.py +339 -0
  23. dot_man/cli/main.py +36 -0
  24. dot_man/cli/navigate_cmd.py +903 -0
  25. dot_man/cli/onboarding.py +546 -0
  26. dot_man/cli/profile_cmd.py +313 -0
  27. dot_man/cli/remote_cmd.py +454 -0
  28. dot_man/cli/restore_cmd.py +82 -0
  29. dot_man/cli/revert_cmd.py +86 -0
  30. dot_man/cli/show_cmd.py +29 -0
  31. dot_man/cli/status_cmd.py +185 -0
  32. dot_man/cli/switch_cmd.py +387 -0
  33. dot_man/cli/tag_cmd.py +164 -0
  34. dot_man/cli/template_cmd.py +244 -0
  35. dot_man/cli/tui_cmd.py +44 -0
  36. dot_man/cli/verify_cmd.py +156 -0
  37. dot_man/completions/_dot-man.zsh +28 -0
  38. dot_man/completions/dot-man.bash +15 -0
  39. dot_man/completions/dot-man.fish +58 -0
  40. dot_man/completions/install.sh +26 -0
  41. dot_man/config.py +23 -0
  42. dot_man/config_detector.py +426 -0
  43. dot_man/constants.py +109 -0
  44. dot_man/core.py +614 -0
  45. dot_man/dotman_config.py +516 -0
  46. dot_man/encryption.py +173 -0
  47. dot_man/exceptions.py +255 -0
  48. dot_man/files.py +443 -0
  49. dot_man/global_config.py +305 -0
  50. dot_man/hooks.py +232 -0
  51. dot_man/interactive.py +460 -0
  52. dot_man/lock.py +64 -0
  53. dot_man/merge.py +440 -0
  54. dot_man/operations.py +212 -0
  55. dot_man/py.typed +1 -0
  56. dot_man/save_deploy_ops.py +466 -0
  57. dot_man/secrets.py +473 -0
  58. dot_man/section.py +207 -0
  59. dot_man/status_ops.py +229 -0
  60. dot_man/tui_log.py +91 -0
  61. dot_man/ui.py +127 -0
  62. dot_man/utils.py +132 -0
  63. dot_man/vault.py +317 -0
  64. dotman_git-1.0.0.dist-info/METADATA +678 -0
  65. dotman_git-1.0.0.dist-info/RECORD +69 -0
  66. dotman_git-1.0.0.dist-info/WHEEL +5 -0
  67. dotman_git-1.0.0.dist-info/entry_points.txt +3 -0
  68. dotman_git-1.0.0.dist-info/licenses/LICENSE +21 -0
  69. dotman_git-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,466 @@
1
+ """Save and deploy operations mixin for DotManOperations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import abstractmethod
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, Callable, Optional
9
+
10
+ from .constants import LOCK_FILE, REPO_DIR
11
+ from .files import (
12
+ atomic_write_text,
13
+ backup_file,
14
+ clear_comparison_cache,
15
+ compare_files,
16
+ copy_directory,
17
+ copy_file,
18
+ )
19
+ from .lock import FileLock
20
+ from .secrets import SecretMatch
21
+
22
+ if TYPE_CHECKING:
23
+ from .config import Section
24
+
25
+ # Binary file extensions that can't contain text secrets
26
+ _BINARY_EXTENSIONS = {
27
+ ".jpg",
28
+ ".jpeg",
29
+ ".png",
30
+ ".gif",
31
+ ".bmp",
32
+ ".ico",
33
+ ".webp",
34
+ ".svg",
35
+ ".mp4",
36
+ ".mp3",
37
+ ".wav",
38
+ ".ogg",
39
+ ".flac",
40
+ ".avi",
41
+ ".mkv",
42
+ ".webm",
43
+ ".pyc",
44
+ ".pyo",
45
+ ".so",
46
+ ".dll",
47
+ ".exe",
48
+ ".bin",
49
+ ".dat",
50
+ ".zip",
51
+ ".tar",
52
+ ".gz",
53
+ ".bz2",
54
+ ".xz",
55
+ ".7z",
56
+ ".rar",
57
+ ".pdf",
58
+ ".doc",
59
+ ".docx",
60
+ ".xls",
61
+ ".xlsx",
62
+ ".ttf",
63
+ ".otf",
64
+ ".woff",
65
+ ".woff2",
66
+ ".eot",
67
+ }
68
+
69
+
70
+ class SaveDeployMixin:
71
+ """Mixin providing save/deploy operations for DotManOperations."""
72
+
73
+ @property
74
+ @abstractmethod
75
+ def vault(self) -> Any: ...
76
+
77
+ @property
78
+ @abstractmethod
79
+ def current_branch(self) -> str: ...
80
+
81
+ @abstractmethod
82
+ def get_section(self, name: str) -> Any: ...
83
+
84
+ @abstractmethod
85
+ def get_sections(self) -> list[str]: ...
86
+
87
+ def _restore_file_secrets(
88
+ self, dest_path: Path, original_path: str, branch: str
89
+ ) -> Optional[str]:
90
+ """
91
+ Restore redacted secrets in a deployed file from the vault.
92
+
93
+ Args:
94
+ dest_path: Path to the file on disk to restore secrets into.
95
+ original_path: The original local path string (used as vault key).
96
+ branch: The branch name for vault lookup.
97
+
98
+ Returns:
99
+ Error message string if an OS error occurred, None on success.
100
+ """
101
+ if dest_path.suffix.lower() in _BINARY_EXTENSIONS:
102
+ return None # Skip binary files silently
103
+
104
+ try:
105
+ content = dest_path.read_text(encoding="utf-8")
106
+ restored = self.vault.restore_secrets_in_content(
107
+ content, original_path, branch
108
+ )
109
+ if restored != content:
110
+ atomic_write_text(dest_path, restored)
111
+ except UnicodeDecodeError:
112
+ pass # Skip binary files silently
113
+ except OSError as e:
114
+ return f"Failed to restore secrets for {dest_path}: {e}"
115
+ return None
116
+
117
+ def save_section(
118
+ self,
119
+ section: Section,
120
+ secret_handler: Optional[Callable[[SecretMatch], str]] = None,
121
+ ) -> tuple[int, list[SecretMatch], list[str]]:
122
+ """
123
+ Save a section from local to repo.
124
+
125
+ Returns:
126
+ (files_saved, secrets_detected, errors)
127
+ """
128
+ saved = 0
129
+ all_secrets: list[SecretMatch] = []
130
+ errors: list[str] = []
131
+
132
+ # Enhanced secret handler that also stashes to vault
133
+ def wrapped_handler(match: SecretMatch) -> str:
134
+ action = "REDACT"
135
+ if secret_handler:
136
+ action = secret_handler(match)
137
+
138
+ if action == "REDACT":
139
+ # Stash to vault
140
+ try:
141
+ secret_hash = self.vault.stash_secret(
142
+ file_path=str(match.file),
143
+ line_number=match.line_number,
144
+ pattern_name=match.pattern_name,
145
+ secret_value=match.matched_text,
146
+ branch=self.current_branch,
147
+ )
148
+ # Return formatted redaction string with hash
149
+ return f"***REDACTED:{secret_hash}***"
150
+ except (OSError, IOError) as e:
151
+ errors.append(f"Failed to stash secret in vault: {e}")
152
+ # Fallback to standard redaction if vault fails
153
+ from .constants import SECRET_REDACTION_TEXT
154
+
155
+ return SECRET_REDACTION_TEXT
156
+
157
+ return action
158
+
159
+ # Merge exclude patterns with ignored directories
160
+ final_excludes = (section.exclude or []) + (section.ignored_directories or [])
161
+
162
+ for local_path in section.paths:
163
+ if not local_path.exists():
164
+ continue
165
+
166
+ repo_path = section.get_repo_path(local_path, REPO_DIR)
167
+
168
+ try:
169
+ if local_path.is_file():
170
+ success, secrets = copy_file(
171
+ local_path,
172
+ repo_path,
173
+ filter_secrets_enabled=section.secrets_filter,
174
+ secret_handler=wrapped_handler,
175
+ )
176
+ if success:
177
+ saved += 1
178
+ all_secrets.extend(secrets)
179
+ else:
180
+ files_copied, files_failed, secrets = copy_directory(
181
+ local_path,
182
+ repo_path,
183
+ filter_secrets_enabled=section.secrets_filter,
184
+ include_patterns=section.include,
185
+ exclude_patterns=final_excludes,
186
+ secret_handler=wrapped_handler,
187
+ follow_symlinks=section.follow_symlinks,
188
+ )
189
+ saved += files_copied
190
+ all_secrets.extend(secrets)
191
+ if files_failed > 0:
192
+ errors.append(
193
+ f"Failed to copy {files_failed} files in {local_path}"
194
+ )
195
+ except (OSError, IOError) as e:
196
+ errors.append(f"Error processing {local_path}: {e}")
197
+
198
+ return saved, all_secrets, errors
199
+
200
+ def deploy_section(self, section: Section) -> tuple[int, bool, list[str]]:
201
+ """
202
+ Deploy a section from repo to local.
203
+
204
+ Returns:
205
+ (files_deployed, had_changes, errors)
206
+ """
207
+ deployed = 0
208
+ had_changes = False
209
+ errors: list[str] = []
210
+
211
+ # Merge exclude patterns with ignored directories
212
+ final_excludes = (section.exclude or []) + (section.ignored_directories or [])
213
+
214
+ for local_path in section.paths:
215
+ repo_path = section.get_repo_path(local_path, REPO_DIR)
216
+ # Handle update strategy
217
+ if section.update_strategy == "ignore" and local_path.exists():
218
+ continue
219
+
220
+ # Check if will change (after strategy filtering)
221
+ will_change = not local_path.exists() or not compare_files(
222
+ repo_path, local_path
223
+ )
224
+ if will_change:
225
+ had_changes = True
226
+
227
+ if section.update_strategy == "rename_old" and local_path.exists():
228
+ backup_file(local_path)
229
+
230
+ # Helper to restore secrets after copy
231
+ def restore_file_secrets(dest_path: Path) -> None:
232
+ err = self._restore_file_secrets(
233
+ dest_path, str(local_path), self.current_branch
234
+ )
235
+ if err:
236
+ errors.append(err)
237
+
238
+ try:
239
+ if repo_path.is_file():
240
+ success, _ = copy_file(
241
+ repo_path, local_path, filter_secrets_enabled=False
242
+ )
243
+ if success:
244
+ if section.secrets_filter:
245
+ restore_file_secrets(local_path)
246
+ deployed += 1
247
+ else:
248
+ files_copied, files_failed, _ = copy_directory(
249
+ repo_path,
250
+ local_path,
251
+ filter_secrets_enabled=False,
252
+ include_patterns=section.include,
253
+ exclude_patterns=final_excludes,
254
+ follow_symlinks=section.follow_symlinks,
255
+ )
256
+ if section.secrets_filter:
257
+ for deployed_file in local_path.rglob("*"):
258
+ if deployed_file.is_file():
259
+ restore_file_secrets(deployed_file)
260
+
261
+ deployed += files_copied
262
+ if files_failed > 0:
263
+ errors.append(
264
+ f"Failed to deploy {files_failed} files in {local_path}"
265
+ )
266
+ except PermissionError:
267
+ errors.append(
268
+ f"Permission denied deploying {local_path}. Try running with sudo?"
269
+ )
270
+ except FileNotFoundError:
271
+ errors.append(f"File not found during deployment: {local_path}")
272
+ except OSError as e:
273
+ errors.append(f"Error deploying {local_path}: {e}")
274
+
275
+ return deployed, had_changes, errors
276
+
277
+ def scan_deployable_changes(self, sections: list[Section]) -> dict:
278
+ """
279
+ Phase 1: Scan for changes and collect hooks (Fast Scan).
280
+
281
+ Returns:
282
+ DeploymentPlan dict
283
+ """
284
+ plan: dict = {
285
+ "sections_to_deploy": [],
286
+ "pre_hooks": [],
287
+ "post_hooks": [],
288
+ "errors": [],
289
+ }
290
+
291
+ for section in sections:
292
+ for local_path in section.paths:
293
+ repo_path = section.get_repo_path(local_path, REPO_DIR)
294
+
295
+ if section.update_strategy == "ignore" and local_path.exists():
296
+ continue
297
+
298
+ try:
299
+ if not repo_path.exists():
300
+ continue
301
+
302
+ will_change = not local_path.exists() or not compare_files(
303
+ repo_path, local_path
304
+ )
305
+
306
+ if will_change:
307
+ plan["sections_to_deploy"].append(
308
+ (section, local_path, repo_path)
309
+ )
310
+
311
+ if section.pre_deploy:
312
+ plan["pre_hooks"].append(section.pre_deploy)
313
+ if section.post_deploy:
314
+ plan["post_hooks"].append(section.post_deploy)
315
+
316
+ except OSError as e:
317
+ plan["errors"].append(f"Error scanning {local_path}: {e}")
318
+
319
+ return plan
320
+
321
+ def execute_deployment_plan(self, plan: dict) -> dict:
322
+ """
323
+ Phase 2: Execute the deployment plan.
324
+
325
+ Returns dict with keys: 'deployed', 'pre_hooks', 'post_hooks', 'errors'
326
+ """
327
+ total_deployed = 0
328
+ all_errors: list[str] = list(plan["errors"])
329
+ pre_hooks = list(dict.fromkeys(plan["pre_hooks"]))
330
+ post_hooks = list(dict.fromkeys(plan["post_hooks"]))
331
+
332
+ sections_to_deploy = plan["sections_to_deploy"]
333
+
334
+ if sections_to_deploy:
335
+ with ThreadPoolExecutor() as executor:
336
+
337
+ def deploy_item(item_tuple):
338
+ section, local_path, repo_path = item_tuple
339
+ deployed_count = 0
340
+ item_errors = []
341
+
342
+ if section.update_strategy == "rename_old" and local_path.exists():
343
+ backup_file(local_path)
344
+
345
+ def restore_file_secrets(dest_path: Path) -> None:
346
+ err = self._restore_file_secrets(
347
+ dest_path, str(local_path), self.current_branch
348
+ )
349
+ if err:
350
+ item_errors.append(err)
351
+
352
+ final_excludes = (section.exclude or []) + (
353
+ section.ignored_directories or []
354
+ )
355
+
356
+ try:
357
+ if repo_path.is_file():
358
+ success, _ = copy_file(
359
+ repo_path, local_path, filter_secrets_enabled=False
360
+ )
361
+ if success:
362
+ if section.secrets_filter:
363
+ restore_file_secrets(local_path)
364
+ deployed_count += 1
365
+ else:
366
+ item_errors.append(
367
+ f"Failed to copy {repo_path} to {local_path}"
368
+ )
369
+ elif repo_path.is_dir():
370
+ files_copied, files_failed, _ = copy_directory(
371
+ repo_path,
372
+ local_path,
373
+ filter_secrets_enabled=False,
374
+ include_patterns=section.include,
375
+ exclude_patterns=final_excludes,
376
+ follow_symlinks=section.follow_symlinks,
377
+ )
378
+ if section.secrets_filter:
379
+ for deployed_file in local_path.rglob("*"):
380
+ if deployed_file.is_file():
381
+ restore_file_secrets(deployed_file)
382
+
383
+ deployed_count += files_copied
384
+ if files_failed > 0:
385
+ item_errors.append(
386
+ f"Failed to deploy {files_failed} files in {local_path}"
387
+ )
388
+ else:
389
+ item_errors.append(f"Source not found in repo: {repo_path}")
390
+
391
+ except PermissionError:
392
+ item_errors.append(
393
+ f"Permission denied deploying {local_path}. Try running with sudo?"
394
+ )
395
+ except FileNotFoundError:
396
+ item_errors.append(
397
+ f"File not found during deployment: {repo_path}"
398
+ )
399
+ except OSError as e:
400
+ item_errors.append(f"Error deploying {local_path}: {e}")
401
+
402
+ return deployed_count, item_errors
403
+
404
+ futures = [
405
+ executor.submit(deploy_item, item) for item in sections_to_deploy
406
+ ]
407
+
408
+ for future in as_completed(futures):
409
+ try:
410
+ count, errs = future.result()
411
+ total_deployed += count
412
+ all_errors.extend(errs)
413
+ except Exception as e:
414
+ all_errors.append(f"Critical error in deployment thread: {e}")
415
+
416
+ # Clear comparison cache after deploying changed files
417
+ clear_comparison_cache()
418
+
419
+ return {
420
+ "deployed": total_deployed,
421
+ "pre_hooks": pre_hooks,
422
+ "post_hooks": post_hooks,
423
+ "errors": all_errors,
424
+ }
425
+
426
+ def save_all(
427
+ self, secret_handler: Optional[Callable[[SecretMatch], str]] = None
428
+ ) -> dict:
429
+ """
430
+ Save all sections from local to repo.
431
+ Returns dict with keys: 'saved', 'secrets', 'errors'
432
+ """
433
+ with FileLock(LOCK_FILE):
434
+ total_saved = 0
435
+ all_secrets: list[SecretMatch] = []
436
+ all_errors: list[str] = []
437
+
438
+ sections = [self.get_section(name) for name in self.get_sections()]
439
+
440
+ with self.vault.batch(), ThreadPoolExecutor() as executor:
441
+ future_to_section = {
442
+ executor.submit(self.save_section, section, secret_handler): section
443
+ for section in sections
444
+ }
445
+
446
+ for future in as_completed(future_to_section):
447
+ try:
448
+ saved, secrets, errors = future.result()
449
+ total_saved += saved
450
+ all_secrets.extend(secrets)
451
+ all_errors.extend(errors)
452
+ except Exception as e:
453
+ all_errors.append(f"Critical error saving section: {e}")
454
+
455
+ return {"saved": total_saved, "secrets": all_secrets, "errors": all_errors}
456
+
457
+ def deploy_all(self) -> dict:
458
+ """
459
+ Deploy all sections from repo to local (Two-Phase).
460
+ Returns dict with keys: 'deployed', 'pre_hooks', 'post_hooks', 'errors'
461
+ """
462
+ with FileLock(LOCK_FILE):
463
+ sections = [self.get_section(name) for name in self.get_sections()]
464
+ plan = self.scan_deployable_changes(sections)
465
+ result = self.execute_deployment_plan(plan)
466
+ return result