From 48fa9a827ed1becccca1e4d646f120e52bdb80d6 Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Wed, 18 Mar 2026 16:08:38 +0100 Subject: [PATCH 1/2] Migrate trail storage from git branches to Entire API Replace git-based trail CRUD operations with API calls when the user is authenticated via `entire login`. Falls back to the existing git-backed store when not logged in, preserving backward compatibility. Key changes: - Add trail.Store interface with GitStore and APIStore implementations - APIStore uses Bearer auth from keyring, resolves org/repo from origin - On first API-backed operation, migrate existing git trails and place a DEPRECATED_MOVED_TO_DATABASE_STORAGE marker on entire/trails/v1 - Skip git fetch/push of trails branch when using API store - Add gitremote package for shared remote URL parsing Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 414167a7bfdb --- cmd/entire/cli/gitremote/remote.go | 120 +++++ .../cli/strategy/manual_commit_hooks.go | 12 +- cmd/entire/cli/trail/api_store.go | 474 ++++++++++++++++++ cmd/entire/cli/trail/migrate.go | 202 ++++++++ cmd/entire/cli/trail/resolve.go | 46 ++ cmd/entire/cli/trail/store.go | 43 +- cmd/entire/cli/trail/trail.go | 20 +- cmd/entire/cli/trail_cmd.go | 69 ++- 8 files changed, 940 insertions(+), 46 deletions(-) create mode 100644 cmd/entire/cli/gitremote/remote.go create mode 100644 cmd/entire/cli/trail/api_store.go create mode 100644 cmd/entire/cli/trail/migrate.go create mode 100644 cmd/entire/cli/trail/resolve.go diff --git a/cmd/entire/cli/gitremote/remote.go b/cmd/entire/cli/gitremote/remote.go new file mode 100644 index 000000000..16e39d3cd --- /dev/null +++ b/cmd/entire/cli/gitremote/remote.go @@ -0,0 +1,120 @@ +// Package gitremote provides utilities for parsing git remote URLs. +package gitremote + +import ( + "context" + "fmt" + "net/url" + "os/exec" + "strings" +) + +// Protocol identifiers for git remotes. +const ( + ProtocolSSH = "ssh" + ProtocolHTTPS = "https" +) + +// RemoteInfo holds parsed components of a git remote URL. +type RemoteInfo struct { + Protocol string // "ssh" or "https" + Host string // e.g., "github.com" + Owner string // e.g., "org" + Repo string // e.g., "my-repo" (without .git) +} + +// ParseRemoteURL parses a git remote URL into its components. +// Supports: +// - SSH SCP format: git@github.com:org/repo.git +// - HTTPS format: https://github.com/org/repo.git +// - SSH protocol format: ssh://git@github.com/org/repo.git +func ParseRemoteURL(rawURL string) (*RemoteInfo, error) { + rawURL = strings.TrimSpace(rawURL) + + // SSH SCP format: git@github.com:org/repo.git + if strings.Contains(rawURL, ":") && !strings.Contains(rawURL, "://") { + parts := strings.SplitN(rawURL, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid SSH URL: %s", redactURL(rawURL)) + } + hostPart := parts[0] + pathPart := parts[1] + + host := hostPart + if idx := strings.Index(host, "@"); idx >= 0 { + host = host[idx+1:] + } + + owner, repo, err := SplitOwnerRepo(pathPart) + if err != nil { + return nil, err + } + + return &RemoteInfo{Protocol: ProtocolSSH, Host: host, Owner: owner, Repo: repo}, nil + } + + // URL format: https://github.com/org/repo.git or ssh://git@github.com/org/repo.git + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %s", redactURL(rawURL)) + } + + protocol := u.Scheme + if protocol == "" { + return nil, fmt.Errorf("no protocol in URL: %s", redactURL(rawURL)) + } + host := u.Hostname() + + pathPart := strings.TrimPrefix(u.Path, "/") + owner, repo, err := SplitOwnerRepo(pathPart) + if err != nil { + return nil, err + } + + return &RemoteInfo{Protocol: protocol, Host: host, Owner: owner, Repo: repo}, nil +} + +// SplitOwnerRepo splits "org/repo.git" into owner and repo (without .git suffix). +func SplitOwnerRepo(path string) (string, string, error) { + path = strings.TrimSuffix(path, ".git") + parts := strings.SplitN(path, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("cannot parse owner/repo from path: %s", path) + } + return parts[0], parts[1], nil +} + +// GetRemoteURL returns the URL configured for a git remote. +func GetRemoteURL(ctx context.Context, remoteName string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "remote", "get-url", remoteName) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("remote %q not found", remoteName) + } + return strings.TrimSpace(string(output)), nil +} + +// GetOriginOwnerRepo extracts the owner and repo from the "origin" remote. +func GetOriginOwnerRepo(ctx context.Context) (owner, repo string, err error) { + rawURL, err := GetRemoteURL(ctx, "origin") + if err != nil { + return "", "", err + } + info, err := ParseRemoteURL(rawURL) + if err != nil { + return "", "", err + } + return info.Owner, info.Repo, nil +} + +// redactURL removes credentials from a URL for safe logging. +func redactURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + if u.User != nil { + u.User = url.User("REDACTED") + } + return u.String() +} diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 7d2f68428..c5d5ac5f4 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1100,10 +1100,12 @@ func (s *ManualCommitStrategy) condenseAndUpdateState( // Link checkpoint to trail (best-effort) branchName := GetCurrentBranchName(repo) if branchName != "" && branchName != GetDefaultBranchName(repo) { - store := trail.NewStore(repo) - existing, findErr := store.FindByBranch(branchName) - if findErr == nil && existing != nil { - appendCheckpointToExistingTrail(store, existing.TrailID, result.CheckpointID, head.Hash(), result.Prompts) + trailStore, resolveErr := trail.ResolveStore(ctx, repo) + if resolveErr == nil { + existing, findErr := trailStore.FindByBranch(branchName) + if findErr == nil && existing != nil { + appendCheckpointToExistingTrail(trailStore, existing.TrailID, result.CheckpointID, head.Hash(), result.Prompts) + } } } @@ -2402,7 +2404,7 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( // appendCheckpointToExistingTrail links a checkpoint to the given trail. // Best-effort: silently returns on any error (trails are non-critical metadata). -func appendCheckpointToExistingTrail(store *trail.Store, trailID trail.ID, cpID id.CheckpointID, commitSHA plumbing.Hash, prompts []string) { +func appendCheckpointToExistingTrail(store trail.Store, trailID trail.ID, cpID id.CheckpointID, commitSHA plumbing.Hash, prompts []string) { var summary *string if len(prompts) > 0 { s := truncateForSummary(prompts[len(prompts)-1], 200) diff --git a/cmd/entire/cli/trail/api_store.go b/cmd/entire/cli/trail/api_store.go new file mode 100644 index 000000000..361a7481b --- /dev/null +++ b/cmd/entire/cli/trail/api_store.go @@ -0,0 +1,474 @@ +package trail + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + apiurl "github.com/entireio/cli/cmd/entire/cli/api" + "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/cmd/entire/cli/gitremote" +) + +const ( + maxAPIResponseBytes = 1 << 20 // 1 MB + apiTimeout = 30 * time.Second +) + +// ErrNotAuthenticated is returned when no auth token is available. +var ErrNotAuthenticated = errors.New("not authenticated: run 'entire login' first") + +// APIStore provides trail CRUD operations via the Entire API. +type APIStore struct { + httpClient *http.Client + baseURL string + token string + owner string + repo string +} + +// NewAPIStore creates a new API-backed trail store. +// It resolves the org/repo from the git origin remote and retrieves the auth token. +func NewAPIStore(ctx context.Context) (*APIStore, error) { + token, err := auth.LookupCurrentToken() + if err != nil { + return nil, fmt.Errorf("failed to look up auth token: %w", err) + } + if token == "" { + return nil, ErrNotAuthenticated + } + + owner, repo, err := gitremote.GetOriginOwnerRepo(ctx) + if err != nil { + return nil, fmt.Errorf("failed to resolve org/repo from git remote: %w", err) + } + + return &APIStore{ + httpClient: &http.Client{}, + baseURL: apiurl.BaseURL(), + token: token, + owner: owner, + repo: repo, + }, nil +} + +// NewAPIStoreWithConfig creates an APIStore with explicit configuration (for testing). +func NewAPIStoreWithConfig(httpClient *http.Client, baseURL, token, owner, repo string) *APIStore { + return &APIStore{ + httpClient: httpClient, + baseURL: baseURL, + token: token, + owner: owner, + repo: repo, + } +} + +// apiTrailRequest is the JSON body for creating a trail via POST. +type apiTrailRequest struct { + TrailID string `json:"trail_id"` + Branch string `json:"branch"` + Base string `json:"base"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + Status string `json:"status,omitempty"` + Author string `json:"author,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Labels []string `json:"labels,omitempty"` + Priority string `json:"priority,omitempty"` + Type string `json:"type,omitempty"` +} + +// apiTrailUpdateRequest is the JSON body for updating a trail via PATCH. +type apiTrailUpdateRequest struct { + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + Status *string `json:"status,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Labels []string `json:"labels,omitempty"` + Priority *string `json:"priority,omitempty"` + Type *string `json:"type,omitempty"` +} + +// apiTrailResponse is the JSON response for a single trail from the API. +type apiTrailResponse struct { + TrailID string `json:"trail_id"` + Branch string `json:"branch"` + Base string `json:"base"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` + Author string `json:"author"` + Assignees []string `json:"assignees"` + Labels []string `json:"labels"` + Priority string `json:"priority"` + Type string `json:"type"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt *jsonTime `json:"merged_at"` + Reviewers []struct { + Login string `json:"login"` + Status string `json:"status"` + } `json:"reviewers"` +} + +// jsonTime wraps time.Time for nullable JSON time parsing. +type jsonTime struct { + time.Time +} + +func (jt *jsonTime) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + if err := json.Unmarshal(data, &jt.Time); err != nil { + return fmt.Errorf("unmarshal time: %w", err) + } + return nil +} + +func (r *apiTrailResponse) toMetadata() *Metadata { + m := &Metadata{ + TrailID: ID(r.TrailID), + Branch: r.Branch, + Base: r.Base, + Title: r.Title, + Body: r.Body, + Status: Status(r.Status), + Author: r.Author, + Assignees: r.Assignees, + Labels: r.Labels, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + Priority: Priority(r.Priority), + Type: Type(r.Type), + } + if r.MergedAt != nil && !r.MergedAt.IsZero() { + t := r.MergedAt.Time + m.MergedAt = &t + } + for _, rv := range r.Reviewers { + m.Reviewers = append(m.Reviewers, Reviewer{ + Login: rv.Login, + Status: ReviewerStatus(rv.Status), + }) + } + if m.Assignees == nil { + m.Assignees = []string{} + } + if m.Labels == nil { + m.Labels = []string{} + } + return m +} + +// trailsPath returns the API path prefix for trails in this repo. +func (s *APIStore) trailsPath() string { + return fmt.Sprintf("/%s/%s/trails", s.owner, s.repo) +} + +// List returns all trail metadata from the API. +func (s *APIStore) List() ([]*Metadata, error) { + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + resp, err := s.doRequest(ctx, http.MethodGet, s.trailsPath(), nil) + if err != nil { + return nil, fmt.Errorf("list trails: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, readAPIError(resp, "list trails") + } + + var items []apiTrailResponse + if err := decodeJSON(resp.Body, &items); err != nil { + return nil, fmt.Errorf("list trails: %w", err) + } + + trails := make([]*Metadata, 0, len(items)) + for i := range items { + trails = append(trails, items[i].toMetadata()) + } + return trails, nil +} + +// Read reads a trail by its ID from the API. +func (s *APIStore) Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) { + if err := ValidateID(string(trailID)); err != nil { + return nil, nil, nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + path := fmt.Sprintf("%s/%s", s.trailsPath(), trailID) + resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, nil, fmt.Errorf("read trail: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil, nil, ErrTrailNotFound + } + if resp.StatusCode != http.StatusOK { + return nil, nil, nil, readAPIError(resp, "read trail") + } + + var item apiTrailResponse + if err := decodeJSON(resp.Body, &item); err != nil { + return nil, nil, nil, fmt.Errorf("read trail: %w", err) + } + + // The API does not return discussion/checkpoints inline. + // Return empty defaults — callers that need them will use separate endpoints. + return item.toMetadata(), &Discussion{Comments: []Comment{}}, &Checkpoints{Checkpoints: []CheckpointRef{}}, nil +} + +// FindByBranch finds a trail for the given branch name. +// NOTE: This filters client-side from List(). A server-side ?branch= filter would be more efficient. +// See "Missing API Endpoints" in the implementation notes. +func (s *APIStore) FindByBranch(branchName string) (*Metadata, error) { + trails, err := s.List() + if err != nil { + return nil, err + } + for _, t := range trails { + if t.Branch == branchName { + return t, nil + } + } + return nil, nil //nolint:nilnil // nil, nil means "not found" — callers check both +} + +// Write creates a new trail via the API. +func (s *APIStore) Write(metadata *Metadata, _ *Discussion, _ *Checkpoints) error { + if metadata.TrailID.IsEmpty() { + return errors.New("trail ID is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + body := apiTrailRequest{ + TrailID: string(metadata.TrailID), + Branch: metadata.Branch, + Base: metadata.Base, + Title: metadata.Title, + Body: metadata.Body, + Status: string(metadata.Status), + Author: metadata.Author, + Assignees: metadata.Assignees, + Labels: metadata.Labels, + Priority: string(metadata.Priority), + Type: string(metadata.Type), + } + + resp, err := s.doJSON(ctx, http.MethodPost, s.trailsPath(), body) + if err != nil { + return fmt.Errorf("create trail: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return readAPIError(resp, "create trail") + } + + return nil +} + +// Update reads the current trail, applies the update function, and PATCHes the changes. +func (s *APIStore) Update(trailID ID, updateFn func(*Metadata)) error { + metadata, _, _, err := s.Read(trailID) + if err != nil { + return fmt.Errorf("read trail for update: %w", err) + } + + // Snapshot fields before update + oldTitle := metadata.Title + oldBody := metadata.Body + oldStatus := metadata.Status + oldAssignees := metadata.Assignees + oldLabels := metadata.Labels + oldPriority := metadata.Priority + oldType := metadata.Type + + updateFn(metadata) + metadata.UpdatedAt = time.Now() + + // Build PATCH body with only changed fields + patch := apiTrailUpdateRequest{} + hasChanges := false + + if metadata.Title != oldTitle { + patch.Title = &metadata.Title + hasChanges = true + } + if metadata.Body != oldBody { + patch.Body = &metadata.Body + hasChanges = true + } + if metadata.Status != oldStatus { + s := string(metadata.Status) + patch.Status = &s + hasChanges = true + } + if !stringSliceEqual(metadata.Assignees, oldAssignees) { + patch.Assignees = metadata.Assignees + hasChanges = true + } + if !stringSliceEqual(metadata.Labels, oldLabels) { + patch.Labels = metadata.Labels + hasChanges = true + } + if metadata.Priority != oldPriority { + p := string(metadata.Priority) + patch.Priority = &p + hasChanges = true + } + if metadata.Type != oldType { + t := string(metadata.Type) + patch.Type = &t + hasChanges = true + } + + if !hasChanges { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + path := fmt.Sprintf("%s/%s", s.trailsPath(), trailID) + resp, err := s.doJSON(ctx, http.MethodPatch, path, patch) + if err != nil { + return fmt.Errorf("update trail: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return readAPIError(resp, "update trail") + } + + return nil +} + +// AddCheckpoint links a checkpoint to a trail. +// NOTE: The backend does not yet have a dedicated endpoint for this. +// This is a no-op that logs the intent. See "Missing API Endpoints". +func (s *APIStore) AddCheckpoint(_ ID, _ CheckpointRef) error { + // TODO: Implement when POST /:org/:repo/trails/:trailId/checkpoints is available. + // For now, checkpoint-to-trail linking is implicit via branch name in the DB. + return nil +} + +// Delete removes a trail via the API. +func (s *APIStore) Delete(trailID ID) error { + if err := ValidateID(string(trailID)); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) + defer cancel() + + path := fmt.Sprintf("%s/%s", s.trailsPath(), trailID) + resp, err := s.doRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("delete trail: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return readAPIError(resp, "delete trail") + } + + return nil +} + +// doRequest sends an HTTP request with auth headers. +func (s *APIStore) doRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + endpoint, err := apiurl.ResolveURLFromBase(s.baseURL, path) + if err != nil { + return nil, fmt.Errorf("resolve URL %s: %w", path, err) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+s.token) + req.Header.Set("User-Agent", "entire-cli") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request %s %s: %w", method, path, err) + } + + return resp, nil +} + +// doJSON sends a JSON-encoded request body. +func (s *APIStore) doJSON(ctx context.Context, method, path string, body any) (*http.Response, error) { + jsonBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + return s.doRequest(ctx, method, path, bytes.NewReader(jsonBytes)) +} + +// readAPIError reads an error response from the API. +func readAPIError(resp *http.Response, action string) error { + body, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes)) + if err != nil { + return fmt.Errorf("%s: status %d", action, resp.StatusCode) + } + + var apiErr struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &apiErr) == nil && apiErr.Error != "" { + return fmt.Errorf("%s: %s", action, apiErr.Error) + } + + text := string(bytes.TrimSpace(body)) + if text != "" { + return fmt.Errorf("%s: status %d: %s", action, resp.StatusCode, text) + } + return fmt.Errorf("%s: status %d", action, resp.StatusCode) +} + +// decodeJSON reads and decodes a JSON response body. +func decodeJSON(r io.Reader, dest any) error { + body, err := io.ReadAll(io.LimitReader(r, maxAPIResponseBytes)) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if err := json.Unmarshal(body, dest); err != nil { + return fmt.Errorf("decode response: %w", err) + } + return nil +} + +// stringSliceEqual compares two string slices for equality. +func stringSliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/cmd/entire/cli/trail/migrate.go b/cmd/entire/cli/trail/migrate.go new file mode 100644 index 000000000..0dd0d234f --- /dev/null +++ b/cmd/entire/cli/trail/migrate.go @@ -0,0 +1,202 @@ +package trail + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/paths" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/filemode" + "github.com/go-git/go-git/v6/plumbing/object" +) + +const ( + migrationMarkerFile = "DEPRECATED_MOVED_TO_DATABASE_STORAGE" +) + +// migrationMarker is the content of the marker file placed on the git branch after migration. +type migrationMarker struct { + MigratedAt time.Time `json:"migrated_at"` + TrailCount int `json:"trail_count"` + Message string `json:"message"` +} + +// IsMigrated checks if the entire/trails/v1 branch has been migrated to the API. +func IsMigrated(repo *git.Repository) bool { + refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) + ref, err := repo.Reference(refName, true) + if err != nil { + // Try remote tracking branch + remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.TrailsBranchName) + ref, err = repo.Reference(remoteRefName, true) + if err != nil { + return false // No branch = not migrated (and nothing to migrate) + } + } + + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + return false + } + + tree, err := repo.TreeObject(commit.TreeHash) + if err != nil { + return false + } + + _, err = tree.FindEntry(migrationMarkerFile) + return err == nil +} + +// MigrateIfNeeded migrates trails from the git branch to the API if not already done. +// Returns the number of trails migrated, or 0 if already migrated or nothing to migrate. +func MigrateIfNeeded(_ context.Context, apiStore *APIStore, repo *git.Repository) (int, error) { //nolint:unparam // error return reserved for future migration failures + if IsMigrated(repo) { + return 0, nil + } + + gitStore := NewGitStore(repo) + trails, err := gitStore.List() + if err != nil { + // No branch or empty — nothing to migrate, mark as done + if markErr := placeMigrationMarker(repo, 0); markErr != nil { + slog.Warn("failed to place migration marker", slog.String("error", markErr.Error())) + } + return 0, nil + } + + if len(trails) == 0 { + if markErr := placeMigrationMarker(repo, 0); markErr != nil { + slog.Warn("failed to place migration marker", slog.String("error", markErr.Error())) + } + return 0, nil + } + + // Migrate each trail to the API + migrated := 0 + for _, m := range trails { + // Read full trail data (discussion, checkpoints) for future use + _, discussion, checkpoints, readErr := gitStore.Read(m.TrailID) + if readErr != nil { + slog.Warn("failed to read trail for migration, skipping", + slog.String("trail_id", string(m.TrailID)), + slog.String("error", readErr.Error()), + ) + continue + } + + if writeErr := apiStore.Write(m, discussion, checkpoints); writeErr != nil { + slog.Warn("failed to migrate trail to API, skipping", + slog.String("trail_id", string(m.TrailID)), + slog.String("error", writeErr.Error()), + ) + continue + } + migrated++ + } + + // Place marker on git branch + if markErr := placeMigrationMarker(repo, migrated); markErr != nil { + slog.Warn("failed to place migration marker", slog.String("error", markErr.Error())) + } + + return migrated, nil +} + +// placeMigrationMarker writes the MIGRATED_TO_API marker file to the entire/trails/v1 branch. +func placeMigrationMarker(repo *git.Repository, trailCount int) error { + refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) + ref, err := repo.Reference(refName, true) + if err != nil { + // Branch doesn't exist — create orphan with just the marker + return createOrphanWithMarker(repo, trailCount) + } + + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + return fmt.Errorf("read trails branch commit: %w", err) + } + + marker := migrationMarker{ + MigratedAt: time.Now().UTC(), + TrailCount: trailCount, + Message: "Trails have been migrated to API-based storage. This branch is kept for historical reference.", + } + markerJSON, err := json.MarshalIndent(marker, "", " ") + if err != nil { + return fmt.Errorf("marshal migration marker: %w", err) + } + + blobHash, err := checkpoint.CreateBlobFromContent(repo, markerJSON) + if err != nil { + return fmt.Errorf("create marker blob: %w", err) + } + + // Add marker file to existing tree + newTreeHash, err := checkpoint.UpdateSubtree( + repo, commit.TreeHash, + nil, // root level + []object.TreeEntry{{Name: migrationMarkerFile, Mode: filemode.Regular, Hash: blobHash}}, + checkpoint.UpdateSubtreeOptions{MergeMode: checkpoint.MergeKeepExisting}, + ) + if err != nil { + return fmt.Errorf("update tree with marker: %w", err) + } + + authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(repo) + commitHash, err := checkpoint.CreateCommit(repo, newTreeHash, ref.Hash(), "Mark trails as migrated to API", authorName, authorEmail) + if err != nil { + return fmt.Errorf("create marker commit: %w", err) + } + + newRef := plumbing.NewHashReference(refName, commitHash) + if err := repo.Storer.SetReference(newRef); err != nil { + return fmt.Errorf("set branch reference: %w", err) + } + return nil +} + +// createOrphanWithMarker creates the entire/trails/v1 orphan branch with just the marker file. +func createOrphanWithMarker(repo *git.Repository, trailCount int) error { + marker := migrationMarker{ + MigratedAt: time.Now().UTC(), + TrailCount: trailCount, + Message: "Trails have been migrated to API-based storage.", + } + markerJSON, err := json.MarshalIndent(marker, "", " ") + if err != nil { + return fmt.Errorf("marshal migration marker: %w", err) + } + + blobHash, err := checkpoint.CreateBlobFromContent(repo, markerJSON) + if err != nil { + return fmt.Errorf("create marker blob: %w", err) + } + + entries := map[string]object.TreeEntry{ + migrationMarkerFile: {Name: migrationMarkerFile, Mode: filemode.Regular, Hash: blobHash}, + } + treeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) + if err != nil { + return fmt.Errorf("build marker tree: %w", err) + } + + authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(repo) + commitHash, err := checkpoint.CreateCommit(repo, treeHash, plumbing.ZeroHash, "Mark trails as migrated to API", authorName, authorEmail) + if err != nil { + return fmt.Errorf("create marker commit: %w", err) + } + + refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) + newRef := plumbing.NewHashReference(refName, commitHash) + if err := repo.Storer.SetReference(newRef); err != nil { + return fmt.Errorf("set branch reference: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/trail/resolve.go b/cmd/entire/cli/trail/resolve.go new file mode 100644 index 000000000..5743fa5e4 --- /dev/null +++ b/cmd/entire/cli/trail/resolve.go @@ -0,0 +1,46 @@ +package trail + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/go-git/go-git/v6" +) + +// ResolveStore returns the best available Store for the current context. +// If the user is authenticated, returns an APIStore (migrating git trails if needed). +// Otherwise, falls back to a GitStore. +func ResolveStore(ctx context.Context, repo *git.Repository) (Store, error) { //nolint:ireturn,unparam // intentional interface return; error reserved for future auth failures + apiStore, err := NewAPIStore(ctx) + if err != nil { + if errors.Is(err, ErrNotAuthenticated) { + // Not logged in — fall back to git store silently + return NewGitStore(repo), nil + } + // Other errors (no remote, etc.) — fall back to git store with a warning + slog.Debug("falling back to git-based trail store", + slog.String("reason", err.Error()), + ) + return NewGitStore(repo), nil + } + + // Authenticated — migrate if needed, then use API + migrated, migrateErr := MigrateIfNeeded(ctx, apiStore, repo) + if migrateErr != nil { + slog.Warn("trail migration failed, using API store anyway", + slog.String("error", migrateErr.Error()), + ) + } else if migrated > 0 { + fmt.Printf("Migrated %d trail(s) to Entire API.\n", migrated) + } + + return apiStore, nil +} + +// IsAPIBacked returns true if the store is an API-backed store. +func IsAPIBacked(store Store) bool { + _, ok := store.(*APIStore) + return ok +} diff --git a/cmd/entire/cli/trail/store.go b/cmd/entire/cli/trail/store.go index fd484eec0..abbfef078 100644 --- a/cmd/entire/cli/trail/store.go +++ b/cmd/entire/cli/trail/store.go @@ -25,18 +25,25 @@ const ( // ErrTrailNotFound is returned when a trail cannot be found. var ErrTrailNotFound = errors.New("trail not found") -// Store provides CRUD operations for trail metadata on the entire/trails/v1 branch. -type Store struct { +// GitStore provides CRUD operations for trail metadata on the entire/trails/v1 branch. +type GitStore struct { repo *git.Repository } // NewStore creates a new trail store backed by the given git repository. -func NewStore(repo *git.Repository) *Store { - return &Store{repo: repo} +// +// Deprecated: Use NewGitStore or ResolveStore instead. +func NewStore(repo *git.Repository) *GitStore { + return NewGitStore(repo) +} + +// NewGitStore creates a new trail store backed by the given git repository. +func NewGitStore(repo *git.Repository) *GitStore { + return &GitStore{repo: repo} } // EnsureBranch creates the entire/trails/v1 orphan branch if it doesn't exist. -func (s *Store) EnsureBranch() error { +func (s *GitStore) EnsureBranch() error { refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) _, err := s.repo.Reference(refName, true) if err == nil { @@ -64,7 +71,7 @@ func (s *Store) EnsureBranch() error { // Write writes trail metadata, discussion, and checkpoints to the entire/trails/v1 branch. // If checkpoints is nil, an empty checkpoints list is written. -func (s *Store) Write(metadata *Metadata, discussion *Discussion, checkpoints *Checkpoints) error { +func (s *GitStore) Write(metadata *Metadata, discussion *Discussion, checkpoints *Checkpoints) error { if metadata.TrailID.IsEmpty() { return errors.New("trail ID is required") } @@ -101,7 +108,7 @@ func (s *Store) Write(metadata *Metadata, discussion *Discussion, checkpoints *C } // buildTrailEntries creates blob objects for a trail's 3 files and returns them as tree entries. -func (s *Store) buildTrailEntries(metadata *Metadata, discussion *Discussion, checkpoints *Checkpoints) ([]object.TreeEntry, error) { +func (s *GitStore) buildTrailEntries(metadata *Metadata, discussion *Discussion, checkpoints *Checkpoints) ([]object.TreeEntry, error) { if discussion == nil { discussion = &Discussion{Comments: []Comment{}} } @@ -140,7 +147,7 @@ func (s *Store) buildTrailEntries(metadata *Metadata, discussion *Discussion, ch } // Read reads a trail by its ID from the entire/trails/v1 branch. -func (s *Store) Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) { +func (s *GitStore) Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) { if err := ValidateID(string(trailID)); err != nil { return nil, nil, nil, err } @@ -207,7 +214,7 @@ func (s *Store) Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) { // FindByBranch finds a trail for the given branch name. // Returns (nil, nil) if no trail exists for the branch. -func (s *Store) FindByBranch(branchName string) (*Metadata, error) { +func (s *GitStore) FindByBranch(branchName string) (*Metadata, error) { trails, err := s.List() if err != nil { return nil, err @@ -222,7 +229,7 @@ func (s *Store) FindByBranch(branchName string) (*Metadata, error) { } // List returns all trail metadata from the entire/trails/v1 branch. -func (s *Store) List() ([]*Metadata, error) { +func (s *GitStore) List() ([]*Metadata, error) { tree, err := s.getBranchTree() if err != nil { // Branch doesn't exist yet — no trails @@ -265,7 +272,7 @@ func (s *Store) List() ([]*Metadata, error) { // Update updates an existing trail's metadata. It reads the current metadata, // applies the provided update function, and writes it back. -func (s *Store) Update(trailID ID, updateFn func(*Metadata)) error { +func (s *GitStore) Update(trailID ID, updateFn func(*Metadata)) error { // ValidateID is called by Read, no need to duplicate here metadata, discussion, checkpoints, err := s.Read(trailID) if err != nil { @@ -280,7 +287,7 @@ func (s *Store) Update(trailID ID, updateFn func(*Metadata)) error { // AddCheckpoint prepends a checkpoint reference to a trail's checkpoints list (newest first). // Only reads and writes the checkpoints.json file — metadata and discussion are untouched. -func (s *Store) AddCheckpoint(trailID ID, ref CheckpointRef) error { +func (s *GitStore) AddCheckpoint(trailID ID, ref CheckpointRef) error { if err := ValidateID(string(trailID)); err != nil { return err } @@ -336,7 +343,7 @@ func (s *Store) AddCheckpoint(trailID ID, ref CheckpointRef) error { } // Delete removes a trail from the entire/trails/v1 branch. -func (s *Store) Delete(trailID ID) error { +func (s *GitStore) Delete(trailID ID) error { if err := ValidateID(string(trailID)); err != nil { return err } @@ -375,7 +382,7 @@ func (s *Store) Delete(trailID ID) error { } // navigateToTrailTree walks rootTree → shard → suffix and returns the trail's subtree. -func (s *Store) navigateToTrailTree(rootTreeHash plumbing.Hash, shard, suffix string) (*object.Tree, error) { +func (s *GitStore) navigateToTrailTree(rootTreeHash plumbing.Hash, shard, suffix string) (*object.Tree, error) { rootTree, err := s.repo.TreeObject(rootTreeHash) if err != nil { return nil, fmt.Errorf("trail %s/%s not found: %w", shard, suffix, err) @@ -406,7 +413,7 @@ func (s *Store) navigateToTrailTree(rootTreeHash plumbing.Hash, shard, suffix st // readCheckpointsFromTrailTree reads checkpoints.json from a trail's subtree. // Returns empty checkpoints if the file doesn't exist yet. -func (s *Store) readCheckpointsFromTrailTree(trailTree *object.Tree) (*Checkpoints, error) { +func (s *GitStore) readCheckpointsFromTrailTree(trailTree *object.Tree) (*Checkpoints, error) { cpEntry, err := trailTree.FindEntry(checkpointsFile) if err != nil { // No checkpoints file yet — return empty @@ -433,7 +440,7 @@ func (s *Store) readCheckpointsFromTrailTree(trailTree *object.Tree) (*Checkpoin } // commitAndUpdateRef creates a commit and updates the trails branch reference. -func (s *Store) commitAndUpdateRef(treeHash, parentHash plumbing.Hash, message string) error { +func (s *GitStore) commitAndUpdateRef(treeHash, parentHash plumbing.Hash, message string) error { authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(s.repo) commitHash, err := checkpoint.CreateCommit(s.repo, treeHash, parentHash, message, authorName, authorEmail) if err != nil { @@ -449,7 +456,7 @@ func (s *Store) commitAndUpdateRef(treeHash, parentHash plumbing.Hash, message s // getBranchRef returns the commit hash and root tree hash for the entire/trails/v1 branch HEAD // without flattening the tree. Falls back to remote tracking branch if local is missing. -func (s *Store) getBranchRef() (commitHash, rootTreeHash plumbing.Hash, err error) { +func (s *GitStore) getBranchRef() (commitHash, rootTreeHash plumbing.Hash, err error) { refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) ref, refErr := s.repo.Reference(refName, true) if refErr != nil { @@ -470,7 +477,7 @@ func (s *Store) getBranchRef() (commitHash, rootTreeHash plumbing.Hash, err erro } // getBranchTree returns the tree for the entire/trails/v1 branch HEAD. -func (s *Store) getBranchTree() (*object.Tree, error) { +func (s *GitStore) getBranchTree() (*object.Tree, error) { _, rootTreeHash, err := s.getBranchRef() if err != nil { return nil, err diff --git a/cmd/entire/cli/trail/trail.go b/cmd/entire/cli/trail/trail.go index 9b3eb672d..9dc39e35b 100644 --- a/cmd/entire/cli/trail/trail.go +++ b/cmd/entire/cli/trail/trail.go @@ -1,7 +1,9 @@ // Package trail provides types and helpers for managing trail metadata. -// Trails are branch-centric work tracking abstractions stored on the -// entire/trails/v1 orphan branch. They answer "why/what" (human intent) -// while checkpoints answer "how/when" (machine snapshots). +// Trails are branch-centric work tracking abstractions. They answer "why/what" +// (human intent) while checkpoints answer "how/when" (machine snapshots). +// +// Trails can be stored either locally on the entire/trails/v1 git orphan branch +// (GitStore) or remotely via the Entire API (APIStore). package trail import ( @@ -13,6 +15,18 @@ import ( "time" ) +// Store is the interface for trail CRUD operations. +// Both GitStore (git-backed) and APIStore (API-backed) implement this. +type Store interface { + Write(metadata *Metadata, discussion *Discussion, checkpoints *Checkpoints) error + Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) + FindByBranch(branchName string) (*Metadata, error) + List() ([]*Metadata, error) + Update(trailID ID, updateFn func(*Metadata)) error + AddCheckpoint(trailID ID, ref CheckpointRef) error + Delete(trailID ID) error +} + const idLength = 6 // 6 bytes = 12 hex chars // ID is a 12-character hex identifier for trails. diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 8795a1ba2..1c36d14af 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -53,12 +53,12 @@ func runTrailShow(w io.Writer) error { return runTrailListAll(w, "", false, false) } - repo, err := strategy.OpenRepository(context.Background()) + ctx := context.Background() + store, err := resolveTrailStore(ctx) if err != nil { - return fmt.Errorf("failed to open repository: %w", err) + return fmt.Errorf("failed to open trail store: %w", err) } - store := trail.NewStore(repo) metadata, err := store.FindByBranch(branch) if err != nil || metadata == nil { return runTrailListAll(w, "", false, false) @@ -109,15 +109,17 @@ func newTrailListCmd() *cobra.Command { } func runTrailListAll(w io.Writer, statusFilter string, jsonOutput, showAll bool) error { - // Fetch remote trails branch so we see trails from collaborators - fetchTrailsBranch() - - repo, err := strategy.OpenRepository(context.Background()) + ctx := context.Background() + store, err := resolveTrailStore(ctx) if err != nil { - return fmt.Errorf("failed to open repository: %w", err) + return fmt.Errorf("failed to open trail store: %w", err) + } + + // Only fetch git branch when using git-backed store + if !trail.IsAPIBacked(store) { + fetchTrailsBranch() } - store := trail.NewStore(repo) trails, err := store.List() if err != nil { return fmt.Errorf("failed to list trails: %w", err) @@ -270,8 +272,14 @@ func runTrailCreate(cmd *cobra.Command, title, body, base, branch, statusStr str fmt.Fprintf(errW, "Note: trail will be created for branch %q (not the current branch)\n", branch) } + // Resolve trail store (API or git) + ctx := context.Background() + store, err := resolveTrailStore(ctx) + if err != nil { + return fmt.Errorf("failed to open trail store: %w", err) + } + // Check if trail already exists for this branch - store := trail.NewStore(repo) existing, err := store.FindByBranch(branch) if err == nil && existing != nil { fmt.Fprintf(w, "Trail already exists for branch %q (ID: %s)\n", branch, existing.TrailID) @@ -327,8 +335,11 @@ func runTrailCreate(cmd *cobra.Command, title, body, base, branch, statusStr str fmt.Fprintf(w, "Pushed branch %s to origin\n", branch) } } - if err := strategy.PushTrailsBranch(context.Background(), "origin"); err != nil { - fmt.Fprintf(errW, "Warning: failed to push trail data: %v\n", err) + // Only push trails branch when using git-backed store + if !trail.IsAPIBacked(store) { + if err := strategy.PushTrailsBranch(context.Background(), "origin"); err != nil { + fmt.Fprintf(errW, "Warning: failed to push trail data: %v\n", err) + } } // Checkout the branch if requested or prompted @@ -381,20 +392,22 @@ func newTrailUpdateCmd() *cobra.Command { } func runTrailUpdate(w, errW io.Writer, statusStr, title, body, branch string, labelAdd, labelRemove []string) error { - repo, err := strategy.OpenRepository(context.Background()) - if err != nil { - return fmt.Errorf("failed to open repository: %w", err) - } + ctx := context.Background() // Determine branch + var err error if branch == "" { - branch, err = GetCurrentBranch(context.Background()) + branch, err = GetCurrentBranch(ctx) if err != nil { return fmt.Errorf("failed to determine current branch: %w", err) } } - store := trail.NewStore(repo) + store, err := resolveTrailStore(ctx) + if err != nil { + return fmt.Errorf("failed to open trail store: %w", err) + } + metadata, err := store.FindByBranch(branch) if err != nil { return fmt.Errorf("failed to find trail: %w", err) @@ -476,8 +489,11 @@ func runTrailUpdate(w, errW io.Writer, statusStr, title, body, branch string, la fmt.Fprintf(w, "Updated trail for branch %s\n", branch) - if err := strategy.PushTrailsBranch(context.Background(), "origin"); err != nil { - fmt.Fprintf(errW, "Warning: failed to push trail data: %v\n", err) + // Only push trails branch when using git-backed store + if !trail.IsAPIBacked(store) { + if err := strategy.PushTrailsBranch(context.Background(), "origin"); err != nil { + fmt.Fprintf(errW, "Warning: failed to push trail data: %v\n", err) + } } return nil @@ -636,3 +652,16 @@ func pushBranchToOrigin(branchName string) error { } return nil } + +// resolveTrailStore returns the best available trail store (API or git-backed). +func resolveTrailStore(ctx context.Context) (trail.Store, error) { //nolint:ireturn // intentional interface return for store abstraction + repo, err := strategy.OpenRepository(ctx) + if err != nil { + return nil, fmt.Errorf("failed to open repository: %w", err) + } + store, err := trail.ResolveStore(ctx, repo) + if err != nil { + return nil, fmt.Errorf("failed to resolve trail store: %w", err) + } + return store, nil +} From 0a22289d30079445a722a3edf5f2cda633a961a2 Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Wed, 18 Mar 2026 16:11:47 +0100 Subject: [PATCH 2/2] Update migrate.go Entire-Checkpoint: 2c5705f6152f --- cmd/entire/cli/trail/migrate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/trail/migrate.go b/cmd/entire/cli/trail/migrate.go index 0dd0d234f..9fb2957de 100644 --- a/cmd/entire/cli/trail/migrate.go +++ b/cmd/entire/cli/trail/migrate.go @@ -17,7 +17,7 @@ import ( ) const ( - migrationMarkerFile = "DEPRECATED_MOVED_TO_DATABASE_STORAGE" + migrationMarkerFile = "DEPRECATED_MOVED_TO_ENTIRE_STORAGE" ) // migrationMarker is the content of the marker file placed on the git branch after migration.