<# .SYNOPSIS Mirror the upstream SimpleDocReview checkout into ./app. .DESCRIPTION ./app is a vendored copy of upstream, trimmed to what the deployment needs: the server (app.py, kept for the Dockerfile escape hatch) and the front end (static/) that the Static Space actually serves. This script wipes and re-mirrors it, then records the upstream commit in UPSTREAM.json so the Space can always be traced back to a revision. Anything you edit under ./app is destroyed by the next run. Fix upstream instead, then re-sync. .PARAMETER UpstreamPath The local SimpleDocReview checkout. Defaults to C:\dev\SimpleDocReview. .PARAMETER AllowDirty Sync even when the upstream checkout has uncommitted changes. Without this, a dirty upstream is an error, because the recorded commit would not describe what actually got deployed. .EXAMPLE .\scripts\sync-upstream.ps1 #> [CmdletBinding()] param( [string] $UpstreamPath = 'C:\dev\SimpleDocReview', [switch] $AllowDirty ) $ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot $appDir = Join-Path $repoRoot 'app' # --- Validate upstream ----------------------------------------------------- if (-not (Test-Path -LiteralPath $UpstreamPath -PathType Container)) { throw "Upstream path not found: $UpstreamPath" } $upstreamAppPy = Join-Path $UpstreamPath 'app.py' if (-not (Test-Path -LiteralPath $upstreamAppPy -PathType Leaf)) { throw "No app.py at $UpstreamPath - is that really the SimpleDocReview checkout?" } $appPySource = Get-Content -LiteralPath $upstreamAppPy -Raw if ($appPySource -notmatch 'server_version\s*=\s*"SimpleDocReview/') { throw "app.py at $UpstreamPath does not identify itself as SimpleDocReview." } $serverVersion = ([regex]'server_version\s*=\s*"(SimpleDocReview/[^"]+)"').Match($appPySource).Groups[1].Value # --- Read upstream git provenance ----------------------------------------- $commit = 'unknown' $branch = 'unknown' $isDirty = $false $originUrl = 'unknown' if (Test-Path -LiteralPath (Join-Path $UpstreamPath '.git')) { Push-Location $UpstreamPath try { $commit = (& git rev-parse HEAD).Trim() $branch = (& git rev-parse --abbrev-ref HEAD).Trim() $status = & git status --porcelain $isDirty = -not [string]::IsNullOrWhiteSpace(($status -join '')) try { $originUrl = (& git remote get-url origin).Trim() } catch { $originUrl = 'none' } } finally { Pop-Location } } else { Write-Warning "$UpstreamPath is not a git repo - provenance will be recorded as 'unknown'." } if ($isDirty -and -not $AllowDirty) { throw @" Upstream checkout has uncommitted changes. Commit them in $UpstreamPath first so the deployed Space maps to a real revision, or re-run with -AllowDirty to deploy the working tree as-is. "@ } # --- Mirror upstream -> ./app --------------------------------------------- # Local review data is a user's documents, not source. It must never be copied # into a repo that gets pushed to a public Space. $excludeDirs = @( (Join-Path $UpstreamPath '.git'), (Join-Path $UpstreamPath '.github'), (Join-Path $UpstreamPath '.claude'), (Join-Path $UpstreamPath '.vscode'), (Join-Path $UpstreamPath '.idea'), (Join-Path $UpstreamPath 'tests'), (Join-Path $UpstreamPath 'docs'), (Join-Path $UpstreamPath 'scripts'), (Join-Path $UpstreamPath 'data'), (Join-Path $UpstreamPath 'uploads'), '__pycache__', '.pytest_cache', '.ruff_cache', '.mypy_cache' ) # Repo-hygiene files the deployment does not read. README.md in particular must # not land in app/, because the Space card at the repository root is the README # Hugging Face renders. $excludeFiles = @( 'README.md', 'CLAUDE.md', 'CHANGELOG.md', '.gitignore', '.gitattributes', '*.pyc', '*.log', '.env', '.env.local' ) Write-Host "Syncing $UpstreamPath -> $appDir" -ForegroundColor Cyan New-Item -ItemType Directory -Force -Path $appDir | Out-Null $robocopyArgs = @( $UpstreamPath, $appDir, '/MIR', # mirror: also deletes files removed upstream '/NFL', '/NDL', # quiet: no per-file or per-directory listing '/NJH', '/NP', '/R:2', '/W:1' ) + @('/XD') + $excludeDirs + @('/XF') + $excludeFiles & robocopy @robocopyArgs | Out-Null # Robocopy exit codes 0-7 are success (8+ means copy failures). if ($LASTEXITCODE -ge 8) { throw "robocopy failed with exit code $LASTEXITCODE" } # --- Sanity-check what landed -------------------------------------------- $required = @('app.py', 'static/index.html', 'static/app.js', 'static/styles.css') $missing = $required | Where-Object { -not (Test-Path -LiteralPath (Join-Path $appDir $_)) } if ($missing.Count -gt 0) { throw "Sync incomplete - missing from app/: $($missing -join ', ')" } # --- Warn if the Dockerfile's entry point no longer fits app.py ---------- # The container calls app.run('0.0.0.0', 7860) positionally, so a change to that # signature breaks the escape hatch without breaking the static build - exactly # the kind of drift nobody notices until they need the container. if ($appPySource -notmatch 'def\s+run\s*\(\s*host[^)]*port') { Write-Warning "app.py's run() signature changed. Check the CMD line in Dockerfile still matches." } # --- Record provenance --------------------------------------------------- function Get-Sha256([string] $path) { return (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() } $upstream = [ordered]@{ upstreamRepo = $originUrl upstreamPath = $UpstreamPath commit = $commit branch = $branch dirtyWorkingTree = $isDirty serverVersion = $serverVersion portedSources = [ordered]@{ 'app.py' = Get-Sha256 (Join-Path $appDir 'app.py') 'static/app.js' = Get-Sha256 (Join-Path $appDir 'static/app.js') } syncedAtUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } # Written without a BOM: Windows PowerShell 5.1's -Encoding utf8 emits one, and # a BOM breaks strict JSON parsers that may read this file later. $upstreamJson = ($upstream | ConvertTo-Json -Depth 4) + [Environment]::NewLine [System.IO.File]::WriteAllText( (Join-Path $repoRoot 'UPSTREAM.json'), $upstreamJson, (New-Object System.Text.UTF8Encoding $false) ) # --- Summary ------------------------------------------------------------- $shortCommit = if ($commit.Length -ge 7) { $commit.Substring(0, 7) } else { $commit } Write-Host '' Write-Host "Synced $serverVersion @ $shortCommit ($branch)" -ForegroundColor Green if ($isDirty) { Write-Warning 'Synced from a dirty upstream working tree.' } Write-Host '' Push-Location $repoRoot try { $changes = & git status --porcelain if ([string]::IsNullOrWhiteSpace(($changes -join ''))) { Write-Host 'No changes - the Space already matches upstream.' -ForegroundColor Yellow } else { Write-Host 'Pending changes:' & git status --short Write-Host '' Write-Host 'Deploy with: .\scripts\deploy.ps1' -ForegroundColor Cyan } } finally { Pop-Location }