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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

- CLI: harden backup writes, config/credentials atomic saves, keyring write verification, line input buffering, disabled-API hints, JSON transform number handling, and untrusted-content wrapping after ClawPatch review.
- CLI: bound retry request replay buffering, recover failed async backup pushes, ignore global git commit signing in backup snapshots, and protect account manager OAuth redirects with CSRF checks.
- Docs: `gog docs insert` now defaults to end-of-doc when `--index` is omitted, instead of always inserting at position 1 (which silently reversed iterative inserts across multiple calls). Pass `--index 1` explicitly to keep the previous behaviour. (#606)

## 0.17.0 - 2026-05-15

Expand Down
2 changes: 1 addition & 1 deletion docs/commands/gog-docs-insert.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ gog docs (doc) insert <docId> [<content>] [flags]
| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
| `--index` | `int64` | 1 | Character index to insert at (1 = beginning) |
| `--index` | `*int64` | | Character index to insert at (1 = beginning). Defaults to end-of-doc when omitted. |
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
Expand Down
41 changes: 29 additions & 12 deletions internal/cmd/docs_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root
type DocsInsertCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Content string `arg:"" optional:"" name:"content" help:"Text to insert (or use --file / stdin)"`
Index int64 `name:"index" help:"Character index to insert at (1 = beginning)" default:"1"`
Index *int64 `name:"index" help:"Character index to insert at (1 = beginning). Defaults to end-of-doc when omitted."`
File string `name:"file" short:"f" help:"Read content from file (use - for stdin)"`
Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"`
TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"`
Expand All @@ -504,7 +504,7 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error {
if content == "" {
return usage("no content provided (use argument, --file, or stdin)")
}
if c.Index < 1 {
if c.Index != nil && *c.Index < 1 {
return usage("--index must be >= 1 (index 0 is reserved)")
}

Expand All @@ -514,33 +514,50 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error {
}
c.Tab = tab

if dryRunErr := dryRunExit(ctx, flags, "docs.insert", map[string]any{
dryRunPayload := map[string]any{
"documentId": docID,
"inserted": len(content),
"atIndex": c.Index,
"tab": c.Tab,
}); dryRunErr != nil {
}
if c.Index != nil {
dryRunPayload["atIndex"] = *c.Index
} else {
dryRunPayload["atIndex"] = "end"
}
if dryRunErr := dryRunExit(ctx, flags, "docs.insert", dryRunPayload); dryRunErr != nil {
return dryRunErr
}

svc, err := requireDocsService(ctx, flags)
if err != nil {
return err
}
if c.Tab != "" {
tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab)
if tabErr != nil {
return tabErr

var insertIndex int64
if c.Index != nil {
insertIndex = *c.Index
if c.Tab != "" {
tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab)
if tabErr != nil {
return tabErr
}
c.Tab = tabID
}
} else {
endIndex, tabID, endErr := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab)
if endErr != nil {
return endErr
}
c.Tab = tabID
insertIndex = docsAppendIndex(endIndex)
}

result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{
Requests: []*docs.Request{{
InsertText: &docs.InsertTextRequest{
Text: content,
Location: &docs.Location{
Index: c.Index,
Index: insertIndex,
TabId: c.Tab,
},
},
Expand All @@ -551,7 +568,7 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error {
}

if outfmt.IsJSON(ctx) {
payload := map[string]any{"documentId": result.DocumentId, "inserted": len(content), "atIndex": c.Index}
payload := map[string]any{"documentId": result.DocumentId, "inserted": len(content), "atIndex": insertIndex}
if c.Tab != "" {
payload["tabId"] = c.Tab
}
Expand All @@ -560,7 +577,7 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error {

u.Out().Linef("documentId\t%s", result.DocumentId)
u.Out().Linef("inserted\t%d bytes", len(content))
u.Out().Linef("atIndex\t%d", c.Index)
u.Out().Linef("atIndex\t%d", insertIndex)
if c.Tab != "" {
u.Out().Linef("tabId\t%s", c.Tab)
}
Expand Down
138 changes: 138 additions & 0 deletions internal/cmd/docs_insert_default_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package cmd

import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"

"google.golang.org/api/docs/v1"
)

// docBodyWithEndIndex returns a Get-response payload whose body endIndex matches
// the provided value, so tests can assert that the insert path resolved
// end-of-doc correctly when --index is omitted.
func docBodyWithEndIndex(end int64) map[string]any {
return map[string]any{
"documentId": "doc1",
"body": map[string]any{
"content": []any{
map[string]any{"startIndex": 0, "endIndex": end},
},
},
}
}

func TestDocsInsertCmd_DefaultsToEndOfDoc(t *testing.T) {
origDocs := newDocsService
t.Cleanup(func() { newDocsService = origDocs })

var batchRequests [][]*docs.Request
var getCalls int

docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"):
getCalls++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(docBodyWithEndIndex(42))
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, ":batchUpdate"):
var req docs.BatchUpdateDocumentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode: %v", err)
}
batchRequests = append(batchRequests, req.Requests)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"})
default:
http.NotFound(w, r)
}
}))
defer cleanup()
newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil }

flags := &RootFlags{Account: "a@b.com"}
ctx := newDocsCmdContext(t)

if err := runKong(t, &DocsInsertCmd{}, []string{"doc1", "hello"}, ctx, flags); err != nil {
t.Fatalf("insert: %v", err)
}

if getCalls != 1 {
t.Fatalf("expected 1 GET to resolve end-index, got %d", getCalls)
}
if len(batchRequests) != 1 || len(batchRequests[0]) != 1 || batchRequests[0][0].InsertText == nil {
t.Fatalf("unexpected requests: %#v", batchRequests)
}
loc := batchRequests[0][0].InsertText.Location
if loc == nil {
t.Fatalf("expected Location, got nil")
}
// endIndex = 42 → docsAppendIndex(42) = 41
if loc.Index != 41 {
t.Fatalf("expected insert at end-1 (41), got %d", loc.Index)
}
}

