feat(packages): flat registry with smart defaults#49
Conversation
There was a problem hiding this comment.
Pull request overview
This PR replaces the nested .brew/apt/packages.yaml/brew/packages.yaml system with a single flat packages.yaml at the repo root, backed by a new packages/sync.sh script. The sync script groups entries by source at runtime, uses a SHA-256 hash cache to skip unchanged syncs, and supports a refresh flag to bypass the cache. CLAUDE.md and test files are updated accordingly.
Changes:
- New
packages.yamlflat registry (bare strings = brew; maps for cask/tap/cargo/apt/dev) - New
packages/sync.shwith hash cache; replaces.brewand.apt .sync-with-rollback,bin/dots,CLAUDE.md, and tests updated to integrate the new system; oldbrew/packages.yamlandapt/packages.yamlremoved
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
packages.yaml |
New flat package registry replacing brew/packages.yaml + apt/packages.yaml |
packages/sync.sh |
New unified sync script with SHA-256 hash cache |
.sync-with-rollback |
Replaces .brew/.apt invocations with packages/sync.sh; adds refresh arg |
bin/dots |
Documents dots s refresh in help text |
CLAUDE.md |
Updated architecture docs, skip list, and "When Adding New Packages" guide |
brew/packages.yaml |
Removed (superseded by root packages.yaml) |
apt/packages.yaml |
Removed (superseded by root packages.yaml) |
.brew |
Removed (superseded by packages/sync.sh) |
.apt |
Removed (superseded by packages/sync.sh) |
tests/config-validation.bats |
Test updated to validate new packages.yaml location and schema |
.sync-with-rollback
Outdated
| for file in *; do | ||
| # Skip state management files | ||
| if [[ "${file}" == ".git" || "${file}" == ".local" || "${file}" == "reference" || "${file}" == ".worktrees" || "${file}" == "apt" ]]; then | ||
| if [[ "${file}" == ".git" || "${file}" == ".local" || "${file}" == "reference" || "${file}" == ".worktrees" || "${file}" == "packages" || "${file}" == "brew" || "${file}" == "apt" ]]; then |
There was a problem hiding this comment.
The packages.yaml root-level file is not excluded from the symlink loop in .sync-with-rollback. The loop iterates over all files in the dotfiles directory and symlinks them as ~/.<filename>, so packages.yaml will be symlinked as ~/.packages.yaml. Since this file is a package registry (not a dotfile to be installed in home), it should be added to the skip list alongside packages, brew, and apt.
To fix this, add "packages.yaml" to the skip condition on line 282 of .sync-with-rollback. The CLAUDE.md skip list documentation on line 197 should also be updated accordingly.
There was a problem hiding this comment.
Good catch. Fixed in 2708b93 — added packages.yaml to the skip list in .sync-with-rollback and updated the CLAUDE.md skip list docs.
packages/sync.sh
Outdated
| echo " + $name" | ||
| elif [[ -n "$git_url" ]]; then | ||
| echo " Installing $name from $git_url..." | ||
| cargo install --git "$git_url" |
There was a problem hiding this comment.
cargo install --git "$git_url" (line 207) does not pass the crate name ($name) to the command. While cargo install --git <url> installs all installable binary crates from the repo, the $name variable is used to check whether the package is already installed (line 203). If the installed binary crate name in Cargo.toml ever differs from the name field in packages.yaml, the check would always fail and attempt a reinstall on every sync (or more accurately, with --force semantics). More importantly, for repos with multiple installable crates, omitting the crate name would install ALL of them.
The correct invocation is cargo install --git "$git_url" "$name", which both specifies the crate to install and keeps it consistent with the name-based installed check.
There was a problem hiding this comment.
Valid — fixed in 2708b93. Now passes "$name" to cargo install --git to target the specific crate.
| sync_brew() { | ||
| if ! command -v brew &>/dev/null; then | ||
| log_info "Installing Homebrew..." | ||
| /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" | ||
| fi | ||
|
|
||
| log_info "Syncing brew packages" | ||
|
|
||
| # Taps first (other packages may depend on them) | ||
| local taps | ||
| taps=$(get_names "tap") | ||
| if [[ -n "$taps" ]]; then | ||
| echo -e "\n${GREEN}Taps:${NC}" | ||
| local tapped | ||
| tapped=$(brew tap) | ||
| while IFS= read -r tap; do | ||
| [[ -z "$tap" ]] && continue | ||
| if echo "$tapped" | grep -qx "$tap"; then | ||
| echo " + $tap" | ||
| else | ||
| echo " Installing tap $tap..." | ||
| brew tap "$tap" | ||
| fi | ||
| done <<< "$taps" | ||
| fi | ||
|
|
||
| # Core formulae | ||
| local formulae | ||
| formulae=$(get_names "brew") | ||
| if [[ -n "$formulae" ]]; then | ||
| echo -e "\n${GREEN}Formulae:${NC}" | ||
| local installed | ||
| installed=$(brew list --formulae 2>/dev/null || true) | ||
| while IFS= read -r pkg; do | ||
| [[ -z "$pkg" ]] && continue | ||
| if echo "$installed" | grep -qx "$pkg"; then | ||
| echo " + $pkg" | ||
| else | ||
| echo " Installing $pkg..." | ||
| brew install "$pkg" | ||
| fi | ||
| done <<< "$formulae" | ||
| fi | ||
|
|
||
| # Core casks | ||
| local casks | ||
| casks=$(get_names "cask") | ||
| if [[ -n "$casks" ]]; then | ||
| echo -e "\n${GREEN}Casks:${NC}" | ||
| local installed_casks | ||
| installed_casks=$(brew list --cask 2>/dev/null || true) | ||
| while IFS= read -r pkg; do | ||
| [[ -z "$pkg" ]] && continue | ||
| if echo "$installed_casks" | grep -qx "$pkg"; then | ||
| echo " + $pkg" | ||
| else | ||
| echo " Installing $pkg..." | ||
| brew install --cask "$pkg" | ||
| fi | ||
| done <<< "$casks" | ||
| fi | ||
|
|
||
| # Dev packages | ||
| if [[ "${DOTFILES_DEV:-false}" == "true" ]]; then | ||
| local dev_formulae | ||
| dev_formulae=$(get_names "brew" "--dev") | ||
| if [[ -n "$dev_formulae" ]]; then | ||
| echo -e "\n${GREEN}Dev formulae:${NC}" | ||
| local installed | ||
| installed=$(brew list --formulae 2>/dev/null || true) | ||
| while IFS= read -r pkg; do | ||
| [[ -z "$pkg" ]] && continue | ||
| if echo "$installed" | grep -qx "$pkg"; then | ||
| echo " + $pkg" | ||
| else | ||
| echo " Installing $pkg..." | ||
| brew install "$pkg" | ||
| fi | ||
| done <<< "$dev_formulae" | ||
| fi | ||
|
|
||
| local dev_casks | ||
| dev_casks=$(get_names "cask" "--dev") | ||
| if [[ -n "$dev_casks" ]]; then | ||
| echo -e "\n${GREEN}Dev casks:${NC}" | ||
| local installed_casks | ||
| installed_casks=$(brew list --cask 2>/dev/null || true) | ||
| while IFS= read -r pkg; do | ||
| [[ -z "$pkg" ]] && continue | ||
| if echo "$installed_casks" | grep -qx "$pkg"; then | ||
| echo " + $pkg" | ||
| else | ||
| echo " Installing $pkg..." | ||
| brew install --cask "$pkg" | ||
| fi | ||
| done <<< "$dev_casks" | ||
| fi | ||
| fi | ||
|
|
||
| log_success "Brew sync complete" | ||
| } |
There was a problem hiding this comment.
The sync_brew function spans approximately 100 lines (lines 83–183), which exceeds the project's 40-line function complexity budget defined in the coding guidelines. The function handles four distinct concerns (taps, formulae, casks, dev packages). Each of these could be extracted into a helper function (e.g., install_brew_pkgs <pkg_list> [--cask]) to bring the function under the limit and eliminate the duplicated install-loop pattern that appears four times.
There was a problem hiding this comment.
Agreed — extracted brew_install_pkgs helper in 2708b93. sync_brew is now ~30 lines, and the duplicated install loop is gone. Also batch-fetches brew list --formulae and brew list --cask once upfront instead of per-section.
Replace nested .brew/.apt files and brew/apt/packages.yaml directories with a single flat packages.yaml registry. Each entry is a bare string (brew formula) or a map with overrides (source, dev, git). Defaults: source=brew, dev=false. Sources: brew, cask, tap, cargo, apt. packages/sync.sh groups entries by source at runtime, using SHA-256 cache to skip unchanged syncs. Tests pass. Syncing now just: `dots sync`. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
899b78c to
e675e0e
Compare
- Add packages.yaml to symlink skip list (prevents ~/.packages.yaml) - Pass crate name to cargo install --git (prevents multi-crate issues) - Extract brew_install_pkgs helper (sync_brew under 40-line budget) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Bare strings now install via platform package manager (brew on mac, apt on linux) instead of always meaning brew - apt: field overrides package name for linux (e.g. fd -> fd-find) - platform: field restricts to mac or linux - Flow-style maps for one-liner overrides (dev, apt name, platform) - Eliminates separate source: apt entries entirely - get_platform_pkgs replaces get_names for cross-platform queries - get_source_pkgs handles explicit sources (tap, cask) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The package name is now the map key, eliminating the redundant name:
field. `- fd: { apt: fd-find }` instead of `- { name: fd, apt: fd-find }`.
Queries use to_entries[0] to extract key (name) and value (overrides).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Unified packages.yaml replaces nested .brew/.apt files and brew/apt/packages.yaml dirs. One flat list, smart defaults: bare strings = brew, maps override (source, dev, git). Sources: brew, cask, tap, cargo, apt.
packages/sync.sh groups by source at runtime. SHA-256 cache skips unchanged syncs. To add a package: bare string for brew, map for anything else.
dots syncauto-installs;dots sync refreshbypasses cache.All 15 bats tests pass. No YAGNI abstractions — just streamlined package management.
🧀