on main help sign in
feat: add centralized project tracking (for Splitrail) (#1553)
commit4b86d090392a761106d4b33b2937e183af62bcd6 authorMike <mike@piebald.ai> dateMon 15 Dec 2025, 11:20 (1 year ago) parent0d0ac7c
5 files changed+448
internal/cmd/projects.go+78 internal/cmd/projects_test.go+56 internal/cmd/root.go+8 internal/projects/projects.go+126 internal/projects/projects_test.go+180
internal/cmd/projects.gonew file+78
@@ -0,0 +1,78 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "os"
6+
7+ "charm.land/lipgloss/v2"
8+ "charm.land/lipgloss/v2/table"
9+ "github.com/charmbracelet/crush/internal/projects"
10+ "github.com/charmbracelet/x/term"
11+ "github.com/spf13/cobra"
12+)
13+
14+var projectsCmd = &cobra.Command{
15+ Use: "projects",
16+ Short: "List all tracked project directories",
17+ Long: `List all directories where Crush has been used.
18+This includes the working directory, data directory path, and last accessed time.`,
19+ Example: `
20+# List all projects in a table
21+crush projects
22+
23+# Output as JSON
24+crush projects --json
25+ `,
26+ RunE: func(cmd *cobra.Command, args []string) error {
27+ jsonOutput, _ := cmd.Flags().GetBool("json")
28+
29+ projectList, err := projects.List()
30+ if err != nil {
31+ return err
32+ }
33+
34+ if jsonOutput {
35+ output := struct {
36+ Projects []projects.Project `json:"projects"`
37+ }{Projects: projectList}
38+
39+ data, err := json.Marshal(output)
40+ if err != nil {
41+ return err
42+ }
43+ cmd.Println(string(data))
44+ return nil
45+ }
46+
47+ if len(projectList) == 0 {
48+ cmd.Println("No projects tracked yet.")
49+ return nil
50+ }
51+
52+ if term.IsTerminal(os.Stdout.Fd()) {
53+ // We're in a TTY: make it fancy.
54+ t := table.New().
55+ Border(lipgloss.RoundedBorder()).
56+ StyleFunc(func(row, col int) lipgloss.Style {
57+ return lipgloss.NewStyle().Padding(0, 2)
58+ }).
59+ Headers("Path", "Data Dir", "Last Accessed")
60+
61+ for _, p := range projectList {
62+ t.Row(p.Path, p.DataDir, p.LastAccessed.Local().Format("2006-01-02 15:04"))
63+ }
64+ lipgloss.Println(t)
65+ return nil
66+ }
67+
68+ // Not a TTY: plain output
69+ for _, p := range projectList {
70+ cmd.Printf("%s\t%s\t%s\n", p.Path, p.DataDir, p.LastAccessed.Format("2006-01-02T15:04:05Z07:00"))
71+ }
72+ return nil
73+ },
74+}
75+
76+func init() {
77+ projectsCmd.Flags().Bool("json", false, "Output as JSON")
78+}
internal/cmd/projects_test.gonew file+56
@@ -0,0 +1,56 @@
1+package cmd
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/charmbracelet/crush/internal/projects"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestProjectsEmpty(t *testing.T) {
13+ // Use a temp directory for projects.json
14+ tmpDir := t.TempDir()
15+ t.Setenv("XDG_DATA_HOME", tmpDir)
16+
17+ var b bytes.Buffer
18+ projectsCmd.SetOut(&b)
19+ projectsCmd.SetErr(&b)
20+ projectsCmd.SetIn(bytes.NewReader(nil))
21+ err := projectsCmd.RunE(projectsCmd, nil)
22+ require.NoError(t, err)
23+ require.Equal(t, "No projects tracked yet.\n", b.String())
24+}
25+
26+func TestProjectsJSON(t *testing.T) {
27+ tmpDir := t.TempDir()
28+ t.Setenv("XDG_DATA_HOME", tmpDir)
29+
30+ // Register a project
31+ err := projects.Register("/test/project", "/test/project/.crush")
32+ require.NoError(t, err)
33+
34+ var b bytes.Buffer
35+ projectsCmd.SetOut(&b)
36+ projectsCmd.SetErr(&b)
37+ projectsCmd.SetIn(bytes.NewReader(nil))
38+
39+ // Set the --json flag
40+ projectsCmd.Flags().Set("json", "true")
41+ defer projectsCmd.Flags().Set("json", "false")
42+
43+ err = projectsCmd.RunE(projectsCmd, nil)
44+ require.NoError(t, err)
45+
46+ // Parse the JSON output
47+ var result struct {
48+ Projects []projects.Project `json:"projects"`
49+ }
50+ err = json.Unmarshal(b.Bytes(), &result)
51+ require.NoError(t, err)
52+
53+ require.Len(t, result.Projects, 1)
54+ require.Equal(t, "/test/project", result.Projects[0].Path)
55+ require.Equal(t, "/test/project/.crush", result.Projects[0].DataDir)
56+}
internal/cmd/root.go+8
@@ -19,6 +19,7 @@ import (
1919 "github.com/charmbracelet/crush/internal/config"
2020 "github.com/charmbracelet/crush/internal/db"
2121 "github.com/charmbracelet/crush/internal/event"
22+ "github.com/charmbracelet/crush/internal/projects"
2223 "github.com/charmbracelet/crush/internal/stringext"
2324 "github.com/charmbracelet/crush/internal/tui"
2425 "github.com/charmbracelet/crush/internal/version"
@@ -40,6 +41,7 @@ func init() {
4041 rootCmd.AddCommand(
4142 runCmd,
4243 dirsCmd,
44+ projectsCmd,
4345 updateProvidersCmd,
4446 logsCmd,
4547 schemaCmd,
@@ -199,6 +201,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
199201 return nil, err
200202 }
201203
204+ // Register this project in the centralized projects list.
205+ if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
206+ slog.Warn("Failed to register project", "error", err)
207+ // Non-fatal: continue even if registration fails
208+ }
209+
202210 // Connect to DB; this will also run migrations.
203211 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
204212 if err != nil {
internal/projects/projects.gonew file+126
@@ -0,0 +1,126 @@
1+package projects
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "slices"
8+ "sync"
9+ "time"
10+
11+ "github.com/charmbracelet/crush/internal/config"
12+)
13+
14+const projectsFileName = "projects.json"
15+
16+// Project represents a tracked project directory.
17+type Project struct {
18+ Path string `json:"path"`
19+ DataDir string `json:"data_dir"`
20+ LastAccessed time.Time `json:"last_accessed"`
21+}
22+
23+// ProjectList holds the list of tracked projects.
24+type ProjectList struct {
25+ Projects []Project `json:"projects"`
26+}
27+
28+var mu sync.Mutex
29+
30+// projectsFilePath returns the path to the projects.json file.
31+func projectsFilePath() string {
32+ return filepath.Join(filepath.Dir(config.GlobalConfigData()), projectsFileName)
33+}
34+
35+// Load reads the projects list from disk.
36+func Load() (*ProjectList, error) {
37+ mu.Lock()
38+ defer mu.Unlock()
39+
40+ path := projectsFilePath()
41+ data, err := os.ReadFile(path)
42+ if err != nil {
43+ if os.IsNotExist(err) {
44+ return &ProjectList{Projects: []Project{}}, nil
45+ }
46+ return nil, err
47+ }
48+
49+ var list ProjectList
50+ if err := json.Unmarshal(data, &list); err != nil {
51+ return nil, err
52+ }
53+
54+ return &list, nil
55+}
56+
57+// Save writes the projects list to disk.
58+func Save(list *ProjectList) error {
59+ mu.Lock()
60+ defer mu.Unlock()
61+
62+ path := projectsFilePath()
63+
64+ // Ensure directory exists
65+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
66+ return err
67+ }
68+
69+ data, err := json.MarshalIndent(list, "", " ")
70+ if err != nil {
71+ return err
72+ }
73+
74+ return os.WriteFile(path, data, 0o600)
75+}
76+
77+// Register adds or updates a project in the list.
78+func Register(workingDir, dataDir string) error {
79+ list, err := Load()
80+ if err != nil {
81+ return err
82+ }
83+
84+ now := time.Now().UTC()
85+
86+ // Check if project already exists
87+ found := false
88+ for i, p := range list.Projects {
89+ if p.Path == workingDir {
90+ list.Projects[i].DataDir = dataDir
91+ list.Projects[i].LastAccessed = now
92+ found = true
93+ break
94+ }
95+ }
96+
97+ if !found {
98+ list.Projects = append(list.Projects, Project{
99+ Path: workingDir,
100+ DataDir: dataDir,
101+ LastAccessed: now,
102+ })
103+ }
104+
105+ // Sort by last accessed (most recent first)
106+ slices.SortFunc(list.Projects, func(a, b Project) int {
107+ if a.LastAccessed.After(b.LastAccessed) {
108+ return -1
109+ }
110+ if a.LastAccessed.Before(b.LastAccessed) {
111+ return 1
112+ }
113+ return 0
114+ })
115+
116+ return Save(list)
117+}
118+
119+// List returns all tracked projects sorted by last accessed.
120+func List() ([]Project, error) {
121+ list, err := Load()
122+ if err != nil {
123+ return nil, err
124+ }
125+ return list.Projects, nil
126+}
internal/projects/projects_test.gonew file+180
@@ -0,0 +1,180 @@
1+package projects
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+ "time"
7+)
8+
9+func TestRegisterAndList(t *testing.T) {
10+ // Create a temporary directory for the test
11+ tmpDir := t.TempDir()
12+
13+ // Override the projects file path for testing
14+ t.Setenv("XDG_DATA_HOME", tmpDir)
15+
16+ // Test registering a project
17+ err := Register("/home/user/project1", "/home/user/project1/.crush")
18+ if err != nil {
19+ t.Fatalf("Register failed: %v", err)
20+ }
21+
22+ // List projects
23+ projects, err := List()
24+ if err != nil {
25+ t.Fatalf("List failed: %v", err)
26+ }
27+
28+ if len(projects) != 1 {
29+ t.Fatalf("Expected 1 project, got %d", len(projects))
30+ }
31+
32+ if projects[0].Path != "/home/user/project1" {
33+ t.Errorf("Expected path /home/user/project1, got %s", projects[0].Path)
34+ }
35+
36+ if projects[0].DataDir != "/home/user/project1/.crush" {
37+ t.Errorf("Expected data_dir /home/user/project1/.crush, got %s", projects[0].DataDir)
38+ }
39+
40+ // Register another project
41+ err = Register("/home/user/project2", "/home/user/project2/.crush")
42+ if err != nil {
43+ t.Fatalf("Register failed: %v", err)
44+ }
45+
46+ projects, err = List()
47+ if err != nil {
48+ t.Fatalf("List failed: %v", err)
49+ }
50+
51+ if len(projects) != 2 {
52+ t.Fatalf("Expected 2 projects, got %d", len(projects))
53+ }
54+
55+ // Most recent should be first
56+ if projects[0].Path != "/home/user/project2" {
57+ t.Errorf("Expected most recent project first, got %s", projects[0].Path)
58+ }
59+}
60+
61+func TestRegisterUpdatesExisting(t *testing.T) {
62+ tmpDir := t.TempDir()
63+ t.Setenv("XDG_DATA_HOME", tmpDir)
64+
65+ // Register a project
66+ err := Register("/home/user/project1", "/home/user/project1/.crush")
67+ if err != nil {
68+ t.Fatalf("Register failed: %v", err)
69+ }
70+
71+ projects, _ := List()
72+ firstAccess := projects[0].LastAccessed
73+
74+ // Wait a bit and re-register
75+ time.Sleep(10 * time.Millisecond)
76+
77+ err = Register("/home/user/project1", "/home/user/project1/.crush-new")
78+ if err != nil {
79+ t.Fatalf("Register failed: %v", err)
80+ }
81+
82+ projects, _ = List()
83+
84+ if len(projects) != 1 {
85+ t.Fatalf("Expected 1 project after update, got %d", len(projects))
86+ }
87+
88+ if projects[0].DataDir != "/home/user/project1/.crush-new" {
89+ t.Errorf("Expected updated data_dir, got %s", projects[0].DataDir)
90+ }
91+
92+ if !projects[0].LastAccessed.After(firstAccess) {
93+ t.Error("Expected LastAccessed to be updated")
94+ }
95+}
96+
97+func TestLoadEmptyFile(t *testing.T) {
98+ tmpDir := t.TempDir()
99+ t.Setenv("XDG_DATA_HOME", tmpDir)
100+
101+ // List before any projects exist
102+ projects, err := List()
103+ if err != nil {
104+ t.Fatalf("List failed: %v", err)
105+ }
106+
107+ if len(projects) != 0 {
108+ t.Errorf("Expected 0 projects, got %d", len(projects))
109+ }
110+}
111+
112+func TestProjectsFilePath(t *testing.T) {
113+ tmpDir := t.TempDir()
114+ t.Setenv("XDG_DATA_HOME", tmpDir)
115+
116+ expected := filepath.Join(tmpDir, "crush", "projects.json")
117+ actual := projectsFilePath()
118+
119+ if actual != expected {
120+ t.Errorf("Expected %s, got %s", expected, actual)
121+ }
122+}
123+
124+func TestRegisterWithParentDataDir(t *testing.T) {
125+ tmpDir := t.TempDir()
126+ t.Setenv("XDG_DATA_HOME", tmpDir)
127+
128+ // Register a project where .crush is in a parent directory.
129+ // e.g., working in /home/user/monorepo/packages/app but .crush is at /home/user/monorepo/.crush
130+ err := Register("/home/user/monorepo/packages/app", "/home/user/monorepo/.crush")
131+ if err != nil {
132+ t.Fatalf("Register failed: %v", err)
133+ }
134+
135+ projects, err := List()
136+ if err != nil {
137+ t.Fatalf("List failed: %v", err)
138+ }
139+
140+ if len(projects) != 1 {
141+ t.Fatalf("Expected 1 project, got %d", len(projects))
142+ }
143+
144+ if projects[0].Path != "/home/user/monorepo/packages/app" {
145+ t.Errorf("Expected path /home/user/monorepo/packages/app, got %s", projects[0].Path)
146+ }
147+
148+ if projects[0].DataDir != "/home/user/monorepo/.crush" {
149+ t.Errorf("Expected data_dir /home/user/monorepo/.crush, got %s", projects[0].DataDir)
150+ }
151+}
152+
153+func TestRegisterWithExternalDataDir(t *testing.T) {
154+ tmpDir := t.TempDir()
155+ t.Setenv("XDG_DATA_HOME", tmpDir)
156+
157+ // Register a project where .crush is in a completely different location.
158+ // e.g., project at /home/user/project but data stored at /var/data/crush/myproject
159+ err := Register("/home/user/project", "/var/data/crush/myproject")
160+ if err != nil {
161+ t.Fatalf("Register failed: %v", err)
162+ }
163+
164+ projects, err := List()
165+ if err != nil {
166+ t.Fatalf("List failed: %v", err)
167+ }
168+
169+ if len(projects) != 1 {
170+ t.Fatalf("Expected 1 project, got %d", len(projects))
171+ }
172+
173+ if projects[0].Path != "/home/user/project" {
174+ t.Errorf("Expected path /home/user/project, got %s", projects[0].Path)
175+ }
176+
177+ if projects[0].DataDir != "/var/data/crush/myproject" {
178+ t.Errorf("Expected data_dir /var/data/crush/myproject, got %s", projects[0].DataDir)
179+ }
180+}