From e3954992af63440986bd39cce889e9c62e1a6b92 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 24 Mar 2026 21:35:12 +0000 Subject: feat: clone story branch in ContainerRunner (ADR-007) - Add BranchName field to task.Task (populated from story at execution time) - Add GetStory to executor Store interface; resolve BranchName from story in both execute() and executeResume() parallel to RepositoryURL resolution - Pass --branch to git clone when BranchName is set; default clone otherwise Co-Authored-By: Claude Sonnet 4.6 --- internal/executor/container.go | 9 +++- internal/executor/container_test.go | 87 +++++++++++++++++++++++++++++++++++++ internal/executor/executor.go | 13 ++++++ internal/executor/executor_test.go | 3 +- 4 files changed, 109 insertions(+), 3 deletions(-) (limited to 'internal/executor') diff --git a/internal/executor/container.go b/internal/executor/container.go index d9ed8ef..d270e20 100644 --- a/internal/executor/container.go +++ b/internal/executor/container.go @@ -101,8 +101,13 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec if err := os.Remove(workspace); err != nil { return fmt.Errorf("removing workspace before clone: %w", err) } - r.Logger.Info("cloning repository", "url", repoURL, "workspace", workspace) - if out, err := r.command(ctx, "git", "clone", repoURL, workspace).CombinedOutput(); err != nil { + r.Logger.Info("cloning repository", "url", repoURL, "workspace", workspace, "branch", t.BranchName) + cloneArgs := []string{"clone"} + if t.BranchName != "" { + cloneArgs = append(cloneArgs, "--branch", t.BranchName) + } + cloneArgs = append(cloneArgs, repoURL, workspace) + if out, err := r.command(ctx, "git", cloneArgs...).CombinedOutput(); err != nil { return fmt.Errorf("git clone failed: %w\n%s", err, string(out)) } if err = os.Chmod(workspace, 0755); err != nil { diff --git a/internal/executor/container_test.go b/internal/executor/container_test.go index b6946ef..c56d1b2 100644 --- a/internal/executor/container_test.go +++ b/internal/executor/container_test.go @@ -514,3 +514,90 @@ func TestContainerRunner_AuthError_SyncsAndRetries(t *testing.T) { t.Error("expected sync-credentials to be called, but marker file not found") } } + +func TestContainerRunner_ClonesStoryBranch(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + var cloneArgs []string + runner := &ContainerRunner{ + Logger: logger, + Image: "busybox", + Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { + if name == "git" && len(arg) > 0 && arg[0] == "clone" { + cloneArgs = append([]string{}, arg...) + dir := arg[len(arg)-1] + os.MkdirAll(dir, 0755) + return exec.Command("true") + } + // docker run fails so the test exits quickly + if name == "docker" { + return exec.Command("sh", "-c", "exit 1") + } + return exec.Command("true") + }, + } + + tk := &task.Task{ + ID: "story-branch-test", + RepositoryURL: "https://example.com/repo.git", + BranchName: "story/my-feature", + Agent: task.AgentConfig{Type: "claude"}, + } + e := &storage.Execution{ID: "exec-1", TaskID: "story-branch-test"} + + runner.Run(context.Background(), tk, e) + os.RemoveAll(e.SandboxDir) + + // Assert git clone was called with --branch + if len(cloneArgs) < 3 { + t.Fatalf("expected clone args, got %v", cloneArgs) + } + found := false + for i, a := range cloneArgs { + if a == "--branch" && i+1 < len(cloneArgs) && cloneArgs[i+1] == "story/my-feature" { + found = true + break + } + } + if !found { + t.Errorf("expected git clone --branch story/my-feature, got args: %v", cloneArgs) + } +} + +func TestContainerRunner_ClonesDefaultBranchWhenNoBranchName(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + var cloneArgs []string + runner := &ContainerRunner{ + Logger: logger, + Image: "busybox", + Command: func(ctx context.Context, name string, arg ...string) *exec.Cmd { + if name == "git" && len(arg) > 0 && arg[0] == "clone" { + cloneArgs = append([]string{}, arg...) + dir := arg[len(arg)-1] + os.MkdirAll(dir, 0755) + return exec.Command("true") + } + if name == "docker" { + return exec.Command("sh", "-c", "exit 1") + } + return exec.Command("true") + }, + } + + tk := &task.Task{ + ID: "no-branch-test", + RepositoryURL: "https://example.com/repo.git", + Agent: task.AgentConfig{Type: "claude"}, + } + e := &storage.Execution{ID: "exec-2", TaskID: "no-branch-test"} + + runner.Run(context.Background(), tk, e) + os.RemoveAll(e.SandboxDir) + + for _, a := range cloneArgs { + if a == "--branch" { + t.Errorf("expected no --branch flag for task without BranchName, got args: %v", cloneArgs) + } + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 440294c..6489060 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -33,6 +33,7 @@ type Store interface { UpdateExecutionChangestats(execID string, stats *task.Changestats) error RecordAgentEvent(e storage.AgentEvent) error GetProject(id string) (*task.Project, error) + GetStory(id string) (*task.Story, error) } // LogPather is an optional interface runners can implement to provide the log @@ -275,6 +276,12 @@ func (p *Pool) executeResume(ctx context.Context, t *task.Task, exec *storage.Ex t.RepositoryURL = proj.RemoteURL } } + // Populate BranchName from Story if missing (ADR-007). + if t.BranchName == "" && t.StoryID != "" { + if story, err := p.store.GetStory(t.StoryID); err == nil && story.BranchName != "" { + t.BranchName = story.BranchName + } + } err = runner.Run(ctx, t, exec) exec.EndTime = time.Now().UTC() @@ -715,6 +722,12 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { t.RepositoryURL = proj.RemoteURL } } + // Populate BranchName from Story if missing (ADR-007). + if t.BranchName == "" && t.StoryID != "" { + if story, err := p.store.GetStory(t.StoryID); err == nil && story.BranchName != "" { + t.BranchName = story.BranchName + } + } // Run the task. err = runner.Run(ctx, t, exec) diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 1f4e92f..2e01230 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -1057,7 +1057,8 @@ func (m *minimalMockStore) UpdateExecutionChangestats(execID string, stats *task return nil } func (m *minimalMockStore) RecordAgentEvent(_ storage.AgentEvent) error { return nil } -func (m *minimalMockStore) GetProject(_ string) (*task.Project, error) { return nil, nil } +func (m *minimalMockStore) GetProject(_ string) (*task.Project, error) { return nil, nil } +func (m *minimalMockStore) GetStory(_ string) (*task.Story, error) { return nil, nil } func (m *minimalMockStore) lastStateUpdate() (string, task.State, bool) { m.mu.Lock() -- cgit v1.2.3