diff --git a/README.md b/README.md index bdbb009..e9bcd43 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,30 @@ 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 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: + +* 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. + +Limitations: + +* 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 all these cases, since we can't prove "cleanliness", we mark affected repos as "dirty" out of caution. + +### 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 329873b..e62df72 100644 --- a/cmd/fork-cleaner/main.go +++ b/cmd/fork-cleaner/main.go @@ -43,6 +43,11 @@ func main() { Usage: "GitHub username or organization name. Defaults to current user.", Aliases: []string{"u"}, }, + &cli.StringFlag{ + Name: "path", + Usage: "directory that is a git repo, or contains git repos, all of which will be scanned", + Aliases: []string{"p"}, + }, &cli.BoolFlag{ Name: "skip-upstream-check", Usage: "Skip checking and pulling details from the parent/upstream repository", @@ -76,6 +81,21 @@ func main() { return cli.Exit("missing github token", 1) } + path := c.String("path") + 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 + } 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/go.mod b/go.mod index 4caaf61..b1b1dfc 100644 --- a/go.mod +++ b/go.mod @@ -7,21 +7,31 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.1.1 github.com/charmbracelet/lipgloss v0.13.0 + github.com/go-git/go-git/v5 v5.8.0 github.com/google/go-github/v50 v50.2.0 github.com/urfave/cli/v2 v2.27.5 golang.org/x/oauth2 v0.23.0 ) require ( + github.com/Microsoft/go-winio v0.5.2 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // 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/charmbracelet/x/ansi v0.2.3 // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // 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.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -29,12 +39,18 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sahilm/fuzzy v0.1.1 // 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-20240521201337-686a1a2994c1 // indirect golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.12.0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.24.0 // indirect golang.org/x/text v0.14.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 585e91d..a727b3e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ +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-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE= github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +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 h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 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= @@ -22,17 +28,49 @@ github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vc github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= github.com/cpuguy83/go-md2man/v2 v2.0.5/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= +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.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.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8= +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/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 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.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/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 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/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/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 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -45,6 +83,13 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +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 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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -52,13 +97,25 @@ 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.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/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.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 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +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-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= @@ -67,10 +124,13 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 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.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 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.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -79,10 +139,14 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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-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-20210809222454-d867a43fc93e/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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -95,8 +159,10 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 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.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -109,3 +175,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc 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= +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 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 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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..ec881e3 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -2,9 +2,14 @@ package ui import ( "context" + "errors" "fmt" + "io/fs" "log" + "os" + "path/filepath" "strings" + "sync" "time" forkcleaner "github.com/caarlos0/fork-cleaner/v2" @@ -16,6 +21,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 +39,18 @@ 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: DELETING", r.Path) + if err := os.RemoveAll(r.Path); err != nil { + return errMsg{err} + } + } + return localReposDeletedMsg{} + } +} + func enqueueGetReposCmd() tea.Msg { return getRepoListMsg{} } @@ -60,3 +81,97 @@ 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) + + 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 { + 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} + + } + } + + 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/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 new file mode 100644 index 0000000..f3c2fef --- /dev/null +++ b/internal/ui/local_app.go @@ -0,0 +1,158 @@ +package ui + +import ( + "log" + "sort" + + "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, + keySortBySize, + keySortByName, + } + } + list.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + keySelectAll, + keySelectNone, + keySortBySize, + keySortByName, + } + } + + 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") + 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) + } + + 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) + 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..178c9ec --- /dev/null +++ b/internal/ui/local_item.go @@ -0,0 +1,114 @@ +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 + " " + ByteCountIEC(i.repo.Size) + " " + i.repo.Path + clean + } + return iconNotSelected + " " + ByteCountIEC(i.repo.Size) + " " + i.repo.Path + clean +} + +func (i localItem) Description() string { + var details []string + if i.repo.StatusClean { + details = append(details, "status clean") + } else { + details = append(details, "status dirty") + } + if i.repo.StashClean { + details = append(details, "stash clean") + } else { + details = append(details, "stash dirty") + } + 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)) +} + +func (i localItem) FilterValue() string { + clean := "dirty" + if i.repo.Clean() { + clean = "clean" + } + + return " " + i.repo.Path + " " + clean + " " + strings.Join(i.repo.RemotesChecked, " ") +} + +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 +} + +// 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/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 new file mode 100644 index 0000000..eaead49 --- /dev/null +++ b/local.go @@ -0,0 +1,241 @@ +package forkcleaner + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/google/go-github/v50/github" +) + +// 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 + Size int64 + 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) { + lr := LocalRepoState{ + Path: path, + MergedOrigin: make(map[string]string), + 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 + } + + // 1) check status + if err := lr.checkLocalStatus(); err != nil { + return nil, err + } + + // 2) check stash + if err := lr.checkLocalStash(); err != nil { + return nil, err + } + + // 3) check branches + if err := lr.checkLocalBranches(client, ctx); err != nil { + return nil, err + } + + return &lr, nil + +} + +func (lr *LocalRepoState) checkLocalStatus() error { + // 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 +} + +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 = lr.Path + cmd.Stdout = &out + cmd.Stderr = os.Stderr + cmd.Run() + lr.StashClean = out.String() == "" + return nil +} + +func (b *LocalRepoState) AddMerged(local, remote string, pr *github.PullRequest) { + b.MergedOrigin[local] = remote + b.MergedPR[local] = pr +} + +func (b *LocalRepoState) AddUnmerged(local string) { + b.Unmerged[local] = struct{}{} +} + +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 (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 := lr.repo.Branches() + if err != nil { + return err + } + + // 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 { + var remotesFound int + + for _, remName := range []string{"origin", "upstream"} { + rem, err := lr.repo.Remote(remName) + if err == git.ErrRemoteNotFound { + continue + } + if err != nil { + return err + } + remotesFound++ + 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 { + // if it's http 404, just continue + if strings.Contains(err.Error(), "404 Not Found") { + continue + } + // 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 + } + + return err + } + if found { // note: pr might be nil if it was committed without a PR + lr.AddMerged(b.Name().Short(), remName, pr) + return nil + } + } + lr.AddUnmerged(b.Name().Short()) + return nil + }) + + if err != nil { + return fmt.Errorf("error while iterating over branches: %w", err) + } + + return nil +} + +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 false, nil, err + } + opts := github.PullRequestListOptions{ + State: "closed", + } + 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 false, nil, nil + } + return false, nil, err + } + + if len(prs) > 0 { + return true, prs[0], 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 +// 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/") + 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 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 +} diff --git a/testscript.sh b/testscript.sh new file mode 100755 index 0000000..f5742ed --- /dev/null +++ b/testscript.sh @@ -0,0 +1,105 @@ +#!/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-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-clean-new-dir +mkdir repo-clean-new-dir/dirty-dir + +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-dirty-stash +cd repo-dirty-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-dirty-commit-to-main +cd repo-dirty-commit-to-main +echo "some change" >> README.md +git commit README.md -m 'extra line' +cd .. + +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 .. + + +## 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) +# 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)