feat: add centralized project tracking (for Splitrail) (#1553)

Mike created

Change summary

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 ++++++++++++++++++++++++++++++++
5 files changed, 448 insertions(+)

Detailed changes

internal/cmd/projects.go 🔗

@@ -0,0 +1,78 @@
+package cmd
+
+import (
+	"encoding/json"
+	"os"
+
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/table"
+	"github.com/charmbracelet/crush/internal/projects"
+	"github.com/charmbracelet/x/term"
+	"github.com/spf13/cobra"
+)
+
+var projectsCmd = &cobra.Command{
+	Use:   "projects",
+	Short: "List all tracked project directories",
+	Long: `List all directories where Crush has been used.
+This includes the working directory, data directory path, and last accessed time.`,
+	Example: `
+# List all projects in a table
+crush projects
+
+# Output as JSON
+crush projects --json
+  `,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		jsonOutput, _ := cmd.Flags().GetBool("json")
+
+		projectList, err := projects.List()
+		if err != nil {
+			return err
+		}
+
+		if jsonOutput {
+			output := struct {
+				Projects []projects.Project `json:"projects"`
+			}{Projects: projectList}
+
+			data, err := json.Marshal(output)
+			if err != nil {
+				return err
+			}
+			cmd.Println(string(data))
+			return nil
+		}
+
+		if len(projectList) == 0 {
+			cmd.Println("No projects tracked yet.")
+			return nil
+		}
+
+		if term.IsTerminal(os.Stdout.Fd()) {
+			// We're in a TTY: make it fancy.
+			t := table.New().
+				Border(lipgloss.RoundedBorder()).
+				StyleFunc(func(row, col int) lipgloss.Style {
+					return lipgloss.NewStyle().Padding(0, 2)
+				}).
+				Headers("Path", "Data Dir", "Last Accessed")
+
+			for _, p := range projectList {
+				t.Row(p.Path, p.DataDir, p.LastAccessed.Local().Format("2006-01-02 15:04"))
+			}
+			lipgloss.Println(t)
+			return nil
+		}
+
+		// Not a TTY: plain output
+		for _, p := range projectList {
+			cmd.Printf("%s\t%s\t%s\n", p.Path, p.DataDir, p.LastAccessed.Format("2006-01-02T15:04:05Z07:00"))
+		}
+		return nil
+	},
+}
+
+func init() {
+	projectsCmd.Flags().Bool("json", false, "Output as JSON")
+}

internal/cmd/projects_test.go 🔗

@@ -0,0 +1,56 @@
+package cmd
+
+import (
+	"bytes"
+	"encoding/json"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/projects"
+	"github.com/stretchr/testify/require"
+)
+
+func TestProjectsEmpty(t *testing.T) {
+	// Use a temp directory for projects.json
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	var b bytes.Buffer
+	projectsCmd.SetOut(&b)
+	projectsCmd.SetErr(&b)
+	projectsCmd.SetIn(bytes.NewReader(nil))
+	err := projectsCmd.RunE(projectsCmd, nil)
+	require.NoError(t, err)
+	require.Equal(t, "No projects tracked yet.\n", b.String())
+}
+
+func TestProjectsJSON(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project
+	err := projects.Register("/test/project", "/test/project/.crush")
+	require.NoError(t, err)
+
+	var b bytes.Buffer
+	projectsCmd.SetOut(&b)
+	projectsCmd.SetErr(&b)
+	projectsCmd.SetIn(bytes.NewReader(nil))
+
+	// Set the --json flag
+	projectsCmd.Flags().Set("json", "true")
+	defer projectsCmd.Flags().Set("json", "false")
+
+	err = projectsCmd.RunE(projectsCmd, nil)
+	require.NoError(t, err)
+
+	// Parse the JSON output
+	var result struct {
+		Projects []projects.Project `json:"projects"`
+	}
+	err = json.Unmarshal(b.Bytes(), &result)
+	require.NoError(t, err)
+
+	require.Len(t, result.Projects, 1)
+	require.Equal(t, "/test/project", result.Projects[0].Path)
+	require.Equal(t, "/test/project/.crush", result.Projects[0].DataDir)
+}

internal/cmd/root.go 🔗

@@ -19,6 +19,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/event"
+	"github.com/charmbracelet/crush/internal/projects"
 	"github.com/charmbracelet/crush/internal/stringext"
 	"github.com/charmbracelet/crush/internal/tui"
 	"github.com/charmbracelet/crush/internal/version"
