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,903 @@
1
+ """Navigate command for dot-man - unified switch + checkout with diff preview."""
2
+
3
+ import os
4
+ import subprocess
5
+ from datetime import datetime
6
+
7
+ import click
8
+
9
+ from .. import ui
10
+ from ..constants import REPO_DIR
11
+ from ..core import GitManager
12
+ from ..files import compare_files
13
+ from ..hooks import run_checkout_hooks, run_switch_hooks
14
+ from .common import (
15
+ complete_switch_args,
16
+ error,
17
+ get_secret_handler,
18
+ parse_branch_arg,
19
+ require_init,
20
+ success,
21
+ warn,
22
+ )
23
+ from .interface import cli as main
24
+
25
+
26
+ def generate_commit_message(
27
+ source: str,
28
+ target: str,
29
+ target_type: str,
30
+ saved_count: int = 0,
31
+ sections: list[str] | None = None,
32
+ ) -> str:
33
+ """Generate a smart commit message based on context.
34
+
35
+ Args:
36
+ source: Source branch/commit
37
+ target: Target branch/commit
38
+ target_type: "branch", "tag", or "commit"
39
+ saved_count: Number of files saved
40
+ sections: List of section names that changed
41
+
42
+ Returns:
43
+ A descriptive commit message
44
+ """
45
+ from ..core import GitManager
46
+
47
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
48
+
49
+ if target_type == "tag":
50
+ action = f"switch to tag {target}"
51
+ elif target_type == "commit":
52
+ action = f"checkout commit {target[:7]}"
53
+ else:
54
+ action = f"switch to branch '{target}'"
55
+
56
+ git = GitManager()
57
+
58
+ msg = f"[dot-man] Save before {action}"
59
+
60
+ details = []
61
+ if saved_count > 0:
62
+ details.append(f"{saved_count} files")
63
+
64
+ if sections:
65
+ relevant = [s for s in sections if s not in ("defaults", "config")]
66
+ if relevant:
67
+ shown = ", ".join(relevant[:3])
68
+ if len(relevant) > 3:
69
+ shown += f" +{len(relevant) - 3} more"
70
+ details.append(f"sections: {shown}")
71
+
72
+ changed_files = []
73
+ try:
74
+ if git.is_dirty():
75
+ for diff in git.repo.index.diff(None):
76
+ if diff.a_path:
77
+ changed_files.append(diff.a_path)
78
+ if diff.b_path:
79
+ changed_files.append(diff.b_path)
80
+ changed_files = list(set(changed_files))[:5]
81
+ if changed_files:
82
+ shown_files = ", ".join([f.split("/")[-1] for f in changed_files])
83
+ if len(changed_files) > 5:
84
+ shown_files += f" +{len(changed_files) - 5} more"
85
+ details.append(f"files: {shown_files}")
86
+ except Exception:
87
+ pass
88
+
89
+ if details:
90
+ msg += f" | {' | '.join(details)}"
91
+
92
+ msg += f" [{timestamp}]"
93
+
94
+ return msg
95
+
96
+
97
+ def get_changed_sections(ops) -> list[str]:
98
+ """Get list of sections that have pending changes."""
99
+ try:
100
+ sections = []
101
+ for section_name in ops.get_sections():
102
+ section = ops.get_section(section_name)
103
+ for local_path in section.paths:
104
+ repo_path = section.get_repo_path(local_path, REPO_DIR)
105
+ if repo_path.exists():
106
+ if not local_path.exists() or not compare_files(
107
+ repo_path, local_path
108
+ ):
109
+ if section_name not in sections:
110
+ sections.append(section_name)
111
+ return sections
112
+ except Exception:
113
+ return []
114
+
115
+
116
+ def run_branch_hooks(ops, hook_type: str) -> None:
117
+ """Run on_activate or on_deactivate hooks from sections.
118
+
119
+ Args:
120
+ ops: DotManOperations instance
121
+ hook_type: "on_activate" or "on_deactivate"
122
+ """
123
+ commands: list[str] = []
124
+ for section_name in ops.get_sections():
125
+ section = ops.get_section(section_name)
126
+ cmd = getattr(section, hook_type, None)
127
+ if cmd:
128
+ commands.append(cmd)
129
+
130
+ commands = list(dict.fromkeys(commands))
131
+
132
+ if commands:
133
+ ui.console.print()
134
+ ui.console.print(f"[bold]Running {hook_type} hooks...[/bold]")
135
+ hook_failed = False
136
+ for cmd in commands:
137
+ ui.console.print(f" Exec: [cyan]{cmd}[/cyan]")
138
+ try:
139
+ shell = os.environ.get("SHELL", "/bin/sh")
140
+ result = subprocess.run(
141
+ [shell, "-c", cmd], capture_output=True, text=True
142
+ )
143
+ if result.returncode != 0:
144
+ hook_failed = True
145
+ ui.console.print(
146
+ f" [yellow]⚠ Hook failed (exit code {result.returncode})[/yellow]"
147
+ )
148
+ if result.stderr:
149
+ for line in result.stderr.splitlines()[:3]:
150
+ ui.console.print(f" [dim]{line}[/dim]")
151
+ except Exception as e:
152
+ hook_failed = True
153
+ warn(f"Failed to run command '{cmd}': {e}")
154
+ if hook_failed:
155
+ ui.console.print("[dim] Some hooks failed - continuing anyway[/dim]")
156
+
157
+
158
+ class BranchParamType(click.ParamType):
159
+ """Parameter type that accepts branch, branch@tag, or commit SHA."""
160
+
161
+ name = "branch"
162
+
163
+ def convert(self, value, param, ctx):
164
+ if not value:
165
+ return None
166
+ parsed = parse_branch_arg(value)
167
+ return parsed
168
+
169
+
170
+ BRANCH = BranchParamType()
171
+
172
+
173
+ @main.command()
174
+ @click.option(
175
+ "--dry-run",
176
+ "-n",
177
+ is_flag=True,
178
+ help="Show what would happen without making changes",
179
+ )
180
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation prompts")
181
+ @click.option(
182
+ "--save",
183
+ "save_mode",
184
+ flag_value="save",
185
+ default=None,
186
+ help="Save current changes before switching",
187
+ )
188
+ @click.option(
189
+ "--no-save",
190
+ "save_mode",
191
+ flag_value="no-save",
192
+ default=None,
193
+ help="Discard current changes before switching",
194
+ )
195
+ @click.option(
196
+ "--message",
197
+ "-m",
198
+ "commit_message",
199
+ type=str,
200
+ default="auto",
201
+ help="Custom commit message for auto-save (default: auto-generated). Use -m 'auto' for smart messages, -m 'none' to disable, or provide your own message.",
202
+ )
203
+ @click.option(
204
+ "--preview",
205
+ "-p",
206
+ is_flag=True,
207
+ help="Preview changes between branches before switching",
208
+ )
209
+ @click.option(
210
+ "--diff",
211
+ "-d",
212
+ is_flag=True,
213
+ help="Show diff of changes when previewing",
214
+ )
215
+ @click.option(
216
+ "--files-only",
217
+ is_flag=True,
218
+ help="Only show commits that affected tracked files",
219
+ )
220
+ @click.argument(
221
+ "target", type=BRANCH, required=False, shell_complete=complete_switch_args
222
+ )
223
+ @require_init
224
+ def navigate(
225
+ target, dry_run, force, save_mode, commit_message, preview, diff, files_only
226
+ ):
227
+ """Navigate to a branch, tag, or commit with optional diff preview.
228
+
229
+ This is the unified command for switching between configurations.
230
+ It supports all branch, tag, and commit targets with full preview
231
+ and diff capabilities.
232
+
233
+ Supports multiple formats:
234
+ \b
235
+ dot-man navigate work # switch to branch
236
+ dot-man navigate work@tag # switch to branch at tag position
237
+ dot-man navigate abc1234 # switch to specific commit
238
+ dot-man navigate my-tag # switch to tag
239
+
240
+ Use --preview or -p to see changes before switching.
241
+ Use --diff or -d to show actual diff when previewing.
242
+ Use --files-only to only show commits that changed tracked files.
243
+ Use -m to provide a custom commit message for auto-save.
244
+ Use -m "auto" for auto-generated messages based on changes.
245
+
246
+ Examples:
247
+ dot-man navigate work # switch to branch
248
+ dot-man navigate work --preview # preview changes
249
+ dot-man navigate work --preview --diff # show full diff
250
+ dot-man navigate work --files-only # only changed commits
251
+ """
252
+ try:
253
+ from ..operations import get_operations
254
+
255
+ if not target:
256
+ error("No branch, tag, or commit specified", exit_code=1)
257
+
258
+ parsed = target
259
+ ops = get_operations()
260
+
261
+ if save_mode is None:
262
+ save_mode = ops.global_config.switch_default_behavior
263
+
264
+ target_type = parsed["type"]
265
+ target_name = parsed["target"]
266
+ current_branch = ops.current_branch
267
+
268
+ if target_type == "commit":
269
+ _handle_commit_navigate(
270
+ ops,
271
+ current_branch,
272
+ target_name,
273
+ save_mode,
274
+ dry_run,
275
+ force,
276
+ preview,
277
+ diff,
278
+ files_only,
279
+ commit_message,
280
+ )
281
+ elif target_type == "tag":
282
+ _handle_tag_navigate(
283
+ ops,
284
+ current_branch,
285
+ parsed["base"],
286
+ target_name,
287
+ save_mode,
288
+ dry_run,
289
+ force,
290
+ preview,
291
+ diff,
292
+ files_only,
293
+ commit_message,
294
+ )
295
+ else:
296
+ _handle_branch_navigate(
297
+ ops,
298
+ current_branch,
299
+ target_name,
300
+ save_mode,
301
+ dry_run,
302
+ force,
303
+ preview,
304
+ diff,
305
+ files_only,
306
+ commit_message,
307
+ )
308
+
309
+ except Exception as e:
310
+ from ..exceptions import ErrorDiagnostic
311
+
312
+ ui.console.print()
313
+ ui.console.print(
314
+ f"[red bold]{ErrorDiagnostic.from_exception(e).title}[/red bold]"
315
+ )
316
+ ui.console.print(f"[red]{ErrorDiagnostic.from_exception(e).details}[/red]")
317
+ raise SystemExit(1)
318
+
319
+
320
+ def _show_branch_diff_preview(ops, source: str, target: str, show_diff: bool = False):
321
+ """Show diff preview between two branches."""
322
+ ui.console.print()
323
+ ui.console.print("[bold cyan]┌─────────────────────────────────────┐[/bold cyan]")
324
+ ui.console.print(
325
+ "[bold cyan]│[/bold cyan] 🔀 Branch Diff Preview [bold cyan]│[/bold cyan]"
326
+ )
327
+ ui.console.print("[bold cyan]└─────────────────────────────────────┘[/bold cyan]")
328
+ ui.console.print()
329
+ ui.console.print(f" [dim]From:[/dim] [cyan]{source}[/cyan]")
330
+ ui.console.print(f" [dim]To:[/dim] [cyan]{target}[/cyan]")
331
+ ui.console.print()
332
+
333
+ git = GitManager()
334
+
335
+ if git.branch_exists(source) and git.branch_exists(target):
336
+ ui.console.print("[bold]📁 Changed files:[/bold]")
337
+ try:
338
+ result = git.repo.git.diff("--name-only", f"{source}...{target}")
339
+ if result:
340
+ for f in result.splitlines():
341
+ ui.console.print(f" • [yellow]{f}[/yellow]")
342
+ else:
343
+ ui.console.print(" [dim](no differences)[/dim]")
344
+
345
+ if show_diff:
346
+ ui.console.print()
347
+ ui.console.print("[bold]📄 Full Diff:[/bold]")
348
+ subprocess.run(
349
+ ["git", "diff", "--color=always", f"{source}...{target}"],
350
+ cwd=REPO_DIR,
351
+ )
352
+ except Exception as e:
353
+ ui.console.print(f" [dim]Could not diff branches: {e}[/dim]")
354
+ else:
355
+ ui.console.print(" [dim](branch diff not available)[/dim]")
356
+
357
+
358
+ def _show_commit_diff(ops, commit_sha: str):
359
+ """Show what files changed in a specific commit."""
360
+ git = GitManager()
361
+ try:
362
+ commit = git.repo.commit(commit_sha)
363
+ ui.console.print()
364
+ ui.console.print(
365
+ "[bold cyan]┌─────────────────────────────────────┐[/bold cyan]"
366
+ )
367
+ ui.console.print(
368
+ "[bold cyan]│[/bold cyan] 📌 Commit Details [bold cyan]│[/bold cyan]"
369
+ )
370
+ ui.console.print(
371
+ "[bold cyan]└─────────────────────────────────────┘[/bold cyan]"
372
+ )
373
+ ui.console.print()
374
+ ui.console.print(f" [dim]Commit:[/dim] [cyan]{commit_sha[:7]}[/cyan]")
375
+ ui.console.print(
376
+ f" [dim]Message:[/dim] {str(commit.message).strip().split(chr(10))[0]}"
377
+ )
378
+ ui.console.print(
379
+ f" [dim]Author:[/dim] {commit.author.name} <{commit.author.email}>"
380
+ )
381
+ ui.console.print()
382
+
383
+ files_changed = []
384
+ for parent in commit.parents:
385
+ diff = parent.diff(commit)
386
+ for d in diff:
387
+ if d.a_path:
388
+ files_changed.append(f"[red]- {d.a_path}[/red]")
389
+ if d.b_path:
390
+ files_changed.append(f"[green]+ {d.b_path}[/green]")
391
+
392
+ if files_changed:
393
+ ui.console.print("[bold]📁 Files changed:[/bold]")
394
+ for f in files_changed[:10]:
395
+ ui.console.print(f" {f}")
396
+ if len(files_changed) > 10:
397
+ ui.console.print(
398
+ f" [dim]... and {len(files_changed) - 10} more[/dim]"
399
+ )
400
+ ui.console.print()
401
+
402
+ subprocess.run(
403
+ ["git", "show", "--color=always", commit_sha, "--"], cwd=REPO_DIR
404
+ )
405
+ except Exception as e:
406
+ ui.console.print(f" [dim]Could not show commit diff: {e}[/dim]")
407
+
408
+
409
+ def _show_commits_list(ops, branch: str, files_only: bool = False, count: int = 20):
410
+ """Show commits for a branch with detailed information."""
411
+ ui.console.print()
412
+ ui.console.print("[bold cyan]┌─────────────────────────────────────┐[/bold cyan]")
413
+ ui.console.print(
414
+ "[bold cyan]│[/bold cyan] 📜 Commit History on [cyan]{}[/cyan] [bold cyan]│[/bold cyan]".format(
415
+ branch
416
+ )
417
+ )
418
+ ui.console.print("[bold cyan]└─────────────────────────────────────┘[/bold cyan]")
419
+ ui.console.print()
420
+
421
+ git = GitManager()
422
+
423
+ if files_only:
424
+ commits = _get_commits_with_file_changes(ops, branch, count)
425
+ if commits:
426
+ for commit in commits:
427
+ tags_str = ""
428
+ ui.console.print(
429
+ f"[cyan]{commit['sha']}[/cyan] [dim]│[/dim] {commit['message']}"
430
+ )
431
+ ui.console.print(f" [dim]{commit['date']}[/dim]")
432
+ ui.console.print(" [dim]📁 Files:[/dim]")
433
+ for f in commit["files"][:5]:
434
+ ui.console.print(f" • [yellow]{f}[/yellow]")
435
+ if commit.get("files_more"):
436
+ ui.console.print(
437
+ f" [dim]... and {commit['files_more']} more[/dim]"
438
+ )
439
+ ui.console.print()
440
+ return
441
+ else:
442
+ ui.console.print(" [dim](no commits with tracked file changes)[/dim]")
443
+ return
444
+
445
+ commits = git.get_commits_detailed(count, branch)
446
+
447
+ if not commits:
448
+ ui.console.print(" [dim](no commits)[/dim]")
449
+ return
450
+
451
+ for commit in commits:
452
+ tags_str = ""
453
+ if commit["tags"]:
454
+ tags_str = f" [dim]│[/dim] [green]🏷 {', '.join(commit['tags'])}[/green]"
455
+
456
+ merge_icon = (
457
+ " [dim]│[/dim] [yellow]⟷ merge[/yellow]" if commit["is_merge"] else ""
458
+ )
459
+
460
+ ui.console.print(
461
+ f"[cyan]{commit['sha']}[/cyan]"
462
+ f" [dim]│[/dim] {commit['message']}"
463
+ f"{tags_str}{merge_icon}"
464
+ )
465
+ ui.console.print(
466
+ f" [dim]{commit['relative_date']}[/dim]"
467
+ f" [dim]│[/dim] [dim]+{commit['insertions']}[/dim]"
468
+ f" [dim]-{commit['deletions']}[/dim]"
469
+ )
470
+
471
+ if commit["files"]:
472
+ ui.console.print(" [dim]📁 Files:[/dim]")
473
+ for f in commit["files"]:
474
+ ui.console.print(f" • [yellow]{f}[/yellow]")
475
+ if commit["files_more"]:
476
+ ui.console.print(f" [dim]... and {commit['files_more']} more[/dim]")
477
+
478
+ ui.console.print()
479
+
480
+
481
+ def _get_commits_with_file_changes(ops, branch: str, max_count: int = 20) -> list[dict]:
482
+ """Get commits that changed tracked files.
483
+
484
+ Args:
485
+ ops: DotManOperations instance
486
+ branch: Branch name
487
+ max_count: Maximum number of commits to check
488
+
489
+ Returns:
490
+ List of dicts with: sha, message, author, date, files
491
+ """
492
+ git = GitManager()
493
+ commits = git.get_commits_detailed(max_count, branch)
494
+
495
+ result = []
496
+ for commit in commits:
497
+ if commit["files"]:
498
+ result.append(
499
+ {
500
+ "sha": commit["sha"],
501
+ "message": commit["message"],
502
+ "author": commit["author"],
503
+ "date": commit["date"],
504
+ "files": commit["files"],
505
+ "files_more": commit["files_more"],
506
+ }
507
+ )
508
+
509
+ return result
510
+
511
+
512
+ def _handle_commit_navigate(
513
+ ops,
514
+ current_branch,
515
+ commit_sha,
516
+ save_mode,
517
+ dry_run,
518
+ force,
519
+ preview,
520
+ show_diff,
521
+ files_only,
522
+ commit_message=None,
523
+ ):
524
+ """Handle navigating to a specific commit."""
525
+ ui.console.print(f"[bold]Navigating to commit[/bold] [cyan]{commit_sha}[/cyan]...")
526
+
527
+ if preview:
528
+ ui.console.print("[bold]Preview mode - showing commit info[/bold]")
529
+ _show_commit_diff(ops, commit_sha)
530
+ ui.console.print("Run again without --preview to checkout this commit.")
531
+ return
532
+
533
+ if dry_run:
534
+ ui.console.print("[dim]Dry run - no changes will be made[/dim]")
535
+ return
536
+
537
+ if save_mode == "save":
538
+ ui.console.print(f"[bold]Saving current branch '{current_branch}'...[/bold]")
539
+ secret_handler = get_secret_handler()
540
+ save_result = ops.save_all(secret_handler)
541
+ saved_count = save_result["saved"]
542
+ sections = get_changed_sections(ops)
543
+
544
+ if commit_message and commit_message.lower() != "none":
545
+ if commit_message.lower() == "auto":
546
+ commit_msg = generate_commit_message(
547
+ current_branch, commit_sha, "commit", saved_count, sections
548
+ )
549
+ else:
550
+ commit_msg = commit_message
551
+
552
+ ops.git.commit(commit_msg)
553
+ ui.console.print(f" Saved {saved_count} files")
554
+ if commit_message.lower() != "auto":
555
+ ui.console.print(f" [dim]Commit: {commit_msg}[/dim]")
556
+ else:
557
+ ui.console.print(f" Saved {saved_count} files (no commit)")
558
+
559
+ run_checkout_hooks("pre", commit_sha)
560
+
561
+ ops.git.checkout_commit(commit_sha)
562
+ ui.console.print(f" Checked out commit: [dim]{commit_sha}[/dim]")
563
+ ui.console.print("[yellow]Note: You are in detached HEAD state[/yellow]")
564
+ ui.console.print(" Use 'dot-man navigate <branch>' to return to a branch")
565
+
566
+ run_checkout_hooks("post", commit_sha)
567
+
568
+
569
+ def _handle_tag_navigate(
570
+ ops,
571
+ current_branch,
572
+ base_branch,
573
+ tag_name,
574
+ save_mode,
575
+ dry_run,
576
+ force,
577
+ preview,
578
+ show_diff,
579
+ files_only,
580
+ commit_message=None,
581
+ ):
582
+ """Handle navigating to a tag."""
583
+ ui.console.print(f"[bold]Navigating to tag[/bold] [cyan]{tag_name}[/cyan]...")
584
+
585
+ if preview:
586
+ ui.console.print("[bold]Preview mode - showing tag info[/bold]")
587
+ tag_commit = ops.git.get_tag_commit(tag_name)
588
+ if tag_commit:
589
+ _show_commit_diff(ops, tag_commit)
590
+ ui.console.print("Run again without --preview to checkout this tag.")
591
+ return
592
+
593
+ if dry_run:
594
+ ui.console.print("[dim]Dry run - no changes will be made[/dim]")
595
+ return
596
+
597
+ tag_commit = ops.git.get_tag_commit(tag_name)
598
+ if not tag_commit:
599
+ error(f"Tag '{tag_name}' not found", exit_code=1)
600
+
601
+ if save_mode == "save":
602
+ ui.console.print(f"[bold]Saving current branch '{current_branch}'...[/bold]")
603
+ secret_handler = get_secret_handler()
604
+ save_result = ops.save_all(secret_handler)
605
+ saved_count = save_result["saved"]
606
+ sections = get_changed_sections(ops)
607
+
608
+ if commit_message and commit_message.lower() != "none":
609
+ if commit_message.lower() == "auto":
610
+ commit_msg = generate_commit_message(
611
+ current_branch, tag_name, "tag", saved_count, sections
612
+ )
613
+ else:
614
+ commit_msg = commit_message
615
+
616
+ ops.git.commit(commit_msg)
617
+ ui.console.print(f" Saved {saved_count} files")
618
+ if commit_message.lower() != "auto":
619
+ ui.console.print(f" [dim]Commit: {commit_msg}[/dim]")
620
+ else:
621
+ ui.console.print(f" Saved {saved_count} files (no commit)")
622
+
623
+ if base_branch and base_branch != "HEAD" and ops.git.branch_exists(base_branch):
624
+ ops.git.checkout(base_branch)
625
+ ui.console.print(f" Switched to branch: {base_branch}")
626
+
627
+ run_switch_hooks("pre", ops, current_branch, tag_name)
628
+
629
+ ops.git.checkout(tag_name)
630
+ ui.console.print(f" Checked out tag: [dim]{tag_name}[/dim]")
631
+
632
+ success(f"Switched to tag '{tag_name}'")
633
+
634
+ run_switch_hooks("post", ops, current_branch, tag_name)
635
+
636
+
637
+ def _handle_branch_navigate(
638
+ ops,
639
+ current_branch,
640
+ target_branch,
641
+ save_mode,
642
+ dry_run,
643
+ force,
644
+ preview,
645
+ show_diff,
646
+ files_only,
647
+ commit_message=None,
648
+ ):
649
+ """Handle navigating to a branch."""
650
+ if current_branch == target_branch and not dry_run:
651
+ ui.console.print(f"Already on branch '[bold]{target_branch}[/bold]'")
652
+
653
+ if preview:
654
+ _show_commits_list(ops, target_branch, files_only)
655
+ return
656
+
657
+ if preview:
658
+ ui.console.print("[bold]Preview mode - showing branch info[/bold]")
659
+ _show_branch_diff_preview(ops, current_branch, target_branch, show_diff)
660
+ _show_commits_list(ops, target_branch, files_only)
661
+ ui.console.print("Run again without --preview to switch to this branch.")
662
+ return
663
+
664
+ if dry_run:
665
+ ui.console.print("[dim]Dry run - no changes will be made[/dim]")
666
+ ui.console.print()
667
+ _show_branch_diff_preview(ops, current_branch, target_branch, show_diff)
668
+ return
669
+
670
+ ui.console.print(
671
+ f"[bold]Navigating to branch[/bold] [cyan]{target_branch}[/cyan]..."
672
+ )
673
+
674
+ ui.console.print()
675
+ ui.console.print(
676
+ f"[bold]Phase 1:[/bold] {'Saving' if save_mode == 'save' else 'Discarding'} branch '{current_branch}'..."
677
+ )
678
+
679
+ if save_mode == "save":
680
+ secret_handler = get_secret_handler()
681
+ save_result = ops.save_all(secret_handler)
682
+ saved_count = save_result["saved"]
683
+ secrets = save_result["secrets"]
684
+ errors = save_result["errors"]
685
+ sections = get_changed_sections(ops)
686
+
687
+ if secrets:
688
+ warn(f"{len(secrets)} secrets were redacted during save")
689
+
690
+ if errors:
691
+ ui.error(f"Encountered {len(errors)} errors during save:")
692
+ for err in errors:
693
+ ui.console.print(f" [red]• {err}[/red]")
694
+
695
+ if commit_message and commit_message.lower() != "none":
696
+ if commit_message.lower() == "auto":
697
+ commit_msg = generate_commit_message(
698
+ current_branch, target_branch, "branch", saved_count, sections
699
+ )
700
+ else:
701
+ commit_msg = commit_message
702
+
703
+ commit_sha = ops.git.commit(commit_msg)
704
+ if commit_sha:
705
+ ui.console.print(f" Committed: [dim]{commit_sha[:7]}[/dim]")
706
+ if commit_message.lower() != "auto":
707
+ ui.console.print(f" [dim]Commit: {commit_msg}[/dim]")
708
+ else:
709
+ commit_sha = None
710
+
711
+ ui.console.print(f" Saved {saved_count} files")
712
+ if not commit_sha and saved_count > 0:
713
+ ui.console.print(" [dim](no commit created)[/dim]")
714
+ else:
715
+ ui.console.print(" [dim]Discarded uncommitted changes[/dim]")
716
+
717
+ ui.console.print()
718
+ ui.console.print(f"[bold]Phase 2:[/bold] Switching to branch '{target_branch}'...")
719
+
720
+ run_branch_hooks(ops, "on_deactivate")
721
+
722
+ run_switch_hooks("pre", ops, current_branch, target_branch)
723
+
724
+ branch_exists = ops.git.branch_exists(target_branch)
725
+ if not branch_exists:
726
+ if not force and not ui.confirm(
727
+ f"Branch '{target_branch}' doesn't exist. Create it?"
728
+ ):
729
+ ui.console.print("[dim]Aborted.[/dim]")
730
+ return
731
+ ui.console.print(f" Creating new branch: [cyan]{target_branch}[/cyan]")
732
+
733
+ ops.git.checkout(target_branch, create=not branch_exists)
734
+ if branch_exists:
735
+ ui.console.print(f" Switched to existing branch: [cyan]{target_branch}[/cyan]")
736
+ else:
737
+ ui.console.print(
738
+ f" Created and switched to new branch: [cyan]{target_branch}[/cyan]"
739
+ )
740
+ ops.reload_config()
741
+
742
+ ui.console.print()
743
+ ui.console.print(
744
+ f"[bold]Phase 3:[/bold] Deploying '{target_branch}' configuration..."
745
+ )
746
+
747
+ deployed_count = 0
748
+ pre_hooks = []
749
+ post_hooks = []
750
+
751
+ for section_name in ops.get_sections():
752
+ section = ops.get_section(section_name)
753
+ for local_path in section.paths:
754
+ repo_path = section.get_repo_path(local_path, REPO_DIR)
755
+ if repo_path.exists():
756
+ will_change = not local_path.exists() or not compare_files(
757
+ repo_path, local_path
758
+ )
759
+ if will_change:
760
+ if section.pre_deploy:
761
+ pre_hooks.append(section.pre_deploy)
762
+ if section.post_deploy:
763
+ post_hooks.append(section.post_deploy)
764
+
765
+ pre_hooks = list(dict.fromkeys(pre_hooks))
766
+ post_hooks = list(dict.fromkeys(post_hooks))
767
+
768
+ if pre_hooks:
769
+ ui.console.print()
770
+ ui.console.print("[bold]Running pre-deploy hooks...[/bold]")
771
+ hook_failed = False
772
+ for cmd in pre_hooks:
773
+ ui.console.print(f" Exec: [cyan]{cmd}[/cyan]")
774
+ try:
775
+ shell = os.environ.get("SHELL", "/bin/sh")
776
+ result = subprocess.run(
777
+ [shell, "-c", cmd], capture_output=True, text=True
778
+ )
779
+ if result.returncode != 0:
780
+ hook_failed = True
781
+ ui.console.print(
782
+ f" [yellow]⚠ Hook failed (exit code {result.returncode})[/yellow]"
783
+ )
784
+ if result.stderr:
785
+ for line in result.stderr.splitlines()[:3]:
786
+ ui.console.print(f" [dim]{line}[/dim]")
787
+ except Exception as e:
788
+ hook_failed = True
789
+ warn(f"Failed to run command '{cmd}': {e}")
790
+ if hook_failed:
791
+ ui.console.print("[dim] Some hooks failed - continuing anyway[/dim]")
792
+ ui.console.print()
793
+
794
+ deploy_result = ops.deploy_all()
795
+ deployed_count = deploy_result["deployed"]
796
+ errors = [e for e in deploy_result["errors"] if e and str(e).strip()]
797
+
798
+ if errors:
799
+ ui.error(f"Encountered {len(errors)} errors during deploy:")
800
+ for err in errors:
801
+ ui.console.print(f" [red]• {err}[/red]")
802
+
803
+ ui.console.print(f" Deployed {deployed_count} files")
804
+
805
+ if post_hooks:
806
+ ui.console.print()
807
+ ui.console.print("[bold]Running post-deploy hooks...[/bold]")
808
+ hook_failed = False
809
+ for cmd in post_hooks:
810
+ ui.console.print(f" Exec: [cyan]{cmd}[/cyan]")
811
+ try:
812
+ shell = os.environ.get("SHELL", "/bin/sh")
813
+ result = subprocess.run(
814
+ [shell, "-c", cmd], capture_output=True, text=True
815
+ )
816
+ if result.returncode != 0:
817
+ hook_failed = True
818
+ ui.console.print(
819
+ f" [yellow]⚠ Hook failed (exit code {result.returncode})[/yellow]"
820
+ )
821
+ if result.stderr:
822
+ for line in result.stderr.splitlines()[:3]:
823
+ ui.console.print(f" [dim]{line}[/dim]")
824
+ except Exception as e:
825
+ hook_failed = True
826
+ warn(f"Failed to run command '{cmd}': {e}")
827
+ if hook_failed:
828
+ ui.console.print("[dim] Some hooks failed - continuing anyway[/dim]")
829
+
830
+ ops.global_config.current_branch = target_branch
831
+ ops.global_config.save()
832
+
833
+ run_switch_hooks("post", ops, current_branch, target_branch)
834
+
835
+ run_branch_hooks(ops, "on_activate")
836
+
837
+ ui.console.print()
838
+ success(f"Switched to '{target_branch}'")
839
+ ui.console.print()
840
+ ui.console.print(f" • Deployed {deployed_count} files for '{target_branch}'")
841
+ ui.console.print()
842
+ ui.next_steps(
843
+ [
844
+ "Run [cyan]dot-man status[/cyan] to verify deployment",
845
+ "Run [cyan]dot-man log[/cyan] to see commit history",
846
+ f"Edit files and run [cyan]dot-man navigate {current_branch}[/cyan] to save changes",
847
+ ]
848
+ )
849
+
850
+
851
+ @main.command()
852
+ @click.argument("command", type=click.Choice(["list", "create", "delete"]))
853
+ @click.argument("phase", type=click.Choice(["pre", "post"]), required=False)
854
+ @click.argument("name", type=str, required=False)
855
+ @require_init
856
+ def hooks(command: str, phase: str | None, name: str | None):
857
+ """Manage dot-man hooks.
858
+
859
+ Hooks allow you to run custom scripts before/after commands.
860
+
861
+ Commands:
862
+ list List all available hooks (no additional args needed)
863
+ create Create a new hook script (requires: pre|post NAME)
864
+ delete Delete a hook script (requires: pre|post NAME)
865
+
866
+ Hook naming: {phase}_{command} (e.g., pre_switch, post_deploy)
867
+
868
+ Examples:
869
+ dot-man hooks list
870
+ dot-man hooks create pre switch
871
+ dot-man hooks create post deploy
872
+ dot-man hooks delete pre checkout
873
+ """
874
+ from ..hooks import (
875
+ create_hook,
876
+ delete_hook,
877
+ list_hooks,
878
+ )
879
+
880
+ if command == "list":
881
+ ui.console.print("[bold]Available Hooks:[/bold]")
882
+ all_hooks = list_hooks()
883
+ for h in all_hooks:
884
+ status = "[green]✓[/green]" if h["exists"] else "[dim]-[/dim]"
885
+ ui.console.print(f" {status} {h['phase']}_{h['command']} -> {h['path']}")
886
+
887
+ elif command == "create":
888
+ if not phase or not name:
889
+ error("'create' requires: pre|post NAME", exit_code=1)
890
+ assert isinstance(name, str) and isinstance(phase, str)
891
+ hook_path = create_hook(name, phase)
892
+ ui.console.print(f"[green]Created hook:[/green] {hook_path}")
893
+ ui.console.print(" Edit this file to add your custom script.")
894
+
895
+ elif command == "delete":
896
+ if not phase or not name:
897
+ error("'delete' requires: pre|post NAME", exit_code=1)
898
+ assert isinstance(name, str) and isinstance(phase, str)
899
+ deleted = delete_hook(name, phase)
900
+ if deleted:
901
+ success(f"Deleted hook: {phase}_{name}")
902
+ else:
903
+ warn(f"Hook not found: {phase}_{name}")