├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── app.go ├── cmd └── git-str │ └── main.go ├── download.go ├── go.mod ├── go.sum ├── helpers.go ├── init.go └── send.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: build cli for all platforms 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | make-release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/create-release@latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | with: 19 | tag_name: ${{ github.ref }} 20 | release_name: ${{ github.ref }} 21 | build-all-for-all: 22 | runs-on: ubuntu-latest 23 | needs: 24 | - make-release 25 | strategy: 26 | matrix: 27 | goos: [linux, freebsd, darwin, windows] 28 | goarch: [amd64, arm64] 29 | exclude: 30 | - goarch: arm64 31 | goos: windows 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: wangyoucao577/go-release-action@v1.40 35 | with: 36 | project_path: cmd/git-str 37 | binary_name: git-str 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | goos: ${{ matrix.goos }} 40 | goarch: ${{ matrix.goarch }} 41 | overwrite: true 42 | md5sum: false 43 | sha256sum: false 44 | compress_assets: true 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | git-nostr-send 2 | git-nostr-show -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2023 npub1zenn0 4 | Copyright © 2024 fiatjaf 5 | 6 | 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: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | 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. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitstr 2 | 3 | Send and receive git patches over Nostr, using [NIP-34](https://github.com/nostr-protocol/nips/pull/997). 4 | 5 | ## How to install 6 | 7 | Do `go install github.com/fiatjaf/gitstr/cmd/git-str@latest` if you have Go or [download a binary](https://github.com/fiatjaf/gitstr/releases). 8 | 9 | ### Confirm the Installation Location 10 | 11 | ```bash 12 | ls $HOME/go/bin 13 | ``` 14 | 15 | If git-str is in $HOME/go/bin, but it’s still not found globally, add $HOME/go/bin to your PATH: 16 | 17 | ```bash 18 | echo 'export PATH=$PATH:$HOME/go/bin' >> ~/.bashrc 19 | ``` 20 | 21 | ```bash 22 | source ~/.bashrc 23 | ``` 24 | 25 | Now try again: 26 | 27 | ```bash 28 | git-str --help 29 | ``` 30 | 31 | or 32 | 33 | ```bash 34 | git str 35 | ``` 36 | 37 | ## How to receive patches 38 | 39 | If you want to receive patches in our repo, call `git str init -r [-r ...]`, this will ask you a bunch of questions (you can also answer them using flags and not be asked, see `git str init --help`) and then it will announce your repository to the relays specified with `-r`. 40 | 41 | After someone has sent you a patch you'll be able to call `git str download` and fetch all patches. They will be stored in the `.git/str/patches/` directory. You can also pass arguments to `git str download`, like an `nevent1...` code or a `npub1...` code, to download only patches narrowed by these arguments. 42 | 43 | After that you can call `git am -i ` to apply the patch. 44 | 45 | ## How to send patches 46 | 47 | First you need to know the `naddr1...` code that corresponds to the target upstream repository you're sending the patch to. Until someone makes an explorer of git repositories or something like that, you'll have to get that manually from the repository owner. 48 | 49 | Then call `git send ` (you can use `HEAD^` for the last commit and other git tricks here). You'll be asked some questions (which you can also answer with flags, see `git str send --help`) and the patch will be sent. You can also give a path to a patch file generated with `git format-patch` too instead. 50 | 51 | ### Sending patches to repositories that haven't announced themselves 52 | 53 | You can pass `--dangling` to `git str send` and that will happen. Later anyone can download that patch by specifying its `nevent1` code on `git str download `. 54 | 55 | ## Contributing to this repository 56 | 57 | Send your patches to `naddr1qqrxw6t5wd68yqg5waehxw309aex2mrp0yhxgctdw4eju6t0qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpzemhxue69uhhyetvv9ujuurjd9kkzmpwdejhgq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmejeaalw2`. 58 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package gitstr 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/nbd-wtf/go-nostr" 7 | "github.com/urfave/cli/v3" 8 | ) 9 | 10 | var pool *nostr.SimplePool 11 | 12 | const ( 13 | RepoAnnouncementKind = 30617 14 | PatchKind = 1617 15 | IssueKind = 1621 16 | ReplyKind = 1622 17 | ) 18 | 19 | var App = &cli.Command{ 20 | Name: "git str", 21 | Description: "NIP-34 git nostr helper", 22 | Suggest: true, 23 | UseShortOptionHandling: true, 24 | Before: func(ctx context.Context, c *cli.Command) error { 25 | pool = nostr.NewSimplePool(ctx) 26 | return nil 27 | }, 28 | Commands: []*cli.Command{ 29 | initRepo, 30 | download, 31 | send, 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /cmd/git-str/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/fiatjaf/gitstr" 9 | ) 10 | 11 | func main() { 12 | if err := gitstr.App.Run(context.Background(), os.Args); err != nil { 13 | fmt.Println(err) 14 | os.Exit(1) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /download.go: -------------------------------------------------------------------------------- 1 | package gitstr 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "slices" 9 | "strings" 10 | "time" 11 | 12 | "github.com/fatih/color" 13 | "github.com/nbd-wtf/go-nostr" 14 | "github.com/nbd-wtf/go-nostr/nip19" 15 | "github.com/urfave/cli/v3" 16 | ) 17 | 18 | var download = &cli.Command{ 19 | Name: "download", 20 | Usage: "", 21 | Description: "", 22 | Flags: []cli.Flag{ 23 | &cli.StringSliceFlag{ 24 | Name: "relay", 25 | Aliases: []string{"r"}, 26 | }, 27 | &cli.IntFlag{ 28 | Name: "limit", 29 | Aliases: []string{"l"}, 30 | Value: 15, 31 | }, 32 | }, 33 | Action: func(ctx context.Context, c *cli.Command) error { 34 | id := getRepositoryID() 35 | pk := getRepositoryPublicKey() 36 | if pk == "" || id == "" { 37 | logf("no repository id and pubkey found on `git config`, this command will only work with specific naddr or nevent patches.\n") 38 | } 39 | 40 | limit := c.Int("limit") 41 | relays := append(getPatchRelays(), c.StringSlice("relay")...) 42 | 43 | // patches we will try to browse -- if given an author we try to get all their patches targeting this repo, 44 | // if given an event pointer we will try to fetch that patch specifically and so on, if given nothing we will 45 | // list the latest patches available to this repository 46 | items := c.Args().Slice() 47 | if len(items) == 0 { 48 | items = []string{""} 49 | } 50 | 51 | for _, arg := range items { 52 | filter := nostr.Filter{ 53 | Limit: int(limit), 54 | Kinds: []int{PatchKind}, 55 | Tags: nostr.TagMap{}, 56 | } 57 | relays := slices.Clone(relays) 58 | 59 | if arg != "" { 60 | prefix, data, err := nip19.Decode(arg) 61 | if err != nil { 62 | logf("invalid argument '%s': %s\n", arg, err) 63 | continue 64 | } 65 | 66 | switch prefix { 67 | case "npub": 68 | filter.Authors = append(filter.Authors, data.(string)) 69 | filter.Tags["a"] = []string{fmt.Sprintf("%d:%s:%s", RepoAnnouncementKind, pk, id)} 70 | case "nprofile": 71 | pp := data.(nostr.ProfilePointer) 72 | filter.Authors = append(filter.Authors, pp.PublicKey) 73 | filter.Tags["a"] = []string{fmt.Sprintf("%d:%s:%s", RepoAnnouncementKind, pk, id)} 74 | relays = append(relays, pp.Relays...) 75 | case "nevent": 76 | ep := data.(nostr.EventPointer) 77 | if ep.Kind != 0 && ep.Kind != PatchKind { 78 | logf("invalid argument %s: expected an encoded kind %d or nothing\n", arg, PatchKind) 79 | continue 80 | } 81 | filter = nostr.Filter{IDs: []string{ep.ID}} 82 | relays = append(relays, ep.Relays...) 83 | case "naddr": 84 | ep := data.(nostr.EntityPointer) 85 | if ep.Kind != RepoAnnouncementKind { 86 | logf("invalid argument %s: expected an encoded kind %d\n", arg, RepoAnnouncementKind) 87 | continue 88 | } 89 | 90 | filter = nostr.Filter{ 91 | Limit: int(limit), 92 | Kinds: []int{PatchKind}, 93 | Tags: nostr.TagMap{ 94 | "a": []string{fmt.Sprintf("%d:%s:%s", RepoAnnouncementKind, ep.PublicKey, ep.Identifier)}, 95 | }, 96 | } 97 | relays = append(relays, ep.Relays...) 98 | default: 99 | continue 100 | } 101 | } 102 | 103 | gitRoot, err := git("rev-parse", "--show-toplevel") 104 | base := filepath.Join(gitRoot, ".git/str/patches") 105 | if err != nil { 106 | return fmt.Errorf("failed to find git root: %w", err) 107 | } else if err := os.MkdirAll(base, 0755); err != nil { 108 | return fmt.Errorf("failed to create .git/str directory") 109 | } 110 | 111 | for ie := range pool.SubManyEose(ctx, relays, nostr.Filters{filter}) { 112 | nevent, _ := nip19.EncodeEvent(ie.ID, nil, "") 113 | npub, _ := nip19.EncodePublicKey(ie.PubKey) 114 | subjectMatch := subjectRegex.FindStringSubmatch(ie.Event.Content) 115 | if len(subjectMatch) == 0 { 116 | continue 117 | } 118 | subject := subjectMatch[1] 119 | subject = strings.ReplaceAll(strings.ReplaceAll(subject, "/", "_"), "'", "") 120 | fileName := base + "/" + fmt.Sprintf("%s [%s] %s", 121 | ie.CreatedAt.Time().Format(time.DateOnly), nevent[65:], subject) 122 | if _, err := os.Stat(fileName); os.IsNotExist(err) { 123 | logf("- downloaded patch %s from %s, saved as '%s'\n", 124 | ie.Event.ID, npub, color.New(color.Underline).Sprint(fileName)) 125 | if err := os.WriteFile(fileName, []byte(ie.Event.Content), 0644); err != nil { 126 | return fmt.Errorf("failed to write '%s': %w", fileName, err) 127 | } 128 | os.Chtimes(fileName, time.Time{}, ie.Event.CreatedAt.Time()) 129 | } 130 | } 131 | } 132 | 133 | return nil 134 | }, 135 | } 136 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fiatjaf/gitstr 2 | 3 | go 1.21 4 | 5 | toolchain go1.21.4 6 | 7 | require ( 8 | github.com/chzyer/readline v1.5.1 9 | github.com/fatih/color v1.16.0 10 | github.com/nbd-wtf/go-nostr v0.29.0 11 | github.com/urfave/cli/v3 v3.0.0-alpha8 12 | ) 13 | 14 | require ( 15 | github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect 16 | github.com/btcsuite/btcd/btcutil v1.1.3 // indirect 17 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect 18 | github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect 19 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 20 | github.com/gobwas/httphead v0.1.0 // indirect 21 | github.com/gobwas/pool v0.2.1 // indirect 22 | github.com/gobwas/ws v1.2.0 // indirect 23 | github.com/josharian/intern v1.0.0 // indirect 24 | github.com/mailru/easyjson v0.7.7 // indirect 25 | github.com/mattn/go-colorable v0.1.13 // indirect 26 | github.com/mattn/go-isatty v0.0.20 // indirect 27 | github.com/puzpuzpuz/xsync/v3 v3.0.2 // indirect 28 | github.com/tidwall/gjson v1.14.4 // indirect 29 | github.com/tidwall/match v1.1.1 // indirect 30 | github.com/tidwall/pretty v1.2.0 // indirect 31 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 32 | golang.org/x/crypto v0.7.0 // indirect 33 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect 34 | golang.org/x/net v0.9.0 // indirect 35 | golang.org/x/sys v0.16.0 // indirect 36 | golang.org/x/text v0.9.0 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= 2 | github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= 3 | github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= 4 | github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY= 5 | github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= 6 | github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= 7 | github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= 8 | github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= 9 | github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= 10 | github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= 11 | github.com/btcsuite/btcd/btcutil v1.1.3 h1:xfbtw8lwpp0G6NwSHb+UE67ryTFHJAiNuipusjXSohQ= 12 | github.com/btcsuite/btcd/btcutil v1.1.3/go.mod h1:UR7dsSJzJUfMmFiiLlIrMq1lS9jh9EdCV7FStZSnpi0= 13 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 14 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 15 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 h1:KdUfX2zKommPRa+PD0sWZUyXe9w277ABlgELO7H04IM= 16 | github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= 17 | github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= 18 | github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= 19 | github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= 20 | github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= 21 | github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= 22 | github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= 23 | github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= 24 | github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= 25 | github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= 26 | github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= 27 | github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= 28 | github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= 29 | github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= 30 | github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= 31 | github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= 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/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 44 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 45 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 46 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 47 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 48 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 49 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 50 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 51 | github.com/gobwas/ws v1.2.0 h1:u0p9s3xLYpZCA1z5JgCkMeB34CKCMMQbM+G8Ii7YD0I= 52 | github.com/gobwas/ws v1.2.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= 53 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 54 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 55 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 56 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 57 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 58 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 59 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 60 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 61 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 62 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 63 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 64 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 65 | github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 66 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 67 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 68 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 69 | github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= 70 | github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= 71 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 72 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 73 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 74 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 75 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 76 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 77 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 78 | github.com/nbd-wtf/go-nostr v0.29.0 h1:kpAZ9oQPFeB9aJPloCsGS+UCNDPyN0jkt7sxlxxZock= 79 | github.com/nbd-wtf/go-nostr v0.29.0/go.mod h1:tiKJY6fWYSujbTQb201Y+IQ3l4szqYVt+fsTnsm7FCk= 80 | github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 81 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 82 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 83 | github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 84 | github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= 85 | github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= 86 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 87 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 88 | github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 89 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 90 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 91 | github.com/puzpuzpuz/xsync/v3 v3.0.2 h1:3yESHrRFYr6xzkz61LLkvNiPFXxJEAABanTQpKbAaew= 92 | github.com/puzpuzpuz/xsync/v3 v3.0.2/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= 93 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 94 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 95 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 96 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 97 | github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= 98 | github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= 99 | github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 100 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 101 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 102 | github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= 103 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 104 | github.com/urfave/cli/v3 v3.0.0-alpha8 h1:H+qxFPoCkGzdF8KUMs2fEOZl5io/1QySgUiGfar8occ= 105 | github.com/urfave/cli/v3 v3.0.0-alpha8/go.mod h1:0kK/RUFHyh+yIKSfWxwheGndfnrvYSmYFVeKCh03ZUc= 106 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 107 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 108 | golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 109 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 110 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 111 | golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= 112 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 113 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= 114 | golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 115 | golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 116 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 117 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 118 | golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 119 | golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 120 | golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= 121 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 122 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 123 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 124 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 125 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 126 | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 127 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 128 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 129 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 130 | golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 131 | golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 132 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 133 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 134 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 135 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 136 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 137 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 138 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 139 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 140 | golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= 141 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 142 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 143 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 144 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 145 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 146 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 147 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 148 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 149 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 150 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 151 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 152 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 153 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 154 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 155 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 156 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 157 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 158 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 159 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 160 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package gitstr 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "regexp" 10 | "strings" 11 | "time" 12 | 13 | "github.com/chzyer/readline" 14 | "github.com/fatih/color" 15 | "github.com/nbd-wtf/go-nostr" 16 | "github.com/nbd-wtf/go-nostr/nip05" 17 | "github.com/nbd-wtf/go-nostr/nip19" 18 | "github.com/nbd-wtf/go-nostr/nip46" 19 | "github.com/nbd-wtf/go-nostr/nip49" 20 | "github.com/urfave/cli/v3" 21 | ) 22 | 23 | var subjectRegex = regexp.MustCompile(`(?m)^Subject: (.*)$`) 24 | 25 | func logf(str string, args ...any) { 26 | fmt.Fprintf(os.Stderr, fmt.Sprintf(str, args...)) 27 | } 28 | 29 | func isPiped() bool { 30 | stat, _ := os.Stdin.Stat() 31 | return stat.Mode()&os.ModeCharDevice == 0 32 | } 33 | 34 | func gatherSecretKeyOrBunker(ctx context.Context, c *cli.Command) ( 35 | bunker *nip46.BunkerClient, 36 | key string, 37 | encrypted bool, 38 | err error, 39 | ) { 40 | askToStore := false 41 | storeWithoutAsking := false 42 | secOrBunker := c.String("sec") 43 | 44 | defer func() { 45 | if err == nil { 46 | if storeWithoutAsking || (askToStore && confirm("store the secret key on git config? ")) { 47 | git("config", "--local", "str.auth", secOrBunker) 48 | } 49 | } 50 | }() 51 | 52 | clientKey, _ := git("config", "str.nip46clientsecret") 53 | if clientKey == "" { 54 | clientKey = nostr.GeneratePrivateKey() 55 | git("config", "--global", "str.nip46clientsecret", clientKey) 56 | } 57 | 58 | if secOrBunker == "" { 59 | secOrBunker, _ = git("config", "--local", "str.secretkey") // TODO: remove this after a while 60 | } 61 | if secOrBunker == "" { 62 | secOrBunker, _ = git("config", "--local", "str.bunker") // TODO: remove this after a while 63 | } 64 | if secOrBunker == "" { 65 | secOrBunker, _ = git("config", "--local", "str.auth") 66 | } else { 67 | git("config", "--local", "str.auth", secOrBunker) // TODO: remove this after a while 68 | } 69 | 70 | if secOrBunker == "" { 71 | secOrBunker, _ = ask("input secret key (hex, nsec, ncryptsec or bunker): ", "", func(answer string) bool { 72 | switch { 73 | case nostr.IsValid32ByteHex(answer): 74 | askToStore = true 75 | return false 76 | case strings.HasPrefix(answer, "nsec1"): 77 | askToStore = true 78 | return false 79 | case strings.HasPrefix(answer, "ncryptsec1"): 80 | storeWithoutAsking = true 81 | return false 82 | case nip46.IsValidBunkerURL(answer): 83 | storeWithoutAsking = true 84 | return false 85 | case nip05.IsValidIdentifier(answer): 86 | storeWithoutAsking = true 87 | return false 88 | default: 89 | return true 90 | } 91 | }) 92 | } 93 | 94 | if _, _, err := nip05.ParseIdentifier(secOrBunker); err == nil || nip46.IsValidBunkerURL(secOrBunker) { 95 | clientPublicKey, _ := nostr.GetPublicKey(clientKey) 96 | logf(color.YellowString("connecting to bunker as %s...\n"), clientPublicKey) 97 | bunker, err := nip46.ConnectBunker(ctx, clientKey, secOrBunker, nil, func(s string) { 98 | fmt.Fprintf(os.Stderr, color.CyanString("[nip46]: open the following URL: %s"), s) 99 | }) 100 | if bunker != nil { 101 | git("config", "--local", "str.auth", secOrBunker) 102 | } 103 | return bunker, "", false, err 104 | } 105 | 106 | if strings.HasPrefix(secOrBunker, "ncryptsec1") { 107 | return nil, secOrBunker, true, nil 108 | } else if strings.HasPrefix(secOrBunker, "nsec1") { 109 | _, hex, err := nip19.Decode(secOrBunker) 110 | if err != nil { 111 | return nil, "", false, fmt.Errorf("invalid nsec: %w", err) 112 | } 113 | return nil, hex.(string), false, nil 114 | } else if ok := nostr.IsValid32ByteHex(secOrBunker); !ok { 115 | return nil, "", false, fmt.Errorf("invalid secret key") 116 | } 117 | 118 | return nil, "", false, fmt.Errorf("couldn't gather secret key") 119 | } 120 | 121 | func getPatchRelays() []string { 122 | str, _ := git("config", "str.patches-relay") 123 | spl := strings.Split(str, " ") 124 | res := make([]string, 0, len(spl)) 125 | for _, url := range spl { 126 | if url != "" { 127 | res = append(res, url) 128 | } 129 | } 130 | return res 131 | } 132 | 133 | func getRepositoryID() string { 134 | id, err := git("config", "--local", "str.id") 135 | if err != nil { 136 | return "" 137 | } 138 | return id 139 | } 140 | 141 | func getRepositoryPublicKey() string { 142 | pk, _ := git("config", "str.publickey") 143 | if nostr.IsValidPublicKey(pk) { 144 | return pk 145 | } 146 | return "" 147 | } 148 | 149 | func git(args ...string) (string, error) { 150 | cmd := exec.Command("git", args...) 151 | stderr := &bytes.Buffer{} 152 | cmd.Stderr = stderr 153 | v, err := cmd.Output() 154 | if err != nil { 155 | err = fmt.Errorf("%w (called %v): %s", err, cmd.Args, stderr.String()) 156 | } 157 | return strings.TrimSpace(string(v)), err 158 | } 159 | 160 | func sprintRepository(repo *nostr.Event) string { 161 | res := "" 162 | npub, _ := nip19.EncodePublicKey(repo.PubKey) 163 | res += "\n author: " + npub 164 | res += "\n id: " + (*repo.Tags.GetFirst([]string{"d", ""}))[1] 165 | res += "\n" 166 | // TODO: more stuff 167 | return color.New(color.Bold).Sprint(res) 168 | } 169 | 170 | func sprintPatch(patch *nostr.Event) string { 171 | res := "" 172 | npub, _ := nip19.EncodePublicKey(patch.PubKey) 173 | res += "\n id: " + patch.ID 174 | res += "\n author: " + npub 175 | 176 | aTag := patch.Tags.GetFirst([]string{"a", ""}) 177 | if aTag != nil { 178 | target := strings.Split((*aTag)[1], ":") 179 | targetId := target[2] 180 | targetNpub, _ := nip19.EncodePublicKey(target[1]) 181 | res += "\n target repo: " + targetId 182 | res += "\n target author: " + targetNpub 183 | } 184 | // TODO: more stuff 185 | 186 | res = color.New(color.Bold).Sprint(res) 187 | res += "\n\n" + patch.Content 188 | // TODO: colors 189 | return res 190 | } 191 | 192 | func humanDate(createdAt nostr.Timestamp) string { 193 | ts := createdAt.Time() 194 | now := time.Now() 195 | if ts.Before(now.AddDate(0, -9, 0)) { 196 | return ts.UTC().Format("02 Jan 2006") 197 | } else if ts.Before(now.AddDate(0, 0, -6)) { 198 | return ts.UTC().Format("Jan _2") 199 | } else { 200 | return ts.UTC().Format("Mon, Jan _2 15:04 UTC") 201 | } 202 | } 203 | 204 | func confirm(msg string) bool { 205 | var res bool 206 | ask(msg+"(y/n) ", "", func(answer string) bool { 207 | switch answer { 208 | case "y", "yes": 209 | res = true 210 | return false 211 | case "n", "no": 212 | res = false 213 | return false 214 | default: 215 | return true 216 | } 217 | }) 218 | return res 219 | } 220 | 221 | func promptDecrypt(ncryptsec1 string) (string, error) { 222 | for i := 1; i < 4; i++ { 223 | var attemptStr string 224 | if i > 1 { 225 | attemptStr = fmt.Sprintf(" [%d/3]", i) 226 | } 227 | password, err := askPassword("type the password to decrypt your secret key"+attemptStr+": ", nil) 228 | if err != nil { 229 | return "", err 230 | } 231 | sec, err := nip49.Decrypt(ncryptsec1, password) 232 | if err != nil { 233 | continue 234 | } 235 | return sec, nil 236 | } 237 | return "", fmt.Errorf("couldn't decrypt private key") 238 | } 239 | 240 | func ask(msg string, defaultValue string, shouldAskAgain func(answer string) bool) (string, error) { 241 | return _ask(&readline.Config{ 242 | Prompt: color.CyanString(msg), 243 | InterruptPrompt: "^C", 244 | DisableAutoSaveHistory: true, 245 | }, msg, defaultValue, shouldAskAgain) 246 | } 247 | 248 | func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, error) { 249 | config := &readline.Config{ 250 | Prompt: color.CyanString(msg), 251 | InterruptPrompt: "^C", 252 | DisableAutoSaveHistory: true, 253 | EnableMask: true, 254 | MaskRune: '*', 255 | } 256 | return _ask(config, msg, "", shouldAskAgain) 257 | } 258 | 259 | func _ask(config *readline.Config, msg string, defaultValue string, shouldAskAgain func(answer string) bool) (string, error) { 260 | rl, err := readline.NewEx(config) 261 | if err != nil { 262 | return "", err 263 | } 264 | 265 | rl.WriteStdin([]byte(defaultValue)) 266 | for { 267 | answer, err := rl.Readline() 268 | if err != nil { 269 | return "", err 270 | } 271 | answer = strings.TrimSpace(strings.ToLower(answer)) 272 | if shouldAskAgain != nil && shouldAskAgain(answer) { 273 | continue 274 | } 275 | return answer, err 276 | } 277 | } 278 | 279 | func concatSlices[V any](slices ...[]V) []V { 280 | size := 0 281 | for _, ss := range slices { 282 | size += len(ss) 283 | } 284 | newSlice := make([]V, size) 285 | pos := 0 286 | for _, ss := range slices { 287 | copy(newSlice[pos:], ss) 288 | pos += len(ss) 289 | } 290 | return newSlice 291 | } 292 | 293 | func filterSlice[V any](slice []V, keep func(v V) bool) []V { 294 | keeping := 0 295 | for i := len(slice) - 1; i >= 0; i-- { 296 | v := slice[i] 297 | if keep(v) { 298 | keeping++ 299 | } else { 300 | copy(slice[i:], slice[i+1:]) 301 | } 302 | } 303 | return slice[0:keeping] 304 | } 305 | 306 | func edit(initial string) (string, error) { 307 | editor := "vim" 308 | if s := os.Getenv("EDITOR"); s != "" { 309 | editor = s 310 | } 311 | // tmpfile 312 | f, err := os.CreateTemp("", "go-editor") 313 | if err != nil { 314 | return "", fmt.Errorf("creating tmpfile: %w", err) 315 | } 316 | defer os.Remove(f.Name()) 317 | 318 | // write initial string to it 319 | if err := os.WriteFile(f.Name(), []byte(initial), 0644); err != nil { 320 | return "", fmt.Errorf("error writing to tmpfile '%s': %w", f.Name(), err) 321 | } 322 | 323 | // open editor 324 | cmd := exec.Command("sh", "-c", editor+" "+f.Name()) 325 | cmd.Stdin = os.Stdin 326 | cmd.Stdout = os.Stdout 327 | cmd.Stderr = os.Stderr 328 | err = cmd.Run() 329 | if err != nil { 330 | return "", fmt.Errorf("executing '%s %s': %w", editor, f.Name(), err) 331 | } 332 | 333 | // read tmpfile 334 | b, err := os.ReadFile(f.Name()) 335 | if err != nil { 336 | return "", fmt.Errorf("reading tmpfile '%s': %w", f.Name(), err) 337 | } 338 | 339 | return string(b), nil 340 | } 341 | 342 | func split(str string) []string { 343 | res := make([]string, 0, 5) 344 | for _, v := range strings.Split(str, " ") { 345 | for _, v := range strings.Split(v, ",") { 346 | v = strings.TrimSpace(v) 347 | if v != "" { 348 | res = append(res, v) 349 | } 350 | } 351 | } 352 | return res 353 | } 354 | -------------------------------------------------------------------------------- /init.go: -------------------------------------------------------------------------------- 1 | package gitstr 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/nbd-wtf/go-nostr" 11 | "github.com/nbd-wtf/go-nostr/nip19" 12 | "github.com/urfave/cli/v3" 13 | ) 14 | 15 | var initRepo = &cli.Command{ 16 | Name: "init", 17 | Usage: "", 18 | Description: "", 19 | Flags: []cli.Flag{ 20 | &cli.StringSliceFlag{ 21 | Name: "relay", 22 | Aliases: []string{"r"}, 23 | }, 24 | &cli.StringFlag{ 25 | Name: "sec", 26 | Usage: "secret key to sign the repository announcement, as hex or nsec, or bunker:// URL, or a NIP-46-powered name@domain", 27 | Aliases: []string{"connect"}, 28 | }, 29 | &cli.StringFlag{ 30 | Name: "id", 31 | Usage: "repository id", 32 | }, 33 | &cli.StringFlag{ 34 | Name: "name", 35 | Usage: "repository name", 36 | }, 37 | &cli.StringFlag{ 38 | Name: "description", 39 | Usage: "repository brief description", 40 | }, 41 | &cli.StringFlag{ 42 | Name: "patches-relay", 43 | Usage: "relay that will be used to read patches", 44 | }, 45 | &cli.StringFlag{ 46 | Name: "clone-url", 47 | Usage: "URL through which this repository can cloned", 48 | }, 49 | &cli.StringFlag{ 50 | Name: "web-url", 51 | Usage: "URL through which this repository can be browsed on the web", 52 | }, 53 | }, 54 | Action: func(ctx context.Context, c *cli.Command) error { 55 | evt := nostr.Event{ 56 | CreatedAt: nostr.Now(), 57 | Kind: RepoAnnouncementKind, 58 | Content: "", 59 | Tags: nostr.Tags{}, 60 | } 61 | 62 | defaultId, _ := os.Getwd() 63 | defaultId = filepath.Base(defaultId) 64 | defaultClone, _ := git("remote", "get-url", "origin") 65 | defaultName := defaultId 66 | defaultWeb := "" 67 | if strings.HasPrefix(defaultClone, "http") { 68 | defaultWeb = defaultClone 69 | } else if strings.HasPrefix(defaultClone, "git@") { 70 | defaultWeb = "https://" + strings.Replace(defaultClone[4:], ":", "/", 1) 71 | } 72 | 73 | for _, prop := range []struct { 74 | name string 75 | tag string 76 | prompt string 77 | deflt string 78 | optional bool 79 | multi bool 80 | }{ 81 | {"id", "d", "specify the repository unique id (for this keypair)", defaultId, false, false}, 82 | {"patches-relay", "relays", "specify relay URLs to watch for patches", "wss://relay.nostr.bg wss://nostr21.com wss://nostr.fmt.wiz.biz", false, true}, 83 | {"clone-url", "clone", "specify the repository URL for git clone", defaultClone, false, true}, 84 | {"name", "name", "specify the repository name", defaultName, true, false}, 85 | {"description", "description", "specify the repository description", "", true, false}, 86 | {"web-url", "web", "specify the repository URL for browsing on the web", defaultWeb, true, true}, 87 | } { 88 | v := c.String(prop.name) 89 | if v == "" { 90 | v, _ = git("config", "--local", "str."+prop.name) 91 | if v == "" { 92 | v = prop.deflt 93 | } 94 | 95 | prompt := prop.prompt 96 | if prop.optional { 97 | prompt += " (optional)" 98 | } 99 | if prop.multi { 100 | prompt += "*" 101 | } 102 | 103 | var err error 104 | v, err = ask(prompt+": ", v, func(answer string) bool { 105 | if prop.optional { 106 | return false 107 | } 108 | return answer == "" 109 | }) 110 | if err != nil { 111 | return err 112 | } 113 | } 114 | 115 | if v != "" { 116 | git("config", "--local", "str."+prop.name, v) 117 | tag := nostr.Tag{prop.tag} 118 | if prop.multi { 119 | manyV := split(v) 120 | tag = append(tag, manyV...) 121 | } else { 122 | tag = append(tag, v) 123 | } 124 | evt.Tags = append(evt.Tags, tag) 125 | } else if v == "" && !prop.optional { 126 | return fmt.Errorf("'%s' is mandatory", prop.name) 127 | } 128 | } 129 | 130 | bunker, sec, isEncrypted, err := gatherSecretKeyOrBunker(ctx, c) 131 | if err != nil { 132 | return fmt.Errorf("failed to get authentication data: %w", err) 133 | } 134 | 135 | if isEncrypted { 136 | sec, err = promptDecrypt(sec) 137 | if err != nil { 138 | return err 139 | } 140 | } 141 | 142 | if bunker != nil { 143 | err = bunker.SignEvent(ctx, &evt) 144 | if err != nil { 145 | return fmt.Errorf("error signing event with bunker: %w", err) 146 | } 147 | } else { 148 | err = evt.Sign(sec) 149 | if err != nil { 150 | return fmt.Errorf("error signing event with key: %w", err) 151 | } 152 | } 153 | 154 | git("config", "--local", "str.publickey", evt.PubKey) 155 | 156 | relays := c.StringSlice("relay") 157 | successRelays := make([]string, 0, len(relays)) 158 | for _, r := range relays { 159 | logf("publishing to %s...", r) 160 | if relay, err := pool.EnsureRelay(r); err == nil { 161 | if err := relay.Publish(ctx, evt); err != nil { 162 | logf(" failed: %s\n", err) 163 | } else { 164 | logf("done\n") 165 | successRelays = append(successRelays, r) 166 | } 167 | } else { 168 | logf(" failed: %s\n", err) 169 | } 170 | } 171 | 172 | if len(successRelays) > 0 { 173 | tag := evt.Tags.GetFirst([]string{"d", ""}) 174 | naddr, _ := nip19.EncodeEntity(evt.PubKey, RepoAnnouncementKind, (*tag)[1], successRelays) 175 | fmt.Println(naddr) 176 | return nil 177 | } else { 178 | fmt.Println(evt) 179 | return fmt.Errorf("couldn't publish the event to any relays, use -r or --relay to specify some relays") 180 | } 181 | }, 182 | } 183 | -------------------------------------------------------------------------------- /send.go: -------------------------------------------------------------------------------- 1 | package gitstr 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "slices" 8 | "strings" 9 | 10 | "github.com/fatih/color" 11 | "github.com/nbd-wtf/go-nostr" 12 | "github.com/nbd-wtf/go-nostr/nip19" 13 | "github.com/urfave/cli/v3" 14 | ) 15 | 16 | var send = &cli.Command{ 17 | Name: "send", 18 | UsageText: "git str send ", 19 | Description: "", 20 | Flags: append([]cli.Flag{ 21 | &cli.StringFlag{ 22 | Name: "sec", 23 | Usage: "secret key to sign the patch, as hex or nsec, or bunker:// URL, or a NIP-46-powered name@domain", 24 | Aliases: []string{"connect"}, 25 | }, 26 | &cli.StringFlag{ 27 | Name: "to", 28 | Aliases: []string{"a", "repository"}, 29 | Usage: "repository reference, as an naddr1... code", 30 | }, 31 | &cli.StringSliceFlag{ 32 | Name: "cc", 33 | Usage: "npub, hex or nprofile to mention in the event", 34 | }, 35 | &cli.BoolFlag{ 36 | Name: "annotate", 37 | Usage: "specify this to submit patches without having a target repository -- anyone can fetch those later and apply wherever they want", 38 | }, 39 | &cli.BoolFlag{ 40 | Name: "dangling", 41 | Usage: "specify this to submit patches without having a target repository -- anyone can fetch those later and apply wherever they want", 42 | }, 43 | &cli.StringFlag{ 44 | Name: "in-reply-to", 45 | Aliases: []string{"e"}, 46 | Usage: "reply to another git event, as an nevent1... or hex code", 47 | }, 48 | &cli.StringSliceFlag{ 49 | Name: "relay", 50 | Aliases: []string{"r"}, 51 | Usage: "extra relays to search for the target repository in and to publish the patch to", 52 | }, 53 | &cli.BoolFlag{ 54 | Name: "yes", 55 | Aliases: []string{"y"}, 56 | Usage: "do not ask for confirmation before publishing", 57 | }, 58 | }, gitFormatPatchFlags...), 59 | Action: func(ctx context.Context, c *cli.Command) error { 60 | // git-format-patch extra flags that will be handled directly to it 61 | gitFormatPatchArgs := []string{"format-patch", "--stdout"} 62 | for _, fd := range gitFormatPatchFlags { 63 | if fd.IsSet() { 64 | switch flag := fd.(type) { 65 | case *cli.StringFlag: 66 | gitFormatPatchArgs = append(gitFormatPatchArgs, "--"+flag.Name+"="+c.String(flag.Name)) 67 | case *cli.BoolFlag: 68 | gitFormatPatchArgs = append(gitFormatPatchArgs, "--"+flag.Name) 69 | } 70 | } 71 | } 72 | 73 | // commit or file 74 | patches := make([]string, 0, 10) 75 | for _, arg := range c.Args().Slice() { 76 | if arg == "" { 77 | return fmt.Errorf("no commit or patch file specified") 78 | } 79 | if contents, err := os.ReadFile(arg); err != nil && !os.IsNotExist(err) { 80 | // it's a file 81 | return fmt.Errorf("error reading file '%s': %w", arg, err) 82 | } else if os.IsNotExist(err) { 83 | // it's a git reference 84 | args := make([]string, len(gitFormatPatchArgs)+1) 85 | copy(args, gitFormatPatchArgs) 86 | args[len(gitFormatPatchArgs)] = arg 87 | out, err := git(args...) 88 | if err != nil { 89 | return fmt.Errorf("error getting patch: %w", err) 90 | } 91 | 92 | // split multiple patches into separate strings 93 | for _, patch := range strings.Split(out, "\n\nFrom ") { 94 | patches = append(patches, "From "+patch) 95 | } 96 | } else { 97 | patches = append(patches, string(contents)) 98 | } 99 | } 100 | 101 | patches = filterSlice(patches, func(v string) bool { return v != "" }) 102 | if len(patches) == 0 { 103 | return fmt.Errorf("couldn't get any patches for %v", c.Args().Slice()) 104 | } 105 | 106 | // create the events 107 | events := make([]*nostr.Event, len(patches)) 108 | for i := range patches { 109 | events[i] = &nostr.Event{ 110 | CreatedAt: nostr.Now(), 111 | Kind: PatchKind, 112 | Tags: nostr.Tags{ 113 | nostr.Tag{"alt", "a git patch"}, 114 | nostr.Tag{"t", "root"}, 115 | }, 116 | } 117 | } 118 | 119 | // get metadata and apply it to events 120 | patchRelays, err := getAndApplyTargetRepository(ctx, c, events, c.StringSlice("relay")) 121 | if err != nil { 122 | return err 123 | } 124 | threadRelays, err := getAndApplyTargetThread(ctx, c, events) 125 | if err != nil { 126 | return err 127 | } 128 | mentionRelays, err := getAndApplyTargetMentions(ctx, c, events) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | // check if there are relays available 134 | targetRelays := concatSlices(patchRelays, threadRelays, mentionRelays, c.StringSlice("relay")) 135 | if len(targetRelays) == 0 { 136 | return fmt.Errorf("got no relays to publish to, you can specify one with --relay/-r") 137 | } 138 | 139 | // possibly annotate and assign patch content to events 140 | for i, patch := range patches { 141 | if c.Bool("annotate") { 142 | var err error 143 | events[i].Content, err = edit(patch) 144 | if err != nil { 145 | return fmt.Errorf("error annotating patch: %w", err) 146 | } 147 | } else { 148 | events[i].Content = patch 149 | } 150 | } 151 | 152 | // gather the secret key 153 | bunker, sec, isEncrypted, err := gatherSecretKeyOrBunker(ctx, c) 154 | if err != nil { 155 | return err 156 | } 157 | if isEncrypted { 158 | sec, err = promptDecrypt(sec) 159 | if err != nil { 160 | return err 161 | } 162 | } 163 | 164 | // publish all the patches 165 | for _, evt := range events { 166 | if bunker != nil { 167 | logf(color.YellowString("signing event with bunker...")) 168 | err = bunker.SignEvent(ctx, evt) 169 | if err != nil { 170 | return fmt.Errorf("error signing event with bunker: %w", err) 171 | } 172 | } else { 173 | err = evt.Sign(sec) 174 | if err != nil { 175 | return fmt.Errorf("error signing event with key: %w", err) 176 | } 177 | } 178 | 179 | goodRelays := make([]string, 0, len(targetRelays)) 180 | logf("\n%s", sprintPatch(evt)) 181 | if confirm("proceed to publish the event? ") { 182 | for _, r := range targetRelays { 183 | relay, err := pool.EnsureRelay(r) 184 | if err != nil { 185 | logf("failed to connect to '%s': %s\n", r, err) 186 | continue 187 | } 188 | if err := relay.Publish(ctx, *evt); err != nil { 189 | logf("failed to publish to '%s': %s\n", r, err) 190 | continue 191 | } 192 | goodRelays = append(goodRelays, relay.URL) 193 | } 194 | } 195 | if len(goodRelays) == 0 { 196 | fmt.Println(evt) 197 | logf(color.RedString("didn't publish the event\n")) 198 | continue 199 | } 200 | 201 | code, _ := nip19.EncodeEvent(evt.GetID(), goodRelays, evt.PubKey) 202 | fmt.Println(code) 203 | } 204 | 205 | return nil 206 | }, 207 | } 208 | 209 | func getAndApplyTargetRepository( 210 | ctx context.Context, 211 | c *cli.Command, 212 | evts []*nostr.Event, 213 | extraRelays []string, 214 | ) (patchRelays []string, err error) { 215 | if c.Bool("dangling") { 216 | logf("this patch won't target any specific repository") 217 | return nil, nil 218 | } 219 | 220 | target := c.String("to") 221 | var stored string 222 | if target == "" { 223 | target, _ = git("config", "--local", "str.upstream") 224 | stored = target 225 | } 226 | 227 | if target == "" { 228 | var err error 229 | target, err = ask("repository to target with this (naddr1...): ", "", func(answer string) bool { 230 | prefix, _, err := nip19.Decode(answer) 231 | if err != nil { 232 | return true 233 | } 234 | if prefix != "naddr" { 235 | return true 236 | } 237 | return false 238 | }) 239 | if err != nil { 240 | return nil, err 241 | } 242 | } 243 | 244 | _, data, _ := nip19.Decode(target) 245 | ep, ok := data.(nostr.EntityPointer) 246 | if !ok { 247 | return nil, fmt.Errorf("invalid target '%s'", target) 248 | } 249 | if ep.Kind != RepoAnnouncementKind { 250 | return nil, fmt.Errorf("invalid kind %d, expected %d", ep.Kind, RepoAnnouncementKind) 251 | } 252 | 253 | filter := nostr.Filter{ 254 | Tags: nostr.TagMap{"d": {ep.Identifier}}, 255 | Authors: []string{ep.PublicKey}, 256 | Kinds: []int{ep.Kind}, 257 | } 258 | 259 | repo := pool.QuerySingle(ctx, append(ep.Relays, extraRelays...), filter) 260 | if repo == nil { 261 | return nil, fmt.Errorf("couldn't find event for %s", filter) 262 | } 263 | 264 | logf("%s %s\n%s\n", color.YellowString("found upstream repository"), 265 | target, sprintRepository(repo.Event)) 266 | 267 | if stored != target { 268 | if confirm("store it as your main upstream target? ") { 269 | git("config", "--local", "str.upstream", target) 270 | } 271 | } 272 | 273 | for _, tag := range repo.Event.Tags.GetAll([]string{"patches", ""}) { 274 | patchRelays = append(patchRelays, tag[1:]...) 275 | } 276 | for _, tag := range repo.Event.Tags.GetAll([]string{"relays", ""}) { 277 | patchRelays = append(patchRelays, tag[1:]...) 278 | } 279 | 280 | for _, evt := range evts { 281 | evt.Tags = append(evt.Tags, 282 | nostr.Tag{ 283 | "a", 284 | fmt.Sprintf("%d:%s:%s", ep.Kind, ep.PublicKey, ep.Identifier), 285 | repo.Relay.URL, 286 | }, 287 | nostr.Tag{"p", ep.PublicKey}, 288 | ) 289 | } 290 | 291 | return patchRelays, nil 292 | } 293 | 294 | func getAndApplyTargetThread( 295 | ctx context.Context, 296 | c *cli.Command, 297 | evts []*nostr.Event, 298 | ) (mentionRelays []string, err error) { 299 | target := c.String("in-reply-to") 300 | if target != "" { 301 | _, data, _ := nip19.Decode(target) 302 | ep, ok := data.(nostr.EventPointer) 303 | if ok { 304 | target = ep.ID 305 | } 306 | } 307 | 308 | target = strings.TrimSpace(target) 309 | 310 | if target != "" { 311 | if nostr.IsValid32ByteHex(target) { 312 | return nil, fmt.Errorf("invalid target thread id") 313 | } 314 | for _, evt := range evts { 315 | evt.Tags = append(evt.Tags, nostr.Tag{"e", target}) 316 | evt.Tags = slices.DeleteFunc(evt.Tags, func(tag nostr.Tag) bool { 317 | return len(tag) >= 2 && tag[0] == "t" && tag[1] == "root" 318 | }) 319 | } 320 | } 321 | 322 | // TODO: fetch user relays, fetch thread root, return related relays so we can submit the patch to those too 323 | return nil, nil 324 | } 325 | 326 | func getAndApplyTargetMentions( 327 | ctx context.Context, 328 | c *cli.Command, 329 | evts []*nostr.Event, 330 | ) (mentionRelays []string, err error) { 331 | for _, target := range c.StringSlice("cc") { 332 | prefix, data, err := nip19.Decode(target) 333 | if err == nil { 334 | switch v := data.(type) { 335 | case string: 336 | if prefix == "npub" { 337 | target = v 338 | } 339 | case nostr.ProfilePointer: 340 | target = v.PublicKey 341 | mentionRelays = append(mentionRelays, v.Relays...) 342 | } 343 | } 344 | target = strings.TrimSpace(target) 345 | 346 | if nostr.IsValid32ByteHex(target) { 347 | for _, evt := range evts { 348 | evt.Tags = append(evt.Tags, nostr.Tag{"p", target}) 349 | } 350 | } else { 351 | return nil, fmt.Errorf("invalid mention '%s'", target) 352 | } 353 | } 354 | 355 | // TODO: fetch user relays, fetch thread root, return related relays so we can submit the patch to those too 356 | return nil, nil 357 | } 358 | 359 | var gitFormatPatchFlags = []cli.Flag{ 360 | &cli.StringFlag{Name: "base", Hidden: true}, 361 | } 362 | --------------------------------------------------------------------------------