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.
- {dotx-3.2.1/src/dotx.egg-info → dotx-3.3.0}/PKG-INFO +1 -1
- {dotx-3.2.1 → dotx-3.3.0}/pyproject.toml +1 -1
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/cli.py +13 -1
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/database.py +36 -6
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/install_cmd.py +10 -2
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/database.py +53 -14
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/install.py +28 -6
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/options.py +28 -1
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/plan.py +40 -3
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/uninstall.py +14 -6
- {dotx-3.2.1 → dotx-3.3.0/src/dotx.egg-info}/PKG-INFO +1 -1
- {dotx-3.2.1 → dotx-3.3.0}/tests/test_cli.py +94 -2
- {dotx-3.2.1 → dotx-3.3.0}/tests/test_cli_database.py +48 -2
- {dotx-3.2.1 → dotx-3.3.0}/tests/test_install.py +141 -0
- {dotx-3.2.1 → dotx-3.3.0}/LICENSE +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/MANIFEST.in +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/README.md +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/setup.cfg +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/__init__.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/always-create +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/__init__.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/path_cmd.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/progress.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/commands/uninstall_cmd.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/dotxignore +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/hierarchy.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/ignore.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx/installed-schema.sql +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx.egg-info/SOURCES.txt +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx.egg-info/dependency_links.txt +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx.egg-info/entry_points.txt +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx.egg-info/requires.txt +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/src/dotx.egg-info/top_level.txt +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/tests/test_always_create.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/tests/test_ignore.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/tests/test_ignore_rules.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/tests/test_options.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/tests/test_path_which.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/tests/test_plan.py +0 -0
- {dotx-3.2.1 → dotx-3.3.0}/tests/test_uninstall.py +0 -0
|
@@ -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[
|
|
156
|
-
console.print(f" [dim]Issue: {issue[
|
|
157
|
-
console.print(f" [dim]Expected: {issue[
|
|
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} -
|
|
59
|
+
f"[red]✗ Error: can't install {source_package.name} - conflicts detected:[/red]"
|
|
60
60
|
)
|
|
61
61
|
for plan_node in failures:
|
|
62
|
-
|
|
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
|
|
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":
|
|
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.
|
|
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":
|
|
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
|
|
151
|
-
destination_path = (
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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():
|
|
@@ -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 "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|