Skip to content
3 changes: 3 additions & 0 deletions .changes/unreleased/Added-20260314-155204.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: Added
body: 'Add commands for drafting, publishing, and managing review comments'
time: 2026-03-14T15:52:04.220487-04:00
177 changes: 177 additions & 0 deletions doc/includes/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,183 @@ This command requires at least Git 2.45.

**Configuration**: [spice.commitPick.restack](/cli/config.md#spicecommitpickrestack)

## Review

### git-spice review comment {#gs-review-comment}

```
gs review comment [<anchor>] [flags]
```

Draft or post a review comment

Adds a review comment to the change request
for the current branch.
The anchor controls the comment scope:

file.go:42 anchored to that line
file.go:42-50 anchored to that line range
file.go anchored to the file

Comments are saved as local drafts by default.
Use --no-draft to post immediately.

If no message is given with -m, an editor is opened.

**Arguments**

* `anchor`: Comment anchor: file.go, file.go:42, or file.go:42-50.

**Flags**

* `-m`, `--message=MSG`: Comment body. Opens editor if not provided.
* `--[no-]draft`: Save the comment as a local draft instead of posting it.
* `-b`, `--branch=BRANCH`: Branch to comment on. Defaults to the current branch.

### git-spice review reply {#gs-review-reply}

```
gs review reply <thread-id> [flags]
```

Draft or post a reply to a review thread

Replies to a review thread on the change request
for the current branch.

Replies are saved as local drafts by default.
Use --no-draft to post immediately.

If no message is given with -m, an editor is opened.

**Arguments**

* `thread-id`: Thread ID to reply to.

**Flags**

* `-m`, `--message=MSG`: Reply body. Opens editor if not provided.
* `--[no-]draft`: Save the reply as a local draft instead of posting it.
* `-b`, `--branch=BRANCH`: Branch containing the thread. Defaults to the current branch.

### git-spice review publish {#gs-review-publish}

```
gs review publish [flags]
```

Publish draft comments as a review

Publishes all draft 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 whose draft comments to publish. Defaults to the current branch.

### git-spice review list {#gs-review-list}

```
gs review list (ls) [flags]
```

List review comments

Lists comments on the change request
associated with the current branch.
Use --branch to target a different branch.

Draft comments are identified by a branch-local integer.

Use --draft-only to show only draft 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 the current branch.
* `--draft-only`: Show only draft comments.
* `--unresolved`: Show only unresolved comments.
* `--json`: Write to stdout as a stream of JSON objects. <span class="mdx-badge"><span class="mdx-badge__icon">:material-tag-hidden:{ title="Released in version" }</span><span class="mdx-badge__text">Unreleased</span>

### git-spice review edit {#gs-review-edit}

```
gs review edit <id> [flags]
```

Edit a draft comment

Edits a local draft comment.

Use 'gs review list --draft-only'
to find the branch-local draft ID.

If no message is given with -m, an editor is opened
with the current comment body pre-filled.

**Arguments**

* `id`: Draft comment ID to edit.

**Flags**

* `-m`, `--message=MSG`: New comment body. Opens editor if not provided.
* `-b`, `--branch=BRANCH`: Branch containing the draft. Defaults to the current branch.

### git-spice review resolve {#gs-review-resolve}

```
gs review resolve <thread-id> [flags]
```

Resolve a review thread

Resolves a review thread on the change request
for the current branch.

The thread ID is shown in 'gs review list'.

**Arguments**

* `thread-id`: Thread ID to resolve.

**Flags**

* `-b`, `--branch=BRANCH`: Branch containing the thread. Defaults to the current branch.

### git-spice review reopen {#gs-review-reopen}

```
gs review reopen <thread-id> [flags]
```

Reopen a resolved review thread

Reopens a resolved review thread on the change request
for the current branch.

The thread ID is shown in 'gs review list'.

**Arguments**

* `thread-id`: Thread ID to reopen.

**Flags**

* `-b`, `--branch=BRANCH`: Branch containing the thread. Defaults to the current branch.

## Rebase

### git-spice rebase continue {#gs-rebase-continue}
Expand Down
15 changes: 15 additions & 0 deletions internal/git/diff_wt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
121 changes: 121 additions & 0 deletions internal/spice/state/staged_comment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package state

import (
"context"
"errors"
"fmt"
"path"

"go.abhg.dev/gs/internal/spice/state/storage"
)

// _stagedCommentsDir is the directory holding staged comments
// for branches that have not yet been submitted as reviews.
const _stagedCommentsDir = "staged-comments"

// StagedComment is a draft inline comment
// waiting to be batch-submitted as part of a review.
type StagedComment struct {
// ID is a local auto-increment identifier
// unique within the branch's staged comments.
ID int `json:"id"`

// File is the file path relative to the repository root.
File string `json:"file"`

// Line is the line number in the new version of the file.
Line int `json:"line"`

// Body is the markdown body of the comment.
Body string `json:"body"`

// ThreadID is set when replying to an existing thread.
// The format is forge-specific.
ThreadID string `json:"threadID,omitempty"`
}

// StagedComments is the collection of staged comments
// for a branch.
type StagedComments struct {
// NextID is the next ID to assign
// to a new staged comment.
NextID int `json:"nextID"`

// Comments are the staged comments.
Comments []StagedComment `json:"comments"`
}

func (s *Store) stagedCommentsJSON(branch string) string {
return path.Join(_stagedCommentsDir, branch)
}

// SaveStagedComments saves the staged comments
// for the given branch.
// If staged comments already exist for the branch,
// they will be overwritten.
func (s *Store) SaveStagedComments(
ctx context.Context,
branch string,
comments *StagedComments,
) error {
err := s.db.Set(
ctx,
s.stagedCommentsJSON(branch),
comments,
fmt.Sprintf(
"%v: save staged comments", branch,
),
)
if err != nil {
return fmt.Errorf(
"set staged comments: %w", err,
)
}
return nil
}

// LoadStagedComments retrieves staged comments
// for the given branch.
// Returns nil if no staged comments exist.
func (s *Store) LoadStagedComments(
ctx context.Context,
branch string,
) (*StagedComments, error) {
var comments StagedComments
err := s.db.Get(
ctx,
s.stagedCommentsJSON(branch),
&comments,
)
if err != nil {
if errors.Is(err, storage.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf(
"get staged comments: %w", err,
)
}
return &comments, nil
}

// ClearStagedComments removes staged comments
// for the given branch.
// This is a no-op if no staged comments exist.
func (s *Store) ClearStagedComments(
ctx context.Context,
branch string,
) error {
err := s.db.Delete(
ctx,
s.stagedCommentsJSON(branch),
fmt.Sprintf(
"%v: clear staged comments", branch,
),
)
if err != nil {
return fmt.Errorf(
"delete staged comments: %w", err,
)
}
return nil
}
Loading
Loading