├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── go.sum └── src ├── apps ├── send │ ├── cmd │ │ └── send.go │ └── main.go └── show │ ├── cmd │ └── show.go │ └── main.go └── internal └── git ├── git.go └── git_test.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | 5 | permissions: 6 | contents: write 7 | packages: write 8 | 9 | jobs: 10 | releases-matrix: 11 | name: Release Go Binary 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | # build and publish in parallel: linux/386, linux/amd64, linux/arm64, windows/386, windows/amd64, darwin/amd64, darwin/arm64 16 | goos: [linux, windows, darwin] 17 | goarch: ["386", amd64, arm64] 18 | name: ["send", "show"] 19 | exclude: 20 | - goarch: "386" 21 | goos: darwin 22 | - goarch: arm64 23 | goos: windows 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: wangyoucao577/go-release-action@v1 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | goos: ${{ matrix.goos }} 30 | goarch: ${{ matrix.goarch }} 31 | project_path: "./src/apps/${{ matrix.name }}" 32 | binary_name: "git-${{ matrix.name }}-nostr" 33 | extra_files: LICENSE README.md 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | git-nostr-send 2 | git-nostr-show -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2023 npub1zenn0 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-git-nostr 2 | 3 | Send and receive git patches over nostr. 4 | 5 | ## Install 6 | 7 | Download latest binaries from the releases page. https://github.com/npub1zenn0/go-git-nostr/releases 8 | 9 | ```sh 10 | $ # You'll have to fix the version 11 | $ VERSION=v0.0.0 wget https://github.com/npub1zenn0/go-git-nostr/releases/download/$VERSION/git-{send,show}-nostr-$VERSION-linux-amd64.tar.gz 12 | $ tar -xzf .tar.gz 13 | ``` 14 | 15 | Note that there are _two_ binaries in the release: `git-send-nostr`, and `git-show-nostr`. You'll want both, but they are [not in the same zip](https://github.com/wangyoucao577/go-release-action/pull/107). 16 | 17 | ```sh 18 | $ git config --global nostr.relays "wss://nos.lol wss://relay.damus.io" # can have multiple, split by space 19 | $ git config --global nostr.secretkey 20 | ``` 21 | 22 | ## Usage 23 | 24 | If you then have the binaries in your `$PATH`, you can then use them like so. 25 | 26 | ```sh 27 | $ git config nostr.hashtag my-repo-name 28 | $ git show-nostr -h 29 | $ # outputs all patches for project "nostr-git-cli". 30 | $ git show-nostr -t nostr-git-cli -r wss://nos.lol # override relays to just wss://nos.lol 31 | 32 | $ git format-patch HEAD~ # This will show you what you're about to send as patch. 33 | 34 | $ # Dry run send a new patch. 35 | $ git send-nostr --dry-run HEAD~ -t nostr-git-cli -r wss://nos.lol -r wss://example.com 36 | 37 | $ # Apply a specific patch. 38 | $ git show-nostr -e "" -t nostr-git-cli -r wss://nos.lol | git am 39 | 40 | $ # From specific user 41 | $ git show-nostr -p "" -t nostr-git-cli -r wss://nos.lol | git am 42 | ``` 43 | 44 | See `git {show,send}-nostr -h` for more. 45 | 46 | ``` 47 | Usage: git-send-nostr 48 | 49 | Arguments: 50 | Commit hash 51 | 52 | Flags: 53 | -h, --help Show context-sensitive help. 54 | -r, --relay=RELAY,... Relay to broadcast to. Will use 'git config 55 | nostr.relays' by default.You can specify multiple 56 | times '-r wss://... -r wss://...' 57 | -d, --dry-run Dry run. Just print event to stdout instead of 58 | relaying. 59 | -s, --sec=STRING Secret key in hex. 60 | ``` 61 | 62 | ``` 63 | Usage: git-show-nostr 64 | 65 | Flags: 66 | -h, --help Show context-sensitive help. 67 | -r, --relay=RELAY,... Relay to broadcast to. Will use 'git config 68 | nostr.relays' by default.You can specify multiple 69 | times '-r wss://... -r wss://...' 70 | -t, --hashtag=STRING Hashtag (e.g. repo name) to search for. Will use 'git 71 | config nostr.hashtag' by default. 72 | -p, --user=STRING Show patches from particular user. 73 | nprofile/pubkey/npub. 74 | -e, --event-id=STRING Show patch from particular event. 75 | ``` 76 | 77 | ## Prior art 78 | 79 | http://git.jb55.com/git-nostr-tools/file/README.txt.html 80 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/npub1zenn0/nostr-git-cli 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/alecthomas/kong v0.7.1 7 | github.com/nbd-wtf/go-nostr v0.16.11 8 | ) 9 | 10 | require ( 11 | github.com/SaveTheRbtz/generic-sync-map-go v0.0.0-20220414055132-a37292614db8 // indirect 12 | github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect 13 | github.com/btcsuite/btcd/btcutil v1.1.3 // indirect 14 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect 15 | github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect 16 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 17 | github.com/gorilla/websocket v1.4.2 // indirect 18 | github.com/samber/lo v1.38.1 // indirect 19 | github.com/valyala/fastjson v1.6.3 // indirect 20 | golang.org/x/exp v0.0.0-20221106115401-f9659909a136 // indirect 21 | golang.org/x/net v0.9.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/SaveTheRbtz/generic-sync-map-go v0.0.0-20220414055132-a37292614db8 h1:Xa6tp8DPDhdV+k23uiTC/GrAYOe4IdyJVKtob4KW3GA= 2 | github.com/SaveTheRbtz/generic-sync-map-go v0.0.0-20220414055132-a37292614db8/go.mod h1:ihkm1viTbO/LOsgdGoFPBSvzqvx7ibvkMzYp3CgtHik= 3 | github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= 4 | github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0= 5 | github.com/alecthomas/kong v0.7.1 h1:azoTh0IOfwlAX3qN9sHWTxACE2oV8Bg2gAwBsMwDQY4= 6 | github.com/alecthomas/kong v0.7.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= 7 | github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= 8 | github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= 9 | github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= 10 | github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= 11 | github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= 12 | github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= 13 | github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= 14 | github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= 15 | github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= 16 | github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= 17 | github.com/btcsuite/btcd/btcutil v1.1.3 h1:xfbtw8lwpp0G6NwSHb+UE67ryTFHJAiNuipusjXSohQ= 18 | github.com/btcsuite/btcd/btcutil v1.1.3/go.mod h1:UR7dsSJzJUfMmFiiLlIrMq1lS9jh9EdCV7FStZSnpi0= 19 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 20 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 21 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 h1:KdUfX2zKommPRa+PD0sWZUyXe9w277ABlgELO7H04IM= 22 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 23 | github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= 24 | github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= 25 | github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= 26 | github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= 27 | github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= 28 | github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= 29 | github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= 30 | github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= 31 | github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= 32 | github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 33 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 35 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 36 | github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= 37 | github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= 38 | github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 39 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= 40 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 41 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 42 | github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= 43 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 44 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 45 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 46 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 47 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 48 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 49 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 50 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 51 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 52 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 53 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 54 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 55 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 56 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 57 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 58 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 59 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 60 | github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 61 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 62 | github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= 63 | github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= 64 | github.com/nbd-wtf/go-nostr v0.16.11 h1:nsWfG/+4D4KJwsWGk4kGLUXsMkqJCuSUnJbBnCUUbcA= 65 | github.com/nbd-wtf/go-nostr v0.16.11/go.mod h1:yvUvLkncMo1zNNytdL1KXnWWc46N2uUQDsBmjHgHYC0= 66 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 67 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 68 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 69 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 70 | github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= 71 | github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= 72 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 73 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 74 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 75 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 76 | github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= 77 | github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= 78 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 79 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 80 | github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= 81 | github.com/valyala/fastjson v1.6.3 h1:tAKFnnwmeMGPbwJ7IwxcTPCNr3uIzoIj3/Fh90ra4xc= 82 | github.com/valyala/fastjson v1.6.3/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= 83 | golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 84 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 85 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 86 | golang.org/x/exp v0.0.0-20221106115401-f9659909a136 h1:Fq7F/w7MAa1KJ5bt2aJ62ihqp9HDcRuyILskkpIAurw= 87 | golang.org/x/exp v0.0.0-20221106115401-f9659909a136/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= 88 | golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 89 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 90 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 91 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 92 | golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 93 | golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= 94 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 95 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 96 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 97 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 98 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 99 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 100 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 101 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 102 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 103 | golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 104 | golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 105 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 106 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 107 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 108 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 109 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 110 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 111 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 112 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 113 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 114 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 115 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 116 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 117 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 118 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 119 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 120 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 121 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 122 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 123 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 124 | -------------------------------------------------------------------------------- /src/apps/send/cmd/send.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/nbd-wtf/go-nostr" 10 | "github.com/nbd-wtf/go-nostr/nip19" 11 | "github.com/npub1zenn0/nostr-git-cli/src/internal/git" 12 | "github.com/samber/lo" 13 | "github.com/samber/lo/parallel" 14 | ) 15 | 16 | // Send a git patch to nostr relays. 17 | func Send(hash string, relays []string, sec string, dryRun bool) (string, error) { 18 | patch, err := git.Run("format-patch", "--stdout", hash) 19 | if err != nil { 20 | return "", fmt.Errorf("error getting patch: %w", err) 21 | } 22 | 23 | author, subject, err := git.ExtractAuthorSubject(patch) 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | relays, err = git.GetRelays(relays) 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | sec, err = git.GetSecKey(sec) 34 | if err != nil { 35 | return "", err 36 | } 37 | 38 | evt := mkEvent(patch, author, subject) 39 | 40 | err = evt.Sign(sec) 41 | if err != nil { 42 | return "", fmt.Errorf("error signing message: %w", err) 43 | } 44 | 45 | if dryRun { 46 | evtJson, _ := evt.MarshalJSON() 47 | fmt.Printf("%v\n", string(evtJson)) 48 | log.Println("this was a dry run") 49 | return "", nil 50 | } 51 | 52 | goodRelays := publishAll(relays, evt) 53 | if len(goodRelays) == 0 { 54 | return "", fmt.Errorf("failed to publish to any relays") 55 | } 56 | 57 | evtOut, err := nip19.EncodeEvent(evt.GetID(), goodRelays, evt.PubKey) 58 | if err != nil { 59 | return "", fmt.Errorf("event published as %v, but failed to encode event %w", evt.ID, err) 60 | } 61 | return evtOut, nil 62 | } 63 | 64 | func publishAll(relays []string, evt nostr.Event) []string { 65 | rs := parallel.Map(relays, func(r string, _ int) string { 66 | err := publish(r, evt) 67 | if err != nil { 68 | log.Println(fmt.Errorf("warning: %w", err)) 69 | return "" 70 | } 71 | return r 72 | }) 73 | return lo.Compact(rs) 74 | } 75 | 76 | func publish(relay string, evt nostr.Event) error { 77 | const connTimeout = 30 * time.Second 78 | 79 | ctx, cancel := context.WithTimeout(context.Background(), connTimeout) 80 | defer cancel() 81 | 82 | conn, err := nostr.RelayConnect(ctx, relay) 83 | if err != nil { 84 | return fmt.Errorf("error connecting to relay %v: %w", relay, err) 85 | } 86 | status, err := conn.Publish(conn.ConnectionContext, evt) 87 | if err != nil { 88 | return fmt.Errorf("error publishing (relay=%v;status=%v): %w", relay, status, err) 89 | } 90 | return nil 91 | } 92 | 93 | func mkEvent(content string, author string, subject string) nostr.Event { 94 | const kind = 19691228 95 | hashtag, _ := git.Run("config", "nostr.hashtag") 96 | 97 | tags := nostr.Tags{ 98 | nostr.Tag{"author", author}, 99 | nostr.Tag{"subject", subject}, 100 | } 101 | if hashtag != "" { 102 | tags = append(tags, nostr.Tag{"t", hashtag}) 103 | } 104 | return nostr.Event{ 105 | CreatedAt: time.Now().UTC(), 106 | Kind: kind, 107 | Tags: tags, 108 | Content: content, 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/apps/send/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/alecthomas/kong" 8 | "github.com/npub1zenn0/nostr-git-cli/src/apps/send/cmd" 9 | ) 10 | 11 | var CLI struct { 12 | Relay []string `short:"r" help:"Relay to broadcast to. Will use 'git config nostr.relays' by default.You can specify multiple times '-r wss://... -r wss://...'"` 13 | 14 | DryRun bool `short:"d" help:"Dry run. Just print event to stdout instead of relaying."` 15 | 16 | // tag? 17 | SecretKey string `short:"s" name:"sec" help:"Secret key" type:"string"` 18 | 19 | // type: can autocast? 20 | Commit string `arg:"" help:"Commit hash" type:"string"` 21 | } 22 | 23 | func main() { 24 | ctx := kong.Parse(&CLI) 25 | switch ctx.Command() { 26 | case "": 27 | id, err := cmd.Send(CLI.Commit, CLI.Relay, CLI.SecretKey, CLI.DryRun) 28 | if err != nil { 29 | log.Fatal(err) 30 | } else if !CLI.DryRun { 31 | fmt.Println(id) 32 | } 33 | default: 34 | log.Fatal("no such command") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/apps/show/cmd/show.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "time" 9 | 10 | "github.com/nbd-wtf/go-nostr" 11 | "github.com/nbd-wtf/go-nostr/nip19" 12 | "github.com/npub1zenn0/nostr-git-cli/src/internal/git" 13 | "github.com/samber/lo" 14 | "github.com/samber/lo/parallel" 15 | ) 16 | 17 | func Show(relays []string, hashtag string, user string, eventID string) (string, error) { 18 | relays, err := git.GetRelays(relays) 19 | if err != nil { 20 | return "", fmt.Errorf("error in relays: %w", err) 21 | } 22 | 23 | hashtag, err = git.GetHashtag(hashtag) 24 | if err != nil { 25 | return "", fmt.Errorf("error in hashtag: %w", err) 26 | } 27 | 28 | pubkey, autoRelays, err := decodeUser(user) 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | evtID, evtRelays, err := decodeEventID(eventID) 34 | if err != nil { 35 | return "", err 36 | } 37 | 38 | // The nprofile/nevent included relays will probably always be useful enough. 39 | allRelays := append(relays, evtRelays...) 40 | allRelays = append(allRelays, autoRelays...) 41 | allRelays = lo.Uniq(allRelays) 42 | 43 | evts := queryAll(allRelays, hashtag, pubkey, evtID) 44 | 45 | patches := lo.Map(evts, func(e *nostr.Event, _ int) string { 46 | return e.Content 47 | }) 48 | 49 | return strings.Join(patches, "\n\n"), nil 50 | } 51 | 52 | func decodeEventID(eventID string) (string, []string, error) { 53 | if !strings.HasPrefix(eventID, "nevent") { 54 | return eventID, nil, nil 55 | } 56 | prefix, nevent, err := nip19.Decode(eventID) 57 | if err != nil { 58 | return "", nil, fmt.Errorf("error decoding eventID: %w", err) 59 | } 60 | if prefix != "nevent" { 61 | return "", nil, fmt.Errorf("received event with unexpected prefix: %v", prefix) 62 | } 63 | evt := nevent.(nostr.EventPointer) 64 | return evt.ID, unsplit(evt.Relays), nil 65 | } 66 | 67 | func decodeUser(user string) (string, []string, error) { 68 | if !strings.HasPrefix(user, "npub") && !strings.HasPrefix(user, "nprofile") { 69 | // Assume it's already in pubkey hex format. 70 | return user, nil, nil 71 | } 72 | prefix, profile, err := nip19.Decode(user) 73 | if err != nil { 74 | return "", nil, fmt.Errorf("error decoding user: %w", err) 75 | } 76 | switch prefix { 77 | case "npub": 78 | return profile.(string), nil, nil 79 | 80 | case "nprofile": 81 | p := profile.(nostr.ProfilePointer) 82 | return p.PublicKey, unsplit(p.Relays), nil 83 | } 84 | return "", nil, fmt.Errorf("received pubkey with unexpected prefix: %v", prefix) 85 | } 86 | 87 | func queryAll( 88 | relays []string, 89 | hashtag string, 90 | userPubkey string, 91 | eventID string, 92 | ) []*nostr.Event { 93 | evts := parallel.Map(relays, func(r string, _ int) []*nostr.Event { 94 | evts, err := query(r, hashtag, userPubkey, eventID) 95 | if err != nil { 96 | log.Printf("failed query %v: %v\n", r, err) 97 | return nil 98 | } 99 | return evts 100 | }) 101 | flatEvts := lo.Flatten(evts) 102 | return lo.UniqBy(flatEvts, func(e *nostr.Event) string { 103 | return e.ID 104 | }) 105 | } 106 | 107 | func query( 108 | relay string, 109 | hashtag string, 110 | userPubkey string, 111 | eventID string, 112 | ) ([]*nostr.Event, error) { 113 | const limit = 20 114 | const kinds = 19691228 115 | const connTimeout = 30 * time.Second 116 | 117 | ctx, cancel := context.WithTimeout(context.Background(), connTimeout) 118 | defer cancel() 119 | conn, err := nostr.RelayConnect(ctx, relay) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | var authors []string 125 | if userPubkey != "" { 126 | authors = append(authors, userPubkey) 127 | } 128 | var ids []string 129 | if eventID != "" { 130 | ids = append(ids, eventID) 131 | } 132 | ctx, cancel = context.WithTimeout(context.Background(), connTimeout) 133 | defer cancel() 134 | evts, err := conn.QuerySync(ctx, nostr.Filter{ 135 | Kinds: []int{kinds}, 136 | Authors: authors, 137 | Limit: limit, 138 | IDs: ids, 139 | Tags: nostr.TagMap{ 140 | "t": []string{hashtag}, 141 | }, 142 | }) 143 | if err != nil { 144 | return nil, fmt.Errorf("error in query: %w", err) 145 | } 146 | return evts, nil 147 | } 148 | 149 | func unsplit(arr []string) []string { 150 | return lo.FlatMap(arr, func(a string, _ int) []string { 151 | return strings.Split(a, ",") 152 | }) 153 | } 154 | -------------------------------------------------------------------------------- /src/apps/show/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/alecthomas/kong" 8 | "github.com/npub1zenn0/nostr-git-cli/src/apps/show/cmd" 9 | ) 10 | 11 | var CLI struct { 12 | Relay []string `short:"r" help:"Relay to broadcast to. Will use 'git config nostr.relays' by default.You can specify multiple times '-r wss://... -r wss://...'"` 13 | 14 | Hashtag string `short:"t" help:"Hashtag (e.g. repo name) to search for. Will use 'git config nostr.hashtag' by default."` 15 | 16 | User string `short:"p" help:"Show patches from particular user. nprofile/pubkey/npub."` 17 | 18 | EventID string `short:"e" help:"Show patch from particular event."` 19 | } 20 | 21 | func main() { 22 | ctx := kong.Parse(&CLI) 23 | switch ctx.Command() { 24 | default: 25 | patches, err := cmd.Show(CLI.Relay, CLI.Hashtag, CLI.User, CLI.EventID) 26 | if err != nil { 27 | log.Fatalln(err) 28 | } 29 | fmt.Println(patches) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/internal/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | func Run(cmd ...string) (string, error) { 11 | v, err := exec.Command("git", cmd...).Output() 12 | return strings.TrimSpace(string(v)), err 13 | } 14 | 15 | var subjectRegex = regexp.MustCompile(`(?m)^Subject: (.*)$`) 16 | var authorRegex = regexp.MustCompile(`(?m)^From: (.*)$`) 17 | 18 | // ExtractAuthorSubject from a git patch. 19 | func ExtractAuthorSubject(patch string) (string, string, error) { 20 | subjectMatch := subjectRegex.FindStringSubmatch(patch) 21 | if len(subjectMatch) == 0 { 22 | return "", "", fmt.Errorf("error getting subject") 23 | } 24 | subject := subjectMatch[1] 25 | 26 | authorMatch := authorRegex.FindStringSubmatch(patch) 27 | if len(authorMatch) == 0 { 28 | return "", "", fmt.Errorf("error getting author") 29 | } 30 | author := authorMatch[1] 31 | return author, subject, nil 32 | } 33 | 34 | func GetSecKey(sec string) (string, error) { 35 | if sec == "" { 36 | _sec, err := Run("config", "nostr.secretkey") 37 | if err != nil { 38 | return "", fmt.Errorf("secret key not set. Use one of\n\t-s \n\tgit config --global nostr.secretkey \n%w", err) 39 | } 40 | sec = _sec 41 | } 42 | return strings.TrimSpace(sec), nil 43 | } 44 | 45 | func GetRelays(relays []string) ([]string, error) { 46 | if len(relays) == 0 { 47 | _relays, err := Run("config", "nostr.relays") 48 | relays = strings.Split(_relays, " ") 49 | if err != nil || len(relays) == 0 { 50 | return nil, fmt.Errorf("relay not set, not relaying. Use one of\n\t-r wss://relay.damus.io\n\tgit config --global nostr.relays wss://relay.damus.io\n%w", err) 51 | } 52 | } 53 | return relays, nil 54 | } 55 | 56 | func GetHashtag(hashtag string) (string, error) { 57 | if hashtag == "" { 58 | _hashtag, err := Run("config", "nostr.hashtag") 59 | if err != nil || _hashtag == "" { 60 | return "", fmt.Errorf("error getting hashtag: %w", err) 61 | } 62 | hashtag = _hashtag 63 | } 64 | return hashtag, nil 65 | } 66 | -------------------------------------------------------------------------------- /src/internal/git/git_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func Test_extractAuthorSubject(t *testing.T) { 10 | type args struct { 11 | patch string 12 | } 13 | tests := []struct { 14 | name string 15 | args args 16 | want string 17 | want1 string 18 | wantErr bool 19 | }{ 20 | { 21 | args: args{ 22 | patch: `From 7e9591868e3985eeeddbfde3cd03901ad6616eef Mon Sep 17 00:00:00 2001 23 | From: Testing 24 | Date: Thu, 13 Apr 2023 23:38:57 +0200 25 | Subject: [PATCH] test 26 | 27 | alalalala 28 | `, 29 | }, 30 | name: "basic", 31 | want: "Testing ", 32 | want1: "[PATCH] test", 33 | }, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | got, got1, err := ExtractAuthorSubject(tt.args.patch) 38 | if (err != nil) != tt.wantErr { 39 | t.Errorf("extractAuthorSubject() error = %v, wantErr %v", err, tt.wantErr) 40 | return 41 | } 42 | if got != tt.want { 43 | t.Errorf("extractAuthorSubject() got = %v, want %v", got, tt.want) 44 | } 45 | if got1 != tt.want1 { 46 | t.Errorf("extractAuthorSubject() got1 = %v, want %v", got1, tt.want1) 47 | } 48 | }) 49 | } 50 | } 51 | 52 | func Test_getRelays(t *testing.T) { 53 | type args struct { 54 | relays []string 55 | } 56 | tests := []struct { 57 | name string 58 | args args 59 | want []string 60 | wantErr bool 61 | }{ 62 | { 63 | name: "multiple", 64 | args: args{ 65 | relays: []string{"relay1", "relay2"}, 66 | }, 67 | want: []string{"relay1", "relay2"}, 68 | }, 69 | } 70 | for _, tt := range tests { 71 | t.Run(tt.name, func(t *testing.T) { 72 | Run("config", "nostr.relays", strings.Join(tt.args.relays, " ")) 73 | got, err := GetRelays([]string{}) 74 | if (err != nil) != tt.wantErr { 75 | t.Errorf("getRelays() error = %v, wantErr %v", err, tt.wantErr) 76 | return 77 | } 78 | if !reflect.DeepEqual(got, tt.want) { 79 | t.Errorf("getRelays() = %v, want %v", got, tt.want) 80 | } 81 | }) 82 | } 83 | } 84 | --------------------------------------------------------------------------------