Files
skills-win/tools-sync/scripts/sync_repos.ps1
T
Myfro9andClaude Opus 5 e39e852853 tools-sync: 增加 ls-remote 同步闸门,并记下 gitea 空 pack 故障的排查法
这次同步事故的教训不是服务器坏了,而是坏了没人知道:pull --rebase 一失败脚本
就退出,push 从没走到,3 个 commit(含整个 bmem skill)在本机躺了 8 天,报告里
却算作"已知故障、待恢复"。而 ls-remote 这 8 天一直是好的。

- sync_repos.ps1 增 Assert-RemoteInSync:push 后(及 -DryRun 时)用 ls-remote 比对
  origin/<branch> 的实际 sha,不一致就 FAIL 并报出滞留/落后的 commit 数。
  远端分支不存在时单独报错,避免那两个计数被静默地按 HEAD 算出误导性数字。
- SKILL.md 阶段 1 写明闸门是硬性的:服务端故障是原因不是结果,不得报成已同步。
- SKILL.md 新增 bad pack header / early EOF 的排查阶梯(三步各排掉一层),并记下
  根因:全局 gitconfig 被注入 [uploadpack] packObjectsHook,把 pack-objects 换成
  了空转脚本。附带提醒 HOME 是 /data/gitea/home 而非 /data/git,以及这是一条
  完整 RCE 链、要连 hook 与 /tmp 载荷一起清。
- bmem/SKILL.md 的"已知边界"改为已修复,避免继续把普通的没 pull 误判成服务端故障。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tMFjKGgxdmxJ28dyXdx1u
2026-09-02 14:57:50 -05:00

169 lines
8.0 KiB
PowerShell

