dotx 3.2.0__tar.gz → 3.2.2__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 (40) hide show
  1. {dotx-3.2.0/src/dotx.egg-info → dotx-3.2.2}/PKG-INFO +11 -1
  2. {dotx-3.2.0 → dotx-3.2.2}/README.md +10 -0
  3. {dotx-3.2.0 → dotx-3.2.2}/pyproject.toml +1 -1
  4. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/commands/database.py +36 -6
  5. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/commands/install_cmd.py +15 -4
  6. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/commands/uninstall_cmd.py +5 -2
  7. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/database.py +53 -14
  8. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/install.py +19 -3
  9. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/plan.py +2 -1
  10. {dotx-3.2.0 → dotx-3.2.2/src/dotx.egg-info}/PKG-INFO +11 -1
  11. {dotx-3.2.0 → dotx-3.2.2}/tests/test_cli.py +55 -2
  12. {dotx-3.2.0 → dotx-3.2.2}/tests/test_cli_database.py +48 -2
  13. {dotx-3.2.0 → dotx-3.2.2}/tests/test_install.py +136 -0
  14. {dotx-3.2.0 → dotx-3.2.2}/LICENSE +0 -0
  15. {dotx-3.2.0 → dotx-3.2.2}/MANIFEST.in +0 -0
  16. {dotx-3.2.0 → dotx-3.2.2}/setup.cfg +0 -0
  17. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/__init__.py +0 -0
  18. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/always-create +0 -0
  19. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/cli.py +0 -0
  20. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/commands/__init__.py +0 -0
  21. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/commands/path_cmd.py +0 -0
  22. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/commands/progress.py +0 -0
  23. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/dotxignore +0 -0
  24. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/hierarchy.py +0 -0
  25. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/ignore.py +0 -0
  26. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/installed-schema.sql +0 -0
  27. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/options.py +0 -0
  28. {dotx-3.2.0 → dotx-3.2.2}/src/dotx/uninstall.py +0 -0
  29. {dotx-3.2.0 → dotx-3.2.2}/src/dotx.egg-info/SOURCES.txt +0 -0
  30. {dotx-3.2.0 → dotx-3.2.2}/src/dotx.egg-info/dependency_links.txt +0 -0
  31. {dotx-3.2.0 → dotx-3.2.2}/src/dotx.egg-info/entry_points.txt +0 -0
  32. {dotx-3.2.0 → dotx-3.2.2}/src/dotx.egg-info/requires.txt +0 -0
  33. {dotx-3.2.0 → dotx-3.2.2}/src/dotx.egg-info/top_level.txt +0 -0
  34. {dotx-3.2.0 → dotx-3.2.2}/tests/test_always_create.py +0 -0
  35. {dotx-3.2.0 → dotx-3.2.2}/tests/test_ignore.py +0 -0
  36. {dotx-3.2.0 → dotx-3.2.2}/tests/test_ignore_rules.py +0 -0
  37. {dotx-3.2.0 → dotx-3.2.2}/tests/test_options.py +0 -0
  38. {dotx-3.2.0 → dotx-3.2.2}/tests/test_path_which.py +0 -0
  39. {dotx-3.2.0 → dotx-3.2.2}/tests/test_plan.py +0 -0
  40. {dotx-3.2.0 → dotx-3.2.2}/tests/test_uninstall.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotx
3
- Version: 3.2.0
3
+ Version: 3.2.2
4
4
  Summary: A command-line tool to install a link-farm to your dotfiles
5
5
  Author-email: Wolf <Wolf@zv.cx>
6
6
  License-Expression: MIT
@@ -58,6 +58,16 @@ purpose tool. It's made for installing a link-farm to any kind of package from
58
58
  it for other purposes, but it's tuned for its goal. It does the renaming task if you want it, but if your source files
59
59
  are named simply `.bashrc` it works just as well.
60
60
 
61
+ ### How to install
62
+
63
+ It's an ordinary PyPI-supplied Python package providing CLI entry points, so you can install it with `pip` or any other tool you like. I think the best way to install it is this:
64
+
65
+ ```bash
66
+ uv tool install dotx
67
+ ```
68
+
69
+ The current version is: v3.2.0
70
+
61
71
  ### The user interface
