diff --git a/tools-sync/references/skills_env_template.md b/tools-sync/references/skills_env_template.md index 3578557..e329663 100644 --- a/tools-sync/references/skills_env_template.md +++ b/tools-sync/references/skills_env_template.md @@ -17,6 +17,27 @@ CODEX_SKILLS_DIR = C:\Users\\.codex\skills 凭据说明:首次 clone 成功后运行 `git config --global credential.helper manager`, 让 Windows 凭据管理器接管,此后 `SKILLS_REPO_PASS` 可以清空。 +### gitea 服务端(tools-sync 的 gitea_doctor 用;不自建 gitea 就整节留空) + +``` +GITEA_SSH_HOST = +GITEA_SSH_PORT = 22 +GITEA_SSH_HOST_ALT = <走公网跳板时的地址,没有就留空> +GITEA_SSH_PORT_ALT = <跳板端口,没有就留空> +GITEA_SSH_USER = <登录用户> +GITEA_HOST_KEY_FP = +GITEA_CONTAINER = gitea +GITEA_DOCKER_BIN = /usr/local/bin/docker +GITEA_DATA_DIR = <宿主机上 bind 到容器 /data 的目录> +GITEA_COMPOSE_FILE = +GITEA_HTTP_LOCAL = http://localhost:3000 +``` + +填写要点:脚本要免密登录,先把本机公钥加进服务端的 `authorized_keys` 并实测一次。 +`GITEA_DOCKER_BIN` 写绝对路径——**ssh 非交互登录通常不加载登录 shell 的 PATH, +`docker` 会找不到**。`GITEA_HOST_KEY_FP` 不是可选项:有跳板时它是你唯一能确认 +"连上的确实是那台机器"的凭据。 + ## Python / Conda ``` diff --git a/tools-sync/scripts/gitea_doctor.ps1 b/tools-sync/scripts/gitea_doctor.ps1 new file mode 100644 index 0000000..1f97f26 --- /dev/null +++ b/tools-sync/scripts/gitea_doctor.ps1 @@ -0,0 +1,165 @@ +# tools-sync: verify the gitea transport from this machine, and when it is broken, log +# into the server and repair it. +# +# Why this exists: on 2026-08-25 the server started handing out empty packs. push kept +# working, `ls-remote` kept working, only the pack plane was dead - so every sync run +# "succeeded" while three commits sat stranded on one machine for eight days. Checking +# the transport is cheap; discovering the outage a week late is not. +# +# .\gitea_doctor.ps1 # check only, report what is wrong +# .\gitea_doctor.ps1 -Fix # check, then repair on the server, then re-verify +# +# Exit: 0 = transport healthy at the end of the run, 1 = still broken. + +param( + [string]$EnvFile = "$env:USERPROFILE\skills_env.md", + [string]$RepoUrl, + # Log in and repair, instead of only reporting. + [switch]$Fix, + # Also allow repairs that can lose data (container recreate, repo surgery). The + # server-side script asks for this separately; nothing here needs it by default. + [switch]$AllowDestructive, + [int]$SshTimeout = 15 +) + +$ErrorActionPreference = "Stop" + +function Fail([string]$Msg) { Write-Host "FAIL: $Msg"; exit 1 } + +# ---------- environment ---------- +if (-not (Test-Path $EnvFile)) { Fail "$EnvFile not found. Copy references\skills_env_template.md there first." } +$envText = Get-Content $EnvFile -Raw +function Get-EnvKey([string]$Key) { + $m = [regex]::Match($envText, "(?m)^\s*$([regex]::Escape($Key))\s*=\s*(.+?)\s*$") + if (-not $m.Success) { return "" } + $v = $m.Groups[1].Value.Trim() + if ($v -match "^<.*>$") { return "" } # unfilled placeholder + return $v +} + +if (-not $RepoUrl) { $RepoUrl = Get-EnvKey "SKILLS_REPO_URL" } +if (-not $RepoUrl) { Fail "SKILLS_REPO_URL missing from $EnvFile and no -RepoUrl given." } + +$sshHost = Get-EnvKey "GITEA_SSH_HOST" +$sshPort = Get-EnvKey "GITEA_SSH_PORT" +$sshHostA = Get-EnvKey "GITEA_SSH_HOST_ALT" +$sshPortA = Get-EnvKey "GITEA_SSH_PORT_ALT" +$sshUser = Get-EnvKey "GITEA_SSH_USER" +$container = Get-EnvKey "GITEA_CONTAINER" +$dockerBin = Get-EnvKey "GITEA_DOCKER_BIN" +if (-not $sshPort) { $sshPort = "22" } +if (-not $container) { $container = "gitea" } +if (-not $dockerBin) { $dockerBin = "/usr/local/bin/docker" } + +# ---------- client-side transport check ---------- +# Two planes, because they fail independently and the difference is the whole diagnosis: +# refs = ls-remote / info-refs (kept working right through the 2026-09 outage) +# pack = an actual fetch (this is what was dead) +function Test-Transport { + $result = [ordered]@{ Refs = $false; Pack = $false; Sha = ""; Detail = "" } + + $prev = $ErrorActionPreference; $ErrorActionPreference = "Continue" + $refs = & git ls-remote $RepoUrl "refs/heads/*" 2>&1 | ForEach-Object { "$_" } + $refsCode = $LASTEXITCODE + $ErrorActionPreference = $prev + if ($refsCode -eq 0 -and $refs) { + $result.Refs = $true + $result.Sha = (($refs -join "`n") -split "\s+")[0] + } else { + $result.Detail = ($refs -join " ") + return $result + } + + # tree:0 + depth 1 keeps this to a few KB; the outage failed even at that size, so a + # tiny probe is a valid detector and costs nothing to run on every sync. + $tmp = Join-Path ([IO.Path]::GetTempPath()) ("gitea-doctor-" + [guid]::NewGuid().ToString("N")) + $prev = $ErrorActionPreference; $ErrorActionPreference = "Continue" + $out = & git -c core.askPass=true clone --quiet --bare --depth 1 --filter=tree:0 $RepoUrl $tmp 2>&1 | ForEach-Object { "$_" } + $code = $LASTEXITCODE + $ErrorActionPreference = $prev + if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue } + if ($code -eq 0) { $result.Pack = $true } else { $result.Detail = ($out -join " ") } + return $result +} + +Write-Host "=== gitea transport check: $RepoUrl ===" +$t = Test-Transport +if ($t.Refs) { Write-Host " refs plane: OK (main-ish head $($t.Sha))" } else { Write-Host " refs plane: BROKEN - $($t.Detail)" } +if ($t.Pack) { Write-Host " pack plane: OK" } else { Write-Host " pack plane: BROKEN - $($t.Detail)" } + +if ($t.Refs -and $t.Pack) { Write-Host "transport healthy"; exit 0 } + +# A dead refs plane too means the host or gitea is down/unreachable, not the empty-pack +# fault - still worth logging in, the triage script starts by checking the container. +if (-not $Fix) { + Write-Host "" + Write-Host "Transport is broken. Re-run with -Fix to log into $sshUser@$sshHost and repair," + Write-Host "or see the skill's Phase 1.5 to do it by hand." + exit 1 +} + +# ---------- reach the server ---------- +if (-not $sshHost -or -not $sshUser) { Fail "GITEA_SSH_HOST / GITEA_SSH_USER missing from $EnvFile - cannot reach the server." } + +function Invoke-Remote([string]$HostName, [string]$Port, [string]$LocalScript, [string[]]$ScriptArgs) { + # Copy the script over rather than piping it into `sh -s`. Piping looks simpler but + # PS 5.1 encodes native-process stdin its own way and prepends a UTF-8 BOM, which + # lands on the shebang and makes sh report "#!/bin/sh: No such file or directory". + # scp moves bytes verbatim, so what runs there is exactly the file that is here. + $remote = "/tmp/tools-sync-triage.$PID.sh" + $prev = $ErrorActionPreference; $ErrorActionPreference = "Continue" + $common = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=$SshTimeout", "-o", "StrictHostKeyChecking=accept-new") + + $cp = & scp @common "-P" $Port $LocalScript "${sshUser}@${HostName}:$remote" 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -ne 0) { + $ErrorActionPreference = $prev + return @{ Code = 255; Out = ($cp -join "`n") } + } + + $cmd = "sh $remote " + ($ScriptArgs -join " ") + "; rc=`$?; rm -f $remote; exit `$rc" + $out = & ssh @common "-p" $Port "$sshUser@$HostName" $cmd 2>&1 | ForEach-Object { "$_" } + $code = $LASTEXITCODE + $ErrorActionPreference = $prev + return @{ Code = $code; Out = ($out -join "`n") } +} + +$triage = Join-Path $PSScriptRoot "gitea_triage.sh" +if (-not (Test-Path $triage)) { Fail "gitea_triage.sh missing next to this script." } +# Normalise to LF and no BOM before shipping it: a CRLF or BOM on the shebang line makes +# sh fail with a "No such file or directory" that names the interpreter, not the cause. +$triageStaged = Join-Path ([IO.Path]::GetTempPath()) "tools-sync-triage-$PID.sh" +$triageText = ((Get-Content $triage -Raw) -replace "`r`n", "`n") -replace "^", "" +[IO.File]::WriteAllText($triageStaged, $triageText, (New-Object System.Text.UTF8Encoding($false))) + +$args = @("--container", $container, "--docker", $dockerBin, "--fix") +if ($AllowDestructive) { $args += "--allow-destructive" } + +$targets = @(@{ H = $sshHost; P = $sshPort; Label = "direct" }) +if ($sshHostA -and $sshPortA) { $targets += @{ H = $sshHostA; P = $sshPortA; Label = "fallback" } } + +$r = $null +foreach ($tg in $targets) { + Write-Host "" + Write-Host "=== server triage via $($tg.Label): $sshUser@$($tg.H):$($tg.P) ===" + $r = Invoke-Remote $tg.H $tg.P $triageStaged $args + if ($r.Out -notmatch "Connection (timed out|refused)|Could not resolve|Permission denied|No route to host|lost connection") { break } + Write-Host " unreachable: $(($r.Out -split "`n")[0])" + $r = $null +} +Remove-Item $triageStaged -Force -ErrorAction SilentlyContinue +if (-not $r) { Fail "could not reach the gitea host on any configured path (see GITEA_SSH_HOST / _ALT)." } + +Write-Host $r.Out + +# ---------- re-verify from here ---------- +# The server saying "repaired" is a claim; the client completing a fetch is the evidence. +Write-Host "" +Write-Host "=== re-verifying transport from this machine ===" +$t2 = Test-Transport +if ($t2.Refs -and $t2.Pack) { + Write-Host "transport healthy again - re-run the sync." + exit 0 +} +if ($t2.Refs) { Write-Host " refs plane: OK" } else { Write-Host " refs plane: BROKEN - $($t2.Detail)" } +if ($t2.Pack) { Write-Host " pack plane: OK" } else { Write-Host " pack plane: BROKEN - $($t2.Detail)" } +Fail "server triage ran but the transport is still broken. Read the RESULT line above; if it says still-broken, the fault is outside the known repairs - escalate to the skill's Phase 1.5 ladder." diff --git a/tools-sync/scripts/gitea_triage.sh b/tools-sync/scripts/gitea_triage.sh new file mode 100644 index 0000000..3d834d4 --- /dev/null +++ b/tools-sync/scripts/gitea_triage.sh @@ -0,0 +1,203 @@ +#!/bin/sh +# tools-sync: gitea server-side triage + repair. Runs ON the gitea host (POSIX sh). +# +# Driven by gitea_doctor.ps1, which pipes this in over ssh. Safe to run by hand too. +# +# Every check prints "PASS " or "FAIL " so the caller can +# parse the outcome without re-deriving it. Repairs only happen with --fix, and only +# the reversible ones; anything that could lose data needs --allow-destructive. +# +# Exit: 0 = healthy (after any repairs), 1 = still broken, 2 = usage/precondition error. + +CONTAINER=gitea +DOCKER=/usr/local/bin/docker +FIX=0 +DESTRUCTIVE=0 +REPO="" + +while [ $# -gt 0 ]; do + case "$1" in + --container) CONTAINER="$2"; shift 2 ;; + --docker) DOCKER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --fix) FIX=1; shift ;; + --allow-destructive) DESTRUCTIVE=1; shift ;; + *) echo "FAIL usage unknown argument: $1"; exit 2 ;; + esac +done + +[ -x "$DOCKER" ] || { echo "FAIL docker $DOCKER not executable (ssh PATH is not the login PATH)"; exit 2; } +D="$DOCKER" +TS=$(date +%Y%m%d-%H%M%S) +BROKEN=0 +REPAIRED="" + +note() { echo "PASS $1 $2"; } +bad() { echo "FAIL $1 $2"; BROKEN=1; } +fixed() { echo "FIXED $1 $2"; REPAIRED="$REPAIRED $1"; } +dex() { $D exec "$CONTAINER" sh -c "$1" 2>&1; } + +echo "=== gitea triage $TS (container=$CONTAINER fix=$FIX destructive=$DESTRUCTIVE) ===" + +# ---------- A. container is up ---------- +STATUS=$($D inspect "$CONTAINER" --format '{{.State.Status}}' 2>/dev/null) +if [ -z "$STATUS" ]; then + bad container "no such container: $CONTAINER" + echo "--- containers present ---"; $D ps -a --format '{{.Names}} {{.Image}} {{.Status}}' + exit 1 +fi +if [ "$STATUS" != "running" ]; then + bad container "status=$STATUS" + if [ "$FIX" = 1 ]; then + $D start "$CONTAINER" >/dev/null 2>&1 && sleep 8 + STATUS=$($D inspect "$CONTAINER" --format '{{.State.Status}}' 2>/dev/null) + [ "$STATUS" = "running" ] && fixed container "started" || { echo "FAIL container could not start"; exit 1; } + else + exit 1 + fi +else + note container "running ($($D inspect "$CONTAINER" --format '{{.Config.Image}}'))" +fi + +# Where gitea's own HOME (and therefore its global gitconfig) lives. It is NOT /data/git. +GHOME=$(dex 'getent passwd git 2>/dev/null | cut -d: -f6') +[ -n "$GHOME" ] || GHOME=/data/git +GCFG=$(dex 'ls /data/gitea/home/.gitconfig >/dev/null 2>&1 && echo /data/gitea/home/.gitconfig || echo '"$GHOME"'/.gitconfig') +GCFG=$(echo "$GCFG" | tr -d '\r') +echo "INFO gitconfig $GCFG" + +# ---------- B. disk ---------- +FREE=$(dex 'df -P /data | tail -1 | awk "{print \$4}"') +FREE=$(echo "$FREE" | tr -d '\r ') +if [ -n "$FREE" ] && [ "$FREE" -lt 1048576 ] 2>/dev/null; then + bad disk "only ${FREE}KB free on /data" +else + note disk "${FREE}KB free on /data" +fi + +# ---------- C. global gitconfig sanity ---------- +# The 2026-09-02 outage: [uploadpack] packObjectsHook replaced git pack-objects with a +# script that produced no pack, so every clone got an empty one. Any hook-ish config +# here is remote code execution by design, so treat its mere presence as a failure. +CFG_SIZE=$(dex "wc -c < $GCFG 2>/dev/null" | tr -d '\r ') +[ -n "$CFG_SIZE" ] || CFG_SIZE=0 +HOOKCFG=$(dex "grep -icE 'packObjectsHook|core\\.hooksPath|sshCommand|credential\\.helper *= *!' $GCFG 2>/dev/null" | tr -d '\r ') +[ -n "$HOOKCFG" ] || HOOKCFG=0 +if [ "$HOOKCFG" != 0 ] || [ "$CFG_SIZE" -gt 65536 ] 2>/dev/null; then + bad gitconfig "size=${CFG_SIZE}B suspicious-directives=${HOOKCFG} (expected a few hundred B, 0)" + dex "grep -inE 'packObjectsHook|core\\.hooksPath|sshCommand' $GCFG | head -5" + if [ "$FIX" = 1 ]; then + # gitea rewrites a clean global gitconfig at startup, so quarantine + restart is + # both the repair and the proof: hand-editing it would only paper over the entry. + dex "cp $GCFG /data/gitea/home/.gitconfig.quarantine.$TS 2>/dev/null || cp $GCFG $GCFG.quarantine.$TS" + dex "rm -f $GCFG" + $D restart "$CONTAINER" >/dev/null 2>&1 + sleep 12 + NEW=$(dex "wc -c < $GCFG 2>/dev/null" | tr -d '\r ') + NEWHOOK=$(dex "grep -icE 'packObjectsHook|core\\.hooksPath' $GCFG 2>/dev/null" | tr -d '\r ') + [ -n "$NEWHOOK" ] || NEWHOOK=0 + if [ "$NEWHOOK" = 0 ] && [ -n "$NEW" ]; then + fixed gitconfig "quarantined to .gitconfig.quarantine.$TS, gitea rebuilt a clean ${NEW}B one" + else + echo "FAIL gitconfig rebuild did not produce a clean file (size=$NEW hooks=$NEWHOOK)" + fi + fi +else + note gitconfig "${CFG_SIZE}B, no hook directives" +fi + +# ---------- D. tampered repo hooks ---------- +TAMPER=$(dex "grep -rlE '/tmp/[A-Za-z0-9_]+' /data/git/repositories 2>/dev/null | head -20") +TAMPER=$(echo "$TAMPER" | tr -d '\r' | sed '/^$/d') +if [ -n "$TAMPER" ]; then + bad hooks "hook files referencing /tmp:" + echo "$TAMPER" | sed 's/^/ /' + if [ "$FIX" = 1 ]; then + for h in $TAMPER; do + base=$(basename "$h") + tpl="/usr/share/git-core/templates/hooks/$base" + if dex "[ -f $tpl ]" >/dev/null 2>&1; then + dex "cp $tpl $h && chmod 755 $h" + else + dex "grep -v '/tmp/' $h > $h.clean && mv $h.clean $h && chmod 755 $h" + fi + done + LEFT=$(dex "grep -rlE '/tmp/[A-Za-z0-9_]+' /data/git/repositories 2>/dev/null | head -5" | tr -d '\r' | sed '/^$/d') + [ -z "$LEFT" ] && fixed hooks "restored from git templates" || echo "FAIL hooks still tampered: $LEFT" + fi +else + note hooks "no hook references /tmp" +fi + +# ---------- E. dropped payloads ---------- +PAYLOAD=$(dex "ls /tmp/giteahook* /tmp/col_* 2>/dev/null | head -20" | tr -d '\r' | sed '/^$/d') +if [ -n "$PAYLOAD" ]; then + bad payload "executable drops in /tmp:" + echo "$PAYLOAD" | sed 's/^/ /' + [ "$FIX" = 1 ] && { dex "rm -f /tmp/giteahook* /tmp/col_*" >/dev/null 2>&1; fixed payload "removed"; } +else + note payload "none" +fi + +# ---------- F. git binary not shimmed ---------- +# A leftover diagnostic wrapper (git.real present) silently changes every git call. +if dex "[ -e /usr/bin/git.real ]" >/dev/null 2>&1; then + bad gitbin "/usr/bin/git.real exists - /usr/bin/git is a wrapper, not the real binary" + if [ "$FIX" = 1 ]; then + dex "cp /usr/bin/git.real /usr/bin/git && rm -f /usr/bin/git.real" >/dev/null 2>&1 + fixed gitbin "real git restored" + fi +else + note gitbin "$(dex 'git --version' | tr -d '\r')" +fi + +# ---------- G. pack generation (the ladder's decisive rung) ---------- +# Must use --stateless-rpc: that is the code path HTTP takes. A `clone file://` proves +# nothing here, which is exactly how this fault stayed misdiagnosed for a week. +if [ -z "$REPO" ]; then + REPO=$(dex 'ls -d /data/git/repositories/*/*.git 2>/dev/null | head -1' | tr -d '\r') +fi +if [ -n "$REPO" ]; then + SHA=$(dex "git --git-dir=$REPO rev-parse HEAD 2>/dev/null" | tr -d '\r') + if [ -n "$SHA" ]; then + BYTES=$(dex "printf '0032want $SHA\n00000009done\n' | git upload-pack --stateless-rpc $REPO 2>/dev/null | wc -c" | tr -d '\r ') + [ -n "$BYTES" ] || BYTES=0 + if [ "$BYTES" -lt 512 ] 2>/dev/null; then + bad packgen "upload-pack --stateless-rpc produced only ${BYTES}B for $REPO (a real pack is KBs+)" + echo " -> git itself is being told not to work; check the gitconfig directives above" + else + note packgen "${BYTES}B pack from $REPO" + fi + else + bad packgen "cannot resolve HEAD of $REPO" + fi +else + bad packgen "no repository found under /data/git/repositories" +fi + +# ---------- H. end-to-end clone inside the container ---------- +# Bypasses frp, any reverse proxy and docker port forwarding: a failure here is gitea's. +PUB=$(dex 'ls -d /data/git/repositories/*/*.git 2>/dev/null | head -5' | tr -d '\r') +CLONE_OK=0 +CLONE_MSG="no public repo reachable anonymously" +for r in $PUB; do + owner=$(basename "$(dirname "$r")") + name=$(basename "$r" .git) + out=$(dex "rm -rf /tmp/tsdoc; GIT_TERMINAL_PROMPT=0 git clone -q --filter=tree:0 --depth 1 http://localhost:3000/$owner/$name.git /tmp/tsdoc 2>&1; ls /tmp/tsdoc/.git >/dev/null 2>&1 && echo TSDOC_OK; rm -rf /tmp/tsdoc") + case "$out" in + *TSDOC_OK*) CLONE_OK=1; CLONE_MSG="$owner/$name"; break ;; + *"could not read Username"*|*"Authentication"*) continue ;; + *) CLONE_MSG="$owner/$name: $(echo "$out" | tr -d '\r' | tr '\n' ' ' | cut -c1-160)" ;; + esac +done +[ "$CLONE_OK" = 1 ] && note httpclone "$CLONE_MSG" || bad httpclone "$CLONE_MSG" + +# ---------- verdict ---------- +echo "=== verdict ===" +if [ "$BROKEN" = 0 ]; then + echo "RESULT healthy"; exit 0 +fi +if [ -n "$REPAIRED" ]; then + echo "RESULT repaired:$REPAIRED (caller must re-verify from the client side)"; exit 0 +fi +echo "RESULT still-broken (run with --fix to apply the reversible repairs)"; exit 1 diff --git a/tools-sync/scripts/sync_repos.ps1 b/tools-sync/scripts/sync_repos.ps1 index 2006a86..eca6f60 100644 --- a/tools-sync/scripts/sync_repos.ps1 +++ b/tools-sync/scripts/sync_repos.ps1 @@ -15,7 +15,12 @@ param( # linking; -Force deletes it and links (caller must have rescued unique content first). [switch]$Force, # Report what would happen without committing/pushing/linking. - [switch]$DryRun + [switch]$DryRun, + # A transport failure normally aborts the run and leaves commits stranded here. By + # default we instead log into the gitea host, repair it, and retry once. -NoDoctor + # keeps the old abort-and-report behaviour. + [switch]$NoDoctor, + [switch]$AllowDestructive ) $ErrorActionPreference = "Stop" @@ -34,6 +39,28 @@ function Invoke-Git { function Fail([string]$Msg) { Write-Host "FAIL: $Msg"; exit 1 } +# git reports a server-side transport fault in the vocabulary of the pack protocol, not +# of the server. These are the shapes seen when gitea is up (auth fine, refs fine) but +# the pack plane is dead - the case gitea_doctor knows how to repair. +function Test-TransportFault([string]$GitOutput) { + return ($GitOutput -match "bad pack header|early EOF|invalid index-pack output|RPC failed|unexpected disconnect|remote end hung up|Could not read from remote repository|Connection (reset|refused|timed out)") +} + +function Invoke-Doctor([string]$Why) { + if ($NoDoctor) { Write-Host "(-NoDoctor set, skipping server repair)"; return $false } + $doctor = Join-Path $PSScriptRoot "gitea_doctor.ps1" + if (-not (Test-Path $doctor)) { Write-Host "gitea_doctor.ps1 not found; cannot repair the server."; return $false } + Write-Host "" + Write-Host "=== transport fault ($Why) - running gitea_doctor -Fix ===" + $dargs = @("-RepoUrl", $RepoUrl, "-Fix") + if ($AllowDestructive) { $dargs += "-AllowDestructive" } + & $doctor @dargs + $ok = ($LASTEXITCODE -eq 0) + Write-Host "=== doctor $(if ($ok) { 'reports the transport healthy' } else { 'could NOT restore the transport' }) ===" + Write-Host "" + return $ok +} + # ---------- Phase 1: primary checkout (~\.claude\skills) ---------- Write-Host "=== [1/2] primary git sync: $ClaudeSkills ===" if (-not (Test-Path (Join-Path $ClaudeSkills ".git"))) { @@ -94,16 +121,26 @@ if ($DryRun) { Assert-RemoteInSync $ClaudeSkills $Branch } else { $r = Invoke-Git $ClaudeSkills @("pull", "--rebase", "origin", $Branch) + if ($r.Code -ne 0 -and (Test-TransportFault $r.Out)) { + # A rebase may have been left half-started before the transport died. + Invoke-Git $ClaudeSkills @("rebase", "--abort") | Out-Null + if (Invoke-Doctor "pull --rebase") { + $r = Invoke-Git $ClaudeSkills @("pull", "--rebase", "origin", $Branch) + } + } if ($r.Code -ne 0) { Write-Host $r.Out $stranded = Invoke-Git $ClaudeSkills @("rev-list", "--count", "origin/$Branch..$Branch") if ($stranded.Code -eq 0 -and [int]$stranded.Out -gt 0) { Write-Host "NOTE: $($stranded.Out) local commit(s) cannot reach the server until this is fixed." } - Fail "pull --rebase failed on primary. Resolve conflicts (or 'git rebase --abort') then re-run. If the transport itself is broken (see skill Phase 1), fix the server -- do not leave commits stranded." + Fail "pull --rebase failed on primary. Conflicts are yours to resolve (or 'git rebase --abort'); a transport fault the doctor could not repair is in the skill's Phase 1.5 ladder. Either way: do not leave commits stranded." } Write-Host $r.Out $r = Invoke-Git $ClaudeSkills @("push", "origin", $Branch) + if ($r.Code -ne 0 -and (Test-TransportFault $r.Out)) { + if (Invoke-Doctor "push") { $r = Invoke-Git $ClaudeSkills @("push", "origin", $Branch) } + } if ($r.Code -ne 0) { Write-Host $r.Out; Fail "push failed (auth? see skill Phase 1)." } Assert-RemoteInSync $ClaudeSkills $Branch }