A CLI for tangled.sh

Compare changes

Choose any two refs to compare.

Changed files
+215 -20
auth
cmd
knit
git
pr
+4
.gitignore
··· 1 + knit 2 + knit.bash 3 + knit.fish 4 + knit.zsh
+35
Makefile
··· 1 + BINARY_NAME=knit 2 + 3 + PREFIX ?= /usr/local 4 + BINDIR = $(PREFIX)/bin 5 + BASHDIR = $(PREFIX)/share/bash-completions/completions 6 + FISHDIR = $(PREFIX)/share/fish/vendor_completions.d 7 + ZSHDIR = $(PREFIX)/share/zsh/vendor_completions 8 + 9 + .PHONY: all build install clean 10 + 11 + all: build completions 12 + 13 + build: 14 + go build -o $(BINARY_NAME) ./cmd/knit 15 + 16 + completions: build 17 + ./$(BINARY_NAME) completion bash > knit.bash 18 + ./$(BINARY_NAME) completion fish > knit.fish 19 + ./$(BINARY_NAME) completion zsh > knit.zsh 20 + 21 + install: build 22 + install -d $(BINDIR) 23 + install -m 755 $(BINARY_NAME) $(BINDIR)/$(BINARY_NAME) 24 + install -d $(BASHDIR) 25 + install -m 755 knit.bash $(BASHDIR)/knit 26 + install -d $(FISHDIR) 27 + install -m 755 knit.fish $(FISHDIR)/knit.fish 28 + install -d $(ZSHDIR) 29 + install -m 755 knit.zsh $(ZSHDIR)/_knit 30 + 31 + clean: 32 + rm -f $(BINARY_NAME) 33 + rm -f knit.bash 34 + rm -f knit.fish 35 + rm -f knit.zsh
+13 -1
README.md
··· 4 4 5 5 ## Installation 6 6 7 - `go install tangled.sh/rockorager.dev/knit` 7 + Using go: 8 + 9 + ``` 10 + go install tangled.sh/rockorager.dev/knit/cmd/knit@latest 11 + ``` 12 + 13 + Otherwise, 14 + 15 + ``` 16 + git clone https://tangled.sh/rockorager.dev/knit 17 + cd knit 18 + make install 19 + ``` 8 20 9 21 ## Usage 10 22
+5 -2
auth/client.go
··· 30 30 return &http.Client{Jar: jar}, nil 31 31 } 32 32 33 - // saveCookies serializes the cookies and saves them to the system keyring 34 - func saveCookies(cookies []*http.Cookie, host string, handle string) error { 33 + // SaveCookies serializes the cookies and saves them to the system keyring 34 + func SaveCookies(cookies []*http.Cookie, host string, handle string) error { 35 + if len(cookies) == 0 { 36 + return nil 37 + } 35 38 data, err := json.Marshal(cookies) 36 39 if err != nil { 37 40 return err
+1 -1
auth/login.go
··· 59 59 return fmt.Errorf("authenticating: %w", err) 60 60 } 61 61 62 - if err := saveCookies(cookies, "tangled.sh", handle); err != nil { 62 + if err := SaveCookies(cookies, knit.DefaultHost, handle); err != nil { 63 63 return err 64 64 } 65 65
+1
cmd/knit/main.go
··· 17 17 Use: "knit", 18 18 Short: "knit is a CLI for tangled.sh", 19 19 } 20 + root.CompletionOptions.HiddenDefaultCmd = true 20 21 21 22 root.AddCommand(auth.Command()) 22 23 root.AddCommand(pr.Command())
+66 -1
git/git.go
··· 4 4 "bufio" 5 5 "fmt" 6 6 "net/url" 7 + "os" 7 8 "os/exec" 9 + "path" 10 + "sort" 8 11 "strings" 9 12 ) 10 13 11 14 type Remote = struct { 15 + Name string 12 16 Host string 13 17 Path string 14 18 } ··· 32 36 if len(fields) != 3 { 33 37 continue 34 38 } 39 + if fields[2] != "(fetch)" { 40 + continue 41 + } 42 + 35 43 u, err := normalizeGitURL(fields[1]) 36 44 if err != nil { 37 45 continue 38 46 } 47 + u.Name = fields[0] 39 48 40 49 for _, remote := range remotes { 41 50 if remote.Host == u.Host && remote.Path == u.Path { ··· 44 53 } 45 54 46 55 remotes = append(remotes, u) 47 - 48 56 } 49 57 50 58 return remotes, nil ··· 92 100 93 101 return string(output), nil 94 102 } 103 + 104 + func FormatPatchToTmp(revRange string) ([]string, error) { 105 + tmpDir, err := os.MkdirTemp("", "knit-patches-") 106 + if err != nil { 107 + return nil, err 108 + } 109 + 110 + cmd := exec.Command( 111 + "git", 112 + "format-patch", 113 + "--output-directory", 114 + tmpDir, 115 + revRange, 116 + ) 117 + if err := cmd.Run(); err != nil { 118 + return nil, err 119 + } 120 + 121 + entries, err := os.ReadDir(tmpDir) 122 + if err != nil { 123 + return nil, err 124 + } 125 + 126 + var paths []string 127 + for _, entry := range entries { 128 + if entry.IsDir() { 129 + continue 130 + } 131 + paths = append(paths, path.Join(tmpDir, entry.Name())) 132 + } 133 + sort.Strings(paths) 134 + return paths, nil 135 + } 136 + 137 + func RemoteBranches(remote string) ([]string, error) { 138 + cmd := exec.Command("git", "branch", "--remotes", "--list", remote+"*") 139 + 140 + output, err := cmd.Output() 141 + if err != nil { 142 + return nil, err 143 + } 144 + 145 + branches := []string{} 146 + scanner := bufio.NewScanner(strings.NewReader(string(output))) 147 + 148 + for scanner.Scan() { 149 + line := strings.TrimSpace(scanner.Text()) 150 + branch := strings.TrimPrefix(line, remote+"/") 151 + if strings.HasPrefix(branch, "HEAD ->") { 152 + continue 153 + } 154 + 155 + branches = append(branches, branch) 156 + 157 + } 158 + return branches, nil 159 + }
+23 -1
knit.go
··· 1 1 package knit 2 2 3 - import "errors" 3 + import ( 4 + "errors" 5 + "os" 6 + "os/exec" 7 + ) 4 8 5 9 // DefaultHost is the default host for tangled.sh services 6 10 const DefaultHost = "tangled.sh" ··· 11 15 // ErrRequiresAuth is returned when a command requires authentication but the 12 16 // user has not authenticated with the host 13 17 var ErrRequiresAuth = errors.New("authentication required") 18 + 19 + func Editor() string { 20 + e := os.Getenv("EDITOR") 21 + if e != "" { 22 + return e 23 + } 24 + 25 + options := []string{"nvim", "vim", "nano"} 26 + for _, opt := range options { 27 + _, err := exec.LookPath(opt) 28 + if err != nil { 29 + continue 30 + } 31 + return opt 32 + } 33 + 34 + return "vi" 35 + }
+67 -14
pr/create.go
··· 6 6 "fmt" 7 7 "net/http" 8 8 "net/url" 9 + "os" 10 + "os/exec" 9 11 "path" 10 12 "strings" 11 13 "sync" ··· 36 38 } 37 39 _ = client 38 40 41 + paths, err := git.FormatPatchToTmp(args[0]) 42 + if err != nil { 43 + return err 44 + } 45 + 46 + editor := exec.Command(knit.Editor(), paths...) 47 + editor.Stdout = os.Stdout 48 + editor.Stderr = os.Stderr 49 + editor.Stdin = os.Stdin 50 + if err := editor.Run(); err != nil { 51 + return err 52 + } 53 + 54 + // Combine the result into a single slice 55 + var patch []byte 56 + for _, p := range paths { 57 + b, err := os.ReadFile(p) 58 + if err != nil { 59 + return err 60 + } 61 + patch = append(patch, b...) 62 + } 63 + 39 64 // Get available remotes 40 65 remotes, err := git.Remotes() 41 66 if err != nil { ··· 60 85 Run() 61 86 } 62 87 63 - patch, err := git.FormatPatch(args[0]) 88 + branches, err := git.RemoteBranches(remote.Name) 64 89 if err != nil { 65 90 return err 66 91 } 67 92 68 - targetBranch := "main" 69 - huh.NewInput(). 93 + options := make([]huh.Option[string], 0, len(branches)) 94 + for _, branch := range branches { 95 + options = append(options, huh.NewOption(branch, branch)) 96 + } 97 + 98 + var ( 99 + targetBranch string 100 + title string 101 + description string 102 + ) 103 + 104 + if err := huh.NewSelect[string](). 70 105 Title("Target branch"). 71 - Placeholder("main"). 72 - Value(&targetBranch).Run() 106 + Options(options...). 107 + Value(&targetBranch).Run(); err != nil { 108 + return err 109 + } 73 110 74 - var title string 75 - huh.NewInput(). 76 - Title("Pull Request Title"). 111 + if err := huh.NewInput(). 112 + Title("Title"). 77 113 Placeholder("(optional)"). 78 - Value(&title).Run() 114 + Value(&title).Run(); err != nil { 115 + return err 116 + } 79 117 80 - var description string 81 - huh.NewInput(). 82 - Title("Pull Request Description"). 118 + if err := huh.NewText(). 119 + Title("Description"). 83 120 Placeholder("(optional)"). 84 - Value(&description).Run() 121 + Value(&description).Run(); err != nil { 122 + return err 123 + } 124 + 125 + var submit bool 126 + if err := huh.NewConfirm(). 127 + Title(fmt.Sprintf("Submit PR to %s/%s?", remote.Host, remote.Path)). 128 + Value(&submit). 129 + Run(); err != nil { 130 + return err 131 + } 132 + 133 + if !submit { 134 + return fmt.Errorf("PR submission canceled") 135 + } 85 136 86 137 form := url.Values{} 87 138 form.Add("title", title) 88 139 form.Add("body", description) 89 140 form.Add("targetBranch", targetBranch) 90 - form.Add("patch", patch) 141 + form.Add("patch", string(patch)) 91 142 92 143 p := remote.Path 93 144 if !strings.HasPrefix(p, "@") { ··· 121 172 return err 122 173 } 123 174 defer resp.Body.Close() 175 + auth.SaveCookies(resp.Cookies(), knit.DefaultHost, handle) 124 176 125 177 if resp.StatusCode != http.StatusOK { 126 178 return fmt.Errorf("unexpected status code: %d", resp.StatusCode) ··· 128 180 129 181 stop() 130 182 wg.Wait() 183 + fmt.Printf("\x1b[32m✔\x1b[m PR created!\r\n") 131 184 132 185 return nil 133 186 }