Files
DeepSeek-Reasonix/internal/worktree/merge_commit.go
T
esengine ec866730e0 fix(gitcmd): host git never runs repository-configured programs
Every git invocation the host makes goes through gitcmd, and each one must
leave the programs a repository's own configuration names unexecuted:
filter and merge drivers, textconv and external diff, fsmonitor, hooks,
signature verifiers, submodule-boundary programs and lazy-fetch transport.
Driver neutralization now covers every subcommand, not diff alone, and takes
driver names from git itself rather than from a separate reading of .git/config.

- Fixed keys are overridden with -c: core.fsmonitor (empty, not "false",
  which git before 2.35.2 reads as a hook name), core.hooksPath,
  log.showSignature, merge.verifySignatures, diff.submodule, gc.auto and
  maintenance.auto and safe.bareRepository=explicit; submodule.recurse=false
  for checkout, switch, restore, reset, merge and read-tree (host branch
  switches and merges never update submodules, whatever the user's global
  submodule.recurse), and elsewhere only where the repository's own config
  sets it.
  GIT_NO_LAZY_FETCH=1 stops a missing object becoming a fetch through the
  repository's remote (git 2.44+); a historical patch that fails for that
  reason returns gitcmd.ErrObjectNotLocal, decided by git's own
  --missing=print listing.
- diff, log and show carry --no-ext-diff and --no-textconv; status and diff
  carry --ignore-submodules=dirty. The subcommand is found past global
  options, so a call passing -C inside args is hardened too.
- Filter and merge drivers defined at local or worktree scope are listed by
  git itself before each invocation (includes followed) and overridden empty;
  user and system scope stay live; a name -c cannot address fails closed
  (ErrRepositoryDrivers).
- worktree add uses --no-checkout and populates through gitcmd inside the new
  worktree, so a conditional include keyed on the new gitdir or branch is
  seen by the driver listing there rather than run by the add's checkout.
  The reset takes the worktree-mutation timeout, and a failed reset removes
  both the worktree and the branch the add created.
  worktree remove uses --force after the existing hardened clean-tree check,
  so git does not run status inside the linked worktree with its config.
- Launcher ls-remote runs Detached with gitcmd's environment (only PATH is
  taken from the launcher's declared env), requires an https://, ssh:// or
  git:// remote (case-insensitive; http:// and local paths are refused), and
  passes the remote and ref past -- so a leading-dash value is an operand.
- worktree git calls with extra environment keep gitcmd's environment.

- Repository identity is resolved once, when a session opens: boot runs
  gitcmd.Open (rev-parse --absolute-git-dir --git-common-dir --show-toplevel)
  before any command, the controller carries the resulting gitcmd.Repo, and a
  rebuild of the same workspace passes it on. Every later host call runs
  through the Repo with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set, so a
  nested .git, a commondir file, a rewritten .git file or a damaged HEAD
  written afterwards cannot substitute another repository or its config; a
  damaged HEAD fails closed. Threaded through the TUI status line, cli
  review (resolved once before the review agent runs), the desktop changes
  panel, change detail, history, commit detail, branch list/checkout/create
  and tab branch label (the tab session's identity; a tab with no session
  yet resolves at display), worktree Inspect/Create/RollbackCreate, and the
  whole merge-back flow: the source and worktree identities are recorded in
  the merge metadata at creation (the worktree's resolved right after
  worktree add) in metadata.repo.json beside metadata.json, whose shape
  older builds decode strictly and so is unchanged; every inspect,
  auto-commit, merge and cleanup call runs against them, and a worktree
  without that file resolves them from the roots.
  InspectMerge finds the allocation root from the managed layout instead of
  asking git in the worktree.
- Repo.Verify applies Open's test to a stored identity; the identities read
  from metadata.repo.json are verified before the merge flow uses them.
- Auto-commit staging uses gitcmd Repo.StageAll: gitlinks present as
  directories and untracked nested repositories are excluded with
  :(exclude,literal) pathspecs, because add -A checks a submodule by running
  git inside it under its own config. A gitlink keeps the commit the index
  records; an untracked nested repository leaves the worktree unverifiable
  and the auto-commit refuses. The worktree state token no longer runs git
  inside a changed nested repository.
