internal/control/commitfile.go
123 lines · 3700 bytes
1package control
2
3import (
4 "fmt"
5 "io"
6 "strings"
7
8 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/policy"
10 "gitbay.org/gitbay/internal/protocol"
11)
12
13func init() {
14 register(Command{
15 Path: []string{"repo", "commit-file"},
16 Summary: "write a file and commit it",
17 Usage: "repo commit-file <owner/name> <path> " +
18 "--ref <branch> [--message <m>] [--file -]",
19 ReadsStdin: true,
20 Run: runCommitFile,
21 })
22}
23
24// maxCommitFileBytes bounds one edit. Large content belongs in a push,
25// not a single-file commit over the control plane.
26const maxCommitFileBytes = 1 << 20
27
28// runCommitFile commits one file's contents to a branch. It exists so the
29// capability is reachable from every surface: the web's editor dispatches
30// this rather than calling git itself, which is what kept editing off the
31// CLI and the API.
32//
33// Commits made here are unsigned, because the server is authoring them.
34// A repository that requires verified signatures therefore refuses the
35// command rather than writing a commit its own policy would reject.
36func runCommitFile(c *Ctx, args []string) int {
37 const usage = "repo commit-file <owner/name> <path> --ref <branch> [--message <m>] [--file -]"
38 var rest []string
39 var ref, message, file string
40 for i := 0; i < len(args); i++ {
41 switch args[i] {
42 case "--ref", "--message", "--file":
43 if i+1 >= len(args) {
44 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
45 }
46 switch args[i] {
47 case "--ref":
48 ref = args[i+1]
49 case "--message":
50 message = args[i+1]
51 case "--file":
52 file = args[i+1]
53 }
54 i++
55 default:
56 if strings.HasPrefix(args[i], "--") {
57 return c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage)
58 }
59 rest = append(rest, args[i])
60 }
61 }
62 if len(rest) != 2 || ref == "" {
63 return c.fail(protocol.ExitUsage, "usage: %s", usage)
64 }
65 repo, code := resolveRepo(c, rest[0], policy.CanWrite)
66 if code >= 0 {
67 return code
68 }
69 if code := refuseArchived(c, repo); code >= 0 {
70 return code
71 }
72 filePath, ok := cleanRepoPath(rest[1])
73 if !ok || filePath == "" {
74 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
75 }
76 // The server authors this commit, so it cannot sign it.
77 if repo.Settings.RequireSignedCommits {
78 return c.fail(protocol.ExitDenied,
79 "%s requires signed commits; this writes an unsigned one — push a signed commit instead",
80 repo.Path())
81 }
82 // A commit carries an identity, and an unverified address is not one.
83 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
84 if err != nil {
85 return c.fail(protocol.ExitFailure, "%v", err)
86 }
87 if email == "" {
88 return c.fail(protocol.ExitDenied,
89 "commits carry your identity: your account needs a verified primary email")
90 }
91
92 var content []byte
93 if file != "" {
94 if file != "-" {
95 return c.fail(protocol.ExitUsage, "--file only supports - (stdin)")
96 }
97 content, err = io.ReadAll(io.LimitReader(c.Stdin, maxCommitFileBytes))
98 if err != nil {
99 return c.fail(protocol.ExitFailure, "reading content: %v", err)
100 }
101 }
102 if message = strings.TrimSpace(message); message == "" {
103 message = "edit " + filePath
104 }
105
106 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
107 sha, err := gitutil.CommitFileChange(dir, ref, filePath, content,
108 c.User.Username, email, message)
109 if err != nil {
110 return c.fail(protocol.ExitFailure, "%v", err)
111 }
112 c.Store.MarkMirrorsDirty(repo.ID, "push")
113
114 d := struct {
115 Path string `json:"path"`
116 Ref string `json:"ref"`
117 File string `json:"file"`
118 SHA string `json:"sha"`
119 }{repo.Path(), ref, filePath, sha}
120 return c.emit(d, func(w io.Writer) {
121 fmt.Fprintf(w, "committed %s on %s: %.10s\n", filePath, ref, sha)
122 })
123}