diff --git a/.tool-versions b/.tool-versions index fa6bee2..d73a169 100644 --- a/.tool-versions +++ b/.tool-versions @@ -5,3 +5,4 @@ uv 0.4.18 copilot-cli 1.0.69 squad-cli 0.10.0 gh 2.92.0 +delta 0.19.2 diff --git a/scripts/linux/tools/delta.sh b/scripts/linux/tools/delta.sh new file mode 100644 index 0000000..5bbe84d --- /dev/null +++ b/scripts/linux/tools/delta.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# scripts/linux/tools/delta.sh -- Install git-delta (syntax-highlighting git pager) at pinned version +# +# Called by: scripts/linux/setup.sh +# Idempotent: yes -- version-aware; upgrades if installed version != pinned version. +# Opt-in: NOT in DEFAULT_TOOLS; only runs when requested via --only=delta. +# +# Ubuntu 22.04+: installs via apt-get (git-delta package). +# Older/non-apt Linux: downloads the pinned release tarball from GitHub releases. +# macOS: brew install git-delta (warns if brew version differs from pin). +# +# Defines apply_delta_git_config() for testability. +# Main install block is guarded by BASH_SOURCE/argv0 check so this file can +# be sourced in tests to call apply_delta_git_config in isolation. + +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/../lib/log.sh" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DELTA_VERSION="$(sh "${SCRIPT_DIR}/../../lib/read-tool-version.sh" delta)" + +apply_delta_git_config() { + log_info "Applying global git config for delta..." + git config --global core.pager delta + git config --global interactive.diffFilter 'delta --color-only' + git config --global delta.navigate true + git config --global delta.dark true + # Light-mode override: git config --global delta.dark false + git config --global merge.conflictStyle zdiff3 + log_ok "delta git config applied (core.pager=delta, dark=true)" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + set -euo pipefail + + # Detect installed version + INSTALLED_VERSION="" + if command -v delta &>/dev/null; then + INSTALLED_VERSION="$(delta --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)" + fi + + if [ "${INSTALLED_VERSION}" = "${DELTA_VERSION}" ]; then + log_ok "git-delta already at pinned version ${DELTA_VERSION}" + apply_delta_git_config + exit 0 + fi + + if [ -n "${INSTALLED_VERSION}" ]; then + log_info "git-delta ${INSTALLED_VERSION} installed; upgrading to pinned ${DELTA_VERSION}..." + else + log_info "Installing git-delta ${DELTA_VERSION}..." + fi + + PLATFORM="$(uname -s)" + if [[ "$PLATFORM" == "Darwin" ]]; then + # Homebrew: versioned formulae for git-delta are not reliably pinnable. + if command -v delta &>/dev/null; then + brew upgrade git-delta || true + else + brew install git-delta + fi + ACTUAL_VERSION="$(delta --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo 'unknown')" + if [ "${ACTUAL_VERSION}" != "${DELTA_VERSION}" ]; then + log_warn "git-delta ${ACTUAL_VERSION} installed (pinned: ${DELTA_VERSION}); brew cannot guarantee exact version" + else + log_ok "git-delta installed at ${DELTA_VERSION}" + fi + else + # Linux: prefer apt-get (Ubuntu 22.04+), fall back to pinned tarball + APT_OK=0 + if command -v apt-get &>/dev/null; then + log_info "Trying apt-get install git-delta..." + sudo apt-get install -y git-delta 2>/dev/null && APT_OK=1 || APT_OK=0 + fi + + if [ "$APT_OK" -eq 1 ]; then + ACTUAL_VERSION="$(delta --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo 'unknown')" + if [ "${ACTUAL_VERSION}" != "${DELTA_VERSION}" ]; then + log_warn "apt installed git-delta ${ACTUAL_VERSION} (pinned: ${DELTA_VERSION}); use tarball for exact pin" + fi + log_ok "git-delta installed via apt at ${ACTUAL_VERSION}" + else + # Tarball: asset name delta--.tar.gz + ARCH="$(uname -m)" + case "$ARCH" in + x86_64) ARCH_TRIPLE="x86_64-unknown-linux-gnu" ;; + aarch64|arm64) ARCH_TRIPLE="aarch64-unknown-linux-gnu" ;; + *) log_error "Unsupported architecture: ${ARCH}"; exit 1 ;; + esac + + TARBALL="delta-${DELTA_VERSION}-${ARCH_TRIPLE}.tar.gz" + TARBALL_URL="https://github.com/dandavison/delta/releases/download/${DELTA_VERSION}/${TARBALL}" + INSTALL_DIR="${HOME}/.local/bin" + WORK_DIR="${HOME}/.local/share/dev-setup-install/delta" + + mkdir -p "$INSTALL_DIR" + mkdir -p "$WORK_DIR" + + log_info "Downloading ${TARBALL}..." + curl -fsSL "$TARBALL_URL" -o "${WORK_DIR}/${TARBALL}" + tar -xzf "${WORK_DIR}/${TARBALL}" -C "$WORK_DIR" + cp "${WORK_DIR}/delta-${DELTA_VERSION}-${ARCH_TRIPLE}/delta" "${INSTALL_DIR}/delta" + chmod +x "${INSTALL_DIR}/delta" + rm -rf "$WORK_DIR" + + log_ok "git-delta ${DELTA_VERSION} installed to ${INSTALL_DIR}/delta" + fi + fi + + apply_delta_git_config +fi diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index 393c5e5..c5bac3d 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -46,6 +46,7 @@ $ErrorActionPreference = 'Stop' . "$PSScriptRoot\tools\profile.ps1" . "$PSScriptRoot\tools\auth.ps1" . "$PSScriptRoot\tools\git-hook.ps1" +. "$PSScriptRoot\tools\delta.ps1" # --------------------------------------------------------------------------- # $DefaultTools -- single ordered source of truth for a no-arg default run. @@ -85,6 +86,7 @@ $ToolRegistry = [ordered]@{ 'dotfiles' = { Install-Dotfiles } 'profile' = { Write-PowerShellProfile } 'git-hook' = { Install-GitHook } + 'delta' = { Install-Delta } } # --------------------------------------------------------------------------- diff --git a/scripts/windows/tools/delta.ps1 b/scripts/windows/tools/delta.ps1 new file mode 100644 index 0000000..71cb7ea --- /dev/null +++ b/scripts/windows/tools/delta.ps1 @@ -0,0 +1,73 @@ +# scripts/windows/tools/delta.ps1 - git-delta installer +# +# Installs git-delta at pinned version from .tool-versions. +# Opt-in: NOT in $DefaultTools; only runs when requested via -Only 'delta'. +# winget preferred (id dandavison.delta); scoop fallback when winget unavailable. +# +# Defines Set-DeltaGitConfig for testability: applies global git config for +# delta without requiring a full install run. +# PS 5.1 ASCII-only: no smart quotes, em-dashes, or non-ASCII characters. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot\..\lib\logging.ps1" +. "$PSScriptRoot\..\lib\path.ps1" +. "$PSScriptRoot\..\..\lib\Read-ToolVersion.ps1" + +function Set-DeltaGitConfig { + [CmdletBinding(SupportsShouldProcess)] + param() + if (-not $PSCmdlet.ShouldProcess('global git config', 'Set delta pager settings')) { return } + Write-Info "Applying global git config for delta..." + git config --global core.pager delta + git config --global interactive.diffFilter 'delta --color-only' + git config --global delta.navigate true + git config --global delta.dark true + # Light-mode override: git config --global delta.dark false + git config --global merge.conflictStyle zdiff3 + Write-Ok "delta git config applied (core.pager=delta, dark=true)" +} + +function Install-Delta { + $DeltaVersion = Get-ToolVersion -Name 'delta' + + # Detect installed version + $InstalledVersion = '' + if (Get-Command delta -ErrorAction SilentlyContinue) { + $raw = (delta --version 2>&1) | Select-Object -First 1 | Out-String + $m = [regex]::Match($raw, '[0-9]+\.[0-9]+\.[0-9]+') + if ($m.Success) { $InstalledVersion = $m.Value } + } + + if ($InstalledVersion -eq $DeltaVersion) { + Write-Ok "git-delta already at pinned version $DeltaVersion" + Set-DeltaGitConfig + return + } + + if ($InstalledVersion) { + Write-Info "git-delta $InstalledVersion installed; upgrading to pinned $DeltaVersion..." + } else { + Write-Info "Installing git-delta $DeltaVersion..." + } + + # winget preferred; fall back to scoop if winget is unavailable + if (Get-Command winget -ErrorAction SilentlyContinue) { + winget install --id dandavison.delta --version $DeltaVersion --silent ` + --accept-source-agreements --accept-package-agreements + Assert-LastExit -ToolName "git-delta" -AllowedExitCodes @(0, -1978335189) + Refresh-SessionPath + Write-Ok "git-delta installed via winget at $DeltaVersion" + } elseif (Get-Command scoop -ErrorAction SilentlyContinue) { + Write-Info "winget not available; falling back to scoop..." + scoop install delta + Assert-LastExit -ToolName "git-delta (scoop)" + Write-Warn "scoop installed latest delta; version may differ from pinned $DeltaVersion" + } else { + Write-Err "Neither winget nor scoop available; cannot install git-delta" + throw "git-delta install failed: no supported package manager found" + } + + Set-DeltaGitConfig +} diff --git a/tests/test_delta_installer.sh b/tests/test_delta_installer.sh new file mode 100644 index 0000000..2e5cf24 --- /dev/null +++ b/tests/test_delta_installer.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# tests/test_delta_installer.sh -- parity tests for git-delta opt-in installer (#466) +# +# Tests: +# T_delta_in_list -- delta appears in --list output (opt-in discoverable) +# T_delta_not_default -- delta is NOT installed by a default no-arg run +# T_delta_gitconfig_iso -- apply_delta_git_config writes core.pager=delta under +# an isolated GIT_CONFIG_GLOBAL (does not touch ~/.gitconfig) +# T_delta_gitconfig_idem -- apply_delta_git_config is idempotent (safe to run twice) +# +# Usage: bash tests/test_delta_installer.sh +# Requires: bash 3.2+ (macOS compatible), git + +set -uo pipefail + +PASS=0 +FAIL=0 +SKIP=0 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +RESET='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +LINUX_SETUP="${REPO_ROOT}/scripts/linux/setup.sh" +DELTA_SH="${REPO_ROOT}/scripts/linux/tools/delta.sh" +STUB_DIR="${REPO_ROOT}/tests/fixtures/stub-tools/linux" + +pass() { printf "${GREEN}PASS${RESET}: %s\n" "$1"; PASS=$((PASS + 1)); } +fail() { printf "${RED}FAIL${RESET}: %s\n" "$1"; FAIL=$((FAIL + 1)); } +# shellcheck disable=SC2329 +skip() { printf "${YELLOW}SKIP${RESET}: %s -- %s\n" "$1" "$2"; SKIP=$((SKIP + 1)); } + +# Log no-ops -- inherited by subshells; called indirectly by delta.sh when sourced. +# shellcheck disable=SC2329 +log_info() { :; } +# shellcheck disable=SC2329 +log_ok() { :; } +# shellcheck disable=SC2329 +log_warn() { :; } +# shellcheck disable=SC2329 +log_error(){ :; } + +# --------------------------------------------------------------------------- +# T_delta_in_list: --list on real tools dir includes 'delta' +# Fails RED when scripts/linux/tools/delta.sh does not exist. +# --------------------------------------------------------------------------- +echo "" +echo "--- T_delta_in_list ---" +list_out="$(bash "$LINUX_SETUP" --list 2>&1)" && list_exit=$? || list_exit=$? +if [ "$list_exit" -ne 0 ]; then + fail "T_delta_in_list: --list exited $list_exit (expected 0)" +elif echo "$list_out" | grep -qF "delta"; then + pass "T_delta_in_list: delta appears in --list output (opt-in discoverable)" +else + fail "T_delta_in_list: delta missing from --list output (is delta.sh in tools/?)" + echo " --list output: $list_out" +fi + +# --------------------------------------------------------------------------- +# T_delta_not_default: default no-arg run with stub dir does NOT run delta +# Delta is opt-in; it must not appear in defaults.txt. +# --------------------------------------------------------------------------- +echo "" +echo "--- T_delta_not_default ---" +RUN_LOG="$(mktemp)" +export RUN_LOG +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true +if grep -qF "delta" "$RUN_LOG" 2>/dev/null; then + fail "T_delta_not_default: delta ran in a default no-arg install (must be opt-in only)" +else + pass "T_delta_not_default: delta does NOT run in a default install (correctly opt-in)" +fi +rm -f "$RUN_LOG" +unset RUN_LOG + +# --------------------------------------------------------------------------- +# T_delta_gitconfig_iso: apply_delta_git_config writes core.pager=delta under +# an isolated GIT_CONFIG_GLOBAL; real ~/.gitconfig is never touched. +# Fails RED when delta.sh does not exist (function not defined). +# --------------------------------------------------------------------------- +echo "" +echo "--- T_delta_gitconfig_iso ---" +if [ ! -f "$DELTA_SH" ]; then + fail "T_delta_gitconfig_iso: delta.sh not found at $DELTA_SH -- RED (pre-implementation)" +else + ISO_CFG="$(mktemp)" + # Run in a subshell so set -euo pipefail from delta.sh does not affect parent. + # GIT_CONFIG_GLOBAL is set inside the subshell intentionally; we read via inline + # env assignment after the subshell exits. + # shellcheck disable=SC2030 + ( + export GIT_CONFIG_GLOBAL="$ISO_CFG" + # shellcheck disable=SC1090 + . "$DELTA_SH" + apply_delta_git_config + ) + sub_exit=$? + if [ "$sub_exit" -ne 0 ]; then + fail "T_delta_gitconfig_iso: apply_delta_git_config subshell exited $sub_exit" + else + # shellcheck disable=SC2031 + got_pager="$(GIT_CONFIG_GLOBAL="$ISO_CFG" git config --global --get core.pager 2>/dev/null || true)" + # shellcheck disable=SC2031 + got_filter="$(GIT_CONFIG_GLOBAL="$ISO_CFG" git config --global --get interactive.diffFilter 2>/dev/null || true)" + if [ "$got_pager" = "delta" ] && [ "$got_filter" = "delta --color-only" ]; then + pass "T_delta_gitconfig_iso: core.pager=delta and interactive.diffFilter set under isolated config" + else + fail "T_delta_gitconfig_iso: unexpected config values (pager='${got_pager}', filter='${got_filter}')" + fi + fi + rm -f "$ISO_CFG" +fi + +# --------------------------------------------------------------------------- +# T_delta_gitconfig_idem: running apply_delta_git_config twice does not error +# (git config --global is naturally idempotent). +# Fails RED when delta.sh does not exist. +# --------------------------------------------------------------------------- +echo "" +echo "--- T_delta_gitconfig_idem ---" +if [ ! -f "$DELTA_SH" ]; then + fail "T_delta_gitconfig_idem: delta.sh not found -- RED (pre-implementation)" +else + ISO_CFG2="$(mktemp)" + # shellcheck disable=SC2030,SC2031 + ( + # shellcheck disable=SC2031 + export GIT_CONFIG_GLOBAL="$ISO_CFG2" + # shellcheck disable=SC1090 + . "$DELTA_SH" + apply_delta_git_config # first run + apply_delta_git_config # second run -- must be a no-op / safe + ) + idem_exit=$? + if [ "$idem_exit" -ne 0 ]; then + fail "T_delta_gitconfig_idem: second run of apply_delta_git_config exited $idem_exit" + else + # shellcheck disable=SC2031 + got_dark="$(GIT_CONFIG_GLOBAL="$ISO_CFG2" git config --global --get delta.dark 2>/dev/null || true)" + if [ "$got_dark" = "true" ]; then + pass "T_delta_gitconfig_idem: idempotent double-run completed; delta.dark=true" + else + fail "T_delta_gitconfig_idem: delta.dark unexpected value after double-run: '${got_dark}'" + fi + fi + rm -f "$ISO_CFG2" +fi + +# --------------------------------------------------------------------------- +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/tests/test_delta_installer_pwsh.ps1 b/tests/test_delta_installer_pwsh.ps1 new file mode 100644 index 0000000..7607de7 --- /dev/null +++ b/tests/test_delta_installer_pwsh.ps1 @@ -0,0 +1,143 @@ +# tests/test_delta_installer_pwsh.ps1 -- parity tests for git-delta opt-in installer (#466) +# +# Tests: +# T_delta_in_list -- delta appears in -List output (opt-in discoverable) +# T_delta_not_default -- delta is NOT installed by a default no-arg run +# T_delta_gitconfig_iso -- Set-DeltaGitConfig writes core.pager=delta under an +# isolated GIT_CONFIG_GLOBAL (does not touch real config) +# T_delta_gitconfig_idem -- Set-DeltaGitConfig is idempotent (safe to run twice) +# +# Usage: powershell -ExecutionPolicy Bypass -File tests\test_delta_installer_pwsh.ps1 +# PS 5.1 ASCII-only: no smart quotes, em-dashes, or non-ASCII characters. + +$ErrorActionPreference = 'Stop' +$TestsPassed = 0 +$TestsFailed = 0 +$TestsSkipped = 0 + +$RepoRoot = Split-Path $PSScriptRoot -Parent +$WinSetup = Join-Path $RepoRoot 'scripts\windows\setup.ps1' +$DeltaPs1 = Join-Path $RepoRoot 'scripts\windows\tools\delta.ps1' +$StubDir = Join-Path $RepoRoot 'tests\fixtures\stub-tools\windows' + +function Test-Scenario { + param([string]$Name, [scriptblock]$Test) + Write-Host "`n=== TEST: $Name ===" -ForegroundColor Cyan + try { + & $Test + Write-Host "[PASS] $Name" -ForegroundColor Green + $script:TestsPassed++ + } + catch { + Write-Host "[FAIL] $Name" -ForegroundColor Red + Write-Host " Error: $_" -ForegroundColor Red + $script:TestsFailed++ + } +} + +function Write-Skip { + param([string]$Name, [string]$Reason) + Write-Host "`n=== TEST: $Name ===" -ForegroundColor Cyan + Write-Host "[SKIP] $Name -- $Reason" -ForegroundColor Yellow + $script:TestsSkipped++ +} + +# --------------------------------------------------------------------------- +# T_delta_in_list: -List on real registry includes 'delta' +# Fails RED when delta.ps1 is not dot-sourced + registered in setup.ps1. +# --------------------------------------------------------------------------- + +Test-Scenario "T_delta_in_list: delta appears in -List output (opt-in discoverable)" { + $out = powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup -List 2>&1 | Out-String + $ec = $LASTEXITCODE + if ($ec -ne 0) { throw "-List exited $ec (expected 0)" } + if ($out -notmatch '\bdelta\b') { + throw "delta missing from -List output; has delta.ps1 been registered in ToolRegistry?" + } +} + +# --------------------------------------------------------------------------- +# T_delta_not_default: default no-arg run with stub dir does NOT run delta +# --------------------------------------------------------------------------- + +Test-Scenario "T_delta_not_default: delta does NOT run in a default install (opt-in only)" { + $runLog = [System.IO.Path]::GetTempFileName() + $env:RUN_LOG = $runLog + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir 2>&1 | Out-Null + $content = Get-Content $runLog -ErrorAction SilentlyContinue + if ($content -contains 'delta') { + throw "delta ran in a default no-arg install (must be opt-in only)" + } + } + finally { + $env:RUN_LOG = $null + Remove-Item $runLog -ErrorAction SilentlyContinue + } +} + +# --------------------------------------------------------------------------- +# T_delta_gitconfig_iso: Set-DeltaGitConfig writes core.pager=delta under +# an isolated GIT_CONFIG_GLOBAL; real ~/.gitconfig is never touched. +# Fails RED when delta.ps1 does not exist. +# --------------------------------------------------------------------------- + +Test-Scenario "T_delta_gitconfig_iso: core.pager=delta written to isolated config" { + if (-not (Test-Path $DeltaPs1)) { + throw "delta.ps1 not found at $DeltaPs1 -- RED (pre-implementation)" + } + $isolatedConfig = [System.IO.Path]::GetTempFileName() + $savedConfig = $env:GIT_CONFIG_GLOBAL + try { + $env:GIT_CONFIG_GLOBAL = $isolatedConfig + # Dot-source delta.ps1 to load Set-DeltaGitConfig (and Install-Delta) + # Install-Delta is defined but not called here. + . $DeltaPs1 + Set-DeltaGitConfig + $pager = git config --global --get core.pager + if ($pager -ne 'delta') { + throw "core.pager expected 'delta', got '$pager'" + } + $filter = git config --global --get interactive.diffFilter + if ($filter -ne 'delta --color-only') { + throw "interactive.diffFilter expected 'delta --color-only', got '$filter'" + } + } + finally { + $env:GIT_CONFIG_GLOBAL = $savedConfig + Remove-Item $isolatedConfig -ErrorAction SilentlyContinue + } +} + +# --------------------------------------------------------------------------- +# T_delta_gitconfig_idem: running Set-DeltaGitConfig twice does not error +# --------------------------------------------------------------------------- + +Test-Scenario "T_delta_gitconfig_idem: Set-DeltaGitConfig is idempotent (safe to run twice)" { + if (-not (Test-Path $DeltaPs1)) { + throw "delta.ps1 not found at $DeltaPs1 -- RED (pre-implementation)" + } + $isolatedConfig = [System.IO.Path]::GetTempFileName() + $savedConfig = $env:GIT_CONFIG_GLOBAL + try { + $env:GIT_CONFIG_GLOBAL = $isolatedConfig + . $DeltaPs1 + Set-DeltaGitConfig # first run + Set-DeltaGitConfig # second run -- must not error + $dark = git config --global --get delta.dark + if ($dark -ne 'true') { + throw "delta.dark expected 'true' after double-run, got '$dark'" + } + } + finally { + $env:GIT_CONFIG_GLOBAL = $savedConfig + Remove-Item $isolatedConfig -ErrorAction SilentlyContinue + } +} + +# --------------------------------------------------------------------------- +Write-Host "" +Write-Host "Results: $TestsPassed passed, $TestsFailed failed, $TestsSkipped skipped" +if ($TestsFailed -gt 0) { exit 1 } +exit 0