Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ require (
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
Expand Down
16 changes: 16 additions & 0 deletions shortcuts/drive/drive_pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ var DrivePull = common.Shortcut{
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
{Name: "ext", Type: "string_slice", Desc: "only include files with these extensions (e.g. md,mdx)"},
{Name: "include", Type: "string_slice", Desc: "include only files whose rel_path matches these glob patterns"},
{Name: "exclude", Type: "string_slice", Desc: "exclude files whose rel_path matches these glob patterns"},
{Name: "if-exists", Desc: "policy when a local file already exists (skip = never touch existing files; smart = skip when local mtime is already up to date; overwrite = always replace)", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSmart, drivePullIfExistsSkip}},
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteRename, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); requires --yes"},
Expand All @@ -83,6 +86,7 @@ var DrivePull = common.Shortcut{
"For repeat syncs, --if-exists=smart is the recommended best-effort incremental mode: it compares local mtime with Drive modified_time and skips downloads when the local copy is already up to date.",
"Duplicate remote rel_path conflicts fail by default. Use --on-duplicate-remote=rename to download duplicate files with stable hashed suffixes.",
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
"Filter precedence is CLI flags (--ext/--include/--exclude) > .larkignore > built-in excludes such as .git/ and .lark-sync/.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
Expand All @@ -109,6 +113,9 @@ var DrivePull = common.Shortcut{
if runtime.Bool("delete-local") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)")
}
if _, err := buildDriveSyncFilter(runtime, localDir); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
Expand All @@ -129,6 +136,10 @@ var DrivePull = common.Shortcut{
duplicateRemote = driveDuplicateRemoteFail
}
deleteLocal := runtime.Bool("delete-local")
filter, err := buildDriveSyncFilter(runtime, localDir)
if err != nil {
return err
}

// Resolve --local-dir to its canonical absolute path before we
// touch the filesystem. SafeInputPath fully evaluates symlinks
Expand Down Expand Up @@ -162,6 +173,7 @@ var DrivePull = common.Shortcut{
if err != nil {
return err
}
entries = filterDriveRemoteEntries(entries, filter)
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
Expand Down Expand Up @@ -248,6 +260,10 @@ var DrivePull = common.Shortcut{
if err != nil {
return err
}
localAbsPaths, err = filterDrivePullLocalAbsPaths(safeRoot, localAbsPaths, filter)
if err != nil {
return err
}
for _, absPath := range localAbsPaths {
rel, relErr := filepath.Rel(safeRoot, absPath)
if relErr != nil {
Expand Down
54 changes: 54 additions & 0 deletions shortcuts/drive/drive_pull_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,60 @@ func TestDrivePullDownloadsAndCreatesParents(t *testing.T) {
mustReadFile(t, filepath.Join("local", "sub", "b.txt"), "BBB")
}

func TestDrivePullFiltersRemoteFilesAndScopesDeleteLocalToFilteredView(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(filepath.Join("local", "keep.md"), []byte("old"), 0o644); err != nil {
t.Fatalf("WriteFile keep.md: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "skip.txt"), []byte("stay"), 0o644); err != nil {
t.Fatalf("WriteFile skip.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_keep", "name": "keep.md", "type": "file"},
map[string]interface{}{"token": "tok_skip", "name": "skip.txt", "type": "file"},
}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_keep/download",
Status: 200,
Body: []byte("new-md"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})

err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--ext", "md",
"--delete-local",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
mustReadFile(t, filepath.Join("local", "keep.md"), "new-md")
mustReadFile(t, filepath.Join("local", "skip.txt"), "stay")
out := stdout.String()
if strings.Contains(out, "skip.txt") {
t.Fatalf("filtered-out file should not appear in stdout: %s", out)
}
}

// TestDrivePullSkipsExistingWhenSkipPolicy verifies --if-exists=skip leaves
// existing local files untouched and counts them under summary.skipped.
func TestDrivePullSkipsExistingWhenSkipPolicy(t *testing.T) {
Expand Down
15 changes: 14 additions & 1 deletion shortcuts/drive/drive_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ var DrivePush = common.Shortcut{
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
{Name: "ext", Type: "string_slice", Desc: "only include files with these extensions (e.g. md,mdx)"},
{Name: "include", Type: "string_slice", Desc: "include only files whose rel_path matches these glob patterns"},
{Name: "exclude", Type: "string_slice", Desc: "exclude files whose rel_path matches these glob patterns"},
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (skip = never touch existing remote files; smart = skip when remote modified_time already matches or is newer, otherwise fall through to overwrite semantics; overwrite = always replace)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSmart, drivePushIfExistsSkip}},
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
Expand All @@ -107,6 +110,7 @@ var DrivePush = common.Shortcut{
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
"Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.",
"Filter precedence is CLI flags (--ext/--include/--exclude) > .larkignore > built-in excludes such as .git/ and .lark-sync/.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
Expand Down Expand Up @@ -150,6 +154,9 @@ var DrivePush = common.Shortcut{
return err
}
}
if _, err := buildDriveSyncFilter(runtime, localDir); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
Expand All @@ -174,6 +181,10 @@ var DrivePush = common.Shortcut{
duplicateRemote = driveDuplicateRemoteFail
}
deleteRemote := runtime.Bool("delete-remote")
filter, err := buildDriveSyncFilter(runtime, localDir)
if err != nil {
return err
}

// Resolve --local-dir to its canonical absolute path before walking.
// SafeInputPath fully evaluates symlinks across the entire path,
Expand All @@ -193,16 +204,18 @@ var DrivePush = common.Shortcut{
}

fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
localFiles, walkedDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
if err != nil {
return err
}
localFiles, localDirs := filterDrivePushLocalView(localFiles, walkedDirs, filter)

fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
if err != nil {
return err
}
entries = filterDriveRemoteEntries(entries, filter)
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
Expand Down
70 changes: 70 additions & 0 deletions shortcuts/drive/drive_push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,76 @@ func TestDrivePushUploadsAndCreatesParents(t *testing.T) {
}
}

func TestDrivePushFiltersLocalFilesAndScopesDeleteRemoteToFilteredView(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())

tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll(filepath.Join("local", "docs"), 0o755); err != nil {
t.Fatalf("MkdirAll docs: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "docs", "keep.md"), []byte("KEEP"), 0o644); err != nil {
t.Fatalf("WriteFile keep.md: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "skip.txt"), []byte("SKIP"), 0o644); err != nil {
t.Fatalf("WriteFile skip.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_keep_remote", "name": "docs", "type": "folder"},
map[string]interface{}{"token": "tok_skip_remote", "name": "skip.txt", "type": "file"},
}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=tok_keep_remote",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{
map[string]interface{}{"token": "tok_keep_existing", "name": "keep.md", "type": "file"},
}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "tok_uploaded", "version": "v2"},
},
})

