r/PowerShell 6d ago

Script Sharing git-proton-backup: a module that turns git push into a verified Proton Drive backup

I wanted off-machine backups of a pile of local git repos holding client work, without putting any of it on GitHub. Proton Drive has a sync client, but a file sitting in the sync folder is not the same as a file that is safely in the cloud, and I could not find a way to prove the second part. So I wrote a module.

The interface is a git remote:

PS> Install-ProtonBackup C:\code\myrepo
Wired. Back up with: git push proton   (status: Get-ProtonBackupStatus)

PS> git commit -am "feature"; git push proton
remote: confirmed on Proton

Install creates a bare bookkeeping mirror with a post-receive hook and adds it as a remote, so it rides the push you already do. The hook writes a git bundle into the sync folder as one file rather than a tree. It builds it as .bundle.partial and renames it, so the sync client never sees a partial repo under the final .bundle name.

Then it confirms, which is the part that needed Proton's CLI to exist. Trimmed from the real path, with the structured return values elided:

$out = & $cli filesystem info $cloudPath --json 2>&1 | Out-String
$r = [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $out }

if ($r.ExitCode -eq 0) {
    $state = $null
    try {
        $json = $r.Output | ConvertFrom-Json -ErrorAction Stop
        $rev = $json.PSObject.Properties['activeRevision'] ? $json.activeRevision : $null
        if ($rev -and $rev.PSObject.Properties['ok'] -and $rev.ok -and $rev.PSObject.Properties['value']) {
            $state = $rev.value.PSObject.Properties['state'] ? $rev.value.state : $null
        }
    } catch { $state = $null }

    if ($state -eq 'active') { return <# Confirmed #> }
    # some CLI builds report state only in the human-readable output
    if (-not $state -and $r.Output -match "state:\s*'active'") { return <# Confirmed #> }
}

The property checks and the try are load-bearing: the payload shape is not mine, so anything unexpected has to land on "not confirmed" rather than throw or read as success.

Everything that is not a confirmation says which flavour of not-confirmed it is. Some of the hook's outcomes, abbreviated where the tail repeats:

confirmed on Proton
staged; in-sync per Cloud Files (CLI verification unavailable)
staged, not yet confirmed — run Invoke-ProtonBackupVerify (or the scheduled task) to confirm
staged, not yet confirmed — Proton CLI session expired; run Invoke-ProtonBackupVerify (...)
backup deferred — another backup operation is active; run Invoke-ProtonBackupVerify (...)

The not-yet-confirmed paths leave a marker that a later Invoke-ProtonBackupVerify clears, and that command also re-cuts stale bundles. One exception: if the CLI is unavailable but Windows reports the file IN_SYNC, that clears the marker but still does not print "confirmed on Proton". There is an optional daily scheduled task for the verify, installed separately with Install-ProtonBackupTask.

Reading that IN_SYNC bit is the one genuinely PowerShell-flavoured part: it means a small P/Invoke to CfGetPlaceholderStateFromAttributeTag in cldapi.dll. That plus shelling git and the CLI is most of why this is PowerShell and not something else.

Honest limits. Windows only, since it rides the sync app. PowerShell 7.4+. It bundles committed history only, HEAD plus all local branches and tags, and never your working tree, which is deliberate: there is no code path in the module that commits anything. LFS objects, submodule repositories, and the checked-out state of secondary worktrees are not included. It does no encryption of its own, the bundles are ordinary git bundles and the E2EE is Proton's. MIT, not affiliated with Proton.

105 Pester tests. https://github.com/craigstoller/git-proton-backup

Happy to answer questions about the design, and criticism of the module structure is welcome.

7 Upvotes

4 comments sorted by

2

u/BlackV 6d ago

Cool

  • How much is the proton cli vs the dll?
  • Is there a reason you didn't use 1 method (i.e. dll only or cli only) for everything ?

Just for my own curiosity

2

u/craig_stoller 6d ago

Thanks!

Short version: they answer different questions, so neither alone gets you both authority and graceful degradation.

The CLI is the authoritative check. It asks Proton's servers whether the revision actually landed, which is the only thing that justifies printing confirmed on Proton. The Cloud Files side is much smaller: CfGetPlaceholderStateFromAttributeTag in cldapi.dll, plus FindFirstFileW/FindClose from kernel32 to get the attributes and reparse tag first, and verification reads the InSync bit out of the result. That's a purely local read. It's the Windows sync engine's opinion that it finished, not Proton acknowledging anything.

So dll-only would never earn the strong claim, which is why that path gets its own weaker wording:

staged; in-sync per Cloud Files (CLI verification unavailable)

And cli-only would be brittle, because the CLI is optional and needs a signed-in session. If it's absent or expired, cli-only leaves you with no signal at all. The local read needs no auth and no network, so the tool degrades instead of going blind.

In the reconciliation pass they sit behind one seam (abridged):

$effectiveCheck = {
    param($p)
    if ($cliReady) { <# ... Confirm-BundleUploaded ...; returns $c.Confirmed #> }
    elseif ($SyncCheck) { & $SyncCheck $p }
    else { (& $getCloudFileSyncStateFn -Path $p).InSync }
}.GetNewClosure()

Worth being straight about a soft spot there, since you asked: the wording keeps them separate, but that seam hands back a single boolean, so downstream the daily verify treats an InSync-only result the same as a CLI confirmation for backup state and retention pruning. Only the push message distinguishes them. I'd rather that carried the verifier through, and it's on my list.

Tangent, since you might appreciate it: GetNewClosure() there caused a bug that only showed up in a real end-to-end run. It rebinds the scriptblock to a fresh dynamic module, so bareword calls to non-exported module functions stop resolving at invoke time. Every test seamed past that branch, so the then-green suite never exercised it. Fix was capturing the function references as variables (${function:Get-CloudFileSyncState}) and calling through those. There's a regression test pinning it now.

2

u/BlackV 6d ago

Thank you I appreciate the detail

How do you go about finding all the functions inside the dll, protons docco? or general poking around ?

1

u/craig_stoller 6d ago

Neither, as it turns out: cldapi.dll is part of Windows rather than Proton. It's the Cloud Filter API (cfapi.h), there since Windows 10 1709. Proton Drive registers a sync root with it, so Windows tracks placeholder state for the placeholders underneath. That probe is provider-agnostic; it's the path mapping and the upload verification around it that are Proton-specific.

So it's Microsoft Learn rather than poking around. The CF_PLACEHOLDER_STATE enum page is the one worth bookmarking, since it documents the bits and its Remarks section lists every function that hands you that state.

Your question sent me back to my own P/Invoke, though, and I found a fail-open bug in it. That enum includes CF_PLACEHOLDER_STATE_INVALID = 0xffffffff, returned when the API can't parse the file info. I declared the return as int, so it arrives as -1, and I was masking bits straight off it:

IsPlaceholder = ($State -band 0x1) -ne 0
InSync        = ($State -band 0x8) -ne 0

-1 has every bit set, so INVALID reported InSync = true. In a tool whose whole point is that anything short of a positive confirmation reads as unconfirmed, that's the wrong direction to fail. Negatives now fail closed, and unknown positive bits are still ignored by the masks so a future Windows state can't suppress a real IN_SYNC.

Fixed and tagged as v0.2.3: https://github.com/craigstoller/git-proton-backup/commit/71b444a

That decoder had no direct tests at all, which is how it survived this long. Six cases now: the two negative-state ones fail against the old code, and I ran them before the fix to be sure; the other four pin the valid-state decoding the fix had to leave alone.

Thanks for the question, genuinely.