diff --git a/.github/scripts/README.md b/.github/scripts/README.md new file mode 100644 index 0000000..0c10c3d --- /dev/null +++ b/.github/scripts/README.md @@ -0,0 +1,119 @@ +# ๐Ÿ“œ Changelog + +DotCtl uses Git tags to maintain versioned changelogs. Each release is tied to a Git tag (e.g., `v1.1.0`), and commits between tags are grouped automatically. + +## ๐Ÿงพ Manual vs Generated Changelog + +You can maintain the changelog in two ways: + +- **Manual (recommended for releases):** curated, clean, user-facing notes +- **Generated (for reference/debugging):** derived from Git history + +--- + +## ๐Ÿš€ Generate Changelog Automatically + +DotCtl includes helper scripts to generate changelog files from Git history. + +### 1. Tag-based changelog (recommended) + +```sh +./changelog_by_tag.sh > CHANGELOG.generated.md +``` + +This groups commits under each Git tag: + +- `v1.0.0` +- `v1.0.1` +- etc. + +Best for release history. + +--- + +### 2. Full commit history changelog + +```sh +./changelog.sh > CHANGELOG.full.md +``` + +Generates a linear commit-based log using: + +- commit hash +- date +- message + +Useful for debugging or audit trails. + +--- + +## ๐Ÿงช Alternative Git Commands + +You can also generate changelog data manually using Git: + +```sh +git log --pretty=format:"%h | %ad | %s" --date=short +``` + +```sh +git log --merges --oneline +``` + +```sh +git log --decorate --pretty=format:"%h | %ad | %d | %s" --date=short +``` + +--- + +## ๐Ÿ“Œ Versioning Strategy + +DotCtl follows **semantic versioning**: + +```txt +MAJOR.MINOR.PATCH +``` + +- **PATCH** โ†’ bug fixes (1.1.0 โ†’ 1.1.1) +- **MINOR** โ†’ new features (1.1.0 โ†’ 1.2.0) +- **MAJOR** โ†’ breaking changes (1.1.0 โ†’ 2.0.0) + +Example: + +- `v1.0.x` โ†’ initial stable feature set +- `v1.1.0` โ†’ new commands like `status`, `diff` +- `v2.0.0` โ†’ breaking CLI or config changes + +--- + +## ๐Ÿ“… Date in Changelog? + +Dates are optional but recommended for releases: + +- Helps track release timeline +- Useful for users consuming releases via GitHub + +Format: + +```txt +# v1.1.0 - 2026-05-23 +``` + +If you prefer minimal style, you can omit dates and rely on Git tags alone. + +--- + +## ๐Ÿ“ฆ Recommended Practice + +For best results: + +- Keep **CHANGELOG.md manually curated** +- Use scripts only to **generate drafts** +- Only include **user-facing changes** +- Avoid commit noise (WIP, refactors, internal changes) + +--- + +If you want next step, I can also help you turn this into: + +- GitHub Release automation (auto changelog per tag) +- or a Python CLI command: `dotctl changelog` diff --git a/.github/scripts/changelog.sh b/.github/scripts/changelog.sh new file mode 100755 index 0000000..658e0ba --- /dev/null +++ b/.github/scripts/changelog.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "# Recent Changelog" +echo + +git log --pretty=format:"- %h | %ad | %s" --date=short -n 100 \ No newline at end of file diff --git a/.github/scripts/changelog_by_tag.sh b/.github/scripts/changelog_by_tag.sh new file mode 100755 index 0000000..edb6cd8 --- /dev/null +++ b/.github/scripts/changelog_by_tag.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "# Changelog" +echo + +# Iterate tags in reverse chronological order +git tag --sort=-creatordate | while read -r tag; do + date=$(git log -1 --format=%ad --date=short "$tag") + + echo "## $tag - $date" + echo + + # commits in this tag range + git log "${tag}^..${tag}" --pretty=format:"- %s" || true + + echo + echo +done \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0bdfd7f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,172 @@ +# Changelog + +## v1.1.0 - 2026-05-23 + +**Features**: + +- Added `status` command +- Added drift detection support +- Added `diff` command +- Added side-by-side diff rendering +- Added colored diff output +- Added short status rendering +- Added structured status report model + +**Fixes**: + +- Fixed incorrect file existence detection for permission-restricted files (`Path.exists()` was not sufficient) +- Improved file stat handling using `os.stat()` / permission-aware checks in status and diff operations + +**UX Improvements**: + +- Improved CLI icons and symbols +- Improved human-readable status output +- Added cleaner sync/drift reporting + +**Known Limitations**: + +- `sudo` handling currently only applies to the `save` command +- Some commands may still show limited metadata for restricted files depending on system permissions + +## v1.0.9 - 2025-06-25 + +**Features**: + +- Added `--prune` flag to clean stale tracked files +- Added automatic config cleanup support +- Added entry cleanup support + +**Fixes**: + +- Fixed dependency-related issues +- Fixed path existence handling in data handler + +**Improvements**: + +- Improved README documentation +- General code cleanup and refactoring + +--- + +## v1.0.8 - 2025-04-22 + +**Features**: + +- Added improved CLI symbols and status messages +- Added automatic pull support during save/apply operations + +**Fixes**: + +- Fixed pull operation for local-only branches +- Prevented overwriting existing hooks/config during initialization + +--- + +## v1.0.7 - 2025-04-10 + +**Features**: + +- Added interactive hooks support +- Added `--hooks-timeout` flag for hook execution control + +**Fixes**: + +- Improved hook error handling +- Improved shell script execution handling + +**Documentation**: + +- Added profile workflow diagram +- Updated README examples and usage docs + +--- + +## v1.0.6 - 2025-04-06 + +**Features**: + +- Added support for creating empty profiles +- Added profile initialization checkpoints +- Added hook system: + - pre-apply hooks + - post-apply hooks + - hook failure control flags + +**Improvements**: + +- Added dynamic props support +- Improved importer/exporter cleanup +- Added profile wipe command + +**Fixes**: + +- Fixed missing templates/packages +- Fixed missing binary handler + +--- + +## v1.0.5 - 2025-04-03 + +**Features**: + +- Added profile import support +- Added profile export support +- Added push support +- Added apply profile functionality + +**Improvements**: + +- Refactored git handlers and saver logic +- Improved initialization workflow +- Added fetch support for cloud metadata sync + +--- + +## v1.0.4 - 2025-03-26 + +**Features**: + +- Added profile listing +- Added profile switching +- Added profile creation +- Added profile removal + +**Improvements**: + +- Added handler-based architecture +- Added detailed profile metadata + +**Fixes**: + +- Fixed local vs archived profile handling +- Fixed repository metadata issues + +--- + +## v1.0.3 - 2025-03-12 + +**Features**: + +- Added initialization workflow +- Added configuration validation +- Added environment-specific templates + +**Fixes**: + +- Fixed permission handling issues +- Fixed workflow and packaging bugs + +**Improvements**: + +- Refactored configuration initialization +- Improved logging and typing + +--- + +## v1.0.2 - 2023-01-19 + +**Initial Public Release**: + +- Added first stable `dotctl` implementation +- Added project structure and packaging +- Added initial CLI functionality diff --git a/README.md b/README.md index 8aacf6b..2c7c281 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,12 @@ Designed for developers and sysadmins, it supports pre/post hook scripts and is - [โŒ `remove` / `rm` / `delete` / `del`](#-remove--rm--delete--del) - [๐Ÿงช `apply`](#-apply) - [๐Ÿ”„ `pull`](#-pull) + - [๐Ÿ” `status`](#-status) + - [๐Ÿ”Ž `diff`](#-diff) - [๐Ÿ“ค `export`](#-export) - [๐Ÿ“ฅ `import`](#-import) - [๐Ÿ”ฅ `wipe`](#-wipe) + - [โš ๏ธ Limitations \& Notes](#๏ธ-limitations--notes) - [๐Ÿง‘โ€๐Ÿ’ป Development \& Publishing](#-development--publishing) - [Setup Development Environment](#setup-development-environment) - [Test the new code](#test-the-new-code) @@ -521,6 +524,42 @@ dotctl pull --- +### ๐Ÿ” `status` + +Inspect the current profile health and detect configuration drift between the repository and local system. + +```sh +dotctl status [--json] [--short] +``` + +**Examples:** + +```sh +dotctl status +dotctl status --short +dotctl status --json +``` + +--- + +### ๐Ÿ”Ž `diff` + +Compare tracked files between the repository and local system. + +```sh +dotctl diff [--color] [--side-by-side] +``` + +**Examples:** + +```sh +dotctl diff +dotctl diff --color +dotctl diff --side-by-side +``` + +--- + ### ๐Ÿ“ค `export` Export a profile to `.dtsv`. @@ -585,6 +624,15 @@ dotctl wipe -y --- +## โš ๏ธ Limitations & Notes + +- Sudo support is currently available only for the `save` command. +- Commands like `status`, `diff`, `apply`, and others may skip restricted files if permissions are insufficient. +- Ensure tracked files/directories have proper read permissions before adding them to `dotctl.yaml`. +- Missing or inaccessible files may appear as drift during `status` or `diff` operations. + +--- + ## ๐Ÿง‘โ€๐Ÿ’ป Development & Publishing ### Setup Development Environment diff --git a/src/dotctl/actions/differ.py b/src/dotctl/actions/differ.py new file mode 100644 index 0000000..1c7d50a --- /dev/null +++ b/src/dotctl/actions/differ.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from pathlib import Path +import sys + +from dotctl.paths import app_profile_directory, app_config_file +from dotctl.handlers.config_handler import conf_reader +from dotctl.handlers.diff_handler import ( + get_file_diff, + render_side_by_side, + render_colored_diff, + is_target_match, +) +from dotctl.handlers.git_handler import get_repo +from dotctl.utils import log + + +@dataclass +class DiffProps: + profile_dir: Path + target: str | None + color: bool + side_by_side: bool + + +differ_default_props = DiffProps( + profile_dir=Path(app_profile_directory), + target=None, + color=False, + side_by_side=False, +) + + +def diff(props: DiffProps) -> None: + log("Fetching diffs...") + repo = get_repo(props.profile_dir) + + if repo.bare: + log("โŒ The repository is bare. No Profile available.") + sys.exit(1) + + config = conf_reader(config_file=Path(app_config_file)) + + changes_found = False + + target_path = Path(props.target).expanduser().resolve() if props.target else None + + for name, section in config.save.items(): + + source_base_dir = Path(section.location) + repo_base_dir = props.profile_dir / name + + for entry in section.entries: + + source = source_base_dir / entry + repo_file = repo_base_dir / entry + if target_path is not None: + if not is_target_match( + target=target_path, + source=source, + repo_file=repo_file, + ): + continue + diff_lines = get_file_diff(source, repo_file) + + if diff_lines: + changes_found = True + + print(f"\n๐Ÿ” Diff: {name}/{entry}") + if props.side_by_side: + render_side_by_side(source, repo_file) + + elif props.color: + render_colored_diff(diff_lines) + + else: + print("".join(diff_lines)) + + if not changes_found: + log("โœ… No differences detected.") diff --git a/src/dotctl/actions/status.py b/src/dotctl/actions/status.py new file mode 100644 index 0000000..0cdcd2f --- /dev/null +++ b/src/dotctl/actions/status.py @@ -0,0 +1,407 @@ +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path +import json + +from dotctl.paths import app_profile_directory, app_config_file +from dotctl.handlers.config_handler import conf_reader, Config +from dotctl.handlers.git_handler import get_repo, is_git_repo +from dotctl.handlers.status_handler import ( + DriftReport, + StatusCode, + Severity, + status_icon, + build_drift_report, +) +from dotctl.utils import log + + +@dataclass +class HealthStatus: + healthy: bool + code: StatusCode + severity: Severity + message: str + errors: list[str] = field(default_factory=list) + + def has_errors(self) -> bool: + return len(self.errors) > 0 + + +@dataclass +class ProfileInfo: + profile_dir: Path + active_profile: str | None + remote_url: str | None + is_remote: bool + health: HealthStatus + + def has_remote(self) -> bool: + return self.is_remote and self.remote_url is not None + + +@dataclass +class ConfigInfo: + config_path: Path + health: HealthStatus + + +@dataclass +class StatusReport: + profile: ProfileInfo + config: ConfigInfo + drift: DriftReport | None + # symlink: CheckResult + # permissions: CheckResult + # hooks: CheckResult + + def is_healthy(self) -> bool: + return self.profile.health.healthy and self.config.health.healthy + + def can_analyze_drift(self) -> bool: + return self.drift is not None + + +@dataclass +class StatusProps: + profile_dir: Path + json: bool + short: bool + + +status_default_props = StatusProps( + profile_dir=Path(app_profile_directory), + json=False, + short=False, +) + + +def render_full(report: StatusReport): + + profile = report.profile + config = report.config + + print(f"{status_icon(profile.health.healthy)} profile: {profile.health.message}") + print(f"{status_icon(config.health.healthy)} config: {config.health.message}") + + if profile.active_profile: + print(f"โ„น active profile: {profile.active_profile}") + + if profile.has_remote(): + print(f"โ„น remote: {profile.remote_url}") + + if not report.can_analyze_drift(): + print("\nโš  drift analysis unavailable") + return + + drift = report.drift + if drift is None: + return + + print() + + if drift.is_clean(): + print("โœ” system: in sync") + else: + print("โš  system: drift detected") + + print(f"โœ” synced files: {len(drift.synced_files)}") + + if drift.modified_files: + print(f"โš  modified files: {len(drift.modified_files)}") + else: + print("โœ” modified files: none") + + if drift.missing_files: + print(f"โš  missing files: {len(drift.missing_files)}") + else: + print("โœ” missing files: none") + + changed = drift.modified_files + drift.missing_files + + if changed: + + grouped = Counter(f.state.value for f in changed) + + print("\nChanged files:") + + for f in changed: + print(f" - {f.path}") + + print() + + for state, count in grouped.items(): + print(f" {state}: {count}") + + +def render_short(report: StatusReport): + + profile = report.profile + config = report.config + + if not profile.health.healthy: + print(f"profile: {profile.health.message}") + return + + if not config.health.healthy: + print(f"config: {config.health.message}") + return + + if not report.can_analyze_drift(): + print("drift: unavailable") + return + + drift = report.drift + + if drift is None: + print("drift: unavailable") + return + + if drift.is_clean(): + print("โœ” clean") + return + + print( + f"modified={len(drift.modified_files)} " + f"missing={len(drift.missing_files)} " + f"synced={len(drift.synced_files)}" + ) + + +def render_json(report: StatusReport): + + def encode_health(health: HealthStatus): + return { + "healthy": health.healthy, + "code": health.code.value, + "severity": health.severity.value, + "message": health.message, + "errors": health.errors, + } + + def encode_file(f): + return { + "group": f.group, + "entry": f.entry, + "path": str(f.path), + "state": f.state.value, + } + + output = { + "profile": { + "profile_dir": str(report.profile.profile_dir), + "active_profile": report.profile.active_profile, + "remote_url": report.profile.remote_url, + "is_remote": report.profile.is_remote, + "health": encode_health(report.profile.health), + }, + "config": { + "config_path": str(report.config.config_path), + "health": encode_health(report.config.health), + }, + "drift": None, + } + + if report.drift: + + drift = report.drift + + output["drift"] = { + "clean": drift.is_clean(), + "total_files": drift.total_files(), + "modified_files": [encode_file(f) for f in drift.modified_files], + "missing_files": [encode_file(f) for f in drift.missing_files], + "synced_files": [encode_file(f) for f in drift.synced_files], + } + + print(json.dumps(output, indent=2)) + + +def collect_profile_info(profile_dir: Path, errors: list[str]) -> ProfileInfo: + health = HealthStatus( + healthy=True, + code=StatusCode.OK, + severity=Severity.INFO, + message="ok", + ) + + active_profile = None + remote_url = None + is_remote = False + + try: + + if not profile_dir.exists(): + health = HealthStatus( + healthy=False, + code=StatusCode.REPO_NOT_FOUND, + severity=Severity.ERROR, + message="profile directory not found", + ) + + elif not profile_dir.is_dir(): + health = HealthStatus( + healthy=False, + code=StatusCode.REPO_INVALID_DIRECTORY, + severity=Severity.ERROR, + message="invalid profile directory", + ) + + elif not is_git_repo(profile_dir): + health = HealthStatus( + healthy=False, + code=StatusCode.REPO_NOT_GIT, + severity=Severity.ERROR, + message="not a git repository", + ) + + else: + + repo = get_repo(profile_dir) + + if repo.bare: + health = HealthStatus( + healthy=False, + code=StatusCode.REPO_BARE, + severity=Severity.ERROR, + message="bare repository", + ) + + else: + try: + active_profile = repo.active_branch.name + except Exception: + active_profile = None + + try: + if repo.remotes: + origin = next( + (r for r in repo.remotes if r.name == "origin"), + None, + ) + if origin: + remote_url = origin.url + is_remote = True + except Exception as e: + errors.append(f"remote inspection failed: {e}") + + except Exception as e: + + health = HealthStatus( + healthy=False, + code=StatusCode.ERROR, + severity=Severity.ERROR, + message="profile check failed", + errors=[str(e)], + ) + + errors.append(f"Profile check failed: {e}") + + return ProfileInfo( + profile_dir=profile_dir, + active_profile=active_profile, + remote_url=remote_url, + is_remote=is_remote, + health=health, + ) + + +def collect_config_info(errors: list[str]) -> tuple[ConfigInfo, Config | None]: + + config_path = Path(app_config_file) + + health = HealthStatus( + healthy=True, + code=StatusCode.OK, + severity=Severity.INFO, + message="ok", + ) + + config = None + + try: + + if not config_path.exists(): + health = HealthStatus( + healthy=False, + code=StatusCode.CONFIG_NOT_FOUND, + severity=Severity.ERROR, + message="config file not found", + ) + + elif not config_path.is_file(): + health = HealthStatus( + healthy=False, + code=StatusCode.CONFIG_INVALID_FILE, + severity=Severity.ERROR, + message="invalid config file", + ) + + else: + + config = conf_reader(config_file=config_path) + + if not config: + health = HealthStatus( + healthy=False, + code=StatusCode.CONFIG_UNSUPPORTED, + severity=Severity.ERROR, + message="invalid config format", + ) + + except Exception as e: + + health = HealthStatus( + healthy=False, + code=StatusCode.CONFIG_PARSE_FAILED, + severity=Severity.ERROR, + message="config parsing failed", + errors=[str(e)], + ) + + errors.append(f"Config check failed: {e}") + + return ( + ConfigInfo( + config_path=config_path, + health=health, + ), + config, + ) + + +def status(props: StatusProps) -> None: + + errors: list[str] = [] + + profile_info = collect_profile_info(props.profile_dir, errors) + + config_info, config = collect_config_info(errors) + + drift_report = None + + try: + if profile_info.health.healthy and config_info.health.healthy and config: + + drift_report = build_drift_report( + props.profile_dir, + config, + ) + + except Exception as e: + errors.append(f"Drift check failed: {e}") + + report = StatusReport( + profile=profile_info, + config=config_info, + drift=drift_report, + ) + + if props.json: + render_json(report) + elif props.short: + log("Fetching status...") + render_short(report) + else: + log("Fetching status...") + render_full(report) diff --git a/src/dotctl/arg_manager.py b/src/dotctl/arg_manager.py index acafeac..a5f6b7e 100644 --- a/src/dotctl/arg_manager.py +++ b/src/dotctl/arg_manager.py @@ -284,6 +284,39 @@ def get_parser() -> argparse.ArgumentParser: pull_parser = subparsers.add_parser( "pull", help="Pull the latest changes from the dotfiles repository" ) + # Diff Parser + diff_parser = subparsers.add_parser( + "diff", help="Diff the current dotfiles repository with the dotfiles repository" + ) + diff_parser.add_argument( + "target", + nargs="?", + help="Specific file or directory to diff", + ) + diff_parser.add_argument( + "--color", + action="store_true", + help="Use color in diff output", + ) + + diff_parser.add_argument( + "--side-by-side", + action="store_true", + help="Use side-by-side diff output", + ) + + # Status Parser + status_parser = subparsers.add_parser("status", help="Status of dotfiles") + status_parser.add_argument( + "--json", + action="store_true", + help="Print status in JSON format", + ) + status_parser.add_argument( + "--short", + action="store_true", + help="Print status in short format", + ) # Wipe Parser wipe_parser = subparsers.add_parser("wipe", help="Wipe Profiles") diff --git a/src/dotctl/handlers/data_handler.py b/src/dotctl/handlers/data_handler.py index 958b4fb..f1c6148 100644 --- a/src/dotctl/handlers/data_handler.py +++ b/src/dotctl/handlers/data_handler.py @@ -133,11 +133,23 @@ def run_command(command: str, sudo_pass: str | None = None): return False, e.stderr.strip() if e.stderr else "", e.returncode # Failure +def path_exists(path: Path) -> bool: + """ + Reliable existence check that raises PermissionError + instead of silently returning False. + """ + try: + path.stat() + return True + except FileNotFoundError: + return False + + def delete(path: Path, skip_sudo=False, sudo_pass: str | None = None): temp_pass = None - path_exists = False + target_exists = False try: - path_exists = path.exists() + target_exists = path_exists(path) except PermissionError: if skip_sudo: log(f"PermissionError: skipping {path}") @@ -146,9 +158,9 @@ def delete(path: Path, skip_sudo=False, sudo_pass: str | None = None): 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 + target_exists = success - if path_exists: + if target_exists: try: remove_file_or_dir(path, temp_pass or sudo_pass) except PermissionError: @@ -167,7 +179,7 @@ def copy(source: Path, dest: Path, skip_sudo=False, sudo_pass=None, prune=False) is_dir = False # Default to file try: - source_exists = source.exists() + source_exists = path_exists(source) is_dir = source.is_dir() except PermissionError: if skip_sudo: diff --git a/src/dotctl/handlers/diff_handler.py b/src/dotctl/handlers/diff_handler.py new file mode 100644 index 0000000..1a189a1 --- /dev/null +++ b/src/dotctl/handlers/diff_handler.py @@ -0,0 +1,134 @@ +from pathlib import Path +from difflib import unified_diff, SequenceMatcher +from rich.console import Console +from rich.table import Table + +console = Console() + + +def get_file_diff(source: Path, dest: Path) -> list[str] | None: + if not source.exists() and not dest.exists(): + return None + + source_lines = [] + dest_lines = [] + + if source.exists(): + source_lines = source.read_text().splitlines(keepends=True) + + if dest.exists(): + dest_lines = dest.read_text().splitlines(keepends=True) + + diff = list( + unified_diff( + dest_lines, + source_lines, + fromfile=str(dest), + tofile=str(source), + ) + ) + return diff + + +def is_target_match( + target: Path | None, + source: Path, + repo_file: Path, +) -> bool: + + if target is None: + return True + + try: + target = target.resolve() + + return source.resolve() == target or repo_file.resolve() == target + + except Exception: + return False + + +def render_colored_diff(lines: list[str]) -> None: + + for line in lines: + + line = line.rstrip("\n") + + if line.startswith("+++") or line.startswith("---"): + console.print(line, style="yellow") + + elif line.startswith("@@"): + console.print(line, style="cyan") + + elif line.startswith("+"): + console.print(line, style="green") + + elif line.startswith("-"): + console.print(line, style="red") + + else: + console.print(line) + + +def render_side_by_side(source, dest): + + source_lines = [] + dest_lines = [] + + if source.exists() and source.is_file(): + source_lines = source.read_text().splitlines() + + if dest.exists() and dest.is_file(): + dest_lines = dest.read_text().splitlines() + + matcher = SequenceMatcher(None, dest_lines, source_lines) + + table = Table(show_lines=False) + + table.add_column("Repository", overflow="fold") + table.add_column("Local", overflow="fold") + + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + + if tag == "equal": + + for left, right in zip( + dest_lines[i1:i2], + source_lines[j1:j2], + ): + table.add_row(left, right) + + elif tag == "replace": + + left_chunk = dest_lines[i1:i2] + right_chunk = source_lines[j1:j2] + + max_len = max(len(left_chunk), len(right_chunk)) + + for idx in range(max_len): + + left = left_chunk[idx] if idx < len(left_chunk) else "" + right = right_chunk[idx] if idx < len(right_chunk) else "" + + table.add_row( + f"[red]{left}[/red]", + f"[green]{right}[/green]", + ) + + elif tag == "delete": + + for left in dest_lines[i1:i2]: + table.add_row( + f"[red]{left}[/red]", + "", + ) + + elif tag == "insert": + + for right in source_lines[j1:j2]: + table.add_row( + "", + f"[green]{right}[/green]", + ) + + console.print(table) diff --git a/src/dotctl/handlers/status_handler.py b/src/dotctl/handlers/status_handler.py new file mode 100644 index 0000000..1c8e664 --- /dev/null +++ b/src/dotctl/handlers/status_handler.py @@ -0,0 +1,162 @@ +from enum import Enum +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path +import json + +from dotctl.handlers.config_handler import Config +from dotctl.handlers.diff_handler import get_file_diff + + +class StatusCode(Enum): + + # Generic + + OK = "ok" + UNKNOWN = "unknown" + ERROR = "error" + + # Repository + + REPO_NOT_FOUND = "repo_not_found" + REPO_INVALID_DIRECTORY = "repo_invalid_directory" + REPO_NOT_GIT = "repo_not_git" + REPO_BARE = "repo_bare" + REPO_REMOTE_UNAVAILABLE = "repo_remote_unavailable" + REPO_FETCH_FAILED = "repo_fetch_failed" + + # Configuration + + CONFIG_NOT_FOUND = "config_not_found" + CONFIG_INVALID_FILE = "config_invalid_file" + CONFIG_PARSE_FAILED = "config_parse_failed" + CONFIG_UNSUPPORTED = "config_unsupported" + + # Drift + + DRIFT_DETECTED = "drift_detected" + DRIFT_ANALYSIS_FAILED = "drift_analysis_failed" + DRIFT_CLEAN = "drift_clean" + + # File states + + FILE_MODIFIED = "file_modified" + FILE_MISSING_SOURCE = "file_missing_source" + FILE_MISSING_PROFILE = "file_missing_profile" + + # Future + + SYMLINK_BROKEN = "symlink_broken" + HOOK_FAILED = "hook_failed" + PERMISSION_DENIED = "permission_denied" + + +class Severity(Enum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" + + +def status_icon(healthy: bool) -> str: + return "โœ”" if healthy else "โš " + + +class FileState(Enum): + SYNCED = "synced" + MODIFIED = "modified" + MISSING_SOURCE = "missing_source" + MISSING_PROFILE = "missing_profile" + + +@dataclass +class StatusEntry: + group: str + entry: str + state: FileState + path: Path + + +@dataclass +class DriftReport: + modified_files: list[StatusEntry] = field(default_factory=list) + missing_files: list[StatusEntry] = field(default_factory=list) + synced_files: list[StatusEntry] = field(default_factory=list) + + def total_drift(self) -> int: + return len(self.modified_files) + len(self.missing_files) + + def total_files(self) -> int: + return ( + len(self.modified_files) + len(self.missing_files) + len(self.synced_files) + ) + + def is_clean(self) -> bool: + return self.total_drift() == 0 + + +def get_file_state(source: Path, repo_file: Path) -> FileState: + + source_exists = source.exists() + repo_exists = repo_file.exists() + + if not source_exists and repo_exists: + return FileState.MISSING_SOURCE + + if source_exists and not repo_exists: + return FileState.MISSING_PROFILE + + if not source_exists and not repo_exists: + return FileState.SYNCED # edge case safe ignore + + diff = get_file_diff(source, repo_file) + + if diff: + return FileState.MODIFIED + + return FileState.SYNCED + + +def build_drift_report(profile_dir: Path, config: Config) -> DriftReport: + + results: list[StatusEntry] = [] + + for name, section in config.save.items(): + + for entry in section.entries: + + source = Path(section.location) / entry + repo_file = profile_dir / name / entry + + state = get_file_state(source, repo_file) + + results.append( + StatusEntry( + group=name, + entry=entry, + path=source, + state=state, + ) + ) + + modified = [] + missing = [] + synced = [] + + for r in results: + + if r.state == FileState.MODIFIED: + modified.append(r) + + elif r.state in (FileState.MISSING_SOURCE, FileState.MISSING_PROFILE): + missing.append(r) + + else: + synced.append(r) + repo_clean = len(modified) == 0 and len(missing) == 0 + + return DriftReport( + modified_files=modified, + missing_files=missing, + synced_files=synced, + ) diff --git a/src/dotctl/main.py b/src/dotctl/main.py index db258de..34df285 100644 --- a/src/dotctl/main.py +++ b/src/dotctl/main.py @@ -15,6 +15,8 @@ from .actions.exporter import exporter, exporter_default_props from .actions.importer import importer, importer_default_props from .actions.wiper import wipe, wiper_default_props +from .actions.differ import diff, differ_default_props +from .actions.status import status, status_default_props class Action(Enum): @@ -24,6 +26,7 @@ class Action(Enum): SWITCH = "switch" SW = "sw" PULL = "pull" + DIFF = "diff" SAVE = "save" APPLY = "apply" CREATE = "create" @@ -35,6 +38,7 @@ class Action(Enum): IMPORT = "import" EXPORT = "export" WIPE = "wipe" + STATUS = "status" HELP = "help" VERSION = "version" @@ -55,6 +59,7 @@ def run(self): Action.SWITCH: self.switch_profile, Action.SW: self.switch_profile, Action.PULL: self.pull_profile, + Action.DIFF: self.check_diff, Action.CREATE: self.create_profile, Action.NEW: self.create_profile, Action.REMOVE: self.remove_profile, @@ -64,6 +69,7 @@ def run(self): Action.EXPORT: self.export_profile, Action.IMPORT: self.import_profile, Action.WIPE: self.wipe_profile, + Action.STATUS: self.show_status, } action_methods.get(self.action, lambda: None)() @@ -148,6 +154,18 @@ def pull_profile(self): """Pull dotfiles profile.""" pull(puller_default_props) + def check_diff(self): + """Check diff between current and previous dotfiles.""" + props = self._build_props( + differ_default_props, "side_by_side", "color", "target" + ) + diff(props) + + def show_status(self): + """Show status of dotfiles.""" + props = self._build_props(status_default_props, "json", "short") + status(props) + @exception_handler def main(): @@ -187,6 +205,11 @@ def main(): "fetch": getattr(args, "fetch", False), "no_confirm": getattr(args, "no_confirm", False), "prune": getattr(args, "prune", False), + "color": getattr(args, "color", False), + "side_by_side": getattr(args, "side_by_side", False), + "target": getattr(args, "target", None), + "json": getattr(args, "json", None), + "short": getattr(args, "short", None), } dot_ctl_obj = DotCtl(**common_args)