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
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}")
@@ -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")
@@ -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}")