diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d3fe29..6195bb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,3 +111,108 @@ jobs: ``` El attestation queda registrado en el transparency log de Sigstore (Rekor), auditable por cualquier tercero independiente del operador. + + ## Cliente git-gost + + Descarga el binario de tu plataforma (git-gost-darwin-arm64, git-gost-darwin-amd64, git-gost-linux-amd64, git-gost-linux-arm64 o git-gost-windows-amd64.exe) y su archivo .sha256, verifica el hash y auto-instálalo: + + ```bash + ./git-gost-darwin-arm64 install + ``` + + El comando copia el binario como `git-gost` en `~/.local/bin` y añade ese directorio al PATH de tu shell (en Windows indica el directorio a añadir manualmente). + + build-client: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - os: darwin + arch: amd64 + - os: darwin + arch: arm64 + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: windows + arch: amd64 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 + with: + go-version-file: go.mod + + - name: Get commit info + id: info + run: | + short=$(git rev-parse --short HEAD) + echo "short=$short" >> "$GITHUB_OUTPUT" + echo "built=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + echo "ver=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + else + echo "ver=$short" >> "$GITHUB_OUTPUT" + fi + + - name: Build git-gost (${{ matrix.os }}/${{ matrix.arch }}) + id: build + env: + CGO_ENABLED: "0" + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + run: | + EXT="" + if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi + BIN="git-gost-${{ matrix.os }}-${{ matrix.arch }}$EXT" + go build \ + -trimpath \ + -ldflags="-s -w \ + -X 'github.com/livrasand/gitGost/internal/cli.version=${{ steps.info.outputs.ver }}'" \ + -o "$BIN" \ + ./cmd/gost + echo "bin=$BIN" >> "$GITHUB_OUTPUT" + + - name: SHA-256 del binario + id: sha + run: | + BIN="${{ steps.build.outputs.bin }}" + HASH=$(sha256sum "$BIN" | awk '{print $1}') + echo "hash=$HASH" >> "$GITHUB_OUTPUT" + echo "### git-gost SHA-256 (${{ matrix.os }}/${{ matrix.arch }})" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "$BIN $HASH" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + + - name: Attest build provenance (Sigstore) + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 + with: + subject-path: ${{ steps.build.outputs.bin }} + + - name: Publicar SHA-256 como artefacto + run: | + BIN="${{ steps.build.outputs.bin }}" + echo "${{ steps.sha.outputs.hash }} $BIN" > "$BIN.sha256" + echo "commit: ${{ steps.info.outputs.short }}" >> "$BIN.sha256" + echo "built: ${{ steps.info.outputs.built }}" >> "$BIN.sha256" + + - name: Upload artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f + with: + name: git-gost-${{ matrix.os }}-${{ matrix.arch }} + path: | + ${{ steps.build.outputs.bin }} + ${{ steps.build.outputs.bin }}.sha256 + retention-days: 90 + + - name: Create/update release on tags + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe + with: + files: | + ${{ steps.build.outputs.bin }} + ${{ steps.build.outputs.bin }}.sha256 diff --git a/cmd/gost/main.go b/cmd/gost/main.go new file mode 100644 index 0000000..436e527 --- /dev/null +++ b/cmd/gost/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/livrasand/gitGost/internal/cli" +) + +func main() { + os.Exit(cli.Run(os.Args[1:])) +} diff --git a/go.mod b/go.mod index 09b5e23..7b68098 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-git/go-git/v5 v5.19.1 github.com/joho/godotenv v1.5.1 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.55.0 ) require ( @@ -19,6 +20,7 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -29,6 +31,7 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect @@ -37,8 +40,10 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -51,4 +56,7 @@ require ( golang.org/x/text v0.39.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 9d8ef8d..7c27c70 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= @@ -59,6 +61,12 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -85,6 +93,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= @@ -95,6 +105,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= @@ -129,9 +141,13 @@ golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -148,6 +164,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -161,3 +179,31 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +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.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/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.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +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.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/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.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +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/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..b68ae1d --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,583 @@ +package cli + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/livrasand/gitGost/internal/jobs" +) + +// version del CLI; puede inyectarse con -ldflags en el build. +var version = "0.1.0" + +// Run despacha el subcomando de `git gost ...`. Devuelve el código de salida. +func Run(args []string) int { + if len(args) == 0 { + printUsage() + return 0 + } + + switch args[0] { + case "install": + return cmdInstall() + case "clone": + return cmdClone(args[1:]) + case "fetch": + return cmdGitJob("fetch", args[1:]) + case "pull": + return cmdGitJob("pull", args[1:]) + case "push": + return cmdGitJob("push", args[1:]) + case "jobs": + return cmdJobs(args[1:]) + case "watch": + return cmdWatch(args[1:]) + case "pause": + return cmdPause(args[1:]) + case "resume": + return cmdResume(args[1:]) + case "cancel": + return cmdCancel(args[1:]) + case "run": // interno: ejecuta un job en background + return cmdRun(args[1:]) + case "help", "-h", "--help": + printUsage() + return 0 + case "version", "--version", "-v": + fmt.Printf("git-gost %s\n", version) + return 0 + default: + // Pass-through: cualquier comando Git sin lógica especial. + return passthrough(args) + } +} + +// passthrough reenvía el comando a Git (status, log, branch, diff, ...). +func passthrough(args []string) int { + cmd := exec.Command("git", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode() + } + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + return 0 +} + +// dataDir devuelve el directorio de datos del cliente (env GITGOST_HOME o ~/.gitgost). +func dataDir() string { + if v := os.Getenv("GITGOST_HOME"); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil { + return ".gitgost" + } + return filepath.Join(home, ".gitgost") +} + +func dbPath() string { + return filepath.Join(dataDir(), "gitgost.db") +} + +func openStore() (*jobs.Store, error) { + return jobs.Open(dbPath()) +} + +// cmdInstall prepara el entorno del cliente: crea la cola SQLite y, si el +// binario no está disponible como 'git-gost' en el PATH, se auto-instala en +// ~/.local/bin y añade ese directorio al PATH del shell. +func cmdInstall() int { + s, err := openStore() + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: instalación fallida: %v\n", err) + return 1 + } + s.Close() + + if err := selfInstall(); err != nil { + fmt.Fprintf(os.Stderr, "aviso: %v\n", err) + } + + fmt.Println("git gost install: OK") + fmt.Printf("Cola de jobs: %s\n", dbPath()) + fmt.Println("Uso: git gost clone | git gost jobs | git gost watch ") + return 0 +} + +// selfInstall copia el binario en ejecución como 'git-gost' a ~/.local/bin y, +// en Unix, añade ese directorio al PATH del shell si no está ya. No hace nada +// si ya hay un 'git-gost' disponible en el PATH. +func selfInstall() error { + if _, err := exec.LookPath("git-gost"); err == nil { + return nil + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("localizar el binario en ejecución: %w", err) + } + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("localizar el directorio home: %w", err) + } + + binName := "git-gost" + if runtime.GOOS == "windows" { + binName += ".exe" + } + binDir := filepath.Join(home, ".local", "bin") + target := filepath.Join(binDir, binName) + + if exe != target { + if err := os.MkdirAll(binDir, 0o755); err != nil { + return fmt.Errorf("crear %s: %w", binDir, err) + } + if err := copyFile(exe, target); err != nil { + return fmt.Errorf("copiar binario a %s: %w", target, err) + } + if err := os.Chmod(target, 0o755); err != nil { + return fmt.Errorf("dar permisos a %s: %w", target, err) + } + fmt.Printf("git-gost instalado en %s\n", target) + } + + if runtime.GOOS == "windows" { + // En Windows no se editan rc files: se indica el directorio a añadir. + fmt.Printf("Añade %s a tu PATH para usar 'git gost ...'.\n", binDir) + return nil + } + return addToPath(binDir, home) +} + +// addToPath añade dir al PATH del shell del usuario si no está ya referenciado. +func addToPath(dir, home string) error { + shell := filepath.Base(os.Getenv("SHELL")) + var rc, line string + switch shell { + case "fish": + rc = filepath.Join(home, ".config", "fish", "config.fish") + line = fmt.Sprintf("fish_add_path %q", dir) + case "bash": + rc = filepath.Join(home, ".bashrc") + if _, err := os.Stat(filepath.Join(home, ".bash_profile")); err == nil { + rc = filepath.Join(home, ".bash_profile") + } + line = fmt.Sprintf(`export PATH=%q:$PATH`, dir) + default: // zsh y cualquier otro shell + rc = filepath.Join(home, ".zshrc") + line = fmt.Sprintf(`export PATH=%q:$PATH`, dir) + } + + data, err := os.ReadFile(rc) + if err != nil && !os.IsNotExist(err) { + return err + } + if strings.Contains(string(data), dir) { + return nil // el directorio ya está en el PATH del shell + } + + if err := os.MkdirAll(filepath.Dir(rc), 0o755); err != nil { + return fmt.Errorf("crear %s: %w", filepath.Dir(rc), err) + } + f, err := os.OpenFile(rc, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + if _, err := fmt.Fprintf(f, "\n# git-gost\n%s\n", line); err != nil { + return err + } + fmt.Printf("Añadido %s al PATH en %s (recarga tu shell o ejecuta: source %s)\n", dir, rc, rc) + return nil +} + +// copyFile copia un archivo (los permisos se ajustan aparte). +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} + +// cmdClone crea un job de clone. Por defecto corre en background; con -f/--foreground en primer plano. +func cmdClone(args []string) int { + foreground, rest, err := parseFlags(args) + if err != nil { + return 1 + } + if len(rest) == 0 { + fmt.Fprintln(os.Stderr, "uso: git gost clone [directorio] [-f]") + return 1 + } + url, target := rest[0], "" + if len(rest) > 1 { + target = rest[1] + } + if target == "" { + target = defaultCloneDir(url) + } + + rewritten, err := RewriteURL(ServerBase(), url) + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + + s, err := openStore() + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + defer s.Close() + + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + id, err := s.Create(&jobs.Job{ + Operation: "clone", + URL: rewritten, + Origin: url, + Target: target, + CWD: cwd, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: crear job: %v\n", err) + return 1 + } + + if foreground { + return runForeground(s, id) + } + return launchBackground(s, id) +} + +// defaultCloneDir deriva el directorio destino de un clone a partir de la URL +// original (basename del repo, sin .git), igual que hace `git clone`. +func defaultCloneDir(raw string) string { + u, err := parseRepoURL(raw) + if err != nil { + return "repo" + } + parts := splitPath(u.Path) + if len(parts) == 0 { + return "repo" + } + return strings.TrimSuffix(parts[len(parts)-1], ".git") +} + +// cmdGitJob crea un job para fetch/pull/push en el repositorio actual. +func cmdGitJob(op string, args []string) int { + foreground, rest, err := parseFlags(args) + if err != nil { + return 1 + } + + s, err := openStore() + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + defer s.Close() + + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + id, err := s.Create(&jobs.Job{ + Operation: op, + Args: rest, + CWD: cwd, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: crear job: %v\n", err) + return 1 + } + + if foreground { + return runForeground(s, id) + } + return launchBackground(s, id) +} + +// parseFlags extrae -f/--foreground del resto de argumentos. +func parseFlags(args []string) (foreground bool, rest []string, err error) { + for _, a := range args { + switch a { + case "-f", "--foreground": + foreground = true + default: + rest = append(rest, a) + } + } + return foreground, rest, nil +} + +// runForeground ejecuta el job en primer plano y muestra el resultado. +func runForeground(s *jobs.Store, id int64) int { + if err := jobs.Run(s, id); err != nil { + job, gerr := s.Get(id) + if gerr == nil && job.Error != "" { + fmt.Fprintf(os.Stderr, "git-gost: %s\n", job.Error) + } else { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + } + return 1 + } + fmt.Printf("Job %d: completed\n", id) + return 0 +} + +// launchBackground lanza `git-gost run ` como proceso independiente. +func launchBackground(s *jobs.Store, id int64) int { + pid, err := startBackground(id) + if err != nil { + _ = s.SetState(id, jobs.StateFailed) + _ = s.SetError(id, err.Error()) + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + _ = s.SetPID(id, pid) + fmt.Printf("Job created.\n\nID: %d\nRunning in background...\n\nUse 'git gost watch %d' to follow progress.\n", id, id) + return 0 +} + +// cmdRun ejecuta un job de la cola (invocado como proceso hijo en background). +func cmdRun(args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "uso interno: git gost run ") + return 1 + } + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: id inválido: %s\n", args[0]) + return 1 + } + s, err := openStore() + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + defer s.Close() + if err := jobs.Run(s, id); err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + return 0 +} + +// cmdJobs lista los jobs recientes de la cola. +func cmdJobs(args []string) int { + s, err := openStore() + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + defer s.Close() + + limit := 50 + for _, a := range args { + if n, err := strconv.Atoi(a); err == nil && n > 0 { + limit = n + } + } + + list, err := s.List(limit) + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + + fmt.Printf("%-6s %-10s %-10s %s\n", "ID", "STATUS", "OPERATION", "TARGET") + for _, j := range list { + target := j.Target + if target == "" { + if j.URL != "" { + target = j.URL + } else { + target = "." + } + } + fmt.Printf("%-6d %-10s %-10s %s\n", j.ID, strings.ToUpper(j.State), j.Operation, target) + } + return 0 +} + +// cmdWatch sigue el progreso de un job hasta que termina. +func cmdWatch(args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "uso: git gost watch ") + return 1 + } + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: id inválido: %s\n", args[0]) + return 1 + } + s, err := openStore() + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + defer s.Close() + + last := "" + for { + job, err := s.Get(id) + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + if job.Progress != last { + fmt.Printf("[%s] %s\n", strings.ToUpper(job.State), job.Progress) + last = job.Progress + } + if job.State == jobs.StateCompleted || job.State == jobs.StateFailed { + if job.State == jobs.StateFailed && job.Error != "" { + fmt.Printf("[FAILED] %s\n", job.Error) + } else { + fmt.Printf("[COMPLETED] Job %d terminado\n", id) + } + return 0 + } + time.Sleep(500 * time.Millisecond) + } +} + +// cmdPause pausa un job en cola o en ejecución. +func cmdPause(args []string) int { + return signalCommand("pause", args, func(j *jobs.Job, s *jobs.Store) (int, error) { + switch j.State { + case jobs.StateQueued, jobs.StateRetrying: + return 0, s.SetState(j.ID, jobs.StatePaused) + case jobs.StateRunning: + if err := signalJob(j.PID, sigStop); err != nil { + return 0, err + } + return 0, s.SetState(j.ID, jobs.StatePaused) + default: + return 0, fmt.Errorf("el job %d no se puede pausar (estado: %s)", j.ID, j.State) + } + }) +} + +// cmdResume reanuda un job pausado. +func cmdResume(args []string) int { + return signalCommand("resume", args, func(j *jobs.Job, s *jobs.Store) (int, error) { + if j.State != jobs.StatePaused { + return 0, fmt.Errorf("el job %d no está pausado (estado: %s)", j.ID, j.State) + } + if j.PID > 0 { + if err := signalJob(j.PID, sigCont); err != nil { + return 0, err + } + return 0, s.SetState(j.ID, jobs.StateRunning) + } + // Pausado sin proceso (estaba en cola): relanzar en background. + pid, err := startBackground(j.ID) + if err != nil { + return 0, err + } + _ = s.SetPID(j.ID, pid) + return 0, s.SetState(j.ID, jobs.StateQueued) + }) +} + +// cmdCancel cancela un job pendiente o en ejecución. +func cmdCancel(args []string) int { + return signalCommand("cancel", args, func(j *jobs.Job, s *jobs.Store) (int, error) { + switch j.State { + case jobs.StateQueued, jobs.StateRetrying, jobs.StatePaused: + _ = s.SetState(j.ID, jobs.StateFailed) + return 0, s.SetError(j.ID, "cancelled") + case jobs.StateRunning: + _ = signalJob(j.PID, sigTerm) + _ = s.SetState(j.ID, jobs.StateFailed) + return 0, s.SetError(j.ID, "cancelled") + default: + return 0, fmt.Errorf("el job %d ya terminó (%s)", j.ID, j.State) + } + }) +} + +// signalCommand resuelve el id, aplica la acción y reporta el resultado. +func signalCommand(name string, args []string, action func(*jobs.Job, *jobs.Store) (int, error)) int { + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "uso: git gost %s \n", name) + return 1 + } + id, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: id inválido: %s\n", args[0]) + return 1 + } + s, err := openStore() + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + defer s.Close() + + j, err := s.Get(id) + if err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + if _, err := action(j, s); err != nil { + fmt.Fprintf(os.Stderr, "git-gost: %v\n", err) + return 1 + } + fmt.Printf("Job %d: %s\n", id, name) + return 0 +} + +func printUsage() { + fmt.Print(`git-gost: una capa de red resiliente para Git (Fase 0). + +Uso: + git gost clone [directorio] Clonar vía servidor gitGost (background por defecto) + git gost fetch [args...] Crear job de fetch en el repo actual + git gost pull [args...] Crear job de pull en el repo actual + git gost push [args...] Crear job de push en el repo actual + git gost jobs [n] Listar los últimos n jobs (por defecto 50) + git gost watch Seguir el progreso de un job + git gost pause Pausar un job + git gost resume Reanudar un job + git gost cancel Cancelar un job + git gost install Preparar el entorno del cliente + git gost version Mostrar versión + +Cualquier otro comando (status, log, branch, ...) se reenvía a Git. + +Opciones de jobs: + -f, --foreground Ejecutar en primer plano mostrando el progreso en vivo + +Variables de entorno: + GITGOST_SERVER URL base del servidor gitGost (por defecto https://gitgost.fly.dev) + GITGOST_HOME Directorio de datos (por defecto ~/.gitgost) +`) +} diff --git a/internal/cli/proc_unix.go b/internal/cli/proc_unix.go new file mode 100644 index 0000000..1b4cad4 --- /dev/null +++ b/internal/cli/proc_unix.go @@ -0,0 +1,51 @@ +//go:build !windows + +package cli + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "syscall" +) + +var ( + sigStop = syscall.SIGSTOP + sigCont = syscall.SIGCONT + sigTerm = syscall.SIGTERM +) + +// startBackground lanza `git-gost run ` como proceso independiente en su +// propio grupo de procesos, de modo que pause/resume/cancel puedan señalarlo. +func startBackground(id int64) (int, error) { + self, err := os.Executable() + if err != nil { + return 0, err + } + cmd := exec.Command(self, "run", strconv.FormatInt(id, 10)) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return 0, err + } + defer devNull.Close() + cmd.Stdin = devNull + cmd.Stdout = devNull + cmd.Stderr = devNull + if err := cmd.Start(); err != nil { + return 0, err + } + // Capturar el PID antes de Release: en Go 1.25, Release invalida Pid (-1). + pid := cmd.Process.Pid + cmd.Process.Release() + return pid, nil +} + +// signalJob envía una señal a todo el grupo de procesos del job. +func signalJob(pid int, sig syscall.Signal) error { + if pid <= 0 { + return fmt.Errorf("el job no tiene proceso en background activo") + } + return syscall.Kill(-pid, sig) +} diff --git a/internal/cli/proc_windows.go b/internal/cli/proc_windows.go new file mode 100644 index 0000000..dbbdf1a --- /dev/null +++ b/internal/cli/proc_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package cli + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "syscall" +) + +var ( + sigStop = syscall.Signal(0) + sigCont = syscall.Signal(0) + sigTerm = syscall.Signal(0) +) + +// startBackground lanza `git-gost run ` como proceso independiente. +func startBackground(id int64) (int, error) { + self, err := os.Executable() + if err != nil { + return 0, err + } + cmd := exec.Command(self, "run", strconv.FormatInt(id, 10)) + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, + } + devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return 0, err + } + defer devNull.Close() + cmd.Stdin = devNull + cmd.Stdout = devNull + cmd.Stderr = devNull + if err := cmd.Start(); err != nil { + return 0, err + } + // Capturar el PID antes de Release: en Go 1.25, Release invalida Pid (-1). + pid := cmd.Process.Pid + cmd.Process.Release() + return pid, nil +} + +// signalJob no está soportado en Windows en esta fase: no hay pausa real por +// señales y cancelar un proceso en ejecución requiere taskkill. +func signalJob(pid int, sig syscall.Signal) error { + return fmt.Errorf("pause/resume/cancel en ejecución no está soportado en Windows en esta fase") +} diff --git a/internal/cli/rewrite.go b/internal/cli/rewrite.go new file mode 100644 index 0000000..41b6495 --- /dev/null +++ b/internal/cli/rewrite.go @@ -0,0 +1,100 @@ +package cli + +import ( + "fmt" + "net/url" + "os" + "strings" +) + +// ServerBase devuelve la URL base del servidor gitGost (env GITGOST_SERVER o default). +func ServerBase() string { + if v := os.Getenv("GITGOST_SERVER"); v != "" { + return strings.TrimRight(v, "/") + } + return "https://gitgost.fly.dev" +} + +// hostPrefix mapea el host del repositorio al prefijo de ruta del servidor gitGost. +var hostPrefix = map[string]string{ + "github.com": "gh", + "gitlab.com": "gl", + "codeberg.org": "cb", +} + +// RewriteURL convierte una URL de repositorio (https o scp-like SSH) en la ruta +// equivalente del servidor gitGost: /v1//owner/repo. Así el tráfico de +// clone/fetch pasa por gitGost sin tocar la URL original del usuario. +func RewriteURL(base, raw string) (string, error) { + base = strings.TrimRight(base, "/") + u, err := parseRepoURL(raw) + if err != nil { + return "", err + } + + prefix, ok := hostPrefix[u.Hostname()] + if !ok { + return "", fmt.Errorf("host no soportado por gitGost: %s", u.Hostname()) + } + + parts := splitPath(u.Path) + if len(parts) != 2 { + return "", fmt.Errorf("URL de repositorio inválida: %s", raw) + } + owner, repo := parts[0], strings.TrimSuffix(parts[1], ".git") + if owner == "" || repo == "" || !validSegment(owner) || !validSegment(repo) { + return "", fmt.Errorf("URL de repositorio inválida: %s", raw) + } + + return fmt.Sprintf("%s/v1/%s/%s/%s", base, prefix, owner, repo), nil +} + +// parseRepoURL acepta URLs https://host/owner/repo y scp-like git@host:owner/repo.git. +func parseRepoURL(raw string) (*url.URL, error) { + if strings.Contains(raw, "://") { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("URL inválida: %w", err) + } + if u.Hostname() == "" { + return nil, fmt.Errorf("URL inválida: %s", raw) + } + return u, nil + } + + // scp-like: user@host:owner/repo(.git) + at := strings.LastIndex(raw, "@") + colon := strings.Index(raw, ":") + if at >= 0 && colon > at { + host := raw[at+1 : colon] + path := raw[colon+1:] + return &url.URL{Scheme: "https", Host: host, Path: "/" + path}, nil + } + return nil, fmt.Errorf("URL inválida: %s", raw) +} + +func splitPath(p string) []string { + var out []string + for _, part := range strings.Split(p, "/") { + if part != "" { + out = append(out, part) + } + } + return out +} + +// validSegment valida un segmento owner/repo (alfanumérico, -, _, .). +func validSegment(s string) bool { + if len(s) == 0 || len(s) > 100 { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '-', r == '_', r == '.': + default: + return false + } + } + return !strings.Contains(s, "..") +} diff --git a/internal/cli/rewrite_test.go b/internal/cli/rewrite_test.go new file mode 100644 index 0000000..891f3c8 --- /dev/null +++ b/internal/cli/rewrite_test.go @@ -0,0 +1,57 @@ +package cli + +import "testing" + +func TestRewriteURL(t *testing.T) { + base := "https://gitgost.fly.dev" + cases := []struct { + name string + raw string + want string + }{ + {"github https", "https://github.com/openai/openai.git", "https://gitgost.fly.dev/v1/gh/openai/openai"}, + {"github sin .git", "https://github.com/livrasand/gitGost", "https://gitgost.fly.dev/v1/gh/livrasand/gitGost"}, + {"github ssh scp-like", "git@github.com:torvalds/linux.git", "https://gitgost.fly.dev/v1/gh/torvalds/linux"}, + {"gitlab", "https://gitlab.com/group/repo.git", "https://gitgost.fly.dev/v1/gl/group/repo"}, + {"codeberg", "https://codeberg.org/user/repo.git", "https://gitgost.fly.dev/v1/cb/user/repo"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := RewriteURL(base, tc.raw) + if err != nil { + t.Fatalf("RewriteURL: %v", err) + } + if got != tc.want { + t.Errorf("RewriteURL(%q) = %q, se esperaba %q", tc.raw, got, tc.want) + } + }) + } + + // La base con slash final también debe funcionar. + if got, err := RewriteURL("https://gitgost.fly.dev/", "https://github.com/foo/bar"); err != nil || got != "https://gitgost.fly.dev/v1/gh/foo/bar" { + t.Errorf("base con slash final: got=%q err=%v", got, err) + } +} + +func TestRewriteURLInvalid(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"host no soportado", "https://bitbucket.org/user/repo.git"}, + {"sin path", "https://github.com"}, + {"path con un solo segmento", "https://github.com/solo"}, + {"path con tres segmentos", "https://github.com/a/b/c"}, + {"scp mal formado", "git@github.com"}, + {"garbage", "no es una url"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := RewriteURL("https://gitgost.fly.dev", tc.raw); err == nil { + t.Errorf("RewriteURL(%q) debería fallar", tc.raw) + } + }) + } +} diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 39ec62e..48c7dd8 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -34,7 +34,10 @@ import ( "github.com/gin-gonic/gin" ) -var uploadPackClient = &http.Client{Timeout: 30 * time.Second} +// uploadPackClient proxifica las descargas de git-upload-pack. El timeout debe +// ser amplio: GitHub puede tardar más de 30s en generar/transferir un pack de +// un repo grande, y un corte aquí trunca la descarga del cliente. +var uploadPackClient = &http.Client{Timeout: 10 * time.Minute} const ( karmaStoreMax = 10000 @@ -657,7 +660,20 @@ func UploadPackDiscoveryHandler(c *gin.Context) { prov := providerFromPath(c.Request.URL.Path) token := os.Getenv(prov.TokenEnvVar()) - remoteURL := prov.CloneURL(owner, repo) + "/info/refs?service=git-upload-pack" + // Reenviar los query params del cliente (protocol=v2 y otros) además del + // service: sin protocol=v2 el servidor remoto responde en protocolo v1 y + // git no puede usar filtros (partial clone) ni la negociación eficiente. + q := url.Values{} + q.Set("service", "git-upload-pack") + for k, vals := range c.Request.URL.Query() { + if k == "service" { + continue + } + for _, v := range vals { + q.Add(k, v) + } + } + remoteURL := prov.CloneURL(owner, repo) + "/info/refs?" + q.Encode() req, err := http.NewRequest("GET", remoteURL, nil) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to build request"}) @@ -712,6 +728,10 @@ func UploadPackHandler(c *gin.Context) { req.Header.Set("Content-Type", "application/x-git-upload-pack-request") req.Header.Set("Accept", "application/x-git-upload-pack-result") req.Header.Set("User-Agent", "git/2.0") + if gp := c.Request.Header.Get("Git-Protocol"); gp != "" { + // Reenviar la negociación de protocolo v2 del cliente al remoto. + req.Header.Set("Git-Protocol", gp) + } if ce := c.Request.Header.Get("Content-Encoding"); ce != "" { req.Header.Set("Content-Encoding", ce) } diff --git a/internal/jobs/clone.go b/internal/jobs/clone.go new file mode 100644 index 0000000..ecae838 --- /dev/null +++ b/internal/jobs/clone.go @@ -0,0 +1,196 @@ +package jobs + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// chunkSize es el número de commits por bloque en la descarga por capas. +// Sobrescribible en tests para validar el resume con varias capas. +var chunkSize = 500 + +// runClone descarga un repositorio por capas: inicializa el repo, descarga la +// historia en bloques de chunkSize commits (--depth/--deepen) y materializa la +// rama por defecto. Cada bloque completado es un checkpoint: si la conexión se +// pierde, al reanudar solo se descargan los bloques pendientes. +func runClone(s *Store, job *Job) error { + dir := job.Target + if dir == "" { + return fmt.Errorf("job de clone sin directorio destino") + } + + // Inicializar el repo si no existe (resume = repo ya inicializado). + if _, err := os.Stat(filepath.Join(dir, ".git")); os.IsNotExist(err) { + if err := runGit("", "init", "-q", dir); err != nil { + return fmt.Errorf("inicializar repo: %w", err) + } + } + + // Asegurar el remote origin con la URL original del usuario (o la efectiva + // si no hay original): el repo resultante debe ser un repo Git normal. + originURL := job.Origin + if originURL == "" { + originURL = job.URL + } + _ = runGit(dir, "remote", "remove", "origin") + if err := runGit(dir, "remote", "add", "origin", originURL); err != nil { + return fmt.Errorf("configurar remote origin: %w", err) + } + + // Rama por defecto del remoto. + ls, err := gitOutputWithRetry(s, job, dir, "ls-remote", "--symref", job.URL, "HEAD") + if err != nil { + return fmt.Errorf("descubrir rama por defecto: %w", err) + } + branch := parseDefaultBranch(ls) + if branch == "" { + return fmt.Errorf("no se pudo determinar la rama por defecto de %s", job.URL) + } + + // Descarga por capas de todas las ramas. Con --filter=blob:none los packs + // solo contienen commits y árboles: cada bloque es pequeño y resistente a + // cortes, y los blobs se materializan bajo demanda durante el checkout. + refspec := "+refs/heads/*:refs/remotes/origin/*" + if repoShallow(dir) { + _ = s.SetProgress(job.ID, "Reanudando desde el checkpoint existente...") + } else { + _ = s.SetProgress(job.ID, "Descargando primer bloque...") + if err := execGitWithRetry(s, job, dir, + "fetch", "--depth="+strconv.Itoa(chunkSize), "--filter=blob:none", job.URL, refspec); err != nil { + return err + } + } + + // Profundizar hasta alcanzar la historia completa. Los shallow points de + // ramas/tags cortas se resuelven sin añadir commits al grafo, así que el + // "sin progreso" real es: el shallow file no cambió Y el count tampoco. + block := 0 + for repoShallow(dir) { + block++ + prevShallow := shallowFile(dir) + prev := gitRevCount(dir) + if err := execGitWithRetry(s, job, dir, + "fetch", "--deepen="+strconv.Itoa(chunkSize), "--filter=blob:none", job.URL, refspec); err != nil { + return err + } + count := gitRevCount(dir) + _ = s.SetProgress(job.ID, fmt.Sprintf("Bloque %d: %d commits descargados", block, count)) + if !repoShallow(dir) { + break + } + if shallowFile(dir) == prevShallow && count == prev { + // El servidor no profundiza más por capas: se completa con unshallow. + break + } + } + + // Cierre: si quedaron shallow points sin resolver, --unshallow completa toda + // la historia restante en una sola petición (ligera con --filter=blob:none). + if repoShallow(dir) { + _ = s.SetProgress(job.ID, "Completando historia restante...") + if err := execGitWithRetry(s, job, dir, + "fetch", "--unshallow", "--filter=blob:none", job.URL, refspec); err != nil { + return err + } + } + + // Materializar la rama por defecto con tracking. + if err := runGit(dir, "checkout", "-b", branch, "--track", "origin/"+branch); err != nil { + // La rama local ya existía (resume tras el checkout): solo cambiar a ella. + if err := runGit(dir, "checkout", branch); err != nil { + return fmt.Errorf("crear rama local %s: %w", branch, err) + } + } + return nil +} + +// shallowFile devuelve el contenido actual del archivo .git/shallow (los +// boundaries pendientes de profundizar), o vacío si el repo no es shallow. +func shallowFile(dir string) string { + data, err := os.ReadFile(filepath.Join(dir, ".git", "shallow")) + if err != nil { + return "" + } + return string(data) +} + +// repoShallow indica si el repo sigue con historia parcial (descarga por capas en curso). +func repoShallow(dir string) bool { + out, err := gitOutput(dir, "rev-parse", "--is-shallow-repository") + return err == nil && strings.TrimSpace(out) == "true" +} + +// gitRevCount cuenta los commits visibles en todas las refs del repo. +func gitRevCount(dir string) int { + out, err := gitOutput(dir, "rev-list", "--count", "--all") + if err != nil { + return 0 + } + n, _ := strconv.Atoi(strings.TrimSpace(out)) + return n +} + +// parseDefaultBranch extrae la rama por defecto de la salida de +// `git ls-remote --symref HEAD` ("ref: refs/heads/main\tHEAD"). +func parseDefaultBranch(ls string) string { + for _, line := range strings.Split(ls, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "ref: refs/heads/") && strings.HasSuffix(line, "\tHEAD") { + return strings.TrimSuffix(strings.TrimPrefix(line, "ref: refs/heads/"), "\tHEAD") + } + } + return "" +} + +// gitOutput ejecuta git y devuelve su salida combinada. Si git falla, el error +// incluye el mensaje real de stderr (p. ej. "unable to access") para que +// isRetryable pueda clasificar el fallo como de red y no como error genérico. +func gitOutput(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return string(out), fmt.Errorf("%w: %s", err, msg) + } + } + return string(out), err +} + +// gitOutputWithRetry como gitOutput pero con reintentos ante fallos de red. +func gitOutputWithRetry(s *Store, job *Job, dir string, args ...string) (string, error) { + var ( + out string + err error + ) + backoff := retryBase + for attempt := 0; attempt <= retryMax; attempt++ { + if attempt > 0 { + _ = s.SetState(job.ID, StateRetrying) + _ = s.SetProgress(job.ID, fmt.Sprintf( + "Reintentando en %s (intento %d/%d)...", backoff, attempt, retryMax)) + time.Sleep(backoff) + backoff *= 2 + } + out, err = gitOutput(dir, args...) + if err == nil { + return out, nil + } + if !isRetryable(err) { + return out, err + } + } + return out, err +} + +// runGit ejecuta git sin capturar salida. +func runGit(dir string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + return cmd.Run() +} diff --git a/internal/jobs/clone_test.go b/internal/jobs/clone_test.go new file mode 100644 index 0000000..ce10e38 --- /dev/null +++ b/internal/jobs/clone_test.go @@ -0,0 +1,142 @@ +package jobs + +import ( + "fmt" + "path/filepath" + "strings" + "testing" +) + +// buildRepo crea un repositorio local con n commits en la rama main. +func buildRepo(t *testing.T, n int) string { + t.Helper() + src := filepath.Join(t.TempDir(), "src") + if err := runGit("", "init", "-q", src); err != nil { + t.Fatalf("init: %v", err) + } + if err := runGit(src, "checkout", "-q", "-b", "main"); err != nil { + t.Fatalf("crear main: %v", err) + } + for i := 0; i < n; i++ { + if err := runGit(src, "commit", "-q", "--allow-empty", "-m", fmt.Sprintf("c%d", i)); err != nil { + t.Fatalf("commit %d: %v", i, err) + } + } + return src +} + +func withChunk(t *testing.T, n int) { + t.Helper() + old := chunkSize + chunkSize = n + t.Cleanup(func() { chunkSize = old }) +} + +// TestRunCloneSharded valida la descarga por capas: un repo de 250 commits con +// bloques de 100 debe quedar completo y no shallow tras 2 profundizaciones. +func TestRunCloneSharded(t *testing.T) { + src := buildRepo(t, 250) + withChunk(t, 100) + + s := testStore(t) + dest := filepath.Join(t.TempDir(), "dest") + id, err := s.Create(&Job{Operation: "clone", URL: "file://" + src, Target: dest, CWD: t.TempDir()}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := Run(s, id); err != nil { + t.Fatalf("Run: %v", err) + } + + job, err := s.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if job.State != StateCompleted { + t.Errorf("estado = %s, se esperaba completed", job.State) + } + if repoShallow(dest) { + t.Error("el repo sigue shallow tras completar") + } + if n := gitRevCount(dest); n != 250 { + t.Errorf("commits = %d, se esperaba 250", n) + } + out, _ := gitOutput(dest, "branch", "--show-current") + if strings.TrimSpace(out) != "main" { + t.Errorf("rama actual = %q, se esperaba main", out) + } + if !strings.Contains(job.Progress, "Bloque") { + t.Errorf("progreso sin reporte de bloques: %q", job.Progress) + } +} + +// TestRunCloneResumesFromCheckpoint simula un clone interrumpido (primer bloque +// ya descargado, repo shallow) y verifica que runClone reanuda hasta completar. +func TestRunCloneResumesFromCheckpoint(t *testing.T) { + src := buildRepo(t, 250) + withChunk(t, 100) + + dest := filepath.Join(t.TempDir(), "dest") + if err := runGit("", "init", "-q", dest); err != nil { + t.Fatalf("init dest: %v", err) + } + refspec := "+refs/heads/*:refs/remotes/origin/*" + if err := runGit(dest, "fetch", "--depth=100", "file://"+src, refspec); err != nil { + t.Fatalf("checkpoint inicial: %v", err) + } + if !repoShallow(dest) { + t.Fatal("el checkpoint debería ser shallow") + } + if n := gitRevCount(dest); n != 100 { + t.Fatalf("checkpoint con %d commits, se esperaba 100", n) + } + + s := testStore(t) + id, err := s.Create(&Job{Operation: "clone", URL: "file://" + src, Target: dest, CWD: t.TempDir()}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := Run(s, id); err != nil { + t.Fatalf("Run (resume): %v", err) + } + + job, err := s.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if job.State != StateCompleted { + t.Errorf("estado = %s, se esperaba completed", job.State) + } + if repoShallow(dest) { + t.Error("el repo sigue shallow tras el resume") + } + if n := gitRevCount(dest); n != 250 { + t.Errorf("commits tras resume = %d, se esperaba 250", n) + } +} + +// TestRunCloneSingleBlock valida el caso de un repo pequeño: un solo bloque, sin shallow. +func TestRunCloneSingleBlock(t *testing.T) { + src := buildRepo(t, 3) + withChunk(t, 500) + + s := testStore(t) + dest := filepath.Join(t.TempDir(), "dest") + id, err := s.Create(&Job{Operation: "clone", URL: "file://" + src, Target: dest, CWD: t.TempDir()}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := Run(s, id); err != nil { + t.Fatalf("Run: %v", err) + } + job, err := s.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if job.State != StateCompleted { + t.Errorf("estado = %s", job.State) + } + if repoShallow(dest) { + t.Error("repo pequeño no debería quedar shallow") + } +} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go new file mode 100644 index 0000000..7916a8a --- /dev/null +++ b/internal/jobs/jobs.go @@ -0,0 +1,30 @@ +package jobs + +import "time" + +// Estados posibles de un job en la cola local. +const ( + StateQueued = "queued" + StateRunning = "running" + StatePaused = "paused" + StateRetrying = "retrying" + StateCompleted = "completed" + StateFailed = "failed" +) + +// Job representa una operación Git gestionada por la cola local de gitGost. +type Job struct { + ID int64 + Operation string // clone | fetch | pull | push + URL string // URL efectiva (reescrita al servidor gitGost cuando aplica) + Origin string // URL original del repositorio (para el remote origin del repo resultante) + Target string // directorio destino para clone; vacío para el resto + CWD string // directorio de trabajo de la operación + Args []string + State string + Progress string // última línea de salida de git (para watch) + Error string + PID int // PID del proceso en background; 0 si no aplica + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/jobs/retry.go b/internal/jobs/retry.go new file mode 100644 index 0000000..bc5b698 --- /dev/null +++ b/internal/jobs/retry.go @@ -0,0 +1,143 @@ +package jobs + +import ( + "bufio" + "fmt" + "os/exec" + "strings" + "time" +) + +// Configuración de reintentos automáticos ante fallos de red. +var ( + retryMax = 3 + retryBase = 2 * time.Second +) + +// isRetryable decide si un error de git merece reintento. Los errores +// definitivos (repo inexistente, autenticación, destino ocupado) no se +// reintentan; los de red/transporte sí. +func isRetryable(err error) bool { + lower := strings.ToLower(err.Error()) + + noRetry := []string{ + "not found", + "repository not found", + "does not appear to be a git repository", + "authentication failed", + "access denied", + "permission denied", + "already exists", + "denied", + "invalid refspec", + "couldn't find remote ref", + "401", + "403", + "404", + } + for _, n := range noRetry { + if strings.Contains(lower, n) { + return false + } + } + + retry := []string{ + "unable to access", + "could not resolve host", + "connection reset", + "connection refused", + "connection timed out", + "operation timed out", + "timed out", + "early eof", + "eof", + "rpc failed", + "remote end hung up", + "invalid index-pack output", + "fetch-pack:", + "tls handshake", + "network is unreachable", + "no route to host", + "broken pipe", + "502", + "503", + "504", + "http error", + } + for _, r := range retry { + if strings.Contains(lower, r) { + return true + } + } + return false +} + +// execGitWithRetry ejecuta un comando git capturando su stderr (progreso) y lo +// reintenta con backoff exponencial si el fallo es de red. +func execGitWithRetry(s *Store, job *Job, dir string, args ...string) error { + var lastErr error + backoff := retryBase + for attempt := 0; attempt <= retryMax; attempt++ { + if attempt > 0 { + _ = s.SetState(job.ID, StateRetrying) + _ = s.SetProgress(job.ID, fmt.Sprintf( + "Reintentando en %s (intento %d/%d)...", backoff, attempt, retryMax)) + time.Sleep(backoff) + backoff *= 2 + } + lastErr = runGitCapture(dir, progressFn(s, job.ID), args...) + if lastErr == nil { + return nil + } + if !isRetryable(lastErr) { + return lastErr + } + } + return lastErr +} + +// runGitCapture ejecuta git y entrega cada línea de stderr al callback de progreso. +// Las barras de progreso usan \r, así que una "línea" puede ser enorme: +// se entrega solo el final, que es el estado visible más reciente. +func runGitCapture(dir string, progress func(string), args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + + lastLine := "" + scanner := bufio.NewScanner(stderr) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || progress == nil { + continue + } + if len(line) > 160 { + line = line[len(line)-160:] + } + lastLine = line + progress(line) + } + + if err := scanner.Err(); err != nil { + // Si el stderr deja de leerse, git puede bloquearse en el pipe: se aborta. + _ = cmd.Process.Kill() + return err + } + if err := cmd.Wait(); err != nil { + // Incluir el último mensaje de git (p. ej. "unable to access") para que + // isRetryable clasifique el fallo; el error de Wait solo dice "exit 128". + if lastLine != "" { + return fmt.Errorf("%w: %s", err, lastLine) + } + return err + } + return nil +} diff --git a/internal/jobs/retry_test.go b/internal/jobs/retry_test.go new file mode 100644 index 0000000..d0f1c68 --- /dev/null +++ b/internal/jobs/retry_test.go @@ -0,0 +1,50 @@ +package jobs + +import ( + "errors" + "testing" +) + +func TestIsRetryable(t *testing.T) { + retryable := []string{ + "fatal: unable to access 'https://x/': Could not resolve host: github.com", + "fatal: unable to access 'https://x/': Failed to connect to github.com port 443: Connection refused", + "error: RPC failed; HTTP 502 curl 22 The requested URL returned error: 502", + "fatal: early EOF", + "fatal: the remote end hung up unexpectedly", + "fatal: fetch-pack: invalid index-pack output", + "error: connection reset by peer", + "fatal: unable to access: The requested URL returned error: 504", + "fatal: Operation timed out after 30000 milliseconds", + } + for _, msg := range retryable { + if !isRetryable(errors.New(msg)) { + t.Errorf("debería ser retryable: %s", msg) + } + } + + notRetryable := []string{ + "fatal: repository 'https://github.com/no/such' not found", + "remote: Repository not found.", + "fatal: '/no/existe' does not appear to be a git repository", + "remote: Authentication failed", + "fatal: destination path 'repo' already exists and is not an empty directory", + "fatal: couldn't find remote ref refs/heads/nope", + "error: Permission denied (publickey)", + } + for _, msg := range notRetryable { + if isRetryable(errors.New(msg)) { + t.Errorf("no debería ser retryable: %s", msg) + } + } +} + +func TestParseDefaultBranch(t *testing.T) { + ls := "ref: refs/heads/main\tHEAD\nabc123\tHEAD\n" + if got := parseDefaultBranch(ls); got != "main" { + t.Errorf("parseDefaultBranch = %q, se esperaba main", got) + } + if got := parseDefaultBranch("abc123\tHEAD\n"); got != "" { + t.Errorf("parseDefaultBranch sin symref = %q, se esperaba vacío", got) + } +} diff --git a/internal/jobs/run.go b/internal/jobs/run.go new file mode 100644 index 0000000..6dc2bfc --- /dev/null +++ b/internal/jobs/run.go @@ -0,0 +1,61 @@ +package jobs + +import ( + "fmt" +) + +// maxErrorLen limita el tamaño del mensaje de error persistido en la cola. +const maxErrorLen = 500 + +// Run ejecuta la operación Git de un job (invocado por `git gost run `, +// normalmente como proceso en background) y actualiza su estado en el store. +// Los fallos de red se reintentan automáticamente (estado retrying); para el +// clone, la descarga es por capas y reanuda desde el último checkpoint. +func Run(s *Store, id int64) error { + job, err := s.Get(id) + if err != nil { + return fmt.Errorf("obtener job %d: %w", id, err) + } + if job.State == StateCompleted || job.State == StateFailed { + return fmt.Errorf("el job %d ya terminó (%s)", id, job.State) + } + if err := s.SetState(id, StateRunning); err != nil { + return err + } + + var runErr error + switch job.Operation { + case "clone": + runErr = runClone(s, job) + default: // fetch, pull, push + runErr = runGitOperation(s, job) + } + + if runErr != nil { + _ = s.SetState(id, StateFailed) + _ = s.SetError(id, truncate(runErr.Error(), maxErrorLen)) + return runErr + } + return s.SetState(id, StateCompleted) +} + +// runGitOperation ejecuta fetch/pull/push en el repositorio actual con +// reintentos automáticos ante fallos de red. +func runGitOperation(s *Store, job *Job) error { + args := append([]string{job.Operation}, job.Args...) + return execGitWithRetry(s, job, job.CWD, args...) +} + +// progressFn devuelve el callback que persiste una línea de progreso en la cola. +func progressFn(s *Store, id int64) func(string) { + return func(line string) { + _ = s.SetProgress(id, line) + } +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/jobs/store.go b/internal/jobs/store.go new file mode 100644 index 0000000..6449d32 --- /dev/null +++ b/internal/jobs/store.go @@ -0,0 +1,169 @@ +package jobs + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +const schema = ` +CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + operation TEXT NOT NULL, + url TEXT NOT NULL DEFAULT '', + origin TEXT NOT NULL DEFAULT '', + target TEXT NOT NULL DEFAULT '', + cwd TEXT NOT NULL DEFAULT '', + args TEXT NOT NULL DEFAULT '[]', + state TEXT NOT NULL DEFAULT 'queued', + progress TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '', + pid INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +);` + +// Store es el acceso a la cola de jobs persistida en SQLite (local al cliente). +type Store struct { + db *sql.DB +} + +// Open abre (o crea) la base de datos de la cola en path. +func Open(path string) (*Store, error) { + if dir := filepath.Dir(path); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("crear directorio de datos: %w", err) + } + } + dsn := "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("crear esquema de jobs: %w", err) + } + return &Store{db: db}, nil +} + +// Close cierra la base de datos. +func (s *Store) Close() error { + return s.db.Close() +} + +// Create inserta un job en estado queued y devuelve su ID. +func (s *Store) Create(j *Job) (int64, error) { + now := time.Now().UTC() + j.State = StateQueued + j.CreatedAt = now + j.UpdatedAt = now + argsJSON, err := json.Marshal(j.Args) + if err != nil { + return 0, fmt.Errorf("serializar args: %w", err) + } + res, err := s.db.Exec( + `INSERT INTO jobs (operation, url, origin, target, cwd, args, state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + j.Operation, j.URL, j.Origin, j.Target, j.CWD, string(argsJSON), j.State, + j.CreatedAt.Format(time.RFC3339Nano), j.UpdatedAt.Format(time.RFC3339Nano), + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// Get devuelve un job por ID. +func (s *Store) Get(id int64) (*Job, error) { + row := s.db.QueryRow( + `SELECT id, operation, url, origin, target, cwd, args, state, progress, error, pid, created_at, updated_at + FROM jobs WHERE id = ?`, id) + return scanJob(row) +} + +// List devuelve los últimos limit jobs, del más reciente al más antiguo. +func (s *Store) List(limit int) ([]Job, error) { + rows, err := s.db.Query( + `SELECT id, operation, url, origin, target, cwd, args, state, progress, error, pid, created_at, updated_at + FROM jobs ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Job + for rows.Next() { + j, err := scanJob(rows) + if err != nil { + return nil, err + } + out = append(out, *j) + } + return out, rows.Err() +} + +// SetState actualiza el estado de un job. +func (s *Store) SetState(id int64, state string) error { + return s.touch(id, "state", state) +} + +// SetProgress actualiza la última línea de progreso del job. +func (s *Store) SetProgress(id int64, progress string) error { + return s.touch(id, "progress", progress) +} + +// SetError registra el error de un job fallido. +func (s *Store) SetError(id int64, errMsg string) error { + return s.touch(id, "error", errMsg) +} + +// SetPID registra el PID del proceso en background del job. +func (s *Store) SetPID(id int64, pid int) error { + if _, err := s.db.Exec(`UPDATE jobs SET pid = ?, updated_at = ? WHERE id = ?`, + pid, time.Now().UTC().Format(time.RFC3339Nano), id); err != nil { + return err + } + return nil +} + +// Delete elimina un job de la cola. +func (s *Store) Delete(id int64) error { + _, err := s.db.Exec(`DELETE FROM jobs WHERE id = ?`, id) + return err +} + +func (s *Store) touch(id int64, column, value string) error { + _, err := s.db.Exec( + fmt.Sprintf(`UPDATE jobs SET %s = ?, updated_at = ? WHERE id = ?`, column), + value, time.Now().UTC().Format(time.RFC3339Nano), id) + return err +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanJob(row rowScanner) (*Job, error) { + var ( + j Job + argsJSON string + created string + updated string + ) + if err := row.Scan(&j.ID, &j.Operation, &j.URL, &j.Origin, &j.Target, &j.CWD, + &argsJSON, &j.State, &j.Progress, &j.Error, &j.PID, &created, &updated); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(argsJSON), &j.Args); err != nil { + return nil, fmt.Errorf("deserializar args: %w", err) + } + j.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + j.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updated) + return &j, nil +} diff --git a/internal/jobs/store_test.go b/internal/jobs/store_test.go new file mode 100644 index 0000000..d2ab361 --- /dev/null +++ b/internal/jobs/store_test.go @@ -0,0 +1,130 @@ +package jobs + +import ( + "path/filepath" + "testing" +) + +func testStore(t *testing.T) *Store { + t.Helper() + s, err := Open(filepath.Join(t.TempDir(), "gitgost.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestCreateAndGet(t *testing.T) { + s := testStore(t) + + id, err := s.Create(&Job{Operation: "clone", URL: "https://gitgost.fly.dev/v1/gh/foo/bar"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if id == 0 { + t.Fatal("Create devolvió id 0") + } + + job, err := s.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if job.State != StateQueued { + t.Errorf("estado inicial = %s, se esperaba queued", job.State) + } + if job.Operation != "clone" || job.URL != "https://gitgost.fly.dev/v1/gh/foo/bar" { + t.Errorf("job inesperado: %+v", job) + } + if job.CreatedAt.IsZero() || job.UpdatedAt.IsZero() { + t.Error("timestamps vacíos") + } +} + +func TestTransitionsAndList(t *testing.T) { + s := testStore(t) + + id, err := s.Create(&Job{Operation: "fetch", Args: []string{"origin", "main"}, CWD: "/tmp"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := s.SetState(id, StateRunning); err != nil { + t.Fatalf("SetState(running): %v", err) + } + if err := s.SetProgress(id, "Receiving objects: 50%"); err != nil { + t.Fatalf("SetProgress: %v", err) + } + if err := s.SetState(id, StateCompleted); err != nil { + t.Fatalf("SetState(completed): %v", err) + } + + job, err := s.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if job.State != StateCompleted { + t.Errorf("estado = %s, se esperaba completed", job.State) + } + if job.Progress != "Receiving objects: 50%" { + t.Errorf("progress = %q", job.Progress) + } + if len(job.Args) != 2 || job.Args[0] != "origin" { + t.Errorf("args no preservados: %v", job.Args) + } + if job.CWD != "/tmp" { + t.Errorf("cwd no preservado: %q", job.CWD) + } + + list, err := s.List(10) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 1 || list[0].ID != id { + t.Errorf("List inesperada: %+v", list) + } +} + +func TestRunFailingJob(t *testing.T) { + s := testStore(t) + + // URL inexistente (local, determinista): el job debe pasar a failed sin reintentos. + id, err := s.Create(&Job{ + Operation: "clone", + URL: "file:///no/existe/repo", + Target: filepath.Join(t.TempDir(), "dest"), + CWD: t.TempDir(), + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := Run(s, id); err == nil { + t.Fatal("Run debería fallar con URL inexistente") + } + + job, err := s.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if job.State != StateFailed { + t.Errorf("estado = %s, se esperaba failed", job.State) + } + if job.Error == "" { + t.Error("error vacío en job fallido") + } +} + +func TestDelete(t *testing.T) { + s := testStore(t) + id, err := s.Create(&Job{Operation: "fetch"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := s.Delete(id); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := s.Get(id); err == nil { + t.Error("Get tras Delete debería fallar") + } +} diff --git a/web/index.html b/web/index.html index 7aa95e5..b9c0839 100644 --- a/web/index.html +++ b/web/index.html @@ -347,7 +347,6 @@ flex: 1; padding: 2rem 2rem 0rem 2rem; gap: 2rem; - max-width: 1400px; margin: 0 auto; width: 100%; min-height: 0; @@ -1130,7 +1129,7 @@ display: flex; } - .sidebar > div:not(#rate-limit-section), + .sidebar > div:not(#rate-limit-section), #rate-limit-section, .sidebar .section-title { display: none; @@ -1499,6 +1498,13 @@ cli + + + + + + + git extension
create a
@@ -1637,10 +1643,9 @@

font-size: 0.72rem; line-height: 1.7; color: var(--fg-muted); - padding-top: 0.75rem; " > - If useful, consider
donating or sponsoring us; you can also forking. AI/LLM scraping prohibited. + If useful, consider donating or sponsoring us; you can also forking. AI/LLM scraping prohibited. Git is a trademark of the Software Freedom Conservancy.

@@ -3112,7 +3117,7 @@

: ""; const count = repo.stars > 0 ? `(${formatNumber(repo.stars)})` : ""; - const dateStr = scrollState.tab === 'new' + const dateStr = scrollState.tab === 'new' ? (repo.created_at || repo.updated_at || repo.pushed_at || '') : (repo.pushed_at || repo.updated_at || repo.created_at || ''); const ago = scrollState.tab === 'new' ? timeAgo(dateStr) : ''; @@ -3575,6 +3580,129 @@

` }, + 'git-extension': { + title: 'Git Extension', + html: () => ` +
+ + +
+
gitGost / git extension
+

An open source Git extension for resilient networking.

+

Git manages the repository. gitGost manages the network. The git-gost client pauses, resumes, queues and retries Git operations in the background — without replacing Git itself.

+
+
+ + + +
+
# Clone runs like a download manager +$ git gost clone https://github.com/openai/openai.git + Job created. ID: 82 — Running in background... +$ git gost watch 82 + 82 openai 74% Downloading pack 18/35
+
+
+ + +
+
Download git-gost
+

Each release publishes a binary per platform with its SHA-256 and a Sigstore attestation. Download yours, verify the hash, and run the auto-installer.

+
+
+
macOS
+
Apple Silicon
+ Download + +
+
+
macOS
+
Intel
+ Download + +
+
+
Linux
+
x86-64
+ Download + +
+
+
Linux
+
ARM64
+ Download + +
+
+
Windows
+
x86-64
+ Download + +
+
+
+ + +
+
Getting started
+
+
+ + + +
+
# Download and install the Git command line extension. Run once per user account. +$ ./git-gost-darwin-arm64 install + git-gost instalado en ~/.local/bin/git-gost +# There is no step three. Clone, fetch and push as you normally would: +$ git gost clone https://github.com/openai/openai.git +# Manage the queue while the download runs in the background +$ git gost jobs +$ git gost watch 82 +$ git gost pause 82 # or resume / cancel
+
+

If the connection is lost, the job retries automatically and the download resumes from its last checkpoint — never from zero.

+
+ + +
+
Features
+
+
+
Pause & resume
+

Checkpoints per layer

+

Clones download in commit blocks; each completed block is a checkpoint. A dropped connection resumes from the last block, not from zero.

+
+
+
Background queue
+

Keep working while it downloads

+

Each clone, fetch or push is a job in a local SQLite queue. Run them in the background and follow progress with git gost watch.

+
+
+
Automatic retries
+

Retrying state with backoff

+

Network failures are detected and retried with exponential backoff. Pause, resume and cancel any job at any time.

+
+
+
Same Git workflow
+

No new commands to learn

+

git gost clone, fetch, pull and push work like Git; status, log and branch pass through to Git.

+
+
+
Any repository
+

Works with any Git remote

+

No fork of Git required: the resulting repository is a normal Git repo whose origin points to the original URL.

+
+
+
Open source
+

Auditable and verifiable

+

AGPL-3.0 licensed. The job queue is local to your machine and every binary ships with SHA-256 and Sigstore attestation.

+
+
+
+ +
` + }, issue: { title: 'Create Anonymous Issue', html: () => `