Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions .github/workflows/cmake-multi-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Typical flow:

1. Run `cfgsync init --storage <dir>` once to create the storage directory and record it as the active storage root.
2. Run `cfgsync add <file>` for one ordinary config file, or `cfgsync add <directory>` 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 <file>` 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.
Expand Down Expand Up @@ -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`
Expand Down
31 changes: 28 additions & 3 deletions src/cli/BuildCli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<commands::RestorePrefixRemap> BuildRestorePrefixRemap(std::string_view fromPrefix,
std::string_view toPrefix) {
if (fromPrefix.empty()) {
Expand Down Expand Up @@ -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([&registry, &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<bool>(false);
auto backupForce = std::make_shared<bool>(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([&registry, &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.");
Expand Down
31 changes: 29 additions & 2 deletions src/commands/BackupCommand.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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;
}

Expand Down
8 changes: 7 additions & 1 deletion src/commands/BackupCommand.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
81 changes: 81 additions & 0 deletions tests/BackupCommandCliTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
Expand Down
76 changes: 76 additions & 0 deletions tests/BackupCommandTests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

namespace {
namespace fs = std::filesystem;
using cfgsync::commands::BackupMode;
using cfgsync::tests::TrackFile;

class BackupCommandTest : public cfgsync::tests::RegistryCommandTestFixture {};
Expand Down Expand Up @@ -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");
Expand Down
Loading