266 lines
8.5 KiB
Go
266 lines
8.5 KiB
Go
// Package gitbootstrap creates and validates Kandev's local baseline for an
|
|
// authenticated remote that has no refs yet.
|
|
package gitbootstrap
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/kandev/kandev/internal/common/securityutil"
|
|
"github.com/kandev/kandev/internal/common/subproc"
|
|
)
|
|
|
|
const (
|
|
// EmptyTreeSHA is the empty tree object in the default SHA-1 Git object
|
|
// format. Repositories using another object format still receive the
|
|
// repository's own empty-tree object from git mktree.
|
|
EmptyTreeSHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
markerPrefix = "refs/kandev/empty-remote/"
|
|
commitMessage = "Kandev empty remote baseline"
|
|
identityName = "Kandev"
|
|
identityEmail = "kandev@localhost"
|
|
commitDate = "1970-01-01T00:00:00Z"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidBaseBranch = errors.New("invalid empty-remote base branch")
|
|
ErrBaselineConflict = errors.New("empty-remote baseline conflicts with local Git history")
|
|
)
|
|
|
|
// Baseline identifies the local commit and refs that represent an empty
|
|
// remote. The marker and base refs must always point at the same commit.
|
|
type Baseline struct {
|
|
Commit string
|
|
BaseRef string
|
|
MarkerRef string
|
|
}
|
|
|
|
// MarkerRef returns the local marker ref for a validated base branch.
|
|
func MarkerRef(baseBranch string) (string, error) {
|
|
branch, err := normalizeBranch(baseBranch)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return markerPrefix + branch, nil
|
|
}
|
|
|
|
// Ensure creates the deterministic local baseline when neither local ref
|
|
// exists. It never writes files or contacts the remote. A concurrent local
|
|
// history write is reported as a conflict and is never overwritten.
|
|
func Ensure(ctx context.Context, repoPath, baseBranch string) (Baseline, error) {
|
|
baseRef, markerRef, err := refsForBranch(baseBranch)
|
|
if err != nil {
|
|
return Baseline{}, err
|
|
}
|
|
existing, present, err := readBaseline(ctx, repoPath, baseRef, markerRef)
|
|
if err != nil {
|
|
return Baseline{}, err
|
|
}
|
|
if present {
|
|
return existing, nil
|
|
}
|
|
if basePresent, err := refPresent(ctx, repoPath, baseRef); err != nil {
|
|
return Baseline{}, err
|
|
} else if basePresent {
|
|
return Baseline{}, ErrBaselineConflict
|
|
}
|
|
|
|
tree, err := createEmptyTree(ctx, repoPath)
|
|
if err != nil {
|
|
return Baseline{}, fmt.Errorf("create empty Git tree: %w", err)
|
|
}
|
|
commit, err := createCommit(ctx, repoPath, tree)
|
|
if err != nil {
|
|
return Baseline{}, fmt.Errorf("create empty-remote baseline commit: %w", err)
|
|
}
|
|
baseline := Baseline{Commit: commit, BaseRef: baseRef, MarkerRef: markerRef}
|
|
if err := createRefs(ctx, repoPath, baseline); err != nil {
|
|
verified, verifiedPresent, verifyErr := readBaseline(ctx, repoPath, baseRef, markerRef)
|
|
if verifyErr == nil && verifiedPresent && verified.Commit == commit {
|
|
return verified, nil
|
|
}
|
|
if verifyErr != nil {
|
|
return Baseline{}, fmt.Errorf("publish local empty-remote refs: %w; verify race: %v", err, verifyErr)
|
|
}
|
|
if verifiedPresent {
|
|
return Baseline{}, ErrBaselineConflict
|
|
}
|
|
return Baseline{}, fmt.Errorf("publish local empty-remote refs: %w", err)
|
|
}
|
|
return baseline, nil
|
|
}
|
|
|
|
// Validate reports whether the exact local baseline marker exists. A missing
|
|
// marker is not an error because it means this repository is not bootstrapped.
|
|
func Validate(ctx context.Context, repoPath, baseBranch string) (Baseline, bool, error) {
|
|
baseRef, markerRef, err := refsForBranch(baseBranch)
|
|
if err != nil {
|
|
return Baseline{}, false, err
|
|
}
|
|
baseline, present, err := readBaseline(ctx, repoPath, baseRef, markerRef)
|
|
return baseline, present, err
|
|
}
|
|
|
|
// Retire removes the local bootstrap marker after the baseline has been
|
|
// published. The expected commit protects a concurrent local ref change.
|
|
func Retire(ctx context.Context, repoPath string, baseline Baseline) error {
|
|
if strings.TrimSpace(baseline.MarkerRef) == "" || strings.TrimSpace(baseline.Commit) == "" {
|
|
return errors.New("invalid empty-remote baseline marker")
|
|
}
|
|
input := strings.Join([]string{
|
|
"start",
|
|
"delete " + baseline.MarkerRef + " " + baseline.Commit,
|
|
"prepare",
|
|
"commit",
|
|
"",
|
|
}, "\n")
|
|
if _, err := runGitWithInput(ctx, repoPath, input, "update-ref", "--stdin"); err != nil {
|
|
return fmt.Errorf("retire empty-remote baseline marker: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func refsForBranch(baseBranch string) (string, string, error) {
|
|
branch, err := normalizeBranch(baseBranch)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return "refs/heads/" + branch, markerPrefix + branch, nil
|
|
}
|
|
|
|
func normalizeBranch(baseBranch string) (string, error) {
|
|
branch := strings.TrimPrefix(strings.TrimSpace(baseBranch), "origin/")
|
|
if !securityutil.IsValidBaseBranchRef(branch) {
|
|
return "", fmt.Errorf("%w: %q", ErrInvalidBaseBranch, baseBranch)
|
|
}
|
|
return branch, nil
|
|
}
|
|
|
|
func readBaseline(ctx context.Context, repoPath, baseRef, markerRef string) (Baseline, bool, error) {
|
|
baseCommit, basePresent, err := readRef(ctx, repoPath, baseRef)
|
|
if err != nil {
|
|
return Baseline{}, false, err
|
|
}
|
|
markerCommit, markerPresent, err := readRef(ctx, repoPath, markerRef)
|
|
if err != nil {
|
|
return Baseline{}, false, err
|
|
}
|
|
if !markerPresent {
|
|
return Baseline{}, false, nil
|
|
}
|
|
if !basePresent || !markerPresent || baseCommit != markerCommit {
|
|
return Baseline{}, false, ErrBaselineConflict
|
|
}
|
|
return Baseline{Commit: baseCommit, BaseRef: baseRef, MarkerRef: markerRef}, true, nil
|
|
}
|
|
|
|
func refPresent(ctx context.Context, repoPath, ref string) (bool, error) {
|
|
_, present, err := readRef(ctx, repoPath, ref)
|
|
return present, err
|
|
}
|
|
|
|
func readRef(ctx context.Context, repoPath, ref string) (string, bool, error) {
|
|
output, err := runGit(ctx, repoPath, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
|
|
if err != nil {
|
|
if strings.TrimSpace(output) == "" {
|
|
return "", false, nil
|
|
}
|
|
return "", false, fmt.Errorf("read local Git ref %q: %w", ref, err)
|
|
}
|
|
commit := strings.TrimSpace(output)
|
|
if commit == "" {
|
|
return "", false, nil
|
|
}
|
|
return commit, true, nil
|
|
}
|
|
|
|
func createEmptyTree(ctx context.Context, repoPath string) (string, error) {
|
|
output, err := runGitWithInput(ctx, repoPath, "", "mktree")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tree := strings.TrimSpace(output)
|
|
if tree == "" {
|
|
return "", errors.New("git mktree returned no object ID")
|
|
}
|
|
return tree, nil
|
|
}
|
|
|
|
func createCommit(ctx context.Context, repoPath, tree string) (string, error) {
|
|
cmd := subproc.NewGitCommand(ctx,
|
|
"-C", repoPath,
|
|
"-c", "user.name="+identityName,
|
|
"-c", "user.email="+identityEmail,
|
|
"-c", "commit.gpgSign=false",
|
|
"commit-tree", tree, "-m", commitMessage,
|
|
)
|
|
cmd.Env = baselineGitEnvironment()
|
|
output, err := subproc.RunGitCombinedOutputClass(ctx, subproc.GitLifecycle, cmd)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
|
|
}
|
|
commit := strings.TrimSpace(string(output))
|
|
if commit == "" {
|
|
return "", errors.New("git commit-tree returned no object ID")
|
|
}
|
|
return commit, nil
|
|
}
|
|
|
|
func baselineGitEnvironment() []string {
|
|
const (
|
|
authorName = "GIT_AUTHOR_NAME="
|
|
authorEmail = "GIT_AUTHOR_EMAIL="
|
|
committerName = "GIT_COMMITTER_NAME="
|
|
committerEmail = "GIT_COMMITTER_EMAIL="
|
|
)
|
|
env := make([]string, 0, len(os.Environ())+6)
|
|
for _, entry := range os.Environ() {
|
|
if strings.HasPrefix(entry, authorName) || strings.HasPrefix(entry, authorEmail) ||
|
|
strings.HasPrefix(entry, committerName) || strings.HasPrefix(entry, committerEmail) {
|
|
continue
|
|
}
|
|
env = append(env, entry)
|
|
}
|
|
return append(env,
|
|
"GIT_AUTHOR_DATE="+commitDate,
|
|
"GIT_COMMITTER_DATE="+commitDate,
|
|
"GIT_AUTHOR_NAME="+identityName,
|
|
"GIT_AUTHOR_EMAIL="+identityEmail,
|
|
"GIT_COMMITTER_NAME="+identityName,
|
|
"GIT_COMMITTER_EMAIL="+identityEmail,
|
|
"GIT_CONFIG_NOSYSTEM=1",
|
|
"GIT_TERMINAL_PROMPT=0",
|
|
)
|
|
}
|
|
|
|
func createRefs(ctx context.Context, repoPath string, baseline Baseline) error {
|
|
input := strings.Join([]string{
|
|
"start",
|
|
"create " + baseline.BaseRef + " " + baseline.Commit,
|
|
"create " + baseline.MarkerRef + " " + baseline.Commit,
|
|
"prepare",
|
|
"commit",
|
|
"",
|
|
}, "\n")
|
|
_, err := runGitWithInput(ctx, repoPath, input, "update-ref", "--stdin")
|
|
return err
|
|
}
|
|
|
|
func runGitWithInput(ctx context.Context, repoPath, input string, args ...string) (string, error) {
|
|
cmd := subproc.NewGitCommand(ctx, append([]string{"-C", repoPath}, args...)...)
|
|
cmd.Stdin = strings.NewReader(input)
|
|
cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0")
|
|
output, err := subproc.RunGitCombinedOutputClass(ctx, subproc.GitLifecycle, cmd)
|
|
if err != nil {
|
|
return string(output), fmt.Errorf("git %v: %s: %w", args, strings.TrimSpace(string(output)), err)
|
|
}
|
|
return string(output), nil
|
|
}
|
|
|
|
func runGit(ctx context.Context, repoPath string, args ...string) (string, error) {
|
|
return runGitWithInput(ctx, repoPath, "", args...)
|
|
}
|