A deployable markdown editor that connects with your self hosted files and lets you edit in a beautiful interface
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement git operations and branch management for Phase 3

- Install go-git library for git operations
- Create GitOperations module with clone, branch, commit, push functions
- Implement BranchManager with 4-hour branch reuse logic
- Add branch state database queries (save, get, delete)
- Create branch status endpoint (GET /api/repos/:owner/:repo/branch/status)
- Create publish endpoint (POST /api/repos/:owner/:repo/publish)
- Publish endpoint handles commit, push, and PR creation
- Auto-delete drafts after successful publish

+874
+19
backend/go.mod
··· 12 12 ) 13 13 14 14 require ( 15 + dario.cat/mergo v1.0.0 // indirect 16 + github.com/Microsoft/go-winio v0.6.2 // indirect 17 + github.com/ProtonMail/go-crypto v1.1.6 // indirect 18 + github.com/cloudflare/circl v1.6.1 // indirect 19 + github.com/cyphar/filepath-securejoin v0.4.1 // indirect 15 20 github.com/dustin/go-humanize v1.0.1 // indirect 21 + github.com/emirpasic/gods v1.18.1 // indirect 22 + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 23 + github.com/go-git/go-billy/v5 v5.6.2 // indirect 24 + github.com/go-git/go-git/v5 v5.16.4 // indirect 25 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 16 26 github.com/google/go-github/v58 v58.0.0 // indirect 17 27 github.com/google/go-querystring v1.1.0 // indirect 18 28 github.com/google/uuid v1.6.0 // indirect 19 29 github.com/gorilla/mux v1.8.1 // indirect 20 30 github.com/gorilla/securecookie v1.1.2 // indirect 31 + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 32 + github.com/kevinburke/ssh_config v1.2.0 // indirect 21 33 github.com/mattn/go-isatty v0.0.20 // indirect 22 34 github.com/ncruces/go-strftime v1.0.0 // indirect 35 + github.com/pjbgf/sha1cd v0.3.2 // indirect 23 36 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 37 + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 38 + github.com/skeema/knownhosts v1.3.1 // indirect 39 + github.com/xanzy/ssh-agent v0.3.3 // indirect 40 + golang.org/x/crypto v0.37.0 // indirect 24 41 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect 42 + golang.org/x/net v0.39.0 // indirect 25 43 golang.org/x/oauth2 v0.27.0 // indirect 26 44 golang.org/x/sys v0.37.0 // indirect 45 + gopkg.in/warnings.v0 v0.1.2 // indirect 27 46 gopkg.in/yaml.v3 v3.0.1 // indirect 28 47 modernc.org/libc v1.67.6 // indirect 29 48 modernc.org/mathutil v1.7.1 // indirect
+64
backend/go.sum
··· 1 + dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 + dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 + github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 + github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 5 + github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 6 + github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= 7 + github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 8 + github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 9 + github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 10 + github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 11 + github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 12 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 1 13 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 14 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 15 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 4 16 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 17 + github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 18 + github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 5 19 github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= 6 20 github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 7 21 github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= 8 22 github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 23 + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 24 + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 25 + github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 26 + github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 27 + github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= 28 + github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= 29 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 30 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 9 31 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 10 32 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 11 33 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 34 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 12 35 github.com/google/go-github/v58 v58.0.0 h1:Una7GGERlF/37XfkPwpzYJe0Vp4dt2k1kCjlxwjIvzw= 13 36 github.com/google/go-github/v58 v58.0.0/go.mod h1:k4hxDKEfoWpSqFlc8LTpGd9fu2KrV1YAa6Hi6FmDNY4= 14 37 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= ··· 27 50 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 28 51 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 29 52 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 53 + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 54 + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 30 55 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 31 56 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 57 + github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 58 + github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 59 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 60 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 61 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 32 62 github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ= 33 63 github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk= 34 64 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 35 65 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 36 66 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 37 67 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 68 + github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 69 + github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 70 + github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 38 71 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 39 72 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 40 73 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 41 74 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 75 + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 76 + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 77 + github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 78 + github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= 79 + github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 80 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 81 + github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 82 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 42 83 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 43 84 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 85 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 86 + github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 87 + github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 88 + golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 89 + golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 90 + golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 44 91 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= 45 92 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= 46 93 golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= 47 94 golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 95 + golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 96 + golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 97 + golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 48 98 golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 49 99 golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 50 100 golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 51 101 golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 102 + golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 103 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 104 + golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 105 + golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 106 + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 107 + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 108 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 109 golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 54 110 golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 111 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 112 + golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 113 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 55 114 golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 56 115 golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 57 116 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 58 117 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 118 + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 119 + gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 120 + gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 121 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 122 + gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 59 123 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 60 124 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 61 125 modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
+304
backend/internal/api/handlers/branch.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "time" 9 + 10 + "github.com/go-chi/chi/v5" 11 + "github.com/google/go-github/v58/github" 12 + "github.com/yourusername/markedit/internal/auth" 13 + "github.com/yourusername/markedit/internal/database" 14 + "github.com/yourusername/markedit/internal/git" 15 + "golang.org/x/oauth2" 16 + ) 17 + 18 + // BranchHandler handles branch and publishing endpoints 19 + type BranchHandler struct { 20 + db *database.DB 21 + gitOps *git.GitOperations 22 + branchManager *git.BranchManager 23 + } 24 + 25 + // NewBranchHandler creates a new branch handler 26 + func NewBranchHandler(db *database.DB, cacheDir string) *BranchHandler { 27 + gitOps := git.NewGitOperations(cacheDir) 28 + branchManager := git.NewBranchManager(db, gitOps) 29 + 30 + return &BranchHandler{ 31 + db: db, 32 + gitOps: gitOps, 33 + branchManager: branchManager, 34 + } 35 + } 36 + 37 + // BranchStatusResponse represents the branch status 38 + type BranchStatusResponse struct { 39 + BranchName string `json:"branch_name"` 40 + BaseBranch string `json:"base_branch"` 41 + HasChanges bool `json:"has_changes"` 42 + LastPushAt time.Time `json:"last_push_at"` 43 + EditedFiles []string `json:"edited_files"` 44 + HoursSincePush float64 `json:"hours_since_push"` 45 + HasDraftContent bool `json:"has_draft_content"` 46 + } 47 + 48 + // GetBranchStatus returns the current branch status for a repository 49 + func (h *BranchHandler) GetBranchStatus(w http.ResponseWriter, r *http.Request) { 50 + owner := chi.URLParam(r, "owner") 51 + repo := chi.URLParam(r, "repo") 52 + 53 + if owner == "" || repo == "" { 54 + http.Error(w, "Missing owner or repo parameter", http.StatusBadRequest) 55 + return 56 + } 57 + 58 + // Get user from session 59 + session, err := auth.GetSession(r) 60 + if err != nil { 61 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 62 + return 63 + } 64 + 65 + userID, ok := auth.GetUserID(session) 66 + if !ok { 67 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 68 + return 69 + } 70 + 71 + repoFullName := fmt.Sprintf("%s/%s", owner, repo) 72 + 73 + // Get branch state 74 + state, err := h.db.GetBranchState(userID, repoFullName) 75 + if err != nil { 76 + log.Printf("Failed to get branch state: %v", err) 77 + http.Error(w, "Failed to get branch state", http.StatusInternalServerError) 78 + return 79 + } 80 + 81 + // If no state exists, return empty response 82 + if state == nil { 83 + response := BranchStatusResponse{ 84 + HasChanges: false, 85 + HasDraftContent: false, 86 + } 87 + w.Header().Set("Content-Type", "application/json") 88 + json.NewEncoder(w).Encode(response) 89 + return 90 + } 91 + 92 + // Parse file paths 93 + var filePaths []string 94 + if err := json.Unmarshal([]byte(state.FilePaths), &filePaths); err != nil { 95 + filePaths = []string{} 96 + } 97 + 98 + // Calculate hours since push 99 + hoursSincePush := time.Since(state.LastPushAt).Hours() 100 + 101 + response := BranchStatusResponse{ 102 + BranchName: state.BranchName, 103 + BaseBranch: state.BaseBranch, 104 + HasChanges: state.HasUncommittedChanges, 105 + LastPushAt: state.LastPushAt, 106 + EditedFiles: filePaths, 107 + HoursSincePush: hoursSincePush, 108 + HasDraftContent: state.HasUncommittedChanges, 109 + } 110 + 111 + w.Header().Set("Content-Type", "application/json") 112 + json.NewEncoder(w).Encode(response) 113 + } 114 + 115 + // PublishRequest represents a publish request 116 + type PublishRequest struct { 117 + CommitMessage string `json:"commit_message"` 118 + PRTitle string `json:"pr_title"` 119 + PRDescription string `json:"pr_description"` 120 + Files []string `json:"files"` 121 + } 122 + 123 + // PublishResponse represents a publish response 124 + type PublishResponse struct { 125 + Success bool `json:"success"` 126 + Branch string `json:"branch"` 127 + CommitSHA string `json:"commit_sha"` 128 + PullRequest *github.PullRequest `json:"pull_request,omitempty"` 129 + Error string `json:"error,omitempty"` 130 + } 131 + 132 + // Publish commits changes and creates a pull request 133 + func (h *BranchHandler) Publish(w http.ResponseWriter, r *http.Request) { 134 + owner := chi.URLParam(r, "owner") 135 + repo := chi.URLParam(r, "repo") 136 + 137 + if owner == "" || repo == "" { 138 + http.Error(w, "Missing owner or repo parameter", http.StatusBadRequest) 139 + return 140 + } 141 + 142 + // Get user from session 143 + session, err := auth.GetSession(r) 144 + if err != nil { 145 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 146 + return 147 + } 148 + 149 + userID, ok := auth.GetUserID(session) 150 + if !ok { 151 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 152 + return 153 + } 154 + 155 + // Get user's GitHub access token 156 + token, err := h.db.GetAuthToken(userID, "github") 157 + if err != nil { 158 + log.Printf("Failed to get auth token: %v", err) 159 + http.Error(w, "Failed to get auth token", http.StatusInternalServerError) 160 + return 161 + } 162 + 163 + // Parse request body 164 + var req PublishRequest 165 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 166 + http.Error(w, "Invalid request body", http.StatusBadRequest) 167 + return 168 + } 169 + 170 + // Validate required fields 171 + if req.CommitMessage == "" { 172 + http.Error(w, "Commit message is required", http.StatusBadRequest) 173 + return 174 + } 175 + 176 + ctx := r.Context() 177 + repoFullName := fmt.Sprintf("%s/%s", owner, repo) 178 + cloneURL := fmt.Sprintf("https://github.com/%s.git", repoFullName) 179 + 180 + // Clone or open repository 181 + gitRepo, err := h.gitOps.CloneOrOpen(ctx, repoFullName, cloneURL, token.AccessToken) 182 + if err != nil { 183 + log.Printf("Failed to clone/open repository: %v", err) 184 + http.Error(w, fmt.Sprintf("Failed to access repository: %v", err), http.StatusInternalServerError) 185 + return 186 + } 187 + 188 + // Get or create branch 189 + branchName, err := h.branchManager.GetOrCreateBranch(ctx, userID, repoFullName, gitRepo, "main") 190 + if err != nil { 191 + log.Printf("Failed to get/create branch: %v", err) 192 + http.Error(w, fmt.Sprintf("Failed to manage branch: %v", err), http.StatusInternalServerError) 193 + return 194 + } 195 + 196 + // Get draft files and write them to the repository 197 + filesToCommit := req.Files 198 + if len(filesToCommit) == 0 { 199 + // Get all draft files for this repository 200 + // For now, we'll require files to be specified 201 + http.Error(w, "No files specified to publish", http.StatusBadRequest) 202 + return 203 + } 204 + 205 + // Write draft content to repository files 206 + for _, filePath := range filesToCommit { 207 + draft, err := h.db.GetDraftContent(userID, repoFullName, filePath) 208 + if err != nil { 209 + log.Printf("Failed to get draft for %s: %v", filePath, err) 210 + continue 211 + } 212 + if draft == nil { 213 + log.Printf("No draft found for %s", filePath) 214 + continue 215 + } 216 + 217 + if err := h.gitOps.WriteFile(repoFullName, filePath, draft.Content); err != nil { 218 + log.Printf("Failed to write file %s: %v", filePath, err) 219 + http.Error(w, fmt.Sprintf("Failed to write file: %v", err), http.StatusInternalServerError) 220 + return 221 + } 222 + } 223 + 224 + // Commit changes 225 + commitSHA, err := h.gitOps.CommitChanges(gitRepo, req.CommitMessage, filesToCommit) 226 + if err != nil { 227 + log.Printf("Failed to commit changes: %v", err) 228 + http.Error(w, fmt.Sprintf("Failed to commit: %v", err), http.StatusInternalServerError) 229 + return 230 + } 231 + 232 + log.Printf("Committed changes: %s", commitSHA) 233 + 234 + // Push to remote 235 + if err := h.gitOps.Push(gitRepo, branchName, token.AccessToken); err != nil { 236 + log.Printf("Failed to push: %v", err) 237 + http.Error(w, fmt.Sprintf("Failed to push: %v", err), http.StatusInternalServerError) 238 + return 239 + } 240 + 241 + log.Printf("Pushed branch %s", branchName) 242 + 243 + // Create GitHub client 244 + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token.AccessToken}) 245 + tc := oauth2.NewClient(ctx, ts) 246 + client := github.NewClient(tc) 247 + 248 + // Create pull request 249 + prTitle := req.PRTitle 250 + if prTitle == "" { 251 + prTitle = req.CommitMessage 252 + } 253 + 254 + prDescription := req.PRDescription 255 + if prDescription == "" { 256 + prDescription = fmt.Sprintf("Updated %d file(s) via MarkEdit", len(filesToCommit)) 257 + } 258 + 259 + baseBranch := "main" 260 + pr, _, err := client.PullRequests.Create(ctx, owner, repo, &github.NewPullRequest{ 261 + Title: &prTitle, 262 + Head: &branchName, 263 + Base: &baseBranch, 264 + Body: &prDescription, 265 + }) 266 + 267 + if err != nil { 268 + log.Printf("Failed to create pull request: %v", err) 269 + // Don't fail the entire request, just log the error 270 + response := PublishResponse{ 271 + Success: true, 272 + Branch: branchName, 273 + CommitSHA: commitSHA, 274 + Error: fmt.Sprintf("Committed and pushed, but failed to create PR: %v", err), 275 + } 276 + w.Header().Set("Content-Type", "application/json") 277 + json.NewEncoder(w).Encode(response) 278 + return 279 + } 280 + 281 + log.Printf("Created pull request #%d", pr.GetNumber()) 282 + 283 + // Update branch state 284 + if err := h.branchManager.UpdateBranchState(userID, repoFullName, branchName, filesToCommit, false); err != nil { 285 + log.Printf("Failed to update branch state: %v", err) 286 + } 287 + 288 + // Delete draft content for published files 289 + for _, filePath := range filesToCommit { 290 + if err := h.db.DeleteDraftContent(userID, repoFullName, filePath); err != nil { 291 + log.Printf("Failed to delete draft for %s: %v", filePath, err) 292 + } 293 + } 294 + 295 + response := PublishResponse{ 296 + Success: true, 297 + Branch: branchName, 298 + CommitSHA: commitSHA, 299 + PullRequest: pr, 300 + } 301 + 302 + w.Header().Set("Content-Type", "application/json") 303 + json.NewEncoder(w).Encode(response) 304 + }
+11
backend/internal/api/router.go
··· 42 42 authHandler := handlers.NewAuthHandler(db) 43 43 repoHandler := handlers.NewRepoHandler(db) 44 44 45 + // Get cache directory from environment 46 + cacheDir := os.Getenv("GIT_CACHE_DIR") 47 + if cacheDir == "" { 48 + cacheDir = "./data/repos" 49 + } 50 + branchHandler := handlers.NewBranchHandler(db, cacheDir) 51 + 45 52 // Public routes 46 53 r.Get("/api/health", handlers.HealthCheck) 47 54 r.Get("/api/auth/github/login", authHandler.BeginAuth) ··· 59 66 r.Get("/api/repos/{owner}/{repo}/files", repoHandler.ListFiles) 60 67 r.Get("/api/repos/{owner}/{repo}/files/*", repoHandler.GetFileContent) 61 68 r.Put("/api/repos/{owner}/{repo}/files/*", repoHandler.UpdateFileContent) 69 + 70 + // Branch routes 71 + r.Get("/api/repos/{owner}/{repo}/branch/status", branchHandler.GetBranchStatus) 72 + r.Post("/api/repos/{owner}/{repo}/publish", branchHandler.Publish) 62 73 }) 63 74 64 75 return r
+73
backend/internal/database/queries.go
··· 194 194 195 195 return nil 196 196 } 197 + 198 + // SaveBranchState saves or updates branch state for a repository 199 + func (db *DB) SaveBranchState(state *BranchState) error { 200 + query := ` 201 + INSERT INTO branch_states (user_id, repo_full_name, branch_name, base_branch, last_push_at, has_uncommitted_changes, file_paths, updated_at) 202 + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) 203 + ON CONFLICT(user_id, repo_full_name) DO UPDATE SET 204 + branch_name = excluded.branch_name, 205 + base_branch = excluded.base_branch, 206 + last_push_at = excluded.last_push_at, 207 + has_uncommitted_changes = excluded.has_uncommitted_changes, 208 + file_paths = excluded.file_paths, 209 + updated_at = CURRENT_TIMESTAMP 210 + RETURNING id, created_at, updated_at 211 + ` 212 + 213 + return db.QueryRow(query, 214 + state.UserID, 215 + state.RepoFullName, 216 + state.BranchName, 217 + state.BaseBranch, 218 + state.LastPushAt, 219 + state.HasUncommittedChanges, 220 + state.FilePaths, 221 + ).Scan(&state.ID, &state.CreatedAt, &state.UpdatedAt) 222 + } 223 + 224 + // GetBranchState retrieves branch state for a repository 225 + func (db *DB) GetBranchState(userID int, repoFullName string) (*BranchState, error) { 226 + state := &BranchState{} 227 + query := ` 228 + SELECT id, user_id, repo_full_name, branch_name, base_branch, last_push_at, has_uncommitted_changes, file_paths, created_at, updated_at 229 + FROM branch_states 230 + WHERE user_id = ? AND repo_full_name = ? 231 + ` 232 + 233 + err := db.QueryRow(query, userID, repoFullName).Scan( 234 + &state.ID, 235 + &state.UserID, 236 + &state.RepoFullName, 237 + &state.BranchName, 238 + &state.BaseBranch, 239 + &state.LastPushAt, 240 + &state.HasUncommittedChanges, 241 + &state.FilePaths, 242 + &state.CreatedAt, 243 + &state.UpdatedAt, 244 + ) 245 + 246 + if err == sql.ErrNoRows { 247 + return nil, nil // No branch state found, return nil without error 248 + } 249 + if err != nil { 250 + return nil, fmt.Errorf("failed to get branch state: %w", err) 251 + } 252 + 253 + return state, nil 254 + } 255 + 256 + // DeleteBranchState deletes branch state for a repository 257 + func (db *DB) DeleteBranchState(userID int, repoFullName string) error { 258 + query := ` 259 + DELETE FROM branch_states 260 + WHERE user_id = ? AND repo_full_name = ? 261 + ` 262 + 263 + _, err := db.Exec(query, userID, repoFullName) 264 + if err != nil { 265 + return fmt.Errorf("failed to delete branch state: %w", err) 266 + } 267 + 268 + return nil 269 + }
+175
backend/internal/git/branch_manager.go
··· 1 + package git 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + "time" 9 + 10 + "github.com/go-git/go-git/v5" 11 + "github.com/yourusername/markedit/internal/database" 12 + ) 13 + 14 + // BranchManager handles branch lifecycle management 15 + type BranchManager struct { 16 + db *database.DB 17 + git *GitOperations 18 + } 19 + 20 + // NewBranchManager creates a new BranchManager 21 + func NewBranchManager(db *database.DB, git *GitOperations) *BranchManager { 22 + return &BranchManager{ 23 + db: db, 24 + git: git, 25 + } 26 + } 27 + 28 + // GetOrCreateBranch returns an existing branch or creates a new one based on the 4-hour rule 29 + func (bm *BranchManager) GetOrCreateBranch( 30 + ctx context.Context, 31 + userID int, 32 + repoFullName string, 33 + repo *git.Repository, 34 + baseBranch string, 35 + ) (string, error) { 36 + // Check database for existing branch state 37 + state, err := bm.db.GetBranchState(userID, repoFullName) 38 + if err != nil { 39 + return "", fmt.Errorf("failed to get branch state: %w", err) 40 + } 41 + 42 + // No existing branch state - create new branch 43 + if state == nil { 44 + return bm.createNewBranch(ctx, userID, repoFullName, repo, baseBranch) 45 + } 46 + 47 + // Calculate time since last push 48 + hoursSincePush := time.Since(state.LastPushAt).Hours() 49 + 50 + // If more than 4 hours have passed and no uncommitted changes, create new branch 51 + if hoursSincePush > 4 && !state.HasUncommittedChanges { 52 + log.Printf("Branch %s is older than 4 hours (%.1f hours), creating new branch", state.BranchName, hoursSincePush) 53 + 54 + // Delete old branch locally 55 + _ = bm.git.DeleteLocalBranch(repo, state.BranchName) 56 + 57 + // Delete old branch state 58 + _ = bm.db.DeleteBranchState(userID, repoFullName) 59 + 60 + return bm.createNewBranch(ctx, userID, repoFullName, repo, baseBranch) 61 + } 62 + 63 + // Reuse existing branch 64 + log.Printf("Reusing existing branch %s (%.1f hours old)", state.BranchName, hoursSincePush) 65 + 66 + // Checkout the existing branch 67 + if err := bm.git.CheckoutBranch(repo, state.BranchName); err != nil { 68 + // If checkout fails, create new branch 69 + log.Printf("Failed to checkout existing branch, creating new one: %v", err) 70 + return bm.createNewBranch(ctx, userID, repoFullName, repo, baseBranch) 71 + } 72 + 73 + return state.BranchName, nil 74 + } 75 + 76 + // createNewBranch creates a new branch and saves its state 77 + func (bm *BranchManager) createNewBranch( 78 + ctx context.Context, 79 + userID int, 80 + repoFullName string, 81 + repo *git.Repository, 82 + baseBranch string, 83 + ) (string, error) { 84 + // Generate branch name with timestamp 85 + branchName := fmt.Sprintf("markedit-%d", time.Now().Unix()) 86 + 87 + log.Printf("Creating new branch: %s", branchName) 88 + 89 + // Create branch in git 90 + if err := bm.git.CreateBranch(repo, branchName, baseBranch); err != nil { 91 + return "", fmt.Errorf("failed to create branch: %w", err) 92 + } 93 + 94 + // Save branch state to database 95 + state := &database.BranchState{ 96 + UserID: userID, 97 + RepoFullName: repoFullName, 98 + BranchName: branchName, 99 + BaseBranch: baseBranch, 100 + LastPushAt: time.Now(), 101 + HasUncommittedChanges: false, 102 + FilePaths: "[]", // Empty JSON array 103 + } 104 + 105 + if err := bm.db.SaveBranchState(state); err != nil { 106 + return "", fmt.Errorf("failed to save branch state: %w", err) 107 + } 108 + 109 + return branchName, nil 110 + } 111 + 112 + // UpdateBranchState updates the branch state after changes 113 + func (bm *BranchManager) UpdateBranchState( 114 + userID int, 115 + repoFullName string, 116 + branchName string, 117 + filePaths []string, 118 + hasUncommittedChanges bool, 119 + ) error { 120 + // Get existing state 121 + state, err := bm.db.GetBranchState(userID, repoFullName) 122 + if err != nil { 123 + return fmt.Errorf("failed to get branch state: %w", err) 124 + } 125 + 126 + if state == nil { 127 + return fmt.Errorf("branch state not found") 128 + } 129 + 130 + // Update state 131 + state.HasUncommittedChanges = hasUncommittedChanges 132 + if filePaths != nil { 133 + filePathsJSON, err := json.Marshal(filePaths) 134 + if err != nil { 135 + return fmt.Errorf("failed to marshal file paths: %w", err) 136 + } 137 + state.FilePaths = string(filePathsJSON) 138 + } 139 + 140 + if !hasUncommittedChanges { 141 + // Reset last push time if we just committed/pushed 142 + state.LastPushAt = time.Now() 143 + } 144 + 145 + if err := bm.db.SaveBranchState(state); err != nil { 146 + return fmt.Errorf("failed to update branch state: %w", err) 147 + } 148 + 149 + return nil 150 + } 151 + 152 + // HandleConflict handles merge conflicts by creating a new branch 153 + func (bm *BranchManager) HandleConflict( 154 + ctx context.Context, 155 + userID int, 156 + repoFullName string, 157 + repo *git.Repository, 158 + baseBranch string, 159 + ) (string, error) { 160 + log.Printf("Handling conflict for %s, creating new branch", repoFullName) 161 + 162 + // Get current state 163 + state, _ := bm.db.GetBranchState(userID, repoFullName) 164 + 165 + // Delete old branch locally if it exists 166 + if state != nil { 167 + _ = bm.git.DeleteLocalBranch(repo, state.BranchName) 168 + } 169 + 170 + // Delete old state 171 + _ = bm.db.DeleteBranchState(userID, repoFullName) 172 + 173 + // Create new branch 174 + return bm.createNewBranch(ctx, userID, repoFullName, repo, baseBranch) 175 + }
+228
backend/internal/git/operations.go
··· 1 + package git 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "path/filepath" 8 + "time" 9 + 10 + "github.com/go-git/go-git/v5" 11 + "github.com/go-git/go-git/v5/config" 12 + "github.com/go-git/go-git/v5/plumbing" 13 + "github.com/go-git/go-git/v5/plumbing/object" 14 + "github.com/go-git/go-git/v5/plumbing/transport/http" 15 + ) 16 + 17 + // GitOperations handles git operations on repositories 18 + type GitOperations struct { 19 + cacheDir string 20 + } 21 + 22 + // NewGitOperations creates a new GitOperations instance 23 + func NewGitOperations(cacheDir string) *GitOperations { 24 + return &GitOperations{ 25 + cacheDir: cacheDir, 26 + } 27 + } 28 + 29 + // getRepoPath returns the local path for a repository 30 + func (g *GitOperations) getRepoPath(repoFullName string) string { 31 + return filepath.Join(g.cacheDir, repoFullName) 32 + } 33 + 34 + // CloneOrOpen clones a repository if it doesn't exist, or opens it if it does 35 + func (g *GitOperations) CloneOrOpen(ctx context.Context, repoFullName, cloneURL, accessToken string) (*git.Repository, error) { 36 + repoPath := g.getRepoPath(repoFullName) 37 + 38 + // Check if repository already exists 39 + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { 40 + // Repository exists, open it 41 + repo, err := git.PlainOpen(repoPath) 42 + if err != nil { 43 + return nil, fmt.Errorf("failed to open repository: %w", err) 44 + } 45 + 46 + // Fetch latest changes 47 + err = repo.Fetch(&git.FetchOptions{ 48 + Auth: &http.BasicAuth{ 49 + Username: "oauth2", 50 + Password: accessToken, 51 + }, 52 + Force: true, 53 + }) 54 + if err != nil && err != git.NoErrAlreadyUpToDate { 55 + return nil, fmt.Errorf("failed to fetch: %w", err) 56 + } 57 + 58 + return repo, nil 59 + } 60 + 61 + // Repository doesn't exist, clone it 62 + if err := os.MkdirAll(filepath.Dir(repoPath), 0755); err != nil { 63 + return nil, fmt.Errorf("failed to create cache directory: %w", err) 64 + } 65 + 66 + repo, err := git.PlainClone(repoPath, false, &git.CloneOptions{ 67 + URL: cloneURL, 68 + Auth: &http.BasicAuth{ 69 + Username: "oauth2", 70 + Password: accessToken, 71 + }, 72 + Progress: nil, 73 + }) 74 + if err != nil { 75 + return nil, fmt.Errorf("failed to clone repository: %w", err) 76 + } 77 + 78 + return repo, nil 79 + } 80 + 81 + // CreateBranch creates a new branch from the default branch 82 + func (g *GitOperations) CreateBranch(repo *git.Repository, branchName, baseBranch string) error { 83 + // Get the base branch reference 84 + baseBranchRef := plumbing.NewRemoteReferenceName("origin", baseBranch) 85 + baseRef, err := repo.Reference(baseBranchRef, true) 86 + if err != nil { 87 + return fmt.Errorf("failed to get base branch reference: %w", err) 88 + } 89 + 90 + // Create new branch reference 91 + newBranchRef := plumbing.NewBranchReferenceName(branchName) 92 + ref := plumbing.NewHashReference(newBranchRef, baseRef.Hash()) 93 + 94 + // Store the reference 95 + if err := repo.Storer.SetReference(ref); err != nil { 96 + return fmt.Errorf("failed to create branch reference: %w", err) 97 + } 98 + 99 + // Checkout the new branch 100 + w, err := repo.Worktree() 101 + if err != nil { 102 + return fmt.Errorf("failed to get worktree: %w", err) 103 + } 104 + 105 + if err := w.Checkout(&git.CheckoutOptions{ 106 + Branch: newBranchRef, 107 + Force: true, 108 + }); err != nil { 109 + return fmt.Errorf("failed to checkout branch: %w", err) 110 + } 111 + 112 + return nil 113 + } 114 + 115 + // CheckoutBranch checks out an existing branch 116 + func (g *GitOperations) CheckoutBranch(repo *git.Repository, branchName string) error { 117 + w, err := repo.Worktree() 118 + if err != nil { 119 + return fmt.Errorf("failed to get worktree: %w", err) 120 + } 121 + 122 + branchRef := plumbing.NewBranchReferenceName(branchName) 123 + if err := w.Checkout(&git.CheckoutOptions{ 124 + Branch: branchRef, 125 + Force: true, 126 + }); err != nil { 127 + return fmt.Errorf("failed to checkout branch: %w", err) 128 + } 129 + 130 + return nil 131 + } 132 + 133 + // WriteFile writes content to a file in the repository 134 + func (g *GitOperations) WriteFile(repoFullName, filePath, content string) error { 135 + repoPath := g.getRepoPath(repoFullName) 136 + fullPath := filepath.Join(repoPath, filePath) 137 + 138 + // Create directory if it doesn't exist 139 + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { 140 + return fmt.Errorf("failed to create directory: %w", err) 141 + } 142 + 143 + // Write file 144 + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { 145 + return fmt.Errorf("failed to write file: %w", err) 146 + } 147 + 148 + return nil 149 + } 150 + 151 + // CommitChanges commits all changes with a message 152 + func (g *GitOperations) CommitChanges(repo *git.Repository, message string, filePaths []string) (string, error) { 153 + w, err := repo.Worktree() 154 + if err != nil { 155 + return "", fmt.Errorf("failed to get worktree: %w", err) 156 + } 157 + 158 + // Add files 159 + for _, filePath := range filePaths { 160 + if _, err := w.Add(filePath); err != nil { 161 + return "", fmt.Errorf("failed to add file %s: %w", filePath, err) 162 + } 163 + } 164 + 165 + // Commit 166 + commit, err := w.Commit(message, &git.CommitOptions{ 167 + Author: &object.Signature{ 168 + Name: "MarkEdit", 169 + Email: "markedit@users.noreply.github.com", 170 + When: time.Now(), 171 + }, 172 + }) 173 + if err != nil { 174 + return "", fmt.Errorf("failed to commit: %w", err) 175 + } 176 + 177 + return commit.String(), nil 178 + } 179 + 180 + // Push pushes the current branch to remote 181 + func (g *GitOperations) Push(repo *git.Repository, branchName, accessToken string) error { 182 + // Get the branch reference 183 + branchRef := plumbing.NewBranchReferenceName(branchName) 184 + 185 + // Push the branch 186 + err := repo.Push(&git.PushOptions{ 187 + RemoteName: "origin", 188 + RefSpecs: []config.RefSpec{ 189 + config.RefSpec(branchRef + ":" + branchRef), 190 + }, 191 + Auth: &http.BasicAuth{ 192 + Username: "oauth2", 193 + Password: accessToken, 194 + }, 195 + Force: false, 196 + }) 197 + 198 + if err != nil && err != git.NoErrAlreadyUpToDate { 199 + return fmt.Errorf("failed to push: %w", err) 200 + } 201 + 202 + return nil 203 + } 204 + 205 + // DeleteLocalBranch deletes a local branch 206 + func (g *GitOperations) DeleteLocalBranch(repo *git.Repository, branchName string) error { 207 + branchRef := plumbing.NewBranchReferenceName(branchName) 208 + 209 + if err := repo.Storer.RemoveReference(branchRef); err != nil { 210 + return fmt.Errorf("failed to delete branch: %w", err) 211 + } 212 + 213 + return nil 214 + } 215 + 216 + // GetCurrentBranch returns the name of the current branch 217 + func (g *GitOperations) GetCurrentBranch(repo *git.Repository) (string, error) { 218 + head, err := repo.Head() 219 + if err != nil { 220 + return "", fmt.Errorf("failed to get HEAD: %w", err) 221 + } 222 + 223 + if !head.Name().IsBranch() { 224 + return "", fmt.Errorf("HEAD is not a branch") 225 + } 226 + 227 + return head.Name().Short(), nil 228 + }