From 41e6dbb96005eeaa6aeee893d9345a5e5895bd90 Mon Sep 17 00:00:00 2001 From: jonnii Date: 2026年2月20日 07:52:53 -0500 Subject: [PATCH 1/2] feat: add prompt notes to track LLM context on commits --- internal/actions/notes/notes.go | 181 ++++++++++++++++++ internal/actions/submit/submit.go | 6 + internal/actions/sync/metadata_sync.go | 7 + internal/actions/sync/sync.go | 6 + .../agents/templates/commands/stack-create.md | 11 ++ .../agents/templates/commands/stack-modify.md | 11 ++ internal/cli/notes.go | 143 ++++++++++++++ internal/cli/passthrough.go | 2 +- internal/cli/root.go | 1 + internal/demo/demo_git_runner.go | 34 ++++ internal/git/interfaces.go | 13 ++ internal/git/notes.go | 157 +++++++++++++++ internal/git/runner.go | 1 + internal/git/tracing_runner.go | 58 ++++++ 14 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 internal/actions/notes/notes.go create mode 100644 internal/cli/notes.go create mode 100644 internal/git/notes.go diff --git a/internal/actions/notes/notes.go b/internal/actions/notes/notes.go new file mode 100644 index 000000000..2b8a364dd --- /dev/null +++ b/internal/actions/notes/notes.go @@ -0,0 +1,181 @@ +// Package notes implements the stackit notes command for managing prompt notes on commits. +package notes + +import ( + "encoding/json" + "fmt" + + "github.com/getstackit/stackit/internal/app" + "github.com/getstackit/stackit/internal/errors" + "github.com/getstackit/stackit/internal/git" + "github.com/getstackit/stackit/internal/output" + "github.com/getstackit/stackit/internal/tui/style" +) + +// ShowOptions contains options for the show subcommand. +type ShowOptions struct { + Commit string // Commit to show note for (defaults to HEAD) +} + +// AddOptions contains options for the add subcommand. +type AddOptions struct { + Prompt string + Summary string + Model string + Memory []string + JSON string // Raw JSON alternative to individual flags +} + +// LogOptions contains options for the log subcommand. +type LogOptions struct { + // Empty for now - uses current branch's commit range +} + +// Show displays the prompt note for a commit. +func Show(ctx *app.Context, opts ShowOptions) error { + out := ctx.Output + + commit := opts.Commit + if commit == "" { + rev, err := ctx.Git().GetCurrentRevision(ctx.Context) + if err != nil { + return fmt.Errorf("failed to get current revision: %w", err) + } + commit = rev + } + + note, err := ctx.Git().ShowPromptNote(ctx.Context, commit) + if err != nil { + return fmt.Errorf("failed to read prompt note: %w", err) + } + + if note == nil { + shortSHA := commit + if len(shortSHA)> 7 { + shortSHA = shortSHA[:7] + } + out.Info("No prompt note on %s.", style.ColorDim(shortSHA)) + return nil + } + + printNote(out, note) + return nil +} + +// Add creates or replaces a prompt note on HEAD. +func Add(ctx *app.Context, opts AddOptions) error { + out := ctx.Output + g := ctx.Git() + + rev, err := g.GetCurrentRevision(ctx.Context) + if err != nil { + return fmt.Errorf("failed to get current revision: %w", err) + } + + var note git.PromptNote + + if opts.JSON != "" { + if err := json.Unmarshal([]byte(opts.JSON), ¬e); err != nil { + return fmt.Errorf("failed to parse JSON: %w", err) + } + } else { + if opts.Prompt == "" { + return fmt.Errorf("--prompt is required") + } + note = git.PromptNote{ + Prompt: opts.Prompt, + Summary: opts.Summary, + Model: opts.Model, + Memory: opts.Memory, + } + } + + // Ensure notes.rewriteRef is configured so notes survive rebase + if err := g.EnsureNotesRewriteConfigured(); err != nil { + out.Debug("Failed to configure notes.rewriteRef: %v", err) + } + + if err := g.AddPromptNote(ctx.Context, rev, ¬e); err != nil { + return err + } + + shortSHA := rev + if len(shortSHA)> 7 { + shortSHA = shortSHA[:7] + } + out.Info("Added prompt note to %s.", style.ColorDim(shortSHA)) + return nil +} + +// Log shows commits on the current branch with their prompt notes. +func Log(ctx *app.Context) error { + eng := ctx.Engine + out := ctx.Output + + currentBranch := eng.CurrentBranch() + if currentBranch == nil { + return errors.ErrNotOnBranch + } + + // Get parent branch for commit range + parent := currentBranch.GetParent() + if parent == nil { + return fmt.Errorf("branch %s has no parent; cannot determine commit range", currentBranch.GetName()) + } + + entries, err := ctx.Git().LogWithNotes(ctx.Context, parent.GetName(), currentBranch.GetName()) + if err != nil { + return err + } + + if len(entries) == 0 { + out.Info("No commits between %s and %s.", parent.GetName(), currentBranch.GetName()) + return nil + } + + for _, entry := range entries { + shortSHA := entry.SHA + if len(shortSHA)> 7 { + shortSHA = shortSHA[:7] + } + out.Info("%s %s", style.ColorDim(shortSHA), entry.Subject) + if entry.Note != nil { + out.Info(" Prompt: %s", entry.Note.Prompt) + if entry.Note.Summary != "" { + out.Info(" Summary: %s", entry.Note.Summary) + } + } else { + out.Info(" %s", style.ColorDim("(no prompt note)")) + } + } + + return nil +} + +// Push pushes prompt notes to the remote. +func Push(ctx *app.Context) error { + out := ctx.Output + + if err := ctx.Git().PushNotes(ctx.Context); err != nil { + return err + } + + out.Info("Pushed prompt notes to remote.") + return nil +} + +func printNote(out output.Output, note *git.PromptNote) { + out.Info("Prompt: %s", note.Prompt) + if note.Summary != "" { + out.Info("Summary: %s", note.Summary) + } + if note.Model != "" { + out.Info("Model: %s", note.Model) + } + if len(note.Memory)> 0 { + out.Info("Memory:") + for _, m := range note.Memory { + out.Info(" - %s", m) + } + } +} diff --git a/internal/actions/submit/submit.go b/internal/actions/submit/submit.go index cc17c5f70..d5d907c40 100644 --- a/internal/actions/submit/submit.go +++ b/internal/actions/submit/submit.go @@ -726,6 +726,12 @@ func pushMetadataRefs(ctx *app.Context, branches engine.Branches) error { } } + // Push prompt notes alongside branches + if err := ctx.Git().PushNotes(ctx.Context); err != nil { + ctx.Output.Debug("Failed to push prompt notes: %v", err) + // Non-fatal: prompt notes push failure shouldn't fail the submit + } + return nil } diff --git a/internal/actions/sync/metadata_sync.go b/internal/actions/sync/metadata_sync.go index 519e5534b..69423e740 100644 --- a/internal/actions/sync/metadata_sync.go +++ b/internal/actions/sync/metadata_sync.go @@ -44,6 +44,13 @@ func processRemoteMetadata(ctx *app.Context, opts *Options, handler Handler) err if err := eng.ConfigureStackMetadataSync(ctx.Context); err != nil { out.Debug("Failed to configure stack metadata refspec: %v", err) } + // Configure prompt notes refspec and rewrite config + if err := ctx.Git().EnsureNotesRefspecConfigured(); err != nil { + out.Debug("Failed to configure notes refspec: %v", err) + } + if err := ctx.Git().EnsureNotesRewriteConfigured(); err != nil { + out.Debug("Failed to configure notes rewrite: %v", err) + } ctx.Logger.Info("configure remote metadata sync completed durationMs=%d", time.Since(configStart).Milliseconds()) // Load remote metadata into cache diff --git a/internal/actions/sync/sync.go b/internal/actions/sync/sync.go index c5b723f1b..f3bf8f504 100644 --- a/internal/actions/sync/sync.go +++ b/internal/actions/sync/sync.go @@ -135,6 +135,12 @@ func Action(ctx *app.Context, opts Options, handler Handler) error { if metadataFetchErr != nil { ctx.Logger.Debug("fetch metadata refs failed (non-fatal) error=%v", metadataFetchErr) } + // Fetch prompt notes + notesFetchStart := time.Now() + if err := ctx.Git().FetchNotes(gctx); err != nil { + ctx.Logger.Debug("fetch prompt notes failed (non-fatal) error=%v", err) + } + ctx.Logger.Info("fetch prompt notes completed durationMs=%v", time.Since(notesFetchStart).Milliseconds()) return nil }) diff --git a/internal/cli/integrations/agents/templates/commands/stack-create.md b/internal/cli/integrations/agents/templates/commands/stack-create.md index 913603f74..b7f4aa0a7 100644 --- a/internal/cli/integrations/agents/templates/commands/stack-create.md +++ b/internal/cli/integrations/agents/templates/commands/stack-create.md @@ -58,6 +58,17 @@ Only run the next shell command needed for the next decision. Avoid eager status **Never use:** `git commit` or `git checkout -b` — always use `stackit create`. +## Prompt Note + +After a successful create, record what was done by running: +```bash +command stackit notes add \ + --prompt "" \ + --summary "" \ + --model "" +``` +This captures LLM context on the commit for audit and memory purposes. Keep the prompt and summary concise (1-2 sentences each). + ## Follow-up After successful creation, use `AskUserQuestion`: diff --git a/internal/cli/integrations/agents/templates/commands/stack-modify.md b/internal/cli/integrations/agents/templates/commands/stack-modify.md index 061a88d0b..7bddc2304 100644 --- a/internal/cli/integrations/agents/templates/commands/stack-modify.md +++ b/internal/cli/integrations/agents/templates/commands/stack-modify.md @@ -59,6 +59,17 @@ Modify the current branch by amending its commit or creating a new commit. Autom **Never use:** `git commit --amend` — always use `stackit modify` so descendants are restacked. +## Prompt Note + +After a successful modify, record what was done by running: +```bash +command stackit notes add \ + --prompt "" \ + --summary "" \ + --model "" +``` +This captures LLM context on the commit for audit and memory purposes. Keep the prompt and summary concise (1-2 sentences each). + ## Follow-up After successful modification, use `AskUserQuestion`: diff --git a/internal/cli/notes.go b/internal/cli/notes.go new file mode 100644 index 000000000..a1471be69 --- /dev/null +++ b/internal/cli/notes.go @@ -0,0 +1,143 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/getstackit/stackit/internal/actions/notes" + "github.com/getstackit/stackit/internal/app" + "github.com/getstackit/stackit/internal/cli/common" +) + +func newNotesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "notes", + Short: "Manage prompt notes on commits", + Long: `View and manage LLM prompt notes stored as git notes on commits. + +Prompt notes track what was asked of an AI and what it did, providing +an audit trail for AI-assisted changes. Notes survive rebases and +can be pushed/fetched alongside branches. + +Examples: + stackit notes # Show note for HEAD + stackit notes show # Show note for HEAD + stackit notes show abc1234 # Show note for specific commit + stackit notes add -p "add logout" # Add note to HEAD + stackit notes log # Show branch commits with notes + stackit notes push # Push notes to remote`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + // Default: show note for HEAD + return common.Run(cmd, func(ctx *app.Context) error { + return notes.Show(ctx, notes.ShowOptions{}) + }) + }, + } + + cmd.AddCommand(newNotesShowCmd()) + cmd.AddCommand(newNotesAddCmd()) + cmd.AddCommand(newNotesLogCmd()) + cmd.AddCommand(newNotesPushCmd()) + + return cmd +} + +func newNotesShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show [commit]", + Short: "Show the prompt note for a commit", + Long: `Show the prompt note attached to a commit. Defaults to HEAD. + +Examples: + stackit notes show # Show note for HEAD + stackit notes show abc1234 # Show note for specific commit`, + SilenceUsage: true, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return common.Run(cmd, func(ctx *app.Context) error { + opts := notes.ShowOptions{} + if len(args)> 0 { + opts.Commit = args[0] + } + return notes.Show(ctx, opts) + }) + }, + } +} + +func newNotesAddCmd() *cobra.Command { + var ( + prompt string + summary string + model string + memory []string + rawJSON string + ) + + cmd := &cobra.Command{ + Use: "add", + Short: "Add a prompt note to HEAD", + Long: `Add or replace a prompt note on the current commit (HEAD). + +Notes store LLM context: what was asked, what was done, which model, +and key insights worth remembering. + +Examples: + stackit notes add -p "add logout button" -s "Added LogoutButton component" + stackit notes add -p "fix auth" -s "Fixed token refresh" --model claude-opus-4 --memory "uses Supabase" + stackit notes add --json '{"prompt":"test","summary":"test summary"}'`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return common.Run(cmd, func(ctx *app.Context) error { + return notes.Add(ctx, notes.AddOptions{ + Prompt: prompt, + Summary: summary, + Model: model, + Memory: memory, + JSON: rawJSON, + }) + }) + }, + } + + cmd.Flags().StringVarP(&prompt, "prompt", "p", "", "The user's instruction to the LLM") + cmd.Flags().StringVarP(&summary, "summary", "s", "", "What the AI actually did") + cmd.Flags().StringVar(&model, "model", "", "Which model was used") + cmd.Flags().StringArrayVar(&memory, "memory", nil, "Key insight worth remembering (repeatable)") + cmd.Flags().StringVar(&rawJSON, "json", "", "Raw JSON note (alternative to individual flags)") + + return cmd +} + +func newNotesLogCmd() *cobra.Command { + return &cobra.Command{ + Use: "log", + Short: "Show branch commits with their prompt notes", + Long: `Show all commits on the current branch (relative to parent) with +their prompt notes, if any. + +Examples: + stackit notes log`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return common.Run(cmd, notes.Log) + }, + } +} + +func newNotesPushCmd() *cobra.Command { + return &cobra.Command{ + Use: "push", + Short: "Push prompt notes to the remote", + Long: `Push all prompt notes to the remote repository. + +This is also done automatically during 'stackit submit'. + +Examples: + stackit notes push`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return common.Run(cmd, notes.Push) + }, + } +} diff --git a/internal/cli/passthrough.go b/internal/cli/passthrough.go index f1e3c58de..56bd1adbe 100644 --- a/internal/cli/passthrough.go +++ b/internal/cli/passthrough.go @@ -36,7 +36,7 @@ var gitCommandAllowlist = []string{ "grep", // "merge" removed - stackit has its own merge command "mv", - "notes", + // "notes" removed - stackit has its own notes command "pull", "push", "range-diff", diff --git a/internal/cli/root.go b/internal/cli/root.go index 23e47dc1f..10e1993d1 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -112,6 +112,7 @@ Commit: ` + commit + ` rootCmd.AddCommand(stack.NewMergeCmd()) rootCmd.AddCommand(branch.NewModifyCmd()) rootCmd.AddCommand(stack.NewMoveCmd()) + rootCmd.AddCommand(newNotesCmd()) rootCmd.AddCommand(navigation.NewParentCmd()) rootCmd.AddCommand(branch.NewPopCmd()) rootCmd.AddCommand(stack.NewPluckCmd()) diff --git a/internal/demo/demo_git_runner.go b/internal/demo/demo_git_runner.go index b4f1dd383..f53b6dddd 100644 --- a/internal/demo/demo_git_runner.go +++ b/internal/demo/demo_git_runner.go @@ -761,3 +761,37 @@ func (d *demoGitRunner) WriteStackMetaBlob(_ *git.StackMeta) (string, error) { func (d *demoGitRunner) GetStackMetaRefSHA(_ string) string { return "" } + +// NotesOperations methods + +func (d *demoGitRunner) AddPromptNote(_ context.Context, _ string, _ *git.PromptNote) error { + return nil +} + +func (d *demoGitRunner) ShowPromptNote(_ context.Context, _ string) (*git.PromptNote, error) { + return nil, nil +} + +func (d *demoGitRunner) RemovePromptNote(_ context.Context, _ string) error { + return nil +} + +func (d *demoGitRunner) LogWithNotes(_ context.Context, _, _ string) ([]git.NoteEntry, error) { + return nil, nil +} + +func (d *demoGitRunner) PushNotes(_ context.Context) error { + return nil +} + +func (d *demoGitRunner) FetchNotes(_ context.Context) error { + return nil +} + +func (d *demoGitRunner) EnsureNotesRewriteConfigured() error { + return nil +} + +func (d *demoGitRunner) EnsureNotesRefspecConfigured() error { + return nil +} diff --git a/internal/git/interfaces.go b/internal/git/interfaces.go index 90025e865..33f575e53 100644 --- a/internal/git/interfaces.go +++ b/internal/git/interfaces.go @@ -272,6 +272,19 @@ type MetadataOperations interface { MetadataCacheStats() MetadataCacheSummary } +// NotesOperations handles prompt note persistence via git notes. +// Notes are stored on individual commits using refs/notes/prompts. +type NotesOperations interface { + AddPromptNote(ctx context.Context, commit string, note *PromptNote) error + ShowPromptNote(ctx context.Context, commit string) (*PromptNote, error) + RemovePromptNote(ctx context.Context, commit string) error + LogWithNotes(ctx context.Context, base, head string) ([]NoteEntry, error) + PushNotes(ctx context.Context) error + FetchNotes(ctx context.Context) error + EnsureNotesRewriteConfigured() error + EnsureNotesRefspecConfigured() error +} + // StackMetadataOperations handles stack-level metadata persistence. // Stack metadata is stored separately from branch metadata and survives branch operations. type StackMetadataOperations interface { diff --git a/internal/git/notes.go b/internal/git/notes.go new file mode 100644 index 000000000..37c4edcbe --- /dev/null +++ b/internal/git/notes.go @@ -0,0 +1,157 @@ +package git + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "time" +) + +const promptNotesRef = "refs/notes/prompts" + +// PromptNote stores LLM context on a commit via git notes. +type PromptNote struct { + Prompt string `json:"prompt"` + Summary string `json:"summary"` + Model string `json:"model,omitempty"` + Timestamp string `json:"timestamp"` + Memory []string `json:"memory,omitempty"` +} + +// NoteEntry pairs a commit SHA and message with its optional prompt note. +type NoteEntry struct { + SHA string + Subject string + Note *PromptNote +} + +// AddPromptNote adds or replaces a prompt note on the given commit. +func (r *runner) AddPromptNote(ctx context.Context, commit string, note *PromptNote) error { + if note.Timestamp == "" { + note.Timestamp = time.Now().UTC().Format(time.RFC3339) + } + + data, err := json.Marshal(note) + if err != nil { + return fmt.Errorf("failed to marshal prompt note: %w", err) + } + + // git notes --ref=refs/notes/prompts add -f -m '' + _, err = r.RunGitCommandWithContext(ctx, "notes", "--ref="+promptNotesRef, "add", "-f", "-m", string(data), commit) + if err != nil { + return fmt.Errorf("failed to add prompt note: %w", err) + } + return nil +} + +// ShowPromptNote reads the prompt note for the given commit. +// Returns nil if no note exists. +func (r *runner) ShowPromptNote(ctx context.Context, commit string) (*PromptNote, error) { + output, err := r.RunGitCommandRawWithContext(ctx, "notes", "--ref="+promptNotesRef, "show", commit) + if err != nil { + // No note exists for this commit - git notes show exits non-zero + return nil, nil //nolint:nilerr // expected: git exits non-zero when no note exists + } + + output = strings.TrimSpace(output) + if output == "" { + return nil, nil + } + + var note PromptNote + if err := json.Unmarshal([]byte(output), ¬e); err != nil { + return nil, fmt.Errorf("failed to parse prompt note: %w", err) + } + return ¬e, nil +} + +// RemovePromptNote removes the prompt note from the given commit. +func (r *runner) RemovePromptNote(ctx context.Context, commit string) error { + _, err := r.RunGitCommandRawWithContext(ctx, "notes", "--ref="+promptNotesRef, "remove", commit) + if err != nil { + return fmt.Errorf("failed to remove prompt note: %w", err) + } + return nil +} + +// LogWithNotes returns commits between base..head with their prompt notes. +func (r *runner) LogWithNotes(ctx context.Context, base, head string) ([]NoteEntry, error) { + // Get commit SHAs and subjects + shas, err := r.GetCommitRange(ctx, base, head, "%H %s") + if err != nil { + return nil, fmt.Errorf("failed to get commit range: %w", err) + } + + entries := make([]NoteEntry, 0, len(shas)) + for _, line := range shas { + if line == "" { + continue + } + sha, subject, _ := strings.Cut(line, " ") + entry := NoteEntry{ + SHA: sha, + Subject: subject, + } + // Try to read note for this commit + note, err := r.ShowPromptNote(ctx, sha) + if err == nil && note != nil { + entry.Note = note + } + entries = append(entries, entry) + } + + return entries, nil +} + +// PushNotes pushes prompt notes to the remote. +func (r *runner) PushNotes(ctx context.Context) error { + _, err := r.RunGitCommandWithContext(ctx, "push", "origin", "+"+promptNotesRef) + if err != nil { + return fmt.Errorf("failed to push prompt notes: %w", err) + } + return nil +} + +// FetchNotes fetches prompt notes from the remote. +func (r *runner) FetchNotes(ctx context.Context) error { + _, err := r.RunGitCommandRawWithContext(ctx, "fetch", "origin", "+"+promptNotesRef+":"+promptNotesRef) + if err != nil { + return fmt.Errorf("failed to fetch prompt notes: %w", err) + } + return nil +} + +// EnsureNotesRewriteConfigured configures notes.rewriteRef so git +// natively copies prompt notes during rebase. +func (r *runner) EnsureNotesRewriteConfigured() error { + values, err := r.GetConfigAll("notes.rewriteRef") + if err != nil { + // Config key doesn't exist yet, that's fine + return r.AddConfigValue("notes.rewriteRef", promptNotesRef) + } + + if slices.Contains(values, promptNotesRef) { + return nil // Already configured + } + + return r.AddConfigValue("notes.rewriteRef", promptNotesRef) +} + +// EnsureNotesRefspecConfigured adds a fetch refspec for prompt notes +// so that `git fetch` automatically fetches them. +func (r *runner) EnsureNotesRefspecConfigured() error { + const notesRefspec = "+" + promptNotesRef + ":" + promptNotesRef + + refspecs, err := r.GetConfigAll("remote.origin.fetch") + if err != nil { + return fmt.Errorf("failed to get fetch refspecs: %w", err) + } + + if slices.Contains(refspecs, notesRefspec) { + return nil // Already configured + } + + return r.AddConfigValue("remote.origin.fetch", notesRefspec) +} diff --git a/internal/git/runner.go b/internal/git/runner.go index 116a20fb3..f0b4ea3c1 100644 --- a/internal/git/runner.go +++ b/internal/git/runner.go @@ -356,6 +356,7 @@ type Runner interface { ObjectOperations MetadataOperations StackMetadataOperations + NotesOperations // Raw command execution RunGitCommandWithContext(ctx context.Context, args ...string) (string, error) diff --git a/internal/git/tracing_runner.go b/internal/git/tracing_runner.go index 7a819147e..9510c655d 100644 --- a/internal/git/tracing_runner.go +++ b/internal/git/tracing_runner.go @@ -1182,6 +1182,64 @@ func (t *tracingRunner) GetLocalMetadataRefSHA(branchName string) string { return t.inner.GetLocalMetadataRefSHA(branchName) } +// NotesOperations methods + +func (t *tracingRunner) AddPromptNote(ctx context.Context, commit string, note *PromptNote) error { + start := time.Now() + err := t.inner.AddPromptNote(ctx, commit, note) + t.trace("AddPromptNote", time.Since(start), err == nil, err, slog.String("commit", commit)) + return err +} + +func (t *tracingRunner) ShowPromptNote(ctx context.Context, commit string) (*PromptNote, error) { + start := time.Now() + result, err := t.inner.ShowPromptNote(ctx, commit) + t.trace("ShowPromptNote", time.Since(start), err == nil, err, slog.String("commit", commit)) + return result, err +} + +func (t *tracingRunner) RemovePromptNote(ctx context.Context, commit string) error { + start := time.Now() + err := t.inner.RemovePromptNote(ctx, commit) + t.trace("RemovePromptNote", time.Since(start), err == nil, err, slog.String("commit", commit)) + return err +} + +func (t *tracingRunner) LogWithNotes(ctx context.Context, base, head string) ([]NoteEntry, error) { + start := time.Now() + result, err := t.inner.LogWithNotes(ctx, base, head) + t.trace("LogWithNotes", time.Since(start), err == nil, err, slog.String("base", base), slog.String("head", head)) + return result, err +} + +func (t *tracingRunner) PushNotes(ctx context.Context) error { + start := time.Now() + err := t.inner.PushNotes(ctx) + t.trace("PushNotes", time.Since(start), err == nil, err) + return err +} + +func (t *tracingRunner) FetchNotes(ctx context.Context) error { + start := time.Now() + err := t.inner.FetchNotes(ctx) + t.trace("FetchNotes", time.Since(start), err == nil, err) + return err +} + +func (t *tracingRunner) EnsureNotesRewriteConfigured() error { + start := time.Now() + err := t.inner.EnsureNotesRewriteConfigured() + t.trace("EnsureNotesRewriteConfigured", time.Since(start), err == nil, err) + return err +} + +func (t *tracingRunner) EnsureNotesRefspecConfigured() error { + start := time.Now() + err := t.inner.EnsureNotesRefspecConfigured() + t.trace("EnsureNotesRefspecConfigured", time.Since(start), err == nil, err) + return err +} + // Raw command execution methods // cmdName returns the command name with optional first argument for tracing. From e39138c4c260b474c3e6d9c0463b862309edd4bc Mon Sep 17 00:00:00 2001 From: jonnii Date: Wed, 4 Mar 2026 22:25:08 -0500 Subject: [PATCH 2/2] feedback --- internal/actions/notes/notes.go | 57 +++++---- internal/cli/notes.go | 107 +++++++++-------- internal/demo/demo_git_runner.go | 8 +- internal/git/interfaces.go | 13 +- internal/git/notes.go | 199 +++++++++++++++++++++++++------ internal/git/notes_test.go | 137 +++++++++++++++++++++ internal/git/tracing_runner.go | 24 ++-- 7 files changed, 419 insertions(+), 126 deletions(-) create mode 100644 internal/git/notes_test.go diff --git a/internal/actions/notes/notes.go b/internal/actions/notes/notes.go index 2b8a364dd..baacf8e8f 100644 --- a/internal/actions/notes/notes.go +++ b/internal/actions/notes/notes.go @@ -1,4 +1,4 @@ -// Package notes implements the stackit notes command for managing prompt notes on commits. +// Package notes implements the stackit notes command for managing contextual notes on commits. package notes import ( @@ -14,24 +14,26 @@ import ( // ShowOptions contains options for the show subcommand. type ShowOptions struct { - Commit string // Commit to show note for (defaults to HEAD) + Commit string // Commit to show note for (defaults to HEAD) + Namespace string // Namespace to read (defaults to story) } // AddOptions contains options for the add subcommand. type AddOptions struct { - Prompt string - Summary string - Model string - Memory []string - JSON string // Raw JSON alternative to individual flags + Namespace string + Prompt string + Summary string + Model string + Memory []string + JSON string // Raw JSON alternative to individual flags } // LogOptions contains options for the log subcommand. type LogOptions struct { - // Empty for now - uses current branch's commit range + Namespace string } -// Show displays the prompt note for a commit. +// Show displays the note for a commit. func Show(ctx *app.Context, opts ShowOptions) error { out := ctx.Output @@ -44,9 +46,14 @@ func Show(ctx *app.Context, opts ShowOptions) error { commit = rev } - note, err := ctx.Git().ShowPromptNote(ctx.Context, commit) + namespace := opts.Namespace + if namespace == "" { + namespace = git.DefaultNotesNamespace + } + + note, err := ctx.Git().ShowPromptNote(ctx.Context, commit, namespace) if err != nil { - return fmt.Errorf("failed to read prompt note: %w", err) + return fmt.Errorf("failed to read note: %w", err) } if note == nil { @@ -54,7 +61,7 @@ func Show(ctx *app.Context, opts ShowOptions) error { if len(shortSHA)> 7 { shortSHA = shortSHA[:7] } - out.Info("No prompt note on %s.", style.ColorDim(shortSHA)) + out.Info("No %s note on %s.", style.ColorDim(namespace), style.ColorDim(shortSHA)) return nil } @@ -62,10 +69,14 @@ func Show(ctx *app.Context, opts ShowOptions) error { return nil } -// Add creates or replaces a prompt note on HEAD. +// Add creates or replaces a note on HEAD. func Add(ctx *app.Context, opts AddOptions) error { out := ctx.Output g := ctx.Git() + namespace := opts.Namespace + if namespace == "" { + namespace = git.DefaultNotesNamespace + } rev, err := g.GetCurrentRevision(ctx.Context) if err != nil { @@ -95,7 +106,7 @@ func Add(ctx *app.Context, opts AddOptions) error { out.Debug("Failed to configure notes.rewriteRef: %v", err) } - if err := g.AddPromptNote(ctx.Context, rev, ¬e); err != nil { + if err := g.AddPromptNote(ctx.Context, rev, namespace, ¬e); err != nil { return err } @@ -103,14 +114,18 @@ func Add(ctx *app.Context, opts AddOptions) error { if len(shortSHA)> 7 { shortSHA = shortSHA[:7] } - out.Info("Added prompt note to %s.", style.ColorDim(shortSHA)) + out.Info("Added %s note to %s.", style.ColorDim(namespace), style.ColorDim(shortSHA)) return nil } -// Log shows commits on the current branch with their prompt notes. -func Log(ctx *app.Context) error { +// Log shows commits on the current branch with notes from the requested namespace. +func Log(ctx *app.Context, opts LogOptions) error { eng := ctx.Engine out := ctx.Output + namespace := opts.Namespace + if namespace == "" { + namespace = git.DefaultNotesNamespace + } currentBranch := eng.CurrentBranch() if currentBranch == nil { @@ -123,7 +138,7 @@ func Log(ctx *app.Context) error { return fmt.Errorf("branch %s has no parent; cannot determine commit range", currentBranch.GetName()) } - entries, err := ctx.Git().LogWithNotes(ctx.Context, parent.GetName(), currentBranch.GetName()) + entries, err := ctx.Git().LogWithNotes(ctx.Context, parent.GetName(), currentBranch.GetName(), namespace) if err != nil { return err } @@ -145,14 +160,14 @@ func Log(ctx *app.Context) error { out.Info(" Summary: %s", entry.Note.Summary) } } else { - out.Info(" %s", style.ColorDim("(no prompt note)")) + out.Info(" %s", style.ColorDim("(no note in namespace "+namespace+")")) } } return nil } -// Push pushes prompt notes to the remote. +// Push pushes notes to the remote. func Push(ctx *app.Context) error { out := ctx.Output @@ -160,7 +175,7 @@ func Push(ctx *app.Context) error { return err } - out.Info("Pushed prompt notes to remote.") + out.Info("Pushed notes to remote.") return nil } diff --git a/internal/cli/notes.go b/internal/cli/notes.go index a1471be69..894318b10 100644 --- a/internal/cli/notes.go +++ b/internal/cli/notes.go @@ -6,30 +6,31 @@ import ( "github.com/getstackit/stackit/internal/actions/notes" "github.com/getstackit/stackit/internal/app" "github.com/getstackit/stackit/internal/cli/common" + "github.com/getstackit/stackit/internal/git" ) func newNotesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "notes", - Short: "Manage prompt notes on commits", - Long: `View and manage LLM prompt notes stored as git notes on commits. + Short: "Manage contextual notes on commits", + Long: `View and manage contextual notes stored as git notes on commits. -Prompt notes track what was asked of an AI and what it did, providing -an audit trail for AI-assisted changes. Notes survive rebases and -can be pushed/fetched alongside branches. +Notes can be organized by namespace (default: story). They survive +rebases and can be pushed/fetched alongside branches. Examples: - stackit notes # Show note for HEAD - stackit notes show # Show note for HEAD - stackit notes show abc1234 # Show note for specific commit - stackit notes add -p "add logout" # Add note to HEAD - stackit notes log # Show branch commits with notes - stackit notes push # Push notes to remote`, + stackit notes # Show note for HEAD + stackit notes show # Show note for HEAD + stackit notes show abc1234 # Show note for a specific commit + stackit notes show --namespace story # Show note in "story" namespace + stackit notes add -p "add logout" # Add note to HEAD + stackit notes log # Show branch commits with notes + stackit notes push # Push notes to remote`, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { // Default: show note for HEAD return common.Run(cmd, func(ctx *app.Context) error { - return notes.Show(ctx, notes.ShowOptions{}) + return notes.Show(ctx, notes.ShowOptions{Namespace: git.DefaultNotesNamespace}) }) }, } @@ -43,19 +44,21 @@ Examples: } func newNotesShowCmd() *cobra.Command { - return &cobra.Command{ + namespace := git.DefaultNotesNamespace + cmd := &cobra.Command{ Use: "show [commit]", - Short: "Show the prompt note for a commit", - Long: `Show the prompt note attached to a commit. Defaults to HEAD. + Short: "Show a note for a commit", + Long: `Show a namespaced note attached to a commit. Defaults to HEAD. Examples: - stackit notes show # Show note for HEAD - stackit notes show abc1234 # Show note for specific commit`, + stackit notes show # Show note for HEAD in story namespace + stackit notes show abc1234 # Show note for specific commit + stackit notes show --namespace learnings # Show note in specific namespace`, SilenceUsage: true, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return common.Run(cmd, func(ctx *app.Context) error { - opts := notes.ShowOptions{} + opts := notes.ShowOptions{Namespace: namespace} if len(args)> 0 { opts.Commit = args[0] } @@ -63,73 +66,83 @@ Examples: }) }, } + cmd.Flags().StringVar(&namespace, "namespace", git.DefaultNotesNamespace, "Note namespace") + return cmd } func newNotesAddCmd() *cobra.Command { var ( - prompt string - summary string - model string - memory []string - rawJSON string + namespace string + prompt string + summary string + model string + memory []string + rawJSON string ) cmd := &cobra.Command{ Use: "add", - Short: "Add a prompt note to HEAD", - Long: `Add or replace a prompt note on the current commit (HEAD). + Short: "Add a note to HEAD", + Long: `Add or replace a namespaced note on the current commit (HEAD). -Notes store LLM context: what was asked, what was done, which model, -and key insights worth remembering. +Notes can capture context such as rationale, story, or learnings. Examples: - stackit notes add -p "add logout button" -s "Added LogoutButton component" - stackit notes add -p "fix auth" -s "Fixed token refresh" --model claude-opus-4 --memory "uses Supabase" - stackit notes add --json '{"prompt":"test","summary":"test summary"}'`, + stackit notes add -p "add logout button" -s "Added LogoutButton component" + stackit notes add --namespace learnings -p "fix auth" -s "Fixed token refresh" + stackit notes add --json '{"prompt":"test","summary":"test summary"}'`, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { return common.Run(cmd, func(ctx *app.Context) error { return notes.Add(ctx, notes.AddOptions{ - Prompt: prompt, - Summary: summary, - Model: model, - Memory: memory, - JSON: rawJSON, + Namespace: namespace, + Prompt: prompt, + Summary: summary, + Model: model, + Memory: memory, + JSON: rawJSON, }) }) }, } - cmd.Flags().StringVarP(&prompt, "prompt", "p", "", "The user's instruction to the LLM") - cmd.Flags().StringVarP(&summary, "summary", "s", "", "What the AI actually did") - cmd.Flags().StringVar(&model, "model", "", "Which model was used") - cmd.Flags().StringArrayVar(&memory, "memory", nil, "Key insight worth remembering (repeatable)") - cmd.Flags().StringVar(&rawJSON, "json", "", "Raw JSON note (alternative to individual flags)") + cmd.Flags().StringVar(&namespace, "namespace", git.DefaultNotesNamespace, "Note namespace") + cmd.Flags().StringVarP(&prompt, "prompt", "p", "", "Prompt or instruction context") + cmd.Flags().StringVarP(&summary, "summary", "s", "", "Summary of what was done") + cmd.Flags().StringVar(&model, "model", "", "Model used (optional)") + cmd.Flags().StringArrayVar(&memory, "memory", nil, "Insight worth remembering (repeatable)") + cmd.Flags().StringVar(&rawJSON, "json", "", "Raw JSON note payload (alternative to individual flags)") return cmd } func newNotesLogCmd() *cobra.Command { - return &cobra.Command{ + namespace := git.DefaultNotesNamespace + cmd := &cobra.Command{ Use: "log", - Short: "Show branch commits with their prompt notes", + Short: "Show branch commits with notes", Long: `Show all commits on the current branch (relative to parent) with -their prompt notes, if any. +notes from a namespace, if any. Examples: - stackit notes log`, + stackit notes log + stackit notes log --namespace learnings`, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - return common.Run(cmd, notes.Log) + return common.Run(cmd, func(ctx *app.Context) error { + return notes.Log(ctx, notes.LogOptions{Namespace: namespace}) + }) }, } + cmd.Flags().StringVar(&namespace, "namespace", git.DefaultNotesNamespace, "Note namespace") + return cmd } func newNotesPushCmd() *cobra.Command { return &cobra.Command{ Use: "push", - Short: "Push prompt notes to the remote", - Long: `Push all prompt notes to the remote repository. + Short: "Push notes to the remote", + Long: `Push all notes to the remote repository. This is also done automatically during 'stackit submit'. diff --git a/internal/demo/demo_git_runner.go b/internal/demo/demo_git_runner.go index f53b6dddd..f761febf2 100644 --- a/internal/demo/demo_git_runner.go +++ b/internal/demo/demo_git_runner.go @@ -764,19 +764,19 @@ func (d *demoGitRunner) GetStackMetaRefSHA(_ string) string { // NotesOperations methods -func (d *demoGitRunner) AddPromptNote(_ context.Context, _ string, _ *git.PromptNote) error { +func (d *demoGitRunner) AddPromptNote(_ context.Context, _, _ string, _ *git.PromptNote) error { return nil } -func (d *demoGitRunner) ShowPromptNote(_ context.Context, _ string) (*git.PromptNote, error) { +func (d *demoGitRunner) ShowPromptNote(_ context.Context, _, _ string) (*git.PromptNote, error) { return nil, nil } -func (d *demoGitRunner) RemovePromptNote(_ context.Context, _ string) error { +func (d *demoGitRunner) RemovePromptNote(_ context.Context, _, _ string) error { return nil } -func (d *demoGitRunner) LogWithNotes(_ context.Context, _, _ string) ([]git.NoteEntry, error) { +func (d *demoGitRunner) LogWithNotes(_ context.Context, _, _, _ string) ([]git.NoteEntry, error) { return nil, nil } diff --git a/internal/git/interfaces.go b/internal/git/interfaces.go index 33f575e53..8d4e42aa9 100644 --- a/internal/git/interfaces.go +++ b/internal/git/interfaces.go @@ -272,13 +272,14 @@ type MetadataOperations interface { MetadataCacheStats() MetadataCacheSummary } -// NotesOperations handles prompt note persistence via git notes. -// Notes are stored on individual commits using refs/notes/prompts. +// NotesOperations handles contextual note persistence via git notes. +// Notes are stored on individual commits using a single notes ref and +// namespaced payloads. type NotesOperations interface { - AddPromptNote(ctx context.Context, commit string, note *PromptNote) error - ShowPromptNote(ctx context.Context, commit string) (*PromptNote, error) - RemovePromptNote(ctx context.Context, commit string) error - LogWithNotes(ctx context.Context, base, head string) ([]NoteEntry, error) + AddPromptNote(ctx context.Context, commit, namespace string, note *PromptNote) error + ShowPromptNote(ctx context.Context, commit, namespace string) (*PromptNote, error) + RemovePromptNote(ctx context.Context, commit, namespace string) error + LogWithNotes(ctx context.Context, base, head, namespace string) ([]NoteEntry, error) PushNotes(ctx context.Context) error FetchNotes(ctx context.Context) error EnsureNotesRewriteConfigured() error diff --git a/internal/git/notes.go b/internal/git/notes.go index 37c4edcbe..dcb3199fd 100644 --- a/internal/git/notes.go +++ b/internal/git/notes.go @@ -3,6 +3,7 @@ package git import ( "context" "encoding/json" + "errors" "fmt" "slices" "strings" @@ -10,8 +11,9 @@ import ( ) const promptNotesRef = "refs/notes/prompts" +const DefaultNotesNamespace = "story" -// PromptNote stores LLM context on a commit via git notes. +// PromptNote stores contextual metadata on a commit via git notes. type PromptNote struct { Prompt string `json:"prompt"` Summary string `json:"summary"` @@ -20,83 +22,143 @@ type PromptNote struct { Memory []string `json:"memory,omitempty"` } -// NoteEntry pairs a commit SHA and message with its optional prompt note. +// noteEnvelope stores namespaced notes inside a single git note payload. +type noteEnvelope struct { + Namespaces map[string]PromptNote `json:"namespaces"` +} + +// NoteEntry pairs a commit SHA and message with its optional note. type NoteEntry struct { SHA string Subject string Note *PromptNote } -// AddPromptNote adds or replaces a prompt note on the given commit. -func (r *runner) AddPromptNote(ctx context.Context, commit string, note *PromptNote) error { +// AddPromptNote adds or replaces a note in the given namespace on the commit. +func (r *runner) AddPromptNote(ctx context.Context, commit, namespace string, note *PromptNote) error { + ns, err := normalizeNoteNamespace(namespace) + if err != nil { + return err + } + if note.Timestamp == "" { note.Timestamp = time.Now().UTC().Format(time.RFC3339) } - data, err := json.Marshal(note) + env, err := r.readNoteEnvelope(ctx, commit) + if err != nil { + return err + } + if env == nil { + env = ¬eEnvelope{Namespaces: map[string]PromptNote{}} + } + if env.Namespaces == nil { + env.Namespaces = map[string]PromptNote{} + } + env.Namespaces[ns] = *note + + data, err := json.Marshal(env) if err != nil { - return fmt.Errorf("failed to marshal prompt note: %w", err) + return fmt.Errorf("failed to marshal note envelope: %w", err) } // git notes --ref=refs/notes/prompts add -f -m '' _, err = r.RunGitCommandWithContext(ctx, "notes", "--ref="+promptNotesRef, "add", "-f", "-m", string(data), commit) if err != nil { - return fmt.Errorf("failed to add prompt note: %w", err) + return fmt.Errorf("failed to add note: %w", err) } return nil } -// ShowPromptNote reads the prompt note for the given commit. +// ShowPromptNote reads a note in the given namespace for the commit. // Returns nil if no note exists. -func (r *runner) ShowPromptNote(ctx context.Context, commit string) (*PromptNote, error) { - output, err := r.RunGitCommandRawWithContext(ctx, "notes", "--ref="+promptNotesRef, "show", commit) +func (r *runner) ShowPromptNote(ctx context.Context, commit, namespace string) (*PromptNote, error) { + ns, err := normalizeNoteNamespace(namespace) if err != nil { - // No note exists for this commit - git notes show exits non-zero - return nil, nil //nolint:nilerr // expected: git exits non-zero when no note exists + return nil, err } - output = strings.TrimSpace(output) - if output == "" { + env, err := r.readNoteEnvelope(ctx, commit) + if err != nil { + return nil, err + } + if env == nil || len(env.Namespaces) == 0 { return nil, nil } - var note PromptNote - if err := json.Unmarshal([]byte(output), ¬e); err != nil { - return nil, fmt.Errorf("failed to parse prompt note: %w", err) + note, ok := env.Namespaces[ns] + if !ok { + return nil, nil } return ¬e, nil } -// RemovePromptNote removes the prompt note from the given commit. -func (r *runner) RemovePromptNote(ctx context.Context, commit string) error { - _, err := r.RunGitCommandRawWithContext(ctx, "notes", "--ref="+promptNotesRef, "remove", commit) +// RemovePromptNote removes a note in the given namespace from the commit. +func (r *runner) RemovePromptNote(ctx context.Context, commit, namespace string) error { + ns, err := normalizeNoteNamespace(namespace) if err != nil { - return fmt.Errorf("failed to remove prompt note: %w", err) + return err + } + + env, err := r.readNoteEnvelope(ctx, commit) + if err != nil { + return err + } + if env == nil || len(env.Namespaces) == 0 { + return nil + } + + delete(env.Namespaces, ns) + if len(env.Namespaces) == 0 { + _, err := r.RunGitCommandRawWithContext(ctx, "notes", "--ref="+promptNotesRef, "remove", commit) + if err != nil && !isNoteMissingError(err) { + return fmt.Errorf("failed to remove note: %w", err) + } + return nil + } + + data, err := json.Marshal(env) + if err != nil { + return fmt.Errorf("failed to marshal note envelope: %w", err) + } + + _, err = r.RunGitCommandWithContext(ctx, "notes", "--ref="+promptNotesRef, "add", "-f", "-m", string(data), commit) + if err != nil { + return fmt.Errorf("failed to update note: %w", err) } return nil } -// LogWithNotes returns commits between base..head with their prompt notes. -func (r *runner) LogWithNotes(ctx context.Context, base, head string) ([]NoteEntry, error) { - // Get commit SHAs and subjects - shas, err := r.GetCommitRange(ctx, base, head, "%H %s") +// LogWithNotes returns commits between base..head with notes for the namespace. +func (r *runner) LogWithNotes(ctx context.Context, base, head, namespace string) ([]NoteEntry, error) { + ns, err := normalizeNoteNamespace(namespace) + if err != nil { + return nil, err + } + + shas, err := r.GetCommitRange(ctx, base, head, "%H") if err != nil { return nil, fmt.Errorf("failed to get commit range: %w", err) } entries := make([]NoteEntry, 0, len(shas)) - for _, line := range shas { - if line == "" { + for _, sha := range shas { + if sha == "" { continue } - sha, subject, _ := strings.Cut(line, " ") + subject, err := r.GetCommitLog(sha, "%s") + if err != nil { + return nil, fmt.Errorf("failed to get subject for %s: %w", sha, err) + } entry := NoteEntry{ SHA: sha, Subject: subject, } - // Try to read note for this commit - note, err := r.ShowPromptNote(ctx, sha) - if err == nil && note != nil { + note, err := r.ShowPromptNote(ctx, sha, ns) + if err != nil { + return nil, err + } + if note != nil { entry.Note = note } entries = append(entries, entry) @@ -105,26 +167,26 @@ func (r *runner) LogWithNotes(ctx context.Context, base, head string) ([]NoteEnt return entries, nil } -// PushNotes pushes prompt notes to the remote. +// PushNotes pushes notes to the remote. func (r *runner) PushNotes(ctx context.Context) error { _, err := r.RunGitCommandWithContext(ctx, "push", "origin", "+"+promptNotesRef) if err != nil { - return fmt.Errorf("failed to push prompt notes: %w", err) + return fmt.Errorf("failed to push notes: %w", err) } return nil } -// FetchNotes fetches prompt notes from the remote. +// FetchNotes fetches notes from the remote. func (r *runner) FetchNotes(ctx context.Context) error { _, err := r.RunGitCommandRawWithContext(ctx, "fetch", "origin", "+"+promptNotesRef+":"+promptNotesRef) if err != nil { - return fmt.Errorf("failed to fetch prompt notes: %w", err) + return fmt.Errorf("failed to fetch notes: %w", err) } return nil } // EnsureNotesRewriteConfigured configures notes.rewriteRef so git -// natively copies prompt notes during rebase. +// natively copies notes during rebase. func (r *runner) EnsureNotesRewriteConfigured() error { values, err := r.GetConfigAll("notes.rewriteRef") if err != nil { @@ -155,3 +217,68 @@ func (r *runner) EnsureNotesRefspecConfigured() error { return r.AddConfigValue("remote.origin.fetch", notesRefspec) } + +func normalizeNoteNamespace(namespace string) (string, error) { + ns := strings.TrimSpace(namespace) + if ns == "" { + return DefaultNotesNamespace, nil + } + if strings.Contains(ns, "/") { + return "", fmt.Errorf("invalid namespace %q: '/' is not allowed", ns) + } + return ns, nil +} + +func (r *runner) readNoteEnvelope(ctx context.Context, commit string) (*noteEnvelope, error) { + output, err := r.RunGitCommandRawWithContext(ctx, "notes", "--ref="+promptNotesRef, "show", commit) + if err != nil { + if isNoteMissingError(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read note: %w", err) + } + + output = strings.TrimSpace(output) + if output == "" { + return nil, nil + } + return decodeNoteEnvelope([]byte(output)) +} + +func decodeNoteEnvelope(data []byte) (*noteEnvelope, error) { + var probe map[string]json.RawMessage + if err := json.Unmarshal(data, &probe); err != nil { + return nil, fmt.Errorf("failed to parse note payload: %w", err) + } + + // Envelope format: {"namespaces":{"story":{...}}} + if _, ok := probe["namespaces"]; ok { + var env noteEnvelope + if err := json.Unmarshal(data, &env); err != nil { + return nil, fmt.Errorf("failed to parse note envelope: %w", err) + } + if env.Namespaces == nil { + env.Namespaces = map[string]PromptNote{} + } + return &env, nil + } + + // Backward compatibility for legacy single-note payload. + var legacy PromptNote + if err := json.Unmarshal(data, &legacy); err != nil { + return nil, fmt.Errorf("failed to parse legacy note payload: %w", err) + } + return ¬eEnvelope{ + Namespaces: map[string]PromptNote{ + DefaultNotesNamespace: legacy, + }, + }, nil +} + +func isNoteMissingError(err error) bool { + var cmdErr *CommandError + if !errors.As(err, &cmdErr) { + return false + } + return strings.Contains(cmdErr.Stderr, "no note found for object") +} diff --git a/internal/git/notes_test.go b/internal/git/notes_test.go new file mode 100644 index 000000000..d16d6fd4f --- /dev/null +++ b/internal/git/notes_test.go @@ -0,0 +1,137 @@ +package git_test + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getstackit/stackit/internal/git" + "github.com/getstackit/stackit/testhelpers" +) + +func TestNamespacedNotesPreserveOtherNamespaces(t *testing.T) { + scene := testhelpers.NewScene(t, func(s *testhelpers.Scene) error { + return s.Repo.CreateChangeAndCommit("initial", "init") + }) + + runner := git.NewRunnerWithPath(scene.Repo.Dir, nil) + require.NoError(t, runner.InitDefaultRepo()) + + ctx := context.Background() + sha, err := scene.Repo.GetCurrentSHA() + require.NoError(t, err) + + require.NoError(t, runner.AddPromptNote(ctx, sha, "story", &git.PromptNote{ + Prompt: "implemented feature x", + Summary: "added handlers and tests", + })) + require.NoError(t, runner.AddPromptNote(ctx, sha, "learnings", &git.PromptNote{ + Prompt: "be careful with force-push", + Summary: "metadata refs can race", + })) + + storyNote, err := runner.ShowPromptNote(ctx, sha, "story") + require.NoError(t, err) + require.NotNil(t, storyNote) + require.Equal(t, "implemented feature x", storyNote.Prompt) + require.NotEmpty(t, storyNote.Timestamp) + + learningNote, err := runner.ShowPromptNote(ctx, sha, "learnings") + require.NoError(t, err) + require.NotNil(t, learningNote) + require.Equal(t, "be careful with force-push", learningNote.Prompt) + require.NotEmpty(t, learningNote.Timestamp) + + missing, err := runner.ShowPromptNote(ctx, sha, "missing") + require.NoError(t, err) + require.Nil(t, missing) +} + +func TestNamespacedNotesReadsLegacyPayload(t *testing.T) { + scene := testhelpers.NewScene(t, func(s *testhelpers.Scene) error { + return s.Repo.CreateChangeAndCommit("initial", "init") + }) + + runner := git.NewRunnerWithPath(scene.Repo.Dir, nil) + require.NoError(t, runner.InitDefaultRepo()) + + sha, err := scene.Repo.GetCurrentSHA() + require.NoError(t, err) + + require.NoError(t, scene.Repo.RunGitCommand( + "notes", "--ref=refs/notes/prompts", "add", "-f", "-m", + `{"prompt":"legacy","summary":"legacy summary"}`, sha, + )) + + ctx := context.Background() + story, err := runner.ShowPromptNote(ctx, sha, git.DefaultNotesNamespace) + require.NoError(t, err) + require.NotNil(t, story) + require.Equal(t, "legacy", story.Prompt) + + require.NoError(t, runner.AddPromptNote(ctx, sha, "learnings", &git.PromptNote{ + Prompt: "new format", + Summary: "migrated on write", + })) + + learning, err := runner.ShowPromptNote(ctx, sha, "learnings") + require.NoError(t, err) + require.NotNil(t, learning) + require.Equal(t, "new format", learning.Prompt) + + rawPayload, err := scene.Repo.RunGitCommandAndGetOutput("notes", "--ref=refs/notes/prompts", "show", sha) + require.NoError(t, err) + require.Contains(t, rawPayload, `"namespaces"`) + require.Contains(t, rawPayload, `"story"`) + require.Contains(t, rawPayload, `"learnings"`) +} + +func TestShowPromptNoteReturnsErrorForInvalidCommit(t *testing.T) { + scene := testhelpers.NewScene(t, func(s *testhelpers.Scene) error { + return s.Repo.CreateChangeAndCommit("initial", "init") + }) + + runner := git.NewRunnerWithPath(scene.Repo.Dir, nil) + require.NoError(t, runner.InitDefaultRepo()) + + _, err := runner.ShowPromptNote(context.Background(), "not-a-real-commit", git.DefaultNotesNamespace) + require.Error(t, err) +} + +func TestLogWithNotesUsesSupportedCommitFormat(t *testing.T) { + scene := testhelpers.NewScene(t, func(s *testhelpers.Scene) error { + return s.Repo.CreateChangeAndCommit("initial", "init") + }) + + require.NoError(t, scene.Repo.CreateAndCheckoutBranch("feature")) + require.NoError(t, scene.Repo.CreateChangeAndCommit("feature 1", "f1")) + require.NoError(t, scene.Repo.CreateChangeAndCommit("feature 2", "f2")) + + recent, err := scene.Repo.RunGitCommandAndGetOutput("rev-list", "--max-count=2", "feature") + require.NoError(t, err) + commits := strings.Split(strings.TrimSpace(recent), "\n") + require.Len(t, commits, 2) + firstFeatureCommit := commits[1] + + runner := git.NewRunnerWithPath(scene.Repo.Dir, nil) + require.NoError(t, runner.InitDefaultRepo()) + require.NoError(t, runner.AddPromptNote(context.Background(), firstFeatureCommit, "story", &git.PromptNote{ + Prompt: "feature narrative", + Summary: "first feature commit rationale", + })) + + entries, err := runner.LogWithNotes(context.Background(), "main", "feature", "story") + require.NoError(t, err) + require.Len(t, entries, 2) + + hasStoryNote := false + for _, entry := range entries { + if entry.Note != nil && entry.Note.Prompt == "feature narrative" { + hasStoryNote = true + break + } + } + require.True(t, hasStoryNote, "expected at least one story note in log entries") +} diff --git a/internal/git/tracing_runner.go b/internal/git/tracing_runner.go index 9510c655d..c37adb1fb 100644 --- a/internal/git/tracing_runner.go +++ b/internal/git/tracing_runner.go @@ -1184,31 +1184,31 @@ func (t *tracingRunner) GetLocalMetadataRefSHA(branchName string) string { // NotesOperations methods -func (t *tracingRunner) AddPromptNote(ctx context.Context, commit string, note *PromptNote) error { +func (t *tracingRunner) AddPromptNote(ctx context.Context, commit, namespace string, note *PromptNote) error { start := time.Now() - err := t.inner.AddPromptNote(ctx, commit, note) - t.trace("AddPromptNote", time.Since(start), err == nil, err, slog.String("commit", commit)) + err := t.inner.AddPromptNote(ctx, commit, namespace, note) + t.trace("AddPromptNote", time.Since(start), err == nil, err, slog.String("commit", commit), slog.String("namespace", namespace)) return err } -func (t *tracingRunner) ShowPromptNote(ctx context.Context, commit string) (*PromptNote, error) { +func (t *tracingRunner) ShowPromptNote(ctx context.Context, commit, namespace string) (*PromptNote, error) { start := time.Now() - result, err := t.inner.ShowPromptNote(ctx, commit) - t.trace("ShowPromptNote", time.Since(start), err == nil, err, slog.String("commit", commit)) + result, err := t.inner.ShowPromptNote(ctx, commit, namespace) + t.trace("ShowPromptNote", time.Since(start), err == nil, err, slog.String("commit", commit), slog.String("namespace", namespace)) return result, err } -func (t *tracingRunner) RemovePromptNote(ctx context.Context, commit string) error { +func (t *tracingRunner) RemovePromptNote(ctx context.Context, commit, namespace string) error { start := time.Now() - err := t.inner.RemovePromptNote(ctx, commit) - t.trace("RemovePromptNote", time.Since(start), err == nil, err, slog.String("commit", commit)) + err := t.inner.RemovePromptNote(ctx, commit, namespace) + t.trace("RemovePromptNote", time.Since(start), err == nil, err, slog.String("commit", commit), slog.String("namespace", namespace)) return err } -func (t *tracingRunner) LogWithNotes(ctx context.Context, base, head string) ([]NoteEntry, error) { +func (t *tracingRunner) LogWithNotes(ctx context.Context, base, head, namespace string) ([]NoteEntry, error) { start := time.Now() - result, err := t.inner.LogWithNotes(ctx, base, head) - t.trace("LogWithNotes", time.Since(start), err == nil, err, slog.String("base", base), slog.String("head", head)) + result, err := t.inner.LogWithNotes(ctx, base, head, namespace) + t.trace("LogWithNotes", time.Since(start), err == nil, err, slog.String("base", base), slog.String("head", head), slog.String("namespace", namespace)) return result, err }

AltStyle によって変換されたページ (->オリジナル) /