Files
kandev/apps/backend/internal/backendapp/branch_materializer_test.go
T

346 lines
12 KiB
Go

package backendapp
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/task/models"
taskrepo "github.com/kandev/kandev/internal/task/repository"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/worktree"
)
// canonicalTempDir resolves symlinks so macOS /var temp roots pass owned-root guards.
func canonicalTempDir(t *testing.T) string {
t.Helper()
d, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
return d
}
// stubRescanner records calls so the materializer test can assert that the
// agentctl rescan was triggered with the expected work_dir (task root) and
// that the frontend-visible "worktree materialized" event also fired.
type stubRescanner struct {
calls []stubRescanCall
notifyCalls []lifecycle.MaterializedWorktree
}
type stubRescanCall struct {
sessionID string
workDir string
}
func (s *stubRescanner) RescanWorkspaceForSession(_ context.Context, sessionID, workDir string, _ ...[]string) error {
s.calls = append(s.calls, stubRescanCall{sessionID: sessionID, workDir: workDir})
return nil
}
func (s *stubRescanner) NotifyWorktreeMaterialized(_ context.Context, wt lifecycle.MaterializedWorktree) {
s.notifyCalls = append(s.notifyCalls, wt)
}
// TestBranchMaterializer_PromotesWorkspacePathAndTriggersRescan is the
// end-to-end happy path: a single-branch task gains a second branch via the
// materializer, which must (1) create the sibling worktree on disk,
// (2) promote task_environments.workspace_path from primary to task root,
// and (3) ping agentctl rescan with the new task root so the trackers
// rebuild without a session restart.
func TestBranchMaterializer_PromotesWorkspacePathAndTriggersRescan(t *testing.T) {
ctx := context.Background()
repoPath, taskRoot, primaryPath := setupMaterializerScenario(t)
t.Logf("taskRoot=%s primary=%s", taskRoot, primaryPath)
repoSqlite := newMaterializerRepo(t)
worktreeMgr := newMaterializerWorktreeMgr(t, taskRoot)
stub := &stubRescanner{}
mat := &branchMaterializer{
repo: repoSqlite,
worktreeMgr: worktreeMgr,
rescanner: stub,
logger: newTestLogger(),
}
seedMaterializerTask(t, ctx, repoSqlite, repoPath, taskRoot, primaryPath)
tr := &models.TaskRepository{
ID: "tr-branch-2",
TaskID: "task-1",
RepositoryID: "repo-1",
BaseBranch: "main",
CheckoutBranch: "branch-2",
Position: 1,
Metadata: map[string]interface{}{},
}
if err := repoSqlite.CreateTaskRepository(ctx, tr); err != nil {
t.Fatalf("CreateTaskRepository: %v", err)
}
materialized, err := mat.MaterializeBranch(ctx, "task-1", tr.ID)
if err != nil {
t.Fatalf("MaterializeBranch: %v", err)
}
wantSiblingDir := filepath.Join(taskRoot, "kandev-branch-2")
if materialized == nil || materialized.WorktreePath != wantSiblingDir || materialized.TaskWorkspacePath != taskRoot {
t.Fatalf("materialization result = %#v, want sibling worktree and task root", materialized)
}
if _, err := os.Stat(wantSiblingDir); err != nil {
t.Fatalf("expected sibling worktree at %s: %v", wantSiblingDir, err)
}
env, err := repoSqlite.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatalf("re-fetch env: %v", err)
}
if env.WorkspacePath != taskRoot {
t.Errorf("workspace_path = %q, want %q (task root)", env.WorkspacePath, taskRoot)
}
if len(stub.calls) != 1 {
t.Fatalf("expected 1 rescan call, got %d", len(stub.calls))
}
if stub.calls[0].workDir != taskRoot {
t.Errorf("rescan work_dir = %q, want %q", stub.calls[0].workDir, taskRoot)
}
if stub.calls[0].sessionID != "session-1" {
t.Errorf("rescan session_id = %q, want session-1", stub.calls[0].sessionID)
}
if len(stub.notifyCalls) != 1 {
t.Fatalf("expected 1 NotifyWorktreeMaterialized call, got %d", len(stub.notifyCalls))
}
notify := stub.notifyCalls[0]
if notify.TaskID != "task-1" || notify.SessionID != "session-1" {
t.Errorf("notify identifiers = %+v, want task-1/session-1", notify)
}
if notify.WorktreePath != wantSiblingDir {
t.Errorf("notify worktree path = %q, want %q", notify.WorktreePath, wantSiblingDir)
}
if notify.BranchSlug != "branch-2" {
t.Errorf("notify branch_slug = %q, want branch-2", notify.BranchSlug)
}
}
// TestBranchMaterializer_SecondBranchKeepsTaskRootPromoted exercises the
// idempotent promotion path: a task that's already been promoted to task
// root must not be flipped back to a primary path on subsequent adds.
func TestBranchMaterializer_SecondBranchKeepsTaskRootPromoted(t *testing.T) {
ctx := context.Background()
repoPath, taskRoot, primaryPath := setupMaterializerScenario(t)
repoSqlite := newMaterializerRepo(t)
worktreeMgr := newMaterializerWorktreeMgr(t, taskRoot)
stub := &stubRescanner{}
mat := &branchMaterializer{
repo: repoSqlite,
worktreeMgr: worktreeMgr,
rescanner: stub,
logger: newTestLogger(),
}
seedMaterializerTask(t, ctx, repoSqlite, repoPath, taskRoot, primaryPath)
tr2 := &models.TaskRepository{
ID: "tr-branch-2", TaskID: "task-1", RepositoryID: "repo-1",
BaseBranch: "main", CheckoutBranch: "branch-2", Position: 1,
Metadata: map[string]interface{}{},
}
if err := repoSqlite.CreateTaskRepository(ctx, tr2); err != nil {
t.Fatalf("CreateTaskRepository branch-2: %v", err)
}
if _, err := mat.MaterializeBranch(ctx, "task-1", tr2.ID); err != nil {
t.Fatalf("MaterializeBranch branch-2: %v", err)
}
tr3 := &models.TaskRepository{
ID: "tr-branch-3", TaskID: "task-1", RepositoryID: "repo-1",
BaseBranch: "main", CheckoutBranch: "branch-3", Position: 2,
Metadata: map[string]interface{}{},
}
if err := repoSqlite.CreateTaskRepository(ctx, tr3); err != nil {
t.Fatalf("CreateTaskRepository branch-3: %v", err)
}
if _, err := mat.MaterializeBranch(ctx, "task-1", tr3.ID); err != nil {
t.Fatalf("MaterializeBranch branch-3: %v", err)
}
env, err := repoSqlite.GetTaskEnvironmentByTaskID(ctx, "task-1")
if err != nil {
t.Fatalf("re-fetch env: %v", err)
}
if env.WorkspacePath != taskRoot {
t.Errorf("workspace_path = %q, want %q (must stay at task root)", env.WorkspacePath, taskRoot)
}
for _, dir := range []string{"kandev-branch-2", "kandev-branch-3"} {
p := filepath.Join(taskRoot, dir)
if _, err := os.Stat(p); err != nil {
t.Errorf("missing sibling worktree dir %s: %v", p, err)
}
}
// Second call should also trigger rescan — the trackers may need to
// add the new sibling.
if len(stub.calls) != 2 {
t.Fatalf("expected 2 rescan calls (one per add_branch), got %d", len(stub.calls))
}
for i, call := range stub.calls {
if call.workDir != taskRoot {
t.Errorf("call %d workDir = %q, want %q", i, call.workDir, taskRoot)
}
}
}
// setupMaterializerScenario creates a bare origin repo + a clone that
// serves as the "primary" worktree at <task-root>/kandev/. Returns the
// repository path, the task root, and the primary worktree path.
func setupMaterializerScenario(t *testing.T) (repoPath, taskRoot, primaryPath string) {
t.Helper()
tmp := canonicalTempDir(t)
bareDir := filepath.Join(tmp, "origin.git")
runGit(t, tmp, "init", "--bare", "-b", "main", bareDir)
repoPath = filepath.Join(tmp, "repo")
cmd := exec.Command("git", "clone", bareDir, repoPath)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git clone: %v\n%s", err, out)
}
runGit(t, repoPath, "config", "user.email", "test@example.com")
runGit(t, repoPath, "config", "user.name", "Test User")
runGit(t, repoPath, "config", "commit.gpgsign", "false")
if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("initial\n"), 0644); err != nil {
t.Fatalf("write README: %v", err)
}
runGit(t, repoPath, "add", "README.md")
runGit(t, repoPath, "commit", "-m", "initial")
runGit(t, repoPath, "push", "origin", "main")
taskRoot = filepath.Join(tmp, "tasks", "task-1_aaa")
if err := os.MkdirAll(taskRoot, 0o755); err != nil {
t.Fatalf("mkdir taskRoot: %v", err)
}
primaryPath = filepath.Join(taskRoot, "kandev")
// Simulate the existing primary worktree by adding it as a git worktree
// off the repo. The materializer doesn't depend on this being a real
// worktree (it only looks at task_environments.workspace_path), but
// the test asserts the on-disk layout afterwards.
runGit(t, repoPath, "worktree", "add", "-b", "feature/initial", primaryPath, "main")
return repoPath, taskRoot, primaryPath
}
// newMaterializerRepo opens an in-memory sqlite repo wired with the task
// schema used by the service + materializer.
func newMaterializerRepo(t *testing.T) *sqliterepo.Repository {
t.Helper()
dbFile := filepath.Join(t.TempDir(), "kandev.db")
conn, err := db.OpenSQLite(dbFile)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlxDB := sqlx.NewDb(conn, "sqlite3")
t.Cleanup(func() {
if err := sqlxDB.Close(); err != nil {
t.Logf("close db: %v", err)
}
})
repo, cleanup, err := taskrepo.Provide(sqlxDB, sqlxDB, nil)
if err != nil {
t.Fatalf("repo provide: %v", err)
}
t.Cleanup(func() {
if err := cleanup(); err != nil {
t.Logf("repo cleanup: %v", err)
}
})
return repo
}
// newMaterializerWorktreeMgr builds a worktree.Manager rooted at taskRoot's
// parent so the manager's TasksBasePath aligns with our scenario.
func newMaterializerWorktreeMgr(t *testing.T, taskRoot string) *worktree.Manager {
t.Helper()
cfg := worktree.Config{Enabled: true, TasksBasePath: filepath.Dir(taskRoot), BranchPrefix: "feature/"}
if err := cfg.Validate(); err != nil {
t.Fatalf("worktree config: %v", err)
}
mgr, err := worktree.NewManager(cfg, nil, newTestLogger())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
return mgr
}
// seedMaterializerTask inserts the task / workspace / repository /
// task_environment rows the materializer relies on.
func seedMaterializerTask(t *testing.T, ctx context.Context, repo *sqliterepo.Repository, repoPath, taskRoot, primaryPath string) {
t.Helper()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"}); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"}); err != nil {
t.Fatalf("CreateWorkflow: %v", err)
}
if err := repo.CreateRepository(ctx, &models.Repository{
ID: "repo-1", WorkspaceID: "ws-1", Name: "kandev",
LocalPath: repoPath, DefaultBranch: "main", WorktreeBranchPrefix: "feature/",
}); err != nil {
t.Fatalf("CreateRepository: %v", err)
}
if err := repo.CreateTask(ctx, &models.Task{
ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1",
Title: "Add Multi Branch", Priority: "medium",
}); err != nil {
t.Fatalf("CreateTask: %v", err)
}
if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{
ID: "tr-primary", TaskID: "task-1", RepositoryID: "repo-1",
BaseBranch: "main", Position: 0, Metadata: map[string]interface{}{},
}); err != nil {
t.Fatalf("CreateTaskRepository primary: %v", err)
}
now := time.Now().UTC()
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "session-1", TaskID: "task-1",
State: models.TaskSessionStateWaitingForInput,
StartedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("CreateTaskSession: %v", err)
}
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{
ID: "env-1", TaskID: "task-1",
TaskDirName: filepath.Base(taskRoot),
WorkspacePath: primaryPath,
WorktreePath: primaryPath,
ExecutorType: "worktree", Status: "ready",
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatalf("CreateTaskEnvironment: %v", err)
}
}
// runGit runs `git` in dir, failing the test on error. Inlined here (rather
// than reused from the worktree package's helper) to keep this package
// independent of internal-test exports.
func runGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s in %s: %v\n%s", strings.Join(args, " "), dir, err, out)
}
return string(out)
}