diff --git a/.golangci.yml b/.golangci.yml
index 2126bf7..d33c699 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -10,6 +10,8 @@ linters:
- fmt.Fprintln(net/http.ResponseWriter)
# Deferred response-body closes in tests.
- (io.ReadCloser).Close
+ # Deferred row-set closes; iteration errors surface via rows.Err().
+ - (*database/sql.Rows).Close
formatters:
enable:
diff --git a/go.mod b/go.mod
index 9c1ed41..5d3cfd4 100644
--- a/go.mod
+++ b/go.mod
@@ -1,8 +1,9 @@
module github.com/atmin/gitmote
-go 1.26
+go 1.26.1
require (
+ github.com/atmin/s3lite v0.0.0-20260426092427-3ca2e2a3f77b
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.27
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.13
@@ -13,6 +14,7 @@ require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.26 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
@@ -25,4 +27,38 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
+ github.com/benbjohnson/litestream v0.5.11 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/hablullah/go-hijri v1.0.2 // indirect
+ github.com/hablullah/go-juliandays v1.0.0 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
+ github.com/lmittmann/tint v1.1.3 // indirect
+ github.com/magefile/mage v1.14.0 // indirect
+ github.com/markusmobius/go-dateparser v1.2.4 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/pierrec/lz4/v4 v4.1.22 // indirect
+ github.com/prometheus/client_golang v1.17.0 // indirect
+ github.com/prometheus/client_model v0.5.0 // indirect
+ github.com/prometheus/common v0.45.0 // indirect
+ github.com/prometheus/procfs v0.12.0 // indirect
+ github.com/psanford/sqlite3vfs v0.0.0-20251127171934-4e34e03a991a // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/superfly/ltx v0.5.1 // indirect
+ github.com/tetratelabs/wazero v1.2.1 // indirect
+ github.com/wasilibs/go-re2 v1.3.0 // indirect
+ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/text v0.36.0 // indirect
+ google.golang.org/protobuf v1.34.2 // indirect
+ modernc.org/libc v1.67.6 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.44.3 // indirect
)
diff --git a/go.sum b/go.sum
index 768400a..87264ec 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,31 @@
+cloud.google.com/go v0.111.0 h1:YHLKNupSD1KqjDbQ3+LVdQ81h/UJbJyZG203cEfnQgM=
+cloud.google.com/go v0.111.0/go.mod h1:0mibmpKP1TyOOFYQY5izo0LnT+ecvOQ0Sg3OdmMiNRU=
+cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
+cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
+cloud.google.com/go/iam v1.1.5 h1:1jTsCu4bcsNsE4iiqNT5SHwrDRCfRmIaaaVFhRveTJI=
+cloud.google.com/go/iam v1.1.5/go.mod h1:rB6P/Ic3mykPbFio+vo7403drjlgvoWfYpJhMXEbzv8=
+cloud.google.com/go/storage v1.36.0 h1:P0mOkAcaJxhCTvAkMhxMfrTKiNcub4YmmPBtlhAyTr8=
+cloud.google.com/go/storage v1.36.0/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8=
+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 h1:Hr5FTipp7SL07o2FvoVOX9HRiRH3CR3Mj8pxqCcdD5A=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0 h1:MhRfI58HblXzCtWEZCO0feHs8LweePB3s90r7WaR1KU=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0/go.mod h1:okZ+ZURbArNdlJ+ptXoyHNuOETzOl1Oww19rm8I2WLA=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 h1:FwladfywkNirM+FZYLBR2kBz5C8Tg0fw5w5Y7meRXWI=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2/go.mod h1:vv5Ad0RrIoT1lJFdWBZwt4mB1+j+V8DUroixmKDTCdk=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y=
+github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
+github.com/atmin/s3lite v0.0.0-20260426092427-3ca2e2a3f77b h1:F4xTF7IX6kY2CULTgCu0J4rJw4PW7wJpJ8XMFo262+s=
+github.com/atmin/s3lite v0.0.0-20260426092427-3ca2e2a3f77b/go.mod h1:dRO3dUGT1cYwyz2hbGCOVDRqeSbaYt5wGhiqdbH6d6Q=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
@@ -8,6 +36,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSd
github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 h1:9vWXHtaepwoAl/UuKzxwgOoJDXPCC3hvgNMfcmdS2Tk=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18/go.mod h1:sKuUZ+MwUTuJbYvZ8pK0x10LvgcJK3Y4rmh63YBekwk=
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.13 h1:zHi0z+ZT207/QPyaCmTr4JksQ0Ty0DM+WXW0xsfckXQ=
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.13/go.mod h1:ycxBs1ztF7RbxNWiJoIegPCvsfuO7dCvtyyloQvRgNs=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
@@ -36,3 +66,247 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 h1:T3ANO8QWDbzQD8f4+UaX+fvJlyGn
github.com/aws/aws-sdk-go-v2/service/sts v1.43.5/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/benbjohnson/litestream v0.5.11 h1:znCjw6UmY26zTtezE0yaZxrrQYFKsQVK/iX/SUzvtB8=
+github.com/benbjohnson/litestream v0.5.11/go.mod h1:+VmnRIpWs6+ijN+zGECh6iPun0ojNFPKwGJBCNpchWw=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
+github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
+github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
+github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
+github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
+github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
+github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
+github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
+github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
+github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
+github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
+github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k=
+github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
+github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
+github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
+github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
+github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
+github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
+github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
+github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
+github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
+github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
+github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/markusmobius/go-dateparser v1.2.4 h1:2e8XJozaERVxGwsRg72coi51L2aiYqE2gukkdLc85ck=
+github.com/markusmobius/go-dateparser v1.2.4/go.mod h1:CBAUADJuMNhJpyM6IYaWAoFhtKaqnUcznY2cL7gNugY=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
+github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
+github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
+github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
+github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
+github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
+github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
+github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
+github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
+github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
+github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
+github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
+github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
+github.com/nats-io/nats.go v1.44.0 h1:ECKVrDLdh/kDPV1g0gAQ+2+m2KprqZK5O/eJAyAnH2M=
+github.com/nats-io/nats.go v1.44.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
+github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
+github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
+github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
+github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
+github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
+github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
+github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
+github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
+github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
+github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
+github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
+github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
+github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
+github.com/psanford/sqlite3vfs v0.0.0-20251127171934-4e34e03a991a h1:r4YWl0uVObCbBFvj1VsIlyHzgZwZOHvY1KdRaQjzzUc=
+github.com/psanford/sqlite3vfs v0.0.0-20251127171934-4e34e03a991a/go.mod h1:iW4cSew5PAb1sMZiTEkVJAIBNrepaB6jTYjeP47WtI0=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
+github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU=
+github.com/studio-b12/gowebdav v0.11.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
+github.com/superfly/ltx v0.5.1 h1:vGUeBhKvBKZ2s2TsTrOSahz+m0PswfqOWDAD+ICiiYY=
+github.com/superfly/ltx v0.5.1/go.mod h1:Nf50QAIXU/ET4ua3AuQ2fh31MbgNQZA7r/DYx6Os77s=
+github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
+github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
+github.com/testcontainers/testcontainers-go/modules/minio v0.42.0 h1:8yTWNv8ALG7JQHYvm1n9PegH0uJT7dRtWNHf6eQeTRs=
+github.com/testcontainers/testcontainers-go/modules/minio v0.42.0/go.mod h1:bcjonmVMA/aEzxFFIh/FRwSkeZ+fnxwvkGN/Z4EiW28=
+github.com/tetratelabs/wazero v1.2.1 h1:J4X2hrGzJvt+wqltuvcSjHQ7ujQxA9gb6PeMs4qlUWs=
+github.com/tetratelabs/wazero v1.2.1/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ=
+github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
+github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
+github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
+github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
+github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw=
+github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
+github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
+github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
+go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 h1:SpGay3w+nEwMpfVnbqOLH5gY52/foP8RE8UzTZ1pdSE=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
+go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
+go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
+go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
+go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
+go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
+go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
+golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
+golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/api v0.154.0 h1:X7QkVKZBskztmpPKWQXgjJRPA2dJYrL6r+sYPRLj050=
+google.golang.org/api v0.154.0/go.mod h1:qhSMkM85hgqiokIYsrRyKxrjfBeIhgl4Z2JmeRkYylc=
+google.golang.org/genproto v0.0.0-20231212172506-995d672761c0 h1:YJ5pD9rF8o9Qtta0Cmy9rdBwkSjrTCT6XTiUQVOtIos=
+google.golang.org/genproto v0.0.0-20231212172506-995d672761c0/go.mod h1:l/k7rMz0vFTBPy+tFSGvXEd3z+BcoG1k7EHbqm+YBsY=
+google.golang.org/genproto/googleapis/api v0.0.0-20231212172506-995d672761c0 h1:s1w3X6gQxwrLEpxnLd/qXTVLgQE2yXwaOaoa6IlY/+o=
+google.golang.org/genproto/googleapis/api v0.0.0-20231212172506-995d672761c0/go.mod h1:CAny0tYF+0/9rmDB9fahA9YLzX3+AEVl1qXbv5hhj6c=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0 h1:/jFB8jK5R3Sq3i/lmeZO0cATSzFfZaJq1J2Euan3XKU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0/go.mod h1:FUoWkonphQm3RhTS+kOEhF8h0iDpm4tdXolVCeZ9KKA=
+google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU=
+google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
+modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
+modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
+modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
+modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
+modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
+modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
+modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/meta/acls.go b/internal/meta/acls.go
new file mode 100644
index 0000000..79cd3d1
--- /dev/null
+++ b/internal/meta/acls.go
@@ -0,0 +1,39 @@
+package meta
+
+import "context"
+
+// Perm is a repository access level, ordered read < write < admin. It matches
+// the CHECK constraint on acls.perm.
+type Perm string
+
+const (
+ PermRead Perm = "read"
+ PermWrite Perm = "write"
+ PermAdmin Perm = "admin"
+)
+
+// SetACL grants (or updates) user's permission on repo. It is an upsert: a
+// second call for the same (repo, user) replaces the level.
+func (m *Metadata) SetACL(ctx context.Context, repoID, userID int64, perm Perm) error {
+ _, err := m.db.ExecContext(ctx,
+ `INSERT INTO acls (repo_id, user_id, perm) VALUES (?, ?, ?)
+ ON CONFLICT(repo_id, user_id) DO UPDATE SET perm = excluded.perm`,
+ repoID, userID, string(perm))
+ return err
+}
+
+// GetACL returns user's permission on repo, or ErrNotFound when none is set
+// (no access).
+func (m *Metadata) GetACL(ctx context.Context, repoID, userID int64) (Perm, error) {
+ var perm string
+ err := m.db.QueryRowContext(ctx,
+ `SELECT perm FROM acls WHERE repo_id = ? AND user_id = ?`, repoID, userID).
+ Scan(&perm)
+ if isNoRows(err) {
+ return "", ErrNotFound
+ }
+ if err != nil {
+ return "", err
+ }
+ return Perm(perm), nil
+}
diff --git a/internal/meta/meta.go b/internal/meta/meta.go
new file mode 100644
index 0000000..72ad0d3
--- /dev/null
+++ b/internal/meta/meta.go
@@ -0,0 +1,87 @@
+// Package meta is the metadata layer: all mutable forge state — repos, refs,
+// users, tokens, ssh keys, ACLs — in an s3lite-backed SQLite database, per the
+// schema in docs/architecture/storage.md. Refs are the source of truth, and a
+// ref update is a single-transaction compare-and-swap (see CASRef); that CAS is
+// the linearization point behind the single-writer, content-before-pointer
+// model in docs/architecture/safety.md.
+package meta
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ _ "embed"
+ "encoding/hex"
+ "errors"
+ "log/slog"
+ "time"
+
+ "github.com/atmin/s3lite"
+)
+
+//go:embed schema.sql
+var schemaSQL string
+
+// ErrNotFound is returned by the Get* lookups when no row matches.
+var ErrNotFound = errors.New("meta: not found")
+
+// Config opens the metadata database. It mirrors the subset of s3lite.Config
+// the forge needs; the schema is supplied internally, so callers never manage
+// migrations.
+type Config struct {
+ // LocalPath is the on-disk path for the SQLite file. Required.
+ LocalPath string
+ // RestoreFrom is a replica URL to restore from on cold start; empty for a
+ // local-only database (tests).
+ RestoreFrom string
+ // BackupTo is a replica URL to replicate to continuously; empty disables
+ // replication (tests).
+ BackupTo string
+ // S3 configures s3:// replicas.
+ S3 s3lite.S3Config
+ // Logger receives litestream's log records; nil suppresses INFO.
+ Logger *slog.Logger
+}
+
+// Metadata is the query layer over the s3lite-backed database.
+type Metadata struct {
+ db *s3lite.DB
+}
+
+// Open opens (restoring and replicating per cfg) the metadata database and
+// applies the embedded schema. The schema is idempotent, so Open on an
+// existing database leaves it unchanged.
+func Open(ctx context.Context, cfg Config) (*Metadata, error) {
+ db, err := s3lite.Open(ctx, s3lite.Config{
+ LocalPath: cfg.LocalPath,
+ RestoreFrom: cfg.RestoreFrom,
+ BackupTo: cfg.BackupTo,
+ S3: cfg.S3,
+ Logger: cfg.Logger,
+ Migrations: []string{schemaSQL},
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &Metadata{db: db}, nil
+}
+
+// Close flushes replication and closes the database.
+func (m *Metadata) Close() error { return m.db.Close() }
+
+// Sync blocks until the replica has caught up; a no-op without replication.
+func (m *Metadata) Sync(ctx context.Context) error { return m.db.Sync(ctx) }
+
+// HashToken maps a raw personal access token to its stored representation.
+// Tokens are high-entropy random strings, so a single SHA-256 is sufficient —
+// it makes the column indexable for lookup while never storing the raw token.
+func HashToken(raw string) string {
+ sum := sha256.Sum256([]byte(raw))
+ return hex.EncodeToString(sum[:])
+}
+
+// now is the timestamp format for the TEXT *_at columns: RFC 3339, UTC.
+func now() string { return time.Now().UTC().Format(time.RFC3339Nano) }
+
+// isNoRows reports whether err is the "no rows" sentinel.
+func isNoRows(err error) bool { return errors.Is(err, sql.ErrNoRows) }
diff --git a/internal/meta/meta_test.go b/internal/meta/meta_test.go
new file mode 100644
index 0000000..483d9e4
--- /dev/null
+++ b/internal/meta/meta_test.go
@@ -0,0 +1,174 @@
+package meta
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "testing"
+)
+
+// open returns a fresh, local-only Metadata at a temp path (no replication).
+func open(t *testing.T) *Metadata {
+ t.Helper()
+ return openAt(t, filepath.Join(t.TempDir(), "meta.sqlite3"))
+}
+
+func openAt(t *testing.T, path string) *Metadata {
+ t.Helper()
+ m, err := Open(context.Background(), Config{LocalPath: path})
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ t.Cleanup(func() { _ = m.Close() })
+ return m
+}
+
+// seedRepo inserts a repo and returns it; a helper for the ref/ACL tests.
+func seedRepo(t *testing.T, m *Metadata, name string) *Repo {
+ t.Helper()
+ r, err := m.CreateRepo(context.Background(), name, "")
+ if err != nil {
+ t.Fatalf("CreateRepo(%q): %v", name, err)
+ }
+ return r
+}
+
+func TestMigrationsIdempotentAndFreshSchema(t *testing.T) {
+ ctx := context.Background()
+ path := filepath.Join(t.TempDir(), "meta.sqlite3")
+
+ // Fresh DB: schema is usable — a round-trip proves the tables exist.
+ m := openAt(t, path)
+ r, err := m.CreateRepo(ctx, "atmin/dotfiles", "")
+ if err != nil {
+ t.Fatalf("CreateRepo on fresh schema: %v", err)
+ }
+ if r.DefaultBranch != "main" {
+ t.Errorf("default branch = %q, want main", r.DefaultBranch)
+ }
+ if err := m.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ // Re-open the same file: migrations are a no-op and data survives.
+ m2 := openAt(t, path)
+ got, err := m2.GetRepo(ctx, "atmin/dotfiles")
+ if err != nil {
+ t.Fatalf("GetRepo after re-open: %v", err)
+ }
+ if got.ID != r.ID {
+ t.Errorf("repo id after re-open = %d, want %d", got.ID, r.ID)
+ }
+}
+
+func TestRepoCRUD(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+
+ if _, err := m.GetRepo(ctx, "nope"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("GetRepo(missing) = %v, want ErrNotFound", err)
+ }
+
+ seedRepo(t, m, "a/one")
+ b := seedRepo(t, m, "b/two")
+ if b.DefaultBranch != "main" {
+ t.Errorf("default branch = %q, want main", b.DefaultBranch)
+ }
+
+ repos, err := m.ListRepos(ctx)
+ if err != nil {
+ t.Fatalf("ListRepos: %v", err)
+ }
+ if len(repos) != 2 || repos[0].Name != "a/one" || repos[1].Name != "b/two" {
+ t.Errorf("ListRepos = %+v, want [a/one b/two]", repos)
+ }
+
+ // name is UNIQUE.
+ if _, err := m.CreateRepo(ctx, "a/one", ""); err == nil {
+ t.Error("CreateRepo(duplicate name) succeeded, want error")
+ }
+}
+
+func TestUserAndTokenAuth(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+
+ u, err := m.CreateUser(ctx, "atmin")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+
+ const raw = "pat_supersecret_value"
+ tok, err := m.CreateToken(ctx, u.ID, raw, "laptop")
+ if err != nil {
+ t.Fatalf("CreateToken: %v", err)
+ }
+
+ // The raw token is never stored.
+ var stored string
+ if err := m.db.QueryRowContext(ctx, `SELECT hash FROM tokens WHERE id = ?`, tok.ID).Scan(&stored); err != nil {
+ t.Fatalf("read hash: %v", err)
+ }
+ if stored == raw {
+ t.Fatal("raw token stored in tokens.hash")
+ }
+ if stored != HashToken(raw) {
+ t.Errorf("stored hash = %q, want %q", stored, HashToken(raw))
+ }
+
+ // Authentication resolves the owner and stamps last_used.
+ got, err := m.AuthenticateToken(ctx, raw)
+ if err != nil {
+ t.Fatalf("AuthenticateToken: %v", err)
+ }
+ if got.ID != u.ID || got.Handle != "atmin" {
+ t.Errorf("AuthenticateToken = %+v, want user %d atmin", got, u.ID)
+ }
+ toks, err := m.ListTokens(ctx, u.ID)
+ if err != nil {
+ t.Fatalf("ListTokens: %v", err)
+ }
+ if len(toks) != 1 || toks[0].Label != "laptop" || toks[0].LastUsed == nil {
+ t.Errorf("ListTokens = %+v, want one 'laptop' token with last_used set", toks)
+ }
+
+ // A wrong token authenticates no one.
+ if _, err := m.AuthenticateToken(ctx, "pat_wrong"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("AuthenticateToken(wrong) = %v, want ErrNotFound", err)
+ }
+}
+
+func TestACLLookup(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+
+ r := seedRepo(t, m, "atmin/repo")
+ u, err := m.CreateUser(ctx, "atmin")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+
+ if _, err := m.GetACL(ctx, r.ID, u.ID); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("GetACL(unset) = %v, want ErrNotFound", err)
+ }
+
+ if err := m.SetACL(ctx, r.ID, u.ID, PermRead); err != nil {
+ t.Fatalf("SetACL(read): %v", err)
+ }
+ if p, _ := m.GetACL(ctx, r.ID, u.ID); p != PermRead {
+ t.Errorf("GetACL = %q, want read", p)
+ }
+
+ // Upsert replaces the level.
+ if err := m.SetACL(ctx, r.ID, u.ID, PermAdmin); err != nil {
+ t.Fatalf("SetACL(admin): %v", err)
+ }
+ if p, _ := m.GetACL(ctx, r.ID, u.ID); p != PermAdmin {
+ t.Errorf("GetACL after upsert = %q, want admin", p)
+ }
+
+ // The CHECK constraint rejects a bogus level.
+ if err := m.SetACL(ctx, r.ID, u.ID, Perm("root")); err == nil {
+ t.Error("SetACL(bogus perm) succeeded, want CHECK violation")
+ }
+}
diff --git a/internal/meta/refs.go b/internal/meta/refs.go
new file mode 100644
index 0000000..9269ece
--- /dev/null
+++ b/internal/meta/refs.go
@@ -0,0 +1,146 @@
+package meta
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+)
+
+// ZeroSHA is git's all-zero object id, used on the wire for a ref that is being
+// created (old = zero) or deleted (new = zero). CASRef treats it — and the
+// empty string — as "the ref is absent".
+const ZeroSHA = "0000000000000000000000000000000000000000"
+
+// Ref is a mutable pointer: a ref name and the object id it resolves to.
+type Ref struct {
+ Name string
+ SHA string
+ UpdatedAt time.Time
+}
+
+// RefUpdate is one compare-and-swap: advance Name from Old to New. Old is the
+// caller's expected current value (ZeroSHA/"" to require the ref be absent);
+// New is the desired value (ZeroSHA/"" to delete it).
+type RefUpdate struct {
+ Name string
+ Old string
+ New string
+}
+
+// CASMismatchError reports that a ref did not hold the expected value, so the
+// update was rejected. The whole call is rolled back.
+type CASMismatchError struct {
+ Name string
+ Want string // expected old value, normalized ("" means "absent")
+ Got string // actual current value ("" means "absent")
+}
+
+func (e *CASMismatchError) Error() string {
+ return fmt.Sprintf("meta: ref %q CAS mismatch: expected %q, found %q",
+ e.Name, absentAsZero(e.Want), absentAsZero(e.Got))
+}
+
+// ListRefs returns a repository's refs ordered by name.
+func (m *Metadata) ListRefs(ctx context.Context, repoID int64) ([]Ref, error) {
+ rows, err := m.db.QueryContext(ctx,
+ `SELECT name, sha, updated_at FROM refs WHERE repo_id = ? ORDER BY name`, repoID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var refs []Ref
+ for rows.Next() {
+ var (
+ r Ref
+ ts string
+ )
+ if err := rows.Scan(&r.Name, &r.SHA, &ts); err != nil {
+ return nil, err
+ }
+ r.UpdatedAt = parseTime(ts)
+ refs = append(refs, r)
+ }
+ return refs, rows.Err()
+}
+
+// CASRef atomically advances a single ref. It succeeds only if the ref
+// currently holds old; otherwise it returns *CASMismatchError and changes
+// nothing. This is the ref linearization point (safety.md §3).
+func (m *Metadata) CASRef(ctx context.Context, repoID int64, name, old, new string) error {
+ return m.CASRefs(ctx, repoID, []RefUpdate{{Name: name, Old: old, New: new}})
+}
+
+// CASRefs applies several ref updates as one all-or-nothing transaction: if any
+// update's expected old value does not match, none are applied and a
+// *CASMismatchError for the first offending ref is returned. This is the atomic
+// multi-ref push guarantee.
+func (m *Metadata) CASRefs(ctx context.Context, repoID int64, updates []RefUpdate) error {
+ if len(updates) == 0 {
+ return nil
+ }
+ tx, err := m.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback() //nolint:errcheck // no-op after a successful Commit
+
+ ts := now()
+ for _, u := range updates {
+ if err := casOne(ctx, tx, repoID, u, ts); err != nil {
+ return err // deferred Rollback undoes any earlier updates
+ }
+ }
+ return tx.Commit()
+}
+
+// casOne applies one RefUpdate inside tx, enforcing the compare against the
+// ref's current value.
+func casOne(ctx context.Context, tx *sql.Tx, repoID int64, u RefUpdate, ts string) error {
+ var current string
+ err := tx.QueryRowContext(ctx,
+ `SELECT sha FROM refs WHERE repo_id = ? AND name = ?`, repoID, u.Name).Scan(¤t)
+ if err != nil && !isNoRows(err) {
+ return err
+ }
+ // current is "" when the ref is absent.
+
+ want := normalizeSHA(u.Old)
+ if normalizeSHA(current) != want {
+ return &CASMismatchError{Name: u.Name, Want: want, Got: normalizeSHA(current)}
+ }
+
+ next := normalizeSHA(u.New)
+ switch {
+ case next == "": // delete
+ _, err = tx.ExecContext(ctx,
+ `DELETE FROM refs WHERE repo_id = ? AND name = ?`, repoID, u.Name)
+ case current == "": // create
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO refs (repo_id, name, sha, updated_at) VALUES (?, ?, ?, ?)`,
+ repoID, u.Name, next, ts)
+ default: // update
+ _, err = tx.ExecContext(ctx,
+ `UPDATE refs SET sha = ?, updated_at = ? WHERE repo_id = ? AND name = ?`,
+ next, ts, repoID, u.Name)
+ }
+ return err
+}
+
+// normalizeSHA collapses the two spellings of "absent" (empty and ZeroSHA) to
+// the empty string, so comparisons and the create/delete branches are uniform.
+func normalizeSHA(sha string) string {
+ if sha == ZeroSHA {
+ return ""
+ }
+ return sha
+}
+
+// absentAsZero renders "absent" as ZeroSHA for error messages.
+func absentAsZero(sha string) string {
+ if sha == "" {
+ return ZeroSHA
+ }
+ return sha
+}
diff --git a/internal/meta/refs_test.go b/internal/meta/refs_test.go
new file mode 100644
index 0000000..282592f
--- /dev/null
+++ b/internal/meta/refs_test.go
@@ -0,0 +1,127 @@
+package meta
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+const (
+ shaA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ shaB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ shaC = "cccccccccccccccccccccccccccccccccccccccc"
+)
+
+// refSHA returns the current sha of a ref, or "" if absent.
+func refSHA(t *testing.T, m *Metadata, repoID int64, name string) string {
+ t.Helper()
+ refs, err := m.ListRefs(context.Background(), repoID)
+ if err != nil {
+ t.Fatalf("ListRefs: %v", err)
+ }
+ for _, r := range refs {
+ if r.Name == name {
+ return r.SHA
+ }
+ }
+ return ""
+}
+
+func TestCASRefCreateUpdateDelete(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ r := seedRepo(t, m, "atmin/repo")
+ const main = "refs/heads/main"
+
+ // Create: old is the zero id (ref must be absent).
+ if err := m.CASRef(ctx, r.ID, main, ZeroSHA, shaA); err != nil {
+ t.Fatalf("CASRef create: %v", err)
+ }
+ if got := refSHA(t, m, r.ID, main); got != shaA {
+ t.Fatalf("after create sha = %q, want %q", got, shaA)
+ }
+
+ // Update: old must match the current value.
+ if err := m.CASRef(ctx, r.ID, main, shaA, shaB); err != nil {
+ t.Fatalf("CASRef update: %v", err)
+ }
+ if got := refSHA(t, m, r.ID, main); got != shaB {
+ t.Fatalf("after update sha = %q, want %q", got, shaB)
+ }
+
+ // Delete: new is the zero id.
+ if err := m.CASRef(ctx, r.ID, main, shaB, ZeroSHA); err != nil {
+ t.Fatalf("CASRef delete: %v", err)
+ }
+ if got := refSHA(t, m, r.ID, main); got != "" {
+ t.Fatalf("after delete sha = %q, want absent", got)
+ }
+}
+
+func TestCASRefRejectsAndRollsBack(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ r := seedRepo(t, m, "atmin/repo")
+ const main = "refs/heads/main"
+
+ if err := m.CASRef(ctx, r.ID, main, ZeroSHA, shaA); err != nil {
+ t.Fatalf("seed create: %v", err)
+ }
+
+ // Stale expected value: a non-fast-forward the server must refuse.
+ var mm *CASMismatchError
+ err := m.CASRef(ctx, r.ID, main, shaB, shaC)
+ if !errors.As(err, &mm) {
+ t.Fatalf("CASRef mismatch = %v, want *CASMismatchError", err)
+ }
+ if mm.Got != shaA || mm.Want != shaB {
+ t.Errorf("mismatch = %+v, want Want=%s Got=%s", mm, shaB, shaA)
+ }
+ // The ref is unchanged — the rejected update rolled back.
+ if got := refSHA(t, m, r.ID, main); got != shaA {
+ t.Errorf("after rejected update sha = %q, want %q (unchanged)", got, shaA)
+ }
+
+ // Create against an already-present ref is also a mismatch.
+ if err := m.CASRef(ctx, r.ID, main, ZeroSHA, shaB); !errors.As(err, &mm) {
+ t.Errorf("CASRef create-over-existing = %v, want *CASMismatchError", err)
+ }
+}
+
+func TestCASRefsAtomicMultiRef(t *testing.T) {
+ ctx := context.Background()
+ m := open(t)
+ r := seedRepo(t, m, "atmin/repo")
+ const (
+ main = "refs/heads/main"
+ dev = "refs/heads/dev"
+ )
+
+ // One bad update in the batch rolls back the whole thing — the good
+ // create for main must not survive.
+ err := m.CASRefs(ctx, r.ID, []RefUpdate{
+ {Name: main, Old: ZeroSHA, New: shaA},
+ {Name: dev, Old: shaB, New: shaC}, // wrong: dev is absent, not shaB
+ })
+ var mm *CASMismatchError
+ if !errors.As(err, &mm) || mm.Name != dev {
+ t.Fatalf("CASRefs = %v, want *CASMismatchError for %q", err, dev)
+ }
+ if got := refSHA(t, m, r.ID, main); got != "" {
+ t.Errorf("main sha = %q after failed batch, want absent (rolled back)", got)
+ }
+
+ // An all-valid batch commits both.
+ if err := m.CASRefs(ctx, r.ID, []RefUpdate{
+ {Name: main, Old: ZeroSHA, New: shaA},
+ {Name: dev, Old: ZeroSHA, New: shaB},
+ }); err != nil {
+ t.Fatalf("CASRefs valid batch: %v", err)
+ }
+ if got := refSHA(t, m, r.ID, main); got != shaA {
+ t.Errorf("main sha = %q, want %q", got, shaA)
+ }
+ if got := refSHA(t, m, r.ID, dev); got != shaB {
+ t.Errorf("dev sha = %q, want %q", got, shaB)
+ }
+}
diff --git a/internal/meta/repos.go b/internal/meta/repos.go
new file mode 100644
index 0000000..1df90dd
--- /dev/null
+++ b/internal/meta/repos.go
@@ -0,0 +1,88 @@
+package meta
+
+import (
+ "context"
+ "time"
+)
+
+// Repo is a hosted repository. DefaultBranch backs the derived HEAD (see
+// storage.md — HEAD is not a stored ref).
+type Repo struct {
+ ID int64
+ Name string
+ DefaultBranch string
+ CreatedAt time.Time
+}
+
+// CreateRepo inserts a repository. defaultBranch defaults to "main" when empty.
+func (m *Metadata) CreateRepo(ctx context.Context, name, defaultBranch string) (*Repo, error) {
+ if defaultBranch == "" {
+ defaultBranch = "main"
+ }
+ created := now()
+ res, err := m.db.ExecContext(ctx,
+ `INSERT INTO repos (name, default_branch, created_at) VALUES (?, ?, ?)`,
+ name, defaultBranch, created)
+ if err != nil {
+ return nil, err
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return nil, err
+ }
+ return &Repo{ID: id, Name: name, DefaultBranch: defaultBranch, CreatedAt: parseTime(created)}, nil
+}
+
+// GetRepo returns the repository named name, or ErrNotFound.
+func (m *Metadata) GetRepo(ctx context.Context, name string) (*Repo, error) {
+ var (
+ r Repo
+ ts string
+ )
+ err := m.db.QueryRowContext(ctx,
+ `SELECT id, name, default_branch, created_at FROM repos WHERE name = ?`, name).
+ Scan(&r.ID, &r.Name, &r.DefaultBranch, &ts)
+ if isNoRows(err) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ r.CreatedAt = parseTime(ts)
+ return &r, nil
+}
+
+// ListRepos returns all repositories ordered by name.
+func (m *Metadata) ListRepos(ctx context.Context) ([]Repo, error) {
+ rows, err := m.db.QueryContext(ctx,
+ `SELECT id, name, default_branch, created_at FROM repos ORDER BY name`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var repos []Repo
+ for rows.Next() {
+ var (
+ r Repo
+ ts string
+ )
+ if err := rows.Scan(&r.ID, &r.Name, &r.DefaultBranch, &ts); err != nil {
+ return nil, err
+ }
+ r.CreatedAt = parseTime(ts)
+ repos = append(repos, r)
+ }
+ return repos, rows.Err()
+}
+
+// parseTime parses an RFC 3339 timestamp written by now(); a parse failure
+// yields the zero time rather than an error, since the column is always
+// written by this package.
+func parseTime(s string) time.Time {
+ t, err := time.Parse(time.RFC3339Nano, s)
+ if err != nil {
+ return time.Time{}
+ }
+ return t
+}
diff --git a/internal/meta/schema.sql b/internal/meta/schema.sql
new file mode 100644
index 0000000..32b5530
--- /dev/null
+++ b/internal/meta/schema.sql
@@ -0,0 +1,49 @@
+-- gitmote metadata schema — the mutable forge state, per
+-- docs/architecture/storage.md. Runs on every Open, so every statement is
+-- idempotent (CREATE ... IF NOT EXISTS); s3lite has no version table.
+
+CREATE TABLE IF NOT EXISTS repos (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE, -- "atmin/dotfiles"
+ default_branch TEXT NOT NULL DEFAULT 'main',
+ created_at TEXT NOT NULL
+);
+
+-- the mutable pointers — the whole reason this DB exists in the design
+CREATE TABLE IF NOT EXISTS refs (
+ repo_id INTEGER NOT NULL REFERENCES repos(id),
+ name TEXT NOT NULL, -- "refs/heads/main"
+ sha TEXT NOT NULL, -- object id
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (repo_id, name)
+);
+
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY,
+ handle TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS tokens ( -- HTTP personal access tokens
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ hash TEXT NOT NULL, -- hash of the PAT, never the raw token
+ label TEXT,
+ created_at TEXT NOT NULL,
+ last_used TEXT
+);
+
+CREATE TABLE IF NOT EXISTS ssh_keys ( -- deferred transport, schema ready
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ pubkey TEXT NOT NULL, -- OpenSSH authorized_keys line
+ label TEXT,
+ created_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS acls (
+ repo_id INTEGER NOT NULL REFERENCES repos(id),
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ perm TEXT NOT NULL CHECK (perm IN ('read','write','admin')),
+ PRIMARY KEY (repo_id, user_id)
+);
diff --git a/internal/meta/users.go b/internal/meta/users.go
new file mode 100644
index 0000000..485ff19
--- /dev/null
+++ b/internal/meta/users.go
@@ -0,0 +1,140 @@
+package meta
+
+import (
+ "context"
+ "database/sql"
+ "time"
+)
+
+// User is a forge account.
+type User struct {
+ ID int64
+ Handle string
+ CreatedAt time.Time
+}
+
+// Token is a stored personal access token. The raw token is never persisted —
+// only Hash (see HashToken) — so it is absent here too.
+type Token struct {
+ ID int64
+ UserID int64
+ Label string
+ CreatedAt time.Time
+ LastUsed *time.Time
+}
+
+// CreateUser inserts a user.
+func (m *Metadata) CreateUser(ctx context.Context, handle string) (*User, error) {
+ created := now()
+ res, err := m.db.ExecContext(ctx,
+ `INSERT INTO users (handle, created_at) VALUES (?, ?)`, handle, created)
+ if err != nil {
+ return nil, err
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return nil, err
+ }
+ return &User{ID: id, Handle: handle, CreatedAt: parseTime(created)}, nil
+}
+
+// GetUser returns the user with the given handle, or ErrNotFound.
+func (m *Metadata) GetUser(ctx context.Context, handle string) (*User, error) {
+ var (
+ u User
+ ts string
+ )
+ err := m.db.QueryRowContext(ctx,
+ `SELECT id, handle, created_at FROM users WHERE handle = ?`, handle).
+ Scan(&u.ID, &u.Handle, &ts)
+ if isNoRows(err) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ u.CreatedAt = parseTime(ts)
+ return &u, nil
+}
+
+// CreateToken stores a personal access token for a user, keeping only the hash
+// of raw. The caller keeps raw; it cannot be recovered from the database.
+func (m *Metadata) CreateToken(ctx context.Context, userID int64, raw, label string) (*Token, error) {
+ created := now()
+ res, err := m.db.ExecContext(ctx,
+ `INSERT INTO tokens (user_id, hash, label, created_at) VALUES (?, ?, ?, ?)`,
+ userID, HashToken(raw), label, created)
+ if err != nil {
+ return nil, err
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return nil, err
+ }
+ return &Token{ID: id, UserID: userID, Label: label, CreatedAt: parseTime(created)}, nil
+}
+
+// AuthenticateToken resolves a raw token to its owning user and stamps the
+// token's last_used. It returns ErrNotFound when no token matches — callers
+// must not distinguish "unknown token" from any other auth failure to the
+// client.
+func (m *Metadata) AuthenticateToken(ctx context.Context, raw string) (*User, error) {
+ hash := HashToken(raw)
+ var (
+ u User
+ userID int64
+ ts string
+ )
+ err := m.db.QueryRowContext(ctx,
+ `SELECT u.id, u.handle, u.created_at
+ FROM tokens t JOIN users u ON u.id = t.user_id
+ WHERE t.hash = ?`, hash).
+ Scan(&userID, &u.Handle, &ts)
+ if isNoRows(err) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ u.ID = userID
+ u.CreatedAt = parseTime(ts)
+
+ if _, err := m.db.ExecContext(ctx,
+ `UPDATE tokens SET last_used = ? WHERE hash = ?`, now(), hash); err != nil {
+ return nil, err
+ }
+ return &u, nil
+}
+
+// ListTokens returns a user's tokens (metadata only, never the hash) ordered by
+// creation time.
+func (m *Metadata) ListTokens(ctx context.Context, userID int64) ([]Token, error) {
+ rows, err := m.db.QueryContext(ctx,
+ `SELECT id, user_id, label, created_at, last_used
+ FROM tokens WHERE user_id = ? ORDER BY created_at, id`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var tokens []Token
+ for rows.Next() {
+ var (
+ t Token
+ label sql.NullString
+ created string
+ lastUsed sql.NullString
+ )
+ if err := rows.Scan(&t.ID, &t.UserID, &label, &created, &lastUsed); err != nil {
+ return nil, err
+ }
+ t.Label = label.String
+ t.CreatedAt = parseTime(created)
+ if lastUsed.Valid {
+ lu := parseTime(lastUsed.String)
+ t.LastUsed = &lu
+ }
+ tokens = append(tokens, t)
+ }
+ return tokens, rows.Err()
+}
diff --git a/tasks/03-metadata-s3lite.md b/tasks/03-metadata-s3lite.md
deleted file mode 100644
index a9af41d..0000000
--- a/tasks/03-metadata-s3lite.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# 03 — Metadata layer (s3lite)
-
-Depends on: 01.
-
-## Spec
-
-All mutable forge state — refs, repos, users, tokens, ACLs — in s3lite, per the
-schema in [storage.md](../docs/architecture/storage.md). Refs are the source of
-truth; a ref update is a single-transaction compare-and-swap
-([safety.md](../docs/architecture/safety.md) §3). Durability (WAL → S3) is
-s3lite's own concern.
-
-## Current
-
-Nothing yet. s3lite is a working dependency to import.
-
-## Change
-
-- Add `github.com/atmin/s3lite`; open it as `*sql.DB` inside a `Metadata` type.
-- `internal/meta/schema.sql` + migrations run automatically on Open (idempotent)
- — the tables from storage.md: `repos`, `refs`, `users`, `tokens`, `ssh_keys`,
- `acls`.
-- `internal/meta/` query layer: `ListRefs`, `CASRef(repo, name, old, new)` (one
- transaction; a multi-ref variant for atomic push), repo/user/token CRUD, ACL
- lookup. Tokens stored **hashed**, never raw.
-- Test seed helpers (insert repo/user/token/refs directly) for later tasks.
-
-## Verify
-
-- Migrations create the schema on a fresh (empty) s3lite; re-open is a no-op.
-- CAS unit tests: success on match; reject + rollback on mismatch; multi-ref
- is all-or-nothing.
-- Non-breaking: DB layer + tests, no route yet.
diff --git a/tasks/README.md b/tasks/README.md
index 2fb0bb0..bead2f2 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -6,7 +6,6 @@ lands** (commits are the record — this list never becomes a changelog).
Ordered by dependency; each task lands in a non-breaking, tested state.
-- [03-metadata-s3lite.md](03-metadata-s3lite.md) — Embed s3lite; schema, migrations, query layer, ref CAS.
- [04-repo-materialization.md](04-repo-materialization.md) — Build ephemeral bare repos from refs + hydrated objects.
- [05-read-path-clone.md](05-read-path-clone.md) — Smart-HTTP `upload-pack`; `git clone` works.
- [06-auth-pat-acl.md](06-auth-pat-acl.md) — Bearer PATs + per-repo ACL enforcement.