mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
Compare commits
1 Commits
feat/sidec
...
pr-870
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
336eeaf18e |
@@ -13,6 +13,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
|
||||||
@@ -26,8 +27,24 @@ type driveStatusEntry struct {
|
|||||||
FileToken string `json:"file_token,omitempty"`
|
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
|
// 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,
|
// Only Drive entries with type=file are compared; online docs (docx, sheet,
|
||||||
// bitable, mindnote, slides) and shortcuts are skipped because there is no
|
// bitable, mindnote, slides) and shortcuts are skipped because there is no
|
||||||
@@ -39,17 +56,19 @@ type driveStatusEntry struct {
|
|||||||
var DriveStatus = common.Shortcut{
|
var DriveStatus = common.Shortcut{
|
||||||
Service: "drive",
|
Service: "drive",
|
||||||
Command: "+status",
|
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",
|
Risk: "read",
|
||||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
|
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
|
||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||||
{Name: "folder-token", Desc: "Drive folder token", 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{
|
Tips: []string{
|
||||||
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
"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 {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||||
@@ -80,14 +99,22 @@ var DriveStatus = common.Shortcut{
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
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().
|
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").
|
GET("/open-apis/drive/v1/files").
|
||||||
Set("folder_token", runtime.Str("folder-token"))
|
Set("folder_token", runtime.Str("folder-token"))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
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.
|
// Resolve --local-dir to its canonical absolute path before walking.
|
||||||
// SafeInputPath fully evaluates symlinks across the entire path,
|
// 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)
|
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||||
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
|
localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -130,30 +157,42 @@ var DriveStatus = common.Shortcut{
|
|||||||
// hashable bytes and are intentionally absent from the diff
|
// hashable bytes and are intentionally absent from the diff
|
||||||
// view (a docx living next to a same-named local file is a
|
// view (a docx living next to a same-named local file is a
|
||||||
// known no-op).
|
// known no-op).
|
||||||
remoteFiles := make(map[string]string, len(entries))
|
remoteFiles := make(map[string]driveStatusRemoteFile, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.Type == driveTypeFile {
|
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
|
var newLocal, newRemote, modified, unchanged []driveStatusEntry
|
||||||
for _, relPath := range paths {
|
for _, relPath := range paths {
|
||||||
localHash, hasLocal := localHashes[relPath]
|
localFile, hasLocal := localFiles[relPath]
|
||||||
remoteToken, hasRemote := remoteFiles[relPath]
|
remoteFile, hasRemote := remoteFiles[relPath]
|
||||||
switch {
|
switch {
|
||||||
case hasLocal && !hasRemote:
|
case hasLocal && !hasRemote:
|
||||||
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
|
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
|
||||||
case !hasLocal && hasRemote:
|
case !hasLocal && hasRemote:
|
||||||
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
|
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken})
|
||||||
default:
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
|
|
||||||
if localHash == remoteHash {
|
if localHash == remoteHash {
|
||||||
unchanged = append(unchanged, entry)
|
unchanged = append(unchanged, entry)
|
||||||
} else {
|
} else {
|
||||||
@@ -163,6 +202,7 @@ var DriveStatus = common.Shortcut{
|
|||||||
}
|
}
|
||||||
|
|
||||||
runtime.Out(map[string]interface{}{
|
runtime.Out(map[string]interface{}{
|
||||||
|
"detection": detection,
|
||||||
"new_local": emptyIfNil(newLocal),
|
"new_local": emptyIfNil(newLocal),
|
||||||
"new_remote": emptyIfNil(newRemote),
|
"new_remote": emptyIfNil(newRemote),
|
||||||
"modified": emptyIfNil(modified),
|
"modified": emptyIfNil(modified),
|
||||||
@@ -180,8 +220,8 @@ var DriveStatus = common.Shortcut{
|
|||||||
// hit, we report rel_path relative to root for the JSON output, and
|
// 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
|
// convert the absolute path to a cwd-relative form so FileIO.Open's
|
||||||
// SafeInputPath check (which rejects absolute paths) still applies.
|
// SafeInputPath check (which rejects absolute paths) still applies.
|
||||||
func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) {
|
func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalFile, error) {
|
||||||
files := make(map[string]string)
|
files := make(map[string]driveStatusLocalFile)
|
||||||
// FileIO has no walker today and shortcuts can't import internal/vfs.
|
// FileIO has no walker today and shortcuts can't import internal/vfs.
|
||||||
// The walk root is the canonical absolute path returned by
|
// The walk root is the canonical absolute path returned by
|
||||||
// validate.SafeInputPath, so it is no longer a symlink itself, and
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
sum, err := hashLocalForStatus(runtime, relToCwd)
|
info, err := d.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
files[filepath.ToSlash(rel)] = sum
|
files[filepath.ToSlash(rel)] = driveStatusLocalFile{PathToCwd: relToCwd, ModTime: info.ModTime()}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,6 +255,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin
|
|||||||
return files, nil
|
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) {
|
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
|
||||||
f, err := runtime.FileIO().Open(path)
|
f, err := runtime.FileIO().Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -244,7 +289,7 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi
|
|||||||
return hex.EncodeToString(h.Sum(nil)), nil
|
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))
|
seen := make(map[string]struct{}, len(local)+len(remote))
|
||||||
for p := range local {
|
for p := range local {
|
||||||
seen[p] = struct{}{}
|
seen[p] = struct{}{}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -105,6 +106,9 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := stdout.String()
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, `"detection": "exact"`) {
|
||||||
|
t.Fatalf("output missing detection=exact\noutput: %s", out)
|
||||||
|
}
|
||||||
checks := []struct {
|
checks := []struct {
|
||||||
bucket string
|
bucket string
|
||||||
path string
|
path string
|
||||||
@@ -134,6 +138,152 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
|
|||||||
reg.Verify(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
|
// TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end
|
||||||
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
|
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
|
||||||
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`
|
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`
|
||||||
|
|||||||
@@ -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 |
|
| [`+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 |
|
| [`+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 |
|
| [`+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. |
|
| [`+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 |
|
| [`+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 |
|
| [`+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 |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||||
|
|
||||||
按 SHA-256 内容哈希比较本地目录与飞书云空间文件夹,输出四类差异:
|
按 **精确 SHA-256**(默认)或 **快速 modified_time**(`--quick`)比较本地目录与飞书云空间文件夹,输出四类差异:
|
||||||
|
|
||||||
| 字段 | 含义 |
|
| 字段 | 含义 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -12,7 +12,10 @@
|
|||||||
| `modified` | 双端都存在但 hash 不一致 |
|
| `modified` | 双端都存在但 hash 不一致 |
|
||||||
| `unchanged` | 双端都存在且 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 \
|
--local-dir ./repo \
|
||||||
--folder-token fldcnxxxxxxxxx
|
--folder-token fldcnxxxxxxxxx
|
||||||
|
|
||||||
|
# 快速模式 —— 只比较 modified_time,不下载远端文件内容
|
||||||
|
lark-cli drive +status \
|
||||||
|
--local-dir ./repo \
|
||||||
|
--folder-token fldcnxxxxxxxxx \
|
||||||
|
--quick
|
||||||
|
|
||||||
# 只看 hash 不一致的项(结合 --jq 过滤)
|
# 只看 hash 不一致的项(结合 --jq 过滤)
|
||||||
lark-cli drive +status \
|
lark-cli drive +status \
|
||||||
--local-dir ./repo \
|
--local-dir ./repo \
|
||||||
@@ -39,6 +48,7 @@ lark-cli drive +status \
|
|||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃逸到 cwd 外的相对路径会被 CLI 直接拒绝) |
|
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃逸到 cwd 外的相对路径会被 CLI 直接拒绝) |
|
||||||
| `--folder-token` | 是 | string | Drive 文件夹 token |
|
| `--folder-token` | 是 | string | Drive 文件夹 token |
|
||||||
|
| `--quick` | 否 | bool | 快速模式:只比较本地 mtime 与远端 `modified_time`,跳过远端下载和 SHA-256 计算;输出里的 `detection` 会变成 `quick` |
|
||||||
|
|
||||||
## 输出 schema
|
## 输出 schema
|
||||||
|
|
||||||
@@ -46,6 +56,7 @@ lark-cli drive +status \
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"detection": "exact",
|
||||||
"new_local": [{"rel_path": "..."}],
|
"new_local": [{"rel_path": "..."}],
|
||||||
"new_remote": [{"rel_path": "...", "file_token": "..."}],
|
"new_remote": [{"rel_path": "...", "file_token": "..."}],
|
||||||
"modified": [{"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` 字段。
|
`rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir` 或 `--folder-token` 的根。仅本地存在时没有 `file_token` 字段。
|
||||||
|
|
||||||
远端同名文件冲突时:
|
远端同名文件冲突时:
|
||||||
@@ -84,6 +100,7 @@ lark-cli drive +status \
|
|||||||
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`。
|
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`。
|
||||||
- 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。
|
- 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。
|
||||||
- 本地侧只比对常规文件(regular file);符号链接、设备文件等被忽略。
|
- 本地侧只比对常规文件(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
|
## 所需 scope
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// TestDrive_StatusDryRunRejectsAbsoluteLocalDir confirms that the
|
||||||
// --local-dir path validator runs in the real binary's Validate stage and
|
// --local-dir path validator runs in the real binary's Validate stage and
|
||||||
// surfaces a structured error referencing --local-dir (not the framework
|
// surfaces a structured error referencing --local-dir (not the framework
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ package drive
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user