diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index fd0f36e..2f51fc1 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -84,26 +84,22 @@ jobs: echo "LLVM_COV=llvm-cov-18" } >> "$GITHUB_ENV" - - name: Install SonarQube Build Wrapper - uses: SonarSource/sonarqube-scan-action/install-build-wrapper@v6 - - name: Configure CMake for SonarQube shell: bash run: | cmake -B "${{ steps.strings.outputs.build-output-dir }}" \ -DCMAKE_CXX_COMPILER="${CXX}" \ -DCMAKE_C_COMPILER="${CC}" \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ -DCMAKE_BUILD_TYPE=Debug \ -DCFGSYNC_BUILD_TESTS=ON \ -DCFGSYNC_ENABLE_COVERAGE=ON \ -S "${{ github.workspace }}" - - name: Build with SonarQube Build Wrapper + - name: Build shell: bash run: | - build-wrapper-linux-x86-64 \ - --out-dir bw-output \ - cmake --build "${{ steps.strings.outputs.build-output-dir }}" --config Debug + cmake --build "${{ steps.strings.outputs.build-output-dir }}" --config Debug - name: Test with LLVM coverage shell: bash @@ -228,7 +224,7 @@ jobs: SONAR_HOST_URL: https://sonarcloud.io with: args: > - -Dsonar.cfamily.compile-commands=bw-output/compile_commands.json + -Dsonar.cfamily.compile-commands=${{ steps.strings.outputs.build-output-dir }}/compile_commands.json -Dsonar.cfamily.llvm-cov.reportPath=${{ steps.strings.outputs.coverage-output-dir }}/llvm-cov.txt -Dsonar.qualitygate.wait=true diff --git a/README.md b/README.md index 5700533..11509fa 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Typical flow: 1. Run `cfgsync init --storage ` once to create the storage directory and record it as the active storage root. 2. Run `cfgsync add ` for one ordinary config file, or `cfgsync add ` to recursively import existing ordinary files under a directory. -3. Run `cfgsync backup` to create stored copies for newly added or otherwise unbacked-up tracked files. +3. Run `cfgsync backup` to create missing stored copies and refresh changed stored backups. 4. Run `cfgsync status` to check which tracked files differ from stored backups. 5. Run `cfgsync diff ` to inspect a tracked file's text changes. 6. Optionally run `cfgsync watch` as a long-running foreground watch that keeps backing up tracked files as they change until you stop it. @@ -226,14 +226,18 @@ No files tracked. ### `cfgsync backup` -Creates missing stored copies for tracked files in the storage `files/` tree. +Creates or refreshes stored copies for tracked files in the storage `files/` tree. -Existing stored backup files are left unchanged. If all tracked files already have stored copies, it prints: +By default, `cfgsync backup` creates missing stored backups, refreshes stored backups whose content differs from the current tracked original, and skips stored backups that already match. If no stored files need to be created or refreshed, it prints: ```text No new files to back up. ``` +Use `cfgsync backup --missing-only` to create only missing stored backups while leaving existing stored backups unchanged, even when the tracked original has changed. + +Use `cfgsync backup --force` to copy every tracked original into storage and overwrite existing stored backups regardless of whether the content already matches. + If one tracked file cannot be backed up, cfgsync reports that file, continues with the remaining entries, and exits with a failure after the batch finishes. ### `cfgsync status` diff --git a/src/cli/BuildCli.cpp b/src/cli/BuildCli.cpp index 4dca8c5..0294904 100644 --- a/src/cli/BuildCli.cpp +++ b/src/cli/BuildCli.cpp @@ -39,6 +39,24 @@ void ValidateRestoreArguments(bool restoreAll, std::string_view restoreFile, std } } +commands::BackupMode BuildBackupMode(bool missingOnly, bool force) { + using enum commands::BackupMode; + + if (missingOnly && force) { + throw CLI::ValidationError("backup", "Specify at most one backup mode: '--missing-only' or '--force'."); + } + + if (missingOnly) { + return MissingOnly; + } + + if (force) { + return Force; + } + + return RefreshChanged; +} + std::optional BuildRestorePrefixRemap(std::string_view fromPrefix, std::string_view toPrefix) { if (fromPrefix.empty()) { @@ -109,11 +127,18 @@ void BuildCli(CLI::App& app, core::Registry& registry, storage::StorageManager& command.Execute(); }); - auto* backupCommand = app.add_subcommand("backup", "Create missing stored copies for tracked files."); - backupCommand->callback([®istry, &storageManager, loadActiveStorage]() { + auto* backupCommand = + app.add_subcommand("backup", "Back up tracked files; by default, create missing copies and refresh changes."); + auto backupMissingOnly = std::make_shared(false); + auto backupForce = std::make_shared(false); + backupCommand->add_flag("--missing-only", *backupMissingOnly, + "Only create missing stored copies; leave existing backups unchanged."); + backupCommand->add_flag("--force", *backupForce, "Overwrite every stored backup, even when content matches."); + backupCommand->callback([®istry, &storageManager, loadActiveStorage, backupMissingOnly, backupForce]() { + const auto backupMode = BuildBackupMode(*backupMissingOnly, *backupForce); loadActiveStorage(); const commands::BackupCommand command{registry, storageManager}; - command.Execute(); + command.Execute(backupMode); }); auto* statusCommand = app.add_subcommand("status", "Show tracked files that differ from stored backups."); diff --git a/src/commands/BackupCommand.cpp b/src/commands/BackupCommand.cpp index febfbba..0f94a46 100644 --- a/src/commands/BackupCommand.cpp +++ b/src/commands/BackupCommand.cpp @@ -96,12 +96,39 @@ bool StoredBackupIsUpToDate(const fs::path& originalPath, const fs::path& stored return originalChecksum == storedChecksum; } +bool StoredBackupExists(const fs::path& storedPath) { + std::error_code errorCode; + + const auto exists = fs::exists(storedPath, errorCode); + if (errorCode) { + throw FileError{ + std::format("Unable to inspect stored backup '{}': {}", storedPath.string(), errorCode.message())}; + } + + return exists; +} + +bool ShouldBackUpEntry(BackupMode mode, const fs::path& originalPath, const fs::path& storedPath) { + using enum BackupMode; + + switch (mode) { + case RefreshChanged: + return !StoredBackupIsUpToDate(originalPath, storedPath); + case MissingOnly: + return !StoredBackupExists(storedPath); + case Force: + return true; + } + + return true; +} + } // namespace BackupCommand::BackupCommand(core::Registry& registry, storage::StorageManager& storageManager) : Registry_(registry), StorageManager_(storageManager) {} -void BackupCommand::Execute() const { +void BackupCommand::Execute(BackupMode mode) const { const auto& trackedEntries = Registry_.GetTrackedEntries(); if (trackedEntries.empty()) { @@ -115,7 +142,7 @@ void BackupCommand::Execute() const { for (const auto& trackedEntry : trackedEntries) { try { if (const auto storedPath = StorageManager_.ResolveStoredPath(trackedEntry); - StoredBackupIsUpToDate(trackedEntry.OriginalPath, storedPath)) { + !ShouldBackUpEntry(mode, trackedEntry.OriginalPath, storedPath)) { continue; } diff --git a/src/commands/BackupCommand.hpp b/src/commands/BackupCommand.hpp index f4a0020..01ac0a0 100644 --- a/src/commands/BackupCommand.hpp +++ b/src/commands/BackupCommand.hpp @@ -5,11 +5,17 @@ namespace cfgsync::commands { +enum class BackupMode { + RefreshChanged, + MissingOnly, + Force, +}; + class BackupCommand { public: BackupCommand(core::Registry& registry, storage::StorageManager& storageManager); - void Execute() const; + void Execute(BackupMode mode = BackupMode::RefreshChanged) const; private: core::Registry& Registry_; // NOLINT(cppcoreguidelines-avoid-const-or-ref-data-members) diff --git a/tests/BackupCommandCliTests.cpp b/tests/BackupCommandCliTests.cpp index 2062d11..ff29905 100644 --- a/tests/BackupCommandCliTests.cpp +++ b/tests/BackupCommandCliTests.cpp @@ -14,6 +14,9 @@ namespace fs = std::filesystem; class BackupCommandCliTest : public cfgsync::tests::CliCommandTestFixture { protected: cfgsync::tests::CommandResult RunBackupCommand() const { return RunCommand("backup"); } + cfgsync::tests::CommandResult RunBackupCommand(const std::string& options) const { + return RunCommand("backup " + options); + } }; TEST_F(BackupCommandCliTest, BackupUsesActiveStorageRootPersistedByInit) { @@ -61,6 +64,84 @@ TEST_F(BackupCommandCliTest, BackupRefreshesExistingStoredCopyWhenContentDiffers EXPECT_EQ(cfgsync::tests::ReadTextFile(cfgsync::tests::StoredPathFor(StorageRoot(), sourcePath)), "new contents\n"); } +TEST_F(BackupCommandCliTest, BackupMissingOnlyCreatesMissingStoredCopy) { + const auto sourcePath = SourcePath(".gitconfig"); + cfgsync::tests::WriteTextFile(sourcePath, "[user]\n"); + ASSERT_TRUE(RunInitCommand()); + ASSERT_TRUE(RunAddCommand(sourcePath)); + + const auto result = RunBackupCommand("--missing-only"); + + EXPECT_EQ(result.ExitCode, 0); + EXPECT_NE(result.Output.find("Backed up file"), std::string::npos); + EXPECT_TRUE(result.Error.empty()); + EXPECT_EQ(cfgsync::tests::ReadTextFile(cfgsync::tests::StoredPathFor(StorageRoot(), sourcePath)), "[user]\n"); +} + +TEST_F(BackupCommandCliTest, BackupMissingOnlyPreservesChangedExistingStoredCopy) { + const auto sourcePath = SourcePath(".gitconfig"); + cfgsync::tests::WriteTextFile(sourcePath, "current contents\n"); + ASSERT_TRUE(RunInitCommand()); + ASSERT_TRUE(RunAddCommand(sourcePath)); + cfgsync::tests::WriteTextFile(cfgsync::tests::StoredPathFor(StorageRoot(), sourcePath), "stored contents\n"); + + const auto backupResult = RunBackupCommand("--missing-only"); + const auto statusResult = RunCommand("status"); + + EXPECT_EQ(backupResult.ExitCode, 0); + EXPECT_NE(backupResult.Output.find("No new files to back up."), std::string::npos); + EXPECT_TRUE(backupResult.Error.empty()); + EXPECT_EQ(cfgsync::tests::ReadTextFile(cfgsync::tests::StoredPathFor(StorageRoot(), sourcePath)), + "stored contents\n"); + EXPECT_EQ(statusResult.ExitCode, 0); + EXPECT_EQ(statusResult.Output, "modified " + cfgsync::utils::NormalizePath(sourcePath).string() + "\n"); +} + +TEST_F(BackupCommandCliTest, BackupForceOverwritesExistingStoredCopy) { + const auto sourcePath = SourcePath(".gitconfig"); + cfgsync::tests::WriteTextFile(sourcePath, "forced contents\n"); + ASSERT_TRUE(RunInitCommand()); + ASSERT_TRUE(RunAddCommand(sourcePath)); + cfgsync::tests::WriteTextFile(cfgsync::tests::StoredPathFor(StorageRoot(), sourcePath), "stored contents\n"); + + const auto backupResult = RunBackupCommand("--force"); + const auto statusResult = RunCommand("status"); + + EXPECT_EQ(backupResult.ExitCode, 0); + EXPECT_NE(backupResult.Output.find("Backed up file"), std::string::npos); + EXPECT_TRUE(backupResult.Error.empty()); + EXPECT_EQ(cfgsync::tests::ReadTextFile(cfgsync::tests::StoredPathFor(StorageRoot(), sourcePath)), + "forced contents\n"); + EXPECT_EQ(statusResult.ExitCode, 0); + EXPECT_EQ(statusResult.Output, "Clean.\n"); +} + +TEST_F(BackupCommandCliTest, BackupForceCopiesMatchingStoredCopy) { + const auto sourcePath = SourcePath(".gitconfig"); + cfgsync::tests::WriteTextFile(sourcePath, "same contents\n"); + ASSERT_TRUE(RunInitCommand()); + ASSERT_TRUE(RunAddCommand(sourcePath)); + cfgsync::tests::WriteTextFile(cfgsync::tests::StoredPathFor(StorageRoot(), sourcePath), "same contents\n"); + + const auto result = RunBackupCommand("--force"); + + EXPECT_EQ(result.ExitCode, 0); + EXPECT_NE(result.Output.find("Backed up file"), std::string::npos); + EXPECT_TRUE(result.Error.empty()); + EXPECT_EQ(cfgsync::tests::ReadTextFile(cfgsync::tests::StoredPathFor(StorageRoot(), sourcePath)), + "same contents\n"); +} + +TEST_F(BackupCommandCliTest, BackupRejectsCombinedExplicitModes) { + const auto result = RunBackupCommand("--missing-only --force"); + + EXPECT_NE(result.ExitCode, 0); + EXPECT_TRUE(result.Output.empty()); + EXPECT_NE(result.Error.find("Specify at most one backup mode"), std::string::npos); + EXPECT_NE(result.Error.find("--missing-only"), std::string::npos); + EXPECT_NE(result.Error.find("--force"), std::string::npos); +} + TEST_F(BackupCommandCliTest, BackupWithoutSourceChangesReportsNoNewFilesAndStatusClean) { const auto sourcePath = SourcePath(".gitconfig"); cfgsync::tests::WriteTextFile(sourcePath, "stored contents\n"); diff --git a/tests/BackupCommandTests.cpp b/tests/BackupCommandTests.cpp index 10db804..6b9a7d9 100644 --- a/tests/BackupCommandTests.cpp +++ b/tests/BackupCommandTests.cpp @@ -12,6 +12,7 @@ namespace { namespace fs = std::filesystem; +using cfgsync::commands::BackupMode; using cfgsync::tests::TrackFile; class BackupCommandTest : public cfgsync::tests::RegistryCommandTestFixture {}; @@ -109,6 +110,81 @@ TEST_F(BackupCommandTest, RefreshesExistingStoredCopyWhenContentDiffers) { EXPECT_EQ(cfgsync::tests::ReadTextFile(StorageRoot() / storedRelativePath), "new contents\n"); } +TEST_F(BackupCommandTest, MissingOnlyCreatesMissingStoredCopy) { + const auto sourcePath = SourcePath(); + cfgsync::tests::WriteTextFile(sourcePath, "[user]\n"); + const auto storedRelativePath = TrackFile(Registry(), sourcePath); + + cfgsync::storage::StorageManager storageManager{StorageRoot()}; + const cfgsync::commands::BackupCommand command{Registry(), storageManager}; + + command.Execute(BackupMode::MissingOnly); + + EXPECT_EQ(cfgsync::tests::ReadTextFile(StorageRoot() / storedRelativePath), "[user]\n"); +} + +TEST_F(BackupCommandTest, MissingOnlyPreservesChangedExistingStoredCopy) { + const auto sourcePath = SourcePath(); + cfgsync::tests::WriteTextFile(sourcePath, "new contents\n"); + const auto storedRelativePath = TrackFile(Registry(), sourcePath); + cfgsync::tests::WriteTextFile(StorageRoot() / storedRelativePath, "old contents\n"); + + cfgsync::storage::StorageManager storageManager{StorageRoot()}; + const cfgsync::commands::BackupCommand command{Registry(), storageManager}; + + testing::internal::CaptureStdout(); + command.Execute(BackupMode::MissingOnly); + const auto output = testing::internal::GetCapturedStdout(); + + EXPECT_EQ(cfgsync::tests::ReadTextFile(StorageRoot() / storedRelativePath), "old contents\n"); + EXPECT_NE(output.find("No new files to back up."), std::string::npos); +} + +TEST_F(BackupCommandTest, MissingOnlyPreservesExistingStoredCopyWhenOriginalIsMissing) { + const auto sourcePath = SourcePath(); + cfgsync::tests::WriteTextFile(sourcePath, "current contents\n"); + const auto storedRelativePath = TrackFile(Registry(), sourcePath); + cfgsync::tests::WriteTextFile(StorageRoot() / storedRelativePath, "stored contents\n"); + fs::remove(sourcePath); + + cfgsync::storage::StorageManager storageManager{StorageRoot()}; + const cfgsync::commands::BackupCommand command{Registry(), storageManager}; + + EXPECT_NO_THROW(command.Execute(BackupMode::MissingOnly)); + EXPECT_EQ(cfgsync::tests::ReadTextFile(StorageRoot() / storedRelativePath), "stored contents\n"); +} + +TEST_F(BackupCommandTest, ForceOverwritesExistingStoredCopy) { + const auto sourcePath = SourcePath(); + cfgsync::tests::WriteTextFile(sourcePath, "forced contents\n"); + const auto storedRelativePath = TrackFile(Registry(), sourcePath); + cfgsync::tests::WriteTextFile(StorageRoot() / storedRelativePath, "stored contents\n"); + + cfgsync::storage::StorageManager storageManager{StorageRoot()}; + const cfgsync::commands::BackupCommand command{Registry(), storageManager}; + + command.Execute(BackupMode::Force); + + EXPECT_EQ(cfgsync::tests::ReadTextFile(StorageRoot() / storedRelativePath), "forced contents\n"); +} + +TEST_F(BackupCommandTest, ForceBacksUpMatchingStoredCopy) { + const auto sourcePath = SourcePath(); + cfgsync::tests::WriteTextFile(sourcePath, "same contents\n"); + const auto storedRelativePath = TrackFile(Registry(), sourcePath); + cfgsync::tests::WriteTextFile(StorageRoot() / storedRelativePath, "same contents\n"); + + cfgsync::storage::StorageManager storageManager{StorageRoot()}; + const cfgsync::commands::BackupCommand command{Registry(), storageManager}; + + testing::internal::CaptureStdout(); + command.Execute(BackupMode::Force); + const auto output = testing::internal::GetCapturedStdout(); + + EXPECT_EQ(cfgsync::tests::ReadTextFile(StorageRoot() / storedRelativePath), "same contents\n"); + EXPECT_NE(output.find("Backed up file"), std::string::npos); +} + TEST_F(BackupCommandTest, SkipsCleanRefreshesChangedAndCreatesMissingStoredCopies) { const auto cleanBackupPath = SourcePath(".gitconfig"); const auto changedBackupPath = SourcePath("starship.toml");