df3c9142ab
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
442 lines
15 KiB
Go
442 lines
15 KiB
Go
package process
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/kandev/kandev/internal/common/securityutil"
|
|
)
|
|
|
|
// Stable error codes for the configured-push-target contract. Callers match on
|
|
// these rather than on message text.
|
|
const (
|
|
pushRemoteNotFoundErrorCode = "push_remote_not_found"
|
|
pushRemoteURLUnmatchedErrorCode = "push_remote_url_unmatched"
|
|
pushRemoteFanoutErrorCode = "push_remote_fanout"
|
|
pushRemoteConfigUnreadableErrorCode = "push_remote_config_unreadable"
|
|
pushRemoteContributionConflictErrorCode = "push_remote_contribution_conflict"
|
|
pushRemoteUpstreamUnsupportedErrorCode = "push_remote_upstream_unsupported"
|
|
pushBranchInvalidErrorCode = "push_branch_invalid"
|
|
pushBranchMismatchErrorCode = "push_branch_mismatch"
|
|
pushBranchMismatchAfterBaselineErrorCode = "push_branch_mismatch_after_baseline"
|
|
pushBranchDetachedErrorCode = "push_branch_detached"
|
|
pushNoRemoteConfiguredErrorCode = "push_no_remote_configured"
|
|
)
|
|
|
|
// defaultPushRemote is the remote the push contract targets when a request
|
|
// names none and no contribution routing applies.
|
|
const defaultPushRemote = "origin"
|
|
|
|
// PushOptions carries the inputs of a push or push-preflight request.
|
|
//
|
|
// Remote is an explicit push target, given either as a configured remote name
|
|
// or as a remote URL that must resolve to an already-configured remote.
|
|
// ExpectedBranch is the branch the caller believes it is publishing. Both are
|
|
// optional; absent reproduces the behavior of a request that names neither.
|
|
type PushOptions struct {
|
|
Force bool
|
|
SetUpstream bool
|
|
Remote string
|
|
ExpectedBranch string
|
|
}
|
|
|
|
// normalized trims both string inputs. A value that is empty after the trim is
|
|
// absent, so a whitespace-only input is an omitted input rather than a refusal.
|
|
func (o PushOptions) normalized() PushOptions {
|
|
o.Remote = strings.TrimSpace(o.Remote)
|
|
o.ExpectedBranch = strings.TrimSpace(o.ExpectedBranch)
|
|
return o
|
|
}
|
|
|
|
// pushRefusal is a refusal carrying one of the stable error codes above.
|
|
type pushRefusal struct {
|
|
code string
|
|
message string
|
|
expectedBranch string
|
|
currentBranch string
|
|
baselinePublished bool
|
|
}
|
|
|
|
func (r *pushRefusal) apply(result *GitOperationResult) {
|
|
result.ErrorCode = r.code
|
|
result.Error = r.message
|
|
result.ExpectedBranch = r.expectedBranch
|
|
result.CurrentBranch = r.currentBranch
|
|
result.BaselinePublished = r.baselinePublished
|
|
}
|
|
|
|
// branchMismatchRefusal reports the expected and current branches so a caller
|
|
// can see what it asked for and what the checkout was actually on. A detached
|
|
// HEAD is reported as an empty current branch, not as the literal "HEAD".
|
|
func branchMismatchRefusal(expected, current string, baselinePublished bool) *pushRefusal {
|
|
code := pushBranchMismatchErrorCode
|
|
if baselinePublished {
|
|
code = pushBranchMismatchAfterBaselineErrorCode
|
|
}
|
|
shown := current
|
|
if shown == "" {
|
|
shown = "a detached HEAD"
|
|
}
|
|
return &pushRefusal{
|
|
code: code,
|
|
message: fmt.Sprintf("expected to publish %q but the checkout is on %s", expected, shown),
|
|
expectedBranch: expected,
|
|
currentBranch: current,
|
|
baselinePublished: baselinePublished,
|
|
}
|
|
}
|
|
|
|
// remoteConfig is a configured remote and its effective push URL set: the
|
|
// remote's push URLs when it has at least one, otherwise the single entry
|
|
// holding its fetch URL. A remote with neither has an empty set.
|
|
type remoteConfig struct {
|
|
name string
|
|
pushURLs []string
|
|
}
|
|
|
|
// configuredRemotes reads every configured remote and its effective push URL
|
|
// set. A remote whose name fails the ref allowlist is skipped: it cannot be
|
|
// named by the name form either, so it is unreachable through this contract in
|
|
// both directions.
|
|
func (g *GitOperator) configuredRemotes(ctx context.Context) ([]remoteConfig, error) {
|
|
output, err := g.runGitCommand(ctx, "remote")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list remotes: %w", err)
|
|
}
|
|
var remotes []remoteConfig
|
|
for _, line := range strings.Split(output, "\n") {
|
|
name := strings.TrimSpace(line)
|
|
if name == "" || !securityutil.IsValidBranchName(name) {
|
|
continue
|
|
}
|
|
urls, err := g.effectivePushURLs(ctx, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
remotes = append(remotes, remoteConfig{name: name, pushURLs: urls})
|
|
}
|
|
return remotes, nil
|
|
}
|
|
|
|
func (g *GitOperator) effectivePushURLs(ctx context.Context, name string) ([]string, error) {
|
|
pushURLs, err := g.remoteConfigValues(ctx, name, "pushurl")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(pushURLs) > 0 {
|
|
return pushURLs, nil
|
|
}
|
|
return g.remoteConfigValues(ctx, name, "url")
|
|
}
|
|
|
|
// remoteConfigValues reads one multi-valued remote config key. Git exits 1 when
|
|
// the key is simply unset, which is an empty set rather than a read failure.
|
|
func (g *GitOperator) remoteConfigValues(ctx context.Context, name, key string) ([]string, error) {
|
|
output, err := g.runGitCommand(ctx, "config", "--get-all", "remote."+name+"."+key)
|
|
if err != nil {
|
|
if isGitConfigKeyUnset(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to read remote.%s.%s: %w", name, key, err)
|
|
}
|
|
var values []string
|
|
for _, line := range strings.Split(output, "\n") {
|
|
if trimmed := strings.TrimSpace(line); trimmed != "" {
|
|
values = append(values, trimmed)
|
|
}
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func isGitConfigKeyUnset(err error) bool {
|
|
var exitErr *exec.ExitError
|
|
return errors.As(err, &exitErr) && exitErr.ExitCode() == 1
|
|
}
|
|
|
|
// resolvePushTarget maps an explicit push target onto a configured remote name.
|
|
// Resolution never mutates configuration and never places a caller-supplied URL
|
|
// on a git command line: only the resolved name reaches a command.
|
|
func (g *GitOperator) resolvePushTarget(ctx context.Context, target string) (string, *pushRefusal) {
|
|
remotes, err := g.configuredRemotes(ctx)
|
|
if err != nil {
|
|
return "", &pushRefusal{
|
|
code: pushRemoteConfigUnreadableErrorCode,
|
|
message: "failed to read the checkout's remote configuration",
|
|
}
|
|
}
|
|
// The permissive ref allowlist is the name/URL discriminator. A value read
|
|
// as a name still has to match an already-configured remote, so a
|
|
// permissive reading cannot reach a destination on its own.
|
|
if securityutil.IsValidBranchName(target) {
|
|
for _, remote := range remotes {
|
|
if remote.name == target {
|
|
return remote.name, nil
|
|
}
|
|
}
|
|
return "", &pushRefusal{
|
|
code: pushRemoteNotFoundErrorCode,
|
|
message: fmt.Sprintf("no configured remote named %q", target),
|
|
}
|
|
}
|
|
return resolvePushTargetURL(remotes, target)
|
|
}
|
|
|
|
// resolvePushTargetURL matches a URL against each remote's effective push URL
|
|
// set. Only a single-entry set matches: a remote that carries the value
|
|
// alongside other push URLs would publish to destinations the caller never
|
|
// named, so it is refused rather than matched. A caller that wants that
|
|
// fan-out names the remote instead.
|
|
func resolvePushTargetURL(remotes []remoteConfig, target string) (string, *pushRefusal) {
|
|
var matches []string
|
|
fanout := false
|
|
for _, remote := range remotes {
|
|
if len(remote.pushURLs) == 1 && remote.pushURLs[0] == target {
|
|
matches = append(matches, remote.name)
|
|
continue
|
|
}
|
|
for _, url := range remote.pushURLs {
|
|
if url == target {
|
|
fanout = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(matches) > 0 {
|
|
// Several remotes can legitimately carry the same URL; they all address
|
|
// the same destination, so first by byte order is well defined.
|
|
sort.Strings(matches)
|
|
return matches[0], nil
|
|
}
|
|
if fanout {
|
|
return "", &pushRefusal{
|
|
code: pushRemoteFanoutErrorCode,
|
|
message: "the requested push URL belongs to a remote that publishes to additional URLs; name the remote instead",
|
|
}
|
|
}
|
|
return "", &pushRefusal{
|
|
code: pushRemoteURLUnmatchedErrorCode,
|
|
message: "no configured remote publishes to the requested URL",
|
|
}
|
|
}
|
|
|
|
// currentBranch reads HEAD as a symbolic ref so that a detached HEAD, reported
|
|
// as an empty branch, is distinguishable from a branch literally named "HEAD".
|
|
// rev-parse --abbrev-ref returns that literal for a detached checkout and so
|
|
// cannot make this distinction.
|
|
func (g *GitOperator) currentBranch(ctx context.Context) (string, error) {
|
|
output, err := g.runGitCommand(ctx, "symbolic-ref", "HEAD")
|
|
if err == nil {
|
|
ref := strings.TrimSpace(output)
|
|
if !strings.HasPrefix(ref, "refs/heads/") {
|
|
return "", nil
|
|
}
|
|
return strings.TrimPrefix(ref, "refs/heads/"), nil
|
|
}
|
|
// symbolic-ref fails both for a detached HEAD and for an unreadable
|
|
// checkout. A resolvable HEAD commit distinguishes the two.
|
|
if _, headErr := g.runGitCommand(ctx, "rev-parse", "--verify", "HEAD"); headErr == nil {
|
|
return "", nil
|
|
}
|
|
return "", fmt.Errorf("failed to read HEAD: %w", err)
|
|
}
|
|
|
|
// pushPlan is the destination a push or preflight resolved to, together with
|
|
// the refspec that reaches it.
|
|
type pushPlan struct {
|
|
remote string
|
|
branch string
|
|
refspec string
|
|
// explicit records that the request named a push target. A push reports
|
|
// the destination fields only in that case, so a request naming none keeps
|
|
// the result shape it has today.
|
|
explicit bool
|
|
// routed records that contribution routing selected the destination.
|
|
routed bool
|
|
// baselineEligible records that empty-remote first publication applies:
|
|
// the origin path with no contribution routing.
|
|
baselineEligible bool
|
|
}
|
|
|
|
// contributionRouted reports whether a contribution binding selects the
|
|
// destination for this operator.
|
|
func (g *GitOperator) contributionRouted() bool {
|
|
return g.contributionDestination != nil || g.remoteContribution != nil
|
|
}
|
|
|
|
// resolvePushPlan evaluates the refusal order from the contribution conflict
|
|
// through the first expected-branch verification, then yields the destination.
|
|
// Every refusal it returns is raised before any remote is contacted, so a
|
|
// refused request leaves the checkout, its refs, and every remote unchanged.
|
|
// requireConfiguredRemote is set by preflight, which answers whether a
|
|
// destination is writable and so must report a missing default remote. A push
|
|
// leaves that to the push command itself.
|
|
func (g *GitOperator) resolvePushPlan(
|
|
ctx context.Context, opts PushOptions, requireConfiguredRemote bool,
|
|
) (*pushPlan, *pushRefusal) {
|
|
if refusal := g.refuseIncompatibleOptions(opts); refusal != nil {
|
|
return nil, refusal
|
|
}
|
|
remote := ""
|
|
if opts.Remote != "" {
|
|
resolved, refusal := g.resolvePushTarget(ctx, opts.Remote)
|
|
if refusal != nil {
|
|
return nil, refusal
|
|
}
|
|
remote = resolved
|
|
}
|
|
if requireConfiguredRemote && remote == "" && !g.contributionRouted() {
|
|
if refusal := g.requireDefaultRemote(ctx); refusal != nil {
|
|
return nil, refusal
|
|
}
|
|
}
|
|
current, err := g.currentBranch(ctx)
|
|
if err != nil {
|
|
return nil, &pushRefusal{message: err.Error()}
|
|
}
|
|
if remote != "" && opts.ExpectedBranch == "" && current == "" {
|
|
return nil, &pushRefusal{
|
|
code: pushBranchDetachedErrorCode,
|
|
message: "cannot publish a detached HEAD without an expected branch",
|
|
}
|
|
}
|
|
if opts.ExpectedBranch != "" && opts.ExpectedBranch != current {
|
|
return nil, branchMismatchRefusal(opts.ExpectedBranch, current, false)
|
|
}
|
|
return g.buildPushPlan(ctx, opts, remote, current)
|
|
}
|
|
|
|
// refuseIncompatibleOptions covers the request-shape refusals, which are the
|
|
// cheapest and so are evaluated before any configuration or checkout read.
|
|
func (g *GitOperator) refuseIncompatibleOptions(opts PushOptions) *pushRefusal {
|
|
if opts.Remote != "" && g.contributionRouted() {
|
|
return &pushRefusal{
|
|
code: pushRemoteContributionConflictErrorCode,
|
|
message: "an explicit push target cannot be combined with a configured contribution",
|
|
}
|
|
}
|
|
if opts.Remote != "" && opts.SetUpstream {
|
|
return &pushRefusal{
|
|
code: pushRemoteUpstreamUnsupportedErrorCode,
|
|
message: "an explicit push target cannot set upstream tracking",
|
|
}
|
|
}
|
|
if opts.ExpectedBranch != "" && !securityutil.IsValidExpectedBranchName(opts.ExpectedBranch) {
|
|
return &pushRefusal{
|
|
code: pushBranchInvalidErrorCode,
|
|
message: fmt.Sprintf("invalid expected branch %q", opts.ExpectedBranch),
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildPushPlan selects the destination and refspec. The contribution paths
|
|
// keep the refspec and branch read they use today; only the explicit-target
|
|
// path is new.
|
|
func (g *GitOperator) buildPushPlan(ctx context.Context, opts PushOptions, remote, current string) (*pushPlan, *pushRefusal) {
|
|
if remote != "" {
|
|
branch := opts.ExpectedBranch
|
|
if branch == "" {
|
|
branch = current
|
|
}
|
|
return &pushPlan{
|
|
remote: remote,
|
|
branch: branch,
|
|
refspec: "HEAD:refs/heads/" + branch,
|
|
explicit: true,
|
|
baselineEligible: remote == defaultPushRemote,
|
|
}, nil
|
|
}
|
|
// The paths below name no explicit target. With no expected branch they
|
|
// must behave exactly as they do today, including reading the branch
|
|
// through getCurrentBranch. With an expected branch, that value is used
|
|
// directly instead of a separate read: verifyExpectedBranch confirms HEAD
|
|
// is still on it immediately before the push runs, and a refspec built
|
|
// from an earlier, independent read could name a branch that check never
|
|
// saw.
|
|
branch := opts.ExpectedBranch
|
|
if branch == "" {
|
|
var err error
|
|
branch, err = g.getCurrentBranch(ctx)
|
|
if err != nil {
|
|
return nil, &pushRefusal{message: err.Error()}
|
|
}
|
|
}
|
|
switch {
|
|
case g.contributionDestination != nil:
|
|
return &pushPlan{
|
|
remote: g.contributionDestination.ContributionRemoteName(),
|
|
branch: branch,
|
|
refspec: branch,
|
|
routed: true,
|
|
}, nil
|
|
case g.remoteContribution != nil:
|
|
return &pushPlan{
|
|
remote: g.remoteContribution.ContributionRemoteName(),
|
|
branch: g.remoteContribution.HeadBranch,
|
|
refspec: "HEAD:refs/heads/" + g.remoteContribution.HeadBranch,
|
|
routed: true,
|
|
}, nil
|
|
default:
|
|
return &pushPlan{
|
|
remote: defaultPushRemote,
|
|
branch: branch,
|
|
refspec: branch,
|
|
baselineEligible: true,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// verifyExpectedBranch is the second verification. It is the last git read
|
|
// before the push command, so nothing Kandev issues can move HEAD between the
|
|
// check and the push.
|
|
func (g *GitOperator) verifyExpectedBranch(ctx context.Context, expected string, baselinePublished bool) *pushRefusal {
|
|
if expected == "" {
|
|
return nil
|
|
}
|
|
current, err := g.currentBranch(ctx)
|
|
if err != nil {
|
|
return &pushRefusal{message: err.Error(), baselinePublished: baselinePublished}
|
|
}
|
|
if current != expected {
|
|
return branchMismatchRefusal(expected, current, baselinePublished)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reportDestination populates the destination fields a successful push reports.
|
|
// A push that named no explicit target omits them so an existing consumer sees
|
|
// an unchanged result shape.
|
|
func (p *pushPlan) reportDestination(result *GitOperationResult) {
|
|
if !p.explicit {
|
|
return
|
|
}
|
|
result.PushedRemote = p.remote
|
|
result.PushedBranch = p.branch
|
|
}
|
|
|
|
// requireDefaultRemote reports a checkout that has nowhere to publish, ahead of
|
|
// the branch guards so a caller learns that rather than a branch it cannot act
|
|
// on yet.
|
|
func (g *GitOperator) requireDefaultRemote(ctx context.Context) *pushRefusal {
|
|
remotes, err := g.configuredRemotes(ctx)
|
|
if err != nil {
|
|
return &pushRefusal{
|
|
code: pushRemoteConfigUnreadableErrorCode,
|
|
message: "failed to read the checkout's remote configuration",
|
|
}
|
|
}
|
|
for _, remote := range remotes {
|
|
if remote.name == defaultPushRemote {
|
|
return nil
|
|
}
|
|
}
|
|
return &pushRefusal{
|
|
code: pushNoRemoteConfiguredErrorCode,
|
|
message: "the checkout has no " + defaultPushRemote + " remote to publish to",
|
|
}
|
|
}
|