62
72
  ```
63
73
  Usage: dotx [OPTIONS] COMMAND [ARGS]...
@@ -30,6 +30,16 @@ purpose tool. It's made for installing a link-farm to any kind of package from
30
30
  it for other purposes, but it's tuned for its goal. It does the renaming task if you want it, but if your source files
31
31
  are named simply `.bashrc` it works just as well.
32
32
 
33
+ ### How to install
34
+
35
+ It's an ordinary PyPI-supplied Python package providing CLI entry points, so you can install it with `pip` or any other tool you like. I think the best way to install it is this:
36
+
37
+ ```bash
38
+ uv tool install dotx
39
+ ```
40
+
41
+ The current version is: v3.2.0
42
+
33
43
  ### The user interface
34
44
  ```
35
45
  Usage: dotx [OPTIONS] COMMAND [ARGS]...
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dotx"
3
- version = "3.2.0"
3
+ version = "3.2.2"
4
4
  description = "A command-line tool to install a link-farm to your dotfiles"
5
5
  authors = [
6
6
  { name = "Wolf", email = "Wolf@zv.cx" }
@@ -152,9 +152,13 @@ def register_commands(app: typer.Typer):
152
152
  if issues:
153
153
  console.print(f"\n[bold cyan]{pkg_name}:[/bold cyan]")
154
154
  for issue in issues:
155
- console.print(f" [red]✗[/red] {issue["target_path"]}")
156
- console.print(f" [dim]Issue: {issue["issue"]}[/dim]")
157
- console.print(f" [dim]Expected: {issue["link_type"]}[/dim]")
155
+ console.print(f" [red]✗[/red] {issue['target_path']}")
156
+ console.print(f" [dim]Issue: {issue['issue']}[/dim]")
157
+ console.print(f" [dim]Expected type: {issue['link_type']}[/dim]")
158
+ if "expected" in issue:
159
+ console.print(f" [dim]Expected source: {issue['expected']}[/dim]")
160
+ if "actual" in issue:
161
+ console.print(f" [dim]Actual target: {issue['actual']}[/dim]")
158
162
  total_issues += len(issues)
159
163
 
160
164
  if total_issues == 0:
@@ -409,6 +413,7 @@ def register_commands(app: typer.Typer):
409
413
  with InstallationDB() as db:
410
414
  all_packages = db.get_all_packages()
411
415
  total_would_clean = 0
416
+ total_dangling_symlinks = 0
412
417
 
413
418
  for pkg_info in all_packages:
414
419
  pkg_root = Path(pkg_info["package_root"])
@@ -416,10 +421,16 @@ def register_commands(app: typer.Typer):
416
421
  orphaned = db.get_orphaned_entries(pkg_root, pkg_name)
417
422
  if orphaned:
418
423
  total_would_clean += len(orphaned)
424
+ # Count dangling symlinks that would be removed
425
+ dangling = [e for e in orphaned if Path(e["target_path"]).is_symlink()]
426
+ total_dangling_symlinks += len(dangling)
419
427
  console.print(f" {pkg_name}: {len(orphaned)} orphaned entry(ies)")
428
+ if dangling and verbose:
429
+ for entry in dangling:
430
+ console.print(f" [dim]Would remove: {entry['target_path']}[/dim]")
420
431
 
421
432
  if total_would_clean > 0:
422
- console.print(f"[yellow]Would remove {total_would_clean} orphaned entry(ies).[/yellow]")
433
+ console.print(f"[yellow]Would remove {total_would_clean} orphaned DB entry(ies) and {total_dangling_symlinks} dangling symlink(s).[/yellow]")
423
434
  else:
424
435
  console.print("[green]No orphaned entries to clean.[/green]")
425
436
 
@@ -462,18 +473,37 @@ def register_commands(app: typer.Typer):
462
473
  console.print("\n[cyan]Cleaning orphaned entries...[/cyan]")
463
474
  all_packages = db.get_all_packages()
464
475
  total_cleaned = 0
476
+ total_symlinks_removed = 0
465
477
 
466
478
  for pkg_info in all_packages:
467
479
  pkg_root = Path(pkg_info["package_root"])
468
480
  pkg_name = pkg_info["package_name"]
481
+
482
+ # Get orphaned entries before cleaning to remove dangling symlinks
483
+ orphaned = db.get_orphaned_entries(pkg_root, pkg_name)
484
+
485
+ # Remove dangling symlinks from filesystem
486
+ for entry in orphaned:
487
+ target_path = Path(entry["target_path"])
488
+ if target_path.is_symlink():
489
+ try:
490
+ target_path.unlink()
491
+ total_symlinks_removed += 1
492
+ logger.info(f"Removed dangling symlink: {target_path}")
493
+ if verbose:
494
+ console.print(f" [dim]Removed: {target_path}[/dim]")
495
+ except OSError as e:
496
+ logger.warning(f"Failed to remove symlink {target_path}: {e}")
497
+
498
+ # Clean database entries
469
499
  cleaned = db.clean_orphaned_entries(pkg_root, pkg_name)
470
500
  if cleaned > 0:
471
501
  total_cleaned += cleaned
472
502
  if verbose:
473
503
  console.print(f" Cleaned {cleaned} orphaned entry(ies) from {pkg_name}")
474
504
 
475
- if total_cleaned > 0:
476
- console.print(f"[green]✓ Removed {total_cleaned} orphaned entry(ies).[/green]")
505
+ if total_cleaned > 0 or total_symlinks_removed > 0:
506
+ console.print(f"[green]✓ Removed {total_cleaned} orphaned DB entry(ies) and {total_symlinks_removed} dangling symlink(s).[/green]")
477
507
  else:
478
508
  console.print("[green]✓ No orphaned entries found.[/green]")
479
509
 
@@ -10,7 +10,7 @@ from rich.console import Console
10
10
  from dotx.commands.progress import execute_plans_with_progress
11
11
  from dotx.database import InstallationDB
12
12
  from dotx.install import plan_install
13
- from dotx.options import is_verbose_mode
13
+ from dotx.options import is_dry_run, is_verbose_mode
14
14
  from dotx.plan import Action, Plan, extract_plan, log_extracted_plan
15
15
 
16
16
 
@@ -56,10 +56,18 @@ def register_command(app: typer.Typer):
56
56
  if failures:
57
57
  can_install = False
58
58
  console.print(
59
- f"[red]✗ Error: can't install {source_package.name} - would overwrite:[/red]"
59
+ f"[red]✗ Error: can't install {source_package.name} - conflicts detected:[/red]"
60
60
  )
61
61
  for plan_node in failures:
62
- console.print(f" {target_path / plan_node.relative_destination_path}")
62
+ dest_path = target_path / plan_node.relative_destination_path
63
+ if dest_path.is_symlink():
64
+ try:
65
+ link_target = dest_path.readlink()
66
+ console.print(f" {dest_path} [dim](symlink → {link_target})[/dim]")
67
+ except OSError:
68
+ console.print(f" {dest_path} [dim](broken symlink)[/dim]")
69
+ else:
70
+ console.print(f" {dest_path} [dim](existing file)[/dim]")
63
71
  console.print()
64
72
 
65
73
  if can_install:
@@ -92,7 +100,10 @@ def register_command(app: typer.Typer):
92
100
  summary_parts.append(f"{total_dirs} dir(s)")
93
101
 
94
102
  summary = " and ".join(summary_parts) if summary_parts else "nothing"
95
- console.print(f"\n[green]✓ Installed {summary} from {len(sources)} package(s)[/green]")
103
+ if is_dry_run(ctx):
104
+ console.print(f"\n[yellow][DRY RUN] Would install {summary} from {len(sources)} package(s)[/yellow]")
105
+ else:
106
+ console.print(f"\n[green]✓ Installed {summary} from {len(sources)} package(s)[/green]")
96
107
  else:
97
108
  console.print("[red]✗ Refusing to install - conflicts detected[/red]")
98
109
 
@@ -9,7 +9,7 @@ from rich.console import Console
9
9
 
10
10
  from dotx.commands.progress import execute_plans_with_progress
11
11
  from dotx.database import InstallationDB
12
- from dotx.options import is_verbose_mode
12
+ from dotx.options import is_dry_run, is_verbose_mode
13
13
  from dotx.plan import Action, Plan, extract_plan, log_extracted_plan
14
14
  from dotx.uninstall import plan_uninstall
15
15
 
@@ -67,6 +67,9 @@ def register_command(app: typer.Typer):
67
67
  len(extract_plan(plan, {Action.UNLINK}))
68
68
  for _, plan in plans
69
69
  )
