Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 79 additions & 10 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package git

import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)

// GitError contains raw output from a git command for agent observation.
Expand Down Expand Up @@ -107,12 +109,66 @@ func (g *Git) run(args ...string) (string, error) {
return strings.TrimSpace(stdout.String()), nil
}

// pushTimeout is the maximum time a git push is allowed to run before being
// killed. This prevents gt done from hanging indefinitely when the remote
// (e.g. GitLab) is unreachable or slow.
const pushTimeout = 60 * time.Second

// runWithTimeout executes a git command with a deadline. If the command does
// not finish within the timeout, the process is killed and an error is returned.
func (g *Git) runWithTimeout(timeout time.Duration, args ...string) (string, error) {
if g.gitDir != "" {
args = append([]string{"--git-dir=" + g.gitDir}, args...)
}

ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

cmd := exec.CommandContext(ctx, "git", args...)
if g.workDir != "" {
cmd.Dir = g.workDir
}

var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

err := cmd.Run()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("git %s timed out after %v (remote may be unreachable)", args[0], timeout)
}
return "", g.wrapError(err, stdout.String(), stderr.String(), args)
}

return strings.TrimSpace(stdout.String()), nil
}

// runWithEnv executes a git command with additional environment variables.
func (g *Git) runWithEnv(args []string, extraEnv []string) (_ string, _ error) { //nolint:unparam // string return kept for consistency with Run()
return g.runWithEnvAndTimeout(args, extraEnv, 0)
}

// runWithEnvAndTimeout executes a git command with extra env vars and an
// optional timeout. Pass 0 for no timeout.
func (g *Git) runWithEnvAndTimeout(args []string, extraEnv []string, timeout time.Duration) (_ string, _ error) {
if g.gitDir != "" {
args = append([]string{"--git-dir=" + g.gitDir}, args...)
}
cmd := exec.Command("git", args...)

var cmd *exec.Cmd
var cancel context.CancelFunc
if timeout > 0 {
var ctx context.Context
ctx, cancel = context.WithTimeout(context.Background(), timeout)
cmd = exec.CommandContext(ctx, "git", args...)
} else {
cmd = exec.Command("git", args...)
}
if cancel != nil {
defer cancel()
}

if g.workDir != "" {
cmd.Dir = g.workDir
}
Expand All @@ -124,6 +180,12 @@ func (g *Git) runWithEnv(args []string, extraEnv []string) (_ string, _ error) {
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
if timeout > 0 {
// Check if the context's deadline was exceeded
if errors.Is(err, context.DeadlineExceeded) {
return "", fmt.Errorf("git %s timed out after %v (remote may be unreachable)", args[0], timeout)
}
}
return "", g.wrapError(err, stdout.String(), stderr.String(), args)
}
return strings.TrimSpace(stdout.String()), nil
Expand Down Expand Up @@ -473,13 +535,14 @@ func (g *Git) GetPushURL(remote string) (string, error) {
return strings.TrimSpace(out), nil
}

// Push pushes to the remote branch.
// Push pushes to the remote branch with a timeout to prevent indefinite hangs
// when the remote is unreachable.
func (g *Git) Push(remote, branch string, force bool) error {
args := []string{"push", remote, branch}
if force {
args = append(args, "--force")
}
_, err := g.run(args...)
_, err := g.runWithTimeout(pushTimeout, args...)
return err
}

Expand All @@ -491,7 +554,7 @@ func (g *Git) PushWithEnv(remote, branch string, force bool, env []string) error
if force {
args = append(args, "--force")
}
_, err := g.runWithEnv(args, env)
_, err := g.runWithEnvAndTimeout(args, env, pushTimeout)
return err
}

Expand Down Expand Up @@ -756,7 +819,7 @@ func (g *Git) RecentCommits(n int) (string, error) {

// DeleteRemoteBranch deletes a branch on the remote.
func (g *Git) DeleteRemoteBranch(remote, branch string) error {
_, err := g.run("push", remote, "--delete", branch)
_, err := g.runWithTimeout(pushTimeout, "push", remote, "--delete", branch)
return err
}

Expand Down Expand Up @@ -1512,11 +1575,12 @@ func isGasTownRuntimePath(path string) bool {
// CleanExcludingRuntime returns true if the only uncommitted changes are Gas Town
// runtime artifacts (.beads/, .claude/, .runtime/, .logs/, __pycache__/).
// Used by gt done to avoid blocking completion on toolchain-managed files.
//
// NOTE: This intentionally ignores UnpushedCommits and StashCount.
// After MQ submit, commits are on the polecat branch but not main — that's
// expected and should not block completion. Stashes survive worktree deletion.
// This function only answers: "are all *file-level* changes runtime artifacts?"
func (s *UncommittedWorkStatus) CleanExcludingRuntime() bool {
if s.StashCount > 0 || s.UnpushedCommits > 0 {
return false
}

for _, f := range s.ModifiedFiles {
if !isGasTownRuntimePath(f) {
return false
Expand Down Expand Up @@ -1935,10 +1999,15 @@ func (g *Git) PushSubmoduleCommit(submodulePath, sha, remote string) error {
if err != nil {
return fmt.Errorf("detecting default branch for submodule %s: %w", submodulePath, err)
}
cmd := exec.Command("git", "-C", absPath, "push", remote, sha+":refs/heads/"+defaultBranch)
ctx, cancel := context.WithTimeout(context.Background(), pushTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "-C", absPath, "push", remote, sha+":refs/heads/"+defaultBranch)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("pushing submodule %s timed out after %v (remote may be unreachable)", submodulePath, pushTimeout)
}
return fmt.Errorf("pushing submodule %s commit %s: %s", submodulePath, sha[:8], strings.TrimSpace(stderr.String()))
}
return nil
Expand Down
8 changes: 4 additions & 4 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1659,18 +1659,18 @@ func TestCleanExcludingRuntime(t *testing.T) {
want: true,
},
{
name: "stashes block",
name: "stashes ignored (survive worktree deletion)",
s: UncommittedWorkStatus{
StashCount: 1,
},
want: false,
want: true,
},
{
name: "unpushed commits block",
name: "unpushed commits ignored (expected after MQ submit)",
s: UncommittedWorkStatus{
UnpushedCommits: 2,
},
want: false,
want: true,
},
{
name: "pycache untracked",
Expand Down