Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
a7fe91482f fix(drive): add trash output + delete tests
Some checks failed
ci / test (push) Has been cancelled
ci / worker (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
2026-02-15 01:32:13 +01:00
laihenyi
9811df2891 fix(drive): delete command now moves to trash instead of permanent deletion
`drive delete` was calling `Files.Delete()` which permanently destroys
files. This contradicts the help text ("moves to trash") and user
expectations. The fix changes the default behavior to use
`Files.Update()` with `Trashed: true`, which moves files to trash
(recoverable for 30 days).

A new `--permanent` flag is added for users who explicitly want
irreversible deletion.

Fixes #261

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 05:02:22 +08:00
6 changed files with 208 additions and 11 deletions

View File

@ -16,6 +16,7 @@
- Gmail: include primary display name in `gmail send` From header when using service account impersonation (domain-wide delegation). (#184) — thanks @salmonumbrella.
- Drive: include shared drives in `drive ls` and `drive search`; reject `drive download --format` for non-Google Workspace files. (#256) — thanks @salmonumbrella.
- Drive: validate `drive download --format` values and error early for unknown formats. (#259)
- Drive: make `drive delete` move files to trash by default; add `--permanent` for irreversible deletion. (#262) — thanks @laihenyi.
## 0.10.0 - 2026-02-14

View File

@ -825,10 +825,11 @@ gog drive mkdir "New Folder" --parent <parentFolderId>
gog drive rename <fileId> "New Name"
gog drive move <fileId> --parent <destinationFolderId>
gog drive delete <fileId> # Move to trash
gog drive delete <fileId> --permanent # Permanently delete
# Permissions
gog drive permissions <fileId>
gog drive share <fileId> --to user --email user@example.com --role reader
# Permissions
gog drive permissions <fileId>
gog drive share <fileId> --to user --email user@example.com --role reader
gog drive share <fileId> --to user --email user@example.com --role writer
gog drive unshare <fileId> --permission-id <permissionId>

View File

@ -175,7 +175,7 @@ Flag aliases:
- `gog drive download <fileId> [--out PATH] [--format F]` (`--format` only applies to Google Workspace files)
- `gog drive upload <localPath> [--name N] [--parent ID] [--convert] [--convert-to doc|sheet|slides]`
- `gog drive mkdir <name> [--parent ID]`
- `gog drive delete <fileId>`
- `gog drive delete <fileId> [--permanent]`
- `gog drive move <fileId> --parent ID`
- `gog drive rename <fileId> <newName>`
- `gog drive share <fileId> --to anyone|user|domain [--email addr] [--domain example.com] [--role reader|writer] [--discoverable]`

View File

@ -57,7 +57,7 @@ type DriveCmd struct {
Copy DriveCopyCmd `cmd:"" name:"copy" help:"Copy a file"`
Upload DriveUploadCmd `cmd:"" name:"upload" help:"Upload a file"`
Mkdir DriveMkdirCmd `cmd:"" name:"mkdir" help:"Create a folder"`
Delete DriveDeleteCmd `cmd:"" name:"delete" help:"Delete a file (moves to trash)" aliases:"rm,del"`
Delete DriveDeleteCmd `cmd:"" name:"delete" help:"Move a file to trash (use --permanent to delete forever)" aliases:"rm,del"`
Move DriveMoveCmd `cmd:"" name:"move" help:"Move a file to a different folder"`
Rename DriveRenameCmd `cmd:"" name:"rename" help:"Rename a file or folder"`
Share DriveShareCmd `cmd:"" name:"share" help:"Share a file or folder"`
@ -541,7 +541,8 @@ func (c *DriveMkdirCmd) Run(ctx context.Context, flags *RootFlags) error {
}
type DriveDeleteCmd struct {
FileID string `arg:"" name:"fileId" help:"File ID"`
FileID string `arg:"" name:"fileId" help:"File ID"`
Permanent bool `name:"permanent" help:"Permanently delete instead of moving to trash" default:"false"`
}
func (c *DriveDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
@ -555,7 +556,11 @@ func (c *DriveDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return usage("empty fileId")
}
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("delete drive file %s", fileID)); confirmErr != nil {
action := "trash drive file"
if c.Permanent {
action = "permanently delete drive file"
}
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("%s %s", action, fileID)); confirmErr != nil {
return confirmErr
}
@ -564,16 +569,32 @@ func (c *DriveDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
if err := svc.Files.Delete(fileID).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
return err
trashed := !c.Permanent
deleted := c.Permanent
if c.Permanent {
if err := svc.Files.Delete(fileID).SupportsAllDrives(true).Context(ctx).Do(); err != nil {
return err
}
} else {
_, err := svc.Files.Update(fileID, &drive.File{Trashed: true}).
SupportsAllDrives(true).
Fields("id, trashed").
Context(ctx).
Do()
if err != nil {
return err
}
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"deleted": true,
"trashed": trashed,
"deleted": deleted,
"id": fileID,
})
}
u.Out().Printf("deleted\ttrue")
u.Out().Printf("trashed\t%t", trashed)
u.Out().Printf("deleted\t%t", deleted)
u.Out().Printf("id\t%s", fileID)
return nil
}

View File

@ -0,0 +1,151 @@
package cmd
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
)
func TestExecute_DriveDelete_DefaultAndPermanent(t *testing.T) {
t.Run("default_trash", func(t *testing.T) {
origNew := newDriveService
t.Cleanup(func() { newDriveService = origNew })
var patchCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/files/id1") || (r.Method != http.MethodPatch && r.Method != http.MethodPut) {
http.NotFound(w, r)
return
}
patchCount++
if got := r.URL.Query().Get("supportsAllDrives"); got != "true" {
t.Fatalf("expected supportsAllDrives=true, got: %q (raw=%q)", got, r.URL.RawQuery)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "\"trashed\":true") {
t.Fatalf("expected trashed=true body, got: %q", string(body))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "id1",
"trashed": true,
"kind": "drive#file",
})
}))
defer srv.Close()
svc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{"--force", "--account", "a@b.com", "drive", "delete", "id1"}); execErr != nil {
t.Fatalf("Execute: %v", execErr)
}
})
})
if !strings.Contains(out, "trashed\ttrue") || !strings.Contains(out, "deleted\tfalse") {
t.Fatalf("unexpected text output: %q", out)
}
jsonOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{"--json", "--force", "--account", "a@b.com", "drive", "delete", "id1"}); execErr != nil {
t.Fatalf("Execute: %v", execErr)
}
})
})
var parsed struct {
Trashed bool `json:"trashed"`
Deleted bool `json:"deleted"`
ID string `json:"id"`
}
if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, jsonOut)
}
if !parsed.Trashed || parsed.Deleted || parsed.ID != "id1" {
t.Fatalf("unexpected json output: %#v", parsed)
}
if patchCount != 2 {
t.Fatalf("expected 2 PATCH calls, got %d", patchCount)
}
})
t.Run("permanent_delete", func(t *testing.T) {
origNew := newDriveService
t.Cleanup(func() { newDriveService = origNew })
var deleteCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/files/id1") || r.Method != http.MethodDelete {
http.NotFound(w, r)
return
}
deleteCount++
if got := r.URL.Query().Get("supportsAllDrives"); got != "true" {
t.Fatalf("expected supportsAllDrives=true, got: %q (raw=%q)", got, r.URL.RawQuery)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
svc, err := drive.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{"--force", "--account", "a@b.com", "drive", "delete", "id1", "--permanent"}); execErr != nil {
t.Fatalf("Execute: %v", execErr)
}
})
})
if !strings.Contains(out, "trashed\tfalse") || !strings.Contains(out, "deleted\ttrue") {
t.Fatalf("unexpected text output: %q", out)
}
jsonOut := captureStdout(t, func() {
_ = captureStderr(t, func() {
if execErr := Execute([]string{"--json", "--force", "--account", "a@b.com", "drive", "delete", "id1", "--permanent"}); execErr != nil {
t.Fatalf("Execute: %v", execErr)
}
})
})
var parsed struct {
Trashed bool `json:"trashed"`
Deleted bool `json:"deleted"`
ID string `json:"id"`
}
if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, jsonOut)
}
if parsed.Trashed || !parsed.Deleted || parsed.ID != "id1" {
t.Fatalf("unexpected json output: %#v", parsed)
}
if deleteCount != 2 {
t.Fatalf("expected 2 DELETE calls, got %d", deleteCount)
}
})
}

