internal/control/commitfile.go

e2a32d5f8d59e4213571c602bd9009b6c8fa86ed
gitbay/internal/control/commitfile.go history · blame · raw

125 lines · 4318 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		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 := parseFlags(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	// A commit carries an identity, and an unverified address is not one.
 76	email, err := c.Store.PrimaryVerifiedEmail(c.User.ID)
 77	if err != nil {
 78		return c.fail(protocol.ExitFailure, "%v", err)
 79	}
 80	if email == "" {
 81		return c.fail(protocol.ExitDenied,
 82			"commits carry your identity: your account needs a verified primary email")
 83	}
 84
 85	var content []byte
 86	if file != "" {
 87		if file != "-" {
 88			return c.fail(protocol.ExitUsage, "--file only supports - (stdin)")
 89		}
 90		content, err = io.ReadAll(io.LimitReader(c.Stdin, maxCommitFileBytes))
 91		if err != nil {
 92			return c.fail(protocol.ExitFailure, "reading content: %v", err)
 93		}
 94	}
 95	if message = strings.TrimSpace(message); message == "" {
 96		message = "edit " + filePath
 97	}
 98
 99	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
100	// The head before the commit bounds what landed; a branch that does
101	// not exist fails in CommitFileChange with its own message.
102	parent, _ := gitutil.ResolveRef(dir, "refs/heads/"+ref)
103	sha, err := gitutil.CommitFileChange(dir, ref, filePath, content,
104		c.User.Username, email, message)
105	if err != nil {
106		return c.fail(protocol.ExitFailure, "%v", err)
107	}
108	c.Store.MarkMirrorsDirty(repo.ID, "push")
109	// This bypasses receive-pack like a merge does, so the commit-message
110	// issue actions (closes #N, references) run here for the default
111	// branch, with the session's scope (#210).
112	if ref == repo.DefaultBranch {
113		ProcessCommitMessages(c.Store, dir, repo, c.User.ID, c.Scope, parent, sha)
114	}
115
116	d := struct {
117		Path string `json:"path"`
118		Ref  string `json:"ref"`
119		File string `json:"file"`
120		SHA  string `json:"sha"`
121	}{repo.Path(), ref, filePath, sha}
122	return c.emit(d, func(w io.Writer) {
123		fmt.Fprintf(w, "committed %s on %s: %.10s\n", filePath, ref, sha)
124	})
125}