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.
+
+
+
+
+
+
+
+$ 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.
+
+
+
+
+
+
Getting started
+
+
+
+
+
+
+
+$ ./git-gost-darwin-arm64 install
+→ git-gost instalado en ~/.local/bin/git-gost
+
+$ git gost clone https://github.com/openai/openai.git
+
+$ git gost jobs
+$ git gost watch 82
+$ git gost pause 82
+
+
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: () => `