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/add_cmd.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Add command for dot-man CLI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .. import ui
|
|
8
|
+
from ..config import DotManConfig, GlobalConfig
|
|
9
|
+
from ..constants import REPO_DIR
|
|
10
|
+
from ..exceptions import DotManError
|
|
11
|
+
from ..files import copy_directory, copy_file
|
|
12
|
+
from .common import error, get_secret_handler, require_init, success, warn
|
|
13
|
+
from .interface import cli as main
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@main.command()
|
|
17
|
+
@click.argument("path", type=click.Path(exists=True))
|
|
18
|
+
@click.option(
|
|
19
|
+
"--section", "-s", help="Section name (default: auto-generated from path)"
|
|
20
|
+
)
|
|
21
|
+
@click.option(
|
|
22
|
+
"--repo-base", "-r", help="Base directory in repo (default: section name)"
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"--exclude",
|
|
26
|
+
"-e",
|
|
27
|
+
multiple=True,
|
|
28
|
+
help="Patterns to exclude (can be specified multiple times)",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--include",
|
|
32
|
+
"-i",
|
|
33
|
+
multiple=True,
|
|
34
|
+
help="Patterns to include (can be specified multiple times)",
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--inherits",
|
|
38
|
+
"-t",
|
|
39
|
+
multiple=True,
|
|
40
|
+
help="Templates to inherit from (can be specified multiple times)",
|
|
41
|
+
)
|
|
42
|
+
@click.option("--post-deploy", help="Command to run after deploying")
|
|
43
|
+
@click.option("--pre-deploy", help="Command to run before deploying")
|
|
44
|
+
@require_init
|
|
45
|
+
def add(
|
|
46
|
+
path: str,
|
|
47
|
+
section: str | None,
|
|
48
|
+
repo_base: str | None,
|
|
49
|
+
exclude: tuple,
|
|
50
|
+
include: tuple,
|
|
51
|
+
inherits: tuple,
|
|
52
|
+
post_deploy: str | None,
|
|
53
|
+
pre_deploy: str | None,
|
|
54
|
+
):
|
|
55
|
+
"""Add a file or directory to be tracked.
|
|
56
|
+
|
|
57
|
+
Adds the specified path to the dot-man.toml configuration and copies
|
|
58
|
+
the content to the repository.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
dot-man add ~/.bashrc
|
|
62
|
+
dot-man add ~/.config/fish --section fish --exclude "*.log"
|
|
63
|
+
dot-man add ~/.config/hypr --inherits linux-gui --post-deploy "hyprctl reload"
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
local_path = Path(path).expanduser().resolve()
|
|
67
|
+
|
|
68
|
+
# Auto-generate section name if not provided
|
|
69
|
+
if not section:
|
|
70
|
+
if local_path.is_dir() and str(local_path).startswith(
|
|
71
|
+
str(Path.home() / ".config")
|
|
72
|
+
):
|
|
73
|
+
section = local_path.name
|
|
74
|
+
else:
|
|
75
|
+
section = local_path.stem or local_path.name
|
|
76
|
+
|
|
77
|
+
repo_base = repo_base or section
|
|
78
|
+
|
|
79
|
+
# Load config
|
|
80
|
+
global_config = GlobalConfig()
|
|
81
|
+
global_config.load()
|
|
82
|
+
|
|
83
|
+
dotman_config = DotManConfig(global_config=global_config)
|
|
84
|
+
try:
|
|
85
|
+
dotman_config.load()
|
|
86
|
+
except (FileNotFoundError, DotManError):
|
|
87
|
+
dotman_config.create_default()
|
|
88
|
+
dotman_config.load()
|
|
89
|
+
|
|
90
|
+
# Check for duplicates
|
|
91
|
+
existing_sections = dotman_config.get_section_names()
|
|
92
|
+
if section in existing_sections:
|
|
93
|
+
error(
|
|
94
|
+
f"Section '{section}' already exists. Use a different --section name."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Convert to home-relative path for config
|
|
98
|
+
path_str = str(local_path)
|
|
99
|
+
home = str(Path.home())
|
|
100
|
+
if path_str.startswith(home):
|
|
101
|
+
path_str = path_str.replace(home, "~", 1)
|
|
102
|
+
|
|
103
|
+
# Add section to config
|
|
104
|
+
dotman_config.add_section(
|
|
105
|
+
name=section,
|
|
106
|
+
paths=[path_str],
|
|
107
|
+
repo_base=repo_base,
|
|
108
|
+
exclude=list(exclude) if exclude else None,
|
|
109
|
+
include=list(include) if include else None,
|
|
110
|
+
inherits=list(inherits) if inherits else None,
|
|
111
|
+
post_deploy=post_deploy,
|
|
112
|
+
pre_deploy=pre_deploy,
|
|
113
|
+
)
|
|
114
|
+
dotman_config.save()
|
|
115
|
+
|
|
116
|
+
# Copy content to repo
|
|
117
|
+
repo_dest = REPO_DIR / repo_base
|
|
118
|
+
|
|
119
|
+
if local_path.is_file():
|
|
120
|
+
repo_dest = repo_dest / local_path.name
|
|
121
|
+
try:
|
|
122
|
+
secret_handler = get_secret_handler()
|
|
123
|
+
success_copy, secrets = copy_file(
|
|
124
|
+
local_path,
|
|
125
|
+
repo_dest,
|
|
126
|
+
filter_secrets_enabled=True,
|
|
127
|
+
secret_handler=secret_handler,
|
|
128
|
+
)
|
|
129
|
+
if success_copy:
|
|
130
|
+
success(f"Added file: {local_path}")
|
|
131
|
+
ui.console.print(f" Section: [cyan][{section}][/cyan]")
|
|
132
|
+
ui.console.print(f" Repo path: [dim]{repo_dest}[/dim]")
|
|
133
|
+
if secrets:
|
|
134
|
+
warn(f"{len(secrets)} secrets were redacted")
|
|
135
|
+
else:
|
|
136
|
+
error(f"Failed to copy file: {local_path}")
|
|
137
|
+
except (FileNotFoundError, OSError) as e:
|
|
138
|
+
error(f"Failed to access file {local_path}: {e}")
|
|
139
|
+
except Exception as e:
|
|
140
|
+
error(f"Error copying file {local_path}: {e}")
|
|
141
|
+
else:
|
|
142
|
+
secret_handler = get_secret_handler()
|
|
143
|
+
copied, failed, secrets = copy_directory(
|
|
144
|
+
local_path,
|
|
145
|
+
repo_dest,
|
|
146
|
+
filter_secrets_enabled=True,
|
|
147
|
+
exclude_patterns=list(exclude) if exclude else None,
|
|
148
|
+
include_patterns=list(include) if include else None,
|
|
149
|
+
secret_handler=secret_handler,
|
|
150
|
+
)
|
|
151
|
+
success(f"Added directory: {local_path}")
|
|
152
|
+
ui.console.print(f" Section: [cyan][{section}][/cyan]")
|
|
153
|
+
ui.console.print(f" Repo path: [dim]{repo_dest}[/dim]")
|
|
154
|
+
ui.console.print(f" Files: {copied} copied, {failed} failed")
|
|
155
|
+
if secrets:
|
|
156
|
+
warn(f"{len(secrets)} secrets were redacted")
|
|
157
|
+
|
|
158
|
+
if inherits:
|
|
159
|
+
ui.console.print(f" Inherits: {', '.join(inherits)}")
|
|
160
|
+
|
|
161
|
+
ui.console.print()
|
|
162
|
+
ui.console.print("[dim]Run 'dot-man switch <branch>' to commit changes.[/dim]")
|
|
163
|
+
|
|
164
|
+
except DotManError as e:
|
|
165
|
+
error(str(e), e.exit_code)
|
|
166
|
+
except Exception as e:
|
|
167
|
+
error(f"Failed to add: {e}")
|
dot_man/cli/audit_cmd.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Audit command for dot-man CLI."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from .. import ui
|
|
6
|
+
from ..constants import REPO_DIR
|
|
7
|
+
from ..core import GitManager
|
|
8
|
+
from ..exceptions import DotManError
|
|
9
|
+
from ..secrets import PermanentRedactGuard, SecretGuard, SecretMatch, SecretScanner
|
|
10
|
+
from .common import error, handle_exception, require_init, success
|
|
11
|
+
from .interface import cli as main
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@main.command()
|
|
15
|
+
@click.option(
|
|
16
|
+
"--strict", is_flag=True, help="Exit with error if secrets found (for CI/CD)"
|
|
17
|
+
)
|
|
18
|
+
@click.option("--fix", is_flag=True, help="Automatically redact found secrets")
|
|
19
|
+
@require_init
|
|
20
|
+
def audit(strict: bool, fix: bool):
|
|
21
|
+
"""Scan repository for accidentally committed secrets.
|
|
22
|
+
|
|
23
|
+
Scans all files in the repository for API keys, passwords,
|
|
24
|
+
private keys, and other sensitive data.
|
|
25
|
+
|
|
26
|
+
Use --strict in CI/CD pipelines to fail builds if secrets are found.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
scanner = SecretScanner()
|
|
30
|
+
guard = SecretGuard()
|
|
31
|
+
permanent_guard = PermanentRedactGuard()
|
|
32
|
+
|
|
33
|
+
ui.console.print("🔒 [bold]Security Audit[/bold]")
|
|
34
|
+
ui.console.print()
|
|
35
|
+
ui.console.print(f"Scanning [cyan]{REPO_DIR}[/cyan]...")
|
|
36
|
+
ui.console.print()
|
|
37
|
+
|
|
38
|
+
all_matches = list(scanner.scan_directory(REPO_DIR))
|
|
39
|
+
|
|
40
|
+
# Filter out allowed or permanently redacted secrets
|
|
41
|
+
matches = [
|
|
42
|
+
match
|
|
43
|
+
for match in all_matches
|
|
44
|
+
if not guard.is_allowed(match.file, match.line_content, match.pattern_name)
|
|
45
|
+
and not permanent_guard.should_redact(
|
|
46
|
+
match.file, match.line_content, match.pattern_name
|
|
47
|
+
)
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
if not matches:
|
|
51
|
+
success("No secrets detected. Repository is clean!")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Group by severity
|
|
55
|
+
from typing import Dict, List
|
|
56
|
+
|
|
57
|
+
by_severity: Dict[str, List[SecretMatch]] = {}
|
|
58
|
+
for match in matches:
|
|
59
|
+
severity = match.severity.value
|
|
60
|
+
if severity not in by_severity:
|
|
61
|
+
by_severity[severity] = []
|
|
62
|
+
by_severity[severity].append(match)
|
|
63
|
+
|
|
64
|
+
# Display results
|
|
65
|
+
severity_order = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
|
|
66
|
+
severity_colors = {
|
|
67
|
+
"CRITICAL": "red",
|
|
68
|
+
"HIGH": "yellow",
|
|
69
|
+
"MEDIUM": "blue",
|
|
70
|
+
"LOW": "dim",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for severity in severity_order:
|
|
74
|
+
if severity not in by_severity:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
color = severity_colors[severity]
|
|
78
|
+
items = by_severity[severity]
|
|
79
|
+
|
|
80
|
+
ui.console.print(f"[{color}]{severity}[/{color}] ({len(items)} findings)")
|
|
81
|
+
ui.console.print("─" * 50)
|
|
82
|
+
|
|
83
|
+
for match in items:
|
|
84
|
+
rel_path = match.file.relative_to(REPO_DIR)
|
|
85
|
+
ui.console.print(f" File: [cyan]{rel_path}[/cyan]")
|
|
86
|
+
ui.console.print(
|
|
87
|
+
f" Line {match.line_number}: {match.line_content[:60]}..."
|
|
88
|
+
)
|
|
89
|
+
ui.console.print(f" Pattern: {match.pattern_name}")
|
|
90
|
+
ui.console.print()
|
|
91
|
+
|
|
92
|
+
# Summary
|
|
93
|
+
ui.console.print("─" * 50)
|
|
94
|
+
ui.console.print(
|
|
95
|
+
f"[bold]Total:[/bold] {len(matches)} secrets in {len(set(m.file for m in matches))} files"
|
|
96
|
+
)
|
|
97
|
+
ui.console.print()
|
|
98
|
+
|
|
99
|
+
# Recommendations
|
|
100
|
+
ui.console.print("[bold]Recommendations:[/bold]")
|
|
101
|
+
ui.console.print(
|
|
102
|
+
" 1. Enable [cyan]secrets_filter = true[/cyan] for affected files"
|
|
103
|
+
)
|
|
104
|
+
ui.console.print(" 2. Move credentials to environment variables")
|
|
105
|
+
ui.console.print(" 3. Run [cyan]dot-man audit --fix[/cyan] to auto-redact")
|
|
106
|
+
|
|
107
|
+
if fix:
|
|
108
|
+
ui.console.print()
|
|
109
|
+
if not ui.confirm("Auto-redact all detected secrets?"):
|
|
110
|
+
ui.info("Aborted.")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# Perform redaction
|
|
114
|
+
fixed_files = set()
|
|
115
|
+
for match in matches:
|
|
116
|
+
if match.file in fixed_files:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
content = match.file.read_text()
|
|
120
|
+
redacted, count = scanner.redact_content(content)
|
|
121
|
+
if count > 0:
|
|
122
|
+
match.file.write_text(redacted)
|
|
123
|
+
fixed_files.add(match.file)
|
|
124
|
+
ui.console.print(
|
|
125
|
+
f" [green]✓[/green] Redacted {count} secrets in {match.file.name}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Commit changes
|
|
129
|
+
git = GitManager()
|
|
130
|
+
git.commit("Security: Auto-redacted secrets detected by audit")
|
|
131
|
+
success(f"Redacted secrets in {len(fixed_files)} files")
|
|
132
|
+
|
|
133
|
+
if strict:
|
|
134
|
+
error("Secrets detected (strict mode)", exit_code=50)
|
|
135
|
+
|
|
136
|
+
except DotManError as e:
|
|
137
|
+
error(str(e), e.exit_code)
|
|
138
|
+
except KeyboardInterrupt:
|
|
139
|
+
handle_exception(KeyboardInterrupt())
|
|
140
|
+
except Exception as e:
|
|
141
|
+
handle_exception(e, "Audit")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Backup command for dot-man CLI."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from .. import ui
|
|
7
|
+
from .common import error, require_init, success
|
|
8
|
+
from .interface import cli as main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@main.group()
|
|
12
|
+
def backup():
|
|
13
|
+
"""Manage local safety backups."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@backup.command("create")
|
|
18
|
+
@click.argument("note", required=False, default="manual")
|
|
19
|
+
@require_init
|
|
20
|
+
def backup_create(note: str):
|
|
21
|
+
"""Create a manual backup snapshot.
|
|
22
|
+
|
|
23
|
+
Backups all currently tracked files to a local snapshot.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
from ..operations import get_operations
|
|
27
|
+
|
|
28
|
+
ops = get_operations()
|
|
29
|
+
|
|
30
|
+
# Collect all tracked files
|
|
31
|
+
paths_to_backup = []
|
|
32
|
+
for section_name in ops.get_sections():
|
|
33
|
+
section = ops.get_section(section_name)
|
|
34
|
+
paths_to_backup.extend([p for p in section.paths if p.exists()])
|
|
35
|
+
|
|
36
|
+
if not paths_to_backup:
|
|
37
|
+
ui.warn("No tracked files found to backup.")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
ui.console.print("[bold]Creating backup...[/bold]")
|
|
41
|
+
backup_id = ops.backups.create_backup(paths_to_backup, note=note)
|
|
42
|
+
|
|
43
|
+
if backup_id:
|
|
44
|
+
success(f"Backup created: [cyan]{backup_id}[/cyan]")
|
|
45
|
+
else:
|
|
46
|
+
ui.warn("Backup created but empty (no files found).")
|
|
47
|
+
|
|
48
|
+
except Exception as e:
|
|
49
|
+
error(f"Failed to create backup: {e}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@backup.command("list")
|
|
53
|
+
@require_init
|
|
54
|
+
def backup_list():
|
|
55
|
+
"""List available backups."""
|
|
56
|
+
try:
|
|
57
|
+
from ..operations import get_operations
|
|
58
|
+
|
|
59
|
+
ops = get_operations()
|
|
60
|
+
backups = ops.backups.list_backups()
|
|
61
|
+
|
|
62
|
+
if not backups:
|
|
63
|
+
ui.console.print("[dim]No backups found[/dim]")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
table = Table(title="Local Backups")
|
|
67
|
+
table.add_column("ID", style="cyan")
|
|
68
|
+
table.add_column("Date")
|
|
69
|
+
table.add_column("Note")
|
|
70
|
+
|
|
71
|
+
for b in backups:
|
|
72
|
+
table.add_row(b["id"], b["date"], b["note"])
|
|
73
|
+
|
|
74
|
+
ui.console.print(table)
|
|
75
|
+
|
|
76
|
+
except Exception as e:
|
|
77
|
+
error(f"Failed to list backups: {e}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@backup.command("restore")
|
|
81
|
+
@click.argument("backup_id")
|
|
82
|
+
@click.option("--force", is_flag=True, help="Skip confirmation")
|
|
83
|
+
@require_init
|
|
84
|
+
def backup_restore(backup_id: str, force: bool):
|
|
85
|
+
"""Restore files from a backup snapshot.
|
|
86
|
+
|
|
87
|
+
WARNING: This will overwrite current local files with the backup version.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
from ..operations import get_operations
|
|
91
|
+
|
|
92
|
+
ops = get_operations()
|
|
93
|
+
|
|
94
|
+
if not force:
|
|
95
|
+
if not ui.confirm(
|
|
96
|
+
f"Restore backup '{backup_id}'? This will overwrite local files."
|
|
97
|
+
):
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
ui.console.print(f"[bold]Restoring backup {backup_id}...[/bold]")
|
|
101
|
+
ops.backups.restore_backup(backup_id)
|
|
102
|
+
success("Backup restored successfully!")
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
error(f"Failed to restore backup: {e}")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Branch command for dot-man CLI."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
from .. import ui
|
|
7
|
+
from ..config import GlobalConfig
|
|
8
|
+
from ..core import GitManager
|
|
9
|
+
from ..exceptions import DotManError
|
|
10
|
+
from .common import complete_branches, error, handle_exception, require_init, success
|
|
11
|
+
from .interface import cli as main
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@main.group()
|
|
15
|
+
def branch():
|
|
16
|
+
"""Manage configuration branches."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@branch.command("list")
|
|
21
|
+
@require_init
|
|
22
|
+
def branch_list():
|
|
23
|
+
"""List all configuration branches."""
|
|
24
|
+
try:
|
|
25
|
+
git = GitManager()
|
|
26
|
+
global_config = GlobalConfig()
|
|
27
|
+
global_config.load()
|
|
28
|
+
|
|
29
|
+
current = global_config.current_branch
|
|
30
|
+
branches = git.list_branches()
|
|
31
|
+
|
|
32
|
+
if not branches:
|
|
33
|
+
ui.console.print("[dim]No branches found[/dim]")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
table = Table(title="Branches")
|
|
37
|
+
table.add_column("Branch")
|
|
38
|
+
table.add_column("Active")
|
|
39
|
+
|
|
40
|
+
for b in branches:
|
|
41
|
+
active = "[green]✓[/green]" if b == current else ""
|
|
42
|
+
style = "bold" if b == current else ""
|
|
43
|
+
table.add_row(f"[{style}]{b}[/{style}]" if style else b, active)
|
|
44
|
+
|
|
45
|
+
ui.console.print(table)
|
|
46
|
+
|
|
47
|
+
except KeyboardInterrupt:
|
|
48
|
+
handle_exception(KeyboardInterrupt())
|
|
49
|
+
except Exception as e:
|
|
50
|
+
handle_exception(e, "Branch list")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@branch.command("delete")
|
|
54
|
+
@click.argument("name", shell_complete=complete_branches)
|
|
55
|
+
@click.option("--force", "-f", is_flag=True, help="Force delete without confirmation")
|
|
56
|
+
@require_init
|
|
57
|
+
def branch_delete(name: str, force: bool):
|
|
58
|
+
"""Delete a configuration branch."""
|
|
59
|
+
try:
|
|
60
|
+
git = GitManager()
|
|
61
|
+
global_config = GlobalConfig()
|
|
62
|
+
global_config.load()
|
|
63
|
+
|
|
64
|
+
if name == global_config.current_branch:
|
|
65
|
+
branches = git.list_branches()
|
|
66
|
+
available = [b for b in branches if b != name]
|
|
67
|
+
error(
|
|
68
|
+
f"Cannot delete the active branch '{name}'.\n"
|
|
69
|
+
f" 💡 Switch to another branch first: dot-man navigate <branch>\n"
|
|
70
|
+
f" Available branches: {', '.join(available) if available else '(none)'}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if not git.branch_exists(name):
|
|
74
|
+
error(f"Branch '{name}' not found")
|
|
75
|
+
|
|
76
|
+
if not force:
|
|
77
|
+
if not ui.confirm(f"Delete branch '{name}'? This cannot be undone"):
|
|
78
|
+
ui.info("Aborted.")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
git.delete_branch(name, force=force)
|
|
82
|
+
success(f"Deleted branch '{name}'")
|
|
83
|
+
|
|
84
|
+
except DotManError as e:
|
|
85
|
+
from ..exceptions import BranchNotMergedError
|
|
86
|
+
|
|
87
|
+
if isinstance(e, BranchNotMergedError):
|
|
88
|
+
if ui.confirm(f"Branch '{name}' is not fully merged. Force delete?"):
|
|
89
|
+
try:
|
|
90
|
+
git.delete_branch(name, force=True) # type: ignore
|
|
91
|
+
success(f"Deleted branch '{name}'")
|
|
92
|
+
return
|
|
93
|
+
except Exception as e2:
|
|
94
|
+
error(f"Failed to force delete: {e2}")
|
|
95
|
+
else:
|
|
96
|
+
ui.info("Aborted.")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
error(str(e), e.exit_code)
|
|
100
|
+
except KeyboardInterrupt:
|
|
101
|
+
handle_exception(KeyboardInterrupt())
|
|
102
|
+
except Exception as e:
|
|
103
|
+
handle_exception(e, "Branch delete")
|
dot_man/cli/clean_cmd.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Clean command for dot-man CLI."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from .. import ui
|
|
6
|
+
from ..constants import REPO_DIR
|
|
7
|
+
from .common import error, require_init, success
|
|
8
|
+
from .interface import cli as main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@main.command("clean")
|
|
12
|
+
@click.option("--backups", is_flag=True, help="Clean old backups")
|
|
13
|
+
@click.option("--orphans", is_flag=True, help="Clean orphaned files from repo")
|
|
14
|
+
@click.option("--all", "clean_all", is_flag=True, help="Clean both backups and orphans")
|
|
15
|
+
@click.option(
|
|
16
|
+
"--keep", type=int, default=0, help="Number of backups to keep (default 0)"
|
|
17
|
+
)
|
|
18
|
+
@click.option("--force", is_flag=True, help="Skip confirmation")
|
|
19
|
+
@click.option("--dry-run", is_flag=True, help="Preview what would be deleted")
|
|
20
|
+
@require_init
|
|
21
|
+
def clean(
|
|
22
|
+
backups: bool, orphans: bool, clean_all: bool, keep: int, force: bool, dry_run: bool
|
|
23
|
+
):
|
|
24
|
+
"""Clean stale backups and orphaned files.
|
|
25
|
+
|
|
26
|
+
Removes old backups and files in the repository that are no longer tracked
|
|
27
|
+
by any configuration section.
|
|
28
|
+
"""
|
|
29
|
+
if not (backups or orphans or clean_all):
|
|
30
|
+
ui.warn("Please specify what to clean: --backups, --orphans, or --all")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from ..operations import get_operations
|
|
35
|
+
|
|
36
|
+
ops = get_operations()
|
|
37
|
+
|
|
38
|
+
# 1. Clean Backups
|
|
39
|
+
if backups or clean_all:
|
|
40
|
+
ui.console.print("[bold]Checking backups...[/bold]")
|
|
41
|
+
if dry_run:
|
|
42
|
+
# Preview backups to delete
|
|
43
|
+
all_backups = ops.backups.list_backups()
|
|
44
|
+
if len(all_backups) > keep:
|
|
45
|
+
to_delete = all_backups[keep:]
|
|
46
|
+
ui.console.print(
|
|
47
|
+
f"[bold]Backups to be deleted ({len(to_delete)}):[/bold]",
|
|
48
|
+
style="red",
|
|
49
|
+
)
|
|
50
|
+
for b in to_delete:
|
|
51
|
+
ui.console.print(f" - {b['id']} ({b['note']})")
|
|
52
|
+
else:
|
|
53
|
+
ui.console.print(" No backups to clean.")
|
|
54
|
+
else:
|
|
55
|
+
current_backups_count = len(ops.backups.list_backups())
|
|
56
|
+
if current_backups_count > keep:
|
|
57
|
+
if force or ui.confirm(
|
|
58
|
+
f"Clean up backups (keeping {keep} newest)?"
|
|
59
|
+
):
|
|
60
|
+
deleted = ops.backups.clean_backups(keep=keep)
|
|
61
|
+
if deleted > 0:
|
|
62
|
+
success(f"Deleted {deleted} old backups.")
|
|
63
|
+
else:
|
|
64
|
+
ui.console.print("No backups cleaned.")
|
|
65
|
+
else:
|
|
66
|
+
ui.console.print(" No backups to clean.")
|
|
67
|
+
|
|
68
|
+
# 2. Clean Orphans
|
|
69
|
+
if orphans or clean_all:
|
|
70
|
+
ui.console.print("[bold]Checking for orphaned files...[/bold]")
|
|
71
|
+
orphaned_files = ops.get_orphaned_files()
|
|
72
|
+
|
|
73
|
+
if not orphaned_files:
|
|
74
|
+
ui.console.print(" No orphaned files found.")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
if dry_run:
|
|
78
|
+
ui.console.print(
|
|
79
|
+
f"[bold]Orphaned files to be deleted ({len(orphaned_files)}):[/bold]",
|
|
80
|
+
style="red",
|
|
81
|
+
)
|
|
82
|
+
for p in orphaned_files:
|
|
83
|
+
# Show path relative to repo
|
|
84
|
+
try:
|
|
85
|
+
rel_path = p.relative_to(REPO_DIR)
|
|
86
|
+
ui.console.print(f" - {rel_path}")
|
|
87
|
+
except ValueError:
|
|
88
|
+
ui.console.print(f" - {p.name}")
|
|
89
|
+
else:
|
|
90
|
+
if force or ui.confirm(
|
|
91
|
+
f"Found {len(orphaned_files)} orphaned files. Delete them?"
|
|
92
|
+
):
|
|
93
|
+
deleted_files = ops.clean_orphaned_files(dry_run=False)
|
|
94
|
+
success(f"Deleted {len(deleted_files)} orphaned files.")
|
|
95
|
+
|
|
96
|
+
except Exception as e:
|
|
97
|
+
error(f"Failed to clean: {e}")
|