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
@@ -0,0 +1,205 @@
1
+ """Encryption command for encrypting/decrypting sensitive dotfiles."""
2
+
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ import click
7
+
8
+ from .. import ui
9
+ from ..encryption import (
10
+ EncryptionError,
11
+ EncryptionManager,
12
+ detect_available_encryption,
13
+ )
14
+ from .common import error, require_init, success, warn
15
+ from .interface import cli as main
16
+
17
+
18
+ @main.command("encrypt")
19
+ @click.argument("action", type=click.Choice(["encrypt", "decrypt", "status"]))
20
+ @click.argument("section", required=False)
21
+ @click.option(
22
+ "--method",
23
+ "-m",
24
+ type=click.Choice(["gpg", "age"]),
25
+ default="gpg",
26
+ help="Encryption method to use",
27
+ )
28
+ @click.option(
29
+ "--recipient",
30
+ "-r",
31
+ help="Encryption recipient (GPG key ID or AGE recipient)",
32
+ )
33
+ @require_init
34
+ def encrypt_cmd(action: str, section: str | None, method: str, recipient: str | None):
35
+ """Encrypt or decrypt sensitive dotfile sections.
36
+
37
+ Actions:
38
+ encrypt - Encrypt a section's files
39
+ decrypt - Decrypt a section's files
40
+ status - Show encryption status of all sections
41
+
42
+ Examples:
43
+ dot-man encrypt status # Show encryption status
44
+ dot-man encrypt encrypt ssh-config # Encrypt ssh-config section
45
+ dot-man encrypt decrypt ssh-config # Decrypt ssh-config section
46
+ dot-man encrypt encrypt secrets -r age1... # Use AGE encryption
47
+ """
48
+ available = detect_available_encryption()
49
+ if not available:
50
+ error(
51
+ "No encryption tools available. Install GPG or AGE:\n"
52
+ " GPG: brew install gpg\n"
53
+ " AGE: brew install age",
54
+ exit_code=1,
55
+ )
56
+
57
+ if method not in available:
58
+ warn(f"{method} not available. Using {available[0]} instead.")
59
+ method = available[0] # type: ignore[assignment]
60
+
61
+ if action == "status":
62
+ _show_encryption_status()
63
+ elif action == "encrypt":
64
+ _encrypt_section(section, method, recipient) # type: ignore[arg-type]
65
+ elif action == "decrypt":
66
+ _decrypt_section(section, method, recipient) # type: ignore[arg-type]
67
+
68
+
69
+ def _show_encryption_status():
70
+ """Show encryption status of all sections."""
71
+ from ..operations import get_operations
72
+
73
+ ops = get_operations()
74
+
75
+ ui.console.print("[bold]Encryption Status:[/bold]")
76
+ ui.console.print()
77
+
78
+ has_encrypted = False
79
+
80
+ for section_name in ops.get_sections():
81
+ section = ops.get_section(section_name)
82
+ if section.encrypted:
83
+ has_encrypted = True
84
+ ui.console.print(f" [green]✓[/green] [{section_name}]")
85
+ ui.console.print(f" Method: {section.encryption_method}")
86
+ if section.encryption_recipient:
87
+ ui.console.print(f" Recipient: {section.encryption_recipient}")
88
+ else:
89
+ ui.console.print(f" [dim]-[/dim] [{section_name}] (not encrypted)")
90
+
91
+ if not has_encrypted:
92
+ ui.console.print("[dim]No encrypted sections configured[/dim]")
93
+ ui.console.print()
94
+ ui.console.print("To encrypt a section, add to dot-man.toml:")
95
+ ui.console.print(" [cyan][ssh-config][/cyan]")
96
+ ui.console.print(" [cyan]encrypted = true[/cyan]")
97
+ ui.console.print(' [cyan]encryption_method = "gpg"[/cyan]')
98
+ ui.console.print(' [cyan]encryption_recipient = "your@email.com"[/cyan]')
99
+
100
+
101
+ def _encrypt_section(
102
+ section_name: str | None, method: Literal["gpg", "age"], recipient: str | None
103
+ ):
104
+ """Encrypt a section's files."""
105
+ if not section_name:
106
+ error("Section name required for encryption", exit_code=1)
107
+
108
+ from ..dotman_config import DotManConfig
109
+ from ..operations import get_operations
110
+
111
+ ops = get_operations()
112
+ config = DotManConfig()
113
+
114
+ assert section_name is not None
115
+ section = ops.get_section(section_name)
116
+ if not section:
117
+ error(f"Section not found: {section_name}", exit_code=1)
118
+
119
+ if recipient is None:
120
+ if section.encryption_recipient:
121
+ recipient = section.encryption_recipient
122
+ else:
123
+ error(
124
+ "No recipient specified. Use --recipient or set encryption_recipient in config",
125
+ exit_code=1,
126
+ )
127
+
128
+ ui.console.print(f"[dim]Encrypting section: {section_name}[/dim]")
129
+
130
+ enc = EncryptionManager(method)
131
+
132
+ for path_str in section.paths:
133
+ local_path = Path(path_str).expanduser()
134
+ repo_dir_str = ops.git.repo.working_dir
135
+ assert repo_dir_str is not None
136
+ repo_path = section.get_repo_path(local_path, Path(repo_dir_str))
137
+
138
+ if not local_path.exists():
139
+ warn(f"File not found: {local_path}")
140
+ continue
141
+
142
+ encrypted_path = repo_path.with_suffix(repo_path.suffix + ".gpg")
143
+
144
+ try:
145
+ enc.encrypt_file(local_path, encrypted_path, recipient)
146
+ ui.console.print(f" [green]✓[/green] Encrypted: {local_path.name}")
147
+ except EncryptionError as e:
148
+ warn(f"Failed to encrypt {local_path.name}: {e}")
149
+
150
+ config.update_section(
151
+ section_name,
152
+ encrypted=True,
153
+ encryption_method=method,
154
+ encryption_recipient=recipient,
155
+ )
156
+ config.save()
157
+
158
+ success(f"Encrypted section '{section_name}'")
159
+
160
+
161
+ def _decrypt_section(
162
+ section_name: str | None, method: Literal["gpg", "age"], recipient: str | None
163
+ ):
164
+ """Decrypt a section's files."""
165
+ if not section_name:
166
+ error("Section name required for decryption", exit_code=1)
167
+
168
+ from ..dotman_config import DotManConfig
169
+ from ..operations import get_operations
170
+
171
+ ops = get_operations()
172
+ config = DotManConfig()
173
+
174
+ assert section_name is not None
175
+ section = ops.get_section(section_name)
176
+ if not section:
177
+ error(f"Section not found: {section_name}", exit_code=1)
178
+
179
+ ui.console.print(f"[dim]Decrypting section: {section_name}[/dim]")
180
+
181
+ enc = EncryptionManager(method)
182
+
183
+ for path_str in section.paths:
184
+ local_path = Path(path_str).expanduser()
185
+ repo_dir_str = ops.git.repo.working_dir
186
+ assert repo_dir_str is not None
187
+ repo_path = section.get_repo_path(local_path, Path(repo_dir_str))
188
+
189
+ encrypted_path = repo_path.with_suffix(repo_path.suffix + ".gpg")
190
+
191
+ if not encrypted_path.exists():
192
+ warn(f"Encrypted file not found: {encrypted_path}")
193
+ continue
194
+
195
+ try:
196
+ enc.decrypt_file(encrypted_path, local_path, recipient)
197
+ ui.console.print(f" [green]✓[/green] Decrypted: {local_path.name}")
198
+ except EncryptionError as e:
199
+ warn(f"Failed to decrypt {local_path.name}: {e}")
200
+
201
+ assert section_name is not None
202
+ config.update_section(section_name, encrypted=False)
203
+ config.save()
204
+
205
+ success(f"Decrypted section '{section_name}'")
@@ -0,0 +1,146 @@
1
+ """Export command for exporting dotfiles to portable formats."""
2
+
3
+ import json
4
+ import os
5
+ import tarfile
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from .. import ui
12
+ from ..constants import REPO_DIR
13
+ from .common import error, require_init, success
14
+ from .interface import cli as main
15
+
16
+
17
+ @main.command("export")
18
+ @click.argument("format", type=click.Choice(["tar", "zip", "json"]))
19
+ @click.argument("output", type=click.Path())
20
+ @click.option(
21
+ "--branch",
22
+ "-b",
23
+ default=None,
24
+ help="Export specific branch (default: current branch)",
25
+ )
26
+ @click.option(
27
+ "--include-secrets",
28
+ is_flag=True,
29
+ help="Include decrypted secrets in export (WARNING: not secure)",
30
+ )
31
+ @require_init
32
+ def export_cmd(format: str, output: str, branch: str | None, include_secrets: bool):
33
+ """Export dotfiles to a portable archive format.
34
+
35
+ Supported formats:
36
+ tar - Tar archive (.tar.gz)
37
+ zip - Zip archive (.zip)
38
+ json - JSON manifest with file contents
39
+
40
+ Examples:
41
+ dot-man export tar backup.tar.gz # Export as tar.gz
42
+ dot-man export zip dots.zip # Export as zip
43
+ dot-man export json manifest.json # Export as JSON
44
+ dot-man export tar archive.tar.gz --branch work # Export specific branch
45
+ """
46
+ from ..operations import get_operations
47
+
48
+ ops = get_operations()
49
+ current_branch = branch or ops.current_branch
50
+
51
+ ui.console.print(f"[dim]Exporting branch: {current_branch}[/dim]")
52
+
53
+ if format == "tar":
54
+ _export_tar(output, current_branch, include_secrets)
55
+ elif format == "zip":
56
+ _export_zip(output, current_branch, include_secrets)
57
+ elif format == "json":
58
+ _export_json(output, current_branch, include_secrets)
59
+
60
+
61
+ def _export_tar(output: str, branch: str, include_secrets: bool):
62
+ """Export to tar.gz archive."""
63
+ output_path = Path(output).expanduser().resolve()
64
+
65
+ if not str(output_path).endswith((".tar.gz", ".tgz")):
66
+ output_path = output_path.with_suffix(".tar.gz")
67
+
68
+ ui.console.print(f"[dim]Creating tar archive: {output_path}[/dim]")
69
+
70
+ try:
71
+ with tarfile.open(output_path, "w:gz") as tar:
72
+ tar.add(REPO_DIR, arcname="dotman-export")
73
+
74
+ success(f"Exported to {output_path}")
75
+ except Exception as e:
76
+ error(f"Export failed: {e}", exit_code=1)
77
+
78
+
79
+ def _export_zip(output: str, branch: str, include_secrets: bool):
80
+ """Export to zip archive."""
81
+ output_path = Path(output).expanduser().resolve()
82
+
83
+ if not str(output_path).endswith(".zip"):
84
+ output_path = output_path.with_suffix(".zip")
85
+
86
+ ui.console.print(f"[dim]Creating zip archive: {output_path}[/dim]")
87
+
88
+ try:
89
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zipf:
90
+ for root, dirs, files in os.walk(REPO_DIR):
91
+ for file in files:
92
+ file_path = Path(root) / file
93
+ arcname = file_path.relative_to(REPO_DIR)
94
+ zipf.write(file_path, arcname)
95
+
96
+ success(f"Exported to {output_path}")
97
+ except Exception as e:
98
+ error(f"Export failed: {e}", exit_code=1)
99
+
100
+
101
+ def _export_json(output: str, branch: str, include_secrets: bool):
102
+ """Export to JSON manifest."""
103
+ output_path = Path(output).expanduser().resolve()
104
+
105
+ if not str(output_path).endswith(".json"):
106
+ output_path = output_path.with_suffix(".json")
107
+
108
+ ui.console.print(f"[dim]Creating JSON manifest: {output_path}[/dim]")
109
+
110
+ from ..operations import get_operations
111
+
112
+ ops = get_operations()
113
+ manifest: dict = {
114
+ "version": "1.0",
115
+ "branch": branch,
116
+ "exported_at": str(Path().home()),
117
+ "files": [], # type: ignore[list-item]
118
+ }
119
+
120
+ for section_name in ops.get_sections():
121
+ section = ops.get_section(section_name)
122
+ for path_str in section.paths:
123
+ path = Path(path_str).expanduser()
124
+ if path.exists():
125
+ file_entry = {
126
+ "section": section_name,
127
+ "local_path": str(path),
128
+ "repo_path": str(path_str),
129
+ }
130
+
131
+ if include_secrets:
132
+ try:
133
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
134
+ file_entry["content"] = f.read()
135
+ except Exception:
136
+ pass
137
+
138
+ manifest["files"].append(file_entry)
139
+
140
+ try:
141
+ with open(output_path, "w") as f:
142
+ json.dump(manifest, f, indent=2)
143
+
144
+ success(f"Exported {len(manifest['files'])} files to {output_path}")
145
+ except Exception as e:
146
+ error(f"Export failed: {e}", exit_code=1)
@@ -0,0 +1,315 @@
1
+ """Import command for migrating from other dotfile managers."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from .. import ui
9
+ from ..core import GitManager
10
+ from .common import error, require_init, warn
11
+ from .interface import cli as main
12
+
13
+
14
+ @main.command("import")
15
+ @click.argument("source", type=click.Choice(["chezmoi", "yadm", "stow", "all"]))
16
+ @click.option(
17
+ "--path",
18
+ "-p",
19
+ type=click.Path(),
20
+ default=None,
21
+ help="Custom source path (default: auto-detect)",
22
+ )
23
+ @click.option(
24
+ "--dry-run",
25
+ is_flag=True,
26
+ help="Show what would be imported without importing",
27
+ )
28
+ @require_init
29
+ def import_cmd(source: str, path: str | None, dry_run: bool):
30
+ """Import dotfiles from other dotfile managers.
31
+
32
+ Supported sources:
33
+ chezmoi - Import from chezmoi managed dotfiles
34
+ yadm - Import from yadm managed dotfiles
35
+ stow - Import from GNU Stow packages
36
+ all - Auto-detect and import from any supported source
37
+
38
+ Examples:
39
+ dot-man import chezmoi # Import from chezmoi
40
+ dot-man import yadm # Import from yadm
41
+ dot-man import stow # Import from Stow packages
42
+ dot-man import all --dry-run # Auto-detect and preview
43
+ """
44
+ git = GitManager()
45
+
46
+ if source == "all":
47
+ _import_all(path, dry_run, git)
48
+ elif source == "chezmoi":
49
+ _import_chezmoi(path, dry_run, git)
50
+ elif source == "yadm":
51
+ _import_yadm(path, dry_run, git)
52
+ elif source == "stow":
53
+ _import_stow(path, dry_run, git)
54
+
55
+
56
+ def _import_all(custom_path: str | None, dry_run: bool, git: GitManager):
57
+ """Auto-detect and import from any supported source."""
58
+ sources_found = []
59
+
60
+ chezmoi_path = _detect_chezmoi()
61
+ if chezmoi_path:
62
+ sources_found.append(("chezmoi", chezmoi_path))
63
+
64
+ yadm_path = _detect_yadm()
65
+ if yadm_path:
66
+ sources_found.append(("yadm", yadm_path))
67
+
68
+ stow_path = _detect_stow()
69
+ if stow_path:
70
+ sources_found.append(("stow", stow_path))
71
+
72
+ if not sources_found:
73
+ error(
74
+ "No dotfile manager sources detected. Use --path to specify a custom path.",
75
+ exit_code=1,
76
+ )
77
+
78
+ ui.console.print("[bold]Detected dotfile sources:[/bold]")
79
+ for src, src_path in sources_found:
80
+ ui.console.print(f" • {src}: {src_path}")
81
+
82
+ if dry_run:
83
+ ui.console.print("\n[dim]Dry-run mode - no changes will be made[/dim]")
84
+ return
85
+
86
+ for src, src_path in sources_found:
87
+ ui.console.print(f"\n[bold]Importing from {src}...[/bold]")
88
+ if src == "chezmoi":
89
+ _import_chezmoi(src_path, False, git)
90
+ elif src == "yadm":
91
+ _import_yadm(src_path, False, git)
92
+ elif src == "stow":
93
+ _import_stow(src_path, False, git)
94
+
95
+
96
+ def _detect_chezmoi() -> str | None:
97
+ """Detect chezmoi source directory."""
98
+ home = Path.home()
99
+
100
+ chezmoi_dirs = [
101
+ home / ".local" / "share" / "chezmoi",
102
+ home / ".config" / "chezmoi",
103
+ home / "Library" / "Application Support" / "chezmoi", # macOS
104
+ ]
105
+
106
+ for d in chezmoi_dirs:
107
+ if d.exists() and (d / ".git").exists() or any(d.iterdir()):
108
+ return str(d)
109
+
110
+ return None
111
+
112
+
113
+ def _detect_yadm() -> str | None:
114
+ """Detect yadm managed dotfiles."""
115
+ home = Path.home()
116
+
117
+ yadm_paths = [
118
+ home / ".yadm.git",
119
+ home / ".config" / "yadm" / "repo.git",
120
+ ]
121
+
122
+ for p in yadm_paths:
123
+ if p.exists():
124
+ return str(home / ".yadm")
125
+
126
+ return None
127
+
128
+
129
+ def _detect_stow() -> str | None:
130
+ """Detect GNU Stow packages in common locations."""
131
+ home = Path.home()
132
+
133
+ stow_locations = [
134
+ home / "dotfiles",
135
+ home / ".dotfiles",
136
+ home / "dotfiles" / ".git",
137
+ home / ".dotfiles" / ".git",
138
+ ]
139
+
140
+ for p in stow_locations:
141
+ if p.exists():
142
+ packages = [
143
+ d for d in p.iterdir() if d.is_dir() and not d.name.startswith(".")
144
+ ]
145
+ if packages:
146
+ return str(p)
147
+
148
+ return None
149
+
150
+
151
+ def _import_chezmoi(source_path: str | None, dry_run: bool, git: GitManager):
152
+ """Import from chezmoi."""
153
+ if source_path is None:
154
+ source_path = _detect_chezmoi()
155
+ if source_path is None:
156
+ error(
157
+ "chezmoi source not found. Install chezmoi first or use --path.",
158
+ exit_code=1,
159
+ )
160
+ assert source_path is not None
161
+
162
+ source = Path(source_path).expanduser().resolve()
163
+
164
+ if not source.exists():
165
+ error(f"Source path does not exist: {source}", exit_code=1)
166
+
167
+ ui.console.print(f"[dim]Importing from chezmoi: {source}[/dim]")
168
+
169
+ files_imported = 0
170
+
171
+ for item in source.rglob("*"):
172
+ if item.is_file():
173
+ rel_path = item.relative_to(source)
174
+
175
+ if rel_path.name.startswith("."):
176
+ dest_path = Path.home() / f".{rel_path.name}"
177
+ else:
178
+ dest_path = Path.home() / ".config" / str(rel_path)
179
+
180
+ if source.name == "chezmoi":
181
+ src_path = item
182
+ else:
183
+ src_path = item
184
+
185
+ if dry_run:
186
+ ui.console.print(f" [dim]Would import: {src_path} → {dest_path}[/dim]")
187
+ else:
188
+ try:
189
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
190
+ shutil.copy2(src_path, dest_path)
191
+ files_imported += 1
192
+ except Exception as e:
193
+ warn(f"Failed to import {src_path}: {e}")
194
+
195
+ if dry_run:
196
+ ui.success(f"Would import {files_imported} files from chezmoi")
197
+ else:
198
+ ui.success(f"Imported {files_imported} files from chezmoi")
199
+
200
+ commit_msg = "Import dotfiles from chezmoi"
201
+ git.add_all()
202
+ git.commit(commit_msg)
203
+
204
+
205
+ def _import_yadm(source_path: str | None, dry_run: bool, git: GitManager):
206
+ """Import from yadm."""
207
+ if source_path is None:
208
+ source_path = _detect_yadm()
209
+ if source_path is None:
210
+ error(
211
+ "yadm source not found. Install yadm first or use --path.", exit_code=1
212
+ )
213
+ assert source_path is not None
214
+
215
+ source = Path(source_path).expanduser().resolve()
216
+
217
+ if not source.exists():
218
+ error(f"Source path does not exist: {source}", exit_code=1)
219
+
220
+ ui.console.print(f"[dim]Importing from yadm: {source}[/dim]")
221
+
222
+ files_imported = 0
223
+
224
+ for item in source.rglob("*"):
225
+ if item.is_file() and not item.name.endswith(".git"):
226
+ rel_path = item.relative_to(source)
227
+
228
+ dest_path = Path.home() / rel_path.name
229
+ if dest_path.exists():
230
+ continue
231
+
232
+ if dry_run:
233
+ ui.console.print(f" [dim]Would import: {item} → {dest_path}[/dim]")
234
+ else:
235
+ try:
236
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
237
+ shutil.copy2(item, dest_path)
238
+ files_imported += 1
239
+ except Exception as e:
240
+ warn(f"Failed to import {item}: {e}")
241
+
242
+ if dry_run:
243
+ ui.success(f"Would import {files_imported} files from yadm")
244
+ else:
245
+ ui.success(f"Imported {files_imported} files from yadm")
246
+
247
+ commit_msg = "Import dotfiles from yadm"
248
+ git.add_all()
249
+ git.commit(commit_msg)
250
+
251
+
252
+ def _import_stow(source_path: str | None, dry_run: bool, git: GitManager):
253
+ """Import from GNU Stow packages."""
254
+ if source_path is None:
255
+ source_path = _detect_stow()
256
+ if source_path is None:
257
+ error("Stow packages not found. Use --path to specify.", exit_code=1)
258
+ assert source_path is not None
259
+
260
+ source = Path(source_path).expanduser().resolve()
261
+
262
+ if not source.exists():
263
+ error(f"Source path does not exist: {source}", exit_code=1)
264
+
265
+ ui.console.print(f"[dim]Importing from Stow: {source}[/dim]")
266
+
267
+ packages = [
268
+ d for d in source.iterdir() if d.is_dir() and not d.name.startswith(".")
269
+ ]
270
+
271
+ if not packages:
272
+ error("No Stow packages found in source directory", exit_code=1)
273
+
274
+ ui.console.print(
275
+ f"[dim]Found {len(packages)} packages: {', '.join([p.name for p in packages])}[/dim]"
276
+ )
277
+
278
+ files_imported = 0
279
+
280
+ for package in packages:
281
+ for item in package.rglob("*"):
282
+ if item.is_file():
283
+ rel_path = item.relative_to(package)
284
+
285
+ if rel_path.parts[0].startswith("."):
286
+ dest_path = Path.home() / "/".join(rel_path.parts)
287
+ else:
288
+ dest_path = (
289
+ Path.home()
290
+ / f".{rel_path.parts[0]}"
291
+ / "/".join(rel_path.parts[1:])
292
+ )
293
+
294
+ if dry_run:
295
+ ui.console.print(f" [dim]Would import: {item} → {dest_path}[/dim]")
296
+ else:
297
+ try:
298
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
299
+ shutil.copy2(item, dest_path)
300
+ files_imported += 1
301
+ except Exception as e:
302
+ warn(f"Failed to import {item}: {e}")
303
+
304
+ if dry_run:
305
+ ui.success(
306
+ f"Would import {files_imported} files from {len(packages)} Stow packages"
307
+ )
308
+ else:
309
+ ui.success(
310
+ f"Imported {files_imported} files from {len(packages)} Stow packages"
311
+ )
312
+
313
+ commit_msg = f"Import dotfiles from {len(packages)} Stow packages"
314
+ git.add_all()
315
+ git.commit(commit_msg)