Skip to content

Commit 509bf8a

Browse files
Merge pull request #304 from dropbox/feat/cp-mv-if-exists
Add --if-exists fail|skip to cp and mv commands
2 parents 1d0c204 + 63f5c9f commit 509bf8a

12 files changed

Lines changed: 711 additions & 28 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ folder or a team folder.
8888
* File operations: `ls`, `cp`, `mkdir`, `mv`, `rm`, `put`, and `get`
8989
* Recursive upload and download with `put -r` and `get -r`
9090
* Pipe-friendly transfers with stdin upload and stdout download
91-
* Upload conflict control with `put --if-exists overwrite|skip|fail`
91+
* Conflict control with `put --if-exists overwrite|skip|fail` and `cp`/`mv --if-exists fail|skip`
9292
* Shared-link creation, listing, inspection, update, revoke, and download
9393
* Search, file revisions, restore, flexible sorting, and time formatting
9494
* Chunked uploads for large files and paginated listing for large directories

cmd/cp.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,14 @@ func cp(cmd *cobra.Command, args []string) error {
3737
return invalidArgumentsErrorWithDetails("cp requires a source and a destination", argumentsErrorDetails("source", "destination"))
3838
}
3939

40+
opts, err := parseRelocationOptions(cmd)
41+
if err != nil {
42+
return err
43+
}
44+
4045
var cpErrors []error
4146
var relocationArgs []*files.RelocationArg
42-
var results []relocationResult
47+
var results []jsonOperationResult
4348
collectResults := commandOutputFormat(cmd) == output.FormatJSON
4449

4550
dbx := filesNewFunc(config)
@@ -52,13 +57,30 @@ func cp(cmd *cobra.Command, args []string) error {
5257
relocationError := fmt.Errorf("Error validating copy for %s to %s: %v", argument, dst, err)
5358
cpErrors = append(cpErrors, relocationError)
5459
} else {
60+
result, skipped, err := relocationSkipIfDestinationExists(dbx, arg, opts)
61+
if err != nil {
62+
cpErrors = append(cpErrors, fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err))
63+
continue
64+
}
65+
if skipped {
66+
if collectResults {
67+
results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result))
68+
}
69+
continue
70+
}
5571
relocationArgs = append(relocationArgs, arg)
5672
}
5773
}
5874

5975
for _, arg := range relocationArgs {
6076
res, err := dbx.CopyV2(arg)
6177
if err != nil {
78+
if result, skipped := relocationSkipAfterDestinationConflict(dbx, arg, err, opts); skipped {
79+
if collectResults {
80+
results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result))
81+
}
82+
continue
83+
}
6284
copyError := fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err)
6385
cpErrors = append(cpErrors, copyError)
6486
continue
@@ -70,7 +92,7 @@ func cp(cmd *cobra.Command, args []string) error {
7092
cpErrors = append(cpErrors, copyError)
7193
continue
7294
}
73-
results = append(results, result)
95+
results = append(results, relocationOperationResult(relocationJSONStatusCopied, result))
7496
}
7597
}
7698

@@ -84,7 +106,7 @@ func cp(cmd *cobra.Command, args []string) error {
84106
if !collectResults {
85107
return nil
86108
}
87-
return renderJSONOperationOutput(cmd, nil, relocationOperationResults(relocationJSONStatusCopied, results))
109+
return renderJSONOperationOutput(cmd, nil, results)
88110
}
89111

90112
// cpCmd represents the cp command
@@ -98,4 +120,5 @@ var cpCmd = &cobra.Command{
98120
func init() {
99121
RootCmd.AddCommand(cpCmd)
100122
enableStructuredOutput(cpCmd)
123+
cpCmd.Flags().String("if-exists", relocationIfExistsFail, "What to do when the destination exists: fail or skip")
101124
}

cmd/cp_test.go

Lines changed: 283 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,240 @@ func TestCpJSONErrorUsesCommandStderr(t *testing.T) {
290290
}
291291
}
292292

