| commit |
a22ffc4596a5207586bbdfb7bf8f514953195b9b629fbcddb46ecdaea2ab29dc
|
|---|---|
| change |
ncgfabwe
|
| author | dewn <dewn5228@proton.me> |
| committer | dewn <dewn5228@proton.me> |
| date | 2026-03-12 16:47:50 |
| phase | public |
| bookmarks | main |
| parents |
90c6eb90
|
| signature | Unsigned |
fix public repo clone auth; shelve/unshelve ignore dirs
--- a/go.mod +++ b/go.mod @@ -1,45 +1,46 @@ module arche go 1.25.0 require ( github.com/BurntSushi/toml v1.6.0 github.com/alecthomas/chroma/v2 v2.23.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/google/uuid v1.6.0 github.com/klauspost/compress v1.18.4 github.com/mattn/go-sqlite3 v1.14.34 github.com/sergi/go-diff v1.4.0 github.com/spf13/cobra v1.10.2 github.com/yuin/goldmark v1.7.16 github.com/zeebo/blake3 v0.2.4 golang.org/x/crypto v0.48.0 ) require ( + github.com/a-h/templ v0.3.1001 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/testify v1. -9 +10 .0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.34.0 // indirect )
--- a/go.sum +++ b/go.sum @@ -1,109 +1,112 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= +github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= 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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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/archesrv/handlers_repo.go +++ b/internal/archesrv/handlers_repo.go @@ -1,894 +1,898 @@ package archesrv import ( "bytes" "encoding/hex" "fmt" "html/template" "net/http" "sort" "strings" "arche/internal/diff" "arche/internal/markdown" "arche/internal/object" "arche/internal/repo" "arche/internal/revset" "arche/internal/syncpkg" "github.com/alecthomas/chroma/v2" chrhtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" "golang.org/x/crypto/ssh" ) func (s *forgeServer) requireRepoAccess(w http.ResponseWriter, r *http.Request) (*repo.Repo, *RepoRecord, bool) { repoName := r.PathValue("repo") rec, err := s.db.GetRepo(repoName) if err != nil || rec == nil { http.NotFound(w, r) return nil, nil, false } user := s.db.currentUser(r) if !s.db.CanRead(rec, user) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return nil, nil, false } repoObj, err := openRepo(s.dataDir(), repoName) if err != nil { http.Error(w, "open repo: "+err.Error(), http.StatusInternalServerError) return nil, nil, false } return repoObj, rec, true } func (s *forgeServer) handleSyncProxy(w http.ResponseWriter, r *http.Request) { repoName := r.PathValue("repo") rec, err := s.db.GetRepo(repoName) if err != nil || rec == nil { http.Error(w, "repo not found", http.StatusNotFound) return } user := s.db.currentUser(r) i -f +sReadOp := r.Method -! += = http.MethodGet +|| + strings.HasSuffix(r.URL.Path, "/arche/v1/bloom") || + strings.HasSuffix(r.URL.Path, "/arche/v1/fetch") + + if !isReadOp && !s.db.CanWrite(rec, user) { user := s.db.currentUser(r) username := "anonymous" if user != nil { username = user.Username } s.log.Warn("sync write denied", "repo", repoName, "user", username) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } if -r.Method == http.MethodGet +isReadOp && !s.db.CanRead(rec, user) { s.log.Warn("sync read denied", "repo", repoName) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } repoObj, err := openRepo(s.dataDir(), repoName) if err != nil { http.Error(w, "open repo: "+err.Error(), http.StatusInternalServerError) return } defer repoObj.Close() action := strings.TrimPrefix(r.URL.Path, "/"+repoName) r2 := r.Clone(r.Context()) r2.URL.Path = action user = s.db.currentUser(r) pusher := "anonymous" if user != nil { pusher = user.Username } srv := syncpkg.NewServer(repoObj, "") repoKey := repoName repoCfg := s.cfg.Repo[repoKey] srv.PreUpdateHook = func(bm, oldHex, newHex string) error { if s.cfg.Hooks.PreReceive != "" || s.cfg.Hooks.Update != "" { if err := runPreReceiveHook(s.cfg.Hooks.PreReceive, bm, oldHex, newHex, s.cfg.Hooks.TimeoutSec); err != nil { return err } if err := runPreReceiveHook(s.cfg.Hooks.Update, bm, oldHex, newHex, s.cfg.Hooks.TimeoutSec); err != nil { return err } } if repoCfg.RequireSignedCommits && user != nil { for _, id := range collectNewCommitIDs(repoObj, oldHex, newHex) { c, err := repoObj.ReadCommit(id) if err != nil { continue } if len(c.CommitSig) == 0 { return fmt.Errorf("commit %s (ch:%s) is unsigned; this repository requires signed commits", hex.EncodeToString(id[:8]), c.ChangeID) } body := object.CommitBodyForSigning(c) keys, _ := s.db.ListSSHKeys(user.ID) verified := false for _, k := range keys { pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.PublicKey)) if err != nil { continue } if object.VerifyCommitSig(body, c.CommitSig, pub) == nil { verified = true break } } if !verified { return fmt.Errorf("commit %s (ch:%s) has an unverifiable signature; this repository requires commits signed by a registered key", hex.EncodeToString(id[:8]), c.ChangeID) } } } return nil } srv.OnBookmarkUpdated = func(bm, oldHex, newHex string) { s.db.FirePushWebhooks(repoName, pusher, bm, oldHex, newHex, collectPushCommits(repoObj, oldHex, newHex)) runPostReceiveHook(s.cfg.Hooks.PostReceive, bm, oldHex, newHex, s.cfg.Hooks.TimeoutSec) if user != nil { for _, id := range collectNewCommitIDs(repoObj, oldHex, newHex) { c, err := repoObj.ReadCommit(id) if err != nil { continue } _ = s.db.RecordCommitSignature(repoObj, id, c, user.ID) } } if allowed, script, _ := s.db.GetRepoHookConfig(rec.ID); allowed && script != "" { if !s.db.hasWriteCollaborator(rec.ID) { runPostReceiveHook(script, bm, oldHex, newHex, s.cfg.Hooks.TimeoutSec) } } } srv.Handler().ServeHTTP(w, r2) } func collectPushCommits(r *repo.Repo, oldHex, newHex string) []CommitRef { if len(newHex) != 64 { return []CommitRef{} } newBytes, err := hex.DecodeString(newHex) if err != nil || len(newBytes) != 32 { return []CommitRef{} } var newID [32]byte copy(newID[:], newBytes) var oldID [32]byte if len(oldHex) == 64 { if oldBytes, err2 := hex.DecodeString(oldHex); err2 == nil && len(oldBytes) == 32 { copy(oldID[:], oldBytes) } } seen := make(map[[32]byte]bool) queue := [][32]byte{newID} var results []CommitRef const maxCommits = 50 for len(queue) > 0 && len(results) < maxCommits { id := queue[0] queue = queue[1:] if seen[id] || id == oldID { continue } seen[id] = true c, err := r.ReadCommit(id) if err != nil { break } author := c.Author.Name if c.Author.Email != "" { author += " <" + c.Author.Email + ">" } results = append(results, CommitRef{ ID: hex.EncodeToString(id[:]), ChangeID: "ch:" + c.ChangeID, Message: c.Message, Author: author, }) for _, p := range c.Parents { if !seen[p] && p != oldID { queue = append(queue, p) } } } return results } type srvHomeData struct { Repo string User *User CommitHex string ShortHex string ReadmeName string ReadmeHTML template.HTML Entries []srvTreeEntry } func (s *forgeServer) handleRepoHome(w http.ResponseWriter, r *http.Request) { repoObj, rec, ok := s.requireRepoAccess(w, r) if !ok { return } defer repoObj.Close() commitID := resolveDefaultCommit(repoObj) if commitID == ([32]byte{}) { _, id, err := repoObj.HeadCommit() if err != nil { http.Redirect(w, r, "/"+rec.Name+"/log", http.StatusFound) return } commitID = id } c, err := repoObj.ReadCommit(commitID) if err != nil { http.Redirect(w, r, "/"+rec.Name+"/log", http.StatusFound) return } tree, err := repoObj.ReadTree(c.TreeID) if err != nil { http.Redirect(w, r, "/"+rec.Name+"/log", http.StatusFound) return } commitHex := fullHex(commitID) var entries []srvTreeEntry for _, e := range tree.Entries { isDir := e.Mode == object.ModeDir var link string if isDir { link = fmt.Sprintf("/%s/tree?id=%s&path=%s", rec.Name, commitHex, e.Name) } else { link = fmt.Sprintf("/%s/file?id=%s&path=%s", rec.Name, commitHex, e.Name) } entries = append(entries, srvTreeEntry{ Name: e.Name, IsDir: isDir, Mode: modeStr(e.Mode), Link: link, }) } var readmeName string var readmeHTML template.HTML for _, candidate := range []string{"README.md", "readme.md", "README", "readme"} { for _, e := range tree.Entries { if e.Name == candidate && e.Mode != object.ModeDir { content, err := repoObj.ReadBlob(e.ObjectID) if err != nil || isBinaryContent(content) { break } readmeName = e.Name if strings.HasSuffix(strings.ToLower(e.Name), ".md") { readmeHTML = markdown.Render(string(content)) } else { readmeHTML = template.HTML("<pre>" + template.HTMLEscapeString(string(content)) + "</pre>") } break } } if readmeName != "" { break } } if readmeName == "" && len(entries) == 0 { http.Redirect(w, r, "/"+rec.Name+"/log", http.StatusFound) return } s.render(w, "srv_repo_home.html", srvHomeData{ Repo: rec.Name, User: s.db.currentUser(r), CommitHex: commitHex, ShortHex: shortHex(commitID), ReadmeName: readmeName, ReadmeHTML: readmeHTML, Entries: entries, }) } type srvCommitRow struct { HexID string ShortHex string ChangeID string Author string Date string Phase string PhaseClass string Message string Bookmarks []string IsHead bool } type srvLogData struct { Repo string User *User Commits []srvCommitRow WhereExpr string WhereErr string BookmarkFilter string AllBookmarks []string } func (s *forgeServer) handleRepoLog(w http.ResponseWriter, r *http.Request) { repoObj, rec, ok := s.requireRepoAccess(w, r) if !ok { return } defer repoObj.Close() const maxCommits = 200 where := r.URL.Query().Get("where") bookmarkFilter := r.URL.Query().Get("bookmark") var whereFilter revset.Func var whereErr string if where != "" { var err error whereFilter, err = revset.Parse(where) if err != nil { whereErr = err.Error() } } headCID, _ := repoObj.HeadChangeID() bmMap := bookmarkMap(repoObj) allBms, _ := repoObj.Store.ListBookmarks() allBmNames := make([]string, 0, len(allBms)) for _, bm := range allBms { allBmNames = append(allBmNames, bm.Name) } var candidateIDs [][32]byte if bookmarkFilter != "" { bm, err := repoObj.Store.GetBookmark(bookmarkFilter) if err == nil && bm != nil { visited := map[[32]byte]bool{} queue := [][32]byte{bm.CommitID} for len(queue) > 0 && len(candidateIDs) < maxCommits*2 { id := queue[0] queue = queue[1:] if visited[id] { continue } visited[id] = true candidateIDs = append(candidateIDs, id) c, err := repoObj.ReadCommit(id) if err != nil { continue } for _, p := range c.Parents { if !visited[p] { queue = append(queue, p) } } } } } else { visited := map[[32]byte]bool{} var queue [][32]byte allBms2, _ := repoObj.Store.ListBookmarks() for _, bm := range allBms2 { if !visited[bm.CommitID] { queue = append(queue, bm.CommitID) visited[bm.CommitID] = true } } if _, headID, err := repoObj.HeadCommit(); err == nil && !visited[headID] { queue = append(queue, headID) visited[headID] = true } for len(queue) > 0 && len(candidateIDs) < maxCommits*2 { id := queue[0] queue = queue[1:] candidateIDs = append(candidateIDs, id) c, err := repoObj.ReadCommit(id) if err != nil { continue } for _, p := range c.Parents { if !visited[p] { visited[p] = true queue = append(queue, p) } } } } type rowWithTime struct { row srvCommitRow time int64 } var withTimes []rowWithTime for _, id := range candidateIDs { c, err := repoObj.ReadCommit(id) if err != nil { continue } phase, _ := repoObj.Store.GetPhase(id) if bookmarkFilter != "" && phase != object.PhasePublic { continue } if whereFilter != nil && !whereFilter(id, c, phase) { continue } hexID := fullHex(id) msg := c.Message if idx := strings.IndexByte(msg, '\n'); idx >= 0 { msg = msg[:idx] } withTimes = append(withTimes, rowWithTime{ row: srvCommitRow{ HexID: hexID, ShortHex: shortHex(id), ChangeID: c.ChangeID, Author: c.Author.Name, Date: c.Author.Timestamp.Format("2006-01-02 15:04"), Phase: phase.String(), PhaseClass: phaseClass(phase), Message: msg, Bookmarks: bmMap[hexID], IsHead: c.ChangeID == headCID, }, time: c.Author.Timestamp.Unix(), }) } sort.Slice(withTimes, func(i, j int) bool { return withTimes[i].time > withTimes[j].time }) if len(withTimes) > maxCommits { withTimes = withTimes[:maxCommits] } rows := make([]srvCommitRow, len(withTimes)) for i, wt := range withTimes { rows[i] = wt.row } s.render(w, "srv_repo_log.html", srvLogData{ Repo: rec.Name, User: s.db.currentUser(r), Commits: rows, WhereExpr: where, WhereErr: whereErr, BookmarkFilter: bookmarkFilter, AllBookmarks: allBmNames, }) } type srvCommitData struct { Repo string User *User HexID string ShortHex string ChangeID string Author string Committer string Date string Phase string PhaseClass string SigStatus string SigKeyID string Message string Bookmarks []string Parents []srvParentLink Diffs []srvFileDiff } type srvParentLink struct { HexID string ShortHex string } type srvDiffLine struct { Class string Text string } type srvFileDiff struct { Path string Status string Lines []srvDiffLine } func (s *forgeServer) handleRepoCommit(w http.ResponseWriter, r *http.Request) { repoObj, rec, ok := s.requireRepoAccess(w, r) if !ok { return } defer repoObj.Close() idStr := r.URL.Query().Get("id") raw, err := hex.DecodeString(idStr) if err != nil || len(raw) != 32 { http.Error(w, "invalid commit id", http.StatusBadRequest) return } var id [32]byte copy(id[:], raw) c, err := repoObj.ReadCommit(id) if err != nil { http.NotFound(w, r) return } phase, _ := repoObj.Store.GetPhase(id) bmMap := bookmarkMap(repoObj) hexID := fullHex(id) var parents []srvParentLink for _, p := range c.Parents { parents = append(parents, srvParentLink{HexID: fullHex(p), ShortHex: shortHex(p)}) } diffs, _ := diff.CommitDiff(repoObj, id) var rendered []srvFileDiff for _, fd := range diffs { rendered = append(rendered, srvFileDiff{ Path: fd.Path, Status: string(fd.Status), Lines: parseSrvDiffLines(fd.Patch), }) } sigStatus := s.db.GetCommitSigStatus(id) s.render(w, "srv_repo_commit.html", srvCommitData{ Repo: rec.Name, User: s.db.currentUser(r), HexID: hexID, ShortHex: shortHex(id), ChangeID: c.ChangeID, Author: fmt.Sprintf("%s <%s>", c.Author.Name, c.Author.Email), Committer: fmt.Sprintf("%s <%s>", c.Committer.Name, c.Committer.Email), Date: c.Author.Timestamp.Format("2006-01-02 15:04:05"), Phase: phase.String(), PhaseClass: phaseClass(phase), SigStatus: sigStatus, Message: c.Message, Bookmarks: bmMap[hexID], Parents: parents, Diffs: rendered, }) } func parseSrvDiffLines(patch string) []srvDiffLine { var out []srvDiffLine for _, line := range strings.Split(patch, "\n") { var class string switch { case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"), strings.HasPrefix(line, "diff "), strings.HasPrefix(line, "@@"): class = "diff-hdr" case strings.HasPrefix(line, "+"): class = "diff-add" case strings.HasPrefix(line, "-"): class = "diff-del" } out = append(out, srvDiffLine{Class: class, Text: line}) } return out } type srvTreeData struct { Repo string User *User CommitHex string ShortHex string TreePath string PathParts []srvPathPart Entries []srvTreeEntry } type srvPathPart struct { Name string Link string } type srvTreeEntry struct { Name string IsDir bool Mode string Link string } func resolveDefaultCommit(r *repo.Repo) [32]byte { bms, err := r.Store.ListBookmarks() if err != nil || len(bms) == 0 { return [32]byte{} } for _, name := range []string{"main", "master"} { for _, bm := range bms { if bm.Name == name { return bm.CommitID } } } return bms[0].CommitID } func (s *forgeServer) handleRepoTree(w http.ResponseWriter, r *http.Request) { repoObj, rec, ok := s.requireRepoAccess(w, r) if !ok { return } defer repoObj.Close() idStr := r.URL.Query().Get("id") treePath := strings.Trim(r.URL.Query().Get("path"), "/") var commitID [32]byte if idStr != "" { raw, err := hex.DecodeString(idStr) if err != nil || len(raw) != 32 { http.Error(w, "invalid id", http.StatusBadRequest) return } copy(commitID[:], raw) } else { commitID = resolveDefaultCommit(repoObj) if commitID == ([32]byte{}) { _, id, err := repoObj.HeadCommit() if err != nil { http.Error(w, "HEAD: "+err.Error(), http.StatusInternalServerError) return } commitID = id } } c, err := repoObj.ReadCommit(commitID) if err != nil { http.NotFound(w, r) return } tree, err := repoObj.ReadTree(c.TreeID) if err != nil { http.Error(w, "tree: "+err.Error(), http.StatusInternalServerError) return } if treePath != "" { for _, seg := range strings.Split(treePath, "/") { var found *object.TreeEntry for i := range tree.Entries { if tree.Entries[i].Name == seg { found = &tree.Entries[i] break } } if found == nil { http.NotFound(w, r) return } if found.Mode != object.ModeDir { http.Redirect(w, r, fmt.Sprintf("/%s/file?id=%s&path=%s", rec.Name, fullHex(commitID), treePath), http.StatusFound) return } tree, err = repoObj.ReadTree(found.ObjectID) if err != nil { http.Error(w, "subtree: "+err.Error(), http.StatusInternalServerError) return } } } commitHex := fullHex(commitID) var parts []srvPathPart if treePath != "" { acc := "" for _, seg := range strings.Split(treePath, "/") { if acc != "" { acc += "/" } acc += seg parts = append(parts, srvPathPart{ Name: seg, Link: fmt.Sprintf("/%s/tree?id=%s&path=%s", rec.Name, commitHex, acc), }) } } var entries []srvTreeEntry for _, e := range tree.Entries { isDir := e.Mode == object.ModeDir childPath := e.Name if treePath != "" { childPath = treePath + "/" + e.Name } var link string if isDir { link = fmt.Sprintf("/%s/tree?id=%s&path=%s", rec.Name, commitHex, childPath) } else { link = fmt.Sprintf("/%s/file?id=%s&path=%s", rec.Name, commitHex, childPath) } entries = append(entries, srvTreeEntry{ Name: e.Name, IsDir: isDir, Mode: modeStr(e.Mode), Link: link, }) } s.render(w, "srv_repo_tree.html", srvTreeData{ Repo: rec.Name, User: s.db.currentUser(r), CommitHex: commitHex, ShortHex: shortHex(commitID), TreePath: treePath, PathParts: parts, Entries: entries, }) } func modeStr(m object.EntryMode) string { switch m { case object.ModeExec: return "exec" case object.ModeSymlink: return "link" default: return "file" } } type srvFileData struct { Repo string User *User CommitHex string ShortHex string FilePath string Content string IsBinary bool Highlighted template.HTML } func highlightCode(filename, content string) template.HTML { lexer := lexers.Match(filename) if lexer == nil { lexer = lexers.Analyse(content) } if lexer == nil { lexer = lexers.Fallback } lexer = chroma.Coalesce(lexer) style := styles.Get("github") if style == nil { style = styles.Fallback } fmt := chrhtml.New( chrhtml.WithLineNumbers(true), chrhtml.WithClasses(false), chrhtml.TabWidth(4), ) iterator, err := lexer.Tokenise(nil, content) if err != nil { return "" } var buf bytes.Buffer if err := fmt.Format(&buf, style, iterator); err != nil { return "" } return template.HTML(buf.String()) //nolint:gosec } func (s *forgeServer) handleRepoFile(w http.ResponseWriter, r *http.Request) { repoObj, rec, ok := s.requireRepoAccess(w, r) if !ok { return } defer repoObj.Close() idStr := r.URL.Query().Get("id") filePath := strings.Trim(r.URL.Query().Get("path"), "/") raw, err := hex.DecodeString(idStr) if err != nil || len(raw) != 32 { http.Error(w, "invalid id", http.StatusBadRequest) return } var commitID [32]byte copy(commitID[:], raw) c, err := repoObj.ReadCommit(commitID) if err != nil { http.NotFound(w, r) return } tree, err := repoObj.ReadTree(c.TreeID) if err != nil { http.Error(w, "tree: "+err.Error(), http.StatusInternalServerError) return } parts := strings.Split(filePath, "/") for i, seg := range parts { var found *object.TreeEntry for j := range tree.Entries { if tree.Entries[j].Name == seg { found = &tree.Entries[j] break } } if found == nil { http.NotFound(w, r) return } if i == len(parts)-1 { if found.Mode == object.ModeDir { http.Error(w, "not a file", http.StatusBadRequest) return } content, err := repoObj.ReadBlob(found.ObjectID) if err != nil { http.Error(w, "blob: "+err.Error(), http.StatusInternalServerError) return } isBin := isBinaryContent(content) var highlighted template.HTML if !isBin && len(content) < 512*1024 { highlighted = highlightCode(filePath, string(content)) } s.render(w, "srv_repo_file.html", srvFileData{ Repo: rec.Name, User: s.db.currentUser(r), CommitHex: fullHex(commitID), ShortHex: shortHex(commitID), FilePath: filePath, Content: string(content), IsBinary: isBin, Highlighted: highlighted, }) return } if found.Mode != object.ModeDir { http.Error(w, "not a directory", http.StatusBadRequest) return } tree, err = repoObj.ReadTree(found.ObjectID) if err != nil { http.Error(w, "subtree: "+err.Error(), http.StatusInternalServerError) return } } http.NotFound(w, r) } func isBinaryContent(data []byte) bool { for _, b := range data { if b == 0 { return true } } return false }
--- a/internal/wc/wc.go +++ b/internal/wc/wc.go @@ -1,1104 +1,1107 @@ package wc import ( "encoding/json" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "syscall" "time" "arche/internal/merge" "arche/internal/object" "arche/internal/repo" "arche/internal/store" "arche/internal/watcher" ) func dirtySet(r *repo.Repo) (map[string]bool, error) { if !watcher.IsActive(r.ArcheDir(), r.Store) { return nil, nil } entries, err := r.Store.ListDirtyWCacheEntries() if err != nil { return nil, err } m := make(map[string]bool, len(entries)) for _, e := range entries { m[e.Path] = true } return m, nil } type FileStatus struct { Path string Status rune } type WC struct { Repo *repo.Repo SignKey string NoAutoAdvance bool AuthorOverride *object.Signature } func New(r *repo.Repo) *WC { return &WC{Repo: r} } func (wc *WC) maybeSign(c *object.Commit) error { if wc.SignKey == "" { return nil } body := object.CommitBodyForSigning(c) sig, _, err := object.SignCommitBody(body, wc.SignKey) if err != nil { return fmt.Errorf("commit signing: %w", err) } c.CommitSig = sig return nil } func (wc *WC) snapshotIntoTx(tx *store.Tx, headCommit *object.Commit, paths []string, cacheMap map[string]store.WCacheEntry, dirty map[string]bool, message string, now time.Time) (*object.Commit, [32]byte, error) { r := wc.Repo var entries []fileEntry if err := r.Store.ClearWCache(tx); err != nil { return nil, object.ZeroID, fmt.Errorf("clear wcache: %w", err) } for _, rel := range paths { if dirty != nil && !dirty[rel] { if cached, ok := cacheMap[rel]; ok { entries = append(entries, fileEntry{ path: rel, blobID: cached.BlobID, mode: object.EntryMode(cached.Mode), }) if err := r.Store.SetWCacheEntry(tx, cached); err != nil { return nil, object.ZeroID, fmt.Errorf("set wcache: %w", err) } continue } } abs := filepath.Join(r.Root, rel) info, err := os.Lstat(abs) if err != nil { continue } var blobID [32]byte mode := fileMode(info) if cached, ok := cacheMap[rel]; ok { st := info.Sys().(*syscall.Stat_t) inode := st.Ino mtime := info.ModTime().UnixNano() size := info.Size() if cached.Inode == inode && cached.MtimeNs == mtime && cached.Size == size { blobID = cached.BlobID } } if blobID == object.ZeroID { content, err := readFileContent(abs, info) if err != nil { return nil, object.ZeroID, err } id, err := repo.WriteBlobTx(r.Store, tx, &object.Blob{Content: content}) if err != nil { return nil, object.ZeroID, err } blobID = id } st := info.Sys().(*syscall.Stat_t) if err := r.Store.SetWCacheEntry(tx, store.WCacheEntry{ Path: rel, Inode: st.Ino, MtimeNs: info.ModTime().UnixNano(), Size: info.Size(), BlobID: blobID, Mode: uint8(mode), }); err != nil { return nil, object.ZeroID, fmt.Errorf("set wcache: %w", err) } entries = append(entries, fileEntry{path: rel, blobID: blobID, mode: mode}) } tree, err := buildTree(r, tx, entries) if err != nil { return nil, object.ZeroID, err } sig := object.Signature{ Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now, } c := &object.Commit{ TreeID: tree, Parents: headCommit.Parents, ChangeID: headCommit.ChangeID, Author: headCommit.Author, Committer: sig, Message: message, Phase: headCommit.Phase, } if headCommit.Author.Timestamp.IsZero() { c.Author = sig } if err := wc.maybeSign(c); err != nil { return nil, object.ZeroID, err } commitID, err := repo.WriteCommitTx(r.Store, tx, c) if err != nil { return nil, object.ZeroID, err } if err := r.Store.SetChangeCommit(tx, c.ChangeID, commitID); err != nil { return nil, object.ZeroID, err } return c, commitID, nil } func (wc *WC) snapshotInput() (paths []string, cacheMap map[string]store.WCacheEntry, dirty map[string]bool, err error) { r := wc.Repo cacheEntries, err := r.Store.ListWCacheEntries() if err != nil { return nil, nil, nil, err } cacheMap = make(map[string]store.WCacheEntry, len(cacheEntries)) for _, e := range cacheEntries { cacheMap[e.Path] = e } dirty, _ = dirtySet(r) if dirty != nil { seen := make(map[string]bool, len(cacheMap)+len(dirty)) for p := range cacheMap { seen[p] = true paths = append(paths, p) } for p := range dirty { if !seen[p] { paths = append(paths, p) } } } else { paths, err = wc.trackedPaths() if err != nil { return nil, nil, nil, err } } return paths, cacheMap, dirty, nil } func (wc *WC) Snapshot(message string) (*object.Commit, [32]byte, error) { r := wc.Repo now := time.Now() head, _, err := r.HeadCommit() if err != nil { return nil, object.ZeroID, err } paths, cacheMap, dirty, err := wc.snapshotInput() if err != nil { return nil, object.ZeroID, err } tx, err := r.Store.Begin() if err != nil { return nil, object.ZeroID, err } c, commitID, err := wc.snapshotIntoTx(tx, head, paths, cacheMap, dirty, message, now) if err != nil { r.Store.Rollback(tx) return nil, object.ZeroID, err } if err := r.Store.Commit(tx); err != nil { return nil, object.ZeroID, err } return c, commitID, nil } func (wc *WC) Snap(message string) (*object.Commit, [32]byte, error) { r := wc.Repo now := time.Now() before, err := r.CaptureRefState() if err != nil { return nil, object.ZeroID, err } statusBefore, err := wc.Status() if err != nil { return nil, object.ZeroID, err } diffPaths := make(map[string]bool, len(statusBefore)) for _, fsEntry := range statusBefore { diffPaths[fsEntry.Path] = true } useRestrictedPaths := len(r.Cfg.Hooks.PreSnap) > 0 if useRestrictedPaths { if err := RunHooksSequential(r.Root, "pre-snap", r.Cfg.Hooks.PreSnap); err != nil { return nil, object.ZeroID, fmt.Errorf("pre-snap hook failed: %w", err) } } head, oldHeadID, err := r.HeadCommit() if err != nil { return nil, object.ZeroID, err } type snapshotFn func(tx *store.Tx) (*object.Commit, [32]byte, error) var doSnapshot snapshotFn if useRestrictedPaths { headBlobs := make(map[string][32]byte) headModes := make(map[string]object.EntryMode) if err := flattenTree(r, head.TreeID, "", headBlobs); err != nil { return nil, object.ZeroID, err } if err := flattenTreeModes(r, head.TreeID, "", headModes); err != nil { return nil, object.ZeroID, err } doSnapshot = func(tx *store.Tx) (*object.Commit, [32]byte, error) { return wc.snapshotRestrictedPathsIntoTx(tx, head, headBlobs, headModes, diffPaths, message, now) } } else { paths, cacheMap, dirty, err := wc.snapshotInput() if err != nil { return nil, object.ZeroID, err } doSnapshot = func(tx *store.Tx) (*object.Commit, [32]byte, error) { return wc.snapshotIntoTx(tx, head, paths, cacheMap, dirty, message, now) } } existingBookmarks, _ := r.Store.ListBookmarks() tx, err := r.Store.Begin() if err != nil { return nil, object.ZeroID, err } snapped, snappedID, err := doSnapshot(tx) if err != nil { r.Store.Rollback(tx) return nil, object.ZeroID, err } if snappedID != oldHeadID { for _, bm := range existingBookmarks { if bm.CommitID == oldHeadID { _ = r.Store.SetBookmark(tx, store.Bookmark{ Name: bm.Name, CommitID: snappedID, Remote: bm.Remote, }) } } } newChangeID, err := r.Store.AllocChangeID(tx) if err != nil { r.Store.Rollback(tx) return nil, object.ZeroID, err } sig := object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now} newDraft := &object.Commit{ TreeID: snapped.TreeID, Parents: [][32]byte{snappedID}, ChangeID: newChangeID, Author: sig, Committer: sig, Message: "", Phase: object.PhaseDraft, } newDraftID, err := repo.WriteCommitTx(r.Store, tx, newDraft) if err != nil { r.Store.Rollback(tx) return nil, object.ZeroID, err } if err := r.Store.SetChangeCommit(tx, newChangeID, newDraftID); err != nil { r.Store.Rollback(tx) return nil, object.ZeroID, err } after := buildRefState(snappedID, object.FormatChangeID(newChangeID)) op := store.Operation{ Kind: "snap", Timestamp: now.Unix(), Before: before, After: after, Metadata: "'" + firstLine(snapped.Message) + "'", } if _, err := r.Store.InsertOperation(tx, op); err != nil { r.Store.Rollback(tx) return nil, object.ZeroID, err } if err := r.Store.Commit(tx); err != nil { return nil, object.ZeroID, err } if err := r.WriteHead(object.FormatChangeID(newChangeID)); err != nil { return nil, object.ZeroID, err } if len(r.Cfg.Hooks.PostSnap) > 0 { if err := RunHooksSequential(r.Root, "post-snap", r.Cfg.Hooks.PostSnap); err != nil { fmt.Fprintf(os.Stderr, "arche snap: post-snap hook: %v\n", err) } } return snapped, snappedID, nil } func (wc *WC) Status() ([]FileStatus, error) { r := wc.Repo head, _, err := r.HeadCommit() if err != nil { return nil, err } headFiles := make(map[string][32]byte) if err := flattenTree(r, head.TreeID, "", headFiles); err != nil { return nil, err } wcPaths, err := wc.trackedPaths() if err != nil { return nil, err } wcSet := make(map[string]bool, len(wcPaths)) for _, p := range wcPaths { wcSet[p] = true } cacheEntries, _ := r.Store.ListWCacheEntries() cacheMap := make(map[string]store.WCacheEntry, len(cacheEntries)) for _, e := range cacheEntries { cacheMap[e.Path] = e } dirty, _ := dirtySet(r) var out []FileStatus for path, headBlobID := range headFiles { if !wcSet[path] { out = append(out, FileStatus{Path: path, Status: 'D'}) continue } if dirty != nil && !dirty[path] { if cached, ok := cacheMap[path]; ok { if cached.BlobID != headBlobID { out = append(out, FileStatus{Path: path, Status: 'M'}) } continue } } curBlobID, err := wc.blobIDForPath(path) if err != nil { continue } if curBlobID != headBlobID { out = append(out, FileStatus{Path: path, Status: 'M'}) } } ignore, _ := loadIgnore(r.Root) for _, path := range wcPaths { if _, inHead := headFiles[path]; !inHead { if ignore.Match(path) { continue } out = append(out, FileStatus{Path: path, Status: 'A'}) } } sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) return out, nil } func (wc *WC) materializeDisk(treeID [32]byte) (map[string][32]byte, map[string]object.EntryMode, error) { r := wc.Repo wantFiles := make(map[string][32]byte) wantMode := make(map[string]object.EntryMode) if err := flattenTree(r, treeID, "", wantFiles); err != nil { return nil, nil, err } if err := flattenTreeModes(r, treeID, "", wantMode); err != nil { return nil, nil, err } ignore, _ := loadIgnore(r.Root) err := filepath.WalkDir(r.Root, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } rel, _ := filepath.Rel(r.Root, path) if rel == "." { return nil } if d.IsDir() { if rel == archeDirName || strings.HasPrefix(rel, archeDirName+string(os.PathSeparator)) { return filepath.SkipDir } + if ignore.MatchDir(rel) { + return filepath.SkipDir + } return nil } if ignore.Match(rel) { return nil } if _, ok := wantFiles[rel]; !ok { return os.Remove(path) } return nil }) if err != nil { return nil, nil, err } var conflictPaths []string for relPath, blobID := range wantFiles { abs := filepath.Join(r.Root, relPath) if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { return nil, nil, err } content, err := r.ReadBlob(blobID) if err != nil { if conf, cErr := r.ReadConflict(blobID); cErr == nil { content = renderConflictMarkers(r, conf) conflictPaths = append(conflictPaths, relPath) err = nil } } if err != nil { return nil, nil, err } perm := fs.FileMode(0o644) if wantMode[relPath] == object.ModeExec { perm = 0o755 } if err := os.WriteFile(abs, content, perm); err != nil { return nil, nil, err } } for _, p := range conflictPaths { delete(wantFiles, p) } return wantFiles, wantMode, nil } func renderConflictMarkers(r *repo.Repo, conf *object.Conflict) []byte { readStr := func(id [32]byte) string { if id == object.ZeroID { return "" } b, _ := r.ReadBlob(id) return string(b) } nl := func(s string) string { if len(s) > 0 && s[len(s)-1] != '\n' { return s + "\n" } return s } if conf.Ours.BlobID == object.ZeroID { return []byte(fmt.Sprintf("<<<<<<< ours\n(deleted)\n=======\n%s>>>>>>> theirs\n", nl(readStr(conf.Theirs.BlobID)))) } if conf.Theirs.BlobID == object.ZeroID { return []byte(fmt.Sprintf("<<<<<<< ours\n%s=======\n(deleted)\n>>>>>>> theirs\n", nl(readStr(conf.Ours.BlobID)))) } return []byte(fmt.Sprintf("<<<<<<< ours\n%s=======\n%s>>>>>>> theirs\n", nl(readStr(conf.Ours.BlobID)), nl(readStr(conf.Theirs.BlobID)))) } func (wc *WC) populateWCacheInTx(tx *store.Tx, wantFiles map[string][32]byte) error { r := wc.Repo if err := r.Store.ClearWCache(tx); err != nil { return err } for relPath, blobID := range wantFiles { abs := filepath.Join(r.Root, relPath) info, err := os.Lstat(abs) if err != nil { continue } st, ok := info.Sys().(*syscall.Stat_t) if !ok { continue } _ = r.Store.SetWCacheEntry(tx, store.WCacheEntry{ Path: relPath, Inode: st.Ino, MtimeNs: info.ModTime().UnixNano(), Size: info.Size(), BlobID: blobID, Mode: uint8(fileMode(info)), }) } return nil } func (wc *WC) MaterializeQuiet(treeID [32]byte) error { r := wc.Repo wantFiles, _, err := wc.materializeDisk(treeID) if err != nil { return err } tx, err := r.Store.Begin() if err != nil { return err } if err := wc.populateWCacheInTx(tx, wantFiles); err != nil { r.Store.Rollback(tx) return err } return r.Store.Commit(tx) } func (wc *WC) Materialize(treeID [32]byte, newChangeID string) error { r := wc.Repo before, _ := r.CaptureRefState() now := time.Now() wantFiles, _, err := wc.materializeDisk(treeID) if err != nil { return err } bare := object.StripChangeIDPrefix(newChangeID) commitID, _ := r.Store.GetChangeCommit(bare) after := buildRefState(commitID, newChangeID) tx, err := r.Store.Begin() if err != nil { return err } if err := wc.populateWCacheInTx(tx, wantFiles); err != nil { r.Store.Rollback(tx) return err } op := store.Operation{ Kind: "co", Timestamp: now.Unix(), Before: before, After: after, Metadata: "checked out " + newChangeID, } if _, err := r.Store.InsertOperation(tx, op); err != nil { r.Store.Rollback(tx) return err } return r.Store.Commit(tx) } const archeDirName = ".arche" func (wc *WC) trackedPaths() ([]string, error) { r := wc.Repo ignore, _ := loadIgnore(r.Root) var paths []string err := filepath.WalkDir(r.Root, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } rel, _ := filepath.Rel(r.Root, path) if rel == "." { return nil } if d.IsDir() { if rel == archeDirName || strings.HasPrefix(rel, archeDirName+string(os.PathSeparator)) { return filepath.SkipDir } if ignore.MatchDir(rel) { return filepath.SkipDir } return nil } if ignore.Match(rel) { return nil } paths = append(paths, filepath.ToSlash(rel)) return nil }) return paths, err } func (wc *WC) TrackedPaths() ([]string, error) { return wc.trackedPaths() } func (wc *WC) blobIDForPath(rel string) ([32]byte, error) { r := wc.Repo abs := filepath.Join(r.Root, rel) info, err := os.Lstat(abs) if err != nil { return object.ZeroID, err } st := info.Sys().(*syscall.Stat_t) if cached, _ := r.Store.GetWCacheEntry(rel); cached != nil { if cached.Inode == st.Ino && cached.MtimeNs == info.ModTime().UnixNano() && cached.Size == info.Size() { return cached.BlobID, nil } } content, err := readFileContent(abs, info) if err != nil { return object.ZeroID, err } b := &object.Blob{Content: content} return object.HashBlob(b), nil } func flattenTree(r *repo.Repo, treeID [32]byte, prefix string, out map[string][32]byte) error { if treeID == object.ZeroID { return nil } t, err := r.ReadTree(treeID) if err != nil { return err } for _, e := range t.Entries { rel := join(prefix, e.Name) switch e.Mode { case object.ModeDir: if err := flattenTree(r, e.ObjectID, rel, out); err != nil { return err } default: out[rel] = e.ObjectID } } return nil } func flattenTreeModes(r *repo.Repo, treeID [32]byte, prefix string, out map[string]object.EntryMode) error { if treeID == object.ZeroID { return nil } t, err := r.ReadTree(treeID) if err != nil { return err } for _, e := range t.Entries { rel := join(prefix, e.Name) switch e.Mode { case object.ModeDir: if err := flattenTreeModes(r, e.ObjectID, rel, out); err != nil { return err } default: out[rel] = e.Mode } } return nil } type fileEntry struct { path string blobID [32]byte mode object.EntryMode } func buildTree(r *repo.Repo, tx *store.Tx, entries []fileEntry) ([32]byte, error) { type node struct { isFile bool blobID [32]byte mode object.EntryMode children map[string]*node } root := &node{children: make(map[string]*node)} for _, e := range entries { parts := strings.Split(e.path, "/") cur := root for i, part := range parts { if i == len(parts)-1 { cur.children[part] = &node{isFile: true, blobID: e.blobID, mode: e.mode} } else { if _, ok := cur.children[part]; !ok { cur.children[part] = &node{children: make(map[string]*node)} } cur = cur.children[part] } } } var writeNode func(n *node) ([32]byte, error) writeNode = func(n *node) ([32]byte, error) { var treeEntries []object.TreeEntry for name, child := range n.children { if child.isFile { treeEntries = append(treeEntries, object.TreeEntry{ Name: name, Mode: child.mode, ObjectID: child.blobID, }) } else { subID, err := writeNode(child) if err != nil { return object.ZeroID, err } treeEntries = append(treeEntries, object.TreeEntry{ Name: name, Mode: object.ModeDir, ObjectID: subID, }) } } sort.Slice(treeEntries, func(i, j int) bool { return treeEntries[i].Name < treeEntries[j].Name }) t := &object.Tree{Entries: treeEntries} id, err := repo.WriteTreeTx(r.Store, tx, t) return id, err } return writeNode(root) } func fileMode(info os.FileInfo) object.EntryMode { if info.Mode()&0o111 != 0 { return object.ModeExec } if info.Mode()&os.ModeSymlink != 0 { return object.ModeSymlink } return object.ModeFile } func readFileContent(abs string, info os.FileInfo) ([]byte, error) { if info.Mode()&os.ModeSymlink != 0 { target, err := os.Readlink(abs) if err != nil { return nil, err } return []byte(target), nil } return os.ReadFile(abs) } func join(prefix, name string) string { if prefix == "" { return name } return prefix + "/" + name } func buildRefState(commitID [32]byte, changeID string) string { m := map[string]string{ "head": changeID, "tip": fmt.Sprintf("%x", commitID), } b, _ := json.Marshal(m) return string(b) } func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s } func (wc *WC) SnapshotTree() ([32]byte, error) { r := wc.Repo paths, cacheMap, dirty, err := wc.snapshotInput() if err != nil { return object.ZeroID, err } tx, err := r.Store.Begin() if err != nil { return object.ZeroID, err } var entries []fileEntry for _, rel := range paths { if dirty != nil && !dirty[rel] { if cached, ok := cacheMap[rel]; ok { entries = append(entries, fileEntry{ path: rel, blobID: cached.BlobID, mode: object.EntryMode(cached.Mode), }) continue } } abs := filepath.Join(r.Root, rel) info, err := os.Lstat(abs) if err != nil { continue } var blobID [32]byte mode := fileMode(info) if cached, ok := cacheMap[rel]; ok { st := info.Sys().(*syscall.Stat_t) if cached.Inode == st.Ino && cached.MtimeNs == info.ModTime().UnixNano() && cached.Size == info.Size() { blobID = cached.BlobID } } if blobID == object.ZeroID { content, err := readFileContent(abs, info) if err != nil { r.Store.Rollback(tx) //nolint:errcheck return object.ZeroID, err } id, err := repo.WriteBlobTx(r.Store, tx, &object.Blob{Content: content}) if err != nil { r.Store.Rollback(tx) //nolint:errcheck return object.ZeroID, err } blobID = id } entries = append(entries, fileEntry{path: rel, blobID: blobID, mode: mode}) } treeID, err := buildTree(r, tx, entries) if err != nil { r.Store.Rollback(tx) //nolint:errcheck return object.ZeroID, err } if err := r.Store.Commit(tx); err != nil { return object.ZeroID, err } return treeID, nil } func (wc *WC) Amend(message string) (*object.Commit, [32]byte, error) { r := wc.Repo now := time.Now() head, oldHeadID, err := r.HeadCommit() if err != nil { return nil, object.ZeroID, err } if head.Phase == object.PhasePublic { return nil, object.ZeroID, fmt.Errorf("cannot amend a public commit; use --force-rewrite if you are sure") } before, err := r.CaptureRefState() if err != nil { return nil, object.ZeroID, err } if message == "" { message = head.Message } paths, cacheMap, dirty, err := wc.snapshotInput() if err != nil { return nil, object.ZeroID, err } tx, err := r.Store.Begin() if err != nil { return nil, object.ZeroID, err } amended, amendedID, err := wc.snapshotIntoTx(tx, head, paths, cacheMap, dirty, message, now) if err != nil { r.Store.Rollback(tx) return nil, object.ZeroID, err } if oldHeadID != amendedID { obs := &object.ObsoleteMarker{ Predecessor: oldHeadID, Successors: [][32]byte{amendedID}, Reason: "amend", Timestamp: now.Unix(), } if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil { r.Store.Rollback(tx) return nil, object.ZeroID, err } } after := buildRefState(amendedID, object.FormatChangeID(amended.ChangeID)) op := store.Operation{ Kind: "amend", Timestamp: now.Unix(), Before: before, After: after, Metadata: "'" + firstLine(amended.Message) + "'", } if _, err := r.Store.InsertOperation(tx, op); err != nil { r.Store.Rollback(tx) return nil, object.ZeroID, err } if err := r.Store.Commit(tx); err != nil { return nil, object.ZeroID, err } if oldHeadID != amendedID { if err := wc.autoRebaseDownstream(oldHeadID, amendedID, head.ChangeID, now); err != nil { fmt.Fprintf(os.Stderr, "arche: warning: downstream rebase failed: %v\n", err) } } return amended, amendedID, nil } func (wc *WC) autoRebaseDownstream(oldParentID, newParentID [32]byte, headChangeID string, now time.Time) error { r := wc.Repo allChanges, err := r.Store.ListChanges() if err != nil { return err } type draftEntry struct { id [32]byte changeID string commit *object.Commit } children := make(map[[32]byte][]draftEntry) for _, ch := range allChanges { if ch.CommitID == object.ZeroID { continue } c, err := r.ReadCommit(ch.CommitID) if err != nil || c == nil { continue } if c.Phase != object.PhaseDraft { continue } if c.ChangeID == headChangeID { continue } if len(c.Parents) == 0 { continue } d := draftEntry{id: ch.CommitID, changeID: ch.Name, commit: c} children[c.Parents[0]] = append(children[c.Parents[0]], d) } type rebaseTask struct { entry draftEntry newParent [32]byte } var tasks []rebaseTask queue := []struct { oldID [32]byte newID [32]byte }{{oldParentID, newParentID}} for len(queue) > 0 { cur := queue[0] queue = queue[1:] for _, child := range children[cur.oldID] { tasks = append(tasks, rebaseTask{entry: child, newParent: cur.newID}) queue = append(queue, struct{ oldID, newID [32]byte }{child.id, child.id}) } } remapped := map[[32]byte][32]byte{oldParentID: newParentID} for _, task := range tasks { oldFirst := task.entry.commit.Parents[0] newParent, ok := remapped[oldFirst] if !ok { newParent = oldFirst } var baseTreeID [32]byte if pc, err2 := r.ReadCommit(oldFirst); err2 == nil { baseTreeID = pc.TreeID } newParentCommit, err := r.ReadCommit(newParent) if err != nil { return fmt.Errorf("read new parent for %s: %w", object.FormatChangeID(task.entry.changeID), err) } result, err := merge.Trees(r, baseTreeID, task.entry.commit.TreeID, newParentCommit.TreeID) if err != nil { return fmt.Errorf("merge for %s: %w", object.FormatChangeID(task.entry.changeID), err) } newCommit := &object.Commit{ TreeID: result.TreeID, Parents: [][32]byte{newParent}, ChangeID: task.entry.changeID, Author: task.entry.commit.Author, Committer: object.Signature{Name: r.Cfg.User.Name, Email: r.Cfg.User.Email, Timestamp: now}, Message: task.entry.commit.Message, Phase: task.entry.commit.Phase, } tx, err := r.Store.Begin() if err != nil { return err } newCommitID, err := repo.WriteCommitTx(r.Store, tx, newCommit) if err != nil { r.Store.Rollback(tx) return err } if err := r.Store.SetChangeCommit(tx, task.entry.changeID, newCommitID); err != nil { r.Store.Rollback(tx) return err } obs := &object.ObsoleteMarker{ Predecessor: task.entry.id, Successors: [][32]byte{newCommitID}, Reason: "amend", Timestamp: now.Unix(), } if _, err := repo.WriteObsoleteTx(r.Store, tx, obs); err != nil { r.Store.Rollback(tx) return err } if err := r.Store.Commit(tx); err != nil { return err } remapped[task.entry.id] = newCommitID conflictNote := "" if len(result.Conflicts) > 0 { conflictNote = fmt.Sprintf(" (%d conflict(s))", len(result.Conflicts)) } fmt.Printf(" auto-rebased %s%s\n", object.FormatChangeID(task.entry.changeID), conflictNote) } return nil }