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/__init__.py
ADDED
dot_man/backups.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Backup management for dot-man."""
|
|
2
|
+
|
|
3
|
+
__all__ = ["BackupManager"]
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .constants import BACKUPS_DIR, MAX_BACKUPS
|
|
11
|
+
from .exceptions import BackupError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BackupManager:
|
|
15
|
+
"""Manages local safety snapshots."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, backups_dir: Path | None = None, max_backups: int | None = None):
|
|
18
|
+
self.backups_dir = backups_dir or BACKUPS_DIR
|
|
19
|
+
self.max_backups = max_backups or MAX_BACKUPS
|
|
20
|
+
# Ensure backups directory exists
|
|
21
|
+
self.backups_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
def _get_backup_name(self, note: str = "manual") -> str:
|
|
24
|
+
"""Generate a unique backup name."""
|
|
25
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
26
|
+
# Sanitize note
|
|
27
|
+
note_safe = "".join(c if c.isalnum() else "_" for c in note)
|
|
28
|
+
return f"{timestamp}_{note_safe}"
|
|
29
|
+
|
|
30
|
+
def create_backup(self, paths: list[Path], note: str = "manual") -> str:
|
|
31
|
+
"""
|
|
32
|
+
Create a backup snapshot of the given paths.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
paths: List of file/directory paths to backup.
|
|
36
|
+
note: Short description/reason for backup.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The name (ID) of the created backup.
|
|
40
|
+
"""
|
|
41
|
+
if not paths:
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
backup_id = self._get_backup_name(note)
|
|
45
|
+
backup_path = self.backups_dir / backup_id
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
backup_path.mkdir()
|
|
49
|
+
|
|
50
|
+
# Metadata to store original paths relative to home or absolute
|
|
51
|
+
manifest = {}
|
|
52
|
+
count = 0
|
|
53
|
+
|
|
54
|
+
for path in paths:
|
|
55
|
+
if not path.exists():
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
# Determine destination structure within backup
|
|
59
|
+
# We replicate the structure relative to user's home or root
|
|
60
|
+
# For simplicity, we flattening somewhat or mirroring full path?
|
|
61
|
+
# Mirroring full path is safest to avoid collisions.
|
|
62
|
+
# E.g. /home/user/.bashrc -> backup/home/user/.bashrc
|
|
63
|
+
|
|
64
|
+
# Strip root anchor to make it relative
|
|
65
|
+
rel_path = path.relative_to(path.anchor)
|
|
66
|
+
dest = backup_path / rel_path
|
|
67
|
+
|
|
68
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
if path.is_file():
|
|
71
|
+
shutil.copy2(path, dest)
|
|
72
|
+
elif path.is_dir():
|
|
73
|
+
shutil.copytree(path, dest)
|
|
74
|
+
|
|
75
|
+
manifest[str(rel_path)] = str(path)
|
|
76
|
+
count += 1
|
|
77
|
+
|
|
78
|
+
if count == 0:
|
|
79
|
+
# No files backed up, remove empty directory
|
|
80
|
+
backup_path.rmdir()
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
# Save manifest
|
|
84
|
+
(backup_path / "manifest.json").write_text(
|
|
85
|
+
json.dumps(manifest, indent=2), encoding="utf-8"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Rotate old backups
|
|
89
|
+
self._rotate_backups()
|
|
90
|
+
|
|
91
|
+
return backup_id
|
|
92
|
+
|
|
93
|
+
except OSError as e:
|
|
94
|
+
# Cleanup on failure
|
|
95
|
+
if backup_path.exists():
|
|
96
|
+
shutil.rmtree(backup_path, ignore_errors=True)
|
|
97
|
+
raise BackupError(f"Failed to create backup '{backup_id}': {e}")
|
|
98
|
+
|
|
99
|
+
def list_backups(self) -> list[dict]:
|
|
100
|
+
"""
|
|
101
|
+
List all available backups.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
List of dicts with keys: id, date, note, path
|
|
105
|
+
"""
|
|
106
|
+
backups = []
|
|
107
|
+
if not self.backups_dir.exists():
|
|
108
|
+
return []
|
|
109
|
+
|
|
110
|
+
for p in self.backups_dir.iterdir():
|
|
111
|
+
if p.is_dir():
|
|
112
|
+
# Parse name: YYYYMMDD_HHMMSS_note
|
|
113
|
+
parts = p.name.split("_", 2)
|
|
114
|
+
if len(parts) >= 2:
|
|
115
|
+
date_str = f"{parts[0]} {parts[1][:2]}:{parts[1][2:4]}"
|
|
116
|
+
note = parts[2] if len(parts) > 2 else "auto"
|
|
117
|
+
backups.append(
|
|
118
|
+
{"id": p.name, "date": date_str, "note": note, "path": p}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Sort by ID (timestamp) descending
|
|
122
|
+
return sorted(backups, key=lambda x: x["id"], reverse=True)
|
|
123
|
+
|
|
124
|
+
def restore_backup(self, backup_id: str) -> bool:
|
|
125
|
+
"""
|
|
126
|
+
Restore files from a backup.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
backup_id: The ID (folder name) of the backup to restore.
|
|
130
|
+
"""
|
|
131
|
+
backup_path = self.backups_dir / backup_id
|
|
132
|
+
if not backup_path.exists():
|
|
133
|
+
raise BackupError(f"Backup '{backup_id}' not found")
|
|
134
|
+
|
|
135
|
+
manifest_file = backup_path / "manifest.json"
|
|
136
|
+
if not manifest_file.exists():
|
|
137
|
+
raise BackupError(f"Backup '{backup_id}' is corrupt (missing manifest)")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
|
|
141
|
+
|
|
142
|
+
for rel_path_str, original_path_str in manifest.items():
|
|
143
|
+
start_source = backup_path / rel_path_str
|
|
144
|
+
original_path = Path(original_path_str)
|
|
145
|
+
|
|
146
|
+
if start_source.exists():
|
|
147
|
+
# Ensure parent exists
|
|
148
|
+
original_path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
|
|
150
|
+
if original_path.exists():
|
|
151
|
+
if original_path.is_dir():
|
|
152
|
+
shutil.rmtree(original_path)
|
|
153
|
+
else:
|
|
154
|
+
original_path.unlink()
|
|
155
|
+
|
|
156
|
+
if start_source.is_dir():
|
|
157
|
+
shutil.copytree(start_source, original_path)
|
|
158
|
+
else:
|
|
159
|
+
shutil.copy2(start_source, original_path)
|
|
160
|
+
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
164
|
+
raise BackupError(f"Failed to restore backup '{backup_id}': {e}")
|
|
165
|
+
|
|
166
|
+
def _rotate_backups(self) -> None:
|
|
167
|
+
"""Keep only the last MAX_BACKUPS backups."""
|
|
168
|
+
self.clean_backups(keep=MAX_BACKUPS)
|
|
169
|
+
|
|
170
|
+
def delete_backup(self, backup_id: str) -> bool:
|
|
171
|
+
"""
|
|
172
|
+
Delete a specific backup by ID.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
backup_id: The ID of the backup to delete.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
True if deleted, False if not found.
|
|
179
|
+
"""
|
|
180
|
+
backup_path = self.backups_dir / backup_id
|
|
181
|
+
if not backup_path.exists():
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
shutil.rmtree(backup_path)
|
|
186
|
+
return True
|
|
187
|
+
except OSError:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
def clean_backups(self, keep: int = 0) -> int:
|
|
191
|
+
"""
|
|
192
|
+
Remove old backups, keeping the specified number of most recent ones.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
keep: Number of recent backups to keep.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Number of backups deleted.
|
|
199
|
+
"""
|
|
200
|
+
backups = self.list_backups()
|
|
201
|
+
if len(backups) <= keep:
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
to_delete = backups[keep:]
|
|
205
|
+
deleted_count = 0
|
|
206
|
+
|
|
207
|
+
for b in to_delete:
|
|
208
|
+
if self.delete_backup(b["id"]):
|
|
209
|
+
deleted_count += 1
|
|
210
|
+
|
|
211
|
+
return deleted_count
|
dot_man/branch_ops.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Branch and revert operations mixin for DotManOperations."""
|
|
2
|
+
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from .constants import LOCK_FILE, REPO_DIR
|
|
8
|
+
from .exceptions import DotManError
|
|
9
|
+
from .files import atomic_write_text, clear_comparison_cache, copy_file
|
|
10
|
+
from .lock import FileLock
|
|
11
|
+
from .merge import get_hook_for_config
|
|
12
|
+
from .secrets import SecretMatch
|
|
13
|
+
from .ui import warn
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
FILE_TO_HOOK_MAP = {
|
|
19
|
+
".bashrc": "bash_reload",
|
|
20
|
+
".zshrc": "zsh_reload",
|
|
21
|
+
".bash_profile": "bash_reload",
|
|
22
|
+
".zprofile": "zsh_reload",
|
|
23
|
+
".config/fish": "fish_reload",
|
|
24
|
+
".tmux.conf": "tmux_reload",
|
|
25
|
+
".config/nvim": "nvim_sync",
|
|
26
|
+
".config/kitty": "kitty_reload",
|
|
27
|
+
".config/alacritty": "alacritty_reload",
|
|
28
|
+
".config/wezterm": "wezterm_reload",
|
|
29
|
+
".config/hypr": "hyprland_reload",
|
|
30
|
+
".config/hyprland": "hyprland_reload",
|
|
31
|
+
".config/sway": "sway_reload",
|
|
32
|
+
".config/i3": "i3_reload",
|
|
33
|
+
".config/awesome": "awesome_reload",
|
|
34
|
+
".config/polybar": "polybar_reload",
|
|
35
|
+
".config/waybar": "waybar_reload",
|
|
36
|
+
".config/dunst": "dunst_reload",
|
|
37
|
+
".config/picom": "picom_reload",
|
|
38
|
+
".Xresources": "xreload",
|
|
39
|
+
".config/starship": "starship_reload",
|
|
40
|
+
".fzf.bash": "fzf_reload",
|
|
41
|
+
".fzf.zsh": "fzf_reload",
|
|
42
|
+
".emacs.d": "emacs_reload",
|
|
43
|
+
".vimrc": "vim_reload",
|
|
44
|
+
".gitconfig": "git_reload",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BranchMixin:
|
|
49
|
+
"""Mixin providing branch switch and file revert operations."""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def current_branch(self) -> str: ...
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def save_all(
|
|
57
|
+
self, secret_handler: Optional[Callable[[SecretMatch], str]] = None
|
|
58
|
+
) -> dict[str, Any]: ...
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def git(self) -> Any: ...
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def reload_config(self) -> None: ...
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def get_sections(self) -> list[str]: ...
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def get_section(self, name: str) -> Any: ...
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def backups(self) -> Any: ...
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def scan_deployable_changes(self, sections: list[Any]) -> Any: ...
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def execute_deployment_plan(self, plan: Any) -> dict[str, Any]: ...
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def global_config(self) -> Any: ...
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def vault(self) -> Any: ...
|
|
90
|
+
|
|
91
|
+
def switch_branch(
|
|
92
|
+
self,
|
|
93
|
+
target_branch: str,
|
|
94
|
+
dry_run: bool = False,
|
|
95
|
+
secret_handler: Optional[Callable[[SecretMatch], str]] = None,
|
|
96
|
+
) -> dict:
|
|
97
|
+
"""
|
|
98
|
+
Switch to a different branch.
|
|
99
|
+
|
|
100
|
+
Returns dict with:
|
|
101
|
+
- saved_count: files saved from current branch
|
|
102
|
+
- deployed_count: files deployed from target branch
|
|
103
|
+
- secrets_redacted: number of secrets redacted
|
|
104
|
+
- pre_hooks: list of pre-deploy commands
|
|
105
|
+
- post_hooks: list of post-deploy commands
|
|
106
|
+
- created_branch: True if branch was newly created
|
|
107
|
+
- errors: list of error messages
|
|
108
|
+
"""
|
|
109
|
+
current_branch = self.current_branch
|
|
110
|
+
result: dict[str, Any] = {
|
|
111
|
+
"saved_count": 0,
|
|
112
|
+
"deployed_count": 0,
|
|
113
|
+
"secrets_redacted": 0,
|
|
114
|
+
"pre_hooks": [],
|
|
115
|
+
"post_hooks": [],
|
|
116
|
+
"created_branch": False,
|
|
117
|
+
"errors": [],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if current_branch == target_branch and not dry_run:
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
import contextlib
|
|
124
|
+
|
|
125
|
+
lock_context = FileLock(LOCK_FILE) if not dry_run else contextlib.nullcontext()
|
|
126
|
+
|
|
127
|
+
with lock_context:
|
|
128
|
+
# Phase 1: Save current branch
|
|
129
|
+
if not dry_run:
|
|
130
|
+
save_result = self.save_all(secret_handler)
|
|
131
|
+
result["saved_count"] = save_result["saved"]
|
|
132
|
+
result["secrets_redacted"] = len(save_result["secrets"])
|
|
133
|
+
result["errors"].extend(save_result["errors"])
|
|
134
|
+
|
|
135
|
+
commit_msg = f"Auto-save from '{current_branch}' before switch to '{target_branch}'"
|
|
136
|
+
self.git.commit(commit_msg)
|
|
137
|
+
|
|
138
|
+
# Phase 2: Switch git branch
|
|
139
|
+
branch_exists = self.git.branch_exists(target_branch)
|
|
140
|
+
result["created_branch"] = not branch_exists
|
|
141
|
+
|
|
142
|
+
if not dry_run:
|
|
143
|
+
self.git.checkout(target_branch, create=not branch_exists)
|
|
144
|
+
self.reload_config()
|
|
145
|
+
|
|
146
|
+
# Phase 3: Deploy target branch
|
|
147
|
+
if not dry_run:
|
|
148
|
+
# Auto-backup before potentially destructive deployment
|
|
149
|
+
try:
|
|
150
|
+
paths_to_backup = []
|
|
151
|
+
for section_name in self.get_sections():
|
|
152
|
+
section = self.get_section(section_name)
|
|
153
|
+
paths_to_backup.extend([p for p in section.paths if p.exists()])
|
|
154
|
+
|
|
155
|
+
if paths_to_backup:
|
|
156
|
+
self.backups.create_backup(
|
|
157
|
+
paths_to_backup, note=f"pre-switch-{target_branch}"
|
|
158
|
+
)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
result["errors"].append(f"Warning: Auto-backup failed: {e}")
|
|
161
|
+
|
|
162
|
+
# Two-Phase Deployment for Target Branch
|
|
163
|
+
try:
|
|
164
|
+
sections = [self.get_section(name) for name in self.get_sections()]
|
|
165
|
+
|
|
166
|
+
plan = self.scan_deployable_changes(sections)
|
|
167
|
+
|
|
168
|
+
result["pre_hooks"].extend(plan["pre_hooks"])
|
|
169
|
+
result["post_hooks"].extend(plan["post_hooks"])
|
|
170
|
+
result["errors"].extend(plan["errors"])
|
|
171
|
+
|
|
172
|
+
result["pre_hooks"] = list(dict.fromkeys(result["pre_hooks"]))
|
|
173
|
+
result["post_hooks"] = list(dict.fromkeys(result["post_hooks"]))
|
|
174
|
+
|
|
175
|
+
deploy_result = self.execute_deployment_plan(plan)
|
|
176
|
+
|
|
177
|
+
result["deployed_count"] = deploy_result["deployed"]
|
|
178
|
+
result["errors"].extend(deploy_result["errors"])
|
|
179
|
+
|
|
180
|
+
# Auto-detect hooks based on changed files
|
|
181
|
+
changed_files = self.get_changed_files_between_branches(
|
|
182
|
+
current_branch, target_branch
|
|
183
|
+
)
|
|
184
|
+
if changed_files:
|
|
185
|
+
auto_hooks = self.detect_hooks_for_changed_files(changed_files)
|
|
186
|
+
for hook in auto_hooks:
|
|
187
|
+
if hook and hook not in result["post_hooks"]:
|
|
188
|
+
result["post_hooks"].append(hook)
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
result["errors"].append(
|
|
192
|
+
f"Critical error during switch deployment: {e}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
self.global_config.current_branch = target_branch
|
|
196
|
+
self.global_config.save()
|
|
197
|
+
|
|
198
|
+
# Clear file comparison cache since files have likely changed
|
|
199
|
+
clear_comparison_cache()
|
|
200
|
+
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
def revert_file(self, path: Path) -> bool:
|
|
204
|
+
"""
|
|
205
|
+
Revert a specific file to its repository version.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
path: Absolute path to the file to revert.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
True if successful, False if file not tracked or not found in repo.
|
|
212
|
+
"""
|
|
213
|
+
path = path.resolve()
|
|
214
|
+
|
|
215
|
+
# Find which section tracks this file
|
|
216
|
+
target_section = None
|
|
217
|
+
repo_source = None
|
|
218
|
+
|
|
219
|
+
for section_name in self.get_sections():
|
|
220
|
+
section = self.get_section(section_name)
|
|
221
|
+
if path in section.paths:
|
|
222
|
+
target_section = section
|
|
223
|
+
repo_source = section.get_repo_path(path, REPO_DIR)
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
for p in section.paths:
|
|
227
|
+
if p.is_dir() and p in path.parents:
|
|
228
|
+
target_section = section
|
|
229
|
+
repo_source = section.get_repo_path(path, REPO_DIR)
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
if target_section:
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
if not target_section or not repo_source:
|
|
236
|
+
warn(f"File not tracked by any section: {path}")
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
if not repo_source.exists():
|
|
240
|
+
warn(
|
|
241
|
+
f"File not found in repository (branch: {self.current_branch}): {repo_source}"
|
|
242
|
+
)
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
success, _ = copy_file(repo_source, path, filter_secrets_enabled=False)
|
|
247
|
+
|
|
248
|
+
if success and target_section.secrets_filter:
|
|
249
|
+
try:
|
|
250
|
+
content = path.read_text(encoding="utf-8")
|
|
251
|
+
restored = self.vault.restore_secrets_in_content(
|
|
252
|
+
content, str(path), self.current_branch
|
|
253
|
+
)
|
|
254
|
+
if restored != content:
|
|
255
|
+
atomic_write_text(path, restored)
|
|
256
|
+
except (OSError, UnicodeDecodeError):
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
return success
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
raise DotManError(f"Failed to revert {path}: {e}")
|
|
263
|
+
|
|
264
|
+
def get_changed_files_between_branches(
|
|
265
|
+
self, source_branch: str, target_branch: str
|
|
266
|
+
) -> list[str]:
|
|
267
|
+
"""Get list of files that differ between two branches.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
source_branch: The source branch name
|
|
271
|
+
target_branch: The target branch name
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
List of file paths that changed
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
diff_output = self.git.repo.git.diff(
|
|
278
|
+
f"{source_branch}...{target_branch}", "--name-only"
|
|
279
|
+
)
|
|
280
|
+
if diff_output.strip():
|
|
281
|
+
return [f.strip() for f in diff_output.strip().split("\n") if f.strip()]
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
def detect_hooks_for_changed_files(self, changed_files: list[str]) -> list[str]:
|
|
287
|
+
"""Determine which hooks to run based on changed files.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
changed_files: List of file paths that changed
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of hook commands to execute (deduplicated)
|
|
294
|
+
"""
|
|
295
|
+
hooks_to_run = set()
|
|
296
|
+
|
|
297
|
+
for file_path in changed_files:
|
|
298
|
+
file_str = str(file_path)
|
|
299
|
+
|
|
300
|
+
for pattern, hook_name in FILE_TO_HOOK_MAP.items():
|
|
301
|
+
if pattern in file_str or file_str.endswith(pattern):
|
|
302
|
+
hook_cmd = get_hook_for_config(pattern)
|
|
303
|
+
if hook_cmd:
|
|
304
|
+
hooks_to_run.add(hook_cmd)
|
|
305
|
+
break
|
|
306
|
+
|
|
307
|
+
section_name = self._find_section_for_file(file_path)
|
|
308
|
+
if section_name:
|
|
309
|
+
section = self.get_section(section_name)
|
|
310
|
+
if section.pre_deploy:
|
|
311
|
+
hooks_to_run.add(section.pre_deploy)
|
|
312
|
+
if section.post_deploy:
|
|
313
|
+
hooks_to_run.add(section.post_deploy)
|
|
314
|
+
|
|
315
|
+
return list(hooks_to_run)
|
|
316
|
+
|
|
317
|
+
def _find_section_for_file(self, file_path: str) -> str | None:
|
|
318
|
+
"""Find which section tracks a given file path.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
file_path: Path to search for
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Section name or None
|
|
325
|
+
"""
|
|
326
|
+
path = (
|
|
327
|
+
Path(file_path).expanduser()
|
|
328
|
+
if file_path.startswith("~")
|
|
329
|
+
else Path(file_path)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
for section_name in self.get_sections():
|
|
333
|
+
section = self.get_section(section_name)
|
|
334
|
+
for section_path in section.paths:
|
|
335
|
+
if path == section_path or (
|
|
336
|
+
section_path.is_dir() and self._is_subpath(path, section_path)
|
|
337
|
+
):
|
|
338
|
+
return section_name
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
def _is_subpath(self, path: Path, parent: Path) -> bool:
|
|
342
|
+
"""Check if path is under parent directory."""
|
|
343
|
+
try:
|
|
344
|
+
path.resolve().relative_to(parent.resolve())
|
|
345
|
+
return True
|
|
346
|
+
except ValueError:
|
|
347
|
+
return False
|
dot_man/cli/__init__.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""dot-man CLI package.
|
|
2
|
+
|
|
3
|
+
This package provides the CLI entry point for dot-man.
|
|
4
|
+
It exposes the modular CLI implementation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .add_cmd import add
|
|
8
|
+
from .audit_cmd import audit
|
|
9
|
+
from .backup_cmd import backup
|
|
10
|
+
from .branch_cmd import branch
|
|
11
|
+
from .clean_cmd import clean
|
|
12
|
+
from .common import (
|
|
13
|
+
DotManGroup,
|
|
14
|
+
_clear_completion_cache,
|
|
15
|
+
_set_git_runner,
|
|
16
|
+
complete_branches,
|
|
17
|
+
complete_commits,
|
|
18
|
+
complete_config_keys,
|
|
19
|
+
complete_profiles,
|
|
20
|
+
complete_switch_args,
|
|
21
|
+
complete_tags,
|
|
22
|
+
complete_template_keys,
|
|
23
|
+
error,
|
|
24
|
+
get_secret_handler,
|
|
25
|
+
parse_branch_arg,
|
|
26
|
+
require_init,
|
|
27
|
+
success,
|
|
28
|
+
warn,
|
|
29
|
+
)
|
|
30
|
+
from .completions_cmd import completions
|
|
31
|
+
from .config_cmd import config
|
|
32
|
+
from .deploy_cmd import deploy
|
|
33
|
+
from .discover_cmd import discover_cmd
|
|
34
|
+
from .doctor_cmd import doctor
|
|
35
|
+
from .edit_cmd import edit
|
|
36
|
+
from .encrypt_cmd import encrypt_cmd
|
|
37
|
+
from .export_cmd import export_cmd
|
|
38
|
+
from .import_cmd import import_cmd
|
|
39
|
+
|
|
40
|
+
# Import commands for easier access and backward compatibility
|
|
41
|
+
from .init_cmd import init
|
|
42
|
+
from .interface import cli
|
|
43
|
+
from .log_cmd import checkout, diff, log
|
|
44
|
+
from .main import main
|
|
45
|
+
from .navigate_cmd import hooks, navigate
|
|
46
|
+
from .onboarding import is_first_run, mark_onboarded, run_onboarding
|
|
47
|
+
from .profile_cmd import profile
|
|
48
|
+
from .remote_cmd import remote, sync
|
|
49
|
+
from .restore_cmd import restore
|
|
50
|
+
from .revert_cmd import revert
|
|
51
|
+
from .show_cmd import show
|
|
52
|
+
from .status_cmd import status
|
|
53
|
+
from .switch_cmd import switch
|
|
54
|
+
from .tag_cmd import tag
|
|
55
|
+
from .template_cmd import template
|
|
56
|
+
from .tui_cmd import tui
|
|
57
|
+
from .verify_cmd import verify
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"main",
|
|
61
|
+
"cli",
|
|
62
|
+
"error",
|
|
63
|
+
"success",
|
|
64
|
+
"warn",
|
|
65
|
+
"require_init",
|
|
66
|
+
"DotManGroup",
|
|
67
|
+
"complete_branches",
|
|
68
|
+
"complete_tags",
|
|
69
|
+
"complete_commits",
|
|
70
|
+
"complete_template_keys",
|
|
71
|
+
"complete_config_keys",
|
|
72
|
+
"complete_profiles",
|
|
73
|
+
"complete_switch_args",
|
|
74
|
+
"parse_branch_arg",
|
|
75
|
+
"get_secret_handler",
|
|
76
|
+
"_set_git_runner",
|
|
77
|
+
"_clear_completion_cache",
|
|
78
|
+
"run_onboarding",
|
|
79
|
+
"is_first_run",
|
|
80
|
+
"mark_onboarded",
|
|
81
|
+
"init",
|
|
82
|
+
"add",
|
|
83
|
+
"status",
|
|
84
|
+
"switch",
|
|
85
|
+
"deploy",
|
|
86
|
+
"edit",
|
|
87
|
+
"audit",
|
|
88
|
+
"backup",
|
|
89
|
+
"branch",
|
|
90
|
+
"remote",
|
|
91
|
+
"sync",
|
|
92
|
+
"tui",
|
|
93
|
+
"config",
|
|
94
|
+
"revert",
|
|
95
|
+
"restore",
|
|
96
|
+
"clean",
|
|
97
|
+
"doctor",
|
|
98
|
+
"verify",
|
|
99
|
+
"log",
|
|
100
|
+
"show",
|
|
101
|
+
"checkout",
|
|
102
|
+
"diff",
|
|
103
|
+
"tag",
|
|
104
|
+
"template",
|
|
105
|
+
"profile",
|
|
106
|
+
"navigate",
|
|
107
|
+
"hooks",
|
|
108
|
+
"import_cmd",
|
|
109
|
+
"export_cmd",
|
|
110
|
+
"encrypt_cmd",
|
|
111
|
+
"discover_cmd",
|
|
112
|
+
"completions",
|
|
113
|
+
]
|