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