Commits
Multi-stage build using golang:1.26 for compilation and
distroless/static-debian12:nonroot for runtime. The build stage
uses BuildKit caches for modules and build artifacts. The runtime
image includes only CA certs and tzdata, runs as nonroot, and
exposes ports 8080 (Smart HTTP), 9418 (git://), and 9090 (metrics).
Static stripped binary compiled with CGO_ENABLED=0 since objgitd
is pure Go and answers the git protocol natively without a git
binary dependency.
.dockerignore excludes the 600MB var/ Go SDK tree, .git, docs,
and other non-essential files to keep the build context lean.
Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Repositories initialized with HEAD -> refs/heads/main (the project
default) but populated by pushing a repo with a different default
branch (golang/go uses master) end up with HEAD pointing at a
nonexistent branch. Git clients cannot check out a worktree and
warn: "remote HEAD refers to nonexistent ref, unable to checkout".
Fix: repoint HEAD at an existing branch when its symbolic target is
missing. Prefer refs/heads/main, then master, then trunk; fall back
to the lexicographically smallest branch. Heal is idempotent — a
detached HEAD, already-valid HEAD, or branch-less repo is left
untouched.
Heal on every repository load (fixes repos already in the bucket with
no re-push) and after successful receive-pack (fixes new pushes
immediately). Both call sites are now guarded by ensureHEAD(), which
is no-op when HEAD is already valid.
Fixes: #99 (clone checkout abort with dangling HEAD warning)
Assisted-by: Claude Opus 4.8 via claude.ai/code
Make each repository's Chroot root register itself as a recursive subtree
prefix with the listing cache. This revives the subtree-scan optimization
(previously dead in chrooted deployments) so the entire repo is answered
from one delimiter-less S3 ListObjectsV2 instead of a list per folder.
Eliminates ~256 loose-object negative-lookup lists per clone and collapses
the background warmer from O(folders) lists/tick to O(1).
Changes:
- listingcache.go: roots field is now atomic.Pointer[[]string] with
registerRoot() method for lock-free runtime registration
- chroot.go: calls registerRoot() when creating a chroot
- listingcache_test.go: updated TestListingCacheChrootShares and added
TestListingCacheChrootSubtreeCollapsesLooseLookups regression guard
Verification: all tests pass, including race detector and cmd/objgitd
protocol tests.
Plan: docs/plans/why-does-objgit-do-zazzy-bentley.md
Assisted-by: Claude Opus 4.8 via Claude Code
Serving a clone via upload-pack's delta-compression re-reads pack
objects thousands of times. Commit 76bb55d made pack reads lazy to
avoid RAM buffering; this causes each access to issue a fresh S3
GetObject, making real-repo clones never finish.
Implement PackCache: immutable pack-dir files (.pack/.idx/.rev)
download once to a local temp file (LRU-bounded by -pack-cache-bytes,
default 2GB) and all reads serve from local disk.
Results on real Tigris bucket:
- objgit (318 objects, 200KB pack): 8500+ GetObjects → 10, 7s
- kefka (197 commits, 19.6MB pack): hang → 14s, full clone
The cache is wired into OpenFile and shared across Chroot children
like the listing cache. Add flags: -pack-cache-bytes, -pack-cache-dir.
See docs/plans/pack-temp-file-cache.md for design.
Assisted-by: Claude Opus 4.8 via claude.ai
Signed-off-by: Xe Iaso <me@xeiaso.net>
The AWS SDK's default transport (IdleConnTimeout: 90s, no
ResponseHeaderTimeout) allows reused keep-alive connections to Tigris
to sit stale and never respond. With context.TODO() (no timeout),
ReadObject hangs forever instead of failing and retrying.
Inject a hardened HTTP client (ResponseHeaderTimeout: 30s,
IdleConnTimeout: 30s) into every S3 call via per-call options, so
stale connections error quickly and the retryer recovers.
Relates to: lazy-read clone hangs (independently useful).
Assisted-by: Claude Opus 4.8 via claude.ai
Signed-off-by: Xe Iaso <me@xeiaso.net>
Replace eager GetObject+ReadAll buffering with lazy on-demand fetching.
Files now stream their bodies only when read, and ReadAt uses S3 Range
requests backed by a 1 MiB read-ahead window. This prevents whole-object
buffering in RAM (critical for large pack files since feat: keep pushed
packs whole) and reduces redundant HeadObject calls by deriving metadata
from the GetObject response.
Sequential Read opens a body at Open or on first read (depending on cache
hit); body streams on demand and reopens after Seek. ReadAt issues Range
requests; nearby offsets are served from the window without new S3 calls.
The redundant HeadObject is dropped from the main path; only Stat on a
never-read handle falls back to it (effectively unreachable in normal flow).
The public billy.File API is unchanged; this is a performance and memory
optimization. All three transport protocols (HTTP, git://, SSH) exercise
real pack/idx reads through go-git's FSObject and Scanner; the full test
suite passes.
See docs/plans/s3fs-lazy-read.md for the design.
Assisted-by: Claude Haiku 4.5 via claude.ai
Signed-off-by: Xe Iaso <me@xeiaso.net>
Eliminate redundant HeadObject and ListObjectsV2 round-trips on repeated
Stat/Open operations against the same S3 prefixes. Implemented via two
groupcache groups with window-encoded TTL and per-prefix local generation
for precise in-process invalidation.
When a path's parent folder is first listed, the cache populates both:
- childEntry metadata (name, kind, size, mtime) for the entire folder
- Background prefetch of HeadObject for each file via detached goroutines
with semaphore bounding (maxPrefetchInFlight=64, configurable)
Stat/Open of a cached folder's absent child answers with zero round-trips
(negative hit from the listing). A present file skips HeadObject via the
prewarmed head cache and only GetObject-s the body. Positive Stat/ReadDir
results are never stale (immutable S3 content); cross-process and
post-restart staleness is bounded by the TTL window (-s3-cache-ttl, default
60s; <=0 disables caching entirely).
Local writes (Create, Remove, Rename, MkdirAll) bump the parent prefix's
generation on success, moving the cache key so the next read re-lists and
sees the change immediately (read-after-write correctness).
When -groupcache-self and -groupcache-peers are configured, listings are
shared across processes via consistent-hash peer pooling.
Changes:
- New: internal/s3fs/listingcache.go (ListingCache type, groupcache groups,
warmer goroutine for refresh at idle TTL boundaries)
- New: internal/s3fs/listingcache_test.go (comprehensive table-driven tests
with counting stub client, head prefetch tests, window TTL, warmer, chroot
integration)
- Modified: internal/s3fs/{filesystem,basic,file,dir,chroot}.go to wire cache
through Stat/Open/ReadDir/Remove/Rename/MkdirAll with invalidation on writes
- Modified: internal/metrics/metrics.go to export groupcache stats via
objgit_s3_listing_cache_* collectors
- Modified: cmd/objgitd/main.go to add -s3-cache-ttl, -s3-cache-refresh,
-s3-cache-idle, -s3-cache-size, -groupcache-self, -groupcache-peers,
-groupcache-bind flags; integrate cache into S3FS and errgroup
Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Signed-off-by: Xe Iaso <me@xeiaso.net>
Instrument the three things an operator needs to see and serve them on a
dedicated listener (-metrics-bind, default :9090; empty disables):
- s3fs: count + latency per S3 API operation (GetObject, PutObject, …) via a
process-level observer set with s3fs.SetMetricsObserver, keeping s3fs free of
any prometheus import.
- git ops: count + latency + in-flight gauge per protocol+service+status,
wrapped once per transport handler (http/git/ssh).
- auth: count + latency per transport+operation+decision, routed through a new
(*daemon).authorize chokepoint that all three transports now call.
Extras: push-hook runs (ok/error/timeout) with latency, repos auto-created
counter, and the default registry's Go-runtime + process collectors.
Per the operator's call there is no repo label (unbounded cardinality); git
ops are keyed by protocol+service+status only.
All vectors live in internal/metrics via promauto on the default registry,
exposed by promhttp.Handler under the existing errgroup Serve/Shutdown idiom.
Hooks now run synchronously and stream stdout/stderr to the pushing client
as "remote: ..." lines over the git sideband progress channel (band 2).
Previously they ran asynchronously after the push response, and output was
captured to slog only — the client never saw it.
To support streaming, fork go-git's transport.ReceivePack (v6) into
cmd/objgitd/receivepack.go as receivePackStreaming(). go-git keeps the
sideband Muxer internal and sends the closing flush-pkt before returning,
so there's no public seam to inject progress. The fork adds one callback
onUpdated(progress io.Writer), invoked after report-status but before the
final flush. When sideband is negotiated, progress writes to band 2
(ProgressMessage); otherwise it's nil and hooks fall back to logging.
HTTP adds a flush-on-write wrapper (flushWriter) to the receive-pack
response so net/http buffering doesn't hold the "remote:" lines back.
git:// and SSH write through live sockets and need no such wrapper.
Remove the now-dead async machinery: hookWG field and its shutdown drain.
Hooks complete before receivePackStreaming returns, so the connection
already holds them until completion.
Update tests to assert hook output appears in the push output (as "remote:"
lines) rather than in logs. Add TestSmartHTTPHookStreams to cover the HTTP
flush path. All three transports (git://, HTTP, SSH) verified.
Note: Clients now wait for hooks to finish (bounded by -hook-timeout,
default 60s) before the push completes, rather than push completing while
hooks run in the background. This is inherent to streaming output live.
Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Address code-review feedback:
- Stat the absolute "/test.git/config" path (memfs can resolve
relative vs absolute lookups differently); matches the rest of
the package.
- Close the listener in startSSHServer cleanup as well as the
server, avoiding a listener leak if Cleanup fires before the
concurrent Serve registers the listener.
- Quote the key path in GIT_SSH_COMMAND so paths with spaces are
not mis-split when git execs ssh.
- Rename the TestGitServiceFor loop variable tc -> tt for
package-wide consistency.
Assisted-by: Claude Opus 4.8 via Claude Code
Add comprehensive SSH integration tests covering:
- push creates repo on demand and clone round-trips match HEAD
- push rejected when disabled, repo not created
- clone of missing repository fails
- receive-pack hooks fire asynchronously after push response
Tests drive a real git client over ssh:// against an in-process
SSH server backed by memfs, generating ephemeral ed25519 client
keys and bypassing host key checking for CI environments. Helpers:
gitSSHEnv() configures GIT_SSH_COMMAND and isolation env vars,
gitWithEnv() runs git with custom env (callers can inspect errors),
startSSHServer() creates an in-memory daemon on an ephemeral port.
Assisted-by: Claude Sonnet 4.6 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
gliderlabs/ssh already stashes the connecting key for Session.PublicKey(),
so the pubKeyContextKey type and PublicKeyHandler SetValue call were
redundant. Read the key directly in handleSSH. Also relax the command
arity guard to len < 2 and document the intentional protocol-v2 omission.
Adds newSSHServer, handleSSH, and gitServiceFor to ssh.go, wiring
gliderlabs/ssh into the daemon so git clone/push over SSH mirrors
the git:// handler: persistent-stream no-op closers, streamingStorer
for receive-pack, and auth.Authorizer gating via pubKeyContextKey.
Adds TestGitServiceFor covering all valid service names and invalid
inputs.
Signed-off-by: Xe Iaso <me@xeiaso.net>
Replace the allowPush bool gate in resolve with a call to d.authz.Authorize,
extracting HTTP Basic credentials via credFromRequest. Remove the now-redundant
allowPush field from the daemon struct and all daemon literals in tests. Add
TestSmartHTTPAnonymousReadWhilePushDisabled to verify reads succeed when writes
are denied.
Signed-off-by: Xe Iaso <me@xeiaso.net>
Signed-off-by: Xe Iaso <me@xeiaso.net>
Implement automatic hook execution after a successful push: when started with
-allow-hooks, objgitd runs .objgit/hooks/receive-pack from the pushed commit
in a restricted shell (kefka virtual bash). The hook sees a read-only view of
the commit tree at /src and writable scratch at /tmp. Execution is async
post-response so hooks cannot reject a push. Only coreutils commands available
(cat, grep, ls, head, tail, sort, sha256sum, etc.) — no arbitrary binaries,
network, or git command.
Refs before/after push are snapshotted to detect branch changes since
transport.ReceivePack does not report them. One hook runs per created/updated
branch; deletions are skipped. Output and exit status logged to slog only.
New packages:
- internal/treefs: lazy read-only billy.Filesystem view of a git tree
(blobs fetched on open, no checkout to disk)
- internal/mountfs: path-prefix composite FS routing /src and /tmp to
separate mounted filesystems
- internal/kefkash: vendored copy of kefka's billysh handler wiring
(adapted to allow writes so /tmp redirections work)
Includes: daemon integration, flags (-allow-hooks, -hook-timeout), tests
(treefs unit tests, diffRefs, e2e push tests), example hook with regression
test, and CLAUDE.md architecture documentation.
Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Store POSIX file attributes (uid, gid, mode, mtime) as x-amz-meta-*
headers on S3 objects so POSIX filesystems layered on Tigris can
round-trip ownership and permissions instead of always reporting
mode 0666 with no owner.
The feature is opt-in and off by default: NewS3FS takes functional
options, and without WithUnixMetadata the filesystem behaves exactly
as before. Callers opt in by passing WithUnixMetadata(uid, gid, umask)
and resolve any name strings to numeric IDs themselves.
New unixmeta package implements Encode/Decode and PosixMode/GoFileMode
conversion; the wire format is documented in docs/reference.
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Captures the non-obvious bits: the shared daemon backing both
transports, why git:// receive-pack hides PackfileWriter to avoid
deadlocking on EOF, why s3fs needs a tempBuffer for go-git's
read-while-write PackWriter, and the suffix-based smart-HTTP dispatch.
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Captures the design decisions and rollout plan for adding HTTP smart
protocol support to objgitd alongside the existing git:// transport.
Signed-off-by: Xe Iaso <me@xeiaso.net>
Move daemon.go and daemon_test.go to git_protocol.go and git_protocol_test.go
to better reflect their purpose. No logic changes.
Signed-off-by: Xe Iaso <me@xeiaso.net>
Implement native git protocol support in objgitd daemon. Enables clients
to use standard git commands (git clone, git fetch) over the git protocol
instead of requiring HTTP. Includes proper request handling and response
formatting for upload-pack operations.
Assisted-by: Claude Haiku 4.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Multi-stage build using golang:1.26 for compilation and
distroless/static-debian12:nonroot for runtime. The build stage
uses BuildKit caches for modules and build artifacts. The runtime
image includes only CA certs and tzdata, runs as nonroot, and
exposes ports 8080 (Smart HTTP), 9418 (git://), and 9090 (metrics).
Static stripped binary compiled with CGO_ENABLED=0 since objgitd
is pure Go and answers the git protocol natively without a git
binary dependency.
.dockerignore excludes the 600MB var/ Go SDK tree, .git, docs,
and other non-essential files to keep the build context lean.
Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Repositories initialized with HEAD -> refs/heads/main (the project
default) but populated by pushing a repo with a different default
branch (golang/go uses master) end up with HEAD pointing at a
nonexistent branch. Git clients cannot check out a worktree and
warn: "remote HEAD refers to nonexistent ref, unable to checkout".
Fix: repoint HEAD at an existing branch when its symbolic target is
missing. Prefer refs/heads/main, then master, then trunk; fall back
to the lexicographically smallest branch. Heal is idempotent — a
detached HEAD, already-valid HEAD, or branch-less repo is left
untouched.
Heal on every repository load (fixes repos already in the bucket with
no re-push) and after successful receive-pack (fixes new pushes
immediately). Both call sites are now guarded by ensureHEAD(), which
is no-op when HEAD is already valid.
Fixes: #99 (clone checkout abort with dangling HEAD warning)
Assisted-by: Claude Opus 4.8 via claude.ai/code
Make each repository's Chroot root register itself as a recursive subtree
prefix with the listing cache. This revives the subtree-scan optimization
(previously dead in chrooted deployments) so the entire repo is answered
from one delimiter-less S3 ListObjectsV2 instead of a list per folder.
Eliminates ~256 loose-object negative-lookup lists per clone and collapses
the background warmer from O(folders) lists/tick to O(1).
Changes:
- listingcache.go: roots field is now atomic.Pointer[[]string] with
registerRoot() method for lock-free runtime registration
- chroot.go: calls registerRoot() when creating a chroot
- listingcache_test.go: updated TestListingCacheChrootShares and added
TestListingCacheChrootSubtreeCollapsesLooseLookups regression guard
Verification: all tests pass, including race detector and cmd/objgitd
protocol tests.
Plan: docs/plans/why-does-objgit-do-zazzy-bentley.md
Assisted-by: Claude Opus 4.8 via Claude Code
Serving a clone via upload-pack's delta-compression re-reads pack
objects thousands of times. Commit 76bb55d made pack reads lazy to
avoid RAM buffering; this causes each access to issue a fresh S3
GetObject, making real-repo clones never finish.
Implement PackCache: immutable pack-dir files (.pack/.idx/.rev)
download once to a local temp file (LRU-bounded by -pack-cache-bytes,
default 2GB) and all reads serve from local disk.
Results on real Tigris bucket:
- objgit (318 objects, 200KB pack): 8500+ GetObjects → 10, 7s
- kefka (197 commits, 19.6MB pack): hang → 14s, full clone
The cache is wired into OpenFile and shared across Chroot children
like the listing cache. Add flags: -pack-cache-bytes, -pack-cache-dir.
See docs/plans/pack-temp-file-cache.md for design.
Assisted-by: Claude Opus 4.8 via claude.ai
Signed-off-by: Xe Iaso <me@xeiaso.net>
The AWS SDK's default transport (IdleConnTimeout: 90s, no
ResponseHeaderTimeout) allows reused keep-alive connections to Tigris
to sit stale and never respond. With context.TODO() (no timeout),
ReadObject hangs forever instead of failing and retrying.
Inject a hardened HTTP client (ResponseHeaderTimeout: 30s,
IdleConnTimeout: 30s) into every S3 call via per-call options, so
stale connections error quickly and the retryer recovers.
Relates to: lazy-read clone hangs (independently useful).
Assisted-by: Claude Opus 4.8 via claude.ai
Signed-off-by: Xe Iaso <me@xeiaso.net>
Replace eager GetObject+ReadAll buffering with lazy on-demand fetching.
Files now stream their bodies only when read, and ReadAt uses S3 Range
requests backed by a 1 MiB read-ahead window. This prevents whole-object
buffering in RAM (critical for large pack files since feat: keep pushed
packs whole) and reduces redundant HeadObject calls by deriving metadata
from the GetObject response.
Sequential Read opens a body at Open or on first read (depending on cache
hit); body streams on demand and reopens after Seek. ReadAt issues Range
requests; nearby offsets are served from the window without new S3 calls.
The redundant HeadObject is dropped from the main path; only Stat on a
never-read handle falls back to it (effectively unreachable in normal flow).
The public billy.File API is unchanged; this is a performance and memory
optimization. All three transport protocols (HTTP, git://, SSH) exercise
real pack/idx reads through go-git's FSObject and Scanner; the full test
suite passes.
See docs/plans/s3fs-lazy-read.md for the design.
Assisted-by: Claude Haiku 4.5 via claude.ai
Signed-off-by: Xe Iaso <me@xeiaso.net>
Eliminate redundant HeadObject and ListObjectsV2 round-trips on repeated
Stat/Open operations against the same S3 prefixes. Implemented via two
groupcache groups with window-encoded TTL and per-prefix local generation
for precise in-process invalidation.
When a path's parent folder is first listed, the cache populates both:
- childEntry metadata (name, kind, size, mtime) for the entire folder
- Background prefetch of HeadObject for each file via detached goroutines
with semaphore bounding (maxPrefetchInFlight=64, configurable)
Stat/Open of a cached folder's absent child answers with zero round-trips
(negative hit from the listing). A present file skips HeadObject via the
prewarmed head cache and only GetObject-s the body. Positive Stat/ReadDir
results are never stale (immutable S3 content); cross-process and
post-restart staleness is bounded by the TTL window (-s3-cache-ttl, default
60s; <=0 disables caching entirely).
Local writes (Create, Remove, Rename, MkdirAll) bump the parent prefix's
generation on success, moving the cache key so the next read re-lists and
sees the change immediately (read-after-write correctness).
When -groupcache-self and -groupcache-peers are configured, listings are
shared across processes via consistent-hash peer pooling.
Changes:
- New: internal/s3fs/listingcache.go (ListingCache type, groupcache groups,
warmer goroutine for refresh at idle TTL boundaries)
- New: internal/s3fs/listingcache_test.go (comprehensive table-driven tests
with counting stub client, head prefetch tests, window TTL, warmer, chroot
integration)
- Modified: internal/s3fs/{filesystem,basic,file,dir,chroot}.go to wire cache
through Stat/Open/ReadDir/Remove/Rename/MkdirAll with invalidation on writes
- Modified: internal/metrics/metrics.go to export groupcache stats via
objgit_s3_listing_cache_* collectors
- Modified: cmd/objgitd/main.go to add -s3-cache-ttl, -s3-cache-refresh,
-s3-cache-idle, -s3-cache-size, -groupcache-self, -groupcache-peers,
-groupcache-bind flags; integrate cache into S3FS and errgroup
Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Instrument the three things an operator needs to see and serve them on a
dedicated listener (-metrics-bind, default :9090; empty disables):
- s3fs: count + latency per S3 API operation (GetObject, PutObject, …) via a
process-level observer set with s3fs.SetMetricsObserver, keeping s3fs free of
any prometheus import.
- git ops: count + latency + in-flight gauge per protocol+service+status,
wrapped once per transport handler (http/git/ssh).
- auth: count + latency per transport+operation+decision, routed through a new
(*daemon).authorize chokepoint that all three transports now call.
Extras: push-hook runs (ok/error/timeout) with latency, repos auto-created
counter, and the default registry's Go-runtime + process collectors.
Per the operator's call there is no repo label (unbounded cardinality); git
ops are keyed by protocol+service+status only.
All vectors live in internal/metrics via promauto on the default registry,
exposed by promhttp.Handler under the existing errgroup Serve/Shutdown idiom.
Hooks now run synchronously and stream stdout/stderr to the pushing client
as "remote: ..." lines over the git sideband progress channel (band 2).
Previously they ran asynchronously after the push response, and output was
captured to slog only — the client never saw it.
To support streaming, fork go-git's transport.ReceivePack (v6) into
cmd/objgitd/receivepack.go as receivePackStreaming(). go-git keeps the
sideband Muxer internal and sends the closing flush-pkt before returning,
so there's no public seam to inject progress. The fork adds one callback
onUpdated(progress io.Writer), invoked after report-status but before the
final flush. When sideband is negotiated, progress writes to band 2
(ProgressMessage); otherwise it's nil and hooks fall back to logging.
HTTP adds a flush-on-write wrapper (flushWriter) to the receive-pack
response so net/http buffering doesn't hold the "remote:" lines back.
git:// and SSH write through live sockets and need no such wrapper.
Remove the now-dead async machinery: hookWG field and its shutdown drain.
Hooks complete before receivePackStreaming returns, so the connection
already holds them until completion.
Update tests to assert hook output appears in the push output (as "remote:"
lines) rather than in logs. Add TestSmartHTTPHookStreams to cover the HTTP
flush path. All three transports (git://, HTTP, SSH) verified.
Note: Clients now wait for hooks to finish (bounded by -hook-timeout,
default 60s) before the push completes, rather than push completing while
hooks run in the background. This is inherent to streaming output live.
Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Address code-review feedback:
- Stat the absolute "/test.git/config" path (memfs can resolve
relative vs absolute lookups differently); matches the rest of
the package.
- Close the listener in startSSHServer cleanup as well as the
server, avoiding a listener leak if Cleanup fires before the
concurrent Serve registers the listener.
- Quote the key path in GIT_SSH_COMMAND so paths with spaces are
not mis-split when git execs ssh.
- Rename the TestGitServiceFor loop variable tc -> tt for
package-wide consistency.
Assisted-by: Claude Opus 4.8 via Claude Code
Add comprehensive SSH integration tests covering:
- push creates repo on demand and clone round-trips match HEAD
- push rejected when disabled, repo not created
- clone of missing repository fails
- receive-pack hooks fire asynchronously after push response
Tests drive a real git client over ssh:// against an in-process
SSH server backed by memfs, generating ephemeral ed25519 client
keys and bypassing host key checking for CI environments. Helpers:
gitSSHEnv() configures GIT_SSH_COMMAND and isolation env vars,
gitWithEnv() runs git with custom env (callers can inspect errors),
startSSHServer() creates an in-memory daemon on an ephemeral port.
Assisted-by: Claude Sonnet 4.6 via Claude Code
Adds newSSHServer, handleSSH, and gitServiceFor to ssh.go, wiring
gliderlabs/ssh into the daemon so git clone/push over SSH mirrors
the git:// handler: persistent-stream no-op closers, streamingStorer
for receive-pack, and auth.Authorizer gating via pubKeyContextKey.
Adds TestGitServiceFor covering all valid service names and invalid
inputs.
Replace the allowPush bool gate in resolve with a call to d.authz.Authorize,
extracting HTTP Basic credentials via credFromRequest. Remove the now-redundant
allowPush field from the daemon struct and all daemon literals in tests. Add
TestSmartHTTPAnonymousReadWhilePushDisabled to verify reads succeed when writes
are denied.
Implement automatic hook execution after a successful push: when started with
-allow-hooks, objgitd runs .objgit/hooks/receive-pack from the pushed commit
in a restricted shell (kefka virtual bash). The hook sees a read-only view of
the commit tree at /src and writable scratch at /tmp. Execution is async
post-response so hooks cannot reject a push. Only coreutils commands available
(cat, grep, ls, head, tail, sort, sha256sum, etc.) — no arbitrary binaries,
network, or git command.
Refs before/after push are snapshotted to detect branch changes since
transport.ReceivePack does not report them. One hook runs per created/updated
branch; deletions are skipped. Output and exit status logged to slog only.
New packages:
- internal/treefs: lazy read-only billy.Filesystem view of a git tree
(blobs fetched on open, no checkout to disk)
- internal/mountfs: path-prefix composite FS routing /src and /tmp to
separate mounted filesystems
- internal/kefkash: vendored copy of kefka's billysh handler wiring
(adapted to allow writes so /tmp redirections work)
Includes: daemon integration, flags (-allow-hooks, -hook-timeout), tests
(treefs unit tests, diffRefs, e2e push tests), example hook with regression
test, and CLAUDE.md architecture documentation.
Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Store POSIX file attributes (uid, gid, mode, mtime) as x-amz-meta-*
headers on S3 objects so POSIX filesystems layered on Tigris can
round-trip ownership and permissions instead of always reporting
mode 0666 with no owner.
The feature is opt-in and off by default: NewS3FS takes functional
options, and without WithUnixMetadata the filesystem behaves exactly
as before. Callers opt in by passing WithUnixMetadata(uid, gid, umask)
and resolve any name strings to numeric IDs themselves.
New unixmeta package implements Encode/Decode and PosixMode/GoFileMode
conversion; the wire format is documented in docs/reference.
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Captures the non-obvious bits: the shared daemon backing both
transports, why git:// receive-pack hides PackfileWriter to avoid
deadlocking on EOF, why s3fs needs a tempBuffer for go-git's
read-while-write PackWriter, and the suffix-based smart-HTTP dispatch.
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>
Implement native git protocol support in objgitd daemon. Enables clients
to use standard git commands (git clone, git fetch) over the git protocol
instead of requiring HTTP. Includes proper request handling and response
formatting for upload-pack operations.
Assisted-by: Claude Haiku 4.5 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>