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.
- dot_man/__init__.py +4 -0
- dot_man/backups.py +211 -0
- dot_man/branch_ops.py +347 -0
- dot_man/cli/__init__.py +113 -0
- dot_man/cli/add_cmd.py +167 -0
- dot_man/cli/audit_cmd.py +141 -0
- dot_man/cli/backup_cmd.py +105 -0
- dot_man/cli/branch_cmd.py +103 -0
- dot_man/cli/clean_cmd.py +97 -0
- dot_man/cli/common.py +548 -0
- dot_man/cli/completions_cmd.py +127 -0
- dot_man/cli/config_cmd.py +979 -0
- dot_man/cli/deploy_cmd.py +169 -0
- dot_man/cli/discover_cmd.py +105 -0
- dot_man/cli/doctor_cmd.py +229 -0
- dot_man/cli/edit_cmd.py +177 -0
- dot_man/cli/encrypt_cmd.py +205 -0
- dot_man/cli/export_cmd.py +146 -0
- dot_man/cli/import_cmd.py +315 -0
- dot_man/cli/init_cmd.py +532 -0
- dot_man/cli/interface.py +56 -0
- dot_man/cli/log_cmd.py +339 -0
- dot_man/cli/main.py +36 -0
- dot_man/cli/navigate_cmd.py +903 -0
- dot_man/cli/onboarding.py +546 -0
- dot_man/cli/profile_cmd.py +313 -0
- dot_man/cli/remote_cmd.py +454 -0
- dot_man/cli/restore_cmd.py +82 -0
- dot_man/cli/revert_cmd.py +86 -0
- dot_man/cli/show_cmd.py +29 -0
- dot_man/cli/status_cmd.py +185 -0
- dot_man/cli/switch_cmd.py +387 -0
- dot_man/cli/tag_cmd.py +164 -0
- dot_man/cli/template_cmd.py +244 -0
- dot_man/cli/tui_cmd.py +44 -0
- dot_man/cli/verify_cmd.py +156 -0
- dot_man/completions/_dot-man.zsh +28 -0
- dot_man/completions/dot-man.bash +15 -0
- dot_man/completions/dot-man.fish +58 -0
- dot_man/completions/install.sh +26 -0
- dot_man/config.py +23 -0
- dot_man/config_detector.py +426 -0
- dot_man/constants.py +109 -0
- dot_man/core.py +614 -0
- dot_man/dotman_config.py +516 -0
- dot_man/encryption.py +173 -0
- dot_man/exceptions.py +255 -0
- dot_man/files.py +443 -0
- dot_man/global_config.py +305 -0
- dot_man/hooks.py +232 -0
- dot_man/interactive.py +460 -0
- dot_man/lock.py +64 -0
- dot_man/merge.py +440 -0
- dot_man/operations.py +212 -0
- dot_man/py.typed +1 -0
- dot_man/save_deploy_ops.py +466 -0
- dot_man/secrets.py +473 -0
- dot_man/section.py +207 -0
- dot_man/status_ops.py +229 -0
- dot_man/tui_log.py +91 -0
- dot_man/ui.py +127 -0
- dot_man/utils.py +132 -0
- dot_man/vault.py +317 -0
- dotman_git-1.0.0.dist-info/METADATA +678 -0
- dotman_git-1.0.0.dist-info/RECORD +69 -0
- dotman_git-1.0.0.dist-info/WHEEL +5 -0
- dotman_git-1.0.0.dist-info/entry_points.txt +3 -0
- dotman_git-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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}")
|