err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--ext", "md",
"--if-exists", "overwrite",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"uploaded": 1`) {
t.Fatalf("expected uploaded=1, got: %s", out)
}
if strings.Contains(out, "skip.txt") {
t.Fatalf("filtered-out file should not appear in stdout: %s", out)
}
if strings.Contains(out, `"deleted_remote": 1`) {
t.Fatalf("filtered-out remote file must not be deleted: %s", out)
}
}

// TestDrivePushOverwritesWhenIfExistsOverwrite verifies that a local file
// whose rel_path already maps to a type=file on Drive is sent through
// upload_all with the existing file_token in the form body, and that the
Expand Down
13 changes: 13 additions & 0 deletions shortcuts/drive/drive_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,15 @@ var DriveStatus = common.Shortcut{
{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"},
{Name: "ext", Type: "string_slice", Desc: "only include files with these extensions (e.g. md,mdx)"},
{Name: "include", Type: "string_slice", Desc: "include only files whose rel_path matches these glob patterns"},
{Name: "exclude", Type: "string_slice", Desc: "exclude files whose rel_path matches these glob patterns"},
},
Tips: []string{
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
"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.",
"Filter precedence is CLI flags (--ext/--include/--exclude) > .larkignore > built-in excludes such as .git/ and .lark-sync/.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
Expand Down Expand Up @@ -109,6 +113,9 @@ var DriveStatus = common.Shortcut{
return err
}
}
if _, err := buildDriveSyncFilter(runtime, localDir); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
Expand All @@ -128,6 +135,10 @@ var DriveStatus = common.Shortcut{
if runtime.Bool("quick") {
detection = driveStatusDetectionQuick
}
filter, err := buildDriveSyncFilter(runtime, localDir)
if err != nil {
return err
}

// Resolve --local-dir to its canonical absolute path before walking.
// SafeInputPath fully evaluates symlinks across the entire path,
Expand Down Expand Up @@ -156,12 +167,14 @@ var DriveStatus = common.Shortcut{
if err != nil {
return err
}
localFiles = filterDriveStatusLocalFiles(localFiles, filter)

fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
if err != nil {
return err
}
entries = filterDriveRemoteEntries(entries, filter)
if duplicates := duplicateRemoteFilePaths(entries); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
Expand Down
54 changes: 54 additions & 0 deletions shortcuts/drive/drive_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,60 @@ func TestDriveStatusQuickCategorizesByModifiedTimeWithoutDownloads(t *testing.T)
reg.Verify(t)
}

func TestDriveStatusFiltersLocalAndRemoteByExt(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.md", []byte("aaa"), 0o644); err != nil {
t.Fatalf("WriteFile a.md: %v", err)
}
if err := os.WriteFile("local/b.txt", []byte("bbb"), 0o644); err != nil {
t.Fatalf("WriteFile b.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.md", "type": "file"},
map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file"},
}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/tok_a/download",
Status: 200,
Body: []byte("aaa"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})

err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--ext", "md",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}

out := stdout.String()
if !strings.Contains(out, `"a.md"`) {
t.Fatalf("expected a.md in status output: %s", out)
}
if strings.Contains(out, `"b.txt"`) || strings.Contains(out, `"c.txt"`) {
t.Fatalf("filtered-out txt files should not appear in output: %s", out)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// TestDriveStatusQuickMarksUntrustedTimestampAsModified locks in the
// conservative fallback for malformed remote modified_time values.
func TestDriveStatusQuickMarksUntrustedTimestampAsModified(t *testing.T) {
Expand Down
Loading
Loading