293+
func TestCpCommandDefinesIfExistsFlag(t *testing.T) {
294+
flag := cpCmd.Flags().Lookup("if-exists")
295+
if flag == nil {
296+
t.Fatal("cp should define --if-exists")
297+
}
298+
if flag.DefValue != relocationIfExistsFail {
299+
t.Fatalf("--if-exists default = %q, want %q", flag.DefValue, relocationIfExistsFail)
300+
}
301+
}
302+
303+
func TestCpInvalidIfExistsReturnsInvalidArguments(t *testing.T) {
304+
var stdout bytes.Buffer
305+
cmd := newRelocationTestCommand(&stdout, nil)
306+
if err := cmd.Flags().Set("if-exists", "replace"); err != nil {
307+
t.Fatal(err)
308+
}
309+
310+
err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"})
311+
if err == nil {
312+
t.Fatal("expected cp error")
313+
}
314+
if code := jsonErrorCode(err); code != jsonErrorCodeInvalidArguments {
315+
t.Fatalf("json error code = %q, want %q", code, jsonErrorCodeInvalidArguments)
316+
}
317+
details := jsonErrorDetails(err)
318+
if details["flag"] != "if-exists" || details["value"] != "replace" {
319+
t.Fatalf("details = %#v, want if-exists flag value", details)
320+
}
321+
if stdout.String() != "" {
322+
t.Fatalf("stdout = %q, want empty", stdout.String())
323+
}
324+
}
325+
326+
func TestCpIfExistsFailCallsCopy(t *testing.T) {
327+
var copied []*files.RelocationArg
328+
stubFilesClient(t, &mockFilesClient{
329+
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
330+
return nil, relocationTestGetMetadataNotFoundError()
331+
},
332+
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
333+
copied = append(copied, arg)
334+
return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 1)), nil
335+
},
336+
})
337+
338+
var stdout bytes.Buffer
339+
cmd := newRelocationTestCommand(&stdout, nil)
340+
if err := cmd.Flags().Set("if-exists", relocationIfExistsFail); err != nil {
341+
t.Fatal(err)
342+
}
343+
if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil {
344+
t.Fatalf("cp error: %v", err)
345+
}
346+
if len(copied) != 1 {
347+
t.Fatalf("copied = %d, want 1", len(copied))
348+
}
349+
}
350+
351+
func TestCpIfExistsSkipExistingDestinationDoesNotCopy(t *testing.T) {
352+
var copied bool
353+
stubFilesClient(t, &mockFilesClient{
354+
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
355+
if arg.Path != "/dest/file.txt" {
356+
t.Fatalf("metadata path = %q, want /dest/file.txt", arg.Path)
357+
}
358+
return relocationTestFileMetadata(arg.Path, 8), nil
359+
},
360+
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
361+
copied = true
362+
return nil, nil
363+
},
364+
})
365+
366+
var stdout bytes.Buffer
367+
cmd := newRelocationTestCommand(&stdout, nil)
368+
if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil {
369+
t.Fatal(err)
370+
}
371+
372+
if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil {
373+
t.Fatalf("cp error: %v", err)
374+
}
375+
if copied {
376+
t.Fatal("CopyV2 called for skipped destination")
377+
}
378+
got := decodeRelocationOutput(t, stdout.Bytes())
379+
if len(got.Results) != 1 {
380+
t.Fatalf("results = %d, want 1", len(got.Results))
381+
}
382+
if got.Results[0].Status != relocationJSONStatusSkipped {
383+
t.Fatalf("status = %q, want skipped", got.Results[0].Status)
384+
}
385+
if got.Results[0].Input.FromPath != "/src/file.txt" || got.Results[0].Input.ToPath != "/dest/file.txt" {
386+
t.Fatalf("input = %#v, want source and destination", got.Results[0].Input)
387+
}
388+
}
389+
390+
func TestCpIfExistsSkipMissingDestinationCopies(t *testing.T) {
391+
var copied []*files.RelocationArg
392+
stubFilesClient(t, &mockFilesClient{
393+
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
394+
return nil, relocationTestGetMetadataNotFoundError()
395+
},
396+
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
397+
copied = append(copied, arg)
398+
return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 3)), nil
399+
},
400+
})
401+
402+
var stdout bytes.Buffer
403+
cmd := newRelocationTestCommand(&stdout, nil)
404+
if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil {
405+
t.Fatal(err)
406+
}
407+
408+
if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil {
409+
t.Fatalf("cp error: %v", err)
410+
}
411+
if len(copied) != 1 {
412+
t.Fatalf("copied = %d, want 1", len(copied))
413+
}
414+
got := decodeRelocationOutput(t, stdout.Bytes())
415+
if got.Results[0].Status != relocationJSONStatusCopied {
416+
t.Fatalf("status = %q, want copied", got.Results[0].Status)
417+
}
418+
}
419+
420+
func TestCpIfExistsSkipConvertsDestinationConflict(t *testing.T) {
421+
getMetadataCalls := 0
422+
stubFilesClient(t, &mockFilesClient{
423+
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
424+
getMetadataCalls++
425+
if getMetadataCalls < 3 {
426+
return nil, relocationTestGetMetadataNotFoundError()
427+
}
428+
return relocationTestFileMetadata(arg.Path, 13), nil
429+
},
430+
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
431+
return nil, relocationTestCopyDestinationConflictError()
432+
},
433+
})
434+
435+
var stdout bytes.Buffer
436+
cmd := newRelocationTestCommand(&stdout, nil)
437+
if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil {
438+
t.Fatal(err)
439+
}
440+
441+
if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil {
442+
t.Fatalf("cp error: %v", err)
443+
}
444+
got := decodeRelocationOutput(t, stdout.Bytes())
445+
if len(got.Results) != 1 {
446+
t.Fatalf("results = %d, want 1", len(got.Results))
447+
}
448+
if got.Results[0].Status != relocationJSONStatusSkipped {
449+
t.Fatalf("status = %q, want skipped", got.Results[0].Status)
450+
}
451+
}
452+
453+
func TestCpIfExistsSkipMultipleSourcesAppliesPerTarget(t *testing.T) {
454+
var copied []string
455+
stubFilesClient(t, &mockFilesClient{
456+
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
457+
switch arg.Path {
458+
case "/dest/a.txt":
459+
return relocationTestFileMetadata(arg.Path, 1), nil
460+
case "/dest/b.txt":
461+
return nil, relocationTestGetMetadataNotFoundError()
462+
default:
463+
t.Fatalf("unexpected metadata path %q", arg.Path)
464+
return nil, nil
465+
}
466+
},
467+
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
468+
copied = append(copied, arg.ToPath)
469+
return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 2)), nil
470+
},
471+
})
472+
473+
var stdout bytes.Buffer
474+
cmd := newRelocationTestCommand(&stdout, nil)
475+
if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil {
476+
t.Fatal(err)
477+
}
478+
479+
if err := cp(cmd, []string{"/src/a.txt", "/src/b.txt", "/dest"}); err != nil {
480+
t.Fatalf("cp error: %v", err)
481+
}
482+
if len(copied) != 1 || copied[0] != "/dest/b.txt" {
483+
t.Fatalf("copied = %#v, want only /dest/b.txt", copied)
484+
}
485+
got := decodeRelocationOutput(t, stdout.Bytes())
486+
if len(got.Results) != 2 {
487+
t.Fatalf("results = %d, want 2", len(got.Results))
488+
}
489+
if got.Results[0].Status != relocationJSONStatusSkipped || got.Results[1].Status != relocationJSONStatusCopied {
490+
t.Fatalf("statuses = %q, %q; want skipped, copied", got.Results[0].Status, got.Results[1].Status)
491+
}
492+
}
493+
494+
func TestCpIfExistsSkipTextModeQuiet(t *testing.T) {
495+
stubFilesClient(t, &mockFilesClient{
496+
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
497+
return relocationTestFileMetadata(arg.Path, 8), nil
498+
},
499+
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
500+
t.Fatal("CopyV2 called for skipped destination")
501+
return nil, nil
502+
},
503+
})
504+
505+
var stdout bytes.Buffer
506+
var stderr bytes.Buffer
507+
cmd := &cobra.Command{}
508+
cmd.Flags().String(outputFlag, string(output.FormatText), "")
509+
cmd.Flags().String("if-exists", relocationIfExistsFail, "")
510+
if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil {
511+
t.Fatal(err)
512+
}
513+
cmd.SetOut(&stdout)
514+
cmd.SetErr(&stderr)
515+
516+
if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil {
517+
t.Fatalf("cp error: %v", err)
518+
}
519+
if stdout.String() != "" {
520+
t.Fatalf("stdout = %q, want empty", stdout.String())
521+
}
522+
if stderr.String() != "" {
523+
t.Fatalf("stderr = %q, want empty", stderr.String())
524+
}
525+
}
526+
293527
func TestCpCommandSupportsStructuredOutput(t *testing.T) {
294528
if !commandSupportsStructuredOutput(cpCmd) {
295529
t.Fatal("cp should support structured output")
@@ -299,6 +533,7 @@ func TestCpCommandSupportsStructuredOutput(t *testing.T) {
299533
func newRelocationTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command {
300534
cmd := &cobra.Command{}
301535
cmd.Flags().String(outputFlag, string(output.FormatText), "")
536+
cmd.Flags().String("if-exists", relocationIfExistsFail, "")
302537
if err := cmd.Flags().Set(outputFlag, string(output.FormatJSON)); err != nil {
303538
panic(err)
304539
}
@@ -312,9 +547,16 @@ func newRelocationTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command {
312547
}
313548

314549
type relocationOutput struct {
315-
Input map[string]any `json:"input"`
316-
Results []relocationResult `json:"results"`
317-
Warnings []jsonWarning `json:"warnings"`
550+
Input map[string]any `json:"input"`
551+
Results []relocationJSONResult `json:"results"`
552+
Warnings []jsonWarning `json:"warnings"`
553+
}
554+
555+
type relocationJSONResult struct {
556+
Status string `json:"status"`
557+
Kind string `json:"kind"`
558+
Input relocationInput `json:"input"`
559+
Result jsonMetadata `json:"result"`
318560
}
319561

320562
func decodeRelocationOutput(t *testing.T, data []byte) relocationOutput {
@@ -337,3 +579,41 @@ func decodeRelocationOutput(t *testing.T, data []byte) relocationOutput {
337579
}
338580
return got
339581
}
582+
583+
func relocationTestFileMetadata(pathDisplay string, size uint64) *files.FileMetadata {
584+
metadata := files.NewFileMetadata(path.Base(pathDisplay), "id:"+path.Base(pathDisplay), time.Time{}, time.Time{}, "rev", size)
585+
metadata.PathDisplay = pathDisplay
586+
metadata.PathLower = strings.ToLower(pathDisplay)
587+
return metadata
588+
}
589+
590+
func relocationTestGetMetadataNotFoundError() error {
591+
return files.GetMetadataAPIError{
592+
EndpointError: &files.GetMetadataError{
593+
Tagged: dropbox.Tagged{Tag: files.GetMetadataErrorPath},
594+
Path: &files.LookupError{Tagged: dropbox.Tagged{Tag: files.LookupErrorNotFound}},
595+
},
596+
}
597+
}
598+
599+
func relocationTestCopyDestinationConflictError() error {
600+
return files.CopyV2APIError{
601+
EndpointError: relocationTestDestinationConflictError(),
602+
}
603+
}
604+
605+
func relocationTestMoveDestinationConflictError() error {
606+
return files.MoveV2APIError{
607+
EndpointError: relocationTestDestinationConflictError(),
608+
}
609+
}
610+
611+
func relocationTestDestinationConflictError() *files.RelocationError {
612+
return &files.RelocationError{
613+
Tagged: dropbox.Tagged{Tag: files.RelocationErrorTo},
614+
To: &files.WriteError{
615+
Tagged: dropbox.Tagged{Tag: files.WriteErrorConflict},
616+
Conflict: &files.WriteConflictError{Tagged: dropbox.Tagged{Tag: files.WriteConflictErrorFile}},
617+
},
618+
}
619+
}

0 commit comments

Comments
 (0)