- Open accepts only a git dir belonging to the work tree that holds the
  directory: the work tree's own .git (sharing no other common dir) or a
  linked worktree's admin dir under <common>/worktrees whose gitdir file
  names that work tree's .git. A .git file or core.worktree pointing at
  another checkout is ErrNotRepository. Submodule checkouts and
  --separate-git-dir repositories opened directly are therefore treated as
  not version-controlled.

Regression tests use real git with payload scripts: the gitcmd config-key
matrix (signatures, lazy fetch, submodule boundary), the worktree call sites
(conditional includes on Create, linked-worktree config on RollbackCreate),
the status readout, the launcher's non-URL remote rejection, identity
pinning after a nested .git, a commondir file and a damaged HEAD (config
origin and filter marker), Open refusing another checkout's git dir (.git
file, core.worktree, foreign admin dir), StageAll with nested repositories
named like pathspec magic or globs, metadata.json read by a strict older
decoder, merge-back without the identity file, StageAll and merge-back auto-commit past a gitlink
with its own filter, merge-back after the worktree's .git file is pointed at
another git dir, the status line and desktop changes panel of a workspace
that later gains its own .git, and a global submodule.recurse=true against
host checkout and reset. The list-then-run race is left to a follow-up that
denies sandboxed writes to .git config and hooks.
2026-09-27 17:35:16 -07:00

524 lines
23 KiB
Go

