internal/control/commitfile.go
128 lines · 4388 bytes
3 symbols in this file
1package control
2
3import (
4 "fmt"
5 "io"
6 "slices"
7 "strings"
8
9 "gitbay.org/gitbay/internal/gitutil"
10 "gitbay.org/gitbay/internal/policy"
11 "gitbay.org/gitbay/internal/protocol"
12)
13
14func init() {
15 register(Command{
16 Path: []string{"repo", "commit-file"},
17 Summary: "write a file and commit it",
18 Usage: "repo commit-file <owner/name> <path> " +
19 "--ref <branch> [--message <m>] [--file -]",
20 Flags: []Flag{
21 {"--ref", "<branch>", "branch to commit to", ""},
22 {"--message", "<m>", "the commit message", ""},
23 {"--file", "-", "read the new content from stdin", ""},
24 },
25 Examples: []string{
26 `repo commit-file krz/gitbay CHANGELOG.org --ref main --message "note the release" --file - < CHANGELOG.org`,
27 },
28 ReadsStdin: true,
29 Run: runCommitFile,
30 })
31}
32
33// maxCommitFileBytes bounds one edit. Large content belongs in a push,
34// not a single-file commit over the control plane.
35const maxCommitFileBytes = 1 << 20
36
37// runCommitFile commits one file's contents to a branch. It exists so the
38// capability is reachable from every surface: the web's editor dispatches
39// this rather than calling git itself, which is what kept editing off the
40// CLI and the API.
41//
42// Commits made here are unsigned, because the server is authoring them.
43// A repository that requires verified signatures therefore refuses the
44// command rather than writing a commit its own policy would reject.
45func runCommitFile(c *Ctx, args []string) int {
46 f, err := c.parseArgs(args, flagSpec{Values: []string{"--ref", "--message", "--file"}, MaxPos: -1, Usage: c.Cmd.Usage})
47 if err != nil {
48 return c.fail(protocol.ExitUsage, "%v", err)
49 }
50 rest := f.Pos
51 ref, message, file := f.Value("--ref"), f.Value("--message"), f.Value("--file")
52 if len(rest) != 2 || ref == "" {
53 return c.usage()
54 }
55 repo, code := resolveRepo(c, rest[0], policy.CanWrite)
56 if code >= 0 {
57 return code
58 }
59 if code := refuseArchived(c, repo); code >= 0 {
60 return code
61 }
62 filePath, ok := cleanRepoPath(rest[1])
63 if !ok || filePath == "" {
64 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
65 }
66 if repo.Settings.RequireMR && slices.Contains(repo.Settings.ProtectedBranches, ref) {
67 return c.fail(protocol.ExitDenied, "branch %s accepts changes through merge requests only; push another branch and open one", ref)
68 }
69 // The server authors this commit, so it cannot sign it.
70 if repo.Settings.RequireSignedCommits {
71 return c.fail(protocol.ExitDenied,
72 "%s requires signed commits; this writes an unsigned one — push a signed commit instead",
73 repo.Path())
74 }
75 if code := checkStorageQuota(c, repo); code >= 0 {
76 return code
77 }
78 // A commit carries an identity, and an unverified address is not one.
79 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
80 if err != nil {
81 return c.fail(protocol.ExitFailure, "%v", err)
82 }
83 if email == "" {
84 return c.fail(protocol.ExitDenied,
85 "commits carry your identity: your account needs a verified primary email")
86 }
87
88 var content []byte
89 if file != "" {
90 if file != "-" {
91 return c.fail(protocol.ExitUsage, "--file only supports - (stdin)")
92 }
93 content, err = io.ReadAll(io.LimitReader(c.Stdin, maxCommitFileBytes))
94 if err != nil {
95 return c.fail(protocol.ExitFailure, "reading content: %v", err)
96 }
97 }
98 if message = strings.TrimSpace(message); message == "" {
99 message = "edit " + filePath
100 }
101
102 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
103 // The head before the commit bounds what landed; a branch that does
104 // not exist fails in CommitFileChange with its own message.
105 parent, _ := gitutil.ResolveRef(dir, "refs/heads/"+ref)
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 // This bypasses receive-pack like a merge does, so the commit-message
113 // issue actions (closes #N, references) run here for the default
114 // branch, with the session's scope (#210).
115 if ref == repo.DefaultBranch {
116 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, c.Scope, parent, sha)
117 }
118
119 d := struct {
120 Path string `json:"path"`
121 Ref string `json:"ref"`
122 File string `json:"file"`
123 SHA string `json:"sha"`
124 }{repo.Path(), ref, filePath, sha}
125 return c.emit(d, func(w io.Writer) {
126 fmt.Fprintf(w, "committed %s on %s: %.10s\n", filePath, ref, sha)
127 })
128}