From c7ebedfde17bf8298b8e24faf5e7f491f2275f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20P=C4=99dzim=C4=85=C5=BC?= Date: Mon, 21 Sep 2026 10:33:26 +0200 Subject: [PATCH 1/2] fix(tmux): the 1.13.2 guard skipped config on the hosts it should serve 1.13.2 added `if ! has tmux` before linking tmux config. `has` is command -v, and ~/.local/bin is NOT on PATH while install.sh runs - so on a no-sudo host the guard fired immediately after _install_tmux had installed tmux there: tmux installed -> ~/.local/bin/tmux (tmux 3.5a) tmux not found - skipping tmux config and plugins Exactly the hosts 1.12.0 exists to serve lost their tmux config. Caught by ci-local.sh nosudo-auto, which failed with .tmux.conf, .tmux.conf.local and tmux-status-rows all missing. Both entry points now use one _tmux_present helper that probes the absolute path as well as PATH - the repo's standing rule about command -v at install time, which 1.13.2 did not follow. --- modules/tmux.sh | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/modules/tmux.sh b/modules/tmux.sh index 21bed24..3af8c54 100755 --- a/modules/tmux.sh +++ b/modules/tmux.sh @@ -9,6 +9,12 @@ _TMUX_PLUGINS=( "tmux-plugins/tmux-cpu" ) +# tmux may live in ~/.local/bin (the no-sudo install), which is not on PATH while +# install.sh runs. One helper so both entry points agree on what "present" means. +_tmux_present() { + has tmux || [ -x "$HOME/.local/bin/tmux" ] +} + install_tmux() { # Do not configure what is not there. Linking tmux config and cloning tmux # plugins for an absent tmux is the incoherence that hid the no-sudo gap: a @@ -16,7 +22,12 @@ install_tmux() { # common cases now, but it still returns empty-handed on a non-x86_64 host, on # a download failure, or when the AppImage neither runs nor extracts - so this # guard is what makes the outcome honest. Same shape as install_zsh(). - if ! has tmux; then + # Probe the absolute path too. install.sh runs non-interactively, so + # ~/.local/bin is NOT on PATH here, and `has tmux` alone reported "not found" + # immediately after _install_tmux had put tmux there - so 1.13.2's guard + # skipped the config on exactly the no-sudo hosts 1.12.0 exists to serve. + # The repo's standing rule applies: probe the file, do not trust command -v. + if ! _tmux_present; then log_warn "tmux not found - skipping tmux config and plugins" log_warn " Install tmux, then re-run: bash ${DOTFILES_DIR}/install.sh" return 0 @@ -34,7 +45,7 @@ _install_tmux_plugins() { # Guarded separately, not just via install_tmux: install.sh chains them with # `install_tmux && _install_tmux_plugins`, so install_tmux's early return - which # must exit 0, or `set -e` would kill the whole install - still lets this run. - if ! has tmux; then + if ! _tmux_present; then log_warn "tmux not found - skipping tmux plugins" return 0 fi From 0ded95dc8ea0a2ab6475bec5c0958a0c05018796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20P=C4=99dzim=C4=85=C5=BC?= Date: Mon, 21 Sep 2026 10:33:26 +0200 Subject: [PATCH 2/2] feat: install and update git-lfs, and configure it safely git-lfs joins the managed tools: apt where available, GitHub binary otherwise, with an update block and test coverage. Its assets use DEBIAN arch names (git-lfs-linux-amd64-*), not the Rust triples bat/fd/rg use, so it maps through _deb_arch instead of copying their case statement. _deb_arch moves from modules/base.sh to lib/utils.sh because update.sh needs it and does not source that module - caught by ci-local.sh, which failed with `_deb_arch: command not found`. The config half deliberately does NOT use `git lfs install`. That writes to the global config, and ~/.gitconfig here is a SYMLINK to the tracked git/.gitconfig - so it would edit a tracked file and leave every host's checkout dirty, which then blocks its own next update. Instead the filters go to ~/.gitconfig.local, the same way install.sh already writes zdiff3, and only when the binary is actually present: they carry `required = true`, so shipping them without git-lfs would make every LFS checkout fail. Verified in clean no-sudo containers: git-lfs 3.8.0 installs, the four filters land in ~/.gitconfig.local, `git config --get filter.lfs.process` resolves, and the tracked git/.gitconfig stays clean. Red-proofed by pointing the installer at a nonexistent repo - no binary, and NO filters written. Idempotent: a second run reports "already configured" and the filter count stays 1. ci-local.sh nosudo-auto and minimal both PASS 1/1. --- AGENTS.md | 5 +++++ CHANGELOG.md | 5 +++++ VERSION | 2 +- install.sh | 33 +++++++++++++++++++++++++++++++++ lib/utils.sh | 10 ++++++++++ modules/base.sh | 49 +++++++++++++++++++++++++++++++++++++++---------- test.sh | 3 +++ update.sh | 22 ++++++++++++++++++++-- 8 files changed, 116 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c54bbd8..b439f5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,11 @@ fallback and the unapplied-pins path - 28 cells total. list with a per-package retry, keep an optional tool's apt steps non-fatal (`|| { log_warn ...; return; }`), and let the `_install_*` GitHub fallbacks cover whatever apt cannot supply. +- **`~/.gitconfig` is a SYMLINK to the tracked `git/.gitconfig`.** Never run a command + that writes global git config (`git lfs install`, `git config --global`) from the + install: it edits a tracked file and leaves every host's checkout dirty, which then + blocks its own next update. Machine-specific git settings go to `~/.gitconfig.local` + conditionally, as the zdiff3 and git-lfs branches in `_link_git_config` do. - **Do not configure what is not installed** (`tests/config-needs-tool.py` enforces it: every linked config dir must declare `installer`, `guarded`, or `prerequisite` with a reason, and an undeclared one fails). diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eaf341..8c02065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.14.0] - 2026-09-21 + +### Added +- `git-lfs` is installed and updated like the other tools: apt where available, GitHub binary otherwise. Its filters are written to `~/.gitconfig.local` only when the binary is actually present - never by `git lfs install`, which would edit the tracked `git/.gitconfig` that `~/.gitconfig` symlinks to and leave every checkout dirty. + ## [1.13.3] - 2026-09-20 ### Added diff --git a/VERSION b/VERSION index 01b7568..850e742 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.13.3 +1.14.0 diff --git a/install.sh b/install.sh index 1b0496f..4db4a2a 100755 --- a/install.sh +++ b/install.sh @@ -83,6 +83,39 @@ _link_git_config() { git config -f ~/.gitconfig.local merge.conflictstyle zdiff3 log_ok "git $git_v: merge.conflictstyle zdiff3 set in ~/.gitconfig.local" fi + _enable_git_lfs +} + +# git-lfs filters, written the same way as zdiff3 above: into ~/.gitconfig.local, +# and only when the binary is actually present. +# +# NOT `git lfs install`, and NOT a section in the tracked git/.gitconfig. Two +# reasons, both measured: +# - `git lfs install` writes to the GLOBAL config, and ~/.gitconfig is a symlink +# to the tracked git/.gitconfig here - so it would edit a tracked file and +# leave every host's checkout dirty, which then blocks its own next update. +# - the filters carry `required = true`, so shipping them where git-lfs is absent +# makes every LFS checkout fail. Config must follow the tool, not precede it. +# A value the user already set is left alone, exactly as the zdiff3 branch does. +_enable_git_lfs() { + # Probe the absolute path as well as PATH. install.sh runs non-interactively, so + # ~/.local/bin is usually NOT on PATH here - `has git-lfs` alone returned false + # immediately after _install_git_lfs had put the binary there, and the filters + # were silently never written. This is the repo's standing rule: never trust + # `command -v` at install time when PATH order matters, probe the file. + if ! has git-lfs && [ ! -x "$HOME/.local/bin/git-lfs" ]; then + return 0 + fi + if git config -f ~/.gitconfig.local --get filter.lfs.process >/dev/null 2>&1; then + log_ok "git-lfs filters already configured in ~/.gitconfig.local" + return 0 + fi + git config -f ~/.gitconfig.local filter.lfs.clean 'git-lfs clean -- %f' + git config -f ~/.gitconfig.local filter.lfs.smudge 'git-lfs smudge -- %f' + git config -f ~/.gitconfig.local filter.lfs.process 'git-lfs filter-process' + git config -f ~/.gitconfig.local filter.lfs.required true + local _lfs_bin; _lfs_bin=$(command -v git-lfs 2>/dev/null || echo "$HOME/.local/bin/git-lfs") + log_ok "git-lfs filters set in ~/.gitconfig.local ($("$_lfs_bin" version 2>/dev/null | head -1))" } # ── Post-install state detection ────────────────────────────────────────────── diff --git a/lib/utils.sh b/lib/utils.sh index 08d30e5..a7ebb05 100755 --- a/lib/utils.sh +++ b/lib/utils.sh @@ -281,6 +281,16 @@ _cmd_version() { # Download a tarball from URL and extract a single binary by name. # Auto-detects .tar.gz vs .tar.xz from the URL. # Usage: _download_tar_bin URL BINARY_NAME DEST +# Debian/Ubuntu architecture string (amd64, arm64, armhf, ...) +_deb_arch() { + dpkg --print-architecture 2>/dev/null || case "$(uname -m)" in + x86_64) echo "amd64" ;; + aarch64) echo "arm64" ;; + armv7l) echo "armhf" ;; + *) uname -m ;; + esac +} + _download_tar_bin() { local url="$1" binname="$2" dest="$3" mkdir -p "$(dirname "$dest")" diff --git a/modules/base.sh b/modules/base.sh index 8d5efa3..7fcc53f 100755 --- a/modules/base.sh +++ b/modules/base.sh @@ -17,7 +17,7 @@ install_base() { jq man-db gnupg python3 python3-venv - ripgrep fd-find tig bat + ripgrep fd-find tig bat git-lfs parallel shellcheck ) log_info "Installing via apt: ${_pkgs[*]} (versions resolved by apt)" @@ -61,6 +61,7 @@ install_base() { _install_fd _install_jq _install_bat + _install_git_lfs else if $CAN_SUDO; then log_warn "No apt on this system - skipping system packages; fetching tools as local binaries" @@ -78,6 +79,7 @@ install_base() { _install_fd _install_jq _install_bat + _install_git_lfs _install_tmux fi @@ -135,6 +137,7 @@ install_base_docker() { _install_fd _install_jq _install_bat + _install_git_lfs else log_warn "No apt - skipping system packages; fetching tools as local binaries" # git, zsh and python3 have no practical single-binary fallback, so they @@ -148,6 +151,7 @@ install_base_docker() { _install_fd _install_jq _install_bat + _install_git_lfs _install_tmux fi @@ -160,15 +164,8 @@ install_base_docker() { # ── Per-tool installers with apt-first / fallback strategy ──────────────────── -# Debian/Ubuntu architecture string (amd64, arm64, armhf, ...) -_deb_arch() { - dpkg --print-architecture 2>/dev/null || case "$(uname -m)" in - x86_64) echo "amd64" ;; - aarch64) echo "arm64" ;; - armv7l) echo "armhf" ;; - *) uname -m ;; - esac -} +# _deb_arch lives in lib/utils.sh - update.sh needs it too and does not +# source this module. # zoxide: apt on Ubuntu 24.04 (≥0.8); GitHub binary on 20.04/22.04 # The 22.04 apt package is 0.4.3 - too old; our .zshrc uses `zi` (needs ≥0.8). @@ -477,6 +474,38 @@ _install_ripgrep() { fi } +# git-lfs: GitHub tarball. Its assets use DEBIAN arch names (git-lfs-linux-amd64-*), +# not the Rust triples bat/fd/rg use, so this maps through _deb_arch instead of +# copying their case statement. The binary sits one level down in the tarball, +# which _download_tar_bin handles. +_install_git_lfs() { + if has git-lfs; then + log_ok "git-lfs already installed - skipping" + return + fi + log_step "git-lfs (GitHub binary)" + local lfs_arch; lfs_arch=$(_deb_arch) + case "$lfs_arch" in + amd64|arm64|arm) ;; + *) + log_warn "git-lfs: unsupported arch $lfs_arch - skipping" + return + ;; + esac + local tag="" url="" + read -r tag url < <(_gh_release_info "git-lfs/git-lfs" "linux-${lfs_arch}-") || true + if [ -z "$url" ]; then + log_warn "git-lfs: could not find release URL - skipping" + return + fi + log_info "git-lfs: installing ${tag:-unknown} → ~/.local/bin/git-lfs" + if _download_tar_bin "$url" "git-lfs" ~/.local/bin/git-lfs; then + log_ok "git-lfs installed → ~/.local/bin/git-lfs ($(~/.local/bin/git-lfs version 2>/dev/null))" + else + log_warn "git-lfs: download failed - skipping" + fi +} + # bat: GitHub tarball - binary is 'bat'. # Debian/Ubuntu ship the binary as 'batcat' (the name 'bat' was already taken), # exactly like fd/fdfind, so both names count as installed and the shim in diff --git a/test.sh b/test.sh index 1dd9275..de38270 100644 --- a/test.sh +++ b/test.sh @@ -129,6 +129,8 @@ else _fail "fd / fdfind not found" fi +check_cmd git-lfs "git-lfs" version + # bat has the same Debian/Ubuntu rename as fd: the binary ships as 'batcat'. if command -v bat &>/dev/null; then _ok "bat → $(command -v bat) ($(bat --version 2>&1 | head -1))" @@ -421,6 +423,7 @@ if [ "$PROFILE" = "nosudo" ]; then check_local_bin rg "ripgrep" check_local_bin fd "fd" check_local_bin bat "bat" + check_local_bin git-lfs "git-lfs" version check_local_bin jq "jq" check_local_bin fzf "fzf" check_local_bin zoxide "zoxide" diff --git a/update.sh b/update.sh index 68fe23a..3000013 100755 --- a/update.sh +++ b/update.sh @@ -8,7 +8,7 @@ # --help, -h Show this message # # Available tools (pass one or more to update only those): -# apt omz tmux-plugins zsh-plugins fzf rg fd bat shellcheck +# apt omz tmux-plugins zsh-plugins fzf rg fd bat git-lfs shellcheck # zoxide delta eza yazi uv ruff neovim tree-sitter cheat pre-commit xcape # # A PATH shadow check always runs at the end (read-only). It detects older @@ -49,7 +49,7 @@ while [[ $# -gt 0 ]]; do shift done -_KNOWN_TOOLS=(apt omz tmux-plugins zsh-plugins fzf rg fd bat shellcheck +_KNOWN_TOOLS=(apt omz tmux-plugins zsh-plugins fzf rg fd bat git-lfs shellcheck zoxide delta eza yazi uv ruff neovim tree-sitter cheat pre-commit xcape) # Validate SELECTED against known tool names. @@ -510,6 +510,24 @@ if _should_run bat; then fi fi +# ── git-lfs ──────────────────────────────────────────────────────────────────── +# Debian-style arch in the asset name (linux-amd64), not a Rust triple, so this +# cannot use _update_std_tool. +if _should_run git-lfs; then + log_step "git-lfs" + if has git-lfs; then + _lfs_arch=$(_deb_arch) + case "$_lfs_arch" in + amd64|arm64|arm) + _gh_update_binary git-lfs "git-lfs/git-lfs" "linux-${_lfs_arch}-" git-lfs \ + "$(_resolve_dest git-lfs ~/.local/bin/git-lfs)" || true ;; + *) log_warn "git-lfs: unsupported arch $_lfs_arch - skipping" ;; + esac + else + log_warn "git-lfs not installed - skipping" + fi +fi + # ── shellcheck ───────────────────────────────────────────────────────────────── # Uses bare arch names (x86_64/aarch64), not Rust triples - keep its own section. if _should_run shellcheck; then