@@ -40,6 +41,7 @@ func init() {
 	rootCmd.AddCommand(
 		runCmd,
 		dirsCmd,
+		projectsCmd,
 		updateProvidersCmd,
 		logsCmd,
 		schemaCmd,
@@ -199,6 +201,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
 		return nil, err
 	}
 
+	// Register this project in the centralized projects list.
+	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
+		slog.Warn("Failed to register project", "error", err)
+		// Non-fatal: continue even if registration fails
+	}
+
 	// Connect to DB; this will also run migrations.
 	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
 	if err != nil {

internal/projects/projects.go 🔗

@@ -0,0 +1,126 @@
+package projects
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"slices"
+	"sync"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+)
+
+const projectsFileName = "projects.json"
+
+// Project represents a tracked project directory.
+type Project struct {
+	Path         string    `json:"path"`
+	DataDir      string    `json:"data_dir"`
+	LastAccessed time.Time `json:"last_accessed"`
+}
+
+// ProjectList holds the list of tracked projects.
+type ProjectList struct {
+	Projects []Project `json:"projects"`
+}
+
+var mu sync.Mutex
+
+// projectsFilePath returns the path to the projects.json file.
+func projectsFilePath() string {
+	return filepath.Join(filepath.Dir(config.GlobalConfigData()), projectsFileName)
+}
+
+// Load reads the projects list from disk.
+func Load() (*ProjectList, error) {
+	mu.Lock()
+	defer mu.Unlock()
+
+	path := projectsFilePath()
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return &ProjectList{Projects: []Project{}}, nil
+		}
+		return nil, err
+	}
+
+	var list ProjectList
+	if err := json.Unmarshal(data, &list); err != nil {
+		return nil, err
+	}
+
+	return &list, nil
+}
+
+// Save writes the projects list to disk.
+func Save(list *ProjectList) error {
+	mu.Lock()
+	defer mu.Unlock()
+
+	path := projectsFilePath()
+
+	// Ensure directory exists
+	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(list, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(path, data, 0o600)
+}
+
+// Register adds or updates a project in the list.
+func Register(workingDir, dataDir string) error {
+	list, err := Load()
+	if err != nil {
+		return err
+	}
+
+	now := time.Now().UTC()
+
+	// Check if project already exists
+	found := false
+	for i, p := range list.Projects {
+		if p.Path == workingDir {
+			list.Projects[i].DataDir = dataDir
+			list.Projects[i].LastAccessed = now
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		list.Projects = append(list.Projects, Project{
+			Path:         workingDir,
+			DataDir:      dataDir,
+			LastAccessed: now,
+		})
+	}
+
+	// Sort by last accessed (most recent first)
+	slices.SortFunc(list.Projects, func(a, b Project) int {
+		if a.LastAccessed.After(b.LastAccessed) {
+			return -1
+		}
+		if a.LastAccessed.Before(b.LastAccessed) {
+			return 1
+		}
+		return 0
+	})
+
+	return Save(list)
+}
+
+// List returns all tracked projects sorted by last accessed.
+func List() ([]Project, error) {
+	list, err := Load()
+	if err != nil {
+		return nil, err
+	}
+	return list.Projects, nil
+}

internal/projects/projects_test.go 🔗

@@ -0,0 +1,180 @@
+package projects
+
+import (
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func TestRegisterAndList(t *testing.T) {
+	// Create a temporary directory for the test
+	tmpDir := t.TempDir()
+
+	// Override the projects file path for testing
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Test registering a project
+	err := Register("/home/user/project1", "/home/user/project1/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	// List projects
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project, got %d", len(projects))
+	}
+
+	if projects[0].Path != "/home/user/project1" {
+		t.Errorf("Expected path /home/user/project1, got %s", projects[0].Path)
+	}
+
+	if projects[0].DataDir != "/home/user/project1/.crush" {
+		t.Errorf("Expected data_dir /home/user/project1/.crush, got %s", projects[0].DataDir)
+	}
+
+	// Register another project
+	err = Register("/home/user/project2", "/home/user/project2/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, err = List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 2 {
+		t.Fatalf("Expected 2 projects, got %d", len(projects))
+	}
+
+	// Most recent should be first
+	if projects[0].Path != "/home/user/project2" {
+		t.Errorf("Expected most recent project first, got %s", projects[0].Path)
+	}
+}
+
+func TestRegisterUpdatesExisting(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project
+	err := Register("/home/user/project1", "/home/user/project1/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, _ := List()
+	firstAccess := projects[0].LastAccessed
+
+	// Wait a bit and re-register
+	time.Sleep(10 * time.Millisecond)
+
+	err = Register("/home/user/project1", "/home/user/project1/.crush-new")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, _ = List()
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project after update, got %d", len(projects))
+	}
+
+	if projects[0].DataDir != "/home/user/project1/.crush-new" {
+		t.Errorf("Expected updated data_dir, got %s", projects[0].DataDir)
+	}
+
+	if !projects[0].LastAccessed.After(firstAccess) {
+		t.Error("Expected LastAccessed to be updated")
+	}
+}
+
+func TestLoadEmptyFile(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// List before any projects exist
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 0 {
+		t.Errorf("Expected 0 projects, got %d", len(projects))
+	}
+}
+
+func TestProjectsFilePath(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	expected := filepath.Join(tmpDir, "crush", "projects.json")
+	actual := projectsFilePath()
+
+	if actual != expected {
+		t.Errorf("Expected %s, got %s", expected, actual)
+	}
+}
+
+func TestRegisterWithParentDataDir(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project where .crush is in a parent directory.
+	// e.g., working in /home/user/monorepo/packages/app but .crush is at /home/user/monorepo/.crush
+	err := Register("/home/user/monorepo/packages/app", "/home/user/monorepo/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project, got %d", len(projects))
+	}
+
+	if projects[0].Path != "/home/user/monorepo/packages/app" {
+		t.Errorf("Expected path /home/user/monorepo/packages/app, got %s", projects[0].Path)
+	}
+
+	if projects[0].DataDir != "/home/user/monorepo/.crush" {
+		t.Errorf("Expected data_dir /home/user/monorepo/.crush, got %s", projects[0].DataDir)
+	}
+}
+
+func TestRegisterWithExternalDataDir(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project where .crush is in a completely different location.
+	// e.g., project at /home/user/project but data stored at /var/data/crush/myproject
+	err := Register("/home/user/project", "/var/data/crush/myproject")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project, got %d", len(projects))
+	}
+
+	if projects[0].Path != "/home/user/project" {
+		t.Errorf("Expected path /home/user/project, got %s", projects[0].Path)
+	}
+
+	if projects[0].DataDir != "/var/data/crush/myproject" {
+		t.Errorf("Expected data_dir /var/data/crush/myproject, got %s", projects[0].DataDir)
+	}
+}