70
- console.print(f"\n[green]✓ Removed {total_removed} symlink(s) from {len(sources)} package(s)[/green]")
70
+ if is_dry_run(ctx):
71
+ console.print(f"\n[yellow][DRY RUN] Would remove {total_removed} symlink(s) from {len(sources)} package(s)[/yellow]")
72
+ else:
73
+ console.print(f"\n[green]✓ Removed {total_removed} symlink(s) from {len(sources)} package(s)[/green]")
71
74
 
72
75
  logger.info("uninstall finished")
@@ -315,12 +315,43 @@ class InstallationDB:
315
315
 
316
316
  return [dict(row) for row in cursor.fetchall()]
317
317
 
318
+ def _verify_symlink(self, target: Path, source_package_root: Path) -> dict | None:
319
+ """
320
+ Verify a symlink points to the correct source.
321
+
322
+ Returns an issue dict if there's a problem, None if symlink is correct.
323
+ """
324
+ try:
325
+ link_target = target.readlink()
326
+ actual_target = target.resolve()
327
+
328
+ # Check if it's a broken symlink (target doesn't exist)
329
+ if not actual_target.exists():
330
+ return {
331
+ "issue": "Broken symlink (target missing)",
332
+ "actual": str(link_target),
333
+ }
334
+
335
+ # Check if symlink points under source_package_root
336
+ try:
337
+ actual_target.relative_to(source_package_root)
338
+ return None # Symlink is correct
339
+ except ValueError:
340
+ return {
341
+ "issue": "Symlink points to wrong target",
342
+ "expected": str(source_package_root),
343
+ "actual": str(link_target),
344
+ }
345
+ except OSError:
346
+ return {"issue": "Cannot read symlink target"}
347
+
318
348
  def verify_installations(self, package_root: Path, package_name: str) -> list[dict]:
