From 01f2a64344f442980871c105467fa98000a544ad Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Wed, 11 Nov 2026 12:10:58 -0800 Subject: [PATCH] put: skip uploads when destination contents match --- cmd/help_json_test.go | 4 +- cmd/help_manifest.go | 2 +- cmd/json_contract_test.go | 2 +- cmd/put.go | 44 ++- cmd/put_skip_same_test.go | 309 ++++++++++++++++++ cmd/put_test.go | 2 +- cmd/testdata/json_contract/error_outputs.json | 2 +- docs/commands/dbxcli_put.md | 8 +- docs/json-schema/v1/commands.schema.json | 3 +- internal/jsonschema/definition_schemas.go | 2 +- 10 files changed, 365 insertions(+), 13 deletions(-) create mode 100644 cmd/put_skip_same_test.go diff --git a/cmd/help_json_test.go b/cmd/help_json_test.go index a9eb7a8..7e4d18c 100644 --- a/cmd/help_json_test.go +++ b/cmd/help_json_test.go @@ -280,7 +280,7 @@ func TestJSONHelpManifestV1MachineFields(t *testing.T) { if jsonHelpFlagByName(t, put.Flags, dryRunFlagName).ValueKind != "boolean" { t.Fatalf("put --%s value_kind = %q, want boolean", dryRunFlagName, jsonHelpFlagByName(t, put.Flags, dryRunFlagName).ValueKind) } - assertStringSliceEqual(t, "put --if-exists enum", jsonHelpFlagByName(t, put.Flags, "if-exists").EnumValues, []string{"overwrite", "skip", "fail", "autorename"}) + assertStringSliceEqual(t, "put --if-exists enum", jsonHelpFlagByName(t, put.Flags, "if-exists").EnumValues, []string{"overwrite", "skip", "skip-same", "fail", "autorename"}) if !put.StdinStdout.ReadsStdin || put.StdinStdout.WritesBinaryStdout { t.Fatalf("put stdin_stdout = %+v, want stdin only", put.StdinStdout) } @@ -311,7 +311,7 @@ func TestJSONHelpManifestV1MachineFields(t *testing.T) { } assertJSONHelpInputProperty(t, put.InputSchema, "target", "string", "arg", "target", "dropbox_path") ifExists := assertJSONHelpInputProperty(t, put.InputSchema, "if_exists", "string", "flag", "if-exists", "enum") - assertStringSliceEqual(t, "put input_schema if_exists enum", ifExists.Enum, []string{"autorename", "fail", "overwrite", "skip"}) + assertStringSliceEqual(t, "put input_schema if_exists enum", ifExists.Enum, []string{"autorename", "fail", "overwrite", "skip", "skip-same"}) if ifExists.Default != "overwrite" { t.Fatalf("put if_exists default = %#v, want overwrite", ifExists.Default) } diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index b45dfeb..0cf6aad 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -199,7 +199,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ "chunksize": {ValueKind: "bytes"}, "debug": {ValueKind: "boolean"}, dryRunFlagName: {ValueKind: "boolean"}, - "if-exists": {EnumValues: []string{"overwrite", "skip", "fail", "autorename"}, ValueKind: "enum"}, + "if-exists": {EnumValues: []string{"overwrite", "skip", "skip-same", "fail", "autorename"}, ValueKind: "enum"}, "recursive": {ValueKind: "boolean"}, "workers": {ValueKind: "integer"}, }, diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 72b4bda..3d76b20 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -841,7 +841,7 @@ func jsonGoldenErrorOutputExamples() map[string]jsonErrorResponse { ), "invalid_arguments": newJSONErrorResponse( jsonErrorExampleCommand("put"), - invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, autorename, or fail)", flagValueErrorDetails("if-exists", "replace"), "replace"), + invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, skip-same, autorename, or fail)", flagValueErrorDetails("if-exists", "replace"), "replace"), ), "not_found": newJSONErrorResponse( jsonErrorExampleCommand("get"), diff --git a/cmd/put.go b/cmd/put.go index 3015633..2c316e9 100644 --- a/cmd/put.go +++ b/cmd/put.go @@ -29,6 +29,7 @@ import ( "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/contenthash" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/dustin/go-humanize" "github.com/mitchellh/ioprogress" @@ -45,6 +46,7 @@ const ( putIfExistsOverwrite = "overwrite" putIfExistsSkip = "skip" + putIfExistsSkipSame = "skip-same" putIfExistsFail = "fail" putIfExistsAutorename = "autorename" ) @@ -542,10 +544,10 @@ func normalizePutIfExists(ifExists string) (string, error) { return putIfExistsOverwrite, nil } switch ifExists { - case putIfExistsOverwrite, putIfExistsSkip, putIfExistsFail, putIfExistsAutorename: + case putIfExistsOverwrite, putIfExistsSkip, putIfExistsSkipSame, putIfExistsFail, putIfExistsAutorename: return ifExists, nil default: - return "", invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, autorename, or fail)", flagValueErrorDetails("if-exists", ifExists), ifExists) + return "", invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use overwrite, skip, skip-same, autorename, or fail)", flagValueErrorDetails("if-exists", ifExists), ifExists) } } @@ -587,6 +589,14 @@ func putFileWithResult(src, dst string, opts putOptions) (putResult, error) { return putResult{}, err } + if ifExists == putIfExistsSkipSame && existingMetadata != nil { + if err := comparePutContents(contents, contentsInfo.Size(), existingMetadata, dst); err != nil { + return putResult{}, err + } + putOutput(opts).Status("Skipping %s (same contents)", dst) + return newPutResult(putStatusSkipped, putKindFile, src, dst, existingMetadata) + } + commitInfo := files.NewCommitInfo(dst) commitInfo.Mode.Tag = writeModeForIfExists(ifExists) commitInfo.StrictConflict = ifExists != putIfExistsOverwrite @@ -618,6 +628,28 @@ func putFileWithResult(src, dst string, opts putOptions) (putResult, error) { return newPutResult(putUploadStatus(ifExists, dst, metadata), putKindFile, src, dst, metadata) } +// comparePutContents only permits skipping when equality can be established. +func comparePutContents(contents io.Reader, size int64, metadata files.IsMetadata, dst string) error { + remote, ok := metadata.(*files.FileMetadata) + if !ok || remote == nil { + return pathConflictErrorWithPath(dst, "destination %q is not a file", dst) + } + if uint64(size) != remote.Size { + return pathConflictErrorWithPath(dst, "destination %q has different contents", dst) + } + if remote.ContentHash == "" { + return pathConflictErrorWithPath(dst, "cannot compare destination %q: content hash is unavailable", dst) + } + localHash, err := contenthash.Compute(contents) + if err != nil { + return fmt.Errorf("compute local content hash: %w", err) + } + if localHash != remote.ContentHash { + return pathConflictErrorWithPath(dst, "destination %q has different contents", dst) + } + return nil +} + // putUploadStatus reports "autorenamed" when --if-exists=autorename caused the // server to store the file under a different path than requested; otherwise it // reports the normal "uploaded" status. @@ -683,6 +715,8 @@ func actionForExistingDestination(dst string, ifExists string, metadata files.Is switch ifExists { case putIfExistsSkip: return putDestinationSkip, metadata, nil + case putIfExistsSkipSame: + return putDestinationUpload, metadata, nil case putIfExistsFail: return putDestinationUpload, nil, pathConflictErrorWithPath(dst, "destination %q already exists", dst) default: @@ -1079,11 +1113,15 @@ var putCmd = &cobra.Command{ - Use - as source to read from stdin (target is required). Stdin is spooled to a temp file before upload and may use disk space up to the full input size. + - Use --if-exists skip-same to skip files with identical contents and fail + when contents differ or the remote content hash is unavailable. + Dry-run only plans uploads; it does not compare contents. - Files larger than 32MiB use Dropbox upload sessions. Each chunk is one upload-session request; chunk size must be a multiple of 4MiB and no more than 128MiB. `, Example: ` dbxcli put file.txt /destination/file.txt + dbxcli put file.txt /destination/file.txt --if-exists skip-same dbxcli put -r ./project /backup/project dbxcli put -w 1 -c 134217728 large.zip /backup/large.zip printf 'hello' | dbxcli put - /hello.txt @@ -1099,5 +1137,5 @@ func init() { putCmd.Flags().IntP("workers", "w", 4, "Number of concurrent upload workers for chunked large-file uploads") putCmd.Flags().Int64P("chunksize", "c", 1<<24, "Chunk size in bytes for chunked large-file uploads; must be a multiple of 4MiB and no more than 128MiB") putCmd.Flags().BoolP("debug", "d", false, "Print debug timing") - putCmd.Flags().String("if-exists", putIfExistsOverwrite, "What to do when the destination file exists: overwrite, skip, autorename, or fail") + putCmd.Flags().String("if-exists", putIfExistsOverwrite, "What to do when the destination file exists: overwrite, skip, skip-same, autorename, or fail") } diff --git a/cmd/put_skip_same_test.go b/cmd/put_skip_same_test.go new file mode 100644 index 0000000..2d10e5e --- /dev/null +++ b/cmd/put_skip_same_test.go @@ -0,0 +1,309 @@ +package cmd + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/contenthash" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" +) + +func samePutMetadata(t *testing.T, data []byte) *files.FileMetadata { + t.Helper() + hash, err := contenthash.Compute(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + return &files.FileMetadata{Size: uint64(len(data)), ContentHash: hash} +} + +func forbidPutUploads(t *testing.T) *mockFilesClient { + t.Helper() + return &mockFilesClient{ + uploadFn: func(*files.UploadArg, io.Reader) (*files.FileMetadata, error) { + t.Fatal("unexpected upload") + return nil, nil + }, + uploadSessionStartFn: func(*files.UploadSessionStartArg, io.Reader) (*files.UploadSessionStartResult, error) { + t.Fatal("unexpected session start") + return nil, nil + }, + uploadSessionAppendV2Fn: func(*files.UploadSessionAppendArg, io.Reader) error { + t.Error("unexpected session append") + return errors.New("unexpected append") + }, + uploadSessionFinishFn: func(*files.UploadSessionFinishArg, io.Reader) (*files.FileMetadata, error) { + t.Fatal("unexpected session finish") + return nil, nil + }, + } +} + +func TestPutSkipSameExisting(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + kind string + want string + }{ + {name: "empty", data: nil}, + {name: "same", data: []byte("hello")}, + {name: "large same", data: bytes.Repeat([]byte("x"), int(singleShotUploadSizeCutoff+1))}, + {name: "different size", data: []byte("hello"), kind: "size", want: "different contents"}, + {name: "different hash", data: []byte("hello"), kind: "hash", want: "different contents"}, + {name: "missing hash", data: []byte("hello"), kind: "no hash", want: "unavailable"}, + {name: "folder", data: []byte("hello"), kind: "folder", want: "folder"}, + {name: "metadata error", data: []byte("hello"), kind: "error", want: "metadata failed"}, + } { + t.Run(tc.name, func(t *testing.T) { + src := filepath.Join(t.TempDir(), "source") + if err := os.WriteFile(src, tc.data, 0600); err != nil { + t.Fatal(err) + } + meta := samePutMetadata(t, tc.data) + switch tc.kind { + case "size": + meta.Size++ + case "hash": + meta.ContentHash = strings.Repeat("0", 64) + case "no hash": + meta.ContentHash = "" + } + mock := forbidPutUploads(t) + mock.getMetadataFn = func(*files.GetMetadataArg) (files.IsMetadata, error) { + if tc.kind == "folder" { + return &files.FolderMetadata{}, nil + } + if tc.kind == "error" { + return nil, errors.New("metadata failed") + } + return meta, nil + } + stubFilesClient(t, mock) + result, err := putFileWithResult(src, "/target", putOptions{ifExists: putIfExistsSkipSame}) + if tc.want != "" { + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) + } + return + } + if err != nil { + t.Fatal(err) + } + if result.Status != putStatusSkipped || result.Result == nil { + t.Fatalf("result = %+v", result) + } + }) + } +} + +func TestPutSkipSameMissingAndConcurrentCreation(t *testing.T) { + for _, race := range []bool{false, true} { + t.Run(map[bool]string{false: "missing", true: "concurrent creation"}[race], func(t *testing.T) { + data := []byte("entire source contents") + src := filepath.Join(t.TempDir(), "source") + if err := os.WriteFile(src, data, 0600); err != nil { + t.Fatal(err) + } + calls := 0 + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(*files.GetMetadataArg) (files.IsMetadata, error) { return nil, getMetadataNotFoundError() }, + uploadFn: func(arg *files.UploadArg, r io.Reader) (*files.FileMetadata, error) { + calls++ + if arg.Mode.Tag != files.WriteModeAdd || !arg.StrictConflict || arg.Autorename { + t.Fatalf("unsafe upload args: %+v", arg) + } + got, err := io.ReadAll(r) + if err != nil || !bytes.Equal(got, data) { + t.Fatalf("uploaded %q, error %v", got, err) + } + if race { + return nil, uploadPathConflictError(files.WriteConflictErrorFile) + } + return samePutMetadata(t, data), nil + }, + }) + result, err := putFileWithResult(src, "/target", putOptions{ifExists: putIfExistsSkipSame}) + if calls != 1 { + t.Fatalf("upload calls = %d", calls) + } + if race { + if err == nil { + t.Fatal("expected conflict") + } + return + } + if err != nil || result.Status != putStatusUploaded { + t.Fatalf("result = %+v, error = %v", result, err) + } + }) + } +} + +type putHashErrorReader struct{ err error } + +func (r putHashErrorReader) Read([]byte) (int, error) { return 0, r.err } +func TestComparePutContentsReadError(t *testing.T) { + want := errors.New("read failed") + err := comparePutContents(putHashErrorReader{want}, 1, samePutMetadata(t, []byte("x")), "/target") + if !errors.Is(err, want) { + t.Fatalf("error = %v, want %v", err, want) + } +} + +func TestPutSkipSameRecursiveMixed(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"same", "new", "different"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0600); err != nil { + t.Fatal(err) + } + } + var uploaded []string + mock := forbidPutUploads(t) + mock.getMetadataFn = func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + switch arg.Path { + case "/backup/same": + return samePutMetadata(t, []byte("same")), nil + case "/backup/different": + return samePutMetadata(t, []byte("DIFFERENT")), nil + } + return nil, getMetadataNotFoundError() + } + mock.uploadFn = func(arg *files.UploadArg, r io.Reader) (*files.FileMetadata, error) { + uploaded = append(uploaded, arg.Path) + return &files.FileMetadata{}, nil + } + mock.createFolderV2Fn = func(*files.CreateFolderArg) (*files.CreateFolderResult, error) { + return &files.CreateFolderResult{}, nil + } + stubFilesClient(t, mock) + err := putRecursive(dir, "/backup", putOptions{ifExists: putIfExistsSkipSame}) + if err == nil || !strings.Contains(err.Error(), "different contents") { + t.Fatalf("error = %v", err) + } + if len(uploaded) != 1 || uploaded[0] != "/backup/new" { + t.Fatalf("uploaded = %v", uploaded) + } +} + +func TestPutSkipSameStdinJSONAndCleanup(t *testing.T) { + for _, same := range []bool{true, false} { + t.Run(map[bool]string{true: "same", false: "different"}[same], func(t *testing.T) { + tmp := t.TempDir() + t.Setenv("TMPDIR", tmp) + t.Setenv("TMP", tmp) + t.Setenv("TEMP", tmp) + mock := forbidPutUploads(t) + mock.getMetadataFn = func(*files.GetMetadataArg) (files.IsMetadata, error) { + data := "hello" + if !same { + data = "other" + } + return samePutMetadata(t, []byte(data)), nil + } + stubFilesClient(t, mock) + var stdout, stderr bytes.Buffer + cmd := testPutJSONCmd(&stdout, &stderr) + cmd.Use = "put" + cmd.SetIn(strings.NewReader("hello")) + if err := cmd.Flags().Set("if-exists", putIfExistsSkipSame); err != nil { + t.Fatal(err) + } + err := put(cmd, []string{"-", "/target"}) + if same { + if err != nil { + t.Fatal(err) + } + got := decodePutOutput(t, &stdout) + if got.Input.IfExists != putIfExistsSkipSame || len(got.Results) != 1 || got.Results[0].Status != putStatusSkipped || got.Results[0].Input.Source != "-" { + t.Fatalf("output = %+v", got) + } + if err := compileJSONSchemaFile(t, "../docs/json-schema/v1/commands.schema.json").Validate(decodeJSONValueForSchema(t, stdout.Bytes())); err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("expected conflict") + } + entries, err := os.ReadDir(tmp) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("temp files remain: %v", entries) + } + }) + } +} + +func TestPutSkipSameDryRun(t *testing.T) { + src := filepath.Join(t.TempDir(), "source") + if err := os.WriteFile(src, []byte("hello"), 0600); err != nil { + t.Fatal(err) + } + mock := forbidPutUploads(t) + mock.getMetadataFn = func(*files.GetMetadataArg) (files.IsMetadata, error) { + t.Fatal("unexpected destination comparison") + return nil, nil + } + stubFilesClient(t, mock) + var stdout bytes.Buffer + cmd := testPutJSONCmd(&stdout, nil) + for flag, value := range map[string]string{"if-exists": putIfExistsSkipSame, dryRunFlagName: "true"} { + if err := cmd.Flags().Set(flag, value); err != nil { + t.Fatal(err) + } + } + // A directory suffix resolves the target without a metadata lookup. + if err := put(cmd, []string{src, "/backup/"}); err != nil { + t.Fatal(err) + } + got := decodePutOutput(t, &stdout) + if got.Input.IfExists != putIfExistsSkipSame || got.Results[0].Status != jsonStatusPlanned { + t.Fatalf("output = %+v", got) + } +} + +func TestComparePutContentsSizeMismatchDoesNotRead(t *testing.T) { + err := comparePutContents(putHashErrorReader{errors.New("must not read")}, 2, samePutMetadata(t, []byte("x")), "/target") + if err == nil || !strings.Contains(err.Error(), "different contents") { + t.Fatalf("error = %v", err) + } +} + +func TestPutSkipSameChunkedConcurrentCreation(t *testing.T) { + src := filepath.Join(t.TempDir(), "large") + f, err := os.Create(src) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(singleShotUploadSizeCutoff + 1); err != nil { + f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + finished := false + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(*files.GetMetadataArg) (files.IsMetadata, error) { return nil, getMetadataNotFoundError() }, + uploadSessionStartFn: func(*files.UploadSessionStartArg, io.Reader) (*files.UploadSessionStartResult, error) { + return &files.UploadSessionStartResult{SessionId: "session"}, nil + }, + uploadSessionFinishFn: func(arg *files.UploadSessionFinishArg, _ io.Reader) (*files.FileMetadata, error) { + finished = true + if arg.Commit.Mode.Tag != files.WriteModeAdd || !arg.Commit.StrictConflict || arg.Commit.Autorename { + t.Errorf("unsafe commit: %+v", arg.Commit) + } + return nil, uploadSessionFinishPathConflictError(files.WriteConflictErrorFile) + }, + }) + _, err = putFileWithResult(src, "/target", putOptions{ifExists: putIfExistsSkipSame, workers: 1, chunkSize: putChunkSizeUnit, errOut: io.Discard}) + if err == nil || !finished { + t.Fatalf("finished=%v, error=%v, want conflict", finished, err) + } +} diff --git a/cmd/put_test.go b/cmd/put_test.go index 01c7078..ac01bcb 100644 --- a/cmd/put_test.go +++ b/cmd/put_test.go @@ -710,7 +710,7 @@ func TestPutIfExistsValidation(t *testing.T) { if err == nil { t.Fatal("expected invalid --if-exists error") } - if !bytes.Contains([]byte(err.Error()), []byte("overwrite, skip, autorename, or fail")) { + if !bytes.Contains([]byte(err.Error()), []byte("overwrite, skip, skip-same, autorename, or fail")) { t.Errorf("error = %q, want valid option list", err.Error()) } } diff --git a/cmd/testdata/json_contract/error_outputs.json b/cmd/testdata/json_contract/error_outputs.json index 7a22324..987030c 100644 --- a/cmd/testdata/json_contract/error_outputs.json +++ b/cmd/testdata/json_contract/error_outputs.json @@ -68,7 +68,7 @@ "schema_version": "1", "command": "put", "error": { - "message": "invalid --if-exists \"replace\" (use overwrite, skip, autorename, or fail)", + "message": "invalid --if-exists \"replace\" (use overwrite, skip, skip-same, autorename, or fail)", "code": "invalid_arguments", "details": { "flag": "if-exists", diff --git a/docs/commands/dbxcli_put.md b/docs/commands/dbxcli_put.md index e0a7584..0d60198 100644 --- a/docs/commands/dbxcli_put.md +++ b/docs/commands/dbxcli_put.md @@ -12,6 +12,9 @@ Upload files or directories to Dropbox. - Use - as source to read from stdin (target is required). Stdin is spooled to a temp file before upload and may use disk space up to the full input size. + - Use --if-exists skip-same to skip files with identical contents and fail + when contents differ or the remote content hash is unavailable. + Dry-run only plans uploads; it does not compare contents. - Files larger than 32MiB use Dropbox upload sessions. Each chunk is one upload-session request; chunk size must be a multiple of 4MiB and no more than 128MiB. @@ -25,6 +28,7 @@ dbxcli put [flags] [] ``` dbxcli put file.txt /destination/file.txt + dbxcli put file.txt /destination/file.txt --if-exists skip-same dbxcli put -r ./project /backup/project dbxcli put -w 1 -c 134217728 large.zip /backup/large.zip printf 'hello' | dbxcli put - /hello.txt @@ -38,7 +42,7 @@ dbxcli put [flags] [] -d, --debug[=true|false] Print debug timing --dry-run[=true|false] Preview intended writes without making changes -h, --help[=true|false] help for put - --if-exists string What to do when the destination file exists: overwrite, skip, autorename, or fail (default "overwrite") + --if-exists string What to do when the destination file exists: overwrite, skip, skip-same, autorename, or fail (default "overwrite") -r, --recursive[=true|false] Recursively upload directories -w, --workers int Number of concurrent upload workers for chunked large-file uploads (default 4) ``` @@ -60,7 +64,7 @@ dbxcli put [flags] [] * Auth modes: `personal`, `team-access` * Dropbox scopes: `files.content.write`, `files.metadata.read` * Arguments: `source` (required, local_path, `-` stream operand), `target` (optional, dropbox_path) -* Flag metadata: `--if-exists` (values: `autorename`, `fail`, `overwrite`, `skip`), `--output` (values: `json`, `text`) +* Flag metadata: `--if-exists` (values: `autorename`, `fail`, `overwrite`, `skip`, `skip-same`), `--output` (values: `json`, `text`) * Stdin/stdout behavior: Use `-` as the local source to upload from stdin; stdin is spooled to a temporary file before upload. * Result statuses: `autorenamed`, `created`, `existing`, `planned`, `skipped`, `uploaded` * Result kinds: `file`, `folder` diff --git a/docs/json-schema/v1/commands.schema.json b/docs/json-schema/v1/commands.schema.json index 793a518..4b89271 100644 --- a/docs/json-schema/v1/commands.schema.json +++ b/docs/json-schema/v1/commands.schema.json @@ -1823,7 +1823,8 @@ "autorename", "fail", "overwrite", - "skip" + "skip", + "skip-same" ], "type": "string" }, diff --git a/internal/jsonschema/definition_schemas.go b/internal/jsonschema/definition_schemas.go index 5c15d33..a04cf6f 100644 --- a/internal/jsonschema/definition_schemas.go +++ b/internal/jsonschema/definition_schemas.go @@ -209,7 +209,7 @@ var commandDefinitionSchemas = map[string]definitionSchemaConfig{ "put_input": { Required: []string{"if_exists", "recursive", "source", "stdin", "target"}, Properties: map[string]any{ - "if_exists": stringEnum("fail", "overwrite", "skip", "autorename"), + "if_exists": stringEnum("fail", "overwrite", "skip", "skip-same", "autorename"), }, }, "put_result_input": {