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