Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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