arche / commit

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
go.mod [M]
--- 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
 )

go.sum [M]
--- 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=

internal/archesrv/handlers_repo.go [M]
--- 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
 }

internal/wc/wc.go [M]
--- 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
 }