From 45a78d77bce53bead9b57d69f83a66f2e4f5dcf1 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 21:46:16 +0000 Subject: [PATCH] feat: absorb workspace command from CLI Workspace, agent, and task management commands. Co-Authored-By: Virgil --- cmd/workspace/cmd.go | 7 + cmd/workspace/cmd_agent.go | 289 ++++++++++++++++++++ cmd/workspace/cmd_agent_test.go | 79 ++++++ cmd/workspace/cmd_task.go | 466 ++++++++++++++++++++++++++++++++ cmd/workspace/cmd_task_test.go | 109 ++++++++ cmd/workspace/cmd_workspace.go | 90 ++++++ cmd/workspace/config.go | 103 +++++++ 7 files changed, 1143 insertions(+) create mode 100644 cmd/workspace/cmd.go create mode 100644 cmd/workspace/cmd_agent.go create mode 100644 cmd/workspace/cmd_agent_test.go create mode 100644 cmd/workspace/cmd_task.go create mode 100644 cmd/workspace/cmd_task_test.go create mode 100644 cmd/workspace/cmd_workspace.go create mode 100644 cmd/workspace/config.go diff --git a/cmd/workspace/cmd.go b/cmd/workspace/cmd.go new file mode 100644 index 0000000..ef46b04 --- /dev/null +++ b/cmd/workspace/cmd.go @@ -0,0 +1,7 @@ +package workspace + +import "forge.lthn.ai/core/go/pkg/cli" + +func init() { + cli.RegisterCommands(AddWorkspaceCommands) +} diff --git a/cmd/workspace/cmd_agent.go b/cmd/workspace/cmd_agent.go new file mode 100644 index 0000000..d071f23 --- /dev/null +++ b/cmd/workspace/cmd_agent.go @@ -0,0 +1,289 @@ +// cmd_agent.go manages persistent agent context within task workspaces. +// +// Each agent gets a directory at: +// +// .core/workspace/p{epic}/i{issue}/agents/{provider}/{agent-name}/ +// +// This directory persists across invocations, allowing agents to build +// understanding over time — QA agents accumulate findings, reviewers +// track patterns, implementors record decisions. +// +// Layout: +// +// agents/ +// ├── claude-opus/implementor/ +// │ ├── memory.md # Persistent notes, decisions, context +// │ └── artifacts/ # Generated artifacts (reports, diffs, etc.) +// ├── claude-opus/qa/ +// │ ├── memory.md +// │ └── artifacts/ +// └── gemini/reviewer/ +// └── memory.md +package workspace + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + "forge.lthn.ai/core/go/pkg/cli" + coreio "forge.lthn.ai/core/go/pkg/io" + "github.com/spf13/cobra" +) + +var ( + agentProvider string + agentName string +) + +func addAgentCommands(parent *cobra.Command) { + agentCmd := &cobra.Command{ + Use: "agent", + Short: "Manage persistent agent context within task workspaces", + } + + initCmd := &cobra.Command{ + Use: "init ", + Short: "Initialize an agent's context directory in the task workspace", + Long: `Creates agents/{provider}/{agent-name}/ with memory.md and artifacts/ +directory. The agent can read/write memory.md across invocations to +build understanding over time.`, + Args: cobra.ExactArgs(1), + RunE: runAgentInit, + } + initCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") + initCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") + _ = initCmd.MarkFlagRequired("epic") + _ = initCmd.MarkFlagRequired("issue") + + agentListCmd := &cobra.Command{ + Use: "list", + Short: "List agents in a task workspace", + RunE: runAgentList, + } + agentListCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") + agentListCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") + _ = agentListCmd.MarkFlagRequired("epic") + _ = agentListCmd.MarkFlagRequired("issue") + + pathCmd := &cobra.Command{ + Use: "path ", + Short: "Print the agent's context directory path", + Args: cobra.ExactArgs(1), + RunE: runAgentPath, + } + pathCmd.Flags().IntVar(&taskEpic, "epic", 0, "Epic/project number") + pathCmd.Flags().IntVar(&taskIssue, "issue", 0, "Issue number") + _ = pathCmd.MarkFlagRequired("epic") + _ = pathCmd.MarkFlagRequired("issue") + + agentCmd.AddCommand(initCmd, agentListCmd, pathCmd) + parent.AddCommand(agentCmd) +} + +// agentContextPath returns the path for an agent's context directory. +func agentContextPath(wsPath, provider, name string) string { + return filepath.Join(wsPath, "agents", provider, name) +} + +// parseAgentID splits "provider/agent-name" into parts. +func parseAgentID(id string) (provider, name string, err error) { + parts := strings.SplitN(id, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("agent ID must be provider/agent-name (e.g. claude-opus/qa)") + } + return parts[0], parts[1], nil +} + +// AgentManifest tracks agent metadata for a task workspace. +type AgentManifest struct { + Provider string `json:"provider"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + LastSeen time.Time `json:"last_seen"` +} + +func runAgentInit(cmd *cobra.Command, args []string) error { + provider, name, err := parseAgentID(args[0]) + if err != nil { + return err + } + + 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 — create it first with `core workspace task create`", taskEpic, taskIssue) + } + + agentDir := agentContextPath(wsPath, provider, name) + + if coreio.Local.IsDir(agentDir) { + // Update last_seen + updateAgentManifest(agentDir, provider, name) + cli.Print("Agent %s/%s already initialized at p%d/i%d\n", + cli.ValueStyle.Render(provider), cli.ValueStyle.Render(name), taskEpic, taskIssue) + cli.Print("Path: %s\n", cli.DimStyle.Render(agentDir)) + return nil + } + + // Create directory structure + if err := coreio.Local.EnsureDir(agentDir); err != nil { + return fmt.Errorf("failed to create agent directory: %w", err) + } + if err := coreio.Local.EnsureDir(filepath.Join(agentDir, "artifacts")); err != nil { + return fmt.Errorf("failed to create artifacts directory: %w", err) + } + + // Create initial memory.md + memoryContent := fmt.Sprintf(`# %s/%s — Issue #%d (EPIC #%d) + +## Context +- **Task workspace:** p%d/i%d +- **Initialized:** %s + +## Notes + + +`, provider, name, taskIssue, taskEpic, taskEpic, taskIssue, time.Now().Format(time.RFC3339)) + + if err := coreio.Local.Write(filepath.Join(agentDir, "memory.md"), memoryContent); err != nil { + return fmt.Errorf("failed to create memory.md: %w", err) + } + + // Write manifest + updateAgentManifest(agentDir, provider, name) + + cli.Print("%s Agent %s/%s initialized at p%d/i%d\n", + cli.SuccessStyle.Render("Done:"), + cli.ValueStyle.Render(provider), cli.ValueStyle.Render(name), + taskEpic, taskIssue) + cli.Print("Memory: %s\n", cli.DimStyle.Render(filepath.Join(agentDir, "memory.md"))) + + return nil +} + +func runAgentList(cmd *cobra.Command, args []string) error { + root, err := FindWorkspaceRoot() + if err != nil { + return cli.Err("not in a workspace") + } + + wsPath := taskWorkspacePath(root, taskEpic, taskIssue) + agentsDir := filepath.Join(wsPath, "agents") + + if !coreio.Local.IsDir(agentsDir) { + cli.Println("No agents in this workspace.") + return nil + } + + providers, err := coreio.Local.List(agentsDir) + if err != nil { + return fmt.Errorf("failed to list agents: %w", err) + } + + found := false + for _, providerEntry := range providers { + if !providerEntry.IsDir() { + continue + } + providerDir := filepath.Join(agentsDir, providerEntry.Name()) + agents, err := coreio.Local.List(providerDir) + if err != nil { + continue + } + + for _, agentEntry := range agents { + if !agentEntry.IsDir() { + continue + } + found = true + agentDir := filepath.Join(providerDir, agentEntry.Name()) + + // Read manifest for last_seen + lastSeen := "" + manifestPath := filepath.Join(agentDir, "manifest.json") + if data, err := coreio.Local.Read(manifestPath); err == nil { + var m AgentManifest + if json.Unmarshal([]byte(data), &m) == nil { + lastSeen = m.LastSeen.Format("2006-01-02 15:04") + } + } + + // Check if memory has content beyond the template + memorySize := "" + if content, err := coreio.Local.Read(filepath.Join(agentDir, "memory.md")); err == nil { + lines := len(strings.Split(content, "\n")) + memorySize = fmt.Sprintf("%d lines", lines) + } + + cli.Print(" %s/%s %s", + cli.ValueStyle.Render(providerEntry.Name()), + cli.ValueStyle.Render(agentEntry.Name()), + cli.DimStyle.Render(memorySize)) + if lastSeen != "" { + cli.Print(" last: %s", cli.DimStyle.Render(lastSeen)) + } + cli.Print("\n") + } + } + + if !found { + cli.Println("No agents in this workspace.") + } + + return nil +} + +func runAgentPath(cmd *cobra.Command, args []string) error { + provider, name, err := parseAgentID(args[0]) + if err != nil { + return err + } + + root, err := FindWorkspaceRoot() + if err != nil { + return cli.Err("not in a workspace") + } + + wsPath := taskWorkspacePath(root, taskEpic, taskIssue) + agentDir := agentContextPath(wsPath, provider, name) + + if !coreio.Local.IsDir(agentDir) { + return cli.Err("agent %s/%s not initialized — run `core workspace agent init %s/%s`", provider, name, provider, name) + } + + // Print just the path (useful for scripting: cd $(core workspace agent path ...)) + cli.Text(agentDir) + return nil +} + +func updateAgentManifest(agentDir, provider, name string) { + now := time.Now() + manifest := AgentManifest{ + Provider: provider, + Name: name, + CreatedAt: now, + LastSeen: now, + } + + // Try to preserve created_at from existing manifest + manifestPath := filepath.Join(agentDir, "manifest.json") + if data, err := coreio.Local.Read(manifestPath); err == nil { + var existing AgentManifest + if json.Unmarshal([]byte(data), &existing) == nil { + manifest.CreatedAt = existing.CreatedAt + } + } + + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return + } + _ = coreio.Local.Write(manifestPath, string(data)) +} diff --git a/cmd/workspace/cmd_agent_test.go b/cmd/workspace/cmd_agent_test.go new file mode 100644 index 0000000..e414cb0 --- /dev/null +++ b/cmd/workspace/cmd_agent_test.go @@ -0,0 +1,79 @@ +package workspace + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseAgentID_Good(t *testing.T) { + provider, name, err := parseAgentID("claude-opus/qa") + require.NoError(t, err) + assert.Equal(t, "claude-opus", provider) + assert.Equal(t, "qa", name) +} + +func TestParseAgentID_Bad(t *testing.T) { + tests := []string{ + "noslash", + "/missing-provider", + "missing-name/", + "", + } + for _, id := range tests { + _, _, err := parseAgentID(id) + assert.Error(t, err, "expected error for: %q", id) + } +} + +func TestAgentContextPath(t *testing.T) { + path := agentContextPath("/ws/p101/i343", "claude-opus", "qa") + assert.Equal(t, "/ws/p101/i343/agents/claude-opus/qa", path) +} + +func TestUpdateAgentManifest_Good(t *testing.T) { + tmp := t.TempDir() + agentDir := filepath.Join(tmp, "agents", "test-provider", "test-agent") + require.NoError(t, os.MkdirAll(agentDir, 0755)) + + updateAgentManifest(agentDir, "test-provider", "test-agent") + + data, err := os.ReadFile(filepath.Join(agentDir, "manifest.json")) + require.NoError(t, err) + + var m AgentManifest + require.NoError(t, json.Unmarshal(data, &m)) + assert.Equal(t, "test-provider", m.Provider) + assert.Equal(t, "test-agent", m.Name) + assert.False(t, m.CreatedAt.IsZero()) + assert.False(t, m.LastSeen.IsZero()) +} + +func TestUpdateAgentManifest_PreservesCreatedAt(t *testing.T) { + tmp := t.TempDir() + agentDir := filepath.Join(tmp, "agents", "p", "a") + require.NoError(t, os.MkdirAll(agentDir, 0755)) + + // First call sets created_at + updateAgentManifest(agentDir, "p", "a") + + data, err := os.ReadFile(filepath.Join(agentDir, "manifest.json")) + require.NoError(t, err) + var first AgentManifest + require.NoError(t, json.Unmarshal(data, &first)) + + // Second call should preserve created_at + updateAgentManifest(agentDir, "p", "a") + + data, err = os.ReadFile(filepath.Join(agentDir, "manifest.json")) + require.NoError(t, err) + var second AgentManifest + require.NoError(t, json.Unmarshal(data, &second)) + + assert.Equal(t, first.CreatedAt, second.CreatedAt) + assert.True(t, second.LastSeen.After(first.CreatedAt) || second.LastSeen.Equal(first.CreatedAt)) +} diff --git a/cmd/workspace/cmd_task.go b/cmd/workspace/cmd_task.go new file mode 100644 index 0000000..115ee6f --- /dev/null +++ b/cmd/workspace/cmd_task.go @@ -0,0 +1,466 @@ +// 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" + + "forge.lthn.ai/core/go/pkg/cli" + coreio "forge.lthn.ai/core/go/pkg/io" + "forge.lthn.ai/core/go/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) +} diff --git a/cmd/workspace/cmd_task_test.go b/cmd/workspace/cmd_task_test.go new file mode 100644 index 0000000..6340470 --- /dev/null +++ b/cmd/workspace/cmd_task_test.go @@ -0,0 +1,109 @@ +package workspace + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestRepo(t *testing.T, dir, name string) string { + t.Helper() + repoPath := filepath.Join(dir, name) + require.NoError(t, os.MkdirAll(repoPath, 0755)) + + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + {"git", "commit", "--allow-empty", "-m", "initial"}, + } + for _, c := range cmds { + cmd := exec.Command(c[0], c[1:]...) + cmd.Dir = repoPath + out, err := cmd.CombinedOutput() + require.NoError(t, err, "cmd %v failed: %s", c, string(out)) + } + return repoPath +} + +func TestTaskWorkspacePath(t *testing.T) { + path := taskWorkspacePath("/home/user/Code/host-uk", 101, 343) + assert.Equal(t, "/home/user/Code/host-uk/.core/workspace/p101/i343", path) +} + +func TestCreateWorktree_Good(t *testing.T) { + tmp := t.TempDir() + repoPath := setupTestRepo(t, tmp, "test-repo") + worktreePath := filepath.Join(tmp, "workspace", "test-repo") + + err := createWorktree(t.Context(), repoPath, worktreePath, "issue/123") + require.NoError(t, err) + + // Verify worktree exists + assert.DirExists(t, worktreePath) + assert.FileExists(t, filepath.Join(worktreePath, ".git")) + + // Verify branch + branch := gitOutput(worktreePath, "rev-parse", "--abbrev-ref", "HEAD") + assert.Equal(t, "issue/123", trimNL(branch)) +} + +func TestCreateWorktree_BranchExists(t *testing.T) { + tmp := t.TempDir() + repoPath := setupTestRepo(t, tmp, "test-repo") + + // Create branch first + cmd := exec.Command("git", "branch", "issue/456") + cmd.Dir = repoPath + require.NoError(t, cmd.Run()) + + worktreePath := filepath.Join(tmp, "workspace", "test-repo") + err := createWorktree(t.Context(), repoPath, worktreePath, "issue/456") + require.NoError(t, err) + + assert.DirExists(t, worktreePath) +} + +func TestCheckWorkspaceSafety_Clean(t *testing.T) { + tmp := t.TempDir() + wsPath := filepath.Join(tmp, "workspace") + require.NoError(t, os.MkdirAll(wsPath, 0755)) + + repoPath := setupTestRepo(t, tmp, "origin-repo") + worktreePath := filepath.Join(wsPath, "origin-repo") + require.NoError(t, createWorktree(t.Context(), repoPath, worktreePath, "test-branch")) + + dirty, reasons := checkWorkspaceSafety(wsPath) + assert.False(t, dirty) + assert.Empty(t, reasons) +} + +func TestCheckWorkspaceSafety_Dirty(t *testing.T) { + tmp := t.TempDir() + wsPath := filepath.Join(tmp, "workspace") + require.NoError(t, os.MkdirAll(wsPath, 0755)) + + repoPath := setupTestRepo(t, tmp, "origin-repo") + worktreePath := filepath.Join(wsPath, "origin-repo") + require.NoError(t, createWorktree(t.Context(), repoPath, worktreePath, "test-branch")) + + // Create uncommitted file + require.NoError(t, os.WriteFile(filepath.Join(worktreePath, "dirty.txt"), []byte("dirty"), 0644)) + + dirty, reasons := checkWorkspaceSafety(wsPath) + assert.True(t, dirty) + assert.Contains(t, reasons[0], "uncommitted changes") +} + +func TestEpicBranchName(t *testing.T) { + assert.Equal(t, "epic/101", epicBranchName(101)) + assert.Equal(t, "epic/42", epicBranchName(42)) +} + +func trimNL(s string) string { + return s[:len(s)-1] +} diff --git a/cmd/workspace/cmd_workspace.go b/cmd/workspace/cmd_workspace.go new file mode 100644 index 0000000..28c26b4 --- /dev/null +++ b/cmd/workspace/cmd_workspace.go @@ -0,0 +1,90 @@ +package workspace + +import ( + "strings" + + "forge.lthn.ai/core/go/pkg/cli" + "github.com/spf13/cobra" +) + +// AddWorkspaceCommands registers workspace management commands. +func AddWorkspaceCommands(root *cobra.Command) { + wsCmd := &cobra.Command{ + Use: "workspace", + Short: "Manage workspace configuration", + RunE: runWorkspaceInfo, + } + + wsCmd.AddCommand(&cobra.Command{ + Use: "active [package]", + Short: "Show or set the active package", + RunE: runWorkspaceActive, + }) + + addTaskCommands(wsCmd) + + root.AddCommand(wsCmd) +} + +func runWorkspaceInfo(cmd *cobra.Command, args []string) error { + root, err := FindWorkspaceRoot() + if err != nil { + return cli.Err("not in a workspace") + } + + config, err := LoadConfig(root) + if err != nil { + return err + } + if config == nil { + return cli.Err("workspace config not found") + } + + cli.Print("Active: %s\n", cli.ValueStyle.Render(config.Active)) + cli.Print("Packages: %s\n", cli.DimStyle.Render(config.PackagesDir)) + if len(config.DefaultOnly) > 0 { + cli.Print("Types: %s\n", cli.DimStyle.Render(strings.Join(config.DefaultOnly, ", "))) + } + + return nil +} + +func runWorkspaceActive(cmd *cobra.Command, args []string) error { + root, err := FindWorkspaceRoot() + if err != nil { + return cli.Err("not in a workspace") + } + + config, err := LoadConfig(root) + if err != nil { + return err + } + if config == nil { + config = DefaultConfig() + } + + // If no args, show active + if len(args) == 0 { + if config.Active == "" { + cli.Println("No active package set") + return nil + } + cli.Text(config.Active) + return nil + } + + // Set active + target := args[0] + if target == config.Active { + cli.Print("Active package is already %s\n", cli.ValueStyle.Render(target)) + return nil + } + + config.Active = target + if err := SaveConfig(root, config); err != nil { + return err + } + + cli.Print("Active package set to %s\n", cli.SuccessStyle.Render(target)) + return nil +} diff --git a/cmd/workspace/config.go b/cmd/workspace/config.go new file mode 100644 index 0000000..91a5303 --- /dev/null +++ b/cmd/workspace/config.go @@ -0,0 +1,103 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + + coreio "forge.lthn.ai/core/go/pkg/io" + "gopkg.in/yaml.v3" +) + +// WorkspaceConfig holds workspace-level configuration from .core/workspace.yaml. +type WorkspaceConfig struct { + Version int `yaml:"version"` + Active string `yaml:"active"` // Active package name + DefaultOnly []string `yaml:"default_only"` // Default types for setup + PackagesDir string `yaml:"packages_dir"` // Where packages are cloned +} + +// DefaultConfig returns a config with default values. +func DefaultConfig() *WorkspaceConfig { + return &WorkspaceConfig{ + Version: 1, + PackagesDir: "./packages", + } +} + +// LoadConfig tries to load workspace.yaml from the given directory's .core subfolder. +// Returns nil if no config file exists (caller should check for nil). +func LoadConfig(dir string) (*WorkspaceConfig, error) { + path := filepath.Join(dir, ".core", "workspace.yaml") + data, err := coreio.Local.Read(path) + if err != nil { + // If using Local.Read, it returns error on not found. + // We can check if file exists first or handle specific error if exposed. + // Simplest is to check existence first or assume IsNotExist. + // Since we don't have easy IsNotExist check on coreio error returned yet (uses wrapped error), + // let's check IsFile first. + if !coreio.Local.IsFile(path) { + // Try parent directory + parent := filepath.Dir(dir) + if parent != dir { + return LoadConfig(parent) + } + // No workspace.yaml found anywhere - return nil to indicate no config + return nil, nil + } + return nil, fmt.Errorf("failed to read workspace config: %w", err) + } + + config := DefaultConfig() + if err := yaml.Unmarshal([]byte(data), config); err != nil { + return nil, fmt.Errorf("failed to parse workspace config: %w", err) + } + + if config.Version != 1 { + return nil, fmt.Errorf("unsupported workspace config version: %d", config.Version) + } + + return config, nil +} + +// SaveConfig saves the configuration to the given directory's .core/workspace.yaml. +func SaveConfig(dir string, config *WorkspaceConfig) error { + coreDir := filepath.Join(dir, ".core") + if err := coreio.Local.EnsureDir(coreDir); err != nil { + return fmt.Errorf("failed to create .core directory: %w", err) + } + + path := filepath.Join(coreDir, "workspace.yaml") + data, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("failed to marshal workspace config: %w", err) + } + + if err := coreio.Local.Write(path, string(data)); err != nil { + return fmt.Errorf("failed to write workspace config: %w", err) + } + + return nil +} + +// FindWorkspaceRoot searches for the root directory containing .core/workspace.yaml. +func FindWorkspaceRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if coreio.Local.IsFile(filepath.Join(dir, ".core", "workspace.yaml")) { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return "", fmt.Errorf("not in a workspace") +}