+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
+5
-2
auth/client.go
+5
-2
auth/client.go
···
30
return &http.Client{Jar: jar}, nil
31
}
32
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
+
}
38
data, err := json.Marshal(cookies)
39
if err != nil {
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
"bufio"
5
"fmt"
6
"net/url"
7
"os/exec"
8
"strings"
9
)
10
11
type Remote = struct {
12
Host string
13
Path string
14
}
···
32
if len(fields) != 3 {
33
continue
34
}
35
u, err := normalizeGitURL(fields[1])
36
if err != nil {
37
continue
38
}
39
40
for _, remote := range remotes {
41
if remote.Host == u.Host && remote.Path == u.Path {
···
44
}
45
46
remotes = append(remotes, u)
47
-
48
}
49
50
return remotes, nil
···
92
93
return string(output), nil
94
}
···
4
"bufio"
5
"fmt"
6
"net/url"
7
+
"os"
8
"os/exec"
9
+
"path"
10
+
"sort"
11
"strings"
12
)
13
14
type Remote = struct {
15
+
Name string
16
Host string
17
Path string
18
}
···
36
if len(fields) != 3 {
37
continue
38
}
39
+
if fields[2] != "(fetch)" {
40
+
continue
41
+
}
42
+
43
u, err := normalizeGitURL(fields[1])
44
if err != nil {
45
continue
46
}
47
+
u.Name = fields[0]
48
49
for _, remote := range remotes {
50
if remote.Host == u.Host && remote.Path == u.Path {
···
53
}
54
55
remotes = append(remotes, u)
56
}
57
58
return remotes, nil
···
100
101
return string(output), nil
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
package knit
2
3
-
import "errors"
4
5
// DefaultHost is the default host for tangled.sh services
6
const DefaultHost = "tangled.sh"
···
11
// ErrRequiresAuth is returned when a command requires authentication but the
12
// user has not authenticated with the host
13
var ErrRequiresAuth = errors.New("authentication required")
···
1
package knit
2
3
+
import (
4
+
"errors"
5
+
"os"
6
+
"os/exec"
7
+
)
8
9
// DefaultHost is the default host for tangled.sh services
10
const DefaultHost = "tangled.sh"
···
15
// ErrRequiresAuth is returned when a command requires authentication but the
16
// user has not authenticated with the host
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
"fmt"
7
"net/http"
8
"net/url"
9
"path"
10
"strings"
11
"sync"
···
36
}
37
_ = client
38
39
// Get available remotes
40
remotes, err := git.Remotes()
41
if err != nil {
···
60
Run()
61
}
62
63
-
patch, err := git.FormatPatch(args[0])
64
if err != nil {
65
return err
66
}
67
68
-
targetBranch := "main"
69
-
huh.NewInput().
70
Title("Target branch").
71
-
Placeholder("main").
72
-
Value(&targetBranch).Run()
73
74
-
var title string
75
-
huh.NewInput().
76
-
Title("Pull Request Title").
77
Placeholder("(optional)").
78
-
Value(&title).Run()
79
80
-
var description string
81
-
huh.NewInput().
82
-
Title("Pull Request Description").
83
Placeholder("(optional)").
84
-
Value(&description).Run()
85
86
form := url.Values{}
87
form.Add("title", title)
88
form.Add("body", description)
89
form.Add("targetBranch", targetBranch)
90
-
form.Add("patch", patch)
91
92
p := remote.Path
93
if !strings.HasPrefix(p, "@") {
···
121
return err
122
}
123
defer resp.Body.Close()
124
125
if resp.StatusCode != http.StatusOK {
126
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
···
128
129
stop()
130
wg.Wait()
131
132
return nil
133
}
···
6
"fmt"
7
"net/http"
8
"net/url"
9
+
"os"
10
+
"os/exec"
11
"path"
12
"strings"
13
"sync"
···
38
}
39
_ = client
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
+
64
// Get available remotes
65
remotes, err := git.Remotes()
66
if err != nil {
···
85
Run()
86
}
87
88
+
branches, err := git.RemoteBranches(remote.Name)
89
if err != nil {
90
return err
91
}
92
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]().
105
Title("Target branch").
106
+
Options(options...).
107
+
Value(&targetBranch).Run(); err != nil {
108
+
return err
109
+
}
110
111
+
if err := huh.NewInput().
112
+
Title("Title").
113
Placeholder("(optional)").
114
+
Value(&title).Run(); err != nil {
115
+
return err
116
+
}
117
118
+
if err := huh.NewText().
119
+
Title("Description").
120
Placeholder("(optional)").
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
+
}
136
137
form := url.Values{}
138
form.Add("title", title)
139
form.Add("body", description)
140
form.Add("targetBranch", targetBranch)
141
+
form.Add("patch", string(patch))
142
143
p := remote.Path
144
if !strings.HasPrefix(p, "@") {
···
172
return err
173
}
174
defer resp.Body.Close()
175
+
auth.SaveCookies(resp.Cookies(), knit.DefaultHost, handle)
176
177
if resp.StatusCode != http.StatusOK {
178
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
···
180
181
stop()
182
wg.Wait()
183
+
fmt.Printf("\x1b[32m✔\x1b[m PR created!\r\n")
184
185
return nil
186
}