Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.13.3
1.14.0
33 changes: 33 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────
Expand Down
10 changes: 10 additions & 0 deletions lib/utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")"
Expand Down
49 changes: 39 additions & 10 deletions modules/base.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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"
Expand All @@ -78,6 +79,7 @@ install_base() {
_install_fd
_install_jq
_install_bat
_install_git_lfs
_install_tmux
fi

Expand Down Expand Up @@ -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
Expand All @@ -148,6 +151,7 @@ install_base_docker() {
_install_fd
_install_jq
_install_bat
_install_git_lfs
_install_tmux
fi

Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions modules/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,25 @@ _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
# host looked configured while nothing could use it. _install_tmux covers the
# 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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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))"
Expand Down Expand Up @@ -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"
Expand Down
22 changes: 20 additions & 2 deletions update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading