dotman-git 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dot_man/__init__.py +4 -0
- dot_man/backups.py +211 -0
- dot_man/branch_ops.py +347 -0
- dot_man/cli/__init__.py +113 -0
- dot_man/cli/add_cmd.py +167 -0
- dot_man/cli/audit_cmd.py +141 -0
- dot_man/cli/backup_cmd.py +105 -0
- dot_man/cli/branch_cmd.py +103 -0
- dot_man/cli/clean_cmd.py +97 -0
- dot_man/cli/common.py +548 -0
- dot_man/cli/completions_cmd.py +127 -0
- dot_man/cli/config_cmd.py +979 -0
- dot_man/cli/deploy_cmd.py +169 -0
- dot_man/cli/discover_cmd.py +105 -0
- dot_man/cli/doctor_cmd.py +229 -0
- dot_man/cli/edit_cmd.py +177 -0
- dot_man/cli/encrypt_cmd.py +205 -0
- dot_man/cli/export_cmd.py +146 -0
- dot_man/cli/import_cmd.py +315 -0
- dot_man/cli/init_cmd.py +532 -0
- dot_man/cli/interface.py +56 -0
- dot_man/cli/log_cmd.py +339 -0
- dot_man/cli/main.py +36 -0
- dot_man/cli/navigate_cmd.py +903 -0
- dot_man/cli/onboarding.py +546 -0
- dot_man/cli/profile_cmd.py +313 -0
- dot_man/cli/remote_cmd.py +454 -0
- dot_man/cli/restore_cmd.py +82 -0
- dot_man/cli/revert_cmd.py +86 -0
- dot_man/cli/show_cmd.py +29 -0
- dot_man/cli/status_cmd.py +185 -0
- dot_man/cli/switch_cmd.py +387 -0
- dot_man/cli/tag_cmd.py +164 -0
- dot_man/cli/template_cmd.py +244 -0
- dot_man/cli/tui_cmd.py +44 -0
- dot_man/cli/verify_cmd.py +156 -0
- dot_man/completions/_dot-man.zsh +28 -0
- dot_man/completions/dot-man.bash +15 -0
- dot_man/completions/dot-man.fish +58 -0
- dot_man/completions/install.sh +26 -0
- dot_man/config.py +23 -0
- dot_man/config_detector.py +426 -0
- dot_man/constants.py +109 -0
- dot_man/core.py +614 -0
- dot_man/dotman_config.py +516 -0
- dot_man/encryption.py +173 -0
- dot_man/exceptions.py +255 -0
- dot_man/files.py +443 -0
- dot_man/global_config.py +305 -0
- dot_man/hooks.py +232 -0
- dot_man/interactive.py +460 -0
- dot_man/lock.py +64 -0
- dot_man/merge.py +440 -0
- dot_man/operations.py +212 -0
- dot_man/py.typed +1 -0
- dot_man/save_deploy_ops.py +466 -0
- dot_man/secrets.py +473 -0
- dot_man/section.py +207 -0
- dot_man/status_ops.py +229 -0
- dot_man/tui_log.py +91 -0
- dot_man/ui.py +127 -0
- dot_man/utils.py +132 -0
- dot_man/vault.py +317 -0
- dotman_git-1.0.0.dist-info/METADATA +678 -0
- dotman_git-1.0.0.dist-info/RECORD +69 -0
- dotman_git-1.0.0.dist-info/WHEEL +5 -0
- dotman_git-1.0.0.dist-info/entry_points.txt +3 -0
- dotman_git-1.0.0.dist-info/licenses/LICENSE +21 -0
- dotman_git-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Save and deploy operations mixin for DotManOperations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
9
|
+
|
|
10
|
+
from .constants import LOCK_FILE, REPO_DIR
|
|
11
|
+
from .files import (
|
|
12
|
+
atomic_write_text,
|
|
13
|
+
backup_file,
|
|
14
|
+
clear_comparison_cache,
|
|
15
|
+
compare_files,
|
|
16
|
+
copy_directory,
|
|
17
|
+
copy_file,
|
|
18
|
+
)
|
|
19
|
+
from .lock import FileLock
|
|
20
|
+
from .secrets import SecretMatch
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .config import Section
|
|
24
|
+
|
|
25
|
+
# Binary file extensions that can't contain text secrets
|
|
26
|
+
_BINARY_EXTENSIONS = {
|
|
27
|
+
".jpg",
|
|
28
|
+
".jpeg",
|
|
29
|
+
".png",
|
|
30
|
+
".gif",
|
|
31
|
+
".bmp",
|
|
32
|
+
".ico",
|
|
33
|
+
".webp",
|
|
34
|
+
".svg",
|
|
35
|
+
".mp4",
|
|
36
|
+
".mp3",
|
|
37
|
+
".wav",
|
|
38
|
+
".ogg",
|
|
39
|
+
".flac",
|
|
40
|
+
".avi",
|
|
41
|
+
".mkv",
|
|
42
|
+
".webm",
|
|
43
|
+
".pyc",
|
|
44
|
+
".pyo",
|
|
45
|
+
".so",
|
|
46
|
+
".dll",
|
|
47
|
+
".exe",
|
|
48
|
+
".bin",
|
|
49
|
+
".dat",
|
|
50
|
+
".zip",
|
|
51
|
+
".tar",
|
|
52
|
+
".gz",
|
|
53
|
+
".bz2",
|
|
54
|
+
".xz",
|
|
55
|
+
".7z",
|
|
56
|
+
".rar",
|
|
57
|
+
".pdf",
|
|
58
|
+
".doc",
|
|
59
|
+
".docx",
|
|
60
|
+
".xls",
|
|
61
|
+
".xlsx",
|
|
62
|
+
".ttf",
|
|
63
|
+
".otf",
|
|
64
|
+
".woff",
|
|
65
|
+
".woff2",
|
|
66
|
+
".eot",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SaveDeployMixin:
|
|
71
|
+
"""Mixin providing save/deploy operations for DotManOperations."""
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def vault(self) -> Any: ...
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def current_branch(self) -> str: ...
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def get_section(self, name: str) -> Any: ...
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def get_sections(self) -> list[str]: ...
|
|
86
|
+
|
|
87
|
+
def _restore_file_secrets(
|
|
88
|
+
self, dest_path: Path, original_path: str, branch: str
|
|
89
|
+
) -> Optional[str]:
|
|
90
|
+
"""
|
|
91
|
+
Restore redacted secrets in a deployed file from the vault.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
dest_path: Path to the file on disk to restore secrets into.
|
|
95
|
+
original_path: The original local path string (used as vault key).
|
|
96
|
+
branch: The branch name for vault lookup.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Error message string if an OS error occurred, None on success.
|
|
100
|
+
"""
|
|
101
|
+
if dest_path.suffix.lower() in _BINARY_EXTENSIONS:
|
|
102
|
+
return None # Skip binary files silently
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
content = dest_path.read_text(encoding="utf-8")
|
|
106
|
+
restored = self.vault.restore_secrets_in_content(
|
|
107
|
+
content, original_path, branch
|
|
108
|
+
)
|
|
109
|
+
if restored != content:
|
|
110
|
+
atomic_write_text(dest_path, restored)
|
|
111
|
+
except UnicodeDecodeError:
|
|
112
|
+
pass # Skip binary files silently
|
|
113
|
+
except OSError as e:
|
|
114
|
+
return f"Failed to restore secrets for {dest_path}: {e}"
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def save_section(
|
|
118
|
+
self,
|
|
119
|
+
section: Section,
|
|
120
|
+
secret_handler: Optional[Callable[[SecretMatch], str]] = None,
|
|
121
|
+
) -> tuple[int, list[SecretMatch], list[str]]:
|
|
122
|
+
"""
|
|
123
|
+
Save a section from local to repo.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
(files_saved, secrets_detected, errors)
|
|
127
|
+
"""
|
|
128
|
+
saved = 0
|
|
129
|
+
all_secrets: list[SecretMatch] = []
|
|
130
|
+
errors: list[str] = []
|
|
131
|
+
|
|
132
|
+
# Enhanced secret handler that also stashes to vault
|
|
133
|
+
def wrapped_handler(match: SecretMatch) -> str:
|
|
134
|
+
action = "REDACT"
|
|
135
|
+
if secret_handler:
|
|
136
|
+
action = secret_handler(match)
|
|
137
|
+
|
|
138
|
+
if action == "REDACT":
|
|
139
|
+
# Stash to vault
|
|
140
|
+
try:
|
|
141
|
+
secret_hash = self.vault.stash_secret(
|
|
142
|
+
file_path=str(match.file),
|
|
143
|
+
line_number=match.line_number,
|
|
144
|
+
pattern_name=match.pattern_name,
|
|
145
|
+
secret_value=match.matched_text,
|
|
146
|
+
branch=self.current_branch,
|
|
147
|
+
)
|
|
148
|
+
# Return formatted redaction string with hash
|
|
149
|
+
return f"***REDACTED:{secret_hash}***"
|
|
150
|
+
except (OSError, IOError) as e:
|
|
151
|
+
errors.append(f"Failed to stash secret in vault: {e}")
|
|
152
|
+
# Fallback to standard redaction if vault fails
|
|
153
|
+
from .constants import SECRET_REDACTION_TEXT
|
|
154
|
+
|
|
155
|
+
return SECRET_REDACTION_TEXT
|
|
156
|
+
|
|
157
|
+
return action
|
|
158
|
+
|
|
159
|
+
# Merge exclude patterns with ignored directories
|
|
160
|
+
final_excludes = (section.exclude or []) + (section.ignored_directories or [])
|
|
161
|
+
|
|
162
|
+
for local_path in section.paths:
|
|
163
|
+
if not local_path.exists():
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
repo_path = section.get_repo_path(local_path, REPO_DIR)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
if local_path.is_file():
|
|
170
|
+
success, secrets = copy_file(
|
|
171
|
+
local_path,
|
|
172
|
+
repo_path,
|
|
173
|
+
filter_secrets_enabled=section.secrets_filter,
|
|
174
|
+
secret_handler=wrapped_handler,
|
|
175
|
+
)
|
|
176
|
+
if success:
|
|
177
|
+
saved += 1
|
|
178
|
+
all_secrets.extend(secrets)
|
|
179
|
+
else:
|
|
180
|
+
files_copied, files_failed, secrets = copy_directory(
|
|
181
|
+
local_path,
|
|
182
|
+
repo_path,
|
|
183
|
+
filter_secrets_enabled=section.secrets_filter,
|
|
184
|
+
include_patterns=section.include,
|
|
185
|
+
exclude_patterns=final_excludes,
|
|
186
|
+
secret_handler=wrapped_handler,
|
|
187
|
+
follow_symlinks=section.follow_symlinks,
|
|
188
|
+
)
|
|
189
|
+
saved += files_copied
|
|
190
|
+
all_secrets.extend(secrets)
|
|
191
|
+
if files_failed > 0:
|
|
192
|
+
errors.append(
|
|
193
|
+
f"Failed to copy {files_failed} files in {local_path}"
|
|
194
|
+
)
|
|
195
|
+
except (OSError, IOError) as e:
|
|
196
|
+
errors.append(f"Error processing {local_path}: {e}")
|
|
197
|
+
|
|
198
|
+
return saved, all_secrets, errors
|
|
199
|
+
|
|
200
|
+
def deploy_section(self, section: Section) -> tuple[int, bool, list[str]]:
|
|
201
|
+
"""
|
|
202
|
+
Deploy a section from repo to local.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
(files_deployed, had_changes, errors)
|
|
206
|
+
"""
|
|
207
|
+
deployed = 0
|
|
208
|
+
had_changes = False
|
|
209
|
+
errors: list[str] = []
|
|
210
|
+
|
|
211
|
+
# Merge exclude patterns with ignored directories
|
|
212
|
+
final_excludes = (section.exclude or []) + (section.ignored_directories or [])
|
|
213
|
+
|
|
214
|
+
for local_path in section.paths:
|
|
215
|
+
repo_path = section.get_repo_path(local_path, REPO_DIR)
|
|
216
|
+
# Handle update strategy
|
|
217
|
+
if section.update_strategy == "ignore" and local_path.exists():
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
# Check if will change (after strategy filtering)
|
|
221
|
+
will_change = not local_path.exists() or not compare_files(
|
|
222
|
+
repo_path, local_path
|
|
223
|
+
)
|
|
224
|
+
if will_change:
|
|
225
|
+
had_changes = True
|
|
226
|
+
|
|
227
|
+
if section.update_strategy == "rename_old" and local_path.exists():
|
|
228
|
+
backup_file(local_path)
|
|
229
|
+
|
|
230
|
+
# Helper to restore secrets after copy
|
|
231
|
+
def restore_file_secrets(dest_path: Path) -> None:
|
|
232
|
+
err = self._restore_file_secrets(
|
|
233
|
+
dest_path, str(local_path), self.current_branch
|
|
234
|
+
)
|
|
235
|
+
if err:
|
|
236
|
+
errors.append(err)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
if repo_path.is_file():
|
|
240
|
+
success, _ = copy_file(
|
|
241
|
+
repo_path, local_path, filter_secrets_enabled=False
|
|
242
|
+
)
|
|
243
|
+
if success:
|
|
244
|
+
if section.secrets_filter:
|
|
245
|
+
restore_file_secrets(local_path)
|
|
246
|
+
deployed += 1
|
|
247
|
+
else:
|
|
248
|
+
files_copied, files_failed, _ = copy_directory(
|
|
249
|
+
repo_path,
|
|
250
|
+
local_path,
|
|
251
|
+
filter_secrets_enabled=False,
|
|
252
|
+
include_patterns=section.include,
|
|
253
|
+
exclude_patterns=final_excludes,
|
|
254
|
+
follow_symlinks=section.follow_symlinks,
|
|
255
|
+
)
|
|
256
|
+
if section.secrets_filter:
|
|
257
|
+
for deployed_file in local_path.rglob("*"):
|
|
258
|
+
if deployed_file.is_file():
|
|
259
|
+
restore_file_secrets(deployed_file)
|
|
260
|
+
|
|
261
|
+
deployed += files_copied
|
|
262
|
+
if files_failed > 0:
|
|
263
|
+
errors.append(
|
|
264
|
+
f"Failed to deploy {files_failed} files in {local_path}"
|
|
265
|
+
)
|
|
266
|
+
except PermissionError:
|
|
267
|
+
errors.append(
|
|
268
|
+
f"Permission denied deploying {local_path}. Try running with sudo?"
|
|
269
|
+
)
|
|
270
|
+
except FileNotFoundError:
|
|
271
|
+
errors.append(f"File not found during deployment: {local_path}")
|
|
272
|
+
except OSError as e:
|
|
273
|
+
errors.append(f"Error deploying {local_path}: {e}")
|
|
274
|
+
|
|
275
|
+
return deployed, had_changes, errors
|
|
276
|
+
|
|
277
|
+
def scan_deployable_changes(self, sections: list[Section]) -> dict:
|
|
278
|
+
"""
|
|
279
|
+
Phase 1: Scan for changes and collect hooks (Fast Scan).
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
DeploymentPlan dict
|
|
283
|
+
"""
|
|
284
|
+
plan: dict = {
|
|
285
|
+
"sections_to_deploy": [],
|
|
286
|
+
"pre_hooks": [],
|
|
287
|
+
"post_hooks": [],
|
|
288
|
+
"errors": [],
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for section in sections:
|
|
292
|
+
for local_path in section.paths:
|
|
293
|
+
repo_path = section.get_repo_path(local_path, REPO_DIR)
|
|
294
|
+
|
|
295
|
+
if section.update_strategy == "ignore" and local_path.exists():
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
if not repo_path.exists():
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
will_change = not local_path.exists() or not compare_files(
|
|
303
|
+
repo_path, local_path
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if will_change:
|
|
307
|
+
plan["sections_to_deploy"].append(
|
|
308
|
+
(section, local_path, repo_path)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if section.pre_deploy:
|
|
312
|
+
plan["pre_hooks"].append(section.pre_deploy)
|
|
313
|
+
if section.post_deploy:
|
|
314
|
+
plan["post_hooks"].append(section.post_deploy)
|
|
315
|
+
|
|
316
|
+
except OSError as e:
|
|
317
|
+
plan["errors"].append(f"Error scanning {local_path}: {e}")
|
|
318
|
+
|
|
319
|
+
return plan
|
|
320
|
+
|
|
321
|
+
def execute_deployment_plan(self, plan: dict) -> dict:
|
|
322
|
+
"""
|
|
323
|
+
Phase 2: Execute the deployment plan.
|
|
324
|
+
|
|
325
|
+
Returns dict with keys: 'deployed', 'pre_hooks', 'post_hooks', 'errors'
|
|
326
|
+
"""
|
|
327
|
+
total_deployed = 0
|
|
328
|
+
all_errors: list[str] = list(plan["errors"])
|
|
329
|
+
pre_hooks = list(dict.fromkeys(plan["pre_hooks"]))
|
|
330
|
+
post_hooks = list(dict.fromkeys(plan["post_hooks"]))
|
|
331
|
+
|
|
332
|
+
sections_to_deploy = plan["sections_to_deploy"]
|
|
333
|
+
|
|
334
|
+
if sections_to_deploy:
|
|
335
|
+
with ThreadPoolExecutor() as executor:
|
|
336
|
+
|
|
337
|
+
def deploy_item(item_tuple):
|
|
338
|
+
section, local_path, repo_path = item_tuple
|
|
339
|
+
deployed_count = 0
|
|
340
|
+
item_errors = []
|
|
341
|
+
|
|
342
|
+
if section.update_strategy == "rename_old" and local_path.exists():
|
|
343
|
+
backup_file(local_path)
|
|
344
|
+
|
|
345
|
+
def restore_file_secrets(dest_path: Path) -> None:
|
|
346
|
+
err = self._restore_file_secrets(
|
|
347
|
+
dest_path, str(local_path), self.current_branch
|
|
348
|
+
)
|
|
349
|
+
if err:
|
|
350
|
+
item_errors.append(err)
|
|
351
|
+
|
|
352
|
+
final_excludes = (section.exclude or []) + (
|
|
353
|
+
section.ignored_directories or []
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
if repo_path.is_file():
|
|
358
|
+
success, _ = copy_file(
|
|
359
|
+
repo_path, local_path, filter_secrets_enabled=False
|
|
360
|
+
)
|
|
361
|
+
if success:
|
|
362
|
+
if section.secrets_filter:
|
|
363
|
+
restore_file_secrets(local_path)
|
|
364
|
+
deployed_count += 1
|
|
365
|
+
else:
|
|
366
|
+
item_errors.append(
|
|
367
|
+
f"Failed to copy {repo_path} to {local_path}"
|
|
368
|
+
)
|
|
369
|
+
elif repo_path.is_dir():
|
|
370
|
+
files_copied, files_failed, _ = copy_directory(
|
|
371
|
+
repo_path,
|
|
372
|
+
local_path,
|
|
373
|
+
filter_secrets_enabled=False,
|
|
374
|
+
include_patterns=section.include,
|
|
375
|
+
exclude_patterns=final_excludes,
|
|
376
|
+
follow_symlinks=section.follow_symlinks,
|
|
377
|
+
)
|
|
378
|
+
if section.secrets_filter:
|
|
379
|
+
for deployed_file in local_path.rglob("*"):
|
|
380
|
+
if deployed_file.is_file():
|
|
381
|
+
restore_file_secrets(deployed_file)
|
|
382
|
+
|
|
383
|
+
deployed_count += files_copied
|
|
384
|
+
if files_failed > 0:
|
|
385
|
+
item_errors.append(
|
|
386
|
+
f"Failed to deploy {files_failed} files in {local_path}"
|
|
387
|
+
)
|
|
388
|
+
else:
|
|
389
|
+
item_errors.append(f"Source not found in repo: {repo_path}")
|
|
390
|
+
|
|
391
|
+
except PermissionError:
|
|
392
|
+
item_errors.append(
|
|
393
|
+
f"Permission denied deploying {local_path}. Try running with sudo?"
|
|
394
|
+
)
|
|
395
|
+
except FileNotFoundError:
|
|
396
|
+
item_errors.append(
|
|
397
|
+
f"File not found during deployment: {repo_path}"
|
|
398
|
+
)
|
|
399
|
+
except OSError as e:
|
|
400
|
+
item_errors.append(f"Error deploying {local_path}: {e}")
|
|
401
|
+
|
|
402
|
+
return deployed_count, item_errors
|
|
403
|
+
|
|
404
|
+
futures = [
|
|
405
|
+
executor.submit(deploy_item, item) for item in sections_to_deploy
|
|
406
|
+
]
|
|
407
|
+
|
|
408
|
+
for future in as_completed(futures):
|
|
409
|
+
try:
|
|
410
|
+
count, errs = future.result()
|
|
411
|
+
total_deployed += count
|
|
412
|
+
all_errors.extend(errs)
|
|
413
|
+
except Exception as e:
|
|
414
|
+
all_errors.append(f"Critical error in deployment thread: {e}")
|
|
415
|
+
|
|
416
|
+
# Clear comparison cache after deploying changed files
|
|
417
|
+
clear_comparison_cache()
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
"deployed": total_deployed,
|
|
421
|
+
"pre_hooks": pre_hooks,
|
|
422
|
+
"post_hooks": post_hooks,
|
|
423
|
+
"errors": all_errors,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
def save_all(
|
|
427
|
+
self, secret_handler: Optional[Callable[[SecretMatch], str]] = None
|
|
428
|
+
) -> dict:
|
|
429
|
+
"""
|
|
430
|
+
Save all sections from local to repo.
|
|
431
|
+
Returns dict with keys: 'saved', 'secrets', 'errors'
|
|
432
|
+
"""
|
|
433
|
+
with FileLock(LOCK_FILE):
|
|
434
|
+
total_saved = 0
|
|
435
|
+
all_secrets: list[SecretMatch] = []
|
|
436
|
+
all_errors: list[str] = []
|
|
437
|
+
|
|
438
|
+
sections = [self.get_section(name) for name in self.get_sections()]
|
|
439
|
+
|
|
440
|
+
with self.vault.batch(), ThreadPoolExecutor() as executor:
|
|
441
|
+
future_to_section = {
|
|
442
|
+
executor.submit(self.save_section, section, secret_handler): section
|
|
443
|
+
for section in sections
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for future in as_completed(future_to_section):
|
|
447
|
+
try:
|
|
448
|
+
saved, secrets, errors = future.result()
|
|
449
|
+
total_saved += saved
|
|
450
|
+
all_secrets.extend(secrets)
|
|
451
|
+
all_errors.extend(errors)
|
|
452
|
+
except Exception as e:
|
|
453
|
+
all_errors.append(f"Critical error saving section: {e}")
|
|
454
|
+
|
|
455
|
+
return {"saved": total_saved, "secrets": all_secrets, "errors": all_errors}
|
|
456
|
+
|
|
457
|
+
def deploy_all(self) -> dict:
|
|
458
|
+
"""
|
|
459
|
+
Deploy all sections from repo to local (Two-Phase).
|
|
460
|
+
Returns dict with keys: 'deployed', 'pre_hooks', 'post_hooks', 'errors'
|
|
461
|
+
"""
|
|
462
|
+
with FileLock(LOCK_FILE):
|
|
463
|
+
sections = [self.get_section(name) for name in self.get_sections()]
|
|
464
|
+
plan = self.scan_deployable_changes(sections)
|
|
465
|
+
result = self.execute_deployment_plan(plan)
|
|
466
|
+
return result
|