214 lines
5.2 KiB
PowerShell
214 lines
5.2 KiB
PowerShell
param(
|
|
[int]$IntervalSeconds = 30,
|
|
[int]$HeartbeatEverySeconds = 60,
|
|
[switch]$Once
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
|
$LocalStateDir = Join-Path $RepoRoot ".sync-state"
|
|
$PublicStateDir = Join-Path $RepoRoot "sync-state"
|
|
$HeartbeatFile = Join-Path $PublicStateDir "heartbeat-pc.json"
|
|
$ErrorFile = Join-Path $PublicStateDir "error-pc.md"
|
|
$AllowedPaths = @(
|
|
"AGENT_BOARD.md",
|
|
"README.md",
|
|
"USER_STATUS.md",
|
|
".cursor/rules",
|
|
"handoff",
|
|
"evidence",
|
|
"rollback",
|
|
"tasks",
|
|
"sync",
|
|
"sync-state",
|
|
".gitattributes",
|
|
".gitignore"
|
|
)
|
|
|
|
function Ensure-StateDir {
|
|
foreach ($path in @($LocalStateDir, $PublicStateDir)) {
|
|
if (-not (Test-Path $path)) {
|
|
New-Item -ItemType Directory -Path $path | Out-Null
|
|
}
|
|
}
|
|
}
|
|
|
|
function Write-SyncError {
|
|
param([string]$Message)
|
|
|
|
Ensure-StateDir
|
|
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss zzz"
|
|
@"
|
|
# PC Sync Error
|
|
|
|
- Time: $timestamp
|
|
- Repository: $RepoRoot
|
|
- Message: $Message
|
|
|
|
Manual action required. Do not continue automatic sync until the repository is clean.
|
|
"@ | Set-Content -Path $ErrorFile -Encoding UTF8
|
|
}
|
|
|
|
function Get-GitOutput {
|
|
param([string[]]$GitArgs)
|
|
$output = & git @GitArgs
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "git $($GitArgs -join ' ') failed with exit code $LASTEXITCODE"
|
|
}
|
|
return $output
|
|
}
|
|
|
|
function Invoke-Git {
|
|
param([string[]]$GitArgs)
|
|
& git @GitArgs
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "git $($GitArgs -join ' ') failed with exit code $LASTEXITCODE"
|
|
}
|
|
}
|
|
|
|
function Remove-StaleSyncError {
|
|
if (Test-Path $ErrorFile) {
|
|
Remove-Item $ErrorFile -Force
|
|
}
|
|
}
|
|
|
|
function Push-WithRetry {
|
|
& git push
|
|
if ($LASTEXITCODE -eq 0) {
|
|
return
|
|
}
|
|
|
|
# Another agent may have pushed between our pull and push. Rebase once and retry.
|
|
Invoke-Git @("pull", "--rebase", "--autostash")
|
|
if (Test-ConflictMarkers) {
|
|
throw "merge or rebase conflicts detected after push retry"
|
|
}
|
|
Invoke-Git @("push")
|
|
}
|
|
|
|
function Get-AllowedChanges {
|
|
$changes = & git -C $RepoRoot status --porcelain
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "git status failed with exit code $LASTEXITCODE"
|
|
}
|
|
|
|
return $changes | Where-Object {
|
|
$line = $_
|
|
if ([string]::IsNullOrWhiteSpace($line)) {
|
|
return $false
|
|
}
|
|
$path = $line.Substring(3).Trim()
|
|
foreach ($allowed in $AllowedPaths) {
|
|
if ($path -eq $allowed -or $path.StartsWith("$allowed/") -or $path.StartsWith("$allowed\")) {
|
|
return $true
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Test-ConflictMarkers {
|
|
$conflicts = & git -C $RepoRoot diff --name-only --diff-filter=U
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "git diff conflict check failed with exit code $LASTEXITCODE"
|
|
}
|
|
return -not [string]::IsNullOrWhiteSpace(($conflicts -join ""))
|
|
}
|
|
|
|
function Get-RepoCleanStatus {
|
|
$changes = & git -C $RepoRoot status --porcelain
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "git status failed with exit code $LASTEXITCODE"
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace(($changes -join ""))) {
|
|
return "clean"
|
|
}
|
|
return "dirty"
|
|
}
|
|
|
|
function Test-HeartbeatDue {
|
|
if ($Once -or -not (Test-Path $HeartbeatFile)) {
|
|
return $true
|
|
}
|
|
|
|
$age = (Get-Date) - (Get-Item $HeartbeatFile).LastWriteTime
|
|
return $age.TotalSeconds -ge $HeartbeatEverySeconds
|
|
}
|
|
|
|
function Write-Heartbeat {
|
|
Ensure-StateDir
|
|
if (-not (Test-HeartbeatDue)) {
|
|
return
|
|
}
|
|
|
|
$head = (Get-GitOutput @("-C", $RepoRoot, "rev-parse", "--short", "HEAD")) -join ""
|
|
$branch = (Get-GitOutput @("-C", $RepoRoot, "branch", "--show-current")) -join ""
|
|
$status = Get-RepoCleanStatus
|
|
$timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz"
|
|
|
|
[ordered]@{
|
|
host = "pc"
|
|
time = $timestamp
|
|
branch = $branch
|
|
head = $head
|
|
status = $status
|
|
intervalSeconds = $IntervalSeconds
|
|
heartbeatEverySeconds = $HeartbeatEverySeconds
|
|
} | ConvertTo-Json | Set-Content -Path $HeartbeatFile -Encoding UTF8
|
|
}
|
|
|
|
function Sync-Once {
|
|
Set-Location $RepoRoot
|
|
Ensure-StateDir
|
|
|
|
Invoke-Git @("pull", "--rebase", "--autostash")
|
|
|
|
if (Test-ConflictMarkers) {
|
|
throw "merge or rebase conflicts detected"
|
|
}
|
|
|
|
Remove-StaleSyncError
|
|
Write-Heartbeat
|
|
|
|
$allowedChanges = @(Get-AllowedChanges)
|
|
if ($allowedChanges.Count -eq 0) {
|
|
return
|
|
}
|
|
|
|
foreach ($path in $AllowedPaths) {
|
|
if (Test-Path (Join-Path $RepoRoot $path)) {
|
|
Invoke-Git @("add", "--", $path)
|
|
}
|
|
}
|
|
|
|
$staged = & git -C $RepoRoot diff --cached --name-only
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "git diff --cached failed with exit code $LASTEXITCODE"
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace(($staged -join ""))) {
|
|
return
|
|
}
|
|
|
|
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
|
Invoke-Git @("commit", "-m", "Sync agent state from PC at $timestamp")
|
|
Push-WithRetry
|
|
}
|
|
|
|
do {
|
|
try {
|
|
Sync-Once
|
|
}
|
|
catch {
|
|
Write-SyncError $_.Exception.Message
|
|
Write-Error $_
|
|
break
|
|
}
|
|
|
|
if ($Once) {
|
|
break
|
|
}
|
|
|
|
Start-Sleep -Seconds $IntervalSeconds
|
|
} while ($true)
|