From 19eb15632da04957d8a7cffd8c5fd2df8463de4f Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 8 Aug 2026 13:04:05 -0700 Subject: [PATCH] fix(install): fall back to a plain shallow clone when git lacks --sparse (#1238) --- scripts/install.cmd | 40 +++++++- scripts/install.ps1 | 46 +++++++++- scripts/install.sh | 45 ++++++++- scripts/install.test.ts | 199 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 315 insertions(+), 15 deletions(-) diff --git a/scripts/install.cmd b/scripts/install.cmd index 6f5f39496..08b2b1d4f 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -1124,6 +1124,10 @@ set "KIRO_AGENTS_DIR=%USERPROFILE%\.kiro\agents" set "OPENCODE_COMMANDS_DIR=%USERPROFILE%\.config\opencode\commands" set "GEMINI_COMMANDS_DIR=%USERPROFILE%\.gemini\commands" set "SKILLS_TMP=%TEMP%\plannotator-skills-%RANDOM%" +REM git's stderr is captured OUTSIDE SKILLS_TMP (which is removed before the +REM failure message prints) so a failed clone can show the real git error +REM (#1238) instead of only the generic "network or git error" line. +set "GIT_ERR_FILE=%TEMP%\plannotator-git-stderr-%RANDOM%.txt" mkdir "!SKILLS_TMP!" >nul 2>&1 REM Opt-out: jump past the clone so no network call is made and @@ -1131,10 +1135,34 @@ REM CHECKOUT_FAILED stays 0 - an opt-out is not a fetch failure and must not REM trip the guard below. Reported above, next to the git check. if "!SKIP_SKILLS!"=="1" goto skills_checkout_done -git clone --depth 1 --filter=blob:none --sparse "https://github.com/!REPO!.git" --branch "!TAG!" "!SKILLS_TMP!\repo" >nul 2>&1 -if !ERRORLEVEL! equ 0 ( +set "CLONE_OK=0" +set "SPARSE_CLONE=1" +git clone --depth 1 --filter=blob:none --sparse "https://github.com/!REPO!.git" --branch "!TAG!" "!SKILLS_TMP!\repo" >nul 2>"!GIT_ERR_FILE!" +if !ERRORLEVEL! equ 0 set "CLONE_OK=1" + +REM Capability probe, not a version parse (same philosophy as the GitButler +REM flag probing in packages/shared/gitbutler-core.ts): `git clone --sparse` +REM needs git >= 2.25, and an older git rejects the flag instantly with +REM "error: unknown option `sparse'" before any network call (#1238). Fall +REM back to a plain shallow clone - it costs download size, not correctness: +REM every path the copy steps below read is present in the full checkout, and +REM `git sparse-checkout set` (equally missing on that git) is skipped +REM because there is nothing to narrow. +set "SPARSE_UNSUPPORTED=0" +if "!CLONE_OK!"=="0" ( + findstr /i /c:"unknown option" "!GIT_ERR_FILE!" >nul 2>&1 && findstr /i /c:"sparse" "!GIT_ERR_FILE!" >nul 2>&1 && set "SPARSE_UNSUPPORTED=1" +) +if "!SPARSE_UNSUPPORTED!"=="1" ( + echo This git does not support "git clone --sparse" ^(needs git ^>= 2.25^) - falling back to a plain shallow clone. + set "SPARSE_CLONE=0" + if exist "!SKILLS_TMP!\repo" rmdir /s /q "!SKILLS_TMP!\repo" >nul 2>&1 + git clone --depth 1 "https://github.com/!REPO!.git" --branch "!TAG!" "!SKILLS_TMP!\repo" >nul 2>"!GIT_ERR_FILE!" + if !ERRORLEVEL! equ 0 set "CLONE_OK=1" +) + +if "!CLONE_OK!"=="1" ( pushd "!SKILLS_TMP!\repo" - git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands >nul 2>&1 + if "!SPARSE_CLONE!"=="1" git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands >nul 2>&1 REM Claude Code reads apps\skills\claude\* (injection `!`plannotator ... $ARGUMENTS`` REM + allowed-tools, so /plannotator-* run with no permission prompt); Codex @@ -1218,9 +1246,15 @@ rmdir /s /q "!SKILLS_TMP!" >nul 2>&1 if "!CHECKOUT_FAILED!"=="1" ( echo Error: unable to fetch !REPO! at !TAG! ^(network or git error^). 1>&2 + if exist "!GIT_ERR_FILE!" ( + echo git reported: 1>&2 + type "!GIT_ERR_FILE!" 1>&2 + del /q "!GIT_ERR_FILE!" >nul 2>&1 + ) echo Something went wrong - run the installer again. 1>&2 exit /b 1 ) +del /q "!GIT_ERR_FILE!" >nul 2>&1 REM Claude Code commands are deprecated in favor of skills. Remove a legacy REM command file only once its replacement skill is actually on disk - running diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 93d47d8e3..f93d62169 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1047,6 +1047,13 @@ function Copy-SkillIfPresent { } } +# Captured tail of git's stderr from the most recent failed clone attempt, +# surfaced with the "network or git error" message below so the real failure +# self-diagnoses instead of being swallowed (#1238). Read before $skillsTmp +# is removed. +$gitStderrTail = @() +$sparseClone = $true + try { # Scoped Continue preference: on PowerShell < 7.2 (and profiles that # restore the old behavior), redirecting a native command's stderr under @@ -1054,9 +1061,32 @@ try { # terminating error, and git prints its normal "Cloning into ..." # progress on stderr, so the clone "failed" on the message announcing it # started (#1162). Real failures stay detectable: the clone is verified - # by Test-Path below, never by a throw. + # by Test-Path below, never by a throw. Stderr goes to a file instead of + # $null (#1238) so failures can be diagnosed. + $gitErrFile = Join-Path $skillsTmp "git-stderr.txt" if (-not $skipSkillsResolved) { - & { $local:ErrorActionPreference = 'Continue'; git clone --depth 1 --filter=blob:none --sparse "https://github.com/$repo.git" --branch $latestTag "$skillsTmp\repo" 2>$null } + & { $local:ErrorActionPreference = 'Continue'; git clone --depth 1 --filter=blob:none --sparse "https://github.com/$repo.git" --branch $latestTag "$skillsTmp\repo" 2>$gitErrFile } + if (-not (Test-Path "$skillsTmp\repo")) { + $cloneErr = "" + if (Test-Path $gitErrFile) { $cloneErr = [System.IO.File]::ReadAllText($gitErrFile) } + # Capability probe, not a version parse (same philosophy as the + # GitButler flag probing in packages/shared/gitbutler-core.ts): + # `git clone --sparse` needs git >= 2.25, and an older git rejects + # the flag instantly with "error: unknown option `sparse'" before + # any network call (#1238). Fall back to a plain shallow clone - + # it costs download size, not correctness: every path the copy + # steps below read is present in the full checkout, and + # `git sparse-checkout set` (equally missing on that git) is + # skipped because there is nothing to narrow. + if ($cloneErr -match '(?i)unknown option' -and $cloneErr -match '(?i)sparse') { + Write-Host "This git does not support 'git clone --sparse' (needs git >= 2.25) - falling back to a plain shallow clone." + $sparseClone = $false + & { $local:ErrorActionPreference = 'Continue'; git clone --depth 1 "https://github.com/$repo.git" --branch $latestTag "$skillsTmp\repo" 2>$gitErrFile } + } + } + if ((-not (Test-Path "$skillsTmp\repo")) -and (Test-Path $gitErrFile)) { + $gitStderrTail = @(Get-Content $gitErrFile -ErrorAction SilentlyContinue | Select-Object -Last 5) + } } # git is a native executable - it does not throw under # $ErrorActionPreference=Stop on non-zero exit. Guard with @@ -1076,8 +1106,12 @@ try { try { # Same scoped Continue as the clone above: sparse-checkout may # write advice to stderr, which must not become a terminating - # error on PowerShell < 7.2 (#1162). - & { $local:ErrorActionPreference = 'Continue'; git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands 2>$null } + # error on PowerShell < 7.2 (#1162). Skipped entirely on the + # plain-clone fallback (#1238): that git has no sparse-checkout + # subcommand, and the full checkout needs no narrowing. + if ($sparseClone) { + & { $local:ErrorActionPreference = 'Continue'; git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands 2>$null } + } # Claude Code and Codex consume different skill bodies. Claude Code # reads apps/skills/claude/* (dynamic-context injection @@ -1168,6 +1202,10 @@ Remove-Item -Recurse -Force $skillsTmp -ErrorAction SilentlyContinue if ($checkoutFailed) { Write-Host "Error: unable to fetch $repo at $latestTag (network or git error)." + if ($gitStderrTail.Count -gt 0) { + Write-Host "git reported:" + foreach ($line in $gitStderrTail) { Write-Host " $line" } + } Write-Host "Something went wrong - run the installer again." exit 1 } diff --git a/scripts/install.sh b/scripts/install.sh index e22d1b61e..b827ee8c6 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1610,7 +1610,7 @@ copy_commands_if_present() { # command of an AND-OR list except the last, and every shell we tested # (bash 3.2.57, which is what `curl | bash` gets on macOS, plus bash 5.3, # dash, zsh, and ksh) carries that suppression into the subshell. Writing -# it as `if ! ( ... ); then` suppresses -e the same way. So the four fetch +# it as `if ! ( ... ); then` suppresses -e the same way. So the fetch # steps below carry an explicit `|| exit 1`: without them a failed clone # ran the whole block anyway, the subshell exited 0 on its trailing `if`, # and the installer printed "YOU'RE ALL SET!" with no skills installed. @@ -1629,10 +1629,47 @@ checkout_failed=0 fi cd "$skills_tmp" || exit 1 - git clone --depth 1 --filter=blob:none --sparse \ - "https://github.com/${REPO}.git" --branch "$latest_tag" repo 2>/dev/null || exit 1 + # Capture git's stderr instead of discarding it (#1238): on failure the + # real error is surfaced below so incompatibilities self-diagnose instead + # of hiding behind the generic "network or git error" message. + git_err="$skills_tmp/git-stderr" + surface_git_error() { + echo "git reported:" >&2 + tail -n 5 "$git_err" >&2 + } + sparse_clone=1 + if ! git clone --depth 1 --filter=blob:none --sparse \ + "https://github.com/${REPO}.git" --branch "$latest_tag" repo 2>"$git_err"; then + # Capability probe, not a version parse (same philosophy as the + # GitButler flag probing in packages/shared/gitbutler-core.ts): + # `git clone --sparse` needs git >= 2.25, and an older git (macOS + # with stale Xcode CLT ships 2.23) rejects the flag instantly with + # "error: unknown option `sparse'" before any network call (#1238). + # Fall back to a plain shallow clone — it costs download size, not + # correctness: every path the copy steps below read is present in + # the full checkout, and `git sparse-checkout set` (equally missing + # on that git) is skipped because there is nothing to narrow. + if grep -qi "unknown option" "$git_err" && grep -qi "sparse" "$git_err"; then + echo "This git does not support 'git clone --sparse' (needs git >= 2.25) — falling back to a plain shallow clone." + sparse_clone=0 + rm -rf repo + if ! git clone --depth 1 \ + "https://github.com/${REPO}.git" --branch "$latest_tag" repo 2>"$git_err"; then + surface_git_error + exit 1 + fi + else + surface_git_error + exit 1 + fi + fi cd repo || exit 1 - git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands 2>/dev/null || exit 1 + if [ "$sparse_clone" -eq 1 ]; then + if ! git sparse-checkout set apps/skills apps/kiro-cli apps/opencode-plugin/commands apps/gemini/commands 2>"$git_err"; then + surface_git_error + exit 1 + fi + fi # Core skills -> Claude Code (also serve as /plannotator-* slash commands) # and the official OpenAI shared-agent path. SOFT guard: a tag pinned diff --git a/scripts/install.test.ts b/scripts/install.test.ts index 1712d13d5..732361f85 100644 --- a/scripts/install.test.ts +++ b/scripts/install.test.ts @@ -1971,9 +1971,69 @@ const FAKE_BINARY_SHA256 = createHash("sha256").update(FAKE_BINARY).digest("hex" const ATTESTATION_FIXTURE = join(scriptsDir, "fixtures", "attestations-response.json"); type GhBehavior = "reject-bundle" | "fail-all" | "pass-all"; +type GitBehavior = "fail" | "sparse-unsupported" | "network-error"; + +// git shim that behaves like git 2.23 (macOS with stale Xcode CLT, #1238): +// `clone --sparse` dies instantly on "unknown option" (exit 129, before any +// network call), a plain shallow clone succeeds and fake-creates the paths +// the installer's copy steps read, and `sparse-checkout` is not a command. +// Backslashes in the clone destination are normalized so the same shim also +// serves the install.ps1 region driver (PS normalizes `\` on Unix; a literal +// backslash dir name would not round-trip through Test-Path). +const GIT_SPARSE_UNSUPPORTED_SHIM = `#!/bin/bash +if [ "$1" = "clone" ]; then + for a in "$@"; do + if [ "$a" = "--sparse" ]; then + echo "error: unknown option \\\`sparse'" >&2 + echo "usage: git clone [] [--] []" >&2 + exit 129 + fi + done + dest="" + for a in "$@"; do dest="$a"; done + dest="\${dest//\\\\//}" + mkdir -p "$dest" + for skill in plannotator-review plannotator-annotate plannotator-last; do + mkdir -p "$dest/apps/skills/claude/$skill" "$dest/apps/skills/core/$skill" + printf 'name: %s\\n' "$skill" > "$dest/apps/skills/claude/$skill/SKILL.md" + printf 'name: %s\\n' "$skill" > "$dest/apps/skills/core/$skill/SKILL.md" + done + mkdir -p "$dest/apps/opencode-plugin/commands" + printf 'stub\\n' > "$dest/apps/opencode-plugin/commands/plannotator-review.md" + exit 0 +fi +if [ "$1" = "sparse-checkout" ]; then + echo "git: 'sparse-checkout' is not a git command. See 'git --help'." >&2 + exit 1 +fi +exit 0 +`; + +// git shim for a genuine (network) clone failure with a distinctive stderr +// the installer must now surface instead of swallowing (#1238). +const GIT_NETWORK_ERROR_SHIM = `#!/bin/bash +if [ "$1" = "clone" ]; then + echo "Cloning into 'repo'..." >&2 + echo "fatal: unable to access 'https://github.com/backnotprop/plannotator.git/': Could not resolve host: github.com" >&2 + exit 128 +fi +exit 1 +`; + +function gitShimBody(git: GitBehavior): string { + switch (git) { + case "sparse-unsupported": + return GIT_SPARSE_UNSUPPORTED_SHIM; + case "network-error": + return GIT_NETWORK_ERROR_SHIM; + case "fail": + return "#!/bin/bash\nexit 1\n"; + } +} function setupInstallSandbox(opts: { gh: GhBehavior; + git?: GitBehavior; codexHome?: boolean; plannotatorConfig?: string; }) { @@ -2023,10 +2083,12 @@ exit 1` : "exit 0"; writeFileSync(join(stub, "gh"), `#!/bin/bash\n${ghBody}\n`, { mode: 0o755 }); - // Stub git that fails instantly on clone, so the skills checkout never - // reaches the network and the run terminates deterministically right - // after the agent-integration blocks whose output the tests assert on. - writeFileSync(join(stub, "git"), "#!/bin/bash\nexit 1\n", { mode: 0o755 }); + // Stub git. Default ("fail"): fails instantly on clone, so the skills + // checkout never reaches the network and the run terminates + // deterministically right after the agent-integration blocks whose output + // the tests assert on. The #1238 behaviors emulate an old git without + // --sparse and a genuine network failure. + writeFileSync(join(stub, "git"), gitShimBody(opts.git ?? "fail"), { mode: 0o755 }); // Real node for the bundle extraction. const nodeBin = Bun.which("node"); @@ -2135,6 +2197,48 @@ describe.skipIf(process.platform === "win32" || !Bun.which("node"))( expect(out).not.toContain("Created Codex hooks at"); expect(existsSync(join(sandbox.home, ".codex", "hooks.json"))).toBe(false); }); + + test("#1238: a git without clone --sparse falls back to a plain shallow clone and still installs the skills", () => { + // git 2.23 (macOS with stale Xcode CLT) rejects --sparse instantly on + // "unknown option" before any network call. The installer must probe + // that from the captured stderr, retry as a plain shallow clone, skip + // `git sparse-checkout set` (equally missing on that git — the shim + // hard-fails it to prove it is never run), and complete the install. + const sandbox = setupInstallSandbox({ gh: "pass-all", git: "sparse-unsupported" }); + const { code, out } = runInstallSh(sandbox, [ + "--version", "v99.9.9", "--non-interactive", "--no-extras", + ]); + expect(out).toContain( + "This git does not support 'git clone --sparse' (needs git >= 2.25)", + ); + expect(out).toContain("falling back to a plain shallow clone"); + expect(out).toContain("Installed Claude Code skills to"); + expect(out).toContain("Installed shared agent skills to"); + // The misleading terminal failure from the issue must be gone entirely. + expect(out).not.toContain("network or git error"); + expect(code).toBe(0); + // The downstream copy steps work identically from the full checkout. + for (const skill of CORE_SKILLS) { + expect(existsSync(join(sandbox.home, ".claude", "skills", skill, "SKILL.md"))).toBe(true); + expect(existsSync(join(sandbox.home, ".agents", "skills", skill, "SKILL.md"))).toBe(true); + } + }); + + test("#1238: a genuine clone failure surfaces git's captured stderr next to the generic message", () => { + const sandbox = setupInstallSandbox({ gh: "pass-all", git: "network-error" }); + const { code, out } = runInstallSh(sandbox, [ + "--version", "v99.9.9", "--non-interactive", "--no-extras", + ]); + // The real diagnostic is no longer swallowed by 2>/dev/null... + expect(out).toContain("git reported:"); + expect(out).toContain("Could not resolve host: github.com"); + // ...and the existing hard-fail contract for genuine errors holds. + expect(out).toContain("network or git error"); + expect(code).toBe(1); + expect(existsSync(join(sandbox.home, ".claude", "skills", "plannotator-review"))).toBe(false); + // A network failure is not a capability miss: no fallback attempt. + expect(out).not.toContain("falling back to a plain shallow clone"); + }); }, ); @@ -2295,3 +2399,90 @@ describe.skipIf(!pwshBin)("attestation bundle scanner under PowerShell (M7)", () }, PWSH_SCANNER_TIMEOUT_MS); } }); + +// --------------------------------------------------------------------------- +// #1238 under PowerShell: the install.ps1 skills-checkout region, extracted +// and driven the same way the M7 scanner region is, against the same git +// shims the bash functional tests use. The shims are bash scripts, so these +// run on Unix hosts with pwsh (the copy-step fidelity on Windows is covered +// by the bash functional tests plus the shared source-scan suite). +// --------------------------------------------------------------------------- + +function extractPs1SkillsCheckoutRegion(): string { + const ps = readScript("install.ps1"); + const start = ps.indexOf("$checkoutFailed = $false"); + const end = ps.indexOf("# Claude Code commands are deprecated"); + if (start < 0 || end < 0 || end <= start) { + throw new Error("could not locate the skills checkout region in install.ps1"); + } + return ps.slice(start, end); +} + +function runPs1SkillsCheckout(git: GitBehavior): { code: number; out: string; home: string } { + const root = mkdtempSync(join(tmpdir(), "plannotator-ps1-checkout-test-")); + const home = join(root, "home"); + const stub = join(root, "stub-bin"); + mkdirSync(join(home, "tmp"), { recursive: true }); + mkdirSync(stub, { recursive: true }); + writeFileSync(join(stub, "git"), gitShimBody(git), { mode: 0o755 }); + + const driver = [ + `$ErrorActionPreference = "Stop"`, + `$repo = "backnotprop/plannotator"`, + `$latestTag = "v9.9.9"`, + `$skipSkillsResolved = $false`, + `$skipKiroResolved = $true`, + `$skipOpencodeResolved = $true`, + `$skipGeminiResolved = $true`, + `$kiroAvailable = $false`, + `$claudeSkillsDir = Join-Path "${home}" ".claude/skills"`, + `$agentsSkillsDir = Join-Path "${home}" ".agents/skills"`, + extractPs1SkillsCheckoutRegion(), + `exit 0`, + ].join("\n"); + const driverPath = join(root, "driver.ps1"); + writeFileSync(driverPath, driver); + const r = Bun.spawnSync([pwshBin!, "-NoProfile", "-File", driverPath], { + env: { + PATH: `${stub}:/usr/bin:/bin`, + HOME: home, + USERPROFILE: home, + TMPDIR: join(home, "tmp"), + }, + stdout: "pipe", + stderr: "pipe", + }); + return { + code: r.exitCode, + out: r.stdout.toString() + r.stderr.toString(), + home, + }; +} + +describe.skipIf(!pwshBin || process.platform === "win32")( + "install.ps1 skills checkout on old git (#1238)", + () => { + test("a git without clone --sparse falls back to a plain shallow clone and still installs the skills", () => { + const { code, out, home } = runPs1SkillsCheckout("sparse-unsupported"); + expect(out).toContain( + "This git does not support 'git clone --sparse' (needs git >= 2.25)", + ); + expect(out).toContain("falling back to a plain shallow clone"); + expect(out).toContain("Installed Claude Code skills to"); + expect(out).not.toContain("network or git error"); + expect(code).toBe(0); + expect( + existsSync(join(home, ".claude", "skills", "plannotator-review", "SKILL.md")), + ).toBe(true); + }, PWSH_SCANNER_TIMEOUT_MS); + + test("a genuine clone failure surfaces git's captured stderr next to the generic message", () => { + const { code, out } = runPs1SkillsCheckout("network-error"); + expect(out).toContain("git reported:"); + expect(out).toContain("Could not resolve host: github.com"); + expect(out).toContain("network or git error"); + expect(out).not.toContain("falling back to a plain shallow clone"); + expect(code).toBe(1); + }, PWSH_SCANNER_TIMEOUT_MS); + }, +);