dotx 3.2.2__tar.gz → 3.3.1__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.2/src/dotx.egg-info → dotx-3.3.1}/PKG-INFO +1 -1
- {dotx-3.2.2 → dotx-3.3.1}/pyproject.toml +1 -1
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/cli.py +13 -1
- dotx-3.3.1/src/dotx/commands/uninstall_cmd.py +155 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/install.py +11 -4
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/options.py +28 -1
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/plan.py +40 -3
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/uninstall.py +14 -6
- {dotx-3.2.2 → dotx-3.3.1/src/dotx.egg-info}/PKG-INFO +1 -1
- {dotx-3.2.2 → dotx-3.3.1}/tests/test_cli.py +156 -0
- {dotx-3.2.2 → dotx-3.3.1}/tests/test_install.py +88 -0
- dotx-3.2.2/src/dotx/commands/uninstall_cmd.py +0 -75
- {dotx-3.2.2 → dotx-3.3.1}/LICENSE +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/MANIFEST.in +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/README.md +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/setup.cfg +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/__init__.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/always-create +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/commands/__init__.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/commands/database.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/commands/install_cmd.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/commands/path_cmd.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/commands/progress.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/database.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/dotxignore +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/hierarchy.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/ignore.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx/installed-schema.sql +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx.egg-info/SOURCES.txt +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx.egg-info/dependency_links.txt +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx.egg-info/entry_points.txt +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx.egg-info/requires.txt +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/src/dotx.egg-info/top_level.txt +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/tests/test_always_create.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/tests/test_cli_database.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/tests/test_ignore.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/tests/test_ignore_rules.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/tests/test_options.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/tests/test_path_which.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/tests/test_plan.py +0 -0
- {dotx-3.2.2 → dotx-3.3.1}/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)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Uninstall command for dotx CLI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from dotx.commands.progress import execute_plans_with_progress
|
|
11
|
+
from dotx.database import InstallationDB
|
|
12
|
+
from dotx.options import is_dry_run, is_verbose_mode
|
|
13
|
+
from dotx.plan import Action, Plan, extract_plan, log_extracted_plan
|
|
14
|
+
from dotx.uninstall import plan_uninstall
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _uninstall_from_database(
|
|
18
|
+
package_path: Path,
|
|
19
|
+
db: InstallationDB,
|
|
20
|
+
console: Console,
|
|
21
|
+
dry_run: bool,
|
|
22
|
+
verbose: bool,
|
|
23
|
+
) -> int:
|
|
24
|
+
"""
|
|
25
|
+
Uninstall a package using database records when source directory is missing.
|
|
26
|
+
|
|
27
|
+
Returns the number of symlinks removed.
|
|
28
|
+
"""
|
|
29
|
+
package_root = package_path.parent
|
|
30
|
+
package_name = package_path.name
|
|
31
|
+
|
|
32
|
+
installations = db.get_installations(package_root, package_name)
|
|
33
|
+
|
|
34
|
+
if not installations:
|
|
35
|
+
console.print(f"[yellow]No installations found for {package_name}[/yellow]")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
removed_count = 0
|
|
39
|
+
|
|
40
|
+
for entry in installations:
|
|
41
|
+
target_path = Path(entry["target_path"])
|
|
42
|
+
|
|
43
|
+
if dry_run:
|
|
44
|
+
if target_path.is_symlink():
|
|
45
|
+
console.print(f" rm {target_path}")
|
|
46
|
+
removed_count += 1
|
|
47
|
+
elif verbose:
|
|
48
|
+
console.print(f" [dim]skip (not a symlink): {target_path}[/dim]")
|
|
49
|
+
else:
|
|
50
|
+
if target_path.is_symlink():
|
|
51
|
+
try:
|
|
52
|
+
target_path.unlink()
|
|
53
|
+
db.remove_installation(target_path)
|
|
54
|
+
removed_count += 1
|
|
55
|
+
if verbose:
|
|
56
|
+
console.print(f" Removed: {target_path}")
|
|
57
|
+
except OSError as e:
|
|
58
|
+
logger.warning(f"Failed to remove {target_path}: {e}")
|
|
59
|
+
else:
|
|
60
|
+
# Not a symlink - just remove from database
|
|
61
|
+
db.remove_installation(target_path)
|
|
62
|
+
if verbose:
|
|
63
|
+
console.print(f" [dim]Cleaned db entry (not a symlink): {target_path}[/dim]")
|
|
64
|
+
|
|
65
|
+
return removed_count
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def register_command(app: typer.Typer):
|
|
69
|
+
"""Register the uninstall command with the Typer app."""
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def uninstall(
|
|
73
|
+
ctx: typer.Context,
|
|
74
|
+
sources: Annotated[
|
|
75
|
+
list[Path],
|
|
76
|
+
typer.Argument(
|
|
77
|
+
help="Source package directories to uninstall (can be deleted)",
|
|
78
|
+
),
|
|
79
|
+
],
|
|
80
|
+
):
|
|
81
|
+
"""
|
|
82
|
+
Uninstall source packages from target directory.
|
|
83
|
+
|
|
84
|
+
If the source package directory still exists, uninstalls by scanning it.
|
|
85
|
+
If the source has been deleted, uninstalls using database records.
|
|
86
|
+
"""
|
|
87
|
+
logger.info("uninstall starting")
|
|
88
|
+
console = Console()
|
|
89
|
+
verbose = is_verbose_mode(ctx)
|
|
90
|
+
dry_run = is_dry_run(ctx)
|
|
91
|
+
|
|
92
|
+
# Get target from options
|
|
93
|
+
target_path = Path(ctx.obj.get("TARGET", Path.home())) if ctx.obj else Path.home()
|
|
94
|
+
|
|
95
|
+
if not sources:
|
|
96
|
+
logger.info("uninstall finished (no sources)")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# Partition sources using set math
|
|
100
|
+
sources_set = set(sources)
|
|
101
|
+
existing_sources = {s for s in sources_set if s.exists() and s.is_dir()}
|
|
102
|
+
missing_sources = sources_set - existing_sources
|
|
103
|
+
|
|
104
|
+
total_removed = 0
|
|
105
|
+
|
|
106
|
+
# Handle existing sources with plan-based uninstall
|
|
107
|
+
if existing_sources:
|
|
108
|
+
plans: list[tuple[Path, Plan]] = []
|
|
109
|
+
for source_package in existing_sources:
|
|
110
|
+
plan: Plan = plan_uninstall(source_package, target_path)
|
|
111
|
+
log_extracted_plan(
|
|
112
|
+
plan,
|
|
113
|
+
description=f"Actual plan to uninstall {source_package}",
|
|
114
|
+
actions_to_extract={Action.UNLINK},
|
|
115
|
+
)
|
|
116
|
+
plans.append((source_package, plan))
|
|
117
|
+
|
|
118
|
+
with InstallationDB() as db:
|
|
119
|
+
execute_plans_with_progress(
|
|
120
|
+
plans,
|
|
121
|
+
target_path,
|
|
122
|
+
{Action.UNLINK},
|
|
123
|
+
"Uninstalling",
|
|
124
|
+
console,
|
|
125
|
+
verbose,
|
|
126
|
+
db,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
total_removed += sum(
|
|
130
|
+
len(extract_plan(plan, {Action.UNLINK}))
|
|
131
|
+
for _, plan in plans
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Handle missing sources with database-based uninstall
|
|
135
|
+
if missing_sources:
|
|
136
|
+
if dry_run:
|
|
137
|
+
console.print("\n[yellow][DRY RUN] Would execute the equivalent of:[/yellow]")
|
|
138
|
+
|
|
139
|
+
with InstallationDB() as db:
|
|
140
|
+
for source_package in missing_sources:
|
|
141
|
+
if verbose or dry_run:
|
|
142
|
+
console.print(f"\n[cyan]Uninstalling {source_package.name} (source deleted)...[/cyan]")
|
|
143
|
+
|
|
144
|
+
removed = _uninstall_from_database(
|
|
145
|
+
source_package, db, console, dry_run, verbose
|
|
146
|
+
)
|
|
147
|
+
total_removed += removed
|
|
148
|
+
|
|
149
|
+
# Show summary
|
|
150
|
+
if dry_run:
|
|
151
|
+
console.print(f"\n[yellow][DRY RUN] Would remove {total_removed} symlink(s) from {len(sources)} package(s)[/yellow]")
|
|
152
|
+
else:
|
|
153
|
+
console.print(f"\n[green]✓ Removed {total_removed} symlink(s) from {len(sources)} package(s)[/green]")
|
|
154
|
+
|
|
155
|
+
logger.info("uninstall finished")
|
|
@@ -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")
|
|
@@ -148,9 +154,10 @@ def plan_install(source_package_root: Path, destination_root: Path) -> Plan:
|
|
|
148
154
|
if plan[child_relative_source_path].requires_rename:
|
|
149
155
|
found_children_to_rename = True
|
|
150
156
|
# Fail if we would overwrite an existing file or symlink pointing elsewhere
|
|
151
|
-
destination_path = (
|
|
152
|
-
|
|
153
|
-
|
|
157
|
+
destination_path = resolve_destination(
|
|
158
|
+
plan[child_relative_source_path].relative_destination_path,
|
|
159
|
+
destination_root,
|
|
160
|
+
xdg_mode,
|
|
154
161
|
)
|
|
155
162
|
if destination_path.is_symlink():
|
|
156
163
|
# Symlink exists - only OK if it already points to our source
|
|
@@ -174,7 +181,7 @@ def plan_install(source_package_root: Path, destination_root: Path) -> Plan:
|
|
|
174
181
|
if current_root_path == source_package_root:
|
|
175
182
|
# Package root always EXISTS (it's the target directory)
|
|
176
183
|
plan[relative_root_path].action = Action.EXISTS
|
|
177
|
-
elif (destination_root
|
|
184
|
+
elif resolve_destination(relative_destination_root_path, destination_root, xdg_mode).exists():
|
|
178
185
|
# Directory already exists at destination - merge into it
|
|
179
186
|
# This takes precedence over always-create because we can't create what exists
|
|
180
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():
|
|
@@ -299,3 +299,159 @@ def test_cli_uninstall_dry_run_shows_indicator(tmp_path, isolated_db):
|
|
|
299
299
|
assert "rm" in result.output
|
|
300
300
|
# File should still exist after dry-run
|
|
301
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"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def test_cli_uninstall_with_deleted_source(tmp_path, isolated_db):
|
|
395
|
+
"""Test that uninstall works even when source package has been deleted."""
|
|
396
|
+
import shutil
|
|
397
|
+
|
|
398
|
+
source = tmp_path / "source"
|
|
399
|
+
source.mkdir()
|
|
400
|
+
target = tmp_path / "target"
|
|
401
|
+
target.mkdir()
|
|
402
|
+
|
|
403
|
+
# Create source files
|
|
404
|
+
(source / "file1.txt").write_text("content1")
|
|
405
|
+
(source / "file2.txt").write_text("content2")
|
|
406
|
+
|
|
407
|
+
runner = CliRunner()
|
|
408
|
+
|
|
409
|
+
# Install the package
|
|
410
|
+
result = runner.invoke(app, [f"--target={target}", "install", str(source)])
|
|
411
|
+
assert result.exit_code == 0, f"Install failed: {result.output}"
|
|
412
|
+
|
|
413
|
+
# Verify files are installed
|
|
414
|
+
assert (target / "file1.txt").is_symlink()
|
|
415
|
+
assert (target / "file2.txt").is_symlink()
|
|
416
|
+
|
|
417
|
+
# Delete the source package
|
|
418
|
+
shutil.rmtree(source)
|
|
419
|
+
assert not source.exists()
|
|
420
|
+
|
|
421
|
+
# Uninstall should still work using database (use --verbose to see "source deleted" message)
|
|
422
|
+
result = runner.invoke(app, [f"--target={target}", "--verbose", "uninstall", str(source)])
|
|
423
|
+
assert result.exit_code == 0, f"Uninstall failed: {result.output}"
|
|
424
|
+
assert "source deleted" in result.output.lower()
|
|
425
|
+
|
|
426
|
+
# Symlinks should be removed (not just broken)
|
|
427
|
+
assert not (target / "file1.txt").is_symlink()
|
|
428
|
+
assert not (target / "file2.txt").is_symlink()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def test_cli_uninstall_deleted_source_dry_run(tmp_path, isolated_db):
|
|
432
|
+
"""Test dry-run uninstall with deleted source shows correct output."""
|
|
433
|
+
import shutil
|
|
434
|
+
|
|
435
|
+
source = tmp_path / "source"
|
|
436
|
+
source.mkdir()
|
|
437
|
+
target = tmp_path / "target"
|
|
438
|
+
target.mkdir()
|
|
439
|
+
|
|
440
|
+
# Create and install
|
|
441
|
+
(source / "file1.txt").write_text("content")
|
|
442
|
+
|
|
443
|
+
runner = CliRunner()
|
|
444
|
+
result = runner.invoke(app, [f"--target={target}", "install", str(source)])
|
|
445
|
+
assert result.exit_code == 0
|
|
446
|
+
|
|
447
|
+
# Delete source
|
|
448
|
+
shutil.rmtree(source)
|
|
449
|
+
|
|
450
|
+
# Dry-run uninstall
|
|
451
|
+
result = runner.invoke(app, [f"--target={target}", "--dry-run", "uninstall", str(source)])
|
|
452
|
+
assert result.exit_code == 0
|
|
453
|
+
assert "[DRY RUN]" in result.output
|
|
454
|
+
assert "rm" in result.output
|
|
455
|
+
|
|
456
|
+
# Symlink should still exist after dry-run (even though it's broken)
|
|
457
|
+
assert (target / "file1.txt").is_symlink()
|
|
@@ -679,3 +679,91 @@ def test_install_deep_nesting_execute(tmp_path, isolated_db):
|
|
|
679
679
|
assert deep_file_dest.is_symlink()
|
|
680
680
|
assert deep_file_dest.exists()
|
|
681
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")
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
"""Uninstall command for dotx CLI."""
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from typing import Annotated
|
|
5
|
-
|
|
6
|
-
import typer
|
|
7
|
-
from loguru import logger
|
|
8
|
-
from rich.console import Console
|
|
9
|
-
|
|
10
|
-
from dotx.commands.progress import execute_plans_with_progress
|
|
11
|
-
from dotx.database import InstallationDB
|
|
12
|
-
from dotx.options import is_dry_run, is_verbose_mode
|
|
13
|
-
from dotx.plan import Action, Plan, extract_plan, log_extracted_plan
|
|
14
|
-
from dotx.uninstall import plan_uninstall
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def register_command(app: typer.Typer):
|
|
18
|
-
"""Register the uninstall command with the Typer app."""
|
|
19
|
-
|
|
20
|
-
@app.command()
|
|
21
|
-
def uninstall(
|
|
22
|
-
ctx: typer.Context,
|
|
23
|
-
sources: Annotated[
|
|
24
|
-
list[Path],
|
|
25
|
-
typer.Argument(
|
|
26
|
-
help="Source package directories to uninstall",
|
|
27
|
-
exists=True,
|
|
28
|
-
file_okay=False,
|
|
29
|
-
dir_okay=True,
|
|
30
|
-
readable=True,
|
|
31
|
-
),
|
|
32
|
-
],
|
|
33
|
-
):
|
|
34
|
-
"""Uninstall source packages from target directory."""
|
|
35
|
-
logger.info("uninstall starting")
|
|
36
|
-
console = Console()
|
|
37
|
-
verbose = is_verbose_mode(ctx)
|
|
38
|
-
|
|
39
|
-
# Get target from options
|
|
40
|
-
target_path = Path(ctx.obj.get("TARGET", Path.home())) if ctx.obj else Path.home()
|
|
41
|
-
|
|
42
|
-
if sources:
|
|
43
|
-
plans: list[tuple[Path, Plan]] = []
|
|
44
|
-
for source_package in sources:
|
|
45
|
-
plan: Plan = plan_uninstall(source_package, target_path)
|
|
46
|
-
log_extracted_plan(
|
|
47
|
-
plan,
|
|
48
|
-
description=f"Actual plan to uninstall {source_package}",
|
|
49
|
-
actions_to_extract={Action.UNLINK},
|
|
50
|
-
)
|
|
51
|
-
plans.append((source_package, plan))
|
|
52
|
-
|
|
53
|
-
# Open database and execute all plans with progress
|
|
54
|
-
with InstallationDB() as db:
|
|
55
|
-
execute_plans_with_progress(
|
|
56
|
-
plans,
|
|
57
|
-
target_path,
|
|
58
|
-
{Action.UNLINK},
|
|
59
|
-
"Uninstalling",
|
|
60
|
-
console,
|
|
61
|
-
verbose,
|
|
62
|
-
db,
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
# Show summary
|
|
66
|
-
total_removed = sum(
|
|
67
|
-
len(extract_plan(plan, {Action.UNLINK}))
|
|
68
|
-
for _, plan in plans
|
|
69
|
-
)
|
|
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]")
|
|
74
|
-
|
|
75
|
-
logger.info("uninstall finished")
|
|
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
|
|
File without changes
|
|
File without changes
|