feat: absorb workspace command from CLI
Workspace, agent, and task management commands. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
65e3ef4d49
commit
45a78d77bc
7 changed files with 1143 additions and 0 deletions
7
cmd/workspace/cmd.go
Normal file
7
cmd/workspace/cmd.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package workspace
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddWorkspaceCommands)
|
||||
}
|
||||
289
cmd/workspace/cmd_agent.go
Normal file
289
cmd/workspace/cmd_agent.go
Normal file
|
|
@ -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 <provider/agent-name>",
|
||||
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 <provider/agent-name>",
|
||||
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
|
||||
|
||||
<!-- Add observations, decisions, and findings below -->
|
||||
`, 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))
|
||||
}
|
||||
79
cmd/workspace/cmd_agent_test.go
Normal file
79
cmd/workspace/cmd_agent_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
466
cmd/workspace/cmd_task.go
Normal file
466
cmd/workspace/cmd_task.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
109
cmd/workspace/cmd_task_test.go
Normal file
109
cmd/workspace/cmd_task_test.go
Normal file
|
|
@ -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]
|
||||
}
|
||||
90
cmd/workspace/cmd_workspace.go
Normal file
90
cmd/workspace/cmd_workspace.go
Normal file
|
|
@ -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
|
||||
}
|
||||
103
cmd/workspace/config.go
Normal file
103
cmd/workspace/config.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue