feat(drive): add read-only reporting commands (#554)

Co-authored-by: Rohan Patnaik <rohan-patnaik@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-05-05 05:36:55 +01:00 committed by GitHub
parent e8e1ac4635
commit e9c496efd5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 833 additions and 3 deletions

View File

@ -8,6 +8,7 @@
- Raw API dumps: add `docs raw`, `sheets raw`, `slides raw`, `drive raw`, `gmail raw`, `calendar raw`, `people raw`, `contacts raw`, `tasks raw`, and `forms raw` subcommands for lossless Google API JSON output, with `--pretty`, Drive raw redaction defaults, Sheets grid-data warnings, and a raw-output security audit. (#495, #496) — thanks @karbassi.
- Docs: add `docs format` and plain-text `docs write` formatting flags for fonts, colors, bold/italic/underline/strikethrough, alignment, and line spacing. (#479) — thanks @mmaghsoodnia.
- Drive: add `--fields` to `drive ls` and `drive get` so callers can pass Drive API field masks for fields beyond the default JSON set. (#495) — thanks @karbassi.
- Drive: add read-only `drive tree`, `drive du`, and `drive inventory` reports for auditing folder contents and sizes. (#116) — thanks @rohan-patnaik.
- Sheets: add `sheets table` list/get/create/delete commands for Google Sheets structured tables. (#470) — thanks @Pedrohgv.
- Agent safety: add baked safety-profile builds for fail-closed agent binaries, with `agent-safe`, `readonly`, and `full` profiles, filtered help/schema output, docs, and build tooling. (#366, #239) — thanks @drewburchfield.
- Calendar: add `--with-meet` to `calendar update` for adding Google Meet conferencing to existing events. (#538) — thanks @alexisperumal.

View File

@ -13,7 +13,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli
- **Calendar** - list/create/update/delete events, manage invitations, aliases, subscriptions, team calendars, free/busy/conflicts, propose new times, focus/OOO/working-location events, recurrence, and reminders
- **Classroom** - manage courses, roster, coursework/materials, submissions, announcements, topics, invitations, guardians, profiles
- **Chat** - list/find/create spaces, list messages/threads, send messages and DMs, and manage emoji reactions (Workspace-only)
- **Drive** - list/search/upload/download files, scope search to folders or shared drives, replace uploads in-place, convert uploads (including Markdown to Google Doc), manage permissions/comments, organize folders, and list shared drives
- **Drive** - list/search/upload/download files, inspect folders with tree/du/inventory reports, scope search to folders or shared drives, replace uploads in-place, convert uploads (including Markdown to Google Doc), manage permissions/comments, organize folders, and list shared drives
- **Contacts** - search/create/update contacts, including addresses, relations, org/title metadata, custom fields, Workspace directory, and other contacts
- **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, plus repeat schedule materialization with RRULE aliases
- **Sheets** - read/write/update spreadsheets, insert rows/cols, manage tabs, named ranges, and Sheets tables, format/merge/freeze/resize cells, manage conditional formatting and banding, read/write notes, inspect formats, find/replace text, list links, and create/export sheets
@ -1132,6 +1132,12 @@ gog drive get <fileId> # Get file metadata
gog drive url <fileId> # Print Drive web URL
gog drive copy <fileId> "Copy Name"
# Read-only reports
gog drive tree --parent <folderId> --depth 2
gog drive inventory --parent <folderId> --max 500 --sort modified --order desc
gog drive du --parent <folderId> --depth 1 --max 50
gog --json drive inventory --parent <folderId> --max 100 | jq '.items[] | {path,size,modifiedTime}'
# Upload and download
gog drive upload ./path/to/file --parent <folderId>
gog drive upload ./path/to/file --replace <fileId> # Replace file content in-place (preserves shared link)

View File

@ -245,7 +245,9 @@ Generated from `gog schema --json`.
- [`gog drive (drv) delete (rm,del) <fileId> [flags]`](commands/gog-drive-delete.md) - Move a file to trash (use --permanent to delete forever)
- [`gog drive (drv) download <fileId> [flags]`](commands/gog-drive-download.md) - Download a file (exports Google Docs formats)
- [`gog drive (drv) drives [flags]`](commands/gog-drive-drives.md) - List shared drives (Team Drives)
- [`gog drive (drv) du [flags]`](commands/gog-drive-du.md) - Summarize Drive folder sizes
- [`gog drive (drv) get <fileId> [flags]`](commands/gog-drive-get.md) - Get file metadata
- [`gog drive (drv) inventory [flags]`](commands/gog-drive-inventory.md) - Export a read-only Drive inventory
- [`gog drive (drv) ls [flags]`](commands/gog-drive-ls.md) - List files in a folder (default: root)
- [`gog drive (drv) mkdir <name> [flags]`](commands/gog-drive-mkdir.md) - Create a folder
- [`gog drive (drv) move <fileId> [flags]`](commands/gog-drive-move.md) - Move a file to a different folder
@ -254,6 +256,7 @@ Generated from `gog schema --json`.
- [`gog drive (drv) rename <fileId> <newName>`](commands/gog-drive-rename.md) - Rename a file or folder
- [`gog drive (drv) search <query> ... [flags]`](commands/gog-drive-search.md) - Full-text search across Drive
- [`gog drive (drv) share <fileId> [flags]`](commands/gog-drive-share.md) - Share a file or folder
- [`gog drive (drv) tree [flags]`](commands/gog-drive-tree.md) - Print a read-only folder tree
- [`gog drive (drv) unshare <fileId> <permissionId>`](commands/gog-drive-unshare.md) - Remove a permission from a file
- [`gog drive (drv) upload <localPath> [flags]`](commands/gog-drive-upload.md) - Upload a file
- [`gog drive (drv) url <fileId> ...`](commands/gog-drive-url.md) - Print web URLs for files

View File

@ -2,7 +2,7 @@
Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments.
Generated pages: 466.
Generated pages: 469.
## Top-level Commands
@ -288,7 +288,9 @@ Generated pages: 466.
- [gog drive delete](gog-drive-delete.md) - Move a file to trash (use --permanent to delete forever)
- [gog drive download](gog-drive-download.md) - Download a file (exports Google Docs formats)
- [gog drive drives](gog-drive-drives.md) - List shared drives (Team Drives)
- [gog drive du](gog-drive-du.md) - Summarize Drive folder sizes
- [gog drive get](gog-drive-get.md) - Get file metadata
- [gog drive inventory](gog-drive-inventory.md) - Export a read-only Drive inventory
- [gog drive ls](gog-drive-ls.md) - List files in a folder (default: root)
- [gog drive mkdir](gog-drive-mkdir.md) - Create a folder
- [gog drive move](gog-drive-move.md) - Move a file to a different folder
@ -297,6 +299,7 @@ Generated pages: 466.
- [gog drive rename](gog-drive-rename.md) - Rename a file or folder
- [gog drive search](gog-drive-search.md) - Full-text search across Drive
- [gog drive share](gog-drive-share.md) - Share a file or folder
- [gog drive tree](gog-drive-tree.md) - Print a read-only folder tree
- [gog drive unshare](gog-drive-unshare.md) - Remove a permission from a file
- [gog drive upload](gog-drive-upload.md) - Upload a file
- [gog drive url](gog-drive-url.md) - Print web URLs for files

View File

@ -0,0 +1,48 @@
# `gog drive du`
> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.
Summarize Drive folder sizes
## Usage
```bash
gog drive (drv) du [flags]
```
## Parent
- [gog drive](gog-drive.md)
## Flags
| Flag | Type | Default | Help |
| --- | --- | --- | --- |
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) |
| `--all-drives` | `bool` | true | Include shared drives (default: true; use --no-all-drives for My Drive only) |
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
| `--color` | `string` | auto | Color output: auto\|always\|never |
| `--depth` | `int` | 1 | Depth for folder totals |
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) |
| `-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. |
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--max` | `int` | 50 | Max folders to return (0 = unlimited) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--order` | `string` | desc | Sort order |
| `--parent` | `string` | | Folder ID to start from (default: root) |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `--sort` | `string` | size | Sort by size\|path\|files |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
## See Also
- [gog drive](gog-drive.md)
- [Command index](README.md)

View File

@ -0,0 +1,48 @@
# `gog drive inventory`
> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.
Export a read-only Drive inventory
## Usage
```bash
gog drive (drv) inventory [flags]
```
## Parent
- [gog drive](gog-drive.md)
## Flags
| Flag | Type | Default | Help |
| --- | --- | --- | --- |
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) |
| `--all-drives` | `bool` | true | Include shared drives (default: true; use --no-all-drives for My Drive only) |
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
| `--color` | `string` | auto | Color output: auto\|always\|never |
| `--depth` | `int` | 0 | Max depth (0 = unlimited) |
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) |
| `-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. |
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--max` | `int` | 500 | Max items to return (0 = unlimited) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--order` | `string` | asc | Sort order |
| `--parent` | `string` | | Folder ID to start from (default: root) |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `--sort` | `string` | path | Sort by path\|size\|modified |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
## See Also
- [gog drive](gog-drive.md)
- [Command index](README.md)

View File

@ -0,0 +1,46 @@
# `gog drive tree`
> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.
Print a read-only folder tree
## Usage
```bash
gog drive (drv) tree [flags]
```
## Parent
- [gog drive](gog-drive.md)
## Flags
| Flag | Type | Default | Help |
| --- | --- | --- | --- |
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) |
| `--all-drives` | `bool` | true | Include shared drives (default: true; use --no-all-drives for My Drive only) |
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
| `--color` | `string` | auto | Color output: auto\|always\|never |
| `--depth` | `int` | 2 | Max depth (0 = unlimited) |
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) |
| `-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. |
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--max` | `int` | 0 | Max items to return (0 = unlimited) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--parent` | `string` | | Folder ID to start from (default: root) |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
## See Also
- [gog drive](gog-drive.md)
- [Command index](README.md)

View File

@ -21,7 +21,9 @@ gog drive (drv) <command> [flags]
- [gog drive delete](gog-drive-delete.md) - Move a file to trash (use --permanent to delete forever)
- [gog drive download](gog-drive-download.md) - Download a file (exports Google Docs formats)
- [gog drive drives](gog-drive-drives.md) - List shared drives (Team Drives)
- [gog drive du](gog-drive-du.md) - Summarize Drive folder sizes
- [gog drive get](gog-drive-get.md) - Get file metadata
- [gog drive inventory](gog-drive-inventory.md) - Export a read-only Drive inventory
- [gog drive ls](gog-drive-ls.md) - List files in a folder (default: root)
- [gog drive mkdir](gog-drive-mkdir.md) - Create a folder
- [gog drive move](gog-drive-move.md) - Move a file to a different folder
@ -30,6 +32,7 @@ gog drive (drv) <command> [flags]
- [gog drive rename](gog-drive-rename.md) - Rename a file or folder
- [gog drive search](gog-drive-search.md) - Full-text search across Drive
- [gog drive share](gog-drive-share.md) - Share a file or folder
- [gog drive tree](gog-drive-tree.md) - Print a read-only folder tree
- [gog drive unshare](gog-drive-unshare.md) - Remove a permission from a file
- [gog drive upload](gog-drive-upload.md) - Upload a file
- [gog drive url](gog-drive-url.md) - Print web URLs for files

View File

@ -33,6 +33,8 @@ var (
)
const (
driveRootID = "root"
driveMimeFolder = "application/vnd.google-apps.folder"
driveMimeGoogleDoc = "application/vnd.google-apps.document"
driveMimeGoogleSheet = "application/vnd.google-apps.spreadsheet"
driveMimeGoogleSlides = "application/vnd.google-apps.presentation"
@ -70,6 +72,9 @@ const (
type DriveCmd struct {
Ls DriveLsCmd `cmd:"" name:"ls" help:"List files in a folder (default: root)"`
Search DriveSearchCmd `cmd:"" name:"search" help:"Full-text search across Drive"`
Tree DriveTreeCmd `cmd:"" name:"tree" help:"Print a read-only folder tree"`
Du DriveDuCmd `cmd:"" name:"du" help:"Summarize Drive folder sizes"`
Inventory DriveInventoryCmd `cmd:"" name:"inventory" help:"Export a read-only Drive inventory"`
Get DriveGetCmd `cmd:"" name:"get" help:"Get file metadata"`
Download DriveDownloadCmd `cmd:"" name:"download" help:"Download a file (exports Google Docs formats)"`
Copy DriveCopyCmd `cmd:"" name:"copy" help:"Copy a file"`
@ -945,7 +950,7 @@ func escapeDriveQueryString(s string) string {
}
func driveType(mimeType string) string {
if mimeType == "application/vnd.google-apps.folder" {
if mimeType == driveMimeFolder {
return "folder"
}
return strFile

View File

@ -0,0 +1,413 @@
package cmd
import (
"context"
"fmt"
"os"
"path"
"strings"
"google.golang.org/api/drive/v3"
gapi "google.golang.org/api/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
const driveDefaultPageSize = 1000
type DriveTreeCmd struct {
Parent string `name:"parent" help:"Folder ID to start from (default: root)"`
Depth int `name:"depth" help:"Max depth (0 = unlimited)" default:"2"`
Max int `name:"max" help:"Max items to return (0 = unlimited)" default:"0"`
AllDrives bool `name:"all-drives" help:"Include shared drives (default: true; use --no-all-drives for My Drive only)" default:"true" negatable:"_"`
}
func (c *DriveTreeCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
rootID := strings.TrimSpace(c.Parent)
if rootID == "" {
rootID = driveRootID
}
depth := c.Depth
if depth < 0 {
depth = 0
}
maxItems := c.Max
if maxItems < 0 {
maxItems = 0
}
_, svc, err := requireDriveService(ctx, flags)
if err != nil {
return err
}
items, truncated, err := listDriveTree(ctx, svc, driveTreeOptions{
RootID: rootID,
MaxDepth: depth,
MaxItems: maxItems,
Fields: driveTreeFields,
IncludeFiles: true,
IncludeFolder: true,
AllDrives: c.AllDrives,
})
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"items": items,
"truncated": truncated,
})
}
if len(items) == 0 {
u.Err().Println("No files")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "PATH\tTYPE\tSIZE\tMODIFIED\tID")
for _, it := range items {
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\n",
sanitizeTab(it.Path),
driveType(it.MimeType),
formatDriveSize(it.Size),
formatDateTime(it.ModifiedTime),
it.ID,
)
}
if truncated {
u.Err().Println("Results truncated; increase --max to see more.")
}
return nil
}
type DriveInventoryCmd struct {
Parent string `name:"parent" help:"Folder ID to start from (default: root)"`
Depth int `name:"depth" help:"Max depth (0 = unlimited)" default:"0"`
Max int `name:"max" help:"Max items to return (0 = unlimited)" default:"500"`
Sort string `name:"sort" help:"Sort by path|size|modified" enum:"path,size,modified" default:"path"`
Order string `name:"order" help:"Sort order" enum:"asc,desc" default:"asc"`
AllDrives bool `name:"all-drives" help:"Include shared drives (default: true; use --no-all-drives for My Drive only)" default:"true" negatable:"_"`
}
func (c *DriveInventoryCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
rootID := strings.TrimSpace(c.Parent)
if rootID == "" {
rootID = driveRootID
}
depth := c.Depth
if depth < 0 {
depth = 0
}
maxItems := c.Max
if maxItems < 0 {
maxItems = 0
}
_, svc, err := requireDriveService(ctx, flags)
if err != nil {
return err
}
items, truncated, err := listDriveTree(ctx, svc, driveTreeOptions{
RootID: rootID,
MaxDepth: depth,
MaxItems: maxItems,
Fields: driveInventoryFields,
IncludeFiles: true,
IncludeFolder: true,
AllDrives: c.AllDrives,
})
if err != nil {
return err
}
sortDriveInventory(items, c.Sort, c.Order)
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"items": items,
"truncated": truncated,
})
}
if len(items) == 0 {
u.Err().Println("No files")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "PATH\tTYPE\tSIZE\tMODIFIED\tOWNER\tID")
for _, it := range items {
owner := "-"
if len(it.Owners) > 0 {
owner = it.Owners[0]
}
fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\t%s\n",
sanitizeTab(it.Path),
driveType(it.MimeType),
formatDriveSize(it.Size),
formatDateTime(it.ModifiedTime),
owner,
it.ID,
)
}
if truncated {
u.Err().Println("Results truncated; increase --max to see more.")
}
return nil
}
type DriveDuCmd struct {
Parent string `name:"parent" help:"Folder ID to start from (default: root)"`
Depth int `name:"depth" help:"Depth for folder totals" default:"1"`
Max int `name:"max" help:"Max folders to return (0 = unlimited)" default:"50"`
Sort string `name:"sort" help:"Sort by size|path|files" enum:"size,path,files" default:"size"`
Order string `name:"order" help:"Sort order" enum:"asc,desc" default:"desc"`
AllDrives bool `name:"all-drives" help:"Include shared drives (default: true; use --no-all-drives for My Drive only)" default:"true" negatable:"_"`
}
func (c *DriveDuCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
rootID := strings.TrimSpace(c.Parent)
if rootID == "" {
rootID = driveRootID
}
depth := c.Depth
if depth < 0 {
depth = 0
}
maxItems := c.Max
if maxItems < 0 {
maxItems = 0
}
_, svc, err := requireDriveService(ctx, flags)
if err != nil {
return err
}
items, truncated, err := listDriveTree(ctx, svc, driveTreeOptions{
RootID: rootID,
MaxDepth: 0,
MaxItems: 0,
Fields: driveTreeFields,
IncludeFiles: true,
IncludeFolder: true,
AllDrives: c.AllDrives,
})
if err != nil {
return err
}
if truncated {
return fmt.Errorf("drive du truncated unexpectedly")
}
summaries := summarizeDriveDu(items, rootID, depth)
sortDriveDu(summaries, c.Sort, c.Order)
if maxItems > 0 && len(summaries) > maxItems {
summaries = summaries[:maxItems]
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"folders": summaries,
})
}
if len(summaries) == 0 {
u.Err().Println("No folders")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "PATH\tSIZE\tFILES")
for _, f := range summaries {
fmt.Fprintf(w, "%s\t%s\t%d\n", sanitizeTab(f.Path), formatDriveSize(f.Size), f.Files)
}
return nil
}
type driveTreeItem struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
ParentID string `json:"parentId,omitempty"`
MimeType string `json:"mimeType"`
Size int64 `json:"size,omitempty"`
ModifiedTime string `json:"modifiedTime,omitempty"`
Owners []string `json:"owners,omitempty"`
MD5 string `json:"md5,omitempty"`
Depth int `json:"depth"`
}
func (d driveTreeItem) IsFolder() bool {
return d.MimeType == driveMimeFolder
}
type driveTreeOptions struct {
RootID string
MaxDepth int
MaxItems int
Fields string
IncludeFiles bool
IncludeFolder bool
AllDrives bool
}
type driveFolderQueueItem struct {
ID string
Path string
Depth int
}
const (
driveTreeFields = "id,name,mimeType,size,modifiedTime"
driveInventoryFields = "id,name,mimeType,size,modifiedTime,owners(emailAddress,displayName)"
)
func listDriveTree(ctx context.Context, svc *drive.Service, opts driveTreeOptions) ([]driveTreeItem, bool, error) {
rootID := strings.TrimSpace(opts.RootID)
if rootID == "" {
rootID = driveRootID
}
fields := strings.TrimSpace(opts.Fields)
if fields == "" {
fields = driveTreeFields
}
queue := []driveFolderQueueItem{{ID: rootID, Path: "", Depth: 0}}
out := make([]driveTreeItem, 0, 128)
truncated := false
for len(queue) > 0 {
folder := queue[0]
queue = queue[1:]
children, err := listDriveChildren(ctx, svc, folder.ID, fields, opts.AllDrives)
if err != nil {
return nil, false, err
}
for _, child := range children {
if child == nil {
continue
}
depth := folder.Depth + 1
item := driveTreeItem{
ID: child.Id,
Name: child.Name,
Path: joinDrivePath(folder.Path, child.Name),
ParentID: folder.ID,
MimeType: child.MimeType,
Size: child.Size,
ModifiedTime: child.ModifiedTime,
Owners: driveOwners(child),
MD5: child.Md5Checksum,
Depth: depth,
}
if item.IsFolder() {
if opts.IncludeFolder {
out = append(out, item)
}
if opts.MaxDepth <= 0 || depth < opts.MaxDepth {
queue = append(queue, driveFolderQueueItem{ID: child.Id, Path: item.Path, Depth: depth})
}
} else if opts.IncludeFiles {
out = append(out, item)
}
if opts.MaxItems > 0 && len(out) >= opts.MaxItems {
truncated = true
return out, truncated, nil
}
}
}
return out, truncated, nil
}
func listDriveChildren(ctx context.Context, svc *drive.Service, parentID string, fields string, allDrives bool) ([]*drive.File, error) {
if parentID == "" {
parentID = driveRootID
}
q := buildDriveListQuery(parentID, "")
out := make([]*drive.File, 0, 64)
var pageToken string
for {
call := svc.Files.List().
Q(q).
PageSize(driveDefaultPageSize).
PageToken(pageToken).
OrderBy("folder,name")
call = driveFilesListCallWithDriveSupport(call, allDrives, "")
call = call.Fields(
gapi.Field("nextPageToken"),
gapi.Field("files("+fields+")"),
).Context(ctx)
resp, err := call.Do()
if err != nil {
return nil, err
}
out = append(out, resp.Files...)
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}
return out, nil
}
func joinDrivePath(parent string, name string) string {
name = sanitizeDriveName(name)
if parent == "" {
return name
}
return path.Join(parent, name)
}
func sanitizeDriveName(name string) string {
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "_"
}
return name
}
func driveOwners(f *drive.File) []string {
if f == nil || len(f.Owners) == 0 {
return nil
}
out := make([]string, 0, len(f.Owners))
for _, owner := range f.Owners {
if owner == nil {
continue
}
if owner.EmailAddress != "" {
out = append(out, owner.EmailAddress)
} else if owner.DisplayName != "" {
out = append(out, owner.DisplayName)
}
}
return out
}

View File

@ -0,0 +1,115 @@
package cmd
import (
"sort"
"strings"
)
type driveDuSummary struct {
ID string `json:"id"`
Path string `json:"path"`
Size int64 `json:"size"`
Files int `json:"files"`
Depth int `json:"depth"`
}
func summarizeDriveDu(items []driveTreeItem, rootID string, depthLimit int) []driveDuSummary {
type folderMeta struct {
path string
depth int
}
parentByID := map[string]string{}
folderMetaByID := map[string]folderMeta{
rootID: {path: ".", depth: 0},
}
for _, it := range items {
if it.IsFolder() {
parentByID[it.ID] = it.ParentID
folderMetaByID[it.ID] = folderMeta{path: it.Path, depth: it.Depth}
}
}
sizes := map[string]*driveDuSummary{}
getSummary := func(id string) *driveDuSummary {
if s, ok := sizes[id]; ok {
return s
}
meta := folderMetaByID[id]
s := &driveDuSummary{
ID: id,
Path: meta.path,
Depth: meta.depth,
}
sizes[id] = s
return s
}
for _, it := range items {
if it.IsFolder() {
continue
}
parentID := it.ParentID
for parentID != "" {
s := getSummary(parentID)
s.Size += it.Size
s.Files++
parentID = parentByID[parentID]
}
}
out := make([]driveDuSummary, 0, len(sizes))
for _, s := range sizes {
if depthLimit > 0 && s.Depth > depthLimit {
continue
}
out = append(out, *s)
}
return out
}
func sortDriveDu(items []driveDuSummary, sortBy string, order string) {
sortBy = strings.ToLower(strings.TrimSpace(sortBy))
order = strings.ToLower(strings.TrimSpace(order))
desc := order == "desc"
less := func(i, j int) bool { return false }
switch sortBy {
case "path":
less = func(i, j int) bool { return items[i].Path < items[j].Path }
case "files":
less = func(i, j int) bool { return items[i].Files < items[j].Files }
default:
less = func(i, j int) bool { return items[i].Size < items[j].Size }
}
sort.Slice(items, func(i, j int) bool {
if desc {
return less(j, i)
}
return less(i, j)
})
}
func sortDriveInventory(items []driveTreeItem, sortBy string, order string) {
sortBy = strings.ToLower(strings.TrimSpace(sortBy))
order = strings.ToLower(strings.TrimSpace(order))
desc := order == "desc"
less := func(i, j int) bool { return false }
switch sortBy {
case "size":
less = func(i, j int) bool { return items[i].Size < items[j].Size }
case "modified":
less = func(i, j int) bool { return items[i].ModifiedTime < items[j].ModifiedTime }
default:
less = func(i, j int) bool { return items[i].Path < items[j].Path }
}
sort.Slice(items, func(i, j int) bool {
if desc {
return less(j, i)
}
return less(i, j)
})
}

View File

@ -0,0 +1,139 @@
package cmd
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
func TestSanitizeDriveName(t *testing.T) {
cases := []struct {
in string
want string
}{
{in: "", want: "_"},
{in: ".", want: "_"},
{in: "..", want: "_"},
{in: "hello", want: "hello"},
{in: "a/b", want: "a_b"},
{in: "a\\b", want: "a_b"},
{in: " foo ", want: "foo"},
}
for _, tc := range cases {
if got := sanitizeDriveName(tc.in); got != tc.want {
t.Fatalf("sanitizeDriveName(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestJoinDrivePath(t *testing.T) {
if got := joinDrivePath("", "file"); got != "file" {
t.Fatalf("joinDrivePath empty = %q", got)
}
if got := joinDrivePath("dir", "file"); got != "dir/file" {
t.Fatalf("joinDrivePath dir = %q", got)
}
}
func TestSummarizeDriveDu(t *testing.T) {
items := []driveTreeItem{
{ID: "f1", Path: "a", ParentID: "root", MimeType: driveMimeFolder, Depth: 1},
{ID: "f2", Path: "a/b", ParentID: "f1", MimeType: driveMimeFolder, Depth: 2},
{ID: "file1", Path: "a/file.txt", ParentID: "f1", MimeType: "text/plain", Size: 10},
{ID: "file2", Path: "a/b/file2.txt", ParentID: "f2", MimeType: "text/plain", Size: 5},
}
summaries := summarizeDriveDu(items, "root", 1)
if len(summaries) == 0 {
t.Fatalf("expected summaries")
}
var rootSize int64
var aSize int64
for _, s := range summaries {
if s.Path == "." {
rootSize = s.Size
}
if s.Path == "a" {
aSize = s.Size
}
}
if rootSize != 15 {
t.Fatalf("root size = %d, want 15", rootSize)
}
if aSize != 15 {
t.Fatalf("a size = %d, want 15", aSize)
}
}
func TestExecuteDriveTreeJSON(t *testing.T) {
svc, closeSrv := newDriveTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || !strings.Contains(r.URL.Path, "/files") {
http.NotFound(w, r)
return
}
requireQuery(t, r, "supportsAllDrives", "true")
requireQuery(t, r, "includeItemsFromAllDrives", "true")
q := r.URL.Query().Get("q")
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(q, "'root' in parents"):
_ = json.NewEncoder(w).Encode(map[string]any{
"files": []map[string]any{
{
"id": "folder1",
"name": "Reports",
"mimeType": driveMimeFolder,
"modifiedTime": "2026-01-01T00:00:00Z",
},
{
"id": "file1",
"name": "root.txt",
"mimeType": "text/plain",
"size": "12",
"modifiedTime": "2026-01-02T00:00:00Z",
},
},
})
case strings.Contains(q, "'folder1' in parents"):
_ = json.NewEncoder(w).Encode(map[string]any{
"files": []map[string]any{
{
"id": "file2",
"name": "child.txt",
"mimeType": "text/plain",
"size": "5",
"modifiedTime": "2026-01-03T00:00:00Z",
},
},
})
default:
t.Fatalf("unexpected query: %q", q)
}
}))
defer closeSrv()
stubDriveServiceForTest(t, svc)
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@example.com", "drive", "tree", "--parent", "root", "--depth", "2"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Items []driveTreeItem `json:"items"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if len(parsed.Items) != 3 {
t.Fatalf("items len = %d, want 3: %#v", len(parsed.Items), parsed.Items)
}
if parsed.Items[2].Path != "Reports/child.txt" {
t.Fatalf("nested path = %q, want Reports/child.txt", parsed.Items[2].Path)
}
}