Switch Angular from hash-based to path-based routing so each Wails window (/tray, /main, /settings) loads its correct route. Archive GitHub Actions workflows to .gh-actions/, update Forgejo deploy registry to dappco.re/osi, and apply gofmt/alignment fixes across packages. Co-Authored-By: Virgil <virgil@lethean.io>
466 lines
13 KiB
Go
466 lines
13 KiB
Go
// 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)
|
|
}
|