Skip to content

feat(drive): add +pull shortcut for one-way Drive → local mirror#696

Open
fangshuyu-768 wants to merge 7 commits intomainfrom
feat/drive-pull
Open

feat(drive): add +pull shortcut for one-way Drive → local mirror#696
fangshuyu-768 wants to merge 7 commits intomainfrom
feat/drive-pull

Conversation

@fangshuyu-768
Copy link
Copy Markdown
Collaborator

@fangshuyu-768 fangshuyu-768 commented Apr 28, 2026

Summary

Adds drive +pull, a one-way Drive → local mirror command. Recursively lists --folder-token, downloads each type=file entry under the matching relative path in --local-dir, and optionally removes local files absent from the remote (mirror semantics).

Output is split into a summary (counts per action) plus items[] (per-file detail with rel_path, file_token, action, and error on failure).

This is the second of three P1 sync-disk commands in the design doc (4.1 +status is in #692; 4.3 +push is the natural follow-up).

Design notes

  • Pagination + recursion. Standard 200-per-page list of --folder-token; subfolders recurse. Subfolder structure is reproduced under --local-dir, with parent directories auto-created by FileIO.Save.
  • type=file only. Online docs (docx/sheet/bitable/mindnote/slides) and shortcuts are skipped — there is no equivalent local binary to write back, so they would otherwise pollute summary.failed or download empty placeholder bytes.
  • --if-exists controls per-file collision policy: overwrite (default) | skip. The framework's enum guard rejects any other value. No keep-both — callers who want it can rename and re-pull.
  • --delete-local is the only destructive flag and is bound to --yes in Validate: without --yes, the command refuses to start (no listing, no download, nothing). With --yes, downloads run first, then --local-dir is walked and any regular file not present in the remote map is os.Remove'd. This matches the spec doc's "high-risk-write" intent without making the default pull path require confirmation.
  • --local-dir is funneled through validate.SafeLocalFlagPath so errors reference --local-dir rather than the framework's default --file. FileIO().Stat then enforces existence and IsDir.
  • Scopes: drive:drive.metadata:readonly + drive:file:download. The broader drive:drive is disabled by enterprise policy in some tenants; this narrower pair was verified end-to-end.
  • Listing helper is duplicated locally (drivePullListRemote) instead of being reused from drive_status.go because that change still sits in open PR feat(drive): add +status shortcut for content-hash diff #692. Once feat(drive): add +status shortcut for content-hash diff #692 merges, both copies should be lifted into a shared package-level helper. TODO marker left in drive_pull.go.

Output shape

{
  "summary": {
    "downloaded": 0,
    "skipped": 0,
    "failed": 0,
    "deleted_local": 0
  },
  "items": [
    {"rel_path": "...", "file_token": "...", "action": "downloaded"},
    {"rel_path": "...", "file_token": "...", "action": "skipped"},
    {"rel_path": "...", "file_token": "...", "action": "failed", "error": "..."},
    {"rel_path": "...", "action": "deleted_local"},
    {"rel_path": "...", "action": "delete_failed", "error": "..."}
  ]
}

Test plan

Static checks

  • go build ./... clean
  • go vet ./... clean
  • golangci-lint run --new-from-rev=origin/main — 0 issues (only the same nolint:forbidigo justifications used in +status for the walker and a single os.Remove in --delete-local, with comments)
  • Skill format check passes
  • go test $(go list ./... | grep -v cli_e2e) -count=1 — all packages green

Unit tests (go test ./shortcuts/drive/... -run TestDrivePull)

  • TestDrivePullDownloadsAndCreatesParents — happy path with a nested subfolder; verifies docx entries are skipped and parent directories are auto-created
  • TestDrivePullSkipsExistingWhenSkipPolicy — pre-existing local file is preserved verbatim, summary.skipped counts it
  • TestDrivePullDeleteLocalRequiresYes--delete-local without --yes rejected upfront
  • TestDrivePullDeletesLocalOnlyFilesWhenYes--delete-local --yes removes both root-level orphan and orphan in a subdirectory
  • TestDrivePullRejectsAbsoluteLocalDir — error message references --local-dir
  • TestDrivePullRejectsBadIfExistsEnum — framework enum guard kicks in
  • TestShortcutsIncludesExpectedCommands updated to require +pull

E2E dry-run tests (tests/cli_e2e/drive/drive_pull_dryrun_test.go)

  • TestDrive_PullDryRun — request shape: GET /open-apis/drive/v1/files + folder_token + description text
  • TestDrive_PullDryRunRejectsAbsoluteLocalDir — Validate runs under --dry-run; --local-dir surfaced in error
  • TestDrive_PullDryRunRejectsDeleteLocalWithoutYes--delete-local guard works under dry-run too
  • TestDrive_PullDryRunRejectsMissingFolderToken — cobra required-flag enforcement

End-to-end against a real Drive folder (9 type=file entries across 4 levels)

Step Command Result
1 fresh pull (overwrite default) downloaded=9 skipped=0 failed=0 deleted_local=0; full tree replicated locally including docs/中文文档.md and images/logo.bin (PNG header bytes intact)
2 re-pull with --if-exists=skip downloaded=0 skipped=9 failed=0; existing local content preserved
3 --delete-local without --yes structured validation error: --delete-local requires --yes (high-risk: deletes local files absent from Drive)
4 --delete-local --yes after seeding stale-extra.txt and orphan-dir/file.txt downloaded=9 deleted_local=2; both orphans removed, final tree exactly matches remote

Summary by CodeRabbit

  • New Features

    • Added drive +pull: mirrors a Drive folder into a local directory with dry-run, conditional overwrite/skip (--if-exists), safe-path validation, and optional one-way local cleanup (destructive deletes require --yes). Emits per-item actions and aggregate summary counts.
  • Documentation

    • New user guide and reference documenting usage, flags, OAuth scopes, and path-safety guidance.
  • Tests

    • Added unit and end-to-end tests for listing/download flows, validation, dry-run, deletion semantics, symlink safety, and edge cases.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new Drive shortcut command +pull that mirrors Drive folder files (only type=="file") into a validated, canonical local directory, supports --if-exists and optional --delete-local (requires --yes), performs paginated Drive listing, downloads files deterministically, optionally deletes local orphans, and emits structured per-item output.

Changes

Cohort / File(s) Summary
Drive Pull Implementation
shortcuts/drive/drive_pull.go
New exported DrivePull shortcut (+pull) with flags --local-dir, --folder-token, --if-exists, --delete-local, --yes; validation (trim/require flags, path safety, --delete-local gating), canonical local root resolution, paginated /open-apis/drive/v1/files listing, deterministic sorted processing of type=file items, download streaming (/download), per-item result recording, and optional local orphan deletion walk.
Unit & Integration Tests
shortcuts/drive/drive_pull_test.go
Comprehensive tests for recursive pulls, type filtering, --if-exists overwrite vs skip, --delete-local gating and deletion behavior, symlink traversal/escape regressions, validation errors, and on-disk content assertions (mustReadFile).
Shortcuts Registry & Tests
shortcuts/drive/shortcuts.go, shortcuts/drive/shortcuts_test.go
Registers DrivePull in the drive shortcuts slice and updates expectations to include +pull.
E2E Dry-run Tests
tests/cli_e2e/drive/drive_pull_dryrun_test.go
Adds CLI dry-run tests asserting rendered request envelope targets Drive list endpoint and validates flag/validation errors for absolute --local-dir, missing --yes with --delete-local, and missing --folder-token.
Documentation
skills/lark-drive/SKILL.md, skills/lark-drive/references/lark-drive-pull.md
User guide and reference for drive +pull describing flags, --if-exists semantics, destructive --delete-local behavior (requires --yes), output schema, OAuth scopes, and path-safety rules limiting --local-dir to the CLI working tree.

Sequence Diagram

sequenceDiagram
    participant User
    participant CLI as CLI\n(+pull)
    participant DriveAPI as Drive API
    participant LocalFS as Local\nFilesystem

    User->>CLI: +pull --local-dir <path> --folder-token <token> [flags]
    CLI->>CLI: Validate flags, resolve safe local root

    CLI->>DriveAPI: GET /open-apis/drive/v1/files (paginated) with folder token
    DriveAPI-->>CLI: File & folder metadata (pages)
    CLI->>CLI: Build rel_path → file_token map (type == "file"), sort keys

    loop For each remote file (sorted)
        CLI->>LocalFS: stat target path
        alt missing or --if-exists=overwrite
            CLI->>DriveAPI: GET /download?file_token=<token>
            DriveAPI-->>CLI: File bytes stream
            CLI->>LocalFS: Write file (create parents)
            LocalFS-->>CLI: OK / Error
        else exists & --if-exists=skip
            CLI->>CLI: Record skipped
        end
    end

    alt --delete-local && --yes
        CLI->>LocalFS: Walk local root for regular files
        loop For each local file
            alt rel_path not in remote map
                CLI->>LocalFS: Delete file
                LocalFS-->>CLI: Deleted / Error
            end
        end
    end

    CLI->>User: Emit structured output (items array + summary counts)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • liujinkun2025
  • liangshuo-1

Poem

🐰 I hopped through folders, tokens bright,
I fetched the bytes by starlit night,
I checked each path, kept roots in sight,
With one small "yes" I cleaned the site,
A mirrored burrow — tidy and right.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a new +pull shortcut for one-way Drive to local mirroring, which is the primary focus of all file changes in the PR.
Description check ✅ Passed The PR description comprehensively covers all required template sections: Summary explains the feature and design intent; Changes section details implementation approach via design notes; Test Plan includes unit tests, E2E dry-run tests, and real Drive folder validation with tabular results. All required information is present and well-structured.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/drive-pull

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added domain/ccm PR touches the ccm domain size/L Large or sensitive change across domains or core paths labels Apr 28, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 28, 2026

Codecov Report

❌ Patch coverage is 67.45562% with 55 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.81%. Comparing base (0bbd0f2) to head (b8cfa76).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
shortcuts/drive/drive_pull.go 67.26% 33 Missing and 22 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #696      +/-   ##
==========================================
+ Coverage   63.55%   63.81%   +0.25%     
==========================================
  Files         497      501       +4     
  Lines       42455    43700    +1245     
==========================================
+ Hits        26984    27887     +903     
- Misses      13129    13350     +221     
- Partials     2342     2463     +121     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 28, 2026

🚀 PR Preview Install Guide

🧰 CLI update

npm i -g https://pkg.pr.new/larksuite/cli/@larksuite/cli@c1b0bed3aa8ea163d3f07db770f7458ea45eed3e

🧩 Skill update

npx skills add larksuite/cli#feat/drive-pull -y -g

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@shortcuts/drive/drive_pull.go`:
- Around line 143-152: The delete-local pass treats any local path not in
remoteFiles as orphaned, but remoteFiles only contains downloadable entries;
modify the logic in the delete branch (when deleteLocal is true) and in the
subsequent deletion section around drivePullListRemote usage (see
drivePullWalkLocal, remoteFiles, drivePullListRemote) to maintain two sets:
remoteFiles (downloadable entries/type=file) and remotePaths (all remote
relative paths regardless of type). Use remotePaths to decide which local files
to keep (skip deletion if rel exists in remotePaths) and reserve remoteFiles for
download/overwrite logic; update the checks in both the initial delete pass and
the later deletion loop (lines ~185-233 area) to reference remotePaths for
existence testing.
- Around line 143-156: Replace direct os.Remove and filepath.WalkDir usage with
the vfs abstraction: in the deletion loop where os.Remove(target) is called,
call vfs.Remove(target) instead; and change the implementation that gathers
localPaths (drivePullWalkLocal) to recursively traverse using vfs.ReadDir()
(iterating entries and recursing into subdirs) rather than filepath.WalkDir(),
so all file deletions and directory reads go through the mockable vfs layer;
ensure drivePullWalkLocal and the removal loop reference the vfs package's
Remove and ReadDir functions and keep the same semantics for relative paths and
error handling.

In `@tests/cli_e2e/drive/drive_pull_dryrun_test.go`:
- Around line 73-100: Update the dry-run assertions in
TestDrive_PullDryRunRejectsAbsoluteLocalDir (and the nearby dry-run test block
around lines 105-138) to follow the suite's dry-run contract: assert
result.ExitCode == 0 instead of non-zero, and check gjson.Get(result.Stdout,
"error") for a populated error field rather than relying on process exit code;
then ensure the error payload mentions "--local-dir" (use the existing result
variable and its Stdout/ Stderr to compose the check).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 75cdde8c-1557-4dfa-8fcf-3e5f8ab89908

📥 Commits

Reviewing files that changed from the base of the PR and between fc22e9a and ca04a0c.

📒 Files selected for processing (7)
  • shortcuts/drive/drive_pull.go
  • shortcuts/drive/drive_pull_test.go
  • shortcuts/drive/shortcuts.go
  • shortcuts/drive/shortcuts_test.go
  • skills/lark-drive/SKILL.md
  • skills/lark-drive/references/lark-drive-pull.md
  • tests/cli_e2e/drive/drive_pull_dryrun_test.go

Comment thread shortcuts/drive/drive_pull.go Outdated
Comment thread shortcuts/drive/drive_pull.go Outdated
Comment thread tests/cli_e2e/drive/drive_pull_dryrun_test.go
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
shortcuts/drive/drive_pull.go (2)

194-195: 🛠️ Refactor suggestion | 🟠 Major

Route the delete path through vfs as well.

The new delete flow still uses os.Remove and filepath.WalkDir on user-facing paths, which bypasses the repo filesystem abstraction the rest of the shortcut relies on for test mocking. Please switch this helper to vfs.Remove plus a small recursive walk built on vfs.ReadDir.

As per coding guidelines **/*.go: Use vfs.* functions instead of os.* for all filesystem access to enable test mocking.

Also applies to: 304-324

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/drive/drive_pull.go` around lines 194 - 195, The delete path
currently calls os.Remove and filepath.WalkDir on user paths (e.g., the branch
where absPath is removed and drivePullItem is appended) which bypasses the repo
VFS; change these to use vfs.Remove and replace the recursive filepath.WalkDir
logic with a small recursive walk implemented with vfs.ReadDir so all filesystem
operations go through the VFS abstraction. Specifically, update the code that
uses os.Remove(absPath) to call vfs.Remove(ctx, absPath) (or vfs.Remove(absPath)
depending on your vfs API) and rewrite any filepath.WalkDir-based recursion (the
block that walks and deletes children and populates items, including the
surrounding helper logic that uses rel, absPath, items, drivePullItem) to
iterate directories via vfs.ReadDir and recurse, preserving the same error
handling and drivePullItem creation.

134-135: ⚠️ Potential issue | 🔴 Critical

Use the full remote entry set for --delete-local.

drivePullListRemote only records downloadable type=file entries, but the delete pass treats that map as the source of truth. A remote docx / sheet / shortcut at the same relative path still looks orphaned locally and can be deleted under --delete-local, even though it is present in Drive.

Proposed fix
-		remoteFiles, err := drivePullListRemote(ctx, runtime, folderToken, "")
+		remoteFiles, remoteEntries, err := drivePullListRemote(ctx, runtime, folderToken, "")
 		if err != nil {
 			return err
 		}
@@
-				if _, ok := remoteFiles[rel]; ok {
+				if _, ok := remoteEntries[rel]; ok {
 					continue
 				}
@@
-func drivePullListRemote(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]string, error) {
+func drivePullListRemote(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]string, map[string]struct{}, error) {
 	files := make(map[string]string)
+	entries := make(map[string]struct{})
 	pageToken := ""
 	for {
@@
 		result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil)
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
@@
-			switch fType {
+			rel := drivePullJoinRel(relBase, fName)
+			entries[rel] = struct{}{}
+			switch fType {
 			case drivePullFileType:
-				files[drivePullJoinRel(relBase, fName)] = fToken
+				files[rel] = fToken
 			case drivePullFolderType:
-				subFiles, err := drivePullListRemote(ctx, runtime, fToken, drivePullJoinRel(relBase, fName))
+				subFiles, subEntries, err := drivePullListRemote(ctx, runtime, fToken, rel)
 				if err != nil {
-					return nil, err
+					return nil, nil, err
 				}
 				for k, v := range subFiles {
 					files[k] = v
 				}
+				for k := range subEntries {
+					entries[k] = struct{}{}
+				}
 			}
 		}
@@
-	return files, nil
+	return files, entries, nil
 }

Please add a --delete-local regression where Drive contains a non-file entry at the same relative path.

Also applies to: 176-189, 223-270

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/drive/drive_pull.go` around lines 134 - 135, The delete-local logic
is using the map returned by drivePullListRemote (assigned to remoteFiles) which
currently only contains downloadable entries (type=file), causing non-file
remote items (docx/sheet/shortcut) at the same relative path to be treated as
missing and deleted; update the code so the delete-local pass consults a full
remote-entry set instead of the filtered downloadable map: either change
drivePullListRemote to return all entries (or add an option like includeNonFile)
and use that full map for the delete-local check, or add a separate call (e.g.
drivePullListRemoteAll) and replace usages of remoteFiles in the delete-local
removal logic in drive_pull.go (and the other delete-local sections) so non-file
remote entries prevent local deletion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@shortcuts/drive/drive_pull.go`:
- Around line 181-196: The two branches that append drivePullItem with Action
"delete_failed" (the relErr branch that appends drivePullItem{RelPath: absPath,
Action: "delete_failed", Error: relErr.Error()} and the os.Remove error branch
that appends drivePullItem{RelPath: rel, Action: "delete_failed", Error:
err.Error()}) should also increment the overall failure counter used for the
JSON summary (the variable that tracks summary.failed). Update both locations to
increment that failure counter (e.g., summary.failed++ or the appropriate field
on the summary struct) immediately after appending the delete_failed item so the
final summary.failed reflects delete failures. Ensure you reference the same
summary variable used when building the JSON summary.

---

Duplicate comments:
In `@shortcuts/drive/drive_pull.go`:
- Around line 194-195: The delete path currently calls os.Remove and
filepath.WalkDir on user paths (e.g., the branch where absPath is removed and
drivePullItem is appended) which bypasses the repo VFS; change these to use
vfs.Remove and replace the recursive filepath.WalkDir logic with a small
recursive walk implemented with vfs.ReadDir so all filesystem operations go
through the VFS abstraction. Specifically, update the code that uses
os.Remove(absPath) to call vfs.Remove(ctx, absPath) (or vfs.Remove(absPath)
depending on your vfs API) and rewrite any filepath.WalkDir-based recursion (the
block that walks and deletes children and populates items, including the
surrounding helper logic that uses rel, absPath, items, drivePullItem) to
iterate directories via vfs.ReadDir and recurse, preserving the same error
handling and drivePullItem creation.
- Around line 134-135: The delete-local logic is using the map returned by
drivePullListRemote (assigned to remoteFiles) which currently only contains
downloadable entries (type=file), causing non-file remote items
(docx/sheet/shortcut) at the same relative path to be treated as missing and
deleted; update the code so the delete-local pass consults a full remote-entry
set instead of the filtered downloadable map: either change drivePullListRemote
to return all entries (or add an option like includeNonFile) and use that full
map for the delete-local check, or add a separate call (e.g.
drivePullListRemoteAll) and replace usages of remoteFiles in the delete-local
removal logic in drive_pull.go (and the other delete-local sections) so non-file
remote entries prevent local deletion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 64b476f6-b0f2-48db-b104-7a761301670e

📥 Commits

Reviewing files that changed from the base of the PR and between ca04a0c and 240b772.

📒 Files selected for processing (2)
  • shortcuts/drive/drive_pull.go
  • shortcuts/drive/drive_pull_test.go

Comment thread shortcuts/drive/drive_pull.go
Adds `drive +pull`, a one-way Drive → local mirror command. It
recursively lists --folder-token, downloads each type=file entry
into --local-dir at the matching relative path, and optionally
deletes local files absent from the remote (mirror semantics).

Implementation notes:

- Listing recurses through subfolders with the standard 200-page
  pagination loop. Online docs (docx, sheet, bitable, mindnote,
  slides) and shortcuts are skipped since there is no equivalent
  local binary to write back. Folder tree is reproduced under
  --local-dir, with parent directories auto-created by FileIO.Save.

- Per-file --if-exists=overwrite (default) | skip controls how
  pre-existing local files are treated; the framework's enum guard
  rejects any other value.

- --delete-local is the only destructive flag and is bound to --yes
  in Validate: --delete-local without --yes is rejected upfront so
  no listing or download even runs. --delete-local --yes performs
  downloads first, then walks --local-dir and removes regular files
  not present in the remote map. This matches the spec doc's
  "high-risk-write" intent for --delete-local without making the
  default pull path require confirmation.

- --local-dir is funneled through validate.SafeLocalFlagPath so
  errors reference --local-dir instead of the framework default
  --file. FileIO().Stat then enforces existence and IsDir.

- Scopes: drive:drive.metadata:readonly + drive:file:download. The
  broader drive:drive is disabled by enterprise policy in some
  tenants.

- Listing helper (drivePullListRemote) is duplicated locally rather
  than reused from drive_status.go because that change is still in
  open PR #692; once it merges, both can be lifted into a shared
  drive package helper. TODO marker is left in the code.

Tests cover six unit scenarios (happy-path with nested subfolder +
docx skipping, --if-exists=skip, --delete-local rejection without
--yes, --delete-local --yes deletes orphans, absolute-path
rejection, bad enum) and four E2E dry-run scenarios (request shape,
absolute path rejection, --delete-local --yes guard, missing
required flag).
Adds references/lark-drive-pull.md covering parameters, output schema
(summary + per-item action breakdown), the type=file scoping rule,
the --if-exists policy matrix, and the --delete-local + --yes safety
contract. Calls out the network-traffic caveat (pull is full-download,
unlike +status which only fetches when both sides have the file) and
the cwd boundary on --local-dir.

Wires +pull into the Shortcuts table in SKILL.md.
… escape

Same root cause as the +status fix: --local-dir was validated through
SafeLocalFlagPath but the walk used the user-supplied raw string.
SafeLocalFlagPath returns the original value (the canonical form is
discarded), and SafeInputPath itself relies on filepath.Clean for
normalization, which shrinks "link/.." to "." purely as string
manipulation. The kernel then resolves "link/.." through the symlink
target's parent at walk time, putting the traversal outside cwd.

For +pull the bug is more dangerous than for +status because it
travels through --delete-local --yes — a raw walk would let the
delete pass land on files outside cwd.

Fix:
- In Execute, resolve --local-dir via validate.SafeInputPath to get a
  canonical absolute path, and resolve "." the same way for cwd.
- Convert the resolved root back to a cwd-relative form
  (filepath.Rel) for download targets so FileIO.Save's existing
  SafeOutputPath check (which rejects absolute paths) still applies.
- For --delete-local, walk the canonical absolute root, then delete
  via the absolute path. Both values come from the validated
  safeRoot, so kernel path resolution cannot redirect a delete to a
  file outside the canonical subtree.
- drivePullWalkLocal now returns absolute paths instead of rel paths;
  the caller computes the rel_path via filepath.Rel against safeRoot
  for output / remote-set membership checks.

Adds TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef as a
regression: it stages an "escape" sibling directory containing a
sentinel file, adds a "link" symlink in cwd pointing into it, and
runs +pull --delete-local --yes against an empty remote with
--local-dir "link/..". The sentinel must survive (proving --delete
did not escape) and the in-cwd file must be removed (proving the
walk did run).
…ases

Adds three regressions on top of the canonical-root walk fix:

- TestDrivePullSkipsSymlinkInsideRoot: a child symlink inside the
  validated root pointing to a sibling temp dir. Under
  --delete-local --yes with an empty remote, the sentinel inside the
  target must survive (walker did not follow the child symlink) and
  the in-cwd file must be deleted (walker did run).

- TestDrivePullSurvivesCircularSymlinkInsideRoot: a child symlink
  pointing at one of its ancestors. The walk must terminate so the
  test does not hang on the per-test timeout.

- TestDrivePullDownloadDoesNotEscapeViaSymlinkParentRef: pins the
  download half of the fix. With --local-dir "link/.." the canonical
  root resolves to cwd, so the remote file must land in cwd, not
  inside the symlink target's parent. The preexisting sentinel inside
  the escape directory must remain untouched.
… by online docs

CodeRabbit (PR #696) flagged that the --delete-local pass treated any
local path missing from `remoteFiles` as orphaned, but `remoteFiles` only
records type=file entries. If Drive held a docx/sheet/shortcut at the
same rel_path as a local file, the local file would be unlinked even
though Drive still owned that path.

drivePullListRemote now returns two views:

  - files:    rel_path -> file_token, type=file only (download/skip set)
  - allPaths: every entry's rel_path regardless of type

The download loop continues to consume `files`; the --delete-local pass
consults `allPaths`, so an online-doc shadow of a local filename keeps
the local file safe.

Also routes the local walk and the delete through the vfs abstraction
(vfs.ReadDir + vfs.Remove) instead of filepath.WalkDir + os.Remove.
This drops the //nolint:forbidigo justifications and lines up with how
internal/keychain and internal/registry already do filesystem I/O. The
recursive vfs.ReadDir walker preserves the same "do not follow child
symlinks" semantics that filepath.WalkDir gave us, so the canonical-root
escape protections in 240b772 stay intact.

Adds TestDrivePullDeleteLocalPreservesLocalFileShadowedByOnlineDoc as a
direct regression: Drive serves keep.txt (file) plus notes.docx (docx),
local has both keep.txt and a hand-edited notes.docx; --delete-local
--yes must download keep.txt, leave notes.docx untouched, and report
deleted_local=0.
CodeRabbit (PR #696) flagged that both delete_failed branches in the
--delete-local pass appended an item but left the `failed` counter at
zero, so the JSON summary could legitimately report `"failed": 0` after
a partially-failed mirror. Increment failed in both branches (the
filepath.Rel error path and the vfs.Remove error path) so summary.failed
reflects every item flagged delete_failed in items[].

Adds TestDrivePullDeleteLocalCountsFailureInSummary, which forces
vfs.Remove to fail by chmod-ing the local dir 0o555 right before the
run and restoring 0o755 in t.Cleanup so t.TempDir teardown still works.
@fangshuyu-768
Copy link
Copy Markdown
Collaborator Author

Pushed two follow-up commits addressing the CodeRabbit review:

45fe4e3fix: --delete-local must not unlink local files shadowed by online docs
Critical comment was correct. drivePullListRemote now returns two views: files (rel_path → file_token, type=file only — drives downloads/skip) and allPaths (every entry's rel_path regardless of type — consulted by --delete-local). An online doc/shortcut at the same rel_path as a local file no longer makes the local file look orphaned. Same commit also routes the local walk and remove through vfs.ReadDir + vfs.Remove (replaces filepath.WalkDir + os.Remove), dropping the two nolint:forbidigo justifications. The recursive vfs.ReadDir walker preserves "do not follow child symlinks" so the canonical-root escape protections from 145c6a2 remain intact. Added TestDrivePullDeleteLocalPreservesLocalFileShadowedByOnlineDoc as the direct regression.

8e5adfdfix: count +pull delete failures in summary.failed
Also correct. Both delete_failed branches now increment failed so the summary stops misreporting failed=0 on partial mirrors. Added TestDrivePullDeleteLocalCountsFailureInSummary (forces vfs.Remove to fail via chmod 0o555 on the parent dir).

tests/cli_e2e/drive/drive_pull_dryrun_test.go exit-code contract — keeping as-is.
The 365-day cap example in drive_search_dryrun_test.go:258 follows exit=0 + stdout.error only because that validation runs inside the DryRun callback (it calls common.NewDryRunAPI().Set("error", ...)). For hard rejections in the Validate callback (cobra enum guards, custom rules), the existing convention in this suite is exit≠0 — see TestDriveSearchDryRun_RejectsInvalidSort (search.go:290) and TestDriveApplyPermissionDryRun_RejectsFullAccess (drive_apply_permission_dryrun_test.go:186). +pull's --local-dir and --delete-local checks live in Validate, so the existing tests follow the right contract for that hook.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
shortcuts/drive/drive_pull.go (3)

181-196: ⚠️ Potential issue | 🟠 Major

Count delete_failed in summary.failed.

Both delete-failure branches append an error item but leave the summary counter unchanged, so callers can see "failed": 0 after a partial cleanup failure.

💡 Proposed fix
 			for _, absPath := range localAbsPaths {
 				rel, relErr := filepath.Rel(safeRoot, absPath)
 				if relErr != nil {
 					items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
+					failed++
 					continue
 				}
 				rel = filepath.ToSlash(rel)
@@
 				if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
 					items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
+					failed++
 					continue
 				}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/drive/drive_pull.go` around lines 181 - 196, The delete-failure
branches add drivePullItem entries but don't increment the summary.failed
counter, so update both error paths in drive_pull.go (the relErr branch that
appends drivePullItem{RelPath: absPath, Action: "delete_failed", Error:
relErr.Error()} and the os.Remove error branch that appends
drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()}) to
also increment the summary.failed field (e.g., summary.failed++ or appropriate
exported field name) immediately after appending the item so the summary
accurately reflects failures.

176-194: 🛠️ Refactor suggestion | 🟠 Major

Route the destructive walk/delete path through the repo FS abstraction.

The download path already goes through runtime.FileIO(), but filepath.WalkDir and os.Remove bypass the mockable filesystem layer for a user-supplied path. That makes the most dangerous branch harder to test and breaks the repo’s filesystem-access rule.

As per coding guidelines, "Use vfs.* functions instead of os.* for all filesystem access to enable test mocking".

Also applies to: 304-321

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/drive/drive_pull.go` around lines 176 - 194, The destructive
local-walk and delete path currently uses filepath.WalkDir and os.Remove (in
drivePullWalkLocal and the loop that removes absPath), bypassing the repo's
mockable filesystem; replace those raw os/filepath usages with the repository
FileIO abstraction obtained from runtime.FileIO() (e.g., use the FileIO's
walk/ReadDir APIs and FileIO.Remove instead of filepath.WalkDir and os.Remove)
so the delete branch is routable through the vfs mock; update both
drivePullWalkLocal and the removal code paths (also the similar code around the
304-321 region) to call runtime.FileIO() and its methods.

134-150: ⚠️ Potential issue | 🔴 Critical

Don’t treat skipped remote entries as orphaned during --delete-local.

remoteFiles only tracks downloadable type=file entries, but the delete pass also uses it as the source of truth. With --delete-local --yes, a local path that matches a remote docx / sheet / shortcut is still treated as missing and gets deleted even though it still exists in Drive. Keep a separate remote-path set for skipped entries and use that for orphan detection; reserve remoteFiles for download tokens. Please add a regression test with the fix.

Also applies to: 176-189

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/drive/drive_pull.go` around lines 134 - 150, The delete-local logic
is treating skipped remote entries (non-file types like docx/sheet/shortcut) as
absent because remoteFiles only stores downloadable type=file tokens; create a
separate set (e.g., skippedRemotePaths or remotePathSet) that records all remote
paths encountered (including skipped types) while iterating remotePaths in the
drive pull flow (functions/vars: drivePullListRemote, remoteFiles, remotePaths,
drivePullItem), then use that full set for the orphan-detection / --delete-local
pass and keep remoteFiles reserved solely for download tokens; add a regression
test that simulates remote entries of non-file types and verifies --delete-local
does not remove matching local files.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@shortcuts/drive/drive_pull.go`:
- Around line 181-196: The delete-failure branches add drivePullItem entries but
don't increment the summary.failed counter, so update both error paths in
drive_pull.go (the relErr branch that appends drivePullItem{RelPath: absPath,
Action: "delete_failed", Error: relErr.Error()} and the os.Remove error branch
that appends drivePullItem{RelPath: rel, Action: "delete_failed", Error:
err.Error()}) to also increment the summary.failed field (e.g., summary.failed++
or appropriate exported field name) immediately after appending the item so the
summary accurately reflects failures.
- Around line 176-194: The destructive local-walk and delete path currently uses
filepath.WalkDir and os.Remove (in drivePullWalkLocal and the loop that removes
absPath), bypassing the repo's mockable filesystem; replace those raw
os/filepath usages with the repository FileIO abstraction obtained from
runtime.FileIO() (e.g., use the FileIO's walk/ReadDir APIs and FileIO.Remove
instead of filepath.WalkDir and os.Remove) so the delete branch is routable
through the vfs mock; update both drivePullWalkLocal and the removal code paths
(also the similar code around the 304-321 region) to call runtime.FileIO() and
its methods.
- Around line 134-150: The delete-local logic is treating skipped remote entries
(non-file types like docx/sheet/shortcut) as absent because remoteFiles only
stores downloadable type=file tokens; create a separate set (e.g.,
skippedRemotePaths or remotePathSet) that records all remote paths encountered
(including skipped types) while iterating remotePaths in the drive pull flow
(functions/vars: drivePullListRemote, remoteFiles, remotePaths, drivePullItem),
then use that full set for the orphan-detection / --delete-local pass and keep
remoteFiles reserved solely for download tokens; add a regression test that
simulates remote entries of non-file types and verifies --delete-local does not
remove matching local files.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 63cdb8e4-55c5-489c-94b7-c6972f88e3dd

📥 Commits

Reviewing files that changed from the base of the PR and between bc19471 and 0d8fb90.

📒 Files selected for processing (7)
  • shortcuts/drive/drive_pull.go
  • shortcuts/drive/drive_pull_test.go
  • shortcuts/drive/shortcuts.go
  • shortcuts/drive/shortcuts_test.go
  • skills/lark-drive/SKILL.md
  • skills/lark-drive/references/lark-drive-pull.md
  • tests/cli_e2e/drive/drive_pull_dryrun_test.go
✅ Files skipped from review due to trivial changes (3)
  • shortcuts/drive/shortcuts.go
  • shortcuts/drive/shortcuts_test.go
  • skills/lark-drive/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/cli_e2e/drive/drive_pull_dryrun_test.go

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@shortcuts/drive/drive_pull.go`:
- Line 18: This file illegally imports internal/vfs; remove the direct import
from drive_pull.go and instead call an allowed abstraction: either (A) extend
the existing runtime/file I/O interface used by shortcuts with the minimal
methods you need (walk, delete, open, stat) and call those methods from
DrivePull-related functions, or (B) extract the vfs-backed helper into a new
non-internal package (e.g., vfshelper) that exposes the exact operations used
and is importable by shortcuts. Replace all direct references to package vfs
(including the walker/delete usage around the regions mentioned and any calls in
the functions handling pull logic at the blocks near lines 196-199 and 341-359)
with calls to the new interface/helper methods (preserve method names like
Walk/Delete/Open/Stat so callers remain obvious) and update constructors to
accept the interface/helper instead of importing internal/vfs. Ensure
tests/consumers pass and CI no longer imports internal packages.
- Around line 221-230: The folder rel_path must be added to the allPaths set
before recursing into its children: currently only the descendant paths from the
subfolder are merged, so add allPaths[rel_path] (or append rel_path) immediately
when you detect a remote folder entry (the same place where you currently
recurse/merge descendants into files/allPaths), then recurse and merge the
child's allPaths; update the logic that builds files and allPaths to treat
folders this way (referencing the variables allPaths and files in the
folder-handling branch). Also add a regression test that creates a local regular
file named "sub" and a remote folder "sub/..." and runs the pull with
--delete-local --yes to assert the local file is not deleted (i.e., remote
folder path prevents deletion).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7ab76da6-dfce-4c6f-a899-ec0badb4a093

📥 Commits

Reviewing files that changed from the base of the PR and between 0d8fb90 and 8e5adfd.

📒 Files selected for processing (2)
  • shortcuts/drive/drive_pull.go
  • shortcuts/drive/drive_pull_test.go

Comment thread shortcuts/drive/drive_pull.go Outdated
Comment thread shortcuts/drive/drive_pull.go
…guard

The previous fix-up commits used vfs.ReadDir + vfs.Remove inside the
+pull shortcut, which depguard's "shortcuts-no-vfs" rule rejects:
shortcuts cannot import internal/vfs directly. CI lint failed on the
import line.

Restore the same pattern used in drive_status.go and the prior +pull
walker:

- filepath.WalkDir to enumerate files under the canonical absolute
  root, gated by //nolint:forbidigo with a comment explaining why.
- os.Remove for the actual delete, also gated by //nolint:forbidigo.

The canonical-root safety still holds: validate.SafeInputPath bounds
the walk root inside cwd before WalkDir runs, and WalkDir's default
"do not follow child symlinks" policy is preserved. The two earlier
fixes (drivePullListRemote returning allPaths so online-doc shadows
do not look orphaned, and incrementing failed on delete_failed) stay
in place.

`go test ./shortcuts/drive/...` and `golangci-lint run
--new-from-rev=origin/main` are both clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain/ccm PR touches the ccm domain size/L Large or sensitive change across domains or core paths

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant