From 594265be46350a194d4057cb4a310736342fd03b Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Thu, 14 May 2026 09:33:17 -0400 Subject: [PATCH 1/3] branch comment: add CLI commands for managing review comments Add `gs branch comment` subcommand group with six commands: - list: show inline comments and staged comments, with `--json` for NDJSON output - stage: stage an inline comment for batch submission - add: post an inline comment immediately - submit-staged: submit all staged comments as a review - resolve: resolve or unresolve a review thread - edit: edit a staged or forge comment Also adds DiffBranchBytes to git.Worktree for programmatic diff access needed by comment coordinate mapping. --- .../unreleased/Added-20260314-155204.yaml | 3 + .../unreleased/Added-20260502-102600.yaml | 3 + branch.go | 3 + branch_comment.go | 10 + branch_comment_add.go | 153 ++++++++ branch_comment_edit.go | 220 +++++++++++ branch_comment_list.go | 361 ++++++++++++++++++ branch_comment_resolve.go | 92 +++++ branch_comment_stage.go | 177 +++++++++ branch_comment_submit_staged.go | 169 ++++++++ doc/includes/cli-reference.md | 330 ++++++++-------- doc/includes/cli-shorthands.md | 5 +- internal/git/diff_wt.go | 15 + testdata/help/branch_comment_add.txt | 27 ++ testdata/help/branch_comment_edit.txt | 29 ++ testdata/help/branch_comment_list.txt | 28 ++ testdata/help/branch_comment_resolve.txt | 24 ++ testdata/help/branch_comment_stage.txt | 29 ++ .../help/branch_comment_submit-staged.txt | 25 ++ testdata/help/gs.txt | 41 +- 20 files changed, 1565 insertions(+), 179 deletions(-) create mode 100644 .changes/unreleased/Added-20260314-155204.yaml create mode 100644 .changes/unreleased/Added-20260502-102600.yaml create mode 100644 branch_comment.go create mode 100644 branch_comment_add.go create mode 100644 branch_comment_edit.go create mode 100644 branch_comment_list.go create mode 100644 branch_comment_resolve.go create mode 100644 branch_comment_stage.go create mode 100644 branch_comment_submit_staged.go create mode 100644 testdata/help/branch_comment_add.txt create mode 100644 testdata/help/branch_comment_edit.txt create mode 100644 testdata/help/branch_comment_list.txt create mode 100644 testdata/help/branch_comment_resolve.txt create mode 100644 testdata/help/branch_comment_stage.txt create mode 100644 testdata/help/branch_comment_submit-staged.txt diff --git a/.changes/unreleased/Added-20260314-155204.yaml b/.changes/unreleased/Added-20260314-155204.yaml new file mode 100644 index 000000000..ffd94f42f --- /dev/null +++ b/.changes/unreleased/Added-20260314-155204.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'branch comment: Add commands for managing change request review comments from the CLI' +time: 2026-03-14T15:52:04.220487-04:00 diff --git a/.changes/unreleased/Added-20260502-102600.yaml b/.changes/unreleased/Added-20260502-102600.yaml new file mode 100644 index 000000000..af000b4cb --- /dev/null +++ b/.changes/unreleased/Added-20260502-102600.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'branch comment list: Add --json flag to write comments as a stream of JSON objects' +time: 2026-05-02T10:26:00.668941-04:00 diff --git a/branch.go b/branch.go index 160587832..e359605ba 100644 --- a/branch.go +++ b/branch.go @@ -38,6 +38,9 @@ type branchCmd struct { // Pull request management Merge branchMergeCmd `cmd:"" aliases:"m" experiment:"merge" help:"Merge a branch into trunk"` Submit branchSubmitCmd `cmd:"" aliases:"s" help:"Submit a branch"` + + // Comment management + Comment branchCommentCmd `cmd:"" aliases:"cmt" help:"Manage change request comments"` } // BranchPromptConfig defines configuration for the branch tree prompt diff --git a/branch_comment.go b/branch_comment.go new file mode 100644 index 000000000..a1ec02b5d --- /dev/null +++ b/branch_comment.go @@ -0,0 +1,10 @@ +package main + +type branchCommentCmd struct { + List branchCommentListCmd `cmd:"" aliases:"ls" help:"List comments on a change request"` + Stage branchCommentStageCmd `cmd:"" help:"Stage an inline comment for batch submission"` + Add branchCommentAddCmd `cmd:"" help:"Post an inline comment immediately"` + SubmitStaged branchCommentSubmitStagedCmd `cmd:"" aliases:"ss" help:"Submit all staged comments as a review"` + Resolve branchCommentResolveCmd `cmd:"" help:"Resolve or unresolve a review thread"` + Edit branchCommentEditCmd `cmd:"" help:"Edit a comment"` +} diff --git a/branch_comment_add.go b/branch_comment_add.go new file mode 100644 index 000000000..e53c32bbf --- /dev/null +++ b/branch_comment_add.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.abhg.dev/gs/internal/diffmap" + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchCommentAddCmd struct { + FileAndLine string `arg:"" optional:"" help:"File and line in the form file.go:42."` + Message string `short:"m" placeholder:"MSG" help:"Comment body. Opens editor if not provided."` + Respond string `placeholder:"THREAD_ID" help:"Thread ID to reply to instead of starting a new thread."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to add comment for. Defaults to current branch."` +} + +func (*branchCommentAddCmd) Help() string { + return text.Dedent(` + Posts an inline comment immediately + on the change request for the current branch. + Provide the file and line number as file.go:42. + + If no message is given with -m, an editor is opened. + + Use --respond to reply to an existing thread + instead of starting a new one. + `) +} + +func (cmd *branchCommentAddCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + repo *git.Repository, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + var file string + var line int + if cmd.Respond == "" { + if cmd.FileAndLine == "" { + return errors.New( + "file:line argument is required " + + "unless --respond is used", + ) + } + var err error + file, line, err = parseFileAndLine(cmd.FileAndLine) + if err != nil { + return err + } + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, "" /* initial */) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + b, err := svc.LookupBranch(ctx, branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf( + "branch not tracked: %s", branch, + ) + } + return fmt.Errorf("get branch: %w", err) + } + + if b.Change == nil { + return fmt.Errorf( + "no change request for %s; "+ + "submit the branch first with "+ + "'gs branch submit'", + branch, + ) + } + + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + return errors.New( + "forge does not support inline comments", + ) + } + + req := forge.InlineCommentRequest{ + Body: body, + ThreadID: cmd.Respond, + } + + // Map file:line to diff coordinates + // if this is a new comment (not a reply). + if cmd.Respond == "" { + diff, err := wt.DiffBranchBytes(ctx, b.Base, branch) + if err != nil { + return fmt.Errorf("get diff: %w", err) + } + + mapper, err := diffmap.New(diff) + if err != nil { + return fmt.Errorf("parse diff: %w", err) + } + + path, diffLine, side, err := mapper.Map(file, line) + if err != nil { + return fmt.Errorf( + "map %s:%d to diff: %w", + file, line, err, + ) + } + + req.Path = path + req.Line = diffLine + req.Side = side + } + + posted, err := inline.PostInlineComment( + ctx, b.Change.ChangeID(), req, + ) + if err != nil { + return fmt.Errorf("post inline comment: %w", err) + } + + log.Infof( + "Posted comment %s on %s.", + posted.ID, b.Change.ChangeID(), + ) + return nil +} diff --git a/branch_comment_edit.go b/branch_comment_edit.go new file mode 100644 index 000000000..be7d9fa7d --- /dev/null +++ b/branch_comment_edit.go @@ -0,0 +1,220 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchCommentEditCmd struct { + ID string `arg:"" help:"Comment ID to edit. Use 'sc-N' for staged comments or a forge comment ID."` + Message string `short:"m" placeholder:"MSG" help:"New comment body. Opens editor if not provided."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch whose comments to edit. Defaults to current branch."` +} + +func (*branchCommentEditCmd) Help() string { + return text.Dedent(` + Edits the body of a comment. + + For staged comments (sc-N prefix), + the comment is updated in the local staging area. + + For forge comments, the comment is updated + on the remote forge. + + If no message is given with -m, an editor is opened + with the current comment body pre-filled. + `) +} + +func (cmd *branchCommentEditCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + store *state.Store, + repo *git.Repository, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + // Handle staged comment edits. + if scID, ok := parseStagedCommentID(cmd.ID); ok { + return cmd.editStaged( + ctx, log, store, repo, branch, scID, + ) + } + + // Handle forge comment edits. + return cmd.editForge( + ctx, log, wt, svc, repo, forgeRepo, branch, + ) +} + +func (cmd *branchCommentEditCmd) editStaged( + ctx context.Context, + log *silog.Logger, + store *state.Store, + repo *git.Repository, + branch string, + scID int, +) error { + staged, err := store.LoadStagedComments(ctx, branch) + if err != nil { + return fmt.Errorf("load staged comments: %w", err) + } + if staged == nil { + staged = &state.StagedComments{} + } + + idx := -1 + for i, c := range staged.Comments { + if c.ID == scID { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf( + "staged comment sc-%d not found", scID, + ) + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, staged.Comments[idx].Body, + ) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + staged.Comments[idx].Body = body + if err := store.SaveStagedComments( + ctx, branch, staged, + ); err != nil { + return fmt.Errorf("save staged comments: %w", err) + } + + log.Infof("Updated staged comment sc-%d.", scID) + return nil +} + +func (cmd *branchCommentEditCmd) editForge( + ctx context.Context, + log *silog.Logger, + _ *git.Worktree, + svc *spice.Service, + repo *git.Repository, + forgeRepo forge.Repository, + branch string, +) error { + b, err := svc.LookupBranch(ctx, branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf( + "branch not tracked: %s", branch, + ) + } + return fmt.Errorf("get branch: %w", err) + } + + if b.Change == nil { + return fmt.Errorf( + "no change request for %s", branch, + ) + } + + editor, ok := forgeRepo.(forge.WithCommentEdit) + if !ok { + return errors.New( + "forge does not support comment editing", + ) + } + + // Find the comment by ID to get its current body. + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + return errors.New( + "forge does not support inline comments", + ) + } + + comments, err := inline.ListInlineComments( + ctx, b.Change.ChangeID(), + ) + if err != nil { + return fmt.Errorf("list comments: %w", err) + } + + var target *forge.InlineComment + for _, c := range comments { + if c.ID.String() == cmd.ID { + target = c + break + } + } + if target == nil { + return fmt.Errorf( + "comment %s not found", cmd.ID, + ) + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, target.Body, + ) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + if err := editor.EditComment( + ctx, target.ID, body, + ); err != nil { + return fmt.Errorf("edit comment: %w", err) + } + + log.Infof("Updated comment %s.", cmd.ID) + return nil +} + +// parseStagedCommentID parses "sc-N" into integer N. +// Returns (N, true) on success, (0, false) otherwise. +func parseStagedCommentID(s string) (int, bool) { + after, found := strings.CutPrefix(s, "sc-") + if !found { + return 0, false + } + id, err := strconv.Atoi(after) + if err != nil { + return 0, false + } + return id, true +} diff --git a/branch_comment_list.go b/branch_comment_list.go new file mode 100644 index 000000000..c0ef9f57b --- /dev/null +++ b/branch_comment_list.go @@ -0,0 +1,361 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/alecthomas/kong" + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchCommentListCmd struct { + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to list comments for. Defaults to current branch."` + Staged bool `help:"Show only staged comments."` + Unresolved bool `help:"Show only unresolved comments."` + JSON bool `name:"json" released:"unreleased" help:"Write to stdout as a stream of JSON objects."` +} + +func (*branchCommentListCmd) Help() string { + return text.Dedent(` + Lists comments on the change request + associated with the current branch. + Use --branch to target a different branch. + + Staged comments that have not yet been submitted + are shown with an 'sc-N' prefix. + + Use --staged to show only staged comments. + Use --unresolved to show only unresolved comments. + + With --json, prints output to stdout + as a stream of JSON objects. + `) +} + +func (cmd *branchCommentListCmd) Run( + ctx context.Context, + kctx *kong.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + store *state.Store, + forgeRepo forge.Repository, +) error { + branch, err := cmd.resolveBranch(ctx, wt) + if err != nil { + return err + } + + staged, forgeComments, err := cmd.loadComments( + ctx, log, svc, store, forgeRepo, branch, + ) + if err != nil { + return err + } + + if cmd.JSON { + return cmd.writeJSON( + kctx.Stdout, staged, forgeComments, + ) + } + return cmd.writeText(log, branch, staged, forgeComments) +} + +func (cmd *branchCommentListCmd) resolveBranch( + ctx context.Context, wt *git.Worktree, +) (string, error) { + if cmd.Branch != "" { + return cmd.Branch, nil + } + branch, err := wt.CurrentBranch(ctx) + if err != nil { + return "", fmt.Errorf("get current branch: %w", err) + } + return branch, nil +} + +func (cmd *branchCommentListCmd) loadComments( + ctx context.Context, + log *silog.Logger, + svc *spice.Service, + store *state.Store, + forgeRepo forge.Repository, + branch string, +) ([]*state.StagedComment, []*forge.InlineComment, error) { + staged, err := loadStagedComments(ctx, store, branch) + if err != nil { + return nil, nil, err + } + + if cmd.Staged { + return staged, nil, nil + } + + forgeComments, err := loadForgeComments( + ctx, log, svc, forgeRepo, branch, + ) + if err != nil { + return nil, nil, err + } + + return staged, cmd.filterForge(forgeComments), nil +} + +func loadStagedComments( + ctx context.Context, + store *state.Store, + branch string, +) ([]*state.StagedComment, error) { + staged, err := store.LoadStagedComments(ctx, branch) + if err != nil { + return nil, fmt.Errorf( + "load staged comments: %w", err, + ) + } + if staged == nil { + return nil, nil + } + + refs := make( + []*state.StagedComment, len(staged.Comments), + ) + for i := range staged.Comments { + refs[i] = &staged.Comments[i] + } + return refs, nil +} + +func loadForgeComments( + ctx context.Context, + log *silog.Logger, + svc *spice.Service, + forgeRepo forge.Repository, + branch string, +) ([]*forge.InlineComment, error) { + b, err := svc.LookupBranch(ctx, branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return nil, fmt.Errorf( + "branch not tracked: %s", branch, + ) + } + return nil, fmt.Errorf("get branch: %w", err) + } + + if b.Change == nil { + log.Infof( + "No change request found for %s.", branch, + ) + return nil, nil + } + + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + log.Infof( + "Forge does not support inline comments.", + ) + return nil, nil + } + + comments, err := inline.ListInlineComments( + ctx, b.Change.ChangeID(), + ) + if err != nil { + return nil, fmt.Errorf( + "list inline comments: %w", err, + ) + } + return comments, nil +} + +func (cmd *branchCommentListCmd) filterForge( + comments []*forge.InlineComment, +) []*forge.InlineComment { + if !cmd.Unresolved { + return comments + } + + var filtered []*forge.InlineComment + for _, c := range comments { + if !c.Resolved { + filtered = append(filtered, c) + } + } + return filtered +} + +// writeText prints comments in human-readable format. +func (cmd *branchCommentListCmd) writeText( + log *silog.Logger, + branch string, + staged []*state.StagedComment, + forgeComments []*forge.InlineComment, +) error { + if len(staged) > 0 { + log.Infof("Staged comments:") + for _, c := range staged { + writeStagedText(log, c) + } + } + + if cmd.Staged && len(staged) == 0 { + log.Infof("No staged comments for %s.", branch) + return nil + } + + if len(forgeComments) > 0 { + log.Infof("Comments:") + for _, c := range forgeComments { + writeForgeText(log, c) + } + } + + if len(forgeComments) == 0 && len(staged) == 0 { + log.Infof("No comments on %s.", branch) + } + return nil +} + +func writeStagedText( + log *silog.Logger, c *state.StagedComment, +) { + location := fmt.Sprintf("%s:%d", c.File, c.Line) + if c.ThreadID != "" { + location = "reply:" + c.ThreadID + } + log.Infof(" sc-%-4d %s", c.ID, location) + writeBodyIndented(log, c.Body) +} + +func writeForgeText( + log *silog.Logger, c *forge.InlineComment, +) { + location := fmt.Sprintf("%s:%d", c.Path, c.Line) + threadInfo := "" + if c.ThreadID != "" { + threadInfo = " [" + c.ThreadID + "]" + } + log.Infof( + " %-12s %s %s %s%s", + c.ID, location, c.Author, + commentStatus(c), threadInfo, + ) + writeBodyIndented(log, c.Body) +} + +func writeBodyIndented(log *silog.Logger, body string) { + for line := range strings.SplitSeq(body, "\n") { + log.Infof(" %s", line) + } +} + +func commentStatus(c *forge.InlineComment) string { + if c.Outdated { + return "outdated" + } + if c.Resolved { + return "resolved" + } + return "open" +} + +// writeJSON encodes comments as NDJSON to stdout. +func (cmd *branchCommentListCmd) writeJSON( + w io.Writer, + staged []*state.StagedComment, + forgeComments []*forge.InlineComment, +) (retErr error) { + bufw := bufio.NewWriter(w) + defer func() { + retErr = errors.Join(retErr, bufw.Flush()) + }() + + enc := json.NewEncoder(bufw) + for _, c := range staged { + if err := enc.Encode(stagedToJSON(c)); err != nil { + return fmt.Errorf("encode staged: %w", err) + } + } + for _, c := range forgeComments { + if err := enc.Encode(forgeToJSON(c)); err != nil { + return fmt.Errorf("encode forge: %w", err) + } + } + return nil +} + +func stagedToJSON(c *state.StagedComment) jsonComment { + return jsonComment{ + Kind: "staged", + ID: fmt.Sprintf("sc-%d", c.ID), + Path: c.File, + Line: c.Line, + Body: c.Body, + ThreadID: c.ThreadID, + } +} + +func forgeToJSON(c *forge.InlineComment) jsonComment { + var createdAt *time.Time + if !c.CreatedAt.IsZero() { + createdAt = &c.CreatedAt + } + return jsonComment{ + Kind: "forge", + ID: c.ID.String(), + Path: c.Path, + Line: c.Line, + Body: c.Body, + ThreadID: c.ThreadID, + Author: c.Author, + Status: commentStatus(c), + CreatedAt: createdAt, + } +} + +// jsonComment is the JSON representation +// of a comment for --json output. +type jsonComment struct { + // Kind is "staged" or "forge". + Kind string `json:"kind"` + + // ID is the comment identifier. + // For staged comments: "sc-N". + // For forge comments: forge-specific ID. + ID string `json:"id"` + + // Path is the file path relative to the repo root. + Path string `json:"path,omitempty"` + + // Line is the line number in the file. + Line int `json:"line,omitempty"` + + // Body is the full markdown body of the comment. + Body string `json:"body"` + + // ThreadID is the thread identifier, if any. + ThreadID string `json:"threadID,omitempty"` + + // Author is the username of the comment author. + // Only set for forge comments. + Author string `json:"author,omitempty"` + + // Status is "open", "resolved", or "outdated". + // Only set for forge comments. + Status string `json:"status,omitempty"` + + // CreatedAt is the time the comment was created. + // Only set for forge comments. + CreatedAt *time.Time `json:"createdAt,omitempty"` +} diff --git a/branch_comment_resolve.go b/branch_comment_resolve.go new file mode 100644 index 000000000..60bf544c3 --- /dev/null +++ b/branch_comment_resolve.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchCommentResolveCmd struct { + ThreadID string `arg:"" help:"Thread ID to resolve."` + Unresolve bool `help:"Unresolve the thread instead of resolving it."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch whose change request contains the thread. Defaults to current branch."` +} + +func (*branchCommentResolveCmd) Help() string { + return text.Dedent(` + Resolves a review thread on the change request + for the current branch. + + Use --unresolve to mark the thread as unresolved. + + The thread ID is shown in 'gs branch comment list'. + `) +} + +func (cmd *branchCommentResolveCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + // Verify branch has a change request. + b, err := svc.LookupBranch(ctx, branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf( + "branch not tracked: %s", branch, + ) + } + return fmt.Errorf("get branch: %w", err) + } + + if b.Change == nil { + return fmt.Errorf( + "no change request for %s", branch, + ) + } + + resolver, ok := forgeRepo.(forge.WithThreadResolution) + if !ok { + return errors.New( + "forge does not support thread resolution", + ) + } + + if cmd.Unresolve { + if err := resolver.UnresolveThread( + ctx, cmd.ThreadID, + ); err != nil { + return fmt.Errorf( + "unresolve thread: %w", err, + ) + } + log.Infof("Unresolved thread %s.", cmd.ThreadID) + } else { + if err := resolver.ResolveThread( + ctx, cmd.ThreadID, + ); err != nil { + return fmt.Errorf("resolve thread: %w", err) + } + log.Infof("Resolved thread %s.", cmd.ThreadID) + } + + return nil +} diff --git a/branch_comment_stage.go b/branch_comment_stage.go new file mode 100644 index 000000000..b5eb87eec --- /dev/null +++ b/branch_comment_stage.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" + "go.abhg.dev/gs/internal/xec" +) + +type branchCommentStageCmd struct { + FileAndLine string `arg:"" optional:"" help:"File and line in the form file.go:42."` + Message string `short:"m" placeholder:"MSG" help:"Comment body. Opens editor if not provided."` + Respond string `placeholder:"THREAD_ID" help:"Thread ID to reply to instead of starting a new thread."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to stage comment for. Defaults to current branch."` +} + +func (*branchCommentStageCmd) Help() string { + return text.Dedent(` + Stages an inline comment for later batch submission. + Provide the file and line number as file.go:42. + + If no message is given with -m, an editor is opened. + + Use --respond to reply to an existing thread + instead of starting a new one. + + Staged comments are submitted together with + 'gs branch comment submit-staged'. + `) +} + +func (cmd *branchCommentStageCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + store *state.Store, + repo *git.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + var file string + var line int + if cmd.Respond == "" { + if cmd.FileAndLine == "" { + return errors.New( + "file:line argument is required " + + "unless --respond is used", + ) + } + var err error + file, line, err = parseFileAndLine(cmd.FileAndLine) + if err != nil { + return err + } + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, "" /* initial */) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + staged, err := store.LoadStagedComments(ctx, branch) + if err != nil { + return fmt.Errorf("load staged comments: %w", err) + } + if staged == nil { + staged = &state.StagedComments{NextID: 1} + } + + comment := state.StagedComment{ + ID: staged.NextID, + File: file, + Line: line, + Body: body, + ThreadID: cmd.Respond, + } + staged.Comments = append(staged.Comments, comment) + staged.NextID++ + + if err := store.SaveStagedComments( + ctx, branch, staged, + ); err != nil { + return fmt.Errorf("save staged comments: %w", err) + } + + if cmd.Respond != "" { + log.Infof( + "Staged reply sc-%d to thread %s.", + comment.ID, cmd.Respond, + ) + } else { + log.Infof( + "Staged comment sc-%d on %s:%d.", + comment.ID, file, line, + ) + } + return nil +} + +// parseFileAndLine parses a "file.go:42" argument +// into file and line components. +func parseFileAndLine(s string) (string, int, error) { + idx := strings.LastIndex(s, ":") + if idx < 0 { + return "", 0, fmt.Errorf( + "expected file:line format, got %q", s, + ) + } + file := s[:idx] + line, err := strconv.Atoi(s[idx+1:]) + if err != nil { + return "", 0, fmt.Errorf( + "invalid line number in %q: %w", s, err, + ) + } + if line <= 0 { + return "", 0, fmt.Errorf( + "line number must be positive, got %d", line, + ) + } + return file, line, nil +} + +// editCommentBody opens an editor for the user +// to write a comment body. +// initial is pre-filled text (may be empty). +func editCommentBody( + ctx context.Context, + repo *git.Repository, + initial string, +) (string, error) { + tmpFile := filepath.Join( + os.TempDir(), "GS_COMMENT_EDITMSG", + ) + if err := os.WriteFile( + tmpFile, []byte(initial), 0o644, + ); err != nil { + return "", fmt.Errorf("write temp file: %w", err) + } + defer func() { _ = os.Remove(tmpFile) }() + + editor := gitEditor(ctx, repo) + cmd := xec.EditCommand(editor, tmpFile) + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("run editor: %w", err) + } + + content, err := os.ReadFile(tmpFile) + if err != nil { + return "", fmt.Errorf("read temp file: %w", err) + } + return string(content), nil +} diff --git a/branch_comment_submit_staged.go b/branch_comment_submit_staged.go new file mode 100644 index 000000000..4194c22ce --- /dev/null +++ b/branch_comment_submit_staged.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/diffmap" + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchCommentSubmitStagedCmd struct { + Body string `placeholder:"BODY" help:"Overall review body."` + Approve bool `help:"Mark the review as approved."` + RequestChanges bool `name:"request-changes" help:"Mark the review as requesting changes."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to submit staged comments for. Defaults to current branch."` +} + +func (*branchCommentSubmitStagedCmd) Help() string { + return text.Dedent(` + Submits all staged comments for the current branch + as a single review on the change request. + + Use --approve or --request-changes + to set the review event type. + Defaults to a comment-only review. + + Use --body to add an overall review body. + `) +} + +func (cmd *branchCommentSubmitStagedCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + store *state.Store, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + staged, err := store.LoadStagedComments(ctx, branch) + if err != nil { + return fmt.Errorf("load staged comments: %w", err) + } + if staged == nil { + staged = &state.StagedComments{} + } + + if len(staged.Comments) == 0 { + log.Infof("No staged comments to submit.") + return nil + } + + b, err := svc.LookupBranch(ctx, branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf( + "branch not tracked: %s", branch, + ) + } + return fmt.Errorf("get branch: %w", err) + } + + if b.Change == nil { + return fmt.Errorf( + "no change request for %s; "+ + "submit the branch first with "+ + "'gs branch submit'", + branch, + ) + } + + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + return errors.New( + "forge does not support inline comments", + ) + } + + // Build diff map for coordinate translation. + diff, err := wt.DiffBranchBytes(ctx, b.Base, branch) + if err != nil { + return fmt.Errorf("get diff: %w", err) + } + + mapper, err := diffmap.New(diff) + if err != nil { + return fmt.Errorf("parse diff: %w", err) + } + + // Map staged comments to forge requests. + var comments []forge.InlineCommentRequest + for _, sc := range staged.Comments { + if sc.ThreadID != "" { + // Thread reply: no coordinate mapping needed. + comments = append(comments, + forge.InlineCommentRequest{ + Body: sc.Body, + ThreadID: sc.ThreadID, + }, + ) + continue + } + + path, diffLine, side, err := mapper.Map( + sc.File, sc.Line, + ) + if err != nil { + return fmt.Errorf( + "sc-%d: map %s:%d to diff: %w", + sc.ID, sc.File, sc.Line, err, + ) + } + + comments = append(comments, + forge.InlineCommentRequest{ + Path: path, + Line: diffLine, + Body: sc.Body, + Side: side, + }, + ) + } + + event := forge.ReviewComment + if cmd.Approve { + event = forge.ReviewApprove + } else if cmd.RequestChanges { + event = forge.ReviewRequestChanges + } + + if err := inline.SubmitReview( + ctx, + b.Change.ChangeID(), + forge.ReviewRequest{ + Body: cmd.Body, + Comments: comments, + Event: event, + }, + ); err != nil { + return fmt.Errorf("submit review: %w", err) + } + + if err := store.ClearStagedComments( + ctx, branch, + ); err != nil { + return fmt.Errorf("clear staged comments: %w", err) + } + + log.Infof( + "Submitted %d comment(s) as review on %s.", + len(comments), + b.Change.ChangeID(), + ) + return nil +} diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 507c9d1c6..ed11008e8 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -12,7 +12,7 @@ git-spice is a command line tool for stacking Git branches. * `-C`, `--dir=DIR`: Change to DIR before doing anything * `--[no-]prompt`: Whether to prompt for missing information -**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.forge.kind](/cli/config.md#spiceforgekind), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) +**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) ## Shell @@ -156,17 +156,11 @@ The repository must have a remote associated for syncing. A prompt will ask for one if the repository was not initialized with a remote. -Branches above merged and deleted branches -are retargeted to the trunk branch. -Run with --restack to also restack them and their upstacks. -Run with --restack=aboves to only restack direct upstacks -of deleted branches, leaving higher branches in place. - **Flags** -* `--restack` ([:material-wrench:{ .middle title="spice.repoSync.restack" }](/cli/config.md#spicereposyncrestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. +* `--restack`: Restack the current stack after syncing -**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges), [spice.repoSync.restack](/cli/config.md#spicereposyncrestack) +**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges) ### git-spice repo restack {#gs-repo-restack} @@ -284,51 +278,7 @@ only if there are multiple CRs in the stack. * `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. :material-tag:{ title="Released in version" }[v0.21.0](/changelog.md#v0.21.0) * `--no-web`: Alias for --web=false. -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) - -### git-spice stack merge {#gs-stack-merge} - -``` -gs stack (s) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a stack - -Merges the CRs for the current branch's stack into trunk. -Use --branch to merge a different branch's stack. - -The stack includes the selected branch, -its downstack branches down to trunk, -and every upstack branch. - -Already-merged branches are skipped automatically. -Branches must have an open Change Request to be merged. - -Before merging, the stack is checked for branches -whose base PR was already merged on the forge. -Use --no-branch-check to skip this validation. - -Before each merge, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait -before failing if merge readiness is not reached. - -By default, a branch failure skips that branch's upstack descendants, -but independent sibling branches continue. -Use --fail-fast to stop the queue after the first branch failure. - -**Flags** - -* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. -* `--no-branch-check`: Skip stale base validation before merging. -* `--fail-fast`: Stop the merge queue after the first branch failure. -* `--branch=NAME`: Branch whose stack to merge - -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice stack restack {#gs-stack-restack} @@ -451,7 +401,7 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice upstack restack {#gs-upstack-restack} @@ -606,68 +556,7 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) - -### git-spice downstack merge {#gs-downstack-merge} - -``` -gs downstack (ds) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a branch and those below it - -Merges the current branch and all branches below it -into trunk via the forge API, bottom-up. -Use --branch to start at a different branch. - -This command acts as a local merge queue: -it merges one Change Request, -waits for that merge to finish, -restacks and updates the next Change Request, -waits for merge readiness on the updated Change Request, -and then repeats the process. - -For a stack like this: - - main <- feature1 <- feature2 <- feature3 - -Running from feature3 merges in this order: - - feature1, feature2, feature3 - -Already-merged branches are skipped automatically. -Branches must have an open Change Request to be merged. - -Before merging, the downstack is checked for branches -whose base PR was already merged on the forge. -Use --no-branch-check to skip this validation. - -Before each merge, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait -(default: 30m, 0 means fail immediately if not ready). - -Between merges, the command waits for each merge -to complete, restacks and updates the next PR, -waits for merge readiness on the updated PR, -and syncs merged branch cleanup. - -Use --no-wait for single branch merging -when you don't want to wait for the merge to propagate. ---no-wait is rejected for multi-branch merges. - -**Flags** - -* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. -* `--no-wait`: Skip polling for a single branch merge to propagate. -* `--no-branch-check`: Skip stale base validation before merging. -* `--branch=NAME`: Branch to start merging from - -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice downstack edit {#gs-downstack-edit} @@ -875,13 +764,10 @@ gs branch (b) delete (d,rm) [ ...] [flags] Delete branches The deleted branches and their commits are removed from the stack. -Branches above the deleted branches are retargeted onto +Branches above the deleted branches are first rebased onto the next branches available downstack, or onto trunk if there are no branches available below. -Use --restack to rebase those branches and their upstacks -immediately after retargeting. - Without any arguments, a prompt will allow selecting the branch to delete. @@ -896,9 +782,8 @@ Use --force to delete the branch regardless of unmerged changes. **Flags** * `--force`: Force deletion of the branch -* `--restack` ([:material-wrench:{ .middle title="spice.branchDelete.restack" }](/cli/config.md#spicebranchdeleterestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchDelete.restack](/cli/config.md#spicebranchdeleterestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch fold {#gs-branch-fold} @@ -1068,12 +953,9 @@ Commits of the current branch are transplanted onto another branch while leaving the rest of the stack intact. That is, branches above the current branch -are retargeted onto its original base, +are first rebased onto its original base, and then the current branch is moved onto the new base. -Use --restack to rebase those branches and their upstacks -immediately after retargeting. - A prompt will allow selecting the new base for the branch. Provide an argument to skip the prompt. Use the --branch flag to target a different branch for the move. @@ -1098,9 +980,8 @@ Use 'gs upstack onto' to also move the upstack branches. **Flags** * `--branch=NAME`: Branch to move -* `--restack` ([:material-wrench:{ .middle title="spice.branchOnto.restack" }](/cli/config.md#spicebranchontorestack)): How to restack branches above the moved branch. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchOnto.restack](/cli/config.md#spicebranchontorestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch diff {#gs-branch-diff} @@ -1121,35 +1002,6 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff -### git-spice branch merge {#gs-branch-merge} - -``` -gs branch (b) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a branch into trunk - -Merges the CR for the current branch into trunk. -Use --branch to merge a different branch. - -The branch must be based directly on trunk. -To merge a stacked branch, use 'gs downstack merge'. - -Before merging, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait. - -**Flags** - -* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. -* `--branch=NAME`: Branch to merge - -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) - ### git-spice branch submit {#gs-branch-submit} ``` @@ -1203,7 +1055,167 @@ only if there are multiple CRs in the stack. * `--body=BODY`: Body of the change request * `--branch=NAME`: Branch to submit -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) + +### git-spice branch comment list {#gs-branch-comment-list} + +``` +gs branch (b) comment (cmt) list (ls) [flags] +``` + +List comments on a change request + +Lists comments on the change request +associated with the current branch. +Use --branch to target a different branch. + +Staged comments that have not yet been submitted +are shown with an 'sc-N' prefix. + +Use --staged to show only staged comments. +Use --unresolved to show only unresolved comments. + +With --json, prints output to stdout +as a stream of JSON objects. + +**Flags** + +* `-b`, `--branch=BRANCH`: Branch to list comments for. Defaults to current branch. +* `--staged`: Show only staged comments. +* `--unresolved`: Show only unresolved comments. +* `--json`: Write to stdout as a stream of JSON objects. :material-tag-hidden:{ title="Released in version" }Unreleased + +### git-spice branch comment stage {#gs-branch-comment-stage} + +``` +gs branch (b) comment (cmt) stage [] [flags] +``` + +Stage an inline comment for batch submission + +Stages an inline comment for later batch submission. +Provide the file and line number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread +instead of starting a new one. + +Staged comments are submitted together with +'gs branch comment submit-staged'. + +**Arguments** + +* `file-and-line`: File and line in the form file.go:42. + +**Flags** + +* `-m`, `--message=MSG`: Comment body. Opens editor if not provided. +* `--respond=THREAD_ID`: Thread ID to reply to instead of starting a new thread. +* `-b`, `--branch=BRANCH`: Branch to stage comment for. Defaults to current branch. + +### git-spice branch comment add {#gs-branch-comment-add} + +``` +gs branch (b) comment (cmt) add [] [flags] +``` + +Post an inline comment immediately + +Posts an inline comment immediately +on the change request for the current branch. +Provide the file and line number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread +instead of starting a new one. + +**Arguments** + +* `file-and-line`: File and line in the form file.go:42. + +**Flags** + +* `-m`, `--message=MSG`: Comment body. Opens editor if not provided. +* `--respond=THREAD_ID`: Thread ID to reply to instead of starting a new thread. +* `-b`, `--branch=BRANCH`: Branch to add comment for. Defaults to current branch. + +### git-spice branch comment submit-staged {#gs-branch-comment-submit-staged} + +``` +gs branch (b) comment (cmt) submit-staged (ss) [flags] +``` + +Submit all staged comments as a review + +Submits all staged comments for the current branch +as a single review on the change request. + +Use --approve or --request-changes +to set the review event type. +Defaults to a comment-only review. + +Use --body to add an overall review body. + +**Flags** + +* `--body=BODY`: Overall review body. +* `--approve`: Mark the review as approved. +* `--request-changes`: Mark the review as requesting changes. +* `-b`, `--branch=BRANCH`: Branch to submit staged comments for. Defaults to current branch. + +### git-spice branch comment resolve {#gs-branch-comment-resolve} + +``` +gs branch (b) comment (cmt) resolve [flags] +``` + +Resolve or unresolve a review thread + +Resolves a review thread on the change request +for the current branch. + +Use --unresolve to mark the thread as unresolved. + +The thread ID is shown in 'gs branch comment list'. + +**Arguments** + +* `thread-id`: Thread ID to resolve. + +**Flags** + +* `--unresolve`: Unresolve the thread instead of resolving it. +* `-b`, `--branch=BRANCH`: Branch whose change request contains the thread. Defaults to current branch. + +### git-spice branch comment edit {#gs-branch-comment-edit} + +``` +gs branch (b) comment (cmt) edit [flags] +``` + +Edit a comment + +Edits the body of a comment. + +For staged comments (sc-N prefix), +the comment is updated in the local staging area. + +For forge comments, the comment is updated +on the remote forge. + +If no message is given with -m, an editor is opened +with the current comment body pre-filled. + +**Arguments** + +* `id`: Comment ID to edit. Use 'sc-N' for staged comments or a forge comment ID. + +**Flags** + +* `-m`, `--message=MSG`: New comment body. Opens editor if not provided. +* `-b`, `--branch=BRANCH`: Branch whose comments to edit. Defaults to current branch. ## Commit diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index 064cad722..42098f47a 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -1,12 +1,13 @@ | **Shorthand** | **Long form** | | --- | --- | | gs bc | [gs branch create](/cli/reference.md#gs-branch-create) | +| gs bcmtls | [gs branch comment list](/cli/reference.md#gs-branch-comment-list) | +| gs bcmtss | [gs branch comment submit-staged](/cli/reference.md#gs-branch-comment-submit-staged) | | gs bco | [gs branch checkout](/cli/reference.md#gs-branch-checkout) | | gs bd | [gs branch delete](/cli/reference.md#gs-branch-delete) | | gs bdi | [gs branch diff](/cli/reference.md#gs-branch-diff) | | gs be | [gs branch edit](/cli/reference.md#gs-branch-edit) | | gs bfo | [gs branch fold](/cli/reference.md#gs-branch-fold) | -| gs bm | [gs branch merge](/cli/reference.md#gs-branch-merge) | | gs bon | [gs branch onto](/cli/reference.md#gs-branch-onto) | | gs br | [gs branch restack](/cli/reference.md#gs-branch-restack) | | gs brn | [gs branch rename](/cli/reference.md#gs-branch-rename) | @@ -21,7 +22,6 @@ | gs cp | [gs commit pick](/cli/reference.md#gs-commit-pick) | | gs csp | [gs commit split](/cli/reference.md#gs-commit-split) | | gs dse | [gs downstack edit](/cli/reference.md#gs-downstack-edit) | -| gs dsm | [gs downstack merge](/cli/reference.md#gs-downstack-merge) | | gs dsr | [gs downstack restack](/cli/reference.md#gs-downstack-restack) | | gs dss | [gs downstack submit](/cli/reference.md#gs-downstack-submit) | | gs dstr | [gs downstack track](/cli/reference.md#gs-downstack-track) | @@ -34,7 +34,6 @@ | gs rs | [gs repo sync](/cli/reference.md#gs-repo-sync) | | gs sd | [gs stack delete](/cli/reference.md#gs-stack-delete) | | gs se | [gs stack edit](/cli/reference.md#gs-stack-edit) | -| gs sm | [gs stack merge](/cli/reference.md#gs-stack-merge) | | gs sr | [gs stack restack](/cli/reference.md#gs-stack-restack) | | gs ss | [gs stack submit](/cli/reference.md#gs-stack-submit) | | gs usd | [gs upstack delete](/cli/reference.md#gs-upstack-delete) | diff --git a/internal/git/diff_wt.go b/internal/git/diff_wt.go index a3a805de9..f1686410b 100644 --- a/internal/git/diff_wt.go +++ b/internal/git/diff_wt.go @@ -19,3 +19,18 @@ func (w *Worktree) DiffBranch(ctx context.Context, base, head string) error { } return nil } + +// DiffBranchBytes returns the unified diff output +// between base and head using triple-dot syntax. +func (w *Worktree) DiffBranchBytes( + ctx context.Context, + base, head string, +) ([]byte, error) { + out, err := w.gitCmd( + ctx, "diff", base+"..."+head, + ).Output() + if err != nil { + return nil, fmt.Errorf("diff: %w", err) + } + return out, nil +} diff --git a/testdata/help/branch_comment_add.txt b/testdata/help/branch_comment_add.txt new file mode 100644 index 000000000..a3a596440 --- /dev/null +++ b/testdata/help/branch_comment_add.txt @@ -0,0 +1,27 @@ +Usage: gs branch (b) comment (cmt) add [] [flags] + +Post an inline comment immediately + +Posts an inline comment immediately on the change request for the current +branch. Provide the file and line number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread instead of starting a new one. + +Arguments: + [] File and line in the form file.go:42. + +Flags: + -m, --message=MSG Comment body. Opens editor if not provided. + --respond=THREAD_ID Thread ID to reply to instead of starting a new + thread. + -b, --branch=BRANCH Branch to add comment for. Defaults to current + branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_edit.txt b/testdata/help/branch_comment_edit.txt new file mode 100644 index 000000000..26ef35744 --- /dev/null +++ b/testdata/help/branch_comment_edit.txt @@ -0,0 +1,29 @@ +Usage: gs branch (b) comment (cmt) edit [flags] + +Edit a comment + +Edits the body of a comment. + +For staged comments (sc-N prefix), the comment is updated in the local staging +area. + +For forge comments, the comment is updated on the remote forge. + +If no message is given with -m, an editor is opened with the current comment +body pre-filled. + +Arguments: + Comment ID to edit. Use 'sc-N' for staged comments or a forge comment + ID. + +Flags: + -m, --message=MSG New comment body. Opens editor if not provided. + -b, --branch=BRANCH Branch whose comments to edit. Defaults to current + branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_list.txt b/testdata/help/branch_comment_list.txt new file mode 100644 index 000000000..2ad4dabf5 --- /dev/null +++ b/testdata/help/branch_comment_list.txt @@ -0,0 +1,28 @@ +Usage: gs branch (b) comment (cmt) list (ls) [flags] + +List comments on a change request + +Lists comments on the change request associated with the current branch. +Use --branch to target a different branch. + +Staged comments that have not yet been submitted are shown with an 'sc-N' +prefix. + +Use --staged to show only staged comments. Use --unresolved to show only +unresolved comments. + +With --json, prints output to stdout as a stream of JSON objects. + +Flags: + -b, --branch=BRANCH Branch to list comments for. Defaults to current + branch. + --staged Show only staged comments. + --unresolved Show only unresolved comments. + --json Write to stdout as a stream of JSON objects. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_resolve.txt b/testdata/help/branch_comment_resolve.txt new file mode 100644 index 000000000..c4ee13472 --- /dev/null +++ b/testdata/help/branch_comment_resolve.txt @@ -0,0 +1,24 @@ +Usage: gs branch (b) comment (cmt) resolve [flags] + +Resolve or unresolve a review thread + +Resolves a review thread on the change request for the current branch. + +Use --unresolve to mark the thread as unresolved. + +The thread ID is shown in 'gs branch comment list'. + +Arguments: + Thread ID to resolve. + +Flags: + --unresolve Unresolve the thread instead of resolving it. + -b, --branch=BRANCH Branch whose change request contains the thread. + Defaults to current branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_stage.txt b/testdata/help/branch_comment_stage.txt new file mode 100644 index 000000000..d8d4e7d2b --- /dev/null +++ b/testdata/help/branch_comment_stage.txt @@ -0,0 +1,29 @@ +Usage: gs branch (b) comment (cmt) stage [] [flags] + +Stage an inline comment for batch submission + +Stages an inline comment for later batch submission. Provide the file and line +number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread instead of starting a new one. + +Staged comments are submitted together with 'gs branch comment submit-staged'. + +Arguments: + [] File and line in the form file.go:42. + +Flags: + -m, --message=MSG Comment body. Opens editor if not provided. + --respond=THREAD_ID Thread ID to reply to instead of starting a new + thread. + -b, --branch=BRANCH Branch to stage comment for. Defaults to current + branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_submit-staged.txt b/testdata/help/branch_comment_submit-staged.txt new file mode 100644 index 000000000..6d6b6c339 --- /dev/null +++ b/testdata/help/branch_comment_submit-staged.txt @@ -0,0 +1,25 @@ +Usage: gs branch (b) comment (cmt) submit-staged (ss) [flags] + +Submit all staged comments as a review + +Submits all staged comments for the current branch as a single review on the +change request. + +Use --approve or --request-changes to set the review event type. Defaults to a +comment-only review. + +Use --body to add an overall review body. + +Flags: + --body=BODY Overall review body. + --approve Mark the review as approved. + --request-changes Mark the review as requesting changes. + -b, --branch=BRANCH Branch to submit staged comments for. Defaults to + current branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 43406fde2..166ac7a91 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -31,7 +31,6 @@ Log Stack stack (s) submit (s) Submit a stack - stack (s) merge (m) Merge a stack stack (s) restack (r) Restack a stack stack (s) edit (e) Edit the order of branches in a stack stack (s) delete (d) Delete all branches in a stack @@ -41,26 +40,34 @@ Stack upstack (us) delete (d) Delete all branches above the current branch downstack (ds) track (tr) Track all untracked branches below a branch downstack (ds) submit (s) Submit a branch and those below it - downstack (ds) merge (m) Merge a branch and those below it downstack (ds) edit (e) Edit the order of branches below a branch downstack (ds) restack (r) Restack a branch and its downstack Branch - branch (b) track (tr) Track a branch - branch (b) untrack (untr) Forget a tracked branch - branch (b) checkout (co) Switch to a branch - branch (b) create (c) Create a new branch - branch (b) delete (d,rm) Delete branches - branch (b) fold (fo) Merge a branch into its base - branch (b) split (sp) Split a branch on commits - branch (b) squash (sq) Squash a branch into one commit - branch (b) edit (e) Edit the commits in a branch - branch (b) rename (rn,mv) Rename a branch - branch (b) restack (r) Restack a branch - branch (b) onto (on) Move a branch onto another branch - branch (b) diff (di) Show diff between a branch and its base - branch (b) merge (m) Merge a branch into trunk - branch (b) submit (s) Submit a branch + branch (b) track (tr) Track a branch + branch (b) untrack (untr) Forget a tracked branch + branch (b) checkout (co) Switch to a branch + branch (b) create (c) Create a new branch + branch (b) delete (d,rm) Delete branches + branch (b) fold (fo) Merge a branch into its base + branch (b) split (sp) Split a branch on commits + branch (b) squash (sq) Squash a branch into one commit + branch (b) edit (e) Edit the commits in a branch + branch (b) rename (rn,mv) Rename a branch + branch (b) restack (r) Restack a branch + branch (b) onto (on) Move a branch onto another branch + branch (b) diff (di) Show diff between a branch and its base + branch (b) submit (s) Submit a branch + branch (b) comment (cmt) list (ls) + List comments on a change request + branch (b) comment (cmt) stage + Stage an inline comment for batch submission + branch (b) comment (cmt) add Post an inline comment immediately + branch (b) comment (cmt) submit-staged (ss) + Submit all staged comments as a review + branch (b) comment (cmt) resolve + Resolve or unresolve a review thread + branch (b) comment (cmt) edit Edit a comment Commit commit (c) create (c) Create a new commit From ef6e3266bdf56c5b1159053a5e821307b86abf79 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:22:28 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 95 ++++++++++++++++++++++++++++++---- doc/includes/cli-shorthands.md | 1 + testdata/help/gs.txt | 1 + 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index ed11008e8..ad319b235 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -12,7 +12,7 @@ git-spice is a command line tool for stacking Git branches. * `-C`, `--dir=DIR`: Change to DIR before doing anything * `--[no-]prompt`: Whether to prompt for missing information -**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) +**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.forge.kind](/cli/config.md#spiceforgekind), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) ## Shell @@ -156,11 +156,17 @@ The repository must have a remote associated for syncing. A prompt will ask for one if the repository was not initialized with a remote. +Branches above merged and deleted branches +are retargeted to the trunk branch. +Run with --restack to also restack them and their upstacks. +Run with --restack=aboves to only restack direct upstacks +of deleted branches, leaving higher branches in place. + **Flags** -* `--restack`: Restack the current stack after syncing +* `--restack` ([:material-wrench:{ .middle title="spice.repoSync.restack" }](/cli/config.md#spicereposyncrestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges) +**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges), [spice.repoSync.restack](/cli/config.md#spicereposyncrestack) ### git-spice repo restack {#gs-repo-restack} @@ -278,7 +284,7 @@ only if there are multiple CRs in the stack. * `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. :material-tag:{ title="Released in version" }[v0.21.0](/changelog.md#v0.21.0) * `--no-web`: Alias for --web=false. -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice stack restack {#gs-stack-restack} @@ -401,7 +407,7 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice upstack restack {#gs-upstack-restack} @@ -556,7 +562,66 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) + +### git-spice downstack merge {#gs-downstack-merge} + +``` +gs downstack (ds) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a branch and those below it + +Merges the current branch and all branches below it +into trunk via the forge API, bottom-up. +Use --branch to start at a different branch. + +This command acts as a local merge queue: +it merges one Change Request, +waits for that merge to finish, +restacks and updates the next Change Request, +waits for its CI checks to pass, +and then repeats the process. + +For a stack like this: + + main <- feature1 <- feature2 <- feature3 + +Running from feature3 merges in this order: + + feature1, feature2, feature3 + +Already-merged branches are skipped automatically. +Branches must have an open Change Request to be merged. + +Before merging, the downstack is checked for branches +whose base PR was already merged on the forge. +Use --no-branch-check to skip this validation. + +Before each merge, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait +(default: 30m, 0 means fail immediately if not ready). + +Between merges, the command waits for each merge +to complete, restacks and updates the next PR, +waits for CI checks on the updated PR, +and syncs merged branch cleanup. + +Use --no-wait for single branch merging +when you don't want to wait for the merge to propagate. +--no-wait is rejected for multi-branch merges. + +**Flags** + +* `--branch=NAME`: Branch to start merging from +* `--no-wait`: Skip polling for a single branch merge to propagate. +* `--no-branch-check`: Skip stale base validation before merging. +* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) ### git-spice downstack edit {#gs-downstack-edit} @@ -764,10 +829,13 @@ gs branch (b) delete (d,rm) [ ...] [flags] Delete branches The deleted branches and their commits are removed from the stack. -Branches above the deleted branches are first rebased onto +Branches above the deleted branches are retargeted onto the next branches available downstack, or onto trunk if there are no branches available below. +Use --restack to rebase those branches and their upstacks +immediately after retargeting. + Without any arguments, a prompt will allow selecting the branch to delete. @@ -782,8 +850,9 @@ Use --force to delete the branch regardless of unmerged changes. **Flags** * `--force`: Force deletion of the branch +* `--restack` ([:material-wrench:{ .middle title="spice.branchDelete.restack" }](/cli/config.md#spicebranchdeleterestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchDelete.restack](/cli/config.md#spicebranchdeleterestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch fold {#gs-branch-fold} @@ -953,9 +1022,12 @@ Commits of the current branch are transplanted onto another branch while leaving the rest of the stack intact. That is, branches above the current branch -are first rebased onto its original base, +are retargeted onto its original base, and then the current branch is moved onto the new base. +Use --restack to rebase those branches and their upstacks +immediately after retargeting. + A prompt will allow selecting the new base for the branch. Provide an argument to skip the prompt. Use the --branch flag to target a different branch for the move. @@ -980,8 +1052,9 @@ Use 'gs upstack onto' to also move the upstack branches. **Flags** * `--branch=NAME`: Branch to move +* `--restack` ([:material-wrench:{ .middle title="spice.branchOnto.restack" }](/cli/config.md#spicebranchontorestack)): How to restack branches above the moved branch. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchOnto.restack](/cli/config.md#spicebranchontorestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch diff {#gs-branch-diff} @@ -1055,7 +1128,7 @@ only if there are multiple CRs in the stack. * `--body=BODY`: Body of the change request * `--branch=NAME`: Branch to submit -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice branch comment list {#gs-branch-comment-list} diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index 42098f47a..5322219d9 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -22,6 +22,7 @@ | gs cp | [gs commit pick](/cli/reference.md#gs-commit-pick) | | gs csp | [gs commit split](/cli/reference.md#gs-commit-split) | | gs dse | [gs downstack edit](/cli/reference.md#gs-downstack-edit) | +| gs dsm | [gs downstack merge](/cli/reference.md#gs-downstack-merge) | | gs dsr | [gs downstack restack](/cli/reference.md#gs-downstack-restack) | | gs dss | [gs downstack submit](/cli/reference.md#gs-downstack-submit) | | gs dstr | [gs downstack track](/cli/reference.md#gs-downstack-track) | diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 166ac7a91..74ac03e07 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -40,6 +40,7 @@ Stack upstack (us) delete (d) Delete all branches above the current branch downstack (ds) track (tr) Track all untracked branches below a branch downstack (ds) submit (s) Submit a branch and those below it + downstack (ds) merge (m) Merge a branch and those below it downstack (ds) edit (e) Edit the order of branches below a branch downstack (ds) restack (r) Restack a branch and its downstack From 112367adf078f4a7ce05f985020b1ec790eef9c5 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:55:43 +0000 Subject: [PATCH 3/3] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 75 ++++++++++++++++++++++++++++++++-- doc/includes/cli-shorthands.md | 2 + testdata/help/gs.txt | 2 + 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index ad319b235..f99e35ecd 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -286,6 +286,48 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +### git-spice stack merge {#gs-stack-merge} + +``` +gs stack (s) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a stack + +Merges the CRs for the current branch's stack into trunk. +Use --branch to merge a different branch's stack. + +The stack includes the selected branch, +its downstack branches down to trunk, +and every upstack branch. + +Already-merged branches are skipped automatically. +Branches must have an open Change Request to be merged. + +Before merging, the stack is checked for branches +whose base PR was already merged on the forge. +Use --no-branch-check to skip this validation. + +Before each merge, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait +before failing if checks are not ready. + +By default, a branch failure skips that branch's upstack descendants, +but independent sibling branches continue. +Use --fail-fast to stop the queue after the first branch failure. + +**Flags** + +* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--no-branch-check`: Skip stale base validation before merging. +* `--fail-fast`: Stop the merge queue after the first branch failure. +* `--branch=NAME`: Branch whose stack to merge + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) + ### git-spice stack restack {#gs-stack-restack} ``` @@ -615,11 +657,11 @@ when you don't want to wait for the merge to propagate. **Flags** -* `--branch=NAME`: Branch to start merging from -* `--no-wait`: Skip polling for a single branch merge to propagate. -* `--no-branch-check`: Skip stale base validation before merging. * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. * `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--no-wait`: Skip polling for a single branch merge to propagate. +* `--no-branch-check`: Skip stale base validation before merging. +* `--branch=NAME`: Branch to start merging from **Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) @@ -1075,6 +1117,33 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff +### git-spice branch merge {#gs-branch-merge} + +``` +gs branch (b) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a branch into trunk + +Merges the CR for the current branch into trunk. +Use --branch to merge a different branch. + +The branch must be based directly on trunk. +To merge a stacked branch, use 'gs downstack merge'. + +Before merging, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait. + +**Flags** + +* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--branch=NAME`: Branch to merge + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) + ### git-spice branch submit {#gs-branch-submit} ``` diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index 5322219d9..b3cb86a0e 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -8,6 +8,7 @@ | gs bdi | [gs branch diff](/cli/reference.md#gs-branch-diff) | | gs be | [gs branch edit](/cli/reference.md#gs-branch-edit) | | gs bfo | [gs branch fold](/cli/reference.md#gs-branch-fold) | +| gs bm | [gs branch merge](/cli/reference.md#gs-branch-merge) | | gs bon | [gs branch onto](/cli/reference.md#gs-branch-onto) | | gs br | [gs branch restack](/cli/reference.md#gs-branch-restack) | | gs brn | [gs branch rename](/cli/reference.md#gs-branch-rename) | @@ -35,6 +36,7 @@ | gs rs | [gs repo sync](/cli/reference.md#gs-repo-sync) | | gs sd | [gs stack delete](/cli/reference.md#gs-stack-delete) | | gs se | [gs stack edit](/cli/reference.md#gs-stack-edit) | +| gs sm | [gs stack merge](/cli/reference.md#gs-stack-merge) | | gs sr | [gs stack restack](/cli/reference.md#gs-stack-restack) | | gs ss | [gs stack submit](/cli/reference.md#gs-stack-submit) | | gs usd | [gs upstack delete](/cli/reference.md#gs-upstack-delete) | diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 74ac03e07..68df72df1 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -31,6 +31,7 @@ Log Stack stack (s) submit (s) Submit a stack + stack (s) merge (m) Merge a stack stack (s) restack (r) Restack a stack stack (s) edit (e) Edit the order of branches in a stack stack (s) delete (d) Delete all branches in a stack @@ -58,6 +59,7 @@ Branch branch (b) restack (r) Restack a branch branch (b) onto (on) Move a branch onto another branch branch (b) diff (di) Show diff between a branch and its base + branch (b) merge (m) Merge a branch into trunk branch (b) submit (s) Submit a branch branch (b) comment (cmt) list (ls) List comments on a change request