func TestDocsInsertCmd_ExplicitIndexSkipsGet(t *testing.T) {
origDocs := newDocsService
t.Cleanup(func() { newDocsService = origDocs })

var batchRequests [][]*docs.Request
var getCalls int

docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"):
getCalls++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(docBodyWithEndIndex(42))
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, ":batchUpdate"):
var req docs.BatchUpdateDocumentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode: %v", err)
}
batchRequests = append(batchRequests, req.Requests)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"})
default:
http.NotFound(w, r)
}
}))
defer cleanup()
newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil }

flags := &RootFlags{Account: "a@b.com"}
ctx := newDocsCmdContext(t)

if err := runKong(t, &DocsInsertCmd{}, []string{"doc1", "hello", "--index", "7"}, ctx, flags); err != nil {
t.Fatalf("insert: %v", err)
}

if getCalls != 0 {
t.Fatalf("explicit --index should not GET the doc, but GET was called %d times", getCalls)
}
if got := batchRequests[0][0].InsertText.Location; got.Index != 7 {
t.Fatalf("expected explicit index 7, got %d", got.Index)
}
}

func TestDocsInsertCmd_ExplicitIndexBelowOneRejected(t *testing.T) {
origDocs := newDocsService
t.Cleanup(func() { newDocsService = origDocs })

docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer cleanup()
newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil }

flags := &RootFlags{Account: "a@b.com"}
ctx := newDocsCmdContext(t)

err := runKong(t, &DocsInsertCmd{}, []string{"doc1", "hello", "--index", "0"}, ctx, flags)
if err == nil || !strings.Contains(err.Error(), "--index must be >= 1") {
t.Fatalf("expected --index >= 1 validation error, got %v", err)
}
}