// cmd_task.go implements task workspace isolation using git worktrees. // // Each task gets an isolated workspace at .core/workspace/p{epic}/i{issue}/ // containing git worktrees of required repos. This prevents agents from // writing to the implementor's working tree. // // Safety checks enforce that workspaces cannot be removed if they contain // uncommitted changes or unpushed branches. package workspace import ( "context" "errors" "fmt" "os/exec" "path/filepath" "strconv" "strings" "github.com/host-uk/core/pkg/cli" coreio "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/repos" "github.com/spf13/cobra" ) var ( taskEpic int taskIssue int taskRepos []string taskForce bool taskBranch string ) func addTaskCommands(parent *cobra.Command) { taskCmd := &cobra.Command{ Use: "task", Short: "Manage isolated task workspaces for agents", } createCmd := &cobra.Command{ Use: "create", Short: "Create an isolated task workspace with git worktrees", Long: `Creates a workspace at .core/workspace/p{epic}/i{issue}/ with git worktrees for each specified repo. Each worktree gets a fresh branch (issue/{id} by default) so agents work in isolation.`, RunE: runTaskCreate, } createCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") createCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") createCmd.Flags().StringSliceVar(&taskRepos, "repo", nil, "Repos to include (default: all from registry)") createCmd.Flags().StringVar(&taskBranch, "branch", "", "Branch name (default: issue/{issue})") _ = createCmd.MarkFlagRequired("epic") _ = createCmd.MarkFlagRequired("issue") removeCmd := &cobra.Command{ Use: "remove", Short: "Remove a task workspace (with safety checks)", Long: `Removes a task workspace after checking for uncommitted changes and unpushed branches. Use --force to skip safety checks.`, RunE: runTaskRemove, } removeCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") removeCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") removeCmd.Flags().BoolVar(&taskForce, "force", false, "Skip safety checks") _ = removeCmd.MarkFlagRequired("epic") _ = removeCmd.MarkFlagRequired("issue") listCmd := &cobra.Command{ Use: "list", Short: "List all task workspaces", RunE: runTaskList, } statusCmd := &cobra.Command{ Use: "status", Short: "Show status of a task workspace", RunE: runTaskStatus, } statusCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") statusCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") _ = statusCmd.MarkFlagRequired("epic") _ = statusCmd.MarkFlagRequired("issue") addAgentCommands(taskCmd) taskCmd.AddCommand(createCmd, removeCmd, listCmd, statusCmd) parent.AddCommand(taskCmd) } // taskWorkspacePath returns the path for a task workspace. func taskWorkspacePath(root string, epic, issue int) string { return filepath.Join(root, ".core", "workspace", fmt.Sprintf("p%d", epic), fmt.Sprintf("i%d", issue)) } func runTaskCreate(cmd *cobra.Command, args []string) error { ctx := context.Background() root, err := FindWorkspaceRoot() if err != nil { return cli.Err("not in a workspace — run from workspace root or a package directory") } wsPath := taskWorkspacePath(root, taskEpic, taskIssue) if coreio.Local.IsDir(wsPath) { return cli.Err("task workspace already exists: %s", wsPath) } branch := taskBranch if branch == "" { branch = fmt.Sprintf("issue/%d", taskIssue) } // Determine repos to include repoNames := taskRepos if len(repoNames) == 0 { repoNames, err = registryRepoNames(root) if err != nil { return fmt.Errorf("failed to load registry: %w", err) } } if len(repoNames) == 0 { return cli.Err("no repos specified and no registry found") } // Resolve package paths config, _ := LoadConfig(root) pkgDir := "./packages" if config != nil && config.PackagesDir != "" { pkgDir = config.PackagesDir } if !filepath.IsAbs(pkgDir) { pkgDir = filepath.Join(root, pkgDir) } if err := coreio.Local.EnsureDir(wsPath); err != nil { return fmt.Errorf("failed to create workspace directory: %w", err) } cli.Print("Creating task workspace: %s\n", cli.ValueStyle.Render(fmt.Sprintf("p%d/i%d", taskEpic, taskIssue))) cli.Print("Branch: %s\n", cli.ValueStyle.Render(branch)) cli.Print("Path: %s\n\n", cli.DimStyle.Render(wsPath)) var created, skipped int for _, repoName := range repoNames { repoPath := filepath.Join(pkgDir, repoName) if !coreio.Local.IsDir(filepath.Join(repoPath, ".git")) { cli.Print(" %s %s (not cloned, skipping)\n", cli.DimStyle.Render("·"), repoName) skipped++ continue } worktreePath := filepath.Join(wsPath, repoName) cli.Print(" %s %s... ", cli.DimStyle.Render("·"), repoName) if err := createWorktree(ctx, repoPath, worktreePath, branch); err != nil { cli.Print("%s\n", cli.ErrorStyle.Render("x "+err.Error())) skipped++ continue } cli.Print("%s\n", cli.SuccessStyle.Render("ok")) created++ } cli.Print("\n%s %d worktrees created", cli.SuccessStyle.Render("Done:"), created) if skipped > 0 { cli.Print(", %d skipped", skipped) } cli.Print("\n") return nil } func runTaskRemove(cmd *cobra.Command, args []string) error { root, err := FindWorkspaceRoot() if err != nil { return cli.Err("not in a workspace") } wsPath := taskWorkspacePath(root, taskEpic, taskIssue) if !coreio.Local.IsDir(wsPath) { return cli.Err("task workspace does not exist: p%d/i%d", taskEpic, taskIssue) } if !taskForce { dirty, reasons := checkWorkspaceSafety(wsPath) if dirty { cli.Print("%s Cannot remove workspace p%d/i%d:\n", cli.ErrorStyle.Render("Blocked:"), taskEpic, taskIssue) for _, r := range reasons { cli.Print(" %s %s\n", cli.ErrorStyle.Render("·"), r) } cli.Print("\nUse --force to override or resolve the issues first.\n") return errors.New("workspace has unresolved changes") } } // Remove worktrees first (so git knows they're gone) entries, err := coreio.Local.List(wsPath) if err != nil { return fmt.Errorf("failed to list workspace: %w", err) } config, _ := LoadConfig(root) pkgDir := "./packages" if config != nil && config.PackagesDir != "" { pkgDir = config.PackagesDir } if !filepath.IsAbs(pkgDir) { pkgDir = filepath.Join(root, pkgDir) } for _, entry := range entries { if !entry.IsDir() { continue } worktreePath := filepath.Join(wsPath, entry.Name()) repoPath := filepath.Join(pkgDir, entry.Name()) // Remove worktree from git if coreio.Local.IsDir(filepath.Join(repoPath, ".git")) { removeWorktree(repoPath, worktreePath) } } // Remove the workspace directory if err := coreio.Local.DeleteAll(wsPath); err != nil { return fmt.Errorf("failed to remove workspace directory: %w", err) } // Clean up empty parent (p{epic}/) if it's now empty epicDir := filepath.Dir(wsPath) if entries, err := coreio.Local.List(epicDir); err == nil && len(entries) == 0 { coreio.Local.DeleteAll(epicDir) } cli.Print("%s Removed workspace p%d/i%d\n", cli.SuccessStyle.Render("Done:"), taskEpic, taskIssue) return nil } func runTaskList(cmd *cobra.Command, args []string) error { root, err := FindWorkspaceRoot() if err != nil { return cli.Err("not in a workspace") } wsRoot := filepath.Join(root, ".core", "workspace") if !coreio.Local.IsDir(wsRoot) { cli.Println("No task workspaces found.") return nil } epics, err := coreio.Local.List(wsRoot) if err != nil { return fmt.Errorf("failed to list workspaces: %w", err) } found := false for _, epicEntry := range epics { if !epicEntry.IsDir() || !strings.HasPrefix(epicEntry.Name(), "p") { continue } epicDir := filepath.Join(wsRoot, epicEntry.Name()) issues, err := coreio.Local.List(epicDir) if err != nil { continue } for _, issueEntry := range issues { if !issueEntry.IsDir() || !strings.HasPrefix(issueEntry.Name(), "i") { continue } found = true wsPath := filepath.Join(epicDir, issueEntry.Name()) // Count worktrees entries, _ := coreio.Local.List(wsPath) dirCount := 0 for _, e := range entries { if e.IsDir() { dirCount++ } } // Check safety dirty, _ := checkWorkspaceSafety(wsPath) status := cli.SuccessStyle.Render("clean") if dirty { status = cli.ErrorStyle.Render("dirty") } cli.Print(" %s/%s %d repos %s\n", epicEntry.Name(), issueEntry.Name(), dirCount, status) } } if !found { cli.Println("No task workspaces found.") } return nil } func runTaskStatus(cmd *cobra.Command, args []string) error { root, err := FindWorkspaceRoot() if err != nil { return cli.Err("not in a workspace") } wsPath := taskWorkspacePath(root, taskEpic, taskIssue) if !coreio.Local.IsDir(wsPath) { return cli.Err("task workspace does not exist: p%d/i%d", taskEpic, taskIssue) } cli.Print("Workspace: %s\n", cli.ValueStyle.Render(fmt.Sprintf("p%d/i%d", taskEpic, taskIssue))) cli.Print("Path: %s\n\n", cli.DimStyle.Render(wsPath)) entries, err := coreio.Local.List(wsPath) if err != nil { return fmt.Errorf("failed to list workspace: %w", err) } for _, entry := range entries { if !entry.IsDir() { continue } worktreePath := filepath.Join(wsPath, entry.Name()) // Get branch branch := gitOutput(worktreePath, "rev-parse", "--abbrev-ref", "HEAD") branch = strings.TrimSpace(branch) // Get status status := gitOutput(worktreePath, "status", "--porcelain") statusLabel := cli.SuccessStyle.Render("clean") if strings.TrimSpace(status) != "" { lines := len(strings.Split(strings.TrimSpace(status), "\n")) statusLabel = cli.ErrorStyle.Render(fmt.Sprintf("%d changes", lines)) } // Get unpushed unpushed := gitOutput(worktreePath, "log", "--oneline", "@{u}..HEAD") unpushedLabel := "" if trimmed := strings.TrimSpace(unpushed); trimmed != "" { count := len(strings.Split(trimmed, "\n")) unpushedLabel = cli.WarningStyle.Render(fmt.Sprintf(" %d unpushed", count)) } cli.Print(" %s %s %s%s\n", cli.RepoStyle.Render(entry.Name()), cli.DimStyle.Render(branch), statusLabel, unpushedLabel) } return nil } // createWorktree adds a git worktree at worktreePath for the given branch. func createWorktree(ctx context.Context, repoPath, worktreePath, branch string) error { // Check if branch exists on remote first cmd := exec.CommandContext(ctx, "git", "worktree", "add", "-b", branch, worktreePath) cmd.Dir = repoPath output, err := cmd.CombinedOutput() if err != nil { errStr := strings.TrimSpace(string(output)) // If branch already exists, try without -b if strings.Contains(errStr, "already exists") { cmd = exec.CommandContext(ctx, "git", "worktree", "add", worktreePath, branch) cmd.Dir = repoPath output, err = cmd.CombinedOutput() if err != nil { return fmt.Errorf("%s", strings.TrimSpace(string(output))) } return nil } return fmt.Errorf("%s", errStr) } return nil } // removeWorktree removes a git worktree. func removeWorktree(repoPath, worktreePath string) { cmd := exec.Command("git", "worktree", "remove", worktreePath) cmd.Dir = repoPath _ = cmd.Run() // Prune stale worktrees cmd = exec.Command("git", "worktree", "prune") cmd.Dir = repoPath _ = cmd.Run() } // checkWorkspaceSafety checks all worktrees in a workspace for uncommitted/unpushed changes. func checkWorkspaceSafety(wsPath string) (dirty bool, reasons []string) { entries, err := coreio.Local.List(wsPath) if err != nil { return false, nil } for _, entry := range entries { if !entry.IsDir() { continue } worktreePath := filepath.Join(wsPath, entry.Name()) // Check for uncommitted changes status := gitOutput(worktreePath, "status", "--porcelain") if strings.TrimSpace(status) != "" { dirty = true reasons = append(reasons, fmt.Sprintf("%s: has uncommitted changes", entry.Name())) } // Check for unpushed commits unpushed := gitOutput(worktreePath, "log", "--oneline", "@{u}..HEAD") if strings.TrimSpace(unpushed) != "" { dirty = true count := len(strings.Split(strings.TrimSpace(unpushed), "\n")) reasons = append(reasons, fmt.Sprintf("%s: %d unpushed commits", entry.Name(), count)) } } return dirty, reasons } // gitOutput runs a git command and returns stdout. func gitOutput(dir string, args ...string) string { cmd := exec.Command("git", args...) cmd.Dir = dir out, _ := cmd.Output() return string(out) } // registryRepoNames returns repo names from the workspace registry. func registryRepoNames(root string) ([]string, error) { // Try to find repos.yaml regPath, err := repos.FindRegistry(coreio.Local) if err != nil { return nil, err } reg, err := repos.LoadRegistry(coreio.Local, regPath) if err != nil { return nil, err } var names []string for _, repo := range reg.List() { // Only include cloneable repos if repo.Clone != nil && !*repo.Clone { continue } // Skip meta repos if repo.Type == "meta" { continue } names = append(names, repo.Name) } return names, nil } // epicBranchName returns the branch name for an EPIC. func epicBranchName(epicID int) string { return "epic/" + strconv.Itoa(epicID) }