dotfiles
1

Configure Feed

Select the types of activity you want to include in your feed.

initial claude slop

Juan Nunez-Iglesias (Jun 20, 2026, 10:31 AM +0200) b813d26e 3a121189

+393
+107
README.md
··· 1 + # dotfiles 2 + 3 + Personal config files for zsh, bash, fish, Ghostty, Neovim, git, COSMIC, and Sway, 4 + managed by a single dependency-free Python script, `dots`. 5 + 6 + ## Layout 7 + 8 + ``` 9 + dotfiles/ 10 + ├── dots # the manager itself - lives at the repo root 11 + ├── zsh/.zshrc 12 + ├── bash/.bashrc 13 + ├── fish/ # ~/.config/fish 14 + ├── ghostty/config 15 + ├── nvim/ # ~/.config/nvim 16 + ├── git/.gitconfig 17 + ├── cosmic/ # ~/.config/cosmic 18 + ├── sway/ # ~/.config/sway 19 + └── .backups/ # created automatically by `dots install`, gitignore-able 20 + ``` 21 + 22 + ## Commands 23 + 24 + ```bash 25 + ./dots collect [--dry-run] # system -> repo (copy current configs in) 26 + ./dots install [--dry-run] # repo -> system (symlink configs into place) 27 + ./dots check # show install status of every tracked item 28 + ./dots diff [timestamp] # compare a backup snapshot against the repo 29 + ./dots diff --list # list available backup snapshots 30 + ``` 31 + 32 + `collect` and `install` print exactly what they're about to do under 33 + `--dry-run`, with no changes made — worth running first any time you're 34 + unsure what will happen. 35 + 36 + ## First-time setup 37 + 38 + ```bash 39 + mkdir -p ~/dotfiles && cd ~/dotfiles 40 + git init 41 + # put `dots` in here, then: 42 + chmod +x dots # note: the executable bit may not survive 43 + # download/transfer - this step is normal 44 + ./dots collect 45 + git add -A 46 + git commit -m "initial dotfiles" 47 + git remote add origin <your-repo-url> 48 + git push -u origin main 49 + ``` 50 + 51 + ## On a new machine 52 + 53 + ```bash 54 + git clone <your-repo-url> ~/dotfiles 55 + cd ~/dotfiles 56 + chmod +x dots 57 + ./dots install 58 + ``` 59 + 60 + Each tracked file/directory becomes a symlink pointing into the repo 61 + (e.g. `~/.config/nvim` -> `~/dotfiles/nvim`). Anything that was already 62 + sitting at that path gets moved into `.backups/<timestamp>/...` inside 63 + the repo first — nothing is ever silently overwritten or deleted. 64 + 65 + ## Keeping it in sync 66 + 67 + Once installed, your real configs *are* the repo files (via the 68 + symlinks), so editing `~/.config/sway/config` day-to-day is editing the 69 + repo directly. Just remember to commit: 70 + 71 + ```bash 72 + cd ~/dotfiles && git add -A && git commit -m "tweak sway config" 73 + ``` 74 + 75 + `collect` is mainly useful for the first run, or any time you've edited 76 + a file outside the symlink (e.g. restored from a backup) and want to 77 + pull it back into the repo. 78 + 79 + ## Checking status / recovering from a backup 80 + 81 + ```bash 82 + ./dots check # what's linked, what's not, what differs 83 + ./dots diff --list # see available backup snapshots 84 + ./dots diff # diff the most recent backup against the repo 85 + ./dots diff 20260620-073535 # diff a specific snapshot 86 + ``` 87 + 88 + `diff` is for the case where `install` backed something up (because a 89 + real file was sitting where a symlink was about to go) and you want to 90 + see exactly what was in it versus what's there now. 91 + 92 + ## Adding or removing tracked files 93 + 94 + Edit the `DOTFILES_MAP` dictionary near the top of `dots`. 95 + 96 + ## Notes / things worth reviewing before your first commit 97 + 98 + - **`cosmic/`** mirrors the entire `~/.config/cosmic` tree, which COSMIC 99 + spreads across many per-component subfolders. After the first 100 + `./dots collect`, skim `git status` for anything that looks like 101 + machine-specific state (caches, instance IDs, etc.) rather than actual 102 + settings, and remove/gitignore it if so. 103 + - **`fish/fish_variables`** stores fish's universal variables (including 104 + any `abbr` abbreviations you've set) — generally fine to track, but 105 + worth a glance in case anything machine-specific snuck in there. 106 + - Consider adding `.backups/` to `.gitignore` — those are local safety 107 + snapshots, not really meant for version control.
+286
dots
··· 1 + #!/usr/bin/env python3 2 + """ 3 + dots - a tiny, dependency-free dotfiles manager. 4 + 5 + Lives at the root of your dotfiles repo. Tracks a fixed set of configs, 6 + mapping a path inside this repo to its canonical location on the system. 7 + 8 + Commands: 9 + collect copy current configs from the system into this repo 10 + install symlink configs from this repo back onto the system 11 + check show the current installation status of each tracked item 12 + diff compare a backup snapshot against the current repo contents 13 + 14 + Edit DOTFILES_MAP below to add/remove tracked files. 15 + """ 16 + 17 + from __future__ import annotations 18 + 19 + import argparse 20 + import difflib 21 + import filecmp 22 + import shutil 23 + import sys 24 + from datetime import datetime 25 + from pathlib import Path 26 + 27 + HOME = Path.home() 28 + 29 + # repo-relative path -> absolute system path 30 + DOTFILES_MAP: dict[str, Path] = { 31 + "zsh/zshrc": HOME / ".zshrc", 32 + "bash/bashrc": HOME / ".bashrc", 33 + "fish": HOME / ".config" / "fish", 34 + "ghostty/config.ghostty": HOME / ".config" / "ghostty" / "config.ghostty", 35 + "nvim": HOME / ".config" / "nvim", 36 + "git/gitconfig": HOME / ".gitconfig", 37 + "cosmic": HOME / ".config" / "cosmic", 38 + "sway": HOME / ".config" / "sway", 39 + } 40 + 41 + REPO_DIR = Path(__file__).resolve().parent 42 + BACKUP_ROOT = REPO_DIR / "backups" 43 + 44 + 45 + def rel_abs_sys_paths(): 46 + """Yield (relative_name, repo_path, system_path) for each tracked item.""" 47 + for rel, sys_path in DOTFILES_MAP.items(): 48 + yield rel, REPO_DIR / rel, sys_path 49 + 50 + 51 + # -------------------------------------------------------------------------- 52 + # collect: system -> repo 53 + # -------------------------------------------------------------------------- 54 + 55 + def cmd_collect(args: argparse.Namespace) -> None: 56 + for rel, dst_repo, sys_path in rel_abs_sys_paths(): 57 + if not sys_path.exists() and not sys_path.is_symlink(): 58 + print(f" skip: {sys_path} (does not exist)") 59 + continue 60 + if sys_path.is_symlink(): 61 + print(f" skip: {sys_path} (already a symlink)") 62 + continue 63 + 64 + if args.dry_run: 65 + print(f" would copy: {sys_path} -> {rel}") 66 + continue 67 + 68 + if dst_repo.exists(): 69 + shutil.rmtree(dst_repo) if dst_repo.is_dir() else dst_repo.unlink() 70 + dst_repo.parent.mkdir(parents=True, exist_ok=True) 71 + if sys_path.is_dir(): 72 + shutil.copytree(sys_path, dst_repo, symlinks=False) 73 + else: 74 + shutil.copy2(sys_path, dst_repo) 75 + print(f" copied: {sys_path} -> {rel}") 76 + 77 + 78 + # -------------------------------------------------------------------------- 79 + # install: repo -> system (symlinks) 80 + # -------------------------------------------------------------------------- 81 + 82 + def cmd_install(args: argparse.Namespace) -> None: 83 + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") 84 + snapshot_dir = BACKUP_ROOT / timestamp 85 + made_backup = False 86 + 87 + for rel, src_repo, sys_path in rel_abs_sys_paths(): 88 + if not src_repo.exists(): 89 + print(f" skip: {rel} (not present in repo)") 90 + continue 91 + 92 + if sys_path.is_symlink() and sys_path.resolve() == src_repo.resolve(): 93 + print(f" ok: {sys_path} (already linked)") 94 + continue 95 + 96 + exists_here = sys_path.exists() or sys_path.is_symlink() 97 + 98 + if args.dry_run: 99 + if exists_here: 100 + print(f" would back up: {sys_path}") 101 + print(f" would link: {sys_path} -> {rel}") 102 + continue 103 + 104 + if exists_here: 105 + if sys_path.is_symlink(): 106 + # Stale symlink pointing somewhere else - nothing worth 107 + # preserving, just clear it. 108 + sys_path.unlink() 109 + else: 110 + backup_dest = snapshot_dir / rel 111 + backup_dest.parent.mkdir(parents=True, exist_ok=True) 112 + shutil.move(sys_path, backup_dest) 113 + made_backup = True 114 + print(f" backed up: {sys_path} -> .backups/{timestamp}/{rel}") 115 + 116 + sys_path.parent.mkdir(parents=True, exist_ok=True) 117 + sys_path.symlink_to(src_repo, target_is_directory=src_repo.is_dir()) 118 + print(f" linked: {sys_path} -> {rel}") 119 + 120 + if made_backup: 121 + print(f"\nBackups saved under .backups/{timestamp}/") 122 + 123 + 124 + # -------------------------------------------------------------------------- 125 + # check: report current status 126 + # -------------------------------------------------------------------------- 127 + 128 + def cmd_check(args: argparse.Namespace) -> None: 129 + width = max(len(rel) for rel, _, _ in rel_abs_sys_paths()) 130 + for rel, src_repo, sys_path in rel_abs_sys_paths(): 131 + if not src_repo.exists(): 132 + status = "not in repo" 133 + elif sys_path.is_symlink(): 134 + status = ( 135 + "linked correctly" 136 + if sys_path.resolve() == src_repo.resolve() 137 + else f"symlinked elsewhere -> {sys_path.resolve()}" 138 + ) 139 + elif sys_path.exists(): 140 + if src_repo.is_dir() or sys_path.is_dir(): 141 + status = "real directory present (not linked)" 142 + else: 143 + same = filecmp.cmp(sys_path, src_repo, shallow=False) 144 + status = ( 145 + "real file present, matches repo" 146 + if same 147 + else "real file present, DIFFERS from repo" 148 + ) 149 + else: 150 + status = "not installed" 151 + print(f" {rel:<{width}} {status}") 152 + 153 + 154 + # -------------------------------------------------------------------------- 155 + # diff: compare a backup snapshot against current repo contents 156 + # -------------------------------------------------------------------------- 157 + 158 + def list_backups() -> list[str]: 159 + if not BACKUP_ROOT.exists(): 160 + return [] 161 + return sorted(p.name for p in BACKUP_ROOT.iterdir() if p.is_dir()) 162 + 163 + 164 + def diff_files(old: Path, new: Path, label: str) -> None: 165 + try: 166 + old_lines = old.read_text().splitlines(keepends=True) 167 + new_lines = new.read_text().splitlines(keepends=True) 168 + except (UnicodeDecodeError, OSError): 169 + print(f"=== {label}: binary or unreadable, skipping line diff ===") 170 + return 171 + 172 + diff = list( 173 + difflib.unified_diff( 174 + old_lines, new_lines, 175 + fromfile=f"backup/{label}", tofile=f"current/{label}", 176 + ) 177 + ) 178 + if diff: 179 + print(f"=== {label} ===") 180 + sys.stdout.writelines(diff) 181 + print() 182 + else: 183 + print(f" {label}: identical") 184 + 185 + 186 + def diff_dirs(old: Path, new: Path, label: str) -> None: 187 + if not old.is_dir() or not new.is_dir(): 188 + print(f"=== {label}: one side is a file, the other a directory ===") 189 + return 190 + 191 + cmp = filecmp.dircmp(str(old), str(new)) 192 + found_diff = False 193 + 194 + def walk(cmp: filecmp.dircmp, prefix: str) -> None: 195 + nonlocal found_diff 196 + for name in cmp.diff_files: 197 + found_diff = True 198 + diff_files(Path(cmp.left) / name, Path(cmp.right) / name, f"{prefix}/{name}") 199 + for name in cmp.left_only: 200 + found_diff = True 201 + print(f" only in backup: {prefix}/{name}") 202 + for name in cmp.right_only: 203 + found_diff = True 204 + print(f" only in current: {prefix}/{name}") 205 + for name, sub in cmp.subdirs.items(): 206 + walk(sub, f"{prefix}/{name}") 207 + 208 + walk(cmp, label) 209 + if not found_diff: 210 + print(f" {label}: identical") 211 + 212 + 213 + def cmd_diff(args: argparse.Namespace) -> None: 214 + backups = list_backups() 215 + if not backups: 216 + print("No backups found under .backups/") 217 + return 218 + 219 + if args.list: 220 + for b in backups: 221 + print(b) 222 + return 223 + 224 + timestamp = args.timestamp or backups[-1] 225 + snapshot_dir = BACKUP_ROOT / timestamp 226 + if not snapshot_dir.is_dir(): 227 + print(f"No such backup: {timestamp}", file=sys.stderr) 228 + print("Available backups:", file=sys.stderr) 229 + for b in backups: 230 + print(f" {b}", file=sys.stderr) 231 + sys.exit(1) 232 + 233 + print(f"Comparing backup '{timestamp}' against current repo contents\n") 234 + 235 + found_any = False 236 + for rel, src_repo, _sys_path in rel_abs_sys_paths(): 237 + backed_up = snapshot_dir / rel 238 + if not backed_up.exists(): 239 + continue 240 + found_any = True 241 + if backed_up.is_dir(): 242 + diff_dirs(backed_up, src_repo, rel) 243 + else: 244 + diff_files(backed_up, src_repo, rel) 245 + 246 + if not found_any: 247 + print("(nothing tracked was backed up in this snapshot)") 248 + 249 + 250 + # -------------------------------------------------------------------------- 251 + # CLI 252 + # -------------------------------------------------------------------------- 253 + 254 + def build_parser() -> argparse.ArgumentParser: 255 + parser = argparse.ArgumentParser( 256 + prog="dots", description="Minimal dependency-free dotfiles manager." 257 + ) 258 + sub = parser.add_subparsers(dest="command", required=True) 259 + 260 + p = sub.add_parser("collect", help="copy configs from the system into this repo") 261 + p.add_argument("--dry-run", action="store_true", help="show actions without making changes") 262 + p.set_defaults(func=cmd_collect) 263 + 264 + p = sub.add_parser("install", help="symlink configs from this repo onto the system") 265 + p.add_argument("--dry-run", action="store_true", help="show actions without making changes") 266 + p.set_defaults(func=cmd_install) 267 + 268 + p = sub.add_parser("check", help="show current installation status of each tracked item") 269 + p.set_defaults(func=cmd_check) 270 + 271 + p = sub.add_parser("diff", help="compare a backup snapshot against current repo contents") 272 + p.add_argument("timestamp", nargs="?", help="backup snapshot to compare (default: most recent)") 273 + p.add_argument("--list", action="store_true", help="list available backup snapshots and exit") 274 + p.set_defaults(func=cmd_diff) 275 + 276 + return parser 277 + 278 + 279 + def main(argv: list[str] | None = None) -> int: 280 + args = build_parser().parse_args(argv) 281 + args.func(args) 282 + return 0 283 + 284 + 285 + if __name__ == "__main__": 286 + sys.exit(main())