Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Designed for developers and sysadmins, it supports pre/post hook scripts and is
- [🆕 `create` / `new`](#-create--new)
- [❌ `remove` / `rm` / `delete` / `del`](#-remove--rm--delete--del)
- [🧪 `apply`](#-apply)
- [🔄 `pull`](#-pull)
- [📤 `export`](#-export)
- [📥 `import`](#-import)
- [🔥 `wipe`](#-wipe)
Expand All @@ -51,6 +52,7 @@ Designed for developers and sysadmins, it supports pre/post hook scripts and is
- 🔄 **Git Integration** — Sync profiles with local or remote Git repositories.
- 📁 **Portable Configs** — Export/import profiles using `.dtsv` files for easy backups and sharing.
- ⚙️ **Custom Configs** — Define tracking rules via `dotctl.yaml`.
- 🧹 **Prune Support** — Clean stale files no longer listed in your profile with --prune.

---

Expand Down Expand Up @@ -355,7 +357,7 @@ dotctl init -c ./my_custom_config.yaml
Save current system state to the active profile.

```sh
dotctl save [-h] [-p <password>] [--skip-sudo] [profile]
dotctl save [-h] [-p <password>] [--skip-sudo] [--prune] [profile]
```

**Examples:**
Expand All @@ -364,12 +366,17 @@ dotctl save [-h] [-p <password>] [--skip-sudo] [profile]
dotctl save
dotctl save my_web_server --skip-sudo
dotctl save my_web_server -p mYsecretp@ssw0rd
dotctl save --prune
```

> Tip: Use --prune to clean files that were saved before but are no longer listed in the config.

**Options:**

- `--skip-sudo` – Ignore restricted resources.
- `-p, --password` – Password for restricted resources.
- `--prune` – Remove stale files from the dot repo that are no longer listed in the current `dotctl.yaml` config.
- `profile` – Target profile to save into (defaults to the active one if not provided)

---

Expand Down Expand Up @@ -498,6 +505,22 @@ dotctl apply MyProfile --skip-pre-hooks --ignore-hook-errors

---

### 🔄 `pull`

Pull the latest changes from the dotfiles repository.

```sh
dotctl pull [-h]
```

**Examples:**

```sh
dotctl pull
```

---

### 📤 `export`

Export a profile to `.dtsv`.
Expand Down
50 changes: 48 additions & 2 deletions src/dotctl/actions/saver.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import datetime
from pathlib import Path
from dotctl.utils import log
from dotctl.handlers.data_handler import copy
from dotctl.handlers.data_handler import copy, delete
from dotctl.paths import app_profile_directory, app_config_file
from dotctl.handlers.config_handler import conf_reader
from dotctl.handlers.git_handler import (
Expand All @@ -28,12 +28,14 @@ class SaverProps:
skip_sudo: bool
password: str | None
profile: str | None
prune: bool


saver_default_props = SaverProps(
skip_sudo=False,
password=None,
profile=None,
prune=False,
)


Expand Down Expand Up @@ -69,7 +71,11 @@ def save(props: SaverProps) -> None:
source = source_base_dir / entry
dest = dest_base_dir / entry
result = copy(
source, dest, skip_sudo=props.skip_sudo, sudo_pass=props.password
source,
dest,
skip_sudo=props.skip_sudo,
sudo_pass=props.password,
prune=props.prune,
)

# Updated props
Expand All @@ -80,6 +86,46 @@ def save(props: SaverProps) -> None:
if sudo_pass is not None:
props.password = sudo_pass

# Dots Config Cleanup
if props.prune:
dot_list = [
p
for p in profile_dir.iterdir()
if p.is_dir() and p.name not in [".git", "hooks"]
]
for dot in dot_list:
if dot.name not in config.save.keys():
log(f'Removing "{dot.name}"...')
result = delete(
path=profile_dir / dot.name,
skip_sudo=props.skip_sudo,
sudo_pass=props.password,
)
# Updated props
if result is not None:
skip_sudo, sudo_pass = result
if skip_sudo is not None:
props.skip_sudo = skip_sudo
if sudo_pass is not None:
props.password = sudo_pass
else:
entry_list = dot.iterdir()
for entry in entry_list:
if entry.name not in config.save[dot.name].entries:
log(f'Removing "{dot.name}:{entry.name}"...')
result = delete(
path=profile_dir / dot.name / entry.name,
skip_sudo=props.skip_sudo,
sudo_pass=props.password,
)
# Updated props
if result is not None:
skip_sudo, sudo_pass = result
if skip_sudo is not None:
props.skip_sudo = skip_sudo
if sudo_pass is not None:
props.password = sudo_pass

add_changes(repo=repo)
if is_repo_changed(repo=repo):
hostname = socket.gethostname()
Expand Down
8 changes: 7 additions & 1 deletion src/dotctl/arg_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,17 @@ def get_parser() -> argparse.ArgumentParser:
action="store_true",
help="Skip all sudo operations",
)
save_parser.add_argument(
"--prune",
required=False,
action="store_true",
help="Prune all previously saved data not present in the current profile",
)
save_parser.add_argument(
"profile",
nargs="?", # Makes positional argument optional
type=str,
help="Profile to save to",
help="Target profile to save into (defaults to the active one if not provided)",
default=None,
)

Expand Down
104 changes: 80 additions & 24 deletions src/dotctl/handlers/data_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,35 @@ def rsync(
return stdout.strip()


def remove_file_or_dir(
location: Path,
sudo_pass: str | None = None,
):
command = ["rm", "-rf", str(location)]
if sudo_pass:
command = ["sshpass", "-p", sudo_pass, "sudo"] + command

process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)

stdout, stderr = process.communicate()

if process.returncode != 0:
log(f"cleanup failed: {stderr.strip()}")

if "Permission denied" in stderr or process.returncode == 13:
raise PermissionError(stdout.strip() or stderr.strip())

raise subprocess.CalledProcessError(process.returncode, command, stderr)

return stdout.strip()


def get_sudo_pass(path: Path, sudo_max_attempts: int = 3):
"""Prompt for sudo password and handle user choices."""
log(f"Required sudo to process {path}")
Expand Down Expand Up @@ -104,39 +133,66 @@ def run_command(command: str, sudo_pass: str | None = None):
return False, e.stderr.strip() if e.stderr else "", e.returncode # Failure


def delete(path: Path, skip_sudo=False, sudo_pass: str | None = None):
temp_pass = None
path_exists = False
try:
path_exists = path.exists()
except PermissionError:
if skip_sudo:
log(f"PermissionError: skipping {path}")
return skip_sudo, sudo_pass
else:
if not temp_pass and not sudo_pass:
temp_pass, sudo_pass, skip_sudo = get_sudo_pass(path)
success, _, _ = run_command(f"ls {path}", temp_pass or sudo_pass)
path_exists = success

if path_exists:
try:
remove_file_or_dir(path, temp_pass or sudo_pass)
except PermissionError:
log(f"PermissionError: {path} requires sudo access.")
if not skip_sudo:
temp_pass, sudo_pass, skip_sudo = get_sudo_pass(path)
if temp_pass or sudo_pass:
remove_file_or_dir(path, temp_pass or sudo_pass)


@exception_handler
def copy(source: Path, dest: Path, skip_sudo=False, sudo_pass=None):
def copy(source: Path, dest: Path, skip_sudo=False, sudo_pass=None, prune=False):
"""Copies files/directories using rsync and handles sudo permission issues."""
temp_pass = None
source_exists = False
is_dir = False # Default to file

try:
source_exists = source.exists()
is_dir = source.is_dir()
except PermissionError:
if skip_sudo:
log(f"PermissionError: skipping {source}")
return skip_sudo, sudo_pass
else:
if not temp_pass and not sudo_pass:
temp_pass, sudo_pass, skip_sudo = get_sudo_pass(source)
success, _, _ = run_command(f"ls {source}", temp_pass or sudo_pass)
source_exists = success
_, _, exit_code = run_command(f"test -d {source}", temp_pass or sudo_pass)
is_dir = exit_code == 0
if source_exists:
try:
source_exists = source.exists()
is_dir = source.is_dir()
except PermissionError:
if skip_sudo:
log(f"PermissionError: skipping {source}")
return skip_sudo, sudo_pass
else:
if not temp_pass and not sudo_pass:
temp_pass, sudo_pass, skip_sudo = get_sudo_pass(source)
success, _, _ = run_command(f"ls {source}", temp_pass or sudo_pass)
source_exists = success
_, _, exit_code = run_command(
f"test -d {source}", temp_pass or sudo_pass
)
is_dir = exit_code == 0

if source_exists:
assert source != dest, "Source and destination can't be the same"
rsync(source, dest, temp_pass or sudo_pass, is_dir=is_dir)
except PermissionError:
log(f"PermissionError: {source} requires sudo access.")
if not skip_sudo:
temp_pass, sudo_pass, skip_sudo = get_sudo_pass(source)
if temp_pass or sudo_pass:
rsync(source, dest, temp_pass or sudo_pass, is_dir=is_dir)
except PermissionError:
log(f"PermissionError: {source} requires sudo access.")
if not skip_sudo:
temp_pass, sudo_pass, skip_sudo = get_sudo_pass(source)
if temp_pass or sudo_pass:
rsync(source, dest, temp_pass or sudo_pass, is_dir=is_dir)
else:
if prune:
log(f'Removing "{dest.parent.name}:{dest.name}"...')
delete(dest, skip_sudo, temp_pass or sudo_pass)

return skip_sudo, sudo_pass
3 changes: 2 additions & 1 deletion src/dotctl/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def init_profile(self):
def save_dots(self):
"""Save current dotfiles."""
props = self._build_props(
saver_default_props, "skip_sudo", "password", "profile"
saver_default_props, "skip_sudo", "password", "profile", "prune"
)
save(props)

Expand Down Expand Up @@ -186,6 +186,7 @@ def main():
"details": getattr(args, "details", False),
"fetch": getattr(args, "fetch", False),
"no_confirm": getattr(args, "no_confirm", False),
"prune": getattr(args, "prune", False),
}

dot_ctl_obj = DotCtl(**common_args)
Expand Down