dotx 3.2.1__tar.gz → 3.3.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 (40) hide show
  1. {dotx-3.2.1/src/dotx.egg-info → dotx-3.3.0}/PKG-INFO +1 -1
  2. {dotx-3.2.1 → dotx-3.3.0}/pyproject.toml +1 -1
  3. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/cli.py +13 -1
  4. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/database.py +36 -6
  5. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/install_cmd.py +10 -2
  6. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/database.py +53 -14
  7. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/install.py +28 -6
  8. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/options.py +28 -1
  9. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/plan.py +40 -3
  10. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/uninstall.py +14 -6
  11. {dotx-3.2.1 → dotx-3.3.0/src/dotx.egg-info}/PKG-INFO +1 -1
  12. {dotx-3.2.1 → dotx-3.3.0}/tests/test_cli.py +94 -2
  13. {dotx-3.2.1 → dotx-3.3.0}/tests/test_cli_database.py +48 -2
  14. {dotx-3.2.1 → dotx-3.3.0}/tests/test_install.py +141 -0
  15. {dotx-3.2.1 → dotx-3.3.0}/LICENSE +0 -0
  16. {dotx-3.2.1 → dotx-3.3.0}/MANIFEST.in +0 -0
  17. {dotx-3.2.1 → dotx-3.3.0}/README.md +0 -0
  18. {dotx-3.2.1 → dotx-3.3.0}/setup.cfg +0 -0
  19. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/__init__.py +0 -0
  20. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/always-create +0 -0
  21. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/__init__.py +0 -0
  22. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/path_cmd.py +0 -0
  23. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/progress.py +0 -0
  24. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/uninstall_cmd.py +0 -0
  25. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/dotxignore +0 -0
  26. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/hierarchy.py +0 -0
  27. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/ignore.py +0 -0
  28. {dotx-3.2.1 → dotx-3.3.0}/src/dotx/installed-schema.sql +0 -0
  29. {dotx-3.2.1 → dotx-3.3.0}/src/dotx.egg-info/SOURCES.txt +0 -0
  30. {dotx-3.2.1 → dotx-3.3.0}/src/dotx.egg-info/dependency_links.txt +0 -0
  31. {dotx-3.2.1 → dotx-3.3.0}/src/dotx.egg-info/entry_points.txt +0 -0
  32. {dotx-3.2.1 → dotx-3.3.0}/src/dotx.egg-info/requires.txt +0 -0
  33. {dotx-3.2.1 → dotx-3.3.0}/src/dotx.egg-info/top_level.txt +0 -0
  34. {dotx-3.2.1 → dotx-3.3.0}/tests/test_always_create.py +0 -0
  35. {dotx-3.2.1 → dotx-3.3.0}/tests/test_ignore.py +0 -0
  36. {dotx-3.2.1 → dotx-3.3.0}/tests/test_ignore_rules.py +0 -0
  37. {dotx-3.2.1 → dotx-3.3.0}/tests/test_options.py +0 -0
  38. {dotx-3.2.1 → dotx-3.3.0}/tests/test_path_which.py +0 -0
  39. {dotx-3.2.1 → dotx-3.3.0}/tests/test_plan.py +0 -0
  40. {dotx-3.2.1 → dotx-3.3.0}/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.3.0
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.3.0"
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" }
@@ -68,13 +68,20 @@ def main(
68
68
  target: Annotated[
69
69
  Path | None,
70
70
  typer.Option(
71
- help="Where to install (defaults to $HOME)",
71
+ help="Where to install (defaults to $HOME). Mutually exclusive with --xdg.",
72
72
  exists=True,
73
73
  file_okay=False,
74
74
  dir_okay=True,
75
75
  writable=True,
76
76
  ),
77
77
  ] = None,
78
+ xdg: Annotated[
79
+ bool,
80
+ typer.Option(
81
+ "--xdg/--no-xdg",
82
+ help="Use XDG Base Directory paths (.config→$XDG_CONFIG_HOME, etc). Mutually exclusive with --target.",
83
+ ),
84
+ ] = False,
78
85
  dry_run: Annotated[
79
86
  bool,
80
87
  typer.Option("--dry-run/--no-dry-run", help="Just echo; don't actually (un)install"),
@@ -91,6 +98,10 @@ def main(
91
98
  """
92
99
  configure_logging(debug, verbose, log)
93
100
 
101
+ # Check mutual exclusivity
102
+ if xdg and target is not None:
103
+ raise typer.BadParameter("--xdg and --target are mutually exclusive")
104
+
94
105
  # Store options in context for commands to access
95
106
  ctx.ensure_object(dict)
96
107
  if target:
@@ -101,6 +112,7 @@ def main(
101
112
  set_option("DEBUG", debug, ctx)
102
113
  set_option("VERBOSE", verbose, ctx)
103
114
  set_option("DRYRUN", dry_run, ctx)
115
+ set_option("XDG", xdg, ctx)
104
116
 
105
117
  if log:
106
118
  set_option("LOG", log, ctx)
@@ -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
 
@@ -23,6 +23,7 @@ from loguru import logger
23
23
 
24
24
  from dotx.hierarchy import HierarchicalPatternMatcher
25
25
  from dotx.ignore import IgnoreRules
26
+ from dotx.options import is_xdg_mode
26
27
  from dotx.plan import (
27
28
  Action,
28
29
  Plan,
@@ -30,6 +31,7 @@ from dotx.plan import (
30
31
  log_extracted_plan,
31
32
  mark_all_ancestors,
32
33
  mark_immediate_children,
34
+ resolve_destination,
33
35
  )
34
36
 
35
37
 
@@ -99,9 +101,13 @@ def plan_install(source_package_root: Path, destination_root: Path) -> Plan:
99
101
  already exists at the destination, which must be created, renamed, linked, or already exist in a way that causes
100
102
  a failure.
101
103
 
104
+ Respects XDG mode: when enabled, .config/*, .local/share/*, .cache/* are resolved to their
105
+ respective XDG Base Directory paths when checking for existing files.
106
+
102
107
  Returns: a `Plan` with all the information needed to complete an install, or to fail
103
108
  """
104
109
  plan: Plan = plan_install_paths(source_package_root)
110
+ xdg_mode = is_xdg_mode()
105
111
 
106
112
  # Load always-create patterns to determine which directories must be real (never symlinked)
107
113
  always_create_matcher = HierarchicalPatternMatcher(".always-create")
@@ -147,19 +153,35 @@ def plan_install(source_package_root: Path, destination_root: Path) -> Plan:
147
153
  # we can't link the whole directory - must CREATE it instead
148
154
  if plan[child_relative_source_path].requires_rename:
149
155
  found_children_to_rename = True
150
- # Fail if we would overwrite an existing regular file
151
- destination_path = (
152
- destination_root
153
- / plan[child_relative_source_path].relative_destination_path
156
+ # Fail if we would overwrite an existing file or symlink pointing elsewhere
157
+ destination_path = resolve_destination(
158
+ plan[child_relative_source_path].relative_destination_path,
159
+ destination_root,
160
+ xdg_mode,
154
161
  )
155
- if destination_path.exists() and destination_path.is_file():
162
+ if destination_path.is_symlink():
163
+ # Symlink exists - only OK if it already points to our source
164
+ expected_source = (
165
+ source_package_root / plan[child_relative_source_path].relative_source_path
166
+ ).resolve()
167
+ try:
168
+ actual_target = destination_path.resolve()
169
+ except OSError:
170
+ # Broken symlink - treat as conflict
171
+ actual_target = None
172
+ if actual_target == expected_source:
173
+ # Already installed correctly - skip it
174
+ plan[child_relative_source_path].action = Action.SKIP
175
+ else:
176
+ plan[child_relative_source_path].action = Action.FAIL
177
+ elif destination_path.exists() and destination_path.is_file():
156
178
  plan[child_relative_source_path].action = Action.FAIL
157
179
 
158
180
  # Second pass: decide action for current directory based on children and destination state
159
181
  if current_root_path == source_package_root:
160
182
  # Package root always EXISTS (it's the target directory)
161
183
  plan[relative_root_path].action = Action.EXISTS
162
- elif (destination_root / relative_destination_root_path).exists():
184
+ elif resolve_destination(relative_destination_root_path, destination_root, xdg_mode).exists():
163
185
  # Directory already exists at destination - merge into it
164
186
  # This takes precedence over always-create because we can't create what exists
165
187
  plan[relative_root_path].action = Action.EXISTS
@@ -4,6 +4,7 @@ This module provides convenience functions for accessing user-data on the associ
4
4
  Note: typer is built on click, so we use click.Context for type annotations.
5
5
  """
6
6
 
7
+ from pathlib import Path
7
8
  from typing import Any
8
9
 
9
10
  import click
@@ -36,7 +37,8 @@ def get_option(option: str, default_for_option: Any = None, ctx: click.Context |
36
37
  """
37
38
  if ctx is None:
38
39
  # Typer uses click under the hood, so we can use click's get_current_context
39
- ctx = click.get_current_context()
40
+ # Use silent=True to return None instead of raising when no context exists
41
+ ctx = click.get_current_context(silent=True)
40
42
  if ctx is not None and ctx.obj is not None and option in ctx.obj:
41
43
  return ctx.obj[option]
42
44
  return default_for_option
@@ -55,3 +57,28 @@ def is_debug_mode(ctx: click.Context | None = None) -> bool:
55
57
  def is_dry_run(ctx: click.Context | None = None) -> bool:
56
58
  """Check if dry-run mode is enabled."""
57
59
  return get_option("DRYRUN", False, ctx)
60
+
61
+
62
+ def is_xdg_mode(ctx: click.Context | None = None) -> bool:
63
+ """Check if XDG mode is enabled."""
64
+ return get_option("XDG", False, ctx)
65
+
66
+
67
+ def get_xdg_paths() -> dict[str, Path]:
68
+ """
69
+ Get XDG Base Directory paths with defaults.
70
+
71
+ Returns a dict mapping destination prefixes to their XDG paths:
72
+ - ".config" → $XDG_CONFIG_HOME (default ~/.config)
73
+ - ".local/share" → $XDG_DATA_HOME (default ~/.local/share)
74
+ - ".cache" → $XDG_CACHE_HOME (default ~/.cache)
75
+ """
76
+ import os
77
+
78
+ home = Path.home()
79
+
80
+ return {
81
+ ".config": Path(os.environ.get("XDG_CONFIG_HOME", home / ".config")),
82
+ ".local/share": Path(os.environ.get("XDG_DATA_HOME", home / ".local/share")),
83
+ ".cache": Path(os.environ.get("XDG_CACHE_HOME", home / ".cache")),
84
+ }
@@ -34,7 +34,7 @@ from typing import TYPE_CHECKING
34
34
  from loguru import logger
35
35
 
36
36
  from dotx.database import NoOpDB
37
- from dotx.options import is_dry_run
37
+ from dotx.options import is_dry_run, is_xdg_mode, get_xdg_paths
38
38
 
39
39
  if TYPE_CHECKING:
40
40
  from dotx.database import InstallationDB
@@ -91,6 +91,33 @@ class PlanNode:
91
91
  Plan = dict[Path, PlanNode]
92
92
 
93
93
 
94
+ def resolve_destination(relative_path: Path, default_root: Path, xdg_mode: bool = False) -> Path:
95
+ """
96
+ Resolve the absolute destination path, respecting XDG mode.
97
+
98
+ In normal mode: default_root / relative_path
99
+ In XDG mode: redirects .config/*, .local/share/*, .cache/* to XDG directories
100
+ """
101
+ if not xdg_mode:
102
+ return default_root / relative_path
103
+
104
+ path_str = str(relative_path)
105
+ xdg_paths = get_xdg_paths()
106
+
107
+ # Check XDG prefixes in order of specificity (longer first)
108
+ for prefix in sorted(xdg_paths.keys(), key=len, reverse=True):
109
+ if path_str == prefix:
110
+ # Exact match - return the XDG dir itself
111
+ return xdg_paths[prefix]
112
+ if path_str.startswith(prefix + "/"):
113
+ # Path under XDG dir - remap it
114
+ suffix = path_str[len(prefix) + 1:]
115
+ return xdg_paths[prefix] / suffix
116
+
117
+ # No XDG prefix match - use default (HOME)
118
+ return default_root / relative_path
119
+
120
+
94
121
  def execute_plan(
95
122
  source_package_root: Path,
96
123
  destination_root: Path,
@@ -104,18 +131,28 @@ def execute_plan(
104
131
  links, or unlinks files using pathlib native functions.
105
132
 
106
133
  If a database is provided, records installations (CREATE, LINK) and removals (UNLINK).
134
+
135
+ Respects XDG mode: when enabled, .config/*, .local/share/*, .cache/* are redirected
136
+ to their respective XDG Base Directory paths.
107
137
  """
108
138
  # Use NoOpDB if no database provided
109
139
  working_db = db if db is not None else NoOpDB()
110
140
 
141
+ # Check XDG mode
142
+ xdg_mode = is_xdg_mode()
143
+
111
144
  # Extract package info for database tracking
112
145
  package_root = source_package_root.parent
113
146
  package_name = source_package_root.name
114
147
 
148
+ def get_destination(step: PlanNode) -> Path:
149
+ """Get the absolute destination path, respecting XDG mode."""
150
+ return resolve_destination(step.relative_destination_path, destination_root, xdg_mode)
151
+
115
152
  def build_shell_command(step: PlanNode):
116
153
  """Print the shell command corresponding to exactly one `PlanNode`"""
117
154
  command = None
118
- destination = destination_root / step.relative_destination_path
155
+ destination = get_destination(step)
119
156
  source = (source_package_root / step.relative_source_path).resolve()
120
157
  try:
121
158
  source = source.relative_to(destination.parent)
@@ -147,7 +184,7 @@ def execute_plan(
147
184
 
148
185
  Records installations and removals in database.
149
186
  """
150
- destination = destination_root / step.relative_destination_path
187
+ destination = get_destination(step)
151
188
  source = (source_package_root / step.relative_source_path).resolve()
152
189
  try:
153
190
  source = source.relative_to(destination.parent)
@@ -11,7 +11,8 @@ Exported functions:
11
11
  import os
12
12
  from pathlib import Path
13
13
 
14
- from dotx.plan import Action, Plan, mark_all_descendents
14
+ from dotx.options import is_xdg_mode
15
+ from dotx.plan import Action, Plan, mark_all_descendents, resolve_destination
15
16
  from dotx.install import plan_install_paths
16
17
 
17
18
 
@@ -19,9 +20,13 @@ def plan_uninstall(source_package_root: Path, destination_root: Path) -> Plan:
19
20
  """
20
21
  Create a plan to uninstall files from destination_root that link to source_package_root.
21
22
 
23
+ Respects XDG mode: when enabled, .config/*, .local/share/*, .cache/* are resolved to their
24
+ respective XDG Base Directory paths when checking for symlinks to remove.
25
+
22
26
  Returns: a `Plan` with actions set to UNLINK for symlinks pointing to the source package
23
27
  """
24
28
  plan: Plan = plan_install_paths(source_package_root)
29
+ xdg_mode = is_xdg_mode()
25
30
 
26
31
  for current_root, _, child_files in os.walk(source_package_root):
27
32
  current_root_path = Path(current_root)
@@ -36,15 +41,18 @@ def plan_uninstall(source_package_root: Path, destination_root: Path) -> Plan:
36
41
  child_relative_source_path = relative_root_path / child
37
42
  if child_relative_source_path not in plan:
38
43
  continue
39
- destination_path = (
40
- destination_root
41
- / plan[child_relative_source_path].relative_destination_path
44
+ destination_path = resolve_destination(
45
+ plan[child_relative_source_path].relative_destination_path,
46
+ destination_root,
47
+ xdg_mode,
42
48
  )
43
49
  if destination_path.is_symlink():
44
50
  plan[child_relative_source_path].action = Action.UNLINK
45
51
 
46
- destination_path = (
47
- destination_root / plan[relative_root_path].relative_destination_path
52
+ destination_path = resolve_destination(
53
+ plan[relative_root_path].relative_destination_path,
54
+ destination_root,
55
+ xdg_mode,
48
56
  )
49
57
  action = None
50
58
  if not destination_path.exists():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotx
3
- Version: 3.2.1
3
+ Version: 3.3.0
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,97 @@ 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()
302
+
303
+
304
+ def test_cli_xdg_and_target_mutually_exclusive(tmp_path, isolated_db):
305
+ """Test that --xdg and --target cannot be used together."""
306
+ source = tmp_path / "source"
307
+ source.mkdir()
308
+ (source / "file1").write_text("content")
309
+
310
+ runner = CliRunner()
311
+
312
+ # Try to use both --xdg and --target
313
+ result = runner.invoke(app, ["--xdg", f"--target={tmp_path}", "install", str(source)])
314
+
315
+ assert result.exit_code != 0
316
+ assert "mutually exclusive" in result.output.lower()
317
+
318
+
319
+ def _has_symlink_ancestor(path: Path) -> bool:
320
+ """Check if any ancestor of path is a symlink."""
321
+ for parent in path.parents:
322
+ if parent.is_symlink():
323
+ return True
324
+ return path.is_symlink()
325
+
326
+
327
+ def test_cli_xdg_mode_install_to_xdg_paths(tmp_path, monkeypatch, isolated_db):
328
+ """Test that --xdg mode installs .config files to XDG_CONFIG_HOME."""
329
+ source = tmp_path / "source"
330
+ source.mkdir()
331
+
332
+ # Create .config/app directory with config file
333
+ config_dir = source / "dot-config" / "myapp"
334
+ config_dir.mkdir(parents=True)
335
+ (config_dir / "config.toml").write_text("setting = true")
336
+
337
+ # Set custom XDG_CONFIG_HOME
338
+ custom_config = tmp_path / "custom-config"
339
+ custom_config.mkdir()
340
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(custom_config))
341
+
342
+ runner = CliRunner()
343
+
344
+ # Install with --xdg mode
345
+ result = runner.invoke(app, ["--xdg", "install", str(source)])
346
+
347
+ assert result.exit_code == 0, f"Install failed: {result.output}"
348
+
349
+ # File should be accessible at XDG path
350
+ config_file = custom_config / "myapp" / "config.toml"
351
+ assert config_file.exists(), "config.toml should be accessible at XDG path"
352
+
353
+ # File should be connected to source via symlink (either directly or via ancestor)
354
+ assert _has_symlink_ancestor(config_file), "File or ancestor should be a symlink"
355
+
356
+ # Content should match source
357
+ assert config_file.read_text() == "setting = true"
358
+
359
+
360
+ def test_cli_xdg_mode_install_and_uninstall_roundtrip(tmp_path, monkeypatch, isolated_db):
361
+ """Test that --xdg mode works for both install and uninstall."""
362
+ source = tmp_path / "source"
363
+ source.mkdir()
364
+
365
+ # Create .config/app directory with config file
366
+ config_dir = source / "dot-config" / "myapp"
367
+ config_dir.mkdir(parents=True)
368
+ (config_dir / "config.toml").write_text("setting = true")
369
+
370
+ # Set custom XDG_CONFIG_HOME
371
+ custom_config = tmp_path / "custom-config"
372
+ custom_config.mkdir()
373
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(custom_config))
374
+
375
+ runner = CliRunner()
376
+
377
+ # Install with --xdg mode
378
+ result = runner.invoke(app, ["--xdg", "install", str(source)])
379
+ assert result.exit_code == 0, f"Install failed: {result.output}"
380
+
381
+ # Verify file is installed and accessible
382
+ config_file = custom_config / "myapp" / "config.toml"
383
+ assert config_file.exists(), "File should be accessible at XDG path"
384
+ assert _has_symlink_ancestor(config_file), "File or ancestor should be a symlink"
385
+
386
+ # Uninstall with --xdg mode
387
+ result = runner.invoke(app, ["--xdg", "uninstall", str(source)])
388
+ assert result.exit_code == 0, f"Uninstall failed: {result.output}"
389
+
390
+ # File should be removed (not accessible anymore)
391
+ assert not config_file.exists(), "File should be removed after uninstall"
@@ -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"
@@ -626,3 +679,91 @@ def test_install_deep_nesting_execute(tmp_path, isolated_db):
626
679
  assert deep_file_dest.is_symlink()
627
680
  assert deep_file_dest.exists()
628
681
  assert deep_file_dest.read_text() == "deep content"
682
+
683
+
684
+ # --- XDG Mode Tests ---
685
+
686
+
687
+ def test_resolve_destination_no_xdg():
688
+ """Test that resolve_destination without XDG mode just joins paths."""
689
+ from dotx.plan import resolve_destination
690
+
691
+ default_root = Path("/home/user")
692
+ relative_path = Path(".config/app/config.toml")
693
+
694
+ result = resolve_destination(relative_path, default_root, xdg_mode=False)
695
+
696
+ assert result == Path("/home/user/.config/app/config.toml")
697
+
698
+
699
+ def test_resolve_destination_xdg_config(monkeypatch):
700
+ """Test that resolve_destination with XDG mode redirects .config paths."""
701
+ from dotx.plan import resolve_destination
702
+
703
+ # Set custom XDG_CONFIG_HOME
704
+ monkeypatch.setenv("XDG_CONFIG_HOME", "/custom/config")
705
+
706
+ default_root = Path("/home/user")
707
+ relative_path = Path(".config/app/config.toml")
708
+
709
+ result = resolve_destination(relative_path, default_root, xdg_mode=True)
710
+
711
+ assert result == Path("/custom/config/app/config.toml")
712
+
713
+
714
+ def test_resolve_destination_xdg_data(monkeypatch):
715
+ """Test that resolve_destination with XDG mode redirects .local/share paths."""
716
+ from dotx.plan import resolve_destination
717
+
718
+ # Set custom XDG_DATA_HOME
719
+ monkeypatch.setenv("XDG_DATA_HOME", "/custom/data")
720
+
721
+ default_root = Path("/home/user")
722
+ relative_path = Path(".local/share/app/data.db")
723
+
724
+ result = resolve_destination(relative_path, default_root, xdg_mode=True)
725
+
726
+ assert result == Path("/custom/data/app/data.db")
727
+
728
+
729
+ def test_resolve_destination_xdg_cache(monkeypatch):
730
+ """Test that resolve_destination with XDG mode redirects .cache paths."""
731
+ from dotx.plan import resolve_destination
732
+
733
+ # Set custom XDG_CACHE_HOME
734
+ monkeypatch.setenv("XDG_CACHE_HOME", "/custom/cache")
735
+
736
+ default_root = Path("/home/user")
737
+ relative_path = Path(".cache/app/cache.db")
738
+
739
+ result = resolve_destination(relative_path, default_root, xdg_mode=True)
740
+
741
+ assert result == Path("/custom/cache/app/cache.db")
742
+
743
+
744
+ def test_resolve_destination_xdg_non_xdg_path(monkeypatch):
745
+ """Test that paths not matching XDG prefixes still use default_root in XDG mode."""
746
+ from dotx.plan import resolve_destination
747
+
748
+ monkeypatch.setenv("XDG_CONFIG_HOME", "/custom/config")
749
+
750
+ default_root = Path("/home/user")
751
+ relative_path = Path(".bashrc")
752
+
753
+ result = resolve_destination(relative_path, default_root, xdg_mode=True)
754
+
755
+ assert result == Path("/home/user/.bashrc")
756
+
757
+
758
+ def test_resolve_destination_xdg_exact_match(monkeypatch):
759
+ """Test that exact XDG prefix paths resolve to XDG directory itself."""
760
+ from dotx.plan import resolve_destination
761
+
762
+ monkeypatch.setenv("XDG_CONFIG_HOME", "/custom/config")
763
+
764
+ default_root = Path("/home/user")
765
+ relative_path = Path(".config")
766
+
767
+ result = resolve_destination(relative_path, default_root, xdg_mode=True)
768
+
769
+ assert result == Path("/custom/config")
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