dotfiles
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327#!/usr/bin/env python3"""dots - a tiny, dependency-free dotfiles manager.Lives at the root of your dotfiles repo. Tracks a fixed set of configs,mapping a path inside this repo to its canonical location on the system.Commands: collect copy current configs from the system into this repo install symlink configs from this repo back onto the system check show the current installation status of each tracked item diff compare a backup snapshot against the current repo contentsEdit DOTFILES_MAP below to add/remove tracked files."""from __future__ import annotationsimport argparseimport difflibimport filecmpimport osimport shutilimport sysfrom datetime import datetimefrom pathlib import PathHOME = Path.home()# repo-relative path -> absolute system pathDOTFILES_MAP: dict[str, Path] = { "zsh/zshrc": HOME / ".zshrc", "bash/bashrc": HOME / ".bashrc", "fish/config.fish": HOME / ".config" / "fish" / "config.fish", "fish/fish_plugins": HOME / ".config" / "fish" / "fish_plugins", "ghostty/config.ghostty": HOME / ".config" / "ghostty" / "config.ghostty", "nvim": HOME / ".config" / "nvim", "git/gitconfig": HOME / ".gitconfig", "ssh/config": HOME / ".ssh" / "config", "ssh/id_ed25519.pub": HOME / ".ssh" / "id_ed25519.pub", "ssh/codeberg.pub": HOME / ".ssh" / "codeberg.pub", "cosmic/xkb_config": HOME / ".config" / "cosmic" / "com.system76.CosmicComp" / "v1" / "xkb_config", "cosmic/custom-shortcuts": HOME / ".config" / "cosmic" / "com.system76.CosmicSettings.Shortcuts" / "v1" / "custom", "cosmic/system-actions": HOME / ".config" / "cosmic" / "com.system76.CosmicSettings.Shortcuts" / "v1" / "system_actions", "sway": HOME / ".config" / "sway", "waybar": HOME / ".config" / "waybar", "starship/starship.toml": HOME / ".config" / "starship.toml", "keyd/default.conf": Path("/etc/keyd/default.conf"), "ipython/ipython_config.py": HOME / ".ipython/profile_default/ipython_config.py"}REPO_DIR = Path(__file__).resolve().parentBACKUP_ROOT = REPO_DIR / "backups"def rel_abs_sys_paths(): """Yield (relative_name, repo_path, system_path) for each tracked item.""" for rel, sys_path in DOTFILES_MAP.items(): yield rel, REPO_DIR / rel, sys_pathdef requires_root(sys_path: Path) -> bool: """A target needs root if it lives outside $HOME (e.g. /etc/...).""" try: sys_path.resolve().relative_to(HOME.resolve()) return False except ValueError: return Truedef running_as_root() -> bool: return hasattr(os, "geteuid") and os.geteuid() == 0# --------------------------------------------------------------------------# collect: system -> repo# --------------------------------------------------------------------------def cmd_collect(args: argparse.Namespace) -> None: for rel, dst_repo, sys_path in rel_abs_sys_paths(): if not sys_path.exists() and not sys_path.is_symlink(): print(f" skip: {sys_path} (does not exist)") continue if sys_path.is_symlink(): print(f" skip: {sys_path} (already a symlink)") continue if args.dry_run: print(f" would copy: {sys_path} -> {rel}") continue if dst_repo.exists(): shutil.rmtree(dst_repo) if dst_repo.is_dir() else dst_repo.unlink() dst_repo.parent.mkdir(parents=True, exist_ok=True) if sys_path.is_dir(): shutil.copytree(sys_path, dst_repo, symlinks=False) else: shutil.copy2(sys_path, dst_repo) print(f" copied: {sys_path} -> {rel}")# --------------------------------------------------------------------------# install: repo -> system (symlinks)# --------------------------------------------------------------------------def cmd_install(args: argparse.Namespace) -> None: timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") snapshot_dir = BACKUP_ROOT / timestamp made_backup = False is_root = running_as_root() skipped_for_privileges: list[tuple[str, bool]] = [] for rel, src_repo, sys_path in rel_abs_sys_paths(): needs_root = requires_root(sys_path) if needs_root != is_root: skipped_for_privileges.append((rel, needs_root)) continue if not src_repo.exists(): print(f" skip: {rel} (not present in repo)") continue if sys_path.is_symlink() and sys_path.resolve() == src_repo.resolve(): print(f" ok: {sys_path} (already linked)") continue exists_here = sys_path.exists() or sys_path.is_symlink() if args.dry_run: if exists_here: print(f" would back up: {sys_path}") print(f" would link: {sys_path} -> {rel}") continue if exists_here: if sys_path.is_symlink(): # Stale symlink pointing somewhere else - nothing worth # preserving, just clear it. sys_path.unlink() else: backup_dest = snapshot_dir / rel backup_dest.parent.mkdir(parents=True, exist_ok=True) shutil.move(sys_path, backup_dest) made_backup = True print(f" backed up: {sys_path} -> .backups/{timestamp}/{rel}") sys_path.parent.mkdir(parents=True, exist_ok=True) sys_path.symlink_to(src_repo, target_is_directory=src_repo.is_dir()) print(f" linked: {sys_path} -> {rel}") if made_backup: print(f"\nBackups saved under .backups/{timestamp}/") if skipped_for_privileges: rerun_as = "without sudo" if is_root else "with sudo" print(f"\nSkipped (re-run {rerun_as} to link these):") for rel, needs_root in skipped_for_privileges: tag = "needs sudo" if needs_root else "user-level" print(f" {rel} ({tag})")# --------------------------------------------------------------------------# check: report current status# --------------------------------------------------------------------------def cmd_check(args: argparse.Namespace) -> None: width = max(len(rel) for rel, _, _ in rel_abs_sys_paths()) for rel, src_repo, sys_path in rel_abs_sys_paths(): if not src_repo.exists(): status = "not in repo" elif sys_path.is_symlink(): status = ( "linked correctly" if sys_path.resolve() == src_repo.resolve() else f"symlinked elsewhere -> {sys_path.resolve()}" ) elif sys_path.exists(): if src_repo.is_dir() or sys_path.is_dir(): status = "real directory present (not linked)" else: same = filecmp.cmp(sys_path, src_repo, shallow=False) status = ( "real file present, matches repo" if same else "real file present, DIFFERS from repo" ) else: status = "not installed" if requires_root(sys_path): status += " [sudo]" print(f" {rel:<{width}} {status}")# --------------------------------------------------------------------------# diff: compare a backup snapshot against current repo contents# --------------------------------------------------------------------------def list_backups() -> list[str]: if not BACKUP_ROOT.exists(): return [] return sorted(p.name for p in BACKUP_ROOT.iterdir() if p.is_dir())def diff_files(old: Path, new: Path, label: str) -> None: try: old_lines = old.read_text().splitlines(keepends=True) new_lines = new.read_text().splitlines(keepends=True) except (UnicodeDecodeError, OSError): print(f"=== {label}: binary or unreadable, skipping line diff ===") return diff = list( difflib.unified_diff( old_lines, new_lines, fromfile=f"backup/{label}", tofile=f"current/{label}", ) ) if diff: print(f"=== {label} ===") sys.stdout.writelines(diff) print() else: print(f" {label}: identical")def diff_dirs(old: Path, new: Path, label: str) -> None: if not old.is_dir() or not new.is_dir(): print(f"=== {label}: one side is a file, the other a directory ===") return cmp = filecmp.dircmp(str(old), str(new)) found_diff = False def walk(cmp: filecmp.dircmp, prefix: str) -> None: nonlocal found_diff for name in cmp.diff_files: found_diff = True diff_files(Path(cmp.left) / name, Path(cmp.right) / name, f"{prefix}/{name}") for name in cmp.left_only: found_diff = True print(f" only in backup: {prefix}/{name}") for name in cmp.right_only: found_diff = True print(f" only in current: {prefix}/{name}") for name, sub in cmp.subdirs.items(): walk(sub, f"{prefix}/{name}") walk(cmp, label) if not found_diff: print(f" {label}: identical")def cmd_diff(args: argparse.Namespace) -> None: backups = list_backups() if not backups: print("No backups found under .backups/") return if args.list: for b in backups: print(b) return timestamp = args.timestamp or backups[-1] snapshot_dir = BACKUP_ROOT / timestamp if not snapshot_dir.is_dir(): print(f"No such backup: {timestamp}", file=sys.stderr) print("Available backups:", file=sys.stderr) for b in backups: print(f" {b}", file=sys.stderr) sys.exit(1) print(f"Comparing backup '{timestamp}' against current repo contents\n") found_any = False for rel, src_repo, _sys_path in rel_abs_sys_paths(): backed_up = snapshot_dir / rel if not backed_up.exists(): continue found_any = True if backed_up.is_dir(): diff_dirs(backed_up, src_repo, rel) else: diff_files(backed_up, src_repo, rel) if not found_any: print("(nothing tracked was backed up in this snapshot)")# --------------------------------------------------------------------------# CLI# --------------------------------------------------------------------------def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="dots", description="Minimal dependency-free dotfiles manager." ) sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("collect", help="copy configs from the system into this repo") p.add_argument("--dry-run", action="store_true", help="show actions without making changes") p.set_defaults(func=cmd_collect) p = sub.add_parser("install", help="symlink configs from this repo onto the system") p.add_argument("--dry-run", action="store_true", help="show actions without making changes") p.set_defaults(func=cmd_install) p = sub.add_parser("check", help="show current installation status of each tracked item") p.set_defaults(func=cmd_check) p = sub.add_parser("diff", help="compare a backup snapshot against current repo contents") p.add_argument("timestamp", nargs="?", help="backup snapshot to compare (default: most recent)") p.add_argument("--list", action="store_true", help="list available backup snapshots and exit") p.set_defaults(func=cmd_diff) return parserdef main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) args.func(args) return 0if __name__ == "__main__": sys.exit(main())