documentation/S3.md · main · 2026-10-20

S3 Remote Backend

An S3-compatible bucket is a git remote through the s3:// remote helper in the gitsocial binary (GITMSG.md §1.3), and it can serve the static site of the repository.

Clone · Push · Thin fork buckets · Local development · Testing · Reference

Clone

git clone https://<bucket-domain>/                                  # stock git, public bucket
gitsocial clone s3://<endpoint>/<bucket>/<prefix>                   # through the helper; private buckets work
git config --global alias.remote-s3 '!gitsocial __git-remote-s3'   # once, for bare `git clone s3://...`

Push

gitsocial config credentials set <endpoint-host>                        # paste the access key and the secret key
gitsocial remote add s3://<endpoint>/<bucket>/<prefix> --default --site  # default push target, site on
gitsocial push

gitsocial push [remote...] sends the gitmsg/* branches, the refs/gitmsg/* state refs, all tags, the default branch when it is ahead, and the heads of open pull requests. A push to an s3 remote with site.publish then rebuilds the site.

Output lineMeaning
Pushed to <remote> (<url>)the refs are on the remote
Site: publishedthe site rebuild is complete
Site: published (incomplete: ...)the first build of the site (the bootstrap) continues over more pushes; push again to finish
Site: published (fork pull requests: <n>, <size> MB)the fork pull request area is on, with its count and size
Site: skipped (<reason>)no site rebuild for this push
Site: failed: <err>the rebuild failed after the refs were sent; a warning, not a push failure
FlagEffect
--no-codeskip code branches
--all-branchessend every local branch; a non-fast-forward branch is reported and not merged; reconcile with git push --force-with-lease
--all-remotespush to every configured remote in alphabetical order; takes no remote arguments
--no-siteskip the site rebuild for this push
--site-onlyrebuild the site, send no refs
--fullsend every object and detach a thin fork bucket
--dry-runprint the plan, send nothing, rebuild nothing, stay offline

url, publish and pages can be different for each remote (see per-remote overrides).

Thin fork buckets

A fork bucket usually holds all the history of its upstream. A thin bucket holds only the objects of the fork and records the URL of the upstream that has the other objects. gitsocial fork create with a bucket destination sets up a thin bucket; to set one up manually, set two keys on the remote:

git config remote.<name>.gitsocial-thin true
git config remote.<name>.gitsocial-upstream https://github.com/org/repo
SurfaceFull bucketThin bucket
gitsocial clone s3://, git clone s3:// through the aliasworksworks, and fetches the missing objects from the upstream
git clone https://<bucket-domain>/worksfails at ref discovery; info/refs is not written
Static siterebuilt on site.publishnot rebuilt; an existing site stays, and is not updated
gitsocial fetch of fork metadataworksworks without upstream

Local development

go build -o bin/locals3 ./library/core/objstore/locals3
bin/locals3 -root <dir>          # 127.0.0.1:9000; each subdirectory of -root is a bucket

A loopback host (s3://127.0.0.1:9000/<bucket>/<prefix>) needs only dummy keys and uses http and path-style by default; every other IP-literal host also uses path-style by default. For another development server, set GITSOCIAL_S3_ENDPOINT=http://... and GITSOCIAL_S3_PATH_STYLE=1. The same port serves a pushed site at http://127.0.0.1:9000/<bucket>/<prefix>/.

locals3 follows S3 on byte ranges, listing pages of 1,000 keys, If-Match and keys that prefix other keys.

gitsocial serve runs the same bucket handler in the process for the workspace and serves its site; see CLI.md.

Testing against a provider

Verified on AWS S3, Cloudflare R2 and DigitalOcean Spaces. Export credentials in the environment only, one provider at a time.

GITSOCIAL_TEST_FULL=1 GITSOCIAL_S3_TEST_HOST=<host> GITSOCIAL_S3_TEST_BUCKET=<bucket> \
  go test ./cli/gitsocial -run TestS3Helper_realBucket -v

Hosts: s3.<region>.amazonaws.com, <account-id>.r2.cloudflarestorage.com, <region>.digitaloceanspaces.com. A pass prints provider round trip OK, and the test deletes the keys that it writes.

The manual flow from a new shell, with credentials exported and nothing else configured:

git init uxdemo && cd uxdemo && git commit --allow-empty -m init
gitsocial remote add "$REMOTE" && gitsocial config site set publish true
gitsocial social post "hello from a bucket" && gitsocial push       # Pushed, Commits 1, Site: published
git push origin main                                                 # plain git through the recorded alias
cd .. && gitsocial clone "$REMOTE" uxclone && cd uxclone
git fsck --strict && gitsocial fetch && gitsocial social timeline    # the post is there

GITSOCIAL_S3_DEBUG=1 dumps every request and response to stderr. The manual flow does not delete its prefixes, so delete them yourself.

Reference

Bucket layout

KeysContents
objects/<xx>/<38-hex>loose git objects
objects/pack/pack-<hash>.{pack,idx}packfiles and their indexes
refs/..., HEADone key per ref, or a .gen/<counter> chain in generation mode, plus the HEAD symref
info/refs, objects/info/packsthe dumb-HTTP ref listing and pack list, rewritten on every ref-moving push
objects/info/http-alternates, objects/info/alternatespresent and empty; a 404 on them can livelock the dumb walker of git
.gitsocial/refs.jsonthe ref manifest: refname to sha for every ref in the bucket; written before and after each ref-moving push and by gitsocial push --site-only; the ref source for clients that cannot list (anonymous or not) and for the static site
.gitsocial/ref-mode, .gitsocial/pack-state.json, .gitsocial/packmap/<xx>.json, .gitsocial/upstreamthe ref mode, the sealing state, the pack map, the thin-fork marker
the shell files at the root, the page directories, .gitsocial/site/the static site; the page and artifact keys are in STATIC-SITE.md
.gitsocial/forks/the fork pull request area: the manifest, one pack per open head and the lock marker
artifacts/<version>/..., artifacts/latest.txtrelease artifacts and the newest version marker, written by gitsocial release artifacts push

These names are reserved, and repository content does not use them. The site rebuild deletes only the keys that it wrote, so release artifacts and other keys at the root stay after each push.

Ref update modes

The first push records the mode in .gitsocial/ref-mode from the provider of the endpoint host, or from a probe of the bucket when the host is unknown. The helper refuses a bucket without conditional writes before it writes a ref.

ModeMechanismProviders
etagone key per ref, updated with If-MatchAWS, R2
generationcreate-only chain <ref>/.gen/<counter>; the highest generation is the value; the newest two are keptSpaces

The helper enforces fast-forward. git push --force-with-lease works in both modes.

Packfiles

Objects in a pushUpload
under GITSOCIAL_S3_PACK_THRESHOLD (default 1,000)one loose key per object
at or above ittwo packfiles: commits and tags at --depth=0, trees and blobs at git's default depth

Maintenance attempts a seal on the first ref-moving push to a bucket, then every 25 pushes, and also when 64 or more objects were uploaded loose after the last attempt. A push that defers maintenance under GITSOCIAL_S3_DEFER_MAINTENANCE does not add its loose objects to that count, so one gitsocial push of several branches counts only the objects of its last git push. After a seal, its loose copies are deleted after 3 more pushes and 1 hour. A shallow or partial clone does not seal.

The client has no multipart upload, so when a pack of a push is over 4 GiB, the push uploads all its objects as loose objects and packs no part of the push. A seal instead skips a pack over 4 GiB (see Sealing).

The pack of commits and tags is written at --depth=0, so one ranged read of a commit gives a complete object. A push of 2,000 objects or more first lists objects/ and skips the objects that the bucket already has, so an interrupted first push continues where it stopped. The pack-objects command line sets pack.indexVersion=2 and pack.packSizeLimit=0, and the git config of the pusher does not change them.

The byte range of each object in a pack is published in the pack map. When a shard of the pack map cannot be written, the push logs the error and continues; a client with no pack map entry for an object reads the range from the .idx.

The pack index readers of the helper and of the site app read 20-byte object ids, so a repository with SHA-256 object ids is not supported: neither reads a pack of its bucket.

Sealing

A seal packs the loose objects of the bucket into packfiles, and the packs of one seal are a round. After a grace period, maintenance deletes the loose copies of the objects in the round, which is the only deletion of an immutable key. Each step is idempotent; a failure leaves the bucket as it was, and the next push tries again.

Push maintenance

Maintenance is the work that the helper does on the bucket after each ref-moving push. It runs after the push report, and its errors do not fail or delay the git push.

Fetch

gitsocial clone and gitsocial fetch walk the object graph from the wanted tips and write objects directly into GIT_DIR/objects. The bucket stores loose objects in the git format, so a download is a byte-for-byte copy.

Cache policy

Every upload carries Cache-Control. The local servers derive the same headers from the key pattern.

KeysCache-Control
loose objects, packfiles and indexes, release artifacts, the versioned shell directories (.gitsocial/site/shell/<rev>/), and the site's full shards, full pages and sitemap partspublic, max-age=31536000, immutable
every mutable key: refs, HEAD, the .gitsocial/ state, info/refs, objects/info/packs, artifacts/latest.txt, and every other site keyno-cache

The helper does not rewrite immutable keys and deletes only a loose object after a seal packed it and the grace period passed.

Each PUT sets Cache-Control from the key; the header is not signed and does not change the SigV4 signature. Three places apply the table above: cacheControlForKey for each upload, siteCacheControl for the immutable site keys, and localbucket (the handler behind locals3), which calculates the header again on each read.

Request behavior

All reads and writes retry in one place, and no caller has its own retry loop.

OutcomeBehavior
429, any 5xx, the 400 AWS documents as a retryable stalled upload, a transport failure carrying no status3 attempts, 0.5 s then 2 s apart
404, 403, a precondition failure, every other 4xxa definite answer, returned immediately

Thin bucket marker

.gitsocial/upstream is rewritten by a thin push that computed a frontier: the upstream branch tips from its fetch of the upstream, each tip a pin. A push that computed no frontier keeps the stored marker when it names the same upstream, and writes a marker with no pins when the upstream changed.

{"version": 1, "url": "https://github.com/alice/repo", "pins": [{"ref": "refs/heads/main", "sha": "..."}]}

Credentials

Resolved per endpoint host, in this order: GITSOCIAL_S3_ACCESS_KEY with GITSOCIAL_S3_SECRET_KEY, then ~/.config/gitsocial/credentials.json, then AWS_ACCESS_KEY_ID with AWS_SECRET_ACCESS_KEY. A source counts only when both keys are set, so a single variable is skipped and is not mixed with another source. When no source is complete, the client is anonymous.

The file path honors XDG_CONFIG_HOME. The file is an object keyed by endpoint host (<account>.r2.cloudflarestorage.com, s3.<region>.amazonaws.com, 127.0.0.1:9000) with {"accessKey": "...", "secretKey": "..."} values. It is written with mode 0600, and a less strict mode gives a warning. A missing file is not an error; a malformed file gives one warning, and the client continues to the AWS variables.

Anonymous reads

With no credentials for the endpoint host, the client is anonymous: it signs nothing and reads only. A push stops with the credentials error before it uploads anything.

A public bucket usually grants s3:GetObject but not s3:ListBucket, so ref discovery cannot list refs/ and takes the ref names from the first document that the bucket has: .gitsocial/refs.json, then info/refs. Also, an absent key answers 403, not 404, so the client reads a 403 on a read as "not found", and thin-fork borrowing and the pack probes still work. A signed client without s3:ListBucket does the same. On a signed GET, a 403 is "not found" only when the provider reports AccessDenied, so a rejected credential keeps its own error; each signed HEAD 403 is "not found", because a HEAD response has no body.

Environment variables

VariableMeaning
GITSOCIAL_S3_ACCESS_KEY, GITSOCIAL_S3_SECRET_KEYcredentials; both required; priority over the file
neither pair set, and no file entryanonymous reads: unsigned, read-only
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEYcredentials, used after the file
GITSOCIAL_S3_ENDPOINT, GITSOCIAL_S3_PATH_STYLEendpoint scheme and path-style addressing for self-hosted servers
GITSOCIAL_S3_REGIONSigV4 region for a host no preset recognizes
GITSOCIAL_S3_CONCURRENCYconcurrent uploads per push; default 16; overrides the s3.concurrency setting
GITSOCIAL_S3_DEBUG=1dump every request and response to stderr
GIT_QUIETset by git; silences the remote helper's progress on stderr
GITSOCIAL_CACHE_DIRthe cache directory of the remote helper; gitsocial sets it from --cache-dir for the git it runs, so a plain git push from another shell reads the default cache
GITSOCIAL_S3_PACK_THRESHOLDobjects per push at which a push uploads packfiles, default 1,000; also the minimum count of loose objects that a seal packs, default 64
GITSOCIAL_SITE_SHARD_COUNT, GITSOCIAL_SITE_WALK_BUDGETtest-fixture overrides: full shard size (default 4,000) and per-push walk budget (default 50,000)
GITSOCIAL_SITE_PAGES_BUDGETlimit on item pages written per push; unbounded when unset
GITSOCIAL_SITE_SITEMAP_PARTtest-fixture override: sitemap part size, default 40,000