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