diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..30cf57e
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/cfgsync.iml b/.idea/cfgsync.iml
new file mode 100644
index 0000000..4c94235
--- /dev/null
+++ b/.idea/cfgsync.iml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000..81469bd
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..79ee123
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/editor.xml b/.idea/editor.xml
new file mode 100644
index 0000000..7bb5dec
--- /dev/null
+++ b/.idea/editor.xml
@@ -0,0 +1,346 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..0b76fe5
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..d550675
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..e5a7a83
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 05abab5..5700533 100644
--- a/README.md
+++ b/README.md
@@ -149,6 +149,15 @@ After `use`, inspect tracked paths with `cfgsync list`, then restore everything
cfgsync restore ~/.gitconfig
```
+If the tracked paths in the registry point at an old machine or user path, remap that prefix during restore:
+
+```bash
+cfgsync restore --all --from-prefix /old/home/user --to-prefix ~/new-home
+cfgsync restore /old/home/user/.gitconfig --from-prefix /old/home/user --to-prefix ~
+```
+
+Prefix remapping only changes the restore destination for that command. It does not rewrite `registry.json`, move stored backups, or change the tracked original paths.
+
## Commands
### `cfgsync init --storage
`
@@ -271,12 +280,16 @@ Parent directories are created before files are restored. Existing destination f
If one tracked file cannot be restored, cfgsync reports that file, continues with the remaining entries, and exits with a failure after the batch finishes.
+Use `--from-prefix --to-prefix ` to restore tracked paths under an old prefix into an equivalent location under a new prefix without modifying the registry.
+
### `cfgsync restore `
Restores one tracked file from storage back to its original location.
The file path is normalized before lookup. The command fails if the file is not tracked or if no stored backup exists.
+Use `--from-prefix --to-prefix ` to restore a tracked original path to the matching destination under a new prefix. The `` argument remains the tracked original path from the registry.
+
## Storage Layout
The storage root is intentionally readable in v0:
diff --git a/src/cli/BuildCli.cpp b/src/cli/BuildCli.cpp
index ccd282f..4dca8c5 100644
--- a/src/cli/BuildCli.cpp
+++ b/src/cli/BuildCli.cpp
@@ -12,13 +12,46 @@
#include "commands/UseCommand.hpp"
#include "commands/WatchCommand.hpp"
#include "utils/LogUtils.hpp"
+#include "utils/PathUtils.hpp"
#include "watch/EfswFileWatcher.hpp"
#include
#include
+#include
#include
+#include
namespace cfgsync::cli {
+namespace {
+
+void ValidateRestoreArguments(bool restoreAll, std::string_view restoreFile, std::string_view fromPrefix,
+ std::string_view toPrefix) {
+ if (restoreAll && !restoreFile.empty()) {
+ throw CLI::ValidationError("restore", "Specify either '--all' or a file.");
+ }
+
+ if (!restoreAll && restoreFile.empty()) {
+ throw CLI::ValidationError("restore", "Specify either '--all' or a single file path to restore.");
+ }
+
+ if (fromPrefix.empty() != toPrefix.empty()) {
+ throw CLI::ValidationError("restore", "Specify '--from-prefix' and '--to-prefix' together.");
+ }
+}
+
+std::optional BuildRestorePrefixRemap(std::string_view fromPrefix,
+ std::string_view toPrefix) {
+ if (fromPrefix.empty()) {
+ return std::nullopt;
+ }
+
+ return commands::RestorePrefixRemap{
+ .FromPrefix = utils::NormalizePath(std::filesystem::path{std::string{fromPrefix}}),
+ .ToPrefix = utils::NormalizePath(std::filesystem::path{std::string{toPrefix}}),
+ };
+}
+
+} // namespace
void BuildCli(CLI::App& app, core::Registry& registry, storage::StorageManager& storageManager,
core::AppConfig& appConfig) {
@@ -110,27 +143,26 @@ void BuildCli(CLI::App& app, core::Registry& registry, storage::StorageManager&
auto* restoreCommand = app.add_subcommand("restore", "Restore one tracked file or all tracked files.");
auto restoreAll = std::make_shared(false);
auto restoreFile = std::make_shared();
+ auto restoreFromPrefix = std::make_shared();
+ auto restoreToPrefix = std::make_shared();
restoreCommand->add_flag("--all", *restoreAll, "Restore every tracked file.");
+ restoreCommand->add_option("--from-prefix", *restoreFromPrefix, "Tracked original path prefix to remap from.");
+ restoreCommand->add_option("--to-prefix", *restoreToPrefix, "Destination path prefix to remap to.");
restoreCommand->add_option("file", *restoreFile, "Tracked file path to restore.");
- restoreCommand->callback([®istry, &storageManager, loadActiveStorage, restoreAll, restoreFile]() {
- if (*restoreAll && !restoreFile->empty()) {
- throw CLI::ValidationError("restore", "Specify either '--all' or a file.");
- }
-
- if (!*restoreAll && restoreFile->empty()) {
- throw CLI::ValidationError("restore", "Specify either '--all' or a single file path to restore.");
- }
-
- loadActiveStorage();
- const commands::RestoreCommand command{registry, storageManager};
-
- if (*restoreAll) {
- command.ExecuteAll();
- return;
- }
-
- command.ExecuteSingle(std::filesystem::path{*restoreFile});
- });
+ restoreCommand->callback(
+ [®istry, &storageManager, loadActiveStorage, restoreAll, restoreFile, restoreFromPrefix, restoreToPrefix]() {
+ ValidateRestoreArguments(*restoreAll, *restoreFile, *restoreFromPrefix, *restoreToPrefix);
+ const auto remap = BuildRestorePrefixRemap(*restoreFromPrefix, *restoreToPrefix);
+ loadActiveStorage();
+ const commands::RestoreCommand command{registry, storageManager};
+
+ if (*restoreAll) {
+ command.ExecuteAll(remap);
+ return;
+ }
+
+ command.ExecuteSingle(std::filesystem::path{*restoreFile}, remap);
+ });
}
} // namespace cfgsync::cli
diff --git a/src/commands/RestoreCommand.cpp b/src/commands/RestoreCommand.cpp
index 294b30c..1947df8 100644
--- a/src/commands/RestoreCommand.cpp
+++ b/src/commands/RestoreCommand.cpp
@@ -9,11 +9,56 @@
#include
namespace cfgsync::commands {
+namespace fs = std::filesystem;
+
+namespace {
+
+bool HasPathPrefix(const fs::path& path, const fs::path& prefix) {
+ auto pathIterator = path.begin();
+ auto prefixIterator = prefix.begin();
+ for (; prefixIterator != prefix.end(); ++prefixIterator, ++pathIterator) {
+ if (pathIterator == path.end() || *pathIterator != *prefixIterator) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+fs::path RemapDestinationPath(const fs::path& originalPath, const RestorePrefixRemap& remap) {
+ if (!HasPathPrefix(originalPath, remap.FromPrefix)) {
+ throw CommandError{"Tracked file is outside --from-prefix: " + originalPath.string()};
+ }
+
+ auto pathIterator = originalPath.begin();
+ for (auto prefixIterator = remap.FromPrefix.begin(); prefixIterator != remap.FromPrefix.end(); ++prefixIterator) {
+ ++pathIterator;
+ }
+
+ auto destinationPath = remap.ToPrefix;
+ for (; pathIterator != originalPath.end(); ++pathIterator) {
+ destinationPath /= *pathIterator;
+ }
+
+ return destinationPath.lexically_normal();
+}
+
+fs::path GetRestoreDestinationPath(const core::TrackedEntry& trackedEntry,
+ const std::optional& remap) {
+ const fs::path originalPath{trackedEntry.OriginalPath};
+ if (!remap.has_value()) {
+ return originalPath;
+ }
+
+ return RemapDestinationPath(originalPath, *remap);
+}
+
+} // namespace
RestoreCommand::RestoreCommand(core::Registry& registry, storage::StorageManager& storageManager)
: Registry_(registry), StorageManager_(storageManager) {}
-void RestoreCommand::ExecuteAll() const {
+void RestoreCommand::ExecuteAll(const std::optional& remap) const {
const auto& trackedEntries = Registry_.GetTrackedEntries();
if (trackedEntries.empty()) {
utils::LogInfo("No files tracked.");
@@ -23,11 +68,14 @@ void RestoreCommand::ExecuteAll() const {
std::size_t failureCount = 0;
for (const auto& trackedEntry : trackedEntries) {
try {
- StorageManager_.RestoreEntry(trackedEntry);
+ StorageManager_.RestoreEntry(trackedEntry, GetRestoreDestinationPath(trackedEntry, remap));
utils::LogInfo("Restored file: " + trackedEntry.OriginalPath);
} catch (const FileError& error) {
++failureCount;
utils::LogWarn("Failed to restore file: " + trackedEntry.OriginalPath + ": " + error.what());
+ } catch (const CommandError& error) {
+ ++failureCount;
+ utils::LogWarn("Failed to restore file: " + trackedEntry.OriginalPath + ": " + error.what());
}
}
@@ -37,14 +85,15 @@ void RestoreCommand::ExecuteAll() const {
}
}
-void RestoreCommand::ExecuteSingle(const std::filesystem::path& filePath) const {
+void RestoreCommand::ExecuteSingle(const std::filesystem::path& filePath,
+ const std::optional& remap) const {
const auto normalizedPath = utils::NormalizePath(filePath);
const auto* trackedEntry = Registry_.FindEntryByOriginalPath(normalizedPath);
if (trackedEntry == nullptr) {
throw CommandError{"File is not tracked: " + normalizedPath.string()};
}
- StorageManager_.RestoreEntry(*trackedEntry);
+ StorageManager_.RestoreEntry(*trackedEntry, GetRestoreDestinationPath(*trackedEntry, remap));
utils::LogInfo("Restored file: " + trackedEntry->OriginalPath);
}
diff --git a/src/commands/RestoreCommand.hpp b/src/commands/RestoreCommand.hpp
index bba8b1d..64d808a 100644
--- a/src/commands/RestoreCommand.hpp
+++ b/src/commands/RestoreCommand.hpp
@@ -4,15 +4,22 @@
#include "storage/StorageManager.hpp"
#include
+#include
namespace cfgsync::commands {
+struct RestorePrefixRemap {
+ std::filesystem::path FromPrefix;
+ std::filesystem::path ToPrefix;
+};
+
class RestoreCommand {
public:
RestoreCommand(core::Registry& registry, storage::StorageManager& storageManager);
- void ExecuteAll() const;
- void ExecuteSingle(const std::filesystem::path& filePath) const;
+ void ExecuteAll(const std::optional& remap = std::nullopt) const;
+ void ExecuteSingle(const std::filesystem::path& filePath,
+ const std::optional& remap = std::nullopt) const;
private:
core::Registry& Registry_; // NOLINT(cppcoreguidelines-avoid-const-or-ref-data-members)
diff --git a/src/storage/StorageManager.cpp b/src/storage/StorageManager.cpp
index 811041a..dc73308 100644
--- a/src/storage/StorageManager.cpp
+++ b/src/storage/StorageManager.cpp
@@ -74,8 +74,11 @@ void StorageManager::BackupEntry(const core::TrackedEntry& entry) const {
}
void StorageManager::RestoreEntry(const core::TrackedEntry& entry) const {
+ RestoreEntry(entry, std::filesystem::path{entry.OriginalPath});
+}
+
+void StorageManager::RestoreEntry(const core::TrackedEntry& entry, const std::filesystem::path& destinationPath) const {
const auto sourcePath = ResolveStoredPath(entry);
- const std::filesystem::path destinationPath{entry.OriginalPath};
utils::RequireOrdinaryFile(sourcePath);
utils::CopyFile(sourcePath, destinationPath);
diff --git a/src/storage/StorageManager.hpp b/src/storage/StorageManager.hpp
index 9994b22..fe7bf3f 100644
--- a/src/storage/StorageManager.hpp
+++ b/src/storage/StorageManager.hpp
@@ -17,6 +17,7 @@ class StorageManager {
std::filesystem::path ResolveStoredPath(const core::TrackedEntry& entry) const;
void BackupEntry(const core::TrackedEntry& entry) const;
void RestoreEntry(const core::TrackedEntry& entry) const;
+ void RestoreEntry(const core::TrackedEntry& entry, const std::filesystem::path& destinationPath) const;
private:
std::filesystem::path StorageRoot_;
diff --git a/tests/RegistryTests.cpp b/tests/RegistryTests.cpp
index 9823f6a..cde811c 100644
--- a/tests/RegistryTests.cpp
+++ b/tests/RegistryTests.cpp
@@ -206,17 +206,10 @@ TEST_F(RegistryTest, DuplicateOriginalPathsInFileThrowClearError) {
TEST_F(RegistryTest, UnsafeStoredRelativePathsThrowClearError) {
const std::vector unsafeStoredRelativePaths{
- "",
- "../escape",
- "files/../../escape",
- "files/../x",
- R"(files\..\x)",
- (StorageRoot() / "escape").string(),
- "/files/x",
- "C:files/x",
- R"(C:\files\x)",
- "backup/foo",
- "files",
+ "", "../escape", "files/../../escape",
+ "files/../x", R"(files\..\x)", (StorageRoot() / "escape").string(),
+ "/files/x", "C:files/x", R"(C:\files\x)",
+ "backup/foo", "files",
};
for (const auto& storedRelativePath : unsafeStoredRelativePaths) {
diff --git a/tests/RestoreCommandCliTests.cpp b/tests/RestoreCommandCliTests.cpp
index 061aeda..75a601a 100644
--- a/tests/RestoreCommandCliTests.cpp
+++ b/tests/RestoreCommandCliTests.cpp
@@ -20,6 +20,20 @@ class RestoreCommandCliTest : public cfgsync::tests::CliCommandTestFixture {
cfgsync::tests::CommandResult RunRestoreSingleCommand(const fs::path& sourcePath) const {
return RunCommand("restore " + cfgsync::tests::QuoteForCommand(sourcePath));
}
+
+ cfgsync::tests::CommandResult RunRestoreAllWithRemapCommand(const fs::path& fromPrefix,
+ const fs::path& toPrefix) const {
+ return RunCommand("restore --all --from-prefix " + cfgsync::tests::QuoteForCommand(fromPrefix) +
+ " --to-prefix " + cfgsync::tests::QuoteForCommand(toPrefix));
+ }
+
+ cfgsync::tests::CommandResult RunRestoreSingleWithRemapCommand(const fs::path& sourcePath,
+ const fs::path& fromPrefix,
+ const fs::path& toPrefix) const {
+ return RunCommand("restore " + cfgsync::tests::QuoteForCommand(sourcePath) + " --from-prefix " +
+ cfgsync::tests::QuoteForCommand(fromPrefix) + " --to-prefix " +
+ cfgsync::tests::QuoteForCommand(toPrefix));
+ }
};
TEST_F(RestoreCommandCliTest, RestoreSingleUsesActiveStorageRootPersistedByInit) {
@@ -87,6 +101,54 @@ TEST_F(RestoreCommandCliTest, RestoreOverwritesChangedLocalFile) {
EXPECT_EQ(cfgsync::tests::ReadTextFile(sourcePath), "stored contents\n");
}
+TEST_F(RestoreCommandCliTest, RestoreSingleWithPrefixRemapRestoresToRemappedDestination) {
+ const auto sourcePath = SourcePath(".config/nvim/init.lua");
+ const auto fromPrefix = SourcePath(".placeholder").parent_path();
+ const auto toPrefix = GetTestRoot() / "new-configs";
+ const auto destinationPath = toPrefix / ".config" / "nvim" / "init.lua";
+ cfgsync::tests::WriteTextFile(sourcePath, "vim.opt.number = true\n");
+ ASSERT_TRUE(RunInitCommand());
+ ASSERT_TRUE(RunAddCommand(sourcePath));
+ ASSERT_EQ(RunBackupCommand().ExitCode, 0);
+
+ const auto result = RunRestoreSingleWithRemapCommand(sourcePath, fromPrefix, toPrefix);
+
+ EXPECT_EQ(result.ExitCode, 0);
+ EXPECT_TRUE(result.Error.empty());
+ EXPECT_EQ(cfgsync::tests::ReadTextFile(destinationPath), "vim.opt.number = true\n");
+}
+
+TEST_F(RestoreCommandCliTest, RestoreAllWithPrefixRemapRestoresToRemappedDestinations) {
+ const auto firstPath = SourcePath(".gitconfig");
+ const auto secondPath = SourcePath(".config/nvim/init.lua");
+ const auto fromPrefix = SourcePath(".placeholder").parent_path();
+ const auto toPrefix = GetTestRoot() / "new-configs";
+ cfgsync::tests::WriteTextFile(firstPath, "[user]\n");
+ cfgsync::tests::WriteTextFile(secondPath, "vim.opt.number = true\n");
+ ASSERT_TRUE(RunInitCommand());
+ ASSERT_TRUE(RunAddCommand(firstPath));
+ ASSERT_TRUE(RunAddCommand(secondPath));
+ ASSERT_EQ(RunBackupCommand().ExitCode, 0);
+
+ const auto result = RunRestoreAllWithRemapCommand(fromPrefix, toPrefix);
+
+ EXPECT_EQ(result.ExitCode, 0);
+ EXPECT_TRUE(result.Error.empty());
+ EXPECT_EQ(cfgsync::tests::ReadTextFile(toPrefix / ".gitconfig"), "[user]\n");
+ EXPECT_EQ(cfgsync::tests::ReadTextFile(toPrefix / ".config" / "nvim" / "init.lua"), "vim.opt.number = true\n");
+}
+
+TEST_F(RestoreCommandCliTest, RestoreWithOnlyOnePrefixFlagReturnsNonZero) {
+ ASSERT_TRUE(RunInitCommand());
+
+ const auto result = RunCommand("restore --all --from-prefix " +
+ cfgsync::tests::QuoteForCommand(SourcePath(".placeholder").parent_path()));
+
+ EXPECT_NE(result.ExitCode, 0);
+ EXPECT_TRUE(result.Output.empty());
+ EXPECT_NE(result.Error.find("Specify '--from-prefix' and '--to-prefix' together."), std::string::npos);
+}
+
TEST_F(RestoreCommandCliTest, SingleRestoreOfUntrackedFileReturnsNonZero) {
const auto trackedPath = SourcePath(".gitconfig");
const auto untrackedPath = SourcePath("untracked.conf");
diff --git a/tests/RestoreCommandTests.cpp b/tests/RestoreCommandTests.cpp
index de464ed..40775f5 100644
--- a/tests/RestoreCommandTests.cpp
+++ b/tests/RestoreCommandTests.cpp
@@ -79,6 +79,72 @@ TEST_F(RestoreCommandTest, OverwritesChangedLocalFile) {
EXPECT_EQ(cfgsync::tests::ReadTextFile(sourcePath), "stored contents\n");
}
+TEST_F(RestoreCommandTest, SingleRestoreWithPrefixRemapRestoresToRemappedDestination) {
+ const auto sourcePath = SourcePath(".config/nvim/init.lua");
+ const auto storedRelativePath = TrackFile(Registry(), sourcePath);
+ const auto fromPrefix = sourcePath.parent_path().parent_path().parent_path();
+ const auto toPrefix = StorageRoot().parent_path() / "new-home" / "user";
+ const auto destinationPath = toPrefix / ".config" / "nvim" / "init.lua";
+ cfgsync::tests::WriteTextFile(StorageRoot() / storedRelativePath, "vim.opt.number = true\n");
+ const auto registryBeforeRestore = cfgsync::tests::ReadJsonFile(RegistryPath());
+
+ cfgsync::storage::StorageManager storageManager{StorageRoot()};
+ const cfgsync::commands::RestoreCommand command{Registry(), storageManager};
+
+ command.ExecuteSingle(sourcePath, cfgsync::commands::RestorePrefixRemap{
+ .FromPrefix = cfgsync::utils::NormalizePath(fromPrefix),
+ .ToPrefix = cfgsync::utils::NormalizePath(toPrefix),
+ });
+
+ EXPECT_EQ(cfgsync::tests::ReadTextFile(destinationPath), "vim.opt.number = true\n");
+ EXPECT_EQ(cfgsync::tests::ReadJsonFile(RegistryPath()), registryBeforeRestore);
+}
+
+TEST_F(RestoreCommandTest, RestoreAllWithPrefixRemapRestoresMultipleFilesToRemappedDestinations) {
+ const auto firstPath = SourcePath(".gitconfig");
+ const auto secondPath = SourcePath(".config/nvim/init.lua");
+ const auto firstStoredRelativePath = TrackFile(Registry(), firstPath);
+ const auto secondStoredRelativePath = TrackFile(Registry(), secondPath);
+ const auto fromPrefix = SourcePath().parent_path();
+ const auto toPrefix = StorageRoot().parent_path() / "new-home" / "user";
+ cfgsync::tests::WriteTextFile(StorageRoot() / firstStoredRelativePath, "[user]\n");
+ cfgsync::tests::WriteTextFile(StorageRoot() / secondStoredRelativePath, "vim.opt.number = true\n");
+
+ cfgsync::storage::StorageManager storageManager{StorageRoot()};
+ const cfgsync::commands::RestoreCommand command{Registry(), storageManager};
+
+ command.ExecuteAll(cfgsync::commands::RestorePrefixRemap{
+ .FromPrefix = cfgsync::utils::NormalizePath(fromPrefix),
+ .ToPrefix = cfgsync::utils::NormalizePath(toPrefix),
+ });
+
+ EXPECT_EQ(cfgsync::tests::ReadTextFile(toPrefix / ".gitconfig"), "[user]\n");
+ EXPECT_EQ(cfgsync::tests::ReadTextFile(toPrefix / ".config" / "nvim" / "init.lua"), "vim.opt.number = true\n");
+}
+
+TEST_F(RestoreCommandTest, SingleRestoreWithPrefixRemapFailsWhenTrackedFileIsOutsidePrefix) {
+ const auto sourcePath = SourcePath(".gitconfig");
+ const auto storedRelativePath = TrackFile(Registry(), sourcePath);
+ const auto fromPrefix = StorageRoot().parent_path() / "other-home" / "user";
+ const auto toPrefix = StorageRoot().parent_path() / "new-home" / "user";
+ cfgsync::tests::WriteTextFile(StorageRoot() / storedRelativePath, "[user]\n");
+
+ cfgsync::storage::StorageManager storageManager{StorageRoot()};
+ const cfgsync::commands::RestoreCommand command{Registry(), storageManager};
+
+ try {
+ command.ExecuteSingle(sourcePath, cfgsync::commands::RestorePrefixRemap{
+ .FromPrefix = cfgsync::utils::NormalizePath(fromPrefix),
+ .ToPrefix = cfgsync::utils::NormalizePath(toPrefix),
+ });
+ FAIL() << "Restore with a non-matching prefix did not throw.";
+ } catch (const cfgsync::CommandError& error) {
+ const std::string message = error.what();
+ EXPECT_NE(message.find("outside --from-prefix"), std::string::npos);
+ EXPECT_NE(message.find(cfgsync::utils::NormalizePath(sourcePath).string()), std::string::npos);
+ }
+}
+
TEST_F(RestoreCommandTest, SingleRestoreFailsForUntrackedFile) {
cfgsync::storage::StorageManager storageManager{StorageRoot()};
const cfgsync::commands::RestoreCommand command{Registry(), storageManager};
@@ -134,6 +200,35 @@ TEST_F(RestoreCommandTest, RestoreAllContinuesAfterMissingStoredBackupAndReports
EXPECT_EQ(cfgsync::tests::ReadJsonFile(RegistryPath()), registryBeforeRestore);
}
+TEST_F(RestoreCommandTest, RestoreAllWithPrefixRemapContinuesWhenEntryIsOutsidePrefix) {
+ const auto restoredPath = SourcePath(".gitconfig");
+ const auto outsidePath = StorageRoot().parent_path() / "other-home" / "user" / "settings.conf";
+ const auto restoredStoredRelativePath = TrackFile(Registry(), restoredPath);
+ const auto outsideStoredRelativePath = TrackFile(Registry(), outsidePath);
+ const auto fromPrefix = SourcePath().parent_path();
+ const auto toPrefix = StorageRoot().parent_path() / "new-home" / "user";
+ cfgsync::tests::WriteTextFile(StorageRoot() / restoredStoredRelativePath, "[user]\n");
+ cfgsync::tests::WriteTextFile(StorageRoot() / outsideStoredRelativePath, "outside\n");
+ const auto registryBeforeRestore = cfgsync::tests::ReadJsonFile(RegistryPath());
+
+ cfgsync::storage::StorageManager storageManager{StorageRoot()};
+ const cfgsync::commands::RestoreCommand command{Registry(), storageManager};
+
+ try {
+ command.ExecuteAll(cfgsync::commands::RestorePrefixRemap{
+ .FromPrefix = cfgsync::utils::NormalizePath(fromPrefix),
+ .ToPrefix = cfgsync::utils::NormalizePath(toPrefix),
+ });
+ FAIL() << "Restore with a non-matching prefix did not throw.";
+ } catch (const cfgsync::CommandError& error) {
+ const std::string message = error.what();
+ EXPECT_NE(message.find("Restore completed with 1 failure."), std::string::npos);
+ }
+
+ EXPECT_EQ(cfgsync::tests::ReadTextFile(toPrefix / ".gitconfig"), "[user]\n");
+ EXPECT_EQ(cfgsync::tests::ReadJsonFile(RegistryPath()), registryBeforeRestore);
+}
+
TEST_F(RestoreCommandTest, EmptyRegistrySucceedsWithoutCreatingStoredFiles) {
cfgsync::storage::StorageManager storageManager{StorageRoot()};
const cfgsync::commands::RestoreCommand command{Registry(), storageManager};
diff --git a/tests/StorageManagerTests.cpp b/tests/StorageManagerTests.cpp
index 464360d..3a06ca4 100644
--- a/tests/StorageManagerTests.cpp
+++ b/tests/StorageManagerTests.cpp
@@ -69,17 +69,10 @@ TEST_F(StorageManagerTest, ResolvesStoredPathFromTrackedRelativePath) {
TEST_F(StorageManagerTest, UnsafeStoredRelativePathsThrowFileError) {
const cfgsync::storage::StorageManager storageManager{StorageRoot()};
const std::vector unsafeStoredRelativePaths{
- "",
- "../escape",
- "files/../../escape",
- "files/../x",
- R"(files\..\x)",
- (StorageRoot() / "escape").string(),
- "/files/x",
- "C:files/x",
- R"(C:\files\x)",
- "backup/foo",
- "files",
+ "", "../escape", "files/../../escape",
+ "files/../x", R"(files\..\x)", (StorageRoot() / "escape").string(),
+ "/files/x", "C:files/x", R"(C:\files\x)",
+ "backup/foo", "files",
};
for (const auto& storedRelativePath : unsafeStoredRelativePaths) {
@@ -115,6 +108,18 @@ TEST_F(StorageManagerTest, RestoreCopiesStoredPathToOriginalDestination) {
EXPECT_EQ(cfgsync::tests::ReadTextFile(sourcePath), "vim.opt.number = true\n");
}
+TEST_F(StorageManagerTest, RestoreCopiesStoredPathToExplicitDestination) {
+ const auto sourcePath = SourcePath(".config/nvim/init.lua");
+ const auto destinationPath = StorageRoot().parent_path() / "new-home" / "user" / ".config" / "nvim" / "init.lua";
+ const auto entry = TrackedEntryFor(sourcePath);
+ const cfgsync::storage::StorageManager storageManager{StorageRoot()};
+ cfgsync::tests::WriteTextFile(storageManager.ResolveStoredPath(entry), "vim.opt.number = true\n");
+
+ storageManager.RestoreEntry(entry, destinationPath);
+
+ EXPECT_EQ(cfgsync::tests::ReadTextFile(destinationPath), "vim.opt.number = true\n");
+}
+
} // namespace
int main(int argc, char** argv) { return cfgsync::tests::RunGoogleTests(argc, argv); }