// Package process provides git operation execution for agentctl. package process import ( "bytes" "context" "errors" "fmt" "os" "path/filepath" "strings" "sync" "time" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/securityutil" "github.com/kandev/kandev/internal/common/subproc" "github.com/kandev/kandev/internal/task/models" "go.uber.org/zap" ) // ErrOperationInProgress is returned when a git operation is already in progress. var ErrOperationInProgress = errors.New("git operation already in progress") // ErrInvalidBranchName is returned when a branch name contains invalid characters. var ErrInvalidBranchName = errors.New("invalid branch name") // GitOperationResult represents the result of a git operation. type GitOperationResult struct { Success bool `json:"success"` Operation string `json:"operation"` Output string `json:"output"` Error string `json:"error,omitempty"` ErrorCode string `json:"error_code,omitempty"` ConflictFiles []string `json:"conflict_files,omitempty"` RecoveryBranch string `json:"recovery_branch,omitempty"` } // GitOperator executes git operations in a workspace directory. type GitOperator struct { workDir string logger *logger.Logger workspaceTracker *WorkspaceTracker environment func() []string // repoName is the multi-repo subpath this operator runs in (e.g. "kandev"). // Empty for the workspace-root operator. Stamped on emitted commit // notifications so the frontend can group commits per repo. repoName string remoteContribution *models.RemoteContribution remoteContributionErr error contributionDestination *models.ContributionDestination contributionDestinationErr error mu sync.Mutex // Prevents concurrent git operations inProgress bool currentOp string } // NewGitOperator creates a new GitOperator for the given workspace directory. func NewGitOperator(workDir string, log *logger.Logger, workspaceTracker *WorkspaceTracker) *GitOperator { return &GitOperator{ workDir: workDir, logger: log.WithFields(zap.String("component", "git-operator")), workspaceTracker: workspaceTracker, environment: os.Environ, } } // NewGitOperatorForRepo creates a GitOperator scoped to a multi-repo subpath // so its emitted events (e.g. commit notifications) carry the repo name. func NewGitOperatorForRepo(workDir, repoName string, log *logger.Logger, workspaceTracker *WorkspaceTracker) *GitOperator { op := NewGitOperator(workDir, log, workspaceTracker) op.repoName = repoName return op } func (g *GitOperator) setEnvironmentProvider(provider func() []string) { if provider != nil { g.environment = provider } } func (g *GitOperator) setRemoteContribution(binding *models.RemoteContribution) { if binding == nil { return } if err := binding.Validate(); err != nil { g.remoteContributionErr = fmt.Errorf("invalid remote contribution binding: %w", err) return } copy := *binding g.remoteContribution = © } func (g *GitOperator) setContributionDestination(destination *models.ContributionDestination) { if destination == nil { return } if err := destination.Validate(); err != nil { g.contributionDestinationErr = fmt.Errorf("invalid contribution destination: %w", err) return } copy := *destination g.contributionDestination = © } func (g *GitOperator) validateContributionRemote(ctx context.Context) error { if g.remoteContribution == nil { return nil } remoteName := g.remoteContribution.ContributionRemoteName() urlsOutput, err := g.runGitCommand(ctx, "config", "--get-all", "remote."+remoteName+".pushurl") if err != nil { // A remote without an explicit pushurl pushes to its configured URL. urlsOutput, err = g.runGitCommand(ctx, "config", "--get-all", "remote."+remoteName+".url") } if err != nil { return errors.New("contribution remote is unavailable") } urls := strings.Split(strings.TrimSpace(urlsOutput), "\n") if len(urls) == 0 || urls[0] == "" { return errors.New("contribution remote has no configured URL") } for _, configured := range urls { if strings.TrimSpace(configured) != g.remoteContribution.SourceRepository.RemoteURL { return errors.New("contribution remote push URL does not match the validated source") } } return nil } func (g *GitOperator) validateContributionDestinationRemote(ctx context.Context) error { if g.contributionDestination == nil { return nil } remoteName := g.contributionDestination.ContributionRemoteName() urlsOutput, err := g.runGitCommand(ctx, "config", "--get-all", "remote."+remoteName+".pushurl") if err != nil { return errors.New("contribution destination remote is unavailable") } urls := strings.Split(strings.TrimSpace(urlsOutput), "\n") if len(urls) == 0 || urls[0] == "" { return errors.New("contribution destination remote has no configured push URL") } for _, configured := range urls { if strings.TrimSpace(configured) != g.contributionDestination.TargetRepository.RemoteURL { return errors.New("contribution destination push URL does not match the validated target") } } return nil } func (g *GitOperator) environmentValues() []string { if g.environment == nil { return os.Environ() } return g.environment() } func (g *GitOperator) environmentValue(key string) string { prefix := key + "=" env := g.environmentValues() for i := len(env) - 1; i >= 0; i-- { if strings.HasPrefix(env[i], prefix) { return strings.TrimPrefix(env[i], prefix) } } return "" } // runGitCommand executes a git command in the workDir with defense-in-depth validation. // Validates both flags and branch/ref arguments to prevent command injection. func (g *GitOperator) runGitCommand(ctx context.Context, args ...string) (string, error) { // Validate that user-controlled arguments don't introduce command injection risks // Even though exec.CommandContext doesn't use a shell, we must prevent argument // injection where malicious input like "--help" or "--exec=..." could be passed // as what appears to be a file/branch name but is interpreted as a flag by git. skipNextArg := false afterDoubleDash := false // Track if we've seen "--" separator for i, arg := range args { // Skip git subcommand (first argument) if i == 0 { continue } // Skip if this argument is a value for a previous flag if skipNextArg { skipNextArg = false continue } // After "--", all arguments are file paths - no validation needed if afterDoubleDash { continue } if strings.HasPrefix(arg, contributionLeaseFlagPrefix) { if err := validateContributionLeaseFlag(arg); err != nil { return "", err } continue } // Validate flags against whitelist if strings.HasPrefix(arg, "-") { if !securityutil.IsKnownSafeGitFlag(arg) { return "", fmt.Errorf("potentially unsafe flag: %s", arg) } // Special handling for "--" separator if arg == "--" { afterDoubleDash = true continue } // Flags that take a value in the next argument if arg == "-m" || arg == "--format" { skipNextArg = true } continue } // Skip known safe git literals (HEAD, origin, etc.) if securityutil.IsKnownSafeGitLiteral(arg) { continue } // Skip commit SHAs (validated elsewhere via validateCommitSHA) if securityutil.LooksLikeCommitSHA(arg) { continue } // Contribution pushes use an explicit, non-force refspec. Validate the // branch portion before allowing the colon-bearing argument through. if strings.HasPrefix(arg, "HEAD:refs/heads/") { if !securityutil.IsValidBranchName(strings.TrimPrefix(arg, "HEAD:refs/heads/")) { return "", ErrInvalidBranchName } continue } // Validate branch references (e.g., "origin/branch", "upstream/main") // This provides defense-in-depth even though branches are validated at call sites if strings.Contains(arg, "/") { if err := securityutil.ValidateBranchReference(arg); err != nil { return "", err } continue } // Validate standalone branch names if securityutil.IsValidBranchName(arg) { // Standalone arg that matches branch name pattern - validated and safe continue } // If we reach here, it's a non-branch argument (file path, etc.) // which we don't validate as strictly } // All args validated: flags in securityutil.IsKnownSafeGitFlag whitelist, branch names via securityutil.IsValidBranchName // regex, commit SHAs via securityutil.LooksLikeCommitSHA pattern, args after "--" separator skipped. // This defense-in-depth validation prevents injection of arbitrary commands. cmd := subproc.NewGitCommand(ctx, args...) cmd.Dir = g.workDir cmd.Env = filterGitEnv(g.environmentValues()) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr g.logger.Debug("executing git command", zap.Strings("args", args)) err := subproc.RunGitClass(ctx, subproc.GitInteractive, cmd) output := stdout.String() if stderr.Len() > 0 { if output != "" { output += "\n" } output += stderr.String() } stderrOutput := stderr.String() if len(args) > 0 && args[0] == "push" { output = g.sanitizeGitPushOutput(output) stderrOutput = g.sanitizeGitPushOutput(stderrOutput) } if err != nil { return output, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderrOutput)) } return output, nil } // filterGitEnv removes GIT_DIR and GIT_WORK_TREE from the environment. // This ensures that external tools like gh CLI correctly detect the repository // from the working directory, which is essential for worktrees where these // env vars could point to the wrong location. func filterGitEnv(env []string) []string { result := make([]string, 0, len(env)) for _, e := range env { if strings.HasPrefix(e, "GIT_DIR=") || strings.HasPrefix(e, "GIT_WORK_TREE=") { continue } result = append(result, e) } return result } // triggerRefresh refreshes git status in the workspace tracker immediately. // Called after git operations like commit, push, pull, etc. to refresh the UI // without waiting for the next poll cycle. func (g *GitOperator) triggerRefresh() { if g.workspaceTracker != nil { g.workspaceTracker.RefreshGitStatus(context.Background()) } } // getCurrentBranch returns the current branch name func (g *GitOperator) getCurrentBranch(ctx context.Context) (string, error) { output, err := g.runGitCommand(ctx, "rev-parse", "--abbrev-ref", "HEAD") if err != nil { return "", fmt.Errorf("failed to get current branch: %w", err) } return strings.TrimSpace(output), nil } // getUpstreamRef returns the current branch's upstream tracking ref, or "" if none is set. func (g *GitOperator) getUpstreamRef(ctx context.Context) string { output, err := g.runGitCommand(ctx, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") if err != nil { return "" } return strings.TrimSpace(output) } // getDefaultRemoteBranch returns "main" or "master", whichever exists on origin. func (g *GitOperator) getDefaultRemoteBranch(ctx context.Context) string { if _, err := g.runGitCommand(ctx, "rev-parse", "--verify", "origin/main"); err == nil { return "main" } if _, err := g.runGitCommand(ctx, "rev-parse", "--verify", "origin/master"); err == nil { return "master" } return "" } // hasUncommittedChanges checks if there are uncommitted changes func (g *GitOperator) hasUncommittedChanges(ctx context.Context) (bool, error) { output, err := g.runGitCommand(ctx, "status", "--porcelain") if err != nil { return false, fmt.Errorf("failed to check uncommitted changes: %w", err) } return strings.TrimSpace(output) != "", nil } // parseConflictFiles parses conflict file names from git output func (g *GitOperator) parseConflictFiles(output string) []string { var conflicts []string lines := strings.Split(output, "\n") for _, line := range lines { line = strings.TrimSpace(line) // Look for "CONFLICT" markers in git output if strings.HasPrefix(line, "CONFLICT") { // Extract file name from patterns like: // "CONFLICT (content): Merge conflict in " // "CONFLICT (add/add): Merge conflict in " if idx := strings.Index(line, "Merge conflict in "); idx != -1 { file := strings.TrimSpace(line[idx+len("Merge conflict in "):]) if file != "" { conflicts = append(conflicts, file) } } } } return conflicts } // Pull performs a git pull operation. func (g *GitOperator) Pull(ctx context.Context, rebase bool) (*GitOperationResult, error) { if !g.tryLock("pull") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "pull", } branch, err := g.getCurrentBranch(ctx) if err != nil { result.Error = err.Error() return result, nil } remote := "origin" pullBranch := branch if g.remoteContributionErr != nil { result.Error = g.remoteContributionErr.Error() return result, nil } if g.contributionDestinationErr != nil { result.Error = g.contributionDestinationErr.Error() return result, nil } if g.remoteContribution != nil { if err := g.validateContributionRemote(ctx); err != nil { result.Error = err.Error() return result, nil } remote = g.remoteContribution.ContributionRemoteName() pullBranch = g.remoteContribution.HeadBranch } else if upstream := g.getUpstreamRef(ctx); upstream == "" { // Use the default branch when the local branch has no upstream. if defaultBranch := g.getDefaultRemoteBranch(ctx); defaultBranch != "" { pullBranch = defaultBranch } } var args []string if rebase { args = []string{"pull", "--rebase", remote, pullBranch} } else { args = []string{"pull", remote, pullBranch} } output, err := g.runGitCommand(ctx, args...) result.Output = output if err != nil { result.Error = err.Error() result.ConflictFiles = g.parseConflictFiles(output) // For rebase conflicts, auto-abort to restore clean state if rebase && len(result.ConflictFiles) > 0 { g.logger.Info("rebase conflict detected, aborting rebase") if _, abortErr := g.runGitCommand(ctx, "rebase", "--abort"); abortErr != nil { g.logger.Warn("failed to abort rebase", zap.Error(abortErr)) } } return result, nil } result.Success = true g.logger.Info("pull completed", zap.String("branch", pullBranch), zap.String("remote", remote), zap.Bool("rebase", rebase)) return result, nil } // Push performs a git push operation. func (g *GitOperator) Push(ctx context.Context, force bool, setUpstream bool) (*GitOperationResult, error) { if !g.tryLock("push") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "push", } if g.remoteContributionErr != nil { result.Error = g.remoteContributionErr.Error() return result, nil } if g.contributionDestinationErr != nil { result.Error = g.contributionDestinationErr.Error() return result, nil } if g.contributionDestination != nil { if err := g.validateContributionDestinationRemote(ctx); err != nil { result.Error = err.Error() return result, nil } } if (g.remoteContribution != nil || g.contributionDestination != nil) && force { result.Error = "force push is not allowed for a remote contribution" return result, nil } if err := g.validateContributionRemote(ctx); err != nil { result.Error = err.Error() return result, nil } branch, err := g.getCurrentBranch(ctx) if err != nil { result.Error = err.Error() return result, nil } args := []string{"push"} shouldSetUpstream := setUpstream || g.getUpstreamRef(ctx) == "" remote := "origin" refspec := branch if g.contributionDestination != nil { remote = g.contributionDestination.ContributionRemoteName() refspec = branch shouldSetUpstream = setUpstream } else if g.remoteContribution != nil { remote = g.remoteContribution.ContributionRemoteName() refspec = "HEAD:refs/heads/" + g.remoteContribution.HeadBranch shouldSetUpstream = setUpstream } if shouldSetUpstream { args = append(args, "--set-upstream") } if force { // Use --force-with-lease for safer force push args = append(args, "--force-with-lease") } args = append(args, remote, refspec) output, err := g.runGitCommand(ctx, args...) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("push completed", zap.String("branch", branch), zap.String("remote", remote), zap.Bool("force", force), zap.Bool("set_upstream", shouldSetUpstream)) return result, nil } // PushPreflight verifies that the configured contribution remote and final // head-branch refspec are writable without mutating the remote or local refs. func (g *GitOperator) PushPreflight(ctx context.Context) (*GitOperationResult, error) { if !g.tryLock("push-preflight") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{Operation: "push_preflight"} if g.remoteContributionErr != nil { result.Error = g.remoteContributionErr.Error() return result, nil } if g.contributionDestinationErr != nil { result.Error = g.contributionDestinationErr.Error() return result, nil } if g.contributionDestination != nil { if err := g.validateContributionDestinationRemote(ctx); err != nil { result.Error = err.Error() return result, nil } branch, err := g.getCurrentBranch(ctx) if err != nil { result.Error = err.Error() return result, nil } output, err := g.runGitCommand(ctx, "push", "--dry-run", g.contributionDestination.ContributionRemoteName(), branch) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("contribution destination push preflight completed", zap.String("branch", branch)) return result, nil } if g.remoteContribution == nil { result.Success = true return result, nil } if err := g.validateContributionRemote(ctx); err != nil { result.Error = err.Error() return result, nil } branch, err := g.getCurrentBranch(ctx) if err != nil { result.Error = err.Error() return result, nil } refspec := "HEAD:refs/heads/" + g.remoteContribution.HeadBranch output, err := g.runGitCommand(ctx, "push", "--dry-run", g.remoteContribution.ContributionRemoteName(), refspec) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("contribution push preflight completed", zap.String("branch", branch)) return result, nil } // Rebase performs a git rebase onto the specified base branch. func (g *GitOperator) Rebase(ctx context.Context, baseBranch string) (*GitOperationResult, error) { // Validate branch name to prevent command injection if !securityutil.IsValidBranchName(baseBranch) { return nil, ErrInvalidBranchName } if !g.tryLock("rebase") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "rebase", } prepareOutput, target, err := g.prepareBaseBranchTarget(ctx, baseBranch) if err != nil { result.Error = err.Error() result.Output = prepareOutput return result, nil } // Perform the rebase rebaseOutput, err := g.runGitCommand(ctx, "rebase", target) result.Output = prepareOutput + rebaseOutput if err != nil { result.Error = err.Error() result.ConflictFiles = g.parseConflictFiles(rebaseOutput) // Auto-abort rebase on conflicts to restore clean state if len(result.ConflictFiles) > 0 { g.logger.Info("rebase conflict detected, aborting rebase") if _, abortErr := g.runGitCommand(ctx, "rebase", "--abort"); abortErr != nil { g.logger.Warn("failed to abort rebase", zap.Error(abortErr)) } } return result, nil } result.Success = true g.logger.Info("rebase completed", zap.String("base_branch", baseBranch)) return result, nil } // Merge performs a git merge of the specified base branch. func (g *GitOperator) Merge(ctx context.Context, baseBranch string) (*GitOperationResult, error) { // Validate branch name to prevent command injection if !securityutil.IsValidBranchName(baseBranch) { return nil, ErrInvalidBranchName } if !g.tryLock("merge") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "merge", } prepareOutput, target, err := g.prepareBaseBranchTarget(ctx, baseBranch) if err != nil { result.Error = err.Error() result.Output = prepareOutput return result, nil } // Perform the merge mergeOutput, err := g.runGitCommand(ctx, "merge", target) result.Output = prepareOutput + mergeOutput if err != nil { result.Error = err.Error() result.ConflictFiles = g.parseConflictFiles(mergeOutput) // For merge conflicts, leave in place so user can resolve // Do NOT auto-abort like we do for rebase return result, nil } result.Success = true g.logger.Info("merge completed", zap.String("base_branch", baseBranch)) return result, nil } // Commit creates a git commit with the specified message. // If stageAll is true, it stages all changes before committing. // If amend is true, it amends the previous commit instead of creating a new one. func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool, amend bool) (*GitOperationResult, error) { if !g.tryLock("commit") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "commit", } // For amend, we don't require staged changes if we're just changing the message if !amend { // Check if there are changes to commit hasChanges, err := g.hasUncommittedChanges(ctx) if err != nil { result.Error = err.Error() return result, nil } if !hasChanges { result.Error = "no changes to commit" return result, nil } } // Stage all changes if requested if stageAll { stageOutput, err := g.runGitCommand(ctx, "add", "-A") if err != nil { result.Error = fmt.Sprintf("failed to stage changes: %s", err.Error()) result.Output = stageOutput return result, nil } result.Output = stageOutput } // Create the commit (with --amend if requested) args := []string{"commit", "-m", message} if amend { args = append(args, "--amend") } commitOutput, err := g.runGitCommand(ctx, args...) result.Output += commitOutput if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("commit completed", zap.String("message", message), zap.Bool("amend", amend)) // Publish commit notification if we have a workspace tracker if g.workspaceTracker != nil { // Get commit details commitSHA, _ := g.runGitCommand(ctx, "rev-parse", "HEAD") parentSHA, _ := g.runGitCommand(ctx, "rev-parse", "HEAD~1") // Get commit info (author name|author email) authorInfo, _ := g.runGitCommand(ctx, "show", "-s", "--format=%an|%ae", "HEAD") authorParts := strings.Split(strings.TrimSpace(authorInfo), "|") authorName := "" authorEmail := "" if len(authorParts) >= 2 { authorName = authorParts[0] authorEmail = authorParts[1] } // Get commit stats filesChanged, insertions, deletions := g.getCommitStats(ctx, strings.TrimSpace(commitSHA)) commit := &streams.GitCommitNotification{ RepositoryName: g.repoName, CommitSHA: strings.TrimSpace(commitSHA), ParentSHA: strings.TrimSpace(parentSHA), Message: message, AuthorName: authorName, AuthorEmail: authorEmail, FilesChanged: filesChanged, Insertions: insertions, Deletions: deletions, CommittedAt: time.Now().UTC(), } g.workspaceTracker.NotifyGitCommit(commit) // Refresh git status so the UI's "unstaged" list clears immediately. // NotifyGitCommit only updates cachedHeadSHA — without an explicit // refresh, currentStatus keeps the pre-commit "modified" entries until // the next poll tick (which never fires when polling is paused). g.triggerRefresh() } return result, nil } // Stage stages files for commit using git add. // If paths is empty, stages all changes (git add -A). func (g *GitOperator) Stage(ctx context.Context, paths []string) (*GitOperationResult, error) { if !g.tryLock("stage") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "stage", } var args []string if len(paths) == 0 { // Stage all changes args = []string{"add", "-A"} } else { // Stage specific files args = append([]string{"add", "--"}, paths...) } output, err := g.runGitCommand(ctx, args...) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("stage completed", zap.Int("files", len(paths))) // Refresh git status so the UI reflects the staged state if g.workspaceTracker != nil { g.workspaceTracker.RefreshGitStatus(ctx) } return result, nil } // Unstage unstages files from the index using git reset. // If paths is empty, unstages all changes (git reset HEAD). func (g *GitOperator) Unstage(ctx context.Context, paths []string) (*GitOperationResult, error) { if !g.tryLock("unstage") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "unstage", } var args []string if len(paths) == 0 { // Unstage all changes args = []string{"reset", "HEAD"} } else { // Unstage specific files args = append([]string{"reset", "HEAD", "--"}, paths...) } output, err := g.runGitCommand(ctx, args...) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("unstage completed", zap.Int("files", len(paths))) // Refresh git status so the UI reflects the unstaged state if g.workspaceTracker != nil { g.workspaceTracker.RefreshGitStatus(ctx) } return result, nil } // Discard discards changes to files, reverting them to HEAD state. // This removes both staged and unstaged changes. // If paths is empty, returns an error (discarding all files requires explicit confirmation). func (g *GitOperator) Discard(ctx context.Context, paths []string) (*GitOperationResult, error) { if !g.tryLock("discard") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "discard", } // Require explicit paths for safety if len(paths) == 0 { result.Error = "no files specified to discard" return result, nil } // Separate files into categories based on their git status // We need to handle untracked/new files differently from tracked files untrackedFiles := []string{} trackedFiles := []string{} // Get status for each file to determine how to discard it for _, path := range paths { statusArgs := []string{"status", "--porcelain", "--", path} statusOutput, err := g.runGitCommand(ctx, statusArgs...) if err != nil { // If we can't get status, assume it's tracked and try to restore it trackedFiles = append(trackedFiles, path) continue } statusLine := strings.TrimSpace(statusOutput) if len(statusLine) >= 2 { indexStatus := statusLine[0] workTreeStatus := statusLine[1] // Untracked files (??), or added files (A ) that don't exist in HEAD if (indexStatus == '?' && workTreeStatus == '?') || indexStatus == 'A' { untrackedFiles = append(untrackedFiles, path) } else { trackedFiles = append(trackedFiles, path) } } else if statusLine == "" { // Empty status means file is not modified - nothing to discard continue } } outputs, errors := g.discardUntrackedFiles(ctx, untrackedFiles) trackedOutputs, trackedErrors := g.discardTrackedFiles(ctx, trackedFiles) outputs = append(outputs, trackedOutputs...) errors = append(errors, trackedErrors...) // Combine outputs and errors result.Output = strings.Join(outputs, "\n") if len(errors) > 0 { result.Error = strings.Join(errors, "; ") result.Success = false } else { result.Success = true } g.triggerRefresh() g.logger.Info("discard completed", zap.Int("total_files", len(paths)), zap.Int("untracked_files", len(untrackedFiles)), zap.Int("tracked_files", len(trackedFiles)), zap.Bool("success", result.Success)) return result, nil } // RevertCommit undoes the latest commit using git reset --soft HEAD~1. // The previously committed changes remain staged so the caller can review // and re-commit or discard as needed. func (g *GitOperator) RevertCommit(ctx context.Context, commitSHA string) (*GitOperationResult, error) { if !g.tryLock("revert_commit") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "revert_commit", } if errMsg := validateCommitSHA(commitSHA); errMsg != "" { result.Error = errMsg return result, nil } // Only the HEAD commit can be reverted via reset --soft. headSHA, err := g.runGitCommand(ctx, "rev-parse", "HEAD") if err != nil { result.Error = "failed to get HEAD: " + err.Error() return result, nil } if strings.TrimSpace(headSHA) != commitSHA { result.Error = "can only revert the latest commit" return result, nil } // git reset --soft HEAD~1 moves HEAD backward while keeping the committed // files staged. The git poller detects the backward HEAD movement and // automatically emits a GitResetNotification, which triggers DB cleanup and // the frontend commits_reset event. output, err := g.runGitCommand(ctx, "reset", "--soft", "HEAD~1") if err != nil { result.Error = err.Error() if output != "" { result.Output = output } return result, nil } result.Success = true result.Output = output g.logger.Info("revert commit completed", zap.String("commit_sha", commitSHA), zap.Bool("success", result.Success)) return result, nil } // RenameBranch renames the current branch to a new name. // Uses git branch -m . func (g *GitOperator) RenameBranch(ctx context.Context, newName string) (*GitOperationResult, error) { if !securityutil.IsValidBranchName(newName) { return nil, ErrInvalidBranchName } if !g.tryLock("rename_branch") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "rename_branch", } // Get current branch name for logging currentBranch, err := g.getCurrentBranch(ctx) if err != nil { result.Error = fmt.Sprintf("failed to get current branch: %s", err.Error()) return result, nil } // Rename the branch output, err := g.runGitCommand(ctx, "branch", "-m", newName) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("branch renamed", zap.String("from", currentBranch), zap.String("to", newName)) // Refresh git status so the UI reflects the new branch name if g.workspaceTracker != nil { g.workspaceTracker.RefreshGitStatus(ctx) } return result, nil } // Reset resets HEAD to the specified commit. // mode can be "soft" (keep changes staged), "mixed" (keep changes unstaged), or "hard" (discard all changes). func (g *GitOperator) Reset(ctx context.Context, commitSHA string, mode string) (*GitOperationResult, error) { if !g.tryLock("reset") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "reset", } // Validate mode validModes := map[string]bool{"soft": true, "mixed": true, "hard": true} if !validModes[mode] { result.Error = fmt.Sprintf("invalid reset mode: %s (must be soft, mixed, or hard)", mode) return result, nil } // Validate commit SHA format if errMsg := validateCommitSHA(commitSHA); errMsg != "" { result.Error = errMsg return result, nil } // Validate commit SHA exists (peel to commit object) if _, err := g.runGitCommand(ctx, "rev-parse", "--verify", commitSHA+"^{commit}"); err != nil { result.Error = fmt.Sprintf("invalid commit: %s", commitSHA) return result, nil } // Capture current HEAD for reset notification previousHead, err := g.runGitCommand(ctx, "rev-parse", "HEAD") if err != nil { result.Error = "failed to resolve HEAD: " + err.Error() return result, nil } previousHead = strings.TrimSpace(previousHead) // Perform the reset output, err := g.runGitCommand(ctx, "reset", "--"+mode, commitSHA) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("reset completed", zap.String("mode", mode), zap.String("commit", commitSHA)) // Send reset notification and refresh git status if g.workspaceTracker != nil { // Notify about the reset newHead, headErr := g.runGitCommand(ctx, "rev-parse", "HEAD") if headErr != nil { g.logger.Warn("failed to resolve HEAD after reset", zap.Error(headErr)) } else { reset := &streams.GitResetNotification{ Timestamp: time.Now().UTC(), RepositoryName: g.repoName, PreviousHead: previousHead, CurrentHead: strings.TrimSpace(newHead), } g.workspaceTracker.NotifyGitReset(reset) } // Refresh git status g.workspaceTracker.RefreshGitStatus(ctx) } return result, nil } func (g *GitOperator) discardUntrackedFiles(ctx context.Context, paths []string) (outputs, errors []string) { for _, path := range paths { resetArgs := []string{"rm", "--cached", "--force", "--", path} resetOutput, resetErr := g.runGitCommand(ctx, resetArgs...) if resetErr != nil && !strings.Contains(resetErr.Error(), "did not match any files") { errors = append(errors, fmt.Sprintf("failed to unstage %s: %s", path, resetErr.Error())) } if resetOutput != "" { outputs = append(outputs, resetOutput) } fullPath := filepath.Join(g.workDir, path) if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { errors = append(errors, fmt.Sprintf("failed to remove %s: %s", path, err.Error())) } } return outputs, errors } func (g *GitOperator) discardTrackedFiles(ctx context.Context, paths []string) (outputs, errors []string) { if len(paths) == 0 { return nil, nil } args := append([]string{"restore", "--source=HEAD", "--staged", "--worktree", "--"}, paths...) output, err := g.runGitCommand(ctx, args...) if output != "" { outputs = append(outputs, output) } if err != nil { errors = append(errors, err.Error()) } return outputs, errors } // Abort aborts an in-progress merge or rebase operation. func (g *GitOperator) Abort(ctx context.Context, operation string) (*GitOperationResult, error) { if !g.tryLock("abort") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "abort", } var args []string switch operation { case "merge": args = []string{"merge", "--abort"} case "rebase": args = []string{"rebase", "--abort"} default: result.Error = fmt.Sprintf("unsupported operation to abort: %s (must be 'merge' or 'rebase')", operation) return result, nil } output, err := g.runGitCommand(ctx, args...) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("abort completed", zap.String("operation", operation)) return result, nil } // tryLock attempts to acquire the operation lock without blocking. // Returns true if the lock was acquired, false if an operation is in progress. func (g *GitOperator) tryLock(opName string) bool { g.mu.Lock() defer g.mu.Unlock() if g.inProgress { return false } g.inProgress = true g.currentOp = opName return true } // unlock releases the operation lock and triggers a git status refresh. func (g *GitOperator) unlock() { g.mu.Lock() defer g.mu.Unlock() g.inProgress = false g.currentOp = "" // Refresh git status in the workspace tracker immediately. // This is called after every git operation completes. g.triggerRefresh() } // PRCreateResult represents the result of a PR creation operation. type PRCreateResult struct { Success bool `json:"success"` BranchPushed bool `json:"branch_pushed,omitempty"` PRURL string `json:"pr_url,omitempty"` Provider string `json:"provider,omitempty"` Output string `json:"output,omitempty"` Error string `json:"error,omitempty"` } // CreatePR creates a pull request using the repository host's CLI. // It first pushes the current branch to the remote, then creates the PR. func (g *GitOperator) CreatePR(ctx context.Context, title, body, baseBranch string, draft bool) (*PRCreateResult, error) { if !g.tryLock("create-pr") { return nil, ErrOperationInProgress } defer g.unlock() result := &PRCreateResult{} if g.remoteContributionErr != nil { result.Error = g.remoteContributionErr.Error() return result, nil } if g.remoteContribution != nil { if err := g.validateContributionRemote(ctx); err != nil { result.Error = err.Error() return result, nil } branch, err := g.getCurrentBranch(ctx) if err != nil { result.Error = fmt.Sprintf("failed to get current branch: %s", err.Error()) return result, nil } output, err := g.runGitCommand(ctx, "push", g.remoteContribution.ContributionRemoteName(), "HEAD:refs/heads/"+g.remoteContribution.HeadBranch) if err != nil { result.Error = fmt.Sprintf("failed to push contribution branch: %s", g.sanitizePRFailure(output, title, body)) result.Output = g.sanitizeGitPushOutput(output) return result, nil } result.Success = true result.BranchPushed = true result.PRURL = g.remoteContribution.CanonicalURL result.Provider = g.remoteContribution.Provider result.Output = g.sanitizeGitPushOutput(output) g.logger.Info("updated existing remote contribution", zap.String("branch", branch), zap.String("provider", result.Provider)) return result, nil } if g.contributionDestinationErr != nil { result.Error = g.contributionDestinationErr.Error() return result, nil } if g.contributionDestination != nil { return g.createManagedContributionPR(ctx, title, body, baseBranch, draft) } branch, err := g.getCurrentBranch(ctx) if err != nil { result.Error = fmt.Sprintf("failed to get current branch: %s", err.Error()) return result, nil } g.logger.Debug("current branch", zap.String("branch", branch)) remoteURL, err := g.getOriginRemoteURL(ctx) if err != nil { result.Error = err.Error() return result, nil } g.logger.Debug("origin remote", zap.String("remote", redactRemoteURL(remoteURL))) provider := g.detectPRProvider(remoteURL) var gitLabInfo *gitLabRepoInfo switch provider { case prProviderAzureRepos: result.Provider = string(prProviderAzureRepos) if _, parseErr := parseAzureRepoInfo(remoteURL); parseErr != nil { result.Error = parseErr.Error() return result, nil } case prProviderGitHub: result.Provider = string(prProviderGitHub) case prProviderGitLab: result.Provider = string(prProviderGitLab) gitLabInfo, err = parseGitLabRepoInfo(remoteURL, g.environmentValue(gitLabHostEnv)) if err != nil { result.Error = err.Error() return result, nil } default: result.Error = fmt.Sprintf( "unsupported git remote for PR creation: %s (GitHub, GitLab, and Azure Repos are supported)", redactRemoteURL(remoteURL), ) return result, nil } pushOutput, err := g.runGitCommand(ctx, "push", "--set-upstream", "origin", "HEAD") if err != nil { sanitizedOutput := g.sanitizePRFailure(pushOutput, title, body) result.Error = fmt.Sprintf("failed to push branch: %s", sanitizedOutput) result.Output = sanitizedOutput return result, nil } g.logger.Debug("pushed branch to remote", zap.String("output", g.sanitizeGitPushOutput(pushOutput))) switch provider { case prProviderAzureRepos: created, createErr := g.createAzureReposPR(ctx, result, remoteURL, branch, title, body, baseBranch, draft) return finalizePRCreationAfterPush(created, createErr) case prProviderGitHub: created, createErr := g.createGitHubPR(ctx, result, branch, title, body, baseBranch, draft) return finalizePRCreationAfterPush(created, createErr) case prProviderGitLab: created, createErr := g.createGitLabPR(ctx, result, gitLabInfo, branch, title, body, baseBranch, draft) return finalizePRCreationAfterPush(created, createErr) default: result.Error = "unsupported git remote for PR creation" return result, nil } } func (g *GitOperator) createManagedContributionPR( ctx context.Context, title, body, baseBranch string, draft bool, ) (*PRCreateResult, error) { result := &PRCreateResult{} originURL, err := g.getOriginRemoteURL(ctx) if err != nil { result.Error = err.Error() return result, nil } if g.detectPRProvider(originURL) != prProviderGitHub { result.Error = fmt.Sprintf("managed contribution destination requires a GitHub origin: %s", redactRemoteURL(originURL)) return result, nil } if err := g.validateContributionDestinationRemote(ctx); err != nil { result.Error = err.Error() return result, nil } branch, err := g.getCurrentBranch(ctx) if err != nil { result.Error = fmt.Sprintf("failed to get current branch: %s", err.Error()) return result, nil } output, err := g.runGitCommand(ctx, "push", g.contributionDestination.ContributionRemoteName(), "HEAD:refs/heads/"+branch) if err != nil { result.Error = fmt.Sprintf("failed to push contribution destination: %s", g.sanitizePRFailure(output, title, body)) result.Output = g.sanitizeGitPushOutput(output) return result, nil } result.Provider = string(prProviderGitHub) result.BranchPushed = true created, createErr := g.createGitHubPR(ctx, result, branch, title, body, baseBranch, draft) return finalizePRCreationAfterPush(created, createErr) } func finalizePRCreationAfterPush(result *PRCreateResult, createErr error) (*PRCreateResult, error) { if result == nil { result = &PRCreateResult{} } if createErr == nil && result.Success { return result, nil } requestName := "pull request" if result.Provider == string(prProviderGitLab) { requestName = "merge request" } result.Success = false result.BranchPushed = true result.PRURL = "" result.Output = "" result.Error = "branch was pushed; retry " + requestName + " creation" return result, nil } // parseStatSummary parses a git --shortstat / --stat summary line like // " 3 files changed, 10 insertions(+), 5 deletions(-)" and returns the counts. func parseStatSummary(summary string) (filesChanged, insertions, deletions int) { if idx := strings.Index(summary, " file"); idx > 0 { part := strings.TrimSpace(summary[:idx]) parts := strings.Fields(part) if len(parts) > 0 { _, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &filesChanged) } } if idx := strings.Index(summary, " insertion"); idx > 0 { start := strings.LastIndex(summary[:idx], " ") + 1 if start > 0 && start < idx { _, _ = fmt.Sscanf(summary[start:idx], "%d", &insertions) } } if idx := strings.Index(summary, " deletion"); idx > 0 { start := strings.LastIndex(summary[:idx], " ") + 1 if start > 0 && start < idx { _, _ = fmt.Sscanf(summary[start:idx], "%d", &deletions) } } return filesChanged, insertions, deletions } // getCommitStats returns the number of files changed, insertions, and deletions for a commit func (g *GitOperator) getCommitStats(ctx context.Context, commitSHA string) (filesChanged, insertions, deletions int) { // git show --stat --format="" HEAD gives us the stat summary output, err := g.runGitCommand(ctx, "show", "--stat", "--format=", commitSHA) if err != nil { return 0, 0, 0 } lines := strings.Split(strings.TrimSpace(output), "\n") if len(lines) == 0 { return 0, 0, 0 } return parseStatSummary(lines[len(lines)-1]) }