dotman-cli 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {dotman_cli-0.3.0/src/dotman_cli.egg-info → dotman_cli-0.4.0}/PKG-INFO +5 -2
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/README.md +4 -1
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/pyproject.toml +3 -1
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/__init__.py +2 -2
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/__init__.py +2 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/absorb.py +16 -4
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/deploy.py +37 -55
- dotman_cli-0.4.0/src/dotman/cli/commands/diff.py +93 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/history.py +32 -42
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/init.py +8 -4
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/push.py +1 -5
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/repo.py +4 -3
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/status.py +11 -39
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/watch.py +2 -2
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli_utils.py +15 -23
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/core/config.py +53 -4
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/core/link_manager.py +39 -23
- dotman_cli-0.4.0/src/dotman/main.py +20 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/managers/history.py +2 -5
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/managers/remote.py +16 -1
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/managers/watcher.py +4 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0/src/dotman_cli.egg-info}/PKG-INFO +5 -2
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman_cli.egg-info/SOURCES.txt +1 -0
- dotman_cli-0.3.0/src/dotman/main.py +0 -13
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/LICENSE +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/setup.cfg +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/__init__.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/clean.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/clone.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/doctor.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/pull.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/core/__init__.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/core/exceptions.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/core/template_engine.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/managers/__init__.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/managers/repository.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/services/__init__.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/services/doctor.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/services/hook_executor.py +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman_cli.egg-info/dependency_links.txt +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman_cli.egg-info/entry_points.txt +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman_cli.egg-info/requires.txt +0 -0
- {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dotman-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: One more dot file manager but an interesting and easy to use one.
|
|
5
5
|
Author: Nimit Bhardwaj
|
|
6
6
|
License: MIT
|
|
@@ -155,6 +155,7 @@ packages:
|
|
|
155
155
|
| `dotman deploy [packages]` | Deploy dotfiles (create symlinks) |
|
|
156
156
|
| `dotman undeploy [packages]` | Remove deployed symlinks |
|
|
157
157
|
| `dotman status [packages]` | Show status of deployed dotfiles |
|
|
158
|
+
| `dotman diff [packages]` | Diff deployed files against what deploy would write (exit 1 on drift) |
|
|
158
159
|
| `dotman list` | List all available packages |
|
|
159
160
|
| `dotman absorb [packages]` | Absorb unmanaged files from target directories |
|
|
160
161
|
| `dotman watch` | Watch for file changes and deploy automatically |
|
|
@@ -170,6 +171,8 @@ packages:
|
|
|
170
171
|
### Options
|
|
171
172
|
|
|
172
173
|
- `--config-dir, -c` - Override the config directory (default: `.dotman/` in current directory or `DOTMAN_CONFIG_DIR` env var)
|
|
174
|
+
- `--repo, -r` - Use a registered repository. With no flag and no dotman repo in the current directory, the default registered repository is used, so `dotman status` works from anywhere
|
|
175
|
+
- `--backup-dir` - Override `settings.backup_dir` (relative paths are relative to the repo)
|
|
173
176
|
- `--dry-run, -n` - Preview changes without applying them
|
|
174
177
|
- `--force, -f` - Overwrite existing files (with backup)
|
|
175
178
|
- Specific packages can be passed to commands for targeted operations
|
|
@@ -220,7 +223,7 @@ packages:
|
|
|
220
223
|
- ".git/**" # Ignore git directories
|
|
221
224
|
```
|
|
222
225
|
|
|
223
|
-
|
|
226
|
+
Each `absorb_ignore` entry is tried as a glob against the path relative to the target (and the file name), then as a regex against the full path. If a file matches any pattern, it is skipped during absorption.
|
|
224
227
|
|
|
225
228
|
## Doctor Configuration
|
|
226
229
|
|
|
@@ -134,6 +134,7 @@ packages:
|
|
|
134
134
|
| `dotman deploy [packages]` | Deploy dotfiles (create symlinks) |
|
|
135
135
|
| `dotman undeploy [packages]` | Remove deployed symlinks |
|
|
136
136
|
| `dotman status [packages]` | Show status of deployed dotfiles |
|
|
137
|
+
| `dotman diff [packages]` | Diff deployed files against what deploy would write (exit 1 on drift) |
|
|
137
138
|
| `dotman list` | List all available packages |
|
|
138
139
|
| `dotman absorb [packages]` | Absorb unmanaged files from target directories |
|
|
139
140
|
| `dotman watch` | Watch for file changes and deploy automatically |
|
|
@@ -149,6 +150,8 @@ packages:
|
|
|
149
150
|
### Options
|
|
150
151
|
|
|
151
152
|
- `--config-dir, -c` - Override the config directory (default: `.dotman/` in current directory or `DOTMAN_CONFIG_DIR` env var)
|
|
153
|
+
- `--repo, -r` - Use a registered repository. With no flag and no dotman repo in the current directory, the default registered repository is used, so `dotman status` works from anywhere
|
|
154
|
+
- `--backup-dir` - Override `settings.backup_dir` (relative paths are relative to the repo)
|
|
152
155
|
- `--dry-run, -n` - Preview changes without applying them
|
|
153
156
|
- `--force, -f` - Overwrite existing files (with backup)
|
|
154
157
|
- Specific packages can be passed to commands for targeted operations
|
|
@@ -199,7 +202,7 @@ packages:
|
|
|
199
202
|
- ".git/**" # Ignore git directories
|
|
200
203
|
```
|
|
201
204
|
|
|
202
|
-
|
|
205
|
+
Each `absorb_ignore` entry is tried as a glob against the path relative to the target (and the file name), then as a regex against the full path. If a file matches any pattern, it is skipped during absorption.
|
|
203
206
|
|
|
204
207
|
## Doctor Configuration
|
|
205
208
|
|
|
@@ -15,7 +15,7 @@ classifiers = [
|
|
|
15
15
|
"Environment :: Console",
|
|
16
16
|
"License :: OSI Approved :: MIT License",
|
|
17
17
|
]
|
|
18
|
-
version = "0.
|
|
18
|
+
version = "0.4.0"
|
|
19
19
|
description = "One more dot file manager but an interesting and easy to use one."
|
|
20
20
|
readme = "README.md"
|
|
21
21
|
requires-python = ">=3.12"
|
|
@@ -74,6 +74,8 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo
|
|
|
74
74
|
combine-as-imports = true
|
|
75
75
|
|
|
76
76
|
[tool.mypy]
|
|
77
|
+
# select.kqueue only exists in the darwin stubs; check as darwin everywhere
|
|
78
|
+
platform = "darwin"
|
|
77
79
|
warn_return_any = true
|
|
78
80
|
warn_unused_configs = true
|
|
79
81
|
warn_redundant_casts = true
|
|
@@ -5,6 +5,7 @@ from dotman.cli.commands import (
|
|
|
5
5
|
add_repository,
|
|
6
6
|
clone,
|
|
7
7
|
deploy,
|
|
8
|
+
diff,
|
|
8
9
|
doctor,
|
|
9
10
|
history,
|
|
10
11
|
init,
|
|
@@ -24,7 +25,6 @@ from dotman.cli_utils import (
|
|
|
24
25
|
app,
|
|
25
26
|
console,
|
|
26
27
|
get_config,
|
|
27
|
-
get_repository_option,
|
|
28
28
|
repo_app,
|
|
29
29
|
)
|
|
30
30
|
|
|
@@ -35,13 +35,13 @@ __all__ = [
|
|
|
35
35
|
"console",
|
|
36
36
|
"doctor",
|
|
37
37
|
"get_config",
|
|
38
|
-
"get_repository_option",
|
|
39
38
|
"repo_app",
|
|
40
39
|
"init",
|
|
41
40
|
"clone",
|
|
42
41
|
"push",
|
|
43
42
|
"pull",
|
|
44
43
|
"deploy",
|
|
44
|
+
"diff",
|
|
45
45
|
"undeploy",
|
|
46
46
|
"status",
|
|
47
47
|
"list_packages",
|
|
@@ -5,6 +5,7 @@ from dotman.cli.commands.absorb import absorb_changes
|
|
|
5
5
|
from dotman.cli.commands.clean import clean
|
|
6
6
|
from dotman.cli.commands.clone import clone
|
|
7
7
|
from dotman.cli.commands.deploy import deploy, undeploy
|
|
8
|
+
from dotman.cli.commands.diff import diff
|
|
8
9
|
from dotman.cli.commands.doctor import doctor
|
|
9
10
|
from dotman.cli.commands.init import init
|
|
10
11
|
from dotman.cli.commands.pull import pull
|
|
@@ -28,6 +29,7 @@ __all__ = [
|
|
|
28
29
|
"clean",
|
|
29
30
|
"clone",
|
|
30
31
|
"deploy",
|
|
32
|
+
"diff",
|
|
31
33
|
"doctor",
|
|
32
34
|
"history",
|
|
33
35
|
"init",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Absorb command for dotman CLI."""
|
|
2
2
|
|
|
3
|
+
import fnmatch
|
|
3
4
|
import re
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Annotated
|
|
@@ -10,10 +11,21 @@ from dotman.cli_utils import app, console, get_config
|
|
|
10
11
|
from dotman.core.link_manager import LinkManager
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
def _is_ignored(pattern: str, path: Path, relative: Path) -> bool:
|
|
15
|
+
"""Match a pattern as a glob on the relative path, or a regex on the full path."""
|
|
16
|
+
if fnmatch.fnmatch(str(relative), pattern) or fnmatch.fnmatch(path.name, pattern):
|
|
17
|
+
return True
|
|
18
|
+
try:
|
|
19
|
+
return re.search(pattern, str(path)) is not None
|
|
20
|
+
except re.error:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
13
24
|
def _should_skip_file(
|
|
14
25
|
target_file: Path,
|
|
15
26
|
absorb_ignore: list[str] | None,
|
|
16
27
|
dest_file: Path,
|
|
28
|
+
relative: Path | None = None,
|
|
17
29
|
) -> tuple[bool, str]:
|
|
18
30
|
"""Check if a file should be skipped during absorption.
|
|
19
31
|
|
|
@@ -24,8 +36,9 @@ def _should_skip_file(
|
|
|
24
36
|
return True, "symlink"
|
|
25
37
|
if target_file.name.endswith(".j2"):
|
|
26
38
|
return True, "template"
|
|
39
|
+
relative = relative or Path(target_file.name)
|
|
27
40
|
if absorb_ignore and any(
|
|
28
|
-
|
|
41
|
+
_is_ignored(pattern, target_file, relative) for pattern in absorb_ignore
|
|
29
42
|
):
|
|
30
43
|
return True, "ignored"
|
|
31
44
|
if dest_file.exists():
|
|
@@ -94,9 +107,8 @@ def absorb_changes(
|
|
|
94
107
|
console.print("[red]Dotman is not initialized. Run 'dotman init' first.[/red]")
|
|
95
108
|
raise typer.Exit(1)
|
|
96
109
|
|
|
97
|
-
packages_to_absorb = packages or config.get_enabled_packages()
|
|
98
110
|
# Sort for deterministic behavior (first package wins conflicts)
|
|
99
|
-
packages_to_absorb.
|
|
111
|
+
packages_to_absorb = sorted(packages or config.get_enabled_packages())
|
|
100
112
|
|
|
101
113
|
package_objs = []
|
|
102
114
|
for package_name in packages_to_absorb:
|
|
@@ -138,7 +150,7 @@ def absorb_changes(
|
|
|
138
150
|
dest_file = source / relative_path
|
|
139
151
|
|
|
140
152
|
should_skip, _ = _should_skip_file(
|
|
141
|
-
target_file, absorb_ignore, dest_file
|
|
153
|
+
target_file, absorb_ignore, dest_file, relative_path
|
|
142
154
|
)
|
|
143
155
|
if should_skip:
|
|
144
156
|
continue
|
|
@@ -10,8 +10,6 @@ from dotman.cli_utils import app, console, get_config
|
|
|
10
10
|
from dotman.core.exceptions import (
|
|
11
11
|
DotmanError,
|
|
12
12
|
HookExecutionError,
|
|
13
|
-
LinkExistsError,
|
|
14
|
-
LinkTargetMissingError,
|
|
15
13
|
MissingDependencyError,
|
|
16
14
|
)
|
|
17
15
|
from dotman.core.link_manager import LinkManager, LinkStatus
|
|
@@ -44,17 +42,13 @@ def deploy(
|
|
|
44
42
|
str | None,
|
|
45
43
|
typer.Option("--backup-dir", help="Override backup directory"),
|
|
46
44
|
] = None,
|
|
47
|
-
template_suffix: Annotated[
|
|
48
|
-
str | None,
|
|
49
|
-
typer.Option("--template-suffix", help="Override template suffix"),
|
|
50
|
-
] = None,
|
|
51
45
|
repo_name: Annotated[
|
|
52
46
|
str | None,
|
|
53
47
|
typer.Option("--repo", "-r", help="Repository name"),
|
|
54
48
|
] = None,
|
|
55
49
|
) -> None:
|
|
56
50
|
"""Deploy dotfiles by creating symlinks."""
|
|
57
|
-
config = get_config(config_dir, backup_dir,
|
|
51
|
+
config = get_config(config_dir, backup_dir, repo_name)
|
|
58
52
|
|
|
59
53
|
if repo_name:
|
|
60
54
|
console.print(f"[cyan]Using repository: {repo_name}[/cyan]")
|
|
@@ -86,6 +80,7 @@ def deploy(
|
|
|
86
80
|
|
|
87
81
|
deployment_id = str(uuid.uuid4())[:8]
|
|
88
82
|
deployed_files: list[DeployedFile] = []
|
|
83
|
+
failed = False
|
|
89
84
|
|
|
90
85
|
if dry_run:
|
|
91
86
|
console.print("[cyan]Dry run mode - no changes will be made[/cyan]")
|
|
@@ -126,6 +121,7 @@ def deploy(
|
|
|
126
121
|
f" [yellow]Skipping package '{pkg_name}'...[/yellow]"
|
|
127
122
|
)
|
|
128
123
|
skip_package = True
|
|
124
|
+
failed = True
|
|
129
125
|
break
|
|
130
126
|
|
|
131
127
|
if skip_package:
|
|
@@ -136,57 +132,39 @@ def deploy(
|
|
|
136
132
|
target = Path(file_mapping.target).expanduser()
|
|
137
133
|
|
|
138
134
|
try:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
console.print(f" [cyan]Would render:[/cyan] {target}")
|
|
135
|
+
results = link_manager.create_link(
|
|
136
|
+
source, target, force, dry_run, template_engine, variables
|
|
137
|
+
)
|
|
138
|
+
except DotmanError as e:
|
|
139
|
+
console.print(f" [red]Error:[/red] {e}")
|
|
140
|
+
failed = True
|
|
141
|
+
continue
|
|
147
142
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
143
|
+
for result in results:
|
|
144
|
+
if result.status != LinkStatus.LINKED:
|
|
145
|
+
continue
|
|
146
|
+
is_template = link_manager.is_template_file(result.source)
|
|
147
|
+
if dry_run:
|
|
148
|
+
console.print(f" [cyan]{result.message}[/cyan]")
|
|
149
|
+
elif is_template:
|
|
150
|
+
console.print(f" [green]Rendered:[/green] {result.target}")
|
|
155
151
|
else:
|
|
156
|
-
|
|
157
|
-
|
|
152
|
+
console.print(
|
|
153
|
+
f" [green]Linked:[/green] {result.target} -> {result.source}"
|
|
154
|
+
)
|
|
155
|
+
if result.backed_up:
|
|
156
|
+
console.print(
|
|
157
|
+
f" [yellow]Backed up to:[/yellow] {result.backed_up}"
|
|
158
158
|
)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if result.backed_up:
|
|
169
|
-
console.print(
|
|
170
|
-
f" [yellow]Backed"
|
|
171
|
-
f" up to:[/yellow] {result.backed_up}"
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
deployed_files.append(
|
|
175
|
-
DeployedFile(
|
|
176
|
-
source=str(result.source),
|
|
177
|
-
target=str(result.target),
|
|
178
|
-
is_template=False,
|
|
179
|
-
backup_path=str(result.backed_up)
|
|
180
|
-
if result.backed_up
|
|
181
|
-
else None,
|
|
182
|
-
)
|
|
183
|
-
)
|
|
184
|
-
except LinkExistsError as e:
|
|
185
|
-
console.print(f" [red]Error:[/red] {e}")
|
|
186
|
-
except LinkTargetMissingError as e:
|
|
187
|
-
console.print(f" [red]Error:[/red] {e}")
|
|
188
|
-
except DotmanError as e:
|
|
189
|
-
console.print(f" [red]Error:[/red] {e}")
|
|
159
|
+
|
|
160
|
+
deployed_files.append(
|
|
161
|
+
DeployedFile(
|
|
162
|
+
source=str(result.source),
|
|
163
|
+
target=str(result.target),
|
|
164
|
+
is_template=is_template,
|
|
165
|
+
backup_path=str(result.backed_up) if result.backed_up else None,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
190
168
|
|
|
191
169
|
if pkg.hooks.post_deploy:
|
|
192
170
|
target_dir = None
|
|
@@ -232,6 +210,10 @@ def deploy(
|
|
|
232
210
|
else:
|
|
233
211
|
console.print("\n[green]Deploy complete![/green]")
|
|
234
212
|
|
|
213
|
+
if failed:
|
|
214
|
+
console.print("[red]Some files failed to deploy (see errors above).[/red]")
|
|
215
|
+
raise typer.Exit(1)
|
|
216
|
+
|
|
235
217
|
|
|
236
218
|
@app.command()
|
|
237
219
|
def undeploy(
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Diff command for dotman CLI."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.syntax import Syntax
|
|
9
|
+
|
|
10
|
+
from dotman.cli_utils import app, console, get_config
|
|
11
|
+
from dotman.core.exceptions import DotmanError
|
|
12
|
+
from dotman.core.link_manager import LinkManager
|
|
13
|
+
from dotman.core.template_engine import TemplateEngine
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.command()
|
|
17
|
+
def diff(
|
|
18
|
+
packages: Annotated[
|
|
19
|
+
list[str] | None,
|
|
20
|
+
typer.Argument(help="Packages to diff (default: all enabled)"),
|
|
21
|
+
] = None,
|
|
22
|
+
config_dir: Annotated[
|
|
23
|
+
Path | None,
|
|
24
|
+
typer.Option("--config-dir", "-c", help="The path of config directory"),
|
|
25
|
+
] = None,
|
|
26
|
+
repo_name: Annotated[
|
|
27
|
+
str | None,
|
|
28
|
+
typer.Option("--repo", "-r", help="Repository name"),
|
|
29
|
+
] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Show what deploy would change in rendered templates and conflicting files.
|
|
32
|
+
|
|
33
|
+
Exits with 1 when differences are found, so it can be used in scripts.
|
|
34
|
+
"""
|
|
35
|
+
config = get_config(config_dir, repo_name=repo_name)
|
|
36
|
+
|
|
37
|
+
if not config.is_initialized():
|
|
38
|
+
console.print("[red]Dotman is not initialized. Run 'dotman init' first.[/red]")
|
|
39
|
+
raise typer.Exit(1)
|
|
40
|
+
|
|
41
|
+
link_manager = LinkManager(config.backup_dir)
|
|
42
|
+
template_engine = TemplateEngine()
|
|
43
|
+
found = False
|
|
44
|
+
|
|
45
|
+
for pkg_name in packages or config.get_enabled_packages():
|
|
46
|
+
pkg = config.get_package(pkg_name)
|
|
47
|
+
if not pkg:
|
|
48
|
+
console.print(f"[yellow]Package '{pkg_name}' not found, skipping.[/yellow]")
|
|
49
|
+
continue
|
|
50
|
+
variables = config.get_merged_variables(pkg_name)
|
|
51
|
+
|
|
52
|
+
for file_mapping in pkg.files:
|
|
53
|
+
source = config.dotfiles_dir / file_mapping.source
|
|
54
|
+
target = Path(file_mapping.target).expanduser()
|
|
55
|
+
if source.is_dir():
|
|
56
|
+
pairs = [
|
|
57
|
+
(f, link_manager.derive_target(f, source, target))
|
|
58
|
+
for f in source.rglob("*")
|
|
59
|
+
if f.is_file()
|
|
60
|
+
]
|
|
61
|
+
else:
|
|
62
|
+
pairs = [(source, target)]
|
|
63
|
+
|
|
64
|
+
for source_file, file_target in pairs:
|
|
65
|
+
# Symlinks and missing targets have no content to compare
|
|
66
|
+
if file_target.is_symlink() or not file_target.is_file():
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
if link_manager.is_template_file(source_file):
|
|
70
|
+
wanted = template_engine.render_file(source_file, variables)
|
|
71
|
+
else:
|
|
72
|
+
wanted = source_file.read_text()
|
|
73
|
+
current = file_target.read_text()
|
|
74
|
+
except (DotmanError, OSError, UnicodeDecodeError) as e:
|
|
75
|
+
console.print(f"[red]Cannot diff {file_target}:[/red] {e}")
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
lines = list(
|
|
79
|
+
difflib.unified_diff(
|
|
80
|
+
current.splitlines(keepends=True),
|
|
81
|
+
wanted.splitlines(keepends=True),
|
|
82
|
+
fromfile=str(file_target),
|
|
83
|
+
tofile=str(source_file),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
if lines:
|
|
87
|
+
found = True
|
|
88
|
+
console.print(Syntax("".join(lines), "diff", theme="ansi_dark"))
|
|
89
|
+
|
|
90
|
+
if not found:
|
|
91
|
+
console.print("[green]No differences.[/green]")
|
|
92
|
+
return
|
|
93
|
+
raise typer.Exit(1)
|
|
@@ -140,54 +140,44 @@ def rollback(
|
|
|
140
140
|
|
|
141
141
|
console.print(f"Processing: {target}")
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
# Templates are regular files we wrote; links must still be symlinks.
|
|
144
|
+
# A real file where a link used to be belongs to the user: leave it.
|
|
145
|
+
ours = target.is_file() if deployed_file.is_template else target.is_symlink()
|
|
146
|
+
if ours:
|
|
147
|
+
if not dry_run:
|
|
148
|
+
target.unlink()
|
|
149
|
+
console.print(f" [green]Removed: {target}[/green]")
|
|
150
|
+
else:
|
|
151
|
+
console.print(f" [cyan]Would remove: {target}[/cyan]")
|
|
152
|
+
success_count += 1
|
|
153
|
+
elif target.exists():
|
|
154
|
+
console.print(
|
|
155
|
+
f" [yellow]Not a dotman-managed file, leaving it: {target}[/yellow]"
|
|
156
|
+
)
|
|
157
|
+
skipped_count += 1
|
|
158
|
+
continue
|
|
159
|
+
else:
|
|
160
|
+
console.print(f" [yellow]Already removed: {target}[/yellow]")
|
|
161
|
+
skipped_count += 1
|
|
162
|
+
|
|
163
|
+
if backup_path and Path(backup_path).exists():
|
|
164
|
+
if not dry_run:
|
|
165
|
+
if history_manager.restore_from_backup(Path(backup_path), target):
|
|
147
166
|
console.print(
|
|
148
|
-
f" [green]
|
|
167
|
+
f" [green]Restored from backup: {backup_path}[/green]"
|
|
149
168
|
)
|
|
169
|
+
history_manager.cleanup_backup(Path(backup_path))
|
|
150
170
|
else:
|
|
151
171
|
console.print(
|
|
152
|
-
f" [
|
|
172
|
+
f" [red]Failed to restore from backup: {backup_path}[/red]"
|
|
153
173
|
)
|
|
154
|
-
|
|
174
|
+
fail_count += 1
|
|
155
175
|
else:
|
|
156
|
-
console.print(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if target.is_symlink():
|
|
162
|
-
target.unlink()
|
|
163
|
-
elif target.is_file():
|
|
164
|
-
target.unlink()
|
|
165
|
-
console.print(f" [green]Removed symlink: {target}[/green]")
|
|
166
|
-
else:
|
|
167
|
-
console.print(f" [cyan]Would remove symlink: {target}[/cyan]")
|
|
168
|
-
success_count += 1
|
|
169
|
-
else:
|
|
170
|
-
console.print(f" [yellow]Already removed: {target}[/yellow]")
|
|
171
|
-
skipped_count += 1
|
|
172
|
-
|
|
173
|
-
if backup_path and Path(backup_path).exists():
|
|
174
|
-
if not dry_run:
|
|
175
|
-
if history_manager.restore_from_backup(Path(backup_path), target):
|
|
176
|
-
console.print(
|
|
177
|
-
f" [green]Restored from backup: {backup_path}[/green]"
|
|
178
|
-
)
|
|
179
|
-
history_manager.cleanup_backup(Path(backup_path))
|
|
180
|
-
else:
|
|
181
|
-
console.print(
|
|
182
|
-
f" [red]Failed to restore from backup: {backup_path}[/red]"
|
|
183
|
-
)
|
|
184
|
-
fail_count += 1
|
|
185
|
-
else:
|
|
186
|
-
console.print(
|
|
187
|
-
f" [cyan]Would restore from backup: {backup_path}[/cyan]"
|
|
188
|
-
)
|
|
189
|
-
elif backup_path:
|
|
190
|
-
console.print(f" [yellow]Backup not found: {backup_path}[/yellow]")
|
|
176
|
+
console.print(
|
|
177
|
+
f" [cyan]Would restore from backup: {backup_path}[/cyan]"
|
|
178
|
+
)
|
|
179
|
+
elif backup_path:
|
|
180
|
+
console.print(f" [yellow]Backup not found: {backup_path}[/yellow]")
|
|
191
181
|
|
|
192
182
|
console.print("\n[bold]Rollback summary:[/bold]")
|
|
193
183
|
console.print(f" Processed: {success_count}")
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Init command for dotman CLI."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from typing import Annotated
|
|
5
6
|
|
|
6
7
|
import typer
|
|
7
8
|
|
|
8
|
-
from dotman.cli_utils import app, console
|
|
9
|
-
from dotman.core.config import get_repo_manager
|
|
9
|
+
from dotman.cli_utils import app, console
|
|
10
|
+
from dotman.core.config import Config, get_repo_manager
|
|
10
11
|
from dotman.managers import RemoteManager
|
|
11
12
|
|
|
12
13
|
|
|
@@ -23,10 +24,13 @@ def init(
|
|
|
23
24
|
) -> None:
|
|
24
25
|
"""Initialize dotman configuration in the current directory.
|
|
25
26
|
|
|
26
|
-
Creates a .dotman/ folder with
|
|
27
|
+
Creates a .dotman/ folder with config.yaml and local.yaml configs.
|
|
27
28
|
Run this from your dotfiles repository root.
|
|
28
29
|
"""
|
|
29
|
-
|
|
30
|
+
# Not get_config(): that falls back to the default repo, init must not.
|
|
31
|
+
config = Config(
|
|
32
|
+
config_dir or Path(os.environ.get("DOTMAN_CONFIG_DIR") or Path.cwd())
|
|
33
|
+
)
|
|
30
34
|
|
|
31
35
|
if config.is_initialized():
|
|
32
36
|
console.print("[yellow]Dotman is already initialized.[/yellow]")
|
|
@@ -70,11 +70,7 @@ def push(
|
|
|
70
70
|
try:
|
|
71
71
|
remote_url = remote_manager.get_remote_url(remote_name)
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
remote_manager.has_staged_changes() or remote_manager.has_unstaged_changes()
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
if not has_changes:
|
|
73
|
+
if not remote_manager.has_changes():
|
|
78
74
|
console.print("[yellow]No changes to commit.[/yellow]")
|
|
79
75
|
raise typer.Exit(0)
|
|
80
76
|
|
|
@@ -96,9 +96,10 @@ def remove_repository(
|
|
|
96
96
|
"""
|
|
97
97
|
repo_manager = get_repo_manager()
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
try:
|
|
100
|
+
repo = repo_manager.get_repository(name)
|
|
101
|
+
except RepositoryNotFoundError as e:
|
|
102
|
+
console.print(f"[red]{e}[/red]")
|
|
102
103
|
raise typer.Exit(1)
|
|
103
104
|
|
|
104
105
|
if not force:
|
|
@@ -44,6 +44,7 @@ def status(
|
|
|
44
44
|
return
|
|
45
45
|
|
|
46
46
|
link_manager = LinkManager(config.backup_dir)
|
|
47
|
+
template_engine = TemplateEngine()
|
|
47
48
|
|
|
48
49
|
table = Table(title="Dotman Status")
|
|
49
50
|
table.add_column("Package", style="cyan")
|
|
@@ -66,55 +67,26 @@ def status(
|
|
|
66
67
|
table.add_row(pkg_name, "-", "[yellow]Not found[/yellow]")
|
|
67
68
|
continue
|
|
68
69
|
|
|
70
|
+
variables = config.get_merged_variables(pkg_name)
|
|
69
71
|
for file_mapping in pkg.files:
|
|
70
72
|
source = config.dotfiles_dir / file_mapping.source
|
|
71
73
|
target = Path(file_mapping.target).expanduser()
|
|
72
74
|
|
|
73
75
|
if source.is_dir():
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
display_target = file_target
|
|
80
|
-
if link_manager.is_template_file(source_file):
|
|
81
|
-
display_target = link_manager.get_template_target(
|
|
82
|
-
file_target
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
variables = None
|
|
86
|
-
template_engine_instance = None
|
|
87
|
-
if link_manager.is_template_file(source_file):
|
|
88
|
-
variables = config.get_merged_variables(pkg_name)
|
|
89
|
-
template_engine_instance = TemplateEngine()
|
|
90
|
-
|
|
91
|
-
link_status = link_manager.get_link_status(
|
|
92
|
-
source_file,
|
|
93
|
-
file_target,
|
|
94
|
-
template_engine_instance,
|
|
95
|
-
variables,
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
status_str = status_styles.get(
|
|
99
|
-
link_status, str(link_status.value)
|
|
100
|
-
)
|
|
101
|
-
table.add_row(pkg_name, str(display_target), status_str)
|
|
76
|
+
pairs = [
|
|
77
|
+
(f, link_manager.derive_target(f, source, target))
|
|
78
|
+
for f in source.rglob("*")
|
|
79
|
+
if f.is_file()
|
|
80
|
+
]
|
|
102
81
|
else:
|
|
103
|
-
|
|
104
|
-
if link_manager.is_template_file(source):
|
|
105
|
-
display_target = link_manager.get_template_target(target)
|
|
106
|
-
|
|
107
|
-
variables = None
|
|
108
|
-
template_engine_instance = None
|
|
109
|
-
if link_manager.is_template_file(source):
|
|
110
|
-
variables = config.get_merged_variables(pkg_name)
|
|
111
|
-
template_engine_instance = TemplateEngine()
|
|
82
|
+
pairs = [(source, target)]
|
|
112
83
|
|
|
84
|
+
for source_file, file_target in pairs:
|
|
113
85
|
link_status = link_manager.get_link_status(
|
|
114
|
-
|
|
86
|
+
source_file, file_target, template_engine, variables
|
|
115
87
|
)
|
|
116
88
|
status_str = status_styles.get(link_status, str(link_status.value))
|
|
117
|
-
table.add_row(pkg_name, str(
|
|
89
|
+
table.add_row(pkg_name, str(file_target), status_str)
|
|
118
90
|
|
|
119
91
|
console.print(table)
|
|
120
92
|
|
|
@@ -137,7 +137,7 @@ def watch(
|
|
|
137
137
|
dry_run=dry_run,
|
|
138
138
|
config_dir=config_dir,
|
|
139
139
|
backup_dir=backup_dir,
|
|
140
|
-
|
|
140
|
+
repo_name=repo_name,
|
|
141
141
|
)
|
|
142
142
|
|
|
143
143
|
if once:
|
|
@@ -197,7 +197,7 @@ def watch(
|
|
|
197
197
|
dry_run=dry_run,
|
|
198
198
|
config_dir=config_dir,
|
|
199
199
|
backup_dir=backup_dir,
|
|
200
|
-
|
|
200
|
+
repo_name=repo_name,
|
|
201
201
|
)
|
|
202
202
|
|
|
203
203
|
deploy_scheduled = False
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import Annotated
|
|
6
5
|
|
|
7
6
|
import typer
|
|
8
7
|
from rich.console import Console
|
|
9
8
|
|
|
10
9
|
from dotman import __version__
|
|
11
10
|
from dotman.core.config import Config, get_repo_manager
|
|
11
|
+
from dotman.core.exceptions import DotmanError
|
|
12
12
|
|
|
13
13
|
console = Console()
|
|
14
14
|
|
|
@@ -27,32 +27,24 @@ repo_app = typer.Typer(
|
|
|
27
27
|
def get_config(
|
|
28
28
|
config_dir: Path | None = None,
|
|
29
29
|
backup_dir: str | None = None,
|
|
30
|
-
template_suffix: str | None = None,
|
|
31
30
|
repo_name: str | None = None,
|
|
32
31
|
) -> Config:
|
|
33
|
-
"""
|
|
34
|
-
if config_dir is None:
|
|
35
|
-
if os.environ.get("DOTMAN_CONFIG_DIR"):
|
|
36
|
-
config_dir = Path(os.environ["DOTMAN_CONFIG_DIR"])
|
|
32
|
+
"""Resolve which dotfiles repository to use and load its configuration.
|
|
37
33
|
|
|
34
|
+
Priority: --repo > --config-dir > $DOTMAN_CONFIG_DIR > cwd, and if cwd is
|
|
35
|
+
not a dotman repo, the default registered repository.
|
|
36
|
+
"""
|
|
38
37
|
if repo_name is not None:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
config_dir =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
str | None,
|
|
50
|
-
typer.Option(
|
|
51
|
-
"--repo", "-r", help="Repository name (uses default if not specified)"
|
|
52
|
-
),
|
|
53
|
-
]:
|
|
54
|
-
"""Repository option for CLI commands."""
|
|
55
|
-
return None
|
|
38
|
+
config_dir = get_repo_manager().get_repository(repo_name).path
|
|
39
|
+
elif config_dir is None and os.environ.get("DOTMAN_CONFIG_DIR"):
|
|
40
|
+
config_dir = Path(os.environ["DOTMAN_CONFIG_DIR"])
|
|
41
|
+
elif config_dir is None and not Config(Path.cwd()).is_initialized():
|
|
42
|
+
try:
|
|
43
|
+
config_dir = get_repo_manager().get_repository(None).path
|
|
44
|
+
except DotmanError:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
return Config(config_dir or Path.cwd(), repo_name=repo_name, backup_dir=backup_dir)
|
|
56
48
|
|
|
57
49
|
|
|
58
50
|
@app.callback(invoke_without_command=True)
|
|
@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field
|
|
|
8
8
|
|
|
9
9
|
from dotman.core.exceptions import (
|
|
10
10
|
CircularDependencyError,
|
|
11
|
+
CircularIncludeError,
|
|
12
|
+
ConfigIncludeNotFoundError,
|
|
11
13
|
ConfigNotFoundError,
|
|
12
14
|
ConfigParseError,
|
|
13
15
|
MissingDependencyError,
|
|
@@ -79,6 +81,15 @@ class LocalConfig(BaseModel):
|
|
|
79
81
|
file_overrides: dict[str, dict[str, str]] = Field(default_factory=dict)
|
|
80
82
|
|
|
81
83
|
|
|
84
|
+
def _merge_config(base: dict[str, Any], overlay: dict[str, Any]) -> None:
|
|
85
|
+
"""Merge overlay into base; mapping sections merge one level deep."""
|
|
86
|
+
for key, value in overlay.items():
|
|
87
|
+
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
|
88
|
+
base[key] = {**base[key], **value}
|
|
89
|
+
else:
|
|
90
|
+
base[key] = value
|
|
91
|
+
|
|
92
|
+
|
|
82
93
|
def get_repo_manager(registry_dir: Path | None = None) -> RepoManager:
|
|
83
94
|
"""Get the repository manager instance.
|
|
84
95
|
|
|
@@ -114,7 +125,12 @@ def get_config_for_repo(
|
|
|
114
125
|
class Config:
|
|
115
126
|
"""Main configuration class that merges global and local configs."""
|
|
116
127
|
|
|
117
|
-
def __init__(
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
repo_dir: Path | None = None,
|
|
131
|
+
repo_name: str | None = None,
|
|
132
|
+
backup_dir: str | None = None,
|
|
133
|
+
):
|
|
118
134
|
"""Initialize config for a dotfiles repository.
|
|
119
135
|
|
|
120
136
|
Args:
|
|
@@ -122,9 +138,11 @@ class Config:
|
|
|
122
138
|
Defaults to current working directory.
|
|
123
139
|
Config files are stored in repo_dir/.dotman/
|
|
124
140
|
repo_name: Optional name of the repository (for display purposes)
|
|
141
|
+
backup_dir: Optional override for settings.backup_dir
|
|
125
142
|
"""
|
|
126
143
|
self.repo_dir = repo_dir or Path.cwd()
|
|
127
144
|
self.repo_name = repo_name
|
|
145
|
+
self._backup_dir_override = backup_dir
|
|
128
146
|
self.dotman_dir = self.repo_dir / ".dotman"
|
|
129
147
|
self.config_path = self.dotman_dir / "config.yaml"
|
|
130
148
|
self.local_config_path = self.dotman_dir / "local.yaml"
|
|
@@ -157,10 +175,40 @@ class Config:
|
|
|
157
175
|
except yaml.YAMLError as e:
|
|
158
176
|
raise ConfigParseError(f"Error parsing {path}: {e}") from e
|
|
159
177
|
|
|
178
|
+
def _load_with_includes(
|
|
179
|
+
self, path: Path, stack: tuple[Path, ...] = ()
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
"""Load a YAML file, merging its `includes` underneath it.
|
|
182
|
+
|
|
183
|
+
Includes resolve relative to the including file; later includes win
|
|
184
|
+
over earlier ones and the including file wins over all of them.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
CircularIncludeError: If a file includes itself, directly or not
|
|
188
|
+
ConfigIncludeNotFoundError: If an included file does not exist
|
|
189
|
+
"""
|
|
190
|
+
path = path.resolve()
|
|
191
|
+
if path in stack:
|
|
192
|
+
chain = " -> ".join(p.name for p in (*stack, path))
|
|
193
|
+
raise CircularIncludeError(f"Circular reference detected: {chain}")
|
|
194
|
+
|
|
195
|
+
data = self._load_yaml(path)
|
|
196
|
+
merged: dict[str, Any] = {}
|
|
197
|
+
for include in data.pop("includes", None) or []:
|
|
198
|
+
include_path = (path.parent / Path(include).expanduser()).resolve()
|
|
199
|
+
if not include_path.exists():
|
|
200
|
+
raise ConfigIncludeNotFoundError(
|
|
201
|
+
f"Included file not found: {include_path} (from {path})"
|
|
202
|
+
)
|
|
203
|
+
included = self._load_with_includes(include_path, (*stack, path))
|
|
204
|
+
_merge_config(merged, included)
|
|
205
|
+
_merge_config(merged, data)
|
|
206
|
+
return merged
|
|
207
|
+
|
|
160
208
|
def _load_global_config(self) -> GlobalConfig:
|
|
161
209
|
"""Load the global configuration."""
|
|
162
210
|
try:
|
|
163
|
-
data = self.
|
|
211
|
+
data = self._load_with_includes(self.config_path)
|
|
164
212
|
return GlobalConfig(**data)
|
|
165
213
|
except ConfigNotFoundError:
|
|
166
214
|
return GlobalConfig()
|
|
@@ -185,8 +233,9 @@ class Config:
|
|
|
185
233
|
|
|
186
234
|
@property
|
|
187
235
|
def backup_dir(self) -> Path:
|
|
188
|
-
"""Get the backup directory path."""
|
|
189
|
-
|
|
236
|
+
"""Get the backup directory path (relative paths are repo-relative)."""
|
|
237
|
+
backup_dir = self._backup_dir_override or self.settings.backup_dir
|
|
238
|
+
return self.repo_dir / Path(backup_dir).expanduser()
|
|
190
239
|
|
|
191
240
|
def get_enabled_packages(self) -> list[str]:
|
|
192
241
|
"""Get list of packages enabled in local config."""
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Symlink management for Dotman."""
|
|
2
2
|
|
|
3
3
|
import shutil
|
|
4
|
+
import uuid
|
|
4
5
|
from dataclasses import dataclass
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
from enum import Enum
|
|
@@ -112,14 +113,13 @@ class LinkManager:
|
|
|
112
113
|
# Check if source is a template file
|
|
113
114
|
if self.is_template_file(source):
|
|
114
115
|
# For template files, we check if the rendered file exists
|
|
115
|
-
|
|
116
|
-
if not template_target.exists():
|
|
116
|
+
if not target.exists():
|
|
117
117
|
return LinkStatus.NOT_DEPLOYED
|
|
118
118
|
|
|
119
119
|
# If template_engine is provided, compare content
|
|
120
120
|
if template_engine is not None:
|
|
121
121
|
is_synced, _ = self.compare_content(
|
|
122
|
-
source,
|
|
122
|
+
source, target, variables or {}, template_engine
|
|
123
123
|
)
|
|
124
124
|
return LinkStatus.MODIFIED if not is_synced else LinkStatus.SYNCED
|
|
125
125
|
|
|
@@ -167,6 +167,18 @@ class LinkManager:
|
|
|
167
167
|
)
|
|
168
168
|
]
|
|
169
169
|
|
|
170
|
+
def derive_target(
|
|
171
|
+
self, source_file: Path, source_dir: Path, target_dir: Path
|
|
172
|
+
) -> Path:
|
|
173
|
+
"""Map a file inside a source directory to its target path.
|
|
174
|
+
|
|
175
|
+
Templates found inside directories drop their .j2 suffix.
|
|
176
|
+
"""
|
|
177
|
+
target_file = target_dir / source_file.relative_to(source_dir)
|
|
178
|
+
if self.is_template_file(source_file):
|
|
179
|
+
return self.get_template_target(target_file)
|
|
180
|
+
return target_file
|
|
181
|
+
|
|
170
182
|
def _create_links_recursive(
|
|
171
183
|
self,
|
|
172
184
|
source_dir: Path,
|
|
@@ -182,8 +194,7 @@ class LinkManager:
|
|
|
182
194
|
for source_file in source_dir.rglob("*"):
|
|
183
195
|
if source_file.is_file():
|
|
184
196
|
# Calculate relative path and target location
|
|
185
|
-
|
|
186
|
-
target_file = target_dir / relative_path
|
|
197
|
+
target_file = self.derive_target(source_file, source_dir, target_dir)
|
|
187
198
|
|
|
188
199
|
result = self._create_single_link(
|
|
189
200
|
source_file, target_file, force, dry_run, template_engine, variables
|
|
@@ -202,27 +213,33 @@ class LinkManager:
|
|
|
202
213
|
variables: dict[str, Any] | None = None,
|
|
203
214
|
) -> LinkResult:
|
|
204
215
|
"""Create a single symlink from target to source."""
|
|
205
|
-
# Handle template files by rendering them
|
|
216
|
+
# Handle template files by rendering them (no symlink for templates)
|
|
206
217
|
if template_engine and self.is_template_file(source):
|
|
207
|
-
target_file = self.get_template_target(target)
|
|
208
218
|
try:
|
|
209
|
-
if
|
|
210
|
-
# Render the template to the target location
|
|
211
|
-
template_engine.render_file(source, variables or {}, target_file)
|
|
219
|
+
if dry_run:
|
|
212
220
|
return LinkResult(
|
|
213
221
|
source=source,
|
|
214
|
-
target=
|
|
222
|
+
target=target,
|
|
215
223
|
status=LinkStatus.LINKED,
|
|
216
|
-
message=f"
|
|
224
|
+
message=f"Would render template: {target}",
|
|
217
225
|
)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
)
|
|
225
|
-
|
|
226
|
+
rendered = template_engine.render_file(source, variables or {})
|
|
227
|
+
backed_up = None
|
|
228
|
+
if target.is_symlink():
|
|
229
|
+
# Writing would go through the link into the repo
|
|
230
|
+
target.unlink()
|
|
231
|
+
elif target.exists() and target.read_text() != rendered:
|
|
232
|
+
backed_up = self._backup_file(target)
|
|
233
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
target.write_text(rendered)
|
|
235
|
+
return LinkResult(
|
|
236
|
+
source=source,
|
|
237
|
+
target=target,
|
|
238
|
+
status=LinkStatus.LINKED,
|
|
239
|
+
message=f"Rendered template: {target}",
|
|
240
|
+
backed_up=backed_up,
|
|
241
|
+
)
|
|
242
|
+
except (OSError, TemplateRenderError) as e:
|
|
226
243
|
raise TemplateRenderError(
|
|
227
244
|
f"Failed to render template {source}: {e}"
|
|
228
245
|
) from e
|
|
@@ -295,7 +312,7 @@ class LinkManager:
|
|
|
295
312
|
"""Backup a file before replacing it."""
|
|
296
313
|
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
297
314
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
298
|
-
backup_name = f"{path.name}.{timestamp}"
|
|
315
|
+
backup_name = f"{path.name}.{timestamp}.{uuid.uuid4().hex[:6]}"
|
|
299
316
|
backup_path = self.backup_dir / backup_name
|
|
300
317
|
shutil.move(str(path), str(backup_path))
|
|
301
318
|
return backup_path
|
|
@@ -324,8 +341,7 @@ class LinkManager:
|
|
|
324
341
|
|
|
325
342
|
for source_file in source_dir.rglob("*"):
|
|
326
343
|
if source_file.is_file():
|
|
327
|
-
|
|
328
|
-
target_file = target_dir / relative_path
|
|
344
|
+
target_file = self.derive_target(source_file, source_dir, target_dir)
|
|
329
345
|
|
|
330
346
|
result = self._remove_single_link(source_file, target_file, dry_run)
|
|
331
347
|
results.append(result)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dotman - A dotfile manager for symlinks and templates."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from dotman.cli import app, console
|
|
7
|
+
from dotman.core.exceptions import DotmanError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
"""Entry point for the dotman CLI."""
|
|
12
|
+
try:
|
|
13
|
+
app()
|
|
14
|
+
except DotmanError as e:
|
|
15
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
16
|
+
sys.exit(1)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|
|
@@ -197,8 +197,5 @@ class HistoryManager:
|
|
|
197
197
|
|
|
198
198
|
def cleanup_backup(self, backup_path: Path, dry_run: bool = False) -> None:
|
|
199
199
|
"""Remove a backup file after successful restoration."""
|
|
200
|
-
if backup_path.exists():
|
|
201
|
-
|
|
202
|
-
backup_path.unlink()
|
|
203
|
-
elif backup_path.exists():
|
|
204
|
-
backup_path.unlink()
|
|
200
|
+
if backup_path.exists() and not dry_run:
|
|
201
|
+
backup_path.unlink()
|
|
@@ -149,7 +149,13 @@ class RemoteManager:
|
|
|
149
149
|
if not target_dir.exists():
|
|
150
150
|
raise RemoteCloneError(f"Clone failed: {target_dir} was not created")
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
if auth_token:
|
|
153
|
+
# The token was embedded in the URL; keep it out of .git/config
|
|
154
|
+
self._run_git_command(
|
|
155
|
+
["remote", "set-url", "origin", url], cwd=target_dir, check=False
|
|
156
|
+
)
|
|
157
|
+
# git-lfs is optional: a missing binary must not fail a finished clone
|
|
158
|
+
self._run_git_command(["lfs", "install"], cwd=target_dir, check=False)
|
|
153
159
|
|
|
154
160
|
return target_dir
|
|
155
161
|
|
|
@@ -324,6 +330,15 @@ class RemoteManager:
|
|
|
324
330
|
result = self._run_git_command(["diff", "--quiet"], check=False)
|
|
325
331
|
return result.returncode != 0
|
|
326
332
|
|
|
333
|
+
def has_changes(self) -> bool:
|
|
334
|
+
"""Check for staged, unstaged, or untracked changes.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
True if `git add -A` would have something to commit
|
|
338
|
+
"""
|
|
339
|
+
result = self._run_git_command(["status", "--porcelain"])
|
|
340
|
+
return bool(cast(str, result.stdout).strip())
|
|
341
|
+
|
|
327
342
|
def add_remote(self, name: str, url: str) -> None:
|
|
328
343
|
"""Add a remote to the repository.
|
|
329
344
|
|
|
@@ -468,6 +468,10 @@ def create_watcher() -> FileSystemWatcher:
|
|
|
468
468
|
A FileSystemWatcher instance appropriate for the current platform
|
|
469
469
|
"""
|
|
470
470
|
if sys.platform == "linux":
|
|
471
|
+
try:
|
|
472
|
+
import inotify.adapters # noqa: F401
|
|
473
|
+
except ImportError:
|
|
474
|
+
return PollingWatcher()
|
|
471
475
|
return InotifyWatcher()
|
|
472
476
|
elif sys.platform == "darwin":
|
|
473
477
|
return KqueueWatcher()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dotman-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: One more dot file manager but an interesting and easy to use one.
|
|
5
5
|
Author: Nimit Bhardwaj
|
|
6
6
|
License: MIT
|
|
@@ -155,6 +155,7 @@ packages:
|
|
|
155
155
|
| `dotman deploy [packages]` | Deploy dotfiles (create symlinks) |
|
|
156
156
|
| `dotman undeploy [packages]` | Remove deployed symlinks |
|
|
157
157
|
| `dotman status [packages]` | Show status of deployed dotfiles |
|
|
158
|
+
| `dotman diff [packages]` | Diff deployed files against what deploy would write (exit 1 on drift) |
|
|
158
159
|
| `dotman list` | List all available packages |
|
|
159
160
|
| `dotman absorb [packages]` | Absorb unmanaged files from target directories |
|
|
160
161
|
| `dotman watch` | Watch for file changes and deploy automatically |
|
|
@@ -170,6 +171,8 @@ packages:
|
|
|
170
171
|
### Options
|
|
171
172
|
|
|
172
173
|
- `--config-dir, -c` - Override the config directory (default: `.dotman/` in current directory or `DOTMAN_CONFIG_DIR` env var)
|
|
174
|
+
- `--repo, -r` - Use a registered repository. With no flag and no dotman repo in the current directory, the default registered repository is used, so `dotman status` works from anywhere
|
|
175
|
+
- `--backup-dir` - Override `settings.backup_dir` (relative paths are relative to the repo)
|
|
173
176
|
- `--dry-run, -n` - Preview changes without applying them
|
|
174
177
|
- `--force, -f` - Overwrite existing files (with backup)
|
|
175
178
|
- Specific packages can be passed to commands for targeted operations
|
|
@@ -220,7 +223,7 @@ packages:
|
|
|
220
223
|
- ".git/**" # Ignore git directories
|
|
221
224
|
```
|
|
222
225
|
|
|
223
|
-
|
|
226
|
+
Each `absorb_ignore` entry is tried as a glob against the path relative to the target (and the file name), then as a regex against the full path. If a file matches any pattern, it is skipped during absorption.
|
|
224
227
|
|
|
225
228
|
## Doctor Configuration
|
|
226
229
|
|
|
@@ -10,6 +10,7 @@ src/dotman/cli/commands/absorb.py
|
|
|
10
10
|
src/dotman/cli/commands/clean.py
|
|
11
11
|
src/dotman/cli/commands/clone.py
|
|
12
12
|
src/dotman/cli/commands/deploy.py
|
|
13
|
+
src/dotman/cli/commands/diff.py
|
|
13
14
|
src/dotman/cli/commands/doctor.py
|
|
14
15
|
src/dotman/cli/commands/history.py
|
|
15
16
|
src/dotman/cli/commands/init.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|