+35
Makefile
+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
+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
+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
+1
-1
auth/login.go
+1
cmd/knit/main.go
+1
cmd/knit/main.go
+66
-1
git/git.go
+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
+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
+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
}