319
349
  """
320
350
  Verify installations for a package against filesystem.
321
351
 
322
352
  Checks if database records match actual filesystem state. Each issue
323
- is a dict with keys: target_path, issue (description), and link_type.
353
+ is a dict with keys: target_path, issue (description), link_type, and
354
+ optionally expected/actual for symlink target mismatches.
324
355
  """
325
356
  if not self.conn:
326
357
  raise RuntimeError("Database not connected")
@@ -331,29 +362,37 @@ class InstallationDB:
331
362
  for install in installations:
332
363
  target = Path(install["target_path"])
333
364
  link_type = install["link_type"]
365
+ source_package_root = Path(install["source_package_root"])
334
366
 
335
- # Check if file exists
336
- if not target.exists():
337
- issues.append({
338
- "target_path": str(target),
339
- "issue": "File missing (in DB but not on filesystem)",
340
- "link_type": link_type,
341
- })
342
- continue
343
-
344
- # Check if it's a symlink (for file and directory types)
345
367
  if link_type in ("file", "directory"):
346
368
  if not target.is_symlink():
369
+ if target.exists():
370
+ issue = "Not a symlink (regular file/dir exists instead)"
371
+ else:
372
+ issue = "Missing (not on filesystem)"
347
373
  issues.append({
348
374
  "target_path": str(target),
349
- "issue": "Not a symlink (should be symlink)",
375
+ "issue": issue,
350
376
  "link_type": link_type,
351
377
  })
378
+ else:
379
+ symlink_issue = self._verify_symlink(target, source_package_root)
380
+ if symlink_issue:
381
+ symlink_issue["target_path"] = str(target)
382
+ symlink_issue["link_type"] = link_type
383
+ issues.append(symlink_issue)
384
+
352
385
  elif link_type == "created_dir":
353
- if not target.is_dir() or target.is_symlink():
386
+ if not target.exists():
387
+ issue = "Missing (not on filesystem)"
388
+ elif not target.is_dir() or target.is_symlink():
389
+ issue = "Not a regular directory (should be created dir)"
390
+ else:
391
+ issue = None
392
+ if issue:
354
393
  issues.append({
355
394
  "target_path": str(target),
356
- "issue": "Not a regular directory (should be created dir)",
395
+ "issue": issue,
357
396
  "link_type": link_type,
358
397
  })
