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