Files
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

702 lines
20 KiB
Go

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))
}