359
398
 
@@ -147,12 +147,27 @@ def plan_install(source_package_root: Path, destination_root: Path) -> Plan:
147
147
  # we can't link the whole directory - must CREATE it instead
148
148
  if plan[child_relative_source_path].requires_rename:
149
149
  found_children_to_rename = True
150
- # Fail if we would overwrite an existing regular file
150
+ # Fail if we would overwrite an existing file or symlink pointing elsewhere
151
151
  destination_path = (
152
152
  destination_root
153
153
  / plan[child_relative_source_path].relative_destination_path
154
154
  )
155
- if destination_path.exists() and destination_path.is_file():
155
+ if destination_path.is_symlink():
156
+ # Symlink exists - only OK if it already points to our source
157
+ expected_source = (
158
+ source_package_root / plan[child_relative_source_path].relative_source_path
159
+ ).resolve()
160
+ try:
161
+ actual_target = destination_path.resolve()
162
+ except OSError:
163
+ # Broken symlink - treat as conflict
164
+ actual_target = None
165
+ if actual_target == expected_source:
166
+ # Already installed correctly - skip it
167
+ plan[child_relative_source_path].action = Action.SKIP
168
+ else:
169
+ plan[child_relative_source_path].action = Action.FAIL
170
+ elif destination_path.exists() and destination_path.is_file():
156
171
  plan[child_relative_source_path].action = Action.FAIL
157
172
 
158
173
  # Second pass: decide action for current directory based on children and destination state
@@ -193,8 +208,9 @@ def plan_install(source_package_root: Path, destination_root: Path) -> Plan:
193
208
  plan=plan,
194
209
  )
195
210
  logger.debug(f"Directory {relative_destination_root_path} matches always-create pattern, will CREATE")
196
- else:
211
+ elif plan[relative_root_path].action != Action.CREATE:
197
212
  # Directory doesn't exist and has no rename conflicts - we can link it
213
+ # But only if it wasn't already marked CREATE by a descendant's mark_all_ancestors
198
214
  plan[relative_root_path].action = Action.LINK
199
215
 
200
216
  # Third pass: mark children based on what we decided for this directory
