Compare commits

...

1 Commits

Author SHA1 Message Date
fangshuyu
336eeaf18e feat(drive): add quick mode to status diff
Add a best-effort --quick path to drive +status so repeated checks can compare local mtimes with Drive modified_time without downloading remote bytes. Keep the default exact hash mode intact, expose detection=exact|quick in output, and cover the quick semantics with unit, dry-run, and live E2E tests.
2026-05-14 14:16:38 +08:00
6 changed files with 544 additions and 23 deletions

View File

@@ -13,6 +13,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -26,8 +27,24 @@ type driveStatusEntry struct {
FileToken string `json:"file_token,omitempty"`
}
type driveStatusLocalFile struct {
PathToCwd string
ModTime time.Time
}
type driveStatusRemoteFile struct {
FileToken string
ModifiedTime string
}
const (
driveStatusDetectionExact = "exact"
driveStatusDetectionQuick = "quick"
)
// DriveStatus walks --local-dir, recursively lists --folder-token, and reports
// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash.
// four buckets (new_local, new_remote, modified, unchanged) either by exact
// SHA-256 hash (default) or by a quick modified_time comparison (--quick).
//
// Only Drive entries with type=file are compared; online docs (docx, sheet,
// bitable, mindnote, slides) and shortcuts are skipped because there is no
@@ -39,17 +56,19 @@ type driveStatusEntry struct {
var DriveStatus = common.Shortcut{
Service: "drive",
Command: "+status",
Description: "Compare a local directory with a Drive folder by content hash",
Description: "Compare a local directory with a Drive folder by exact hash or quick modified_time",
Risk: "read",
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "Drive folder token", Required: true},
{Name: "quick", Type: "bool", Desc: "compare modified_time only and skip remote downloads for files present on both sides"},
},
Tips: []string{
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
"Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.",
"Default detection=exact downloads files present on both sides and SHA-256 hashes them in memory; expect noticeable I/O on large folders.",
"Pass --quick for the recommended fast preflight mode: it compares local mtime with Drive modified_time, skips remote downloads, and reports detection=quick as a best-effort diff.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
@@ -80,14 +99,22 @@ var DriveStatus = common.Shortcut{
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
desc := "Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256."
if runtime.Bool("quick") {
desc = "Walk --local-dir, recursively list --folder-token, and compare local mtime with Drive modified_time for files present on both sides without downloading remote bytes."
}
return common.NewDryRunAPI().
Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256.").
Desc(desc).
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
detection := driveStatusDetectionExact
if runtime.Bool("quick") {
detection = driveStatusDetectionQuick
}
// Resolve --local-dir to its canonical absolute path before walking.
// SafeInputPath fully evaluates symlinks across the entire path,
@@ -112,7 +139,7 @@ var DriveStatus = common.Shortcut{
}
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical)
if err != nil {
return err
}
@@ -130,30 +157,42 @@ var DriveStatus = common.Shortcut{
// hashable bytes and are intentionally absent from the diff
// view (a docx living next to a same-named local file is a
// known no-op).
remoteFiles := make(map[string]string, len(entries))
remoteFiles := make(map[string]driveStatusRemoteFile, len(entries))
for _, entry := range entries {
if entry.Type == driveTypeFile {
remoteFiles[entry.RelPath] = entry.FileToken
remoteFiles[entry.RelPath] = driveStatusRemoteFile{FileToken: entry.FileToken, ModifiedTime: entry.ModifiedTime}
}
}
paths := mergeStatusPaths(localHashes, remoteFiles)
paths := mergeStatusPaths(localFiles, remoteFiles)
var newLocal, newRemote, modified, unchanged []driveStatusEntry
for _, relPath := range paths {
localHash, hasLocal := localHashes[relPath]
remoteToken, hasRemote := remoteFiles[relPath]
localFile, hasLocal := localFiles[relPath]
remoteFile, hasRemote := remoteFiles[relPath]
switch {
case hasLocal && !hasRemote:
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
case !hasLocal && hasRemote:
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken})
default:
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken)
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}
if detection == driveStatusDetectionQuick {
if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
continue
}
localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd)
if err != nil {
return err
}
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken)
if err != nil {
return err
}
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
if localHash == remoteHash {
unchanged = append(unchanged, entry)
} else {
@@ -163,6 +202,7 @@ var DriveStatus = common.Shortcut{
}
runtime.Out(map[string]interface{}{
"detection": detection,
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
@@ -180,8 +220,8 @@ var DriveStatus = common.Shortcut{
// hit, we report rel_path relative to root for the JSON output, and
// convert the absolute path to a cwd-relative form so FileIO.Open's
// SafeInputPath check (which rejects absolute paths) still applies.
func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) {
files := make(map[string]string)
func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalFile, error) {
files := make(map[string]driveStatusLocalFile)
// FileIO has no walker today and shortcuts can't import internal/vfs.
// The walk root is the canonical absolute path returned by
// validate.SafeInputPath, so it is no longer a symlink itself, and
@@ -202,11 +242,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin
if err != nil {
return err
}
sum, err := hashLocalForStatus(runtime, relToCwd)
info, err := d.Info()
if err != nil {
return err
}
files[filepath.ToSlash(rel)] = sum
files[filepath.ToSlash(rel)] = driveStatusLocalFile{PathToCwd: relToCwd, ModTime: info.ModTime()}
return nil
})
if err != nil {
@@ -215,6 +255,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin
return files, nil
}
func driveStatusShouldTreatAsUnchangedQuick(remoteModified string, local time.Time) bool {
cmp, ok := compareDriveRemoteModifiedToLocal(remoteModified, local)
return ok && cmp == 0
}
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
f, err := runtime.FileIO().Open(path)
if err != nil {
@@ -244,7 +289,7 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi
return hex.EncodeToString(h.Sum(nil)), nil
}
func mergeStatusPaths(local, remote map[string]string) []string {
func mergeStatusPaths(local map[string]driveStatusLocalFile, remote map[string]driveStatusRemoteFile) []string {
seen := make(map[string]struct{}, len(local)+len(remote))
for p := range local {
seen[p] = struct{}{}

View File

@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
@@ -105,6 +106,9 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
}
out := stdout.String()
if !strings.Contains(out, `"detection": "exact"`) {
t.Fatalf("output missing detection=exact\noutput: %s", out)
}
checks := []struct {
bucket string
path string
@@ -134,6 +138,152 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
reg.Verify(t)
}
func TestDriveStatusQuickCategorizesByModifiedTimeWithoutDownloads(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local/sub", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %v", err)
}
if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil {
t.Fatalf("WriteFile b.txt: %v", err)
}
if err := os.WriteFile("local/sub/c.txt", []byte("local-c"), 0o644); err != nil {
t.Fatalf("WriteFile sub/c.txt: %v", err)
}
matchTime := time.Unix(1715594880, 0)
changedTime := time.Unix(1715594940, 0)
if err := os.Chtimes("local/a.txt", matchTime, matchTime); err != nil {
t.Fatalf("Chtimes a.txt: %v", err)
}
if err := os.Chtimes("local/sub/c.txt", changedTime, changedTime); err != nil {
t.Fatalf("Chtimes sub/c.txt: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "1715594880"},
map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"},
map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file", "modified_time": "1715595000"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=tok_sub",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file", "modified_time": "1715594880"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--quick",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"detection": "quick"`) {
t.Fatalf("output missing detection=quick\noutput: %s", out)
}
checks := []struct {
bucket string
path string
token string
}{
{"new_local", "b.txt", ""},
{"new_remote", "d.txt", "tok_d"},
{"modified", "sub/c.txt", "tok_c"},
{"unchanged", "a.txt", "tok_a"},
}
for _, c := range checks {
if !strings.Contains(out, `"`+c.bucket+`":`) {
t.Errorf("output missing bucket %q\noutput: %s", c.bucket, out)
}
if !strings.Contains(out, `"rel_path": "`+c.path+`"`) {
t.Errorf("output missing rel_path %q (expected in %s)\noutput: %s", c.path, c.bucket, out)
}
if c.token != "" && !strings.Contains(out, `"file_token": "`+c.token+`"`) {
t.Errorf("output missing file_token %q (expected in %s)\noutput: %s", c.token, c.bucket, out)
}
}
reg.Verify(t)
}
func TestDriveStatusQuickMarksUntrustedTimestampAsModified(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile("local/a.txt", []byte("local"), 0o644); err != nil {
t.Fatalf("WriteFile a.txt: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "not-a-timestamp"},
},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--quick",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"detection": "quick"`) {
t.Fatalf("output missing detection=quick\noutput: %s", out)
}
if !strings.Contains(out, `"modified":`) || !strings.Contains(out, `"rel_path": "a.txt"`) {
t.Fatalf("invalid remote modified_time must fall back to modified\noutput: %s", out)
}
reg.Verify(t)
}
// TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`

View File

@@ -229,7 +229,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact|quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
按 SHA-256 内容哈希比较本地目录与飞书云空间文件夹,输出四类差异:
**精确 SHA-256**(默认)或 **快速 modified_time**`--quick`比较本地目录与飞书云空间文件夹,输出四类差异:
| 字段 | 含义 |
|------|------|
@@ -12,7 +12,10 @@
| `modified` | 双端都存在但 hash 不一致 |
| `unchanged` | 双端都存在且 hash 一致 |
只读命令:流式 hash不下载落盘但双端都有的文件会从云端拉一份字节流过来在内存里算 hash大目录 / 大文件会有可观的网络流量。
只读命令:
- 默认 `detection=exact`:双端都有的文件会从云端拉一份字节流过来在内存里算 hash不下载落盘但大目录 / 大文件会有可观的网络流量。
-`--quick``detection=quick`:只比较本地 mtime 与远端 `modified_time`**不下载远端文件内容**,适合先做快速预检查;它是 best-effort不等同于严格内容一致性判断。
## 远端同名文件冲突
@@ -26,6 +29,12 @@ lark-cli drive +status \
--local-dir ./repo \
--folder-token fldcnxxxxxxxxx
# 快速模式 —— 只比较 modified_time不下载远端文件内容
lark-cli drive +status \
--local-dir ./repo \
--folder-token fldcnxxxxxxxxx \
--quick
# 只看 hash 不一致的项(结合 --jq 过滤)
lark-cli drive +status \
--local-dir ./repo \
@@ -39,6 +48,7 @@ lark-cli drive +status \
|------|------|------|------|
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃逸到 cwd 外的相对路径会被 CLI 直接拒绝) |
| `--folder-token` | 是 | string | Drive 文件夹 token |
| `--quick` | 否 | bool | 快速模式:只比较本地 mtime 与远端 `modified_time`,跳过远端下载和 SHA-256 计算;输出里的 `detection` 会变成 `quick` |
## 输出 schema
@@ -46,6 +56,7 @@ lark-cli drive +status \
```json
{
"detection": "exact",
"new_local": [{"rel_path": "..."}],
"new_remote": [{"rel_path": "...", "file_token": "..."}],
"modified": [{"rel_path": "...", "file_token": "..."}],
@@ -53,6 +64,11 @@ lark-cli drive +status \
}
```
其中:
- `detection=exact`:默认模式,双端都有的文件会下载远端字节流并做 SHA-256 比较。
- `detection=quick``--quick` 模式,只按本地 mtime 与远端 `modified_time` 做 best-effort 判断。
`rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir``--folder-token` 的根。仅本地存在时没有 `file_token` 字段。
远端同名文件冲突时:
@@ -84,6 +100,7 @@ lark-cli drive +status \
- 子文件夹会递归遍历rel_path 形如 `sub1/sub2/file.txt`
- 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。
- 本地侧只比对常规文件regular file符号链接、设备文件等被忽略。
- `--quick` 模式下,双端都有的文件只在 **远端时间精度** 下比较 `modified_time` / 本地 mtime相等才记为 `unchanged`,否则记为 `modified`;远端时间戳缺失或非法时,走保守路径记为 `modified`,不会盲判 `unchanged`
## 范围限制
@@ -99,9 +116,10 @@ lark-cli drive +status \
## 性能注意
- `unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
- 默认 `detection=exact` 下,`unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
- `--quick` / `detection=quick` 下,不会下载双端共有文件的远端内容,执行时间更接近 `O(文件数量)`,而不是 `O(总文件大小)`
- 仅一侧存在的文件不会被下载。
- Hash 计算在内存里流式做io.Copy → sha256.New不会把云端文件落到磁盘。
- 默认模式的 hash 计算在内存里流式做io.Copy → sha256.New不会把云端文件落到磁盘。
## 所需 scope

View File

@@ -70,6 +70,50 @@ func TestDrive_StatusDryRun(t *testing.T) {
}
}
func TestDrive_StatusDryRunQuick(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--quick",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "modified_time") || strings.Contains(desc, "SHA-256") {
t.Fatalf("quick description must mention modified_time and skip SHA-256 wording, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_StatusDryRunRejectsAbsoluteLocalDir confirms that the
// --local-dir path validator runs in the real binary's Validate stage and
// surfaces a structured error referencing --local-dir (not the framework

View File

@@ -5,8 +5,10 @@ package drive
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"time"
@@ -183,4 +185,266 @@ func TestDrive_StatusWorkflow(t *testing.T) {
t.Errorf("data.%s length=%d want %d\nstdout:\n%s", b.bucket, got, b.want, out)
}
}
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
"--quick",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
quickResult.AssertExitCode(t, 0)
quickResult.AssertStdoutStatus(t, true)
quickOut := quickResult.Stdout
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
}
if got := int(gjson.Get(quickOut, "data.new_local.#").Int()); got != 1 {
t.Fatalf("quick new_local length=%d want 1\nstdout:\n%s", got, quickOut)
}
if got := int(gjson.Get(quickOut, "data.new_remote.#").Int()); got != 1 {
t.Fatalf("quick new_remote length=%d want 1\nstdout:\n%s", got, quickOut)
}
if got := gjson.Get(quickOut, "data.new_local.0.rel_path").String(); got != "local-only.txt" {
t.Fatalf("quick new_local path=%q want local-only.txt\nstdout:\n%s", got, quickOut)
}
if got := gjson.Get(quickOut, "data.new_remote.0.rel_path").String(); got != "remote-only.txt" {
t.Fatalf("quick new_remote path=%q want remote-only.txt\nstdout:\n%s", got, quickOut)
}
sharedCount := int(gjson.Get(quickOut, "data.modified.#").Int() + gjson.Get(quickOut, "data.unchanged.#").Int())
if sharedCount != 2 {
t.Fatalf("quick shared file count=%d want 2 across modified+unchanged\nstdout:\n%s", sharedCount, quickOut)
}
for _, path := range []string{"unchanged.txt", "modified.txt"} {
if !gjson.Get(quickOut, `data.modified.#(rel_path="`+path+`")`).Exists() && !gjson.Get(quickOut, `data.unchanged.#(rel_path="`+path+`")`).Exists() {
t.Fatalf("quick output missing shared path %q\nstdout:\n%s", path, quickOut)
}
}
}
// TestDrive_StatusQuickWorkflow proves that --quick really follows modified_time
// semantics on the live backend instead of silently behaving like the default
// exact hash mode.
//
// The fixture intentionally makes the two shared files diverge in opposite ways:
// - same-mtime.txt: bytes differ, mtime matches remote → quick=unchanged / exact=modified
// - remote-newer.txt: bytes match, local mtime is older → quick=modified / exact=unchanged
//
// This locks in the best-effort nature of quick mode with real Drive
// modified_time values fetched from the list API, plus the expected new_local /
// new_remote buckets.
func TestDrive_StatusQuickWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-status-quick-" + suffix
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
writeLocal := func(rel, content string) {
t.Helper()
full := filepath.Join(workDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
uploadDriveFile := func(name, content string) string {
t.Helper()
stage := "_upload_" + name
writeLocal(stage, content)
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stage,
"--folder-token", folderToken,
"--name", name,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
tokSameMtime := uploadDriveFile("same-mtime.txt", "remote bytes A")
tokRemoteNewer := uploadDriveFile("remote-newer.txt", "remote bytes B")
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote only")
remoteFiles := listDriveFolderFilesByName(t, ctx, folderToken)
sameMtimeRemote := remoteFiles["same-mtime.txt"]
remoteNewer := remoteFiles["remote-newer.txt"]
if sameMtimeRemote.ModifiedTime == "" || remoteNewer.ModifiedTime == "" {
t.Fatalf("expected modified_time for shared remote files, got: %#v", remoteFiles)
}
writeLocal("local/same-mtime.txt", "local bytes A") // bytes differ from remote
writeLocal("local/remote-newer.txt", "remote bytes B") // bytes match remote
writeLocal("local/local-only.txt", "local only") // local-only bucket
sameMtimePath := filepath.Join(workDir, "local", "same-mtime.txt")
remoteNewerPath := filepath.Join(workDir, "local", "remote-newer.txt")
sameMtimeAt := mustParseDriveEpochForE2E(t, sameMtimeRemote.ModifiedTime)
remoteNewerAt := mustParseDriveEpochForE2E(t, remoteNewer.ModifiedTime)
if err := os.Chtimes(sameMtimePath, sameMtimeAt, sameMtimeAt); err != nil {
t.Fatalf("chtimes same-mtime.txt: %v", err)
}
localOlder := remoteNewerAt.Add(-2 * time.Second)
if err := os.Chtimes(remoteNewerPath, localOlder, localOlder); err != nil {
t.Fatalf("chtimes remote-newer.txt: %v", err)
}
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
"--quick",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
quickResult.AssertExitCode(t, 0)
quickResult.AssertStdoutStatus(t, true)
quickOut := quickResult.Stdout
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
}
assertStatusBucketEntry(t, quickOut, "unchanged", "same-mtime.txt", tokSameMtime)
assertStatusBucketEntry(t, quickOut, "modified", "remote-newer.txt", tokRemoteNewer)
assertStatusBucketEntry(t, quickOut, "new_local", "local-only.txt", "")
assertStatusBucketEntry(t, quickOut, "new_remote", "remote-only.txt", tokRemoteOnly)
assertStatusBucketLen(t, quickOut, "unchanged", 1)
assertStatusBucketLen(t, quickOut, "modified", 1)
assertStatusBucketLen(t, quickOut, "new_local", 1)
assertStatusBucketLen(t, quickOut, "new_remote", 1)
exactResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
exactResult.AssertExitCode(t, 0)
exactResult.AssertStdoutStatus(t, true)
exactOut := exactResult.Stdout
if got := gjson.Get(exactOut, "data.detection").String(); got != "exact" {
t.Fatalf("exact detection=%q want exact\nstdout:\n%s", got, exactOut)
}
assertStatusBucketEntry(t, exactOut, "modified", "same-mtime.txt", tokSameMtime)
assertStatusBucketEntry(t, exactOut, "unchanged", "remote-newer.txt", tokRemoteNewer)
assertStatusBucketEntry(t, exactOut, "new_local", "local-only.txt", "")
assertStatusBucketEntry(t, exactOut, "new_remote", "remote-only.txt", tokRemoteOnly)
assertStatusBucketLen(t, exactOut, "unchanged", 1)
assertStatusBucketLen(t, exactOut, "modified", 1)
assertStatusBucketLen(t, exactOut, "new_local", 1)
assertStatusBucketLen(t, exactOut, "new_remote", 1)
}
type driveStatusListedFile struct {
Token string
ModifiedTime string
}
func listDriveFolderFilesByName(t *testing.T, ctx context.Context, folderToken string) map[string]driveStatusListedFile {
t.Helper()
params := fmt.Sprintf(`{"folder_token":"%s","page_size":200}`, folderToken)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "files", "list", "--params", params},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
files := make(map[string]driveStatusListedFile)
gjson.Get(result.Stdout, "data.files").ForEach(func(_, entry gjson.Result) bool {
name := entry.Get("name").String()
if name == "" {
return true
}
files[name] = driveStatusListedFile{
Token: entry.Get("token").String(),
ModifiedTime: entry.Get("modified_time").String(),
}
return true
})
return files
}
func mustParseDriveEpochForE2E(t *testing.T, raw string) time.Time {
t.Helper()
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
t.Fatalf("parse Drive epoch %q: %v", raw, err)
}
switch {
case v > 1e14 || v < -1e14:
return time.UnixMicro(v)
case v > 1e11 || v < -1e11:
return time.UnixMilli(v)
default:
return time.Unix(v, 0)
}
}
func assertStatusBucketEntry(t *testing.T, stdout, bucket, relPath, fileToken string) {
t.Helper()
entry := gjson.Get(stdout, `data.`+bucket+`.#(rel_path="`+relPath+`")`)
if !entry.Exists() {
t.Fatalf("bucket %s missing rel_path %q\nstdout:\n%s", bucket, relPath, stdout)
}
if fileToken == "" {
if got := entry.Get("file_token").String(); got != "" {
t.Fatalf("bucket %s rel_path %q unexpectedly carried file_token=%q\nstdout:\n%s", bucket, relPath, got, stdout)
}
return
}
if got := entry.Get("file_token").String(); got != fileToken {
t.Fatalf("bucket %s rel_path %q file_token=%q want %q\nstdout:\n%s", bucket, relPath, got, fileToken, stdout)
}
}
func assertStatusBucketLen(t *testing.T, stdout, bucket string, want int) {
t.Helper()
if got := int(gjson.Get(stdout, "data."+bucket+".#").Int()); got != want {
t.Fatalf("bucket %s length=%d want %d\nstdout:\n%s", bucket, got, want, stdout)
}
}