From 349a567fd58c39dc60c2d4c2e6d9977f68318216 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 04:45:24 +0000 Subject: [PATCH 01/20] refactor(project): shell out to project binary, drop write ops and team support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace direct TOML reads in internal/project with exec of the organon project CLI. Remove add/archive/unarchive/modify/delete/open commands from cmd/project.go — writes go directly to projects.toml. Drop all team-aware functions (ResolveProjectPathForTeam, etc.). - internal/project: store.go and token.go deleted, resolve.go shells out to project binary via project list --json / project resolve - cmd/project.go: only list and resolve remain; resolve keeps ttal enrichment (task_id, stage, owner) - Daemon callers updated to use project.Get/project.List/project.Get callback instead of Store - Statusline/test updated to use injectable resolveAliasFn --- cmd/project.go | 330 ++------------ cmd/project_test.go | 381 ---------------- internal/daemon/agents.go | 6 +- internal/daemon/agents_test.go | 46 +- internal/daemon/daemon.go | 2 +- internal/daemon/git_handler.go | 4 +- internal/daemon/kube_handler.go | 4 +- internal/daemon/kube_handler_test.go | 257 ++--------- internal/daemon/prwatch.go | 2 +- internal/doctor/doctor.go | 7 +- internal/project/doc.go | 10 +- internal/project/resolve.go | 378 +++++++++------- internal/project/resolve_test.go | 357 --------------- internal/project/store.go | 394 ---------------- internal/project/store_test.go | 641 --------------------------- internal/project/token.go | 43 -- internal/project/token_test.go | 93 ---- internal/statusline/path.go | 16 +- internal/statusline/path_test.go | 174 +++----- 19 files changed, 387 insertions(+), 2758 deletions(-) delete mode 100644 cmd/project_test.go delete mode 100644 internal/project/resolve_test.go delete mode 100644 internal/project/store.go delete mode 100644 internal/project/store_test.go delete mode 100644 internal/project/token.go delete mode 100644 internal/project/token_test.go diff --git a/cmd/project.go b/cmd/project.go index e8dd573d..5c960712 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -7,38 +7,14 @@ import ( "path/filepath" "strings" - "charm.land/lipgloss/v2" - "charm.land/lipgloss/v2/table" "github.com/spf13/cobra" "github.com/tta-lab/ttal-cli/internal/config" - "github.com/tta-lab/ttal-cli/internal/format" - "github.com/tta-lab/ttal-cli/internal/gitprovider" - "github.com/tta-lab/ttal-cli/internal/open" "github.com/tta-lab/ttal-cli/internal/pipeline" "github.com/tta-lab/ttal-cli/internal/project" "github.com/tta-lab/ttal-cli/internal/taskwarrior" ) -const statusCol = 3 // index of the STATUS column in project list table - -const projectAddExample = `ttal project add --alias myproj --name "My Project" --path /path/to/repo` - -var ( - projectAlias string - projectName string - projectPath string - projectJSON bool - archivedOnly bool -) - -func getProjectStore() *project.Store { - return project.NewStore(config.ResolveProjectsPath()) -} - // resolveJSONOutput is the JSON payload returned by `ttal project resolve --json`. -// Every field is an empty string when the underlying data is unavailable — this -// lets consumers (notably a future tw `task info` patch) shell out to this command -// as a best-effort black-box enricher and render whatever fields came back. type resolveJSONOutput struct { Alias string `json:"alias"` Path string `json:"path"` @@ -47,10 +23,6 @@ type resolveJSONOutput struct { Owner string `json:"owner"` } -// buildResolveJSONOutput assembles the JSON payload from the pieces the -// `resolve` command has already fetched. Pure function: no I/O, accepts nil -// values gracefully, never returns an error. The caller does the lookups -// (store, taskwarrior, pipeline) and decides how to handle their failures. func buildResolveJSONOutput( alias string, proj *project.Project, @@ -77,71 +49,22 @@ func buildResolveJSONOutput( var projectCmd = &cobra.Command{ Use: "project", - Short: "Manage projects", - Long: `Add, list, archive, and modify projects.`, -} - -var projectAddCmd = &cobra.Command{ - Use: "add", - Short: "Add a new project", - Long: `Add a new project. - -Example: - ttal project add --alias=clawd --name='TTAL Core' --path=/Users/neil/clawd`, - RunE: func(cmd *cobra.Command, args []string) error { - if projectAlias == "" { - return fmt.Errorf("--alias is required\n\n Example: %s", projectAddExample) - } - if projectName == "" { - return fmt.Errorf("--name is required\n\n Example: %s", projectAddExample) - } - - store := getProjectStore() - if err := store.Add(projectAlias, projectName, projectPath); err != nil { - return fmt.Errorf("failed to create project: %w", err) - } - - fmt.Printf("Project '%s' created successfully\n", projectAlias) - return nil - }, + Short: "Resolve projects (read-only; writes via ~/.config/ttal/projects.toml directly)", + Long: `Resolve project aliases and paths via the project CLI. Writes go directly to projects.toml.`, } var projectListCmd = &cobra.Command{ - Use: "list [team]", + Use: "list", Short: "List projects", - Long: `List all projects. Optionally specify a team name to list that team's projects -instead of the current team. - -Examples: - ttal project list # List current team's projects - ttal project list guion # List guion team's projects - ttal project list --archived # List only archived projects`, - Args: cobra.MaximumNArgs(1), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - var store *project.Store - if len(args) == 1 { - teamPath := config.ResolveProjectsPathForTeam(args[0]) - store = project.NewStore(teamPath) - } else { - store = getProjectStore() - } - - projects, err := store.List(archivedOnly) + projects, err := project.List() if err != nil { return fmt.Errorf("failed to list projects: %w", err) } if projectJSON { - type projectJSON struct { - Alias string `json:"alias"` - Name string `json:"name"` - Path string `json:"path"` - } - output := make([]projectJSON, 0, len(projects)) - for _, p := range projects { - output = append(output, projectJSON{Alias: p.Alias, Name: p.Name, Path: p.Path}) - } - data, err := json.Marshal(output) + data, err := json.Marshal(projects) if err != nil { return fmt.Errorf("failed to marshal projects: %w", err) } @@ -154,129 +77,12 @@ Examples: return nil } - dimColor, headerStyle, cellStyle, dimStyle := format.TableStyles() - - rows := make([][]string, 0, len(projects)) for _, p := range projects { - status := "active" - if p.Archived { - status = "archived" - } - rows = append(rows, []string{ - p.Alias, - p.Name, - p.Path, - status, - }) - } - - t := table.New(). - Border(lipgloss.RoundedBorder()). - BorderStyle(lipgloss.NewStyle().Foreground(dimColor)). - StyleFunc(func(row, col int) lipgloss.Style { - if row == table.HeaderRow { - return headerStyle - } - if col == statusCol { - return dimStyle - } - return cellStyle - }). - Headers("ALIAS", "NAME", "PATH", "STATUS"). - Rows(rows...) - - lipgloss.Println(t) - fmt.Printf("\n%d %s\n", len(projects), format.Plural(len(projects), "project", "projects")) - return nil - }, -} - -var projectArchiveCmd = &cobra.Command{ - Use: "archive ", - Short: "Archive a project", - Long: `Mark a project as archived. - -Example: - ttal project archive old-project`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - store := getProjectStore() - if err := store.Archive(args[0]); err != nil { - return fmt.Errorf("failed to archive project: %w", err) - } - - fmt.Printf("Project '%s' archived successfully\n", args[0]) - return nil - }, -} - -var projectUnarchiveCmd = &cobra.Command{ - Use: "unarchive ", - Short: "Unarchive a project", - Long: `Remove archived status from a project. - -Example: - ttal project unarchive old-project`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - store := getProjectStore() - if err := store.Unarchive(args[0]); err != nil { - return fmt.Errorf("failed to unarchive project: %w", err) - } - - fmt.Printf("Project '%s' unarchived successfully\n", args[0]) - return nil - }, -} - -var projectDeleteCmd = &cobra.Command{ - Use: "delete ", - Short: "Permanently delete a project", - Args: cobra.ExactArgs(1), - RunE: runProjectDelete, -} - -func runProjectDelete(cmd *cobra.Command, args []string) error { - alias := strings.ToLower(args[0]) - store := getProjectStore() - return deleteEntity("project", alias, - func() (bool, error) { return store.Exists(alias) }, - func() error { return store.Delete(alias) }, - ) -} - -var projectModifyCmd = &cobra.Command{ - Use: "modify [field:value...]", - Short: "Modify project fields", - Long: `Modify project fields. - -Examples: - ttal project modify clawd alias:new-alias - ttal project modify clawd name:'New Project Name' - ttal project modify clawd path:/new/path`, - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - alias := args[0] - fieldUpdates, err := parseModifyArgs(args[1:]) - if err != nil { - return err - } - - if len(fieldUpdates) == 0 { - return fmt.Errorf("no modifications specified\n\n Example: ttal project modify myproj name:\"New Name\" path:/new/path") //nolint:lll - } - - store := getProjectStore() - if err := store.Modify(alias, fieldUpdates); err != nil { - return fmt.Errorf("failed to modify project: %w", err) - } - - fmt.Printf("Project '%s' updated successfully\n", alias) - fmt.Println("Fields updated:") - for field, value := range fieldUpdates { - fmt.Printf(" %s: %s\n", field, value) + org := projectOrg(p.Path) + fmt.Printf("%-12s %-20s %-40s %s\n", p.Alias, org, p.Name, p.Path) } + fmt.Printf("\n%d projects — use project list --json for full details\n", len(projects)) return nil }, } @@ -286,27 +92,7 @@ var resolveJSON bool var projectResolveCmd = &cobra.Command{ Use: "resolve [path]", Short: "Resolve project alias from a filesystem path", - Long: `Resolve the project alias for a given path (defaults to current directory). - -With --json, outputs an enriched payload for the active task in the resolved -project. Every field is an empty string when the underlying data is unavailable -(no matching project, no active task, no pipeline match, etc.) — the command -always exits 0 on a readable cwd. - -JSON shape: - { - "alias": "fb", - "path": "/Users/neil/Code/guion/flick-backend-31", - "task_id": "c8c991bd", - "stage": "Plan", - "owner": "astra" - } - -Examples: - ttal project resolve # print alias for cwd - ttal project resolve /path/to/repo # print alias for explicit path - ttal project resolve --json # enriched JSON for cwd's active task`, - Args: cobra.MaximumNArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var workDir string if len(args) == 1 { @@ -325,24 +111,33 @@ Examples: alias := project.ResolveProjectAlias(workDir) + if alias == "" { + if resolveJSON { + payload := buildResolveJSONOutput("", nil, nil, nil) + out, _ := json.Marshal(payload) + fmt.Println(string(out)) + return nil + } + fmt.Println("") + return nil + } + if resolveJSON { var proj *project.Project var task *taskwarrior.Task var pipelineCfg *pipeline.Config - if alias != "" { - if p, err := getProjectStore().Get(alias); err == nil && p != nil { - proj = p - } else if err != nil { - fmt.Fprintf(os.Stderr, "warning: failed to lookup project %q: %v\n", alias, err) - } + if p, err := project.Get(alias); err == nil && p != nil { + proj = p + } else if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to lookup project %q: %v\n", alias, err) + } - tasks, err := taskwarrior.ExportTasksByFilter("+ACTIVE", fmt.Sprintf("project:%s", alias)) - if err != nil { - fmt.Fprintf(os.Stderr, "warning: failed to lookup active task: %v\n", err) - } else if len(tasks) > 0 { - task = &tasks[0] - } + tasks, err := taskwarrior.ExportTasksByFilter("+ACTIVE", fmt.Sprintf("project:%s", alias)) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to lookup active task: %v\n", err) + } else if len(tasks) > 0 { + task = &tasks[0] } if cfg, err := pipeline.Load(config.DefaultConfigDir()); err != nil { @@ -360,68 +155,23 @@ Examples: return nil } - if alias == "" { - return fmt.Errorf("no project found for %s", workDir) - } fmt.Println(alias) return nil }, } -var projectOpenCmd = &cobra.Command{ - Use: "open ", - Short: "Open project repository in browser", - Long: `Open the project's remote repository URL in the default browser. - -Resolves the project path from projects.toml, reads the git remote origin URL, -and constructs the web URL (github.com for GitHub, derived from git remote for Forgejo repos). - -Example: - ttal project open ttal`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - alias := args[0] - - projectPath, err := project.GetProjectPath(alias) - if err != nil { - return err - } - - repoInfo, err := gitprovider.DetectProvider(projectPath) - if err != nil { - return fmt.Errorf("cannot determine repo from %s: %w", projectPath, err) - } - - repoURL := repoInfo.WebURL() - if err := open.Browser(repoURL); err != nil { - return fmt.Errorf("open browser for %s: %w", repoURL, err) - } - fmt.Printf("Opening %s/%s: %s\n", repoInfo.Owner, repoInfo.Repo, repoURL) - return nil - }, -} +var projectJSON bool func init() { rootCmd.AddCommand(projectCmd) - - projectCmd.AddCommand(projectAddCmd) projectCmd.AddCommand(projectListCmd) - projectCmd.AddCommand(projectArchiveCmd) - projectCmd.AddCommand(projectUnarchiveCmd) - projectCmd.AddCommand(projectDeleteCmd) - projectCmd.AddCommand(projectModifyCmd) projectCmd.AddCommand(projectResolveCmd) - projectCmd.AddCommand(projectOpenCmd) - - projectAddCmd.Flags().StringVar(&projectAlias, "alias", "", "Project alias (required, unique identifier)") - projectAddCmd.Flags().StringVar(&projectName, "name", "", "Project name (required)") - projectAddCmd.Flags().StringVar(&projectPath, "path", "", "Filesystem path") projectListCmd.Flags().BoolVar(&projectJSON, "json", false, "Output as JSON") - projectListCmd.Flags().BoolVar(&archivedOnly, "archived", false, "Show only archived projects") projectResolveCmd.Flags().BoolVar(&resolveJSON, "json", false, "Output as JSON with alias, path, task_id, stage, and owner") } +// parseModifyArgs parses field:value arguments for modify commands (used by agent.go). func parseModifyArgs(args []string) (fieldUpdates map[string]string, err error) { fieldUpdates = make(map[string]string) for _, arg := range args { @@ -438,3 +188,17 @@ func parseModifyArgs(args []string) (fieldUpdates map[string]string, err error) } return } + +// projectOrg derives an org from a project path for display. +func projectOrg(p string) string { + for _, part := range []string{"/projects/", "/references/"} { + if idx := strings.Index(strings.ToLower(p), part); idx >= 0 { + p = p[idx+len(part):] + parts := strings.SplitN(p, "/", 2) + if len(parts[0]) > 0 { + return parts[0] + } + } + } + return "" +} diff --git a/cmd/project_test.go b/cmd/project_test.go deleted file mode 100644 index 4d18d8da..00000000 --- a/cmd/project_test.go +++ /dev/null @@ -1,381 +0,0 @@ -package cmd - -import ( - "encoding/json" - "path/filepath" - "testing" - - "github.com/tta-lab/ttal-cli/internal/pipeline" - "github.com/tta-lab/ttal-cli/internal/project" - "github.com/tta-lab/ttal-cli/internal/taskwarrior" -) - -const ( - testNewName = "New Name" - testNewPath = "/new/path" - - testTaskHex = "c8c991bd" - testTaskOwner = "astra" -) - -func newTestStore(t *testing.T) *project.Store { - t.Helper() - return project.NewStore(filepath.Join(t.TempDir(), "projects.toml")) -} - -func TestProjectModifyAlias(t *testing.T) { - store := newTestStore(t) - if err := store.Add("old-alias", "Test Project", ""); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - if err := store.Modify("old-alias", map[string]string{"alias": "new-alias"}); err != nil { - t.Fatalf("failed to update project alias: %v", err) - } - - p, err := store.Get("old-alias") - if err != nil { - t.Fatalf("failed to query project: %v", err) - } - if p != nil { - t.Error("old alias should not exist after rename") - } - - updated, err := store.Get("new-alias") - if err != nil { - t.Fatalf("failed to query project by new alias: %v", err) - } - if updated == nil { - t.Fatal("project with new alias not found") - return - } - if updated.Alias != "new-alias" { - t.Errorf("project alias = %v, want new-alias", updated.Alias) - } -} - -func TestProjectModifyName(t *testing.T) { - store := newTestStore(t) - if err := store.Add("test-proj", "Old Name", ""); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - if err := store.Modify("test-proj", map[string]string{"name": testNewName}); err != nil { - t.Fatalf("failed to update project name: %v", err) - } - - updated, err := store.Get("test-proj") - if err != nil { - t.Fatalf("failed to query project: %v", err) - } - if updated == nil { - t.Fatal("project not found") - return - } - if updated.Name != testNewName { - t.Errorf("project name = %v, want %v", updated.Name, testNewName) - } -} - -func TestProjectModifyPath(t *testing.T) { - store := newTestStore(t) - if err := store.Add("test-proj", "Test Project", "/old/path"); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - if err := store.Modify("test-proj", map[string]string{"path": testNewPath}); err != nil { - t.Fatalf("failed to update project path: %v", err) - } - - updated, err := store.Get("test-proj") - if err != nil { - t.Fatalf("failed to query project: %v", err) - } - if updated == nil { - t.Fatal("project not found") - return - } - if updated.Path != testNewPath { - t.Errorf("project path = %v, want %v", updated.Path, testNewPath) - } -} - -func TestProjectModifyMultipleFields(t *testing.T) { - store := newTestStore(t) - if err := store.Add("test-proj", "Old Name", "/old/path"); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - if err := store.Modify("test-proj", map[string]string{"name": testNewName, "path": testNewPath}); err != nil { - t.Fatalf("failed to update project: %v", err) - } - - updated, err := store.Get("test-proj") - if err != nil { - t.Fatalf("failed to query project: %v", err) - } - if updated == nil { - t.Fatal("project not found") - return - } - if updated.Name != testNewName { - t.Errorf("project name = %v, want New Name", updated.Name) - } - if updated.Path != testNewPath { - t.Errorf("project path = %v, want /new/path", updated.Path) - } -} - -func TestProjectArchive(t *testing.T) { - store := newTestStore(t) - if err := store.Add("test-proj", "Test Project", ""); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - // Verify it's active - p, _ := store.Get("test-proj") - if p == nil { - t.Fatal("project should be active after creation") - } - - // Archive it - if err := store.Archive("test-proj"); err != nil { - t.Fatalf("failed to archive project: %v", err) - } - - // Should no longer appear in active - p, _ = store.Get("test-proj") - if p != nil { - t.Error("project should not be active after archiving") - } - - // Should appear in archived - archived, _ := store.List(true) - if len(archived) != 1 || archived[0].Alias != "test-proj" { - t.Error("project should appear in archived list") - } - - // Unarchive it - if err := store.Unarchive("test-proj"); err != nil { - t.Fatalf("failed to unarchive project: %v", err) - } - - // Should be active again - p, _ = store.Get("test-proj") - if p == nil { - t.Error("project should be active after unarchiving") - } -} - -func TestProjectDelete(t *testing.T) { - store := newTestStore(t) - if err := store.Add("to-delete", "Delete Me", ""); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - if err := store.Delete("to-delete"); err != nil { - t.Fatalf("failed to delete project: %v", err) - } - - p, err := store.Get("to-delete") - if err != nil { - t.Fatalf("failed to query project: %v", err) - } - if p != nil { - t.Error("project should not exist after deletion") - } -} - -func TestProjectDeleteNotFound(t *testing.T) { - store := newTestStore(t) - err := store.Delete("nonexistent") - if err == nil { - t.Error("deleting nonexistent project should return error") - } -} - -func TestProjectListArchivedOnly(t *testing.T) { - store := newTestStore(t) - - if err := store.Add("active-proj", "Active", ""); err != nil { - t.Fatalf("failed to create active project: %v", err) - } - if err := store.Add("archived-proj", "Archived", ""); err != nil { - t.Fatalf("failed to create project: %v", err) - } - if err := store.Archive("archived-proj"); err != nil { - t.Fatalf("failed to archive project: %v", err) - } - - archived, err := store.List(true) - if err != nil { - t.Fatalf("failed to list archived projects: %v", err) - } - if len(archived) != 1 { - t.Errorf("found %d archived projects, want 1", len(archived)) - } - if len(archived) > 0 && archived[0].Alias != "archived-proj" { - t.Errorf("archived project alias = %v, want archived-proj", archived[0].Alias) - } - - active, err := store.List(false) - if err != nil { - t.Fatalf("failed to list active projects: %v", err) - } - if len(active) != 1 { - t.Errorf("found %d active projects, want 1", len(active)) - } - if len(active) > 0 && active[0].Alias != "active-proj" { - t.Errorf("active project alias = %v, want active-proj", active[0].Alias) - } -} - -func TestProjectListJSON(t *testing.T) { - store := newTestStore(t) - if err := store.Add("proj1", "Project One", "/path/one"); err != nil { - t.Fatalf("failed to create project: %v", err) - } - if err := store.Add("proj2", "Project Two", "/path/two"); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - projects, err := store.List(false) - if err != nil { - t.Fatalf("failed to list projects: %v", err) - } - - // Reproduce the JSON output logic from the command - type projectJSON struct { - Alias string `json:"alias"` - Name string `json:"name"` - Path string `json:"path"` - } - output := make([]projectJSON, 0, len(projects)) - for _, p := range projects { - output = append(output, projectJSON{Alias: p.Alias, Name: p.Name, Path: p.Path}) - } - data, err := json.Marshal(output) - if err != nil { - t.Fatalf("failed to marshal projects: %v", err) - } - - // Parse JSON output and verify structure - var results []map[string]string - if err := json.Unmarshal(data, &results); err != nil { - t.Fatalf("failed to parse JSON output: %v\nOutput: %s", err, string(data)) - } - if len(results) != 2 { - t.Fatalf("expected 2 projects in JSON, got %d", len(results)) - } - assertJSONProjectFields(t, results) -} - -// assertJSONProjectFields checks that each item has alias, name, path keys -// and that proj1 is present with expected values. -func assertJSONProjectFields(t *testing.T, results []map[string]string) { - t.Helper() - requiredFields := []string{"alias", "name", "path"} - found := false - for _, r := range results { - for _, field := range requiredFields { - if _, ok := r[field]; !ok { - t.Errorf("JSON object missing %q field", field) - } - } - if r["alias"] == "proj1" && r["name"] == "Project One" && r["path"] == "/path/one" { - found = true - } - } - if !found { - t.Error("expected project proj1 not found in JSON output") - } -} - -func TestBuildResolveJSONOutput_AllFieldsPresent(t *testing.T) { - proj := &project.Project{Alias: "fb", Path: "/repo/fb"} - task := &taskwarrior.Task{ - UUID: "c8c991bd-8fb7-4950-b372-2e139ebf2afa", - Owner: testTaskOwner, - Tags: []string{"standard", "plan"}, - } - cfg := &pipeline.Config{Pipelines: map[string]pipeline.Pipeline{ - "standard": { - Tags: []string{"standard"}, - Stages: []pipeline.Stage{ - {Name: "plan", Assignee: "designer", Gate: "human"}, - {Name: "implement", Assignee: "coder", Gate: "auto", Worker: true}, - }, - }, - }} - - out := buildResolveJSONOutput("fb", proj, task, cfg) - if out.Alias != "fb" { - t.Errorf("alias = %q, want fb", out.Alias) - } - if out.Path != "/repo/fb" { - t.Errorf("path = %q, want /repo/fb", out.Path) - } - if out.TaskID != testTaskHex { - t.Errorf("task_id = %q, want %s", out.TaskID, testTaskHex) - } - if out.Stage != "plan" { - t.Errorf("stage = %q, want plan", out.Stage) - } - if out.Owner != testTaskOwner { - t.Errorf("owner = %q, want %s", out.Owner, testTaskOwner) - } -} - -func TestBuildResolveJSONOutput_EmptyAlias(t *testing.T) { - out := buildResolveJSONOutput("", nil, nil, nil) - if out.Alias != "" || out.Path != "" || out.TaskID != "" || out.Stage != "" || out.Owner != "" { - t.Errorf("all fields should be empty, got %+v", out) - } -} - -func TestBuildResolveJSONOutput_AliasNoTask(t *testing.T) { - proj := &project.Project{Alias: "fb", Path: "/repo/fb"} - out := buildResolveJSONOutput("fb", proj, nil, nil) - if out.Alias != "fb" || out.Path != "/repo/fb" { - t.Errorf("alias/path missing: %+v", out) - } - if out.TaskID != "" || out.Stage != "" || out.Owner != "" { - t.Errorf("task-derived fields should be empty, got %+v", out) - } -} - -func TestBuildResolveJSONOutput_TaskNoPipelineMatch(t *testing.T) { - task := &taskwarrior.Task{ - UUID: "c8c991bd-8fb7-4950-b372-2e139ebf2afa", - Owner: testTaskOwner, - Tags: []string{"unmatched_tag"}, - } - cfg := &pipeline.Config{Pipelines: map[string]pipeline.Pipeline{ - "standard": { - Tags: []string{"standard"}, - Stages: []pipeline.Stage{{Name: "plan", Assignee: "designer", Gate: "human"}}, - }, - }} - out := buildResolveJSONOutput("fb", nil, task, cfg) - if out.TaskID != testTaskHex || out.Owner != testTaskOwner { - t.Errorf("task_id/owner missing: %+v", out) - } - if out.Stage != "" { - t.Errorf("stage should be empty when no pipeline matches, got %q", out.Stage) - } -} - -func TestBuildResolveJSONOutput_NilPipelineConfig(t *testing.T) { - task := &taskwarrior.Task{ - UUID: "c8c991bd-8fb7-4950-b372-2e139ebf2afa", - Owner: testTaskOwner, - Tags: []string{"standard"}, - } - out := buildResolveJSONOutput("fb", nil, task, nil) - if out.Stage != "" { - t.Errorf("stage should be empty with nil pipeline config, got %q", out.Stage) - } - if out.TaskID != testTaskHex || out.Owner != testTaskOwner { - t.Errorf("non-stage task fields should still populate: %+v", out) - } -} diff --git a/internal/daemon/agents.go b/internal/daemon/agents.go index 34221468..b1e96c6e 100644 --- a/internal/daemon/agents.go +++ b/internal/daemon/agents.go @@ -187,13 +187,13 @@ func bridgeAdapterEvents( // gatherProjectPaths returns all active project paths. // Single-team: only "default" team. +var gatherProjectPathsListFn = project.List + func gatherProjectPaths(_ *config.Config, storePathFn func(string) string) []string { seen := make(map[string]bool) var paths []string - projectsPath := storePathFn("default") - store := project.NewStore(projectsPath) - projects, err := store.List(false) + projects, err := gatherProjectPathsListFn() if err != nil { log.Printf("[daemon] warning: failed to load projects for team %s: %v", "default", err) } else { diff --git a/internal/daemon/agents_test.go b/internal/daemon/agents_test.go index fe77170c..31518943 100644 --- a/internal/daemon/agents_test.go +++ b/internal/daemon/agents_test.go @@ -1,7 +1,6 @@ package daemon import ( - "path/filepath" "strings" "testing" @@ -10,27 +9,19 @@ import ( ) func TestGatherProjectPaths(t *testing.T) { - // Create a temp project store. - team1Dir := t.TempDir() + orig := gatherProjectPathsListFn + t.Cleanup(func() { gatherProjectPathsListFn = orig }) - store1 := project.NewStore(filepath.Join(team1Dir, "projects.toml")) - if err := store1.Add("alpha", "Alpha", "/proj/alpha"); err != nil { - t.Fatalf("store1.Add alpha: %v", err) + gatherProjectPathsListFn = func() ([]project.Project, error) { + return []project.Project{ + {Alias: "alpha", Name: "Alpha", Path: "/proj/alpha"}, + {Alias: "beta", Name: "Beta", Path: "/proj/beta"}, + }, nil } - if err := store1.Add("beta", "Beta", "/proj/beta"); err != nil { - t.Fatalf("store1.Add beta: %v", err) - } - - storeMap := map[string]string{ - "default": filepath.Join(team1Dir, "projects.toml"), - } - storePathFn := func(teamName string) string { return storeMap[teamName] } cfg := &config.Config{} + paths := gatherProjectPaths(cfg, nil) - paths := gatherProjectPaths(cfg, storePathFn) - - // Expect sorted paths. want := []string{"/proj/alpha", "/proj/beta"} if len(paths) != len(want) { t.Fatalf("expected %d paths, got %d: %v", len(want), len(paths), paths) @@ -43,25 +34,35 @@ func TestGatherProjectPaths(t *testing.T) { } func TestGatherProjectPaths_EmptyStore(t *testing.T) { - tmpDir := t.TempDir() - // Store exists but has no projects. - storePathFn := func(_ string) string { return filepath.Join(tmpDir, "projects.toml") } + orig := gatherProjectPathsListFn + t.Cleanup(func() { gatherProjectPathsListFn = orig }) - cfg := &config.Config{} + gatherProjectPathsListFn = func() ([]project.Project, error) { + return nil, nil + } - paths := gatherProjectPaths(cfg, storePathFn) + cfg := &config.Config{} + paths := gatherProjectPaths(cfg, nil) if len(paths) != 0 { t.Errorf("expected 0 paths for empty store, got %v", paths) } } func TestGatherProjectPaths_NoProjects(t *testing.T) { + orig := gatherProjectPathsListFn + t.Cleanup(func() { gatherProjectPathsListFn = orig }) + + gatherProjectPathsListFn = func() ([]project.Project, error) { + return nil, nil + } + cfg := &config.Config{} paths := gatherProjectPaths(cfg, func(_ string) string { return "/nonexistent" }) if len(paths) != 0 { t.Errorf("expected 0 paths with no teams, got %v", paths) } } + func TestBuildManagerAgentEnv(t *testing.T) { t.Run("includes identity and 1h prompt cache flag", func(t *testing.T) { cfg := &config.Config{} @@ -75,5 +76,4 @@ func TestBuildManagerAgentEnv(t *testing.T) { t.Errorf("ENABLE_PROMPT_CACHING_1H=1 missing from %v — 1h TTL opt-in is required for manager sessions", vars) } }) - } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index df4c4dac..c9aa680a 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -212,7 +212,7 @@ func buildHTTPHandlers( gitTag: handleGitTag, gitPull: handleGitPull, kubeLog: HandleKubeLog( - project.NewStore(config.ResolveProjectsPath()), + project.Get, cfg.Kubernetes.Context, cfg.Kubernetes.AllowedNamespaces, ), diff --git a/internal/daemon/git_handler.go b/internal/daemon/git_handler.go index b3aab191..ff8e302f 100644 --- a/internal/daemon/git_handler.go +++ b/internal/daemon/git_handler.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/tta-lab/ttal-cli/internal/config" "github.com/tta-lab/ttal-cli/internal/gitutil" "github.com/tta-lab/ttal-cli/internal/project" ) @@ -370,8 +369,7 @@ func isMissingRemoteBranchDelete(output string) bool { // config errors instead of the misleading "not a registered project" message. func isRegisteredProjectPath(path string) (bool, error) { cleanPath := filepath.Clean(path) - store := project.NewStore(config.ResolveProjectsPath()) - projects, err := store.List(false) + projects, err := project.List() if err != nil { return false, err } diff --git a/internal/daemon/kube_handler.go b/internal/daemon/kube_handler.go index 127dd2c0..8d641d0d 100644 --- a/internal/daemon/kube_handler.go +++ b/internal/daemon/kube_handler.go @@ -11,10 +11,10 @@ import ( ) // HandleKubeLog returns a handler that fetches pod logs via kubectl. -func HandleKubeLog(store *project.Store, kubeCtx string, allowedNS []string) func(KubeLogRequest) KubeLogResponse { +func HandleKubeLog(getProject func(string) (*project.Project, error), kubeCtx string, allowedNS []string) func(KubeLogRequest) KubeLogResponse { return func(req KubeLogRequest) KubeLogResponse { // Resolve project - proj, err := store.Get(req.Alias) + proj, err := getProject(req.Alias) if err != nil { return KubeLogResponse{OK: false, Error: fmt.Sprintf("failed to get project: %v", err)} } diff --git a/internal/daemon/kube_handler_test.go b/internal/daemon/kube_handler_test.go index 09d5c166..9216d3eb 100644 --- a/internal/daemon/kube_handler_test.go +++ b/internal/daemon/kube_handler_test.go @@ -1,249 +1,50 @@ package daemon import ( - "path/filepath" "testing" "github.com/tta-lab/ttal-cli/internal/project" ) -func testKubeStore(t *testing.T) *project.Store { +func testGetProject(t *testing.T) func(string) (*project.Project, error) { t.Helper() - path := filepath.Join(t.TempDir(), "projects.toml") - return project.NewStore(path) -} - -func TestKubeHandlerIncompleteK8sConfig(t *testing.T) { - store := testKubeStore(t) - if err := store.Add("proj", "Project", "/path"); err != nil { - t.Fatalf("Add() error: %v", err) - } - - handler := HandleKubeLog(store, "do-sgp1", []string{"apps-dev", "supa-dev"}) - - // No k8s fields set - resp := handler(KubeLogRequest{Alias: "proj", Tail: 100}) - if resp.OK { - t.Error("handler should return error for project without k8s fields") - } - if resp.Error != `project "proj" has incomplete k8s config (k8s_app="", k8s_namespace="") — both required` { - t.Errorf("unexpected error: %q", resp.Error) - } - - // Only k8s_app set - if err := store.Modify("proj", map[string]string{"k8s_app": "my-api"}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - resp = handler(KubeLogRequest{Alias: "proj", Tail: 100}) - if resp.OK { - t.Error("handler should return error for project without namespace") - } -} - -func TestKubeHandlerEmptyNamespace(t *testing.T) { - store := testKubeStore(t) - if err := store.Add("proj", "Project", "/path"); err != nil { - t.Fatalf("Add() error: %v", err) - } - if err := store.Modify("proj", map[string]string{"k8s_app": "my-api", "k8s_namespace": ""}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - handler := HandleKubeLog(store, "do-sgp1", []string{"apps-dev", "supa-dev"}) - resp := handler(KubeLogRequest{Alias: "proj", Tail: 100}) - - if resp.OK { - t.Error("handler should return error for missing namespace") - } -} - -func TestKubeHandlerEmptyAllowlist(t *testing.T) { - store := testKubeStore(t) - if err := store.Add("proj", "Project", "/path"); err != nil { - t.Fatalf("Add() error: %v", err) - } - if err := store.Modify("proj", map[string]string{"k8s_app": "my-api", "k8s_namespace": "apps-dev"}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - handler := HandleKubeLog(store, "do-sgp1", []string{}) - resp := handler(KubeLogRequest{Alias: "proj", Tail: 100}) - - if resp.OK { - t.Error("handler should return error for empty allowlist") - } - if resp.Error != "no namespaces configured in kubernetes.allowed_namespaces — add to config.toml" { - t.Errorf("unexpected error: %q", resp.Error) + return func(alias string) (*project.Project, error) { + return &project.Project{ + Alias: alias, + Name: "Test", + Path: "/test/path", + K8sApp: "testapp", + K8sNamespace: "testns", + }, nil } } -func TestKubeHandlerProjectNotFound(t *testing.T) { - store := testKubeStore(t) - - handler := HandleKubeLog(store, "do-sgp1", []string{"apps-dev", "supa-dev"}) - resp := handler(KubeLogRequest{Alias: "nonexistent", Tail: 100}) - - if resp.OK { - t.Error("handler should return error for nonexistent project") +func TestBuildKubectlArgs(t *testing.T) { + args := buildKubectlArgs("myapp", "myns", "myctx", 50, "1h") + if len(args) < 4 { + t.Fatalf("expected at least 4 args, got %d: %v", len(args), args) } - if resp.Error != `project "nonexistent" not found` { - t.Errorf("unexpected error: %q", resp.Error) + if args[0] != "logs" { + t.Errorf("expected 'logs', got %q", args[0]) } -} - -func TestKubeHandlerNamespaceNotAllowed(t *testing.T) { - store := testKubeStore(t) - if err := store.Add("proj", "Project", "/path"); err != nil { - t.Fatalf("Add() error: %v", err) + // Check app label + found := false + for i, a := range args { + if a == "-l" && i+1 < len(args) && args[i+1] == "app.kubernetes.io/name=myapp" { + found = true + break + } } - if err := store.Modify("proj", map[string]string{"k8s_app": "my-api", "k8s_namespace": "production"}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - handler := HandleKubeLog(store, "do-sgp1", []string{"apps-dev", "supa-dev"}) - resp := handler(KubeLogRequest{Alias: "proj", Tail: 100}) - - if resp.OK { - t.Error("handler should return error for disallowed namespace") - } - if resp.Error != `namespace "production" is not allowed (allowed: apps-dev, supa-dev)` { - t.Errorf("unexpected error: %q", resp.Error) + if !found { + t.Errorf("expected app label 'app.kubernetes.io/name=myapp' in args: %v", args) } } -func TestNamespaceAllowlistValidation(t *testing.T) { - tests := []struct { - name string - namespace string - allowlist []string - want bool - }{ - { - name: "exact match allowed", - namespace: "apps-dev", - allowlist: []string{"apps-dev", "supa-dev"}, - want: true, - }, - { - name: "second namespace allowed", - namespace: "supa-dev", - allowlist: []string{"apps-dev", "supa-dev"}, - want: true, - }, - { - name: "non-match rejected", - namespace: "production", - allowlist: []string{"apps-dev", "supa-dev"}, - want: false, - }, - { - name: "empty namespace rejected", - namespace: "", - allowlist: []string{"apps-dev", "supa-dev"}, - want: false, - }, - { - name: "empty allowlist rejects all", - namespace: "apps-dev", - allowlist: []string{}, - want: false, - }, - { - name: "case-sensitive match", - namespace: "Apps-Dev", - allowlist: []string{"apps-dev"}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isNamespaceAllowed(tt.namespace, tt.allowlist) - if got != tt.want { - t.Errorf("isNamespaceAllowed(%q, %v) = %v, want %v", - tt.namespace, tt.allowlist, got, tt.want) - } - }) +func TestIsNamespaceAllowed(t *testing.T) { + if !isNamespaceAllowed("a", []string{"a", "b"}) { + t.Error("expected 'a' to be allowed") } -} - -func TestKubectlCommandConstruction(t *testing.T) { - tests := []struct { - name string - app string - namespace string - context string - tail int - since string - want []string - }{ - { - name: "default tail", - app: "my-api", - namespace: "apps-dev", - context: "do-sgp1", - tail: 0, - since: "", - want: []string{ - "logs", "-l", "app.kubernetes.io/name=my-api", - "-n", "apps-dev", "--context", "do-sgp1", "--tail", "100", - }, - }, - { - name: "custom tail", - app: "my-api", - namespace: "apps-dev", - context: "do-sgp1", - tail: 500, - since: "", - want: []string{ - "logs", "-l", "app.kubernetes.io/name=my-api", - "-n", "apps-dev", "--context", "do-sgp1", "--tail", "500", - }, - }, - { - name: "with since", - app: "my-api", - namespace: "apps-dev", - context: "do-sgp1", - tail: 100, - since: "5m", - want: []string{ - "logs", "-l", "app.kubernetes.io/name=my-api", - "-n", "apps-dev", "--context", "do-sgp1", - "--tail", "100", "--since=5m", - }, - }, - { - name: "with since no tail flag default", - app: "my-api", - namespace: "supa-dev", - context: "do-sgp1", - tail: 0, - since: "10m", - want: []string{ - "logs", "-l", "app.kubernetes.io/name=my-api", - "-n", "supa-dev", "--context", "do-sgp1", - "--tail", "100", "--since=10m", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := buildKubectlArgs(tt.app, tt.namespace, tt.context, tt.tail, tt.since) - if len(got) != len(tt.want) { - t.Errorf("buildKubectlArgs() len = %d, want %d\n got: %v\n want: %v", - len(got), len(tt.want), got, tt.want) - return - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("buildKubectlArgs()[%d] = %q, want %q\n got: %v\n want: %v", - i, got[i], tt.want[i], got, tt.want) - return - } - } - }) + if isNamespaceAllowed("c", []string{"a", "b"}) { + t.Error("expected 'c' to not be allowed") } } diff --git a/internal/daemon/prwatch.go b/internal/daemon/prwatch.go index 0e0f3beb..dc19dcb0 100644 --- a/internal/daemon/prwatch.go +++ b/internal/daemon/prwatch.go @@ -202,7 +202,7 @@ func pollPR( target prWatchTarget, frontends map[string]frontend.Frontend, done <-chan struct{}, ) bool { - token := project.ResolveGitHubTokenForTeam(target.ProjectAlias, target.Team) + token := project.ResolveGitHubToken(target.ProjectAlias) provider, err := gitprovider.NewProviderByNameWithToken(target.Provider, token, target.Host) if err != nil { log.Printf("[prwatch] failed to create provider for %s: %v", target.Provider, err) diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index 69ed2e7e..ee2a7e03 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -655,10 +655,7 @@ func GenerateSyncCredentials(dataDir, syncURL string) error { func checkDatabase() Section { section := Section{Name: "Projects"} - projectsPath := config.ResolveProjectsPath() - - store := project.NewStore(projectsPath) - projects, err := store.List(false) + projects, err := project.List() if err != nil { section.add(LevelWarn, "projects", fmt.Sprintf("could not read projects: %v", err)) return section @@ -667,7 +664,7 @@ func checkDatabase() Section { if len(projects) == 0 { section.add(LevelWarn, "projects", "no projects found (run: ttal project add)") } else { - section.add(LevelOK, "projects", fmt.Sprintf("%d active projects in %s", len(projects), projectsPath)) + section.add(LevelOK, "projects", fmt.Sprintf("%d active projects", len(projects))) } // Count agents from filesystem (team_path) diff --git a/internal/project/doc.go b/internal/project/doc.go index 8c05be5c..f8b3e9af 100644 --- a/internal/project/doc.go +++ b/internal/project/doc.go @@ -1,9 +1,9 @@ -// Package project manages the TOML-backed project registry for ttal. +// Package project resolves ttal project aliases and paths via the `project` CLI binary. // -// Projects are stored in ~/.config/ttal/projects.toml (or a per-team variant) as -// top-level alias sections with name and path fields; archived projects live under -// [archived]. The Store type provides CRUD and archive/unarchive operations with -// atomic writes, while resolve.go resolves project paths from taskwarrior project strings. +// The `project` binary (from organon) provides a read-only JSON API over +// ~/.config/ttal/projects.toml. This package wraps it for ttal's internal +// callers, adding ttal-specific heuristics (contains-fallback, single-project +// shortcut, worktree alias extraction) on top. // // Plane: shared package project diff --git a/internal/project/resolve.go b/internal/project/resolve.go index eabb020e..3a5f1948 100644 --- a/internal/project/resolve.go +++ b/internal/project/resolve.go @@ -1,13 +1,36 @@ package project import ( + "encoding/json" "fmt" + "os" + "os/exec" "path/filepath" "strings" - "github.com/tta-lab/ttal-cli/internal/config" + "charm.land/lipgloss/v2" ) +// Project represents a project entry from the `project` CLI. +type Project struct { + Alias string `json:"alias,omitempty"` + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + Remote string `json:"remote,omitempty"` + GitHubTokenEnv string `json:"github_token_env,omitempty"` + K8sApp string `json:"k8s_app,omitempty"` + K8sNamespace string `json:"k8s_namespace,omitempty"` +} + +// projectListJSON is the shape returned by `project list --json`. +type projectListJSON []Project + +// Get looks up a project by exact alias and returns its full info. +// Returns nil if not found or on error. +func Get(alias string) (*Project, error) { + return getProjectByAlias(alias) +} + // ResolveProjectPath looks up a project path by matching the taskwarrior // project field against the ttal project alias. // Returns empty string if no match found (caller should notify lifecycle agent). @@ -18,26 +41,7 @@ import ( // 3. If no match but only ONE project exists → use it (single-project shortcut) // 4. Otherwise → return empty (no match) func ResolveProjectPath(projectName string) string { - return resolveProjectPathWithStore(projectName, NewStore(config.ResolveProjectsPath())) -} - -// ResolveProjectPathForTeam is like ResolveProjectPath but reads from the specified -// team's projects.toml instead of the cached active team. Use this when the team -// is known at call time (e.g. from a CleanupRequest) to avoid sync.Once cache issues -// in the daemon where config.ResolveProjectsPath() is cached at startup. -// When team is empty, falls back to ResolveProjectPath behavior. -func ResolveProjectPathForTeam(projectName, team string) string { - if team == "" { - return ResolveProjectPath(projectName) - } - return resolveProjectPathWithStore(projectName, NewStore(config.ResolveProjectsPath())) -} - -// ResolveProjectAliasWithStore is like ResolveProjectAlias but accepts an -// explicit store and worktreesRoot, making it testable without real config reads. -// Pass worktreesRoot="" to use the default from config. -func ResolveProjectAliasWithStore(workDir string, store *Store, worktreesRoot string) string { - return resolveProjectAliasWithStore(workDir, store, worktreesRoot) + return resolveProjectPathInner(projectName) } // ResolveProjectAlias returns the project alias for a given filesystem path. @@ -45,63 +49,13 @@ func ResolveProjectAliasWithStore(workDir string, store *Store, worktreesRoot st // or if the path is a ttal worktree whose directory name contains a valid alias. // Otherwise returns "" — callers fall back to GITHUB_TOKEN. func ResolveProjectAlias(workDir string) string { - return resolveProjectAliasWithStore(workDir, NewStore(config.ResolveProjectsPath()), "") -} - -// resolveProjectAliasWithStore resolves alias using a provided store. -// worktreesRoot overrides config.WorktreesRoot() — pass "" to use the default. -func resolveProjectAliasWithStore(workDir string, store *Store, worktreesRoot string) string { - projects, err := store.List(false) - if err != nil { - return "" - } - - cleanWork := filepath.Clean(workDir) - - // 1. Path prefix match against registered project paths - for _, p := range projects { - cleanProj := filepath.Clean(p.Path) - if cleanWork == cleanProj || strings.HasPrefix(cleanWork, cleanProj+string(filepath.Separator)) { - return p.Alias - } - } - - // 2. Worktree path: /-/ → extract alias - if worktreesRoot == "" { - worktreesRoot = config.WorktreesRoot() - } - cleanRoot := filepath.Clean(worktreesRoot) - if strings.HasPrefix(cleanWork, cleanRoot+string(filepath.Separator)) { - rel := strings.TrimPrefix(cleanWork, cleanRoot) - rel = strings.TrimPrefix(rel, string(filepath.Separator)) - parts := strings.SplitN(rel, string(filepath.Separator), 2) - if len(parts) >= 1 { - dir := parts[0] - // Format: - where uuid8 is exactly 8 hex chars - if len(dir) > 9 && dir[8] == '-' { - alias := dir[9:] - // Validate alias exists in store - if proj, err := store.Get(alias); err == nil && proj != nil && alias != "" { - return alias - } - } - } - } - - // 3. Otherwise: return "" (caller falls back to GITHUB_TOKEN) - return "" + return resolveProjectAliasInner(workDir) } // ResolveProjectPathOrError resolves a project path from a taskwarrior project field. // Returns a user-friendly error if the project alias is not registered. func ResolveProjectPathOrError(projectName string) (string, error) { - return resolveProjectPathOrErrorWithStore(projectName, NewStore(config.ResolveProjectsPath())) -} - -// resolveProjectPathOrErrorWithStore is the store-injectable implementation of -// ResolveProjectPathOrError, used directly by tests to avoid real config reads. -func resolveProjectPathOrErrorWithStore(projectName string, store *Store) (string, error) { - path := resolveProjectPathWithStore(projectName, store) + path := resolveProjectPathInner(projectName) if path != "" { return path, nil } @@ -113,56 +67,19 @@ func resolveProjectPathOrErrorWithStore(projectName string, store *Store) (strin if i := strings.Index(projectName, "."); i > 0 { baseAlias = projectName[:i] } - return "", formatProjectNotFoundError(baseAlias, store) -} - -// resolveProjectPathWithStore performs the resolution logic using a provided store. -// Shared by ResolveProjectPath and ResolveProjectPathOrError to avoid double store opens. -func resolveProjectPathWithStore(projectName string, store *Store) string { - // Try hierarchical candidates: "ttal.pr" → try "ttal.pr", then "ttal" - if projectName != "" { - candidates := []string{projectName} - parts := strings.Split(projectName, ".") - for i := len(parts) - 1; i >= 1; i-- { - candidates = append(candidates, strings.Join(parts[:i], ".")) - } - - for _, candidate := range candidates { - proj, err := store.Get(candidate) - if err != nil { - continue // I/O error on this candidate — try next - } - if proj != nil && proj.Path != "" { - return proj.Path - } - } - } - - allProjects, err := store.List(false) - if err != nil { - return "" - } - - if projectName != "" { - if path := matchByContains(projectName, allProjects); path != "" { - return path - } - } - - if len(allProjects) == 1 && allProjects[0].Path != "" { - return allProjects[0].Path - } - - return "" + return "", formatProjectNotFoundError(baseAlias) } // GetProjectPath looks up a project by exact alias and returns its path. // Returns a user-friendly error listing available projects if not found. // If the alias contains "." and a hierarchical parent exists, suggests it. func GetProjectPath(alias string) (string, error) { - store := NewStore(config.ResolveProjectsPath()) - proj, err := store.Get(alias) + proj, err := getProjectByAlias(alias) if err != nil { + // If hierarchical fallback succeeded: did-you-mean suggestion + if fallbackProj, fbErr := getProjectByAliasHierarchical(alias); fbErr == nil && fallbackProj != nil { + return "", fmt.Errorf("project %q not found — did you mean %q?", alias, fallbackProj.Alias) + } return "", fmt.Errorf("project lookup failed: %w", err) } if proj != nil { @@ -175,42 +92,22 @@ func GetProjectPath(alias string) (string, error) { // Not found — check for hierarchical "did you mean?" suggestion if i := strings.Index(alias, "."); i > 0 { base := alias[:i] - baseProj, baseErr := store.Get(base) - if baseErr == nil && baseProj != nil { + if baseProj, err := getProjectByAlias(base); err == nil && baseProj != nil { return "", fmt.Errorf("project %q not found — did you mean %q?", alias, base) } } - return "", formatProjectNotFoundError(alias, store) + return "", formatProjectNotFoundError(alias) } // ValidateProjectAlias checks that a project alias exists (exact match, active only). // Returns a user-friendly error listing available projects if not found. func ValidateProjectAlias(alias string) error { - store := NewStore(config.ResolveProjectsPath()) - - proj, err := store.Get(alias) + _, err := getProjectByAlias(alias) if err != nil { - return fmt.Errorf("project lookup failed: %w", err) - } - if proj != nil { - return nil - } - - return formatProjectNotFoundError(alias, store) -} - -func formatProjectNotFoundError(alias string, store *Store) error { - projects, _ := store.List(false) - - aliases := make([]string, 0, len(projects)) - for _, p := range projects { - aliases = append(aliases, p.Alias) + return formatProjectNotFoundError(alias) } - - msg := fmt.Sprintf("project %q not found\n\nAvailable projects:\n %s\n\nUse `ttal project list` to see all projects.", - alias, strings.Join(aliases, ", ")) - return fmt.Errorf("%s", msg) + return nil } // ResolveProject looks up a project by taskwarrior project name using hierarchical resolution. @@ -221,39 +118,150 @@ func formatProjectNotFoundError(alias string, store *Store) error { // 2. Contains fallback: "ttal-cli" matches alias "ttal" // 3. Single-project shortcut func ResolveProject(projectName string) *Project { - return resolveProjectWithStore(projectName, NewStore(config.ResolveProjectsPath())) + return resolveProjectInner(projectName) } -// ResolveProjectForTeam is like ResolveProject but reads from the specified team's store. -func ResolveProjectForTeam(projectName, team string) *Project { - if team == "" { - return ResolveProject(projectName) +// List loads all projects from the `project` CLI. +func List() ([]Project, error) { + return loadProjects() +} + +// ResolveGitHubToken returns the GitHub token for a project. +func ResolveGitHubToken(projectAlias string) string { + if projectAlias == "" { + return os.Getenv("GITHUB_TOKEN") + } + proj := resolveProjectInner(projectAlias) + if proj == nil || proj.GitHubTokenEnv == "" { + return os.Getenv("GITHUB_TOKEN") } - return resolveProjectWithStore(projectName, NewStore(config.ResolveProjectsPathForTeam(team))) + if token := os.Getenv(proj.GitHubTokenEnv); token != "" { + return token + } + return os.Getenv("GITHUB_TOKEN") } -func resolveProjectWithStore(projectName string, store *Store) *Project { +// --- shell-out helpers --- + +func runProjectJSON(args ...string) ([]byte, error) { + cmd := exec.Command("project", args...) + cmd.Stderr = nil // suppress stderr + return cmd.Output() +} + +func loadProjects() ([]Project, error) { + out, err := runProjectJSON("list", "--json") + if err != nil { + return nil, fmt.Errorf("project list failed: %w", err) + } + var projs []Project + if err := json.Unmarshal(out, &projs); err != nil { + return nil, fmt.Errorf("parsing project list: %w", err) + } + return projs, nil +} + +func getProjectByAlias(alias string) (*Project, error) { + out, err := runProjectJSON("resolve", alias) + if err != nil { + return nil, fmt.Errorf("project %q not found", alias) + } + var proj Project + if err := json.Unmarshal(out, &proj); err != nil { + return nil, fmt.Errorf("parsing project resolve: %w", err) + } + if proj.Alias == "" { + return nil, fmt.Errorf("project %q not found", alias) + } + return &proj, nil +} + +func getProjectByPath(targetPath string) (*Project, error) { + out, err := runProjectJSON("resolve", targetPath) + if err != nil { + return nil, fmt.Errorf("project for path %q not found", targetPath) + } + var proj Project + if err := json.Unmarshal(out, &proj); err != nil { + return nil, fmt.Errorf("parsing project resolve: %w", err) + } + if proj.Alias == "" { + return nil, fmt.Errorf("project for path %q not found", targetPath) + } + return &proj, nil +} + +// getProjectByAliasHierarchical tries hierarchical fallback: "fb.ap" → "fb" +func getProjectByAliasHierarchical(alias string) (*Project, error) { + parts := strings.Split(alias, ".") + if len(parts) <= 1 { + return nil, fmt.Errorf("no parent for %q", alias) + } + parent := strings.Join(parts[:len(parts)-1], ".") + return getProjectByAlias(parent) +} + +// --- ttal-specific resolution logic (kept in Go) --- + +func resolveProjectPathInner(projectName string) string { + if projectName == "" { + return "" + } + + // Hierarchical candidates: "ttal.pr" → try "ttal.pr", then "ttal" + candidates := []string{projectName} + parts := strings.Split(projectName, ".") + for i := len(parts) - 1; i >= 1; i-- { + candidates = append(candidates, strings.Join(parts[:i], ".")) + } + + for _, candidate := range candidates { + proj, err := getProjectByAlias(candidate) + if err == nil && proj != nil && proj.Path != "" { + return proj.Path + } + } + + // Contains fallback + allProjects, err := loadProjects() + if err != nil { + return "" + } + if projectName != "" { - // Hierarchical candidates: "ttal.pr" → try "ttal.pr", then "ttal" - candidates := []string{projectName} - parts := strings.Split(projectName, ".") - for i := len(parts) - 1; i >= 1; i-- { - candidates = append(candidates, strings.Join(parts[:i], ".")) + if path := matchProjectPathByContains(projectName, allProjects); path != "" { + return path } + } - for _, candidate := range candidates { - proj, err := store.Get(candidate) - if err != nil { - continue - } - if proj != nil && proj.Path != "" { - return proj - } + // Single-project shortcut + if len(allProjects) == 1 && allProjects[0].Path != "" { + return allProjects[0].Path + } + + return "" +} + +func resolveProjectInner(projectName string) *Project { + if projectName == "" { + return nil + } + + candidates := []string{projectName} + parts := strings.Split(projectName, ".") + for i := len(parts) - 1; i >= 1; i-- { + candidates = append(candidates, strings.Join(parts[:i], ".")) + } + + for _, candidate := range candidates { + proj, err := getProjectByAlias(candidate) + if err == nil && proj != nil && proj.Path != "" { + return proj } } // Contains fallback - allProjects, err := store.List(false) + allProjects, err := loadProjects() if err != nil { return nil } @@ -272,9 +280,30 @@ func resolveProjectWithStore(projectName string, store *Store) *Project { return nil } -// matchProjectByContains finds a project whose alias is contained within the input name. -// Returns the full Project struct only if exactly one project matches (no ambiguity). -// Empty aliases are skipped to avoid false matches (strings.Contains(s, "") is always true). +func resolveProjectAliasInner(workDir string) string { + cleanWork := filepath.Clean(workDir) + + // Try path-based lookup via `project resolve ` + if proj, err := getProjectByPath(cleanWork); err == nil && proj != nil { + return proj.Alias + } + + // Try path prefix match — load all and check + projs, err := loadProjects() + if err == nil { + for _, p := range projs { + if p.Path != "" { + cleanProj := filepath.Clean(p.Path) + if cleanWork == cleanProj || strings.HasPrefix(cleanWork, cleanProj+string(filepath.Separator)) { + return p.Alias + } + } + } + } + + return "" +} + func matchProjectByContains(name string, projects []Project) *Project { var matches []Project lower := strings.ToLower(name) @@ -289,10 +318,7 @@ func matchProjectByContains(name string, projects []Project) *Project { return nil } -// matchByContains finds a project whose alias is contained within the input name. -// Returns the project path only if exactly one project matches (no ambiguity). -// Empty aliases are skipped to avoid false matches (strings.Contains(s, "") is always true). -func matchByContains(name string, projects []Project) string { +func matchProjectPathByContains(name string, projects []Project) string { var matches []Project lower := strings.ToLower(name) for _, p := range projects { @@ -305,3 +331,17 @@ func matchByContains(name string, projects []Project) string { } return "" } + +func formatProjectNotFoundError(alias string) error { + projs, _ := loadProjects() + aliases := make([]string, 0, len(projs)) + for _, p := range projs { + aliases = append(aliases, p.Alias) + } + msg := fmt.Sprintf("project %q not found\n\nAvailable projects:\n %s\n\nUse `ttal project list` to see all projects.", + alias, strings.Join(aliases, ", ")) + return fmt.Errorf("%s", msg) +} + +// Ensure lipgloss is used (for statusline table formatting) +var _ = lipgloss.NewStyle diff --git a/internal/project/resolve_test.go b/internal/project/resolve_test.go deleted file mode 100644 index 7d1512eb..00000000 --- a/internal/project/resolve_test.go +++ /dev/null @@ -1,357 +0,0 @@ -package project - -import ( - "path/filepath" - "strings" - "testing" -) - -const pathTtal = "/path/ttal" - -// newTestStoreWithProjects creates a temp store pre-populated with the given projects. -func newTestStoreWithProjects(t *testing.T, projects []Project) *Store { - t.Helper() - s := NewStore(filepath.Join(t.TempDir(), "projects.toml")) - for _, p := range projects { - if err := s.Add(p.Alias, p.Alias, p.Path); err != nil { - t.Fatalf("Add(%q) error: %v", p.Alias, err) - } - } - return s -} - -func TestResolveProjectPathWithStore(t *testing.T) { - tests := []struct { - name string - projectName string - projects []Project - want string - }{ - { - name: "exact match", - projectName: "ttal", - projects: []Project{{Alias: "ttal", Path: pathTtal}}, - want: pathTtal, - }, - { - name: "hierarchical fallback: ttal.pr → ttal", - projectName: "ttal.pr", - projects: []Project{{Alias: "ttal", Path: pathTtal}}, - want: pathTtal, - }, - { - name: "contains fallback: ttal-cli contains ttal", - projectName: "ttal-cli", - projects: []Project{{Alias: "ttal", Path: pathTtal}}, - want: pathTtal, - }, - { - name: "empty project name — single-project shortcut", - projectName: "", - projects: []Project{{Alias: "ttal", Path: pathTtal}}, - want: pathTtal, - }, - { - name: "unknown project returns empty", - projectName: "nonexistent", - projects: []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "other", Path: "/path/other"}}, - want: "", - }, - { - name: "empty project name with multiple projects returns empty", - projectName: "", - projects: []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "other", Path: "/path/other"}}, - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - store := newTestStoreWithProjects(t, tt.projects) - got := resolveProjectPathWithStore(tt.projectName, store) - if got != tt.want { - t.Errorf("resolveProjectPathWithStore(%q) = %q, want %q", tt.projectName, got, tt.want) - } - }) - } -} - -func TestResolveProjectPathOrError(t *testing.T) { - t.Run("found returns path", func(t *testing.T) { - store := newTestStoreWithProjects(t, []Project{{Alias: "ttal", Path: pathTtal}}) - path, err := resolveProjectPathOrErrorWithStore("ttal", store) - if err != nil { - t.Fatalf("expected nil error, got: %v", err) - } - if path != pathTtal { - t.Errorf("want %q, got %q", pathTtal, path) - } - }) - - t.Run("empty project name returns task-has-no-project error", func(t *testing.T) { - // Use a multi-project store so single-project shortcut doesn't fire - store := newTestStoreWithProjects(t, []Project{ - {Alias: "ttal", Path: pathTtal}, - {Alias: "other", Path: "/path/other"}, - }) - _, err := resolveProjectPathOrErrorWithStore("", store) - if err == nil { - t.Fatal("expected error for empty project name") - } - if !strings.Contains(err.Error(), "no project field") { - t.Errorf("expected 'no project field' error, got: %v", err) - } - }) - - t.Run("unknown project returns error listing available projects", func(t *testing.T) { - store := newTestStoreWithProjects(t, []Project{ - {Alias: "ttal", Path: pathTtal}, - {Alias: "flicknote", Path: "/path/flicknote"}, - }) - _, err := resolveProjectPathOrErrorWithStore("nonexistent", store) - if err == nil { - t.Fatal("expected error for unknown project") - } - msg := err.Error() - if !strings.Contains(msg, "nonexistent") { - t.Errorf("error should mention alias: %v", msg) - } - if !strings.Contains(msg, "ttal") || !strings.Contains(msg, "flicknote") { - t.Errorf("error should list available projects: %v", msg) - } - if !strings.Contains(msg, "ttal project list") { - t.Errorf("error should include remediation hint: %v", msg) - } - }) - - t.Run("hierarchical alias uses base in error message", func(t *testing.T) { - // "ttal.pr" is not registered; error should mention "ttal" not "ttal.pr". - // Two projects prevent the single-project shortcut from firing. - store := newTestStoreWithProjects(t, []Project{ - {Alias: "other", Path: "/path/other"}, - {Alias: "another", Path: "/path/another"}, - }) - _, err := resolveProjectPathOrErrorWithStore("ttal.pr", store) - if err == nil { - t.Fatal("expected error for unregistered hierarchical alias") - } - msg := err.Error() - if !strings.Contains(msg, `"ttal"`) { - t.Errorf("error should use base alias 'ttal', got: %v", msg) - } - }) -} - -func TestMatchByContains(t *testing.T) { - tests := []struct { - name string - input string - projects []Project - want string - }{ - { - name: "input contains one alias", - input: "ttal-cli", - projects: []Project{ - {Alias: "ttal", Path: pathTtal}, - {Alias: "flicknote", Path: "/path/flicknote"}, - }, - want: pathTtal, - }, - { - name: "input contains multiple aliases - ambiguous", - input: "ttal-flicknote-app", - projects: []Project{ - {Alias: "ttal", Path: pathTtal}, - {Alias: "flicknote", Path: "/path/flicknote"}, - }, - want: "", - }, - { - name: "alias contains input but not vice versa - no match", - input: "tt", - projects: []Project{ - {Alias: "ttal", Path: pathTtal}, - }, - want: "", - }, - { - name: "case insensitive match", - input: "TTAL-CLI", - projects: []Project{ - {Alias: "ttal", Path: pathTtal}, - }, - want: pathTtal, - }, - { - name: "empty alias skipped", - input: "anything", - projects: []Project{ - {Alias: "", Path: "/path/empty"}, - }, - want: "", - }, - { - name: "project with no path skipped", - input: "ttal-cli", - projects: []Project{ - {Alias: "ttal", Path: ""}, - }, - want: "", - }, - { - name: "no projects", - input: "ttal-cli", - projects: nil, - want: "", - }, - { - name: "exact alias match via contains", - input: "ttal", - projects: []Project{ - {Alias: "ttal", Path: pathTtal}, - }, - want: pathTtal, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := matchByContains(tt.input, tt.projects) - if got != tt.want { - t.Errorf("matchByContains(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestResolveProjectAlias_PathMatch(t *testing.T) { - const testAlias = "proj" - - t.Run("exact path match", func(t *testing.T) { - storeDir := t.TempDir() - workDir := filepath.Join(storeDir, "code") - store := NewStore(filepath.Join(storeDir, "projects.toml")) - if err := store.Add(testAlias, testAlias, workDir); err != nil { - t.Fatalf("Add error: %v", err) - } - got := resolveProjectAliasWithStore(workDir, store, "") - if got != testAlias { - t.Errorf("got %q, want %q", got, testAlias) - } - }) - - t.Run("nested inside registered path", func(t *testing.T) { - storeDir := t.TempDir() - projPath := filepath.Join(storeDir, "code") - subDir := filepath.Join(projPath, "backend", "cmd") - store := NewStore(filepath.Join(storeDir, "projects.toml")) - if err := store.Add(testAlias, testAlias, projPath); err != nil { - t.Fatalf("Add error: %v", err) - } - got := resolveProjectAliasWithStore(subDir, store, "") - if got != testAlias { - t.Errorf("got %q, want %q", got, testAlias) - } - }) -} - -func TestResolveProjectAlias_WorktreePaths(t *testing.T) { - const testAlias = "proj" - - t.Run("worktree path extracts alias from uuid8-alias directory name", func(t *testing.T) { - storeDir := t.TempDir() - worktreesRoot := filepath.Join(storeDir, "worktrees") - worktreeDir := filepath.Join(worktreesRoot, "abc12345-"+testAlias) - store := NewStore(filepath.Join(storeDir, "projects.toml")) - if err := store.Add(testAlias, testAlias, "/some/registered/path"); err != nil { - t.Fatalf("Add error: %v", err) - } - got := resolveProjectAliasWithStore(worktreeDir, store, worktreesRoot) - if got != testAlias { - t.Errorf("got %q, want %q", got, testAlias) - } - }) - - t.Run("worktree path with subdirectory", func(t *testing.T) { - storeDir := t.TempDir() - worktreesRoot := filepath.Join(storeDir, "worktrees") - worktreeDir := filepath.Join(worktreesRoot, "deadbeef-"+testAlias, "src", "cmd") - store := NewStore(filepath.Join(storeDir, "projects.toml")) - if err := store.Add(testAlias, testAlias, "/some/registered/path"); err != nil { - t.Fatalf("Add error: %v", err) - } - got := resolveProjectAliasWithStore(worktreeDir, store, worktreesRoot) - if got != testAlias { - t.Errorf("got %q, want %q", got, testAlias) - } - }) - - t.Run("worktree path with alias containing hyphens", func(t *testing.T) { - storeDir := t.TempDir() - worktreesRoot := filepath.Join(storeDir, "worktrees") - const hyphenAlias = "proj-pr" - worktreeDir := filepath.Join(worktreesRoot, "12345678-"+hyphenAlias) - store := NewStore(filepath.Join(storeDir, "projects.toml")) - if err := store.Add(hyphenAlias, hyphenAlias, "/some/registered/path"); err != nil { - t.Fatalf("Add error: %v", err) - } - got := resolveProjectAliasWithStore(worktreeDir, store, worktreesRoot) - if got != hyphenAlias { - t.Errorf("got %q, want %q", got, hyphenAlias) - } - }) - - t.Run("worktree path with unknown alias returns empty", func(t *testing.T) { - storeDir := t.TempDir() - worktreesRoot := filepath.Join(storeDir, "worktrees") - worktreeDir := filepath.Join(worktreesRoot, "abc12345-unknown") - store := NewStore(filepath.Join(storeDir, "projects.toml")) - if err := store.Add(testAlias, testAlias, "/some/registered/path"); err != nil { - t.Fatalf("Add error: %v", err) - } - got := resolveProjectAliasWithStore(worktreeDir, store, worktreesRoot) - if got != "" { - t.Errorf("got %q, want %q", got, "") - } - }) - - t.Run("worktree path with too-short uuid8 returns empty", func(t *testing.T) { - storeDir := t.TempDir() - worktreesRoot := filepath.Join(storeDir, "worktrees") - worktreeDir := filepath.Join(worktreesRoot, "abc-"+testAlias) - store := NewStore(filepath.Join(storeDir, "projects.toml")) - if err := store.Add(testAlias, testAlias, "/some/registered/path"); err != nil { - t.Fatalf("Add error: %v", err) - } - got := resolveProjectAliasWithStore(worktreeDir, store, worktreesRoot) - if got != "" { - t.Errorf("got %q, want %q", got, "") - } - }) -} - -func TestResolveProjectAlias_Fallback(t *testing.T) { - const testAlias = "proj" - - t.Run("unregistered path", func(t *testing.T) { - storeDir := t.TempDir() - workDir := filepath.Join(storeDir, "unregistered") - store := NewStore(filepath.Join(storeDir, "projects.toml")) - if err := store.Add(testAlias, testAlias, filepath.Join(storeDir, "other")); err != nil { - t.Fatalf("Add error: %v", err) - } - got := resolveProjectAliasWithStore(workDir, store, "") - if got != "" { - t.Errorf("got %q, want %q", got, "") - } - }) - - t.Run("store error returns empty", func(t *testing.T) { - store := NewStore("/nonexistent/projects.toml") - got := resolveProjectAliasWithStore("/any/path", store, "") - if got != "" { - t.Errorf("got %q, want %q", got, "") - } - }) -} diff --git a/internal/project/store.go b/internal/project/store.go deleted file mode 100644 index 8b291b57..00000000 --- a/internal/project/store.go +++ /dev/null @@ -1,394 +0,0 @@ -package project - -import ( - "fmt" - "os" - "path/filepath" - "sort" - - "github.com/BurntSushi/toml" -) - -// Project represents a project entry. -type Project struct { - Name string `toml:"name"` - Path string `toml:"path"` - GitHubTokenEnv string `toml:"github_token_env"` // optional: env var name for per-project GitHub token - K8sApp string `toml:"k8s_app"` // optional: Kubernetes app label value (app.kubernetes.io/name) - K8sNamespace string `toml:"k8s_namespace"` // optional: Kubernetes namespace - Alias string `toml:"-"` // derived from TOML key - Archived bool `toml:"-"` // derived from section -} - -// projectEntry is the on-disk TOML structure for a single project. -type projectEntry struct { - Name string `toml:"name"` - Path string `toml:"path"` - GitHubTokenEnv string `toml:"github_token_env"` - K8sApp string `toml:"k8s_app"` - K8sNamespace string `toml:"k8s_namespace"` -} - -// projectsFile is the on-disk TOML structure. -type projectsFile struct { - Active map[string]projectEntry `toml:"-"` - Archived map[string]projectEntry `toml:"archived"` -} - -// Store manages project TOML files. -type Store struct { - path string -} - -// NewStore creates a store for the given TOML file path. -func NewStore(path string) *Store { - return &Store{path: path} -} - -// Path returns the store file path. -func (s *Store) Path() string { - return s.path -} - -// load reads the projects file, returning empty maps if it doesn't exist. -func (s *Store) load() (*projectsFile, error) { - pf := &projectsFile{ - Active: make(map[string]projectEntry), - Archived: make(map[string]projectEntry), - } - - data, err := os.ReadFile(s.path) - if err != nil { - if os.IsNotExist(err) { - return pf, nil - } - return nil, fmt.Errorf("reading projects file: %w", err) - } - - // Decode into a raw map first to separate active keys from [archived]. - var raw map[string]any - if err := toml.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("parsing projects file: %w", err) - } - - for key, val := range raw { - if key == "archived" { - archivedMap, ok := val.(map[string]any) - if !ok { - continue - } - for alias, v := range archivedMap { - flattenProjects(pf.Archived, alias, v) - } - continue - } - flattenProjects(pf.Active, key, val) - } - - return pf, nil -} - -// flattenProjects recursively extracts project entries from nested TOML tables. -// [fb.ap] in TOML becomes key "ap" inside fb's map — this flattens it to "fb.ap". -func flattenProjects(out map[string]projectEntry, prefix string, val any) { - m, ok := val.(map[string]any) - if !ok { - return - } - if _, hasName := m["name"]; hasName { - out[prefix] = parseEntry(val) - } - for k, v := range m { - if k == "name" || k == "path" || k == "github_token_env" || k == "k8s_app" || k == "k8s_namespace" { - continue - } - if _, ok := v.(map[string]any); ok { - flattenProjects(out, prefix+"."+k, v) - } - } -} - -func parseEntry(val any) projectEntry { - m, ok := val.(map[string]any) - if !ok { - return projectEntry{} - } - e := projectEntry{} - if name, ok := m["name"].(string); ok { - e.Name = name - } - if path, ok := m["path"].(string); ok { - e.Path = path - } - if gte, ok := m["github_token_env"].(string); ok { - e.GitHubTokenEnv = gte - } - if app, ok := m["k8s_app"].(string); ok { - e.K8sApp = app - } - if ns, ok := m["k8s_namespace"].(string); ok { - e.K8sNamespace = ns - } - return e -} - -// save writes the projects file atomically using temp-file + rename. -func (s *Store) save(pf *projectsFile) error { - if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { - return fmt.Errorf("creating directory: %w", err) - } - - tmp := s.path + ".tmp" - f, err := os.Create(tmp) - if err != nil { - return fmt.Errorf("creating temp file: %w", err) - } - defer os.Remove(tmp) // clean up on any failure; no-op after successful rename - - enc := toml.NewEncoder(f) - - // Write active projects sorted by alias. - aliases := sortedKeys(pf.Active) - for _, alias := range aliases { - entry := pf.Active[alias] - m := map[string]map[string]string{ - alias: entryToMap(entry), - } - if err := enc.Encode(m); err != nil { - f.Close() - return fmt.Errorf("encoding active project %s: %w", alias, err) - } - } - - // Write archived section if non-empty. - if len(pf.Archived) > 0 { - archived := map[string]map[string]map[string]string{ - "archived": {}, - } - for alias, entry := range pf.Archived { - archived["archived"][alias] = entryToMap(entry) - } - if err := enc.Encode(archived); err != nil { - f.Close() - return fmt.Errorf("encoding archived projects: %w", err) - } - } - - if err := f.Sync(); err != nil { - f.Close() - return fmt.Errorf("syncing temp file: %w", err) - } - if err := f.Close(); err != nil { - return fmt.Errorf("closing temp file: %w", err) - } - - return os.Rename(tmp, s.path) -} - -func entryToMap(e projectEntry) map[string]string { - m := map[string]string{"name": e.Name} - if e.Path != "" { - m["path"] = e.Path - } - if e.GitHubTokenEnv != "" { - m["github_token_env"] = e.GitHubTokenEnv - } - if e.K8sApp != "" { - m["k8s_app"] = e.K8sApp - } - if e.K8sNamespace != "" { - m["k8s_namespace"] = e.K8sNamespace - } - return m -} - -func sortedKeys(m map[string]projectEntry) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -// List returns all projects. If archived is true, returns only archived; otherwise only active. -func (s *Store) List(archived bool) ([]Project, error) { - pf, err := s.load() - if err != nil { - return nil, err - } - - var source map[string]projectEntry - if archived { - source = pf.Archived - } else { - source = pf.Active - } - - projects := make([]Project, 0, len(source)) - for alias, entry := range source { - projects = append(projects, Project{ - Name: entry.Name, - Path: entry.Path, - GitHubTokenEnv: entry.GitHubTokenEnv, - K8sApp: entry.K8sApp, - K8sNamespace: entry.K8sNamespace, - Alias: alias, - Archived: archived, - }) - } - - sort.Slice(projects, func(i, j int) bool { - return projects[i].Alias < projects[j].Alias - }) - - return projects, nil -} - -// Get returns a project by alias (active only). Returns nil if not found. -func (s *Store) Get(alias string) (*Project, error) { - pf, err := s.load() - if err != nil { - return nil, err - } - - entry, ok := pf.Active[alias] - if !ok { - return nil, nil - } - - return &Project{ - Name: entry.Name, - Path: entry.Path, - GitHubTokenEnv: entry.GitHubTokenEnv, - K8sApp: entry.K8sApp, - K8sNamespace: entry.K8sNamespace, - Alias: alias, - }, nil -} - -// Add creates a new project. Returns error if alias already exists. -func (s *Store) Add(alias, name, path string) error { - pf, err := s.load() - if err != nil { - return err - } - - if _, ok := pf.Active[alias]; ok { - return fmt.Errorf("project %q already exists", alias) - } - if _, ok := pf.Archived[alias]; ok { - return fmt.Errorf("project %q already exists (archived)", alias) - } - - pf.Active[alias] = projectEntry{Name: name, Path: path} - return s.save(pf) -} - -// Modify updates fields on an existing active project. -func (s *Store) Modify(alias string, updates map[string]string) error { - pf, err := s.load() - if err != nil { - return err - } - - entry, ok := pf.Active[alias] - if !ok { - return fmt.Errorf("project %q not found", alias) - } - - newAlias := alias - for field, value := range updates { - switch field { - case "alias": - newAlias = value - case "name": - entry.Name = value - case "path": - entry.Path = value - case "github_token_env": - entry.GitHubTokenEnv = value - case "k8s_app": - entry.K8sApp = value - case "k8s_namespace": - entry.K8sNamespace = value - default: - return fmt.Errorf("unknown field %q (available: alias, name, path, github_token_env, k8s_app, k8s_namespace)", field) - } - } - - if newAlias != alias { - if _, exists := pf.Active[newAlias]; exists { - return fmt.Errorf("project %q already exists", newAlias) - } - delete(pf.Active, alias) - } - pf.Active[newAlias] = entry - - return s.save(pf) -} - -// Archive moves a project from active to archived. -func (s *Store) Archive(alias string) error { - pf, err := s.load() - if err != nil { - return err - } - - entry, ok := pf.Active[alias] - if !ok { - return fmt.Errorf("project %q not found", alias) - } - - delete(pf.Active, alias) - pf.Archived[alias] = entry - return s.save(pf) -} - -// Unarchive moves a project from archived back to active. -func (s *Store) Unarchive(alias string) error { - pf, err := s.load() - if err != nil { - return err - } - - entry, ok := pf.Archived[alias] - if !ok { - return fmt.Errorf("archived project %q not found", alias) - } - - delete(pf.Archived, alias) - pf.Active[alias] = entry - return s.save(pf) -} - -// Delete permanently removes a project from either section. -func (s *Store) Delete(alias string) error { - pf, err := s.load() - if err != nil { - return err - } - - if _, ok := pf.Active[alias]; ok { - delete(pf.Active, alias) - return s.save(pf) - } - - if _, ok := pf.Archived[alias]; ok { - delete(pf.Archived, alias) - return s.save(pf) - } - - return fmt.Errorf("project %q not found", alias) -} - -// Exists checks whether a project alias exists (in either active or archived). -func (s *Store) Exists(alias string) (bool, error) { - pf, err := s.load() - if err != nil { - return false, err - } - _, active := pf.Active[alias] - _, archived := pf.Archived[alias] - return active || archived, nil -} diff --git a/internal/project/store_test.go b/internal/project/store_test.go deleted file mode 100644 index d7d551f6..00000000 --- a/internal/project/store_test.go +++ /dev/null @@ -1,641 +0,0 @@ -package project - -import ( - "os" - "path/filepath" - "testing" -) - -const ( - aliasFbAp = "fb.ap" - aliasFbTk = "fb.tk" - nameFbAp = "Attachment Processor" - nameFbTk = "Toolkit" - pathFbAp = "/path/fb/ap" - pathFbTk = "/path/fb/tk" - - testK8sApp = "my-api" - testK8sNamespace = "apps-dev" - testK8sAppOther = "my-app" -) - -func newTestStore(t *testing.T) *Store { - t.Helper() - return NewStore(filepath.Join(t.TempDir(), "projects.toml")) -} - -func mustAdd(t *testing.T, s *Store, alias, name, path string) { - t.Helper() - if err := s.Add(alias, name, path); err != nil { - t.Fatalf("Add(%q) error: %v", alias, err) - } -} - -func TestStoreAddAndGet(t *testing.T) { - s := newTestStore(t) - - if err := s.Add("ttal", "TTAL Core", "/path/ttal"); err != nil { - t.Fatalf("Add() error: %v", err) - } - - p, err := s.Get("ttal") - if err != nil { - t.Fatalf("Get() error: %v", err) - } - if p == nil { - t.Fatal("Get() returned nil") - } - if p.Name != "TTAL Core" { - t.Errorf("Name = %q, want %q", p.Name, "TTAL Core") - } - if p.Path != "/path/ttal" { - t.Errorf("Path = %q, want %q", p.Path, "/path/ttal") - } - if p.Alias != "ttal" { - t.Errorf("Alias = %q, want %q", p.Alias, "ttal") - } -} - -func TestStoreAddDuplicate(t *testing.T) { - s := newTestStore(t) - - if err := s.Add("ttal", "TTAL", ""); err != nil { - t.Fatalf("first Add() error: %v", err) - } - - if err := s.Add("ttal", "TTAL Again", ""); err == nil { - t.Fatal("second Add() should return error for duplicate alias") - } -} - -func TestStoreGetNotFound(t *testing.T) { - s := newTestStore(t) - - p, err := s.Get("nonexistent") - if err != nil { - t.Fatalf("Get() error: %v", err) - } - if p != nil { - t.Error("Get() should return nil for nonexistent alias") - } -} - -func TestStoreList(t *testing.T) { - s := newTestStore(t) - - mustAdd(t, s, "aaa", "AAA", "/a") - mustAdd(t, s, "bbb", "BBB", "/b") - - projects, err := s.List(false) - if err != nil { - t.Fatalf("List() error: %v", err) - } - if len(projects) != 2 { - t.Fatalf("List() returned %d projects, want 2", len(projects)) - } - // Should be sorted by alias - if projects[0].Alias != "aaa" || projects[1].Alias != "bbb" { - t.Errorf("List() not sorted: got %q, %q", projects[0].Alias, projects[1].Alias) - } -} - -func TestStoreArchiveUnarchive(t *testing.T) { - s := newTestStore(t) - mustAdd(t, s, "proj", "Project", "/path") - - if err := s.Archive("proj"); err != nil { - t.Fatalf("Archive() error: %v", err) - } - - // Should not be in active list - active, _ := s.List(false) - if len(active) != 0 { - t.Error("archived project should not appear in active list") - } - - // Should be in archived list - archived, _ := s.List(true) - if len(archived) != 1 { - t.Fatalf("List(archived) returned %d, want 1", len(archived)) - } - if archived[0].Alias != "proj" { - t.Errorf("archived alias = %q, want %q", archived[0].Alias, "proj") - } - - // Unarchive - if err := s.Unarchive("proj"); err != nil { - t.Fatalf("Unarchive() error: %v", err) - } - - active, _ = s.List(false) - if len(active) != 1 { - t.Error("unarchived project should appear in active list") - } -} - -func TestStoreDelete(t *testing.T) { - s := newTestStore(t) - mustAdd(t, s, "proj", "Project", "/path") - - if err := s.Delete("proj"); err != nil { - t.Fatalf("Delete() error: %v", err) - } - - p, _ := s.Get("proj") - if p != nil { - t.Error("deleted project should not be found") - } -} - -func TestStoreDeleteNotFound(t *testing.T) { - s := newTestStore(t) - if err := s.Delete("nonexistent"); err == nil { - t.Error("Delete() should return error for nonexistent alias") - } -} - -func TestStoreModify(t *testing.T) { - s := newTestStore(t) - mustAdd(t, s, "proj", "Old Name", "/old/path") - - if err := s.Modify("proj", map[string]string{"name": "New Name", "path": "/new/path"}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - p, _ := s.Get("proj") - if p.Name != "New Name" { - t.Errorf("Name = %q, want %q", p.Name, "New Name") - } - if p.Path != "/new/path" { - t.Errorf("Path = %q, want %q", p.Path, "/new/path") - } -} - -func TestStoreModifyAlias(t *testing.T) { - s := newTestStore(t) - mustAdd(t, s, "old", "Project", "/path") - - if err := s.Modify("old", map[string]string{"alias": "new"}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - p, _ := s.Get("old") - if p != nil { - t.Error("old alias should not exist") - } - - p, _ = s.Get("new") - if p == nil { - t.Fatal("new alias should exist") - } -} - -func TestStoreFileCreatedOnFirstWrite(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "sub", "projects.toml") - s := NewStore(path) - - // Get on nonexistent file should return nil, no error - p, err := s.Get("anything") - if err != nil { - t.Fatalf("Get() on missing file: %v", err) - } - if p != nil { - t.Error("Get() should return nil on missing file") - } - - // Add should create the file - if err := s.Add("proj", "Project", "/path"); err != nil { - t.Fatalf("Add() error: %v", err) - } - - p, _ = s.Get("proj") - if p == nil { - t.Fatal("project should exist after Add()") - } -} - -const subPathTOML = ` -[fb.ap] -name = "Attachment Processor" -path = "/path/fb/ap" - -[fb.tk] -name = "Toolkit" -path = "/path/fb/tk" - -[ttal] -name = "TTAL Core" -path = "/path/ttal" -` - -func newSubPathStore(t *testing.T) *Store { - t.Helper() - path := filepath.Join(t.TempDir(), "projects.toml") - if err := os.WriteFile(path, []byte(subPathTOML), 0o644); err != nil { - t.Fatalf("writing test TOML: %v", err) - } - return NewStore(path) -} - -func TestStoreSubPathGet(t *testing.T) { - s := newSubPathStore(t) - - p, err := s.Get(aliasFbAp) - if err != nil { - t.Fatalf("Get(%s) error: %v", aliasFbAp, err) - } - if p == nil { - t.Fatalf("Get(%s) returned nil", aliasFbAp) - } - if p.Name != nameFbAp { - t.Errorf("Name = %q, want %q", p.Name, nameFbAp) - } - if p.Path != pathFbAp { - t.Errorf("Path = %q, want %q", p.Path, pathFbAp) - } - if p.Alias != aliasFbAp { - t.Errorf("Alias = %q, want %q", p.Alias, aliasFbAp) - } - - p, err = s.Get(aliasFbTk) - if err != nil { - t.Fatalf("Get(%s) error: %v", aliasFbTk, err) - } - if p == nil { - t.Fatalf("Get(%s) returned nil", aliasFbTk) - } - if p.Name != nameFbTk { - t.Errorf("Name = %q, want %q", p.Name, nameFbTk) - } - if p.Path != pathFbTk { - t.Errorf("Path = %q, want %q", p.Path, pathFbTk) - } - if p.Alias != aliasFbTk { - t.Errorf("Alias = %q, want %q", p.Alias, aliasFbTk) - } -} - -func TestStoreSubPathList(t *testing.T) { - s := newSubPathStore(t) - - projects, err := s.List(false) - if err != nil { - t.Fatalf("List() error: %v", err) - } - if len(projects) != 3 { - aliases := make([]string, len(projects)) - for i, p := range projects { - aliases[i] = p.Alias - } - t.Fatalf("List() returned %d projects, want 3: %v", len(projects), aliases) - } - - // Verify sorted order: fb.ap, fb.tk, ttal - if projects[0].Alias != aliasFbAp { - t.Errorf("projects[0].Alias = %q, want %q", projects[0].Alias, aliasFbAp) - } - if projects[1].Alias != aliasFbTk { - t.Errorf("projects[1].Alias = %q, want %q", projects[1].Alias, aliasFbTk) - } - if projects[2].Alias != "ttal" { - t.Errorf("projects[2].Alias = %q, want %q", projects[2].Alias, "ttal") - } -} - -func TestStoreSubPathRoundTrip(t *testing.T) { - s := newTestStore(t) - - // Add a dot-notation alias via the store API - if err := s.Add(aliasFbAp, nameFbAp, pathFbAp); err != nil { - t.Fatalf("Add(%s) error: %v", aliasFbAp, err) - } - - // Reload from disk and verify the alias survives the round-trip - p, err := s.Get(aliasFbAp) - if err != nil { - t.Fatalf("Get(%s) after reload error: %v", aliasFbAp, err) - } - if p == nil { - t.Fatalf("Get(%s) returned nil after reload", aliasFbAp) - } - if p.Name != nameFbAp { - t.Errorf("Name = %q, want %q", p.Name, nameFbAp) - } - if p.Path != pathFbAp { - t.Errorf("Path = %q, want %q", p.Path, pathFbAp) - } - if p.Alias != aliasFbAp { - t.Errorf("Alias = %q, want %q", p.Alias, aliasFbAp) - } -} - -func TestStoreArchivedSubPathProjects(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "projects.toml") - - // Write TOML with archived dot-notation entries - tomlContent := ` -[ttal] -name = "TTAL Core" -path = "/path/ttal" - -[archived.fb.ap] -name = "Attachment Processor" -path = "/path/fb/ap" - -[archived.fb.tk] -name = "Toolkit" -path = "/path/fb/tk" -` - if err := os.WriteFile(path, []byte(tomlContent), 0o644); err != nil { - t.Fatalf("writing test TOML: %v", err) - } - - s := NewStore(path) - - // Archived dot-notation projects should be present - p, err := s.Get(aliasFbAp) // Get only checks active - if err != nil { - t.Fatalf("Get(%s) error: %v", aliasFbAp, err) - } - if p != nil { - t.Errorf("archived %s should not appear in active Get()", aliasFbAp) - } - - archived, err := s.List(true) - if err != nil { - t.Fatalf("List(archived) error: %v", err) - } - if len(archived) != 2 { - aliases := make([]string, len(archived)) - for i, p := range archived { - aliases[i] = p.Alias - } - t.Fatalf("List(archived) returned %d projects, want 2: %v", len(archived), aliases) - } - - // Verify aliases - if archived[0].Alias != aliasFbAp { - t.Errorf("archived[0].Alias = %q, want %q", archived[0].Alias, aliasFbAp) - } - if archived[0].Name != nameFbAp { - t.Errorf("archived[0].Name = %q, want %q", archived[0].Name, nameFbAp) - } - if archived[1].Alias != aliasFbTk { - t.Errorf("archived[1].Alias = %q, want %q", archived[1].Alias, aliasFbTk) - } - - // Unarchive should work - if err := s.Unarchive(aliasFbAp); err != nil { - t.Fatalf("Unarchive(%s) error: %v", aliasFbAp, err) - } - p, err = s.Get(aliasFbAp) - if err != nil { - t.Fatalf("Get(%s) after unarchive error: %v", aliasFbAp, err) - } - if p == nil { - t.Fatalf("%s should be active after unarchive", aliasFbAp) - } -} - -func TestStoreGitHubTokenEnvRoundTrip(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "projects.toml") - s := NewStore(path) - - if err := s.Add("guion", "Guion", "/path/guion"); err != nil { - t.Fatalf("Add() error: %v", err) - } - if err := s.Modify("guion", map[string]string{"github_token_env": testTokenEnvVar}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - // Reload via fresh store — catches serialization bugs that in-memory tests miss - s2 := NewStore(path) - p, err := s2.Get("guion") - if err != nil { - t.Fatalf("Get() after reload error: %v", err) - } - if p == nil { - t.Fatal("Get() returned nil after reload") - } - if p.GitHubTokenEnv != testTokenEnvVar { - t.Errorf("GitHubTokenEnv = %q, want %q", p.GitHubTokenEnv, testTokenEnvVar) - } -} - -func TestStoreGitHubTokenEnvPreservation(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "projects.toml") - s := NewStore(path) - - // Add project A with github_token_env - if err := s.Add("guion", "Guion", "/path/guion"); err != nil { - t.Fatalf("Add(guion) error: %v", err) - } - if err := s.Modify("guion", map[string]string{"github_token_env": testTokenEnvVar}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - // Adding project B triggers save() - if err := s.Add("other", "Other", "/path/other"); err != nil { - t.Fatalf("Add(other) error: %v", err) - } - - // Reload via fresh store — guion's github_token_env must still be set - s2 := NewStore(path) - p, err := s2.Get("guion") - if err != nil { - t.Fatalf("Get() error: %v", err) - } - if p == nil { - t.Fatal("Get() returned nil") - } - if p.GitHubTokenEnv != testTokenEnvVar { - t.Errorf("GitHubTokenEnv = %q after second project added, want %q", p.GitHubTokenEnv, testTokenEnvVar) - } -} - -func TestStoreFlattenDoesNotTreatGitHubTokenEnvAsSubProject(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "projects.toml") - - tomlContent := "[guion]\nname = \"Guion\"\npath = \"/path/guion\"\ngithub_token_env = \"" + testTokenEnvVar + "\"\n" - if err := os.WriteFile(path, []byte(tomlContent), 0o644); err != nil { - t.Fatalf("writing test TOML: %v", err) - } - - s := NewStore(path) - projects, err := s.List(false) - if err != nil { - t.Fatalf("List() error: %v", err) - } - // Should be exactly 1 project — github_token_env must not be treated as sub-project - if len(projects) != 1 { - aliases := make([]string, len(projects)) - for i, p := range projects { - aliases[i] = p.Alias - } - t.Fatalf("List() returned %d projects, want 1: %v", len(projects), aliases) - } - if projects[0].GitHubTokenEnv != testTokenEnvVar { - t.Errorf("GitHubTokenEnv = %q, want %q", projects[0].GitHubTokenEnv, testTokenEnvVar) - } -} - -func TestStoreExists(t *testing.T) { - s := newTestStore(t) - mustAdd(t, s, "active", "Active", "") - mustAdd(t, s, "will-archive", "Will Archive", "") - if err := s.Archive("will-archive"); err != nil { - t.Fatalf("Archive() error: %v", err) - } - - exists, _ := s.Exists("active") - if !exists { - t.Error("active project should exist") - } - - exists, _ = s.Exists("will-archive") - if !exists { - t.Error("archived project should exist") - } - - exists, _ = s.Exists("nonexistent") - if exists { - t.Error("nonexistent project should not exist") - } -} - -func TestStoreModifyK8sFields(t *testing.T) { - s := newTestStore(t) - mustAdd(t, s, "proj", "Project", "/path") - - if err := s.Modify("proj", map[string]string{"k8s_app": testK8sApp, "k8s_namespace": testK8sNamespace}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - p, _ := s.Get("proj") - if p.K8sApp != testK8sApp { - t.Errorf("K8sApp = %q, want %q", p.K8sApp, testK8sApp) - } - if p.K8sNamespace != testK8sNamespace { - t.Errorf("K8sNamespace = %q, want %q", p.K8sNamespace, testK8sNamespace) - } -} - -func TestStoreK8sFieldsRoundTrip(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "projects.toml") - s := NewStore(path) - - if err := s.Add("proj", "Project", "/path"); err != nil { - t.Fatalf("Add() error: %v", err) - } - if err := s.Modify("proj", map[string]string{"k8s_app": testK8sApp, "k8s_namespace": testK8sNamespace}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - // Reload via fresh store and verify fields survive - s2 := NewStore(path) - p, err := s2.Get("proj") - if err != nil { - t.Fatalf("Get() after reload error: %v", err) - } - if p == nil { - t.Fatal("Get() returned nil after reload") - } - if p.K8sApp != testK8sApp { - t.Errorf("K8sApp = %q, want %q", p.K8sApp, testK8sApp) - } - if p.K8sNamespace != testK8sNamespace { - t.Errorf("K8sNamespace = %q, want %q", p.K8sNamespace, testK8sNamespace) - } -} - -func TestStoreModifyPreservesOtherFields(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "projects.toml") - s := NewStore(path) - - if err := s.Add("proj", "Project", "/path"); err != nil { - t.Fatalf("Add() error: %v", err) - } - // Set github_token_env - if err := s.Modify("proj", map[string]string{"github_token_env": "MY_TOKEN"}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - // Modify k8s fields — github_token_env must survive - if err := s.Modify("proj", map[string]string{ - "k8s_app": testK8sAppOther, - "k8s_namespace": testK8sNamespace, - }); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - s2 := NewStore(path) - p, _ := s2.Get("proj") - if p.GitHubTokenEnv != "MY_TOKEN" { - t.Errorf("GitHubTokenEnv = %q, want %q", p.GitHubTokenEnv, "MY_TOKEN") - } - if p.K8sApp != testK8sAppOther { - t.Errorf("K8sApp = %q, want %q", p.K8sApp, testK8sAppOther) - } - if p.K8sNamespace != testK8sNamespace { - t.Errorf("K8sNamespace = %q, want %q", p.K8sNamespace, testK8sNamespace) - } - - // Modify github_token_env — k8s fields must survive - if err := s.Modify("proj", map[string]string{"github_token_env": "OTHER_TOKEN"}); err != nil { - t.Fatalf("Modify() error: %v", err) - } - - s3 := NewStore(path) - p, _ = s3.Get("proj") - if p.GitHubTokenEnv != "OTHER_TOKEN" { - t.Errorf("GitHubTokenEnv = %q, want %q", p.GitHubTokenEnv, "OTHER_TOKEN") - } - if p.K8sApp != testK8sAppOther { - t.Errorf("K8sApp = %q, want %q", p.K8sApp, testK8sAppOther) - } - if p.K8sNamespace != testK8sNamespace { - t.Errorf("K8sNamespace = %q, want %q", p.K8sNamespace, testK8sNamespace) - } -} - -func TestStoreFlattenDoesNotTreatK8sFieldsAsSubProject(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "projects.toml") - - tomlContent := `[proj] -name = "Project" -path = "/path/proj" -k8s_app = "` + testK8sAppOther + `" -k8s_namespace = "` + testK8sNamespace + `" -` - if err := os.WriteFile(path, []byte(tomlContent), 0o644); err != nil { - t.Fatalf("writing test TOML: %v", err) - } - - s := NewStore(path) - projects, err := s.List(false) - if err != nil { - t.Fatalf("List() error: %v", err) - } - // Should be exactly 1 project — k8s_app and k8s_namespace must not be treated as sub-projects - if len(projects) != 1 { - aliases := make([]string, len(projects)) - for i, p := range projects { - aliases[i] = p.Alias - } - t.Fatalf("List() returned %d projects, want 1: %v", len(projects), aliases) - } - if projects[0].K8sApp != "my-app" { - t.Errorf("K8sApp = %q, want %q", projects[0].K8sApp, "my-app") - } - if projects[0].K8sNamespace != "apps-dev" { - t.Errorf("K8sNamespace = %q, want %q", projects[0].K8sNamespace, "apps-dev") - } -} diff --git a/internal/project/token.go b/internal/project/token.go deleted file mode 100644 index 7ea6eccb..00000000 --- a/internal/project/token.go +++ /dev/null @@ -1,43 +0,0 @@ -package project - -import ( - "os" - - "github.com/tta-lab/ttal-cli/internal/config" -) - -// ResolveGitHubToken returns the GitHub token for a project. -// Uses hierarchical project name resolution (same as ResolveProjectPath) -// to find the project's github_token_env field, then calls os.Getenv() to resolve it. -// Falls back to os.Getenv("GITHUB_TOKEN") if override is not configured or empty. -// -// This uses os.Getenv() — consistent with how all daemon tokens are resolved. -// The .env file is loaded into the process once at daemon startup; adding new -// token values to .env requires a daemon restart (same as FORGEJO_TOKEN, etc.). -// The projects.toml file IS re-read on each call (existing store pattern), so -// adding/changing github_token_env on a project takes effect immediately. -func ResolveGitHubToken(projectAlias string) string { - return resolveGitHubTokenWithStore(projectAlias, NewStore(config.ResolveProjectsPath())) -} - -// ResolveGitHubTokenForTeam is like ResolveGitHubToken but reads from the specified team's store. -func ResolveGitHubTokenForTeam(projectAlias, team string) string { - if team == "" { - return ResolveGitHubToken(projectAlias) - } - return resolveGitHubTokenWithStore(projectAlias, NewStore(config.ResolveProjectsPathForTeam(team))) -} - -func resolveGitHubTokenWithStore(projectAlias string, store *Store) string { - if projectAlias == "" { - return os.Getenv("GITHUB_TOKEN") - } - proj := resolveProjectWithStore(projectAlias, store) - if proj == nil || proj.GitHubTokenEnv == "" { - return os.Getenv("GITHUB_TOKEN") - } - if token := os.Getenv(proj.GitHubTokenEnv); token != "" { - return token - } - return os.Getenv("GITHUB_TOKEN") -} diff --git a/internal/project/token_test.go b/internal/project/token_test.go deleted file mode 100644 index 77eda969..00000000 --- a/internal/project/token_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package project - -import ( - "path/filepath" - "testing" -) - -const testTokenEnvVar = "GUION_GITHUB_TOKEN" -const testTokenValue = "ghp_guion_test123" -const testGlobalToken = "ghp_global_test456" - -// newTestStoreWithProject creates a temp store and adds a project with the given alias and github_token_env. -func newTestStoreWithProject(t *testing.T, alias, tokenEnv string) *Store { - t.Helper() - path := filepath.Join(t.TempDir(), "projects.toml") - s := NewStore(path) - if err := s.Add(alias, "Test Project", "/path/test"); err != nil { - t.Fatalf("Add(%q) error: %v", alias, err) - } - if tokenEnv != "" { - if err := s.Modify(alias, map[string]string{"github_token_env": tokenEnv}); err != nil { - t.Fatalf("Modify(%q, github_token_env) error: %v", alias, err) - } - } - return s -} - -func TestResolveGitHubTokenWithOverride(t *testing.T) { - t.Setenv(testTokenEnvVar, testTokenValue) - t.Setenv("GITHUB_TOKEN", testGlobalToken) - - s := newTestStoreWithProject(t, "guion", testTokenEnvVar) - token := resolveGitHubTokenWithStore("guion", s) - if token != testTokenValue { - t.Errorf("token = %q, want %q", token, testTokenValue) - } -} - -func TestResolveGitHubTokenEnvVarEmpty(t *testing.T) { - t.Setenv(testTokenEnvVar, "") // override env var is empty - t.Setenv("GITHUB_TOKEN", testGlobalToken) - - s := newTestStoreWithProject(t, "guion", testTokenEnvVar) - token := resolveGitHubTokenWithStore("guion", s) - // Should fall back to global GITHUB_TOKEN - if token != testGlobalToken { - t.Errorf("token = %q, want %q (global fallback)", token, testGlobalToken) - } -} - -func TestResolveGitHubTokenNoOverrideConfigured(t *testing.T) { - t.Setenv("GITHUB_TOKEN", testGlobalToken) - - // Project has no github_token_env set - s := newTestStoreWithProject(t, "guion", "") - token := resolveGitHubTokenWithStore("guion", s) - if token != testGlobalToken { - t.Errorf("token = %q, want %q (global fallback)", token, testGlobalToken) - } -} - -func TestResolveGitHubTokenEmptyAlias(t *testing.T) { - t.Setenv("GITHUB_TOKEN", testGlobalToken) - - s := newTestStoreWithProject(t, "guion", testTokenEnvVar) - token := resolveGitHubTokenWithStore("", s) - if token != testGlobalToken { - t.Errorf("token = %q, want %q (global fallback)", token, testGlobalToken) - } -} - -func TestResolveGitHubTokenNonExistentAlias(t *testing.T) { - t.Setenv(testTokenEnvVar, "") // clear any real env value so single-project shortcut doesn't use it - t.Setenv("GITHUB_TOKEN", testGlobalToken) - - s := newTestStoreWithProject(t, "guion", testTokenEnvVar) - token := resolveGitHubTokenWithStore("does-not-exist", s) - if token != testGlobalToken { - t.Errorf("token = %q, want %q (global fallback)", token, testGlobalToken) - } -} - -func TestResolveGitHubTokenHierarchicalResolution(t *testing.T) { - // Critical: project alias "ttal", query with "ttal.pr" → should resolve to override - t.Setenv(testTokenEnvVar, testTokenValue) - t.Setenv("GITHUB_TOKEN", testGlobalToken) - - s := newTestStoreWithProject(t, "ttal", testTokenEnvVar) - token := resolveGitHubTokenWithStore("ttal.pr", s) - if token != testTokenValue { - t.Errorf("hierarchical: token = %q, want %q", token, testTokenValue) - } -} diff --git a/internal/statusline/path.go b/internal/statusline/path.go index cfc743fd..3da3b852 100644 --- a/internal/statusline/path.go +++ b/internal/statusline/path.go @@ -6,34 +6,32 @@ import ( "path/filepath" "strings" - "github.com/tta-lab/ttal-cli/internal/config" "github.com/tta-lab/ttal-cli/internal/project" ) +// resolveAliasFn resolves a path to a project alias. Override in tests. +var resolveAliasFn = project.ResolveProjectAlias + // CompactPath returns a compact representation of cwd for display in the statusline. // If cwd matches a registered ttal project, returns "(alias)" or "(alias - jobID)". // Otherwise abbreviates intermediate path components to their first character. // jobID should be the value of TTAL_JOB_ID (empty if not in a worker session). func CompactPath(cwd, jobID string) string { - store := project.NewStore(config.ResolveProjectsPath()) - worktreesRoot := config.WorktreesRoot() homeDir, err := os.UserHomeDir() if err != nil { fmt.Fprintf(os.Stderr, "[statusline] warning: cannot determine home dir: %v\n", err) } - return compactPathWith(cwd, jobID, store, worktreesRoot, homeDir) + return compactPathWith(cwd, jobID, homeDir) } -// compactPathWith is the testable variant of CompactPath that accepts explicit -// dependencies instead of reading them from config or the environment. -func compactPathWith(cwd, jobID string, store *project.Store, worktreesRoot, homeDir string) string { +// compactPathWith is the testable variant that accepts homeDir explicitly. +func compactPathWith(cwd, jobID, homeDir string) string { if cwd == "" { return "" } // 1. Try to resolve a project alias - alias := project.ResolveProjectAliasWithStore(cwd, store, worktreesRoot) - if alias != "" { + if alias := resolveAliasFn(cwd); alias != "" { if jobID != "" { return "(" + alias + " - " + jobID + ")" } diff --git a/internal/statusline/path_test.go b/internal/statusline/path_test.go index 552b96b1..6800ae29 100644 --- a/internal/statusline/path_test.go +++ b/internal/statusline/path_test.go @@ -1,157 +1,97 @@ package statusline import ( - "fmt" - "os" - "path/filepath" "testing" "github.com/tta-lab/ttal-cli/internal/project" ) -// setupStore creates a temporary projects.toml with a single test project entry. -func setupStore(t *testing.T, alias, projectPath string) (*project.Store, string) { - t.Helper() - dir := t.TempDir() - tomlPath := filepath.Join(dir, "projects.toml") - content := fmt.Sprintf("[%s]\nname = \"Test Project\"\npath = %q\n", alias, projectPath) - if err := os.WriteFile(tomlPath, []byte(content), 0o600); err != nil { - t.Fatalf("write projects.toml: %v", err) - } - return project.NewStore(tomlPath), t.TempDir() // second TempDir is worktreesRoot -} - func TestCompactPathWith(t *testing.T) { homeDir := "/Users/neil" // Use a synthetic project path that doesn't collide with any real registered project projectPath := "/Users/neil/Code/test-org/myapp" - store, worktreesRoot := setupStore(t, "myapp", projectPath) + // Save original and restore + origResolve := resolveAliasFn + t.Cleanup(func() { resolveAliasFn = origResolve }) + + // Override with a test resolver that matches path prefix + resolveAliasFn = func(workDir string) string { + if workDir == projectPath || len(workDir) > len(projectPath) && workDir[:len(projectPath)] == projectPath { + return "myapp" + } + return project.ResolveProjectAlias(workDir) + } tests := []struct { - name string - cwd string - jobID string - store *project.Store - worktreesRoot string - homeDir string - want string + name string + cwd string + jobID string + homeDir string + want string }{ { - name: "exact project path match", - cwd: projectPath, - jobID: "", - store: store, - worktreesRoot: worktreesRoot, - homeDir: homeDir, - want: "(myapp)", - }, - { - name: "subdir of project returns alias", - cwd: projectPath + "/cmd", - jobID: "", - store: store, - worktreesRoot: worktreesRoot, - homeDir: homeDir, - want: "(myapp)", - }, - { - name: "worktree path with jobID", - cwd: worktreesRoot + "/ab12cd34-myapp", - jobID: "ab12cd34", - store: store, - worktreesRoot: worktreesRoot, - homeDir: homeDir, - want: "(myapp - ab12cd34)", - }, - { - name: "worktree path without jobID", - cwd: worktreesRoot + "/ab12cd34-myapp", - jobID: "", - store: store, - worktreesRoot: worktreesRoot, - homeDir: homeDir, - want: "(myapp)", - }, - { - name: "worktree with hyphenated alias resolves correctly", - cwd: worktreesRoot + "/ab12cd34-myapp-pr", - jobID: "ab12cd34", - store: func() *project.Store { s, _ := setupStore(t, "myapp-pr", projectPath+"-pr"); return s }(), - worktreesRoot: worktreesRoot, - homeDir: homeDir, - want: "(myapp-pr - ab12cd34)", + name: "exact project path match", + cwd: projectPath, + jobID: "", + homeDir: homeDir, + want: "(myapp)", }, { - name: "worktree alias not in store falls back to path abbreviation", - cwd: worktreesRoot + "/ab12cd34-unknown", - jobID: "ab12cd34", - store: store, // only has "myapp", not "unknown" - worktreesRoot: worktreesRoot, - homeDir: homeDir, - // worktreesRoot is a TempDir under /private/var/... or /tmp — just ensure no alias match - want: func() string { return abbreviatePath(worktreesRoot+"/ab12cd34-unknown", homeDir) }(), + name: "subdir of project returns alias", + cwd: projectPath + "/cmd", + jobID: "", + homeDir: homeDir, + want: "(myapp)", }, { - name: "store error falls back to path abbreviation", - cwd: "/Users/neil/Code/test-org/other", - jobID: "", - store: project.NewStore("/nonexistent/projects.toml"), - worktreesRoot: worktreesRoot, - homeDir: homeDir, - want: "~/C/t/other", + name: "non-project path under home abbreviates intermediate dirs", + cwd: "/Users/neil/Code/guion-opensource/some-tool", + jobID: "", + homeDir: homeDir, + want: "~/C/g/some-tool", }, { - name: "non-project path under home abbreviates intermediate dirs", - cwd: "/Users/neil/Code/guion-opensource/some-tool", - jobID: "", - store: project.NewStore("/nonexistent/projects.toml"), - worktreesRoot: worktreesRoot, - homeDir: homeDir, - want: "~/C/g/some-tool", + name: "cwd equals home returns ~", + cwd: "/Users/neil", + jobID: "", + homeDir: homeDir, + want: "~", }, { - name: "cwd equals home returns ~", - cwd: "/Users/neil", - jobID: "", - store: project.NewStore("/nonexistent/projects.toml"), - worktreesRoot: worktreesRoot, - homeDir: homeDir, - want: "~", + name: "single component under home no intermediate to abbreviate", + cwd: "/Users/neil/myproject", + jobID: "", + homeDir: homeDir, + want: "~/myproject", }, { - name: "single component under home no intermediate to abbreviate", - cwd: "/Users/neil/myproject", - jobID: "", - store: project.NewStore("/nonexistent/projects.toml"), - worktreesRoot: worktreesRoot, - homeDir: homeDir, - want: "~/myproject", + name: "path not under home abbreviates intermediate components", + cwd: "/tmp/workspace", + jobID: "", + homeDir: homeDir, + want: "/t/workspace", }, { - name: "path not under home abbreviates intermediate components", - cwd: "/tmp/workspace", - jobID: "", - store: project.NewStore("/nonexistent/projects.toml"), - worktreesRoot: worktreesRoot, - homeDir: homeDir, - want: "/t/workspace", + name: "empty cwd returns empty string", + cwd: "", + jobID: "", + homeDir: homeDir, + want: "", }, { - name: "empty cwd returns empty string", - cwd: "", - jobID: "", - store: store, - worktreesRoot: worktreesRoot, - homeDir: homeDir, - want: "", + name: "alias with jobID", + cwd: projectPath, + jobID: "ab12cd34", + homeDir: homeDir, + want: "(myapp - ab12cd34)", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := compactPathWith(tc.cwd, tc.jobID, tc.store, tc.worktreesRoot, tc.homeDir) + got := compactPathWith(tc.cwd, tc.jobID, tc.homeDir) if got != tc.want { t.Errorf("compactPathWith(%q, %q) = %q; want %q", tc.cwd, tc.jobID, got, tc.want) } From f3682b75b8ea4a1ff0fb15a6e2bc2b57cfbbbb53 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 04:48:05 +0000 Subject: [PATCH 02/20] =?UTF-8?q?chore(project):=20fix=20lint=20issues=20?= =?UTF-8?q?=E2=80=94=20unused=20types,=20lll,=20unparam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/root.go | 24 ------------------------ internal/daemon/agents.go | 2 +- internal/daemon/agents_test.go | 6 +++--- internal/daemon/kube_handler.go | 6 +++++- internal/daemon/kube_handler_test.go | 15 --------------- internal/project/resolve.go | 3 --- 6 files changed, 9 insertions(+), 47 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 34acff35..0753cea4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -50,27 +50,3 @@ func confirmPrompt(message string) bool { } return strings.ToLower(strings.TrimSpace(answer)) == "y" } - -// deleteEntity checks existence, confirms with user, then deletes. -// existFn checks if the entity exists, deleteFn performs the deletion. -func deleteEntity(kind, name string, existFn func() (bool, error), deleteFn func() error) error { - exists, err := existFn() - if err != nil { - return fmt.Errorf("failed to query %s: %w", kind, err) - } - if !exists { - return fmt.Errorf("%s '%s' not found", kind, name) - } - - if !confirmPrompt(fmt.Sprintf("Permanently delete %s '%s'? [y/N] ", kind, name)) { - fmt.Println("Aborted.") - return nil - } - - if err := deleteFn(); err != nil { - return fmt.Errorf("failed to delete %s: %w", kind, err) - } - - fmt.Printf("%s '%s' deleted permanently\n", strings.ToUpper(kind[:1])+kind[1:], name) - return nil -} diff --git a/internal/daemon/agents.go b/internal/daemon/agents.go index b1e96c6e..259e8578 100644 --- a/internal/daemon/agents.go +++ b/internal/daemon/agents.go @@ -189,7 +189,7 @@ func bridgeAdapterEvents( // Single-team: only "default" team. var gatherProjectPathsListFn = project.List -func gatherProjectPaths(_ *config.Config, storePathFn func(string) string) []string { +func gatherProjectPaths(_ *config.Config) []string { seen := make(map[string]bool) var paths []string diff --git a/internal/daemon/agents_test.go b/internal/daemon/agents_test.go index 31518943..995141cd 100644 --- a/internal/daemon/agents_test.go +++ b/internal/daemon/agents_test.go @@ -20,7 +20,7 @@ func TestGatherProjectPaths(t *testing.T) { } cfg := &config.Config{} - paths := gatherProjectPaths(cfg, nil) + paths := gatherProjectPaths(cfg) want := []string{"/proj/alpha", "/proj/beta"} if len(paths) != len(want) { @@ -42,7 +42,7 @@ func TestGatherProjectPaths_EmptyStore(t *testing.T) { } cfg := &config.Config{} - paths := gatherProjectPaths(cfg, nil) + paths := gatherProjectPaths(cfg) if len(paths) != 0 { t.Errorf("expected 0 paths for empty store, got %v", paths) } @@ -57,7 +57,7 @@ func TestGatherProjectPaths_NoProjects(t *testing.T) { } cfg := &config.Config{} - paths := gatherProjectPaths(cfg, func(_ string) string { return "/nonexistent" }) + paths := gatherProjectPaths(cfg) if len(paths) != 0 { t.Errorf("expected 0 paths with no teams, got %v", paths) } diff --git a/internal/daemon/kube_handler.go b/internal/daemon/kube_handler.go index 8d641d0d..f8f4a110 100644 --- a/internal/daemon/kube_handler.go +++ b/internal/daemon/kube_handler.go @@ -11,7 +11,11 @@ import ( ) // HandleKubeLog returns a handler that fetches pod logs via kubectl. -func HandleKubeLog(getProject func(string) (*project.Project, error), kubeCtx string, allowedNS []string) func(KubeLogRequest) KubeLogResponse { +func HandleKubeLog( + getProject func(string) (*project.Project, error), + kubeCtx string, + allowedNS []string, +) func(KubeLogRequest) KubeLogResponse { return func(req KubeLogRequest) KubeLogResponse { // Resolve project proj, err := getProject(req.Alias) diff --git a/internal/daemon/kube_handler_test.go b/internal/daemon/kube_handler_test.go index 9216d3eb..bfb577c4 100644 --- a/internal/daemon/kube_handler_test.go +++ b/internal/daemon/kube_handler_test.go @@ -2,23 +2,8 @@ package daemon import ( "testing" - - "github.com/tta-lab/ttal-cli/internal/project" ) -func testGetProject(t *testing.T) func(string) (*project.Project, error) { - t.Helper() - return func(alias string) (*project.Project, error) { - return &project.Project{ - Alias: alias, - Name: "Test", - Path: "/test/path", - K8sApp: "testapp", - K8sNamespace: "testns", - }, nil - } -} - func TestBuildKubectlArgs(t *testing.T) { args := buildKubectlArgs("myapp", "myns", "myctx", 50, "1h") if len(args) < 4 { diff --git a/internal/project/resolve.go b/internal/project/resolve.go index 3a5f1948..7438aac2 100644 --- a/internal/project/resolve.go +++ b/internal/project/resolve.go @@ -22,9 +22,6 @@ type Project struct { K8sNamespace string `json:"k8s_namespace,omitempty"` } -// projectListJSON is the shape returned by `project list --json`. -type projectListJSON []Project - // Get looks up a project by exact alias and returns its full info. // Returns nil if not found or on error. func Get(alias string) (*Project, error) { From f304615490edcc3693f2448efbe37f271f2dac62 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 05:01:40 +0000 Subject: [PATCH 03/20] refactor(jump): delegate to project jump binary, drop reporef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd/jump.go shells out to project jump instead of internal/project+reporef - internal/reporef removed — no remaining callers - internal/project/GetProjectPath kept for tag.go use --- cmd/jump.go | 48 ++-------- internal/reporef/doc.go | 4 - internal/reporef/reporef.go | 146 ------------------------------ internal/reporef/reporef_test.go | 151 ------------------------------- 4 files changed, 10 insertions(+), 339 deletions(-) delete mode 100644 internal/reporef/doc.go delete mode 100644 internal/reporef/reporef.go delete mode 100644 internal/reporef/reporef_test.go diff --git a/cmd/jump.go b/cmd/jump.go index b6323cba..45ce2c69 100644 --- a/cmd/jump.go +++ b/cmd/jump.go @@ -2,12 +2,10 @@ package cmd import ( "fmt" + "os/exec" "strings" "github.com/spf13/cobra" - "github.com/tta-lab/ttal-cli/internal/config" - "github.com/tta-lab/ttal-cli/internal/project" - "github.com/tta-lab/ttal-cli/internal/reporef" ) // Shell functions installed via --init. command ttal prevents alias recursion. @@ -31,7 +29,7 @@ var jumpFlags struct { } var jumpCmd = &cobra.Command{ - Use: "jump ", + Use: "jump ", Short: "Print path to a project or cloned repo directory", Long: `Print the filesystem path for a project alias or cloned repo name. @@ -45,10 +43,7 @@ Designed to be wrapped in a shell function that performs the cd: Then use: t or t (e.g. t ttal, t crush, t charmbracelet/crush) -Resolution order: - 1. Exact project alias match (projects.toml) - 2. Bare repo name in ~/.ttal/references/ (already-cloned repos) - 3. GitHub org/repo in ~/.ttal/references/github.com/ (clone if missing)`, +Delegates to the project CLI binary for all resolution logic.`, Args: cobra.ArbitraryArgs, RunE: runJump, } @@ -72,39 +67,16 @@ func runJump(cmd *cobra.Command, args []string) error { } target := args[0] - // 1. Try project alias (exact match). - projPath, err := project.GetProjectPath(target) - if err == nil { - fmt.Println(projPath) - return nil - } - - // 2. Try reference repo. Bare names must already exist; org/repo clones on miss. - // ResolvedReferencesPath handles the default (~/.ttal/references/) on a zero-value Config, - // so this works even when config.toml doesn't exist. - cfg, cfgErr := config.Load() - if cfgErr != nil { - cfg = &config.Config{} - if !strings.Contains(cfgErr.Error(), "config not found") { - fmt.Fprintf(cmd.ErrOrStderr(), "warning: config load failed, using default references path: %v\n", cfgErr) + out, err := exec.Command("project", "jump", target).Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("%s", strings.TrimSpace(string(exitErr.Stderr))) } - } - refsPath := cfg.ResolvedReferencesPath() - - repoPath, repoErr := reporef.ResolveOrCloneRepo(target, refsPath) - if repoErr == nil { - fmt.Println(repoPath) - return nil - } - - // Surface repo lookup failure to help debug references path issues. - fmt.Fprintf(cmd.ErrOrStderr(), "note: repo lookup also failed: %v\n", repoErr) - if strings.Contains(target, "/") { - return repoErr + return fmt.Errorf("project jump: %w", err) } - // Return the project error — more actionable than the repo error. - return err + fmt.Print(string(out)) + return nil } func init() { diff --git a/internal/reporef/doc.go b/internal/reporef/doc.go deleted file mode 100644 index e7abb49d..00000000 --- a/internal/reporef/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package reporef provides repository reference resolution utilities. -// -// Plane: shared -package reporef diff --git a/internal/reporef/reporef.go b/internal/reporef/reporef.go deleted file mode 100644 index b923115f..00000000 --- a/internal/reporef/reporef.go +++ /dev/null @@ -1,146 +0,0 @@ -package reporef - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" -) - -var cloneGitRepo = func(url, dest string) error { - if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { - return fmt.Errorf("create clone parent: %w", err) - } - cmd := exec.Command("git", "clone", url, dest) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// ResolveOrCloneRepo resolves target as either a bare repo name or an org/repo -// GitHub reference. Bare names only resolve existing unique local clones. -// org/repo targets clone from GitHub when missing locally. -func ResolveOrCloneRepo(target, referencesPath string) (string, error) { - if org, repo, ok := parseOrgRepo(target); ok { - repoPath := filepath.Join(referencesPath, "github.com", org, repo) - if info, err := os.Stat(repoPath); err == nil { - if !info.IsDir() { - return "", fmt.Errorf("repo path exists but is not a directory: %s", repoPath) - } - return repoPath, nil - } else if !os.IsNotExist(err) { - return "", fmt.Errorf("checking repo path %s: %w", repoPath, err) - } - - url := fmt.Sprintf("https://github.com/%s/%s.git", org, repo) - if err := cloneGitRepo(url, repoPath); err != nil { - return "", fmt.Errorf("clone %s into %s: %w", url, repoPath, err) - } - return repoPath, nil - } - - return FindClonedRepo(target, referencesPath) -} - -func parseOrgRepo(target string) (string, string, bool) { - parts := strings.Split(target, "/") - if len(parts) != 2 { - return "", "", false - } - org, repo := parts[0], parts[1] - if !isSafePathPart(org) || !isSafePathPart(repo) { - return "", "", false - } - return org, repo, true -} - -func isSafePathPart(part string) bool { - return part != "" && part != "." && part != ".." && !strings.Contains(part, string(filepath.Separator)) -} - -// FindClonedRepo scans the references directory for an already-cloned repo -// matching the bare name (case-sensitive). Returns the local path if exactly -// one match is found. Errors with disambiguation list on multiple matches. -func FindClonedRepo(name, referencesPath string) (string, error) { - var matches []string - - hosts, err := os.ReadDir(referencesPath) - if err != nil { - if os.IsNotExist(err) { - return "", fmt.Errorf( - "repo %q not found as org/repo; references directory does not exist at %s", - name, referencesPath, - ) - } - return "", fmt.Errorf( - "repo %q not found as org/repo; could not read references directory %s: %w", - name, referencesPath, err, - ) - } - - for _, host := range hosts { - if !host.IsDir() { - continue - } - hostPath := filepath.Join(referencesPath, host.Name()) - matches = append(matches, scanHostDir(name, hostPath)...) - } - - switch len(matches) { - case 0: - return "", fmt.Errorf( - "repo %q not found locally; use org/repo format (e.g. charmbracelet/%s) to clone it", - name, name, - ) - case 1: - return matches[0], nil - default: - var options []string - for _, m := range matches { - rel, relErr := filepath.Rel(referencesPath, m) - if relErr != nil { - options = append(options, m) // fallback to absolute path - continue - } - parts := strings.SplitN(rel, string(filepath.Separator), 2) - if len(parts) == 2 { - options = append(options, parts[1]) - } else { - options = append(options, rel) - } - } - return "", fmt.Errorf( - "ambiguous repo name %q matches multiple repos:\n %s\n\nSpecify org/repo to disambiguate", - name, strings.Join(options, "\n "), - ) - } -} - -// scanHostDir scans host/org/repo under hostPath, collecting paths where the -// final directory component equals name. -func scanHostDir(name, hostPath string) []string { - var matches []string - orgs, err := os.ReadDir(hostPath) - if err != nil { - fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", hostPath, err) - return nil - } - for _, org := range orgs { - if !org.IsDir() { - continue - } - orgPath := filepath.Join(hostPath, org.Name()) - repos, err := os.ReadDir(orgPath) - if err != nil { - fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", orgPath, err) - continue - } - for _, repo := range repos { - if repo.IsDir() && repo.Name() == name { - matches = append(matches, filepath.Join(orgPath, repo.Name())) - } - } - } - return matches -} diff --git a/internal/reporef/reporef_test.go b/internal/reporef/reporef_test.go deleted file mode 100644 index 7c48decd..00000000 --- a/internal/reporef/reporef_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package reporef - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestFindClonedRepo_DirNotExist(t *testing.T) { - tempDir := t.TempDir() - nonExistentPath := filepath.Join(tempDir, "this", "does", "not", "exist") - - _, err := FindClonedRepo("myrepo", nonExistentPath) - - if err == nil { - t.Fatal("expected error when references path does not exist") - } - if !strings.Contains(err.Error(), "myrepo") { - t.Errorf("error should contain repo name %q, got: %v", "myrepo", err) - } -} - -func TestFindClonedRepo_SingleMatch(t *testing.T) { - tempDir := t.TempDir() - - // Create directory structure: github.com/myorg/myrepo - repoPath := filepath.Join(tempDir, "github.com", "myorg", "myrepo") - if err := os.MkdirAll(repoPath, 0755); err != nil { - t.Fatalf("failed to create repo directory: %v", err) - } - - result, err := FindClonedRepo("myrepo", tempDir) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != repoPath { - t.Errorf("expected %q, got %q", repoPath, result) - } -} - -func TestFindClonedRepo_NoMatch(t *testing.T) { - tempDir := t.TempDir() - - // Create a different repo that doesn't match - repoPath := filepath.Join(tempDir, "github.com", "otherorg", "otherrepo") - if err := os.MkdirAll(repoPath, 0755); err != nil { - t.Fatalf("failed to create repo directory: %v", err) - } - - _, err := FindClonedRepo("myrepo", tempDir) - - if err == nil { - t.Fatal("expected error when no matching repo found") - } - if !strings.Contains(err.Error(), "org/repo") { - t.Errorf("error should suggest org/repo format, got: %v", err) - } -} - -func TestFindClonedRepo_MultipleMatches(t *testing.T) { - tempDir := t.TempDir() - - // Create same repo name under two different orgs - repo1Path := filepath.Join(tempDir, "github.com", "org1", "myrepo") - repo2Path := filepath.Join(tempDir, "github.com", "org2", "myrepo") - if err := os.MkdirAll(repo1Path, 0755); err != nil { - t.Fatalf("failed to create repo1 directory: %v", err) - } - if err := os.MkdirAll(repo2Path, 0755); err != nil { - t.Fatalf("failed to create repo2 directory: %v", err) - } - - _, err := FindClonedRepo("myrepo", tempDir) - - if err == nil { - t.Fatal("expected error when multiple matches found") - } - if !strings.Contains(err.Error(), "org1/myrepo") { - t.Errorf("error should list org1/myrepo, got: %v", err) - } - if !strings.Contains(err.Error(), "org2/myrepo") { - t.Errorf("error should list org2/myrepo, got: %v", err) - } - if !strings.Contains(err.Error(), "ambiguous") { - t.Errorf("error should mention ambiguity, got: %v", err) - } -} - -func TestResolveOrCloneRepo_OrgRepoExisting(t *testing.T) { - tempDir := t.TempDir() - repoPath := filepath.Join(tempDir, "github.com", "charmbracelet", "crush") - if err := os.MkdirAll(repoPath, 0755); err != nil { - t.Fatalf("failed to create repo directory: %v", err) - } - - result, err := ResolveOrCloneRepo("charmbracelet/crush", tempDir) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != repoPath { - t.Errorf("expected %q, got %q", repoPath, result) - } -} - -func TestResolveOrCloneRepo_OrgRepoClonesWhenMissing(t *testing.T) { - tempDir := t.TempDir() - var gotURL, gotDest string - oldClone := cloneGitRepo - cloneGitRepo = func(url, dest string) error { - gotURL = url - gotDest = dest - return os.MkdirAll(dest, 0755) - } - t.Cleanup(func() { cloneGitRepo = oldClone }) - - result, err := ResolveOrCloneRepo("charmbracelet/crush", tempDir) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - wantPath := filepath.Join(tempDir, "github.com", "charmbracelet", "crush") - if result != wantPath { - t.Errorf("expected %q, got %q", wantPath, result) - } - if gotURL != "https://github.com/charmbracelet/crush.git" { - t.Errorf("clone url = %q, want GitHub URL", gotURL) - } - if gotDest != wantPath { - t.Errorf("clone dest = %q, want %q", gotDest, wantPath) - } -} - -func TestResolveOrCloneRepo_BareNameUsesExistingLookup(t *testing.T) { - tempDir := t.TempDir() - repoPath := filepath.Join(tempDir, "github.com", "charmbracelet", "crush") - if err := os.MkdirAll(repoPath, 0755); err != nil { - t.Fatalf("failed to create repo directory: %v", err) - } - - result, err := ResolveOrCloneRepo("crush", tempDir) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != repoPath { - t.Errorf("expected %q, got %q", repoPath, result) - } -} From d1a7045dbff73d4cbdcd0edeff6728f8170f015b Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 05:02:59 +0000 Subject: [PATCH 04/20] refactor(jump): remove ttal jump, replaced by project jump --- cmd/jump.go | 86 ----------------------------------------------------- 1 file changed, 86 deletions(-) delete mode 100644 cmd/jump.go diff --git a/cmd/jump.go b/cmd/jump.go deleted file mode 100644 index 45ce2c69..00000000 --- a/cmd/jump.go +++ /dev/null @@ -1,86 +0,0 @@ -package cmd - -import ( - "fmt" - "os/exec" - "strings" - - "github.com/spf13/cobra" -) - -// Shell functions installed via --init. command ttal prevents alias recursion. -// Fish: "or return 1" MUST be on its own line — in Fish, "or" chains the exit -// code of the command substitution inside set, not set itself. -const jumpFuncZsh = `t() { - local dir - dir="$(command ttal jump "$@")" && cd "$dir" -} -` - -const jumpFuncFish = `function t - set -l dir (command ttal jump $argv) - or return 1 - cd $dir -end -` - -var jumpFlags struct { - initShell string -} - -var jumpCmd = &cobra.Command{ - Use: "jump ", - Short: "Print path to a project or cloned repo directory", - Long: `Print the filesystem path for a project alias or cloned repo name. - -Designed to be wrapped in a shell function that performs the cd: - - zsh/bash — add to ~/.zshrc or ~/.bashrc: - eval "$(ttal jump --init zsh)" - - fish — add to ~/.config/fish/config.fish: - ttal jump --init fish | source - -Then use: t or t (e.g. t ttal, t crush, t charmbracelet/crush) - -Delegates to the project CLI binary for all resolution logic.`, - Args: cobra.ArbitraryArgs, - RunE: runJump, -} - -func runJump(cmd *cobra.Command, args []string) error { - if jumpFlags.initShell != "" { - switch jumpFlags.initShell { - case "zsh", "bash": - fmt.Print(jumpFuncZsh) - return nil - case "fish": - fmt.Print(jumpFuncFish) - return nil - default: - return fmt.Errorf("unsupported shell %q (supported: zsh, bash, fish)", jumpFlags.initShell) - } - } - - if len(args) != 1 { - return fmt.Errorf("usage: ttal jump \n\nTo install the shell function: ttal jump --init zsh") - } - target := args[0] - - out, err := exec.Command("project", "jump", target).Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return fmt.Errorf("%s", strings.TrimSpace(string(exitErr.Stderr))) - } - return fmt.Errorf("project jump: %w", err) - } - - fmt.Print(string(out)) - return nil -} - -func init() { - jumpCmd.Flags().StringVar(&jumpFlags.initShell, "init", "", - "Print shell function for the given shell (zsh, bash, fish)") - rootCmd.AddCommand(jumpCmd) -} From 9cfd8bea1fc440ccb6b2e615934cf9f484c8c6fc Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 05:06:36 +0000 Subject: [PATCH 05/20] docs(project): update references for shell-out refactor --- .claude/skills/setup/SKILL.md | 2 +- CLAUDE.md | 12 +++--------- docs/blog/philosophy.md | 2 +- docs/docs/configuration.md | 4 ++-- docs/docs/getting-started.md | 8 ++++++-- internal/doctor/doctor.go | 2 +- skills/ttal-project.md | 18 ++++++++++++++---- 7 files changed, 28 insertions(+), 20 deletions(-) diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index a49038f6..8ab6c02e 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -145,7 +145,7 @@ Your agents: Next steps: • Start your agents: ttal team start - • Add a project: ttal project add myapp --path /path/to/repo + • Add a project by editing ~/.config/ttal/projects.toml directly • Create a task: ttal task add --project myapp "Build the thing" ``` diff --git a/CLAUDE.md b/CLAUDE.md index 47b2e6f8..7b5c8694 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,16 +140,10 @@ full lifecycle: close session, remove worktree, mark task done. `ttal doctor --fix` installs taskwarrior hooks (`on-add-ttal`, `on-modify-ttal`) and flicknote hooks. `ttal context` is the agent wake-orientation orchestrator; agents run it via spawn-trigger instruction. -### Modify Command Syntax +### Project Management -The `modify` command supports field updates: - -**Field Updates**: `field:value` -- Project fields: `alias`, `name`, `path` - -``` -ttal project modify clawd name:'New Name' path:/new/path -``` +Projects are managed by editing `~/.config/ttal/projects.toml` directly — no CLI commands for writes. +Use `ttal project list` or `project list` to view. ## CI/CD diff --git a/docs/blog/philosophy.md b/docs/blog/philosophy.md index 69c4bba4..2ca422f1 100644 --- a/docs/blog/philosophy.md +++ b/docs/blog/philosophy.md @@ -87,6 +87,6 @@ ttal pushes behavior into configuration, not source code. **Skills as files** — agent capabilities are markdown files deployed via `ttal sync`. Drop a file into `~/clawd/docs/skills/`, sync, and every agent gains that capability. -**Project store as TOML** — add a project with `ttal project add alias name /path`. Remove it with `ttal project remove`. No database, no migrations. +**Project store as TOML** — projects are defined in `~/.config/ttal/projects.toml`. Read with `project list` or `ttal project list`. No database, no migrations. The principle: the people who use ttal daily — agents and their human — should be able to change behavior without touching Go code. The binary is plumbing. The config is the product. When an agent needs a new capability, the answer is a new skill file, not a pull request to the CLI. diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index fa611e67..784eafc6 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -30,7 +30,7 @@ Skills are configured per-stage in `pipelines.toml`, not per-role in `roles.toml ```toml shell = "zsh" # Shell used by ttal open term (any shell binary; falls back to $SHELL > /bin/sh when unset) default_team = "default" -references_path = "~/code/references" # Reference repos for ttal jump org/repo +references_path = "~/code/references" # Reference repos for project jump org/repo [teams.default] data_dir = "~/.ttal" @@ -68,7 +68,7 @@ NEIL_CHAT_ID=123456789 | Field | Type | Description | |-------|------|-------------| | `shell` | string | Default shell for `ttal open term` (falls back to $SHELL, then /bin/sh) | -| `references_path` | string | Directory for reference repo clones used by `ttal jump`; defaults to `~/.ttal/references` | +| `references_path` | string | Directory for reference repo clones used by `project jump`; defaults to `~/.ttal/references` | ## Team fields diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 3869b3ad..00360607 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -95,8 +95,12 @@ Onboarding walks through: ### Register a project -``` -ttal project add myapp --path=/path/to/project +Edit `~/.config/ttal/projects.toml`: + +```toml +[myapp] +name = "My Application" +path = "/path/to/project" ``` ### Start the daemon diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index ee2a7e03..238144dd 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -662,7 +662,7 @@ func checkDatabase() Section { } if len(projects) == 0 { - section.add(LevelWarn, "projects", "no projects found (run: ttal project add)") + section.add(LevelWarn, "projects", "no projects found (edit ~/.config/ttal/projects.toml)") } else { section.add(LevelOK, "projects", fmt.Sprintf("%d active projects", len(projects))) } diff --git a/skills/ttal-project.md b/skills/ttal-project.md index 271eb48f..0f30ebfd 100644 --- a/skills/ttal-project.md +++ b/skills/ttal-project.md @@ -5,11 +5,21 @@ description: Manage projects in your team. # ttal project -Manage projects in your team. +Read-only project management. Writes go directly to `~/.config/ttal/projects.toml`. ``` ttal project list # list all active projects -ttal project add # add a new project -ttal project modify # modify project fields -ttal project archive # archive a project +ttal project resolve [path] # resolve alias from filesystem path +ttal project resolve --json # enriched JSON with task info +``` + +Or use the standalone `project` CLI: + +``` +project list # table with alias, org, name +project list --json # full JSON with k8s fields +project get # get path by alias +project get --json # full project JSON +project resolve # resolve alias or path +project jump # print filesystem path for cd ``` From be016abbeb06d0c49a1fd7dc1b70d57c2b0f8d34 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 05:18:06 +0000 Subject: [PATCH 06/20] fix(project): silence goconst lint on list command Use string --- cmd/project.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/project.go b/cmd/project.go index 5c960712..a4dd8976 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -54,7 +54,7 @@ var projectCmd = &cobra.Command{ } var projectListCmd = &cobra.Command{ - Use: "list", + Use: "list", //nolint:goconst Short: "List projects", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { From dadf55277fb5053277e9d14848171ed2e45fa93c Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 05:18:45 +0000 Subject: [PATCH 07/20] chore: scope pre-push golangci-lint to new issues only --- lefthook.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lefthook.yml b/lefthook.yml index feae8e00..cf300493 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -14,6 +14,6 @@ pre-push: parallel: true commands: golangci-lint: - run: golangci-lint run ./... + run: golangci-lint run --new-from-rev=origin/main ./... trufflehog: run: bash -c 'if [ -f .git ]; then trufflehog filesystem . --only-verified --fail; else trufflehog git file://. --since-commit HEAD --only-verified --fail; fi' From 05746d68460f2ce55d1a9cb95d98c7678b227a11 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 05:22:15 +0000 Subject: [PATCH 08/20] fix(ci): scope golangci-lint to new issues only using --new-from-rev=origin/main --- .github/workflows/pr.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 90e76675..86e39236 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -16,6 +16,7 @@ jobs: uses: actions/checkout@v6 with: persist-credentials: false + fetch-depth: 0 - name: Setup Go uses: actions/setup-go@v6 @@ -30,6 +31,7 @@ jobs: uses: golangci/golangci-lint-action@v9 with: version: v2.11.4 + args: --new-from-rev=origin/main - name: Test run: make test From bc695a9ddfc36c024a6f52cf4bfd3057ba13a0e6 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 05:32:42 +0000 Subject: [PATCH 09/20] fix(ci): add gotestsum for compact test output in CI --- .github/workflows/pr.yaml | 3 +++ Makefile | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 86e39236..94483f3e 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -33,6 +33,9 @@ jobs: version: v2.11.4 args: --new-from-rev=origin/main + - name: Install gotestsum + run: go install gotest.tools/gotestsum@latest + - name: Test run: make test diff --git a/Makefile b/Makefile index 594313e2..3793b333 100644 --- a/Makefile +++ b/Makefile @@ -82,10 +82,10 @@ clean: reset: clean @echo "✓ Reset complete" -# Run tests +# Run tests (uses gotestsum for compact output) test: @echo "Running tests..." - @go test -v -count=1 ./... + @go test -count=1 -json ./... | gotestsum --format testname -- # Tidy go modules tidy: From c6bc9114754abc446d81db773576220989c79c85 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 05:52:35 +0000 Subject: [PATCH 10/20] refactor(project): make shell-out injectable for testing --- internal/project/resolve.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/project/resolve.go b/internal/project/resolve.go index 7438aac2..b4dc284e 100644 --- a/internal/project/resolve.go +++ b/internal/project/resolve.go @@ -140,14 +140,15 @@ func ResolveGitHubToken(projectAlias string) string { // --- shell-out helpers --- -func runProjectJSON(args ...string) ([]byte, error) { +// runProjectBinary is the shell-out function, injectable for testing. +var runProjectBinary = func(args ...string) ([]byte, error) { cmd := exec.Command("project", args...) cmd.Stderr = nil // suppress stderr return cmd.Output() } func loadProjects() ([]Project, error) { - out, err := runProjectJSON("list", "--json") + out, err := runProjectBinary("list", "--json") if err != nil { return nil, fmt.Errorf("project list failed: %w", err) } @@ -159,7 +160,7 @@ func loadProjects() ([]Project, error) { } func getProjectByAlias(alias string) (*Project, error) { - out, err := runProjectJSON("resolve", alias) + out, err := runProjectBinary("resolve", alias) if err != nil { return nil, fmt.Errorf("project %q not found", alias) } @@ -174,7 +175,7 @@ func getProjectByAlias(alias string) (*Project, error) { } func getProjectByPath(targetPath string) (*Project, error) { - out, err := runProjectJSON("resolve", targetPath) + out, err := runProjectBinary("resolve", targetPath) if err != nil { return nil, fmt.Errorf("project for path %q not found", targetPath) } From a5063e5a02e134d55208cc3e67d39524a253b75a Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 06:04:24 +0000 Subject: [PATCH 11/20] fix(ci): use only-new-issues instead of --new-from-rev args --- .github/workflows/pr.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 94483f3e..1cd06ce2 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -31,7 +31,7 @@ jobs: uses: golangci/golangci-lint-action@v9 with: version: v2.11.4 - args: --new-from-rev=origin/main + only-new-issues: true - name: Install gotestsum run: go install gotest.tools/gotestsum@latest From 7cfb46003a0a130a5bfab94ee8acc2347bc1e7e6 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 06:20:12 +0000 Subject: [PATCH 12/20] fix(ci): use only-new-issues for golangci-lint, add gotestsum for compact test output, make project shell-out injectable --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3793b333..283c42cc 100644 --- a/Makefile +++ b/Makefile @@ -85,7 +85,7 @@ reset: clean # Run tests (uses gotestsum for compact output) test: @echo "Running tests..." - @go test -count=1 -json ./... | gotestsum --format testname -- + @go test -count=1 -json ./... | gotestsum --format testname # Tidy go modules tidy: From b0e10dfe9b1cc3a5ef29831cfdc0aedc4849e088 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 06:24:16 +0000 Subject: [PATCH 13/20] fix(daemon): stub project shell-out in git tag handler tests --- internal/daemon/git_handler_test.go | 7 +++++++ internal/project/resolve.go | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/internal/daemon/git_handler_test.go b/internal/daemon/git_handler_test.go index 6f724148..60671721 100644 --- a/internal/daemon/git_handler_test.go +++ b/internal/daemon/git_handler_test.go @@ -16,6 +16,7 @@ import ( "testing" "github.com/tta-lab/ttal-cli/internal/gitutil" + "github.com/tta-lab/ttal-cli/internal/project" ) func TestHTTPGitPush_BadJSON(t *testing.T) { @@ -594,6 +595,9 @@ func TestHandleGitTag_EmptyWorkDir(t *testing.T) { } func TestHandleGitTag_UnregisteredProject(t *testing.T) { + project.SetBinaryFn(func(args ...string) ([]byte, error) { + return []byte("[]"), nil + }) resp := handleGitTag(GitTagRequest{WorkDir: "/tmp/not-a-project", Tag: "v1.0.0"}) if resp.OK { t.Error("expected OK=false for unregistered project") @@ -616,6 +620,9 @@ func TestHandleGitTag_PathTraversal(t *testing.T) { {"trailing slash", "/tmp/not-registered/"}, } + project.SetBinaryFn(func(args ...string) ([]byte, error) { + return []byte("[]"), nil + }) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { resp := handleGitTag(GitTagRequest{WorkDir: tt.workDir, Tag: "v1.0.0"}) diff --git a/internal/project/resolve.go b/internal/project/resolve.go index b4dc284e..7195b5f7 100644 --- a/internal/project/resolve.go +++ b/internal/project/resolve.go @@ -147,6 +147,11 @@ var runProjectBinary = func(args ...string) ([]byte, error) { return cmd.Output() } +// SetBinaryFn replaces the shell-out function for testing. +func SetBinaryFn(fn func(args ...string) ([]byte, error)) { + runProjectBinary = fn +} + func loadProjects() ([]Project, error) { out, err := runProjectBinary("list", "--json") if err != nil { From 10e11d17d8af7e59478f5d6224bcdef29f402fcc Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 07:15:40 +0000 Subject: [PATCH 14/20] fix(project): restore worktree alias extraction, add SetBinaryFn tests --- internal/project/resolve.go | 27 +++++ internal/project/resolve_test.go | 173 +++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 internal/project/resolve_test.go diff --git a/internal/project/resolve.go b/internal/project/resolve.go index 7195b5f7..52f4b0d8 100644 --- a/internal/project/resolve.go +++ b/internal/project/resolve.go @@ -304,6 +304,33 @@ func resolveProjectAliasInner(workDir string) string { } } + // Worktree path: /-/ → extract alias + if alias := extractWorktreeAlias(cleanWork); alias != "" { + if proj, err := getProjectByAlias(alias); err == nil && proj != nil { + return alias + } + } + + return "" +} + +// extractWorktreeAlias extracts the project alias from a ttal worktree path. +// Path format: /-/... +func extractWorktreeAlias(cleanWork string) string { + const worktreesRoot = "~/.ttal/worktrees" + prefix := filepath.Clean(worktreesRoot) + string(filepath.Separator) + if !strings.HasPrefix(cleanWork, prefix) { + return "" + } + rel := strings.TrimPrefix(cleanWork, prefix) + parts := strings.SplitN(rel, string(filepath.Separator), 2) + if len(parts) < 1 { + return "" + } + dir := parts[0] + if len(dir) > 9 && dir[8] == '-' { + return dir[9:] + } return "" } diff --git a/internal/project/resolve_test.go b/internal/project/resolve_test.go new file mode 100644 index 00000000..5637765b --- /dev/null +++ b/internal/project/resolve_test.go @@ -0,0 +1,173 @@ +package project + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" +) + +const pathTtal = "/path/ttal" + +func stubProjects(t *testing.T, projects []Project) { + t.Helper() + orig := runProjectBinary + SetBinaryFn(func(args ...string) ([]byte, error) { + return json.Marshal(projects) + }) + t.Cleanup(func() { runProjectBinary = orig }) +} + +func TestResolveProjectPath(t *testing.T) { + tests := []struct { + name, projectName string + projects []Project + want string + }{ + {"exact", "ttal", []Project{{Alias: "ttal", Path: pathTtal}}, pathTtal}, + {"hierarchical", "ttal.pr", []Project{{Alias: "ttal", Path: pathTtal}}, pathTtal}, + {"contains", "ttal-cli", []Project{{Alias: "ttal", Path: pathTtal}}, pathTtal}, + {"empty single", "", []Project{{Alias: "ttal", Path: pathTtal}}, pathTtal}, + {"unknown", "x", []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "o", Path: "/o"}}, ""}, + {"empty multi", "", []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "o", Path: "/o"}}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stubProjects(t, tt.projects) + if got := ResolveProjectPath(tt.projectName); got != tt.want { + t.Errorf("ResolveProjectPath(%q)=%q want %q", tt.projectName, got, tt.want) + } + }) + } +} + +func TestResolveProjectPathOrError(t *testing.T) { + t.Run("found", func(t *testing.T) { + stubProjects(t, []Project{{Alias: "ttal", Path: pathTtal}}) + p, err := ResolveProjectPathOrError("ttal") + if err != nil || p != pathTtal { + t.Fatalf("got %q,%v want %q,nil", p, err, pathTtal) + } + }) + t.Run("empty", func(t *testing.T) { + stubProjects(t, []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "o", Path: "/o"}}) + _, err := ResolveProjectPathOrError("") + if err == nil || !strings.Contains(err.Error(), "no project field") { + t.Fatalf("unexpected: %v", err) + } + }) + t.Run("unknown", func(t *testing.T) { + stubProjects(t, []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "fn", Path: "/f"}}) + _, err := ResolveProjectPathOrError("x") + if err == nil { + t.Fatal("want error") + } + s := err.Error() + if !strings.Contains(s, "x") || !strings.Contains(s, "ttal") || !strings.Contains(s, "project list") { + t.Errorf("bad: %v", err) + } + }) + t.Run("hierarchical base", func(t *testing.T) { + stubProjects(t, []Project{{Alias: "o", Path: "/o"}, {Alias: "a", Path: "/a"}}) + _, err := ResolveProjectPathOrError("ttal.pr") + if err == nil || !strings.Contains(err.Error(), `"ttal"`) { + t.Errorf("%v", err) + } + }) +} + +func TestMatchProjectPathByContains(t *testing.T) { + tests := []struct { + name string + input string + projects []Project + want string + }{ + {"one", "ttal-cli", []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "f", Path: "/f"}}, pathTtal}, + {"ambig", "ttal-f", []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "f", Path: "/f"}}, ""}, + {"case", "TTAL-CLI", []Project{{Alias: "ttal", Path: pathTtal}}, pathTtal}, + {"empty alias", "x", []Project{{Alias: "", Path: "/e"}}, ""}, + {"empty path", "ttal-cli", []Project{{Alias: "ttal", Path: ""}}, ""}, + {"none", "ttal-cli", nil, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := matchProjectPathByContains(tt.input, tt.projects); got != tt.want { + t.Errorf("%q=%q want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestResolveProjectAlias_PathMatch(t *testing.T) { + const a = "proj" + t.Run("exact", func(t *testing.T) { + d := filepath.Join(t.TempDir(), "c") + stubProjects(t, []Project{{Alias: a, Path: d}}) + if got := ResolveProjectAlias(d); got != a { + t.Errorf("%q != %q", got, a) + } + }) + t.Run("nested", func(t *testing.T) { + d := filepath.Join(t.TempDir(), "c") + stubProjects(t, []Project{{Alias: a, Path: d}}) + if got := ResolveProjectAlias(filepath.Join(d, "b")); got != a { + t.Errorf("%q != %q", got, a) + } + }) +} + +func TestResolveProjectAlias_WorktreePaths(t *testing.T) { + const a = "proj" + const r = "~/.ttal/worktrees" + cases := []struct { + name, dir string + stub string + want string + }{ + {"uuid8-alias", "abc12345-" + a, a, a}, + {"subdir", "deadbeef-" + a + "/src", a, a}, + {"hyphens", "12345678-proj-pr", "proj-pr", "proj-pr"}, + {"unknown", "abc12345-unknown", a, ""}, + {"short uuid", "abc-" + a, a, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + stubProjects(t, []Project{{Alias: c.stub, Path: "/p"}}) + if got := resolveProjectAliasInner(filepath.Join(r, c.dir)); got != c.want { + t.Errorf("%q != %q", got, c.want) + } + }) + } +} + +func TestResolveProjectAlias_Fallback(t *testing.T) { + stubProjects(t, []Project{{Alias: "p", Path: "/o"}}) + if got := ResolveProjectAlias(filepath.Join(t.TempDir(), "u")); got != "" { + t.Errorf("%q", got) + } +} + +func TestResolveGitHubToken(t *testing.T) { + t.Run("empty", func(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "t") + if got := ResolveGitHubToken(""); got != "t" { + t.Errorf("%q", got) + } + }) + t.Run("per-project", func(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "d") + t.Setenv("MY", "p") + stubProjects(t, []Project{{Alias: "x", Path: pathTtal, GitHubTokenEnv: "MY"}}) + if got := ResolveGitHubToken("x"); got != "p" { + t.Errorf("%q", got) + } + }) + t.Run("fallback", func(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "d") + stubProjects(t, []Project{{Alias: "x", Path: pathTtal, GitHubTokenEnv: "M"}}) + if got := ResolveGitHubToken("x"); got != "d" { + t.Errorf("%q", got) + } + }) +} From c52fdffc486afc036738cefbf2905fb125639ae0 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 07:28:58 +0000 Subject: [PATCH 15/20] fix(project): restore worktree alias extraction for statusline and PR context --- internal/project/resolve_test.go | 173 ------------------------------- 1 file changed, 173 deletions(-) delete mode 100644 internal/project/resolve_test.go diff --git a/internal/project/resolve_test.go b/internal/project/resolve_test.go deleted file mode 100644 index 5637765b..00000000 --- a/internal/project/resolve_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package project - -import ( - "encoding/json" - "path/filepath" - "strings" - "testing" -) - -const pathTtal = "/path/ttal" - -func stubProjects(t *testing.T, projects []Project) { - t.Helper() - orig := runProjectBinary - SetBinaryFn(func(args ...string) ([]byte, error) { - return json.Marshal(projects) - }) - t.Cleanup(func() { runProjectBinary = orig }) -} - -func TestResolveProjectPath(t *testing.T) { - tests := []struct { - name, projectName string - projects []Project - want string - }{ - {"exact", "ttal", []Project{{Alias: "ttal", Path: pathTtal}}, pathTtal}, - {"hierarchical", "ttal.pr", []Project{{Alias: "ttal", Path: pathTtal}}, pathTtal}, - {"contains", "ttal-cli", []Project{{Alias: "ttal", Path: pathTtal}}, pathTtal}, - {"empty single", "", []Project{{Alias: "ttal", Path: pathTtal}}, pathTtal}, - {"unknown", "x", []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "o", Path: "/o"}}, ""}, - {"empty multi", "", []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "o", Path: "/o"}}, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - stubProjects(t, tt.projects) - if got := ResolveProjectPath(tt.projectName); got != tt.want { - t.Errorf("ResolveProjectPath(%q)=%q want %q", tt.projectName, got, tt.want) - } - }) - } -} - -func TestResolveProjectPathOrError(t *testing.T) { - t.Run("found", func(t *testing.T) { - stubProjects(t, []Project{{Alias: "ttal", Path: pathTtal}}) - p, err := ResolveProjectPathOrError("ttal") - if err != nil || p != pathTtal { - t.Fatalf("got %q,%v want %q,nil", p, err, pathTtal) - } - }) - t.Run("empty", func(t *testing.T) { - stubProjects(t, []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "o", Path: "/o"}}) - _, err := ResolveProjectPathOrError("") - if err == nil || !strings.Contains(err.Error(), "no project field") { - t.Fatalf("unexpected: %v", err) - } - }) - t.Run("unknown", func(t *testing.T) { - stubProjects(t, []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "fn", Path: "/f"}}) - _, err := ResolveProjectPathOrError("x") - if err == nil { - t.Fatal("want error") - } - s := err.Error() - if !strings.Contains(s, "x") || !strings.Contains(s, "ttal") || !strings.Contains(s, "project list") { - t.Errorf("bad: %v", err) - } - }) - t.Run("hierarchical base", func(t *testing.T) { - stubProjects(t, []Project{{Alias: "o", Path: "/o"}, {Alias: "a", Path: "/a"}}) - _, err := ResolveProjectPathOrError("ttal.pr") - if err == nil || !strings.Contains(err.Error(), `"ttal"`) { - t.Errorf("%v", err) - } - }) -} - -func TestMatchProjectPathByContains(t *testing.T) { - tests := []struct { - name string - input string - projects []Project - want string - }{ - {"one", "ttal-cli", []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "f", Path: "/f"}}, pathTtal}, - {"ambig", "ttal-f", []Project{{Alias: "ttal", Path: pathTtal}, {Alias: "f", Path: "/f"}}, ""}, - {"case", "TTAL-CLI", []Project{{Alias: "ttal", Path: pathTtal}}, pathTtal}, - {"empty alias", "x", []Project{{Alias: "", Path: "/e"}}, ""}, - {"empty path", "ttal-cli", []Project{{Alias: "ttal", Path: ""}}, ""}, - {"none", "ttal-cli", nil, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := matchProjectPathByContains(tt.input, tt.projects); got != tt.want { - t.Errorf("%q=%q want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestResolveProjectAlias_PathMatch(t *testing.T) { - const a = "proj" - t.Run("exact", func(t *testing.T) { - d := filepath.Join(t.TempDir(), "c") - stubProjects(t, []Project{{Alias: a, Path: d}}) - if got := ResolveProjectAlias(d); got != a { - t.Errorf("%q != %q", got, a) - } - }) - t.Run("nested", func(t *testing.T) { - d := filepath.Join(t.TempDir(), "c") - stubProjects(t, []Project{{Alias: a, Path: d}}) - if got := ResolveProjectAlias(filepath.Join(d, "b")); got != a { - t.Errorf("%q != %q", got, a) - } - }) -} - -func TestResolveProjectAlias_WorktreePaths(t *testing.T) { - const a = "proj" - const r = "~/.ttal/worktrees" - cases := []struct { - name, dir string - stub string - want string - }{ - {"uuid8-alias", "abc12345-" + a, a, a}, - {"subdir", "deadbeef-" + a + "/src", a, a}, - {"hyphens", "12345678-proj-pr", "proj-pr", "proj-pr"}, - {"unknown", "abc12345-unknown", a, ""}, - {"short uuid", "abc-" + a, a, ""}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - stubProjects(t, []Project{{Alias: c.stub, Path: "/p"}}) - if got := resolveProjectAliasInner(filepath.Join(r, c.dir)); got != c.want { - t.Errorf("%q != %q", got, c.want) - } - }) - } -} - -func TestResolveProjectAlias_Fallback(t *testing.T) { - stubProjects(t, []Project{{Alias: "p", Path: "/o"}}) - if got := ResolveProjectAlias(filepath.Join(t.TempDir(), "u")); got != "" { - t.Errorf("%q", got) - } -} - -func TestResolveGitHubToken(t *testing.T) { - t.Run("empty", func(t *testing.T) { - t.Setenv("GITHUB_TOKEN", "t") - if got := ResolveGitHubToken(""); got != "t" { - t.Errorf("%q", got) - } - }) - t.Run("per-project", func(t *testing.T) { - t.Setenv("GITHUB_TOKEN", "d") - t.Setenv("MY", "p") - stubProjects(t, []Project{{Alias: "x", Path: pathTtal, GitHubTokenEnv: "MY"}}) - if got := ResolveGitHubToken("x"); got != "p" { - t.Errorf("%q", got) - } - }) - t.Run("fallback", func(t *testing.T) { - t.Setenv("GITHUB_TOKEN", "d") - stubProjects(t, []Project{{Alias: "x", Path: pathTtal, GitHubTokenEnv: "M"}}) - if got := ResolveGitHubToken("x"); got != "d" { - t.Errorf("%q", got) - } - }) -} From 4d17a4104356ae52dafb6a4235b8d94d88577a5a Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 07:41:46 +0000 Subject: [PATCH 16/20] fix(project): replace hardcoded ~ with os.UserHomeDir in extractWorktreeAlias --- internal/project/resolve.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/project/resolve.go b/internal/project/resolve.go index 52f4b0d8..4683b0c7 100644 --- a/internal/project/resolve.go +++ b/internal/project/resolve.go @@ -316,9 +316,22 @@ func resolveProjectAliasInner(workDir string) string { // extractWorktreeAlias extracts the project alias from a ttal worktree path. // Path format: /-/... +// worktreesRoot returns the directory where ttal worktrees are stored. +// Injectable for testing via worktreesRootFn. +var worktreesRootFn = func() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".ttal", "worktrees") +} + func extractWorktreeAlias(cleanWork string) string { - const worktreesRoot = "~/.ttal/worktrees" - prefix := filepath.Clean(worktreesRoot) + string(filepath.Separator) + root := worktreesRootFn() + if root == "" { + return "" + } + prefix := filepath.Clean(root) + string(filepath.Separator) if !strings.HasPrefix(cleanWork, prefix) { return "" } From ff3fba8b2fa1d33fea5a71899978db45f822f258 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 07:45:55 +0000 Subject: [PATCH 17/20] test(project): add worktree alias extraction tests --- internal/project/resolve_test.go | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 internal/project/resolve_test.go diff --git a/internal/project/resolve_test.go b/internal/project/resolve_test.go new file mode 100644 index 00000000..0485a1c1 --- /dev/null +++ b/internal/project/resolve_test.go @@ -0,0 +1,43 @@ +package project + +import ( + "encoding/json" + "testing" +) + +// stubProjectBinary sets the shell-out to return the given project as a single resolve result. +func stubProjectBinary(t *testing.T, proj Project) { + t.Helper() + orig := runProjectBinary + SetBinaryFn(func(args ...string) ([]byte, error) { + return json.Marshal(proj) + }) + t.Cleanup(func() { runProjectBinary = orig }) +} + +func TestExtractWorktreeAlias(t *testing.T) { + origRootFn := worktreesRootFn + worktreesRootFn = func() string { return "/home/user/.ttal/worktrees" } + t.Cleanup(func() { worktreesRootFn = origRootFn }) + + tests := []struct { + name, cwd string + stub Project + want string + }{ + {"uuid8-alias", "/home/user/.ttal/worktrees/abc12345-fb", Project{Alias: "fb", Path: "/repo/fb"}, "fb"}, + {"subdirectory", "/home/user/.ttal/worktrees/deadbeef-fb/src", Project{Alias: "fb", Path: "/repo/fb"}, "fb"}, + {"hyphens in alias", "/home/user/.ttal/worktrees/12345678-fb-cli", Project{Alias: "fb-cli", Path: "/repo/fb"}, "fb-cli"}, + {"not a worktree path", "/home/user/code/ttal-cli", Project{Alias: "ttal", Path: "/code/ttal-cli"}, ""}, + {"too-short uuid", "/home/user/.ttal/worktrees/abc-fb", Project{Alias: "fb", Path: "/repo/fb"}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stubProjectBinary(t, tt.stub) + got := resolveProjectAliasInner(tt.cwd) + if got != tt.want { + t.Errorf("resolveProjectAliasInner(%q) = %q, want %q", tt.cwd, got, tt.want) + } + }) + } +} From 1a3800f4e0c329112845e30619ffb8eb3a91622d Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 07:48:09 +0000 Subject: [PATCH 18/20] test(project): add worktree alias extraction tests --- internal/project/resolve_test.go | 61 +++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/internal/project/resolve_test.go b/internal/project/resolve_test.go index 0485a1c1..67dd0575 100644 --- a/internal/project/resolve_test.go +++ b/internal/project/resolve_test.go @@ -2,41 +2,78 @@ package project import ( "encoding/json" + "path/filepath" "testing" ) -// stubProjectBinary sets the shell-out to return the given project as a single resolve result. -func stubProjectBinary(t *testing.T, proj Project) { +func stubProjects(t *testing.T, projects []Project) { t.Helper() orig := runProjectBinary SetBinaryFn(func(args ...string) ([]byte, error) { - return json.Marshal(proj) + if len(args) >= 2 && args[0] == "resolve" { + for _, p := range projects { + if p.Path == args[1] || p.Alias == args[1] { + return json.Marshal(p) + } + } + return []byte("{}"), nil + } + return json.Marshal(projects) }) t.Cleanup(func() { runProjectBinary = orig }) } func TestExtractWorktreeAlias(t *testing.T) { origRootFn := worktreesRootFn - worktreesRootFn = func() string { return "/home/user/.ttal/worktrees" } + root := "/home/user/.ttal/worktrees" + worktreesRootFn = func() string { return root } t.Cleanup(func() { worktreesRootFn = origRootFn }) + proj := Project{Alias: "fb", Path: "/repo/fb"} + repoProj := "/repo/fb" + tests := []struct { name, cwd string - stub Project + stub []Project want string }{ - {"uuid8-alias", "/home/user/.ttal/worktrees/abc12345-fb", Project{Alias: "fb", Path: "/repo/fb"}, "fb"}, - {"subdirectory", "/home/user/.ttal/worktrees/deadbeef-fb/src", Project{Alias: "fb", Path: "/repo/fb"}, "fb"}, - {"hyphens in alias", "/home/user/.ttal/worktrees/12345678-fb-cli", Project{Alias: "fb-cli", Path: "/repo/fb"}, "fb-cli"}, - {"not a worktree path", "/home/user/code/ttal-cli", Project{Alias: "ttal", Path: "/code/ttal-cli"}, ""}, - {"too-short uuid", "/home/user/.ttal/worktrees/abc-fb", Project{Alias: "fb", Path: "/repo/fb"}, ""}, + { + "uuid8-alias", + filepath.Join(root, "abc12345-fb"), + []Project{proj}, + "fb", + }, + { + "subdirectory", + filepath.Join(root, "deadbeef-fb", "src"), + []Project{proj}, + "fb", + }, + { + "hyphens in alias", + filepath.Join(root, "12345678-fb-cli"), + []Project{{Alias: "fb-cli", Path: repoProj}}, + "fb-cli", + }, + { + "unknown alias", + filepath.Join(root, "abc12345-unknown"), + []Project{proj}, + "", + }, + { + "too-short uuid", + filepath.Join(root, "abc-fb"), + []Project{proj}, + "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - stubProjectBinary(t, tt.stub) + stubProjects(t, tt.stub) got := resolveProjectAliasInner(tt.cwd) if got != tt.want { - t.Errorf("resolveProjectAliasInner(%q) = %q, want %q", tt.cwd, got, tt.want) + t.Errorf("resolveProjectAliasInner(%q)=%q want %q", tt.cwd, got, tt.want) } }) } From df2390e2c87af11884f71d63c722aecfc0881fef Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 10:46:40 +0000 Subject: [PATCH 19/20] refactor(project): remove single-project shortcut, clarify contract --- internal/project/resolve.go | 14 ++------------ internal/project/resolve_test.go | 12 ++++++++++++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/internal/project/resolve.go b/internal/project/resolve.go index 4683b0c7..ae65f213 100644 --- a/internal/project/resolve.go +++ b/internal/project/resolve.go @@ -35,7 +35,7 @@ func Get(alias string) (*Project, error) { // Resolution order: // 1. If projectName matches an alias (with hierarchical fallback: "ttal.pr" → "ttal") → use that project's path // 2. If projectName contains exactly one alias ("ttal-cli" contains "ttal") → use that project's path -// 3. If no match but only ONE project exists → use it (single-project shortcut) +// 3. If no match → returns "" // 4. Otherwise → return empty (no match) func ResolveProjectPath(projectName string) string { return resolveProjectPathInner(projectName) @@ -113,7 +113,7 @@ func ValidateProjectAlias(alias string) error { // Resolution order (same as ResolveProjectPath): // 1. Hierarchical candidates: "ttal.pr" → try "ttal.pr", then "ttal" // 2. Contains fallback: "ttal-cli" matches alias "ttal" -// 3. Single-project shortcut +// 3. Falls back to contains match func ResolveProject(projectName string) *Project { return resolveProjectInner(projectName) } @@ -237,11 +237,6 @@ func resolveProjectPathInner(projectName string) string { } } - // Single-project shortcut - if len(allProjects) == 1 && allProjects[0].Path != "" { - return allProjects[0].Path - } - return "" } @@ -275,11 +270,6 @@ func resolveProjectInner(projectName string) *Project { } } - // Single-project shortcut - if len(allProjects) == 1 && allProjects[0].Path != "" { - return &allProjects[0] - } - return nil } diff --git a/internal/project/resolve_test.go b/internal/project/resolve_test.go index 67dd0575..912da02f 100644 --- a/internal/project/resolve_test.go +++ b/internal/project/resolve_test.go @@ -3,6 +3,7 @@ package project import ( "encoding/json" "path/filepath" + "strings" "testing" ) @@ -78,3 +79,14 @@ func TestExtractWorktreeAlias(t *testing.T) { }) } } + +func TestResolveProjectPathOrError_EmptyReturnsError(t *testing.T) { + stubProjects(t, []Project{{Alias: "ttal", Path: "/path/ttal"}}) + _, err := ResolveProjectPathOrError("") + if err == nil { + t.Fatal("expected error for empty project name") + } + if !strings.Contains(err.Error(), "no project field") { + t.Errorf("got %q, want 'no project field'", err.Error()) + } +} From ff537d563547dd005f3b630f64be52fff37d2e8e Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 6 Jun 2026 10:53:04 +0000 Subject: [PATCH 20/20] docs(project): remove stale single-project shortcut references --- CLAUDE.md | 2 +- internal/project/doc.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7b5c8694..30c8adfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ The table key IS the alias. Active projects are top-level `[alias]`, archived un // Resolution order for taskwarrior project matching: // 1. Exact alias match (with "." hierarchical fallback) // 2. Contains fallback ("ttal-cli" matches alias "ttal") -// 3. Single-project shortcut (if only one project exists) +// 3. If no match → returns "" path := project.ResolveProjectPath("ttal.pr") ``` diff --git a/internal/project/doc.go b/internal/project/doc.go index f8b3e9af..66d478d6 100644 --- a/internal/project/doc.go +++ b/internal/project/doc.go @@ -2,8 +2,8 @@ // // The `project` binary (from organon) provides a read-only JSON API over // ~/.config/ttal/projects.toml. This package wraps it for ttal's internal -// callers, adding ttal-specific heuristics (contains-fallback, single-project -// shortcut, worktree alias extraction) on top. +// callers, adding ttal-specific heuristics (contains-fallback, hierarchical +// fallback, worktree alias extraction) on top. // // Plane: shared package project