···11+# dotfiles
22+33+Personal config files for zsh, bash, fish, Ghostty, Neovim, git, COSMIC, and Sway,
44+managed by a single dependency-free Python script, `dots`.
55+66+## Layout
77+88+```
99+dotfiles/
1010+├── dots # the manager itself - lives at the repo root
1111+├── zsh/.zshrc
1212+├── bash/.bashrc
1313+├── fish/ # ~/.config/fish
1414+├── ghostty/config
1515+├── nvim/ # ~/.config/nvim
1616+├── git/.gitconfig
1717+├── cosmic/ # ~/.config/cosmic
1818+├── sway/ # ~/.config/sway
1919+└── .backups/ # created automatically by `dots install`, gitignore-able
2020+```
2121+2222+## Commands
2323+2424+```bash
2525+./dots collect [--dry-run] # system -> repo (copy current configs in)
2626+./dots install [--dry-run] # repo -> system (symlink configs into place)
2727+./dots check # show install status of every tracked item
2828+./dots diff [timestamp] # compare a backup snapshot against the repo
2929+./dots diff --list # list available backup snapshots
3030+```
3131+3232+`collect` and `install` print exactly what they're about to do under
3333+`--dry-run`, with no changes made — worth running first any time you're
3434+unsure what will happen.
3535+3636+## First-time setup
3737+3838+```bash
3939+mkdir -p ~/dotfiles && cd ~/dotfiles
4040+git init
4141+# put `dots` in here, then:
4242+chmod +x dots # note: the executable bit may not survive
4343+ # download/transfer - this step is normal
4444+./dots collect
4545+git add -A
4646+git commit -m "initial dotfiles"
4747+git remote add origin <your-repo-url>
4848+git push -u origin main
4949+```
5050+5151+## On a new machine
5252+5353+```bash
5454+git clone <your-repo-url> ~/dotfiles
5555+cd ~/dotfiles
5656+chmod +x dots
5757+./dots install
5858+```
5959+6060+Each tracked file/directory becomes a symlink pointing into the repo
6161+(e.g. `~/.config/nvim` -> `~/dotfiles/nvim`). Anything that was already
6262+sitting at that path gets moved into `.backups/<timestamp>/...` inside
6363+the repo first — nothing is ever silently overwritten or deleted.
6464+6565+## Keeping it in sync
6666+6767+Once installed, your real configs *are* the repo files (via the
6868+symlinks), so editing `~/.config/sway/config` day-to-day is editing the
6969+repo directly. Just remember to commit:
7070+7171+```bash
7272+cd ~/dotfiles && git add -A && git commit -m "tweak sway config"
7373+```
7474+7575+`collect` is mainly useful for the first run, or any time you've edited
7676+a file outside the symlink (e.g. restored from a backup) and want to
7777+pull it back into the repo.
7878+7979+## Checking status / recovering from a backup
8080+8181+```bash
8282+./dots check # what's linked, what's not, what differs
8383+./dots diff --list # see available backup snapshots
8484+./dots diff # diff the most recent backup against the repo
8585+./dots diff 20260620-073535 # diff a specific snapshot
8686+```
8787+8888+`diff` is for the case where `install` backed something up (because a
8989+real file was sitting where a symlink was about to go) and you want to
9090+see exactly what was in it versus what's there now.
9191+9292+## Adding or removing tracked files
9393+9494+Edit the `DOTFILES_MAP` dictionary near the top of `dots`.
9595+9696+## Notes / things worth reviewing before your first commit
9797+9898+- **`cosmic/`** mirrors the entire `~/.config/cosmic` tree, which COSMIC
9999+ spreads across many per-component subfolders. After the first
100100+ `./dots collect`, skim `git status` for anything that looks like
101101+ machine-specific state (caches, instance IDs, etc.) rather than actual
102102+ settings, and remove/gitignore it if so.
103103+- **`fish/fish_variables`** stores fish's universal variables (including
104104+ any `abbr` abbreviations you've set) — generally fine to track, but
105105+ worth a glance in case anything machine-specific snuck in there.
106106+- Consider adding `.backups/` to `.gitignore` — those are local safety
107107+ snapshots, not really meant for version control.
+286
dots
···11+#!/usr/bin/env python3
22+"""
33+dots - a tiny, dependency-free dotfiles manager.
44+55+Lives at the root of your dotfiles repo. Tracks a fixed set of configs,
66+mapping a path inside this repo to its canonical location on the system.
77+88+Commands:
99+ collect copy current configs from the system into this repo
1010+ install symlink configs from this repo back onto the system
1111+ check show the current installation status of each tracked item
1212+ diff compare a backup snapshot against the current repo contents
1313+1414+Edit DOTFILES_MAP below to add/remove tracked files.
1515+"""
1616+1717+from __future__ import annotations
1818+1919+import argparse
2020+import difflib
2121+import filecmp
2222+import shutil
2323+import sys
2424+from datetime import datetime
2525+from pathlib import Path
2626+2727+HOME = Path.home()
2828+2929+# repo-relative path -> absolute system path
3030+DOTFILES_MAP: dict[str, Path] = {
3131+ "zsh/zshrc": HOME / ".zshrc",
3232+ "bash/bashrc": HOME / ".bashrc",
3333+ "fish": HOME / ".config" / "fish",
3434+ "ghostty/config.ghostty": HOME / ".config" / "ghostty" / "config.ghostty",
3535+ "nvim": HOME / ".config" / "nvim",
3636+ "git/gitconfig": HOME / ".gitconfig",
3737+ "cosmic": HOME / ".config" / "cosmic",
3838+ "sway": HOME / ".config" / "sway",
3939+}
4040+4141+REPO_DIR = Path(__file__).resolve().parent
4242+BACKUP_ROOT = REPO_DIR / "backups"
4343+4444+4545+def rel_abs_sys_paths():
4646+ """Yield (relative_name, repo_path, system_path) for each tracked item."""
4747+ for rel, sys_path in DOTFILES_MAP.items():
4848+ yield rel, REPO_DIR / rel, sys_path
4949+5050+5151+# --------------------------------------------------------------------------
5252+# collect: system -> repo
5353+# --------------------------------------------------------------------------
5454+5555+def cmd_collect(args: argparse.Namespace) -> None:
5656+ for rel, dst_repo, sys_path in rel_abs_sys_paths():
5757+ if not sys_path.exists() and not sys_path.is_symlink():
5858+ print(f" skip: {sys_path} (does not exist)")
5959+ continue
6060+ if sys_path.is_symlink():
6161+ print(f" skip: {sys_path} (already a symlink)")
6262+ continue
6363+6464+ if args.dry_run:
6565+ print(f" would copy: {sys_path} -> {rel}")
6666+ continue
6767+6868+ if dst_repo.exists():
6969+ shutil.rmtree(dst_repo) if dst_repo.is_dir() else dst_repo.unlink()
7070+ dst_repo.parent.mkdir(parents=True, exist_ok=True)
7171+ if sys_path.is_dir():
7272+ shutil.copytree(sys_path, dst_repo, symlinks=False)
7373+ else:
7474+ shutil.copy2(sys_path, dst_repo)
7575+ print(f" copied: {sys_path} -> {rel}")
7676+7777+7878+# --------------------------------------------------------------------------
7979+# install: repo -> system (symlinks)
8080+# --------------------------------------------------------------------------
8181+8282+def cmd_install(args: argparse.Namespace) -> None:
8383+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
8484+ snapshot_dir = BACKUP_ROOT / timestamp
8585+ made_backup = False
8686+8787+ for rel, src_repo, sys_path in rel_abs_sys_paths():
8888+ if not src_repo.exists():
8989+ print(f" skip: {rel} (not present in repo)")
9090+ continue
9191+9292+ if sys_path.is_symlink() and sys_path.resolve() == src_repo.resolve():
9393+ print(f" ok: {sys_path} (already linked)")
9494+ continue
9595+9696+ exists_here = sys_path.exists() or sys_path.is_symlink()
9797+9898+ if args.dry_run:
9999+ if exists_here:
100100+ print(f" would back up: {sys_path}")
101101+ print(f" would link: {sys_path} -> {rel}")
102102+ continue
103103+104104+ if exists_here:
105105+ if sys_path.is_symlink():
106106+ # Stale symlink pointing somewhere else - nothing worth
107107+ # preserving, just clear it.
108108+ sys_path.unlink()
109109+ else:
110110+ backup_dest = snapshot_dir / rel
111111+ backup_dest.parent.mkdir(parents=True, exist_ok=True)
112112+ shutil.move(sys_path, backup_dest)
113113+ made_backup = True
114114+ print(f" backed up: {sys_path} -> .backups/{timestamp}/{rel}")
115115+116116+ sys_path.parent.mkdir(parents=True, exist_ok=True)
117117+ sys_path.symlink_to(src_repo, target_is_directory=src_repo.is_dir())
118118+ print(f" linked: {sys_path} -> {rel}")
119119+120120+ if made_backup:
121121+ print(f"\nBackups saved under .backups/{timestamp}/")
122122+123123+124124+# --------------------------------------------------------------------------
125125+# check: report current status
126126+# --------------------------------------------------------------------------
127127+128128+def cmd_check(args: argparse.Namespace) -> None:
129129+ width = max(len(rel) for rel, _, _ in rel_abs_sys_paths())
130130+ for rel, src_repo, sys_path in rel_abs_sys_paths():
131131+ if not src_repo.exists():
132132+ status = "not in repo"
133133+ elif sys_path.is_symlink():
134134+ status = (
135135+ "linked correctly"
136136+ if sys_path.resolve() == src_repo.resolve()
137137+ else f"symlinked elsewhere -> {sys_path.resolve()}"
138138+ )
139139+ elif sys_path.exists():
140140+ if src_repo.is_dir() or sys_path.is_dir():
141141+ status = "real directory present (not linked)"
142142+ else:
143143+ same = filecmp.cmp(sys_path, src_repo, shallow=False)
144144+ status = (
145145+ "real file present, matches repo"
146146+ if same
147147+ else "real file present, DIFFERS from repo"
148148+ )
149149+ else:
150150+ status = "not installed"
151151+ print(f" {rel:<{width}} {status}")
152152+153153+154154+# --------------------------------------------------------------------------
155155+# diff: compare a backup snapshot against current repo contents
156156+# --------------------------------------------------------------------------
157157+158158+def list_backups() -> list[str]:
159159+ if not BACKUP_ROOT.exists():
160160+ return []
161161+ return sorted(p.name for p in BACKUP_ROOT.iterdir() if p.is_dir())
162162+163163+164164+def diff_files(old: Path, new: Path, label: str) -> None:
165165+ try:
166166+ old_lines = old.read_text().splitlines(keepends=True)
167167+ new_lines = new.read_text().splitlines(keepends=True)
168168+ except (UnicodeDecodeError, OSError):
169169+ print(f"=== {label}: binary or unreadable, skipping line diff ===")
170170+ return
171171+172172+ diff = list(
173173+ difflib.unified_diff(
174174+ old_lines, new_lines,
175175+ fromfile=f"backup/{label}", tofile=f"current/{label}",
176176+ )
177177+ )
178178+ if diff:
179179+ print(f"=== {label} ===")
180180+ sys.stdout.writelines(diff)
181181+ print()
182182+ else:
183183+ print(f" {label}: identical")
184184+185185+186186+def diff_dirs(old: Path, new: Path, label: str) -> None:
187187+ if not old.is_dir() or not new.is_dir():
188188+ print(f"=== {label}: one side is a file, the other a directory ===")
189189+ return
190190+191191+ cmp = filecmp.dircmp(str(old), str(new))
192192+ found_diff = False
193193+194194+ def walk(cmp: filecmp.dircmp, prefix: str) -> None:
195195+ nonlocal found_diff
196196+ for name in cmp.diff_files:
197197+ found_diff = True
198198+ diff_files(Path(cmp.left) / name, Path(cmp.right) / name, f"{prefix}/{name}")
199199+ for name in cmp.left_only:
200200+ found_diff = True
201201+ print(f" only in backup: {prefix}/{name}")
202202+ for name in cmp.right_only:
203203+ found_diff = True
204204+ print(f" only in current: {prefix}/{name}")
205205+ for name, sub in cmp.subdirs.items():
206206+ walk(sub, f"{prefix}/{name}")
207207+208208+ walk(cmp, label)
209209+ if not found_diff:
210210+ print(f" {label}: identical")
211211+212212+213213+def cmd_diff(args: argparse.Namespace) -> None:
214214+ backups = list_backups()
215215+ if not backups:
216216+ print("No backups found under .backups/")
217217+ return
218218+219219+ if args.list:
220220+ for b in backups:
221221+ print(b)
222222+ return
223223+224224+ timestamp = args.timestamp or backups[-1]
225225+ snapshot_dir = BACKUP_ROOT / timestamp
226226+ if not snapshot_dir.is_dir():
227227+ print(f"No such backup: {timestamp}", file=sys.stderr)
228228+ print("Available backups:", file=sys.stderr)
229229+ for b in backups:
230230+ print(f" {b}", file=sys.stderr)
231231+ sys.exit(1)
232232+233233+ print(f"Comparing backup '{timestamp}' against current repo contents\n")
234234+235235+ found_any = False
236236+ for rel, src_repo, _sys_path in rel_abs_sys_paths():
237237+ backed_up = snapshot_dir / rel
238238+ if not backed_up.exists():
239239+ continue
240240+ found_any = True
241241+ if backed_up.is_dir():
242242+ diff_dirs(backed_up, src_repo, rel)
243243+ else:
244244+ diff_files(backed_up, src_repo, rel)
245245+246246+ if not found_any:
247247+ print("(nothing tracked was backed up in this snapshot)")
248248+249249+250250+# --------------------------------------------------------------------------
251251+# CLI
252252+# --------------------------------------------------------------------------
253253+254254+def build_parser() -> argparse.ArgumentParser:
255255+ parser = argparse.ArgumentParser(
256256+ prog="dots", description="Minimal dependency-free dotfiles manager."
257257+ )
258258+ sub = parser.add_subparsers(dest="command", required=True)
259259+260260+ p = sub.add_parser("collect", help="copy configs from the system into this repo")
261261+ p.add_argument("--dry-run", action="store_true", help="show actions without making changes")
262262+ p.set_defaults(func=cmd_collect)
263263+264264+ p = sub.add_parser("install", help="symlink configs from this repo onto the system")
265265+ p.add_argument("--dry-run", action="store_true", help="show actions without making changes")
266266+ p.set_defaults(func=cmd_install)
267267+268268+ p = sub.add_parser("check", help="show current installation status of each tracked item")
269269+ p.set_defaults(func=cmd_check)
270270+271271+ p = sub.add_parser("diff", help="compare a backup snapshot against current repo contents")
272272+ p.add_argument("timestamp", nargs="?", help="backup snapshot to compare (default: most recent)")
273273+ p.add_argument("--list", action="store_true", help="list available backup snapshots and exit")
274274+ p.set_defaults(func=cmd_diff)
275275+276276+ return parser
277277+278278+279279+def main(argv: list[str] | None = None) -> int:
280280+ args = build_parser().parse_args(argv)
281281+ args.func(args)
282282+ return 0
283283+284284+285285+if __name__ == "__main__":
286286+ sys.exit(main())