View File

@ -3,6 +3,7 @@ package cmd
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
@ -66,6 +67,9 @@ func TestExecute_DriveMoreCommands_JSON(t *testing.T) {
return
case strings.Contains(path, "/files/id1") && (r.Method == http.MethodPatch || r.Method == http.MethodPut):
w.Header().Set("Content-Type", "application/json")
if got := r.URL.Query().Get("supportsAllDrives"); got != "true" {
t.Fatalf("expected supportsAllDrives=true, got: %q (raw=%q)", got, r.URL.RawQuery)
}
if addParents := r.URL.Query().Get("addParents"); addParents != "" {
if addParents != "np" {
t.Fatalf("expected addParents=np, got: %q", r.URL.RawQuery)
@ -81,6 +85,14 @@ func TestExecute_DriveMoreCommands_JSON(t *testing.T) {
})
return
}
body, _ := io.ReadAll(r.Body)
if strings.Contains(string(body), "\"trashed\":true") {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "id1",
"trashed": true,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "id1",
"name": "New",
@ -234,6 +246,17 @@ func TestExecute_DriveMoreCommands_Text(t *testing.T) {
return
case strings.Contains(path, "/files/id1") && (r.Method == http.MethodPatch || r.Method == http.MethodPut):
w.Header().Set("Content-Type", "application/json")
if got := r.URL.Query().Get("supportsAllDrives"); got != "true" {
t.Fatalf("expected supportsAllDrives=true, got: %q (raw=%q)", got, r.URL.RawQuery)
}
body, _ := io.ReadAll(r.Body)
if strings.Contains(string(body), "\"trashed\":true") {
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "id1",
"trashed": true,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "id1",
"name": "New",