~bouncepaw/mycorrhiza-devel

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
1

[RFC PATCH] Migrate to go-git

Details
Message ID
<20220819200017.10180-1-umar@handlerug.me>
DKIM signature
missing
Download raw message
Patch: +747 -513
This opens doors for many features like following renames, no run-time
dependencies, clean isolation, extra history features (like rich diffs),
and even an embedded git server! It should also handle errors better,
because now we're in control of every git operation executed, unlike
with shelling out to the git CLI.

---
Review with care! There are probably a lot of edge cases that have bugs

 categories/categories.go                 |  26 +-
 go.mod                                   |  17 +-
 go.sum                                   |  81 +++++-
 help/help.go                             |   3 +-
 history/diff.go                          |  59 ++++
 history/feed.go                          |  56 ++--
 history/history.go                       |  81 ++----
 history/histweb/histview.go              |  15 +-
 history/histweb/view_primitive_diff.html |   2 +-
 history/histweb/view_recent_changes.html |  36 +--
 history/operations.go                    |  77 +++--
 history/revision.go                      | 346 ++++++++++-------------
 history/revlog.go                        | 142 ++++++++++
 history/view.qtpl                        |  17 +-
 history/view.qtpl.go                     | 273 +++++++++---------
 l18n/l18n.go                             |   1 +
 main.go                                  |   6 +-
 migration/migration.go                   |   4 +-
 shroom/upload.go                         |  15 +-
 util/util.go                             |   3 +-
 20 files changed, 747 insertions(+), 513 deletions(-)
 create mode 100644 history/diff.go
 create mode 100644 history/revlog.go

diff --git a/categories/categories.go b/categories/categories.go
index 4a093f5..d49ee3d 100644
--- a/categories/categories.go
+++ b/categories/categories.go
@@ -3,22 +3,22 @@
// As per the long pondering, this is how categories (cats for short)
// work in Mycorrhiza:
//
//     - Cats are not hyphae. Cats are separate entities. This is not as
//       vibeful as I would have wanted, but seems to be more practical
//       due to //the reasons//.
//     - Cats are stored outside of git. Instead, they are stored in a
//       JSON file, path to which is determined by files.CategoriesJSON.
//     - Due to not being stored in git, no cat history is tracked, and
//       cat operations are not mentioned on the recent changes page.
//     - For cat A, if there are 0 hyphae in the cat, cat A does not
//       exist. If there are 1 or more hyphae in the cat, cat A exists.
//   - Cats are not hyphae. Cats are separate entities. This is not as
//     vibeful as I would have wanted, but seems to be more practical
//     due to //the reasons//.
//   - Cats are stored outside of git. Instead, they are stored in a
//     JSON file, path to which is determined by files.CategoriesJSON.
//   - Due to not being stored in git, no cat history is tracked, and
//     cat operations are not mentioned on the recent changes page.
//   - For cat A, if there are 0 hyphae in the cat, cat A does not
//     exist. If there are 1 or more hyphae in the cat, cat A exists.
//
// List of things to do with categories later:
//
//     - Forbid / in cat names.
//     - Rename categories.
//     - Delete categories.
//     - Bind hyphae.
//   - Forbid / in cat names.
//   - Rename categories.
//   - Delete categories.
//   - Bind hyphae.
package categories

import "sync"
diff --git a/go.mod b/go.mod
index 79779d7..1dca575 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.18