package worktree
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"reasonix/internal/gitcmd"
)
const (
mergeCommitterName = "Reasonix"
mergeCommitterEmail = "reasonix@local"
)
type sourceMutationFence struct {
files []*os.File
paths []string
}
func mergeSourceCheckout(ctx context.Context, inspection MergeInspection) (string, bool, error) {
originalHead := inspection.TargetHead
message := fmt.Sprintf("Merge worktree branch '%s' into %s", inspection.WorktreeBranch, inspection.TargetBranch)
noteMergeStep("before_merge_prepare")
if err := verifySourceIdentity(ctx, inspection.source, inspection.TargetBranch, originalHead, false); err != nil {
return "", false, fmt.Errorf("source changed before merge preparation: %w", err)
}
if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil {
return "", false, fmt.Errorf("worktree changed before merge preparation: %w", err)
}
expectedTree, hasConflicts, conflictFiles, err := mergeTree(ctx, inspection.source, originalHead, inspection.WorktreeHead)
if err != nil {
return "", false, fmt.Errorf("recompute source merge tree: %w", err)
}
if hasConflicts {
return "", false, fmt.Errorf("source merge conflicts changed after inspection: %s", strings.Join(conflictFiles, ", "))
}
if _, stderr, err := runGit(ctx, inspection.source,
"-c", "user.name="+mergeCommitterName, "-c", "user.email="+mergeCommitterEmail,
"merge", "--no-ff", "--no-commit", "--no-verify", inspection.WorktreeHead); err != nil {
recovered, recoveryErr := abortAndVerifyMerge(ctx, inspection.source, inspection.TargetBranch, originalHead)
if !recovered {
return "", true, fmt.Errorf("merge failed%s: %w", stderrSuffix(stderr), errors.Join(err, fmt.Errorf("automatic recovery failed: %w", recoveryErr)))
}
return "", false, fmt.Errorf("merge failed and was aborted: %w%s", err, stderrSuffix(stderr))
}
noteMergeStep("after_merge_prepare")
if err := verifyPreparedMerge(ctx, inspection.source, inspection.TargetBranch, originalHead, inspection.WorktreeHead, expectedTree); err != nil {
return "", true, fmt.Errorf("merge preparation identity changed; source state was preserved for recovery: %w", err)
}
if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil {
return abortPreparedWorktreeDrift(ctx, inspection, originalHead, err)
}
mergedHead, stderr, err := gitValue(ctx, inspection.source,
"-c", "user.name="+mergeCommitterName, "-c", "user.email="+mergeCommitterEmail,
"commit-tree", expectedTree, "-p", originalHead, "-p", inspection.WorktreeHead, "-m", message)
if err != nil {
recovered, recoveryErr := abortAndVerifyMerge(ctx, inspection.source, inspection.TargetBranch, originalHead)
if !recovered {
return "", true, fmt.Errorf("create exact merge commit%s: %w", stderrSuffix(stderr), errors.Join(err, fmt.Errorf("automatic recovery failed: %w", recoveryErr)))
}
return "", false, fmt.Errorf("create exact merge commit: %w%s; merge was aborted", err, stderrSuffix(stderr))
}
noteMergeStep("after_merge_commit_object")
if err := verifyPreparedMerge(ctx, inspection.source, inspection.TargetBranch, originalHead, inspection.WorktreeHead, expectedTree); err != nil {
return "", true, fmt.Errorf("source changed before target ref update; source state was preserved for recovery: %w", err)
}
snapshot, err := snapshotPreparedSourceFiles(ctx, inspection.source)
if err != nil {
return abortPreparedSourceDrift(ctx, inspection, originalHead, expectedTree, err)
}
noteMergeStep("before_merge_ref_update")
fence, err := acquireSourceMutationFence(ctx, inspection.source)
if err != nil {
return abortPreparedSourceDrift(ctx, inspection, originalHead, expectedTree, fmt.Errorf("acquire source mutation fence: %w", err))
}
err = verifyPreparedSourceFiles(ctx, inspection.source, snapshot)
if err == nil {
err = verifyWorktreeMergeIdentity(ctx, inspection)
}
if err == nil {
err = verifyPreparedSourceFiles(ctx, inspection.source, snapshot)
}
if err != nil {
fence.release()
return abortPreparedSourceDrift(ctx, inspection, originalHead, expectedTree, err)
}
noteMergeStep("before_merge_ref_transaction")
if stderr, err := updateMergeRefs(ctx, inspection, originalHead, mergedHead); err != nil {
fence.release()
return recoverRefUpdateFailure(ctx, inspection, originalHead, expectedTree, stderr, err)
}
fence.release()
noteMergeStep("after_merge_ref_update")
if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil {
return "", true, fmt.Errorf("merge commit was installed but the worktree identity changed; recovery is required: %w", err)
}
if err := verifyInstalledMergeState(ctx, inspection, mergedHead, expectedTree); err != nil {
return "", true, fmt.Errorf("merge commit was installed but prepared source state changed; recovery is required: %w", err)
}
if _, stderr, err := runGit(ctx, inspection.source, "merge", "--quit"); err != nil {
return "", true, fmt.Errorf("merge commit was installed but merge state cleanup failed; source requires recovery: %w%s", err, stderrSuffix(stderr))
}
noteMergeStep("after_merge_commit")
verifiedHead, err := verifySuccessfulMerge(ctx, inspection.source, inspection.TargetBranch, originalHead, inspection.WorktreeHead, expectedTree)
if err != nil {
return "", true, fmt.Errorf("merge succeeded but the source checkout requires recovery: %w", err)
}
if err := verifyWorktreeMergeIdentity(ctx, inspection); err != nil {
return "", true, fmt.Errorf("merge succeeded but the worktree identity changed; recovery is required: %w", err)
}
return verifiedHead, false, nil
}
func abortPreparedSourceDrift(ctx context.Context, inspection MergeInspection, originalHead, expectedTree string, driftErr error) (string, bool, error) {
if verifyErr := verifyPreparedMerge(ctx, inspection.source, inspection.TargetBranch, originalHead, inspection.WorktreeHead, expectedTree); verifyErr != nil {
return "", true, fmt.Errorf("source changed before target ref update; source state was preserved for recovery: %w", driftErr)
}
recovered, recoveryErr := abortAndVerifyMerge(ctx, inspection.source, inspection.TargetBranch, originalHead)
if recovered {
return "", false, fmt.Errorf("source changed before target ref update; merge was aborted: %w", driftErr)
}
return "", true, fmt.Errorf("source changed before target ref update: %w", errors.Join(driftErr, fmt.Errorf("automatic recovery failed: %w", recoveryErr)))
}
func abortPreparedWorktreeDrift(ctx context.Context, inspection MergeInspection, originalHead string, driftErr error) (string, bool, error) {
recovered, recoveryErr := abortAndVerifyMerge(ctx, inspection.source, inspection.TargetBranch, originalHead)
if recovered {
return "", false, fmt.Errorf("worktree changed before target ref update; merge was aborted: %w", driftErr)
}
return "", true, fmt.Errorf("worktree changed before target ref update: %w", errors.Join(driftErr, fmt.Errorf("automatic recovery failed: %w", recoveryErr)))
}
func updateMergeRefs(ctx context.Context, inspection MergeInspection, originalHead, mergedHead string) (string, error) {
targetRef := "refs/heads/" + inspection.TargetBranch
worktreeRef := "refs/heads/" + inspection.WorktreeBranch
input := fmt.Sprintf("verify %s %s\nupdate %s %s %s\n", worktreeRef, inspection.WorktreeHead, targetRef, mergedHead, originalHead)
transactionDir, err := createDetachedRefTransactionDir(ctx, inspection.source)
if err != nil {
return "", err
}
transaction := inspection.source
transaction.GitDir = transactionDir
_, stderr, updateErr := runGitEnvInput(ctx, transaction, input, nil, "update-ref", "--stdin")
cleanupErr := removeDetachedRefTransactionDir(transactionDir)
err = errors.Join(updateErr, cleanupErr)
return stderr, err
}
// createDetachedRefTransactionDir gives update-ref access to the repository's
// common ref store without identifying the source checkout as its active
// worktree. That lets the caller keep the source HEAD.lock held while Git owns
// and atomically updates the target/worktree branch refs. A normal update-ref
// run from the source checkout also tries to lock HEAD for its reflog.
func createDetachedRefTransactionDir(ctx context.Context, sourceRoot gitcmd.Repo) (string, error) {
commonDir := sourceRoot.CommonDir
transactionDir, err := os.MkdirTemp("", "reasonix-ref-transaction-")
if err != nil {
return "", fmt.Errorf("create detached ref transaction directory: %w", err)
}
write := func(name, body string) error {
path := filepath.Join(transactionDir, name)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
return fmt.Errorf("write detached ref transaction %s: %w", name, err)
}
return nil
}
if err := write("commondir", commonDir+"\n"); err != nil {
_ = removeDetachedRefTransactionDir(transactionDir)
return "", err
}
if err := write("HEAD", "ref: refs/reasonix/merge-back-ref-transaction\n"); err != nil {
_ = removeDetachedRefTransactionDir(transactionDir)
return "", err
}
return transactionDir, nil
}
func removeDetachedRefTransactionDir(path string) error {
var cleanupErr error
for _, name := range []string{"HEAD", "commondir"} {
if err := os.Remove(filepath.Join(path, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("remove detached ref transaction %s: %w", name, err))
}
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("remove detached ref transaction directory: %w", err))
}
return cleanupErr
}
func acquireSourceMutationFence(ctx context.Context, sourceRoot gitcmd.Repo) (*sourceMutationFence, error) {
fence := &sourceMutationFence{}
// update-ref must own HEAD.lock while advancing the checked-out target.
// Keep the mutable non-ref state fenced here and verify HEAD in the same
// ref transaction as the target/worktree refs.
markers := []string{"HEAD", "MERGE_HEAD", "index"}
paths := make([]string, 0, len(markers))
for _, marker := range markers {
path, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--git-path", marker)
if err != nil {
return nil, fmt.Errorf("resolve %s lock path: %w%s", marker, err, stderrSuffix(stderr))
}
if !filepath.IsAbs(path) {
path = filepath.Join(sourceRoot.Dir, path)
}
paths = append(paths, filepath.Clean(path)+".lock")
}
sort.Strings(paths)
for _, path := range paths {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
fence.release()
return nil, fmt.Errorf("lock %s: %w", filepath.Base(strings.TrimSuffix(path, ".lock")), err)
}
fence.files = append(fence.files, file)
fence.paths = append(fence.paths, path)
}
return fence, nil
}
func (fence *sourceMutationFence) release() {
if fence == nil {
return
}
for index, file := range slices.Backward(fence.files) {
_ = file.Close()
_ = os.Remove(fence.paths[index])
}
fence.files = nil
fence.paths = nil
}
type preparedSourceFiles map[string]string
func snapshotPreparedSourceFiles(ctx context.Context, sourceRoot gitcmd.Repo) (preparedSourceFiles, error) {
snapshot := preparedSourceFiles{}
for _, marker := range []string{"HEAD", "MERGE_HEAD", "index"} {
path, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--git-path", marker)
if err != nil {
return nil, fmt.Errorf("resolve prepared %s: %w%s", marker, err, stderrSuffix(stderr))
}
if !filepath.IsAbs(path) {
path = filepath.Join(sourceRoot.Dir, path)
}
body, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read prepared %s: %w", marker, err)
}
snapshot[filepath.Clean(path)] = string(body)
}
return snapshot, nil
}
func verifyPreparedSourceFiles(ctx context.Context, sourceRoot gitcmd.Repo, snapshot preparedSourceFiles) error {
current, err := snapshotPreparedSourceFiles(ctx, sourceRoot)
if err != nil {
return err
}
for path, expected := range snapshot {
if current[path] != expected {
return fmt.Errorf("prepared %s changed while target ref was fenced", filepath.Base(path))
}
}
return nil
}
func verifyInstalledMergeState(ctx context.Context, inspection MergeInspection, mergedHead, expectedTree string) error {
branch, stderr, err := gitValue(ctx, inspection.source, "symbolic-ref", "--quiet", "--short", "HEAD")
if err != nil || branch != inspection.TargetBranch {
return fmt.Errorf("source branch is %q, expected %q%s", branch, inspection.TargetBranch, stderrSuffix(stderr))
}
head, stderr, err := gitValue(ctx, inspection.source, "rev-parse", "--verify", "HEAD")
if err != nil || head != mergedHead {
return fmt.Errorf("source HEAD is %s, expected installed merge %s%s", head, mergedHead, stderrSuffix(stderr))
}
mergeHead, stderr, err := gitValue(ctx, inspection.source, "rev-parse", "--verify", "MERGE_HEAD")
if err != nil || mergeHead != inspection.WorktreeHead {
return fmt.Errorf("MERGE_HEAD changed from %s to %s%s", inspection.WorktreeHead, mergeHead, stderrSuffix(stderr))
}
preparedTree, stderr, err := gitValue(ctx, inspection.source, "write-tree")
if err != nil || preparedTree != expectedTree {
return fmt.Errorf("prepared source tree is %s, expected %s%s", preparedTree, expectedTree, stderrSuffix(stderr))
}
return nil
}
func recoverRefUpdateFailure(ctx context.Context, inspection MergeInspection, originalHead, expectedTree, stderr string, updateErr error) (string, bool, error) {
targetRef, targetStderr, targetErr := gitValue(ctx, inspection.source, "rev-parse", "--verify", "refs/heads/"+inspection.TargetBranch)
if targetErr != nil || targetRef != originalHead {
return "", true, fmt.Errorf("target ref changed during compare-and-swap; source requires recovery: %w%s%s", updateErr, stderrSuffix(stderr), stderrSuffix(targetStderr))
}
if verifyErr := verifyPreparedMerge(ctx, inspection.source, inspection.TargetBranch, originalHead, inspection.WorktreeHead, expectedTree); verifyErr != nil {
return "", true, fmt.Errorf("target ref changed during compare-and-swap; source requires recovery: %w%s", updateErr, stderrSuffix(stderr))
}
recovered, recoveryErr := abortAndVerifyMerge(ctx, inspection.source, inspection.TargetBranch, originalHead)
if recovered {
return "", false, fmt.Errorf("target ref update failed and merge was aborted: %w%s", updateErr, stderrSuffix(stderr))
}
return "", true, fmt.Errorf("target ref update failed%s: %w", stderrSuffix(stderr), errors.Join(updateErr, fmt.Errorf("automatic recovery failed: %w", recoveryErr)))
}
func verifyWorktreeMergeIdentity(ctx context.Context, inspection MergeInspection) error {
if err := verifyRepositoryRoot(ctx, inspection.worktree); err != nil {
return fmt.Errorf("worktree checkout identity changed: %w", err)
}
if err := verifySameCommonDir(inspection.source, inspection.worktree); err != nil {
return fmt.Errorf("worktree repository identity changed: %w", err)
}
branch, stderr, err := gitValue(ctx, inspection.worktree, "symbolic-ref", "--quiet", "--short", "HEAD")
if err != nil || branch != inspection.WorktreeBranch {
return fmt.Errorf("worktree branch is %q, expected %q%s", branch, inspection.WorktreeBranch, stderrSuffix(stderr))
}
branchHead, stderr, err := gitValue(ctx, inspection.worktree, "rev-parse", "--verify", "refs/heads/"+inspection.WorktreeBranch)
if err != nil || branchHead != inspection.WorktreeHead {
return fmt.Errorf("worktree branch HEAD changed from %s to %s%s", inspection.WorktreeHead, branchHead, stderrSuffix(stderr))
}
head, stderr, err := gitValue(ctx, inspection.worktree, "rev-parse", "--verify", "HEAD")
if err != nil || head != inspection.WorktreeHead {
return fmt.Errorf("worktree HEAD changed from %s to %s%s", inspection.WorktreeHead, head, stderrSuffix(stderr))
}
operation, err := gitOperation(ctx, inspection.worktree)
if err != nil {
return err
}
if operation != "" {
return fmt.Errorf("worktree Git %s operation is in progress", operation)
}
token, err := worktreeStateToken(ctx, inspection.worktree)
if err != nil {
return fmt.Errorf("snapshot worktree contents: %w", err)
}
if token != inspection.WorktreeStateToken {
return errors.New("worktree contents changed after confirmation")
}
return nil
}
func mergeTree(ctx context.Context, root gitcmd.Repo, targetHead, worktreeHead string) (string, bool, []string, error) {
out, stderr, err := runGit(ctx, root, "merge-tree", "--write-tree", "--name-only", targetHead, worktreeHead)
lines := strings.Split(out, "\n")
tree := ""
if len(lines) > 0 {
tree = strings.TrimSpace(lines[0])
}
if err == nil {
if !isHexObject(tree) {
return "", false, []string{}, errors.New("preflight merge did not produce a tree")
}
return tree, false, []string{}, nil
}
if exitCode(err) != 1 {
return "", false, []string{}, fmt.Errorf("preflight merge conflicts: %w%s", err, stderrSuffix(stderr))
}
paths := []string{}
for index, line := range lines {
line = strings.TrimSpace(line)
if index == 0 || line == "" || strings.Contains(line, " ") || isHexObject(line) {
continue
}
paths = append(paths, line)
}
sort.Strings(paths)
return tree, true, paths, nil
}
func isHexObject(value string) bool {
if len(value) != 40 && len(value) != 64 {
return false
}
for _, char := range value {
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {
return false
}
}
return true
}
func verifySourceIdentity(ctx context.Context, sourceRoot gitcmd.Repo, targetBranch, originalHead string, expectMerge bool) error {
branch, stderr, err := gitValue(ctx, sourceRoot, "symbolic-ref", "--quiet", "--short", "HEAD")
if err != nil || branch != targetBranch {
return fmt.Errorf("source branch is %q, expected %q%s", branch, targetBranch, stderrSuffix(stderr))
}
head, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", "HEAD")
if err != nil || head != originalHead {
return fmt.Errorf("source HEAD changed from %s to %s%s", originalHead, head, stderrSuffix(stderr))
}
operation, err := gitOperation(ctx, sourceRoot)
if err != nil {
return err
}
if expectMerge && operation != "merge" {
return fmt.Errorf("prepared merge operation is missing (found %q)", operation)
}
if !expectMerge && operation != "" {
return fmt.Errorf("source Git %s operation is already in progress", operation)
}
if !expectMerge {
status, stderr, err := runGit(ctx, sourceRoot, "status", "--porcelain=v1", "--untracked-files=all")
if err != nil {
return fmt.Errorf("inspect source status: %w%s", err, stderrSuffix(stderr))
}
if strings.TrimSpace(status) != "" {
return errors.New("source checkout is no longer clean")
}
}
return nil
}
func verifyPreparedMerge(ctx context.Context, sourceRoot gitcmd.Repo, targetBranch, originalHead, worktreeHead, expectedTree string) error {
if err := verifySourceIdentity(ctx, sourceRoot, targetBranch, originalHead, true); err != nil {
return err
}
mergeHead, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", "MERGE_HEAD")
if err != nil {
return fmt.Errorf("read prepared MERGE_HEAD: %w%s", err, stderrSuffix(stderr))
}
if mergeHead != worktreeHead {
return fmt.Errorf("prepared MERGE_HEAD is %s, expected %s", mergeHead, worktreeHead)
}
preparedTree, stderr, err := gitValue(ctx, sourceRoot, "write-tree")
if err != nil {
return fmt.Errorf("read prepared merge tree: %w%s", err, stderrSuffix(stderr))
}
if preparedTree != expectedTree {
return fmt.Errorf("prepared merge tree is %s, expected %s", preparedTree, expectedTree)
}
return nil
}
func abortAndVerifyMerge(ctx context.Context, sourceRoot gitcmd.Repo, targetBranch, originalHead string) (bool, error) {
operation, operationErr := gitOperation(ctx, sourceRoot)
if operationErr != nil {
return false, operationErr
}
if operation == "merge" {
if _, stderr, err := runGit(ctx, sourceRoot, "merge", "--abort"); err != nil {
return false, fmt.Errorf("git merge --abort: %w%s", err, stderrSuffix(stderr))
}
}
if err := verifySourceIdentity(ctx, sourceRoot, targetBranch, originalHead, false); err != nil {
return false, err
}
branchRef, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", "refs/heads/"+targetBranch)
if err != nil || branchRef != originalHead {
return false, fmt.Errorf("target branch ref was not restored%s", stderrSuffix(stderr))
}
status, stderr, err := runGit(ctx, sourceRoot, "status", "--porcelain=v1", "--untracked-files=all")
if err != nil || strings.TrimSpace(status) != "" {
return false, fmt.Errorf("source checkout was not restored clean%s", stderrSuffix(stderr))
}
operation, err = gitOperation(ctx, sourceRoot)
if err != nil || operation != "" {
return false, fmt.Errorf("source Git operation remains after abort: %s", operation)
}
return true, nil
}
func verifySuccessfulMerge(ctx context.Context, sourceRoot gitcmd.Repo, targetBranch, originalHead, worktreeHead, expectedTree string) (string, error) {
branch, stderr, err := gitValue(ctx, sourceRoot, "symbolic-ref", "--quiet", "--short", "HEAD")
if err != nil || branch != targetBranch {
return "", fmt.Errorf("source branch changed after merge; found %q, expected %q%s", branch, targetBranch, stderrSuffix(stderr))
}
mergedHead, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", "HEAD")
if err != nil {
return "", fmt.Errorf("read merged target HEAD: %w%s", err, stderrSuffix(stderr))
}
branchHead, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", "refs/heads/"+targetBranch)
if err != nil || branchHead != mergedHead {
return "", fmt.Errorf("target branch ref does not identify the merge commit%s", stderrSuffix(stderr))
}
commitTree, stderr, err := gitValue(ctx, sourceRoot, "rev-parse", "--verify", mergedHead+"^{tree}")
if err != nil {
return "", fmt.Errorf("read merge commit tree: %w%s", err, stderrSuffix(stderr))
}
if commitTree != expectedTree {
return "", errors.New("merge commit tree differs from the exact prepared tree")
}
indexTree, stderr, err := gitValue(ctx, sourceRoot, "write-tree")
if err != nil {
return "", fmt.Errorf("read merged source index tree: %w%s", err, stderrSuffix(stderr))
}
if indexTree != expectedTree {
return "", errors.New("merged source index differs from the exact prepared tree")
}
parents, stderr, err := gitValue(ctx, sourceRoot, "rev-list", "--parents", "-n", "1", mergedHead)
if err != nil {
return "", fmt.Errorf("read merge commit parents: %w%s", err, stderrSuffix(stderr))
}
fields := strings.Fields(parents)
if len(fields) != 3 || fields[0] != mergedHead || fields[1] != originalHead || fields[2] != worktreeHead {
return "", errors.New("merge commit does not have the exact prepared parents")
}
for _, ancestor := range []struct{ label, head string }{{"original target", originalHead}, {"worktree", worktreeHead}} {
contained, ancestorErr := isAncestor(ctx, sourceRoot, ancestor.head, mergedHead)
if ancestorErr != nil {
return "", fmt.Errorf("verify %s ancestry: %w", ancestor.label, ancestorErr)
}
if !contained {
return "", fmt.Errorf("%s HEAD is not contained in merged target", ancestor.label)
}
}
status, stderr, err := runGit(ctx, sourceRoot, "status", "--porcelain=v1", "--untracked-files=all")
if err != nil {
return "", fmt.Errorf("verify merged source status: %w%s", err, stderrSuffix(stderr))
}
if strings.TrimSpace(status) != "" {
return "", errors.New("merged source checkout is not clean")
}
operation, err := gitOperation(ctx, sourceRoot)
if err != nil {
return "", err
}
if operation != "" {
return "", fmt.Errorf("source Git %s operation remains after merge", operation)
}
return mergedHead, nil
}