From 6f6b57e6d8a6abf8855c610ad195a4bfd8662c33 Mon Sep 17 00:00:00 2001 From: Patrick Connallon Date: Sat, 18 Jul 2026 00:57:45 -0400 Subject: [PATCH] Harden backup against GitLab soft-deletion and mirror branch protection The scheduled backup fired on time but silently pushed nothing for affected repos, because git push --mirror failed for two independent reasons the log recorded only as "exit 1": 1. Soft-deletion: a project deleted on gitlab.com (Free) lingers in a ~7-day retention, still occupying its path but read-only, so pushes return 403. Get-GitLabProject sees it as existing (HTTP 200, not 404), so the script neither recreates nor restores it and 403-loops every run. 2. Branch protection: GitLab auto-protects the default branch, and push --mirror needs force-updates on active repos (rebased branches, deleted PR refs), which the protected-branch hook rejects. Fix: - Get-GitLabProjectDeletionSchedule reads marked_for_deletion_at (with the deprecated marked_for_deletion_on as fallback) via PSObject so an absent field yields $null under Set-StrictMode; backup.ps1 restores pending-deletion projects and logs a WARN. - Clear-GitLabProtectedBranches strips mirror branch protection before each push (per_page=100; empty-list result filtered for StrictMode). Verified: full backup.ps1 run is 7 succeeded, 0 failed, exit 0; PSScriptAnalyzer clean. Closes #2 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AMMuyUCUP5qoeD9wvesThC --- backup.ps1 | 23 ++++++++++++++++++- lib/GitLab.psm1 | 59 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/backup.ps1 b/backup.ps1 index 8eeb7f4..7655723 100644 --- a/backup.ps1 +++ b/backup.ps1 @@ -71,10 +71,24 @@ try { try { $projectPath = "$glNamespace/$($repo.name)" $existing = Get-GitLabProject -GitLabHost $config.GitLabHost -Token $glToken -ProjectPath $projectPath + + # A project the user deleted on GitLab is only soft-deleted: it lingers + # for a retention window, still occupying its path but read-only, so the + # mirror push fails with 403. It also answers "exists" here, so without + # this we'd never recreate it either. Restore it back to writable. + if ($existing) { + $pendingDeletion = Get-GitLabProjectDeletionSchedule -Project $existing + if ($pendingDeletion) { + Write-Log -Level WARN (" GitLab project '{0}' is pending deletion (scheduled {1}); restoring it" -f $projectPath, $pendingDeletion) + $null = Restore-GitLabProject -GitLabHost $config.GitLabHost -Token $glToken -ProjectId $existing.id + $existing = Get-GitLabProject -GitLabHost $config.GitLabHost -Token $glToken -ProjectPath $projectPath + } + } + if (-not $existing) { Write-Log " creating GitLab project: $projectPath" # NamespaceId omitted -> GitLab creates under the authenticated user - $null = New-GitLabProject ` + $existing = New-GitLabProject ` -GitLabHost $config.GitLabHost ` -Token $glToken ` -Name $repo.name ` @@ -83,6 +97,13 @@ try { -Description ("Mirror of github.com/{0}" -f $repo.full_name) } + # GitLab auto-protects the default branch; on a mirror that blocks the + # force-updates push --mirror needs. Strip protection before pushing. + $clearedRules = Clear-GitLabProtectedBranches -GitLabHost $config.GitLabHost -Token $glToken -ProjectId $existing.id + if ($clearedRules -gt 0) { + Write-Log (" cleared {0} protected-branch rule(s) on mirror" -f $clearedRules) + } + $cachePath = Join-Path $config.CachePath ("{0}.git" -f $repo.name) $result = Sync-Mirror ` -GitHubFullName $repo.full_name ` diff --git a/lib/GitLab.psm1 b/lib/GitLab.psm1 index 5dd96ba..5a7d747 100644 --- a/lib/GitLab.psm1 +++ b/lib/GitLab.psm1 @@ -77,4 +77,61 @@ function New-GitLabProject { return Invoke-GitLabApi -GitLabHost $GitLabHost -Token $Token -Path '/api/v4/projects' -Method POST -Body $body } -Export-ModuleMember -Function Get-GitLabCurrentUser, Get-GitLabProject, New-GitLabProject +function Get-GitLabProjectDeletionSchedule { + # Returns the pending-deletion timestamp for a project object, or $null if the + # project is not scheduled for deletion. GitLab renamed this field from + # 'marked_for_deletion_on' to 'marked_for_deletion_at'; check both, and probe + # via PSObject so an absent field yields $null instead of throwing under + # Set-StrictMode. + [CmdletBinding()] + param([Parameter(Mandatory)]$Project) + foreach ($name in 'marked_for_deletion_at', 'marked_for_deletion_on') { + $prop = $Project.PSObject.Properties[$name] + if ($prop -and $prop.Value) { return $prop.Value } + } + return $null +} + +function Restore-GitLabProject { + # Undo a pending (delayed) deletion. gitlab.com keeps a deleted project for + # a retention window during which it exists but is read-only, so pushes fail + # with HTTP 403 "You are not allowed to push code to this project". Restoring + # makes it writable again. The restore route requires the numeric project id + # (the URL-encoded path form returns 405). + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$GitLabHost, + [Parameter(Mandatory)][string]$Token, + [Parameter(Mandatory)][int]$ProjectId + ) + return Invoke-GitLabApi -GitLabHost $GitLabHost -Token $Token ` + -Path "/api/v4/projects/$ProjectId/restore" -Method POST +} + +function Clear-GitLabProtectedBranches { + # Remove every branch-protection rule on a mirror project. GitLab auto-protects + # the default branch on a project's first push; thereafter `git push --mirror` + # fails whenever it needs to force-update that branch (rebased history, deleted + # refs) with "You are not allowed to force push code to a protected branch". + # Mirrors are throwaway copies of the GitHub source, so protection serves no + # purpose here. Returns the number of rules removed. + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$GitLabHost, + [Parameter(Mandatory)][string]$Token, + [Parameter(Mandatory)][int]$ProjectId + ) + $response = Invoke-GitLabApi -GitLabHost $GitLabHost -Token $Token ` + -Path "/api/v4/projects/$ProjectId/protected_branches?per_page=100" + # An empty list comes back as $null; filter it so the loop doesn't try to + # dereference a null element under Set-StrictMode. + $protected = @($response | Where-Object { $null -ne $_ }) + foreach ($b in $protected) { + $name = [uri]::EscapeDataString($b.name) + $null = Invoke-GitLabApi -GitLabHost $GitLabHost -Token $Token ` + -Path "/api/v4/projects/$ProjectId/protected_branches/$name" -Method DELETE + } + return $protected.Count +} + +Export-ModuleMember -Function Get-GitLabCurrentUser, Get-GitLabProject, New-GitLabProject, Get-GitLabProjectDeletionSchedule, Restore-GitLabProject, Clear-GitLabProtectedBranches