├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── main.go ├── main_test.go └── testdata └── script ├── change-id.txtar ├── hash-negatives.txtar └── hash-positives.txtar /.gitattributes: -------------------------------------------------------------------------------- 1 | # To prevent CRLF breakages on Windows for fragile files, like testdata. 2 | * -text 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mvdan 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.23.x, 1.24.x] 8 | os: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-go@v5 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - run: go test ./... 16 | - run: go test -race ./... 17 | 18 | # Static checks from this point forward. Only run on one Go version and on 19 | # Linux, since it's the fastest platform, and the tools behave the same. 20 | - if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.24.x' 21 | run: diff <(echo -n) <(gofmt -s -d .) 22 | - if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.24.x' 23 | run: go vet ./... 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Daniel Martí. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of the copyright holder nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-picked 2 | 3 | This tool is a drop-in replacement for `git branch --merged` which also works 4 | when branches are rebased or cherry-picked into `HEAD`. 5 | 6 | go install mvdan.cc/git-picked@latest 7 | 8 | It tries to match commits via their 9 | [Change-Id](https://gerrit-review.googlesource.com/Documentation/user-changeid.html), 10 | if it is present. Otherwise, a hash is used consisting of: 11 | 12 | * Author name 13 | * Author email 14 | * Author date (in UTC) 15 | * Commit summary (first line of its message) 16 | 17 | Note that the matching is only done with the tip commit of each branch. 18 | 19 | Matching is done against the history of `HEAD`, stopping when either all commits 20 | have been found or when the main history dates fall behind the author dates of 21 | the commits left to match. This will work nicely as long as noone uses a time 22 | machine. 23 | 24 | This is a standalone binary and does not depend on the `git` executable. 25 | 26 | Note that this heuristic may get confused with release branches. As such, if you 27 | name your release branches `release-x.y` you likely want to use an alias like: 28 | 29 | git-picked | grep -vE '^(master|release|backport)' 30 | 31 | Branches with patches targeting branches other than master should also be 32 | excluded, like `backport-some-feature` in this case. 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module mvdan.cc/git-picked 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/go-git/go-git/v5 v5.13.2 7 | github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a 8 | ) 9 | 10 | require ( 11 | dario.cat/mergo v1.0.0 // indirect 12 | github.com/Microsoft/go-winio v0.6.1 // indirect 13 | github.com/ProtonMail/go-crypto v1.1.5 // indirect 14 | github.com/cloudflare/circl v1.3.7 // indirect 15 | github.com/cyphar/filepath-securejoin v0.3.6 // indirect 16 | github.com/emirpasic/gods v1.18.1 // indirect 17 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 18 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 19 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 20 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 21 | github.com/kevinburke/ssh_config v1.2.0 // indirect 22 | github.com/pjbgf/sha1cd v0.3.2 // indirect 23 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 24 | github.com/skeema/knownhosts v1.3.0 // indirect 25 | github.com/xanzy/ssh-agent v0.3.3 // indirect 26 | golang.org/x/crypto v0.32.0 // indirect 27 | golang.org/x/mod v0.21.0 // indirect 28 | golang.org/x/net v0.34.0 // indirect 29 | golang.org/x/sync v0.10.0 // indirect 30 | golang.org/x/sys v0.29.0 // indirect 31 | golang.org/x/tools v0.26.0 // indirect 32 | gopkg.in/warnings.v0 v0.1.2 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 5 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 6 | github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= 7 | github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 8 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 10 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 12 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 13 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 14 | github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= 15 | github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 16 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/elazarl/goproxy v1.4.0 h1:4GyuSbFa+s26+3rmYNSuUVsx+HgPrV1bk1jXI0l9wjM= 20 | github.com/elazarl/goproxy v1.4.0/go.mod h1:X/5W/t+gzDyLfHW4DrMdpjqYjpXsURlBt9lpBDxZZZQ= 21 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 22 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 23 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 24 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 25 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 26 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 27 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 28 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 29 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 30 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 31 | github.com/go-git/go-git/v5 v5.13.2 h1:7O7xvsK7K+rZPKW6AQR1YyNhfywkv7B8/FsP3ki6Zv0= 32 | github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A= 33 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 34 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 35 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 36 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 37 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 38 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 39 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 40 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 41 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 42 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 43 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 44 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 45 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 46 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 47 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 48 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 49 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 50 | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 51 | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 52 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 53 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 54 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 55 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 56 | github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM= 57 | github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY= 58 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 59 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 60 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 61 | github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= 62 | github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= 63 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 64 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 65 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 66 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 67 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 68 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 69 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 70 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 71 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 72 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 73 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 74 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 75 | golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= 76 | golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 77 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 78 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 79 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 80 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 81 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 82 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 83 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 84 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 85 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 86 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 88 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 89 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 90 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 91 | golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= 92 | golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 93 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 94 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 95 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 96 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 97 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 98 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 99 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 100 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 101 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 102 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 103 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 104 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 105 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 106 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 107 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 108 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 109 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2017, Daniel Martí 2 | // See LICENSE for licensing information 3 | 4 | package main 5 | 6 | import ( 7 | "flag" 8 | "fmt" 9 | "os" 10 | "sort" 11 | "strings" 12 | "time" 13 | 14 | git "github.com/go-git/go-git/v5" 15 | "github.com/go-git/go-git/v5/plumbing" 16 | "github.com/go-git/go-git/v5/plumbing/object" 17 | "github.com/go-git/go-git/v5/plumbing/storer" 18 | ) 19 | 20 | func main() { 21 | flag.Usage = func() { fmt.Fprintln(os.Stderr, `usage: git-picked [flags]`) } 22 | flag.Parse() 23 | if len(flag.Args()) > 0 { 24 | flag.Usage() // we don't take any args 25 | } 26 | 27 | branches, err := pickedBranches() 28 | if err != nil { 29 | fmt.Fprintln(os.Stderr, err) 30 | os.Exit(1) 31 | } 32 | sort.Strings(branches) 33 | for _, b := range branches { 34 | fmt.Println(b) 35 | } 36 | } 37 | 38 | type branchInfo struct { 39 | refs []*plumbing.Reference 40 | author time.Time 41 | } 42 | 43 | func pickedBranches() ([]string, error) { 44 | openOpt := &git.PlainOpenOptions{DetectDotGit: true} 45 | r, err := git.PlainOpenWithOptions(".", openOpt) 46 | if err != nil { 47 | return nil, err 48 | } 49 | all, err := allBranches(r) 50 | if err != nil { 51 | return nil, err 52 | } 53 | head, err := r.Head() 54 | if err != nil { 55 | return nil, err 56 | } 57 | // commits not yet confirmed picked 58 | commitsLeft := make(map[string]branchInfo, len(all)-1) 59 | for _, ref := range all { 60 | // HEAD is obviously part of itself 61 | if ref.Name() == head.Name() { 62 | continue 63 | } 64 | cm, err := r.CommitObject(ref.Hash()) 65 | if err != nil { 66 | return nil, err 67 | } 68 | key := commitKey(cm) 69 | prev := commitsLeft[key] 70 | commitsLeft[key] = branchInfo{ 71 | refs: append(prev.refs, ref), 72 | author: cm.Author.When.UTC(), 73 | } 74 | } 75 | if len(commitsLeft) == 0 { 76 | return nil, nil 77 | } 78 | hcm, err := r.CommitObject(head.Hash()) 79 | if err != nil { 80 | return nil, err 81 | } 82 | stopTime := oldestTime(commitsLeft) 83 | picked := make([]string, 0) 84 | iter := object.NewCommitIterCTime(hcm, nil, nil) 85 | err = iter.ForEach(func(cm *object.Commit) error { 86 | if cm.Committer.When.Before(stopTime) { 87 | return storer.ErrStop 88 | } 89 | key := commitKey(cm) 90 | if bi, e := commitsLeft[key]; e { 91 | delete(commitsLeft, key) 92 | for _, ref := range bi.refs { 93 | picked = append(picked, ref.Name().Short()) 94 | } 95 | if len(commitsLeft) == 0 { 96 | return storer.ErrStop 97 | } 98 | stopTime = oldestTime(commitsLeft) 99 | } 100 | return nil 101 | }) 102 | return picked, err 103 | } 104 | 105 | func oldestTime(m map[string]branchInfo) (oldest time.Time) { 106 | first := true 107 | for _, bi := range m { 108 | if first || bi.author.Before(oldest) { 109 | oldest = bi.author 110 | } 111 | first = false 112 | } 113 | return 114 | } 115 | 116 | // commitKey returns a string that uniquely identifies a commit. If a commit 117 | // message contains a Change-Id as described by 118 | // https://gerrit-review.googlesource.com/Documentation/user-changeid.html, it 119 | // will be returned directly. Otherwise, a string containing commit metadata 120 | // will be returned instead, including the author information and the commit 121 | // summary. 122 | func commitKey(cm *object.Commit) string { 123 | const changeIdPrefix = "Change-Id: " 124 | // Split the lines. Trim spaces too, as commit messages often end in a 125 | // newline. 126 | lines := strings.Split(strings.TrimSpace(cm.Message), "\n") 127 | 128 | // Start from the bottom, as the Change-Id belongs in the footer. 129 | for i := len(lines) - 1; i >= 0; i-- { 130 | line := lines[i] 131 | if line == "" { 132 | break // Change-Id can only be part of the footer 133 | } 134 | if !strings.HasPrefix(line, changeIdPrefix) { 135 | continue // not a Change-Id 136 | } 137 | // We found the Change-Id. 138 | id := strings.TrimSpace(line[len(changeIdPrefix):]) 139 | if len(id) < 10 { 140 | // Gerrit's IDs are "I" + 40 hex chars. 141 | // Require at least 10, for minimum uniqueness. 142 | continue 143 | } 144 | return id 145 | } 146 | 147 | // No Change-Id found; fall back to inferring uniqueness from the 148 | // metadata. 149 | var b strings.Builder 150 | b.WriteString(cm.Author.Name) 151 | b.WriteString(cm.Author.Email) 152 | b.WriteString(cm.Author.When.UTC().String()) 153 | summary := cm.Message 154 | if i := strings.IndexByte(summary, '\n'); i > 0 { 155 | summary = summary[:i] 156 | } 157 | b.WriteString(summary) 158 | return b.String() 159 | } 160 | 161 | func allBranches(r *git.Repository) ([]*plumbing.Reference, error) { 162 | refs, err := r.References() 163 | if err != nil { 164 | return nil, err 165 | } 166 | defer refs.Close() 167 | all := make([]*plumbing.Reference, 0) 168 | refs.ForEach(func(ref *plumbing.Reference) error { 169 | if ref.Name().IsBranch() { 170 | all = append(all, ref) 171 | } 172 | return nil 173 | }) 174 | return all, nil 175 | } 176 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019, Daniel Martí 2 | // See LICENSE for licensing information 3 | 4 | package main 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/rogpeppe/go-internal/testscript" 13 | ) 14 | 15 | func TestMain(m *testing.M) { 16 | testscript.Main(m, map[string]func(){ 17 | "git-picked": main, 18 | "join-lines": joinLines, 19 | }) 20 | } 21 | 22 | // joinLines is a little helper, since it's impossible to have multiline strings 23 | // in testscript files. 24 | func joinLines() { 25 | for _, arg := range os.Args[1:] { 26 | fmt.Println(arg) 27 | } 28 | } 29 | 30 | func TestScript(t *testing.T) { 31 | t.Parallel() 32 | testscript.Run(t, testscript.Params{ 33 | Dir: filepath.Join("testdata", "script"), 34 | RequireExplicitExec: true, 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /testdata/script/change-id.txtar: -------------------------------------------------------------------------------- 1 | [!exec:git] skip 'git is required to run this script' 2 | 3 | exec git init 4 | exec git config user.name "Test Script" 5 | exec git config user.email "test@script.tld" 6 | 7 | exec git add initial.txt 8 | exec git commit -m 'initial commit' 9 | 10 | # A cherry-picked commit with a change-id. 11 | exec git checkout -b cherry-pick 12 | exec git add cherry-pick.txt 13 | 14 | exec join-lines 'cherry-pick commit' '' 'Change-Id: I1111111111111111111111111111111111111111' 15 | stdin stdout 16 | exec git commit -F- 17 | 18 | exec git checkout master 19 | exec git cherry-pick cherry-pick 20 | 21 | # The same, but with a merge commit 22 | exec git checkout -b merge-commit 23 | exec git add merge-commit.txt 24 | 25 | exec join-lines 'merge-commit commit' '' 'Change-Id: I2222222222222222222222222222222222222222' 26 | stdin stdout 27 | exec git commit -F- 28 | 29 | exec git checkout master 30 | exec git merge --no-ff merge-commit 31 | 32 | # A cherry-picked commit with different dates. 33 | exec git checkout -b ancient-commit 34 | exec git add ancient-commit.txt 35 | env GIT_AUTHOR_DATE='2010-11-22 03:04:05' 36 | env GIT_COMMITTER_DATE='2010-11-22 03:04:05' 37 | 38 | exec join-lines 'ancient commit that was cherry-picked later' '' 'Change-Id: I3333333333333333333333333333333333333333' 39 | stdin stdout 40 | exec git commit -F- 41 | 42 | env GIT_AUTHOR_DATE= 43 | env GIT_COMMITTER_DATE= 44 | exec git checkout master 45 | exec git cherry-pick ancient-commit 46 | exec git commit --amend --no-edit --date=now 47 | 48 | # A cherry-picked commit with a changed title. 49 | exec git checkout -b changed-title 50 | exec git add changed-title.txt 51 | 52 | exec join-lines 'old title' '' 'Change-Id: I4444444444444444444444444444444444444444' 53 | stdin stdout 54 | exec git commit -F- 55 | 56 | exec git checkout master 57 | exec git cherry-pick changed-title 58 | exec join-lines 'new title' '' 'Change-Id: I4444444444444444444444444444444444444444' 59 | stdin stdout 60 | exec git commit --amend -F- 61 | 62 | # A cherry-picked commit that got its Change-Id lost isn't picked. 63 | exec git checkout -b lost-id 64 | exec git add lost-id.txt 65 | 66 | exec join-lines 'lost id commit' '' 'Change-Id: I5555555555555555555555555555555555555555' 67 | stdin stdout 68 | exec git commit -F- 69 | 70 | exec git checkout master 71 | exec git cherry-pick lost-id 72 | exec git commit --amend -m 'lost id commit' 73 | 74 | # A cherry-picked commit whose Change-Id was too short, so we skipped it. Given 75 | # that we change the title, it's not picked. 76 | exec git checkout -b bad-id 77 | exec git add bad-id.txt 78 | 79 | exec join-lines 'bad id commit' '' 'Change-Id: Iabc123' 80 | stdin stdout 81 | exec git commit -F- 82 | 83 | exec git checkout master 84 | exec git cherry-pick bad-id 85 | exec join-lines 'changed bad id commit' '' 'Change-Id: Iabc123' 86 | stdin stdout 87 | exec git commit --amend -F- 88 | 89 | # Check all the branches set up above. 90 | exec git-picked 91 | ! stdout master 92 | cmp stdout stdout.golden 93 | 94 | -- stdout.golden -- 95 | ancient-commit 96 | changed-title 97 | cherry-pick 98 | merge-commit 99 | -- initial.txt -- 100 | initial content 101 | 102 | -- cherry-pick.txt -- 103 | cherry-picked content 104 | 105 | -- merge-commit.txt -- 106 | merge-committed content 107 | 108 | -- ancient-commit.txt -- 109 | ancient-commit content 110 | 111 | -- changed-title.txt -- 112 | changed-title content 113 | 114 | -- lost-id.txt -- 115 | lost-id content 116 | 117 | -- bad-id.txt -- 118 | bad-id content 119 | -------------------------------------------------------------------------------- /testdata/script/hash-negatives.txtar: -------------------------------------------------------------------------------- 1 | [!exec:git] skip 'git is required to run this script' 2 | 3 | exec git init 4 | exec git config user.name "Test Script" 5 | exec git config user.email "test@script.tld" 6 | 7 | # Running in the master branch, with no other branches. 8 | exec git add initial.txt 9 | exec git commit -m 'initial commit' 10 | 11 | exec git-picked 12 | ! stdout . 13 | 14 | # Set up a branch that's never picked 15 | exec git checkout -b non-picked 16 | exec git add non-picked.txt 17 | exec git commit -m 'non-picked commit' 18 | exec git checkout master 19 | 20 | # Set up a branch with a different history 21 | exec git checkout -b different-history 22 | exec git commit --amend -m 'rewritten initial commit' 23 | exec git checkout master 24 | 25 | # Set up a branch that's cherry-picked with a different author 26 | exec git checkout -b picked-different-author 27 | exec git add picked-different-author.txt 28 | exec git commit -m 'picked commit with different author' 29 | exec git checkout master 30 | exec git cherry-pick picked-different-author 31 | exec git commit --amend --no-edit --author='Someone Else ' 32 | 33 | # Set up a branch that's cherry-picked with a different date 34 | exec git checkout -b picked-different-date 35 | exec git add picked-different-date.txt 36 | exec git commit --date='2 hours ago' -m 'picked commit with different date' 37 | exec git checkout master 38 | exec git cherry-pick picked-different-date 39 | exec git commit --amend --no-edit --date=now 40 | 41 | # Set up a branch that's cherry-picked with a different message 42 | exec git checkout -b picked-different-message 43 | exec git add picked-different-message.txt 44 | exec git commit -m 'picked commit with different message' 45 | exec git checkout master 46 | exec git cherry-pick picked-different-message 47 | exec git commit --amend -m 'a whole new commit message' 48 | 49 | # None of the branches above should show up as picked. 50 | exec git-picked 51 | ! stdout . 52 | 53 | -- initial.txt -- 54 | initial content 55 | 56 | -- non-picked.txt -- 57 | non-picked content 58 | 59 | -- picked-different-author.txt -- 60 | picked content with different author 61 | 62 | -- picked-different-date.txt -- 63 | picked content with different date 64 | 65 | -- picked-different-message.txt -- 66 | picked content with different message 67 | -------------------------------------------------------------------------------- /testdata/script/hash-positives.txtar: -------------------------------------------------------------------------------- 1 | [!exec:git] skip 'git is required to run this script' 2 | 3 | exec git init 4 | exec git config user.name "Test Script" 5 | exec git config user.email "test@script.tld" 6 | 7 | exec git add initial.txt 8 | exec git commit -m 'initial commit' 9 | 10 | # Running in an equal non-master branch will find "master" as being picked. 11 | exec git checkout -b fast-forward 12 | 13 | exec git-picked 14 | stdout 'master' 15 | 16 | # Adding a commit will still find master as being picked. 17 | exec git add fast-forward.txt 18 | exec git commit -m 'fast-forward commit' 19 | 20 | exec git-picked 21 | stdout 'master' 22 | 23 | # Doing a merge of the branch (fast-forward) will show it as picked. 24 | exec git checkout master 25 | exec git merge --ff-only fast-forward 26 | 27 | exec git-picked 28 | stdout 'fast-forward' 29 | 30 | # The same, but with cherry-pick 31 | exec git checkout -b cherry-pick 32 | exec git add cherry-pick.txt 33 | exec git commit -m 'cherry-pick commit' 34 | exec git checkout master 35 | exec git cherry-pick cherry-pick 36 | 37 | # The same, but with a merge commit 38 | exec git checkout -b merge-commit 39 | exec git add merge-commit.txt 40 | exec git commit -m 'merge-commit commit' 41 | exec git checkout master 42 | exec git merge --no-ff merge-commit 43 | 44 | # A merged commit, with an ancient commit merged after it. 45 | exec git checkout -b out-of-order 46 | exec git add out-of-order.txt 47 | exec git commit -m 'out-of-order commit' 48 | exec git checkout master 49 | 50 | exec git checkout -b ancient-commit 51 | exec git add ancient-commit.txt 52 | env GIT_AUTHOR_DATE='2010-11-22 03:04:05' 53 | env GIT_COMMITTER_DATE='2010-11-22 03:04:05' 54 | exec git commit -m 'ancient commit that was merged later' 55 | env GIT_AUTHOR_DATE= 56 | env GIT_COMMITTER_DATE= 57 | exec git checkout master 58 | 59 | exec git merge --no-ff out-of-order 60 | exec git merge --no-ff ancient-commit 61 | exec git branch -D ancient-commit # we're not interested in the ancient branch 62 | 63 | # Check all the branches set up above. 64 | exec git-picked 65 | ! stdout master 66 | cmp stdout stdout.golden 67 | 68 | -- stdout.golden -- 69 | cherry-pick 70 | fast-forward 71 | merge-commit 72 | out-of-order 73 | -- initial.txt -- 74 | initial content 75 | 76 | -- fast-forward.txt -- 77 | fast-forwarded content 78 | 79 | -- cherry-pick.txt -- 80 | cherry-picked content 81 | 82 | -- merge-commit.txt -- 83 | merge-committed content 84 | 85 | -- out-of-order.txt -- 86 | out-of-order content 87 | -- ancient-commit.txt -- 88 | ancient-commit content 89 | --------------------------------------------------------------------------------