@@ -183,11 +183,12 @@ def execute_plan(
183
183
  # This does duplicate the loop code, but avoids thousands of repeated is_dry_run() calls
184
184
  # in installations with many files.
185
185
  if is_dry_run():
186
+ print("[DRY RUN] Would execute the equivalent of:")
186
187
  for step in steps:
187
188
  command = build_shell_command(step)
188
189
  if command is not None:
189
190
  logger.info(command)
190
- print(command)
191
+ print(f" {command}")
191
192
  else:
192
193
  for step in steps:
193
194
  command = build_shell_command(step)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotx
3
- Version: 3.2.0
3
+ Version: 3.2.2
4
4
  Summary: A command-line tool to install a link-farm to your dotfiles
5
5
  Author-email: Wolf <Wolf@zv.cx>
6
6
  License-Expression: MIT
@@ -58,6 +58,16 @@ purpose tool. It's made for installing a link-farm to any kind of package from
58
58
  it for other purposes, but it's tuned for its goal. It does the renaming task if you want it, but if your source files
59
59
  are named simply `.bashrc` it works just as well.
60
60
 
61
+ ### How to install
62
+
63
+ It's an ordinary PyPI-supplied Python package providing CLI entry points, so you can install it with `pip` or any other tool you like. I think the best way to install it is this:
64
+
65
+ ```bash
66
+ uv tool install dotx
67
+ ```
68
+
69
+ The current version is: v3.2.0
70
+
61
71
  ### The user interface
62
72
  ```
63
73
  Usage: dotx [OPTIONS] COMMAND [ARGS]...
@@ -42,9 +42,9 @@ def test_cli_install_conflict_detection(tmp_path):
42
42
 
43
43
  assert result.exit_code == 0 # CLI doesn't return error code, just refuses
44
44
  assert "can't install" in result.output.lower()
45
- assert "would overwrite" in result.output.lower()
45
+ assert "conflicts detected" in result.output.lower()
46
+ assert "existing file" in result.output.lower()
46
47
  assert "Refusing to install" in result.output
47
- assert "conflicts detected" in result.output
48
48
 
49
49
 
50
50
  def test_cli_install_with_verbose(tmp_path, isolated_db):
@@ -246,3 +246,56 @@ def test_options_functions():
246
246
  result = runner.invoke(app, ["--verbose", "--debug", "--dry-run", "debug"])
247
247
 
248
248
  assert len(result.output) == 0
249
+
250
+
251
+ def test_cli_install_dry_run_shows_indicator(tmp_path):
252
+ """Test install with --dry-run clearly indicates dry-run mode in output."""
253
+ source = tmp_path / "source"
254
+ source.mkdir()
255
+ target = tmp_path / "target"
256
+ target.mkdir()
257
+
258
+ # Create source file
259
+ (source / "file1").write_text("content1")
260
+
261
+ runner = CliRunner()
262
+
263
+ # Install with --dry-run
264
+ result = runner.invoke(app, [f"--target={target}", "--dry-run", "install", str(source)])
265
+
266
+ assert result.exit_code == 0
267
+ # Should clearly indicate dry-run mode
268
+ assert "[DRY RUN]" in result.output
269
+ assert "Would install" in result.output
270
+ # File should NOT actually be created
271
+ assert not (target / "file1").exists()
272
+
273
+
274
+ def test_cli_uninstall_dry_run_shows_indicator(tmp_path, isolated_db):
275
+ """Test uninstall with --dry-run clearly indicates dry-run mode in output."""
276
+ source = tmp_path / "source"
277
+ source.mkdir()
278
+ target = tmp_path / "target"
279
+ target.mkdir()
280
+
281
+ # Create and install source file
282
+ (source / "file1").write_text("content1")
283
+
284
+ runner = CliRunner()
285
+
286
+ # First install for real
287
+ result = runner.invoke(app, [f"--target={target}", "install", str(source)])
288
+ assert result.exit_code == 0
289
+ assert (target / "file1").exists()
290
+
291
+ # Uninstall with --dry-run
292
+ result = runner.invoke(app, [f"--target={target}", "--dry-run", "uninstall", str(source)])
293
+
294
+ assert result.exit_code == 0
295
+ # Should clearly indicate dry-run mode
296
+ assert "[DRY RUN]" in result.output
297
+ assert "Would remove" in result.output
298
+ # Should show the rm command that would be executed
299
+ assert "rm" in result.output
300
+ # File should still exist after dry-run
301
+ assert (target / "file1").exists()
@@ -117,6 +117,52 @@ def test_verify_not_symlink(tmp_path):
117
117
  assert "not a symlink" in issues[0]["issue"].lower()
118
118
 
119
119
 
120
+ def test_verify_wrong_symlink_target(tmp_path):
121
+ """Test 'dotx verify' detects symlinks pointing to wrong target."""
122
+ db_path = tmp_path / "test.db"
123
+ package = tmp_path / "package"
124
+ package.mkdir()
125
+ (package / "dot-bashrc").write_text("# correct source")
126
+
127
+ target_file = tmp_path / ".bashrc"
128
+ # Create symlink pointing to wrong location
129
+ wrong_target = tmp_path / "wrong-file"
130
+ wrong_target.write_text("# wrong target")
131
+ target_file.symlink_to(wrong_target)
132
+
133
+ # Record as if it were pointing to package
134
+ with InstallationDB(db_path) as db:
135
+ db.record_installation(tmp_path, "package", package, target_file, "file")
136
+
137
+ # Verify - should detect wrong target
138
+ with InstallationDB(db_path) as db:
139
+ issues = db.verify_installations(tmp_path, "package")
140
+ assert len(issues) == 1
141
+ assert "wrong target" in issues[0]["issue"].lower()
142
+ assert "actual" in issues[0]
143
+
144
+
145
+ def test_verify_broken_symlink(tmp_path):
146
+ """Test 'dotx verify' detects broken symlinks."""
147
+ db_path = tmp_path / "test.db"
148
+ package = tmp_path / "package"
149
+ package.mkdir()
150
+
151
+ target_file = tmp_path / ".bashrc"
152
+ # Create symlink pointing to nonexistent file
153
+ target_file.symlink_to("/nonexistent/path")
154
+
155
+ # Record installation
156
+ with InstallationDB(db_path) as db:
157
+ db.record_installation(tmp_path, "package", package, target_file, "file")
158
+
159
+ # Verify - should detect broken symlink
160
+ with InstallationDB(db_path) as db:
161
+ issues = db.verify_installations(tmp_path, "package")
162
+ assert len(issues) == 1
163
+ assert "broken" in issues[0]["issue"].lower()
164
+
165
+
120
166
  def test_show_package(tmp_path):
121
167
  """Test 'dotx show' displays package installation details."""
122
168
  db_path = tmp_path / "test.db"
@@ -557,7 +603,7 @@ def test_cli_sync_clean_dry_run(tmp_path, monkeypatch):
557
603
 
558
604
  assert result.exit_code == 0
559
605
  assert "Would clean orphaned entries" in result.output
560
- assert "Would remove 1 orphaned entry(ies)" in result.output
606
+ assert "Would remove 1 orphaned DB entry(ies)" in result.output
561
607
  assert "Dry run" in result.output
562
608
 
563
609
 
@@ -604,7 +650,7 @@ def test_cli_sync_clean(tmp_path, monkeypatch):
604
650
 
605
651
  assert result.exit_code == 0
606
652
  assert "Cleaning orphaned entries" in result.output
607
- assert "Removed 1 orphaned entry(ies)" in result.output
653
+ assert "Removed 1 orphaned DB entry(ies)" in result.output
608
654
 
609
655
  # Check database now has only 1 entry
610
656
  with InstallationDB() as db:
@@ -289,6 +289,59 @@ def test_install_normal_file_fail(tmp_path):
289
289
  )
290
290
 
291
291
 
292
+ def test_install_symlink_to_wrong_target_fails(tmp_path):
293
+ """Test that existing symlink pointing to wrong target is marked FAIL."""
294
+ source_package_root = tmp_path / "source"
295
+ destination_root = tmp_path / "dest"
296
+ source_package_root.mkdir()
297
+ destination_root.mkdir()
298
+ file_path = Path("file1")
299
+ (source_package_root / file_path).write_text("content")
300
+
301
+ # Create a symlink pointing to a different location
302
+ wrong_target = tmp_path / "wrong_target"
303
+ wrong_target.write_text("wrong content")
304
+ (destination_root / file_path).symlink_to(wrong_target)
305
+
306
+ plan = plan_install(source_package_root, destination_root)
307
+
308
+ assert plan[file_path].action == Action.FAIL
309
+
310
+
311
+ def test_install_broken_symlink_fails(tmp_path):
312
+ """Test that existing broken symlink is marked FAIL."""
313
+ source_package_root = tmp_path / "source"
314
+ destination_root = tmp_path / "dest"
315
+ source_package_root.mkdir()
316
+ destination_root.mkdir()
317
+ file_path = Path("file1")
318
+ (source_package_root / file_path).write_text("content")
319
+
320
+ # Create a broken symlink (target doesn't exist)
321
+ (destination_root / file_path).symlink_to("/nonexistent/path")
322
+
323
+ plan = plan_install(source_package_root, destination_root)
324
+
325
+ assert plan[file_path].action == Action.FAIL
326
+
327
+
328
+ def test_install_symlink_to_our_source_skips(tmp_path):
329
+ """Test that existing symlink pointing to our source is marked SKIP."""
330
+ source_package_root = tmp_path / "source"
331
+ destination_root = tmp_path / "dest"
332
+ source_package_root.mkdir()
333
+ destination_root.mkdir()
334
+ file_path = Path("file1")
335
+ (source_package_root / file_path).write_text("content")
336
+
337
+ # Create a symlink pointing to our source (already installed)
338
+ (destination_root / file_path).symlink_to(source_package_root / file_path)
339
+
340
+ plan = plan_install(source_package_root, destination_root)
341
+
342
+ assert plan[file_path].action == Action.SKIP
343
+
344
+
292
345
  def test_install_hidden_file(tmp_path):
293
346
  source_package_root = tmp_path / "source"
294
347
  destination_root = tmp_path / "dest"
@@ -543,3 +596,86 @@ def test_install_renamed_dir_inside_renamed_dir(tmp_path):
543
596
  Action.LINK, True, dir2_path, Path(".SIMPLE-DIR1/.SIMPLE-DIR2"), True
544
597
  )
545
598
  assert plan[file_path].action == Action.SKIP
599
+
600
+
601
+ def test_install_deep_nesting_with_renamed_leaf(tmp_path):
602
+ """
603
+ Test that deeply nested directories with a renamed file at the leaf install correctly.
604
+
605
+ This tests the fix for a bug where:
606
+ - Deep directory structure: a/b/c/d/e/f/dot-deepfile
607
+ - The leaf file needs renaming (dot-deepfile -> .deepfile)
608
+ - Directory f gets marked CREATE (because child needs renaming)
609
+ - All parent directories should also be CREATE (via mark_all_ancestors)
610
+
611
+ Bug was: parent dirs were overwritten to LINK when processed bottom-up,
612
+ causing FileExistsError when we tried to CREATE f under a symlinked parent.
613
+
614
+ Fix: preserve CREATE if already set by a descendant's mark_all_ancestors.
615
+ """
616
+ source_package_root = tmp_path / "source"
617
+ destination_root = tmp_path / "dest"
618
+ source_package_root.mkdir()
619
+ destination_root.mkdir()
620
+
621
+ # Create deep nesting: a/b/c/d/e/f/dot-deepfile
622
+ deep_path = Path("a/b/c/d/e/f")
623
+ file_path = deep_path / "dot-deepfile"
624
+ (source_package_root / deep_path).mkdir(parents=True)
625
+ (source_package_root / file_path).write_text("deep content")
626
+
627
+ plan = plan_install(source_package_root, destination_root)
628
+
629
+ # All directories should be CREATE (to support the renamed file at the leaf)
630
+ for part_count in range(1, 7): # a, b, c, d, e, f
631
+ parts = ["a", "b", "c", "d", "e", "f"][:part_count]
632
+ subdir_path = Path("/".join(parts))
633
+ assert plan[subdir_path].action == Action.CREATE, f"{subdir_path} should be CREATE"
634
+
635
+ # The file should be LINK with renaming
636
+ assert plan[file_path].action == Action.LINK
637
+ assert plan[file_path].requires_rename
638
+ assert plan[file_path].relative_destination_path == Path("a/b/c/d/e/f/.deepfile")
639
+
640
+
641
+ def test_install_deep_nesting_execute(tmp_path, isolated_db):
642
+ """
643
+ Test that deeply nested directories with renamed leaf actually install without error.
644
+
645
+ This is an integration test that verifies the full install flow works,
646
+ not just the planning phase.
647
+ """
648
+ from typer.testing import CliRunner
649
+
650
+ from dotx.cli import app
651
+
652
+ source_package_root = tmp_path / "source"
653
+ destination_root = tmp_path / "dest"
654
+ source_package_root.mkdir()
655
+ destination_root.mkdir()
656
+
657
+ # Create deep nesting: a/b/c/d/e/f/dot-deepfile
658
+ deep_path = Path("a/b/c/d/e/f")
659
+ file_path = deep_path / "dot-deepfile"
660
+ (source_package_root / deep_path).mkdir(parents=True)
661
+ (source_package_root / file_path).write_text("deep content")
662
+
663
+ runner = CliRunner()
664
+
665
+ # Execute install via CLI - should not raise an error
666
+ result = runner.invoke(app, [f"--target={destination_root}", "install", str(source_package_root)])
667
+
668
+ assert result.exit_code == 0, f"Install failed: {result.output}"
669
+
670
+ # Verify: all directories should be real directories (CREATE), not symlinks
671
+ for part_count in range(1, 7):
672
+ parts = ["a", "b", "c", "d", "e", "f"][:part_count]
673
+ dir_path = destination_root / "/".join(parts)
674
+ assert dir_path.is_dir(), f"{dir_path} should be a directory"
675
+ assert not dir_path.is_symlink(), f"{dir_path} should NOT be a symlink"
676
+
677
+ # The file should be a symlink with the renamed name (.deepfile, not dot-deepfile)
678
+ deep_file_dest = destination_root / "a/b/c/d/e/f/.deepfile"
679
+ assert deep_file_dest.is_symlink()
680
+ assert deep_file_dest.exists()
681
+ assert deep_file_dest.read_text() == "deep content"
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
File without changes
File without changes
File without changes
File without changes