From 43d2ad9c4f93d1109bf6660df34356a13f2fe9d4 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 3 Apr 2023 09:00:18 +0300 Subject: [PATCH 01/20] add dependencies go get github.com/go-git/go-git/v5 go get github.com/go-git/go-git/v5/plumbing go get github.com/go-git/go-git/v5/plumbing/object go get github.com/go-git/go-git/v5/plumbing/storer --- go.mod | 17 ++++++++- go.sum | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 0bf4808..72dfce5 100644 --- a/go.mod +++ b/go.mod @@ -13,14 +13,24 @@ require ( ) require ( - github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/Microsoft/go-winio v0.5.2 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 // indirect + github.com/acomagu/bufpipe v1.0.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cloudflare/circl v1.3.3 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.4.1 // indirect + github.com/go-git/go-git/v5 v5.8.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/imdario/mergo v0.3.15 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -29,9 +39,13 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.1 // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect + github.com/sergi/go-diff v1.1.0 // indirect + github.com/skeema/knownhosts v1.1.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect golang.org/x/crypto v0.11.0 // indirect golang.org/x/net v0.12.0 // indirect @@ -41,4 +55,5 @@ require ( golang.org/x/text v0.11.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index f82c294..f23202a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,13 @@ +github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 h1:ZK3C5DtzV2nVAQTx5S5jQvMeDqWtD1By5mOoyY/xJek= +github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE= +github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= +github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -20,6 +28,26 @@ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= +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/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4= +github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= +github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo= +github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk= +github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8= +github.com/go-git/go-git/v5 v5.8.0 h1:Rc543s6Tyq+YcyPwZRvU4jzZGM8rB/wWu94TnTIYALQ= +github.com/go-git/go-git/v5 v5.8.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= @@ -27,13 +55,29 @@ github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v50 v50.2.0 h1:j2FyongEHlO9nxXLc+LP3wuBSVU9mVxfpdYUexMpIfk= github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= +github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= +github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= +github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +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 v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.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/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 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/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= @@ -43,6 +87,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mmcloughlin/avo v0.5.0/go.mod h1:ChHFdoV7ql95Wi7vuq2YT1bwCJqiWdZrQ1im3VujLYM= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 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= @@ -51,6 +97,11 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= @@ -59,39 +110,89 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +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.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.1.0 h1:Wvr9V0MxhjRbl3f9nMnKnFfiWTJmtECJ9Njkea3ysW0= +github.com/skeema/knownhosts v1.1.0/go.mod h1:sKFq3RD6/TKZkSWn8boUbDC7Qkgcv+8XXijpFO6roag= +github.com/skeema/knownhosts v1.1.1 h1:MTk78x9FPgDFVFkDLTrsnnfCJl7g1C/nnKvePgrIngE= +github.com/skeema/knownhosts v1.1.1/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= +golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/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-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= @@ -99,3 +200,11 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/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.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From e476c5d700457211622a355d8e4891a7c2ad9ed3 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Sun, 2 Apr 2023 19:31:59 +0300 Subject: [PATCH 02/20] WIP: beginnings of local git repos support --- cmd/fork-cleaner/main.go | 30 +++++ local.go | 248 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 local.go diff --git a/cmd/fork-cleaner/main.go b/cmd/fork-cleaner/main.go index 21253a4..fa36f2f 100644 --- a/cmd/fork-cleaner/main.go +++ b/cmd/fork-cleaner/main.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + forkcleaner "github.com/caarlos0/fork-cleaner/v2" "github.com/caarlos0/fork-cleaner/v2/internal/ui" tea "github.com/charmbracelet/bubbletea" "github.com/google/go-github/v50/github" @@ -43,6 +44,16 @@ func main() { Usage: "GitHub username or organization name. Defaults to current user.", Aliases: []string{"u"}, }, + &cli.StringFlag{ + Name: "path", + // future options here: + // 1. support for many paths explicitly provided, each path being 1 git repo (working copy checkout) + // 2. iterate all subdirs that hold a git repository, in a given path + // 3. like 2, but allow specifying multiple paths. + // probably option 2 and later 3 is best + Usage: "FOR NOW: dir to git repo for testing", + Aliases: []string{"p"}, + }, } app.Action = func(c *cli.Context) error { @@ -69,6 +80,25 @@ func main() { return cli.Exit("missing github token", 1) } + path := c.String("path") + if path == "" { + return cli.Exit("missing path", 1) + } + + clean, err := forkcleaner.IsClean(ctx, path, client) + if err != nil { + return cli.Exit(err, 1) + } + // for now, just print the result. in the future, use the terminal menu to navigate + if !clean { + log.Println("not clean") + return cli.Exit("not clean", 1) + } + if clean { + log.Println("clean") + return cli.Exit("clean", 1) + } + p := tea.NewProgram(ui.NewAppModel(client, login), tea.WithAltScreen()) if _, err = p.Run(); err != nil { return cli.Exit(err.Error(), 1) diff --git a/local.go b/local.go new file mode 100644 index 0000000..15f3c23 --- /dev/null +++ b/local.go @@ -0,0 +1,248 @@ +package forkcleaner + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/google/go-github/v50/github" +) + +func IsClean(ctx context.Context, path string, client *github.Client) (bool, error) { + + r, err := git.PlainOpen(path) + if err != nil { + return false, err + } + + // 1) check status + //if !isLocalStatusClean(r) { + // return false, nil + //} + + // 2) check stash + //if !isLocalStashClean(path) { + // return false, nil + //} + + // 3) check branches + ok, bms, err := isLocalBranchesClean(ctx, r, client) + if err != nil { + return false, err + } + fmt.Println("isclean?") + bms.Dump() + + return ok, nil +} + +func isLocalStatusClean(r *git.Repository) bool { + w, err := r.Worktree() + if err != nil { + panic(err) + } + status, err := w.Status() + if err != nil { + panic(err) + } + fmt.Println("status clean", status.IsClean()) // WORKS + return status.IsClean() +} + +func isLocalStashClean(path string) bool { + cmd := exec.Command("git", "stash", "list") + var out bytes.Buffer + cmd.Dir = path + cmd.Stdout = &out + cmd.Stderr = os.Stderr + cmd.Run() + fmt.Println("git stashempty :", out.String() == "") // WORKS + return out.String() == "" +} + +// BranchMergeState tracks for each branch, to which remote branch it has been merged, if any. +type BranchMergeState struct { + MergedOrigin map[string]string + MergedPR map[string]*github.PullRequest + Unmerged map[string]struct{} +} + +func NewBranchMergeState() *BranchMergeState { + return &BranchMergeState{ + MergedOrigin: make(map[string]string), + MergedPR: make(map[string]*github.PullRequest), + Unmerged: make(map[string]struct{}), + } +} + +func (b *BranchMergeState) AddMerged(local, remote string, pr *github.PullRequest) { + b.MergedOrigin[local] = remote + b.MergedPR[local] = pr +} + +func (b *BranchMergeState) AddUnmerged(local string) { + b.Unmerged[local] = struct{}{} +} + +func (b *BranchMergeState) Clean() bool { + return len(b.Unmerged) == 0 +} + +func (b *BranchMergeState) Dump() { + fmt.Println("Merged:") + // get longest key + longest := 0 + for k := range b.MergedOrigin { + if len(k) > longest { + longest = len(k) + } + } + // define format string using longest length so they all align nicely + format := fmt.Sprintf("%%-%ds -> %%s\n", longest) + + for k, v := range b.MergedOrigin { + fmt.Printf(format, k, v) + fmt.Printf(" PR #%5d : %s\n", b.MergedPR[k].GetNumber(), b.MergedPR[k].GetTitle()) + fmt.Printf(" Merged %t by %s\n", b.MergedPR[k].GetMerged(), b.MergedPR[k].GetMergedBy().GetLogin()) + fmt.Printf(" Head: %s\n", b.MergedPR[k].GetHead().GetLabel()) + fmt.Printf(" Base: %s\n", b.MergedPR[k].GetBase().GetLabel()) + } + fmt.Println("Unmerged:") + for k := range b.Unmerged { + fmt.Printf(" %s\n", k) + } +} + +// does the git repository have any commits that are not pushed to the remote? +func isLocalBranchesClean(ctx context.Context, r *git.Repository, client *github.Client) (bool, *BranchMergeState, error) { + // first get the branches + branches, err := r.Branches() + if err != nil { + panic(err) + } + // all local branches, with a name like refs/heads/ or use b.Name().Short() + + bms := NewBranchMergeState() + + // then get the commits for each branch and check if the commit is in the remote + // it could be in a branch with the same name, or in a branch with a different name + err = branches.ForEach(func(b *plumbing.Reference) error { + fmt.Println("branch:", b.Name().Short(), "sha", b.Hash()) + + var remotesFound int + + // Note: we pay no mind to other remotes you may have. + // only "official" upstream/origin remotes count toward having the commit be "safe" + for _, remName := range []string{"origin", "upstream"} { + rem, err := r.Remote(remName) + if err == git.ErrRemoteNotFound { + continue + } + if err != nil { + return err + } + remotesFound++ + pr, err := isCommitInRemote(ctx, client, rem, b.Hash()) + if err != nil { + return err + } + if pr != nil { + bms.AddMerged(b.Name().Short(), remName, pr) + return nil + } + } + if remotesFound == 0 { + return fmt.Errorf("no suitable upstream/origin remote found") + } + bms.AddUnmerged(b.Name().Short()) + return nil + + // is the commit in the remote? + // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits + // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-branches-for-head-commit <- only if commit is the head of a branch + // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-pull-requests-associated-with-a-commit // merged PR or open PR. seems good + // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit <-- but does it say anything about merged or not? + // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#compare-two-commits <- maybe this works? i think it needs branch names, not commits + + //commit, err := b.Commit() + //if err != nil { + // panic(err) + //} + // get the remote for the branch + /* + remote, err := r.Remote(b.Name().String()) + if err != nil { + panic(err) + } + if remote != nil { + // get the remote url + remoteUrl := remote.Config().URLs[0] + // get the remote branch + remoteBranch := remote.Config().Fetch[0].Src + // get the remote commits + remoteCommits, err := r.Log(&git.LogOptions{From: commit.Hash, Order: git.LogOrderCommitterTime}) + if err != nil { + panic(err) + } + // check if the commit is in the remote + commitInRemote := false + remoteCommits.ForEach(func(c *object.Commit) error { + if c.Hash == commit.Hash { + commitInRemote = true + return storer.ErrStop + } + return nil + }) + fmt.Printf("branch %s, remote: %s, remote branch: %s, commit in remote: %t\n", b.Name(), remoteUrl, remoteBranch, commitInRemote) + } + return nil + */ + }) + + if err != nil { + return false, bms, fmt.Errorf("error while iterating over branches: %w", err) + } + + return bms.Clean(), bms, nil +} + +func isCommitInRemote(ctx context.Context, client *github.Client, rem *git.Remote, commit plumbing.Hash) (*github.PullRequest, error) { + remoteUrl := rem.Config().URLs[0] + + owner, name, err := extractOwnerAndNameFromRemoteUrl(remoteUrl) + if err != nil { + return nil, err + } + opts := github.PullRequestListOptions{ + State: "closed", + } + prs, _, err := client.PullRequests.ListPullRequestsWithCommit(ctx, owner, name, commit.String(), &opts) + if err != nil { + return nil, err + } + + if len(prs) > 0 { + fmt.Println("commit", commit.String(), "is in remote", rem.Config().Name) + return prs[0], nil + } + fmt.Println("commit", commit.String(), "is NOT in remote", rem.Config().Name) + return nil, nil +} + +// extractOwnerAndNameFromRemoteUrl extracts the owner and name from a remote URL +// the remoteURL is in the form of either git@github.com:/.git or https://github.com//.git +func extractOwnerAndNameFromRemoteUrl(remoteUrl string) (string, string, error) { + str := strings.TrimSuffix(remoteUrl, ".git") + str = strings.TrimPrefix(str, "git@github.com:") + str = strings.TrimPrefix(str, "https://github.com/") + split := strings.Split(str, "/") + if len(split) != 2 { + return "", "", fmt.Errorf("invalid remote url: %s", remoteUrl) + } + return split[0], split[1], nil +} From 8e13183cf109cf7e9c0e0f3140ac9a48fb67bd1a Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 24 Jul 2023 08:46:11 +0200 Subject: [PATCH 03/20] test script --- testscript.sh | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100755 testscript.sh diff --git a/testscript.sh b/testscript.sh new file mode 100755 index 0000000..648b46e --- /dev/null +++ b/testscript.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +dir=$(mktemp -d) +cd $dir +pwd + +function mk () { + echo "making $1" + git clone -q git@github.com:caarlos0/fork-cleaner.git $1 +} + +mk repo-clean + +mk repo-new-file +echo dirty-file > repo-new-file/dirty-file + +# note: we can consider this as clean, if there's no other changes +mk repo-new-dir +mkdir repo-new-dir/dirty-dir + +mk repo-new-file-in-new-dir +mkdir repo-new-file-in-new-dir/dirty-dir +echo dirty-file > repo-new-file-in-new-dir/dirty-dir/dirty-file + + +mk repo-stash +cd repo-stash +echo "some change" >> README.md +git stash +cd .. + +mk repo-dirty-index +cd repo-dirty-index +echo "some change" >> README.md +git add README.md +cd .. + +mk repo-commit-to-main +cd repo-commit-to-main +echo "some change" >> README.md +git commit README.md -m 'extra line' +cd .. + +# TODO more test cases: +# have a local branch that: +# does/does not exist in any remote (we don't have to differentiate between upstream and our git fork, because fork-cleaner in classic 'github mode' will figure out whether our fork contains anything special or not) +# has a commit ahead of main or the remote branch +# has been merged as a squash commit (these cases are probably to hard to figure out with a script and we may need to leave these marked as 'dirty' and manually look into them) From a3fec6d917ab820b8d3ad4c738ebf5052e32c4bd Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Sun, 13 Oct 2024 23:08:00 +0300 Subject: [PATCH 04/20] fix nilpointer when ratelimits is nil due to error --- internal/ui/commands.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/ui/commands.go b/internal/ui/commands.go index 15bea28..b4b5458 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -35,7 +35,12 @@ func enqueueGetReposCmd() tea.Msg { } func getReposCmd(client *github.Client, login string, skipUpstream bool) tea.Cmd { - limits, _, _ := client.RateLimits(context.Background()) + limits, _, err := client.RateLimits(context.Background()) + if err != nil { + return func() tea.Msg { + return errMsg{err} + } + } log.Println("RateLimits: ", limits) if limits.Core.Remaining < 1 { return func() tea.Msg { From 8ad25501903704f2993381c2562ded2f7bb56d3e Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 14 Oct 2024 10:56:23 +0300 Subject: [PATCH 05/20] more test cases --- testscript.sh | 68 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/testscript.sh b/testscript.sh index 648b46e..67f61c6 100755 --- a/testscript.sh +++ b/testscript.sh @@ -11,20 +11,20 @@ function mk () { mk repo-clean -mk repo-new-file -echo dirty-file > repo-new-file/dirty-file +mk repo-dirty-new-file +echo dirty-file > repo-dirty-new-file/dirty-file # note: we can consider this as clean, if there's no other changes -mk repo-new-dir -mkdir repo-new-dir/dirty-dir +mk repo-clean-new-dir +mkdir repo-clean-new-dir/dirty-dir -mk repo-new-file-in-new-dir -mkdir repo-new-file-in-new-dir/dirty-dir -echo dirty-file > repo-new-file-in-new-dir/dirty-dir/dirty-file +mk repo-dirty-new-file-in-new-dir +mkdir repo-dirty-new-file-in-new-dir/dirty-dir +echo dirty-file > repo-dirty-new-file-in-new-dir/dirty-dir/dirty-file -mk repo-stash -cd repo-stash +mk repo-dirty-stash +cd repo-dirty-stash echo "some change" >> README.md git stash cd .. @@ -35,14 +35,56 @@ echo "some change" >> README.md git add README.md cd .. -mk repo-commit-to-main -cd repo-commit-to-main +mk repo-dirty-commit-to-main +cd repo-dirty-commit-to-main echo "some change" >> README.md git commit README.md -m 'extra line' cd .. -# TODO more test cases: +mk repo-dirty-removed-file-in-main +cd repo-dirty-removed-file-in-main +rm README.md +cd .. + +mk repo-dirty-commit-to-existing-branch +cd repo-dirty-commit-to-existing-branch +git checkout list # some pre-existing branch from github. this may stop working in the future +echo "some change" >> README.md +git commit README.md -m 'extra line' +cd .. + +# same, but a bit more hidden as we check main out again +mk repo-dirty-commit-to-existing-branch-back-to-main +cd repo-dirty-commit-to-existing-branch-back-to-main +git checkout list # some pre-existing branch from github. this may stop working in the future +echo "some change" >> README.md +git commit README.md -m 'extra line' +git checkout main +cd .. + + + +mk repo-clean-other-branch +cd repo-clean-other-branch +git checkout -b some-other-branch +cd .. + +mk repo-dirty-commit-to-new-branch +cd repo-dirty-commit-to-new-branch +git checkout -b some-other-branch +echo "some change" >> README.md +git commit README.md -m 'extra line' +cd .. + +mk repo-dirty-commit-to-new-branch-back-to-main +cd repo-dirty-commit-to-new-branch-back-to-main +git checkout -b some-other-branch +echo "some change" >> README.md +git commit README.md -m 'extra line' +git checkout main +cd .. + +# special cases we don't need to do anything special for: # have a local branch that: # does/does not exist in any remote (we don't have to differentiate between upstream and our git fork, because fork-cleaner in classic 'github mode' will figure out whether our fork contains anything special or not) -# has a commit ahead of main or the remote branch # has been merged as a squash commit (these cases are probably to hard to figure out with a script and we may need to leave these marked as 'dirty' and manually look into them) From 1b506901c408ac64de6eb78b12a73658850d1692 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 14 Oct 2024 16:07:32 +0300 Subject: [PATCH 06/20] cleanup --- README.md | 20 ++++++++++++ cmd/fork-cleaner/main.go | 4 +-- local.go | 67 +++++++++------------------------------- 3 files changed, 35 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index bdbb009..33c3a83 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,26 @@ Download the binaries from the [latest release](https://github.com/caarlos0/fork You'll need to [create a personal access token](https://github.com/settings/tokens/new?scopes=repo,delete_repo&description=fork-cleaner) with `repo` and `delete_repo` permissions. You'll need to pass this token to `fork-cleaner` with the `--token` flag. +### Local mode + +This is a recently added mode, which scans git repositories that you have checked out (cloned) locally. +It marks each repository, as either "clean" (safe to delete), or "dirty" (not safe to delete). + +For a repository to be marked clean, it needs to meet all of the following conditions: + +* the are no uncommitted changes. +* all branches are found in a remote named "upstream" or "origin". +* there is nothing in the stash. + +Note: + +* if your local branches are found in remote that goes by another name, the local repository is still marked "dirty". This could perhaps + be considered as "clean" in the future. (with an optional flag) + +### Remote mode + +This is the original fork-cleaner mode. + ```sh fork-cleaner --token "" ``` diff --git a/cmd/fork-cleaner/main.go b/cmd/fork-cleaner/main.go index 1ab142e..a92ee91 100644 --- a/cmd/fork-cleaner/main.go +++ b/cmd/fork-cleaner/main.go @@ -45,7 +45,7 @@ func main() { Aliases: []string{"u"}, }, &cli.StringFlag{ - Name: "path", + Name: "path", // future options here: // 1. support for many paths explicitly provided, each path being 1 git repo (working copy checkout) // 2. iterate all subdirs that hold a git repository, in a given path @@ -98,11 +98,9 @@ func main() { } // for now, just print the result. in the future, use the terminal menu to navigate if !clean { - log.Println("not clean") return cli.Exit("not clean", 1) } if clean { - log.Println("clean") return cli.Exit("clean", 1) } diff --git a/local.go b/local.go index 15f3c23..0d20a7d 100644 --- a/local.go +++ b/local.go @@ -21,21 +21,20 @@ func IsClean(ctx context.Context, path string, client *github.Client) (bool, err } // 1) check status - //if !isLocalStatusClean(r) { - // return false, nil - //} + if !isLocalStatusClean(r) { + return false, nil + } // 2) check stash - //if !isLocalStashClean(path) { - // return false, nil - //} + if !isLocalStashClean(path) { + return false, nil + } // 3) check branches ok, bms, err := isLocalBranchesClean(ctx, r, client) if err != nil { return false, err } - fmt.Println("isclean?") bms.Dump() return ok, nil @@ -55,13 +54,15 @@ func isLocalStatusClean(r *git.Repository) bool { } func isLocalStashClean(path string) bool { + // stash is not supported yet in the go library, so we run the git command + // https://github.com/go-git/go-git/issues/606 + cmd := exec.Command("git", "stash", "list") var out bytes.Buffer cmd.Dir = path cmd.Stdout = &out cmd.Stderr = os.Stderr cmd.Run() - fmt.Println("git stashempty :", out.String() == "") // WORKS return out.String() == "" } @@ -120,12 +121,11 @@ func (b *BranchMergeState) Dump() { // does the git repository have any commits that are not pushed to the remote? func isLocalBranchesClean(ctx context.Context, r *git.Repository, client *github.Client) (bool, *BranchMergeState, error) { - // first get the branches + // first get the local branches. they have a name like refs/heads/ or use b.Name().Short() branches, err := r.Branches() if err != nil { panic(err) } - // all local branches, with a name like refs/heads/ or use b.Name().Short() bms := NewBranchMergeState() @@ -136,8 +136,6 @@ func isLocalBranchesClean(ctx context.Context, r *git.Repository, client *github var remotesFound int - // Note: we pay no mind to other remotes you may have. - // only "official" upstream/origin remotes count toward having the commit be "safe" for _, remName := range []string{"origin", "upstream"} { rem, err := r.Remote(remName) if err == git.ErrRemoteNotFound { @@ -161,47 +159,6 @@ func isLocalBranchesClean(ctx context.Context, r *git.Repository, client *github } bms.AddUnmerged(b.Name().Short()) return nil - - // is the commit in the remote? - // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits - // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-branches-for-head-commit <- only if commit is the head of a branch - // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-pull-requests-associated-with-a-commit // merged PR or open PR. seems good - // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit <-- but does it say anything about merged or not? - // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#compare-two-commits <- maybe this works? i think it needs branch names, not commits - - //commit, err := b.Commit() - //if err != nil { - // panic(err) - //} - // get the remote for the branch - /* - remote, err := r.Remote(b.Name().String()) - if err != nil { - panic(err) - } - if remote != nil { - // get the remote url - remoteUrl := remote.Config().URLs[0] - // get the remote branch - remoteBranch := remote.Config().Fetch[0].Src - // get the remote commits - remoteCommits, err := r.Log(&git.LogOptions{From: commit.Hash, Order: git.LogOrderCommitterTime}) - if err != nil { - panic(err) - } - // check if the commit is in the remote - commitInRemote := false - remoteCommits.ForEach(func(c *object.Commit) error { - if c.Hash == commit.Hash { - commitInRemote = true - return storer.ErrStop - } - return nil - }) - fmt.Printf("branch %s, remote: %s, remote branch: %s, commit in remote: %t\n", b.Name(), remoteUrl, remoteBranch, commitInRemote) - } - return nil - */ }) if err != nil { @@ -223,6 +180,10 @@ func isCommitInRemote(ctx context.Context, client *github.Client, rem *git.Remot } prs, _, err := client.PullRequests.ListPullRequestsWithCommit(ctx, owner, name, commit.String(), &opts) if err != nil { + if strings.Contains(err.Error(), "No commit found for SHA") { + fmt.Println("commit", commit.String(), "is NOT in remote", rem.Config().Name) + return nil, nil + } return nil, err } From 5a73e0e0c73cca3555e4ddfcd10b38b766206a78 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 14 Oct 2024 18:16:43 +0300 Subject: [PATCH 07/20] cleanly integrate the new 'remote' mode into the bubbletea UI --- README.md | 2 +- cmd/fork-cleaner/main.go | 38 ++++------- internal/ui/app.go | 2 +- internal/ui/commands.go | 71 +++++++++++++++++++ internal/ui/item.go | 7 -- internal/ui/local_app.go | 140 ++++++++++++++++++++++++++++++++++++++ internal/ui/local_item.go | 67 ++++++++++++++++++ internal/ui/msgs.go | 7 ++ internal/ui/util.go | 8 +++ local.go | 132 ++++++++++++++--------------------- testscript.sh | 15 ++++ 11 files changed, 377 insertions(+), 112 deletions(-) create mode 100644 internal/ui/local_app.go create mode 100644 internal/ui/local_item.go create mode 100644 internal/ui/util.go diff --git a/README.md b/README.md index 33c3a83..c6ea505 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ permissions. You'll need to pass this token to `fork-cleaner` with the `--token` ### Local mode -This is a recently added mode, which scans git repositories that you have checked out (cloned) locally. +This is a newly added mode, which scans one or more git repositories that you have checked out (cloned) locally. It marks each repository, as either "clean" (safe to delete), or "dirty" (not safe to delete). For a repository to be marked clean, it needs to meet all of the following conditions: diff --git a/cmd/fork-cleaner/main.go b/cmd/fork-cleaner/main.go index a92ee91..e62df72 100644 --- a/cmd/fork-cleaner/main.go +++ b/cmd/fork-cleaner/main.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" - forkcleaner "github.com/caarlos0/fork-cleaner/v2" "github.com/caarlos0/fork-cleaner/v2/internal/ui" tea "github.com/charmbracelet/bubbletea" "github.com/google/go-github/v50/github" @@ -45,13 +44,8 @@ func main() { Aliases: []string{"u"}, }, &cli.StringFlag{ - Name: "path", - // future options here: - // 1. support for many paths explicitly provided, each path being 1 git repo (working copy checkout) - // 2. iterate all subdirs that hold a git repository, in a given path - // 3. like 2, but allow specifying multiple paths. - // probably option 2 and later 3 is best - Usage: "FOR NOW: dir to git repo for testing", + Name: "path", + Usage: "directory that is a git repo, or contains git repos, all of which will be scanned", Aliases: []string{"p"}, }, &cli.BoolFlag{ @@ -88,22 +82,20 @@ func main() { } path := c.String("path") - if path == "" { - return cli.Exit("missing path", 1) + if path != "" { + fi, err := os.Stat(path) + if err != nil { + return cli.Exit(err.Error(), 1) + } + if !fi.IsDir() { + return cli.Exit("path must be a directory", 1) + } + p := tea.NewProgram(ui.NewLocalAppModel(client, login, path), tea.WithAltScreen()) + if _, err = p.Run(); err != nil { + return cli.Exit(err.Error(), 1) + } + return nil } - - clean, err := forkcleaner.IsClean(ctx, path, client) - if err != nil { - return cli.Exit(err, 1) - } - // for now, just print the result. in the future, use the terminal menu to navigate - if !clean { - return cli.Exit("not clean", 1) - } - if clean { - return cli.Exit("clean", 1) - } - p := tea.NewProgram(ui.NewAppModel(client, login, skipUpstream), tea.WithAltScreen()) if _, err = p.Run(); err != nil { return cli.Exit(err.Error(), 1) diff --git a/internal/ui/app.go b/internal/ui/app.go index 65d5906..de04ee5 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -22,7 +22,7 @@ type AppModel struct { // NewAppModel creates a new AppModel with required fields. func NewAppModel(client *github.Client, login string, skipUpstream bool) AppModel { list := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) - list.Title = "Fork Cleaner" + list.Title = "Fork Cleaner - remote mode" list.SetSpinner(spinner.MiniDot) list.AdditionalShortHelpKeys = func() []key.Binding { return []key.Binding{ diff --git a/internal/ui/commands.go b/internal/ui/commands.go index b4b5458..488b499 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -2,8 +2,12 @@ package ui import ( "context" + "errors" "fmt" + "io/fs" "log" + "os" + "path/filepath" "strings" "time" @@ -16,6 +20,10 @@ func requestDeleteReposCmd() tea.Msg { return requestDeleteSelectedReposMsg{} } +func requestDeleteLocalReposCmd() tea.Msg { + return requestDeleteSelectedLocalReposMsg{} +} + func deleteReposCmd(client *github.Client, repos []*forkcleaner.RepositoryWithDetails) tea.Cmd { return func() tea.Msg { var names []string @@ -30,6 +38,19 @@ func deleteReposCmd(client *github.Client, repos []*forkcleaner.RepositoryWithDe } } +func deleteLocalReposCmd(repos []*forkcleaner.LocalRepoState) tea.Cmd { + return func() tea.Msg { + for _, r := range repos { + log.Println("deleteLocalReposCmd", r.Path) + // if err := os.RemoveAll(r.Path); err != nil { + // return errMsg{err} + //} + } + return errMsg{ + errors.New("TODO delete ")} + } +} + func enqueueGetReposCmd() tea.Msg { return getRepoListMsg{} } @@ -60,3 +81,53 @@ func getReposCmd(client *github.Client, login string, skipUpstream bool) tea.Cmd return gotRepoListMsg{repos} } } + +func enqueueGetLocalReposCmd() tea.Msg { + return getLocalRepoListMsg{} +} + +func getLocalReposCmd(client *github.Client, path string) tea.Cmd { + return func() tea.Msg { + + // path should already have been validated to be a directory + // if path has a .git directory in it, scan it + // otherwise, find all directories inside of it that have a .git directory in them and scan them. + + _, err := os.Stat(filepath.Join(path, ".git")) + + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return errMsg{err} + } + if err == nil { + lr, err := forkcleaner.NewLocalRepoState(path, client, context.Background()) + if err != nil { + return errMsg{err} + } + return gotLocalRepoListMsg{[]*forkcleaner.LocalRepoState{lr}} + + } + // we had an error but it was ErrNotExist for .git, so we assume it's a directory that contains code repos (checkouts) + + var repos []*forkcleaner.LocalRepoState + + entries, err := os.ReadDir(path) + if err != nil { + return errMsg{err} + } + + for _, entry := range entries { + if entry.IsDir() { + gitpath := filepath.Join(path, entry.Name(), ".git") + if _, err := os.Stat(gitpath); err == nil { + lr, err := forkcleaner.NewLocalRepoState(filepath.Join(path, entry.Name()), client, context.Background()) + if err != nil { + return errMsg{err} + } + repos = append(repos, lr) + } + } + } + + return gotLocalRepoListMsg{repos} + } +} diff --git a/internal/ui/item.go b/internal/ui/item.go index 5057882..628844d 100644 --- a/internal/ui/item.go +++ b/internal/ui/item.go @@ -57,13 +57,6 @@ func (i item) Description() string { return detailsStyle.Render(strings.Join(details, separator)) } -func maybePlural(n int) string { - if n == 1 { - return "" - } - return "s" -} - func (i item) FilterValue() string { return " " + i.repo.Name } func splitBySelection(items []list.Item) ([]*forkcleaner.RepositoryWithDetails, []*forkcleaner.RepositoryWithDetails) { diff --git a/internal/ui/local_app.go b/internal/ui/local_app.go new file mode 100644 index 0000000..bd4cb3b --- /dev/null +++ b/internal/ui/local_app.go @@ -0,0 +1,140 @@ +package ui + +import ( + "log" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/google/go-github/v50/github" +) + +// AppModel is the UI when the CLI starts, basically loading the repos. +type LocalAppModel struct { + err error + login string + client *github.Client + path string + list list.Model +} + +// NewAppModel creates a new AppModel with required fields. +func NewLocalAppModel(client *github.Client, login, path string) LocalAppModel { + list := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + list.Title = "Fork Cleaner - local mode" + list.SetSpinner(spinner.MiniDot) + list.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + keySelectToggle, + keyDeletedSelected, + } + } + list.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + keySelectAll, + keySelectNone, + } + } + + return LocalAppModel{ + client: client, + login: login, + path: path, + list: list, + } +} + +func (m LocalAppModel) Init() tea.Cmd { + return tea.Batch(enqueueGetLocalReposCmd, m.list.StartSpinner()) +} + +func (m LocalAppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + log.Println("tea.WindowSizeMsg") + top, right, bottom, left := listStyle.GetMargin() + m.list.SetSize(msg.Width-left-right, msg.Height-top-bottom) + case errMsg: + log.Println("errMsg") + m.err = msg.error + case getLocalRepoListMsg: + log.Println("getLocalRepoListMsg") + cmds = append(cmds, m.list.StartSpinner(), getLocalReposCmd(m.client, m.path)) + case gotLocalRepoListMsg: + log.Println("gotLocalRepoListMsg") + m.list.StopSpinner() + cmds = append(cmds, m.list.SetItems(localReposToItems(msg.repos))) + case localReposDeletedMsg: + log.Println("localReposDeletedMsg") + cmds = append(cmds, m.list.StartSpinner(), enqueueGetLocalReposCmd) + case requestDeleteSelectedLocalReposMsg: + log.Println("requestDeleteSelectedLocalReposMsg") + selected, unselected := splitLocalBySelection(m.list.Items()) + cmds = append( + cmds, + m.list.SetItems(localReposToItems(unselected)), + deleteLocalReposCmd(selected), + ) + + case tea.KeyMsg: + if m.list.SettingFilter() { + break + } + + if key.Matches(msg, keySelectAll) { + log.Println("tea.KeyMsg -> selectAll") + cmds = append(cmds, m.changeSelect(true)...) + } + + if key.Matches(msg, keySelectNone) { + log.Println("tea.KeyMsg -> selectNone") + cmds = append(cmds, m.changeSelect(false)...) + } + + if key.Matches(msg, keySelectToggle) { + log.Println("tea.KeyMsg -> selectToggle") + cmds = append(cmds, m.toggleSelection()) + } + + if key.Matches(msg, keyDeletedSelected) { + log.Println("tea.KeyMsg -> deleteSelected") + cmds = append(cmds, m.list.StartSpinner(), requestDeleteLocalReposCmd) + } + } + + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m LocalAppModel) View() string { + if m.err != nil { + return errorStyle.Bold(true).Render("Error gathering the repository list") + + "\n" + + errorStyle.Render(m.err.Error()) + } + return m.list.View() +} + +func (m LocalAppModel) toggleSelection() tea.Cmd { + idx := m.list.Index() + item := m.list.SelectedItem().(localItem) + item.selected = !item.selected + m.list.RemoveItem(idx) + return m.list.InsertItem(idx, item) +} + +func (m LocalAppModel) changeSelect(selected bool) []tea.Cmd { + var cmds []tea.Cmd + for idx, i := range m.list.Items() { + item := i.(localItem) + item.selected = selected + m.list.RemoveItem(idx) + cmds = append(cmds, m.list.InsertItem(idx, item)) + } + return cmds +} diff --git a/internal/ui/local_item.go b/internal/ui/local_item.go new file mode 100644 index 0000000..8ea8293 --- /dev/null +++ b/internal/ui/local_item.go @@ -0,0 +1,67 @@ +package ui + +import ( + "fmt" + "strings" + + forkcleaner "github.com/caarlos0/fork-cleaner/v2" + "github.com/charmbracelet/bubbles/list" +) + +type localItem struct { + repo *forkcleaner.LocalRepoState + selected bool +} + +func (i localItem) Title() string { + clean := " (DIRTY)" + if i.repo.Clean() { + clean = " (clean)" + } + if i.selected { + return iconSelected + " " + i.repo.Path + clean + } + return iconNotSelected + " " + i.repo.Path + clean +} + +func (i localItem) Description() string { + var details []string + if i.repo.StatusClean { + details = append(details, "git status: clean") + } else { + details = append(details, "git status: dirty") + } + if i.repo.StashClean { + details = append(details, "git stash: clean") + } else { + details = append(details, "git stash: dirty") + } + details = append(details, fmt.Sprintf("%d unmerged branches", len(i.repo.Unmerged))) + + return detailsStyle.Render(strings.Join(details, separator)) +} + +func (i localItem) FilterValue() string { return " " + i.repo.Path } + +func splitLocalBySelection(localItems []list.Item) ([]*forkcleaner.LocalRepoState, []*forkcleaner.LocalRepoState) { + var selected, unselected []*forkcleaner.LocalRepoState + for _, it := range localItems { + localItem := it.(localItem) + if localItem.selected { + selected = append(selected, localItem.repo) + } else { + unselected = append(unselected, localItem.repo) + } + } + return selected, unselected +} + +func localReposToItems(repos []*forkcleaner.LocalRepoState) []list.Item { + var localItems = make([]list.Item, 0, len(repos)) + for _, repo := range repos { + localItems = append(localItems, localItem{ + repo: repo, + }) + } + return localItems +} diff --git a/internal/ui/msgs.go b/internal/ui/msgs.go index 9d0aaed..73ce21c 100644 --- a/internal/ui/msgs.go +++ b/internal/ui/msgs.go @@ -7,11 +7,18 @@ type errMsg struct{ error } func (e errMsg) Error() string { return e.error.Error() } type getRepoListMsg struct{} +type getLocalRepoListMsg struct{} type gotRepoListMsg struct { repos []*forkcleaner.RepositoryWithDetails } +type gotLocalRepoListMsg struct { + repos []*forkcleaner.LocalRepoState +} + type reposDeletedMsg struct{} +type localReposDeletedMsg struct{} type requestDeleteSelectedReposMsg struct{} +type requestDeleteSelectedLocalReposMsg struct{} diff --git a/internal/ui/util.go b/internal/ui/util.go new file mode 100644 index 0000000..85c36e9 --- /dev/null +++ b/internal/ui/util.go @@ -0,0 +1,8 @@ +package ui + +func maybePlural(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/local.go b/local.go index 0d20a7d..90a6197 100644 --- a/local.go +++ b/local.go @@ -13,131 +13,106 @@ import ( "github.com/google/go-github/v50/github" ) -func IsClean(ctx context.Context, path string, client *github.Client) (bool, error) { +// LocalRepoState tracks the git status cleanliness, git stash cleanliness, and +// for each branch, to which remote branch it has been merged, if any. +type LocalRepoState struct { + Path string + repo *git.Repository + StatusClean bool + StashClean bool + MergedOrigin map[string]string + MergedPR map[string]*github.PullRequest + Unmerged map[string]struct{} +} + +func NewLocalRepoState(path string, client *github.Client, ctx context.Context) (*LocalRepoState, error) { + lr := LocalRepoState{ + Path: path, + MergedOrigin: make(map[string]string), + MergedPR: make(map[string]*github.PullRequest), + Unmerged: make(map[string]struct{}), + } - r, err := git.PlainOpen(path) + var err error + lr.repo, err = git.PlainOpen(path) if err != nil { - return false, err + return nil, err } // 1) check status - if !isLocalStatusClean(r) { - return false, nil + if err := lr.checkLocalStatus(); err != nil { + return nil, err } // 2) check stash - if !isLocalStashClean(path) { - return false, nil + if err := lr.checkLocalStash(); err != nil { + return nil, err } // 3) check branches - ok, bms, err := isLocalBranchesClean(ctx, r, client) - if err != nil { - return false, err + if err := lr.checkLocalBranches(client, ctx); err != nil { + return nil, err } - bms.Dump() - return ok, nil + return &lr, nil + } -func isLocalStatusClean(r *git.Repository) bool { - w, err := r.Worktree() +func (lr *LocalRepoState) checkLocalStatus() error { + w, err := lr.repo.Worktree() if err != nil { - panic(err) + return err } status, err := w.Status() if err != nil { - panic(err) + return err } - fmt.Println("status clean", status.IsClean()) // WORKS - return status.IsClean() + lr.StatusClean = status.IsClean() + return nil } -func isLocalStashClean(path string) bool { +func (lr *LocalRepoState) checkLocalStash() error { // stash is not supported yet in the go library, so we run the git command // https://github.com/go-git/go-git/issues/606 cmd := exec.Command("git", "stash", "list") var out bytes.Buffer - cmd.Dir = path + cmd.Dir = lr.Path cmd.Stdout = &out cmd.Stderr = os.Stderr cmd.Run() - return out.String() == "" -} - -// BranchMergeState tracks for each branch, to which remote branch it has been merged, if any. -type BranchMergeState struct { - MergedOrigin map[string]string - MergedPR map[string]*github.PullRequest - Unmerged map[string]struct{} -} - -func NewBranchMergeState() *BranchMergeState { - return &BranchMergeState{ - MergedOrigin: make(map[string]string), - MergedPR: make(map[string]*github.PullRequest), - Unmerged: make(map[string]struct{}), - } + lr.StashClean = out.String() == "" + return nil } -func (b *BranchMergeState) AddMerged(local, remote string, pr *github.PullRequest) { +func (b *LocalRepoState) AddMerged(local, remote string, pr *github.PullRequest) { b.MergedOrigin[local] = remote b.MergedPR[local] = pr } -func (b *BranchMergeState) AddUnmerged(local string) { +func (b *LocalRepoState) AddUnmerged(local string) { b.Unmerged[local] = struct{}{} } -func (b *BranchMergeState) Clean() bool { - return len(b.Unmerged) == 0 -} - -func (b *BranchMergeState) Dump() { - fmt.Println("Merged:") - // get longest key - longest := 0 - for k := range b.MergedOrigin { - if len(k) > longest { - longest = len(k) - } - } - // define format string using longest length so they all align nicely - format := fmt.Sprintf("%%-%ds -> %%s\n", longest) - - for k, v := range b.MergedOrigin { - fmt.Printf(format, k, v) - fmt.Printf(" PR #%5d : %s\n", b.MergedPR[k].GetNumber(), b.MergedPR[k].GetTitle()) - fmt.Printf(" Merged %t by %s\n", b.MergedPR[k].GetMerged(), b.MergedPR[k].GetMergedBy().GetLogin()) - fmt.Printf(" Head: %s\n", b.MergedPR[k].GetHead().GetLabel()) - fmt.Printf(" Base: %s\n", b.MergedPR[k].GetBase().GetLabel()) - } - fmt.Println("Unmerged:") - for k := range b.Unmerged { - fmt.Printf(" %s\n", k) - } +func (b *LocalRepoState) Clean() bool { + return len(b.Unmerged) == 0 && b.StatusClean && b.StashClean } // does the git repository have any commits that are not pushed to the remote? -func isLocalBranchesClean(ctx context.Context, r *git.Repository, client *github.Client) (bool, *BranchMergeState, error) { +func (lr *LocalRepoState) checkLocalBranches(client *github.Client, ctx context.Context) error { // first get the local branches. they have a name like refs/heads/ or use b.Name().Short() - branches, err := r.Branches() + branches, err := lr.repo.Branches() if err != nil { - panic(err) + return err } - bms := NewBranchMergeState() - // then get the commits for each branch and check if the commit is in the remote // it could be in a branch with the same name, or in a branch with a different name err = branches.ForEach(func(b *plumbing.Reference) error { - fmt.Println("branch:", b.Name().Short(), "sha", b.Hash()) - var remotesFound int for _, remName := range []string{"origin", "upstream"} { - rem, err := r.Remote(remName) + rem, err := lr.repo.Remote(remName) if err == git.ErrRemoteNotFound { continue } @@ -150,22 +125,22 @@ func isLocalBranchesClean(ctx context.Context, r *git.Repository, client *github return err } if pr != nil { - bms.AddMerged(b.Name().Short(), remName, pr) + lr.AddMerged(b.Name().Short(), remName, pr) return nil } } if remotesFound == 0 { return fmt.Errorf("no suitable upstream/origin remote found") } - bms.AddUnmerged(b.Name().Short()) + lr.AddUnmerged(b.Name().Short()) return nil }) if err != nil { - return false, bms, fmt.Errorf("error while iterating over branches: %w", err) + return fmt.Errorf("error while iterating over branches: %w", err) } - return bms.Clean(), bms, nil + return nil } func isCommitInRemote(ctx context.Context, client *github.Client, rem *git.Remote, commit plumbing.Hash) (*github.PullRequest, error) { @@ -181,17 +156,14 @@ func isCommitInRemote(ctx context.Context, client *github.Client, rem *git.Remot prs, _, err := client.PullRequests.ListPullRequestsWithCommit(ctx, owner, name, commit.String(), &opts) if err != nil { if strings.Contains(err.Error(), "No commit found for SHA") { - fmt.Println("commit", commit.String(), "is NOT in remote", rem.Config().Name) return nil, nil } return nil, err } if len(prs) > 0 { - fmt.Println("commit", commit.String(), "is in remote", rem.Config().Name) return prs[0], nil } - fmt.Println("commit", commit.String(), "is NOT in remote", rem.Config().Name) return nil, nil } diff --git a/testscript.sh b/testscript.sh index 67f61c6..f5742ed 100755 --- a/testscript.sh +++ b/testscript.sh @@ -84,6 +84,21 @@ git commit README.md -m 'extra line' git checkout main cd .. + +## note, it's irrelevant to do this. any checked out commit is always part of any of the branches we're checking +# mk repo-clean-old-checkout +# cd repo-clean-old-checkout +# git checkout 1891e364bedf834fdafeac95c678a2bd725f5e62 # a commit on main +# cd .. + +# the code is not merged but it's available online, so we can delete our local copy +# for now, doesn't work yet, so just be safe and mark it dirty +# mk repo-clean-unmerged-pr +# cd repo-clean-unmerged-pr +# git checkout 8e13183 # <-- this doesn't work yet. a branch from unmerged PR https://github.com/caarlos0/fork-cleaner/pull/154 +# cd .. + + # special cases we don't need to do anything special for: # have a local branch that: # does/does not exist in any remote (we don't have to differentiate between upstream and our git fork, because fork-cleaner in classic 'github mode' will figure out whether our fork contains anything special or not) From b1f4db037d3cc73f48fe348e4f6cd6c1ab25e41e Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 14 Oct 2024 19:42:14 +0300 Subject: [PATCH 08/20] implement deleting --- internal/ui/commands.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/ui/commands.go b/internal/ui/commands.go index 488b499..4c73894 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -41,13 +41,12 @@ func deleteReposCmd(client *github.Client, repos []*forkcleaner.RepositoryWithDe func deleteLocalReposCmd(repos []*forkcleaner.LocalRepoState) tea.Cmd { return func() tea.Msg { for _, r := range repos { - log.Println("deleteLocalReposCmd", r.Path) - // if err := os.RemoveAll(r.Path); err != nil { - // return errMsg{err} - //} + log.Println("deleteLocalReposCmd: DELETING", r.Path) + if err := os.RemoveAll(r.Path); err != nil { + return errMsg{err} + } } - return errMsg{ - errors.New("TODO delete ")} + return localReposDeletedMsg{} } } From 52a7105823edcacf279f4b42854c8c5158ee4f74 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 14 Oct 2024 21:26:40 +0300 Subject: [PATCH 09/20] treat broken/missing remotes gracefully --- local.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/local.go b/local.go index 90a6197..62dadfb 100644 --- a/local.go +++ b/local.go @@ -122,6 +122,15 @@ func (lr *LocalRepoState) checkLocalBranches(client *github.Client, ctx context. remotesFound++ pr, err := isCommitInRemote(ctx, client, rem, b.Hash()) if err != nil { + // if it's http 404, just continue + if strings.Contains(err.Error(), "404 Not Found") { + continue + } + // can't use an invalid URL.. + if strings.Contains(err.Error(), "invalid remote url:") { + continue + } + return err } if pr != nil { @@ -129,9 +138,6 @@ func (lr *LocalRepoState) checkLocalBranches(client *github.Client, ctx context. return nil } } - if remotesFound == 0 { - return fmt.Errorf("no suitable upstream/origin remote found") - } lr.AddUnmerged(b.Name().Short()) return nil }) From b6936141c28a6fc83703f335c5daac4b8161ed53 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 14 Oct 2024 21:47:46 +0300 Subject: [PATCH 10/20] better --- internal/ui/local_item.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/ui/local_item.go b/internal/ui/local_item.go index 8ea8293..385de8a 100644 --- a/internal/ui/local_item.go +++ b/internal/ui/local_item.go @@ -32,16 +32,22 @@ func (i localItem) Description() string { details = append(details, "git status: dirty") } if i.repo.StashClean { - details = append(details, "git stash: clean") + details = append(details, "git stash: clean") } else { - details = append(details, "git stash: dirty") + details = append(details, "git stash: dirty") } details = append(details, fmt.Sprintf("%d unmerged branches", len(i.repo.Unmerged))) return detailsStyle.Render(strings.Join(details, separator)) } -func (i localItem) FilterValue() string { return " " + i.repo.Path } +func (i localItem) FilterValue() string { + clean := "dirty" + if i.repo.Clean() { + clean = "clean" + } + return " " + i.repo.Path + " " + clean +} func splitLocalBySelection(localItems []list.Item) ([]*forkcleaner.LocalRepoState, []*forkcleaner.LocalRepoState) { var selected, unselected []*forkcleaner.LocalRepoState From 9840a6be5f47c6155598fc6cd0d6269e535fc6d5 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 14 Oct 2024 22:09:50 +0300 Subject: [PATCH 11/20] support direct merges (without PR) --- local.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/local.go b/local.go index 62dadfb..14a9862 100644 --- a/local.go +++ b/local.go @@ -120,7 +120,7 @@ func (lr *LocalRepoState) checkLocalBranches(client *github.Client, ctx context. return err } remotesFound++ - pr, err := isCommitInRemote(ctx, client, rem, b.Hash()) + found, pr, err := isCommitInRemote(ctx, client, rem, b.Hash()) if err != nil { // if it's http 404, just continue if strings.Contains(err.Error(), "404 Not Found") { @@ -133,7 +133,7 @@ func (lr *LocalRepoState) checkLocalBranches(client *github.Client, ctx context. return err } - if pr != nil { + if found { // note: pr might be nil if it was committed without a PR lr.AddMerged(b.Name().Short(), remName, pr) return nil } @@ -149,12 +149,12 @@ func (lr *LocalRepoState) checkLocalBranches(client *github.Client, ctx context. return nil } -func isCommitInRemote(ctx context.Context, client *github.Client, rem *git.Remote, commit plumbing.Hash) (*github.PullRequest, error) { +func isCommitInRemote(ctx context.Context, client *github.Client, rem *git.Remote, commit plumbing.Hash) (bool, *github.PullRequest, error) { remoteUrl := rem.Config().URLs[0] owner, name, err := extractOwnerAndNameFromRemoteUrl(remoteUrl) if err != nil { - return nil, err + return false, nil, err } opts := github.PullRequestListOptions{ State: "closed", @@ -162,15 +162,16 @@ func isCommitInRemote(ctx context.Context, client *github.Client, rem *git.Remot prs, _, err := client.PullRequests.ListPullRequestsWithCommit(ctx, owner, name, commit.String(), &opts) if err != nil { if strings.Contains(err.Error(), "No commit found for SHA") { - return nil, nil + return false, nil, nil } - return nil, err + return false, nil, err } if len(prs) > 0 { - return prs[0], nil + return true, prs[0], nil } - return nil, nil + // important! we are here because a commit was committed directly (without a PR) + return true, nil, nil } // extractOwnerAndNameFromRemoteUrl extracts the owner and name from a remote URL From 9bdd4a8d6052d979d8f490f53600d30cef0e049d Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 14 Oct 2024 23:26:53 +0300 Subject: [PATCH 12/20] clearer output --- internal/ui/local_item.go | 19 ++++++++++++++----- local.go | 17 ++++++++++------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/internal/ui/local_item.go b/internal/ui/local_item.go index 385de8a..9a388dc 100644 --- a/internal/ui/local_item.go +++ b/internal/ui/local_item.go @@ -27,16 +27,25 @@ func (i localItem) Title() string { func (i localItem) Description() string { var details []string if i.repo.StatusClean { - details = append(details, "git status: clean") + details = append(details, "status clean") } else { - details = append(details, "git status: dirty") + details = append(details, "status dirty") } if i.repo.StashClean { - details = append(details, "git stash: clean") + details = append(details, "stash clean") } else { - details = append(details, "git stash: dirty") + details = append(details, "stash dirty") } - details = append(details, fmt.Sprintf("%d unmerged branches", len(i.repo.Unmerged))) + if len(i.repo.Unmerged) > 2 || len(i.repo.Unmerged) == 0 { + details = append(details, fmt.Sprintf("%d unmerged branches", len(i.repo.Unmerged))) + } else { + var keys []string + for k := range i.repo.Unmerged { + keys = append(keys, k) + } + details = append(details, fmt.Sprintf("unmerged: %s", strings.Join(keys, ", "))) + } + details = append(details, i.repo.RemotesChecked...) return detailsStyle.Render(strings.Join(details, separator)) } diff --git a/local.go b/local.go index 14a9862..2362adf 100644 --- a/local.go +++ b/local.go @@ -16,13 +16,14 @@ import ( // LocalRepoState tracks the git status cleanliness, git stash cleanliness, and // for each branch, to which remote branch it has been merged, if any. type LocalRepoState struct { - Path string - repo *git.Repository - StatusClean bool - StashClean bool - MergedOrigin map[string]string - MergedPR map[string]*github.PullRequest - Unmerged map[string]struct{} + Path string + repo *git.Repository + StatusClean bool + StashClean bool + MergedOrigin map[string]string + MergedPR map[string]*github.PullRequest + Unmerged map[string]struct{} + RemotesChecked []string } func NewLocalRepoState(path string, client *github.Client, ctx context.Context) (*LocalRepoState, error) { @@ -120,6 +121,8 @@ func (lr *LocalRepoState) checkLocalBranches(client *github.Client, ctx context. return err } remotesFound++ + lr.RemotesChecked = append(lr.RemotesChecked, rem.Config().URLs[0]) + found, pr, err := isCommitInRemote(ctx, client, rem, b.Hash()) if err != nil { // if it's http 404, just continue From 2131fdcda7302b81d5b9b83ac48d7a5681f1edc0 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Mon, 14 Oct 2024 23:27:54 +0300 Subject: [PATCH 13/20] fix --- local.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/local.go b/local.go index 2362adf..9763dfc 100644 --- a/local.go +++ b/local.go @@ -183,9 +183,10 @@ func extractOwnerAndNameFromRemoteUrl(remoteUrl string) (string, string, error) str := strings.TrimSuffix(remoteUrl, ".git") str = strings.TrimPrefix(str, "git@github.com:") str = strings.TrimPrefix(str, "https://github.com/") + str = strings.TrimPrefix(str, "git://github.com/") split := strings.Split(str, "/") if len(split) != 2 { - return "", "", fmt.Errorf("invalid remote url: %s", remoteUrl) + return "", "", fmt.Errorf("unsupported remote url: %s", remoteUrl) } return split[0], split[1], nil } From 0908b13844ed368ef7ffd3962b4fcfbc087de23d Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Tue, 15 Oct 2024 13:48:21 +0300 Subject: [PATCH 14/20] speed up local repository scanning via concurrency --- internal/ui/commands.go | 59 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/internal/ui/commands.go b/internal/ui/commands.go index 4c73894..ec881e3 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" forkcleaner "github.com/caarlos0/fork-cleaner/v2" @@ -105,25 +106,69 @@ func getLocalReposCmd(client *github.Client, path string) tea.Cmd { return gotLocalRepoListMsg{[]*forkcleaner.LocalRepoState{lr}} } - // we had an error but it was ErrNotExist for .git, so we assume it's a directory that contains code repos (checkouts) - var repos []*forkcleaner.LocalRepoState + // we had an error but it was ErrNotExist for .git, so we assume it's a directory that contains code repos (checkouts) entries, err := os.ReadDir(path) if err != nil { return errMsg{err} } + var repos []*forkcleaner.LocalRepoState + repoCh := make(chan *forkcleaner.LocalRepoState) + errorCh := make(chan error) + var wg sync.WaitGroup + sem := make(chan bool, 10) + ctx, cancel := context.WithCancel(context.Background()) + for _, entry := range entries { if entry.IsDir() { gitpath := filepath.Join(path, entry.Name(), ".git") if _, err := os.Stat(gitpath); err == nil { - lr, err := forkcleaner.NewLocalRepoState(filepath.Join(path, entry.Name()), client, context.Background()) - if err != nil { - return errMsg{err} - } - repos = append(repos, lr) + wg.Add(1) + go func(repoPath string) { + defer wg.Done() + sem <- true // acquire semaphore + defer func() { <-sem }() // release semaphore + // check the context to see if we should still do this work + select { + case <-ctx.Done(): + return + default: + } + lr, err := forkcleaner.NewLocalRepoState(repoPath, client, context.Background()) + if err != nil { + errorCh <- err + return + } + repoCh <- lr + }(filepath.Join(path, entry.Name())) + } + } + } + go func() { + wg.Wait() + close(repoCh) + close(errorCh) + cancel() + }() + + loop: + for { + select { + case repo, ok := <-repoCh: + if !ok { + break loop + } + repos = append(repos, repo) + + case err, ok := <-errorCh: + if !ok { + break } + cancel() + return errMsg{err} + } } From 505e57ea8d5fa9b03ec23f253f0b2b0b668202b3 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Tue, 15 Oct 2024 13:48:41 +0300 Subject: [PATCH 15/20] no need to re-scan local repos after deleting some --- internal/ui/local_app.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/ui/local_app.go b/internal/ui/local_app.go index bd4cb3b..c5a9cdc 100644 --- a/internal/ui/local_app.go +++ b/internal/ui/local_app.go @@ -70,7 +70,6 @@ func (m LocalAppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.list.SetItems(localReposToItems(msg.repos))) case localReposDeletedMsg: log.Println("localReposDeletedMsg") - cmds = append(cmds, m.list.StartSpinner(), enqueueGetLocalReposCmd) case requestDeleteSelectedLocalReposMsg: log.Println("requestDeleteSelectedLocalReposMsg") selected, unselected := splitLocalBySelection(m.list.Items()) From aabe0bb55b2dd95ac226d10a6e37651b4bdad5fb Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Tue, 15 Oct 2024 13:49:17 +0300 Subject: [PATCH 16/20] fix --- README.md | 4 ++++ local.go | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c6ea505..c5c8b6f 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ Note: * if your local branches are found in remote that goes by another name, the local repository is still marked "dirty". This could perhaps be considered as "clean" in the future. (with an optional flag) +* we can only check for the prescensce of commits (branches) hosted on GitHub. Other hosts/remotes are ignored for now, which may lead + to such repositories be marked as "dirty" because we could not prove they are clean. + + In both cases, it seems more prudent to bias towards "dirty", because "clean" means you can safely delete it. ### Remote mode diff --git a/local.go b/local.go index 9763dfc..1d9f906 100644 --- a/local.go +++ b/local.go @@ -129,8 +129,9 @@ func (lr *LocalRepoState) checkLocalBranches(client *github.Client, ctx context. if strings.Contains(err.Error(), "404 Not Found") { continue } - // can't use an invalid URL.. - if strings.Contains(err.Error(), "invalid remote url:") { + // can't use an invalid or unsupported URL. + // this usually means you use a host other than GitHub. these are not supported yet + if strings.Contains(err.Error(), "unsupported remote URL") { continue } @@ -183,10 +184,11 @@ func extractOwnerAndNameFromRemoteUrl(remoteUrl string) (string, string, error) str := strings.TrimSuffix(remoteUrl, ".git") str = strings.TrimPrefix(str, "git@github.com:") str = strings.TrimPrefix(str, "https://github.com/") + str = strings.TrimPrefix(str, "http://github.com/") str = strings.TrimPrefix(str, "git://github.com/") split := strings.Split(str, "/") if len(split) != 2 { - return "", "", fmt.Errorf("unsupported remote url: %s", remoteUrl) + return "", "", fmt.Errorf("unsupported remote URL: %s", remoteUrl) } return split[0], split[1], nil } From d6cf7aff1b65a2522b2fda2bb4cce70ea28f1f32 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Tue, 15 Oct 2024 13:56:44 +0300 Subject: [PATCH 17/20] workaround go-git 'status' broken --- local.go | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/local.go b/local.go index 1d9f906..a75f4ad 100644 --- a/local.go +++ b/local.go @@ -60,15 +60,29 @@ func NewLocalRepoState(path string, client *github.Client, ctx context.Context) } func (lr *LocalRepoState) checkLocalStatus() error { - w, err := lr.repo.Worktree() - if err != nil { - return err - } - status, err := w.Status() - if err != nil { - return err - } - lr.StatusClean = status.IsClean() + // git-go's status sometimes reports dirty when it should be clean. e.g.: + // https://github.com/go-git/go-git/issues/691 + // https://github.com/go-git/go-git/issues/736 + /* + w, err := lr.repo.Worktree() + if err != nil { + return err + } + status, err := w.Status() + if err != nil { + return err + } + log.Println("Status for ", lr.Path, status) + lr.StatusClean = status.IsClean() + */ + + cmd := exec.Command("git", "status", "--porcelain") + var out bytes.Buffer + cmd.Dir = lr.Path + cmd.Stdout = &out + cmd.Stderr = os.Stderr + cmd.Run() + lr.StatusClean = out.String() == "" return nil } From d7a4d3b499f25a521554f620465dbcfda1139cde Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Tue, 15 Oct 2024 14:59:01 +0300 Subject: [PATCH 18/20] only display each URI once, allow searching on it --- internal/ui/local_item.go | 3 ++- local.go | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/ui/local_item.go b/internal/ui/local_item.go index 9a388dc..60fb8ff 100644 --- a/internal/ui/local_item.go +++ b/internal/ui/local_item.go @@ -55,7 +55,8 @@ func (i localItem) FilterValue() string { if i.repo.Clean() { clean = "clean" } - return " " + i.repo.Path + " " + clean + + return " " + i.repo.Path + " " + clean + " " + strings.Join(i.repo.RemotesChecked, " ") } func splitLocalBySelection(localItems []list.Item) ([]*forkcleaner.LocalRepoState, []*forkcleaner.LocalRepoState) { diff --git a/local.go b/local.go index a75f4ad..8b2b1cf 100644 --- a/local.go +++ b/local.go @@ -135,7 +135,15 @@ func (lr *LocalRepoState) checkLocalBranches(client *github.Client, ctx context. return err } remotesFound++ - lr.RemotesChecked = append(lr.RemotesChecked, rem.Config().URLs[0]) + var found bool + for _, existing := range lr.RemotesChecked { + if existing == rem.Config().URLs[0] { + found = true + } + } + if !found { + lr.RemotesChecked = append(lr.RemotesChecked, rem.Config().URLs[0]) + } found, pr, err := isCommitInRemote(ctx, client, rem, b.Hash()) if err != nil { From c1d998edec34d203980f58309b05ca9525af2aa5 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Tue, 15 Oct 2024 20:43:37 +0300 Subject: [PATCH 19/20] clarify readme --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c5c8b6f..e9bcd43 100644 --- a/README.md +++ b/README.md @@ -64,18 +64,18 @@ It marks each repository, as either "clean" (safe to delete), or "dirty" (not sa For a repository to be marked clean, it needs to meet all of the following conditions: -* the are no uncommitted changes. -* all branches are found in a remote named "upstream" or "origin". +* there are no uncommitted changes: `git status` is clean. +* all branches (technically, their HEAD commit) are found in a remote named "upstream" or "origin". * there is nothing in the stash. -Note: +Limitations: -* if your local branches are found in remote that goes by another name, the local repository is still marked "dirty". This could perhaps - be considered as "clean" in the future. (with an optional flag) -* we can only check for the prescensce of commits (branches) hosted on GitHub. Other hosts/remotes are ignored for now, which may lead - to such repositories be marked as "dirty" because we could not prove they are clean. +* if your local branches are found in a remote that goes by another name, they are not considered "safely merged" ("clean") + (could add an optional flag to support this) +* we can only check for the presence of commits hosted on GitHub. Other hosts/remotes are ignored for now. +* branches that were integrated in the upstream/origin via a history-altering operation (e.g. squash-merged, rebase-merged) can't be detected. - In both cases, it seems more prudent to bias towards "dirty", because "clean" means you can safely delete it. +In all these cases, since we can't prove "cleanliness", we mark affected repos as "dirty" out of caution. ### Remote mode From 7c3a997ee29a60776336875dff284dd00feb0e09 Mon Sep 17 00:00:00 2001 From: Dieter Plaetinck Date: Wed, 16 Oct 2024 10:18:21 +0300 Subject: [PATCH 20/20] display per-repo disk space used + sort by size and name support --- internal/ui/keys.go | 2 ++ internal/ui/local_app.go | 19 +++++++++++++++++++ internal/ui/local_item.go | 35 +++++++++++++++++++++++++++++++++-- local.go | 27 ++++++++++++++++++++++++++- 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/internal/ui/keys.go b/internal/ui/keys.go index c93f1ce..eec52fe 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -7,4 +7,6 @@ var ( keySelectNone = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "select none")) keySelectToggle = key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle selected item")) keyDeletedSelected = key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete selected forks")) + keySortBySize = key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort by size (Desc)")) + keySortByName = key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "sort by name")) ) diff --git a/internal/ui/local_app.go b/internal/ui/local_app.go index c5a9cdc..f3c2fef 100644 --- a/internal/ui/local_app.go +++ b/internal/ui/local_app.go @@ -2,6 +2,7 @@ package ui import ( "log" + "sort" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" @@ -28,12 +29,16 @@ func NewLocalAppModel(client *github.Client, login, path string) LocalAppModel { return []key.Binding{ keySelectToggle, keyDeletedSelected, + keySortBySize, + keySortByName, } } list.AdditionalFullHelpKeys = func() []key.Binding { return []key.Binding{ keySelectAll, keySelectNone, + keySortBySize, + keySortByName, } } @@ -103,6 +108,20 @@ func (m LocalAppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { log.Println("tea.KeyMsg -> deleteSelected") cmds = append(cmds, m.list.StartSpinner(), requestDeleteLocalReposCmd) } + + if key.Matches(msg, keySortBySize) { + log.Println("tea.KeyMsg -> sortBySize") + items := m.list.Items() + sort.Sort(bySizeDesc(items)) + cmds = append(cmds, m.list.SetItems(items)) + } + + if key.Matches(msg, keySortByName) { + log.Println("tea.KeyMsg -> sortByName") + items := m.list.Items() + sort.Sort(byName(items)) + cmds = append(cmds, m.list.SetItems(items)) + } } m.list, cmd = m.list.Update(msg) diff --git a/internal/ui/local_item.go b/internal/ui/local_item.go index 60fb8ff..178c9ec 100644 --- a/internal/ui/local_item.go +++ b/internal/ui/local_item.go @@ -18,10 +18,11 @@ func (i localItem) Title() string { if i.repo.Clean() { clean = " (clean)" } + if i.selected { - return iconSelected + " " + i.repo.Path + clean + return iconSelected + " " + ByteCountIEC(i.repo.Size) + " " + i.repo.Path + clean } - return iconNotSelected + " " + i.repo.Path + clean + return iconNotSelected + " " + ByteCountIEC(i.repo.Size) + " " + i.repo.Path + clean } func (i localItem) Description() string { @@ -81,3 +82,33 @@ func localReposToItems(repos []*forkcleaner.LocalRepoState) []list.Item { } return localItems } + +// support for sorting by size +type bySizeDesc []list.Item + +func (s bySizeDesc) Less(i, j int) bool { + return s[i].(localItem).repo.Size > s[j].(localItem).repo.Size +} +func (s bySizeDesc) Len() int { return len(s) } +func (s bySizeDesc) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +type byName []list.Item + +func (s byName) Less(i, j int) bool { return s[i].(localItem).repo.Path < s[j].(localItem).repo.Path } +func (s byName) Len() int { return len(s) } +func (s byName) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +// from https://yourbasic.org/golang/formatting-byte-size-to-human-readable-format/ +func ByteCountIEC(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", + float64(b)/float64(div), "KMGTPE"[exp]) +} diff --git a/local.go b/local.go index 8b2b1cf..eaead49 100644 --- a/local.go +++ b/local.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "github.com/go-git/go-git/v5" @@ -18,6 +19,7 @@ import ( type LocalRepoState struct { Path string repo *git.Repository + Size int64 StatusClean bool StashClean bool MergedOrigin map[string]string @@ -33,8 +35,12 @@ func NewLocalRepoState(path string, client *github.Client, ctx context.Context) MergedPR: make(map[string]*github.PullRequest), Unmerged: make(map[string]struct{}), } - var err error + + lr.Size, err = getDiskSpaceUsed(path) + if err != nil { + return nil, err + } lr.repo, err = git.PlainOpen(path) if err != nil { return nil, err @@ -214,3 +220,22 @@ func extractOwnerAndNameFromRemoteUrl(remoteUrl string) (string, string, error) } return split[0], split[1], nil } + +// getDiskSpaceUsed calculates the amount of disk space used under `path` +// to achieve this we must fully, recursively walk the directory tree. +// note: this is not super accurate, we don't account for the size of the directories themselves +func getDiskSpaceUsed(path string) (int64, error) { + var size int64 + err := filepath.Walk(path, func(base string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + //log.Println("info.Size():", info.Size(), path, base) + size += info.Size() + } + return nil + }) + //log.Println("total size", size) + return size, err +}