From 27ffc291f76486b2ea24bb541ac97f47aac336a2 Mon Sep 17 00:00:00 2001 From: Manu Lorente Date: Tue, 14 Jul 2026 10:58:42 -0600 Subject: [PATCH] fix(doctor): install GUARD-001 memory-sink hooks on Windows + fix agy abs-path check setup-windows.ps1 never deployed the git-hooks dispatcher or wired core.hooksPath, so the machine-wide memory-sink guard AGENTS.md advertises did not exist on Windows -- an agent could commit MEMORY.md/memory/ into any repo. Separately, doctor's AGY_APP_DATA check used HasPrefix(_, "/"), false-FAILing an absolute Windows path (C:\Users\...\antigravity-cli) whenever agy was on PATH. - add scripts/install-git-hooks.ps1 (Windows twin of install-git-hooks.sh): clean-mirror the dispatcher + wire core.hooksPath (unset-only, preserve existing) - setup-windows.ps1 deploys + wires via Install-GitHooks -DotfilesDir $DotfilesDest; the wired path equals the Go filepath.Join(cfg.DotfilesDir,"git-hooks") doctor verifies (backslash round-trip verified empirically) - doctor: HasPrefix(agyData,"/") -> filepath.IsAbs - Pester (8) + a Go OS-aware test; e2e on Windows: dotf doctor GUARD all ok Closes #691 --- .../doctor/checks_antigravity_test.go | 35 +++++ cli/internal/doctor/checks_deploy.go | 5 +- scripts/install-git-hooks.ps1 | 130 ++++++++++++++++++ setup-windows.ps1 | 21 +++ specs/BUG-032-windows-guard-001/proposal.md | 118 ++++++++++++++++ specs/BUG-032-windows-guard-001/tasks.md | 41 ++++++ .../BUG-032-windows-guard-001/verification.md | 73 ++++++++++ tests/install-git-hooks.Tests.ps1 | 78 +++++++++++ 8 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 cli/internal/doctor/checks_antigravity_test.go create mode 100644 scripts/install-git-hooks.ps1 create mode 100644 specs/BUG-032-windows-guard-001/proposal.md create mode 100644 specs/BUG-032-windows-guard-001/tasks.md create mode 100644 specs/BUG-032-windows-guard-001/verification.md create mode 100644 tests/install-git-hooks.Tests.ps1 diff --git a/cli/internal/doctor/checks_antigravity_test.go b/cli/internal/doctor/checks_antigravity_test.go new file mode 100644 index 00000000..0f6e77f9 --- /dev/null +++ b/cli/internal/doctor/checks_antigravity_test.go @@ -0,0 +1,35 @@ +package doctor + +import ( + "bytes" + "runtime" + "strings" + "testing" +) + +// TestCheckAntigravity_AbsolutePathAccepted guards the C20 fix (#691): the +// AGY_APP_DATA absolute-path check used strings.HasPrefix(_, "/"), which +// false-FAILed an absolute Windows path (C:\Users\...\antigravity-cli) whenever +// agy was on PATH on Windows. filepath.IsAbs recognizes each OS's absolute form, +// so an OS-native absolute path must PASS. On the windows-latest runner this +// exercises the exact regression; on ubuntu it confirms the POSIX form still +// passes. +func TestCheckAntigravity_AbsolutePathAccepted(t *testing.T) { + abs := "/home/me/.gemini/antigravity-cli" + if runtime.GOOS == "windows" { + abs = `C:\Users\me\.gemini\antigravity-cli` + } + + sys := newSys(map[string]string{"AGY_APP_DATA": abs}, []string{"agy"}, nil) + var buf bytes.Buffer + rep := capture(&buf) + checkAntigravity(sys, rep) + + out := buf.String() + if strings.Contains(out, "AGY_APP_DATA is relative or unset") { + t.Errorf("absolute path %q was false-FAILed:\n%s", abs, out) + } + if !strings.Contains(out, "AGY_APP_DATA is absolute") { + t.Errorf("absolute path %q should report absolute:\n%s", abs, out) + } +} diff --git a/cli/internal/doctor/checks_deploy.go b/cli/internal/doctor/checks_deploy.go index 07db516e..2473268e 100644 --- a/cli/internal/doctor/checks_deploy.go +++ b/cli/internal/doctor/checks_deploy.go @@ -347,7 +347,10 @@ func checkAntigravity(sys *System, rep *Report) { } agyData := sys.env("AGY_APP_DATA", filepath.Join(sys.home(), ".gemini", "antigravity-cli")) - if strings.HasPrefix(agyData, "/") { + // filepath.IsAbs, not HasPrefix(_, "/"): an absolute Windows path + // (C:\Users\...\.gemini\antigravity-cli) is not '/'-rooted, so the POSIX-only + // check false-FAILed it whenever agy was on PATH on Windows (#691 / C20). + if filepath.IsAbs(agyData) { rep.Pass("AGY_APP_DATA is absolute") } else { rep.Fail("AGY_APP_DATA is relative or unset: " + agyData) diff --git a/scripts/install-git-hooks.ps1 b/scripts/install-git-hooks.ps1 new file mode 100644 index 00000000..dddc387a --- /dev/null +++ b/scripts/install-git-hooks.ps1 @@ -0,0 +1,130 @@ +# install-git-hooks.ps1 - deploy the GUARD-001 global memory-sink dispatcher and +# wire it machine-wide via core.hooksPath. The Windows twin of +# scripts/install-git-hooks.sh (which always noted "a Windows setup twin is a +# tracked follow-up" -- this is it, #691). +# +# Bootstrap stays shell (ADR-020 C7): the deployed `dotf` release carries no +# source tree, so setup-windows.ps1 places the dispatcher and `dotf doctor` +# verifies the wiring thereafter. The dispatcher hooks are POSIX and run under +# Git-for-Windows `sh`, so no per-OS hook rewrite is needed -- only the +# deploy + wire. +# +# Sourced by setup-windows.ps1; also runnable standalone to (re)install: +# .\scripts\install-git-hooks.ps1 [SourceDir] [DotfilesDir] +# +# Idempotent (clean mirror + converge) and safety-first: an unrelated pre-existing +# core.hooksPath is preserved, never clobbered (a global hooksPath has machine-wide +# blast radius). Functions take src/dest as args so Pester can drive them against +# fixtures under a throwaway GIT_CONFIG_GLOBAL with no real ~/.gitconfig mutation. +# +# ASCII-only by repo convention (pattern-powershell-ascii-only). + +Set-StrictMode -Version Latest + +# Deploy-GitHooks: clean-mirror the dispatcher tree into the deploy mirror. A +# clean mirror (remove-then-copy, not a bare copy) so a hook removed upstream +# never lingers and keeps firing -- a stale security hook is worse than none. +# Returns $true on success, $false on a guard trip or error. +function Deploy-GitHooks { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', + Justification = 'GitHooks is inherently plural; mirrors install-git-hooks.sh and the git-hooks/ dispatcher dir')] + param( + [Parameter(Mandatory)][string]$Source, + [Parameter(Mandatory)][string]$Destination + ) + + if (-not (Test-Path -LiteralPath $Source -PathType Container)) { + Write-Host "[FAIL] install-git-hooks: source dispatcher dir not found: $Source" -ForegroundColor Red + return $false + } + if (-not (Test-Path -LiteralPath (Join-Path $Source 'pre-commit') -PathType Leaf)) { + Write-Host "[FAIL] install-git-hooks: $Source has no pre-commit dispatcher -- refusing to deploy" -ForegroundColor Red + return $false + } + + # Guard the destructive mirror: the dest MUST be a non-root *\git-hooks path. + # Defends against an empty/misconfigured DOTFILES_DIR turning Remove-Item loose. + $normDest = $Destination.TrimEnd('\', '/') + if ($normDest -notmatch '[\\/]git-hooks$') { + Write-Host "[FAIL] install-git-hooks: refusing to mirror to unexpected path: '$Destination'" -ForegroundColor Red + return $false + } + if ($normDest -eq (Join-Path $HOME 'git-hooks').TrimEnd('\', '/') -or + $normDest -match '^[A-Za-z]:[\\/]git-hooks$' -or $normDest -match '^[\\/]git-hooks$') { + Write-Host "[FAIL] install-git-hooks: refusing unsafe dest: '$Destination'" -ForegroundColor Red + return $false + } + + # Self-mirror guard (#695): if src and dest resolve to the same directory, the + # clean-mirror below (remove dest THEN copy) would delete the dispatcher and + # copy nothing back. Nothing to mirror onto itself; report already-in-place. + $srcFull = (Resolve-Path -LiteralPath $Source).Path.TrimEnd('\', '/') + $destFull = if (Test-Path -LiteralPath $Destination) { + (Resolve-Path -LiteralPath $Destination).Path.TrimEnd('\', '/') + } else { $normDest } + if ($srcFull -eq $destFull) { + Write-Host "[INFO] install-git-hooks: src and dest are the same directory ($Destination) -- skipping mirror (already in place)" -ForegroundColor Blue + Write-Host "[SUCCESS] GUARD dispatcher already in place at $Destination" -ForegroundColor Green + return $true + } + + if (Test-Path -LiteralPath $Destination) { Remove-Item -LiteralPath $Destination -Recurse -Force } + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + Copy-Item -Path (Join-Path $Source '*') -Destination $Destination -Recurse -Force + Write-Host "[SUCCESS] GUARD dispatcher deployed to $Destination" -ForegroundColor Green + return $true +} + +# Set-GlobalHooksPath: point git's global core.hooksPath at the deployed +# dispatcher -- but ONLY when unset. An unrelated value is preserved (machine-wide +# blast radius) and surfaced as a warning; an already-correct value is a no-op. +# Mirrors the `dotf doctor` checkGuardHooks contract. +function Set-GlobalHooksPath { + param([Parameter(Mandatory)][string]$Target) + + $current = (git config --global --get core.hooksPath 2>$null) + if ($current) { $current = "$current".Trim() } + + if (-not $current) { + git config --global core.hooksPath $Target + if ($LASTEXITCODE -eq 0) { + Write-Host "[SUCCESS] core.hooksPath wired to the GUARD dispatcher ($Target)" -ForegroundColor Green + return $true + } + Write-Host "[FAIL] install-git-hooks: failed to set core.hooksPath" -ForegroundColor Red + return $false + } + if ($current -eq $Target) { + Write-Host "[INFO] core.hooksPath already wired to the GUARD dispatcher" -ForegroundColor Blue + return $true + } + Write-Host "[WARNING] core.hooksPath is '$current' (not the GUARD dispatcher) -- preserving it; the memory-sink guard is INACTIVE. Point it at $Target, or chain the dispatcher from your hooks, manually." -ForegroundColor Yellow + return $true +} + +# Install-GitHooks: deploy + wire. Defaults resolve the repo's git-hooks/ (this +# script's sibling parent) and the ~/.dotfiles deploy mirror. +function Install-GitHooks { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', + Justification = 'GitHooks is inherently plural; mirrors install-git-hooks.sh and the git-hooks/ dispatcher dir')] + param( + [string]$Source = (Join-Path $PSScriptRoot '..\git-hooks'), + [string]$DotfilesDir = $(if ($env:DOTFILES_DIR) { $env:DOTFILES_DIR } else { Join-Path $HOME '.dotfiles' }) + ) + + if (-not $DotfilesDir -or $DotfilesDir.TrimEnd('\', '/') -eq $HOME.TrimEnd('\', '/') -or + $DotfilesDir -match '^[A-Za-z]:[\\/]?$') { + Write-Host "[FAIL] install-git-hooks: unsafe DOTFILES_DIR: '$DotfilesDir'" -ForegroundColor Red + return $false + } + + $dest = Join-Path $DotfilesDir 'git-hooks' + if (-not (Deploy-GitHooks -Source $Source -Destination $dest)) { return $false } + if (-not (Set-GlobalHooksPath -Target $dest)) { return $false } + return $true +} + +# Run standalone only when executed directly (not when dot-sourced by setup). +if ($MyInvocation.InvocationName -ne '.') { + $null = Install-GitHooks @args +} diff --git a/setup-windows.ps1 b/setup-windows.ps1 index e7c3b0d2..fcaadd9c 100644 --- a/setup-windows.ps1 +++ b/setup-windows.ps1 @@ -331,6 +331,27 @@ if (Test-Path $packagesSource) { Write-Warn "packages.json not found at $packagesSource" } +# ============================================================================ +# 1b2. GUARD-001 MEMORY-SINK HOOKS (#691) +# ============================================================================ +# Deploy the dispatcher into the ~/.dotfiles mirror and wire core.hooksPath +# machine-wide, so the guard that rejects MEMORY.md/memory/ outside the vault is +# active in every repo -- parity with setup-linux.sh (which had no Windows twin). +# The wired path (Join-Path $DotfilesDest 'git-hooks') equals the Go +# filepath.Join(cfg.DotfilesDir, "git-hooks") that `dotf doctor` verifies. +# Non-fatal: doctor verifies + repairs thereafter. +$installHooksPs1 = Join-Path $PSScriptRoot 'scripts\install-git-hooks.ps1' +if (Test-Path -LiteralPath $installHooksPs1) { + . $installHooksPs1 + if (Install-GitHooks -Source (Join-Path $PSScriptRoot 'git-hooks') -DotfilesDir $DotfilesDest) { + Write-Success "GUARD-001 memory-sink hooks installed" + } else { + Write-Warn "GUARD-001 hooks install incomplete (continuing; run 'dotf doctor --fix')" + } +} else { + Write-Warn "scripts\install-git-hooks.ps1 not found; skipping memory-sink guard install" +} + # ============================================================================ # 1c. DEVELOPER TOOLS (via winget) # ============================================================================ diff --git a/specs/BUG-032-windows-guard-001/proposal.md b/specs/BUG-032-windows-guard-001/proposal.md new file mode 100644 index 00000000..9676b7b1 --- /dev/null +++ b/specs/BUG-032-windows-guard-001/proposal.md @@ -0,0 +1,118 @@ +--- +id: "BUG-032-windows-guard-001" +type: spec +status: implementing # draft | implementing | verifying | archived +created: "2026-07-14" +issue: "mlorentedev/dotfiles#691" # repo#NNN — GitHub issue / Project item that tracks this spec +tags: [spec, proposal, guard-001, memory-sink, powershell, doctor, fresh-machine, security] +template_version: "1.0" +--- + +# BUG-032-windows-guard-001 + +Install the GUARD-001 memory-sink dispatcher on Windows (the setup twin +`install-git-hooks.sh` always flagged as a follow-up) and fix the POSIX-only +absolute-path check that made `dotf doctor` false-FAIL the agy AGY_APP_DATA on +Windows. + +## Why + +Two Windows-side gaps in the memory-sink guard and doctor (audit C20 + the +GUARD-001 Windows gap): + +- **GUARD-001 never installs on Windows.** `setup-windows.ps1` has zero + `core.hooksPath` / git-hooks deploy references (grep-confirmed). AGENTS.md's + "MEMORY SINGLE-SINK (GUARD-001)" advertises a machine-wide pre-commit guard that + rejects `MEMORY.md`/`memory/` outside the vault — on Windows it simply does not + exist, so an agent can commit memory artifacts into any repo. The Linux setup + deploys the dispatcher and wires the global hook via + `scripts/install-git-hooks.sh`; there is no Windows equivalent, and + `install-git-hooks.sh` itself notes "a Windows setup twin is a tracked + follow-up." Empirically on a real box: `dotf doctor` reports + `[FAIL] dispatcher not found at C:\Users\\.dotfiles\git-hooks — run dotfiles + setup to deploy git-hooks/`, and `core.hooksPath` is unset. +- **C20 — doctor false-FAIL on a valid Windows path.** `checks_deploy.go:350` + gates `AGY_APP_DATA` with `strings.HasPrefix(agyData, "/")`. An absolute Windows + path (`C:\Users\…\.gemini\antigravity-cli`) does not start with `/`, so whenever + agy is on PATH on Windows `dotf doctor` reports "AGY_APP_DATA is relative or + unset" — the same POSIX-only assumption the #551 `contractOS` work removed + elsewhere in this package. + +## What + +- **`scripts/install-git-hooks.ps1`** — the PowerShell twin of + `install-git-hooks.sh`. Two functions plus an entry point, matching the shell + contract exactly: + - `Deploy-GitHooks `: clean-mirror the dispatcher tree into + `` (remove-then-copy, so a hook removed upstream never lingers — a stale + security hook is worse than none). Same safety guards: refuse a `` that + is not a `*\git-hooks` path, refuse `$HOME\git-hooks` / a drive root, and no-op + when `src` and `dest` resolve to the same directory (#695 self-empty guard). + - `Set-GlobalHooksPath `: wire global `core.hooksPath` to `` + **only when unset**; an unrelated pre-existing value is preserved and warned + (machine-wide blast radius), an already-correct value is a no-op. Mirrors the + `dotf doctor` `checkGuardHooks` contract. + - `Install-GitHooks [src] [dotfilesDir]`: deploy + wire, defaulting `src` to the + repo's `git-hooks/` and `dotfilesDir` to `~/.dotfiles`. +- **`setup-windows.ps1`** sources `install-git-hooks.ps1` and calls + `Install-GitHooks`, non-fatal (a warning on failure, like the Linux side), so a + fresh Windows box ends with the dispatcher at `~/.dotfiles\git-hooks` and + `core.hooksPath` wired. The wired path is `Join-Path $DotfilesDest 'git-hooks'`, + which round-trips byte-for-byte through `git config` and equals the Go + `filepath.Join(cfg.DotfilesDir, "git-hooks")` the doctor compares against + (verified empirically), so `dotf doctor` then PASSES on Windows. +- **C20 fix (Go).** `checks_deploy.go`: `strings.HasPrefix(agyData, "/")` -> + `filepath.IsAbs(agyData)`, so an absolute Windows path passes. +- **Tests.** Pester for `install-git-hooks.ps1` (clean-mirror deploys the + dispatcher; re-deploy prunes a stale hook; wire sets an unset hooksPath; + preserves an unrelated value; refuses an unsafe dest) driven against fixtures + under an isolated temp dir with a throwaway `--file` git config — no real + `~/.gitconfig` mutation. Go unit test for the `filepath.IsAbs` branch. + +## Out of scope + +- **Porting the guard install to Go.** Bootstrap stays shell (ADR-020 C7): the + deployed `dotf` release carries no source tree, so it cannot self-deploy + `git-hooks/`; the setup bootstrap places them and `dotf doctor` verifies. The + `.ps1` twin mirrors the existing `.sh`; this is not a new-tooling case. +- **The dispatcher hooks themselves.** They are POSIX and already run under + Git-for-Windows `sh`; unchanged. +- **The other fresh-machine bugs** (#690, #688) — their own issues. + +## Risks / open questions + +- **Path-match (resolved).** `git config --global core.hooksPath` stores a + backslash Windows path and `--get` returns it unchanged (verified with a + throwaway config file), so the PS-wired `~/.dotfiles\git-hooks` equals the + doctor's `filepath.Join(cfg.DotfilesDir, "git-hooks")` and `current == target` + holds. No forward/back-slash normalization drift. +- **Machine-wide blast radius.** Global `core.hooksPath` affects every repo. The + wire is unset-only + preserve-existing + warn, identical to the Linux twin and + the doctor `--fix`; never clobbered. +- **Idempotency.** Clean-mirror + unset-only wire are safe to re-run each setup. +- **Windows CI.** The `test-windows` Pester job runs `setup-windows.ps1` e2e on a + runner; the new install runs there. PSScriptAnalyzer must be extended to cover + the new script (currently only 4 files; tracked in #692) — meanwhile it is + linted locally. + +## Acceptance criteria + +- [ ] `scripts/install-git-hooks.ps1` `Deploy-GitHooks` clean-mirrors `git-hooks/` + to a `*\git-hooks` dest and refuses unsafe dests. +- [ ] `Set-GlobalHooksPath` wires an unset `core.hooksPath`, no-ops when already + correct, preserves+warns on an unrelated value. +- [ ] `setup-windows.ps1` deploys the dispatcher to `~/.dotfiles\git-hooks` and + wires `core.hooksPath`; afterward `dotf doctor` GUARD section PASSES on + Windows (verified end-to-end on a real box). +- [ ] `dotf doctor` reports AGY_APP_DATA absolute for a Windows path + (`filepath.IsAbs`), no false-FAIL. +- [ ] Pester green for `install-git-hooks.ps1`; Go test green for the IsAbs branch; + `go build/vet/test ./...` + PSScriptAnalyzer (changed files) clean; ASCII-only. + +## References + +- GH issue: [#691](https://github.com/mlorentedev/dotfiles/issues/691) +- Linux twin: `scripts/install-git-hooks.sh` (deploy + wire, the contract mirrored) +- GUARD-001 history: #398 (dispatcher), #415 (verifier), #418 (wiring), #695 (self-empty guard) +- ADR-020 C7 (bootstrap stays shell); ADR-025 (cross-machine paths) +- Sibling fresh-machine bugs: #690, #688; PSSA coverage gap: #692 diff --git a/specs/BUG-032-windows-guard-001/tasks.md b/specs/BUG-032-windows-guard-001/tasks.md new file mode 100644 index 00000000..059e4b78 --- /dev/null +++ b/specs/BUG-032-windows-guard-001/tasks.md @@ -0,0 +1,41 @@ +--- +tags: [spec, tasks, guard-001, powershell, doctor] +created: "2026-07-14" +--- + +# Tasks - BUG-032-windows-guard-001 + +> TDD order. One task = one focused commit. + +## Setup + +- [x] Branch created from main: `fix/windows-guard-001-memory-sink` +- [x] `proposal.md` complete; acceptance criteria testable +- [x] Path-match risk resolved empirically before coding (git config round-trip) + +## Implementation + +- [x] Go: failing test for the C20 branch — an OS-absolute AGY_APP_DATA must PASS + (`TestCheckAntigravity_AbsolutePathAccepted`, OS-aware); proved red under + `HasPrefix("/")`, green under `filepath.IsAbs` +- [x] Go: `checks_deploy.go` `strings.HasPrefix(agyData,"/")` -> `filepath.IsAbs` +- [x] `scripts/install-git-hooks.ps1`: `Deploy-GitHooks` (clean mirror + safety + guards), `Set-GlobalHooksPath` (wire-when-unset, preserve, no-op), + `Install-GitHooks` (deploy+wire); dot-source-safe entry guard +- [x] Pester: `tests/install-git-hooks.Tests.ps1` (mirror/prune/refuse-unsafe; + wire/no-op/preserve via throwaway GIT_CONFIG_GLOBAL) — 8/8 +- [x] `setup-windows.ps1`: source + `Install-GitHooks -DotfilesDir $DotfilesDest` + (non-fatal), as a new GUARD-001 section +- [x] Empirical e2e on this Windows box: install + `dotf doctor` GUARD = all ok + +## Closing + +- [x] Every acceptance criterion covered by a test or the e2e run +- [x] `go build`/`vet`/`test ./...` clean (golangci-lint: CI) +- [x] PSScriptAnalyzer clean on changed files (PSUseSingularNouns suppressed with + justification for the inherently-plural GitHooks nouns); ASCII-only +- [x] No unrelated changes (pre-existing setup-windows.ps1 em-dashes untouched, #692) +- [x] `verification.md` filled in +- [ ] PR opened referencing this spec folder + +> `features.json` omitted, matching the BUG-029/030/031 precedent. diff --git a/specs/BUG-032-windows-guard-001/verification.md b/specs/BUG-032-windows-guard-001/verification.md new file mode 100644 index 00000000..72a567f7 --- /dev/null +++ b/specs/BUG-032-windows-guard-001/verification.md @@ -0,0 +1,73 @@ +--- +tags: [spec, verification, guard-001, powershell, doctor, fresh-machine, security] +created: "2026-07-14" +--- + +# Verification - BUG-032-windows-guard-001 + +## Evidence + +- [x] `Deploy-GitHooks` clean-mirrors to a `*\git-hooks` dest and refuses unsafe + dests -> `tests/install-git-hooks.Tests.ps1` (mirror/prune/refuse-non-git-hooks/ + refuse-drive-root/refuse-no-pre-commit), 8/8 Pester. +- [x] `Set-GlobalHooksPath` wires an unset `core.hooksPath`, no-ops when correct, + preserves+warns on an unrelated value -> same suite, isolated via a throwaway + `GIT_CONFIG_GLOBAL`. +- [x] `setup-windows.ps1` deploys the dispatcher to `~/.dotfiles\git-hooks` and + wires `core.hooksPath`; `dotf doctor` GUARD section then PASSES on Windows -> + **verified end-to-end on this box**: with a temp `DOTFILES_DIR` + + `GIT_CONFIG_GLOBAL`, `Install-GitHooks` wired + `...\.dotfiles\git-hooks`, the dispatcher `pre-commit` was present, and + `dotf doctor` reported `[GUARD memory-sink hooks] (1 checks, all ok)`. +- [x] `dotf doctor` reports AGY_APP_DATA absolute for a Windows path -> + `TestCheckAntigravity_AbsolutePathAccepted` (OS-aware): proven red under the + old `HasPrefix("/")` (FAILed `C:\Users\me\...`), green under `filepath.IsAbs`. +- [x] `go build`/`vet`/`test ./...` clean; Pester 8/8; PSScriptAnalyzer clean on + the changed `.ps1`; all changed `.ps1` ASCII-only. + +## Test status + +- Go: `go build/vet/test ./...` -> ok; `TestCheckAntigravity_AbsolutePathAccepted` + passes (and fails under the reverted `HasPrefix`, confirming it guards the bug). +- Pester (Windows, this box): `install-git-hooks.Tests.ps1` -> Passed=8 Failed=0. +- End-to-end (this box): install + `dotf doctor` agree — the PS-wired path equals + the Go `filepath.Join(cfg.DotfilesDir,"git-hooks")` target, so the guard shows + "all ok" rather than a false unwired/absent FAIL. +- PSScriptAnalyzer: PASS on `install-git-hooks.ps1`, `setup-windows.ps1`, + `install-git-hooks.Tests.ps1` (repo settings, Error+Warning). + +## Decisions made during implementation + +- **Verify the path-match empirically BEFORE coding.** The one real risk was that + git normalizes a backslash `core.hooksPath` and breaks the doctor's exact + `current == target` compare. A throwaway `git config --file` round-trip proved + the backslash path is stored+returned verbatim, so replicating the path in + PowerShell (mirroring the Linux twin) is safe — no need to route the wiring + through `dotf doctor --fix`. +- **A `.ps1` twin of `install-git-hooks.sh`, not a Go port.** Bootstrap stays shell + (ADR-020 C7): the deployed `dotf` carries no source tree, so setup must place the + dispatcher. Factored into `scripts/install-git-hooks.ps1` (not inlined in + setup-windows.ps1) for Pester-testability + symmetry with the `.sh`. +- **Split the pure IsAbs test OS-aware.** `filepath.IsAbs` is OS-dependent + (`C:\...` is absolute only on Windows); the test uses the current OS's absolute + form so it is green on both the ubuntu and windows Go runners while the + windows-latest run exercises the exact regression. +- **`PSUseSingularNouns` suppressed with justification** rather than renaming: + "GitHooks" is inherently plural and mirrors `install-git-hooks.sh` / the + `git-hooks/` dir. (Not CI-caught today — install-git-hooks.ps1 is outside the + hardcoded lint list, #692 — but fixed so a coverage fix later stays green.) + +## Promotion candidates + +- [ ] Lesson for `docs/lessons.md`? yes (small) — "when a Windows twin must match a + Go-computed path via `git config`, verify the storage round-trip empirically + before assuming string equality." Capture on merge if it recurs; the + proposal + this file already record it. +- [ ] ADR-worthy? no — applies ADR-020 C7, does not change it. +- [ ] New vault pattern? no — repo-local. + +## Archive checklist + +- [ ] `proposal.md` frontmatter set to `status: archived` +- [ ] Folder moved to `specs/archive/BUG-032-windows-guard-001/` +- [ ] Bitácora ticket (#691) closed with PR link (ADR-018) diff --git a/tests/install-git-hooks.Tests.ps1 b/tests/install-git-hooks.Tests.ps1 new file mode 100644 index 00000000..b98ef044 --- /dev/null +++ b/tests/install-git-hooks.Tests.ps1 @@ -0,0 +1,78 @@ +# Pester 5 tests for scripts/install-git-hooks.ps1 (BUG-032 / #691): the Windows +# twin of install-git-hooks.sh that deploys the GUARD-001 dispatcher and wires +# core.hooksPath. Fixtures live under $TestDrive (auto-cleaned); git config is +# isolated via a throwaway GIT_CONFIG_GLOBAL so the real ~/.gitconfig is never +# mutated. + +BeforeAll { + . (Join-Path $PSScriptRoot '..\scripts\install-git-hooks.ps1') +} + +Describe 'Deploy-GitHooks (clean mirror + safety guards)' { + BeforeEach { + $script:src = Join-Path $TestDrive 'repo\git-hooks' + New-Item -ItemType Directory -Path $script:src -Force | Out-Null + Set-Content -Path (Join-Path $script:src 'pre-commit') -Value 'dispatcher' + New-Item -ItemType Directory -Path (Join-Path $script:src 'lib') -Force | Out-Null + Set-Content -Path (Join-Path $script:src 'lib\memory-sink-guard.sh') -Value 'guard' + $script:dest = Join-Path $TestDrive 'deploy\git-hooks' + } + + It 'clean-mirrors the dispatcher tree to a *\git-hooks dest' { + Deploy-GitHooks -Source $script:src -Destination $script:dest | Should -BeTrue + Test-Path (Join-Path $script:dest 'pre-commit') | Should -BeTrue + Test-Path (Join-Path $script:dest 'lib\memory-sink-guard.sh') | Should -BeTrue + } + + It 'prunes a hook removed upstream on re-deploy (clean mirror, not additive)' { + Deploy-GitHooks -Source $script:src -Destination $script:dest | Should -BeTrue + Set-Content -Path (Join-Path $script:dest 'stale-hook') -Value 'old' + Deploy-GitHooks -Source $script:src -Destination $script:dest | Should -BeTrue + Test-Path (Join-Path $script:dest 'stale-hook') | Should -BeFalse + } + + It 'refuses a dest that is not a *\git-hooks path' { + Deploy-GitHooks -Source $script:src -Destination (Join-Path $TestDrive 'deploy\hooks') | Should -BeFalse + } + + It 'refuses a drive-root git-hooks dest' { + Deploy-GitHooks -Source $script:src -Destination 'C:\git-hooks' | Should -BeFalse + } + + It 'refuses a source without a pre-commit dispatcher' { + $empty = Join-Path $TestDrive 'empty\git-hooks' + New-Item -ItemType Directory -Path $empty -Force | Out-Null + Deploy-GitHooks -Source $empty -Destination $script:dest | Should -BeFalse + } +} + +Describe 'Set-GlobalHooksPath (wire when unset, preserve otherwise)' { + BeforeEach { + $script:cfg = Join-Path $TestDrive 'throwaway-gitconfig' + Set-Content -Path $script:cfg -Value '' + $script:prevGlobal = $env:GIT_CONFIG_GLOBAL + $env:GIT_CONFIG_GLOBAL = $script:cfg + } + AfterEach { + $env:GIT_CONFIG_GLOBAL = $script:prevGlobal + } + + It 'wires an unset core.hooksPath to the target' { + $target = 'C:\Users\me\.dotfiles\git-hooks' + Set-GlobalHooksPath -Target $target | Should -BeTrue + (git config --global --get core.hooksPath).Trim() | Should -BeExactly $target + } + + It 'is a no-op when already wired to the target' { + $target = 'C:\d\git-hooks' + git config --global core.hooksPath $target + Set-GlobalHooksPath -Target $target | Should -BeTrue + (git config --global --get core.hooksPath).Trim() | Should -BeExactly $target + } + + It 'preserves an unrelated pre-existing core.hooksPath' { + git config --global core.hooksPath 'C:\other\hooks' + Set-GlobalHooksPath -Target 'C:\d\git-hooks' | Should -BeTrue + (git config --global --get core.hooksPath).Trim() | Should -BeExactly 'C:\other\hooks' + } +}