Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## 0.27.2 - Unreleased

### Added

- Contacts: add guarded `contacts dedupe --apply` merging with exact dry-run plans, repeatable `--resource` scoping, confirmation, full updatable-field preservation, etag checks before deletion, and refusal of ambiguous or unmergeable groups. (#815) — thanks @privatenumber.

### Changed

- Gmail: show ordinary message bodies in full by default in text output, retain a generous cap for unusually large messages, and point truncated output to `--full` or `--json`. (#807) — thanks @privatenumber.
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,16 @@ Docs: [contacts dedupe](docs/contacts-dedupe.md),
gog contacts search alice --json
gog contacts export --all --out contacts.vcf

# Preview only: no merge/delete/update call is made.
# Preview by default.
gog contacts dedupe --json
gog contacts dedupe --match email,phone,name --dry-run
gog contacts dedupe --match email,phone,name

# Inspect the mutation plan, then apply with confirmation.
gog contacts dedupe --apply --dry-run --json
gog contacts dedupe --apply

# Scope automation to exact reviewed contact resources.
gog contacts dedupe --resource people/123 --resource people/456 --apply --force --json
```

### Docs
Expand Down
2 changes: 1 addition & 1 deletion docs/commands.generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ Generated from `gog schema --json`.
- [`gog config unset (rm,del,remove) <key>`](commands/gog-config-unset.md) - Unset a config value
- [`gog contacts (contact) <command> [flags]`](commands/gog-contacts.md) - Google Contacts
- [`gog contacts (contact) create (add,new) [flags]`](commands/gog-contacts-create.md) - Create a contact
- [`gog contacts (contact) dedupe [flags]`](commands/gog-contacts-dedupe.md) - Find likely duplicate contacts (preview only)
- [`gog contacts (contact) dedupe [flags]`](commands/gog-contacts-dedupe.md) - Find likely duplicate contacts and optionally merge them
- [`gog contacts (contact) delete (rm,del,remove) <resourceName>`](commands/gog-contacts-delete.md) - Delete a contact
- [`gog contacts (contact) directory <command>`](commands/gog-contacts-directory.md) - Directory contacts
- [`gog contacts (contact) directory list [flags]`](commands/gog-contacts-directory-list.md) - List people from the Workspace directory
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ Generated pages: 646.
- [gog config unset](gog-config-unset.md) - Unset a config value
- [gog contacts](gog-contacts.md) - Google Contacts
- [gog contacts create](gog-contacts-create.md) - Create a contact
- [gog contacts dedupe](gog-contacts-dedupe.md) - Find likely duplicate contacts (preview only)
- [gog contacts dedupe](gog-contacts-dedupe.md) - Find likely duplicate contacts and optionally merge them
- [gog contacts delete](gog-contacts-delete.md) - Delete a contact
- [gog contacts directory](gog-contacts-directory.md) - Directory contacts
- [gog contacts directory list](gog-contacts-directory-list.md) - List people from the Workspace directory
Expand Down
4 changes: 3 additions & 1 deletion docs/commands/gog-contacts-dedupe.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.

Find likely duplicate contacts (preview only)
Find likely duplicate contacts and optionally merge them

## Usage

Expand All @@ -20,6 +20,7 @@ gog contacts (contact) dedupe [flags]
| --- | --- | --- | --- |
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands |
| `--apply`<br>`--merge` | `bool` | | Merge duplicate groups and delete redundant contacts (requires confirmation) |
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
| `--color` | `string` | auto | Color output: auto\|always\|never |
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
Expand All @@ -36,6 +37,7 @@ gog contacts (contact) dedupe [flags]
| `--max`<br>`--limit` | `int64` | 0 | Max contacts to scan (0 = all) |
| `--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) |
| `--resource` | `[]string` | | Limit dedupe to exact contact resource names (people/...); repeatable |
| `--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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/commands/gog-contacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ gog contacts (contact) <command> [flags]
## Subcommands

- [gog contacts create](gog-contacts-create.md) - Create a contact
- [gog contacts dedupe](gog-contacts-dedupe.md) - Find likely duplicate contacts (preview only)
- [gog contacts dedupe](gog-contacts-dedupe.md) - Find likely duplicate contacts and optionally merge them
- [gog contacts delete](gog-contacts-delete.md) - Delete a contact
- [gog contacts directory](gog-contacts-directory.md) - Directory contacts
- [gog contacts export](gog-contacts-export.md) - Export contacts as vCard (.vcf)
Expand Down
81 changes: 72 additions & 9 deletions docs/contacts-dedupe.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# Contacts Dedupe Preview
# Contacts Dedupe

read_when:
- Finding duplicate Google Contacts.
- Reviewing or changing `gog contacts dedupe`.

`gog contacts dedupe` finds likely duplicate personal contacts and prints a
merge plan. It is preview-only: it does not merge, update, or delete contacts.
merge plan. Preview is the default; `--apply` performs the reviewed plan.

## Command Page

Expand All @@ -31,6 +31,49 @@ Name matching is opt-in because it can produce false positives:
gog contacts dedupe --match email,phone,name
```

## Apply A Merge

Review the plan first:

```bash
gog contacts dedupe --json
```

Then inspect the exact mutation plan without changing contacts:

```bash
gog contacts dedupe --apply --dry-run --json
```

For automation, copy the contact resource names from the reviewed preview and
scope both dry-run and apply to that exact set:

```bash
gog contacts dedupe \
--resource people/123 \
--resource people/456 \
--apply \
--dry-run \
--json
```

Apply interactively:

```bash
gog contacts dedupe --apply
```

Non-interactive automation must explicitly skip confirmation:

```bash
gog contacts dedupe \
--resource people/123 \
--resource people/456 \
--apply \
--force \
--json
```

## Output

The command groups contacts that share a matching key. JSON output includes:
Expand All @@ -42,16 +85,36 @@ The command groups contacts that share a matching key. JSON output includes:
- `matched_on`: duplicate email/phone/name keys that caused the group
- `members`: all contacts in the group

## Safety
Applied output also includes:

`contacts dedupe` is read-only. There is no apply flag.
- `applied`: whether mutations ran
- `groups_merged`: groups completed
- `contacts_deleted`: redundant contacts deleted
- `update_fields`: People API fields unioned into the primary contact
- `delete`: redundant contacts removed after their data was copied

Use `--dry-run` in automation anyway when you want a uniform safety habit across
commands:
## Safety

```bash
gog contacts dedupe --dry-run --json
```
`contacts dedupe` remains read-only unless `--apply` is present. Apply mode:

- requires confirmation unless `--force` is present
- honors `--dry-run`
- supports repeatable `--resource` scoping so automation can apply an exact
reviewed contact set
- reads contact-source data only
- refreshes each contact before planning
- updates the selected primary before deleting anything
- rechecks each redundant contact's etag immediately before deletion
- refuses groups with conflicting singleton fields (`names`, `birthdays`,
`biographies`, or `genders`)
- refuses secondary contacts with photos or other fields the People API cannot
preserve through `updateContact`

The People API does not expose Google Contacts' native merge operation. gog
therefore unions all API-updatable fields into the selected primary, then
deletes redundant contacts sequentially. If a request fails, the command stops
and reports completed groups/deletions; copied data remains on the primary and
undeleted contacts remain intact.

Use `--fail-empty` in scheduled checks when "no duplicates" should be reported
as a distinct exit code:
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ gog slides create-from-markdown "Weekly update" --content-file slides.md
- **Runtime discovery.** `gog schema --json` exposes command shape, stable exit codes, output modes, and effective safety state.
- **Multi-account, multi-client.** Many Google accounts and OAuth client projects in one config; OAuth, direct access tokens, ADC, and Workspace service accounts all supported.
- **One automation contract.** Humans, scripts, CI, and agents use the same commands, with JSON/TSV output, non-interactive operation, stable exit codes, untrusted-content wrapping, runtime command guards, and baked safety profiles.
- **Read-only audits.** Drive `tree`, `du`, `inventory`; Contacts `dedupe` preview; raw API JSON dumps without ever mutating remote state.
- **Preview-first audits.** Drive `tree`, `du`, `inventory`; Contacts `dedupe` previews by default and requires explicit `--apply` for guarded merges; raw API JSON dumps never mutate remote state.
- **Generated reference.** Every command has a docs page produced from `gog schema --json`.

## Pick your path
Expand Down
1 change: 1 addition & 0 deletions docs/live-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ available services. Recent-feature coverage includes:
- Drive shortcuts, revisions, and persisted changes polling.
- Gmail thread-aware drafts, attachment metadata preservation/clearing, and
explicit thread archive semantics.
- Contacts duplicate merge dry-run, apply, merged-field readback, and cleanup.
- CLI schema exit codes, Git-style help, output-mode precedence, and early
validation errors.

Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ after the bounded retry window, the command exits with retryable code `8`.
- `gog classroom guardian-invitations get <studentId> <invitationId>`
- `gog classroom guardian-invitations create <studentId> --email EMAIL`
- `gog classroom profile [userId]`
- `gog contacts dedupe [--match email,phone,name] [--max N]`
- `gog contacts dedupe [--match email,phone,name] [--max N] [--resource people/...] [--apply]`
- `gog gmail search <query> [--max N] [--page TOKEN]`
- `gog gmail messages search <query> [--max N] [--page TOKEN] [--include-body] [--body-format text|html] [--full]`
- `gog gmail autoreply <query> [--max N] [--subject S] [--body B|--body-file PATH|--body-html HTML] [--from addr] [--reply-to addr] [--label L] [--archive] [--mark-read] [--skip-bulk] [--allow-self]`
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type ContactsCmd struct {
List ContactsListCmd `cmd:"" name:"list" aliases:"ls" help:"List contacts"`
Get ContactsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a contact"`
Export ContactsExportCmd `cmd:"" name:"export" help:"Export contacts as vCard (.vcf)"`
Dedupe ContactsDedupeCmd `cmd:"" name:"dedupe" help:"Find likely duplicate contacts (preview only)"`
Dedupe ContactsDedupeCmd `cmd:"" name:"dedupe" help:"Find likely duplicate contacts and optionally merge them"`
Create ContactsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a contact"`
Update ContactsUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update a contact"`
Delete ContactsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a contact"`
Expand Down
85 changes: 77 additions & 8 deletions internal/cmd/contacts_dedupe.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import (
)

type ContactsDedupeCmd struct {
Match string `name:"match" help:"Match fields: email,phone,name" default:"email,phone"`
Max int64 `name:"max" aliases:"limit" help:"Max contacts to scan (0 = all)" default:"0"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no duplicates"`
Match string `name:"match" help:"Match fields: email,phone,name" default:"email,phone"`
Max int64 `name:"max" aliases:"limit" help:"Max contacts to scan (0 = all)" default:"0"`
Resources []string `name:"resource" help:"Limit dedupe to exact contact resource names (people/...); repeatable"`
Apply bool `name:"apply" aliases:"merge" help:"Merge duplicate groups and delete redundant contacts (requires confirmation)"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no duplicates"`
}

func (c *ContactsDedupeCmd) Run(ctx context.Context, flags *RootFlags) error {
Expand All @@ -31,24 +33,57 @@ func (c *ContactsDedupeCmd) Run(ctx context.Context, flags *RootFlags) error {
if c.Max < 0 {
return usage("--max must be >= 0")
}
resources, err := normalizeContactsDedupeResources(c.Resources)
if err != nil {
return err
}
if len(resources) > 0 && c.Max != 0 {
return usage("--max cannot be combined with --resource")
}

svc, err := peopleContactsService(ctx, account)
if err != nil {
return err
}
contacts, err := contactsDedupeList(ctx, svc, c.Max)
var contacts []*people.Person
if len(resources) > 0 {
contacts, err = contactsDedupeGetResources(ctx, svc, resources)
} else {
contacts, err = contactsDedupeList(ctx, svc, c.Max)
}
if err != nil {
return wrapPeopleAPIError(err)
}

groups := buildContactsDedupeGroups(contacts, match)
if err := writeContactsDedupe(ctx, u, groups, len(contacts)); err != nil {
return err
}
if len(groups) == 0 {
if writeErr := writeContactsDedupe(ctx, u, groups, len(contacts)); writeErr != nil {
return writeErr
}
return failEmptyExit(c.FailEmpty)
}
return nil
if !c.Apply {
return writeContactsDedupe(ctx, u, groups, len(contacts))
}

plans, err := prepareContactsDedupeApply(ctx, svc, groups, match)
if err != nil {
return err
}
deleteCount := contactsDedupeDeleteCount(plans)
if u != nil {
u.Err().Linef("Prepared %d duplicate group(s); %d redundant contact(s) will be deleted", len(plans), deleteCount)
}
if confirmErr := dryRunAndConfirmDestructive(ctx, flags, "contacts.dedupe.apply", contactsDedupeApplyPayload(len(contacts), plans),
contactsDedupeApplyAction(len(plans), deleteCount)); confirmErr != nil {
return confirmErr
}

result, err := applyContactsDedupePlans(ctx, svc, len(contacts), plans)
if err != nil {
return err
}
return writeContactsDedupeApplyResult(ctx, u, result)
}

type contactsDedupeMatch struct {
Expand Down Expand Up @@ -79,6 +114,39 @@ func parseContactsDedupeMatch(value string) (contactsDedupeMatch, error) {
return out, nil
}

func normalizeContactsDedupeResources(values []string) ([]string, error) {
seen := map[string]bool{}
out := make([]string, 0, len(values))
for _, value := range values {
resource := strings.TrimSpace(value)
if !strings.HasPrefix(resource, "people/") || len(resource) == len("people/") {
return nil, usagef("invalid --resource %q (expected people/...)", value)
}
if seen[resource] {
continue
}
seen[resource] = true
out = append(out, resource)
}
return out, nil
}

func contactsDedupeGetResources(ctx context.Context, svc *people.Service, resources []string) ([]*people.Person, error) {
contacts := make([]*people.Person, 0, len(resources))
for _, resource := range resources {
person, err := svc.People.Get(resource).
PersonFields(contactsReadMask).
Sources(contactsDedupeContactSource).
Context(ctx).
Do()
if err != nil {
return nil, err
}
contacts = append(contacts, person)
}
return contacts, nil
}

func contactsDedupeList(ctx context.Context, svc *people.Service, maxResults int64) ([]*people.Person, error) {
var out []*people.Person
pageToken := ""
Expand All @@ -92,6 +160,7 @@ func contactsDedupeList(ctx context.Context, svc *people.Service, maxResults int
PageSize(pageSize).
PageToken(pageToken).
RequestSyncToken(false).
Sources("READ_SOURCE_TYPE_CONTACT").
Context(ctx).
Do()
if err != nil {
Expand Down
Loading
Loading