internal/control/commitfile.go
117 lines · 3993 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 f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--message", "--file"}, MaxPos: -1, Usage: c.Cmd.Usage})
39 if err != nil {
40 return c.fail(protocol.ExitUsage, "%v", err)
41 }
42 rest := f.Pos
43 ref, message, file := f.Value("--ref"), f.Value("--message"), f.Value("--file")
44 if len(rest) != 2 || ref == "" {
45 return c.usage()
46 }
47 repo, code := resolveRepo(c, rest[0], policy.CanWrite)
48 if code >= 0 {
49 return code
50 }
51 if code := refuseArchived(c, repo); code >= 0 {
52 return code
53 }
54 filePath, ok := cleanRepoPath(rest[1])
55 if !ok || filePath == "" {
56 return c.fail(protocol.ExitUsage, "path must stay inside the repository")
57 }
58 if repo.Settings.RequireMR && slices.Contains(repo.Settings.ProtectedBranches, ref) {
59 return c.fail(protocol.ExitDenied, "branch %s accepts changes through merge requests only; push another branch and open one", ref)
60 }
61 // The server authors this commit, so it cannot sign it.
62 if repo.Settings.RequireSignedCommits {
63 return c.fail(protocol.ExitDenied,
64 "%s requires signed commits; this writes an unsigned one — push a signed commit instead",
65 repo.Path())
66 }
67 // A commit carries an identity, and an unverified address is not one.
68 email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
69 if err != nil {
70 return c.fail(protocol.ExitFailure, "%v", err)
71 }
72 if email == "" {
73 return c.fail(protocol.ExitDenied,
74 "commits carry your identity: your account needs a verified primary email")
75 }
76
77 var content []byte
78 if file != "" {
79 if file != "-" {
80 return c.fail(protocol.ExitUsage, "--file only supports - (stdin)")
81 }
82 content, err = io.ReadAll(io.LimitReader(c.Stdin, maxCommitFileBytes))
83 if err != nil {
84 return c.fail(protocol.ExitFailure, "reading content: %v", err)
85 }
86 }
87 if message = strings.TrimSpace(message); message == "" {
88 message = "edit " + filePath
89 }
90
91 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
92 // The head before the commit bounds what landed; a branch that does
93 // not exist fails in CommitFileChange with its own message.
94 parent, _ := gitutil.ResolveRef(dir, "refs/heads/"+ref)
95 sha, err := gitutil.CommitFileChange(dir, ref, filePath, content,
96 c.User.Username, email, message)
97 if err != nil {
98 return c.fail(protocol.ExitFailure, "%v", err)
99 }
100 c.Store.MarkMirrorsDirty(repo.ID, "push")
101 // This bypasses receive-pack like a merge does, so the commit-message
102 // issue actions (closes #N, references) run here for the default
103 // branch, with the session's scope (#210).
104 if ref == repo.DefaultBranch {
105 ProcessCommitMessages(c.Store, dir, repo, c.User.ID, c.Scope, parent, sha)
106 }
107
108 d := struct {
109 Path string `json:"path"`
110 Ref string `json:"ref"`
111 File string `json:"file"`
112 SHA string `json:"sha"`
113 }{repo.Path(), ref, filePath, sha}
114 return c.emit(d, func(w io.Writer) {
115 fmt.Fprintf(w, "committed %s on %s: %.10s\n", filePath, ref, sha)
116 })
117}