diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index b12ad9412..ee7e77dd0 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -172,6 +172,17 @@ type TokenCalculator interface { CalculateTokenUsage(transcriptData []byte, fromOffset int) (*TokenUsage, error) } +// TextGenerator is an optional interface for agents whose CLI supports +// non-interactive text generation (e.g., claude --print). +// Used for AI-powered metadata generation (trail titles, summaries). +type TextGenerator interface { + Agent + + // GenerateText sends a prompt to the agent's CLI and returns the raw text response. + // model is a hint (e.g., "haiku", "sonnet"). Implementations may ignore if not applicable. + GenerateText(ctx context.Context, prompt string, model string) (string, error) +} + // HookResponseWriter is implemented by agents that support structured hook responses. // Agents that implement this can output messages (e.g., banners) to the user via // the agent's response protocol. For example, Claude Code outputs JSON with a diff --git a/cmd/entire/cli/agent/claudecode/generate.go b/cmd/entire/cli/agent/claudecode/generate.go new file mode 100644 index 000000000..8533375c8 --- /dev/null +++ b/cmd/entire/cli/agent/claudecode/generate.go @@ -0,0 +1,72 @@ +package claudecode + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +// GenerateText sends a prompt to the Claude CLI and returns the raw text response. +// Implements the agent.TextGenerator interface. +// The model parameter hints which model to use (e.g., "haiku", "sonnet"). +// If empty, defaults to "haiku" for fast, cheap generation. +func (c *ClaudeCodeAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { + claudePath := "claude" + if model == "" { + model = "haiku" + } + + cmd := exec.CommandContext(ctx, claudePath, + "--print", "--output-format", "json", + "--model", model, "--setting-sources", "") + + // Isolate from the user's git repo to prevent recursive hook triggers + // and index pollution (same approach as summarize/claude.go). + cmd.Dir = os.TempDir() + cmd.Env = stripGitEnv(os.Environ()) + cmd.Stdin = strings.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + var execErr *exec.Error + if errors.As(err, &execErr) { + return "", fmt.Errorf("claude CLI not found: %w", err) + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", fmt.Errorf("claude CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) + } + return "", fmt.Errorf("failed to run claude CLI: %w", err) + } + + // Parse the {"result": "..."} envelope + var response struct { + Result string `json:"result"` + } + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + return "", fmt.Errorf("failed to parse claude CLI response: %w", err) + } + + return response.Result, nil +} + +// stripGitEnv returns a copy of env with all GIT_* variables removed. +// This prevents a subprocess from discovering or modifying the parent's git repo. +// Duplicated from summarize/claude.go — simple filter not worth extracting to shared package. +func stripGitEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if !strings.HasPrefix(e, "GIT_") { + filtered = append(filtered, e) + } + } + return filtered +} diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index ecc908a83..0939010d2 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -1526,6 +1526,40 @@ func GetGitAuthorFromRepo(repo *git.Repository) (name, email string) { return name, email } +// CreateCommit creates a git commit object with the given tree, parent, message, and author. +// If parentHash is ZeroHash, the commit is created without a parent (orphan commit). +func CreateCommit(repo *git.Repository, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) { + now := time.Now() + sig := object.Signature{ + Name: authorName, + Email: authorEmail, + When: now, + } + + commit := &object.Commit{ + TreeHash: treeHash, + Author: sig, + Committer: sig, + Message: message, + } + + if parentHash != plumbing.ZeroHash { + commit.ParentHashes = []plumbing.Hash{parentHash} + } + + obj := repo.Storer.NewEncodedObject() + if err := commit.Encode(obj); err != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err) + } + + hash, err := repo.Storer.SetEncodedObject(obj) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to store commit: %w", err) + } + + return hash, nil +} + // readTranscriptFromTree reads a transcript from a git tree, handling both chunked and non-chunked formats. // It checks for chunk files first (.001, .002, etc.), then falls back to the base file. // The agentType is used for reassembling chunks in the correct format. diff --git a/cmd/entire/cli/checkpoint/temporary.go b/cmd/entire/cli/checkpoint/temporary.go index f066f1024..15543d05e 100644 --- a/cmd/entire/cli/checkpoint/temporary.go +++ b/cmd/entire/cli/checkpoint/temporary.go @@ -764,36 +764,7 @@ func (s *GitStore) buildTreeWithChanges( // createCommit creates a commit object. func (s *GitStore) createCommit(treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) { - now := time.Now() - sig := object.Signature{ - Name: authorName, - Email: authorEmail, - When: now, - } - - commit := &object.Commit{ - TreeHash: treeHash, - Author: sig, - Committer: sig, - Message: message, - } - - // Add parent if not a new branch - if parentHash != plumbing.ZeroHash { - commit.ParentHashes = []plumbing.Hash{parentHash} - } - - obj := s.repo.Storer.NewEncodedObject() - if err := commit.Encode(obj); err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err) - } - - hash, err := s.repo.Storer.SetEncodedObject(obj) - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to store commit: %w", err) - } - - return hash, nil + return CreateCommit(s.repo, treeHash, parentHash, message, authorName, authorEmail) } // Helper functions extracted from strategy/common.go diff --git a/cmd/entire/cli/integration_test/deferred_finalization_test.go b/cmd/entire/cli/integration_test/deferred_finalization_test.go index ba84de8b8..b376b5d5f 100644 --- a/cmd/entire/cli/integration_test/deferred_finalization_test.go +++ b/cmd/entire/cli/integration_test/deferred_finalization_test.go @@ -365,7 +365,7 @@ func TestShadow_CarryForward_ActiveSession(t *testing.T) { // indicate a regression in carry-forward cleanup. branchesAfterAll := env.ListBranchesWithPrefix("entire/") for _, b := range branchesAfterAll { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { t.Errorf("Unexpected shadow branch after all files committed: %s", b) } } @@ -576,7 +576,7 @@ func TestShadow_AgentCommitsMidTurn_UserCommitsRemainder(t *testing.T) { // No shadow branches should remain after all files are committed branchesAfter := env.ListBranchesWithPrefix("entire/") for _, b := range branchesAfter { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { t.Errorf("Unexpected shadow branch after all files committed: %s", b) } } @@ -1180,7 +1180,7 @@ func TestShadow_EndedSession_UserCommitsRemainingFiles(t *testing.T) { // No shadow branches should remain branchesAfter := env.ListBranchesWithPrefix("entire/") for _, b := range branchesAfter { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { t.Errorf("Unexpected shadow branch after all files committed: %s", b) } } @@ -1274,7 +1274,7 @@ func TestShadow_DeletedFiles_CheckpointAndCarryForward(t *testing.T) { // doesn't produce full metadata (known limitation). branchesAfter := env.ListBranchesWithPrefix("entire/") for _, b := range branchesAfter { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { t.Logf("Shadow branch remaining after commits (may be expected for deleted files): %s", b) } } @@ -1368,7 +1368,7 @@ func TestShadow_CarryForward_ModifiedExistingFiles(t *testing.T) { // No shadow branches should remain branchesAfter := env.ListBranchesWithPrefix("entire/") for _, b := range branchesAfter { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { t.Errorf("Unexpected shadow branch after all files committed: %s", b) } } diff --git a/cmd/entire/cli/integration_test/mid_session_commit_test.go b/cmd/entire/cli/integration_test/mid_session_commit_test.go index 364384fea..14e90e82b 100644 --- a/cmd/entire/cli/integration_test/mid_session_commit_test.go +++ b/cmd/entire/cli/integration_test/mid_session_commit_test.go @@ -70,7 +70,7 @@ func TestShadowStrategy_MidSessionCommit_FromTranscript(t *testing.T) { shadowBranches := env.ListBranchesWithPrefix("entire/") hasShadowBranch := false for _, b := range shadowBranches { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { hasShadowBranch = true break } diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index 7d3890e24..355b689a9 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -36,6 +36,10 @@ const ( // MetadataBranchName is the orphan branch used by manual-commit strategy to store metadata const MetadataBranchName = "entire/checkpoints/v1" +// TrailsBranchName is the orphan branch used to store trail metadata. +// Trails are branch-centric work tracking abstractions that link to checkpoints by branch name. +const TrailsBranchName = "entire/trails/v1" + // CheckpointPath returns the sharded storage path for a checkpoint ID. // Uses first 2 characters as shard (256 buckets), remaining as folder name. // Example: "a3b2c4d5e6f7" -> "a3/b2c4d5e6f7" diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index f48bc7585..87fd1c1eb 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -83,6 +83,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newVersionCmd()) cmd.AddCommand(newExplainCmd()) cmd.AddCommand(newDoctorCmd()) + cmd.AddCommand(newTrailCmd()) cmd.AddCommand(newSendAnalyticsCmd()) cmd.AddCommand(newCurlBashPostInstallCmd()) diff --git a/cmd/entire/cli/strategy/cleanup.go b/cmd/entire/cli/strategy/cleanup.go index 713981f6e..9ec2890ff 100644 --- a/cmd/entire/cli/strategy/cleanup.go +++ b/cmd/entire/cli/strategy/cleanup.go @@ -64,8 +64,8 @@ var shadowBranchPattern = regexp.MustCompile(`^entire/[0-9a-fA-F]{7,}(-[0-9a-fA- // commit hash is at least 7 hex characters and worktree hash is 6 hex characters. // The "entire/checkpoints/v1" branch is NOT a shadow branch. func IsShadowBranch(branchName string) bool { - // Explicitly exclude entire/checkpoints/v1 - if branchName == paths.MetadataBranchName { + // Explicitly exclude metadata and trails branches + if branchName == paths.MetadataBranchName || branchName == paths.TrailsBranchName { return false } return shadowBranchPattern.MatchString(branchName) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index eafff5c60..72bfb863d 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -296,7 +296,9 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re SessionID: state.SessionID, CheckpointsCount: state.StepCount, FilesTouched: sessionData.FilesTouched, + Prompts: sessionData.Prompts, TotalTranscriptLines: sessionData.FullTranscriptLines, + Transcript: sessionData.Transcript, }, nil } diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 1948ea029..3fb823232 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -24,6 +24,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/stringutil" + "github.com/entireio/cli/cmd/entire/cli/trail" "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/entireio/cli/redact" @@ -1015,6 +1016,16 @@ func (s *ManualCommitStrategy) condenseAndUpdateState( return false } + // 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) + } + } + // Track this shadow branch for cleanup shadowBranchesToDelete[shadowBranchName] = struct{}{} @@ -2230,3 +2241,28 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( slog.Int("remaining_files", len(remainingFiles)), ) } + +// 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) { + var summary *string + if len(prompts) > 0 { + s := truncateForSummary(prompts[len(prompts)-1], 200) + summary = &s + } + + //nolint:errcheck,gosec // best-effort: trail checkpoint linking is non-critical + store.AddCheckpoint(trailID, trail.CheckpointRef{ + CheckpointID: cpID.String(), + CommitSHA: commitSHA.String(), + CreatedAt: time.Now().UTC(), + Summary: summary, + }) +} + +// truncateForSummary takes the first line of s and truncates to maxLen runes. +func truncateForSummary(s string, maxLen int) string { + line, _, _ := strings.Cut(s, "\n") + line = strings.TrimSpace(line) + return stringutil.TruncateRunes(line, maxLen, "...") +} diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index b9959a4dd..9bf6c8bf7 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -13,5 +13,8 @@ import ( // - "prompt" (default): ask user with option to enable auto // - "false"/"off"/"no": never push func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error { - return pushSessionsBranchCommon(ctx, remote, paths.MetadataBranchName) + if err := pushSessionsBranchCommon(ctx, remote, paths.MetadataBranchName); err != nil { + return err + } + return PushTrailsBranch(ctx, remote) } diff --git a/cmd/entire/cli/strategy/manual_commit_types.go b/cmd/entire/cli/strategy/manual_commit_types.go index 3192cfc06..2af81a55c 100644 --- a/cmd/entire/cli/strategy/manual_commit_types.go +++ b/cmd/entire/cli/strategy/manual_commit_types.go @@ -52,7 +52,9 @@ type CondenseResult struct { SessionID string CheckpointsCount int FilesTouched []string - TotalTranscriptLines int // Total lines in transcript after this condensation + Prompts []string // User prompts from the condensed session + TotalTranscriptLines int // Total lines in transcript after this condensation + Transcript []byte // Raw transcript bytes for downstream consumers (trail title generation) } // ExtractedSessionData contains data extracted from a shadow branch. diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 90337d2c3..342527521 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -10,6 +10,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/go-git/go-git/v5" @@ -28,6 +29,12 @@ func pushSessionsBranchCommon(ctx context.Context, remote, branchName string) er return nil } + return pushBranchIfNeeded(ctx, remote, branchName) +} + +// pushBranchIfNeeded pushes a branch to the remote if it has unpushed changes. +// Does not check any settings — callers are responsible for gating. +func pushBranchIfNeeded(ctx context.Context, remote, branchName string) error { repo, err := OpenRepository(ctx) if err != nil { return nil //nolint:nilerr // Hook must be silent on failure @@ -47,7 +54,7 @@ func pushSessionsBranchCommon(ctx context.Context, remote, branchName string) er return nil } - return doPushSessionsBranch(ctx, remote, branchName) + return doPushBranch(ctx, remote, branchName) } // hasUnpushedSessionsCommon checks if the local branch differs from the remote. @@ -76,9 +83,9 @@ func isPushSessionsDisabled(ctx context.Context) bool { return s.IsPushSessionsDisabled() } -// doPushSessionsBranch pushes the sessions branch to the remote. -func doPushSessionsBranch(ctx context.Context, remote, branchName string) error { - fmt.Fprintf(os.Stderr, "[entire] Pushing session logs to %s...\n", remote) +// doPushBranch pushes the given branch to the remote with fetch+merge recovery. +func doPushBranch(ctx context.Context, remote, branchName string) error { + fmt.Fprintf(os.Stderr, "[entire] Pushing %s to %s...\n", branchName, remote) // Try pushing first if err := tryPushSessionsCommon(ctx, remote, branchName); err == nil { @@ -86,16 +93,16 @@ func doPushSessionsBranch(ctx context.Context, remote, branchName string) error } // Push failed - likely non-fast-forward. Try to fetch and merge. - fmt.Fprintf(os.Stderr, "[entire] Syncing with remote session logs...\n") + fmt.Fprintf(os.Stderr, "[entire] Syncing %s with remote...\n", branchName) if err := fetchAndMergeSessionsCommon(ctx, remote, branchName); err != nil { - fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't sync sessions: %v\n", err) + fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't sync %s: %v\n", branchName, err) return nil // Don't fail the main push } // Try pushing again after merge if err := tryPushSessionsCommon(ctx, remote, branchName); err != nil { - fmt.Fprintf(os.Stderr, "[entire] Warning: failed to push sessions after sync: %v\n", err) + fmt.Fprintf(os.Stderr, "[entire] Warning: failed to push %s after sync: %v\n", branchName, err) } return nil @@ -201,6 +208,12 @@ func fetchAndMergeSessionsCommon(ctx context.Context, remote, branchName string) return nil } +// PushTrailsBranch pushes the entire/trails/v1 branch to the remote. +// Trails are always pushed regardless of the push_sessions setting. +func PushTrailsBranch(ctx context.Context, remote string) error { + return pushBranchIfNeeded(ctx, remote, paths.TrailsBranchName) +} + // createMergeCommitCommon creates a merge commit with multiple parents. func createMergeCommitCommon(repo *git.Repository, treeHash plumbing.Hash, parents []plumbing.Hash, message string) (plumbing.Hash, error) { authorName, authorEmail := GetGitAuthorFromRepo(repo) diff --git a/cmd/entire/cli/trail/store.go b/cmd/entire/cli/trail/store.go new file mode 100644 index 000000000..1e6e6f555 --- /dev/null +++ b/cmd/entire/cli/trail/store.go @@ -0,0 +1,485 @@ +package trail + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/paths" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" +) + +const ( + metadataFile = "metadata.json" + discussionFile = "discussion.json" + checkpointsFile = "checkpoints.json" +) + +// 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 { + 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} +} + +// EnsureBranch creates the entire/trails/v1 orphan branch if it doesn't exist. +func (s *Store) EnsureBranch() error { + refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) + _, err := s.repo.Reference(refName, true) + if err == nil { + return nil // Branch already exists + } + + // Create orphan branch with empty tree + emptyTreeHash, err := checkpoint.BuildTreeFromEntries(s.repo, make(map[string]object.TreeEntry)) + if err != nil { + return fmt.Errorf("failed to build empty tree: %w", err) + } + + authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(s.repo) + commitHash, err := checkpoint.CreateCommit(s.repo, emptyTreeHash, plumbing.ZeroHash, "Initialize trails branch", authorName, authorEmail) + if err != nil { + return fmt.Errorf("failed to create initial commit: %w", err) + } + + newRef := plumbing.NewHashReference(refName, commitHash) + if err := s.repo.Storer.SetReference(newRef); err != nil { + return fmt.Errorf("failed to set branch reference: %w", err) + } + return nil +} + +// 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 { + if metadata.TrailID.IsEmpty() { + return errors.New("trail ID is required") + } + + if err := s.EnsureBranch(); err != nil { + return fmt.Errorf("failed to ensure trails branch: %w", err) + } + + commitHash, rootTreeHash, err := s.getBranchRef() + if err != nil { + return fmt.Errorf("failed to get branch ref: %w", err) + } + + // Build blob entries for the trail's 3 files + trailEntries, err := s.buildTrailEntries(metadata, discussion, checkpoints) + if err != nil { + return err + } + + // Splice into tree at [shard, suffix] — preserves sibling trails automatically + shard, suffix := metadata.TrailID.ShardParts() + newTreeHash, err := checkpoint.UpdateSubtree( + s.repo, rootTreeHash, + []string{shard, suffix}, + trailEntries, + checkpoint.UpdateSubtreeOptions{MergeMode: checkpoint.ReplaceAll}, + ) + if err != nil { + return fmt.Errorf("failed to update subtree: %w", err) + } + + commitMsg := fmt.Sprintf("Trail: %s (%s)", metadata.Title, metadata.TrailID) + return s.commitAndUpdateRef(newTreeHash, commitHash, commitMsg) +} + +// 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) { + if discussion == nil { + discussion = &Discussion{Comments: []Comment{}} + } + if checkpoints == nil { + checkpoints = &Checkpoints{Checkpoints: []CheckpointRef{}} + } + + type fileSpec struct { + name string + data any + } + files := []fileSpec{ + {metadataFile, metadata}, + {discussionFile, discussion}, + {checkpointsFile, checkpoints}, + } + + entries := make([]object.TreeEntry, 0, len(files)) + for _, f := range files { + jsonBytes, err := json.MarshalIndent(f.data, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal %s: %w", f.name, err) + } + blobHash, err := checkpoint.CreateBlobFromContent(s.repo, jsonBytes) + if err != nil { + return nil, fmt.Errorf("failed to create %s blob: %w", f.name, err) + } + entries = append(entries, object.TreeEntry{ + Name: f.name, + Mode: filemode.Regular, + Hash: blobHash, + }) + } + + return entries, nil +} + +// Read reads a trail by its ID from the entire/trails/v1 branch. +func (s *Store) Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) { + if err := ValidateID(string(trailID)); err != nil { + return nil, nil, nil, err + } + + tree, err := s.getBranchTree() + if err != nil { + return nil, nil, nil, err + } + + basePath := trailID.Path() + "/" + + // Read metadata + metadataEntry, err := tree.FindEntry(basePath + metadataFile) + if err != nil { + return nil, nil, nil, fmt.Errorf("trail %s not found: %w", trailID, err) + } + metadataBlob, err := s.repo.BlobObject(metadataEntry.Hash) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to read metadata blob: %w", err) + } + metadataReader, err := metadataBlob.Reader() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to open metadata reader: %w", err) + } + defer metadataReader.Close() + + var metadata Metadata + if err := json.NewDecoder(metadataReader).Decode(&metadata); err != nil { + return nil, nil, nil, fmt.Errorf("failed to decode metadata: %w", err) + } + + // Read discussion (optional, may not exist yet) + var discussion Discussion + discussionEntry, err := tree.FindEntry(basePath + discussionFile) + if err == nil { + discussionBlob, blobErr := s.repo.BlobObject(discussionEntry.Hash) + if blobErr == nil { + discussionReader, readerErr := discussionBlob.Reader() + if readerErr == nil { + //nolint:errcheck,gosec // best-effort decode of optional discussion + json.NewDecoder(discussionReader).Decode(&discussion) + _ = discussionReader.Close() + } + } + } + + // Read checkpoints (optional, may not exist yet) + var checkpoints Checkpoints + checkpointsEntry, err := tree.FindEntry(basePath + checkpointsFile) + if err == nil { + checkpointsBlob, blobErr := s.repo.BlobObject(checkpointsEntry.Hash) + if blobErr == nil { + checkpointsReader, readerErr := checkpointsBlob.Reader() + if readerErr == nil { + //nolint:errcheck,gosec // best-effort decode of optional checkpoints + json.NewDecoder(checkpointsReader).Decode(&checkpoints) + _ = checkpointsReader.Close() + } + } + } + + return &metadata, &discussion, &checkpoints, nil +} + +// 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) { + 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 +} + +// List returns all trail metadata from the entire/trails/v1 branch. +func (s *Store) List() ([]*Metadata, error) { + tree, err := s.getBranchTree() + if err != nil { + // Branch doesn't exist yet — no trails + return nil, nil //nolint:nilerr // Expected when no trails exist yet + } + + var trails []*Metadata + entries := make(map[string]object.TreeEntry) + if err := checkpoint.FlattenTree(s.repo, tree, "", entries); err != nil { + return nil, fmt.Errorf("failed to flatten tree: %w", err) + } + + // Find all metadata.json files + for path, entry := range entries { + if !strings.HasSuffix(path, "/"+metadataFile) { + continue + } + + blob, err := s.repo.BlobObject(entry.Hash) + if err != nil { + continue + } + reader, err := blob.Reader() + if err != nil { + continue + } + + var metadata Metadata + decodeErr := json.NewDecoder(reader).Decode(&metadata) + _ = reader.Close() + if decodeErr != nil { + continue + } + + trails = append(trails, &metadata) + } + + return trails, nil +} + +// 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 { + // ValidateID is called by Read, no need to duplicate here + metadata, discussion, checkpoints, err := s.Read(trailID) + if err != nil { + return fmt.Errorf("failed to read trail for update: %w", err) + } + + updateFn(metadata) + metadata.UpdatedAt = time.Now() + + return s.Write(metadata, discussion, checkpoints) +} + +// 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 { + if err := ValidateID(string(trailID)); err != nil { + return err + } + + if err := s.EnsureBranch(); err != nil { + return fmt.Errorf("failed to ensure trails branch: %w", err) + } + + commitHash, rootTreeHash, err := s.getBranchRef() + if err != nil { + return fmt.Errorf("failed to get branch ref: %w", err) + } + + // Navigate to the trail's subtree and read only checkpoints.json + shard, suffix := trailID.ShardParts() + trailTree, err := s.navigateToTrailTree(rootTreeHash, shard, suffix) + if err != nil { + return fmt.Errorf("failed to read checkpoints for trail %s: %w", trailID, err) + } + + checkpoints, err := s.readCheckpointsFromTrailTree(trailTree) + if err != nil { + return fmt.Errorf("failed to read checkpoints for trail %s: %w", trailID, err) + } + + // Prepend new ref (newest first) + checkpoints.Checkpoints = append(checkpoints.Checkpoints, CheckpointRef{}) + copy(checkpoints.Checkpoints[1:], checkpoints.Checkpoints[:len(checkpoints.Checkpoints)-1]) + checkpoints.Checkpoints[0] = ref + + // Create new blob and splice back — MergeKeepExisting preserves metadata.json and discussion.json + checkpointsJSON, err := json.MarshalIndent(checkpoints, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal checkpoints: %w", err) + } + blobHash, err := checkpoint.CreateBlobFromContent(s.repo, checkpointsJSON) + if err != nil { + return fmt.Errorf("failed to create checkpoints blob: %w", err) + } + + newTreeHash, err := checkpoint.UpdateSubtree( + s.repo, rootTreeHash, + []string{shard, suffix}, + []object.TreeEntry{{Name: checkpointsFile, Mode: filemode.Regular, Hash: blobHash}}, + checkpoint.UpdateSubtreeOptions{MergeMode: checkpoint.MergeKeepExisting}, + ) + if err != nil { + return fmt.Errorf("failed to update subtree: %w", err) + } + + commitMsg := fmt.Sprintf("Add checkpoint to trail: %s", trailID) + return s.commitAndUpdateRef(newTreeHash, commitHash, commitMsg) +} + +// Delete removes a trail from the entire/trails/v1 branch. +func (s *Store) Delete(trailID ID) error { + if err := ValidateID(string(trailID)); err != nil { + return err + } + + if err := s.EnsureBranch(); err != nil { + return fmt.Errorf("failed to ensure trails branch: %w", err) + } + + commitHash, rootTreeHash, err := s.getBranchRef() + if err != nil { + return fmt.Errorf("failed to get branch ref: %w", err) + } + + // Verify the trail exists by navigating the tree at O(depth) + shard, suffix := trailID.ShardParts() + if _, err := s.navigateToTrailTree(rootTreeHash, shard, suffix); err != nil { + return err + } + + // Delete the trail's subtree by removing it from the shard directory + newTreeHash, err := checkpoint.UpdateSubtree( + s.repo, rootTreeHash, + []string{shard}, + nil, + checkpoint.UpdateSubtreeOptions{ + MergeMode: checkpoint.MergeKeepExisting, + DeleteNames: []string{suffix}, + }, + ) + if err != nil { + return fmt.Errorf("failed to update subtree: %w", err) + } + + commitMsg := fmt.Sprintf("Delete trail: %s", trailID) + return s.commitAndUpdateRef(newTreeHash, commitHash, commitMsg) +} + +// navigateToTrailTree walks rootTree → shard → suffix and returns the trail's subtree. +func (s *Store) 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) + } + + shardEntry, err := rootTree.FindEntry(shard) + if err != nil { + return nil, fmt.Errorf("trail %s/%s not found: %w", shard, suffix, err) + } + + shardTree, err := s.repo.TreeObject(shardEntry.Hash) + if err != nil { + return nil, fmt.Errorf("trail %s/%s not found: %w", shard, suffix, err) + } + + trailEntry, err := shardTree.FindEntry(suffix) + if err != nil { + return nil, fmt.Errorf("trail %s/%s not found: %w", shard, suffix, err) + } + + trailTree, err := s.repo.TreeObject(trailEntry.Hash) + if err != nil { + return nil, fmt.Errorf("trail %s/%s not found: %w", shard, suffix, err) + } + + return trailTree, nil +} + +// 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) { + cpEntry, err := trailTree.FindEntry(checkpointsFile) + if err != nil { + // No checkpoints file yet — return empty + return &Checkpoints{Checkpoints: []CheckpointRef{}}, nil + } + + blob, err := s.repo.BlobObject(cpEntry.Hash) + if err != nil { + return nil, fmt.Errorf("failed to read checkpoints blob: %w", err) + } + + reader, err := blob.Reader() + if err != nil { + return nil, fmt.Errorf("failed to open checkpoints reader: %w", err) + } + defer reader.Close() + + var checkpoints Checkpoints + if err := json.NewDecoder(reader).Decode(&checkpoints); err != nil { + return nil, fmt.Errorf("failed to decode checkpoints: %w", err) + } + + return &checkpoints, nil +} + +// commitAndUpdateRef creates a commit and updates the trails branch reference. +func (s *Store) 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 { + return fmt.Errorf("failed to create commit: %w", err) + } + + newRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(paths.TrailsBranchName), commitHash) + if err := s.repo.Storer.SetReference(newRef); err != nil { + return fmt.Errorf("failed to update branch reference: %w", err) + } + return nil +} + +// 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) { + refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) + ref, refErr := s.repo.Reference(refName, true) + if refErr != nil { + // Try remote tracking branch + remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.TrailsBranchName) + ref, refErr = s.repo.Reference(remoteRefName, true) + if refErr != nil { + return plumbing.ZeroHash, plumbing.ZeroHash, fmt.Errorf("trails branch not found: %w", refErr) + } + } + + commit, err := s.repo.CommitObject(ref.Hash()) + if err != nil { + return plumbing.ZeroHash, plumbing.ZeroHash, fmt.Errorf("failed to get commit: %w", err) + } + + return ref.Hash(), commit.TreeHash, nil +} + +// getBranchTree returns the tree for the entire/trails/v1 branch HEAD. +func (s *Store) getBranchTree() (*object.Tree, error) { + _, rootTreeHash, err := s.getBranchRef() + if err != nil { + return nil, err + } + + tree, err := s.repo.TreeObject(rootTreeHash) + if err != nil { + return nil, fmt.Errorf("failed to get tree: %w", err) + } + + return tree, nil +} diff --git a/cmd/entire/cli/trail/store_test.go b/cmd/entire/cli/trail/store_test.go new file mode 100644 index 000000000..f0fbca687 --- /dev/null +++ b/cmd/entire/cli/trail/store_test.go @@ -0,0 +1,479 @@ +package trail + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5" +) + +// initTestRepo creates a test git repository with an initial commit. +func initTestRepo(t *testing.T) *git.Repository { + t.Helper() + + dir := t.TempDir() + + ctx := context.Background() + cmds := [][]string{ + {"git", "init", dir}, + {"git", "-C", dir, "config", "user.name", "Test"}, + {"git", "-C", dir, "config", "user.email", "test@test.com"}, + } + for _, args := range cmds { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("command %v failed: %v\n%s", args, err, out) + } + } + + // Create a file and commit + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Test"), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + commitCmds := [][]string{ + {"git", "-C", dir, "add", "."}, + {"git", "-C", dir, "commit", "-m", "Initial commit"}, + } + for _, args := range commitCmds { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("command %v failed: %v\n%s", args, err, out) + } + } + + repo, err := git.PlainOpen(dir) + if err != nil { + t.Fatalf("failed to open repo: %v", err) + } + return repo +} + +func TestStore_EnsureBranch(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + // First call should create the branch + if err := store.EnsureBranch(); err != nil { + t.Fatalf("EnsureBranch() error = %v", err) + } + + // Second call should be idempotent + if err := store.EnsureBranch(); err != nil { + t.Fatalf("EnsureBranch() second call error = %v", err) + } +} + +func TestStore_WriteAndRead(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + trailID, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + + now := time.Now().Truncate(time.Second) + metadata := &Metadata{ + TrailID: trailID, + Branch: "feature/test", + Base: "main", + Title: "Test trail", + Body: "A test trail", + Status: StatusDraft, + Author: "tester", + Assignees: []string{}, + Labels: []string{"test"}, + CreatedAt: now, + UpdatedAt: now, + } + + discussion := &Discussion{Comments: []Comment{}} + + if err := store.Write(metadata, discussion, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + + // Read it back + gotMeta, gotDisc, _, err := store.Read(trailID) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + + if gotMeta.TrailID != trailID { + t.Errorf("Read() trail_id = %s, want %s", gotMeta.TrailID, trailID) + } + if gotMeta.Branch != "feature/test" { + t.Errorf("Read() branch = %q, want %q", gotMeta.Branch, "feature/test") + } + if gotMeta.Title != "Test trail" { + t.Errorf("Read() title = %q, want %q", gotMeta.Title, "Test trail") + } + if gotMeta.Status != StatusDraft { + t.Errorf("Read() status = %q, want %q", gotMeta.Status, StatusDraft) + } + if len(gotMeta.Labels) != 1 || gotMeta.Labels[0] != "test" { + t.Errorf("Read() labels = %v, want [test]", gotMeta.Labels) + } + if gotDisc == nil { + t.Error("Read() discussion should not be nil") + } +} + +func TestStore_FindByBranch(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + now := time.Now() + + // Create two trails for different branches + for _, branch := range []string{"feature/a", "feature/b"} { + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + meta := &Metadata{ + TrailID: id, + Branch: branch, + Base: "main", + Title: HumanizeBranchName(branch), + Status: StatusDraft, + Author: "test", + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.Write(meta, nil, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + } + + // Find by branch + found, err := store.FindByBranch("feature/a") + if err != nil { + t.Fatalf("FindByBranch() error = %v", err) + } + if found == nil { + t.Fatal("FindByBranch() returned nil, expected trail") + } + if found.Branch != "feature/a" { + t.Errorf("FindByBranch() branch = %q, want %q", found.Branch, "feature/a") + } + + // Not found + notFound, err := store.FindByBranch("feature/c") + if err != nil { + t.Fatalf("FindByBranch() error = %v", err) + } + if notFound != nil { + t.Error("FindByBranch() should return nil for non-existent branch") + } +} + +func TestStore_List(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + // List when no trails exist (branch doesn't exist yet) + trails, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if trails != nil { + t.Errorf("List() = %v, want nil for empty store", trails) + } + + // Create a trail + now := time.Now() + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + meta := &Metadata{ + TrailID: id, + Branch: "feature/test", + Base: "main", + Title: "Test", + Status: StatusDraft, + Author: "test", + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.Write(meta, nil, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + + trails, err = store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(trails) != 1 { + t.Fatalf("List() returned %d trails, want 1", len(trails)) + } + if trails[0].TrailID != id { + t.Errorf("List()[0].TrailID = %s, want %s", trails[0].TrailID, id) + } +} + +func TestStore_Update(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + now := time.Now() + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + meta := &Metadata{ + TrailID: id, + Branch: "feature/test", + Base: "main", + Title: "Original", + Status: StatusDraft, + Author: "test", + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.Write(meta, nil, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + + // Update + if err := store.Update(id, func(m *Metadata) { + m.Title = "Updated" + m.Status = StatusInProgress + m.Labels = []string{"urgent"} + }); err != nil { + t.Fatalf("Update() error = %v", err) + } + + // Verify + updated, _, _, err := store.Read(id) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if updated.Title != "Updated" { + t.Errorf("Read() title = %q, want %q", updated.Title, "Updated") + } + if updated.Status != StatusInProgress { + t.Errorf("Read() status = %q, want %q", updated.Status, StatusInProgress) + } + if len(updated.Labels) != 1 || updated.Labels[0] != "urgent" { + t.Errorf("Read() labels = %v, want [urgent]", updated.Labels) + } + if !updated.UpdatedAt.After(now) { + t.Error("Read() updated_at should be after original") + } +} + +func TestStore_Delete(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + now := time.Now() + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + meta := &Metadata{ + TrailID: id, + Branch: "feature/test", + Base: "main", + Title: "To delete", + Status: StatusDraft, + Author: "test", + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.Write(meta, nil, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + + // Delete + if err := store.Delete(id); err != nil { + t.Fatalf("Delete() error = %v", err) + } + + // Verify it's gone + _, _, _, err = store.Read(id) + if err == nil { + t.Error("Read() should fail after delete") + } +} + +func TestStore_ReadNonExistent(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + if err := store.EnsureBranch(); err != nil { + t.Fatalf("EnsureBranch() error = %v", err) + } + + _, _, _, err := store.Read(ID("abcdef123456")) + if err == nil { + t.Error("Read() should fail for non-existent trail") + } +} + +func TestStore_ReadInvalidID(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + // Invalid format: too short + _, _, _, err := store.Read(ID("abc")) + if err == nil { + t.Error("Read() should fail for invalid trail ID") + } + + // Path traversal attempt + _, _, _, err = store.Read(ID("../../etc/pass")) + if err == nil { + t.Error("Read() should fail for path traversal ID") + } +} + +func TestStore_DeleteInvalidID(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + // Invalid format: uppercase hex + err := store.Delete(ID("ABCDEF123456")) + if err == nil { + t.Error("Delete() should fail for invalid trail ID") + } + + // Path traversal attempt + err = store.Delete(ID("../../../etc")) + if err == nil { + t.Error("Delete() should fail for path traversal ID") + } +} + +func TestStore_AddCheckpointPreservesOtherFields(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + trailID, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + + now := time.Now().Truncate(time.Second) + metadata := &Metadata{ + TrailID: trailID, + Branch: "feature/preserve", + Base: "main", + Title: "Preservation test", + Body: "Verify AddCheckpoint doesn't corrupt other fields", + Status: StatusInProgress, + Author: "tester", + Assignees: []string{"alice"}, + Labels: []string{"important"}, + CreatedAt: now, + UpdatedAt: now, + } + discussion := &Discussion{Comments: []Comment{ + {ID: "c1", Author: "bob", Body: "looks good", CreatedAt: now}, + }} + + if err := store.Write(metadata, discussion, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + + // Add a checkpoint + firstSummary := "first checkpoint" + cpRef := CheckpointRef{ + CheckpointID: "aabbccddeeff", + CommitSHA: "deadbeef1234", + CreatedAt: now, + Summary: &firstSummary, + } + if err := store.AddCheckpoint(trailID, cpRef); err != nil { + t.Fatalf("AddCheckpoint() error = %v", err) + } + + // Read back and verify metadata + discussion are unchanged + gotMeta, gotDisc, gotCPs, err := store.Read(trailID) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + + // Metadata unchanged + if gotMeta.Title != "Preservation test" { + t.Errorf("metadata title changed: got %q, want %q", gotMeta.Title, "Preservation test") + } + if gotMeta.Body != "Verify AddCheckpoint doesn't corrupt other fields" { + t.Errorf("metadata body changed: got %q", gotMeta.Body) + } + if gotMeta.Status != StatusInProgress { + t.Errorf("metadata status changed: got %q, want %q", gotMeta.Status, StatusInProgress) + } + if len(gotMeta.Assignees) != 1 || gotMeta.Assignees[0] != "alice" { + t.Errorf("metadata assignees changed: got %v", gotMeta.Assignees) + } + if len(gotMeta.Labels) != 1 || gotMeta.Labels[0] != "important" { + t.Errorf("metadata labels changed: got %v", gotMeta.Labels) + } + + // Discussion unchanged + if len(gotDisc.Comments) != 1 { + t.Fatalf("discussion comments count = %d, want 1", len(gotDisc.Comments)) + } + if gotDisc.Comments[0].ID != "c1" || gotDisc.Comments[0].Body != "looks good" { + t.Errorf("discussion comment changed: got %+v", gotDisc.Comments[0]) + } + + // Checkpoint added correctly + if len(gotCPs.Checkpoints) != 1 { + t.Fatalf("checkpoints count = %d, want 1", len(gotCPs.Checkpoints)) + } + if gotCPs.Checkpoints[0].CheckpointID != "aabbccddeeff" { + t.Errorf("checkpoint ID = %q, want %q", gotCPs.Checkpoints[0].CheckpointID, "aabbccddeeff") + } + + // Add a second checkpoint — should prepend + secondSummary := "second checkpoint" + cpRef2 := CheckpointRef{ + CheckpointID: "112233445566", + CommitSHA: "cafebabe5678", + CreatedAt: now, + Summary: &secondSummary, + } + if err := store.AddCheckpoint(trailID, cpRef2); err != nil { + t.Fatalf("AddCheckpoint() second call error = %v", err) + } + + _, _, gotCPs2, err := store.Read(trailID) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if len(gotCPs2.Checkpoints) != 2 { + t.Fatalf("checkpoints count = %d, want 2", len(gotCPs2.Checkpoints)) + } + if gotCPs2.Checkpoints[0].CheckpointID != "112233445566" { + t.Errorf("newest checkpoint should be first, got %q", gotCPs2.Checkpoints[0].CheckpointID) + } + if gotCPs2.Checkpoints[1].CheckpointID != "aabbccddeeff" { + t.Errorf("older checkpoint should be second, got %q", gotCPs2.Checkpoints[1].CheckpointID) + } +} diff --git a/cmd/entire/cli/trail/trail.go b/cmd/entire/cli/trail/trail.go new file mode 100644 index 000000000..9b3eb672d --- /dev/null +++ b/cmd/entire/cli/trail/trail.go @@ -0,0 +1,234 @@ +// 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). +package trail + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "regexp" + "strings" + "time" +) + +const idLength = 6 // 6 bytes = 12 hex chars + +// ID is a 12-character hex identifier for trails. +type ID string + +// EmptyID represents an unset or invalid trail ID. +const EmptyID ID = "" + +// idRegex validates the format: exactly 12 lowercase hex characters. +var idRegex = regexp.MustCompile(`^[0-9a-f]{12}$`) + +// GenerateID creates a new random 12-character hex trail ID. +func GenerateID() (ID, error) { + bytes := make([]byte, idLength) + if _, err := rand.Read(bytes); err != nil { + return EmptyID, fmt.Errorf("failed to generate random trail ID: %w", err) + } + return ID(hex.EncodeToString(bytes)), nil +} + +// ValidateID checks if a string is a valid trail ID format. +func ValidateID(s string) error { + if !idRegex.MatchString(s) { + return fmt.Errorf("invalid trail ID %q: must be 12 lowercase hex characters", s) + } + return nil +} + +// String returns the trail ID as a string. +func (id ID) String() string { + return string(id) +} + +// IsEmpty returns true if the trail ID is empty or unset. +func (id ID) IsEmpty() bool { + return id == EmptyID +} + +// Path returns the sharded storage path for this trail ID. +// Uses first 2 characters as shard (256 buckets), remaining as folder name. +// Example: "a3b2c4d5e6f7" -> "a3/b2c4d5e6f7" +func (id ID) Path() string { + if len(id) < 3 { + return string(id) + } + return string(id[:2]) + "/" + string(id[2:]) +} + +// ShardParts returns the shard prefix and suffix separately. +// Example: "a3b2c4d5e6f7" -> ("a3", "b2c4d5e6f7") +func (id ID) ShardParts() (shard, suffix string) { + if len(id) < 3 { + return string(id), "" + } + return string(id[:2]), string(id[2:]) +} + +// Status represents the lifecycle status of a trail. +type Status string + +const ( + StatusDraft Status = "draft" + StatusOpen Status = "open" + StatusInProgress Status = "in_progress" + StatusInReview Status = "in_review" + StatusMerged Status = "merged" + StatusClosed Status = "closed" +) + +// ValidStatuses returns all valid trail statuses in lifecycle order. +func ValidStatuses() []Status { + return []Status{ + StatusDraft, + StatusOpen, + StatusInProgress, + StatusInReview, + StatusMerged, + StatusClosed, + } +} + +// IsValid returns true if the status is a recognized trail status. +func (s Status) IsValid() bool { + for _, vs := range ValidStatuses() { + if s == vs { + return true + } + } + return false +} + +// Priority represents the priority level of a trail. +type Priority string + +const ( + PriorityUrgent Priority = "urgent" + PriorityHigh Priority = "high" + PriorityMedium Priority = "medium" + PriorityLow Priority = "low" + PriorityNone Priority = "none" +) + +// Type represents the type/category of a trail. +type Type string + +const ( + TypeBug Type = "bug" + TypeFeature Type = "feature" + TypeChore Type = "chore" + TypeDocs Type = "docs" + TypeRefactor Type = "refactor" +) + +// ReviewerStatus represents the review status for a reviewer. +type ReviewerStatus string + +const ( + ReviewerPending ReviewerStatus = "pending" + ReviewerApproved ReviewerStatus = "approved" + ReviewerChangesRequested ReviewerStatus = "changes_requested" +) + +// Reviewer represents a reviewer assigned to a trail. +type Reviewer struct { + Login string `json:"login"` + Status ReviewerStatus `json:"status"` +} + +// Metadata represents the metadata for a trail, matching the web PR format. +type Metadata struct { + TrailID ID `json:"trail_id"` + Branch string `json:"branch"` + Base string `json:"base"` + Title string `json:"title"` + Body string `json:"body"` + Status Status `json:"status"` + Author string `json:"author"` + Assignees []string `json:"assignees"` + Labels []string `json:"labels"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt *time.Time `json:"merged_at"` + Priority Priority `json:"priority,omitempty"` + Type Type `json:"type,omitempty"` + Reviewers []Reviewer `json:"reviewers,omitempty"` +} + +// Discussion holds the discussion/comments for a trail. +type Discussion struct { + Comments []Comment `json:"comments"` +} + +// Comment represents a single comment on a trail. +type Comment struct { + ID string `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + Resolved bool `json:"resolved"` + ResolvedBy *string `json:"resolved_by"` + ResolvedAt *time.Time `json:"resolved_at"` + Replies []CommentReply `json:"replies,omitempty"` +} + +// CommentReply represents a reply to a comment. +type CommentReply struct { + ID string `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` +} + +// commonBranchPrefixes are stripped from branch names when humanizing. +var commonBranchPrefixes = []string{ + "feature/", + "fix/", + "bugfix/", + "chore/", + "hotfix/", + "release/", +} + +// CheckpointRef links a checkpoint to a trail. +type CheckpointRef struct { + CheckpointID string `json:"checkpoint_id"` + CommitSHA string `json:"commit_sha"` + CreatedAt time.Time `json:"created_at"` + Summary *string `json:"summary"` +} + +// Checkpoints holds the list of checkpoint references for a trail. +type Checkpoints struct { + Checkpoints []CheckpointRef `json:"checkpoints"` +} + +// HumanizeBranchName converts a branch name into a human-readable title. +// It strips common prefixes (feature/, fix/, etc.), replaces dashes/underscores +// with spaces, and capitalizes the first word. +func HumanizeBranchName(branch string) string { + name := branch + for _, prefix := range commonBranchPrefixes { + if strings.HasPrefix(name, prefix) { + name = strings.TrimPrefix(name, prefix) + break + } + } + + // Replace - and _ with spaces + name = strings.NewReplacer("-", " ", "_", " ").Replace(name) + + // Trim spaces and capitalize first letter + name = strings.TrimSpace(name) + if name == "" { + return branch + } + + // Capitalize first character + return strings.ToUpper(name[:1]) + name[1:] +} diff --git a/cmd/entire/cli/trail/trail_test.go b/cmd/entire/cli/trail/trail_test.go new file mode 100644 index 000000000..7174a17cc --- /dev/null +++ b/cmd/entire/cli/trail/trail_test.go @@ -0,0 +1,172 @@ +package trail + +import ( + "testing" +) + +func TestGenerateID(t *testing.T) { + t.Parallel() + + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + if len(id) != 12 { + t.Errorf("expected 12-char ID, got %d: %q", len(id), id) + } + if err := ValidateID(id.String()); err != nil { + t.Errorf("generated ID failed validation: %v", err) + } +} + +func TestGenerateID_Unique(t *testing.T) { + t.Parallel() + + seen := make(map[ID]bool) + for range 100 { + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + if seen[id] { + t.Errorf("duplicate ID generated: %s", id) + } + seen[id] = true + } +} + +func TestValidateID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + id string + wantErr bool + }{ + {"valid", "abcdef123456", false}, + {"valid_all_hex", "0123456789ab", false}, + {"too_short", "abcdef", true}, + {"too_long", "abcdef1234567", true}, + {"uppercase", "ABCDEF123456", true}, + {"non_hex", "ghijkl123456", true}, + {"empty", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateID(tt.id) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateID(%q) error = %v, wantErr %v", tt.id, err, tt.wantErr) + } + }) + } +} + +func TestID_Path(t *testing.T) { + t.Parallel() + + tests := []struct { + id ID + want string + }{ + {"abcdef123456", "ab/cdef123456"}, + {"0123456789ab", "01/23456789ab"}, + {"ab", "ab"}, + } + + for _, tt := range tests { + t.Run(string(tt.id), func(t *testing.T) { + t.Parallel() + if got := tt.id.Path(); got != tt.want { + t.Errorf("ID(%q).Path() = %q, want %q", tt.id, got, tt.want) + } + }) + } +} + +func TestID_IsEmpty(t *testing.T) { + t.Parallel() + + if !EmptyID.IsEmpty() { + t.Error("EmptyID.IsEmpty() should return true") + } + id := ID("abcdef123456") + if id.IsEmpty() { + t.Error("non-empty ID.IsEmpty() should return false") + } +} + +func TestStatus_IsValid(t *testing.T) { + t.Parallel() + + tests := []struct { + status Status + valid bool + }{ + {StatusDraft, true}, + {StatusOpen, true}, + {StatusInProgress, true}, + {StatusInReview, true}, + {StatusMerged, true}, + {StatusClosed, true}, + {"invalid", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + t.Parallel() + if got := tt.status.IsValid(); got != tt.valid { + t.Errorf("Status(%q).IsValid() = %v, want %v", tt.status, got, tt.valid) + } + }) + } +} + +func TestValidStatuses(t *testing.T) { + t.Parallel() + + statuses := ValidStatuses() + if len(statuses) != 6 { + t.Errorf("expected 6 statuses, got %d", len(statuses)) + } + // Verify lifecycle order + expected := []Status{StatusDraft, StatusOpen, StatusInProgress, StatusInReview, StatusMerged, StatusClosed} + for i, s := range expected { + if statuses[i] != s { + t.Errorf("status[%d] = %q, want %q", i, statuses[i], s) + } + } +} + +func TestHumanizeBranchName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + branch string + want string + }{ + {"feature prefix", "feature/add-auth", "Add auth"}, + {"fix prefix", "fix/login-bug", "Login bug"}, + {"bugfix prefix", "bugfix/typo-fix", "Typo fix"}, + {"chore prefix", "chore/update-deps", "Update deps"}, + {"hotfix prefix", "hotfix/security-patch", "Security patch"}, + {"release prefix", "release/v2.0", "V2.0"}, + {"no prefix", "add-auth", "Add auth"}, + {"underscores", "add_user_auth", "Add user auth"}, + {"mixed separators", "fix/some_complex-name", "Some complex name"}, + {"simple name", "main", "Main"}, + {"empty after prefix", "feature/", "feature/"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := HumanizeBranchName(tt.branch); got != tt.want { + t.Errorf("HumanizeBranchName(%q) = %q, want %q", tt.branch, got, tt.want) + } + }) + } +} diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go new file mode 100644 index 000000000..383719daa --- /dev/null +++ b/cmd/entire/cli/trail_cmd.go @@ -0,0 +1,615 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "slices" + "sort" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/stringutil" + "github.com/entireio/cli/cmd/entire/cli/trail" + + "github.com/charmbracelet/huh" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/spf13/cobra" +) + +func newTrailCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "trail", + Short: "Manage trails for your branches", + Hidden: true, + Long: `Trails are branch-centric work tracking abstractions. They describe the +"why" and "what" of your work, while checkpoints capture the "how" and "when". + +Running 'entire trail' without a subcommand shows the trail for the current +branch, or lists all trails if no trail exists for the current branch.`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runTrailShow(cmd.OutOrStdout()) + }, + } + + cmd.AddCommand(newTrailListCmd()) + cmd.AddCommand(newTrailCreateCmd()) + cmd.AddCommand(newTrailUpdateCmd()) + + return cmd +} + +// runTrailShow shows the trail for the current branch, or falls through to list. +func runTrailShow(w io.Writer) error { + branch, err := GetCurrentBranch(context.Background()) + if err != nil { + return runTrailListAll(w, "", false) + } + + repo, err := strategy.OpenRepository(context.Background()) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + store := trail.NewStore(repo) + metadata, err := store.FindByBranch(branch) + if err != nil || metadata == nil { + return runTrailListAll(w, "", false) + } + + printTrailDetails(w, metadata) + return nil +} + +func printTrailDetails(w io.Writer, m *trail.Metadata) { + fmt.Fprintf(w, "Trail: %s\n", m.Title) + fmt.Fprintf(w, " ID: %s\n", m.TrailID) + fmt.Fprintf(w, " Branch: %s\n", m.Branch) + fmt.Fprintf(w, " Base: %s\n", m.Base) + fmt.Fprintf(w, " Status: %s\n", m.Status) + fmt.Fprintf(w, " Author: %s\n", m.Author) + if m.Body != "" { + fmt.Fprintf(w, " Body: %s\n", m.Body) + } + if len(m.Labels) > 0 { + fmt.Fprintf(w, " Labels: %s\n", strings.Join(m.Labels, ", ")) + } + if len(m.Assignees) > 0 { + fmt.Fprintf(w, " Assignees: %s\n", strings.Join(m.Assignees, ", ")) + } + fmt.Fprintf(w, " Created: %s\n", m.CreatedAt.Format(time.RFC3339)) + fmt.Fprintf(w, " Updated: %s\n", m.UpdatedAt.Format(time.RFC3339)) +} + +func newTrailListCmd() *cobra.Command { + var statusFilter string + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "list", + Short: "List all trails", + RunE: func(cmd *cobra.Command, _ []string) error { + return runTrailListAll(cmd.OutOrStdout(), statusFilter, jsonOutput) + }, + } + + cmd.Flags().StringVar(&statusFilter, "status", "", "Filter by status (draft, open, in_progress, in_review, merged, closed)") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + + return cmd +} + +func runTrailListAll(w io.Writer, statusFilter string, jsonOutput bool) error { + // Fetch remote trails branch so we see trails from collaborators + fetchTrailsBranch() + + repo, err := strategy.OpenRepository(context.Background()) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + store := trail.NewStore(repo) + trails, err := store.List() + if err != nil { + return fmt.Errorf("failed to list trails: %w", err) + } + + if trails == nil { + trails = []*trail.Metadata{} + } + + // Apply status filter + if statusFilter != "" { + status := trail.Status(statusFilter) + if !status.IsValid() { + return fmt.Errorf("invalid status %q: valid values are %s", statusFilter, formatValidStatuses()) + } + var filtered []*trail.Metadata + for _, t := range trails { + if t.Status == status { + filtered = append(filtered, t) + } + } + trails = filtered + } + + // Sort by updated_at descending + sort.Slice(trails, func(i, j int) bool { + return trails[i].UpdatedAt.After(trails[j].UpdatedAt) + }) + + if jsonOutput { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(trails); err != nil { + return fmt.Errorf("failed to encode JSON: %w", err) + } + return nil + } + + if len(trails) == 0 { + fmt.Fprintln(w, "No trails found.") + fmt.Fprintln(w) + fmt.Fprintln(w, "Commands:") + fmt.Fprintln(w, " entire trail create Create a trail for the current branch") + fmt.Fprintln(w, " entire trail list List all trails") + fmt.Fprintln(w, " entire trail update Update trail metadata") + return nil + } + + // Table output + fmt.Fprintf(w, "%-30s %-40s %-13s %-15s %s\n", "BRANCH", "TITLE", "STATUS", "AUTHOR", "UPDATED") + for _, t := range trails { + branch := stringutil.TruncateRunes(t.Branch, 30, "...") + title := stringutil.TruncateRunes(t.Title, 40, "...") + fmt.Fprintf(w, "%-30s %-40s %-13s %-15s %s\n", + branch, title, t.Status, stringutil.TruncateRunes(t.Author, 15, "..."), timeAgo(t.UpdatedAt)) + } + + return nil +} + +func newTrailCreateCmd() *cobra.Command { + var title, body, base, branch, status string + var checkout bool + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a trail for the current or a new branch", + RunE: func(cmd *cobra.Command, _ []string) error { + return runTrailCreate(cmd, title, body, base, branch, status, checkout) + }, + } + + cmd.Flags().StringVar(&title, "title", "", "Trail title") + cmd.Flags().StringVar(&body, "body", "", "Trail body") + cmd.Flags().StringVar(&base, "base", "", "Base branch (defaults to detected default branch)") + cmd.Flags().StringVar(&branch, "branch", "", "Branch for the trail (defaults to current branch)") + cmd.Flags().StringVar(&status, "status", "", "Initial status (defaults to draft)") + cmd.Flags().BoolVar(&checkout, "checkout", false, "Check out the branch after creating it") + + return cmd +} + +//nolint:cyclop // sequential steps for creating a trail — splitting would obscure the flow +func runTrailCreate(cmd *cobra.Command, title, body, base, branch, statusStr string, checkout bool) error { + w := cmd.OutOrStdout() + errW := cmd.ErrOrStderr() + + repo, err := strategy.OpenRepository(context.Background()) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + // Determine base branch + if base == "" { + base = strategy.GetDefaultBranchName(repo) + if base == "" { + base = defaultBaseBranch + } + } + + _, currentBranch, _ := IsOnDefaultBranch(context.Background()) //nolint:errcheck // best-effort detection + interactive := !cmd.Flags().Changed("title") && !cmd.Flags().Changed("branch") + + if interactive { + // Interactive flow: title → body → branch (derived) → status + if err := runTrailCreateInteractive(&title, &body, &branch, &statusStr); err != nil { + return err + } + } else { + // Non-interactive: derive missing values from provided flags + if branch == "" { + if cmd.Flags().Changed("title") { + branch = slugifyTitle(title) + } else { + branch = currentBranch + } + } + if title == "" { + title = trail.HumanizeBranchName(branch) + } + } + if branch == "" { + return errors.New("branch name is required") + } + + // Create the branch if it doesn't exist + needsCreation := branchNeedsCreation(repo, branch) + if needsCreation { + if err := createBranch(repo, branch); err != nil { + return fmt.Errorf("failed to create branch %q: %w", branch, err) + } + fmt.Fprintf(w, "Created branch %s\n", branch) + } else if currentBranch != branch { + fmt.Fprintf(errW, "Note: trail will be created for branch %q (not the current branch)\n", branch) + } + + // 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) + return nil + } + + // Determine status + var trailStatus trail.Status + if statusStr != "" { + trailStatus = trail.Status(statusStr) + if !trailStatus.IsValid() { + return fmt.Errorf("invalid status %q: valid values are %s", statusStr, formatValidStatuses()) + } + } else { + trailStatus = trail.StatusDraft + } + + // Generate trail ID + trailID, err := trail.GenerateID() + if err != nil { + return fmt.Errorf("failed to generate trail ID: %w", err) + } + + // Get author (GitHub username, falls back to git user.name) + authorName := getTrailAuthor(repo) + + now := time.Now() + metadata := &trail.Metadata{ + TrailID: trailID, + Branch: branch, + Base: base, + Title: title, + Body: body, + Status: trailStatus, + Author: authorName, + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, + } + + if err := store.Write(metadata, nil, nil); err != nil { + return fmt.Errorf("failed to create trail: %w", err) + } + + fmt.Fprintf(w, "Created trail %q for branch %s (ID: %s)\n", title, branch, trailID) + + // Push the branch and trail data to origin + if needsCreation { + if err := pushBranchToOrigin(branch); err != nil { + fmt.Fprintf(errW, "Warning: failed to push branch: %v\n", err) + } else { + 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) + } + + // Checkout the branch if requested or prompted + if needsCreation && currentBranch != branch { + shouldCheckout := checkout + if !shouldCheckout && !cmd.Flags().Changed("checkout") { + // Interactive: ask whether to checkout + form := NewAccessibleForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Check out branch %s?", branch)). + Value(&shouldCheckout), + ), + ) + if formErr := form.Run(); formErr == nil && shouldCheckout { + checkout = true + } + } + if checkout { + if err := CheckoutBranch(context.Background(), branch); err != nil { + return fmt.Errorf("failed to checkout branch %q: %w", branch, err) + } + fmt.Fprintf(w, "Switched to branch %s\n", branch) + } + } + + return nil +} + +func newTrailUpdateCmd() *cobra.Command { + var statusStr, title, body, branch string + var labelAdd, labelRemove []string + + cmd := &cobra.Command{ + Use: "update", + Short: "Update trail metadata", + RunE: func(cmd *cobra.Command, _ []string) error { + return runTrailUpdate(cmd.OutOrStdout(), statusStr, title, body, branch, labelAdd, labelRemove) + }, + } + + cmd.Flags().StringVar(&statusStr, "status", "", "Update status") + cmd.Flags().StringVar(&title, "title", "", "Update title") + cmd.Flags().StringVar(&body, "body", "", "Update body") + cmd.Flags().StringVar(&branch, "branch", "", "Branch to update trail for (defaults to current)") + cmd.Flags().StringSliceVar(&labelAdd, "add-label", nil, "Add label(s)") + cmd.Flags().StringSliceVar(&labelRemove, "remove-label", nil, "Remove label(s)") + + return cmd +} + +func runTrailUpdate(w 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) + } + + // Determine branch + if branch == "" { + branch, err = GetCurrentBranch(context.Background()) + if err != nil { + return fmt.Errorf("failed to determine current branch: %w", err) + } + } + + store := trail.NewStore(repo) + metadata, err := store.FindByBranch(branch) + if err != nil { + return fmt.Errorf("failed to find trail: %w", err) + } + if metadata == nil { + return fmt.Errorf("no trail found for branch %q", branch) + } + + // Interactive mode when no flags are provided + noFlags := statusStr == "" && title == "" && body == "" && labelAdd == nil && labelRemove == nil + if noFlags { + // Build status options with current value as default. + // Exclude "merged" and "closed" unless the trail is already in that status + // (otherwise the select would silently reset to the first option). + var statusOptions []huh.Option[string] + for _, s := range trail.ValidStatuses() { + if (s == trail.StatusMerged || s == trail.StatusClosed) && s != metadata.Status { + continue + } + label := string(s) + if s == metadata.Status { + label += " (current)" + } + statusOptions = append(statusOptions, huh.NewOption(label, string(s))) + } + statusStr = string(metadata.Status) + title = metadata.Title + body = metadata.Body + + form := NewAccessibleForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Status"). + Options(statusOptions...). + Value(&statusStr), + huh.NewInput(). + Title("Title"). + Value(&title), + huh.NewText(). + Title("Body"). + Value(&body), + ), + ) + if formErr := form.Run(); formErr != nil { + return fmt.Errorf("form cancelled: %w", formErr) + } + } + + // Validate status if provided + if statusStr != "" { + status := trail.Status(statusStr) + if !status.IsValid() { + return fmt.Errorf("invalid status %q: valid values are %s", statusStr, formatValidStatuses()) + } + } + + err = store.Update(metadata.TrailID, func(m *trail.Metadata) { + if statusStr != "" { + m.Status = trail.Status(statusStr) + } + if title != "" { + m.Title = title + } + if body != "" { + m.Body = body + } + for _, l := range labelAdd { + if !slices.Contains(m.Labels, l) { + m.Labels = append(m.Labels, l) + } + } + for _, l := range labelRemove { + m.Labels = slices.DeleteFunc(m.Labels, func(v string) bool { return v == l }) + } + }) + if err != nil { + return fmt.Errorf("failed to update trail: %w", err) + } + + fmt.Fprintf(w, "Updated trail for branch %s\n", branch) + return nil +} + +// defaultBaseBranch is the fallback base branch name when it cannot be determined. +const defaultBaseBranch = "main" + +func formatValidStatuses() string { + statuses := trail.ValidStatuses() + names := make([]string, len(statuses)) + for i, s := range statuses { + names[i] = string(s) + } + return strings.Join(names, ", ") +} + +// runTrailCreateInteractive runs the interactive form for trail creation. +// Prompts for title, body, branch (derived from title), and status. +func runTrailCreateInteractive(title, body, branch, statusStr *string) error { + // Step 1: Title and body + form := NewAccessibleForm( + huh.NewGroup( + huh.NewInput(). + Title("Trail title"). + Placeholder("What are you working on?"). + Value(title), + huh.NewText(). + Title("Body (optional)"). + Value(body), + ), + ) + if err := form.Run(); err != nil { + return fmt.Errorf("form cancelled: %w", err) + } + *title = strings.TrimSpace(*title) + if *title == "" { + return errors.New("trail title is required") + } + + // Step 2: Branch (derived from title) and status + suggested := slugifyTitle(*title) + *branch = suggested + + // Build status options, excluding done/closed + var statusOptions []huh.Option[string] + for _, s := range trail.ValidStatuses() { + if s == trail.StatusMerged || s == trail.StatusClosed { + continue + } + statusOptions = append(statusOptions, huh.NewOption(string(s), string(s))) + } + if *statusStr == "" { + *statusStr = string(trail.StatusDraft) + } + + form = NewAccessibleForm( + huh.NewGroup( + huh.NewInput(). + Title("Branch name"). + Placeholder(suggested). + Value(branch), + huh.NewSelect[string](). + Title("Status"). + Options(statusOptions...). + Value(statusStr), + ), + ) + if err := form.Run(); err != nil { + return fmt.Errorf("form cancelled: %w", err) + } + *branch = strings.TrimSpace(*branch) + if *branch == "" { + *branch = suggested + } + return nil +} + +// fetchTrailsBranch fetches the remote trails branch so we see trails from collaborators. +// Best-effort: silently ignores errors (e.g., no remote, no network). +func fetchTrailsBranch() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + branchName := paths.TrailsBranchName + refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branchName, branchName) + + cmd := exec.CommandContext(ctx, "git", "fetch", "origin", refSpec) + // Ensure non-interactive fetch in hook/agent contexts + cmd.Stdin = nil + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + _ = cmd.Run() //nolint:errcheck // best-effort fetch +} + +// getTrailAuthor returns the GitHub username for the trail author. +// Falls back to git user.name if gh CLI is unavailable or not authenticated. +func getTrailAuthor(repo *git.Repository) string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "gh", "api", "user", "-q", ".login") + if output, err := cmd.Output(); err == nil { + if login := strings.TrimSpace(string(output)); login != "" { + return login + } + } + name, _ := strategy.GetGitAuthorFromRepo(repo) + return name +} + +// slugifyTitle converts a title string into a branch-friendly slug. +// Example: "Add user authentication" -> "add-user-authentication" +func slugifyTitle(title string) string { + s := strings.ToLower(strings.TrimSpace(title)) + // Replace spaces and underscores with hyphens + s = strings.NewReplacer(" ", "-", "_", "-").Replace(s) + // Remove anything that's not alphanumeric, hyphen, or slash + var b strings.Builder + prevHyphen := false + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '/' { + b.WriteRune(r) + prevHyphen = false + } else if r == '-' && !prevHyphen { + b.WriteRune('-') + prevHyphen = true + } + } + return strings.Trim(b.String(), "-") +} + +// branchNeedsCreation checks if a branch exists locally. +func branchNeedsCreation(repo *git.Repository, branchName string) bool { + _, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true) + return err != nil +} + +// createBranch creates a new local branch pointing at HEAD without checking it out. +func createBranch(repo *git.Repository, branchName string) error { + head, err := repo.Head() + if err != nil { + return fmt.Errorf("failed to get HEAD: %w", err) + } + ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), head.Hash()) + if err := repo.Storer.SetReference(ref); err != nil { + return fmt.Errorf("failed to create branch ref: %w", err) + } + return nil +} + +// pushBranchToOrigin pushes a branch to the origin remote. +func pushBranchToOrigin(branchName string) error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "push", "--no-verify", "-u", "origin", branchName) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) + } + return nil +}