From ba5eb230fbf7b38f133b1b4caacd25a7a0db9482 Mon Sep 17 00:00:00 2001
From: fishbullet <shindu666@gmail.com>
Date: Fri, 20 Mar 2020 21:43:29 +0300
Subject: [PATCH] operations: interactive mode -i/--interactive for destructive
 operations #3886

---
 fs/config.go                         |  1 +
 fs/config/configflags/configflags.go |  1 +
 fs/operations/dedupe.go              |  8 +---
 fs/operations/operations.go          | 61 ++++++++++++++++++----------
 4 files changed, 44 insertions(+), 27 deletions(-)

diff --git a/fs/config.go b/fs/config.go
index 233e9e64c..df22bced4 100644
--- a/fs/config.go
+++ b/fs/config.go
@@ -44,6 +44,7 @@ type ConfigInfo struct {
 	StatsLogLevel          LogLevel
 	UseJSONLog             bool
 	DryRun                 bool
+	Interactive            bool
 	CheckSum               bool
 	SizeOnly               bool
 	IgnoreTimes            bool
diff --git a/fs/config/configflags/configflags.go b/fs/config/configflags/configflags.go
index 42ac28db1..9d2120919 100644
--- a/fs/config/configflags/configflags.go
+++ b/fs/config/configflags/configflags.go
@@ -51,6 +51,7 @@ func AddFlags(flagSet *pflag.FlagSet) {
 	flags.BoolVarP(flagSet, &fs.Config.IgnoreExisting, "ignore-existing", "", fs.Config.IgnoreExisting, "Skip all files that exist on destination")
 	flags.BoolVarP(flagSet, &fs.Config.IgnoreErrors, "ignore-errors", "", fs.Config.IgnoreErrors, "delete even if there are I/O errors")
 	flags.BoolVarP(flagSet, &fs.Config.DryRun, "dry-run", "n", fs.Config.DryRun, "Do a trial run with no permanent changes")
+	flags.BoolVarP(flagSet, &fs.Config.Interactive, "interactive", "i", fs.Config.Interactive, "Enable interactive mode")
 	flags.DurationVarP(flagSet, &fs.Config.ConnectTimeout, "contimeout", "", fs.Config.ConnectTimeout, "Connect timeout")
 	flags.DurationVarP(flagSet, &fs.Config.Timeout, "timeout", "", fs.Config.Timeout, "IO idle timeout")
 	flags.DurationVarP(flagSet, &fs.Config.ExpectContinueTimeout, "expect-continue-timeout", "", fs.Config.ExpectContinueTimeout, "Timeout when using expect / 100-continue in HTTP")
diff --git a/fs/operations/dedupe.go b/fs/operations/dedupe.go
index 5cb8f1ec8..ba2c7e447 100644
--- a/fs/operations/dedupe.go
+++ b/fs/operations/dedupe.go
@@ -44,7 +44,7 @@ outer:
 			newName = fmt.Sprintf("%s-%d%s", base, i+suffix, ext)
 			_, err = f.NewObject(ctx, newName)
 		}
-		if !fs.Config.DryRun {
+		if !skipDestructive(ctx, o, "rename") {
 			newObj, err := doMove(ctx, o, newName)
 			if err != nil {
 				err = fs.CountError(err)
@@ -52,8 +52,6 @@ outer:
 				continue
 			}
 			fs.Infof(newObj, "renamed from: %v", o)
-		} else {
-			fs.Logf(remote, "Not renaming to %q as --dry-run", newName)
 		}
 	}
 }
