From 6295a2439cc63801ffb226842a0fb4038e648619 Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Sun, 21 Jun 2026 19:04:37 +0200 Subject: [PATCH 01/13] feat(.idea): Add initial IDE project configuration --- .idea/.gitignore | 10 ++++++++++ .idea/cfgsync.iml | 2 ++ .idea/misc.xml | 7 +++++++ .idea/modules.xml | 8 ++++++++ 4 files changed, 27 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/cfgsync.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml 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/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 From 0995afffe6a6b4ccd10684947b07a3aa69aa4a41 Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Sun, 21 Jun 2026 19:04:37 +0200 Subject: [PATCH 02/13] style(.idea): Configure C++ code style settings --- .idea/codeStyles/Project.xml | 105 +++++++++++++++++++++++++++ .idea/codeStyles/codeStyleConfig.xml | 5 ++ 2 files changed, 110 insertions(+) create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml 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 From d5166b06f794d4e8842ffbfb169fb8fbee957f90 Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Sun, 21 Jun 2026 19:04:37 +0200 Subject: [PATCH 03/13] config(.idea): Adjust editor and inspection settings --- .idea/editor.xml | 346 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 .idea/editor.xml 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 From d683aa1f3e5f8dcbd1a6aca563f02842998df730 Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Sun, 21 Jun 2026 19:04:37 +0200 Subject: [PATCH 04/13] config(.idea): Add Git VCS mappings for dependencies --- .idea/vcs.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .idea/vcs.xml 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 From 6b1197426080881623270ccc6bd1d9fc21474066 Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Thu, 25 Jun 2026 14:06:50 +0200 Subject: [PATCH 05/13] feat(storage): Allow explicit destination path for RestoreEntry --- src/storage/StorageManager.cpp | 5 ++++- src/storage/StorageManager.hpp | 1 + tests/StorageManagerTests.cpp | 12 ++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) 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/StorageManagerTests.cpp b/tests/StorageManagerTests.cpp index 464360d..1eb481f 100644 --- a/tests/StorageManagerTests.cpp +++ b/tests/StorageManagerTests.cpp @@ -115,6 +115,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); } From f04b4c0976fa0e3c75d61349ebcd2430e4e4b208 Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Thu, 25 Jun 2026 14:06:50 +0200 Subject: [PATCH 06/13] feat(commands/restore): Introduce prefix remapping logic --- src/commands/RestoreCommand.cpp | 47 ++++++++++++++++++++++++++++++++- src/commands/RestoreCommand.hpp | 11 ++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/commands/RestoreCommand.cpp b/src/commands/RestoreCommand.cpp index 294b30c..6428ab5 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."); 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) From 3cd6d824f3cd37ad358627baaee95fbb7d37677c Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Thu, 25 Jun 2026 14:06:50 +0200 Subject: [PATCH 07/13] feat(commands/restore): Integrate prefix remapping into restore operations --- src/commands/RestoreCommand.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/commands/RestoreCommand.cpp b/src/commands/RestoreCommand.cpp index 6428ab5..1947df8 100644 --- a/src/commands/RestoreCommand.cpp +++ b/src/commands/RestoreCommand.cpp @@ -68,11 +68,14 @@ void RestoreCommand::ExecuteAll(const std::optional& remap) 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()); } } @@ -82,14 +85,15 @@ void RestoreCommand::ExecuteAll(const std::optional& remap) } } -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); } From 69196764d84f7ca883d52de8a207a022d8afc79c Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Thu, 25 Jun 2026 14:06:50 +0200 Subject: [PATCH 08/13] feat(cli/restore): Add prefix remapping options to restore command --- src/cli/BuildCli.cpp | 59 ++++++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/src/cli/BuildCli.cpp b/src/cli/BuildCli.cpp index ccd282f..a385d00 100644 --- a/src/cli/BuildCli.cpp +++ b/src/cli/BuildCli.cpp @@ -12,10 +12,12 @@ #include "commands/UseCommand.hpp" #include "commands/WatchCommand.hpp" #include "utils/LogUtils.hpp" +#include "utils/PathUtils.hpp" #include "watch/EfswFileWatcher.hpp" #include #include +#include #include namespace cfgsync::cli { @@ -110,27 +112,46 @@ 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]() { + 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."); + } + + const auto hasFromPrefix = !restoreFromPrefix->empty(); + const auto hasToPrefix = !restoreToPrefix->empty(); + if (hasFromPrefix != hasToPrefix) { + throw CLI::ValidationError("restore", "Specify '--from-prefix' and '--to-prefix' together."); + } + + std::optional remap; + if (hasFromPrefix) { + remap = commands::RestorePrefixRemap{ + .FromPrefix = utils::NormalizePath(std::filesystem::path{*restoreFromPrefix}), + .ToPrefix = utils::NormalizePath(std::filesystem::path{*restoreToPrefix}), + }; + } + + loadActiveStorage(); + const commands::RestoreCommand command{registry, storageManager}; + + if (*restoreAll) { + command.ExecuteAll(remap); + return; + } + + command.ExecuteSingle(std::filesystem::path{*restoreFile}, remap); + }); } } // namespace cfgsync::cli From c9a024633e7863bff1d50a7883fb0681255529cb Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Thu, 25 Jun 2026 14:06:50 +0200 Subject: [PATCH 09/13] test(restore): Add comprehensive tests for prefix remapping --- tests/RestoreCommandCliTests.cpp | 62 +++++++++++++++++++++ tests/RestoreCommandTests.cpp | 95 ++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) 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}; From 76ded9d1c9200bf9ab8decc6ab304bfb7aafe372 Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Thu, 25 Jun 2026 14:06:50 +0200 Subject: [PATCH 10/13] docs: Document restore prefix remapping --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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: From 2e52490db95ace08018aee96e9e829e694308edb Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Thu, 25 Jun 2026 14:06:50 +0200 Subject: [PATCH 11/13] refactor: Reformat test vector initializers --- tests/RegistryTests.cpp | 15 ++++----------- tests/StorageManagerTests.cpp | 15 ++++----------- 2 files changed, 8 insertions(+), 22 deletions(-) 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/StorageManagerTests.cpp b/tests/StorageManagerTests.cpp index 1eb481f..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) { From faed642dc207ebecdad768a56cf622c0ff96867d Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Thu, 25 Jun 2026 14:32:49 +0200 Subject: [PATCH 12/13] refactor(cli): Extract restore command argument logic into helpers --- src/cli/BuildCli.cpp | 54 ++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/src/cli/BuildCli.cpp b/src/cli/BuildCli.cpp index a385d00..6ad3c29 100644 --- a/src/cli/BuildCli.cpp +++ b/src/cli/BuildCli.cpp @@ -21,6 +21,36 @@ #include namespace cfgsync::cli { +namespace { + +void ValidateRestoreArguments(bool restoreAll, const std::string& restoreFile, const std::string& fromPrefix, + const std::string& 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(const std::string& fromPrefix, + const std::string& toPrefix) { + if (fromPrefix.empty()) { + return std::nullopt; + } + + return commands::RestorePrefixRemap{ + .FromPrefix = utils::NormalizePath(std::filesystem::path{fromPrefix}), + .ToPrefix = utils::NormalizePath(std::filesystem::path{toPrefix}), + }; +} + +} // namespace void BuildCli(CLI::App& app, core::Registry& registry, storage::StorageManager& storageManager, core::AppConfig& appConfig) { @@ -120,28 +150,8 @@ void BuildCli(CLI::App& app, core::Registry& registry, storage::StorageManager& restoreCommand->add_option("file", *restoreFile, "Tracked file path to restore."); restoreCommand->callback( [®istry, &storageManager, loadActiveStorage, restoreAll, restoreFile, restoreFromPrefix, restoreToPrefix]() { - 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."); - } - - const auto hasFromPrefix = !restoreFromPrefix->empty(); - const auto hasToPrefix = !restoreToPrefix->empty(); - if (hasFromPrefix != hasToPrefix) { - throw CLI::ValidationError("restore", "Specify '--from-prefix' and '--to-prefix' together."); - } - - std::optional remap; - if (hasFromPrefix) { - remap = commands::RestorePrefixRemap{ - .FromPrefix = utils::NormalizePath(std::filesystem::path{*restoreFromPrefix}), - .ToPrefix = utils::NormalizePath(std::filesystem::path{*restoreToPrefix}), - }; - } - + ValidateRestoreArguments(*restoreAll, *restoreFile, *restoreFromPrefix, *restoreToPrefix); + const auto remap = BuildRestorePrefixRemap(*restoreFromPrefix, *restoreToPrefix); loadActiveStorage(); const commands::RestoreCommand command{registry, storageManager}; From 3b8c955abfa8a5191ddb72996865401882c6bae2 Mon Sep 17 00:00:00 2001 From: Oleksii Gaidadim Date: Thu, 25 Jun 2026 14:49:26 +0200 Subject: [PATCH 13/13] feat: Use `std::string_view` for read-only string parameters in CLI build functions --- src/cli/BuildCli.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/cli/BuildCli.cpp b/src/cli/BuildCli.cpp index 6ad3c29..4dca8c5 100644 --- a/src/cli/BuildCli.cpp +++ b/src/cli/BuildCli.cpp @@ -19,12 +19,13 @@ #include #include #include +#include namespace cfgsync::cli { namespace { -void ValidateRestoreArguments(bool restoreAll, const std::string& restoreFile, const std::string& fromPrefix, - const std::string& toPrefix) { +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."); } @@ -38,15 +39,15 @@ void ValidateRestoreArguments(bool restoreAll, const std::string& restoreFile, c } } -std::optional BuildRestorePrefixRemap(const std::string& fromPrefix, - const std::string& toPrefix) { +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{fromPrefix}), - .ToPrefix = utils::NormalizePath(std::filesystem::path{toPrefix}), + .FromPrefix = utils::NormalizePath(std::filesystem::path{std::string{fromPrefix}}), + .ToPrefix = utils::NormalizePath(std::filesystem::path{std::string{toPrefix}}), }; }