源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,701 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/tutti-os/tutti/packages/agent/daemon/runtimecmd"
|
||||
)
|
||||
|
||||
const agentGitPatchLogPrefix = "[agent-git-patch]"
|
||||
|
||||
type ApplyGitPatchStatus string
|
||||
|
||||
const (
|
||||
ApplyGitPatchStatusSuccess ApplyGitPatchStatus = "success"
|
||||
ApplyGitPatchStatusPartialSuccess ApplyGitPatchStatus = "partial-success"
|
||||
ApplyGitPatchStatusError ApplyGitPatchStatus = "error"
|
||||
)
|
||||
|
||||
type ApplyGitPatchErrorCode string
|
||||
|
||||
const (
|
||||
ApplyGitPatchErrorNone ApplyGitPatchErrorCode = ""
|
||||
ApplyGitPatchErrorNotGitRepo ApplyGitPatchErrorCode = "not-git-repo"
|
||||
ApplyGitPatchErrorInvalidPatch ApplyGitPatchErrorCode = "invalid-patch"
|
||||
ApplyGitPatchErrorPatchDoesNotApply ApplyGitPatchErrorCode = "patch-does-not-apply"
|
||||
)
|
||||
|
||||
type ApplyGitPatchTarget string
|
||||
|
||||
const (
|
||||
ApplyGitPatchTargetUnstaged ApplyGitPatchTarget = "unstaged"
|
||||
ApplyGitPatchTargetStaged ApplyGitPatchTarget = "staged"
|
||||
ApplyGitPatchTargetStagedAndUnstaged ApplyGitPatchTarget = "staged-and-unstaged"
|
||||
)
|
||||
|
||||
type ApplyGitPatchInput struct {
|
||||
Cwd string
|
||||
Diff string
|
||||
Revert bool
|
||||
Atomic bool
|
||||
Target ApplyGitPatchTarget
|
||||
AllowBinary bool
|
||||
}
|
||||
|
||||
type ApplyGitPatchExecOutput struct {
|
||||
Command string
|
||||
Stdout string
|
||||
Stderr string
|
||||
}
|
||||
|
||||
type ApplyGitPatchResult struct {
|
||||
Status ApplyGitPatchStatus
|
||||
AppliedPaths []string
|
||||
SkippedPaths []string
|
||||
ConflictedPaths []string
|
||||
ErrorCode ApplyGitPatchErrorCode
|
||||
ExecOutput ApplyGitPatchExecOutput
|
||||
}
|
||||
|
||||
type GitPatchSupport struct {
|
||||
Supported bool
|
||||
Root string
|
||||
ErrorCode ApplyGitPatchErrorCode
|
||||
}
|
||||
|
||||
type applyGitPatchOptions struct {
|
||||
TempParent string
|
||||
}
|
||||
|
||||
type gitPatchRepo struct {
|
||||
Root string
|
||||
DirectoryPrefix string
|
||||
}
|
||||
|
||||
func (*Service) ApplyGitPatchForPath(ctx context.Context, workspaceID string, input ApplyGitPatchInput) (ApplyGitPatchResult, error) {
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
input.Cwd = normalizeGitPatchCwd(input.Cwd)
|
||||
logAgentGitPatch("requested", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"cwd": input.Cwd,
|
||||
"revert": input.Revert,
|
||||
"atomic": input.Atomic,
|
||||
"target": input.Target,
|
||||
"allowBinary": input.AllowBinary,
|
||||
"diffBytes": len(input.Diff),
|
||||
"diffSha256": gitPatchDiffHash(input.Diff),
|
||||
})
|
||||
if workspaceID == "" || input.Cwd == "" || strings.TrimSpace(input.Diff) == "" {
|
||||
logAgentGitPatch("rejected", map[string]any{"reason": "invalid-argument"})
|
||||
return emptyApplyGitPatchResult(ApplyGitPatchStatusError), ErrInvalidArgument
|
||||
}
|
||||
result, err := applyGitPatchWithOptions(ctx, input, applyGitPatchOptions{})
|
||||
if err != nil {
|
||||
logAgentGitPatch("failed", map[string]any{"error": err.Error()})
|
||||
return result, err
|
||||
}
|
||||
logAgentGitPatch("completed", map[string]any{
|
||||
"status": result.Status,
|
||||
"errorCode": result.ErrorCode,
|
||||
"appliedPaths": result.AppliedPaths,
|
||||
"skippedPaths": result.SkippedPaths,
|
||||
"conflictedPaths": result.ConflictedPaths,
|
||||
"command": result.ExecOutput.Command,
|
||||
"stderr": result.ExecOutput.Stderr,
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func gitPatchDiffHash(diff string) string {
|
||||
sum := sha256.Sum256([]byte(diff))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func logAgentGitPatch(event string, payload map[string]any) {
|
||||
payload["event"] = event
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
slog.Warn(agentGitPatchLogPrefix, "payload_json", `{"event":"log-encode-failed"}`)
|
||||
return
|
||||
}
|
||||
slog.Info(agentGitPatchLogPrefix, "payload_json", string(encoded))
|
||||
}
|
||||
|
||||
func (*Service) ResolveGitPatchSupportForPath(ctx context.Context, workspaceID string, cwd string) (GitPatchSupport, error) {
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
cwd = normalizeGitPatchCwd(cwd)
|
||||
if workspaceID == "" || cwd == "" {
|
||||
return GitPatchSupport{}, ErrInvalidArgument
|
||||
}
|
||||
repo, ok := resolveGitPatchRepo(ctx, cwd)
|
||||
if !ok {
|
||||
return GitPatchSupport{
|
||||
Supported: false,
|
||||
ErrorCode: ApplyGitPatchErrorNotGitRepo,
|
||||
}, nil
|
||||
}
|
||||
return GitPatchSupport{
|
||||
Supported: true,
|
||||
Root: repo.Root,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyGitPatchWithOptions(ctx context.Context, input ApplyGitPatchInput, options applyGitPatchOptions) (ApplyGitPatchResult, error) {
|
||||
input.Cwd = strings.TrimSpace(input.Cwd)
|
||||
if input.Target == "" {
|
||||
input.Target = ApplyGitPatchTargetUnstaged
|
||||
}
|
||||
repo, ok := resolveGitPatchRepo(ctx, input.Cwd)
|
||||
if !ok {
|
||||
result := emptyApplyGitPatchResult(ApplyGitPatchStatusError)
|
||||
result.ErrorCode = ApplyGitPatchErrorNotGitRepo
|
||||
return result, nil
|
||||
}
|
||||
|
||||
tempDir, err := os.MkdirTemp(options.TempParent, "tutti-apply-")
|
||||
if err != nil {
|
||||
return emptyApplyGitPatchResult(ApplyGitPatchStatusError), fmt.Errorf("create patch temp dir: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.RemoveAll(tempDir)
|
||||
}()
|
||||
|
||||
patchPath := filepath.Join(tempDir, "patch.diff")
|
||||
if err := os.WriteFile(patchPath, []byte(input.Diff), 0o600); err != nil {
|
||||
return emptyApplyGitPatchResult(ApplyGitPatchStatusError), fmt.Errorf("write patch diff: %w", err)
|
||||
}
|
||||
|
||||
baseEnv := runtimecmd.InjectSystemProxyEnv(gitEnvScopedToDir())
|
||||
env := baseEnv
|
||||
diffPaths := parseGitDiffPaths(input.Diff)
|
||||
if input.Target == ApplyGitPatchTargetUnstaged && !input.Atomic {
|
||||
var err error
|
||||
env, err = prepareTemporaryGitIndex(ctx, repo, tempDir, env, diffPaths)
|
||||
if err != nil {
|
||||
return emptyApplyGitPatchResult(ApplyGitPatchStatusError), err
|
||||
}
|
||||
}
|
||||
|
||||
preflightResult := runGitPatchCommand(ctx, repo.Root, env, gitPatchArgs(input, repo, patchPath, true)...)
|
||||
if preflightResult.ExitCode != 0 {
|
||||
result := classifyGitPatchResult(ApplyGitPatchStatusError, diffPaths, preflightResult)
|
||||
result.ErrorCode = applyGitPatchPreflightErrorCode(preflightResult)
|
||||
if fallback, ok := tryApplyCreatedFileRevertFallback(ctx, repo, input, diffPaths, baseEnv, result); ok {
|
||||
return fallback, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
args := gitPatchArgs(input, repo, patchPath, false)
|
||||
execResult := runGitPatchCommand(ctx, repo.Root, env, args...)
|
||||
status := applyGitPatchStatus(execResult.ExitCode, input.Atomic)
|
||||
result := classifyGitPatchResult(status, diffPaths, execResult)
|
||||
if fallback, ok := tryApplyCreatedFileRevertFallback(ctx, repo, input, diffPaths, baseEnv, result); ok {
|
||||
return fallback, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func gitPatchArgs(input ApplyGitPatchInput, repo gitPatchRepo, patchPath string, check bool) []string {
|
||||
args := []string{"apply"}
|
||||
if check {
|
||||
args = append(args, "--check")
|
||||
}
|
||||
if input.Revert {
|
||||
args = append(args, "-R")
|
||||
}
|
||||
if input.AllowBinary {
|
||||
args = append(args, "--binary")
|
||||
}
|
||||
if !input.Atomic {
|
||||
args = append(args, "--3way")
|
||||
}
|
||||
switch input.Target {
|
||||
case ApplyGitPatchTargetStaged:
|
||||
args = append(args, "--cached")
|
||||
case ApplyGitPatchTargetStagedAndUnstaged:
|
||||
args = append(args, "--index")
|
||||
}
|
||||
if repo.DirectoryPrefix != "" {
|
||||
args = append(args, "--directory="+repo.DirectoryPrefix)
|
||||
}
|
||||
return append(args, patchPath)
|
||||
}
|
||||
|
||||
func applyGitPatchPreflightErrorCode(result gitPatchCommandResult) ApplyGitPatchErrorCode {
|
||||
if result.ExitCode == 128 || strings.Contains(strings.ToLower(result.Stderr), "corrupt patch") {
|
||||
return ApplyGitPatchErrorInvalidPatch
|
||||
}
|
||||
return ApplyGitPatchErrorPatchDoesNotApply
|
||||
}
|
||||
|
||||
func resolveGitPatchRepo(ctx context.Context, cwd string) (gitPatchRepo, bool) {
|
||||
gitCwd, err := existingGitPatchDirectory(cwd)
|
||||
if err != nil {
|
||||
return gitPatchRepo{}, false
|
||||
}
|
||||
root, err := runGitOutputWithEnv(ctx, gitCwd, runtimecmd.InjectSystemProxyEnv(gitEnvScopedToDir()), "rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
return gitPatchRepo{}, false
|
||||
}
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return gitPatchRepo{}, false
|
||||
}
|
||||
absRoot, err := canonicalGitPatchPath(root)
|
||||
if err != nil {
|
||||
return gitPatchRepo{}, false
|
||||
}
|
||||
absCwd, err := canonicalGitPatchPath(gitCwd)
|
||||
if err != nil {
|
||||
return gitPatchRepo{}, false
|
||||
}
|
||||
prefix := ""
|
||||
if absCwd != absRoot {
|
||||
rel, err := filepath.Rel(absRoot, absCwd)
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||
return gitPatchRepo{}, false
|
||||
}
|
||||
prefix = filepath.ToSlash(rel)
|
||||
}
|
||||
return gitPatchRepo{Root: absRoot, DirectoryPrefix: prefix}, true
|
||||
}
|
||||
|
||||
func normalizeGitPatchCwd(value string) string {
|
||||
return normalizeGitPatchCwdForPlatform(value, runtime.GOOS == "windows")
|
||||
}
|
||||
|
||||
func normalizeGitPatchCwdForPlatform(value string, windows bool) string {
|
||||
normalized := strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
if !windows {
|
||||
return normalized
|
||||
}
|
||||
if len(normalized) < 2 || normalized[0] != '/' || !isASCIIPathLetter(normalized[1]) {
|
||||
return normalized
|
||||
}
|
||||
if len(normalized) == 2 || normalized[2] == '/' {
|
||||
return strings.ToUpper(string(normalized[1])) + ":" + normalized[2:]
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func isASCIIPathLetter(value byte) bool {
|
||||
return (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z')
|
||||
}
|
||||
|
||||
func existingGitPatchDirectory(path string) (string, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
current := absPath
|
||||
for {
|
||||
info, err := os.Stat(current)
|
||||
if err == nil {
|
||||
if info.IsDir() {
|
||||
return current, nil
|
||||
}
|
||||
return filepath.Dir(current), nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return "", err
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalGitPatchPath(path string) (string, error) {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
realPath, err := filepath.EvalSymlinks(absPath)
|
||||
if err == nil {
|
||||
return realPath, nil
|
||||
}
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func prepareTemporaryGitIndex(ctx context.Context, repo gitPatchRepo, tempDir string, env []string, diffPaths []string) ([]string, error) {
|
||||
indexPath, err := runGitOutputWithEnv(ctx, repo.Root, env, "rev-parse", "--git-path", "index")
|
||||
if err != nil {
|
||||
return env, fmt.Errorf("resolve git index path: %w", err)
|
||||
}
|
||||
indexPath = gitPathFromOutput(repo.Root, indexPath)
|
||||
tempIndexPath := filepath.Join(tempDir, "index")
|
||||
if err := copyFileIfExists(indexPath, tempIndexPath); err != nil {
|
||||
return env, fmt.Errorf("copy git index: %w", err)
|
||||
}
|
||||
sharedIndexPath, _ := runGitOutputWithEnv(ctx, repo.Root, env, "rev-parse", "--shared-index-path")
|
||||
if sharedIndexPath = gitPathFromOutput(repo.Root, sharedIndexPath); sharedIndexPath != "" {
|
||||
_ = copyFileIfExists(sharedIndexPath, filepath.Join(tempDir, filepath.Base(sharedIndexPath)))
|
||||
}
|
||||
|
||||
nextEnv := append([]string{}, env...)
|
||||
nextEnv = append(nextEnv, "GIT_INDEX_FILE="+tempIndexPath)
|
||||
paths := existingRootRelativePaths(repo, diffPaths)
|
||||
if len(paths) == 0 {
|
||||
return nextEnv, nil
|
||||
}
|
||||
// -f so ignored paths (e.g. under .gitignore) still enter the temp index;
|
||||
// otherwise unstaged undo/reapply fails when the working tree wrote there.
|
||||
args := append([]string{"add", "-f", "--"}, paths...)
|
||||
addResult := runGitPatchCommand(ctx, repo.Root, nextEnv, args...)
|
||||
if addResult.ExitCode != 0 {
|
||||
return nextEnv, fmt.Errorf("prepare temporary git index: %s", strings.TrimSpace(addResult.Stderr))
|
||||
}
|
||||
return nextEnv, nil
|
||||
}
|
||||
|
||||
func gitPathFromOutput(root string, value string) string {
|
||||
path := strings.TrimSpace(value)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(root, filepath.FromSlash(path))
|
||||
}
|
||||
|
||||
func copyFileIfExists(source string, destination string) error {
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return nil
|
||||
}
|
||||
content, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(destination, content, 0o600)
|
||||
}
|
||||
|
||||
func existingRootRelativePaths(repo gitPatchRepo, diffPaths []string) []string {
|
||||
paths := make([]string, 0, len(diffPaths))
|
||||
seen := make(map[string]bool, len(diffPaths))
|
||||
for _, path := range diffPaths {
|
||||
rootRelative := rootRelativePatchPath(repo, path)
|
||||
if rootRelative == "" || seen[rootRelative] {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(repo.Root, filepath.FromSlash(rootRelative))); err != nil {
|
||||
continue
|
||||
}
|
||||
seen[rootRelative] = true
|
||||
paths = append(paths, rootRelative)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func rootRelativePatchPath(repo gitPatchRepo, path string) string {
|
||||
path = strings.TrimSpace(strings.TrimPrefix(path, "a/"))
|
||||
path = strings.TrimPrefix(path, "b/")
|
||||
if path == "" || path == "/dev/null" || path == "dev/null" {
|
||||
return ""
|
||||
}
|
||||
if repo.DirectoryPrefix == "" {
|
||||
return filepath.ToSlash(filepath.Clean(path))
|
||||
}
|
||||
return filepath.ToSlash(filepath.Join(filepath.FromSlash(repo.DirectoryPrefix), filepath.FromSlash(path)))
|
||||
}
|
||||
|
||||
type gitPatchCommandResult struct {
|
||||
Args []string
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
}
|
||||
|
||||
func runGitPatchCommand(ctx context.Context, cwd string, env []string, args ...string) gitPatchCommandResult {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = cwd
|
||||
cmd.Env = env
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
code := 0
|
||||
if err != nil {
|
||||
code = -1
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
|
||||
code = status.ExitStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
return gitPatchCommandResult{
|
||||
Args: append([]string{}, args...),
|
||||
ExitCode: code,
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func runGitOutputWithEnv(ctx context.Context, cwd string, env []string, args ...string) (string, error) {
|
||||
result := runGitPatchCommand(ctx, cwd, env, args...)
|
||||
if result.ExitCode != 0 {
|
||||
return "", fmt.Errorf("git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(result.Stderr))
|
||||
}
|
||||
return result.Stdout, nil
|
||||
}
|
||||
|
||||
func applyGitPatchStatus(exitCode int, atomic bool) ApplyGitPatchStatus {
|
||||
if exitCode == 0 {
|
||||
return ApplyGitPatchStatusSuccess
|
||||
}
|
||||
if exitCode == 1 && !atomic {
|
||||
return ApplyGitPatchStatusPartialSuccess
|
||||
}
|
||||
return ApplyGitPatchStatusError
|
||||
}
|
||||
|
||||
func classifyGitPatchResult(status ApplyGitPatchStatus, diffPaths []string, execResult gitPatchCommandResult) ApplyGitPatchResult {
|
||||
result := emptyApplyGitPatchResult(status)
|
||||
result.ExecOutput = ApplyGitPatchExecOutput{
|
||||
Command: "git " + strings.Join(execResult.Args, " "),
|
||||
Stdout: execResult.Stdout,
|
||||
Stderr: execResult.Stderr,
|
||||
}
|
||||
if status == ApplyGitPatchStatusSuccess {
|
||||
result.AppliedPaths = append([]string{}, diffPaths...)
|
||||
return result
|
||||
}
|
||||
result.AppliedPaths = parseGitApplyCleanPaths(execResult.Stdout + "\n" + execResult.Stderr)
|
||||
result.ConflictedPaths = parseGitApplyConflictedPaths(execResult.Stdout + "\n" + execResult.Stderr)
|
||||
result.SkippedPaths = remainingPatchPaths(diffPaths, result.AppliedPaths, result.ConflictedPaths)
|
||||
return result
|
||||
}
|
||||
|
||||
func tryApplyCreatedFileRevertFallback(ctx context.Context, repo gitPatchRepo, input ApplyGitPatchInput, diffPaths []string, env []string, result ApplyGitPatchResult) (ApplyGitPatchResult, bool) {
|
||||
if result.Status == ApplyGitPatchStatusSuccess || !input.Revert || input.Atomic || input.Target != ApplyGitPatchTargetUnstaged {
|
||||
return result, false
|
||||
}
|
||||
createdFiles := parseCreatedPatchFiles(input.Diff)
|
||||
if len(createdFiles) == 0 || len(createdFiles) != len(diffPaths) {
|
||||
return result, false
|
||||
}
|
||||
|
||||
rootRelativePaths := make([]string, 0, len(createdFiles))
|
||||
for _, file := range createdFiles {
|
||||
rootRelative := rootRelativePatchPath(repo, file.Path)
|
||||
if rootRelative == "" || !gitPatchPathIsUntracked(ctx, repo.Root, env, rootRelative) {
|
||||
return result, false
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(repo.Root, filepath.FromSlash(rootRelative)))
|
||||
if err != nil || !createdPatchContentMatches(string(content), file.Content) {
|
||||
return result, false
|
||||
}
|
||||
rootRelativePaths = append(rootRelativePaths, rootRelative)
|
||||
}
|
||||
|
||||
for _, path := range rootRelativePaths {
|
||||
if err := os.Remove(filepath.Join(repo.Root, filepath.FromSlash(path))); err != nil {
|
||||
return result, false
|
||||
}
|
||||
}
|
||||
fallback := emptyApplyGitPatchResult(ApplyGitPatchStatusSuccess)
|
||||
fallback.AppliedPaths = append([]string{}, diffPaths...)
|
||||
fallback.ExecOutput = result.ExecOutput
|
||||
return fallback, true
|
||||
}
|
||||
|
||||
func emptyApplyGitPatchResult(status ApplyGitPatchStatus) ApplyGitPatchResult {
|
||||
return ApplyGitPatchResult{
|
||||
Status: status,
|
||||
AppliedPaths: []string{},
|
||||
SkippedPaths: []string{},
|
||||
ConflictedPaths: []string{},
|
||||
ErrorCode: ApplyGitPatchErrorNone,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
gitDiffHeaderPattern = regexp.MustCompile(`(?m)^diff --git a/(.+?) b/(.+?)$`)
|
||||
gitDiffOldNewHeaderPattern = regexp.MustCompile(`(?m)^(?:---|\+\+\+)\s+(?:a/|b/)?([^\n]+)$`)
|
||||
gitApplyCleanPattern = regexp.MustCompile(`(?m)Applied patch to '([^']+)' cleanly\.`)
|
||||
gitApplyConflictPattern = regexp.MustCompile(`(?m)(?:Applied patch to '([^']+)' with conflicts\.|^U\s+(.+)$)`)
|
||||
)
|
||||
|
||||
func parseGitDiffPaths(diff string) []string {
|
||||
seen := map[string]bool{}
|
||||
var paths []string
|
||||
add := func(path string) {
|
||||
path = normalizePatchResultPath(path)
|
||||
if path == "" || seen[path] {
|
||||
return
|
||||
}
|
||||
seen[path] = true
|
||||
paths = append(paths, path)
|
||||
}
|
||||
for _, match := range gitDiffHeaderPattern.FindAllStringSubmatch(diff, -1) {
|
||||
if len(match) >= 3 {
|
||||
add(match[2])
|
||||
}
|
||||
}
|
||||
if len(paths) > 0 {
|
||||
return paths
|
||||
}
|
||||
for _, match := range gitDiffOldNewHeaderPattern.FindAllStringSubmatch(diff, -1) {
|
||||
if len(match) >= 2 {
|
||||
add(match[1])
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func parseGitApplyCleanPaths(output string) []string {
|
||||
return parseGitApplyPaths(output, gitApplyCleanPattern)
|
||||
}
|
||||
|
||||
func parseGitApplyConflictedPaths(output string) []string {
|
||||
return parseGitApplyPaths(output, gitApplyConflictPattern)
|
||||
}
|
||||
|
||||
func parseGitApplyPaths(output string, pattern *regexp.Regexp) []string {
|
||||
seen := map[string]bool{}
|
||||
var paths []string
|
||||
for _, match := range pattern.FindAllStringSubmatch(output, -1) {
|
||||
for _, group := range match[1:] {
|
||||
path := normalizePatchResultPath(group)
|
||||
if path == "" || seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
paths = append(paths, path)
|
||||
break
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func remainingPatchPaths(all []string, applied []string, conflicted []string) []string {
|
||||
var remaining []string
|
||||
for _, path := range all {
|
||||
if slices.Contains(applied, path) || slices.Contains(conflicted, path) {
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, path)
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
type createdPatchFile struct {
|
||||
Path string
|
||||
Content string
|
||||
}
|
||||
|
||||
func parseCreatedPatchFiles(diff string) []createdPatchFile {
|
||||
lines := strings.Split(strings.ReplaceAll(diff, "\r\n", "\n"), "\n")
|
||||
files := []createdPatchFile{}
|
||||
for index := 0; index < len(lines); {
|
||||
if !strings.HasPrefix(lines[index], "diff --git ") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
next := index + 1
|
||||
for next < len(lines) && !strings.HasPrefix(lines[next], "diff --git ") {
|
||||
next++
|
||||
}
|
||||
if file, ok := parseCreatedPatchFile(lines[index:next]); ok {
|
||||
files = append(files, file)
|
||||
}
|
||||
index = next
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func parseCreatedPatchFile(lines []string) (createdPatchFile, bool) {
|
||||
if len(lines) == 0 {
|
||||
return createdPatchFile{}, false
|
||||
}
|
||||
header := gitDiffHeaderPattern.FindStringSubmatch(lines[0])
|
||||
if len(header) < 3 {
|
||||
return createdPatchFile{}, false
|
||||
}
|
||||
hasNewFileMode := false
|
||||
hasDevNullOldPath := false
|
||||
var content strings.Builder
|
||||
inHunk := false
|
||||
for _, line := range lines[1:] {
|
||||
if strings.HasPrefix(line, "new file mode ") {
|
||||
hasNewFileMode = true
|
||||
continue
|
||||
}
|
||||
if line == "--- /dev/null" {
|
||||
hasDevNullOldPath = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "@@ ") {
|
||||
inHunk = true
|
||||
continue
|
||||
}
|
||||
if !inHunk {
|
||||
continue
|
||||
}
|
||||
if line == `\ No newline at end of file` {
|
||||
value := content.String()
|
||||
content.Reset()
|
||||
content.WriteString(strings.TrimSuffix(value, "\n"))
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") {
|
||||
content.WriteString(strings.TrimPrefix(line, "+"))
|
||||
content.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
if !hasNewFileMode || !hasDevNullOldPath {
|
||||
return createdPatchFile{}, false
|
||||
}
|
||||
return createdPatchFile{
|
||||
Path: normalizePatchResultPath(header[2]),
|
||||
Content: content.String(),
|
||||
}, true
|
||||
}
|
||||
|
||||
func gitPatchPathIsUntracked(ctx context.Context, repoRoot string, env []string, path string) bool {
|
||||
result := runGitPatchCommand(ctx, repoRoot, env, "ls-files", "--error-unmatch", "--", path)
|
||||
return result.ExitCode == 1
|
||||
}
|
||||
|
||||
func createdPatchContentMatches(current string, expected string) bool {
|
||||
current = strings.ReplaceAll(current, "\r\n", "\n")
|
||||
expected = strings.ReplaceAll(expected, "\r\n", "\n")
|
||||
return current == expected || strings.TrimRight(current, "\n") == strings.TrimRight(expected, "\n")
|
||||
}
|
||||
|
||||
func normalizePatchResultPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
path = strings.TrimPrefix(path, "a/")
|
||||
path = strings.TrimPrefix(path, "b/")
|
||||
if path == "" || path == "/dev/null" || path == "dev/null" {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(filepath.Clean(path))
|
||||
}
|
||||
Reference in New Issue
Block a user