Self-hosted Git LFS server with pluggable storage backends.
GiftHub implements the Git LFS Batch API, so any standard git lfs client can push and pull large files through it. The actual bytes are stored on a backend of your choice — local disk, Google Drive, or Google Drive via the gws CLI.
Git repos should stay lightweight. Videos, datasets, and other large files don't belong in git history. GiftHub lets you git lfs track them and store the real bytes somewhere else — your own disk, your Google Drive, or (eventually) S3.
git repo GiftHub server Storage backend
───────── ────────────── ───────────────
*.mp4 (pointer file) ───► POST /objects/batch ───► Google Drive
~130 bytes PUT /objects/:oid (or local disk)
GET /objects/:oid (or gws proxy)
When you git push, the LFS client sends the large file to GiftHub. GiftHub stores it on the configured backend using the file's SHA-256 hash as the key (content-addressed). Your git repo only ever contains a tiny pointer file.
When you git clone + git lfs pull, the LFS client asks GiftHub for the file by its hash, and GiftHub streams it back from the backend.
The CLI binary is gfh (GiftHub).
# Local disk backend (default)
gfh serve
gfh serve --driver local --storage-path ./lfs-objects
# Google Drive via gws CLI (recommended)
gfh serve --driver gwsProxy --drive-folder-id "FOLDER_ID"
# Google Drive via OAuth (programmatic access)
gfh serve --driver googleDrive \
--drive-folder-id "FOLDER_ID" \
--drive-credentials ~/gws-creds.json
# Custom host/port
gfh serve --hostname 0.0.0.0 --port 9080| Option | Default | Description |
|---|---|---|
--driver |
local |
Storage backend: local, gwsProxy, googleDrive |
--hostname / -h |
127.0.0.1 |
Hostname to bind to |
--port / -p |
8080 |
Port to listen on |
--storage-path |
./lfs-objects |
Root directory (local driver) |
--drive-folder-id |
— | Google Drive folder ID (required for Drive drivers) |
--drive-credentials |
— | Path to gws auth export JSON (googleDrive driver) |
--registry-path |
./lfs-registry.json |
Path to object registry file |
When you already have files on Google Drive and want Git LFS to serve them without re-uploading:
# Register: OID → Drive file ID
gfh alias add <oid> <drive-file-id>
# List all aliases
gfh alias list
# Remove an alias
gfh alias remove <oid>| Option | Default | Description |
|---|---|---|
--registry-path |
./lfs-registry-aliases.json |
Path to alias registry file |
# 1. Compute SHA-256 of the Drive file (streaming, no download)
gws drive files get --params '{"fileId":"FILE_ID","alt":"media"}' -o /dev/stdout | shasum -a 256
# 2. Register the alias
gfh alias add "SHA256_HASH" "DRIVE_FILE_ID"
# 3. Get the file size
gws drive files get --params '{"fileId":"FILE_ID","fields":"size"}'
# 4. Create the LFS pointer file in your repo
cat > path/to/video.mp4 << EOF
version https://git-lfs.github.com/spec/v1
oid sha256:SHA256_HASH
size FILE_SIZE
EOF
# 5. git add + commit — only the pointer file (130 bytes) is stored
git add path/to/video.mp4
git commit -m "add video via LFS alias"gfh init --drive-folder-id "YOUR_FOLDER_ID"Creates .gfh.json and stores Drive OAuth credentials in macOS Keychain.
# Link a Drive file to a local path (creates pointer + alias)
gfh link <drive-file-id-or-url> <local-path>Creates the pointer file locally and registers the OID → Drive file ID alias. Also renames the Drive file to match the local filename.
After linking, run
git add <local-path>beforegfh pull.
# Pull specific files
gfh pull lectures/04/video.mp4
# Pull all LFS files
gfh pullStarts a temporary LFS server, then delegates to git lfs pull. Validates that files are tracked by git before pulling, and verifies pointer files are replaced with real content afterward.
# Rename all pointer files' Drive counterparts
gfh rename
# Rename specific files
gfh rename lectures/04/video.mp4
# Preview without changes
gfh rename --dry-run# Upload and replace local file with pointer
gfh upload path/to/video.mp4
# Upload multiple files
gfh upload *.mp4
# Preview without changes
gfh upload --dry-run path/to/video.mp4
# Keep local file after upload
gfh upload --keep-local path/to/video.mp4For files already on Drive. Reclaims local disk space.
# Dehydrate all LFS files in the repo
gfh dehydrate
# Dehydrate specific files
gfh dehydrate lectures/*.mp4
# Preview without changes
gfh dehydrate --dry-runTo restore files: gfh pull <file> or git lfs pull.
gfh version| Backend | Driver | Auth | Best for |
|---|---|---|---|
| Local disk | local |
None | Development, small teams |
| Google Drive | drive |
OAuth via macOS Keychain | Personal use, programmatic access |
Legacy driver values
gwsProxyandgoogleDriveauto-map todrive.
Objects stored as files at <root>/<oid[0:2]>/<oid[2:4]>/<oid>.
gfh serve --driver local --storage-path ./lfs-objectsDelegates all Drive operations to the gws CLI. No tokens to manage — just gws auth login once.
# 1. Install and authenticate gws
npm install -g @googleworkspace/cli
gws auth setup
gws auth login
# 2. Create a folder on Drive for LFS objects
gws drive files create --json '{"name":"GiftHub-LFS","mimeType":"application/vnd.google-apps.folder"}'
# Note the folder ID from the response
# 3. Start the server
gfh serve --driver gwsProxy --drive-folder-id "YOUR_FOLDER_ID"Uses the Drive REST API directly with a refresh token.
gws auth export > ~/gws-creds.json
# Edit the file to fill in unmasked values
gfh serve --driver googleDrive \
--drive-folder-id "YOUR_FOLDER_ID" \
--drive-credentials ~/gws-creds.jsonPoint your repo's LFS to the GiftHub server:
# In your git repo
git lfs install
git lfs track "*.mp4" "*.mov" "*.mkv" "*.zip"
git config lfs.url http://localhost:8080/owner/repo.git/info/lfs
git add .gitattributes
git add large-file.mp4 # git stores a ~130 byte pointer
git commit -m "add video via LFS"
git push # LFS uploads the real file to GiftHubRequires Swift 6.0+ and macOS 14+.
swift build
swift test
# Install to ~/bin
swift build --configuration release
mkdir -p ~/bin
cp .build/release/gfh ~/bin/Important: Make sure
~/binis in your PATH. Add this to~/.zshrcor~/.zprofile:export PATH="$HOME/bin:$PATH"Then restart your terminal or run
source ~/.zshrc.Verify:
gfh --help
After gfh dehydrate, local .mp4 files are replaced with ~130 byte LFS pointers. The pointer content looks like:
version https://git-lfs.github.com/spec/v1
oid sha256:00d55d6c88cf...
size 1238181437
Do NOT use git lfs pointer --file — it treats the pointer as a regular file and hashes the 130-byte text, returning a wrong OID.
Correct methods:
# Method 1: git lfs ls-files (always correct, regardless of hydration state)
git lfs ls-files --long | grep "video.mp4"
# 00d55d6c... - video.mp4
# Method 2: Read the pointer content directly
grep 'oid sha256:' video.mp4 | sed 's/oid sha256://'
# Method 3: Check first, then branch
if git lfs pointer --check --file video.mp4 2>/dev/null; then
# It's a pointer — read OID from content
OID=$(grep 'oid sha256:' video.mp4 | sed 's/oid sha256://')
else
# It's a real file — compute OID
OID=$(git lfs pointer --file video.mp4 | grep sha256 | sed 's/.*sha256://')
fiLocal file (1.2 GB)
──gfh upload──► Google Drive + local pointer (130 B)
──gfh dehydrate──► local pointer (130 B) [if already on Drive]
──gfh pull──► Local file (1.2 GB) [download from Drive]
GiftHub/
├── Packages/PawKit/ # Storage layer (drivers + registry)
│ ├── StorageDriver.swift # Protocol: put/get/head/delete/stream
│ ├── LocalDiskDriver.swift
│ ├── DriveDriver.swift # Google Drive via Keychain OAuth
│ ├── KeychainHelper.swift # macOS Keychain read/write
│ ├── AliasRegistry.swift # OID → Drive file ID mapping
│ ├── ContentHash.swift # SHA-256 + Dropbox content hash
│ └── StorageConfiguration.swift
├── Sources/GiftHub/ # Core library (Hummingbird HTTP server)
│ ├── LFS/ # Git LFS Batch API handlers
│ │ └── LFSPointer.swift # Pointer file detection utility
│ ├── Auth/
│ ├── Registry/ # Object metadata tracking
│ └── Server/
└── Sources/gfs/ # CLI entry point (binary: gfh)
├── GFS.swift # Main command
├── Serve.swift # serve subcommand
├── Init.swift # init subcommand
├── Upload.swift # upload subcommand
├── Link.swift # link subcommand
├── Pull.swift # pull subcommand
├── Dehydrate.swift # dehydrate subcommand
├── Alias.swift # alias subcommand
├── Rename.swift # rename subcommand
└── Version.swift