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
dot_man/cli/common.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""Common utilities for dot-man CLI commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from .. import ui
|
|
14
|
+
from ..constants import DOT_MAN_DIR, REPO_DIR
|
|
15
|
+
from ..core import GitManager
|
|
16
|
+
from ..secrets import PermanentRedactGuard, SecretGuard, SecretMatch
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def error(message: str, exit_code: int = 1) -> None:
|
|
20
|
+
"""Print error message and exit."""
|
|
21
|
+
ui.error(message, exit_code)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def success(message: str) -> None:
|
|
25
|
+
"""Print success message."""
|
|
26
|
+
ui.success(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def warn(message: str) -> None:
|
|
30
|
+
"""Print warning message."""
|
|
31
|
+
ui.warn(message)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def handle_exception(exc: BaseException, context: str = "Operation") -> None:
|
|
35
|
+
"""Handle exceptions with user-friendly diagnostics.
|
|
36
|
+
|
|
37
|
+
Uses ErrorDiagnostic to categorize errors and provide helpful suggestions.
|
|
38
|
+
This is the centralized exception handler for all CLI commands.
|
|
39
|
+
"""
|
|
40
|
+
from ..exceptions import DotManError, ErrorDiagnostic
|
|
41
|
+
|
|
42
|
+
if isinstance(exc, DotManError):
|
|
43
|
+
error(str(exc), exc.exit_code)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
47
|
+
ui.console.print()
|
|
48
|
+
warn("Operation cancelled by user")
|
|
49
|
+
raise SystemExit(130)
|
|
50
|
+
|
|
51
|
+
diagnostic = ErrorDiagnostic.from_exception(exc) # type: ignore[arg-type]
|
|
52
|
+
ui.console.print()
|
|
53
|
+
ui.console.print(f"[red bold]{diagnostic.title}[/red bold]")
|
|
54
|
+
ui.console.print(f"[red]{diagnostic.details}[/red]")
|
|
55
|
+
ui.console.print()
|
|
56
|
+
ui.console.print(f"[dim]💡 {diagnostic.suggestion}[/dim]")
|
|
57
|
+
raise SystemExit(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DotManGroup(click.Group):
|
|
61
|
+
"""Custom Click Group to provide suggestions for typos."""
|
|
62
|
+
|
|
63
|
+
def get_command(self, ctx, cmd_name):
|
|
64
|
+
rv = click.Group.get_command(self, ctx, cmd_name)
|
|
65
|
+
if rv is not None:
|
|
66
|
+
return rv
|
|
67
|
+
|
|
68
|
+
matches = [cmd for cmd in self.list_commands(ctx)]
|
|
69
|
+
suggestion = ui.suggest_command(cmd_name, matches)
|
|
70
|
+
|
|
71
|
+
ui.error(f"Unknown command '{cmd_name}'", exit_code=0)
|
|
72
|
+
if suggestion:
|
|
73
|
+
ui.warn(f"Did you mean '{suggestion}'?")
|
|
74
|
+
|
|
75
|
+
ctx.exit(2)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def require_init(func):
|
|
79
|
+
"""Decorator to require initialization before running command."""
|
|
80
|
+
|
|
81
|
+
@wraps(func)
|
|
82
|
+
def wrapper(*args, **kwargs):
|
|
83
|
+
if not DOT_MAN_DIR.exists():
|
|
84
|
+
ui.console.print()
|
|
85
|
+
ui.print_banner("🎯 Welcome to dot-man!")
|
|
86
|
+
ui.console.print()
|
|
87
|
+
ui.console.print("[bold]The Dotfile Manager for Professionals[/bold]")
|
|
88
|
+
ui.console.print()
|
|
89
|
+
ui.console.print("[bold cyan]Get started:[/bold cyan]")
|
|
90
|
+
ui.console.print(
|
|
91
|
+
" [cyan]dot-man init[/cyan] - Initialize your dotfiles repository"
|
|
92
|
+
)
|
|
93
|
+
ui.console.print(
|
|
94
|
+
" [cyan]dot-man init --help[/cyan] - See all init options"
|
|
95
|
+
)
|
|
96
|
+
ui.console.print()
|
|
97
|
+
ui.console.print("[bold cyan]Quick overview:[/bold cyan]")
|
|
98
|
+
ui.console.print(
|
|
99
|
+
" [cyan]dot-man add <path>[/cyan] - Add files to track"
|
|
100
|
+
)
|
|
101
|
+
ui.console.print(
|
|
102
|
+
" [cyan]dot-man status[/cyan] - View tracked files"
|
|
103
|
+
)
|
|
104
|
+
ui.console.print(
|
|
105
|
+
" [cyan]dot-man navigate <branch>[/cyan] - Switch between configurations"
|
|
106
|
+
)
|
|
107
|
+
ui.console.print(
|
|
108
|
+
" [cyan]dot-man --help[/cyan] - See all commands"
|
|
109
|
+
)
|
|
110
|
+
ui.console.print()
|
|
111
|
+
ui.console.print("[dim]💡 Run 'dot-man init' to get started![/dim]")
|
|
112
|
+
ui.console.print()
|
|
113
|
+
raise SystemExit(1)
|
|
114
|
+
|
|
115
|
+
if not REPO_DIR.exists() or not (REPO_DIR / ".git").exists():
|
|
116
|
+
error("Repository not initialized. Run 'dot-man init' first.", exit_code=1)
|
|
117
|
+
|
|
118
|
+
return func(*args, **kwargs)
|
|
119
|
+
|
|
120
|
+
return wrapper
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_COMPLETION_CACHE_TTL = 10
|
|
124
|
+
_COMPLETION_CACHE_FILE = REPO_DIR / ".dotman" / "completion_cache.json"
|
|
125
|
+
|
|
126
|
+
_git_runner = None
|
|
127
|
+
_memory_cache: dict | None = None
|
|
128
|
+
_memory_cache_time: float = 0
|
|
129
|
+
_template_cache: list[str] | None = None
|
|
130
|
+
_config_keys_cache: list[str] | None = None
|
|
131
|
+
_profiles_cache: list[str] | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _set_git_runner(runner):
|
|
135
|
+
"""Set custom git runner for testing."""
|
|
136
|
+
global _git_runner
|
|
137
|
+
_git_runner = runner
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _run_git(args, cwd=REPO_DIR, timeout=2):
|
|
141
|
+
"""Run git command, using custom runner if set."""
|
|
142
|
+
if _git_runner is not None:
|
|
143
|
+
return _git_runner(args, cwd, timeout)
|
|
144
|
+
result = subprocess.run(
|
|
145
|
+
args,
|
|
146
|
+
cwd=cwd,
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True,
|
|
149
|
+
timeout=timeout,
|
|
150
|
+
)
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _get_completion_cache() -> dict:
|
|
155
|
+
"""Load completion cache from memory or file."""
|
|
156
|
+
global _memory_cache, _memory_cache_time
|
|
157
|
+
|
|
158
|
+
if (
|
|
159
|
+
_memory_cache is not None
|
|
160
|
+
and time.time() - _memory_cache_time < _COMPLETION_CACHE_TTL
|
|
161
|
+
):
|
|
162
|
+
return _memory_cache
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
if not REPO_DIR.exists():
|
|
166
|
+
_memory_cache = {}
|
|
167
|
+
_memory_cache_time = time.time()
|
|
168
|
+
return _memory_cache
|
|
169
|
+
if _COMPLETION_CACHE_FILE.exists():
|
|
170
|
+
mtime = os.path.getmtime(_COMPLETION_CACHE_FILE)
|
|
171
|
+
if time.time() - mtime < _COMPLETION_CACHE_TTL:
|
|
172
|
+
_memory_cache = json.loads(_COMPLETION_CACHE_FILE.read_text())
|
|
173
|
+
_memory_cache_time = time.time()
|
|
174
|
+
return _memory_cache
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
_memory_cache = {}
|
|
179
|
+
_memory_cache_time = time.time()
|
|
180
|
+
return _memory_cache
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _save_completion_cache(data: dict) -> None:
|
|
184
|
+
"""Save completion cache to memory and file."""
|
|
185
|
+
global _memory_cache, _memory_cache_time
|
|
186
|
+
_memory_cache = data
|
|
187
|
+
_memory_cache_time = time.time()
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
if not REPO_DIR.exists():
|
|
191
|
+
return
|
|
192
|
+
_COMPLETION_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
_COMPLETION_CACHE_FILE.write_text(json.dumps(data))
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _clear_completion_cache() -> None:
|
|
199
|
+
"""Clear completion cache for testing."""
|
|
200
|
+
global _memory_cache, _memory_cache_time
|
|
201
|
+
_memory_cache = None
|
|
202
|
+
_memory_cache_time = 0
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
if _COMPLETION_CACHE_FILE.exists():
|
|
206
|
+
_COMPLETION_CACHE_FILE.unlink()
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _clear_all_caches() -> None:
|
|
212
|
+
"""Clear all completion caches including in-memory."""
|
|
213
|
+
global _memory_cache, _memory_cache_time
|
|
214
|
+
global _template_cache, _config_keys_cache, _profiles_cache
|
|
215
|
+
|
|
216
|
+
_memory_cache = None
|
|
217
|
+
_memory_cache_time = 0
|
|
218
|
+
_template_cache = None
|
|
219
|
+
_config_keys_cache = None
|
|
220
|
+
_profiles_cache = None
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
if _COMPLETION_CACHE_FILE.exists():
|
|
224
|
+
_COMPLETION_CACHE_FILE.unlink()
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def parse_branch_arg(arg: str) -> dict:
|
|
230
|
+
"""Parse branch argument with @tag or commit SHA support.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
arg: Branch string like "work", "work@tag", or "abc1234"
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
dict with keys: type (branch|tag|commit), base, target
|
|
237
|
+
"""
|
|
238
|
+
match = re.match(r"^(.+)@(.+)$", arg)
|
|
239
|
+
if match:
|
|
240
|
+
base = match.group(1)
|
|
241
|
+
target = match.group(2)
|
|
242
|
+
|
|
243
|
+
if not base:
|
|
244
|
+
base = "HEAD"
|
|
245
|
+
|
|
246
|
+
if re.match(r"^[a-f0-9]{7,40}$", target):
|
|
247
|
+
return {"type": "commit", "base": base, "target": target}
|
|
248
|
+
|
|
249
|
+
return {"type": "tag", "base": base, "target": target}
|
|
250
|
+
|
|
251
|
+
if re.match(r"^[a-f0-9]{7,40}$", arg):
|
|
252
|
+
return {"type": "commit", "base": "HEAD", "target": arg}
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
git = GitManager()
|
|
256
|
+
if arg in git.list_tags():
|
|
257
|
+
return {"type": "tag", "base": "HEAD", "target": arg}
|
|
258
|
+
except Exception as e:
|
|
259
|
+
import logging
|
|
260
|
+
|
|
261
|
+
logging.debug(f"Could not check tags: {e}")
|
|
262
|
+
|
|
263
|
+
return {"type": "branch", "base": arg, "target": arg}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def complete_switch_args(ctx, param, incomplete):
|
|
267
|
+
"""Shell completion callback for switch (branches, tags, commits).
|
|
268
|
+
|
|
269
|
+
Returns tuples (value, description) for shell completion.
|
|
270
|
+
Order: branches first, then tags, then commits (git checkout style).
|
|
271
|
+
"""
|
|
272
|
+
try:
|
|
273
|
+
return _complete_navigate_items(incomplete)
|
|
274
|
+
except Exception as e:
|
|
275
|
+
import logging
|
|
276
|
+
|
|
277
|
+
logging.debug(f"Completion error: {e}")
|
|
278
|
+
return []
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _complete_navigate_items(
|
|
282
|
+
incomplete: str,
|
|
283
|
+
) -> "list[click.shell_completion.CompletionItem]":
|
|
284
|
+
"""Get completion items for navigate command with context.
|
|
285
|
+
|
|
286
|
+
Uses cache and lightweight git commands for performance.
|
|
287
|
+
Order: branches -> tags -> commits (like git checkout)
|
|
288
|
+
"""
|
|
289
|
+
from click.shell_completion import CompletionItem
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
cache = _get_completion_cache()
|
|
293
|
+
items: list[CompletionItem] = []
|
|
294
|
+
|
|
295
|
+
if "branches" not in cache or "current_branch" not in cache:
|
|
296
|
+
result = _run_git(["git", "branch", "--list", "--format=%(refname:short)"])
|
|
297
|
+
branches = [
|
|
298
|
+
b.strip() for b in result.stdout.strip().split("\n") if b.strip()
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
result = _run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
|
302
|
+
current_branch = result.stdout.strip() or "HEAD"
|
|
303
|
+
|
|
304
|
+
cache["branches"] = branches
|
|
305
|
+
cache["current_branch"] = current_branch
|
|
306
|
+
else:
|
|
307
|
+
branches = cache["branches"]
|
|
308
|
+
current_branch = cache["current_branch"]
|
|
309
|
+
|
|
310
|
+
branch_items: list[CompletionItem] = []
|
|
311
|
+
other_branches: list[CompletionItem] = []
|
|
312
|
+
for b in branches:
|
|
313
|
+
if b.startswith(incomplete):
|
|
314
|
+
if b == current_branch:
|
|
315
|
+
branch_items.append(CompletionItem(b, help="current branch"))
|
|
316
|
+
else:
|
|
317
|
+
other_branches.append(CompletionItem(b, help="branch"))
|
|
318
|
+
|
|
319
|
+
other_branches.sort(key=lambda x: x.value.lower())
|
|
320
|
+
items.extend(branch_items)
|
|
321
|
+
items.extend(other_branches)
|
|
322
|
+
|
|
323
|
+
if "tags" not in cache:
|
|
324
|
+
result = _run_git(["git", "tag", "-l"])
|
|
325
|
+
tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
|
|
326
|
+
cache["tags"] = tags
|
|
327
|
+
else:
|
|
328
|
+
tags = cache["tags"]
|
|
329
|
+
|
|
330
|
+
tag_items: list[CompletionItem] = []
|
|
331
|
+
for t in tags:
|
|
332
|
+
if t.startswith(incomplete):
|
|
333
|
+
result = _run_git(["git", "rev-parse", f"{t}^{{commit}}"])
|
|
334
|
+
commit_hash = (
|
|
335
|
+
result.stdout.strip()[:7] if result.returncode == 0 else ""
|
|
336
|
+
)
|
|
337
|
+
tag_items.append(CompletionItem(t, help=f"tag → {commit_hash}"))
|
|
338
|
+
|
|
339
|
+
tag_items.sort(key=lambda x: x.value.lower())
|
|
340
|
+
items.extend(tag_items)
|
|
341
|
+
|
|
342
|
+
if "commits" not in cache:
|
|
343
|
+
result = _run_git(
|
|
344
|
+
["git", "log", "--oneline", "-n", "20", "--format=%H %s"], timeout=3
|
|
345
|
+
)
|
|
346
|
+
commits = []
|
|
347
|
+
for line in result.stdout.strip().split("\n"):
|
|
348
|
+
if line:
|
|
349
|
+
parts = line.split(" ", 1)
|
|
350
|
+
if len(parts) == 2:
|
|
351
|
+
commits.append({"sha": parts[0][:7], "message": parts[1][:30]})
|
|
352
|
+
cache["commits"] = commits
|
|
353
|
+
else:
|
|
354
|
+
commits = cache["commits"]
|
|
355
|
+
|
|
356
|
+
commit_items: list[CompletionItem] = []
|
|
357
|
+
for c in commits:
|
|
358
|
+
if c["sha"].startswith(incomplete):
|
|
359
|
+
commit_items.append(CompletionItem(c["sha"], help=f"{c['message']}..."))
|
|
360
|
+
|
|
361
|
+
items.extend(commit_items)
|
|
362
|
+
|
|
363
|
+
if "@" in incomplete:
|
|
364
|
+
parts = incomplete.split("@", 1)
|
|
365
|
+
if parts[0] in branches:
|
|
366
|
+
for t in tags:
|
|
367
|
+
if t.startswith(parts[1] if len(parts) > 1 else ""):
|
|
368
|
+
result = _run_git(["git", "rev-parse", f"{t}^{{commit}}"])
|
|
369
|
+
commit_hash = (
|
|
370
|
+
result.stdout.strip()[:7] if result.returncode == 0 else ""
|
|
371
|
+
)
|
|
372
|
+
items.append(
|
|
373
|
+
CompletionItem(
|
|
374
|
+
f"{parts[0]}@{t}", help=f"tag at {commit_hash}"
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
_save_completion_cache(cache)
|
|
379
|
+
return items
|
|
380
|
+
except Exception:
|
|
381
|
+
return []
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def complete_branches(ctx, param, incomplete):
|
|
385
|
+
"""Shell completion callback for branches."""
|
|
386
|
+
try:
|
|
387
|
+
cache = _get_completion_cache()
|
|
388
|
+
if "branches" not in cache:
|
|
389
|
+
result = _run_git(["git", "branch", "--list", "--format=%(refname:short)"])
|
|
390
|
+
branches = [
|
|
391
|
+
b.strip() for b in result.stdout.strip().split("\n") if b.strip()
|
|
392
|
+
]
|
|
393
|
+
cache["branches"] = branches
|
|
394
|
+
_save_completion_cache(cache)
|
|
395
|
+
else:
|
|
396
|
+
branches = cache["branches"]
|
|
397
|
+
return [b for b in branches if b.startswith(incomplete)]
|
|
398
|
+
except Exception:
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def complete_tags(ctx, param, incomplete):
|
|
403
|
+
"""Shell completion callback for tags."""
|
|
404
|
+
try:
|
|
405
|
+
cache = _get_completion_cache()
|
|
406
|
+
if "tags" not in cache:
|
|
407
|
+
result = _run_git(["git", "tag", "-l"])
|
|
408
|
+
tags = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
|
|
409
|
+
cache["tags"] = tags
|
|
410
|
+
_save_completion_cache(cache)
|
|
411
|
+
else:
|
|
412
|
+
tags = cache["tags"]
|
|
413
|
+
return [t for t in tags if t.startswith(incomplete)]
|
|
414
|
+
except Exception:
|
|
415
|
+
return []
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def complete_commits(ctx, param, incomplete):
|
|
419
|
+
"""Shell completion callback for commits."""
|
|
420
|
+
try:
|
|
421
|
+
cache = _get_completion_cache()
|
|
422
|
+
if "commits_all" not in cache:
|
|
423
|
+
result = _run_git(
|
|
424
|
+
["git", "log", "--oneline", "-n", "50", "--format=%h"], timeout=3
|
|
425
|
+
)
|
|
426
|
+
commits = [
|
|
427
|
+
c.strip() for c in result.stdout.strip().split("\n") if c.strip()
|
|
428
|
+
]
|
|
429
|
+
cache["commits_all"] = commits
|
|
430
|
+
_save_completion_cache(cache)
|
|
431
|
+
else:
|
|
432
|
+
commits = cache["commits_all"]
|
|
433
|
+
return [c for c in commits if c.startswith(incomplete)]
|
|
434
|
+
except Exception:
|
|
435
|
+
return []
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def complete_template_keys(ctx, param, incomplete):
|
|
439
|
+
"""Shell completion callback for template keys."""
|
|
440
|
+
global _template_cache
|
|
441
|
+
|
|
442
|
+
if _template_cache is not None:
|
|
443
|
+
return [k for k in _template_cache if k.startswith(incomplete)]
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
from ..global_config import GlobalConfig
|
|
447
|
+
|
|
448
|
+
gc = GlobalConfig()
|
|
449
|
+
templates = gc.get_all_templates()
|
|
450
|
+
_template_cache = list(templates.keys())
|
|
451
|
+
return [k for k in _template_cache if k.startswith(incomplete)]
|
|
452
|
+
except Exception:
|
|
453
|
+
return []
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def complete_config_keys(ctx, param, incomplete):
|
|
457
|
+
"""Shell completion callback for config keys."""
|
|
458
|
+
global _config_keys_cache
|
|
459
|
+
|
|
460
|
+
if _config_keys_cache is not None:
|
|
461
|
+
return [k for k in _config_keys_cache if k.startswith(incomplete)]
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
keys = [
|
|
465
|
+
"dot-man.current_branch",
|
|
466
|
+
"remote.url",
|
|
467
|
+
"security.strict_mode",
|
|
468
|
+
"switch.default_behavior",
|
|
469
|
+
"secrets_filter_enabled",
|
|
470
|
+
]
|
|
471
|
+
_config_keys_cache = keys
|
|
472
|
+
return [k for k in keys if k.startswith(incomplete)]
|
|
473
|
+
except Exception:
|
|
474
|
+
return []
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def complete_profiles(ctx, param, incomplete):
|
|
478
|
+
"""Shell completion callback for profiles."""
|
|
479
|
+
global _profiles_cache
|
|
480
|
+
|
|
481
|
+
if _profiles_cache is not None:
|
|
482
|
+
return [k for k in _profiles_cache if k.startswith(incomplete)]
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
from ..global_config import GlobalConfig
|
|
486
|
+
|
|
487
|
+
gc = GlobalConfig()
|
|
488
|
+
profiles = gc._data.get("profiles", {})
|
|
489
|
+
_profiles_cache = list(profiles.keys())
|
|
490
|
+
return [k for k in _profiles_cache if k.startswith(incomplete)]
|
|
491
|
+
except Exception:
|
|
492
|
+
return []
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def get_secret_handler() -> Callable[[SecretMatch], str]:
|
|
496
|
+
"""Get a secret handler that prompts the user for action."""
|
|
497
|
+
guard = SecretGuard()
|
|
498
|
+
permanent_guard = PermanentRedactGuard()
|
|
499
|
+
|
|
500
|
+
def handle_secret(match: SecretMatch) -> str:
|
|
501
|
+
if permanent_guard.should_redact(
|
|
502
|
+
match.file, match.line_content, match.pattern_name
|
|
503
|
+
):
|
|
504
|
+
return "REDACT"
|
|
505
|
+
|
|
506
|
+
if guard.is_allowed(match.file, match.line_content, match.pattern_name):
|
|
507
|
+
return "IGNORE"
|
|
508
|
+
|
|
509
|
+
ui.console.print()
|
|
510
|
+
ui.warn("Potential secret detected!")
|
|
511
|
+
ui.console.print(f"File: [cyan]{match.file}[/cyan]")
|
|
512
|
+
ui.console.print(f"Line {match.line_number}: {match.line_content[:80]}...")
|
|
513
|
+
ui.console.print(
|
|
514
|
+
f"Pattern: {match.pattern_name} (severity: {match.severity.value})"
|
|
515
|
+
)
|
|
516
|
+
ui.console.print()
|
|
517
|
+
|
|
518
|
+
ui.console.print("Choose how to handle this secret:")
|
|
519
|
+
ui.console.print(" 1. [dim]Ignore (skip it this time)[/dim]")
|
|
520
|
+
ui.console.print(
|
|
521
|
+
" 2. [yellow]Protect (replace with ***REDACTED*** this time)[/yellow]"
|
|
522
|
+
)
|
|
523
|
+
ui.console.print(
|
|
524
|
+
" 3. [blue]Add to skip list (skip this line every time)[/blue]"
|
|
525
|
+
)
|
|
526
|
+
ui.console.print(" 4. [red]Protect forever (always replace in repo)[/red]")
|
|
527
|
+
ui.console.print()
|
|
528
|
+
|
|
529
|
+
choices = ["1", "2", "3", "4"]
|
|
530
|
+
choice = ui.ask("Enter choice", choices=choices, default="2")
|
|
531
|
+
|
|
532
|
+
if choice == "1":
|
|
533
|
+
return "IGNORE"
|
|
534
|
+
elif choice == "2":
|
|
535
|
+
return "REDACT"
|
|
536
|
+
elif choice == "3":
|
|
537
|
+
guard.add_allowed(match.file, match.line_content, match.pattern_name)
|
|
538
|
+
ui.info("Added to skip list.")
|
|
539
|
+
return "IGNORE"
|
|
540
|
+
elif choice == "4":
|
|
541
|
+
permanent_guard.add_permanent_redact(
|
|
542
|
+
match.file, match.line_content, match.pattern_name
|
|
543
|
+
)
|
|
544
|
+
ui.warn("Will always redact this secret.")
|
|
545
|
+
return "REDACT"
|
|
546
|
+
return "REDACT"
|
|
547
|
+
|
|
548
|
+
return handle_secret
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Completions command for dot-man CLI."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .interface import cli as main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@main.command("completions")
|
|
12
|
+
@click.option(
|
|
13
|
+
"--shell",
|
|
14
|
+
type=click.Choice(["bash", "zsh", "fish", "all"]),
|
|
15
|
+
default="all",
|
|
16
|
+
help="Shell to install completions for",
|
|
17
|
+
)
|
|
18
|
+
@click.option(
|
|
19
|
+
"--source-only",
|
|
20
|
+
is_flag=True,
|
|
21
|
+
help="Only print source command, don't install",
|
|
22
|
+
)
|
|
23
|
+
def completions(shell: str, source_only: bool):
|
|
24
|
+
"""Install or show shell completions for dot-man.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
dot-man completions # Install all completions
|
|
28
|
+
dot-man completions --shell bash # Install bash only
|
|
29
|
+
dot-man completions --source-only # Show source commands
|
|
30
|
+
"""
|
|
31
|
+
home = Path.home()
|
|
32
|
+
completions_dir = home / ".local" / "share" / "bash-completion" / "completions"
|
|
33
|
+
zsh_compdir = home / ".local" / "share" / "zsh" / "site-functions"
|
|
34
|
+
fish_compdir = home / ".config" / "fish" / "completions"
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from dot_man import completions as completions_pkg
|
|
38
|
+
except ImportError:
|
|
39
|
+
click.echo("Error: Completions not found. Is dot-man installed?", err=True)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
completions_path = Path(completions_pkg.__file__).parent
|
|
43
|
+
|
|
44
|
+
def do_bash():
|
|
45
|
+
if shell not in ("bash", "all"):
|
|
46
|
+
return
|
|
47
|
+
bash_src = completions_path / "dot-man.bash"
|
|
48
|
+
bash_dest = completions_dir / "dot-man"
|
|
49
|
+
if source_only:
|
|
50
|
+
click.echo("# Add to ~/.bashrc or ~/.profile:")
|
|
51
|
+
click.echo(f"source {bash_src}")
|
|
52
|
+
else:
|
|
53
|
+
completions_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
shutil.copy(bash_src, bash_dest)
|
|
55
|
+
click.echo(f"✓ Installed bash completion to {bash_dest}")
|
|
56
|
+
|
|
57
|
+
def do_zsh():
|
|
58
|
+
if shell not in ("zsh", "all"):
|
|
59
|
+
return
|
|
60
|
+
zsh_src = completions_path / "_dot-man.zsh"
|
|
61
|
+
zsh_dest = zsh_compdir / "_dot-man"
|
|
62
|
+
if source_only:
|
|
63
|
+
click.echo("# Add to ~/.zshrc:")
|
|
64
|
+
click.echo(f"source {zsh_src}")
|
|
65
|
+
else:
|
|
66
|
+
zsh_compdir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
shutil.copy(zsh_src, zsh_dest)
|
|
68
|
+
click.echo(f"✓ Installed zsh completion to {zsh_dest}")
|
|
69
|
+
|
|
70
|
+
def do_fish():
|
|
71
|
+
if shell not in ("fish", "all"):
|
|
72
|
+
return
|
|
73
|
+
fish_src = completions_path / "dot-man.fish"
|
|
74
|
+
fish_dest = fish_compdir / "dot-man.fish"
|
|
75
|
+
if source_only:
|
|
76
|
+
click.echo("# Add to ~/.config/fish/config.fish:")
|
|
77
|
+
click.echo(f"source {fish_src}")
|
|
78
|
+
else:
|
|
79
|
+
fish_compdir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
shutil.copy(fish_src, fish_dest)
|
|
81
|
+
click.echo(f"✓ Installed fish completion to {fish_dest}")
|
|
82
|
+
|
|
83
|
+
do_bash()
|
|
84
|
+
do_zsh()
|
|
85
|
+
do_fish()
|
|
86
|
+
|
|
87
|
+
if not source_only:
|
|
88
|
+
click.echo(
|
|
89
|
+
"\nRestart your shell or source your shell config to enable completions."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_install() -> None:
|
|
94
|
+
"""Entry point for pip install hook - silently installs completions."""
|
|
95
|
+
try:
|
|
96
|
+
from dot_man import completions as completions_pkg
|
|
97
|
+
except ImportError:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
home = Path.home()
|
|
101
|
+
completions_path = Path(completions_pkg.__file__).parent
|
|
102
|
+
|
|
103
|
+
# Bash
|
|
104
|
+
bash_dest = (
|
|
105
|
+
home / ".local" / "share" / "bash-completion" / "completions" / "dot-man"
|
|
106
|
+
)
|
|
107
|
+
if not bash_dest.exists():
|
|
108
|
+
bash_src = completions_path / "dot-man.bash"
|
|
109
|
+
if bash_src.exists():
|
|
110
|
+
bash_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
shutil.copy(bash_src, bash_dest)
|
|
112
|
+
|
|
113
|
+
# Zsh
|
|
114
|
+
zsh_dest = home / ".local" / "share" / "zsh" / "site-functions" / "_dot-man"
|
|
115
|
+
if not zsh_dest.exists():
|
|
116
|
+
zsh_src = completions_path / "_dot-man.zsh"
|
|
117
|
+
if zsh_src.exists():
|
|
118
|
+
zsh_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
shutil.copy(zsh_src, zsh_dest)
|
|
120
|
+
|
|
121
|
+
# Fish
|
|
122
|
+
fish_dest = home / ".config" / "fish" / "completions" / "dot-man.fish"
|
|
123
|
+
if not fish_dest.exists():
|
|
124
|
+
fish_src = completions_path / "dot-man.fish"
|
|
125
|
+
if fish_src.exists():
|
|
126
|
+
fish_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
shutil.copy(fish_src, fish_dest)
|