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.
Files changed (43) hide show
  1. {dotman_cli-0.3.0/src/dotman_cli.egg-info → dotman_cli-0.4.0}/PKG-INFO +5 -2
  2. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/README.md +4 -1
  3. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/pyproject.toml +3 -1
  4. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/__init__.py +2 -2
  5. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/__init__.py +2 -0
  6. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/absorb.py +16 -4
  7. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/deploy.py +37 -55
  8. dotman_cli-0.4.0/src/dotman/cli/commands/diff.py +93 -0
  9. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/history.py +32 -42
  10. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/init.py +8 -4
  11. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/push.py +1 -5
  12. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/repo.py +4 -3
  13. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/status.py +11 -39
  14. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/watch.py +2 -2
  15. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli_utils.py +15 -23
  16. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/core/config.py +53 -4
  17. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/core/link_manager.py +39 -23
  18. dotman_cli-0.4.0/src/dotman/main.py +20 -0
  19. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/managers/history.py +2 -5
  20. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/managers/remote.py +16 -1
  21. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/managers/watcher.py +4 -0
  22. {dotman_cli-0.3.0 → dotman_cli-0.4.0/src/dotman_cli.egg-info}/PKG-INFO +5 -2
  23. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman_cli.egg-info/SOURCES.txt +1 -0
  24. dotman_cli-0.3.0/src/dotman/main.py +0 -13
  25. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/LICENSE +0 -0
  26. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/setup.cfg +0 -0
  27. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/__init__.py +0 -0
  28. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/clean.py +0 -0
  29. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/clone.py +0 -0
  30. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/doctor.py +0 -0
  31. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/cli/commands/pull.py +0 -0
  32. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/core/__init__.py +0 -0
  33. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/core/exceptions.py +0 -0
  34. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/core/template_engine.py +0 -0
  35. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/managers/__init__.py +0 -0
  36. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/managers/repository.py +0 -0
  37. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/services/__init__.py +0 -0
  38. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/services/doctor.py +0 -0
  39. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman/services/hook_executor.py +0 -0
  40. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman_cli.egg-info/dependency_links.txt +0 -0
  41. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman_cli.egg-info/entry_points.txt +0 -0
  42. {dotman_cli-0.3.0 → dotman_cli-0.4.0}/src/dotman_cli.egg-info/requires.txt +0 -0
  43. {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.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
- The `absorb_ignore` field accepts a list of regex patterns that are matched against the full file path. If a file matches any pattern, it will be skipped during absorption.
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
- The `absorb_ignore` field accepts a list of regex patterns that are matched against the full file path. If a file matches any pattern, it will be skipped during absorption.
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.3.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
- re.search(pattern, str(target_file)) for pattern in absorb_ignore
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.sort()
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, template_suffix, repo_name)
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
- is_template = link_manager.is_template_file(source)
140
-
141
- if is_template:
142
- if not dry_run:
143
- template_engine.render_file(source, variables, target)
144
- console.print(f" [green]Rendered:[/green] {target}")
145
- else:
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
- deployed_files.append(
149
- DeployedFile(
150
- source=str(source),
151
- target=str(target),
152
- is_template=True,
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
- results = link_manager.create_link(
157
- source, target, force, dry_run, template_engine, variables
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
- for result in results:
160
- if result.status == LinkStatus.LINKED:
161
- if dry_run:
162
- console.print(f" [cyan]{result.message}[/cyan]")
163
- else:
164
- console.print(
165
- f" [green]Linked:[/green]"
166
- f" {result.target} -> {result.source}"
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
- if deployed_file.is_template:
144
- if target.exists():
145
- if not dry_run:
146
- target.unlink()
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]Removed rendered template: {target}[/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" [cyan]Would remove rendered template: {target}[/cyan]"
172
+ f" [red]Failed to restore from backup: {backup_path}[/red]"
153
173
  )
154
- success_count += 1
174
+ fail_count += 1
155
175
  else:
156
- console.print(f" [yellow]Already removed: {target}[/yellow]")
157
- skipped_count += 1
158
- else:
159
- if target.is_symlink() or target.exists():
160
- if not dry_run:
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, get_config
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 global.yaml and local.yaml configs.
27
+ Creates a .dotman/ folder with config.yaml and local.yaml configs.
27
28
  Run this from your dotfiles repository root.
28
29
  """
29
- config = get_config(config_dir=config_dir)
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
- has_changes = (
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
- repo = repo_manager.get_repository(name)
100
- if not repo:
101
- console.print(f"[red]Repository '{name}' not found.[/red]")
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
- for source_file in source.rglob("*"):
75
- if source_file.is_file():
76
- relative_path = source_file.relative_to(source)
77
- file_target = target / relative_path
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
- display_target = target
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
- source, target, template_engine_instance, variables
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(display_target), status_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
- template_suffix=None,
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
- template_suffix=None,
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
- """Get the configuration instance."""
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
- repo_manager = get_repo_manager()
40
- repo_config = repo_manager.get_repository(repo_name)
41
- config_dir = repo_config.path
42
-
43
- repo_dir = config_dir if config_dir is not None else Path.cwd()
44
-
45
- return Config(repo_dir, repo_name=repo_name)
46
-
47
-
48
- def get_repository_option() -> Annotated[
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__(self, repo_dir: Path | None = None, repo_name: str | None = None):
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._load_yaml(self.config_path)
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
- return self.dotman_dir / "backups"
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
- template_target = self.get_template_target(target)
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, template_target, variables or {}, template_engine
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
- relative_path = source_file.relative_to(source_dir)
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 first (no symlink for templates)
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 not dry_run:
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=target_file,
222
+ target=target,
215
223
  status=LinkStatus.LINKED,
216
- message=f"Rendered template: {target_file}",
224
+ message=f"Would render template: {target}",
217
225
  )
218
- else:
219
- return LinkResult(
220
- source=source,
221
- target=target_file,
222
- status=LinkStatus.LINKED,
223
- message=f"Would render template: {target_file}",
224
- )
225
- except Exception as e:
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
- relative_path = source_file.relative_to(source_dir)
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
- if not dry_run:
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
- self._run_git_command(["lfs", "install"], cwd=target_dir)
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.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
- The `absorb_ignore` field accepts a list of regex patterns that are matched against the full file path. If a file matches any pattern, it will be skipped during absorption.
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
@@ -1,13 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Dotman - A dotfile manager for symlinks and templates."""
3
-
4
- from dotman.cli import app
5
-
6
-
7
- def main() -> None:
8
- """Entry point for the dotman CLI."""
9
- app()
10
-
11
-
12
- if __name__ == "__main__":
13
- main()
File without changes
File without changes