From 0cb652145cec09dade9457b7978919d23a9b5f00 Mon Sep 17 00:00:00 2001 From: Pankaj Jackson Date: Wed, 25 Jun 2025 19:46:16 +0530 Subject: [PATCH 1/6] Added delete func --- src/dotctl/handlers/data_handler.py | 101 +++++++++++++++++++++------- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/src/dotctl/handlers/data_handler.py b/src/dotctl/handlers/data_handler.py index 03cfa69..c2494dc 100644 --- a/src/dotctl/handlers/data_handler.py +++ b/src/dotctl/handlers/data_handler.py @@ -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}") @@ -104,6 +133,33 @@ 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: + log(f"Removing {path}...") + 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): """Copies files/directories using rsync and handles sudo permission issues.""" @@ -112,31 +168,30 @@ def copy(source: Path, dest: Path, skip_sudo=False, sudo_pass=None): 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: + delete(dest, skip_sudo, temp_pass or sudo_pass) return skip_sudo, sudo_pass From 5c1bb11874f7030b645f9dd34c6d40fd4f9f95ef Mon Sep 17 00:00:00 2001 From: Pankaj Jackson Date: Wed, 25 Jun 2025 20:24:32 +0530 Subject: [PATCH 2/6] Added config cleanup --- src/dotctl/actions/saver.py | 25 ++++++++++++++++++++++++- src/dotctl/handlers/data_handler.py | 1 - 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/dotctl/actions/saver.py b/src/dotctl/actions/saver.py index 80e289c..a0cb4c2 100644 --- a/src/dotctl/actions/saver.py +++ b/src/dotctl/actions/saver.py @@ -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 ( @@ -80,6 +80,29 @@ def save(props: SaverProps) -> None: if sudo_pass is not None: props.password = sudo_pass + dot_list = [ + p + for p in profile_dir.iterdir() + if p.is_dir() and p.name not in [".git", "hooks"] + # and p.name not in config.save.keys() + ] + log(str(dot_list)) + 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 + add_changes(repo=repo) if is_repo_changed(repo=repo): hostname = socket.gethostname() diff --git a/src/dotctl/handlers/data_handler.py b/src/dotctl/handlers/data_handler.py index c2494dc..751bd84 100644 --- a/src/dotctl/handlers/data_handler.py +++ b/src/dotctl/handlers/data_handler.py @@ -149,7 +149,6 @@ def delete(path: Path, skip_sudo=False, sudo_pass: str | None = None): path_exists = success if path_exists: - log(f"Removing {path}...") try: remove_file_or_dir(path, temp_pass or sudo_pass) except PermissionError: From a276c9699785066e8030428ff591a5dea0780b64 Mon Sep 17 00:00:00 2001 From: Pankaj Jackson Date: Wed, 25 Jun 2025 20:41:45 +0530 Subject: [PATCH 3/6] Added entry cleanup --- src/dotctl/actions/saver.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/dotctl/actions/saver.py b/src/dotctl/actions/saver.py index a0cb4c2..cbffd32 100644 --- a/src/dotctl/actions/saver.py +++ b/src/dotctl/actions/saver.py @@ -80,6 +80,7 @@ def save(props: SaverProps) -> None: if sudo_pass is not None: props.password = sudo_pass + # Dots Config Cleanup dot_list = [ p for p in profile_dir.iterdir() @@ -102,6 +103,23 @@ def save(props: SaverProps) -> 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): From 927587b0c933d01cc762e86d71fc3e3c8d5657e6 Mon Sep 17 00:00:00 2001 From: Pankaj Jackson Date: Wed, 25 Jun 2025 20:48:19 +0530 Subject: [PATCH 4/6] code cleanup --- src/dotctl/actions/saver.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/dotctl/actions/saver.py b/src/dotctl/actions/saver.py index cbffd32..3b99333 100644 --- a/src/dotctl/actions/saver.py +++ b/src/dotctl/actions/saver.py @@ -87,7 +87,6 @@ def save(props: SaverProps) -> None: if p.is_dir() and p.name not in [".git", "hooks"] # and p.name not in config.save.keys() ] - log(str(dot_list)) for dot in dot_list: if dot.name not in config.save.keys(): log(f'Removing "{dot.name}"...') @@ -107,7 +106,7 @@ def save(props: SaverProps) -> None: 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}"...') + log(f'Removing "{dot.name}:{entry.name}"...') result = delete( path=profile_dir / dot.name / entry.name, skip_sudo=props.skip_sudo, From b8aa8f72e0893f8acdac94d7458c009c3e42062c Mon Sep 17 00:00:00 2001 From: Pankaj Jackson Date: Wed, 25 Jun 2025 21:30:06 +0530 Subject: [PATCH 5/6] Added cli flag --prune --- src/dotctl/actions/saver.py | 84 +++++++++++++++-------------- src/dotctl/arg_manager.py | 6 +++ src/dotctl/handlers/data_handler.py | 6 ++- src/dotctl/main.py | 3 +- 4 files changed, 57 insertions(+), 42 deletions(-) diff --git a/src/dotctl/actions/saver.py b/src/dotctl/actions/saver.py index 3b99333..b89b732 100644 --- a/src/dotctl/actions/saver.py +++ b/src/dotctl/actions/saver.py @@ -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, ) @@ -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 @@ -81,44 +87,44 @@ def save(props: SaverProps) -> None: props.password = sudo_pass # Dots Config Cleanup - dot_list = [ - p - for p in profile_dir.iterdir() - if p.is_dir() and p.name not in [".git", "hooks"] - # and p.name not in config.save.keys() - ] - 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 + 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): diff --git a/src/dotctl/arg_manager.py b/src/dotctl/arg_manager.py index 9d3910a..2babda2 100644 --- a/src/dotctl/arg_manager.py +++ b/src/dotctl/arg_manager.py @@ -70,6 +70,12 @@ 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 diff --git a/src/dotctl/handlers/data_handler.py b/src/dotctl/handlers/data_handler.py index 751bd84..958b4fb 100644 --- a/src/dotctl/handlers/data_handler.py +++ b/src/dotctl/handlers/data_handler.py @@ -160,7 +160,7 @@ def delete(path: Path, skip_sudo=False, sudo_pass: str | None = None): @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 @@ -191,6 +191,8 @@ def copy(source: Path, dest: Path, skip_sudo=False, sudo_pass=None): if temp_pass or sudo_pass: rsync(source, dest, temp_pass or sudo_pass, is_dir=is_dir) else: - delete(dest, skip_sudo, temp_pass or sudo_pass) + if prune: + log(f'Removing "{dest.parent.name}:{dest.name}"...') + delete(dest, skip_sudo, temp_pass or sudo_pass) return skip_sudo, sudo_pass diff --git a/src/dotctl/main.py b/src/dotctl/main.py index 8675446..db258de 100644 --- a/src/dotctl/main.py +++ b/src/dotctl/main.py @@ -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) @@ -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) From 5ac9b10e424d62845cb3ab65772300a64dbbe1b2 Mon Sep 17 00:00:00 2001 From: Pankaj Jackson Date: Wed, 25 Jun 2025 21:58:05 +0530 Subject: [PATCH 6/6] Updated readme --- README.md | 25 ++++++++++++++++++++++++- src/dotctl/arg_manager.py | 2 +- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 62c0bd0..8aacf6b 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. --- @@ -355,7 +357,7 @@ dotctl init -c ./my_custom_config.yaml Save current system state to the active profile. ```sh -dotctl save [-h] [-p ] [--skip-sudo] [profile] +dotctl save [-h] [-p ] [--skip-sudo] [--prune] [profile] ``` **Examples:** @@ -364,12 +366,17 @@ dotctl save [-h] [-p ] [--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) --- @@ -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`. diff --git a/src/dotctl/arg_manager.py b/src/dotctl/arg_manager.py index 2babda2..acafeac 100644 --- a/src/dotctl/arg_manager.py +++ b/src/dotctl/arg_manager.py @@ -80,7 +80,7 @@ def get_parser() -> argparse.ArgumentParser: "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, )