diff --git a/config/config.go b/config/config.go index dabb4ecb..17661666 100644 --- a/config/config.go +++ b/config/config.go @@ -338,7 +338,7 @@ func (c *Config) DisplayConfigurationIssues() { msg = append([]string{ "the configuration contains relative \"path\" items which may lead to unstable results in restic " + "commands that select snapshots. Consider using absolute paths in \"path\" (and \"source\"), " + - "set \"base-dir\" in the profile or use \"tag\" instead of \"path\" (path = false) to select " + + "set \"base-dir\" or \"source-base\" in the profile or use \"tag\" instead of \"path\" (path = false) to select " + "snapshots for restic commands.", "Affected paths are:", }, msg...) diff --git a/config/profile.go b/config/profile.go index e3c457fb..08e2fbed 100644 --- a/config/profile.go +++ b/config/profile.go @@ -172,6 +172,7 @@ type BackupSection struct { CheckAfter bool `mapstructure:"check-after" description:"Check the repository after the backup command succeeded"` UseStdin bool `mapstructure:"stdin" argument:"stdin"` StdinCommand []string `mapstructure:"stdin-command" description:"Shell command(s) that generate content to redirect into the stdin of restic. When set, the flag \"stdin\" is always set to \"true\"."` + SourceRelative bool `mapstructure:"source-relative" description:"Enable backup with relative source paths. This will change the working directory of the \"restic backup\" command to \"source-base\", and will not expand \"source\" to an absolute path."` SourceBase string `mapstructure:"source-base" examples:"/;$PWD;C:\\;%cd%" description:"The base path to resolve relative backup paths against. Defaults to current directory if unset or empty (see also \"base-dir\" in profile)"` Source []string `mapstructure:"source" examples:"/opt/;/home/user/;C:\\Users\\User\\Documents" description:"The paths to backup"` Exclude []string `mapstructure:"exclude" argument:"exclude" argument-type:"no-glob"` @@ -198,7 +199,7 @@ func (b *BackupSection) resolve(profile *Profile) { if b.unresolvedSource == nil { b.unresolvedSource = b.Source } - b.Source = profile.resolveSourcePath(b.SourceBase, b.unresolvedSource...) + b.Source = profile.resolveSourcePath(b.SourceBase, b.SourceRelative, b.unresolvedSource...) // Extras, only enabled for Version >= 2 (to remain backward compatible in version 1) if profile.config != nil && profile.config.version >= Version02 { @@ -648,18 +649,24 @@ func (p *Profile) SetRootPath(rootPath string) { } } -func (p *Profile) resolveSourcePath(sourceBase string, sourcePaths ...string) []string { +func (p *Profile) resolveSourcePath(sourceBase string, relativePaths bool, sourcePaths ...string) []string { var applySourceBase, applyBaseDir pathFix - // Backup source is NOT relative to the configuration, but to PWD or sourceBase (if not empty) - // Applying "sourceBase" if set - if sourceBase = strings.TrimSpace(sourceBase); sourceBase != "" { - sourceBase = fixPath(sourceBase, expandEnv, expandUserHome) - applySourceBase = absolutePrefix(sourceBase) - } - // Applying a custom PWD eagerly so that own commands (e.g. "show") display correct paths - if p.BaseDir != "" { - applyBaseDir = absolutePrefix(p.BaseDir) + sourceBase = fixPath(strings.TrimSpace(sourceBase), expandEnv, expandUserHome) + // When "source-relative" is set, the source paths are relative to the "source-base" + if !relativePaths { + // Backup source is NOT relative to the configuration, but to PWD or sourceBase (if not empty) + // Applying "sourceBase" if set + if sourceBase != "" { + applySourceBase = absolutePrefix(sourceBase) + } + // Applying a custom PWD eagerly so that own commands (e.g. "show") display correct paths + if p.BaseDir != "" { + applyBaseDir = absolutePrefix(p.BaseDir) + } + + } else if p.BaseDir == "" && sourceBase == "" && p.config != nil { + p.config.reportChangedPath(".", "", "source-base (for relative source)") } // prefix paths starting with "-" with a "./" to distinguish a source path from a flag @@ -693,7 +700,7 @@ func (p *Profile) SetTag(tags ...string) { // SetPath will replace any path value from a boolean to sourcePaths and change paths to absolute func (p *Profile) SetPath(basePath string, sourcePaths ...string) { resolvePath := func(origin string, paths []string, revolver func(string) []string) (resolved []string) { - hasAbsoluteBase := len(p.BaseDir) > 0 && filepath.IsAbs(p.BaseDir) + hasAbsoluteBase := len(p.BaseDir) > 0 && filepath.IsAbs(p.BaseDir) || basePath != "" && filepath.IsAbs(basePath) for _, path := range paths { if len(path) > 0 { for _, rp := range revolver(path) { @@ -720,7 +727,7 @@ func (p *Profile) SetPath(basePath string, sourcePaths ...string) { // Replace bool-true with absolute sourcePaths if !sourcePathsResolved { sourcePaths = resolvePath("path (from source)", sourcePaths, func(path string) []string { - return fixPaths(p.resolveSourcePath(basePath, path), absolutePath) + return fixPaths(p.resolveSourcePath(basePath, false, path), absolutePath) }) sourcePathsResolved = true } diff --git a/config/profile_test.go b/config/profile_test.go index ff231979..9714b390 100644 --- a/config/profile_test.go +++ b/config/profile_test.go @@ -636,9 +636,10 @@ func TestResolveSourcesWithFlagPrefixInBackup(t *testing.T) { } func TestResolveSourcesAgainstBase(t *testing.T) { - backupSource := func(base, source string) []string { + backupSource := func(base, source string, changeWorkingDir bool) []string { config := ` [profile.backup] + source-relative = ` + strconv.FormatBool(changeWorkingDir) + ` source-base = "` + filepath.ToSlash(base) + `" source = "` + filepath.ToSlash(source) + `" ` @@ -653,18 +654,27 @@ func TestResolveSourcesAgainstBase(t *testing.T) { assert.NoError(t, err) t.Run("no-base", func(t *testing.T) { - assert.Equal(t, []string{"src"}, backupSource("", "src")) + assert.Equal(t, []string{"src"}, backupSource("", "src", false)) }) t.Run("relative-base", func(t *testing.T) { - assert.Equal(t, []string{filepath.Join("rel", "src")}, backupSource("rel", "src")) + assert.Equal(t, []string{filepath.Join("rel", "src")}, backupSource("rel", "src", false)) }) t.Run("absolute-base", func(t *testing.T) { - assert.Equal(t, []string{filepath.Join(cwd, "src")}, backupSource(cwd, "src")) + assert.Equal(t, []string{filepath.Join(cwd, "src")}, backupSource(cwd, "src", false)) }) t.Run("env-var-base", func(t *testing.T) { assert.NoError(t, os.Setenv("RP_TEST_CWD", cwd)) defer os.Unsetenv("RP_TEST_CWD") - assert.Equal(t, []string{filepath.Join(cwd, "path", "src")}, backupSource("${RP_TEST_CWD}/path", "src")) + assert.Equal(t, []string{filepath.Join(cwd, "path", "src")}, backupSource("${RP_TEST_CWD}/path", "src", false)) + }) + t.Run("change-relative-working-dir", func(t *testing.T) { + assert.Equal(t, []string{"."}, backupSource("path", ".", true)) + assert.Equal(t, []string{filepath.Join("path", ".")}, backupSource("path", ".", false)) + }) + t.Run("change-env-var-working-dir", func(t *testing.T) { + assert.NoError(t, os.Setenv("RP_TEST_ENV", ".")) + defer os.Unsetenv("RP_TEST_ENV") + assert.Equal(t, []string{"."}, backupSource("path", "${RP_TEST_ENV}", true)) }) } diff --git a/docs/content/configuration/path.md b/docs/content/configuration/path.md index 5b2dcab6..f833d85f 100644 --- a/docs/content/configuration/path.md +++ b/docs/content/configuration/path.md @@ -101,6 +101,7 @@ default { {{% notice hint %}} Set `base-dir` to an absolute path to resolve `files` and `local:backup` relative to it. Set `source-base` if you need a separate base path for backup sources. +When you want to use relative source paths for backup, set the `source-relative` option. This will change the working directory of the `restic backup` command to `source-base` and will not expand `source` to an absolute path. {{% /notice %}} ## How the configuration file is resolved diff --git a/shell/command.go b/shell/command.go index db4fc510..e2f46513 100644 --- a/shell/command.go +++ b/shell/command.go @@ -114,6 +114,8 @@ func (c *Command) Run() (monitor.Summary, string, error) { cmd.Env = append(cmd.Env, c.Environ...) } + cmd.Dir = c.Dir + start := time.Now() // spawn the child process diff --git a/shell/command_test.go b/shell/command_test.go index f118cdfb..8a25575c 100644 --- a/shell/command_test.go +++ b/shell/command_test.go @@ -3,6 +3,7 @@ package shell import ( "bytes" "fmt" + "github.com/creativeprojects/resticprofile/platform" "io/ioutil" "os" "os/exec" @@ -285,6 +286,26 @@ func TestSelectCustomShell(t *testing.T) { assert.Empty(t, shell) } +func TestRunShellWorkingDir(t *testing.T) { + command := func() string { + if platform.IsWindows() { + return "@echo %CD%" + } + return "pwd" + }() + temp := t.TempDir() + buffer := new(strings.Builder) + cmd := NewCommand(command, nil) + cmd.Stdout = buffer + cmd.Dir = temp + _, _, err := cmd.Run() + if err != nil { + t.Fatal(err) + } + + assert.Contains(t, strings.TrimSpace(buffer.String()), temp) +} + func TestRunShellEcho(t *testing.T) { buffer := &bytes.Buffer{} cmd := NewCommand("echo", []string{"TestRunShellEcho"}) diff --git a/shell_command.go b/shell_command.go index 22930775..72417aed 100644 --- a/shell_command.go +++ b/shell_command.go @@ -18,6 +18,7 @@ type shellCommandDefinition struct { publicArgs []string env []string shell []string + dir string stdin io.ReadCloser stdout io.Writer stderr io.Writer @@ -85,6 +86,10 @@ func runShellCommand(command shellCommandDefinition) (summary monitor.Summary, s shellCmd.Environ = append(shellCmd.Environ, command.env...) } + // If Dir is the empty string, Run runs the command in the + // calling process's current directory. + shellCmd.Dir = command.dir + // scan output if command.scanOutput != nil { shellCmd.ScanStdout = command.scanOutput diff --git a/wrapper.go b/wrapper.go index 557a7066..c4ecb664 100644 --- a/wrapper.go +++ b/wrapper.go @@ -366,8 +366,12 @@ func (r *resticWrapper) prepareCommand(command string, args *shell.Args, allowEx args.AddArgs(moreArgs, shell.ArgCommandLineEscape) // Special case for backup command + var dir string if command == constants.CommandBackup { args.AddArgs(r.profile.GetBackupSource(), shell.ArgConfigBackupSource) + if r.profile.Backup != nil && r.profile.Backup.SourceRelative { + dir = r.profile.Backup.SourceBase + } } // Special case for copy command if command == constants.CommandCopy { @@ -409,6 +413,7 @@ func (r *resticWrapper) prepareCommand(command string, args *shell.Args, allowEx rCommand.stdout = term.GetOutput() rCommand.stderr = term.GetErrorOutput() rCommand.streamError = r.profile.StreamError + rCommand.dir = dir return rCommand }