Skip to content
Open
Show file tree
Hide file tree
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
16 changes: 15 additions & 1 deletion cmd/entire/cli/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ func newDispatchCmd() *cobra.Command {
flagRepos []string
flagVoice string
flagInsecureHTTPAuth bool
flagAuthor string
flagMe bool
)

cmd := &cobra.Command{
Expand All @@ -39,6 +41,8 @@ Examples:
entire dispatch
entire dispatch --local --all-branches
entire dispatch --repos entireio/cli
entire dispatch --repos entireio/cli --me
entire dispatch --repos entireio/cli --author teammate@example.com
entire dispatch --voice neutral`,
RunE: func(cmd *cobra.Command, _ []string) error {
var (
Expand All @@ -49,7 +53,7 @@ Examples:
if shouldRunDispatchWizard(cmd.Flags().NFlag(), isTerminalStdin(os.Stdin), interactive.IsTerminalWriter(cmd.OutOrStdout())) {
opts, err = runDispatchWizard(cmd)
} else {
opts, err = parseDispatchFlags(cmd, flagLocal, flagSince, flagUntil, flagAllBranches, flagRepos, flagVoice, flagInsecureHTTPAuth)
opts, err = parseDispatchFlags(cmd, flagLocal, flagSince, flagUntil, flagAllBranches, flagRepos, flagVoice, flagInsecureHTTPAuth, flagAuthor, flagMe)
}
if err != nil {
if errors.Is(err, errDispatchCancelled) {
Expand All @@ -74,6 +78,8 @@ Examples:
cmd.Flags().BoolVar(&flagAllBranches, "all-branches", false, "include every existing local branch (--local only; renamed or deleted branches are skipped)")
cmd.Flags().StringSliceVar(&flagRepos, "repos", nil, fmt.Sprintf("cloud repo slugs, up to %d (for example entireio/cli)", dispatchpkg.CloudRepoLimit))
cmd.Flags().StringVar(&flagVoice, "voice", "", "voice preset name or literal description")
cmd.Flags().StringVar(&flagAuthor, "author", "", "filter to checkpoints authored by this email")
cmd.Flags().BoolVar(&flagMe, "me", false, "filter to your own checkpoints (alias for --author <self>)")
Comment on lines +81 to +82
cmd.Flags().BoolVar(&flagInsecureHTTPAuth, "insecure-http-auth", false, "Allow authentication over plain HTTP (insecure, for local development only)")
if err := cmd.Flags().MarkHidden("insecure-http-auth"); err != nil {
panic(fmt.Sprintf("hide insecure-http-auth flag: %v", err))
Expand Down Expand Up @@ -125,6 +131,8 @@ func parseDispatchFlags(
flagRepos []string,
flagVoice string,
flagInsecureHTTPAuth bool,
flagAuthor string,
flagMe bool,
) (dispatchpkg.Options, error) {
return resolveDispatchOptions(
flagLocal,
Expand All @@ -134,6 +142,8 @@ func parseDispatchFlags(
flagRepos,
flagVoice,
flagInsecureHTTPAuth,
flagAuthor,
flagMe,
func() (string, error) {
return GetCurrentBranch(cmd.Context())
},
Expand All @@ -149,6 +159,8 @@ func resolveDispatchOptions(
flagRepos []string,
flagVoice string,
flagInsecureHTTPAuth bool,
flagAuthor string,
flagMe bool,
currentBranch func() (string, error),
) (dispatchpkg.Options, error) {
return dispatchpkg.ResolveOptions(
Expand All @@ -159,6 +171,8 @@ func resolveDispatchOptions(
flagRepos,
flagVoice,
flagInsecureHTTPAuth,
flagAuthor,
flagMe,
currentBranch,
)
}
4 changes: 4 additions & 0 deletions cmd/entire/cli/dispatch/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ type CreateDispatchRequest struct {
Until string `json:"until"`
Generate bool `json:"generate"`
Voice string `json:"voice,omitempty"`
// Author filters checkpoints to a specific author email.
Author string `json:"author,omitempty"`
// Me asks the server to filter to the bearer-token user's checkpoints.
Me bool `json:"me,omitempty"`
}

type CreateDispatchResponse struct {
Expand Down
109 changes: 109 additions & 0 deletions cmd/entire/cli/dispatch/cloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,115 @@ func TestCloudClient_CreateDispatch_AcceptsVoiceResponseField(t *testing.T) {
}
}

func TestCloudClient_CreateDispatch_SendsAuthorAndMeWhenSet(t *testing.T) {
t.Parallel()

type bodyAssertion struct {
expectAuthor string
expectMe bool
}
cases := []struct {
name string
req CreateDispatchRequest
want bodyAssertion
}{
{
name: "author only",
req: CreateDispatchRequest{
Repos: []string{"entireio/cli"},
Since: "2026-04-09T00:00:00Z",
Until: "2026-04-16T00:00:00Z",
Generate: true,
Author: "teammate@example.com",
},
want: bodyAssertion{expectAuthor: "teammate@example.com"},
},
{
name: "me only",
req: CreateDispatchRequest{
Repos: []string{"entireio/cli"},
Since: "2026-04-09T00:00:00Z",
Until: "2026-04-16T00:00:00Z",
Generate: true,
Me: true,
},
want: bodyAssertion{expectMe: true},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if tc.want.expectAuthor != "" {
got, ok := body["author"].(string)
if !ok || got != tc.want.expectAuthor {
t.Fatalf("expected author %q in body, got %v", tc.want.expectAuthor, body["author"])
}
} else if _, ok := body["author"]; ok {
t.Fatalf("did not expect author in body, got %v", body["author"])
}
if tc.want.expectMe {
got, ok := body["me"].(bool)
if !ok || !got {
t.Fatalf("expected me=true in body, got %v", body["me"])
}
} else if _, ok := body["me"]; ok {
t.Fatalf("did not expect me in body, got %v", body["me"])
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"repos":[],"generated_markdown":"hi"}`)) //nolint:errcheck // test fixture response
}))
defer srv.Close()

