1package git
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12
13 "github.com/charmbracelet/log"
14 "github.com/charmbracelet/soft-serve/git"
15 "github.com/go-git/go-git/v5/plumbing/format/pktline"
16 "golang.org/x/sync/errgroup"
17)
18
19var (
20
21 // ErrNotAuthed represents unauthorized access.
22 ErrNotAuthed = errors.New("you are not authorized to do this")
23
24 // ErrSystemMalfunction represents a general system error returned to clients.
25 ErrSystemMalfunction = errors.New("something went wrong")
26
27 // ErrInvalidRepo represents an attempt to access a non-existent repo.
28 ErrInvalidRepo = errors.New("invalid repo")
29
30 // ErrInvalidRequest represents an invalid request.
31 ErrInvalidRequest = errors.New("invalid request")
32
33 // ErrMaxConnections represents a maximum connection limit being reached.
34 ErrMaxConnections = errors.New("too many connections, try again later")
35
36 // ErrTimeout is returned when the maximum read timeout is exceeded.
37 ErrTimeout = errors.New("I/O timeout reached")
38)
39
40// Git protocol commands.
41const (
42 ReceivePackBin = "git-receive-pack"
43 UploadPackBin = "git-upload-pack"
44 UploadArchiveBin = "git-upload-archive"
45)
46
47// UploadPack runs the git upload-pack protocol against the provided repo.
48func UploadPack(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
49 exists, err := fileExists(repoDir)
50 if !exists {
51 return ErrInvalidRepo
52 }
53 if err != nil {
54 return err
55 }
56 return RunGit(ctx, in, out, er, "", envs, UploadPackBin[4:], repoDir)
57}
58
59// UploadArchive runs the git upload-archive protocol against the provided repo.
60func UploadArchive(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
61 exists, err := fileExists(repoDir)
62 if !exists {
63 return ErrInvalidRepo
64 }
65 if err != nil {
66 return err
67 }
68 return RunGit(ctx, in, out, er, "", envs, UploadArchiveBin[4:], repoDir)
69}
70
71// ReceivePack runs the git receive-pack protocol against the provided repo.
72func ReceivePack(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
73 if err := RunGit(ctx, in, out, er, "", envs, ReceivePackBin[4:], repoDir); err != nil {
74 return err
75 }
76 return EnsureDefaultBranch(ctx, in, out, er, repoDir)
77}
78
79// RunGit runs a git command in the given repo.
80func RunGit(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, dir string, envs []string, args ...string) error {
81 logger := log.WithPrefix("server.git")
82 c := exec.CommandContext(ctx, "git", args...)
83 c.Dir = dir
84 c.Env = append(c.Env, envs...)
85 c.Env = append(c.Env, "SOFT_SERVE_DEBUG="+os.Getenv("SOFT_SERVE_DEBUG"))
86 c.Env = append(c.Env, "PATH="+os.Getenv("PATH"))
87
88 stdin, err := c.StdinPipe()
89 if err != nil {
90 logger.Error("failed to get stdin pipe", "err", err)
91 return err
92 }
93
94 stdout, err := c.StdoutPipe()
95 if err != nil {
96 logger.Error("failed to get stdout pipe", "err", err)
97 return err
98 }
99
100 stderr, err := c.StderrPipe()
101 if err != nil {
102 logger.Error("failed to get stderr pipe", "err", err)
103 return err
104 }
105
106 if err := c.Start(); err != nil {
107 logger.Error("failed to start command", "err", err)
108 return err
109 }
110
111 errg, ctx := errgroup.WithContext(ctx)
112
113 // stdin
114 errg.Go(func() error {
115 defer stdin.Close()
116
117 _, err := io.Copy(stdin, in)
118 return err
119 })
120
121 // stdout
122 errg.Go(func() error {
123 _, err := io.Copy(out, stdout)
124 return err
125 })
126
127 // stderr
128 errg.Go(func() error {
129 _, err := io.Copy(er, stderr)
130 return err
131 })
132
133 if err := errg.Wait(); err != nil {
134 logger.Error("while running git command", "err", err)
135 return err
136 }
137
138 return nil
139}
140
141// WritePktline encodes and writes a pktline to the given writer.
142func WritePktline(w io.Writer, v ...interface{}) {
143 msg := fmt.Sprintln(v...)
144 pkt := pktline.NewEncoder(w)
145 if err := pkt.EncodeString(msg); err != nil {
146 log.Debugf("git: error writing pkt-line message: %s", err)
147 }
148 if err := pkt.Flush(); err != nil {
149 log.Debugf("git: error flushing pkt-line message: %s", err)
150 }
151}
152
153// EnsureWithin ensures the given repo is within the repos directory.
154func EnsureWithin(reposDir string, repo string) error {
155 repoDir := filepath.Join(reposDir, repo)
156 absRepos, err := filepath.Abs(reposDir)
157 if err != nil {
158 log.Debugf("failed to get absolute path for repo: %s", err)
159 return ErrSystemMalfunction
160 }
161 absRepo, err := filepath.Abs(repoDir)
162 if err != nil {
163 log.Debugf("failed to get absolute path for repos: %s", err)
164 return ErrSystemMalfunction
165 }
166
167 // ensure the repo is within the repos directory
168 if !strings.HasPrefix(absRepo, absRepos) {
169 log.Debugf("repo path is outside of repos directory: %s", absRepo)
170 return ErrInvalidRepo
171 }
172
173 return nil
174}
175
176func fileExists(path string) (bool, error) {
177 _, err := os.Stat(path)
178 if err == nil {
179 return true, nil
180 }
181 if os.IsNotExist(err) {
182 return false, nil
183 }
184 return true, err
185}
186
187func EnsureDefaultBranch(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoPath string) error {
188 r, err := git.Open(repoPath)
189 if err != nil {
190 return err
191 }
192 brs, err := r.Branches()
193 if err != nil {
194 return err
195 }
196 if len(brs) == 0 {
197 return fmt.Errorf("no branches found")
198 }
199 // Rename the default branch to the first branch available
200 _, err = r.HEAD()
201 if err == git.ErrReferenceNotExist {
202 err = RunGit(ctx, in, out, er, repoPath, []string{}, "branch", "-M", brs[0])
203 if err != nil {
204 return err
205 }
206 }
207 if err != nil && err != git.ErrReferenceNotExist {
208 return err
209 }
210 return nil
211}