10 files changed,
748 insertions(+),
6 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-01-21 01:49:14 +0200
Change ID:
qpvvmyqqypuryxsyqlzzrnlpolunqqwq
Parent:
b407568
M
go.mod
@@ -2,4 +2,30 @@ module olexsmir.xyz/mugit
go 1.25.3 -require gopkg.in/yaml.v2 v2.4.0 +require ( + github.com/bluekeyes/go-gitdiff v0.8.1 + github.com/go-git/go-git/v5 v5.16.4 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +)
M
go.sum
@@ -1,4 +1,105 @@
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/bluekeyes/go-gitdiff v0.8.1 h1:lL1GofKMywO17c0lgQmJYcKek5+s8X6tXVNOLxy4smI= +github.com/bluekeyes/go-gitdiff v0.8.1/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +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/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= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= +github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +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/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/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +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/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= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +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= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
A
internal/git/diff.go
@@ -0,0 +1,127 @@
+package git + +import ( + "fmt" + "strings" + + "github.com/bluekeyes/go-gitdiff/gitdiff" + "github.com/go-git/go-git/v5/plumbing/object" +) + +type TextFragment struct { + Header string + Lines []gitdiff.Line +} + +type Diff struct { + Name struct { + Old string + New string + } + TextFragments []TextFragment + IsBinary bool + IsNew bool + IsDelete bool +} + +type NiceDiff struct { + Diff []Diff + Commit struct { + Message string + Author object.Signature + This string + Parent string + } + Stat struct { + FilesChanged int + Insertions int + Deletions int + } +} + +func (g *Repo) Diff() (*NiceDiff, error) { + c, err := g.r.CommitObject(g.h) + if err != nil { + return nil, fmt.Errorf("commit object: %w", err) + } + + patch, parent, err := g.getPatch(c) + if err != nil { + return nil, err + } + + diffs, _, err := gitdiff.Parse(strings.NewReader(patch.String())) + if err != nil { + return nil, fmt.Errorf("parsing diff: %w", err) + } + + nd := NiceDiff{} + nd.Commit.Message = c.Message + nd.Commit.Author = c.Author + nd.Commit.This = c.Hash.String() + nd.Commit.Parent = getParentHash(parent) + nd.Stat.FilesChanged = len(diffs) + + nd.Diff = make([]Diff, len(diffs)) + for i, d := range diffs { + diff := &nd.Diff[i] + diff.Name.New = d.NewName + diff.Name.Old = d.OldName + diff.IsBinary = d.IsBinary + diff.IsNew = d.IsNew + diff.IsDelete = d.IsDelete + + for _, tf := range d.TextFragments { + diff.TextFragments = append(diff.TextFragments, TextFragment{ + Header: tf.Header(), + Lines: tf.Lines, + }) + for _, l := range tf.Lines { + switch l.Op { + case gitdiff.OpAdd: + nd.Stat.Insertions += 1 + case gitdiff.OpDelete: + nd.Stat.Deletions += 1 + } + } + } + } + return &nd, nil +} + +func (g *Repo) getPatch(c *object.Commit) (*object.Patch, *object.Commit, error) { + commitTree, err := c.Tree() + if err != nil { + return nil, nil, err + } + + var parentTree *object.Tree + var parent *object.Commit + + if c.NumParents() != 0 { + parent, err = c.Parents().Next() + if err != nil { + return nil, nil, err + } + parentTree, err = parent.Tree() + if err != nil { + return nil, nil, err + } + } else { + parentTree = &object.Tree{} + } + + patch, err := parentTree.Patch(commitTree) + if err != nil { + return nil, nil, fmt.Errorf("patch: %w", err) + } + + return patch, parent, nil +} + +func getParentHash(parent *object.Commit) string { + if parent == nil || parent.Hash.IsZero() { + return "" + } + return parent.Hash.String() +}
A
internal/git/gitservice/gitservice.go
@@ -0,0 +1,105 @@
+package gitservice + +import ( + "bytes" + "fmt" + "io" + "os/exec" + "strings" + "syscall" +) + +// Thanks https://git.icyphox.sh/legit/blob/master/git/service/service.go + +// InfoRefs executes git-upload-pack --advertise-refs for smart-HTTP discovery. +func InfoRefs(dir string, out io.Writer) error { + cmd := exec.Command("git", + "upload-pack", + "--stateless-rpc", + "--advertise-refs", + ".") + cmd.Dir = dir + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + stdoutPipe, _ := cmd.StdoutPipe() + cmd.Stderr = cmd.Stdout + defer stdoutPipe.Close() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start git-upload-pack: %w", err) + } + + if err := packLine(out, "# service=git-upload-pack\n"); err != nil { + return fmt.Errorf("write pack line: %w", err) + } + + if err := packFlush(out); err != nil { + return fmt.Errorf("flush pack: %w", err) + } + + var buf bytes.Buffer + if _, err := io.Copy(&buf, stdoutPipe); err != nil { + return fmt.Errorf("copy stdout to buffer: %w", err) + } + + if err := cmd.Wait(); err != nil { + var out strings.Builder + io.Copy(&out, &buf) + return fmt.Errorf("git-upload-pack: %w: %s", err, out.String()) + } + + if _, err := io.Copy(out, &buf); err != nil { + return fmt.Errorf("copy buffer to output: %w", err) + } + + return nil +} + +// UploadPack executes git-upload-pack for smart-HTTP git fetch/clone. +func UploadPack(dir string, in io.Reader, out io.Writer) error { + cmd := exec.Command("git", + "-c", "uploadpack.allowFilter=true", + "upload-pack", + "--stateless-rpc", + ".") + cmd.Dir = dir + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + stdoutPipe, _ := cmd.StdoutPipe() + cmd.Stderr = cmd.Stdout + defer stdoutPipe.Close() + + stdinPipe, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + defer stdinPipe.Close() + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start git-upload-pack: %w", err) + } + + if _, err := io.Copy(stdinPipe, in); err != nil { + return fmt.Errorf("copy to stdin: %w", err) + } + stdinPipe.Close() + + if _, err := io.Copy(out, stdoutPipe); err != nil { + return fmt.Errorf("copy stdout: %w", err) + } + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("git-upload-pack: %w", err) + } + + return nil +} + +func packLine(w io.Writer, s string) error { + _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s) + return err +} + +func packFlush(w io.Writer) error { + _, err := fmt.Fprint(w, "0000") + return err +}
A
internal/git/repo.go
@@ -0,0 +1,171 @@
+package git + +import ( + "fmt" + "sort" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// Thanks https://git.icyphox.sh/legit/blob/master/git/git.go + +type Repo struct { + path string + r *git.Repository + h plumbing.Hash +} + +// Open opens a git repository at path. If ref is empty, HEAD is used. +func Open(path string, ref string) (*Repo, error) { + var err error + g := Repo{} + g.path = path + g.r, err = git.PlainOpen(path) + if err != nil { + return nil, fmt.Errorf("opening %s: %w", path, err) + } + + if ref == "" { + head, err := g.r.Head() + if err != nil { + return nil, fmt.Errorf("getting head of %s: %w", path, err) + } + g.h = head.Hash() + } else { + hash, err := g.r.ResolveRevision(plumbing.Revision(ref)) + if err != nil { + return nil, fmt.Errorf("resolving rev %s for %s: %w", ref, path, err) + } + g.h = *hash + } + return &g, nil +} + +func (g *Repo) Commits() ([]*object.Commit, error) { + ci, err := g.r.Log(&git.LogOptions{From: g.h}) + if err != nil { + return nil, fmt.Errorf("commits from ref: %w", err) + } + + commits := []*object.Commit{} + ci.ForEach(func(c *object.Commit) error { + commits = append(commits, c) + return nil + }) + + return commits, nil +} + +func (g *Repo) LastCommit() (*object.Commit, error) { + c, err := g.r.CommitObject(g.h) + if err != nil { + return nil, fmt.Errorf("last commit: %w", err) + } + return c, nil +} + +func (g *Repo) FileContent(path string) (string, error) { + c, err := g.r.CommitObject(g.h) + if err != nil { + return "", fmt.Errorf("commit object: %w", err) + } + + tree, err := c.Tree() + if err != nil { + return "", fmt.Errorf("file tree: %w", err) + } + + file, err := tree.File(path) + if err != nil { + return "", err + } + + isbin, _ := file.IsBinary() + if !isbin { + return file.Contents() + } else { + return "Not displaying binary file", nil + } +} + +func (g *Repo) Tags() ([]*TagReference, error) { + iter, err := g.r.Tags() + if err != nil { + return nil, fmt.Errorf("tag objects: %w", err) + } + + tags := make([]*TagReference, 0) + if err := iter.ForEach(func(ref *plumbing.Reference) error { + obj, err := g.r.TagObject(ref.Hash()) + switch err { + case nil: + tags = append(tags, &TagReference{ + ref: ref, + tag: obj, + }) + case plumbing.ErrObjectNotFound: + tags = append(tags, &TagReference{ + ref: ref, + }) + default: + return err + } + return nil + }); err != nil { + return nil, err + } + + tagList := &TagList{r: g.r, refs: tags} + sort.Sort(tagList) + return tags, nil +} + +func (g *Repo) Branches() ([]*plumbing.Reference, error) { + bi, err := g.r.Branches() + if err != nil { + return nil, fmt.Errorf("branch: %w", err) + } + + branches := []*plumbing.Reference{} + err = bi.ForEach(func(ref *plumbing.Reference) error { + branches = append(branches, ref) + return nil + }) + return branches, err +} + +func (g *Repo) Description() (string, error) { + c, err := g.r.Config() + if err != nil { + return "", fmt.Errorf("failed to read config: %w", err) + } + + s := c.Raw.Section("mugit") + return s.Options.Get("description"), nil +} + +func (g *Repo) IsPrivate() (bool, error) { + c, err := g.r.Config() + if err != nil { + return false, fmt.Errorf("failed to read config: %w", err) + } + + s := c.Raw.Section("mugit") + return s.Options.Get("private") == "true", nil +} + +func (g *Repo) IsGoMod() bool { + _, err := g.FileContent("go.mod") + return err == nil +} + +func (g *Repo) FindMasterBranch(masters []string) (string, error) { + for _, b := range masters { + if _, err := g.r.ResolveRevision(plumbing.Revision(b)); err == nil { + return b, nil + } + } + return "", fmt.Errorf("unable to find master branch") +}
A
internal/git/tree.go
@@ -0,0 +1,62 @@
+package git + +import ( + "fmt" + + "github.com/go-git/go-git/v5/plumbing/object" +) + +type NiceTree struct { + Name string + Mode string + Size int64 + IsFile bool + IsSubtree bool +} + +func makeNiceTree(t *object.Tree) []NiceTree { + nts := []NiceTree{} + for _, e := range t.Entries { + mode, _ := e.Mode.ToOSFileMode() + sz, _ := t.Size(e.Name) + nts = append(nts, NiceTree{ + Name: e.Name, + Mode: mode.String(), + IsFile: e.Mode.IsFile(), + Size: sz, + }) + } + return nts +} + +func (g *Repo) FileTree(path string) ([]NiceTree, error) { + c, err := g.r.CommitObject(g.h) + if err != nil { + return nil, fmt.Errorf("commit object: %w", err) + } + + files := []NiceTree{} + tree, err := c.Tree() + if err != nil { + return nil, fmt.Errorf("file tree: %w", err) + } + + if path == "" { + files = makeNiceTree(tree) + } else { + o, err := tree.FindEntry(path) + if err != nil { + return nil, err + } + + if !o.Mode.IsFile() { + subtree, err := tree.Tree(path) + if err != nil { + return nil, err + } + files = makeNiceTree(subtree) + } + } + + return files, nil +}
M
internal/handlers/git.go
@@ -1,1 +1,77 @@
package handlers + +import ( + "compress/gzip" + "io" + "log/slog" + "net/http" + "path/filepath" + + "olexsmir.xyz/mugit/internal/git/gitservice" +) + +func (h *handlers) infoRefs(w http.ResponseWriter, r *http.Request) { + // TODO: 404 for private repos + + name := filepath.Clean(r.PathValue("name")) + + w.Header().Set("content-type", "application/x-git-upload-pack-advertisement") + w.WriteHeader(http.StatusOK) + + if err := gitservice.InfoRefs( + filepath.Join(h.c.Repo.Dir, name), // FIXME: use securejoin + w, + ); err != nil { + slog.Error("git: info/refs", "err", err) + return + } +} + +func (h *handlers) uploadPack(w http.ResponseWriter, r *http.Request) { + // TODO: 404 for private repos + + name := filepath.Clean(r.PathValue("name")) + + w.Header().Set("content-type", "application/x-git-upload-pack-result") + w.Header().Set("Connection", "Keep-Alive") + w.Header().Set("Transfer-Encoding", "chunked") + w.WriteHeader(http.StatusOK) + + reader := io.Reader(r.Body) + if r.Header.Get("Content-Encoding") == "gzip" { + gr, err := gzip.NewReader(r.Body) + if err != nil { + slog.Error("git: gzip reader", "err", err) + return + } + defer gr.Close() + reader = gr + } + + if err := gitservice.UploadPack( + filepath.Join(h.c.Repo.Dir, name), + reader, + newFlushWriter(w), + ); err != nil { + slog.Error("git: upload-pack", "err", err) + return + } +} + +type flushWriter struct { + w io.Writer + f http.Flusher +} + +func newFlushWriter(w http.ResponseWriter) io.Writer { + f, _ := w.(http.Flusher) + return &flushWriter{w: w, f: f} +} + +func (fw *flushWriter) Write(p []byte) (int, error) { + n, err := fw.w.Write(p) + if fw.f != nil { + fw.f.Flush() + } + return n, err +}
M
internal/handlers/handles.go
@@ -24,3 +24,24 @@ mux.HandleFunc("GET /", h.index)
return mux } + +// multiplex if request smells like gitprotocol-http(5) passes it to the git +// http service renders templates. +func (h *handlers) multiplex(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery == "service=git-receive-pack" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("http pushing isn't supported")) + return + } + + path := r.PathValue("rest") + if path == "info/refs" && + r.URL.RawQuery == "service=git-upload-pack" && + r.Method == "GET" { + h.infoRefs(w, r) + } else if path == "git-upload-pack" && r.Method == "POST" { + h.uploadPack(w, r) + } else if r.Method == "GET" { + h.repoIndex(w, r) + } +}