client := NewCloudClient(CloudConfig{BaseURL: srv.URL, Token: "t"})
if _, err := client.CreateDispatch(ctx, tc.req); err != nil {
t.Fatal(err)
}
})
}
}

func TestCloudClient_CreateDispatch_OmitsAuthorAndMeWhenUnset(t *testing.T) {
t.Parallel()

ctx := context.Background()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if _, ok := body["author"]; ok {
t.Fatalf("did not expect author in unset body, got %v", body["author"])
}
if _, ok := body["me"]; ok {
t.Fatalf("did not expect me in unset body, got %v", body["me"])
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"repos":[],"generated_markdown":"hi"}`)) //nolint:errcheck // test fixture response
}))
defer srv.Close()

client := NewCloudClient(CloudConfig{BaseURL: srv.URL, Token: "t"})
if _, err := client.CreateDispatch(ctx, CreateDispatchRequest{
Repos: []string{"entireio/cli"},
Since: "2026-04-09T00:00:00Z",
Until: "2026-04-16T00:00:00Z",
Generate: true,
}); err != nil {
t.Fatal(err)
}
}

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
Expand Down
10 changes: 10 additions & 0 deletions cmd/entire/cli/dispatch/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ type Options struct {
ImplicitCurrentBranch bool
Voice string
InsecureHTTPAuth bool

// Author filters checkpoints to a specific author email.
// Cloud mode passes it through to the server; local mode matches it
// case-insensitively against the metadata-branch commit author.
Comment on lines +37 to +39
Author string

// Me requests filtering to the current operator. Cloud mode delegates
// resolution to the bearer-token user; local mode resolves it to
// `git config user.email` at run time.
Me bool
}

// CloudRepoLimit caps how many repos the cloud mode may query in one request.
Expand Down
2 changes: 2 additions & 0 deletions cmd/entire/cli/dispatch/mode_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ func runServer(ctx context.Context, opts Options) (*Dispatch, error) {
Until: normalizedUntil.Format(time.RFC3339),
Generate: true,
Voice: resolvedDispatchVoicePreference(opts.Voice),
Author: opts.Author,
Me: opts.Me,
}
response, err := cloud.CreateDispatch(ctx, reqBody)
if err != nil {
Expand Down
125 changes: 125 additions & 0 deletions cmd/entire/cli/dispatch/mode_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ func runLocal(ctx context.Context, opts Options) (*Dispatch, error) {
return nil, err
}

authorFilter, err := resolveLocalAuthorFilter(ctx, opts, repoRoots)
if err != nil {
return nil, err
}

allCandidates := make([]candidate, 0)
var candidatesMu sync.Mutex
group, groupCtx := errgroup.WithContext(ctx)
Expand All @@ -59,6 +64,12 @@ func runLocal(ctx context.Context, opts Options) (*Dispatch, error) {
if err != nil {
return err
}
if authorFilter != "" {
candidates, err = filterCandidatesByAuthor(groupCtx, repoRoot, candidates, authorFilter)
if err != nil {
return err
}
}
candidatesMu.Lock()
allCandidates = append(allCandidates, candidates...)
candidatesMu.Unlock()
Expand All @@ -69,6 +80,10 @@ func runLocal(ctx context.Context, opts Options) (*Dispatch, error) {
return nil, fmt.Errorf("enumerate repo candidates: %w", err)
}

if authorFilter != "" && len(allCandidates) == 0 {
return nil, fmt.Errorf("no checkpoints in window for author %q", authorFilter)
}

fallback := applyFallbackChain(allCandidates)
dispatch := &Dispatch{
CoveredRepos: coveredRepos(allCandidates),
Expand Down Expand Up @@ -351,6 +366,116 @@ func localBranchNames(repo *git.Repository) ([]string, error) {
return names, nil
}

// resolveLocalAuthorFilter computes the lowercased email used to filter
// candidates in local mode. Returns "" when no filter is requested. --me
// resolves to `git config user.email` from the first repo (cross-repo email
// mismatches are rare; pass --author <email> to override).
func resolveLocalAuthorFilter(ctx context.Context, opts Options, repoRoots []string) (string, error) {
if opts.Author != "" {
return strings.ToLower(opts.Author), nil
}
if !opts.Me {
return "", nil
}
if len(repoRoots) == 0 {
return "", errors.New("--me requires a git repository")
}
email, err := readGitUserEmail(ctx, repoRoots[0])
if err != nil {
return "", err
}
if email == "" {
return "", errors.New("--me requires git config user.email; set it or pass --author <email>")
}
return strings.ToLower(email), nil
}

func readGitUserEmail(ctx context.Context, repoRoot string) (string, error) {
out, err := exec.CommandContext(ctx, "git", "-C", repoRoot, "config", "user.email").Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return "", nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

readGitUserEmail swallows fatal git exit codes

Medium Severity

readGitUserEmail treats all exec.ExitError results identically by returning ("", nil). For git config user.email, exit code 1 means "key not set" (a normal condition), while exit code 128+ indicates a real failure (e.g., corrupt .gitconfig). By not checking exitErr.ExitCode(), a genuine error is silently swallowed, and the caller produces the misleading message "requires git config user.email" instead of surfacing the actual git failure.

Fix in Cursor Fix in Web

Triggered by learned rule: Distinguish git command exit codes — exit 1 means "no results", not error

Reviewed by Cursor Bugbot for commit 1ec45c3. Configure here.

return "", fmt.Errorf("read git user.email: %w", err)
}
return strings.TrimSpace(string(out)), nil
}

// filterCandidatesByAuthor returns the subset of candidates whose
// entire/checkpoints/v1 metadata commit was authored by authorEmailLower.
func filterCandidatesByAuthor(ctx context.Context, repoRoot string, candidates []candidate, authorEmailLower string) ([]candidate, error) {
if len(candidates) == 0 {
return candidates, nil
}
authors, err := loadCommitAuthorsByCheckpoint(ctx, repoRoot)
if err != nil {
return nil, err
}
filtered := make([]candidate, 0, len(candidates))
for _, cand := range candidates {
email, ok := authors[cand.CheckpointID]
if !ok {
continue
}
if strings.ToLower(email) != authorEmailLower {
continue
}
filtered = append(filtered, cand)
}
return filtered, nil
}

// loadCommitAuthorsByCheckpoint maps checkpoint ID → author email by reading
// commits on entire/checkpoints/v1 (subject is "Checkpoint: <id>"). Returns an
// empty map when the metadata branch does not exist yet.
func loadCommitAuthorsByCheckpoint(ctx context.Context, repoRoot string) (map[string]string, error) {
cmd := exec.CommandContext(
ctx,
"git",
"-C",
repoRoot,
"log",
"entire/checkpoints/v1",
"--format=%ae%x00%s%x00%x00",
)
Comment on lines +432 to +441
output, err := cmd.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return map[string]string{}, nil
}
return nil, fmt.Errorf("git log entire/checkpoints/v1: %w", err)
}

authors := make(map[string]string)
const prefix = "Checkpoint: "
for _, record := range strings.Split(string(output), "\x00\x00") {
record = strings.TrimSuffix(record, "\x00")
if record == "" {
continue
}
parts := strings.SplitN(record, "\x00", 2)
if len(parts) != 2 {
continue
}
email := strings.TrimSpace(parts[0])
subject := strings.TrimSpace(parts[1])
if !strings.HasPrefix(subject, prefix) {
continue
}
id := strings.TrimSpace(strings.TrimPrefix(subject, prefix))
if id == "" {
continue
}
if _, exists := authors[id]; exists {
continue
}
authors[id] = email
}
return authors, nil
}

func loadCommitSubjectsByCheckpoint(ctx context.Context, repoRoot string, since time.Time) (map[string]string, error) {
cmd := exec.CommandContext(
ctx,
Expand Down
Loading
Loading