Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 153 additions & 2 deletions cmd/entire/cli/trail_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ branch, or lists all trails if no trail exists for the current branch.`,
cmd.AddCommand(newTrailListCmd())
cmd.AddCommand(newTrailCreateCmd())
cmd.AddCommand(newTrailUpdateCmd())
cmd.AddCommand(newTrailLinkCmd())

return cmd
}
Expand Down Expand Up @@ -91,7 +92,11 @@ func runTrailShow(ctx context.Context, w io.Writer, insecureHTTP bool) error {
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)
if m.Branch != "" {
fmt.Fprintf(w, " Branch: %s\n", m.Branch)
} else {
fmt.Fprintf(w, " Branch: (none)\n")
}
fmt.Fprintf(w, " Base: %s\n", m.Base)
fmt.Fprintf(w, " Status: %s\n", m.Status)
fmt.Fprintf(w, " Author: %s\n", m.Author)
Expand Down Expand Up @@ -217,7 +222,11 @@ func runTrailListAll(ctx context.Context, w io.Writer, statusFilter string, json
// 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, "...")
branchDisplay := t.Branch
if branchDisplay == "" {
branchDisplay = "(none)"
}
branch := stringutil.TruncateRunes(branchDisplay, 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))
Expand Down Expand Up @@ -542,6 +551,148 @@ func buildTrailUpdateRequest(current *api.TrailResource, statusStr, title, body
return req
}

func newTrailLinkCmd() *cobra.Command {
var branch string

cmd := &cobra.Command{
Use: "link [trail-id]",
Short: "Link a trail to a branch",
Long: `Link a trail that has no branch to a git branch. By default, the trail is
linked to the currently checked-out branch. Use --branch to specify a different branch.

Each branch can only be linked to one trail. If the branch is already linked
to another trail, the command will fail.

If no trail ID is provided, an interactive picker shows all trails without a branch.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var trailID string
if len(args) > 0 {
trailID = args[0]
}
return runTrailLink(cmd, trailID, branch)
},
}

cmd.Flags().StringVar(&branch, "branch", "", "Branch to link (defaults to current branch)")

return cmd
}

func runTrailLink(cmd *cobra.Command, trailID, branch string) error {
ctx := cmd.Context()
w := cmd.OutOrStdout()

// Determine target branch
if branch == "" {
var err error
branch, err = GetCurrentBranch(ctx)
if err != nil {
return fmt.Errorf("failed to determine current branch (use --branch to specify): %w", err)
}
}

client, err := NewAuthenticatedAPIClient(trailInsecureHTTP(cmd))
if err != nil {
return fmt.Errorf("authentication required: %w", err)
}

host, owner, repoName, err := strategy.ResolveRemoteRepo(ctx, "origin")
if err != nil {
return fmt.Errorf("failed to resolve repository: %w", err)
}

// If no trail ID provided, show interactive picker of branchless trails
if trailID == "" {
picked, err := pickBranchlessTrail(ctx, cmd, client, host, owner, repoName)
if err != nil {
return err
}
trailID = picked
}

// PATCH the trail to set the branch
updateReq := api.TrailUpdateRequest{
Branch: &branch,
}
Comment on lines +614 to +617
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command help/PR intent says this subcommand links branchless trails, but when a trail ID is provided it unconditionally PATCHes the branch and can effectively re-link an already-linked trail to a new branch. Consider fetching the trail first (GET the trail detail endpoint) and refusing when Trail.Branch is already set (or update the command description/introduce an explicit --force to make re-linking intentional).

Copilot uses AI. Check for mistakes.

resp, err := client.Patch(ctx, trailsBasePath(host, owner, repoName)+"/"+trailID, updateReq)
if err != nil {
return fmt.Errorf("failed to link trail: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusConflict {
return fmt.Errorf("branch %q is already linked to another trail — each branch can only be linked to one trail", branch)
}
if err := checkTrailResponse(resp); err != nil {
return err
}

var updateResp api.TrailUpdateResponse
if err := api.DecodeJSON(resp, &updateResp); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}

fmt.Fprintf(w, "Linked trail %q to branch %s\n", updateResp.Trail.Title, branch)
return nil
}

// pickBranchlessTrail fetches all trails and presents an interactive picker
// for trails that have no branch assigned.
func pickBranchlessTrail(ctx context.Context, cmd *cobra.Command, client *api.Client, host, owner, repo string) (string, error) {
w := cmd.OutOrStdout()

resp, err := client.Get(ctx, trailsBasePath(host, owner, repo))
if err != nil {
return "", fmt.Errorf("failed to list trails: %w", err)
}
defer resp.Body.Close()
if err := checkTrailResponse(resp); err != nil {
return "", err
}

var listResp api.TrailListResponse
if err := api.DecodeJSON(resp, &listResp); err != nil {
return "", fmt.Errorf("failed to decode trail list: %w", err)
}

// Filter to trails without a branch
var branchless []api.TrailResource
for _, t := range listResp.Trails {
if t.Branch == "" {
branchless = append(branchless, t)
}
}

if len(branchless) == 0 {
fmt.Fprintln(w, "No trails without a branch found.")
fmt.Fprintln(w, "Use 'entire trail create' to create a new trail, or pass a trail ID directly.")
return "", NewSilentError(errors.New("no branchless trails"))
Comment on lines +668 to +671
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This path prints an error condition to stdout and then returns SilentError. Other commands print user-facing error messages to cmd.ErrOrStderr() before returning SilentError (e.g., cmd/entire/cli/migrate.go:45-46). Consider writing these messages to stderr (cmd.ErrOrStderr()) so scripts/pipes can distinguish normal output from errors.

Copilot uses AI. Check for mistakes.
}

// Build options for the picker
options := make([]huh.Option[string], 0, len(branchless))
for _, t := range branchless {
label := fmt.Sprintf("%s (%s)", t.Title, t.TrailID)
options = append(options, huh.NewOption(label, t.TrailID))
}

var selected string
form := NewAccessibleForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Select a trail to link").
Options(options...).
Value(&selected),
),
)
if err := form.Run(); err != nil {
return "", handleFormCancellation(w, "Trail link", err)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Form cancellation proceeds with empty trail ID

Medium Severity

When the user cancels the interactive trail picker (Ctrl+C), handleFormCancellation returns nil (by design, for clean cobra exits). But pickBranchlessTrail returns ("", nil), and the caller runTrailLink only checks err != nil, so it continues with an empty trailID. This causes a PATCH request to an invalid URL (trailing / with no ID), resulting in a confusing API error after the "Trail link cancelled." message. Every other call site uses handleFormCancellation as a direct return from RunE, where returning nil correctly signals a clean exit.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cf07d31. Configure here.

}
return selected, nil
}

// defaultBaseBranch is the fallback base branch name when it cannot be determined.
const defaultBaseBranch = "main"

Expand Down
Loading