diff --git a/.claude/rules/package-organization.md b/.claude/rules/package-organization.md index e81885e..5ef8834 100644 --- a/.claude/rules/package-organization.md +++ b/.claude/rules/package-organization.md @@ -2,35 +2,35 @@ ## Principles -- Organize by **domain** (config, state, git, registry) not by layer (handlers, services) +- Organize by **domain** (config, registry, git, userconfig) not by layer (handlers, services) - Each package has a **single clear responsibility** - Package names: short, lowercase, noun describing what it contains - No circular dependencies — extract shared types if needed ## Package Responsibilities -| Package | Responsibility | -|--------------|--------------------------------------------------------------------| -| `config/` | Load `fr8.json` / `conductor.json` with fallback chain | -| `state/` | Workspace state CRUD in `.git/fr8.json` with file locking | -| `git/` | All git operations via `os/exec` — no go-git | -| `port/` | Sequential port block allocation | -| `names/` | Adjective-city name generation | -| `filesync/` | `.worktreeinclude` glob matching and file copy | -| `env/` | Build `FR8_*` and `CONDUCTOR_*` environment variables | -| `workspace/` | Workspace resolution: CWD -> registry -> explicit | -| `registry/` | Global repo registry CRUD with file locking | -| `opener/` | Workspace opener config management | -| `tmux/` | Thin wrapper around tmux CLI for background sessions | -| `tui/` | Bubble Tea dashboard TUI | -| `exitcode/` | Exit code constants, `ExitError` type, error classification | -| `jsonout/` | JSON output mode — `Write()`, `WriteError()`, `Conciser` interface | -| `mcp/` | MCP server for tool integrations | +| Package | Responsibility | +|---------------|--------------------------------------------------------------------------| +| `config/` | Load `fr8.json` / `conductor.json` with fallback chain | +| `git/` | All git operations via `os/exec` — no go-git | +| `port/` | Sequential port block allocation | +| `names/` | Adjective-city name generation | +| `filesync/` | `.worktreeinclude` glob matching and file copy | +| `env/` | Build `FR8_*` and `CONDUCTOR_*` environment variables | +| `workspace/` | Workspace resolution: CWD -> registry -> explicit | +| `registry/` | Unified repo + workspace state CRUD (`~/.local/state/fr8/repos.json`) | +| `userconfig/` | User preferences: openers, future settings (`~/.config/fr8/config.json`) | +| `opener/` | Opener execution (`Run()` only) | +| `tmux/` | Thin wrapper around tmux CLI for background sessions | +| `tui/` | Bubble Tea dashboard TUI | +| `exitcode/` | Exit code constants, `ExitError` type, error classification | +| `jsonout/` | JSON output mode — `Write()`, `WriteError()`, `Conciser` interface | +| `mcp/` | MCP server for tool integrations | ## Key Patterns - **Git operations**: always go through `internal/git/` — never shell out directly from `cmd/` -- **State/registry CRUD**: go through their respective packages with file locking +- **Registry/userconfig CRUD**: go through their respective packages with file locking - **File locking**: `.lock` file + `syscall.Flock(LOCK_EX)` + defer unlock + defer remove - **Config fallback**: `fr8.json` -> `conductor.json` -> defaults - **Env vars**: `internal/env/` sets both `FR8_*` and `CONDUCTOR_*` (backwards compat) diff --git a/CLAUDE.md b/CLAUDE.md index ec3accd..797ae31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,15 +40,15 @@ cmd/ # Cobra command definitions (one file per comma resolve.go # Shared resolveWorkspace() helper (local → global fallback) internal/ config/config.go # Load fr8.json / conductor.json - state/state.go # Workspace state CRUD (.git/fr8.json) git/git.go # Shell out to git (worktree, branch, status) port/port.go # Sequential port block allocation names/{names,words}.go # Adjective-city name generation filesync/filesync.go # .worktreeinclude glob + copy env/env.go # Build FR8_* and CONDUCTOR_* env vars workspace/resolve.go # Resolve workspace by name, CWD, or global registry - registry/registry.go # Global repo registry (~/.config/fr8/repos.json) - opener/opener.go # Workspace opener config (~/.config/fr8/openers.json) + registry/registry.go # Unified repo + workspace state (~/.local/state/fr8/repos.json) + userconfig/userconfig.go # User preferences: openers (~/.config/fr8/config.json) + opener/opener.go # Opener execution (Run only) tmux/tmux.go # Thin wrapper around tmux CLI for background sessions tui/ # Bubble Tea dashboard TUI create_workspace.go # TUI view for creating workspaces @@ -79,4 +79,6 @@ Key architectural notes: - `createWorkspace()` in `cmd/new.go` is the shared creation function used by both CLI and TUI dashboard - Background process management uses tmux sessions named `fr8//`; graceful degradation when tmux is not installed -- Workspace openers are stored at `~/.config/fr8/openers.json`; TUI picker shown when multiple workspaces are configured +- State storage: `~/.local/state/fr8/repos.json` (unified repo + workspace registry); `~/.config/fr8/config.json` (user preferences like openers) +- Env var overrides: `FR8_STATE_DIR` for state, `FR8_CONFIG_DIR` for config +- Auto-migration from old format (`~/.config/fr8/repos.json` + `.git/fr8.json`) runs on first command diff --git a/cmd/archive.go b/cmd/archive.go index 2a84232..317359d 100644 --- a/cmd/archive.go +++ b/cmd/archive.go @@ -10,7 +10,7 @@ import ( "github.com/protocollar/fr8/internal/exitcode" "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/jsonout" - "github.com/protocollar/fr8/internal/state" + "github.com/protocollar/fr8/internal/registry" "github.com/protocollar/fr8/internal/tmux" ) @@ -43,7 +43,7 @@ func runArchive(cmd *cobra.Command, args []string) error { name = args[0] } - ws, rootPath, commonDir, err := resolveWorkspace(name) + ws, rootPath, err := resolveWorkspace(name) if err != nil { if archiveIfExists { if jsonout.Enabled { @@ -61,11 +61,6 @@ func runArchive(cmd *cobra.Command, args []string) error { return fmt.Errorf("loading config: %w", err) } - st, err := state.Load(commonDir) - if err != nil { - return fmt.Errorf("loading state: %w", err) - } - // Dry run: just report what would happen if archiveDryRun { dirty, _ := git.HasUncommittedChanges(ws.Path) @@ -170,9 +165,20 @@ func runArchive(cmd *cobra.Command, args []string) error { } // Update state - _ = st.Remove(ws.Name) - if err := st.Save(commonDir); err != nil { - return fmt.Errorf("saving state: %w", err) + regPath, err := registry.DefaultPath() + if err != nil { + return fmt.Errorf("finding state path: %w", err) + } + reg, err := registry.Load(regPath) + if err != nil { + return fmt.Errorf("loading registry: %w", err) + } + repo := reg.FindByPath(rootPath) + if repo != nil { + _ = repo.RemoveWorkspace(ws.Name) + if err := reg.Save(regPath); err != nil { + return fmt.Errorf("saving state: %w", err) + } } if jsonout.Enabled { diff --git a/cmd/attach.go b/cmd/attach.go index cd9a5db..e62ce20 100644 --- a/cmd/attach.go +++ b/cmd/attach.go @@ -40,7 +40,7 @@ func runAttach(cmd *cobra.Command, args []string) error { name = args[0] } - ws, rootPath, _, err := resolveWorkspace(name) + ws, rootPath, err := resolveWorkspace(name) if err != nil { return err } diff --git a/cmd/browser.go b/cmd/browser.go index efda809..ce5c095 100644 --- a/cmd/browser.go +++ b/cmd/browser.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/protocollar/fr8/internal/jsonout" "github.com/protocollar/fr8/internal/port" - "github.com/protocollar/fr8/internal/state" + "github.com/protocollar/fr8/internal/registry" ) func init() { @@ -27,7 +27,7 @@ func runBrowser(cmd *cobra.Command, args []string) error { name = args[0] } - ws, _, _, err := resolveWorkspace(name) + ws, _, err := resolveWorkspace(name) if err != nil { return err } @@ -35,7 +35,7 @@ func runBrowser(cmd *cobra.Command, args []string) error { return openWorkspaceBrowser(ws) } -func openWorkspaceBrowser(ws *state.Workspace) error { +func openWorkspaceBrowser(ws *registry.Workspace) error { listening := !port.IsFree(ws.Port) url := fmt.Sprintf("http://localhost:%d", ws.Port) diff --git a/cmd/cd.go b/cmd/cd.go index 1805e33..b5167c8 100644 --- a/cmd/cd.go +++ b/cmd/cd.go @@ -30,7 +30,7 @@ func runCd(cmd *cobra.Command, args []string) error { name = args[0] } - ws, _, _, err := resolveWorkspace(name) + ws, _, err := resolveWorkspace(name) if err != nil { return err } diff --git a/cmd/completion.go b/cmd/completion.go index d15abd6..73fa50c 100644 --- a/cmd/completion.go +++ b/cmd/completion.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/registry" - "github.com/protocollar/fr8/internal/state" ) func init() { @@ -53,45 +52,29 @@ func workspaceNameCompletion(cmd *cobra.Command, args []string, toComplete strin return nil, cobra.ShellCompDirectiveNoFileComp } - commonDir, err := git.CommonDir(cwd) - if err != nil { - // Not inside a git repo — search all registered repos - return allRegistryWorkspaceNames(), cobra.ShellCompDirectiveNoFileComp - } - - st, err := state.Load(commonDir) - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - return st.Names(), cobra.ShellCompDirectiveNoFileComp -} - -// allRegistryWorkspaceNames returns workspace names from all registered repos. -func allRegistryWorkspaceNames() []string { regPath, err := registry.DefaultPath() if err != nil { - return nil + return nil, cobra.ShellCompDirectiveNoFileComp } - reg, err := registry.Load(regPath) if err != nil { - return nil + return nil, cobra.ShellCompDirectiveNoFileComp } - var names []string - for _, repo := range reg.Repos { - commonDir, err := git.CommonDir(repo.Path) - if err != nil { - continue - } - - st, err := state.Load(commonDir) - if err != nil { - continue + // Try CWD match + repo := reg.FindRepoByWorkspacePath(cwd) + if repo == nil { + if git.IsInsideWorkTree(cwd) { + rootPath, err := git.RootWorktreePath(cwd) + if err == nil { + repo = reg.FindByPath(rootPath) + } } - - names = append(names, st.Names()...) } - return names + if repo != nil { + return repo.WorkspaceNames(), cobra.ShellCompDirectiveNoFileComp + } + + // Fall back to all registered workspace names + return reg.AllWorkspaceNames(), cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/config.go b/cmd/config.go index 2c0558c..fbc75dc 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -13,6 +13,7 @@ import ( "github.com/protocollar/fr8/internal/exitcode" "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/jsonout" + "github.com/protocollar/fr8/internal/registry" ) var doctorFix bool @@ -23,7 +24,7 @@ func init() { configCmd.AddCommand(configDoctorCmd) configCmd.AddCommand(configValidateCmd) // alias configCmd.AddCommand(configOpenCmd) - rootCmd.AddCommand(configCmd) +rootCmd.AddCommand(configCmd) } var configCmd = &cobra.Command{ @@ -62,11 +63,7 @@ var configOpenCmd = &cobra.Command{ // configDir returns the fr8 global config directory (~/.config/fr8). func configDir() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("finding home directory: %w", err) - } - return filepath.Join(home, ".config", "fr8"), nil + return registry.ConfigDir() } func runConfigOpen(cmd *cobra.Command, args []string) error { diff --git a/cmd/dashboard.go b/cmd/dashboard.go index ea931c6..30a4b3f 100644 --- a/cmd/dashboard.go +++ b/cmd/dashboard.go @@ -87,15 +87,11 @@ func runDashboard(cmd *cobra.Command, args []string) error { if result.OpenWorkspace != nil { ws := result.OpenWorkspace - openerPath, err := opener.DefaultPath() + cfg, _, err := loadUserConfig() if err != nil { return err } - openers, err := opener.Load(openerPath) - if err != nil { - return fmt.Errorf("loading openers: %w", err) - } - o := opener.Find(openers, result.OpenerName) + o := cfg.FindOpener(result.OpenerName) if o == nil { return fmt.Errorf("opener %q not found", result.OpenerName) } @@ -108,7 +104,7 @@ func runDashboard(cmd *cobra.Command, args []string) error { } if result.CreateRequested { - ws, err := createWorkspace(result.RootPath, result.CommonDir, result.CreateName, "", false, true, false) + ws, err := createWorkspace(result.RootPath, result.CreateName, "", false, true, false) if err != nil { fmt.Fprintf(os.Stderr, "Error creating workspace: %v\n", err) } else { diff --git a/cmd/env.go b/cmd/env.go index 15acb35..81ae607 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -29,7 +29,7 @@ func runEnv(cmd *cobra.Command, args []string) error { name = args[0] } - ws, rootPath, _, err := resolveWorkspace(name) + ws, rootPath, err := resolveWorkspace(name) if err != nil { return err } diff --git a/cmd/exec.go b/cmd/exec.go index 7d03c53..7aaf8c5 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -64,7 +64,7 @@ func runExec(cmd *cobra.Command, args []string) error { return fmt.Errorf("no command specified after --") } - ws, rootPath, _, err := resolveWorkspace(wsName) + ws, rootPath, err := resolveWorkspace(wsName) if err != nil { return err } diff --git a/cmd/list.go b/cmd/list.go index 80af166..637bfc2 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -11,7 +11,6 @@ import ( "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/jsonout" "github.com/protocollar/fr8/internal/registry" - "github.com/protocollar/fr8/internal/state" "github.com/protocollar/fr8/internal/tmux" ) @@ -51,23 +50,27 @@ func runList(cmd *cobra.Command, args []string) error { return err } - commonDir, err := git.CommonDir(cwd) + regPath, err := registry.DefaultPath() if err != nil { - // Not inside a git repo — fall back to listing all registered repos - return runListAll() + return err } - - st, err := state.Load(commonDir) + reg, err := registry.Load(regPath) if err != nil { - return fmt.Errorf("loading state: %w", err) + return fmt.Errorf("loading registry: %w", err) + } + + rootPath, _ := git.RootWorktreePath(cwd) + repo := reg.FindByPath(rootPath) + if repo == nil { + // Not in a registered repo — fall back to listing all + return runListAll() } // Reconcile: remove workspaces whose paths no longer exist - reconcile(st, cwd) + reconcileRepo(repo, cwd) // Determine repo name for tmux session lookup hasTmux := tmux.Available() == nil - rootPath, _ := git.RootWorktreePath(cwd) repoName := filepath.Base(rootPath) defaultBranch, _ := git.DefaultBranch(rootPath) hasFilters := listRunning || listDirty || listMerged @@ -82,7 +85,7 @@ func runList(cmd *cobra.Command, args []string) error { } var items []workspaceListItem - for _, ws := range st.Workspaces { + for _, ws := range repo.Workspaces { running := false if hasTmux { sessionName := tmux.SessionName(repoName, ws.Name) @@ -120,7 +123,7 @@ func runList(cmd *cobra.Command, args []string) error { } // Save reconciled state - _ = st.Save(commonDir) + _ = reg.Save(regPath) if jsonout.Enabled { if items == nil { @@ -174,26 +177,10 @@ func runListAll() error { var items []workspaceListItem for _, repo := range reg.Repos { - commonDir, err := git.CommonDir(repo.Path) - if err != nil { - if !jsonout.Enabled { - fmt.Fprintf(os.Stderr, "Warning: unable to read %s: %v\n", repo.Name, err) - } - continue - } - - st, err := state.Load(commonDir) - if err != nil { - if !jsonout.Enabled { - fmt.Fprintf(os.Stderr, "Warning: unable to load state for %s: %v\n", repo.Name, err) - } - continue - } - rootPath, _ := git.RootWorktreePath(repo.Path) defaultBranch, _ := git.DefaultBranch(rootPath) - for _, ws := range st.Workspaces { + for _, ws := range repo.Workspaces { running := false if hasTmux { sessionName := tmux.SessionName(repo.Name, ws.Name) @@ -263,10 +250,10 @@ func runListAll() error { return nil } -func reconcile(st *state.State, cwd string) { +func reconcileRepo(repo *registry.Repo, cwd string) { gitWorktrees, err := git.WorktreeList(cwd) if err != nil { - return // can't reconcile, leave state as-is + return } wtPaths := make(map[string]bool, len(gitWorktrees)) @@ -274,11 +261,11 @@ func reconcile(st *state.State, cwd string) { wtPaths[wt.Path] = true } - var remaining []state.Workspace - for _, ws := range st.Workspaces { + var remaining []registry.Workspace + for _, ws := range repo.Workspaces { if wtPaths[ws.Path] { remaining = append(remaining, ws) } } - st.Workspaces = remaining + repo.Workspaces = remaining } diff --git a/cmd/logs.go b/cmd/logs.go index 793eea9..c9b2636 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -52,7 +52,7 @@ func runLogs(cmd *cobra.Command, args []string) error { name = args[0] } - ws, rootPath, _, err := resolveWorkspace(name) + ws, rootPath, err := resolveWorkspace(name) if err != nil { return err } diff --git a/cmd/mcp_tools.go b/cmd/mcp_tools.go index d11967d..9f7eb84 100644 --- a/cmd/mcp_tools.go +++ b/cmd/mcp_tools.go @@ -16,7 +16,6 @@ import ( "github.com/protocollar/fr8/internal/gh" "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/registry" - "github.com/protocollar/fr8/internal/state" "github.com/protocollar/fr8/internal/tmux" "github.com/protocollar/fr8/internal/workspace" ) @@ -43,43 +42,47 @@ func mcpError(msg string) (*mcp.CallToolResult, error) { // mcpResolveWorkspace resolves a workspace by name with optional repo filter. // Unlike the CLI's resolveWorkspace(), this never detects from CWD — it always // uses the global registry for lookup, since the MCP server runs as a long-lived process. -func mcpResolveWorkspace(name, repo string) (*state.Workspace, string, string, error) { +func mcpResolveWorkspace(name, repo string) (*registry.Workspace, string, error) { if name == "" { - return nil, "", "", fmt.Errorf("workspace name is required") + return nil, "", fmt.Errorf("workspace name is required") } if repo != "" { - return workspace.ResolveFromRepo(name, repo) + ws, _, rootPath, err := workspace.ResolveFromRepo(name, repo) + if err != nil { + return nil, "", err + } + return ws, rootPath, nil } - return workspace.ResolveGlobal(name) + ws, _, rootPath, err := workspace.ResolveGlobal(name) + if err != nil { + return nil, "", err + } + return ws, rootPath, nil } -// mcpResolveRepo resolves a repo's root path and git common dir from a repo name. +// mcpResolveRepo resolves a repo's root path from a repo name. // Unlike the CLI, this never detects from CWD — the MCP server runs as a long-lived process. -func mcpResolveRepo(repo string) (rootPath, commonDir string, err error) { +func mcpResolveRepo(repo string) (rootPath string, err error) { if repo == "" { - return "", "", fmt.Errorf("repo parameter is required") + return "", fmt.Errorf("repo parameter is required") } regPath, err := registry.DefaultPath() if err != nil { - return "", "", err + return "", err } reg, err := registry.Load(regPath) if err != nil { - return "", "", fmt.Errorf("loading registry: %w", err) + return "", fmt.Errorf("loading registry: %w", err) } r := reg.Find(repo) if r == nil { - return "", "", fmt.Errorf("repo %q not found in registry (see: fr8 repo list)", repo) + return "", fmt.Errorf("repo %q not found in registry (see: fr8 repo list)", repo) } rootPath, err = git.RootWorktreePath(r.Path) if err != nil { rootPath = r.Path } - commonDir, err = git.CommonDir(r.Path) - if err != nil { - return "", "", fmt.Errorf("finding git common dir: %w", err) - } - return rootPath, commonDir, nil + return rootPath, nil } func registerMCPTools(s *server.MCPServer) { @@ -253,18 +256,10 @@ func handleWorkspaceList(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal if repo != "" && r.Name != repo { continue } - commonDir, err := git.CommonDir(r.Path) - if err != nil { - continue - } - st, err := state.Load(commonDir) - if err != nil { - continue - } rootPath, _ := git.RootWorktreePath(r.Path) defaultBranch, _ := git.DefaultBranch(rootPath) - for _, ws := range st.Workspaces { + for _, ws := range r.Workspaces { running := false if hasTmux { sessionName := tmux.SessionName(r.Name, ws.Name) @@ -313,7 +308,7 @@ func handleWorkspaceStatus(ctx context.Context, req mcp.CallToolRequest) (*mcp.C name := req.GetString("name", "") repo := req.GetString("repo", "") - ws, rootPath, _, err := mcpResolveWorkspace(name, repo) + ws, rootPath, err := mcpResolveWorkspace(name, repo) if err != nil { return mcpError(err.Error()) } @@ -375,7 +370,7 @@ func handleWorkspaceCreate(ctx context.Context, req mcp.CallToolRequest) (*mcp.C noSetup := req.GetBool("no_setup", false) ifNotExists := req.GetBool("if_not_exists", false) - rootPath, commonDir, err := mcpResolveRepo(repo) + rootPath, err := mcpResolveRepo(repo) if err != nil { return mcpError(err.Error()) } @@ -396,25 +391,31 @@ func handleWorkspaceCreate(ctx context.Context, req mcp.CallToolRequest) (*mcp.C // Handle if_not_exists before calling createWorkspace (avoids global flag dependency) if ifNotExists && wsName != "" { - st, err := state.Load(commonDir) + regPath, err := registry.DefaultPath() if err == nil { - if existing := st.Find(wsName); existing != nil { - return mcpResult(struct { - Action string `json:"action"` - Workspace *state.Workspace `json:"workspace"` - }{Action: "already_exists", Workspace: existing}) + reg, err := registry.Load(regPath) + if err == nil { + r := reg.FindByPath(rootPath) + if r != nil { + if existing := r.FindWorkspace(wsName); existing != nil { + return mcpResult(struct { + Action string `json:"action"` + Workspace *registry.Workspace `json:"workspace"` + }{Action: "already_exists", Workspace: existing}) + } + } } } } - ws, err := createWorkspace(rootPath, commonDir, wsName, branch, trackRemote, !noSetup, false) + ws, err := createWorkspace(rootPath, wsName, branch, trackRemote, !noSetup, false) if err != nil { return mcpError(err.Error()) } return mcpResult(struct { - Action string `json:"action"` - Workspace *state.Workspace `json:"workspace"` + Action string `json:"action"` + Workspace *registry.Workspace `json:"workspace"` }{Action: "created", Workspace: ws}) } @@ -424,7 +425,7 @@ func handleWorkspaceArchive(ctx context.Context, req mcp.CallToolRequest) (*mcp. force := req.GetBool("force", false) ifExists := req.GetBool("if_exists", false) - ws, rootPath, commonDir, err := mcpResolveWorkspace(name, repo) + ws, rootPath, err := mcpResolveWorkspace(name, repo) if err != nil { if ifExists { return mcpResult(struct { @@ -439,11 +440,6 @@ func handleWorkspaceArchive(ctx context.Context, req mcp.CallToolRequest) (*mcp. return mcpError(fmt.Sprintf("loading config: %v", err)) } - st, err := state.Load(commonDir) - if err != nil { - return mcpError(fmt.Sprintf("loading state: %v", err)) - } - // Capture branch before worktree removal branch, _ := git.CurrentBranch(ws.Path) @@ -473,10 +469,21 @@ func handleWorkspaceArchive(ctx context.Context, req mcp.CallToolRequest) (*mcp. // Remove worktree _ = git.WorktreeRemove(rootPath, ws.Path) - // Update state - _ = st.Remove(ws.Name) - if err := st.Save(commonDir); err != nil { - return mcpError(fmt.Sprintf("saving state: %v", err)) + // Update state via registry + regPath, err := registry.DefaultPath() + if err != nil { + return mcpError(fmt.Sprintf("finding state path: %v", err)) + } + reg, err := registry.Load(regPath) + if err != nil { + return mcpError(fmt.Sprintf("loading registry: %v", err)) + } + r := reg.FindByPath(rootPath) + if r != nil { + _ = r.RemoveWorkspace(ws.Name) + if err := reg.Save(regPath); err != nil { + return mcpError(fmt.Sprintf("saving state: %v", err)) + } } return mcpResult(struct { @@ -507,7 +514,7 @@ func handleWorkspaceRun(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call return mcpError(err.Error()) } - ws, rootPath, _, err := mcpResolveWorkspace(name, repo) + ws, rootPath, err := mcpResolveWorkspace(name, repo) if err != nil { return mcpError(err.Error()) } @@ -555,7 +562,7 @@ func handleWorkspaceStop(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal return mcpError(err.Error()) } - ws, rootPath, _, err := mcpResolveWorkspace(name, repo) + ws, rootPath, err := mcpResolveWorkspace(name, repo) if err != nil { return mcpError(err.Error()) } @@ -587,7 +594,7 @@ func handleWorkspaceEnv(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call name := req.GetString("name", "") repo := req.GetString("repo", "") - ws, rootPath, _, err := mcpResolveWorkspace(name, repo) + ws, rootPath, err := mcpResolveWorkspace(name, repo) if err != nil { return mcpError(err.Error()) } @@ -615,7 +622,7 @@ func handleWorkspaceLogs(ctx context.Context, req mcp.CallToolRequest) (*mcp.Cal return mcpError(err.Error()) } - ws, rootPath, _, err := mcpResolveWorkspace(name, repo) + ws, rootPath, err := mcpResolveWorkspace(name, repo) if err != nil { return mcpError(err.Error()) } @@ -642,29 +649,37 @@ func handleWorkspaceRename(ctx context.Context, req mcp.CallToolRequest) (*mcp.C return mcpError("both old_name and new_name are required") } - ws, rootPath, commonDir, err := mcpResolveWorkspace(oldName, repo) + ws, rootPath, err := mcpResolveWorkspace(oldName, repo) if err != nil { return mcpError(err.Error()) } - st, err := state.Load(commonDir) - if err != nil { - return mcpError(fmt.Sprintf("loading state: %v", err)) - } - oldPath := ws.Path newPath := filepath.Join(filepath.Dir(oldPath), newName) if err := git.WorktreeMove(rootPath, oldPath, newPath); err != nil { return mcpError(fmt.Sprintf("moving worktree: %v", err)) } - if err := st.Rename(oldName, newName); err != nil { + // Update state via registry + regPath, err := registry.DefaultPath() + if err != nil { + return mcpError(fmt.Sprintf("finding state path: %v", err)) + } + reg, err := registry.Load(regPath) + if err != nil { + return mcpError(fmt.Sprintf("loading registry: %v", err)) + } + r := reg.FindByPath(rootPath) + if r == nil { + return mcpError(fmt.Sprintf("repo not found in registry for path: %s", rootPath)) + } + if err := r.RenameWorkspace(oldName, newName); err != nil { return mcpError(err.Error()) } - renamed := st.Find(newName) + renamed := r.FindWorkspace(newName) renamed.Path = newPath - if err := st.Save(commonDir); err != nil { + if err := reg.Save(regPath); err != nil { return mcpError(fmt.Sprintf("saving state: %v", err)) } @@ -699,8 +714,8 @@ func handleRepoList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool } type mcpRepoItem struct { - Name string `json:"name"` - Path string `json:"path"` + Name string `json:"name"` + Path string `json:"path"` Workspaces []workspaceListItem `json:"workspaces,omitempty"` } @@ -720,7 +735,7 @@ func handleRepoList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTool func handleConfigShow(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { repo := req.GetString("repo", "") - rootPath, _, err := mcpResolveRepo(repo) + rootPath, err := mcpResolveRepo(repo) if err != nil { return mcpError(err.Error()) } @@ -747,7 +762,7 @@ func handleConfigShow(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo func handleConfigDoctor(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { repo := req.GetString("repo", "") - rootPath, _, err := mcpResolveRepo(repo) + rootPath, err := mcpResolveRepo(repo) if err != nil { return mcpError(err.Error()) } diff --git a/cmd/mcp_tools_test.go b/cmd/mcp_tools_test.go index 567a115..c978a33 100644 --- a/cmd/mcp_tools_test.go +++ b/cmd/mcp_tools_test.go @@ -62,7 +62,7 @@ func TestMcpError(t *testing.T) { } func TestMcpResolveWorkspaceEmptyName(t *testing.T) { - _, _, _, err := mcpResolveWorkspace("", "") + _, _, err := mcpResolveWorkspace("", "") if err == nil { t.Fatal("expected error for empty name") } @@ -72,7 +72,7 @@ func TestMcpResolveWorkspaceEmptyName(t *testing.T) { } func TestMcpResolveWorkspaceWithRepoNoName(t *testing.T) { - _, _, _, err := mcpResolveWorkspace("", "some-repo") + _, _, err := mcpResolveWorkspace("", "some-repo") if err == nil { t.Fatal("expected error for empty name even with repo") } @@ -82,7 +82,7 @@ func TestMcpResolveWorkspaceWithRepoNoName(t *testing.T) { } func TestMcpResolveRepoRequiresParam(t *testing.T) { - _, _, err := mcpResolveRepo("") + _, err := mcpResolveRepo("") if err == nil { t.Fatal("expected error for empty repo param") } diff --git a/cmd/new.go b/cmd/new.go index 866ecd4..ea46bec 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -18,7 +18,6 @@ import ( "github.com/protocollar/fr8/internal/names" "github.com/protocollar/fr8/internal/port" "github.com/protocollar/fr8/internal/registry" - "github.com/protocollar/fr8/internal/state" ) var newBranch string @@ -56,7 +55,7 @@ var newCmd = &cobra.Command{ } func runNew(cmd *cobra.Command, args []string) error { - var rootPath, commonDir string + var rootPath string if resolveRepo != "" { // Resolve from registry @@ -73,10 +72,6 @@ func runNew(cmd *cobra.Command, args []string) error { return fmt.Errorf("repo %q not found in registry (see: fr8 repo list)", resolveRepo) } rootPath = repo.Path - commonDir, err = git.CommonDir(rootPath) - if err != nil { - return fmt.Errorf("finding git common dir: %w", err) - } } else { cwd, err := os.Getwd() if err != nil { @@ -91,11 +86,6 @@ func runNew(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("finding root worktree: %w", err) } - - commonDir, err = git.CommonDir(cwd) - if err != nil { - return fmt.Errorf("finding git common dir: %w", err) - } } // Determine branch and whether to track remote @@ -118,7 +108,7 @@ func runNew(cmd *cobra.Command, args []string) error { // When --json, never enter a subshell enterShell := !noShell && !jsonout.Enabled - ws, err := createWorkspace(rootPath, commonDir, nameFromArgs(args), branch, trackRemote, !noSetup, enterShell) + ws, err := createWorkspace(rootPath, nameFromArgs(args), branch, trackRemote, !noSetup, enterShell) if err != nil { return err } @@ -155,25 +145,40 @@ func resolvePRBranch(dir, prNumber string) (string, error) { // createWorkspace is the shared workspace creation logic used by both the CLI // (runNew) and the TUI dashboard loop. When trackRemote is true, the branch is // expected to exist on origin and a local tracking branch will be created. -func createWorkspace(rootPath, commonDir, wsName, branch string, trackRemote, runSetup, enterShell bool) (*state.Workspace, error) { +func createWorkspace(rootPath, wsName, branch string, trackRemote, runSetup, enterShell bool) (*registry.Workspace, error) { cfg, err := config.Load(rootPath) if err != nil { return nil, fmt.Errorf("loading config: %w", err) } - st, err := state.Load(commonDir) + // Load registry for workspace state + regPath, err := registry.DefaultPath() + if err != nil { + return nil, fmt.Errorf("finding state path: %w", err) + } + reg, err := registry.Load(regPath) if err != nil { - return nil, fmt.Errorf("loading state: %w", err) + return nil, fmt.Errorf("loading registry: %w", err) + } + + repo := reg.FindByPath(rootPath) + if repo == nil { + // Auto-register the repo + newRepo := registry.Repo{Name: filepath.Base(rootPath), Path: rootPath} + if err := reg.Add(newRepo); err != nil { + return nil, fmt.Errorf("registering repo: %w", err) + } + repo = reg.FindByPath(rootPath) } // Workspace name if wsName != "" { - if existing := st.Find(wsName); existing != nil { + if existing := repo.FindWorkspace(wsName); existing != nil { if newIfNotExists { if jsonout.Enabled { return existing, jsonout.Write(struct { - Action string `json:"action"` - Workspace *state.Workspace `json:"workspace"` + Action string `json:"action"` + Workspace *registry.Workspace `json:"workspace"` }{Action: "already_exists", Workspace: existing}) } _, _ = fmt.Fprintf(jsonout.MsgOut(), "Workspace %q already exists.\n", wsName) @@ -182,7 +187,7 @@ func createWorkspace(rootPath, commonDir, wsName, branch string, trackRemote, ru return nil, fmt.Errorf("workspace %q already exists", wsName) } } else { - wsName = names.Generate(st.Names()) + wsName = names.Generate(repo.WorkspaceNames()) } // Determine default branch and fetch latest from origin @@ -229,9 +234,7 @@ func createWorkspace(rootPath, commonDir, wsName, branch string, trackRemote, ru } // Port — collect ports from all registered repos to avoid cross-repo conflicts - globalPorts := allAllocatedPorts() - localPorts := st.AllocatedPorts() - allocatedPort, err := port.Allocate(mergePorts(globalPorts, localPorts), cfg.BasePort, cfg.PortRange) + allocatedPort, err := port.Allocate(reg.AllAllocatedPorts(), cfg.BasePort, cfg.PortRange) if err != nil { return nil, fmt.Errorf("allocating port: %w", err) } @@ -242,7 +245,7 @@ func createWorkspace(rootPath, commonDir, wsName, branch string, trackRemote, ru // Dry run: report what would be created without doing it if newDryRun { - planned := state.Workspace{ + planned := registry.Workspace{ Name: wsName, Path: wsPath, Port: allocatedPort, @@ -282,26 +285,23 @@ func createWorkspace(rootPath, commonDir, wsName, branch string, trackRemote, ru return nil, fmt.Errorf("creating worktree: %w", err) } - ws := state.Workspace{ + ws := registry.Workspace{ Name: wsName, Path: wsPath, Port: allocatedPort, CreatedAt: time.Now().UTC(), } - if err := st.Add(ws); err != nil { + if err := repo.AddWorkspace(ws); err != nil { // Clean up worktree on state failure _ = git.WorktreeRemove(rootPath, wsPath) return nil, fmt.Errorf("saving workspace: %w", err) } - if err := st.Save(commonDir); err != nil { + if err := reg.Save(regPath); err != nil { _ = git.WorktreeRemove(rootPath, wsPath) return nil, fmt.Errorf("saving state: %w", err) } - // Auto-register repo in global registry - autoRegisterRepo(rootPath) - // Sync files _, _ = fmt.Fprintf(jsonout.MsgOut(), "Syncing files...\n") if err := filesync.Sync(rootPath, wsPath); err != nil { @@ -378,49 +378,6 @@ func createWorkspace(rootPath, commonDir, wsName, branch string, trackRemote, ru return &ws, nil } -// allAllocatedPorts collects every allocated port across all repos in the -// global registry. Failures are silently skipped so this never blocks -// workspace creation. -func allAllocatedPorts() []int { - regPath, err := registry.DefaultPath() - if err != nil { - return nil - } - reg, err := registry.Load(regPath) - if err != nil { - return nil - } - var ports []int - for _, repo := range reg.Repos { - commonDir, err := git.CommonDir(repo.Path) - if err != nil { - continue - } - st, err := state.Load(commonDir) - if err != nil { - continue - } - ports = append(ports, st.AllocatedPorts()...) - } - return ports -} - -// mergePorts returns the union of two port slices, deduplicating entries from b -// that already appear in a. -func mergePorts(a, b []int) []int { - seen := make(map[int]bool, len(a)) - for _, p := range a { - seen[p] = true - } - merged := append([]int{}, a...) - for _, p := range b { - if !seen[p] { - merged = append(merged, p) - } - } - return merged -} - func shortenHomePath(p string) string { home, err := os.UserHomeDir() if err != nil || home == "" { diff --git a/cmd/opener.go b/cmd/opener.go index c8b0a1a..6fb3a0a 100644 --- a/cmd/opener.go +++ b/cmd/opener.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/protocollar/fr8/internal/jsonout" - "github.com/protocollar/fr8/internal/opener" + "github.com/protocollar/fr8/internal/userconfig" ) func init() { @@ -67,32 +67,40 @@ var openerRemoveCmd = &cobra.Command{ RunE: runOpenerRemove, } -func runOpenerList(cmd *cobra.Command, args []string) error { - path, err := opener.DefaultPath() +func loadUserConfig() (*userconfig.Config, string, error) { + path, err := userconfig.DefaultPath() if err != nil { - return err + return nil, "", err + } + cfg, err := userconfig.Load(path) + if err != nil { + return nil, "", fmt.Errorf("loading config: %w", err) } + return cfg, path, nil +} - openers, err := opener.Load(path) +func runOpenerList(cmd *cobra.Command, args []string) error { + cfg, _, err := loadUserConfig() if err != nil { - return fmt.Errorf("loading openers: %w", err) + return err } if jsonout.Enabled { + openers := cfg.Openers if openers == nil { - openers = []opener.Opener{} + openers = []userconfig.Opener{} } return jsonout.Write(openers) } - if len(openers) == 0 { + if len(cfg.Openers) == 0 { fmt.Println("No openers configured. Add one with: fr8 opener add [command...]") return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintln(w, "NAME\tCOMMAND\tDEFAULT") - for _, o := range openers { + for _, o := range cfg.Openers { def := "" if o.Default { def = "(default)" @@ -110,31 +118,24 @@ func runOpenerAdd(cmd *cobra.Command, args []string) error { command = strings.Join(args[1:], " ") } - path, err := opener.DefaultPath() + cfg, path, err := loadUserConfig() if err != nil { return err } - openers, err := opener.Load(path) - if err != nil { - return fmt.Errorf("loading openers: %w", err) + o := userconfig.Opener{Name: name, Command: command} + if err := cfg.AddOpener(o); err != nil { + return err } - if opener.Find(openers, name) != nil { - return fmt.Errorf("opener %q already exists (remove it first with: fr8 opener remove %s)", name, name) - } - - o := opener.Opener{Name: name, Command: command} - openers = append(openers, o) - - if err := opener.Save(path, openers); err != nil { - return fmt.Errorf("saving openers: %w", err) + if err := cfg.Save(path); err != nil { + return fmt.Errorf("saving config: %w", err) } if jsonout.Enabled { return jsonout.Write(struct { - Action string `json:"action"` - Opener opener.Opener `json:"opener"` + Action string `json:"action"` + Opener userconfig.Opener `json:"opener"` }{Action: "added", Opener: o}) } @@ -152,22 +153,17 @@ func runOpenerAdd(cmd *cobra.Command, args []string) error { func runOpenerSetDefault(cmd *cobra.Command, args []string) error { name := args[0] - path, err := opener.DefaultPath() + cfg, path, err := loadUserConfig() if err != nil { return err } - openers, err := opener.Load(path) - if err != nil { - return fmt.Errorf("loading openers: %w", err) - } - - if err := opener.SetDefault(openers, name); err != nil { + if err := cfg.SetDefaultOpener(name); err != nil { return err } - if err := opener.Save(path, openers); err != nil { - return fmt.Errorf("saving openers: %w", err) + if err := cfg.Save(path); err != nil { + return fmt.Errorf("saving config: %w", err) } if jsonout.Enabled { @@ -184,31 +180,17 @@ func runOpenerSetDefault(cmd *cobra.Command, args []string) error { func runOpenerRemove(cmd *cobra.Command, args []string) error { name := args[0] - path, err := opener.DefaultPath() + cfg, path, err := loadUserConfig() if err != nil { return err } - openers, err := opener.Load(path) - if err != nil { - return fmt.Errorf("loading openers: %w", err) - } - - found := false - for i, o := range openers { - if o.Name == name { - openers = append(openers[:i], openers[i+1:]...) - found = true - break - } - } - - if !found { - return fmt.Errorf("opener %q not found", name) + if err := cfg.RemoveOpener(name); err != nil { + return err } - if err := opener.Save(path, openers); err != nil { - return fmt.Errorf("saving openers: %w", err) + if err := cfg.Save(path); err != nil { + return fmt.Errorf("saving config: %w", err) } if jsonout.Enabled { @@ -227,19 +209,10 @@ func openerNameCompletion(cmd *cobra.Command, args []string, toComplete string) return nil, cobra.ShellCompDirectiveNoFileComp } - path, err := opener.DefaultPath() - if err != nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - openers, err := opener.Load(path) + cfg, _, err := loadUserConfig() if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } - names := make([]string, len(openers)) - for i, o := range openers { - names[i] = o.Name - } - return names, cobra.ShellCompDirectiveNoFileComp + return cfg.OpenerNames(), cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/rename.go b/cmd/rename.go index c0b7b9b..01f1668 100644 --- a/cmd/rename.go +++ b/cmd/rename.go @@ -7,7 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/jsonout" - "github.com/protocollar/fr8/internal/state" + "github.com/protocollar/fr8/internal/registry" "github.com/protocollar/fr8/internal/tmux" ) @@ -27,16 +27,11 @@ func runRename(cmd *cobra.Command, args []string) error { oldName := args[0] newName := args[1] - ws, rootPath, commonDir, err := resolveWorkspace(oldName) + ws, rootPath, err := resolveWorkspace(oldName) if err != nil { return err } - st, err := state.Load(commonDir) - if err != nil { - return fmt.Errorf("loading state: %w", err) - } - // Move the worktree directory (e.g. ~/fr8/myapp/old-name → ~/fr8/myapp/new-name) oldPath := ws.Path newPath := filepath.Join(filepath.Dir(oldPath), newName) @@ -45,13 +40,25 @@ func runRename(cmd *cobra.Command, args []string) error { } // Update state: name and path - if err := st.Rename(oldName, newName); err != nil { + regPath, err := registry.DefaultPath() + if err != nil { + return err + } + reg, err := registry.Load(regPath) + if err != nil { + return fmt.Errorf("loading registry: %w", err) + } + repo := reg.FindByPath(rootPath) + if repo == nil { + return fmt.Errorf("repo not found in registry for path: %s", rootPath) + } + if err := repo.RenameWorkspace(oldName, newName); err != nil { return err } - renamed := st.Find(newName) + renamed := repo.FindWorkspace(newName) renamed.Path = newPath - if err := st.Save(commonDir); err != nil { + if err := reg.Save(regPath); err != nil { return fmt.Errorf("saving state: %w", err) } diff --git a/cmd/repo.go b/cmd/repo.go index 3601543..4d8bfa1 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -11,7 +11,6 @@ import ( "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/jsonout" "github.com/protocollar/fr8/internal/registry" - "github.com/protocollar/fr8/internal/state" "github.com/protocollar/fr8/internal/tmux" ) @@ -108,25 +107,13 @@ func runRepoList(cmd *cobra.Command, args []string) error { for _, repo := range reg.Repos { fmt.Printf("%s (%s)\n", repo.Name, repo.Path) - commonDir, err := git.CommonDir(repo.Path) - if err != nil { - fmt.Fprintf(os.Stderr, " (unable to read git data: %v)\n", err) - continue - } - - st, err := state.Load(commonDir) - if err != nil { - fmt.Fprintf(os.Stderr, " (unable to load state: %v)\n", err) - continue - } - - if len(st.Workspaces) == 0 { + if len(repo.Workspaces) == 0 { fmt.Println(" (no workspaces)") continue } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - for _, ws := range st.Workspaces { + for _, ws := range repo.Workspaces { branch, _ := git.CurrentBranch(ws.Path) _, _ = fmt.Fprintf(w, " %s\t%s\t%d\n", ws.Name, branch, ws.Port) } @@ -137,19 +124,9 @@ func runRepoList(cmd *cobra.Command, args []string) error { } func repoWorkspaces(repo registry.Repo) []workspaceListItem { - commonDir, err := git.CommonDir(repo.Path) - if err != nil { - return []workspaceListItem{} - } - - st, err := state.Load(commonDir) - if err != nil { - return []workspaceListItem{} - } - hasTmux := tmux.Available() == nil - items := make([]workspaceListItem, 0, len(st.Workspaces)) - for _, ws := range st.Workspaces { + items := make([]workspaceListItem, 0, len(repo.Workspaces)) + for _, ws := range repo.Workspaces { running := false if hasTmux { sessionName := tmux.SessionName(repo.Name, ws.Name) @@ -266,37 +243,6 @@ func runRepoRemove(cmd *cobra.Command, args []string) error { return nil } -// autoRegisterRepo silently registers a repo if not already present. -// Skips on name collision — never blocks other commands. -func autoRegisterRepo(rootPath string) { - regPath, err := registry.DefaultPath() - if err != nil { - return - } - - reg, err := registry.Load(regPath) - if err != nil { - return - } - - // Already registered by path - if reg.FindByPath(rootPath) != nil { - return - } - - name := filepath.Base(rootPath) - - // Name collision — skip silently - if reg.Find(name) != nil { - return - } - - reg.Repos = append(reg.Repos, registry.Repo{Name: name, Path: rootPath}) - if err := reg.Save(regPath); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to auto-register repo: %v\n", err) - } -} - // repoNameCompletion returns a ValidArgsFunction that completes repo names. func repoNameCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) > 0 { diff --git a/cmd/resolve.go b/cmd/resolve.go index 8ac47b5..f106bd0 100644 --- a/cmd/resolve.go +++ b/cmd/resolve.go @@ -6,62 +6,75 @@ import ( "path/filepath" "github.com/protocollar/fr8/internal/git" - "github.com/protocollar/fr8/internal/state" + "github.com/protocollar/fr8/internal/registry" "github.com/protocollar/fr8/internal/workspace" ) // resolveWorkspace tries CWD-based resolution, falling back to global registry lookup. // If --repo is set, resolves directly from that registered repo. -// Returns (workspace, rootPath, commonDir, error). -func resolveWorkspace(name string) (*state.Workspace, string, string, error) { +// Returns (workspace, rootPath, error). +func resolveWorkspace(name string) (*registry.Workspace, string, error) { // If --repo is specified, bypass CWD and resolve from that repo if resolveRepo != "" { if name == "" { - return nil, "", "", fmt.Errorf("workspace name is required when using --repo") + return nil, "", fmt.Errorf("workspace name is required when using --repo") } - ws, rootPath, commonDir, err := workspace.ResolveFromRepo(name, resolveRepo) + ws, _, rootPath, err := workspace.ResolveFromRepo(name, resolveRepo) if err != nil { - return nil, "", "", err + return nil, "", err } - return ws, rootPath, commonDir, nil + return ws, rootPath, nil } cwd, err := os.Getwd() if err != nil { - return nil, "", "", err + return nil, "", err } - commonDir, cdErr := git.CommonDir(cwd) - if cdErr == nil { - // Inside a git repo — use local state - st, err := state.Load(commonDir) - if err != nil { - return nil, "", "", fmt.Errorf("loading state: %w", err) + // Load registry + regPath, err := registry.DefaultPath() + if err != nil { + return nil, "", err + } + reg, err := registry.Load(regPath) + if err != nil { + return nil, "", fmt.Errorf("loading registry: %w", err) + } + + // Try to find repo by CWD (workspace path match) + repo := reg.FindRepoByWorkspacePath(cwd) + if repo == nil { + // Try rootPath match (CWD is inside a git repo registered in the registry) + if git.IsInsideWorkTree(cwd) { + rootPath, err := git.RootWorktreePath(cwd) + if err == nil { + repo = reg.FindByPath(rootPath) + } } + } - ws, err := workspace.Resolve(name, st) + if repo != nil { + ws, err := workspace.Resolve(name, repo) if err != nil { - return nil, "", "", err + return nil, "", err } - - rootPath, err := git.RootWorktreePath(cwd) + rootPath, err := git.RootWorktreePath(repo.Path) if err != nil { - return nil, "", "", fmt.Errorf("finding root worktree: %w", err) + rootPath = repo.Path } - - return ws, rootPath, commonDir, nil + return ws, rootPath, nil } - // Not inside a git repo — try global registry if a name was given + // Not found via CWD — try global if a name was given if name == "" { - return nil, "", "", fmt.Errorf("not inside a git repository (specify a workspace name or run from inside a repo)") + return nil, "", fmt.Errorf("not inside a git repository (specify a workspace name or run from inside a repo)") } - ws, rootPath, commonDir, err := workspace.ResolveGlobal(name) + ws, _, rootPath, err := workspace.ResolveGlobal(name) if err != nil { - return nil, "", "", err + return nil, "", err } fmt.Fprintf(os.Stderr, "(resolved from repo %q)\n", filepath.Base(rootPath)) - return ws, rootPath, commonDir, nil + return ws, rootPath, nil } diff --git a/cmd/run.go b/cmd/run.go index 3402689..ceb8717 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -10,7 +10,7 @@ import ( "github.com/protocollar/fr8/internal/env" "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/jsonout" - "github.com/protocollar/fr8/internal/state" + "github.com/protocollar/fr8/internal/registry" "github.com/protocollar/fr8/internal/tmux" ) @@ -51,7 +51,7 @@ func runRun(cmd *cobra.Command, args []string) error { name = args[0] } - ws, rootPath, _, err := resolveWorkspace(name) + ws, rootPath, err := resolveWorkspace(name) if err != nil { return err } @@ -114,17 +114,27 @@ func runRunAll() error { return err } - commonDir, err := git.CommonDir(cwd) + regPath, err := registry.DefaultPath() if err != nil { - return fmt.Errorf("not inside a git repository (run from a repo or use --repo )") + return fmt.Errorf("finding state path: %w", err) + } + reg, err := registry.Load(regPath) + if err != nil { + return fmt.Errorf("loading registry: %w", err) } - st, err := state.Load(commonDir) + // Find repo in registry by CWD + rootPath, err := git.RootWorktreePath(cwd) if err != nil { - return fmt.Errorf("loading state: %w", err) + return fmt.Errorf("finding root worktree: %w", err) + } + + repo := reg.FindByPath(rootPath) + if repo == nil { + return fmt.Errorf("repo not found in registry (run fr8 repo add first)") } - if len(st.Workspaces) == 0 { + if len(repo.Workspaces) == 0 { if jsonout.Enabled { return jsonout.Write(struct { Started []string `json:"started"` @@ -136,11 +146,6 @@ func runRunAll() error { return nil } - rootPath, err := git.RootWorktreePath(cwd) - if err != nil { - return fmt.Errorf("finding root worktree: %w", err) - } - cfg, err := config.Load(rootPath) if err != nil { return fmt.Errorf("loading config: %w", err) @@ -157,8 +162,8 @@ func runRunAll() error { var startedNames, alreadyRunning []string var failed []runFailedItem - for i := range st.Workspaces { - ws := &st.Workspaces[i] + for i := range repo.Workspaces { + ws := &repo.Workspaces[i] sessionName := tmux.SessionName(repoName, ws.Name) if tmux.IsRunning(sessionName) { diff --git a/cmd/shell.go b/cmd/shell.go index 3039911..d65dbb8 100644 --- a/cmd/shell.go +++ b/cmd/shell.go @@ -41,7 +41,7 @@ func runShell(cmd *cobra.Command, args []string) error { name = args[0] } - ws, rootPath, _, err := resolveWorkspace(name) + ws, rootPath, err := resolveWorkspace(name) if err != nil { return err } diff --git a/cmd/status.go b/cmd/status.go index 6c4176a..1a027d1 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -60,7 +60,7 @@ func runStatus(cmd *cobra.Command, args []string) error { name = args[0] } - ws, rootPath, _, err := resolveWorkspace(name) + ws, rootPath, err := resolveWorkspace(name) if err != nil { return err } diff --git a/cmd/stop.go b/cmd/stop.go index 54f6f40..4d67e18 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -43,7 +43,7 @@ func runStop(cmd *cobra.Command, args []string) error { name = args[0] } - ws, rootPath, _, err := resolveWorkspace(name) + ws, rootPath, err := resolveWorkspace(name) if err != nil { return err } diff --git a/cmd/ws_open.go b/cmd/ws_open.go index 55255b0..2cb2c36 100644 --- a/cmd/ws_open.go +++ b/cmd/ws_open.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/protocollar/fr8/internal/jsonout" "github.com/protocollar/fr8/internal/opener" + "github.com/protocollar/fr8/internal/userconfig" ) var wsOpenOpener string @@ -33,41 +34,36 @@ func runWsOpen(cmd *cobra.Command, args []string) error { name = args[0] } - ws, _, _, err := resolveWorkspace(name) + ws, _, err := resolveWorkspace(name) if err != nil { return err } - path, err := opener.DefaultPath() + cfg, _, err := loadUserConfig() if err != nil { return err } - openers, err := opener.Load(path) - if err != nil { - return fmt.Errorf("loading openers: %w", err) - } - - if len(openers) == 0 { + if len(cfg.Openers) == 0 { return fmt.Errorf("no openers configured — add one with: fr8 opener add [executable]") } - var o *opener.Opener + var o *userconfig.Opener if wsOpenOpener != "" { - o = opener.Find(openers, wsOpenOpener) + o = cfg.FindOpener(wsOpenOpener) if o == nil { return fmt.Errorf("opener %q not found (see: fr8 opener list)", wsOpenOpener) } - } else if len(openers) == 1 { - o = &openers[0] - } else if d := opener.FindDefault(openers); d != nil { + } else if len(cfg.Openers) == 1 { + o = &cfg.Openers[0] + } else if d := cfg.FindDefaultOpener(); d != nil { o = d } else { if jsonout.Enabled { return fmt.Errorf("multiple openers configured; specify one with --opener (or set a default with: fr8 opener set-default )") } fmt.Println("Multiple openers configured:") - for _, op := range openers { + for _, op := range cfg.Openers { fmt.Printf(" - %s\n", op.Name) } return fmt.Errorf("specify one with --opener (or set a default with: fr8 opener set-default )") diff --git a/internal/env/env.go b/internal/env/env.go index 82f4ccc..69ea358 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -4,13 +4,13 @@ import ( "fmt" "os" - "github.com/protocollar/fr8/internal/state" + "github.com/protocollar/fr8/internal/registry" ) // Build returns a complete environment variable slice for running scripts // in the given workspace. Includes both FR8_* and CONDUCTOR_* (compat) vars, // merged with the current process environment. -func Build(ws *state.Workspace, rootPath, defaultBranch string) []string { +func Build(ws *registry.Workspace, rootPath, defaultBranch string) []string { fr8Vars := map[string]string{ "FR8_WORKSPACE_NAME": ws.Name, "FR8_WORKSPACE_PATH": ws.Path, @@ -49,7 +49,7 @@ func Build(ws *state.Workspace, rootPath, defaultBranch string) []string { // BuildFr8Only returns only the FR8_* and CONDUCTOR_* environment variables // (not merged with the current process env). Used for tmux sessions where // the user's shell environment is inherited automatically. -func BuildFr8Only(ws *state.Workspace, rootPath, defaultBranch string) []string { +func BuildFr8Only(ws *registry.Workspace, rootPath, defaultBranch string) []string { return []string{ "FR8_WORKSPACE_NAME=" + ws.Name, "FR8_WORKSPACE_PATH=" + ws.Path, diff --git a/internal/env/env_test.go b/internal/env/env_test.go index 6af8d6a..4bd1378 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -5,11 +5,11 @@ import ( "testing" "time" - "github.com/protocollar/fr8/internal/state" + "github.com/protocollar/fr8/internal/registry" ) func TestBuildContainsAllVars(t *testing.T) { - ws := &state.Workspace{ + ws := ®istry.Workspace{ Name: "test-ws", Path: "/tmp/ws/test-ws", Port: 5000, @@ -45,7 +45,7 @@ func TestBuildContainsAllVars(t *testing.T) { } func TestBuildPreservesExistingEnv(t *testing.T) { - ws := &state.Workspace{Name: "ws", Path: "/tmp/ws", Port: 5000, CreatedAt: time.Now()} + ws := ®istry.Workspace{Name: "ws", Path: "/tmp/ws", Port: 5000, CreatedAt: time.Now()} result := Build(ws, "/root", "main") envMap := toMap(result) @@ -57,7 +57,7 @@ func TestBuildPreservesExistingEnv(t *testing.T) { } func TestBuildFr8OverridesConductor(t *testing.T) { - ws := &state.Workspace{Name: "ws", Path: "/tmp/ws", Port: 5000} + ws := ®istry.Workspace{Name: "ws", Path: "/tmp/ws", Port: 5000} result := Build(ws, "/root", "main") envMap := toMap(result) @@ -69,7 +69,7 @@ func TestBuildFr8OverridesConductor(t *testing.T) { } func TestBuildFr8OnlyContainsOnlyFr8Vars(t *testing.T) { - ws := &state.Workspace{ + ws := ®istry.Workspace{ Name: "test-ws", Path: "/tmp/ws/test-ws", Port: 5000, @@ -110,7 +110,7 @@ func TestBuildFr8OnlyContainsOnlyFr8Vars(t *testing.T) { } func TestBuildFr8OnlyExcludesProcessEnv(t *testing.T) { - ws := &state.Workspace{Name: "ws", Path: "/tmp/ws", Port: 5000} + ws := ®istry.Workspace{Name: "ws", Path: "/tmp/ws", Port: 5000} result := BuildFr8Only(ws, "/root", "main") envMap := toMap(result) diff --git a/internal/opener/opener.go b/internal/opener/opener.go index aa91edb..18ac7cb 100644 --- a/internal/opener/opener.go +++ b/internal/opener/opener.go @@ -1,117 +1,18 @@ package opener import ( - "encoding/json" "fmt" "os" "os/exec" - "path/filepath" "strings" - "github.com/protocollar/fr8/internal/flock" + "github.com/protocollar/fr8/internal/userconfig" ) -// Opener defines a named command for opening a workspace in an external tool. -type Opener struct { - Name string `json:"name"` - Command string `json:"command"` - Default bool `json:"default,omitempty"` -} - -// DefaultPath returns the default openers config path (~/.config/fr8/openers.json). -func DefaultPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("finding home directory: %w", err) - } - return filepath.Join(home, ".config", "fr8", "openers.json"), nil -} - -// Load reads the opener list from path. Returns an empty slice if the file doesn't exist. -func Load(path string) ([]Opener, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("reading openers: %w", err) - } - var openers []Opener - if err := json.Unmarshal(data, &openers); err != nil { - return nil, fmt.Errorf("parsing openers: %w", err) - } - return openers, nil -} - -// Save writes the opener list to path. -// Uses advisory file locking to prevent concurrent modifications. -func Save(path string, openers []Opener) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("creating openers directory: %w", err) - } - - data, err := json.MarshalIndent(openers, "", " ") - if err != nil { - return fmt.Errorf("marshaling openers: %w", err) - } - data = append(data, '\n') - - f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - return fmt.Errorf("creating lock file: %w", err) - } - defer func() { _ = f.Close() }() - defer func() { _ = os.Remove(path + ".lock") }() - - if err := flock.Lock(f.Fd()); err != nil { - return fmt.Errorf("acquiring lock: %w", err) - } - defer func() { _ = flock.Unlock(f.Fd()) }() - - return os.WriteFile(path, data, 0644) -} - -// Find returns the opener with the given name, or nil. -func Find(openers []Opener, name string) *Opener { - for i := range openers { - if openers[i].Name == name { - return &openers[i] - } - } - return nil -} - -// FindDefault returns the opener marked as default, or nil if none. -func FindDefault(openers []Opener) *Opener { - for i := range openers { - if openers[i].Default { - return &openers[i] - } - } - return nil -} - -// SetDefault marks the named opener as default and clears the flag on all others. -func SetDefault(openers []Opener, name string) error { - found := false - for i := range openers { - if openers[i].Name == name { - openers[i].Default = true - found = true - } else { - openers[i].Default = false - } - } - if !found { - return fmt.Errorf("opener %q not found (see available: fr8 opener list)", name) - } - return nil -} - // Run resolves the opener's command to an executable and opens the workspace path. // The Command field may contain arguments (e.g. "code --new-window"). // Returns an error if the executable is not found in $PATH. -func Run(o Opener, workspacePath string) error { +func Run(o userconfig.Opener, workspacePath string) error { parts := strings.Fields(o.Command) if len(parts) == 0 { return fmt.Errorf("opener %q has an empty command", o.Name) diff --git a/internal/opener/opener_test.go b/internal/opener/opener_test.go index 8c0ea7d..d3622e3 100644 --- a/internal/opener/opener_test.go +++ b/internal/opener/opener_test.go @@ -1,159 +1,14 @@ package opener import ( - "os" - "path/filepath" "strings" "testing" -) - -func TestLoadNonExistent(t *testing.T) { - openers, err := Load("/nonexistent/path/openers.json") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if openers != nil { - t.Errorf("expected nil, got %v", openers) - } -} - -func TestSaveAndLoad(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "openers.json") - - openers := []Opener{ - {Name: "vscode", Command: "code"}, - {Name: "cursor", Command: "cursor"}, - } - - if err := Save(path, openers); err != nil { - t.Fatalf("Save: %v", err) - } - - loaded, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - - if len(loaded) != 2 { - t.Fatalf("loaded %d openers, want 2", len(loaded)) - } - if loaded[0].Name != "vscode" || loaded[0].Command != "code" { - t.Errorf("opener[0] = %+v, want vscode/code", loaded[0]) - } - if loaded[1].Name != "cursor" || loaded[1].Command != "cursor" { - t.Errorf("opener[1] = %+v, want cursor", loaded[1]) - } -} - -func TestSaveCreatesDirectories(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "nested", "deep", "openers.json") - - if err := Save(path, []Opener{{Name: "test", Command: "echo"}}); err != nil { - t.Fatalf("Save: %v", err) - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - t.Error("expected file to exist after Save") - } -} - -func TestLoadInvalidJSON(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "openers.json") - if err := os.WriteFile(path, []byte("not json"), 0644); err != nil { - t.Fatal(err) - } - - _, err := Load(path) - if err == nil { - t.Error("expected error for invalid JSON") - } -} - -func TestFind(t *testing.T) { - openers := []Opener{ - {Name: "vscode", Command: "code"}, - {Name: "cursor", Command: "cursor"}, - } - - if o := Find(openers, "vscode"); o == nil { - t.Error("expected to find vscode") - } else if o.Command != "code" { - t.Errorf("command = %q, want %q", o.Command, "code") - } - - if o := Find(openers, "cursor"); o == nil { - t.Error("expected to find cursor") - } - - if o := Find(openers, "missing"); o != nil { - t.Errorf("expected nil for missing opener, got %+v", o) - } - - if o := Find(nil, "vscode"); o != nil { - t.Errorf("expected nil for nil slice, got %+v", o) - } -} - -func TestFindDefault(t *testing.T) { - openers := []Opener{ - {Name: "vscode", Command: "code"}, - {Name: "cursor", Command: "cursor", Default: true}, - } - d := FindDefault(openers) - if d == nil { - t.Fatal("expected to find default") - } - if d.Name != "cursor" { - t.Errorf("default = %q, want cursor", d.Name) - } -} - -func TestFindDefaultNone(t *testing.T) { - openers := []Opener{ - {Name: "vscode", Command: "code"}, - {Name: "cursor", Command: "cursor"}, - } - - if d := FindDefault(openers); d != nil { - t.Errorf("expected nil, got %+v", d) - } -} - -func TestSetDefault(t *testing.T) { - openers := []Opener{ - {Name: "vscode", Command: "code", Default: true}, - {Name: "cursor", Command: "cursor"}, - } - - if err := SetDefault(openers, "cursor"); err != nil { - t.Fatal(err) - } - - if openers[0].Default { - t.Error("vscode should not be default") - } - if !openers[1].Default { - t.Error("cursor should be default") - } -} - -func TestSetDefaultNotFound(t *testing.T) { - openers := []Opener{ - {Name: "vscode", Command: "code"}, - } - - err := SetDefault(openers, "missing") - if err == nil { - t.Fatal("expected error for missing opener") - } -} + "github.com/protocollar/fr8/internal/userconfig" +) func TestRunMissingExecutable(t *testing.T) { - o := Opener{Name: "fake", Command: "fr8_nonexistent_binary_xyz"} + o := userconfig.Opener{Name: "fake", Command: "fr8_nonexistent_binary_xyz"} err := Run(o, "/tmp") if err == nil { t.Fatal("expected error for missing executable") @@ -163,51 +18,16 @@ func TestRunMissingExecutable(t *testing.T) { } } -func TestSaveAndLoadPreservesDefault(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "openers.json") - - openers := []Opener{ - {Name: "vscode", Command: "code"}, - {Name: "cursor", Command: "cursor", Default: true}, - } - - if err := Save(path, openers); err != nil { - t.Fatalf("Save: %v", err) - } - - loaded, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - - if len(loaded) != 2 { - t.Fatalf("loaded %d openers, want 2", len(loaded)) - } - if loaded[0].Default { - t.Error("vscode should not be default after roundtrip") - } - if !loaded[1].Default { - t.Error("cursor should be default after roundtrip") - } -} - func TestRunMultiWordCommand(t *testing.T) { - // Use "echo" which exists everywhere — the multi-word command should - // split correctly and pass extra args before the workspace path. - o := Opener{Name: "echo-test", Command: "echo --flag extra"} + o := userconfig.Opener{Name: "echo-test", Command: "echo --flag extra"} err := Run(o, "/tmp/workspace") if err != nil { t.Fatalf("Run with multi-word command: %v", err) } - // echo starts and exits immediately — no cleanup needed. - // The key assertion is that Run() didn't error, meaning it correctly - // split "echo --flag extra" into ["echo", "--flag", "extra"] and appended - // the workspace path. } func TestRunEmptyCommand(t *testing.T) { - o := Opener{Name: "empty", Command: ""} + o := userconfig.Opener{Name: "empty", Command: ""} err := Run(o, "/tmp") if err == nil { t.Fatal("expected error for empty command") @@ -216,20 +36,3 @@ func TestRunEmptyCommand(t *testing.T) { t.Errorf("error = %q, want it to mention 'empty command'", err.Error()) } } - -func TestSaveEmptySlice(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "openers.json") - - if err := Save(path, []Opener{}); err != nil { - t.Fatalf("Save: %v", err) - } - - loaded, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if len(loaded) != 0 { - t.Errorf("expected empty slice, got %d items", len(loaded)) - } -} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 2bd1e0f..28a7a0e 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -5,14 +5,25 @@ import ( "fmt" "os" "path/filepath" + "strings" + "time" "github.com/protocollar/fr8/internal/flock" ) +// Workspace represents a single managed worktree within a repo. +type Workspace struct { + Name string `json:"name"` + Path string `json:"path"` + Port int `json:"port"` + CreatedAt time.Time `json:"created_at"` +} + // Repo is a registered repository. type Repo struct { - Name string `json:"name"` - Path string `json:"path"` + Name string `json:"name"` + Path string `json:"path"` + Workspaces []Workspace `json:"workspaces,omitempty"` } // Registry holds all registered repositories. @@ -20,13 +31,30 @@ type Registry struct { Repos []Repo `json:"repos"` } -// DefaultPath returns the default registry file path (~/.config/fr8/repos.json). +// DefaultPath returns the path to the unified state file (~/.local/state/fr8/repos.json). +// Respects FR8_STATE_DIR to override the directory. func DefaultPath() (string, error) { + if dir := os.Getenv("FR8_STATE_DIR"); dir != "" { + return filepath.Join(dir, "repos.json"), nil + } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("finding home directory: %w", err) } - return filepath.Join(home, ".config", "fr8", "repos.json"), nil + return filepath.Join(home, ".local", "state", "fr8", "repos.json"), nil +} + +// ConfigDir returns the fr8 config directory (~/.config/fr8). +// Respects FR8_CONFIG_DIR to override the directory. +func ConfigDir() (string, error) { + if dir := os.Getenv("FR8_CONFIG_DIR"); dir != "" { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("finding home directory: %w", err) + } + return filepath.Join(home, ".config", "fr8"), nil } // Load reads the registry from path. Returns an empty registry if the file doesn't exist. @@ -124,3 +152,149 @@ func (r *Registry) Names() []string { } return names } + +// --- Workspace methods on Repo --- + +// FindWorkspace returns the workspace with the given name, or nil. +func (r *Repo) FindWorkspace(name string) *Workspace { + for i := range r.Workspaces { + if r.Workspaces[i].Name == name { + return &r.Workspaces[i] + } + } + return nil +} + +// FindWorkspaceByPath returns the workspace whose path matches or contains dir. +func (r *Repo) FindWorkspaceByPath(dir string) *Workspace { + dir = filepath.Clean(dir) + for i := range r.Workspaces { + wsPath := filepath.Clean(r.Workspaces[i].Path) + if dir == wsPath || strings.HasPrefix(dir, wsPath+string(filepath.Separator)) { + return &r.Workspaces[i] + } + } + return nil +} + +// AddWorkspace appends a workspace. Returns an error if the name already exists. +func (r *Repo) AddWorkspace(ws Workspace) error { + if r.FindWorkspace(ws.Name) != nil { + return fmt.Errorf("workspace %q already exists", ws.Name) + } + r.Workspaces = append(r.Workspaces, ws) + return nil +} + +// RemoveWorkspace deletes a workspace by name. +func (r *Repo) RemoveWorkspace(name string) error { + for i, ws := range r.Workspaces { + if ws.Name == name { + r.Workspaces = append(r.Workspaces[:i], r.Workspaces[i+1:]...) + return nil + } + } + return fmt.Errorf("workspace %q not found (see available: fr8 ws list)", name) +} + +// RenameWorkspace changes a workspace's name. Returns an error if old doesn't exist or new already does. +func (r *Repo) RenameWorkspace(oldName, newName string) error { + if oldName == newName { + return fmt.Errorf("old and new names are the same") + } + if r.FindWorkspace(newName) != nil { + return fmt.Errorf("workspace %q already exists", newName) + } + ws := r.FindWorkspace(oldName) + if ws == nil { + return fmt.Errorf("workspace %q not found (see available: fr8 ws list)", oldName) + } + ws.Name = newName + return nil +} + +// WorkspaceNames returns all workspace names in this repo. +func (r *Repo) WorkspaceNames() []string { + names := make([]string, len(r.Workspaces)) + for i, ws := range r.Workspaces { + names[i] = ws.Name + } + return names +} + +// AllocatedPorts returns all ports currently allocated in this repo. +func (r *Repo) AllocatedPorts() []int { + ports := make([]int, len(r.Workspaces)) + for i, ws := range r.Workspaces { + ports[i] = ws.Port + } + return ports +} + +// --- Global workspace methods on Registry --- + +// AllAllocatedPorts returns every allocated port across all repos. +func (r *Registry) AllAllocatedPorts() []int { + var ports []int + for _, repo := range r.Repos { + ports = append(ports, repo.AllocatedPorts()...) + } + return ports +} + +// AllWorkspaceNames returns every workspace name across all repos. +func (r *Registry) AllWorkspaceNames() []string { + var names []string + for _, repo := range r.Repos { + names = append(names, repo.WorkspaceNames()...) + } + return names +} + +// globalMatch holds a workspace match found during global resolution. +type globalMatch struct { + Workspace *Workspace + Repo *Repo +} + +// FindWorkspaceGlobal searches all repos for a workspace by name. +// Returns an error if more than one repo contains a matching workspace. +func (r *Registry) FindWorkspaceGlobal(name string) (*Workspace, *Repo, error) { + var matches []globalMatch + for i := range r.Repos { + ws := r.Repos[i].FindWorkspace(name) + if ws != nil { + matches = append(matches, globalMatch{ + Workspace: ws, + Repo: &r.Repos[i], + }) + } + } + + switch len(matches) { + case 0: + return nil, nil, fmt.Errorf("workspace %q not found in any registered repo (see repos: fr8 repo list)", name) + case 1: + return matches[0].Workspace, matches[0].Repo, nil + default: + var repoNames []string + for _, m := range matches { + repoNames = append(repoNames, m.Repo.Name) + } + return nil, nil, fmt.Errorf( + "workspace %q found in multiple repos: %s\nUse --repo to disambiguate: fr8 ws --repo %s", + name, strings.Join(repoNames, ", "), name, + ) + } +} + +// FindRepoByWorkspacePath walks all repos and returns the one containing a +// workspace whose path matches or contains dir. +func (r *Registry) FindRepoByWorkspacePath(dir string) *Repo { + for i := range r.Repos { + if r.Repos[i].FindWorkspaceByPath(dir) != nil { + return &r.Repos[i] + } + } + return nil +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 46f5ad4..711ae2d 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func TestLoadMissingFile(t *testing.T) { @@ -155,15 +156,295 @@ func TestNames(t *testing.T) { } } +// --- DefaultPath and ConfigDir tests --- + func TestDefaultPath(t *testing.T) { path, err := DefaultPath() if err != nil { t.Fatalf("DefaultPath: %v", err) } - home, _ := os.UserHomeDir() - expected := filepath.Join(home, ".config", "fr8", "repos.json") + expected := filepath.Join(home, ".local", "state", "fr8", "repos.json") if path != expected { t.Errorf("expected %s, got %s", expected, path) } } + +func TestDefaultPathEnvOverride(t *testing.T) { + t.Setenv("FR8_STATE_DIR", "/tmp/custom-state") + path, err := DefaultPath() + if err != nil { + t.Fatalf("DefaultPath: %v", err) + } + expected := "/tmp/custom-state/repos.json" + if path != expected { + t.Errorf("expected %s, got %s", expected, path) + } +} + +func TestConfigDir(t *testing.T) { + dir, err := ConfigDir() + if err != nil { + t.Fatalf("ConfigDir: %v", err) + } + home, _ := os.UserHomeDir() + expected := filepath.Join(home, ".config", "fr8") + if dir != expected { + t.Errorf("expected %s, got %s", expected, dir) + } +} + +func TestConfigDirEnvOverride(t *testing.T) { + t.Setenv("FR8_CONFIG_DIR", "/tmp/custom-config") + dir, err := ConfigDir() + if err != nil { + t.Fatalf("ConfigDir: %v", err) + } + if dir != "/tmp/custom-config" { + t.Errorf("expected /tmp/custom-config, got %s", dir) + } +} + +// --- Workspace CRUD tests --- + +func TestWorkspaceCRUD(t *testing.T) { + repo := &Repo{Name: "myapp", Path: "/home/user/myapp"} + + ws := Workspace{Name: "feature-1", Path: "/tmp/ws/feature-1", Port: 5000, CreatedAt: time.Now()} + if err := repo.AddWorkspace(ws); err != nil { + t.Fatalf("AddWorkspace: %v", err) + } + + found := repo.FindWorkspace("feature-1") + if found == nil { + t.Fatal("expected to find workspace") + } + if found.Port != 5000 { + t.Errorf("Port = %d, want 5000", found.Port) + } + + // Duplicate add + if err := repo.AddWorkspace(ws); err == nil { + t.Fatal("expected error for duplicate workspace name") + } + + // Remove + if err := repo.RemoveWorkspace("feature-1"); err != nil { + t.Fatalf("RemoveWorkspace: %v", err) + } + if repo.FindWorkspace("feature-1") != nil { + t.Error("expected workspace to be removed") + } + + // Remove nonexistent + if err := repo.RemoveWorkspace("nonexistent"); err == nil { + t.Error("expected error for nonexistent workspace") + } +} + +func TestWorkspaceRename(t *testing.T) { + repo := &Repo{Name: "myapp", Path: "/home/user/myapp"} + if err := repo.AddWorkspace(Workspace{Name: "alpha"}); err != nil { + t.Fatal(err) + } + if err := repo.AddWorkspace(Workspace{Name: "beta"}); err != nil { + t.Fatal(err) + } + + if err := repo.RenameWorkspace("alpha", "gamma"); err != nil { + t.Fatal(err) + } + if repo.FindWorkspace("alpha") != nil { + t.Error("expected alpha to be gone") + } + if repo.FindWorkspace("gamma") == nil { + t.Error("expected gamma to exist") + } + + // Same name + if err := repo.RenameWorkspace("gamma", "gamma"); err == nil { + t.Error("expected error for same name") + } + // Duplicate + if err := repo.RenameWorkspace("gamma", "beta"); err == nil { + t.Error("expected error for duplicate name") + } + // Not found + if err := repo.RenameWorkspace("nonexistent", "foo"); err == nil { + t.Error("expected error for nonexistent workspace") + } +} + +func TestWorkspaceNames(t *testing.T) { + repo := &Repo{Name: "myapp", Path: "/home/user/myapp"} + if err := repo.AddWorkspace(Workspace{Name: "alpha"}); err != nil { + t.Fatal(err) + } + if err := repo.AddWorkspace(Workspace{Name: "beta"}); err != nil { + t.Fatal(err) + } + + names := repo.WorkspaceNames() + if len(names) != 2 || names[0] != "alpha" || names[1] != "beta" { + t.Errorf("WorkspaceNames = %v, want [alpha beta]", names) + } +} + +func TestAllocatedPorts(t *testing.T) { + repo := &Repo{Name: "myapp", Path: "/home/user/myapp"} + if err := repo.AddWorkspace(Workspace{Name: "a", Port: 5000}); err != nil { + t.Fatal(err) + } + if err := repo.AddWorkspace(Workspace{Name: "b", Port: 5010}); err != nil { + t.Fatal(err) + } + + ports := repo.AllocatedPorts() + if len(ports) != 2 || ports[0] != 5000 || ports[1] != 5010 { + t.Errorf("AllocatedPorts = %v, want [5000 5010]", ports) + } +} + +func TestFindWorkspaceByPath(t *testing.T) { + repo := &Repo{Name: "myapp", Path: "/home/user/myapp"} + if err := repo.AddWorkspace(Workspace{Name: "ws1", Path: "/tmp/workspaces/ws1"}); err != nil { + t.Fatal(err) + } + + // Exact match + if ws := repo.FindWorkspaceByPath("/tmp/workspaces/ws1"); ws == nil || ws.Name != "ws1" { + t.Error("expected to find ws1 by exact path") + } + // Subdirectory + if ws := repo.FindWorkspaceByPath("/tmp/workspaces/ws1/app/models"); ws == nil || ws.Name != "ws1" { + t.Error("expected to find ws1 by subdirectory") + } + // No match + if repo.FindWorkspaceByPath("/tmp/other") != nil { + t.Error("expected nil for non-matching path") + } + // False positive (ws10 should not match ws1) + if repo.FindWorkspaceByPath("/tmp/workspaces/ws10") != nil { + t.Error("expected nil — ws10 should not match ws1 prefix") + } +} + +func TestAllAllocatedPorts(t *testing.T) { + r := &Registry{ + Repos: []Repo{ + {Name: "a", Path: "/a", Workspaces: []Workspace{{Name: "w1", Port: 5000}, {Name: "w2", Port: 5010}}}, + {Name: "b", Path: "/b", Workspaces: []Workspace{{Name: "w3", Port: 6000}}}, + }, + } + ports := r.AllAllocatedPorts() + if len(ports) != 3 { + t.Fatalf("expected 3 ports, got %d", len(ports)) + } +} + +func TestAllWorkspaceNames(t *testing.T) { + r := &Registry{ + Repos: []Repo{ + {Name: "a", Path: "/a", Workspaces: []Workspace{{Name: "w1"}, {Name: "w2"}}}, + {Name: "b", Path: "/b", Workspaces: []Workspace{{Name: "w3"}}}, + }, + } + names := r.AllWorkspaceNames() + if len(names) != 3 { + t.Fatalf("expected 3 names, got %d", len(names)) + } +} + +func TestFindWorkspaceGlobal(t *testing.T) { + r := &Registry{ + Repos: []Repo{ + {Name: "a", Path: "/a", Workspaces: []Workspace{{Name: "unique"}}}, + {Name: "b", Path: "/b", Workspaces: []Workspace{{Name: "other"}}}, + }, + } + + ws, repo, err := r.FindWorkspaceGlobal("unique") + if err != nil { + t.Fatal(err) + } + if ws.Name != "unique" || repo.Name != "a" { + t.Errorf("got ws=%q repo=%q, want unique/a", ws.Name, repo.Name) + } + + // Not found + _, _, err = r.FindWorkspaceGlobal("nonexistent") + if err == nil { + t.Error("expected error for nonexistent workspace") + } +} + +func TestFindWorkspaceGlobalAmbiguous(t *testing.T) { + r := &Registry{ + Repos: []Repo{ + {Name: "a", Path: "/a", Workspaces: []Workspace{{Name: "shared"}}}, + {Name: "b", Path: "/b", Workspaces: []Workspace{{Name: "shared"}}}, + }, + } + + _, _, err := r.FindWorkspaceGlobal("shared") + if err == nil { + t.Fatal("expected error for ambiguous workspace") + } +} + +func TestFindRepoByWorkspacePath(t *testing.T) { + r := &Registry{ + Repos: []Repo{ + {Name: "a", Path: "/a", Workspaces: []Workspace{{Name: "w1", Path: "/tmp/ws/w1"}}}, + {Name: "b", Path: "/b", Workspaces: []Workspace{{Name: "w2", Path: "/tmp/ws/w2"}}}, + }, + } + + repo := r.FindRepoByWorkspacePath("/tmp/ws/w1/app") + if repo == nil || repo.Name != "a" { + t.Error("expected to find repo a") + } + + if r.FindRepoByWorkspacePath("/tmp/other") != nil { + t.Error("expected nil for non-matching path") + } +} + +func TestSaveAndLoadWithWorkspaces(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "repos.json") + + now := time.Date(2026, 2, 14, 12, 0, 0, 0, time.UTC) + r := &Registry{ + Repos: []Repo{ + { + Name: "myapp", + Path: "/home/user/myapp", + Workspaces: []Workspace{ + {Name: "ws1", Path: "/tmp/ws1", Port: 5000, CreatedAt: now}, + }, + }, + }, + } + + if err := r.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if len(loaded.Repos) != 1 { + t.Fatalf("expected 1 repo, got %d", len(loaded.Repos)) + } + repo := loaded.Repos[0] + if len(repo.Workspaces) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(repo.Workspaces)) + } + ws := repo.Workspaces[0] + if ws.Name != "ws1" || ws.Port != 5000 { + t.Errorf("workspace = %+v, want ws1/5000", ws) + } +} diff --git a/internal/state/state.go b/internal/state/state.go deleted file mode 100644 index 0210c87..0000000 --- a/internal/state/state.go +++ /dev/null @@ -1,150 +0,0 @@ -package state - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/protocollar/fr8/internal/flock" -) - -const stateFile = "fr8.json" - -// State holds all tracked workspaces for a repository. -type State struct { - Workspaces []Workspace `json:"workspaces"` -} - -// Workspace represents a single managed worktree. -type Workspace struct { - Name string `json:"name"` - Path string `json:"path"` - Port int `json:"port"` - CreatedAt time.Time `json:"created_at"` -} - -// Load reads the state file from the git common directory. -// Returns an empty state if the file doesn't exist. -func Load(gitCommonDir string) (*State, error) { - p := statePath(gitCommonDir) - data, err := os.ReadFile(p) - if err != nil { - if os.IsNotExist(err) { - return &State{}, nil - } - return nil, fmt.Errorf("reading state: %w", err) - } - var s State - if err := json.Unmarshal(data, &s); err != nil { - return nil, fmt.Errorf("parsing state: %w", err) - } - return &s, nil -} - -// Save writes the state file to the git common directory. -// Uses advisory file locking to prevent concurrent modifications. -func (s *State) Save(gitCommonDir string) error { - p := statePath(gitCommonDir) - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return fmt.Errorf("marshaling state: %w", err) - } - data = append(data, '\n') - - f, err := os.OpenFile(p+".lock", os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - return fmt.Errorf("creating lock file: %w", err) - } - defer func() { _ = f.Close() }() - defer func() { _ = os.Remove(p + ".lock") }() - - if err := flock.Lock(f.Fd()); err != nil { - return fmt.Errorf("acquiring lock: %w", err) - } - defer func() { _ = flock.Unlock(f.Fd()) }() - - return os.WriteFile(p, data, 0644) -} - -// Add appends a workspace to the state. Returns an error if the name already exists. -func (s *State) Add(w Workspace) error { - if s.Find(w.Name) != nil { - return fmt.Errorf("workspace %q already exists", w.Name) - } - s.Workspaces = append(s.Workspaces, w) - return nil -} - -// Remove deletes a workspace by name. -func (s *State) Remove(name string) error { - for i, w := range s.Workspaces { - if w.Name == name { - s.Workspaces = append(s.Workspaces[:i], s.Workspaces[i+1:]...) - return nil - } - } - return fmt.Errorf("workspace %q not found (see available: fr8 ws list)", name) -} - -// Find returns the workspace with the given name, or nil. -func (s *State) Find(name string) *Workspace { - for i := range s.Workspaces { - if s.Workspaces[i].Name == name { - return &s.Workspaces[i] - } - } - return nil -} - -// FindByPath returns the workspace whose path contains the given directory. -func (s *State) FindByPath(dir string) *Workspace { - dir = filepath.Clean(dir) - for i := range s.Workspaces { - wsPath := filepath.Clean(s.Workspaces[i].Path) - if dir == wsPath || strings.HasPrefix(dir, wsPath+string(filepath.Separator)) { - return &s.Workspaces[i] - } - } - return nil -} - -// AllocatedPorts returns all ports currently allocated. -func (s *State) AllocatedPorts() []int { - ports := make([]int, len(s.Workspaces)) - for i, w := range s.Workspaces { - ports[i] = w.Port - } - return ports -} - -// Names returns all workspace names. -func (s *State) Names() []string { - names := make([]string, len(s.Workspaces)) - for i, w := range s.Workspaces { - names[i] = w.Name - } - return names -} - -// Rename changes a workspace's name. Returns an error if old doesn't exist or new already does. -func (s *State) Rename(oldName, newName string) error { - if oldName == newName { - return fmt.Errorf("old and new names are the same") - } - if s.Find(newName) != nil { - return fmt.Errorf("workspace %q already exists", newName) - } - ws := s.Find(oldName) - if ws == nil { - return fmt.Errorf("workspace %q not found (see available: fr8 ws list)", oldName) - } - ws.Name = newName - return nil -} - -func statePath(gitCommonDir string) string { - return filepath.Join(gitCommonDir, stateFile) -} diff --git a/internal/state/state_test.go b/internal/state/state_test.go deleted file mode 100644 index e3fc89c..0000000 --- a/internal/state/state_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package state - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestAddAndFind(t *testing.T) { - s := &State{} - - ws := Workspace{Name: "test-ws", Path: "/tmp/ws", Port: 5000, CreatedAt: time.Now()} - if err := s.Add(ws); err != nil { - t.Fatal(err) - } - - found := s.Find("test-ws") - if found == nil { - t.Fatal("expected to find workspace") - } - if found.Port != 5000 { - t.Errorf("Port = %d, want 5000", found.Port) - } -} - -func TestAddDuplicate(t *testing.T) { - s := &State{} - ws := Workspace{Name: "test-ws"} - if err := s.Add(ws); err != nil { - t.Fatal(err) - } - - err := s.Add(ws) - if err == nil { - t.Fatal("expected error for duplicate name") - } -} - -func TestRemove(t *testing.T) { - s := &State{} - if err := s.Add(Workspace{Name: "a"}); err != nil { - t.Fatal(err) - } - if err := s.Add(Workspace{Name: "b"}); err != nil { - t.Fatal(err) - } - if err := s.Add(Workspace{Name: "c"}); err != nil { - t.Fatal(err) - } - - if err := s.Remove("b"); err != nil { - t.Fatal(err) - } - if len(s.Workspaces) != 2 { - t.Fatalf("expected 2 workspaces, got %d", len(s.Workspaces)) - } - if s.Find("b") != nil { - t.Error("expected b to be removed") - } - if s.Find("a") == nil || s.Find("c") == nil { - t.Error("expected a and c to remain") - } -} - -func TestRemoveNotFound(t *testing.T) { - s := &State{} - err := s.Remove("nonexistent") - if err == nil { - t.Fatal("expected error for nonexistent workspace") - } -} - -func TestFindNil(t *testing.T) { - s := &State{} - if s.Find("nope") != nil { - t.Error("expected nil for nonexistent workspace") - } -} - -func TestFindByPathExact(t *testing.T) { - s := &State{ - Workspaces: []Workspace{ - {Name: "ws1", Path: "/tmp/workspaces/ws1"}, - {Name: "ws2", Path: "/tmp/workspaces/ws2"}, - }, - } - - found := s.FindByPath("/tmp/workspaces/ws2") - if found == nil || found.Name != "ws2" { - t.Errorf("FindByPath exact = %v, want ws2", found) - } -} - -func TestFindByPathSubdirectory(t *testing.T) { - s := &State{ - Workspaces: []Workspace{ - {Name: "ws1", Path: "/tmp/workspaces/ws1"}, - }, - } - - found := s.FindByPath("/tmp/workspaces/ws1/app/models") - if found == nil || found.Name != "ws1" { - t.Errorf("FindByPath subdirectory = %v, want ws1", found) - } -} - -func TestFindByPathNoMatch(t *testing.T) { - s := &State{ - Workspaces: []Workspace{ - {Name: "ws1", Path: "/tmp/workspaces/ws1"}, - }, - } - - if s.FindByPath("/tmp/other") != nil { - t.Error("expected nil for non-matching path") - } -} - -func TestFindByPathPrefixFalsePositive(t *testing.T) { - s := &State{ - Workspaces: []Workspace{ - {Name: "ws1", Path: "/tmp/workspaces/ws1"}, - }, - } - - // /tmp/workspaces/ws10 should NOT match /tmp/workspaces/ws1 - if s.FindByPath("/tmp/workspaces/ws10") != nil { - t.Error("expected nil — ws10 should not match ws1 prefix") - } -} - -func TestAllocatedPorts(t *testing.T) { - s := &State{ - Workspaces: []Workspace{ - {Name: "a", Port: 5000}, - {Name: "b", Port: 5010}, - }, - } - - ports := s.AllocatedPorts() - if len(ports) != 2 || ports[0] != 5000 || ports[1] != 5010 { - t.Errorf("AllocatedPorts = %v, want [5000 5010]", ports) - } -} - -func TestNames(t *testing.T) { - s := &State{ - Workspaces: []Workspace{ - {Name: "alpha"}, - {Name: "beta"}, - }, - } - - names := s.Names() - if len(names) != 2 || names[0] != "alpha" || names[1] != "beta" { - t.Errorf("Names = %v, want [alpha beta]", names) - } -} - -func TestRename(t *testing.T) { - s := &State{} - if err := s.Add(Workspace{Name: "alpha"}); err != nil { - t.Fatal(err) - } - if err := s.Add(Workspace{Name: "beta"}); err != nil { - t.Fatal(err) - } - - if err := s.Rename("alpha", "gamma"); err != nil { - t.Fatal(err) - } - if s.Find("alpha") != nil { - t.Error("expected alpha to be gone") - } - if s.Find("gamma") == nil { - t.Error("expected gamma to exist") - } - if s.Find("beta") == nil { - t.Error("expected beta to remain") - } -} - -func TestRenameNotFound(t *testing.T) { - s := &State{} - err := s.Rename("nonexistent", "new") - if err == nil { - t.Fatal("expected error for nonexistent workspace") - } -} - -func TestRenameAlreadyExists(t *testing.T) { - s := &State{} - if err := s.Add(Workspace{Name: "alpha"}); err != nil { - t.Fatal(err) - } - if err := s.Add(Workspace{Name: "beta"}); err != nil { - t.Fatal(err) - } - - err := s.Rename("alpha", "beta") - if err == nil { - t.Fatal("expected error for duplicate name") - } -} - -func TestRenameSameName(t *testing.T) { - s := &State{} - if err := s.Add(Workspace{Name: "alpha"}); err != nil { - t.Fatal(err) - } - - err := s.Rename("alpha", "alpha") - if err == nil { - t.Fatal("expected error for same name") - } -} - -func TestSaveAndLoad(t *testing.T) { - dir := t.TempDir() - now := time.Date(2026, 2, 11, 12, 0, 0, 0, time.UTC) - - original := &State{ - Workspaces: []Workspace{ - {Name: "ws1", Path: "/tmp/ws1", Port: 5000, CreatedAt: now}, - }, - } - - if err := original.Save(dir); err != nil { - t.Fatal(err) - } - - // Verify file exists - if _, err := os.Stat(filepath.Join(dir, "fr8.json")); err != nil { - t.Fatal("state file not created") - } - - loaded, err := Load(dir) - if err != nil { - t.Fatal(err) - } - - if len(loaded.Workspaces) != 1 { - t.Fatalf("expected 1 workspace, got %d", len(loaded.Workspaces)) - } - ws := loaded.Workspaces[0] - if ws.Name != "ws1" || ws.Port != 5000 { - t.Errorf("loaded workspace = %+v, want ws1/5000", ws) - } -} - -func TestLoadMissing(t *testing.T) { - s, err := Load(t.TempDir()) - if err != nil { - t.Fatal(err) - } - if len(s.Workspaces) != 0 { - t.Errorf("expected empty state, got %d workspaces", len(s.Workspaces)) - } -} - -func TestLoadMalformed(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "fr8.json"), []byte(`{broken`), 0644); err != nil { - t.Fatal(err) - } - - _, err := Load(dir) - if err == nil { - t.Fatal("expected error for malformed JSON") - } -} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 1d5a345..46dc118 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -4,19 +4,18 @@ import ( "fmt" tea "github.com/charmbracelet/bubbletea" - "github.com/protocollar/fr8/internal/state" + "github.com/protocollar/fr8/internal/registry" ) // DashboardResult holds the outcome of the TUI session. type DashboardResult struct { - ShellWorkspace *state.Workspace - AttachWorkspace *state.Workspace - OpenWorkspace *state.Workspace - OpenerName string - RootPath string - CreateRequested bool - CreateName string - CommonDir string + ShellWorkspace *registry.Workspace + AttachWorkspace *registry.Workspace + OpenWorkspace *registry.Workspace + OpenerName string + RootPath string + CreateRequested bool + CreateName string } // RunDashboard launches the interactive TUI and returns the result. @@ -47,7 +46,6 @@ func RunDashboard() (*DashboardResult, error) { result.CreateRequested = true result.CreateName = fm.createRequest.name result.RootPath = fm.createRequest.rootPath - result.CommonDir = fm.createRequest.commonDir } return result, nil } diff --git a/internal/tui/messages.go b/internal/tui/messages.go index df184e0..203963d 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -3,9 +3,8 @@ package tui import ( "github.com/protocollar/fr8/internal/gh" "github.com/protocollar/fr8/internal/git" - "github.com/protocollar/fr8/internal/opener" "github.com/protocollar/fr8/internal/registry" - "github.com/protocollar/fr8/internal/state" + "github.com/protocollar/fr8/internal/userconfig" ) type viewState int @@ -30,7 +29,7 @@ type repoItem struct { // workspaceItem is a workspace with live git status. type workspaceItem struct { - Workspace state.Workspace + Workspace registry.Workspace Branch string // live branch from git (not stored in state) DirtyCount git.DirtyCount // staged/modified/untracked counts Merged bool @@ -56,7 +55,6 @@ type workspacesLoadedMsg struct { workspaces []workspaceItem repoName string rootPath string - commonDir string defaultBranch string err error } @@ -67,17 +65,17 @@ type archiveResultMsg struct { } type shellRequestMsg struct { - workspace state.Workspace + workspace registry.Workspace rootPath string } type attachRequestMsg struct { - workspace state.Workspace + workspace registry.Workspace rootPath string } type openRequestMsg struct { - workspace state.Workspace + workspace registry.Workspace openerName string } @@ -109,7 +107,7 @@ type stopAllResultMsg struct { } type openersLoadedMsg struct { - openers []opener.Opener + openers []userconfig.Opener err error } @@ -122,5 +120,4 @@ type batchArchiveResultMsg struct { type createRequestMsg struct { name string rootPath string - commonDir string } diff --git a/internal/tui/model.go b/internal/tui/model.go index a59be8c..f80e09b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -15,11 +15,10 @@ import ( "github.com/protocollar/fr8/internal/env" "github.com/protocollar/fr8/internal/gh" "github.com/protocollar/fr8/internal/git" - "github.com/protocollar/fr8/internal/opener" "github.com/protocollar/fr8/internal/port" "github.com/protocollar/fr8/internal/registry" - "github.com/protocollar/fr8/internal/state" "github.com/protocollar/fr8/internal/tmux" + "github.com/protocollar/fr8/internal/userconfig" ) type model struct { @@ -32,7 +31,6 @@ type model struct { err error repoName string // current repo being viewed rootPath string // root worktree path for current repo - commonDir string // git common dir for current repo defaultBranch string // default branch for current repo shellRequest *shellRequestMsg attachRequest *attachRequestMsg @@ -40,7 +38,7 @@ type model struct { createRequest *createRequestMsg archiveIdx int // workspace index pending archive confirmation batchArchiveNames []string - openers []opener.Opener + openers []userconfig.Opener openerCursor int openerWsIdx int // workspace index for which opener picker was opened createInput textinput.Model @@ -104,7 +102,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.workspaces = msg.workspaces m.repoName = msg.repoName m.rootPath = msg.rootPath - m.commonDir = msg.commonDir m.defaultBranch = msg.defaultBranch m.cursor = 0 m.view = viewWorkspaceList @@ -252,7 +249,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } // Check for default opener - if d := opener.FindDefault(msg.openers); d != nil { + if d := findDefaultOpener(msg.openers); d != nil { ws := m.workspaces[m.openerWsIdx] m.openRequest = &openRequestMsg{ workspace: ws.Workspace, @@ -407,7 +404,7 @@ func (m model) handleWorkspaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.loading = true m.err = nil - return m, tea.Batch(startWorkspaceCmd(ws.Workspace, m.rootPath, m.commonDir), m.spinner.Tick) + return m, tea.Batch(startWorkspaceCmd(ws.Workspace, m.rootPath), m.spinner.Tick) } case key.Matches(msg, keys.Browser): if len(m.workspaces) > 0 { @@ -492,7 +489,7 @@ func (m model) handleConfirmBatchArchiveKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) case key.Matches(msg, keys.Yes): m.loading = true m.view = viewWorkspaceList - return m, tea.Batch(batchArchiveCmd(m.batchArchiveNames, m.rootPath, m.commonDir), m.spinner.Tick) + return m, tea.Batch(batchArchiveCmd(m.batchArchiveNames, m.rootPath), m.spinner.Tick) case key.Matches(msg, keys.No): m.batchArchiveNames = nil m.view = viewWorkspaceList @@ -509,9 +506,8 @@ func (m model) handleCreateWorkspaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: name := strings.TrimSpace(m.createInput.Value()) m.createRequest = &createRequestMsg{ - name: name, - rootPath: m.rootPath, - commonDir: m.commonDir, + name: name, + rootPath: m.rootPath, } return m, tea.Quit } @@ -527,7 +523,7 @@ func (m model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { ws := m.workspaces[m.archiveIdx] m.loading = true m.view = viewWorkspaceList - return m, tea.Batch(archiveWorkspaceCmd(ws.Workspace, m.rootPath, m.commonDir), m.spinner.Tick) + return m, tea.Batch(archiveWorkspaceCmd(ws.Workspace, m.rootPath), m.spinner.Tick) case key.Matches(msg, keys.No): m.view = viewWorkspaceList } @@ -574,18 +570,7 @@ func loadReposCmd() tea.Msg { items := make([]repoItem, len(reg.Repos)) for i, repo := range reg.Repos { - items[i] = repoItem{Repo: repo} - commonDir, err := git.CommonDir(repo.Path) - if err != nil { - items[i].Err = err - continue - } - st, err := state.Load(commonDir) - if err != nil { - items[i].Err = err - continue - } - items[i].WorkspaceCount = len(st.Workspaces) + items[i] = repoItem{Repo: repo, WorkspaceCount: len(repo.Workspaces)} } // Enrich with running counts from tmux sessions. @@ -605,21 +590,11 @@ func loadReposCmd() tea.Msg { func loadWorkspacesCmd(repo registry.Repo) tea.Cmd { return func() tea.Msg { - commonDir, err := git.CommonDir(repo.Path) - if err != nil { - return workspacesLoadedMsg{err: fmt.Errorf("reading git data for %s: %w", repo.Name, err)} - } - rootPath, err := git.RootWorktreePath(repo.Path) if err != nil { return workspacesLoadedMsg{err: fmt.Errorf("finding root worktree: %w", err)} } - st, err := state.Load(commonDir) - if err != nil { - return workspacesLoadedMsg{err: fmt.Errorf("loading state for %s: %w", repo.Name, err)} - } - defaultBranch, _ := git.DefaultBranch(rootPath) hasTmux := tmux.Available() == nil @@ -634,16 +609,16 @@ func loadWorkspacesCmd(repo registry.Repo) tea.Cmd { } } - items := make([]workspaceItem, len(st.Workspaces)) + items := make([]workspaceItem, len(repo.Workspaces)) // Fan out git enrichment per workspace in parallel type enrichResult struct { idx int item workspaceItem } - gitCh := make(chan enrichResult, len(st.Workspaces)) - for i, ws := range st.Workspaces { - go func(idx int, ws state.Workspace) { + gitCh := make(chan enrichResult, len(repo.Workspaces)) + for i, ws := range repo.Workspaces { + go func(idx int, ws registry.Workspace) { branch, _ := git.CurrentBranch(ws.Path) item := workspaceItem{Workspace: ws, Branch: branch} item.PortFree = port.IsFree(ws.Port) @@ -691,7 +666,7 @@ func loadWorkspacesCmd(repo registry.Repo) tea.Cmd { gitCh <- enrichResult{idx: idx, item: item} }(i, ws) } - for range st.Workspaces { + for range repo.Workspaces { res := <-gitCh items[res.idx] = res.item } @@ -704,7 +679,7 @@ func loadWorkspacesCmd(repo registry.Repo) tea.Cmd { } ch := make(chan prResult, len(items)) for i, item := range items { - go func(idx int, branch string, ws state.Workspace) { + go func(idx int, branch string, ws registry.Workspace) { pr, _ := gh.PRStatus(ws.Path, branch) ch <- prResult{idx: idx, pr: pr} }(i, item.Branch, item.Workspace) @@ -719,13 +694,12 @@ func loadWorkspacesCmd(repo registry.Repo) tea.Cmd { workspaces: items, repoName: repo.Name, rootPath: rootPath, - commonDir: commonDir, defaultBranch: defaultBranch, } } } -func startWorkspaceCmd(ws state.Workspace, rootPath, commonDir string) tea.Cmd { +func startWorkspaceCmd(ws registry.Workspace, rootPath string) tea.Cmd { return func() tea.Msg { if err := tmux.Available(); err != nil { return startResultMsg{name: ws.Name, err: err} @@ -753,7 +727,7 @@ func startWorkspaceCmd(ws state.Workspace, rootPath, commonDir string) tea.Cmd { } } -func stopWorkspaceCmd(ws state.Workspace, rootPath string) tea.Cmd { +func stopWorkspaceCmd(ws registry.Workspace, rootPath string) tea.Cmd { return func() tea.Msg { if err := tmux.Available(); err != nil { return stopResultMsg{name: ws.Name, err: err} @@ -769,7 +743,7 @@ func stopWorkspaceCmd(ws state.Workspace, rootPath string) tea.Cmd { } } -func openBrowserCmd(ws state.Workspace) tea.Cmd { +func openBrowserCmd(ws registry.Workspace) tea.Cmd { return func() tea.Msg { url := fmt.Sprintf("http://localhost:%d", ws.Port) err := openURL(url) @@ -799,21 +773,11 @@ func runAllCmd(item repoItem) tea.Cmd { return runAllResultMsg{repoName: repo.Name, err: err} } - commonDir, err := git.CommonDir(repo.Path) - if err != nil { - return runAllResultMsg{repoName: repo.Name, err: err} - } - rootPath, err := git.RootWorktreePath(repo.Path) if err != nil { return runAllResultMsg{repoName: repo.Name, err: err} } - st, err := state.Load(commonDir) - if err != nil { - return runAllResultMsg{repoName: repo.Name, err: err} - } - cfg, err := config.Load(rootPath) if err != nil { return runAllResultMsg{repoName: repo.Name, err: err} @@ -833,7 +797,7 @@ func runAllCmd(item repoItem) tea.Cmd { } var started int - for _, ws := range st.Workspaces { + for _, ws := range repo.Workspaces { sessionName := tmux.SessionName(repoName, ws.Name) if runningSessions[sessionName] { continue @@ -905,21 +869,11 @@ func runAllGlobalCmd(items []repoItem) tea.Cmd { } repo := item.Repo - commonDir, err := git.CommonDir(repo.Path) - if err != nil { - continue - } - rootPath, err := git.RootWorktreePath(repo.Path) if err != nil { continue } - st, err := state.Load(commonDir) - if err != nil { - continue - } - cfg, err := config.Load(rootPath) if err != nil || cfg.Scripts.Run == "" { continue @@ -928,7 +882,7 @@ func runAllGlobalCmd(items []repoItem) tea.Cmd { defaultBranch, _ := git.DefaultBranch(rootPath) repoName := tmux.RepoName(rootPath) - for _, ws := range st.Workspaces { + for _, ws := range repo.Workspaces { sessionName := tmux.SessionName(repoName, ws.Name) if runningSessions[sessionName] { continue @@ -991,16 +945,26 @@ func stopAllGlobalCmd() tea.Cmd { func loadOpenersCmd() tea.Cmd { return func() tea.Msg { - path, err := opener.DefaultPath() + path, err := userconfig.DefaultPath() if err != nil { return openersLoadedMsg{err: err} } - openers, err := opener.Load(path) + cfg, err := userconfig.Load(path) if err != nil { return openersLoadedMsg{err: err} } - return openersLoadedMsg{openers: openers} + return openersLoadedMsg{openers: cfg.Openers} + } +} + +// findDefaultOpener returns the opener marked as default, or nil if none. +func findDefaultOpener(openers []userconfig.Opener) *userconfig.Opener { + for i := range openers { + if openers[i].Default { + return &openers[i] + } } + return nil } // refreshRunningCounts re-derives RunningCount on all repos from tmux sessions. @@ -1021,11 +985,19 @@ func refreshRunningCounts(repos []repoItem) { } } -func batchArchiveCmd(names []string, rootPath, commonDir string) tea.Cmd { +func batchArchiveCmd(names []string, rootPath string) tea.Cmd { return func() tea.Msg { - st, err := state.Load(commonDir) + regPath, err := registry.DefaultPath() + if err != nil { + return batchArchiveResultMsg{err: fmt.Errorf("finding state path: %w", err)} + } + reg, err := registry.Load(regPath) if err != nil { - return batchArchiveResultMsg{err: fmt.Errorf("loading state: %w", err)} + return batchArchiveResultMsg{err: fmt.Errorf("loading registry: %w", err)} + } + repo := reg.FindByPath(rootPath) + if repo == nil { + return batchArchiveResultMsg{err: fmt.Errorf("repo not found for path %s", rootPath)} } cfg, err := config.Load(rootPath) @@ -1038,7 +1010,7 @@ func batchArchiveCmd(names []string, rootPath, commonDir string) tea.Cmd { var archived, failed []string for _, name := range names { - ws := st.Find(name) + ws := repo.FindWorkspace(name) if ws == nil { failed = append(failed, name) continue @@ -1074,11 +1046,11 @@ func batchArchiveCmd(names []string, rootPath, commonDir string) tea.Cmd { archived = append(archived, name) } - // Batch state update + // Batch registry update for _, name := range archived { - _ = st.Remove(name) + _ = repo.RemoveWorkspace(name) } - if err := st.Save(commonDir); err != nil { + if err := reg.Save(regPath); err != nil { return batchArchiveResultMsg{err: fmt.Errorf("saving state: %w", err)} } @@ -1086,7 +1058,7 @@ func batchArchiveCmd(names []string, rootPath, commonDir string) tea.Cmd { } } -func archiveWorkspaceCmd(ws state.Workspace, rootPath, commonDir string) tea.Cmd { +func archiveWorkspaceCmd(ws registry.Workspace, rootPath string) tea.Cmd { return func() tea.Msg { // Auto-stop tmux session before archiving if tmux.Available() == nil { @@ -1123,14 +1095,21 @@ func archiveWorkspaceCmd(ws state.Workspace, rootPath, commonDir string) tea.Cmd return archiveResultMsg{name: ws.Name, err: fmt.Errorf("removing worktree: %w", err)} } - // Update state - st, err := state.Load(commonDir) + // Update registry + regPath, err := registry.DefaultPath() + if err != nil { + return archiveResultMsg{name: ws.Name, err: fmt.Errorf("finding state path: %w", err)} + } + reg, err := registry.Load(regPath) if err != nil { - return archiveResultMsg{name: ws.Name, err: fmt.Errorf("loading state: %w", err)} + return archiveResultMsg{name: ws.Name, err: fmt.Errorf("loading registry: %w", err)} } - _ = st.Remove(ws.Name) - if err := st.Save(commonDir); err != nil { - return archiveResultMsg{name: ws.Name, err: fmt.Errorf("saving state: %w", err)} + repo := reg.FindByPath(rootPath) + if repo != nil { + _ = repo.RemoveWorkspace(ws.Name) + if err := reg.Save(regPath); err != nil { + return archiveResultMsg{name: ws.Name, err: fmt.Errorf("saving state: %w", err)} + } } return archiveResultMsg{name: ws.Name} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 822e752..b795a7d 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -6,9 +6,8 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/protocollar/fr8/internal/git" - "github.com/protocollar/fr8/internal/opener" "github.com/protocollar/fr8/internal/registry" - "github.com/protocollar/fr8/internal/state" + "github.com/protocollar/fr8/internal/userconfig" ) // Key helpers for constructing tea.KeyMsg values. @@ -33,11 +32,10 @@ func seedWorkspaceModel() model { view: viewWorkspaceList, repoName: "alpha", rootPath: "/a", - commonDir: "/a/.git", workspaces: []workspaceItem{ - {Workspace: state.Workspace{Name: "ws-one", Port: 3000}, Branch: "feat-1"}, - {Workspace: state.Workspace{Name: "ws-two", Port: 3010}, Branch: "feat-2"}, - {Workspace: state.Workspace{Name: "ws-three", Port: 3020}, Branch: "feat-3"}, + {Workspace: registry.Workspace{Name: "ws-one", Port: 3000}, Branch: "feat-1"}, + {Workspace: registry.Workspace{Name: "ws-two", Port: 3010}, Branch: "feat-2"}, + {Workspace: registry.Workspace{Name: "ws-three", Port: 3020}, Branch: "feat-3"}, }, cursor: 0, } @@ -329,14 +327,13 @@ func TestWorkspacesLoadedMsg(t *testing.T) { m := model{view: viewRepoList, loading: true} workspaces := []workspaceItem{ - {Workspace: state.Workspace{Name: "ws1", Port: 3000}, Branch: "feat"}, + {Workspace: registry.Workspace{Name: "ws1", Port: 3000}, Branch: "feat"}, } m = updateModel(m, workspacesLoadedMsg{ workspaces: workspaces, repoName: "myrepo", rootPath: "/myrepo", - commonDir: "/myrepo/.git", }) if m.view != viewWorkspaceList { @@ -369,16 +366,15 @@ func TestArchiveResultClearsLoading(t *testing.T) { func TestArchiveLastWorkspaceClearsLoading(t *testing.T) { m := model{ - view: viewWorkspaceList, - loading: true, - repoName: "alpha", - rootPath: "/a", - commonDir: "/a/.git", + view: viewWorkspaceList, + loading: true, + repoName: "alpha", + rootPath: "/a", repos: []repoItem{ {Repo: registry.Repo{Name: "alpha", Path: "/a"}, WorkspaceCount: 1}, }, workspaces: []workspaceItem{ - {Workspace: state.Workspace{Name: "only-ws", Port: 3000}, Branch: "feat-1"}, + {Workspace: registry.Workspace{Name: "only-ws", Port: 3000}, Branch: "feat-1"}, }, cursor: 0, } @@ -748,7 +744,7 @@ func TestOpenersLoadedSingleOpenerQuitsDirectly(t *testing.T) { m.openerWsIdx = 1 result, cmd := m.Update(openersLoadedMsg{ - openers: []opener.Opener{{Name: "vscode", Command: "code"}}, + openers: []userconfig.Opener{{Name: "vscode", Command: "code"}}, }) m = result.(model) @@ -776,7 +772,7 @@ func TestOpenersLoadedMultipleShowsPicker(t *testing.T) { m.openerWsIdx = 0 m = updateModel(m, openersLoadedMsg{ - openers: []opener.Opener{ + openers: []userconfig.Opener{ {Name: "vscode", Command: "code"}, {Name: "cursor", Command: "cursor"}, }, @@ -831,7 +827,7 @@ func TestOpenerPickerNavigation(t *testing.T) { m := seedWorkspaceModel() m.view = viewOpenerPicker m.openerWsIdx = 0 - m.openers = []opener.Opener{ + m.openers = []userconfig.Opener{ {Name: "vscode", Command: "code"}, {Name: "cursor", Command: "cursor"}, {Name: "terminal", Command: "open"}, @@ -866,7 +862,7 @@ func TestOpenerPickerSelectQuitsWithRequest(t *testing.T) { m := seedWorkspaceModel() m.view = viewOpenerPicker m.openerWsIdx = 1 - m.openers = []opener.Opener{ + m.openers = []userconfig.Opener{ {Name: "vscode", Command: "code"}, {Name: "cursor", Command: "cursor"}, } @@ -897,7 +893,7 @@ func TestOpenerPickerEscGoesBack(t *testing.T) { m := seedWorkspaceModel() m.view = viewOpenerPicker m.openerWsIdx = 0 - m.openers = []opener.Opener{ + m.openers = []userconfig.Opener{ {Name: "vscode", Command: "code"}, } @@ -1098,7 +1094,7 @@ func TestOpenersLoadedDefaultOpenerAutoSelect(t *testing.T) { m.openerWsIdx = 1 result, cmd := m.Update(openersLoadedMsg{ - openers: []opener.Opener{ + openers: []userconfig.Opener{ {Name: "vscode", Command: "code"}, {Name: "cursor", Command: "cursor", Default: true}, {Name: "terminal", Command: "open"}, @@ -1135,7 +1131,7 @@ func TestOpenersLoadedNoDefaultShowsPicker(t *testing.T) { // Multiple openers, none is default — should show picker m = updateModel(m, openersLoadedMsg{ - openers: []opener.Opener{ + openers: []userconfig.Opener{ {Name: "vscode", Command: "code"}, {Name: "cursor", Command: "cursor"}, {Name: "terminal", Command: "open"}, diff --git a/internal/userconfig/userconfig.go b/internal/userconfig/userconfig.go new file mode 100644 index 0000000..4c7a912 --- /dev/null +++ b/internal/userconfig/userconfig.go @@ -0,0 +1,143 @@ +package userconfig + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/protocollar/fr8/internal/flock" + "github.com/protocollar/fr8/internal/registry" +) + +// Opener defines a named command for opening a workspace in an external tool. +type Opener struct { + Name string `json:"name"` + Command string `json:"command"` + Default bool `json:"default,omitempty"` +} + +// Config holds user-level preferences stored in ~/.config/fr8/config.json. +type Config struct { + Openers []Opener `json:"openers,omitempty"` +} + +// DefaultPath returns the path to the user config file (~/.config/fr8/config.json). +func DefaultPath() (string, error) { + dir, err := registry.ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.json"), nil +} + +// Load reads the config from path. Returns an empty config if the file doesn't exist. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &Config{}, nil + } + return nil, fmt.Errorf("reading config: %w", err) + } + var c Config + if err := json.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + return &c, nil +} + +// Save writes the config to path. +// Uses advisory file locking to prevent concurrent modifications. +func (c *Config) Save(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + data = append(data, '\n') + + f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("creating lock file: %w", err) + } + defer func() { _ = f.Close() }() + defer func() { _ = os.Remove(path + ".lock") }() + + if err := flock.Lock(f.Fd()); err != nil { + return fmt.Errorf("acquiring lock: %w", err) + } + defer func() { _ = flock.Unlock(f.Fd()) }() + + return os.WriteFile(path, data, 0644) +} + +// FindOpener returns the opener with the given name, or nil. +func (c *Config) FindOpener(name string) *Opener { + for i := range c.Openers { + if c.Openers[i].Name == name { + return &c.Openers[i] + } + } + return nil +} + +// FindDefaultOpener returns the opener marked as default, or nil if none. +func (c *Config) FindDefaultOpener() *Opener { + for i := range c.Openers { + if c.Openers[i].Default { + return &c.Openers[i] + } + } + return nil +} + +// AddOpener appends an opener. Returns an error if the name already exists. +func (c *Config) AddOpener(o Opener) error { + if c.FindOpener(o.Name) != nil { + return fmt.Errorf("opener %q already exists (remove it first with: fr8 opener remove %s)", o.Name, o.Name) + } + c.Openers = append(c.Openers, o) + return nil +} + +// RemoveOpener removes an opener by name. +func (c *Config) RemoveOpener(name string) error { + for i, o := range c.Openers { + if o.Name == name { + c.Openers = append(c.Openers[:i], c.Openers[i+1:]...) + return nil + } + } + return fmt.Errorf("opener %q not found", name) +} + +// SetDefaultOpener marks the named opener as default and clears the flag on all others. +func (c *Config) SetDefaultOpener(name string) error { + found := false + for i := range c.Openers { + if c.Openers[i].Name == name { + c.Openers[i].Default = true + found = true + } else { + c.Openers[i].Default = false + } + } + if !found { + return fmt.Errorf("opener %q not found (see available: fr8 opener list)", name) + } + return nil +} + +// OpenerNames returns all opener names. +func (c *Config) OpenerNames() []string { + names := make([]string, len(c.Openers)) + for i, o := range c.Openers { + names[i] = o.Name + } + return names +} + diff --git a/internal/userconfig/userconfig_test.go b/internal/userconfig/userconfig_test.go new file mode 100644 index 0000000..db74c30 --- /dev/null +++ b/internal/userconfig/userconfig_test.go @@ -0,0 +1,104 @@ +package userconfig + +import ( + "path/filepath" + "testing" +) + +func TestLoadMissingFile(t *testing.T) { + c, err := Load("/nonexistent/path/config.json") + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if len(c.Openers) != 0 { + t.Fatalf("expected empty config, got %d openers", len(c.Openers)) + } +} + +func TestSaveAndLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + c := &Config{ + Openers: []Opener{ + {Name: "vscode", Command: "code", Default: true}, + {Name: "cursor", Command: "cursor"}, + }, + } + + if err := c.Save(path); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if len(loaded.Openers) != 2 { + t.Fatalf("expected 2 openers, got %d", len(loaded.Openers)) + } + if loaded.Openers[0].Name != "vscode" || !loaded.Openers[0].Default { + t.Errorf("unexpected first opener: %+v", loaded.Openers[0]) + } +} + +func TestOpenerCRUD(t *testing.T) { + c := &Config{} + + // Add + if err := c.AddOpener(Opener{Name: "vscode", Command: "code"}); err != nil { + t.Fatalf("AddOpener: %v", err) + } + + // Find + if o := c.FindOpener("vscode"); o == nil { + t.Fatal("expected to find vscode") + } + + // Duplicate add + if err := c.AddOpener(Opener{Name: "vscode", Command: "code"}); err == nil { + t.Fatal("expected error for duplicate") + } + + // Set default + if err := c.SetDefaultOpener("vscode"); err != nil { + t.Fatalf("SetDefaultOpener: %v", err) + } + if d := c.FindDefaultOpener(); d == nil || d.Name != "vscode" { + t.Error("expected vscode as default") + } + + // Names + if names := c.OpenerNames(); len(names) != 1 || names[0] != "vscode" { + t.Errorf("OpenerNames = %v, want [vscode]", names) + } + + // Remove + if err := c.RemoveOpener("vscode"); err != nil { + t.Fatalf("RemoveOpener: %v", err) + } + if c.FindOpener("vscode") != nil { + t.Error("expected vscode to be removed") + } + + // Remove nonexistent + if err := c.RemoveOpener("nonexistent"); err == nil { + t.Error("expected error for nonexistent opener") + } +} + +func TestSetDefaultOpenerNotFound(t *testing.T) { + c := &Config{} + if err := c.SetDefaultOpener("nonexistent"); err == nil { + t.Error("expected error for nonexistent opener") + } +} + +func TestFindDefaultOpenerNone(t *testing.T) { + c := &Config{Openers: []Opener{{Name: "vscode", Command: "code"}}} + if c.FindDefaultOpener() != nil { + t.Error("expected nil when no default is set") + } +} + diff --git a/internal/workspace/resolve.go b/internal/workspace/resolve.go index c0248ba..6fe0ab8 100644 --- a/internal/workspace/resolve.go +++ b/internal/workspace/resolve.go @@ -7,14 +7,13 @@ import ( "github.com/protocollar/fr8/internal/git" "github.com/protocollar/fr8/internal/registry" - "github.com/protocollar/fr8/internal/state" ) -// Resolve finds a workspace by name, or detects it from the current directory. +// Resolve finds a workspace by name within a specific repo. // If name is empty, uses CWD to find the matching workspace. -func Resolve(name string, st *state.State) (*state.Workspace, error) { +func Resolve(name string, repo *registry.Repo) (*registry.Workspace, error) { if name != "" { - ws := st.Find(name) + ws := repo.FindWorkspace(name) if ws == nil { return nil, fmt.Errorf("workspace %q not found (see available: fr8 ws list)", name) } @@ -27,110 +26,63 @@ func Resolve(name string, st *state.State) (*state.Workspace, error) { return nil, fmt.Errorf("getting working directory: %w", err) } - ws := st.FindByPath(cwd) + ws := repo.FindWorkspaceByPath(cwd) if ws == nil { return nil, fmt.Errorf("not inside a managed workspace (run from a workspace directory or specify a name)") } return ws, nil } -// globalMatch holds a workspace match found during global resolution. -type globalMatch struct { - Workspace *state.Workspace - RootPath string - CommonDir string - RepoName string -} - // ResolveGlobal searches all registered repos for a workspace by name. // Returns an error listing the matching repos if more than one is found. -func ResolveGlobal(name string) (*state.Workspace, string, string, error) { +func ResolveGlobal(name string) (*registry.Workspace, *registry.Repo, string, error) { regPath, err := registry.DefaultPath() if err != nil { - return nil, "", "", err + return nil, nil, "", err } reg, err := registry.Load(regPath) if err != nil { - return nil, "", "", fmt.Errorf("loading registry: %w", err) + return nil, nil, "", fmt.Errorf("loading registry: %w", err) } - var matches []globalMatch - - for _, repo := range reg.Repos { - commonDir, err := git.CommonDir(repo.Path) - if err != nil { - continue - } - - st, err := state.Load(commonDir) - if err != nil { - continue - } - - ws := st.Find(name) - if ws != nil { - rootPath, err := git.RootWorktreePath(repo.Path) - if err != nil { - rootPath = repo.Path - } - matches = append(matches, globalMatch{ - Workspace: ws, - RootPath: rootPath, - CommonDir: commonDir, - RepoName: repo.Name, - }) + ws, repo, err := reg.FindWorkspaceGlobal(name) + if err != nil { + // Reformat multi-repo ambiguity error with suggestion + if strings.Contains(err.Error(), "multiple repos") { + return nil, nil, "", err } + return nil, nil, "", fmt.Errorf("workspace %q not found in any registered repo (see repos: fr8 repo list)", name) } - switch len(matches) { - case 0: - return nil, "", "", fmt.Errorf("workspace %q not found in any registered repo (see repos: fr8 repo list)", name) - case 1: - m := matches[0] - return m.Workspace, m.RootPath, m.CommonDir, nil - default: - var repoNames []string - for _, m := range matches { - repoNames = append(repoNames, m.RepoName) - } - return nil, "", "", fmt.Errorf( - "workspace %q found in multiple repos: %s\nUse --repo to disambiguate: fr8 ws --repo %s", - name, strings.Join(repoNames, ", "), name, - ) + rootPath, err := git.RootWorktreePath(repo.Path) + if err != nil { + rootPath = repo.Path } + + return ws, repo, rootPath, nil } // ResolveFromRepo resolves a workspace by name from a specific registered repo. -func ResolveFromRepo(name, repoName string) (*state.Workspace, string, string, error) { +func ResolveFromRepo(name, repoName string) (*registry.Workspace, *registry.Repo, string, error) { regPath, err := registry.DefaultPath() if err != nil { - return nil, "", "", err + return nil, nil, "", err } reg, err := registry.Load(regPath) if err != nil { - return nil, "", "", fmt.Errorf("loading registry: %w", err) + return nil, nil, "", fmt.Errorf("loading registry: %w", err) } repo := reg.Find(repoName) if repo == nil { - return nil, "", "", fmt.Errorf("repo %q not found in registry (see: fr8 repo list)", repoName) - } - - commonDir, err := git.CommonDir(repo.Path) - if err != nil { - return nil, "", "", fmt.Errorf("reading git data for %s: %w", repoName, err) - } - - st, err := state.Load(commonDir) - if err != nil { - return nil, "", "", fmt.Errorf("loading state for %s: %w", repoName, err) + return nil, nil, "", fmt.Errorf("repo %q not found in registry (see: fr8 repo list)", repoName) } - ws := st.Find(name) + ws := repo.FindWorkspace(name) if ws == nil { - return nil, "", "", fmt.Errorf("workspace %q not found in repo %q (see available: fr8 ws list --repo %s)", name, repoName, repoName) + return nil, nil, "", fmt.Errorf("workspace %q not found in repo %q (see available: fr8 ws list --repo %s)", name, repoName, repoName) } rootPath, err := git.RootWorktreePath(repo.Path) @@ -138,5 +90,5 @@ func ResolveFromRepo(name, repoName string) (*state.Workspace, string, string, e rootPath = repo.Path } - return ws, rootPath, commonDir, nil + return ws, repo, rootPath, nil } diff --git a/internal/workspace/resolve_test.go b/internal/workspace/resolve_test.go index 7c237e0..b4e4cf6 100644 --- a/internal/workspace/resolve_test.go +++ b/internal/workspace/resolve_test.go @@ -3,18 +3,20 @@ package workspace import ( "testing" - "github.com/protocollar/fr8/internal/state" + "github.com/protocollar/fr8/internal/registry" ) func TestResolveByName(t *testing.T) { - st := &state.State{ - Workspaces: []state.Workspace{ + repo := ®istry.Repo{ + Name: "test", + Path: "/tmp/repo", + Workspaces: []registry.Workspace{ {Name: "alpha", Path: "/tmp/alpha"}, {Name: "beta", Path: "/tmp/beta"}, }, } - ws, err := Resolve("beta", st) + ws, err := Resolve("beta", repo) if err != nil { t.Fatal(err) } @@ -24,9 +26,9 @@ func TestResolveByName(t *testing.T) { } func TestResolveByNameNotFound(t *testing.T) { - st := &state.State{} + repo := ®istry.Repo{Name: "test", Path: "/tmp/repo"} - _, err := Resolve("nonexistent", st) + _, err := Resolve("nonexistent", repo) if err == nil { t.Fatal("expected error for nonexistent workspace") }