# tools-sync: mechanical sync of skills between Claude Code and Codex.
# Phase 1 — git sync of the primary checkout (~\.claude\skills): commit -> pull --rebase -> push.
# Phase 2 — junction reconciliation: every top-level skill dir in the primary gets a
# directory junction of the same name in ~\.codex\skills, so both tools share
# one physical copy and can never drift. Codex auto-registers any dir there
# containing SKILL.md (new sessions only).
# Judgment calls (rebase conflicts, codex-local dirs worth keeping) are left to the caller.
param(
[Parameter(Mandatory = $true)][string]$RepoUrl,
[string]$ClaudeSkills = "$env:USERPROFILE\.claude\skills",
[string]$CodexSkills = "$env:USERPROFILE\.codex\skills",
[string]$Branch = "main",
# A real (non-junction) dir in the codex side that shadows a primary skill blocks
# 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
)
$ErrorActionPreference = "Stop"
function Invoke-Git {
param([string]$Dir, [string[]]$GitArgs)
# PS 5.1: git writes progress/warnings to stderr; 2>&1 wraps them in ErrorRecords
# which terminate under EAP=Stop. Relax EAP locally and stringify every line.
$prev = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$out = & git -C $Dir @GitArgs 2>&1 | ForEach-Object { "$_" }
$code = $LASTEXITCODE
$ErrorActionPreference = $prev
return @{ Code = $code; Out = (($out -join "`n")).Trim() }
}
function Fail([string]$Msg) { Write-Host "FAIL: $Msg"; exit 1 }
# ---------- Phase 1: primary checkout (~\.claude\skills) ----------
Write-Host "=== [1/2] primary git sync: $ClaudeSkills ==="
if (-not (Test-Path (Join-Path $ClaudeSkills ".git"))) {
Fail "$ClaudeSkills is not a git repo. Clone $RepoUrl there first (new machine: git clone $RepoUrl `"$ClaudeSkills`")."
}
$st = Invoke-Git $ClaudeSkills @("status", "--porcelain")
if ($st.Out) {
Write-Host "local changes:"; Write-Host $st.Out
if ($DryRun) {
Write-Host "(dry-run) would commit these changes"
} else {
Invoke-Git $ClaudeSkills @("add", "-A") | Out-Null
$msg = "sync: $env:COMPUTERNAME $(Get-Date -Format yyyy-MM-dd)"
$r = Invoke-Git $ClaudeSkills @("commit", "-m", $msg)
if ($r.Code -ne 0) { Fail "commit failed:`n$($r.Out)" }
Write-Host "committed: $msg"
}
} else {
Write-Host "working tree clean"
}
# The sync is only "done" when the remote branch actually points at our commit.
# ls-remote asks for refs only, so it keeps working even when the server's pack
# service is broken -- which is exactly the outage this gate exists to survive.
function Assert-RemoteInSync {
param([string]$Dir, [string]$Branch)
$local = Invoke-Git $Dir @("rev-parse", $Branch)
if ($local.Code -ne 0) { Fail "cannot resolve local $Branch." }
$ls = Invoke-Git $Dir @("ls-remote", "origin", "refs/heads/$Branch")
if ($ls.Code -ne 0) {
Write-Host $ls.Out
Fail "ls-remote failed: cannot verify the push landed. Treat this run as NOT synced."
}
$remote = ($ls.Out -split "\s+")[0]
if ($remote -ne $local.Out) {
Write-Host " local $Branch = $($local.Out)"
if (-not $remote) {
# Empty ref: the counts below would silently be measured against HEAD.
Fail "origin has no refs/heads/$Branch. Nothing on this machine is backed up by the server."
}
Write-Host " remote $Branch = $remote"
$ahead = Invoke-Git $Dir @("rev-list", "--count", "$remote..$($local.Out)")
if ($ahead.Code -eq 0 -and [int]$ahead.Out -gt 0) {
Write-Host " $($ahead.Out) commit(s) STRANDED on this machine - they exist nowhere else."
}
$behind = Invoke-Git $Dir @("rev-list", "--count", "$($local.Out)..$remote")
if ($behind.Code -eq 0 -and [int]$behind.Out -gt 0) {
Write-Host " $($behind.Out) commit(s) from other machines not merged here."
}
Fail "primary is NOT in sync with origin/$Branch. A server-side outage is a reason, never an outcome: do not report this run as synced."
}
Write-Host "verified: origin/$Branch = $remote (this machine's work is on the server)"
}
if ($DryRun) {
# Read-only, but still worth running: silent drift is what this gate is for.
Assert-RemoteInSync $ClaudeSkills $Branch
} else {
$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."
}
Write-Host $r.Out
$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
}
# ---------- Phase 2: junction reconciliation (~\.codex\skills) ----------
Write-Host "=== [2/2] codex junctions: $CodexSkills ==="
if (-not (Test-Path $CodexSkills)) { New-Item -ItemType Directory -Force $CodexSkills | Out-Null }
# Left over from the older mirror-checkout approach; junctions need no repo here.
if (Test-Path (Join-Path $CodexSkills ".git")) {
Write-Host "removing stale .git from codex dir (junction model needs no second checkout)"
if (-not $DryRun) { Remove-Item -Recurse -Force (Join-Path $CodexSkills ".git") }
}
$primarySkills = Get-ChildItem $ClaudeSkills -Directory | Where-Object { $_.Name -notlike ".*" }
$blocked = @()
foreach ($d in $primarySkills) {
$link = Join-Path $CodexSkills $d.Name
if (Test-Path $link) {
$item = Get-Item $link
if ($item.LinkType -eq "Junction") { continue }
# A real dir shadows the primary skill; its content may be unique.
if ($Force) {
if ($DryRun) { Write-Host "(dry-run) would replace real dir with junction: $($d.Name)" ; continue }
Remove-Item -Recurse -Force $link
New-Item -ItemType Junction -Path $link -Target $d.FullName | Out-Null
Write-Host "replaced real dir with junction: $($d.Name)"
} else {
$blocked += $d.Name
}
} else {
if ($DryRun) { Write-Host "(dry-run) would link: $($d.Name)"; continue }
New-Item -ItemType Junction -Path $link -Target $d.FullName | Out-Null
Write-Host "linked: $($d.Name)"
}
}
# Junctions whose primary target is gone (skill deleted/renamed upstream) confuse Codex.
foreach ($item in Get-ChildItem $CodexSkills -Directory | Where-Object { $_.LinkType -eq "Junction" }) {
$target = ($item.Target | Select-Object -First 1)
if ($target -and $target.StartsWith($ClaudeSkills) -and -not (Test-Path $target)) {
if ($DryRun) { Write-Host "(dry-run) would remove stale junction: $($item.Name)"; continue }
# A junction is just a link — removing it never touches target content.
$item.Delete()
Write-Host "removed stale junction: $($item.Name)"
}
}
if ($blocked) {
Write-Host "these codex dirs are REAL directories shadowing primary skills (not linked):"
$blocked | ForEach-Object { Write-Host " $_" }
Fail "rescue any unique content into the primary checkout, then re-run with -Force."
}
# Registration sanity: every linked skill must expose a SKILL.md for Codex to register it.
$missing = $primarySkills | Where-Object { -not (Test-Path (Join-Path $CodexSkills "$($_.Name)\SKILL.md")) }
if ($missing) {
Write-Host "WARNING: no SKILL.md visible through these links (Codex will ignore them):"
$missing | ForEach-Object { Write-Host " $($_.Name)" }
}
Write-Host "=== sync_repos done (Codex picks up changes on its next new session) ==="