dotx 3.2.1__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.1/src/dotx.egg-info → dotx-3.2.2}/PKG-INFO +1 -1
  2. {dotx-3.2.1 → dotx-3.2.2}/pyproject.toml +1 -1
  3. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/commands/database.py +36 -6
  4. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/commands/install_cmd.py +10 -2
  5. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/database.py +53 -14
  6. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/install.py +17 -2
  7. {dotx-3.2.1 → dotx-3.2.2/src/dotx.egg-info}/PKG-INFO +1 -1
  8. {dotx-3.2.1 → dotx-3.2.2}/tests/test_cli.py +4 -2
  9. {dotx-3.2.1 → dotx-3.2.2}/tests/test_cli_database.py +48 -2
  10. {dotx-3.2.1 → dotx-3.2.2}/tests/test_install.py +53 -0
  11. {dotx-3.2.1 → dotx-3.2.2}/LICENSE +0 -0
  12. {dotx-3.2.1 → dotx-3.2.2}/MANIFEST.in +0 -0
  13. {dotx-3.2.1 → dotx-3.2.2}/README.md +0 -0
  14. {dotx-3.2.1 → dotx-3.2.2}/setup.cfg +0 -0
  15. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/__init__.py +0 -0
  16. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/always-create +0 -0
  17. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/cli.py +0 -0
  18. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/commands/__init__.py +0 -0
  19. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/commands/path_cmd.py +0 -0
  20. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/commands/progress.py +0 -0
  21. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/commands/uninstall_cmd.py +0 -0
  22. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/dotxignore +0 -0
  23. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/hierarchy.py +0 -0
  24. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/ignore.py +0 -0
  25. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/installed-schema.sql +0 -0
  26. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/options.py +0 -0
  27. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/plan.py +0 -0
  28. {dotx-3.2.1 → dotx-3.2.2}/src/dotx/uninstall.py +0 -0
  29. {dotx-3.2.1 → dotx-3.2.2}/src/dotx.egg-info/SOURCES.txt +0 -0
  30. {dotx-3.2.1 → dotx-3.2.2}/src/dotx.egg-info/dependency_links.txt +0 -0
  31. {dotx-3.2.1 → dotx-3.2.2}/src/dotx.egg-info/entry_points.txt +0 -0
  32. {dotx-3.2.1 → dotx-3.2.2}/src/dotx.egg-info/requires.txt +0 -0
  33. {dotx-3.2.1 → dotx-3.2.2}/src/dotx.egg-info/top_level.txt +0 -0
  34. {dotx-3.2.1 → dotx-3.2.2}/tests/test_always_create.py +0 -0
  35. {dotx-3.2.1 → dotx-3.2.2}/tests/test_ignore.py +0 -0
  36. {dotx-3.2.1 → dotx-3.2.2}/tests/test_ignore_rules.py +0 -0
  37. {dotx-3.2.1 → dotx-3.2.2}/tests/test_options.py +0 -0
  38. {dotx-3.2.1 → dotx-3.2.2}/tests/test_path_which.py +0 -0
  39. {dotx-3.2.1 → dotx-3.2.2}/tests/test_plan.py +0 -0
  40. {dotx-3.2.1 → 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.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dotx"
3
- version = "3.2.1"
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
 
@@ -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:
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotx
3
- Version: 3.2.1
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
@@ -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):
@@ -295,5 +295,7 @@ def test_cli_uninstall_dry_run_shows_indicator(tmp_path, isolated_db):
295
295
  # Should clearly indicate dry-run mode
296
296
  assert "[DRY RUN]" in result.output
297
297
  assert "Would remove" in result.output
298
+ # Should show the rm command that would be executed
299
+ assert "rm" in result.output
298
300
  # File should still exist after dry-run
299
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"
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
File without changes
File without changes