require (
	github.com/bouncepaw/mycomarkup/v5 v5.1.2
	github.com/go-git/go-git/v5 v5.4.2
	github.com/go-ini/ini v1.63.2
	github.com/gorilla/feeds v1.1.1
	github.com/gorilla/mux v1.8.0
@@ -15,10 +16,22 @@ require (
)

require (
	github.com/kr/pretty v0.2.1 // indirect
	github.com/stretchr/testify v1.7.0 // indirect
	github.com/Microsoft/go-winio v0.4.16 // indirect
	github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect
	github.com/acomagu/bufpipe v1.0.3 // indirect
	github.com/emirpasic/gods v1.12.0 // indirect
	github.com/go-git/gcfg v1.5.0 // indirect
	github.com/go-git/go-billy/v5 v5.3.1 // indirect
	github.com/imdario/mergo v0.3.12 // indirect
	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
	github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect
	github.com/mitchellh/go-homedir v1.1.0 // indirect
	github.com/sergi/go-diff v1.1.0 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/xanzy/ssh-agent v0.3.0 // indirect
	golang.org/x/net v0.0.0-20210510120150-4163338589ed // indirect
	golang.org/x/sys v0.0.0-20211109184856-51b60fd695b3 // indirect
	gopkg.in/warnings.v0 v0.1.2 // indirect
)

// Use this trick to test local Mycomarkup changes, replace the path with yours,
diff --git a/go.sum b/go.sum
index 06428ae..bc23362 100644
--- a/go.sum
+++ b/go.sum
@@ -1,26 +1,79 @@
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk=
github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ=
github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo=
github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk=
github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bouncepaw/mycomarkup/v5 v5.1.2 h1:0ARUfHMRCWygp/ATq8yjdM7Mxf3beXA1XYV9oB3YW9A=
github.com/bouncepaw/mycomarkup/v5 v5.1.2/go.mod h1:jyB/vxKe3X8SsN7FjjPf24IZwFM/H1C4LNvQ5UyXwjU=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34=
github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8=
github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
github.com/go-ini/ini v1.63.2 h1:kwN3umicd2HF3Tgvap4um1ZG52/WyKT9GGdPx0CJk6Y=
github.com/go-ini/ini v1.63.2/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -29,15 +82,29 @@ github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD
github.com/valyala/quicktemplate v1.7.0 h1:LUPTJmlVcb46OOUY3IeD9DojFpAVbsG+5WFTcjMJzCM=
github.com/valyala/quicktemplate v1.7.0/go.mod h1:sqKJnoaOF88V07vkO+9FL8fb9uZg/VPSJnLYn+LmLk8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa h1:idItI2DDfCokpg0N51B2VtiLdJ4vAuXC9fnCb2gACo4=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20220414153411-bcd21879b8fd h1:zVFyTKZN/Q7mNRWSs1GOYnHM9NiFSJ54YVRsD0rNWT4=
golang.org/x/exp v0.0.0-20220414153411-bcd21879b8fd/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
golang.org/x/net v0.0.0-20210510120150-4163338589ed h1:p9UgmWI9wKpfYmgaV/IZKGdXc5qEK45tDwwwDyjS26I=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211109184856-51b60fd695b3 h1:T6tyxxvHMj2L1R2kZg0uNMpS8ZhB9lRa9XRGTCSA65w=
golang.org/x/sys v0.0.0-20211109184856-51b60fd695b3/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -50,5 +117,15 @@ golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/help/help.go b/help/help.go
index 057d3c1..5091c6e 100644
--- a/help/help.go
+++ b/help/help.go
@@ -9,7 +9,8 @@ import (
var fs embed.FS

// Get determines what help text you need and returns it. The path is a substring from URL, it follows this form:
//     <language>/<topic>
//
//	<language>/<topic>
func Get(path string) ([]byte, error) {
	if path == "" {
		return Get("en")
diff --git a/history/diff.go b/history/diff.go
new file mode 100644
index 0000000..45ac22e
--- /dev/null
+++ b/history/diff.go
@@ -0,0 +1,59 @@
package history

import (
	"strings"

	"github.com/go-git/go-git/v5/plumbing"

	"github.com/bouncepaw/mycorrhiza/util"
)

// PrimitiveDiffAtRevision generates a plain-text diff for the given filepath
// at the commit with the given hash. It may return an error if git fails.
func PrimitiveDiffAtRevision(filepath, hash string) (*Revision, string, error) {
	filepath = util.ShorterPath(filepath)
	commit, err := repo.CommitObject(plumbing.NewHash(hash))
	if err != nil {
		return nil, "", err
	}
	rev := parseRevision(commit)
	oldTree, newTree, err := getDiffTrees(rev.commit)
	if err != nil {
		return rev, "", err
	}
	patch, err := oldTree.Patch(newTree)
	if err != nil {
		return rev, "", err
	}
	origPatch := patch.String()
	encodedPatch := origPatch
	origPatch = "Failed to find the hypha. Displaying the full diff.\n\n" + origPatch
	for {
		idx := strings.Index(encodedPatch, "diff ")
		if idx == -1 {
			return rev, origPatch, nil
		}
		encodedPatch = encodedPatch[idx:]
		header := encodedPatch
		idx = strings.Index(header, "\n")
		if idx == -1 {
			return rev, origPatch, nil
		}
		header = header[:idx]
		parts := strings.Split(header, " ")
		idx = strings.Index(encodedPatch[len(header):], "diff ")
		if parts[3] == "b/"+filepath {
			if idx != -1 {
				encodedPatch = encodedPatch[:idx]
			}
			return rev, encodedPatch, nil
		} else {
			if idx != -1 {
				encodedPatch = encodedPatch[idx:]
			} else {
				return rev, origPatch, nil
			}
		}
	}
	return rev, encodedPatch, nil
}
diff --git a/history/feed.go b/history/feed.go
index d48c5a1..2621eb5 100644
--- a/history/feed.go
+++ b/history/feed.go
@@ -21,7 +21,7 @@ func recentChangesFeed(opts FeedOptions) *feeds.Feed {
		Description: fmt.Sprintf("List of %d recent changes on the wiki", changeGroupMaxSize),
		Updated:     time.Now(),
	}
	revs := newRecentChangesStream()
	revs := NewRevLogStream()
	groups := groupRevisions(revs, opts)
	for _, grp := range groups {
		item := grp.feedItem(opts)
@@ -46,29 +46,29 @@ func RecentChangesJSON(opts FeedOptions) (string, error) {
}

// revisionGroup is a slice of revisions, ordered most recent first.
type revisionGroup []Revision
type revisionGroup []*Revision

func newRevisionGroup(rev Revision) revisionGroup {
	return []Revision{rev}
func newRevisionGroup(revs ...*Revision) revisionGroup {
	return revs
}

func (grp *revisionGroup) addRevision(rev Revision) {
func (grp *revisionGroup) addRevision(rev *Revision) {
	*grp = append(*grp, rev)
}

// orderedIndex returns the ith revision in the group following the given order.
func (grp *revisionGroup) orderedIndex(i int, order feedGroupOrder) *Revision {
func (grp revisionGroup) orderedIndex(i int, order feedGroupOrder) *Revision {
	switch order {
	case newToOld:
		return &(*grp)[i]
		return grp[i]
	case oldToNew:
		return &(*grp)[len(*grp)-1-i]
		return grp[len(grp)-1-i]
	}
	// unreachable
	return nil
}

func groupRevisionsByMonth(revs []Revision) (res []revisionGroup) {
func groupRevisionsByMonth(revs []*Revision) (res []revisionGroup) {
	var (
		currentYear  int
		currentMonth time.Month
@@ -88,27 +88,25 @@ func groupRevisionsByMonth(revs []Revision) (res []revisionGroup) {
// groupRevisions groups revisions for a feed.
// It returns the first changeGroupMaxSize (30) groups.
// The grouping parameter determines when two revisions will be grouped.
func groupRevisions(revs recentChangesStream, opts FeedOptions) (res []revisionGroup) {
	nextRev := revs.iterator()
	rev, empty := nextRev()
	if empty {
		return res
	}

	currGroup := newRevisionGroup(rev)
	for rev, done := nextRev(); !done; rev, done = nextRev() {
func groupRevisions(revs RevLogStream, opts FeedOptions) (res []revisionGroup) {
	currGroup := newRevisionGroup()
	revs.ForEach(func(rev *Revision) error {
		if opts.canGroup(currGroup, rev) {
			currGroup.addRevision(rev)
		} else {
			res = append(res, currGroup)
			currGroup = newRevisionGroup()
			if len(res) == changeGroupMaxSize {
				return res
				return errors.New("stop")
			}
			currGroup = newRevisionGroup(rev)
			currGroup.addRevision(rev)
		}
		return nil
	})
	if len(currGroup) > 0 {
		res = append(res, currGroup)
	}
	// no more revisions, haven't added the last group yet
	return append(res, currGroup)
	return res
}

func (grp revisionGroup) feedItem(opts FeedOptions) feeds.Item {
@@ -232,14 +230,8 @@ func (parser *feedOptionParserState) parseFeedGroupingSame(query url.Values) err
			for _, sameCond := range same {
				switch sameCond {
				case "author":
					if cond.author {
						return errors.New("set same=author twice")
					}
					cond.author = true
				case "message":
					if cond.message {
						return errors.New("set same=message twice")
					}
					cond.message = true
				default:
					return errors.New("unknown same option " + sameCond)
@@ -281,7 +273,7 @@ func (parser *feedOptionParserState) parseFeedGroupingOrder(query url.Values) er
}

// canGroup determines whether a revision can be added to a group.
func (opts FeedOptions) canGroup(grp revisionGroup, rev Revision) bool {
func (opts FeedOptions) canGroup(grp revisionGroup, rev *Revision) bool {
	if len(opts.conds) == 0 {
		return false
	}
@@ -295,7 +287,7 @@ func (opts FeedOptions) canGroup(grp revisionGroup, rev Revision) bool {
}

type groupingCondition interface {
	canGroup(grp revisionGroup, rev Revision) bool
	canGroup(grp revisionGroup, rev *Revision) bool
}

// periodGroupingCondition will group two revisions if they are within period of each other.
@@ -303,7 +295,7 @@ type periodGroupingCondition struct {
	period time.Duration
}

func (cond periodGroupingCondition) canGroup(grp revisionGroup, rev Revision) bool {
func (cond periodGroupingCondition) canGroup(grp revisionGroup, rev *Revision) bool {
	return grp[len(grp)-1].Time.Sub(rev.Time) < cond.period
}

@@ -312,7 +304,7 @@ type sameGroupingCondition struct {
	message bool
}

func (c sameGroupingCondition) canGroup(grp revisionGroup, rev Revision) bool {
func (c sameGroupingCondition) canGroup(grp revisionGroup, rev *Revision) bool {
	return (!c.author || grp[0].Username == rev.Username) &&
		(!c.message || grp[0].Message == rev.Message)
}
diff --git a/history/history.go b/history/history.go
index 559d9d5..029557e 100644
--- a/history/history.go
+++ b/history/history.go
@@ -2,82 +2,33 @@
package history

import (
	"bytes"
	"fmt"
	"errors"
	"log"
	"os/exec"
	"path/filepath"
	"regexp"

	"github.com/go-git/go-git/v5"

	"github.com/bouncepaw/mycorrhiza/files"
	"github.com/bouncepaw/mycorrhiza/util"
)

// Path to git executable. Set at init()
var gitpath string

var repo *git.Repository
var worktree *git.Worktree
var renameMsgPattern = regexp.MustCompile(`^Rename ā€˜(.*)ā€™ to ā€˜.*ā€™`)

var gitEnv = []string{"GIT_COMMITTER_NAME=wikimind", "GIT_COMMITTER_EMAIL=wikimind@mycorrhiza"}

// Start finds git and initializes git credentials.
func Start() {
	path, err := exec.LookPath("git")
	if err != nil {
		log.Fatal("Could not find the git executable. Check your $PATH.")
	}
	gitpath = path
}

// InitGitRepo checks a Git repository and initializes it if necessary.
func InitGitRepo() {
	// Detect if the Git repo directory is a Git repository
	isGitRepo := true
	buf, err := silentGitsh("rev-parse", "--git-dir")
	if err != nil {
		isGitRepo = false
	}
	if isGitRepo {
		gitDir := buf.String()
		if filepath.IsAbs(gitDir) && !filepath.HasPrefix(gitDir, files.HyphaeDir()) {
			isGitRepo = false
// Init initializes the history subsystem.
func Init() {
	var err error
	repo, err = git.PlainOpen(files.GitRepo())
	if errors.Is(err, git.ErrRepositoryNotExists) {
		repo, err = git.PlainInit(files.GitRepo(), false)
		if err != nil {
			log.Fatal(err)
		}
	}
	if !isGitRepo {
		log.Println("Initializing Git repo at", files.HyphaeDir())
		gitsh("init")
		gitsh("config", "core.quotePath", "false")
	}
}

// I pronounce it as [gÉŖtĶ”Źƒ].
// gitsh is async-safe, therefore all other git-related functions in this module are too.
func gitsh(args ...string) (out bytes.Buffer, err error) {
	fmt.Printf("$ %v\n", args)
	cmd := exec.Command(gitpath, args...)
	cmd.Dir = files.HyphaeDir()
	cmd.Env = gitEnv

	b, err := cmd.CombinedOutput()
	worktree, err = repo.Worktree()
	if err != nil {
		log.Println("gitsh:", err)
		// Assertion: non-bare repositories must have a worktree
		panic(err)
	}
	return *bytes.NewBuffer(b), err
}

// silentGitsh is like gitsh, except it writes less to the stdout.
func silentGitsh(args ...string) (out bytes.Buffer, err error) {
	cmd := exec.Command(gitpath, args...)
	cmd.Dir = files.HyphaeDir()
	cmd.Env = gitEnv

	b, err := cmd.CombinedOutput()
	return *bytes.NewBuffer(b), err
}

// Rename renames from `from` to `to` using `git mv`.
func Rename(from, to string) error {
	log.Println(util.ShorterPath(from), util.ShorterPath(to))
	_, err := gitsh("mv", "--force", from, to)
	return err
}
diff --git a/history/histweb/histview.go b/history/histweb/histview.go
index 4467b44..6a42ea5 100644
--- a/history/histweb/histview.go
+++ b/history/histweb/histview.go
@@ -61,12 +61,12 @@ func handlerPrimitiveDiff(w http.ResponseWriter, rq *http.Request) {
	case *hyphae.EmptyHypha:
		mycoFilePath = filepath.Join(files.HyphaeDir(), h.CanonicalName()+".myco")
	}
	text, err := history.PrimitiveDiffAtRevision(mycoFilePath, revHash)
	rev, text, err := history.PrimitiveDiffAtRevision(mycoFilePath, revHash)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	primitiveDiff(viewutil.MetaFrom(w, rq), h, revHash, text)
	primitiveDiff(viewutil.MetaFrom(w, rq), h, rev.ShortHash(), text)
}

// handlerRecentChanges displays the /recent-changes/ page.
@@ -82,13 +82,10 @@ func handlerRecentChanges(w http.ResponseWriter, rq *http.Request) {
// handlerHistory lists all revisions of a hypha.
func handlerHistory(w http.ResponseWriter, rq *http.Request) {
	hyphaName := util.HyphaNameFromRq(rq, "history")
	var list string

	// History can be found for files that do not exist anymore.
	revs, err := history.Revisions(hyphaName)
	if err == nil {
		list = history.WithRevisions(hyphaName, revs)
	}
	revs := history.Revisions(hyphaName)
	list := history.WithRevisions(hyphaName, revs)
	log.Println("Found", len(revs), "revisions for", hyphaName)

	historyView(viewutil.MetaFrom(w, rq), hyphaName, list)
@@ -148,12 +145,12 @@ var (
type recentChangesData struct {
	*viewutil.BaseData
	EditCount int
	Changes   []history.Revision
	Changes   []*history.Revision
	UserHypha string
	Stops     []int
}

func recentChanges(meta viewutil.Meta, editCount int, changes []history.Revision) {
func recentChanges(meta viewutil.Meta, editCount int, changes []*history.Revision) {
	viewutil.ExecutePage(meta, chainRecentChanges, recentChangesData{
		BaseData:  &viewutil.BaseData{},
		EditCount: editCount,
diff --git a/history/histweb/view_primitive_diff.html b/history/histweb/view_primitive_diff.html
index c2025a0..2fd2a7d 100644
--- a/history/histweb/view_primitive_diff.html
+++ b/history/histweb/view_primitive_diff.html
@@ -8,4 +8,4 @@
		<pre class="codeblock"><code>{{.Text}}</code></pre>
	</article>
</main>
{{end}}
\ No newline at end of file
{{end}}
diff --git a/history/histweb/view_recent_changes.html b/history/histweb/view_recent_changes.html
index 02842ff..67cecb3 100644
--- a/history/histweb/view_recent_changes.html
+++ b/history/histweb/view_recent_changes.html
@@ -22,14 +22,14 @@
			<div class="recent-changes__entry">
				<div>
					<time class="recent-changes__entry__time">
                        {{ $time.Format "15:04 UTC" }}
						{{ $time.Format "15:04 UTC" }}
					</time>
					<span class="recent-changes__entry__message">{{$entry.Hash}}</span>
                    {{ if $entry.Username | ne "anon" }}
					<span class="recent-changes__entry__message">{{$entry.ShortHash}}</span>
					{{ if $entry.Username | ne "anon" }}
						<span class="recent-changes__entry__author">
							&mdash; <a href="/hypha/{{$userHypha}}/{{$entry.Username}}" rel="author">{{$entry.Username}}</a>
						</span>
                    {{end}}
					{{end}}
				</div>
				<div>
					<span class="recent-changes__entry__links">
@@ -46,24 +46,24 @@
	</section>

	<p class="recent-changes__count">
        {{block "count pre" .}}See{{end}}
        {{ $editCount := .EditCount }}
        {{range $i, $m := .Stops }}
            {{if gt $i 0}}
				<span aria-hidden="true">|</span>
            {{end}}
            {{if $m | eq $editCount}}
				<b>{{$m}}</b>
            {{else}}
				<a href="/recent-changes/{{$m}}">{{$m}}</a>
            {{end}}
        {{end}}
        {{block "count post" .}}recent changes{{end}}
	{{block "count pre" .}}See{{end}}
	{{ $editCount := .EditCount }}
	{{range $i, $m := .Stops }}
		{{if gt $i 0}}
			<span aria-hidden="true">|</span>
		{{end}}
		{{if $m | eq $editCount}}
			<b>{{$m}}</b>
		{{else}}
			<a href="/recent-changes/{{$m}}">{{$m}}</a>
		{{end}}
	{{end}}
	{{block "count post" .}}recent changes{{end}}
	</p>

	<p>
		<img class="icon" width="20" height="20" src="/static/icon/feed.svg" aria-hidden="true" alt="RSS icon">
        {{block "subscribe via" .}}Subscribe via <a href="/recent-changes-rss">RSS</a>, <a href="/recent-changes-atom">Atom</a> or <a href="/recent-changes-json">JSON feed</a>.{{end}}
		{{block "subscribe via" .}}Subscribe via <a href="/recent-changes-rss">RSS</a>, <a href="/recent-changes-atom">Atom</a> or <a href="/recent-changes-json">JSON feed</a>.{{end}}
	</p>
</main>
{{end}}
diff --git a/history/operations.go b/history/operations.go
index 1081017..5d40800 100644
--- a/history/operations.go
+++ b/history/operations.go
@@ -3,15 +3,23 @@ package history
// history/operations.go
// 	Things related to writing history.
import (
	"fmt"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing/object"

	"github.com/bouncepaw/mycorrhiza/user"
	"github.com/bouncepaw/mycorrhiza/util"
)

const committerName = "wikimind"
const committerEmail = "wikimind@mycorrhiza"

// gitMutex is used for blocking git operations to avoid clashes.
var gitMutex = sync.Mutex{}

@@ -57,16 +65,6 @@ func Operation(opType OpType) *Op {
	return hop
}

// git operation maker helper
func (hop *Op) gitop(args ...string) *Op {
	out, err := gitsh(args...)
	if err != nil {
		fmt.Println("out:", out.String())
		hop.Errs = append(hop.Errs, err)
	}
	return hop
}

// withErr appends the `err` to the list of errors.
func (hop *Op) withErr(err error) *Op {
	hop.Errs = append(hop.Errs, err)
@@ -80,13 +78,13 @@ func (hop *Op) WithErrAbort(err error) *Op {

// WithFilesRemoved git-rm-s all passed `paths`. Paths can be rooted or not. Paths that are empty strings are ignored.
func (hop *Op) WithFilesRemoved(paths ...string) *Op {
	args := []string{"rm", "--quiet", "--"}
	for _, path := range paths {
		if path != "" {
			args = append(args, path)
		_, err := worktree.Remove(path)
		if err != nil {
			hop = hop.withErr(err)
		}
	}
	return hop.gitop(args...)
	return hop
}

// WithFilesRenamed git-mv-s all passed keys of `pairs` to values of `pairs`. Paths can be rooted ot not. Empty keys are ignored.
@@ -94,10 +92,17 @@ func (hop *Op) WithFilesRenamed(pairs map[string]string) *Op {
	for from, to := range pairs {
		if from != "" {
			if err := os.MkdirAll(filepath.Dir(to), 0777); err != nil {
				hop.Errs = append(hop.Errs, err)
				hop = hop.withErr(err)
				continue
			}
			hop.gitop("mv", "--force", from, to)
			if err := os.Remove(to); err != nil && !errors.Is(err, os.ErrNotExist) {
				hop = hop.withErr(err)
				continue
			}
			_, err := worktree.Move(from, to)
			if err != nil {
				hop = hop.withErr(err)
			}
		}
	}
	return hop
@@ -105,20 +110,31 @@ func (hop *Op) WithFilesRenamed(pairs map[string]string) *Op {

// WithFiles stages all passed `paths`. Paths can be rooted or not.
func (hop *Op) WithFiles(paths ...string) *Op {
	for i, path := range paths {
		paths[i] = util.ShorterPath(path)
	for _, path := range paths {
		path = util.ShorterPath(path)
		_, err := worktree.Add(path)
		if err != nil {
			hop = hop.withErr(err)
		}
	}
	// 1 git operation is more effective than n operations.
	return hop.gitop(append([]string{"add"}, paths...)...)
	return hop
}

// Apply applies history operation by doing the commit. You do not need to call Abort afterwards.
func (hop *Op) Apply() *Op {
	hop.gitop(
		"commit",
		"--author='"+hop.name+" <"+hop.email+">'",
		"--message="+hop.userMsg,
	)
	now := time.Now()
	worktree.Commit(hop.userMsg, &git.CommitOptions{
		Author: &object.Signature{
			Name:  hop.name,
			Email: hop.email,
			When:  now,
		},
		Committer: &object.Signature{
			Name:  committerName,
			Email: committerEmail,
			When:  now,
		},
	})
	gitMutex.Unlock()
	return hop
}
@@ -131,12 +147,11 @@ func (hop *Op) Abort() *Op {

// WithMsg sets what message will be used for the future commit. If user message exceeds one line, it is stripped down.
func (hop *Op) WithMsg(userMsg string) *Op {
	for _, ch := range userMsg {
		if ch == '\r' || ch == '\n' {
			break
		}
		hop.userMsg += string(ch)
	idx := strings.IndexAny(userMsg, "\r\n")
	if idx >= 0 {
		userMsg = userMsg[:idx]
	}
	hop.userMsg = userMsg
	return hop
}

diff --git a/history/revision.go b/history/revision.go
index 4817aa1..89df41b 100644
--- a/history/revision.go
+++ b/history/revision.go
@@ -1,254 +1,216 @@
package history

import (
	"errors"
	"fmt"
	"log"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/bouncepaw/mycorrhiza/files"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
)

// Revision represents a revision, duh. Hash is usually short. Username is extracted from email.
// Revision represents a revision.
type Revision struct {
	Hash              string
	Username          string
	Time              time.Time
	Message           string
	filesAffectedBuf  []string
	hyphaeAffectedBuf []string
}

// gitLog calls `git log` and parses the results.
func gitLog(args ...string) ([]Revision, error) {
	args = append([]string{
		"log", "--abbrev-commit", "--no-merges",
		"--pretty=format:%h\t%ae\t%at\t%s",
	}, args...)
	out, err := silentGitsh(args...)
	if err != nil {
		return nil, err
	}

	outStr := out.String()
	if outStr == "" {
		// if there are no commits to return
		return nil, nil
	}

	var revs []Revision
	for _, line := range strings.Split(outStr, "\n") {
		revs = append(revs, parseRevisionLine(line))
	}
	return revs, nil
}

type recentChangesStream struct {
	currHash string
}

func newRecentChangesStream() recentChangesStream {
	// next returns the next n revisions from the stream, ordered most recent first.
	// If there are less than n revisions remaining, it will return only those.
	return recentChangesStream{currHash: ""}
}

func (stream *recentChangesStream) next(n int) []Revision {
	args := []string{"--max-count=" + strconv.Itoa(n)}
	if stream.currHash == "" {
		args = append(args, "HEAD")
	} else {
		// currHash is the last revision from the last call, so skip it
		args = append(args, "--skip=1", stream.currHash)
	}
	// I don't think this can fail, so ignore the error
	res, _ := gitLog(args...)
	if len(res) != 0 {
		stream.currHash = res[len(res)-1].Hash
	}
	return res
}

// recentChangesIterator returns a function that returns successive revisions from the stream.
// It buffers revisions to avoid calling git every time.
func (stream recentChangesStream) iterator() func() (Revision, bool) {
	var buf []Revision
	return func() (Revision, bool) {
		if len(buf) == 0 {
			// no real reason to choose 30, just needs some large number
			buf = stream.next(30)
			if len(buf) == 0 {
				// revs has no revisions left
				return Revision{}, true
	Hash      string
	Username  string
	Time      time.Time
	Message   string
	HyphaName string

	commit *object.Commit

	// cached properties
	changesVal        object.Changes
	changesErr        error
	filesAffectedVal  []*object.File
	filesAffectedErr  error
	hyphaeAffectedVal []string
	hyphaeAffectedErr error
}

// parseRevision converts a git commit into a revision.
func parseRevision(commit *object.Commit) *Revision {
	r := &Revision{
		Hash:     commit.Hash.String(),
		Username: commit.Author.Name,
		Time:     commit.Author.When,
		Message:  commit.Message,
		commit:   commit,
	}
	changes, err := r.changes()
	if err == nil && len(changes) > 0 {
		name := changes[0].To.Name
		if name == "" {
			name = changes[0].From.Name
		}
		if name != "" {
			first, _, found := strings.Cut(name, ".")
			if found {
				r.HyphaName = first
			}
		}
		rev := buf[0]
		buf = buf[1:]
		return rev, false
	}
	return r
}

// RecentChanges gathers an arbitrary number of latest changes in form of revisions slice, ordered most recent first.
func RecentChanges(n int) []Revision {
	stream := newRecentChangesStream()
	revs := stream.next(n)
	log.Printf("Found %d recent changes", len(revs))
	return revs
}

// Revisions returns slice of revisions for the given hypha name, ordered most recent first.
func Revisions(hyphaName string) ([]Revision, error) {
	revs, err := gitLog("--", hyphaName+".*")
	log.Printf("Found %d revisions for ā€˜%sā€™\n", len(revs), hyphaName)
	return revs, err
// ShortHash returns a shorter version of the revision hash for display.
func (r *Revision) ShortHash() string {
	if len(r.Hash) <= 7 {
		return r.Hash
	}
	return r.Hash[:7]
}

// FileChanged tells you if the file has been changed since the last commit.
func FileChanged(path string) bool {
	_, err := gitsh("diff", "--exit-code", path)
	return err != nil
// TimeString returns a human readable time representation.
func (rev Revision) TimeString() string {
	return rev.Time.Format(time.RFC822)
}

// Return time like dd ā€” 13:42
// timeToDisplay formats time in the format "dd ā€” 13:42".
func (rev *Revision) timeToDisplay() string {
	D := rev.Time.Day()
	h, m, _ := rev.Time.Clock()
	return fmt.Sprintf("%02d ā€” %02d:%02d", D, h, m)
}

var revisionLinePattern = regexp.MustCompile("(.*)\t(.*)@.*\t(.*)\t(.*)")

// Convert a UNIX timestamp as string into a time. If nil is returned, it means that the timestamp could not be converted.
func unixTimestampAsTime(ts string) *time.Time {
	i, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return nil
// changes computes the changes between this and the parent revision.
func (rev *Revision) changes() (object.Changes, error) {
	if rev.changesVal == nil && rev.changesErr == nil {
		rev.changesVal, rev.changesErr = rev.findChanges()
	}
	tm := time.Unix(i, 0)
	return &tm
	return rev.changesVal, rev.changesErr
}

func parseRevisionLine(line string) Revision {
	results := revisionLinePattern.FindStringSubmatch(line)
	return Revision{
		Hash:     results[1],
		Username: results[2],
		Time:     *unixTimestampAsTime(results[3]),
		Message:  results[4],
func (rev *Revision) findChanges() (object.Changes, error) {
	oldTree, newTree, err := getDiffTrees(rev.commit)
	if err != nil {
		return nil, err
	}
	return oldTree.Diff(newTree)
}

// filesAffected tells what files have been affected by the revision.
func (rev *Revision) filesAffected() (filenames []string) {
	if nil != rev.filesAffectedBuf {
		return rev.filesAffectedBuf
func (rev *Revision) filesAffected() ([]*object.File, error) {
	if rev.filesAffectedVal == nil && rev.filesAffectedErr == nil {
		rev.filesAffectedVal, rev.filesAffectedErr = rev.findFilesAffected()
	}
	// List of files affected by this revision, one per line.
	out, err := silentGitsh("diff-tree", "--no-commit-id", "--name-only", "-r", rev.Hash)
	// There's an error? Well, whatever, let's just assign an empty slice, who cares.
	if err != nil {
		rev.filesAffectedBuf = []string{}
	} else {
		rev.filesAffectedBuf = strings.Split(out.String(), "\n")
	}
	return rev.filesAffectedBuf
	return rev.filesAffectedVal, rev.filesAffectedErr
}

// determine what hyphae were affected by this revision
func (rev *Revision) hyphaeAffected() (hyphae []string) {
	if nil != rev.hyphaeAffectedBuf {
		return rev.hyphaeAffectedBuf
	}
	hyphae = make([]string, 0)
	var (
		// set is used to determine if a certain hypha has been already noted (hyphae are stored in 2 files at most currently).
		set       = make(map[string]bool)
		isNewName = func(hyphaName string) bool {
			if _, present := set[hyphaName]; present {
				return false
			}
			set[hyphaName] = true
			return true
func (rev *Revision) findFilesAffected() ([]*object.File, error) {
	changes, err := rev.changes()
	if err != nil {
		return nil, err
	}
	var files []*object.File
	for _, change := range changes {
		from, to, err := change.Files()
		if err != nil {
			continue
		}
		filesAffected = rev.filesAffected()
	)
	for _, filename := range filesAffected {
		if strings.ContainsRune(filename, '.') {
			dotPos := strings.LastIndexByte(filename, '.')
			hyphaName := string([]byte(filename)[0:dotPos]) // is it safe?
			if isNewName(hyphaName) {
				hyphae = append(hyphae, hyphaName)
			}
		if from != nil && to != nil && from.Name == to.Name {
			to = nil
		}
		if from != nil {
			files = append(files, from)
		}
		if to != nil {
			files = append(files, to)
		}
	}
	rev.hyphaeAffectedBuf = hyphae
	return hyphae
	return files, nil
}

// TimeString returns a human readable time representation.
func (rev Revision) TimeString() string {
	return rev.Time.Format(time.RFC822)
// hyphaeAffected tells what hyphae were affected by this revision.
func (rev *Revision) hyphaeAffected() ([]string, error) {
	if rev.hyphaeAffectedVal == nil && rev.hyphaeAffectedErr == nil {
		rev.hyphaeAffectedVal, rev.hyphaeAffectedErr = rev.findHyphaeAffected()
	}
	return rev.hyphaeAffectedVal, rev.hyphaeAffectedErr
}

// textDiff generates a good enough diff to display in a web feed. It is not html-escaped.
func (rev *Revision) textDiff() (diff string) {
	filenames, ok := rev.mycoFiles()
	if !ok {
		return "No text changes"
func (rev *Revision) findHyphaeAffected() ([]string, error) {
	files, err := rev.filesAffected()
	if err != nil {
		return nil, err
	}

	var hyphae []string
	set := make(map[string]bool)
	isPresent := func(hyphaName string) bool {
		present := set[hyphaName]
		set[hyphaName] = true
		return present
	}
	for _, filename := range filenames {
		text, err := PrimitiveDiffAtRevision(filename, rev.Hash)
		if err != nil {
			diff += "\nAn error has occurred with " + filename + "\n"
	for _, file := range files {
		hyphaName, _, found := strings.Cut(file.Name, ".")
		if found && !isPresent(hyphaName) {
			hyphae = append(hyphae, hyphaName)
		}
		diff += text + "\n"
	}
	return diff
	return hyphae, nil
}

// mycoFiles returns filenames of .myco file. It is not ok if there are no myco files.
func (rev *Revision) mycoFiles() (filenames []string, ok bool) {
	filenames = []string{}
	for _, filename := range rev.filesAffected() {
		if strings.HasSuffix(filename, ".myco") {
			filenames = append(filenames, filename)
// textDiff generates a diff used in web feeds.
func (rev *Revision) textDiff() string {
	parent, err := rev.commit.Parent(0)
	if errors.Is(err, object.ErrParentNotFound) {
		return "Root commit"
	} else if err != nil {
		return "Failed to get the parent commit"
	}
	patch, err := rev.commit.Patch(parent)
	if err != nil {
		return "Failed to get a diff"
	}
	return patch.String()
}

// mycoFiles returns filenames of .myco file. It is not ok if there are no myco
// files.
func (rev *Revision) mycoFiles() ([]*object.File, bool) {
	var files []*object.File
	allFiles, err := rev.filesAffected()
	if err != nil {
		// XXX: error "casted" to bool without any context
		return nil, false
	}
	for _, file := range allFiles {
		if strings.HasSuffix(file.Name, ".myco") {
			files = append(files, file)
		}
	}
	return filenames, len(filenames) > 0
	return files, len(files) > 0
}

// Try and guess what link is the most important by looking at the message.
func (rev *Revision) bestLink() string {
	var (
		revs      = rev.hyphaeAffected()
		renameRes = renameMsgPattern.FindStringSubmatch(rev.Message)
	)
	switch {
	case renameRes != nil:
		return "/hypha/" + renameRes[1]
	case len(revs) == 0:
	hyphae, err := rev.hyphaeAffected()
	if err != nil || len(hyphae) == 0 {
		return ""
	default:
		return "/hypha/" + revs[0]
	}
	if r := renameMsgPattern.FindStringSubmatch(rev.Message); r != nil {
		return "/hypha/" + r[1]
	}
	return "/hypha/" + hyphae[0]
}

// FileAtRevision shows how the file with the given file path looked at the commit with the hash. It may return an error if git fails.
func FileAtRevision(filepath, hash string) (string, error) {
	out, err := gitsh("show", hash+":"+strings.TrimPrefix(filepath, files.HyphaeDir()+"/"))
func getDiffTrees(commit *object.Commit) (*object.Tree, *object.Tree, error) {
	var oldTree *object.Tree
	newTree, err := commit.Tree()
	if err != nil {
		return "", err
		return nil, nil, err
	}
	parent, err := commit.Parent(0)
	if err == nil {
		oldTree, err = parent.Tree()
	}
	return out.String(), err
}

// PrimitiveDiffAtRevision generates a plain-text diff for the given filepath at the commit with the given hash. It may return an error if git fails.
func PrimitiveDiffAtRevision(filepath, hash string) (string, error) {
	out, err := silentGitsh("diff", "--unified=1", "--no-color", hash+"~", hash, "--", filepath)
	if err != nil {
		return "", err
		treeCopy := *newTree
		oldTree = &treeCopy
		oldTree.Entries = nil
		oldTree.Hash = plumbing.ZeroHash
	}
	return out.String(), err
	return oldTree, newTree, nil
}
diff --git a/history/revlog.go b/history/revlog.go
new file mode 100644
index 0000000..a339344
--- /dev/null
+++ b/history/revlog.go
@@ -0,0 +1,142 @@
package history

import (
	"io"
	"log"
	"strings"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"

	"github.com/bouncepaw/mycorrhiza/util"
)

// RevLogStream is an iterator over revisions.
type RevLogStream struct {
	commitIter object.CommitIter
}

// NewRevLogStream creates a revision log stream.
func NewRevLogStream() RevLogStream {
	var (
		s   RevLogStream
		err error
	)
	if _, err := repo.Head(); err != nil {
		return s
	}
	s.commitIter, err = repo.Log(&git.LogOptions{})
	if err != nil {
		panic(err)
	}
	return s
}

// Next returns the next revision from the stream, ordered most recent first.
// If there are no revisions remaining, the err returned will be io.EOF.
func (s RevLogStream) Next() (*Revision, error) {
	if s.commitIter == nil {
		return nil, io.EOF
	}
	commit, err := s.commitIter.Next()
	if err != nil {
		return nil, err
	}
	return parseRevision(commit), nil
}

// ForEach invokes a callback for every revision. If the callback returns an
// error, ForEach stops and returns the error.
func (s RevLogStream) ForEach(fn func(*Revision) error) error {
	for {
		rev, err := s.Next()
		if rev == nil || err == io.EOF {
			return nil
		}
		if err := fn(rev); err != nil {
			return err
		}
	}
	return nil
}

// RecentChanges gathers an arbitrary number of latest changes in form of
// revisions slice, ordered most recent first.
func RecentChanges(n int) []*Revision {
	stream := NewRevLogStream()
	var revs []*Revision
	for len(revs) < n {
		rev, err := stream.Next()
		if rev == nil || err != nil {
			break
		}
		revs = append(revs, rev)
	}
	log.Printf("Found %d recent changes", len(revs))
	return revs
}

// Revisions returns revisions of the given hypha, ordered most recent first.
func Revisions(hyphaName string) (revs []*Revision) {
	fileName := hyphaName + ".myco"
	ref, err := repo.Head()
	if err != nil {
		log.Printf("failed to resolve the HEAD reference: %v", err)
		return
	}
	commit, err := repo.CommitObject(ref.Hash())
	if err != nil {
		log.Printf("failed to retrieve the HEAD commit: %v", err)
		return
	}
	for commit != nil {
		rev := parseRevision(commit)
		parent, err := commit.Parent(0)
		if err != nil {
			commit = nil
		} else {
			commit = parent
		}
		changes, err := rev.changes()
		if err != nil {
			continue
		}
		for _, change := range changes {
			if change.To.Name == fileName {
				rev.HyphaName = strings.TrimSuffix(fileName, ".myco")
				revs = append(revs, rev)
				if change.From.Name != "" {
					fileName = change.From.Name
				}
			}
		}
	}
	return revs
}

// FileAtRevision shows how the file with the given file path looked at the
// commit with the hash. It may return an error if git fails.
func FileAtRevision(filepath, hash string) (string, error) {
	filepath = util.ShorterPath(filepath)
	commit, err := repo.CommitObject(plumbing.NewHash(hash))
	if err != nil {
		return "", err
	}
	file, err := commit.File(filepath)
	if err != nil {
		return "", err
	}
	bin, err := file.IsBinary()
	if err != nil {
		return "", err
	}
	if bin {
		return "(binary file)", nil
	}
	contents, err := file.Contents()
	if err != nil {
		return "", err
	}
	return contents, nil
}
diff --git a/history/view.qtpl b/history/view.qtpl
index 67e5cfd..dd9be10 100644
--- a/history/view.qtpl
+++ b/history/view.qtpl
@@ -3,8 +3,9 @@

HyphaeLinksHTML returns a comma-separated list of hyphae that were affected by this revision as HTML string.
{% func (rev Revision) HyphaeLinksHTML() %}
{% code hyphae, _ := rev.hyphaeAffected() %}
{% stripspace %}
	{% for i, hyphaName := range rev.hyphaeAffected() %}
	{% for i, hyphaName := range hyphae %}
		{% if i > 0 %}
			<span aria-hidden="true">, </span>
		{% endif %}
@@ -21,7 +22,7 @@ descriptionForFeed generates a good enough HTML contents for a web feed.
{% endfunc %}

WithRevisions returns an html representation of `revs` that is meant to be inserted in a history page.
{% func WithRevisions(hyphaName string, revs []Revision) %}
{% func WithRevisions(hyphaName string, revs []*Revision) %}
{% for _, grp := range groupRevisionsByMonth(revs) %}
	{% code
    currentYear := grp[0].Time.Year()
@@ -42,14 +43,20 @@ WithRevisions returns an html representation of `revs` that is meant to be inser
{% endfunc %}

{% func (rev *Revision) asHistoryEntry(hyphaName string) %}
{% code
	changedHypha := rev.HyphaName
	if changedHypha == "" {
		changedHypha = hyphaName
	}
%}
<li class="history__entry">
	<a class="history-entry" href="/rev/{%s rev.Hash %}/{%s hyphaName %}">
	<a class="history-entry" href="/rev/{%s rev.Hash %}/{%s changedHypha %}">
        <time class="history-entry__time">{%s rev.timeToDisplay() %}</time>
    </a>
	<span class="history-entry__hash"><a href="/primitive-diff/{%s rev.Hash %}/{%s hyphaName %}">{%s rev.Hash %}</a></span>
	<span class="history-entry__hash"><a href="/primitive-diff/{%s rev.Hash %}/{%s changedHypha %}">{%s rev.ShortHash() %}</a></span>
	<span class="history-entry__msg">{%s rev.Message %}</span>
	{% if rev.Username != "anon" %}
        <span class="history-entry__author">by <a href="/hypha/{%s cfg.UserHypha %}/{%s rev.Username %}" rel="author">{%s rev.Username %}</a></span>
    {% endif %}
</li>
{% endfunc %}
\ No newline at end of file
{% endfunc %}
diff --git a/history/view.qtpl.go b/history/view.qtpl.go
index 64bc380..db9f804 100644
--- a/history/view.qtpl.go
+++ b/history/view.qtpl.go
@@ -30,297 +30,312 @@ func (rev Revision) StreamHyphaeLinksHTML(qw422016 *qt422016.Writer) {
//line history/view.qtpl:5
	qw422016.N().S(`
`)
//line history/view.qtpl:7
	for i, hyphaName := range rev.hyphaeAffected() {
//line history/view.qtpl:6
	hyphae, _ := rev.hyphaeAffected()

//line history/view.qtpl:6
	qw422016.N().S(`
`)
//line history/view.qtpl:8
	for i, hyphaName := range hyphae {
//line history/view.qtpl:9
		if i > 0 {
//line history/view.qtpl:8
//line history/view.qtpl:9
			qw422016.N().S(`<span aria-hidden="true">, </span>`)
//line history/view.qtpl:10
//line history/view.qtpl:11
		}
//line history/view.qtpl:10
		qw422016.N().S(`<a href="/hypha/`)
//line history/view.qtpl:11
		qw422016.N().S(`<a href="/hypha/`)
//line history/view.qtpl:12
		qw422016.E().S(hyphaName)
//line history/view.qtpl:11
//line history/view.qtpl:12
		qw422016.N().S(`">`)
//line history/view.qtpl:11
//line history/view.qtpl:12
		qw422016.E().S(hyphaName)
//line history/view.qtpl:11
		qw422016.N().S(`</a>`)
//line history/view.qtpl:12
	}
		qw422016.N().S(`</a>`)
//line history/view.qtpl:13
	}
//line history/view.qtpl:14
	qw422016.N().S(`
`)
//line history/view.qtpl:14
//line history/view.qtpl:15
}

//line history/view.qtpl:14
//line history/view.qtpl:15
func (rev Revision) WriteHyphaeLinksHTML(qq422016 qtio422016.Writer) {
//line history/view.qtpl:14
//line history/view.qtpl:15
	qw422016 := qt422016.AcquireWriter(qq422016)
//line history/view.qtpl:14
//line history/view.qtpl:15
	rev.StreamHyphaeLinksHTML(qw422016)
//line history/view.qtpl:14
//line history/view.qtpl:15
	qt422016.ReleaseWriter(qw422016)
//line history/view.qtpl:14
//line history/view.qtpl:15
}

//line history/view.qtpl:14
//line history/view.qtpl:15
func (rev Revision) HyphaeLinksHTML() string {
//line history/view.qtpl:14
//line history/view.qtpl:15
	qb422016 := qt422016.AcquireByteBuffer()
//line history/view.qtpl:14
//line history/view.qtpl:15
	rev.WriteHyphaeLinksHTML(qb422016)
//line history/view.qtpl:14
//line history/view.qtpl:15
	qs422016 := string(qb422016.B)
//line history/view.qtpl:14
//line history/view.qtpl:15
	qt422016.ReleaseByteBuffer(qb422016)
//line history/view.qtpl:14
//line history/view.qtpl:15
	return qs422016
//line history/view.qtpl:14
//line history/view.qtpl:15
}

// descriptionForFeed generates a good enough HTML contents for a web feed.

//line history/view.qtpl:17
//line history/view.qtpl:18
func (rev *Revision) streamdescriptionForFeed(qw422016 *qt422016.Writer) {
//line history/view.qtpl:17
//line history/view.qtpl:18
	qw422016.N().S(`
<p><b>`)
//line history/view.qtpl:18
//line history/view.qtpl:19
	qw422016.E().S(rev.Message)
//line history/view.qtpl:18
//line history/view.qtpl:19
	qw422016.N().S(`</b> (by `)
//line history/view.qtpl:18
//line history/view.qtpl:19
	qw422016.E().S(rev.Username)
//line history/view.qtpl:18
//line history/view.qtpl:19
	qw422016.N().S(` at `)
//line history/view.qtpl:18
//line history/view.qtpl:19
	qw422016.E().S(rev.TimeString())
//line history/view.qtpl:18
//line history/view.qtpl:19
	qw422016.N().S(`)</p>
<p>Hyphae affected: `)
//line history/view.qtpl:19
//line history/view.qtpl:20
	rev.StreamHyphaeLinksHTML(qw422016)
//line history/view.qtpl:19
//line history/view.qtpl:20
	qw422016.N().S(`</p>
<pre><code>`)
//line history/view.qtpl:20
//line history/view.qtpl:21
	qw422016.E().S(rev.textDiff())
//line history/view.qtpl:20
//line history/view.qtpl:21
	qw422016.N().S(`</code></pre>
`)
//line history/view.qtpl:21
//line history/view.qtpl:22
}

//line history/view.qtpl:21
//line history/view.qtpl:22
func (rev *Revision) writedescriptionForFeed(qq422016 qtio422016.Writer) {
//line history/view.qtpl:21
//line history/view.qtpl:22
	qw422016 := qt422016.AcquireWriter(qq422016)
//line history/view.qtpl:21
//line history/view.qtpl:22
	rev.streamdescriptionForFeed(qw422016)
//line history/view.qtpl:21
//line history/view.qtpl:22
	qt422016.ReleaseWriter(qw422016)
//line history/view.qtpl:21
//line history/view.qtpl:22
}

//line history/view.qtpl:21
//line history/view.qtpl:22
func (rev *Revision) descriptionForFeed() string {
//line history/view.qtpl:21
//line history/view.qtpl:22
	qb422016 := qt422016.AcquireByteBuffer()
//line history/view.qtpl:21
//line history/view.qtpl:22
	rev.writedescriptionForFeed(qb422016)
//line history/view.qtpl:21
//line history/view.qtpl:22
	qs422016 := string(qb422016.B)
//line history/view.qtpl:21
//line history/view.qtpl:22
	qt422016.ReleaseByteBuffer(qb422016)
//line history/view.qtpl:21
//line history/view.qtpl:22
	return qs422016
//line history/view.qtpl:21
//line history/view.qtpl:22
}

// WithRevisions returns an html representation of `revs` that is meant to be inserted in a history page.

//line history/view.qtpl:24
func StreamWithRevisions(qw422016 *qt422016.Writer, hyphaName string, revs []Revision) {
//line history/view.qtpl:24
//line history/view.qtpl:25
func StreamWithRevisions(qw422016 *qt422016.Writer, hyphaName string, revs []*Revision) {
//line history/view.qtpl:25
	qw422016.N().S(`
`)
//line history/view.qtpl:25
//line history/view.qtpl:26
	for _, grp := range groupRevisionsByMonth(revs) {
//line history/view.qtpl:25
//line history/view.qtpl:26
		qw422016.N().S(`
	`)
//line history/view.qtpl:27
//line history/view.qtpl:28
		currentYear := grp[0].Time.Year()
		currentMonth := grp[0].Time.Month()
		sectionId := fmt.Sprintf("%04d-%02d", currentYear, currentMonth)

//line history/view.qtpl:30
//line history/view.qtpl:31
		qw422016.N().S(`
<section class="history__month">
	<a href="#`)
//line history/view.qtpl:32
//line history/view.qtpl:33
		qw422016.E().S(sectionId)
//line history/view.qtpl:32
//line history/view.qtpl:33
		qw422016.N().S(`" class="history__month-anchor">
		<h2 id="`)
//line history/view.qtpl:33
//line history/view.qtpl:34
		qw422016.E().S(sectionId)
//line history/view.qtpl:33
//line history/view.qtpl:34
		qw422016.N().S(`" class="history__month-title">`)
//line history/view.qtpl:33
//line history/view.qtpl:34
		qw422016.N().D(currentYear)
//line history/view.qtpl:33
//line history/view.qtpl:34
		qw422016.N().S(` `)
//line history/view.qtpl:33
//line history/view.qtpl:34
		qw422016.E().S(currentMonth.String())
//line history/view.qtpl:33
//line history/view.qtpl:34
		qw422016.N().S(`</h2>
	</a>
	<ul class="history__entries">
        `)
//line history/view.qtpl:36
//line history/view.qtpl:37
		for _, rev := range grp {
//line history/view.qtpl:36
//line history/view.qtpl:37
			qw422016.N().S(`
            `)
//line history/view.qtpl:37
//line history/view.qtpl:38
			rev.streamasHistoryEntry(qw422016, hyphaName)
//line history/view.qtpl:37
//line history/view.qtpl:38
			qw422016.N().S(`
        `)
//line history/view.qtpl:38
//line history/view.qtpl:39
		}
//line history/view.qtpl:38
//line history/view.qtpl:39
		qw422016.N().S(`
	</ul>
</section>
`)
//line history/view.qtpl:41
//line history/view.qtpl:42
	}
//line history/view.qtpl:41
//line history/view.qtpl:42
	qw422016.N().S(`
`)
//line history/view.qtpl:42
//line history/view.qtpl:43
}

//line history/view.qtpl:42
func WriteWithRevisions(qq422016 qtio422016.Writer, hyphaName string, revs []Revision) {
//line history/view.qtpl:42
//line history/view.qtpl:43
func WriteWithRevisions(qq422016 qtio422016.Writer, hyphaName string, revs []*Revision) {
//line history/view.qtpl:43
	qw422016 := qt422016.AcquireWriter(qq422016)
//line history/view.qtpl:42
//line history/view.qtpl:43
	StreamWithRevisions(qw422016, hyphaName, revs)
//line history/view.qtpl:42
//line history/view.qtpl:43
	qt422016.ReleaseWriter(qw422016)
//line history/view.qtpl:42
//line history/view.qtpl:43
}

//line history/view.qtpl:42
func WithRevisions(hyphaName string, revs []Revision) string {
//line history/view.qtpl:42
//line history/view.qtpl:43
func WithRevisions(hyphaName string, revs []*Revision) string {
//line history/view.qtpl:43
	qb422016 := qt422016.AcquireByteBuffer()
//line history/view.qtpl:42
//line history/view.qtpl:43
	WriteWithRevisions(qb422016, hyphaName, revs)
//line history/view.qtpl:42
//line history/view.qtpl:43
	qs422016 := string(qb422016.B)
//line history/view.qtpl:42
//line history/view.qtpl:43
	qt422016.ReleaseByteBuffer(qb422016)
//line history/view.qtpl:42
//line history/view.qtpl:43
	return qs422016
//line history/view.qtpl:42
//line history/view.qtpl:43
}

//line history/view.qtpl:44
//line history/view.qtpl:45
func (rev *Revision) streamasHistoryEntry(qw422016 *qt422016.Writer, hyphaName string) {
//line history/view.qtpl:44
//line history/view.qtpl:45
	qw422016.N().S(`
`)
//line history/view.qtpl:47
	changedHypha := rev.HyphaName
	if changedHypha == "" {
		changedHypha = hyphaName
	}

//line history/view.qtpl:51
	qw422016.N().S(`
<li class="history__entry">
	<a class="history-entry" href="/rev/`)
//line history/view.qtpl:46
//line history/view.qtpl:53
	qw422016.E().S(rev.Hash)
//line history/view.qtpl:46
//line history/view.qtpl:53
	qw422016.N().S(`/`)
//line history/view.qtpl:46
	qw422016.E().S(hyphaName)
//line history/view.qtpl:46
//line history/view.qtpl:53
	qw422016.E().S(changedHypha)
//line history/view.qtpl:53
	qw422016.N().S(`">
        <time class="history-entry__time">`)
//line history/view.qtpl:47
//line history/view.qtpl:54
	qw422016.E().S(rev.timeToDisplay())
//line history/view.qtpl:47
//line history/view.qtpl:54
	qw422016.N().S(`</time>
    </a>
	<span class="history-entry__hash"><a href="/primitive-diff/`)
//line history/view.qtpl:49
//line history/view.qtpl:56
	qw422016.E().S(rev.Hash)
//line history/view.qtpl:49
//line history/view.qtpl:56
	qw422016.N().S(`/`)
//line history/view.qtpl:49
	qw422016.E().S(hyphaName)
//line history/view.qtpl:49
//line history/view.qtpl:56
	qw422016.E().S(changedHypha)
//line history/view.qtpl:56
	qw422016.N().S(`">`)
//line history/view.qtpl:49
	qw422016.E().S(rev.Hash)
//line history/view.qtpl:49
//line history/view.qtpl:56
	qw422016.E().S(rev.ShortHash())
//line history/view.qtpl:56
	qw422016.N().S(`</a></span>
	<span class="history-entry__msg">`)
//line history/view.qtpl:50
//line history/view.qtpl:57
	qw422016.E().S(rev.Message)
//line history/view.qtpl:50
//line history/view.qtpl:57
	qw422016.N().S(`</span>
	`)
//line history/view.qtpl:51
//line history/view.qtpl:58
	if rev.Username != "anon" {
//line history/view.qtpl:51
//line history/view.qtpl:58
		qw422016.N().S(`
        <span class="history-entry__author">by <a href="/hypha/`)
//line history/view.qtpl:52
//line history/view.qtpl:59
		qw422016.E().S(cfg.UserHypha)
//line history/view.qtpl:52
//line history/view.qtpl:59
		qw422016.N().S(`/`)
//line history/view.qtpl:52
//line history/view.qtpl:59
		qw422016.E().S(rev.Username)
//line history/view.qtpl:52
//line history/view.qtpl:59
		qw422016.N().S(`" rel="author">`)
//line history/view.qtpl:52
//line history/view.qtpl:59
		qw422016.E().S(rev.Username)
//line history/view.qtpl:52
//line history/view.qtpl:59
		qw422016.N().S(`</a></span>
    `)
//line history/view.qtpl:53
//line history/view.qtpl:60
	}
//line history/view.qtpl:53
//line history/view.qtpl:60
	qw422016.N().S(`
</li>
`)
//line history/view.qtpl:55
//line history/view.qtpl:62
}

//line history/view.qtpl:55
//line history/view.qtpl:62
func (rev *Revision) writeasHistoryEntry(qq422016 qtio422016.Writer, hyphaName string) {
//line history/view.qtpl:55
//line history/view.qtpl:62
	qw422016 := qt422016.AcquireWriter(qq422016)
//line history/view.qtpl:55
//line history/view.qtpl:62
	rev.streamasHistoryEntry(qw422016, hyphaName)
//line history/view.qtpl:55
//line history/view.qtpl:62
	qt422016.ReleaseWriter(qw422016)
//line history/view.qtpl:55
//line history/view.qtpl:62
}

//line history/view.qtpl:55
//line history/view.qtpl:62
func (rev *Revision) asHistoryEntry(hyphaName string) string {
//line history/view.qtpl:55
//line history/view.qtpl:62
	qb422016 := qt422016.AcquireByteBuffer()
//line history/view.qtpl:55
//line history/view.qtpl:62
	rev.writeasHistoryEntry(qb422016, hyphaName)
//line history/view.qtpl:55
//line history/view.qtpl:62
	qs422016 := string(qb422016.B)
//line history/view.qtpl:55
//line history/view.qtpl:62
	qt422016.ReleaseByteBuffer(qb422016)
//line history/view.qtpl:55
//line history/view.qtpl:62
	return qs422016
//line history/view.qtpl:55
//line history/view.qtpl:62
}
diff --git a/l18n/l18n.go b/l18n/l18n.go
index b557de0..d352d61 100644
--- a/l18n/l18n.go
+++ b/l18n/l18n.go
@@ -40,6 +40,7 @@ type Localizer struct {
}

// locales is a filesystem containing all localization files.
//
//go:embed en ru
var locales embed.FS

diff --git a/main.go b/main.go
index ad764f1..65587d1 100644
--- a/main.go
+++ b/main.go
@@ -1,9 +1,10 @@
// Command mycorrhiza is a program that runs a mycorrhiza wiki.
//
//go:generate go run github.com/valyala/quicktemplate/qtc -dir=tree
//go:generate go run github.com/valyala/quicktemplate/qtc -dir=history
//go:generate go run github.com/valyala/quicktemplate/qtc -dir=mycoopts
//go:generate go run github.com/valyala/quicktemplate/qtc -dir=auth
//go:generate go run github.com/valyala/quicktemplate/qtc -dir=hypview
// Command mycorrhiza is a program that runs a mycorrhiza wiki.
package main

import (
@@ -48,8 +49,7 @@ func main() {
	backlinks.IndexBacklinks()
	go backlinks.RunBacklinksConveyor()
	user.InitUserDatabase()
	history.Start()
	history.InitGitRepo()
	history.Init()
	migration.MigrateRocketsMaybe()
	migration.MigrateHeadingsMaybe()
	shroom.SetHeaderLinks()
diff --git a/migration/migration.go b/migration/migration.go
index 7f4a683..00bf1ff 100644
--- a/migration/migration.go
+++ b/migration/migration.go
@@ -3,8 +3,8 @@
// Migrations are meant to be removed couple of versions after being introduced.
//
// Available migrations:
//     * Rocket links
//     * Headings
//   - Rocket links
//   - Headings
package migration

import (
diff --git a/shroom/upload.go b/shroom/upload.go
index 5599b01..a9127c6 100644
--- a/shroom/upload.go
+++ b/shroom/upload.go
@@ -4,17 +4,17 @@ import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"mime/multipart"
	"os"
	"path/filepath"

	"github.com/bouncepaw/mycorrhiza/backlinks"
	"github.com/bouncepaw/mycorrhiza/files"
	"github.com/bouncepaw/mycorrhiza/history"
	"github.com/bouncepaw/mycorrhiza/hyphae"
	"github.com/bouncepaw/mycorrhiza/mimetype"
	"github.com/bouncepaw/mycorrhiza/user"
	"io"
	"log"
	"mime/multipart"
	"os"
	"path/filepath"
)

func historyMessageForTextUpload(h hyphae.Hypha, userMessage string) string {
@@ -183,6 +183,7 @@ func UploadBinary(h hyphae.Hypha, mime string, file multipart.File, u *user.User
	if err != nil {
		return err
	}
	filesChanged := []string{uploadedFilePath}

	switch h := h.(type) {
	case *hyphae.EmptyHypha:
@@ -193,10 +194,10 @@ func UploadBinary(h hyphae.Hypha, mime string, file multipart.File, u *user.User
	case *hyphae.MediaHypha: // If this is not the first media the hypha gets
		prevFilePath := h.MediaFilePath()
		if prevFilePath != uploadedFilePath {
			if err := history.Rename(prevFilePath, uploadedFilePath); err != nil {
			if err := os.Remove(prevFilePath); err != nil {
				return err
			}
			log.Printf("Move ā€˜%sā€™ to ā€˜%sā€™\n", prevFilePath, uploadedFilePath)
			filesChanged = append(filesChanged, prevFilePath)
			h.SetMediaFilePath(uploadedFilePath)
		}
	}
diff --git a/util/util.go b/util/util.go
index 74d641f..2fb27b2 100644
--- a/util/util.go
+++ b/util/util.go
@@ -3,13 +3,14 @@ package util
import (
	"crypto/rand"
	"encoding/hex"
	"github.com/bouncepaw/mycorrhiza/files"
	"log"
	"net/http"
	"strings"

	"github.com/bouncepaw/mycomarkup/v5/util"

	"github.com/bouncepaw/mycorrhiza/cfg"
	"github.com/bouncepaw/mycorrhiza/files"
)

// PrepareRq strips the trailing / in rq.URL.Path. In the future it might do more stuff for making all request structs uniform.
-- 
2.32.1 (Apple Git-133)
Details
Message ID
<04df0e8f-dd39-e8e4-a492-9682dd03606d@ya.ru>
In-Reply-To
<20220819200017.10180-1-umar@handlerug.me> (view parent)
DKIM signature
missing
Download raw message
This is a long-awaited patch! To test it, I ran a local copy of
https://mycorrhiza.wiki as of the time of the patch.

My OS is Linux Mint 20.1 x86_64, my processor is weak.

> +	github.com/Microsoft/go-winio v0.4.16 // indirect
> +	github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect
> +	github.com/acomagu/bufpipe v1.0.3 // indirect
> +	github.com/emirpasic/gods v1.12.0 // indirect
> +	github.com/go-git/gcfg v1.5.0 // indirect
> +	github.com/go-git/go-billy/v5 v5.3.1 // indirect
> +	github.com/imdario/mergo v0.3.12 // indirect
> +	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
> +	github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect
> +	github.com/mitchellh/go-homedir v1.1.0 // indirect

Do we really need that many new dependencies? Especially from suspicious
vendors like Microsoft and ProtonMail.

Loading /history/mycorrhiza_wiki takes ~5 seconds. Loading the same
page before the patch takes little time.

When trying to rename [[mycorrhiza wiki]] to [[home]] ran into the
following bug:

> lstat /home/bouncepaw/annoying-email/wiki.git/home/bouncepaw/annoying-email/wiki.git/mycorrhiza_wiki.myco: no such file or directory.

This is an old problem. Just stating that it persists.

Failed to rename [[mycorrhiza wiki]] to [[desho]]. It was loading for
more than 30 seconds. I observe this both with redirection hyphae left
and not.

Hypha deletion does not seem to work as well.

Failed to rename [[integration]] to [[mouko]]:

> lstat /home/bouncepaw/annoying-email/wiki.git/home/bouncepaw/annoying-email/wiki.git/integration.myco: no such file or directory.

Deleting [[integration]] failed:

> entry not found.

Seems like all mutating git operations fail after a different operation
had failed before.

Hypha editing and recent changes seem to be fine.

Overall, there are many bugs, and the implementation is critically
slow. Also, please propose the whitespace changes in a separate patch,
they are indubitably relevant.
Reply to thread Export thread (mbox)