@@ -255,15 +253,13 @@ func dedupeMergeDuplicateDirs(ctx context.Context, f fs.Fs, duplicateDirs [][]fs
 		return errors.Errorf("%v: can't flush dir cache", f)
 	}
 	for _, dirs := range duplicateDirs {
-		if !fs.Config.DryRun {
+		if !skipDestructive(ctx, dirs[0], "merge duplicate directories") {
 			fs.Infof(dirs[0], "Merging contents of duplicate directories")
 			err := mergeDirs(ctx, dirs)
 			if err != nil {
 				err = fs.CountError(err)
 				fs.Errorf(nil, "merge duplicate dirs: %v", err)
 			}
-		} else {
-			fs.Infof(dirs[0], "NOT Merging contents of duplicate directories as --dry-run")
 		}
 	}
 	dirCacheFlush()
diff --git a/fs/operations/operations.go b/fs/operations/operations.go
index 02b0c8c71..1605ab105 100644
--- a/fs/operations/operations.go
+++ b/fs/operations/operations.go
@@ -24,6 +24,7 @@ import (
 	"github.com/rclone/rclone/fs"
 	"github.com/rclone/rclone/fs/accounting"
 	"github.com/rclone/rclone/fs/cache"
+	"github.com/rclone/rclone/fs/config"
 	"github.com/rclone/rclone/fs/fserrors"
 	"github.com/rclone/rclone/fs/fshttp"
 	"github.com/rclone/rclone/fs/hash"
@@ -211,9 +212,7 @@ func equal(ctx context.Context, src fs.ObjectInfo, dst fs.Object, opt equalOpt)
 
 	// mod time differs but hash is the same to reset mod time if required
 	if opt.updateModTime {
-		if fs.Config.DryRun {
-			fs.Logf(src, "Not updating modification time as --dry-run")
-		} else {
+		if !skipDestructive(ctx, src, "update modification time") {
 			// Size and hash the same but mtime different
 			// Error if objects are treated as immutable
 			if fs.Config.Immutable {
@@ -348,8 +347,7 @@ func Copy(ctx context.Context, f fs.Fs, dst fs.Object, remote string, src fs.Obj
 		tr.Done(err)
 	}()
 	newDst = dst
-	if fs.Config.DryRun {
-		fs.Logf(src, "Not copying as --dry-run")
+	if skipDestructive(ctx, src, "copy") {
 		return newDst, nil
 	}
 	maxTries := fs.Config.LowLevelRetries
@@ -527,8 +525,7 @@ func Move(ctx context.Context, fdst fs.Fs, dst fs.Object, remote string, src fs.
 		tr.Done(err)
 	}()
 	newDst = dst
-	if fs.Config.DryRun {
-		fs.Logf(src, "Not moving as --dry-run")
+	if skipDestructive(ctx, src, "move") {
 		return newDst, nil
 	}
 	// See if we have Move available
@@ -603,12 +600,12 @@ func DeleteFileWithBackupDir(ctx context.Context, dst fs.Object, backupDir fs.Fs
 	if fs.Config.MaxDelete != -1 && numDeletes > fs.Config.MaxDelete {
 		return fserrors.FatalError(errors.New("--max-delete threshold reached"))
 	}
-	action, actioned, actioning := "delete", "Deleted", "deleting"
+	action, actioned := "delete", "Deleted"
 	if backupDir != nil {
-		action, actioned, actioning = "move into backup dir", "Moved into backup dir", "moving into backup dir"
+		action, actioned = "move into backup dir", "Moved into backup dir"
 	}
-	if fs.Config.DryRun {
-		fs.Logf(dst, "Not %s as --dry-run", actioning)
+	if skipDestructive(ctx, dst, action) {
+		// do nothing
 	} else if backupDir != nil {
 		err = MoveBackupDir(ctx, backupDir, dst)
 	} else {
@@ -1154,8 +1151,7 @@ func Mkdir(ctx context.Context, f fs.Fs, dir string) error {
 // TryRmdir removes a container but not if not empty.  It doesn't
 // count errors but may return one.
 func TryRmdir(ctx context.Context, f fs.Fs, dir string) error {
-	if fs.Config.DryRun {
-		fs.Logf(fs.LogDirName(f, dir), "Not deleting as dry run is set")
+	if skipDestructive(ctx, fs.LogDirName(f, dir), "remove directory") {
 		return nil
 	}
 	fs.Debugf(fs.LogDirName(f, dir), "Removing directory")
@@ -1180,8 +1176,8 @@ func Purge(ctx context.Context, f fs.Fs, dir string) error {
 		// FIXME change the Purge interface so it takes a dir - see #1891
 		if doPurge := f.Features().Purge; doPurge != nil {
 			doFallbackPurge = false
-			if fs.Config.DryRun {
-				fs.Logf(f, "Not purging as --dry-run set")
+			if skipDestructive(ctx, fs.LogDirName(f, dir), "purge directory") {
+				return nil
 			} else {
 				err = doPurge(ctx)
 				if err == fs.ErrorCantPurge {
@@ -1255,8 +1251,7 @@ func CleanUp(ctx context.Context, f fs.Fs) error {
 	if doCleanUp == nil {
 		return errors.Errorf("%v doesn't support cleanup", f)
 	}
-	if fs.Config.DryRun {
-		fs.Logf(f, "Not running cleanup as --dry-run set")
+	if skipDestructive(ctx, f, "clean up old files") {
 		return nil
 	}
 	return doCleanUp(ctx)
@@ -1394,8 +1389,7 @@ func Rcat(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadCloser,
 		fStreamTo = tmpLocalFs
 	}
 
-	if fs.Config.DryRun {
-		fs.Logf("stdin", "Not uploading as --dry-run")
+	if skipDestructive(ctx, dstFileName, "upload from pipe") {
 		// prevents "broken pipe" errors
 		_, err = io.Copy(ioutil.Discard, in)
 		return nil, err
@@ -1677,8 +1671,7 @@ func RcatSize(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadClo
 		body := ioutil.NopCloser(in) // we let the server close the body
 		in := tr.Account(body)       // account the transfer (no buffering)
 
-		if fs.Config.DryRun {
-			fs.Logf("stdin", "Not uploading as --dry-run")
+		if skipDestructive(ctx, dstFileName, "upload from pipe") {
 			// prevents "broken pipe" errors
 			_, err = io.Copy(ioutil.Discard, in)
 			return nil, err
@@ -2193,3 +2186,29 @@ func GetFsInfo(f fs.Fs) *FsInfo {
 	}
 	return info
 }
+
+var interactiveMutex sync.Mutex
+
+func skipDestructive(ctx context.Context, subject interface{}, action string) bool {
+	var (
+		flag string
+		skip bool
+	)
+	switch {
+	case fs.Config.DryRun:
+		flag = "--dry-run"
+		skip = true
+	case fs.Config.Interactive:
+		flag = "--interactive"
+		interactiveMutex.Lock()
+		defer interactiveMutex.Unlock()
+		fmt.Printf("rclone: %s \"%v\"?\n", action, subject)
+		skip = !config.Confirm(true)
+	default:
+		skip = false
+	}
+	if skip {
+		fs.Logf(subject, "Skipped %s as %s is set", action, flag)
+	}
+	return skip
+}